From 6f74479017d7c0a4986fcb29a7fa5a8e40faf480 Mon Sep 17 00:00:00 2001 From: worktrunk-bot Date: Fri, 19 Jun 2026 00:52:09 +0000 Subject: [PATCH 01/18] feat(hooks): connect foreground hooks to the terminal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .claude/settings.local.json | 1 + docs/content/extending.md | 2 +- docs/content/hook.md | 15 ++- skills/worktrunk/reference/extending.md | 2 +- skills/worktrunk/reference/hook.md | 15 ++- src/cli/mod.rs | 15 ++- src/commands/command_executor.rs | 17 ++-- src/commands/hooks.rs | 19 ++-- .../integration_tests/post_start_commands.rs | 99 +++++++++---------- 9 files changed, 105 insertions(+), 80 deletions(-) create mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000000..b28ffd45fb --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1 @@ +{"permissions":{"defaultMode":"bypassPermissions","allow":["Bash","Edit","Read","Write","Glob","Grep","WebSearch","WebFetch","Task","Skill"]},"skipDangerousModePermissionPrompt":true} diff --git a/docs/content/extending.md b/docs/content/extending.md index 77349b7665..e671146f5c 100644 --- a/docs/content/extending.md +++ b/docs/content/extending.md @@ -224,7 +224,7 @@ Aside from the differences below, hooks and aliases behave the same. | Force-bind escape | `--var KEY=VALUE` (deprecated in favor of `--KEY=VALUE`, but still force-binds) | None; smart routing is the only path | | `--help` | `wt hook --help` lists hook types; `wt hook --help` shows flags and arguments for that type | The template body is the documentation: `wt --help` redirects to `wt config alias show` / `dry-run`. `wt --help` and `wt step --help` list configured aliases alongside built-in commands | | Inspection | `wt hook show [type] [--expanded]` | `wt config alias show ` / `wt config alias dry-run ` | -| Stdin | All template variables as JSON (parse with `json.load(sys.stdin)`) | Inherits parent stdin (pipes pass through; interactive TUIs like `wt switch` keep the tty) | +| Stdin | Foreground (`pre-*`) inherit parent stdin, same as aliases (interactive prompts work); background (`post-*`) receive all template variables as JSON (parse with `json.load(sys.stdin)`) | Inherits parent stdin (pipes pass through; interactive TUIs like `wt switch` keep the tty) | | Template-context extras | `hook_type`, `hook_name`, per-type operation vars (`base`, `target`, `pr_number`, …) | `args` on top of the shared base variables | diff --git a/docs/content/hook.md b/docs/content/hook.md index af44c25875..30c65a5501 100644 --- a/docs/content/hook.md +++ b/docs/content/hook.md @@ -257,13 +257,20 @@ The `worktree_path_of_branch` function returns the filesystem path of a worktree setup = "cp {{ worktree_path_of_branch('main') }}/config.local {{ worktree_path }}" ``` -## JSON context +## Interactive hooks and JSON context -Hooks receive all template variables as JSON on stdin, enabling complex logic that templates can't express: +Foreground (`pre-*`) hooks run connected to your terminal, so a hook can prompt before continuing — for example, confirming an action before running it: ```toml [pre-start] -setup = "python3 scripts/pre-start-setup.py" +trust = "gum confirm 'trust this worktree?' && mise trust" +``` + +Background (`post-*`) hooks run detached, with no terminal. They receive all template variables as JSON on stdin, enabling complex logic that templates can't express: + +```toml +[post-start] +setup = "python3 scripts/post-start-setup.py" ``` ```python @@ -273,6 +280,8 @@ if ctx['branch'].startswith('feature/') and 'backend' in ctx['repo']: subprocess.run(['make', 'seed-db']) ``` +Every hook also receives its template variables through `{{ }}` substitution, regardless of form. + ## Copying untracked files One specific command worth calling out: [`wt step copy-ignored`](@/step.md#wt-step-copy-ignored). Git worktrees share the repository but not untracked files, and this copies gitignored files between worktrees: diff --git a/skills/worktrunk/reference/extending.md b/skills/worktrunk/reference/extending.md index 101ac86e72..d0d8665275 100644 --- a/skills/worktrunk/reference/extending.md +++ b/skills/worktrunk/reference/extending.md @@ -231,7 +231,7 @@ Aside from the differences below, hooks and aliases behave the same. | Force-bind escape | `--var KEY=VALUE` (deprecated in favor of `--KEY=VALUE`, but still force-binds) | None; smart routing is the only path | | `--help` | `wt hook --help` lists hook types; `wt hook --help` shows flags and arguments for that type | The template body is the documentation: `wt --help` redirects to `wt config alias show` / `dry-run`. `wt --help` and `wt step --help` list configured aliases alongside built-in commands | | Inspection | `wt hook show [type] [--expanded]` | `wt config alias show ` / `wt config alias dry-run ` | -| Stdin | All template variables as JSON (parse with `json.load(sys.stdin)`) | Inherits parent stdin (pipes pass through; interactive TUIs like `wt switch` keep the tty) | +| Stdin | Foreground (`pre-*`) inherit parent stdin, same as aliases (interactive prompts work); background (`post-*`) receive all template variables as JSON (parse with `json.load(sys.stdin)`) | Inherits parent stdin (pipes pass through; interactive TUIs like `wt switch` keep the tty) | | Template-context extras | `hook_type`, `hook_name`, per-type operation vars (`base`, `target`, `pr_number`, …) | `args` on top of the shared base variables | diff --git a/skills/worktrunk/reference/hook.md b/skills/worktrunk/reference/hook.md index f37ed069fa..997b12871b 100644 --- a/skills/worktrunk/reference/hook.md +++ b/skills/worktrunk/reference/hook.md @@ -248,13 +248,20 @@ The `worktree_path_of_branch` function returns the filesystem path of a worktree setup = "cp {{ worktree_path_of_branch('main') }}/config.local {{ worktree_path }}" ``` -## JSON context +## Interactive hooks and JSON context -Hooks receive all template variables as JSON on stdin, enabling complex logic that templates can't express: +Foreground (`pre-*`) hooks run connected to your terminal, so a hook can prompt before continuing — for example, confirming an action before running it: ```toml [pre-start] -setup = "python3 scripts/pre-start-setup.py" +trust = "gum confirm 'trust this worktree?' && mise trust" +``` + +Background (`post-*`) hooks run detached, with no terminal. They receive all template variables as JSON on stdin, enabling complex logic that templates can't express: + +```toml +[post-start] +setup = "python3 scripts/post-start-setup.py" ``` ```python @@ -264,6 +271,8 @@ if ctx['branch'].startswith('feature/') and 'backend' in ctx['repo']: subprocess.run(['make', 'seed-db']) ``` +Every hook also receives its template variables through `{{ }}` substitution, regardless of form. + ## Copying untracked files One specific command worth calling out: [`wt step copy-ignored`](https://worktrunk.dev/step/#wt-step-copy-ignored). Git worktrees share the repository but not untracked files, and this copies gitignored files between worktrees: diff --git a/src/cli/mod.rs b/src/cli/mod.rs index db10fc7629..2b48e1a349 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1533,13 +1533,20 @@ The `worktree_path_of_branch` function returns the filesystem path of a worktree setup = "cp {{ worktree_path_of_branch('main') }}/config.local {{ worktree_path }}" ``` -## JSON context +## Interactive hooks and JSON context -Hooks receive all template variables as JSON on stdin, enabling complex logic that templates can't express: +Foreground (`pre-*`) hooks run connected to your terminal, so a hook can prompt before continuing — for example, confirming an action before running it: ```toml [pre-start] -setup = "python3 scripts/pre-start-setup.py" +trust = "gum confirm 'trust this worktree?' && mise trust" +``` + +Background (`post-*`) hooks run detached, with no terminal. They receive all template variables as JSON on stdin, enabling complex logic that templates can't express: + +```toml +[post-start] +setup = "python3 scripts/post-start-setup.py" ``` ```python @@ -1549,6 +1556,8 @@ if ctx['branch'].startswith('feature/') and 'backend' in ctx['repo']: subprocess.run(['make', 'seed-db']) ``` +Every hook also receives its template variables through `{{ }}` substitution, regardless of form. + ## Copying untracked files One specific command worth calling out: [`wt step copy-ignored`](@/step.md#wt-step-copy-ignored). Git worktrees share the repository but not untracked files, and this copies gitignored files between worktrees: diff --git a/src/commands/command_executor.rs b/src/commands/command_executor.rs index 73ecf96e8c..e3993c087f 100644 --- a/src/commands/command_executor.rs +++ b/src/commands/command_executor.rs @@ -84,7 +84,7 @@ pub type ErrorWrapper = Box) -> any /// Supplied at conversion time (`sourced_steps_to_foreground`) so a single /// `SourcedStep` shape can be produced by both alias and hook resolution. /// Drives the per-step trust model (EXEC passthrough), announce policy, -/// stdin handling, and error wrapping. Hook-only metadata +/// stdout redirection, and error wrapping. Hook-only metadata /// (`hook_type`, `display_path`) lives on the `Hook` variant — it's /// per-pipeline, not per-step, so the per-step shape stays neutral. #[derive(Clone)] @@ -117,10 +117,6 @@ pub struct ForegroundStep { /// line plus the bash gutter; aliases stay silent (the caller emits one /// pipeline summary line). pub announce: PipelineKind, - /// Pipe `context_json` to the child's stdin (hooks); when `false`, inherit - /// the parent's stdin so interactive children keep the controlling tty - /// (aliases). - pub pipe_stdin: bool, /// Merge the child's stdout onto wt's stderr (`true`, hooks) or pass it /// through unchanged (`false`, aliases). Hooks merge so their output stays /// ordered with wt's own stderr "Running …" lines; aliases pass through so @@ -569,15 +565,16 @@ fn run_one_command( }; announce_command(cmd, &fg_step.announce, &command_str); - // Hooks get a documented JSON context on stdin; aliases inherit stdin so - // interactive children (e.g. `wt switch`'s picker) keep their controlling - // terminal. Piping JSON into an interactive alias body steals the tty. - let stdin_json = fg_step.pipe_stdin.then(|| cmd.context_json()); + // Foreground steps inherit the parent's stdin so an interactive child keeps + // its controlling terminal — a `pre-*` hook can prompt (e.g. `gum confirm` + // before `mise trust`), and an alias body's `wt switch` picker keeps the + // tty. The JSON context still reaches concurrent and background (`post-*`) + // hooks, which can't be interactive, via their own stdin pipe. let log_label = fg_step.announce.log_label(cmd); let result = execute_shell_command( wt_path, &command_str, - stdin_json.as_deref(), + None, log_label.as_deref(), directives.clone(), fg_step.redirect_stdout_to_stderr, diff --git a/src/commands/hooks.rs b/src/commands/hooks.rs index 9b9d9078c0..500b2ef529 100644 --- a/src/commands/hooks.rs +++ b/src/commands/hooks.rs @@ -561,10 +561,16 @@ fn spawn_hook_pipeline_quiet(repo: &Repository, pipeline: &PendingPipeline) -> a /// Convert source-tagged steps into foreground steps with pipeline-kind policy. /// /// Shared between hook and alias dispatch. The `kind` argument supplies the -/// per-call-site policy (announce style, stdin handling, error wrapping) while -/// the `source` field on each step drives the per-step trust model +/// per-call-site policy (announce style, stdout redirection, error wrapping) +/// while the `source` field on each step drives the per-step trust model /// (`DirectivePassthrough`). /// +/// Foreground steps — hook and alias alike — inherit the parent's stdin so an +/// interactive child keeps the controlling terminal (a `pre-*` hook can prompt; +/// an alias body's `wt switch` picker works). The JSON context still reaches +/// concurrent and background (`post-*`) hooks, which can't be interactive, via +/// their own per-child stdin pipe. +/// /// Trust model: /// - User-source alias steps pass EXEC through. The body lives in the user's /// own config, so a nested `wt switch --execute …` is no different from the @@ -588,16 +594,13 @@ pub(crate) fn sourced_steps_to_foreground( } _ => DirectivePassthrough::inherit_from_env(), }; - let (pipe_stdin, redirect_stdout_to_stderr, error_wrapper) = match kind { - PipelineKind::Hook { hook_type, .. } => { - (true, true, hook_error_wrapper(*hook_type)) - } - PipelineKind::Alias { name } => (false, false, alias_error_wrapper(name.clone())), + let (redirect_stdout_to_stderr, error_wrapper) = match kind { + PipelineKind::Hook { hook_type, .. } => (true, hook_error_wrapper(*hook_type)), + PipelineKind::Alias { name } => (false, alias_error_wrapper(name.clone())), }; ForegroundStep { step: sourced.step, announce: kind.clone(), - pipe_stdin, redirect_stdout_to_stderr, error_wrapper, directives, diff --git a/tests/integration_tests/post_start_commands.rs b/tests/integration_tests/post_start_commands.rs index d93f45e70a..82e0e14690 100644 --- a/tests/integration_tests/post_start_commands.rs +++ b/tests/integration_tests/post_start_commands.rs @@ -548,31 +548,48 @@ approved-commands = [ } #[rstest] -fn test_pre_start_json_stdin(repo: TestRepo) { +fn test_pre_start_inherits_stdin(repo: TestRepo) { use crate::common::wt_command; + use std::io::Write; + use std::process::Stdio; - // Create project config with a command that reads JSON from stdin - // Use cat to capture stdin to a file - repo.write_project_config(r#"pre-start = "cat > context.json""#); + // Foreground (`pre-*`) hooks inherit the parent's stdin — same as aliases — + // so an interactive child keeps the controlling terminal. The legacy + // JSON-context-on-stdin contract no longer applies here (it survives only + // for concurrent and background `post-*` hooks). Capture whatever the hook + // sees on stdin to a file and verify it's the parent's raw stdin, not JSON. + repo.write_project_config(r#"pre-start = "cat > captured.txt""#); repo.commit("Add config"); // Pre-approve the command repo.write_test_approvals( r#"[projects."../origin"] -approved-commands = ["cat > context.json"] +approved-commands = ["cat > captured.txt"] "#, ); - // Create worktree - this should pipe JSON to the hook's stdin + // Create worktree, piping a sentinel to wt's stdin. The pre-start hook + // inherits that stdin, so `cat` captures the sentinel verbatim. let temp_home = TempDir::new().unwrap(); let mut cmd = wt_command(); - cmd.args(["switch", "--create", "feature-json"]) + cmd.args(["switch", "--create", "feature-stdin"]) .current_dir(repo.root_path()) .env("WORKTRUNK_CONFIG_PATH", repo.test_config_path()) - .env("WORKTRUNK_APPROVALS_PATH", repo.test_approvals_path()); + .env("WORKTRUNK_APPROVALS_PATH", repo.test_approvals_path()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); set_temp_home_env(&mut cmd, temp_home.path()); - let output = cmd.output().expect("failed to run wt switch"); + + let mut child = cmd.spawn().expect("failed to spawn wt switch"); + child + .stdin + .take() + .expect("stdin piped") + .write_all(b"sentinel-from-parent-stdin\n") + .expect("failed to write to wt stdin"); + let output = child.wait_with_output().expect("failed to run wt switch"); assert!( output.status.success(), @@ -580,46 +597,26 @@ approved-commands = ["cat > context.json"] String::from_utf8_lossy(&output.stderr) ); - // Find the worktree and read the JSON - let worktree_path = repo.root_path().parent().unwrap().join("repo.feature-json"); - let json_file = worktree_path.join("context.json"); + let worktree_path = repo + .root_path() + .parent() + .unwrap() + .join("repo.feature-stdin"); + let captured = worktree_path.join("captured.txt"); assert!( - json_file.exists(), - "context.json should have been created from stdin" + captured.exists(), + "captured.txt should have been created from the inherited stdin" ); - let contents = fs::read_to_string(&json_file).unwrap(); - - // Parse and verify the JSON contains expected fields - let json: serde_json::Value = serde_json::from_str(&contents) - .unwrap_or_else(|e| panic!("Should be valid JSON: {}\nContents: {}", e, contents)); - - assert!( - json.get("repo").is_some(), - "JSON should contain 'repo' field" - ); - assert!( - json.get("branch").is_some(), - "JSON should contain 'branch' field" - ); + let contents = fs::read_to_string(&captured).unwrap(); assert_eq!( - json["branch"].as_str(), - Some("feature-json"), - "Branch should be sanitized (feature-json)" + contents, "sentinel-from-parent-stdin\n", + "Foreground hook should receive the parent's raw stdin, not the JSON context" ); assert!( - json.get("worktree").is_some(), - "JSON should contain 'worktree' field" - ); - assert!( - json.get("repo_root").is_some(), - "JSON should contain 'repo_root' field" - ); - assert_eq!( - json["hook_type"].as_str(), - Some("pre-start"), - "JSON should contain hook_type" + !contents.contains("\"branch\""), + "The JSON context must not be piped to a foreground hook: {contents}" ); } @@ -654,9 +651,12 @@ with open('hook_output.txt', 'w') as f: fs::write(&script_path, script_content).unwrap(); fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755)).unwrap(); - // Create project config that runs the script + // Create project config that runs the script. Background (`post-*`) hooks + // run detached and still receive the JSON context on stdin — that's the + // path this script exercises. (Foreground `pre-*` hooks now inherit the + // terminal instead; see `test_pre_start_inherits_stdin`.) repo.write_project_config( - r#"[pre-start] + r#"[post-start] setup = "./scripts/setup.py" "#, ); @@ -686,18 +686,15 @@ approved-commands = ["./scripts/setup.py"] String::from_utf8_lossy(&output.stderr) ); - // Find the worktree and verify the script wrote the expected output + // Find the worktree and verify the script wrote the expected output. The + // hook is detached, so poll until it finishes writing. let worktree_path = repo .root_path() .parent() .unwrap() .join("repo.feature-script"); let output_file = worktree_path.join("hook_output.txt"); - - assert!( - output_file.exists(), - "Script should have created hook_output.txt" - ); + wait_for_file_content(&output_file); let contents = fs::read_to_string(&output_file).unwrap(); assert!( @@ -711,7 +708,7 @@ approved-commands = ["./scripts/setup.py"] contents ); assert!( - contents.contains("hook_type=pre-start"), + contents.contains("hook_type=post-start"), "Output should contain hook_type: {}", contents ); From a9f7bc22ee7408abbe8dd6f40af40cf512edd932 Mon Sep 17 00:00:00 2001 From: worktrunk-bot Date: Fri, 19 Jun 2026 01:00:27 +0000 Subject: [PATCH 02/18] chore: drop accidentally-committed .claude/settings.local.json 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 --- .claude/settings.local.json | 1 - .gitignore | 3 +++ 2 files changed, 3 insertions(+), 1 deletion(-) delete mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index b28ffd45fb..0000000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1 +0,0 @@ -{"permissions":{"defaultMode":"bypassPermissions","allow":["Bash","Edit","Read","Write","Glob","Grep","WebSearch","WebFetch","Task","Skill"]},"skipDangerousModePermissionPrompt":true} diff --git a/.gitignore b/.gitignore index bf6d9744fe..dcd93c8abf 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,9 @@ test-concurrent.sh logo-variant-*.png test-results/ +# Local per-machine agent settings (gitignore'd by convention) +.claude/settings.local.json + # Nix build outputs /result /result-* From f0581ee67c3486fc085b29020cd3c9ec7fc4ba15 Mon Sep 17 00:00:00 2001 From: Maximilian Roos Date: Wed, 22 Jul 2026 18:45:15 -0700 Subject: [PATCH 03/18] docs: regenerate plugin skills mirror after merge 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 --- .../skills/worktrunk/reference/extending.md | 2 +- .../worktrunk/skills/worktrunk/reference/hook.md | 15 ++++++++++++--- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/plugins/worktrunk/skills/worktrunk/reference/extending.md b/plugins/worktrunk/skills/worktrunk/reference/extending.md index 4dfa23a66f..48573eb8b0 100644 --- a/plugins/worktrunk/skills/worktrunk/reference/extending.md +++ b/plugins/worktrunk/skills/worktrunk/reference/extending.md @@ -224,7 +224,7 @@ Aside from the differences below, hooks and aliases behave the same. | Force-bind escape | `--var KEY=VALUE` (deprecated in favor of `--KEY=VALUE`, but still force-binds) | None; smart routing is the only path | | `--help` | `wt hook --help` lists hook types; `wt hook --help` shows flags and arguments for that type | The template body is the documentation: `wt --help` redirects to `wt config alias show` / `dry-run`. `wt --help` and `wt step --help` list configured aliases alongside built-in commands | | Inspection | `wt hook show [type] [--expanded]` | `wt config alias show ` / `wt config alias dry-run ` | -| Stdin | All template variables as JSON (parse with `json.load(sys.stdin)`) | Inherits parent stdin (pipes pass through; interactive TUIs like `wt switch` keep the tty) | +| Stdin | Foreground (`pre-*`) inherit parent stdin, same as aliases (interactive prompts work); background (`post-*`) receive all template variables as JSON (parse with `json.load(sys.stdin)`) | Inherits parent stdin (pipes pass through; interactive TUIs like `wt switch` keep the tty) | | Template-context extras | `hook_type`, `hook_name`, per-type operation vars (`base`, `target`, `pr_number`, …) | `args` on top of the shared base variables | diff --git a/plugins/worktrunk/skills/worktrunk/reference/hook.md b/plugins/worktrunk/skills/worktrunk/reference/hook.md index 9dc5e9ff0c..88c9a0268f 100644 --- a/plugins/worktrunk/skills/worktrunk/reference/hook.md +++ b/plugins/worktrunk/skills/worktrunk/reference/hook.md @@ -249,13 +249,20 @@ The `worktree_path_of_branch` function returns the filesystem path of a worktree setup = "cp {{ worktree_path_of_branch('main') }}/config.local {{ worktree_path }}" ``` -## JSON context +## Interactive hooks and JSON context -Hooks receive all template variables as JSON on stdin, enabling complex logic that templates can't express: +Foreground (`pre-*`) hooks run connected to your terminal, so a hook can prompt before continuing — for example, confirming an action before running it: ```toml [pre-start] -setup = "python3 scripts/pre-start-setup.py" +trust = "gum confirm 'trust this worktree?' && mise trust" +``` + +Background (`post-*`) hooks run detached, with no terminal. They receive all template variables as JSON on stdin, enabling complex logic that templates can't express: + +```toml +[post-start] +setup = "python3 scripts/post-start-setup.py" ``` ```python @@ -265,6 +272,8 @@ if ctx['branch'].startswith('feature/') and 'backend' in ctx['repo']: subprocess.run(['make', 'seed-db']) ``` +Every hook also receives its template variables through `{{ }}` substitution, regardless of form. + ## Copying untracked files One specific command worth calling out: [`wt step copy-ignored`](https://worktrunk.dev/step/#wt-step-copy-ignored). Git worktrees share the repository but not untracked files, and this copies gitignored files between worktrees: From 42e70fb12de5fdc6b5cf88e09c859a6cf16cea5f Mon Sep 17 00:00:00 2001 From: Maximilian Roos Date: Fri, 14 Aug 2026 01:18:30 -0700 Subject: [PATCH 04/18] chore: drop the duplicate .claude/settings.local.json ignore entry The path was already ignored a few lines above; a9f7bc22e added a second entry for it. --- .gitignore | 3 --- 1 file changed, 3 deletions(-) diff --git a/.gitignore b/.gitignore index 3348743a0d..0fa10eebef 100644 --- a/.gitignore +++ b/.gitignore @@ -17,9 +17,6 @@ test-concurrent.sh logo-variant-*.png test-results/ -# Local per-machine agent settings (gitignore'd by convention) -.claude/settings.local.json - # Nix build outputs /result /result-* From 8266ac479999052243b821967dcf7debf00fa0aa Mon Sep 17 00:00:00 2001 From: Maximilian Roos Date: Fri, 14 Aug 2026 01:44:44 -0700 Subject: [PATCH 05/18] docs(hook): name the forms that get the terminal and the ones that get 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. --- docs/content/extending.md | 2 +- docs/content/hook.md | 8 ++++++-- plugins/worktrunk/skills/worktrunk/reference/extending.md | 2 +- plugins/worktrunk/skills/worktrunk/reference/hook.md | 8 ++++++-- skills/worktrunk/reference/extending.md | 2 +- skills/worktrunk/reference/hook.md | 8 ++++++-- src/cli/mod.rs | 8 ++++++-- 7 files changed, 27 insertions(+), 11 deletions(-) diff --git a/docs/content/extending.md b/docs/content/extending.md index 595eaa25aa..1ef274ee24 100644 --- a/docs/content/extending.md +++ b/docs/content/extending.md @@ -217,7 +217,7 @@ Aside from the differences below, hooks and aliases behave the same. | Force-bind escape | `--var KEY=VALUE` (deprecated in favor of `--KEY=VALUE`, but still force-binds) | None; smart routing is the only path | | `--help` | `wt hook --help` lists hook types; `wt hook --help` shows flags and arguments for that type | The template body is the documentation: `wt --help` redirects to `wt config alias show` / `dry-run`. `wt --help` and `wt step --help` list configured aliases alongside built-in commands | | Inspection | `wt hook show [type] [--expanded]` | `wt config alias show ` / `wt config alias dry-run ` | -| Stdin | Foreground (`pre-*`) inherit parent stdin, same as aliases (interactive prompts work); background (`post-*`) receive all template variables as JSON (parse with `json.load(sys.stdin)`) | Inherits parent stdin (pipes pass through; interactive TUIs like `wt switch` keep the tty) | +| Stdin | A `pre-*` hook running on its own in the foreground inherits parent stdin, same as aliases (interactive prompts work); background (`post-*`) hooks and concurrent groups receive all template variables as JSON (parse with `json.load(sys.stdin)`) | Inherits parent stdin (pipes pass through; interactive TUIs like `wt switch` keep the tty) | | Template-context extras | `hook_type`, `hook_name`, per-type operation vars (`base`, `target`, `pr_number`, …) | `args` on top of the shared base variables | diff --git a/docs/content/hook.md b/docs/content/hook.md index 2aba2886a9..6ac2d047c4 100644 --- a/docs/content/hook.md +++ b/docs/content/hook.md @@ -261,14 +261,18 @@ setup = "cp {{ worktree_path_of_branch('main') }}/config.local {{ worktree_path ## Interactive hooks and JSON context -Foreground (`pre-*`) hooks run connected to your terminal, so a hook can prompt before continuing — for example, confirming an action before running it: +A hook's stdin carries the terminal when the hook can prompt, and the JSON context when it can't. + +A `pre-*` hook that runs on its own in the foreground gets the terminal, so it can ask before continuing: ```toml [pre-start] trust = "gum confirm 'trust this worktree?' && mise trust" ``` -Background (`post-*`) hooks run detached, with no terminal. They receive all template variables as JSON on stdin, enabling complex logic that templates can't express: +Two other forms can't prompt, and receive all template variables as JSON on stdin instead: background (`post-*`) hooks, which run detached with no terminal, and concurrent groups, whose children would otherwise race for one terminal. Adding a second key to the table above turns it into a concurrent group, which is enough to take the terminal away from `gum confirm` — keep a hook that prompts in a table of its own. + +The JSON context enables logic that templates can't express: ```toml [post-start] diff --git a/plugins/worktrunk/skills/worktrunk/reference/extending.md b/plugins/worktrunk/skills/worktrunk/reference/extending.md index 48573eb8b0..d2d49f27cf 100644 --- a/plugins/worktrunk/skills/worktrunk/reference/extending.md +++ b/plugins/worktrunk/skills/worktrunk/reference/extending.md @@ -224,7 +224,7 @@ Aside from the differences below, hooks and aliases behave the same. | Force-bind escape | `--var KEY=VALUE` (deprecated in favor of `--KEY=VALUE`, but still force-binds) | None; smart routing is the only path | | `--help` | `wt hook --help` lists hook types; `wt hook --help` shows flags and arguments for that type | The template body is the documentation: `wt --help` redirects to `wt config alias show` / `dry-run`. `wt --help` and `wt step --help` list configured aliases alongside built-in commands | | Inspection | `wt hook show [type] [--expanded]` | `wt config alias show ` / `wt config alias dry-run ` | -| Stdin | Foreground (`pre-*`) inherit parent stdin, same as aliases (interactive prompts work); background (`post-*`) receive all template variables as JSON (parse with `json.load(sys.stdin)`) | Inherits parent stdin (pipes pass through; interactive TUIs like `wt switch` keep the tty) | +| Stdin | A `pre-*` hook running on its own in the foreground inherits parent stdin, same as aliases (interactive prompts work); background (`post-*`) hooks and concurrent groups receive all template variables as JSON (parse with `json.load(sys.stdin)`) | Inherits parent stdin (pipes pass through; interactive TUIs like `wt switch` keep the tty) | | Template-context extras | `hook_type`, `hook_name`, per-type operation vars (`base`, `target`, `pr_number`, …) | `args` on top of the shared base variables | diff --git a/plugins/worktrunk/skills/worktrunk/reference/hook.md b/plugins/worktrunk/skills/worktrunk/reference/hook.md index 8959f221a4..138ea62b8a 100644 --- a/plugins/worktrunk/skills/worktrunk/reference/hook.md +++ b/plugins/worktrunk/skills/worktrunk/reference/hook.md @@ -252,14 +252,18 @@ setup = "cp {{ worktree_path_of_branch('main') }}/config.local {{ worktree_path ## Interactive hooks and JSON context -Foreground (`pre-*`) hooks run connected to your terminal, so a hook can prompt before continuing — for example, confirming an action before running it: +A hook's stdin carries the terminal when the hook can prompt, and the JSON context when it can't. + +A `pre-*` hook that runs on its own in the foreground gets the terminal, so it can ask before continuing: ```toml [pre-start] trust = "gum confirm 'trust this worktree?' && mise trust" ``` -Background (`post-*`) hooks run detached, with no terminal. They receive all template variables as JSON on stdin, enabling complex logic that templates can't express: +Two other forms can't prompt, and receive all template variables as JSON on stdin instead: background (`post-*`) hooks, which run detached with no terminal, and concurrent groups, whose children would otherwise race for one terminal. Adding a second key to the table above turns it into a concurrent group, which is enough to take the terminal away from `gum confirm` — keep a hook that prompts in a table of its own. + +The JSON context enables logic that templates can't express: ```toml [post-start] diff --git a/skills/worktrunk/reference/extending.md b/skills/worktrunk/reference/extending.md index 48573eb8b0..d2d49f27cf 100644 --- a/skills/worktrunk/reference/extending.md +++ b/skills/worktrunk/reference/extending.md @@ -224,7 +224,7 @@ Aside from the differences below, hooks and aliases behave the same. | Force-bind escape | `--var KEY=VALUE` (deprecated in favor of `--KEY=VALUE`, but still force-binds) | None; smart routing is the only path | | `--help` | `wt hook --help` lists hook types; `wt hook --help` shows flags and arguments for that type | The template body is the documentation: `wt --help` redirects to `wt config alias show` / `dry-run`. `wt --help` and `wt step --help` list configured aliases alongside built-in commands | | Inspection | `wt hook show [type] [--expanded]` | `wt config alias show ` / `wt config alias dry-run ` | -| Stdin | Foreground (`pre-*`) inherit parent stdin, same as aliases (interactive prompts work); background (`post-*`) receive all template variables as JSON (parse with `json.load(sys.stdin)`) | Inherits parent stdin (pipes pass through; interactive TUIs like `wt switch` keep the tty) | +| Stdin | A `pre-*` hook running on its own in the foreground inherits parent stdin, same as aliases (interactive prompts work); background (`post-*`) hooks and concurrent groups receive all template variables as JSON (parse with `json.load(sys.stdin)`) | Inherits parent stdin (pipes pass through; interactive TUIs like `wt switch` keep the tty) | | Template-context extras | `hook_type`, `hook_name`, per-type operation vars (`base`, `target`, `pr_number`, …) | `args` on top of the shared base variables | diff --git a/skills/worktrunk/reference/hook.md b/skills/worktrunk/reference/hook.md index 8959f221a4..138ea62b8a 100644 --- a/skills/worktrunk/reference/hook.md +++ b/skills/worktrunk/reference/hook.md @@ -252,14 +252,18 @@ setup = "cp {{ worktree_path_of_branch('main') }}/config.local {{ worktree_path ## Interactive hooks and JSON context -Foreground (`pre-*`) hooks run connected to your terminal, so a hook can prompt before continuing — for example, confirming an action before running it: +A hook's stdin carries the terminal when the hook can prompt, and the JSON context when it can't. + +A `pre-*` hook that runs on its own in the foreground gets the terminal, so it can ask before continuing: ```toml [pre-start] trust = "gum confirm 'trust this worktree?' && mise trust" ``` -Background (`post-*`) hooks run detached, with no terminal. They receive all template variables as JSON on stdin, enabling complex logic that templates can't express: +Two other forms can't prompt, and receive all template variables as JSON on stdin instead: background (`post-*`) hooks, which run detached with no terminal, and concurrent groups, whose children would otherwise race for one terminal. Adding a second key to the table above turns it into a concurrent group, which is enough to take the terminal away from `gum confirm` — keep a hook that prompts in a table of its own. + +The JSON context enables logic that templates can't express: ```toml [post-start] diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 750269d9b8..e114f14335 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1795,14 +1795,18 @@ setup = "cp {{ worktree_path_of_branch('main') }}/config.local {{ worktree_path ## Interactive hooks and JSON context -Foreground (`pre-*`) hooks run connected to your terminal, so a hook can prompt before continuing — for example, confirming an action before running it: +A hook's stdin carries the terminal when the hook can prompt, and the JSON context when it can't. + +A `pre-*` hook that runs on its own in the foreground gets the terminal, so it can ask before continuing: ```toml [pre-start] trust = "gum confirm 'trust this worktree?' && mise trust" ``` -Background (`post-*`) hooks run detached, with no terminal. They receive all template variables as JSON on stdin, enabling complex logic that templates can't express: +Two other forms can't prompt, and receive all template variables as JSON on stdin instead: background (`post-*`) hooks, which run detached with no terminal, and concurrent groups, whose children would otherwise race for one terminal. Adding a second key to the table above turns it into a concurrent group, which is enough to take the terminal away from `gum confirm` — keep a hook that prompts in a table of its own. + +The JSON context enables logic that templates can't express: ```toml [post-start] From 5f0dae1a8b040759a7b46b6960597888f4c57619 Mon Sep 17 00:00:00 2001 From: Maximilian Roos Date: Fri, 14 Aug 2026 02:07:17 -0700 Subject: [PATCH 06/18] docs: cover `--foreground` in the stdin rule, and refresh the pgroup spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `wt hook --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. --- docs/content/extending.md | 2 +- docs/content/hook.md | 6 +++--- .../worktrunk/skills/worktrunk/reference/extending.md | 2 +- plugins/worktrunk/skills/worktrunk/reference/hook.md | 6 +++--- skills/worktrunk/reference/extending.md | 2 +- skills/worktrunk/reference/hook.md | 6 +++--- src/cli/mod.rs | 6 +++--- src/shell_exec.rs | 11 ++++++++--- 8 files changed, 23 insertions(+), 18 deletions(-) diff --git a/docs/content/extending.md b/docs/content/extending.md index 1ef274ee24..30d84653db 100644 --- a/docs/content/extending.md +++ b/docs/content/extending.md @@ -217,7 +217,7 @@ Aside from the differences below, hooks and aliases behave the same. | Force-bind escape | `--var KEY=VALUE` (deprecated in favor of `--KEY=VALUE`, but still force-binds) | None; smart routing is the only path | | `--help` | `wt hook --help` lists hook types; `wt hook --help` shows flags and arguments for that type | The template body is the documentation: `wt --help` redirects to `wt config alias show` / `dry-run`. `wt --help` and `wt step --help` list configured aliases alongside built-in commands | | Inspection | `wt hook show [type] [--expanded]` | `wt config alias show ` / `wt config alias dry-run ` | -| Stdin | A `pre-*` hook running on its own in the foreground inherits parent stdin, same as aliases (interactive prompts work); background (`post-*`) hooks and concurrent groups receive all template variables as JSON (parse with `json.load(sys.stdin)`) | Inherits parent stdin (pipes pass through; interactive TUIs like `wt switch` keep the tty) | +| Stdin | A hook running alone in the foreground inherits parent stdin, same as aliases (interactive prompts work) — `pre-*` hooks, and any type under `wt hook --foreground`; detached `post-*` hooks and concurrent groups receive all template variables as JSON (parse with `json.load(sys.stdin)`) | Inherits parent stdin (pipes pass through; interactive TUIs like `wt switch` keep the tty) | | Template-context extras | `hook_type`, `hook_name`, per-type operation vars (`base`, `target`, `pr_number`, …) | `args` on top of the shared base variables | diff --git a/docs/content/hook.md b/docs/content/hook.md index 6ac2d047c4..d95ff7873c 100644 --- a/docs/content/hook.md +++ b/docs/content/hook.md @@ -261,16 +261,16 @@ setup = "cp {{ worktree_path_of_branch('main') }}/config.local {{ worktree_path ## Interactive hooks and JSON context -A hook's stdin carries the terminal when the hook can prompt, and the JSON context when it can't. +A hook's stdin carries the terminal when the hook runs alone in the foreground, and the JSON context otherwise. -A `pre-*` hook that runs on its own in the foreground gets the terminal, so it can ask before continuing: +A `pre-*` hook gets the terminal, so it can ask before continuing: ```toml [pre-start] trust = "gum confirm 'trust this worktree?' && mise trust" ``` -Two other forms can't prompt, and receive all template variables as JSON on stdin instead: background (`post-*`) hooks, which run detached with no terminal, and concurrent groups, whose children would otherwise race for one terminal. Adding a second key to the table above turns it into a concurrent group, which is enough to take the terminal away from `gum confirm` — keep a hook that prompts in a table of its own. +Two forms receive all template variables as JSON on stdin instead: `post-*` hooks, which run detached with no terminal, and concurrent groups, whose children would otherwise race for one terminal. Adding a second key to the table above turns it into a concurrent group, which is enough to take the terminal away from `gum confirm` — keep a hook that prompts in a table of its own. `wt hook --foreground` runs a hook in the foreground whatever its type, so a `post-*` hook invoked that way gets the terminal and no JSON. The JSON context enables logic that templates can't express: diff --git a/plugins/worktrunk/skills/worktrunk/reference/extending.md b/plugins/worktrunk/skills/worktrunk/reference/extending.md index d2d49f27cf..285aeb9ca4 100644 --- a/plugins/worktrunk/skills/worktrunk/reference/extending.md +++ b/plugins/worktrunk/skills/worktrunk/reference/extending.md @@ -224,7 +224,7 @@ Aside from the differences below, hooks and aliases behave the same. | Force-bind escape | `--var KEY=VALUE` (deprecated in favor of `--KEY=VALUE`, but still force-binds) | None; smart routing is the only path | | `--help` | `wt hook --help` lists hook types; `wt hook --help` shows flags and arguments for that type | The template body is the documentation: `wt --help` redirects to `wt config alias show` / `dry-run`. `wt --help` and `wt step --help` list configured aliases alongside built-in commands | | Inspection | `wt hook show [type] [--expanded]` | `wt config alias show ` / `wt config alias dry-run ` | -| Stdin | A `pre-*` hook running on its own in the foreground inherits parent stdin, same as aliases (interactive prompts work); background (`post-*`) hooks and concurrent groups receive all template variables as JSON (parse with `json.load(sys.stdin)`) | Inherits parent stdin (pipes pass through; interactive TUIs like `wt switch` keep the tty) | +| Stdin | A hook running alone in the foreground inherits parent stdin, same as aliases (interactive prompts work) — `pre-*` hooks, and any type under `wt hook --foreground`; detached `post-*` hooks and concurrent groups receive all template variables as JSON (parse with `json.load(sys.stdin)`) | Inherits parent stdin (pipes pass through; interactive TUIs like `wt switch` keep the tty) | | Template-context extras | `hook_type`, `hook_name`, per-type operation vars (`base`, `target`, `pr_number`, …) | `args` on top of the shared base variables | diff --git a/plugins/worktrunk/skills/worktrunk/reference/hook.md b/plugins/worktrunk/skills/worktrunk/reference/hook.md index 138ea62b8a..28ece12c6b 100644 --- a/plugins/worktrunk/skills/worktrunk/reference/hook.md +++ b/plugins/worktrunk/skills/worktrunk/reference/hook.md @@ -252,16 +252,16 @@ setup = "cp {{ worktree_path_of_branch('main') }}/config.local {{ worktree_path ## Interactive hooks and JSON context -A hook's stdin carries the terminal when the hook can prompt, and the JSON context when it can't. +A hook's stdin carries the terminal when the hook runs alone in the foreground, and the JSON context otherwise. -A `pre-*` hook that runs on its own in the foreground gets the terminal, so it can ask before continuing: +A `pre-*` hook gets the terminal, so it can ask before continuing: ```toml [pre-start] trust = "gum confirm 'trust this worktree?' && mise trust" ``` -Two other forms can't prompt, and receive all template variables as JSON on stdin instead: background (`post-*`) hooks, which run detached with no terminal, and concurrent groups, whose children would otherwise race for one terminal. Adding a second key to the table above turns it into a concurrent group, which is enough to take the terminal away from `gum confirm` — keep a hook that prompts in a table of its own. +Two forms receive all template variables as JSON on stdin instead: `post-*` hooks, which run detached with no terminal, and concurrent groups, whose children would otherwise race for one terminal. Adding a second key to the table above turns it into a concurrent group, which is enough to take the terminal away from `gum confirm` — keep a hook that prompts in a table of its own. `wt hook --foreground` runs a hook in the foreground whatever its type, so a `post-*` hook invoked that way gets the terminal and no JSON. The JSON context enables logic that templates can't express: diff --git a/skills/worktrunk/reference/extending.md b/skills/worktrunk/reference/extending.md index d2d49f27cf..285aeb9ca4 100644 --- a/skills/worktrunk/reference/extending.md +++ b/skills/worktrunk/reference/extending.md @@ -224,7 +224,7 @@ Aside from the differences below, hooks and aliases behave the same. | Force-bind escape | `--var KEY=VALUE` (deprecated in favor of `--KEY=VALUE`, but still force-binds) | None; smart routing is the only path | | `--help` | `wt hook --help` lists hook types; `wt hook --help` shows flags and arguments for that type | The template body is the documentation: `wt --help` redirects to `wt config alias show` / `dry-run`. `wt --help` and `wt step --help` list configured aliases alongside built-in commands | | Inspection | `wt hook show [type] [--expanded]` | `wt config alias show ` / `wt config alias dry-run ` | -| Stdin | A `pre-*` hook running on its own in the foreground inherits parent stdin, same as aliases (interactive prompts work); background (`post-*`) hooks and concurrent groups receive all template variables as JSON (parse with `json.load(sys.stdin)`) | Inherits parent stdin (pipes pass through; interactive TUIs like `wt switch` keep the tty) | +| Stdin | A hook running alone in the foreground inherits parent stdin, same as aliases (interactive prompts work) — `pre-*` hooks, and any type under `wt hook --foreground`; detached `post-*` hooks and concurrent groups receive all template variables as JSON (parse with `json.load(sys.stdin)`) | Inherits parent stdin (pipes pass through; interactive TUIs like `wt switch` keep the tty) | | Template-context extras | `hook_type`, `hook_name`, per-type operation vars (`base`, `target`, `pr_number`, …) | `args` on top of the shared base variables | diff --git a/skills/worktrunk/reference/hook.md b/skills/worktrunk/reference/hook.md index 138ea62b8a..28ece12c6b 100644 --- a/skills/worktrunk/reference/hook.md +++ b/skills/worktrunk/reference/hook.md @@ -252,16 +252,16 @@ setup = "cp {{ worktree_path_of_branch('main') }}/config.local {{ worktree_path ## Interactive hooks and JSON context -A hook's stdin carries the terminal when the hook can prompt, and the JSON context when it can't. +A hook's stdin carries the terminal when the hook runs alone in the foreground, and the JSON context otherwise. -A `pre-*` hook that runs on its own in the foreground gets the terminal, so it can ask before continuing: +A `pre-*` hook gets the terminal, so it can ask before continuing: ```toml [pre-start] trust = "gum confirm 'trust this worktree?' && mise trust" ``` -Two other forms can't prompt, and receive all template variables as JSON on stdin instead: background (`post-*`) hooks, which run detached with no terminal, and concurrent groups, whose children would otherwise race for one terminal. Adding a second key to the table above turns it into a concurrent group, which is enough to take the terminal away from `gum confirm` — keep a hook that prompts in a table of its own. +Two forms receive all template variables as JSON on stdin instead: `post-*` hooks, which run detached with no terminal, and concurrent groups, whose children would otherwise race for one terminal. Adding a second key to the table above turns it into a concurrent group, which is enough to take the terminal away from `gum confirm` — keep a hook that prompts in a table of its own. `wt hook --foreground` runs a hook in the foreground whatever its type, so a `post-*` hook invoked that way gets the terminal and no JSON. The JSON context enables logic that templates can't express: diff --git a/src/cli/mod.rs b/src/cli/mod.rs index e114f14335..174758a641 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1795,16 +1795,16 @@ setup = "cp {{ worktree_path_of_branch('main') }}/config.local {{ worktree_path ## Interactive hooks and JSON context -A hook's stdin carries the terminal when the hook can prompt, and the JSON context when it can't. +A hook's stdin carries the terminal when the hook runs alone in the foreground, and the JSON context otherwise. -A `pre-*` hook that runs on its own in the foreground gets the terminal, so it can ask before continuing: +A `pre-*` hook gets the terminal, so it can ask before continuing: ```toml [pre-start] trust = "gum confirm 'trust this worktree?' && mise trust" ``` -Two other forms can't prompt, and receive all template variables as JSON on stdin instead: background (`post-*`) hooks, which run detached with no terminal, and concurrent groups, whose children would otherwise race for one terminal. Adding a second key to the table above turns it into a concurrent group, which is enough to take the terminal away from `gum confirm` — keep a hook that prompts in a table of its own. +Two forms receive all template variables as JSON on stdin instead: `post-*` hooks, which run detached with no terminal, and concurrent groups, whose children would otherwise race for one terminal. Adding a second key to the table above turns it into a concurrent group, which is enough to take the terminal away from `gum confirm` — keep a hook that prompts in a table of its own. `wt hook --foreground` runs a hook in the foreground whatever its type, so a `post-*` hook invoked that way gets the terminal and no JSON. The JSON context enables logic that templates can't express: diff --git a/src/shell_exec.rs b/src/shell_exec.rs index bd29e41108..caaf7754d7 100644 --- a/src/shell_exec.rs +++ b/src/shell_exec.rs @@ -16,8 +16,8 @@ //! - **Isolated** (`forward_signals()` alone): the child gets its own process //! group via `process_group(0)`. A signal_hook listener catches SIGINT/ //! SIGTERM in wt and `killpg`s the child group with SIGINT→SIGTERM→SIGKILL -//! escalation. Used for non-interactive children that may fork further -//! subprocesses (hook pipelines, alias steps that read from stdin) — `killpg` +//! escalation. Used for children wt runs on its own behalf across worktrees +//! (`wt step for-each`), which may fork further subprocesses — `killpg` //! reaches the whole subtree, which a shared-pgroup approach cannot. //! //! - **Shared-tty** (`forward_signals().inherit_stdin()`): the child stays in @@ -26,7 +26,12 @@ //! reach the child via the kernel's foreground-pgroup broadcast; the listener //! additionally delivers externally-targeted signals (e.g. `kill -TERM //! `) to the child by PID, single-shot. Used for interactive TUIs -//! (skim picker, pagers, `$EDITOR`). +//! (skim picker, pagers, `$EDITOR`) and for every single foreground step of a +//! hook or alias pipeline, which inherits wt's stdin so the step can prompt. +//! A foreground step's own subtree is therefore not reachable by `killpg`: an +//! externally-targeted signal reaches the step's shell by PID and stops +//! there, while Ctrl-C still reaches the whole subtree through the kernel's +//! broadcast. //! //! In both cases the listener still records `seen_signal`, so a signal-derived //! exit surfaces as `WorktrunkError::ChildProcessExited { signal: Some(_) }` — From dad71a291cf518b4a240bfe18c99ace245bc1db5 Mon Sep 17 00:00:00 2001 From: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com> Date: Fri, 14 Aug 2026 09:14:21 +0000 Subject: [PATCH 07/18] refactor: drop `execute_shell_command`'s unreachable stdin branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/commands/command_executor.rs | 6 +++--- src/output/handlers.rs | 30 +++++++++++++++++++----------- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/src/commands/command_executor.rs b/src/commands/command_executor.rs index a2b43a5eeb..447bfae227 100644 --- a/src/commands/command_executor.rs +++ b/src/commands/command_executor.rs @@ -588,13 +588,13 @@ fn run_one_command( // Foreground steps inherit the parent's stdin so an interactive child keeps // its controlling terminal — a `pre-*` hook can prompt (e.g. `gum confirm` // before `mise trust`), and an alias body's `wt switch` picker keeps the - // tty. The JSON context still reaches concurrent and background (`post-*`) - // hooks, which can't be interactive, via their own stdin pipe. + // tty. The JSON context still reaches concurrent groups and detached + // (`post-*`) hooks, which can't be interactive, via their own stdin pipe; + // `execute_shell_command` no longer has a branch that pipes it. let log_label = fg_step.announce.log_label(cmd); let result = execute_shell_command( wt_path, &command_str, - None, log_label.as_deref(), directives.clone(), fg_step.redirect_stdout_to_stderr, diff --git a/src/output/handlers.rs b/src/output/handlers.rs index 36148cf119..6b9c331332 100644 --- a/src/output/handlers.rs +++ b/src/output/handlers.rs @@ -2045,6 +2045,21 @@ fn remove_removed_worktree_silently( /// SIGINT/SIGTERM forwarding to child process group, ANSI reset before child /// runs, `Cmd` tracing/logging, and directive file control. /// +/// ## Stdin +/// +/// The child always inherits the parent's stdin, so an interactive body keeps +/// the controlling terminal — a `pre-*` hook can `gum confirm`, an alias body's +/// `wt switch` picker can drive `/dev/tty`. `inherit_stdin()` also keeps the +/// child in wt's process group, which is what makes its `tcsetattr` on +/// `/dev/tty` succeed; see that method's docs for the SIGTTOU rationale, and +/// the "Process groups and signal handling" module docs in +/// [`worktrunk::shell_exec`] for what a shared pgroup costs on teardown. +/// +/// The JSON context reaches the two forms that can't be interactive by other +/// spawn paths, not this one: concurrent groups write a per-child pipe in +/// `output/concurrent.rs`, and detached `post-*` pipelines write theirs in +/// `commands/run_pipeline.rs`. +/// /// ## Directive files /// /// `directives` controls whether the child can write shell-integration @@ -2094,7 +2109,6 @@ fn remove_removed_worktree_silently( pub fn execute_shell_command( working_dir: &std::path::Path, command: &str, - stdin_content: Option<&str>, command_log_label: Option<&str>, directives: DirectivePassthrough, redirect_stdout_to_stderr: bool, @@ -2128,16 +2142,10 @@ pub fn execute_shell_command( cmd = cmd.external(label); } - if let Some(content) = stdin_content { - cmd = cmd.stdin_bytes(content); - } else { - // Inherit the parent's stdin so interactive children (e.g. TUI - // pickers) keep their controlling terminal. `inherit_stdin()` also - // keeps the child in the parent's process group so `tcsetattr` on - // `/dev/tty` succeeds — see the method's doc comment for the - // SIGTTOU rationale. - cmd = cmd.inherit_stdin(); - } + // Inherit the parent's stdin so interactive children (e.g. TUI pickers, + // a `gum confirm` in a hook body) keep their controlling terminal — see + // the "Stdin" section of this function's docs. + cmd = cmd.inherit_stdin(); if let Some(path) = directives.cd_file { cmd = cmd.directive_cd_file(path); From d69c43c6db431b6e05768fe0dd81fcdc4a36a19a Mon Sep 17 00:00:00 2001 From: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com> Date: Fri, 14 Aug 2026 09:33:44 +0000 Subject: [PATCH 08/18] docs: repoint the stdin cross-references this change inverted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --foreground`. --- src/commands/hooks.rs | 4 ++-- src/commands/run_pipeline.rs | 7 ++++--- src/shell_exec.rs | 6 +++--- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/commands/hooks.rs b/src/commands/hooks.rs index 7032c09293..ec890e48e0 100644 --- a/src/commands/hooks.rs +++ b/src/commands/hooks.rs @@ -565,8 +565,8 @@ fn spawn_hook_pipeline_quiet(repo: &Repository, pipeline: &PendingPipeline) -> a /// Foreground steps — hook and alias alike — inherit the parent's stdin so an /// interactive child keeps the controlling terminal (a `pre-*` hook can prompt; /// an alias body's `wt switch` picker works). The JSON context still reaches -/// concurrent and background (`post-*`) hooks, which can't be interactive, via -/// their own per-child stdin pipe. +/// concurrent groups and detached (`post-*`) hooks, which can't be interactive, +/// via their own per-child stdin pipe. /// /// Trust model: /// - User-source alias steps pass EXEC through. The body lives in the user's diff --git a/src/commands/run_pipeline.rs b/src/commands/run_pipeline.rs index f9b02efb13..a5b74d1454 100644 --- a/src/commands/run_pipeline.rs +++ b/src/commands/run_pipeline.rs @@ -31,9 +31,10 @@ //! git config, so order matters for `vars.*`), so a later command's expansion //! can run after an earlier command's child has already started. //! -//! **Stdin**: every child receives the spec's context as JSON on stdin, -//! matching the foreground hook convention. Commands that don't read stdin -//! ignore it. +//! **Stdin**: every child receives the spec's context as JSON on stdin. The +//! foreground path inherits wt's stdin instead, so a single step there can +//! prompt (see `execute_shell_command` in `output/handlers.rs`). Commands that +//! don't read stdin ignore it. //! //! ## Template freshness //! diff --git a/src/shell_exec.rs b/src/shell_exec.rs index caaf7754d7..d0baec7814 100644 --- a/src/shell_exec.rs +++ b/src/shell_exec.rs @@ -25,9 +25,9 @@ //! without the kernel raising SIGTTOU. Tty-initiated signals (Ctrl-C, hangup) //! reach the child via the kernel's foreground-pgroup broadcast; the listener //! additionally delivers externally-targeted signals (e.g. `kill -TERM -//! `) to the child by PID, single-shot. Used for interactive TUIs -//! (skim picker, pagers, `$EDITOR`) and for every single foreground step of a -//! hook or alias pipeline, which inherits wt's stdin so the step can prompt. +//! `) to the child by PID, single-shot. Used for every `Single` +//! foreground step of a hook or alias pipeline, which inherits wt's stdin so +//! the step can prompt — since #3129 the only `Cmd` call site with this pair. //! A foreground step's own subtree is therefore not reachable by `killpg`: an //! externally-targeted signal reaches the step's shell by PID and stops //! there, while Ctrl-C still reaches the whole subtree through the kernel's From a0f534c17cb693ba2741b279d34245c1ca11ac95 Mon Sep 17 00:00:00 2001 From: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com> Date: Fri, 14 Aug 2026 09:33:44 +0000 Subject: [PATCH 09/18] test: pin `wt hook post-* --foreground`'s inherited stdin 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. --- tests/integration_tests/user_hooks.rs | 47 +++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/tests/integration_tests/user_hooks.rs b/tests/integration_tests/user_hooks.rs index 56f0ad3e97..64742aeb5f 100644 --- a/tests/integration_tests/user_hooks.rs +++ b/tests/integration_tests/user_hooks.rs @@ -1856,6 +1856,53 @@ fn test_standalone_hook_post_start_foreground(repo: TestRepo) { ); } +#[rstest] +fn test_standalone_hook_post_start_foreground_inherits_stdin(repo: TestRepo) { + use std::io::Write; + use std::process::Stdio; + + // `--foreground` routes a `post-*` hook through the single-step foreground + // path, which inherits wt's stdin rather than piping the JSON context — so + // the hook can prompt in the mode that exists to debug it. (The detached + // default still gets the JSON; see `test_post_start_json_stdin`.) + repo.write_project_config(r#"post-start = "cat > captured.txt""#); + + let mut cmd = crate::common::wt_command(); + cmd.current_dir(repo.root_path()); + cmd.env("WORKTRUNK_CONFIG_PATH", repo.test_config_path()); + cmd.args(["hook", "post-start", "--yes", "--foreground"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let mut child = cmd.spawn().expect("failed to spawn wt hook post-start"); + child + .stdin + .take() + .expect("stdin piped") + .write_all(b"sentinel-from-parent-stdin\n") + .expect("failed to write to wt stdin"); + let output = child.wait_with_output().expect("failed to run wt hook"); + + assert!( + output.status.success(), + "wt hook post-start --foreground should succeed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let captured = repo.root_path().join("captured.txt"); + assert!( + captured.exists(), + "captured.txt should have been created from the inherited stdin" + ); + + let contents = fs::read_to_string(&captured).unwrap(); + assert_eq!( + contents, "sentinel-from-parent-stdin\n", + "`--foreground` should hand the hook the parent's raw stdin, not the JSON context" + ); +} + #[rstest] fn test_standalone_hook_pre_commit(repo: TestRepo) { // Write project config with pre-commit hook From f4e84d6d57dc7eb186104f9885ee7773ab222446 Mon Sep 17 00:00:00 2001 From: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com> Date: Fri, 14 Aug 2026 09:33:44 +0000 Subject: [PATCH 10/18] docs(changelog): add the foreground-hook entry --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bfa983a0b..9f7635f5ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Improved +- **A foreground hook keeps the terminal, so it can ask before continuing**: A `pre-*` hook running on its own now inherits wt's stdin exactly as an alias body already did, so `trust = "gum confirm 'trust this worktree?' && mise trust"` gets a prompt instead of an immediate EOF. (Breaking: that hook no longer receives the JSON context on its stdin — every value is still reachable as a template variable, e.g. `{{ branch }}`. The JSON survives for the two forms that can't be interactive anyway: concurrent groups and detached `post-*` hooks. `wt hook --foreground` runs in the foreground whatever the type, so it gets the terminal too.) Fixes [#3093](https://github.com/max-sixty/worktrunk/issues/3093). ([#3129](https://github.com/max-sixty/worktrunk/pull/3129)) + - **`remote_repo` names the repository as the remote spells it**: `repo` is the directory on disk, so a renamed clone reports the new name. `{{ remote_repo }}` takes it from the primary remote's URL, available everywhere `owner` is and unset when no remote parses. ([#3745](https://github.com/max-sixty/worktrunk/pull/3745), thanks @canac) ## 0.73.0 From aef32b3612eacefb093391c1dfc5960df411f672 Mon Sep 17 00:00:00 2001 From: Maximilian Roos Date: Fri, 14 Aug 2026 03:09:03 -0700 Subject: [PATCH 11/18] docs: state the stdin rule for a serial pipeline too, and pin it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 2 +- docs/content/extending.md | 2 +- docs/content/hook.md | 6 ++- .../skills/worktrunk/reference/extending.md | 2 +- .../skills/worktrunk/reference/hook.md | 6 ++- skills/worktrunk/reference/extending.md | 2 +- skills/worktrunk/reference/hook.md | 6 ++- src/cli/mod.rs | 6 ++- tests/integration_tests/user_hooks.rs | 46 +++++++++++++++++++ 9 files changed, 66 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d27578f9f..ed6dbdd63c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ - **`wt config approvals add --yes` records approvals without a terminal**: the command refused every non-interactive run, so a container that had just cloned a project could only pre-approve it by hand-writing `approvals.toml` from `wt config approvals list --format=json`. `--yes` now lists what it trusts and writes it. A save it cannot make fails the command rather than warning and exiting 0, since the record is all `add` produces — a change that applies to the interactive run too. -- **A foreground hook keeps the terminal, so it can ask before continuing**: A `pre-*` hook running on its own now inherits wt's stdin exactly as an alias body already did, so `trust = "gum confirm 'trust this worktree?' && mise trust"` gets a prompt instead of an immediate EOF. (Breaking: that hook no longer receives the JSON context on its stdin — every value is still reachable as a template variable, e.g. `{{ branch }}`. The JSON survives for the two forms that can't be interactive anyway: concurrent groups and detached `post-*` hooks. `wt hook --foreground` runs in the foreground whatever the type, so a lone hook invoked that way gets the terminal too; a concurrent group keeps its JSON either way.) Fixes [#3093](https://github.com/max-sixty/worktrunk/issues/3093). ([#3129](https://github.com/max-sixty/worktrunk/pull/3129)) +- **A foreground hook keeps the terminal, so it can ask before continuing**: A `pre-*` hook running on its own now inherits wt's stdin exactly as an alias body already did, so `trust = "gum confirm 'trust this worktree?' && mise trust"` gets a prompt instead of an immediate EOF. (Breaking: that hook no longer receives the JSON context on its stdin — every value is still reachable as a template variable, e.g. `{{ branch }}`. The JSON survives for the two forms that can't be interactive anyway: concurrent groups and detached `post-*` hooks. `wt hook --foreground` runs in the foreground whatever the type, so `post-*` follows the same rule there. Foreground steps share one stdin, so only the first to read it sees anything.) Fixes [#3093](https://github.com/max-sixty/worktrunk/issues/3093). ([#3129](https://github.com/max-sixty/worktrunk/pull/3129)) - **`remote_repo` names the repository as the remote spells it**: `repo` is the directory on disk, so a renamed clone reports the new name. `{{ remote_repo }}` takes it from the primary remote's URL, available everywhere `owner` is and unset when no remote parses. ([#3745](https://github.com/max-sixty/worktrunk/pull/3745), thanks @canac) diff --git a/docs/content/extending.md b/docs/content/extending.md index 30d84653db..f9a4d87095 100644 --- a/docs/content/extending.md +++ b/docs/content/extending.md @@ -217,7 +217,7 @@ Aside from the differences below, hooks and aliases behave the same. | Force-bind escape | `--var KEY=VALUE` (deprecated in favor of `--KEY=VALUE`, but still force-binds) | None; smart routing is the only path | | `--help` | `wt hook --help` lists hook types; `wt hook --help` shows flags and arguments for that type | The template body is the documentation: `wt --help` redirects to `wt config alias show` / `dry-run`. `wt --help` and `wt step --help` list configured aliases alongside built-in commands | | Inspection | `wt hook show [type] [--expanded]` | `wt config alias show ` / `wt config alias dry-run ` | -| Stdin | A hook running alone in the foreground inherits parent stdin, same as aliases (interactive prompts work) — `pre-*` hooks, and any type under `wt hook --foreground`; detached `post-*` hooks and concurrent groups receive all template variables as JSON (parse with `json.load(sys.stdin)`) | Inherits parent stdin (pipes pass through; interactive TUIs like `wt switch` keep the tty) | +| Stdin | A hook running in the foreground inherits parent stdin, same as aliases (interactive prompts work) — `pre-*` hooks, and any type under `wt hook --foreground`; detached `post-*` hooks and concurrent groups receive all template variables as JSON (parse with `json.load(sys.stdin)`) | Inherits parent stdin (pipes pass through; interactive TUIs like `wt switch` keep the tty) | | Template-context extras | `hook_type`, `hook_name`, per-type operation vars (`base`, `target`, `pr_number`, …) | `args` on top of the shared base variables | diff --git a/docs/content/hook.md b/docs/content/hook.md index 3c9c315f83..02f9c93d10 100644 --- a/docs/content/hook.md +++ b/docs/content/hook.md @@ -261,7 +261,7 @@ setup = "cp {{ worktree_path_of_branch('main') }}/config.local {{ worktree_path ## Interactive hooks and JSON context -A hook's stdin carries the terminal when the hook runs alone in the foreground, and the JSON context otherwise. +A hook's stdin carries the terminal when the hook runs in the foreground, and the JSON context when it can't. A `pre-*` hook gets the terminal, so it can ask before continuing: @@ -270,7 +270,9 @@ A `pre-*` hook gets the terminal, so it can ask before continuing: trust = "gum confirm 'trust this worktree?' && mise trust" ``` -Two forms receive all template variables as JSON on stdin instead: `post-*` hooks, which run detached with no terminal, and concurrent groups, whose children would otherwise race for one terminal. Adding a second key to the table above turns it into a concurrent group, which is enough to take the terminal away from `gum confirm` — keep a hook that prompts in a table of its own. `wt hook --foreground` runs a hook in the foreground whatever its type, so a lone `post-*` hook invoked that way gets the terminal and no JSON. A concurrent group keeps its JSON either way. +Two forms receive all template variables as JSON on stdin instead: detached `post-*` hooks, which have no terminal, and concurrent groups, whose children would otherwise race for one. A table of two or more keys *is* a concurrent group, so adding a second key takes the terminal away from `gum confirm` — keep a hook that prompts in a table of its own. `wt hook --foreground` runs a hook in the foreground whatever its type, so `post-*` follows the same rule there. + +Foreground steps run in order and share one stdin, so a step that reads it to EOF leaves nothing for the steps behind it — only one step in a pipeline can prompt. Steps accumulate across config files, so a user `[pre-start]` and a project `[pre-start]` form one pipeline. The JSON context enables logic that templates can't express: diff --git a/plugins/worktrunk/skills/worktrunk/reference/extending.md b/plugins/worktrunk/skills/worktrunk/reference/extending.md index 285aeb9ca4..64f56d1c4b 100644 --- a/plugins/worktrunk/skills/worktrunk/reference/extending.md +++ b/plugins/worktrunk/skills/worktrunk/reference/extending.md @@ -224,7 +224,7 @@ Aside from the differences below, hooks and aliases behave the same. | Force-bind escape | `--var KEY=VALUE` (deprecated in favor of `--KEY=VALUE`, but still force-binds) | None; smart routing is the only path | | `--help` | `wt hook --help` lists hook types; `wt hook --help` shows flags and arguments for that type | The template body is the documentation: `wt --help` redirects to `wt config alias show` / `dry-run`. `wt --help` and `wt step --help` list configured aliases alongside built-in commands | | Inspection | `wt hook show [type] [--expanded]` | `wt config alias show ` / `wt config alias dry-run ` | -| Stdin | A hook running alone in the foreground inherits parent stdin, same as aliases (interactive prompts work) — `pre-*` hooks, and any type under `wt hook --foreground`; detached `post-*` hooks and concurrent groups receive all template variables as JSON (parse with `json.load(sys.stdin)`) | Inherits parent stdin (pipes pass through; interactive TUIs like `wt switch` keep the tty) | +| Stdin | A hook running in the foreground inherits parent stdin, same as aliases (interactive prompts work) — `pre-*` hooks, and any type under `wt hook --foreground`; detached `post-*` hooks and concurrent groups receive all template variables as JSON (parse with `json.load(sys.stdin)`) | Inherits parent stdin (pipes pass through; interactive TUIs like `wt switch` keep the tty) | | Template-context extras | `hook_type`, `hook_name`, per-type operation vars (`base`, `target`, `pr_number`, …) | `args` on top of the shared base variables | diff --git a/plugins/worktrunk/skills/worktrunk/reference/hook.md b/plugins/worktrunk/skills/worktrunk/reference/hook.md index a74a85ffb2..6dbe3b8d07 100644 --- a/plugins/worktrunk/skills/worktrunk/reference/hook.md +++ b/plugins/worktrunk/skills/worktrunk/reference/hook.md @@ -252,7 +252,7 @@ setup = "cp {{ worktree_path_of_branch('main') }}/config.local {{ worktree_path ## Interactive hooks and JSON context -A hook's stdin carries the terminal when the hook runs alone in the foreground, and the JSON context otherwise. +A hook's stdin carries the terminal when the hook runs in the foreground, and the JSON context when it can't. A `pre-*` hook gets the terminal, so it can ask before continuing: @@ -261,7 +261,9 @@ A `pre-*` hook gets the terminal, so it can ask before continuing: trust = "gum confirm 'trust this worktree?' && mise trust" ``` -Two forms receive all template variables as JSON on stdin instead: `post-*` hooks, which run detached with no terminal, and concurrent groups, whose children would otherwise race for one terminal. Adding a second key to the table above turns it into a concurrent group, which is enough to take the terminal away from `gum confirm` — keep a hook that prompts in a table of its own. `wt hook --foreground` runs a hook in the foreground whatever its type, so a lone `post-*` hook invoked that way gets the terminal and no JSON. A concurrent group keeps its JSON either way. +Two forms receive all template variables as JSON on stdin instead: detached `post-*` hooks, which have no terminal, and concurrent groups, whose children would otherwise race for one. A table of two or more keys *is* a concurrent group, so adding a second key takes the terminal away from `gum confirm` — keep a hook that prompts in a table of its own. `wt hook --foreground` runs a hook in the foreground whatever its type, so `post-*` follows the same rule there. + +Foreground steps run in order and share one stdin, so a step that reads it to EOF leaves nothing for the steps behind it — only one step in a pipeline can prompt. Steps accumulate across config files, so a user `[pre-start]` and a project `[pre-start]` form one pipeline. The JSON context enables logic that templates can't express: diff --git a/skills/worktrunk/reference/extending.md b/skills/worktrunk/reference/extending.md index 285aeb9ca4..64f56d1c4b 100644 --- a/skills/worktrunk/reference/extending.md +++ b/skills/worktrunk/reference/extending.md @@ -224,7 +224,7 @@ Aside from the differences below, hooks and aliases behave the same. | Force-bind escape | `--var KEY=VALUE` (deprecated in favor of `--KEY=VALUE`, but still force-binds) | None; smart routing is the only path | | `--help` | `wt hook --help` lists hook types; `wt hook --help` shows flags and arguments for that type | The template body is the documentation: `wt --help` redirects to `wt config alias show` / `dry-run`. `wt --help` and `wt step --help` list configured aliases alongside built-in commands | | Inspection | `wt hook show [type] [--expanded]` | `wt config alias show ` / `wt config alias dry-run ` | -| Stdin | A hook running alone in the foreground inherits parent stdin, same as aliases (interactive prompts work) — `pre-*` hooks, and any type under `wt hook --foreground`; detached `post-*` hooks and concurrent groups receive all template variables as JSON (parse with `json.load(sys.stdin)`) | Inherits parent stdin (pipes pass through; interactive TUIs like `wt switch` keep the tty) | +| Stdin | A hook running in the foreground inherits parent stdin, same as aliases (interactive prompts work) — `pre-*` hooks, and any type under `wt hook --foreground`; detached `post-*` hooks and concurrent groups receive all template variables as JSON (parse with `json.load(sys.stdin)`) | Inherits parent stdin (pipes pass through; interactive TUIs like `wt switch` keep the tty) | | Template-context extras | `hook_type`, `hook_name`, per-type operation vars (`base`, `target`, `pr_number`, …) | `args` on top of the shared base variables | diff --git a/skills/worktrunk/reference/hook.md b/skills/worktrunk/reference/hook.md index a74a85ffb2..6dbe3b8d07 100644 --- a/skills/worktrunk/reference/hook.md +++ b/skills/worktrunk/reference/hook.md @@ -252,7 +252,7 @@ setup = "cp {{ worktree_path_of_branch('main') }}/config.local {{ worktree_path ## Interactive hooks and JSON context -A hook's stdin carries the terminal when the hook runs alone in the foreground, and the JSON context otherwise. +A hook's stdin carries the terminal when the hook runs in the foreground, and the JSON context when it can't. A `pre-*` hook gets the terminal, so it can ask before continuing: @@ -261,7 +261,9 @@ A `pre-*` hook gets the terminal, so it can ask before continuing: trust = "gum confirm 'trust this worktree?' && mise trust" ``` -Two forms receive all template variables as JSON on stdin instead: `post-*` hooks, which run detached with no terminal, and concurrent groups, whose children would otherwise race for one terminal. Adding a second key to the table above turns it into a concurrent group, which is enough to take the terminal away from `gum confirm` — keep a hook that prompts in a table of its own. `wt hook --foreground` runs a hook in the foreground whatever its type, so a lone `post-*` hook invoked that way gets the terminal and no JSON. A concurrent group keeps its JSON either way. +Two forms receive all template variables as JSON on stdin instead: detached `post-*` hooks, which have no terminal, and concurrent groups, whose children would otherwise race for one. A table of two or more keys *is* a concurrent group, so adding a second key takes the terminal away from `gum confirm` — keep a hook that prompts in a table of its own. `wt hook --foreground` runs a hook in the foreground whatever its type, so `post-*` follows the same rule there. + +Foreground steps run in order and share one stdin, so a step that reads it to EOF leaves nothing for the steps behind it — only one step in a pipeline can prompt. Steps accumulate across config files, so a user `[pre-start]` and a project `[pre-start]` form one pipeline. The JSON context enables logic that templates can't express: diff --git a/src/cli/mod.rs b/src/cli/mod.rs index bcdcc3b9ef..41f89a1595 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1795,7 +1795,7 @@ setup = "cp {{ worktree_path_of_branch('main') }}/config.local {{ worktree_path ## Interactive hooks and JSON context -A hook's stdin carries the terminal when the hook runs alone in the foreground, and the JSON context otherwise. +A hook's stdin carries the terminal when the hook runs in the foreground, and the JSON context when it can't. A `pre-*` hook gets the terminal, so it can ask before continuing: @@ -1804,7 +1804,9 @@ A `pre-*` hook gets the terminal, so it can ask before continuing: trust = "gum confirm 'trust this worktree?' && mise trust" ``` -Two forms receive all template variables as JSON on stdin instead: `post-*` hooks, which run detached with no terminal, and concurrent groups, whose children would otherwise race for one terminal. Adding a second key to the table above turns it into a concurrent group, which is enough to take the terminal away from `gum confirm` — keep a hook that prompts in a table of its own. `wt hook --foreground` runs a hook in the foreground whatever its type, so a lone `post-*` hook invoked that way gets the terminal and no JSON. A concurrent group keeps its JSON either way. +Two forms receive all template variables as JSON on stdin instead: detached `post-*` hooks, which have no terminal, and concurrent groups, whose children would otherwise race for one. A table of two or more keys *is* a concurrent group, so adding a second key takes the terminal away from `gum confirm` — keep a hook that prompts in a table of its own. `wt hook --foreground` runs a hook in the foreground whatever its type, so `post-*` follows the same rule there. + +Foreground steps run in order and share one stdin, so a step that reads it to EOF leaves nothing for the steps behind it — only one step in a pipeline can prompt. Steps accumulate across config files, so a user `[pre-start]` and a project `[pre-start]` form one pipeline. The JSON context enables logic that templates can't express: diff --git a/tests/integration_tests/user_hooks.rs b/tests/integration_tests/user_hooks.rs index 67158e4da1..2e4e441bde 100644 --- a/tests/integration_tests/user_hooks.rs +++ b/tests/integration_tests/user_hooks.rs @@ -1903,6 +1903,52 @@ fn test_standalone_hook_post_start_foreground_inherits_stdin(repo: TestRepo) { ); } +#[rstest] +fn test_foreground_pipeline_steps_share_one_stdin(repo: TestRepo) { + use std::io::Write; + use std::process::Stdio; + + // Foreground steps run in order against wt's own stdin, so the first one + // to read it to EOF leaves nothing behind — only one step in a pipeline + // can prompt. Steps accumulate across config files, so a user and a + // project hook of the same type reach this shape without an array. + repo.write_project_config(r#"post-start = ["cat > a.txt", "cat > b.txt"]"#); + + let mut cmd = crate::common::wt_command(); + cmd.current_dir(repo.root_path()); + cmd.env("WORKTRUNK_CONFIG_PATH", repo.test_config_path()); + cmd.args(["hook", "post-start", "--yes", "--foreground"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let mut child = cmd.spawn().expect("failed to spawn wt hook post-start"); + child + .stdin + .take() + .expect("stdin piped") + .write_all(b"sentinel-from-parent-stdin\n") + .expect("failed to write to wt stdin"); + let output = child.wait_with_output().expect("failed to run wt hook"); + + assert!( + output.status.success(), + "wt hook post-start --foreground should succeed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + assert_eq!( + fs::read_to_string(repo.root_path().join("a.txt")).unwrap(), + "sentinel-from-parent-stdin\n", + "the first foreground step should read the parent's stdin" + ); + assert_eq!( + fs::read_to_string(repo.root_path().join("b.txt")).unwrap(), + "", + "the first step drained stdin, so the second should see EOF" + ); +} + #[rstest] fn test_standalone_hook_concurrent_group_keeps_json_under_foreground(repo: TestRepo) { use std::io::Write; From a5219070e93b90606083b1c399feaf50d0664693 Mon Sep 17 00:00:00 2001 From: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com> Date: Fri, 14 Aug 2026 10:21:54 +0000 Subject: [PATCH 12/18] docs: the pipeline-stdin warning is about pipes, not terminals "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. --- CHANGELOG.md | 2 +- docs/content/hook.md | 2 +- plugins/worktrunk/skills/worktrunk/reference/hook.md | 2 +- skills/worktrunk/reference/hook.md | 2 +- src/cli/mod.rs | 2 +- tests/integration_tests/user_hooks.rs | 7 ++++--- 6 files changed, 9 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed6dbdd63c..85dd8ae271 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ - **`wt config approvals add --yes` records approvals without a terminal**: the command refused every non-interactive run, so a container that had just cloned a project could only pre-approve it by hand-writing `approvals.toml` from `wt config approvals list --format=json`. `--yes` now lists what it trusts and writes it. A save it cannot make fails the command rather than warning and exiting 0, since the record is all `add` produces — a change that applies to the interactive run too. -- **A foreground hook keeps the terminal, so it can ask before continuing**: A `pre-*` hook running on its own now inherits wt's stdin exactly as an alias body already did, so `trust = "gum confirm 'trust this worktree?' && mise trust"` gets a prompt instead of an immediate EOF. (Breaking: that hook no longer receives the JSON context on its stdin — every value is still reachable as a template variable, e.g. `{{ branch }}`. The JSON survives for the two forms that can't be interactive anyway: concurrent groups and detached `post-*` hooks. `wt hook --foreground` runs in the foreground whatever the type, so `post-*` follows the same rule there. Foreground steps share one stdin, so only the first to read it sees anything.) Fixes [#3093](https://github.com/max-sixty/worktrunk/issues/3093). ([#3129](https://github.com/max-sixty/worktrunk/pull/3129)) +- **A foreground hook keeps the terminal, so it can ask before continuing**: A `pre-*` hook running on its own now inherits wt's stdin exactly as an alias body already did, so `trust = "gum confirm 'trust this worktree?' && mise trust"` gets a prompt instead of an immediate EOF. (Breaking: that hook no longer receives the JSON context on its stdin — every value is still reachable as a template variable, e.g. `{{ branch }}`. The JSON survives for the two forms that can't be interactive anyway: concurrent groups and detached `post-*` hooks. `wt hook --foreground` runs in the foreground whatever the type, so `post-*` follows the same rule there. Foreground steps share one stdin, so a step that drains it to EOF — a `cat`, a `json.load(sys.stdin)` — starves the steps behind it when that stdin is a pipe or a file.) Fixes [#3093](https://github.com/max-sixty/worktrunk/issues/3093). ([#3129](https://github.com/max-sixty/worktrunk/pull/3129)) - **`remote_repo` names the repository as the remote spells it**: `repo` is the directory on disk, so a renamed clone reports the new name. `{{ remote_repo }}` takes it from the primary remote's URL, available everywhere `owner` is and unset when no remote parses. ([#3745](https://github.com/max-sixty/worktrunk/pull/3745), thanks @canac) diff --git a/docs/content/hook.md b/docs/content/hook.md index 02f9c93d10..d547bf6a35 100644 --- a/docs/content/hook.md +++ b/docs/content/hook.md @@ -272,7 +272,7 @@ trust = "gum confirm 'trust this worktree?' && mise trust" Two forms receive all template variables as JSON on stdin instead: detached `post-*` hooks, which have no terminal, and concurrent groups, whose children would otherwise race for one. A table of two or more keys *is* a concurrent group, so adding a second key takes the terminal away from `gum confirm` — keep a hook that prompts in a table of its own. `wt hook --foreground` runs a hook in the foreground whatever its type, so `post-*` follows the same rule there. -Foreground steps run in order and share one stdin, so a step that reads it to EOF leaves nothing for the steps behind it — only one step in a pipeline can prompt. Steps accumulate across config files, so a user `[pre-start]` and a project `[pre-start]` form one pipeline. +Foreground steps run in order and share one stdin, so a step that drains it to EOF — a `cat`, a `json.load(sys.stdin)` — leaves nothing for the steps behind it when that stdin is a pipe or a file. Under a terminal each step can prompt in turn. Steps accumulate across config files, so a user `[pre-start]` and a project `[pre-start]` form one pipeline. The JSON context enables logic that templates can't express: diff --git a/plugins/worktrunk/skills/worktrunk/reference/hook.md b/plugins/worktrunk/skills/worktrunk/reference/hook.md index 6dbe3b8d07..ed0df4a9ab 100644 --- a/plugins/worktrunk/skills/worktrunk/reference/hook.md +++ b/plugins/worktrunk/skills/worktrunk/reference/hook.md @@ -263,7 +263,7 @@ trust = "gum confirm 'trust this worktree?' && mise trust" Two forms receive all template variables as JSON on stdin instead: detached `post-*` hooks, which have no terminal, and concurrent groups, whose children would otherwise race for one. A table of two or more keys *is* a concurrent group, so adding a second key takes the terminal away from `gum confirm` — keep a hook that prompts in a table of its own. `wt hook --foreground` runs a hook in the foreground whatever its type, so `post-*` follows the same rule there. -Foreground steps run in order and share one stdin, so a step that reads it to EOF leaves nothing for the steps behind it — only one step in a pipeline can prompt. Steps accumulate across config files, so a user `[pre-start]` and a project `[pre-start]` form one pipeline. +Foreground steps run in order and share one stdin, so a step that drains it to EOF — a `cat`, a `json.load(sys.stdin)` — leaves nothing for the steps behind it when that stdin is a pipe or a file. Under a terminal each step can prompt in turn. Steps accumulate across config files, so a user `[pre-start]` and a project `[pre-start]` form one pipeline. The JSON context enables logic that templates can't express: diff --git a/skills/worktrunk/reference/hook.md b/skills/worktrunk/reference/hook.md index 6dbe3b8d07..ed0df4a9ab 100644 --- a/skills/worktrunk/reference/hook.md +++ b/skills/worktrunk/reference/hook.md @@ -263,7 +263,7 @@ trust = "gum confirm 'trust this worktree?' && mise trust" Two forms receive all template variables as JSON on stdin instead: detached `post-*` hooks, which have no terminal, and concurrent groups, whose children would otherwise race for one. A table of two or more keys *is* a concurrent group, so adding a second key takes the terminal away from `gum confirm` — keep a hook that prompts in a table of its own. `wt hook --foreground` runs a hook in the foreground whatever its type, so `post-*` follows the same rule there. -Foreground steps run in order and share one stdin, so a step that reads it to EOF leaves nothing for the steps behind it — only one step in a pipeline can prompt. Steps accumulate across config files, so a user `[pre-start]` and a project `[pre-start]` form one pipeline. +Foreground steps run in order and share one stdin, so a step that drains it to EOF — a `cat`, a `json.load(sys.stdin)` — leaves nothing for the steps behind it when that stdin is a pipe or a file. Under a terminal each step can prompt in turn. Steps accumulate across config files, so a user `[pre-start]` and a project `[pre-start]` form one pipeline. The JSON context enables logic that templates can't express: diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 41f89a1595..e46de6bed2 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1806,7 +1806,7 @@ trust = "gum confirm 'trust this worktree?' && mise trust" Two forms receive all template variables as JSON on stdin instead: detached `post-*` hooks, which have no terminal, and concurrent groups, whose children would otherwise race for one. A table of two or more keys *is* a concurrent group, so adding a second key takes the terminal away from `gum confirm` — keep a hook that prompts in a table of its own. `wt hook --foreground` runs a hook in the foreground whatever its type, so `post-*` follows the same rule there. -Foreground steps run in order and share one stdin, so a step that reads it to EOF leaves nothing for the steps behind it — only one step in a pipeline can prompt. Steps accumulate across config files, so a user `[pre-start]` and a project `[pre-start]` form one pipeline. +Foreground steps run in order and share one stdin, so a step that drains it to EOF — a `cat`, a `json.load(sys.stdin)` — leaves nothing for the steps behind it when that stdin is a pipe or a file. Under a terminal each step can prompt in turn. Steps accumulate across config files, so a user `[pre-start]` and a project `[pre-start]` form one pipeline. The JSON context enables logic that templates can't express: diff --git a/tests/integration_tests/user_hooks.rs b/tests/integration_tests/user_hooks.rs index 2e4e441bde..587560ba22 100644 --- a/tests/integration_tests/user_hooks.rs +++ b/tests/integration_tests/user_hooks.rs @@ -1909,9 +1909,10 @@ fn test_foreground_pipeline_steps_share_one_stdin(repo: TestRepo) { use std::process::Stdio; // Foreground steps run in order against wt's own stdin, so the first one - // to read it to EOF leaves nothing behind — only one step in a pipeline - // can prompt. Steps accumulate across config files, so a user and a - // project hook of the same type reach this shape without an array. + // to drain it to EOF leaves nothing behind — which is a property of the + // pipe this test constructs, not of a terminal, where each step could + // still prompt in turn. Steps accumulate across config files, so a user + // and a project hook of the same type reach this shape without an array. repo.write_project_config(r#"post-start = ["cat > a.txt", "cat > b.txt"]"#); let mut cmd = crate::common::wt_command(); From 897fcc73e8863bf34b20971915a69c8e6cadfd3f Mon Sep 17 00:00:00 2001 From: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com> Date: Thu, 20 Aug 2026 22:39:36 +0000 Subject: [PATCH 13/18] feat(hooks): remove JSON-on-stdin, leaving one stdin rule 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. --- CHANGELOG.md | 2 +- docs/content/extending.md | 2 +- docs/content/hook.md | 22 ++--- .../skills/worktrunk/reference/extending.md | 2 +- .../skills/worktrunk/reference/hook.md | 22 ++--- skills/worktrunk/reference/extending.md | 2 +- skills/worktrunk/reference/hook.md | 22 ++--- src/cli/mod.rs | 22 ++--- src/commands/command_executor.rs | 33 +++---- src/commands/hooks.rs | 7 +- src/commands/process.rs | 93 ++----------------- src/commands/run_pipeline.rs | 38 +++----- src/config/expansion.rs | 19 ++-- src/output/concurrent.rs | 29 +++--- src/output/handlers.rs | 8 +- src/testing/mod.rs | 24 ----- .../integration_tests/post_start_commands.rs | 92 ++++++++---------- tests/integration_tests/user_hooks.rs | 31 +++---- 18 files changed, 153 insertions(+), 317 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85dd8ae271..9003bbdb0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ - **`wt config approvals add --yes` records approvals without a terminal**: the command refused every non-interactive run, so a container that had just cloned a project could only pre-approve it by hand-writing `approvals.toml` from `wt config approvals list --format=json`. `--yes` now lists what it trusts and writes it. A save it cannot make fails the command rather than warning and exiting 0, since the record is all `add` produces — a change that applies to the interactive run too. -- **A foreground hook keeps the terminal, so it can ask before continuing**: A `pre-*` hook running on its own now inherits wt's stdin exactly as an alias body already did, so `trust = "gum confirm 'trust this worktree?' && mise trust"` gets a prompt instead of an immediate EOF. (Breaking: that hook no longer receives the JSON context on its stdin — every value is still reachable as a template variable, e.g. `{{ branch }}`. The JSON survives for the two forms that can't be interactive anyway: concurrent groups and detached `post-*` hooks. `wt hook --foreground` runs in the foreground whatever the type, so `post-*` follows the same rule there. Foreground steps share one stdin, so a step that drains it to EOF — a `cat`, a `json.load(sys.stdin)` — starves the steps behind it when that stdin is a pipe or a file.) Fixes [#3093](https://github.com/max-sixty/worktrunk/issues/3093). ([#3129](https://github.com/max-sixty/worktrunk/pull/3129)) +- **A foreground hook keeps the terminal, so it can ask before continuing**: A `pre-*` hook now inherits wt's stdin exactly as an alias body already did, so `trust = "gum confirm 'trust this worktree?' && mise trust"` gets a prompt instead of an immediate EOF. That covers every type under `wt hook --foreground` too. (Breaking: hooks no longer receive the JSON context on stdin at all — a hook that ran `json.load(sys.stdin)` must take the values it needs as template variables instead, e.g. `setup = "python3 setup.py {{ branch }} {{ repo }}"`, which every hook has always had. The forms that can't hold a terminal — detached `post-*` hooks, and the children of a concurrent group, who would race for one — now read EOF rather than JSON. Foreground steps share one stdin, so a step that drains it to EOF starves the steps behind it when that stdin is a pipe or a file.) Fixes [#3093](https://github.com/max-sixty/worktrunk/issues/3093). ([#3129](https://github.com/max-sixty/worktrunk/pull/3129)) - **`remote_repo` names the repository as the remote spells it**: `repo` is the directory on disk, so a renamed clone reports the new name. `{{ remote_repo }}` takes it from the primary remote's URL, available everywhere `owner` is and unset when no remote parses. ([#3745](https://github.com/max-sixty/worktrunk/pull/3745), thanks @canac) diff --git a/docs/content/extending.md b/docs/content/extending.md index f9a4d87095..d175988345 100644 --- a/docs/content/extending.md +++ b/docs/content/extending.md @@ -217,7 +217,7 @@ Aside from the differences below, hooks and aliases behave the same. | Force-bind escape | `--var KEY=VALUE` (deprecated in favor of `--KEY=VALUE`, but still force-binds) | None; smart routing is the only path | | `--help` | `wt hook --help` lists hook types; `wt hook --help` shows flags and arguments for that type | The template body is the documentation: `wt --help` redirects to `wt config alias show` / `dry-run`. `wt --help` and `wt step --help` list configured aliases alongside built-in commands | | Inspection | `wt hook show [type] [--expanded]` | `wt config alias show ` / `wt config alias dry-run ` | -| Stdin | A hook running in the foreground inherits parent stdin, same as aliases (interactive prompts work) — `pre-*` hooks, and any type under `wt hook --foreground`; detached `post-*` hooks and concurrent groups receive all template variables as JSON (parse with `json.load(sys.stdin)`) | Inherits parent stdin (pipes pass through; interactive TUIs like `wt switch` keep the tty) | +| Stdin | A hook running in the foreground inherits parent stdin, same as aliases (interactive prompts work) — `pre-*` hooks, and any type under `wt hook --foreground`; detached `post-*` hooks and concurrent children read EOF | Inherits parent stdin (pipes pass through; interactive TUIs like `wt switch` keep the tty) | | Template-context extras | `hook_type`, `hook_name`, per-type operation vars (`base`, `target`, `pr_number`, …) | `args` on top of the shared base variables | diff --git a/docs/content/hook.md b/docs/content/hook.md index d547bf6a35..454190a4d6 100644 --- a/docs/content/hook.md +++ b/docs/content/hook.md @@ -259,37 +259,33 @@ The `worktree_path_of_branch` function returns the filesystem path of a worktree setup = "cp {{ worktree_path_of_branch('main') }}/config.local {{ worktree_path }}" ``` -## Interactive hooks and JSON context +## Interactive hooks -A hook's stdin carries the terminal when the hook runs in the foreground, and the JSON context when it can't. - -A `pre-*` hook gets the terminal, so it can ask before continuing: +A hook running in the foreground inherits wt's stdin, so it can ask before continuing: ```toml [pre-start] trust = "gum confirm 'trust this worktree?' && mise trust" ``` -Two forms receive all template variables as JSON on stdin instead: detached `post-*` hooks, which have no terminal, and concurrent groups, whose children would otherwise race for one. A table of two or more keys *is* a concurrent group, so adding a second key takes the terminal away from `gum confirm` — keep a hook that prompts in a table of its own. `wt hook --foreground` runs a hook in the foreground whatever its type, so `post-*` follows the same rule there. +That covers `pre-*` hooks and any type under `wt hook --foreground`. Everything else reads EOF: a detached `post-*` hook has no terminal, and the children of a concurrent group would race for one. Nothing is ever piped in — a hook reads its context through template variables, whatever form it runs in. -Foreground steps run in order and share one stdin, so a step that drains it to EOF — a `cat`, a `json.load(sys.stdin)` — leaves nothing for the steps behind it when that stdin is a pipe or a file. Under a terminal each step can prompt in turn. Steps accumulate across config files, so a user `[pre-start]` and a project `[pre-start]` form one pipeline. +Foreground steps run in order and share one stdin, so a step that drains it to EOF — a `cat`, a `read` — leaves nothing for the steps behind it when that stdin is a pipe or a file. Under a terminal each step can prompt in turn. Steps accumulate across config files, so a user `[pre-start]` and a project `[pre-start]` form one pipeline. -The JSON context enables logic that templates can't express: +Logic that templates can't express belongs in a script, with the values it needs passed as arguments: ```toml [post-start] -setup = "python3 scripts/post-start-setup.py" +setup = "python3 scripts/post-start-setup.py {{ branch }} {{ repo }}" ``` ```python -import json, sys, subprocess -ctx = json.load(sys.stdin) -if ctx['branch'].startswith('feature/') and 'backend' in ctx['repo']: +import subprocess, sys +branch, repo = sys.argv[1], sys.argv[2] +if branch.startswith('feature/') and 'backend' in repo: subprocess.run(['make', 'seed-db']) ``` -Every hook also receives its template variables through `{{ }}` substitution, regardless of form. - ## Copying untracked files One specific command worth calling out: [`wt step copy-ignored`](@/step.md#wt-step-copy-ignored). Git worktrees share the repository but not untracked files, and this copies gitignored files between worktrees: diff --git a/plugins/worktrunk/skills/worktrunk/reference/extending.md b/plugins/worktrunk/skills/worktrunk/reference/extending.md index 64f56d1c4b..13df254bad 100644 --- a/plugins/worktrunk/skills/worktrunk/reference/extending.md +++ b/plugins/worktrunk/skills/worktrunk/reference/extending.md @@ -224,7 +224,7 @@ Aside from the differences below, hooks and aliases behave the same. | Force-bind escape | `--var KEY=VALUE` (deprecated in favor of `--KEY=VALUE`, but still force-binds) | None; smart routing is the only path | | `--help` | `wt hook --help` lists hook types; `wt hook --help` shows flags and arguments for that type | The template body is the documentation: `wt --help` redirects to `wt config alias show` / `dry-run`. `wt --help` and `wt step --help` list configured aliases alongside built-in commands | | Inspection | `wt hook show [type] [--expanded]` | `wt config alias show ` / `wt config alias dry-run ` | -| Stdin | A hook running in the foreground inherits parent stdin, same as aliases (interactive prompts work) — `pre-*` hooks, and any type under `wt hook --foreground`; detached `post-*` hooks and concurrent groups receive all template variables as JSON (parse with `json.load(sys.stdin)`) | Inherits parent stdin (pipes pass through; interactive TUIs like `wt switch` keep the tty) | +| Stdin | A hook running in the foreground inherits parent stdin, same as aliases (interactive prompts work) — `pre-*` hooks, and any type under `wt hook --foreground`; detached `post-*` hooks and concurrent children read EOF | Inherits parent stdin (pipes pass through; interactive TUIs like `wt switch` keep the tty) | | Template-context extras | `hook_type`, `hook_name`, per-type operation vars (`base`, `target`, `pr_number`, …) | `args` on top of the shared base variables | diff --git a/plugins/worktrunk/skills/worktrunk/reference/hook.md b/plugins/worktrunk/skills/worktrunk/reference/hook.md index ed0df4a9ab..9af5237d66 100644 --- a/plugins/worktrunk/skills/worktrunk/reference/hook.md +++ b/plugins/worktrunk/skills/worktrunk/reference/hook.md @@ -250,37 +250,33 @@ The `worktree_path_of_branch` function returns the filesystem path of a worktree setup = "cp {{ worktree_path_of_branch('main') }}/config.local {{ worktree_path }}" ``` -## Interactive hooks and JSON context +## Interactive hooks -A hook's stdin carries the terminal when the hook runs in the foreground, and the JSON context when it can't. - -A `pre-*` hook gets the terminal, so it can ask before continuing: +A hook running in the foreground inherits wt's stdin, so it can ask before continuing: ```toml [pre-start] trust = "gum confirm 'trust this worktree?' && mise trust" ``` -Two forms receive all template variables as JSON on stdin instead: detached `post-*` hooks, which have no terminal, and concurrent groups, whose children would otherwise race for one. A table of two or more keys *is* a concurrent group, so adding a second key takes the terminal away from `gum confirm` — keep a hook that prompts in a table of its own. `wt hook --foreground` runs a hook in the foreground whatever its type, so `post-*` follows the same rule there. +That covers `pre-*` hooks and any type under `wt hook --foreground`. Everything else reads EOF: a detached `post-*` hook has no terminal, and the children of a concurrent group would race for one. Nothing is ever piped in — a hook reads its context through template variables, whatever form it runs in. -Foreground steps run in order and share one stdin, so a step that drains it to EOF — a `cat`, a `json.load(sys.stdin)` — leaves nothing for the steps behind it when that stdin is a pipe or a file. Under a terminal each step can prompt in turn. Steps accumulate across config files, so a user `[pre-start]` and a project `[pre-start]` form one pipeline. +Foreground steps run in order and share one stdin, so a step that drains it to EOF — a `cat`, a `read` — leaves nothing for the steps behind it when that stdin is a pipe or a file. Under a terminal each step can prompt in turn. Steps accumulate across config files, so a user `[pre-start]` and a project `[pre-start]` form one pipeline. -The JSON context enables logic that templates can't express: +Logic that templates can't express belongs in a script, with the values it needs passed as arguments: ```toml [post-start] -setup = "python3 scripts/post-start-setup.py" +setup = "python3 scripts/post-start-setup.py {{ branch }} {{ repo }}" ``` ```python -import json, sys, subprocess -ctx = json.load(sys.stdin) -if ctx['branch'].startswith('feature/') and 'backend' in ctx['repo']: +import subprocess, sys +branch, repo = sys.argv[1], sys.argv[2] +if branch.startswith('feature/') and 'backend' in repo: subprocess.run(['make', 'seed-db']) ``` -Every hook also receives its template variables through `{{ }}` substitution, regardless of form. - ## Copying untracked files One specific command worth calling out: [`wt step copy-ignored`](https://worktrunk.dev/step/#wt-step-copy-ignored). Git worktrees share the repository but not untracked files, and this copies gitignored files between worktrees: diff --git a/skills/worktrunk/reference/extending.md b/skills/worktrunk/reference/extending.md index 64f56d1c4b..13df254bad 100644 --- a/skills/worktrunk/reference/extending.md +++ b/skills/worktrunk/reference/extending.md @@ -224,7 +224,7 @@ Aside from the differences below, hooks and aliases behave the same. | Force-bind escape | `--var KEY=VALUE` (deprecated in favor of `--KEY=VALUE`, but still force-binds) | None; smart routing is the only path | | `--help` | `wt hook --help` lists hook types; `wt hook --help` shows flags and arguments for that type | The template body is the documentation: `wt --help` redirects to `wt config alias show` / `dry-run`. `wt --help` and `wt step --help` list configured aliases alongside built-in commands | | Inspection | `wt hook show [type] [--expanded]` | `wt config alias show ` / `wt config alias dry-run ` | -| Stdin | A hook running in the foreground inherits parent stdin, same as aliases (interactive prompts work) — `pre-*` hooks, and any type under `wt hook --foreground`; detached `post-*` hooks and concurrent groups receive all template variables as JSON (parse with `json.load(sys.stdin)`) | Inherits parent stdin (pipes pass through; interactive TUIs like `wt switch` keep the tty) | +| Stdin | A hook running in the foreground inherits parent stdin, same as aliases (interactive prompts work) — `pre-*` hooks, and any type under `wt hook --foreground`; detached `post-*` hooks and concurrent children read EOF | Inherits parent stdin (pipes pass through; interactive TUIs like `wt switch` keep the tty) | | Template-context extras | `hook_type`, `hook_name`, per-type operation vars (`base`, `target`, `pr_number`, …) | `args` on top of the shared base variables | diff --git a/skills/worktrunk/reference/hook.md b/skills/worktrunk/reference/hook.md index ed0df4a9ab..9af5237d66 100644 --- a/skills/worktrunk/reference/hook.md +++ b/skills/worktrunk/reference/hook.md @@ -250,37 +250,33 @@ The `worktree_path_of_branch` function returns the filesystem path of a worktree setup = "cp {{ worktree_path_of_branch('main') }}/config.local {{ worktree_path }}" ``` -## Interactive hooks and JSON context +## Interactive hooks -A hook's stdin carries the terminal when the hook runs in the foreground, and the JSON context when it can't. - -A `pre-*` hook gets the terminal, so it can ask before continuing: +A hook running in the foreground inherits wt's stdin, so it can ask before continuing: ```toml [pre-start] trust = "gum confirm 'trust this worktree?' && mise trust" ``` -Two forms receive all template variables as JSON on stdin instead: detached `post-*` hooks, which have no terminal, and concurrent groups, whose children would otherwise race for one. A table of two or more keys *is* a concurrent group, so adding a second key takes the terminal away from `gum confirm` — keep a hook that prompts in a table of its own. `wt hook --foreground` runs a hook in the foreground whatever its type, so `post-*` follows the same rule there. +That covers `pre-*` hooks and any type under `wt hook --foreground`. Everything else reads EOF: a detached `post-*` hook has no terminal, and the children of a concurrent group would race for one. Nothing is ever piped in — a hook reads its context through template variables, whatever form it runs in. -Foreground steps run in order and share one stdin, so a step that drains it to EOF — a `cat`, a `json.load(sys.stdin)` — leaves nothing for the steps behind it when that stdin is a pipe or a file. Under a terminal each step can prompt in turn. Steps accumulate across config files, so a user `[pre-start]` and a project `[pre-start]` form one pipeline. +Foreground steps run in order and share one stdin, so a step that drains it to EOF — a `cat`, a `read` — leaves nothing for the steps behind it when that stdin is a pipe or a file. Under a terminal each step can prompt in turn. Steps accumulate across config files, so a user `[pre-start]` and a project `[pre-start]` form one pipeline. -The JSON context enables logic that templates can't express: +Logic that templates can't express belongs in a script, with the values it needs passed as arguments: ```toml [post-start] -setup = "python3 scripts/post-start-setup.py" +setup = "python3 scripts/post-start-setup.py {{ branch }} {{ repo }}" ``` ```python -import json, sys, subprocess -ctx = json.load(sys.stdin) -if ctx['branch'].startswith('feature/') and 'backend' in ctx['repo']: +import subprocess, sys +branch, repo = sys.argv[1], sys.argv[2] +if branch.startswith('feature/') and 'backend' in repo: subprocess.run(['make', 'seed-db']) ``` -Every hook also receives its template variables through `{{ }}` substitution, regardless of form. - ## Copying untracked files One specific command worth calling out: [`wt step copy-ignored`](https://worktrunk.dev/step/#wt-step-copy-ignored). Git worktrees share the repository but not untracked files, and this copies gitignored files between worktrees: diff --git a/src/cli/mod.rs b/src/cli/mod.rs index e46de6bed2..a588aadce2 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1793,37 +1793,33 @@ The `worktree_path_of_branch` function returns the filesystem path of a worktree setup = "cp {{ worktree_path_of_branch('main') }}/config.local {{ worktree_path }}" ``` -## Interactive hooks and JSON context +## Interactive hooks -A hook's stdin carries the terminal when the hook runs in the foreground, and the JSON context when it can't. - -A `pre-*` hook gets the terminal, so it can ask before continuing: +A hook running in the foreground inherits wt's stdin, so it can ask before continuing: ```toml [pre-start] trust = "gum confirm 'trust this worktree?' && mise trust" ``` -Two forms receive all template variables as JSON on stdin instead: detached `post-*` hooks, which have no terminal, and concurrent groups, whose children would otherwise race for one. A table of two or more keys *is* a concurrent group, so adding a second key takes the terminal away from `gum confirm` — keep a hook that prompts in a table of its own. `wt hook --foreground` runs a hook in the foreground whatever its type, so `post-*` follows the same rule there. +That covers `pre-*` hooks and any type under `wt hook --foreground`. Everything else reads EOF: a detached `post-*` hook has no terminal, and the children of a concurrent group would race for one. Nothing is ever piped in — a hook reads its context through template variables, whatever form it runs in. -Foreground steps run in order and share one stdin, so a step that drains it to EOF — a `cat`, a `json.load(sys.stdin)` — leaves nothing for the steps behind it when that stdin is a pipe or a file. Under a terminal each step can prompt in turn. Steps accumulate across config files, so a user `[pre-start]` and a project `[pre-start]` form one pipeline. +Foreground steps run in order and share one stdin, so a step that drains it to EOF — a `cat`, a `read` — leaves nothing for the steps behind it when that stdin is a pipe or a file. Under a terminal each step can prompt in turn. Steps accumulate across config files, so a user `[pre-start]` and a project `[pre-start]` form one pipeline. -The JSON context enables logic that templates can't express: +Logic that templates can't express belongs in a script, with the values it needs passed as arguments: ```toml [post-start] -setup = "python3 scripts/post-start-setup.py" +setup = "python3 scripts/post-start-setup.py {{ branch }} {{ repo }}" ``` ```python -import json, sys, subprocess -ctx = json.load(sys.stdin) -if ctx['branch'].startswith('feature/') and 'backend' in ctx['repo']: +import subprocess, sys +branch, repo = sys.argv[1], sys.argv[2] +if branch.startswith('feature/') and 'backend' in repo: subprocess.run(['make', 'seed-db']) ``` -Every hook also receives its template variables through `{{ }}` substitution, regardless of form. - ## Copying untracked files One specific command worth calling out: [`wt step copy-ignored`](@/step.md#wt-step-copy-ignored). Git worktrees share the repository but not untracked files, and this copies gitignored files between worktrees: diff --git a/src/commands/command_executor.rs b/src/commands/command_executor.rs index 447bfae227..ed8e42bc57 100644 --- a/src/commands/command_executor.rs +++ b/src/commands/command_executor.rs @@ -31,7 +31,7 @@ pub struct PreparedCommand { /// read fresh from git config. pub template: String, /// Template variables, frozen at preparation. Serialized to JSON only at - /// the process boundary (child stdin, background pipeline spec). + /// the process boundary (the background pipeline spec). pub context: TemplateContext, /// Name used in template expansion errors: `"user:foo"` for named hook /// commands, `"user pre-merge hook"` for unnamed ones, the alias name for @@ -42,13 +42,6 @@ pub struct PreparedCommand { pub label: String, } -impl PreparedCommand { - /// The JSON form of `context` piped to the child's stdin. - pub fn context_json(&self) -> String { - self.context.to_json() - } -} - /// A step in a prepared pipeline, mirroring `HookStep`. #[derive(Debug)] pub enum PreparedStep { @@ -225,7 +218,8 @@ impl<'a> CommandContext<'a> { /// Resolve the template variables for one command invocation. /// /// The sole producer of [`TemplateContext`], which owns what happens to the -/// result: expansion, the JSON a hook child reads on stdin, the `-v` table. +/// result: expansion, the JSON a `wt step for-each` child reads on stdin, the +/// `-v` table. /// /// `scope` decides how much to resolve. [`VarScope::Referenced`] skips the git /// lookups behind vars the templates don't name (`var_commit` rev-parse, @@ -239,10 +233,10 @@ impl<'a> CommandContext<'a> { /// `referenced_vars_for_templates` over its command and trailing args. /// - **`All`** when something reads keys the `{{ }}` templates never mention. /// Either the child receives the whole context as JSON on stdin and may pull -/// keys out of it (e.g. via `jq`) — hook pipelines, `wt step for-each` — or -/// the command's output *is* the variable listing, which filtering would -/// narrow to what the body happens to reference: `wt step eval -v`, and the -/// hook pipeline's own `format_hook_variables` table. +/// keys out of it (e.g. via `jq`) — `wt step for-each` — or the command's +/// output *is* the variable listing, which filtering would narrow to what the +/// body happens to reference: `wt step eval -v`, and the hook pipeline's own +/// `format_hook_variables` table. /// /// The template-preview paths (`render_hook_commands`, `wt config alias`) fit /// neither case and still pass `All`: they expand and print one line per @@ -530,7 +524,6 @@ fn run_concurrent_group( }) .collect(); - let context_jsons: Vec = cmds.iter().map(PreparedCommand::context_json).collect(); let log_labels: Vec> = cmds .iter() .map(|cmd| fg_step.announce.log_label(cmd)) @@ -542,7 +535,6 @@ fn run_concurrent_group( label: labels[i], expanded: &expanded[i], working_dir: wt_path, - context_json: &context_jsons[i], log_label: log_labels[i].as_deref(), directives, scrub_git_discovery, @@ -588,9 +580,8 @@ fn run_one_command( // Foreground steps inherit the parent's stdin so an interactive child keeps // its controlling terminal — a `pre-*` hook can prompt (e.g. `gum confirm` // before `mise trust`), and an alias body's `wt switch` picker keeps the - // tty. The JSON context still reaches concurrent groups and detached - // (`post-*`) hooks, which can't be interactive, via their own stdin pipe; - // `execute_shell_command` no longer has a branch that pipes it. + // tty. Nothing is ever piped in: template variables are how a hook reads + // its context, whatever form it runs in. let log_label = fg_step.announce.log_label(cmd); let result = execute_shell_command( wt_path, @@ -773,7 +764,7 @@ impl PreparedPipeline { /// and background) and the `wt hook show --expanded` listing come through here, /// so a context key added here reaches both with no second edit. /// -/// Each command freezes its context as JSON and keeps its raw template; +/// Each command freezes its context and keeps its raw template; /// rendering happens when the command runs, so semantic errors (undefined /// variable, filter failure) surface at the failing step. The returned /// [`PreparedPipeline`] makes the caller choose what an unparsable template @@ -788,7 +779,7 @@ pub fn prepare_steps( // Built once per pipeline — build_hook_context spawns git subprocesses. let mut base_context = build_hook_context(ctx, extra_vars, VarScope::All)?; - // hook_type is always available as a template variable and in JSON context + // hook_type is always available as a template variable base_context.insert("hook_type", hook_type.to_string()); // `{{ args }}` is always available in hook scope. Default to an empty // JSON sequence (rendered via ShellArgs rehydration) so templates can @@ -803,7 +794,7 @@ pub fn prepare_steps( } let steps = map_config_steps(command_config, |cmd| { - // hook_name is per-command: available as template variable and in JSON context + // hook_name is per-command, available as a template variable let mut cmd_context = base_context.clone(); if let Some(ref name) = cmd.name { cmd_context.insert("hook_name", name.clone()); diff --git a/src/commands/hooks.rs b/src/commands/hooks.rs index ec890e48e0..0ff87c3756 100644 --- a/src/commands/hooks.rs +++ b/src/commands/hooks.rs @@ -564,9 +564,10 @@ fn spawn_hook_pipeline_quiet(repo: &Repository, pipeline: &PendingPipeline) -> a /// /// Foreground steps — hook and alias alike — inherit the parent's stdin so an /// interactive child keeps the controlling terminal (a `pre-*` hook can prompt; -/// an alias body's `wt switch` picker works). The JSON context still reaches -/// concurrent groups and detached (`post-*`) hooks, which can't be interactive, -/// via their own per-child stdin pipe. +/// an alias body's `wt switch` picker works). The forms that can't be +/// interactive get a closed stdin rather than a substitute payload: concurrent +/// children (they'd race for the terminal) and detached (`post-*`) hooks (there +/// is none). Template variables carry the context in every form. /// /// Trust model: /// - User-source alias steps pass EXEC through. The body lives in the user's diff --git a/src/commands/process.rs b/src/commands/process.rs index 3682635f8f..1208d20ee2 100644 --- a/src/commands/process.rs +++ b/src/commands/process.rs @@ -124,25 +124,6 @@ fn posix_command_separator(command: &str) -> &'static str { } } -/// Build the POSIX-shell payload for a detached spawn that pipes optional -/// `context_json` into `command`'s stdin. With a JSON context, wraps the -/// command in a `printf '%s' '…' | { …; }` group so the inner command receives -/// the JSON verbatim and pipeline parsing isn't perturbed by `&&`/`||` inside -/// `command`. Without one, returns `command` unchanged. Shared by the Unix -/// path and the Windows Git-Bash branch — both feed POSIX shells, so the JSON -/// is POSIX-single-quote-escaped regardless of host. -fn build_printf_pipe_command(command: &str, context_json: Option<&str>) -> String { - match context_json { - Some(json) => format!( - "printf '%s' {} | {{ {}{} }}", - shell_escape::unix::escape(json.into()), - command, - posix_command_separator(command) - ), - None => command.to_string(), - } -} - /// Create the log directory and file for a detached process. /// /// Returns `(log_path, log_file)`. Shared by `spawn_detached` and @@ -195,7 +176,6 @@ pub fn spawn_detached( command: &str, branch: &str, hook_log: &HookLog, - context_json: Option<&str>, ) -> anyhow::Result { let (log_path, log_file) = create_detach_log(repo, branch, hook_log)?; @@ -209,12 +189,12 @@ pub fn spawn_detached( #[cfg(unix)] { let low_priority = matches!(hook_log, HookLog::Internal(_) | HookLog::Shared(_)); - spawn_detached_unix(worktree_path, command, log_file, context_json, low_priority)?; + spawn_detached_unix(worktree_path, command, log_file, low_priority)?; } #[cfg(windows)] { - spawn_detached_windows(worktree_path, command, log_file, context_json)?; + spawn_detached_windows(worktree_path, command, log_file)?; } Ok(log_path) @@ -225,22 +205,15 @@ fn spawn_detached_unix( worktree_path: &Path, command: &str, log_file: fs::File, - context_json: Option<&str>, low_priority: bool, ) -> anyhow::Result<()> { use std::os::unix::process::CommandExt; - let full_command = build_printf_pipe_command(command, context_json); - // Wrap in braces so `&` backgrounds the entire compound command. // Without braces, `cmd1 && cmd2; cmd3 &` parses as two statements: // `cmd1 && cmd2` (foreground) then `cmd3 &` (background) — the semicolon // has lower precedence than `&`, so only the last segment is backgrounded. - let shell_cmd = format!( - "{{ {}{} }} &", - full_command, - posix_command_separator(&full_command) - ); + let shell_cmd = format!("{{ {}{} }} &", command, posix_command_separator(command)); // Detachment via process_group(0): puts the spawned shell in its own process group. // When the controlling PTY closes, SIGHUP is sent to the foreground process group. @@ -278,7 +251,6 @@ fn spawn_detached_windows( worktree_path: &Path, command: &str, log_file: fs::File, - context_json: Option<&str>, ) -> anyhow::Result<()> { use std::os::windows::process::CommandExt; use worktrunk::shell_exec::ShellConfig; @@ -290,28 +262,9 @@ fn spawn_detached_windows( let shell = ShellConfig::get()?; - // Build the command based on shell type - let mut cmd = if shell.is_posix { - // Git Bash available - use same syntax as Unix - let full_command = build_printf_pipe_command(command, context_json); - shell.command(&full_command) - } else { - // PowerShell fallback - let full_command = match context_json { - Some(json) => { - // PowerShell single-quote escaping: - // - Single quotes prevent variable expansion ($) and are literal - // - Backticks are literal in single quotes (NOT escape characters) - // - Only single quotes need doubling (`'` → `''`) - // See: https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_quoting_rules - let escaped_json = json.replace('\'', "''"); - // Pipe JSON to the command via PowerShell script block - format!("'{}' | & {{ {} }}", escaped_json, command) - } - None => command.to_string(), - }; - shell.command(&full_command) - }; + // Git Bash and the PowerShell fallback both run the command as written — + // nothing is piped in, so neither needs a wrapper around it. + let mut cmd = shell.command(command); cmd.current_dir(worktree_path) .stdin(Stdio::null()) @@ -546,7 +499,6 @@ pub fn sweep_stale_trash(repo: &Repository) { &command, "", &HookLog::shared(InternalOp::TrashSweep), - None, ) { tracing::debug!(error = %e, "Failed to spawn stale trash sweep: {e}"); } @@ -918,39 +870,6 @@ mod tests { assert_snapshot!(build_remove_command_staged(&special_path, &special_original, true), @"sleep 1 && rmdir -- '/tmp/test worktree' 2>/dev/null; rm -rf -- '/tmp/repo/.git/wt/trash/test worktree-123'"); } - #[test] - fn test_build_printf_pipe_command() { - // No JSON context: command passes through unchanged. - assert_snapshot!( - build_printf_pipe_command("echo hi", None), - @"echo hi" - ); - - // With JSON: command wrapped in a printf-pipe group. The JSON is - // POSIX-single-quote-escaped, the closing `}` is preceded by `;` - // because the command doesn't already end with one. - assert_snapshot!( - build_printf_pipe_command("jq .", Some(r#"{"branch":"main"}"#)), - @r#"printf '%s' '{"branch":"main"}' | { jq .; }"# - ); - - // Command that already ends in a semicolon: separator suppressed. - assert_snapshot!( - build_printf_pipe_command("jq .;", Some(r#"{"k":"v"}"#)), - @r#"printf '%s' '{"k":"v"}' | { jq .; }"# - ); - - // JSON containing characters POSIX shells treat specially must end up - // inside single quotes so command substitution doesn't fire inside the - // detached `sh -c` wrapping the spawn. The embedded single quote uses - // the standard `'\''` idiom. Regression guard for - // shell_escape::escape vs unix::escape on Windows. - assert_snapshot!( - build_printf_pipe_command("jq .", Some(r#"{"x":"$(echo pwned)","y":"a'b"}"#)), - @r#"printf '%s' '{"x":"$(echo pwned)","y":"a'\''b"}' | { jq .; }"# - ); - } - #[test] fn test_build_trash_sweep_command() { // Empty list still produces a well-formed command — the caller diff --git a/src/commands/run_pipeline.rs b/src/commands/run_pipeline.rs index a5b74d1454..c0e9719ab8 100644 --- a/src/commands/run_pipeline.rs +++ b/src/commands/run_pipeline.rs @@ -31,10 +31,11 @@ //! git config, so order matters for `vars.*`), so a later command's expansion //! can run after an earlier command's child has already started. //! -//! **Stdin**: every child receives the spec's context as JSON on stdin. The -//! foreground path inherits wt's stdin instead, so a single step there can -//! prompt (see `execute_shell_command` in `output/handlers.rs`). Commands that -//! don't read stdin ignore it. +//! **Stdin**: every child gets a closed stdin — this runner is detached, so +//! there is no terminal to hand over and nothing to read. The foreground path +//! inherits wt's stdin instead, so a step there can prompt (see +//! `execute_shell_command` in `output/handlers.rs`). Every template variable +//! reaches a step either way, through `{{ }}` expansion. //! //! ## Template freshness //! @@ -109,9 +110,8 @@ pub fn run_pipeline() -> anyhow::Result<()> { let log_file = create_command_log(&spec, &log_name)?; let step_ctx = step_context(&spec.context, name.as_deref()); let expanded = expand_shell_template(template, &step_ctx, &repo, template_name)?; - let step_json = step_ctx.to_json(); let (mut child, mut trace) = - spawn_shell_command(&expanded, &spec.worktree_path, &step_json, log_file)?; + spawn_shell_command(&expanded, &spec.worktree_path, log_file)?; let status = wait_resolving(&mut child, &mut trace, &expanded)?; if !status.success() { return Err(failure_error(&status, name.as_deref().unwrap_or(&expanded))); @@ -142,16 +142,18 @@ fn step_context<'a>(base: &'a TemplateContext, name: Option<&str>) -> Cow<'a, Te } } -/// Spawn a shell command with context JSON piped to stdin. +/// Spawn a shell command with its output redirected to a log file. /// /// Uses `ShellConfig` for portable shell detection (Git Bash on Windows, /// `sh` on Unix). stdout/stderr are redirected to `log_file` so each /// command gets its own log. Returns the `Child` so the caller controls /// when to wait. +/// +/// Stdin is closed: a detached step has no terminal, so a read returns EOF +/// rather than blocking on one that isn't there. fn spawn_shell_command( expanded: &str, worktree_path: &Path, - context_json: &str, log_file: fs::File, ) -> anyhow::Result<(Child, CommandTrace)> { let shell = ShellConfig::get()?; @@ -159,21 +161,19 @@ fn spawn_shell_command( .try_clone() .context("failed to clone log file handle")?; // Start the trace just before spawning; the caller resolves it once the - // child is waited on (see `wait_resolving`). The step is fed its own - // `context_json` on stdin, so mark it stdin-reading — the same command - // across worktrees isn't a duplicate (different per-worktree input). - let mut trace = CommandTrace::new(None, expanded).reads_stdin(true); + // child is waited on (see `wait_resolving`). + let mut trace = CommandTrace::new(None, expanded); let mut command = shell.command(expanded); command .current_dir(worktree_path) - .stdin(Stdio::piped()) + .stdin(Stdio::null()) .stdout(Stdio::from(log_file)) .stderr(Stdio::from(log_err)); // Background hooks, like foreground ones, discover their repo from the // worktree cwd, not an inherited GIT_DIR/GIT_WORK_TREE (issue #3373). This // runner only ever executes hook pipelines, so the scrub is unconditional. scrub_git_discovery_env_vars(&mut command); - let mut child = match command.spawn() { + let child = match command.spawn() { Ok(child) => child, Err(e) => { trace.fail(&e); @@ -181,13 +181,6 @@ fn spawn_shell_command( } }; - // Write context JSON to stdin, then drop to close the pipe. - if let Some(mut stdin) = child.stdin.take() { - use std::io::Write; - // Ignore BrokenPipe — child may exit or close stdin early. - let _ = stdin.write_all(context_json.as_bytes()); - } - Ok((child, trace)) } @@ -243,9 +236,8 @@ fn run_concurrent_group( let cmd_ctx = step_context(&spec.context, cmd.name.as_deref()); let expanded = expand_shell_template(&cmd.template, &cmd_ctx, repo, &cmd.template_name)?; - let cmd_json = cmd_ctx.to_json(); let (mut child, mut trace) = - spawn_shell_command(&expanded, &spec.worktree_path, &cmd_json, log_file)?; + spawn_shell_command(&expanded, &spec.worktree_path, log_file)?; *cmd_index += 1; if serial { diff --git a/src/config/expansion.rs b/src/config/expansion.rs index aa9ac3e6cb..c92d5d4e04 100644 --- a/src/config/expansion.rs +++ b/src/config/expansion.rs @@ -81,7 +81,7 @@ pub fn base_vars() -> Vec<&'static str> { /// Reserved context key carrying a JSON-encoded `Vec` of positional /// CLI args forwarded to an alias. The key flows through -/// [`TemplateContext`]'s flat string map — stable for stdin JSON — and +/// [`TemplateContext`]'s flat string map — stable for JSON round-trips — and /// [`expand_template`] rehydrates it as a `ShellArgs` object so bare /// `{{ args }}` renders as a space-joined, shell-escaped string while /// indexing, iteration, and `length` behave like a sequence. @@ -111,14 +111,14 @@ pub const LIST_COLUMN_VARS: &[&str] = &["branch", "worktree_path", "worktree_nam /// The resolved template variables for one command invocation. /// /// Wraps the map `build_hook_context` produces and owns every operation on -/// it: expansion, the JSON a hook child reads on stdin, and the `-v` variables -/// table. Callers hold this rather than a bare map so the borrow +/// it: expansion, the JSON a `wt step for-each` child reads on stdin, and the +/// `-v` variables table. Callers hold this rather than a bare map so the borrow /// [`expand_template`] needs, the `serde_json` call, and the `(unset)` / /// `(unused)` rendering each have one home. /// -/// Serializes transparently, so the JSON a child reads and the pipeline spec a -/// background runner deserializes are both the flat `{"branch": "…", …}` -/// object the [`ALIAS_ARGS_KEY`] contract describes. +/// Serializes transparently, so the JSON a for-each child reads and the +/// pipeline spec a background runner deserializes are both the flat +/// `{"branch": "…", …}` object the [`ALIAS_ARGS_KEY`] contract describes. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(transparent)] pub struct TemplateContext(HashMap); @@ -174,7 +174,7 @@ impl TemplateContext { expand_template_with(template, &vars, escape_mode, repo, name, vars_mode) } - /// The JSON form piped to a child's stdin. + /// The JSON form piped to a `wt step for-each` child's stdin. pub fn to_json(&self) -> String { serde_json::to_string(&self.0) .expect("HashMap serialization should never fail") @@ -191,8 +191,9 @@ pub enum VarScope<'a> { /// looked up. The set comes from `referenced_vars_for_config` or /// [`referenced_vars_for_templates`]. Referenced(&'a BTreeSet), - /// Something reads keys the templates never mention: a child consuming - /// the JSON on stdin, or a command whose output is the variable listing. + /// Something reads keys the templates never mention: a `wt step for-each` + /// child consuming the JSON on stdin, or a command whose output is the + /// variable listing. All, } diff --git a/src/output/concurrent.rs b/src/output/concurrent.rs index 6a8f13e2b6..b63eba7c0d 100644 --- a/src/output/concurrent.rs +++ b/src/output/concurrent.rs @@ -17,10 +17,10 @@ //! ## Execution model //! //! For each command: -//! 1. Spawn a shell child with stdout+stderr piped and (on Unix) its own -//! process group so SIGINT/SIGTERM can be delivered to the whole tree. -//! 2. Pipe `context_json` to stdin if provided, then close. -//! 3. Launch two reader threads that read lines and send labeled lines on +//! 1. Spawn a shell child with stdout+stderr piped, stdin closed, and (on +//! Unix) its own process group so SIGINT/SIGTERM can be delivered to the +//! whole tree. +//! 2. Launch two reader threads that read lines and send labeled lines on //! a shared channel. A single consumer writes to stderr — one writer //! preserves line atomicity so readers never mix bytes mid-line. //! @@ -64,9 +64,6 @@ pub struct ConcurrentCommand<'a> { pub expanded: &'a str, /// Child's working directory. pub working_dir: &'a Path, - /// JSON blob written to the child's stdin and closed. Callers that have - /// no context to pass should supply `"{}"`. - pub context_json: &'a str, /// Optional label for `commands.jsonl` tracing. pub log_label: Option<&'a str>, /// Directive file env vars to pass through to the child. See @@ -289,10 +286,14 @@ fn spawn_child( index: usize, cmd: &ConcurrentCommand<'_>, ) -> anyhow::Result { + // Each child gets a closed stdin: it can't have the terminal (siblings run + // at the same time and would race for it), and each runs in its own process + // group, where a read from the controlling terminal earns SIGTTIN and stops + // the child rather than returning. A closed stdin reads EOF immediately. let mut command = shell.command(cmd.expanded); command .current_dir(cmd.working_dir) - .stdin(Stdio::piped()) + .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); @@ -327,10 +328,8 @@ fn spawn_child( // Start the trace just before spawning so its duration brackets the real // spawn → wait span (the child keeps running while we drain its output). - // Each child is fed its own `context_json` on stdin, so mark it stdin-reading - // — the same command across worktrees isn't a duplicate (different input). - let mut trace = CommandTrace::new(None, cmd.expanded).reads_stdin(true); - let mut child = match command.spawn() { + let mut trace = CommandTrace::new(None, cmd.expanded); + let child = match command.spawn() { Ok(child) => child, Err(e) => { trace.fail(&e); @@ -339,11 +338,6 @@ fn spawn_child( } }; - if let Some(mut stdin) = child.stdin.take() { - // Ignore BrokenPipe — child may exit or close stdin early. - let _ = stdin.write_all(cmd.context_json.as_bytes()); - } - Ok(SpawnedChild { child, cmd_str: cmd.expanded.to_string(), @@ -498,7 +492,6 @@ mod tests { label, expanded: script, working_dir: &wd, - context_json: "{}", log_label, directives, scrub_git_discovery: false, diff --git a/src/output/handlers.rs b/src/output/handlers.rs index 6b9c331332..1c135bcc15 100644 --- a/src/output/handlers.rs +++ b/src/output/handlers.rs @@ -179,7 +179,6 @@ fn spawn_background_removal( &remove_command, log_label, &HookLog::internal(InternalOp::Remove), - None, )?; } Ok(fate) @@ -2055,9 +2054,10 @@ fn remove_removed_worktree_silently( /// the "Process groups and signal handling" module docs in /// [`worktrunk::shell_exec`] for what a shared pgroup costs on teardown. /// -/// The JSON context reaches the two forms that can't be interactive by other -/// spawn paths, not this one: concurrent groups write a per-child pipe in -/// `output/concurrent.rs`, and detached `post-*` pipelines write theirs in +/// Nothing is ever written to that stdin — a hook reads its context through +/// template variables, whatever form it runs in. The two forms that can't be +/// interactive close stdin instead, in their own spawn paths: concurrent groups +/// in `output/concurrent.rs`, detached `post-*` pipelines in /// `commands/run_pipeline.rs`. /// /// ## Directive files diff --git a/src/testing/mod.rs b/src/testing/mod.rs index eb250d5c93..a2fb8e99f0 100644 --- a/src/testing/mod.rs +++ b/src/testing/mod.rs @@ -3345,30 +3345,6 @@ pub fn wait_for_file_lines(path: &Path, expected_lines: usize) { ); } -/// Wait for a file to contain valid JSON, polling with exponential backoff. -/// Use when a background process writes JSON that may be partially written. -pub fn wait_for_valid_json(path: &Path) -> serde_json::Value { - let start = std::time::Instant::now(); - let mut attempt = 0; - let mut last_error = String::new(); - while start.elapsed() < BG_TIMEOUT { - if let Ok(content) = std::fs::read_to_string(path) { - match serde_json::from_str(&content) { - Ok(json) => return json, - Err(e) => last_error = format!("{e} (content: {content})"), - } - } - exponential_sleep(attempt); - attempt += 1; - } - panic!( - "File did not contain valid JSON within {:?}: {}\nLast error: {}", - BG_TIMEOUT, - path.display(), - last_error - ); -} - /// Poll until a condition is met, with exponential backoff. /// /// Use this instead of fixed sleeps for any condition that may take time to become true. diff --git a/tests/integration_tests/post_start_commands.rs b/tests/integration_tests/post_start_commands.rs index 8b8a2dd509..b594ddb45a 100644 --- a/tests/integration_tests/post_start_commands.rs +++ b/tests/integration_tests/post_start_commands.rs @@ -2,7 +2,6 @@ use crate::common::{ SLEEP_FOR_ABSENCE_CHECK, TestRepo, make_snapshot_cmd, make_snapshot_cmd_with_global_flags, repo, repo_with_remote, resolve_git_common_dir, set_temp_home_env, setup_snapshot_settings, wait_for_file, wait_for_file_content, wait_for_file_count, wait_for_file_lines, - wait_for_valid_json, }; use insta::assert_snapshot; use insta_cmd::assert_cmd_snapshot; @@ -549,10 +548,8 @@ fn test_pre_start_inherits_stdin(repo: TestRepo) { use std::process::Stdio; // Foreground (`pre-*`) hooks inherit the parent's stdin — same as aliases — - // so an interactive child keeps the controlling terminal. The legacy - // JSON-context-on-stdin contract no longer applies here (it survives only - // for concurrent and background `post-*` hooks). Capture whatever the hook - // sees on stdin to a file and verify it's the parent's raw stdin, not JSON. + // so an interactive child keeps the controlling terminal. Capture whatever + // the hook sees on stdin to a file and verify it's the parent's raw stdin. repo.write_project_config(r#"pre-start = "cat > captured.txt""#); repo.commit("Add config"); @@ -607,21 +604,18 @@ approved-commands = ["cat > captured.txt"] let contents = fs::read_to_string(&captured).unwrap(); assert_eq!( contents, "sentinel-from-parent-stdin\n", - "Foreground hook should receive the parent's raw stdin, not the JSON context" - ); - assert!( - !contents.contains("\"branch\""), - "The JSON context must not be piped to a foreground hook: {contents}" + "Foreground hook should receive the parent's raw stdin" ); } #[rstest] #[cfg(unix)] -fn test_post_start_script_reads_json(repo: TestRepo) { +fn test_post_start_script_reads_template_args(repo: TestRepo) { use crate::common::wt_command; use std::os::unix::fs::PermissionsExt; - // Create a scripts directory and a Python script that reads JSON from stdin + // Create a scripts directory and a Python script that reads its context + // from argv — the channel every hook has, whatever form it runs in. let scripts_dir = repo.root_path().join("scripts"); fs::create_dir_all(&scripts_dir).unwrap(); @@ -631,15 +625,14 @@ fn test_post_start_script_reads_json(repo: TestRepo) { // path, so shebang resolution can't piggyback on PATH directly. let python = which::which("python3").expect("python3 in PATH for test"); let script_body = r#" -import json import sys -ctx = json.load(sys.stdin) +repo, branch, hook_type, hook_name = sys.argv[1:5] with open('hook_output.txt', 'w') as f: - f.write(f"repo={ctx['repo']}\n") - f.write(f"branch={ctx['branch']}\n") - f.write(f"hook_type={ctx['hook_type']}\n") - f.write(f"hook_name={ctx.get('hook_name', 'unnamed')}\n") + f.write(f"repo={repo}\n") + f.write(f"branch={branch}\n") + f.write(f"hook_type={hook_type}\n") + f.write(f"hook_name={hook_name}\n") "#; let script_content = format!("#!{}{}", python.display(), script_body); let script_path = scripts_dir.join("setup.py"); @@ -647,23 +640,23 @@ with open('hook_output.txt', 'w') as f: fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755)).unwrap(); // Create project config that runs the script. Background (`post-*`) hooks - // run detached and still receive the JSON context on stdin — that's the - // path this script exercises. (Foreground `pre-*` hooks now inherit the - // terminal instead; see `test_pre_start_inherits_stdin`.) - repo.write_project_config( + // run detached with a closed stdin, so the template variables in the + // command line are how the script receives its context. + let command = "./scripts/setup.py {{ repo }} {{ branch }} {{ hook_type }} {{ hook_name }}"; + repo.write_project_config(&format!( r#"[post-start] -setup = "./scripts/setup.py" -"#, - ); +setup = "{command}" +"# + )); repo.commit("Add setup script and config"); // Pre-approve the command - repo.write_test_approvals( + repo.write_test_approvals(&format!( r#"[projects."../origin"] -approved-commands = ["./scripts/setup.py"] -"#, - ); +approved-commands = ["{command}"] +"# + )); // Create worktree let temp_home = TempDir::new().unwrap(); @@ -715,25 +708,28 @@ approved-commands = ["./scripts/setup.py"] } #[rstest] -fn test_post_start_json_stdin(repo: TestRepo) { +fn test_post_start_detached_hook_gets_no_stdin(repo: TestRepo) { use crate::common::wt_command; - // Create project config with a background command that reads JSON from stdin - repo.write_project_config(r#"post-start = "cat > context.json""#); + // A detached hook has no terminal to inherit and receives no piped payload, + // so a command that reads stdin sees EOF straight away. The `&& echo` marks + // completion, since the captured file itself is expected to stay empty. + let command = "cat > captured.txt && echo done > done.txt"; + repo.write_project_config(&format!(r#"post-start = "{command}""#)); repo.commit("Add config"); // Pre-approve the command - repo.write_test_approvals( + repo.write_test_approvals(&format!( r#"[projects."../origin"] -approved-commands = ["cat > context.json"] -"#, - ); +approved-commands = ["{command}"] +"# + )); // Create worktree let temp_home = TempDir::new().unwrap(); let mut cmd = wt_command(); - cmd.args(["switch", "--create", "bg-json"]) + cmd.args(["switch", "--create", "bg-stdin"]) .current_dir(repo.root_path()) .env("WORKTRUNK_CONFIG_PATH", repo.test_config_path()) .env("WORKTRUNK_APPROVALS_PATH", repo.test_approvals_path()); @@ -746,24 +742,14 @@ approved-commands = ["cat > context.json"] String::from_utf8_lossy(&output.stderr) ); - // Find the worktree and wait for valid JSON (polls until cat finishes writing) - let worktree_path = repo.root_path().parent().unwrap().join("repo.bg-json"); - let json_file = worktree_path.join("context.json"); - let json = wait_for_valid_json(&json_file); + // The hook is detached, so wait for its completion marker before reading. + let worktree_path = repo.root_path().parent().unwrap().join("repo.bg-stdin"); + wait_for_file_content(&worktree_path.join("done.txt")); + let captured = fs::read_to_string(worktree_path.join("captured.txt")).unwrap(); assert_eq!( - json["branch"].as_str(), - Some("bg-json"), - "Background hook should receive JSON with branch" - ); - assert!( - json.get("repo").is_some(), - "Background hook should receive JSON with repo" - ); - assert_eq!( - json["hook_type"].as_str(), - Some("post-start"), - "Background hook should receive hook_type" + captured, "", + "a detached hook should read EOF — no JSON context, no inherited stdin" ); } diff --git a/tests/integration_tests/user_hooks.rs b/tests/integration_tests/user_hooks.rs index 587560ba22..59e9bcc568 100644 --- a/tests/integration_tests/user_hooks.rs +++ b/tests/integration_tests/user_hooks.rs @@ -1862,9 +1862,9 @@ fn test_standalone_hook_post_start_foreground_inherits_stdin(repo: TestRepo) { use std::process::Stdio; // `--foreground` routes a `post-*` hook through the single-step foreground - // path, which inherits wt's stdin rather than piping the JSON context — so - // the hook can prompt in the mode that exists to debug it. (The detached - // default still gets the JSON; see `test_post_start_json_stdin`.) + // path, which inherits wt's stdin — so the hook can prompt in the mode that + // exists to debug it. (The detached default reads EOF; see + // `test_post_start_detached_hook_gets_no_stdin`.) repo.write_project_config(r#"post-start = "cat > captured.txt""#); let mut cmd = crate::common::wt_command(); @@ -1899,7 +1899,7 @@ fn test_standalone_hook_post_start_foreground_inherits_stdin(repo: TestRepo) { let contents = fs::read_to_string(&captured).unwrap(); assert_eq!( contents, "sentinel-from-parent-stdin\n", - "`--foreground` should hand the hook the parent's raw stdin, not the JSON context" + "`--foreground` should hand the hook the parent's raw stdin" ); } @@ -1951,14 +1951,14 @@ fn test_foreground_pipeline_steps_share_one_stdin(repo: TestRepo) { } #[rstest] -fn test_standalone_hook_concurrent_group_keeps_json_under_foreground(repo: TestRepo) { +fn test_standalone_hook_concurrent_group_gets_no_stdin_under_foreground(repo: TestRepo) { use std::io::Write; use std::process::Stdio; - // A multi-key table parses as `HookStep::Concurrent`, whose children each - // get their own JSON pipe — so the terminal `--foreground` hands a lone - // step never reaches them. This is the shape that silently takes the tty - // away from a `gum confirm`, so pin it rather than leaving it to the docs. + // A multi-key table parses as `HookStep::Concurrent`, whose children run at + // the same time and so can't share one terminal — each gets a closed stdin + // instead. This is the shape that silently takes the tty away from a + // `gum confirm`, so pin it rather than leaving it to the docs. repo.write_project_config( r#"[post-start] a = "cat > cap_a.txt" @@ -1989,17 +1989,10 @@ b = "true" String::from_utf8_lossy(&output.stderr) ); - let captured = repo.root_path().join("cap_a.txt"); - let contents = fs::read_to_string(&captured).unwrap(); - let json: serde_json::Value = serde_json::from_str(&contents).unwrap_or_else(|e| { - panic!( - "a concurrent child should still receive the JSON context: {e}\nContents: {contents}" - ) - }); + let contents = fs::read_to_string(repo.root_path().join("cap_a.txt")).unwrap(); assert_eq!( - json["hook_type"].as_str(), - Some("post-start"), - "the JSON context should name the hook type" + contents, "", + "a concurrent child should read EOF — neither the parent's stdin nor a JSON context" ); } From d9cc02311197b6996857b49cd9198bdf23f8f4a3 Mon Sep 17 00:00:00 2001 From: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com> Date: Thu, 20 Aug 2026 23:04:01 +0000 Subject: [PATCH 14/18] fix(process): scope `posix_command_separator` to the Unix spawn that uses it --- src/commands/process.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/commands/process.rs b/src/commands/process.rs index bc7bfdf56d..da106ab276 100644 --- a/src/commands/process.rs +++ b/src/commands/process.rs @@ -106,6 +106,10 @@ impl HookLog { /// Get the separator needed before closing brace in POSIX shell command grouping. /// Returns empty string if command already ends with newline or semicolon. +/// +/// Unix-only: the `{ …; } &` grouping it feeds is `spawn_detached_unix`'s +/// backgrounding wrapper, which the Windows spawn has no counterpart for. +#[cfg(unix)] fn posix_command_separator(command: &str) -> &'static str { if command.ends_with('\n') || command.ends_with(';') { "" @@ -803,6 +807,7 @@ mod tests { } #[test] + #[cfg(unix)] fn test_posix_command_separator() { // Commands ending with newline don't need separator assert_eq!(posix_command_separator("echo hello\n"), ""); From e9974030322861de5b0e006e81eaf8c184f72a0f Mon Sep 17 00:00:00 2001 From: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com> Date: Thu, 20 Aug 2026 23:31:11 +0000 Subject: [PATCH 15/18] docs(hook): a multi-key `pre-*` table is a concurrent group, so it reads 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. --- docs/content/hook.md | 2 +- plugins/worktrunk/skills/worktrunk/reference/hook.md | 2 +- skills/worktrunk/reference/hook.md | 2 +- src/cli/mod.rs | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/content/hook.md b/docs/content/hook.md index 454190a4d6..f508ebf51a 100644 --- a/docs/content/hook.md +++ b/docs/content/hook.md @@ -268,7 +268,7 @@ A hook running in the foreground inherits wt's stdin, so it can ask before conti trust = "gum confirm 'trust this worktree?' && mise trust" ``` -That covers `pre-*` hooks and any type under `wt hook --foreground`. Everything else reads EOF: a detached `post-*` hook has no terminal, and the children of a concurrent group would race for one. Nothing is ever piped in — a hook reads its context through template variables, whatever form it runs in. +That covers `pre-*` hooks and any type under `wt hook --foreground`, except where the hook is a concurrent group — a table with two or more keys, whose children would race for the terminal, so each reads EOF instead. A detached `post-*` hook reads EOF too, having no terminal at all. Nothing is ever piped in — a hook reads its context through template variables, whatever form it runs in. Foreground steps run in order and share one stdin, so a step that drains it to EOF — a `cat`, a `read` — leaves nothing for the steps behind it when that stdin is a pipe or a file. Under a terminal each step can prompt in turn. Steps accumulate across config files, so a user `[pre-start]` and a project `[pre-start]` form one pipeline. diff --git a/plugins/worktrunk/skills/worktrunk/reference/hook.md b/plugins/worktrunk/skills/worktrunk/reference/hook.md index 9af5237d66..ecdc930867 100644 --- a/plugins/worktrunk/skills/worktrunk/reference/hook.md +++ b/plugins/worktrunk/skills/worktrunk/reference/hook.md @@ -259,7 +259,7 @@ A hook running in the foreground inherits wt's stdin, so it can ask before conti trust = "gum confirm 'trust this worktree?' && mise trust" ``` -That covers `pre-*` hooks and any type under `wt hook --foreground`. Everything else reads EOF: a detached `post-*` hook has no terminal, and the children of a concurrent group would race for one. Nothing is ever piped in — a hook reads its context through template variables, whatever form it runs in. +That covers `pre-*` hooks and any type under `wt hook --foreground`, except where the hook is a concurrent group — a table with two or more keys, whose children would race for the terminal, so each reads EOF instead. A detached `post-*` hook reads EOF too, having no terminal at all. Nothing is ever piped in — a hook reads its context through template variables, whatever form it runs in. Foreground steps run in order and share one stdin, so a step that drains it to EOF — a `cat`, a `read` — leaves nothing for the steps behind it when that stdin is a pipe or a file. Under a terminal each step can prompt in turn. Steps accumulate across config files, so a user `[pre-start]` and a project `[pre-start]` form one pipeline. diff --git a/skills/worktrunk/reference/hook.md b/skills/worktrunk/reference/hook.md index 9af5237d66..ecdc930867 100644 --- a/skills/worktrunk/reference/hook.md +++ b/skills/worktrunk/reference/hook.md @@ -259,7 +259,7 @@ A hook running in the foreground inherits wt's stdin, so it can ask before conti trust = "gum confirm 'trust this worktree?' && mise trust" ``` -That covers `pre-*` hooks and any type under `wt hook --foreground`. Everything else reads EOF: a detached `post-*` hook has no terminal, and the children of a concurrent group would race for one. Nothing is ever piped in — a hook reads its context through template variables, whatever form it runs in. +That covers `pre-*` hooks and any type under `wt hook --foreground`, except where the hook is a concurrent group — a table with two or more keys, whose children would race for the terminal, so each reads EOF instead. A detached `post-*` hook reads EOF too, having no terminal at all. Nothing is ever piped in — a hook reads its context through template variables, whatever form it runs in. Foreground steps run in order and share one stdin, so a step that drains it to EOF — a `cat`, a `read` — leaves nothing for the steps behind it when that stdin is a pipe or a file. Under a terminal each step can prompt in turn. Steps accumulate across config files, so a user `[pre-start]` and a project `[pre-start]` form one pipeline. diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 7e6d11f012..f13e544164 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1802,7 +1802,7 @@ A hook running in the foreground inherits wt's stdin, so it can ask before conti trust = "gum confirm 'trust this worktree?' && mise trust" ``` -That covers `pre-*` hooks and any type under `wt hook --foreground`. Everything else reads EOF: a detached `post-*` hook has no terminal, and the children of a concurrent group would race for one. Nothing is ever piped in — a hook reads its context through template variables, whatever form it runs in. +That covers `pre-*` hooks and any type under `wt hook --foreground`, except where the hook is a concurrent group — a table with two or more keys, whose children would race for the terminal, so each reads EOF instead. A detached `post-*` hook reads EOF too, having no terminal at all. Nothing is ever piped in — a hook reads its context through template variables, whatever form it runs in. Foreground steps run in order and share one stdin, so a step that drains it to EOF — a `cat`, a `read` — leaves nothing for the steps behind it when that stdin is a pipe or a file. Under a terminal each step can prompt in turn. Steps accumulate across config files, so a user `[pre-start]` and a project `[pre-start]` form one pipeline. From 77528d9b7bc80bfd8063c6090dec6bb2dec6205b Mon Sep 17 00:00:00 2001 From: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com> Date: Thu, 20 Aug 2026 23:31:17 +0000 Subject: [PATCH 16/18] docs: name what `prepare_steps`' `VarScope::All` still buys, and what it costs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/commands/command_executor.rs | 9 +++++++++ tests/integration_tests/step_alias.rs | 7 ++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/commands/command_executor.rs b/src/commands/command_executor.rs index ed8e42bc57..790fa981e1 100644 --- a/src/commands/command_executor.rs +++ b/src/commands/command_executor.rs @@ -238,6 +238,15 @@ impl<'a> CommandContext<'a> { /// body happens to reference: `wt step eval -v`, and the hook pipeline's own /// `format_hook_variables` table. /// +/// The hook pipeline's reader is conditional, so its `All` in [`prepare_steps`] +/// buys nothing at default verbosity — `format_hook_variables` renders only at +/// `verbosity() >= 1`. A hook whose templates name nothing but `{{ branch }}` +/// still pays for `rev-parse --verify`, `primary_worktree()`, +/// `primary_remote()`, and `default_branch()`, whose first call per repo may +/// reach `git ls-remote`. Narrowing it wants a scope that varies with +/// verbosity, or a separate entry point for the paths that list or preview; +/// neither belongs in the change that removed the JSON reader. +/// /// The template-preview paths (`render_hook_commands`, `wt config alias`) fit /// neither case and still pass `All`: they expand and print one line per /// configured command, so filtering would be correct but saves nothing an diff --git a/tests/integration_tests/step_alias.rs b/tests/integration_tests/step_alias.rs index 3922b1eeeb..e2bc3e2e50 100644 --- a/tests/integration_tests/step_alias.rs +++ b/tests/integration_tests/step_alias.rs @@ -1078,8 +1078,9 @@ fn test_user_and_project_alias_collision_scrubs_only_project_step(repo: TestRepo /// /// Regression test for #406: alias execution used to pipe the template /// context JSON into each child's stdin, which displaced the tty and broke -/// `stdin().is_terminal()` guards in interactive commands. Only hooks have a -/// documented JSON-on-stdin contract; aliases must leave stdin alone. +/// `stdin().is_terminal()` guards in interactive commands. Hooks no longer +/// pipe JSON either, so the `"branch"` assertion below pins the rule for +/// every foreground step rather than a hooks-vs-aliases distinction. #[rstest] fn test_alias_inherits_stdin(repo: TestRepo) { repo.write_test_config( @@ -1124,7 +1125,7 @@ echo-stdin = "cat" // "branch" key) to stdin, so `cat` would have echoed that instead. assert!( !combined.contains("\"branch\""), - "alias stdin should not receive the hook JSON context, \ + "no foreground step should receive a JSON context on stdin, \ got stdout={stdout:?} stderr={stderr:?}", ); } From 1003304efa22bdb07f700163c96fd45ac7bb60bd Mon Sep 17 00:00:00 2001 From: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:13:09 +0000 Subject: [PATCH 17/18] docs: correct the shared-tty and EXEC-passthrough module docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both are review findings against the merge of main. The Shared-tty bullet in `shell_exec.rs` claimed foreground pipeline steps were the only `Cmd` call site pairing `forward_signals()` with `inherit_stdin()`. #3977 added a second: `execute_command` in `output/global.rs`, the `wt switch --execute` launcher. The undercount mattered because the next sentence is the `killpg` caveat — a reader would have concluded a `wt switch -x cargo test` subtree *is* reachable by `killpg`. Name both sites and generalise the caveat to "such a child's" so it covers the `--execute` program too. `sourced_steps_to_foreground`'s doc still said the `source` field drives the per-step trust model, but #3977 left an unconditional `DirectivePassthrough::inherit_from_env()` there, so `source` selects nothing. The same stale clause sits on `PipelineKind` and on `ForegroundStep::directives` in `command_executor.rs`; corrected together so the two files don't contradict each other. Doc comments only — no behavior change. --- src/commands/command_executor.rs | 14 +++++++------- src/commands/hooks.rs | 6 +++--- src/shell_exec.rs | 10 +++++----- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/commands/command_executor.rs b/src/commands/command_executor.rs index e27da800cc..26ac813992 100644 --- a/src/commands/command_executor.rs +++ b/src/commands/command_executor.rs @@ -85,8 +85,8 @@ pub type ErrorWrapper = Box) -> any /// /// Supplied at conversion time (`sourced_steps_to_foreground`) so a single /// `SourcedStep` shape can be produced by both alias and hook resolution. -/// Drives the per-step trust model (EXEC passthrough), announce policy, -/// stdout redirection, and error wrapping. Hook-only metadata +/// Drives announce policy, stdout redirection, and error wrapping. Hook-only +/// metadata /// (`hook_type`, `display_path`) lives on the `Hook` variant — it's /// per-pipeline, not per-step, so the per-step shape stays neutral. #[derive(Clone)] @@ -134,11 +134,11 @@ pub struct ForegroundStep { pub redirect_stdout_to_stderr: bool, /// Wraps a per-command failure into the final error returned to the caller. pub error_wrapper: ErrorWrapper, - /// Per-step directive passthrough. Trust differs by source — user-source - /// alias steps pass EXEC through (the body is the user's own config), - /// while project-source steps and all hook steps scrub it. Per-step rather - /// than per-pipeline so a merged user+project alias relaxes the user's - /// own steps without leaking the project's body into the parent shell. + /// Per-step directive passthrough. Every step carries the same + /// `DirectivePassthrough::inherit_from_env()` — the CD file, so a nested + /// `wt switch` can still move the parent shell. The source-dependent EXEC + /// passthrough this field used to select is gone (#3977); the shape stays + /// per-step because that is where the pipeline builds it. pub directives: DirectivePassthrough, } diff --git a/src/commands/hooks.rs b/src/commands/hooks.rs index 1dddec8692..26c19fcb37 100644 --- a/src/commands/hooks.rs +++ b/src/commands/hooks.rs @@ -526,9 +526,9 @@ fn spawn_hook_pipeline_quiet(repo: &Repository, pipeline: PendingPipeline) -> an /// Convert source-tagged steps into foreground steps with pipeline-kind policy. /// /// Shared between hook and alias dispatch. The `kind` argument supplies the -/// per-call-site policy (announce style, stdout redirection, error wrapping) -/// while the `source` field on each step drives the per-step trust model -/// (`DirectivePassthrough`). +/// per-call-site policy (announce style, stdout redirection, error wrapping). +/// Every step gets the same `DirectivePassthrough::inherit_from_env()`; the +/// EXEC passthrough the `source` field used to select is gone (#3977). /// /// Foreground steps — hook and alias alike — inherit the parent's stdin so an /// interactive child keeps the controlling terminal (a `pre-*` hook can prompt; diff --git a/src/shell_exec.rs b/src/shell_exec.rs index 4905e0a89b..3443b6ad31 100644 --- a/src/shell_exec.rs +++ b/src/shell_exec.rs @@ -27,11 +27,11 @@ //! additionally delivers externally-targeted signals (e.g. `kill -TERM //! `) to the child by PID, single-shot. Used for every `Single` //! foreground step of a hook or alias pipeline, which inherits wt's stdin so -//! the step can prompt — since #3129 the only `Cmd` call site with this pair. -//! A foreground step's own subtree is therefore not reachable by `killpg`: an -//! externally-targeted signal reaches the step's shell by PID and stops -//! there, while Ctrl-C still reaches the whole subtree through the kernel's -//! broadcast. +//! the step can prompt, and for the program `wt switch --execute` launches +//! (`execute_command` in `output/global.rs`). Such a child's own subtree is +//! therefore not reachable by `killpg`: an externally-targeted signal +//! reaches the child by PID and stops there, while Ctrl-C still reaches the +//! whole subtree through the kernel's broadcast. //! //! In both cases the listener still records `seen_signal`, so a signal-derived //! exit surfaces as `WorktrunkError::ChildProcessExited { signal: Some(_) }` — From 1709bfb62c0b83955ce330f2d209bcb4902c799a Mon Sep 17 00:00:00 2001 From: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:29:45 +0000 Subject: [PATCH 18/18] docs: retire the last EXEC-passthrough claims and complete PipelineKind's list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `AliasEntry`'s docstring still described the per-source EXEC regime as live behavior ("user steps skip approval and pass EXEC through; project steps require approval and scrub EXEC"), contradicting the same file's module doc ~300 lines above, which #3977 already updated. The EXEC directive var is retired: `scrub_directive_env_vars` removes it unconditionally and `directive_cd_file` re-adds only CD, so both regimes scrub it. The approval half of the sentence still holds and stays. `ConcurrentCommand::directives` pointed at "`DirectivePassthrough` for the trust model (CD passthrough, EXEC scrub)" — the type has one field and no EXEC dimension, so that no longer resolves to anything. `PipelineKind`'s doc listed three things the type drives but omitted two: `log_label` (the `commands.jsonl` trace label) and `is_hook` (whether the child's inherited `GIT_DIR`/`GIT_WORK_TREE` are scrubbed), both read off `fg_step.announce` in `run_one_command`. Splitting the list by when each applies also fixes the rewrap that left `metadata` alone on its own line. `sourced_steps_to_foreground`'s closing paragraph restated the CD inheritance the paragraph above it already names by constructor; dropped. Doc comments only, no behavior change. --- src/commands/alias.rs | 5 +++-- src/commands/command_executor.rs | 5 +++-- src/commands/hooks.rs | 3 --- src/output/concurrent.rs | 3 ++- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/commands/alias.rs b/src/commands/alias.rs index ca3098b2b9..59205c6072 100644 --- a/src/commands/alias.rs +++ b/src/commands/alias.rs @@ -330,8 +330,9 @@ fn format_alias_announcement(name: &str, entry: &AliasEntry) -> Option { /// `load_aliases` returns (a name only appears in the map if some source /// defines it). When both are set, both bodies run — user first, then /// project — and each runs under its own trust regime: user steps skip -/// approval and pass EXEC through (issue #2101); project steps require -/// approval and scrub EXEC. +/// approval, project steps require it. Directive passthrough no longer +/// varies by source: the EXEC directive file is retired (#3977), so every +/// child scrubs it and only the CD file is passed through. pub(crate) struct AliasEntry { pub user: Option, pub project: Option, diff --git a/src/commands/command_executor.rs b/src/commands/command_executor.rs index 26ac813992..70e32094ae 100644 --- a/src/commands/command_executor.rs +++ b/src/commands/command_executor.rs @@ -85,8 +85,9 @@ pub type ErrorWrapper = Box) -> any /// /// Supplied at conversion time (`sourced_steps_to_foreground`) so a single /// `SourcedStep` shape can be produced by both alias and hook resolution. -/// Drives announce policy, stdout redirection, and error wrapping. Hook-only -/// metadata +/// Drives announce policy, stdout redirection, and error wrapping at +/// conversion time, plus the `commands.jsonl` trace label (`log_label`) and +/// git-discovery scrubbing (`is_hook`) at execution. Hook-only metadata /// (`hook_type`, `display_path`) lives on the `Hook` variant — it's /// per-pipeline, not per-step, so the per-step shape stays neutral. #[derive(Clone)] diff --git a/src/commands/hooks.rs b/src/commands/hooks.rs index 26c19fcb37..5cf6aedfd3 100644 --- a/src/commands/hooks.rs +++ b/src/commands/hooks.rs @@ -536,9 +536,6 @@ fn spawn_hook_pipeline_quiet(repo: &Repository, pipeline: PendingPipeline) -> an /// interactive get a closed stdin rather than a substitute payload: concurrent /// children (they'd race for the terminal) and detached (`post-*`) hooks (there /// is none). Template variables carry the context in every form. -/// -/// Every foreground step inherits the CD directive so a nested switch can -/// still move the parent shell. pub(crate) fn sourced_steps_to_foreground( sourced_steps: Vec, kind: &PipelineKind, diff --git a/src/output/concurrent.rs b/src/output/concurrent.rs index 191e98c8e8..a657a2473d 100644 --- a/src/output/concurrent.rs +++ b/src/output/concurrent.rs @@ -66,7 +66,8 @@ pub struct ConcurrentCommand<'a> { /// Optional label for `commands.jsonl` tracing. pub log_label: Option<&'a str>, /// Directive file env vars to pass through to the child. See - /// `DirectivePassthrough` for the trust model (CD passthrough, EXEC scrub). + /// `DirectivePassthrough`: the CD directive file is the only one passed + /// through — every child scrubs the rest. pub directives: &'a DirectivePassthrough, /// Scrub inherited git-discovery vars (`GIT_DIR`/`GIT_WORK_TREE`/…) from the /// child. `true` for hooks (they operate on the worktree wt targets), `false`