1use std::process::{Command, Stdio};
4
5fn 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
30fn 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
44pub 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
56pub 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
71pub 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
87pub 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
105pub 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
124pub 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
147pub 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
156pub struct Output {
158 pub code: i32,
159 pub stdout: String,
160 pub stderr: String,
161}
162
163pub fn output(args: &[&str]) -> Option<Output> {
173 let mut cmd = Command::new("git");
174 cmd.args(args);
175 full_output(cmd)
176}
177
178pub 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 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
199pub enum Probe {
205 Exit(i32),
207 TimedOut(u64),
209 Failed,
211}
212
213pub fn probe(args: &[&str], budget_secs: u64) -> Probe {
224 probe_env(args, budget_secs, &[])
225}
226
227#[derive(Clone, Copy, Debug, PartialEq)]
229pub enum Ssh {
230 User,
234 Batch,
236}
237
238pub fn remote_env(ssh: Ssh) -> (Vec<&'static str>, Vec<(&'static str, &'static str)>) {
250 let args = vec![
251 "-c",
252 "http.lowSpeedLimit=1",
253 "-c",
254 "http.lowSpeedTime=10",
255 "-c",
256 "credential.interactive=never",
257 ];
258 let mut env = vec![
259 ("GIT_TERMINAL_PROMPT", "0"),
260 ("GIT_ASKPASS", ""),
261 ("GCM_INTERACTIVE", "never"),
262 ];
263 if ssh == Ssh::Batch {
264 env.push((
265 "GIT_SSH_COMMAND",
266 "ssh -o BatchMode=yes -o ConnectTimeout=10",
267 ));
268 }
269 (args, env)
270}
271
272pub fn configured_ssh() -> Ssh {
274 let set = |v: &str| std::env::var_os(v).is_some_and(|v| !v.is_empty());
275 if set("GIT_SSH_COMMAND") || set("GIT_SSH") || succeeds(&["config", "--get", "core.sshCommand"])
276 {
277 Ssh::User
278 } else {
279 Ssh::Batch
280 }
281}
282
283pub fn probe_remote(args: &[&str], budget_secs: u64) -> Probe {
285 let (pre, env) = remote_env(configured_ssh());
286 let mut all: Vec<&str> = pre;
287 all.extend_from_slice(args);
288 probe_env(&all, budget_secs, &env)
289}
290
291pub fn probe_env(args: &[&str], budget_secs: u64, env: &[(&str, &str)]) -> Probe {
293 probe_run(args, budget_secs, env, false).0
294}
295
296pub fn probe_remote_stdout(args: &[&str], budget_secs: u64) -> (Probe, String) {
305 let (pre, env) = remote_env(configured_ssh());
306 let mut all: Vec<&str> = pre;
307 all.extend_from_slice(args);
308 probe_run(&all, budget_secs, &env, true)
309}
310
311fn probe_run(
312 args: &[&str],
313 budget_secs: u64,
314 env: &[(&str, &str)],
315 capture: bool,
316) -> (Probe, String) {
317 let mut cmd = Command::new("git");
318 cmd.args(args)
319 .envs(env.iter().copied())
320 .stdin(Stdio::null())
321 .stdout(if capture {
322 Stdio::piped()
323 } else {
324 Stdio::null()
325 })
326 .stderr(Stdio::null());
327 let read_out = |child: &mut std::process::Child| -> String {
328 use std::io::Read;
329 let mut text = String::new();
330 if let Some(mut out) = child.stdout.take() {
331 let _ = out.read_to_string(&mut text);
332 }
333 text
334 };
335 if budget_secs == 0 && !capture {
336 return match retrying(|| cmd.status()) {
337 Ok(s) => (
338 s.code().map(Probe::Exit).unwrap_or(Probe::Failed),
339 String::new(),
340 ),
341 Err(_) => (Probe::Failed, String::new()),
342 };
343 }
344 let Ok(mut child) = retrying(|| cmd.spawn()) else {
345 return (Probe::Failed, String::new());
346 };
347 let budget = if budget_secs == 0 { 3600 } else { budget_secs };
351 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(budget);
352 loop {
353 match child.try_wait() {
354 Ok(Some(s)) => {
355 let text = read_out(&mut child);
356 return (s.code().map(Probe::Exit).unwrap_or(Probe::Failed), text);
357 }
358 Ok(None) => {}
359 Err(_) => return (Probe::Failed, String::new()),
360 }
361 if std::time::Instant::now() >= deadline {
362 let _ = child.kill();
363 let _ = child.wait();
364 return (Probe::TimedOut(budget), String::new());
365 }
366 std::thread::sleep(std::time::Duration::from_millis(25));
367 }
368}
369
370pub fn succeeds(args: &[&str]) -> bool {
372 let mut cmd = Command::new("git");
373 cmd.args(args)
374 .stdin(Stdio::null())
375 .stdout(Stdio::null())
376 .stderr(Stdio::null());
377 retrying(|| cmd.status())
378 .map(|s| s.success())
379 .unwrap_or(false)
380}
381
382pub fn stdout_paths(args: &[&str]) -> Option<Vec<String>> {
395 let (first, rest) = args.split_first()?;
396 let mut argv = Vec::with_capacity(args.len() + 1);
397 argv.push(*first);
398 argv.push("-z");
399 argv.extend_from_slice(rest);
400 stdout_raw(&argv).map(|raw| split_nul_paths(&raw))
401}
402
403pub(crate) fn split_nul_paths(raw: &[u8]) -> Vec<String> {
408 raw.split(|&b| b == 0)
409 .filter(|s| !s.is_empty())
410 .map(|s| String::from_utf8_lossy(s).into_owned())
411 .collect()
412}
413
414#[cfg(test)]
415mod retry_tests {
416 use super::*;
417
418 #[test]
420 fn transient_covers_the_fork_pressure_kinds_and_nothing_else() {
421 for code in [4, 11, 26, 35] {
422 assert!(
423 transient(&std::io::Error::from_raw_os_error(code)),
424 "raw {code} is a loaded-machine hiccup"
425 );
426 }
427 assert!(transient(&std::io::Error::from(
428 std::io::ErrorKind::Interrupted
429 )));
430 assert!(!transient(&std::io::Error::from(
431 std::io::ErrorKind::NotFound
432 )));
433 assert!(!transient(&std::io::Error::from_raw_os_error(13))); }
435
436 #[test]
439 fn retrying_gives_up_after_three_transient_failures() {
440 let mut calls = 0;
441 let r: std::io::Result<()> = retrying(|| {
442 calls += 1;
443 Err(std::io::Error::from_raw_os_error(11))
444 });
445 assert!(r.is_err());
446 assert_eq!(calls, 3);
447 }
448
449 #[test]
452 fn a_hard_error_is_not_retried() {
453 let mut calls = 0;
454 let r: std::io::Result<()> = retrying(|| {
455 calls += 1;
456 Err(std::io::Error::from(std::io::ErrorKind::NotFound))
457 });
458 assert!(r.is_err());
459 assert_eq!(calls, 1);
460 }
461
462 #[test]
464 fn one_hiccup_then_an_answer_is_an_answer() {
465 let mut calls = 0;
466 let r = retrying(|| {
467 calls += 1;
468 if calls == 1 {
469 Err(std::io::Error::from_raw_os_error(4))
470 } else {
471 Ok(42)
472 }
473 });
474 assert_eq!(r.unwrap(), 42);
475 assert_eq!(calls, 2);
476 }
477}
478
479#[cfg(test)]
480mod tests {
481 use super::*;
482
483 #[test]
484 fn probe_remote_stdout_returns_what_git_printed_and_the_exit_code() {
485 let dir = std::env::temp_dir().join(format!("amont-probe-stdout-{}", std::process::id()));
486 let _ = std::fs::remove_dir_all(&dir);
487 std::fs::create_dir_all(&dir).expect("mkdir");
488 let run = |args: &[&str]| {
489 let out = Command::new("git")
490 .arg("-C")
491 .arg(&dir)
492 .args(args)
493 .output()
494 .expect("git");
495 assert!(out.status.success(), "{args:?}");
496 String::from_utf8_lossy(&out.stdout).trim().to_string()
497 };
498 run(&["init", "-q", "--initial-branch=main", "."]);
499 run(&[
500 "-c",
501 "user.email=t@e",
502 "-c",
503 "user.name=t",
504 "-c",
505 "core.hooksPath=/dev/null",
506 "commit",
507 "-q",
508 "--allow-empty",
509 "-m",
510 "x",
511 ]);
512 let sha = run(&["rev-parse", "HEAD"]);
513 let url = dir.to_string_lossy().to_string();
514
515 let (probe, out) = probe_remote_stdout(&["ls-remote", &url, "refs/heads/main"], 30);
516 assert!(matches!(probe, Probe::Exit(0)));
517 assert!(out.starts_with(&sha), "{out}");
518
519 let (probe, out) = probe_remote_stdout(&["ls-remote", &url, "refs/heads/nope"], 30);
521 assert!(matches!(probe, Probe::Exit(0)));
522 assert!(out.trim().is_empty());
523
524 let (probe, out) = probe_remote_stdout(&["ls-remote", "/nonexistent/repo.git"], 30);
526 assert!(matches!(probe, Probe::Exit(c) if c != 0));
527 assert!(out.is_empty());
528 let _ = std::fs::remove_dir_all(&dir);
529 }
530
531 #[test]
532 fn splits_on_nul_and_drops_the_trailing_empty_segment() {
533 assert_eq!(
534 split_nul_paths(b"src/main.rs\0Cargo.toml\0"),
535 vec!["src/main.rs", "Cargo.toml"]
536 );
537 }
538
539 #[test]
540 fn empty_input_is_no_paths() {
541 assert_eq!(split_nul_paths(b""), Vec::<String>::new());
542 }
543
544 #[test]
551 fn a_non_ascii_path_is_not_reinterpreted_as_its_quoted_form() {
552 let mut raw = "é.json".as_bytes().to_vec();
553 raw.push(0);
554 let got = split_nul_paths(&raw);
555 assert_eq!(got, vec!["é.json".to_string()]);
556 assert_ne!(got[0], "\"\\303\\251.json\"", "must not be the quoted form");
557 }
558}
559
560pub fn current_branch() -> Option<&'static str> {
568 static BRANCH: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
569 BRANCH
570 .get_or_init(|| stdout(&["symbolic-ref", "--quiet", "--short", "HEAD"]))
571 .as_deref()
572}
573
574pub fn has_remote() -> bool {
578 static REMOTE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
579 *REMOTE.get_or_init(|| stdout(&["remote"]).is_some_and(|r| !r.is_empty()))
580}