Commit Graph

40 Commits

Author SHA1 Message Date
Worktrunk Bot d4538f2047 fix(alias): carry the shell's cwd into alias and hook bodies (#3724)
## Problem

Since #939 / #3344, `wt switch` and `wt remove` preserve the user's
subdirectory position — from `monorepo.feature/subproject/` you land in
`monorepo/subproject/`. #3723 reports that this is lost one layer down:
with `[aliases] finish = "wt remove -y"`, `wt finish` drops you at the
primary worktree root.

The resolution reads the user's position from the wt process's own cwd
(`resolve_subdir_in_target`, called with `std::env::current_dir()`).
That answers "where is the user standing?" only for a top-level
invocation. Alias and hook bodies run with the worktree root as their
working directory, so the nested `wt` strips the source root off a cwd
that *is* the source root, gets an empty relative path, and falls back
to the destination root.

## Solution

The CD directive file already travels to exactly the children that are
allowed to move the user's shell. The shell's directory now travels with
it: `apply_cd_directive_env` sets `WORKTRUNK_SHELL_CWD` wherever the CD
file is re-added (`Cmd::stream` and the concurrent runner), and
`scrub_directive_env_vars` strips it alongside the other directive vars,
so an untrusted child neither keeps nor receives it.

`shell_exec::shell_cwd()` reads it back, preferring the inherited value
over the process cwd — which is what makes nesting compose, since each
layer forwards the shell's directory rather than its own. Three sites
ask that question and now go through it: `wt switch`, `wt remove`
(`prepare_remove_directory_change`), and `wt step relocate`, whose
existing comment already asks to behave identically to the other two.

Nothing about the working directory of alias or hook *bodies* changes —
`{{ cwd }}` is still the worktree root, per the documented contract. The
only change is what a nested `wt` believes about the user's position.

## Testing

Two integration tests in `tests/integration_tests/step_alias.rs`, both
failing before the change with the exact symptom reported:

```
CD file should preserve the subdirectory (…/repo/apps/gateway), got: "…/repo\n"
```

- `test_alias_wrapping_remove_preserves_subdir` — the reported case
(`[aliases] finish = "wt remove -y"` run from `feature/apps/gateway`).
- `test_alias_wrapping_switch_preserves_subdir` — the same for `wt
switch` inside an alias.

The existing subdirectory-preservation tests in `directives.rs`
(including the fall-back-to-root cases) still pass, as does the full
integration suite — apart from
`test_copy_ignored_preserves_file_executable_permissions`, which fails
identically on `main` in this sandbox (umask `0002`, expects `0644` gets
`0664`) and is unrelated.

The open question from the first revision is answered: the new remove
test leaves two processes with a cwd inside the worktree being removed
(the alias parent in the subdirectory, the nested `wt` at the root)
where the existing test has one, and `test (windows)` passes on it.

Review follow-ups are in 31d8b3d (pure `shell_cwd_from` plus its unit
test, `SHELL_CWD_ENV_VAR` added to the scrub-coverage test, corrected
`startup_cwd()` comment) and 2578348 (the fixtures in that unit test
derive from `temp_dir()` instead of a `cfg!(windows)` literal pair,
whose untaken arm was the last `codecov/patch` miss). One
`code-coverage` run failed on
`progressive_handler::tests::on_update_pokes_run_preview_only_when_the_visible_pane_changes`
— unrelated to this change, passing in `test (linux)` on the same commit
and in the coverage run on the parent commit — and passed on re-run.

Closes #3723

---------

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
2026-08-05 09:29:36 -07:00
Maximilian Roos 958b3082de fix(env): scrub GIT_* discovery vars from for-each and --execute fallback (#3400)
#3374 scrubbed inherited `GIT_*` discovery vars from user-hook spawns,
but two more spawn sites relocate a user command into a wt-chosen
worktree and had the same bug: `wt step for-each` and the `--execute`
payload when wt executes it directly (no shell integration — including
every `!wt`-alias invocation, which bypasses the wrapper). Git resolves
`GIT_DIR`/`GIT_WORK_TREE` before walking up from the cwd, so the
inherited value silently overrode the worktree wt placed the command in:
`git w step for-each -- git rev-parse --show-toplevel` from a linked
worktree printed the invoking worktree's path for every iteration while
the headers named each worktree. The linked-worktree alias case is the
common trigger — git exports an absolute `GIT_DIR` pinned to the
invoking worktree's private gitdir there (verified on git 2.54; from a
main-worktree root it exports none).

The rule is now stated once, on `scrub_git_discovery_env_vars`: **scrub
exactly when wt chose the child's cwd** (hooks, for-each, the
`--execute` fallback); keep when the command runs in the user's own
context (aliases, `commit.generation`, and wt's internal plumbing, which
keeps the absolutize-and-forward behavior from #1914). A CLAUDE.md rule
points there, mirroring the `CommandTrace` any-new-spawn-site
convention.

Testing: regression tests for both new scrub sites (each validated by
reverting the fix and watching it fail) plus an alias pass-through test
pinning the keep side. The `--execute` test is cross-platform, so
Windows CI drives the non-unix `spawn` variant and unix the `exec`
variant; the for-each test follows the file's unix `sh -c` precedent.

Ref #3373 (already closed by #3374; this completes the sweep).

> _This was written by Claude Code on behalf of max-sixty_

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 22:08:33 -07:00
Maximilian Roos 9be99ee21b test: consolidate absence-window sleeps behind a shared SLEEP_FOR_ABSENCE_CHECK (#3206)
Absence assertions (verifying something did NOT happen) need a fixed
window rather than polling, since there's no event to wait for. That
window was expressed three inconsistent ways across the test suite: a
per-file `const SLEEP_FOR_ABSENCE_CHECK` duplicated in `user_hooks.rs`
and `post_start_commands.rs`, and bare `Duration::from_millis(500)`
literals scattered through `switch.rs`, `step_alias.rs`, `remove.rs`,
`bare_repository.rs`, and `merge.rs` — none greppable as a group, and
`merge.rs` sitting below the documented 500ms floor at 200ms.

This promotes `SLEEP_FOR_ABSENCE_CHECK` to `src/testing/mod.rs`, next to
the presence helper `wait_for_file`, re-exported via `tests/common`, and
routes all 15 absence sleeps through it. The constant's doc comment
states the discriminator that decides the tool: a *presence* assertion
polls (and returns the instant the event lands); an *absence* assertion
has no event to wait for, so it holds a bounded window. The three sleeps
left as literals are genuinely different roles — bash startup, signal
sequencing between two SIGINTs, and PTY output sequencing — not absence
checks.

It also rewrites the `tests/CLAUDE.md` timing section around that
polarity discriminator (split into Presence / Absence subsections), with
the absence example using the constant, plus two traps: pairing one
sleep with both a presence and an absence assertion (the presence half
goes flaky), and structural absence — when the event is gated on a
condition the test never sets up, drop the window and poll the positive
precondition instead.

This is the consistency-and-guidance follow-up to the watchdog
de-flaking in #3187: the marker convention makes legitimate absence
sleeps self-labeling, which is what lets a reviewer (or the nightly
sweep) tell them apart from a flaky fixed sleep before a presence
assertion.

## Testing

No behavior change — test-infrastructure and docs only. Each of the 15
conversions was verified to be followed by a negative assertion
(adversarial review, 20/20 confirmed); the full `pre-merge` gate passes
(4170 tests). `merge.rs`'s window widened 200ms → 500ms, which only
makes its absence check more conservative.

> _This was written by Claude Code on behalf of max_

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:41:57 -07:00
Maximilian Roos 3334ffa9d8 test(step_alias): reap orphaned sleep in external-signal alias tests (#3136)
The two `#[cfg(unix)]` external-signal alias tests
(`test_alias_external_sigterm_reaches_child`,
`test_alias_external_sigint_reaches_child`) run an alias body of `...
sleep 30 & wait $!` and deliver a PID-targeted signal to wt. wt forwards
it to the wrapper `sh` by PID — the contract these tests pin — so the
trap's `exit` tears down the shell and leaves the backgrounded `sleep
30` orphaned in wt's process group, lingering ~30s. Two tests, one
orphan each, accounted for the two stray `sleep 30` processes a full
suite run left behind (invisible to nextest's leak detector because
their stdio is redirected to null).

This is a test artifact, not a product bug: wt faithfully reproduces
plain-shell semantics, where a trap that `exit`s without `kill $!`
orphans its background job. The fix reaps the orphan after
`child.wait()` with a negative-pgid SIGKILL — wt ran in its own process
group (`process_group(0)`) and is reaped before the kill, so the SIGKILL
reaches only the leftover sleep. Same pattern as the fsmonitor
SIGKILL-escalation test fix in #3135.

Verified: targeted runs 5/5 leave 0 strays (was 2/2); full suite
(`--features shell-integration-tests`) 4002 passed with 0 strays at +0s
and +5s; clippy and pre-commit clean.

> _This was written by Claude Code on behalf of max_

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 20:53:13 -07:00
Maximilian Roos 751e419615 fix(concurrent): drop per-child SIGINT escalation race (#3075) 2026-06-15 02:16:13 -07:00
Maximilian Roos 22768d425f feat(hooks)!: table-form pre-* hooks run concurrently — remove the serial path (#3052)
## Summary

Lands the cutover announced in v0.37.0 (#2135): a multi-entry table hook
now runs its commands concurrently for every hook type. `[pre-merge]`
with several keys behaves like `[post-start]` with several keys and like
a single `[[pre-merge]]` block — one execution semantic for `Concurrent`
steps.

The serial behavior was not implemented where one might expect. The
executor's serial branch (`ForegroundStep.concurrent` /
`SourcedStep.is_pipeline` / `CommandConfig::is_pipeline()`) was mostly
dead: the deprecation shipped with a load-time TOML migration that
rewrote multi-entry pre-* tables into serial pipeline form in memory on
every config load, so affected configs never reached the executor as
`Concurrent` steps. Removing that migration (now a `DEPRECATION_RULES`
row, after #3045) is the actual behavior change; the executor flags come
out with it.

## Why no replacement runtime warning

Affected configs have printed a warning on every `wt` invocation for
twenty minor releases (v0.37.0, April 12 → v0.57.0), with `wt config
update` offering a one-command migration that preserved serial behavior
explicitly ("migrate now to keep the current serial behavior once the
table form is repurposed"). A post-cutover warning has no coherent
shape: table form is now legitimate concurrent config, so it would nag
users who want exactly that behavior with no way to silence it, and the
`wt config update` rewrite would no longer be behavior-preserving.

## Parse normalization

A one-entry top-level table previously parsed as a `Concurrent` group of
one; with the serial branch gone it would have picked up `name │` output
prefixes. One-entry maps now parse as `Single(named)` everywhere,
matching one-entry maps inside pipeline lists (removing the documented
dict-at-top vs dict-in-list asymmetry), and a new `Serialize` arm keeps
one-step named configs round-tripping as named tables. Single-entry
table hooks render exactly as before.

## Behavior changes

1. **Multi-entry table-form pre-* hooks: serial → concurrent.** The
announced change.
2. **`wt hook <post-type>` foreground runs of multi-entry table hooks:
serial → concurrent.** Previously an undocumented inconsistency — the
same config already ran concurrently via the background path.
3. **Single-entry table aliases (`[aliases.x]` with one key):
prefixed-stderr → stdout passthrough.** Now matches the string and
`[[aliases.x]]` spellings, and makes `wt <alias> | …` work for this
spelling too.

(2) and (3) were never deprecation-warned; all three belong in the
release notes.

## Tests

- `test_pre_merge_deprecated_table_runs_serially` → rewritten as
`test_pre_merge_table_form_runs_concurrently`; the
single-`[[pre-merge]]`-block test is deleted (both forms now parse
identically, so it duplicated the rewritten test).
- Fixtures that genuinely need serial ordering (`>`/`>>` chains, the
signal-abort "second must not run" assertion) converted to pipeline
form.
- `test_user_hooks_preserve_toml_order` (#737) and
`test_post_start_named_commands` keep table form and pin ordering via
`WORKTRUNK_TEST_SERIAL_CONCURRENT=1`, so insertion order is still
asserted through the real concurrent input ordering.
- New: one-entry-table parse/round-trip unit test;
`test_alias_single_entry_table_writes_to_stdout` pinning change (3).
- Regenerated snapshots drop a stale `RUST_LOG: warn` env line (the
harness stopped setting it in #2901).

## Known follow-up

Concurrent-group announcements expose a pre-existing ANSI dim-bleed:
`format_bash_with_gutter` output ends without closing the dim attribute,
so back-to-back `◎ Running …` lines render dim (visible today on
pipeline-form hooks and `--execute` verbose). Now that this rendering is
the table-form default it's worth fixing, but the fix lives in the
gutter formatter and churns every bash-gutter snapshot — separate PR.

> _This was written by Claude Code on behalf of max_

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:31:07 -07:00
Maximilian Roos 1fa70d3e08 fix(concurrent): report originating signal as wt's exit code after escalation (#2724)
## Summary

`wt step <concurrent-alias>` interrupted by Ctrl-C could exit with code
143 (SIGTERM) instead of 130 (SIGINT) under load. The race lives in
`src/output/concurrent.rs`'s signal forwarder: it sends SIGINT to each
child's process group, then waits 200ms before escalating to SIGTERM.
Under CI load the path `signal_hook polls → killpg → child receives →
forwards to sleep → sleep dies → sh dies → pgroup empty` exceeds 200ms,
so wt escalates and the child dies from SIGTERM. Wt's
`interrupt_exit_code()` reads the child's actual death signal and
reports `128 + 15 = 143` even though the user only ever sent SIGINT.

This was the cause of the `affected tests (linux, advisory)` failure on
#2709 (selected `test_alias_concurrent_receives_sigint` because that PR
touched `src/main.rs`, dragging in coverage of nearly every wt
invocation path) — diagnosis recorded there, not a regression from the
approval refactor.

## Fix

Record the user's first signal in a shared `Arc<AtomicI32>` set by the
signal forwarder thread. After every child has been waited on, override
any per-child `ChildProcessExited::signal` whose value differs from the
originating one. wt's exit code now reflects what the user pressed (130
for SIGINT) regardless of which signal the escalation chain ended up
using to actually kill the child.

The fix is contained to `run_concurrent_commands`. The single-step
`shell_exec.rs::stream` path has the same escalation logic but isn't
exercised by the failing test; if the equivalent flake shows up there,
the same pattern can be applied.

## Test plan

- New `test_alias_concurrent_sigint_reports_origin_after_escalation`
(`tests/integration_tests/step_alias.rs:1406`) — children `trap "" INT`
so SIGINT is ignored and only the SIGTERM escalation kills them.
Verified to reproduce the exact CI failure mode
(`unix_wait_status(36608)` = exit 143) without the fix and pass with it.
- Existing `test_alias_concurrent_receives_sigint` and
`test_alias_concurrent_second_sigint_kills` still pass.
- `cargo run -- hook pre-merge --yes`: 3667 tests pass, lints clean.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-11 15:46:35 -07:00
Maximilian Roos 014765252f feat(config): bare wt config alias show lists full definitions (#2691)
## Summary

`wt config alias show` with no name showed a compact one-line summary
listing (the `name command-summary (source)` table added in #2684). This
swaps it for the full per-alias view: the same `○ Alias <name>
(<source>):` header + gutter block that `wt config alias show <name>`
prints, emitted for every configured alias in name order (user entry
before project when a name is defined in both), with the
shadowed-by-built-in stderr warning preserved (once per name). `--help`
/ `wt step --help` keep the compact names-only `Aliases:` section and
point here.

```
○ Alias landed (user):
  wt up && wt hook post-merge; git merge {{ default_branch }} --no-edit && { git
   diff {{ default_branch }} --quiet && git reset {{ default_branch }}; :; }
○ Alias sw (user):
  wt switch {{ args }}
○ Alias up (user):
  git fetch --all --prune && wt step for-each -- sh -c '
    ...
  '
```

Also drops the blank line between consecutive `format_entry` blocks —
applied consistently to `wt config alias show` (the new listing), `wt
config alias show <name>` (had a blank line between the user and project
entries when a name is in both configs), and `wt config alias dry-run
<name>` (same). The `○ Alias …` header is the delimiter everywhere.

The compact-summary renderer (`render_aliases_section` +
`format_alias_summary`) and its unit tests are removed — only #2684's
no-arg listing used them; `--help`'s names-only section is the separate
`render_aliases_help_section`, which is untouched.

## Changes

- `src/commands/config/alias.rs` — `list_aliases()` rewritten to emit
`format_entry` per alias; `handle_alias_show` / `handle_alias_dry_run`
drop the inter-entry blank line; docstrings + module doc updated.
- `src/commands/alias.rs` — delete `render_aliases_section`,
`format_alias_summary`, and their tests; tidy the
`render_aliases_help_section` doc.
- `src/cli/config.rs` — `wt config alias show` help text reflects the
new behavior.
- Docs (`docs/content/config.md`, skill mirror) regenerated; three
snapshots updated (one content change + cosmetic env drift on two).

## Test plan

- [x] `cargo run -- hook pre-merge --yes` — 3581 tests pass, all lints
green
- [x] Manual: `wt config alias show`, `wt config alias show <name>`, `wt
config alias dry-run`, `wt config alias show --help`

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

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-11 00:16:12 -07:00
Maximilian Roos a3733dcce8 feat(config): list all aliases when wt config alias show has no name (#2684)
`wt config alias show <name>` shows one alias's configured template.
With no name it now lists every configured alias — the same `Aliases:`
block `wt --help` renders (name, source, one-line summary). This is the
receiving end of a separate change that replaces the inline aliases
block in `wt --help` with a pointer to `wt config alias show`.

Implementation: `name` becomes optional with a single internal branch —
no `--list` or `--all` flag. The listing reuses `render_aliases_section`
/ `load_aliases_for_listing` from `commands/alias.rs` (promoted to
`pub(crate)`) rather than duplicating. It tolerates running outside a
repo (user-config aliases still list, project-config ones are skipped)
or outside a config, prints `No aliases configured` when empty, and
pages like `wt hook show`. Top-level shadowing is annotated (`list` →
`(shadowed by built-in)`).

Audit of other `show`/`view`/`info`/`print` subcommands found nothing
else to change: `wt hook show [TYPE]` already does this, `wt config
show` / `wt config shell show-theme` / `wt config state get` take no
positional and already show everything, and no `view`/`info`/`print`
subcommands exist.

Help text and the auto-synced docs/skill reference are updated; new
snapshot tests cover the populated and empty listings, and the four
`config_alias_show_unknown_*` snapshots pick up the `Usage: ... [NAME]`
change.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:42:06 -07:00
Worktrunk Bot af3be9e655 refactor(alias)!: source-tag steps and decide EXEC per-step (#2474)
## Summary

Two phases on the same theme — making per-source policy first-class
instead of working around a name-keyed merge.

**Phase 1 — source-tag steps, decide EXEC per-step:**

- `SourcedStep` (`hook_type` now `Option<HookType>`) is the shared
sourced-step type; `sourced_steps_to_foreground(steps, &PipelineKind)`
is the single resolver. User-source alias steps get
`DirectivePassthrough::inherit_from_env_with_exec()`; everything else
(project-source alias steps, hooks of either source) gets
`inherit_from_env()`.
- `ForegroundStep` carries its own `directives` field, so
`execute_pipeline_foreground` no longer takes a per-pipeline
`&DirectivePassthrough`.
- Removed the name-keyed `project_aliases_contain` EXEC switch —
replaced by the per-step decision the source tag carries.

**Phase 2 — key aliases by name + source instead of merging:**

- `AliasMap = BTreeMap<String, AliasEntry { user, project }>` replaces
the `BTreeMap<String, CommandConfig>` that was previously built via
`append_aliases`. Same-name collisions are no longer a special case —
the data shape carries both sources directly.
- `load_aliases(repo, user_config, project_config)` is the single
resolver. `load_merged_aliases`, `alias_needs_approval`, and
`prepare_alias_sourced_steps` are gone.
- `run_alias` takes `&AliasEntry`, drops the duplicate
`user_config.aliases(...)` lookup, drops the `project_config` and
merged-map parameters; approval reads `entry.project.as_ref()`.
- `format_alias_announcement` walks both sources in runtime order.
- `unknown_alias_error` (config/alias.rs) routes through `load_aliases`
for canonical name iteration.
- `load_aliases_for_completion` returns `AliasMap`; stub builders use
`entry.representative()` for help text (user wins, matching the
announcement order).
- `append_aliases` is no longer used by alias dispatch — it survives
inside `UserConfig::aliases` for the global vs. per-project user-config
merge, which is a different axis.

Addresses
[@vandamm](https://github.com/max-sixty/worktrunk/issues/2101#issuecomment-4343744458)'s
use case directly:

```toml
[aliases]  # in the user's config — ~/.config/worktrunk/config.toml
issue = "wt switch --create {{ args | join('-') | lower }} -x claude -- 'Read Linear issue {{ args[0] | upper }} via the linear MCP and propose an approach.'"
```

`wt issue XX-1200 add-button` now lands the user in the new worktree and
execs `claude` in their interactive shell.

## Behavior change vs prior release

Same-name user+project alias collisions used to scrub EXEC for the whole
merged pipeline — a conservative side effect of name-keyed merging in
`append_aliases`. With per-step decisions, the user's own steps keep the
EXEC relaxation; only the project-authored steps scrub. Strictly more
permissive than the prior approach.

## Test plan

- [x] `test_user_alias_passes_exec_directive` — user alias body running
`wt switch --execute echo from-user-alias` writes the payload to the
parent's EXEC file and emits no scrub warning.
- [x] `test_project_alias_scrubs_exec_directive` — project alias body
running the same command leaves the EXEC file empty, emits the warning
with the issue link, and exits 0.
- [x] `test_user_and_project_alias_collision_scrubs_only_project_step` —
same alias name in both configs: user-step EXEC payload lands in the
EXEC file, project-step EXEC payload is scrubbed, project warning fires
once.
- [x] `test_format_alias_announcement_concatenates_user_and_project` —
collision banner walks user steps first, then project steps.
- [x] Existing `test_switch_exec_scrubbed_warns` snapshot updated for
the new wording.
- [x] `cargo test --test integration` — 1584 passed, 0 failed.
- [x] `cargo clippy --all-targets` clean.
- [x] `cargo run -- hook pre-merge --yes` — 3404/3404 tests, all lints
pass end-to-end.

---------

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Maximilian Roos <m@maxroos.com>
2026-04-30 21:11:26 -07:00
Worktrunk Bot f3eb8fe93d fix(alias): pass alias stdout through so 'wt <alias> | ...' works (#2479)
## 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>
2026-04-29 16:16:54 -07:00
Worktrunk Bot 6f1974cdba fix(alias): share parent pgroup so interactive TUIs don't freeze (#2444)
## Summary

- Aliases with interactive children (`wt switch`'s skim picker) froze
with a blank screen because the alias child was put in a new process
group; the kernel then raised SIGTTOU when the picker called `tcsetattr`
on `/dev/tty`, stopping the child mid-render.
- New `Cmd::inherit_stdin()` builder both inherits stdin *and* keeps the
child in the parent's process group. `execute_shell_command` uses it for
the interactive (no-stdin-payload) path, which covers aliases.
- In shared-pgroup mode the signal listener forwards by PID via
`kill(child.id(), sig)` so externally-delivered signals (e.g. `kill
-TERM <wt-pid>`) reach the child — they otherwise stop at wt because the
kernel only multicasts tty-initiated signals to the foreground pgroup.
Single-shot, no SIGINT→SIGTERM→SIGKILL escalation: the caller chose the
signal, and a premature SIGKILL would skip the child's tty restore
(raw-mode reset, cursor-show) and leave the terminal wedged.
`WorktrunkError::ChildProcessExited { signal: Some(_) }` accounting is
preserved.
- Module-level docstring at the top of `shell_exec.rs` summarizes the
two `Cmd::stream` shapes (isolated vs shared-tty), how each interacts
with the structured `ChildProcessExited` channel used by loop callers,
and why detached/concurrent spawn paths isolate.

Hooks (which still get JSON-on-stdin via `stdin_bytes`) are unaffected —
they go through the `stdin_data`/piped branch and never call
`inherit_stdin()`.

## Test plan

- [x] New regression test `test_alias_child_shares_parent_pgroup`:
spawns wt as its own pgroup leader, runs an alias that records its shell
pgid via `ps -o pgid= -p \$\$`, asserts the recorded pgid equals wt's
pid (i.e. the alias child shares wt's pgroup).
- [x] New regression tests `test_alias_external_sigterm_reaches_child`
and `test_alias_external_sigint_reaches_child`: target wt with PID-only
`kill -<sig>` (not the pgroup) and assert the alias child's signal trap
fires. Without the PID-forwarding fix the test would block until the
child's `sleep 30` completes; with it, each settles in ~1.3s. Together
they exercise both arms of `forward_signal_to_pid`.
- [x] Existing `test_alias_concurrent_receives_sigint` and
`test_alias_concurrent_second_sigint_kills` still pass — concurrent
aliases use a separate executor (`output/concurrent.rs`) that's
unaffected by this change.
- [x] Full `step_alias` (70 tests) and `user_hooks` (108 tests) suites
green locally.
- [ ] Manual verification that `wt sw` (aliased to `wt switch {{ args
}}`) now renders the picker instead of hanging.

---------

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Maximilian Roos <m@maxroos.com>
2026-04-28 07:23:30 -07:00
Worktrunk Bot e1f2fbd76a fix(alias): inherit stdin so interactive children keep the tty (#2380)
## Summary

Alias execution piped the template context JSON into each child's stdin,
displacing the controlling terminal. Interactive children like `wt
switch` then saw a pipe and bailed with `Interactive picker requires an
interactive terminal`. See [#406
follow-up](https://github.com/max-sixty/worktrunk/issues/406#issuecomment-4290869876)
for the reproduction.

The fix is scoped to the single call site in `run_one_command`
(`src/commands/command_executor.rs`) where `CommandOrigin` already
distinguishes hooks from aliases:

- `CommandOrigin::Hook { .. }` → keep piping `context_json` on stdin
(documented contract, covered by `test_post_create_json_stdin` /
`test_post_start_json_stdin`).
- `CommandOrigin::Alias { .. }` → pass `None`; `execute_shell_command`
now treats `None` as `Stdio::inherit()` so the child keeps the parent's
tty.

No subcommand sniffing, no new configs, no change to `for_each` (which
still opts into JSON-on-stdin).

## Test plan

- [x] New regression test `test_alias_inherits_stdin` in
`tests/integration_tests/step_alias.rs` — pipes a sentinel into `wt`,
runs an alias of `cat`, asserts the sentinel (not the context JSON)
reaches the alias body.
- [x] `cargo test --test integration -- step_alias` — all 67 tests pass,
including the new one.
- [x] `cargo test --test integration -- test_post_create_json_stdin
test_post_start_json_stdin` — hook JSON-on-stdin still works.
- [x] `cargo test --test integration -- post_start post_create for_each`
— 70 tests pass.
- [x] `cargo clippy --all-targets` — clean.

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

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 20:21:14 -07:00
Maximilian Roos 38e538232c Render alias args in -v table as shell-escaped {{ args }} (#2328)
Follow-up to #2324.

The alias `-v` table previously showed `args` as its raw JSON wire
format (`["foo","bar baz"]`) — the internal `HashMap<String, String>`
storage. Templates rehydrate that JSON into a `ShellArgs` object whose
`render` impl produces a space-joined, shell-escaped string; the table
now does the same so its value matches what `{{ args }}` substitutes one
line below.

Factored the space-join-and-escape into a `shell_join` helper used by
both `ShellArgs::render` and the alias table — one canonical rendering.

Also moved `args` into the **exec** group next to `cwd`, matching the
`## Template variables` help table (previously it sat between active and
repo, implying "operation context").

## Before/after

| Input | Before | After |
|---|---|---|
| `wt -v greet world` | `args = ["world"]` | `args = world` |
| `wt -v greet foo "bar baz"` | `args = ["foo","bar baz"]` | `args = foo
'bar baz'` |

> _This was written by Claude Code on behalf of @max-sixty_

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 00:27:56 -07:00
Maximilian Roos 0253260503 Extend -v variable dump to aliases + help-table drift test (#2324)
Follow-ups from #2316.

## What's in here

1. **Alias `-v` variable dump.** Added `format_alias_variables(ctx)`
alongside the existing `format_hook_variables(hook_type, ctx)`, with a
private `format_variables_table` helper sharing the alignment +
`(unset)` logic. Wired into `run_alias` before the announcement,
symmetric with the foreground hook path.

2. **Help-table drift test.**
`test_template_variables_table_matches_constants` parses the `##
Template variables` table out of `src/cli/mod.rs`, extracts `(kind,
var_name)` pairs, and asserts presence + group placement against
`ACTIVE_VARS` / `REPO_VARS` / `EXEC_BASE_VARS` / `ALIAS_ARGS_KEY` /
union of `vars_available_in(Hook(*))`. Uses public API only — no leak of
the private `hook_extras` helper. Adding a var to the constants without
updating the table (or vice versa) fails the test. Descriptions stay
free-form.

3. **Shorter `-v` help text.** `Verbose output (-v: info logs +
hook/alias template variable & output; ...)`.

## Example

```
\$ wt -v greet world
○ template variables:
  branch                = feature
  worktree_path         = _REPO_.feature
  worktree_name         = repo.feature
  …
  args                  = ["world"]
  repo                  = repo
  …
  cwd                   = _REPO_.feature
◎ Running alias greet
○ Expanding greet
  echo hello {{ args }}
  →
  echo hello world
hello world
```

> _This was written by Claude Code on behalf of @max-sixty_

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 23:39:29 -07:00
Maximilian Roos 24181f251d fix(alias): unify typo error format across config alias show/dry-run (#2306)
## Summary

Four alias-typo surfaces rendered in two formats. After #2304's round-2
review, this aligns `config alias show/dry-run` with `wt <typo>` and `wt
step <typo>`.

Before:
- `wt deplyo` / `wt step deplyo` → clap-native `error: unrecognized
subcommand 'X'` + `tip: …`, exit 2
- `wt config alias show deplyo` / `dry-run deplyo` → custom anyhow
gutter `✗ unknown alias 'X'` + gutter bars, exit 1

After (all four surfaces):
- Same clap-native `error:` / `tip:` / `Usage:` layout, exit 2
- `config alias show/dry-run` say "alias" / "aliases" instead of
"subcommand" / "subcommands" — the positional is an alias name, not a
subcommand

## How

`unknown_alias_error` builds a real `clap::Error` with
`ErrorKind::InvalidSubcommand`, renders it through clap, then
substitutes clap's fixed phrases (`unrecognized subcommand` →
`unrecognized alias`, `similar subcommands` → `similar aliases`,
`similar subcommand` → `similar alias`). Writing through
`anstream::AutoStream::auto(stderr)` gets NO_COLOR / TTY detection and
clap's singular-vs-plural phrasing automatically. Returns
`WorktrunkError::AlreadyDisplayed { exit_code: 2 }` so `main`'s
`finish_command` still runs `terminate_output` + `write_if_verbose`.

Substitutions are deliberately narrow — an alias name or typo containing
the literal substring `subcommand` (e.g. `my-subcommand`) is echoed
verbatim, not mangled.

## Changes

- `src/commands/config/alias.rs`: replace the anyhow-returning
`unknown_alias_error` (exit 1, custom gutter) with a clap-rendering
version (exit 2, matches clap-native surfaces). Parameterized by `sub`
so the Usage line reads `wt config alias <sub> <NAME>`.
- New tests: `test_config_alias_dry_run_unknown_suggests`,
`test_config_alias_show_unknown_singular_suggestion`,
`test_config_alias_show_unknown_preserves_user_input`.
- Refreshed `test_config_alias_show_unknown_suggests` and
`test_config_alias_show_unknown_no_suggestions` snapshots.

## Test plan

- [x] `cargo test --test integration step_alias` (58 pass)
- [x] `cargo test --lib --bins` (598 pass)
- [x] `pre-commit run --all-files` clean
- [x] Manual smoke on all four paths (exit 2; ANSI stripped under
`NO_COLOR=1 | cat`; preserved under `CLICOLOR_FORCE=1`)
- [x] Aliases containing `subcommand` echoed verbatim under typo

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:27:06 -07:00
Maximilian Roos 68229ce571 Smart routing for alias arguments + dry-run routing display (#2304)
Routes `--KEY=VALUE` tokens based on what the template references.
Tokens whose `KEY` appears as `{{ KEY }}` in the alias bind to that
variable; everything else joins `{{ args }}`. Space form `--KEY VALUE`
is equivalent.

Key changes:
- Parser uses template reference introspection (via
`referenced_vars_for_config`) to decide binding at parse time.
- Post-alias `--yes` retired — use `wt -y <alias>` (the global form)
instead. `--dry-run` on an alias invocation is also retired; `wt config
alias show/dry-run <name>` replaces it (landed separately in #2291).
- Hyphens in keys canonicalize to underscores, so `--my-var=x` binds `{{
my_var }}`.
- `--` is a literal-forward escape — everything after it forwards to `{{
args }}` regardless of bindings.
- `wt <alias> --help` / `-h` prints a hint pointing at `wt config alias
show/dry-run` rather than silently forwarding the flag into `{{ args
}}`. `wt <alias> -- --help` still forwards.

Advisory warnings (printed on stderr, don't affect execution):
- `--KEY VALUE` with `--`-prefixed VALUE: almost always a typo where
`--KEY=VALUE` was meant.
- `wt config alias show`/`dry-run` on a name that shadows a top-level
built-in (e.g. `list`): the alias is unreachable via `wt <name>`.

`wt config alias dry-run` now prints `# bound:` and `# args:` routing
comments above the rendered command so users can see how each token was
interpreted.

Docs rewrite of the aliases section: concrete fly preview-env example,
"Passing values" section (renamed from the opaque "How arguments are
routed"), routing mechanism moved above the introspection tools, `up`
rebase recipe restored, `since-main` alias added.

## Test plan
- [x] `cargo test --lib --bins commands::alias::tests` — unit tests
(new: duplicate-key precedence, footgun warning, multi-`=` value)
- [x] `cargo test --test integration step_alias` — integration tests
(new: shadow warning on `show` and `dry-run`, `--help` intercept,
retired `--dry-run` via `wt step`)
- [x] `cargo test --test integration
test_command_pages_and_skill_files_are_in_sync` — skill file sync
- [x] `cargo run -- hook pre-merge --yes` — full test suite + lints
green

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Worktrunk Bot <w@worktrunk.dev>
2026-04-19 14:39:07 -07:00
Maximilian Roos c7b94a7276 feat(config): add wt config alias show and dry-run subcommands (#2291)
## Summary

Third PR in the series simplifying alias-control flags. Introduces `wt
config alias` as the home for alias introspection and removes the
`--dry-run` flag that lived on alias dispatch.

- **`wt config alias show <name>`** — prints the configured template,
source-labeled (user/project). Each step in a pipeline is rendered in a
single gutter block with `# <step-name>` comment lines above named
steps.
- **`wt config alias dry-run <name> [-- args...]`** — parses the
arguments with the same parser `wt <alias>` uses, renders templates
against the execution-time context, and prints without running. Args
after `--` are forwarded verbatim, so `wt config alias dry-run s --
target-branch` previews exactly what `wt s target-branch` would run.
Same layout as `show`; only the header verb differs (`:` vs ` would
run:`).
- **`--dry-run` removed** from `AliasOptions` — both `wt <alias>
--dry-run` and `wt step <alias> --dry-run` now return an actionable
error pointing at the new subcommand. No deprecation period: top-level
alias dispatch is recent, so cutover is acceptable.
- **Tab-completion** completes alias names under `show` / `dry-run`.

## Output format

```
○ Alias deploy (project) would run:
 ┃ # install
 ┃ npm install
 ┃ # build
 ┃ npm run build
```

The `○ Alias <name> (<source>)` header matches the existing
`info_message` style. Bolding the name (what the user typed) and tagging
the source in parens keeps the invocation identifier visually primary.
When both user and project define the same alias, both entries print
back-to-back (user first, matching runtime execution order).

## Design notes

- The new subcommand reuses `AliasOptions::parse` as the source of truth
for invocation parsing, so preview stays aligned with runtime. When user
and project configs both define the alias, `referenced_vars` is unioned
across entries so a flag binds if any template references it.
- Templates referencing `vars.*` are shown unexpanded, mirroring the
lazy execution path — those values are read from git config just before
each step runs, potentially after earlier steps have set them. Syntax
errors still surface up front.
- `wt step <alias>` also breaks since both paths share
`AliasOptions::parse`. That's fine — `wt step <alias>` has no
forward-compat promise beyond the basic call.
- `AliasSource` is promoted to `pub(crate)` with a `label()` helper so
`wt config alias` and the alias runtime code read from one enum.

## Navigating the diff

- `src/cli/config.rs` — adds `ConfigAliasCommand` + the `Alias` variant
on `ConfigCommand`.
- `src/commands/config/alias.rs` — new module. `handle_alias_show` and
`handle_alias_dry_run` share a `format_entry` helper; the `verb:
Option<&str>` parameter is the only difference between show and dry-run
output. `render_preview` replaces the old `render_for_dry_run` in alias
dispatch.
- `src/commands/alias.rs` — the `--dry-run` flag and its rendering
branch are gone. The parser bails on `--dry-run` with a migration
message pointing at the new subcommand; the bail sits after the `--`
literal-mode check, so `wt alias -- --dry-run` still forwards as
positional.
- `src/completion.rs` — `alias_name_completer()` mirrors the existing
`hook_command_name_completer` pattern.
- `tests/integration_tests/step_alias.rs` — existing `--dry-run` tests
migrate to `wt config alias dry-run`, plus new coverage for `show`,
unknown-alias suggestions, and the retired-flag error.
- `docs/content/extending.md` — adds an "Inspecting and previewing"
subsection.

## Test plan

- [x] Full suite via `wt hook pre-merge --yes` (3271 tests, all passing)
- [x] `cargo insta test --accept` — all snapshots up to date
- [x] `cargo test --test integration
test_command_pages_and_skill_files_are_in_sync` — docs synced
- [x] Manual verification: `wt config alias show <name>` and `wt config
alias dry-run <name> [-- args...]` in a repo with mixed user/project
aliases

> _This was written by Claude Code on behalf of Maximilian_

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 16:00:01 -07:00
Maximilian Roos 8e3abf46db feat(alias): route --key=value via template refs, add -- escape, drop --var (#2287)
## Summary

Replace the alias-arg parser's "every `--KEY=VALUE` binds; bare `--KEY`
errors" grammar with template-driven routing. A token only binds when
the alias's pipeline references `{{ KEY }}`; otherwise it forwards into
`{{ args }}`. Adds `--` as a literal-forward escape and removes the
`--var KEY=VALUE` / `--var=KEY=VALUE` special cases.

The template already declares which variables it consumes — using that
as the routing signal removes both surprise binding (silent
`env=staging` for templates that never reference `env`) and the
unknown-flag error class. Every post-alias token now has a deterministic
destination.

## Grammar

Tokens after `wt <alias>` are walked left-to-right:

| Token shape | Routes to |
|---|---|
| `--KEY=VALUE` or `--KEY VALUE` where the template references `{{ KEY
}}` | Bound — `KEY` becomes the template value |
| `--KEY=VALUE` where the template doesn't reference `KEY` | Forwarded
literally to `{{ args }}` |
| `--KEY` followed by another `--…` (or end of args) | Forwarded
literally to `{{ args }}` |
| Bare positional | Forwarded to `{{ args }}` |
| Anything after `--` | Forwarded to `{{ args }}` regardless of shape |

`--dry-run` is the only post-alias built-in. `--yes`/`-y` is global only
(`wt -y <alias>`) per #2290 — post-alias `--yes` falls through the shape
rule and forwards as positional. Hyphens in the key canonicalize to
underscores before lookup, so `--my-var=value` binds to `{{ my_var }}`.

## Behavior changes

| Input | Before | After |
|---|---|---|
| `wt rm --force` (no `{{ force }}` ref) | Errored "Unknown flag" |
Forwards `--force` to `{{ args }}` |
| `wt deploy --env staging` | Errored "Unknown flag" | Binds
`env=staging` if referenced; else forwards both to `{{ args }}` |
| `wt deploy --env=staging` (no `{{ env }}` ref) | Silently bound
`env=staging` | Forwards `--env=staging` to `{{ args }}` |
| `wt foo --var x=1` | Bound `x=1` via the special `--var` arm | Same as
`--var=x=1`: binds `var="x=1"` if referenced, else forwards |
| `wt run -- --env=staging` | `--env=staging` consumed | `--env=staging`
forwarded literally |
| `wt show --branch=override` (with `{{ branch }}` ref) | Bound (worked,
undocumented) | Bound, now documented and tested |

Users who relied on `--var KEY=VALUE` should switch to `--KEY=VALUE`
directly.

## Navigating the diff

- `src/commands/alias.rs` — `AliasOptions::parse` rewritten as a
left-to-right walk against `referenced_vars: &BTreeSet<String>`.
`try_alias` and `step_alias` resolve the alias's `CommandConfig` first,
compute `referenced_vars`, then parse. `step_alias` now takes
`Vec<String>` (parses internally) because routing needs the resolved
alias.
- `src/config/expansion.rs` — new `referenced_vars_for_config` helper
unions `template.undeclared_variables(false)` across every command in a
pipeline.
- `src/main.rs` — call site updated for new `step_alias` signature.
- `tests/integration_tests/step_alias.rs` — six new integration tests
cover the grammar end-to-end (referenced bind, unreferenced forward,
`--` escape, multi-step binding, built-in overshadow, space-separated
bind). The `--var` tests are gone.
- `tests/integration_tests/approval_ui.rs` — updated
`test_post_alias_yes_does_not_skip_approval` (renamed from main's
`…_no_longer_supported`): under the new grammar `--yes` doesn't error,
it forwards as positional, but still doesn't skip approval.
- `docs/content/extending.md` (and auto-synced skill reference) — new
"How arguments are routed" section, `--` escape documented, built-in
overshadow caveat added.

## Test plan

- [x] `cargo run -- hook pre-merge --yes` (3255 tests, all pass)
- [x] Unit tests for parse grammar: `test_parse_built_in_flags`,
`test_parse_key_value_routing`, `test_parse_space_separated_routing`,
`test_parse_hyphen_canonicalization`,
`test_parse_literal_forward_escape`, `test_parse_mixed_pipeline`,
`test_parse_positionals` (covers post-alias `--yes`/`-y` forwarding),
`test_referenced_vars_for_config_unions_steps`
- [x] Integration tests for end-to-end routing
- [x] Doc sync test passes

> _This was written by Claude Code on behalf of Maximilian_

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-18 14:15:24 -07:00
Maximilian Roos f10ea23df7 refactor(cli): drop post-alias --yes from hand-rolled parser (#2290)
PR #2279 promoted `-y`/`--yes` to a top-level global clap flag. The
hand-rolled `AliasOptions::parse` kept consuming a post-alias `--yes`
for back-compat — that's the deferred cleanup landing here. `run_alias`
now reads only the global flag (`skip_approval = global_yes`).

This is a real cutover, not a no-op. Clap's `global = true` does not
propagate flags across an `external_subcommand` boundary (verified
against clap 4.6 — `wt deploy --yes` results in `Custom(["deploy",
"--yes"])` with `global_yes = false`). So the post-alias form was never
reaching the global parser; it only worked because `AliasOptions::parse`
consumed it. Removing the field removes user-visible behavior:

| Form | Before | After |
|---|---|---|
| `wt -y deploy` | skips approval | skips approval |
| `wt --yes deploy` | skips approval | skips approval |
| `wt deploy --yes` | skips approval | errors: "Unknown flag '--yes'" |
| `wt deploy -y` | skips approval | silently forwards `-y` as `{{ args
}}` positional |

The `--yes` / `-y` asymmetry on the post-alias side is documented in the
`AliasOptions::parse` docstring and locked in by parser tests.

## Test changes

- `test_post_alias_yes_still_works` →
`test_post_alias_yes_no_longer_supported` (asserts the new error)
- 14 call sites in `tests/integration_tests/step_alias.rs` migrated from
`&[..., "--yes"]` to `make_snapshot_cmd_with_global_flags(..., &["-y"])`
- 4 unnecessary `--yes` flags removed from
`tests/integration_tests/bare_repository.rs` (the `print-repo-path`
user-config alias never required approval)

> _This was written by Claude Code on behalf of Maximilian_

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-18 12:08:59 -07:00
Maximilian Roos 4ec7023ee5 feat(alias): forward positional args to templates via {{ args }} (#2280)
## Summary

Non-flag tokens after an alias name are forwarded to the template as `{{
args }}` — a space-joined, shell-escaped sequence. `wt s some-branch`
with `s = "wt switch {{ args }}"` expands to `wt switch some-branch`.

Before: `wt s some-branch` errored with `Unexpected argument
'some-branch' for alias 's'`.

## Why

Users want to pass arguments through to aliased commands — `wt s foo` →
`wt switch foo` — without configuring `--var foo=…` for every possible
parameter. Positional args are the natural fit.

## Behavior

`{{ args }}` is a minijinja sequence. All four access patterns work:

| Template | Render |
|---|---|
| `{{ args }}` | space-joined, per-element shell-escaped |
| `{{ args[0] }}` | first arg (escaped) |
| `{{ args \| length }}` | count |
| `{% for a in args %}` | iteration |

Each element is individually shell-escaped, so `wt run 'a b' 'c;d'`
splices in as `'a b' 'c;d'` and can't inject shell syntax. Positionals
interleave freely with flags — `wt deploy foo --dry-run bar` collects
`["foo", "bar"]` into `args`.

`--dry-run`, `--yes`, `-y`, `--var KEY=VALUE`, and `--KEY=VALUE` parsing
are unchanged.

## Navigating the diff

- `src/config/expansion.rs` — new `ShellArgs` struct wraps `Vec<String>`
and implements minijinja's `Object` trait with `ObjectRepr::Seq`. Its
`render()` writes `shell_escape::unix::escape` of each element,
space-joined. The shell-escape formatter in `setup_template_env` detects
`ShellArgs` via `downcast_object_ref` and passes its `Display` through
unmodified — so bare `{{ args }}` isn't double-escaped by the generic
per-value escape. Iteration and indexing yield plain
`Value::from(String)` that still flow through the generic formatter. New
`ALIAS_ARGS_KEY` constant (`"args"`) is the reserved context key
carrying the JSON-encoded list.
- `src/commands/alias.rs` — `AliasOptions` gains a `positional_args:
Vec<String>` field. `AliasOptions::parse` collects non-flag tokens into
it instead of erroring. `run_alias` inserts the JSON-encoded list into
`context_map` under `ALIAS_ARGS_KEY` right after `build_hook_context`.
The sole special-case lives in `expand_template`, which rehydrates the
key as a `ShellArgs` object; all other call sites (hooks, for-each,
eval) are untouched and never see the key.
- `validate_template` — injects an empty `ShellArgs` so templates
referencing `{{ args }}` pass pre-flight validation. `args` is added to
`TEMPLATE_VARS`.
- `docs/content/extending.md` — new "Forwarding positional arguments"
subsection under Aliases with a shell-safety guarantee and
access-pattern examples.

## Tests

3233 tests pass, lints clean. New:

- `src/commands/alias.rs` — `test_parse` snapshots extended with
positional cases including interleaved flags and metacharacter-laden
args. `test_parse_errors` no longer expects "Unexpected argument".
- `src/config/expansion.rs` — `test_expand_template_args_sequence`
covers indexing, iteration, and length.
`test_expand_template_args_empty` confirms empty renders to empty
string. `test_expand_template_args_shell_metachar_safety` asserts the
exact output for `['; rm -rf /', '$(whoami)', "a'b"]`.
`test_validate_template_valid` now covers `{{ args }}`, `{{ args |
length }}`, and iteration.
- `tests/integration_tests/step_alias.rs` — end-to-end snapshots for `wt
step <alias> positionals`, `wt <alias> some-branch --dry-run`, empty
positionals, and sequence-style access.

## Do-nots

- No changes to `--dry-run`, `--yes`, `--var`, or `--KEY=VALUE` parsing
— the broader flag-handling question (whether alias-level flags should
move pre-name) is being designed separately.
- No `KEY=value` bare-token parsing for vars — deferred.
- No change to `wt step <alias>` semantics beyond inheriting positional
support via shared `AliasOptions::parse`.

> _This was written by Claude Code on behalf of Maximilian_

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 23:52:31 -07:00
Maximilian Roos a1be5645c4 feat(alias): dispatch aliases from top-level wt <name> (#2266)
`wt deploy` now resolves `deploy` against configured aliases before
falling through to a `wt-deploy` PATH binary. Built-ins still win (clap
matches before alias dispatch ever runs), and `wt step <name>` keeps
working at runtime — only the docs cut over to the new form.

## Why

`wt deploy` reads better than `wt step deploy`, and aliases as
first-class commands lower friction for using them as everyday
shortcuts.

## Precedence

built-in (clap) → alias (user/project config, merged) → `wt-<name>` PATH
binary → "unrecognized subcommand" error.

User config wins over PATH binaries because aliases are how users
customize wt — same model as git, where `[alias]` entries shadow
`git-foo` externals.

## Navigating the diff

- `src/commands/alias.rs` — refactored `step_alias` to share `run_alias`
with the new `try_alias(name, rest) -> Result<Option<()>>`. Returns
`Ok(None)` when the name isn't a configured alias or when not in a git
repo; propagates config-load errors so a broken `wt.toml` fails loudly
instead of silently turning into "unrecognized subcommand". Argument
parsing is gated on alias-membership, so unrelated args meant for an
external binary don't surface as alias parse errors. New
`alias_names_for_suggestions()` mixes alias names into "did you mean"
hints. `HelpContext` enum lets the help splice annotate "(shadowed by
built-in)" against the right level (top-level builtins for `wt --help`,
step builtins for `wt step --help`). The user-facing "shadow warning"
was removed entirely — under the new model an alias named `commit` runs
fine via `wt commit`, only `wt step commit` is shadowed.
- `src/commands/external.rs` — `handle_external_command` calls
`try_alias` first, then PATH lookup, then unrecognized-subcommand error.
Suggestions include alias names. Non-UTF-8 args bypass alias dispatch
(alias parser requires UTF-8; binary subcommands get raw `OsStr`).
- `src/help.rs` + `src/main.rs` — early-parse pass returns
`Option<HelpContext>`; help splice fires for both `wt --help` and `wt
step --help`.
- `src/completion.rs` — aliases injected at the top level in addition to
`step`.
- `src/cli/mod.rs` — long Aliases section moved out of
`Step::after_long_help` into hand-authored `docs/content/extending.md`.
New sync test `test_top_level_builtins_match_clap` keeps the
`TOP_LEVEL_BUILTINS` constant aligned with the `Cli` enum.

## Tests

3221 tests pass, lints clean. New integration tests:
`test_top_level_alias_dispatch`,
`test_top_level_alias_with_step_builtin_name`,
`test_top_level_alias_did_you_mean`. Removed
`test_step_alias_shadows_builtin_plural` (warning gone). Reframed
`test_step_alias_shadows_builtin` to verify shadow filtering of typo
suggestions instead. Completion tests now isolate user config via
`WORKTRUNK_CONFIG_PATH=/dev/null` — project config isolation is a noted
gap (commented inline).

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

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 14:56:03 -07:00
Maximilian Roos 9d6c5b71d4 fix(step): honor -C and --config in wt step --help (#2176)
`augment_step_help` resolves aliases via `Repository::current()` and
`UserConfig::load()`. Because help ran before `apply_global_options`,
`wt -C other step --help` listed aliases from the process cwd instead of
`other`, and `wt --config custom.toml step --help` ignored `--config`.

Parse globals in a single early pass against the real `Cli` definition
(`cli::build_command().ignore_errors(true)`), apply them, then run help.
The same matches also tell us whether this is `wt step` help (vs a
nested `step promote --help`), so the splice path has no separate arg
scanner and no second declaration of which global flags take values.

Net: −20 lines, one arg-scanning pass, global-flag definitions live only
on `Cli`.

Integration test covers the `-C` path; the shared early parse means
`--config` rides the same code.

> _This was written by Claude Code on behalf of Maximilian_

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-12 23:29:11 -07:00
Maximilian Roos 56b00c8496 fix(help): silence user-config side effects on wt step --help (#2179)
`wt step --help` called `UserConfig::load()` to render the alias
listing, which triggered `check_and_migrate` side effects: deprecation
warnings to stderr and a `.new` migration file written next to the user
config. Help is an informational surface that should stay quiet.

Extend the existing `suppress_warnings()` latch — already used by shell
completion, picker, and statusline — to also gate `.new` file writes and
the `approved-commands` → `approvals.toml` copy. Call it from
`augment_step_help` before loading config. One latch, one mechanism; no
second loader.

Integration test writes a user config containing the deprecated `{{
main_worktree }}` template variable, runs `wt step --help`, and asserts
stderr is empty and no `.new` file was created.

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-12 23:07:41 -07:00
Maximilian Roos 5da43d1994 fix(alias): make wt step <alias> --dry-run match lazy-expansion runtime (#2170)
## Summary

Recent changes made alias template expansion lazy so that a later step
in a pipeline can read `{{ vars.foo }}` set by an earlier step via git
config. The dry-run path still eagerly expanded every command against
the initial context, so `wt step deploy --dry-run` failed with an
undefined `vars.*` even when `wt step deploy` would succeed.

Mirror the hook dry-run pattern: for templates that reference `vars.*`,
syntax-validate (catching typos like `{{ vars..foo }}`) and show the raw
template; other templates expand eagerly as before. Factor the
parse-only check into a shared `validate_template_syntax` helper used by
both alias and hook lazy paths.

## Test plan

- [x] New test `test_step_alias_dry_run_vars_across_steps` — pipeline
where step 2 reads `{{ vars.target }}` set by step 1 now succeeds on
`--dry-run --yes` and shows the raw template for step 2.
- [x] New test `test_step_alias_dry_run_catches_syntax_error` — `{{
vars..target }}` still fails dry-run with `syntax error` in stderr.
- [x] `cargo run -- hook pre-merge --yes` — all 3158 tests pass, all
lints clean.

> _This was written by Claude Code on behalf of @max-sixty_

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-12 22:08:11 -07:00
Maximilian Roos 101c341673 Prefixed-line output for concurrent foreground commands (#2145)
Concurrent foreground commands (alias tables, and eventually the
pipeline-form concurrent step for pre-* hooks once its deprecation
completes) now stream each child's output prefixed by a colored command
label, via a shared `output::concurrent::run_concurrent_commands`
executor.

## Why

Main's `execute_pipeline_foreground` ran concurrent children via
`std::thread::scope` + `execute_shell_command` with inherited
stdout/stderr. Under load that path is strictly worse than serial:
concurrent writes to the same fd aren't atomic past `PIPE_BUF`, so lines
drop (measured: 12 of 100 000 lost in a two-child 50 000-line test), and
the output is unlabelled, so you can't tell which child said what.

## What

- **New executor** — `src/output/concurrent.rs`. Spawns every child with
piped stdout+stderr, one reader thread per stream, a single-writer drain
on the main thread that prefixes every line with `{label} │ ` (color
rotated through an 8-entry palette, label padded to the widest in the
group). One signal-forwarder thread watches SIGINT/SIGTERM and delivers
graceful-escalation to every child's process group.
- **Rewire** — `execute_pipeline_foreground`'s concurrent branch
(`command_executor.rs`) now dispatches to the new executor when
`concurrent=true` (aliases). Serial callers (hooks — `concurrent=false`
pending the pre-hook table-form deprecation) unchanged.
- **Hardening** — the executor was stress-tested via `/popper` and four
actionable issues were fixed inline:
- Non-UTF-8 child output no longer hangs the reader (switched from
`BufRead::lines()` to byte-oriented `read_until(b'\n')` +
`from_utf8_lossy`). `git diff` on binaries, non-UTF-8 locales, and
raw-byte tools now drain correctly.
- Spawn failure mid-loop kills+reaps every already-spawned child before
propagating (no orphans on e.g. `RLIMIT_NPROC`).
- `signal_hook::Signals` latch is installed before the spawn loop, so a
SIGINT arriving mid-spawn is queued rather than default-killing wt.
- Second SIGINT immediately SIGKILLs every pgid — "user is impatient"
path, skips the graceful escalation that was silently discarding repeat
presses.

## Tests

Alias concurrent path now has five targeted tests covering the hardened
properties end-to-end:

- `test_alias_concurrent_prefixes_output` — per-command `{label} │ …` in
stderr
- `test_alias_concurrent_receives_sigint` — SIGINT forwards to every
child, no orphans after wt exits
- `test_alias_concurrent_large_output` — two 50 000-line children,
asserts 100 000 prefixed occurrences (regression test for the line-loss
that main's path exhibited)
- `test_alias_concurrent_handles_non_utf8` — mid-stream `\xff` followed
by 50 000 lines, asserts every line drains
- Plus the existing `test_alias_concurrent_steps` and
`test_alias_concurrent_step_failure`

Deterministic-ordering tests (e.g. `test_alias_pipeline_announcement`)
still work via the `WORKTRUNK_TEST_SERIAL_CONCURRENT` env var, which the
executor honors internally by spawning sequentially while keeping
prefixed output.

## Scope

- Aliases get the new behavior today.
- Foreground hooks are unchanged (`concurrent=false`) until the pre-hook
table-form deprecation window closes — that flip is queued on a separate
branch.
- Background pipeline runner is unchanged (different output target:
per-command log files).

## Deferred low-severity findings

Documented in `.tmp/iterate-concurrent-tests.md`; none affect
correctness for typical workflows. Covers serial per-child escalation
latency, zombie-in-input-order waiting, `stderr().lock()` held for whole
drain, PID-recycling race window, and `context_json` stdin-write
blocking if payloads ever exceed 64KB.

> _This was written by Claude Code on behalf of @max-sixty_

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-12 19:52:13 -07:00
Maximilian Roos b6ae21d6e1 refactor: prefer raw strings over escaped literals (#2150)
Converts ~29 string literals across 16 files from escaped form
(`"\\d+"`, `"{\"name\": 1}"`) to raw-string form (`r"\d+"`, `r#"{"name":
1}"#`) where possible. Strings containing control escapes (`\n`, `\t`,
`\u{1b}`, etc.) are left alone — raw strings can't represent those.

No behavior change; `cargo check/clippy -D warnings/fmt/test` all clean
locally.

> _This was written by Claude Code on behalf of max-sixty_

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-12 18:19:14 -07:00
Maximilian Roos faab53f1f8 feat(step): show aliases in wt step --help; list user + project separately (#2141)
Follow-ups from #2131, addressing two gaps raised in that review.

## 1. `wt step --help` / `-h` now shows configured aliases

In the original PR, the Aliases section appeared only when running bare
`wt step`, because clap's `DisplayHelp` error (triggered by `--help` /
`-h`) was intercepted in `help.rs` before `step_list()` ran.

Made `StepCommand` required (`arg_required_else_help = true`) so bare
`wt step` triggers the same `DisplayHelp` path. The splice now lives in
`help.rs::maybe_handle_help_with_pager`, scoped to the step subcommand
via a small `is_help_for_step` positional-args scan. `step_list()` and
its help-rendering helper are deleted — one entry point, one splice.

## 2. User + project aliases show as two rows, not a merged summary

Previously, when user and project both defined the same alias name,
`load_aliases_for_listing` called `append_aliases` which merged them via
`merge_append`. The summary for that merged config lost the original
per-source command text (often reduced to `<2 steps>` for unnamed
singles).

Now each source contributes its own row, user first (matching runtime
order — both still run). Rows get `(user)` / `(project)` markers only
when the name appears in both; unique names stay unannotated.

## Testing

Unit test covers the new render behavior (unique names, collision with
two rows). Integration test covers `wt step -h` showing aliases.
Existing `test_step_list_with_aliases` / `test_step_list_no_aliases`
snapshots picked up the new rendering path (clap help →
`md_help::render_markdown_in_help_with_width` instead of direct
`eprint!`); `help_step_long` / `help_step_short` and the generated docs
picked up `[COMMAND]` → `<COMMAND>` from the required-subcommand change.

> _This was written by Claude Code on behalf of max-sixty_

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 17:49:55 -07:00
Maximilian Roos 371528e4cd feat(step): list configured aliases in wt step (#2131)
Previously `wt step` with no subcommand printed clap's auto-help, which
only covered built-in steps (`commit`, `squash`, `push`, …). Aliases
defined in `[aliases]` were invisible there — users had to remember what
they'd configured, or grep their config files.

Now `wt step` renders the same help plus an `Aliases:` section listing
merged user+project aliases. Each entry shows a one-line template
summary (first line with `…` suffix for multi-line bodies; `name; a, b`
form for named pipelines) and a `(shadowed by built-in)` marker in
yellow for names that collide with built-in steps. The section is
spliced in right after Commands — next to the built-ins it extends — by
finding the styled `Options:` heading in clap's rendered output and
inserting before it.

`wt step -h` is unchanged (still pure clap) so reference help stays
stable; the aliases listing is a discovery surface for the no-args case.

### Example

```console
$ wt step
wt step - Run individual operations

Usage: wt step [OPTIONS] [COMMAND]

Commands:
  commit        Stage and commit with LLM-generated message
  squash        Squash commits since branching
  …
  relocate      [experimental] Move worktrees to expected paths

Aliases:
  deploy    make deploy BRANCH={{ branch }}
  port      echo http://localhost:{{ branch | hash_port }}
  squash    this shadows built-in (shadowed by built-in)

Options:
  -h, --help  Print help (see more with '--help')

Global Options:
  -C <path>            Working directory for this command
  …
```

### Notes

- `Commands::Step { action }` moves from `StepCommand` to
`Option<StepCommand>`; the usage line changes from `<COMMAND>` to
`[COMMAND]` (reflected in the updated help snapshots).
- Splice search string is derived from `cli::help_styles().get_header()`
— same source clap uses — so if the header style changes, the search
string moves with it. Snapshot tests catch any behavior drift.
- Outside a repo or with a malformed project config, the aliases section
falls back silently to just listing user-config aliases (or nothing).
This is a discovery surface, not an execution surface, so breaking help
rendering for a bad config seemed wrong.

### Testing

- Two new integration snapshot tests: `test_step_list_with_aliases`,
`test_step_list_no_aliases`.
- Four unit tests for `format_alias_summary` covering single-command,
multi-line, named pipeline, and all-unnamed pipeline cases.
- Updated `help_step_short` / `help_step_long` snapshots for the
`<COMMAND>` → `[COMMAND]` change.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-12 15:41:24 -07:00
Maximilian Roos b174658e29 Split directive file into CD (raw path) and EXEC (shell) files (#2118)
The shell wrapper previously used a single `WORKTRUNK_DIRECTIVE_FILE`
where wt wrote shell commands (`cd '/path'`, arbitrary `--execute`
payloads). This meant the cd path went through shell parsing — any
content wt wrote was sourced as shell.

This splits the protocol into two files with different trust levels:

- **`WORKTRUNK_DIRECTIVE_CD_FILE`** — raw path, read with `cd -- "$(<
file)"`. No shell parsing, no escaping, no injection surface. Safe to
pass through to alias/hook child processes.
- **`WORKTRUNK_DIRECTIVE_EXEC_FILE`** — arbitrary shell (from
`--execute`), sourced by the wrapper. Scrubbed from alias/hook child
environments so hook bodies cannot inject shell into the parent session.

When a nested `wt` inside an alias body tries `--execute` without the
EXEC file, the command is dropped with a warning linking to #2101 for
user feedback.

The old `WORKTRUNK_DIRECTIVE_FILE` is silently honored for one release
(users who upgrade wt without restarting their shell). Bash, zsh, fish,
and PowerShell self-update on restart; nushell requires `wt config shell
install`.

Closes #2101

> _This was written by Claude Code on behalf of @max-sixty_

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-12 13:52:04 -07:00
Maximilian Roos 66458e87e4 refactor(alias): remove lazy/eager template expansion branching (#2110)
The lazy/eager distinction in alias template expansion was unnecessary —
alias expansion always happens at execution time (in
`AliasExecCtx::run`), so `vars.*` references naturally read fresh values
from git config without special handling. The "lazy" path was
round-tripping `context_json` back to the same `HashMap` that was
serialized from `context_map`.

Removes the intermediate `HashMap<&str, &str>`, the `is_pipeline` field,
and the `template_references_var` branching. Both dry-run and execution
paths now route through `expand_shell_template`, making it the canonical
expansion path for all three consumers (hooks, aliases, pipelines).

Follow-up to #2095 and #2103.

> _This was written by Claude Code on behalf of @max-sixty_

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-11 19:39:10 -07:00
Maximilian Roos 7b91a71c37 Show pipeline structure in alias announcements (#2092)
## 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>
2026-04-11 16:36:38 -07:00
Maximilian Roos 5b89ef3b99 feat(alias): support --key=value shorthand for alias variables (#2091)
Aliases previously required `--var key=value` to pass template
variables. Unknown `--key=value` flags are now treated as variable
assignments, so `wt step deploy --env=staging` is equivalent to `--var
env=staging`.

The `=` is required to disambiguate from boolean flags — `--env value`
would be ambiguous (is `value` a positional?), but `--env=value` is
unambiguous. `--var` remains as the escape hatch for variable names that
collide with built-in flags (`--dry-run`, `--yes`).

Implementation is a single fallthrough match arm in
`AliasOptions::parse()` (the alias parser is hand-rolled because aliases
are clap external subcommands). Unit tests cover the shorthand alongside
`--var`, equals-in-value, mixed forms, empty values, and bare `--key`
errors. An integration test confirms end-to-end equivalence with
`--var`.

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-11 14:51:48 -07:00
Maximilian Roos 73b4f8305d Unify hook and alias shell execution (#2089)
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_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
2026-04-11 14:29:34 -07:00
Worktrunk Bot d889738258 fix: pass directive file through wt step aliases (#2077) 2026-04-11 11:24:20 -07:00
Maximilian Roos 79dc6a229a refactor: extract append_aliases helper, add e2e alias test (#1731)
Follow-up to #1727. Two improvements:

1. **DRY**: Extract the repeated
`entry().and_modify(merge_append).or_insert()` pattern into
`append_aliases()` in `config::commands`. Used by all 4 alias merge
sites (`merge_alias_maps`, `aliases()` accessor, `step_alias()`,
`load_aliases_for_completion()`).

2. **Integration test**: `test_alias_append_executes_both` verifies both
user and project aliases actually execute in order — output shows `USER`
then `PROJECT`. Also adds doc comments noting the named-table format
works for aliases (for consistency with hooks, not documented for
users).

> _This was written by Claude Code on behalf of @max-sixty_

Co-authored-by: Claude <noreply@anthropic.com>
2026-03-25 13:38:00 -07:00
Maximilian Roos a1fc38f3e5 feat: use append semantics for alias merging (#1727)
Alias merging now uses append semantics across all layers, matching how
hooks merge:

**Within user config** (global + per-project): both run on name
collision (global first). Uses `CommandConfig::merge_append()`.

**Across configs** (user + project): both run (user first, then
project). Project commands always need approval — the old bypass that
skipped approval when user had the same alias name is removed.

The alias value type changes from `String` to `CommandConfig` — the same
type hooks use. This stores each command separately and enables the
named-table TOML format for multi-command aliases.

Key changes:
- `merge_alias_maps()` and `UserConfig::aliases()` use
`CommandConfig::merge_append()` on collision
- `step_alias()` iterates over `CommandConfig.commands()`, running each
command separately (fail-fast)
- `approve_alias_commands()` approves each project-config command
individually
- `alias_needs_approval()` simplified — always returns project commands
if they exist

> _This was written by Claude Code on behalf of @max-sixty_

Co-authored-by: Claude <noreply@anthropic.com>
2026-03-25 12:58:43 -07:00
Maximilian Roos bf6030b940 fix(step): warn when alias shadows a built-in step command (#1389)
When aliases are configured with names that match built-in `wt step`
subcommands
(e.g., `commit`, `rebase`), they're silently shadowed — clap intercepts
the
command before the alias handler runs. This adds a warning when any
alias
invocation detects shadowed names in the merged config.

Handles singular/plural grammar and bolds alias names for consistency
with
adjacent error messages:
- "Alias **commit** shadows a built-in step command and will never run"
- "Aliases **commit**, **rebase** shadow built-in step commands and will
never run"

> _This was written by Claude Code on behalf of @max-sixty_

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-03-08 19:12:25 -07:00
Maximilian Roos 64c70ef869 feat(alias): add typo suggestions for unknown step commands (#1363)
When the user types an unknown step command or alias name, suggest the
closest match using Jaro similarity (same algorithm and 0.7 threshold as
clap). Checks both built-in step commands and configured aliases.

Also fixes error message styling per output guidelines: bold for names
instead of quotes, no second-person pronouns ("perhaps" instead of "did
you mean").

Examples:
- `wt step comit` → `Unknown step command comit — perhaps commit?`
- `wt step deplyo` (with alias `deploy`) → `Unknown step command deplyo
— perhaps deploy?`
- `wt step zzz` (no close match) → `Unknown alias zzz (available:
deploy, hello)`

> _This was written by Claude Code on behalf of max-sixty_

Co-authored-by: Claude <noreply@anthropic.com>
2026-03-08 01:02:43 -08:00
Maximilian Roos 55bc9188b4 feat(step): add alias command for user-defined command templates (#1348)
## Summary

- Adds `wt step <alias-name>` for running user-defined command templates
configured in `[aliases]` sections of user or project config
- Aliases support the same template variables as hooks (`{{ branch }}`,
`{{ worktree }}`, etc.) plus custom `--var KEY=VALUE` variables
- Project-config aliases require the same command approval flow as
project hooks; user-config aliases are trusted. `--dry-run` skips
approval since it's a read-only preview
- Alias names matching built-in step commands are filtered from the
"available" list in error messages (shadowed by the built-in)
- Introduces `Phase` enum (`Hook(HookType)` | `Alias`) replacing the old
`hook_type` + `phase_override` pattern in the approval system
- Renames `HookCommand` to `ApprovableCommand` since it now covers both
hooks and aliases

## Test plan

- [x] Unit tests for `AliasOptions::parse` (name-only, --dry-run, --yes,
--var, empty key rejection, positional arg rejection)
- [x] Integration tests for alias execution, dry-run (without --yes),
exit code propagation
- [x] Integration tests for shadowing (filtered from available list)
- [x] Integration tests for user/project config merging
- [x] Integration tests for approval flow (project prompts, user skips,
override skips, already-approved, --yes bypass, decline)
- [x] Unit tests for `merge_alias_maps` coverage
- [x] Sync test: `BUILTIN_STEP_COMMANDS` matches actual `StepCommand`
clap variants

> _This was written by Claude Code on behalf of max-sixty_

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-03-07 21:46:41 -08:00