Skip to content

fix(hooks): approve post-switch against the removal's destination worktree#2748

Merged
max-sixty merged 1 commit into
mainfrom
picker-remove-via-handlers
May 12, 2026
Merged

fix(hooks): approve post-switch against the removal's destination worktree#2748
max-sixty merged 1 commit into
mainfrom
picker-remove-via-handlers

Conversation

@max-sixty

Copy link
Copy Markdown
Owner

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

…orktree

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()`), but in a bare repo, removing the default-branch worktree from a
different worktree, the gate approved the primary's `post-switch` while the
executor ran cwd's — so an unapproved project command could run. (`wt merge`
and the picker's `removal_hooks_approved` already passed the destination.)

`collect_remove_hook_commands` now takes `(removed_worktree_paths,
destination_paths)` instead of `(primary_repo, removed_worktree_paths)`:
`pre-remove` / `post-remove` from each removed worktree, `post-switch` from
each (path-deduped) destination — the same `.config/wt.toml` the executor
reads. New `RemoveResult::destination_path() -> Option<&Path>` (sibling of
`removed_worktree_path()`). `wt remove` passes each plan's `destination_path()`;
`wt merge` passes the merge destination; `wt step prune` passes `home_path()`
(a prune candidate is never the primary worktree, so that's always its
destination).

Behavior-neutral in every case the suite exercises — `post-switch` only fires
when `changed_directory`, and in all of those sub-cases `main_path` resolves to
`home_path()` anyway; the change closes the latent gate/executor decoupling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@max-sixty max-sixty merged commit caa47cf into main May 12, 2026
34 checks passed
@max-sixty max-sixty deleted the picker-remove-via-handlers branch May 12, 2026 22:13
@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