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
199#[derive(Debug)]
205pub enum Probe {
206 Exit(i32),
208 TimedOut(u64),
210 Failed,
212}
213
214pub fn probe(args: &[&str], budget_secs: u64) -> Probe {
225 probe_env(args, budget_secs, &[])
226}
227
228#[derive(Clone, Copy, Debug, PartialEq)]
230pub enum Ssh {
231 User,
235 Batch,
237}
238
239pub 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
273pub 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
284pub 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
292pub fn probe_env(args: &[&str], budget_secs: u64, env: &[(&str, &str)]) -> Probe {
294 probe_run(args, budget_secs, env, false).0
295}
296
297pub 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 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
371pub 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
383pub 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
404pub(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 #[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))); }
436
437 #[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 #[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 #[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 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 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 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 #[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
566pub 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
580pub 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}