mirror of
https://github.com/max-sixty/worktrunk.git
synced 2026-09-14 20:00:38 +08:00
d4538f2047
## Problem Since #939 / #3344, `wt switch` and `wt remove` preserve the user's subdirectory position — from `monorepo.feature/subproject/` you land in `monorepo/subproject/`. #3723 reports that this is lost one layer down: with `[aliases] finish = "wt remove -y"`, `wt finish` drops you at the primary worktree root. The resolution reads the user's position from the wt process's own cwd (`resolve_subdir_in_target`, called with `std::env::current_dir()`). That answers "where is the user standing?" only for a top-level invocation. Alias and hook bodies run with the worktree root as their working directory, so the nested `wt` strips the source root off a cwd that *is* the source root, gets an empty relative path, and falls back to the destination root. ## Solution The CD directive file already travels to exactly the children that are allowed to move the user's shell. The shell's directory now travels with it: `apply_cd_directive_env` sets `WORKTRUNK_SHELL_CWD` wherever the CD file is re-added (`Cmd::stream` and the concurrent runner), and `scrub_directive_env_vars` strips it alongside the other directive vars, so an untrusted child neither keeps nor receives it. `shell_exec::shell_cwd()` reads it back, preferring the inherited value over the process cwd — which is what makes nesting compose, since each layer forwards the shell's directory rather than its own. Three sites ask that question and now go through it: `wt switch`, `wt remove` (`prepare_remove_directory_change`), and `wt step relocate`, whose existing comment already asks to behave identically to the other two. Nothing about the working directory of alias or hook *bodies* changes — `{{ cwd }}` is still the worktree root, per the documented contract. The only change is what a nested `wt` believes about the user's position. ## Testing Two integration tests in `tests/integration_tests/step_alias.rs`, both failing before the change with the exact symptom reported: ``` CD file should preserve the subdirectory (…/repo/apps/gateway), got: "…/repo\n" ``` - `test_alias_wrapping_remove_preserves_subdir` — the reported case (`[aliases] finish = "wt remove -y"` run from `feature/apps/gateway`). - `test_alias_wrapping_switch_preserves_subdir` — the same for `wt switch` inside an alias. The existing subdirectory-preservation tests in `directives.rs` (including the fall-back-to-root cases) still pass, as does the full integration suite — apart from `test_copy_ignored_preserves_file_executable_permissions`, which fails identically on `main` in this sandbox (umask `0002`, expects `0644` gets `0664`) and is unrelated. The open question from the first revision is answered: the new remove test leaves two processes with a cwd inside the worktree being removed (the alias parent in the subdirectory, the nested `wt` at the root) where the existing test has one, and `test (windows)` passes on it. Review follow-ups are in31d8b3d(pure `shell_cwd_from` plus its unit test, `SHELL_CWD_ENV_VAR` added to the scrub-coverage test, corrected `startup_cwd()` comment) and2578348(the fixtures in that unit test derive from `temp_dir()` instead of a `cfg!(windows)` literal pair, whose untaken arm was the last `codecov/patch` miss). One `code-coverage` run failed on `progressive_handler::tests::on_update_pokes_run_preview_only_when_the_visible_pane_changes` — unrelated to this change, passing in `test (linux)` on the same commit and in the coverage run on the parent commit — and passed on re-run. Closes #3723 --------- Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
2630 lines
84 KiB
Rust
2630 lines
84 KiB
Rust
//! Integration tests for `wt step <alias>`
|
||
|
||
use crate::common::{
|
||
TestRepo, configure_directive_files, directive_files, make_snapshot_cmd,
|
||
make_snapshot_cmd_with_global_flags, repo, repo_with_remote, setup_snapshot_settings, wt_bin,
|
||
};
|
||
// Used only by the `#[cfg(unix)]` signal tests below; gate the import to match,
|
||
// or it reads as unused on Windows under `-D warnings`.
|
||
#[cfg(unix)]
|
||
use crate::common::SLEEP_FOR_ABSENCE_CHECK;
|
||
use insta_cmd::assert_cmd_snapshot;
|
||
use rstest::rstest;
|
||
use std::io::Write;
|
||
use std::process::Stdio;
|
||
|
||
/// Alias from project config runs with template expansion (-y bypasses approval)
|
||
#[rstest]
|
||
fn test_step_alias_from_project_config(mut repo: TestRepo) {
|
||
repo.write_project_config(
|
||
r#"
|
||
[aliases]
|
||
hello = "echo Hello from {{ branch }}"
|
||
"#,
|
||
);
|
||
repo.commit("Add alias config");
|
||
let feature_path = repo.add_worktree("feature");
|
||
|
||
let settings = setup_snapshot_settings(&repo);
|
||
let _guard = settings.bind_to_scope();
|
||
|
||
assert_cmd_snapshot!(make_snapshot_cmd_with_global_flags(
|
||
&repo,
|
||
"step",
|
||
&["hello"],
|
||
Some(&feature_path),
|
||
&["-y"],
|
||
));
|
||
}
|
||
|
||
/// `wt config alias dry-run <name>` shows the expanded command without running it
|
||
/// (no approval needed — preview never executes project commands).
|
||
#[rstest]
|
||
fn test_config_alias_dry_run(mut repo: TestRepo) {
|
||
repo.write_project_config(
|
||
r#"
|
||
[aliases]
|
||
hello = "echo Hello from {{ branch }}"
|
||
"#,
|
||
);
|
||
repo.commit("Add alias config");
|
||
let feature_path = repo.add_worktree("feature");
|
||
|
||
let settings = setup_snapshot_settings(&repo);
|
||
let _guard = settings.bind_to_scope();
|
||
|
||
assert_cmd_snapshot!(make_snapshot_cmd(
|
||
&repo,
|
||
"config",
|
||
&["alias", "dry-run", "hello"],
|
||
Some(&feature_path),
|
||
));
|
||
}
|
||
|
||
/// Unknown alias shows error with available aliases
|
||
#[rstest]
|
||
fn test_step_alias_unknown_with_available(mut repo: TestRepo) {
|
||
repo.write_project_config(
|
||
r#"
|
||
[aliases]
|
||
hello = "echo Hello"
|
||
deploy = "make deploy"
|
||
"#,
|
||
);
|
||
repo.commit("Add alias config");
|
||
let feature_path = repo.add_worktree("feature");
|
||
|
||
let settings = setup_snapshot_settings(&repo);
|
||
let _guard = settings.bind_to_scope();
|
||
|
||
assert_cmd_snapshot!(make_snapshot_cmd(
|
||
&repo,
|
||
"step",
|
||
&["nonexistent"],
|
||
Some(&feature_path),
|
||
));
|
||
}
|
||
|
||
/// Typo in alias name suggests the closest match
|
||
#[rstest]
|
||
fn test_step_alias_did_you_mean(mut repo: TestRepo) {
|
||
repo.write_project_config(
|
||
r#"
|
||
[aliases]
|
||
deploy = "make deploy"
|
||
hello = "echo Hello"
|
||
"#,
|
||
);
|
||
repo.commit("Add alias config");
|
||
let feature_path = repo.add_worktree("feature");
|
||
|
||
let settings = setup_snapshot_settings(&repo);
|
||
let _guard = settings.bind_to_scope();
|
||
|
||
assert_cmd_snapshot!(make_snapshot_cmd(
|
||
&repo,
|
||
"step",
|
||
&["deplyo"],
|
||
Some(&feature_path),
|
||
));
|
||
}
|
||
|
||
/// Unknown step command with no aliases configured
|
||
#[rstest]
|
||
fn test_step_alias_unknown_no_aliases(mut repo: TestRepo) {
|
||
let feature_path = repo.add_worktree("feature");
|
||
|
||
let settings = setup_snapshot_settings(&repo);
|
||
let _guard = settings.bind_to_scope();
|
||
|
||
assert_cmd_snapshot!(make_snapshot_cmd(
|
||
&repo,
|
||
"step",
|
||
&["deploy"],
|
||
Some(&feature_path),
|
||
));
|
||
}
|
||
|
||
/// `--KEY=VALUE` binds to `{{ KEY }}` when the template references it.
|
||
#[rstest]
|
||
fn test_step_alias_binds_referenced_var(mut repo: TestRepo) {
|
||
repo.write_project_config(
|
||
r#"
|
||
[aliases]
|
||
greet = "echo Hello {{ name }} from {{ branch }}"
|
||
"#,
|
||
);
|
||
repo.commit("Add alias config");
|
||
let feature_path = repo.add_worktree("feature");
|
||
|
||
let settings = setup_snapshot_settings(&repo);
|
||
let _guard = settings.bind_to_scope();
|
||
|
||
assert_cmd_snapshot!(make_snapshot_cmd(
|
||
&repo,
|
||
"config",
|
||
&["alias", "dry-run", "greet", "--", "--name=World"],
|
||
Some(&feature_path),
|
||
));
|
||
}
|
||
|
||
/// `--KEY VALUE` (space-separated) binds the same way `--KEY=VALUE` does
|
||
/// when KEY is referenced and VALUE doesn't look like a flag.
|
||
#[rstest]
|
||
fn test_step_alias_binds_space_separated_var(mut repo: TestRepo) {
|
||
repo.write_project_config(
|
||
r#"
|
||
[aliases]
|
||
greet = "echo Hello {{ name }} from {{ branch }}"
|
||
"#,
|
||
);
|
||
repo.commit("Add alias config");
|
||
let feature_path = repo.add_worktree("feature");
|
||
|
||
let settings = setup_snapshot_settings(&repo);
|
||
let _guard = settings.bind_to_scope();
|
||
|
||
assert_cmd_snapshot!(make_snapshot_cmd(
|
||
&repo,
|
||
"config",
|
||
&["alias", "dry-run", "greet", "--", "--name", "World"],
|
||
Some(&feature_path),
|
||
));
|
||
}
|
||
|
||
/// `--KEY=VALUE` for a key the template doesn't reference forwards to
|
||
/// `{{ args }}` instead of binding silently.
|
||
#[rstest]
|
||
fn test_step_alias_unreferenced_key_forwards_to_args(mut repo: TestRepo) {
|
||
repo.write_project_config(
|
||
r#"
|
||
[aliases]
|
||
run = "echo got {{ args }}"
|
||
"#,
|
||
);
|
||
repo.commit("Add alias config");
|
||
let feature_path = repo.add_worktree("feature");
|
||
|
||
let settings = setup_snapshot_settings(&repo);
|
||
let _guard = settings.bind_to_scope();
|
||
|
||
assert_cmd_snapshot!(make_snapshot_cmd_with_global_flags(
|
||
&repo,
|
||
"run",
|
||
&["--env=staging", "foo"],
|
||
Some(&feature_path),
|
||
&["-y"],
|
||
));
|
||
}
|
||
|
||
/// `--` is a literal-forward escape: every later token goes to `{{ args }}`,
|
||
/// so flag-shaped values that would normally bind are passed through verbatim.
|
||
#[rstest]
|
||
fn test_step_alias_double_dash_escape(mut repo: TestRepo) {
|
||
repo.write_project_config(
|
||
r#"
|
||
[aliases]
|
||
run = "echo got {{ args }}"
|
||
"#,
|
||
);
|
||
repo.commit("Add alias config");
|
||
let feature_path = repo.add_worktree("feature");
|
||
|
||
let settings = setup_snapshot_settings(&repo);
|
||
let _guard = settings.bind_to_scope();
|
||
|
||
assert_cmd_snapshot!(make_snapshot_cmd_with_global_flags(
|
||
&repo,
|
||
"run",
|
||
&["--", "--env=staging", "literal"],
|
||
Some(&feature_path),
|
||
&["-y"],
|
||
));
|
||
}
|
||
|
||
/// `--KEY=VALUE` overrides built-in template variables. The user-supplied
|
||
/// value wins because `extra_refs` is applied after built-ins are seeded.
|
||
#[rstest]
|
||
fn test_step_alias_user_var_overshadows_builtin(mut repo: TestRepo) {
|
||
repo.write_project_config(
|
||
r#"
|
||
[aliases]
|
||
show = "echo branch={{ branch }}"
|
||
"#,
|
||
);
|
||
repo.commit("Add alias config");
|
||
let feature_path = repo.add_worktree("feature");
|
||
|
||
let settings = setup_snapshot_settings(&repo);
|
||
let _guard = settings.bind_to_scope();
|
||
|
||
assert_cmd_snapshot!(make_snapshot_cmd_with_global_flags(
|
||
&repo,
|
||
"show",
|
||
&["--branch=override"],
|
||
Some(&feature_path),
|
||
&["-y"],
|
||
));
|
||
}
|
||
|
||
/// Multi-step pipeline: each step's `{{ KEY }}` references contribute to
|
||
/// the binding-eligible set, so a single invocation can bind both `env`
|
||
/// (referenced in step 1) and `region` (referenced in step 2).
|
||
#[rstest]
|
||
fn test_step_alias_multi_step_binds_across_pipeline(mut repo: TestRepo) {
|
||
repo.write_test_config(
|
||
r#"
|
||
[aliases]
|
||
deploy = [
|
||
"echo step1 env={{ env }}",
|
||
{ publish = "echo step2 region={{ region }}" },
|
||
]
|
||
"#,
|
||
);
|
||
repo.commit("initial");
|
||
let feature_path = repo.add_worktree("feature");
|
||
|
||
let settings = setup_snapshot_settings(&repo);
|
||
let _guard = settings.bind_to_scope();
|
||
|
||
let mut cmd = make_snapshot_cmd(
|
||
&repo,
|
||
"step",
|
||
&["deploy", "--env=prod", "--region=us-east"],
|
||
Some(&feature_path),
|
||
);
|
||
cmd.env("WORKTRUNK_TEST_SERIAL_CONCURRENT", "1");
|
||
assert_cmd_snapshot!(cmd);
|
||
}
|
||
|
||
/// Alias body output is delivered on stdout so it can be piped into other
|
||
/// commands (#2478). Worktrunk's own progress/announcement messages still go
|
||
/// to stderr; only the child shell's stdout passes through.
|
||
#[rstest]
|
||
fn test_alias_body_writes_to_stdout(mut repo: TestRepo) {
|
||
repo.write_project_config(
|
||
r#"
|
||
[aliases]
|
||
emit = "echo hello"
|
||
"#,
|
||
);
|
||
repo.commit("Add alias config");
|
||
let feature_path = repo.add_worktree("feature");
|
||
|
||
let output = repo
|
||
.wt_command()
|
||
.args(["-y", "emit"])
|
||
.current_dir(&feature_path)
|
||
.output()
|
||
.unwrap();
|
||
|
||
assert!(
|
||
output.status.success(),
|
||
"alias failed:\nstdout: {}\nstderr: {}",
|
||
String::from_utf8_lossy(&output.stdout),
|
||
String::from_utf8_lossy(&output.stderr)
|
||
);
|
||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||
assert!(
|
||
stdout.lines().any(|line| line.trim() == "hello"),
|
||
"expected `hello` on stdout (so `wt <alias> | …` is usable in scripts), \
|
||
got stdout={stdout:?} stderr={stderr:?}"
|
||
);
|
||
}
|
||
|
||
/// A one-entry alias table is a single named command, not a concurrent group
|
||
/// of one: its output passes through on stdout without a `name │` prefix,
|
||
/// identical to the string and `[[aliases.x]]` spellings.
|
||
#[rstest]
|
||
fn test_alias_single_entry_table_writes_to_stdout(mut repo: TestRepo) {
|
||
repo.write_project_config(
|
||
r#"
|
||
[aliases.emit]
|
||
say = "echo hello"
|
||
"#,
|
||
);
|
||
repo.commit("Add alias config");
|
||
let feature_path = repo.add_worktree("feature");
|
||
|
||
let output = repo
|
||
.wt_command()
|
||
.args(["-y", "emit"])
|
||
.current_dir(&feature_path)
|
||
.output()
|
||
.unwrap();
|
||
|
||
assert!(
|
||
output.status.success(),
|
||
"alias failed:\nstdout: {}\nstderr: {}",
|
||
String::from_utf8_lossy(&output.stdout),
|
||
String::from_utf8_lossy(&output.stderr)
|
||
);
|
||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||
assert!(
|
||
stdout.lines().any(|line| line.trim() == "hello"),
|
||
"expected unprefixed `hello` on stdout, got stdout={stdout:?} stderr={stderr:?}"
|
||
);
|
||
assert!(
|
||
!stdout.contains('│') && !stderr.contains("say │"),
|
||
"single named command must not use the concurrent `name │` prefix, \
|
||
got stdout={stdout:?} stderr={stderr:?}"
|
||
);
|
||
}
|
||
|
||
/// Alias command failure propagates exit code (-y bypasses approval)
|
||
#[rstest]
|
||
fn test_step_alias_exit_code(mut repo: TestRepo) {
|
||
repo.write_project_config(
|
||
r#"
|
||
[aliases]
|
||
fail = "exit 42"
|
||
"#,
|
||
);
|
||
repo.commit("Add alias config");
|
||
let feature_path = repo.add_worktree("feature");
|
||
|
||
let settings = setup_snapshot_settings(&repo);
|
||
let _guard = settings.bind_to_scope();
|
||
|
||
assert_cmd_snapshot!(make_snapshot_cmd_with_global_flags(
|
||
&repo,
|
||
"step",
|
||
&["fail"],
|
||
Some(&feature_path),
|
||
&["-y"],
|
||
));
|
||
}
|
||
|
||
/// Alias from user config works
|
||
#[rstest]
|
||
fn test_step_alias_from_user_config(mut repo: TestRepo) {
|
||
let feature_path = repo.add_worktree("feature");
|
||
repo.write_test_config(
|
||
r#"
|
||
[aliases]
|
||
greet = "echo Greetings from {{ branch }}"
|
||
"#,
|
||
);
|
||
|
||
let settings = setup_snapshot_settings(&repo);
|
||
let _guard = settings.bind_to_scope();
|
||
|
||
assert_cmd_snapshot!(make_snapshot_cmd(
|
||
&repo,
|
||
"step",
|
||
&["greet"],
|
||
Some(&feature_path),
|
||
));
|
||
}
|
||
|
||
/// Top-level alias dispatch: `wt <name>` runs an alias when `<name>` is not
|
||
/// a built-in subcommand, with the same template-expansion and approval flow
|
||
/// as `wt step <name>`.
|
||
#[rstest]
|
||
fn test_top_level_alias_dispatch(mut repo: TestRepo) {
|
||
repo.write_project_config(
|
||
r#"
|
||
[aliases]
|
||
hello = "echo Hello from {{ branch }}"
|
||
"#,
|
||
);
|
||
repo.commit("Add alias config");
|
||
let feature_path = repo.add_worktree("feature");
|
||
|
||
let settings = setup_snapshot_settings(&repo);
|
||
let _guard = settings.bind_to_scope();
|
||
|
||
assert_cmd_snapshot!(make_snapshot_cmd_with_global_flags(
|
||
&repo,
|
||
"hello",
|
||
&[],
|
||
Some(&feature_path),
|
||
&["-y"],
|
||
));
|
||
}
|
||
|
||
/// An alias whose name matches a `wt step` built-in is unreachable via
|
||
/// `wt step <name>` (the built-in always wins) but runs from the top level
|
||
/// via `wt <name>` — there's no top-level `commit` built-in.
|
||
#[rstest]
|
||
fn test_top_level_alias_with_step_builtin_name(mut repo: TestRepo) {
|
||
repo.write_project_config(
|
||
r#"
|
||
[aliases]
|
||
commit = "echo custom-commit"
|
||
"#,
|
||
);
|
||
repo.commit("Add alias config");
|
||
let feature_path = repo.add_worktree("feature");
|
||
|
||
let settings = setup_snapshot_settings(&repo);
|
||
let _guard = settings.bind_to_scope();
|
||
|
||
assert_cmd_snapshot!(make_snapshot_cmd_with_global_flags(
|
||
&repo,
|
||
"commit",
|
||
&[],
|
||
Some(&feature_path),
|
||
&["-y"],
|
||
));
|
||
}
|
||
|
||
/// Top-level typo on an alias name suggests the alias in the `tip:` line,
|
||
/// matching `wt step <typo>`.
|
||
#[rstest]
|
||
fn test_top_level_alias_did_you_mean(mut repo: TestRepo) {
|
||
repo.write_project_config(
|
||
r#"
|
||
[aliases]
|
||
deploy = "make deploy"
|
||
hello = "echo Hello"
|
||
"#,
|
||
);
|
||
repo.commit("Add alias config");
|
||
let feature_path = repo.add_worktree("feature");
|
||
|
||
let settings = setup_snapshot_settings(&repo);
|
||
let _guard = settings.bind_to_scope();
|
||
|
||
assert_cmd_snapshot!(make_snapshot_cmd(&repo, "deplyo", &[], Some(&feature_path),));
|
||
}
|
||
|
||
/// Aliases shadowed by `wt step` built-ins are filtered from the typo
|
||
/// suggestion list — `wt step commit` does not suggest a (shadowed) alias
|
||
/// named `commit`, only the real built-in.
|
||
#[rstest]
|
||
fn test_step_alias_shadows_builtin(mut repo: TestRepo) {
|
||
repo.write_project_config(
|
||
r#"
|
||
[aliases]
|
||
commit = "echo custom-commit"
|
||
"#,
|
||
);
|
||
repo.commit("Add alias config");
|
||
let feature_path = repo.add_worktree("feature");
|
||
|
||
let settings = setup_snapshot_settings(&repo);
|
||
let _guard = settings.bind_to_scope();
|
||
|
||
assert_cmd_snapshot!(make_snapshot_cmd(
|
||
&repo,
|
||
"step",
|
||
&["comit"],
|
||
Some(&feature_path),
|
||
));
|
||
}
|
||
|
||
/// User config aliases merge with project config aliases
|
||
#[rstest]
|
||
fn test_step_alias_merge_user_and_project(mut repo: TestRepo) {
|
||
repo.write_project_config(
|
||
r#"
|
||
[aliases]
|
||
project-cmd = "echo from-project"
|
||
shared = "echo project-version"
|
||
"#,
|
||
);
|
||
repo.commit("Add alias config");
|
||
let feature_path = repo.add_worktree("feature");
|
||
repo.write_test_config(
|
||
r#"
|
||
[aliases]
|
||
user-cmd = "echo from-user"
|
||
shared = "echo user-version"
|
||
"#,
|
||
);
|
||
|
||
let settings = setup_snapshot_settings(&repo);
|
||
let _guard = settings.bind_to_scope();
|
||
|
||
// User alias available
|
||
assert_cmd_snapshot!(
|
||
"user_alias",
|
||
make_snapshot_cmd(
|
||
&repo,
|
||
"config",
|
||
&["alias", "dry-run", "user-cmd"],
|
||
Some(&feature_path),
|
||
)
|
||
);
|
||
|
||
// Project alias available — dry-run never needs approval (it doesn't execute).
|
||
assert_cmd_snapshot!(
|
||
"project_alias",
|
||
make_snapshot_cmd(
|
||
&repo,
|
||
"config",
|
||
&["alias", "dry-run", "project-cmd"],
|
||
Some(&feature_path),
|
||
)
|
||
);
|
||
|
||
// Both definitions visible on collision: user first, then project (matches runtime order).
|
||
assert_cmd_snapshot!(
|
||
"user_and_project_append",
|
||
make_snapshot_cmd(
|
||
&repo,
|
||
"config",
|
||
&["alias", "dry-run", "shared"],
|
||
Some(&feature_path),
|
||
)
|
||
);
|
||
}
|
||
|
||
/// Both global and per-project user aliases execute in order on name collision.
|
||
///
|
||
/// Uses project config (`.config/wt.toml`) + user config to verify the
|
||
/// project-vs-user append, since `test_aliases_accessor_appends_on_collision`
|
||
/// already covers the user-config internal append via unit test.
|
||
#[rstest]
|
||
fn test_alias_append_executes_both(mut repo: TestRepo) {
|
||
repo.write_project_config(
|
||
r#"
|
||
[aliases]
|
||
greet = "echo PROJECT"
|
||
"#,
|
||
);
|
||
repo.commit("Add alias config");
|
||
let feature_path = repo.add_worktree("feature");
|
||
repo.write_test_config(
|
||
r#"
|
||
[aliases]
|
||
greet = "echo USER"
|
||
"#,
|
||
);
|
||
|
||
let settings = setup_snapshot_settings(&repo);
|
||
let _guard = settings.bind_to_scope();
|
||
|
||
// Both commands execute: user first, then project (-y approves project alias)
|
||
assert_cmd_snapshot!(make_snapshot_cmd_with_global_flags(
|
||
&repo,
|
||
"step",
|
||
&["greet"],
|
||
Some(&feature_path),
|
||
&["-y"],
|
||
));
|
||
}
|
||
|
||
// ============================================================================
|
||
// Approval tests
|
||
// ============================================================================
|
||
|
||
/// Helper for alias approval snapshot tests
|
||
fn snapshot_alias_approval(
|
||
test_name: &str,
|
||
repo: &TestRepo,
|
||
alias_args: &[&str],
|
||
approve: bool,
|
||
cwd: Option<&std::path::Path>,
|
||
) {
|
||
let mut cmd = make_snapshot_cmd(repo, "step", alias_args, cwd);
|
||
cmd.stdin(Stdio::piped())
|
||
.stdout(Stdio::piped())
|
||
.stderr(Stdio::piped());
|
||
|
||
let mut child = cmd.spawn().unwrap();
|
||
|
||
{
|
||
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();
|
||
|
||
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);
|
||
}
|
||
|
||
/// Project-config alias prompts for approval in non-TTY (fails with hint)
|
||
#[rstest]
|
||
fn test_alias_approval_project_config_prompts(mut repo: TestRepo) {
|
||
repo.write_project_config(
|
||
r#"
|
||
[aliases]
|
||
deploy = "echo deploying {{ branch }}"
|
||
"#,
|
||
);
|
||
repo.commit("Add alias config");
|
||
let feature_path = repo.add_worktree("feature");
|
||
|
||
let settings = setup_snapshot_settings(&repo);
|
||
let _guard = settings.bind_to_scope();
|
||
|
||
// Without --yes, project alias triggers approval prompt (fails in non-TTY)
|
||
assert_cmd_snapshot!(make_snapshot_cmd(
|
||
&repo,
|
||
"step",
|
||
&["deploy"],
|
||
Some(&feature_path),
|
||
));
|
||
}
|
||
|
||
/// Already-approved project-config alias runs without re-prompting
|
||
#[rstest]
|
||
fn test_alias_approval_already_approved(mut repo: TestRepo) {
|
||
// Remove origin so worktrunk uses directory name as project identifier
|
||
repo.run_git(&["remote", "remove", "origin"]);
|
||
|
||
repo.write_project_config(
|
||
r#"
|
||
[aliases]
|
||
deploy = "echo deploying {{ branch }}"
|
||
"#,
|
||
);
|
||
repo.commit("Add alias config");
|
||
let feature_path = repo.add_worktree("feature");
|
||
|
||
// Pre-approve the alias command
|
||
repo.write_test_approvals(&format!(
|
||
r#"[projects.'{}']
|
||
approved-commands = ["echo deploying {{{{ branch }}}}"]
|
||
"#,
|
||
repo.project_id()
|
||
));
|
||
|
||
let settings = setup_snapshot_settings(&repo);
|
||
let _guard = settings.bind_to_scope();
|
||
|
||
// Should run without prompting
|
||
assert_cmd_snapshot!(make_snapshot_cmd(
|
||
&repo,
|
||
"step",
|
||
&["deploy"],
|
||
Some(&feature_path),
|
||
));
|
||
}
|
||
|
||
/// User-config alias skips approval entirely
|
||
#[rstest]
|
||
fn test_alias_approval_user_config_skips(mut repo: TestRepo) {
|
||
let feature_path = repo.add_worktree("feature");
|
||
repo.write_test_config(
|
||
r#"
|
||
[aliases]
|
||
deploy = "echo deploying from user config"
|
||
"#,
|
||
);
|
||
|
||
let settings = setup_snapshot_settings(&repo);
|
||
let _guard = settings.bind_to_scope();
|
||
|
||
// User alias runs without approval
|
||
assert_cmd_snapshot!(make_snapshot_cmd(
|
||
&repo,
|
||
"step",
|
||
&["deploy"],
|
||
Some(&feature_path),
|
||
));
|
||
}
|
||
|
||
/// User override of project alias skips approval (user is trusted)
|
||
#[rstest]
|
||
fn test_alias_approval_user_and_project_both_need_approval(mut repo: TestRepo) {
|
||
repo.write_project_config(
|
||
r#"
|
||
[aliases]
|
||
deploy = "echo project deploy"
|
||
"#,
|
||
);
|
||
repo.commit("Add alias config");
|
||
let feature_path = repo.add_worktree("feature");
|
||
repo.write_test_config(
|
||
r#"
|
||
[aliases]
|
||
deploy = "echo user deploy"
|
||
"#,
|
||
);
|
||
|
||
let settings = setup_snapshot_settings(&repo);
|
||
let _guard = settings.bind_to_scope();
|
||
|
||
// Both run with -y: user first, then project (project needs approval)
|
||
assert_cmd_snapshot!(make_snapshot_cmd_with_global_flags(
|
||
&repo,
|
||
"step",
|
||
&["deploy"],
|
||
Some(&feature_path),
|
||
&["-y"],
|
||
));
|
||
}
|
||
|
||
/// -y bypasses approval for project-config alias without saving
|
||
#[rstest]
|
||
fn test_alias_approval_yes_bypasses(mut repo: TestRepo) {
|
||
repo.write_project_config(
|
||
r#"
|
||
[aliases]
|
||
deploy = "echo deploying"
|
||
"#,
|
||
);
|
||
repo.commit("Add alias config");
|
||
let feature_path = repo.add_worktree("feature");
|
||
|
||
let settings = setup_snapshot_settings(&repo);
|
||
let _guard = settings.bind_to_scope();
|
||
|
||
// First run with -y succeeds
|
||
assert_cmd_snapshot!(
|
||
"alias_approval_yes_first_run",
|
||
make_snapshot_cmd_with_global_flags(
|
||
&repo,
|
||
"step",
|
||
&["deploy"],
|
||
Some(&feature_path),
|
||
&["-y"],
|
||
)
|
||
);
|
||
|
||
// Second run without -y should still prompt (-y doesn't save approval)
|
||
assert_cmd_snapshot!(
|
||
"alias_approval_yes_second_run_prompts",
|
||
make_snapshot_cmd(&repo, "step", &["deploy"], Some(&feature_path),)
|
||
);
|
||
}
|
||
|
||
// ============================================================================
|
||
// Directive file passthrough
|
||
// ============================================================================
|
||
|
||
/// `wt step <alias>` passes the parent's `WORKTRUNK_DIRECTIVE_CD_FILE` through
|
||
/// to the alias subprocess so inner `wt switch --create` calls can land the
|
||
/// user in the new worktree.
|
||
///
|
||
/// Regression test for #2075: without the passthrough, an alias that wraps
|
||
/// `wt switch --create` prints the "shell integration not installed" hint and
|
||
/// the parent shell never `cd`s into the new worktree.
|
||
#[rstest]
|
||
fn test_alias_passes_directive_file_to_subprocess(repo: TestRepo) {
|
||
repo.commit("initial");
|
||
|
||
// Alias body invokes the test wt binary directly (PATH lookup in the
|
||
// subprocess shell wouldn't find it).
|
||
repo.write_test_config(&wt_alias_config(
|
||
"new-branch",
|
||
"switch --create alias-created",
|
||
));
|
||
|
||
let (cd_path, exec_path, _guard) = directive_files();
|
||
|
||
let mut cmd = repo.wt_command();
|
||
configure_directive_files(&mut cmd, &cd_path, &exec_path);
|
||
cmd.args(["step", "new-branch"]);
|
||
let output = cmd.output().unwrap();
|
||
|
||
assert!(
|
||
output.status.success(),
|
||
"wt step new-branch failed: stdout={}\nstderr={}",
|
||
String::from_utf8_lossy(&output.stdout),
|
||
String::from_utf8_lossy(&output.stderr),
|
||
);
|
||
|
||
let cd_content = std::fs::read_to_string(&cd_path).unwrap_or_default();
|
||
assert!(
|
||
!cd_content.trim().is_empty(),
|
||
"alias wrapping `wt switch --create` should write a path to the \
|
||
CD directive file, got: {cd_content:?}"
|
||
);
|
||
assert!(
|
||
cd_content.contains("alias-created"),
|
||
"cd directive should target the new worktree (alias-created), got: {cd_content:?}"
|
||
);
|
||
|
||
// Stderr should NOT contain the "shell integration not installed" hint
|
||
// — that hint is what appears when the inner wt can't find the directive
|
||
// file, which is exactly the bug this test guards against.
|
||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||
assert!(
|
||
!stderr.contains("shell integration"),
|
||
"inner wt should not warn about shell integration being uninstalled, got: {stderr}",
|
||
);
|
||
}
|
||
|
||
/// Build an `[aliases]` config whose single alias body invokes the test `wt`
|
||
/// binary. Suits either config source — pass it to `write_test_config` for a
|
||
/// user alias or `write_project_config` for a project one.
|
||
///
|
||
/// The binary path has to be embedded in a TOML basic string inside a shell
|
||
/// command, so single quotes must be absent (they'd terminate the shell
|
||
/// quoting) and backslashes doubled (a Windows path like `D:\a\wt.exe` would
|
||
/// otherwise parse `\a` as a TOML escape).
|
||
fn wt_alias_config(name: &str, args: &str) -> String {
|
||
let wt = wt_bin();
|
||
let wt_str = wt.to_string_lossy();
|
||
assert!(
|
||
!wt_str.contains('\''),
|
||
"wt binary path should not contain single quotes: {wt_str}"
|
||
);
|
||
let wt_toml = wt_str.replace('\\', r"\\");
|
||
format!(
|
||
r#"
|
||
[aliases]
|
||
{name} = "'{wt_toml}' {args}"
|
||
"#
|
||
)
|
||
}
|
||
|
||
/// `wt switch` inside an alias body preserves the user's subdirectory position,
|
||
/// the same as a top-level `wt switch` does.
|
||
///
|
||
/// Regression test for #3723: alias steps run with the worktree root as their
|
||
/// working directory, so the nested `wt` used to see the root as the user's
|
||
/// cwd and land the shell at the target worktree's root instead of the
|
||
/// matching subdirectory.
|
||
#[rstest]
|
||
fn test_alias_wrapping_switch_preserves_subdir(#[from(repo_with_remote)] mut repo: TestRepo) {
|
||
let feature_wt = repo.add_worktree("feature");
|
||
|
||
// The same subdirectory exists in both worktrees.
|
||
let subdir = std::path::Path::new("apps").join("gateway");
|
||
std::fs::create_dir_all(repo.root_path().join(&subdir)).unwrap();
|
||
std::fs::create_dir_all(feature_wt.join(&subdir)).unwrap();
|
||
|
||
repo.write_test_config(&wt_alias_config("go", "switch feature"));
|
||
|
||
let (cd_path, exec_path, _guard) = directive_files();
|
||
|
||
let mut cmd = repo.wt_command();
|
||
configure_directive_files(&mut cmd, &cd_path, &exec_path);
|
||
cmd.args(["step", "go"])
|
||
.current_dir(repo.root_path().join(&subdir));
|
||
let output = cmd.output().unwrap();
|
||
assert!(
|
||
output.status.success(),
|
||
"wt step go failed: stdout={}\nstderr={}",
|
||
String::from_utf8_lossy(&output.stdout),
|
||
String::from_utf8_lossy(&output.stderr),
|
||
);
|
||
|
||
let cd_content = std::fs::read_to_string(&cd_path).unwrap_or_default();
|
||
let expected = feature_wt.join(&subdir);
|
||
let expected_str = expected.to_string_lossy();
|
||
assert!(
|
||
cd_content.contains(&*expected_str),
|
||
"CD file should preserve the subdirectory ({expected_str}), got: {cd_content:?}"
|
||
);
|
||
}
|
||
|
||
/// `wt remove` inside an alias body preserves the user's subdirectory position.
|
||
///
|
||
/// Regression test for #3723: the reported case is a `remove`-wrapping alias
|
||
/// (`[aliases] finish = "wt remove -y"`) in a monorepo, which used to drop the
|
||
/// user at the primary worktree root instead of their sub-project.
|
||
#[rstest]
|
||
fn test_alias_wrapping_remove_preserves_subdir(#[from(repo_with_remote)] mut repo: TestRepo) {
|
||
let feature_wt = repo.add_worktree("feature");
|
||
|
||
// The same subdirectory exists in the worktree being removed (where the
|
||
// user is) and in the main worktree (where removal lands).
|
||
let subdir = std::path::Path::new("apps").join("gateway");
|
||
std::fs::create_dir_all(repo.root_path().join(&subdir)).unwrap();
|
||
std::fs::create_dir_all(feature_wt.join(&subdir)).unwrap();
|
||
|
||
repo.write_test_config(&wt_alias_config("finish", "remove -y"));
|
||
|
||
let (cd_path, exec_path, _guard) = directive_files();
|
||
|
||
let mut cmd = repo.wt_command();
|
||
configure_directive_files(&mut cmd, &cd_path, &exec_path);
|
||
cmd.args(["step", "finish"])
|
||
.current_dir(feature_wt.join(&subdir));
|
||
let output = cmd.output().unwrap();
|
||
assert!(
|
||
output.status.success(),
|
||
"wt step finish failed: stdout={}\nstderr={}",
|
||
String::from_utf8_lossy(&output.stdout),
|
||
String::from_utf8_lossy(&output.stderr),
|
||
);
|
||
|
||
let cd_content = std::fs::read_to_string(&cd_path).unwrap_or_default();
|
||
let expected = repo.root_path().join(&subdir);
|
||
let expected_str = expected.to_string_lossy();
|
||
assert!(
|
||
cd_content.contains(&*expected_str),
|
||
"CD file should preserve the subdirectory ({expected_str}), got: {cd_content:?}"
|
||
);
|
||
}
|
||
|
||
/// User-source aliases pass the EXEC directive file through to the body, so a
|
||
/// nested `wt switch --execute X` writes the payload back to the parent
|
||
/// shell's EXEC file the same as a top-level `wt switch --execute X` would.
|
||
///
|
||
/// Regression test for #2101: the conservative scrub used to refuse `--execute`
|
||
/// inside any alias body, breaking workflows like `issue = "wt switch --create
|
||
/// {{ args[0] }} --execute claude"` even when the alias lived in user config.
|
||
#[rstest]
|
||
fn test_user_alias_passes_exec_directive(repo: TestRepo) {
|
||
repo.commit("initial");
|
||
|
||
repo.write_test_config(&wt_alias_config(
|
||
"exec-passthrough",
|
||
"switch --create user-alias-target --execute 'echo from-user-alias'",
|
||
));
|
||
|
||
let (cd_path, exec_path, _guard) = directive_files();
|
||
|
||
let mut cmd = repo.wt_command();
|
||
configure_directive_files(&mut cmd, &cd_path, &exec_path);
|
||
cmd.args(["step", "exec-passthrough"]);
|
||
let output = cmd.output().unwrap();
|
||
|
||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||
assert!(
|
||
output.status.success(),
|
||
"wt step exec-passthrough failed: stdout={}\nstderr={stderr}",
|
||
String::from_utf8_lossy(&output.stdout),
|
||
);
|
||
assert!(
|
||
!stderr.contains("disabled inside"),
|
||
"user alias should not trip the EXEC scrub warning, got: {stderr}"
|
||
);
|
||
|
||
let exec_content = std::fs::read_to_string(&exec_path).unwrap_or_default();
|
||
assert!(
|
||
exec_content.contains("echo from-user-alias"),
|
||
"EXEC file should contain the alias's --execute payload, got: {exec_content:?}"
|
||
);
|
||
}
|
||
|
||
/// Project-source aliases keep the conservative EXEC scrub: a nested
|
||
/// `wt switch --execute X` is refused with a warning pointing at the tracking
|
||
/// issue. The body is shared config, so allowing it would let the project
|
||
/// inject arbitrary shell into every contributor's interactive session.
|
||
#[rstest]
|
||
fn test_project_alias_scrubs_exec_directive(repo: TestRepo) {
|
||
repo.commit("initial");
|
||
|
||
repo.write_project_config(&wt_alias_config(
|
||
"exec-blocked",
|
||
"switch --create project-alias-target --execute 'echo should-not-pass'",
|
||
));
|
||
|
||
let (cd_path, exec_path, _guard) = directive_files();
|
||
|
||
let mut cmd = repo.wt_command();
|
||
configure_directive_files(&mut cmd, &cd_path, &exec_path);
|
||
// -y skips the project-alias approval prompt.
|
||
cmd.args(["-y", "step", "exec-blocked"]);
|
||
let output = cmd.output().unwrap();
|
||
|
||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||
assert!(
|
||
output.status.success(),
|
||
"wt -y step exec-blocked failed: stdout={}\nstderr={stderr}",
|
||
String::from_utf8_lossy(&output.stdout),
|
||
);
|
||
assert!(
|
||
stderr.contains("disabled inside project alias"),
|
||
"project alias should warn that --execute is disabled, got: {stderr}"
|
||
);
|
||
assert!(
|
||
stderr.contains("issues/2101"),
|
||
"warning should link the tracking issue, got: {stderr}"
|
||
);
|
||
|
||
let exec_content = std::fs::read_to_string(&exec_path).unwrap_or_default();
|
||
assert!(
|
||
!exec_content.contains("should-not-pass"),
|
||
"EXEC file should NOT contain the scrubbed payload, got: {exec_content:?}"
|
||
);
|
||
}
|
||
|
||
/// Same alias name in both user and project configs: EXEC passes through for
|
||
/// the user-source step but is scrubbed for the project-source step.
|
||
///
|
||
/// EXEC passthrough is a per-step decision driven by `HookSource` on each
|
||
/// `SourcedStep`, not a per-pipeline scrub. When both sources define the
|
||
/// alias, both run (user first, then project) — the user's own step still
|
||
/// gets the relaxation, and the project-authored step keeps the conservative
|
||
/// scrub even though it's part of the same merged body.
|
||
#[rstest]
|
||
fn test_user_and_project_alias_collision_scrubs_only_project_step(repo: TestRepo) {
|
||
repo.commit("initial");
|
||
|
||
repo.write_test_config(&wt_alias_config(
|
||
"shared",
|
||
"switch --create user-step-target --execute 'echo from-user-step'",
|
||
));
|
||
// Project step also tries `--execute` so we can assert its payload is
|
||
// scrubbed even though the user step's payload passes through.
|
||
repo.write_project_config(&wt_alias_config(
|
||
"shared",
|
||
"switch --create project-step-target --execute 'echo from-project-step'",
|
||
));
|
||
|
||
let (cd_path, exec_path, _guard) = directive_files();
|
||
|
||
let mut cmd = repo.wt_command();
|
||
configure_directive_files(&mut cmd, &cd_path, &exec_path);
|
||
// -y skips the project-alias approval prompt.
|
||
cmd.args(["-y", "step", "shared"]);
|
||
let output = cmd.output().unwrap();
|
||
|
||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||
assert!(
|
||
output.status.success(),
|
||
"wt -y step shared failed: stdout={}\nstderr={stderr}",
|
||
String::from_utf8_lossy(&output.stdout),
|
||
);
|
||
assert!(
|
||
stderr.contains("disabled inside project alias"),
|
||
"project-source step should still warn that --execute is disabled, got: {stderr}"
|
||
);
|
||
|
||
let exec_content = std::fs::read_to_string(&exec_path).unwrap_or_default();
|
||
assert!(
|
||
exec_content.contains("from-user-step"),
|
||
"EXEC file should contain the user step's payload (per-step relaxation), got: {exec_content:?}"
|
||
);
|
||
assert!(
|
||
!exec_content.contains("from-project-step"),
|
||
"EXEC file should NOT contain the project step's payload (per-step scrub), got: {exec_content:?}"
|
||
);
|
||
}
|
||
|
||
/// Alias subprocesses inherit the parent's stdin so interactive children
|
||
/// (e.g. `wt switch`'s picker) keep the controlling terminal.
|
||
///
|
||
/// 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.
|
||
#[rstest]
|
||
fn test_alias_inherits_stdin(repo: TestRepo) {
|
||
repo.write_test_config(
|
||
r#"
|
||
[aliases]
|
||
echo-stdin = "cat"
|
||
"#,
|
||
);
|
||
repo.commit("initial");
|
||
|
||
let sentinel = "INHERITED_STDIN_TOKEN";
|
||
let mut cmd = repo.wt_command();
|
||
cmd.args(["step", "echo-stdin"])
|
||
.stdin(Stdio::piped())
|
||
.stdout(Stdio::piped())
|
||
.stderr(Stdio::piped());
|
||
let mut child = cmd.spawn().unwrap();
|
||
{
|
||
// Drop closes the pipe so `cat` sees EOF and exits.
|
||
let mut stdin = child.stdin.take().unwrap();
|
||
stdin.write_all(sentinel.as_bytes()).unwrap();
|
||
}
|
||
let output = child.wait_with_output().unwrap();
|
||
|
||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||
assert!(
|
||
output.status.success(),
|
||
"wt step echo-stdin failed: stdout={stdout}\nstderr={stderr}",
|
||
);
|
||
|
||
// stderr carries the "Running alias" banner; the alias body's stdout is
|
||
// redirected to stderr for deterministic ordering (see
|
||
// `execute_shell_command`). Check the combined output.
|
||
let combined = format!("{stdout}{stderr}");
|
||
assert!(
|
||
combined.contains(sentinel),
|
||
"alias body should read the parent's stdin ({sentinel} expected), \
|
||
got stdout={stdout:?} stderr={stderr:?}",
|
||
);
|
||
// The pre-fix behavior piped the context JSON (which always includes a
|
||
// "branch" key) to stdin, so `cat` would have echoed that instead.
|
||
assert!(
|
||
!combined.contains("\"branch\""),
|
||
"alias stdin should not receive the hook JSON context, \
|
||
got stdout={stdout:?} stderr={stderr:?}",
|
||
);
|
||
}
|
||
|
||
/// Pipeline aliases announce their structure: named serial and concurrent
|
||
/// steps appear in the "Running alias" line, joined by `;` and `,`.
|
||
///
|
||
/// `WORKTRUNK_TEST_SERIAL_CONCURRENT=1` forces the concurrent step to run
|
||
/// commands sequentially (in declaration order) so the snapshot captures a
|
||
/// deterministic interleaving — analogous to how `RAYON_NUM_THREADS=1` is
|
||
/// used in `step_prune` tests.
|
||
#[rstest]
|
||
fn test_alias_pipeline_announcement(mut repo: TestRepo) {
|
||
repo.write_test_config(
|
||
r#"
|
||
[aliases]
|
||
deploy = [
|
||
{ install = "echo INSTALL" },
|
||
{ build = "echo BUILD", lint = "echo LINT" },
|
||
]
|
||
"#,
|
||
);
|
||
repo.commit("initial");
|
||
let feature_path = repo.add_worktree("feature");
|
||
|
||
let settings = setup_snapshot_settings(&repo);
|
||
let _guard = settings.bind_to_scope();
|
||
|
||
let mut cmd = make_snapshot_cmd(&repo, "step", &["deploy"], Some(&feature_path));
|
||
cmd.env("WORKTRUNK_TEST_SERIAL_CONCURRENT", "1");
|
||
assert_cmd_snapshot!(cmd);
|
||
}
|
||
|
||
/// Concurrent alias steps (named table) execute all commands
|
||
#[rstest]
|
||
fn test_alias_concurrent_steps(mut repo: TestRepo) {
|
||
// Named table form: commands run concurrently within the step
|
||
repo.write_test_config(
|
||
r#"
|
||
[aliases.build]
|
||
lint = "echo LINT"
|
||
test = "echo TEST"
|
||
"#,
|
||
);
|
||
repo.commit("initial");
|
||
let feature_path = repo.add_worktree("feature");
|
||
|
||
let mut cmd = repo.wt_command();
|
||
cmd.args(["step", "build"]).current_dir(&feature_path);
|
||
let output = cmd.output().unwrap();
|
||
|
||
assert!(
|
||
output.status.success(),
|
||
"concurrent alias failed: stderr={}",
|
||
String::from_utf8_lossy(&output.stderr),
|
||
);
|
||
|
||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||
// Both commands should have run (order may vary due to concurrency)
|
||
assert!(stderr.contains("LINT"), "expected LINT in output: {stderr}");
|
||
assert!(stderr.contains("TEST"), "expected TEST in output: {stderr}");
|
||
}
|
||
|
||
/// Concurrent alias commands have their output streamed with a per-command
|
||
/// colored prefix label (`{name} │ …`), so multiple children's lines remain
|
||
/// attributable even when they interleave.
|
||
#[rstest]
|
||
fn test_alias_concurrent_prefixes_output(mut repo: TestRepo) {
|
||
repo.write_test_config(
|
||
r#"
|
||
[aliases.build]
|
||
lint = "echo HELLO_LINT"
|
||
test = "echo HELLO_TEST"
|
||
"#,
|
||
);
|
||
repo.commit("initial");
|
||
let feature_path = repo.add_worktree("feature");
|
||
|
||
let mut cmd = repo.wt_command();
|
||
cmd.args(["step", "build"])
|
||
.current_dir(&feature_path)
|
||
.env("NO_COLOR", "1");
|
||
let output = cmd.output().unwrap();
|
||
|
||
assert!(
|
||
output.status.success(),
|
||
"concurrent alias failed: stderr={}",
|
||
String::from_utf8_lossy(&output.stderr),
|
||
);
|
||
|
||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||
// Each command must produce a line starting with its prefixed label and
|
||
// separated by the box-drawing `│` that the executor emits. Whitespace
|
||
// between label and `│` varies with padding-to-widest-label.
|
||
for (label, body) in [("lint", "HELLO_LINT"), ("test", "HELLO_TEST")] {
|
||
let has_prefixed_line = stderr
|
||
.lines()
|
||
.any(|l| l.starts_with(label) && l.contains('│') && l.contains(body));
|
||
assert!(
|
||
has_prefixed_line,
|
||
"expected a line starting with '{label}' containing '{body}' and a '│' separator, got:\n{stderr}"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// A failing concurrent step causes the alias to fail. Covers the error
|
||
/// propagation path in the `HookStep::Concurrent` join loop, complementing
|
||
/// the happy-path `test_alias_concurrent_steps` above.
|
||
#[rstest]
|
||
fn test_alias_concurrent_step_failure(repo: TestRepo) {
|
||
repo.write_test_config(
|
||
r#"
|
||
[aliases.check]
|
||
ok = "true"
|
||
fail = "exit 1"
|
||
"#,
|
||
);
|
||
repo.commit("initial");
|
||
|
||
let mut cmd = repo.wt_command();
|
||
cmd.args(["step", "check"]);
|
||
let output = cmd.output().expect("wt step check failed to spawn");
|
||
assert!(
|
||
!output.status.success(),
|
||
"wt step check should fail when a concurrent step exits non-zero"
|
||
);
|
||
}
|
||
|
||
/// Aliases must run their child in wt's process group when stdin is inherited,
|
||
/// not isolate it in a new pgroup. Interactive children (`wt switch`'s picker,
|
||
/// pagers, anything that calls `tcsetattr` on `/dev/tty`) only work when they
|
||
/// share the foreground tty pgroup — otherwise the kernel sends SIGTTOU and
|
||
/// stops the child mid-render. We check the invariant directly: the alias body
|
||
/// records its shell pgid, and we assert it matches wt's pid (== wt's pgid
|
||
/// because the test spawns wt as its own pgroup leader).
|
||
#[rstest]
|
||
#[cfg(unix)]
|
||
fn test_alias_child_shares_parent_pgroup(repo: TestRepo) {
|
||
use std::os::unix::process::CommandExt;
|
||
use std::process::Stdio;
|
||
|
||
repo.write_test_config(
|
||
r#"
|
||
[aliases]
|
||
record-pgid = "ps -o pgid= -p $$ | tr -d ' \n' > alias_pgid.txt"
|
||
"#,
|
||
);
|
||
repo.commit("initial");
|
||
|
||
let mut cmd = repo.wt_command();
|
||
cmd.args(["step", "record-pgid"]);
|
||
cmd.current_dir(repo.root_path());
|
||
cmd.stdout(Stdio::null());
|
||
cmd.stderr(Stdio::null());
|
||
// wt becomes its own pgroup leader: wt.pid == wt.pgid. The alias child
|
||
// (a `sh -c '...'` shell) should inherit that pgid rather than being
|
||
// moved into a new group.
|
||
cmd.process_group(0);
|
||
let mut child = cmd.spawn().expect("failed to spawn wt step record-pgid");
|
||
let wt_pid = child.id() as i32;
|
||
let status = child.wait().expect("failed to wait for wt");
|
||
assert!(status.success(), "alias should succeed, got: {status:?}");
|
||
|
||
let marker = repo.root_path().join("alias_pgid.txt");
|
||
let recorded = std::fs::read_to_string(&marker)
|
||
.unwrap_or_else(|e| panic!("missing pgid marker {marker:?}: {e}"));
|
||
let alias_pgid: i32 = recorded
|
||
.trim()
|
||
.parse()
|
||
.unwrap_or_else(|e| panic!("could not parse pgid {recorded:?}: {e}"));
|
||
assert_eq!(
|
||
alias_pgid, wt_pid,
|
||
"alias child shell must share wt's pgroup (wt pid={wt_pid}); \
|
||
got child pgid {alias_pgid}, indicating a new pgroup was created"
|
||
);
|
||
}
|
||
|
||
/// Externally-delivered SIGTERM to wt (e.g. `kill -TERM <wt-pid>`) must still
|
||
/// reach the alias child in the shared-pgroup case. The kernel only delivers
|
||
/// PID-targeted signals to the named process, not the foreground pgroup, so
|
||
/// the listener in `Cmd::stream` must re-deliver to the child by PID.
|
||
/// Without that, wt would latch the signal but the child would keep running
|
||
/// indefinitely.
|
||
#[rstest]
|
||
#[cfg(unix)]
|
||
fn test_alias_external_sigterm_reaches_child(repo: TestRepo) {
|
||
use crate::common::wait_for_file_content;
|
||
use nix::sys::signal::{Signal, kill};
|
||
use nix::unistd::Pid;
|
||
use std::os::unix::process::CommandExt;
|
||
use std::process::Stdio;
|
||
|
||
// The trap installs in wt's wrapper sh (no inner `sh -c`), so SIGTERM
|
||
// delivered to wt's child by PID hits the same shell that has the
|
||
// handler. The body uses `sleep 30 & wait $!` rather than a plain
|
||
// `sleep 30` because POSIX shells defer trap execution until the
|
||
// current foreground command returns; `wait` (a builtin) is the
|
||
// canonical interruptible idiom.
|
||
repo.write_test_config(
|
||
r#"
|
||
[aliases]
|
||
trapped = "trap 'echo got-term >> trap.log; exit 143' TERM; echo started >> start.log; sleep 30 & wait $!"
|
||
"#,
|
||
);
|
||
repo.commit("initial");
|
||
|
||
let mut cmd = repo.wt_command();
|
||
cmd.args(["step", "trapped"]);
|
||
cmd.current_dir(repo.root_path());
|
||
cmd.stdout(Stdio::null());
|
||
cmd.stderr(Stdio::null());
|
||
// wt becomes its own pgroup leader so a PID-targeted `kill -TERM` against
|
||
// wt does NOT incidentally hit the test harness's pgroup, and only reaches
|
||
// wt itself (not the alias child via kernel broadcast).
|
||
cmd.process_group(0);
|
||
let mut child = cmd.spawn().expect("failed to spawn wt step trapped");
|
||
|
||
// Wait until the alias shell has installed its trap and entered `sleep`.
|
||
let start_marker = repo.root_path().join("start.log");
|
||
wait_for_file_content(&start_marker);
|
||
|
||
// Target wt's PID specifically (not its pgroup) — simulates an external
|
||
// `kill -TERM <wt-pid>`.
|
||
let wt_pid = Pid::from_raw(child.id() as i32);
|
||
kill(wt_pid, Signal::SIGTERM).expect("failed to send SIGTERM to wt");
|
||
|
||
let status = child.wait().expect("failed to wait for wt");
|
||
|
||
// wt forwarded SIGTERM to the wrapper sh by PID (the contract this test
|
||
// pins), so the trap's `exit` orphaned the backgrounded `sleep 30` in wt's
|
||
// process group. wt led that group (`process_group(0)` above) and is now
|
||
// reaped, so SIGKILL the whole group (negative pgid) to reap the sleep
|
||
// before returning — otherwise it lingers ~30s as a stray process.
|
||
let _ = kill(Pid::from_raw(-wt_pid.as_raw()), Signal::SIGKILL);
|
||
|
||
// The trap marker proves the alias shell received SIGTERM. Without the
|
||
// PID-targeted forwarding, this file would never appear and wt would
|
||
// block on the 30s sleep.
|
||
let trap_marker = repo.root_path().join("trap.log");
|
||
let recorded = std::fs::read_to_string(&trap_marker).unwrap_or_else(|e| {
|
||
panic!(
|
||
"missing trap marker {trap_marker:?}: {e} — \
|
||
alias child did not receive SIGTERM (forwarding regressed?)"
|
||
)
|
||
});
|
||
assert!(
|
||
recorded.contains("got-term"),
|
||
"trap marker missing expected content: {recorded:?}"
|
||
);
|
||
|
||
// wt itself exits with the signal-derived code (128 + 15 = 143).
|
||
use std::os::unix::process::ExitStatusExt;
|
||
assert!(
|
||
status.signal() == Some(15) || status.code() == Some(143),
|
||
"wt should exit from SIGTERM (signal 15) or with code 143, got: {status:?}"
|
||
);
|
||
}
|
||
|
||
/// SIGINT counterpart of `test_alias_external_sigterm_reaches_child`. A
|
||
/// PID-targeted `kill -INT <wt-pid>` (not pgroup-broadcast) must traverse the
|
||
/// SIGINT arm of `forward_signal_to_pid` — kernel pgroup delivery is bypassed
|
||
/// when the signal is aimed at a single PID, so the child only sees SIGINT if
|
||
/// wt re-delivers it.
|
||
#[rstest]
|
||
#[cfg(unix)]
|
||
fn test_alias_external_sigint_reaches_child(repo: TestRepo) {
|
||
use crate::common::wait_for_file_content;
|
||
use nix::sys::signal::{Signal, kill};
|
||
use nix::unistd::Pid;
|
||
use std::os::unix::process::CommandExt;
|
||
use std::process::Stdio;
|
||
|
||
repo.write_test_config(
|
||
r#"
|
||
[aliases]
|
||
trapped = "trap 'echo got-int >> trap.log; exit 130' INT; echo started >> start.log; sleep 30 & wait $!"
|
||
"#,
|
||
);
|
||
repo.commit("initial");
|
||
|
||
let mut cmd = repo.wt_command();
|
||
cmd.args(["step", "trapped"]);
|
||
cmd.current_dir(repo.root_path());
|
||
cmd.stdout(Stdio::null());
|
||
cmd.stderr(Stdio::null());
|
||
cmd.process_group(0);
|
||
let mut child = cmd.spawn().expect("failed to spawn wt step trapped");
|
||
|
||
let start_marker = repo.root_path().join("start.log");
|
||
wait_for_file_content(&start_marker);
|
||
|
||
// Target wt's PID specifically (not its pgroup). Kernel won't broadcast to
|
||
// the alias child; the only way the child sees SIGINT is via wt's PID-
|
||
// forwarding path.
|
||
let wt_pid = Pid::from_raw(child.id() as i32);
|
||
kill(wt_pid, Signal::SIGINT).expect("failed to send SIGINT to wt");
|
||
|
||
let status = child.wait().expect("failed to wait for wt");
|
||
|
||
// wt forwarded SIGINT to the wrapper sh by PID (the contract this test
|
||
// pins), so the trap's `exit` orphaned the backgrounded `sleep 30` in wt's
|
||
// process group. wt led that group (`process_group(0)` above) and is now
|
||
// reaped, so SIGKILL the whole group (negative pgid) to reap the sleep
|
||
// before returning — otherwise it lingers ~30s as a stray process.
|
||
let _ = kill(Pid::from_raw(-wt_pid.as_raw()), Signal::SIGKILL);
|
||
|
||
let trap_marker = repo.root_path().join("trap.log");
|
||
let recorded = std::fs::read_to_string(&trap_marker).unwrap_or_else(|e| {
|
||
panic!(
|
||
"missing trap marker {trap_marker:?}: {e} — \
|
||
alias child did not receive SIGINT (forwarding regressed?)"
|
||
)
|
||
});
|
||
assert!(
|
||
recorded.contains("got-int"),
|
||
"trap marker missing expected content: {recorded:?}"
|
||
);
|
||
|
||
use std::os::unix::process::ExitStatusExt;
|
||
assert!(
|
||
status.signal() == Some(2) || status.code() == Some(130),
|
||
"wt should exit from SIGINT (signal 2) or with code 130, got: {status:?}"
|
||
);
|
||
}
|
||
|
||
/// SIGINT sent to `wt step <alias>` while a concurrent group is mid-flight
|
||
/// must reach every child's process group and tear them all down — otherwise
|
||
/// Ctrl-C on a long-running concurrent alias would leave orphans behind.
|
||
///
|
||
/// We spawn the alias in its own process group, wait until BOTH children have
|
||
/// written their "start" marker (proving they're actually running concurrently),
|
||
/// send SIGINT to the group, then verify that the subsequent "done" markers
|
||
/// never appear — every child was interrupted.
|
||
#[rstest]
|
||
#[cfg(unix)]
|
||
fn test_alias_concurrent_receives_sigint(repo: TestRepo) {
|
||
use crate::common::wait_for_file_content;
|
||
use nix::sys::signal::{Signal, kill};
|
||
use nix::unistd::Pid;
|
||
use std::os::unix::process::CommandExt;
|
||
use std::process::Stdio;
|
||
|
||
repo.write_test_config(
|
||
r#"
|
||
[aliases.slow]
|
||
one = "sh -c 'echo start-one >> slow_one.log; sleep 30; echo done-one >> slow_one.log'"
|
||
two = "sh -c 'echo start-two >> slow_two.log; sleep 30; echo done-two >> slow_two.log'"
|
||
"#,
|
||
);
|
||
repo.commit("initial");
|
||
|
||
let mut cmd = repo.wt_command();
|
||
cmd.args(["step", "slow"]);
|
||
cmd.current_dir(repo.root_path());
|
||
cmd.stdout(Stdio::null());
|
||
cmd.stderr(Stdio::null());
|
||
cmd.process_group(0); // wt becomes leader of its own process group
|
||
let mut child = cmd.spawn().expect("failed to spawn wt step slow");
|
||
|
||
// Wait until BOTH children write their start marker — proves the group
|
||
// is running concurrently before we send the signal.
|
||
let one_log = repo.root_path().join("slow_one.log");
|
||
let two_log = repo.root_path().join("slow_two.log");
|
||
wait_for_file_content(&one_log);
|
||
wait_for_file_content(&two_log);
|
||
|
||
// SIGINT the wt process group (wt == leader). The concurrent executor's
|
||
// signal forwarder must propagate it to every child's process group.
|
||
let wt_pgid = Pid::from_raw(child.id() as i32);
|
||
kill(Pid::from_raw(-wt_pgid.as_raw()), Signal::SIGINT)
|
||
.expect("failed to send SIGINT to wt's process group");
|
||
|
||
let status = child.wait().expect("failed to wait for wt");
|
||
|
||
use std::os::unix::process::ExitStatusExt;
|
||
assert!(
|
||
status.signal() == Some(2) || status.code() == Some(130),
|
||
"wt should exit from SIGINT (signal 2) or with code 130, got: {status:?}"
|
||
);
|
||
|
||
// Grace period — the killed children must NOT reach their "done" write.
|
||
std::thread::sleep(SLEEP_FOR_ABSENCE_CHECK);
|
||
for log in [&one_log, &two_log] {
|
||
let contents = std::fs::read_to_string(log).unwrap_or_default();
|
||
assert!(
|
||
!contents.contains("done"),
|
||
"sibling child reached 'done' after SIGINT, log: {contents:?}"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Children that trap SIGINT survive a single Ctrl-C — wt forwards the
|
||
/// user's signal once and waits. Matches `make` / `cargo` behavior:
|
||
/// stubborn children need a second Ctrl-C (see
|
||
/// `test_alias_concurrent_second_sigint_kills`) to escalate to SIGKILL.
|
||
///
|
||
/// Counterpart to `test_alias_concurrent_receives_sigint` (cooperative
|
||
/// children, single press is sufficient). Together they pin the
|
||
/// no-per-child-escalation contract: the previous design escalated
|
||
/// SIGINT → SIGTERM → SIGKILL inside one press, which under CI scheduling
|
||
/// latency could land SIGTERM on a cooperative-but-slow child and make
|
||
/// `wt step <alias>` exit 143 instead of 130 on Ctrl-C.
|
||
#[rstest]
|
||
#[cfg(unix)]
|
||
fn test_alias_concurrent_sigint_trapped_survives_first_press(repo: TestRepo) {
|
||
use crate::common::wait_for_file_content;
|
||
use nix::sys::signal::{Signal, kill};
|
||
use nix::unistd::Pid;
|
||
use std::os::unix::process::CommandExt;
|
||
use std::process::Stdio;
|
||
|
||
// Children trap SIGINT (ignore it). With the new no-escalation
|
||
// contract, a single SIGINT should NOT kill them.
|
||
repo.write_test_config(
|
||
r#"
|
||
[aliases.intignored]
|
||
one = "sh -c 'trap \"\" INT; echo start-one >> ignored_one.log; sleep 30'"
|
||
two = "sh -c 'trap \"\" INT; echo start-two >> ignored_two.log; sleep 30'"
|
||
"#,
|
||
);
|
||
repo.commit("initial");
|
||
|
||
let mut cmd = repo.wt_command();
|
||
cmd.args(["step", "intignored"]);
|
||
cmd.current_dir(repo.root_path());
|
||
cmd.stdout(Stdio::null());
|
||
cmd.stderr(Stdio::null());
|
||
cmd.process_group(0);
|
||
let mut child = cmd.spawn().expect("failed to spawn wt step intignored");
|
||
|
||
wait_for_file_content(&repo.root_path().join("ignored_one.log"));
|
||
wait_for_file_content(&repo.root_path().join("ignored_two.log"));
|
||
|
||
let wt_pgid = Pid::from_raw(child.id() as i32);
|
||
kill(Pid::from_raw(-wt_pgid.as_raw()), Signal::SIGINT)
|
||
.expect("failed to send SIGINT to wt's process group");
|
||
|
||
// Give the signal a moment to traverse the forwarder. With escalation
|
||
// (the old design), within ~400 ms SIGTERM would have killed both
|
||
// children and wt would be in the process of exiting. With the new
|
||
// contract, both children keep sleeping and wt is still running.
|
||
std::thread::sleep(SLEEP_FOR_ABSENCE_CHECK);
|
||
|
||
match child.try_wait().expect("try_wait failed") {
|
||
None => {
|
||
// Expected: wt is still running, blocked on its sleeping
|
||
// children. Clean up with a second SIGINT (impatient
|
||
// SIGKILL path).
|
||
kill(Pid::from_raw(-wt_pgid.as_raw()), Signal::SIGINT)
|
||
.expect("failed to send second SIGINT");
|
||
let _ = child.wait();
|
||
}
|
||
Some(status) => panic!(
|
||
"wt exited after a single SIGINT against SIGINT-trapping children — \
|
||
per-child escalation appears to have crept back in. status: {status:?}"
|
||
),
|
||
}
|
||
}
|
||
|
||
/// A second SIGINT (user mashing Ctrl-C) must escalate to SIGKILL on every
|
||
/// child immediately — otherwise a child that traps SIGINT keeps the group
|
||
/// alive for up to N × 400ms of per-pgid escalation, with subsequent
|
||
/// presses silently discarded.
|
||
#[rstest]
|
||
#[cfg(unix)]
|
||
fn test_alias_concurrent_second_sigint_kills(repo: TestRepo) {
|
||
use crate::common::wait_for_file_content;
|
||
use nix::sys::signal::{Signal, kill};
|
||
use nix::unistd::Pid;
|
||
use std::os::unix::process::CommandExt;
|
||
use std::process::Stdio;
|
||
|
||
// Both children trap SIGINT and sleep; first SIGINT does nothing
|
||
// (graceful escalation to SIGTERM is also trapped), a second SIGINT
|
||
// must SIGKILL the pgids and exit wt promptly.
|
||
repo.write_test_config(
|
||
r#"
|
||
[aliases.stubborn]
|
||
one = "sh -c 'trap \"\" INT TERM; echo start-one >> stubborn_one.log; sleep 30'"
|
||
two = "sh -c 'trap \"\" INT TERM; echo start-two >> stubborn_two.log; sleep 30'"
|
||
"#,
|
||
);
|
||
repo.commit("initial");
|
||
|
||
let mut cmd = repo.wt_command();
|
||
cmd.args(["step", "stubborn"]);
|
||
cmd.current_dir(repo.root_path());
|
||
cmd.stdout(Stdio::null());
|
||
cmd.stderr(Stdio::null());
|
||
cmd.process_group(0);
|
||
let mut child = cmd.spawn().expect("failed to spawn wt step stubborn");
|
||
|
||
wait_for_file_content(&repo.root_path().join("stubborn_one.log"));
|
||
wait_for_file_content(&repo.root_path().join("stubborn_two.log"));
|
||
|
||
let wt_pgid = Pid::from_raw(child.id() as i32);
|
||
// First SIGINT — trapped by children; graceful path chews through
|
||
// escalation serially.
|
||
kill(Pid::from_raw(-wt_pgid.as_raw()), Signal::SIGINT).expect("failed to send first SIGINT");
|
||
|
||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||
|
||
// Second SIGINT — impatient path should SIGKILL the whole tree now.
|
||
kill(Pid::from_raw(-wt_pgid.as_raw()), Signal::SIGINT).expect("failed to send second SIGINT");
|
||
|
||
let start = std::time::Instant::now();
|
||
let _status = child.wait().expect("failed to wait for wt");
|
||
let elapsed = start.elapsed();
|
||
|
||
// With only graceful escalation (200ms × 2 grace windows × 2 pgids),
|
||
// worst case would be ~800ms. The impatient SIGKILL should be faster
|
||
// still. Give 3s headroom for slow CI without being so loose that a
|
||
// regression (no SIGKILL on 2nd press) would slip through — that
|
||
// regression would leave wt waiting ~60s for the sleeps to finish.
|
||
assert!(
|
||
elapsed < std::time::Duration::from_secs(3),
|
||
"wt took too long to die after 2nd SIGINT; impatient path may not be firing: {elapsed:?}"
|
||
);
|
||
}
|
||
|
||
/// Non-UTF-8 bytes and CRLF line endings in child output must not stall the
|
||
/// executor. Earlier code using `BufRead::lines()` returned `InvalidData` on
|
||
/// the first invalid byte and terminated the iterator, leaving the child's
|
||
/// pipe un-drained and `child.wait()` hanging forever. Also exercises the
|
||
/// `\r\n` strip path so trailing carriage returns don't render visibly.
|
||
#[rstest]
|
||
fn test_alias_concurrent_handles_non_utf8(repo: TestRepo) {
|
||
// `printf` emits a raw 0xff byte (invalid as a lone UTF-8 sequence), a
|
||
// CRLF-terminated line, then `yes` floods 50_000 more valid UTF-8 lines.
|
||
// If the reader stopped at the bad byte, the pipe would fill and the
|
||
// child would block — we'd time out. The trivial `other` command keeps
|
||
// the step a multi-command group so it routes through the concurrent
|
||
// executor (a one-command map parses as a plain `Single` step).
|
||
repo.write_test_config(
|
||
r#"
|
||
[aliases.noisy]
|
||
mixed = "sh -c 'printf \"BEFORE\\n\\xff\\nCRLF-LINE\\r\\nAFTER\\n\"; yes PAYLOAD | head -n 50000'"
|
||
other = "true"
|
||
"#,
|
||
);
|
||
repo.commit("initial");
|
||
|
||
let mut cmd = repo.wt_command();
|
||
cmd.args(["step", "noisy"]);
|
||
let output = cmd.output().expect("wt step noisy failed to spawn");
|
||
|
||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||
assert!(
|
||
output.status.success(),
|
||
"alias should succeed despite non-UTF-8 byte, got: {status:?}\nlast 500 bytes of stderr: {tail}",
|
||
status = output.status,
|
||
tail = &stderr[stderr.len().saturating_sub(500)..],
|
||
);
|
||
|
||
// All clean lines plus all 50_000 flood lines must land — proves the
|
||
// reader kept going past the invalid byte.
|
||
assert!(stderr.contains("BEFORE"), "expected BEFORE in stderr");
|
||
assert!(
|
||
stderr.contains("AFTER"),
|
||
"expected AFTER in stderr (reader stopped at the invalid byte)"
|
||
);
|
||
// CRLF line ending: the reader strips the trailing \r, so the visible
|
||
// line is `CRLF-LINE` not `CRLF-LINE\r`. Any `\r` in the output would
|
||
// indicate the strip didn't fire.
|
||
assert!(
|
||
stderr.contains("CRLF-LINE"),
|
||
"expected CRLF-LINE (with the \\r stripped) in stderr"
|
||
);
|
||
assert!(
|
||
!stderr.contains("CRLF-LINE\r"),
|
||
"trailing \\r should have been stripped before printing"
|
||
);
|
||
assert_eq!(
|
||
stderr.matches("PAYLOAD").count(),
|
||
50_000,
|
||
"expected 50000 PAYLOAD lines after the invalid byte, got {}",
|
||
stderr.matches("PAYLOAD").count(),
|
||
);
|
||
}
|
||
|
||
/// A concurrent child that produces a large volume of stdout must not
|
||
/// deadlock the executor — the reader thread has to keep draining the pipe so
|
||
/// the child can keep writing. We run two commands, each emitting ~400 KB, and
|
||
/// assert both streams land in stderr intact.
|
||
#[rstest]
|
||
fn test_alias_concurrent_large_output(repo: TestRepo) {
|
||
// yes piped through head is a portable way to generate many lines fast.
|
||
// Each command produces ~400 KB = 50_000 * ~8 bytes ("aaaa...\n" etc.).
|
||
// If the reader thread were ever to stall, the child's stdout pipe would
|
||
// fill (default ~64 KB) and the child would block forever — the test would
|
||
// time out rather than produce a misleading pass.
|
||
repo.write_test_config(
|
||
r#"
|
||
[aliases.bulk]
|
||
first = "yes 'FIRST-PAYLOAD-AAAAA' | head -n 50000"
|
||
second = "yes 'SECOND-PAYLOAD-BBBBB' | head -n 50000"
|
||
"#,
|
||
);
|
||
repo.commit("initial");
|
||
|
||
let mut cmd = repo.wt_command();
|
||
cmd.args(["step", "bulk"]);
|
||
let output = cmd.output().expect("wt step bulk failed to spawn");
|
||
|
||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||
assert!(
|
||
output.status.success(),
|
||
"concurrent alias with large output should exit 0, got: {status:?}\nlast 500 bytes of stderr: {tail}",
|
||
status = output.status,
|
||
tail = &stderr[stderr.len().saturating_sub(500)..],
|
||
);
|
||
|
||
// Count occurrences so we know both children's full output was streamed —
|
||
// not truncated by a blocked pipe. 50_000 exact matches per payload.
|
||
let first_count = stderr.matches("FIRST-PAYLOAD-AAAAA").count();
|
||
let second_count = stderr.matches("SECOND-PAYLOAD-BBBBB").count();
|
||
assert_eq!(
|
||
first_count, 50_000,
|
||
"expected 50000 occurrences of first payload in stderr, got {first_count}"
|
||
);
|
||
assert_eq!(
|
||
second_count, 50_000,
|
||
"expected 50000 occurrences of second payload in stderr, got {second_count}"
|
||
);
|
||
}
|
||
|
||
/// Pipeline-form aliases (list of steps) run sequentially. A later step
|
||
/// referencing `{{ vars.X }}` must see vars set by an earlier step —
|
||
/// `expand_shell_template` reads `vars.*` fresh from git config on each call.
|
||
#[rstest]
|
||
fn test_alias_pipeline_vars_across_steps(repo: TestRepo) {
|
||
repo.write_test_config(
|
||
r#"
|
||
[aliases]
|
||
deploy = [
|
||
"git config worktrunk.state.main.vars.target 'staging'",
|
||
{ publish = "echo target={{ vars.target }} > alias_lazy.txt" },
|
||
]
|
||
"#,
|
||
);
|
||
repo.commit("initial");
|
||
|
||
let mut cmd = repo.wt_command();
|
||
cmd.args(["step", "deploy"]);
|
||
let output = cmd.output().expect("wt step deploy failed to spawn");
|
||
assert!(
|
||
output.status.success(),
|
||
"wt step deploy failed: stdout={}\nstderr={}",
|
||
String::from_utf8_lossy(&output.stdout),
|
||
String::from_utf8_lossy(&output.stderr),
|
||
);
|
||
|
||
let marker = repo.root_path().join("alias_lazy.txt");
|
||
let content = std::fs::read_to_string(&marker)
|
||
.unwrap_or_else(|e| panic!("missing marker {marker:?}: {e}"));
|
||
assert_eq!(
|
||
content.trim(),
|
||
"target=staging",
|
||
"lazy step should see var set by prior serial step"
|
||
);
|
||
}
|
||
|
||
/// `dry-run` for a pipeline where a later step references `{{ vars.X }}` set by
|
||
/// an earlier step succeeds, mirroring the lazy execution path. The unresolved
|
||
/// `vars.*` reference is shown as the raw template since its value isn't
|
||
/// knowable until the earlier step actually runs.
|
||
#[rstest]
|
||
fn test_config_alias_dry_run_vars_across_steps(mut repo: TestRepo) {
|
||
repo.write_project_config(
|
||
r#"
|
||
[aliases]
|
||
deploy = [
|
||
"git config worktrunk.state.main.vars.target 'prod'",
|
||
{ publish = "echo deploying to {{ vars.target }}" },
|
||
]
|
||
"#,
|
||
);
|
||
repo.commit("Add alias config");
|
||
let feature_path = repo.add_worktree("feature");
|
||
|
||
let settings = setup_snapshot_settings(&repo);
|
||
let _guard = settings.bind_to_scope();
|
||
|
||
// Must succeed: dry-run must not require vars.* to be resolvable.
|
||
assert_cmd_snapshot!(make_snapshot_cmd(
|
||
&repo,
|
||
"config",
|
||
&["alias", "dry-run", "deploy"],
|
||
Some(&feature_path),
|
||
));
|
||
}
|
||
|
||
/// `dry-run` still catches template syntax errors (e.g., `{{ vars..foo }}`) even
|
||
/// on the lazy path where `vars.*` rendering is skipped.
|
||
#[rstest]
|
||
fn test_config_alias_dry_run_catches_syntax_error(mut repo: TestRepo) {
|
||
repo.write_project_config(
|
||
r#"
|
||
[aliases]
|
||
broken = "echo {{ vars..target }}"
|
||
"#,
|
||
);
|
||
repo.commit("Add alias config");
|
||
let feature_path = repo.add_worktree("feature");
|
||
|
||
let output = repo
|
||
.wt_command()
|
||
.args(["config", "alias", "dry-run", "broken"])
|
||
.current_dir(&feature_path)
|
||
.output()
|
||
.unwrap();
|
||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||
assert!(
|
||
!output.status.success(),
|
||
"dry-run should fail on syntax error; stderr:\n{stderr}"
|
||
);
|
||
assert!(
|
||
stderr.contains("syntax error"),
|
||
"expected 'syntax error' in stderr, got:\n{stderr}"
|
||
);
|
||
}
|
||
|
||
/// Retired `wt <alias> --dry-run` flag produces an actionable error pointing at
|
||
/// the new subcommand. Snapshots the top-level path; the shared parser covers
|
||
/// both `wt <alias>` and `wt step <alias>` dispatch routes (unit test in
|
||
/// `commands::alias::tests::test_parse_errors` verifies the message verbatim).
|
||
#[rstest]
|
||
fn test_retired_dry_run_flag(mut repo: TestRepo) {
|
||
repo.write_project_config(
|
||
r#"
|
||
[aliases]
|
||
deploy = "echo hi"
|
||
"#,
|
||
);
|
||
repo.commit("Add alias config");
|
||
let feature_path = repo.add_worktree("feature");
|
||
|
||
let settings = setup_snapshot_settings(&repo);
|
||
let _guard = settings.bind_to_scope();
|
||
|
||
assert_cmd_snapshot!(make_snapshot_cmd(
|
||
&repo,
|
||
"deploy",
|
||
&["--dry-run"],
|
||
Some(&feature_path),
|
||
));
|
||
}
|
||
|
||
/// Retired `--dry-run` bail also fires through `wt step <alias>` — the parser
|
||
/// is shared, but this pins the `step_alias` dispatch route specifically.
|
||
#[rstest]
|
||
fn test_retired_dry_run_flag_via_step(mut repo: TestRepo) {
|
||
repo.write_project_config(
|
||
r#"
|
||
[aliases]
|
||
deploy = "echo hi"
|
||
"#,
|
||
);
|
||
repo.commit("Add alias config");
|
||
let feature_path = repo.add_worktree("feature");
|
||
|
||
let settings = setup_snapshot_settings(&repo);
|
||
let _guard = settings.bind_to_scope();
|
||
|
||
assert_cmd_snapshot!(make_snapshot_cmd(
|
||
&repo,
|
||
"step",
|
||
&["deploy", "--dry-run"],
|
||
Some(&feature_path),
|
||
));
|
||
}
|
||
|
||
/// `wt <alias> --help` prints guidance rather than forwarding `--help` into
|
||
/// `{{ args }}`. Aliases have no clap-style help page; the canonical
|
||
/// inspection path is `wt config alias show / dry-run`.
|
||
#[rstest]
|
||
fn test_alias_help_flag_prints_hint(mut repo: TestRepo) {
|
||
repo.write_project_config(
|
||
r#"
|
||
[aliases]
|
||
deploy = "echo hi {{ args }}"
|
||
"#,
|
||
);
|
||
repo.commit("Add alias config");
|
||
let feature_path = repo.add_worktree("feature");
|
||
|
||
let settings = setup_snapshot_settings(&repo);
|
||
let _guard = settings.bind_to_scope();
|
||
|
||
assert_cmd_snapshot!(make_snapshot_cmd(
|
||
&repo,
|
||
"deploy",
|
||
&["--help"],
|
||
Some(&feature_path),
|
||
));
|
||
}
|
||
|
||
/// `wt <alias> -- --help` bypasses the intercept and forwards `--help` into
|
||
/// the alias body — the documented escape.
|
||
#[rstest]
|
||
fn test_alias_help_flag_after_double_dash_forwards(mut repo: TestRepo) {
|
||
repo.write_project_config(
|
||
r#"
|
||
[aliases]
|
||
deploy = "echo {{ args }}"
|
||
"#,
|
||
);
|
||
repo.commit("Add alias config");
|
||
let feature_path = repo.add_worktree("feature");
|
||
|
||
let settings = setup_snapshot_settings(&repo);
|
||
let _guard = settings.bind_to_scope();
|
||
|
||
assert_cmd_snapshot!(make_snapshot_cmd(
|
||
&repo,
|
||
"deploy",
|
||
&["--", "--help"],
|
||
Some(&feature_path),
|
||
));
|
||
}
|
||
|
||
/// `wt config alias show <name>` prints the configured template text, source-labeled.
|
||
#[rstest]
|
||
fn test_config_alias_show_single(mut repo: TestRepo) {
|
||
repo.write_project_config(
|
||
r#"
|
||
[aliases]
|
||
deploy = "make deploy BRANCH={{ branch }}"
|
||
"#,
|
||
);
|
||
repo.commit("Add alias config");
|
||
let feature_path = repo.add_worktree("feature");
|
||
|
||
let settings = setup_snapshot_settings(&repo);
|
||
let _guard = settings.bind_to_scope();
|
||
|
||
assert_cmd_snapshot!(make_snapshot_cmd(
|
||
&repo,
|
||
"config",
|
||
&["alias", "show", "deploy"],
|
||
Some(&feature_path),
|
||
));
|
||
}
|
||
|
||
/// `wt config alias show` with no name prints every configured alias's full
|
||
/// definition — the same per-alias header + gutter block as `wt config alias
|
||
/// show <name>`, emitted for each alias in name order. Names defined in both
|
||
/// user and project config show twice (user first), and a name shadowed by a
|
||
/// top-level built-in gets the stderr warning.
|
||
#[rstest]
|
||
fn test_config_alias_show_lists_all(mut repo: TestRepo) {
|
||
repo.write_project_config(
|
||
r#"
|
||
[aliases]
|
||
deploy = "echo from project"
|
||
"only-project" = "echo p"
|
||
list = "echo custom list"
|
||
"#,
|
||
);
|
||
repo.commit("Add alias config");
|
||
let feature_path = repo.add_worktree("feature");
|
||
repo.write_test_config(
|
||
r#"
|
||
[aliases]
|
||
deploy = "echo from user"
|
||
greet = "echo hi {{ branch }}"
|
||
"#,
|
||
);
|
||
|
||
let settings = setup_snapshot_settings(&repo);
|
||
let _guard = settings.bind_to_scope();
|
||
|
||
assert_cmd_snapshot!(make_snapshot_cmd(
|
||
&repo,
|
||
"config",
|
||
&["alias", "show"],
|
||
Some(&feature_path),
|
||
));
|
||
}
|
||
|
||
/// `wt config alias show` with no name and no aliases configured prints a note
|
||
/// rather than empty output.
|
||
#[rstest]
|
||
fn test_config_alias_show_lists_none(mut repo: TestRepo) {
|
||
repo.commit("Initial commit");
|
||
let feature_path = repo.add_worktree("feature");
|
||
|
||
let settings = setup_snapshot_settings(&repo);
|
||
let _guard = settings.bind_to_scope();
|
||
|
||
assert_cmd_snapshot!(make_snapshot_cmd(
|
||
&repo,
|
||
"config",
|
||
&["alias", "show"],
|
||
Some(&feature_path),
|
||
));
|
||
}
|
||
|
||
/// Unknown alias name triggers a did-you-mean suggestion. Format mirrors
|
||
/// `wt <typo>` and `wt step <typo>`: clap-native `InvalidSubcommand` with a
|
||
/// `tip:` line — same shape at every alias-typo surface.
|
||
#[rstest]
|
||
fn test_config_alias_show_unknown_suggests(mut repo: TestRepo) {
|
||
repo.write_project_config(
|
||
r#"
|
||
[aliases]
|
||
deploy = "make deploy"
|
||
hello = "echo hi"
|
||
"#,
|
||
);
|
||
repo.commit("Add alias config");
|
||
let feature_path = repo.add_worktree("feature");
|
||
|
||
let settings = setup_snapshot_settings(&repo);
|
||
let _guard = settings.bind_to_scope();
|
||
|
||
assert_cmd_snapshot!(make_snapshot_cmd(
|
||
&repo,
|
||
"config",
|
||
&["alias", "show", "deplyo"],
|
||
Some(&feature_path),
|
||
));
|
||
}
|
||
|
||
/// Aliases or typos that literally contain `subcommand` must be echoed
|
||
/// verbatim — the word-substitution pass that rewrites clap's
|
||
/// "unrecognized subcommand" to "unrecognized alias" must only touch
|
||
/// clap's fixed phrases, not the user's input.
|
||
#[rstest]
|
||
fn test_config_alias_show_unknown_preserves_user_input(mut repo: TestRepo) {
|
||
repo.write_project_config(
|
||
r#"
|
||
[aliases]
|
||
"my-subcommand" = "echo hi"
|
||
"#,
|
||
);
|
||
repo.commit("Add alias config");
|
||
let feature_path = repo.add_worktree("feature");
|
||
|
||
let settings = setup_snapshot_settings(&repo);
|
||
let _guard = settings.bind_to_scope();
|
||
|
||
// Typo `my-subcommond` is close enough to `my-subcommand` to suggest it —
|
||
// both the echoed typo and the suggestion must keep `subcommand` intact.
|
||
assert_cmd_snapshot!(make_snapshot_cmd(
|
||
&repo,
|
||
"config",
|
||
&["alias", "show", "my-subcommond"],
|
||
Some(&feature_path),
|
||
));
|
||
}
|
||
|
||
/// Single-match case: tip phrasing switches to the singular form
|
||
/// ("a similar alias exists") — mirrors clap's own singular rendering.
|
||
#[rstest]
|
||
fn test_config_alias_show_unknown_singular_suggestion(mut repo: TestRepo) {
|
||
repo.write_project_config(
|
||
r#"
|
||
[aliases]
|
||
deploy = "make deploy"
|
||
"#,
|
||
);
|
||
repo.commit("Add alias config");
|
||
let feature_path = repo.add_worktree("feature");
|
||
|
||
let settings = setup_snapshot_settings(&repo);
|
||
let _guard = settings.bind_to_scope();
|
||
|
||
assert_cmd_snapshot!(make_snapshot_cmd(
|
||
&repo,
|
||
"config",
|
||
&["alias", "show", "deplyo"],
|
||
Some(&feature_path),
|
||
));
|
||
}
|
||
|
||
/// `wt config alias dry-run <typo>` produces the same clap-native typo error
|
||
/// as `show` — consistent format across both introspection subcommands.
|
||
#[rstest]
|
||
fn test_config_alias_dry_run_unknown_suggests(mut repo: TestRepo) {
|
||
repo.write_project_config(
|
||
r#"
|
||
[aliases]
|
||
deploy = "make deploy"
|
||
hello = "echo hi"
|
||
"#,
|
||
);
|
||
repo.commit("Add alias config");
|
||
let feature_path = repo.add_worktree("feature");
|
||
|
||
let settings = setup_snapshot_settings(&repo);
|
||
let _guard = settings.bind_to_scope();
|
||
|
||
assert_cmd_snapshot!(make_snapshot_cmd(
|
||
&repo,
|
||
"config",
|
||
&["alias", "dry-run", "deplyo"],
|
||
Some(&feature_path),
|
||
));
|
||
}
|
||
|
||
/// Multi-step pipeline renders with per-step structure in the header.
|
||
#[rstest]
|
||
fn test_config_alias_show_pipeline(mut repo: TestRepo) {
|
||
repo.write_project_config(
|
||
r#"
|
||
[[aliases.release]]
|
||
install = "npm install"
|
||
|
||
[[aliases.release]]
|
||
build = "npm run build"
|
||
lint = "npm run lint"
|
||
"#,
|
||
);
|
||
repo.commit("Add alias config");
|
||
let feature_path = repo.add_worktree("feature");
|
||
|
||
let settings = setup_snapshot_settings(&repo);
|
||
let _guard = settings.bind_to_scope();
|
||
|
||
assert_cmd_snapshot!(make_snapshot_cmd(
|
||
&repo,
|
||
"config",
|
||
&["alias", "show", "release"],
|
||
Some(&feature_path),
|
||
));
|
||
}
|
||
|
||
/// Positional args via `wt config alias dry-run <name> -- foo bar` flow through as `{{ args }}`.
|
||
#[rstest]
|
||
fn test_config_alias_dry_run_positional_args(mut repo: TestRepo) {
|
||
repo.write_project_config(
|
||
r#"
|
||
[aliases]
|
||
s = "wt switch {{ args }}"
|
||
"#,
|
||
);
|
||
repo.commit("Add alias config");
|
||
let feature_path = repo.add_worktree("feature");
|
||
|
||
let settings = setup_snapshot_settings(&repo);
|
||
let _guard = settings.bind_to_scope();
|
||
|
||
assert_cmd_snapshot!(make_snapshot_cmd(
|
||
&repo,
|
||
"config",
|
||
&["alias", "dry-run", "s", "--", "target-branch"],
|
||
Some(&feature_path),
|
||
));
|
||
}
|
||
|
||
/// `wt config alias show <name>` with the same alias defined in both user and
|
||
/// project config prints both entries in runtime order (user first).
|
||
#[rstest]
|
||
fn test_config_alias_show_user_and_project(mut repo: TestRepo) {
|
||
repo.write_project_config(
|
||
r#"
|
||
[aliases]
|
||
deploy = "echo from project"
|
||
"#,
|
||
);
|
||
repo.commit("Add alias config");
|
||
let feature_path = repo.add_worktree("feature");
|
||
repo.write_test_config(
|
||
r#"
|
||
[aliases]
|
||
deploy = "echo from user"
|
||
"#,
|
||
);
|
||
|
||
let settings = setup_snapshot_settings(&repo);
|
||
let _guard = settings.bind_to_scope();
|
||
|
||
assert_cmd_snapshot!(make_snapshot_cmd(
|
||
&repo,
|
||
"config",
|
||
&["alias", "show", "deploy"],
|
||
Some(&feature_path),
|
||
));
|
||
}
|
||
|
||
/// Unknown alias name with no similar configured aliases shows a plain error
|
||
/// without a "did you mean" tail.
|
||
#[rstest]
|
||
fn test_config_alias_show_unknown_no_suggestions(mut repo: TestRepo) {
|
||
repo.write_project_config(
|
||
r#"
|
||
[aliases]
|
||
deploy = "echo hi"
|
||
"#,
|
||
);
|
||
repo.commit("Add alias config");
|
||
let feature_path = repo.add_worktree("feature");
|
||
|
||
let settings = setup_snapshot_settings(&repo);
|
||
let _guard = settings.bind_to_scope();
|
||
|
||
// Query name shares no meaningful prefix with any configured alias — the
|
||
// Jaro-Winkler threshold rejects it, so the error has no suggestion list.
|
||
assert_cmd_snapshot!(make_snapshot_cmd(
|
||
&repo,
|
||
"config",
|
||
&["alias", "show", "zzzzzzzz"],
|
||
Some(&feature_path),
|
||
));
|
||
}
|
||
|
||
/// `wt config alias show <name>` on an alias whose name is also a top-level
|
||
/// built-in subcommand warns that the alias is unreachable via `wt <name>`.
|
||
/// The alias is still configured, so the show output itself is shown — the
|
||
/// warning is an advisory on stderr.
|
||
#[rstest]
|
||
fn test_config_alias_show_warns_on_shadowed_name(mut repo: TestRepo) {
|
||
repo.write_project_config(
|
||
r#"
|
||
[aliases]
|
||
list = "echo custom list"
|
||
"#,
|
||
);
|
||
repo.commit("Add alias config");
|
||
let feature_path = repo.add_worktree("feature");
|
||
|
||
let settings = setup_snapshot_settings(&repo);
|
||
let _guard = settings.bind_to_scope();
|
||
|
||
assert_cmd_snapshot!(make_snapshot_cmd(
|
||
&repo,
|
||
"config",
|
||
&["alias", "show", "list"],
|
||
Some(&feature_path),
|
||
));
|
||
}
|
||
|
||
/// `wt config alias dry-run` on a shadowed name emits the same advisory as
|
||
/// `show` — both are discovery surfaces, so both point out the shadowing.
|
||
#[rstest]
|
||
fn test_config_alias_dry_run_warns_on_shadowed_name(mut repo: TestRepo) {
|
||
repo.write_project_config(
|
||
r#"
|
||
[aliases]
|
||
list = "echo custom list"
|
||
"#,
|
||
);
|
||
repo.commit("Add alias config");
|
||
let feature_path = repo.add_worktree("feature");
|
||
|
||
let settings = setup_snapshot_settings(&repo);
|
||
let _guard = settings.bind_to_scope();
|
||
|
||
assert_cmd_snapshot!(make_snapshot_cmd(
|
||
&repo,
|
||
"config",
|
||
&["alias", "dry-run", "list"],
|
||
Some(&feature_path),
|
||
));
|
||
}
|
||
|
||
/// `wt step` with no subcommand lists built-in steps plus configured aliases.
|
||
///
|
||
/// Skipped on Windows: clap renders `[experimental]` subcommand tags
|
||
/// differently (markdown escaping), same reason `tests/integration_tests/help.rs`
|
||
/// is Windows-gated.
|
||
#[cfg(not(windows))]
|
||
#[rstest]
|
||
fn test_step_list_with_aliases(mut repo: TestRepo) {
|
||
repo.write_project_config(
|
||
r#"
|
||
[aliases]
|
||
deploy = "make deploy BRANCH={{ branch }}"
|
||
port = "echo http://localhost:{{ branch | hash_port }}"
|
||
squash = "this shadows the built-in"
|
||
"#,
|
||
);
|
||
repo.commit("Add alias config");
|
||
let feature_path = repo.add_worktree("feature");
|
||
|
||
let settings = setup_snapshot_settings(&repo);
|
||
let _guard = settings.bind_to_scope();
|
||
|
||
assert_cmd_snapshot!(make_snapshot_cmd(&repo, "step", &[], Some(&feature_path)));
|
||
}
|
||
|
||
/// `wt step` without configured aliases still works — just prints help.
|
||
#[cfg(not(windows))]
|
||
#[rstest]
|
||
fn test_step_list_no_aliases(mut repo: TestRepo) {
|
||
let feature_path = repo.add_worktree("feature");
|
||
|
||
let settings = setup_snapshot_settings(&repo);
|
||
let _guard = settings.bind_to_scope();
|
||
|
||
assert_cmd_snapshot!(make_snapshot_cmd(&repo, "step", &[], Some(&feature_path)));
|
||
}
|
||
|
||
/// `wt step --help` includes the same Aliases section as bare `wt step`.
|
||
///
|
||
/// Without this, users running `--help` in the normal discovery flow would
|
||
/// see only built-in commands and miss their configured aliases.
|
||
#[cfg(not(windows))]
|
||
#[rstest]
|
||
fn test_step_help_includes_aliases(mut repo: TestRepo) {
|
||
repo.write_project_config(
|
||
r#"
|
||
[aliases]
|
||
deploy = "make deploy BRANCH={{ branch }}"
|
||
"#,
|
||
);
|
||
repo.commit("Add alias config");
|
||
let feature_path = repo.add_worktree("feature");
|
||
|
||
let settings = setup_snapshot_settings(&repo);
|
||
let _guard = settings.bind_to_scope();
|
||
|
||
assert_cmd_snapshot!(make_snapshot_cmd(
|
||
&repo,
|
||
"step",
|
||
&["-h"],
|
||
Some(&feature_path)
|
||
));
|
||
}
|
||
|
||
/// `wt step --help` must not emit deprecation warnings or write `.new`
|
||
/// migration files when the user config contains deprecated patterns —
|
||
/// help is a discovery surface, not an execution surface, and the user
|
||
/// will see those warnings from `wt config show` and normal commands.
|
||
#[cfg(not(windows))]
|
||
#[rstest]
|
||
fn test_step_help_silent_with_deprecated_user_config(repo: TestRepo) {
|
||
// Deprecated `main_worktree` template variable — migrated to `repo`.
|
||
repo.write_test_config(
|
||
r#"worktree-path = "../{{ main_worktree }}.{{ branch }}"
|
||
"#,
|
||
);
|
||
let migration_file = repo.test_config_path().with_extension("toml.new");
|
||
|
||
let output = repo.wt_command().args(["step", "--help"]).output().unwrap();
|
||
|
||
assert!(
|
||
output.status.success(),
|
||
"step --help should succeed: {}",
|
||
String::from_utf8_lossy(&output.stderr)
|
||
);
|
||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||
assert_eq!(
|
||
stderr, "",
|
||
"step --help must emit no stderr on deprecated user config"
|
||
);
|
||
assert!(
|
||
!migration_file.exists(),
|
||
"step --help must not write .new migration file at {}",
|
||
migration_file.display()
|
||
);
|
||
}
|
||
|
||
/// `wt -C <other> step --help` lists aliases from `<other>`'s project config,
|
||
/// not from the process cwd. Without applying global options before the help
|
||
/// branch, the Aliases section was rendered from the wrong repo.
|
||
#[cfg(not(windows))]
|
||
#[rstest]
|
||
fn test_step_help_honors_dash_c(repo: TestRepo) {
|
||
repo.write_project_config(
|
||
r#"
|
||
[aliases]
|
||
xyzzy = "echo nothing happens"
|
||
"#,
|
||
);
|
||
repo.commit("Add alias config");
|
||
let repo_path = repo.root_path().to_path_buf();
|
||
|
||
// Invoke from a directory that is *not* inside the repo so the alias can
|
||
// only be discovered via -C. Using the system temp dir keeps this
|
||
// independent of the test's working directory.
|
||
let cwd = std::env::temp_dir();
|
||
let mut cmd = repo.wt_command();
|
||
cmd.current_dir(&cwd)
|
||
.args(["-C", repo_path.to_str().unwrap(), "step", "--help"]);
|
||
let output = cmd.output().expect("failed to run wt");
|
||
|
||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||
assert!(
|
||
stdout.contains("xyzzy"),
|
||
"expected `xyzzy` alias to appear in `wt -C <repo> step --help` output\n\
|
||
stdout:\n{stdout}\nstderr:\n{stderr}"
|
||
);
|
||
}
|
||
|
||
/// Positional args after the alias name forward to `{{ args }}` in the
|
||
/// template — space-joined and shell-escaped so args with spaces, quotes,
|
||
/// or metacharacters splice safely into a command line.
|
||
#[rstest]
|
||
fn test_step_alias_forwards_positional_args(mut repo: TestRepo) {
|
||
repo.write_project_config(
|
||
r#"
|
||
[aliases]
|
||
run = "echo got {{ args }}"
|
||
"#,
|
||
);
|
||
repo.commit("Add alias config");
|
||
let feature_path = repo.add_worktree("feature");
|
||
|
||
let settings = setup_snapshot_settings(&repo);
|
||
let _guard = settings.bind_to_scope();
|
||
|
||
assert_cmd_snapshot!(make_snapshot_cmd_with_global_flags(
|
||
&repo,
|
||
"run",
|
||
&["one", "two three", "four"],
|
||
Some(&feature_path),
|
||
&["-y"],
|
||
));
|
||
}
|
||
|
||
/// Templates can treat `{{ args }}` as a sequence: indexing, iteration,
|
||
/// and `length` all work because `ShellArgs` reports as `ObjectRepr::Seq`.
|
||
#[rstest]
|
||
fn test_step_alias_args_sequence_access(mut repo: TestRepo) {
|
||
repo.write_project_config(
|
||
r#"
|
||
[aliases]
|
||
show = '''echo first={{ args[0] }}; echo count={{ args | length }}; echo each={% for a in args %} {{ a }}{% endfor %}'''
|
||
"#,
|
||
);
|
||
repo.commit("Add alias config");
|
||
let feature_path = repo.add_worktree("feature");
|
||
|
||
let settings = setup_snapshot_settings(&repo);
|
||
let _guard = settings.bind_to_scope();
|
||
|
||
assert_cmd_snapshot!(make_snapshot_cmd_with_global_flags(
|
||
&repo,
|
||
"show",
|
||
&["alpha", "beta gamma"],
|
||
Some(&feature_path),
|
||
&["-y"],
|
||
));
|
||
}
|
||
|
||
/// With no positionals, `{{ args }}` renders empty — the rest of the line
|
||
/// stays intact and no stray whitespace is introduced.
|
||
#[rstest]
|
||
fn test_step_alias_empty_args_renders_empty(mut repo: TestRepo) {
|
||
repo.write_project_config(
|
||
r#"
|
||
[aliases]
|
||
run = "echo [{{ args }}]"
|
||
"#,
|
||
);
|
||
repo.commit("Add alias config");
|
||
let feature_path = repo.add_worktree("feature");
|
||
|
||
let settings = setup_snapshot_settings(&repo);
|
||
let _guard = settings.bind_to_scope();
|
||
|
||
assert_cmd_snapshot!(make_snapshot_cmd_with_global_flags(
|
||
&repo,
|
||
"run",
|
||
&[],
|
||
Some(&feature_path),
|
||
&["-y"],
|
||
));
|
||
}
|
||
|
||
/// `wt s some-branch` with `s = "wt switch {{ args }}"` forwards the
|
||
/// positional into the expanded command. Verified via `wt config alias dry-run`
|
||
/// so the inner `wt switch` is not actually executed.
|
||
#[rstest]
|
||
fn test_top_level_alias_positional_expands_in_dry_run(mut repo: TestRepo) {
|
||
repo.write_project_config(
|
||
r#"
|
||
[aliases]
|
||
s = "wt switch {{ args }}"
|
||
"#,
|
||
);
|
||
repo.commit("Add alias config");
|
||
let feature_path = repo.add_worktree("feature");
|
||
|
||
let settings = setup_snapshot_settings(&repo);
|
||
let _guard = settings.bind_to_scope();
|
||
|
||
assert_cmd_snapshot!(make_snapshot_cmd(
|
||
&repo,
|
||
"config",
|
||
&["alias", "dry-run", "s", "--", "target-branch"],
|
||
Some(&feature_path),
|
||
));
|
||
}
|
||
|
||
/// Declining approval prevents alias execution
|
||
#[rstest]
|
||
fn test_alias_approval_decline(mut repo: TestRepo) {
|
||
repo.write_project_config(
|
||
r#"
|
||
[aliases]
|
||
deploy = "echo deploying"
|
||
"#,
|
||
);
|
||
repo.commit("Add alias config");
|
||
let feature_path = repo.add_worktree("feature");
|
||
|
||
let settings = setup_snapshot_settings(&repo);
|
||
let _guard = settings.bind_to_scope();
|
||
|
||
snapshot_alias_approval(
|
||
"alias_approval_decline",
|
||
&repo,
|
||
&["deploy"],
|
||
false,
|
||
Some(&feature_path),
|
||
);
|
||
}
|
||
|
||
/// Under `-v`, aliases print a table of resolved template variables before
|
||
/// the announcement — symmetric with the hook-invocation verbose block, but
|
||
/// with alias-scoped vars (`args` included, no `hook_*` keys).
|
||
#[rstest]
|
||
fn test_alias_verbose_prints_variable_table(mut repo: TestRepo) {
|
||
repo.write_test_config(
|
||
r#"
|
||
[aliases]
|
||
greet = "echo hello {{ args }}"
|
||
"#,
|
||
);
|
||
let feature_path = repo.add_worktree("feature");
|
||
|
||
let settings = setup_snapshot_settings(&repo);
|
||
settings.bind(|| {
|
||
let mut cmd = make_snapshot_cmd_with_global_flags(
|
||
&repo,
|
||
"greet",
|
||
&["world"],
|
||
Some(&feature_path),
|
||
&["-v"],
|
||
);
|
||
assert_cmd_snapshot!("alias_verbose_variable_table", cmd);
|
||
});
|
||
}
|
||
|
||
/// Multi-token args with whitespace render shell-escaped in the `-v` table,
|
||
/// matching what `{{ args }}` actually expands to on the line below.
|
||
#[rstest]
|
||
fn test_alias_verbose_renders_args_shell_escaped(mut repo: TestRepo) {
|
||
repo.write_test_config(
|
||
r#"
|
||
[aliases]
|
||
greet = "echo hello {{ args }}"
|
||
"#,
|
||
);
|
||
let feature_path = repo.add_worktree("feature");
|
||
|
||
let settings = setup_snapshot_settings(&repo);
|
||
settings.bind(|| {
|
||
let mut cmd = make_snapshot_cmd_with_global_flags(
|
||
&repo,
|
||
"greet",
|
||
&["foo", "bar baz"],
|
||
Some(&feature_path),
|
||
&["-v"],
|
||
);
|
||
assert_cmd_snapshot!("alias_verbose_args_shell_escaped", cmd);
|
||
});
|
||
}
|
||
|
||
/// Aliases keep `wt`'s inherited git-discovery context: `wt <alias>` is the
|
||
/// user's own top-level command, run where they invoked it, so the inherited
|
||
/// `GIT_DIR`/`GIT_WORK_TREE` pass through — unlike hooks and `wt step
|
||
/// for-each`, which `wt` relocates into worktrees it selects and therefore
|
||
/// scrubs (the keep side of the classification in
|
||
/// `scrub_git_discovery_env_vars`; issue #3373).
|
||
#[rstest]
|
||
#[cfg(unix)]
|
||
fn test_alias_keeps_inherited_git_discovery_vars(repo: TestRepo) {
|
||
repo.write_project_config(
|
||
r#"
|
||
[aliases]
|
||
record-git-env = "printf '[%s][%s]' \"$GIT_DIR\" \"$GIT_WORK_TREE\" > env_seen.txt"
|
||
"#,
|
||
);
|
||
repo.commit("Add alias config");
|
||
|
||
let git_dir = repo.root_path().join(".git");
|
||
let output = repo
|
||
.wt_command()
|
||
.args(["-y", "record-git-env"])
|
||
.env("GIT_DIR", &git_dir)
|
||
.env("GIT_WORK_TREE", repo.root_path())
|
||
.output()
|
||
.unwrap();
|
||
assert!(
|
||
output.status.success(),
|
||
"alias failed:\nstdout: {}\nstderr: {}",
|
||
String::from_utf8_lossy(&output.stdout),
|
||
String::from_utf8_lossy(&output.stderr)
|
||
);
|
||
|
||
let seen = std::fs::read_to_string(repo.root_path().join("env_seen.txt")).unwrap();
|
||
let expected = format!("[{}][{}]", git_dir.display(), repo.root_path().display());
|
||
assert_eq!(
|
||
seen, expected,
|
||
"alias should see wt's inherited git-discovery vars unchanged"
|
||
);
|
||
}
|