Skip to main content

grit_lib/
command_runner.rs

1//! Injectable subprocess execution for hooks, filters, credentials, and helpers.
2//!
3//! All [`std::process::Command`] usage in non-test library code lives in
4//! [`SystemCommandRunner`]. Embedders and tests use [`RecordingRunner`] or custom
5//! [`CommandRunner`] implementations.
6//!
7//! Library guide: [command_runner](https://grit-scm.com/docs/library/embedding)
8//!
9//! ```rust
10//! use grit_lib::repo::{InitOptions, Repository};
11//! # fn main() -> grit_lib::error::Result<()> {
12//! let dir = tempfile::tempdir()?;
13//! let _repo = Repository::init(dir.path(), &InitOptions::default())?;
14//! # Ok(())
15//! # }
16//! ```
17
18use 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/// Returned when an operation requires a live OS child but the handle is synthetic.
29#[derive(Debug, Error)]
30#[error("command handle is not backed by a live OS process")]
31pub struct CommandHandleError;
32
33/// How a child process should inherit or receive environment variables.
34#[derive(Debug, Clone, Default)]
35pub struct CommandEnvironment {
36    /// When `true`, the child starts from the current process environment (system runner only).
37    pub inherit_process: bool,
38    /// Variables to set or override (after inherit).
39    pub set: Vec<(OsString, OsString)>,
40    /// Variable names to remove (after inherit and `set`).
41    pub remove: Vec<OsString>,
42}
43
44impl CommandEnvironment {
45    /// Inherit the process environment with no changes.
46    #[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    /// Build env for a subprocess from an explicit [`Environment`] snapshot plus overrides.
56    ///
57    /// The child does **not** inherit the host process environment; only variables present
58    /// on `env` and `extra` are exported (`extra` wins on duplicate keys).
59    #[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/// Stdio disposition for a child stream.
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
81pub enum CommandStdio {
82    #[default]
83    /// Returned when inherit.
84    Inherit,
85    /// Returned when null.
86    Null,
87    /// Returned when pipe.
88    Pipe,
89    /// Connect the stream to this process's stderr (hooks write their stdout there, as in Git).
90    ParentStderr,
91}
92
93/// Stdin source for the child.
94#[derive(Debug, Clone, Default)]
95pub enum CommandStdin {
96    #[default]
97    /// Returned when inherit.
98    Inherit,
99    /// Returned when null.
100    Null,
101    /// Returned when pipe.
102    Pipe(Vec<u8>),
103}
104
105/// Shell invocation mode (Git `sh -c` hooks and filters).
106#[derive(Debug, Clone)]
107pub enum ShellInvocation {
108    /// `program` is `sh`: `-c script`, optional `argv0` positional (`hook`), then `args`.
109    DashC {
110        /// Script for this error or warning.
111        script: String,
112        /// Argv0 for this error or warning.
113        argv0: Option<String>,
114        /// Args for this error or warning.
115        args: Vec<OsString>,
116    },
117    /// Run `sh script_path` with `args` (traditional hook ENOEXEC retry).
118    ScriptPath {
119        /// Script path for this error or warning.
120        script_path: PathBuf,
121        /// Args for this error or warning.
122        args: Vec<OsString>,
123    },
124}
125
126/// Typed description of a subprocess to spawn.
127#[derive(Debug, Clone)]
128pub struct CommandSpec {
129    /// Executable when not using [`Self::shell`], or `sh` for shell modes.
130    ///
131    /// [`SystemCommandRunner`] runs a bare `sh` the way Git does: `/bin/sh` on Unix, and on
132    /// Windows the `sh` of the Git for Windows install that owns `git` on the child's `PATH`
133    /// (then `sh` elsewhere on that `PATH`), so filters and helpers work when only Git's
134    /// `cmd` directory is on `PATH`.
135    pub program: PathBuf,
136    /// argv tail when not using shell `-c`.
137    pub args: Vec<OsString>,
138    /// When set, `program` is invoked as a shell wrapper per [`ShellInvocation`].
139    pub shell: Option<ShellInvocation>,
140    /// Process working directory used during repository discovery.
141    pub cwd: Option<PathBuf>,
142    /// Env for this item.
143    pub env: CommandEnvironment,
144    /// Stdin for this item.
145    pub stdin: CommandStdin,
146    /// Stdout for this item.
147    pub stdout: CommandStdio,
148    /// Stderr for this item.
149    pub stderr: CommandStdio,
150}
151
152impl CommandSpec {
153    /// `sh -c expanded` with piped stdin/stdout (one-shot clean/smudge filter).
154    #[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/// Exit status of a completed child.
174#[derive(Debug, Clone, Copy, PartialEq, Eq)]
175pub struct CommandExit {
176    /// Process exit code when available.
177    pub code: Option<i32>,
178    /// Whether the process exited successfully.
179    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/// Output from [`RunningCommand::wait_with_output`].
192#[derive(Debug, Clone)]
193pub struct CommandOutput {
194    /// Subprocess exit status from the filter command.
195    pub status: CommandExit,
196    /// Stdout for this item.
197    pub stdout: Vec<u8>,
198    /// Stderr for this item.
199    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
233/// Live child process with optional piped stdio.
234pub struct RunningCommand {
235    inner: RunningCommandInner,
236}
237
238impl RunningCommand {
239    /// Build a handle from a spawned OS child and taken stdio pipes.
240    #[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    /// Build a synthetic handle for tests and custom [`CommandRunner`] implementations.
258    #[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    /// Writable stdin when the spec requested a pipe.
278    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    /// Close the stdin pipe so the child sees EOF (required after writing hook/filter stdin).
290    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    /// Readable stdout when the spec requested a pipe.
298    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    /// Wait for the child and return its exit status.
308    ///
309    /// # Errors
310    ///
311    /// Returns I/O errors from [`Child::wait`].
312    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    /// Wait and collect piped stdout/stderr.
323    ///
324    /// # Errors
325    ///
326    /// Returns I/O errors from [`Child::wait_with_output`].
327    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    /// Split into OS child and taken stdio (system runner only).
359    ///
360    /// # Errors
361    ///
362    /// Returns [`CommandHandleError`] when the handle is not backed by a live process.
363    #[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
416/// Spawns subprocesses; the only production implementation uses [`std::process::Command`].
417pub trait CommandRunner: Send + Sync {
418    /// Start `spec` and return a handle with stdio configured per the spec.
419    ///
420    /// # Errors
421    ///
422    /// Returns I/O errors from spawn or missing piped stdio when requested.
423    fn spawn(&self, spec: &CommandSpec) -> io::Result<RunningCommand>;
424}
425
426/// Runs commands with the real OS process API.
427#[derive(Debug, Clone, Default)]
428pub struct SystemCommandRunner;
429
430impl SystemCommandRunner {
431    // hygiene: grit-lib subprocess execution is centralized here.
432    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); // hygiene: centralized subprocess spawn
441                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); // hygiene: centralized subprocess spawn
450                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); // hygiene: centralized subprocess spawn
459                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
506/// [`CommandSpec::program`] value that stands for the platform shell.
507const SHELL_NAME: &str = "sh";
508
509/// `PATH` as a child spawned with `env` will see it: the last explicit `PATH` in
510/// [`CommandEnvironment::set`], else the process `PATH` when the child inherits it.
511pub(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                // hygiene: an inheriting child sees the process PATH; the spawn boundary reads it.
520                .then(|| std::env::var_os("PATH"))
521                .flatten()
522        })
523}
524
525/// Shell for `sh -c` commands (filters, hooks, helpers) spawned with `env`.
526///
527/// `/bin/sh` on Unix, as Git uses. Windows has no `/bin/sh`, so `sh` is resolved against
528/// the child's `PATH` with [`resolve_script_interpreter`].
529pub(crate) fn shell_program(env: &CommandEnvironment) -> PathBuf {
530    shell_interpreter(env).program
531}
532
533/// [`shell_program`] together with the Git for Windows tree it was found in.
534pub(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
542/// First `dir/name` (or `dir/name.exe` on Windows) that is a file, for `dir` in `dirs`.
543fn 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
557/// [`find_program_in_dirs`] over the entries of a `PATH` value.
558fn 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
562/// Whether `dir` holds Windows' WSL launchers (`System32\bash.exe`, `WindowsApps\bash.exe`),
563/// which would run a script inside a Linux distribution instead of against this checkout.
564fn 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/// Program that runs a script, and the Git for Windows installation it belongs to.
571#[derive(Debug, Clone, PartialEq, Eq)]
572pub(crate) struct ScriptInterpreter {
573    pub(crate) program: PathBuf,
574    /// Installation root (e.g. `C:\Program Files\Git`) when `program` was found inside one.
575    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
587/// Resolve the interpreter `name` (`sh`, `bash`, …) for running a script or shell command.
588///
589/// Looks first in the Git for Windows tree that owns a `git` found on `path_var`
590/// (`<root>/usr/bin`, `<root>/bin`), as Git for Windows puts those ahead of `PATH` for the
591/// commands it runs, then on `path_var` itself, skipping the WSL launcher directories. Falls
592/// back to the bare name so the OS search applies.
593pub(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
622/// Directories of a Git for Windows installation that its `git` puts at the front of `PATH`
623/// for the commands it runs: the MinGW `bin` of the installed architecture, then `usr/bin`
624/// with the MSYS tools (`grep`, `sed`, `env`, …) that shell scripts expect. Only existing
625/// directories are returned.
626pub(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
639/// Put `dirs` in front of the `PATH` a child spawned with `env` will see (see
640/// [`child_path_var`]), setting it explicitly in [`CommandEnvironment::set`].
641///
642/// Leaves `env` unchanged when `dirs` is empty or cannot be joined into a `PATH` value.
643pub(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                    // Close stdin so one-shot helpers (credentials, filters) see EOF.
679                    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/// Default shared system runner.
691#[must_use]
692pub fn system_command_runner() -> Arc<dyn CommandRunner> {
693    Arc::new(SystemCommandRunner)
694}
695
696/// Scripted responses for [`RecordingRunner`].
697#[derive(Debug, Clone)]
698pub enum RecordedResponse {
699    /// Returned when success.
700    Success,
701    /// Returned when exit.
702    Exit(i32),
703    /// Returned when output.
704    Output {
705        /// Stdout disposition for the child process.
706        stdout: Vec<u8>,
707        /// Code.
708        code: i32,
709    },
710}
711
712/// Records [`CommandSpec`] invocations and returns configurable exit statuses.
713#[derive(Debug, Default)]
714pub struct RecordingRunner {
715    /// Recorded for this item.
716    pub recorded: Mutex<Vec<CommandSpec>>,
717    stdin_payloads: Mutex<Vec<Arc<Mutex<Vec<u8>>>>>,
718    responses: Mutex<VecDeque<RecordedResponse>>,
719}
720
721impl RecordingRunner {
722    /// Create a runner that succeeds for every spawn.
723    #[must_use]
724    pub fn always_success() -> Arc<Self> {
725        Arc::new(Self::default())
726    }
727
728    /// Queue responses consumed in FIFO order (defaults to success when empty).
729    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    /// Snapshot recorded specs.
737    #[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    /// Stdin bytes written to each spawned hook/filter, in spawn order.
746    #[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
823/// Run `sh -c script` and collect stdout (common helper for textconv/trailers).
824pub 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
838/// Run a program with argv and collect stdout.
839pub 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
860/// Git hook execution helpers.
861pub 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}