Skip to content

fix(worktree): guard a registered path that no longer holds its worktree - #3785

Merged
max-sixty merged 5 commits into
mainfrom
worktree-path-not-found-error
Aug 9, 2026
Merged

fix(worktree): guard a registered path that no longer holds its worktree#3785
max-sixty merged 5 commits into
mainfrom
worktree-path-not-found-error

Conversation

@max-sixty

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

Copy link
Copy Markdown
Owner

A registered worktree's path was trusted to still hold that worktree. Three defects followed, one of them destroying data, and the check that would have caught them — where it existed at all — was Path::exists().

wt remove --force deleted an unrelated repository

A clone that came to sit at a stale registration's path was removed whole, including uncommitted work and, for a repo never pushed, the only copy of its objects. wt's own hint routed the user there: the dirty gate reads git status in that directory, reports the occupant's changes as this worktree's, and offers --force as the cure.

$ wt remove feature
✗ Cannot remove worktree: feature has uncommitted changes
  ?? precious.txt                                          # ← the other repo's file
↳ ... to lose uncommitted changes, run wt remove --force feature

git refuses that same removal, --force included (validation failed … is not a .git file). Worktrunk's fast path renames the directory into trash rather than asking git to, so git's validation never ran. ensure_belongs_to_repo makes it, comparing the directory's git dir against this repository's: a linked worktree's sits under <common>/worktrees/, the main worktree's is the common dir, anything else answers to someone else. One comparison covers both worktree kinds and also rejects a .git file pointing at another repo, which git's shape test accepts.

It runs at planning, ahead of the dirty gate, and again at the rename for callers that stage without planning. Now:

$ wt remove --force feature
✗ Directory @ ../repo.feature is not this repository's worktree
↳ Removing it could destroy unrelated data; move the directory aside, then run git worktree prune

A recreated worktree directory leaked git's exit 128

wt switch, wt merge, and wt step push walked into git rev-parse --git-dir failed (exit 128). Two of them probed Path::exists() first, which a deleted-and-recreated directory passes; the third asked nothing.

worktree_is_unusable is the union of both tests, because neither implies the other. exists() catches the absent directory; git's prunable catches the recreated one. prunable alone is not the wider test it looks like — git withholds the attribute from a locked worktree even when its directory is gone, since prunability is its pruning policy and a lock means "don't prune this":

$ git worktree list --porcelain          # wt2 locked, all three directories removed
worktree /tmp/ptest/wt1
prunable gitdir file points to non-existent location
worktree /tmp/ptest/wt2
locked removable media                   # ← no prunable line
worktree /tmp/ptest/wt3
prunable gitdir file points to non-existent location

A locked worktree on an unmounted volume is exactly what prepare_worktree_removal's lock guard exists for, so a prunable-only test would read it as healthy. All three commands now give the message the merely-deleted case already gave.

wt remove keeps exists(), deliberately: there it is the precondition for the cleanup path rather than a health test, since prune_worktree_entry unregisters via git worktree remove, which skips validation only while the directory is absent. No scoped git command clears the recreated case, so it reports and names the repo-wide git worktree prune that does.

wt switch docs/ missed a branch sitting right there

Git's ref format forbids a trailing /, so the branch lookup never had a candidate — and shell completion produces exactly that spelling whenever a docs directory sits beside the branch. Selectors are normalized before resolution.

Resolving selectors through one ladder

The three fixes landed in three of the four places that assemble "expand shortcuts, try the branch, try the path, classify the failure" by hand. Each gated its path attempt on "did something rewrite this token?", answered by comparing an expansion's output against its input:

where the comparison
resolve_worktree branch == name
plan_switch target.branch == branch
target_worktree_at_path target.filter(|t| *t == resolved)
resolve_base_ref resolved == base

That is a fact the rewriting step knows, re-derived downstream from its output, and it is wrong in both directions. A shortcut can expand to the token it was given — - pointing at the branch you are already on — and string equality reads that as a literal, turning the path arm back on for a token nobody typed. Normalization breaks it the other way, which is why the trailing-separator fix needed threading through three call sites.

Selector carries the fact instead: expand_shortcut reports whether it fired, wt switch reports its pr:/mr: dispatch and remote-prefix strip, and names_a_path() replaces all four comparisons. resolve_selector is the ladder, and plan_switch expands into it rather than re-implementing its phases.

names_a_path() gates both path steps together — the worktree-by-path lookup and the directory verdict — which is what wt switch --create needs: the argument names a branch to create, so branch_only() takes the arm off at the producer rather than each consumer re-testing create.

It also reaches the directory verdict, so ResolvedWorktree gains NoWorktreeAtPath and the four sites that called path_selector_error themselves stop re-deriving it. The docstring defending that laziness didn't survive checking — the function returns on is_valid_branch_name before touching the filesystem, so every ordinary branch name already short-circuited.

before after
normalize_selector call sites 3 1
path_selector_* call sites 4 2
"was it rewritten?" comparisons 4 0

Navigating the diff

  • src/git/repository/mod.rsSelector, normalize_selector, the new ResolvedWorktree variant.
  • src/git/repository/worktrees.rsexpand_shortcut, expand_selector, resolve_selector, usable_worktree_for_branch.
  • src/git/repository/working_tree.rsensure_belongs_to_repo, the ownership check.
  • src/git/remove.rs, src/commands/repository_ext.rs — where it gates removal, and why before the dirty gate.
  • Call sites: commands/worktree/switch.rs, commands/worktree/push.rs, commands/merge.rs, commands/remove.rs, git/repository/config.rs.

Size

Comments and docstrings are the largest share: the ownership check and the four conditions behind the directory verdict all look like things to simplify away, so the reason each exists is recorded where it's enforced.

+
Production code 277 142
Comments & docstrings 320 75
Tests 325 8
Snapshots 186 0
Docs 6 0
Total 1114 225

Testing

Seven new tests. The data-safety one drives the real binary and asserts the filesystem afterwards, not just the exit code — removal stages by rename and deletes in a detached process, so a passing exit would not have caught a staged-then-deleted tree. The others cover the recreated directory (switch and remove), the trailing separator, --create against a worktree registered at that path, and, at the unit boundary, the four states of worktree_is_unusable — healthy, absent, locked-and-absent, recreated — and the selector's path-ness, including the degenerate case string equality got wrong. Each new test was confirmed to fail with its fix reverted.

One more covers an omitted merge target in a repo whose default branch can't be determined. ^ had a test for that error; the omitted-target route to the same message had none. The gap predates this branch — the closure is byte-identical to the one it replaces and codecov records those lines as missed at the base commit too — but relocating them into resolve_target_selector re-counted them as patch lines, which is what surfaced it.

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

Behavioral matrix, verified against a build
docs/ (trailing sep)      ▲ Worktree for docs @ ../repo.docs
detached by path          ▲ Worktree for detached worktree @ ../repo.det
leftover dir              ✗ No worktree @ ../repo.leftover
recreated dir             ✗ Worktree directory missing for rec
shortcut ^                ▲ Worktree for main @ ../repo
remove leftover           ✗ No worktree @ ../repo.leftover
--base docs/              ✓ Created branch nf from docs
foreign-repo remove       ✗ Directory @ ../repo.frn is not this repository's worktree
                             precious.txt survives
Also swept, and one thing left alone

Three more instances of the same shape, fixed here:

  • resolve_base_ref was the fourth copy of the comparison, so --base docs/ now resolves too.
  • hint_for_repo suggested wt switch ^ after an existence probe a recreated directory passes, pointing at a worktree the switch then refuses.
  • The pre-switch hook's target var used the bare shortcut expander, so a hook saw docs/ where the switch resolved docs.

The identical unborn/stale default-branch block in require_target_branch and require_target_ref is extracted. The rest of that pair differs in its existence predicate, extra arms, and final error; sharing it would cost more in parameters than the duplication does.

Left alone: live_sibling_checkout decides whether another worktree still holds a branch during removal, and also uses exists(). Switching it to prunable would make branch deletion more likely in a corner case where the detached path already answers the other way. That is a data-safety surface and a separate decision.

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

Three defects shared one cause: a registration's path was trusted to still
hold the worktree git recorded there, tested — where it was tested at all —
with `Path::exists()`.

`wt remove --force` deleted an unrelated repository that had come to sit at a
registered path, taking its uncommitted work and the only copy of its objects.
git refuses that removal, `--force` included; worktrunk's fast path renames the
directory into trash itself rather than asking git to, so git's validation
never ran. `ensure_belongs_to_repo` makes it, comparing the directory's git dir
against this repository's rather than git's `.git`-is-a-file shape, which one
comparison covers both worktree kinds and also rejects a `.git` pointing
elsewhere. It runs at planning (before the dirty-worktree gate, which would
otherwise report the occupant's dirt as this worktree's and offer `--force`)
and again at the rename, for callers that stage without planning.

A worktree directory deleted and *recreated* passed `exists()`, so `wt switch`,
`wt merge`, and `wt step push` walked on into `git rev-parse … (exit 128)`.
`usable_worktree_for_branch` asks git's own `prunable` instead, replacing two
hand-rolled probes and supplying the check `wt merge` never had. `wt remove`
keeps `exists()` deliberately: it is the precondition for the cleanup path, not
a health test — `prune_worktree_entry` unregisters with `git worktree remove`,
which skips validation only while the directory is absent — and the recreated
case, which no scoped git command clears, now reports instead of failing raw.

A trailing path separator can never be part of a branch name, so `docs/` — what
shell completion produces when a `docs` directory sits beside the branch —
found nothing. `normalize_selector` strips it where a raw token enters
resolution, which is also where the "did a shortcut rewrite this?" test lives.
Four places assembled "expand shortcuts, try the branch, try the path,
classify the failure" by hand — `resolve_worktree`, `plan_switch`,
`require_target_branch`, and `resolve_base_ref` — and each gated its path
attempt on "did something rewrite this token?", answered by comparing the
expansion's output against its input:

  branch == name                  resolve_worktree
  target.branch == branch         plan_switch
  target.filter(|t| *t == resolved)  target_worktree_at_path
  resolved == base                resolve_base_ref

That is a fact the rewriting step knows, re-derived downstream from its
output, and it is wrong in both directions: a shortcut can legitimately expand
to the token it was given (`-` pointing at the branch you are on), and any
normalization applied underneath reads as a rewrite — which is why stripping a
trailing separator had to be threaded through three call sites rather than one.

`Selector` carries the fact instead. `expand_shortcut` reports whether it
fired, `wt switch` sets it when `pr:`/`mr:` or the remote-prefix strip does,
and `names_a_path()` replaces all four comparisons. Normalization then has one
home, so `--base docs/` and a pre-switch hook's `target` var pick it up for
free.

`resolve_selector` is the ladder itself, and `plan_switch` now expands into it
rather than re-implementing its phases. It also reaches the directory verdict,
so `ResolvedWorktree` gains `NoWorktreeAtPath` and the four sites that called
`path_selector_error` themselves stop re-deriving it — the laziness that
argued for deferring it bought nothing, since the check returns on
`is_valid_branch_name` before touching the filesystem.

Swept for the same shape elsewhere. `resolve_base_ref` was the fourth copy,
above. `hint_for_repo` suggested `wt switch ^` after an existence probe a
recreated directory passes, so the hint could point at a worktree the switch
then refuses; it asks git's `prunable` now. The unborn/stale default-branch
block was identical in both `require_target_*` and is extracted; the rest of
that pair differs in its existence predicate, extra arms, and final error, and
forcing it together would cost more in parameters than the duplication does.

No behavior change beyond those three, all of which only widen what resolves.

@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.

Three findings, inline. The first is the one I'd want settled before merge — the new prunable test isn't a superset of the Path::exists() checks it replaces.

Also a merge-order heads-up rather than a finding: #3770 rewrites the same detached-worktree arm of prepare_worktree_removal this PR inserts the is_prunable() branch into, and adds a remove_branch_with_prunable_detached_worktree snapshot. Whichever lands second will need its arm re-derived rather than textually merged — this PR changes detached + absent directory from "fall through to the removal error" to WorktreeMissing.

Evidence for the locked-worktree finding

git 2.54.0, three linked worktrees, directory removed from each; wt2 locked first:

worktree /tmp/ptest/wt1
branch refs/heads/f1
prunable gitdir file points to non-existent location

worktree /tmp/ptest/wt2
branch refs/heads/f2
locked

worktree /tmp/ptest/wt3
branch refs/heads/f3
prunable gitdir file points to non-existent location

wt2 — absent directory, locked — carries no prunable attribute, so WorktreeInfo::is_prunable() (self.prunable.is_some()) is false for it. That is git's pruning policy (a lock means "don't prune this"), not a health verdict, which is what makes it a narrower test than exists() rather than a wider one.

The repo already treats this state as realistic: the lock guard in prepare_worktree_removal cites #3645 with "a temporarily-absent directory (removable media, a network mount, a dropped VPN) is exactly the case git worktree lock exists for".

Comment thread src/git/repository/worktrees.rs Outdated
Comment thread src/commands/worktree/switch.rs Outdated
Comment thread src/git/repository/mod.rs Outdated
Review found `prunable` is not the wider test it looks like. git withholds the
attribute from a locked worktree even when its directory is gone, because
prunability is its pruning policy and a lock means "don't prune this" —
verified on git 2.55.0. So replacing `Path::exists()` with it lost the locked
worktree on an unmounted volume, the case `prepare_worktree_removal`'s lock
guard exists for: `wt switch` reported a successful switch and then died in
`git rev-parse`, where it used to refuse upfront.

Neither test implies the other, so `worktree_is_unusable` is the union —
`exists()` catches the absent directory, `prunable` the recreated one.

Two more from the same review:

`--create` had lost its path arm. The old Phase 2b gated both the
worktree-by-path lookup and the directory verdict on `!create`; folding them
into `resolve_selector` moved that gate onto only the verdict, so `wt switch
--create docs` with a worktree registered at `./docs` returned that worktree
and silently dropped `--create`. The gate belongs on the selector, where
`create` lives: `branch_only()` takes the path arm off, and `resolve_selector`
now returns `BranchOnly` for any token that may not name a path — both arms
switch off together.

`map_token` had no callers; it was speculative. Deleting it also removes the
only genuinely-new uncovered lines on the patch. `resolve_worktree_name` had
none either once production callers moved to `expand_selector`, so it goes too.

@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.

All three findings from the previous review check out as fixed, verified against the code rather than the commit message:

  • worktree_is_unusable leads with !path.exists() and falls through to prunable, so the locked-and-absent case is caught again. All three sites that had lost it — usable_worktree_for_branch (the wt merge / wt step push path), plan_switch, and hint_for_repo — go through it, and worktree_is_unusable_covers_locked_absent_and_recreated pins all four states.
  • Selector::branch_only() in resolve_switch_target takes the path arm off where create is in scope, and resolve_selector returns BranchOnly before both path steps — so the worktree-by-path lookup and the directory verdict switch off together, which makes the comment on the NoWorktreeAtPath arm true again. The pr:/mr: early returns already produce rewritten_to, so they're unaffected.
  • map_token and resolve_worktree_name are gone; git grep finds no remaining references outside the CHANGELOG's historical entry.

Two docstring inaccuracies inline, both on lines this PR adds.

Not approving — this is the removal surface. The diff changes src/git/remove.rs and src/commands/remove.rs, and widens what wt remove --force refuses to delete. Per the repo's review guidance a change to what wt remove --force can reach isn't an agent's to sign off on, however good the change looks — and the ownership check here is exactly the kind of thing worth a human reading twice, since it's the only thing standing between a stale registration and an unrelated repository. Nothing outstanding from me; it wants a human on the merge.

codecov/patch is red at 95.77% (9 misses on 213 patch lines). That matches the count you handed over in the map_token thread, so I'm not re-deriving it — flagging only because CLAUDE.md makes it a merge gate that needs an explicit call.

Comment thread src/git/repository/worktrees.rs Outdated
Comment thread src/git/repository/worktrees.rs Outdated
`usable_worktree_for_branch` claimed `wt switch` and the selector ladder as
consumers. Neither is: `wt switch` and `recover` hold a path by then and ask
`worktree_is_unusable` directly, and the ladder asks neither — which
`resolve_worktree`'s own spec says a few hundred lines down, so the two
contradicted each other.

`expand_selector` named the `rewritten` field, which the previous commit
renamed to `may_name_path` with the opposite polarity, and credited
`expand_shortcut` with reporting the flag when it reports only whether it fired.
…branch

`^` had a test for the unknowable-default-branch error; an omitted target
reaches the same message by the other route — `resolve_target_selector` asking
for the default directly rather than the shortcut expander — and had none.

The gap predates this branch: the closure is byte-identical to the one it
replaced, and codecov records those lines as missed at the base commit too.
Relocating them into the new function is what re-counted them as patch lines,
which is what surfaced the gap.
@max-sixty
max-sixty merged commit cf822f7 into main Aug 9, 2026
37 checks passed
@max-sixty
max-sixty deleted the worktree-path-not-found-error branch August 9, 2026 13:23
max-sixty added a commit that referenced this pull request Aug 9, 2026
…3786)

Three specs in #3785's selector refactor describe mechanisms that change
removed or altered. Comment-only; no behavior change.

Each is wrong in a way a reader would act on rather than merely notice:

**`worktree_is_unusable`** closed with "`false` for a path git has no
registration for". The `!path.exists()` early return makes that untrue
as a claim about the return value — such a path answers `true` when it
is simply gone. The sentence was only ever about the `prunable` lookup,
so it now says so, and records that no caller reaches the combination
(all three take their path out of the listing).

**`normalize_selector`** named three call sites, two of which no longer
call it, and spent a paragraph explaining the string-comparison design
the refactor deleted — documenting a removed mechanism as current, which
is the worst of the three. It now names its one home and says what made
a single home possible.

**`ResolvedTarget::selector`** credited only a rewrite with taking the
path arm off, missing `--create`, which takes it off without rewriting
anything.

The first was raised on #3785 after I had worked its other threads, so
it never got a reply there. The other two came from re-reading the
neighbouring specs while fixing it.

<details>
<summary>Why these were worth a change rather than a note</summary>

A spec that describes a deleted mechanism is worse than no spec:
`normalize_selector`'s explained why each assembly of the resolution
ladder had to normalize for itself, which was true before `Selector`
carried "may this token name a path?" as a fact rather than a string
comparison. A reader adding a fourth entry point would have followed it
and re-introduced the per-site normalization the refactor removed.

</details>

> _This was written by Claude Code on behalf of max-sixty_
worktrunk-bot added a commit that referenced this pull request Aug 9, 2026
#3785 moved the leftover-directory report out of the BranchOnly arm into
its own NoWorktreeAtPath arm, which this branch's detached guard sat on
top of. The guard keeps the BranchOnly arm to itself; the directory
verdict keeps main's new arm.
max-sixty pushed a commit that referenced this pull request Aug 11, 2026
…er (#3796)

Comment-only. Two adjacent comments in `prepare_worktree_removal`'s
`WorktreePath` arm describe a routing that cf822f7 ("guard a
registered path that no longer holds its worktree", #3785) changed
underneath them.

The branch-only cleanup's comment still ends with *"A detached worktree
has no branch to fall back to, so it proceeds and surfaces the removal
error."* It no longer proceeds. A detached worktree whose directory is
gone fails the first arm's `wt.branch.as_deref()` test, and git reports
exactly that registration as `prunable` — verified against git directly:

```
$ git worktree add --detach ../det HEAD && rm -rf ../det && git worktree list --porcelain
worktree /tmp/gt/det
HEAD 7c8964f04dc09ce2dff86351d5b63906ac4de41e
detached
prunable gitdir file points to non-existent location
```

So it lands in the `else if wt.is_prunable()` arm added by that commit
and is refused at planning with `WorktreeMissing` plus the `git worktree
prune` hint. Behavior is unchanged by this PR; the old path also ended
in an error, just git's raw one.

That second arm's own comment has the mirror-image gap: it opens
*"Registered, directory present"*, which names only the
deleted-and-recreated shape, while the detached-and-absent shape reaches
it too. It also says the cleanup above "needs the directory gone" when
that arm wants a branch *and* an absent directory — the missing half is
precisely why the detached case falls through.

Both comments now name the two shapes the arm catches and what each
cleanup actually requires.

No regression test: the change is comments, and the routing it describes
is already pinned by the prunable-registration tests that landed with
#3785.

Deliberately narrow. The asymmetry it exposes — a stale branch-carrying
registration is cleaned up by `wt remove` while a stale detached one is
refused — overlaps #3769 and #3791, so it is left to those rather than
folded in here.

---------

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
max-sixty pushed a commit to max-sixty/tend that referenced this pull request Aug 11, 2026
…930)

Three `tend-review` reviews on `max-sixty/worktrunk` withheld APPROVE
and attributed the decision to a repo review policy that does not exist
in writing. The withholding itself is correct and authorised — this is
about the citation, not the verdict.

| Review | Wording |
|---|---|
| [worktrunk#3784
r4891319505](max-sixty/worktrunk#3784 (review))
12:04:47Z | "**Per this repo's tend review policy** the `wt remove
--reap` path is a hold-for-human surface" |
| [worktrunk#3784
r4891354215](max-sixty/worktrunk#3784 (review))
12:22:27Z | "`wt remove --reap` is a hold-for-human surface **under this
repo's review policy**" |
| [worktrunk#3785
r4891406429](max-sixty/worktrunk#3785 (review))
12:47:20Z | "**Per the repo's review guidance** a change to what `wt
remove --force` can reach isn't an agent's to sign off on" |

No such rule is written anywhere a reader could follow the pointer to:

- worktrunk's overlay (`.claude/skills/running-tend/SKILL.md`, 383
lines) — zero hits for `hold.for.human|data.safety|sign off|not an
agent`. Its only approval-related content is "Don't Self-Dismiss Over
Unrelated Test Flakes".
- worktrunk's `CLAUDE.md` §"Data Safety" exists, but it is a *code*
guideline ("prefer failure over silent loss", "explicit consent for
destructive ops") — it says nothing about who may approve a PR.
- tend's bundled skills and `shared/` — zero hits for the same patterns
across the whole tree.

What actually authorises the behaviour is
[`review/SKILL.md`](https://github.com/max-sixty/tend/blob/main/plugins/tend-ci-runner/skills/review/SKILL.md)'s
own "If the design involves a judgment call, flag it for human review as
a COMMENT". That is a sufficient reason on its own. Dressing it as a
citable repo policy sends the author looking for a rule that isn't there
— the same shape as a fabricated API flag or a guessed docs slug, which
`running-in-ci` §"Grounded Analysis" already treats as the fastest way
to erode trust in everything else in the comment.

## The change

One paragraph in the review skill, next to the existing low-confidence
guidance: cite repo guidance only when you can name the file and
heading; otherwise own the call as your own judgment, with a contrasting
example.

## Gate assessment

- **Evidence level: High** (consistent pattern across multiple sessions)
— **3 occurrences**, across 2 PRs, 3 distinct sessions, 2 analysis
windows. Occurrence 1 was recorded in the evidence gist by the prior
window (run 31312038759), which set "whether another review cites a
'repo policy' that grep can't find" as its explicit next-window check;
occurrences 2 and 3 landed in this window. Every non-approving review in
the window did it, 3/3.
- **Structural vs stochastic**: the phrasing is a model choice, so
stochastic-leaning — but the skill authorises flagging judgment calls
for human review without saying anything about how to attribute the
withholding, and the observed rate is 3/3. The edit closes the gap
rather than legislating a one-off lapse.
- **Change type: targeted fix** (one paragraph beside the guidance it
qualifies, no restructuring) — Gate 2 normal bar, met at 3.
- Substance is not in question: the prior window re-verified six of this
reviewer's factual claims against source, six for six, and the human
author acted on every finding in both PRs this window.

Evidence log: https://gist.github.com/a88c03f4d0c3fb1791060ff3dd97d1c4

---------

Co-authored-by: tend-agent <270458913+tend-agent@users.noreply.github.com>
max-sixty added a commit that referenced this pull request Aug 13, 2026
)

`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`:

```console
$ 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'
```

<details>
<summary>Reproducer, verified against a build of main</summary>

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.

```console
$ 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
```

</details>

## 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:

```console
$ 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:

```console
$ 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.

<details>
<summary>One residual, left alone</summary>

`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`.

</details>

## 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 #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_

---------

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