Skip to content

hooks: combine background hook announces per command - #2457

Merged
max-sixty merged 4 commits into
mainfrom
lines
Apr 29, 2026
Merged

max-sixty merged 4 commits into
mainfrom
lines

Conversation

@max-sixty

@max-sixty max-sixty commented Apr 29, 2026 •

Copy link
Copy Markdown
Owner

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 single wt command produces a single status line for its background hooks.

# before
◎ Running post-remove: user:cleanup; post-switch: user:notify
◎ Running post-merge: user:sync

# after
◎ Running post-remove: user:cleanup; post-switch: user:notify; post-merge: user:sync

Approach

HookAnnouncer (in src/commands/hooks.rs) registers pipelines from multiple phases and flushes once. It stores owned PendingPipeline data so registration sites at different points in the command's lifecycle can pass short-lived CommandContexts without lifetime gymnastics; flush() rebuilds contexts from owned data and delegates to the existing combined formatter.

A Drop impl 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, explicit flush() runs first so Drop sees empty pending and is a no-op.

Wiring

  • handle_remove_output gains an announcer: Option<&mut HookAnnouncer<'_>> parameter, threaded through the internal handlers (handle_named_removed_worktree_*, handle_detached_removed_worktree_output, spawn_hooks_after_remove). When Some, pipelines register on the announcer instead of self-spawning.
  • wt merge constructs one announcer, passes it through the remove block, registers post-merge after, calls flush() once before json output.
  • Standalone wt remove, prune, and the picker remove path pass None — output unchanged.
  • wt switch --create (already one-line via announce_and_spawn_background_hooks) is unchanged. Migrating it to HookAnnouncer for consistency is a follow-up.

Testing

  • test_merge_combines_post_remove_post_switch_post_merge snapshots the combined announce line.
  • test_merge_drops_pending_hooks_when_post_merge_fails covers the Drop fallback: post-merge template prep errors after post-remove + post-switch register, and the hooks still fire via Drop.

codecov/patch reports 96.40% vs 96.73% target. The five missed lines are the inner eprintln! arm of the Drop fallback (fires only when flush() itself errors during Drop — requires spawn_hook_pipeline_quiet to fail) and a few trailing )?; lines that llvm-cov misattributes. Defensive logging path, not meaningfully testable; merging with the gap.

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

@worktrunk-bot
worktrunk-bot dismissed their stale review April 29, 2026 15:59

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

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.

Comment thread tests/integration_tests/user_hooks.rs
Comment thread tests/integration_tests/user_hooks.rs
@worktrunk-bot
worktrunk-bot dismissed their stale review April 29, 2026 16:17

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

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.)

Comment thread tests/integration_tests/user_hooks.rs Outdated
Comment thread tests/integration_tests/user_hooks.rs Outdated
@worktrunk-bot
worktrunk-bot dismissed their stale review April 29, 2026 16:34

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>
worktrunk-bot
worktrunk-bot previously approved these changes Apr 29, 2026
@worktrunk-bot
worktrunk-bot dismissed their stale review April 29, 2026 16:51

codecov/patch failing — see follow-up comment

@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.

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.

@max-sixty
max-sixty merged commit 091a3d6 into main Apr 29, 2026
27 of 28 checks passed
@max-sixty
max-sixty deleted the lines branch April 29, 2026 17:39
max-sixty added a commit that referenced this pull request Apr 29, 2026
…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>
max-sixty added a commit that referenced this pull request Apr 30, 2026
…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>
max-sixty added a commit that referenced this pull request Sep 7, 2026
… 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>
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