From dc2cdea360396b2caea3dc1fd9597103c3507c0a Mon Sep 17 00:00:00 2001 From: SBPro Date: Wed, 27 May 2026 03:32:04 +0100 Subject: [PATCH] =?UTF-8?q?Release=202.2.0=20=E2=80=94=20unified=20onboard?= =?UTF-8?q?=20strategy=20+=20safety=20hardening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Folds `josh-sync adopt` into `josh-sync onboard ` as the adopt strategy alongside the original reset flow. Strategy resolves by precedence: --mode flag > targets[].history_lock config (preserve|rewrite) > auto-detect via `git ls-remote --heads`. `josh-sync adopt` is kept as a thin alias. Adds the new `targets[].history_lock` config field (validated at parse time) and folds `/adopt.json` state into `/onboard.json` with a `.strategy` field; legacy state files are read with a backward-compat fallback. Safety hardening (from a multi-angle review of the unification): - auto-detect distinguishes auth/network failure from empty repo - resume validates --mode against the strategy saved in state - `josh-sync adopt` rejects a conflicting --mode in the forwarded args - missing import-PR lookup dies in both strategies (was WARN+continue for reset, which could create duplicate import PRs on resume) - --restart durably removes the legacy adopt.json from the state branch - adopt_branch is now a subshell function (EXIT trap can't clobber callers) - strategy value validated after resolve/load (reset|adopt) - --mode with missing/empty value dies with a usage hint - migrate-pr against an adopt-strategy target dies with a specific hint - reset importing asserts archived_url is present (no "null" → git clone) End-to-end + CLI bats coverage added (tests/unit/adopt_e2e.bats, tests/unit/cli.bats). 72 tests, shellcheck clean. Makefile dist bundle header now correctly interpolates VERSION and line count. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 28 + Makefile | 4 +- VERSION | 2 +- bin/josh-sync | 76 +-- docs/adr/013-non-destructive-adoption.md | 1 + docs/config-reference.md | 1 + docs/guide.md | 60 +- lib/adopt.sh | 250 +------- lib/config.sh | 9 + lib/onboard.sh | 467 ++++++++++----- schema/config-schema.json | 5 + tests/unit/adopt_e2e.bats | 697 +++++++++++++++++++++++ tests/unit/cli.bats | 123 ++++ 13 files changed, 1284 insertions(+), 439 deletions(-) create mode 100644 tests/unit/adopt_e2e.bats create mode 100644 tests/unit/cli.bats diff --git a/CHANGELOG.md b/CHANGELOG.md index 31ad6e1..b0318ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,33 @@ # Changelog +## 2.2.0 + +### Changes + +- **Unified onboarding via `josh-sync onboard `.** The previously separate `adopt` workflow is now an onboarding strategy alongside the original `reset` flow. Strategy is resolved by precedence: `--mode={reset,adopt}` flag → `targets[].history_lock` config (`preserve` → adopt, `rewrite` → reset) → auto-detect via `git ls-remote --heads` on the subrepo (heads → adopt, empty → reset). The chosen strategy is logged at preflight (and re-announced on every resume). +- **`josh-sync adopt` kept as a back-compat CLI alias** for `josh-sync onboard --mode=adopt`. Existing in-flight adoptions (state at `/adopt.json` on the `josh-sync-state` branch) continue to resume — `read_onboard_state` falls back to that legacy file with an implicit `strategy: adopt` and strips the legacy `.mode` field. Pre-unification `onboard.json` files (no `.strategy`) are read as `strategy: reset`. +- **New `targets[].history_lock` config field** (`preserve` | `rewrite`) — pins the onboarding strategy independently of the subrepo's current emptiness. Validated at config parse time. See [Config Reference](docs/config-reference.md#targets-section). +- **End-to-end bats coverage** — tree-mismatch surfacing, idempotency, merge shape (ADR-005 trailer, ADR-008 first-parent ordering), fast-forward push semantics, reverse-sync loop guard, linearize-fallback selection, resume from every checkpoint, strategy resolution precedence, legacy-state migration, and the CLI surface (flag parsing, alias forwarding, error messages). + +### Safety + +- **Auto-detect now distinguishes auth/network failures from genuinely empty repos.** A failing `git ls-remote` previously fell through to `reset` (destructive force-push); it now dies with a clear "pass --mode explicitly" message. +- **Resume validates `--mode` against the strategy already saved in state.** Conflicting flags die with a `--restart` hint instead of being silently ignored. +- **`josh-sync adopt` rejects a conflicting `--mode`.** Previously the user-supplied flag silently won. +- **Missing import-PR lookup now `die`s in both strategies.** The reset path used to `WARN` and continue without recording the PR number, leading to duplicate import PRs on resume. +- **`--restart` durably removes the legacy `/adopt.json`** from the state branch so it can't silently resurrect via the read fallback. +- **`adopt_branch` is now a subshell function** — its EXIT trap can no longer clobber callers' traps (test reporting, etc.). +- **Strategy value is validated** after resolve/load (`reset|adopt`); unknown/corrupt values die with a `--restart` hint. +- **`--mode` with a missing/empty value** dies with a usage hint instead of crashing under `set -u`. +- **`migrate-pr` against an adopt-strategy target** dies with a specific message rather than the misleading "Run onboard first" loop. +- **Reset import step asserts `archived_url` is present** instead of silently flowing the literal string `"null"` into `git clone`. + +ADR-013 updated with a status note pointing at the unified command. + +### Fixes + +- Makefile `dist/josh-sync` bundle header now correctly interpolates VERSION and line count (was emitting empty values due to make's `$(...)` swallowing the shell command substitution). + ## 2.1.0 ### Breaking Changes diff --git a/Makefile b/Makefile index 3b4de3e..754132c 100644 --- a/Makefile +++ b/Makefile @@ -19,7 +19,7 @@ dist/josh-sync: bin/josh-sync lib/*.sh VERSION @mkdir -p dist @echo "Bundling josh-sync..." @echo '#!/usr/bin/env bash' > dist/josh-sync - @echo "# josh-sync $(cat VERSION) — bundled distribution" >> dist/josh-sync + @echo "# josh-sync $$(cat VERSION) — bundled distribution" >> dist/josh-sync @echo '# Generated by: make build' >> dist/josh-sync @echo '' >> dist/josh-sync @# Inline all library modules (strip shebangs and source directives) @@ -36,7 +36,7 @@ dist/josh-sync: bin/josh-sync lib/*.sh VERSION | sed '/^JOSH_LIB_DIR=/,/^source/d' \ >> dist/josh-sync @chmod +x dist/josh-sync - @echo "Built: dist/josh-sync ($(wc -l < dist/josh-sync) lines)" + @echo "Built: dist/josh-sync ($$(wc -l < dist/josh-sync) lines)" clean: rm -rf dist/ diff --git a/VERSION b/VERSION index 7ec1d6d..ccbccc3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.1.0 +2.2.0 diff --git a/bin/josh-sync b/bin/josh-sync index d126e3b..bba810b 100755 --- a/bin/josh-sync +++ b/bin/josh-sync @@ -8,9 +8,9 @@ # sync Run forward and/or reverse sync # preflight Validate config, connectivity, auth # import Initial import: pull subrepo into monorepo -# adopt Adopt existing subrepo history without rewriting it +# onboard Interactive onboarding (auto-picks reset|adopt strategy) +# adopt Alias for `onboard --mode=adopt` # reset Reset subrepo to josh-filtered view -# onboard Import existing subrepo into monorepo (interactive) # migrate-pr [PR#...] [--all] Move PRs from archived to new subrepo # status Show target config and sync state # state show|reset Manage sync state directly @@ -75,9 +75,13 @@ Commands: sync Run forward and/or reverse sync preflight Validate config, connectivity, auth, workflow coverage import Initial import: pull existing subrepo into monorepo (creates PR) - adopt Adopt existing subrepo history without rewriting it + onboard Onboard a subrepo (interactive, resumable). Picks a strategy: + reset — force-push josh-filtered history (replaces subrepo) + adopt — non-destructive merge that keeps subrepo history + Precedence: --mode flag > targets[].history_lock > auto-detect + via ls-remote (heads → adopt, empty → reset). + adopt Alias for 'onboard --mode=adopt' (kept for back-compat) reset Reset subrepo to josh-filtered view (after merging import PR) - onboard Import existing subrepo into monorepo (interactive, resumable) migrate-pr [PR#...] [--all] Move PRs from archived to new subrepo status Show target config and sync state state show [branch] Show sync state JSON @@ -96,6 +100,9 @@ Sync flags: --target NAME Filter to target(s) — comma-separated for multiple (env: JOSH_SYNC_TARGET) --branch BRANCH Filter to one branch +Onboard flags: + --mode={reset,adopt} Override the onboarding strategy + Environment: JOSH_SYNC_TARGET Restrict to a single target name JOSH_SYNC_STATE_BRANCH State branch name (default: josh-sync-state) @@ -534,40 +541,33 @@ cmd_import() { done } -# ─── Adopt Command ───────────────────────────────────────────────── +# ─── Adopt Command (alias) ───────────────────────────────────────── +# `josh-sync adopt` is preserved as a thin alias for the unified onboard +# command. Effectively: `josh-sync onboard --mode=adopt `. cmd_adopt() { - local config_file=".josh-sync.yml" - local target_name="" - local restart=false - - while [ $# -gt 0 ]; do - case "$1" in - --config) config_file="$2"; shift 2 ;; - --debug) export JOSH_SYNC_DEBUG=1; shift ;; - --restart) restart=true; shift ;; - -*) die "Unknown flag: $1" ;; - *) target_name="$1"; shift ;; + # Reject a conflicting --mode in the forwarded args: the alias means adopt, + # so `josh-sync adopt … --mode=reset` is contradictory. Without this guard + # the user-supplied --mode would silently win (last --mode in cmd_onboard's + # parser is the one that takes effect). + local i=1 a + while [ $i -le $# ]; do + a="${!i}" + case "$a" in + --mode=adopt) ;; + --mode=*) die "'josh-sync adopt' is an alias for --mode=adopt; conflicting '${a}' given. Use 'josh-sync onboard ' instead." ;; + --mode) + i=$((i + 1)) + if [ $i -gt $# ] || [ "${!i}" != "adopt" ]; then + die "'josh-sync adopt' is an alias for --mode=adopt; conflicting '--mode ${!i:-}' given. Use 'josh-sync onboard ' instead." + fi + ;; esac + i=$((i + 1)) done - if [ -z "$target_name" ]; then - echo "Usage: josh-sync adopt [--restart]" >&2 - parse_config "$config_file" - echo "Available targets:" >&2 - echo "$JOSH_SYNC_TARGETS" | jq -r '.[].name' | sed 's/^/ /' >&2 - exit 1 - fi - - parse_config "$config_file" - - local target_json - target_json=$(echo "$JOSH_SYNC_TARGETS" | jq -c --arg n "$target_name" '.[] | select(.name == $n)') - [ -n "$target_json" ] || die "Target '${target_name}' not found in config" - - log "INFO" "══════ Adopt target: ${target_name} ══════" - load_target "$target_json" - adopt_flow "$target_json" "$restart" + log "INFO" "(josh-sync adopt is now an alias for: josh-sync onboard --mode=adopt)" + cmd_onboard --mode=adopt "$@" } # ─── Reset Command ───────────────────────────────────────────────── @@ -743,19 +743,25 @@ cmd_onboard() { local config_file=".josh-sync.yml" local target_name="" local restart=false + local mode="" while [ $# -gt 0 ]; do case "$1" in --config) config_file="$2"; shift 2 ;; --debug) export JOSH_SYNC_DEBUG=1; shift ;; --restart) restart=true; shift ;; + --mode=*) mode="${1#--mode=}" + [ -n "$mode" ] || die "Empty --mode= (use --mode=reset|adopt)" + shift ;; + --mode) [ $# -ge 2 ] || die "--mode requires a value (reset|adopt)" + mode="$2"; shift 2 ;; -*) die "Unknown flag: $1" ;; *) target_name="$1"; shift ;; esac done if [ -z "$target_name" ]; then - echo "Usage: josh-sync onboard [--restart]" >&2 + echo "Usage: josh-sync onboard [--mode=reset|adopt] [--restart]" >&2 parse_config "$config_file" echo "Available targets:" >&2 echo "$JOSH_SYNC_TARGETS" | jq -r '.[].name' | sed 's/^/ /' >&2 @@ -770,7 +776,7 @@ cmd_onboard() { log "INFO" "══════ Onboard target: ${target_name} ══════" load_target "$target_json" - onboard_flow "$target_json" "$restart" + onboard_flow "$target_json" "$restart" "$mode" } # ─── Migrate PR Command ────────────────────────────────────────── diff --git a/docs/adr/013-non-destructive-adoption.md b/docs/adr/013-non-destructive-adoption.md index a744b77..7e56849 100644 --- a/docs/adr/013-non-destructive-adoption.md +++ b/docs/adr/013-non-destructive-adoption.md @@ -2,6 +2,7 @@ **Status:** Accepted **Date:** 2026-04 +**Update (2026-05):** The standalone `josh-sync adopt ` command described below has been folded into `josh-sync onboard ` as the `adopt` strategy (selectable via `--mode=adopt`, `targets[].history_lock: preserve`, or auto-detection when the subrepo has any branches). `josh-sync adopt` is retained as a thin CLI alias. The state file moved from `/adopt.json` to `/onboard.json` with a `.strategy` field; the legacy file is still read for backward compatibility. See the guide's Onboarding section. ## Context diff --git a/docs/config-reference.md b/docs/config-reference.md index c953083..fc1beca 100644 --- a/docs/config-reference.md +++ b/docs/config-reference.md @@ -42,6 +42,7 @@ Each target maps a monorepo subfolder to an external subrepo. | `branches` | object | Yes | — | Branch mapping: `mono_branch: subrepo_branch`. Each key-value pair syncs those branches bidirectionally. | | `forward_only` | string[] | No | `[]` | Branches that only sync mono → subrepo, never reverse. | | `exclude` | string[] | No | `[]` | File/directory patterns to exclude from sync via josh `:exclude` filter. Excluded files exist only in the monorepo, never in the subrepo. See [Excluding Files](guide.md#excluding-files-from-sync). | +| `history_lock` | string | No | (auto-detect) | Onboarding strategy hint. `"preserve"` selects the non-destructive [adopt](guide.md#onboarding) strategy (keeps subrepo history via an adoption merge — ADR-013); `"rewrite"` selects the destructive reset strategy (force-pushes josh-filtered history). When omitted, `josh-sync onboard` auto-detects: subrepos with branches → adopt, empty subrepos → reset. The CLI flag `--mode={reset,adopt}` overrides this. | ## `bot` Section diff --git a/docs/guide.md b/docs/guide.md index 540b685..fdac918 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -222,20 +222,31 @@ This validates: For a new monorepo before import, preflight may warn that subfolders don't exist yet — that's expected. -## Step 5: Import Existing Subrepos +## Step 5: Onboard Existing Subrepos -This is the critical onboarding step. There are three approaches: +`josh-sync onboard ` is the single entry point for connecting an existing subrepo to the monorepo. It runs interactively with checkpoint/resume and picks one of two strategies: -- **`josh-sync adopt`** (recommended for active existing subrepos) — non-destructive, resumable, preserves existing subrepo history -- **`josh-sync onboard`** — destructive replacement-repo workflow for teams that intentionally archive the old repo -- **Manual `import` → merge → `reset`** — lower-level destructive path for automation or empty replacement repos +- **adopt** (non-destructive) — keeps the subrepo's existing history and joins it to josh-filtered history via a single adoption merge commit. No force-push. Developers fast-forward; open PR branches stay valid. +- **reset** (destructive) — force-pushes josh-filtered history onto the subrepo, replacing its prior history. Use this for empty replacement repos (typically paired with renaming the old repo to `*-archived`). -### Option A: Adopt (recommended for active subrepos) +### Strategy selection -Use `adopt` when the subrepo already exists, developers already clone it, or open PR branches should remain based on existing history. +`josh-sync onboard` resolves the strategy in this order (highest precedence first): + +1. **CLI flag** — `--mode=adopt` or `--mode=reset` overrides everything. +2. **Config** — `targets[].history_lock: preserve` selects adopt; `rewrite` selects reset. +3. **Auto-detect** — `git ls-remote --heads` on the subrepo. Branches present → adopt. Empty repo → reset. + +The chosen strategy is logged at preflight so you can see what will happen before any side effects. + +`josh-sync adopt ` is kept as a back-compat alias for `josh-sync onboard --mode=adopt`. + +### Adopt strategy (auto-selected for active subrepos) ```bash -josh-sync adopt billing +josh-sync onboard billing # auto-detect — picks adopt for non-empty subrepo +josh-sync onboard billing --mode=adopt # force adopt explicitly +josh-sync adopt billing # equivalent back-compat alias ``` The command will: @@ -245,7 +256,7 @@ The command will: 4. **Adopt** — creates a merge commit on the subrepo with Josh-filtered HEAD as parent 1 and existing subrepo HEAD as parent 2 5. **Push** — pushes the adoption merge with a normal fast-forward push, never force-push -The adoption merge preserves the old subrepo history while giving Josh a first-parent path back to the monorepo. If interrupted, re-run `josh-sync adopt billing` to resume. Use `--restart` to start over. +The adoption merge preserves the old subrepo history while giving Josh a first-parent path back to the monorepo. If interrupted, re-run `josh-sync onboard billing` to resume. Use `--restart` to start over. See [ADR-013](adr/013-non-destructive-adoption.md) for the design rationale. After adoption, developers can update existing clones with a normal fast-forward: @@ -255,9 +266,9 @@ git checkout main git merge --ff-only origin/main ``` -### Option B: Onboard with replacement repo +### Reset strategy (auto-selected for empty replacement repos) -The `onboard` command walks through the destructive replacement-repo process interactively, with checkpoint/resume at every step. +Use the reset strategy when you intentionally want to archive the old subrepo and start fresh from a new empty repo. **Before you start:** @@ -269,7 +280,8 @@ The rename preserves the archived repo with all its history and open PRs. The ne **Run onboard:** ```bash -josh-sync onboard billing +josh-sync onboard billing # auto-detect — picks reset for empty subrepo +josh-sync onboard billing --mode=reset # force reset explicitly ``` The command will: @@ -298,13 +310,13 @@ josh-sync migrate-pr billing 5 8 12 PR migration works by fetching the diff from the archived repo's PR, applying it to the new repo, and creating a new PR. File content is identical after reset, so patches apply cleanly. -### Option C: Manual import → merge → reset +### Manual: import → merge → reset -Use this for scripted automation or when you intentionally want to replace subrepo history. +Use this for scripted automation or when you intentionally want to replace subrepo history without the interactive wrapper. > Do this **one target at a time** to keep PRs reviewable. -#### 5c-1. Import +#### Manual: Import ```bash josh-sync import billing @@ -319,13 +331,13 @@ This: Review the import PR — check for leaked credentials, environment-specific config, or files that shouldn't be in the monorepo. -#### 5c-2. Merge the import PR +#### Manual: Merge the import PR Merge the PR using your Git platform's UI. This lands the subrepo content into the monorepo's main branch. > At this point, the monorepo has the content but the histories are disconnected. Sync will **not** work until you complete the reset step. -#### 5c-3. Reset +#### Manual: Reset ```bash josh-sync reset billing @@ -352,7 +364,7 @@ git checkout stage && git reset --hard origin/stage # repeat for each branch Or simply delete and re-clone the subrepo. Local-only branches (not pushed to the remote) will be lost either way. -#### 5c-4. Repeat for each target +#### Manual: Repeat for each target ``` For each target: @@ -646,15 +658,13 @@ To add a new subrepo after initial setup: 1. Add the target to `.josh-sync.yml` 2. Update the forward workflow's `paths:` list to include the new subfolder 3. Commit and push -4. Import or adopt the target: +4. Onboard the target — `josh-sync onboard` auto-picks `adopt` for non-empty subrepos and `reset` for empty ones; override with `--mode={reset,adopt}` or pin via the target's `history_lock` config field. ```bash - # Recommended for an existing active subrepo - josh-sync adopt new-target + josh-sync onboard new-target # auto-detect strategy + josh-sync onboard new-target --mode=adopt # force adopt + josh-sync onboard new-target --mode=reset # force reset - # Replacement-repo workflow - josh-sync onboard new-target - - # Or manual: import → merge PR → reset + # Manual lower-level path (when scripting): josh-sync import new-target # merge the PR josh-sync reset new-target diff --git a/lib/adopt.sh b/lib/adopt.sh index b779aa7..70a55bc 100644 --- a/lib/adopt.sh +++ b/lib/adopt.sh @@ -1,52 +1,14 @@ #!/usr/bin/env bash -# lib/adopt.sh — Non-destructive adoption of existing subrepo history +# lib/adopt.sh — Non-destructive adoption merge primitive # # Provides: -# adopt_flow() — Import → wait for merge → adoption merge per branch -# adopt_branch() — Create the adoption merge and push it fast-forward only +# adopt_branch() — Create one adoption merge commit and push it fast-forward # -# Adopt state is stored on the josh-sync-state branch at /adopt.json. -# Steps: start → importing → waiting-for-merge → adopting → complete +# The orchestration (state machine, import → wait → finalize) lives in +# lib/onboard.sh under strategy="adopt". `josh-sync adopt` is now a CLI alias +# for `josh-sync onboard --mode=adopt`; see ADR-013 for design rationale. # -# Requires: lib/core.sh, lib/config.sh, lib/auth.sh, lib/state.sh, lib/sync.sh sourced - -# ─── Adopt State Helpers ───────────────────────────────────────── - -read_adopt_state() { - local target_name="${1:-$JOSH_SYNC_TARGET_NAME}" - git fetch origin "$STATE_BRANCH" 2>/dev/null || true - git show "origin/${STATE_BRANCH}:${target_name}/adopt.json" 2>/dev/null || echo '{}' -} - -write_adopt_state() { - local target_name="${1:-$JOSH_SYNC_TARGET_NAME}" - local state_json="$2" - local key="${target_name}/adopt" - local tmp_dir - tmp_dir=$(mktemp -d) - - if git rev-parse "origin/${STATE_BRANCH}" >/dev/null 2>&1; then - git worktree add "$tmp_dir" "origin/${STATE_BRANCH}" 2>/dev/null - else - git worktree add --detach "$tmp_dir" 2>/dev/null - (cd "$tmp_dir" && git checkout --orphan "$STATE_BRANCH" && git rm -rf . 2>/dev/null || true) - fi - - mkdir -p "$(dirname "${tmp_dir}/${key}.json")" - echo "$state_json" | jq '.' > "${tmp_dir}/${key}.json" - - ( - cd "$tmp_dir" || exit - git add -A - if ! git diff --cached --quiet 2>/dev/null; then - git -c user.name="$BOT_NAME" -c user.email="$BOT_EMAIL" \ - commit -m "adopt: update ${target_name}" - git push origin "HEAD:${STATE_BRANCH}" || log "WARN" "Failed to push adopt state" - fi - ) - - git worktree remove "$tmp_dir" 2>/dev/null || rm -rf "$tmp_dir" -} +# Requires: lib/core.sh, lib/config.sh, lib/auth.sh sourced # ─── Adopt Branch ──────────────────────────────────────────────── # Establishes shared ancestry without rewriting the existing subrepo branch. @@ -61,7 +23,11 @@ write_adopt_state() { # # Returns: adopted | already-adopted | tree-mismatch | push-rejected | missing-branch -adopt_branch() { +adopt_branch() ( + # Subshell function: contains the function-level EXIT trap, the internal cd, + # and any set -e propagation so it cannot pollute the caller's shell. Without + # this, the trap below would clobber callers' EXIT handlers (bats's own test + # reporting in particular silently disappears). local mono_branch="$SYNC_BRANCH_MONO" local subrepo_branch="$SYNC_BRANCH_SUBREPO" local work_dir @@ -140,196 +106,4 @@ ${BOT_TRAILER}: adopt/${mono_branch}/$(date -u +%Y-%m-%dT%H:%M:%SZ)") log "WARN" "Fast-forward push rejected — subrepo changed during adoption" echo "push-rejected" fi -} - -# ─── Adopt Flow ────────────────────────────────────────────────── -# Interactive orchestrator with checkpoint/resume. -# Usage: adopt_flow - -adopt_flow() { - local target_json="$1" - local restart="${2:-false}" - local target_name="$JOSH_SYNC_TARGET_NAME" - - local adopt_state current_step - adopt_state=$(read_adopt_state "$target_name") - current_step=$(echo "$adopt_state" | jq -r '.step // "start"') - - if [ "$restart" = true ]; then - log "INFO" "Restarting adopt from scratch" - current_step="start" - adopt_state='{}' - fi - - log "INFO" "Adopt step: ${current_step}" - - if [ "$current_step" = "start" ]; then - echo "" >&2 - echo "=== Adopting ${target_name} ===" >&2 - echo "" >&2 - echo "This keeps the existing subrepo history and adds one adoption merge commit per branch." >&2 - echo "No force-push is used. Existing open PR branches remain based on the old history." >&2 - echo "" >&2 - - adopt_state=$(jq -n \ - --arg step "importing" \ - --arg mode "adopt" \ - --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ - '{step:$step, mode:$mode, import_prs:{}, adopted_branches:[], timestamp:$ts}') - write_adopt_state "$target_name" "$adopt_state" - current_step="importing" - fi - - if [ "$current_step" = "importing" ]; then - echo "" >&2 - log "INFO" "Step 1: Importing current subrepo content into monorepo..." - - local branches import_prs - branches=$(echo "$target_json" | jq -r '.branches | keys[]') - import_prs=$(echo "$adopt_state" | jq -r '.import_prs // {}') - - for branch in $branches; do - local mapped - mapped=$(echo "$target_json" | jq -r --arg b "$branch" '.branches[$b] // empty') - [ -z "$mapped" ] && continue - - if echo "$import_prs" | jq -e --arg b "$branch" 'has($b)' >/dev/null 2>&1; then - log "INFO" "Import PR already recorded for ${branch} — skipping" - continue - fi - - export SYNC_BRANCH_MONO="$branch" - export SYNC_BRANCH_SUBREPO="$mapped" - - local result - result=$(initial_import) - log "INFO" "Import result for ${branch}: ${result}" - - if [ "$result" = "pr-created" ]; then - local prs pr_number - prs=$(list_open_prs "$MONOREPO_API" "$GITEA_TOKEN") - pr_number=$(echo "$prs" | jq -r --arg t "$target_name" --arg b "$branch" \ - '[.[] | select(.title | test("\\[Import\\] " + $t + ":")) | select(.base.ref == $b)] | .[0].number // empty') - - [ -n "$pr_number" ] || die "Could not find import PR number for ${branch}; cannot safely continue adoption" - import_prs=$(echo "$import_prs" | jq --arg b "$branch" --arg n "$pr_number" '. + {($b): ($n | tonumber)}') - log "INFO" "Import PR for ${branch}: #${pr_number}" - fi - - adopt_state=$(echo "$adopt_state" | jq --argjson prs "$import_prs" '.import_prs = $prs') - write_adopt_state "$target_name" "$adopt_state" - done - - adopt_state=$(echo "$adopt_state" | jq \ - --arg step "waiting-for-merge" \ - --argjson prs "$import_prs" \ - '.step = $step | .import_prs = $prs') - write_adopt_state "$target_name" "$adopt_state" - current_step="waiting-for-merge" - fi - - if [ "$current_step" = "waiting-for-merge" ]; then - echo "" >&2 - log "INFO" "Step 2: Waiting for import PR(s) to be merged..." - - local import_prs pr_count - import_prs=$(echo "$adopt_state" | jq -r '.import_prs') - pr_count=$(echo "$import_prs" | jq 'length') - - if [ "$pr_count" -gt 0 ]; then - echo "" >&2 - echo "Import PRs to merge:" >&2 - echo "$import_prs" | jq -r 'to_entries[] | " \(.key): PR #\(.value)"' >&2 - echo "" >&2 - echo "Merge the import PR(s) on the monorepo, then press Enter..." >&2 - read -r - - local all_merged=true - for branch in $(echo "$import_prs" | jq -r 'keys[]'); do - local pr_number pr_json merged - pr_number=$(echo "$import_prs" | jq -r --arg b "$branch" '.[$b]') - pr_json=$(get_pr "$MONOREPO_API" "$GITEA_TOKEN" "$pr_number") - merged=$(echo "$pr_json" | jq -r '.merged // false') - - if [ "$merged" = "true" ]; then - log "INFO" "PR #${pr_number} (${branch}): merged" - else - log "ERROR" "PR #${pr_number} (${branch}): NOT merged — merge it first" - all_merged=false - fi - done - - if [ "$all_merged" = false ]; then - die "Not all import PRs are merged. Re-run 'josh-sync adopt ${target_name}' after merging." - fi - else - log "INFO" "No import PRs recorded — subfolder already matched subrepo" - fi - - adopt_state=$(echo "$adopt_state" | jq '.step = "adopting"') - write_adopt_state "$target_name" "$adopt_state" - current_step="adopting" - fi - - if [ "$current_step" = "adopting" ]; then - echo "" >&2 - log "INFO" "Step 3: Creating adoption merge commit(s)..." - - local branches already_adopted - branches=$(echo "$target_json" | jq -r '.branches | keys[]') - already_adopted=$(echo "$adopt_state" | jq -r '.adopted_branches // []') - - for branch in $branches; do - if echo "$already_adopted" | jq -e --arg b "$branch" 'index($b) != null' >/dev/null 2>&1; then - log "INFO" "Branch ${branch} already adopted — skipping" - continue - fi - - local mapped - mapped=$(echo "$target_json" | jq -r --arg b "$branch" '.branches[$b] // empty') - [ -z "$mapped" ] && continue - - export SYNC_BRANCH_MONO="$branch" - export SYNC_BRANCH_SUBREPO="$mapped" - - local result - result=$(adopt_branch) - log "INFO" "Adopt result for ${branch}: ${result}" - - case "$result" in - adopted|already-adopted) - adopt_state=$(echo "$adopt_state" | jq --arg b "$branch" '.adopted_branches += [$b]') - write_adopt_state "$target_name" "$adopt_state" - ;; - tree-mismatch) - die "Tree mismatch for ${branch}. Merge the import PR and make sure subrepo/${mapped} matches the Josh-filtered monorepo tree." - ;; - push-rejected) - die "Subrepo branch ${mapped} changed during adoption. Re-run 'josh-sync adopt ${target_name}' to retry." - ;; - missing-branch) - die "Subrepo branch ${mapped} does not exist." - ;; - *) - die "Unexpected adopt result: ${result}" - ;; - esac - done - - adopt_state=$(echo "$adopt_state" | jq \ - --arg step "complete" \ - --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ - '.step = $step | .timestamp = $ts') - write_adopt_state "$target_name" "$adopt_state" - current_step="complete" - fi - - if [ "$current_step" = "complete" ]; then - echo "" >&2 - echo "=== Adoption complete! ===" >&2 - echo "" >&2 - echo "The subrepo keeps its existing history and now has a josh-sync adoption merge." >&2 - echo "Developers can keep their clones; they only need to fast-forward active branches:" >&2 - echo " git fetch origin && git checkout main && git merge --ff-only origin/main" >&2 - fi -} +) diff --git a/lib/config.sh b/lib/config.sh index d8655ee..5347796 100644 --- a/lib/config.sh +++ b/lib/config.sh @@ -88,6 +88,15 @@ parse_config() { ) ]') + # Validate targets[].history_lock against the schema enum (preserve|rewrite). + # Caught at config parse time so typos don't sit silently until `onboard` runs. + local bad_lock + bad_lock=$(echo "$JOSH_SYNC_TARGETS" | jq -r ' + .[] | select(.history_lock != null) + | select(.history_lock != "preserve" and .history_lock != "rewrite") + | "\(.name): \(.history_lock)"' | head -1) + [ -z "$bad_lock" ] || die "Invalid targets[].history_lock value (must be 'preserve' or 'rewrite'): ${bad_lock}" + # Load .env credentials (if present, not required — CI sets these via secrets) if [ -f .env ]; then # shellcheck source=/dev/null diff --git a/lib/onboard.sh b/lib/onboard.sh index 1cc6916..0c21369 100644 --- a/lib/onboard.sh +++ b/lib/onboard.sh @@ -1,23 +1,56 @@ #!/usr/bin/env bash -# lib/onboard.sh — Onboard orchestration and PR migration +# lib/onboard.sh — Unified onboard orchestration (reset + adopt strategies) # # Provides: -# onboard_flow() — Interactive: import → wait for merge → reset to new repo -# migrate_one_pr() — Migrate a single PR from archived repo to new repo +# onboard_flow() — Interactive: import → wait → finalize +# finalize = subrepo_reset (rewrite) or +# adopt_branch (preserve) +# resolve_onboard_strategy() — Pick reset|adopt from --mode / config / +# ls-remote auto-detection +# migrate_one_pr() — Migrate one PR from archived to new subrepo # -# Onboard state is stored on the josh-sync-state branch at /onboard.json. -# Steps: start → importing → waiting-for-merge → resetting → complete +# State is stored on the josh-sync-state branch at /onboard.json: +# { step, strategy, import_prs, ... } +# read_onboard_state falls back to legacy /adopt.json (with implicit +# strategy="adopt") so in-flight v2.1 adoptions continue to resume correctly. # -# Requires: lib/core.sh, lib/config.sh, lib/auth.sh, lib/state.sh, lib/sync.sh sourced -# Expects: JOSH_SYNC_TARGET_NAME, BOT_NAME, BOT_EMAIL, SUBREPO_API, SUBREPO_TOKEN, etc. +# Steps: +# start → importing → waiting-for-merge → resetting | adopting → complete +# +# Requires: lib/core.sh, lib/config.sh, lib/auth.sh, lib/state.sh, lib/sync.sh, +# lib/adopt.sh sourced +# Expects: JOSH_SYNC_TARGET_NAME, BOT_NAME, BOT_EMAIL, SUBREPO_API, +# SUBREPO_TOKEN, etc. -# ─── Onboard State Helpers ──────────────────────────────────────── -# Follow the same pattern as read_state()/write_state() in lib/state.sh. +# ─── State Helpers ──────────────────────────────────────────────── +# Same pattern as read_state()/write_state() in lib/state.sh. read_onboard_state() { local target_name="${1:-$JOSH_SYNC_TARGET_NAME}" git fetch origin "$STATE_BRANCH" 2>/dev/null || true - git show "origin/${STATE_BRANCH}:${target_name}/onboard.json" 2>/dev/null || echo '{}' + + local json + json=$(git show "origin/${STATE_BRANCH}:${target_name}/onboard.json" 2>/dev/null || echo "") + if [ -n "$json" ]; then + # Pre-unification onboard.json had no .strategy field — the only flow that + # existed was the destructive reset path; treat missing/empty .strategy as + # reset so resume doesn't silently flip via re-resolution. + echo "$json" | jq '. + {strategy: ((.strategy // "") | if . == "" then "reset" else . end)}' + return + fi + + # Backward-compat: fall back to v2.1 /adopt.json so in-flight + # adoptions started before the unification continue to resume. The legacy + # schema lacks `.strategy`; inject "adopt" so resolve/dispatch downstream + # treats it correctly. Also drop the legacy `.mode` field (v2.1 carried it + # redundantly) to avoid future schema collisions. + json=$(git show "origin/${STATE_BRANCH}:${target_name}/adopt.json" 2>/dev/null || echo "") + if [ -n "$json" ]; then + echo "$json" | jq 'del(.mode) + {strategy: ((.strategy // "") | if . == "" then "adopt" else . end)}' + return + fi + + echo '{}' } write_onboard_state() { @@ -50,18 +83,97 @@ write_onboard_state() { git worktree remove "$tmp_dir" 2>/dev/null || rm -rf "$tmp_dir" } +# Remove the v2.1 /adopt.json from the state branch if present, so +# that a --restart durably clears legacy state on the next read. Called by +# onboard_flow's restart path. +_onboard_state_remove_legacy() { + local target_name="${1:-$JOSH_SYNC_TARGET_NAME}" + git fetch origin "$STATE_BRANCH" 2>/dev/null || true + git show "origin/${STATE_BRANCH}:${target_name}/adopt.json" >/dev/null 2>&1 || return 0 + + local tmp_dir + tmp_dir=$(mktemp -d) + if ! git worktree add "$tmp_dir" "origin/${STATE_BRANCH}" 2>/dev/null; then + rm -rf "$tmp_dir" + return 0 + fi + ( + cd "$tmp_dir" || exit + git rm -q "${target_name}/adopt.json" 2>/dev/null || true + if ! git diff --cached --quiet 2>/dev/null; then + git -c user.name="$BOT_NAME" -c user.email="$BOT_EMAIL" \ + commit -q -m "onboard: drop legacy ${target_name}/adopt.json on --restart" + git push -q origin "HEAD:${STATE_BRANCH}" || log "WARN" "Failed to push legacy adopt.json removal" + fi + ) + git worktree remove "$tmp_dir" 2>/dev/null || rm -rf "$tmp_dir" +} + +# ─── Strategy Resolution ────────────────────────────────────────── +# Precedence (highest first): +# 1. explicit --mode flag +# 2. targets[].history_lock (preserve → adopt, rewrite → reset) +# 3. auto-detect: subrepo has any branches → adopt, otherwise reset +# +# Prints the chosen strategy (reset|adopt) on stdout; logs the source on +# stderr so the preflight line in cmd_onboard surfaces the choice. + +resolve_onboard_strategy() { + local target_json="$1" + local explicit_mode="${2:-}" + + if [ -n "$explicit_mode" ]; then + case "$explicit_mode" in + reset|adopt) ;; + *) die "Invalid --mode '${explicit_mode}' (must be 'reset' or 'adopt')" ;; + esac + log "INFO" "Strategy: ${explicit_mode} (from --mode flag)" + echo "$explicit_mode" + return + fi + + local history_lock + history_lock=$(echo "$target_json" | jq -r '.history_lock // empty') + if [ -n "$history_lock" ]; then + case "$history_lock" in + preserve) + log "INFO" "Strategy: adopt (from config history_lock: preserve)" + echo "adopt"; return ;; + rewrite) + log "INFO" "Strategy: reset (from config history_lock: rewrite)" + echo "reset"; return ;; + *) + die "Invalid history_lock '${history_lock}' in target config (must be 'preserve' or 'rewrite')" ;; + esac + fi + + # Critical: distinguish a genuinely empty subrepo from a failed ls-remote. + # Treating an auth/network failure as "empty" would silently pick reset and + # force-push a live subrepo's history away. + local heads + if heads=$(git ls-remote --heads "$(subrepo_auth_url)" 2>/dev/null); then + if [ -n "$heads" ]; then + log "INFO" "Strategy: adopt (auto-detected — subrepo has branches)" + echo "adopt" + else + log "INFO" "Strategy: reset (auto-detected — subrepo has no branches)" + echo "reset" + fi + else + die "Cannot auto-detect strategy: 'git ls-remote' against the subrepo failed (auth/network). Pass --mode=reset|adopt explicitly, or set targets[].history_lock for this target." + fi +} + # ─── Derive Archived API URL ───────────────────────────────────── # Given a URL like "git@host:org/repo-archived.git" or # "https://host/org/repo-archived.git", derive the Gitea API URL. _archived_api_from_url() { local url="$1" - # Strip .git suffix first — avoids non-greedy regex issues in POSIX ERE url="${url%.git}" local host repo_path if echo "$url" | grep -qE '^(ssh://|git@)'; then - # SSH URL if echo "$url" | grep -q '^ssh://'; then host=$(echo "$url" | sed -E 's|ssh://[^@]*@([^/]+)/.*|\1|') repo_path=$(echo "$url" | sed -E 's|ssh://[^@]*@[^/]+/(.+)$|\1|') @@ -70,7 +182,6 @@ _archived_api_from_url() { repo_path=$(echo "$url" | sed -E 's|git@[^:/]+[:/](.+)$|\1|') fi else - # HTTPS URL host=$(echo "$url" | sed -E 's|https?://([^/]+)/.*|\1|') repo_path=$(echo "$url" | sed -E 's|https?://[^/]+/(.+)$|\1|') fi @@ -80,104 +191,131 @@ _archived_api_from_url() { # ─── Onboard Flow ──────────────────────────────────────────────── # Interactive orchestrator with checkpoint/resume. -# Usage: onboard_flow +# Usage: onboard_flow [explicit_mode] +# explicit_mode — "reset", "adopt", or "" for auto-resolve. onboard_flow() { local target_json="$1" local restart="${2:-false}" + local explicit_mode="${3:-}" local target_name="$JOSH_SYNC_TARGET_NAME" - # Load existing onboard state (or empty) - local onboard_state - onboard_state=$(read_onboard_state "$target_name") - local current_step - current_step=$(echo "$onboard_state" | jq -r '.step // "start"') + local state current_step strategy + state=$(read_onboard_state "$target_name") + current_step=$(echo "$state" | jq -r '.step // "start"') + strategy=$(echo "$state" | jq -r '.strategy // empty') if [ "$restart" = true ]; then log "INFO" "Restarting onboard from scratch" current_step="start" - onboard_state='{}' + state='{}' + strategy="" + # Durably clear any legacy v2.1 adopt.json so the next read doesn't + # silently fall back to it. + _onboard_state_remove_legacy "$target_name" fi + if [ -z "$strategy" ]; then + strategy=$(resolve_onboard_strategy "$target_json" "$explicit_mode") + elif [ -n "$explicit_mode" ] && [ "$strategy" != "$explicit_mode" ]; then + die "Saved strategy is '${strategy}' (mid-flow); cannot switch to --mode=${explicit_mode} without --restart." + else + log "INFO" "Strategy: ${strategy} (resumed from state)" + fi + + case "$strategy" in + reset|adopt) ;; + *) die "Unknown strategy '${strategy}' in state (corrupt?). Re-run with --restart to start over." ;; + esac + log "INFO" "Onboard step: ${current_step}" - # ── Step 1: Prerequisites + archived repo info ── + # ── Step 1: Strategy-specific intro and (for reset) archived repo info ── if [ "$current_step" = "start" ]; then echo "" >&2 - echo "=== Onboarding ${target_name} ===" >&2 - echo "" >&2 - echo "Before proceeding, you should have:" >&2 - echo " 1. Renamed the existing subrepo (e.g., storefront → storefront-archived)" >&2 - echo " 2. Created a new EMPTY repo at the original URL" >&2 + echo "=== Onboarding ${target_name} (strategy: ${strategy}) ===" >&2 echo "" >&2 - # Verify the new (empty) subrepo is reachable (no HEAD ref — works on empty repos) - if git ls-remote "$(subrepo_auth_url)" >/dev/null 2>&1; then - # shellcheck disable=SC2001 # sed is clearer for URL pattern replacement - log "INFO" "New subrepo is reachable at $(echo "$SUBREPO_URL" | sed 's|://[^@]*@|://***@|')" + if [ "$strategy" = "reset" ]; then + echo "Before proceeding, you should have:" >&2 + echo " 1. Renamed the existing subrepo (e.g., storefront → storefront-archived)" >&2 + echo " 2. Created a new EMPTY repo at the original URL" >&2 + echo "" >&2 + + if git ls-remote "$(subrepo_auth_url)" >/dev/null 2>&1; then + # shellcheck disable=SC2001 # sed is clearer for URL pattern replacement + log "INFO" "New subrepo is reachable at $(echo "$SUBREPO_URL" | sed 's|://[^@]*@|://***@|')" + else + log "WARN" "New subrepo is not reachable — make sure you created the new empty repo" + fi + + echo "Enter the archived repo URL (e.g., git@host:org/repo-archived.git):" >&2 + local archived_url + read -r archived_url + [ -n "$archived_url" ] || die "Archived URL is required" + + local archived_auth="${SUBREPO_AUTH:-https}" + local archived_api + archived_api=$(_archived_api_from_url "$archived_url") + + if curl -sf -H "Authorization: token ${SUBREPO_TOKEN}" \ + "${archived_api}" >/dev/null 2>&1; then + log "INFO" "Archived repo reachable: ${archived_api}" + else + log "WARN" "Cannot reach archived repo API — check URL and token" + echo "Continue anyway? (y/N):" >&2 + local confirm + read -r confirm + [ "$confirm" = "y" ] || [ "$confirm" = "Y" ] || die "Aborted" + fi + + state=$(jq -n \ + --arg step "importing" \ + --arg strategy "reset" \ + --arg archived_api "$archived_api" \ + --arg archived_url "$archived_url" \ + --arg archived_auth "$archived_auth" \ + --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + '{step:$step, strategy:$strategy, archived_api:$archived_api, + archived_url:$archived_url, archived_auth:$archived_auth, + import_prs:{}, reset_branches:[], migrated_prs:[], timestamp:$ts}') else - log "WARN" "New subrepo is not reachable — make sure you created the new empty repo" + # strategy = adopt + echo "This keeps the existing subrepo history and adds one adoption merge commit per branch." >&2 + echo "No force-push is used. Existing open PR branches remain based on the old history." >&2 + echo "" >&2 + + state=$(jq -n \ + --arg step "importing" \ + --arg strategy "adopt" \ + --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + '{step:$step, strategy:$strategy, import_prs:{}, adopted_branches:[], timestamp:$ts}') fi - echo "Enter the archived repo URL (e.g., git@host:org/repo-archived.git):" >&2 - local archived_url - read -r archived_url - [ -n "$archived_url" ] || die "Archived URL is required" - - # Determine auth type for archived repo (same as current subrepo) - local archived_auth="${SUBREPO_AUTH:-https}" - - # Derive API URL - local archived_api - archived_api=$(_archived_api_from_url "$archived_url") - - # Verify archived repo is reachable via API - if curl -sf -H "Authorization: token ${SUBREPO_TOKEN}" \ - "${archived_api}" >/dev/null 2>&1; then - log "INFO" "Archived repo reachable: ${archived_api}" - else - log "WARN" "Cannot reach archived repo API — check URL and token" - echo "Continue anyway? (y/N):" >&2 - local confirm - read -r confirm - [ "$confirm" = "y" ] || [ "$confirm" = "Y" ] || die "Aborted" - fi - - # Save state - onboard_state=$(jq -n \ - --arg step "importing" \ - --arg archived_api "$archived_api" \ - --arg archived_url "$archived_url" \ - --arg archived_auth "$archived_auth" \ - --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ - '{step:$step, archived_api:$archived_api, archived_url:$archived_url, - archived_auth:$archived_auth, import_prs:{}, reset_branches:[], - migrated_prs:[], timestamp:$ts}') - write_onboard_state "$target_name" "$onboard_state" + write_onboard_state "$target_name" "$state" current_step="importing" fi - # ── Step 2: Import (reuses initial_import()) ── + # ── Step 2: Import (reset uses archived clone URL; adopt uses subrepo URL) ── if [ "$current_step" = "importing" ]; then echo "" >&2 log "INFO" "Step 2: Importing subrepo content into monorepo..." - local branches + local branches import_prs branches=$(echo "$target_json" | jq -r '.branches | keys[]') + import_prs=$(echo "$state" | jq -r '.import_prs // {}') - # Load existing import_prs from state (resume support) - local import_prs - import_prs=$(echo "$onboard_state" | jq -r '.import_prs // {}') - - # Build the archived repo clone URL for initial_import(). - # The content lives in the archived repo — the new repo at SUBREPO_URL is empty. - local archived_url archived_clone_url - archived_url=$(echo "$onboard_state" | jq -r '.archived_url') - if [ "${SUBREPO_AUTH:-https}" = "ssh" ]; then - archived_clone_url="$archived_url" - else - # shellcheck disable=SC2001 - archived_clone_url=$(echo "$archived_url" | sed "s|https://|https://${BOT_USER}:${SUBREPO_TOKEN}@|") + local archived_clone_url="" + if [ "$strategy" = "reset" ]; then + local archived_url + archived_url=$(echo "$state" | jq -r '.archived_url // empty') + [ -n "$archived_url" ] || die "Reset strategy: archived_url missing from state (corrupt or interrupted before start step finished). Re-run with --restart." + if [ "${SUBREPO_AUTH:-https}" = "ssh" ]; then + archived_clone_url="$archived_url" + else + # shellcheck disable=SC2001 + archived_clone_url=$(echo "$archived_url" | sed "s|https://|https://${BOT_USER}:${SUBREPO_TOKEN}@|") + fi fi for branch in $branches; do @@ -185,7 +323,6 @@ onboard_flow() { mapped=$(echo "$target_json" | jq -r --arg b "$branch" '.branches[$b] // empty') [ -z "$mapped" ] && continue - # Skip branches that already have an import PR recorded if echo "$import_prs" | jq -e --arg b "$branch" 'has($b)' >/dev/null 2>&1; then log "INFO" "Import PR already recorded for ${branch} — skipping" continue @@ -196,11 +333,14 @@ onboard_flow() { log "INFO" "Importing branch: ${branch} (subrepo: ${mapped})" local result - result=$(initial_import "$archived_clone_url") + if [ -n "$archived_clone_url" ]; then + result=$(initial_import "$archived_clone_url") + else + result=$(initial_import) + fi log "INFO" "Import result for ${branch}: ${result}" if [ "$result" = "pr-created" ]; then - # Find the import PR number via API local prs pr_number prs=$(list_open_prs "$MONOREPO_API" "$GITEA_TOKEN") pr_number=$(echo "$prs" | jq -r --arg t "$target_name" --arg b "$branch" \ @@ -210,37 +350,35 @@ onboard_flow() { import_prs=$(echo "$import_prs" | jq --arg b "$branch" --arg n "$pr_number" '. + {($b): ($n | tonumber)}') log "INFO" "Import PR for ${branch}: #${pr_number}" else - log "WARN" "Could not find import PR number for ${branch} — check monorepo PRs" + # Same hazard for both strategies: continuing without recording the + # PR number means the next resume re-runs initial_import and creates + # a duplicate import PR. Bail out and let the user reconcile. + die "Could not find import PR number for ${branch} on monorepo. Re-running would create a duplicate import PR — check the monorepo's open PRs first." fi fi - # Save progress after each branch (resume support) - onboard_state=$(echo "$onboard_state" | jq --argjson prs "$import_prs" '.import_prs = $prs') - write_onboard_state "$target_name" "$onboard_state" + state=$(echo "$state" | jq --argjson prs "$import_prs" '.import_prs = $prs') + write_onboard_state "$target_name" "$state" done - # Update state - onboard_state=$(echo "$onboard_state" | jq \ + state=$(echo "$state" | jq \ --arg step "waiting-for-merge" \ --argjson prs "$import_prs" \ '.step = $step | .import_prs = $prs') - write_onboard_state "$target_name" "$onboard_state" + write_onboard_state "$target_name" "$state" current_step="waiting-for-merge" fi - # ── Step 3: Wait for merge ── + # ── Step 3: Wait for import PR(s) to merge ── if [ "$current_step" = "waiting-for-merge" ]; then echo "" >&2 log "INFO" "Step 3: Waiting for import PR(s) to be merged..." - local import_prs - import_prs=$(echo "$onboard_state" | jq -r '.import_prs') - local pr_count + local import_prs pr_count + import_prs=$(echo "$state" | jq -r '.import_prs') pr_count=$(echo "$import_prs" | jq 'length') - if [ "$pr_count" -eq 0 ]; then - log "WARN" "No import PRs recorded — skipping merge check" - else + if [ "$pr_count" -gt 0 ]; then echo "" >&2 echo "Import PRs to merge:" >&2 echo "$import_prs" | jq -r 'to_entries[] | " \(.key): PR #\(.value)"' >&2 @@ -248,12 +386,10 @@ onboard_flow() { echo "Merge the import PR(s) on the monorepo, then press Enter..." >&2 read -r - # Verify each PR is merged local all_merged=true for branch in $(echo "$import_prs" | jq -r 'keys[]'); do - local pr_number + local pr_number pr_json merged pr_number=$(echo "$import_prs" | jq -r --arg b "$branch" '.[$b]') - local pr_json merged pr_json=$(get_pr "$MONOREPO_API" "$GITEA_TOKEN" "$pr_number") merged=$(echo "$pr_json" | jq -r '.merged // false') @@ -268,26 +404,31 @@ onboard_flow() { if [ "$all_merged" = false ]; then die "Not all import PRs are merged. Re-run 'josh-sync onboard ${target_name}' after merging." fi + else + log "INFO" "No import PRs recorded — subfolder already matched subrepo" fi - # Update state - onboard_state=$(echo "$onboard_state" | jq '.step = "resetting"') - write_onboard_state "$target_name" "$onboard_state" - current_step="resetting" + local next_step + if [ "$strategy" = "reset" ]; then + next_step="resetting" + else + next_step="adopting" + fi + state=$(echo "$state" | jq --arg s "$next_step" '.step = $s') + write_onboard_state "$target_name" "$state" + current_step="$next_step" fi - # ── Step 4: Reset (pushes josh-filtered history to new repo) ── + # ── Step 4a: Finalize via reset (force-push josh-filtered history) ── if [ "$current_step" = "resetting" ]; then echo "" >&2 log "INFO" "Step 4: Pushing josh-filtered history to new subrepo..." - local branches + local branches already_reset branches=$(echo "$target_json" | jq -r '.branches | keys[]') - local already_reset - already_reset=$(echo "$onboard_state" | jq -r '.reset_branches // []') + already_reset=$(echo "$state" | jq -r '.reset_branches // []') for branch in $branches; do - # Skip branches already reset (resume support) if echo "$already_reset" | jq -e --arg b "$branch" 'index($b) != null' >/dev/null 2>&1; then log "INFO" "Branch ${branch} already reset — skipping" continue @@ -304,18 +445,69 @@ onboard_flow() { result=$(subrepo_reset) log "INFO" "Reset result for ${branch}: ${result}" - # Track progress - onboard_state=$(echo "$onboard_state" | jq --arg b "$branch" \ - '.reset_branches += [$b]') - write_onboard_state "$target_name" "$onboard_state" + state=$(echo "$state" | jq --arg b "$branch" '.reset_branches += [$b]') + write_onboard_state "$target_name" "$state" done - # Update state - onboard_state=$(echo "$onboard_state" | jq \ + state=$(echo "$state" | jq \ --arg step "complete" \ --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ '.step = $step | .timestamp = $ts') - write_onboard_state "$target_name" "$onboard_state" + write_onboard_state "$target_name" "$state" + current_step="complete" + fi + + # ── Step 4b: Finalize via adoption merge ── + if [ "$current_step" = "adopting" ]; then + echo "" >&2 + log "INFO" "Step 4: Creating adoption merge commit(s)..." + + local branches already_adopted + branches=$(echo "$target_json" | jq -r '.branches | keys[]') + already_adopted=$(echo "$state" | jq -r '.adopted_branches // []') + + for branch in $branches; do + if echo "$already_adopted" | jq -e --arg b "$branch" 'index($b) != null' >/dev/null 2>&1; then + log "INFO" "Branch ${branch} already adopted — skipping" + continue + fi + + local mapped + mapped=$(echo "$target_json" | jq -r --arg b "$branch" '.branches[$b] // empty') + [ -z "$mapped" ] && continue + + export SYNC_BRANCH_MONO="$branch" + export SYNC_BRANCH_SUBREPO="$mapped" + + local result + result=$(adopt_branch) + log "INFO" "Adopt result for ${branch}: ${result}" + + case "$result" in + adopted|already-adopted) + state=$(echo "$state" | jq --arg b "$branch" '.adopted_branches += [$b]') + write_onboard_state "$target_name" "$state" + ;; + tree-mismatch) + die "Tree mismatch for ${branch}. Merge the import PR and make sure subrepo/${mapped} matches the Josh-filtered monorepo tree." + ;; + push-rejected) + die "Subrepo branch ${mapped} changed during adoption. Re-run 'josh-sync onboard ${target_name} --mode=adopt' to retry." + ;; + missing-branch) + die "Subrepo branch ${mapped} does not exist." + ;; + *) + die "Unexpected adopt result: ${result}" + ;; + esac + done + + state=$(echo "$state" | jq \ + --arg step "complete" \ + --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + '.step = $step | .timestamp = $ts') + write_onboard_state "$target_name" "$state" current_step="complete" fi @@ -324,14 +516,20 @@ onboard_flow() { echo "" >&2 echo "=== Onboarding complete! ===" >&2 echo "" >&2 - echo "The new subrepo now has josh-filtered history." >&2 - echo "Developers should re-clone or reset their local copies:" >&2 - echo " git fetch origin && git reset --hard origin/main" >&2 - echo "" >&2 - echo "To migrate open PRs from the archived repo:" >&2 - echo " josh-sync migrate-pr ${target_name} # interactive picker" >&2 - echo " josh-sync migrate-pr ${target_name} --all # migrate all" >&2 - echo " josh-sync migrate-pr ${target_name} 5 8 12 # specific PRs" >&2 + if [ "$strategy" = "reset" ]; then + echo "The new subrepo now has josh-filtered history." >&2 + echo "Developers should re-clone or reset their local copies:" >&2 + echo " git fetch origin && git reset --hard origin/main" >&2 + echo "" >&2 + echo "To migrate open PRs from the archived repo:" >&2 + echo " josh-sync migrate-pr ${target_name} # interactive picker" >&2 + echo " josh-sync migrate-pr ${target_name} --all # migrate all" >&2 + echo " josh-sync migrate-pr ${target_name} 5 8 12 # specific PRs" >&2 + else + echo "The subrepo keeps its existing history and now has a josh-sync adoption merge." >&2 + echo "Developers can keep their clones; they only need to fast-forward active branches:" >&2 + echo " git fetch origin && git checkout main && git merge --ff-only origin/main" >&2 + fi fi } @@ -346,15 +544,17 @@ migrate_one_pr() { local pr_number="$1" local target_name="$JOSH_SYNC_TARGET_NAME" - # Read archived repo info from onboard state - local onboard_state archived_api + local onboard_state strategy archived_api onboard_state=$(read_onboard_state "$target_name") + strategy=$(echo "$onboard_state" | jq -r '.strategy // "reset"') + if [ "$strategy" = "adopt" ]; then + die "Target '${target_name}' was onboarded with the adopt strategy, which does not record an archived repo. migrate-pr only applies to the reset strategy." + fi archived_api=$(echo "$onboard_state" | jq -r '.archived_api') if [ -z "$archived_api" ] || [ "$archived_api" = "null" ]; then - die "No archived repo info found. Run 'josh-sync onboard ${target_name}' first." + die "No archived repo info found for '${target_name}'. Run 'josh-sync onboard ${target_name} --mode=reset' first." fi - # Check if this PR was already migrated local already_migrated already_migrated=$(echo "$onboard_state" | jq -r \ --argjson num "$pr_number" '.migrated_prs // [] | map(select(.old_number == $num)) | length') @@ -363,10 +563,8 @@ migrate_one_pr() { return 0 fi - # Same credentials — the repo was just renamed local archived_token="$SUBREPO_TOKEN" - # 1. Get PR metadata from archived repo local pr_json title base head body pr_json=$(get_pr "$archived_api" "$archived_token" "$pr_number") \ || die "Failed to fetch PR #${pr_number} from archived repo" @@ -377,8 +575,6 @@ migrate_one_pr() { log "INFO" "Migrating PR #${pr_number}: \"${title}\" (${base} <- ${head})" - # 2. Clone new subrepo, add archived repo as second remote - # Save cwd so we can restore it (function runs in caller's shell, not subshell) local original_dir original_dir=$(pwd) @@ -394,7 +590,6 @@ migrate_one_pr() { git config user.name "$BOT_NAME" git config user.email "$BOT_EMAIL" - # Build authenticated URL for the archived repo local archived_url archived_clone_url archived_url=$(echo "$onboard_state" | jq -r '.archived_url') if [ "${SUBREPO_AUTH:-https}" = "ssh" ]; then @@ -404,12 +599,10 @@ migrate_one_pr() { archived_clone_url=$(echo "$archived_url" | sed "s|https://|https://${BOT_USER}:${SUBREPO_TOKEN}@|") fi - # Fetch the PR's head and base branches from the archived repo git remote add archived "$archived_clone_url" git fetch archived "$head" "$base" 2>&1 \ || die "Failed to fetch branches from archived repo" - # 3. Compute diff locally and apply with --3way git checkout -B "$head" >&2 local diff @@ -428,13 +621,11 @@ Migrated from archived repo PR #${pr_number}" >&2 git push "$(subrepo_auth_url)" "$head" >&2 \ || die "Failed to push branch ${head}" - # 4. Create PR on new repo local new_number new_number=$(create_pr_number "$SUBREPO_API" "$SUBREPO_TOKEN" \ "$base" "$head" "$title" "$body") log "INFO" "Migrated PR #${pr_number} -> #${new_number}: \"${title}\"" - # 5. Record in onboard state cd "$original_dir" || true onboard_state=$(read_onboard_state "$target_name") onboard_state=$(echo "$onboard_state" | jq \ diff --git a/schema/config-schema.json b/schema/config-schema.json index eff6966..da8b169 100644 --- a/schema/config-schema.json +++ b/schema/config-schema.json @@ -91,6 +91,11 @@ "items": { "type": "string" }, "default": [], "description": "File/directory patterns to exclude from sync via josh :exclude filter. Josh pattern syntax: 'dir/' for directories, '*.ext' for globs, '**/dir/' for nested matches. Patterns are embedded inline in the josh-proxy URL." + }, + "history_lock": { + "type": "string", + "enum": ["rewrite", "preserve"], + "description": "Onboarding strategy override. 'preserve' selects the non-destructive adopt strategy (keeps subrepo history via an adoption merge); 'rewrite' selects the destructive reset strategy (force-pushes josh-filtered history). When omitted, `josh-sync onboard` auto-detects: subrepos with branches → adopt, empty subrepos → reset. The CLI flag `--mode={reset,adopt}` overrides this." } } } diff --git a/tests/unit/adopt_e2e.bats b/tests/unit/adopt_e2e.bats new file mode 100644 index 0000000..a566829 --- /dev/null +++ b/tests/unit/adopt_e2e.bats @@ -0,0 +1,697 @@ +#!/usr/bin/env bats + +bats_require_minimum_version 1.5.0 + +# tests/unit/adopt_e2e.bats — End-to-end adopt behaviour and ADR cross-cuts +# +# Covers the goal-1 scenarios from the adopt unification plan: +# - tree-equality rejection (clear hint + no commit on subrepo) +# - already-adopted idempotency +# - merge commit shape (ADR-005 trailer, ADR-008 first-parent walk) +# - fast-forward push semantics (success + push-rejected surfacing) +# - reverse-sync loop guard (ADR-005) +# - linearize-fallback selection invariant (ADR-011) +# - checkpoint/resume at each adopt_flow step (ADR-010) +# +# Tests run against real local bare repos — no mocking of the git layer. + +setup() { + export JOSH_SYNC_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" + source "$JOSH_SYNC_ROOT/lib/core.sh" + source "$JOSH_SYNC_ROOT/lib/state.sh" + source "$JOSH_SYNC_ROOT/lib/auth.sh" + source "$JOSH_SYNC_ROOT/lib/adopt.sh" + source "$JOSH_SYNC_ROOT/lib/onboard.sh" + + TEST_ROOT="$(mktemp -d)" + export BOT_NAME="test-bot" + export BOT_EMAIL="test-bot@test.local" + export BOT_TRAILER="Josh-Sync-Origin" + export JOSH_SYNC_TARGET_NAME="example" + export SYNC_BRANCH_MONO="main" + export SYNC_BRANCH_SUBREPO="main" + + # Test-local auth shims must be defined AFTER sourcing lib/auth.sh, otherwise + # the lib's real subrepo_auth_url / josh_auth_url (which need SUBREPO_URL, + # BOT_USER, etc.) overrides our bare-repo shortcut. + subrepo_auth_url() { printf '%s\n' "${SUBREPO_BARE:-}"; } + josh_auth_url() { printf '%s\n' "${JOSH_BARE:-}"; } +} + +teardown() { + # Restore cwd before the rm — setup_state_monorepo cd's into a dir that's + # about to be deleted, and bats doesn't reset cwd between tests. + cd "$BATS_TEST_DIRNAME" 2>/dev/null || true + rm -rf "$TEST_ROOT" +} + +# ─── Helpers ──────────────────────────────────────────────────────── + +# Build a fresh bare repo seeded with one commit; returns the bare path on stdout. +make_bare_repo() { + local name="$1" + local content="$2" + local message="$3" + local work="${TEST_ROOT}/${name}-work" + local bare="${TEST_ROOT}/${name}.git" + + git init "$work" >/dev/null + git -C "$work" checkout -b main >/dev/null + git -C "$work" config user.name "Test User" + git -C "$work" config user.email "test@test.local" + printf '%s\n' "$content" > "${work}/README.md" + git -C "$work" add README.md + git -C "$work" commit -m "$message" >/dev/null + + git init --bare "$bare" >/dev/null + # Make HEAD on the bare point at main so later clones don't land on master. + git -C "$bare" symbolic-ref HEAD refs/heads/main + git -C "$work" remote add origin "$bare" + git -C "$work" push origin main >/dev/null + printf '%s\n' "$bare" +} + +# Append a commit to an existing bare repo. If $3 is empty, makes an empty commit +# (tree unchanged) so first-parent walks have something to observe. +add_commit_to_bare() { + local bare="$1" + local message="$2" + local content="${3:-}" + local work="${TEST_ROOT}/add-${RANDOM}-$$" + git clone "$bare" "$work" >/dev/null 2>&1 + git -C "$work" config user.name "Test User" + git -C "$work" config user.email "test@test.local" + if [ -n "$content" ]; then + printf '%s\n' "$content" >> "${work}/README.md" + git -C "$work" commit -am "$message" >/dev/null + else + git -C "$work" commit --allow-empty -m "$message" >/dev/null + fi + git -C "$work" push origin main >/dev/null 2>&1 +} + +# Stand up a local working "monorepo" with an `origin` bare remote and cd into +# it. Gives lib/state.sh helpers (worktree + push to origin) something real to +# work against. +setup_state_monorepo() { + local mono_work="${TEST_ROOT}/mono" + local mono_bare="${TEST_ROOT}/mono.git" + git init -q "$mono_work" + git -C "$mono_work" checkout -q -b main + git -C "$mono_work" config user.name "Test User" + git -C "$mono_work" config user.email "test@test.local" + printf 'mono\n' > "${mono_work}/README.md" + git -C "$mono_work" add README.md + git -C "$mono_work" commit -q -m "initial" + git init -q --bare "$mono_bare" + git -C "$mono_work" remote add origin "$mono_bare" + git -C "$mono_work" push -q origin main + cd "$mono_work" +} + +# adopt_branch is defined as a subshell function (`adopt_branch() ( ... )`) in +# lib/adopt.sh so its EXIT trap stays contained — no helper needed here. +adopt_branch_quietly() { + adopt_branch >/dev/null +} + +# ─── Tree-mismatch surfacing ──────────────────────────────────────── + +@test "adopt_branch tree-mismatch error message points the user at the import PR" { + SUBREPO_BARE="$(make_bare_repo subrepo "subrepo content" "subrepo history")" + JOSH_BARE="$(make_bare_repo josh "josh content" "josh filtered history")" + + local combined + combined=$(adopt_branch 2>&1) + + [[ "$combined" == *"merge the import PR"* ]] +} + +@test "adopt_branch tree-mismatch creates no commit on the subrepo (clean abort)" { + SUBREPO_BARE="$(make_bare_repo subrepo "subrepo content" "subrepo history")" + JOSH_BARE="$(make_bare_repo josh "josh content" "josh filtered history")" + + local before_count before_head after_count after_head + before_count=$(git --git-dir="$SUBREPO_BARE" rev-list --count main) + before_head=$(git --git-dir="$SUBREPO_BARE" rev-parse main) + + adopt_branch_quietly 2>&1 + + after_count=$(git --git-dir="$SUBREPO_BARE" rev-list --count main) + after_head=$(git --git-dir="$SUBREPO_BARE" rev-parse main) + [ "$before_count" = "$after_count" ] + [ "$before_head" = "$after_head" ] +} + +# ─── Idempotency ──────────────────────────────────────────────────── + +@test "adopt_branch is idempotent — second invocation returns already-adopted, no new commit" { + SUBREPO_BARE="$(make_bare_repo subrepo "same tree" "subrepo history")" + JOSH_BARE="$(make_bare_repo josh "same tree" "josh filtered history")" + + local first_result first_head first_count + first_result=$(adopt_branch) + first_head=$(git --git-dir="$SUBREPO_BARE" rev-parse main) + first_count=$(git --git-dir="$SUBREPO_BARE" rev-list --count main) + + local second_result second_head second_count + second_result=$(adopt_branch) + second_head=$(git --git-dir="$SUBREPO_BARE" rev-parse main) + second_count=$(git --git-dir="$SUBREPO_BARE" rev-list --count main) + + [ "$first_result" = "adopted" ] + [ "$second_result" = "already-adopted" ] + [ "$first_head" = "$second_head" ] + [ "$first_count" = "$second_count" ] +} + +# ─── Merge commit shape ───────────────────────────────────────────── + +@test "adopt_branch merge carries the configured bot trailer (ADR-005)" { + SUBREPO_BARE="$(make_bare_repo subrepo "same tree" "subrepo history")" + JOSH_BARE="$(make_bare_repo josh "same tree" "josh filtered history")" + + adopt_branch_quietly + + local merge_msg + merge_msg=$(git --git-dir="$SUBREPO_BARE" log -1 --format=%B main) + [[ "$merge_msg" == *"${BOT_TRAILER}: adopt/${SYNC_BRANCH_MONO}/"* ]] +} + +@test "adopt_branch merge places josh-filtered HEAD as parent 1 — first-parent walk reaches monorepo (ADR-008)" { + SUBREPO_BARE="$(make_bare_repo subrepo "same tree" "subrepo c1")" + add_commit_to_bare "$SUBREPO_BARE" "subrepo c2" + + JOSH_BARE="$(make_bare_repo josh "same tree" "josh c1")" + add_commit_to_bare "$JOSH_BARE" "josh c2" + + adopt_branch_quietly + + # First-parent walk from the adoption merge follows the josh chain (parent 1) + # and never visits subrepo-only history. If parent ordering ever flipped, the + # subrepo commits would appear here — which is exactly the failure mode that + # corrupted the monorepo branch in ADR-008. + local first_parent_msgs + first_parent_msgs=$(git --git-dir="$SUBREPO_BARE" log --first-parent --format=%s main) + [[ "$first_parent_msgs" == *"josh c1"* ]] + [[ "$first_parent_msgs" == *"josh c2"* ]] + [[ "$first_parent_msgs" != *"subrepo c1"* ]] + [[ "$first_parent_msgs" != *"subrepo c2"* ]] +} + +# ─── Fast-forward push semantics ──────────────────────────────────── + +@test "adopt_branch fast-forwards without --force, leaving old subrepo HEAD reachable" { + SUBREPO_BARE="$(make_bare_repo subrepo "same tree" "subrepo history")" + JOSH_BARE="$(make_bare_repo josh "same tree" "josh filtered history")" + + local old_head result new_head + old_head=$(git --git-dir="$SUBREPO_BARE" rev-parse main) + result=$(adopt_branch) + new_head=$(git --git-dir="$SUBREPO_BARE" rev-parse main) + + [ "$result" = "adopted" ] + [ "$new_head" != "$old_head" ] + # The original subrepo HEAD must still be an ancestor of the new HEAD — + # nothing was rewritten. This is what makes the push a real fast-forward. + git --git-dir="$SUBREPO_BARE" merge-base --is-ancestor "$old_head" "$new_head" +} + +@test "adopt_branch surfaces push-rejected when the bare subrepo rejects the push (simulated 'subrepo moved')" { + SUBREPO_BARE="$(make_bare_repo subrepo "same tree" "subrepo history")" + JOSH_BARE="$(make_bare_repo josh "same tree" "josh filtered history")" + + # Equivalent observable behaviour to "subrepo HEAD changed mid-flight": the + # fast-forward push fails. Install a pre-receive hook that rejects + # deterministically — adopt_branch's else-branch should surface push-rejected. + printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'echo "subrepo changed during adoption" >&2' \ + 'exit 1' \ + > "${SUBREPO_BARE}/hooks/pre-receive" + chmod +x "${SUBREPO_BARE}/hooks/pre-receive" + + local before_head result after_head + before_head=$(git --git-dir="$SUBREPO_BARE" rev-parse main) + result=$(adopt_branch 2>/dev/null) + after_head=$(git --git-dir="$SUBREPO_BARE" rev-parse main) + + [ "$result" = "push-rejected" ] + [ "$before_head" = "$after_head" ] +} + +# ─── Reverse-sync loop guard (ADR-005) ────────────────────────────── + +@test "adoption merge is invisible to reverse-sync bot-trailer filter (no re-ingestion loop)" { + SUBREPO_BARE="$(make_bare_repo subrepo "same tree" "subrepo history")" + JOSH_BARE="$(make_bare_repo josh "same tree" "josh filtered history")" + + local josh_head + josh_head=$(git --git-dir="$JOSH_BARE" rev-parse main) + + adopt_branch_quietly + + # Mirror reverse_sync's exact query (lib/sync.sh): + # base = merge-base(mono-filtered/, HEAD) + # human_commits = git log --ancestry-path "$base..HEAD" \ + # --invert-grep --grep="^${BOT_TRAILER}:" + # --ancestry-path is critical: without it, the subrepo's parent-2 orphan + # commit (no trailer, real user commit) would show up as a "human commit" + # and reverse sync would try to re-ingest the adoption merge's other side. + local merge_sha base human_commits + merge_sha=$(git --git-dir="$SUBREPO_BARE" rev-parse main) + base=$(git --git-dir="$SUBREPO_BARE" merge-base "$josh_head" "$merge_sha") + human_commits=$(git --git-dir="$SUBREPO_BARE" log \ + --ancestry-path "${base}..${merge_sha}" \ + --invert-grep --grep="^${BOT_TRAILER}:" \ + --oneline) + + [ -z "$human_commits" ] +} + +# ─── Linearize fallback (ADR-011) ─────────────────────────────────── + +@test "linearize-fallback commit selection excludes the adopt merge but keeps human commits on top" { + SUBREPO_BARE="$(make_bare_repo subrepo "same tree" "subrepo history")" + JOSH_BARE="$(make_bare_repo josh "same tree" "josh filtered history")" + + local josh_head + josh_head=$(git --git-dir="$JOSH_BARE" rev-parse main) + + adopt_branch_quietly + + # Real human commit on top of the adoption merge. + add_commit_to_bare "$SUBREPO_BARE" "human: add note" "human change" + + # Replicate reverse_sync's linearize walk exactly (lib/sync.sh): + # base = merge-base(mono-filtered/, HEAD) + # commits = git log --ancestry-path "$base..HEAD" --reverse \ + # --format=%H --invert-grep --grep="^${BOT_TRAILER}:" + # With messy upstream (adopt-merge in the path), linearize must skip the + # merge (trailer-filtered) and cherry-pick only the human commit. + local work base picked + work="${TEST_ROOT}/linearize-walk" + git clone "$SUBREPO_BARE" "$work" >/dev/null 2>&1 + base=$(git -C "$work" merge-base "$josh_head" HEAD) + picked=$(git -C "$work" log --ancestry-path "${base}..HEAD" \ + --reverse --format=%s --invert-grep --grep="^${BOT_TRAILER}:") + + [[ "$picked" == *"human: add note"* ]] + [[ "$picked" != *"Adopt ${JOSH_SYNC_TARGET_NAME} for josh-sync"* ]] +} + +# ─── Checkpoint / resume (ADR-010) — strategy=adopt path through onboard_flow + +# Stubs that run inside $(...) subshells can't increment shell variables in +# the parent. Track invocations via append-only files instead and count bytes. +call_log() { printf '.' >> "${TEST_ROOT}/${1}.log"; } +call_count() { + local f="${TEST_ROOT}/${1}.log" + if [ -f "$f" ]; then wc -c < "$f" | tr -d ' '; else echo 0; fi +} + +@test "write_onboard_state then read_onboard_state round-trips the JSON" { + setup_state_monorepo + + write_onboard_state "$JOSH_SYNC_TARGET_NAME" \ + '{"step":"adopting","strategy":"adopt","import_prs":{"main":42},"adopted_branches":[]}' + + local got + got=$(read_onboard_state "$JOSH_SYNC_TARGET_NAME") + [ "$(echo "$got" | jq -r '.step')" = "adopting" ] + [ "$(echo "$got" | jq -r '.strategy')" = "adopt" ] + [ "$(echo "$got" | jq -r '.import_prs.main')" = "42" ] +} + +@test "read_onboard_state falls back to legacy /adopt.json with implicit strategy=adopt" { + setup_state_monorepo + + # Simulate a state branch where only the v2.1-era adopt.json exists (e.g., + # an adoption started before the unification). Write directly to the state + # branch under the legacy filename, without the new strategy field. + local tmp + tmp=$(mktemp -d) + git worktree add --detach "$tmp" >/dev/null 2>&1 + ( cd "$tmp" && git checkout -q --orphan "$STATE_BRANCH" && { git rm -rfq . 2>/dev/null || true; } + mkdir -p "$JOSH_SYNC_TARGET_NAME" + printf '%s\n' '{"step":"adopting","import_prs":{},"adopted_branches":[]}' \ + > "${JOSH_SYNC_TARGET_NAME}/adopt.json" + git add -A + git -c user.name="$BOT_NAME" -c user.email="$BOT_EMAIL" \ + commit -q -m "legacy adopt state" + git push -q origin "HEAD:${STATE_BRANCH}" ) + git worktree remove "$tmp" 2>/dev/null || rm -rf "$tmp" + + local got + got=$(read_onboard_state "$JOSH_SYNC_TARGET_NAME") + [ "$(echo "$got" | jq -r '.step')" = "adopting" ] + [ "$(echo "$got" | jq -r '.strategy')" = "adopt" ] +} + +@test "onboard_flow resumes from step=importing (strategy=adopt) — runs import, falls through wait, finalizes via adopt_branch" { + setup_state_monorepo + write_onboard_state "$JOSH_SYNC_TARGET_NAME" \ + '{"step":"importing","strategy":"adopt","import_prs":{},"adopted_branches":[]}' + + initial_import() { call_log import; echo "skip"; } + adopt_branch() { call_log adopt; echo "adopted"; } + subrepo_reset() { call_log reset; echo "reset"; } + get_pr() { echo '{"merged":true}'; } + list_open_prs() { echo '[]'; } + + local target_json='{"name":"example","branches":{"main":"main"}}' + onboard_flow "$target_json" "false" "" >/dev/null 2>&1 + + [ "$(call_count import)" = "1" ] + [ "$(call_count adopt)" = "1" ] + [ "$(call_count reset)" = "0" ] + local final + final=$(read_onboard_state "$JOSH_SYNC_TARGET_NAME") + [ "$(echo "$final" | jq -r '.step')" = "complete" ] + [ "$(echo "$final" | jq -r '.strategy')" = "adopt" ] +} + +@test "onboard_flow resumes from step=waiting-for-merge (strategy=adopt) — confirms merged PR, finalizes via adopt_branch" { + setup_state_monorepo + write_onboard_state "$JOSH_SYNC_TARGET_NAME" \ + '{"step":"waiting-for-merge","strategy":"adopt","import_prs":{"main":42},"adopted_branches":[]}' + + initial_import() { call_log import; echo "skip"; } + adopt_branch() { call_log adopt; echo "adopted"; } + subrepo_reset() { call_log reset; echo "reset"; } + get_pr() { echo '{"merged":true}'; } + list_open_prs() { echo '[]'; } + export MONOREPO_API="http://stub" GITEA_TOKEN="stub" + + local target_json='{"name":"example","branches":{"main":"main"}}' + onboard_flow "$target_json" "false" "" <<< "" >/dev/null 2>&1 + + [ "$(call_count import)" = "0" ] + [ "$(call_count adopt)" = "1" ] + [ "$(call_count reset)" = "0" ] + local final + final=$(read_onboard_state "$JOSH_SYNC_TARGET_NAME") + [ "$(echo "$final" | jq -r '.step')" = "complete" ] +} + +@test "onboard_flow resumes from step=adopting (strategy=adopt) — skips importing and waiting-for-merge entirely" { + setup_state_monorepo + write_onboard_state "$JOSH_SYNC_TARGET_NAME" \ + '{"step":"adopting","strategy":"adopt","import_prs":{},"adopted_branches":[]}' + + initial_import() { call_log import; echo "skip"; } + adopt_branch() { call_log adopt; echo "adopted"; } + subrepo_reset() { call_log reset; echo "reset"; } + get_pr() { echo '{"merged":true}'; } + list_open_prs() { echo '[]'; } + + local target_json='{"name":"example","branches":{"main":"main"}}' + onboard_flow "$target_json" "false" "" >/dev/null 2>&1 + + [ "$(call_count import)" = "0" ] + [ "$(call_count adopt)" = "1" ] + [ "$(call_count reset)" = "0" ] + local final + final=$(read_onboard_state "$JOSH_SYNC_TARGET_NAME") + [ "$(echo "$final" | jq -r '.step')" = "complete" ] +} + +@test "onboard_flow with step=complete is a no-op (no import, no finalize)" { + setup_state_monorepo + write_onboard_state "$JOSH_SYNC_TARGET_NAME" \ + '{"step":"complete","strategy":"adopt","import_prs":{},"adopted_branches":["main"]}' + + initial_import() { call_log import; echo "skip"; } + adopt_branch() { call_log adopt; echo "adopted"; } + subrepo_reset() { call_log reset; echo "reset"; } + + local target_json='{"name":"example","branches":{"main":"main"}}' + onboard_flow "$target_json" "false" "" >/dev/null 2>&1 + + [ "$(call_count import)" = "0" ] + [ "$(call_count adopt)" = "0" ] + [ "$(call_count reset)" = "0" ] +} + +@test "onboard_flow --restart with explicit strategy=adopt wipes state and re-runs the full sequence" { + setup_state_monorepo + write_onboard_state "$JOSH_SYNC_TARGET_NAME" \ + '{"step":"complete","strategy":"adopt","import_prs":{"main":7},"adopted_branches":["main"]}' + + initial_import() { call_log import; echo "skip"; } + adopt_branch() { call_log adopt; echo "adopted"; } + subrepo_reset() { call_log reset; echo "reset"; } + get_pr() { echo '{"merged":true}'; } + list_open_prs() { echo '[]'; } + + local target_json='{"name":"example","branches":{"main":"main"}}' + onboard_flow "$target_json" "true" "adopt" >/dev/null 2>&1 + + [ "$(call_count import)" = "1" ] + [ "$(call_count adopt)" = "1" ] + [ "$(call_count reset)" = "0" ] +} + +# ─── Goal 2: strategy resolution precedence ───────────────────────── + +@test "resolve_onboard_strategy honours explicit --mode flag above config and auto-detect" { + # Config says preserve (would map to adopt); flag should win. + local target_json='{"name":"x","history_lock":"preserve"}' + local picked + picked=$(resolve_onboard_strategy "$target_json" "reset") + [ "$picked" = "reset" ] +} + +@test "resolve_onboard_strategy maps history_lock=preserve to adopt" { + local target_json='{"name":"x","history_lock":"preserve"}' + local picked + picked=$(resolve_onboard_strategy "$target_json" "") + [ "$picked" = "adopt" ] +} + +@test "resolve_onboard_strategy maps history_lock=rewrite to reset" { + local target_json='{"name":"x","history_lock":"rewrite"}' + local picked + picked=$(resolve_onboard_strategy "$target_json" "") + [ "$picked" = "reset" ] +} + +@test "resolve_onboard_strategy auto-detects adopt when subrepo has any branches" { + SUBREPO_BARE="$(make_bare_repo subrepo "any" "any")" + local target_json='{"name":"x"}' + local picked + picked=$(resolve_onboard_strategy "$target_json" "") + [ "$picked" = "adopt" ] +} + +@test "resolve_onboard_strategy auto-detects reset when subrepo has no branches (empty repo)" { + SUBREPO_BARE="${TEST_ROOT}/empty.git" + git init -q --bare "$SUBREPO_BARE" + local target_json='{"name":"x"}' + local picked + picked=$(resolve_onboard_strategy "$target_json" "") + [ "$picked" = "reset" ] +} + +@test "resolve_onboard_strategy rejects unknown --mode value" { + local target_json='{"name":"x"}' + run resolve_onboard_strategy "$target_json" "weird" + [ "$status" -ne 0 ] + [[ "$output" == *"Invalid --mode"* ]] +} + +@test "resolve_onboard_strategy rejects unknown history_lock value" { + local target_json='{"name":"x","history_lock":"freeze"}' + run resolve_onboard_strategy "$target_json" "" + [ "$status" -ne 0 ] + [[ "$output" == *"Invalid history_lock"* ]] +} + +# ─── Goal 2: onboard_flow with strategy=reset (regression-preserving) ── + +@test "onboard_flow resumes from step=resetting (strategy=reset) — finalizes via subrepo_reset" { + setup_state_monorepo + write_onboard_state "$JOSH_SYNC_TARGET_NAME" \ + '{"step":"resetting","strategy":"reset","import_prs":{},"reset_branches":[]}' + + initial_import() { call_log import; echo "skip"; } + adopt_branch() { call_log adopt; echo "adopted"; } + subrepo_reset() { call_log reset; echo "reset"; } + + local target_json='{"name":"example","branches":{"main":"main"}}' + onboard_flow "$target_json" "false" "" >/dev/null 2>&1 + + [ "$(call_count import)" = "0" ] + [ "$(call_count adopt)" = "0" ] + [ "$(call_count reset)" = "1" ] + local final + final=$(read_onboard_state "$JOSH_SYNC_TARGET_NAME") + [ "$(echo "$final" | jq -r '.step')" = "complete" ] + [ "$(echo "$final" | jq -r '.strategy')" = "reset" ] +} + +@test "onboard_flow finalize step (strategy=adopt) drives the real adopt_branch end-to-end against bare repos" { + # No stubbing of adopt_branch: this test exercises the full unified + # dispatch by pre-seeding state at step=adopting and letting onboard_flow + # call adopt_branch for real against local bare repos via the auth-URL + # shims. Closes the spec's end-to-end criterion for `onboard --mode=adopt`. + setup_state_monorepo + SUBREPO_BARE="$(make_bare_repo subrepo "same tree" "subrepo history")" + JOSH_BARE="$(make_bare_repo josh "same tree" "josh filtered history")" + + local old_subrepo_head josh_head + old_subrepo_head=$(git --git-dir="$SUBREPO_BARE" rev-parse main) + josh_head=$(git --git-dir="$JOSH_BARE" rev-parse main) + + write_onboard_state "$JOSH_SYNC_TARGET_NAME" \ + '{"step":"adopting","strategy":"adopt","import_prs":{},"adopted_branches":[]}' + + local target_json='{"name":"example","branches":{"main":"main"}}' + onboard_flow "$target_json" "false" "" >/dev/null 2>&1 + + # 1. Subrepo HEAD advanced to a real adoption merge. + local new_subrepo_head + new_subrepo_head=$(git --git-dir="$SUBREPO_BARE" rev-parse main) + [ "$new_subrepo_head" != "$old_subrepo_head" ] + + # 2. Merge shape matches ADR-013: parent 1 = josh_head, parent 2 = old subrepo head. + [ "$(git --git-dir="$SUBREPO_BARE" rev-parse 'main^1')" = "$josh_head" ] + [ "$(git --git-dir="$SUBREPO_BARE" rev-parse 'main^2')" = "$old_subrepo_head" ] + + # 3. Merge message carries the ADR-005 bot trailer. + local merge_msg + merge_msg=$(git --git-dir="$SUBREPO_BARE" log -1 --format=%B main) + [[ "$merge_msg" == *"${BOT_TRAILER}: adopt/${SYNC_BRANCH_MONO}/"* ]] + + # 4. State advanced to complete and recorded the adopted branch. + local final + final=$(read_onboard_state "$JOSH_SYNC_TARGET_NAME") + [ "$(echo "$final" | jq -r '.step')" = "complete" ] + [ "$(echo "$final" | jq -r '.strategy')" = "adopt" ] + [ "$(echo "$final" | jq -r '.adopted_branches[0]')" = "main" ] +} + +# ─── Goal 2: legacy adopt.json → onboard.json migration on write ──── + +@test "after read_onboard_state falls back to legacy adopt.json, the next write lands in onboard.json" { + setup_state_monorepo + + # Seed the state branch with ONLY the legacy file (no onboard.json yet). + local tmp + tmp=$(mktemp -d) + git worktree add --detach "$tmp" >/dev/null 2>&1 + ( cd "$tmp" && git checkout -q --orphan "$STATE_BRANCH" && { git rm -rfq . 2>/dev/null || true; } + mkdir -p "$JOSH_SYNC_TARGET_NAME" + printf '%s\n' '{"step":"adopting","import_prs":{},"adopted_branches":[]}' \ + > "${JOSH_SYNC_TARGET_NAME}/adopt.json" + git add -A + git -c user.name="$BOT_NAME" -c user.email="$BOT_EMAIL" \ + commit -q -m "legacy adopt state" + git push -q origin "HEAD:${STATE_BRANCH}" ) + git worktree remove "$tmp" 2>/dev/null || rm -rf "$tmp" + + # Read → mutate → write back via the unified helpers. + local state + state=$(read_onboard_state "$JOSH_SYNC_TARGET_NAME") + state=$(echo "$state" | jq '.step = "complete"') + write_onboard_state "$JOSH_SYNC_TARGET_NAME" "$state" + + # Fetch a fresh copy of the state branch to see what's actually committed. + git fetch origin "$STATE_BRANCH" >/dev/null 2>&1 + + # onboard.json now exists with the updated step, strategy preserved. + local onboard_json + onboard_json=$(git show "origin/${STATE_BRANCH}:${JOSH_SYNC_TARGET_NAME}/onboard.json") + [ "$(echo "$onboard_json" | jq -r '.step')" = "complete" ] + [ "$(echo "$onboard_json" | jq -r '.strategy')" = "adopt" ] + + # Legacy adopt.json is left in place (harmless; read fallback still resolves). + git show "origin/${STATE_BRANCH}:${JOSH_SYNC_TARGET_NAME}/adopt.json" >/dev/null +} + +@test "onboard_flow importing step dies (does not warn-and-continue) when pr_number lookup fails — prevents duplicate import PR on resume" { + setup_state_monorepo + write_onboard_state "$JOSH_SYNC_TARGET_NAME" \ + '{"step":"importing","strategy":"adopt","import_prs":{},"adopted_branches":[]}' + + # initial_import claims a PR was created, but list_open_prs returns nothing + # matching → pr_number ends up empty. The fix turns the old WARN-and-continue + # into a die so a subsequent resume can't re-import and duplicate the PR. + initial_import() { echo "pr-created"; } + list_open_prs() { echo '[]'; } + adopt_branch() { echo "adopted"; } + export MONOREPO_API="http://stub" GITEA_TOKEN="stub" + + local target_json='{"name":"example","branches":{"main":"main"}}' + run --separate-stderr onboard_flow "$target_json" "false" "" + [ "$status" -ne 0 ] + [[ "$stderr" == *"Could not find import PR number"* ]] + [[ "$stderr" == *"duplicate import PR"* ]] +} + +@test "resolve_onboard_strategy dies (does not silently pick reset) when ls-remote against the subrepo fails" { + # Point auth at a nonexistent local path so ls-remote fails (exit non-zero). + SUBREPO_BARE="${TEST_ROOT}/this-does-not-exist.git" + local target_json='{"name":"x"}' + run --separate-stderr resolve_onboard_strategy "$target_json" "" + [ "$status" -ne 0 ] + [[ "$stderr" == *"Cannot auto-detect strategy"* ]] +} + +@test "onboard_flow rejects --mode that conflicts with a strategy already saved in state" { + setup_state_monorepo + write_onboard_state "$JOSH_SYNC_TARGET_NAME" \ + '{"step":"adopting","strategy":"adopt","import_prs":{},"adopted_branches":[]}' + + initial_import() { echo "skip"; } + adopt_branch() { echo "adopted"; } + subrepo_reset() { echo "reset"; } + + local target_json='{"name":"example","branches":{"main":"main"}}' + run --separate-stderr onboard_flow "$target_json" "false" "reset" + [ "$status" -ne 0 ] + [[ "$stderr" == *"Saved strategy is 'adopt'"* ]] + [[ "$stderr" == *"--restart"* ]] +} + +@test "read_onboard_state injects strategy=reset for legacy v2.1 onboard.json (no .strategy field)" { + setup_state_monorepo + + # Seed only a legacy-format onboard.json (no .strategy field). + local tmp + tmp=$(mktemp -d) + git worktree add --detach "$tmp" >/dev/null 2>&1 + ( cd "$tmp" && git checkout -q --orphan "$STATE_BRANCH" && { git rm -rfq . 2>/dev/null || true; } + mkdir -p "$JOSH_SYNC_TARGET_NAME" + printf '%s\n' '{"step":"resetting","import_prs":{"main":42},"reset_branches":["main"]}' \ + > "${JOSH_SYNC_TARGET_NAME}/onboard.json" + git add -A + git -c user.name="$BOT_NAME" -c user.email="$BOT_EMAIL" \ + commit -q -m "legacy onboard state" + git push -q origin "HEAD:${STATE_BRANCH}" ) + git worktree remove "$tmp" 2>/dev/null || rm -rf "$tmp" + + local got + got=$(read_onboard_state "$JOSH_SYNC_TARGET_NAME") + [ "$(echo "$got" | jq -r '.step')" = "resetting" ] + [ "$(echo "$got" | jq -r '.strategy')" = "reset" ] +} + +@test "onboard_flow resumes from step=waiting-for-merge (strategy=reset) — transitions to resetting" { + setup_state_monorepo + write_onboard_state "$JOSH_SYNC_TARGET_NAME" \ + '{"step":"waiting-for-merge","strategy":"reset","import_prs":{"main":42},"reset_branches":[],"archived_url":"git@host:org/old.git","archived_api":"https://host/api/v1/repos/org/old","archived_auth":"https"}' + + initial_import() { call_log import; echo "skip"; } + adopt_branch() { call_log adopt; echo "adopted"; } + subrepo_reset() { call_log reset; echo "reset"; } + get_pr() { echo '{"merged":true}'; } + list_open_prs() { echo '[]'; } + export MONOREPO_API="http://stub" GITEA_TOKEN="stub" + + local target_json='{"name":"example","branches":{"main":"main"}}' + onboard_flow "$target_json" "false" "" <<< "" >/dev/null 2>&1 + + [ "$(call_count adopt)" = "0" ] + [ "$(call_count reset)" = "1" ] +} diff --git a/tests/unit/cli.bats b/tests/unit/cli.bats new file mode 100644 index 0000000..7f08b8e --- /dev/null +++ b/tests/unit/cli.bats @@ -0,0 +1,123 @@ +#!/usr/bin/env bats + +bats_require_minimum_version 1.5.0 + +# tests/unit/cli.bats — bin/josh-sync CLI surface tests +# +# Invokes the CLI as a subprocess (not by sourcing libs) so flag parsing, +# command dispatch, help/usage text, and the `adopt → onboard --mode=adopt` +# alias regress cleanly. These tests deliberately stay at the parse/dispatch +# layer — anything that would reach out to git remotes is set up to fail at +# target-lookup time, before any side effects. +# +# Uses `run --separate-stderr` so we can assert on stderr separately (most of +# josh-sync's output is logged via core.sh's `log()` which writes to stderr). + +setup() { + JOSH_SYNC_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" + JOSH_BIN="${JOSH_SYNC_ROOT}/bin/josh-sync" + FIXTURES="${JOSH_SYNC_ROOT}/tests/fixtures" + TEST_ROOT="$(mktemp -d)" +} + +teardown() { + cd "$BATS_TEST_DIRNAME" 2>/dev/null || true + rm -rf "$TEST_ROOT" +} + +# Drop the minimal fixture config into a clean cwd. Most cmd_* paths read +# .josh-sync.yml from the working directory. +seed_minimal_config() { + cp "${FIXTURES}/minimal.yml" "${TEST_ROOT}/.josh-sync.yml" + cd "$TEST_ROOT" +} + +# ─── Global flags ─────────────────────────────────────────────────── + +@test "--version exits 0 and prints the version" { + run "$JOSH_BIN" --version + [ "$status" -eq 0 ] + [[ "$output" == *"josh-sync"* ]] +} + +@test "--help exits 0 and advertises onboard, the adopt alias, and --mode" { + run "$JOSH_BIN" --help + [ "$status" -eq 0 ] + # Help text is printed to stderr via `cat >&2`, so combine with --separate. + run --separate-stderr "$JOSH_BIN" --help + [ "$status" -eq 0 ] + [[ "$stderr" == *"onboard "* ]] + [[ "$stderr" == *"adopt "* ]] + [[ "$stderr" == *"Alias for 'onboard --mode=adopt'"* ]] + [[ "$stderr" == *"--mode={reset,adopt}"* ]] +} + +# ─── onboard flag parsing & dispatch ──────────────────────────────── + +@test "onboard with no target exits non-zero and prints usage including --mode" { + seed_minimal_config + run --separate-stderr "$JOSH_BIN" onboard + [ "$status" -ne 0 ] + [[ "$stderr" == *"Usage: josh-sync onboard"* ]] + [[ "$stderr" == *"--mode=reset|adopt"* ]] +} + +@test "onboard --mode=invalid is rejected with a clear error" { + seed_minimal_config + run --separate-stderr "$JOSH_BIN" onboard --mode=invalid example + [ "$status" -ne 0 ] + [[ "$stderr" == *"Invalid --mode"* ]] +} + +@test "onboard --mode=adopt against an unknown target reports target-not-found" { + seed_minimal_config + # Proves --mode parses correctly: parse_config succeeds, then cmd_onboard + # fails at target lookup, before any git/network activity. + run --separate-stderr "$JOSH_BIN" onboard --mode=adopt nonexistent + [ "$status" -ne 0 ] + [[ "$stderr" == *"not found"* ]] +} + +# ─── adopt alias ──────────────────────────────────────────────────── + +@test "adopt alias forwards to onboard --mode=adopt (visible via alias log line + target lookup)" { + seed_minimal_config + run --separate-stderr "$JOSH_BIN" adopt nonexistent + [ "$status" -ne 0 ] + # Alias notice from cmd_adopt: + [[ "$stderr" == *"alias for: josh-sync onboard --mode=adopt"* ]] + # And the forwarded call lands in cmd_onboard's target lookup: + [[ "$stderr" == *"not found"* ]] +} + +@test "onboard usage listing shows --restart and --mode flags together" { + seed_minimal_config + run --separate-stderr "$JOSH_BIN" onboard + [ "$status" -ne 0 ] + [[ "$stderr" == *"--restart"* ]] + [[ "$stderr" == *"--mode"* ]] +} + +# ─── --mode value-parsing guards ──────────────────────────────────── + +@test "onboard --mode (no value) fails with a helpful message instead of crashing on unbound \$2" { + seed_minimal_config + run --separate-stderr "$JOSH_BIN" onboard example --mode + [ "$status" -ne 0 ] + [[ "$stderr" == *"--mode requires a value"* ]] +} + +@test "onboard --mode= (empty after equals) is rejected explicitly, not silently ignored" { + seed_minimal_config + run --separate-stderr "$JOSH_BIN" onboard example --mode= + [ "$status" -ne 0 ] + [[ "$stderr" == *"Empty --mode="* ]] +} + +@test "adopt alias dies when a conflicting --mode=reset is also passed (instead of silently honouring reset)" { + seed_minimal_config + run --separate-stderr "$JOSH_BIN" adopt example --mode=reset + [ "$status" -ne 0 ] + [[ "$stderr" == *"alias for --mode=adopt"* ]] + [[ "$stderr" == *"conflicting"* ]] +}