Repository navigation
fix(prune): leave a stale detached entry whose directory remains - #4226
Merged
Merged
Conversation
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>
worktrunk-bot
approved these changes
Sep 22, 2026
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>
1 task
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Git calls a worktree entry prunable when the
.gitfile its registration points at is gone, which also happens when only that file went and the directory stayed. A detached checkout under macOS's$TMPDIRturned up this way, with its idle files deleted and its directories kept.wt step pruneselected such an entry and unregistered it withgit worktree remove, which skips its validation only while the directory is absent: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_entryalready documents. An entry whose directory remains stays registered, as its branch-bearing twin already does:prepare_worktree_removalrefuses that one withWorktreeMissing(#3785).Since #3785, no
Prunableentry can plan the worktree removal that runspre-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_remainsdeletes a detached worktree's.gitfile, 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.🤖 Generated with Claude Code