#!/usr/bin/env bats # EGB-1230 / EGB-1231: store sync — loud failures on `pull`, and a `secrets # sync` verb that reconciles a diverged store instead of dead-ending. # # bash 3.2 gotcha (see CLAUDE.md): every standalone [[ ]] assertion MUST end # with `|| false`, or a failing assertion does not fail the test. load test_helper # ─── fixtures ────────────────────────────────────────────────────────── # # A "peer" is a second clone of the same bare remote. Committing + pushing # from the peer is how we put the store under test *behind* its remote # without touching the store itself. peer_commit_and_push() { local name="${1:-peer-file}" content="${2:-peer}" local peer="$TEST_TMPDIR/peer" if [ ! -d "$peer" ]; then git clone "$REMOTE_DIR" "$peer" >/dev/null 2>&1 fi ( cd "$peer" && git pull >/dev/null 2>&1 || true ) echo "$content" > "$peer/$name" ( cd "$peer" && git add -A && git commit -m "peer: $name" >/dev/null && git push >/dev/null 2>&1 ) } # Give the store a local-only commit (store becomes "ahead"). store_local_commit() { local name="${1:-local-file}" echo "local" > "$SECRETS_DIR/$name" git -C "$SECRETS_DIR" add -A git -C "$SECRETS_DIR" commit -m "local: $name" >/dev/null } # Leave an uncommitted modification in the store working tree ("dirty"). store_dirty() { echo "scratch" > "$SECRETS_DIR/${1:-dirty-file}" git -C "$SECRETS_DIR" add -A >/dev/null 2>&1 || true } store_head() { git -C "$SECRETS_DIR" rev-parse HEAD; } # init_with_remote (shared helper) commits --allow-empty, which leaves the # files `secrets init` wrote — .gitignore, .secrets-format, recipients.txt — # untracked. That is a genuinely dirty store, so tests that assert on the # "nothing to reconcile" path must land them first. init_clean_store() { init_with_remote git -C "$SECRETS_DIR" add -A git -C "$SECRETS_DIR" commit -m "store: initial files" >/dev/null 2>&1 || true git -C "$SECRETS_DIR" push >/dev/null 2>&1 } # ─── EGB-1230: pull must never fail silently ─────────────────────────── @test "EGB-1230: pull surfaces git's diagnosis when the store can't fast-forward" { init_with_remote peer_commit_and_push remote-only.txt store_local_commit local-only.txt # now diverged: ahead 1, behind 1 create_project_dir divproj run "$SECRETS_BIN" pull [ "$status" -ne 0 ] # The failure is named, not silent — this is the whole bug. [[ "$output" == *"Store sync failed"* ]] || false [[ "$output" == *"$SECRETS_DIR"* ]] || false } @test "EGB-1230: pull's sync failure points at secrets sync" { init_with_remote peer_commit_and_push remote-only.txt store_local_commit local-only.txt create_project_dir divproj run "$SECRETS_BIN" pull [ "$status" -ne 0 ] [[ "$output" == *"secrets sync"* ]] || false } @test "EGB-1230: pull writes the diagnosis to stderr, not just stdout" { init_with_remote peer_commit_and_push remote-only.txt store_local_commit local-only.txt create_project_dir divproj run bash -c "'$SECRETS_BIN' pull 2>&1 1>/dev/null" [[ "$output" == *"Store sync failed"* ]] || false } @test "EGB-1230: a clean store still pulls normally" { init_with_remote create_project_dir cleanproj run "$SECRETS_BIN" push [ "$status" -eq 0 ] rm -f .env .env.staging run "$SECRETS_BIN" pull [ "$status" -eq 0 ] [ -f .env ] } @test "EGB-1230: pull's sync is fast-forward only (no silent merge commit)" { init_with_remote create_project_dir ffproj run "$SECRETS_BIN" push [ "$status" -eq 0 ] peer_commit_and_push remote-only.txt store_local_commit local-only.txt local before; before=$(store_head) run "$SECRETS_BIN" pull [ "$status" -ne 0 ] # A merge commit would have moved HEAD. Nothing was integrated. [ "$(store_head)" = "$before" ] } # ─── EGB-1231: which reports store state ─────────────────────────────── @test "EGB-1231: which reports ahead/behind/dirty for a diverged store" { init_with_remote peer_commit_and_push remote-only.txt store_local_commit local-only.txt git -C "$SECRETS_DIR" fetch origin >/dev/null 2>&1 create_project_dir whichproj run "$SECRETS_BIN" which [ "$status" -eq 0 ] [[ "$output" == *"remote:"* ]] || false [[ "$output" == *"ahead 1"* ]] || false [[ "$output" == *"behind 1"* ]] || false } @test "EGB-1231: which reports an in-sync store as up to date" { init_clean_store create_project_dir syncedproj run "$SECRETS_BIN" which [ "$status" -eq 0 ] [[ "$output" == *"remote:"* ]] || false [[ "$output" == *"up to date"* ]] || false } @test "EGB-1231: which stays quiet about the remote when none is configured" { run "$SECRETS_BIN" init create_project_dir noremote run "$SECRETS_BIN" which [ "$status" -eq 0 ] [[ "$output" != *"remote:"* ]] || false } @test "EGB-1231: which reports a dirty store working tree" { init_with_remote store_dirty scratch.age create_project_dir dirtyproj run "$SECRETS_BIN" which [ "$status" -eq 0 ] [[ "$output" == *"modified"* ]] || false } # ─── EGB-1231: secrets sync reconciles ───────────────────────────────── @test "EGB-1231: sync rebases a diverged store onto the remote" { init_with_remote peer_commit_and_push remote-only.txt store_local_commit local-only.txt run "$SECRETS_BIN" sync --yes [ "$status" -eq 0 ] # Both sides' work survives the reconcile. [ -f "$SECRETS_DIR/remote-only.txt" ] [ -f "$SECRETS_DIR/local-only.txt" ] } @test "EGB-1231: sync leaves the store able to pull again" { init_with_remote create_project_dir recovered run "$SECRETS_BIN" push [ "$status" -eq 0 ] peer_commit_and_push remote-only.txt store_local_commit local-only.txt run "$SECRETS_BIN" sync --yes [ "$status" -eq 0 ] rm -f .env .env.staging run "$SECRETS_BIN" pull [ "$status" -eq 0 ] [ -f .env ] } @test "EGB-1231: sync fast-forwards a store that is only behind" { init_with_remote peer_commit_and_push remote-only.txt run "$SECRETS_BIN" sync --yes [ "$status" -eq 0 ] [ -f "$SECRETS_DIR/remote-only.txt" ] } @test "EGB-1231: sync stashes and restores a dirty working tree" { init_with_remote peer_commit_and_push remote-only.txt echo "uncommitted work" > "$SECRETS_DIR/scratch.age" run "$SECRETS_BIN" sync --yes [ "$status" -eq 0 ] [ -f "$SECRETS_DIR/remote-only.txt" ] # The local uncommitted blob edit is NOT lost. [ -f "$SECRETS_DIR/scratch.age" ] [ "$(cat "$SECRETS_DIR/scratch.age")" = "uncommitted work" ] } @test "EGB-1231: sync on an already-clean store reports no work and changes nothing" { init_clean_store local before; before=$(store_head) run "$SECRETS_BIN" sync --yes [ "$status" -eq 0 ] [ "$(store_head)" = "$before" ] [[ "$output" == *"up to date"* ]] || false } @test "EGB-1231: sync dies directed when the store has no remote" { run "$SECRETS_BIN" init run "$SECRETS_BIN" sync --yes [ "$status" -ne 0 ] [[ "$output" == *"no remote"* ]] || false } # ─── EGB-1231: --dry-run changes nothing ─────────────────────────────── @test "EGB-1231: sync --dry-run reports state without mutating the store" { init_with_remote peer_commit_and_push remote-only.txt store_local_commit local-only.txt local before; before=$(store_head) run "$SECRETS_BIN" sync --dry-run [ "$status" -eq 0 ] [[ "$output" == *"ahead 1"* ]] || false [[ "$output" == *"behind 1"* ]] || false [ "$(store_head)" = "$before" ] [ ! -f "$SECRETS_DIR/remote-only.txt" ] } @test "EGB-1231: sync --dry-run does not push local commits" { init_with_remote store_local_commit local-only.txt run "$SECRETS_BIN" sync --dry-run [ "$status" -eq 0 ] # The remote never received the local commit. run git -C "$REMOTE_DIR" log --oneline [[ "$output" != *"local: local-only.txt"* ]] || false } # ─── EGB-1231: the push gate ─────────────────────────────────────────── @test "EGB-1231: sync --yes pushes reconciled local commits to the remote" { init_with_remote peer_commit_and_push remote-only.txt store_local_commit local-only.txt run "$SECRETS_BIN" sync --yes [ "$status" -eq 0 ] run git -C "$REMOTE_DIR" log --oneline [[ "$output" == *"local: local-only.txt"* ]] || false } @test "EGB-1231: sync without confirmation reconciles locally but does not push" { init_with_remote peer_commit_and_push remote-only.txt store_local_commit local-only.txt # No tty and no --yes: the push gate must not open on its own. run bash -c "'$SECRETS_BIN' sync < /dev/null" [ "$status" -eq 0 ] # Local reconcile happened... [ -f "$SECRETS_DIR/remote-only.txt" ] # ...but nothing was published to the shared store. run git -C "$REMOTE_DIR" log --oneline [[ "$output" != *"local: local-only.txt"* ]] || false } @test "EGB-1231: sync says how to publish when the push gate stays closed" { init_with_remote store_local_commit local-only.txt run bash -c "'$SECRETS_BIN' sync < /dev/null" [ "$status" -eq 0 ] [[ "$output" == *"--yes"* ]] || false } # ─── EGB-1231: conflicts restore the store as found ──────────────────── @test "EGB-1231: a rebase conflict leaves the store exactly as it was" { init_with_remote # Both sides edit the same path — a guaranteed rebase conflict. peer_commit_and_push contested.txt "from-remote" git -C "$SECRETS_DIR" fetch origin >/dev/null 2>&1 echo "from-local" > "$SECRETS_DIR/contested.txt" git -C "$SECRETS_DIR" add -A git -C "$SECRETS_DIR" commit -m "local: contested" >/dev/null local before; before=$(store_head) run "$SECRETS_BIN" sync --yes [ "$status" -ne 0 ] [ "$(store_head)" = "$before" ] # No half-finished rebase left behind for the user to trip over. [ ! -d "$SECRETS_DIR/.git/rebase-merge" ] [ ! -d "$SECRETS_DIR/.git/rebase-apply" ] [ "$(cat "$SECRETS_DIR/contested.txt")" = "from-local" ] } @test "EGB-1231: a rebase conflict names the conflicting files" { init_with_remote peer_commit_and_push contested.txt "from-remote" git -C "$SECRETS_DIR" fetch origin >/dev/null 2>&1 echo "from-local" > "$SECRETS_DIR/contested.txt" git -C "$SECRETS_DIR" add -A git -C "$SECRETS_DIR" commit -m "local: contested" >/dev/null run "$SECRETS_BIN" sync --yes [ "$status" -ne 0 ] [[ "$output" == *"contested.txt"* ]] || false } @test "EGB-1231: sync never force-pushes or hard-resets" { # Guard rail on the implementation itself: this store holds the only # copy of encrypted secrets, so the destructive git verbs must not # appear anywhere in the sync path. run bash -c "sed -n '/^cmd_sync()/,/^}/p' '$SECRETS_BIN'" [[ "$output" != *"--force"* ]] || false [[ "$output" != *"reset --hard"* ]] || false [[ "$output" != *"stash drop"* ]] || false } # ─── EGB-1231: the push path's advice is no longer a dead end ────────── @test "EGB-1231: push's fast-forward failure points at secrets sync" { init_with_remote peer_commit_and_push remote-only.txt store_local_commit local-only.txt create_project_dir pushproj run "$SECRETS_BIN" push [ "$status" -ne 0 ] [[ "$output" == *"secrets sync"* ]] || false } # ─── help ────────────────────────────────────────────────────────────── @test "EGB-1231: sync is documented in help" { run "$SECRETS_BIN" help [ "$status" -eq 0 ] [[ "$output" == *"secrets sync"* ]] || false }