Skip to main content

wsx_core/git/
mod.rs

1pub mod info;
2pub mod worktree;
3
4use std::path::Path;
5use std::process::Command;
6
7/// Base git command scoped to `repo` via `-C`.
8/// Stdin null + env vars prevent any interactive prompt from opening /dev/tty:
9///   GIT_TERMINAL_PROMPT=0  — disables git's own credential prompts
10///   GIT_SSH_COMMAND        — BatchMode=yes + ConnectTimeout=5 so SSH fails fast
11pub fn git_cmd(repo: &Path) -> Command {
12    let mut cmd = Command::new("git");
13    cmd.arg("-C")
14        .arg(repo)
15        .stdin(std::process::Stdio::null())
16        .env("GIT_TERMINAL_PROMPT", "0")
17        .env(
18            "GIT_SSH_COMMAND",
19            "ssh -o BatchMode=yes -o ConnectTimeout=5",
20        );
21    cmd
22}
23
24/// Spawn `cmd` in its own process group with piped stdout/stderr.
25/// Kills the entire group on timeout so ssh + credential helpers are also reaped.
26/// Joins reader threads after kill to prevent thread leaks.
27pub fn output_with_timeout(
28    cmd: &mut Command,
29    timeout: std::time::Duration,
30) -> std::io::Result<std::process::Output> {
31    output_with_timeout_inner(cmd, timeout, None)
32}
33
34/// Timeout-bounded subprocess output with a per-stream byte limit.
35pub fn output_with_timeout_limit(
36    cmd: &mut Command,
37    timeout: std::time::Duration,
38    max_stream_bytes: usize,
39) -> std::io::Result<std::process::Output> {
40    output_with_timeout_inner(cmd, timeout, Some(max_stream_bytes))
41}
42
43fn output_with_timeout_inner(
44    cmd: &mut Command,
45    timeout: std::time::Duration,
46    max_stream_bytes: Option<usize>,
47) -> std::io::Result<std::process::Output> {
48    use std::os::unix::process::CommandExt;
49    use std::process::Stdio;
50
51    // ! own process group so killpg doesn't hit the parent
52    cmd.process_group(0);
53    let mut child = cmd.stdout(Stdio::piped()).stderr(Stdio::piped()).spawn()?;
54    let child_pgid = child.id() as libc::pid_t;
55
56    let stdout = child.stdout.take();
57    let stderr = child.stderr.take();
58
59    let stdout_thread = std::thread::spawn(move || read_bounded(stdout, max_stream_bytes));
60    let stderr_thread = std::thread::spawn(move || read_bounded(stderr, max_stream_bytes));
61
62    let start = std::time::Instant::now();
63    loop {
64        match child.try_wait() {
65            Ok(Some(status)) => {
66                let stdout = join_reader(stdout_thread);
67                let stderr = join_reader(stderr_thread);
68                return Ok(std::process::Output {
69                    status,
70                    stdout: stdout?,
71                    stderr: stderr?,
72                });
73            }
74            Ok(None) => {
75                if start.elapsed() >= timeout {
76                    if let Ok(Some(status)) = child.try_wait() {
77                        let stdout = join_reader(stdout_thread);
78                        let stderr = join_reader(stderr_thread);
79                        return Ok(std::process::Output {
80                            status,
81                            stdout: stdout?,
82                            stderr: stderr?,
83                        });
84                    }
85                    // Kill the entire process group — git + ssh + credential helpers
86                    unsafe { libc::killpg(child_pgid, libc::SIGKILL) };
87                    let _ = child.wait();
88                    // Pipes are now closed; readers unblock and finish
89                    let _ = stdout_thread.join();
90                    let _ = stderr_thread.join();
91                    return Err(std::io::Error::new(
92                        std::io::ErrorKind::TimedOut,
93                        "git command timed out",
94                    ));
95                }
96                std::thread::sleep(std::time::Duration::from_millis(5));
97            }
98            Err(e) => return Err(e),
99        }
100    }
101}
102
103fn read_bounded<R: std::io::Read>(
104    reader: Option<R>,
105    max_bytes: Option<usize>,
106) -> std::io::Result<Vec<u8>> {
107    use std::io::Read;
108
109    let Some(reader) = reader else {
110        return Ok(Vec::new());
111    };
112    let mut bytes = Vec::new();
113    match max_bytes {
114        Some(limit) => {
115            reader
116                .take(limit.saturating_add(1) as u64)
117                .read_to_end(&mut bytes)?;
118            if bytes.len() > limit {
119                return Err(std::io::Error::new(
120                    std::io::ErrorKind::InvalidData,
121                    "subprocess output exceeded byte limit",
122                ));
123            }
124        }
125        None => {
126            let mut reader = reader;
127            reader.read_to_end(&mut bytes)?;
128        }
129    }
130    Ok(bytes)
131}
132
133fn join_reader(
134    thread: std::thread::JoinHandle<std::io::Result<Vec<u8>>>,
135) -> std::io::Result<Vec<u8>> {
136    thread
137        .join()
138        .map_err(|_| std::io::Error::other("subprocess output reader panicked"))?
139}
140
141#[cfg(test)]
142mod tests {
143    use super::read_bounded;
144    use std::io::Cursor;
145
146    #[test]
147    fn bounded_reader_rejects_oversized_output() {
148        let error = read_bounded(Some(Cursor::new(b"four")), Some(3)).unwrap_err();
149        assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
150    }
151}