1pub mod clone;
14pub mod repo_url;
15pub mod shadow;
16
17use std::io::Read;
18use std::path::Path;
19use std::process::{Command, Stdio};
20use std::time::{Duration, Instant};
21
22#[derive(Debug, Clone)]
24pub struct GitOutput {
25 pub stdout: String,
26 pub stderr: String,
27 pub success: bool,
28}
29
30impl GitOutput {
31 pub fn ok_stdout(self) -> Result<String, String> {
33 if self.success {
34 Ok(self.stdout)
35 } else {
36 let msg = self.stderr.trim();
37 Err(if msg.is_empty() {
38 "git command failed".to_string()
39 } else {
40 msg.to_string()
41 })
42 }
43 }
44}
45
46pub fn git_available() -> bool {
48 Command::new("git")
49 .arg("--version")
50 .stdin(Stdio::null())
51 .stdout(Stdio::null())
52 .stderr(Stdio::null())
53 .status()
54 .is_ok_and(|s| s.success())
55}
56
57pub fn run_git(
64 args: &[&str],
65 cwd: &Path,
66 timeout: Duration,
67 env: &[(&str, &str)],
68) -> Result<GitOutput, String> {
69 let mut cmd = Command::new("git");
70 cmd.args(args)
71 .current_dir(cwd)
72 .stdin(Stdio::null())
73 .stdout(Stdio::piped())
74 .stderr(Stdio::piped())
75 .env("GIT_TERMINAL_PROMPT", "0")
77 .env("GCM_INTERACTIVE", "never")
78 .env("GIT_ASKPASS", "")
79 .env("SSH_ASKPASS", "");
80 for (k, v) in env {
81 cmd.env(k, v);
82 }
83
84 let mut child = cmd
85 .spawn()
86 .map_err(|e| format!("failed to start git (is it installed?): {e}"))?;
87
88 let mut out_pipe = child.stdout.take();
89 let mut err_pipe = child.stderr.take();
90 let out_handle = std::thread::spawn(move || drain(out_pipe.as_mut()));
91 let err_handle = std::thread::spawn(move || drain(err_pipe.as_mut()));
92
93 let start = Instant::now();
94 let status = loop {
95 match child.try_wait() {
96 Ok(Some(status)) => break status,
97 Ok(None) => {
98 if start.elapsed() >= timeout {
99 let _ = child.kill();
100 let _ = child.wait();
101 return Err(format!("git timed out after {}s", timeout.as_secs()));
102 }
103 std::thread::sleep(Duration::from_millis(40));
104 }
105 Err(e) => return Err(format!("git wait failed: {e}")),
106 }
107 };
108
109 let stdout = out_handle.join().unwrap_or_default();
110 let stderr = err_handle.join().unwrap_or_default();
111 Ok(GitOutput {
112 stdout,
113 stderr,
114 success: status.success(),
115 })
116}
117
118fn drain(pipe: Option<&mut impl Read>) -> String {
119 let mut buf = String::new();
120 if let Some(p) = pipe {
121 let _ = p.read_to_string(&mut buf);
122 }
123 buf
124}
125
126#[cfg(test)]
127mod tests {
128 use super::*;
129
130 #[test]
131 fn git_version_runs() {
132 if !git_available() {
133 return; }
135 let out = run_git(&["--version"], Path::new("."), Duration::from_secs(5), &[])
136 .expect("git --version");
137 assert!(out.success);
138 assert!(out.stdout.to_lowercase().contains("git version"));
139 }
140
141 #[test]
142 fn failed_command_surfaces_stderr() {
143 if !git_available() {
144 return;
145 }
146 let out = run_git(
147 &["rev-parse", "--verify", "definitely-not-a-ref"],
148 Path::new("."),
149 Duration::from_secs(5),
150 &[],
151 )
152 .expect("git should run");
153 assert!(!out.success);
154 assert!(out.ok_stdout().is_err());
155 }
156}