feat(repo-hygiene): capture every branch tip and require it before any deletion batch - #3900
Conversation
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
Claude finished @kyle-sexton's task in 6m 12s —— View job Security review complete
Reviewed the diff introducing Two IMPORTANT findings, both in the capture-trust model that gates deletion — posted as inline comments with suggested fixes:
Everything else in the new deletion path held up under adversarial reading: branch names can't carry the tab/newline/ Not re-reported here (out of this lane's scope): GitHub Actions hardening (zizmor's lane) — not applicable, no workflow files changed. |
|
Claude finished @kyle-sexton's task in 4m 4s —— View job Claude finished the code review
SummaryReviewed the diff ( No blocking findings. I traced the two windows this PR closes end to end:
I did not independently re-run the shell test suites or |
|
Last security-reviewed head: |
|
Claude has reviewed this PR 1 time. The lane skips further automatic reviews after 5; deleting this comment resets the count. |
…y deletion batch git-branch-audit.sh reports a Tip: field for every branch and writes the tips, with tier, PR, upstream, ahead/behind and a timestamp, to a sealed capture under the main checkout's .git/repo-hygiene/branch-tips/, printed as TipCapture:. git-branch-delete.sh is the only sanctioned deletion path: it refuses the whole batch without a capture, a captured tip per branch, and an unmoved tip; in --apply it pins each tip under refs/repo-hygiene/deleted/<branch>, appends the deletion to a ledger, and only then deletes. The test restores deleted branches from the capture alone after gc --prune=now. Closes #3853 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ViPsHkL3ng9xWt2GjEQJob
Replaces the `A && pass || fail` chains (SC2015) with assert_branch and assert_eq helpers so a failing pass() can never fall through to fail(). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ViPsHkL3ng9xWt2GjEQJob
The suite was committed 100644 while every sibling test is 100755, which fails the exec-bit lint gate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ViPsHkL3ng9xWt2GjEQJob
…and the capture format shape-based The delete step is now `git update-ref -d refs/heads/<branch> <tip>`, a compare-and-delete inside git's ref lock. The per-branch re-check closes the window between the batch check and the pin; the conditional delete closes the window between the pin and the delete, which `git branch -D` left open: a tip moved there was deleted and its commit lost to gc. A refused delete aborts the batch with the branch and its pin intact and a `# not deleted:` note in the ledger. The merged check `git branch -d` used to make is now a batch-level refusal for SAFE-by-ancestry rows, so a forged SAFE row no longer yields a ledger row for a branch still present. Capture rows are recognised by shape (nine columns, a commit id second) on both sides instead of by a leading `#`, which is a legal first character of a branch name and made the seal count one row short, failing the whole audit. The `.part` is created with noclobber so a stamp-pid collision cannot interleave two runs, and a foreign `.part` is left alone. The ledger is derived from the capture's real path, so a symlinked capture no longer puts it beside the link. Tests open both tip-move windows deterministically: a FIFO at the ledger path parks the script between pin and delete, and the pin appearing is the signal to move the tip. Removing the re-check fails the window-A cases; reverting to `git branch -D` fails the window-B cases including the moved commit being pruned; restoring the comment-based seal fails every capture case with TipCaptureError. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ViPsHkL3ng9xWt2GjEQJob
…_dir delete_mode() treated a SAFE row as a force delete whenever the captured pr column contained the substring MERGED, which skipped the ancestry check. Both tier and pr are untrusted capture text, so require the audit's exact format (`#<n> MERGED`, optionally ` (tip drift)`). A test forges pr to UNMERGED (contains MERGED, not the format) and expects the ancestry refusal. An unresolved capture common_dir (missing or the literal unknown) skipped the only cross-repository check. Treat it as a refusal, matching the audit's fail-closed posture when it cannot resolve one. Co-authored-by: ksextonmelodic <ksextonmelodic@gmail.com>
ae18b84 to
5a8093b
Compare
markdownlint MD038 rejects spaces inside code spans. Spell the optional suffix as `#<n> MERGED (tip drift)` instead of a code span that starts with a space. Co-authored-by: ksextonmelodic <ksextonmelodic@gmail.com>
…ing them when no telemetry sink is set (#3913) Closes #3862 ## Summary Guard decisions left the process only through `HOOK_TELEMETRY_SINK`, which is inert unless an environment variable names an executable. On an ordinary install every decision was discarded as it was made, so "why was this denied", "has it denied this all along", and "did the guard run at all" had no evidence to answer from. New `plugins/disk-hygiene/lib/guard_decision_log.py` appends one JSON object per line to `<CLAUDE_PLUGIN_DATA>/guard-decisions/decisions.jsonl`, default on, no configuration. `destructive_guard.py` records every branch that reaches a verdict; `guard_launch_monitor.py` records the did-not-run state the guard structurally cannot write about itself. Version taken: **0.23.0**. Verified free against current `main` (`6db96637d`, 0.21.9) and against the head of every open PR at the moment of opening: #3880 claims 0.21.10, #3783 claims 0.22.0, and #3851/#3887/#3900/#3779 leave the manifest at 0.21.9. 0.23.0 is strictly greater than all of them, so it cannot collide under any merge order; the 0.22.x gap is what `--check-order` explicitly reads as correctly ordered. ## Fix **Where the record lives, and why it survives.** Under the plugin's own persistent data root, resolved by the guard's existing `resolve_authorized_data_root()` (the `--authorized-data-root` / `--plugin-root` / `CLAUDE_PLUGIN_DATA` ladder) beside the run records already kept there. Because this plugin is the one that deletes things, the filename was checked against the engine's own discovery hints in `reference/baseline-policy.json`: `decisions.jsonl` and `decisions.previous.jsonl` match none of the 14 bundled name globs (`*.tmp`, `tmp-*`, `tmp_*`, `scratch*`, `*.lock`, `__pycache__`, `*.partial`, `*.crdownload`, `*.tmp.*`, `.claude.json.tmp.*`, `temp_git_*`, `.pulumi-write-test-*`, `.DS_Store`, `Thumbs.db`), so the plugin's own hints never nominate its audit trail. Appending directly rather than writing a temp file and renaming is deliberate for the same reason: an atomic-write staging name would land on `*.tmp.*`, which is a bundled hint. **What is recorded.** `schema_version`, `timestamp` (UTC, milliseconds), `hook`, `decision`, `rule`, `tool`, `mode`, `command`, `reason`. `decision` is `allow` / `ask` / `deny` / `none` (ran, issued no `permissionDecision`) / `not-run`. `rule` names the branch that fired, so `kill-switch-disabled-apply` is distinguishable from `not-exact-engine-command`: two different answers to "why". `command` is the input that drove it and `reason` is the exact text the host was given, so the record and the host cannot disagree. Both are clipped to 400 characters, which keeps it a record of the decision rather than a copy of the payload and keeps every line short enough that concurrent hook processes appending to the same file do not interleave. **Bounded, enforced.** The live file rotates to `decisions.previous.jsonl` at 1 MiB via `os.replace`, so the record occupies at most about 2 MiB forever with no operator pruning. The bound is checked from the offset the append already returns (`handle.tell()` in append mode), so enforcing it costs no extra syscall. **Cost.** Measured with `strace -f -e trace=clone,clone3,fork,vfork,execve,openat,write` against a detached worktree of `origin/main` at `6db96637d`, five invocations per arm plus a warm-path detail run: | Path | Before | After | | --- | --- | --- | | defer (a Bash command not naming the engine, the always-on branch) | 1 `execve`, 1 `clone3` | 1 `execve`, 1 `clone3`, 0 record syscalls | | decision (deny), warm data root | 1 `execve`, 1 `clone3` | 1 `execve`, 1 `clone3`, 1 `openat` + 1 `write` | | decision (deny), first write of an install | 1 `execve`, 1 `clone3` | plus 1 failed `openat` and 1 `mkdir` | The single `clone3` is `CLONE_THREAD`, the existing watchdog thread, not a process. **The process and exec census is unchanged on every path.** The plugin-level defer branch, which is what this always-on hook takes for work unrelated to disk-hygiene, writes nothing at all and is byte-for-byte the path it was. Wall clock over 40 invocations per arm, alternated twice, moved inside run-to-run noise on this host (52 to 58 ms both before and after, the sign of the difference changing between repetitions), which is why the syscall census rather than a duration is the figure cited. **Failure behavior: the verdict never changes.** Two boundaries, both load-bearing. `guard_decision_log.record` returns a bool and catches `BaseException` around the whole write. `_record_decision` in the guard wraps its own call, because the data root and mode are resolved in the argument list, outside `record`'s protection, and one of its call sites is `main`'s own `except BaseException` handler, where a raise would reach the interpreter's default handler: exit 1, which PreToolUse treats as non-blocking, so the command the guard just denied would run. Every record call is made after the verdict has been emitted, and its result is discarded. **What is deliberately not recorded.** The plugin-level defer (hot path, and not a decision anyone reconstructs later). The watchdog expiry path: that callback runs while the main thread is presumed wedged inside a filesystem call and stays syscall-free for exactly that reason, so a write there could hang on the same filesystem. Both are stated in the README rather than left implicit. **Adjacency, stayed out of.** #3861 (the guard's fail-open when no interpreter resolves) is `needs-human`. This change touches the guard's decision branches but not interpreter resolution, and adds no new fail-open path; the `not-run` record makes the fail-open class more visible after the fact without adjudicating it. **Escape hatch.** `DISK_HYGIENE_GUARD_DECISION_LOG` set to `0` / `off` / `false` / `no` turns the record off. Opt-out, not opt-in: any other value, including an absent one, records. ## Verification All runs local and in the foreground; draft CI is not cited as test evidence. - `bash scripts/affected-tests.sh --run --shard N/4`: shards 0, 1, 2 exit **3** (success, with `NOT RUN` non-shell ecosystems), 0 `FAIL` lines each. Shard 3 exits 1 with exactly three `FAIL` lines, all from `plugins/claude-ops/skills/plugins/scripts/cache-content-check.test.sh` (`process budget: the trace probe actually counted something`, `process budget: a one-install report costs at most 26 process creations`). **Reproduced unchanged on a clean detached worktree of `origin/main` at `6db96637d`**: same suite, same 2 cases, exit 1. Pre-existing, not this change. Every changed file maps to at least one suite; the three docs/manifest files resolve through the recorded no-suite allowlist. - Direct suites: `hygiene.test.sh` RC=0 (350 cases), `guard_launch_monitor.test.sh` RC=0 (28 cases), `run-python-hook.test.sh` RC=0, `test_guard_decision_log.py` + `test_hook_telemetry.py` RC=0 (15 new cases). - All four parity modes green: `--check`, `--check-order`, `--check-bump origin/main`, `--check-preserved origin/main`. - `scripts/run-ruff.sh check plugins/disk-hygiene`: all checks passed. `format --check`: my four touched/new Python files are clean. Five files remain unformatted in this plugin (`killswitch_config.py`, `hygiene.py`, `guard_launch_monitor.py:137`, `test_hygiene.py:2373`, `test_kill_switch_probe.py:96`); all five are identically unformatted on `origin/main`, so none is introduced here. - Gates run clean: `check-purged-em-dashes.sh`, `check-drive-root-litter.sh`, `check-silent-skips.sh`, `check-discriminating-test-skips.sh`, `check-fixture-git-isolation.sh`, `check-hook-exec-form.sh`, `check-killswitch-hoist.sh`, all RC=0. New test file committed `100755` (verified with `git ls-tree`), the new library `100644` matching its sibling `hook_telemetry.py`. - No shell files changed, so shellcheck and shfmt have nothing to say about this diff. `lib/hook-utils.sh` untouched. **Mutation proof (the new assertions discriminate).** Nine mutations applied one group at a time, each reverted: | Mutation | Caught by | | --- | --- | | rotation call disabled | 3 lib cases (`rotates_at_the_bound`, `discards_only_the_generation_before_last`, `a_failing_rotation...`) | | `_clip` returns text unchanged | `long_command_and_reason_are_truncated` | | `enabled()` hardcoded True | 5 lib subtests + `the_record_can_be_turned_off_without_changing_a_verdict` | | deny rule string collapsed onto the kill-switch rule | `denied_engine_command_is_recorded_with_its_rule_and_input` | | a record added on the defer path | `engine_gate_defer_records_nothing` | | `_record_decision`'s try/except removed | `a_broken_decision_record_never_changes_a_verdict` (record raises), `record_decision_swallows_a_failure_in_data_root_resolution`, and the **pre-existing** `every_call_graph_function_failure_denies_at_exit_2_never_1` for both `resolve_mode` and `resolve_authorized_data_root` | | `_record_not_run` call removed from the monitor | 3 monitor cases | The write-failure proof is `test_a_broken_decision_record_never_changes_a_verdict`, which drives all five verdict shapes (`allow`, `ask`, `deny`-by-authority, `deny`-by-kill-switch, and the no-output defer) three times: unsabotaged, against a data root whose parent is a regular file (a real filesystem `OSError` on both the append and the `mkdir` behind it), and with `record` raising `RuntimeError`. All three runs produce the identical verdict list, and the unwritable root is asserted to still not exist afterwards. `test_a_write_failure_leaves_the_deny_exit_status_untouched` pins the same thing end to end through `main`. **Hermeticity fix included.** `run_guard_engine_gate` previously passed `SCRIPT_DIR / "data-root"` as the authorized data root. With records being written, that would have littered the checkout on every test run, so it now takes a per-test temp path, and `GuardTests.setUp` pops an inherited `CLAUDE_PLUGIN_DATA` so a developer's real plugin data directory is never written to by the suite. ## Related - Closes #3862; parent #3347 finding F12. - Sibling #3861 (guard fail-open when no interpreter resolves) is `needs-human` and deliberately untouched; the `not-run` record is what makes that class visible after the fact. - Sibling finding on a false denial: `rule` plus `command` plus `reason` is what answers it from the record instead of by reproduction. - Hook budget convention: `docs/conventions/hook-budget/README.md`, `.claude/rules/hook-budget.md`. Measured share stated in the plugin README's trust-surface record. - Version contention checked against open PRs #3783 (0.22.0) and #3880 (0.21.10). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01ViPsHkL3ng9xWt2GjEQJob --- _Generated by [Claude Code](https://claude.ai/code/session_01ViPsHkL3ng9xWt2GjEQJob)_ --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: ksextonmelodic <ksextonmelodic@gmail.com>
…accept-loss gate (#3918) Closes #3854 ## Summary `repo-hygiene:clean` classified every branch as safe, needing review, or protected, with no way to say "this loses commits, and that is acceptable here". Operators either lost work or were blocked. This adds a LOSSY tier: a branch that is deletable but carries unmerged commits, surfaced as its own block before the deletion confirmation rather than folded into the safe list, and gated behind an explicit `--accept-loss` acknowledgement. ## Fix Rebased onto origin/main after #3900 merged. The overlapping tip-capture commits were dropped; the LOSSY commits replayed on top of the merged delete path, and `git-branch-delete.sh` now admits LOSSY under `--accept-loss`. - `git-branch-audit.sh` refines a REVIEW verdict into LOSSY when the tip resolved, `origin/<default>` is present, the count of commits on no remote ref and no tag succeeded and is positive, and the PR state is neither MERGED nor OPEN. Every missing or failed signal yields `Loss: undetermined` and REVIEW; SAFE and LIKELY-SAFE are untouched. The LOSSY set is printed again as its own LossBlock, and Summary gains `lossy=`. - `git-branch-delete.sh` admits LOSSY only under `--accept-loss`; a batch that carries one without it is refused whole. `--force-review` does not admit LOSSY and `--accept-loss` does not admit REVIEW. A LOSSY apply pins the captured tip, ledgers the tier, compare-and-deletes, and restores after `gc --prune=now`. - SKILL.md §4.2 and `context/git-branch-cleanup.md` state the tier's checkable boundary and that the LossBlock is its own section before the deletion confirmation, with its own affirmative answer. Plugin version `0.10.36` (above main `0.10.35`). Ambiguous loss is REVIEW, not LOSSY: the block sits immediately before the deletion confirmation, so misclassifying an unrecoverable branch as lossy-but-deletable is the failure that matters. That is stricter than "not SAFE"; it also refuses the deletable tier when the loss cannot be measured. ## Verification - `plugins/repo-hygiene/skills/clean/scripts/git-branch-audit.test.sh` (115 cases) - `plugins/repo-hygiene/skills/clean/scripts/git-branch-delete.test.sh` (147 cases), including LOSSY apply through the sanctioned path and the near-miss batch (SAFE + LOSSY on the SAFE confirmation refuses the whole batch) - `scripts/affected-tests.sh --run` (shell suites covering the diff; the one `block-hook-bypass` FAIL is the suite's symlink case when the worktree itself lives under `/tmp`, reproduced on origin/main, not this change) - all four `check-changelog-parity.sh` modes vs origin/main - `shellcheck -x` and `shfmt -d` on the changed scripts - mutation: dropping LOSSY admission, admitting LOSSY without `--accept-loss`, and treating a missing `origin/<default>` as LOSSY are all killed by the new tests ## Remaining work None. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Kyle Sexton <ksextonclaude@outlook.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: ksextonmelodic <ksextonmelodic@gmail.com>
Closes #3853
Summary
Makes recovery from a wrong branch deletion mechanical.
git-branch-audit.shnow reports every branch's tip commit as a structuredTip:field and writes all tips to a durable capture file; a newgit-branch-delete.shbecomes the git tier's only sanctioned deletion path and refuses to delete anything unless that capture exists, covers every branch in the batch, and still matches each branch's live tip. The value is in the ordering: the tip is recorded, pinned, and ledgered before the destructive step, the destructive step itself is an atomic compare-and-delete on the recorded tip, and the test restores deleted branches from the capture alone after agc --prune=now.Fix
Where the capture lives and why it survives.
<git-common-dir>/repo-hygiene/branch-tips/<utc-stamp>-<pid>.tsv, i.e. the main checkout's.git/, even when the audit ran in a linked worktree (--capture-file PATHoverrides)..git/is untouched bytreeresets and by linked-worktree removal, and is findable by someone who knows only the repository. The path convention is documented incontext/git-branch-cleanup.md(§4.2 and the new §4.8) so recovery needs no memory of how the run was invoked.What is captured. Per branch: name, tip SHA, tier, PR state, upstream, ahead, behind, commits not on
origin/<default>, and a UTC timestamp; header lines name the repo, its common dir, the default branch, and the restore command. Rows are written for every branch regardless of verdict, since a verdict can be wrong in either direction. Rows are recognised by shape on both sides (nine tab-separated columns, a commit id in the second, the run's stamp in the last), never by a leading#:#is a legal first character of a branch name, and a comment heuristic made such a branch count one row short at the seal, failing the whole audit withTipCaptureError: short writeand leaving no branch in that repository deletable.Enforcement, and what happens when capture fails (fail closed).
.partfile, created exclusively (noclobber) so a<stamp>-<pid>collision across PID namespaces sharing a mount, or an interrupted run, is refused rather than interleaved into one file (the foreign.partis left alone). It is renamed into place only after a shape-based row count on disk equals the rows produced. Any failure (unresolvable common dir, unwritable directory, existing.part, short write, failed rename) printsTipCaptureError: <why>and noTipCapture:path. A branch whose tip cannot be resolved getsTip: unresolvedand no row.git-branch-delete.sh --capture PATH BRANCH...runs a batch-wide precondition before the first deletion: capture present, readable, correct header, taken in this repository (compared by common dir), every branch has a row, every captured tip equals the live tip, a SAFE-by-ancestry tip is actually merged intoorigin/<default>(the checkgit branch -dused to make, now made before anything is written), and no branch is current/default/protected-pattern/worktree-checked-out; REVIEW needs--force-review, PROTECTED and WORKTREE are never deletable. Any refusal printsRefused: <branch> (<what is missing and how to produce it>),Summary: planned=0 refused=N deleted=0, exit 3, and nothing is deleted, including the branches that would have passed.Per-branch vs per-batch, and partial failure. Preconditions are per batch (all or nothing). In
--apply, each branch is then processed in order: re-verify the live tip,git update-ref refs/repo-hygiene/deleted/<branch> <tip>(the pin:git-prune.sh --applyrunsgcright after deletion andgcprunes unreachable objects pastgc.pruneExpire, so a SHA in a file alone is not a recoverable capture), append to the ledger (<capture>.deleted.tsv, beside the capture's real file with symlinks resolved), and only then delete withgit update-ref -d refs/heads/<branch> <tip>. That delete is a compare-and-delete inside git's ref lock: it refuses (cannot lock ref ... is at X but expected Y) when the tip is no longer the captured one. The per-branch re-check closes the window between the batch check and the pin; the conditional delete closes the window between the pin and the delete, which a plaingit branch -Dleft open (a tip moved there was deleted and its commit lost to the next prune). A failure in any step before the delete printsAborted: <branch> (<why>)and stops: branches already deleted keep their pin and ledger row, the failing branch and everything after it are untouched,Summary:countsdeleted/failed/aborted/untouched, exit 1. A delete refused because the tip moved also aborts: the branch stays at its new tip, its pin stays (harmless, and it records the tip the audit saw), and the ledger gains a# not deleted:note. Safe delete for SAFE by ancestry; force delete for SAFE with a merged PR (squash), LIKELY-SAFE, and forced REVIEW; the delete mechanism is the same atomic form in every mode. EveryDeleted:line and the closingRestore:line carrygit branch <branch> <tip>.Ledger path choice. The ledger follows the capture's real location rather than refusing a capture outside the sink, because
--capture-fileto a writable location is the documented fallback when the sink itself is unwritable; refusing it would remove the only recovery path from aTipCaptureError. The pin, not the ledger, is what protects the tip.Docs. SKILL.md §4.2 states the precondition;
context/git-branch-cleanup.md§4.7 routes deletion through the script, documents the atomic delete, the ledger and the pins, and adds §4.8 (restore steps);context/action-router.mdnames the script in thegitrow. The delete script is deliberately not inallowed-tools(mutating; the pairing test's allowlist is unchanged and passes). The session guard does not matchgit branch -D(that is #3852), so "only through the script" is enforced by the script's preconditions plus the skill text, not by a hook.Interaction with sibling #3854 (not implemented here). A lossy-but-deletable verdict block would sit at the confirmation gate, immediately before
git-branch-delete.sh --apply; the delete script's tier gate (SAFE/LIKELY-SAFEfree,REVIEWbehind--force-review) is where a new tier would need to be admitted. The two changes touch adjacent but distinct places.Version.
0.10.35. Main is0.10.33; open PR #3887 (claude/3850-repo-hygiene) takes0.10.34. Re-checked against current main and every open head (61 refs) immediately before the latest push: only this branch carries0.10.35.git merge-treeagainst #3887 shows textual conflicts in four files:CHANGELOG.md(adjacent new entries),plugin.json(version line),git-branch-audit.sh(header comment block, and the setup block afterCURRENT_BRANCHwhere #3887 adds itsclean_pr_mapblock and this PR adds the capture setup), andgit-branch-cleanup.md(the output-contract line). All are additive on both sides. Merge against current main is clean.Verification
Ran in this container (Linux, git 2.43, ShellCheck 0.11.0, shfmt 3.12.0):
git-branch-delete.test.sh(new, 106 cases) andgit-branch-audit.test.sh(extended, +25 cases): pass. The delete suite demonstrates (a) a dry-run plans and deletes nothing and an apply deletes four branches, one named#42-hash, with ledger rows and pins; (b) no--capture, unreadable capture, bad header, foreign-repo capture, missing row, moved tip, forged SAFE row on an unmerged branch, mixed batch with one REVIEW branch, protected/current/worktree: each exits 3 with every branch still present, including the deletable siblings; backup-ref failure mid-batch aborts with the blocked branch and the following one untouched; an unwritable ledger aborts before anything is deleted; a symlinked capture puts the ledger beside the real file; (c) aftergc --prune=now, each deleted branch is recreated withgit branch <name> <tip>using only the name-to-tip lookup in the capture file, and its content is intact; a control shows the same prune destroys an unpinned tip; (d) both tip-move windows, opened deterministically: a FIFO at the ledger path parks the script, with no reader present, at the ledger append that sits after the pin and before the delete, and the pin appearing is the signal to move the tip. Window B moves the parked branch's own tip (between pin and delete): the delete refuses, the branch stays at the moved tip, the pin records the captured tip, the ledger carries the# not deleted:note, and the moved commit survivesreflog expireplusgc --prune=now. Window A parks on a first branch and moves the second branch's tip (after the batch check, before its re-check): the first branch is deleted, the second aborts at the re-check with no pin and no ledger row.git branch -D: 7 failures, all window B, including the branch gone and the moved commit pruned, which reproduces the loss the review demonstrated; restore thegrep -c -v '^#'seal and the comment-skipping parser: every capture case in both suites fails withTipCaptureError, which is the bricking the review reported. Earlier battery on the same tree (drop tip-moved refusal 6, drop the pin 9, drop missing-row refusal 1, skip refused branches 17, ignore ledger write failure 1, audit stops printingTip:3, audit hidesTipCaptureError:2, audit writes no rows 9) unchanged. No surviving mutation.scripts/affected-tests.sh --runin four shards, foreground: shards 0/4, 2/4, 3/4 exit 3 (all shell suites passed; the NOT RUN entries are other ecosystems). Shard 1/4 exit 1 solely fromplugins/claude-ops/skills/plugins/scripts/cache-content-check.test.sh(2/24 process-budget cases), reproduced identically on a cleangit archive origin/mainexport, so pre-existing. The audit suite ran in shard 1, the delete suite in shard 2,allowed-tools-pairing.test.shin shard 1; all passed.shellcheck -xandshfmt -dclean on the four changed shell files.check-purged-em-dashes.sh --checkclean; no em dashes in added lines (byte check on the diff).check-changelog-parity.sh:--check,--check-order,--check-bump origin/main,--check-preserved origin/mainall exit 0.git-branch-delete.test.shwas committed100644and failed the CIlintjob's exec-bit gate on0e701107a; it is100755fromf6f87d91eon (git ls-treeconfirmed). CI on the current head (ae18b8433):lint,test-linux (0..3),hook-utils,changes,managed-files-guard, GitGuardian andci-statusall success;test-windowsand the review lanes skipped (draft).Related
git branch -D; not implemented here)0.10.34; expect the textual conflicts listed above)🤖 Generated with Claude Code
https://claude.ai/code/session_01ViPsHkL3ng9xWt2GjEQJob