fix(worktree): guard a registered path that no longer holds its worktree - #3785
Conversation
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
left a comment
There was a problem hiding this comment.
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".
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
left a comment
There was a problem hiding this comment.
All three findings from the previous review check out as fixed, verified against the code rather than the commit message:
worktree_is_unusableleads with!path.exists()and falls through toprunable, so the locked-and-absent case is caught again. All three sites that had lost it —usable_worktree_for_branch(thewt merge/wt step pushpath),plan_switch, andhint_for_repo— go through it, andworktree_is_unusable_covers_locked_absent_and_recreatedpins all four states.Selector::branch_only()inresolve_switch_targettakes the path arm off wherecreateis in scope, andresolve_selectorreturnsBranchOnlybefore both path steps — so the worktree-by-path lookup and the directory verdict switch off together, which makes the comment on theNoWorktreeAtPatharm true again. Thepr:/mr:early returns already producerewritten_to, so they're unaffected.map_tokenandresolve_worktree_nameare gone;git grepfinds 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.
`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.
…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_
#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.
…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>
…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>
) `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>
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 --forcedeleted an unrelated repositoryA 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 statusin that directory, reports the occupant's changes as this worktree's, and offers--forceas the cure.git refuses that same removal,
--forceincluded (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_repomakes 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.gitfile 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:
A recreated worktree directory leaked git's exit 128
wt switch,wt merge, andwt step pushwalked intogit rev-parse --git-dir failed (exit 128). Two of them probedPath::exists()first, which a deleted-and-recreated directory passes; the third asked nothing.worktree_is_unusableis the union of both tests, because neither implies the other.exists()catches the absent directory; git'sprunablecatches the recreated one.prunablealone 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":A locked worktree on an unmounted volume is exactly what
prepare_worktree_removal's lock guard exists for, so aprunable-only test would read it as healthy. All three commands now give the message the merely-deleted case already gave.wt removekeepsexists(), deliberately: there it is the precondition for the cleanup path rather than a health test, sinceprune_worktree_entryunregisters viagit 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-widegit worktree prunethat does.wt switch docs/missed a branch sitting right thereGit's ref format forbids a trailing
/, so the branch lookup never had a candidate — and shell completion produces exactly that spelling whenever adocsdirectory 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:
resolve_worktreebranch == nameplan_switchtarget.branch == branchtarget_worktree_at_pathtarget.filter(|t| *t == resolved)resolve_base_refresolved == baseThat 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.Selectorcarries the fact instead:expand_shortcutreports whether it fired,wt switchreports itspr:/mr:dispatch and remote-prefix strip, andnames_a_path()replaces all four comparisons.resolve_selectoris the ladder, andplan_switchexpands 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 whatwt switch --createneeds: the argument names a branch to create, sobranch_only()takes the arm off at the producer rather than each consumer re-testingcreate.It also reaches the directory verdict, so
ResolvedWorktreegainsNoWorktreeAtPathand the four sites that calledpath_selector_errorthemselves stop re-deriving it. The docstring defending that laziness didn't survive checking — the function returns onis_valid_branch_namebefore touching the filesystem, so every ordinary branch name already short-circuited.normalize_selectorcall sitespath_selector_*call sitesNavigating the diff
src/git/repository/mod.rs—Selector,normalize_selector, the newResolvedWorktreevariant.src/git/repository/worktrees.rs—expand_shortcut,expand_selector,resolve_selector,usable_worktree_for_branch.src/git/repository/working_tree.rs—ensure_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.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.
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,
--createagainst a worktree registered at that path, and, at the unit boundary, the four states ofworktree_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 intoresolve_target_selectorre-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
Also swept, and one thing left alone
Three more instances of the same shape, fixed here:
resolve_base_refwas the fourth copy of the comparison, so--base docs/now resolves too.hint_for_reposuggestedwt switch ^after an existence probe a recreated directory passes, pointing at a worktree the switch then refuses.targetvar used the bare shortcut expander, so a hook sawdocs/where the switch resolveddocs.The identical unborn/stale default-branch block in
require_target_branchandrequire_target_refis 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_checkoutdecides whether another worktree still holds a branch during removal, and also usesexists(). Switching it toprunablewould 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.