Skip to main content

speccoder_runtime/
git.rs

1//! A thin wrapper over the git CLI — the user's git, with the user's
2//! configuration, for anything that writes.
3//!
4//! Every call is argv-only, never prompts, takes no optional locks, and runs
5//! with `LC_ALL=C` so output is parseable. Nothing here contacts a remote.
6
7use std::ffi::OsStr;
8use std::io::Write;
9use std::path::{Path, PathBuf};
10use std::process::{Command, Stdio};
11
12/// A failed git invocation.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct GitError(pub String);
15
16impl std::fmt::Display for GitError {
17    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
18        f.write_str(&self.0)
19    }
20}
21
22/// git, run in one directory.
23#[derive(Debug, Clone)]
24pub struct Git {
25    dir: PathBuf,
26}
27
28/// A git invocation's result.
29struct Output {
30    ok: bool,
31    code: Option<i32>,
32    stdout: Vec<u8>,
33    stderr: String,
34}
35
36impl Git {
37    /// git with `dir` as working directory.
38    #[must_use]
39    pub fn at(dir: &Path) -> Self {
40        Self {
41            dir: dir.to_path_buf(),
42        }
43    }
44
45    /// The working directory.
46    #[must_use]
47    pub fn dir(&self) -> &Path {
48        &self.dir
49    }
50
51    fn exec(
52        &self,
53        args: &[&str],
54        env: &[(&str, &OsStr)],
55        stdin: Option<&[u8]>,
56    ) -> Result<Output, GitError> {
57        let mut cmd = Command::new("git");
58        // A repository's own configuration never runs code through
59        // speccoder's git: no fsmonitor, no hooks.
60        cmd.args([
61            "-c",
62            "core.fsmonitor=false",
63            "-c",
64            "core.hooksPath=/dev/null",
65        ])
66        .args(args)
67        .current_dir(&self.dir)
68        .env("GIT_TERMINAL_PROMPT", "0")
69        .env("GIT_OPTIONAL_LOCKS", "0")
70        .env("LC_ALL", "C")
71        .stdin(if stdin.is_some() {
72            Stdio::piped()
73        } else {
74            Stdio::null()
75        })
76        .stdout(Stdio::piped())
77        .stderr(Stdio::piped());
78        for (k, v) in env {
79            cmd.env(k, v);
80        }
81        let mut child = cmd
82            .spawn()
83            .map_err(|e| GitError(format!("cannot run git: {e}")))?;
84        if let (Some(input), Some(mut pipe)) = (stdin, child.stdin.take()) {
85            pipe.write_all(input)
86                .map_err(|e| GitError(format!("git stdin: {e}")))?;
87        }
88        let out = child
89            .wait_with_output()
90            .map_err(|e| GitError(format!("git: {e}")))?;
91        Ok(Output {
92            ok: out.status.success(),
93            code: out.status.code(),
94            stdout: out.stdout,
95            stderr: String::from_utf8_lossy(&out.stderr).trim().to_owned(),
96        })
97    }
98
99    fn checked(
100        &self,
101        args: &[&str],
102        env: &[(&str, &OsStr)],
103        stdin: Option<&[u8]>,
104    ) -> Result<Vec<u8>, GitError> {
105        let out = self.exec(args, env, stdin)?;
106        if out.ok {
107            Ok(out.stdout)
108        } else {
109            Err(GitError(format!("git {}: {}", args.join(" "), out.stderr)))
110        }
111    }
112
113    /// Runs `git <args>`, returning stdout.
114    pub fn run(&self, args: &[&str]) -> Result<Vec<u8>, GitError> {
115        self.checked(args, &[], None)
116    }
117
118    /// Runs `git <args>` with `env` set as well.
119    pub fn run_with_env(&self, args: &[&str], env: &[(&str, &OsStr)]) -> Result<Vec<u8>, GitError> {
120        self.checked(args, env, None)
121    }
122
123    /// Runs `git <args>` feeding `input` on stdin.
124    pub fn run_with_stdin(&self, args: &[&str], input: &[u8]) -> Result<Vec<u8>, GitError> {
125        self.checked(args, &[], Some(input))
126    }
127
128    /// Runs `git <args>` with a separate index file, optionally feeding stdin.
129    pub fn run_with_index(
130        &self,
131        index: &Path,
132        args: &[&str],
133        stdin: Option<&[u8]>,
134    ) -> Result<Vec<u8>, GitError> {
135        self.checked(args, &[("GIT_INDEX_FILE", index.as_os_str())], stdin)
136    }
137
138    /// Runs `git <args>` and returns stdout as trimmed UTF-8.
139    pub fn line(&self, args: &[&str]) -> Result<String, GitError> {
140        let out = self.run(args)?;
141        Ok(String::from_utf8_lossy(&out).trim().to_owned())
142    }
143
144    /// Whether `git <args>` succeeds (exit 0); errors only if git can't run.
145    pub fn succeeds(&self, args: &[&str]) -> Result<bool, GitError> {
146        Ok(self.exec(args, &[], None)?.ok)
147    }
148
149    /// The working tree root containing `dir`, or `None` outside a repository.
150    pub fn toplevel(&self) -> Result<Option<PathBuf>, GitError> {
151        let out = self.exec(&["rev-parse", "--show-toplevel"], &[], None)?;
152        if !out.ok {
153            return Ok(None);
154        }
155        let path = String::from_utf8_lossy(&out.stdout).trim().to_owned();
156        Ok(Some(PathBuf::from(path)))
157    }
158
159    /// The commit HEAD points at, or `None` when HEAD is unborn.
160    pub fn head(&self) -> Result<Option<String>, GitError> {
161        let out = self.exec(&["rev-parse", "--verify", "-q", "HEAD^{commit}"], &[], None)?;
162        match (out.ok, out.code) {
163            (true, _) => Ok(Some(String::from_utf8_lossy(&out.stdout).trim().to_owned())),
164            (false, Some(1)) => Ok(None),
165            (false, _) => Err(GitError(format!("git rev-parse HEAD: {}", out.stderr))),
166        }
167    }
168
169    /// An absolute path git uses inside the repository (`--git-path`).
170    pub fn git_path(&self, name: &str) -> Result<PathBuf, GitError> {
171        let p = PathBuf::from(self.line(&["rev-parse", "--git-path", name])?);
172        Ok(if p.is_absolute() { p } else { self.dir.join(p) })
173    }
174}
175
176/// Splits NUL-terminated git output into UTF-8 (lossy) fields.
177#[must_use]
178pub fn nul_fields(out: &[u8]) -> Vec<String> {
179    out.split(|b| *b == 0)
180        .filter(|f| !f.is_empty())
181        .map(|f| String::from_utf8_lossy(f).into_owned())
182        .collect()
183}