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, ErrorKind, 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/// Error 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    /// Inherit this process's corresponding stream.
84    Inherit,
85    /// Discard the stream (`/dev/null`).
86    Null,
87    /// Open a pipe for read or write via [`RunningCommand`].
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    /// Inherit this process's stdin.
98    Inherit,
99    /// No stdin (`/dev/null`).
100    Null,
101    /// Bytes written to the child's stdin before it is closed.
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        /// Shell script passed to `sh -c`.
111        script: String,
112        /// Optional `argv[0]` for the shell (Git hook name).
113        argv0: Option<String>,
114        /// Extra arguments after the script positional.
115        args: Vec<OsString>,
116    },
117    /// Run `sh script_path` with `args` (traditional hook ENOEXEC retry).
118    ScriptPath {
119        /// Hook or filter script on disk.
120        script_path: PathBuf,
121        /// Arguments appended after the script path.
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    /// Child environment (inherit and overrides).
143    pub env: CommandEnvironment,
144    /// Stdin source for the child.
145    pub stdin: CommandStdin,
146    /// Stdout disposition.
147    pub stdout: CommandStdio,
148    /// Stderr disposition.
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    /// Captured stdout bytes.
197    pub stdout: Vec<u8>,
198    /// Captured stderr bytes.
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        kill_process_group: bool,
209    },
210    Recording {
211        state: Arc<Mutex<RecordingState>>,
212        stdin: Option<RecordingStdinBuffer>,
213    },
214}
215
216struct RecordingStdinBuffer(Arc<Mutex<Vec<u8>>>);
217
218impl Write for RecordingStdinBuffer {
219    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
220        self.0.lock().unwrap_or_else(|e| e.into_inner()).write(buf)
221    }
222
223    fn flush(&mut self) -> io::Result<()> {
224        self.0.lock().unwrap_or_else(|e| e.into_inner()).flush()
225    }
226}
227
228struct RecordingState {
229    stdout: Option<Cursor<Vec<u8>>>,
230    stderr: Vec<u8>,
231    exit: CommandExit,
232}
233
234/// Live child process with optional piped stdio.
235pub struct RunningCommand {
236    inner: RunningCommandInner,
237}
238
239/// Stdin written while stdout and stderr pipes are drained concurrently.
240pub(crate) enum ConcurrentStdinFeed<'a> {
241    /// Bytes copied to the child's stdin pipe.
242    Bytes(&'a [u8]),
243    /// File copied to stdin on a background thread ([`std::fs::File`] is [`Send`]).
244    File(std::fs::File),
245}
246
247impl RunningCommand {
248    /// Build a handle from a spawned OS child and taken stdio pipes.
249    #[must_use]
250    pub fn from_system(
251        child: Child,
252        stdin: Option<ChildStdin>,
253        stdout: Option<ChildStdout>,
254        stderr: Option<ChildStderr>,
255        kill_process_group: bool,
256    ) -> Self {
257        Self {
258            inner: RunningCommandInner::System {
259                child,
260                stdin,
261                stdout,
262                stderr,
263                kill_process_group,
264            },
265        }
266    }
267
268    /// Build a synthetic handle for tests and custom [`CommandRunner`] implementations.
269    #[must_use]
270    pub fn from_recording(
271        exit: CommandExit,
272        stdout: Option<Vec<u8>>,
273        stderr: Vec<u8>,
274        piped_stdin: bool,
275    ) -> Self {
276        Self {
277            inner: RunningCommandInner::Recording {
278                state: Arc::new(Mutex::new(RecordingState {
279                    stdout: stdout.map(Cursor::new),
280                    stderr,
281                    exit,
282                })),
283                stdin: piped_stdin.then(|| RecordingStdinBuffer(Arc::new(Mutex::new(Vec::new())))),
284            },
285        }
286    }
287
288    /// Writable stdin when the spec requested a pipe.
289    pub fn stdin_mut(&mut self) -> Option<&mut dyn Write> {
290        match &mut self.inner {
291            RunningCommandInner::System { stdin, .. } => {
292                stdin.as_mut().map(|s| s as &mut dyn Write)
293            }
294            RunningCommandInner::Recording { stdin, .. } => {
295                stdin.as_mut().map(|s| s as &mut dyn Write)
296            }
297        }
298    }
299
300    /// Close the stdin pipe so the child sees EOF (required after writing hook/filter stdin).
301    pub fn close_stdin(&mut self) {
302        match &mut self.inner {
303            RunningCommandInner::System { stdin, .. } => *stdin = None,
304            RunningCommandInner::Recording { stdin, .. } => *stdin = None,
305        }
306    }
307
308    /// OS pid when this handle wraps a live child; `None` for synthetic recording handles.
309    #[must_use]
310    pub fn child_pid(&self) -> Option<u32> {
311        match &self.inner {
312            RunningCommandInner::System { child, .. } => Some(child.id()),
313            RunningCommandInner::Recording { .. } => None,
314        }
315    }
316
317    /// Whether [`wait_hook_command`] should signal the child's process group on cancel.
318    #[must_use]
319    pub(crate) fn kill_process_group(&self) -> bool {
320        match &self.inner {
321            RunningCommandInner::System {
322                kill_process_group, ..
323            } => *kill_process_group,
324            RunningCommandInner::Recording { .. } => false,
325        }
326    }
327
328    /// Poll whether the child has exited without blocking.
329    ///
330    /// # Errors
331    ///
332    /// Returns I/O errors from [`Child::try_wait`].
333    pub fn try_wait(&mut self) -> io::Result<Option<CommandExit>> {
334        match &mut self.inner {
335            RunningCommandInner::System { child, .. } => {
336                child.try_wait().map(|opt| opt.map(Into::into))
337            }
338            RunningCommandInner::Recording { state, .. } => {
339                let guard = state.lock().unwrap_or_else(|e| e.into_inner());
340                Ok(Some(guard.exit))
341            }
342        }
343    }
344
345    /// Send `SIGKILL` to a live child (Unix) or terminate it (Windows).
346    ///
347    /// # Errors
348    ///
349    /// Returns I/O errors from [`Child::kill`].
350    pub fn kill(&mut self) -> io::Result<()> {
351        match &mut self.inner {
352            RunningCommandInner::System { child, .. } => child.kill(),
353            RunningCommandInner::Recording { .. } => Ok(()),
354        }
355    }
356    /// Wait for the child and return its exit status.
357    ///
358    /// # Errors
359    ///
360    /// Returns I/O errors from [`Child::wait`].
361    pub fn wait(self) -> io::Result<CommandExit> {
362        match self.inner {
363            RunningCommandInner::System { mut child, .. } => child.wait().map(Into::into),
364            RunningCommandInner::Recording { state, .. } => {
365                let guard = state.lock().unwrap_or_else(|e| e.into_inner());
366                Ok(guard.exit)
367            }
368        }
369    }
370
371    /// Feed stdin while draining stdout/stderr so hooks cannot deadlock on full pipes.
372    ///
373    /// Use this instead of writing to [`Self::stdin_mut`] before [`Self::wait_with_output`]
374    /// when the child may emit substantial stderr while still reading stdin (receive hooks).
375    ///
376    /// A [`ErrorKind::BrokenPipe`] from stdin writes is ignored (the child exited early).
377    ///
378    /// # Errors
379    ///
380    /// Returns I/O errors from pipe reads or [`Child::wait`], other than broken pipe on stdin.
381    pub(crate) fn feed_stdin_and_wait_with_output(
382        self,
383        stdin_feed: Option<ConcurrentStdinFeed<'_>>,
384    ) -> io::Result<CommandOutput> {
385        match self.inner {
386            RunningCommandInner::System {
387                mut child,
388                stdin,
389                stdout,
390                stderr,
391                kill_process_group: _,
392            } => std::thread::scope(|scope| {
393                let drain = scope.spawn(move || drain_child_pipes(stdout, stderr));
394
395                if let Some(mut stdin_pipe) = stdin {
396                    if let Some(feed) = stdin_feed {
397                        let _ = scope.spawn(move || {
398                            write_stdin_feed(feed, &mut stdin_pipe);
399                            drop(stdin_pipe);
400                        });
401                    } else {
402                        drop(stdin_pipe);
403                    }
404                }
405
406                let (stdout_bytes, stderr_bytes) =
407                    drain.join().unwrap_or(Ok((Vec::new(), Vec::new())))?;
408                let status = child.wait()?.into();
409                Ok(CommandOutput {
410                    status,
411                    stdout: stdout_bytes,
412                    stderr: stderr_bytes,
413                })
414            }),
415            RunningCommandInner::Recording { state, mut stdin } => {
416                if let (Some(stdin_mut), Some(feed)) = (stdin.as_mut(), stdin_feed) {
417                    write_stdin_feed(feed, stdin_mut);
418                }
419                drop(stdin);
420                let mut guard = state.lock().unwrap_or_else(|e| e.into_inner());
421                let mut stdout_bytes = Vec::new();
422                if let Some(cursor) = &mut guard.stdout {
423                    let _ = cursor.read_to_end(&mut stdout_bytes);
424                }
425                Ok(CommandOutput {
426                    status: guard.exit,
427                    stdout: stdout_bytes,
428                    stderr: guard.stderr.clone(),
429                })
430            }
431        }
432    }
433
434    /// Wait and collect piped stdout/stderr.
435    ///
436    /// # Errors
437    ///
438    /// Returns I/O errors from [`Child::wait_with_output`].
439    pub fn wait_with_output(self) -> io::Result<CommandOutput> {
440        match self.inner {
441            RunningCommandInner::System {
442                mut child,
443                stdout,
444                stderr,
445                ..
446            } => {
447                let (stdout_bytes, stderr_bytes) = drain_child_pipes(stdout, stderr)?;
448                let status = child.wait()?.into();
449                Ok(CommandOutput {
450                    status,
451                    stdout: stdout_bytes,
452                    stderr: stderr_bytes,
453                })
454            }
455            RunningCommandInner::Recording { state, .. } => {
456                let mut guard = state.lock().unwrap_or_else(|e| e.into_inner());
457                let mut stdout_bytes = Vec::new();
458                if let Some(cursor) = &mut guard.stdout {
459                    let _ = cursor.read_to_end(&mut stdout_bytes);
460                }
461                Ok(CommandOutput {
462                    status: guard.exit,
463                    stdout: stdout_bytes,
464                    stderr: guard.stderr.clone(),
465                })
466            }
467        }
468    }
469
470    /// Split into OS child and taken stdio (system runner only).
471    ///
472    /// # Errors
473    ///
474    /// Returns [`CommandHandleError`] when the handle is not backed by a live process.
475    #[expect(clippy::type_complexity)]
476    pub fn into_system_parts(
477        self,
478    ) -> Result<
479        (
480            Child,
481            Option<ChildStdin>,
482            Option<ChildStdout>,
483            Option<ChildStderr>,
484        ),
485        CommandHandleError,
486    > {
487        match self.inner {
488            RunningCommandInner::System {
489                child,
490                stdin,
491                stdout,
492                stderr,
493                kill_process_group: _,
494            } => Ok((child, stdin, stdout, stderr)),
495            RunningCommandInner::Recording { .. } => Err(CommandHandleError),
496        }
497    }
498}
499
500fn write_stdin_feed(feed: ConcurrentStdinFeed<'_>, stdin: &mut dyn Write) {
501    let write_result = match feed {
502        ConcurrentStdinFeed::Bytes(data) => stdin.write_all(data).map(|_| ()),
503        ConcurrentStdinFeed::File(mut file) => io::copy(&mut file, stdin).map(|_| ()),
504    };
505    if let Err(e) = write_result {
506        if e.kind() != ErrorKind::BrokenPipe {
507            let _ = e;
508        }
509    }
510}
511
512fn drain_child_pipes(
513    stdout: Option<ChildStdout>,
514    stderr: Option<ChildStderr>,
515) -> io::Result<(Vec<u8>, Vec<u8>)> {
516    std::thread::scope(|scope| {
517        let out_task = stdout.map(|mut out| {
518            scope.spawn(move || {
519                let mut buf = Vec::new();
520                let _ = out.read_to_end(&mut buf);
521                buf
522            })
523        });
524        let err_task = stderr.map(|mut err| {
525            scope.spawn(move || {
526                let mut buf = Vec::new();
527                let _ = err.read_to_end(&mut buf);
528                buf
529            })
530        });
531        let stdout_bytes = out_task
532            .map(|h| h.join().unwrap_or_default())
533            .unwrap_or_default();
534        let stderr_bytes = err_task
535            .map(|h| h.join().unwrap_or_default())
536            .unwrap_or_default();
537        Ok((stdout_bytes, stderr_bytes))
538    })
539}
540
541/// Spawns subprocesses; the only production implementation uses [`std::process::Command`].
542pub trait CommandRunner: Send + Sync {
543    /// Start `spec` and return a handle with stdio configured per the spec.
544    ///
545    /// # Errors
546    ///
547    /// Returns I/O errors from spawn or missing piped stdio when requested.
548    fn spawn(&self, spec: &CommandSpec) -> io::Result<RunningCommand>;
549
550    /// Spawn a server-side receive-pack hook subprocess (isolated process group on Unix).
551    ///
552    /// Default implementation delegates to [`Self::spawn`].
553    fn spawn_server_receive_hook(&self, spec: &CommandSpec) -> io::Result<RunningCommand> {
554        self.spawn(spec)
555    }
556}
557
558/// Runs commands with the real OS process API.
559#[derive(Debug, Clone, Default)]
560pub struct SystemCommandRunner;
561
562impl SystemCommandRunner {
563    // hygiene: grit-lib subprocess execution is centralized here.
564    fn build_command(spec: &CommandSpec) -> Command {
565        let program = if spec.program.as_os_str() == SHELL_NAME {
566            shell_program(&spec.env)
567        } else {
568            spec.program.clone()
569        };
570        let mut cmd = match &spec.shell {
571            None => {
572                let mut c = Command::new(&program); // hygiene: centralized subprocess spawn
573                c.args(&spec.args);
574                c
575            }
576            Some(ShellInvocation::DashC {
577                script,
578                argv0,
579                args,
580            }) => {
581                let mut c = Command::new(&program); // hygiene: centralized subprocess spawn
582                c.arg("-c").arg(script);
583                if let Some(name) = argv0 {
584                    c.arg(name);
585                }
586                c.args(args);
587                c
588            }
589            Some(ShellInvocation::ScriptPath { script_path, args }) => {
590                let mut c = Command::new(&program); // hygiene: centralized subprocess spawn
591                c.arg(script_path);
592                c.args(args);
593                c
594            }
595        };
596
597        if let Some(cwd) = &spec.cwd {
598            cmd.current_dir(cwd);
599        }
600
601        apply_env(&mut cmd, &spec.env);
602        apply_stdio(&mut cmd, spec);
603        cmd
604    }
605}
606
607fn apply_env(cmd: &mut Command, env: &CommandEnvironment) {
608    if !env.inherit_process {
609        cmd.env_clear();
610    }
611    for key in &env.remove {
612        cmd.env_remove(key);
613    }
614    for (k, v) in &env.set {
615        cmd.env(k, v);
616    }
617}
618
619fn apply_stdio(cmd: &mut Command, spec: &CommandSpec) {
620    cmd.stdin(match spec.stdin {
621        CommandStdin::Inherit => Stdio::inherit(),
622        CommandStdin::Null => Stdio::null(),
623        CommandStdin::Pipe(_) => Stdio::piped(),
624    });
625    cmd.stdout(child_stdio(spec.stdout));
626    cmd.stderr(child_stdio(spec.stderr));
627}
628
629fn child_stdio(mode: CommandStdio) -> Stdio {
630    match mode {
631        CommandStdio::Inherit => Stdio::inherit(),
632        CommandStdio::Null => Stdio::null(),
633        CommandStdio::Pipe => Stdio::piped(),
634        CommandStdio::ParentStderr => std::io::stderr().into(),
635    }
636}
637
638/// [`CommandSpec::program`] value that stands for the platform shell.
639const SHELL_NAME: &str = "sh";
640
641/// `PATH` as a child spawned with `env` will see it: the last explicit `PATH` in
642/// [`CommandEnvironment::set`], else the process `PATH` when the child inherits it.
643pub(crate) fn child_path_var(env: &CommandEnvironment) -> Option<OsString> {
644    env.set
645        .iter()
646        .rev()
647        .find(|(k, _)| is_path_var(k))
648        .map(|(_, v)| v.clone())
649        .or_else(|| {
650            env.inherit_process
651                // hygiene: an inheriting child sees the process PATH; the spawn boundary reads it.
652                .then(|| std::env::var_os("PATH"))
653                .flatten()
654        })
655}
656
657/// Shell for `sh -c` commands (filters, hooks, helpers) spawned with `env`.
658///
659/// `/bin/sh` on Unix, as Git uses. Windows has no `/bin/sh`, so `sh` is resolved against
660/// the child's `PATH` with [`resolve_script_interpreter`].
661pub(crate) fn shell_program(env: &CommandEnvironment) -> PathBuf {
662    shell_interpreter(env).program
663}
664
665/// [`shell_program`] together with the Git for Windows tree it was found in.
666pub(crate) fn shell_interpreter(env: &CommandEnvironment) -> ScriptInterpreter {
667    if cfg!(windows) {
668        resolve_script_interpreter(SHELL_NAME, child_path_var(env).as_deref())
669    } else {
670        ScriptInterpreter::bare("/bin/sh")
671    }
672}
673
674/// First `dir/name` (or `dir/name.exe` on Windows) that is a file, for `dir` in `dirs`.
675fn find_program_in_dirs(name: &str, dirs: impl IntoIterator<Item = PathBuf>) -> Option<PathBuf> {
676    let suffix = std::env::consts::EXE_SUFFIX;
677    dirs.into_iter()
678        .filter(|dir| !dir.as_os_str().is_empty())
679        .find_map(|dir| {
680            let with_suffix = (!suffix.is_empty() && !name.ends_with(suffix))
681                .then(|| dir.join(format!("{name}{suffix}")));
682            with_suffix
683                .into_iter()
684                .chain(std::iter::once(dir.join(name)))
685                .find(|candidate| candidate.is_file())
686        })
687}
688
689/// [`find_program_in_dirs`] over the entries of a `PATH` value.
690fn find_program_in_path(name: &str, path_var: &OsStr) -> Option<PathBuf> {
691    find_program_in_dirs(name, std::env::split_paths(path_var))
692}
693
694/// Whether `dir` holds Windows' WSL launchers (`System32\bash.exe`, `WindowsApps\bash.exe`),
695/// which would run a script inside a Linux distribution instead of against this checkout.
696fn is_wsl_launcher_dir(dir: &Path) -> bool {
697    dir.file_name().and_then(|n| n.to_str()).is_some_and(|n| {
698        n.eq_ignore_ascii_case("System32") || n.eq_ignore_ascii_case("WindowsApps")
699    })
700}
701
702/// Program that runs a script, and the Git for Windows installation it belongs to.
703#[derive(Debug, Clone, PartialEq, Eq)]
704pub(crate) struct ScriptInterpreter {
705    pub(crate) program: PathBuf,
706    /// Installation root (e.g. `C:\Program Files\Git`) when `program` was found inside one.
707    pub(crate) git_root: Option<PathBuf>,
708}
709
710impl ScriptInterpreter {
711    pub(crate) fn bare(program: impl Into<PathBuf>) -> Self {
712        Self {
713            program: program.into(),
714            git_root: None,
715        }
716    }
717}
718
719/// Resolve the interpreter `name` (`sh`, `bash`, …) for running a script or shell command.
720///
721/// Looks first in the Git for Windows tree that owns a `git` found on `path_var`
722/// (`<root>/usr/bin`, `<root>/bin`), as Git for Windows puts those ahead of `PATH` for the
723/// commands it runs, then on `path_var` itself, skipping the WSL launcher directories. Falls
724/// back to the bare name so the OS search applies.
725pub(crate) fn resolve_script_interpreter(
726    name: &str,
727    path_var: Option<&OsStr>,
728) -> ScriptInterpreter {
729    let Some(path_var) = path_var else {
730        return ScriptInterpreter::bare(name);
731    };
732    let in_git_tree = || {
733        let git = find_program_in_path("git", path_var)?;
734        git.parent()?.ancestors().skip(1).take(2).find_map(|root| {
735            let program =
736                find_program_in_dirs(name, [root.join("usr").join("bin"), root.join("bin")])?;
737            Some(ScriptInterpreter {
738                program,
739                git_root: Some(root.to_path_buf()),
740            })
741        })
742    };
743    in_git_tree()
744        .or_else(|| {
745            find_program_in_dirs(
746                name,
747                std::env::split_paths(path_var).filter(|dir| !is_wsl_launcher_dir(dir)),
748            )
749            .map(ScriptInterpreter::bare)
750        })
751        .unwrap_or_else(|| ScriptInterpreter::bare(name))
752}
753
754/// Directories of a Git for Windows installation that its `git` puts at the front of `PATH`
755/// for the commands it runs: the MinGW `bin` of the installed architecture, then `usr/bin`
756/// with the MSYS tools (`grep`, `sed`, `env`, …) that shell scripts expect. Only existing
757/// directories are returned.
758pub(crate) fn git_for_windows_tool_dirs(root: &Path) -> Vec<PathBuf> {
759    const MINGW_PREFIXES: [&str; 4] = ["mingw64", "clangarm64", "ucrt64", "mingw32"];
760    let mingw_bin = MINGW_PREFIXES
761        .iter()
762        .map(|prefix| root.join(prefix).join("bin"))
763        .find(|dir| dir.is_dir());
764    mingw_bin
765        .into_iter()
766        .chain(std::iter::once(root.join("usr").join("bin")))
767        .filter(|dir| dir.is_dir())
768        .collect()
769}
770
771/// Put `dirs` in front of the `PATH` a child spawned with `env` will see (see
772/// [`child_path_var`]), setting it explicitly in [`CommandEnvironment::set`].
773///
774/// Leaves `env` unchanged when `dirs` is empty or cannot be joined into a `PATH` value.
775pub(crate) fn prepend_to_child_path(env: &mut CommandEnvironment, dirs: &[PathBuf]) {
776    if dirs.is_empty() {
777        return;
778    }
779    let Ok(mut path) = std::env::join_paths(dirs) else {
780        return;
781    };
782    if let Some(current) = child_path_var(env).filter(|p| !p.is_empty()) {
783        path.push(if cfg!(windows) { ";" } else { ":" });
784        path.push(current);
785    }
786    match env.set.iter_mut().rev().find(|(k, _)| is_path_var(k)) {
787        Some((_, value)) => *value = path,
788        None => env.set.push((OsString::from("PATH"), path)),
789    }
790}
791
792fn is_path_var(key: &OsStr) -> bool {
793    if cfg!(windows) {
794        key.eq_ignore_ascii_case("PATH")
795    } else {
796        key == "PATH"
797    }
798}
799
800impl SystemCommandRunner {
801    fn spawn_with_process_group(
802        &self,
803        spec: &CommandSpec,
804        isolate_process_group: bool,
805    ) -> io::Result<RunningCommand> {
806        let mut cmd = Self::build_command(spec);
807        if isolate_process_group {
808            #[cfg(unix)]
809            {
810                use std::os::unix::process::CommandExt;
811                unsafe {
812                    cmd.pre_exec(|| {
813                        // SAFETY: called in the child between fork and exec.
814                        if libc::setpgid(0, 0) != 0 {
815                            return Err(io::Error::last_os_error());
816                        }
817                        Ok(())
818                    });
819                }
820            }
821        }
822        let kill_process_group = isolate_process_group;
823        let mut child = cmd.spawn()?;
824
825        let mut stdin = child.stdin.take();
826        if let CommandStdin::Pipe(ref bytes) = spec.stdin {
827            if let Some(ref mut stdin_handle) = stdin {
828                if !bytes.is_empty() {
829                    let _ = stdin_handle.write_all(bytes);
830                    // Close stdin so one-shot helpers (credentials, filters) see EOF.
831                    stdin = None;
832                }
833            }
834        }
835
836        let stdout = child.stdout.take();
837        let stderr = child.stderr.take();
838        Ok(RunningCommand::from_system(
839            child,
840            stdin,
841            stdout,
842            stderr,
843            kill_process_group,
844        ))
845    }
846}
847
848impl CommandRunner for SystemCommandRunner {
849    fn spawn(&self, spec: &CommandSpec) -> io::Result<RunningCommand> {
850        self.spawn_with_process_group(spec, false)
851    }
852
853    fn spawn_server_receive_hook(&self, spec: &CommandSpec) -> io::Result<RunningCommand> {
854        self.spawn_with_process_group(spec, true)
855    }
856}
857
858/// Force-terminate a process by pid (process group on Unix, tree on Windows).
859pub(crate) fn force_kill_process_pid(pid: u32, kill_process_group: bool) {
860    #[cfg(unix)]
861    {
862        if let Ok(pid_t) = libc::pid_t::try_from(pid) {
863            // SAFETY: `pid` names a hook child this process spawned or tracked.
864            unsafe {
865                if kill_process_group {
866                    let pgid = libc::getpgid(pid_t);
867                    if pgid > 0 {
868                        libc::kill(-pgid, libc::SIGKILL);
869                        return;
870                    }
871                }
872                libc::kill(pid_t, libc::SIGKILL);
873            }
874        }
875    }
876    #[cfg(windows)]
877    {
878        let mut args = vec![
879            OsString::from("/F"),
880            OsString::from("/PID"),
881            OsString::from(pid.to_string()),
882        ];
883        if kill_process_group {
884            args.push(OsString::from("/T"));
885        }
886        let spec = CommandSpec {
887            program: PathBuf::from("taskkill"),
888            args,
889            shell: None,
890            cwd: None,
891            env: CommandEnvironment::inherit_process_only(),
892            stdin: CommandStdin::Null,
893            stdout: CommandStdio::Null,
894            stderr: CommandStdio::Null,
895        };
896        let _ = SystemCommandRunner
897            .spawn(&spec)
898            .and_then(|child| child.wait().map(|_| ()));
899    }
900    #[cfg(not(any(unix, windows)))]
901    {
902        let _ = (pid, kill_process_group);
903    }
904}
905
906/// Default shared system runner.
907#[must_use]
908pub fn system_command_runner() -> Arc<dyn CommandRunner> {
909    Arc::new(SystemCommandRunner)
910}
911
912/// Scripted responses for [`RecordingRunner`].
913#[derive(Debug, Clone)]
914pub enum RecordedResponse {
915    /// Exit status 0 with empty stdout/stderr.
916    Success,
917    /// Exit with the given non-zero code and empty output.
918    Exit(i32),
919    /// Exit with the given code and stdout payload.
920    Output {
921        /// Stdout bytes returned to the caller.
922        stdout: Vec<u8>,
923        /// Process exit code.
924        code: i32,
925    },
926}
927
928/// Records [`CommandSpec`] invocations and returns configurable exit statuses.
929#[derive(Debug, Default)]
930pub struct RecordingRunner {
931    /// Every [`CommandSpec`] passed to [`CommandRunner::spawn`], in order.
932    pub recorded: Mutex<Vec<CommandSpec>>,
933    stdin_payloads: Mutex<Vec<Arc<Mutex<Vec<u8>>>>>,
934    responses: Mutex<VecDeque<RecordedResponse>>,
935}
936
937impl RecordingRunner {
938    /// Create a runner that succeeds for every spawn.
939    #[must_use]
940    pub fn always_success() -> Arc<Self> {
941        Arc::new(Self::default())
942    }
943
944    /// Queue responses consumed in FIFO order (defaults to success when empty).
945    pub fn push_response(&self, response: RecordedResponse) {
946        self.responses
947            .lock()
948            .unwrap_or_else(|e| e.into_inner())
949            .push_back(response);
950    }
951
952    /// Snapshot recorded specs.
953    #[must_use]
954    pub fn specs(&self) -> Vec<CommandSpec> {
955        self.recorded
956            .lock()
957            .unwrap_or_else(|e| e.into_inner())
958            .clone()
959    }
960
961    /// Stdin bytes written to each spawned hook/filter, in spawn order.
962    #[must_use]
963    pub fn stdin_payloads(&self) -> Vec<Vec<u8>> {
964        self.stdin_payloads
965            .lock()
966            .unwrap_or_else(|e| e.into_inner())
967            .iter()
968            .map(|buf| buf.lock().unwrap_or_else(|e| e.into_inner()).clone())
969            .collect()
970    }
971}
972
973impl CommandRunner for RecordingRunner {
974    fn spawn(&self, spec: &CommandSpec) -> io::Result<RunningCommand> {
975        self.recorded
976            .lock()
977            .unwrap_or_else(|e| e.into_inner())
978            .push(spec.clone());
979
980        let response = self
981            .responses
982            .lock()
983            .unwrap_or_else(|e| e.into_inner())
984            .pop_front()
985            .unwrap_or(RecordedResponse::Success);
986
987        let piped_stdin = matches!(spec.stdin, CommandStdin::Pipe(_));
988
989        let (stdout_bytes, exit) = match response {
990            RecordedResponse::Success => (
991                None,
992                CommandExit {
993                    code: Some(0),
994                    success: true,
995                },
996            ),
997            RecordedResponse::Exit(code) => (
998                None,
999                CommandExit {
1000                    code: Some(code),
1001                    success: code == 0,
1002                },
1003            ),
1004            RecordedResponse::Output { stdout, code } => (
1005                Some(stdout),
1006                CommandExit {
1007                    code: Some(code),
1008                    success: code == 0,
1009                },
1010            ),
1011        };
1012
1013        let stdout_cursor = if spec.stdout == CommandStdio::Pipe {
1014            Some(Cursor::new(stdout_bytes.unwrap_or_default()))
1015        } else {
1016            None
1017        };
1018
1019        Ok(RunningCommand {
1020            inner: RunningCommandInner::Recording {
1021                state: Arc::new(Mutex::new(RecordingState {
1022                    stdout: stdout_cursor,
1023                    stderr: Vec::new(),
1024                    exit,
1025                })),
1026                stdin: piped_stdin.then(|| {
1027                    let buf = Arc::new(Mutex::new(Vec::new()));
1028                    self.stdin_payloads
1029                        .lock()
1030                        .unwrap_or_else(|e| e.into_inner())
1031                        .push(buf.clone());
1032                    RecordingStdinBuffer(buf)
1033                }),
1034            },
1035        })
1036    }
1037}
1038
1039/// Run `sh -c script` and collect stdout (common helper for textconv/trailers).
1040pub fn run_sh_dash_c(
1041    runner: &dyn CommandRunner,
1042    script: &str,
1043    cwd: Option<&Path>,
1044    stdin: CommandStdin,
1045    stderr: CommandStdio,
1046) -> io::Result<CommandOutput> {
1047    let mut spec = CommandSpec::sh_dash_c_filter(script);
1048    spec.cwd = cwd.map(Path::to_path_buf);
1049    spec.stdin = stdin;
1050    spec.stderr = stderr;
1051    runner.spawn(&spec)?.wait_with_output()
1052}
1053
1054/// Run a program with argv and collect stdout.
1055pub fn run_program(
1056    runner: &dyn CommandRunner,
1057    program: &Path,
1058    args: &[OsString],
1059    cwd: Option<&Path>,
1060    stdin: CommandStdin,
1061    stderr: CommandStdio,
1062) -> io::Result<CommandOutput> {
1063    let spec = CommandSpec {
1064        program: program.to_path_buf(),
1065        args: args.to_vec(),
1066        shell: None,
1067        cwd: cwd.map(Path::to_path_buf),
1068        env: CommandEnvironment::inherit_process_only(),
1069        stdin,
1070        stdout: CommandStdio::Pipe,
1071        stderr,
1072    };
1073    runner.spawn(&spec)?.wait_with_output()
1074}
1075
1076/// Git hook execution helpers.
1077pub mod hooks {
1078    pub use crate::hooks::*;
1079}
1080
1081#[cfg(test)]
1082mod tests {
1083    use super::*;
1084    #[cfg(unix)]
1085    use std::os::unix::fs::PermissionsExt;
1086
1087    fn touch(path: &Path) {
1088        std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir");
1089        std::fs::write(path, b"").expect("write");
1090    }
1091
1092    fn exe(name: &str) -> String {
1093        format!("{name}{}", std::env::consts::EXE_SUFFIX)
1094    }
1095
1096    #[test]
1097    fn find_program_in_path_searches_entries_in_order() {
1098        let tmp = tempfile::tempdir().expect("tempdir");
1099        let first = tmp.path().join("first");
1100        let second = tmp.path().join("second");
1101        std::fs::create_dir_all(&first).expect("mkdir");
1102        touch(&second.join(exe("sh")));
1103        touch(&tmp.path().join("third").join(exe("sh")));
1104        let path_var = std::env::join_paths([
1105            PathBuf::new(),
1106            first,
1107            second.clone(),
1108            tmp.path().join("third"),
1109        ])
1110        .expect("join");
1111
1112        assert_eq!(
1113            find_program_in_path("sh", &path_var),
1114            Some(second.join(exe("sh")))
1115        );
1116        assert_eq!(find_program_in_path("bash", &path_var), None);
1117        assert_eq!(find_program_in_path("sh", OsStr::new("")), None);
1118    }
1119
1120    #[test]
1121    fn resolve_script_interpreter_prefers_path() {
1122        let tmp = tempfile::tempdir().expect("tempdir");
1123        let bin = tmp.path().join("bin");
1124        touch(&bin.join(exe("bash")));
1125        let path_var = bin.clone().into_os_string();
1126        assert_eq!(
1127            resolve_script_interpreter("bash", Some(&path_var)).program,
1128            bin.join(exe("bash"))
1129        );
1130    }
1131
1132    #[test]
1133    fn resolve_script_interpreter_finds_git_for_windows_shell() {
1134        for git_dir in [&["cmd"][..], &["mingw64", "bin"][..]] {
1135            let tmp = tempfile::tempdir().expect("tempdir");
1136            let root = tmp.path().join("Git");
1137            let git_bin = git_dir.iter().fold(root.clone(), |p, c| p.join(c));
1138            touch(&git_bin.join(exe("git")));
1139            touch(&root.join("usr").join("bin").join(exe("sh")));
1140            let path_var = git_bin.into_os_string();
1141            assert_eq!(
1142                resolve_script_interpreter("sh", Some(&path_var)).program,
1143                root.join("usr").join("bin").join(exe("sh")),
1144                "git under {git_dir:?}"
1145            );
1146        }
1147    }
1148
1149    #[test]
1150    fn resolve_script_interpreter_prefers_git_tree_over_earlier_path_entries() {
1151        let tmp = tempfile::tempdir().expect("tempdir");
1152        let other = tmp.path().join("other-bin");
1153        touch(&other.join(exe("bash")));
1154        let root = tmp.path().join("Git");
1155        touch(&root.join("cmd").join(exe("git")));
1156        touch(&root.join("usr").join("bin").join(exe("bash")));
1157        let path_var = std::env::join_paths([other, root.join("cmd")]).expect("join");
1158        assert_eq!(
1159            resolve_script_interpreter("bash", Some(&path_var)).program,
1160            root.join("usr").join("bin").join(exe("bash"))
1161        );
1162    }
1163
1164    #[test]
1165    fn resolve_script_interpreter_skips_wsl_launchers() {
1166        let tmp = tempfile::tempdir().expect("tempdir");
1167        let system32 = tmp.path().join("Windows").join("System32");
1168        let apps = tmp.path().join("WindowsApps");
1169        let msys = tmp.path().join("msys64").join("usr").join("bin");
1170        touch(&system32.join(exe("bash")));
1171        touch(&apps.join(exe("bash")));
1172        touch(&msys.join(exe("bash")));
1173        let path_var =
1174            std::env::join_paths([system32.clone(), apps.clone(), msys.clone()]).expect("join");
1175        assert_eq!(
1176            resolve_script_interpreter("bash", Some(&path_var)).program,
1177            msys.join(exe("bash"))
1178        );
1179        let only_wsl = std::env::join_paths([system32, apps]).expect("join");
1180        assert_eq!(
1181            resolve_script_interpreter("bash", Some(&only_wsl)).program,
1182            PathBuf::from("bash")
1183        );
1184    }
1185
1186    #[test]
1187    fn resolve_script_interpreter_falls_back_to_bare_name() {
1188        let tmp = tempfile::tempdir().expect("tempdir");
1189        let path_var = tmp.path().as_os_str().to_os_string();
1190        assert_eq!(
1191            resolve_script_interpreter("sh", Some(&path_var)).program,
1192            PathBuf::from("sh")
1193        );
1194        assert_eq!(
1195            resolve_script_interpreter("sh", None),
1196            ScriptInterpreter::bare("sh")
1197        );
1198    }
1199
1200    #[test]
1201    fn child_path_var_uses_last_explicit_path() {
1202        let mut env = CommandEnvironment::default();
1203        assert_eq!(child_path_var(&env), None);
1204        env.set.push(("PATH".into(), "/first".into()));
1205        env.set.push(("HOME".into(), "/home/u".into()));
1206        env.set.push(("PATH".into(), "/second".into()));
1207        assert_eq!(child_path_var(&env), Some(OsString::from("/second")));
1208    }
1209
1210    #[cfg(windows)]
1211    #[test]
1212    fn child_path_var_ignores_case_on_windows() {
1213        let mut env = CommandEnvironment::default();
1214        env.set.push(("Path".into(), r"C:\Git\cmd".into()));
1215        assert_eq!(child_path_var(&env), Some(OsString::from(r"C:\Git\cmd")));
1216    }
1217
1218    #[test]
1219    fn child_path_var_falls_back_to_process_path_only_when_inheriting() {
1220        let inherit = CommandEnvironment::inherit_process_only();
1221        assert_eq!(child_path_var(&inherit), std::env::var_os("PATH"));
1222        assert_eq!(child_path_var(&CommandEnvironment::default()), None);
1223    }
1224
1225    #[cfg(unix)]
1226    #[test]
1227    fn shell_program_is_bin_sh_on_unix() {
1228        let mut env = CommandEnvironment::default();
1229        env.set.push(("PATH".into(), "/nonexistent".into()));
1230        assert_eq!(shell_program(&env), PathBuf::from("/bin/sh"));
1231    }
1232
1233    #[cfg(windows)]
1234    #[test]
1235    fn shell_program_finds_git_for_windows_shell_from_cmd_dir() {
1236        let tmp = tempfile::tempdir().expect("tempdir");
1237        let root = tmp.path().join("Git");
1238        touch(&root.join("cmd").join(exe("git")));
1239        touch(&root.join("usr").join("bin").join(exe("sh")));
1240        let mut env = CommandEnvironment::default();
1241        env.set
1242            .push(("Path".into(), root.join("cmd").into_os_string()));
1243        assert_eq!(
1244            shell_program(&env),
1245            root.join("usr").join("bin").join(exe("sh"))
1246        );
1247    }
1248
1249    #[test]
1250    fn resolve_script_interpreter_reports_the_git_for_windows_root() {
1251        let tmp = tempfile::tempdir().expect("tempdir");
1252        let root = tmp.path().join("Git");
1253        touch(&root.join("cmd").join(exe("git")));
1254        touch(&root.join("usr").join("bin").join(exe("sh")));
1255        let path_var = root.join("cmd").into_os_string();
1256        assert_eq!(
1257            resolve_script_interpreter("sh", Some(&path_var)).git_root,
1258            Some(root)
1259        );
1260
1261        let other = tmp.path().join("other-bin");
1262        touch(&other.join(exe("bash")));
1263        let path_var = other.clone().into_os_string();
1264        assert_eq!(
1265            resolve_script_interpreter("bash", Some(&path_var)),
1266            ScriptInterpreter::bare(other.join(exe("bash")))
1267        );
1268    }
1269
1270    #[test]
1271    fn git_for_windows_tool_dirs_lists_mingw_then_usr_bin() {
1272        let tmp = tempfile::tempdir().expect("tempdir");
1273        let root = tmp.path().join("Git");
1274        assert!(git_for_windows_tool_dirs(&root).is_empty());
1275
1276        touch(&root.join("usr").join("bin").join(exe("grep")));
1277        assert_eq!(
1278            git_for_windows_tool_dirs(&root),
1279            vec![root.join("usr").join("bin")]
1280        );
1281
1282        touch(&root.join("clangarm64").join("bin").join(exe("git")));
1283        touch(&root.join("mingw64").join("bin").join(exe("git")));
1284        assert_eq!(
1285            git_for_windows_tool_dirs(&root),
1286            vec![
1287                root.join("mingw64").join("bin"),
1288                root.join("usr").join("bin")
1289            ]
1290        );
1291    }
1292
1293    #[test]
1294    fn prepend_to_child_path_puts_dirs_first() {
1295        let sep = if cfg!(windows) { ";" } else { ":" };
1296        let first = PathBuf::from("first");
1297        let second = PathBuf::from("second");
1298        let mut env = CommandEnvironment::default();
1299        env.set.push(("HOME".into(), "home".into()));
1300        env.set.push(("PATH".into(), "inherited".into()));
1301        prepend_to_child_path(&mut env, &[first.clone(), second.clone()]);
1302        assert_eq!(
1303            child_path_var(&env),
1304            Some(OsString::from(format!("first{sep}second{sep}inherited")))
1305        );
1306        assert_eq!(env.set.len(), 2);
1307        assert_eq!(env.set[0].1, OsString::from("home"));
1308
1309        let mut empty_path = CommandEnvironment::default();
1310        empty_path.set.push(("PATH".into(), "".into()));
1311        prepend_to_child_path(&mut empty_path, std::slice::from_ref(&first));
1312        assert_eq!(child_path_var(&empty_path), Some(OsString::from("first")));
1313
1314        let mut no_path = CommandEnvironment::default();
1315        prepend_to_child_path(&mut no_path, std::slice::from_ref(&first));
1316        assert_eq!(
1317            no_path.set,
1318            vec![(OsString::from("PATH"), OsString::from("first"))]
1319        );
1320
1321        let mut unchanged = CommandEnvironment::default();
1322        unchanged.set.push(("PATH".into(), "inherited".into()));
1323        prepend_to_child_path(&mut unchanged, &[]);
1324        assert_eq!(
1325            child_path_var(&unchanged),
1326            Some(OsString::from("inherited"))
1327        );
1328    }
1329
1330    #[test]
1331    fn prepend_to_child_path_extends_the_inherited_process_path() {
1332        let mut env = CommandEnvironment::inherit_process_only();
1333        let inherited = child_path_var(&env);
1334        prepend_to_child_path(&mut env, &[PathBuf::from("first")]);
1335        let mut expected = OsString::from("first");
1336        if let Some(inherited) = inherited.filter(|p| !p.is_empty()) {
1337            expected.push(if cfg!(windows) { ";" } else { ":" });
1338            expected.push(inherited);
1339        }
1340        assert_eq!(child_path_var(&env), Some(expected));
1341    }
1342
1343    #[cfg(unix)]
1344    #[test]
1345    fn system_runner_runs_bare_sh_without_sh_on_path() {
1346        let mut spec = CommandSpec::sh_dash_c_filter("printf ok");
1347        spec.env = CommandEnvironment {
1348            inherit_process: false,
1349            set: vec![("PATH".into(), "/nonexistent".into())],
1350            remove: Vec::new(),
1351        };
1352        let out = SystemCommandRunner
1353            .spawn(&spec)
1354            .expect("spawn")
1355            .wait_with_output()
1356            .expect("wait");
1357        assert!(out.status.success);
1358        assert_eq!(out.stdout, b"ok");
1359    }
1360
1361    #[cfg(unix)]
1362    #[test]
1363    fn system_runner_runs_sh_dash_c_script_with_argument() {
1364        let tmp = tempfile::tempdir().expect("tempdir");
1365        let helper = tmp.path().join("h.sh");
1366        std::fs::write(
1367            &helper,
1368            "#!/bin/sh\nif [ \"$1\" = get ]; then echo username=alice; fi\n",
1369        )
1370        .expect("write");
1371        let mut perms = std::fs::metadata(&helper).expect("meta").permissions();
1372        perms.set_mode(0o755);
1373        std::fs::set_permissions(&helper, perms).expect("chmod");
1374
1375        let script = format!("{} get", helper.display());
1376        let spec = CommandSpec {
1377            program: "sh".into(),
1378            args: Vec::new(),
1379            shell: Some(ShellInvocation::DashC {
1380                script,
1381                argv0: None,
1382                args: Vec::new(),
1383            }),
1384            cwd: None,
1385            env: CommandEnvironment::inherit_process_only(),
1386            stdin: CommandStdin::Null,
1387            stdout: CommandStdio::Pipe,
1388            stderr: CommandStdio::Inherit,
1389        };
1390        let out = SystemCommandRunner
1391            .spawn(&spec)
1392            .expect("spawn")
1393            .wait_with_output()
1394            .expect("wait");
1395        assert!(out.status.success);
1396        assert!(
1397            out.stdout.starts_with(b"username=alice"),
1398            "runner stdout={:?} stderr={:?} status={:?}",
1399            out.stdout,
1400            out.stderr,
1401            out.status
1402        );
1403
1404        let direct = std::process::Command::new("sh")
1405            .arg("-c")
1406            .arg(format!("{} get", helper.display()))
1407            .output()
1408            .expect("direct");
1409        assert!(
1410            direct.stdout.starts_with(b"username=alice"),
1411            "direct stdout={:?}",
1412            direct.stdout
1413        );
1414    }
1415
1416    #[test]
1417    fn feed_stdin_and_wait_with_output_avoids_hook_pipe_deadlock() {
1418        let script = "while read line; do printf 'checking %s\\n' \"$line\" >&2; done";
1419        let stdin_lines: String = (1..=3000)
1420            .map(|i| format!("1111111111111111111111111111111111111111 2222222222222222222222222222222222222222 refs/heads/b{i}\n"))
1421            .collect();
1422        let spec = CommandSpec {
1423            program: "sh".into(),
1424            args: Vec::new(),
1425            shell: Some(ShellInvocation::DashC {
1426                script: script.into(),
1427                argv0: None,
1428                args: Vec::new(),
1429            }),
1430            cwd: None,
1431            env: CommandEnvironment::inherit_process_only(),
1432            stdin: CommandStdin::Pipe(Vec::new()),
1433            stdout: CommandStdio::Pipe,
1434            stderr: CommandStdio::Pipe,
1435        };
1436        let started = std::time::Instant::now();
1437        let out = SystemCommandRunner
1438            .spawn(&spec)
1439            .expect("spawn")
1440            .feed_stdin_and_wait_with_output(Some(ConcurrentStdinFeed::Bytes(
1441                stdin_lines.as_bytes(),
1442            )))
1443            .expect("wait");
1444        assert!(
1445            started.elapsed() < std::time::Duration::from_secs(5),
1446            "hook I/O took {:?}",
1447            started.elapsed()
1448        );
1449        assert!(out.status.success);
1450        assert!(out.stderr.len() > 64 * 1024);
1451        assert!(out.stderr.starts_with(b"checking "));
1452    }
1453
1454    #[test]
1455    fn feed_stdin_and_wait_with_output_treats_broken_pipe_as_early_exit() {
1456        let script = "exit 1";
1457        let payload = vec![b'x'; 128 * 1024];
1458        let spec = CommandSpec {
1459            program: "sh".into(),
1460            args: Vec::new(),
1461            shell: Some(ShellInvocation::DashC {
1462                script: script.into(),
1463                argv0: None,
1464                args: Vec::new(),
1465            }),
1466            cwd: None,
1467            env: CommandEnvironment::inherit_process_only(),
1468            stdin: CommandStdin::Pipe(Vec::new()),
1469            stdout: CommandStdio::Pipe,
1470            stderr: CommandStdio::Pipe,
1471        };
1472        let out = SystemCommandRunner
1473            .spawn(&spec)
1474            .expect("spawn")
1475            .feed_stdin_and_wait_with_output(Some(ConcurrentStdinFeed::Bytes(&payload)))
1476            .expect("wait");
1477        assert!(!out.status.success);
1478        assert_eq!(out.status.code, Some(1));
1479    }
1480
1481    #[test]
1482    fn wait_with_output_drains_stderr_and_stdout_concurrently() {
1483        let script = "dd if=/dev/zero bs=1024 count=1024 >&2; printf ok";
1484        let spec = CommandSpec {
1485            program: "sh".into(),
1486            args: Vec::new(),
1487            shell: Some(ShellInvocation::DashC {
1488                script: script.into(),
1489                argv0: None,
1490                args: Vec::new(),
1491            }),
1492            cwd: None,
1493            env: CommandEnvironment::inherit_process_only(),
1494            stdin: CommandStdin::Null,
1495            stdout: CommandStdio::Pipe,
1496            stderr: CommandStdio::Pipe,
1497        };
1498        let out = SystemCommandRunner
1499            .spawn(&spec)
1500            .expect("spawn")
1501            .wait_with_output()
1502            .expect("wait");
1503        assert!(out.status.success);
1504        assert_eq!(out.stdout, b"ok");
1505        assert!(out.stderr.len() >= 1024);
1506    }
1507
1508    #[test]
1509    fn recording_runner_captures_spec_and_exit() {
1510        let runner = RecordingRunner::always_success();
1511        runner.push_response(RecordedResponse::Output {
1512            stdout: b"ok".to_vec(),
1513            code: 0,
1514        });
1515        let mut spec = CommandSpec::sh_dash_c_filter("echo hi");
1516        spec.stdin = CommandStdin::Pipe(b"data".to_vec());
1517        let handle = runner.spawn(&spec).expect("spawn");
1518        let out = handle.wait_with_output().expect("wait");
1519        assert!(out.status.success);
1520        assert_eq!(out.stdout, b"ok");
1521        let recorded = runner.specs();
1522        assert_eq!(recorded.len(), 1);
1523        match &recorded[0].stdin {
1524            CommandStdin::Pipe(b) => assert_eq!(b, b"data"),
1525            _ => panic!("expected pipe stdin"),
1526        }
1527    }
1528}