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    /// Underlying OS child for long-running filters (system runner only).
359    ///
360    /// # Errors
361    ///
362    /// Returns [`CommandHandleError`] when the handle is not backed by a live process.
363    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    /// Split into OS child and taken stdio (system runner only).
371    ///
372    /// # Errors
373    ///
374    /// Returns [`CommandHandleError`] when the handle is not backed by a live process.
375    #[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
428/// Spawns subprocesses; the only production implementation uses [`std::process::Command`].
429pub trait CommandRunner: Send + Sync {
430    /// Start `spec` and return a handle with stdio configured per the spec.
431    ///
432    /// # Errors
433    ///
434    /// Returns I/O errors from spawn or missing piped stdio when requested.
435    fn spawn(&self, spec: &CommandSpec) -> io::Result<RunningCommand>;
436}
437
438/// Runs commands with the real OS process API.
439#[derive(Debug, Clone, Default)]
440pub struct SystemCommandRunner;
441
442impl SystemCommandRunner {
443    // hygiene: grit-lib subprocess execution is centralized here.
444    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); // hygiene: centralized subprocess spawn
453                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); // hygiene: centralized subprocess spawn
462                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); // hygiene: centralized subprocess spawn
471                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
518/// [`CommandSpec::program`] value that stands for the platform shell.
519const SHELL_NAME: &str = "sh";
520
521/// `PATH` as a child spawned with `env` will see it: the last explicit `PATH` in
522/// [`CommandEnvironment::set`], else the process `PATH` when the child inherits it.
523pub(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                // hygiene: an inheriting child sees the process PATH; the spawn boundary reads it.
532                .then(|| std::env::var_os("PATH"))
533                .flatten()
534        })
535}
536
537/// Shell for `sh -c` commands (filters, hooks, helpers) spawned with `env`.
538///
539/// `/bin/sh` on Unix, as Git uses. Windows has no `/bin/sh`, so `sh` is resolved against
540/// the child's `PATH` with [`resolve_script_interpreter`].
541pub(crate) fn shell_program(env: &CommandEnvironment) -> PathBuf {
542    shell_interpreter(env).program
543}
544
545/// [`shell_program`] together with the Git for Windows tree it was found in.
546pub(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
554/// First `dir/name` (or `dir/name.exe` on Windows) that is a file, for `dir` in `dirs`.
555fn 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
569/// [`find_program_in_dirs`] over the entries of a `PATH` value.
570fn 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
574/// Whether `dir` holds Windows' WSL launchers (`System32\bash.exe`, `WindowsApps\bash.exe`),
575/// which would run a script inside a Linux distribution instead of against this checkout.
576fn 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/// Program that runs a script, and the Git for Windows installation it belongs to.
583#[derive(Debug, Clone, PartialEq, Eq)]
584pub(crate) struct ScriptInterpreter {
585    pub(crate) program: PathBuf,
586    /// Installation root (e.g. `C:\Program Files\Git`) when `program` was found inside one.
587    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
599/// Resolve the interpreter `name` (`sh`, `bash`, …) for running a script or shell command.
600///
601/// Looks first in the Git for Windows tree that owns a `git` found on `path_var`
602/// (`<root>/usr/bin`, `<root>/bin`), as Git for Windows puts those ahead of `PATH` for the
603/// commands it runs, then on `path_var` itself, skipping the WSL launcher directories. Falls
604/// back to the bare name so the OS search applies.
605pub(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
634/// Directories of a Git for Windows installation that its `git` puts at the front of `PATH`
635/// for the commands it runs: the MinGW `bin` of the installed architecture, then `usr/bin`
636/// with the MSYS tools (`grep`, `sed`, `env`, …) that shell scripts expect. Only existing
637/// directories are returned.
638pub(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
651/// Put `dirs` in front of the `PATH` a child spawned with `env` will see (see
652/// [`child_path_var`]), setting it explicitly in [`CommandEnvironment::set`].
653///
654/// Leaves `env` unchanged when `dirs` is empty or cannot be joined into a `PATH` value.
655pub(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                    // Close stdin so one-shot helpers (credentials, filters) see EOF.
691                    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/// Default shared system runner.
703#[must_use]
704pub fn system_command_runner() -> Arc<dyn CommandRunner> {
705    Arc::new(SystemCommandRunner)
706}
707
708/// Scripted responses for [`RecordingRunner`].
709#[derive(Debug, Clone)]
710pub enum RecordedResponse {
711    /// Returned when success.
712    Success,
713    /// Returned when exit.
714    Exit(i32),
715    /// Returned when output.
716    Output {
717        /// Stdout disposition for the child process.
718        stdout: Vec<u8>,
719        /// Code.
720        code: i32,
721    },
722}
723
724/// Records [`CommandSpec`] invocations and returns configurable exit statuses.
725#[derive(Debug, Default)]
726pub struct RecordingRunner {
727    /// Recorded for this item.
728    pub recorded: Mutex<Vec<CommandSpec>>,
729    stdin_payloads: Mutex<Vec<Arc<Mutex<Vec<u8>>>>>,
730    responses: Mutex<VecDeque<RecordedResponse>>,
731}
732
733impl RecordingRunner {
734    /// Create a runner that succeeds for every spawn.
735    #[must_use]
736    pub fn always_success() -> Arc<Self> {
737        Arc::new(Self::default())
738    }
739
740    /// Queue responses consumed in FIFO order (defaults to success when empty).
741    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    /// Snapshot recorded specs.
749    #[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    /// Stdin bytes written to each spawned hook/filter, in spawn order.
758    #[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
835/// Run `sh -c script` and collect stdout (common helper for textconv/trailers).
836pub 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
850/// Run a program with argv and collect stdout.
851pub 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
872/// Git hook execution helpers.
873pub 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}