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
373 lines
12 KiB
Bash
373 lines
12 KiB
Bash
#!/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
|
|
}
|