Skip to content

fix(prune): age branches without a reflog by when git last wrote their ref - #4154

Merged
max-sixty merged 2 commits into
mainfrom
prune-min-age-no-reflog
Sep 16, 2026
Merged

max-sixty merged 2 commits into
mainfrom
prune-min-age-no-reflog

Conversation

@max-sixty

@max-sixty max-sixty commented Sep 16, 2026 •

Copy link
Copy Markdown
Owner

wt step prune --min-age dated 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.logAllRefUpdates defaults off there, so git clone --bare, a git fetch into refs/heads/*, and wt switch --create run from the bare directory all leave no reflog. In the bare layout tips-patterns.md recommends, a branch that had just arrived and pointed at an integrated commit was pruned immediately, and --dry-run gave 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, else packed-refs, or reftable/tables.list under 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 concurrent git pack-refs from dating a branch by an older file. Across runs the rewrites still count: after git gc, or a run that deleted a packed branch, older branches without a reflog are skipped until --min-age has passed. The --min-age help says so.

A failing git reflog show or 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 rewrite packed-refs, a missing or expired reflog prints nothing and exits 0, reftable rewrites tables.list on each update) was checked against git 2.55 and a locally built 2.43.0. Tests:

  • a unit test reads both backends' layouts from a synthetic common directory;
  • a dry run in a bare clone with packed-refs and a loose ref dated separately, which fails with the fallback removed or the loose lookup broken;
  • a live run over two packed branches, with a git shim 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.

This was written by Claude Code on behalf of max-sixty

🤖 Generated with Claude Code

…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 worktrunk-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/commands/step/prune.rs Outdated
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>
@max-sixty
max-sixty merged commit 1ab21ab into main Sep 16, 2026
38 checks passed
@max-sixty
max-sixty deleted the prune-min-age-no-reflog branch September 16, 2026 23:07
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.

2 participants