Skip to content

fix(remove): gate removal on the registration, not the repository - #3808

Merged
max-sixty merged 5 commits into
mainfrom
remove-registration-ownership
Aug 13, 2026
Merged

fix(remove): gate removal on the registration, not the repository#3808
max-sixty merged 5 commits into
mainfrom
remove-registration-ownership

Conversation

@max-sixty

@max-sixty max-sixty commented Aug 12, 2026

Copy link
Copy Markdown
Owner

wt remove --force deleted 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 the rm -rf to a detached process. It is not prunable either: its gitdir file points at a location that exists, so the is_prunable arm from the same PR doesn't catch it.

Git's own validation is one level finer. validate_worktree requires the directory to point back at this registration, and refuses this removal with --force:

$ git -C repo worktree remove --force ../repo.feature
fatal: validation failed, cannot remove working tree:
  '.../repo.feature' does not point back to '.git/worktrees/repo.feature'
Reproducer, verified against a build of main

The occupant has to be moved onto the path rather than created there — git worktree add refuses a registered path, which is what leaves a plain mv as the way this state arises.

$ git -C repo worktree add ../repo.feature -b feature
$ git -C repo worktree add ../repo.bar -b bar
$ rm -rf ../repo.feature && mv ../repo.bar ../repo.feature
$ echo PRECIOUS > ../repo.feature/precious.txt
$ wt remove --force --yes feature
◎ Removing feature worktree (--force) & branch in background (same commit as main, _)
$ ls ../repo.feature
ls: ../repo.feature: No such file or directory

The fix

The gate is now git's comparison at git's granularity: the directory's .git must name this registration, and that registration's gitdir file must name the directory back. Repository-level ownership stays as the weaker half of the conjunction — it is what rejects a .git file pointing at another repository — and the main worktree is the same test where there is no registration to point back at.

ensure_belongs_to_repo becomes ensure_holds_this_worktree, since it no longer merely asks about repository membership.

Resolution moves to Repository::git_dir_at, the fs-only resolver the wt list prewarm 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 returned git_common_dir() on trust, and now canonicalizes the .git it actually found. It also never walks up to a parent, which is what git reads too — git rev-parse --git-dir in 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 fact ensure_clean re-runs git status while this gate answered from GIT_DIRS, memoized process-wide, so the second call was vacuous and the window — which contains the approval prompt and the pre-remove hook — was unguarded. git_dir_at reads 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 run git 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:

$ mv ../repo.feature ../repo.aside && git worktree prune -v
Removing worktrees/repo.feature: gitdir file points to non-existent location
Removing worktrees/repo.other: gitdir file points to non-existent location
$ git -C ../repo.aside status
fatal: not a git repository: (null)

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:

$ wt remove --force --yes feature
✗ Directory @ ../repo.feature does not hold the worktree registered there
↳ Removing it could destroy the worktree registered @ ../repo.other; move the directory back there, then run git worktree prune

That path is read through canonicalize_with_parents, because a relative gitdir entry 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 makes crate::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 — so src/path.rs records 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_removal at planning, stage_worktree_removal at the rename), so this reaches wt merge --remove, wt step prune, and picker removal, not only wt 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 --force skips. It now refuses at the guard.

One residual, left alone

git worktree repair <path> after the mv produces a double registration: both worktrees/repo.bar/gitdir and worktrees/repo.feature/gitdir come to record the same path, and git worktree list reports two worktrees there. In that state the new gate accepts the removal — the occupant does point at the feature registration, and that registration does point back — while git refuses, because its path→worktree lookup happens to match the bar entry first. Closing it means knowing the registration id at the gate, or scanning every worktrees/*/gitdir for duplicate claims. Unchanged by this PR, and reachable only via mv followed by repair.

Testing

Five new tests, each confirmed to fail with the line it covers reverted and to leave the others passing. Three drive the binary:

  • the sibling case — follows fix(worktree): guard a registered path that no longer holds its worktree #3785's data-safety model: asserts the filesystem afterwards, not just the exit code, since removal stages by rename and deletes in a detached process. Snapshots the refusal, so the hint and the path it names are pinned. Fails with the pointer-back conjunct removed, while the foreign-repo test still passes without it — the two cover different halves.
  • the re-check at the rename — a pre-remove hook repoints the worktree's .git at 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 the GIT_DIRS-cached git_dir().
  • a relative gitdir entry — removal succeeds, and git reads the rewritten entry back, which is what makes it the form git itself writes. Rewriting the entry rather than setting worktree.useRelativePaths keeps the test independent of the git version that introduced the option.

Two sit at the gate, where the CLI can't reach:

  • both worktree shapes are accepted — including the main worktree, whose git dir is the common dir. wt remove rejects 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.
  • the refusal names a normalized path — asserted against Diagnostic::render, since the path is in the hint and Display carries only the title.

Local gate green: 4607 tests, lints, doctests, rustdoc under -Dwarnings.

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

max-sixty and others added 2 commits August 12, 2026 09:59
`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 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.

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.

Comment thread src/git/error.rs
Comment thread src/git/remove.rs
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 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.

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.

Comment thread src/git/repository/working_tree.rs
max-sixty and others added 2 commits August 12, 2026 14:58
…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 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.

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.

@max-sixty
max-sixty merged commit 7aba380 into main Aug 13, 2026
46 checks passed
@max-sixty
max-sixty deleted the remove-registration-ownership branch August 13, 2026 11:00
max-sixty added a commit that referenced this pull request Aug 13, 2026
… 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_
@max-sixty max-sixty mentioned this pull request Aug 14, 2026
max-sixty added a commit that referenced this pull request Aug 14, 2026
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>
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