Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b28662d3b4 | |||
| 5e9b134168 | |||
| 1cb23a4411 | |||
| 479b592076 | |||
| 65cc8eaf8a | |||
| 4861179c6f | |||
| c9b37f2477 | |||
| d3799187a1 | |||
| b4eaa47ef6 | |||
| d6f334b861 |
99
CHANGELOG.md
99
CHANGELOG.md
@@ -1,7 +1,102 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2.0.0
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
|
||||||
|
- **`schema_version: 2` is now required.** v1 configs are rejected with a migration pointer. Add `schema_version: 2` at the top of `.josh-sync.yml`.
|
||||||
|
- **`josh.monorepo_url` is now required.** Set it to your monorepo's git clone URL — `git@host:org/repo.git` (SSH), `ssh://...`, or `https://host/org/repo.git`. Removes the silent inference of the gitea host from the first target's `subrepo_url`, which silently misbehaved when the monorepo and a target lived on different gitea instances. See [ADR-012](docs/adr/012-explicit-monorepo-url.md).
|
||||||
|
- **`MONOREPO_API` derivation changed.** The default is now `https://{monorepo_url's host}/api/v1/repos/{monorepo_path}`. The env-var override is preserved as an escape hatch for custom API endpoints.
|
||||||
|
|
||||||
|
#### Migration
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Before (v1.x)
|
||||||
|
josh:
|
||||||
|
proxy_url: "https://josh.example.com"
|
||||||
|
monorepo_path: "org/monorepo"
|
||||||
|
|
||||||
|
# After (v2.0.0)
|
||||||
|
schema_version: 2
|
||||||
|
josh:
|
||||||
|
proxy_url: "https://josh.example.com"
|
||||||
|
monorepo_path: "org/monorepo"
|
||||||
|
monorepo_url: "git@gitea.example.com:org/monorepo.git" # required
|
||||||
|
monorepo_auth: "ssh" # optional, default "https"
|
||||||
|
```
|
||||||
|
|
||||||
|
If you previously set the `MONOREPO_API` env var to work around the first-target-host bug, you can remove it — the new derivation gets it right from `monorepo_url`.
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Multi-host configs supported.** The monorepo and any subrepo may live on different gitea instances. Target ordering no longer affects how the monorepo's host is resolved.
|
||||||
|
- **SSH clone of the monorepo via `monorepo_auth: ssh`.** Uses the user's ssh-agent / `~/.ssh/config`; no token required for the clone itself. PR creation still uses `GITEA_TOKEN`.
|
||||||
|
- **Symmetric config shape.** Monorepo and subrepos now both declare a URL plus an auth method.
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- First-target ordering no longer load-bearing on `MONOREPO_API` derivation (was `gitea_host=.[0].gitea_host` in v1.x).
|
||||||
|
|
||||||
|
## 1.5.0
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
|
||||||
|
_None._
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **`--force` flag for reverse sync**: `josh-sync sync --reverse --force` bypasses
|
||||||
|
the PR step and force-pushes the subrepo's state directly onto the monorepo branch.
|
||||||
|
Also skips the `skip-dirty` guard. Only valid with `--reverse`. Returns status `forced`.
|
||||||
|
|
||||||
|
## 1.4.0
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
|
||||||
|
_None._
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **`schema_version` config field**: New optional integer field in `.josh-sync.yml`. Defaults to `1` when absent. josh-sync fails fast with a clear error if an unsupported version is present — safe path for future config migrations without silent misinterpretation.
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Linearize fallback applying wrong diff**: Fallback for failed `cherry-pick` was diffing from `HEAD` to the commit's full tree instead of from the commit's own parent, causing unrelated file deletions. Now diffs from the commit's own parent (`${sha}^` / `${sha}^1`).
|
||||||
|
- **Linearize fallback for already-applied merge commits**: When `cherry-pick -m 1` produces an empty result because a merge's changes were already applied by a prior cherry-pick, the commit is now skipped instead of attempting a failing `git apply`.
|
||||||
|
- **Linearize fallback output**: Suppressed git noise from cherry-pick, checkout, and commit during linearization; added delimiters and consistent indented log lines.
|
||||||
|
|
||||||
|
### Docs
|
||||||
|
|
||||||
|
- Workflow guide: removed redundant step-by-step subsections (table already covers them); tightened ADR-011 alternatives; removed stale version pins.
|
||||||
|
- README: added Versioning section with semver policy table and floating tag explanation.
|
||||||
|
|
||||||
|
## 1.3.0
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
|
||||||
|
_None._
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Linearize fallback for reverse sync**: When josh-proxy rejects a push due to unmappable merge commits, reverse sync automatically falls back to linearizing the history — cherry-picking regular commits individually and squashing only the problematic merge commits via `cherry-pick -m 1`. Preserves individual commit granularity while handling complex merge topologies that josh cannot map. PR body notes when linearization was used.
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Reverse sync human commit detection**: Fixed `--ancestry-path` returning empty when `mono-filtered` has advanced past the last forward sync. Any monorepo commit (even outside the subfolder) causes josh to create a new filtered commit, breaking the ancestry path to subrepo HEAD. Now uses `git merge-base` to find the last connected point.
|
||||||
|
- **State not updated on false skip**: When trees differ but no human commits are found, reverse sync now returns `skip-dirty` instead of `skip`. State is not updated, so the system retries on the next run instead of silently dropping commits.
|
||||||
|
|
||||||
|
### Docs
|
||||||
|
|
||||||
|
- Expanded troubleshooting guide for "Josh rejected push" with root cause analysis and prevention advice.
|
||||||
|
- Added ADR-011: Linearize fallback for reverse sync.
|
||||||
|
- Added recommended Git workflow section to the guide.
|
||||||
|
|
||||||
## 1.2.0
|
## 1.2.0
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
|
||||||
|
_None._
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
- **File exclusion**: `exclude` config field removes files/directories from the subrepo at the josh-proxy transport layer. Patterns are embedded inline in the josh-proxy URL using `:exclude[::pattern,...]` syntax — no extra files to generate or commit.
|
- **File exclusion**: `exclude` config field removes files/directories from the subrepo at the josh-proxy transport layer. Patterns are embedded inline in the josh-proxy URL using `:exclude[::pattern,...]` syntax — no extra files to generate or commit.
|
||||||
@@ -21,6 +116,10 @@
|
|||||||
|
|
||||||
## 1.1.0
|
## 1.1.0
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
|
||||||
|
_None._
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
- **`onboard` command**: Interactive, resumable workflow for importing existing subrepos into the monorepo. Walks through: prerequisites check, import (creates PRs), wait for merge, reset (pushes josh-filtered history). Checkpoint/resume at every step.
|
- **`onboard` command**: Interactive, resumable workflow for importing existing subrepos into the monorepo. Walks through: prerequisites check, import (creates PRs), wait for merge, reset (pushes josh-filtered history). Checkpoint/resume at every step.
|
||||||
|
|||||||
20
README.md
20
README.md
@@ -9,9 +9,13 @@ Bidirectional monorepo ↔ subrepo sync via [josh-proxy](https://josh-project.gi
|
|||||||
Create `.josh-sync.yml` in your monorepo root:
|
Create `.josh-sync.yml` in your monorepo root:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
schema_version: 2
|
||||||
|
|
||||||
josh:
|
josh:
|
||||||
proxy_url: "https://josh.example.com"
|
proxy_url: "https://josh.example.com"
|
||||||
monorepo_path: "org/monorepo"
|
monorepo_path: "org/monorepo"
|
||||||
|
monorepo_url: "git@gitea.example.com:org/monorepo.git"
|
||||||
|
monorepo_auth: "ssh" # optional, default "https"
|
||||||
|
|
||||||
targets:
|
targets:
|
||||||
- name: "billing"
|
- name: "billing"
|
||||||
@@ -63,10 +67,22 @@ Run `josh-sync preflight` to validate your setup.
|
|||||||
- **[Architecture Decision Records](docs/adr/)** — Design rationale and trade-offs
|
- **[Architecture Decision Records](docs/adr/)** — Design rationale and trade-offs
|
||||||
- **[Changelog](CHANGELOG.md)** — Version history
|
- **[Changelog](CHANGELOG.md)** — Version history
|
||||||
|
|
||||||
|
## Versioning
|
||||||
|
|
||||||
|
josh-sync follows [semver](https://semver.org/):
|
||||||
|
|
||||||
|
| Bump | Meaning | Action required |
|
||||||
|
|------|---------|-----------------|
|
||||||
|
| **Patch** (1.x.**y**) | Bug fixes only | Safe to upgrade |
|
||||||
|
| **Minor** (1.**y**.0) | New optional config fields, new commands, new action inputs | Safe to upgrade; new features are opt-in |
|
||||||
|
| **Major** (**y**.0.0) | Removed or renamed config fields, CLI flags, action inputs, env vars, or state format | Read the **Breaking Changes** section in the changelog before upgrading |
|
||||||
|
|
||||||
|
CI workflows pin to a floating major tag (e.g. `@v1`). A breaking change bumps the major version and moves the tag — `@v1` workflows keep working until you explicitly update to `@v2`.
|
||||||
|
|
||||||
## CLI
|
## CLI
|
||||||
|
|
||||||
```
|
```
|
||||||
josh-sync sync [--forward|--reverse] [--target NAME[,NAME]] [--branch BRANCH]
|
josh-sync sync [--forward|--reverse] [--force] [--target NAME[,NAME]] [--branch BRANCH]
|
||||||
josh-sync preflight
|
josh-sync preflight
|
||||||
josh-sync import <target>
|
josh-sync import <target>
|
||||||
josh-sync reset <target>
|
josh-sync reset <target>
|
||||||
@@ -80,7 +96,7 @@ josh-sync state reset <target> [branch]
|
|||||||
## How It Works
|
## How It Works
|
||||||
|
|
||||||
- **Forward sync** (mono → subrepo): pushes directly if clean, creates conflict PR if not. Uses `--force-with-lease` for safety.
|
- **Forward sync** (mono → subrepo): pushes directly if clean, creates conflict PR if not. Uses `--force-with-lease` for safety.
|
||||||
- **Reverse sync** (subrepo → mono): always creates a PR, never pushes directly.
|
- **Reverse sync** (subrepo → mono): creates a PR by default. Add `--force` to bypass the PR and push directly to the mono branch (destructive).
|
||||||
- **File exclusion**: `exclude` patterns are embedded inline in the josh-proxy URL. Excluded files exist only in the monorepo.
|
- **File exclusion**: `exclude` patterns are embedded inline in the josh-proxy URL. Excluded files exist only in the monorepo.
|
||||||
- **Filter reconciliation**: Changing the exclude list auto-creates a merge commit that connects old and new histories — no force-push needed.
|
- **Filter reconciliation**: Changing the exclude list auto-creates a merge commit that connects old and new histories — no force-push needed.
|
||||||
- **Loop prevention**: `Josh-Sync-Origin:` git trailer filters out bot commits.
|
- **Loop prevention**: `Josh-Sync-Origin:` git trailer filters out bot commits.
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ Global flags:
|
|||||||
Sync flags:
|
Sync flags:
|
||||||
--forward Forward only (mono → subrepo)
|
--forward Forward only (mono → subrepo)
|
||||||
--reverse Reverse only (subrepo → mono)
|
--reverse Reverse only (subrepo → mono)
|
||||||
|
--force Force-push subrepo state to mono branch (--reverse only, skips PR)
|
||||||
--target NAME Filter to target(s) — comma-separated for multiple (env: JOSH_SYNC_TARGET)
|
--target NAME Filter to target(s) — comma-separated for multiple (env: JOSH_SYNC_TARGET)
|
||||||
--branch BRANCH Filter to one branch
|
--branch BRANCH Filter to one branch
|
||||||
|
|
||||||
@@ -108,6 +109,7 @@ cmd_sync() {
|
|||||||
local filter_target="${JOSH_SYNC_TARGET:-}"
|
local filter_target="${JOSH_SYNC_TARGET:-}"
|
||||||
local filter_branch=""
|
local filter_branch=""
|
||||||
local config_file=".josh-sync.yml"
|
local config_file=".josh-sync.yml"
|
||||||
|
local force_flag=false
|
||||||
|
|
||||||
while [ $# -gt 0 ]; do
|
while [ $# -gt 0 ]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
@@ -117,10 +119,18 @@ cmd_sync() {
|
|||||||
--branch) filter_branch="$2"; shift 2 ;;
|
--branch) filter_branch="$2"; shift 2 ;;
|
||||||
--config) config_file="$2"; shift 2 ;;
|
--config) config_file="$2"; shift 2 ;;
|
||||||
--debug) export JOSH_SYNC_DEBUG=1; shift ;;
|
--debug) export JOSH_SYNC_DEBUG=1; shift ;;
|
||||||
|
--force) force_flag=true; shift ;;
|
||||||
*) die "Unknown flag: $1" ;;
|
*) die "Unknown flag: $1" ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
|
if [ "$force_flag" = true ] && [ "$direction" != "reverse" ]; then
|
||||||
|
die "--force requires --reverse"
|
||||||
|
fi
|
||||||
|
if [ "$force_flag" = true ]; then
|
||||||
|
export SYNC_REVERSE_FORCE=1
|
||||||
|
fi
|
||||||
|
|
||||||
parse_config "$config_file"
|
parse_config "$config_file"
|
||||||
|
|
||||||
# Forward sync
|
# Forward sync
|
||||||
@@ -259,6 +269,13 @@ _sync_direction() {
|
|||||||
echo "::error::Target ${target_name}, branch ${branch}: josh rejected push — check proxy logs"
|
echo "::error::Target ${target_name}, branch ${branch}: josh rejected push — check proxy logs"
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
|
if [ "$result" = "skip-dirty" ]; then
|
||||||
|
log "WARN" "Trees differ but no human commits — skipping state update (will retry)"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if [ "$result" = "forced" ]; then
|
||||||
|
log "WARN" "Target ${target_name}, branch ${branch}: force-pushed subrepo directly to mono — DESTRUCTIVE"
|
||||||
|
fi
|
||||||
|
|
||||||
# Update state (only on success)
|
# Update state (only on success)
|
||||||
local new_state
|
local new_state
|
||||||
|
|||||||
58
docs/adr/011-linearize-fallback-reverse.md
Normal file
58
docs/adr/011-linearize-fallback-reverse.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# ADR-011: Linearize Fallback for Reverse Sync
|
||||||
|
|
||||||
|
**Status:** Accepted
|
||||||
|
**Date:** 2026-03
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Josh-proxy rejects reverse sync pushes when the subrepo history contains merge commits whose parents it cannot map through the filter. This happens when:
|
||||||
|
|
||||||
|
1. A long-lived branch (e.g., `stage`) is merged into `main` via a merge commit
|
||||||
|
2. That branch contains auto-sync merge commits or criss-cross merges with `main`
|
||||||
|
3. Josh encounters the merge commit, tries to map both parents through the filter, and fails with a 500 error
|
||||||
|
|
||||||
|
This is a legitimate subrepo workflow — teams merge staging branches into main regularly. The sync tool should handle it without requiring teams to change their Git workflow.
|
||||||
|
|
||||||
|
### Alternatives considered
|
||||||
|
|
||||||
|
1. **Squash all commits into one**: Simple but destroys all commit granularity.
|
||||||
|
2. **Rewrite subrepo history**: Breaks josh's SHA mapping, forces all developers to re-clone.
|
||||||
|
3. **Linearize: cherry-pick regular commits, squash only merges**: Preserves individual commit granularity; only merge commits lose their multi-parent structure.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
When the direct push through josh-proxy fails, fall back to option 4: linearize the history by cherry-picking onto the josh-filtered base.
|
||||||
|
|
||||||
|
### How it works
|
||||||
|
|
||||||
|
1. Direct `git push` through josh-proxy is attempted first (existing behavior)
|
||||||
|
2. If it fails, create a temporary branch from `mono-filtered/<branch>`
|
||||||
|
3. Walk human commits (oldest-first, bot commits excluded) from the ancestry path:
|
||||||
|
- **Regular commits** (≤1 parent): `git cherry-pick <sha>` — preserves author, date, and message
|
||||||
|
- **Merge commits** (>1 parent): `git cherry-pick -m 1 <sha>` — applies the merge's diff relative to its first parent as a single commit
|
||||||
|
4. If cherry-pick conflicts (rare — usually due to ordering issues), fall back to `git diff | git apply` with the original author metadata
|
||||||
|
5. Push the linearized branch through josh-proxy
|
||||||
|
6. PR body includes a note explaining that merge commits were squashed
|
||||||
|
|
||||||
|
### What is preserved
|
||||||
|
|
||||||
|
- Individual non-merge commits (author, date, message, diff)
|
||||||
|
- The net effect of each merge commit (as a squashed single commit)
|
||||||
|
- The original commit list in the PR body for reference
|
||||||
|
|
||||||
|
### What is lost
|
||||||
|
|
||||||
|
- The multi-parent structure of merge commits (they become single-parent)
|
||||||
|
- The distinction between "changes introduced by the merge" vs "changes from each parent" — the merge is represented as its diff from first parent
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Positive:**
|
||||||
|
- Reverse sync no longer gets stuck on merge commits — handles the common case automatically
|
||||||
|
- Non-merge commits retain full granularity (no unnecessary squashing)
|
||||||
|
- Subrepo history is untouched — no rewriting, no broken sync relationship
|
||||||
|
- The PR body documents when linearization was used, so reviewers know
|
||||||
|
|
||||||
|
**Negative:**
|
||||||
|
- Merge commit semantics are lost on the monorepo side (they appear as regular commits)
|
||||||
|
- Adds complexity to the reverse sync path (two code paths: direct push and linearize fallback)
|
||||||
53
docs/adr/012-explicit-monorepo-url.md
Normal file
53
docs/adr/012-explicit-monorepo-url.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# ADR-012: Explicit `monorepo_url` (drop first-target-host inference)
|
||||||
|
|
||||||
|
**Status:** Accepted
|
||||||
|
**Date:** 2026-04
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
josh-sync v1.x had no first-class field for the monorepo's git location. To call the gitea PR API and to clone the monorepo for `initial_import` / force-push, the script derived the gitea host from the **first target's `subrepo_url`**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# lib/config.sh (v1.x)
|
||||||
|
gitea_host=$(echo "$JOSH_SYNC_TARGETS" | jq -r '.[0].gitea_host')
|
||||||
|
export MONOREPO_API="${MONOREPO_API:-https://${gitea_host}/api/v1/repos/${MONOREPO_PATH}}"
|
||||||
|
```
|
||||||
|
|
||||||
|
This worked silently for every existing config — studio, kinanah/stores, itkan/sdlc — because in each, target 0's host happens to match the monorepo's host. The portal monorepo broke the assumption: monorepo on `code.atome.ac:atome-portal/portal`, single target `bff` on `code.itkan.io:sdlc/odoo_bff_core`. The derivation produced `https://code.itkan.io/atome-portal/portal.git` (host borrowed from the wrong target), giving a 404 on `josh-sync import`.
|
||||||
|
|
||||||
|
Two issues stack:
|
||||||
|
|
||||||
|
1. **Implicit coupling**: target ordering becomes load-bearing in a non-obvious way. The `MONOREPO_API` env var was the only escape hatch, hidden in shell init.
|
||||||
|
2. **Asymmetry**: `subrepo_url` is declared explicitly per target with full SSH/HTTPS support; the monorepo had only a `monorepo_path` and was always cloned via HTTPS+token. Local users with SSH keys for the monorepo still had to provide a token.
|
||||||
|
|
||||||
|
### Alternatives considered
|
||||||
|
|
||||||
|
1. **Keep deriving, document the env override loudly.** Cheapest. Leaves the booby trap in place — still fails closed for any future cross-host config.
|
||||||
|
2. **Add `josh.gitea_host` only.** Fixes the host issue without enabling SSH cloning of the monorepo. Half a fix, and asymmetric with how subrepos are declared.
|
||||||
|
3. **Add `josh.monorepo_url` (full URL), mirror `subrepo_url` exactly.** Removes the inference; enables SSH clone; symmetric with subrepos. Breaking — every existing config needs to add the field.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Add a required `josh.monorepo_url` field plus an optional `josh.monorepo_auth` (default `"https"`). Mirror the `subrepo_url` parsing and `subrepo_auth_url()` helper exactly:
|
||||||
|
|
||||||
|
- `parse_config` extracts `MONOREPO_GITEA_HOST` and `MONOREPO_REPO_PATH` from `monorepo_url` using the same jq regex as `subrepo_url`.
|
||||||
|
- `MONOREPO_API` derives from `MONOREPO_GITEA_HOST` instead of `.[0].gitea_host`. The env-var override stays as a real escape hatch (custom API endpoints).
|
||||||
|
- `mono_auth_url()` in `lib/auth.sh` mirrors `subrepo_auth_url()`: SSH returns the bare URL, HTTPS injects `${BOT_USER}:${GITEA_TOKEN}@` (auto-converting `git@host:org/repo.git` to `https://host/org/repo.git` first).
|
||||||
|
- `initial_import()` and the force-push path in `lib/sync.sh` call `mono_auth_url()` instead of constructing the URL inline from `MONOREPO_API`.
|
||||||
|
|
||||||
|
The change is shipped as **v2.0.0** with `schema_version: 2` required. v1 configs are rejected with a migration pointer; configs missing `monorepo_url` are rejected with the field name and an example.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Positive:**
|
||||||
|
- No more first-target-ordering coupling. Add or reorder targets freely.
|
||||||
|
- Multi-host setups (monorepo on host A, subrepo on host B) are first-class.
|
||||||
|
- SSH clone of the monorepo works without a token (the user's ssh-agent / `~/.ssh/config` resolves the key). PR creation still needs `GITEA_TOKEN`.
|
||||||
|
- Symmetric config: monorepo and subrepos both declare a URL + auth method.
|
||||||
|
|
||||||
|
**Negative:**
|
||||||
|
- Breaking change. Every existing config must add `schema_version: 2` and `josh.monorepo_url`. Mitigated by the validation `die`s pointing at the field name and changelog.
|
||||||
|
- Two related-but-distinct fields (`monorepo_path` for the josh-proxy filter; `monorepo_url` for the gitea clone) — kept separate because josh-proxy's path may not always equal the gitea path in custom setups.
|
||||||
|
|
||||||
|
**Risks:**
|
||||||
|
- Users may set `monorepo_url` to a host that doesn't match their `proxy_url`'s upstream. Detection is out of scope for this ADR; a follow-up `josh-sync preflight` check could compare hosts and warn.
|
||||||
@@ -16,3 +16,5 @@ This directory contains Architecture Decision Records (ADRs) for josh-sync. Each
|
|||||||
| [008](008-first-parent-ordering.md) | First-parent ordering in reconciliation merges | Accepted |
|
| [008](008-first-parent-ordering.md) | First-parent ordering in reconciliation merges | Accepted |
|
||||||
| [009](009-tree-comparison-guard.md) | Tree comparison as sync skip guard | Accepted |
|
| [009](009-tree-comparison-guard.md) | Tree comparison as sync skip guard | Accepted |
|
||||||
| [010](010-onboard-checkpoint-resume.md) | Onboard workflow with checkpoint/resume | Accepted |
|
| [010](010-onboard-checkpoint-resume.md) | Onboard workflow with checkpoint/resume | Accepted |
|
||||||
|
| [011](011-linearize-fallback-reverse.md) | Linearize fallback for reverse sync | Accepted |
|
||||||
|
| [012](012-explicit-monorepo-url.md) | Explicit `monorepo_url` (drop first-target-host inference) | Accepted |
|
||||||
|
|||||||
@@ -5,17 +5,26 @@ Full reference for `.josh-sync.yml` fields and environment variables.
|
|||||||
## `.josh-sync.yml` Structure
|
## `.josh-sync.yml` Structure
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
josh: # josh-proxy settings (required)
|
schema_version: 2 # required since v2.0.0
|
||||||
targets: # sync targets (required, at least 1)
|
josh: # josh-proxy + monorepo settings (required)
|
||||||
bot: # bot identity for sync commits (required)
|
targets: # sync targets (required, at least 1)
|
||||||
|
bot: # bot identity for sync commits (required)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## `schema_version`
|
||||||
|
|
||||||
|
| Type | Required | Description |
|
||||||
|
|------|----------|-------------|
|
||||||
|
| integer | Yes | Must be `2` for josh-sync v2.x configs. v1 configs are rejected; see [Changelog v2.0.0](../CHANGELOG.md#200) for migration. |
|
||||||
|
|
||||||
## `josh` Section
|
## `josh` Section
|
||||||
|
|
||||||
| Field | Type | Required | Description |
|
| Field | Type | Required | Description |
|
||||||
|-------|------|----------|-------------|
|
|-------|------|----------|-------------|
|
||||||
| `proxy_url` | string | Yes | Josh-proxy URL, no trailing slash. Must start with `http://` or `https://`. |
|
| `proxy_url` | string | Yes | Josh-proxy URL, no trailing slash. Must start with `http://` or `https://`. |
|
||||||
| `monorepo_path` | string | Yes | Repository path as josh-proxy sees it (e.g., `org/monorepo`). |
|
| `monorepo_path` | string | Yes | Repository path as josh-proxy sees it (e.g., `org/monorepo`). |
|
||||||
|
| `monorepo_url` | string | Yes | Monorepo git clone URL — `git@host:org/repo.git` (SSH), `ssh://...`, or `https://host/org/repo.git`. The gitea host is derived from this URL and used for the PR API and direct clones, decoupled from any target's host. |
|
||||||
|
| `monorepo_auth` | string | No (default `"https"`) | Clone auth method: `"https"` (injects `${SYNC_BOT_USER}:${SYNC_BOT_TOKEN}` into the URL) or `"ssh"` (uses the user's ssh-agent / `~/.ssh/config`). PR creation always uses `GITEA_TOKEN` regardless. |
|
||||||
|
|
||||||
## `targets[]` Section
|
## `targets[]` Section
|
||||||
|
|
||||||
@@ -73,7 +82,7 @@ targets:
|
|||||||
| `JOSH_SYNC_TARGET` | Restrict sync to a single target | All targets |
|
| `JOSH_SYNC_TARGET` | Restrict sync to a single target | All targets |
|
||||||
| `JOSH_SYNC_STATE_BRANCH` | Name of the orphan branch for state storage | `josh-sync-state` |
|
| `JOSH_SYNC_STATE_BRANCH` | Name of the orphan branch for state storage | `josh-sync-state` |
|
||||||
| `JOSH_SYNC_DEBUG` | Enable verbose logging (`1` to enable) | `0` |
|
| `JOSH_SYNC_DEBUG` | Enable verbose logging (`1` to enable) | `0` |
|
||||||
| `MONOREPO_API` | Override monorepo API URL | Auto-derived from first target's host |
|
| `MONOREPO_API` | Override the monorepo gitea PR API URL. Escape hatch for custom API endpoints; rarely needed. | `https://{monorepo_url's host}/api/v1/repos/{monorepo_path}` |
|
||||||
|
|
||||||
## JSON Schema
|
## JSON Schema
|
||||||
|
|
||||||
|
|||||||
@@ -78,9 +78,13 @@ git ls-remote https://josh.example.com/org/monorepo.git HEAD
|
|||||||
Create `.josh-sync.yml` at the monorepo root. Each target maps a monorepo subfolder to an external subrepo:
|
Create `.josh-sync.yml` at the monorepo root. Each target maps a monorepo subfolder to an external subrepo:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
schema_version: 2
|
||||||
|
|
||||||
josh:
|
josh:
|
||||||
proxy_url: "https://josh.example.com" # josh-proxy URL (no trailing slash)
|
proxy_url: "https://josh.example.com" # josh-proxy URL (no trailing slash)
|
||||||
monorepo_path: "org/monorepo" # repo path as josh sees it
|
monorepo_path: "org/monorepo" # repo path as josh-proxy sees it
|
||||||
|
monorepo_url: "git@gitea.example.com:org/monorepo.git" # monorepo git clone URL
|
||||||
|
monorepo_auth: "ssh" # optional, default "https"
|
||||||
|
|
||||||
targets:
|
targets:
|
||||||
- name: "billing" # unique identifier
|
- name: "billing" # unique identifier
|
||||||
@@ -518,6 +522,38 @@ Bot commits include a git trailer like `Josh-Sync-Origin: forward/main/2024-02-1
|
|||||||
|
|
||||||
Sync state is stored as JSON files on an orphan branch (`josh-sync-state`), one file per target/branch. This tracks the last-synced commit SHAs and timestamps to avoid re-syncing the same changes.
|
Sync state is stored as JSON files on an orphan branch (`josh-sync-state`), one file per target/branch. This tracks the last-synced commit SHAs and timestamps to avoid re-syncing the same changes.
|
||||||
|
|
||||||
|
## Recommended Git Workflow
|
||||||
|
|
||||||
|
Josh-proxy maps commits through a filter per-branch. It handles linear history and simple merges (short-lived feature branches) without issues. However, it **cannot map merge commits whose parents were created on the subrepo side** — because those commits were never pushed through josh and have no monorepo-side mapping.
|
||||||
|
|
||||||
|
This means cross-branch merges (e.g., `stage` → `main`) must happen on the monorepo side, where josh can filter the result cleanly.
|
||||||
|
|
||||||
|
### The rule
|
||||||
|
|
||||||
|
**The monorepo owns branch topology. The subrepo owns feature development.**
|
||||||
|
|
||||||
|
### What to do where
|
||||||
|
|
||||||
|
| Action | Where to do it | Why |
|
||||||
|
|--------|---------------|-----|
|
||||||
|
| Feature branch → `main` | **Subrepo** (PR, any merge strategy) | Short-lived branch with clean lineage — josh handles it |
|
||||||
|
| `stage` → `main` (promotion) | **Monorepo** | Cross-branch merge — forward sync propagates the result to both subrepo branches |
|
||||||
|
| `main` → `stage` (catch-up) | **Monorepo** | Same reason — avoids criss-cross merge history on subrepo |
|
||||||
|
| Hotfix to `main` | **Either side** | Single commit or small PR — works everywhere |
|
||||||
|
| Config/CI changes (monorepo-only) | **Monorepo** | Not synced to subrepo (use `exclude` for monorepo-only files) |
|
||||||
|
|
||||||
|
### What to avoid
|
||||||
|
|
||||||
|
- **Don't merge `stage` into `main` on the subrepo with a merge commit.** The merge parents include commits created on the subrepo side (forward sync merges, criss-cross merges) that josh has no mapping for. Josh rejects the push with a 500 error.
|
||||||
|
- **Don't merge `main` into `stage` on the subrepo.** Creates criss-cross merge history that causes the same josh mapping failure when `stage` is later merged back.
|
||||||
|
- **Don't rebase synced branches on the subrepo.** This rewrites commit SHAs that josh has already mapped, breaking the sync relationship.
|
||||||
|
|
||||||
|
### If you must merge cross-branch on the subrepo
|
||||||
|
|
||||||
|
Use a **squash merge**. A squash merge produces a single commit with one parent — josh can always map it. You lose the individual commit history on the target branch, but the sync goes through cleanly.
|
||||||
|
|
||||||
|
As a safety net, josh-sync automatically falls back to linearizing the history when josh rejects a push — cherry-picking regular commits individually and squashing only the problematic merge commits. See the [troubleshooting section](#josh-rejected-push-reverse-sync) and [ADR-011](adr/011-linearize-fallback-reverse.md).
|
||||||
|
|
||||||
## Excluding Files from Sync
|
## Excluding Files from Sync
|
||||||
|
|
||||||
Some files in the monorepo subfolder may not belong in the subrepo (e.g., monorepo-specific CI configs, internal tooling). The `exclude` config field removes these at the josh-proxy layer — excluded files never appear in the subrepo.
|
Some files in the monorepo subfolder may not belong in the subrepo (e.g., monorepo-specific CI configs, internal tooling). The `exclude` config field removes these at the josh-proxy layer — excluded files never appear in the subrepo.
|
||||||
@@ -616,7 +652,35 @@ Normal: the subrepo changed while sync was running. The next sync run will pick
|
|||||||
|
|
||||||
### "Josh rejected push" (reverse sync)
|
### "Josh rejected push" (reverse sync)
|
||||||
|
|
||||||
Josh-proxy couldn't map the push back to the monorepo. Check josh-proxy logs, verify the josh filter is correct. May indicate a history divergence — consider running `josh-sync reset <target>`.
|
Josh-proxy couldn't map the push back to the monorepo. This has two common causes:
|
||||||
|
|
||||||
|
#### Merge commits with unmappable parents
|
||||||
|
|
||||||
|
**Symptom:** Josh returns `500 Internal Server Error` with a message like:
|
||||||
|
|
||||||
|
```
|
||||||
|
rejecting merge with 2 parents:
|
||||||
|
"Merge pull request 'stage' (#30) from stage into main" (c4fa3c9...)
|
||||||
|
1) "Merge branch 'auto-sync/import-...'" (4bf8704...)
|
||||||
|
2) "Merge branch 'main' into stage" (d021654...)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cause:** The subrepo has a merge commit whose parents josh-proxy cannot trace through its filter. This typically happens when:
|
||||||
|
- A long-lived branch (e.g., `stage`) is merged into `main` via a merge commit (not squash)
|
||||||
|
- That branch contains auto-sync merge commits or other history that doesn't exist in josh's filtered view
|
||||||
|
- Someone merges `main` into a feature/staging branch and then merges it back — the criss-cross parents confuse josh's mapping
|
||||||
|
|
||||||
|
**Automatic handling:** josh-sync automatically falls back to linearizing the history when the direct push fails. Regular commits are cherry-picked individually (preserving authorship and messages), while merge commits are squashed into single commits via `cherry-pick -m 1`. The PR notes when this fallback was used. See [ADR-011](adr/011-linearize-fallback-reverse.md).
|
||||||
|
|
||||||
|
**Prevention:** In josh-synced subrepos, prefer **squash merges** when merging long-lived branches (stage, develop) into the synced branch. Squash merges produce a single commit with no merge parents, which josh can always map. Regular feature branch merges (short-lived, no auto-sync history) are usually fine.
|
||||||
|
|
||||||
|
**Manual resolution (if automatic fallback also fails):** This indicates a more fundamental history issue. Options:
|
||||||
|
1. Cherry-pick the desired changes manually onto a clean branch and push through josh
|
||||||
|
2. Run `josh-sync reset <target>` to re-establish history (destructive — all subrepo clones must re-fetch)
|
||||||
|
|
||||||
|
#### Filter or path mismatch
|
||||||
|
|
||||||
|
Josh-proxy couldn't map the push due to an incorrect filter. Check josh-proxy logs, verify the `josh_filter` or `subfolder` in `.josh-sync.yml` is correct, and ensure the subfolder exists in the monorepo.
|
||||||
|
|
||||||
### Import PR shows "No changes"
|
### Import PR shows "No changes"
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,22 @@
|
|||||||
# .josh-sync.yml — Multi-target configuration example
|
# .josh-sync.yml — Multi-target configuration example
|
||||||
# Place this at the root of your monorepo.
|
# Place this at the root of your monorepo.
|
||||||
|
|
||||||
|
# Required since v2.0.0. Bump alongside the josh-sync major version when
|
||||||
|
# breaking config changes ship.
|
||||||
|
schema_version: 2
|
||||||
|
|
||||||
josh:
|
josh:
|
||||||
# Your josh-proxy instance URL (no trailing slash)
|
# Your josh-proxy instance URL (no trailing slash)
|
||||||
proxy_url: "https://josh.example.com"
|
proxy_url: "https://josh.example.com"
|
||||||
# Repo path as josh sees it (org/repo on your Gitea/GitHub)
|
# Repo path as josh-proxy sees it (org/repo)
|
||||||
monorepo_path: "org/monorepo"
|
monorepo_path: "org/monorepo"
|
||||||
|
# Monorepo git clone URL — git@host:org/repo.git (SSH) or https://host/org/repo.git.
|
||||||
|
# The gitea host is derived from this URL (used for the PR API and direct clones).
|
||||||
|
# The monorepo and any subrepo may live on different gitea hosts.
|
||||||
|
monorepo_url: "git@gitea.example.com:org/monorepo.git"
|
||||||
|
# Optional. Default "https". Use "ssh" to clone the monorepo via SSH (no token
|
||||||
|
# needed for the clone — PR creation still requires GITEA_TOKEN).
|
||||||
|
monorepo_auth: "ssh"
|
||||||
|
|
||||||
targets:
|
targets:
|
||||||
- name: "billing"
|
- name: "billing"
|
||||||
|
|||||||
24
lib/auth.sh
24
lib/auth.sh
@@ -2,7 +2,8 @@
|
|||||||
# lib/auth.sh — Authenticated URLs, remote queries, and PR creation
|
# lib/auth.sh — Authenticated URLs, remote queries, and PR creation
|
||||||
#
|
#
|
||||||
# Requires: lib/core.sh and lib/config.sh sourced first
|
# Requires: lib/core.sh and lib/config.sh sourced first
|
||||||
# Expects: JOSH_PROXY_URL, MONOREPO_PATH, BOT_USER, GITEA_TOKEN, JOSH_FILTER,
|
# Expects: JOSH_PROXY_URL, MONOREPO_PATH, MONOREPO_URL, MONOREPO_AUTH,
|
||||||
|
# BOT_USER, GITEA_TOKEN, JOSH_FILTER,
|
||||||
# SUBREPO_URL, SUBREPO_AUTH, SUBREPO_TOKEN (set by parse_config + load_target)
|
# SUBREPO_URL, SUBREPO_AUTH, SUBREPO_TOKEN (set by parse_config + load_target)
|
||||||
|
|
||||||
# ─── Josh-Proxy Auth URL ───────────────────────────────────────────
|
# ─── Josh-Proxy Auth URL ───────────────────────────────────────────
|
||||||
@@ -28,6 +29,27 @@ subrepo_auth_url() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ─── Monorepo Auth URL ─────────────────────────────────────────────
|
||||||
|
# HTTPS: injects user:token into MONOREPO_URL (auto-converts git@ form to https)
|
||||||
|
# SSH: returns bare MONOREPO_URL (auth via user's ssh-agent / ~/.ssh/config,
|
||||||
|
# or via GIT_SSH_COMMAND if the caller wired one up for the monorepo host)
|
||||||
|
|
||||||
|
mono_auth_url() {
|
||||||
|
if [ "${MONOREPO_AUTH:-https}" = "ssh" ]; then
|
||||||
|
echo "$MONOREPO_URL"
|
||||||
|
else
|
||||||
|
local https_url="$MONOREPO_URL"
|
||||||
|
# Convert git@host:org/repo.git → https://host/org/repo.git so token injection works
|
||||||
|
if [[ "$https_url" == git@* ]]; then
|
||||||
|
https_url=$(echo "$https_url" | sed -E 's|^git@([^:]+):(.+)$|https://\1/\2|')
|
||||||
|
elif [[ "$https_url" == ssh://* ]]; then
|
||||||
|
https_url=$(echo "$https_url" | sed -E 's|^ssh://[^@]*@([^/]+)/(.+)$|https://\1/\2|')
|
||||||
|
fi
|
||||||
|
# shellcheck disable=SC2001
|
||||||
|
echo "$https_url" | sed "s|https://|https://${BOT_USER}:${GITEA_TOKEN}@|"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
# ─── Remote Queries ─────────────────────────────────────────────────
|
# ─── Remote Queries ─────────────────────────────────────────────────
|
||||||
|
|
||||||
subrepo_ls_remote() {
|
subrepo_ls_remote() {
|
||||||
|
|||||||
@@ -16,11 +16,21 @@ parse_config() {
|
|||||||
local config_json
|
local config_json
|
||||||
config_json=$(yq -o json "$config_file") || die "Failed to parse ${config_file}"
|
config_json=$(yq -o json "$config_file") || die "Failed to parse ${config_file}"
|
||||||
|
|
||||||
|
# Schema version check — required, must be 2 (v1 configs are no longer supported)
|
||||||
|
local schema_version
|
||||||
|
schema_version=$(echo "$config_json" | jq -r '.schema_version // empty')
|
||||||
|
[ -n "$schema_version" ] || die "schema_version missing in ${config_file} (required since v2.0.0 — set 'schema_version: 2' at the top of the file; see CHANGELOG.md for migration instructions)"
|
||||||
|
[ "$schema_version" = "2" ] || die "Unsupported schema_version ${schema_version} in ${config_file} (this version of josh-sync requires schema_version 2 — see CHANGELOG.md for migration instructions)"
|
||||||
|
|
||||||
# Export global values
|
# Export global values
|
||||||
export JOSH_PROXY_URL
|
export JOSH_PROXY_URL
|
||||||
JOSH_PROXY_URL=$(echo "$config_json" | jq -r '.josh.proxy_url')
|
JOSH_PROXY_URL=$(echo "$config_json" | jq -r '.josh.proxy_url')
|
||||||
export MONOREPO_PATH
|
export MONOREPO_PATH
|
||||||
MONOREPO_PATH=$(echo "$config_json" | jq -r '.josh.monorepo_path')
|
MONOREPO_PATH=$(echo "$config_json" | jq -r '.josh.monorepo_path')
|
||||||
|
export MONOREPO_URL
|
||||||
|
MONOREPO_URL=$(echo "$config_json" | jq -r '.josh.monorepo_url')
|
||||||
|
export MONOREPO_AUTH
|
||||||
|
MONOREPO_AUTH=$(echo "$config_json" | jq -r '.josh.monorepo_auth // "https"')
|
||||||
export BOT_NAME
|
export BOT_NAME
|
||||||
BOT_NAME=$(echo "$config_json" | jq -r '.bot.name')
|
BOT_NAME=$(echo "$config_json" | jq -r '.bot.name')
|
||||||
export BOT_EMAIL
|
export BOT_EMAIL
|
||||||
@@ -30,8 +40,27 @@ parse_config() {
|
|||||||
|
|
||||||
[ -n "$JOSH_PROXY_URL" ] && [ "$JOSH_PROXY_URL" != "null" ] || die "josh.proxy_url missing in config"
|
[ -n "$JOSH_PROXY_URL" ] && [ "$JOSH_PROXY_URL" != "null" ] || die "josh.proxy_url missing in config"
|
||||||
[ -n "$MONOREPO_PATH" ] && [ "$MONOREPO_PATH" != "null" ] || die "josh.monorepo_path missing in config"
|
[ -n "$MONOREPO_PATH" ] && [ "$MONOREPO_PATH" != "null" ] || die "josh.monorepo_path missing in config"
|
||||||
|
[ -n "$MONOREPO_URL" ] && [ "$MONOREPO_URL" != "null" ] || die "josh.monorepo_url is required (added in schema_version 2). Set it to your monorepo's git clone URL, e.g. 'git@code.example.io:org/repo.git'."
|
||||||
|
[ "$MONOREPO_AUTH" = "ssh" ] || [ "$MONOREPO_AUTH" = "https" ] || die "josh.monorepo_auth must be 'ssh' or 'https' (got '${MONOREPO_AUTH}')"
|
||||||
[ -n "$BOT_TRAILER" ] && [ "$BOT_TRAILER" != "null" ] || die "bot.trailer missing in config"
|
[ -n "$BOT_TRAILER" ] && [ "$BOT_TRAILER" != "null" ] || die "bot.trailer missing in config"
|
||||||
|
|
||||||
|
# Derive monorepo gitea_host and repo_path from monorepo_url (mirrors target derivation below)
|
||||||
|
local monorepo_meta
|
||||||
|
monorepo_meta=$(jq -nr --arg url "$MONOREPO_URL" '
|
||||||
|
$url as $u |
|
||||||
|
if ($u | test("^ssh://")) then
|
||||||
|
($u | capture("ssh://[^@]*@(?<h>[^/]+)/(?<p>.+?)(\\.git)?$") // {h: "", p: ""})
|
||||||
|
elif ($u | test("^git@")) then
|
||||||
|
($u | capture("git@(?<h>[^:/]+)[:/](?<p>.+?)(\\.git)?$") // {h: "", p: ""})
|
||||||
|
elif ($u | test("^https?://")) then
|
||||||
|
($u | capture("https?://(?<h>[^/]+)/(?<p>.+?)(\\.git)?$") // {h: "", p: ""})
|
||||||
|
else
|
||||||
|
{h: "", p: ""}
|
||||||
|
end | "\(.h) \(.p)"')
|
||||||
|
export MONOREPO_GITEA_HOST="${monorepo_meta% *}"
|
||||||
|
export MONOREPO_REPO_PATH="${monorepo_meta#* }"
|
||||||
|
[ -n "$MONOREPO_GITEA_HOST" ] || die "Could not derive gitea host from josh.monorepo_url='${MONOREPO_URL}' (expected git@host:org/repo.git or https://host/org/repo.git)"
|
||||||
|
|
||||||
# Enrich targets with derived fields (gitea_host, subrepo_repo_path, auto josh_filter)
|
# Enrich targets with derived fields (gitea_host, subrepo_repo_path, auto josh_filter)
|
||||||
export JOSH_SYNC_TARGETS
|
export JOSH_SYNC_TARGETS
|
||||||
JOSH_SYNC_TARGETS=$(echo "$config_json" | jq '[.targets[] | . +
|
JOSH_SYNC_TARGETS=$(echo "$config_json" | jq '[.targets[] | . +
|
||||||
@@ -68,10 +97,8 @@ parse_config() {
|
|||||||
export GITEA_TOKEN="${GITEA_TOKEN:-${SYNC_BOT_TOKEN:-}}"
|
export GITEA_TOKEN="${GITEA_TOKEN:-${SYNC_BOT_TOKEN:-}}"
|
||||||
export BOT_USER="${BOT_USER:-${SYNC_BOT_USER:-}}"
|
export BOT_USER="${BOT_USER:-${SYNC_BOT_USER:-}}"
|
||||||
|
|
||||||
# Monorepo API URL (derived from first target's host, overridable via env)
|
# Monorepo API URL (derived from monorepo_url's host, overridable via env for custom API endpoints)
|
||||||
local gitea_host
|
export MONOREPO_API="${MONOREPO_API:-https://${MONOREPO_GITEA_HOST}/api/v1/repos/${MONOREPO_PATH}}"
|
||||||
gitea_host=$(echo "$JOSH_SYNC_TARGETS" | jq -r '.[0].gitea_host')
|
|
||||||
export MONOREPO_API="${MONOREPO_API:-https://${gitea_host}/api/v1/repos/${MONOREPO_PATH}}"
|
|
||||||
|
|
||||||
log "INFO" "Config loaded: $(echo "$JOSH_SYNC_TARGETS" | jq 'length') target(s)"
|
log "INFO" "Config loaded: $(echo "$JOSH_SYNC_TARGETS" | jq 'length') target(s)"
|
||||||
}
|
}
|
||||||
|
|||||||
173
lib/sync.sh
173
lib/sync.sh
@@ -226,7 +226,9 @@ ${BOT_TRAILER}: filter-change/${mono_branch}/$(date -u +%Y-%m-%dT%H:%M:%SZ)")
|
|||||||
# ─── Reverse Sync: Subrepo → Monorepo ──────────────────────────────
|
# ─── Reverse Sync: Subrepo → Monorepo ──────────────────────────────
|
||||||
#
|
#
|
||||||
# Always creates a PR on the monorepo — never pushes directly.
|
# Always creates a PR on the monorepo — never pushes directly.
|
||||||
# Returns: skip | pr-created | josh-rejected
|
# Returns: skip | skip-dirty | pr-created | josh-rejected
|
||||||
|
# skip — trees identical, nothing to do
|
||||||
|
# skip-dirty — trees differ but no human commits found (don't update state)
|
||||||
|
|
||||||
reverse_sync() {
|
reverse_sync() {
|
||||||
local mono_branch="$SYNC_BRANCH_MONO"
|
local mono_branch="$SYNC_BRANCH_MONO"
|
||||||
@@ -238,6 +240,8 @@ reverse_sync() {
|
|||||||
|
|
||||||
log "INFO" "=== Reverse sync: subrepo/${subrepo_branch} → mono/${mono_branch} ==="
|
log "INFO" "=== Reverse sync: subrepo/${subrepo_branch} → mono/${mono_branch} ==="
|
||||||
|
|
||||||
|
local force_mode="${SYNC_REVERSE_FORCE:-0}"
|
||||||
|
|
||||||
# 1. Clone subrepo
|
# 1. Clone subrepo
|
||||||
git clone "$(subrepo_auth_url)" \
|
git clone "$(subrepo_auth_url)" \
|
||||||
--branch "$subrepo_branch" --single-branch \
|
--branch "$subrepo_branch" --single-branch \
|
||||||
@@ -265,16 +269,32 @@ reverse_sync() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# 4. Find new human commits (excludes bot commits from forward sync)
|
# 4. Find new human commits (excludes bot commits from forward sync)
|
||||||
# Uses --ancestry-path to restrict to the direct lineage and avoid
|
# Uses merge-base + --ancestry-path to restrict to the direct lineage.
|
||||||
# leaking old history through reconciliation merge parents.
|
# merge-base is needed because mono-filtered may have advanced past the
|
||||||
local human_commits
|
# last forward sync (any monorepo commit, even outside the subfolder,
|
||||||
human_commits=$(git log --ancestry-path "mono-filtered/${mono_branch}..HEAD" \
|
# causes josh to create a new filtered commit). Using mono-filtered
|
||||||
--oneline --invert-grep --grep="^${BOT_TRAILER}:" 2>/dev/null || echo "")
|
# directly with --ancestry-path returns empty when it's not an ancestor
|
||||||
|
# of HEAD. merge-base always finds the last connected point.
|
||||||
|
local base human_commits
|
||||||
|
base=$(git merge-base "mono-filtered/${mono_branch}" HEAD 2>/dev/null || echo "")
|
||||||
|
|
||||||
|
if [ -n "$base" ]; then
|
||||||
|
human_commits=$(git log --ancestry-path "${base}..HEAD" \
|
||||||
|
--oneline --invert-grep --grep="^${BOT_TRAILER}:" 2>/dev/null || echo "")
|
||||||
|
else
|
||||||
|
# No common ancestor (first sync or fully diverged) — skip ancestry-path
|
||||||
|
human_commits=$(git log "mono-filtered/${mono_branch}..HEAD" \
|
||||||
|
--oneline --invert-grep --grep="^${BOT_TRAILER}:" 2>/dev/null || echo "")
|
||||||
|
fi
|
||||||
|
|
||||||
if [ -z "$human_commits" ]; then
|
if [ -z "$human_commits" ]; then
|
||||||
log "INFO" "No new human commits in subrepo — nothing to sync"
|
if [ "$force_mode" = "1" ]; then
|
||||||
echo "skip"
|
log "WARN" "No human commits found — force mode: continuing anyway"
|
||||||
return
|
else
|
||||||
|
log "INFO" "No new human commits in subrepo — nothing to sync"
|
||||||
|
echo "skip-dirty"
|
||||||
|
return
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
log "INFO" "New human commits to sync:"
|
log "INFO" "New human commits to sync:"
|
||||||
@@ -284,12 +304,129 @@ reverse_sync() {
|
|||||||
local ts
|
local ts
|
||||||
ts=$(date +%Y%m%d-%H%M%S)
|
ts=$(date +%Y%m%d-%H%M%S)
|
||||||
local staging_branch="auto-sync/subrepo-${subrepo_branch}-${ts}"
|
local staging_branch="auto-sync/subrepo-${subrepo_branch}-${ts}"
|
||||||
|
local push_ref="HEAD"
|
||||||
|
local linearized=false
|
||||||
|
|
||||||
if git push -o "base=${mono_branch}" "$(josh_auth_url)" "HEAD:refs/heads/${staging_branch}"; then
|
if ! git push -o "base=${mono_branch}" "$(josh_auth_url)" "${push_ref}:refs/heads/${staging_branch}" 2>/dev/null; then
|
||||||
log "INFO" "Pushed to staging branch via josh: ${staging_branch}"
|
# Josh rejects pushes when the history contains merge commits whose
|
||||||
|
# parents it cannot map (e.g., merges of staging/feature branches that
|
||||||
|
# include auto-sync history). Fall back to linearizing: cherry-pick
|
||||||
|
# regular commits as-is, squash only the problematic merge commits.
|
||||||
|
# See ADR-011.
|
||||||
|
log "WARN" "Direct push failed — linearizing history (cherry-pick + squash merges)"
|
||||||
|
log "INFO" "─── linearize start ───"
|
||||||
|
|
||||||
# 6. Create PR on monorepo (NEVER direct push)
|
local original_head
|
||||||
|
original_head=$(git rev-parse HEAD)
|
||||||
|
|
||||||
|
git checkout -b "tmp-linearize-$$" "mono-filtered/${mono_branch}" >/dev/null 2>&1 \
|
||||||
|
|| die "Failed to create linearize branch"
|
||||||
|
|
||||||
|
# Reuse merge-base from step 4 (same reasoning: mono-filtered may have
|
||||||
|
# advanced past the last forward sync, breaking --ancestry-path).
|
||||||
|
local commit_shas
|
||||||
|
if [ -n "$base" ]; then
|
||||||
|
commit_shas=$(git log --ancestry-path "${base}..${original_head}" \
|
||||||
|
--reverse --format="%H" --invert-grep --grep="^${BOT_TRAILER}:" 2>/dev/null || echo "")
|
||||||
|
else
|
||||||
|
commit_shas=$(git log "mono-filtered/${mono_branch}..${original_head}" \
|
||||||
|
--reverse --format="%H" --invert-grep --grep="^${BOT_TRAILER}:" 2>/dev/null || echo "")
|
||||||
|
fi
|
||||||
|
|
||||||
|
local sha
|
||||||
|
for sha in $commit_shas; do
|
||||||
|
local parent_count
|
||||||
|
parent_count=$(git cat-file -p "$sha" | grep -c "^parent ")
|
||||||
|
local short_msg
|
||||||
|
short_msg=$(git log -1 --format="%s" "$sha")
|
||||||
|
|
||||||
|
if [ "$parent_count" -le 1 ]; then
|
||||||
|
# Regular commit — cherry-pick to preserve message and authorship
|
||||||
|
if git cherry-pick "$sha" >/dev/null 2>&1; then
|
||||||
|
log "INFO" " cherry-pick ${sha:0:7}: ${short_msg}"
|
||||||
|
else
|
||||||
|
log "WARN" " conflict ${sha:0:7} — applying patch from parent"
|
||||||
|
git cherry-pick --abort 2>/dev/null
|
||||||
|
local msg author_name author_email author_date
|
||||||
|
msg=$(git log -1 --format="%B" "$sha")
|
||||||
|
author_name=$(git log -1 --format="%an" "$sha")
|
||||||
|
author_email=$(git log -1 --format="%ae" "$sha")
|
||||||
|
author_date=$(git log -1 --format="%aI" "$sha")
|
||||||
|
# Diff from the commit's own parent — NOT from HEAD (which is
|
||||||
|
# the linearize branch and may have a different base).
|
||||||
|
git diff "${sha}^" "$sha" | git apply --index >/dev/null 2>&1 \
|
||||||
|
|| die "Failed to apply diff for ${sha:0:7}"
|
||||||
|
GIT_AUTHOR_NAME="$author_name" GIT_AUTHOR_EMAIL="$author_email" \
|
||||||
|
GIT_AUTHOR_DATE="$author_date" \
|
||||||
|
git commit -m "$msg" >/dev/null 2>&1 || die "Failed to commit ${sha:0:7}"
|
||||||
|
log "INFO" " applied ${sha:0:7}: ${short_msg}"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# Merge commit — cherry-pick relative to first parent (squashes the merge)
|
||||||
|
local msg author_name author_email author_date
|
||||||
|
msg=$(git log -1 --format="%B" "$sha")
|
||||||
|
author_name=$(git log -1 --format="%an" "$sha")
|
||||||
|
author_email=$(git log -1 --format="%ae" "$sha")
|
||||||
|
author_date=$(git log -1 --format="%aI" "$sha")
|
||||||
|
|
||||||
|
if git cherry-pick -m 1 "$sha" >/dev/null 2>&1; then
|
||||||
|
log "INFO" " squash ${sha:0:7}: ${short_msg}"
|
||||||
|
else
|
||||||
|
# Cherry-pick failed — distinguish empty result (changes already
|
||||||
|
# applied by a prior cherry-pick) from a real conflict.
|
||||||
|
if git diff --cached --quiet 2>/dev/null && git diff --quiet 2>/dev/null; then
|
||||||
|
# Working tree clean — the merge changes are already present
|
||||||
|
git cherry-pick --abort 2>/dev/null || git reset --hard HEAD >/dev/null 2>&1
|
||||||
|
log "INFO" " skip ${sha:0:7}: ${short_msg} (already applied)"
|
||||||
|
else
|
||||||
|
# Real conflict — apply diff from first parent (what the merge
|
||||||
|
# actually introduced, NOT from HEAD which would capture all
|
||||||
|
# tree differences).
|
||||||
|
git cherry-pick --abort 2>/dev/null
|
||||||
|
git diff "${sha}^1" "$sha" | git apply --index >/dev/null 2>&1 \
|
||||||
|
|| die "Failed to apply merge diff for ${sha:0:7}"
|
||||||
|
GIT_AUTHOR_NAME="$author_name" GIT_AUTHOR_EMAIL="$author_email" \
|
||||||
|
GIT_AUTHOR_DATE="$author_date" \
|
||||||
|
git commit -m "$msg" >/dev/null 2>&1 || die "Failed to commit merge ${sha:0:7}"
|
||||||
|
log "INFO" " squash ${sha:0:7}: ${short_msg}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
log "INFO" "─── linearize done ────"
|
||||||
|
|
||||||
|
push_ref="HEAD"
|
||||||
|
linearized=true
|
||||||
|
|
||||||
|
git push -o "base=${mono_branch}" "$(josh_auth_url)" \
|
||||||
|
"${push_ref}:refs/heads/${staging_branch}" \
|
||||||
|
|| { log "ERROR" "Josh rejected linearized push — check josh-proxy logs"; echo "josh-rejected"; return; }
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "INFO" "Pushed to staging branch via josh: ${staging_branch}${linearized:+ (linearized)}"
|
||||||
|
|
||||||
|
# 6. Publish: force-push directly (--force) or create PR (default)
|
||||||
|
if [ "$force_mode" = "1" ]; then
|
||||||
|
log "WARN" "Force mode: pushing ${staging_branch} directly to mono/${mono_branch} — bypassing PR"
|
||||||
|
local mono_dir="${work_dir}/monorepo"
|
||||||
|
git clone "$(mono_auth_url)" \
|
||||||
|
--branch "$staging_branch" --single-branch \
|
||||||
|
"$mono_dir" || die "Failed to clone monorepo staging branch for force-push"
|
||||||
|
git -C "$mono_dir" push --force "$(mono_auth_url)" \
|
||||||
|
"HEAD:refs/heads/${mono_branch}" \
|
||||||
|
|| die "Failed to force-push to mono/${mono_branch}"
|
||||||
|
log "INFO" "Force-pushed mono/${staging_branch} → mono/${mono_branch}"
|
||||||
|
echo "forced"
|
||||||
|
else
|
||||||
local pr_body
|
local pr_body
|
||||||
|
local linearize_note=""
|
||||||
|
if [ "$linearized" = true ]; then
|
||||||
|
linearize_note="
|
||||||
|
> **Note:** Merge commits were squashed during sync because the subrepo
|
||||||
|
> history contained merges that josh-proxy cannot map (see ADR-011).
|
||||||
|
> Regular commits are preserved individually."
|
||||||
|
fi
|
||||||
pr_body="## Subrepo changes
|
pr_body="## Subrepo changes
|
||||||
|
|
||||||
New commits from subrepo \`${subrepo_branch}\`:
|
New commits from subrepo \`${subrepo_branch}\`:
|
||||||
@@ -297,7 +434,7 @@ New commits from subrepo \`${subrepo_branch}\`:
|
|||||||
\`\`\`
|
\`\`\`
|
||||||
${human_commits}
|
${human_commits}
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
${linearize_note}
|
||||||
**Review checklist:**
|
**Review checklist:**
|
||||||
- [ ] Changes scoped to synced subfolder
|
- [ ] Changes scoped to synced subfolder
|
||||||
- [ ] No leaked credentials or environment-specific config
|
- [ ] No leaked credentials or environment-specific config
|
||||||
@@ -311,9 +448,6 @@ ${human_commits}
|
|||||||
|
|
||||||
log "INFO" "Reverse sync PR created on monorepo"
|
log "INFO" "Reverse sync PR created on monorepo"
|
||||||
echo "pr-created"
|
echo "pr-created"
|
||||||
else
|
|
||||||
log "ERROR" "Josh rejected push — check josh-proxy logs"
|
|
||||||
echo "josh-rejected"
|
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,12 +475,7 @@ initial_import() {
|
|||||||
log "INFO" "=== Initial import: subrepo/${subrepo_branch} → mono/${mono_branch}:${subfolder}/ ==="
|
log "INFO" "=== Initial import: subrepo/${subrepo_branch} → mono/${mono_branch}:${subfolder}/ ==="
|
||||||
|
|
||||||
# 1. Clone monorepo (directly, not through josh — we need the real repo)
|
# 1. Clone monorepo (directly, not through josh — we need the real repo)
|
||||||
local mono_auth_url
|
git clone "$(mono_auth_url)" \
|
||||||
local api_host_path
|
|
||||||
api_host_path=$(echo "$MONOREPO_API" | sed 's|https://||; s|/api/v1/repos/|/|')
|
|
||||||
mono_auth_url="https://${BOT_USER}:${GITEA_TOKEN}@${api_host_path}.git"
|
|
||||||
|
|
||||||
git clone "$mono_auth_url" \
|
|
||||||
--branch "$mono_branch" --single-branch \
|
--branch "$mono_branch" --single-branch \
|
||||||
"${work_dir}/monorepo" || die "Failed to clone monorepo"
|
"${work_dir}/monorepo" || die "Failed to clone monorepo"
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,16 @@
|
|||||||
"title": "josh-sync configuration",
|
"title": "josh-sync configuration",
|
||||||
"description": "Configuration for bidirectional monorepo ↔ subrepo sync via josh-proxy",
|
"description": "Configuration for bidirectional monorepo ↔ subrepo sync via josh-proxy",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": ["josh", "targets", "bot"],
|
"required": ["schema_version", "josh", "targets", "bot"],
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"schema_version": {
|
||||||
|
"type": "integer",
|
||||||
|
"const": 2,
|
||||||
|
"description": "Schema version. Required. Must be 2 for josh-sync v2.x configs."
|
||||||
|
},
|
||||||
"josh": {
|
"josh": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": ["proxy_url", "monorepo_path"],
|
"required": ["proxy_url", "monorepo_path", "monorepo_url"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"proxy_url": {
|
"proxy_url": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -16,7 +21,17 @@
|
|||||||
},
|
},
|
||||||
"monorepo_path": {
|
"monorepo_path": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Repo path as josh sees it (org/repo)"
|
"description": "Repo path as josh-proxy sees it (org/repo)"
|
||||||
|
},
|
||||||
|
"monorepo_url": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Monorepo git clone URL — git@host:org/repo.git (SSH) or https://host/org/repo.git. The gitea host is derived from this URL."
|
||||||
|
},
|
||||||
|
"monorepo_auth": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["https", "ssh"],
|
||||||
|
"default": "https",
|
||||||
|
"description": "Auth method for cloning the monorepo. SSH uses the user's ssh-agent / ~/.ssh/config; HTTPS injects ${SYNC_BOT_USER}:${SYNC_BOT_TOKEN} into the URL."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
2
tests/fixtures/minimal.yml
vendored
2
tests/fixtures/minimal.yml
vendored
@@ -1,7 +1,9 @@
|
|||||||
# Test fixture: minimal single-target config
|
# Test fixture: minimal single-target config
|
||||||
|
schema_version: 2
|
||||||
josh:
|
josh:
|
||||||
proxy_url: "https://josh.test.local"
|
proxy_url: "https://josh.test.local"
|
||||||
monorepo_path: "org/repo"
|
monorepo_path: "org/repo"
|
||||||
|
monorepo_url: "git@gitea.test.local:org/repo.git"
|
||||||
|
|
||||||
targets:
|
targets:
|
||||||
- name: "example"
|
- name: "example"
|
||||||
|
|||||||
2
tests/fixtures/multi-target.yml
vendored
2
tests/fixtures/multi-target.yml
vendored
@@ -1,7 +1,9 @@
|
|||||||
# Test fixture: multi-target config
|
# Test fixture: multi-target config
|
||||||
|
schema_version: 2
|
||||||
josh:
|
josh:
|
||||||
proxy_url: "https://josh.test.local"
|
proxy_url: "https://josh.test.local"
|
||||||
monorepo_path: "org/monorepo"
|
monorepo_path: "org/monorepo"
|
||||||
|
monorepo_url: "git@gitea.test.local:org/monorepo.git"
|
||||||
|
|
||||||
targets:
|
targets:
|
||||||
- name: "app-a"
|
- name: "app-a"
|
||||||
|
|||||||
@@ -37,3 +37,39 @@ setup() {
|
|||||||
url=$(subrepo_auth_url)
|
url=$(subrepo_auth_url)
|
||||||
[ "$url" = "git@gitea.test.local:ext/app-a.git" ]
|
[ "$url" = "git@gitea.test.local:ext/app-a.git" ]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@test "mono_auth_url returns bare URL for SSH" {
|
||||||
|
export MONOREPO_AUTH="ssh"
|
||||||
|
export MONOREPO_URL="git@code.example.io:org/monorepo.git"
|
||||||
|
|
||||||
|
local url
|
||||||
|
url=$(mono_auth_url)
|
||||||
|
[ "$url" = "git@code.example.io:org/monorepo.git" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "mono_auth_url injects credentials for HTTPS git@-form monorepo_url" {
|
||||||
|
export MONOREPO_AUTH="https"
|
||||||
|
export MONOREPO_URL="git@code.example.io:org/monorepo.git"
|
||||||
|
|
||||||
|
local url
|
||||||
|
url=$(mono_auth_url)
|
||||||
|
[ "$url" = "https://sync-bot:test-token-123@code.example.io/org/monorepo.git" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "mono_auth_url injects credentials for HTTPS https-form monorepo_url" {
|
||||||
|
export MONOREPO_AUTH="https"
|
||||||
|
export MONOREPO_URL="https://code.example.io/org/monorepo.git"
|
||||||
|
|
||||||
|
local url
|
||||||
|
url=$(mono_auth_url)
|
||||||
|
[ "$url" = "https://sync-bot:test-token-123@code.example.io/org/monorepo.git" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "mono_auth_url defaults to HTTPS when MONOREPO_AUTH unset" {
|
||||||
|
unset MONOREPO_AUTH
|
||||||
|
export MONOREPO_URL="https://code.example.io/org/monorepo.git"
|
||||||
|
|
||||||
|
local url
|
||||||
|
url=$(mono_auth_url)
|
||||||
|
[ "$url" = "https://sync-bot:test-token-123@code.example.io/org/monorepo.git" ]
|
||||||
|
}
|
||||||
|
|||||||
@@ -104,3 +104,151 @@ setup() {
|
|||||||
run bash -c 'source "$JOSH_SYNC_ROOT/lib/core.sh"; source "$JOSH_SYNC_ROOT/lib/config.sh"; parse_config "nonexistent.yml"'
|
run bash -c 'source "$JOSH_SYNC_ROOT/lib/core.sh"; source "$JOSH_SYNC_ROOT/lib/config.sh"; parse_config "nonexistent.yml"'
|
||||||
[ "$status" -ne 0 ]
|
[ "$status" -ne 0 ]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@test "parse_config fails when schema_version is missing (no v1 fallback)" {
|
||||||
|
cd "$(mktemp -d)"
|
||||||
|
cat > .josh-sync.yml <<'YAML'
|
||||||
|
josh:
|
||||||
|
proxy_url: "https://josh.test.local"
|
||||||
|
monorepo_path: "org/repo"
|
||||||
|
monorepo_url: "git@gitea.test.local:org/repo.git"
|
||||||
|
targets:
|
||||||
|
- name: "x"
|
||||||
|
subfolder: "x"
|
||||||
|
subrepo_url: "git@gitea.test.local:ext/x.git"
|
||||||
|
branches: { main: main }
|
||||||
|
bot:
|
||||||
|
name: "b"
|
||||||
|
email: "b@b"
|
||||||
|
trailer: "T"
|
||||||
|
YAML
|
||||||
|
run bash -c 'source "$JOSH_SYNC_ROOT/lib/core.sh"; source "$JOSH_SYNC_ROOT/lib/config.sh"; parse_config ".josh-sync.yml"'
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
[[ "$output" == *"schema_version missing"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "parse_config rejects schema_version: 1" {
|
||||||
|
cd "$(mktemp -d)"
|
||||||
|
cat > .josh-sync.yml <<'YAML'
|
||||||
|
schema_version: 1
|
||||||
|
josh:
|
||||||
|
proxy_url: "https://josh.test.local"
|
||||||
|
monorepo_path: "org/repo"
|
||||||
|
monorepo_url: "git@gitea.test.local:org/repo.git"
|
||||||
|
targets:
|
||||||
|
- name: "x"
|
||||||
|
subfolder: "x"
|
||||||
|
subrepo_url: "git@gitea.test.local:ext/x.git"
|
||||||
|
branches: { main: main }
|
||||||
|
bot:
|
||||||
|
name: "b"
|
||||||
|
email: "b@b"
|
||||||
|
trailer: "T"
|
||||||
|
YAML
|
||||||
|
run bash -c 'source "$JOSH_SYNC_ROOT/lib/core.sh"; source "$JOSH_SYNC_ROOT/lib/config.sh"; parse_config ".josh-sync.yml"'
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
[[ "$output" == *"Unsupported schema_version"* ]] || [[ "$output" == *"requires schema_version 2"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "parse_config fails when josh.monorepo_url is missing" {
|
||||||
|
cd "$(mktemp -d)"
|
||||||
|
cat > .josh-sync.yml <<'YAML'
|
||||||
|
schema_version: 2
|
||||||
|
josh:
|
||||||
|
proxy_url: "https://josh.test.local"
|
||||||
|
monorepo_path: "org/repo"
|
||||||
|
targets:
|
||||||
|
- name: "x"
|
||||||
|
subfolder: "x"
|
||||||
|
subrepo_url: "git@gitea.test.local:ext/x.git"
|
||||||
|
branches: { main: main }
|
||||||
|
bot:
|
||||||
|
name: "b"
|
||||||
|
email: "b@b"
|
||||||
|
trailer: "T"
|
||||||
|
YAML
|
||||||
|
run bash -c 'source "$JOSH_SYNC_ROOT/lib/core.sh"; source "$JOSH_SYNC_ROOT/lib/config.sh"; parse_config ".josh-sync.yml"'
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
[[ "$output" == *"monorepo_url"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "parse_config exports MONOREPO_URL, MONOREPO_AUTH, MONOREPO_GITEA_HOST, MONOREPO_REPO_PATH from SSH form" {
|
||||||
|
cd "$(mktemp -d)"
|
||||||
|
cp "$FIXTURES/minimal.yml" .josh-sync.yml
|
||||||
|
|
||||||
|
parse_config ".josh-sync.yml"
|
||||||
|
|
||||||
|
[ "$MONOREPO_URL" = "git@gitea.test.local:org/repo.git" ]
|
||||||
|
[ "$MONOREPO_AUTH" = "https" ]
|
||||||
|
[ "$MONOREPO_GITEA_HOST" = "gitea.test.local" ]
|
||||||
|
[ "$MONOREPO_REPO_PATH" = "org/repo" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "parse_config derives MONOREPO_GITEA_HOST from HTTPS monorepo_url" {
|
||||||
|
cd "$(mktemp -d)"
|
||||||
|
cat > .josh-sync.yml <<'YAML'
|
||||||
|
schema_version: 2
|
||||||
|
josh:
|
||||||
|
proxy_url: "https://josh.test.local"
|
||||||
|
monorepo_path: "org/repo"
|
||||||
|
monorepo_url: "https://gitea.example.io/org/repo.git"
|
||||||
|
targets:
|
||||||
|
- name: "x"
|
||||||
|
subfolder: "x"
|
||||||
|
subrepo_url: "git@gitea.test.local:ext/x.git"
|
||||||
|
branches: { main: main }
|
||||||
|
bot:
|
||||||
|
name: "b"
|
||||||
|
email: "b@b"
|
||||||
|
trailer: "T"
|
||||||
|
YAML
|
||||||
|
parse_config ".josh-sync.yml"
|
||||||
|
[ "$MONOREPO_GITEA_HOST" = "gitea.example.io" ]
|
||||||
|
[ "$MONOREPO_REPO_PATH" = "org/repo" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "parse_config derives MONOREPO_API from monorepo_url's host (cross-host: monorepo and target on different hosts)" {
|
||||||
|
cd "$(mktemp -d)"
|
||||||
|
cat > .josh-sync.yml <<'YAML'
|
||||||
|
schema_version: 2
|
||||||
|
josh:
|
||||||
|
proxy_url: "https://josh.example.io"
|
||||||
|
monorepo_path: "org/repo"
|
||||||
|
monorepo_url: "git@code.example.io:org/repo.git"
|
||||||
|
targets:
|
||||||
|
- name: "x"
|
||||||
|
subfolder: "x"
|
||||||
|
subrepo_url: "git@other-host.io:ext/x.git"
|
||||||
|
branches: { main: main }
|
||||||
|
bot:
|
||||||
|
name: "b"
|
||||||
|
email: "b@b"
|
||||||
|
trailer: "T"
|
||||||
|
YAML
|
||||||
|
parse_config ".josh-sync.yml"
|
||||||
|
[ "$MONOREPO_API" = "https://code.example.io/api/v1/repos/org/repo" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "parse_config rejects invalid monorepo_auth value" {
|
||||||
|
cd "$(mktemp -d)"
|
||||||
|
cat > .josh-sync.yml <<'YAML'
|
||||||
|
schema_version: 2
|
||||||
|
josh:
|
||||||
|
proxy_url: "https://josh.test.local"
|
||||||
|
monorepo_path: "org/repo"
|
||||||
|
monorepo_url: "git@gitea.test.local:org/repo.git"
|
||||||
|
monorepo_auth: "weird"
|
||||||
|
targets:
|
||||||
|
- name: "x"
|
||||||
|
subfolder: "x"
|
||||||
|
subrepo_url: "git@gitea.test.local:ext/x.git"
|
||||||
|
branches: { main: main }
|
||||||
|
bot:
|
||||||
|
name: "b"
|
||||||
|
email: "b@b"
|
||||||
|
trailer: "T"
|
||||||
|
YAML
|
||||||
|
run bash -c 'source "$JOSH_SYNC_ROOT/lib/core.sh"; source "$JOSH_SYNC_ROOT/lib/config.sh"; parse_config ".josh-sync.yml"'
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
[[ "$output" == *"monorepo_auth"* ]]
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user