Repository navigation
Conversation
Today `wt merge` (with removal) emits two or three separate `◎ Running …` lines for post-remove + post-switch + post-merge. Collapse them into one combined announce by introducing a `HookAnnouncer` that registers pipelines across phases and flushes once. The announcer holds owned `PendingPipeline` data so registration sites can have short-lived contexts. `flush()` rebuilds CommandContexts and delegates to the existing combined formatter. A `Drop` impl flushes pending hooks on early-return errors, preserving prior fire-and-forget behavior when later phases fail to register. `handle_remove_output` gains an `announcer: Option<&mut HookAnnouncer<'_>>` parameter. `wt merge` passes one through the remove handler and registers post-merge after; standalone `wt remove` and prune pass `None` and continue to self-announce per-worktree. Co-Authored-By: Claude <noreply@anthropic.com>
worktrunk-bot
left a comment
There was a problem hiding this comment.
codecov/patch failed
The patch fails coverage at 91.89% on src/commands/hooks.rs — the Drop impl's error-recovery path is uncovered:
L377-383: } if let Err(err) = self.flush() { eprintln!(warning_message(...))
This is reachable when an earlier phase registers pipelines on the announcer and a later phase errors before flush() runs (e.g., post-remove succeeds, then announcer.register(HookType::PostMerge, …) fails because of a malformed template, dropping the announcer with non-empty pending). It's the path the docstring on Drop calls out as "preserves prior fire-and-forget behavior on early-return errors" — codecov correctly flags that there's no test for it.
A test could mirror the new test_merge_combines_post_remove_post_switch_post_merge but with a post-merge config that fails template rendering (e.g. an undefined variable like {{ does_not_exist }}). The post-remove pipelines would be queued, post-merge prep would error, and the Drop path would fire and emit the warning.
A smaller secondary gap: extend() line 312 (continue for empty steps) is unreachable because all callers source from prepare_background_hooks, which already filters out empty groups. Could be dropped, since announce_and_spawn_background_hooks also filters defensively before calling announce_and_spawn_pipelines.
codecov/patch failed — Drop error-recovery path uncovered (see follow-up review)
- Add `test_merge_drops_pending_hooks_when_post_merge_fails`: when post-merge template prep errors after post-remove + post-switch register, the Drop impl flushes pending hooks so they still fire. - Remove the empty-steps `continue` in `HookAnnouncer::extend` — `prepare_background_hooks` already filters empty groups before pushing. Co-Authored-By: Claude <noreply@anthropic.com>
worktrunk-bot
left a comment
There was a problem hiding this comment.
CI is failing on Windows on the new test. I missed this when approving — apologies.
test_merge_drops_pending_hooks_when_post_merge_fails writes the marker paths into TOML basic strings (double-quoted). On Windows the temp path looks like D:\tmp\.tmphrqBat\drop_postremove_marker.txt; in a TOML basic string \t becomes a tab and \. is an invalid escape, so config load fails before HookAnnouncer is even constructed. The merge exits non-success (which the test still happens to assert) but neither hook ever registers, so the post-remove marker stays empty and wait_for_file_content times out at 60s. Linux/macOS pass because their paths use forward slashes.
Fix: switch the two embedded-path lines to TOML literal strings (single-quoted), which don't interpret escapes. The format-level {{{{ does_not_exist }}}} still resolves to a literal {{ does_not_exist }} in the rendered TOML, which is what we want for the template-prep failure.
Happy to push the fix if you'd like.
CI failed on Windows — see follow-up review for analysis and inline fix suggestions
Windows temp paths like `D:\tmp\…` contain `\t` which TOML basic strings interpret as a tab escape — the marker file path got mangled and the test hung waiting for it. Switch to single-quoted literal strings so backslashes are taken verbatim. Co-Authored-By: Claude <noreply@anthropic.com>
worktrunk-bot
left a comment
There was a problem hiding this comment.
CI is still red on Windows on the same test, despite the literal-string fix. Apologies — I missed the second half of the path-handling problem when I approved.
The literal-string change made the TOML parse clean, but the rendered shell command still embeds the raw Windows path with backslashes (D:\tmp\.tmpXXXX\drop_postremove_marker.txt). On Windows wt shells via Git Bash (Cmd::shell per src/shell_exec.rs:687-693), and in unquoted bash \. and \t are eaten as escape-of-next-char, mangling the path. So echo writes to a different filename and wait_for_file_content times out at 60s on the path the test is watching.
The project convention for embedding paths into shell commands in tests is path_slash::PathExt::to_slash_lossy() (and CLAUDE.md mandates it under "Use Existing Dependencies"). The pattern is already used in tests/integration_tests/remove.rs (e.g. marker_file.to_slash_lossy() at line 1603). Two .display() calls need swapping; you'll also need a use path_slash::PathExt as _;.
Suggestions inline. Happy to push the fix if you'd like.
(Codecov is also flagging 96.40% vs 96.73% target — five missed lines that are mostly trailing )?; lines plus the eprintln! body inside the Drop fallback. The Drop path itself is now exercised; the remaining miss is just the inner format! call when flush() itself errors. Probably not worth chasing.)
CI failed on Windows — path with backslashes still mangled by Git Bash; see follow-up review
Even with TOML literal strings, the rendered shell command embeds raw backslashes that Windows Git Bash chews as escapes (`\t`, `\.`). Use `path_slash::PathExt::to_slash_lossy()` so the marker paths survive the shell layer — same pattern as the existing tests in remove.rs. Co-Authored-By: Claude <noreply@anthropic.com>
codecov/patch failing — see follow-up comment
worktrunk-bot
left a comment
There was a problem hiding this comment.
CI is green across Linux/macOS/Windows — the to_slash_lossy() fix worked.
codecov/patch is still red at 96.40% vs 96.73% target. Same five misses I flagged on the previous commit: the HookAnnouncer::new/extend/register/flush body lines plus the eprintln! arm inside Drop (when flush() itself errors). The Drop path is now exercised by test_merge_drops_pending_hooks_when_post_merge_fails; the inner-error miss is what remains. Per CLAUDE.md the failure needs your sign-off before merging — flagging again so the merge decision is explicit.
…es (#2472) ## Summary - Replaces the `CommandOrigin` enum in `command_executor.rs` with explicit fields on `ForegroundStep` (`AnnouncePolicy`, `pipe_stdin: bool`, `error_wrapper: ErrorWrapper`) and per-command `label` / `log_label` set at prep time. `handle_command_error` collapses from a 4-variant case-split to a single branch (`FailFast` calls the closure, `Warn` prints). Resolves the pre-existing TODO at `command_executor.rs:308-316`. - Tightens `hooks.rs` around two pub entries (`run_hooks_foreground`, `run_hooks_background`) plus `pub(crate)` shortcuts (`execute_hook`, `spawn_background_hooks`) that absorb the auto-config-lookup boilerplate. `execute_hook` is the canonical operation-driven path and applies `add_hook_skip_hint` centrally; `wt hook <type>` calls `run_hooks_foreground` directly so failures don't carry the misleading `--no-hooks` reminder. - Adds `HookType::is_pre()` and `FailureStrategy::default_for(hook_type)` so `run_hook`'s pre/post dispatch is one `if`. Adds `approve_or_skip` for the "approve hooks → fall through if declined" pattern (used at 4 sites). - Collapses `run_post_hook`'s filter/no-filter dispatch to a single `is_empty()` branch. Eliminates `prepare_background_hooks` duplication via `into_source_groups` over the flat `prepare_sourced_steps` result. Tightens `spawn_hook_pipeline_quiet`'s base-context extraction (caller invariants guarantee non-empty steps). - Integrates with `HookAnnouncer` (#2457): `register` calls `prepare_background_pipelines`, `flush` calls `run_hooks_background`. Net: 500 insertions, 593 deletions across 16 files. 3391 tests pass; lints and snapshots clean. New regression test `test_standalone_hook_failure_omits_skip_hint` locks in the `wt hook <type>` skip-hint behaviour. ## Test plan - [x] \`cargo run -- hook pre-merge --yes\` (3391 tests, lints, snapshots clean) - [x] \`cargo insta test --accept -- --test integration "test_help"\` (no snapshot drift) - [x] Regression test verified by re-introducing the skip-hint over-application — test fails; reverting fixes it. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude <noreply@anthropic.com>
…cer (#2482) Extends the `HookAnnouncer` coordinator (introduced in #2457, refined in #2477) to more hot paths so multi-phase background hooks share a single `◎ Running …` line. **Migrated sites:** - `wt switch --create` — post-switch + post-start (`spawn_switch_background_hooks` in `handle_switch.rs`). - `wt merge` — auto-commit (when dirty) + post-remove + post-switch + post-merge. - `wt merge --squash` — post-commit (from squash) + post-remove + post-switch + post-merge. **Plumbing:** `CommitOptions::commit` and `handle_squash` gained an optional `&mut HookAnnouncer<'_>` parameter. `handle_merge` constructs one announcer early and threads it through the commit, squash, and remove paths; `flush()` runs once at the end. Standalone `wt commit` / `wt step squash` pass `None` and self-announce as before. **No new abstractions** — the `Some` arm calls `extend`, the `None` arm calls `run_hooks_background`, mirroring the existing precedent in `output/handlers.rs::spawn_hooks_after_remove`. As a follow-on, `spawn_background_hooks` (whose only caller after migration was `picker::do_removal`, gated `#[cfg(unix)]`) is dropped and the picker's call site is inlined to match — fixing a Windows dead-code build error and unifying all four single-shot post-hook sites on the same shape. **Test coverage:** new `test_merge_squash_combines_post_commit_post_remove_post_switch_post_merge` and `test_merge_auto_commit_combines_post_commit_post_remove_post_switch_post_merge` snapshot the combined four-phase announce line for both squash and non-squash auto-commit paths. Existing `test_merge_combines_post_remove_post_switch_post_merge`, `test_merge_drops_pending_hooks_when_post_merge_fails`, and `test_switch_combined_post_switch_and_post_start_hooks` remain green. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… its worktree (#4026) `wt merge` prints `◎ Running post-commit: …` and then spawns that pipeline into a path the removal has already emptied, so a `post-commit` hook can't be relied on to do what it says. Nothing reports this, and two doc lines promised the behavior that doesn't happen. `post-commit` is the only hook in the merge's background batch anchored on the feature worktree — `post-merge`, `post-switch` and `post-remove` all anchor on the destination. `HookAnnouncer` flushes every pending pipeline once at the end of the command, which is after `finish_after_merge` has removed that worktree. What the runner then finds depends on where the worktree lived, because the fast removal path renames it into `.git/wt/trash/` and leaves an empty placeholder at the original path (`changed_directory: true`, so the shell's `$PWD` stays valid), torn down a second later by the detached `sleep 1 && rmdir`. `run_pipeline` calls `Repository::at` on that placeholder, and git discovery walks up from it: - **Worktree outside the repo** (the default `../{{ repo }}.{{ branch }}`): no git ancestor, so the runner logs `failed to open repository for pipeline` to `.git/wt/logs/<branch>/<source>/post-commit/runner.log`, which nothing reads back. The hook doesn't run. - **Worktree nested inside the repo** (`worktree-path = "{{ repo_path }}/.worktrees/{{ branch | sanitize }}"`, one of the config page's own examples): discovery resolves to the **primary** worktree, `Repository::at` succeeds, and the steps do run — with their cwd set to the placeholder `rmdir` unlinks a moment later, and any `git` inside them resolving against the primary worktree. It's a regression, not a hook that never worked: #1679 added `post-commit` and it fired for five weeks, until #2457 collapsed the merge's two or three `◎ Running …` lines into one. That was a cosmetic change, and deferring the spawn to a single end-of-command flush moved it past the removal. Documented rather than fixed, deliberately. The commit `post-commit` would fire on is squashed and rebased before the merge lands; `pre-remove` already covers work that must finish in the feature worktree, blocking removal until it does; and spawning at commit time only narrows the window, since `git worktree remove` succeeds against a live cwd. `post-commit` still runs properly wherever the worktree survives — `--no-remove`, merging on the target branch, merging from the primary worktree — and on `wt step commit` / `wt step squash`. So this adds the explanation at the `HookAnnouncer` construction in `handle_merge`, and corrects what the docs claimed: - The merge pipeline's step 1 said "Post-commit hooks run in background" flatly. - The hook page said "During `wt merge`, hooks run in this order: pre-commit → post-commit → pre-merge → pre-remove → post-remove + post-merge" — wrong twice, since the `post-*` hooks don't run in sequence either (#4020 corrected that one row above). It now gives the blocking order, says the `post-*` hooks start together in the worktree each is anchored on, and points at `pre-remove` or `--no-remove`. - `test_merge_squash_combines_post_commit_…` said the merge "fires post-commit"; it announces it. Both it and its auto-commit sibling now say the announce line is all they pin. No behavior change. <details> <summary>Reproduction</summary> A scratch repo with a `post-commit` hook, a feature worktree with one commit and one uncommitted file, then `wt merge main --yes`. Every case prints `◎ Running post-commit: mark (user)`: | Layout | Flags | Outcome | |--------|-------|---------| | external (`../{{ repo }}.{{ branch }}`) | default (squash) | hook doesn't run | | external | `--no-squash` | hook doesn't run | | external | `--no-remove` | runs correctly | | nested (`{{ repo_path }}/.worktrees/{{ branch }}`) | default (squash) | runs in the doomed placeholder | The external removing cases leave this in `.git/wt/logs/feat/user/post-commit/runner.log`: ``` ✗ failed to open repository for pipeline fatal: not a git repository (or any of the parent directories): .git ``` The nested case leaves an empty `runner.log` and the hook's own output shows where it landed — `pwd` is the removed worktree's path, while `git rev-parse --show-toplevel` from inside it answers with the primary worktree: ``` cwd=/…/repo/.worktrees/feat toplevel=/private/…/repo ``` The commit itself is made in every case — `Changes to dirty.txt` reaches `main`. </details> > _This was written by Claude Code on behalf of max-sixty_ --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Today
wt merge(with removal) emits two or three separate◎ Running …lines — one for post-remove + post-switch (already batched), then another for post-merge. Collapse them into one combined announce so a singlewtcommand produces a single status line for its background hooks.Approach
HookAnnouncer(insrc/commands/hooks.rs) registers pipelines from multiple phases and flushes once. It stores ownedPendingPipelinedata so registration sites at different points in the command's lifecycle can pass short-livedCommandContexts without lifetime gymnastics;flush()rebuilds contexts from owned data and delegates to the existing combined formatter.A
Dropimpl flushes pending hooks on early-return errors. Without it, a later registration failure would silently swallow earlier-registered pipelines — a regression from the prior fire-and-forget pattern. On the success path, explicitflush()runs first soDropsees empty pending and is a no-op.Wiring
handle_remove_outputgains anannouncer: Option<&mut HookAnnouncer<'_>>parameter, threaded through the internal handlers (handle_named_removed_worktree_*,handle_detached_removed_worktree_output,spawn_hooks_after_remove). WhenSome, pipelines register on the announcer instead of self-spawning.wt mergeconstructs one announcer, passes it through the remove block, registers post-merge after, callsflush()once before json output.wt remove, prune, and the picker remove path passNone— output unchanged.wt switch --create(already one-line viaannounce_and_spawn_background_hooks) is unchanged. Migrating it toHookAnnouncerfor consistency is a follow-up.Testing
test_merge_combines_post_remove_post_switch_post_mergesnapshots the combined announce line.test_merge_drops_pending_hooks_when_post_merge_failscovers the Drop fallback: post-merge template prep errors after post-remove + post-switch register, and the hooks still fire via Drop.codecov/patchreports 96.40% vs 96.73% target. The five missed lines are the innereprintln!arm of the Drop fallback (fires only whenflush()itself errors during Drop — requiresspawn_hook_pipeline_quietto fail) and a few trailing)?;lines that llvm-cov misattributes. Defensive logging path, not meaningfully testable; merging with the gap.