Skip to main content

ctx_tui/
git.rs

1use std::path::Path;
2use std::process::{Command, Stdio};
3use std::sync::atomic::{AtomicBool, Ordering};
4use std::sync::{Arc, LazyLock, Mutex};
5
6// A stalled transfer otherwise hangs forever: ssh sends no keepalives by
7// default, so a dead connection is never noticed, and git accepts an
8// arbitrarily slow one. Both are capped to about a minute of silence.
9const 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
18// Fallback for callers that pass no cwd: the process's own cwd may have been
19// deleted under it (e.g. removing the context it sits in), which breaks git.
20// All call sites use absolute paths, so any directory that always exists does.
21const SAFE_CWD: &str = "/";
22
23/// A failed git call whose message is git's own error when it was captured.
24#[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
45/// A Command for any program, with the test environment applied under tests.
46pub(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    // A prompt would block on a terminal the caller may not be showing.
68    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
102// In-flight git process groups, so cancellation can end a transfer and its
103// ssh child instead of orphaning them (the asyncio version killed the group
104// on task cancellation; here quit and SIGINT do the equivalent).
105static INFLIGHT: Mutex<Vec<i32>> = Mutex::new(Vec::new());
106static INTERRUPTED: LazyLock<Arc<AtomicBool>> = LazyLock::new(|| Arc::new(AtomicBool::new(false)));
107
108/// Route SIGINT through the interrupted flag: in-flight git calls kill their
109/// transfers and fail, so cleanup paths run instead of the process dying
110/// mid-write with the transfer orphaned.
111pub fn install_interrupt_handler() {
112    let _ = signal_hook::flag::register(signal_hook::consts::SIGINT, INTERRUPTED.clone());
113}
114
115/// Kill every in-flight git call's process group (quit with a transfer running).
116///
117/// Only the calls running right now are killed; the interrupt flag stays
118/// untouched so the process can keep using git — the TUI kills a stray
119/// transfer on exit and then creates the context the user asked for.
120pub 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    // Own process group, so cancellation can kill git and its ssh child
153    // together, and a terminal SIGINT reaches this process alone.
154    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
191/// Run git, letting stderr (progress, errors) stream to the terminal.
192///
193/// Every call gets the timeouts, not just the ones that reach a remote: they
194/// are inert for local work, and marking each remote call by hand is a thing
195/// to get wrong.
196pub fn git(args: &[&str], cwd: Option<&Path>) -> Result<String, GitError> {
197    run_command(command(args, cwd), args, false)
198}
199
200/// Like `git`, but quiet: stderr is captured into the error rather than
201/// streamed, for callers that are UIs which must not be written over.
202pub 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        // Unmocked: proves the -c options sit where git accepts them.
247        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        // Quiet here: streaming would print git's error over the test output.
262        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        // Sitting in a deleted directory (e.g. a removed context) must not
287        // break git. The library never depends on the process cwd (every
288        // call passes one, or defaults to /), so a doomed cwd is simulated
289        // per call rather than by moving the whole test process there.
290        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}