mirror of
https://github.com/max-sixty/worktrunk.git
synced 2026-09-14 20:00:38 +08:00
ec62580c29
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>
798 lines
23 KiB
Rust
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)
|
|
);
|
|
}
|