#!/usr/bin/env bats bats_require_minimum_version 1.5.0 # tests/unit/rename_e2e.bats — End-to-end `josh-sync rename` behaviour # # Covers: state_list_target_files / state_migrate_target_prefix primitives, # rename_target's full validation order (concurrency heuristic, destination # state conflict, URL reachability), and crash-resumability. # # Tests run against real local bare git repos — no mocking of the git layer, # following the tests/unit/adopt_e2e.bats convention. Local filesystem paths # work directly as subrepo_url/monorepo_url values (subrepo_auth_url()/ # mono_auth_url() only rewrite URLs starting with "https://"/"git@"/"ssh://", # so a plain path passes through unchanged) — no auth shimming needed. setup() { export JOSH_SYNC_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" source "$JOSH_SYNC_ROOT/lib/core.sh" source "$JOSH_SYNC_ROOT/lib/config.sh" source "$JOSH_SYNC_ROOT/lib/auth.sh" source "$JOSH_SYNC_ROOT/lib/state.sh" source "$JOSH_SYNC_ROOT/lib/rename.sh" TEST_ROOT="$(mktemp -d)" # Needed by state_migrate_target_prefix/seed_state_commit when called # directly (without parse_config first setting these from the config file). export BOT_NAME="test-bot" export BOT_EMAIL="test-bot@test.local" } teardown() { # Restore cwd before rm -rf — setup_state_monorepo cd's into a dir 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 on main; prints the bare path. make_bare_repo() { local name="$1" local work="${TEST_ROOT}/${name}-work" local bare="${TEST_ROOT}/${name}.git" git init -q "$work" git -C "$work" checkout -q -b main git -C "$work" config user.name "Test User" git -C "$work" config user.email "test@test.local" printf '%s\n' "$name" > "${work}/README.md" git -C "$work" add README.md git -C "$work" commit -q -m "initial" git init -q --bare "$bare" git -C "$bare" symbolic-ref HEAD refs/heads/main git -C "$work" remote add origin "$bare" git -C "$work" push -q origin main printf '%s\n' "$bare" } # Stand up a local working "monorepo" with an `origin` bare remote and cd into # it, so state.sh's worktree+push machinery has something real to push to. 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" } # Write a minimal .josh-sync.yml with one target "billing" in the cwd. # # josh.monorepo_url is a well-formed PLACEHOLDER (parse_config validates its # shape to derive a gitea host, but nothing in rename_target actually dials # it — state.sh's fetch/push calls use the cwd's real "origin" remote, which # setup_state_monorepo already points at the real bare repo). write_billing_config() { local subrepo_url="$1" cat > .josh-sync.yml < [ ...] seed_state_commit() { local commit_date="$1"; shift local tmp tmp=$(mktemp -d) if git rev-parse "origin/${STATE_BRANCH}" >/dev/null 2>&1; then git worktree add "$tmp" "origin/${STATE_BRANCH}" >/dev/null 2>&1 else git worktree add --detach "$tmp" >/dev/null 2>&1 ( cd "$tmp" && git checkout -q --orphan "$STATE_BRANCH" && { git rm -rfq . 2>/dev/null || true; } ) fi while [ $# -ge 2 ]; do local path="$1" content="$2"; shift 2 mkdir -p "$(dirname "${tmp}/${path}")" printf '%s' "$content" > "${tmp}/${path}" done ( cd "$tmp" || exit 1 git add -A if [ -n "$commit_date" ]; then GIT_AUTHOR_DATE="$commit_date" GIT_COMMITTER_DATE="$commit_date" \ git -c user.name="test-bot" -c user.email="test-bot@test.local" \ commit -q -m "seed state" else git -c user.name="test-bot" -c user.email="test-bot@test.local" \ commit -q -m "seed state" fi git push -q origin "HEAD:${STATE_BRANCH}" ) git worktree remove "$tmp" 2>/dev/null || rm -rf "$tmp" } state_branch_commit_count() { git fetch origin "$STATE_BRANCH" >/dev/null 2>&1 || true git rev-parse -q --verify "origin/${STATE_BRANCH}" >/dev/null 2>&1 || { echo 0; return; } git rev-list --count "origin/${STATE_BRANCH}" } # ─── state_list_target_files / state_migrate_target_prefix ───────── @test "state_list_target_files lists every path under a target prefix" { setup_state_monorepo seed_state_commit "" "billing/main.json" '{}' "billing/onboard.json" '{"step":"complete"}' files=$(state_list_target_files "billing") [[ "$files" == *"billing/main.json"* ]] [[ "$files" == *"billing/onboard.json"* ]] } @test "state_list_target_files is empty when the state branch doesn't exist" { setup_state_monorepo files=$(state_list_target_files "billing") [ -z "$files" ] } @test "state_migrate_target_prefix moves per-branch state and onboard.json in one commit" { setup_state_monorepo seed_state_commit "" "billing/main.json" '{"last_forward":{"mono_sha":"abc"}}' \ "billing/onboard.json" '{"step":"complete"}' before=$(state_branch_commit_count) state_migrate_target_prefix "billing" "payments" "" after=$(state_branch_commit_count) [ "$after" -eq $((before + 1)) ] old_files=$(state_list_target_files "billing") new_files=$(state_list_target_files "payments") [ -z "$old_files" ] [[ "$new_files" == *"payments/main.json"* ]] [[ "$new_files" == *"payments/onboard.json"* ]] } @test "state_migrate_target_prefix rewrites last_forward.josh_filter, leaves onboard.json untouched" { setup_state_monorepo seed_state_commit "" \ "billing/main.json" '{"last_forward":{"mono_sha":"abc","josh_filter":":/services/billing"}}' \ "billing/onboard.json" '{"step":"complete"}' state_migrate_target_prefix "billing" "payments" ":/services/payments" git fetch origin "$STATE_BRANCH" >/dev/null 2>&1 filter=$(git show "origin/${STATE_BRANCH}:payments/main.json" | jq -r '.last_forward.josh_filter') mono_sha=$(git show "origin/${STATE_BRANCH}:payments/main.json" | jq -r '.last_forward.mono_sha') onboard_step=$(git show "origin/${STATE_BRANCH}:payments/onboard.json" | jq -r '.step') [ "$filter" = ":/services/payments" ] [ "$mono_sha" = "abc" ] [ "$onboard_step" = "complete" ] } @test "state_migrate_target_prefix is a no-op when the source prefix has no files" { setup_state_monorepo run state_migrate_target_prefix "ghost" "new-ghost" "" [ "$status" -eq 0 ] run git rev-parse "origin/${STATE_BRANCH}" [ "$status" -ne 0 ] } @test "state_migrate_target_prefix dies without partial mutation when a destination already exists" { setup_state_monorepo seed_state_commit "" "billing/main.json" '{}' "billing/develop.json" '{}' seed_state_commit "" "payments/main.json" '{"pre-existing":true}' before=$(state_branch_commit_count) run state_migrate_target_prefix "billing" "payments" "" [ "$status" -ne 0 ] after=$(state_branch_commit_count) [ "$after" -eq "$before" ] remaining=$(state_list_target_files "billing") [[ "$remaining" == *"billing/main.json"* ]] [[ "$remaining" == *"billing/develop.json"* ]] } # ─── rename_target: destination-state conflict (never forceable) ── @test "rename_target aborts on a destination-state conflict, and --force does not bypass it" { setup_state_monorepo local subrepo_bare subrepo_bare=$(make_bare_repo "billing-sub") write_billing_config "$subrepo_bare" parse_config ".josh-sync.yml" seed_state_commit "" "payments/main.json" '{"pre-existing":true}' run rename_target "billing" "payments" "" "" ".josh-sync.yml" false true false [ "$status" -ne 0 ] [[ "$output" == *"already exists"* ]] || [[ "$output" == *"refusing"* ]] run rename_target "billing" "payments" "" "" ".josh-sync.yml" false true true [ "$status" -ne 0 ] } # ─── rename_target: concurrency heuristic ────────────────────────── @test "rename_target concurrency heuristic aborts on a recent state-branch commit, without --force" { setup_state_monorepo local subrepo_bare subrepo_bare=$(make_bare_repo "billing-sub") write_billing_config "$subrepo_bare" parse_config ".josh-sync.yml" seed_state_commit "" "billing/main.json" '{"last_forward":{"mono_sha":"abc"}}' run rename_target "billing" "payments" "" "" ".josh-sync.yml" false true false [ "$status" -ne 0 ] [[ "$output" == *"Recent sync activity"* ]] } @test "rename_target concurrency heuristic: --force proceeds" { setup_state_monorepo local subrepo_bare subrepo_bare=$(make_bare_repo "billing-sub") write_billing_config "$subrepo_bare" parse_config ".josh-sync.yml" seed_state_commit "" "billing/main.json" '{"last_forward":{"mono_sha":"abc"}}' rename_target "billing" "payments" "" "" ".josh-sync.yml" false true true parse_config ".josh-sync.yml" name=$(echo "$JOSH_SYNC_TARGETS" | jq -r '.[0].name') [ "$name" = "payments" ] } @test "rename_target concurrency heuristic does not block on a commit older than the window" { setup_state_monorepo local subrepo_bare subrepo_bare=$(make_bare_repo "billing-sub") write_billing_config "$subrepo_bare" parse_config ".josh-sync.yml" local old_date old_date=$(date -u -d '1 hour ago' +%Y-%m-%dT%H:%M:%S 2>/dev/null || date -u -v-1H +%Y-%m-%dT%H:%M:%S) seed_state_commit "$old_date" "billing/main.json" '{"last_forward":{"mono_sha":"abc"}}' rename_target "billing" "payments" "" "" ".josh-sync.yml" false true false parse_config ".josh-sync.yml" name=$(echo "$JOSH_SYNC_TARGETS" | jq -r '.[0].name') [ "$name" = "payments" ] } # ─── rename_target: subrepo URL reachability ─────────────────────── @test "rename_target dies when the new subrepo URL is unreachable" { setup_state_monorepo local subrepo_bare subrepo_bare=$(make_bare_repo "billing-sub") write_billing_config "$subrepo_bare" parse_config ".josh-sync.yml" run rename_target "billing" "" "" "${TEST_ROOT}/does-not-exist.git" ".josh-sync.yml" false true false [ "$status" -ne 0 ] [[ "$output" == *"not reachable"* ]] } @test "rename_target succeeds when the new subrepo URL is reachable" { setup_state_monorepo local subrepo_bare new_bare subrepo_bare=$(make_bare_repo "billing-sub") new_bare=$(make_bare_repo "payments-sub") write_billing_config "$subrepo_bare" parse_config ".josh-sync.yml" rename_target "billing" "" "" "$new_bare" ".josh-sync.yml" false true false parse_config ".josh-sync.yml" url=$(echo "$JOSH_SYNC_TARGETS" | jq -r '.[0].subrepo_url') [ "$url" = "$new_bare" ] } # ─── rename_target: resumability ─────────────────────────────────── @test "rename_target resumes cleanly when config already shows the new name but state wasn't migrated" { setup_state_monorepo local subrepo_bare subrepo_bare=$(make_bare_repo "billing-sub") # Config already shows the NEW name — simulates a crash after the config # edit landed but before state migration ran. write_billing_config "$subrepo_bare" sed -i 's/name: "billing"/name: "payments"/' .josh-sync.yml parse_config ".josh-sync.yml" seed_state_commit "" "billing/main.json" '{"last_forward":{"mono_sha":"abc"}}' # --force: the seed above is itself a "recent" state-branch commit under # billing/, which the concurrency heuristic correctly flags — this test is # about resumability, not the heuristic (covered separately above). rename_target "billing" "payments" "" "" ".josh-sync.yml" false true true old_files=$(state_list_target_files "billing") new_files=$(state_list_target_files "payments") [ -z "$old_files" ] [[ "$new_files" == *"payments/main.json"* ]] } # ─── rename_target: combined name+subfolder in one call ─────────── @test "rename_target combining --name and --subfolder produces exactly one state-branch commit" { setup_state_monorepo local subrepo_bare subrepo_bare=$(make_bare_repo "billing-sub") write_billing_config "$subrepo_bare" parse_config ".josh-sync.yml" mkdir -p services/billing echo "content" > services/billing/file.txt git add -A git commit -q -m "seed billing subfolder" seed_state_commit "" "billing/main.json" '{"last_forward":{"mono_sha":"abc","josh_filter":":/services/billing"}}' # --force: the seed_state_commit above just wrote a "recent" commit under # billing/, which the concurrency heuristic (correctly) flags — this test # is about the combined name+subfolder migration, not the heuristic itself. before=$(state_branch_commit_count) rename_target "billing" "payments" "services/payments" "" ".josh-sync.yml" false true true after=$(state_branch_commit_count) [ "$after" -eq $((before + 1)) ] parse_config ".josh-sync.yml" name=$(echo "$JOSH_SYNC_TARGETS" | jq -r '.[0].name') subfolder=$(echo "$JOSH_SYNC_TARGETS" | jq -r '.[0].subfolder') [ "$name" = "payments" ] [ "$subfolder" = "services/payments" ] git fetch origin "$STATE_BRANCH" >/dev/null 2>&1 filter=$(git show "origin/${STATE_BRANCH}:payments/main.json" | jq -r '.last_forward.josh_filter') [ "$filter" = ":/services/payments" ] msg=$(git log "origin/${STATE_BRANCH}" -1 --format=%s) [ "$msg" = "state: rename billing -> payments" ] [ ! -e services/billing ] [ -f services/payments/file.txt ] } # ─── rename_target: subfolder move (git mv in the monorepo tree) ── @test "rename_target git mv's the subfolder and dies if the old path isn't tracked" { setup_state_monorepo local subrepo_bare subrepo_bare=$(make_bare_repo "billing-sub") write_billing_config "$subrepo_bare" parse_config ".josh-sync.yml" # services/billing was never created/tracked — not "managed by josh-sync". run rename_target "billing" "" "services/relocated" "" ".josh-sync.yml" false true false [ "$status" -ne 0 ] [[ "$output" == *"does not exist"* ]] } @test "rename_target dies when the new subfolder path already exists" { setup_state_monorepo local subrepo_bare subrepo_bare=$(make_bare_repo "billing-sub") write_billing_config "$subrepo_bare" parse_config ".josh-sync.yml" mkdir -p services/billing services/relocated echo "old" > services/billing/file.txt echo "existing" > services/relocated/other.txt git add -A git commit -q -m "seed" run rename_target "billing" "" "services/relocated" "" ".josh-sync.yml" false true false [ "$status" -ne 0 ] [[ "$output" == *"already exists"* ]] # nothing should have moved [ -f services/billing/file.txt ] } @test "rename_target moves the subfolder via git mv, staged but not committed" { setup_state_monorepo local subrepo_bare subrepo_bare=$(make_bare_repo "billing-sub") write_billing_config "$subrepo_bare" parse_config ".josh-sync.yml" mkdir -p services/billing echo "content" > services/billing/file.txt git add -A git commit -q -m "seed" rename_target "billing" "" "services/relocated" "" ".josh-sync.yml" false true false [ ! -e services/billing ] [ -f services/relocated/file.txt ] staged=$(git diff --cached --name-only) [[ "$staged" == *"relocated/file.txt"* ]] # Not committed — mirrors the config edit, left for the user to review. head_files=$(git show --name-only --format= HEAD) [[ "$head_files" != *"relocated"* ]] }