fix(remove): gate removal on the registration, not the repository - #3808
Conversation
`ensure_belongs_to_repo` compared the occupant's git dir against the repository, so a sibling worktree of the same repository moved onto a registered path passed the guard. `wt remove --force` then renamed that live checkout into trash and deleted it, uncommitted work included, while git refuses the identical removal (`does not point back to '.git/worktrees/<id>'`). The gate is now registration-level, which is git's own `validate_worktree` comparison: the directory's `.git` must name this registration, and that registration's `gitdir` file must name the directory back. Resolution reads the `.git` entry every call, so the second gate at the rename re-decides across the approval prompt and the `pre-remove` hook rather than answering from the planning-time `GIT_DIRS` entry. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
worktrunk-bot
left a comment
There was a problem hiding this comment.
The registration-level gate is the right comparison — I reproduced the sibling state against stock git and it refuses exactly as the PR describes (does not point back to '.git/worktrees/repo.feature'), so matching validate_worktree rather than repository membership lines wt up with git instead of ahead of it. Moving resolution to a per-call git_dir_at and dropping the walk-up is what makes the second gate mean something, and the new test asserting the filesystem rather than the exit code is the right shape for a staged-then-detached deletion.
Two things below: the hint under the widened error now mis-advises the case this PR adds, and the re-check half of the fix is untested.
Not approving — per this repo's review policy the removal surface (wt remove --force, src/git/remove.rs, stage_worktree_removal) is held for human sign-off regardless of how the diff reads.
Three gaps from review of the registration-level gate. The hint under the widened refusal only fitted the foreign-clone case. For a sibling worktree, moving the directory "aside" makes *both* registrations prunable, so the repo-wide prune it names clears them both and leaves a live checkout pointing at a registration that no longer exists. The error now carries where the occupant's own registration records it, and the hint names that path: moving it back leaves prune with only the stale entry. The re-check at the rename had no test. A `pre-remove` hook — which runs in exactly the claimed window — repoints the worktree's `.git` at a sibling's registration after planning has cleared it, so resolving through the per-process cache instead fails the test rather than passing silently. A registration recording its worktree with a relative `gitdir` entry (what git writes under `worktree.useRelativePaths`) now has a test, covering the branch that resolves it against the registration directory. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
worktrunk-bot
left a comment
There was a problem hiding this comment.
Both prior points land. The hook mutation in test_remove_rechecks_ownership_after_pre_remove_hook is the discriminating one — rewriting the registration's gitdir instead would fail with a cached git_dir() too, so repointing the worktree's .git is what isolates the freshness property. One new thing on the path the widened error now carries, inline.
Two smaller doc-precision notes. The occupant_registered_at field doc says None is "one that answers to a different repository", but registration_worktree_path also returns None for a git dir that is under <common>/worktrees/ whose gitdir file is missing or unreadable — a broken registration of ours, which then gets the foreign-occupant hint. The hint is still the right advice in that state; it's the sentence that's narrower than the code. And paths_name_one_directory sits next to an existing canonicalizing comparison in src/path.rs (paths_match, over canonicalize_with_parents) — the stricter form here reads deliberate, since resolving a vanished recorded path against its existing parents is exactly what this gate must not do, but nothing says so, and the next reader has to derive that the two aren't interchangeable.
Not approving — unchanged from the last round: wt remove --force and src/git/remove.rs are the removal surface this repo holds for human sign-off regardless of how the diff reads.
…omparison Three more from review. A relative `gitdir` entry resolves against `<common>/worktrees/<id>`, so the recorded path arrived with that `..` chain still in it, and the hint printed the traversal verbatim — the directory no longer exists, which is why the arm was reached, so plain canonicalization can't normalize it. `canonicalize_with_parents` can, and does it once where the path is read. That normalization also makes `paths_match` — the crate's canonicalizing path comparison, over the same helper — the right test for the gate, so the hand-rolled one beside it is gone. Fail-closed behavior is unchanged: normalizing rewrites spellings and cannot equate two directories. `occupant_registered_at`'s `None` also covers a registration of ours that has lost its `gitdir` file, which its doc claimed was the foreign-occupant case alone. Two unit tests, at the gate rather than through the CLI: one that both worktree shapes are accepted, covering the main-worktree arm that `wt remove` rejects upstream of this gate; one that the refusal names a normalized path. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…_parents `paths_match` decides whether a worktree registration's recorded path and the directory sitting at it are one directory, and removal deletes that directory when they are. That rests on `..` being resolved by the filesystem rather than collapsed lexically — across a symlink the two readings name different directories — which src/path.rs did not say, leaving a lexical rewrite looking like a tidy-up. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
worktrunk-bot
left a comment
There was a problem hiding this comment.
Both of last round's items are closed, and the fix went wider than the suggestion — normalizing inside registration_worktree_path and deleting paths_name_one_directory for paths_match — so the thing worth checking is whether the gate stays fail-closed without the helper whose docstring carried that property.
It does, for a reason that lives in canonicalize_with_parents rather than at the call site: it canonicalizes only the longest existing prefix and re-appends the missing tail verbatim, so a recorded path whose directory is gone keeps a component that doesn't resolve and can never equal the canonical form of a directory that is there. self.path is always there by the time the comparison runs — git_dir_at returns None for a directory with no readable .git, and that arm refuses first. The .. chain is resolved by stat on the prefix rather than collapsed lexically, so a traversal through a symlinked parent can't be flattened onto the wrong directory, and a path that walks out to a bare .. hits file_name() == None and comes back unchanged rather than half-resolved. Equal spellings still settle it without the old a == b shortcut, since normalization is a function of its input.
worktree_path_not_ours_names_a_normalized_path discriminates the right thing, too: the ..-free assertion alone would also pass under the foreign-occupant hint, which names no path at all, and the second assertion is what rules that out. All three platforms are green on d1f322c, including macOS, whose temp roots are the /var → /private/var symlink this comparison has to see through.
Not approving — unchanged from the last two rounds, and not a reading of this diff: wt remove --force and src/git/remove.rs are the removal surface this repo holds for human sign-off.
… directory (#3815) Removal's ownership gate (`ensure_holds_this_worktree`, #3808) reads `<dir>/.git` and stops there, so a directory that has lost that entry holds no worktree of ours and is refused. Nothing named that: `wt remove` reaches the same refusal upstream at the `prunable` check, and the gate's own tests cover the sibling-worktree and foreign-repo cases instead. The case bites where the emptied directory sits inside a repository — a worktree nested in another, or any worktree under a git-managed `~`. `git rev-parse --git-dir` walks up from it and answers with the enclosing repository's git dir, which *is* the common dir, so a gate resolving that way reads the directory as the main worktree and accepts it: ```console $ git -C repo worktree add nested -b nested && rm repo/nested/.git $ git -C repo/nested rev-parse --git-dir /…/repo/.git ``` So the test nests, and asserts that premise before asserting the refusal — otherwise it would go vacuous if the nesting stopped producing that resolution. In the flat sibling layout the walk-up finds no repository at all and resolution simply fails, which is why a sibling-shaped test would pin nothing. Confirmed discriminating: with resolution swapped back to the `GIT_DIRS`-cached `git_dir()`, it fails while `ensure_holds_this_worktree_accepts_both_worktree_shapes` still passes. > _This was written by Claude Code on behalf of max-sixty_
Cuts 0.74.0. Minor bump: `cargo semver-checks` reports two breaking library changes from #3808 (`GitError::WorktreePathNotOurs` gained a field, `WorkingTree::ensure_belongs_to_repo` was renamed), and patch is disallowed pre-1.0 with semver breakage. Alongside the release, one fix the release's data-loss review turned up. ## The fix `wt step promote` stages a worktree's gitignored files through `<git-common-dir>/wt/staging/promote` and moves them back after the branch exchange. When a worktree and the git dir sit on different filesystems, `fs::rename` fails with EXDEV and `copy_and_remove` copies then unconditionally deletes the source. #3744 made the copy tolerate a source that vanishes mid-walk — correct for `wt step copy-ignored`, which never deletes a source, and wrong here: an incomplete copy reported `Ok` immediately before the delete, so a gitignored file a concurrent build removed and rewrote was destroyed rather than moved. Before #3744 the copy errored and the source survived, so this was a regression introduced in this release window and caught before it shipped. `copy_dir_recursive` now returns how many of the entries the walk collected to copy were not copied. `copy_and_remove` refuses on a non-zero count and leaves the source in place; `copy-ignored` names the count and drops it, with `#[must_use]` so a future third caller decides rather than inheriting the old bug. A non-regular file is dropped at classification rather than counted, per @worktrunk-bot's review: a socket carries no content the destination can be short of, and refusing over one lands worst where it is least recoverable — `distribute_staged` runs *after* `exchange_branches`, so a socket that reached staging by same-filesystem rename would kill the promote with the branches already swapped and the staged files behind a `check_leftover_staging` refusal whose remedy deletes them. Both directions are pinned by tests. ## Release gates - Local `wt hook pre-merge --yes`: 4649 tests, lints, doctests, rustdoc under `-Dwarnings`. - `nightly.yaml` green twice on this branch (full 3-OS matrix, feature-powerset, release-target, nix-flake, minimal-versions), and green on the cut-from tip 92dfb68. - Data-loss surface review over `v0.73.0..HEAD` with four independent finders (behavioral, blast-radius, shipped-automation, keyword). Thirteen candidates: one real, fixed here; twelve adjudicated acceptable and signed off. - Every changelog entry verified against its diff, with attributions and links checked. ## Known-red check `codecov/patch` fails on the three `skipped += 1;` counters in `src/copy.rs`. Each sits inside a pre-existing `ErrorKind::NotFound` arm that was already uncovered at the base commit — the concurrent-rewrite races (`read_dir` vanished, `entry.file_type()` vanished, `set_permissions` vanished), none with a deterministic trigger. `codecov/project` passes. Merging over it is approved. > _This was written by Claude Code on behalf of max-sixty_ --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
wt remove --forcedeleted a live worktree of this same repository, uncommitted work included, whenever that worktree had been moved onto another worktree's registered path.The guard added in #3785 asks which repository the occupant answers to: a linked worktree's git dir sits under
<common>/worktrees/, the main worktree's is the common dir, anything else is someone else's. A sibling worktree moved onto the path satisfies that — its git dir sits under<common>/worktrees/like any worktree of this repo — so it passed, and the fast path renamed the directory into trash and handed therm -rfto a detached process. It is not prunable either: its gitdir file points at a location that exists, so theis_prunablearm from the same PR doesn't catch it.Git's own validation is one level finer.
validate_worktreerequires the directory to point back at this registration, and refuses this removal with--force:Reproducer, verified against a build of main
The occupant has to be moved onto the path rather than created there —
git worktree addrefuses a registered path, which is what leaves a plainmvas the way this state arises.The fix
The gate is now git's comparison at git's granularity: the directory's
.gitmust name this registration, and that registration'sgitdirfile must name the directory back. Repository-level ownership stays as the weaker half of the conjunction — it is what rejects a.gitfile pointing at another repository — and the main worktree is the same test where there is no registration to point back at.ensure_belongs_to_repobecomesensure_holds_this_worktree, since it no longer merely asks about repository membership.Resolution moves to
Repository::git_dir_at, the fs-only resolver thewt listprewarm already used (derive_worktree_git_dir), generalized to answer for a directory rather than for a known worktree of this repo: its main-worktree branch returnedgit_common_dir()on trust, and now canonicalizes the.gitit actually found. It also never walks up to a parent, which is what git reads too —git rev-parse --git-dirin an emptied worktree can resolve the enclosing repository.That settles a second thing the old docstring got wrong. It claimed the plan→rename window was "narrower than
ensure_clean's"; in factensure_cleanre-runsgit statuswhile this gate answered fromGIT_DIRS, memoized process-wide, so the second call was vacuous and the window — which contains the approval prompt and thepre-removehook — was unguarded.git_dir_atreads the filesystem on every call, so the check at the rename now re-decides.The refusal was
Directory @ … is not this repository's worktree, which is false in the sibling case: it is one of this repository's worktrees, just not the one registered there. Its hint didn't fit either — "move the directory aside, then rungit worktree prune" is a repo-wide prune, and with a sibling moved aside both registrations are prunable, so following it clears both and leaves a live checkout that has stopped being a worktree:So the error carries where the occupant's own registration records it, and each case gets the remedy that fits. Moving it back to that path leaves prune with only the stale entry to clear:
That path is read through
canonicalize_with_parents, because a relativegitdirentry resolves against<common>/worktrees/<id>and would otherwise reach the hint with the..chain still in it — and plain canonicalization can't normalize a directory that no longer exists, which is the case the arm is reached for. Normalizing there also makescrate::path::paths_match, the crate's canonicalizing comparison over that same helper, the right test for the gate, so there is no second comparison beside it.The gate's fail-closed behavior now rests on that helper resolving
..through the filesystem rather than collapsing it lexically — across a symlink the two readings name different directories — sosrc/path.rsrecords the constraint where a lexical rewrite would otherwise read as a tidy-up.The FAQ's "What can Worktrunk delete?" paragraph carried the same "a different repository" framing and is corrected.
Scope
Pre-existing, and 0.73.0 already narrowed it — 0.72.0 had no ownership check at all and deleted foreign clones too. The guard has two call sites (
prepare_worktree_removalat planning,stage_worktree_removalat the rename), so this reacheswt merge --remove,wt step prune, and picker removal, not onlywt remove.One incidental tightening:
wt remove <bare-repo-path>previously passed the guard (a bare root's git dir is the common dir) and was stopped only by the dirty check, which--forceskips. It now refuses at the guard.One residual, left alone
git worktree repair <path>after themvproduces a double registration: bothworktrees/repo.bar/gitdirandworktrees/repo.feature/gitdircome to record the same path, andgit worktree listreports two worktrees there. In that state the new gate accepts the removal — the occupant does point at thefeatureregistration, and that registration does point back — while git refuses, because its path→worktree lookup happens to match thebarentry first. Closing it means knowing the registration id at the gate, or scanning everyworktrees/*/gitdirfor duplicate claims. Unchanged by this PR, and reachable only viamvfollowed byrepair.Testing
Five new tests, each confirmed to fail with the line it covers reverted and to leave the others passing. Three drive the binary:
pre-removehook repoints the worktree's.gitat a sibling's registration after planning has already cleared it, which is what makes the second gate's freshness observable. Fails when resolution routes back through theGIT_DIRS-cachedgit_dir().gitdirentry — removal succeeds, and git reads the rewritten entry back, which is what makes it the form git itself writes. Rewriting the entry rather than settingworktree.useRelativePathskeeps the test independent of the git version that introduced the option.Two sit at the gate, where the CLI can't reach:
wt removerejects the main worktree well upstream of this gate and a bare repository's worktrees are all linked, so nothing through the CLI would notice that arm inverting.Diagnostic::render, since the path is in the hint andDisplaycarries only the title.Local gate green: 4607 tests, lints, doctests, rustdoc under
-Dwarnings.