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/// stdout of a git command that itself reads a list from stdin — `diff-tree
72/// --stdin`, fed a list of commits, is the only caller today. Lossy but
73/// untrimmed: every line is a path, and the caller trims those itself.
74pub fn stdout_piped(args: &[&str], stdin: &str) -> Option<String> {
75    use std::io::Write;
76    let mut cmd = Command::new("git");
77    cmd.args(args)
78        .stdin(Stdio::piped())
79        .stdout(Stdio::piped())
80        .stderr(Stdio::null());
81    let mut child = retrying(|| cmd.spawn()).ok()?;
82    child.stdin.take()?.write_all(stdin.as_bytes()).ok()?;
83    let out = child.wait_with_output().ok()?;
84    out.status
85        .success()
86        .then(|| String::from_utf8_lossy(&out.stdout).into_owned())
87}
88
89/// As `stdout_piped`, but returning the RAW bytes.
90///
91/// Needed by the one caller that must both feed git a list on stdin and read a
92/// `-z` path list back — `diff-tree --stdin -z`. `stdout_paths` cannot serve it
93/// (no stdin) and `stdout_piped` cannot either (lossy `String`, and the NUL
94/// separators are the whole point).
95pub fn stdout_piped_raw(args: &[&str], stdin: &str) -> Option<Vec<u8>> {
96    use std::io::Write;
97    let mut cmd = Command::new("git");
98    cmd.args(args)
99        .stdin(Stdio::piped())
100        .stdout(Stdio::piped())
101        .stderr(Stdio::null());
102    let mut child = retrying(|| cmd.spawn()).ok()?;
103    child.stdin.take()?.write_all(stdin.as_bytes()).ok()?;
104    let out = child.wait_with_output().ok()?;
105    out.status.success().then_some(out.stdout)
106}
107
108/// As `stdout_piped`, but inside `dir` and taking raw bytes.
109///
110/// `-C dir` matters for `hash-object`: a repository configured for SHA-256
111/// computes a different id than the default, so the identity has to be asked
112/// of THAT repository. Bytes rather than `&str` because the input is a file we
113/// have already read and must not re-encode.
114pub fn stdout_piped_in(dir: &std::path::Path, args: &[&str], stdin: &[u8]) -> Option<String> {
115    use std::io::Write;
116    let mut cmd = Command::new("git");
117    cmd.arg("-C")
118        .arg(dir)
119        .args(args)
120        .stdin(Stdio::piped())
121        .stdout(Stdio::piped())
122        .stderr(Stdio::null());
123    let mut child = retrying(|| cmd.spawn()).ok()?;
124    child.stdin.take()?.write_all(stdin).ok()?;
125    let out = child.wait_with_output().ok()?;
126    out.status
127        .success()
128        .then(|| String::from_utf8_lossy(&out.stdout).trim().to_string())
129}
130
131/// Raw stdout, untrimmed and not lossy — for a patch, where a trailing newline
132/// and any byte in a binary hunk are load-bearing.
133pub fn stdout_raw(args: &[&str]) -> Option<Vec<u8>> {
134    let mut cmd = Command::new("git");
135    cmd.args(args).stderr(Stdio::null());
136    let out = retrying(|| cmd.output()).ok()?;
137    out.status.success().then_some(out.stdout)
138}
139
140/// Everything a git command said: its exit code, its stdout and its stderr.
141pub struct Output {
142    pub code: i32,
143    pub stdout: String,
144    pub stderr: String,
145}
146
147/// A git command's full result, for the caller that must tell one kind of
148/// failure from another.
149///
150/// [`stdout`] collapses every non-zero exit to `None` and discards stderr,
151/// which is the right shape for "cannot tell, do not block". It is the wrong
152/// shape for reading configuration: `git config --get` exits **1** for a key
153/// nobody set and **128** for a key set to something git itself refuses to
154/// parse, and those two must not become the same answer — one is a default,
155/// the other is a mistake somebody needs to be told about. See `config`.
156pub fn output(args: &[&str]) -> Option<Output> {
157    let mut cmd = Command::new("git");
158    cmd.args(args).stdin(Stdio::null());
159    let out = retrying(|| cmd.output()).ok()?;
160    Some(Output {
161        // A process killed by a signal has no code. Treat that as "git did not
162        // answer" rather than inventing one; the caller falls back.
163        code: out.status.code()?,
164        stdout: String::from_utf8_lossy(&out.stdout).trim().to_string(),
165        stderr: String::from_utf8_lossy(&out.stderr).trim().to_string(),
166    })
167}
168
169/// True when the command exits 0. Output discarded.
170pub fn succeeds(args: &[&str]) -> bool {
171    let mut cmd = Command::new("git");
172    cmd.args(args)
173        .stdin(Stdio::null())
174        .stdout(Stdio::null())
175        .stderr(Stdio::null());
176    retrying(|| cmd.status())
177        .map(|s| s.success())
178        .unwrap_or(false)
179}
180
181/// A path list from `diff --name-only`, `diff-tree --name-only` or
182/// `ls-files` — commands whose output is meant to be split into individual
183/// paths, never just read as one blob.
184///
185/// By default git QUOTES any "unusual" byte in a path, non-ASCII included:
186/// `é.json` prints as `"\303\251.json"`. Reading that line as a literal path
187/// looks up a file that does not exist — the caller then treats real,
188/// unstaged content as absent, which is how `StagedOnly` used to lose it.
189/// `-z` disables quoting entirely and NUL-terminates each entry instead, so
190/// there is no escaping left to get wrong. Inserted right after the
191/// subcommand (`args[0]`), which is always a valid position for it on every
192/// command this is used for.
193pub fn stdout_paths(args: &[&str]) -> Option<Vec<String>> {
194    let (first, rest) = args.split_first()?;
195    let mut argv = Vec::with_capacity(args.len() + 1);
196    argv.push(*first);
197    argv.push("-z");
198    argv.extend_from_slice(rest);
199    stdout_raw(&argv).map(|raw| split_nul_paths(&raw))
200}
201
202/// The parsing half of [`stdout_paths`], split out so it can be tested on
203/// literal bytes rather than a real git process — including the byte
204/// sequence a QUOTED path would have produced under the old line-splitting
205/// approach, to prove `-z` output is never reinterpreted that way.
206pub(crate) fn split_nul_paths(raw: &[u8]) -> Vec<String> {
207    raw.split(|&b| b == 0)
208        .filter(|s| !s.is_empty())
209        .map(|s| String::from_utf8_lossy(s).into_owned())
210        .collect()
211}
212
213#[cfg(test)]
214mod retry_tests {
215    use super::*;
216
217    /// The classifier: scheduler hiccups retry, real answers do not.
218    #[test]
219    fn transient_covers_the_fork_pressure_kinds_and_nothing_else() {
220        for code in [4, 11, 26, 35] {
221            assert!(
222                transient(&std::io::Error::from_raw_os_error(code)),
223                "raw {code} is a loaded-machine hiccup"
224            );
225        }
226        assert!(transient(&std::io::Error::from(
227            std::io::ErrorKind::Interrupted
228        )));
229        assert!(!transient(&std::io::Error::from(
230            std::io::ErrorKind::NotFound
231        )));
232        assert!(!transient(&std::io::Error::from_raw_os_error(13))); // EACCES
233    }
234
235    /// Three attempts, then the error is the caller's: a git that is
236    /// genuinely absent must not cost more than ~130ms of patience.
237    #[test]
238    fn retrying_gives_up_after_three_transient_failures() {
239        let mut calls = 0;
240        let r: std::io::Result<()> = retrying(|| {
241            calls += 1;
242            Err(std::io::Error::from_raw_os_error(11))
243        });
244        assert!(r.is_err());
245        assert_eq!(calls, 3);
246    }
247
248    /// A non-transient error returns immediately — a missing git is an
249    /// answer, not a hiccup.
250    #[test]
251    fn a_hard_error_is_not_retried() {
252        let mut calls = 0;
253        let r: std::io::Result<()> = retrying(|| {
254            calls += 1;
255            Err(std::io::Error::from(std::io::ErrorKind::NotFound))
256        });
257        assert!(r.is_err());
258        assert_eq!(calls, 1);
259    }
260
261    /// A success after a hiccup is a success.
262    #[test]
263    fn one_hiccup_then_an_answer_is_an_answer() {
264        let mut calls = 0;
265        let r = retrying(|| {
266            calls += 1;
267            if calls == 1 {
268                Err(std::io::Error::from_raw_os_error(4))
269            } else {
270                Ok(42)
271            }
272        });
273        assert_eq!(r.unwrap(), 42);
274        assert_eq!(calls, 2);
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281
282    #[test]
283    fn splits_on_nul_and_drops_the_trailing_empty_segment() {
284        assert_eq!(
285            split_nul_paths(b"src/main.rs\0Cargo.toml\0"),
286            vec!["src/main.rs", "Cargo.toml"]
287        );
288    }
289
290    #[test]
291    fn empty_input_is_no_paths() {
292        assert_eq!(split_nul_paths(b""), Vec::<String>::new());
293    }
294
295    /// The exact bug this exists to prevent: under `--name-only` without
296    /// `-z`, git would have printed `é.json` as the quoted, LINE-oriented
297    /// text `"\303\251.json"` — literal backslashes, digits and quotes, nine
298    /// bytes standing in for the original two-byte UTF-8 sequence. `-z`
299    /// output carries the real UTF-8 bytes of the path with no such
300    /// reinterpretation, so splitting on NUL must hand them back unchanged.
301    #[test]
302    fn a_non_ascii_path_is_not_reinterpreted_as_its_quoted_form() {
303        let mut raw = "é.json".as_bytes().to_vec();
304        raw.push(0);
305        let got = split_nul_paths(&raw);
306        assert_eq!(got, vec!["é.json".to_string()]);
307        assert_ne!(got[0], "\"\\303\\251.json\"", "must not be the quoted form");
308    }
309}