use std::collections::VecDeque;
use std::ffi::{OsStr, OsString};
use std::io::{self, Cursor, Read, Write};
use std::path::{Path, PathBuf};
use std::process::{Child, ChildStderr, ChildStdin, ChildStdout, Command, ExitStatus, Stdio};
use std::sync::{Arc, Mutex};
use thiserror::Error;
use crate::environment::Environment;
#[derive(Debug, Error)]
#[error("command handle is not backed by a live OS process")]
pub struct CommandHandleError;
#[derive(Debug, Clone, Default)]
pub struct CommandEnvironment {
pub inherit_process: bool,
pub set: Vec<(OsString, OsString)>,
pub remove: Vec<OsString>,
}
impl CommandEnvironment {
#[must_use]
pub fn inherit_process_only() -> Self {
Self {
inherit_process: true,
set: Vec::new(),
remove: Vec::new(),
}
}
#[must_use]
pub fn from_repository_environment(env: &Environment, extra: &[(String, String)]) -> Self {
let mut set = env.subprocess_environment();
for (k, v) in extra {
let key = OsString::from(k.as_str());
let val = OsString::from(v.as_str());
if let Some(slot) = set.iter_mut().find(|(ek, _)| ek == &key) {
slot.1 = val;
} else {
set.push((key, val));
}
}
Self {
inherit_process: false,
set,
remove: Vec::new(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum CommandStdio {
#[default]
Inherit,
Null,
Pipe,
ParentStderr,
}
#[derive(Debug, Clone, Default)]
pub enum CommandStdin {
#[default]
Inherit,
Null,
Pipe(Vec<u8>),
}
#[derive(Debug, Clone)]
pub enum ShellInvocation {
DashC {
script: String,
argv0: Option<String>,
args: Vec<OsString>,
},
ScriptPath {
script_path: PathBuf,
args: Vec<OsString>,
},
}
#[derive(Debug, Clone)]
pub struct CommandSpec {
pub program: PathBuf,
pub args: Vec<OsString>,
pub shell: Option<ShellInvocation>,
pub cwd: Option<PathBuf>,
pub env: CommandEnvironment,
pub stdin: CommandStdin,
pub stdout: CommandStdio,
pub stderr: CommandStdio,
}
impl CommandSpec {
#[must_use]
pub fn sh_dash_c_filter(script: impl Into<String>) -> Self {
Self {
program: PathBuf::from("sh"),
args: Vec::new(),
shell: Some(ShellInvocation::DashC {
script: script.into(),
argv0: None,
args: Vec::new(),
}),
cwd: None,
env: CommandEnvironment::inherit_process_only(),
stdin: CommandStdin::Pipe(Vec::new()),
stdout: CommandStdio::Pipe,
stderr: CommandStdio::Inherit,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CommandExit {
pub code: Option<i32>,
pub success: bool,
}
impl From<ExitStatus> for CommandExit {
fn from(status: ExitStatus) -> Self {
Self {
code: status.code(),
success: status.success(),
}
}
}
#[derive(Debug, Clone)]
pub struct CommandOutput {
pub status: CommandExit,
pub stdout: Vec<u8>,
pub stderr: Vec<u8>,
}
enum RunningCommandInner {
System {
child: Child,
stdin: Option<ChildStdin>,
stdout: Option<ChildStdout>,
stderr: Option<ChildStderr>,
},
Recording {
state: Arc<Mutex<RecordingState>>,
stdin: Option<RecordingStdinBuffer>,
},
}
struct RecordingStdinBuffer(Arc<Mutex<Vec<u8>>>);
impl Write for RecordingStdinBuffer {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0.lock().unwrap_or_else(|e| e.into_inner()).write(buf)
}
fn flush(&mut self) -> io::Result<()> {
self.0.lock().unwrap_or_else(|e| e.into_inner()).flush()
}
}
struct RecordingState {
stdout: Option<Cursor<Vec<u8>>>,
stderr: Vec<u8>,
exit: CommandExit,
}
pub struct RunningCommand {
inner: RunningCommandInner,
}
impl RunningCommand {
#[must_use]
pub fn from_system(
child: Child,
stdin: Option<ChildStdin>,
stdout: Option<ChildStdout>,
stderr: Option<ChildStderr>,
) -> Self {
Self {
inner: RunningCommandInner::System {
child,
stdin,
stdout,
stderr,
},
}
}
#[must_use]
pub fn from_recording(
exit: CommandExit,
stdout: Option<Vec<u8>>,
stderr: Vec<u8>,
piped_stdin: bool,
) -> Self {
Self {
inner: RunningCommandInner::Recording {
state: Arc::new(Mutex::new(RecordingState {
stdout: stdout.map(Cursor::new),
stderr,
exit,
})),
stdin: piped_stdin.then(|| RecordingStdinBuffer(Arc::new(Mutex::new(Vec::new())))),
},
}
}
pub fn stdin_mut(&mut self) -> Option<&mut dyn Write> {
match &mut self.inner {
RunningCommandInner::System { stdin, .. } => {
stdin.as_mut().map(|s| s as &mut dyn Write)
}
RunningCommandInner::Recording { stdin, .. } => {
stdin.as_mut().map(|s| s as &mut dyn Write)
}
}
}
pub fn close_stdin(&mut self) {
match &mut self.inner {
RunningCommandInner::System { stdin, .. } => *stdin = None,
RunningCommandInner::Recording { stdin, .. } => *stdin = None,
}
}
pub fn stdout_mut(&mut self) -> Option<&mut dyn Read> {
match &mut self.inner {
RunningCommandInner::System { stdout, .. } => {
stdout.as_mut().map(|s| s as &mut dyn Read)
}
RunningCommandInner::Recording { .. } => None,
}
}
pub fn wait(self) -> io::Result<CommandExit> {
match self.inner {
RunningCommandInner::System { mut child, .. } => child.wait().map(Into::into),
RunningCommandInner::Recording { state, .. } => {
let guard = state.lock().unwrap_or_else(|e| e.into_inner());
Ok(guard.exit)
}
}
}
pub fn wait_with_output(self) -> io::Result<CommandOutput> {
match self.inner {
RunningCommandInner::System {
mut child,
stdout,
stderr,
..
} => {
let (stdout_bytes, stderr_bytes) = drain_child_pipes(stdout, stderr)?;
let status = child.wait()?.into();
Ok(CommandOutput {
status,
stdout: stdout_bytes,
stderr: stderr_bytes,
})
}
RunningCommandInner::Recording { state, .. } => {
let mut guard = state.lock().unwrap_or_else(|e| e.into_inner());
let mut stdout_bytes = Vec::new();
if let Some(cursor) = &mut guard.stdout {
let _ = cursor.read_to_end(&mut stdout_bytes);
}
Ok(CommandOutput {
status: guard.exit,
stdout: stdout_bytes,
stderr: guard.stderr.clone(),
})
}
}
}
pub fn into_child(self) -> Result<Child, CommandHandleError> {
match self.inner {
RunningCommandInner::System { child, .. } => Ok(child),
RunningCommandInner::Recording { .. } => Err(CommandHandleError),
}
}
#[expect(clippy::type_complexity)]
pub fn into_system_parts(
self,
) -> Result<
(
Child,
Option<ChildStdin>,
Option<ChildStdout>,
Option<ChildStderr>,
),
CommandHandleError,
> {
match self.inner {
RunningCommandInner::System {
child,
stdin,
stdout,
stderr,
} => Ok((child, stdin, stdout, stderr)),
RunningCommandInner::Recording { .. } => Err(CommandHandleError),
}
}
}
fn drain_child_pipes(
stdout: Option<ChildStdout>,
stderr: Option<ChildStderr>,
) -> io::Result<(Vec<u8>, Vec<u8>)> {
std::thread::scope(|scope| {
let out_task = stdout.map(|mut out| {
scope.spawn(move || {
let mut buf = Vec::new();
let _ = out.read_to_end(&mut buf);
buf
})
});
let err_task = stderr.map(|mut err| {
scope.spawn(move || {
let mut buf = Vec::new();
let _ = err.read_to_end(&mut buf);
buf
})
});
let stdout_bytes = out_task
.map(|h| h.join().unwrap_or_default())
.unwrap_or_default();
let stderr_bytes = err_task
.map(|h| h.join().unwrap_or_default())
.unwrap_or_default();
Ok((stdout_bytes, stderr_bytes))
})
}
pub trait CommandRunner: Send + Sync {
fn spawn(&self, spec: &CommandSpec) -> io::Result<RunningCommand>;
}
#[derive(Debug, Clone, Default)]
pub struct SystemCommandRunner;
impl SystemCommandRunner {
fn build_command(spec: &CommandSpec) -> Command {
let program = if spec.program.as_os_str() == SHELL_NAME {
shell_program(&spec.env)
} else {
spec.program.clone()
};
let mut cmd = match &spec.shell {
None => {
let mut c = Command::new(&program); c.args(&spec.args);
c
}
Some(ShellInvocation::DashC {
script,
argv0,
args,
}) => {
let mut c = Command::new(&program); c.arg("-c").arg(script);
if let Some(name) = argv0 {
c.arg(name);
}
c.args(args);
c
}
Some(ShellInvocation::ScriptPath { script_path, args }) => {
let mut c = Command::new(&program); c.arg(script_path);
c.args(args);
c
}
};
if let Some(cwd) = &spec.cwd {
cmd.current_dir(cwd);
}
apply_env(&mut cmd, &spec.env);
apply_stdio(&mut cmd, spec);
cmd
}
}
fn apply_env(cmd: &mut Command, env: &CommandEnvironment) {
if !env.inherit_process {
cmd.env_clear();
}
for key in &env.remove {
cmd.env_remove(key);
}
for (k, v) in &env.set {
cmd.env(k, v);
}
}
fn apply_stdio(cmd: &mut Command, spec: &CommandSpec) {
cmd.stdin(match spec.stdin {
CommandStdin::Inherit => Stdio::inherit(),
CommandStdin::Null => Stdio::null(),
CommandStdin::Pipe(_) => Stdio::piped(),
});
cmd.stdout(child_stdio(spec.stdout));
cmd.stderr(child_stdio(spec.stderr));
}
fn child_stdio(mode: CommandStdio) -> Stdio {
match mode {
CommandStdio::Inherit => Stdio::inherit(),
CommandStdio::Null => Stdio::null(),
CommandStdio::Pipe => Stdio::piped(),
CommandStdio::ParentStderr => std::io::stderr().into(),
}
}
const SHELL_NAME: &str = "sh";
pub(crate) fn child_path_var(env: &CommandEnvironment) -> Option<OsString> {
env.set
.iter()
.rev()
.find(|(k, _)| is_path_var(k))
.map(|(_, v)| v.clone())
.or_else(|| {
env.inherit_process
.then(|| std::env::var_os("PATH"))
.flatten()
})
}
pub(crate) fn shell_program(env: &CommandEnvironment) -> PathBuf {
shell_interpreter(env).program
}
pub(crate) fn shell_interpreter(env: &CommandEnvironment) -> ScriptInterpreter {
if cfg!(windows) {
resolve_script_interpreter(SHELL_NAME, child_path_var(env).as_deref())
} else {
ScriptInterpreter::bare("/bin/sh")
}
}
fn find_program_in_dirs(name: &str, dirs: impl IntoIterator<Item = PathBuf>) -> Option<PathBuf> {
let suffix = std::env::consts::EXE_SUFFIX;
dirs.into_iter()
.filter(|dir| !dir.as_os_str().is_empty())
.find_map(|dir| {
let with_suffix = (!suffix.is_empty() && !name.ends_with(suffix))
.then(|| dir.join(format!("{name}{suffix}")));
with_suffix
.into_iter()
.chain(std::iter::once(dir.join(name)))
.find(|candidate| candidate.is_file())
})
}
fn find_program_in_path(name: &str, path_var: &OsStr) -> Option<PathBuf> {
find_program_in_dirs(name, std::env::split_paths(path_var))
}
fn is_wsl_launcher_dir(dir: &Path) -> bool {
dir.file_name().and_then(|n| n.to_str()).is_some_and(|n| {
n.eq_ignore_ascii_case("System32") || n.eq_ignore_ascii_case("WindowsApps")
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ScriptInterpreter {
pub(crate) program: PathBuf,
pub(crate) git_root: Option<PathBuf>,
}
impl ScriptInterpreter {
pub(crate) fn bare(program: impl Into<PathBuf>) -> Self {
Self {
program: program.into(),
git_root: None,
}
}
}
pub(crate) fn resolve_script_interpreter(
name: &str,
path_var: Option<&OsStr>,
) -> ScriptInterpreter {
let Some(path_var) = path_var else {
return ScriptInterpreter::bare(name);
};
let in_git_tree = || {
let git = find_program_in_path("git", path_var)?;
git.parent()?.ancestors().skip(1).take(2).find_map(|root| {
let program =
find_program_in_dirs(name, [root.join("usr").join("bin"), root.join("bin")])?;
Some(ScriptInterpreter {
program,
git_root: Some(root.to_path_buf()),
})
})
};
in_git_tree()
.or_else(|| {
find_program_in_dirs(
name,
std::env::split_paths(path_var).filter(|dir| !is_wsl_launcher_dir(dir)),
)
.map(ScriptInterpreter::bare)
})
.unwrap_or_else(|| ScriptInterpreter::bare(name))
}
pub(crate) fn git_for_windows_tool_dirs(root: &Path) -> Vec<PathBuf> {
const MINGW_PREFIXES: [&str; 4] = ["mingw64", "clangarm64", "ucrt64", "mingw32"];
let mingw_bin = MINGW_PREFIXES
.iter()
.map(|prefix| root.join(prefix).join("bin"))
.find(|dir| dir.is_dir());
mingw_bin
.into_iter()
.chain(std::iter::once(root.join("usr").join("bin")))
.filter(|dir| dir.is_dir())
.collect()
}
pub(crate) fn prepend_to_child_path(env: &mut CommandEnvironment, dirs: &[PathBuf]) {
if dirs.is_empty() {
return;
}
let Ok(mut path) = std::env::join_paths(dirs) else {
return;
};
if let Some(current) = child_path_var(env).filter(|p| !p.is_empty()) {
path.push(if cfg!(windows) { ";" } else { ":" });
path.push(current);
}
match env.set.iter_mut().rev().find(|(k, _)| is_path_var(k)) {
Some((_, value)) => *value = path,
None => env.set.push((OsString::from("PATH"), path)),
}
}
fn is_path_var(key: &OsStr) -> bool {
if cfg!(windows) {
key.eq_ignore_ascii_case("PATH")
} else {
key == "PATH"
}
}
impl CommandRunner for SystemCommandRunner {
fn spawn(&self, spec: &CommandSpec) -> io::Result<RunningCommand> {
let mut cmd = Self::build_command(spec);
let mut child = cmd.spawn()?;
let mut stdin = child.stdin.take();
if let CommandStdin::Pipe(ref bytes) = spec.stdin {
if let Some(ref mut stdin_handle) = stdin {
if !bytes.is_empty() {
let _ = stdin_handle.write_all(bytes);
stdin = None;
}
}
}
let stdout = child.stdout.take();
let stderr = child.stderr.take();
Ok(RunningCommand::from_system(child, stdin, stdout, stderr))
}
}
#[must_use]
pub fn system_command_runner() -> Arc<dyn CommandRunner> {
Arc::new(SystemCommandRunner)
}
#[derive(Debug, Clone)]
pub enum RecordedResponse {
Success,
Exit(i32),
Output {
stdout: Vec<u8>,
code: i32,
},
}
#[derive(Debug, Default)]
pub struct RecordingRunner {
pub recorded: Mutex<Vec<CommandSpec>>,
stdin_payloads: Mutex<Vec<Arc<Mutex<Vec<u8>>>>>,
responses: Mutex<VecDeque<RecordedResponse>>,
}
impl RecordingRunner {
#[must_use]
pub fn always_success() -> Arc<Self> {
Arc::new(Self::default())
}
pub fn push_response(&self, response: RecordedResponse) {
self.responses
.lock()
.unwrap_or_else(|e| e.into_inner())
.push_back(response);
}
#[must_use]
pub fn specs(&self) -> Vec<CommandSpec> {
self.recorded
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone()
}
#[must_use]
pub fn stdin_payloads(&self) -> Vec<Vec<u8>> {
self.stdin_payloads
.lock()
.unwrap_or_else(|e| e.into_inner())
.iter()
.map(|buf| buf.lock().unwrap_or_else(|e| e.into_inner()).clone())
.collect()
}
}
impl CommandRunner for RecordingRunner {
fn spawn(&self, spec: &CommandSpec) -> io::Result<RunningCommand> {
self.recorded
.lock()
.unwrap_or_else(|e| e.into_inner())
.push(spec.clone());
let response = self
.responses
.lock()
.unwrap_or_else(|e| e.into_inner())
.pop_front()
.unwrap_or(RecordedResponse::Success);
let piped_stdin = matches!(spec.stdin, CommandStdin::Pipe(_));
let (stdout_bytes, exit) = match response {
RecordedResponse::Success => (
None,
CommandExit {
code: Some(0),
success: true,
},
),
RecordedResponse::Exit(code) => (
None,
CommandExit {
code: Some(code),
success: code == 0,
},
),
RecordedResponse::Output { stdout, code } => (
Some(stdout),
CommandExit {
code: Some(code),
success: code == 0,
},
),
};
let stdout_cursor = if spec.stdout == CommandStdio::Pipe {
Some(Cursor::new(stdout_bytes.unwrap_or_default()))
} else {
None
};
Ok(RunningCommand {
inner: RunningCommandInner::Recording {
state: Arc::new(Mutex::new(RecordingState {
stdout: stdout_cursor,
stderr: Vec::new(),
exit,
})),
stdin: piped_stdin.then(|| {
let buf = Arc::new(Mutex::new(Vec::new()));
self.stdin_payloads
.lock()
.unwrap_or_else(|e| e.into_inner())
.push(buf.clone());
RecordingStdinBuffer(buf)
}),
},
})
}
}
pub fn run_sh_dash_c(
runner: &dyn CommandRunner,
script: &str,
cwd: Option<&Path>,
stdin: CommandStdin,
stderr: CommandStdio,
) -> io::Result<CommandOutput> {
let mut spec = CommandSpec::sh_dash_c_filter(script);
spec.cwd = cwd.map(Path::to_path_buf);
spec.stdin = stdin;
spec.stderr = stderr;
runner.spawn(&spec)?.wait_with_output()
}
pub fn run_program(
runner: &dyn CommandRunner,
program: &Path,
args: &[OsString],
cwd: Option<&Path>,
stdin: CommandStdin,
stderr: CommandStdio,
) -> io::Result<CommandOutput> {
let spec = CommandSpec {
program: program.to_path_buf(),
args: args.to_vec(),
shell: None,
cwd: cwd.map(Path::to_path_buf),
env: CommandEnvironment::inherit_process_only(),
stdin,
stdout: CommandStdio::Pipe,
stderr,
};
runner.spawn(&spec)?.wait_with_output()
}
pub mod hooks {
pub use crate::hooks::*;
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
fn touch(path: &Path) {
std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir");
std::fs::write(path, b"").expect("write");
}
fn exe(name: &str) -> String {
format!("{name}{}", std::env::consts::EXE_SUFFIX)
}
#[test]
fn find_program_in_path_searches_entries_in_order() {
let tmp = tempfile::tempdir().expect("tempdir");
let first = tmp.path().join("first");
let second = tmp.path().join("second");
std::fs::create_dir_all(&first).expect("mkdir");
touch(&second.join(exe("sh")));
touch(&tmp.path().join("third").join(exe("sh")));
let path_var = std::env::join_paths([
PathBuf::new(),
first,
second.clone(),
tmp.path().join("third"),
])
.expect("join");
assert_eq!(
find_program_in_path("sh", &path_var),
Some(second.join(exe("sh")))
);
assert_eq!(find_program_in_path("bash", &path_var), None);
assert_eq!(find_program_in_path("sh", OsStr::new("")), None);
}
#[test]
fn resolve_script_interpreter_prefers_path() {
let tmp = tempfile::tempdir().expect("tempdir");
let bin = tmp.path().join("bin");
touch(&bin.join(exe("bash")));
let path_var = bin.clone().into_os_string();
assert_eq!(
resolve_script_interpreter("bash", Some(&path_var)).program,
bin.join(exe("bash"))
);
}
#[test]
fn resolve_script_interpreter_finds_git_for_windows_shell() {
for git_dir in [&["cmd"][..], &["mingw64", "bin"][..]] {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().join("Git");
let git_bin = git_dir.iter().fold(root.clone(), |p, c| p.join(c));
touch(&git_bin.join(exe("git")));
touch(&root.join("usr").join("bin").join(exe("sh")));
let path_var = git_bin.into_os_string();
assert_eq!(
resolve_script_interpreter("sh", Some(&path_var)).program,
root.join("usr").join("bin").join(exe("sh")),
"git under {git_dir:?}"
);
}
}
#[test]
fn resolve_script_interpreter_prefers_git_tree_over_earlier_path_entries() {
let tmp = tempfile::tempdir().expect("tempdir");
let other = tmp.path().join("other-bin");
touch(&other.join(exe("bash")));
let root = tmp.path().join("Git");
touch(&root.join("cmd").join(exe("git")));
touch(&root.join("usr").join("bin").join(exe("bash")));
let path_var = std::env::join_paths([other, root.join("cmd")]).expect("join");
assert_eq!(
resolve_script_interpreter("bash", Some(&path_var)).program,
root.join("usr").join("bin").join(exe("bash"))
);
}
#[test]
fn resolve_script_interpreter_skips_wsl_launchers() {
let tmp = tempfile::tempdir().expect("tempdir");
let system32 = tmp.path().join("Windows").join("System32");
let apps = tmp.path().join("WindowsApps");
let msys = tmp.path().join("msys64").join("usr").join("bin");
touch(&system32.join(exe("bash")));
touch(&apps.join(exe("bash")));
touch(&msys.join(exe("bash")));
let path_var =
std::env::join_paths([system32.clone(), apps.clone(), msys.clone()]).expect("join");
assert_eq!(
resolve_script_interpreter("bash", Some(&path_var)).program,
msys.join(exe("bash"))
);
let only_wsl = std::env::join_paths([system32, apps]).expect("join");
assert_eq!(
resolve_script_interpreter("bash", Some(&only_wsl)).program,
PathBuf::from("bash")
);
}
#[test]
fn resolve_script_interpreter_falls_back_to_bare_name() {
let tmp = tempfile::tempdir().expect("tempdir");
let path_var = tmp.path().as_os_str().to_os_string();
assert_eq!(
resolve_script_interpreter("sh", Some(&path_var)).program,
PathBuf::from("sh")
);
assert_eq!(
resolve_script_interpreter("sh", None),
ScriptInterpreter::bare("sh")
);
}
#[test]
fn child_path_var_uses_last_explicit_path() {
let mut env = CommandEnvironment::default();
assert_eq!(child_path_var(&env), None);
env.set.push(("PATH".into(), "/first".into()));
env.set.push(("HOME".into(), "/home/u".into()));
env.set.push(("PATH".into(), "/second".into()));
assert_eq!(child_path_var(&env), Some(OsString::from("/second")));
}
#[cfg(windows)]
#[test]
fn child_path_var_ignores_case_on_windows() {
let mut env = CommandEnvironment::default();
env.set.push(("Path".into(), r"C:\Git\cmd".into()));
assert_eq!(child_path_var(&env), Some(OsString::from(r"C:\Git\cmd")));
}
#[test]
fn child_path_var_falls_back_to_process_path_only_when_inheriting() {
let inherit = CommandEnvironment::inherit_process_only();
assert_eq!(child_path_var(&inherit), std::env::var_os("PATH"));
assert_eq!(child_path_var(&CommandEnvironment::default()), None);
}
#[cfg(unix)]
#[test]
fn shell_program_is_bin_sh_on_unix() {
let mut env = CommandEnvironment::default();
env.set.push(("PATH".into(), "/nonexistent".into()));
assert_eq!(shell_program(&env), PathBuf::from("/bin/sh"));
}
#[cfg(windows)]
#[test]
fn shell_program_finds_git_for_windows_shell_from_cmd_dir() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().join("Git");
touch(&root.join("cmd").join(exe("git")));
touch(&root.join("usr").join("bin").join(exe("sh")));
let mut env = CommandEnvironment::default();
env.set
.push(("Path".into(), root.join("cmd").into_os_string()));
assert_eq!(
shell_program(&env),
root.join("usr").join("bin").join(exe("sh"))
);
}
#[test]
fn resolve_script_interpreter_reports_the_git_for_windows_root() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().join("Git");
touch(&root.join("cmd").join(exe("git")));
touch(&root.join("usr").join("bin").join(exe("sh")));
let path_var = root.join("cmd").into_os_string();
assert_eq!(
resolve_script_interpreter("sh", Some(&path_var)).git_root,
Some(root)
);
let other = tmp.path().join("other-bin");
touch(&other.join(exe("bash")));
let path_var = other.clone().into_os_string();
assert_eq!(
resolve_script_interpreter("bash", Some(&path_var)),
ScriptInterpreter::bare(other.join(exe("bash")))
);
}
#[test]
fn git_for_windows_tool_dirs_lists_mingw_then_usr_bin() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().join("Git");
assert!(git_for_windows_tool_dirs(&root).is_empty());
touch(&root.join("usr").join("bin").join(exe("grep")));
assert_eq!(
git_for_windows_tool_dirs(&root),
vec![root.join("usr").join("bin")]
);
touch(&root.join("clangarm64").join("bin").join(exe("git")));
touch(&root.join("mingw64").join("bin").join(exe("git")));
assert_eq!(
git_for_windows_tool_dirs(&root),
vec![
root.join("mingw64").join("bin"),
root.join("usr").join("bin")
]
);
}
#[test]
fn prepend_to_child_path_puts_dirs_first() {
let sep = if cfg!(windows) { ";" } else { ":" };
let first = PathBuf::from("first");
let second = PathBuf::from("second");
let mut env = CommandEnvironment::default();
env.set.push(("HOME".into(), "home".into()));
env.set.push(("PATH".into(), "inherited".into()));
prepend_to_child_path(&mut env, &[first.clone(), second.clone()]);
assert_eq!(
child_path_var(&env),
Some(OsString::from(format!("first{sep}second{sep}inherited")))
);
assert_eq!(env.set.len(), 2);
assert_eq!(env.set[0].1, OsString::from("home"));
let mut empty_path = CommandEnvironment::default();
empty_path.set.push(("PATH".into(), "".into()));
prepend_to_child_path(&mut empty_path, std::slice::from_ref(&first));
assert_eq!(child_path_var(&empty_path), Some(OsString::from("first")));
let mut no_path = CommandEnvironment::default();
prepend_to_child_path(&mut no_path, std::slice::from_ref(&first));
assert_eq!(
no_path.set,
vec![(OsString::from("PATH"), OsString::from("first"))]
);
let mut unchanged = CommandEnvironment::default();
unchanged.set.push(("PATH".into(), "inherited".into()));
prepend_to_child_path(&mut unchanged, &[]);
assert_eq!(
child_path_var(&unchanged),
Some(OsString::from("inherited"))
);
}
#[test]
fn prepend_to_child_path_extends_the_inherited_process_path() {
let mut env = CommandEnvironment::inherit_process_only();
let inherited = child_path_var(&env);
prepend_to_child_path(&mut env, &[PathBuf::from("first")]);
let mut expected = OsString::from("first");
if let Some(inherited) = inherited.filter(|p| !p.is_empty()) {
expected.push(if cfg!(windows) { ";" } else { ":" });
expected.push(inherited);
}
assert_eq!(child_path_var(&env), Some(expected));
}
#[cfg(unix)]
#[test]
fn system_runner_runs_bare_sh_without_sh_on_path() {
let mut spec = CommandSpec::sh_dash_c_filter("printf ok");
spec.env = CommandEnvironment {
inherit_process: false,
set: vec![("PATH".into(), "/nonexistent".into())],
remove: Vec::new(),
};
let out = SystemCommandRunner
.spawn(&spec)
.expect("spawn")
.wait_with_output()
.expect("wait");
assert!(out.status.success);
assert_eq!(out.stdout, b"ok");
}
#[cfg(unix)]
#[test]
fn system_runner_runs_sh_dash_c_script_with_argument() {
let tmp = tempfile::tempdir().expect("tempdir");
let helper = tmp.path().join("h.sh");
std::fs::write(
&helper,
"#!/bin/sh\nif [ \"$1\" = get ]; then echo username=alice; fi\n",
)
.expect("write");
let mut perms = std::fs::metadata(&helper).expect("meta").permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&helper, perms).expect("chmod");
let script = format!("{} get", helper.display());
let spec = CommandSpec {
program: "sh".into(),
args: Vec::new(),
shell: Some(ShellInvocation::DashC {
script,
argv0: None,
args: Vec::new(),
}),
cwd: None,
env: CommandEnvironment::inherit_process_only(),
stdin: CommandStdin::Null,
stdout: CommandStdio::Pipe,
stderr: CommandStdio::Inherit,
};
let out = SystemCommandRunner
.spawn(&spec)
.expect("spawn")
.wait_with_output()
.expect("wait");
assert!(out.status.success);
assert!(
out.stdout.starts_with(b"username=alice"),
"runner stdout={:?} stderr={:?} status={:?}",
out.stdout,
out.stderr,
out.status
);
let direct = std::process::Command::new("sh")
.arg("-c")
.arg(format!("{} get", helper.display()))
.output()
.expect("direct");
assert!(
direct.stdout.starts_with(b"username=alice"),
"direct stdout={:?}",
direct.stdout
);
}
#[test]
fn wait_with_output_drains_stderr_and_stdout_concurrently() {
let script = "dd if=/dev/zero bs=1024 count=1024 >&2; printf ok";
let spec = CommandSpec {
program: "sh".into(),
args: Vec::new(),
shell: Some(ShellInvocation::DashC {
script: script.into(),
argv0: None,
args: Vec::new(),
}),
cwd: None,
env: CommandEnvironment::inherit_process_only(),
stdin: CommandStdin::Null,
stdout: CommandStdio::Pipe,
stderr: CommandStdio::Pipe,
};
let out = SystemCommandRunner
.spawn(&spec)
.expect("spawn")
.wait_with_output()
.expect("wait");
assert!(out.status.success);
assert_eq!(out.stdout, b"ok");
assert!(out.stderr.len() >= 1024);
}
#[test]
fn recording_runner_captures_spec_and_exit() {
let runner = RecordingRunner::always_success();
runner.push_response(RecordedResponse::Output {
stdout: b"ok".to_vec(),
code: 0,
});
let mut spec = CommandSpec::sh_dash_c_filter("echo hi");
spec.stdin = CommandStdin::Pipe(b"data".to_vec());
let handle = runner.spawn(&spec).expect("spawn");
let out = handle.wait_with_output().expect("wait");
assert!(out.status.success);
assert_eq!(out.stdout, b"ok");
let recorded = runner.specs();
assert_eq!(recorded.len(), 1);
match &recorded[0].stdin {
CommandStdin::Pipe(b) => assert_eq!(b, b"data"),
_ => panic!("expected pipe stdin"),
}
}
}