Repository navigation
fix(prune): age branches without a reflog by when git last wrote their ref - #4154
Conversation
…r ref `wt step prune --min-age` dated a branch with no worktree from its oldest reflog entry, and a branch with no reflog got no guard at all: it was pruned the moment it looked integrated. In a bare repository most branches have no reflog, because `core.logAllRefUpdates` defaults off there. `git clone --bare`, a `git fetch` into `refs/heads/*`, and `wt switch --create` run from the bare directory all leave none. So in the bare layout the docs recommend, a freshly cloned or fetched branch that points at an integrated commit was deleted with no hint the guard had not applied. Without reflog entries, the age now comes from the mtime of the file holding the ref: the loose `refs/heads/<branch>` if there is one, else `packed-refs`, or `reftable/tables.list` under the reftable backend. Git writes each of these by renaming a new file into place, so the age is never older than the branch. It errs toward keeping it: rewriting `packed-refs` (`git pack-refs`, `git gc`, or deleting a packed branch) makes every packed branch young again for up to `--min-age`. A `git reflog show` failure or an unparsable selector now fails the prune instead of silently disabling the guard, and the command passes `--` so a file named like the ref can't make its argument ambiguous. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
worktrunk-bot
left a comment
There was a problem hiding this comment.
ref_write_epoch reads the packed-refs mtime from inside the concurrent scan, and prune's own deletions rewrite that file — so a run that deletes one packed branch re-dates every packed branch still being checked, and those come out "younger than --min-age".
In a bare clone of a repo with 45 integrated branches (all packed, no reflogs) and packed-refs backdated 30 days, wt step prune --yes pruned 22 and skipped 23. Serialized it is sharper — five branches under RAYON_NUM_THREADS=1:
✓ Removed branch b1 (same commit as main, _)
✓ Removed branch b2 (same commit as main, _)
◌ Skipped b3 (younger than 1d)
◌ Skipped b4 (younger than 1d)
◌ Skipped b5 (younger than 1d)
Checks and hook-free removals both hold the read side of check_lock, so a branch-only deletion runs while later items are still inside check_one. git branch -d on a packed branch rewrites packed-refs whole (a loose-only branch leaves it alone) — the same rewrite the docstring already calls out for pack-refs/gc, but occurring within a single invocation. The user-visible result is a partial prune whose skip message contradicts the branch's real age, and N runs to clear N packed branches — in exactly the bare layout this PR is fixing.
The new test is --dry-run, which never removes, so it can't reach this. A live-path test over two packed branches with RAYON_NUM_THREADS=1 would pin it.
Fix: read the fallback mtime once before the scan fans out and thread it through alongside now_secs, leaving only the loose-ref stat per branch — a deletion never re-dates another branch's loose ref. That covers reftable too, where every ref update rewrites tables.list. Happy to push that if you want it.
Separately, the cross-run form of the same effect — git gc re-dating every packed branch at once — is in the docstring but not in the user-facing min-age text, so a user in the bare layout can see younger than 1d on a month-old branch with nothing in the docs accounting for it. Worth a clause there, or a decision that the code comment is enough.
Deleting a packed branch rewrites `packed-refs`, the file that dates every branch without a reflog, and prune's removals run while the scan is still checking later candidates. So a live run that deleted one packed branch dated the rest as brand new and skipped them as younger than `--min-age`: in a bare clone, one packed branch per run. The write times are now read once, before the scan fans out: each orphan branch's loose ref first, then `packed-refs` or `reftable/tables.list`. That order keeps a concurrent `git pack-refs` from dating a branch by an older file. The file reading takes the common directory, so a unit test covers both backends, and a shimmed integration test holds one branch's check until another's deletion has finished. The `--min-age` help now says that `git gc` or deleting a branch can make older branches without a reflog count as young again. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
wt step prune --min-agedated a branch with no worktree from its oldest reflog entry, and a branch with no reflog got no age guard at all. In a bare repository most branches have none:core.logAllRefUpdatesdefaults off there, sogit clone --bare, agit fetchintorefs/heads/*, andwt switch --createrun from the bare directory all leave no reflog. In the bare layouttips-patterns.mdrecommends, a branch that had just arrived and pointed at an integrated commit was pruned immediately, and--dry-rungave no sign the guard hadn't applied.Without reflog entries, the age now comes from the mtime of the file holding the ref: the loose
refs/heads/<branch>if present, elsepacked-refs, orreftable/tables.listunder the reftable backend. Git writes each by renaming a new file into place, so the age is never older than the branch and the guard errs toward keeping it.Prune's own removals rewrite those shared files (deleting a packed branch rewrites
packed-refs) while the scan is still checking later candidates, so the times are read once before the scan fans out: loose refs first, then the shared file, which keeps a concurrentgit pack-refsfrom dating a branch by an older file. Across runs the rewrites still count: aftergit gc, or a run that deleted a packed branch, older branches without a reflog are skipped until--min-agehas passed. The--min-agehelp says so.A failing
git reflog showor an unparsable selector now fails the prune rather than disabling the guard, and the command passes--so a file named like the ref can't make its argument ambiguous.The ref-storage behavior this relies on (clone writes
packed-refs, fetch writes loose refs, a no-op fetch leaves mtimes alone,pack-refs/gc/packed-branch deletion rewritepacked-refs, a missing or expired reflog prints nothing and exits 0, reftable rewritestables.liston each update) was checked against git 2.55 and a locally built 2.43.0. Tests:packed-refsand a loose ref dated separately, which fails with the fallback removed or the loose lookup broken;gitshim holding the second branch's check until the first one's deletion has finished (unix-only, like the other shim tests), which reproduced the in-run re-dating before the fix.🤖 Generated with Claude Code