fix: surface store sync failures + add secrets sync (EGB-1230, EGB-1231)
EGB-1230: `cmd_pull` synced the store with `git pull >/dev/null 2>&1` under `set -euo pipefail`. A store that couldn't fast-forward killed the script at that line with git's exit 128 and nothing on stdout or stderr — a banner, no restored files, no reason, and invisible in a pipeline. The sync now routes through `_store_sync_pull`, which guards the pull, captures git's output as the diagnosis, and dies naming the store path and `secrets sync`. It is now `--ff-only` to match the push path, so a plain pull can no longer manufacture a merge commit in the store. EGB-1231: once a store diverged there was no way out — push demanded a fast-forward and pointed at pull, which couldn't fast-forward either, so the advice looped and recovery meant hand-running git next to encrypted blobs. Adds `secrets sync`: fetch, stash, rebase onto the remote, restore the stash, then a confirmation-gated push of local commits (`--yes` to skip the prompt, `--dry-run` to report only). Non-destructive by construction — no merge, no force-push, no `reset --hard`, no `stash drop`; a rebase conflict names the conflicting files and leaves the store exactly as found. `secrets which` gains a `remote:` line reporting ahead/behind/dirty, and push's dead-end message now points at `sync`. test/sync.bats: 25 new tests. Full suite 353/353 green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BrUoYuUMoTj91rzV4vxGPB
This commit is contained in:
parent
2558ea3c23
commit
dd6a025fa0
6 changed files with 681 additions and 8 deletions
373
test/sync.bats
Normal file
373
test/sync.bats
Normal file
|
|
@ -0,0 +1,373 @@
|
|||
#!/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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue