use std::ffi::{OsStr, OsString};
use std::io::{ErrorKind, Read, Write};
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::sync::OnceLock;
use std::time::Instant;
use wait_timeout::ChildExt;
use crate::git::{GitError, WorktrunkError};
use crate::sync::Semaphore;
static CMD_SEMAPHORE: OnceLock<Semaphore> = OnceLock::new();
static TRACE_EPOCH: OnceLock<Instant> = OnceLock::new();
fn trace_epoch() -> &'static Instant {
TRACE_EPOCH.get_or_init(Instant::now)
}
const DEFAULT_CONCURRENT_COMMANDS: usize = 32;
fn parse_concurrent_limit(value: &str) -> Option<usize> {
value
.parse::<usize>()
.ok()
.map(|n| if n == 0 { usize::MAX } else { n })
}
fn max_concurrent_commands() -> usize {
std::env::var("WORKTRUNK_MAX_CONCURRENT_COMMANDS")
.ok()
.and_then(|s| parse_concurrent_limit(&s))
.unwrap_or(DEFAULT_CONCURRENT_COMMANDS)
}
fn semaphore() -> &'static Semaphore {
CMD_SEMAPHORE.get_or_init(|| Semaphore::new(max_concurrent_commands()))
}
static SHELL_CONFIG: OnceLock<Result<ShellConfig, String>> = OnceLock::new();
#[derive(Debug, Clone)]
pub struct ShellConfig {
pub executable: PathBuf,
pub args: Vec<String>,
pub is_posix: bool,
pub name: String,
}
impl ShellConfig {
pub fn get() -> anyhow::Result<&'static ShellConfig> {
SHELL_CONFIG
.get_or_init(detect_shell)
.as_ref()
.map_err(|e| anyhow::anyhow!("{e}"))
}
pub fn command(&self, shell_command: &str) -> Command {
let mut cmd = Command::new(&self.executable);
for arg in &self.args {
cmd.arg(arg);
}
cmd.arg(shell_command);
cmd
}
pub fn is_posix(&self) -> bool {
self.is_posix
}
}
fn detect_shell() -> Result<ShellConfig, String> {
#[cfg(unix)]
{
Ok(ShellConfig {
executable: PathBuf::from("sh"),
args: vec!["-c".to_string()],
is_posix: true,
name: "sh".to_string(),
})
}
#[cfg(windows)]
{
detect_windows_shell()
}
}
#[cfg(windows)]
fn detect_windows_shell() -> Result<ShellConfig, String> {
if let Some(bash_path) = find_git_bash() {
return Ok(ShellConfig {
executable: bash_path,
args: vec!["-c".to_string()],
is_posix: true,
name: "Git Bash".to_string(),
});
}
Err("Git for Windows is required but not found.\n\
Install from https://git-scm.com/download/win"
.to_string())
}
#[cfg(windows)]
fn find_git_bash() -> Option<PathBuf> {
if let Ok(git_path) = which::which("git") {
if let Some(git_dir) = git_path.parent().and_then(|p| p.parent()) {
let bash_path = git_dir.join("bin").join("bash.exe");
if bash_path.exists() {
return Some(bash_path);
}
let bash_path = git_dir.join("usr").join("bin").join("bash.exe");
if bash_path.exists() {
return Some(bash_path);
}
}
}
let bash_path = PathBuf::from(r"C:\Program Files\Git\bin\bash.exe");
if bash_path.exists() {
return Some(bash_path);
}
if let Ok(local_app_data) = std::env::var("LOCALAPPDATA") {
let bash_path = PathBuf::from(local_app_data)
.join("Programs")
.join("Git")
.join("bin")
.join("bash.exe");
if bash_path.exists() {
return Some(bash_path);
}
}
None
}
pub const DIRECTIVE_FILE_ENV_VAR: &str = "WORKTRUNK_DIRECTIVE_FILE";
use std::cell::Cell;
use std::time::Duration;
thread_local! {
static COMMAND_TIMEOUT: Cell<Option<Duration>> = const { Cell::new(None) };
}
pub fn set_command_timeout(timeout: Option<Duration>) {
COMMAND_TIMEOUT.with(|t| t.set(timeout));
}
pub fn trace_instant(event: &str) {
let ts = Instant::now().duration_since(*trace_epoch()).as_micros() as u64;
let tid = thread_id_number();
log::debug!("[wt-trace] ts={} tid={} event=\"{}\"", ts, tid, event);
}
fn thread_id_number() -> u64 {
let thread_id = std::thread::current().id();
let debug_str = format!("{:?}", thread_id);
debug_str
.strip_prefix("ThreadId(")
.and_then(|s| s.strip_suffix(")"))
.and_then(|s| s.parse().ok())
.unwrap_or(0)
}
fn log_output(output: &std::process::Output) {
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
for line in stdout.lines() {
log::debug!(" {}", line);
}
for line in stderr.lines() {
log::debug!(" ! {}", line);
}
}
fn run_with_timeout_impl(
cmd: &mut Command,
timeout: std::time::Duration,
) -> std::io::Result<std::process::Output> {
let mut child = cmd
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
let mut stdout_handle = child.stdout.take();
let mut stderr_handle = child.stderr.take();
let stdout_thread = std::thread::spawn(move || {
let mut buf = Vec::new();
if let Some(ref mut handle) = stdout_handle {
let _ = handle.read_to_end(&mut buf);
}
buf
});
let stderr_thread = std::thread::spawn(move || {
let mut buf = Vec::new();
if let Some(ref mut handle) = stderr_handle {
let _ = handle.read_to_end(&mut buf);
}
buf
});
let status = match child.wait_timeout(timeout)? {
Some(status) => status,
None => {
let _ = child.kill();
let _ = child.wait();
let _ = stdout_thread.join();
let _ = stderr_thread.join();
return Err(std::io::Error::new(
ErrorKind::TimedOut,
"command timed out",
));
}
};
let stdout = stdout_thread.join().unwrap_or_default();
let stderr = stderr_thread.join().unwrap_or_default();
Ok(std::process::Output {
status,
stdout,
stderr,
})
}
pub struct Cmd {
program: String,
args: Vec<String>,
current_dir: Option<std::path::PathBuf>,
context: Option<String>,
stdin_data: Option<Vec<u8>>,
timeout: Option<std::time::Duration>,
envs: Vec<(OsString, OsString)>,
env_removes: Vec<OsString>,
shell_wrap: bool,
stdout_cfg: Option<std::process::Stdio>,
stdin_cfg: Option<std::process::Stdio>,
forward_signals: bool,
external_label: Option<String>,
}
struct ExternalCommandLog {
label: Option<String>,
cmd_str: String,
started_at: Option<Instant>,
}
impl ExternalCommandLog {
fn new(label: Option<String>, cmd_str: String) -> Self {
let started_at = label.as_ref().map(|_| Instant::now());
Self {
label,
cmd_str,
started_at,
}
}
fn record(&self, exit_code: Option<i32>) {
if let Some(label) = &self.label {
let duration = self.started_at.as_ref().map(Instant::elapsed);
crate::command_log::log_command(label, &self.cmd_str, exit_code, duration);
}
}
}
impl Cmd {
fn builder(program: impl Into<String>, shell_wrap: bool) -> Self {
Self {
program: program.into(),
args: Vec::new(),
current_dir: None,
context: None,
stdin_data: None,
timeout: None,
envs: Vec::new(),
env_removes: Vec::new(),
shell_wrap,
stdout_cfg: None,
stdin_cfg: None,
forward_signals: false,
external_label: None,
}
}
pub fn new(program: impl Into<String>) -> Self {
Self::builder(program, false)
}
pub fn shell(command: impl Into<String>) -> Self {
Self::builder(command, true)
}
fn command_string(&self) -> String {
if self.shell_wrap || self.args.is_empty() {
self.program.clone()
} else {
format!("{} {}", self.program, self.args.join(" "))
}
}
fn direct_command(&self) -> Command {
let mut cmd = Command::new(&self.program);
cmd.args(&self.args);
cmd
}
fn apply_common_settings(&self, cmd: &mut Command) {
if let Some(dir) = &self.current_dir {
cmd.current_dir(dir);
}
for (key, val) in &self.envs {
cmd.env(key, val);
}
for key in &self.env_removes {
cmd.env_remove(key);
}
cmd.env_remove(DIRECTIVE_FILE_ENV_VAR);
}
fn log_run_start(&self, cmd_str: &str) {
match &self.context {
Some(ctx) => log::debug!("$ {} [{}]", cmd_str, ctx),
None => log::debug!("$ {}", cmd_str),
}
}
fn log_stream_start(&self, cmd_str: &str, exec_mode: &str) {
match &self.context {
Some(ctx) => log::debug!("$ {} [{}] (streaming, {})", cmd_str, ctx, exec_mode),
None => log::debug!("$ {} (streaming, {})", cmd_str, exec_mode),
}
}
pub fn arg(mut self, arg: impl Into<String>) -> Self {
self.args.push(arg.into());
self
}
pub fn args<I, S>(mut self, args: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.args.extend(args.into_iter().map(Into::into));
self
}
pub fn current_dir(mut self, dir: impl Into<std::path::PathBuf>) -> Self {
self.current_dir = Some(dir.into());
self
}
pub fn context(mut self, ctx: impl Into<String>) -> Self {
self.context = Some(ctx.into());
self
}
pub fn stdin_bytes(mut self, data: impl Into<Vec<u8>>) -> Self {
self.stdin_data = Some(data.into());
self
}
pub fn timeout(mut self, duration: std::time::Duration) -> Self {
self.timeout = Some(duration);
self
}
pub fn env(mut self, key: impl AsRef<OsStr>, val: impl AsRef<OsStr>) -> Self {
self.envs
.push((key.as_ref().to_os_string(), val.as_ref().to_os_string()));
self
}
pub fn env_remove(mut self, key: impl AsRef<OsStr>) -> Self {
self.env_removes.push(key.as_ref().to_os_string());
self
}
pub fn stdout(mut self, cfg: std::process::Stdio) -> Self {
self.stdout_cfg = Some(cfg);
self
}
pub fn stdin(mut self, cfg: std::process::Stdio) -> Self {
self.stdin_cfg = Some(cfg);
self
}
pub fn forward_signals(mut self) -> Self {
self.forward_signals = true;
self
}
pub fn external(mut self, label: impl Into<String>) -> Self {
self.external_label = Some(label.into());
self
}
pub fn run(self) -> std::io::Result<std::process::Output> {
assert!(
!self.shell_wrap,
"Cmd::shell() commands must use .stream(), not .run()"
);
let cmd_str = self.command_string();
let external_log = ExternalCommandLog::new(self.external_label.clone(), cmd_str.clone());
self.log_run_start(&cmd_str);
let _guard = semaphore().acquire();
let t0 = Instant::now();
let ts = t0.duration_since(*trace_epoch()).as_micros() as u64;
let tid = thread_id_number();
let mut cmd = self.direct_command();
self.apply_common_settings(&mut cmd);
let effective_timeout = self.timeout.or_else(|| COMMAND_TIMEOUT.with(|t| t.get()));
let result = if let Some(stdin_data) = self.stdin_data {
cmd.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = cmd.spawn()?;
if let Some(mut stdin) = child.stdin.take()
&& let Err(e) = stdin.write_all(&stdin_data)
&& e.kind() != std::io::ErrorKind::BrokenPipe
{
return Err(e);
}
child.wait_with_output()
} else if let Some(timeout_duration) = effective_timeout {
run_with_timeout_impl(&mut cmd, timeout_duration)
} else {
cmd.output()
};
let dur_us = t0.elapsed().as_micros() as u64;
match (&result, &self.context) {
(Ok(output), Some(ctx)) => {
log::debug!(
"[wt-trace] ts={} tid={} context={} cmd=\"{}\" dur_us={} ok={}",
ts,
tid,
ctx,
cmd_str,
dur_us,
output.status.success()
);
log_output(output);
}
(Ok(output), None) => {
log::debug!(
"[wt-trace] ts={} tid={} cmd=\"{}\" dur_us={} ok={}",
ts,
tid,
cmd_str,
dur_us,
output.status.success()
);
log_output(output);
}
(Err(e), Some(ctx)) => {
log::debug!(
"[wt-trace] ts={} tid={} context={} cmd=\"{}\" dur_us={} err=\"{}\"",
ts,
tid,
ctx,
cmd_str,
dur_us,
e
);
}
(Err(e), None) => {
log::debug!(
"[wt-trace] ts={} tid={} cmd=\"{}\" dur_us={} err=\"{}\"",
ts,
tid,
cmd_str,
dur_us,
e
);
}
}
let exit_code = result.as_ref().ok().and_then(|output| output.status.code());
external_log.record(exit_code);
result
}
pub fn stream(mut self) -> anyhow::Result<()> {
#[cfg(unix)]
use {
signal_hook::consts::{SIGINT, SIGPIPE, SIGTERM},
signal_hook::iterator::Signals,
std::os::unix::process::CommandExt,
};
assert!(
!self.shell_wrap || self.args.is_empty(),
"Cmd::shell() cannot use .arg() - include arguments in the shell command string"
);
let (mut cmd, exec_mode) = if self.shell_wrap {
let shell = ShellConfig::get()?;
let mode = format!("shell: {}", shell.name);
(shell.command(&self.program), mode)
} else {
(self.direct_command(), "direct".to_string())
};
let cmd_str = self.command_string();
let external_log = ExternalCommandLog::new(self.external_label.take(), cmd_str.clone());
self.log_stream_start(&cmd_str, &exec_mode);
self.apply_common_settings(&mut cmd);
#[cfg(not(unix))]
let _ = self.forward_signals;
let stdout_mode = self.stdout_cfg.unwrap_or_else(std::process::Stdio::inherit);
let stdin_mode = if self.stdin_data.is_some() {
std::process::Stdio::piped()
} else {
self.stdin_cfg.unwrap_or_else(std::process::Stdio::null)
};
#[cfg(unix)]
let mut signals = if self.forward_signals {
Some(Signals::new([SIGINT, SIGTERM])?)
} else {
None
};
#[cfg(unix)]
if self.forward_signals {
cmd.process_group(0);
}
cmd.stdin(stdin_mode)
.stdout(stdout_mode)
.stderr(std::process::Stdio::inherit()) .env_remove("VERGEN_GIT_DESCRIBE");
let mut child = cmd.spawn().map_err(|e| {
anyhow::Error::from(GitError::Other {
message: format!("Failed to execute command ({}): {}", exec_mode, e),
})
})?;
if let Some(ref content) = self.stdin_data
&& let Some(mut stdin) = child.stdin.take()
&& let Err(e) = stdin.write_all(content)
&& e.kind() != std::io::ErrorKind::BrokenPipe
{
return Err(e.into());
}
#[cfg(unix)]
let (status, seen_signal) = if self.forward_signals {
let child_pgid = child.id() as i32;
let mut seen_signal: Option<i32> = None;
loop {
if let Some(status) = child.try_wait().map_err(|e| {
anyhow::Error::from(GitError::Other {
message: format!("Failed to wait for command: {}", e),
})
})? {
break (status, seen_signal);
}
if let Some(signals) = signals.as_mut() {
for sig in signals.pending() {
if seen_signal.is_none() {
seen_signal = Some(sig);
forward_signal_with_escalation(child_pgid, sig);
}
}
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
} else {
let status = child.wait().map_err(|e| {
anyhow::Error::from(GitError::Other {
message: format!("Failed to wait for command: {}", e),
})
})?;
(status, None)
};
#[cfg(not(unix))]
let status = child.wait().map_err(|e| {
anyhow::Error::from(GitError::Other {
message: format!("Failed to wait for command: {}", e),
})
})?;
#[cfg(unix)]
if let Some(sig) = seen_signal {
external_log.record(Some(128 + sig));
return Err(WorktrunkError::ChildProcessExited {
code: 128 + sig,
message: format!("terminated by signal {}", sig),
}
.into());
}
#[cfg(unix)]
if let Some(sig) = std::os::unix::process::ExitStatusExt::signal(&status) {
if sig == SIGPIPE {
external_log.record(Some(0));
return Ok(());
}
external_log.record(Some(128 + sig));
return Err(WorktrunkError::ChildProcessExited {
code: 128 + sig,
message: format!("terminated by signal {}", sig),
}
.into());
}
if !status.success() {
let code = status.code().unwrap_or(1);
external_log.record(status.code());
return Err(WorktrunkError::ChildProcessExited {
code,
message: format!("exit status: {}", code),
}
.into());
}
external_log.record(Some(0));
Ok(())
}
}
#[cfg(unix)]
fn process_group_alive(pgid: i32) -> bool {
match nix::sys::signal::killpg(nix::unistd::Pid::from_raw(pgid), None) {
Ok(_) => true,
Err(nix::errno::Errno::ESRCH) => false,
Err(_) => true,
}
}
#[cfg(unix)]
fn wait_for_exit(pgid: i32, grace: std::time::Duration) -> bool {
std::thread::sleep(grace);
!process_group_alive(pgid)
}
#[cfg(unix)]
fn forward_signal_with_escalation(pgid: i32, sig: i32) {
let pgid = nix::unistd::Pid::from_raw(pgid);
let initial_signal = match sig {
signal_hook::consts::SIGINT => nix::sys::signal::Signal::SIGINT,
signal_hook::consts::SIGTERM => nix::sys::signal::Signal::SIGTERM,
_ => return,
};
let _ = nix::sys::signal::killpg(pgid, initial_signal);
let grace = std::time::Duration::from_millis(200);
if sig == signal_hook::consts::SIGINT {
if !wait_for_exit(pgid.as_raw(), grace) {
let _ = nix::sys::signal::killpg(pgid, nix::sys::signal::Signal::SIGTERM);
if !wait_for_exit(pgid.as_raw(), grace) {
let _ = nix::sys::signal::killpg(pgid, nix::sys::signal::Signal::SIGKILL);
}
}
} else {
if !wait_for_exit(pgid.as_raw(), grace) {
let _ = nix::sys::signal::killpg(pgid, nix::sys::signal::Signal::SIGKILL);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_max_concurrent_commands_defaults() {
assert!(max_concurrent_commands() >= 1, "Default should be >= 1");
assert_eq!(
max_concurrent_commands(),
DEFAULT_CONCURRENT_COMMANDS,
"Without env var, should use default"
);
}
#[test]
fn test_parse_concurrent_limit() {
assert_eq!(parse_concurrent_limit("1"), Some(1));
assert_eq!(parse_concurrent_limit("32"), Some(32));
assert_eq!(parse_concurrent_limit("100"), Some(100));
assert_eq!(parse_concurrent_limit("0"), Some(usize::MAX));
assert_eq!(parse_concurrent_limit(""), None);
assert_eq!(parse_concurrent_limit("abc"), None);
assert_eq!(parse_concurrent_limit("-1"), None);
assert_eq!(parse_concurrent_limit("1.5"), None);
}
#[test]
fn test_shell_config_is_available() {
let config = ShellConfig::get().unwrap();
assert!(!config.name.is_empty());
assert!(!config.args.is_empty());
}
#[test]
#[cfg(unix)]
fn test_unix_shell_is_posix() {
let config = ShellConfig::get().unwrap();
assert!(config.is_posix);
assert_eq!(config.name, "sh");
}
#[test]
fn test_command_creation() {
let config = ShellConfig::get().unwrap();
let cmd = config.command("echo hello");
let _ = format!("{:?}", cmd);
}
#[test]
fn test_shell_command_execution() {
let config = ShellConfig::get().unwrap();
let output = config
.command("echo hello")
.output()
.expect("Failed to execute shell command");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"echo should succeed. Shell: {} ({:?}), exit: {:?}, stdout: '{}', stderr: '{}'",
config.name,
config.executable,
output.status.code(),
stdout.trim(),
stderr.trim()
);
assert!(
stdout.contains("hello"),
"stdout should contain 'hello', got: '{}'",
stdout.trim()
);
}
#[test]
#[cfg(windows)]
fn test_windows_uses_git_bash() {
let config = ShellConfig::get().unwrap();
assert_eq!(config.name, "Git Bash");
assert!(config.is_posix, "Git Bash should support POSIX syntax");
assert!(
config.args.contains(&"-c".to_string()),
"Git Bash should use -c flag"
);
}
#[test]
#[cfg(windows)]
fn test_windows_echo_command() {
let config = ShellConfig::get().unwrap();
let output = config
.command("echo test_output")
.output()
.expect("Failed to execute echo");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(output.status.success());
assert!(
stdout.contains("test_output"),
"stdout should contain 'test_output', got: '{}'",
stdout.trim()
);
}
#[test]
#[cfg(windows)]
fn test_windows_posix_redirection() {
let config = ShellConfig::get().unwrap();
let output = config
.command("echo redirected 1>&2")
.output()
.expect("Failed to execute redirection test");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(output.status.success());
assert!(
stderr.contains("redirected"),
"stderr should contain 'redirected' (stdout redirected to stderr), got: '{}'",
stderr.trim()
);
}
#[test]
fn test_shell_config_clone() {
let config = ShellConfig::get().unwrap();
let cloned = config.clone();
assert_eq!(config.name, cloned.name);
assert_eq!(config.is_posix, cloned.is_posix);
assert_eq!(config.args, cloned.args);
}
#[test]
fn test_shell_is_posix_method() {
let config = ShellConfig::get().unwrap();
assert_eq!(config.is_posix(), config.is_posix);
}
#[test]
fn test_cmd_completes_fast_command() {
let result = Cmd::new("echo")
.arg("hello")
.timeout(Duration::from_secs(5))
.run();
assert!(result.is_ok());
let output = result.unwrap();
assert!(output.status.success());
assert!(String::from_utf8_lossy(&output.stdout).contains("hello"));
}
#[test]
#[cfg(unix)]
fn test_cmd_timeout_kills_slow_command() {
let result = Cmd::new("sleep")
.arg("10")
.timeout(Duration::from_millis(50))
.run();
assert!(result.is_err());
assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::TimedOut);
}
#[test]
fn test_cmd_without_timeout_completes() {
let result = Cmd::new("echo").arg("no timeout").run();
assert!(result.is_ok());
}
#[test]
fn test_cmd_with_context() {
let result = Cmd::new("echo")
.arg("with context")
.context("test-context")
.run();
assert!(result.is_ok());
}
#[test]
fn test_cmd_with_stdin() {
let result = Cmd::new("cat").stdin_bytes("hello from stdin").run();
assert!(result.is_ok());
let output = result.unwrap();
assert!(output.status.success());
assert!(String::from_utf8_lossy(&output.stdout).contains("hello from stdin"));
}
#[test]
fn test_thread_local_timeout_setting() {
let initial = COMMAND_TIMEOUT.with(|t| t.get());
set_command_timeout(Some(Duration::from_millis(100)));
let after_set = COMMAND_TIMEOUT.with(|t| t.get());
assert_eq!(after_set, Some(Duration::from_millis(100)));
set_command_timeout(initial);
let after_clear = COMMAND_TIMEOUT.with(|t| t.get());
assert_eq!(after_clear, initial);
}
#[test]
fn test_cmd_uses_thread_local_timeout() {
set_command_timeout(None);
let result = Cmd::new("echo").arg("thread local test").run();
assert!(result.is_ok());
set_command_timeout(None);
}
#[test]
#[cfg(unix)]
fn test_cmd_thread_local_timeout_kills_slow_command() {
set_command_timeout(Some(Duration::from_millis(50)));
let result = Cmd::new("sleep").arg("10").run();
assert!(result.is_err());
assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::TimedOut);
set_command_timeout(None);
}
#[test]
fn test_cmd_shell_stream_succeeds() {
let result = Cmd::shell("echo hello").stream();
assert!(result.is_ok());
}
#[test]
fn test_cmd_shell_stream_fails_on_nonzero_exit() {
use crate::git::WorktrunkError;
let result = Cmd::shell("exit 42").stream();
assert!(result.is_err());
let err = result.unwrap_err();
let wt_err = err.downcast_ref::<WorktrunkError>().unwrap();
match wt_err {
WorktrunkError::ChildProcessExited { code, .. } => {
assert_eq!(*code, 42);
}
_ => panic!("Expected ChildProcessExited error"),
}
}
#[test]
#[cfg(unix)]
fn test_cmd_stream_sigpipe_is_not_an_error() {
let result = Cmd::new("sh").args(["-c", "kill -PIPE $$"]).stream();
assert!(
result.is_ok(),
"SIGPIPE should not be treated as an error: {result:?}"
);
}
#[test]
#[cfg(unix)]
fn test_cmd_stream_other_signals_are_errors() {
use crate::git::WorktrunkError;
let result = Cmd::new("sh").args(["-c", "kill -TERM $$"]).stream();
assert!(result.is_err());
let err = result.unwrap_err();
let wt_err = err.downcast_ref::<WorktrunkError>().unwrap();
match wt_err {
WorktrunkError::ChildProcessExited { code, .. } => {
assert_eq!(*code, 128 + 15); }
_ => panic!("Expected ChildProcessExited error"),
}
}
#[test]
#[cfg(unix)]
fn test_cmd_shell_stream_with_stdin() {
let result = Cmd::shell("cat").stdin_bytes("test content").stream();
assert!(result.is_ok());
}
#[test]
#[cfg(unix)]
fn test_cmd_new_stream_succeeds() {
let result = Cmd::new("echo").arg("hello").stream();
assert!(result.is_ok());
}
#[test]
#[cfg(unix)]
fn test_cmd_shell_stream_with_stdout_redirect() {
use std::process::Stdio;
let result = Cmd::shell("echo redirected")
.stdout(Stdio::from(std::io::stderr()))
.stream();
assert!(result.is_ok());
}
#[test]
#[cfg(unix)]
fn test_cmd_shell_stream_with_stdin_inherit() {
use std::process::Stdio;
let result = Cmd::shell("true").stdin(Stdio::inherit()).stream();
assert!(result.is_ok());
}
#[test]
#[cfg(unix)]
fn test_cmd_shell_stream_with_env() {
let result = Cmd::shell("printenv TEST_VAR")
.env("TEST_VAR", "test_value")
.env_remove("SOME_NONEXISTENT_VAR")
.stream();
assert!(result.is_ok());
}
#[test]
#[cfg(unix)]
fn test_process_group_alive_with_current_process() {
let pgid = nix::unistd::getpgrp().as_raw();
assert!(super::process_group_alive(pgid));
}
#[test]
#[cfg(unix)]
fn test_process_group_alive_with_nonexistent_pgid() {
assert!(!super::process_group_alive(999_999_999));
}
#[test]
#[cfg(unix)]
fn test_forward_signal_with_escalation_unknown_signal() {
super::forward_signal_with_escalation(1, 999);
}
}