use std::process::{Command, Stdio};
fn retrying<T>(mut attempt: impl FnMut() -> std::io::Result<T>) -> std::io::Result<T> {
let mut delay = std::time::Duration::from_millis(10);
for tries_left in [2u8, 1, 0] {
match attempt() {
Err(e) if tries_left > 0 && transient(&e) => {
std::thread::sleep(delay);
delay *= 3;
}
other => return other,
}
}
unreachable!("the zero-tries arm returns")
}
fn transient(e: &std::io::Error) -> bool {
if matches!(
e.kind(),
std::io::ErrorKind::Interrupted | std::io::ErrorKind::WouldBlock
) {
return true;
}
matches!(e.raw_os_error(), Some(4 | 11 | 26 | 35))
}
pub fn stdout(args: &[&str]) -> Option<String> {
let mut cmd = Command::new("git");
cmd.args(args).stderr(Stdio::null());
let out = retrying(|| cmd.output()).ok()?;
if !out.status.success() {
return None;
}
Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
}
pub fn stdout_in(dir: &std::path::Path, args: &[&str]) -> Option<String> {
let mut cmd = Command::new("git");
cmd.arg("-C").arg(dir).args(args).stderr(Stdio::null());
let out = retrying(|| cmd.output()).ok()?;
if !out.status.success() {
return None;
}
Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
}
pub fn succeeds_in(dir: &std::path::Path, args: &[&str]) -> bool {
let mut cmd = Command::new("git");
cmd.arg("-C")
.arg(dir)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
retrying(|| cmd.status())
.map(|s| s.success())
.unwrap_or(false)
}
pub fn stdout_piped(args: &[&str], stdin: &str) -> Option<String> {
use std::io::Write;
let mut cmd = Command::new("git");
cmd.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null());
let mut child = retrying(|| cmd.spawn()).ok()?;
child.stdin.take()?.write_all(stdin.as_bytes()).ok()?;
let out = child.wait_with_output().ok()?;
out.status
.success()
.then(|| String::from_utf8_lossy(&out.stdout).into_owned())
}
pub fn stdout_piped_raw(args: &[&str], stdin: &str) -> Option<Vec<u8>> {
use std::io::Write;
let mut cmd = Command::new("git");
cmd.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null());
let mut child = retrying(|| cmd.spawn()).ok()?;
child.stdin.take()?.write_all(stdin.as_bytes()).ok()?;
let out = child.wait_with_output().ok()?;
out.status.success().then_some(out.stdout)
}
pub fn stdout_piped_in(dir: &std::path::Path, args: &[&str], stdin: &[u8]) -> Option<String> {
use std::io::Write;
let mut cmd = Command::new("git");
cmd.arg("-C")
.arg(dir)
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null());
let mut child = retrying(|| cmd.spawn()).ok()?;
child.stdin.take()?.write_all(stdin).ok()?;
let out = child.wait_with_output().ok()?;
out.status
.success()
.then(|| String::from_utf8_lossy(&out.stdout).trim().to_string())
}
pub fn stdout_raw(args: &[&str]) -> Option<Vec<u8>> {
let mut cmd = Command::new("git");
cmd.args(args).stderr(Stdio::null());
let out = retrying(|| cmd.output()).ok()?;
out.status.success().then_some(out.stdout)
}
pub struct Output {
pub code: i32,
pub stdout: String,
pub stderr: String,
}
pub fn output(args: &[&str]) -> Option<Output> {
let mut cmd = Command::new("git");
cmd.args(args);
full_output(cmd)
}
pub fn output_in(dir: &std::path::Path, args: &[&str]) -> Option<Output> {
let mut cmd = Command::new("git");
cmd.arg("-C").arg(dir).args(args);
full_output(cmd)
}
fn full_output(mut cmd: Command) -> Option<Output> {
cmd.stdin(Stdio::null());
let out = retrying(|| cmd.output()).ok()?;
Some(Output {
code: out.status.code()?,
stdout: String::from_utf8_lossy(&out.stdout).trim().to_string(),
stderr: String::from_utf8_lossy(&out.stderr).trim().to_string(),
})
}
#[derive(Debug)]
pub enum Probe {
Exit(i32),
TimedOut(u64),
Failed,
}
pub fn probe(args: &[&str], budget_secs: u64) -> Probe {
probe_env(args, budget_secs, &[])
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Ssh {
User,
Batch,
}
pub fn remote_env(ssh: Ssh) -> (Vec<&'static str>, Vec<(&'static str, &'static str)>) {
let args = vec![
"-c",
"http.lowSpeedLimit=1",
"-c",
"http.lowSpeedTime=10",
"-c",
"credential.interactive=never",
];
let mut env = vec![
("GIT_TERMINAL_PROMPT", "0"),
("GIT_ASKPASS", ""),
("GCM_INTERACTIVE", "never"),
];
if ssh == Ssh::Batch {
env.push((
"GIT_SSH_COMMAND",
"ssh -o BatchMode=yes -o ConnectTimeout=10",
));
}
(args, env)
}
pub fn configured_ssh() -> Ssh {
let set = |v: &str| std::env::var_os(v).is_some_and(|v| !v.is_empty());
if set("GIT_SSH_COMMAND") || set("GIT_SSH") || succeeds(&["config", "--get", "core.sshCommand"])
{
Ssh::User
} else {
Ssh::Batch
}
}
pub fn probe_remote(args: &[&str], budget_secs: u64) -> Probe {
let (pre, env) = remote_env(configured_ssh());
let mut all: Vec<&str> = pre;
all.extend_from_slice(args);
probe_env(&all, budget_secs, &env)
}
pub fn probe_env(args: &[&str], budget_secs: u64, env: &[(&str, &str)]) -> Probe {
probe_run(args, budget_secs, env, false).0
}
pub fn probe_remote_stdout(args: &[&str], budget_secs: u64) -> (Probe, String) {
let (pre, env) = remote_env(configured_ssh());
let mut all: Vec<&str> = pre;
all.extend_from_slice(args);
probe_run(&all, budget_secs, &env, true)
}
fn probe_run(
args: &[&str],
budget_secs: u64,
env: &[(&str, &str)],
capture: bool,
) -> (Probe, String) {
let mut cmd = Command::new("git");
cmd.args(args)
.envs(env.iter().copied())
.stdin(Stdio::null())
.stdout(if capture {
Stdio::piped()
} else {
Stdio::null()
})
.stderr(Stdio::null());
let read_out = |child: &mut std::process::Child| -> String {
use std::io::Read;
let mut text = String::new();
if let Some(mut out) = child.stdout.take() {
let _ = out.read_to_string(&mut text);
}
text
};
if budget_secs == 0 && !capture {
return match retrying(|| cmd.status()) {
Ok(s) => (
s.code().map(Probe::Exit).unwrap_or(Probe::Failed),
String::new(),
),
Err(_) => (Probe::Failed, String::new()),
};
}
let Ok(mut child) = retrying(|| cmd.spawn()) else {
return (Probe::Failed, String::new());
};
let budget = if budget_secs == 0 { 3600 } else { budget_secs };
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(budget);
loop {
match child.try_wait() {
Ok(Some(s)) => {
let text = read_out(&mut child);
return (s.code().map(Probe::Exit).unwrap_or(Probe::Failed), text);
}
Ok(None) => {}
Err(_) => return (Probe::Failed, String::new()),
}
if std::time::Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
return (Probe::TimedOut(budget), String::new());
}
std::thread::sleep(std::time::Duration::from_millis(25));
}
}
pub fn succeeds(args: &[&str]) -> bool {
let mut cmd = Command::new("git");
cmd.args(args)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
retrying(|| cmd.status())
.map(|s| s.success())
.unwrap_or(false)
}
pub fn stdout_paths(args: &[&str]) -> Option<Vec<String>> {
let (first, rest) = args.split_first()?;
let mut argv = Vec::with_capacity(args.len() + 1);
argv.push(*first);
argv.push("-z");
argv.extend_from_slice(rest);
stdout_raw(&argv).map(|raw| split_nul_paths(&raw))
}
pub(crate) fn split_nul_paths(raw: &[u8]) -> Vec<String> {
raw.split(|&b| b == 0)
.filter(|s| !s.is_empty())
.map(|s| String::from_utf8_lossy(s).into_owned())
.collect()
}
#[cfg(test)]
mod retry_tests {
use super::*;
#[test]
fn transient_covers_the_fork_pressure_kinds_and_nothing_else() {
for code in [4, 11, 26, 35] {
assert!(
transient(&std::io::Error::from_raw_os_error(code)),
"raw {code} is a loaded-machine hiccup"
);
}
assert!(transient(&std::io::Error::from(
std::io::ErrorKind::Interrupted
)));
assert!(!transient(&std::io::Error::from(
std::io::ErrorKind::NotFound
)));
assert!(!transient(&std::io::Error::from_raw_os_error(13))); }
#[test]
fn retrying_gives_up_after_three_transient_failures() {
let mut calls = 0;
let r: std::io::Result<()> = retrying(|| {
calls += 1;
Err(std::io::Error::from_raw_os_error(11))
});
assert!(r.is_err());
assert_eq!(calls, 3);
}
#[test]
fn a_hard_error_is_not_retried() {
let mut calls = 0;
let r: std::io::Result<()> = retrying(|| {
calls += 1;
Err(std::io::Error::from(std::io::ErrorKind::NotFound))
});
assert!(r.is_err());
assert_eq!(calls, 1);
}
#[test]
fn one_hiccup_then_an_answer_is_an_answer() {
let mut calls = 0;
let r = retrying(|| {
calls += 1;
if calls == 1 {
Err(std::io::Error::from_raw_os_error(4))
} else {
Ok(42)
}
});
assert_eq!(r.unwrap(), 42);
assert_eq!(calls, 2);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn probe_remote_stdout_returns_what_git_printed_and_the_exit_code() {
let _cwd = crate::TEST_CWD.lock().unwrap_or_else(|p| p.into_inner());
let dir = std::env::temp_dir().join(format!("amont-probe-stdout-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("mkdir");
let run = |args: &[&str]| {
let out = Command::new("git")
.arg("-C")
.arg(&dir)
.args(args)
.output()
.expect("git");
assert!(out.status.success(), "{args:?}");
String::from_utf8_lossy(&out.stdout).trim().to_string()
};
run(&["init", "-q", "--initial-branch=main", "."]);
run(&[
"-c",
"user.email=t@e",
"-c",
"user.name=t",
"-c",
"core.hooksPath=/dev/null",
"commit",
"-q",
"--allow-empty",
"-m",
"x",
]);
let sha = run(&["rev-parse", "HEAD"]);
let url = dir.to_string_lossy().to_string();
let (probe, out) = probe_remote_stdout(&["ls-remote", &url, "refs/heads/main"], 30);
assert!(matches!(probe, Probe::Exit(0)), "{probe:?} {out}");
assert!(out.starts_with(&sha), "{out}");
let (probe, out) = probe_remote_stdout(&["ls-remote", &url, "refs/heads/nope"], 30);
assert!(matches!(probe, Probe::Exit(0)), "{probe:?} {out}");
assert!(out.trim().is_empty());
let (probe, out) = probe_remote_stdout(&["ls-remote", "/nonexistent/repo.git"], 30);
assert!(matches!(probe, Probe::Exit(c) if c != 0));
assert!(out.is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn splits_on_nul_and_drops_the_trailing_empty_segment() {
assert_eq!(
split_nul_paths(b"src/main.rs\0Cargo.toml\0"),
vec!["src/main.rs", "Cargo.toml"]
);
}
#[test]
fn empty_input_is_no_paths() {
assert_eq!(split_nul_paths(b""), Vec::<String>::new());
}
#[test]
fn a_non_ascii_path_is_not_reinterpreted_as_its_quoted_form() {
let mut raw = "é.json".as_bytes().to_vec();
raw.push(0);
let got = split_nul_paths(&raw);
assert_eq!(got, vec!["é.json".to_string()]);
assert_ne!(got[0], "\"\\303\\251.json\"", "must not be the quoted form");
}
}
pub fn current_branch() -> Option<&'static str> {
static BRANCH: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
BRANCH
.get_or_init(|| stdout(&["symbolic-ref", "--quiet", "--short", "HEAD"]))
.as_deref()
}
pub fn has_remote() -> bool {
static REMOTE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*REMOTE.get_or_init(|| stdout(&["remote"]).is_some_and(|r| !r.is_empty()))
}