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 pub fn into_child(self) -> Result<Child, CommandHandleError> {
364 match self.inner {
365 RunningCommandInner::System { child, .. } => Ok(child),
366 RunningCommandInner::Recording { .. } => Err(CommandHandleError),
367 }
368 }
369
370 #[expect(clippy::type_complexity)]
376 pub fn into_system_parts(
377 self,
378 ) -> Result<
379 (
380 Child,
381 Option<ChildStdin>,
382 Option<ChildStdout>,
383 Option<ChildStderr>,
384 ),
385 CommandHandleError,
386 > {
387 match self.inner {
388 RunningCommandInner::System {
389 child,
390 stdin,
391 stdout,
392 stderr,
393 } => Ok((child, stdin, stdout, stderr)),
394 RunningCommandInner::Recording { .. } => Err(CommandHandleError),
395 }
396 }
397}
398
399fn drain_child_pipes(
400 stdout: Option<ChildStdout>,
401 stderr: Option<ChildStderr>,
402) -> io::Result<(Vec<u8>, Vec<u8>)> {
403 std::thread::scope(|scope| {
404 let out_task = stdout.map(|mut out| {
405 scope.spawn(move || {
406 let mut buf = Vec::new();
407 let _ = out.read_to_end(&mut buf);
408 buf
409 })
410 });
411 let err_task = stderr.map(|mut err| {
412 scope.spawn(move || {
413 let mut buf = Vec::new();
414 let _ = err.read_to_end(&mut buf);
415 buf
416 })
417 });
418 let stdout_bytes = out_task
419 .map(|h| h.join().unwrap_or_default())
420 .unwrap_or_default();
421 let stderr_bytes = err_task
422 .map(|h| h.join().unwrap_or_default())
423 .unwrap_or_default();
424 Ok((stdout_bytes, stderr_bytes))
425 })
426}
427
428pub trait CommandRunner: Send + Sync {
430 fn spawn(&self, spec: &CommandSpec) -> io::Result<RunningCommand>;
436}
437
438#[derive(Debug, Clone, Default)]
440pub struct SystemCommandRunner;
441
442impl SystemCommandRunner {
443 fn build_command(spec: &CommandSpec) -> Command {
445 let program = if spec.program.as_os_str() == SHELL_NAME {
446 shell_program(&spec.env)
447 } else {
448 spec.program.clone()
449 };
450 let mut cmd = match &spec.shell {
451 None => {
452 let mut c = Command::new(&program); c.args(&spec.args);
454 c
455 }
456 Some(ShellInvocation::DashC {
457 script,
458 argv0,
459 args,
460 }) => {
461 let mut c = Command::new(&program); c.arg("-c").arg(script);
463 if let Some(name) = argv0 {
464 c.arg(name);
465 }
466 c.args(args);
467 c
468 }
469 Some(ShellInvocation::ScriptPath { script_path, args }) => {
470 let mut c = Command::new(&program); c.arg(script_path);
472 c.args(args);
473 c
474 }
475 };
476
477 if let Some(cwd) = &spec.cwd {
478 cmd.current_dir(cwd);
479 }
480
481 apply_env(&mut cmd, &spec.env);
482 apply_stdio(&mut cmd, spec);
483 cmd
484 }
485}
486
487fn apply_env(cmd: &mut Command, env: &CommandEnvironment) {
488 if !env.inherit_process {
489 cmd.env_clear();
490 }
491 for key in &env.remove {
492 cmd.env_remove(key);
493 }
494 for (k, v) in &env.set {
495 cmd.env(k, v);
496 }
497}
498
499fn apply_stdio(cmd: &mut Command, spec: &CommandSpec) {
500 cmd.stdin(match spec.stdin {
501 CommandStdin::Inherit => Stdio::inherit(),
502 CommandStdin::Null => Stdio::null(),
503 CommandStdin::Pipe(_) => Stdio::piped(),
504 });
505 cmd.stdout(child_stdio(spec.stdout));
506 cmd.stderr(child_stdio(spec.stderr));
507}
508
509fn child_stdio(mode: CommandStdio) -> Stdio {
510 match mode {
511 CommandStdio::Inherit => Stdio::inherit(),
512 CommandStdio::Null => Stdio::null(),
513 CommandStdio::Pipe => Stdio::piped(),
514 CommandStdio::ParentStderr => std::io::stderr().into(),
515 }
516}
517
518const SHELL_NAME: &str = "sh";
520
521pub(crate) fn child_path_var(env: &CommandEnvironment) -> Option<OsString> {
524 env.set
525 .iter()
526 .rev()
527 .find(|(k, _)| is_path_var(k))
528 .map(|(_, v)| v.clone())
529 .or_else(|| {
530 env.inherit_process
531 .then(|| std::env::var_os("PATH"))
533 .flatten()
534 })
535}
536
537pub(crate) fn shell_program(env: &CommandEnvironment) -> PathBuf {
542 shell_interpreter(env).program
543}
544
545pub(crate) fn shell_interpreter(env: &CommandEnvironment) -> ScriptInterpreter {
547 if cfg!(windows) {
548 resolve_script_interpreter(SHELL_NAME, child_path_var(env).as_deref())
549 } else {
550 ScriptInterpreter::bare("/bin/sh")
551 }
552}
553
554fn find_program_in_dirs(name: &str, dirs: impl IntoIterator<Item = PathBuf>) -> Option<PathBuf> {
556 let suffix = std::env::consts::EXE_SUFFIX;
557 dirs.into_iter()
558 .filter(|dir| !dir.as_os_str().is_empty())
559 .find_map(|dir| {
560 let with_suffix = (!suffix.is_empty() && !name.ends_with(suffix))
561 .then(|| dir.join(format!("{name}{suffix}")));
562 with_suffix
563 .into_iter()
564 .chain(std::iter::once(dir.join(name)))
565 .find(|candidate| candidate.is_file())
566 })
567}
568
569fn find_program_in_path(name: &str, path_var: &OsStr) -> Option<PathBuf> {
571 find_program_in_dirs(name, std::env::split_paths(path_var))
572}
573
574fn is_wsl_launcher_dir(dir: &Path) -> bool {
577 dir.file_name().and_then(|n| n.to_str()).is_some_and(|n| {
578 n.eq_ignore_ascii_case("System32") || n.eq_ignore_ascii_case("WindowsApps")
579 })
580}
581
582#[derive(Debug, Clone, PartialEq, Eq)]
584pub(crate) struct ScriptInterpreter {
585 pub(crate) program: PathBuf,
586 pub(crate) git_root: Option<PathBuf>,
588}
589
590impl ScriptInterpreter {
591 pub(crate) fn bare(program: impl Into<PathBuf>) -> Self {
592 Self {
593 program: program.into(),
594 git_root: None,
595 }
596 }
597}
598
599pub(crate) fn resolve_script_interpreter(
606 name: &str,
607 path_var: Option<&OsStr>,
608) -> ScriptInterpreter {
609 let Some(path_var) = path_var else {
610 return ScriptInterpreter::bare(name);
611 };
612 let in_git_tree = || {
613 let git = find_program_in_path("git", path_var)?;
614 git.parent()?.ancestors().skip(1).take(2).find_map(|root| {
615 let program =
616 find_program_in_dirs(name, [root.join("usr").join("bin"), root.join("bin")])?;
617 Some(ScriptInterpreter {
618 program,
619 git_root: Some(root.to_path_buf()),
620 })
621 })
622 };
623 in_git_tree()
624 .or_else(|| {
625 find_program_in_dirs(
626 name,
627 std::env::split_paths(path_var).filter(|dir| !is_wsl_launcher_dir(dir)),
628 )
629 .map(ScriptInterpreter::bare)
630 })
631 .unwrap_or_else(|| ScriptInterpreter::bare(name))
632}
633
634pub(crate) fn git_for_windows_tool_dirs(root: &Path) -> Vec<PathBuf> {
639 const MINGW_PREFIXES: [&str; 4] = ["mingw64", "clangarm64", "ucrt64", "mingw32"];
640 let mingw_bin = MINGW_PREFIXES
641 .iter()
642 .map(|prefix| root.join(prefix).join("bin"))
643 .find(|dir| dir.is_dir());
644 mingw_bin
645 .into_iter()
646 .chain(std::iter::once(root.join("usr").join("bin")))
647 .filter(|dir| dir.is_dir())
648 .collect()
649}
650
651pub(crate) fn prepend_to_child_path(env: &mut CommandEnvironment, dirs: &[PathBuf]) {
656 if dirs.is_empty() {
657 return;
658 }
659 let Ok(mut path) = std::env::join_paths(dirs) else {
660 return;
661 };
662 if let Some(current) = child_path_var(env).filter(|p| !p.is_empty()) {
663 path.push(if cfg!(windows) { ";" } else { ":" });
664 path.push(current);
665 }
666 match env.set.iter_mut().rev().find(|(k, _)| is_path_var(k)) {
667 Some((_, value)) => *value = path,
668 None => env.set.push((OsString::from("PATH"), path)),
669 }
670}
671
672fn is_path_var(key: &OsStr) -> bool {
673 if cfg!(windows) {
674 key.eq_ignore_ascii_case("PATH")
675 } else {
676 key == "PATH"
677 }
678}
679
680impl CommandRunner for SystemCommandRunner {
681 fn spawn(&self, spec: &CommandSpec) -> io::Result<RunningCommand> {
682 let mut cmd = Self::build_command(spec);
683 let mut child = cmd.spawn()?;
684
685 let mut stdin = child.stdin.take();
686 if let CommandStdin::Pipe(ref bytes) = spec.stdin {
687 if let Some(ref mut stdin_handle) = stdin {
688 if !bytes.is_empty() {
689 let _ = stdin_handle.write_all(bytes);
690 stdin = None;
692 }
693 }
694 }
695
696 let stdout = child.stdout.take();
697 let stderr = child.stderr.take();
698 Ok(RunningCommand::from_system(child, stdin, stdout, stderr))
699 }
700}
701
702#[must_use]
704pub fn system_command_runner() -> Arc<dyn CommandRunner> {
705 Arc::new(SystemCommandRunner)
706}
707
708#[derive(Debug, Clone)]
710pub enum RecordedResponse {
711 Success,
713 Exit(i32),
715 Output {
717 stdout: Vec<u8>,
719 code: i32,
721 },
722}
723
724#[derive(Debug, Default)]
726pub struct RecordingRunner {
727 pub recorded: Mutex<Vec<CommandSpec>>,
729 stdin_payloads: Mutex<Vec<Arc<Mutex<Vec<u8>>>>>,
730 responses: Mutex<VecDeque<RecordedResponse>>,
731}
732
733impl RecordingRunner {
734 #[must_use]
736 pub fn always_success() -> Arc<Self> {
737 Arc::new(Self::default())
738 }
739
740 pub fn push_response(&self, response: RecordedResponse) {
742 self.responses
743 .lock()
744 .unwrap_or_else(|e| e.into_inner())
745 .push_back(response);
746 }
747
748 #[must_use]
750 pub fn specs(&self) -> Vec<CommandSpec> {
751 self.recorded
752 .lock()
753 .unwrap_or_else(|e| e.into_inner())
754 .clone()
755 }
756
757 #[must_use]
759 pub fn stdin_payloads(&self) -> Vec<Vec<u8>> {
760 self.stdin_payloads
761 .lock()
762 .unwrap_or_else(|e| e.into_inner())
763 .iter()
764 .map(|buf| buf.lock().unwrap_or_else(|e| e.into_inner()).clone())
765 .collect()
766 }
767}
768
769impl CommandRunner for RecordingRunner {
770 fn spawn(&self, spec: &CommandSpec) -> io::Result<RunningCommand> {
771 self.recorded
772 .lock()
773 .unwrap_or_else(|e| e.into_inner())
774 .push(spec.clone());
775
776 let response = self
777 .responses
778 .lock()
779 .unwrap_or_else(|e| e.into_inner())
780 .pop_front()
781 .unwrap_or(RecordedResponse::Success);
782
783 let piped_stdin = matches!(spec.stdin, CommandStdin::Pipe(_));
784
785 let (stdout_bytes, exit) = match response {
786 RecordedResponse::Success => (
787 None,
788 CommandExit {
789 code: Some(0),
790 success: true,
791 },
792 ),
793 RecordedResponse::Exit(code) => (
794 None,
795 CommandExit {
796 code: Some(code),
797 success: code == 0,
798 },
799 ),
800 RecordedResponse::Output { stdout, code } => (
801 Some(stdout),
802 CommandExit {
803 code: Some(code),
804 success: code == 0,
805 },
806 ),
807 };
808
809 let stdout_cursor = if spec.stdout == CommandStdio::Pipe {
810 Some(Cursor::new(stdout_bytes.unwrap_or_default()))
811 } else {
812 None
813 };
814
815 Ok(RunningCommand {
816 inner: RunningCommandInner::Recording {
817 state: Arc::new(Mutex::new(RecordingState {
818 stdout: stdout_cursor,
819 stderr: Vec::new(),
820 exit,
821 })),
822 stdin: piped_stdin.then(|| {
823 let buf = Arc::new(Mutex::new(Vec::new()));
824 self.stdin_payloads
825 .lock()
826 .unwrap_or_else(|e| e.into_inner())
827 .push(buf.clone());
828 RecordingStdinBuffer(buf)
829 }),
830 },
831 })
832 }
833}
834
835pub fn run_sh_dash_c(
837 runner: &dyn CommandRunner,
838 script: &str,
839 cwd: Option<&Path>,
840 stdin: CommandStdin,
841 stderr: CommandStdio,
842) -> io::Result<CommandOutput> {
843 let mut spec = CommandSpec::sh_dash_c_filter(script);
844 spec.cwd = cwd.map(Path::to_path_buf);
845 spec.stdin = stdin;
846 spec.stderr = stderr;
847 runner.spawn(&spec)?.wait_with_output()
848}
849
850pub fn run_program(
852 runner: &dyn CommandRunner,
853 program: &Path,
854 args: &[OsString],
855 cwd: Option<&Path>,
856 stdin: CommandStdin,
857 stderr: CommandStdio,
858) -> io::Result<CommandOutput> {
859 let spec = CommandSpec {
860 program: program.to_path_buf(),
861 args: args.to_vec(),
862 shell: None,
863 cwd: cwd.map(Path::to_path_buf),
864 env: CommandEnvironment::inherit_process_only(),
865 stdin,
866 stdout: CommandStdio::Pipe,
867 stderr,
868 };
869 runner.spawn(&spec)?.wait_with_output()
870}
871
872pub mod hooks {
874 pub use crate::hooks::*;
875}
876
877#[cfg(test)]
878mod tests {
879 use super::*;
880 #[cfg(unix)]
881 use std::os::unix::fs::PermissionsExt;
882
883 fn touch(path: &Path) {
884 std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir");
885 std::fs::write(path, b"").expect("write");
886 }
887
888 fn exe(name: &str) -> String {
889 format!("{name}{}", std::env::consts::EXE_SUFFIX)
890 }
891
892 #[test]
893 fn find_program_in_path_searches_entries_in_order() {
894 let tmp = tempfile::tempdir().expect("tempdir");
895 let first = tmp.path().join("first");
896 let second = tmp.path().join("second");
897 std::fs::create_dir_all(&first).expect("mkdir");
898 touch(&second.join(exe("sh")));
899 touch(&tmp.path().join("third").join(exe("sh")));
900 let path_var = std::env::join_paths([
901 PathBuf::new(),
902 first,
903 second.clone(),
904 tmp.path().join("third"),
905 ])
906 .expect("join");
907
908 assert_eq!(
909 find_program_in_path("sh", &path_var),
910 Some(second.join(exe("sh")))
911 );
912 assert_eq!(find_program_in_path("bash", &path_var), None);
913 assert_eq!(find_program_in_path("sh", OsStr::new("")), None);
914 }
915
916 #[test]
917 fn resolve_script_interpreter_prefers_path() {
918 let tmp = tempfile::tempdir().expect("tempdir");
919 let bin = tmp.path().join("bin");
920 touch(&bin.join(exe("bash")));
921 let path_var = bin.clone().into_os_string();
922 assert_eq!(
923 resolve_script_interpreter("bash", Some(&path_var)).program,
924 bin.join(exe("bash"))
925 );
926 }
927
928 #[test]
929 fn resolve_script_interpreter_finds_git_for_windows_shell() {
930 for git_dir in [&["cmd"][..], &["mingw64", "bin"][..]] {
931 let tmp = tempfile::tempdir().expect("tempdir");
932 let root = tmp.path().join("Git");
933 let git_bin = git_dir.iter().fold(root.clone(), |p, c| p.join(c));
934 touch(&git_bin.join(exe("git")));
935 touch(&root.join("usr").join("bin").join(exe("sh")));
936 let path_var = git_bin.into_os_string();
937 assert_eq!(
938 resolve_script_interpreter("sh", Some(&path_var)).program,
939 root.join("usr").join("bin").join(exe("sh")),
940 "git under {git_dir:?}"
941 );
942 }
943 }
944
945 #[test]
946 fn resolve_script_interpreter_prefers_git_tree_over_earlier_path_entries() {
947 let tmp = tempfile::tempdir().expect("tempdir");
948 let other = tmp.path().join("other-bin");
949 touch(&other.join(exe("bash")));
950 let root = tmp.path().join("Git");
951 touch(&root.join("cmd").join(exe("git")));
952 touch(&root.join("usr").join("bin").join(exe("bash")));
953 let path_var = std::env::join_paths([other, root.join("cmd")]).expect("join");
954 assert_eq!(
955 resolve_script_interpreter("bash", Some(&path_var)).program,
956 root.join("usr").join("bin").join(exe("bash"))
957 );
958 }
959
960 #[test]
961 fn resolve_script_interpreter_skips_wsl_launchers() {
962 let tmp = tempfile::tempdir().expect("tempdir");
963 let system32 = tmp.path().join("Windows").join("System32");
964 let apps = tmp.path().join("WindowsApps");
965 let msys = tmp.path().join("msys64").join("usr").join("bin");
966 touch(&system32.join(exe("bash")));
967 touch(&apps.join(exe("bash")));
968 touch(&msys.join(exe("bash")));
969 let path_var =
970 std::env::join_paths([system32.clone(), apps.clone(), msys.clone()]).expect("join");
971 assert_eq!(
972 resolve_script_interpreter("bash", Some(&path_var)).program,
973 msys.join(exe("bash"))
974 );
975 let only_wsl = std::env::join_paths([system32, apps]).expect("join");
976 assert_eq!(
977 resolve_script_interpreter("bash", Some(&only_wsl)).program,
978 PathBuf::from("bash")
979 );
980 }
981
982 #[test]
983 fn resolve_script_interpreter_falls_back_to_bare_name() {
984 let tmp = tempfile::tempdir().expect("tempdir");
985 let path_var = tmp.path().as_os_str().to_os_string();
986 assert_eq!(
987 resolve_script_interpreter("sh", Some(&path_var)).program,
988 PathBuf::from("sh")
989 );
990 assert_eq!(
991 resolve_script_interpreter("sh", None),
992 ScriptInterpreter::bare("sh")
993 );
994 }
995
996 #[test]
997 fn child_path_var_uses_last_explicit_path() {
998 let mut env = CommandEnvironment::default();
999 assert_eq!(child_path_var(&env), None);
1000 env.set.push(("PATH".into(), "/first".into()));
1001 env.set.push(("HOME".into(), "/home/u".into()));
1002 env.set.push(("PATH".into(), "/second".into()));
1003 assert_eq!(child_path_var(&env), Some(OsString::from("/second")));
1004 }
1005
1006 #[cfg(windows)]
1007 #[test]
1008 fn child_path_var_ignores_case_on_windows() {
1009 let mut env = CommandEnvironment::default();
1010 env.set.push(("Path".into(), r"C:\Git\cmd".into()));
1011 assert_eq!(child_path_var(&env), Some(OsString::from(r"C:\Git\cmd")));
1012 }
1013
1014 #[test]
1015 fn child_path_var_falls_back_to_process_path_only_when_inheriting() {
1016 let inherit = CommandEnvironment::inherit_process_only();
1017 assert_eq!(child_path_var(&inherit), std::env::var_os("PATH"));
1018 assert_eq!(child_path_var(&CommandEnvironment::default()), None);
1019 }
1020
1021 #[cfg(unix)]
1022 #[test]
1023 fn shell_program_is_bin_sh_on_unix() {
1024 let mut env = CommandEnvironment::default();
1025 env.set.push(("PATH".into(), "/nonexistent".into()));
1026 assert_eq!(shell_program(&env), PathBuf::from("/bin/sh"));
1027 }
1028
1029 #[cfg(windows)]
1030 #[test]
1031 fn shell_program_finds_git_for_windows_shell_from_cmd_dir() {
1032 let tmp = tempfile::tempdir().expect("tempdir");
1033 let root = tmp.path().join("Git");
1034 touch(&root.join("cmd").join(exe("git")));
1035 touch(&root.join("usr").join("bin").join(exe("sh")));
1036 let mut env = CommandEnvironment::default();
1037 env.set
1038 .push(("Path".into(), root.join("cmd").into_os_string()));
1039 assert_eq!(
1040 shell_program(&env),
1041 root.join("usr").join("bin").join(exe("sh"))
1042 );
1043 }
1044
1045 #[test]
1046 fn resolve_script_interpreter_reports_the_git_for_windows_root() {
1047 let tmp = tempfile::tempdir().expect("tempdir");
1048 let root = tmp.path().join("Git");
1049 touch(&root.join("cmd").join(exe("git")));
1050 touch(&root.join("usr").join("bin").join(exe("sh")));
1051 let path_var = root.join("cmd").into_os_string();
1052 assert_eq!(
1053 resolve_script_interpreter("sh", Some(&path_var)).git_root,
1054 Some(root)
1055 );
1056
1057 let other = tmp.path().join("other-bin");
1058 touch(&other.join(exe("bash")));
1059 let path_var = other.clone().into_os_string();
1060 assert_eq!(
1061 resolve_script_interpreter("bash", Some(&path_var)),
1062 ScriptInterpreter::bare(other.join(exe("bash")))
1063 );
1064 }
1065
1066 #[test]
1067 fn git_for_windows_tool_dirs_lists_mingw_then_usr_bin() {
1068 let tmp = tempfile::tempdir().expect("tempdir");
1069 let root = tmp.path().join("Git");
1070 assert!(git_for_windows_tool_dirs(&root).is_empty());
1071
1072 touch(&root.join("usr").join("bin").join(exe("grep")));
1073 assert_eq!(
1074 git_for_windows_tool_dirs(&root),
1075 vec![root.join("usr").join("bin")]
1076 );
1077
1078 touch(&root.join("clangarm64").join("bin").join(exe("git")));
1079 touch(&root.join("mingw64").join("bin").join(exe("git")));
1080 assert_eq!(
1081 git_for_windows_tool_dirs(&root),
1082 vec![
1083 root.join("mingw64").join("bin"),
1084 root.join("usr").join("bin")
1085 ]
1086 );
1087 }
1088
1089 #[test]
1090 fn prepend_to_child_path_puts_dirs_first() {
1091 let sep = if cfg!(windows) { ";" } else { ":" };
1092 let first = PathBuf::from("first");
1093 let second = PathBuf::from("second");
1094 let mut env = CommandEnvironment::default();
1095 env.set.push(("HOME".into(), "home".into()));
1096 env.set.push(("PATH".into(), "inherited".into()));
1097 prepend_to_child_path(&mut env, &[first.clone(), second.clone()]);
1098 assert_eq!(
1099 child_path_var(&env),
1100 Some(OsString::from(format!("first{sep}second{sep}inherited")))
1101 );
1102 assert_eq!(env.set.len(), 2);
1103 assert_eq!(env.set[0].1, OsString::from("home"));
1104
1105 let mut empty_path = CommandEnvironment::default();
1106 empty_path.set.push(("PATH".into(), "".into()));
1107 prepend_to_child_path(&mut empty_path, std::slice::from_ref(&first));
1108 assert_eq!(child_path_var(&empty_path), Some(OsString::from("first")));
1109
1110 let mut no_path = CommandEnvironment::default();
1111 prepend_to_child_path(&mut no_path, std::slice::from_ref(&first));
1112 assert_eq!(
1113 no_path.set,
1114 vec![(OsString::from("PATH"), OsString::from("first"))]
1115 );
1116
1117 let mut unchanged = CommandEnvironment::default();
1118 unchanged.set.push(("PATH".into(), "inherited".into()));
1119 prepend_to_child_path(&mut unchanged, &[]);
1120 assert_eq!(
1121 child_path_var(&unchanged),
1122 Some(OsString::from("inherited"))
1123 );
1124 }
1125
1126 #[test]
1127 fn prepend_to_child_path_extends_the_inherited_process_path() {
1128 let mut env = CommandEnvironment::inherit_process_only();
1129 let inherited = child_path_var(&env);
1130 prepend_to_child_path(&mut env, &[PathBuf::from("first")]);
1131 let mut expected = OsString::from("first");
1132 if let Some(inherited) = inherited.filter(|p| !p.is_empty()) {
1133 expected.push(if cfg!(windows) { ";" } else { ":" });
1134 expected.push(inherited);
1135 }
1136 assert_eq!(child_path_var(&env), Some(expected));
1137 }
1138
1139 #[cfg(unix)]
1140 #[test]
1141 fn system_runner_runs_bare_sh_without_sh_on_path() {
1142 let mut spec = CommandSpec::sh_dash_c_filter("printf ok");
1143 spec.env = CommandEnvironment {
1144 inherit_process: false,
1145 set: vec![("PATH".into(), "/nonexistent".into())],
1146 remove: Vec::new(),
1147 };
1148 let out = SystemCommandRunner
1149 .spawn(&spec)
1150 .expect("spawn")
1151 .wait_with_output()
1152 .expect("wait");
1153 assert!(out.status.success);
1154 assert_eq!(out.stdout, b"ok");
1155 }
1156
1157 #[cfg(unix)]
1158 #[test]
1159 fn system_runner_runs_sh_dash_c_script_with_argument() {
1160 let tmp = tempfile::tempdir().expect("tempdir");
1161 let helper = tmp.path().join("h.sh");
1162 std::fs::write(
1163 &helper,
1164 "#!/bin/sh\nif [ \"$1\" = get ]; then echo username=alice; fi\n",
1165 )
1166 .expect("write");
1167 let mut perms = std::fs::metadata(&helper).expect("meta").permissions();
1168 perms.set_mode(0o755);
1169 std::fs::set_permissions(&helper, perms).expect("chmod");
1170
1171 let script = format!("{} get", helper.display());
1172 let spec = CommandSpec {
1173 program: "sh".into(),
1174 args: Vec::new(),
1175 shell: Some(ShellInvocation::DashC {
1176 script,
1177 argv0: None,
1178 args: Vec::new(),
1179 }),
1180 cwd: None,
1181 env: CommandEnvironment::inherit_process_only(),
1182 stdin: CommandStdin::Null,
1183 stdout: CommandStdio::Pipe,
1184 stderr: CommandStdio::Inherit,
1185 };
1186 let out = SystemCommandRunner
1187 .spawn(&spec)
1188 .expect("spawn")
1189 .wait_with_output()
1190 .expect("wait");
1191 assert!(out.status.success);
1192 assert!(
1193 out.stdout.starts_with(b"username=alice"),
1194 "runner stdout={:?} stderr={:?} status={:?}",
1195 out.stdout,
1196 out.stderr,
1197 out.status
1198 );
1199
1200 let direct = std::process::Command::new("sh")
1201 .arg("-c")
1202 .arg(format!("{} get", helper.display()))
1203 .output()
1204 .expect("direct");
1205 assert!(
1206 direct.stdout.starts_with(b"username=alice"),
1207 "direct stdout={:?}",
1208 direct.stdout
1209 );
1210 }
1211
1212 #[test]
1213 fn wait_with_output_drains_stderr_and_stdout_concurrently() {
1214 let script = "dd if=/dev/zero bs=1024 count=1024 >&2; printf ok";
1215 let spec = CommandSpec {
1216 program: "sh".into(),
1217 args: Vec::new(),
1218 shell: Some(ShellInvocation::DashC {
1219 script: script.into(),
1220 argv0: None,
1221 args: Vec::new(),
1222 }),
1223 cwd: None,
1224 env: CommandEnvironment::inherit_process_only(),
1225 stdin: CommandStdin::Null,
1226 stdout: CommandStdio::Pipe,
1227 stderr: CommandStdio::Pipe,
1228 };
1229 let out = SystemCommandRunner
1230 .spawn(&spec)
1231 .expect("spawn")
1232 .wait_with_output()
1233 .expect("wait");
1234 assert!(out.status.success);
1235 assert_eq!(out.stdout, b"ok");
1236 assert!(out.stderr.len() >= 1024);
1237 }
1238
1239 #[test]
1240 fn recording_runner_captures_spec_and_exit() {
1241 let runner = RecordingRunner::always_success();
1242 runner.push_response(RecordedResponse::Output {
1243 stdout: b"ok".to_vec(),
1244 code: 0,
1245 });
1246 let mut spec = CommandSpec::sh_dash_c_filter("echo hi");
1247 spec.stdin = CommandStdin::Pipe(b"data".to_vec());
1248 let handle = runner.spawn(&spec).expect("spawn");
1249 let out = handle.wait_with_output().expect("wait");
1250 assert!(out.status.success);
1251 assert_eq!(out.stdout, b"ok");
1252 let recorded = runner.specs();
1253 assert_eq!(recorded.len(), 1);
1254 match &recorded[0].stdin {
1255 CommandStdin::Pipe(b) => assert_eq!(b, b"data"),
1256 _ => panic!("expected pipe stdin"),
1257 }
1258 }
1259}