#!/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" ] }