Skip to main content

lean_ctx/core/git/
mod.rs

1//! Native git support: remote-repo reading (cached shallow clone) and a shadow
2//! history of agent changes.
3//!
4//! * [`repo_url`] parses repository URLs into a [`repo_url::RepoRef`].
5//! * [`clone`] maintains a bounded, SSRF-guarded local clone cache.
6//! * [`shadow`] records agent edits in a git history kept *outside* the user's
7//!   own `.git`.
8//!
9//! All git invocations go through [`run_git`], which never uses a shell, always
10//! disables interactive credential prompts (so a private/auth-required remote
11//! fails fast instead of hanging), and enforces a wall-clock timeout.
12
13pub 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/// Result of a single `git` invocation.
23#[derive(Debug, Clone)]
24pub struct GitOutput {
25    pub stdout: String,
26    pub stderr: String,
27    pub success: bool,
28}
29
30impl GitOutput {
31    /// Stdout if the command succeeded, else an error carrying trimmed stderr.
32    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
46/// `true` if a `git` binary is callable.
47pub 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
57/// Run `git <args>` in `cwd` with extra env vars and a wall-clock `timeout`.
58///
59/// No shell is involved (args are passed directly). Interactive credential
60/// prompts are disabled so an auth-required remote errors immediately rather
61/// than blocking. stdout/stderr are drained on dedicated threads to avoid
62/// pipe-buffer deadlock on chatty commands (e.g. `clone` progress).
63pub 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        // Fail fast instead of prompting for credentials / hanging.
76        .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; // CI without git — nothing to assert
134        }
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}