Skip to content

Commit ae4497e

Browse files
committed
fix(specgit): restore failed bootstrap record
1 parent f0ab55a commit ae4497e

3 files changed

Lines changed: 185 additions & 6 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,7 @@ Kept OUTSIDE the managed block so `specgit init`/`--force` never rewrites them;
279279

280280
- Never run bare `specgit init --force` here: it overwrites the six specialized bytes; the wrapper exists to make that refresh transient.
281281
- Fail-closed rejections: dirty write-surface paths (tracked/staged/untracked) → exit 2 with the offending paths listed; no SpecGit binding (`.specgit.yaml` or `spec_git/policy.yaml` missing) → exit 3; restore hash mismatch → exit 3 with the snapshot kept for forensics. Rejection paths print plain `specgit-bootstrap:` stderr lines and NEVER produce a `--json` envelope.
282-
- The inner `.specgit.yaml` written by `specgit issue` is a legitimate binding artifact and is never rolled back.
282+
- The inner `.specgit.yaml` delivery record is rolled back to its pre-run bytes when the inner `specgit issue` exits nonzero (or a signal/init failure interrupts); a successful call keeps the new binding. Record-restore failure keeps the forensic snapshot and exits 3, overriding the inner exit code. Branches, commits, and remote side effects are never undone (#530).
283283
- Managed-block guidance referencing bare `specgit issue` commands is superseded by this section for this repository. Behavior tests: `bash script/specgit-bootstrap.test.sh` (stubbed CLI, zero network; not CI-wired).
284284

285285
<!-- specgit:block:start -->

script/specgit-bootstrap.sh

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,14 @@
1919
# verifies each restored file byte-for-byte against the recorded hash;
2020
# any mismatch is reported loudly and exits 3.
2121
#
22-
# The `.specgit.yaml` binding written/updated by the inner `specgit issue` is a
23-
# legitimate delivery artifact and is never rolled back. Wrapper rejections
24-
# print plain stderr lines prefixed `specgit-bootstrap:` — never a `--json`
25-
# envelope; only the inner CLI receives the wrapped arguments.
22+
# The `.specgit.yaml` delivery record gets conditional rollback (#530): a
23+
# failed inner `specgit issue` (nonzero exit, signal, or init failure) has its
24+
# pre-run bytes restored byte-for-byte; a successful inner call keeps the new
25+
# binding. Record-restore failure keeps the snapshot for forensics and exits
26+
# 3, overriding the inner exit code. Branches, commits, and remote side
27+
# effects are never undone. Wrapper rejections print plain stderr lines
28+
# prefixed `specgit-bootstrap:` — never a `--json` envelope; only the inner
29+
# CLI receives the wrapped arguments.
2630
#
2731
# Usage: script/specgit-bootstrap.sh <specgit issue args...>
2832
#
@@ -99,7 +103,26 @@ for rel in $SURFACE; do
99103
}
100104
done
101105

106+
# Conditional record rollback (#530): snapshot the pre-run `.specgit.yaml`
107+
# bytes so a failed inner call can restore them; snapshot failure aborts
108+
# before any side effect.
109+
RECORD_SNAPPED=0
110+
cp .specgit.yaml "$SNAP/tree/.specgit.yaml" || {
111+
rm -rf "$SNAP"
112+
say "snapshot copy failed for .specgit.yaml"
113+
exit 3
114+
}
115+
git hash-object -- .specgit.yaml > "$SNAP/hashes/.specgit.yaml" || {
116+
rm -rf "$SNAP"
117+
say "content hash failed for .specgit.yaml"
118+
exit 3
119+
}
120+
RECORD_SNAPPED=1
121+
102122
RESTORED=0
123+
# Default "failed until the inner call proves success": signal and init
124+
# failure paths hit restore_all before `issue_status` is ever assigned.
125+
issue_status=1
103126

104127
# Idempotent restore + byte verification. On mismatch the snapshot directory
105128
# is KEPT for forensics and the wrapper exits 3 (fail-closed, aligning with
@@ -120,6 +143,23 @@ restore_all() {
120143
mismatched=1
121144
fi
122145
done
146+
# Roll back the delivery record only when the inner bootstrap failed;
147+
# success keeps the new binding verbatim (#530).
148+
if [ "$RECORD_SNAPPED" -eq 1 ] && [ "$issue_status" -ne 0 ]; then
149+
if [ -f "$SNAP/tree/.specgit.yaml" ]; then
150+
cp "$SNAP/tree/.specgit.yaml" .specgit.yaml
151+
now=$(git hash-object -- .specgit.yaml 2>/dev/null)
152+
want=$(cat "$SNAP/hashes/.specgit.yaml" 2>/dev/null)
153+
if [ "$now" != "$want" ]; then
154+
printf 'specgit-bootstrap: RESTORE MISMATCH for %s (got %s, expected %s)\n' \
155+
".specgit.yaml" "${now:-<none>}" "${want:-<none>}" >&2
156+
mismatched=1
157+
fi
158+
else
159+
printf 'specgit-bootstrap: RESTORE MISMATCH for %s (snapshot missing)\n' ".specgit.yaml" >&2
160+
mismatched=1
161+
fi
162+
fi
123163
if [ "$mismatched" -eq 1 ]; then
124164
say "restored bytes differ from pre-run snapshots - specialized harness bytes may be corrupted."
125165
say "snapshot kept for forensics at $SNAP; inspect 'git diff' before continuing."

script/specgit-bootstrap.test.sh

Lines changed: 140 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
# shellcheck disable=SC2015,SC2329
33
# ok/bad always return 0, so `cond && ok .. || bad ..` cannot mis-fire (SC2015);
44
# cleanup() runs via the EXIT trap, which shellcheck does not count (SC2329).
5-
# Behavior tests for script/specgit-bootstrap.sh (#521).
5+
# Behavior tests for script/specgit-bootstrap.sh (#521, #530 record rollback).
66
#
77
# Zero network, zero forge: `specgit` is a stub placed first on PATH; every
88
# fixture is a throwaway git repo under $TMPDIR. The real repository is never
@@ -130,6 +130,36 @@ case "$cmd" in
130130
fi
131131
exit "${STUB_ISSUE_EXIT:-0}"
132132
;;
133+
fail_after_branch_write)
134+
printf 'version: 1\nbranch: feat/probe\nissues: [530]\n' > .specgit.yaml
135+
exit "${STUB_ISSUE_EXIT:-3}"
136+
;;
137+
fail_after_push_delete)
138+
rm -f .specgit.yaml
139+
exit "${STUB_ISSUE_EXIT:-3}"
140+
;;
141+
fail_after_pr_write)
142+
printf 'version: 1\nbranch: feat/probe\nissues: [530, 533]\n' > .specgit.yaml
143+
exit "${STUB_ISSUE_EXIT:-3}"
144+
;;
145+
fail_after_push_commit)
146+
printf 'version: 1\nbranch: feat/probe\nissues: [530]\n' > .specgit.yaml
147+
git add .specgit.yaml
148+
git commit -qm "stub: record delivery binding"
149+
exit "${STUB_ISSUE_EXIT:-3}"
150+
;;
151+
succeed_after_record_write)
152+
printf 'version: 1\nbranch: feat/probe\nissues: [530]\n' > .specgit.yaml
153+
exit 0
154+
;;
155+
tamper_record_snapshot)
156+
d=$(ls -dt "${TMPDIR:-/tmp}"/specgit-bootstrap.* 2>/dev/null | sed -n '1p')
157+
if [ -n "$d" ] && [ -d "$d/tree" ]; then
158+
printf 'tampered-record\n' > "$d/tree/.specgit.yaml"
159+
printf '%s\n' "$d" >> "${STUB_LOG}.snapdir"
160+
fi
161+
exit "${STUB_ISSUE_EXIT:-0}"
162+
;;
133163
esac
134164
;;
135165
*)
@@ -267,5 +297,114 @@ assert_rc 3 "$rc" && ok "case8: wrapper exits 3 on unbound repository" || bad "c
267297
grep -q 'spec_git/policy.yaml\|\.specgit\.yaml' "$WORK/c8.err" && ok "case8: stderr names the missing binding" || bad "case8: stderr lacks binding context"
268298
grep -q '^specgit-bootstrap:' "$WORK/c8.err" && ok "case8: diagnostics use specgit-bootstrap: prefix" || bad "case8: missing prefix"
269299

300+
# ---- case 9: validation failure — record untouched, rc passthrough ---------
301+
new_fixture c9
302+
make_stub c9
303+
cp "$WORK/c9/.specgit.yaml" "$WORK/c9.record-baseline"
304+
surface_digests "$WORK/c9" > "$WORK/c9.baseline"
305+
STUB_ISSUE_EXIT=2 run_wrapper c9 "fix: probe"
306+
rc=$?
307+
assert_rc 2 "$rc" && ok "case9: wrapper exits 2 (validation failure passthrough)" || bad "case9: exit $rc, want 2"
308+
diff "$WORK/c9/.specgit.yaml" "$WORK/c9.record-baseline" >/dev/null && ok "case9: record bytes untouched" || bad "case9: record bytes changed"
309+
assert_surface_restored "$WORK/c9" "$WORK/c9.baseline" && ok "case9: surface bytes restored" || bad "case9: surface bytes differ"
310+
assert_clean "$WORK/c9" && ok "case9: fixture clean" || bad "case9: fixture dirty: $(git -C "$WORK/c9" status --porcelain | tr '\n' '|')"
311+
312+
# ---- case 10: branch-creation failure — rewritten record rolled back --------
313+
new_fixture c10
314+
make_stub c10
315+
cp "$WORK/c10/.specgit.yaml" "$WORK/c10.record-baseline"
316+
surface_digests "$WORK/c10" > "$WORK/c10.baseline"
317+
STUB_ISSUE_MODE=fail_after_branch_write STUB_ISSUE_EXIT=3 run_wrapper c10 "fix: probe"
318+
rc=$?
319+
assert_rc 3 "$rc" && ok "case10: wrapper exits 3 (branch failure passthrough)" || bad "case10: exit $rc, want 3"
320+
diff "$WORK/c10/.specgit.yaml" "$WORK/c10.record-baseline" >/dev/null && ok "case10: rewritten record rolled back to pre-run bytes" || bad "case10: record not rolled back: $(tr '\n' '|' < "$WORK/c10/.specgit.yaml")"
321+
assert_surface_restored "$WORK/c10" "$WORK/c10.baseline" && ok "case10: surface bytes restored" || bad "case10: surface bytes differ"
322+
assert_clean "$WORK/c10" && ok "case10: fixture clean" || bad "case10: fixture dirty: $(git -C "$WORK/c10" status --porcelain | tr '\n' '|')"
323+
324+
# ---- case 11: push failure deleting the record — file recreated (#519) ------
325+
new_fixture c11
326+
make_stub c11
327+
cp "$WORK/c11/.specgit.yaml" "$WORK/c11.record-baseline"
328+
surface_digests "$WORK/c11" > "$WORK/c11.baseline"
329+
STUB_ISSUE_MODE=fail_after_push_delete STUB_ISSUE_EXIT=3 run_wrapper c11 "fix: probe"
330+
rc=$?
331+
assert_rc 3 "$rc" && ok "case11: wrapper exits 3 (push failure passthrough)" || bad "case11: exit $rc, want 3"
332+
[ -f "$WORK/c11/.specgit.yaml" ] && ok "case11: deleted record recreated" || bad "case11: record still missing"
333+
diff "$WORK/c11/.specgit.yaml" "$WORK/c11.record-baseline" >/dev/null 2>&1 && ok "case11: record bytes byte-identical after deletion rollback" || bad "case11: record bytes: $(tr '\n' '|' < "$WORK/c11/.specgit.yaml" 2>/dev/null)"
334+
assert_surface_restored "$WORK/c11" "$WORK/c11.baseline" && ok "case11: surface bytes restored" || bad "case11: surface bytes differ"
335+
assert_clean "$WORK/c11" && ok "case11: fixture clean" || bad "case11: fixture dirty: $(git -C "$WORK/c11" status --porcelain | tr '\n' '|')"
336+
337+
# ---- case 12: PR-creation failure — rewritten record rolled back ------------
338+
new_fixture c12
339+
make_stub c12
340+
cp "$WORK/c12/.specgit.yaml" "$WORK/c12.record-baseline"
341+
surface_digests "$WORK/c12" > "$WORK/c12.baseline"
342+
STUB_ISSUE_MODE=fail_after_pr_write STUB_ISSUE_EXIT=3 run_wrapper c12 "fix: probe"
343+
rc=$?
344+
assert_rc 3 "$rc" && ok "case12: wrapper exits 3 (PR failure passthrough)" || bad "case12: exit $rc, want 3"
345+
diff "$WORK/c12/.specgit.yaml" "$WORK/c12.record-baseline" >/dev/null && ok "case12: rewritten record rolled back to pre-run bytes" || bad "case12: record not rolled back: $(tr '\n' '|' < "$WORK/c12/.specgit.yaml")"
346+
assert_surface_restored "$WORK/c12" "$WORK/c12.baseline" && ok "case12: surface bytes restored" || bad "case12: surface bytes differ"
347+
assert_clean "$WORK/c12" && ok "case12: fixture clean" || bad "case12: fixture dirty: $(git -C "$WORK/c12" status --porcelain | tr '\n' '|')"
348+
349+
# ---- case 13: success keeps the new binding ---------------------------------
350+
new_fixture c13
351+
make_stub c13
352+
surface_digests "$WORK/c13" > "$WORK/c13.baseline"
353+
STUB_ISSUE_MODE=succeed_after_record_write run_wrapper c13 "feat: probe"
354+
rc=$?
355+
assert_rc 0 "$rc" && ok "case13: wrapper exits 0" || bad "case13: exit $rc, want 0"
356+
grep -q 'issues: \[530\]' "$WORK/c13/.specgit.yaml" && ok "case13: successful bootstrap keeps new binding" || bad "case13: new binding lost: $(tr '\n' '|' < "$WORK/c13/.specgit.yaml")"
357+
assert_surface_restored "$WORK/c13" "$WORK/c13.baseline" && ok "case13: surface bytes restored" || bad "case13: surface bytes differ"
358+
[ "$(git -C "$WORK/c13" status --porcelain | grep -cv 'specgit.yaml')" = "0" ] && ok "case13: only the record binding differs post-success" || bad "case13: unexpected residual changes: $(git -C "$WORK/c13" status --porcelain | tr '\n' '|')"
359+
360+
# ---- case 14: pre-run dirty record — dirty bytes restored, not committed ----
361+
new_fixture c14
362+
make_stub c14
363+
printf 'version: 1\nbranch: feat/dirty-pre\nissues: [519]\n' > "$WORK/c14/.specgit.yaml"
364+
cp "$WORK/c14/.specgit.yaml" "$WORK/c14.record-baseline"
365+
surface_digests "$WORK/c14" > "$WORK/c14.baseline"
366+
STUB_ISSUE_MODE=fail_after_branch_write STUB_ISSUE_EXIT=3 run_wrapper c14 "fix: probe"
367+
rc=$?
368+
assert_rc 3 "$rc" && ok "case14: wrapper exits 3" || bad "case14: exit $rc, want 3"
369+
diff "$WORK/c14/.specgit.yaml" "$WORK/c14.record-baseline" >/dev/null && ok "case14: pre-run dirty bytes restored (not committed bytes)" || bad "case14: record bytes: $(tr '\n' '|' < "$WORK/c14/.specgit.yaml")"
370+
assert_surface_restored "$WORK/c14" "$WORK/c14.baseline" && ok "case14: surface bytes restored" || bad "case14: surface bytes differ"
371+
372+
# ---- case 15: untracked record (git rm --cached) — restored byte-for-byte ---
373+
new_fixture c15
374+
make_stub c15
375+
git -C "$WORK/c15" rm -q --cached .specgit.yaml
376+
cp "$WORK/c15/.specgit.yaml" "$WORK/c15.record-baseline"
377+
STUB_ISSUE_MODE=fail_after_pr_write STUB_ISSUE_EXIT=3 run_wrapper c15 "fix: probe"
378+
rc=$?
379+
assert_rc 3 "$rc" && ok "case15: wrapper exits 3" || bad "case15: exit $rc, want 3"
380+
[ -f "$WORK/c15/.specgit.yaml" ] && ok "case15: untracked record file exists after restore" || bad "case15: record missing"
381+
diff "$WORK/c15/.specgit.yaml" "$WORK/c15.record-baseline" >/dev/null && ok "case15: untracked record bytes restored" || bad "case15: record bytes: $(tr '\n' '|' < "$WORK/c15/.specgit.yaml")"
382+
383+
# ---- case 16: record snapshot tampering — loud mismatch, exit 3, snap kept --
384+
new_fixture c16
385+
make_stub c16
386+
STUB_ISSUE_MODE=tamper_record_snapshot STUB_ISSUE_EXIT=1 run_wrapper c16 "fix: probe"
387+
rc=$?
388+
assert_rc 3 "$rc" && ok "case16: wrapper exits 3 on record restore mismatch" || bad "case16: exit $rc, want 3"
389+
grep -q 'RESTORE MISMATCH for .specgit.yaml' "$WORK/c16.err" && ok "case16: record mismatch reported loudly on stderr" || bad "case16: no record RESTORE MISMATCH diagnostic"
390+
snapdir=$(sed -n '1p' "$WORK/stubs/c16/log.snapdir" 2>/dev/null)
391+
[ -n "$snapdir" ] && [ -d "$snapdir" ] && ok "case16: forensic snapshot kept" || bad "case16: snapshot removed: ${snapdir:-<none>}"
392+
grep -q 'tampered-record' "$WORK/c16/.specgit.yaml" && ok "case16: corrupted record surfaced (deliberate artifact)" || bad "case16: record bytes unexpected"
393+
394+
# ---- case 17: failed push that committed — commit kept, worktree restored ---
395+
new_fixture c17
396+
make_stub c17
397+
cp "$WORK/c17/.specgit.yaml" "$WORK/c17.record-baseline"
398+
heads0=$(git -C "$WORK/c17" rev-list --count HEAD)
399+
STUB_ISSUE_MODE=fail_after_push_commit STUB_ISSUE_EXIT=3 run_wrapper c17 "fix: probe"
400+
rc=$?
401+
assert_rc 3 "$rc" && ok "case17: wrapper exits 3" || bad "case17: exit $rc, want 3"
402+
heads1=$(git -C "$WORK/c17" rev-list --count HEAD)
403+
assert_rc $((heads0 + 1)) "$heads1" && ok "case17: commit made during failed bootstrap is NOT undone" || bad "case17: HEAD count $heads1, want $((heads0 + 1))"
404+
git -C "$WORK/c17" show HEAD:.specgit.yaml > "$WORK/c17.head-record" 2>/dev/null && \
405+
grep -q 'issues: \[530\]' "$WORK/c17.head-record" && ok "case17: committed record content intact in HEAD" || bad "case17: HEAD record missing stub content"
406+
diff "$WORK/c17/.specgit.yaml" "$WORK/c17.record-baseline" >/dev/null && ok "case17: only the record's worktree diff restored" || bad "case17: worktree record not restored: $(tr '\n' '|' < "$WORK/c17/.specgit.yaml")"
407+
[ "$(git -C "$WORK/c17" status --porcelain | grep -cv 'specgit.yaml')" = "0" ] && ok "case17: non-record paths clean (worktree == committed surface)" || bad "case17: unexpected residual changes: $(git -C "$WORK/c17" status --porcelain | tr '\n' '|')"
408+
270409
report
271410
exit $?

0 commit comments

Comments
 (0)