Skip to content

Unify hook and alias shell execution - #2089

Merged
max-sixty merged 6 commits into
mainfrom
unify-hook-alias
Apr 11, 2026
Merged

max-sixty merged 6 commits into
mainfrom
unify-hook-alias

Conversation

@max-sixty

Copy link
Copy Markdown
Owner

Hooks, aliases, and for-each all run shell commands in a worktree but used two separate execution functions with different capabilities. This collapses them into one (execute_shell_command), then builds on the shared foundation.

Phase 1 — run_command_streaming and CommandError deleted. All three consumers call execute_shell_command (renamed from execute_command_in_worktree) with a new directive_file parameter. Cmd gains a .directive_file() builder method that re-adds the env var after the security scrub. Aliases and for-each gain signal forwarding and ANSI reset.

Phase 2 — Aliases iterate cmd_config.steps() instead of flattening via commands(). HookStep::Concurrent steps spawn threads via thread::scope. Lazy vars.* expansion supported in pipelines.

Phase 3 — Foreground hooks (pre-*, post-* with --foreground) pass the directive file through to child processes. wt switch --create inside a pre-start hook body now lands the shell in the new worktree. Background hooks continue to scrub.

Follow-up: alias announcements could show pipeline summary (e.g., Running alias deploy: install; build, lint).

This was written by Claude Code on behalf of @max-sixty

max-sixty and others added 4 commits April 11, 2026 13:09
Replace two separate execution functions (`execute_command_in_worktree` and
`run_command_streaming`) with a single `execute_shell_command`. All three
consumers (hooks, aliases, for-each) now share the same code path.

Aliases and for-each gain signal forwarding (SIGINT/SIGTERM to child process
group) and ANSI reset before command output. The `Cmd` builder gains a
`.directive_file()` method that re-adds the env var after the security scrub,
used by aliases to pass through shell integration directives.

Deletes `CommandError` enum and `run_command_streaming` from for_each.rs.
Call sites switch to `WorktrunkError::ChildProcessExited` downcasting.

Phase 1 of the unify-hook-alias spec (Phase 2: alias pipeline support and
Phase 3: foreground hook directive file are follow-ups).

Co-Authored-By: Claude <noreply@anthropic.com>
Phase 2: Aliases now iterate `cmd_config.steps()` instead of flattening
via `commands()`. Concurrent steps (named-table commands) spawn threads
and run in parallel. Lazy `vars.*` expansion is supported in pipelines.

Phase 3: Foreground hooks (all pre-* hooks; post-* with --foreground)
pass the parent shell's directive file through to child processes, so
inner `wt switch --create` in a hook body lands the shell in the new
worktree. Background hooks continue to scrub the env var.

Completes the unify-hook-alias spec. Removes implementation order
section from spec (all phases done).

Co-Authored-By: Claude <noreply@anthropic.com>
Update the spec diagram to use `execute_shell_command` (the actual
implemented name), remove the proposed `CommandExecOptions` struct
(individual parameters used instead), and fix the announcement section
to reflect that pipeline summary is a follow-up, not yet implemented.

Co-Authored-By: Claude <noreply@anthropic.com>
The architectural knowledge from the spec belongs in the code it
describes. Expanded docstrings on `execute_shell_command` (consumer list,
directive file trust model, ANSI reset rationale) and the `alias` module
(pipeline execution model, lazy vars, trust model).

Co-Authored-By: Claude <noreply@anthropic.com>

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

Nice consolidation — three call sites collapsing into execute_shell_command reads cleanly, and the directive-file pass-through model (scrub by default, opt-in for trusted contexts) is a good shape. A few items worth raising before merge:

codecov/patch is failing at 75.6% (20 missed lines). Per project policy this must be addressed or explicitly waived. The biggest gaps are in src/commands/alias.rs (the new AliasExecCtx::run method, the HookStep::Concurrent branch in run_alias_command, and the lazy template_references_var re-expansion path) and the spawn-failed branch in src/commands/for_each.rs. Concurrent alias execution and the spawn-failed JSON path in particular look genuinely uncovered, not just instrumentation noise.

Phase 3 lacks a regression test. The directive-file pass-through to foreground hooks is the security-relevant half of this PR — it enables wt switch --create inside a pre-start hook to actually land the user in the new worktree. The parallel alias change in #2077 added test_alias_passes_directive_file_to_subprocess; the equivalent for foreground hooks is missing. Without it, a future refactor could silently re-scrub WORKTRUNK_DIRECTIVE_FILE from foreground hooks and no test would catch it. A test that runs a foreground hook whose body writes WORKTRUNK_DIRECTIVE_FILE and asserts the parent file received the line would lock this in.

Cmd::run() silently ignores .directive_file(). Only Cmd::stream() re-adds the env var after the security scrub. A caller that builds a Cmd with .directive_file(path).run() will get a child with WORKTRUNK_DIRECTIVE_FILE unset and no warning. Today nothing in-tree calls .run() after .directive_file(), so this isn't a live bug, but it's a footgun for the next caller. Either mirror the re-add in run(), document the constraint on the builder, or debug_assert! in run() that directive_file.is_none().

Minor observations (non-blocking):

  • src/commands/for_each.rs spawn-failed path now prints the gutter-formatted error before the "Failed in …" header, where previously the header came first. Intentional?
  • HookStep::Concurrent alias execution joins and propagates only the first Err — a panic in any other thread is still surfaced via expect, but a returned Err from a sibling step is dropped. Acceptable for this PR (matches single-step semantics) but worth a comment if we expect users to run failure-prone concurrent steps.
  • for_each JSON error field now reads "exit status: 1" instead of "exit code 1". Snapshot tests are updated, but if anything downstream parses this it will break — flag in the changelog if there is one.

- Add `test_alias_concurrent_steps` — covers `HookStep::Concurrent` branch
  in alias execution (named-table form runs commands concurrently)
- Add `test_foreground_hook_passes_directive_file` — regression test for
  Phase 3; verifies `wt switch --create` inside a foreground hook writes
  shell directives back to the parent
- Add `debug_assert!` in `Cmd::run()` catching `.directive_file().run()`
  misuse (directive_file only applies in `.stream()`)
- Fix for-each spawn-failed print order: header before detail

Co-Authored-By: Claude <noreply@anthropic.com>
@worktrunk-bot

Copy link
Copy Markdown
Collaborator

codecov/patch failure — false positive analysis

codecov/patch failed at 81.32% (target 95.90%). I checked each "uncovered" hot spot against the base commit (d8897382) and the gaps are pre-existing instrumentation holes that this PR's rename makes visible — not new untested code.

Method: queried api.codecov.io/api/v2/.../file_report/<file>?sha=<commit> for both head and base, compared line hit counts at the same lexical positions.

File "Uncovered" lines flagged on patch Same lines on base (d8897382)
src/output/handlers.rs execute_shell_command (L1396–1437) All 0 hits Old name execute_command_in_worktree (L1389–1438): also all 0 hits
src/commands/for_each.rs step_for_each (L74–130) All 0 hits Identical block on base: also all 0 hits
src/commands/alias.rs step_alias execution path (L300–376) All 0 hits except L351–357, 372–373 Identical block at L240–380 on base: also all 0 hits except L317–318

The integration tests test_step_alias_from_project_config, test_step_for_each_*, the new test_alias_concurrent_steps, and test_foreground_hook_passes_directive_file exercise these paths end-to-end via subprocess wt invocations. repo.wt_command() correctly forwards LLVM_PROFILE_FILE to the child (src/testing/mod.rs:409–417), so subprocess coverage is collected — but cobertura reports each of these specific functions as 0-hit. The BASE commit shows the exact same pattern at the exact same lines, which is the canonical signal for the rename-triggered false positive documented in CLAUDE.md › "Renames and moves".

Likely root cause: eprint!/eprintln! macros from worktrunk::styling (re-exports of std::eprint) plus Stdio::from(std::io::stderr()) defeat per-line instrumentation in this code shape. Same pattern recurs in step_for_each and step_alias. Worth investigating separately, but not a blocker for this PR — it's not a regression introduced here.

The two coverage gaps the prior review actually identified are now closed:

  • HookStep::Concurrent branch in run_alias_command → test_alias_concurrent_steps (step_alias.rs:579)
  • Phase 3 directive-file pass-through to foreground hooks → test_foreground_hook_passes_directive_file (user_hooks.rs:2797)

Patch coverage went from 75.6% → 81.32% as a result. The remainder is the rename artifact. Approval stands — flagging this so you can override the codecov gate consciously.

Adds three additive tests on top of 8e9e1ac:

* `test_for_each_json_spawn_failure` — exercises the spawn-failed branch
  of `step_for_each`'s failure handler in JSON mode (`exit_code: null`).
  PATH is pointed at a directory containing only a `git` symlink so wt
  can still run while the child shell spawn fails.
* `test_alias_pipeline_lazy_vars` — covers the lazy `vars.` re-expansion
  path in `AliasExecCtx::run`, where a pipeline-form alias step
  references a var set by an earlier step.
* `test_alias_concurrent_step_failure` — covers error propagation out of
  the `HookStep::Concurrent` join loop, complementing the happy-path
  `test_alias_concurrent_steps`.
@worktrunk-bot

Copy link
Copy Markdown
Collaborator

Quick follow-up to the false-positive analysis above: pushed 1f26c165 with three additive tests that turned out to cover real (not just rename-artifact) gaps:

  • test_for_each_json_spawn_failure — exercises the spawn-failed branch of step_for_each's failure handler in --format=json mode (exit_code: null). Forces the child shell spawn to fail by pointing PATH at a directory containing only a git symlink, so wt itself still runs.
  • test_alias_pipeline_lazy_vars — covers the lazy vars. re-expansion path in AliasExecCtx::run, where a pipeline-form alias step references a var set by an earlier step.
  • test_alias_concurrent_step_failure — covers error propagation out of the HookStep::Concurrent join loop, complementing the happy-path test_alias_concurrent_steps from 8e9e1ac0.

codecov/patch is now passing on the latest commit, so the gate doesn't need a conscious override after all — the rename-artifact analysis stands for the older lines, but these three branches were genuinely uncovered.

@max-sixty
max-sixty merged commit 73b4f83 into main Apr 11, 2026
25 checks passed
@max-sixty
max-sixty deleted the unify-hook-alias branch April 11, 2026 21:29
max-sixty added a commit that referenced this pull request Apr 11, 2026
Extracts two helpers in command_executor.rs to consolidate duplication
left over from PR #2089:

- `expand_shell_template` replaces three lazy-expansion sites (hooks,
  aliases, pipeline runner) and the eager sibling in `expand_commands`,
  so every shell-command template expansion flows through one helper.
- `wait_first_error` drains a sequence of Result items and returns the
  first error, letting alias's `thread::scope` block and the pipeline
  runner's concurrent group share "wait all, return first error"
  semantics over different concurrency primitives.

Also folds `expand_now` and `build_step_context_json` in run_pipeline.rs
into a single `step_context` helper returning `Cow`, eliminating a
per-step double-clone of the context map. The concurrent-group bail
format now matches the serial-step format ("command failed with
{exit}: {label}"), removing a minor inconsistency.

Co-Authored-By: Claude <noreply@anthropic.com>
max-sixty added a commit that referenced this pull request Apr 11, 2026
…2095)

Two small follow-ups to #2089, which unified shell command execution for
hooks and aliases but left some duplication on the template-expansion
and concurrent-wait paths.

## What changed

**`expand_shell_template`** (in `src/commands/command_executor.rs`) —
one helper that builds the `&str` vars map and calls `expand_template`
with `shell_escape=true`. Replaces three lazy-expansion sites
(foreground hooks, background pipeline runner, alias runner) and the
eager sibling inside `expand_commands`, so every shell template
expansion now flows through one helper.

**`wait_first_error`** (same file) — drains a sequence of `Result<(),
E>` items and returns the first error. Alias's `thread::scope` block and
the pipeline runner's concurrent group now share "wait all, return first
error" semantics over different concurrency primitives (in-process
threads vs OS subprocesses). The concurrent-group bail format now
matches the serial-step format (`command failed with {exit}: {label}`),
removing a minor inconsistency — per-command output already lives in
each command's log file.

**`step_context` in `run_pipeline.rs`** — folds `expand_now` and
`build_step_context_json` into one helper returning `Cow<HashMap<String,
String>>`. Unnamed steps borrow the base context; named steps clone once
and insert `hook_name`. Previously, named steps cloned twice (once for
expansion, once for JSON).

## Where to look

- `src/commands/command_executor.rs` — two new helpers
- `src/commands/hooks.rs` — deleted local `expand_lazy_template`
- `src/commands/alias.rs` — concurrent block and `AliasExecCtx::run`
- `src/commands/run_pipeline.rs` — `step_context`,
`run_concurrent_group`, `expand_commands` in main loop

## Testing

`cargo run -- hook pre-merge --yes` is green: 3025/3025 insta-nextest
tests, pre-commit lints, doctests, `cargo doc`. No snapshots changed.

Co-authored-by: Claude <noreply@anthropic.com>
max-sixty added a commit that referenced this pull request Apr 11, 2026
## Summary

Two follow-ups to the recently-merged unify-hook-alias work (#2089):

**Pipeline announcement.** Aliases now print a structured summary of
their pipeline (e.g., `Running alias deploy: install; build, lint`)
instead of the bare `Running alias deploy`. Hook and alias formatters
share a new `format_pipeline_summary_from_names` helper that takes
per-step names plus label closures — hooks pass `source:` prefixes with
unnamed-count fallbacks, aliases pass plain names and skip unnamed (no
natural fallback label like `user`/`project`).

**Concurrent execution stays separate.** I considered unifying
`AliasExecCtx::run` (uses `thread::scope` because
`execute_shell_command` is a blocking streaming call) with
`run_concurrent_group` in `run_pipeline.rs` (uses raw `Child` handles +
log-file redirection). The leaf primitives diverge — one needs threads
because the streaming executor blocks, the other uses processes directly
— and bridging them requires either making the streaming executor
non-blocking (entangled with signal forwarding, ANSI reset, `Cmd`
tracing) or threading background commands that don't need it. A
module-level doc comment in `alias.rs` explains the divergence so the
next person doesn't repeat the analysis.

**Test escape hatch.** Adds `WORKTRUNK_TEST_SERIAL_CONCURRENT=1` honored
by both concurrent paths, mirroring the `RAYON_NUM_THREADS=1` pattern in
`step_prune` tests. Lets snapshot tests use meaningful command output
(e.g., `echo INSTALL/BUILD/LINT`) and still produce a deterministic
ordering.

## Test plan

- [x] 5 unit tests for `format_alias_announcement` (single unnamed,
all-unnamed pipeline, concurrent named, pipeline named, mixed
named/unnamed)
- [x] Integration snapshot test `test_alias_pipeline_announcement`
exercising the alias path
- [x] Two integration tests in `post_start_commands.rs`
(`test_post_start_concurrent_serial_force`,
`test_post_start_concurrent_serial_bails_on_failure`) exercising the
background pipeline runner's serial branch — happy path (deterministic
append ordering) and failure path (bail-on-first, second never runs)
- [x] Existing hook formatter tests still cover the source-prefixed path
via the shared helper
- [x] Full `wt hook pre-merge --yes` (3030 tests, all green)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude <noreply@anthropic.com>
max-sixty pushed a commit that referenced this pull request Apr 29, 2026
)

## Problem

`wt <alias>` writes the alias body's stdout onto wt's stderr instead of
stdout, so `wt my-alias | tr …` (or any other downstream pipe) silently
produces no output. Reported in #2478:

```sh
# config.toml
[aliases]
test-output = "echo 'hello'"

$ wt test-output | tr '[:lower:]' '[:upper:]'
hello   # ← should be HELLO
```

Root cause is in `src/output/handlers.rs::execute_shell_command`, which
`Cmd::shell(...).stdout(Stdio::from(io::stderr()))` for *every*
foreground command. The redirect was originally a hook-only behavior
added for deterministic ordering with wt's own stderr "Running …"
messages; PR #2089 unified hooks, aliases, and `for-each` onto this
shared executor and inherited the redirect uniformly without
distinguishing aliases.

## Solution

Thread a `redirect_stdout_to_stderr` bool through `ForegroundStep` and
`execute_shell_command`. Hooks (`src/commands/hooks.rs`) and `wt step
for-each` (`src/commands/for_each.rs`) keep the merge (`true`) — their
output is decoration around wt's stderr messages, and merging keeps
ordering deterministic. Aliases (`src/commands/alias.rs`) pass through
(`false`) — `wt <alias>` is a user-defined command and its stdout must
remain pipeable.

Concurrent alias steps still write through `output/concurrent.rs`'s
prefixed-line stderr consumer — the gutter rendering (`build │ BUILD`)
inherently belongs on stderr, so that path is unchanged.

## Testing

- Added `test_alias_body_writes_to_stdout` in
`tests/integration_tests/step_alias.rs`. It runs an alias whose body is
`echo hello` and asserts the output appears on stdout, not stderr.
Failed before the fix; passes after.
- Existing snapshot tests for `step_alias` pick up the routing change —
diffs are limited to lines moving from `----- stderr -----` to `-----
stdout -----`, which is the user-visible behavior we want. Updated via
`cargo insta test --accept`.
- Manual end-to-end check: `wt -y test-output | tr '[:lower:]'
'[:upper:]'` now prints `HELLO`.
- Full `cargo test --test integration` (1569 tests) and `cargo test
--lib --bins` (606 tests) pass.

---

Closes #2478 — automated triage

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
Co-authored-by: Claude <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