Files
Maximilian Roos baf161bf60 Run switch --execute as literal argv (#3977)
## Summary

- Treat `wt switch -x` as one program plus arguments that bypass
Worktrunk shell parsing; program lookup and argument decoding use native
operating-system behavior.
- Launch the program from `wt` in the selected worktree, preserving
terminal access, signals, and exit status.
- Remove the shell exec directive, shell-specific escaping, and implicit
`sh` dependency. Shell wrappers now carry only the directory change.
- Make the Nushell wrapper recognize clustered execute flags such as
`-cx`, stop scanning at `--`, and warn when a retired exec-file wrapper
buffers stdout away from the terminal.

Shell syntax remains explicit: `-x sh -- -c 'code . && test -f
Cargo.toml'`. On Windows, shell shims need their extension (`-x
code.cmd`) or an explicit shell such as `-x cmd.exe -- /C code`.

This completes the argv cutover proposed in #2860 and removes the
remaining Nushell-on-Windows problem in #3944. It adds no PATHEXT
lookup, custom Windows quoting, or PowerShell launcher path, and still
removes 597 lines from `src` and `templates` on net.

## Validation

- `cargo run -- hook pre-merge --yes`
- 4,712 tests passed; one skipped
- Formatting, clippy, docs, doctests, lockfile, and snapshot checks
passed

Thanks @omgreenfield for testing the migration path in #2860.

Closes #2860
Closes #3944

> _This was written by Codex on behalf of @max-sixty_
2026-09-01 10:36:30 -07:00

4419 lines
165 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Shell wrapper integration tests
//!
//! Tests that verify the complete shell integration path - commands executed through
//! the actual shell wrapper (wt_exec in bash/zsh/fish).
//!
//! These tests ensure that:
//! - Directives are never leaked to users
//! - Output is properly formatted for humans
//! - Shell integration works end-to-end as users experience it
//!
//! ## Why Manual PTY Execution + File Snapshots (Not insta_cmd)?
//!
//! These tests use PTY execution because testing shell wrappers requires real TTY behavior
//! (streaming output, ANSI codes, signal handling). `insta_cmd` uses `std::process::Command`
//! which doesn't provide a TTY to child processes.
//!
//! Output normalization uses insta's `add_filter()` API via `shell_wrapper_settings()`,
//! which is consistent with how other tests in the codebase handle path and hash
//! normalization. The filters handle:
//! - PTY-specific artifacts (CRLF, ^D control sequences, ANSI resets)
//! - Temporary directory paths
//! - Commit hashes (non-deterministic in PTY tests due to timing/environment)
//! - Project root paths
// All shell integration tests and infrastructure gated by feature flag
// Supports both Unix (bash/zsh/fish) and Windows (PowerShell)
#![cfg(feature = "shell-integration-tests")]
// =============================================================================
// Imports
// =============================================================================
// Shared imports (both platforms)
use crate::common::{TestRepo, shell::shell_binary, wt_bin};
use std::process::Command;
use worktrunk::shell;
// Unix-only imports
#[cfg(unix)]
use {
crate::common::{add_pty_filters, canonicalize, wait_for_file_content},
insta::assert_snapshot,
std::{fs, path::PathBuf, sync::LazyLock},
};
/// Output from executing a command through a shell wrapper
#[derive(Debug)]
struct ShellOutput {
/// Combined stdout and stderr as user would see
combined: String,
/// Exit code from the command
exit_code: i32,
}
/// Regex for detecting bash job control messages
/// Matches patterns like "[1] 12345" (job start) and "[1]+ Done" (job completion)
#[cfg(unix)]
static JOB_CONTROL_REGEX: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"\[\d+\][+-]?\s+(Done|\d+)").unwrap());
impl ShellOutput {
/// Check if output contains no directive leaks
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
);
}
/// Check if output contains no bash job control messages
///
/// Job control messages like "[1] 12345" (job start) and "[1]+ Done ..." (job completion)
/// should not appear in user-facing output. These are internal shell artifacts from
/// background process management that leak implementation details.
#[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
);
}
/// Assert command exited successfully (exit code 0)
#[cfg(unix)]
fn assert_success(&self) {
assert_eq!(
self.exit_code, 0,
"Expected exit code 0, got {}.\nOutput:\n{}",
self.exit_code, self.combined
);
}
}
/// Insta settings for shell wrapper tests.
///
/// Inherits snapshot_path and path filters from TestRepo (bound to scope),
/// then adds PTY-specific filters for cross-platform consistency.
#[cfg(unix)]
fn shell_wrapper_settings() -> insta::Settings {
let mut settings = insta::Settings::clone_current();
add_pty_filters(&mut settings);
settings
}
/// Generate a shell wrapper script using the actual `wt config shell init` command
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);
// Configure environment
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))
}
/// Generate shell completions script for the given shell
///
/// Note: Fish completions are custom (use $WORKTRUNK_BIN to bypass shell wrapper).
/// Bash and Zsh use inline lazy loading in the init script.
#[cfg(unix)]
fn generate_completions(_repo: &TestRepo, shell: &str) -> String {
match shell {
"fish" => {
// Fish uses a custom completion that bypasses the shell wrapper
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()
}
_ => {
// Bash and Zsh use inline lazy loading in the init script
String::new()
}
}
}
/// Quote a shell argument if it contains special characters
fn quote_arg(arg: &str) -> String {
if arg.is_empty() || arg.contains(' ') || arg.contains(';') || arg.contains('\'') {
shell_quote(arg)
} else {
arg.to_string()
}
}
/// Always quote a string for shell use, properly escaping single quotes.
/// Handles paths like `/path/to/worktrunk.'∅'/target/debug/wt`
fn shell_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', r"'\''"))
}
/// Always quote a Nushell string. Nushell does not support the POSIX `\''`
/// concatenation idiom, while ordinary double-quoted strings accept `\\` and
/// `\"` escapes without interpolation (`$"…"` is the interpolation form).
fn nushell_quote(s: &str) -> String {
format!("\"{}\"", s.replace('\\', r"\\").replace('"', r#"\""#))
}
/// Quote a path for PowerShell (escape backticks and single quotes)
fn powershell_quote(s: &str) -> String {
// PowerShell string escaping: use single quotes and escape embedded single quotes by doubling
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');
}
/// Build a shell script that sources the wrapper and runs a command
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);
// Build the command
script.push_str("wt ");
script.push_str(subcommand);
for arg in args {
script.push(' ');
match shell {
"powershell" | "pwsh" => {
// PowerShell argument quoting
// Note: -- is special in PowerShell (stop-parsing token), so we must quote it
if arg.contains(' ') || arg.contains(';') || arg.contains('\'') || *arg == "--" {
script.push_str(&powershell_quote(arg));
} else {
script.push_str(arg);
}
}
"nu" | "nushell" => {
if arg.is_empty()
|| arg
.chars()
.any(|c| c.is_whitespace() || matches!(c, ';' | '\'' | '"' | '$' | '\\'))
{
script.push_str(&nushell_quote(arg));
} else {
script.push_str(arg);
}
}
_ => {
script.push_str(&quote_arg(arg));
}
}
}
script.push('\n');
// Merge stderr to stdout to simulate real terminal behavior
// In a real terminal, both streams interleave naturally by the OS.
// The .output() method captures them separately, so we merge them here
// to preserve temporal locality (output appears when operations complete, not batched at the end)
match shell {
"fish" => {
// Fish uses begin...end for grouping
// Note: This exposes a Fish wrapper buffering bug where child output appears out of order
// (see templates/fish.fish - psub causes buffering). Tests document current behavior.
format!("begin\n{}\nend 2>&1", script)
}
"nu" => {
// Nushell doesn't need explicit stderr redirect - PTY captures both streams
// The script is executed directly
script
}
"bash" => {
// For bash, we don't use a subshell wrapper because it would isolate job control messages.
// Instead, we use exec to redirect stderr to stdout, then run the script.
// This ensures job control messages (like "[1] 12345" and "[1]+ Done") are captured,
// allowing tests to catch these leaks.
format!("exec 2>&1\n{}", script)
}
"powershell" | "pwsh" => {
// PowerShell: run script directly, redirect stderr to stdout for the wt call
// The & { } wrapper was causing output to be lost in ConPTY.
// Instead, we run the script directly - stderr naturally appears in the PTY.
// Exit with LASTEXITCODE to propagate the wt function's exit code to the calling process.
format!("{}\nexit $LASTEXITCODE", script)
}
_ => {
// zsh uses parentheses for subshell grouping
format!("( {} ) 2>&1", script)
}
}
}
/// Execute a command in a PTY with interactive input support.
///
/// The PTY will automatically echo the input (like a real terminal), so you'll
/// see both the prompts and the input in the captured output.
///
/// # Arguments
/// * `shell` - The shell to use (e.g., "bash", "zsh")
/// * `script` - The script to execute
/// * `working_dir` - Working directory for the command
/// * `env_vars` - Environment variables to set
/// * `inputs` - A slice of strings to send as input (e.g., `&["y\n", "feature\n"]`)
///
/// # Example
/// ```no_run
/// let (output, exit_code) = exec_in_pty_interactive(
/// "bash",
/// "wt switch --create",
/// repo.root_path(),
/// &[("CLICOLOR_FORCE", "1")],
/// &["y\n"], // Send 'y' and newline when prompted
/// );
/// // The output will show: "Allow? [y/N] y"
/// ```
#[cfg(test)]
fn exec_in_pty_interactive(
shell: &str,
script: &str,
working_dir: &std::path::Path,
env_vars: &[(&str, &str)],
inputs: &[&str],
) -> (String, i32) {
exec_in_pty_shell(shell, script, working_dir, env_vars, inputs, true)
}
/// Like [`exec_in_pty_interactive`] but lets the caller choose whether `bash` /
/// `zsh` run interactively (`-i`).
///
/// An interactive shell does job-control initialization at startup: if it
/// decides it isn't the PTY's foreground process group it sends its own process
/// group `SIGTTIN` and stops — producing no output and never exiting. Under
/// parallel load that occasionally wedged `test_source_flag_forwards_errors`
/// until the 180s harness timeout. A non-interactive shell skips job control
/// entirely, so the wedge can't happen; pass `interactive = false` for tests
/// that don't assert on job-control output. Tests that *do* (e.g.
/// `test_zsh_no_job_control`) pass `interactive = true`.
#[cfg(test)]
fn exec_in_pty_shell(
shell: &str,
script: &str,
working_dir: &std::path::Path,
env_vars: &[(&str, &str)],
inputs: &[&str],
interactive: bool,
) -> (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);
// Isolated environment (env_clear, HOME, PATH, determinism baselines, coverage)
crate::common::configure_pty_command(&mut cmd);
cmd.env("USER", "testuser");
cmd.env("SHELL", shell_binary);
// Interactive shells (`-i`) enable job control, so tests can catch
// job-control message leaks like "[1] 12345" and "[1]+ Done". Callers that
// don't assert on job control pass `interactive = false` to skip `-i` and
// avoid the startup job-control wedge (see `exec_in_pty_shell`).
match shell {
"zsh" => {
// Isolate from user rc files
cmd.env("ZDOTDIR", "/dev/null");
if interactive {
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" => {
// Isolate from user rc/profile files, mirroring the zsh arm and
// `exec_bash_truly_interactive`. A no-op for non-interactive
// `bash -c`, but it keeps interactive (`-i`) startup from sourcing
// the host's ~/.bashrc and leaking host-specific output.
cmd.arg("--norc");
cmd.arg("--noprofile");
if interactive {
cmd.arg("-i");
}
cmd.arg("-c");
cmd.arg(script);
}
"powershell" | "pwsh" => {
// PowerShell: write script to temp file and execute via -File
// Using -Command with long scripts can cause issues with ConPTY
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" => {
// Nushell: isolate from user config
cmd.arg("--no-config-file");
cmd.arg("-c");
cmd.arg(script);
}
_ => {
// fish and other shells
cmd.arg("-c");
cmd.arg(script);
}
}
cmd.cwd(working_dir);
// Add test-specific environment variables (convert &str tuples to String tuples)
for (key, value) in env_vars {
cmd.env(key, value);
}
// Pass through LLVM coverage env vars for subprocess coverage collection
crate::common::pass_coverage_env_to_pty_cmd(&mut cmd);
let mut child = pair.slave.spawn_command(cmd).unwrap();
drop(pair.slave); // Close slave in parent
// Get reader and writer for the PTY master
let reader = pair.master.try_clone_reader().unwrap();
let mut writer = pair.master.take_writer().unwrap();
// Write input synchronously if we have any (matches approval_pty.rs approach)
for input in inputs {
writer.write_all(input.as_bytes()).unwrap();
writer.flush().unwrap();
}
// Read output and wait for exit using platform-aware handling
// On Windows ConPTY, this handles cursor queries and proper pipe closure
let (buf, exit_code) =
crate::common::pty::read_pty_output(reader, writer, pair.master, &mut child);
// Normalize CRLF to LF (PTYs use CRLF on some platforms)
let normalized = buf.replace("\r\n", "\n");
(normalized, exit_code)
}
/// Execute bash in true interactive mode by writing commands to the PTY
///
/// Unlike `exec_in_pty_interactive` which uses `bash -i -c "script"`, this function
/// starts bash without `-c` and writes commands directly to the PTY. This captures
/// job control notifications (`[1]+ Done`) that only appear at prompt-time in bash.
///
/// The setup_script is written to a temp file and sourced. Then final_cmd is run
/// directly at the prompt (where job notifications appear).
///
/// `success_marker` is a substring the command is expected to print; the helper
/// polls for it to know the command finished, instead of sleeping a fixed guess.
#[cfg(all(test, unix))]
fn exec_bash_truly_interactive(
setup_script: &str,
final_cmd: &str,
success_marker: &str,
working_dir: &std::path::Path,
env_vars: &[(&str, &str)],
) -> (String, i32) {
use portable_pty::CommandBuilder;
use std::io::{Read, Write};
use std::sync::mpsc;
use std::thread;
use std::time::{Duration, Instant};
// Write setup script to a temp file
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();
// Spawn bash in true interactive mode using env to pass flags
// (portable_pty's CommandBuilder can have issues with flag parsing)
let mut cmd = CommandBuilder::new("env");
cmd.arg("bash");
cmd.arg("--norc");
cmd.arg("--noprofile");
cmd.arg("-i");
// Isolated environment (env_clear, HOME, PATH, determinism baselines, coverage)
crate::common::configure_pty_command(&mut cmd);
cmd.env("USER", "testuser");
cmd.env("SHELL", "bash");
// Simple prompt to make output cleaner ($ followed by space)
cmd.env("PS1", "$ ");
cmd.cwd(working_dir);
// Add test-specific environment variables
for (key, value) in env_vars {
cmd.env(key, value);
}
let mut child = pair.slave.spawn_command(cmd).unwrap();
drop(pair.slave); // Close slave in parent
let reader = pair.master.try_clone_reader().unwrap();
let mut writer = pair.master.take_writer().unwrap();
// Stream PTY output over a channel so the main thread can poll for prompts and
// command output as they arrive, rather than reading only after `exit`. This is
// what lets the blind startup/command sleeps become waits on real output.
let (tx, rx) = mpsc::channel::<Vec<u8>>();
let reader_thread = thread::spawn(move || {
let mut reader = reader;
let mut buf = [0u8; 4096];
loop {
match reader.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
if tx.send(buf[..n].to_vec()).is_err() {
break;
}
}
Err(_) => break,
}
}
});
let mut accumulated: Vec<u8> = Vec::new();
let poll = Duration::from_millis(10);
// Drain pending chunks, then poll until `needle` appears or the timeout elapses.
// This is the presence half: wait on the output that the command must produce.
let mut wait_for = |needle: &[u8], timeout: Duration, what: &str| {
let start = Instant::now();
loop {
while let Ok(chunk) = rx.try_recv() {
accumulated.extend_from_slice(&chunk);
}
if accumulated.windows(needle.len()).any(|w| w == needle) {
return;
}
if start.elapsed() > timeout {
panic!(
"Timed out waiting for {} ({:?}). Output so far:\n{}",
what,
String::from_utf8_lossy(needle),
String::from_utf8_lossy(&accumulated)
);
}
thread::sleep(poll);
}
};
// Wait for bash's first prompt instead of guessing a startup delay.
wait_for(b"$ ", Duration::from_secs(10), "bash startup prompt");
// Write setup and command (but not exit yet).
let commands = format!("source '{}'\n{}\n", script_path.display(), final_cmd);
writer.write_all(commands.as_bytes()).unwrap();
writer.flush().unwrap();
// Wait for the command's success output, confirming it ran to completion.
wait_for(
success_marker.as_bytes(),
Duration::from_secs(30),
"command output",
);
// Absence window: a leaked `[1]+ Done` surfaces at prompt-time after the
// backgrounded post-start hook finishes. Hold a fixed window so a leak has time
// to appear. The assertion is that it does not, so there is no event to poll for.
thread::sleep(crate::common::SLEEP_FOR_ABSENCE_CHECK);
// Now send exit.
writer.write_all(b"exit\n").unwrap();
writer.flush().unwrap();
drop(writer);
// Drain the remaining output once bash has exited.
let status = child.wait().unwrap();
reader_thread.join().unwrap();
while let Ok(chunk) = rx.try_recv() {
accumulated.extend_from_slice(&chunk);
}
// Normalize CRLF to LF (same as exec_in_pty_interactive).
let normalized = String::from_utf8_lossy(&accumulated).replace("\r\n", "\n");
(normalized, status.exit_code() as i32)
}
/// Execute a command through a shell wrapper
///
/// This simulates what actually happens when users run `wt switch`, etc. in their shell:
/// 1. The `wt` function is defined (from shell integration)
/// 2. It sets WORKTRUNK_DIRECTIVE_CD_FILE
/// 3. The wrapper reads the cd path after wt exits
/// 4. Users see stdout/stderr output in real-time
///
/// Now uses PTY interactive mode for consistent behavior and potential input echoing.
///
/// Returns ShellOutput with combined output and exit code
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 {
// Delegate to interactive version with no input
// This provides consistent PTY behavior across all tests
exec_through_wrapper_interactive(shell, repo, subcommand, args, working_dir, &[])
}
/// Execute a command through a shell wrapper with interactive input support
///
/// This is similar to `exec_through_wrapper_from` but allows sending input during execution
/// (e.g., approval responses). The PTY will automatically echo the input, so you'll see
/// both the prompts and the responses in the captured output.
///
/// # Arguments
/// * `shell` - The shell to use (e.g., "bash", "zsh", "fish")
/// * `repo` - The test repository
/// * `subcommand` - The wt subcommand (e.g., "merge", "switch")
/// * `args` - Arguments to the subcommand (without --yes)
/// * `working_dir` - Working directory for the command
/// * `inputs` - Input strings to send (e.g., `&["y\n"]` for approval prompts)
///
/// # Example
/// ```no_run
/// // Test merge with approval prompt visible in output
/// let output = exec_through_wrapper_interactive(
/// "bash",
/// &repo,
/// "merge",
/// &["main"],
/// repo.root_path(),
/// &["y\n"], // Approve the merge
/// );
/// // Output will show: "❓ Allow and remember? [y/N] y"
/// ```
#[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, &[])
}
/// Execute a command through a shell wrapper with custom environment variables
///
/// Like `exec_through_wrapper_interactive` but allows additional env vars to be set.
/// Useful for tests that need custom PATH (e.g., for mock binaries).
#[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);
// Add extra env vars (these can override defaults if needed)
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,
}
}
/// Build the per-test half of a shell-wrapper PTY environment.
///
/// The determinism baselines come from [`crate::common::configure_pty_command`],
/// which `exec_in_pty_shell` applies first; these are what it can't supply —
/// the paths pointing wt at this test's fixture, a git identity for the
/// commits the scripts make, and a `TERM` with real terminfo. The caller must
/// keep both path strings alive for the duration of the returned Vec's use.
#[cfg(test)]
fn build_test_env_vars<'a>(
config_path: &'a str,
approvals_path: &'a str,
) -> Vec<(&'a str, &'a str)> {
vec![
("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"),
]
}
// =============================================================================
// Unix Shell Tests (bash/zsh/fish)
// =============================================================================
//
// All Unix shell integration tests are in this module, gated by #[cfg(unix)].
// This includes tests for bash, zsh, and fish shells.
//
// Shared infrastructure (exec_through_wrapper, ShellOutput, etc.) is defined
// above and works on both platforms.
#[cfg(unix)]
mod unix_tests {
use super::*;
use crate::common::repo;
use rstest::rstest;
// ========================================================================
// Cross-Shell Error Handling Tests
// ========================================================================
//
// These tests use parametrized testing to verify consistent behavior
// across all supported shells (bash, zsh, fish).
//
// Note: Zsh tests run in isolated mode (--no-rcs, ZDOTDIR=/dev/null) to prevent
// user startup files from touching /dev/tty, which would cause SIGTTIN/TTOU/TSTP
// signals. This isolation ensures tests are deterministic across all environments.
//
// SNAPSHOT CONSOLIDATION:
// Tests use `insta::allow_duplicates!` to share a single snapshot across all shells
// when output is deterministic and identical. This reduces snapshot count from 3×N to N.
//
// Trade-off: If future changes introduce shell-specific output differences, all three
// shells will fail with "doesn't match snapshot" rather than showing which specific
// shell differs. For tests with non-deterministic output (PTY buffering causes varying
// order), we keep shell-specific snapshots.
//
// TODO: Consider adding a test assertion that compares bash/zsh/fish outputs are
// byte-identical before the snapshot check, so we can identify which shell diverged.
#[rstest]
#[case("bash")]
#[case("zsh")]
#[case("fish")]
#[case("nu")]
fn test_wrapper_handles_command_failure(#[case] shell: &str, mut repo: TestRepo) {
// Create a worktree that already exists
repo.add_worktree("existing");
// Try to create it again - should fail
let output = exec_through_wrapper(shell, &repo, "switch", &["--create", "existing"]);
// Shell-agnostic assertions: these must be true for ALL shells
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
);
// Consolidated snapshot - output should be identical across all shells
shell_wrapper_settings().bind(|| {
insta::allow_duplicates! {
assert_snapshot!("command_failure", &output.combined);
}
});
}
/// The nushell wrapper reports a failing `wt` without spawning a POSIX shell.
///
/// `Shell::Nushell` is not platform-gated — `wt config shell install` writes
/// this wrapper on Windows whenever `nu` is on PATH, and stock Windows has no
/// `sh`. Exit-code propagation runs on *every* failing `wt` command, so a `sh`
/// dependency there appended a ``Command `sh` not found`` trace (printing the
/// wrapper's own source) to each one and flattened the real exit code to 1
/// (#3944).
///
/// Restricting PATH to `nu` and `git` reproduces the only property of Windows
/// that this path depends on — no POSIX shell to spawn — so the regression is
/// covered on the Unix runners where the nushell wrapper is actually tested.
#[rstest]
fn test_nushell_wrapper_needs_no_posix_shell(mut repo: TestRepo) {
repo.add_worktree("existing");
// A PATH with no `sh`/`bash`/`dash`: just nushell, git, the command run
// by `--execute`, and the `rm` used by the wrapper's Unix cleanup branch.
// Dropping `rm` would leak every
// `mktemp` file, silently — the branch is wrapped in `try` — and would
// also make the test less faithful to the platform it stands in for:
// Windows takes the `$nu.os-info.family` branch, where cleanup uses the
// nushell builtin and does run.
let bin_dir = tempfile::tempdir().unwrap();
for tool in ["nu", "git", "rm", "printf"] {
let resolved = which::which(tool)
.unwrap_or_else(|e| panic!("{tool} must be installed to run tests: {e}"));
std::os::unix::fs::symlink(resolved, bin_dir.path().join(tool)).unwrap();
}
let sanitized_path = bin_dir.path().display().to_string();
// Re-creating an existing branch fails, so the wrapper takes its
// exit-code propagation branch.
let output = exec_through_wrapper_with_env(
"nu",
&repo,
"switch",
&["--create", "existing"],
repo.root_path(),
&[],
&[("PATH", &sanitized_path)],
);
assert_eq!(
output.exit_code, 1,
"failure should propagate as exit 1.\nOutput:\n{}",
output.combined
);
assert!(
output.combined.contains("already exists"),
"wt's own error should still reach the terminal.\nOutput:\n{}",
output.combined
);
// The shape the bug produced: nushell rendering a spawn failure for the
// wrapper's internals on top of wt's error.
assert!(
!output.combined.contains("External command failed")
&& !output.combined.contains("not found"),
"wrapper leaked a nushell external-command error.\nOutput:\n{}",
output.combined
);
// The other half of the bug: a failed spawn flattens every exit code to
// 1, so an exit-1 command can't tell the two templates apart. `wt config
// alias show <unknown>` exits 2 entirely inside wt (`unknown_alias_error`
// in `src/commands/config/alias.rs`), so the code only survives if the
// wrapper's propagation command actually ran.
let output = exec_through_wrapper_with_env(
"nu",
&repo,
"config",
&["alias", "show", "no-such-alias"],
repo.root_path(),
&[],
&[("PATH", &sanitized_path)],
);
assert_eq!(
output.exit_code, 2,
"wt's exit code should propagate unchanged, not flatten to 1.\nOutput:\n{}",
output.combined
);
assert!(
!output.combined.contains("External command failed")
&& !output.combined.contains("not found"),
"wrapper leaked a nushell external-command error.\nOutput:\n{}",
output.combined
);
// `--execute` uses the same shell-free path.
let output = exec_through_wrapper_with_env(
"nu",
&repo,
"switch",
&[
"--create",
"nu-exec-no-sh",
"--execute",
"printf",
"--",
"NU_EXEC_OK",
],
repo.root_path(),
&[],
&[("PATH", &sanitized_path)],
);
assert_eq!(output.exit_code, 0, "Output:\n{}", output.combined);
assert!(
output.combined.contains("NU_EXEC_OK"),
"directly executed program output is missing.\nOutput:\n{}",
output.combined
);
assert!(
!output.combined.contains("Command `sh` not found"),
"wrapper tried to introduce an implicit POSIX shell.\nOutput:\n{}",
output.combined
);
output.assert_no_directive_leaks();
}
#[rstest]
#[case("bash")]
#[case("zsh")]
#[case("fish")]
#[case("nu")]
fn test_wrapper_switch_create(#[case] shell: &str, repo: TestRepo) {
let branch = "feature/with-dashes_and_underscores";
let output = exec_through_wrapper(shell, &repo, "switch", &["--create", branch]);
// Shell-agnostic assertions
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(branch)
&& output.combined.contains("and worktree"),
"{shell}: Should create the exact branch passed through the wrapper"
);
// Consolidated snapshot - output should be identical across all shells
shell_wrapper_settings().bind(|| {
insta::allow_duplicates! {
assert_snapshot!("switch_create", &output.combined);
}
});
}
/// A user's `alias rm = ...` must not intercept the nushell wrapper's
/// temp-file cleanup.
///
/// Nushell resolves aliases at parse time, and `config.nu` runs before the
/// vendor autoload dir the wrapper is installed into — so an alias declared
/// there is already in scope when the wrapper's `def` is parsed. Before the
/// fix, an alias that exits non-zero raised a ShellError mid-cleanup that
/// aborted the wrapper before it returned the command's stdout, and left
/// every temp file behind. `^false` stands in for the realistic aliases
/// (`trash`, a wrapper that prompts, one that isn't installed on this box).
///
/// The POSIX wrappers use `command rm` for the same reason; nushell has no
/// `command` builtin, so the template branches on `$nu.os-info.family`.
#[rstest]
fn test_nu_wrapper_cleanup_survives_rm_alias(repo: TestRepo) {
// A dedicated TMPDIR so the wrapper's `mktemp` files are the only
// occupants, and a leak is directly observable.
let tmp = tempfile::tempdir().unwrap();
let tmp_path = tmp.path().to_string_lossy().to_string();
let mut script = String::new();
// Must precede the wrapper's `def`: alias resolution is parse-time.
script.push_str("alias rm = ^false\n");
append_wrapper_setup(&mut script, "nu", &repo);
// `config show` writes to stdout, which the wrapper returns as the
// function's value — the part a mid-cleanup abort swallows. `switch`
// wouldn't show it: its output is on stderr, which streams to the
// terminal before cleanup runs either way.
script.push_str("let out = (wt config show)\n");
script.push_str("print $\"WRAPPER_STDOUT_EMPTY:($out | is-empty)\"\n");
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(("TMPDIR", &tmp_path));
let (combined, exit_code) =
exec_in_pty_interactive("nu", &script, repo.root_path(), &env_vars, &[]);
assert!(
combined.contains("WRAPPER_STDOUT_EMPTY:false"),
"wrapper returned no stdout — the marker is missing entirely when cleanup \
aborted the wrapper, and reads `true` when it returned an empty value.\nOutput:\n{combined}"
);
assert_eq!(exit_code, 0, "Output:\n{combined}");
let leftover: Vec<_> = fs::read_dir(tmp.path())
.unwrap()
.map(|e| e.unwrap().file_name())
.collect();
assert!(
leftover.is_empty(),
"wrapper leaked temp files past cleanup: {leftover:?}"
);
}
#[rstest]
fn test_nu_wrapper_ignores_execute_flags_after_argument_boundary(repo: TestRepo) {
let mut script = String::new();
append_wrapper_setup(&mut script, "nu", &repo);
script.push_str(
"let out = (wt step for-each --format=json -- printf -- -x)\n\
print $\"PIPELINE_CAPTURED:(not ($out | is-empty))\"\n",
);
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("nu", &script, repo.root_path(), &env_vars, &[]);
assert_eq!(exit_code, 0, "Output:\n{combined}");
assert!(
combined.contains("PIPELINE_CAPTURED:true"),
"-x after -- should not disable Nushell pipeline capture.\nOutput:\n{combined}"
);
}
#[rstest]
#[case("bash")]
#[case("zsh")]
#[case("fish")]
#[case("nu")]
fn test_wrapper_switch_with_execute(#[case] shell: &str, repo: TestRepo) {
let args = if shell == "nu" {
vec![
"-cx",
"sh",
"test-exec",
"--yes",
"--",
"-c",
"test -t 1 && printf 'EXEC_STDOUT_TTY:true\\n'",
]
} else {
vec![
"--create",
"test-exec",
"--yes",
"--execute",
"sh",
"--",
"-c",
"test -t 1 && printf 'EXEC_STDOUT_TTY:true\\n'",
]
};
// The child must inherit a real terminal, including through Nushell's
// normally-buffered wrapper path. Its case uses the valid `-cx`
// cluster so execute detection follows clap's short-option parsing.
let output = exec_through_wrapper_with_env(
shell,
&repo,
"switch",
&args,
repo.root_path(),
&[],
&[("WORKTRUNK_DIRECTIVE_EXEC_FILE", "retired")],
);
// Shell-agnostic assertions
assert_eq!(
output.exit_code, 0,
"{}: Command should succeed\nOutput:\n{}",
shell, output.combined
);
output.assert_no_directive_leaks();
assert!(
output.combined.contains("EXEC_STDOUT_TTY:true"),
"{}: execute child did not inherit terminal stdout",
shell
);
assert!(
!output.combined.contains("Shell wrapper is out of date"),
"{shell}: a stale wrapper with terminal stdout should not warn"
);
// Keep one snapshot per wrapper implementation: all consume the same
// argv protocol, but their PTY echo can still differ.
shell_wrapper_settings().bind(|| {
assert_snapshot!(format!("switch_with_execute_{shell}"), &output.combined);
});
}
/// Test that --execute command exit codes are propagated
/// Verifies that when wt succeeds but the --execute command fails,
/// the wrapper returns the command's exit code, not wt's.
#[rstest]
#[case("bash")]
#[case("zsh")]
#[case("fish")]
#[case("nu")]
fn test_wrapper_execute_exit_code_propagation(#[case] shell: &str, repo: TestRepo) {
// Use --yes to skip approval prompt in tests
// wt should succeed (creates worktree), but the execute command should fail with exit 42
let output = exec_through_wrapper(
shell,
&repo,
"switch",
&[
"--create",
"test-exit-code",
"--yes",
"--execute",
"sh",
"--",
"-c",
"exit 42",
],
);
// Shell-agnostic assertions
assert_eq!(
output.exit_code, 42,
"{}: Should propagate execute command's exit code (42), not wt's (0)",
shell
);
output.assert_no_directive_leaks();
// Should still show wt's success message (worktree was created)
assert!(
output.combined.contains("Created branch") && output.combined.contains("and worktree"),
"{}: Should show wt's success message even though execute command failed",
shell
);
}
/// A failing `--execute` body must not abort the nushell wrapper before its
/// cleanup runs.
///
/// Nushell 0.98+ raises a `ShellError` on a non-zero external exit, so a
/// failing program can unwind the whole `def` unless the wrapper catches
/// it before cleanup — the temp files leaked and the stdout the function still had to return was
/// discarded. `test_wrapper_execute_exit_code_propagation` covers the same
/// command and passes either way: the unwind *happens* to carry exit 42 out
/// to the shell, which is all that test asserts. So the leak sat on a path
/// with coverage, and the assertion that distinguishes the two is whether the
/// wrapper reached its own end — observable here as an empty `TMPDIR`.
///
/// The wrapper's terminal `^$nu.current-exe -c $"exit ($exit_code)"` is
/// *meant* to abort the caller, since that's how the code propagates; the
/// driving script wraps the call in nushell's own `try` so that intended
/// propagation doesn't hide whether cleanup ran first.
#[rstest]
fn test_nu_wrapper_execute_failure_runs_cleanup(repo: TestRepo) {
// A dedicated TMPDIR so the wrapper's `mktemp` files are the only
// occupants and a leak is directly observable.
let tmp = tempfile::tempdir().unwrap();
let tmp_path = tmp.path().to_string_lossy().to_string();
let mut script = String::new();
append_wrapper_setup(&mut script, "nu", &repo);
script.push_str(
"let code = (try { wt switch --create exec-abort --yes --execute sh -- -c \"exit 42\"; 0 } catch { $env.LAST_EXIT_CODE })\n",
);
script.push_str("print $\"WT_EXIT:($code)\"\n");
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(("TMPDIR", &tmp_path));
let (combined, _) =
exec_in_pty_interactive("nu", &script, repo.root_path(), &env_vars, &[]);
// The body's exit code still reaches the caller — the contract
// `test_wrapper_execute_exit_code_propagation` pins, now carried by the
// wrapper's own propagation rather than by the unwind.
assert!(
combined.contains("WT_EXIT:42"),
"wrapper should propagate the --execute body's exit code (42).\nOutput:\n{combined}"
);
let leftover: Vec<_> = fs::read_dir(tmp.path())
.unwrap()
.map(|e| e.unwrap().file_name())
.collect();
assert!(
leftover.is_empty(),
"wrapper aborted before cleanup and leaked temp files: {leftover:?}\nOutput:\n{combined}"
);
}
/// Test switch --create with pre-start (blocking) and post-start (background)
/// Note: bash and fish disabled due to flaky PTY buffering race conditions
///
/// TODO: Fix timing/race condition in bash where "Building project..." output appears
/// before the command display, causing snapshot mismatch (appears on line 7 instead of line 9).
/// This is a non-deterministic PTY output ordering issue.
#[rstest]
// #[case("bash")] // TODO: Flaky PTY output ordering - command output appears before command display
#[case("zsh")]
// #[case("fish")] // TODO: Fish shell has non-deterministic PTY output ordering
fn test_wrapper_switch_with_hooks(#[case] shell: &str, repo: TestRepo) {
// Create project config with both pre-start and post-start hooks
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");
// Pre-approve the commands
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-specific snapshot - output ordering varies due to PTY buffering
shell_wrapper_settings().bind(|| {
assert_snapshot!(format!("switch_with_hooks_{}", shell), &output.combined);
});
}
/// Test merge with successful pre-merge validation
/// Note: fish disabled due to flaky PTY buffering race conditions
/// TODO: bash variant occasionally fails on Ubuntu CI with snapshot mismatches due to PTY timing
#[rstest]
#[case("bash")]
#[case("zsh")]
// #[case("fish")] // TODO: Fish shell has non-deterministic PTY output ordering
fn test_wrapper_merge_with_pre_merge_success(#[case] shell: &str, mut repo: TestRepo) {
// Create project config with pre-merge validation
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();
// Suppress commit generation prompt (fires in PTY when claude is on PATH)
repo.write_test_config("");
// Pre-approve commands
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'",
]
"#,
);
// Run merge from the feature worktree
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-specific snapshot - output ordering varies due to PTY buffering
shell_wrapper_settings().bind(|| {
assert_snapshot!(
format!("merge_with_pre_merge_success_{}", shell),
&output.combined
);
});
}
/// Test merge with failing pre-merge that aborts the merge
/// Note: fish disabled due to flaky PTY buffering race conditions
#[rstest]
#[case("bash")]
#[case("zsh")]
// #[case("fish")] // TODO: Fish shell has non-deterministic PTY output ordering
fn test_wrapper_merge_with_pre_merge_failure(#[case] shell: &str, mut repo: TestRepo) {
// Create project config with failing pre-merge validation
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");
// Suppress commit generation prompt (fires in PTY when claude is on PATH)
repo.write_test_config("");
// Create feature worktree with a commit
let feature_wt = repo.add_worktree_with_commit(
"feature-fail",
"feature.txt",
"feature content",
"Add feature",
);
// Pre-approve the commands
repo.write_test_approvals(
r#"[projects."../origin"]
approved-commands = [
"echo '✓ Code formatting check passed'",
"echo '✗ Test suite failed: 3 tests failing' && exit 1",
]
"#,
);
// Run merge from the feature worktree
let output =
exec_through_wrapper_from(shell, &repo, "merge", &["main", "--yes"], &feature_wt);
output.assert_no_directive_leaks();
// Shell-specific snapshot - output ordering varies due to PTY buffering
shell_wrapper_settings().bind(|| {
assert_snapshot!(
format!("merge_with_pre_merge_failure_{}", shell),
&output.combined
);
});
}
/// Test merge with pre-merge commands that output to both stdout and stderr
/// Verifies that interleaved stdout/stderr appears in correct temporal order
/// Note: fish disabled due to flaky PTY buffering race conditions
#[rstest]
#[case("bash")]
#[case("zsh")]
// #[case("fish")] // TODO: Fish shell has non-deterministic PTY output ordering
fn test_wrapper_merge_with_mixed_stdout_stderr(#[case] shell: &str, mut repo: TestRepo) {
// Copy the fixture script to the test repo to avoid path issues with special characters
// (CARGO_MANIFEST_DIR may contain single quotes like worktrunk.'∅' which break shell parsing)
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();
// Make the script executable
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755)).unwrap();
}
// Create project config with pre-merge commands that output to both stdout and stderr.
// Use relative path (./mixed-output.sh) instead of absolute temp path to avoid flaky
// snapshot matching on macOS where _REPO_ filter can intermittently fail to match
// absolute paths inside syntax-highlighted format_bash_with_gutter output, causing
// the broader [PROJECT_ID] catch-all to consume the entire path.
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 }}""#);
// Pre-approve commands
repo.write_test_approvals(
r#"[projects."../origin"]
approved-commands = [
"./mixed-output.sh check1 3",
"./mixed-output.sh check2 3",
]
"#,
);
// Run merge from the feature worktree
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();
// Verify output shows proper temporal ordering:
// header1 → all check1 output (interleaved stdout/stderr) → header2 → all check2 output
// This ensures that stdout/stderr from child processes properly stream through
// to the terminal in real-time, maintaining correct ordering
shell_wrapper_settings().bind(|| {
assert_snapshot!(
format!("merge_with_mixed_stdout_stderr_{}", shell),
&output.combined
);
});
}
// ========================================================================
// Bash Shell Wrapper Tests
// ========================================================================
#[rstest]
fn test_switch_with_post_start_command_no_directive_leak(repo: TestRepo) {
// Configure a post-start command in the project config (this is where the bug manifests)
// The println! in handle_post_start_commands causes directive leaks
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");
// Pre-approve the 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"]);
// The critical assertion: directives must never appear in user-facing output
// This is where the bug occurs - "🔄 Starting (background):" is printed with println!
// which causes it to concatenate with the directive
output.assert_no_directive_leaks();
output.assert_no_job_control_messages();
output.assert_success();
// Normalize paths in output for snapshot testing
// Snapshot the output
shell_wrapper_settings().bind(|| assert_snapshot!(&output.combined));
}
#[rstest]
fn test_switch_with_execute_through_wrapper(repo: TestRepo) {
// Use --yes to skip approval prompt in tests
let output = exec_through_wrapper(
"bash",
&repo,
"switch",
&[
"--create",
"test-exec",
"--yes",
"--execute",
"echo",
"--",
"executed",
],
);
// No directives should leak
output.assert_no_directive_leaks();
output.assert_success();
// The executed command output should appear
assert!(
output.combined.contains("executed"),
"Execute command output missing"
);
// Normalize paths in output for snapshot testing
// Snapshot the output
shell_wrapper_settings().bind(|| assert_snapshot!(&output.combined));
}
#[rstest]
fn test_bash_shell_integration_hint_suppressed(repo: TestRepo) {
// When running through the shell wrapper, the "To enable automatic cd" hint
// should NOT appear because the user already has shell integration
let output = exec_through_wrapper("bash", &repo, "switch", &["--create", "bash-test"]);
// Critical: shell integration hint must be suppressed when shell integration is active
assert!(
!output.combined.contains("To enable automatic cd"),
"Shell integration hint should not appear when running through wrapper. Output:\n{}",
output.combined
);
// Should still have the success message
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) {
// Create worktree through shell wrapper (suppresses hint)
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) {
// Create worktrees (fix-auth is where we are after step 2, feature-api exists from earlier)
exec_through_wrapper("bash", &repo, "switch", &["--create", "fix-auth"]);
// Create feature-api from main (simulating it already existed)
exec_through_wrapper("bash", &repo, "switch", &["--create", "feature-api"]);
// Switch to feature-api from fix-auth (showing navigation between worktrees)
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) {
// Create worktrees
exec_through_wrapper("bash", &repo, "switch", &["--create", "fix-auth"]);
exec_through_wrapper("bash", &repo, "switch", &["--create", "feature-api"]);
// Remove feature-api from within it (current worktree removal)
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) {
// Configure a post-start background command that will trigger progress output
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");
// Pre-approve the command
repo.write_test_approvals(
r#"[projects."../origin"]
approved-commands = ["echo 'background task'"]
"#,
);
let output = exec_through_wrapper("bash", &repo, "switch", &["--create", "feature-bg"]);
// No directives should leak
output.assert_no_directive_leaks();
output.assert_success();
// Snapshot verifies progress messages appear to users
// (catches the bug where progress() was incorrectly suppressed)
shell_wrapper_settings().bind(|| assert_snapshot!(&output.combined));
}
// ============================================================================
// Fish Shell Wrapper Tests
// ============================================================================
//
// These tests verify that the Fish shell wrapper correctly:
// 1. Captures stdout (shell script) via command substitution and evals it
// 2. Streams stderr (progress, success, hints) to terminal in real-time
// 3. Never leaks shell script commands to users
// 4. Preserves exit codes from both wt and executed commands
//
// Fish uses `string collect` to join command substitution output into
// a single string before eval (fish splits on newlines by default).
#[rstest]
fn test_fish_wrapper_preserves_progress_messages(repo: TestRepo) {
// Configure a post-start background command that will trigger progress output
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");
// Pre-approve the 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"]);
// No directives should leak
output.assert_no_directive_leaks();
output.assert_success();
// Snapshot verifies progress messages appear to users through Fish wrapper
shell_wrapper_settings().bind(|| assert_snapshot!(&output.combined));
}
#[rstest]
fn test_fish_multiline_command_execution(repo: TestRepo) {
// Shell behavior remains available when requested explicitly.
let multiline_cmd = "echo 'line 1'; echo 'line 2'; echo 'line 3'";
// Use --yes to skip approval prompt in tests
let output = exec_through_wrapper(
"fish",
&repo,
"switch",
&[
"--create",
"fish-multiline",
"--yes",
"--execute",
"fish",
"--",
"-c",
multiline_cmd,
],
);
// No directives should leak
output.assert_no_directive_leaks();
output.assert_success();
// All three lines should be executed and visible
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");
// Normalize paths in output for snapshot testing
shell_wrapper_settings().bind(|| assert_snapshot!(&output.combined));
}
#[rstest]
fn test_fish_wrapper_handles_empty_chunks(repo: TestRepo) {
// Test edge case: command that produces minimal output
// This verifies Fish's `test -n "$chunk"` check works correctly
let output = exec_through_wrapper("fish", &repo, "switch", &["--create", "fish-minimal"]);
// No directives should leak even with minimal output
output.assert_no_directive_leaks();
output.assert_success();
// Should still show success message
assert!(
output.combined.contains("Created branch") && output.combined.contains("and worktree"),
"Success message missing from minimal output"
);
// Normalize paths in output for snapshot testing
shell_wrapper_settings().bind(|| assert_snapshot!(&output.combined));
}
// ========================================================================
// --source Flag Error Passthrough Tests
// ========================================================================
//
// These tests verify that actual error messages pass through correctly
// when using the --source flag (instead of being hidden with generic
// wrapper error messages like "Error: cargo build failed").
// Note: Nushell not included - this test builds custom scripts with bash syntax
#[rstest]
#[case("bash")]
#[case("fish")]
fn test_source_flag_forwards_errors(#[case] shell: &str, repo: TestRepo) {
use std::env;
use std::os::unix::fs::PermissionsExt;
// Stub `cargo` instead of invoking the real one. The wrapper's
// `--source` branch shells out to `cargo run --bin wt --quiet --
// <args>`. Real cargo unlinks and re-links `target/debug/wt` on every
// invocation (~4% non-existence window measured locally), which
// races concurrent tests that `spawn(target/debug/wt)` and produces
// ENOENT flakes on Linux CI. The stub strips the cargo args and
// execs the existing wt binary directly — same end-to-end coverage
// of error pass-through through the `--source` branch, no race.
let stub_dir = repo.root_path().join("stub-bin");
fs::create_dir_all(&stub_dir).unwrap();
let stub_cargo = stub_dir.join("cargo");
fs::write(
&stub_cargo,
"#!/bin/sh\nshift 5\nexec \"$WORKTRUNK_BIN\" \"$@\"\n",
)
.unwrap();
fs::set_permissions(&stub_cargo, fs::Permissions::from_mode(0o755)).unwrap();
let stub_path = format!(
"{}:{}",
stub_dir.display(),
env::var("PATH").unwrap_or_default()
);
// Get the worktrunk source directory (where this test is running from)
// This is the directory that contains Cargo.toml with the workspace
let worktrunk_source = canonicalize(&env::current_dir().unwrap()).unwrap();
// Build a shell script that runs from the worktrunk source directory
let mut script = String::new();
append_wrapper_setup(&mut script, shell, &repo);
// Try to run wt --source with an invalid subcommand. The wrapper
// routes this through the stub `cargo`, which execs the real wt;
// wt then fails with clap's "unrecognized subcommand" error.
script.push_str("wt --source foo\n");
// Wrap in subshell to merge stderr
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![
("PATH", &stub_path),
("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"),
];
// Run non-interactively: this test only checks that the `--source`
// branch forwards wt's error, never job-control output. Skipping `-i`
// avoids the interactive-shell startup wedge that flaked this test.
let (combined, exit_code) = exec_in_pty_shell(
shell,
&final_script,
&worktrunk_source,
&env_vars,
&[],
false,
);
let output = ShellOutput {
combined,
exit_code,
};
// Shell-agnostic assertions
assert_ne!(output.exit_code, 0, "{}: Command should fail", shell);
// CRITICAL: Should see wt's actual error message about an unknown subcommand
assert!(
output.combined.contains("unrecognized subcommand"),
"{}: Should show clap's 'unrecognized subcommand' error.\nOutput:\n{}",
shell,
output.combined
);
// CRITICAL: Should NOT see the old generic wrapper error message
assert!(
!output.combined.contains("Error: cargo build failed"),
"{}: Should not contain old generic error message",
shell
);
// Consolidated snapshot - output should be identical across shells
// (wt error messages are deterministic)
shell_wrapper_settings().bind(|| {
insta::allow_duplicates! {
assert_snapshot!("source_flag_error_passthrough", &output.combined);
}
});
}
// ========================================================================
// Job Control Notification Tests
// ========================================================================
//
// These tests verify that job control notifications ([1] 12345, [1] + done)
// don't leak into user output. Zsh suppresses these with NO_MONITOR,
// bash shows them at the next prompt (less intrusive).
/// Test that zsh doesn't show job control notifications inline
/// The NO_MONITOR option should suppress [1] 12345 and [1] + done messages
#[rstest]
fn test_zsh_no_job_control_notifications(repo: TestRepo) {
// Configure a post-start command that will trigger background job
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");
// Pre-approve the 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();
// Critical: zsh should NOT show job control notifications
// These patterns indicate job control messages leaked through
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
);
}
/// Test that bash job control messages are suppressed in true interactive mode
///
/// Bash shows `[1]+ Done` notifications at prompt-time, not during script execution.
/// To detect if they leak, we use `exec_bash_truly_interactive` which runs bash without
/// `-c` and writes commands to the PTY, triggering prompts where notifications appear.
///
/// The shell wrapper suppresses these via two mechanisms (see bash.sh/zsh.zsh templates):
/// - START notifications (`[1] 12345`): stderr redirection around `&`
/// - DONE notifications (`[1]+ Done`): `set +m` before backgrounding
#[rstest]
fn test_bash_job_control_suppression(repo: TestRepo) {
// Configure a post-start command that will trigger background job
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");
// Pre-approve the command
repo.write_test_approvals(
r#"[projects."../origin"]
approved-commands = ["echo 'bash background'"]
"#,
);
// Build the setup script that defines the wt function
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"),
];
// Run wt at the prompt (where job notifications appear)
let (output, exit_code) = exec_bash_truly_interactive(
&setup_script,
"wt switch --create bash-job-test",
"and worktree",
repo.root_path(),
&env_vars,
);
assert_eq!(exit_code, 0, "Command should succeed.\nOutput:\n{}", output);
// Verify the command completed successfully
assert!(
output.contains("Created branch") && output.contains("and worktree"),
"Should show success message.\nOutput:\n{}",
output
);
// Verify no job control messages leak through.
// The shell wrapper suppresses both START notifications (`[1] 12345` via stderr
// redirection) and DONE notifications (`[1]+ Done` via `set +m`).
// This test uses true interactive mode to ensure we'd see them if they leaked.
assert!(
!JOB_CONTROL_REGEX.is_match(&output),
"Output contains job control messages (e.g., '[1] 12345' or '[1]+ Done'):\n{}",
output
);
}
// ========================================================================
// Completion Functionality Tests
// ========================================================================
/// Test that bash completions are properly registered
/// Note: Completions are inline in the wrapper script (lazy loading)
#[rstest]
fn test_bash_completions_registered(repo: TestRepo) {
// Use a marker file to avoid PTY output race conditions.
// PTY buffer flushing is unreliable on CI, so we write to a file and poll for it.
let marker_file = repo.root_path().join(".completions_test_marker");
let marker_path = marker_file.to_string_lossy().to_string();
// Script that sources wrapper and checks if completion is registered
// (completions are inline in the wrapper via lazy loading)
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);
// Poll for marker file instead of relying on PTY output
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
);
}
/// Test that fish completions are properly registered
#[rstest]
fn test_fish_completions_registered(repo: TestRepo) {
let completions_script = generate_completions(&repo, "fish");
// Use a marker file to avoid PTY output race conditions.
// PTY buffer flushing is unreliable on CI, so we write to a file and poll for it.
let marker_file = repo.root_path().join(".completions_test_marker");
let marker_path = marker_file.to_string_lossy().to_string();
// Script that sources wrapper, completions, and checks if completion is registered
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);
// Poll for marker file instead of relying on PTY output
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
);
}
/// Test that zsh wrapper function is properly defined
/// Note: Completions are inline in the wrapper script (lazy loading via compdef)
#[rstest]
fn test_zsh_wrapper_function_registered(repo: TestRepo) {
let wt_bin = wt_bin();
let wrapper_script = generate_wrapper(&repo, "zsh");
// Use a marker file to avoid PTY output race conditions.
// PTY buffer flushing is unreliable on CI, so we write to a file and poll for it.
let marker_file = repo.root_path().join(".wrapper_test_marker");
let marker_path = marker_file.to_string_lossy().to_string();
// Script that sources wrapper and checks if wt function exists
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);
// Poll for marker file instead of relying on PTY output
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
);
}
// ========================================================================
// WORKTRUNK_BIN Fallback Tests
// ========================================================================
/// Test that shell integration works when wt is not in PATH but WORKTRUNK_BIN is set
// Note: Nushell not included - this test builds custom scripts with bash syntax
#[rstest]
#[case("bash")]
#[case("fish")]
fn test_worktrunk_bin_fallback(#[case] shell: &str, repo: TestRepo) {
let wt_bin = wt_bin();
let wrapper_script = generate_wrapper(&repo, shell);
// Use shell_quote to handle paths with special chars (like single quotes)
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 path = crate::common::setup_minimal_path_with_git(&repo.home_path().join("test-bin"));
let path_quoted = shell_quote(&path);
// Script that explicitly removes wt from PATH but sets WORKTRUNK_BIN
let script = match shell {
"fish" => format!(
r#"
# Clear PATH to ensure wt is not found via PATH
set -x PATH {}
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"
"#,
path_quoted, wt_bin_quoted, config_quoted, approvals_quoted, wrapper_script
),
_ => format!(
r#"
# Clear PATH to ensure wt is not found via PATH
export PATH={}
export WORKTRUNK_BIN={}
export WORKTRUNK_CONFIG_PATH={}
export WORKTRUNK_APPROVALS_PATH={}
export CLICOLOR_FORCE=1
{}
wt switch --create fallback-test
echo "__PWD__ $PWD"
"#,
path_quoted, 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
);
// Verify we actually cd'd to the new worktree
assert!(
output.combined.contains("fallback-test"),
"{}: Should be in the new worktree directory.\nOutput:\n{}",
shell,
output.combined
);
}
/// Test that fish wrapper shows clear error when wt binary is not available
///
/// This tests the scenario where:
/// 1. User has shell integration installed (functions/wt.fish exists)
/// 2. But wt binary is not in PATH
/// 3. And WORKTRUNK_BIN is not set
///
/// The fish function should show "wt: command not found" and exit 127.
/// This is fish-specific because bash/zsh have an outer guard that prevents
/// the function from being defined when wt isn't available.
#[rstest]
#[case("fish")]
fn test_fish_binary_not_found_clear_error(#[case] shell: &str, repo: TestRepo) {
let wrapper_script = generate_wrapper(&repo, shell);
// Use a marker file for the exit code — PTY output capture can be empty on macOS
let marker_file = repo.root_path().join(".test-exit-code-marker");
// Script that clears PATH and does NOT set WORKTRUNK_BIN
// This simulates having the fish function installed but wt not available
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,
};
// PRIMARY CHECK: Verify exit code 127 via marker file (reliable on all platforms)
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
);
// TODO(macos-pty): PTY output capture for fish returns empty on macOS, so we
// can only assert the error message on Linux. We'd like to re-enable this on
// macOS once the underlying PTY issue is resolved. See #1268.
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
);
}
}
/// Test that fish WRAPPER (bootstrap) handles missing binary gracefully
///
/// This tests the WRAPPER file (fish_wrapper.fish) that gets installed to
/// ~/.config/fish/functions/wt.fish. Unlike the full function (tested above),
/// the wrapper tries to SOURCE the full function from the binary at runtime.
///
/// When wt isn't in PATH:
/// - `command wt config shell init fish` fails
/// - The wrapper should return 127, NOT infinite loop
///
/// This is different from test_fish_binary_not_found_clear_error which tests
/// the FULL function (which has its own WORKTRUNK_BIN check).
#[rstest]
#[case("fish")]
fn test_fish_wrapper_binary_not_found_no_infinite_loop(#[case] shell: &str, repo: TestRepo) {
// Use the WRAPPER template (not the full function from generate_wrapper)
let init = shell::ShellInit::with_prefix(shell::Shell::Fish, "wt".to_string());
let wrapper_content = init.generate_fish_wrapper().unwrap();
// Create a marker file path to prove the script completed (didn't infinite loop)
let marker_file = repo.root_path().join(".test-completed-marker");
// Script that clears PATH so wt isn't found, then calls wt.
// The marker file is written AFTER the wt call to prove we didn't infinite loop.
// We capture the exit status before writing the marker so it's preserved.
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, &[]);
// PRIMARY CHECK: The marker file must exist, proving the script completed
// (didn't get stuck in an infinite loop). This is reliable even when PTY
// output capture fails on macOS.
assert!(
marker_file.exists(),
"Fish wrapper infinite looped (marker file not created).\n\
Exit code: {}\nOutput:\n{}",
exit_code,
combined
);
// Read the exit status from the marker file. We use this rather than the PTY's
// exit_code because PTY layer behavior can differ from the shell's $status.
let marker_content = fs::read_to_string(&marker_file).unwrap_or_default();
let marker_exit_code: i32 = marker_content.trim().parse().unwrap_or(-1);
// Verify exit code 127 (command not found)
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
);
// SECONDARY CHECK: When output is available, verify no infinite recursion signs.
// One occurrence of "in function 'wt'" is normal (fish's error trace).
// Infinite recursion would show this MANY times.
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
);
}
}
// ========================================================================
// README Example Tests (PTY-based for interleaved output)
// ========================================================================
//
// These tests generate snapshots for README.md examples. They use PTY execution
// to capture stdout/stderr interleaved in the order users see them.
//
// See tests/CLAUDE.md for background on why PTY-based tests are needed for README examples.
/// README example: Pre-merge hooks with squash and LLM commit message
///
/// This test demonstrates:
/// - Multiple commits being squashed with LLM commit message
/// - Pre-merge hooks (test, lint) running before merge
///
/// Source: tests/snapshots/shell_wrapper__tests__readme_example_hooks_pre_merge.snap
#[rstest]
fn test_readme_example_hooks_pre_merge(mut repo: TestRepo) {
// Create project config with pre-merge hooks
let config_dir = repo.root_path().join(".config");
fs::create_dir_all(&config_dir).unwrap();
// Create mock commands for realistic output
let bin_dir = repo.root_path().join(".bin");
fs::create_dir_all(&bin_dir).unwrap();
// Mock pytest command
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();
// Mock ruff command
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();
// Mock llm command for commit message
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();
// Mock uv command for running pytest and ruff
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();
// Make scripts executable (Unix only)
#[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();
// Commit the config
repo.run_git(&["add", ".config/wt.toml", ".bin"]);
repo.run_git(&["commit", "-m", "Add pre-merge hooks"]);
// Create a feature worktree and make multiple commits
let feature_wt = repo.add_worktree("feature-auth");
// First commit - create initial auth.py with login endpoint
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"]);
// Second commit - add tests
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"]);
// Third commit - add refresh endpoint
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"]);
// Configure LLM in worktrunk config
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);
// Set PATH with mock binaries and run merge
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));
}
/// README example: Creating worktree with pre-start and post-start hooks
///
/// This test demonstrates:
/// - Pre-start hooks (install dependencies)
/// - Post-start hooks (start dev server)
///
/// Uses shell wrapper to avoid "To enable automatic cd" hint.
///
/// Source: tests/snapshots/shell_wrapper__tests__readme_example_hooks_pre_start.snap
#[rstest]
fn test_readme_example_hooks_pre_start(repo: TestRepo) {
// Create project config with pre-start and post-start hooks
let config_dir = repo.root_path().join(".config");
fs::create_dir_all(&config_dir).unwrap();
// Create mock commands for realistic output
let bin_dir = repo.root_path().join(".bin");
fs::create_dir_all(&bin_dir).unwrap();
// Mock uv command that simulates dependency installation
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();
// Make scripts executable (Unix only)
#[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();
// Commit the config
repo.run_git(&["add", ".config/wt.toml", ".bin"]);
repo.run_git(&["commit", "-m", "Add project hooks"]);
// Set PATH with mock binaries and run switch --create
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));
}
/// README example: approval prompt for pre-start commands
/// This test captures just the prompt (before responding) to show what users see.
///
/// Note: This uses direct PTY execution (not shell wrapper) because interactive prompts
/// require direct stdin access. The shell wrapper approach detects non-interactive mode.
/// The shell integration hint is truncated from the output.
#[rstest]
fn test_readme_example_approval_prompt(repo: TestRepo) {
use portable_pty::CommandBuilder;
use std::io::Write;
// Remove origin so worktrunk uses directory name as project identifier
repo.run_git(&["remote", "remove", "origin"]);
// Create project config with named pre-start commands
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());
// Set environment
crate::common::configure_pty_command(&mut cmd);
for (key, value) in repo.test_env_vars() {
cmd.env(key, value);
}
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();
// Send "n" to decline and complete the command
writer.write_all(b"n\n").unwrap();
writer.flush().unwrap();
drop(writer);
// Read all output
let buf = crate::common::pty::read_pty_master_to_string(&mut reader);
child.wait().unwrap();
// Normalize: strip ANSI codes and control characters
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();
// Remove ^D and backspaces (macOS PTY artifacts)
let ctrl_d_regex = regex::Regex::new(r"\^D\x08+").unwrap();
let output = ctrl_d_regex.replace_all(&output, "").to_string();
// Normalize paths (local regexes since we're extracting content, not snapshotting)
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"
);
// Extract just the prompt portion (from "🟡" to "[y/N]")
// This removes the echoed input at the start and anything after the 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);
}
/// Black-box test: bash completion is registered and produces correct output.
///
/// This test verifies completion works WITHOUT knowing internal function names.
/// It uses `complete -p wt` to discover whatever completion function is registered,
/// then calls it via shell completion machinery.
///
/// This catches bugs like:
/// - Completion not registered at all
/// - Completion function not loading (lazy loading broken)
/// - Completion output being executed as commands (the COMPLETE mode bug)
#[rstest]
fn test_bash_completion_produces_correct_output(repo: TestRepo) {
let wt_bin = wt_bin();
let wt_bin_dir = wt_bin.parent().unwrap();
// Generate wrapper without WORKTRUNK_BIN (simulates installed wt)
let output = std::process::Command::new(&wt_bin)
.args(["config", "shell", "init", "bash"])
.output()
.unwrap();
let wrapper_script = String::from_utf8_lossy(&output.stdout);
// Black-box test: don't reference internal function names
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 buf = crate::common::pty::read_pty_master_to_string(&mut reader);
let status = child.wait().unwrap();
let output = buf.replace("\r\n", "\n");
// Verify no "command not found" error (the COMPLETE mode bug)
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
);
}
/// Black-box test: zsh completion is registered and produces correct output.
///
/// This test verifies completion works WITHOUT knowing internal function names.
/// It checks that a completion is registered for 'wt' and that calling the
/// wt command with COMPLETE=zsh produces completion candidates.
#[rstest]
fn test_zsh_completion_produces_correct_output(repo: TestRepo) {
let wt_bin = wt_bin();
let wt_bin_dir = wt_bin.parent().unwrap();
// Generate wrapper without WORKTRUNK_BIN (simulates installed wt)
let output = std::process::Command::new(&wt_bin)
.args(["config", "shell", "init", "zsh"])
.output()
.unwrap();
let wrapper_script = String::from_utf8_lossy(&output.stdout);
// Black-box test: don't reference internal function names
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 buf = crate::common::pty::read_pty_master_to_string(&mut reader);
let status = child.wait().unwrap();
let output = buf.replace("\r\n", "\n");
// Verify no "command not found" error (the COMPLETE mode bug)
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
);
}
/// Build a hermetic PATH for completion tests.
///
/// Creates a temp dir with a symlink to the `wt` binary, then builds PATH
/// from that dir + system dirs (excluding cargo target directories). This
/// prevents co-built binaries like `wt-perf` from leaking into completion
/// output as custom subcommands.
///
/// NOTE: passing this via `.env("PATH", ...)` is not enough when spawning
/// a shell whose startup files mutate PATH: a typical `~/.zshenv` sources
/// `~/.cargo/env` (even for non-interactive `zsh -c`), and `~/.bashrc` or
/// `~/.bash_profile` can do the same. Invoke the shell with its
/// rc-bypass flag (`zsh -f`, `bash --noprofile --norc`) alongside the
/// PATH override. `/etc/zshenv` is always sourced and cannot be bypassed,
/// but it doesn't typically touch PATH on the environments we care about.
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();
// Only include the symlink dir + essential system dirs. This prevents
// any user-installed `wt-*` binaries (e.g. ~/.cargo/bin/wt-sync) or
// co-compiled helpers (target/debug/wt-perf) from appearing.
let path = format!("{}:/usr/bin:/bin:/usr/sbin:/sbin", dir.path().display());
(dir, path)
}
/// Point the spawned `wt` (and its child shells) at empty user, system,
/// and project configs so aliases don't leak into completion output.
/// Aliases surface as top-level completion candidates, so without this
/// isolation any aliases in the developer's
/// `~/.config/worktrunk/config.toml`, a system config, or this repo's own
/// `.config/wt.toml` would pollute the snapshot.
fn set_empty_configs(cmd: &mut std::process::Command) {
cmd.env("WORKTRUNK_CONFIG_PATH", "/dev/null");
cmd.env("WORKTRUNK_SYSTEM_CONFIG_PATH", "/dev/null");
cmd.env("WORKTRUNK_PROJECT_CONFIG_PATH", "/dev/null");
}
/// Black-box test: zsh completion produces correct subcommands.
///
/// Sources actual `wt config shell init zsh`, triggers completion, snapshots result.
#[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);
// Override _describe to print completions (it normally writes to zsh's internal state)
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
"#
);
// Filter PATH to exclude cargo target directories so `wt-perf` (test
// helper) doesn't leak into completion output as a custom subcommand.
let (_dir, clean_path) = completion_test_path(&wt_bin);
// `-f` skips ~/.zshenv (which typically sources ~/.cargo/env and
// re-prepends ~/.cargo/bin). `/etc/zshenv` is still read — it can't
// be bypassed — but doesn't touch PATH in our test environments.
let mut cmd = std::process::Command::new("zsh");
cmd.args(["-f", "-c"]).arg(&script).env("PATH", &clean_path);
set_empty_configs(&mut cmd);
let output = cmd.output().unwrap();
assert_snapshot!(String::from_utf8_lossy(&output.stdout));
}
/// Black-box test: bash completion produces correct subcommands.
///
/// Sources actual `wt config shell init bash`, triggers completion, snapshots result.
#[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);
// `--noprofile --norc` skips ~/.bash_profile, ~/.bashrc, /etc/profile
// so our clean PATH isn't polluted with ~/.cargo/bin etc.
let mut cmd = std::process::Command::new("bash");
cmd.args(["--noprofile", "--norc", "-c"])
.arg(&script)
.env("PATH", &clean_path);
set_empty_configs(&mut cmd);
let output = cmd.output().unwrap();
assert_snapshot!(String::from_utf8_lossy(&output.stdout));
}
/// Black-box test: fish completion produces correct subcommands.
///
/// Fish completions call binary with COMPLETE=fish (separate from init script).
#[test]
fn test_fish_completion_subcommands() {
let wt_bin = wt_bin();
let (_dir, clean_path) = completion_test_path(&wt_bin);
let mut cmd = std::process::Command::new(&wt_bin);
cmd.args(["--", "wt", ""])
.env("COMPLETE", "fish")
.env("_CLAP_COMPLETE_INDEX", "1")
.env("PATH", &clean_path);
set_empty_configs(&mut cmd);
let output = cmd.output().unwrap();
// Fish format is "value\tdescription" - extract just values
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);
}
/// Black-box test: nushell completion produces correct subcommands.
///
/// Nushell completions call binary with COMPLETE=nu (same protocol as fish).
#[test]
fn test_nushell_completion_subcommands() {
let wt_bin = wt_bin();
let (_dir, clean_path) = completion_test_path(&wt_bin);
let mut cmd = std::process::Command::new(&wt_bin);
cmd.args(["--", "wt", ""])
.env("COMPLETE", "nu")
.env("PATH", &clean_path);
set_empty_configs(&mut cmd);
let output = cmd.output().unwrap();
// Nushell format is "value\tdescription" - extract just values
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);
}
/// Shell integration test: completing a custom subcommand's flags
/// forwards the request to the `wt-*` binary on PATH.
///
/// Places a `wt-fake` script in the hermetic PATH, then triggers
/// `wt fake --<tab>` via the fish completion protocol. Verifies that:
/// 1. The `wt-fake` script's output appears (forwarding works)
/// 2. `_CLAP_COMPLETE_INDEX` is decremented by 1 (index adjustment)
#[test]
fn test_fish_completion_forwards_to_custom() {
use std::os::unix::fs::PermissionsExt;
let wt_bin = wt_bin();
let (dir, clean_path) = completion_test_path(&wt_bin);
// Place a wt-fake script that outputs distinctive completions and
// echoes the received _CLAP_COMPLETE_INDEX so we can verify adjustment.
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();
// Fish protocol: `wt -- wt fake --` with COMPLETE=fish.
// _CLAP_COMPLETE_INDEX=2 (completing the 3rd word "—" in "wt fake --").
// The forwarding code should detect "fake" is external, forward to
// wt-fake, and decrement the index to 1.
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 the `wt-fake` script output: {stdout}"
);
assert!(
stdout.contains("idx:1"),
"_CLAP_COMPLETE_INDEX should be adjusted from 2 to 1: {stdout}"
);
}
/// Shell integration test: bash completion forwards to custom subcommands.
///
/// Sources the real `wt config shell init bash` output, places a `wt-fake`
/// script on the hermetic PATH, and triggers `wt fake --<tab>` through
/// bash's completion machinery.
#[test]
fn test_bash_completion_forwards_to_custom() {
use std::os::unix::fs::PermissionsExt;
let wt_bin = wt_bin();
let (dir, clean_path) = completion_test_path(&wt_bin);
// Place a wt-fake script that outputs completions
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);
// Trigger completion for "wt fake --" through bash's completion system.
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}"
);
}
// ========================================================================
// Stderr/Stdout Redirection Tests
// ========================================================================
//
// These tests verify that output redirection works correctly through the
// shell wrapper. When a user runs `wt --help &> file`, ALL output should
// go to the file - nothing should leak to the terminal.
//
// This is particularly important for fish where command substitution `(...)`
// doesn't propagate stderr redirects from the calling function.
/// Test that `wt --help &> file` redirects all output to the file.
///
/// This test verifies that stderr redirection works correctly through the
/// shell wrapper. The issue being tested: in some shells (particularly fish),
/// command substitution doesn't propagate stderr redirects, causing help
/// output to appear on the terminal even when redirected.
// Note: Nushell not included - this test builds custom scripts with bash syntax
#[rstest]
#[case("bash")]
#[case("fish")]
fn test_wrapper_help_redirect_captures_all_output(#[case] shell: &str, repo: TestRepo) {
let wt_bin = wt_bin();
let wt_bin_dir = wt_bin.parent().unwrap();
// Create a temp file for the redirect target
let tmp_dir = tempfile::tempdir().unwrap();
let redirect_file = tmp_dir.path().join("subprocess.log");
let redirect_path = redirect_file.display().to_string();
// Generate wrapper script
let output = std::process::Command::new(&wt_bin)
.args(["config", "shell", "init", shell])
.output()
.unwrap();
let wrapper_script = String::from_utf8_lossy(&output.stdout);
// Build shell script that:
// 1. Sources the wrapper
// 2. Runs `wt --help &> file`
// 3. Echoes a marker so we know the script completed
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,
),
_ => 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 buf = crate::common::pty::read_pty_master_to_string(&mut reader);
let _status = child.wait().unwrap();
let terminal_output = buf.replace("\r\n", "\n");
// Read the redirect file
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
)
});
// Verify script completed
assert!(
terminal_output.contains("SCRIPT_COMPLETED"),
"{}: Script did not complete successfully.\nTerminal output:\n{}",
shell,
terminal_output
);
// Verify help content went to the file
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
);
// Verify help content did NOT leak to the terminal
// We check for specific help markers that shouldn't appear on terminal
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
);
}
}
}
/// Test that interactive `wt --help` uses a pager.
///
/// This is the complement to `test_wrapper_help_redirect_captures_all_output`:
/// - Redirect case (`&>file`): pager should be SKIPPED (output goes to file)
/// - Interactive case (no redirect): pager should be USED
///
/// We verify pager invocation by setting GIT_PAGER to a script that creates
/// a marker file before passing through the content.
// Note: Nushell not included - this test builds custom scripts with bash syntax
#[rstest]
#[case("bash")]
#[case("fish")]
fn test_wrapper_help_interactive_uses_pager(#[case] shell: &str, repo: TestRepo) {
let wt_bin = wt_bin();
let wt_bin_dir = wt_bin.parent().unwrap();
// Create temp dir for marker file and pager script
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");
// Create a pager script that:
// 1. Creates a marker file to prove it was invoked
// 2. Passes stdin through to stdout (like cat)
fs::write(
&pager_script,
format!("#!/bin/sh\ntouch '{}'\ncat\n", marker_file.display()),
)
.unwrap();
// Make script executable
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&pager_script, fs::Permissions::from_mode(0o755)).unwrap();
}
// Generate wrapper script
let output = std::process::Command::new(&wt_bin)
.args(["config", "shell", "init", shell])
.output()
.unwrap();
let wrapper_script = String::from_utf8_lossy(&output.stdout);
// Build shell script that sources wrapper and runs help interactively
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,
),
_ => 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 buf = crate::common::pty::read_pty_master_to_string(&mut reader);
let _status = child.wait().unwrap();
let terminal_output = buf.replace("\r\n", "\n");
// Verify script completed
assert!(
terminal_output.contains("SCRIPT_COMPLETED"),
"{}: Script did not complete successfully.\nTerminal output:\n{}",
shell,
terminal_output
);
// Verify pager was invoked (marker file should exist)
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
);
}
}
// =============================================================================
// Windows PowerShell Tests
// =============================================================================
//
// All Windows-specific tests are in this module, gated by #[cfg(windows)].
// This keeps platform-specific tests clearly separated.
#[cfg(windows)]
mod windows_tests {
use super::*;
use crate::common::repo;
use rstest::rstest;
// ConPTY Output Limitation (2026-01):
//
// The `test_powershell_*` wrapper tests are marked #[ignore] because ConPTY
// output is not captured when the host process (cargo test) has its stdout
// redirected. This is a known Windows limitation documented in:
// https://github.com/microsoft/terminal/issues/11276
//
// The simplified PowerShell template (`& $wtBin @Arguments`) works correctly
// in normal terminal usage. Only the test harness is affected because cargo
// test redirects stdout to capture test output.
//
// MANUAL VERIFICATION (2026-01):
// The PowerShell wrapper was hand-tested on macOS using PowerShell Core (pwsh):
// - Wrapper function registration works
// - `wt list`, `wt --version` work correctly
// - `wt switch --create` creates worktree, runs hooks, and changes directory
// - Error handling returns correct exit codes
// - `wt remove` works correctly
// The wrapper logic is sound; only the CI test harness has the ConPTY issue.
//
// TODO: Re-enable these tests if a workaround for ConPTY stdout capture is found.
//
// The `test_conpty_*` diagnostic tests still run because they test direct
// command execution without the shell wrapper.
// ConPTY Handling Notes (2026-01):
//
// ConPTY behaves differently from Unix PTYs:
// - Output pipe doesn't close when child exits (owned by pseudoconsole)
// - ClosePseudoConsole must be called on separate thread while draining output
// - Cursor position requests (ESC[6n) MUST be answered or console hangs
//
// Our implementation in tests/common/pty.rs handles this by:
// 1. Keeping writer alive to respond to cursor queries
// 2. Reading in chunks (not read_to_string)
// 3. Detecting ESC[6n and responding with ESC[1;1R
// 4. Closing master on separate thread while continuing to drain
//
// References:
// - https://learn.microsoft.com/en-us/windows/console/closepseudoconsole
// - https://github.com/microsoft/terminal/discussions/17716
/// Diagnostic test: Verify basic ConPTY functionality works with our cursor response handling.
/// This test runs cmd.exe which is simpler than PowerShell and validates the core ConPTY fix.
#[test]
fn test_conpty_basic_cmd() {
use crate::common::pty::{build_pty_command, exec_cmd_in_pty};
// Use cmd.exe for simplest possible test
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);
// Accept exit code 0 or check for expected output
// On ConPTY, we should now get the output without blocking
assert!(
output.contains("CONPTY_WORKS") || exit_code == 0,
"ConPTY basic test should work. Output: {}, Exit: {}",
output,
exit_code
);
}
/// Diagnostic test: Verify wt --version works via ConPTY.
#[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);
// wt --version should exit 0 and contain version info
assert_eq!(
exit_code, 0,
"wt --version should succeed. Output: {}",
output
);
assert!(
output.contains("wt") || output.contains("worktrunk"),
"Should contain version info. Output: {}",
output
);
}
/// Diagnostic test: Verify basic PowerShell execution works via PTY.
#[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();
// Set minimal Windows env vars
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
);
}
/// Test that PowerShell shell integration works for switch --create
#[rstest]
#[ignore = "ConPTY output not captured when cargo test redirects stdout"]
fn test_powershell_switch_create(repo: TestRepo) {
// Debug: print the script being generated
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
);
}
/// Test that PowerShell shell integration handles command failures correctly
#[rstest]
#[ignore = "ConPTY output not captured when cargo test redirects stdout"]
fn test_powershell_command_failure(mut repo: TestRepo) {
// Create a worktree that already exists
repo.add_worktree("existing");
// Try to create it again - should fail
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
);
}
/// Test that PowerShell shell integration works for remove
#[rstest]
#[ignore = "ConPTY output not captured when cargo test redirects stdout"]
fn test_powershell_remove(mut repo: TestRepo) {
// Create a worktree to remove
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();
}
/// Test that PowerShell shell integration works for wt list
#[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();
// Should show the main worktree
assert!(
output.combined.contains("main"),
"PowerShell: Should show main branch.\nOutput:\n{}",
output.combined
);
}
/// Test that PowerShell correctly propagates exit codes from --execute commands
#[rstest]
#[ignore = "ConPTY output not captured when cargo test redirects stdout"]
fn test_powershell_execute_exit_code_propagation(repo: TestRepo) {
// Create a worktree with --execute that exits with a specific code
let output = exec_through_wrapper(
"powershell",
&repo,
"switch",
&[
"--create",
"feature",
"--execute",
"pwsh",
"--",
"-NoProfile",
"-Command",
"exit 42",
],
);
// The wrapper should propagate the exit code from the executed command
assert_eq!(
output.exit_code, 42,
"PowerShell: Should propagate exit code 42 from --execute.\nOutput:\n{}",
output.combined
);
output.assert_no_directive_leaks();
}
/// Test that PowerShell handles branch names with slashes correctly
#[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();
// Verify the worktree was created with sanitized name
assert!(
output.combined.contains("feature/auth") || output.combined.contains("feature-auth"),
"PowerShell: Should show branch name.\nOutput:\n{}",
output.combined
);
}
/// Test that PowerShell handles branch names with dashes and underscores
#[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();
}
/// Test that PowerShell wrapper function is properly registered
#[rstest]
#[ignore = "ConPTY output not captured when cargo test redirects stdout"]
fn test_powershell_wrapper_function_registered(repo: TestRepo) {
// Test that the wrapper function is defined by checking if it exists
let wt_bin = wt_bin();
let wrapper_script = generate_wrapper(&repo, "powershell");
// Build a script that sources the wrapper and checks if wt is a function
// Note: powershell_quote adds single quotes, so don't add them in the format string
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
);
}
/// Test that PowerShell completion is registered
#[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");
// Build a script that sources the wrapper and checks for completion
// Note: powershell_quote adds single quotes, so don't add them in the format string
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, &[]);
// Completion registration might fail silently if COMPLETE env handling differs
// Just verify the wrapper loaded without errors
assert!(
exit_code == 0 || combined.contains("COMPLETION"),
"PowerShell: Should attempt completion registration.\nOutput:\n{}",
combined
);
}
/// Test that PowerShell step for-each works across worktrees
#[rstest]
#[ignore = "ConPTY output not captured when cargo test redirects stdout"]
fn test_powershell_step_for_each(mut repo: TestRepo) {
// Create multiple worktrees
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();
}
/// Test that PowerShell handles help output correctly
#[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();
// Should show usage information
assert!(
output.combined.contains("Usage:") || output.combined.contains("USAGE:"),
"PowerShell: Should show usage in help.\nOutput:\n{}",
output.combined
);
}
/// Test that PowerShell preserves WORKTRUNK_BIN environment variable
#[rstest]
#[ignore = "ConPTY output not captured when cargo test redirects stdout"]
fn test_powershell_worktrunk_bin_env(repo: TestRepo) {
// This tests the fix we just made - WORKTRUNK_BIN should be used
let wt_bin = wt_bin();
let wrapper_script = generate_wrapper(&repo, "powershell");
// Script that prints which binary would be used
// Note: powershell_quote adds single quotes, so don't add them in the format string
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
);
}
/// Test that PowerShell merge command works
#[rstest]
#[ignore = "ConPTY output not captured when cargo test redirects stdout"]
fn test_powershell_merge(mut repo: TestRepo) {
// Create a feature branch worktree
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();
}
/// Test that PowerShell switch with execute works
#[rstest]
#[ignore = "ConPTY output not captured when cargo test redirects stdout"]
fn test_powershell_switch_with_execute(repo: TestRepo) {
// Use --yes to skip approval prompt
let output = exec_through_wrapper(
"powershell",
&repo,
"switch",
&[
"--create",
"test-exec",
"--yes",
"--execute",
"pwsh",
"--",
"-NoProfile",
"-Command",
"Write-Host 'executed'",
],
);
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
);
}
/// Test PowerShell switch to existing worktree (no --create)
#[rstest]
#[ignore = "ConPTY output not captured when cargo test redirects stdout"]
fn test_powershell_switch_existing(mut repo: TestRepo) {
// First create a worktree
repo.add_worktree("existing-feature");
// Now switch to it without --create
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();
}
/// Test PowerShell with --format json output
#[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();
// JSON output should be parseable (contains array brackets)
assert!(
output.combined.contains('[') && output.combined.contains(']'),
"PowerShell: Should output JSON array.\nOutput:\n{}",
output.combined
);
}
/// Test PowerShell config show command
#[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();
}
/// Test PowerShell version command
#[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();
// Should contain version number
assert!(
output.combined.contains("wt ") || output.combined.contains("worktrunk"),
"PowerShell: Should show version info.\nOutput:\n{}",
output.combined
);
}
/// Test that PowerShell suppresses shell integration hint when running through wrapper
#[rstest]
#[ignore = "ConPTY output not captured when cargo test redirects stdout"]
fn test_powershell_shell_integration_hint_suppressed(repo: TestRepo) {
// When running through the shell wrapper, the "To enable automatic cd" hint
// should NOT appear because the user already has shell integration
let output = exec_through_wrapper("powershell", &repo, "switch", &["--create", "ps-test"]);
// Critical: shell integration hint must be suppressed when shell integration is active
assert!(
!output.combined.contains("To enable automatic cd"),
"PowerShell: Shell integration hint should not appear when running through wrapper.\nOutput:\n{}",
output.combined
);
// Should still have the success message
assert!(
output.combined.contains("Created branch") && output.combined.contains("worktree"),
"PowerShell: Success message missing.\nOutput:\n{}",
output.combined
);
}
/// Test PowerShell switch from one worktree to another
#[rstest]
#[ignore = "ConPTY output not captured when cargo test redirects stdout"]
fn test_powershell_switch_between_worktrees(mut repo: TestRepo) {
// Create two worktrees
repo.add_worktree("feature-first");
repo.add_worktree("feature-second");
// Switch from main to feature-first
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();
}
/// Test PowerShell with long branch names
#[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();
}
/// Test PowerShell remove with branch name argument
#[rstest]
#[ignore = "ConPTY output not captured when cargo test redirects stdout"]
fn test_powershell_remove_by_name(mut repo: TestRepo) {
// Create a worktree
repo.add_worktree("to-delete");
// Remove it by name
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();
}
/// Test PowerShell list with verbose output
///
/// NOTE: This test is ignored due to a ConPTY race condition where the output pipe
/// doesn't properly close when the child exits. The --verbose flag produces enough
/// output to trigger this race. Other PowerShell tests pass because they produce
/// less output. This is a known limitation of ConPTY - see Microsoft docs on
/// ClosePseudoConsole for background.
#[rstest]
#[ignore = "ConPTY output not captured when cargo test redirects stdout"]
fn test_powershell_list_verbose(mut repo: TestRepo) {
// Create a worktree
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();
}
/// Test PowerShell config shell init output
#[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();
// Should output PowerShell init script
assert!(
output.combined.contains("function") || output.combined.contains("WORKTRUNK"),
"PowerShell: Should output shell init script.\nOutput:\n{}",
output.combined
);
}
/// Test PowerShell handles missing branch gracefully
#[rstest]
#[ignore = "ConPTY output not captured when cargo test redirects stdout"]
fn test_powershell_switch_nonexistent_branch(repo: TestRepo) {
// Try to switch to a branch that doesn't exist (without --create)
let output = exec_through_wrapper("powershell", &repo, "switch", &["nonexistent-branch"]);
// Should fail with appropriate error
assert_ne!(
output.exit_code, 0,
"PowerShell: switch to nonexistent branch should fail.\nOutput:\n{}",
output.combined
);
output.assert_no_directive_leaks();
}
/// Test PowerShell step next command
#[rstest]
#[ignore = "ConPTY output not captured when cargo test redirects stdout"]
fn test_powershell_step_next(mut repo: TestRepo) {
// Create worktrees to step through
repo.add_worktree("step-1");
repo.add_worktree("step-2");
let output = exec_through_wrapper("powershell", &repo, "step", &["next"]);
// Step next might succeed or indicate nothing to step to
output.assert_no_directive_leaks();
}
/// Test PowerShell step prev command
#[rstest]
#[ignore = "ConPTY output not captured when cargo test redirects stdout"]
fn test_powershell_step_prev(mut repo: TestRepo) {
// Create worktrees
repo.add_worktree("prev-1");
repo.add_worktree("prev-2");
let output = exec_through_wrapper("powershell", &repo, "step", &["prev"]);
// Step prev might succeed or indicate nothing to step to
output.assert_no_directive_leaks();
}
/// Test PowerShell handles paths with spaces (common on Windows)
/// Note: This test creates a branch name, not a path with spaces
/// Path with spaces handling is tested implicitly via temp directories
#[rstest]
#[ignore = "ConPTY output not captured when cargo test redirects stdout"]
fn test_powershell_special_branch_name(repo: TestRepo) {
// Test a branch name with various special characters
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();
}
/// Test PowerShell hook show command
#[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();
}
}