refactor(picker): route alt-r removal through handle_remove_output#2746
Merged
Conversation
The picker's `do_removal` was a parallel reimplementation of the `wt remove` teardown pipeline — pre-remove hook → git worktree removal → post-remove (and post-switch) hooks — kept in lockstep with `output::handle_remove_output` by hand (#2736 had to fix a hook-config bug in both). It now routes through `handle_remove_output` in a new `silent` mode: the same git removal, but with no progress/success messages, no trash-cleanup spinner, and no `cd` directive, since the picker runs it from a background thread while skim owns the terminal. The duplicated hook construction (`PostRemoveContext`, the announcer, the `removed_project_config` snapshot plumbing) is gone, and the picker now also registers `post-switch` hooks when alt-r removes the current worktree — matching `wt remove`. The old `do_removal` ran project `pre-remove` / `post-remove` / `post-switch` hooks unconditionally — the one removal/switch path that bypassed the approval gate. The picker can't show an approval prompt mid-render, so it now consults `Approvals` read-only and runs those hooks only when every one is already approved (e.g. from a prior `wt remove` / `wt merge`), skipping them otherwise. `CLAUDE.md` gains a "Project Commands Run Only After Approval" section stating the rule. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
worktrunk-bot
previously approved these changes
May 12, 2026
worktrunk-bot
left a comment
Collaborator
There was a problem hiding this comment.
codecov/patch is failing — 4 added lines uncovered out of ~110. CLAUDE.md requires explicit maintainer approval before merging with failing codecov/patch, so dismissing my approval; reapply or override as appropriate.
The misses:
| File | Line | Code | Note |
|---|---|---|---|
src/output/handlers.rs |
1500 | )?; |
Error-propagation ? on the new remove_worktree_with_cleanup call in remove_removed_worktree_silently. Happy path is covered by the new test_do_removal_* tests; only the failure branch is uncovered. |
src/commands/worktree/finish.rs |
172 | )?; |
Reformat artifact — the single-line handle_remove_output(..., announcer)? call was split across multiple lines to add the new silent parameter, and Codecov flagged the closing )?; as a new uncovered line. The error branch was likely also unmeasured before. |
src/commands/picker/mod.rs |
314 | let approvals = Arc::clone(&self.approvals); |
Inside invoke()'s alt-r spawn block — acknowledged in the PR description as not unit-testable (Full invoke() tests require interactive skim). |
src/commands/picker/mod.rs |
318 | if let Err(e) = Self::do_removal(...) |
Same invoke() block. |
The two invoke() lines are the ones the PR description already flagged. The other two are ? error-propagation closings whose happy paths are covered but whose Err branches would need failure injection (a forced remove_worktree_with_cleanup failure / a failed handle_remove_output from finish_after_merge) — straightforward but a real test addition rather than a one-line fix.
codecov/patch failed — see comment for analysis; reapply once addressed or override per CLAUDE.md policy.
`codecov/patch` flagged the closing `)?;` of two multi-line calls as
new uncovered lines (their happy paths are covered; only the `?`-error
region on a standalone line counts as a miss):
- `remove_removed_worktree_silently` — extract `RemoveOptions` into a
local so `remove_worktree_with_cleanup(repo, &snapshot, path, options)?`
fits on one line.
- `finish_after_merge` — `import` `handle_remove_output` (and the two
`*_hook_display_path` helpers already used with the `crate::output::`
prefix) so the call fits on one line again, undoing the multi-line
split rustfmt did when the `silent` parameter was added.
The two remaining misses are in `PickerCollector::invoke`'s alt-r spawn
block, which the codebase already documents as not unit-testable
("Full invoke() tests require interactive skim").
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
worktrunk-bot
approved these changes
May 12, 2026
max-sixty
added a commit
that referenced
this pull request
May 12, 2026
…orktree (#2748) When a worktree is removed, the `post-switch` hook runs in the *destination* worktree — where the user lands — which `prepare_worktree_removal` records as `RemoveResult.main_path` (the primary worktree, except cwd when the primary worktree is itself the removal target). But `wt remove`'s and `wt step prune`'s approval gates collected the `post-switch` commands to prompt for from `repo.home_path()` instead. They agree in the common case (`main_path == home_path()`), so nobody noticed — but in a bare repo, removing the default-branch worktree from a *different* worktree, `main_path == current_path` while the gate read `home_path()`, so the gate approved the primary's `post-switch` while the executor ran cwd's — an unapproved project command could run. (`wt merge` and the picker's `removal_hooks_approved`, added in #2746, already passed the destination correctly.) ## What changed `collect_remove_hook_commands` now takes `(removed_worktree_paths: &[&Path], destination_paths: &[&Path])` instead of `(primary_repo: &Repository, removed_worktree_paths)` — it collects `pre-remove` / `post-remove` from each removed worktree and `post-switch` from each destination (path-deduped, since the common case is the same primary repeated). New `RemoveResult::destination_path() -> Option<&Path>` returns `main_path` for `RemovedWorktree`, `None` for `BranchOnly` — a sibling of the existing `removed_worktree_path()`. Callers updated: - `wt remove` (single): `approve_remove(result.removed_worktree_path().as_slice(), result.destination_path().as_slice(), …)`. (Multi: the same projections over the plan list.) - `wt merge`: passes `&[destination_path]` — matches `finish_after_merge`'s `RemoveResult { main_path: destination_path, … }`. - `wt step prune`: passes `&[home_path()]` — a prune candidate is never the primary worktree (`gather_check_items` filters non-linked worktrees and the default-branch worktree), so each candidate's removal destination is always `home_path()`. - The picker's `removal_hooks_approved` gains a `main_path` param and passes `&[main_path]`. The `commands::hooks` "Which `.config/wt.toml` a hook reads" table row for `post-switch after a removal` now points at `RemoveResult::destination_path` and notes the bare-repo cwd case. ## Testing Behavior-neutral in every case the test suite exercises — `post-switch` only fires when `changed_directory` (the removed worktree was cwd), and in all of those sub-cases `main_path` resolves to `home_path()` anyway, so the old and new gates collect the same commands. The change closes the latent decoupling. No new test added: the only observable difference is the bare-repo-remove-the-primary-from-elsewhere corner, and a dedicated integration test for it would be disproportionate; the new code paths (`destination_path()` on both variants, the destination loop in `collect_remove_hook_commands`, the `main.rs` plumbing) are covered by the existing `wt remove` / `wt merge` / `wt step prune` integration tests and the `test_do_removal_*` picker unit tests. `cargo run -- hook pre-merge --yes` is green (3693 tests, lints, fmt, doctests). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Merged
max-sixty
added a commit
that referenced
this pull request
May 13, 2026
Release v0.50.0. Highlights: - Experimental Azure DevOps support (#1256, thanks @mikeyroush; fixes #1144 from @dlecan) — `wt switch pr:<N>`, `wt list --full`, and `wt config show --full` recognize Azure DevOps via the `az` CLI. - Experimental Gitea CI-status detection (#2702) on top of the Gitea `pr:` shortcut (#1320, thanks @SjB). - Hooks now resolve `.config/wt.toml` from the worktree they act on — the primary-worktree fallback is gone, and `post-remove` reads the removed worktree's config (snapshotted before removal). Approval prompts collect hook commands from the same worktree. Breaking change for setups that relied on the primary-worktree fallback; the changelog entry has the recovery action. (#2690, #2703, #2714, #2717, #2701, #2708, #2727, #2736, #2748) - The `wt switch` picker's `alt-r` removal no longer runs unapproved project hooks (#2746) — the picker's removal path is now routed through `handle_remove_output` and consults the existing approval state read-only. - `wt config alias show` with no name lists every alias's full definition (#2684, #2691); `wt --help` switches to a compact aliases pointer (#2688). - `wt list --branches` warm-run perf: SHA-keyed cache for `main↕` and `Remote⇅` ahead/behind counts; shared push-remote URL and local-branch scan (#2704, #2718, #2673). - Claude Code plugin ships the `wt-switch-create` skill (#2737, thanks @onetom for #2631). See `CHANGELOG.md` for the full list (8 Improved, 5 Fixed, 5 Internal). semver-checks reports breaking library-API changes (new enum variants without `#[non_exhaustive]`, removed `Branch::github_push_url`, new trait method on `RemoteRefProvider`), which mandates at minimum a minor bump pre-1.0.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The picker's
alt-rremoval (PickerCollector::do_removal) was a parallel reimplementation of thewt removeteardown pipeline —pre-removehook → git worktree removal →post-remove(andpost-switch) hooks — that had to be kept in lockstep withoutput::handle_remove_outputby hand. #2736 had to fix the same hook-config bug in both. This routesdo_removalthroughhandle_remove_output, so the duplication is gone, and gates the picker's hooks on approval (which the old path didn't do).What changed
handle_remove_outputgains asilentflag (src/output/handlers.rs). When set, aRemovedWorktreeresult is removed with no progress/success messages, no trash-cleanup spinner, and nocddirective — justpre-remove, the synchronous git worktree removal (remove_removed_worktree_silently), andpost-remove/post-switchhook registration. Required because the picker runsdo_removalfrom a background thread spawned off skim's event loop, so anywt-generated stderr output would corrupt skim's frame.silentis the only new knob; it threads through the fivehandle_remove_outputcall sites asfalseeverywhere except the picker.do_removalis now a thin call intohandle_remove_output(src/commands/picker/mod.rs). TheRemovedWorktreearm drops its hand-rolledpre-remove(CommandContext+execute_hook) / git removal /post-remove(PostRemoveContext+HookAnnouncer::register_with_project_config) code — net ~80 lines, plus theremoved_project_configsnapshot plumbing #2736 added now lives in one place. TheBranchOnlyarm is unchanged (it's small andhandle_remove_output'sBranchOnlypath prints things the picker can't have).The picker's hooks now run only when already approved. The old
do_removalran projectpre-remove/post-remove/post-switchhooks unconditionally — it was the one removal/switch path that bypassed the approval gate, so a freshly-cloned repo'spost-switchhook would fire onalt-r. The picker can't show an approval prompt mid-render, so it now consultsApprovalsread-only (removal_hooks_approved— the samecollect_remove_hook_commandssetwt removewould prompt for, checked againstApprovals::is_command_approved) and passes the result asverify; when it'sfalse,execute_pre_remove_hooks_if_neededandspawn_hooks_after_removeearly-return.CLAUDE.mdgains a "Project Commands Run Only After Approval" section stating the general rule (src/commands/command_approval.rsis the gate; never build a path that runs project commands without it; a context that can't prompt skips the unapproved ones).One behavior delta beyond consolidation: removing the current worktree via
alt-rnow also registerspost-switchhooks for the home worktree (if approved), matchingwt remove/wt merge/wt step prune. The olddo_removalregistered onlypost-remove.Testing
cargo run -- hook pre-merge --yesis green (3690 tests). Thetest_do_removal_*unit tests cover the silent removal path (worktree + branch + detached), and a newtest_do_removal_skips_unapproved_pre_remove_hookcovers the approval gating (removal_hooks_approvedis fully covered). Not covered: the two lines ininvoke()'salt-rspawn block that hand theApprovalssnapshot to the removal thread —invoke()is already documented as not unit-testable ("Full invoke() tests require interactive skim"), and the rest of its body is uncovered too; the approval-check logic itself is. No end-to-end test exercises "hook present and approved → runs" through the picker (would need seedingApprovalsagainst the test repo's path) — the unit test asserts the logic, just not that exact integration.🤖 Generated with Claude Code