Skip to content

feat(worktree): warn when a branch is checked out in multiple worktrees - #3480

Merged
max-sixty merged 5 commits into
mainfrom
fix/issue-3392-duplicate-checkout-warn
Jul 25, 2026
Merged

feat(worktree): warn when a branch is checked out in multiple worktrees#3480
max-sixty merged 5 commits into
mainfrom
fix/issue-3392-duplicate-checkout-warn

Conversation

@worktrunk-bot

Copy link
Copy Markdown
Collaborator

Problem

git worktree add --force <path> <branch> bypasses git's "already used by worktree" guard, so the same branch can be live in two worktrees at once — breaking worktrunk's branch ⇔ worktree bijection. worktrunk never creates that state itself, but once it exists worktree_for_branch silently resolved to whichever worktree git listed first (roughly creation order), and that choice flowed into every resolution path — wt switch, wt push, wt step diff, wt context, the picker — with no warning, error, or way to name the shadowed worktree.

This implements the detect-and-warn option from #3392: make the invisible choice visible without changing resolution semantics.

Solution

  • worktree_for_branch now collects all worktrees on the branch via a pure worktree_paths_for_branch helper; when more than one exists it warns once (per branch, deduplicated per process) and still resolves to the first — semantics are unchanged.
  • The warning names every path and points at a concrete fix:
▲ Branch feature is checked out in 2 worktrees; wt uses the first:
   ┃ ~/repo.feature
   ┃ ~/repo.feature-dup
↳ To drop a duplicate, run git worktree remove ~/repo.feature-dup

Detection is separated from emission so the pure list logic is unit-testable and the ambiguity check stays cheap — list_worktrees() already holds the full list.

Testing

  • Unit test test_worktree_paths_for_branch_detects_duplicates — the pure helper returns both paths in git's listing order for a duplicated branch, one path for a unique branch, and none for an absent one.
  • Integration test test_step_diff_duplicate_branch_warns — creates the duplicate with git worktree add --force, runs wt step diff --branch=feature, and snapshots the rendered warning.

The warning only fires when a duplicate exists (which no prior test sets up), so existing snapshots are unaffected.


Closes #3392 — automated triage

git worktree add --force lets the same branch live in several worktrees at
once, breaking worktrunk's branch <-> worktree bijection. worktree_for_branch
silently resolved to whichever git listed first, so the duplicate was
invisible to every resolution path (switch, push, step diff, context, ...).

Detect the ambiguity in worktree_for_branch and warn once per branch, naming
every path, without changing resolution semantics (the first match still
wins). Detection lives in a pure worktree_paths_for_branch helper.

Closes #3392

@worktrunk-bot worktrunk-bot left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Self-review — semantics are preserved (worktree_for_branch still returns the first matching path, None when absent), the pure worktree_paths_for_branch split is nicely testable, poisoned-lock handling is correct, and the styling follows house conventions. Two things worth a second look:

Placement — the warning now fires from ambient/read-only paths. worktree_for_branch feeds primary_worktree(), which is called by wt list collection (collect/mod.rs:1062) and the statusline is_home check (statusline.rs:914 / 1035) — but only for bare repos, where primary_worktree() resolves the default branch through worktree_for_branch. So for a bare repo whose default branch is duplicated, a plain wt list prints the warning to stderr as a side effect of a read-only listing, and wt list statusline re-warns on every render (the per-process dedup can't span the fresh process each redraw). That may well be intended — surfacing the ambiguity broadly is the point of #3392 — but worth confirming the statusline/list case is desired rather than scoping the emission to explicit branch-targeting commands (switch/merge/push/step). The layer itself is fine; emit_user_config_warnings in repository/mod.rs already warns from here.

Multi-duplicate hint is incomplete. When 3+ worktrees share the branch, the body lists all of them but the actionable hint only names paths.get(1), so following it once still leaves the branch ambiguous and the warning recurs. Inline suggestion below emits one git worktree remove line per extra.

Comment thread src/git/repository/worktrees.rs Outdated
…rning

Recover the poisoned warned-set lock inline and gate emission on the
insert result instead of early-returning, and index the guaranteed-present
shadowed path directly — removing three uncovered defensive lines flagged by
codecov/patch. Emitted output is unchanged.
Resolve a --force-duplicated branch twice in one process so the once-per-branch
guard is exercised on both the emit and skip sides — covering the codecov/patch
line gap on the dedup branch.
For 3+ worktrees on one branch, emit a removal hint per extra so cleaning up
all of them is actionable, instead of only naming the first. Single-extra
output (the common case) is unchanged.
@max-sixty

Copy link
Copy Markdown
Owner

should we hint with wt remove rather than the git command?

@worktrunk-bot

Copy link
Copy Markdown
Collaborator Author

Good instinct, but I think git worktree remove is actually the right tool here — wt remove can't name the worktree we need to drop.

wt remove addresses worktrees by branch name: resolve_worktree_arg treats any argument as a branch, then resolves it through worktree_for_branch. In this exact scenario the branch ⇔ worktree bijection is broken, so wt remove feature would resolve feature back through worktree_for_branch — which returns the first worktree (the one wt keeps), not the shadowed duplicate. It literally can't target the worktree the hint is telling you to remove.

git worktree remove <path> takes an explicit path, which is the only way to disambiguate the two worktrees on the same branch. That's why the hint points at the path.

If we'd rather keep users inside wt, the clean fix would be to teach wt remove to accept an explicit path and remove by path when the branch is ambiguous — but that's a separate enhancement beyond this PR's scope. Happy to do it as a follow-up; otherwise I'd keep the git hint as-is.

@max-sixty

Copy link
Copy Markdown
Owner

doesn't wt remove also take a path?

@worktrunk-bot

Copy link
Copy Markdown
Collaborator Author

Good catch — you're right that wt remove accepts a path, and my earlier "it can't target the worktree" was too strong. resolve_worktree_arg does have a path fallback (resolve.rs:52-67). But I traced it through end-to-end, and for a duplicate on a named branch the path gets discarded, with a worse result than doing nothing. Verified against target/debug/wt:

$ git worktree list
…/repo.dup    [feature]   ← git lists this first
…/repo.first  [feature]

$ wt remove …/repo.dup --yes        # explicit path to the duplicate
◎ Removing feature worktree & branch …

$ git worktree list
…/repo.first  0000000  [feature]    # branch deleted, survivor orphaned

Why the path is ignored: resolve_worktree_arg resolves the path to Worktree { path: repo.dup, branch: Some("feature") }worktree_at_path fills in the branch. Then remove.rs:124-130 only honors the path for a branch-less (detached) worktree; when a branch is present it builds RemoveTarget::Branch("feature"), which resolves back through worktree_for_branch to git's first-listed worktree — not the path you named. So wt remove <dup-path> removes an arbitrary duplicate (whichever git lists first), deletes the shared branch, and orphans the other worktree at 0000000. That's exactly the ambiguity this PR is warning about, made destructive.

git worktree remove <path> is unambiguous — it removes precisely the path, touches no branch — which is why the hint points there.

The clean fix is the follow-up I mentioned: make wt remove honor an explicit path when the branch is ambiguous — i.e. when a worktree is resolved via the path fallback and its branch has duplicates, target RemoveTarget::Path instead of RemoveTarget::Branch, and don't delete the branch (it's still checked out elsewhere). That's a real wt remove semantics change with data-safety implications, so I'd keep it out of this warn-only PR. Happy to open it as a follow-up if you'd like — then the hint could point back at wt remove.

@max-sixty

Copy link
Copy Markdown
Owner

yes let's have wt remove honor a path generally, not just detached worktrees

@worktrunk-bot

Copy link
Copy Markdown
Collaborator Author

Opened #3533, which makes wt remove honor an explicit path generally. It removes the exact worktree named (by path, not by resolving the branch back to git's first-listed worktree), and — since the branch is still checked out in the sibling — retains it rather than deleting it and orphaning the survivor:

$ wt remove …/repo.dup
◎ Removing feature worktree in background
↳ Branch feature retained; still checked out @ …/repo.first

Single-checkout removals are unchanged (branch still deleted). Once #3533 lands, the duplicate-checkout hint here can point at wt remove <path> instead of git worktree remove <path>.

@max-sixty
max-sixty merged commit bcd1ffd into main Jul 25, 2026
39 checks passed
@max-sixty
max-sixty deleted the fix/issue-3392-duplicate-checkout-warn branch July 25, 2026 20:47
max-sixty added a commit that referenced this pull request Jul 26, 2026
## Problem

Follow-up to #3480. That PR made a duplicated branch checkout (`git
worktree add --force <path> <branch>`) visible at *resolution* time:
`worktree_for_branch` warns once per branch, then resolves to whichever
worktree git lists first. `wt list` said nothing about it. Two rows
named `feature`, and no column that explains why.

The nearest thing to a signal was accidental. A force-added duplicate
usually lands off-template, since the original holds the template path,
so it picks up `⚑` for the location mismatch — while the worktree *at*
the template path, the one `wt` actually resolves to, carried no flag at
all. Exactly backwards from what's useful.

The framing from the request: a worktree in the wrong location gets a
status flag; a worktree sharing its branch should get one too.

## Solution

`⚑` now covers both, on every worktree of the duplicated branch,
resolved one included. Which worktree `wt` picks is git's listing order,
so singling out the shadowed rows would imply a legitimacy the ordering
doesn't carry.

```
@ main           ^|                                      |     .                    05a4a45d  16h   Initial commit
+ feature       ⚑_                                             ../repo.feature      05a4a45d  16h   Initial commit
+ feature       ⚑_                                             ../repo.feature-dup  05a4a45d  16h   Initial commit
```

This started as a seventh glyph (`⧉`) and collapsed onto `⚑` in the
second commit. The Status column is a dense alphabet the reader has to
learn, and the two states say one thing: this worktree's place in the
branch ⇔ worktree map is irregular. Off-template path and
branch-claimed-twice are both instances. The table already distinguishes
them without a glyph — a repeated Branch cell is the duplicate, an odd
Path cell the mismatch — so the flag only has to say "not a rendering
glitch, look at the Path column". Sharing the glyph means sharing its
dim-yellow styling, since the codebase treats a symbol's color as part
of its identity; #3480's warning remains the loud channel, firing the
moment any command resolves the branch.

**The Path column comes along.** It previously appeared only for a
location mismatch, on the reasoning that the path is otherwise redundant
with the branch. A duplicate inverts that: the branch name no longer
identifies the row, and the path is the only thing telling the two
apart. The layout flag is renamed from `has_branch_worktree_mismatch` to
`path_is_informative` to say what it now means.

**The data model keeps the distinction.** JSON has no cardinality
budget, and reporting a duplicate that sits at the template path as
`branch_worktree_mismatch` would be false — its path does match. Schema
1's `worktree.state` names the cause (`"duplicate_branch"`), schema 2
gets its own `worktree.duplicate_branch` bool beside `branch_mismatch`,
matching that schema's one-fact-per-field shape. The priority between
the two `⚑` states now decides only which cause JSON reports.

Detection is one pass over the worktree list (`duplicated_branches`,
next to #3480's `worktree_paths_for_branch`), in memory, pre-skeleton,
no git calls.

## Testing

- `test_worktree_paths_for_branch_detects_duplicates` gains the set form
and a detached-HEAD worktree, which has no branch to duplicate.
- `test_metadata_worktree_state_priority` covers the two `⚑` states'
ordering and both yielding to `⊟`/`⊞`.
- `test_list_duplicate_branch` snapshots the table above, showing both
flagged rows and the Path column earning its place.
- `test_list_duplicate_branch_json` asserts both schemas flag exactly
the two duplicated rows.

The flag only fires on a state no prior test sets up, so the only
snapshot churn is the help pages and the schema-2 envelope's new field.

> _This was written by Claude Code on behalf of max_

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
max-sixty added a commit that referenced this pull request Jul 26, 2026
…3607)

Follow-up to the [`wt remove <path>` discussion on
#3480](#3480 (comment)),
widened from that one command to the whole surface. #3480 has since
landed and is merged in here — its duplicate-checkout warning composes
with this: the warning names the shadowed worktrees, and a path is how
you then address one.

## Audit

Verified against the built binary. wt had three answers to "what does
this token mean?":

| Route | `@` `-` `^` | worktree path | `pr:N` |
|---|---|---|---|
| `wt switch` (`resolve_switch_target`) | yes | only if absolute or ≥2
components, and not `--create` | yes |
| `wt remove` (`resolve_worktree_arg`) | yes | any token | no |
| everything else (raw `worktree_for_branch`) | **no** | **no** | no |

Same token, same cwd, two answers:

```console
$ wt remove inner     # ✓ Removed innerbranch worktree & branch
$ wt switch inner     # ✗ No branch named inner
```

And outside switch/remove the shortcuts didn't work at all — `wt step
diff --branch @` was `✗ Branch @ has no worktree`, while `wt config
state marker set --branch @` silently wrote state under the literal key
`@`. Separately, wt prints paths as `~/…` but wouldn't accept that form
back.

## Change

One canonicalizer in the lib, `Repository::resolve_worktree`, absorbing
the path fallback that lived in the bin crate's `resolve_worktree_arg`
(now deleted). Resolution order is documented once, on that function:
`@`, then `-`/`^`, then a branch with a worktree, then a path naming a
registered worktree, then the branch alone.

**Branch-first, everywhere.** A directory never shadows a branch that
shares its name; a path answers only what a branch cannot — a detached
worktree, or one of two checkouts of the same branch (#3480's case). The
`looks_like_path` shape gate is gone, so a single-component path
resolves like any other.

Two shapes cover what callers need: `require_worktree` for commands that
need a worktree to operate in, `require_selected_branch` for arguments
that key by branch. The merge/rebase target validators fall through to
the same path lookup, so a target can be named by the worktree it's
checked out in.

Routed through it: `switch` (including `--base`), `remove`, `step commit
--branch`, `step diff --branch` and its target, `step copy-ignored
--from`/`--to`, `step promote`, `step relocate`, `config state --branch`
(9 sites), and `merge` / `step rebase` / `step squash` / `step push`
targets.

`resolve_input_path` — already documented as the one resolution point
for user-supplied paths — now expands a leading `~`, so the tilde form
worktrunk prints is a form it reads back. `~user` stays literal; wt
doesn't reimplement that shell feature.

## Documentation

A path is an alias, not a second addressing scheme, so it is stated once
rather than on every argument: one paragraph in `wt switch`'s help and
one sentence on the addressing line in `worktrunk.md`. Argument
descriptions still read as branches. The two exceptions are the
arguments whose descriptions are already catalogues of accepted forms —
`wt switch`'s (`Branch, worktree path, shortcut, or PR/MR URL`) and `wt
remove`'s, which has named the path since before this branch. The
Worktree Model section of `CLAUDE.md` records which way to document it,
so the next argument doesn't grow its own copy.

## Two silent no-ops fixed along the way

- `wt step relocate <unmatched>` matched arguments against branch names
by string equality, so a typo filtered everything out and the empty
result rendered as `○ All worktrees are at expected paths` — a success
message for work that never happened. Every way an argument can fail to
land on a relocatable worktree now errors, including the detached and
prunable cases the new path route makes reachable.
- A selector matching nothing was reported as a branch without a
worktree, hinting `wt switch <token>` — which creates a worktree only
when the branch exists, so for a mistyped path it would just fail again.
`WorktreeSelectorNotFound` now says `No branch or worktree named X`; a
branch that genuinely exists without a checkout keeps the create hint.

## Testing

Full gate green: 4596 tests, lints, docs sync, `--features
shell-integration-tests` clippy. `codecov/patch` is 99.25% of diff hit
against a 97.93% target. New coverage:

- Unit: branch-and-path equivalence, branch-beats-same-named-directory,
detached-by-path (and its `require_selected_branch` refusal), shortcuts
never treated as paths, branch-only fallthrough, and the two distinct
not-found errors. Plus `expand_tilde` round-tripping
`format_path_for_display`.
- Integration: `switch` by relative/single-component/absolute/tilde
path, `--base` by path, `step diff --branch` by path and `@` (asserted
equal to the by-branch output), `config state --branch` set via `@` and
read via the worktree path, and both new relocate errors.

`wt remove`'s resolution is unchanged — it already had this rule; it now
shares the implementation. The 106-test `remove::` suite is untouched
and green.

- Integration: `wt step push <worktree-path>` (the
`require_target_branch` half of the target fallback), and `wt step
relocate` against a prunable worktree.

One diff line is unhit: `expand_tilde`'s fallback when `home_dir()`
returns `None`, which has no deterministic trigger. The `@`-resolution
backstop in `resolve_worktree` is untested for the same reason — no CLI
route reaches it — so it kept its original `match` arm rather than being
re-indented into the diff.

## Left out

`wt config state default-branch set` and `previous-branch set` take a
branch name as a *value to store* rather than a selector, so they still
take it literally.

> _This was written by Claude Code on behalf of Maximilian Roos_

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
max-sixty added a commit that referenced this pull request Jul 27, 2026
…#3608)

## What prompted this

Getting #3605 green ran into codecov reporting a `base_commit` three
commits
older than the real merge-base. This audits whether our config causes
that.

## The cause

Codecov picks a PR's base by walking back to the newest ancestor that
has a
coverage report. It used the real merge-base for PRs #3480, #3532 and
#3602,
and a stale one for #3603 and #3605. The difference is whether the
merge-base
uploaded a report. **29 of the last 40 main commits did not.**

`ci` had one concurrency group for main pushes, and GitHub cancels the
*pending* run in a group whenever a newer one joins, even with
`cancel-in-progress: false`. So the question is how long a run holds the
group,
and a run isn't done until its slowest job is:

| job | duration on main |
|-----|------------------|
| `fast-checks` | 2 min |
| `code-coverage` | 3-4 min |
| `test (windows)` | 11 min |
| `collect affected coverage (windows)` | 110-129 min |

Each main run held the group for ~2 hours, so nearly every subsequent
main push
was cancelled while queued, taking the 4-minute coverage job with it.
Every
cancelled main run's `updated_at` lands within a second of the next
push's
`created_at`.

The 2 hours is real work, not queue: 2-5s from `created_at` to
`started_at`,
then 108 minutes inside `cargo affected collect` — 4181 tests under
`-C instrument-coverage` with a per-test LLVM profile, ~5 GB of profraw.

## The fix: one workflow per cadence

The three groups of jobs have incompatible needs, and one group was
serving all
of them.

| workflow | cadence on main | why |
|----------|-----------------|-----|
| `ci` | every commit, ~11 min | required gate + fast checks |
| `coverage` | every commit, keyed per-sha | a skipped upload leaves
later PRs on a stale base |
| `affected` | sampled, ~2 h | a DB a few commits old still anchors a
correct superset |

`affected` keeps exactly the grouping it has today, so its sampling is
unchanged and deliberate. It just no longer drags the other two along.

### Scope of the impact

The posted `codecov/patch` check scopes to the PR's own GitHub diff, so
a stale
base did **not** score PRs against other people's lines. On #3605 the
posted
91.66% is exactly `github.rs`'s 11/12, while the stale-base compare
object
reported 64/65 across 13 files. What a stale base costs:

- `codecov/project` reports "compared to \<stale sha\>"
- the patch `auto` target is the stale base's project coverage (0.02pp
here)
- the compare API object widens to `base..head`, which is what made the
  investigation look like silence

Separately, `test`/`lint`/`fast-checks` also stopped completing on main.
Nothing
load-bearing rode on that (they already ran on the PR), but it left
`tend-ci-fix` with nothing to watch, since it doesn't fire on cancelled
runs.

## Two smaller fixes

- `ignore: "**/tests/**"` compiles to `.*/tests/.*` (confirmed against
codecov's validator), which needs a leading directory and so never
matched
`tests/` itself. Inert today since `cargo llvm-cov` reports only `src/`
(verified against a downloaded `cobertura.xml`), but now correct if that
  changes. Now `tests/**`.
- `fail_ci_if_error` gated on `github.repository_owner`, which is the
*base*
repo's owner on a fork PR too, so the soft-fail its comment describes
never
  applied. It keys off the head repo now.

## Docs

The API behaviour was ours to misuse, not codecov's to explain. Three
traps,
all confirmed against the live API:

- `file_report/<path>/` 404s with `coverage info not found` because the
route
swallows the trailing slash into the path. Without it the endpoint
returns
  `line_coverage`.
- `?pullid=N` always compares the PR's **current** head. `?base=&head=`
asks
  about an earlier commit.
- the compare response has no `patch_totals` key, and `.name` is
`{base, head}` rather than a string, so a filename lookup silently
matches
  nothing.

A working recipe already existed in `running-tend`, but that skill is
scoped to
CI. `tests/CLAUDE.md` owns coverage investigation, so the queries go
there and
`running-tend` points at them instead of keeping a second copy.

Re-running the corrected query against #3605's failing commit reproduces
the
miss exactly: `src/git/remote_ref/github.rs:164`, the `gh repo
set-default`
hint, matching what the session eventually found by hand.

## This PR demonstrates it

It changes no Rust at all, only YAML and markdown. Codecov still
reported a
**10-file, 111-line patch** on its first commit, because it based the
comparison on `203603909` rather than the real merge-base `32f380a27`.
Every
main commit in between has no report:

| commit | ci run | report |
|--------|--------|--------|
| `32f380a27` | queued | no |
| `9645e3e13` | cancelled | no |
| `bcd1ffdfd` | cancelled | no |
| `8865f20ab` | cancelled | no |

Every one of those 111 patch lines belongs to somebody else's merged
commit. It
passed at 100% only because those commits are well covered.

> _This was written by Claude Code on behalf of @max-sixty_

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
max-sixty added a commit that referenced this pull request Jul 27, 2026
Resolves the conflicts with #3607 (path resolution) and #3617 (the
removal chain's one-check-per-guarantee refactor), and reworks the
retention onto their shape:

- One `live_sibling_checkout` predicate replaces the two divergent
  sibling scans. A sibling entry whose directory is already gone no
  longer retains the branch and names a path that isn't there.
- `wt merge` and `wt step prune` ask it too. Both reached the same ref
  deletion outside the guard; merge asserted the invariant in a comment
  instead of checking it.
- `wt step prune` targets candidates by path, matching `wt remove`.
  Targeting a stale entry by branch name resolved to a live worktree the
  same prune had skipped as too young, then removed it.
- A `-D` this refuses warns instead of passing quietly.
- #3480's duplicate hint points at `wt remove <path>`, now the safe
  answer, and `wt remove --help` states the retention.

Tests: remove (path, name, refused -D, pruned fallback, stale sibling
must not retain), prune (stale entry with a live sibling), merge
(duplicate checkout).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
max-sixty added a commit that referenced this pull request Jul 28, 2026
…ranches (#3533)

Follow-up to the discussion on #3480: [@max-sixty
asked](#3480 (comment))
to have `wt remove` honor an explicit path generally. #3607 has since
landed the resolution half, `Repository::resolve_worktree`, which turns
a path into a worktree. This is the removal half, and it is the part
resolution cannot decide: which worktree to act on is a naming question,
whether the branch may be deleted is not.

## Problem

`wt remove` threw the resolved answer away. For any non-current worktree
it re-targeted by branch name, and `prepare_worktree_removal` mapped
that branch back to git's *first-listed* worktree. Two failures
followed, both silent.

**The wrong worktree is removed.** With `feature` checked out twice:

```console
$ git worktree list
…/dup    [feature]
…/first  [feature]

$ wt remove …/first
◎ Removing feature worktree & branch in background (same commit as main, _)
```

`…/dup` is gone. `…/first`, the one named, is still there.

**The survivor is left broken.** The shared branch is deleted with it.
Worktrunk deletes branches with `git update-ref -d`, git's
compare-and-swap primitive, which unlike `git branch -d` does not refuse
a ref that is checked out somewhere:

```console
$ git worktree list
…/first  0000000 [feature]

$ git -C …/first rev-parse HEAD
fatal: ambiguous argument 'HEAD': unknown revision or path not in the working tree.
```

`wt step prune` reached the same deletion unattended, and `wt merge`
reached it with a freshly integrated branch, so nothing else declined. A
branch gets a second worktree only through `git worktree add --force`;
worktrunk never does it itself.

## Fix

**Remove the worktree that was named.** `wt remove` drops non-current
worktrees via `RemoveTarget::Path`. `wt step prune` does the same: its
candidates already carry a path, and targeting a *stale* entry by branch
name resolved to a live worktree that the same prune had just skipped as
too young, then removed it.

**Retain a branch another worktree holds.** One predicate,
`live_sibling_checkout`, answers "would deleting this ref orphan a
checkout?", and every path that can delete a branch asks it:
`prepare_worktree_removal`'s worktree and pruned-branch-only arms
(covering `wt remove`, `wt step prune`, and the picker, which already
targeted by path) and `wt merge`'s finish. A hit forces
`BranchDeletionMode::Keep`, the single chokepoint every deletion path
honors, and names the surviving checkout:

```console
$ wt remove …/dup
◎ Removing feature worktree in background
○ Branch feature retained; still checked out @ …/first
```

A sibling whose *directory* is already gone is stale metadata, not a
checkout with anything to lose, so it does not retain: removing the last
live checkout still deletes the branch.

**`-D` is refused out loud.** Everywhere else `-D` is the override that
wins, so one that cannot be honored warns rather than passing quietly:

```console
$ wt remove …/dup -D
◎ Removing feature worktree in background
▲ Branch feature retained despite -D; still checked out @ …/first
```

The ordinary single-checkout case is unchanged, and a retained branch
skips the integration check entirely rather than computing a verdict it
would discard.

#3480's duplicate-checkout hint now points at `wt remove <path>`, which
this makes the safe answer.

## Testing

Full gate green. New coverage, each case asserting the survivor still
resolves `HEAD`, which is the corruption in question:

- `remove`: by path, by name, refused `-D`, the pruned-directory
fallback, and the mirror case where a stale sibling must *not* retain.
- `step prune`: a stale entry whose branch is live in an age-skipped
worktree. This test is what surfaced the wrong-worktree bug in prune.
- `merge`: merging a branch that a `--force` duplicate also holds.

## Not addressed

`wt step prune`'s summary counts candidates rather than outcomes, so a
retained branch still reports `✓ Pruned 1 branch`. The per-item line
above it already says the branch was retained. Fixing the count means
threading removal outcomes back through prune's accounting, which is a
separate change.

> _This was written by Claude Code on behalf of Maximilian Roos_

---------

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
Co-authored-by: Maximilian Roos <m@maxroos.com>
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.

handling duplicate checked-out branches

2 participants