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).stdin(Stdio::null());
175 let out = retrying(|| cmd.output()).ok()?;
176 Some(Output {
177 // A process killed by a signal has no code. Treat that as "git did not
178 // answer" rather than inventing one; the caller falls back.
179 code: out.status.code()?,
180 stdout: String::from_utf8_lossy(&out.stdout).trim().to_string(),
181 stderr: String::from_utf8_lossy(&out.stderr).trim().to_string(),
182 })
183}
184
185/// How a bounded network probe ended. Exit codes stay visible because the
186/// callers need to keep git's answers apart: `ls-remote --exit-code` exits
187/// **2** for "connected, no such ref" and **128** for "could not connect",
188/// and reading those as one boolean is how offline got reported as
189/// "upstream deleted".
190pub enum Probe {
191 /// Ran to completion with this exit code.
192 Exit(i32),
193 /// Killed at the deadline; carries the budget it exceeded, in seconds.
194 TimedOut(u64),
195 /// Could not spawn, or died to a signal — "git did not answer".
196 Failed,
197}
198
199/// A git command that TALKS TO THE NETWORK, killed at `budget_secs`.
200///
201/// Every other runner in this module waits forever, which is correct for
202/// local plumbing — a `rev-parse` that hangs means the machine is already
203/// lost. A network verb hanging is Tuesday: captive portal, VPN split
204/// brain, a remote that accepts the TCP connect and then says nothing.
205/// Unbounded, that held the push hostage inside the index hold with no
206/// deadline anywhere; the learned response is `--no-verify`, permanently.
207/// `budget_secs == 0` means no deadline (the same opt-out `amont.timeout`
208/// honours). Output is discarded — network callers decide on exit codes.
209pub fn probe(args: &[&str], budget_secs: u64) -> Probe {
210 probe_env(args, budget_secs, &[])
211}
212
213/// Whose ssh a remote call uses.
214#[derive(Clone, Copy, Debug, PartialEq)]
215pub enum Ssh {
216 /// The user configured their own (`GIT_SSH_COMMAND`, `GIT_SSH` or
217 /// `core.sshCommand`); it is left alone — replacing it could drop the
218 /// key selection it exists for. It may prompt; the deadline bounds that.
219 User,
220 /// Ours: batch mode, no prompt, a 10 s connect timeout.
221 Batch,
222}
223
224/// The configuration and environment every call that talks to a REMOTE for a
225/// verifier runs with, as data so both branches are testable without
226/// touching the process environment.
227///
228/// Never a prompt: `GIT_TERMINAL_PROMPT=0` alone is not enough, because git
229/// runs `GIT_ASKPASS` / `core.askPass` / `SSH_ASKPASS` BEFORE consulting it;
230/// a `GIT_ASKPASS` that is present and EMPTY makes git run none of them.
231/// `GCM_INTERACTIVE` and `credential.interactive` tell credential managers
232/// the same; the helpers themselves stay, so stored credentials still work.
233/// Never a stall: curl gives up after 10 s under 1 byte/s, ssh after a 10 s
234/// connect — and the caller's own deadline covers the rest.
235pub fn remote_env(ssh: Ssh) -> (Vec<&'static str>, Vec<(&'static str, &'static str)>) {
236 let args = vec![
237 "-c",
238 "http.lowSpeedLimit=1",
239 "-c",
240 "http.lowSpeedTime=10",
241 "-c",
242 "credential.interactive=never",
243 ];
244 let mut env = vec![
245 ("GIT_TERMINAL_PROMPT", "0"),
246 ("GIT_ASKPASS", ""),
247 ("GCM_INTERACTIVE", "never"),
248 ];
249 if ssh == Ssh::Batch {
250 env.push((
251 "GIT_SSH_COMMAND",
252 "ssh -o BatchMode=yes -o ConnectTimeout=10",
253 ));
254 }
255 (args, env)
256}
257
258/// Whose ssh this environment configures.
259pub fn configured_ssh() -> Ssh {
260 let set = |v: &str| std::env::var_os(v).is_some_and(|v| !v.is_empty());
261 if set("GIT_SSH_COMMAND") || set("GIT_SSH") || succeeds(&["config", "--get", "core.sshCommand"])
262 {
263 Ssh::User
264 } else {
265 Ssh::Batch
266 }
267}
268
269/// `git <args>` against a remote, with [`remote_env`] and a deadline.
270pub fn probe_remote(args: &[&str], budget_secs: u64) -> Probe {
271 let (pre, env) = remote_env(configured_ssh());
272 let mut all: Vec<&str> = pre;
273 all.extend_from_slice(args);
274 probe_env(&all, budget_secs, &env)
275}
276
277/// [`probe`], with extra environment for the child.
278pub fn probe_env(args: &[&str], budget_secs: u64, env: &[(&str, &str)]) -> Probe {
279 let mut cmd = Command::new("git");
280 cmd.args(args)
281 .envs(env.iter().copied())
282 .stdin(Stdio::null())
283 .stdout(Stdio::null())
284 .stderr(Stdio::null());
285 if budget_secs == 0 {
286 return match retrying(|| cmd.status()) {
287 Ok(s) => s.code().map(Probe::Exit).unwrap_or(Probe::Failed),
288 Err(_) => Probe::Failed,
289 };
290 }
291 let Ok(mut child) = retrying(|| cmd.spawn()) else {
292 return Probe::Failed;
293 };
294 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(budget_secs);
295 loop {
296 match child.try_wait() {
297 Ok(Some(s)) => return s.code().map(Probe::Exit).unwrap_or(Probe::Failed),
298 Ok(None) => {}
299 Err(_) => return Probe::Failed,
300 }
301 if std::time::Instant::now() >= deadline {
302 let _ = child.kill();
303 let _ = child.wait();
304 return Probe::TimedOut(budget_secs);
305 }
306 std::thread::sleep(std::time::Duration::from_millis(25));
307 }
308}
309
310/// True when the command exits 0. Output discarded.
311pub fn succeeds(args: &[&str]) -> bool {
312 let mut cmd = Command::new("git");
313 cmd.args(args)
314 .stdin(Stdio::null())
315 .stdout(Stdio::null())
316 .stderr(Stdio::null());
317 retrying(|| cmd.status())
318 .map(|s| s.success())
319 .unwrap_or(false)
320}
321
322/// A path list from `diff --name-only`, `diff-tree --name-only` or
323/// `ls-files` — commands whose output is meant to be split into individual
324/// paths, never just read as one blob.
325///
326/// By default git QUOTES any "unusual" byte in a path, non-ASCII included:
327/// `é.json` prints as `"\303\251.json"`. Reading that line as a literal path
328/// looks up a file that does not exist — the caller then treats real,
329/// unstaged content as absent, which is how `StagedOnly` used to lose it.
330/// `-z` disables quoting entirely and NUL-terminates each entry instead, so
331/// there is no escaping left to get wrong. Inserted right after the
332/// subcommand (`args[0]`), which is always a valid position for it on every
333/// command this is used for.
334pub fn stdout_paths(args: &[&str]) -> Option<Vec<String>> {
335 let (first, rest) = args.split_first()?;
336 let mut argv = Vec::with_capacity(args.len() + 1);
337 argv.push(*first);
338 argv.push("-z");
339 argv.extend_from_slice(rest);
340 stdout_raw(&argv).map(|raw| split_nul_paths(&raw))
341}
342
343/// The parsing half of [`stdout_paths`], split out so it can be tested on
344/// literal bytes rather than a real git process — including the byte
345/// sequence a QUOTED path would have produced under the old line-splitting
346/// approach, to prove `-z` output is never reinterpreted that way.
347pub(crate) fn split_nul_paths(raw: &[u8]) -> Vec<String> {
348 raw.split(|&b| b == 0)
349 .filter(|s| !s.is_empty())
350 .map(|s| String::from_utf8_lossy(s).into_owned())
351 .collect()
352}
353
354#[cfg(test)]
355mod retry_tests {
356 use super::*;
357
358 /// The classifier: scheduler hiccups retry, real answers do not.
359 #[test]
360 fn transient_covers_the_fork_pressure_kinds_and_nothing_else() {
361 for code in [4, 11, 26, 35] {
362 assert!(
363 transient(&std::io::Error::from_raw_os_error(code)),
364 "raw {code} is a loaded-machine hiccup"
365 );
366 }
367 assert!(transient(&std::io::Error::from(
368 std::io::ErrorKind::Interrupted
369 )));
370 assert!(!transient(&std::io::Error::from(
371 std::io::ErrorKind::NotFound
372 )));
373 assert!(!transient(&std::io::Error::from_raw_os_error(13))); // EACCES
374 }
375
376 /// Three attempts, then the error is the caller's: a git that is
377 /// genuinely absent must not cost more than ~130ms of patience.
378 #[test]
379 fn retrying_gives_up_after_three_transient_failures() {
380 let mut calls = 0;
381 let r: std::io::Result<()> = retrying(|| {
382 calls += 1;
383 Err(std::io::Error::from_raw_os_error(11))
384 });
385 assert!(r.is_err());
386 assert_eq!(calls, 3);
387 }
388
389 /// A non-transient error returns immediately — a missing git is an
390 /// answer, not a hiccup.
391 #[test]
392 fn a_hard_error_is_not_retried() {
393 let mut calls = 0;
394 let r: std::io::Result<()> = retrying(|| {
395 calls += 1;
396 Err(std::io::Error::from(std::io::ErrorKind::NotFound))
397 });
398 assert!(r.is_err());
399 assert_eq!(calls, 1);
400 }
401
402 /// A success after a hiccup is a success.
403 #[test]
404 fn one_hiccup_then_an_answer_is_an_answer() {
405 let mut calls = 0;
406 let r = retrying(|| {
407 calls += 1;
408 if calls == 1 {
409 Err(std::io::Error::from_raw_os_error(4))
410 } else {
411 Ok(42)
412 }
413 });
414 assert_eq!(r.unwrap(), 42);
415 assert_eq!(calls, 2);
416 }
417}
418
419#[cfg(test)]
420mod tests {
421 use super::*;
422
423 #[test]
424 fn splits_on_nul_and_drops_the_trailing_empty_segment() {
425 assert_eq!(
426 split_nul_paths(b"src/main.rs\0Cargo.toml\0"),
427 vec!["src/main.rs", "Cargo.toml"]
428 );
429 }
430
431 #[test]
432 fn empty_input_is_no_paths() {
433 assert_eq!(split_nul_paths(b""), Vec::<String>::new());
434 }
435
436 /// The exact bug this exists to prevent: under `--name-only` without
437 /// `-z`, git would have printed `é.json` as the quoted, LINE-oriented
438 /// text `"\303\251.json"` — literal backslashes, digits and quotes, nine
439 /// bytes standing in for the original two-byte UTF-8 sequence. `-z`
440 /// output carries the real UTF-8 bytes of the path with no such
441 /// reinterpretation, so splitting on NUL must hand them back unchanged.
442 #[test]
443 fn a_non_ascii_path_is_not_reinterpreted_as_its_quoted_form() {
444 let mut raw = "é.json".as_bytes().to_vec();
445 raw.push(0);
446 let got = split_nul_paths(&raw);
447 assert_eq!(got, vec!["é.json".to_string()]);
448 assert_ne!(got[0], "\"\\303\\251.json\"", "must not be the quoted form");
449 }
450}
451
452/// The branch `HEAD` names, or `None` on a detached head — asked of git ONCE
453/// per process and lent to every check that wants it.
454///
455/// Two always-on pre-commit checks (`branch-pattern`, `branch-protect`) open
456/// with this same question. Asked twice it is two spawns on every commit,
457/// which is exactly the o(checks) growth `tests/spawn_budget.rs` exists to
458/// refuse; asked once it is the price of one check, however many share it.
459pub fn current_branch() -> Option<&'static str> {
460 static BRANCH: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
461 BRANCH
462 .get_or_init(|| stdout(&["symbolic-ref", "--quiet", "--short", "HEAD"]))
463 .as_deref()
464}
465
466/// Whether any remote is configured. Same device, same reason: a contract
467/// about pushing has nothing to gate in a repository nothing is pushed from,
468/// and more than one check asks before speaking.
469pub fn has_remote() -> bool {
470 static REMOTE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
471 *REMOTE.get_or_init(|| stdout(&["remote"]).is_some_and(|r| !r.is_empty()))
472}