Skip to content

fix(worktree): read the reflog by content, so a gc stops vetoing every worktree - #440

Merged
wshallwshall merged 2 commits into
mainfrom
prune-activity-reflog
Aug 19, 2026
Merged

fix(worktree): read the reflog by content, so a gc stops vetoing every worktree#440
wshallwshall merged 2 commits into
mainfrom
prune-activity-reflog

Conversation

@wshallwshall

Copy link
Copy Markdown
Collaborator

The bug

Occupancy signal 2 took the newest mtime of seven private 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, 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-refs rewritten at
08:15:34, the packs at 08:16:02–08:16:05). 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 — 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/0158 names.

The fix

logs/HEAD is read by content: the epoch on its last entry, stamped by the operation that
appended 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:

  • Non-empty but unparseable → falls back to the mtime. A shape this does not recognise is a
    reason to keep vetoing, never a reason to stop; idle is the direction that removes a worktree.
  • Zero-length → contributes nothing, and is not an error. Every entry 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 — the ones most likely to be prunable.

Re-measured over the 15 live siblings

old new
twelve worktrees all 2.98 h — the same gc stamp real ages, 11.80 h184.65 h
cross the 36 h window 0 6
vetoes added 0

Zero 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

Mutation Which tests die
Reinstate logs/HEAD in the mtime list the gc test, and the empty-reflog test
Remove the reflog read entirely the fresh-entry test, and the unparseable test

The 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/HEAD would prune a worktree somebody is working in — and would pass the gc test while
doing it. Each test also carries an in-invocation positive control, so none can pass vacuously.

Fixture change

_backdate now ages the reflog's entries, 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
restore, _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.

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

…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>
@wshallwshall
wshallwshall enabled auto-merge (squash) August 18, 2026 18:24
@wshallwshall

Copy link
Copy Markdown
Collaborator Author

Do not merge without rebasing first: this would silently DELETE content from main

Measured with git merge-tree --write-tree origin/main <head>, then diffing the resulting tree against main -- i.e. what the merge would actually produce, not what the branch contains.

228 deletions across 2 files:

  • scripts/worktree/prune-merged.ps1 (-83) and tests/test_worktree_prune_merged.py (-158)
  • This PR changes the tool that DELETES worktrees; repo memory records that a removed worktree can strand commits in no ref and no reflog

Why nothing would warn you

This merges CLEAN. Git conflicts on concurrent edits, never on invalidated claims -- a stale branch silently drops work it never touched, no marker appears, and every check stays green. The deletions above are main's content that this branch predates.

The remedy

Rebase onto origin/main, then re-review. The rebase is where you decide, line by line, which of these deletions were intended and which are the base being old. Do not clear the BEHIND state with update-branch and merge on green -- green does not see this.

Found during a sweep of all open PRs; five of twelve show this shape. Not a judgement on the work, only on the base.

@wshallwshall

Copy link
Copy Markdown
Collaborator Author

Correction to my previous comment: I quoted the wrong side of the stat

My earlier comment said 228 deletions. That number is what this branch ADDS, not what merging would lose. I read git diff --stat in the wrong direction.

The corrected figure: merging would lose 13 line(s) that main currently has.

git diff --shortstat origin/main <merge-tree>
  -> 228 insertions (this branch's work, correctly added)
  -> 13 deletions  (main's content, silently dropped)

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.

@wshallwshall

Copy link
Copy Markdown
Collaborator Author

RETRACTION: my "would silently delete content" comments on this PR were wrong

Both 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 wrong

I 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 PR

For every line main has that the merge result lacks, I extracted a distinctive identifier and asked whether it survives anywhere in the merged tree:

removed lines carrying an identifier : 1
payload SURVIVES in the merged tree  : 1
genuinely absent                     : 0

Zero. Every removed line's payload is present in the merged result.

The concrete case that broke my own claim

I said this branch deletes initial_password_expiry_hours (ASVS 6.4.1). Measured occurrence counts:

initial_password_expiry_hours   main: 10   after merge: 25
bootstrap_expiry_hours          main: 35   after merge: 65
password_claimed_at             main:  0   after merge: 52

Not deleted -- expanded, plus a new mechanism (ADR 0164, password_claimed_at: recorded rather than inferred from mutable credential state).

What still stands, and what does not

Does 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 strict: true, so it must re-sync before merging regardless.

Credit where due: a peer session caught this by verifying one file in #437 -- apt-get install lines I scored as removed were present in the merged tree wrapped in timeout and a conditional, which is that branch's entire subject. Same shape as errors I flagged in others today: the instrument answered truthfully, about the neighbouring question.

@wshallwshall
wshallwshall merged commit 3a06f9a into main Aug 19, 2026
37 of 38 checks passed
@wshallwshall
wshallwshall deleted the prune-activity-reflog branch August 19, 2026 15:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant