fix(picker): identify worktree rows by path, not branch name#2866
Conversation
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
left a comment
There was a problem hiding this comment.
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(whenas_any().downcast_ref::<WorktreeSkimItem>()returnsNone).
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.
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
left a comment
There was a problem hiding this comment.
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 twomatchblocks intest_picker_removal_target_from_signal. They're unreachable when theSome(...)arm matches. - L1045-L1047 —
FakeSkimItem::text().SkimItemrequirestext(), but the test path only exercisesoutput(), 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.
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>
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.
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 ingit worktree list, and could prepare removal for the wrong one. Theitems.retaincall also dropped every row sharing that text.Worktree-backed rows now return a unique
worktree-path:<path>token fromoutput();PickerRemovalTarget::from_signaldecodes it back into an exactRemoveTarget::Path/Branch, replacing the branch-match-then-first-detached fallback. Apicker_item_identifierhelper resolves the switch identifier off the same token (the switch path can no longer read it raw, sinceoutput()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.