1use std::path::Path;
2use std::process::{Command, Stdio};
3use std::sync::atomic::{AtomicBool, Ordering};
4use std::sync::{Arc, LazyLock, Mutex};
5
6const SSH_COMMAND: &str =
10 "ssh -o ConnectTimeout=10 -o ServerAliveInterval=15 -o ServerAliveCountMax=3";
11const STALL_CONFIG: &[&str] = &[
12 "-c",
13 "http.lowSpeedLimit=1000",
14 "-c",
15 "http.lowSpeedTime=60",
16];
17
18const SAFE_CWD: &str = "/";
22
23#[derive(Debug)]
25pub struct GitError {
26 pub argv: Vec<String>,
27 pub code: Option<i32>,
28 pub stdout: String,
29 pub stderr: Option<String>,
30}
31
32impl std::error::Error for GitError {}
33
34impl std::fmt::Display for GitError {
35 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36 let detail = self.stderr.as_deref().unwrap_or("").trim();
37 if detail.is_empty() {
38 write!(f, "command failed ({})", self.argv.join(" "))
39 } else {
40 write!(f, "{detail}")
41 }
42 }
43}
44
45pub(crate) fn new_command(program: &str) -> Command {
47 #[allow(unused_mut)]
48 let mut cmd = Command::new(program);
49 #[cfg(test)]
50 crate::testutil::apply_test_env(&mut cmd);
51 cmd
52}
53
54fn command_with(
55 args: &[&str],
56 cwd: Option<&Path>,
57 ssh_configured: bool,
58 prompt_configured: bool,
59) -> Command {
60 let mut cmd = new_command("git");
61 cmd.args(STALL_CONFIG)
62 .args(args)
63 .current_dir(cwd.unwrap_or(Path::new(SAFE_CWD)));
64 if !ssh_configured {
65 cmd.env("GIT_SSH_COMMAND", SSH_COMMAND);
66 }
67 if !prompt_configured {
69 cmd.env("GIT_TERMINAL_PROMPT", "0");
70 }
71 cmd
72}
73
74fn command(args: &[&str], cwd: Option<&Path>) -> Command {
75 command_with(
76 args,
77 cwd,
78 std::env::var_os("GIT_SSH_COMMAND").is_some(),
79 std::env::var_os("GIT_TERMINAL_PROMPT").is_some(),
80 )
81}
82
83fn argv(args: &[&str]) -> Vec<String> {
84 ["git"]
85 .iter()
86 .copied()
87 .chain(STALL_CONFIG.iter().copied())
88 .chain(args.iter().copied())
89 .map(str::to_string)
90 .collect()
91}
92
93fn spawn_error(args: &[&str], err: std::io::Error) -> GitError {
94 GitError {
95 argv: argv(args),
96 code: None,
97 stdout: String::new(),
98 stderr: Some(err.to_string()),
99 }
100}
101
102static INFLIGHT: Mutex<Vec<i32>> = Mutex::new(Vec::new());
106static INTERRUPTED: LazyLock<Arc<AtomicBool>> = LazyLock::new(|| Arc::new(AtomicBool::new(false)));
107
108pub fn install_interrupt_handler() {
112 let _ = signal_hook::flag::register(signal_hook::consts::SIGINT, INTERRUPTED.clone());
113}
114
115pub fn kill_inflight() {
121 for pgid in INFLIGHT.lock().expect("registry lock").iter() {
122 unsafe {
123 libc::killpg(*pgid, libc::SIGKILL);
124 }
125 }
126}
127
128fn drain(stream: Option<impl std::io::Read + Send + 'static>) -> impl FnOnce() -> String {
129 let handle = stream.map(|mut stream| {
130 std::thread::spawn(move || {
131 let mut buf = Vec::new();
132 let _ = stream.read_to_end(&mut buf);
133 buf
134 })
135 });
136 move || {
137 handle
138 .and_then(|handle| handle.join().ok())
139 .map(|buf| String::from_utf8_lossy(&buf).into_owned())
140 .unwrap_or_default()
141 }
142}
143
144fn run_command(mut cmd: Command, args: &[&str], quiet: bool) -> Result<String, GitError> {
145 use std::os::unix::process::CommandExt;
146 use wait_timeout::ChildExt;
147
148 cmd.stdout(Stdio::piped());
149 if quiet {
150 cmd.stderr(Stdio::piped());
151 }
152 cmd.process_group(0);
155 let mut child = cmd.spawn().map_err(|err| spawn_error(args, err))?;
156 let pgid = child.id() as i32;
157 INFLIGHT.lock().expect("registry lock").push(pgid);
158 let stdout = drain(child.stdout.take());
159 let stderr = drain(child.stderr.take());
160 let mut killed = false;
161 let status = loop {
162 if INTERRUPTED.load(Ordering::SeqCst) && !killed {
163 unsafe {
164 libc::killpg(pgid, libc::SIGKILL);
165 }
166 killed = true;
167 }
168 match child.wait_timeout(std::time::Duration::from_millis(50)) {
169 Ok(Some(status)) => break Ok(status),
170 Ok(None) => continue,
171 Err(err) => break Err(err),
172 }
173 };
174 INFLIGHT
175 .lock()
176 .expect("registry lock")
177 .retain(|p| *p != pgid);
178 let status = status.map_err(|err| spawn_error(args, err))?;
179 let stdout = stdout().trim().to_string();
180 if !status.success() {
181 return Err(GitError {
182 argv: argv(args),
183 code: status.code(),
184 stdout,
185 stderr: quiet.then(stderr),
186 });
187 }
188 Ok(stdout)
189}
190
191pub fn git(args: &[&str], cwd: Option<&Path>) -> Result<String, GitError> {
197 run_command(command(args, cwd), args, false)
198}
199
200pub fn git_quiet(args: &[&str], cwd: Option<&Path>) -> Result<String, GitError> {
203 run_command(command(args, cwd), args, true)
204}
205
206#[cfg(test)]
207mod tests {
208 use std::ffi::OsStr;
209
210 use super::*;
211 use crate::testutil::{git as fixture_git, test_env};
212
213 fn env_of(cmd: &Command, key: &str) -> Option<String> {
214 cmd.get_envs()
215 .find(|(k, _)| *k == OsStr::new(key))
216 .and_then(|(_, v)| v.map(|v| v.to_string_lossy().into_owned()))
217 }
218
219 #[test]
220 fn calls_cap_stalled_transfers() {
221 let cmd = command_with(&["fetch", "origin"], None, false, false);
222
223 let args: Vec<_> = cmd.get_args().collect();
224 assert_eq!(
225 args[..2],
226 [OsStr::new("-c"), OsStr::new("http.lowSpeedLimit=1000")]
227 );
228 assert!(
229 env_of(&cmd, "GIT_SSH_COMMAND")
230 .unwrap()
231 .contains("ServerAliveInterval")
232 );
233 assert_eq!(env_of(&cmd, "GIT_TERMINAL_PROMPT").unwrap(), "0");
234 }
235
236 #[test]
237 fn a_configured_ssh_command_wins() {
238 let cmd = command_with(&["fetch", "origin"], None, true, true);
239
240 assert_eq!(env_of(&cmd, "GIT_SSH_COMMAND"), None);
241 assert_eq!(env_of(&cmd, "GIT_TERMINAL_PROMPT"), None);
242 }
243
244 #[test]
245 fn the_stall_config_reaches_git() {
246 let env = test_env();
248 let origin = env.origin();
249
250 assert_eq!(
251 git(&["config", "--get", "http.lowSpeedLimit"], Some(&origin)).unwrap(),
252 "1000"
253 );
254 }
255
256 #[test]
257 fn git_still_reports_failure() {
258 let env = test_env();
259 let origin = env.origin();
260
261 assert!(git_quiet(&["rev-parse", "--verify", "no-such-ref"], Some(&origin)).is_err());
263 }
264
265 #[test]
266 fn git_quiet_returns_output_and_reports_failure() {
267 let env = test_env();
268 let origin = env.origin();
269
270 assert_eq!(
271 git_quiet(&["rev-parse", "--abbrev-ref", "HEAD"], Some(&origin)).unwrap(),
272 "main"
273 );
274
275 let err = git_quiet(&["rev-parse", "--verify", "no-such-ref"], Some(&origin))
276 .expect_err("bad ref must fail");
277 assert!(err.stderr.as_deref().unwrap().contains("fatal"));
278 assert!(
279 err.to_string().contains("fatal"),
280 "the message must carry git's stderr"
281 );
282 }
283
284 #[test]
285 fn calls_survive_a_deleted_working_directory() {
286 let env = test_env();
291 let origin = env.origin();
292
293 let clone = env.root().join("clone");
294 git_quiet(
295 &["clone", &origin.to_string_lossy(), &clone.to_string_lossy()],
296 None,
297 )
298 .unwrap();
299
300 assert_eq!(
301 git_quiet(&["rev-parse", "--abbrev-ref", "HEAD"], Some(&clone)).unwrap(),
302 "main"
303 );
304 }
305
306 #[test]
307 fn fixture_git_commits_with_the_isolated_identity() {
308 let env = test_env();
309 let origin = env.origin();
310
311 assert_eq!(fixture_git(&["log", "-1", "--format=%an"], &origin), "Test");
312 }
313}