fix(worktree): read the reflog by content, so a gc stops vetoing every worktree - #440
Conversation
…y worktree Signal 2 took the newest MTIME of seven metadata files, one of which is logs/HEAD. A `git gc` rewrites every reflog in place while appending nothing, so it moves all of them at once. MEASURED 2026-08-18 on this repository. A gc ran at 08:15:34-08:16:05 -- packed-refs rewritten at 08:15:34, the packs at 08:16:02-08:16:05 -- and 48 of 50 worktree logs/HEAD files came out carrying the IDENTICAL mtime 2026-08-18 08:15:42, to the second, while their contents were days older: one worktree's last entry is 2026-08-14 12:55:35, four days before its own mtime. The pruner then skipped 11 of 14 candidates as "recently active" and removed nothing. With 50 worktrees and 9 live sessions producing loose objects, gc is frequent enough that the veto can sit permanently on -- and a veto that always fires carries no more information than one that never fires, which is the failure class ADR 0158 names. So logs/HEAD is now read by CONTENT: the epoch on its last entry, stamped by the operation that appended it, which nothing can move without appending. The other six files keep their mtimes; gc does not touch them. Two states that are not the same fact no longer act alike. A non-empty reflog that will not parse FALLS BACK to the mtime -- a shape this does not recognise is a reason to keep vetoing, never a reason to stop, because idle is the direction that removes a worktree. A zero-length reflog contributes NOTHING and is not an error: every entry has expired, so it holds no evidence either way and the six mtimes still speak. Reading its mtime there would reinstate the bug on the oldest worktrees in the repository, which are the ones most likely to be prunable. Re-measured over the 15 live siblings: the old probe reported 2.98 h for twelve of them (all the same gc stamp); the real ages span 11.80 h to 184.65 h. Six cross the 36 h window. ZERO worktrees move the other way -- the change can only ever reveal an age, never manufacture one. Of those six, two stay skipped on their own merits (one dirty, one carrying untracked files), which is the point: this restores the other checks' ability to speak, it does not overrule them. Four tests, and each is killed by a different mutation. Reinstating the mtime read kills the gc test and the empty-reflog test; removing the reflog read kills the fresh-entry test and the unparseable test. The fresh-entry test is the one that stops this from being a deletion of signal 2: every mtime says 100 h idle and only the content says otherwise, so a script that had merely stopped reading logs/HEAD would prune a worktree somebody is working in. The fixture ages the reflog's entries now, not just its mtime -- and restores the mtime after rewriting, because the write itself moved it to now, which is the gc's own signature. Without that, _backdate silently stopped setting the field it appears to set; found by mutating the script and watching the CONTROL line fail instead of the assertion under test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Do not merge without rebasing first: this would silently DELETE content from
|
Correction to my previous comment: I quoted the wrong side of the statMy earlier comment said 228 deletions. That number is what this branch ADDS, not what merging would lose. I read The corrected figure: merging would lose 13 line(s) that The finding itself stands and so does the remedy -- rebase before merging, do not clear BEHIND and merge on green -- because a clean merge still drops those 13 lines with no marker. But the magnitude was overstated and the number in my first comment should not be quoted. |
RETRACTION: my "would silently delete content" comments on this PR were wrongBoth of my earlier comments are withdrawn. The finding does not survive a discriminator, and the remedy I attached to it was justified by a defect that is not there. What I did wrongI counted removed lines in a merge result and reported them as lost content. Those are different questions. A line-for-line REPLACEMENT scores identically to a DELETION under a line count -- and this branch's changes are overwhelmingly rewrites. The discriminator, run on this PRFor every line Zero. Every removed line's payload is present in the merged result. The concrete case that broke my own claimI said this branch deletes Not deleted -- expanded, plus a new mechanism (ADR 0164, What still stands, and what does notDoes not stand: any claim that merging this drops content, and the "never update-branch-and-merge-on-green" remedy insofar as it rested on that. Still true, but unremarkable: this branch is BEHIND, and branch protection is Credit where due: a peer session caught this by verifying one file in #437 -- |
The bug
Occupancy signal 2 took the newest mtime of seven private metadata files, one of which is
logs/HEAD. Agit gcrewrites every reflog in place while appending nothing — so it moves all ofthem at once, and the pruner reads the whole repository as occupied.
Measured 2026-08-18 on this repository. A gc ran at 08:15:34–08:16:05 (
packed-refsrewritten at08:15:34, the packs at 08:16:02–08:16:05). 48 of 50 worktree
logs/HEADfiles came out carryingthe identical mtime
2026-08-18 08:15:42, to the second, while their contents were days older — oneworktree's last entry is
2026-08-14 12:55:35, four days before its own mtime.The pruner then skipped 11 of 14 candidates as "recently active — someone may be working here"
and removed nothing. With 50 worktrees and 9 live sessions producing loose objects, gc is frequent
enough that the veto can sit permanently on. A veto that always fires carries no more information
than one that never fires, which is the failure class
docs/adr/0158names.The fix
logs/HEADis read by content: the epoch on its last entry, stamped by the operation thatappended it. Nothing can move that number without appending. The other six files keep their mtimes —
gc does not touch them.
Two states that are not the same fact no longer act alike:
reason to keep vetoing, never a reason to stop; idle is the direction that removes a worktree.
evidence either way and the six mtimes still speak. Reading its mtime there would reinstate the bug
on the oldest worktrees in the repository — the ones most likely to be prunable.
Re-measured over the 15 live siblings
2.98 h— the same gc stamp11.80 h–184.65 hZero move the other way: the change can only ever reveal an age, never manufacture one. Of the six,
two stay skipped on their own merits — one dirty, one carrying untracked files. That is the point.
This restores the other checks' ability to speak; it does not overrule them.
Tests — four, each killed by a different mutation
logs/HEADin the mtime listThe fresh-entry test is what stops this from being a quiet deletion of signal 2: every mtime says
100 h idle and only the reflog's content says otherwise, so a script that had merely stopped
reading
logs/HEADwould prune a worktree somebody is working in — and would pass the gc test whiledoing it. Each test also carries an in-invocation positive control, so none can pass vacuously.
Fixture change
_backdatenow ages the reflog's entries, not just its mtime — and restores the mtime afterrewriting, because the write itself moved it to now, which is the gc's own signature. Without that
restore,
_backdatesilently stopped setting the field it appears to set. Found by mutating thescript and watching the control line fail instead of the assertion under test.
Disabling the reflog aging alone fails 14 of the existing tests, which is the evidence that the
new code path is load-bearing rather than decorative.
Full suite: 71 existing + 4 new pass, plus the tooling partition.
🤖 Generated with Claude Code