Skip to content

refactor(picker): route alt-r removal through handle_remove_output#2746

Merged
max-sixty merged 2 commits into
mainfrom
picker-remove-via-handlers
May 12, 2026
Merged

refactor(picker): route alt-r removal through handle_remove_output#2746
max-sixty merged 2 commits into
mainfrom
picker-remove-via-handlers

Conversation

@max-sixty

Copy link
Copy Markdown
Owner

The picker's alt-r removal (PickerCollector::do_removal) was a parallel reimplementation of the wt remove teardown pipeline — pre-remove hook → git worktree removal → post-remove (and post-switch) hooks — that had to be kept in lockstep with output::handle_remove_output by hand. #2736 had to fix the same hook-config bug in both. This routes do_removal through handle_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_output gains a silent flag (src/output/handlers.rs). When set, a RemovedWorktree result is removed with no progress/success messages, no trash-cleanup spinner, and no cd directive — just pre-remove, the synchronous git worktree removal (remove_removed_worktree_silently), and post-remove / post-switch hook registration. Required because the picker runs do_removal from a background thread spawned off skim's event loop, so any wt-generated stderr output would corrupt skim's frame. silent is the only new knob; it threads through the five handle_remove_output call sites as false everywhere except the picker.

do_removal is now a thin call into handle_remove_output (src/commands/picker/mod.rs). The RemovedWorktree arm drops its hand-rolled pre-remove (CommandContext + execute_hook) / git removal / post-remove (PostRemoveContext + HookAnnouncer::register_with_project_config) code — net ~80 lines, plus the removed_project_config snapshot plumbing #2736 added now lives in one place. The BranchOnly arm is unchanged (it's small and handle_remove_output's BranchOnly path prints things the picker can't have).

The picker's hooks now run only when already approved. The old do_removal ran project pre-remove / post-remove / post-switch hooks unconditionally — it was the one removal/switch path that bypassed the approval gate, so a freshly-cloned repo's post-switch hook would fire on alt-r. The picker can't show an approval prompt mid-render, so it now consults Approvals read-only (removal_hooks_approved — the same collect_remove_hook_commands set wt remove would prompt for, checked against Approvals::is_command_approved) and passes the result as verify; when it's false, execute_pre_remove_hooks_if_needed and spawn_hooks_after_remove early-return. CLAUDE.md gains a "Project Commands Run Only After Approval" section stating the general rule (src/commands/command_approval.rs is 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-r now also registers post-switch hooks for the home worktree (if approved), matching wt remove / wt merge / wt step prune. The old do_removal registered only post-remove.

Testing

cargo run -- hook pre-merge --yes is green (3690 tests). The test_do_removal_* unit tests cover the silent removal path (worktree + branch + detached), and a new test_do_removal_skips_unapproved_pre_remove_hook covers the approval gating (removal_hooks_approved is fully covered). Not covered: the two lines in invoke()'s alt-r spawn block that hand the Approvals snapshot 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 seeding Approvals against the test repo's path) — the unit test asserts the logic, just not that exact integration.

🤖 Generated with Claude Code

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
worktrunk-bot previously approved these changes May 12, 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 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.

@worktrunk-bot worktrunk-bot dismissed their stale review May 12, 2026 18:02

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>
@max-sixty max-sixty merged commit 92f5079 into main May 12, 2026
34 checks passed
@max-sixty max-sixty deleted the picker-remove-via-handlers branch May 12, 2026 18:19
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>
@max-sixty max-sixty mentioned this pull request May 13, 2026
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.
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