1use std::collections::VecDeque;
19use std::ffi::{OsStr, OsString};
20use std::io::{self, Cursor, Read, Write};
21use std::path::{Path, PathBuf};
22use std::process::{Child, ChildStderr, ChildStdin, ChildStdout, Command, ExitStatus, Stdio};
23use std::sync::{Arc, Mutex};
24use thiserror::Error;
25
26use crate::environment::Environment;
27
28#[derive(Debug, Error)]
30#[error("command handle is not backed by a live OS process")]
31pub struct CommandHandleError;
32
33#[derive(Debug, Clone, Default)]
35pub struct CommandEnvironment {
36 pub inherit_process: bool,
38 pub set: Vec<(OsString, OsString)>,
40 pub remove: Vec<OsString>,
42}
43
44impl CommandEnvironment {
45 #[must_use]
47 pub fn inherit_process_only() -> Self {
48 Self {
49 inherit_process: true,
50 set: Vec::new(),
51 remove: Vec::new(),
52 }
53 }
54
55 #[must_use]
60 pub fn from_repository_environment(env: &Environment, extra: &[(String, String)]) -> Self {
61 let mut set = env.subprocess_environment();
62 for (k, v) in extra {
63 let key = OsString::from(k.as_str());
64 let val = OsString::from(v.as_str());
65 if let Some(slot) = set.iter_mut().find(|(ek, _)| ek == &key) {
66 slot.1 = val;
67 } else {
68 set.push((key, val));
69 }
70 }
71 Self {
72 inherit_process: false,
73 set,
74 remove: Vec::new(),
75 }
76 }
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
81pub enum CommandStdio {
82 #[default]
83 Inherit,
85 Null,
87 Pipe,
89 ParentStderr,
91}
92
93#[derive(Debug, Clone, Default)]
95pub enum CommandStdin {
96 #[default]
97 Inherit,
99 Null,
101 Pipe(Vec<u8>),
103}
104
105#[derive(Debug, Clone)]
107pub enum ShellInvocation {
108 DashC {
110 script: String,
112 argv0: Option<String>,
114 args: Vec<OsString>,
116 },
117 ScriptPath {
119 script_path: PathBuf,
121 args: Vec<OsString>,
123 },
124}
125
126#[derive(Debug, Clone)]
128pub struct CommandSpec {
129 pub program: PathBuf,
136 pub args: Vec<OsString>,
138 pub shell: Option<ShellInvocation>,
140 pub cwd: Option<PathBuf>,
142 pub env: CommandEnvironment,
144 pub stdin: CommandStdin,
146 pub stdout: CommandStdio,
148 pub stderr: CommandStdio,
150}
151
152impl CommandSpec {
153 #[must_use]
155 pub fn sh_dash_c_filter(script: impl Into<String>) -> Self {
156 Self {
157 program: PathBuf::from("sh"),
158 args: Vec::new(),
159 shell: Some(ShellInvocation::DashC {
160 script: script.into(),
161 argv0: None,
162 args: Vec::new(),
163 }),
164 cwd: None,
165 env: CommandEnvironment::inherit_process_only(),
166 stdin: CommandStdin::Pipe(Vec::new()),
167 stdout: CommandStdio::Pipe,
168 stderr: CommandStdio::Inherit,
169 }
170 }
171}
172
173#[derive(Debug, Clone, Copy, PartialEq, Eq)]
175pub struct CommandExit {
176 pub code: Option<i32>,
178 pub success: bool,
180}
181
182impl From<ExitStatus> for CommandExit {
183 fn from(status: ExitStatus) -> Self {
184 Self {
185 code: status.code(),
186 success: status.success(),
187 }
188 }
189}
190
191#[derive(Debug, Clone)]
193pub struct CommandOutput {
194 pub status: CommandExit,
196 pub stdout: Vec<u8>,
198 pub stderr: Vec<u8>,
200}
201
202enum RunningCommandInner {
203 System {
204 child: Child,
205 stdin: Option<ChildStdin>,
206 stdout: Option<ChildStdout>,
207 stderr: Option<ChildStderr>,
208 },
209 Recording {
210 state: Arc<Mutex<RecordingState>>,
211 stdin: Option<RecordingStdinBuffer>,
212 },
213}
214
215struct RecordingStdinBuffer(Arc<Mutex<Vec<u8>>>);
216
217impl Write for RecordingStdinBuffer {
218 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
219 self.0.lock().unwrap_or_else(|e| e.into_inner()).write(buf)
220 }
221
222 fn flush(&mut self) -> io::Result<()> {
223 self.0.lock().unwrap_or_else(|e| e.into_inner()).flush()
224 }
225}
226
227struct RecordingState {
228 stdout: Option<Cursor<Vec<u8>>>,
229 stderr: Vec<u8>,
230 exit: CommandExit,
231}
232
233pub struct RunningCommand {
235 inner: RunningCommandInner,
236}
237
238impl RunningCommand {
239 #[must_use]
241 pub fn from_system(
242 child: Child,
243 stdin: Option<ChildStdin>,
244 stdout: Option<ChildStdout>,
245 stderr: Option<ChildStderr>,
246 ) -> Self {
247 Self {
248 inner: RunningCommandInner::System {
249 child,
250 stdin,
251 stdout,
252 stderr,
253 },
254 }
255 }
256
257 #[must_use]
259 pub fn from_recording(
260 exit: CommandExit,
261 stdout: Option<Vec<u8>>,
262 stderr: Vec<u8>,
263 piped_stdin: bool,
264 ) -> Self {
265 Self {
266 inner: RunningCommandInner::Recording {
267 state: Arc::new(Mutex::new(RecordingState {
268 stdout: stdout.map(Cursor::new),
269 stderr,
270 exit,
271 })),
272 stdin: piped_stdin.then(|| RecordingStdinBuffer(Arc::new(Mutex::new(Vec::new())))),
273 },
274 }
275 }
276
277 pub fn stdin_mut(&mut self) -> Option<&mut dyn Write> {
279 match &mut self.inner {
280 RunningCommandInner::System { stdin, .. } => {
281 stdin.as_mut().map(|s| s as &mut dyn Write)
282 }
283 RunningCommandInner::Recording { stdin, .. } => {
284 stdin.as_mut().map(|s| s as &mut dyn Write)
285 }
286 }
287 }
288
289 pub fn close_stdin(&mut self) {
291 match &mut self.inner {
292 RunningCommandInner::System { stdin, .. } => *stdin = None,
293 RunningCommandInner::Recording { stdin, .. } => *stdin = None,
294 }
295 }
296
297 pub fn stdout_mut(&mut self) -> Option<&mut dyn Read> {
299 match &mut self.inner {
300 RunningCommandInner::System { stdout, .. } => {
301 stdout.as_mut().map(|s| s as &mut dyn Read)
302 }
303 RunningCommandInner::Recording { .. } => None,
304 }
305 }
306
307 pub fn wait(self) -> io::Result<CommandExit> {
313 match self.inner {
314 RunningCommandInner::System { mut child, .. } => child.wait().map(Into::into),
315 RunningCommandInner::Recording { state, .. } => {
316 let guard = state.lock().unwrap_or_else(|e| e.into_inner());
317 Ok(guard.exit)
318 }
319 }
320 }
321
322 pub fn wait_with_output(self) -> io::Result<CommandOutput> {
328 match self.inner {
329 RunningCommandInner::System {
330 mut child,
331 stdout,
332 stderr,
333 ..
334 } => {
335 let (stdout_bytes, stderr_bytes) = drain_child_pipes(stdout, stderr)?;
336 let status = child.wait()?.into();
337 Ok(CommandOutput {
338 status,
339 stdout: stdout_bytes,
340 stderr: stderr_bytes,
341 })
342 }
343 RunningCommandInner::Recording { state, .. } => {
344 let mut guard = state.lock().unwrap_or_else(|e| e.into_inner());
345 let mut stdout_bytes = Vec::new();
346 if let Some(cursor) = &mut guard.stdout {
347 let _ = cursor.read_to_end(&mut stdout_bytes);
348 }
349 Ok(CommandOutput {
350 status: guard.exit,
351 stdout: stdout_bytes,
352 stderr: guard.stderr.clone(),
353 })
354 }
355 }
356 }
357
358 #[expect(clippy::type_complexity)]
364 pub fn into_system_parts(
365 self,
366 ) -> Result<
367 (
368 Child,
369 Option<ChildStdin>,
370 Option<ChildStdout>,
371 Option<ChildStderr>,
372 ),
373 CommandHandleError,
374 > {
375 match self.inner {
376 RunningCommandInner::System {
377 child,
378 stdin,
379 stdout,
380 stderr,
381 } => Ok((child, stdin, stdout, stderr)),
382 RunningCommandInner::Recording { .. } => Err(CommandHandleError),
383 }
384 }
385}
386
387fn drain_child_pipes(
388 stdout: Option<ChildStdout>,
389 stderr: Option<ChildStderr>,
390) -> io::Result<(Vec<u8>, Vec<u8>)> {
391 std::thread::scope(|scope| {
392 let out_task = stdout.map(|mut out| {
393 scope.spawn(move || {
394 let mut buf = Vec::new();
395 let _ = out.read_to_end(&mut buf);
396 buf
397 })
398 });
399 let err_task = stderr.map(|mut err| {
400 scope.spawn(move || {
401 let mut buf = Vec::new();
402 let _ = err.read_to_end(&mut buf);
403 buf
404 })
405 });
406 let stdout_bytes = out_task
407 .map(|h| h.join().unwrap_or_default())
408 .unwrap_or_default();
409 let stderr_bytes = err_task
410 .map(|h| h.join().unwrap_or_default())
411 .unwrap_or_default();
412 Ok((stdout_bytes, stderr_bytes))
413 })
414}
415
416pub trait CommandRunner: Send + Sync {
418 fn spawn(&self, spec: &CommandSpec) -> io::Result<RunningCommand>;
424}
425
426#[derive(Debug, Clone, Default)]
428pub struct SystemCommandRunner;
429
430impl SystemCommandRunner {
431 fn build_command(spec: &CommandSpec) -> Command {
433 let program = if spec.program.as_os_str() == SHELL_NAME {
434 shell_program(&spec.env)
435 } else {
436 spec.program.clone()
437 };
438 let mut cmd = match &spec.shell {
439 None => {
440 let mut c = Command::new(&program); c.args(&spec.args);
442 c
443 }
444 Some(ShellInvocation::DashC {
445 script,
446 argv0,
447 args,
448 }) => {
449 let mut c = Command::new(&program); c.arg("-c").arg(script);
451 if let Some(name) = argv0 {
452 c.arg(name);
453 }
454 c.args(args);
455 c
456 }
457 Some(ShellInvocation::ScriptPath { script_path, args }) => {
458 let mut c = Command::new(&program); c.arg(script_path);
460 c.args(args);
461 c
462 }
463 };
464
465 if let Some(cwd) = &spec.cwd {
466 cmd.current_dir(cwd);
467 }
468
469 apply_env(&mut cmd, &spec.env);
470 apply_stdio(&mut cmd, spec);
471 cmd
472 }
473}
474
475fn apply_env(cmd: &mut Command, env: &CommandEnvironment) {
476 if !env.inherit_process {
477 cmd.env_clear();
478 }
479 for key in &env.remove {
480 cmd.env_remove(key);
481 }
482 for (k, v) in &env.set {
483 cmd.env(k, v);
484 }
485}
486
487fn apply_stdio(cmd: &mut Command, spec: &CommandSpec) {
488 cmd.stdin(match spec.stdin {
489 CommandStdin::Inherit => Stdio::inherit(),
490 CommandStdin::Null => Stdio::null(),
491 CommandStdin::Pipe(_) => Stdio::piped(),
492 });
493 cmd.stdout(child_stdio(spec.stdout));
494 cmd.stderr(child_stdio(spec.stderr));
495}
496
497fn child_stdio(mode: CommandStdio) -> Stdio {
498 match mode {
499 CommandStdio::Inherit => Stdio::inherit(),
500 CommandStdio::Null => Stdio::null(),
501 CommandStdio::Pipe => Stdio::piped(),
502 CommandStdio::ParentStderr => std::io::stderr().into(),
503 }
504}
505
506const SHELL_NAME: &str = "sh";
508
509pub(crate) fn child_path_var(env: &CommandEnvironment) -> Option<OsString> {
512 env.set
513 .iter()
514 .rev()
515 .find(|(k, _)| is_path_var(k))
516 .map(|(_, v)| v.clone())
517 .or_else(|| {
518 env.inherit_process
519 .then(|| std::env::var_os("PATH"))
521 .flatten()
522 })
523}
524
525pub(crate) fn shell_program(env: &CommandEnvironment) -> PathBuf {
530 shell_interpreter(env).program
531}
532
533pub(crate) fn shell_interpreter(env: &CommandEnvironment) -> ScriptInterpreter {
535 if cfg!(windows) {
536 resolve_script_interpreter(SHELL_NAME, child_path_var(env).as_deref())
537 } else {
538 ScriptInterpreter::bare("/bin/sh")
539 }
540}
541
542fn find_program_in_dirs(name: &str, dirs: impl IntoIterator<Item = PathBuf>) -> Option<PathBuf> {
544 let suffix = std::env::consts::EXE_SUFFIX;
545 dirs.into_iter()
546 .filter(|dir| !dir.as_os_str().is_empty())
547 .find_map(|dir| {
548 let with_suffix = (!suffix.is_empty() && !name.ends_with(suffix))
549 .then(|| dir.join(format!("{name}{suffix}")));
550 with_suffix
551 .into_iter()
552 .chain(std::iter::once(dir.join(name)))
553 .find(|candidate| candidate.is_file())
554 })
555}
556
557fn find_program_in_path(name: &str, path_var: &OsStr) -> Option<PathBuf> {
559 find_program_in_dirs(name, std::env::split_paths(path_var))
560}
561
562fn is_wsl_launcher_dir(dir: &Path) -> bool {
565 dir.file_name().and_then(|n| n.to_str()).is_some_and(|n| {
566 n.eq_ignore_ascii_case("System32") || n.eq_ignore_ascii_case("WindowsApps")
567 })
568}
569
570#[derive(Debug, Clone, PartialEq, Eq)]
572pub(crate) struct ScriptInterpreter {
573 pub(crate) program: PathBuf,
574 pub(crate) git_root: Option<PathBuf>,
576}
577
578impl ScriptInterpreter {
579 pub(crate) fn bare(program: impl Into<PathBuf>) -> Self {
580 Self {
581 program: program.into(),
582 git_root: None,
583 }
584 }
585}
586
587pub(crate) fn resolve_script_interpreter(
594 name: &str,
595 path_var: Option<&OsStr>,
596) -> ScriptInterpreter {
597 let Some(path_var) = path_var else {
598 return ScriptInterpreter::bare(name);
599 };
600 let in_git_tree = || {
601 let git = find_program_in_path("git", path_var)?;
602 git.parent()?.ancestors().skip(1).take(2).find_map(|root| {
603 let program =
604 find_program_in_dirs(name, [root.join("usr").join("bin"), root.join("bin")])?;
605 Some(ScriptInterpreter {
606 program,
607 git_root: Some(root.to_path_buf()),
608 })
609 })
610 };
611 in_git_tree()
612 .or_else(|| {
613 find_program_in_dirs(
614 name,
615 std::env::split_paths(path_var).filter(|dir| !is_wsl_launcher_dir(dir)),
616 )
617 .map(ScriptInterpreter::bare)
618 })
619 .unwrap_or_else(|| ScriptInterpreter::bare(name))
620}
621
622pub(crate) fn git_for_windows_tool_dirs(root: &Path) -> Vec<PathBuf> {
627 const MINGW_PREFIXES: [&str; 4] = ["mingw64", "clangarm64", "ucrt64", "mingw32"];
628 let mingw_bin = MINGW_PREFIXES
629 .iter()
630 .map(|prefix| root.join(prefix).join("bin"))
631 .find(|dir| dir.is_dir());
632 mingw_bin
633 .into_iter()
634 .chain(std::iter::once(root.join("usr").join("bin")))
635 .filter(|dir| dir.is_dir())
636 .collect()
637}
638
639pub(crate) fn prepend_to_child_path(env: &mut CommandEnvironment, dirs: &[PathBuf]) {
644 if dirs.is_empty() {
645 return;
646 }
647 let Ok(mut path) = std::env::join_paths(dirs) else {
648 return;
649 };
650 if let Some(current) = child_path_var(env).filter(|p| !p.is_empty()) {
651 path.push(if cfg!(windows) { ";" } else { ":" });
652 path.push(current);
653 }
654 match env.set.iter_mut().rev().find(|(k, _)| is_path_var(k)) {
655 Some((_, value)) => *value = path,
656 None => env.set.push((OsString::from("PATH"), path)),
657 }
658}
659
660fn is_path_var(key: &OsStr) -> bool {
661 if cfg!(windows) {
662 key.eq_ignore_ascii_case("PATH")
663 } else {
664 key == "PATH"
665 }
666}
667
668impl CommandRunner for SystemCommandRunner {
669 fn spawn(&self, spec: &CommandSpec) -> io::Result<RunningCommand> {
670 let mut cmd = Self::build_command(spec);
671 let mut child = cmd.spawn()?;
672
673 let mut stdin = child.stdin.take();
674 if let CommandStdin::Pipe(ref bytes) = spec.stdin {
675 if let Some(ref mut stdin_handle) = stdin {
676 if !bytes.is_empty() {
677 let _ = stdin_handle.write_all(bytes);
678 stdin = None;
680 }
681 }
682 }
683
684 let stdout = child.stdout.take();
685 let stderr = child.stderr.take();
686 Ok(RunningCommand::from_system(child, stdin, stdout, stderr))
687 }
688}
689
690#[must_use]
692pub fn system_command_runner() -> Arc<dyn CommandRunner> {
693 Arc::new(SystemCommandRunner)
694}
695
696#[derive(Debug, Clone)]
698pub enum RecordedResponse {
699 Success,
701 Exit(i32),
703 Output {
705 stdout: Vec<u8>,
707 code: i32,
709 },
710}
711
712#[derive(Debug, Default)]
714pub struct RecordingRunner {
715 pub recorded: Mutex<Vec<CommandSpec>>,
717 stdin_payloads: Mutex<Vec<Arc<Mutex<Vec<u8>>>>>,
718 responses: Mutex<VecDeque<RecordedResponse>>,
719}
720
721impl RecordingRunner {
722 #[must_use]
724 pub fn always_success() -> Arc<Self> {
725 Arc::new(Self::default())
726 }
727
728 pub fn push_response(&self, response: RecordedResponse) {
730 self.responses
731 .lock()
732 .unwrap_or_else(|e| e.into_inner())
733 .push_back(response);
734 }
735
736 #[must_use]
738 pub fn specs(&self) -> Vec<CommandSpec> {
739 self.recorded
740 .lock()
741 .unwrap_or_else(|e| e.into_inner())
742 .clone()
743 }
744
745 #[must_use]
747 pub fn stdin_payloads(&self) -> Vec<Vec<u8>> {
748 self.stdin_payloads
749 .lock()
750 .unwrap_or_else(|e| e.into_inner())
751 .iter()
752 .map(|buf| buf.lock().unwrap_or_else(|e| e.into_inner()).clone())
753 .collect()
754 }
755}
756
757impl CommandRunner for RecordingRunner {
758 fn spawn(&self, spec: &CommandSpec) -> io::Result<RunningCommand> {
759 self.recorded
760 .lock()
761 .unwrap_or_else(|e| e.into_inner())
762 .push(spec.clone());
763
764 let response = self
765 .responses
766 .lock()
767 .unwrap_or_else(|e| e.into_inner())
768 .pop_front()
769 .unwrap_or(RecordedResponse::Success);
770
771 let piped_stdin = matches!(spec.stdin, CommandStdin::Pipe(_));
772
773 let (stdout_bytes, exit) = match response {
774 RecordedResponse::Success => (
775 None,
776 CommandExit {
777 code: Some(0),
778 success: true,
779 },
780 ),
781 RecordedResponse::Exit(code) => (
782 None,
783 CommandExit {
784 code: Some(code),
785 success: code == 0,
786 },
787 ),
788 RecordedResponse::Output { stdout, code } => (
789 Some(stdout),
790 CommandExit {
791 code: Some(code),
792 success: code == 0,
793 },
794 ),
795 };
796
797 let stdout_cursor = if spec.stdout == CommandStdio::Pipe {
798 Some(Cursor::new(stdout_bytes.unwrap_or_default()))
799 } else {
800 None
801 };
802
803 Ok(RunningCommand {
804 inner: RunningCommandInner::Recording {
805 state: Arc::new(Mutex::new(RecordingState {
806 stdout: stdout_cursor,
807 stderr: Vec::new(),
808 exit,
809 })),
810 stdin: piped_stdin.then(|| {
811 let buf = Arc::new(Mutex::new(Vec::new()));
812 self.stdin_payloads
813 .lock()
814 .unwrap_or_else(|e| e.into_inner())
815 .push(buf.clone());
816 RecordingStdinBuffer(buf)
817 }),
818 },
819 })
820 }
821}
822
823pub fn run_sh_dash_c(
825 runner: &dyn CommandRunner,
826 script: &str,
827 cwd: Option<&Path>,
828 stdin: CommandStdin,
829 stderr: CommandStdio,
830) -> io::Result<CommandOutput> {
831 let mut spec = CommandSpec::sh_dash_c_filter(script);
832 spec.cwd = cwd.map(Path::to_path_buf);
833 spec.stdin = stdin;
834 spec.stderr = stderr;
835 runner.spawn(&spec)?.wait_with_output()
836}
837
838pub fn run_program(
840 runner: &dyn CommandRunner,
841 program: &Path,
842 args: &[OsString],
843 cwd: Option<&Path>,
844 stdin: CommandStdin,
845 stderr: CommandStdio,
846) -> io::Result<CommandOutput> {
847 let spec = CommandSpec {
848 program: program.to_path_buf(),
849 args: args.to_vec(),
850 shell: None,
851 cwd: cwd.map(Path::to_path_buf),
852 env: CommandEnvironment::inherit_process_only(),
853 stdin,
854 stdout: CommandStdio::Pipe,
855 stderr,
856 };
857 runner.spawn(&spec)?.wait_with_output()
858}
859
860pub mod hooks {
862 pub use crate::hooks::*;
863}
864
865#[cfg(test)]
866mod tests {
867 use super::*;
868 #[cfg(unix)]
869 use std::os::unix::fs::PermissionsExt;
870
871 fn touch(path: &Path) {
872 std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir");
873 std::fs::write(path, b"").expect("write");
874 }
875
876 fn exe(name: &str) -> String {
877 format!("{name}{}", std::env::consts::EXE_SUFFIX)
878 }
879
880 #[test]
881 fn find_program_in_path_searches_entries_in_order() {
882 let tmp = tempfile::tempdir().expect("tempdir");
883 let first = tmp.path().join("first");
884 let second = tmp.path().join("second");
885 std::fs::create_dir_all(&first).expect("mkdir");
886 touch(&second.join(exe("sh")));
887 touch(&tmp.path().join("third").join(exe("sh")));
888 let path_var = std::env::join_paths([
889 PathBuf::new(),
890 first,
891 second.clone(),
892 tmp.path().join("third"),
893 ])
894 .expect("join");
895
896 assert_eq!(
897 find_program_in_path("sh", &path_var),
898 Some(second.join(exe("sh")))
899 );
900 assert_eq!(find_program_in_path("bash", &path_var), None);
901 assert_eq!(find_program_in_path("sh", OsStr::new("")), None);
902 }
903
904 #[test]
905 fn resolve_script_interpreter_prefers_path() {
906 let tmp = tempfile::tempdir().expect("tempdir");
907 let bin = tmp.path().join("bin");
908 touch(&bin.join(exe("bash")));
909 let path_var = bin.clone().into_os_string();
910 assert_eq!(
911 resolve_script_interpreter("bash", Some(&path_var)).program,
912 bin.join(exe("bash"))
913 );
914 }
915
916 #[test]
917 fn resolve_script_interpreter_finds_git_for_windows_shell() {
918 for git_dir in [&["cmd"][..], &["mingw64", "bin"][..]] {
919 let tmp = tempfile::tempdir().expect("tempdir");
920 let root = tmp.path().join("Git");
921 let git_bin = git_dir.iter().fold(root.clone(), |p, c| p.join(c));
922 touch(&git_bin.join(exe("git")));
923 touch(&root.join("usr").join("bin").join(exe("sh")));
924 let path_var = git_bin.into_os_string();
925 assert_eq!(
926 resolve_script_interpreter("sh", Some(&path_var)).program,
927 root.join("usr").join("bin").join(exe("sh")),
928 "git under {git_dir:?}"
929 );
930 }
931 }
932
933 #[test]
934 fn resolve_script_interpreter_prefers_git_tree_over_earlier_path_entries() {
935 let tmp = tempfile::tempdir().expect("tempdir");
936 let other = tmp.path().join("other-bin");
937 touch(&other.join(exe("bash")));
938 let root = tmp.path().join("Git");
939 touch(&root.join("cmd").join(exe("git")));
940 touch(&root.join("usr").join("bin").join(exe("bash")));
941 let path_var = std::env::join_paths([other, root.join("cmd")]).expect("join");
942 assert_eq!(
943 resolve_script_interpreter("bash", Some(&path_var)).program,
944 root.join("usr").join("bin").join(exe("bash"))
945 );
946 }
947
948 #[test]
949 fn resolve_script_interpreter_skips_wsl_launchers() {
950 let tmp = tempfile::tempdir().expect("tempdir");
951 let system32 = tmp.path().join("Windows").join("System32");
952 let apps = tmp.path().join("WindowsApps");
953 let msys = tmp.path().join("msys64").join("usr").join("bin");
954 touch(&system32.join(exe("bash")));
955 touch(&apps.join(exe("bash")));
956 touch(&msys.join(exe("bash")));
957 let path_var =
958 std::env::join_paths([system32.clone(), apps.clone(), msys.clone()]).expect("join");
959 assert_eq!(
960 resolve_script_interpreter("bash", Some(&path_var)).program,
961 msys.join(exe("bash"))
962 );
963 let only_wsl = std::env::join_paths([system32, apps]).expect("join");
964 assert_eq!(
965 resolve_script_interpreter("bash", Some(&only_wsl)).program,
966 PathBuf::from("bash")
967 );
968 }
969
970 #[test]
971 fn resolve_script_interpreter_falls_back_to_bare_name() {
972 let tmp = tempfile::tempdir().expect("tempdir");
973 let path_var = tmp.path().as_os_str().to_os_string();
974 assert_eq!(
975 resolve_script_interpreter("sh", Some(&path_var)).program,
976 PathBuf::from("sh")
977 );
978 assert_eq!(
979 resolve_script_interpreter("sh", None),
980 ScriptInterpreter::bare("sh")
981 );
982 }
983
984 #[test]
985 fn child_path_var_uses_last_explicit_path() {
986 let mut env = CommandEnvironment::default();
987 assert_eq!(child_path_var(&env), None);
988 env.set.push(("PATH".into(), "/first".into()));
989 env.set.push(("HOME".into(), "/home/u".into()));
990 env.set.push(("PATH".into(), "/second".into()));
991 assert_eq!(child_path_var(&env), Some(OsString::from("/second")));
992 }
993
994 #[cfg(windows)]
995 #[test]
996 fn child_path_var_ignores_case_on_windows() {
997 let mut env = CommandEnvironment::default();
998 env.set.push(("Path".into(), r"C:\Git\cmd".into()));
999 assert_eq!(child_path_var(&env), Some(OsString::from(r"C:\Git\cmd")));
1000 }
1001
1002 #[test]
1003 fn child_path_var_falls_back_to_process_path_only_when_inheriting() {
1004 let inherit = CommandEnvironment::inherit_process_only();
1005 assert_eq!(child_path_var(&inherit), std::env::var_os("PATH"));
1006 assert_eq!(child_path_var(&CommandEnvironment::default()), None);
1007 }
1008
1009 #[cfg(unix)]
1010 #[test]
1011 fn shell_program_is_bin_sh_on_unix() {
1012 let mut env = CommandEnvironment::default();
1013 env.set.push(("PATH".into(), "/nonexistent".into()));
1014 assert_eq!(shell_program(&env), PathBuf::from("/bin/sh"));
1015 }
1016
1017 #[cfg(windows)]
1018 #[test]
1019 fn shell_program_finds_git_for_windows_shell_from_cmd_dir() {
1020 let tmp = tempfile::tempdir().expect("tempdir");
1021 let root = tmp.path().join("Git");
1022 touch(&root.join("cmd").join(exe("git")));
1023 touch(&root.join("usr").join("bin").join(exe("sh")));
1024 let mut env = CommandEnvironment::default();
1025 env.set
1026 .push(("Path".into(), root.join("cmd").into_os_string()));
1027 assert_eq!(
1028 shell_program(&env),
1029 root.join("usr").join("bin").join(exe("sh"))
1030 );
1031 }
1032
1033 #[test]
1034 fn resolve_script_interpreter_reports_the_git_for_windows_root() {
1035 let tmp = tempfile::tempdir().expect("tempdir");
1036 let root = tmp.path().join("Git");
1037 touch(&root.join("cmd").join(exe("git")));
1038 touch(&root.join("usr").join("bin").join(exe("sh")));
1039 let path_var = root.join("cmd").into_os_string();
1040 assert_eq!(
1041 resolve_script_interpreter("sh", Some(&path_var)).git_root,
1042 Some(root)
1043 );
1044
1045 let other = tmp.path().join("other-bin");
1046 touch(&other.join(exe("bash")));
1047 let path_var = other.clone().into_os_string();
1048 assert_eq!(
1049 resolve_script_interpreter("bash", Some(&path_var)),
1050 ScriptInterpreter::bare(other.join(exe("bash")))
1051 );
1052 }
1053
1054 #[test]
1055 fn git_for_windows_tool_dirs_lists_mingw_then_usr_bin() {
1056 let tmp = tempfile::tempdir().expect("tempdir");
1057 let root = tmp.path().join("Git");
1058 assert!(git_for_windows_tool_dirs(&root).is_empty());
1059
1060 touch(&root.join("usr").join("bin").join(exe("grep")));
1061 assert_eq!(
1062 git_for_windows_tool_dirs(&root),
1063 vec![root.join("usr").join("bin")]
1064 );
1065
1066 touch(&root.join("clangarm64").join("bin").join(exe("git")));
1067 touch(&root.join("mingw64").join("bin").join(exe("git")));
1068 assert_eq!(
1069 git_for_windows_tool_dirs(&root),
1070 vec![
1071 root.join("mingw64").join("bin"),
1072 root.join("usr").join("bin")
1073 ]
1074 );
1075 }
1076
1077 #[test]
1078 fn prepend_to_child_path_puts_dirs_first() {
1079 let sep = if cfg!(windows) { ";" } else { ":" };
1080 let first = PathBuf::from("first");
1081 let second = PathBuf::from("second");
1082 let mut env = CommandEnvironment::default();
1083 env.set.push(("HOME".into(), "home".into()));
1084 env.set.push(("PATH".into(), "inherited".into()));
1085 prepend_to_child_path(&mut env, &[first.clone(), second.clone()]);
1086 assert_eq!(
1087 child_path_var(&env),
1088 Some(OsString::from(format!("first{sep}second{sep}inherited")))
1089 );
1090 assert_eq!(env.set.len(), 2);
1091 assert_eq!(env.set[0].1, OsString::from("home"));
1092
1093 let mut empty_path = CommandEnvironment::default();
1094 empty_path.set.push(("PATH".into(), "".into()));
1095 prepend_to_child_path(&mut empty_path, std::slice::from_ref(&first));
1096 assert_eq!(child_path_var(&empty_path), Some(OsString::from("first")));
1097
1098 let mut no_path = CommandEnvironment::default();
1099 prepend_to_child_path(&mut no_path, std::slice::from_ref(&first));
1100 assert_eq!(
1101 no_path.set,
1102 vec![(OsString::from("PATH"), OsString::from("first"))]
1103 );
1104
1105 let mut unchanged = CommandEnvironment::default();
1106 unchanged.set.push(("PATH".into(), "inherited".into()));
1107 prepend_to_child_path(&mut unchanged, &[]);
1108 assert_eq!(
1109 child_path_var(&unchanged),
1110 Some(OsString::from("inherited"))
1111 );
1112 }
1113
1114 #[test]
1115 fn prepend_to_child_path_extends_the_inherited_process_path() {
1116 let mut env = CommandEnvironment::inherit_process_only();
1117 let inherited = child_path_var(&env);
1118 prepend_to_child_path(&mut env, &[PathBuf::from("first")]);
1119 let mut expected = OsString::from("first");
1120 if let Some(inherited) = inherited.filter(|p| !p.is_empty()) {
1121 expected.push(if cfg!(windows) { ";" } else { ":" });
1122 expected.push(inherited);
1123 }
1124 assert_eq!(child_path_var(&env), Some(expected));
1125 }
1126
1127 #[cfg(unix)]
1128 #[test]
1129 fn system_runner_runs_bare_sh_without_sh_on_path() {
1130 let mut spec = CommandSpec::sh_dash_c_filter("printf ok");
1131 spec.env = CommandEnvironment {
1132 inherit_process: false,
1133 set: vec![("PATH".into(), "/nonexistent".into())],
1134 remove: Vec::new(),
1135 };
1136 let out = SystemCommandRunner
1137 .spawn(&spec)
1138 .expect("spawn")
1139 .wait_with_output()
1140 .expect("wait");
1141 assert!(out.status.success);
1142 assert_eq!(out.stdout, b"ok");
1143 }
1144
1145 #[cfg(unix)]
1146 #[test]
1147 fn system_runner_runs_sh_dash_c_script_with_argument() {
1148 let tmp = tempfile::tempdir().expect("tempdir");
1149 let helper = tmp.path().join("h.sh");
1150 std::fs::write(
1151 &helper,
1152 "#!/bin/sh\nif [ \"$1\" = get ]; then echo username=alice; fi\n",
1153 )
1154 .expect("write");
1155 let mut perms = std::fs::metadata(&helper).expect("meta").permissions();
1156 perms.set_mode(0o755);
1157 std::fs::set_permissions(&helper, perms).expect("chmod");
1158
1159 let script = format!("{} get", helper.display());
1160 let spec = CommandSpec {
1161 program: "sh".into(),
1162 args: Vec::new(),
1163 shell: Some(ShellInvocation::DashC {
1164 script,
1165 argv0: None,
1166 args: Vec::new(),
1167 }),
1168 cwd: None,
1169 env: CommandEnvironment::inherit_process_only(),
1170 stdin: CommandStdin::Null,
1171 stdout: CommandStdio::Pipe,
1172 stderr: CommandStdio::Inherit,
1173 };
1174 let out = SystemCommandRunner
1175 .spawn(&spec)
1176 .expect("spawn")
1177 .wait_with_output()
1178 .expect("wait");
1179 assert!(out.status.success);
1180 assert!(
1181 out.stdout.starts_with(b"username=alice"),
1182 "runner stdout={:?} stderr={:?} status={:?}",
1183 out.stdout,
1184 out.stderr,
1185 out.status
1186 );
1187
1188 let direct = std::process::Command::new("sh")
1189 .arg("-c")
1190 .arg(format!("{} get", helper.display()))
1191 .output()
1192 .expect("direct");
1193 assert!(
1194 direct.stdout.starts_with(b"username=alice"),
1195 "direct stdout={:?}",
1196 direct.stdout
1197 );
1198 }
1199
1200 #[test]
1201 fn wait_with_output_drains_stderr_and_stdout_concurrently() {
1202 let script = "dd if=/dev/zero bs=1024 count=1024 >&2; printf ok";
1203 let spec = CommandSpec {
1204 program: "sh".into(),
1205 args: Vec::new(),
1206 shell: Some(ShellInvocation::DashC {
1207 script: script.into(),
1208 argv0: None,
1209 args: Vec::new(),
1210 }),
1211 cwd: None,
1212 env: CommandEnvironment::inherit_process_only(),
1213 stdin: CommandStdin::Null,
1214 stdout: CommandStdio::Pipe,
1215 stderr: CommandStdio::Pipe,
1216 };
1217 let out = SystemCommandRunner
1218 .spawn(&spec)
1219 .expect("spawn")
1220 .wait_with_output()
1221 .expect("wait");
1222 assert!(out.status.success);
1223 assert_eq!(out.stdout, b"ok");
1224 assert!(out.stderr.len() >= 1024);
1225 }
1226
1227 #[test]
1228 fn recording_runner_captures_spec_and_exit() {
1229 let runner = RecordingRunner::always_success();
1230 runner.push_response(RecordedResponse::Output {
1231 stdout: b"ok".to_vec(),
1232 code: 0,
1233 });
1234 let mut spec = CommandSpec::sh_dash_c_filter("echo hi");
1235 spec.stdin = CommandStdin::Pipe(b"data".to_vec());
1236 let handle = runner.spawn(&spec).expect("spawn");
1237 let out = handle.wait_with_output().expect("wait");
1238 assert!(out.status.success);
1239 assert_eq!(out.stdout, b"ok");
1240 let recorded = runner.specs();
1241 assert_eq!(recorded.len(), 1);
1242 match &recorded[0].stdin {
1243 CommandStdin::Pipe(b) => assert_eq!(b, b"data"),
1244 _ => panic!("expected pipe stdin"),
1245 }
1246 }
1247}