Files
max-sixty__worktrunk/tests/integration_tests/approval_ui.rs
Worktrunk Bot ec62580c29 revert(hooks): keep docs on pre-start/post-start; code accepts both (#2857)
Per @max-sixty's [direction in
#2838](https://github.com/max-sixty/worktrunk/issues/2838#issuecomment-4509447593):
revert the docs portion of #2840 and keep the code. Docs continue to
recommend `pre-start`/`post-start`; both names work in code so anyone
who already followed the briefly-changed docs (e.g. @EcksDy) isn't
stranded once a release ships these aliases.

## User-visible — back to `pre-start`/`post-start`

- README, docs site, skill mirrors, `dev/*.example.toml`,
`plugins/worktrunk/README.md`, `flake.nix`, `.config/wt.toml`
- `src/cli/mod.rs` / `src/cli/config.rs` / `src/cli/step.rs` /
`src/help.rs` after_long_help and example snippets — and the auto-synced
`docs/content/` and `skills/worktrunk/reference/` mirrors
- `wt hook --help` canonical subcommand names; completion advertises
`-start` only
- `HookType` Display via strum, serde `rename`, and clap `ValueEnum`
name — all `pre-start`/`post-start`. The Rust variant identifiers stay
`PreCreate`/`PostCreate` (internal; we already paid for that rename in
#2840, and now the eventual flip is a Display-only change)
- `HooksConfig` serde canonical fields

## `*-create` still works (kept code)

- `wt hook pre-create` / `post-create` — CLI alias on the canonical
subcommand
- `pre-create` / `post-create` in config: top-level, `[hooks.*]`, and
per-project, in string, `[table]`, and `[[array-of-tables]]` form.
Mechanism: serde `alias = ...` on the field, plus a silent in-memory
rename in `migrate_content()` so the round-trip in `unknown_tree`
doesn't flag table forms as schema-unknown.
- The pre-0.32.0 `post-create` fatal-load-error machinery stays removed
— the name is reclaimed, and both forms load without error.

## Smaller bits

- `valid_user_config_keys()` / `valid_project_config_keys()` append
`pre-create` / `post-create` so the unknown-field round-trip skips them.
`test_valid_*_keys_all_deserialize` skips both aliases (they can't sit
alongside the canonical without a duplicate-field error).
- `DEPRECATED_SECTION_KEYS` drops the `pre-start`/`post-start` entries
#2840 added — `pre-start`/`post-start` are canonical again.
- `find_pre_start_from_doc` / `find_post_start_from_doc` /
`find_renamed_hook_key` / `is_non_empty_item` /
`migrate_start_hooks_doc` and their tests are removed; the migration
direction flips via a new `migrate_create_hooks_doc` (silent, mirrors
the prior shape).
- Test files `e2e_shell_post_create.rs` and `post_create_commands.rs`
rename back to `_post_start_` (via `git mv`, so the rename shows as a
rename).

## Testing

`cargo run -- hook pre-merge --yes` — 3806 tests pass; the 10 failures
are all `case_4` of `shell_wrapper::unix_tests::*` (nu-shell case; `nu`
isn't installed in this runner; same failures occur on `main`).

Also manually verified that a fresh `wt switch --create` against a
project config with `[post-create]` loads cleanly with no unknown-field
warning and the hook fires as `post-start`.

## Follow-up

Per @max-sixty: in a couple of weeks, once a release with
both-names-work is out and users have had a chance to upgrade, the docs
flip is straightforward (most of it is in `src/cli/mod.rs`'s
`after_long_help` and the doc-sync test propagates).

Re #2838.

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 16:03:03 +00:00

798 lines
23 KiB
Rust

//! Tests for command approval UI
use crate::common::{TestRepo, make_snapshot_cmd, repo};
use insta_cmd::assert_cmd_snapshot;
use rstest::rstest;
use std::fs;
use std::io::Write;
use std::process::Stdio;
/// Helper to create snapshot with test environment
fn snapshot_approval(test_name: &str, repo: &TestRepo, args: &[&str], approve: bool) {
let mut cmd = make_snapshot_cmd(repo, "switch", args, None);
cmd.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = cmd.spawn().unwrap();
// Write approval response
{
let stdin = child.stdin.as_mut().unwrap();
let response = if approve { b"y\n" } else { b"n\n" };
stdin.write_all(response).unwrap();
}
let output = child.wait_with_output().unwrap();
// Use insta snapshot for combined output
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let combined = format!(
"exit_code: {}\n----- stdout -----\n{}\n----- stderr -----\n{}",
output.status.code().unwrap_or(-1),
stdout,
stderr
);
insta::assert_snapshot!(test_name, combined);
}
#[rstest]
fn test_approval_single_command(repo: TestRepo) {
repo.write_project_config(r#"pre-start = "echo 'Worktree path: {{ worktree_path }}'""#);
repo.commit("Add config");
snapshot_approval(
"approval_single_command",
&repo,
&["--create", "feature/test-approval"],
false,
);
}
#[rstest]
fn test_approval_multiple_commands(repo: TestRepo) {
repo.write_project_config(
r#"[pre-start]
branch = "echo 'Branch: {{ branch }}'"
worktree = "echo 'Worktree: {{ worktree_path }}'"
repo = "echo 'Repo: {{ repo }}'"
pwd = "cd {{ worktree_path }} && pwd"
"#,
);
repo.commit("Add config");
snapshot_approval(
"approval_multiple_commands",
&repo,
&["--create", "test/nested-branch"],
false,
);
}
#[rstest]
fn test_approval_mixed_approved_unapproved(repo: TestRepo) {
// Remove origin so worktrunk uses directory name as project identifier
repo.run_git(&["remote", "remove", "origin"]);
repo.write_project_config(
r#"[pre-start]
first = "echo 'First command'"
second = "echo 'Second command'"
third = "echo 'Third command'"
"#,
);
repo.commit("Add config");
// Pre-approve the second command
repo.write_test_approvals(&format!(
r#"[projects.'{}']
approved-commands = ["echo 'Second command'"]
"#,
repo.project_id()
));
snapshot_approval(
"approval_mixed_approved_unapproved",
&repo,
&["--create", "test-mixed"],
false,
);
}
#[rstest]
fn test_yes_flag_does_not_save_approvals(repo: TestRepo) {
repo.write_project_config(r#"pre-start = "echo 'test command' > output.txt""#);
repo.commit("Add config");
// Run with --yes
assert_cmd_snapshot!(
"yes_does_not_save_approvals_first_run",
make_snapshot_cmd(&repo, "switch", &["--create", "test-yes", "--yes"], None)
);
// Clean up the worktree
repo.wt_command()
.args(["remove", "test-yes", "--yes"])
.output()
.unwrap();
// Run again WITHOUT --yes - should prompt
snapshot_approval(
"yes_does_not_save_approvals_second_run",
&repo,
&["--create", "test-yes-2"],
false,
);
}
#[rstest]
fn test_already_approved_commands_skip_prompt(repo: TestRepo) {
// Remove origin so worktrunk uses directory name as project identifier
repo.run_git(&["remote", "remove", "origin"]);
repo.write_project_config(r#"pre-start = "echo 'approved' > output.txt""#);
repo.commit("Add config");
// Pre-approve the command
repo.write_test_approvals(&format!(
r#"[projects.'{}']
approved-commands = ["echo 'approved' > output.txt"]
"#,
repo.project_id()
));
// Should execute without prompting
assert_cmd_snapshot!(
"already_approved_skip_prompt",
make_snapshot_cmd(&repo, "switch", &["--create", "test-approved"], None)
);
}
#[rstest]
fn test_decline_approval_skips_only_unapproved(repo: TestRepo) {
// Remove origin so worktrunk uses directory name as project identifier
repo.run_git(&["remote", "remove", "origin"]);
repo.write_project_config(
r#"[pre-start]
first = "echo 'First command'"
second = "echo 'Second command'"
third = "echo 'Third command'"
"#,
);
repo.commit("Add config");
// Pre-approve the second command
fs::write(
repo.test_approvals_path(),
format!(
r#"[projects.'{}']
approved-commands = ["echo 'Second command'"]
"#,
repo.project_id()
),
)
.unwrap();
snapshot_approval(
"decline_approval_skips_only_unapproved",
&repo,
&["--create", "test-decline"],
false,
);
}
#[rstest]
fn test_approval_named_commands(repo: TestRepo) {
repo.write_project_config(
r#"[pre-start]
install = "echo 'Installing dependencies...'"
build = "echo 'Building project...'"
test = "echo 'Running tests...'"
"#,
);
repo.commit("Add config");
snapshot_approval(
"approval_named_commands",
&repo,
&["--create", "test-named"],
false,
);
}
/// Helper for step hook snapshot tests with approval prompt
fn snapshot_run_hook(test_name: &str, repo: &TestRepo, hook_type: &str, approve: bool) {
let mut cmd = make_snapshot_cmd(repo, "hook", &[hook_type], None);
cmd.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = cmd.spawn().unwrap();
// Write approval response
{
let stdin = child.stdin.as_mut().unwrap();
let response = if approve { b"y\n" } else { b"n\n" };
stdin.write_all(response).unwrap();
}
let output = child.wait_with_output().unwrap();
// Use insta snapshot for combined output
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let combined = format!(
"exit_code: {}\n----- stdout -----\n{}\n----- stderr -----\n{}",
output.status.code().unwrap_or(-1),
stdout,
stderr
);
insta::assert_snapshot!(test_name, combined);
}
///
/// This verifies the fix for the security issue where hooks were bypassing approval.
/// Before the fix, pre-merge hooks ran with auto_trust=true, skipping approval prompts.
#[rstest]
fn test_run_hook_pre_merge_requires_approval(repo: TestRepo) {
repo.write_project_config(r#"pre-merge = "echo 'Running pre-merge checks on {{ branch }}'""#);
repo.commit("Add pre-merge hook");
// Decline approval to verify the prompt appears
snapshot_run_hook(
"run_hook_pre_merge_requires_approval",
&repo,
"pre-merge",
false,
);
}
///
/// This verifies the fix for the security issue where hooks were bypassing approval.
/// Before the fix, post-merge hooks ran with auto_trust=true, skipping approval prompts.
#[rstest]
fn test_run_hook_post_merge_requires_approval(repo: TestRepo) {
repo.write_project_config(r#"post-merge = "echo 'Post-merge cleanup for {{ branch }}'""#);
repo.commit("Add post-merge hook");
// Decline approval to verify the prompt appears
snapshot_run_hook(
"run_hook_post_merge_requires_approval",
&repo,
"post-merge",
false,
);
}
///
/// When stdin is not a TTY (e.g., CI/CD, piped input), approval prompts cannot be shown.
/// The command should fail with a clear error telling users to use --yes.
#[rstest]
fn test_approval_fails_in_non_tty(repo: TestRepo) {
repo.write_project_config(r#"pre-start = "echo 'test command'""#);
repo.commit("Add config");
// Run WITHOUT piping stdin - this simulates non-TTY environment
// When running under cargo test, stdin is not a TTY
assert_cmd_snapshot!(
"approval_fails_in_non_tty",
make_snapshot_cmd(&repo, "switch", &["--create", "test-non-tty"], None)
);
}
///
/// Even in non-TTY environments, --yes should allow commands to execute.
#[rstest]
fn test_yes_bypasses_tty_check(repo: TestRepo) {
repo.write_project_config(r#"pre-start = "echo 'test command'""#);
repo.commit("Add config");
// Run with --yes to bypass approval entirely
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"switch",
&["--create", "test-yes-tty", "--yes"],
None
));
}
///
/// When `wt hook post-merge` runs standalone (not via `wt merge`), the `{{ target }}`
/// variable should be the current branch, not always the default branch.
/// This allows hooks to behave correctly when testing from feature worktrees.
#[rstest]
fn test_hook_post_merge_target_is_current_branch(repo: TestRepo) {
// Hook that writes {{ target }} to a file so we can verify its value
repo.write_project_config(r#"post-merge = "echo '{{ target }}' > target-branch.txt""#);
repo.commit("Add post-merge hook");
// Create and switch to a feature branch
repo.run_git(&["checkout", "-b", "my-feature-branch"]);
// Run the hook with --yes to skip approval
let output = repo
.wt_command()
.args(["hook", "post-merge", "--yes", "--foreground"])
.env("NO_COLOR", "1")
.output()
.expect("Failed to run wt hook post-merge");
assert!(
output.status.success(),
"wt hook post-merge failed: {}",
String::from_utf8_lossy(&output.stderr)
);
// Verify {{ target }} was set to the current branch, not "main"
let target_file = repo.root_path().join("target-branch.txt");
let target_content = fs::read_to_string(&target_file).expect("target-branch.txt should exist");
assert_eq!(
target_content.trim(),
"my-feature-branch",
"{{ target }} should be current branch, not default branch"
);
}
#[rstest]
fn test_hook_pre_merge_target_is_current_branch(repo: TestRepo) {
// Hook that writes {{ target }} to a file so we can verify its value
repo.write_project_config(r#"pre-merge = "echo '{{ target }}' > target-branch.txt""#);
repo.commit("Add pre-merge hook");
// Create and switch to a feature branch
repo.run_git(&["checkout", "-b", "my-feature-branch"]);
// Run the hook with --yes to skip approval
let output = repo
.wt_command()
.args(["hook", "pre-merge", "--yes"])
.env("NO_COLOR", "1")
.output()
.expect("Failed to run wt hook pre-merge");
assert!(
output.status.success(),
"wt hook pre-merge failed: {}",
String::from_utf8_lossy(&output.stderr)
);
// Verify {{ target }} was set to the current branch, not "main"
let target_file = repo.root_path().join("target-branch.txt");
let target_content = fs::read_to_string(&target_file).expect("target-branch.txt should exist");
assert_eq!(
target_content.trim(),
"my-feature-branch",
"{{ target }} should be current branch, not default branch"
);
}
#[rstest]
fn test_step_hook_run_named_command(repo: TestRepo) {
// Config with multiple named commands
repo.write_project_config(
r#"pre-merge = [
{test = "echo 'running test' > test.txt"},
{lint = "echo 'running lint' > lint.txt"},
{build = "echo 'running build' > build.txt"},
]
"#,
);
repo.commit("Add pre-merge hooks");
// Run only the "lint" command with --yes to skip approval
let output = repo
.wt_command()
.args(["hook", "pre-merge", "lint", "--yes"])
.env("NO_COLOR", "1")
.output()
.expect("Failed to run wt hook pre-merge lint");
assert!(
output.status.success(),
"wt hook pre-merge lint failed: {}",
String::from_utf8_lossy(&output.stderr)
);
// Only lint.txt should exist
assert!(
repo.root_path().join("lint.txt").exists(),
"lint.txt should exist (lint command ran)"
);
assert!(
!repo.root_path().join("test.txt").exists(),
"test.txt should NOT exist (test command should not have run)"
);
assert!(
!repo.root_path().join("build.txt").exists(),
"build.txt should NOT exist (build command should not have run)"
);
}
#[rstest]
fn test_step_hook_unknown_name_error(repo: TestRepo) {
// Config with multiple named commands
repo.write_project_config(
r#"pre-merge = [
{test = "echo 'test'"},
{lint = "echo 'lint'"},
]
"#,
);
repo.commit("Add pre-merge hooks");
// Run with a name that doesn't exist
assert_cmd_snapshot!(
"step_hook_unknown_name_error",
make_snapshot_cmd(&repo, "hook", &["pre-merge", "nonexistent", "--yes"], None)
);
}
#[rstest]
fn test_step_hook_name_filter_on_unnamed_command(repo: TestRepo) {
// Config with a single unnamed command (no table)
repo.write_project_config(r#"pre-merge = "echo 'test'""#);
repo.commit("Add pre-merge hook");
// Run with a name filter on a hook that has no named commands
assert_cmd_snapshot!(
"step_hook_name_filter_on_unnamed",
make_snapshot_cmd(&repo, "hook", &["pre-merge", "test", "--yes"], None)
);
}
/// Helper for step hook snapshot tests with extra args and approval prompt
fn snapshot_run_hook_with_args(test_name: &str, repo: &TestRepo, args: &[&str], approve: bool) {
let mut cmd = make_snapshot_cmd(repo, "hook", args, None);
cmd.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = cmd.spawn().unwrap();
// Write approval response
{
let stdin = child.stdin.as_mut().unwrap();
let response = if approve { b"y\n" } else { b"n\n" };
stdin.write_all(response).unwrap();
}
let output = child.wait_with_output().unwrap();
// Use insta snapshot for combined output
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let combined = format!(
"exit_code: {}\n----- stdout -----\n{}\n----- stderr -----\n{}",
output.status.code().unwrap_or(-1),
stdout,
stderr
);
insta::assert_snapshot!(test_name, combined);
}
///
/// This verifies the fix for the approval bypass vulnerability where `project:name`
/// filter syntax was not correctly parsed by the approval gate, allowing project
/// hooks to run without approval.
#[rstest]
fn test_project_prefix_requires_approval(repo: TestRepo) {
repo.write_project_config(
r#"[pre-merge]
test = "echo 'Running project test'"
"#,
);
repo.commit("Add pre-merge hook");
// Running with project: prefix should still require approval
// Decline to verify the prompt appears
snapshot_run_hook_with_args(
"project_prefix_requires_approval",
&repo,
&["pre-merge", "project:test"],
false,
);
}
#[rstest]
fn test_project_prefix_all_requires_approval(repo: TestRepo) {
repo.write_project_config(
r#"pre-merge = [
{test = "echo 'Running project test'"},
{lint = "echo 'Running project lint'"},
]
"#,
);
repo.commit("Add pre-merge hooks");
// Running with project: (no name) should require approval for all project hooks
snapshot_run_hook_with_args(
"project_prefix_all_requires_approval",
&repo,
&["pre-merge", "project:"],
false,
);
}
#[rstest]
fn test_user_prefix_skips_approval(repo: TestRepo) {
// Set up user config with a hook
repo.write_test_config(
r#"[pre-merge]
test = "echo 'user test'"
"#,
);
// Running with user: prefix should not prompt for approval
assert_cmd_snapshot!(
"user_prefix_skips_approval",
make_snapshot_cmd(&repo, "hook", &["pre-merge", "user:test"], None)
);
}
#[rstest]
fn test_mixed_source_name_filters_do_not_cross_match(repo: TestRepo) {
repo.write_test_config(
r#"[pre-merge]
lint = "echo 'user lint' > user_lint.txt"
deploy = "echo 'user deploy' > user_deploy.txt"
"#,
);
repo.write_project_config(
r#"[pre-merge]
lint = "echo 'project lint' > project_lint.txt"
deploy = "echo 'project deploy' > project_deploy.txt"
"#,
);
repo.commit("Add pre-merge hooks");
let output = repo
.wt_command()
.args(["--yes", "hook", "pre-merge", "project:deploy", "user:lint"])
.env("NO_COLOR", "1")
.output()
.expect("Failed to run wt hook pre-merge");
assert!(
output.status.success(),
"wt hook pre-merge failed: {}",
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(
fs::read_to_string(repo.root_path().join("project_deploy.txt")).unwrap(),
"project deploy\n"
);
assert_eq!(
fs::read_to_string(repo.root_path().join("user_lint.txt")).unwrap(),
"user lint\n"
);
assert!(
!repo.root_path().join("project_lint.txt").exists(),
"project lint should not run for a user-scoped lint filter"
);
assert!(
!repo.root_path().join("user_deploy.txt").exists(),
"user deploy should not run for a project-scoped deploy filter"
);
}
#[rstest]
fn test_step_hook_run_all_commands(repo: TestRepo) {
// Config with multiple named commands
repo.write_project_config(
r#"pre-merge = [
{first = "echo 'first' >> output.txt"},
{second = "echo 'second' >> output.txt"},
{third = "echo 'third' >> output.txt"},
]
"#,
);
repo.commit("Add pre-merge hooks");
// Run without name filter (all commands should run)
let output = repo
.wt_command()
.args(["hook", "pre-merge", "--yes"])
.env("NO_COLOR", "1")
.output()
.expect("Failed to run wt hook pre-merge");
assert!(
output.status.success(),
"wt hook pre-merge failed: {}",
String::from_utf8_lossy(&output.stderr)
);
// All three commands should have written to output.txt
let output_file = repo.root_path().join("output.txt");
let content = fs::read_to_string(&output_file).expect("output.txt should exist");
let lines: Vec<&str> = content.lines().collect();
assert_eq!(
lines,
vec!["first", "second", "third"],
"All commands should have run in order"
);
}
/// The global `-y` / `--yes` flag skips approval when placed before the
/// subcommand (e.g. `wt -y switch --create …`), not only in the per-command
/// position where it used to live. Non-TTY invocations would fail on an
/// approval prompt, so a clean exit confirms `-y` was honored.
#[rstest]
fn test_global_yes_before_subcommand(repo: TestRepo) {
repo.write_project_config(r#"pre-start = "echo 'test command'""#);
repo.commit("Add config");
// Place `-y` before the subcommand name.
let output = repo
.wt_command()
.args(["-y", "switch", "--create", "feature-global-y"])
.env("NO_COLOR", "1")
.output()
.expect("Failed to run wt -y switch");
assert!(
output.status.success(),
"wt -y switch failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
/// The global `--yes` flag skips approval for `wt hook <type>`, matching the
/// per-command form at the same position.
#[rstest]
fn test_global_yes_for_hook(repo: TestRepo) {
repo.write_project_config(r#"pre-merge = "echo 'ran' > marker.txt""#);
repo.commit("Add pre-merge hook");
let output = repo
.wt_command()
.args(["--yes", "hook", "pre-merge"])
.env("NO_COLOR", "1")
.output()
.expect("Failed to run wt --yes hook pre-merge");
assert!(
output.status.success(),
"wt --yes hook pre-merge failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let marker = repo.root_path().join("marker.txt");
let content = std::fs::read_to_string(&marker).expect("marker.txt should exist");
assert_eq!(content.trim(), "ran");
}
/// The global `-y` flag skips approval for project-config aliases, matching
/// the post-alias `--yes` form.
#[rstest]
fn test_global_yes_for_alias(repo: TestRepo) {
repo.write_project_config(
r#"[aliases]
deploy = "echo 'ran' > marker.txt"
"#,
);
repo.commit("Add alias");
let output = repo
.wt_command()
.args(["-y", "deploy"])
.env("NO_COLOR", "1")
.output()
.expect("Failed to run wt -y deploy");
assert!(
output.status.success(),
"wt -y deploy failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let marker = repo.root_path().join("marker.txt");
let content = std::fs::read_to_string(&marker).expect("marker.txt should exist");
assert_eq!(content.trim(), "ran");
}
/// The post-alias `--yes` form (`wt deploy --yes`) does not skip approval —
/// clap's `global = true` does not propagate flags across an
/// `external_subcommand` boundary, so the post-alias position never reaches
/// the global `-y` parser. Under the smart-routing grammar `--yes` simply
/// forwards as a positional into `{{ args }}` (since `yes` is not a
/// referenced template var), and the alias still hits the approval path.
/// Use `wt -y deploy` / `wt --yes deploy` to skip approval.
#[rstest]
fn test_post_alias_yes_does_not_skip_approval(repo: TestRepo) {
repo.write_project_config(
r#"[aliases]
deploy = "echo 'ran' > marker.txt"
"#,
);
repo.commit("Add alias");
let output = repo
.wt_command()
.args(["deploy", "--yes"])
.env("NO_COLOR", "1")
.output()
.expect("Failed to run wt deploy --yes");
assert!(
!output.status.success(),
"wt deploy --yes should fail at approval now that post-alias --yes is just a forwarded arg"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("approval") || stderr.contains("Cannot prompt"),
"expected approval-failure error, got: {stderr}"
);
let marker = repo.root_path().join("marker.txt");
assert!(
!marker.exists(),
"alias must not run when approval is denied"
);
}
/// The global `-y` flag skips approval when dispatched through
/// `wt step <alias>`, covering the `step_alias` threading path.
#[rstest]
fn test_global_yes_for_step_alias(repo: TestRepo) {
repo.write_project_config(
r#"[aliases]
deploy = "echo 'ran' > marker.txt"
"#,
);
repo.commit("Add alias");
let output = repo
.wt_command()
.args(["-y", "step", "deploy"])
.env("NO_COLOR", "1")
.output()
.expect("Failed to run wt -y step deploy");
assert!(
output.status.success(),
"wt -y step deploy failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let marker = repo.root_path().join("marker.txt");
let content = std::fs::read_to_string(&marker).expect("marker.txt should exist");
assert_eq!(content.trim(), "ran");
}
/// Commands without approval prompts accept `-y` without erroring — e.g.
/// `wt -y list` is a valid no-op.
#[rstest]
fn test_global_yes_on_command_without_approval(repo: TestRepo) {
let output = repo
.wt_command()
.args(["-y", "list"])
.env("NO_COLOR", "1")
.output()
.expect("Failed to run wt -y list");
assert!(
output.status.success(),
"wt -y list failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}