Skip to content

fix(prune): leave a stale detached entry whose directory remains - #4226

Merged
max-sixty merged 2 commits into
mainfrom
prune-fail
Sep 22, 2026
Merged

max-sixty merged 2 commits into
mainfrom
prune-fail

Conversation

@max-sixty

Copy link
Copy Markdown
Owner

Git calls a worktree entry prunable when the .git file its registration points at is gone, which also happens when only that file went and the directory stayed. A detached checkout under macOS's $TMPDIR turned up this way, with its idle files deleted and its directories kept. wt step prune selected such an entry and unregistered it with git worktree remove, which skips its validation only while the directory is absent:

✗ pruning stale worktree for (detached 59b73365)
  fatal: validation failed, cannot remove working tree: '…/checkout/.git' does not exist

The failure aborted every removal still queued and failed the run, so each later prune in that repo failed the same way.

The scan now selects a detached stale entry only when its directory is gone, the precondition prune_worktree_entry already documents. An entry whose directory remains stays registered, as its branch-bearing twin already does: prepare_worktree_removal refuses that one with WorktreeMissing (#3785).

Since #3785, no Prunable entry can plan the worktree removal that runs pre-remove/post-remove, so the pessimistic hook plan stops registering them, and the two comments that said a stale entry with a surviving directory plans a full removal are corrected. Live worktrees are registered as before.

test_prune_keeps_stale_detached_entry_whose_directory_remains deletes a detached worktree's .git file, then checks that prune succeeds, removes an integrated sibling, and leaves the entry and its directory in place. With the fix reverted, it fails with the error above.

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

🤖 Generated with Claude Code

max-sixty and others added 2 commits September 21, 2026 18:31
Git calls a worktree entry prunable when the `.git` file its registration
points at is gone, which also happens when only that file went and the
directory stayed. A detached checkout under macOS's `$TMPDIR` turned up this
way, with its idle files deleted and its directories kept. Prune selected
such an entry and unregistered it with `git worktree remove`, which refuses
an entry whose directory remains:

    fatal: validation failed, cannot remove working tree: '<path>/.git' does not exist

That one failure aborted the remaining removals and failed the run on every
later prune. The scan now selects a detached stale entry only when its
directory is gone, the precondition `prune_worktree_entry` already names,
so the entry stays registered like its branch-bearing twin, which planning
refuses with `WorktreeMissing`.

The pessimistic hook plan stops registering `Prunable` entries: since
`WorktreeMissing`, none of them can plan the worktree removal that runs
hooks, so the registration and the comments describing it were stale.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@max-sixty
max-sixty merged commit 7193c09 into main Sep 22, 2026
38 checks passed
@max-sixty
max-sixty deleted the prune-fail branch September 22, 2026 02:14
max-sixty added a commit that referenced this pull request Sep 22, 2026
Git calls a worktree entry prunable once the `.git` its registration
names is gone. That happens when the whole directory is deleted, and
also when only the `.git` file goes: macOS's `$TMPDIR` sweep deletes
idle files and keeps directories, which left two such checkouts
registered in a real repo. Worktrunk unregistered stale entries with
`git worktree remove`, which refuses an entry whose directory remains
(`validation failed … .git does not exist`, `--force --force` included).
#4226 stopped that from failing `wt step prune` by skipping the entry,
so it stayed `⊟` forever, and `wt remove` refused it with
`WorktreeMissing`.

`Repository::prune_worktree_entry` now reproduces `git worktree prune`
for the one named entry. Under the registry write lock it finds the
registration whose `gitdir` names the path, repeats git's
`should_prune_worktree` test (no `locked` file, nothing at the recorded
`.git`), and deletes `.git/worktrees/<id>`, which is git's
`delete_git_dir`. The directory and any files in it stay. The test runs
at deletion time, so an entry locked or reconnected since the scan is
refused rather than unregistered.

No git command does this for one entry: `git worktree prune` takes no
path, `remove` and `move` fail validation, and `repair` relinks rather
than unregisters. A repo-wide `git worktree prune` also unregisters
entries `wt step prune` deliberately keeps: branches with unmerged
commits, detached HEADs holding the only reference to their commits
(which `git gc` can then delete), the default branch's worktree, and
unborn orphans. The rationale sits in the `prune_worktree_entry`
docstring.

What changes for users:

- `wt step prune` and `wt remove <branch>` unregister a stale entry
whether its directory is gone or only its `.git` is. `wt remove feature`
on a recreated directory used to fail with `✗ Worktree directory missing
for feature`. It now prints `✓ Pruned stale worktree & removed branch
feature (same commit as main, _)` and leaves the directory in place.
- The picker's branch-only removal now unregisters the stale entry
before deleting the branch. Before, it deleted only the branch, leaving
an entry that named a deleted branch, which `wt step prune` skips as
unborn.
- A worktree path that became a regular file counts as having no `.git`,
matching git, instead of refusing.
- Messages no longer call every stale entry "directory missing". The
switch/merge/push error reads `Worktree for X is stale; its directory or
.git is gone`, the `⊟` legend reads `Prunable (worktree directory or its
.git gone)`, and the retained-branch line reads `Pruned stale worktree
for X`. Where the directory remains, that error's hint offers `git
worktree repair <path>` instead of `git worktree prune`, which would
sweep every stale entry in the repo.

Unregistering deletes the entry's index, per-worktree reflog and any
in-progress rebase, merge, cherry-pick, revert or bisect state. While
the entry exists, `git worktree repair <path>` restores all of it
(recreating the directory first if it went too); afterwards staged files
survive only as dangling blobs. So here worktrunk is more careful than
`git worktree prune`. `Repository::stale_worktree_work` reads the
registration: `diff-index --cached --quiet HEAD` against its index, plus
the same state files `WorkingTree::operation_in_progress` reads, now
shared as `operation_in_progress_at`. An entry holding either is kept:

- `wt step prune` skips it silently, as it skips a dirty or locked
worktree.
- `wt remove` refuses with `✗ Worktree for feature is stale but holds
staged changes` (or `… with a rebase in progress`) and `↳ To discard the
stale worktree, run wt remove -f feature; to restore it, run git
worktree repair <path>`. `--force` waives the check, as it waives a live
worktree's uncommitted changes.
- On an unborn `HEAD` the index is compared against the empty tree, as
`git diff --cached` does, so an orphan worktree with nothing staged
still unregisters. A check that fails for any other reason keeps the
entry.

The deletion-time test in `prune_worktree_entry` closes the gap after
the check: a stale entry has no `.git` to stage through, and one
repaired in between is refused.

Tests: unit tests cover `prune_worktree_entry`'s refusals (live, locked,
unregistered, a `.git` it can't stat), each stale shape (absent,
directory without `.git`, path now a file), and its wait on the registry
lock. Removing either check or the lock fails them. The picker tests
cover both a pruned stale entry and a failed prune that keeps the
branch, `wt remove` on a detached stale entry is pinned to its error,
and a shim that fails the branch's CAS delete checks that prune still
unregisters the entry and reports the kept branch. The two tests that
use a permission bit as the failure trigger skip under root, which
ignores it. The prune and picker tests cover a directory that remains.
`test_prune_surfaces_failing_metadata_prune` now triggers the failure
with a read-only registration directory.
`test_prune_metadata_removals_serialize` and its two git shims are
removed: they intercepted `git worktree remove` teardowns, which this
path no longer runs. For the work guard, a unit test runs
`stale_worktree_work` on clean, staged, bisecting, `--no-checkout` (no
index), unborn-with-a-staged-file and unborn-with-an-emptied-index
registrations. `test_prune_keeps_stale_entries_holding_work` checks that
prune keeps a staged branch entry and a bisecting detached one while
pruning a clean one. `test_remove_stale_worktree_holding_staged_changes`
pins the refusal and checks that `wt remove -f` then prunes the entry
and leaves the files.

> _This was written by Claude Code on behalf of max-sixty_

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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