pub mod clone;
pub mod repo_url;
pub mod shadow;
use std::io::Read;
use std::path::Path;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
#[derive(Debug, Clone)]
pub struct GitOutput {
pub stdout: String,
pub stderr: String,
pub success: bool,
}
impl GitOutput {
pub fn ok_stdout(self) -> Result<String, String> {
if self.success {
Ok(self.stdout)
} else {
let msg = self.stderr.trim();
Err(if msg.is_empty() {
"git command failed".to_string()
} else {
msg.to_string()
})
}
}
}
pub fn git_available() -> bool {
Command::new("git")
.arg("--version")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.is_ok_and(|s| s.success())
}
pub fn run_git(
args: &[&str],
cwd: &Path,
timeout: Duration,
env: &[(&str, &str)],
) -> Result<GitOutput, String> {
let mut cmd = Command::new("git");
cmd.args(args)
.current_dir(cwd)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.env("GIT_TERMINAL_PROMPT", "0")
.env("GCM_INTERACTIVE", "never")
.env("GIT_ASKPASS", "")
.env("SSH_ASKPASS", "");
for (k, v) in env {
cmd.env(k, v);
}
let mut child = cmd
.spawn()
.map_err(|e| format!("failed to start git (is it installed?): {e}"))?;
let mut out_pipe = child.stdout.take();
let mut err_pipe = child.stderr.take();
let out_handle = std::thread::spawn(move || drain(out_pipe.as_mut()));
let err_handle = std::thread::spawn(move || drain(err_pipe.as_mut()));
let start = Instant::now();
let status = loop {
match child.try_wait() {
Ok(Some(status)) => break status,
Ok(None) => {
if start.elapsed() >= timeout {
let _ = child.kill();
let _ = child.wait();
return Err(format!("git timed out after {}s", timeout.as_secs()));
}
std::thread::sleep(Duration::from_millis(40));
}
Err(e) => return Err(format!("git wait failed: {e}")),
}
};
let stdout = out_handle.join().unwrap_or_default();
let stderr = err_handle.join().unwrap_or_default();
Ok(GitOutput {
stdout,
stderr,
success: status.success(),
})
}
fn drain(pipe: Option<&mut impl Read>) -> String {
let mut buf = String::new();
if let Some(p) = pipe {
let _ = p.read_to_string(&mut buf);
}
buf
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn git_version_runs() {
if !git_available() {
return; }
let out = run_git(&["--version"], Path::new("."), Duration::from_secs(5), &[])
.expect("git --version");
assert!(out.success);
assert!(out.stdout.to_lowercase().contains("git version"));
}
#[test]
fn failed_command_surfaces_stderr() {
if !git_available() {
return;
}
let out = run_git(
&["rev-parse", "--verify", "definitely-not-a-ref"],
Path::new("."),
Duration::from_secs(5),
&[],
)
.expect("git should run");
assert!(!out.success);
assert!(out.ok_stdout().is_err());
}
}