feat(hooks): connect foreground hooks to the terminal - #3129
feat(hooks): connect foreground hooks to the terminal#3129worktrunk-bot wants to merge 20 commits into
Conversation
Foreground (`pre-*`) hooks now inherit the parent's stdin, exactly as
aliases already do, so an interactive child keeps the controlling
terminal — a hook can prompt before continuing (e.g. `gum confirm`
before `mise trust`). Previously every foreground hook had the JSON
context piped to its stdin, which stole the tty and made interactive
hooks impossible.
The lever is the one aliases already use: `sourced_steps_to_foreground`
hard-coded `pipe_stdin = true` for hooks and `false` for aliases. With
both sides now inheriting stdin in the single-step path, the flag was
uniformly false, so the `pipe_stdin` field is removed rather than left
vestigial.
The JSON context is unchanged for the paths that can't be interactive:
concurrent hook groups and background (`post-*`) detached hooks still
receive it on stdin. Template variables (`{{ }}`) reach every hook
regardless of form.
Closes #3093
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
worktrunk-bot
left a comment
There was a problem hiding this comment.
The core change is clean and the docs/tests match it. I traced the two paths the PR claims still receive the JSON context and both hold: concurrent groups build context_jsons and write them to each child's stdin (command_executor.rs → output/concurrent.rs), and the detached post-* pipeline writes context_json in run_pipeline.rs. The foreground single-step path (run_one_command) is the only one that flips to inheriting stdin, which is exactly the pre-* half the issue asked for. The misnamed test_post_start_script_reads_json repoint (it was configured with [pre-start] while living among the post-start tests) is a real correctness improvement — it now exercises the JSON-on-stdin path that actually survives, with wait_for_file_content for the detached write.
One thing that looks unintended: this PR adds .claude/settings.local.json containing {"permissions":{"defaultMode":"bypassPermissions",...},"skipDangerousModePermissionPrompt":true}. That's a local agent-sandbox settings file (the .local.json suffix is the convention for per-machine, gitignore'd settings), and the content is unrelated to the foreground-hooks feature. On main this file is tracked but empty; this PR fills it with bypass-permission flags. Worth dropping it from the commit (and likely adding .claude/settings.local.json to .gitignore) so the feature PR stays scoped to the hook change.
This bypass-permissions agent-sandbox settings file was committed unintentionally and is unrelated to the foreground-hooks change. Remove it from tracking and gitignore it (per the .local.json per-machine convention). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Good catch on the stray One small correction to the review note: on |
|
I'm really not sure about adding the complication around whether or not we pipe JSON in. I think we should probably either remove it completely or not merge this — any votes either way? |
|
My vote: merge as-is, with "remove JSON entirely" as the clean fallback and "don't merge" as the weakest option. Reasoning below — the key fact is that the JSON context carries no data the templates don't. The split isn't a new axis. The JSON-vs-stdin distinction this PR introduces maps exactly onto the foreground/background split that already structures hooks: If you want strict uniformity anyway, "remove it completely" is strictly better than "don't merge" — because the JSON context loses nothing. It's built from the same So my ranking: merge as-is ≥ remove JSON entirely ≫ don't merge. Caveats if you go the "remove entirely" route
|
Merging main brought in the doc-sync mirror mechanism's latest state; regenerate the plugin copies of hook.md and extending.md to match this branch's interactive-foreground-hooks doc updates. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The path was already ignored a few lines above; a9f7bc2 added a second entry for it.
…t JSON The stdin split isn't pre-* vs post-*: a concurrent group pipes each child its own JSON context, so a multi-key [pre-start] table silently takes the terminal away from the `gum confirm` this feature exists for. State the rule that covers all three forms, and warn about the second key.
worktrunk-bot
left a comment
There was a problem hiding this comment.
Two consequences of the stdin change that the PR doesn't cover, plus one leftover. Nothing here re-argues the merge-as-is vs. remove-JSON question above — but the first finding is a third case that rule doesn't cover, surfaced while 8266ac4 was tightening it to cover the second.
wt hook post-* --foreground silently loses the JSON context. --foreground is the debugging mode for background hooks (--foreground Run in foreground (block until complete)), and it routes run_post_hook → run_hooks_foreground → run_one_command, so it now hands the hook the parent's stdin instead of the context. Verified against a build of this branch, same post-start = "cat > capture.txt" hook both times:
$ wt hook post-start --foreground --yes < /dev/null
capture.txt: 0 bytes
$ wt hook post-start --yes < /dev/null # default: background
capture.txt: 634 bytes — {"target":"main","branch":"main","cwd":"…",…}
So a json.load(sys.stdin) hook reads empty under a redirect, and blocks on the terminal waiting for EOF when run from a tty — in the one mode that exists to debug it. The docs added here state the rule by hook type (pre-* vs post-* / concurrent), but the lever is the execution path, and --foreground crosses it. Either the flag keeps piping the context (it's the closest thing to "what the background run does", which is what makes it useful for debugging), or the documented rule needs a third case.
Foreground hooks changed signal shape, and shell_exec's spec still describes the old one. execute_shell_command's no-payload branch is inherit_stdin(), which sets share_parent_pgroup, so foreground hooks moved from the Isolated shape to Shared-tty. The "Process groups and signal handling" module doc in src/shell_exec.rs still lists them under Isolated — "Used for non-interactive children that may fork further subprocesses (hook pipelines, alias steps that read from stdin) — killpg reaches the whole subtree, which a shared-pgroup approach cannot" — and that last clause is exactly what foreground hooks give up: an externally-delivered kill -TERM <wt-pid> during a pre-merge hook is now forwarded single-shot to the hook shell's PID rather than killpg'd across its subtree, and there's no SIGINT → SIGTERM → SIGKILL escalation. Ctrl-C is unaffected (the kernel broadcasts to the shared foreground pgroup). Worth naming in the PR body, and the spec needs correcting either way.
execute_shell_command's stdin_content parameter is now vestigial. In src/output/handlers.rs, the single remaining caller passes a literal None, so the if let Some(content) = stdin_content { cmd = cmd.stdin_bytes(content) } branch is unreachable — the same argument the commit message makes for dropping pipe_stdin rather than leaving it uniformly false.
Smaller note, not this PR
prepare_steps resolves hook contexts with VarScope::All, justified by the JSON contract. A single-step pre-* hook at verbosity 0 no longer has a reader for the variables its templates don't name, and build_hook_context spawns git subprocesses on the wt switch --create path — alias.rs and switch.rs already narrow to VarScope::Referenced for that reason.
Happy to push the shell_exec spec correction and the stdin_content removal onto this branch if you'd rather have them here than as follow-ups.
…spec `wt hook <type> --foreground` routes a post-* hook through the same single-step foreground path, so it inherits the terminal and gets no JSON — verified with a post-start hook capturing 0 bytes there against 1102 in the default detached run. State the rule by execution path rather than hook type. The same move puts every foreground hook and alias step in wt's process group, so the shell_exec spec's Isolated bullet no longer describes them.
With every single foreground step now inheriting stdin, the sole caller passes `None`, so the `stdin_bytes` branch can't be reached. Take the parameter out rather than leave a dead arm — the same argument the `pipe_stdin` removal made. The JSON context never came through here anyway: concurrent groups write a per-child pipe in `output/concurrent.rs`, detached `post-*` pipelines write theirs in `run_pipeline.rs`. Document that on the function, along with what inheriting stdin means for the child's process group.
worktrunk-bot
left a comment
There was a problem hiding this comment.
The two new commits close all three findings from the previous review — I re-verified each. stdin_content is gone with no dead code left behind (PreparedCommand::context_json still has a live caller at the concurrent fan-out in command_executor.rs), the --foreground case is now stated in the hook docs, and the shell_exec Isolated bullet is accurate: for_each.rs is the only remaining forward_signals()-without-inherit_stdin() call site in the tree. Nothing below re-opens the merge-as-is vs. remove-JSON question.
Two more spec-staleness findings of the same class the commits just fixed, plus a test gap.
run_pipeline.rs's module doc still asserts the convention this PR inverted. Its Execution-model section reads "Stdin: every child receives the spec's context as JSON on stdin, matching the foreground hook convention." Foreground hooks are now precisely the thing that doesn't match — the sentence was the cross-reference that made the background behavior legible, and it now points at the opposite rule. It's outside the diff, so no inline suggestion; the fix is to drop the clause or repoint it (e.g. "…as JSON on stdin — the foreground path inherits wt's stdin instead, see output/handlers.rs's execute_shell_command"). Same file, spawn_pipeline_command at the "the same command" comment, is fine as-is.
The Shared-tty bullet's example list doesn't name a real Cmd call site — inline suggestion below. The bullet the commit extended still leads with "Used for interactive TUIs (skim picker, pagers, $EDITOR)", and none of the three go through Cmd: skim is the in-process skim crate (skim::prelude in picker/preview_orchestrator.rs, no child at all), both pagers spawn a bare std::process::Command (help_pager.rs's pipe_through_pager builds ShellConfig::command(...), which returns std::process::Command; picker/pager.rs uses Command::new("sh")), and $EDITOR isn't spawned anywhere — the only match in src/ outside this doc line is an rc-file fixture string. Since inherit_stdin() has exactly one call site (execute_shell_command), foreground hook and alias steps aren't also Shared-tty — after this PR they're the only thing that is, which is a stronger and simpler statement than the one the doc makes.
The same suggestion tightens "every single foreground step", which reads as emphasis ("each and every") rather than as the PreparedStep::Single vs Concurrent distinction it means — and the wrong reading is the one that matters here, since a concurrent group's children get process_group(0) and their own stdin pipe in output/concurrent.rs, i.e. the other shape.
Nothing pins the newly documented --foreground behavior. The docs now commit to "a post-* hook invoked that way gets the terminal and no JSON", which is a behavior change from before this PR, and it's the case the review thread turned on. The existing wt hook post-start --yes --foreground tests in tests/integration_tests/user_hooks.rs assert only that the hook completed synchronously and that its stdout reached the command output — neither would notice the stdin shape flipping back. test_pre_start_inherits_stdin is the right template: same sentinel-through-Stdio::piped() shape, wt hook post-start --foreground instead of wt switch --create.
Smaller notes
hooks.rs's newsourced_steps_to_foregrounddoc says the JSON reaches "concurrent and background (post-*) hooks".handlers.rsandrun_one_commandboth say detachedpost-*, which is the qualifier that makes the sentence survive--foreground; worth matching the three.- No
CHANGELOG.mdentry under## Unreleased. Given this both closes #3093 and breaks the documentedjson.load(sys.stdin)contract forpre-*, it's the kind of entry the release pass is most likely to under-describe if it's derived from the commit subjects alone.
Happy to push any of these onto the branch — though given the open question above, they may be better held until that's settled.
The Shared-tty bullet led with interactive TUIs that don't go through `Cmd` (skim is in-process, both pagers spawn a bare `std::process::Command`, and `$EDITOR` isn't spawned at all), and `run_pipeline.rs` still described its JSON-on-stdin as "matching the foreground hook convention" — the one convention this PR inverted. Also matches `hooks.rs` to the "detached" qualifier `handlers.rs` and `run_one_command` use, which is what makes the sentence survive `wt hook <type> --foreground`.
The existing --foreground tests assert only that the hook ran synchronously and that its stdout reached the command output; neither would notice the stdin shape flipping back to the JSON context.
|
All four items are on the branch — three commits, pushed as f4e84d6.
Verified locally: the five hook-stdin tests pass ( None of this touches the open question above — @max-sixty, that's still the one thing gating this PR, and my vote stands as posted: merge as-is ≥ remove the JSON context entirely ≫ don't merge. If you pick "remove entirely", the changelog entry and the |
worktrunk-bot
left a comment
There was a problem hiding this comment.
Two findings on the three new commits. Neither touches the open merge-as-is vs. remove-JSON question.
--foreground does not hand a concurrent group the terminal, but the changelog and the hook docs say it does. The new ## Interactive hooks and JSON context section closes with "wt hook <type> --foreground runs a hook in the foreground whatever its type, so a post-* hook invoked that way gets the terminal and no JSON", and f4e84d6's changelog entry repeats it as "runs in the foreground whatever the type, so it gets the terminal too". Both are unconditional, and both are wrong for a table with two or more keys: map_to_step in src/config/commands.rs turns a multi-entry map into HookStep::Concurrent, run_post_hook's if foreground.unwrap_or(false) branch hands it to run_hooks_foreground, and execute_pipeline_foreground's PreparedStep::Concurrent arm routes it to run_concurrent_group — which still builds context_jsons and writes one per child. So wt hook post-start --foreground against a two-key [post-start] table gets the JSON, not the terminal. The paragraph does state the concurrent rule two sentences earlier, but "whatever its type" reads as the override, and this is the sentence a user consults when a --foreground debug run behaves unlike the real one. Inline suggestion on the changelog line; the same clause is in src/cli/mod.rs (the after_long_help primary) and its four rendered mirrors, so that half wants an edit plus a doc-sync regen rather than a one-click apply.
Nothing pins the concurrent half of the "JSON survives" claim. The design in both the changelog and the docs rests on two surviving forms — concurrent groups and detached post-* hooks. test_post_start_json_stdin covers the detached one, and a0f534c just closed the --foreground gap, but no test feeds a multi-key hook table and reads what lands on its children's stdin: outside src/output/concurrent.rs itself, context_json appears in the test tree only as a comment in user_hooks.rs (test_user_post_start_pipeline_hook_name_per_step asserts through {{ hook_name }} templates, not stdin), and the sole fixture is a context_json: "{}" in concurrent.rs's own unit test, which asserts nothing about delivery. That matters more after this PR than before it: concurrent is now the only foreground path still piping JSON, one match arm away in the same execute_pipeline_foreground loop as the arm this PR flipped, so a later change that simplifies run_concurrent_group the way this one simplified run_one_command passes green. test_standalone_hook_post_start_foreground_inherits_stdin is the template with the assertion inverted — a two-key [post-start] table (a = "cat > a.json", b = "cat > b.json"), a sentinel written to wt's stdin, then assert each file parses as JSON carrying its own hook_name and that neither contains the sentinel.
Happy to push both onto the branch if you'd rather have them here — though as before they may be better held until the design question above is settled.
Both sides added an Unreleased entry; kept both. Also narrows the `--foreground` claim: a concurrent group's children each get their own JSON pipe whatever the execution path, so only a lone step gets the terminal — pinned by test_standalone_hook_concurrent_group_keeps_json_under_foreground.
worktrunk-bot
left a comment
There was a problem hiding this comment.
Both findings from the previous review are closed on 0dd61e8, verified: the --foreground clause now carves out concurrent groups in CHANGELOG.md, src/cli/mod.rs's after_long_help and all four rendered mirrors (grep for "A concurrent group keeps its JSON either way" hits all six, so test_docs_are_in_sync should be satisfied), and test_standalone_hook_concurrent_group_keeps_json_under_foreground pins the concurrent half — the serde_json::from_str on cap_a.txt fails if the sentinel arrives instead, so it pins both directions of the claim.
One new thing, and it bears on the open question above rather than being a wording nit.
The rule now needs a third case, and "lone" doesn't cover it. A serial pipeline is neither lone nor concurrent, and it lands on the terminal side. execute_pipeline_foreground matches on PreparedStep, and every Single arm — however many of them a pipeline has — goes to run_one_command, which since this PR has no stdin branch left at all: execute_shell_command is unconditionally inherit_stdin(). So post-start = ["cat > a.txt", "cat > b.txt"] under --foreground hands the terminal to both steps and JSON to neither, and the first cat drains wt's stdin so the second sees EOF. The commoner trigger isn't the array form — it's layering: a user [pre-start] and a project [pre-start] merge by appending steps (CommandConfig's "commands are appended, matching how hooks merge across config layers"), so two single-key tables in two different config files produce exactly this shape. Before 0dd61e8 the docs said "whatever its type", which was right for a serial pipeline and wrong for a concurrent group; "lone" fixes the concurrent half and opens this one. Inline suggestion on the changelog line; src/cli/mod.rs carries the same clause and wants an edit plus a doc-sync regen rather than a one-click apply.
Which is the part worth weighing on the merge question: stating this correctly takes three cases keyed on an internal distinction (PreparedStep::Single vs Concurrent) that nothing in the TOML surface names — a second key in one table is a concurrent group, a second table across two config files is not. I voted merge-as-is earlier; on this increment I'd shift toward "remove it completely". Dropping the JSON everywhere collapses the rule to one sentence with no cases and no way for a config edit to silently move a hook across it, at the cost of the json.load(sys.stdin) contract for post-* — which {{ }} substitution already covers for everything except a hook that wants the whole context as one object. Not my call, but the third case is new evidence and it points that way.
Trace
src/commands/command_executor.rs,execute_pipeline_foreground:for fg_step in steps { match &fg_step.step { PreparedStep::Single(cmd) => run_one_command(...), PreparedStep::Concurrent(cmds) => run_concurrent_group(...) } }— the loop is over steps, so a serial pipeline hits theSinglearm once per step.src/output/handlers.rs,execute_shell_command:cmd = cmd.inherit_stdin();with no surrounding conditional after this PR.src/config/commands.rs,CommandConfig: "Pipeline:post-start = ["cmd", { a = "cmd1", b = "cmd2" }]→ serial steps";map_to_stepreturnsSinglefor a one-entry map andConcurrentotherwise.append_aliases/merge_append: "On name collision, commands are appended (base first, then additions), matching how hooks merge across config layers."
Code trace, not a run — the path has no branches, and test_pre_start_inherits_stdin already exercises the single-step form of it.
Every foreground step inherits wt's stdin, a serial pipeline's included — so "a lone hook" was wrong in the other direction from "whatever its type". The steps also share that stdin: `post-start = ["cat > a.txt", "cat > b.txt"]` under --foreground gives the first the sentinel and the second EOF, which config layering reaches without an array, since a user and a project hook of the same type form one pipeline.
worktrunk-bot
left a comment
There was a problem hiding this comment.
The serial-pipeline case from the previous review is closed on aef32b3 — the docs and changelog both state it, and test_foreground_pipeline_steps_share_one_stdin pins it in both directions (asserting b.txt is empty is what makes it a pin rather than just "the first step read something"). Nothing below reopens the merge-as-is vs. remove-JSON question.
One finding on the new prose.
The new sentence is right about pipes and wrong about terminals — and it's the terminal case it's phrased for. docs/content/hook.md (via src/cli/mod.rs's after_long_help, plus the three mirrors) now closes with "a step that reads it to EOF leaves nothing for the steps behind it — only one step in a pipeline can prompt", and CHANGELOG.md compresses that to "only the first to read it sees anything". The leading clause is exact; both summaries aren't. Every foreground step gets Stdio::inherit() of the same descriptor — execute_shell_command ends at cmd = cmd.inherit_stdin(); with no conditional left after this PR — and "EOF is permanent" is a property of a pipe, not of a tty: a read() on a terminal returns the line the user typed and leaves the descriptor readable. So gum confirm in step 1 and gum confirm in step 2 both prompt normally. That's the shape the paragraph is warning about, since the trigger it names — a user [pre-start] and a project [pre-start] merging into one pipeline — is two prompting hooks, not two cats, and a user who reads "only one step can prompt" will restructure config that already worked.
What holds is narrower: a step that drains stdin to EOF (cat, json.load(sys.stdin)) starves the steps behind it, and only when wt's stdin is a pipe or a file — which is exactly what the new test constructs (Stdio::piped() plus cat > a.txt). Under a terminal neither half of the summary is true.
Inline suggestion on the changelog line. src/cli/mod.rs carries the docs half, so that one wants an edit plus a doc-sync regen rather than a one-click apply — replacing the — only one step in a pipeline can prompt clause with something like "…leaves nothing for the steps behind it when wt's stdin is a pipe or a file; under a terminal each step can prompt in turn" keeps the warning the paragraph is there to give while dropping the part that isn't true of a tty.
Trace
src/commands/command_executor.rs,execute_pipeline_foreground:for fg_step in steps { match &fg_step.step { PreparedStep::Single(cmd) => run_one_command(...), … } }— oneSinglearm per pipeline step, each reachingexecute_shell_command.src/output/handlers.rs,execute_shell_command:cmd = cmd.inherit_stdin();, unconditional.src/shell_exec.rs,Cmd::inherit_stdin:self.stdin_cfg = Some(std::process::Stdio::inherit());— the child receives wt's own stdin descriptor, so the pipe-vs-tty distinction is the kernel's, not wt's.
Code trace plus the POSIX read semantics, not a tty run — the existing pipe-backed tests already cover the half that is true, and the tty half isn't reachable from the integration suite.
"only one step in a pipeline can prompt" is true of a pipe or a file, where EOF is permanent, and false of a terminal, where a read returns the line typed and leaves the descriptor readable. Every foreground step gets an unconditional `Stdio::inherit()` of the same descriptor, so under a tty two `gum confirm` steps both prompt normally. State the condition instead: a step that drains stdin to EOF starves the steps behind it when that stdin is a pipe or a file. The test's own comment made the same overbroad claim about the pipe it constructs.
|
CI status on a521907, for the record: |
max-sixty
left a comment
There was a problem hiding this comment.
The later test and documentation work makes the tradeoff clear: let's take the simpler route and remove JSON-on-stdin for hooks entirely.
Foreground hooks should inherit stdin, detached hooks should keep null stdin, and hook context should come through the existing template variables. That gives the feature in #3093 one stable rule instead of making stdin depend on whether a hook becomes a single step, a serial pipeline, a concurrent group, or a --foreground invocation. Please keep the interactivity tests, remove the JSON delivery plumbing and JSON-specific tests, and describe the documented behavior change in the changelog.
This was written by Codex on behalf of max-sixty
Per the maintainer's review: a foreground hook inherits stdin, a hook that can't hold a terminal reads EOF, and template variables are the only context channel. Removes the JSON delivery plumbing: `PreparedCommand::context_json`, `ConcurrentCommand::context_json` (children now spawn with a closed stdin), `run_pipeline`'s per-step JSON write (`Stdio::null()` instead), and `spawn_detached`'s unused `context_json` parameter along with `build_printf_pipe_command`. Both spawn paths drop `reads_stdin(true)`, so identical (command, context) pairs are duplicates again in the cache report. Tests: the interactivity tests stay; the two JSON-delivery tests are repointed at the new rule (a detached hook and a concurrent child each read EOF), and the post-start script test now takes its context from template arguments.
…active-foreground-hooks # Conflicts: # src/commands/process.rs # src/output/handlers.rs
|
Done — JSON-on-stdin is gone for hooks entirely, pushed as cd5e135 (plus a merge of The rule is now: a hook running in the foreground inherits wt's stdin, anything else reads EOF, and context arrives through template variables in every form. Removed with the delivery plumbing: One place I enforced the rule rather than following it literally: a concurrent group's children get a closed stdin, not the terminal. They run at once and can't share a prompt, and each runs in its own process group — which is what lets wt forward SIGINT to a whole subtree — so a read from the controlling terminal earns SIGTTIN and stops the child instead of returning. That would turn today's immediate EOF into a hang, and handing them the tty would mean rebuilding the concurrent runner's signal model. So the shape the docs state is "foreground single/serial steps get the terminal, everything else reads EOF"; no form receives a payload, which is the part that was making stdin depend on config shape. Happy to revisit if you'd rather they inherit and take the SIGTTIN risk. Tests: the three interactivity tests are unchanged; the JSON-delivery tests are repointed at the new rule ( Local verificationOn the merged tree: The one integration failure is |
worktrunk-bot
left a comment
There was a problem hiding this comment.
897fcc7 answers the open question the way you asked — JSON-on-stdin is gone outright rather than split by config shape. I re-derived the removal independently and it's complete: context_json survives only for wt step for-each, every execute_shell_command / spawn_detached call site is updated, and nothing is left dead behind it. The main merge is clean too — git diff main..cd5e135 is exactly this PR's own changes, and both conflict hunks kept main's HookLog::Internal / HookLog::Shared variants while dropping the None argument.
Two things, neither blocking.
The VarScope::All note I deferred two reviews ago is now this PR's. prepare_steps still resolves the hook context with VarScope::All, and the doc comment immediately above it — which this diff edits — used to justify that with "the child receives the whole context as JSON on stdin … hook pipelines, wt step for-each". The edit narrows the JSON case to wt step for-each alone and leaves hook pipelines resting on the other justification, format_hook_variables. But that table only renders at verbosity() >= 1 (print_background_variable_table sits behind if verbosity() >= 1 in hooks.rs), so at the default verbosity a hook pipeline now resolves All with no reader at all. The cost is real: build_hook_context's scope.wants(…) gates are what stand between a hook and rev-parse --verify, short_sha, primary_worktree(), primary_remote() / remote_url() / upstream() — and default_branch(), which per CLAUDE.md's Network Access rule may fall through to git ls-remote on its first call per repo. So a pre-start hook whose templates name nothing but {{ branch }} can put a fresh clone on the wire.
It's pre-existing cost rather than a regression, and narrowing it isn't free — wt hook show --expanded and the -v table both still want All, so the fix is either a verbosity-dependent scope or a second entry point. But this is the commit that removes the reason it's there, and switch.rs / alias.rs already narrow to Referenced on the same path for the same reason. Worth narrowing here or naming as a follow-up in the PR body, rather than leaving the doc comment carrying a justification that now only holds at -v.
"Everything else" doesn't hold for a multi-key pre-* table — inline comment on the src/cli/mod.rs line.
Smaller note
tests/integration_tests/step_alias.rs, the doc comment on test_alias_inherits_stdin, still ends "Only hooks have a documented JSON-on-stdin contract; aliases must leave stdin alone." Both halves are now false — there is no such contract, and the hooks-vs-aliases distinction the test was written to defend no longer exists. The test itself is still correct and arguably stronger now: its !combined.contains("\"branch\"") assertion is a second pin on the removal. Outside the diff, so no suggestion; happy to push it.
What I verified
On cd5e135 (the merged head):
- Nothing the removal orphaned is left behind:
posix_command_separatorstill has itsspawn_detached_unixcaller,Writeinoutput/concurrent.rsis still used by the twowriteln!sites, andshell_escapeinprocess.rsstill has callers — so the cleanclippyrun is clean for the right reason rather than by suppression. - The three
hook.mdmirrors carry the new## Interactive hookstext, and nothing in the tree links the removed#json-contextanchor.check-docsis green on this head.
On 897fcc7 (the change itself, unaltered by the merge):
context_jsonhas exactly two remaining references insrc/, both incommands/for_each.rs.TemplateContext::to_json's only non-test caller is the same file — theto_jsonhit intesting/mock_commands.rsis a different type.execute_shell_commandhas one caller (run_one_command) andspawn_detachedhas two (spawn_background_removal,sweep_stale_trash); all updated.wait_for_valid_jsonhas no remaining users, so deleting it is right.- The
shell_exec.rsIsolated / Shared-tty bullets are accurate:forward_signals()has exactly two call sites,for_each.rs(Isolated, paired withstdin_bytes) andhandlers.rs(Shared-tty, paired withinherit_stdin), so "the onlyCmdcall site with this pair" holds literally. spawn_detached's behaviour is byte-identical for both callers: withcontext_json: None,build_printf_pipe_commandreturnedcommandunchanged and the Windows PowerShell branch fell through toshell.command(command)— exactly what both paths do now. So dropping that escaping machinery can't reach the removal path.
test (linux|macos|windows), lint, code-coverage and the advisory affected tests jobs are all in flight on cd5e135 as I write this; I haven't seen them land.
Data-safety surface
Flagging per this repo's review guidance (.claude/skills/running-tend/references/review-pr.md § Data-Loss Surface: Hold for Human Review, which holds on a diff that edits a file containing one of the listed destructive operations): the diff edits src/commands/process.rs, which builds the rm -rf trash-sweep command and the staged wt remove command, and src/output/handlers.rs's spawn_background_removal. Nothing widens — both spawn_detached callers already passed None, so the spawned removal command is unchanged — but it's a maintainer call rather than a bot one. Moot for approval here in any case, since this is self-authored.
…ads EOF `Everything else reads EOF` was a set-complement claim, and a multi-key `[pre-start]` table sat in both sets: `map_to_step` returns `Concurrent` for a table with two or more entries regardless of hook type, so it is a `pre-*` hook (covered by the first sentence) whose children read EOF (covered by the second). Fold the concurrent-group carve-out into the covering sentence instead, leaving the serial-pipeline paragraph below untouched.
… it costs Removing the JSON reader took away the hook pipeline's unconditional reader of the full context, leaving `format_hook_variables` — which renders only at `verbosity() >= 1`. So the `All` in `prepare_steps` now buys nothing at the default verbosity while still paying for the git lookups `scope.wants(…)` gates, `default_branch()` among them. Say so where the scope is justified rather than leaving the bullet implying a full-time reader. Also drop the stale hooks-vs-aliases framing from `test_alias_inherits_stdin`: there is no JSON-on-stdin contract for either any more, which makes its `"branch"` assertion a pin on the rule for every foreground step.
worktrunk-bot
left a comment
There was a problem hiding this comment.
The three new commits close the last open finding and the deferred note; nothing new on this increment, and no concerns remain from my side. Self-authored, so this stands in for an approval rather than being one.
- The
Everything elseset-complement (src/cli/mod.rsand its three mirrors) — the suggested carve-out landed verbatim in all four, and the sentence now matches the code:map_to_stepreturnsConcurrentfor a table with two or more entries whatever the hook type, andrun_concurrent_groupspawns each child withStdio::null()(output/concurrent.rs, the "Each child gets a closed stdin" comment). No stale copy of the old sentence is left anywhere in the tree, and no--helpsnapshot carries this text, so the doc-sync regen was the whole obligation. Thread resolved. VarScope::All— the newprepare_stepscomment states the cost rather than paying it, which is the right disposition for this PR. I re-checked the claim it rests on: bothformat_hook_variablescall sites are behindverbosity() >= 1(command_executor.rs's step announcer andhooks.rs'sprint_background_variable_table), so "buys nothing at default verbosity" is literally true.posix_command_separator— the#[cfg(unix)]is correctly scoped: the only caller is theformat!("{{ {}{} }} &", …)line insidespawn_detached_unix, itself#[cfg(unix)], and the unit test is gated to match.
The CHANGELOG.md clause has the same general-claim-then-exception-list shape the docs sentence just lost, but its parenthetical names concurrent-group children explicitly, so I still read it as fine and am not reopening it.
test (linux|macos|windows), code-coverage, and the advisory affected tests jobs are in flight on 77528d9 as I write; lint, check-docs, fast-checks, and feature-check are green. test (windows) is the one that would catch the cfg gating if I've read it wrong.
|
Both notes from the last review are on the branch as 77528d9, and CI has now landed green on that head — Two things it doesn't cover:
@max-sixty this should clear the change request from your review: JSON-on-stdin is gone outright, which was the ask. |
Problem
Foreground hooks couldn't run interactively. Every foreground hook had the JSON context piped to its stdin, which stole the controlling terminal, so a hook like
gum confirm 'trust this worktree?' && mise trustcould never see a TTY. The issue asked for a way to gate a hook behind a confirmation — #3093.Solution
One rule, no exceptions by config shape: a hook running in the foreground inherits wt's stdin; a hook that can't hold a terminal reads EOF; context reaches every hook through template variables.
JSON-on-stdin is gone for hooks entirely. It was the only reason stdin depended on whether a hook was a single step, a serial pipeline, a concurrent group, or a
--foregroundinvocation — and it carried no data the templates don't. A hook that parsed it takes what it needs as arguments instead:wt step for-eachkeeps its JSON context; that's a separate documented feature, not a hook.What was removed
PreparedCommand::context_jsonandTemplateContext's use as a stdin payload for hooks.ConcurrentCommand::context_json— a concurrent child now spawns withStdio::null().run_pipeline's per-step JSON write — a detached step spawns withStdio::null().spawn_detached'scontext_jsonparameter andbuild_printf_pipe_command. Both callers already passedNone, so that whole POSIX/PowerShell escaping path was dead once the detached hook runner stopped being its notional user.reads_stdin(true)on both spawn paths: with nothing piped in, two runs of the same(command, context)pair really are duplicates, sowt config state logs profile's cache report can see them again.One place the rule is enforced rather than followed
A concurrent group's children get a closed stdin, not the terminal. They run at the same time and can't share a prompt, and each runs in its own process group (that's what lets
wtforward SIGINT to a whole subtree), where a read from the controlling terminal earns SIGTTIN and stops the child instead of returning — a hang where the old behavior gave EOF. Handing them the tty would need the concurrent runner's signal model rebuilt. So the observable rule is "foreground single/serial steps get the terminal, everything else reads EOF", and no form receives a payload.Testing
test_pre_start_inherits_stdin,test_standalone_hook_post_start_foreground_inherits_stdin,test_foreground_pipeline_steps_share_one_stdin— unchanged, they pin the interactivity this PR exists for.test_standalone_hook_concurrent_group_gets_no_stdin_under_foreground(was…_keeps_json_under_foreground) — a concurrent child's capture file is empty.test_post_start_detached_hook_gets_no_stdin(wastest_post_start_json_stdin) — a detachedcat > captured.txtwrites zero bytes; an&& echo donemarker makes the empty file waitable.test_post_start_script_reads_template_args(wastest_post_start_script_reads_json) — same detached-script coverage, context now arriving as argv.cargo clippy --all-targets --all-featuresclean,cargo test --lib --bins(2483) green,cargo test --test integration1999/2000 green,cargo fmt --checkclean, doc sync regenerated. The one failure,test_copy_ignored_preserves_file_executable_permissions, reproduces identically on the branch with these changes stashed — it's the CI sandbox'sumask 0002giving 0664 where the test expects 0644.Docs
## Interactive hooksin the hook docs (src/cli/mod.rs, with the rendered mirrors regenerated), the hooks-vs-aliases stdin row inextending.md, and the CHANGELOG entry all state the single rule and the breaking removal. The module specs that described the old convention are repointed:execute_shell_command,run_pipeline's stdin paragraph,sourced_steps_to_foreground,output/concurrent.rs's execution model, andTemplateContext/VarScope, whose "JSON a child reads" now nameswt step for-eachas its one remaining consumer.Follow-up, deliberately not in this PR
prepare_stepsstill resolves the hook context withVarScope::All, and removing the JSON reader removed the unconditional reason it was there. What's left isformat_hook_variables, which renders only atverbosity() >= 1— so at the default verbosity a hook pipeline resolves keys nothing reads, paying for the git lookupsscope.wants(…)gates:rev-parse --verify,short_sha,primary_worktree(),primary_remote()/remote_url()/upstream(), anddefault_branch(), whose first call per repo may fall through togit ls-remote. Apre-starthook whose templates name nothing but{{ branch }}can therefore put a fresh clone on the wire.That cost predates this PR and narrowing it isn't a one-liner —
wt hook show --expandedand the-vtable both still wantAll, so the fix is a scope that varies with verbosity or a second entry point for the listing/preview paths. Either is a behavior change in a hot path, unrelated to stdin, so it belongs in its own PR rather than in the change that removed the reader. Thebuild_hook_contextdoc comment now names the cost and the two fix shapes where the scope is justified, instead of leaving the bullet implying a full-time reader.Closes #3093