Skip to content

fix(picker): identify worktree rows by path, not branch name#2866

Merged
max-sixty merged 4 commits into
mainfrom
picker-detached-rebase
May 22, 2026
Merged

fix(picker): identify worktree rows by path, not branch name#2866
max-sixty merged 4 commits into
mainfrom
picker-detached-rebase

Conversation

@max-sixty

Copy link
Copy Markdown
Owner

The interactive picker's alt-r removal signal carried each row's branch name. Detached worktrees all report (detached), so two detached rows produced the same signal — the collector matched by branch, fell back to the first detached worktree in git worktree list, and could prepare removal for the wrong one. The items.retain call also dropped every row sharing that text.

Worktree-backed rows now return a unique worktree-path:<path> token from output(); PickerRemovalTarget::from_signal decodes it back into an exact RemoveTarget::Path / Branch, replacing the branch-match-then-first-detached fallback. A picker_item_identifier helper resolves the switch identifier off the same token (the switch path can no longer read it raw, since output() now returns the token for every worktree row).

Includes a regression test — two detached worktrees, alt-r on the second removes exactly that one — verified to fail without the fix.

This was written by Claude Code on behalf of Maximilian Roos

The picker's alt-r removal signal carried each item's `output()` text,
which was the branch name. Detached worktrees all report `(detached)`
from `ListItem::branch_name()`, so two detached worktrees produced the
same signal. `PickerCollector::prepare_removal` then matched it by
branch, falling back to the *first* detached worktree in
`git worktree list` — alt-r on one detached row could prepare removal
for another. The `items.retain` call also dropped every row sharing
that output text.

- `WorktreeSkimItem::output()` now returns `worktree-path:<path>` for
  any item backed by a worktree (paths are unique); branch-only items
  still return the branch name.
- `PickerRemovalTarget::from_signal` parses that token back into an
  exact `RemoveTarget::Path` or `RemoveTarget::Branch`, replacing the
  branch-match-then-first-detached fallback in `prepare_removal`.
- `picker_item_identifier` resolves the switch identifier (path for
  detached worktrees, branch otherwise), replacing the ad-hoc inline
  `downcast_ref` + `output()` read on the switch path — `output()` now
  returns the `worktree-path:` token for every worktree row, so the
  switch identifier can no longer be read straight off it.

Adds `test_invoke_removes_selected_detached_worktree_by_path_token`:
with two detached worktrees, alt-r on the second removes exactly that
worktree and drops only its row from the list. Drops
`test_prepare_removal_refreshes_stale_detached_worktree_cache`, which
guarded a wrong-resolution failure mode of the deleted branch-name
fallback that exact-path resolution makes structurally impossible.

clawpatch-finding: fnd_sig-feat-custom-remove-da9ddc8e8_034e3e9a38

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
worktrunk-bot
worktrunk-bot previously approved these changes May 21, 2026

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

codecov/patch failed — 7 added lines uncovered, all in src/commands/picker/mod.rs (94.53%):

  • L159, L163 — early-return guards in PickerRemovalTarget::from_signal (empty trimmed signal; worktree-path: prefix with empty path after the strip).
  • L182, L187-L190 — the fallback path in picker_item_identifier (when as_any().downcast_ref::<WorktreeSkimItem>() returns None).

The from_signal guards are trivially testable with a small unit test:

#[test]
fn test_picker_removal_target_from_signal_rejects_empty() {
    assert!(PickerRemovalTarget::from_signal("").is_none());
    assert!(PickerRemovalTarget::from_signal("   ").is_none());
    assert!(PickerRemovalTarget::from_signal("worktree-path:").is_none());
}

The picker_item_identifier fallback is harder — it's only reachable when downcast_ref fails (the skim 0.20 cross-thread TypeId bug noted in the inline comment). One option is a test that passes HeaderSkimItem (which downcast won't match), exercising L182 and L187-L190 minus the WorktreePath arm. Another option is to drop the fallback if the maintainer is confident picker_item_identifier's callsite always runs on the main thread where the downcast succeeds — the original code's detached-worktree special case relied on the same downcast working there.

Happy to push a commit with the from_signal test plus either a HeaderSkimItem test for the fallback or a fallback removal, whichever you prefer. Dismissing the approval per CLAUDE.md's codecov/patch gate.

@worktrunk-bot worktrunk-bot dismissed their stale review May 21, 2026 19:57

codecov/patch failed — 7 uncovered lines in picker/mod.rs

Add unit tests for the two uncovered patch regions flagged by codecov:

- `PickerRemovalTarget::from_signal` early-return guards (empty/blank
  signal, bare `worktree-path:` prefix with no path).
- `picker_item_identifier`'s `output()`-token fallback, exercised via a
  test-local `FakeSkimItem` that stands in for the cross-thread
  `downcast_ref` failure (skim 0.20) the fallback handles in production.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
worktrunk-bot
worktrunk-bot previously approved these changes May 21, 2026

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

codecov/patch failed again — 5 of 28 added lines uncovered on this commit (82.14%), all in the new test code:

  • L1027, L1033 — the _ => panic!(...) arms of the two match blocks in test_picker_removal_target_from_signal. They're unreachable when the Some(...) arm matches.
  • L1045-L1047FakeSkimItem::text(). SkimItem requires text(), but the test path only exercises output(), so the body never runs.

Both gaps are mechanical to close: replace the two match/_ => panic! blocks with assert!(matches!(... if ...)), and call .text() once on the fake item alongside the existing picker_item_identifier assertion. Inline suggestions below. Happy to push the commit if you'd rather not hand-apply.

Dismissing my approval per CLAUDE.md's codecov/patch gate.

Comment thread src/commands/picker/mod.rs Outdated
Comment thread src/commands/picker/mod.rs Outdated
@worktrunk-bot worktrunk-bot dismissed their stale review May 21, 2026 20:55

codecov/patch failed — 5 uncovered lines in the new test code; analysis in follow-up review

Close the codecov gap left by the previous test commit. The two
`match`/`_ => panic!` blocks in `test_picker_removal_target_from_signal`
left their fallback arms uncovered; `assert!(matches!(...))` expresses
the same check without an unreachable branch. Exercise
`FakeSkimItem::text()` so the trait impl required by `SkimItem` runs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`picker_item_identifier` returned the worktree path only for detached
rows and the branch name for branched ones — an asymmetry with no
reason. `wt switch` resolves any existing worktree by path (`plan_switch`
phase 2b), so a worktree-backed row can always switch by its unique
path; the branch name is needed only for a branch-only row, which has no
worktree.

With the asymmetry gone, the function is a pure decode of the `output()`
token, identical to what the removal path already does — so the
`downcast_ref::<WorktreeSkimItem>()` branch (and its skim-0.20
cross-thread fallback) collapses into a single `from_signal` call. Both
the switch and removal paths now share that one decoder.

Replace the `FakeSkimItem` test stub with real `WorktreeSkimItem`s built
from `ListItem` snapshots; `test_picker_item_identifier` now also covers
the branched-worktree-by-path case.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@max-sixty max-sixty closed this May 21, 2026
@max-sixty max-sixty deleted the picker-detached-rebase branch May 21, 2026 22:44
@max-sixty max-sixty restored the picker-detached-rebase branch May 21, 2026 22:45
@max-sixty max-sixty reopened this May 21, 2026
@max-sixty max-sixty merged commit a103236 into main May 22, 2026
67 checks passed
@max-sixty max-sixty deleted the picker-detached-rebase branch May 22, 2026 02:18
max-sixty added a commit that referenced this pull request May 23, 2026
The interactive picker's alt-r removal signal carried each row's branch name; detached worktrees all report `(detached)`, so two detached rows collided and alt-r could remove the wrong one. Worktree-backed rows now identify by their unique `worktree-path:<path>` token, decoded to an exact removal/switch target; branch-only rows keep the branch name.
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