Skip to main content

amont_runtime/
git.rs

1//! Thin wrappers over the `git` calls the hooks make.
2
3use std::process::{Command, Stdio};
4
5/// Run `cmd`, retrying the transient SPAWN failures a loaded machine
6/// produces: EINTR, EAGAIN (fork pressure), ETXTBSY (another thread's
7/// fork-to-exec window still holding a write descriptor on the executable).
8/// A NON-ZERO EXIT IS NEVER RETRIED — that is git answering; this covers
9/// only "git could not be asked".
10///
11/// The failure this ends: `gate_stamp`'s tests — and, invisibly, real
12/// hooks on a loaded machine — watched a single failed fork turn
13/// `bind_to_head` into "nothing to stamp". The hooks' fail-open reading of
14/// `None` is right for a git that is genuinely absent; three attempts over
15/// ~130ms is the difference between that and a scheduler hiccup.
16fn retrying<T>(mut attempt: impl FnMut() -> std::io::Result<T>) -> std::io::Result<T> {
17    let mut delay = std::time::Duration::from_millis(10);
18    for tries_left in [2u8, 1, 0] {
19        match attempt() {
20            Err(e) if tries_left > 0 && transient(&e) => {
21                std::thread::sleep(delay);
22                delay *= 3;
23            }
24            other => return other,
25        }
26    }
27    unreachable!("the zero-tries arm returns")
28}
29
30/// The retryable kinds, matched on raw OS codes because the precise
31/// `io::ErrorKind` variants (`ExecutableFileBusy`, `ResourceBusy`) are not
32/// stable at this crate's MSRV: EINTR(4), EAGAIN(11 linux / 35 mac),
33/// ETXTBSY(26).
34fn transient(e: &std::io::Error) -> bool {
35    if matches!(
36        e.kind(),
37        std::io::ErrorKind::Interrupted | std::io::ErrorKind::WouldBlock
38    ) {
39        return true;
40    }
41    matches!(e.raw_os_error(), Some(4 | 11 | 26 | 35))
42}
43
44/// stdout of a git command, trimmed. `None` when git itself failed — which the
45/// hooks treat as "cannot tell, do not block", never as "empty".
46pub fn stdout(args: &[&str]) -> Option<String> {
47    let mut cmd = Command::new("git");
48    cmd.args(args).stderr(Stdio::null());
49    let out = retrying(|| cmd.output()).ok()?;
50    if !out.status.success() {
51        return None;
52    }
53    Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
54}
55
56/// The same, run inside `dir`.
57///
58/// The dashboard asks about repositories it is not standing in, and must get
59/// the answer git would give THERE — config is per-repository, so asking from
60/// the wrong directory returns the wrong severity.
61pub fn stdout_in(dir: &std::path::Path, args: &[&str]) -> Option<String> {
62    let mut cmd = Command::new("git");
63    cmd.arg("-C").arg(dir).args(args).stderr(Stdio::null());
64    let out = retrying(|| cmd.output()).ok()?;
65    if !out.status.success() {
66        return None;
67    }
68    Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
69}
70
71/// [`succeeds`] for a repository this process is not standing in — the shape
72/// the fleet needs to UNDO something (delete a ref it wrote) rather than ask
73/// about it. Output discarded; `false` covers "git could not run" too.
74pub fn succeeds_in(dir: &std::path::Path, args: &[&str]) -> bool {
75    let mut cmd = Command::new("git");
76    cmd.arg("-C")
77        .arg(dir)
78        .args(args)
79        .stdin(Stdio::null())
80        .stdout(Stdio::null())
81        .stderr(Stdio::null());
82    retrying(|| cmd.status())
83        .map(|s| s.success())
84        .unwrap_or(false)
85}
86
87/// stdout of a git command that itself reads a list from stdin — `diff-tree
88/// --stdin`, fed a list of commits, is the only caller today. Lossy but
89/// untrimmed: every line is a path, and the caller trims those itself.
90pub fn stdout_piped(args: &[&str], stdin: &str) -> Option<String> {
91    use std::io::Write;
92    let mut cmd = Command::new("git");
93    cmd.args(args)
94        .stdin(Stdio::piped())
95        .stdout(Stdio::piped())
96        .stderr(Stdio::null());
97    let mut child = retrying(|| cmd.spawn()).ok()?;
98    child.stdin.take()?.write_all(stdin.as_bytes()).ok()?;
99    let out = child.wait_with_output().ok()?;
100    out.status
101        .success()
102        .then(|| String::from_utf8_lossy(&out.stdout).into_owned())
103}
104
105/// As `stdout_piped`, but returning the RAW bytes.
106///
107/// Needed by the one caller that must both feed git a list on stdin and read a
108/// `-z` path list back — `diff-tree --stdin -z`. `stdout_paths` cannot serve it
109/// (no stdin) and `stdout_piped` cannot either (lossy `String`, and the NUL
110/// separators are the whole point).
111pub fn stdout_piped_raw(args: &[&str], stdin: &str) -> Option<Vec<u8>> {
112    use std::io::Write;
113    let mut cmd = Command::new("git");
114    cmd.args(args)
115        .stdin(Stdio::piped())
116        .stdout(Stdio::piped())
117        .stderr(Stdio::null());
118    let mut child = retrying(|| cmd.spawn()).ok()?;
119    child.stdin.take()?.write_all(stdin.as_bytes()).ok()?;
120    let out = child.wait_with_output().ok()?;
121    out.status.success().then_some(out.stdout)
122}
123
124/// As `stdout_piped`, but inside `dir` and taking raw bytes.
125///
126/// `-C dir` matters for `hash-object`: a repository configured for SHA-256
127/// computes a different id than the default, so the identity has to be asked
128/// of THAT repository. Bytes rather than `&str` because the input is a file we
129/// have already read and must not re-encode.
130pub fn stdout_piped_in(dir: &std::path::Path, args: &[&str], stdin: &[u8]) -> Option<String> {
131    use std::io::Write;
132    let mut cmd = Command::new("git");
133    cmd.arg("-C")
134        .arg(dir)
135        .args(args)
136        .stdin(Stdio::piped())
137        .stdout(Stdio::piped())
138        .stderr(Stdio::null());
139    let mut child = retrying(|| cmd.spawn()).ok()?;
140    child.stdin.take()?.write_all(stdin).ok()?;
141    let out = child.wait_with_output().ok()?;
142    out.status
143        .success()
144        .then(|| String::from_utf8_lossy(&out.stdout).trim().to_string())
145}
146
147/// Raw stdout, untrimmed and not lossy — for a patch, where a trailing newline
148/// and any byte in a binary hunk are load-bearing.
149pub fn stdout_raw(args: &[&str]) -> Option<Vec<u8>> {
150    let mut cmd = Command::new("git");
151    cmd.args(args).stderr(Stdio::null());
152    let out = retrying(|| cmd.output()).ok()?;
153    out.status.success().then_some(out.stdout)
154}
155
156/// Everything a git command said: its exit code, its stdout and its stderr.
157pub struct Output {
158    pub code: i32,
159    pub stdout: String,
160    pub stderr: String,
161}
162
163/// A git command's full result, for the caller that must tell one kind of
164/// failure from another.
165///
166/// [`stdout`] collapses every non-zero exit to `None` and discards stderr,
167/// which is the right shape for "cannot tell, do not block". It is the wrong
168/// shape for reading configuration: `git config --get` exits **1** for a key
169/// nobody set and **128** for a key set to something git itself refuses to
170/// parse, and those two must not become the same answer — one is a default,
171/// the other is a mistake somebody needs to be told about. See `config`.
172pub fn output(args: &[&str]) -> Option<Output> {
173    let mut cmd = Command::new("git");
174    cmd.args(args);
175    full_output(cmd)
176}
177
178/// [`output`] run inside `dir` — for a caller that must ask about a
179/// repository without moving the whole process there (`set_current_dir` is
180/// process-global, and races every test in the binary).
181pub fn output_in(dir: &std::path::Path, args: &[&str]) -> Option<Output> {
182    let mut cmd = Command::new("git");
183    cmd.arg("-C").arg(dir).args(args);
184    full_output(cmd)
185}
186
187fn full_output(mut cmd: Command) -> Option<Output> {
188    cmd.stdin(Stdio::null());
189    let out = retrying(|| cmd.output()).ok()?;
190    Some(Output {
191        // A process killed by a signal has no code. Treat that as "git did not
192        // answer" rather than inventing one; the caller falls back.
193        code: out.status.code()?,
194        stdout: String::from_utf8_lossy(&out.stdout).trim().to_string(),
195        stderr: String::from_utf8_lossy(&out.stderr).trim().to_string(),
196    })
197}
198
199/// How a bounded network probe ended. Exit codes stay visible because the
200/// callers need to keep git's answers apart: `ls-remote --exit-code` exits
201/// **2** for "connected, no such ref" and **128** for "could not connect",
202/// and reading those as one boolean is how offline got reported as
203/// "upstream deleted".
204#[derive(Debug)]
205pub enum Probe {
206    /// Ran to completion with this exit code.
207    Exit(i32),
208    /// Killed at the deadline; carries the budget it exceeded, in seconds.
209    TimedOut(u64),
210    /// Could not spawn, or died to a signal — "git did not answer".
211    Failed,
212}
213
214/// A git command that TALKS TO THE NETWORK, killed at `budget_secs`.
215///
216/// Every other runner in this module waits forever, which is correct for
217/// local plumbing — a `rev-parse` that hangs means the machine is already
218/// lost. A network verb hanging is Tuesday: captive portal, VPN split
219/// brain, a remote that accepts the TCP connect and then says nothing.
220/// Unbounded, that held the push hostage inside the index hold with no
221/// deadline anywhere; the learned response is `--no-verify`, permanently.
222/// `budget_secs == 0` means no deadline (the same opt-out `amont.timeout`
223/// honours). Output is discarded — network callers decide on exit codes.
224pub fn probe(args: &[&str], budget_secs: u64) -> Probe {
225    probe_env(args, budget_secs, &[])
226}
227
228/// Whose ssh a remote call uses.
229#[derive(Clone, Copy, Debug, PartialEq)]
230pub enum Ssh {
231    /// The user configured their own (`GIT_SSH_COMMAND`, `GIT_SSH` or
232    /// `core.sshCommand`); it is left alone — replacing it could drop the
233    /// key selection it exists for. It may prompt; the deadline bounds that.
234    User,
235    /// Ours: batch mode, no prompt, a 10 s connect timeout.
236    Batch,
237}
238
239/// The configuration and environment every call that talks to a REMOTE for a
240/// verifier runs with, as data so both branches are testable without
241/// touching the process environment.
242///
243/// Never a prompt: `GIT_TERMINAL_PROMPT=0` alone is not enough, because git
244/// runs `GIT_ASKPASS` / `core.askPass` / `SSH_ASKPASS` BEFORE consulting it;
245/// a `GIT_ASKPASS` that is present and EMPTY makes git run none of them.
246/// `GCM_INTERACTIVE` and `credential.interactive` tell credential managers
247/// the same; the helpers themselves stay, so stored credentials still work.
248/// Never a stall: curl gives up after 10 s under 1 byte/s, ssh after a 10 s
249/// connect — and the caller's own deadline covers the rest.
250pub fn remote_env(ssh: Ssh) -> (Vec<&'static str>, Vec<(&'static str, &'static str)>) {
251    let args = vec![
252        "-c",
253        "http.lowSpeedLimit=1",
254        "-c",
255        "http.lowSpeedTime=10",
256        "-c",
257        "credential.interactive=never",
258    ];
259    let mut env = vec![
260        ("GIT_TERMINAL_PROMPT", "0"),
261        ("GIT_ASKPASS", ""),
262        ("GCM_INTERACTIVE", "never"),
263    ];
264    if ssh == Ssh::Batch {
265        env.push((
266            "GIT_SSH_COMMAND",
267            "ssh -o BatchMode=yes -o ConnectTimeout=10",
268        ));
269    }
270    (args, env)
271}
272
273/// Whose ssh this environment configures.
274pub fn configured_ssh() -> Ssh {
275    let set = |v: &str| std::env::var_os(v).is_some_and(|v| !v.is_empty());
276    if set("GIT_SSH_COMMAND") || set("GIT_SSH") || succeeds(&["config", "--get", "core.sshCommand"])
277    {
278        Ssh::User
279    } else {
280        Ssh::Batch
281    }
282}
283
284/// `git <args>` against a remote, with [`remote_env`] and a deadline.
285pub fn probe_remote(args: &[&str], budget_secs: u64) -> Probe {
286    let (pre, env) = remote_env(configured_ssh());
287    let mut all: Vec<&str> = pre;
288    all.extend_from_slice(args);
289    probe_env(&all, budget_secs, &env)
290}
291
292/// [`probe`], with extra environment for the child.
293pub fn probe_env(args: &[&str], budget_secs: u64, env: &[(&str, &str)]) -> Probe {
294    probe_run(args, budget_secs, env, false).0
295}
296
297/// [`probe_remote`], keeping what git printed on stdout.
298///
299/// For the callers that need the ANSWER and not only the exit code: `land`
300/// reads a remote tip with `ls-remote`, and a probe that discards stdout
301/// could say "connected" but never which commit. Same environment, same
302/// deadline, same separation of exit codes. The output is small by
303/// construction (one ref), so it is read after the child ends rather than
304/// beside it; a caller asking for a large stream wants a different tool.
305pub fn probe_remote_stdout(args: &[&str], budget_secs: u64) -> (Probe, String) {
306    let (pre, env) = remote_env(configured_ssh());
307    let mut all: Vec<&str> = pre;
308    all.extend_from_slice(args);
309    probe_run(&all, budget_secs, &env, true)
310}
311
312fn probe_run(
313    args: &[&str],
314    budget_secs: u64,
315    env: &[(&str, &str)],
316    capture: bool,
317) -> (Probe, String) {
318    let mut cmd = Command::new("git");
319    cmd.args(args)
320        .envs(env.iter().copied())
321        .stdin(Stdio::null())
322        .stdout(if capture {
323            Stdio::piped()
324        } else {
325            Stdio::null()
326        })
327        .stderr(Stdio::null());
328    let read_out = |child: &mut std::process::Child| -> String {
329        use std::io::Read;
330        let mut text = String::new();
331        if let Some(mut out) = child.stdout.take() {
332            let _ = out.read_to_string(&mut text);
333        }
334        text
335    };
336    if budget_secs == 0 && !capture {
337        return match retrying(|| cmd.status()) {
338            Ok(s) => (
339                s.code().map(Probe::Exit).unwrap_or(Probe::Failed),
340                String::new(),
341            ),
342            Err(_) => (Probe::Failed, String::new()),
343        };
344    }
345    let Ok(mut child) = retrying(|| cmd.spawn()) else {
346        return (Probe::Failed, String::new());
347    };
348    // No deadline (`budget_secs == 0`) is an hour here, not forever: the
349    // capture path polls like the bounded one and a poll with no end is the
350    // unbounded wait this module exists to avoid.
351    let budget = if budget_secs == 0 { 3600 } else { budget_secs };
352    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(budget);
353    loop {
354        match child.try_wait() {
355            Ok(Some(s)) => {
356                let text = read_out(&mut child);
357                return (s.code().map(Probe::Exit).unwrap_or(Probe::Failed), text);
358            }
359            Ok(None) => {}
360            Err(_) => return (Probe::Failed, String::new()),
361        }
362        if std::time::Instant::now() >= deadline {
363            let _ = child.kill();
364            let _ = child.wait();
365            return (Probe::TimedOut(budget), String::new());
366        }
367        std::thread::sleep(std::time::Duration::from_millis(25));
368    }
369}
370
371/// True when the command exits 0. Output discarded.
372pub fn succeeds(args: &[&str]) -> bool {
373    let mut cmd = Command::new("git");
374    cmd.args(args)
375        .stdin(Stdio::null())
376        .stdout(Stdio::null())
377        .stderr(Stdio::null());
378    retrying(|| cmd.status())
379        .map(|s| s.success())
380        .unwrap_or(false)
381}
382
383/// A path list from `diff --name-only`, `diff-tree --name-only` or
384/// `ls-files` — commands whose output is meant to be split into individual
385/// paths, never just read as one blob.
386///
387/// By default git QUOTES any "unusual" byte in a path, non-ASCII included:
388/// `é.json` prints as `"\303\251.json"`. Reading that line as a literal path
389/// looks up a file that does not exist — the caller then treats real,
390/// unstaged content as absent, which is how `StagedOnly` used to lose it.
391/// `-z` disables quoting entirely and NUL-terminates each entry instead, so
392/// there is no escaping left to get wrong. Inserted right after the
393/// subcommand (`args[0]`), which is always a valid position for it on every
394/// command this is used for.
395pub fn stdout_paths(args: &[&str]) -> Option<Vec<String>> {
396    let (first, rest) = args.split_first()?;
397    let mut argv = Vec::with_capacity(args.len() + 1);
398    argv.push(*first);
399    argv.push("-z");
400    argv.extend_from_slice(rest);
401    stdout_raw(&argv).map(|raw| split_nul_paths(&raw))
402}
403
404/// The parsing half of [`stdout_paths`], split out so it can be tested on
405/// literal bytes rather than a real git process — including the byte
406/// sequence a QUOTED path would have produced under the old line-splitting
407/// approach, to prove `-z` output is never reinterpreted that way.
408pub(crate) fn split_nul_paths(raw: &[u8]) -> Vec<String> {
409    raw.split(|&b| b == 0)
410        .filter(|s| !s.is_empty())
411        .map(|s| String::from_utf8_lossy(s).into_owned())
412        .collect()
413}
414
415#[cfg(test)]
416mod retry_tests {
417    use super::*;
418
419    /// The classifier: scheduler hiccups retry, real answers do not.
420    #[test]
421    fn transient_covers_the_fork_pressure_kinds_and_nothing_else() {
422        for code in [4, 11, 26, 35] {
423            assert!(
424                transient(&std::io::Error::from_raw_os_error(code)),
425                "raw {code} is a loaded-machine hiccup"
426            );
427        }
428        assert!(transient(&std::io::Error::from(
429            std::io::ErrorKind::Interrupted
430        )));
431        assert!(!transient(&std::io::Error::from(
432            std::io::ErrorKind::NotFound
433        )));
434        assert!(!transient(&std::io::Error::from_raw_os_error(13))); // EACCES
435    }
436
437    /// Three attempts, then the error is the caller's: a git that is
438    /// genuinely absent must not cost more than ~130ms of patience.
439    #[test]
440    fn retrying_gives_up_after_three_transient_failures() {
441        let mut calls = 0;
442        let r: std::io::Result<()> = retrying(|| {
443            calls += 1;
444            Err(std::io::Error::from_raw_os_error(11))
445        });
446        assert!(r.is_err());
447        assert_eq!(calls, 3);
448    }
449
450    /// A non-transient error returns immediately — a missing git is an
451    /// answer, not a hiccup.
452    #[test]
453    fn a_hard_error_is_not_retried() {
454        let mut calls = 0;
455        let r: std::io::Result<()> = retrying(|| {
456            calls += 1;
457            Err(std::io::Error::from(std::io::ErrorKind::NotFound))
458        });
459        assert!(r.is_err());
460        assert_eq!(calls, 1);
461    }
462
463    /// A success after a hiccup is a success.
464    #[test]
465    fn one_hiccup_then_an_answer_is_an_answer() {
466        let mut calls = 0;
467        let r = retrying(|| {
468            calls += 1;
469            if calls == 1 {
470                Err(std::io::Error::from_raw_os_error(4))
471            } else {
472                Ok(42)
473            }
474        });
475        assert_eq!(r.unwrap(), 42);
476        assert_eq!(calls, 2);
477    }
478}
479
480#[cfg(test)]
481mod tests {
482    use super::*;
483
484    #[test]
485    fn probe_remote_stdout_returns_what_git_printed_and_the_exit_code() {
486        // `probe_remote_stdout` spawns git in the process cwd, which the
487        // cwd-moving tests in `attest` and `gate_stamp` change under it: it ran
488        // inside another test's repository, or one already deleted, and git
489        // exited non-zero. The crate-wide lock is the rule for exactly this.
490        let _cwd = crate::TEST_CWD.lock().unwrap_or_else(|p| p.into_inner());
491        let dir = std::env::temp_dir().join(format!("amont-probe-stdout-{}", std::process::id()));
492        let _ = std::fs::remove_dir_all(&dir);
493        std::fs::create_dir_all(&dir).expect("mkdir");
494        let run = |args: &[&str]| {
495            let out = Command::new("git")
496                .arg("-C")
497                .arg(&dir)
498                .args(args)
499                .output()
500                .expect("git");
501            assert!(out.status.success(), "{args:?}");
502            String::from_utf8_lossy(&out.stdout).trim().to_string()
503        };
504        run(&["init", "-q", "--initial-branch=main", "."]);
505        run(&[
506            "-c",
507            "user.email=t@e",
508            "-c",
509            "user.name=t",
510            "-c",
511            "core.hooksPath=/dev/null",
512            "commit",
513            "-q",
514            "--allow-empty",
515            "-m",
516            "x",
517        ]);
518        let sha = run(&["rev-parse", "HEAD"]);
519        let url = dir.to_string_lossy().to_string();
520
521        let (probe, out) = probe_remote_stdout(&["ls-remote", &url, "refs/heads/main"], 30);
522        assert!(matches!(probe, Probe::Exit(0)), "{probe:?} {out}");
523        assert!(out.starts_with(&sha), "{out}");
524
525        // Connected, and no such ref: success with nothing on stdout.
526        let (probe, out) = probe_remote_stdout(&["ls-remote", &url, "refs/heads/nope"], 30);
527        assert!(matches!(probe, Probe::Exit(0)), "{probe:?} {out}");
528        assert!(out.trim().is_empty());
529
530        // Could not connect: the exit code stays visible, stdout is empty.
531        let (probe, out) = probe_remote_stdout(&["ls-remote", "/nonexistent/repo.git"], 30);
532        assert!(matches!(probe, Probe::Exit(c) if c != 0));
533        assert!(out.is_empty());
534        let _ = std::fs::remove_dir_all(&dir);
535    }
536
537    #[test]
538    fn splits_on_nul_and_drops_the_trailing_empty_segment() {
539        assert_eq!(
540            split_nul_paths(b"src/main.rs\0Cargo.toml\0"),
541            vec!["src/main.rs", "Cargo.toml"]
542        );
543    }
544
545    #[test]
546    fn empty_input_is_no_paths() {
547        assert_eq!(split_nul_paths(b""), Vec::<String>::new());
548    }
549
550    /// The exact bug this exists to prevent: under `--name-only` without
551    /// `-z`, git would have printed `é.json` as the quoted, LINE-oriented
552    /// text `"\303\251.json"` — literal backslashes, digits and quotes, nine
553    /// bytes standing in for the original two-byte UTF-8 sequence. `-z`
554    /// output carries the real UTF-8 bytes of the path with no such
555    /// reinterpretation, so splitting on NUL must hand them back unchanged.
556    #[test]
557    fn a_non_ascii_path_is_not_reinterpreted_as_its_quoted_form() {
558        let mut raw = "é.json".as_bytes().to_vec();
559        raw.push(0);
560        let got = split_nul_paths(&raw);
561        assert_eq!(got, vec!["é.json".to_string()]);
562        assert_ne!(got[0], "\"\\303\\251.json\"", "must not be the quoted form");
563    }
564}
565
566/// The branch `HEAD` names, or `None` on a detached head — asked of git ONCE
567/// per process and lent to every check that wants it.
568///
569/// Two always-on pre-commit checks (`branch-pattern`, `branch-protect`) open
570/// with this same question. Asked twice it is two spawns on every commit,
571/// which is exactly the o(checks) growth `tests/spawn_budget.rs` exists to
572/// refuse; asked once it is the price of one check, however many share it.
573pub fn current_branch() -> Option<&'static str> {
574    static BRANCH: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
575    BRANCH
576        .get_or_init(|| stdout(&["symbolic-ref", "--quiet", "--short", "HEAD"]))
577        .as_deref()
578}
579
580/// Whether any remote is configured. Same device, same reason: a contract
581/// about pushing has nothing to gate in a repository nothing is pushed from,
582/// and more than one check asks before speaking.
583pub fn has_remote() -> bool {
584    static REMOTE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
585    *REMOTE.get_or_init(|| stdout(&["remote"]).is_some_and(|r| !r.is_empty()))
586}