#![cfg(feature = "shell-integration-tests")]
use crate::common::{TestRepo, shell::shell_binary, wt_bin};
use std::process::Command;
use worktrunk::shell;
#[cfg(unix)]
use {
crate::common::{add_pty_filters, canonicalize, wait_for_file_content},
insta::assert_snapshot,
std::{fs, path::PathBuf, sync::LazyLock},
};
#[derive(Debug)]
struct ShellOutput {
combined: String,
exit_code: i32,
}
#[cfg(unix)]
static JOB_CONTROL_REGEX: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"\[\d+\][+-]?\s+(Done|\d+)").unwrap());
impl ShellOutput {
fn assert_no_directive_leaks(&self) {
assert!(
!self.combined.contains("__WORKTRUNK_CD__"),
"Output contains leaked __WORKTRUNK_CD__ directive:\n{}",
self.combined
);
assert!(
!self.combined.contains("__WORKTRUNK_EXEC__"),
"Output contains leaked __WORKTRUNK_EXEC__ directive:\n{}",
self.combined
);
}
#[cfg(unix)]
fn assert_no_job_control_messages(&self) {
assert!(
!JOB_CONTROL_REGEX.is_match(&self.combined),
"Output contains job control messages (e.g., '[1] 12345' or '[1]+ Done'):\n{}",
self.combined
);
}
#[cfg(unix)]
fn assert_success(&self) {
assert_eq!(
self.exit_code, 0,
"Expected exit code 0, got {}.\nOutput:\n{}",
self.exit_code, self.combined
);
}
}
#[cfg(unix)]
fn shell_wrapper_settings() -> insta::Settings {
let mut settings = insta::Settings::clone_current();
add_pty_filters(&mut settings);
settings
}
fn generate_wrapper(repo: &TestRepo, shell: &str) -> String {
let wt_bin = wt_bin();
let mut cmd = Command::new(&wt_bin);
cmd.arg("config").arg("shell").arg("init").arg(shell);
repo.configure_wt_cmd(&mut cmd);
let output = cmd.output().unwrap_or_else(|e| {
panic!(
"Failed to run wt config shell init {}: {} (binary: {})",
shell,
e,
wt_bin.display()
)
});
if !output.status.success() {
panic!(
"wt config shell init {} failed with exit code: {:?}\nOutput:\n{}",
shell,
output.status.code(),
String::from_utf8_lossy(&output.stderr)
);
}
String::from_utf8(output.stdout)
.unwrap_or_else(|_| panic!("wt config shell init {} produced invalid UTF-8", shell))
}
#[cfg(unix)]
fn generate_completions(_repo: &TestRepo, shell: &str) -> String {
match shell {
"fish" => {
r#"# worktrunk completions for fish - uses $WORKTRUNK_BIN to bypass shell wrapper
complete --keep-order --exclusive --command wt --arguments "(COMPLETE=fish \$WORKTRUNK_BIN -- (commandline --current-process --tokenize --cut-at-cursor) (commandline --current-token))"
"#.to_string()
}
_ => {
String::new()
}
}
}
fn quote_arg(arg: &str) -> String {
if arg.contains(' ') || arg.contains(';') || arg.contains('\'') {
shell_quote(arg)
} else {
arg.to_string()
}
}
fn shell_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', r"'\''"))
}
fn powershell_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', "''"))
}
fn wrapper_shell(shell_name: &str) -> shell::Shell {
match shell_name {
"bash" => shell::Shell::Bash,
"fish" => shell::Shell::Fish,
"nu" | "nushell" => shell::Shell::Nushell,
"zsh" => shell::Shell::Zsh,
"powershell" | "pwsh" => shell::Shell::PowerShell,
other => panic!("Unsupported shell wrapper test shell: {other}"),
}
}
fn wrapper_env_vars(shell: shell::Shell, repo: &TestRepo) -> Vec<(&'static str, String)> {
let quote = |value: &str| match shell {
shell::Shell::PowerShell => powershell_quote(value),
_ => shell_quote(value),
};
vec![
("WORKTRUNK_BIN", quote(&wt_bin().display().to_string())),
(
"WORKTRUNK_CONFIG_PATH",
quote(&repo.test_config_path().display().to_string()),
),
(
"WORKTRUNK_APPROVALS_PATH",
quote(&repo.test_approvals_path().display().to_string()),
),
(
"CLICOLOR_FORCE",
match shell {
shell::Shell::Nushell | shell::Shell::PowerShell => "'1'".to_string(),
_ => "1".to_string(),
},
),
]
}
fn append_shell_env_exports(script: &mut String, shell: shell::Shell, vars: &[(&str, String)]) {
if matches!(shell, shell::Shell::Zsh) {
script.push_str("autoload -Uz compinit && compinit -i 2>/dev/null\n");
}
for (key, value) in vars {
match shell {
shell::Shell::Fish => script.push_str(&format!("set -x {key} {value}\n")),
shell::Shell::Nushell => script.push_str(&format!("$env.{key} = {value}\n")),
shell::Shell::PowerShell => script.push_str(&format!("$env:{key} = {value}\n")),
shell::Shell::Bash | shell::Shell::Zsh => {
script.push_str(&format!("export {key}={value}\n"))
}
}
}
}
fn append_wrapper_setup(script: &mut String, shell_name: &str, repo: &TestRepo) {
let shell = wrapper_shell(shell_name);
let env_vars = wrapper_env_vars(shell, repo);
append_shell_env_exports(script, shell, &env_vars);
script.push_str(&generate_wrapper(repo, shell_name));
script.push('\n');
}
fn build_shell_script(shell: &str, repo: &TestRepo, subcommand: &str, args: &[&str]) -> String {
let mut script = String::new();
append_wrapper_setup(&mut script, shell, repo);
script.push_str("wt ");
script.push_str(subcommand);
for arg in args {
script.push(' ');
match shell {
"powershell" | "pwsh" => {
if arg.contains(' ') || arg.contains(';') || arg.contains('\'') || *arg == "--" {
script.push_str(&powershell_quote(arg));
} else {
script.push_str(arg);
}
}
_ => {
script.push_str("e_arg(arg));
}
}
}
script.push('\n');
match shell {
"fish" => {
format!("begin\n{}\nend 2>&1", script)
}
"nu" => {
script
}
"bash" => {
format!("exec 2>&1\n{}", script)
}
"powershell" | "pwsh" => {
format!("{}\nexit $LASTEXITCODE", script)
}
_ => {
format!("( {} ) 2>&1", script)
}
}
}
#[cfg(test)]
fn exec_in_pty_interactive(
shell: &str,
script: &str,
working_dir: &std::path::Path,
env_vars: &[(&str, &str)],
inputs: &[&str],
) -> (String, i32) {
use portable_pty::CommandBuilder;
use std::io::Write;
let pair = crate::common::open_pty();
let shell_binary = shell_binary(shell);
let mut cmd = CommandBuilder::new(shell_binary);
cmd.env_clear();
let home_dir = home::home_dir().unwrap().to_string_lossy().to_string();
cmd.env("HOME", &home_dir);
#[cfg(windows)]
{
cmd.env("USERPROFILE", &home_dir);
if let Ok(val) = std::env::var("SystemRoot") {
cmd.env("SystemRoot", &val);
cmd.env("windir", &val); }
if let Ok(val) = std::env::var("SystemDrive") {
cmd.env("SystemDrive", val);
}
if let Ok(val) = std::env::var("TEMP") {
cmd.env("TEMP", &val);
cmd.env("TMP", val);
}
if let Ok(val) = std::env::var("COMSPEC") {
cmd.env("COMSPEC", val);
}
if let Ok(val) = std::env::var("PSModulePath") {
cmd.env("PSModulePath", val);
}
}
#[cfg(unix)]
let default_path = "/usr/bin:/bin";
#[cfg(windows)]
let default_path = std::env::var("PATH").unwrap_or_default();
cmd.env(
"PATH",
std::env::var("PATH").unwrap_or_else(|_| default_path.to_string()),
);
cmd.env("USER", "testuser");
cmd.env("SHELL", shell_binary);
match shell {
"zsh" => {
cmd.env("ZDOTDIR", "/dev/null");
cmd.arg("-i");
cmd.arg("--no-rcs");
cmd.arg("-o");
cmd.arg("NO_GLOBAL_RCS");
cmd.arg("-o");
cmd.arg("NO_RCS");
cmd.arg("-c");
cmd.arg(script);
}
"bash" => {
cmd.arg("-i");
cmd.arg("-c");
cmd.arg(script);
}
"powershell" | "pwsh" => {
let temp_dir = std::env::temp_dir();
let script_path = temp_dir.join(format!("wt_test_{}.ps1", std::process::id()));
std::fs::write(&script_path, script).expect("Failed to write temp script");
cmd.arg("-NoProfile");
cmd.arg("-ExecutionPolicy");
cmd.arg("Bypass");
cmd.arg("-File");
cmd.arg(script_path.to_string_lossy().to_string());
}
"nu" => {
cmd.arg("--no-config-file");
cmd.arg("-c");
cmd.arg(script);
}
_ => {
cmd.arg("-c");
cmd.arg(script);
}
}
cmd.cwd(working_dir);
for (key, value) in env_vars {
cmd.env(key, value);
}
crate::common::pass_coverage_env_to_pty_cmd(&mut cmd);
let mut child = pair.slave.spawn_command(cmd).unwrap();
drop(pair.slave);
let reader = pair.master.try_clone_reader().unwrap();
let mut writer = pair.master.take_writer().unwrap();
for input in inputs {
writer.write_all(input.as_bytes()).unwrap();
writer.flush().unwrap();
}
let (buf, exit_code) =
crate::common::pty::read_pty_output(reader, writer, pair.master, &mut child);
let normalized = buf.replace("\r\n", "\n");
(normalized, exit_code)
}
#[cfg(all(test, unix))]
fn exec_bash_truly_interactive(
setup_script: &str,
final_cmd: &str,
working_dir: &std::path::Path,
env_vars: &[(&str, &str)],
) -> (String, i32) {
use portable_pty::CommandBuilder;
use std::io::{Read, Write};
use std::thread;
use std::time::Duration;
let tmp_dir = tempfile::tempdir().unwrap();
let script_path = tmp_dir.path().join("setup.sh");
fs::write(&script_path, setup_script).unwrap();
let pair = crate::common::open_pty();
let mut cmd = CommandBuilder::new("env");
cmd.arg("bash");
cmd.arg("--norc");
cmd.arg("--noprofile");
cmd.arg("-i");
cmd.env_clear();
cmd.env(
"HOME",
home::home_dir().unwrap().to_string_lossy().to_string(),
);
cmd.env(
"PATH",
std::env::var("PATH").unwrap_or_else(|_| "/usr/bin:/bin".to_string()),
);
cmd.env("USER", "testuser");
cmd.env("SHELL", "bash");
cmd.env("PS1", "$ ");
cmd.cwd(working_dir);
for (key, value) in env_vars {
cmd.env(key, value);
}
crate::common::pass_coverage_env_to_pty_cmd(&mut cmd);
let mut child = pair.slave.spawn_command(cmd).unwrap();
drop(pair.slave);
let reader = pair.master.try_clone_reader().unwrap();
let mut writer = pair.master.take_writer().unwrap();
thread::sleep(Duration::from_millis(200));
let commands = format!("source '{}'\n{}\n", script_path.display(), final_cmd);
writer.write_all(commands.as_bytes()).unwrap();
writer.flush().unwrap();
thread::sleep(Duration::from_millis(500));
writer.write_all(b"exit\n").unwrap();
writer.flush().unwrap();
drop(writer);
let reader_thread = thread::spawn(move || {
let mut reader = reader;
let mut buf = String::new();
reader.read_to_string(&mut buf).unwrap();
buf
});
let status = child.wait().unwrap();
let buf = reader_thread.join().unwrap();
let normalized = buf.replace("\r\n", "\n");
(normalized, status.exit_code() as i32)
}
fn exec_through_wrapper(
shell: &str,
repo: &TestRepo,
subcommand: &str,
args: &[&str],
) -> ShellOutput {
exec_through_wrapper_from(shell, repo, subcommand, args, repo.root_path())
}
fn exec_through_wrapper_from(
shell: &str,
repo: &TestRepo,
subcommand: &str,
args: &[&str],
working_dir: &std::path::Path,
) -> ShellOutput {
exec_through_wrapper_interactive(shell, repo, subcommand, args, working_dir, &[])
}
#[cfg(test)]
fn exec_through_wrapper_interactive(
shell: &str,
repo: &TestRepo,
subcommand: &str,
args: &[&str],
working_dir: &std::path::Path,
inputs: &[&str],
) -> ShellOutput {
exec_through_wrapper_with_env(shell, repo, subcommand, args, working_dir, inputs, &[])
}
#[cfg(test)]
fn exec_through_wrapper_with_env(
shell: &str,
repo: &TestRepo,
subcommand: &str,
args: &[&str],
working_dir: &std::path::Path,
inputs: &[&str],
extra_env: &[(&str, &str)],
) -> ShellOutput {
let script = build_shell_script(shell, repo, subcommand, args);
let config_path = repo.test_config_path().to_string_lossy().to_string();
let approvals_path = repo.test_approvals_path().to_string_lossy().to_string();
let mut env_vars = build_test_env_vars(&config_path, &approvals_path);
env_vars.push(("CLICOLOR_FORCE", "1"));
env_vars.extend(extra_env.iter().copied());
let (combined, exit_code) =
exec_in_pty_interactive(shell, &script, working_dir, &env_vars, inputs);
ShellOutput {
combined,
exit_code,
}
}
const STANDARD_TEST_ENV: &[(&str, &str)] = &[
("TERM", "xterm"),
("GIT_AUTHOR_NAME", "Test User"),
("GIT_AUTHOR_EMAIL", "test@example.com"),
("GIT_COMMITTER_NAME", "Test User"),
("GIT_COMMITTER_EMAIL", "test@example.com"),
("GIT_AUTHOR_DATE", "2025-01-01T00:00:00Z"),
("GIT_COMMITTER_DATE", "2025-01-01T00:00:00Z"),
("LANG", "C"),
("LC_ALL", "C"),
("WORKTRUNK_TEST_EPOCH", "1735776000"),
("WORKTRUNK_TEST_DELAYED_STREAM_MS", "-1"),
];
#[cfg(test)]
fn build_test_env_vars<'a>(
config_path: &'a str,
approvals_path: &'a str,
) -> Vec<(&'a str, &'a str)> {
let mut env_vars: Vec<(&str, &str)> = vec![
("WORKTRUNK_CONFIG_PATH", config_path),
("WORKTRUNK_APPROVALS_PATH", approvals_path),
];
env_vars.extend_from_slice(STANDARD_TEST_ENV);
env_vars
}
#[cfg(unix)]
mod unix_tests {
use super::*;
use crate::common::repo;
use rstest::rstest;
#[rstest]
#[case("bash")]
#[case("zsh")]
#[case("fish")]
#[case("nu")]
fn test_wrapper_handles_command_failure(#[case] shell: &str, mut repo: TestRepo) {
repo.add_worktree("existing");
let output = exec_through_wrapper(shell, &repo, "switch", &["--create", "existing"]);
assert_eq!(
output.exit_code, 1,
"{}: Command should fail with exit code 1",
shell
);
output.assert_no_directive_leaks();
assert!(
output.combined.contains("already exists"),
"{}: Error message should mention 'already exists'.\nOutput:\n{}",
shell,
output.combined
);
shell_wrapper_settings().bind(|| {
insta::allow_duplicates! {
assert_snapshot!("command_failure", &output.combined);
}
});
}
#[rstest]
#[case("bash")]
#[case("zsh")]
#[case("fish")]
#[case("nu")]
fn test_wrapper_switch_create(#[case] shell: &str, repo: TestRepo) {
let output = exec_through_wrapper(shell, &repo, "switch", &["--create", "feature"]);
assert_eq!(output.exit_code, 0, "{}: Command should succeed", shell);
output.assert_no_directive_leaks();
output.assert_no_job_control_messages();
assert!(
output.combined.contains("Created branch") && output.combined.contains("and worktree"),
"{}: Should show success message",
shell
);
shell_wrapper_settings().bind(|| {
insta::allow_duplicates! {
assert_snapshot!("switch_create", &output.combined);
}
});
}
#[rstest]
#[case("bash")]
#[case("zsh")]
#[case("fish")]
#[case("nu")]
fn test_wrapper_remove(#[case] shell: &str, mut repo: TestRepo) {
repo.add_worktree("to-remove");
let output = exec_through_wrapper(shell, &repo, "remove", &["to-remove"]);
assert_eq!(output.exit_code, 0, "{}: Command should succeed", shell);
output.assert_no_directive_leaks();
shell_wrapper_settings().bind(|| {
insta::allow_duplicates! {
assert_snapshot!("remove", &output.combined);
}
});
}
#[rstest]
#[case("bash")]
#[case("zsh")]
#[case("fish")]
#[case("nu")]
fn test_wrapper_step_for_each(#[case] shell: &str, mut repo: TestRepo) {
repo.remove_fixture_worktrees();
repo.commit("Initial commit");
repo.add_worktree("feature-a");
repo.add_worktree("feature-b");
let output = exec_through_wrapper(
shell,
&repo,
"step",
&["for-each", "--", "echo", "Branch: {{ branch }}"],
);
assert_eq!(output.exit_code, 0, "{}: Command should succeed", shell);
output.assert_no_directive_leaks();
output.assert_no_job_control_messages();
assert!(
output.combined.contains("Branch: main"),
"{}: Should show main branch output.\nOutput:\n{}",
shell,
output.combined
);
assert!(
output.combined.contains("Branch: feature-a"),
"{}: Should show feature-a branch output.\nOutput:\n{}",
shell,
output.combined
);
assert!(
output.combined.contains("Branch: feature-b"),
"{}: Should show feature-b branch output.\nOutput:\n{}",
shell,
output.combined
);
assert!(
output.combined.contains("Completed in 3 worktrees"),
"{}: Should show completion summary.\nOutput:\n{}",
shell,
output.combined
);
shell_wrapper_settings().bind(|| {
insta::allow_duplicates! {
assert_snapshot!("step_for_each", &output.combined);
}
});
}
#[rstest]
#[case("bash")]
#[case("zsh")]
#[case("fish")]
#[case("nu")]
fn test_wrapper_merge(#[case] shell: &str, mut repo: TestRepo) {
repo.write_test_config("");
repo.add_worktree("feature");
let output = exec_through_wrapper(shell, &repo, "merge", &["main"]);
assert_eq!(output.exit_code, 0, "{}: Command should succeed", shell);
output.assert_no_directive_leaks();
shell_wrapper_settings().bind(|| {
insta::allow_duplicates! {
assert_snapshot!("merge", &output.combined);
}
});
}
#[rstest]
#[case("bash")]
#[case("zsh")]
#[case("fish")]
#[case("nu")]
fn test_wrapper_switch_with_execute(#[case] shell: &str, repo: TestRepo) {
let output = exec_through_wrapper(
shell,
&repo,
"switch",
&[
"--create",
"test-exec",
"--execute",
"echo executed",
"--yes",
],
);
assert_eq!(output.exit_code, 0, "{}: Command should succeed", shell);
output.assert_no_directive_leaks();
assert!(
output.combined.contains("executed"),
"{}: Execute command output missing",
shell
);
shell_wrapper_settings().bind(|| {
insta::allow_duplicates! {
assert_snapshot!("switch_with_execute", &output.combined);
}
});
}
#[rstest]
#[case("bash")]
#[case("zsh")]
#[case("fish")]
#[case("nu")]
fn test_wrapper_execute_exit_code_propagation(#[case] shell: &str, repo: TestRepo) {
let output = exec_through_wrapper(
shell,
&repo,
"switch",
&[
"--create",
"test-exit-code",
"--execute",
"exit 42",
"--yes",
],
);
assert_eq!(
output.exit_code, 42,
"{}: Should propagate execute command's exit code (42), not wt's (0)",
shell
);
output.assert_no_directive_leaks();
assert!(
output.combined.contains("Created branch") && output.combined.contains("and worktree"),
"{}: Should show wt's success message even though execute command failed",
shell
);
}
#[rstest]
#[case("zsh")]
fn test_wrapper_switch_with_hooks(#[case] shell: &str, repo: TestRepo) {
let config_dir = repo.root_path().join(".config");
fs::create_dir_all(&config_dir).unwrap();
fs::write(
config_dir.join("wt.toml"),
r#"# Blocking commands that run before worktree is ready
pre-start = [
{install = "echo 'Installing dependencies...'"},
{build = "echo 'Building project...'"},
]
# Background commands that run in parallel
[post-start]
server = "echo 'Starting dev server on port 3000'"
watch = "echo 'Watching for file changes'"
"#,
)
.unwrap();
repo.commit("Add hooks");
repo.write_test_approvals(
r#"[projects."../origin"]
approved-commands = [
"echo 'Installing dependencies...'",
"echo 'Building project...'",
"echo 'Starting dev server on port 3000'",
"echo 'Watching for file changes'",
]
"#,
);
let output = exec_through_wrapper(shell, &repo, "switch", &["--create", "feature-hooks"]);
assert_eq!(output.exit_code, 0, "{}: Command should succeed", shell);
output.assert_no_directive_leaks();
shell_wrapper_settings().bind(|| {
assert_snapshot!(format!("switch_with_hooks_{}", shell), &output.combined);
});
}
#[rstest]
#[case("bash")]
#[case("zsh")]
fn test_wrapper_merge_with_pre_merge_success(#[case] shell: &str, mut repo: TestRepo) {
let config_dir = repo.root_path().join(".config");
fs::create_dir_all(&config_dir).unwrap();
fs::write(
config_dir.join("wt.toml"),
r#"pre-merge = [
{format = "echo '✓ Code formatting check passed'"},
{lint = "echo '✓ Linting passed - no warnings'"},
{test = "echo '✓ All 47 tests passed in 2.3s'"},
]
"#,
)
.unwrap();
repo.commit("Add pre-merge validation");
let feature_wt = repo.add_feature();
repo.write_test_config("");
repo.write_test_approvals(
r#"[projects."../origin"]
approved-commands = [
"echo '✓ Code formatting check passed'",
"echo '✓ Linting passed - no warnings'",
"echo '✓ All 47 tests passed in 2.3s'",
]
"#,
);
let output =
exec_through_wrapper_from(shell, &repo, "merge", &["main", "--yes"], &feature_wt);
assert_eq!(output.exit_code, 0, "{}: Merge should succeed", shell);
output.assert_no_directive_leaks();
shell_wrapper_settings().bind(|| {
assert_snapshot!(
format!("merge_with_pre_merge_success_{}", shell),
&output.combined
);
});
}
#[rstest]
#[case("bash")]
#[case("zsh")]
fn test_wrapper_merge_with_pre_merge_failure(#[case] shell: &str, mut repo: TestRepo) {
let config_dir = repo.root_path().join(".config");
fs::create_dir_all(&config_dir).unwrap();
fs::write(
config_dir.join("wt.toml"),
r#"pre-merge = [
{format = "echo '✓ Code formatting check passed'"},
{test = "echo '✗ Test suite failed: 3 tests failing' && exit 1"},
]
"#,
)
.unwrap();
repo.commit("Add failing pre-merge validation");
repo.write_test_config("");
let feature_wt = repo.add_worktree_with_commit(
"feature-fail",
"feature.txt",
"feature content",
"Add feature",
);
repo.write_test_approvals(
r#"[projects."../origin"]
approved-commands = [
"echo '✓ Code formatting check passed'",
"echo '✗ Test suite failed: 3 tests failing' && exit 1",
]
"#,
);
let output =
exec_through_wrapper_from(shell, &repo, "merge", &["main", "--yes"], &feature_wt);
output.assert_no_directive_leaks();
shell_wrapper_settings().bind(|| {
assert_snapshot!(
format!("merge_with_pre_merge_failure_{}", shell),
&output.combined
);
});
}
#[rstest]
#[case("bash")]
#[case("zsh")]
fn test_wrapper_merge_with_mixed_stdout_stderr(#[case] shell: &str, mut repo: TestRepo) {
let fixtures_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures");
let script_content = fs::read(fixtures_dir.join("mixed-output.sh")).unwrap();
let script_path = repo.root_path().join("mixed-output.sh");
fs::write(&script_path, &script_content).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755)).unwrap();
}
let config_dir = repo.root_path().join(".config");
fs::create_dir_all(&config_dir).unwrap();
fs::write(
config_dir.join("wt.toml"),
r#"pre-merge = [
{check1 = "./mixed-output.sh check1 3"},
{check2 = "./mixed-output.sh check2 3"},
]
"#,
)
.unwrap();
repo.commit("Add pre-merge validation with mixed output");
let feature_wt = repo.add_feature();
repo.write_test_config(r#"worktree-path = "../{{ repo }}.{{ branch }}""#);
repo.write_test_approvals(
r#"[projects."../origin"]
approved-commands = [
"./mixed-output.sh check1 3",
"./mixed-output.sh check2 3",
]
"#,
);
let output =
exec_through_wrapper_from(shell, &repo, "merge", &["main", "--yes"], &feature_wt);
assert_eq!(output.exit_code, 0, "{}: Merge should succeed", shell);
output.assert_no_directive_leaks();
shell_wrapper_settings().bind(|| {
assert_snapshot!(
format!("merge_with_mixed_stdout_stderr_{}", shell),
&output.combined
);
});
}
#[rstest]
fn test_switch_with_post_start_command_no_directive_leak(repo: TestRepo) {
let config_dir = repo.root_path().join(".config");
fs::create_dir_all(&config_dir).unwrap();
fs::write(
config_dir.join("wt.toml"),
r#"post-start = "echo 'test command executed'""#,
)
.unwrap();
repo.commit("Add post-start command");
repo.write_test_approvals(
r#"[projects."../origin"]
approved-commands = ["echo 'test command executed'"]
"#,
);
let output =
exec_through_wrapper("bash", &repo, "switch", &["--create", "feature-with-hooks"]);
output.assert_no_directive_leaks();
output.assert_no_job_control_messages();
output.assert_success();
shell_wrapper_settings().bind(|| assert_snapshot!(&output.combined));
}
#[rstest]
fn test_switch_with_execute_through_wrapper(repo: TestRepo) {
let output = exec_through_wrapper(
"bash",
&repo,
"switch",
&[
"--create",
"test-exec",
"--execute",
"echo executed",
"--yes",
],
);
output.assert_no_directive_leaks();
output.assert_success();
assert!(
output.combined.contains("executed"),
"Execute command output missing"
);
shell_wrapper_settings().bind(|| assert_snapshot!(&output.combined));
}
#[rstest]
fn test_bash_shell_integration_hint_suppressed(repo: TestRepo) {
let output = exec_through_wrapper("bash", &repo, "switch", &["--create", "bash-test"]);
assert!(
!output.combined.contains("To enable automatic cd"),
"Shell integration hint should not appear when running through wrapper. Output:\n{}",
output.combined
);
assert!(
output.combined.contains("Created branch") && output.combined.contains("and worktree"),
"Success message missing"
);
shell_wrapper_settings().bind(|| assert_snapshot!(&output.combined));
}
#[rstest]
fn test_readme_example_simple_switch(repo: TestRepo) {
let output = exec_through_wrapper("bash", &repo, "switch", &["--create", "fix-auth"]);
assert!(
!output.combined.contains("To enable automatic cd"),
"Shell integration hint should be suppressed"
);
shell_wrapper_settings().bind(|| assert_snapshot!(&output.combined));
}
#[rstest]
fn test_readme_example_switch_back(repo: TestRepo) {
exec_through_wrapper("bash", &repo, "switch", &["--create", "fix-auth"]);
exec_through_wrapper("bash", &repo, "switch", &["--create", "feature-api"]);
let fix_auth_path = repo.root_path().parent().unwrap().join("repo.fix-auth");
let output =
exec_through_wrapper_from("bash", &repo, "switch", &["feature-api"], &fix_auth_path);
assert!(
!output.combined.contains("To enable automatic cd"),
"Shell integration hint should be suppressed"
);
shell_wrapper_settings().bind(|| assert_snapshot!(&output.combined));
}
#[rstest]
fn test_readme_example_remove(repo: TestRepo) {
exec_through_wrapper("bash", &repo, "switch", &["--create", "fix-auth"]);
exec_through_wrapper("bash", &repo, "switch", &["--create", "feature-api"]);
let feature_api_path = repo.root_path().parent().unwrap().join("repo.feature-api");
let output = exec_through_wrapper_from("bash", &repo, "remove", &[], &feature_api_path);
assert!(
!output.combined.contains("To enable automatic cd"),
"Shell integration hint should be suppressed"
);
shell_wrapper_settings().bind(|| assert_snapshot!(&output.combined));
}
#[rstest]
fn test_wrapper_preserves_progress_messages(repo: TestRepo) {
let config_dir = repo.root_path().join(".config");
fs::create_dir_all(&config_dir).unwrap();
fs::write(
config_dir.join("wt.toml"),
r#"post-start = "echo 'background task'""#,
)
.unwrap();
repo.commit("Add post-start command");
repo.write_test_approvals(
r#"[projects."../origin"]
approved-commands = ["echo 'background task'"]
"#,
);
let output = exec_through_wrapper("bash", &repo, "switch", &["--create", "feature-bg"]);
output.assert_no_directive_leaks();
output.assert_success();
shell_wrapper_settings().bind(|| assert_snapshot!(&output.combined));
}
#[rstest]
fn test_fish_wrapper_preserves_progress_messages(repo: TestRepo) {
let config_dir = repo.root_path().join(".config");
fs::create_dir_all(&config_dir).unwrap();
fs::write(
config_dir.join("wt.toml"),
r#"post-start = "echo 'fish background task'""#,
)
.unwrap();
repo.commit("Add post-start command");
repo.write_test_approvals(
r#"[projects."../origin"]
approved-commands = ["echo 'fish background task'"]
"#,
);
let output = exec_through_wrapper("fish", &repo, "switch", &["--create", "fish-bg"]);
output.assert_no_directive_leaks();
output.assert_success();
shell_wrapper_settings().bind(|| assert_snapshot!(&output.combined));
}
#[rstest]
fn test_fish_multiline_command_execution(repo: TestRepo) {
let multiline_cmd = "echo 'line 1'; echo 'line 2'; echo 'line 3'";
let output = exec_through_wrapper(
"fish",
&repo,
"switch",
&[
"--create",
"fish-multiline",
"--execute",
multiline_cmd,
"--yes",
],
);
output.assert_no_directive_leaks();
output.assert_success();
assert!(output.combined.contains("line 1"), "First line missing");
assert!(output.combined.contains("line 2"), "Second line missing");
assert!(output.combined.contains("line 3"), "Third line missing");
shell_wrapper_settings().bind(|| assert_snapshot!(&output.combined));
}
#[rstest]
fn test_fish_wrapper_handles_empty_chunks(repo: TestRepo) {
let output = exec_through_wrapper("fish", &repo, "switch", &["--create", "fish-minimal"]);
output.assert_no_directive_leaks();
output.assert_success();
assert!(
output.combined.contains("Created branch") && output.combined.contains("and worktree"),
"Success message missing from minimal output"
);
shell_wrapper_settings().bind(|| assert_snapshot!(&output.combined));
}
#[rstest]
#[case("bash")]
#[case("zsh")]
#[case("fish")]
fn test_source_flag_forwards_errors(#[case] shell: &str, repo: TestRepo) {
use std::env;
let worktrunk_source = canonicalize(&env::current_dir().unwrap()).unwrap();
let mut script = String::new();
append_wrapper_setup(&mut script, shell, &repo);
script.push_str("wt --source foo\n");
let final_script = match shell {
"fish" => format!("begin\n{}\nend 2>&1", script),
_ => format!("( {} ) 2>&1", script),
};
let config_path = repo.test_config_path().to_string_lossy().to_string();
let approvals_path = repo.test_approvals_path().to_string_lossy().to_string();
let env_vars: Vec<(&str, &str)> = vec![
("CLICOLOR_FORCE", "1"),
("WORKTRUNK_CONFIG_PATH", &config_path),
("WORKTRUNK_APPROVALS_PATH", &approvals_path),
("TERM", "xterm"),
("GIT_AUTHOR_NAME", "Test User"),
("GIT_AUTHOR_EMAIL", "test@example.com"),
("GIT_COMMITTER_NAME", "Test User"),
("GIT_COMMITTER_EMAIL", "test@example.com"),
("GIT_AUTHOR_DATE", "2025-01-01T00:00:00Z"),
("GIT_COMMITTER_DATE", "2025-01-01T00:00:00Z"),
("LANG", "C"),
("LC_ALL", "C"),
("WORKTRUNK_TEST_EPOCH", "1735776000"),
];
let (combined, exit_code) =
exec_in_pty_interactive(shell, &final_script, &worktrunk_source, &env_vars, &[]);
let output = ShellOutput {
combined,
exit_code,
};
assert_ne!(output.exit_code, 0, "{}: Command should fail", shell);
assert!(
output.combined.contains("unrecognized subcommand"),
"{}: Should show clap's 'unrecognized subcommand' error.\nOutput:\n{}",
shell,
output.combined
);
assert!(
!output.combined.contains("Error: cargo build failed"),
"{}: Should not contain old generic error message",
shell
);
shell_wrapper_settings().bind(|| {
insta::allow_duplicates! {
assert_snapshot!("source_flag_error_passthrough", &output.combined);
}
});
}
#[rstest]
fn test_zsh_no_job_control_notifications(repo: TestRepo) {
let config_dir = repo.root_path().join(".config");
fs::create_dir_all(&config_dir).unwrap();
fs::write(
config_dir.join("wt.toml"),
r#"post-start = "echo 'background job'""#,
)
.unwrap();
repo.commit("Add post-start command");
repo.write_test_approvals(
r#"[projects."../origin"]
approved-commands = ["echo 'background job'"]
"#,
);
let output = exec_through_wrapper("zsh", &repo, "switch", &["--create", "zsh-job-test"]);
output.assert_success();
output.assert_no_directive_leaks();
assert!(
!output.combined.contains("[1]"),
"Zsh should suppress job control notifications with NO_MONITOR.\nOutput:\n{}",
output.combined
);
assert!(
!output.combined.contains("+ done"),
"Zsh should suppress job completion notifications.\nOutput:\n{}",
output.combined
);
}
#[rstest]
fn test_bash_job_control_suppression(repo: TestRepo) {
let config_dir = repo.root_path().join(".config");
fs::create_dir_all(&config_dir).unwrap();
fs::write(
config_dir.join("wt.toml"),
r#"post-start = "echo 'bash background'""#,
)
.unwrap();
repo.commit("Add post-start command");
repo.write_test_approvals(
r#"[projects."../origin"]
approved-commands = ["echo 'bash background'"]
"#,
);
let mut setup_script = String::new();
append_wrapper_setup(&mut setup_script, "bash", &repo);
let config_path = repo.test_config_path().to_string_lossy().to_string();
let approvals_path = repo.test_approvals_path().to_string_lossy().to_string();
let env_vars: Vec<(&str, &str)> = vec![
("CLICOLOR_FORCE", "1"),
("WORKTRUNK_CONFIG_PATH", &config_path),
("WORKTRUNK_APPROVALS_PATH", &approvals_path),
("TERM", "xterm"),
("GIT_AUTHOR_NAME", "Test User"),
("GIT_AUTHOR_EMAIL", "test@example.com"),
("GIT_COMMITTER_NAME", "Test User"),
("GIT_COMMITTER_EMAIL", "test@example.com"),
];
let (output, exit_code) = exec_bash_truly_interactive(
&setup_script,
"wt switch --create bash-job-test",
repo.root_path(),
&env_vars,
);
assert_eq!(exit_code, 0, "Command should succeed.\nOutput:\n{}", output);
assert!(
output.contains("Created branch") && output.contains("and worktree"),
"Should show success message.\nOutput:\n{}",
output
);
assert!(
!JOB_CONTROL_REGEX.is_match(&output),
"Output contains job control messages (e.g., '[1] 12345' or '[1]+ Done'):\n{}",
output
);
}
#[rstest]
fn test_bash_completions_registered(repo: TestRepo) {
let marker_file = repo.root_path().join(".completions_test_marker");
let marker_path = marker_file.to_string_lossy().to_string();
let marker_quoted = shell_quote(&marker_path);
let mut script = String::new();
append_wrapper_setup(&mut script, "bash", &repo);
script.push_str(&format!(
r#"
# Check if wt completion is registered and write result to marker file
if complete -p wt 2>/dev/null; then
echo "__COMPLETION_REGISTERED__" > {}
else
echo "__NO_COMPLETION__" > {}
fi
"#,
marker_quoted, marker_quoted,
));
let final_script = format!("( {} ) 2>&1", script);
let config_path = repo.test_config_path().to_string_lossy().to_string();
let approvals_path = repo.test_approvals_path().to_string_lossy().to_string();
let env_vars: Vec<(&str, &str)> = vec![
("WORKTRUNK_CONFIG_PATH", &config_path),
("WORKTRUNK_APPROVALS_PATH", &approvals_path),
("TERM", "xterm"),
];
let (_combined, exit_code) =
exec_in_pty_interactive("bash", &final_script, repo.root_path(), &env_vars, &[]);
assert_eq!(exit_code, 0);
wait_for_file_content(&marker_file);
let result = std::fs::read_to_string(&marker_file).unwrap();
assert!(
result.contains("__COMPLETION_REGISTERED__"),
"Bash completions should be registered after sourcing wrapper.\nMarker file content:\n{}",
result
);
}
#[rstest]
fn test_fish_completions_registered(repo: TestRepo) {
let completions_script = generate_completions(&repo, "fish");
let marker_file = repo.root_path().join(".completions_test_marker");
let marker_path = marker_file.to_string_lossy().to_string();
let marker_quoted = shell_quote(&marker_path);
let mut script = String::new();
append_wrapper_setup(&mut script, "fish", &repo);
script.push_str(&completions_script);
script.push_str(&format!(
r#"
# Check if wt completions are registered and write result to marker file
if complete -c wt 2>/dev/null | grep -q .
echo "__COMPLETION_REGISTERED__" > {}
else
echo "__NO_COMPLETION__" > {}
end
"#,
marker_quoted, marker_quoted,
));
let final_script = format!("begin\n{}\nend 2>&1", script);
let config_path = repo.test_config_path().to_string_lossy().to_string();
let approvals_path = repo.test_approvals_path().to_string_lossy().to_string();
let env_vars: Vec<(&str, &str)> = vec![
("WORKTRUNK_CONFIG_PATH", &config_path),
("WORKTRUNK_APPROVALS_PATH", &approvals_path),
("TERM", "xterm"),
];
let (_combined, exit_code) =
exec_in_pty_interactive("fish", &final_script, repo.root_path(), &env_vars, &[]);
assert_eq!(exit_code, 0);
wait_for_file_content(&marker_file);
let result = std::fs::read_to_string(&marker_file).unwrap();
assert!(
result.contains("__COMPLETION_REGISTERED__"),
"Fish completions should be registered after sourcing wrapper.\nMarker file content:\n{}",
result
);
}
#[rstest]
fn test_zsh_wrapper_function_registered(repo: TestRepo) {
let wt_bin = wt_bin();
let wrapper_script = generate_wrapper(&repo, "zsh");
let marker_file = repo.root_path().join(".wrapper_test_marker");
let marker_path = marker_file.to_string_lossy().to_string();
let wt_bin_quoted = shell_quote(&wt_bin.display().to_string());
let config_quoted = shell_quote(&repo.test_config_path().display().to_string());
let approvals_quoted = shell_quote(&repo.test_approvals_path().display().to_string());
let marker_quoted = shell_quote(&marker_path);
let script = format!(
r#"
export WORKTRUNK_BIN={wt_bin}
export WORKTRUNK_CONFIG_PATH={config}
export WORKTRUNK_APPROVALS_PATH={approvals}
{wrapper}
# Check if wt wrapper function is defined and write result to marker file
if (( $+functions[wt] )); then
echo "__WRAPPER_REGISTERED__" > {marker}
else
echo "__NO_WRAPPER__" > {marker}
fi
"#,
wt_bin = wt_bin_quoted,
config = config_quoted,
approvals = approvals_quoted,
wrapper = wrapper_script,
marker = marker_quoted,
);
let final_script = format!("( {} ) 2>&1", script);
let config_path = repo.test_config_path().to_string_lossy().to_string();
let approvals_path = repo.test_approvals_path().to_string_lossy().to_string();
let env_vars: Vec<(&str, &str)> = vec![
("WORKTRUNK_CONFIG_PATH", &config_path),
("WORKTRUNK_APPROVALS_PATH", &approvals_path),
("TERM", "xterm"),
("ZDOTDIR", "/dev/null"),
];
let (_combined, exit_code) =
exec_in_pty_interactive("zsh", &final_script, repo.root_path(), &env_vars, &[]);
assert_eq!(exit_code, 0);
wait_for_file_content(&marker_file);
let result = std::fs::read_to_string(&marker_file).unwrap();
assert!(
result.contains("__WRAPPER_REGISTERED__"),
"Zsh wrapper function should be registered after sourcing.\nMarker file content:\n{}",
result
);
}
#[rstest]
#[case("bash")]
#[case("zsh")]
#[case("fish")]
#[case("nu")]
fn test_branch_name_with_slashes(#[case] shell: &str, repo: TestRepo) {
let output =
exec_through_wrapper(shell, &repo, "switch", &["--create", "feature/test-branch"]);
assert_eq!(output.exit_code, 0, "{}: Command should succeed", shell);
output.assert_no_directive_leaks();
assert!(
output.combined.contains("Created branch") && output.combined.contains("and worktree"),
"{}: Should create worktree for branch with slashes",
shell
);
}
#[rstest]
#[case("bash")]
#[case("zsh")]
#[case("fish")]
#[case("nu")]
fn test_branch_name_with_dashes_underscores(#[case] shell: &str, repo: TestRepo) {
let output = exec_through_wrapper(shell, &repo, "switch", &["--create", "fix-bug_123"]);
assert_eq!(output.exit_code, 0, "{}: Command should succeed", shell);
output.assert_no_directive_leaks();
assert!(
output.combined.contains("Created branch") && output.combined.contains("and worktree"),
"{}: Should create worktree for branch with dashes/underscores",
shell
);
}
#[rstest]
#[case("bash")]
#[case("zsh")]
#[case("fish")]
fn test_worktrunk_bin_fallback(#[case] shell: &str, repo: TestRepo) {
let wt_bin = wt_bin();
let wrapper_script = generate_wrapper(&repo, shell);
let wt_bin_quoted = shell_quote(&wt_bin.display().to_string());
let config_quoted = shell_quote(&repo.test_config_path().display().to_string());
let approvals_quoted = shell_quote(&repo.test_approvals_path().display().to_string());
let script = match shell {
"zsh" => format!(
r#"
autoload -Uz compinit && compinit -i 2>/dev/null
# Clear PATH to ensure wt is not found via PATH
export PATH="/usr/bin:/bin"
export WORKTRUNK_BIN={}
export WORKTRUNK_CONFIG_PATH={}
export WORKTRUNK_APPROVALS_PATH={}
export CLICOLOR_FORCE=1
{}
wt switch --create fallback-test
echo "__PWD__ $PWD"
"#,
wt_bin_quoted, config_quoted, approvals_quoted, wrapper_script
),
"fish" => format!(
r#"
# Clear PATH to ensure wt is not found via PATH
set -x PATH /usr/bin /bin
set -x WORKTRUNK_BIN {}
set -x WORKTRUNK_CONFIG_PATH {}
set -x WORKTRUNK_APPROVALS_PATH {}
set -x CLICOLOR_FORCE 1
{}
wt switch --create fallback-test
echo "__PWD__ $PWD"
"#,
wt_bin_quoted, config_quoted, approvals_quoted, wrapper_script
),
_ => format!(
r#"
# Clear PATH to ensure wt is not found via PATH
export PATH="/usr/bin:/bin"
export WORKTRUNK_BIN={}
export WORKTRUNK_CONFIG_PATH={}
export WORKTRUNK_APPROVALS_PATH={}
export CLICOLOR_FORCE=1
{}
wt switch --create fallback-test
echo "__PWD__ $PWD"
"#,
wt_bin_quoted, config_quoted, approvals_quoted, wrapper_script
),
};
let final_script = match shell {
"fish" => format!("begin\n{}\nend 2>&1", script),
_ => format!("( {} ) 2>&1", script),
};
let config_path = repo.test_config_path().to_string_lossy().to_string();
let approvals_path = repo.test_approvals_path().to_string_lossy().to_string();
let env_vars = build_test_env_vars(&config_path, &approvals_path);
let (combined, exit_code) =
exec_in_pty_interactive(shell, &final_script, repo.root_path(), &env_vars, &[]);
let output = ShellOutput {
combined,
exit_code,
};
assert_eq!(
output.exit_code, 0,
"{}: Command should succeed with WORKTRUNK_BIN fallback",
shell
);
output.assert_no_directive_leaks();
assert!(
output.combined.contains("Created branch") && output.combined.contains("and worktree"),
"{}: Should create worktree using WORKTRUNK_BIN fallback.\nOutput:\n{}",
shell,
output.combined
);
assert!(
output.combined.contains("fallback-test"),
"{}: Should be in the new worktree directory.\nOutput:\n{}",
shell,
output.combined
);
}
#[rstest]
#[case("fish")]
fn test_fish_binary_not_found_clear_error(#[case] shell: &str, repo: TestRepo) {
let wrapper_script = generate_wrapper(&repo, shell);
let marker_file = repo.root_path().join(".test-exit-code-marker");
let script = format!(
r#"
# Clear PATH to ensure wt is not found via PATH
set -x PATH /usr/bin /bin
# Explicitly unset WORKTRUNK_BIN to ensure it's not set
set -e WORKTRUNK_BIN
set -x CLICOLOR_FORCE 1
{wrapper_script}
wt --version
set -l wt_exit_status $status
# Write exit code to marker file (reliable even when PTY output is empty)
echo $wt_exit_status > {marker_file}
"#,
wrapper_script = wrapper_script,
marker_file = marker_file.display()
);
let final_script = format!("begin\n{}\nend 2>&1", script);
let config_path = repo.test_config_path().to_string_lossy().to_string();
let approvals_path = repo.test_approvals_path().to_string_lossy().to_string();
let env_vars = build_test_env_vars(&config_path, &approvals_path);
let (combined, exit_code) =
exec_in_pty_interactive(shell, &final_script, repo.root_path(), &env_vars, &[]);
let output = ShellOutput {
combined,
exit_code,
};
assert!(
marker_file.exists(),
"Fish wrapper did not complete (marker file not created).\n\
Exit code: {}\nOutput:\n{}",
output.exit_code,
output.combined
);
let marker_content = fs::read_to_string(&marker_file).unwrap_or_default();
let marker_exit_code: i32 = marker_content.trim().parse().unwrap_or(-1);
assert_eq!(
marker_exit_code, 127,
"Fish wrapper should return exit code 127 when binary is missing.\n\
Marker file content: {:?}\nPTY exit code: {}\nOutput:\n{}",
marker_content, output.exit_code, output.combined
);
if !output.combined.is_empty() {
assert!(
output.combined.contains("wt: command not found"),
"Fish wrapper should show 'wt: command not found' when binary is missing.\nOutput:\n{}",
output.combined
);
}
}
#[rstest]
#[case("fish")]
fn test_fish_wrapper_binary_not_found_no_infinite_loop(#[case] shell: &str, repo: TestRepo) {
let init = shell::ShellInit::with_prefix(shell::Shell::Fish, "wt".to_string());
let wrapper_content = init.generate_fish_wrapper().unwrap();
let marker_file = repo.root_path().join(".test-completed-marker");
let script = format!(
r#"
# Clear PATH to ensure wt is not found
set -x PATH /usr/bin /bin
set -x CLICOLOR_FORCE 1
{wrapper_content}
wt --version
set -l wt_exit_status $status
# Write marker file to prove script completed (didn't infinite loop)
echo $wt_exit_status > {marker_file}
exit $wt_exit_status
"#,
wrapper_content = wrapper_content,
marker_file = marker_file.display()
);
let final_script = format!("begin\n{}\nend 2>&1", script);
let config_path = repo.test_config_path().to_string_lossy().to_string();
let approvals_path = repo.test_approvals_path().to_string_lossy().to_string();
let env_vars = build_test_env_vars(&config_path, &approvals_path);
let (combined, exit_code) =
exec_in_pty_interactive(shell, &final_script, repo.root_path(), &env_vars, &[]);
assert!(
marker_file.exists(),
"Fish wrapper infinite looped (marker file not created).\n\
Exit code: {}\nOutput:\n{}",
exit_code,
combined
);
let marker_content = fs::read_to_string(&marker_file).unwrap_or_default();
let marker_exit_code: i32 = marker_content.trim().parse().unwrap_or(-1);
assert_eq!(
marker_exit_code, 127,
"Fish wrapper should return exit code 127 when binary is missing.\n\
Marker file content: {:?}\nPTY exit code: {}\nOutput:\n{}",
marker_content, exit_code, combined
);
if !combined.is_empty() {
let function_call_count = combined.matches("in function 'wt'").count();
assert!(
function_call_count <= 1,
"Fish wrapper shows signs of infinite loop ({} recursive calls).\nOutput:\n{}",
function_call_count,
combined
);
}
}
#[rstest]
#[case("bash")]
#[case("zsh")]
#[case("fish")]
#[case("nu")]
fn test_shell_completes_cleanly(#[case] shell: &str, repo: TestRepo) {
let config_dir = repo.root_path().join(".config");
fs::create_dir_all(&config_dir).unwrap();
fs::write(
config_dir.join("wt.toml"),
r#"post-start = "echo 'cleanup test'""#,
)
.unwrap();
repo.commit("Add post-start command");
repo.write_test_approvals(
r#"[projects."../origin"]
approved-commands = ["echo 'cleanup test'"]
"#,
);
let output = exec_through_wrapper(shell, &repo, "switch", &["--create", "cleanup-test"]);
assert_eq!(
output.exit_code, 0,
"{}: Command should complete cleanly",
shell
);
output.assert_no_directive_leaks();
assert!(
output.combined.contains("Created branch") && output.combined.contains("and worktree"),
"{}: Should complete successfully",
shell
);
}
#[rstest]
fn test_readme_example_hooks_pre_merge(mut repo: TestRepo) {
let config_dir = repo.root_path().join(".config");
fs::create_dir_all(&config_dir).unwrap();
let bin_dir = repo.root_path().join(".bin");
fs::create_dir_all(&bin_dir).unwrap();
let pytest_script = r#"#!/bin/sh
cat << 'EOF'
============================= test session starts ==============================
collected 3 items
tests/test_auth.py::test_login_success PASSED [ 33%]
tests/test_auth.py::test_login_invalid_password PASSED [ 66%]
tests/test_auth.py::test_token_validation PASSED [100%]
============================== 3 passed in 0.8s ===============================
EOF
exit 0
"#;
fs::write(bin_dir.join("pytest"), pytest_script).unwrap();
let ruff_script = r#"#!/bin/sh
if [ "$1" = "check" ]; then
echo ""
echo "All checks passed!"
echo ""
exit 0
else
echo "ruff: unknown command '$1'"
exit 1
fi
"#;
fs::write(bin_dir.join("ruff"), ruff_script).unwrap();
let llm_script = r#"#!/bin/sh
cat > /dev/null
cat << 'EOF'
feat(api): Add user authentication endpoints
Implement login and token refresh endpoints with JWT validation.
Includes comprehensive test coverage and input validation.
EOF
"#;
fs::write(bin_dir.join("llm"), llm_script).unwrap();
let uv_script = r#"#!/bin/sh
if [ "$1" = "run" ] && [ "$2" = "pytest" ]; then
exec pytest
elif [ "$1" = "run" ] && [ "$2" = "ruff" ]; then
shift 2
exec ruff "$@"
else
echo "uv: unknown command '$1 $2'"
exit 1
fi
"#;
fs::write(bin_dir.join("uv"), uv_script).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
for script in &["pytest", "ruff", "llm", "uv"] {
let mut perms = fs::metadata(bin_dir.join(script)).unwrap().permissions();
perms.set_mode(0o755);
fs::set_permissions(bin_dir.join(script), perms).unwrap();
}
}
let config_content = r#"
pre-merge = [
{"test" = "uv run pytest"},
{"lint" = "uv run ruff check"},
]
"#;
fs::write(config_dir.join("wt.toml"), config_content).unwrap();
repo.run_git(&["add", ".config/wt.toml", ".bin"]);
repo.run_git(&["commit", "-m", "Add pre-merge hooks"]);
let feature_wt = repo.add_worktree("feature-auth");
fs::create_dir_all(feature_wt.join("api")).unwrap();
let auth_py_v1 = r#"# Authentication API endpoints
from typing import Dict, Optional
import jwt
from datetime import datetime, timedelta, timezone
def login(username: str, password: str) -> Optional[Dict]:
"""Authenticate user and return JWT token."""
# Validate credentials (stub)
if not username or not password:
return None
# Generate JWT token
payload = {
'sub': username,
'exp': datetime.now(timezone.utc) + timedelta(hours=1)
}
token = jwt.encode(payload, 'secret', algorithm='HS256')
return {'token': token, 'expires_in': 3600}
"#;
std::fs::write(feature_wt.join("api/auth.py"), auth_py_v1).unwrap();
repo.run_git_in(&feature_wt, &["add", "api/auth.py"]);
repo.run_git_in(&feature_wt, &["commit", "-m", "Add login endpoint"]);
fs::create_dir_all(feature_wt.join("tests")).unwrap();
let test_auth_py = r#"# Authentication endpoint tests
import pytest
from api.auth import login
def test_login_success():
result = login('user', 'pass')
assert result and 'token' in result
def test_login_invalid_password():
result = login('user', '')
assert result is None
def test_token_validation():
assert login('valid_user', 'valid_pass')['expires_in'] == 3600
"#;
std::fs::write(feature_wt.join("tests/test_auth.py"), test_auth_py).unwrap();
repo.run_git_in(&feature_wt, &["add", "tests/test_auth.py"]);
repo.run_git_in(&feature_wt, &["commit", "-m", "Add authentication tests"]);
let auth_py_v2 = r#"# Authentication API endpoints
from typing import Dict, Optional
import jwt
from datetime import datetime, timedelta, timezone
def login(username: str, password: str) -> Optional[Dict]:
"""Authenticate user and return JWT token."""
# Validate credentials (stub)
if not username or not password:
return None
# Generate JWT token
payload = {
'sub': username,
'exp': datetime.now(timezone.utc) + timedelta(hours=1)
}
token = jwt.encode(payload, 'secret', algorithm='HS256')
return {'token': token, 'expires_in': 3600}
def refresh_token(token: str) -> Optional[Dict]:
"""Refresh an existing JWT token."""
try:
payload = jwt.decode(token, 'secret', algorithms=['HS256'])
new_payload = {
'sub': payload['sub'],
'exp': datetime.now(timezone.utc) + timedelta(hours=1)
}
new_token = jwt.encode(new_payload, 'secret', algorithm='HS256')
return {'token': new_token, 'expires_in': 3600}
except jwt.InvalidTokenError:
return None
"#;
std::fs::write(feature_wt.join("api/auth.py"), auth_py_v2).unwrap();
repo.run_git_in(&feature_wt, &["add", "api/auth.py"]);
repo.run_git_in(&feature_wt, &["commit", "-m", "Add validation"]);
let llm_path = bin_dir.join("llm");
let worktrunk_config = format!(
r#"worktree-path = "../repo.{{{{ branch }}}}"
[commit.generation]
command = "{}"
"#,
llm_path.display()
);
repo.write_test_config(&worktrunk_config);
let path_with_bin = format!(
"{}:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin",
bin_dir.display()
);
let output = exec_through_wrapper_with_env(
"bash",
&repo,
"merge",
&["main", "--yes"],
&feature_wt,
&[],
&[("PATH", &path_with_bin)],
);
output.assert_success();
shell_wrapper_settings().bind(|| assert_snapshot!(&output.combined));
}
#[rstest]
fn test_readme_example_hooks_pre_start(repo: TestRepo) {
let config_dir = repo.root_path().join(".config");
fs::create_dir_all(&config_dir).unwrap();
let bin_dir = repo.root_path().join(".bin");
fs::create_dir_all(&bin_dir).unwrap();
let uv_script = r#"#!/bin/sh
if [ "$1" = "sync" ]; then
echo ""
echo " Resolved 24 packages in 145ms"
echo " Installed 24 packages in 1.2s"
exit 0
elif [ "$1" = "run" ] && [ "$2" = "dev" ]; then
echo ""
echo " Starting dev server on http://localhost:3000..."
exit 0
else
echo "uv: unknown command '$1 $2'"
exit 1
fi
"#;
fs::write(bin_dir.join("uv"), uv_script).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(bin_dir.join("uv")).unwrap().permissions();
perms.set_mode(0o755);
fs::set_permissions(bin_dir.join("uv"), perms).unwrap();
}
let config_content = r#"
[pre-start]
"install" = "uv sync"
[post-start]
"dev" = "uv run dev"
"#;
fs::write(config_dir.join("wt.toml"), config_content).unwrap();
repo.run_git(&["add", ".config/wt.toml", ".bin"]);
repo.run_git(&["commit", "-m", "Add project hooks"]);
let path_with_bin = format!(
"{}:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin",
bin_dir.display()
);
let output = exec_through_wrapper_with_env(
"bash",
&repo,
"switch",
&["--create", "feature-x", "--yes"],
repo.root_path(),
&[],
&[("PATH", &path_with_bin)],
);
output.assert_success();
shell_wrapper_settings().bind(|| assert_snapshot!(&output.combined));
}
#[rstest]
fn test_readme_example_approval_prompt(repo: TestRepo) {
use portable_pty::CommandBuilder;
use std::io::{Read, Write};
repo.run_git(&["remote", "remove", "origin"]);
repo.write_project_config(
r#"pre-start = [
{install = "echo 'Installing dependencies...'"},
{build = "echo 'Building project...'"},
{test = "echo 'Running tests...'"},
]
"#,
);
repo.commit("Add config");
let pair = crate::common::open_pty();
let cargo_bin = wt_bin();
let mut cmd = CommandBuilder::new(cargo_bin);
cmd.arg("switch");
cmd.arg("--create");
cmd.arg("test-approval");
cmd.cwd(repo.root_path());
cmd.env_clear();
cmd.env(
"HOME",
home::home_dir().unwrap().to_string_lossy().to_string(),
);
cmd.env(
"PATH",
std::env::var("PATH").unwrap_or_else(|_| "/usr/bin:/bin".to_string()),
);
for (key, value) in repo.test_env_vars() {
cmd.env(key, value);
}
crate::common::pass_coverage_env_to_pty_cmd(&mut cmd);
let mut child = pair.slave.spawn_command(cmd).unwrap();
drop(pair.slave);
let mut reader = pair.master.try_clone_reader().unwrap();
let mut writer = pair.master.take_writer().unwrap();
writer.write_all(b"n\n").unwrap();
writer.flush().unwrap();
drop(writer);
let mut buf = String::new();
reader.read_to_string(&mut buf).unwrap();
child.wait().unwrap();
let ansi_regex = regex::Regex::new(r"\x1b\[[0-9;]*m").unwrap();
let output = ansi_regex
.replace_all(&buf, "")
.replace("\r\n", "\n")
.to_string();
let ctrl_d_regex = regex::Regex::new(r"\^D\x08+").unwrap();
let output = ctrl_d_regex.replace_all(&output, "").to_string();
let tmpdir_regex = regex::Regex::new(
r#"(?:/private)?/var/folders/[^/]+/[^/]+/T/\.tmp[^\s/'\x1b\)]+|/tmp/\.tmp[^\s/'\x1b\)]+"#,
)
.unwrap();
let output = tmpdir_regex.replace_all(&output, "[TMPDIR]").to_string();
let collapse_regex = regex::Regex::new(r"\[TMPDIR](?:/?\[TMPDIR])+").unwrap();
let output = collapse_regex.replace_all(&output, "[TMPDIR]").to_string();
assert!(
output.contains("needs approval"),
"Should show approval prompt"
);
assert!(
output.contains("[y/N]"),
"Should show the interactive prompt"
);
let prompt_start = output.find("🟡").unwrap_or(0);
let prompt_end = output.find("[y/N]").map(|i| i + "[y/N]".len());
let prompt_only = if let Some(end) = prompt_end {
output[prompt_start..end].trim().to_string()
} else {
output[prompt_start..].trim().to_string()
};
assert_snapshot!(prompt_only);
}
#[rstest]
fn test_bash_completion_produces_correct_output(repo: TestRepo) {
use std::io::Read;
let wt_bin = wt_bin();
let wt_bin_dir = wt_bin.parent().unwrap();
let output = std::process::Command::new(&wt_bin)
.args(["config", "shell", "init", "bash"])
.output()
.unwrap();
let wrapper_script = String::from_utf8_lossy(&output.stdout);
let script = format!(
r#"
# Do NOT set WORKTRUNK_BIN - simulate real user scenario
export CLICOLOR_FORCE=1
# Source the shell integration
{wrapper_script}
# Step 1: Verify SOME completion is registered for 'wt' (black-box check)
if ! complete -p wt >/dev/null 2>&1; then
echo "FAILURE: No completion registered for wt"
exit 1
fi
echo "SUCCESS: Completion is registered for wt"
# Step 2: Get the completion function name (whatever it's called)
completion_func=$(complete -p wt 2>/dev/null | sed -n 's/.*-F \([^ ]*\).*/\1/p')
if [[ -z "$completion_func" ]]; then
echo "FAILURE: Could not extract completion function name"
exit 1
fi
echo "SUCCESS: Found completion function: $completion_func"
# Step 3: Set up completion environment and call the function
COMP_WORDS=(wt "")
COMP_CWORD=1
COMP_TYPE=9 # TAB
COMP_LINE="wt "
COMP_POINT=${{#COMP_LINE}}
# Call the completion function (this triggers lazy loading if needed)
"$completion_func" wt "" wt 2>&1
# Step 4: Verify we got completions (black-box: just check we got results)
if [[ "${{#COMPREPLY[@]}}" -eq 0 ]]; then
echo "FAILURE: No completions returned"
echo "COMPREPLY is empty"
exit 1
fi
echo "SUCCESS: Got ${{#COMPREPLY[@]}} completions"
# Print completions
for c in "${{COMPREPLY[@]}}"; do
echo " - $c"
done
# Step 5: Verify expected subcommands are present
if printf '%s\n' "${{COMPREPLY[@]}}" | grep -q '^config$'; then
echo "VERIFIED: 'config' is in completions"
else
echo "FAILURE: 'config' not found in completions"
exit 1
fi
if printf '%s\n' "${{COMPREPLY[@]}}" | grep -q '^list$'; then
echo "VERIFIED: 'list' is in completions"
else
echo "FAILURE: 'list' not found in completions"
exit 1
fi
"#,
wrapper_script = wrapper_script
);
let pair = crate::common::open_pty();
let mut cmd = crate::common::shell_command("bash", Some(wt_bin_dir));
cmd.arg("-c");
cmd.arg(&script);
cmd.cwd(repo.root_path());
let mut child = pair.slave.spawn_command(cmd).unwrap();
drop(pair.slave);
let mut reader = pair.master.try_clone_reader().unwrap();
let mut buf = String::new();
reader.read_to_string(&mut buf).unwrap();
let status = child.wait().unwrap();
let output = buf.replace("\r\n", "\n");
assert!(
!output.contains("command not found"),
"Completion output should NOT be executed as a command.\n\
This indicates the COMPLETE mode fix is not working.\n\
Output: {}",
output
);
assert!(
output.contains("SUCCESS: Completion is registered"),
"Completion should be registered.\nOutput: {}\nExit: {}",
output,
status.exit_code()
);
assert!(
output.contains("SUCCESS: Got") && output.contains("completions"),
"Completion should return results.\nOutput: {}\nExit: {}",
output,
status.exit_code()
);
assert!(
output.contains("VERIFIED: 'config' is in completions"),
"Expected 'config' subcommand in completions.\nOutput: {}",
output
);
assert!(
output.contains("VERIFIED: 'list' is in completions"),
"Expected 'list' subcommand in completions.\nOutput: {}",
output
);
}
#[rstest]
fn test_zsh_completion_produces_correct_output(repo: TestRepo) {
use std::io::Read;
let wt_bin = wt_bin();
let wt_bin_dir = wt_bin.parent().unwrap();
let output = std::process::Command::new(&wt_bin)
.args(["config", "shell", "init", "zsh"])
.output()
.unwrap();
let wrapper_script = String::from_utf8_lossy(&output.stdout);
let script = format!(
r#"
autoload -Uz compinit && compinit -i 2>/dev/null
# Do NOT set WORKTRUNK_BIN - simulate real user scenario
export CLICOLOR_FORCE=1
# Source the shell integration
{wrapper_script}
# Step 1: Verify SOME completion is registered for 'wt' (black-box check)
# In zsh, $_comps[wt] contains the completion function if registered
if (( $+_comps[wt] )); then
echo "SUCCESS: Completion is registered for wt"
else
echo "FAILURE: No completion registered for wt"
exit 1
fi
# Step 2: Test that COMPLETE mode works through our shell function
# This is the key test - the wt() shell function must detect COMPLETE
# and call the binary directly, not through wt_exec which would eval the output
words=(wt "")
CURRENT=2
_CLAP_COMPLETE_INDEX=1
_CLAP_IFS=$'\n'
# Call wt with COMPLETE=zsh - this goes through our shell function
completions=$(COMPLETE=zsh _CLAP_IFS="$_CLAP_IFS" _CLAP_COMPLETE_INDEX="$_CLAP_COMPLETE_INDEX" wt -- "${{words[@]}}" 2>&1)
if [[ -z "$completions" ]]; then
echo "FAILURE: No completions returned"
exit 1
fi
echo "SUCCESS: Got completions"
# Print first few completions
echo "$completions" | head -10 | while read line; do
echo " - $line"
done
# Step 3: Verify expected subcommands are present
if echo "$completions" | grep -q 'config'; then
echo "VERIFIED: 'config' is in completions"
else
echo "FAILURE: 'config' not found in completions"
exit 1
fi
"#,
wrapper_script = wrapper_script
);
let pair = crate::common::open_pty();
let mut cmd = crate::common::shell_command("zsh", Some(wt_bin_dir));
cmd.arg("-c");
cmd.arg(&script);
cmd.cwd(repo.root_path());
let mut child = pair.slave.spawn_command(cmd).unwrap();
drop(pair.slave);
let mut reader = pair.master.try_clone_reader().unwrap();
let mut buf = String::new();
reader.read_to_string(&mut buf).unwrap();
let status = child.wait().unwrap();
let output = buf.replace("\r\n", "\n");
assert!(
!output.contains("command not found"),
"Completion output should NOT be executed as a command.\n\
Output: {}",
output
);
assert!(
output.contains("SUCCESS: Completion is registered"),
"Completion should be registered.\nOutput: {}\nExit: {}",
output,
status.exit_code()
);
assert!(
output.contains("SUCCESS: Got completions"),
"Completion should return results.\nOutput: {}\nExit: {}",
output,
status.exit_code()
);
assert!(
output.contains("VERIFIED: 'config' is in completions"),
"Expected 'config' subcommand in completions.\nOutput: {}",
output
);
}
fn completion_test_path(wt_bin: &std::path::Path) -> (tempfile::TempDir, String) {
let dir = tempfile::tempdir().unwrap();
std::os::unix::fs::symlink(wt_bin, dir.path().join("wt")).unwrap();
let path = format!("{}:/usr/bin:/bin:/usr/sbin:/sbin", dir.path().display());
(dir, path)
}
#[test]
fn test_zsh_completion_subcommands() {
let wt_bin = wt_bin();
let init = std::process::Command::new(&wt_bin)
.args(["config", "shell", "init", "zsh"])
.output()
.unwrap();
let shell_integration = String::from_utf8_lossy(&init.stdout);
let script = format!(
r#"
autoload -Uz compinit && compinit -i 2>/dev/null
_describe() {{
while [[ "$1" == -* ]]; do shift; done; shift
for arr in "$@"; do for item in "${{(@P)arr}}"; do echo "${{item%%:*}}"; done; done
}}
{shell_integration}
words=(wt "") CURRENT=2
_wt_lazy_complete
"#
);
let (_dir, clean_path) = completion_test_path(&wt_bin);
let output = std::process::Command::new("zsh")
.args(["-f", "-c"])
.arg(&script)
.env("PATH", &clean_path)
.output()
.unwrap();
assert_snapshot!(String::from_utf8_lossy(&output.stdout));
}
#[test]
fn test_bash_completion_subcommands() {
let wt_bin = wt_bin();
let init = std::process::Command::new(&wt_bin)
.args(["config", "shell", "init", "bash"])
.output()
.unwrap();
let shell_integration = String::from_utf8_lossy(&init.stdout);
let script = format!(
r#"
{shell_integration}
COMP_WORDS=(wt "") COMP_CWORD=1
_wt_lazy_complete
for c in "${{COMPREPLY[@]}}"; do echo "${{c%% *}}"; done
"#
);
let (_dir, clean_path) = completion_test_path(&wt_bin);
let output = std::process::Command::new("bash")
.args(["--noprofile", "--norc", "-c"])
.arg(&script)
.env("PATH", &clean_path)
.output()
.unwrap();
assert_snapshot!(String::from_utf8_lossy(&output.stdout));
}
#[test]
fn test_fish_completion_subcommands() {
let wt_bin = wt_bin();
let (_dir, clean_path) = completion_test_path(&wt_bin);
let output = std::process::Command::new(&wt_bin)
.args(["--", "wt", ""])
.env("COMPLETE", "fish")
.env("_CLAP_COMPLETE_INDEX", "1")
.env("PATH", &clean_path)
.output()
.unwrap();
let completions: String = String::from_utf8_lossy(&output.stdout)
.lines()
.map(|line| line.split('\t').next().unwrap_or(line))
.collect::<Vec<_>>()
.join("\n");
assert_snapshot!(completions);
}
#[test]
fn test_nushell_completion_subcommands() {
let wt_bin = wt_bin();
let (_dir, clean_path) = completion_test_path(&wt_bin);
let output = std::process::Command::new(&wt_bin)
.args(["--", "wt", ""])
.env("COMPLETE", "nu")
.env("PATH", &clean_path)
.output()
.unwrap();
let completions: String = String::from_utf8_lossy(&output.stdout)
.lines()
.map(|line| line.split('\t').next().unwrap_or(line))
.collect::<Vec<_>>()
.join("\n");
assert_snapshot!(completions);
}
#[test]
fn test_fish_completion_forwards_to_external() {
use std::os::unix::fs::PermissionsExt;
let wt_bin = wt_bin();
let (dir, clean_path) = completion_test_path(&wt_bin);
let script = dir.path().join("wt-fake");
std::fs::write(
&script,
"#!/bin/sh\nprintf '%s\\n%s\\n' '--fake-flag' \"idx:${_CLAP_COMPLETE_INDEX}\"\n",
)
.unwrap();
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
let output = std::process::Command::new(&wt_bin)
.args(["--", "wt", "fake", "--"])
.env("COMPLETE", "fish")
.env("_CLAP_COMPLETE_INDEX", "2")
.env("PATH", &clean_path)
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("--fake-flag"),
"Forwarded completions should include external script output: {stdout}"
);
assert!(
stdout.contains("idx:1"),
"_CLAP_COMPLETE_INDEX should be adjusted from 2 to 1: {stdout}"
);
}
#[test]
fn test_bash_completion_forwards_to_external() {
use std::os::unix::fs::PermissionsExt;
let wt_bin = wt_bin();
let (dir, clean_path) = completion_test_path(&wt_bin);
let ext_script = dir.path().join("wt-fake");
std::fs::write(
&ext_script,
"#!/bin/sh\nprintf '%s\\n%s\\n' '--fake-opt' '--fake-verbose'\n",
)
.unwrap();
std::fs::set_permissions(&ext_script, std::fs::Permissions::from_mode(0o755)).unwrap();
let init = std::process::Command::new(&wt_bin)
.args(["config", "shell", "init", "bash"])
.output()
.unwrap();
let shell_integration = String::from_utf8_lossy(&init.stdout);
let script = format!(
r#"
{shell_integration}
COMP_WORDS=(wt fake --) COMP_CWORD=2
_wt_lazy_complete
for c in "${{COMPREPLY[@]}}"; do echo "${{c%% *}}"; done
"#
);
let output = std::process::Command::new("bash")
.args(["--noprofile", "--norc", "-c"])
.arg(&script)
.env("PATH", &clean_path)
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("--fake-opt"),
"Bash completion should forward to wt-fake and show its output: {stdout}"
);
assert!(
stdout.contains("--fake-verbose"),
"Bash completion should include all external completions: {stdout}"
);
}
#[rstest]
#[case("bash")]
#[case("zsh")]
#[case("fish")]
fn test_wrapper_help_redirect_captures_all_output(#[case] shell: &str, repo: TestRepo) {
use std::io::Read;
let wt_bin = wt_bin();
let wt_bin_dir = wt_bin.parent().unwrap();
let tmp_dir = tempfile::tempdir().unwrap();
let redirect_file = tmp_dir.path().join("output.log");
let redirect_path = redirect_file.display().to_string();
let output = std::process::Command::new(&wt_bin)
.args(["config", "shell", "init", shell])
.output()
.unwrap();
let wrapper_script = String::from_utf8_lossy(&output.stdout);
let script = match shell {
"fish" => format!(
r#"
set -x WORKTRUNK_BIN '{wt_bin}'
set -x CLICOLOR_FORCE 1
# Source the shell integration
{wrapper_script}
# Run help with redirect - ALL output should go to file
wt --help &>'{redirect_path}'
# Marker to show script completed
echo "SCRIPT_COMPLETED"
"#,
wt_bin = wt_bin.display(),
wrapper_script = wrapper_script,
redirect_path = redirect_path,
),
"zsh" => format!(
r#"
autoload -Uz compinit && compinit -i 2>/dev/null
export WORKTRUNK_BIN='{wt_bin}'
export CLICOLOR_FORCE=1
# Source the shell integration
{wrapper_script}
# Run help with redirect - ALL output should go to file
wt --help &>'{redirect_path}'
# Marker to show script completed
echo "SCRIPT_COMPLETED"
"#,
wt_bin = wt_bin.display(),
wrapper_script = wrapper_script,
redirect_path = redirect_path,
),
_ => format!(
r#"
export WORKTRUNK_BIN='{wt_bin}'
export CLICOLOR_FORCE=1
# Source the shell integration
{wrapper_script}
# Run help with redirect - ALL output should go to file
wt --help &>'{redirect_path}'
# Marker to show script completed
echo "SCRIPT_COMPLETED"
"#,
wt_bin = wt_bin.display(),
wrapper_script = wrapper_script,
redirect_path = redirect_path,
),
};
let pair = crate::common::open_pty();
let mut cmd = crate::common::shell_command(shell, Some(wt_bin_dir));
cmd.arg("-c");
cmd.arg(&script);
cmd.cwd(repo.root_path());
let mut child = pair.slave.spawn_command(cmd).unwrap();
drop(pair.slave);
let mut reader = pair.master.try_clone_reader().unwrap();
let mut buf = String::new();
reader.read_to_string(&mut buf).unwrap();
let _status = child.wait().unwrap();
let terminal_output = buf.replace("\r\n", "\n");
let file_content = fs::read_to_string(&redirect_file).unwrap_or_else(|e| {
panic!(
"{}: Failed to read redirect file: {}\nTerminal output:\n{}",
shell, e, terminal_output
)
});
assert!(
terminal_output.contains("SCRIPT_COMPLETED"),
"{}: Script did not complete successfully.\nTerminal output:\n{}",
shell,
terminal_output
);
assert!(
file_content.contains("Usage:") || file_content.contains("wt"),
"{}: Help content should be in the redirect file.\nFile content:\n{}\nTerminal output:\n{}",
shell,
file_content,
terminal_output
);
let help_markers = ["Usage:", "Commands:", "Options:", "USAGE:"];
for marker in help_markers {
if terminal_output.contains(marker) {
panic!(
"{}: Help output leaked to terminal (found '{}').\n\
This indicates stderr redirection is not working correctly.\n\
Terminal output:\n{}\n\
File content:\n{}",
shell, marker, terminal_output, file_content
);
}
}
}
#[rstest]
#[case("bash")]
#[case("zsh")]
#[case("fish")]
fn test_wrapper_help_interactive_uses_pager(#[case] shell: &str, repo: TestRepo) {
use std::io::Read;
let wt_bin = wt_bin();
let wt_bin_dir = wt_bin.parent().unwrap();
let tmp_dir = tempfile::tempdir().unwrap();
let marker_file = tmp_dir.path().join("pager_invoked.marker");
let pager_script = tmp_dir.path().join("test_pager.sh");
fs::write(
&pager_script,
format!("#!/bin/sh\ntouch '{}'\ncat\n", marker_file.display()),
)
.unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&pager_script, fs::Permissions::from_mode(0o755)).unwrap();
}
let output = std::process::Command::new(&wt_bin)
.args(["config", "shell", "init", shell])
.output()
.unwrap();
let wrapper_script = String::from_utf8_lossy(&output.stdout);
let script = match shell {
"fish" => format!(
r#"
set -x WORKTRUNK_BIN '{wt_bin}'
set -x GIT_PAGER '{pager_script}'
set -x CLICOLOR_FORCE 1
# Source the shell integration
{wrapper_script}
# Run help interactively (no redirect) - pager should be invoked
wt --help
# Marker to show script completed
echo "SCRIPT_COMPLETED"
"#,
wt_bin = wt_bin.display(),
pager_script = pager_script.display(),
wrapper_script = wrapper_script,
),
"zsh" => format!(
r#"
autoload -Uz compinit && compinit -i 2>/dev/null
export WORKTRUNK_BIN='{wt_bin}'
export GIT_PAGER='{pager_script}'
export CLICOLOR_FORCE=1
# Source the shell integration
{wrapper_script}
# Run help interactively (no redirect) - pager should be invoked
wt --help
# Marker to show script completed
echo "SCRIPT_COMPLETED"
"#,
wt_bin = wt_bin.display(),
pager_script = pager_script.display(),
wrapper_script = wrapper_script,
),
_ => format!(
r#"
export WORKTRUNK_BIN='{wt_bin}'
export GIT_PAGER='{pager_script}'
export CLICOLOR_FORCE=1
# Source the shell integration
{wrapper_script}
# Run help interactively (no redirect) - pager should be invoked
wt --help
# Marker to show script completed
echo "SCRIPT_COMPLETED"
"#,
wt_bin = wt_bin.display(),
pager_script = pager_script.display(),
wrapper_script = wrapper_script,
),
};
let pair = crate::common::open_pty();
let mut cmd = crate::common::shell_command(shell, Some(wt_bin_dir));
cmd.arg("-c");
cmd.arg(&script);
cmd.cwd(repo.root_path());
let mut child = pair.slave.spawn_command(cmd).unwrap();
drop(pair.slave);
let mut reader = pair.master.try_clone_reader().unwrap();
let mut buf = String::new();
reader.read_to_string(&mut buf).unwrap();
let _status = child.wait().unwrap();
let terminal_output = buf.replace("\r\n", "\n");
assert!(
terminal_output.contains("SCRIPT_COMPLETED"),
"{}: Script did not complete successfully.\nTerminal output:\n{}",
shell,
terminal_output
);
assert!(
marker_file.exists(),
"{}: Pager was NOT invoked for interactive help.\n\
The marker file was not created, indicating show_help_in_pager() \n\
skipped the pager even though stderr is a TTY.\n\
Terminal output:\n{}",
shell,
terminal_output
);
}
}
#[cfg(windows)]
mod windows_tests {
use super::*;
use crate::common::repo;
use rstest::rstest;
#[test]
fn test_conpty_basic_cmd() {
use crate::common::pty::{build_pty_command, exec_cmd_in_pty};
let tmp = tempfile::tempdir().unwrap();
let cmd = build_pty_command(
"cmd.exe",
&["/C", "echo CONPTY_WORKS"],
tmp.path(),
&[],
None,
);
let (output, exit_code) = exec_cmd_in_pty(cmd, "");
eprintln!("ConPTY test output: {:?}", output);
eprintln!("ConPTY test exit code: {}", exit_code);
assert!(
output.contains("CONPTY_WORKS") || exit_code == 0,
"ConPTY basic test should work. Output: {}, Exit: {}",
output,
exit_code
);
}
#[test]
fn test_conpty_wt_version() {
use crate::common::pty::{build_pty_command, exec_cmd_in_pty};
use crate::common::wt_bin;
let wt_bin = wt_bin();
let tmp = tempfile::tempdir().unwrap();
let cmd = build_pty_command(
wt_bin.to_str().unwrap(),
&["--version"],
tmp.path(),
&[],
None,
);
let (output, exit_code) = exec_cmd_in_pty(cmd, "");
eprintln!("wt --version output: {:?}", output);
eprintln!("wt --version exit code: {}", exit_code);
assert_eq!(
exit_code, 0,
"wt --version should succeed. Output: {}",
output
);
assert!(
output.contains("wt") || output.contains("worktrunk"),
"Should contain version info. Output: {}",
output
);
}
#[test]
fn test_conpty_powershell_basic() {
let pair = crate::common::open_pty();
let shell_binary = shell_binary("powershell");
let mut cmd = portable_pty::CommandBuilder::new(shell_binary);
cmd.env_clear();
if let Ok(val) = std::env::var("SystemRoot") {
cmd.env("SystemRoot", &val);
}
if let Ok(val) = std::env::var("TEMP") {
cmd.env("TEMP", &val);
}
cmd.env("PATH", std::env::var("PATH").unwrap_or_default());
cmd.arg("-NoProfile");
cmd.arg("-Command");
cmd.arg("Write-Host 'POWERSHELL_WORKS'; exit 42");
let tmp = tempfile::tempdir().unwrap();
cmd.cwd(tmp.path());
crate::common::pass_coverage_env_to_pty_cmd(&mut cmd);
let mut child = pair.slave.spawn_command(cmd).unwrap();
drop(pair.slave);
let reader = pair.master.try_clone_reader().unwrap();
let writer = pair.master.take_writer().unwrap();
let (output, exit_code) =
crate::common::pty::read_pty_output(reader, writer, pair.master, &mut child);
let normalized = output.replace("\r\n", "\n");
eprintln!("PowerShell basic test output: {:?}", normalized);
eprintln!("PowerShell basic test exit code: {}", exit_code);
assert_eq!(exit_code, 42, "Should get exit code from PowerShell");
assert!(
normalized.contains("POWERSHELL_WORKS"),
"Should capture PowerShell output. Got: {}",
normalized
);
}
#[rstest]
#[ignore = "ConPTY output not captured when cargo test redirects stdout"]
fn test_powershell_switch_create(repo: TestRepo) {
let script = build_shell_script("powershell", &repo, "switch", &["--create", "feature"]);
eprintln!("=== PowerShell Script Being Executed ===");
eprintln!("{}", script);
eprintln!("=== End Script ===");
eprintln!("Script length: {} bytes", script.len());
let output = exec_through_wrapper("powershell", &repo, "switch", &["--create", "feature"]);
eprintln!("=== PowerShell Output ===");
eprintln!("{:?}", output.combined);
eprintln!("Exit code: {}", output.exit_code);
eprintln!("=== End Output ===");
assert_eq!(output.exit_code, 0, "PowerShell: Command should succeed");
output.assert_no_directive_leaks();
assert!(
output.combined.contains("Created branch") && output.combined.contains("and worktree"),
"PowerShell: Should show success message.\nOutput:\n{}",
output.combined
);
}
#[rstest]
#[ignore = "ConPTY output not captured when cargo test redirects stdout"]
fn test_powershell_command_failure(mut repo: TestRepo) {
repo.add_worktree("existing");
let output = exec_through_wrapper("powershell", &repo, "switch", &["--create", "existing"]);
assert_eq!(
output.exit_code, 1,
"PowerShell: Command should fail with exit code 1"
);
output.assert_no_directive_leaks();
assert!(
output.combined.contains("already exists"),
"PowerShell: Error message should mention 'already exists'.\nOutput:\n{}",
output.combined
);
}
#[rstest]
#[ignore = "ConPTY output not captured when cargo test redirects stdout"]
fn test_powershell_remove(mut repo: TestRepo) {
repo.add_worktree("to-remove");
let output = exec_through_wrapper("powershell", &repo, "remove", &["to-remove"]);
assert_eq!(output.exit_code, 0, "PowerShell: Command should succeed");
output.assert_no_directive_leaks();
}
#[rstest]
#[ignore = "ConPTY output not captured when cargo test redirects stdout"]
fn test_powershell_list(repo: TestRepo) {
let output = exec_through_wrapper("powershell", &repo, "list", &[]);
assert_eq!(output.exit_code, 0, "PowerShell: Command should succeed");
output.assert_no_directive_leaks();
assert!(
output.combined.contains("main"),
"PowerShell: Should show main branch.\nOutput:\n{}",
output.combined
);
}
#[rstest]
#[ignore = "ConPTY output not captured when cargo test redirects stdout"]
fn test_powershell_execute_exit_code_propagation(repo: TestRepo) {
let output = exec_through_wrapper(
"powershell",
&repo,
"switch",
&["--create", "feature", "--execute", "exit 42"],
);
assert_eq!(
output.exit_code, 42,
"PowerShell: Should propagate exit code 42 from --execute.\nOutput:\n{}",
output.combined
);
output.assert_no_directive_leaks();
}
#[rstest]
#[ignore = "ConPTY output not captured when cargo test redirects stdout"]
fn test_powershell_branch_with_slashes(repo: TestRepo) {
let output =
exec_through_wrapper("powershell", &repo, "switch", &["--create", "feature/auth"]);
assert_eq!(
output.exit_code, 0,
"PowerShell: Should handle branch names with slashes.\nOutput:\n{}",
output.combined
);
output.assert_no_directive_leaks();
assert!(
output.combined.contains("feature/auth") || output.combined.contains("feature-auth"),
"PowerShell: Should show branch name.\nOutput:\n{}",
output.combined
);
}
#[rstest]
#[ignore = "ConPTY output not captured when cargo test redirects stdout"]
fn test_powershell_branch_with_dashes_underscores(repo: TestRepo) {
let output = exec_through_wrapper(
"powershell",
&repo,
"switch",
&["--create", "my-feature_branch"],
);
assert_eq!(
output.exit_code, 0,
"PowerShell: Should handle branch names with dashes/underscores.\nOutput:\n{}",
output.combined
);
output.assert_no_directive_leaks();
}
#[rstest]
#[ignore = "ConPTY output not captured when cargo test redirects stdout"]
fn test_powershell_wrapper_function_registered(repo: TestRepo) {
let wt_bin = wt_bin();
let wrapper_script = generate_wrapper(&repo, "powershell");
let script = format!(
"$env:WORKTRUNK_BIN = {}\n\
$env:WORKTRUNK_CONFIG_PATH = {}\n\
$env:WORKTRUNK_APPROVALS_PATH = {}\n\
{}\n\
if (Get-Command wt -CommandType Function -ErrorAction SilentlyContinue) {{\n\
Write-Host 'WRAPPER_REGISTERED'\n\
exit 0\n\
}} else {{\n\
Write-Host 'WRAPPER_NOT_REGISTERED'\n\
exit 1\n\
}}",
powershell_quote(&wt_bin.display().to_string()),
powershell_quote(&repo.test_config_path().display().to_string()),
powershell_quote(&repo.test_approvals_path().display().to_string()),
wrapper_script
);
let config_path = repo.test_config_path().to_string_lossy().to_string();
let approvals_path = repo.test_approvals_path().to_string_lossy().to_string();
let env_vars = build_test_env_vars(&config_path, &approvals_path);
let (combined, exit_code) =
exec_in_pty_interactive("powershell", &script, repo.root_path(), &env_vars, &[]);
assert_eq!(
exit_code, 0,
"PowerShell: Wrapper function should be registered.\nOutput:\n{}",
combined
);
assert!(
combined.contains("WRAPPER_REGISTERED"),
"PowerShell: Should confirm wrapper is registered.\nOutput:\n{}",
combined
);
}
#[rstest]
#[ignore = "ConPTY output not captured when cargo test redirects stdout"]
fn test_powershell_completion_registered(repo: TestRepo) {
let wt_bin = wt_bin();
let wrapper_script = generate_wrapper(&repo, "powershell");
let script = format!(
"$env:WORKTRUNK_BIN = {}\n\
$env:WORKTRUNK_CONFIG_PATH = {}\n\
$env:WORKTRUNK_APPROVALS_PATH = {}\n\
{}\n\
$completers = Get-ArgumentCompleter -Native\n\
if ($completers | Where-Object {{ $_.CommandName -eq 'wt' }}) {{\n\
Write-Host 'COMPLETION_REGISTERED'\n\
exit 0\n\
}} else {{\n\
Write-Host 'COMPLETION_NOT_REGISTERED'\n\
exit 1\n\
}}",
powershell_quote(&wt_bin.display().to_string()),
powershell_quote(&repo.test_config_path().display().to_string()),
powershell_quote(&repo.test_approvals_path().display().to_string()),
wrapper_script
);
let config_path = repo.test_config_path().to_string_lossy().to_string();
let approvals_path = repo.test_approvals_path().to_string_lossy().to_string();
let env_vars = build_test_env_vars(&config_path, &approvals_path);
let (combined, exit_code) =
exec_in_pty_interactive("powershell", &script, repo.root_path(), &env_vars, &[]);
assert!(
exit_code == 0 || combined.contains("COMPLETION"),
"PowerShell: Should attempt completion registration.\nOutput:\n{}",
combined
);
}
#[rstest]
#[ignore = "ConPTY output not captured when cargo test redirects stdout"]
fn test_powershell_step_for_each(mut repo: TestRepo) {
repo.add_worktree("feature-1");
repo.add_worktree("feature-2");
let output = exec_through_wrapper(
"powershell",
&repo,
"step",
&["for-each", "--", "git", "status", "--short"],
);
assert_eq!(
output.exit_code, 0,
"PowerShell: step for-each should succeed.\nOutput:\n{}",
output.combined
);
output.assert_no_directive_leaks();
}
#[rstest]
#[ignore = "ConPTY output not captured when cargo test redirects stdout"]
fn test_powershell_help_output(repo: TestRepo) {
let output = exec_through_wrapper("powershell", &repo, "--help", &[]);
assert_eq!(
output.exit_code, 0,
"PowerShell: --help should succeed.\nOutput:\n{}",
output.combined
);
output.assert_no_directive_leaks();
assert!(
output.combined.contains("Usage:") || output.combined.contains("USAGE:"),
"PowerShell: Should show usage in help.\nOutput:\n{}",
output.combined
);
}
#[rstest]
#[ignore = "ConPTY output not captured when cargo test redirects stdout"]
fn test_powershell_worktrunk_bin_env(repo: TestRepo) {
let wt_bin = wt_bin();
let wrapper_script = generate_wrapper(&repo, "powershell");
let script = format!(
"$env:WORKTRUNK_BIN = {}\n\
$env:WORKTRUNK_CONFIG_PATH = {}\n\
$env:WORKTRUNK_APPROVALS_PATH = {}\n\
{}\n\
Write-Host \"BIN_PATH: $env:WORKTRUNK_BIN\"",
powershell_quote(&wt_bin.display().to_string()),
powershell_quote(&repo.test_config_path().display().to_string()),
powershell_quote(&repo.test_approvals_path().display().to_string()),
wrapper_script
);
let config_path = repo.test_config_path().to_string_lossy().to_string();
let approvals_path = repo.test_approvals_path().to_string_lossy().to_string();
let env_vars = build_test_env_vars(&config_path, &approvals_path);
let (combined, exit_code) =
exec_in_pty_interactive("powershell", &script, repo.root_path(), &env_vars, &[]);
assert_eq!(
exit_code, 0,
"PowerShell: Script should succeed.\nOutput:\n{}",
combined
);
assert!(
combined.contains("BIN_PATH:"),
"PowerShell: Should show bin path.\nOutput:\n{}",
combined
);
}
#[rstest]
#[ignore = "ConPTY output not captured when cargo test redirects stdout"]
fn test_powershell_merge(mut repo: TestRepo) {
repo.add_worktree("feature");
let output = exec_through_wrapper("powershell", &repo, "merge", &["main"]);
assert_eq!(
output.exit_code, 0,
"PowerShell: merge should succeed.\nOutput:\n{}",
output.combined
);
output.assert_no_directive_leaks();
}
#[rstest]
#[ignore = "ConPTY output not captured when cargo test redirects stdout"]
fn test_powershell_switch_with_execute(repo: TestRepo) {
let output = exec_through_wrapper(
"powershell",
&repo,
"switch",
&[
"--create",
"test-exec",
"--execute",
"Write-Host 'executed'",
"--yes",
],
);
assert_eq!(
output.exit_code, 0,
"PowerShell: switch with execute should succeed.\nOutput:\n{}",
output.combined
);
output.assert_no_directive_leaks();
assert!(
output.combined.contains("executed"),
"PowerShell: Execute command output missing.\nOutput:\n{}",
output.combined
);
}
#[rstest]
#[ignore = "ConPTY output not captured when cargo test redirects stdout"]
fn test_powershell_switch_existing(mut repo: TestRepo) {
repo.add_worktree("existing-feature");
let output = exec_through_wrapper("powershell", &repo, "switch", &["existing-feature"]);
assert_eq!(
output.exit_code, 0,
"PowerShell: switch to existing should succeed.\nOutput:\n{}",
output.combined
);
output.assert_no_directive_leaks();
}
#[rstest]
#[ignore = "ConPTY output not captured when cargo test redirects stdout"]
fn test_powershell_list_json(repo: TestRepo) {
let output = exec_through_wrapper("powershell", &repo, "list", &["--format", "json"]);
assert_eq!(
output.exit_code, 0,
"PowerShell: list --format json should succeed.\nOutput:\n{}",
output.combined
);
output.assert_no_directive_leaks();
assert!(
output.combined.contains('[') && output.combined.contains(']'),
"PowerShell: Should output JSON array.\nOutput:\n{}",
output.combined
);
}
#[rstest]
#[ignore = "ConPTY output not captured when cargo test redirects stdout"]
fn test_powershell_config_show(repo: TestRepo) {
let output = exec_through_wrapper("powershell", &repo, "config", &["show"]);
assert_eq!(
output.exit_code, 0,
"PowerShell: config show should succeed.\nOutput:\n{}",
output.combined
);
output.assert_no_directive_leaks();
}
#[rstest]
#[ignore = "ConPTY output not captured when cargo test redirects stdout"]
fn test_powershell_version(repo: TestRepo) {
let output = exec_through_wrapper("powershell", &repo, "--version", &[]);
assert_eq!(
output.exit_code, 0,
"PowerShell: --version should succeed.\nOutput:\n{}",
output.combined
);
output.assert_no_directive_leaks();
assert!(
output.combined.contains("wt ") || output.combined.contains("worktrunk"),
"PowerShell: Should show version info.\nOutput:\n{}",
output.combined
);
}
#[rstest]
#[ignore = "ConPTY output not captured when cargo test redirects stdout"]
fn test_powershell_shell_integration_hint_suppressed(repo: TestRepo) {
let output = exec_through_wrapper("powershell", &repo, "switch", &["--create", "ps-test"]);
assert!(
!output.combined.contains("To enable automatic cd"),
"PowerShell: Shell integration hint should not appear when running through wrapper.\nOutput:\n{}",
output.combined
);
assert!(
output.combined.contains("Created branch") && output.combined.contains("worktree"),
"PowerShell: Success message missing.\nOutput:\n{}",
output.combined
);
}
#[rstest]
#[ignore = "ConPTY output not captured when cargo test redirects stdout"]
fn test_powershell_switch_between_worktrees(mut repo: TestRepo) {
repo.add_worktree("feature-first");
repo.add_worktree("feature-second");
let output = exec_through_wrapper("powershell", &repo, "switch", &["feature-first"]);
assert_eq!(
output.exit_code, 0,
"PowerShell: switch to existing worktree should succeed.\nOutput:\n{}",
output.combined
);
output.assert_no_directive_leaks();
}
#[rstest]
#[ignore = "ConPTY output not captured when cargo test redirects stdout"]
fn test_powershell_long_branch_name(repo: TestRepo) {
let long_name = "feature-with-a-really-long-descriptive-branch-name-that-goes-on";
let output = exec_through_wrapper("powershell", &repo, "switch", &["--create", long_name]);
assert_eq!(
output.exit_code, 0,
"PowerShell: Should handle long branch names.\nOutput:\n{}",
output.combined
);
output.assert_no_directive_leaks();
}
#[rstest]
#[ignore = "ConPTY output not captured when cargo test redirects stdout"]
fn test_powershell_remove_by_name(mut repo: TestRepo) {
repo.add_worktree("to-delete");
let output = exec_through_wrapper("powershell", &repo, "remove", &["to-delete"]);
assert_eq!(
output.exit_code, 0,
"PowerShell: remove by name should succeed.\nOutput:\n{}",
output.combined
);
output.assert_no_directive_leaks();
}
#[rstest]
#[ignore = "ConPTY output not captured when cargo test redirects stdout"]
fn test_powershell_list_verbose(mut repo: TestRepo) {
repo.add_worktree("verbose-test");
let output = exec_through_wrapper("powershell", &repo, "list", &["--verbose"]);
assert_eq!(
output.exit_code, 0,
"PowerShell: list --verbose should succeed.\nOutput:\n{}",
output.combined
);
output.assert_no_directive_leaks();
}
#[rstest]
#[ignore = "ConPTY output not captured when cargo test redirects stdout"]
fn test_powershell_config_shell_init(repo: TestRepo) {
let output = exec_through_wrapper(
"powershell",
&repo,
"config",
&["shell", "init", "powershell"],
);
assert_eq!(
output.exit_code, 0,
"PowerShell: config shell init should succeed.\nOutput:\n{}",
output.combined
);
output.assert_no_directive_leaks();
assert!(
output.combined.contains("function") || output.combined.contains("WORKTRUNK"),
"PowerShell: Should output shell init script.\nOutput:\n{}",
output.combined
);
}
#[rstest]
#[ignore = "ConPTY output not captured when cargo test redirects stdout"]
fn test_powershell_switch_nonexistent_branch(repo: TestRepo) {
let output = exec_through_wrapper("powershell", &repo, "switch", &["nonexistent-branch"]);
assert_ne!(
output.exit_code, 0,
"PowerShell: switch to nonexistent branch should fail.\nOutput:\n{}",
output.combined
);
output.assert_no_directive_leaks();
}
#[rstest]
#[ignore = "ConPTY output not captured when cargo test redirects stdout"]
fn test_powershell_step_next(mut repo: TestRepo) {
repo.add_worktree("step-1");
repo.add_worktree("step-2");
let output = exec_through_wrapper("powershell", &repo, "step", &["next"]);
output.assert_no_directive_leaks();
}
#[rstest]
#[ignore = "ConPTY output not captured when cargo test redirects stdout"]
fn test_powershell_step_prev(mut repo: TestRepo) {
repo.add_worktree("prev-1");
repo.add_worktree("prev-2");
let output = exec_through_wrapper("powershell", &repo, "step", &["prev"]);
output.assert_no_directive_leaks();
}
#[rstest]
#[ignore = "ConPTY output not captured when cargo test redirects stdout"]
fn test_powershell_special_branch_name(repo: TestRepo) {
let output =
exec_through_wrapper("powershell", &repo, "switch", &["--create", "fix_bug-123"]);
assert_eq!(
output.exit_code, 0,
"PowerShell: Should handle special chars in branch names.\nOutput:\n{}",
output.combined
);
output.assert_no_directive_leaks();
}
#[rstest]
#[ignore = "ConPTY output not captured when cargo test redirects stdout"]
fn test_powershell_hook_show(repo: TestRepo) {
let output = exec_through_wrapper("powershell", &repo, "hook", &["show"]);
assert_eq!(
output.exit_code, 0,
"PowerShell: hook show should succeed.\nOutput:\n{}",
output.combined
);
output.assert_no_directive_leaks();
}
}