Skip to main content

vcs_git/
lib.rs

1//! `vcs-git` — automate Git from Rust through CLI process execution.
2//!
3//! Async, mockable, and structured-error: consumers depend on the [`GitApi`]
4//! trait and substitute a mock for the real [`Git`] client in tests. Commands
5//! run inside an OS job (via [`processkit`]) so a `git` subprocess is never
6//! orphaned, and honour an optional [timeout](Git::default_timeout).
7//!
8//! ```no_run
9//! use vcs_git::{Git, GitApi};
10//! use std::path::Path;
11//!
12//! # async fn run(git: &dyn GitApi) -> Result<(), processkit::Error> {
13//! let branch = git.current_branch(Path::new(".")).await?;
14//! # let _ = branch; Ok(()) }
15//! ```
16//!
17//! Two test seams: enable the `mock` feature for a `mockall`-generated
18//! `MockGitApi`, or inject a fake runner with
19//! `Git::with_runner(`[`ScriptedRunner`](processkit::ScriptedRunner)`)`.
20
21use std::path::{Path, PathBuf};
22
23use processkit::ProcessRunner;
24// Re-export the processkit types that appear in this crate's public API, so
25// consumers needn't depend on processkit directly. (`Error`/`Result`/`ProcessResult`
26// are in scope here too via this `pub use`.)
27pub use processkit::{Error, ProcessResult, Result};
28
29mod parse;
30pub use parse::{Branch, Commit, StatusEntry};
31
32/// Name of the underlying CLI binary this crate drives.
33pub const BINARY: &str = "git";
34
35/// The Git operations this crate exposes — the interface consumers code against
36/// and mock in tests.
37#[cfg_attr(feature = "mock", mockall::automock)]
38#[async_trait::async_trait]
39pub trait GitApi: Send + Sync {
40    /// Run `git <args>` in the current directory, returning trimmed stdout
41    /// (throws on a non-zero exit). A raw escape hatch for unmodelled commands.
42    async fn run(&self, args: &[String]) -> Result<String>;
43    /// Like [`GitApi::run`] but never errors on a non-zero exit — returns the
44    /// captured [`ProcessResult`].
45    async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>>;
46    /// Installed Git version (`git --version`).
47    async fn version(&self) -> Result<String>;
48    /// Working-tree status (`git status --porcelain=v1 -z`).
49    async fn status(&self, dir: &Path) -> Result<Vec<StatusEntry>>;
50    /// Current branch name (`git rev-parse --abbrev-ref HEAD`).
51    async fn current_branch(&self, dir: &Path) -> Result<String>;
52    /// Local branches, current one flagged (`git branch`).
53    async fn branches(&self, dir: &Path) -> Result<Vec<Branch>>;
54    /// Latest `max` commits, newest first (`git log`).
55    async fn log(&self, dir: &Path, max: usize) -> Result<Vec<Commit>>;
56    /// Resolve a revision to a full hash (`git rev-parse <rev>`).
57    async fn rev_parse(&self, dir: &Path, rev: &str) -> Result<String>;
58    /// Initialise a repository (`git init`).
59    async fn init(&self, dir: &Path) -> Result<()>;
60    /// Stage `paths` (`git add -- <paths>`).
61    async fn add(&self, dir: &Path, paths: &[PathBuf]) -> Result<()>;
62    /// Commit staged changes (`git commit -m`).
63    async fn commit(&self, dir: &Path, message: &str) -> Result<()>;
64    /// Create a branch without switching to it (`git branch <name>`).
65    async fn create_branch(&self, dir: &Path, name: &str) -> Result<()>;
66    /// Switch to a branch or revision (`git checkout <reference>`).
67    async fn checkout(&self, dir: &Path, reference: &str) -> Result<()>;
68    /// Whether the working tree has no unstaged changes (`git diff --quiet`).
69    async fn diff_is_empty(&self, dir: &Path) -> Result<bool>;
70}
71
72processkit::cli_client!(
73    /// The real Git client. Generic over the [`ProcessRunner`] so tests can inject
74    /// a fake process executor; `Git::new()` uses the real job-backed runner.
75    pub struct Git => BINARY
76);
77
78#[async_trait::async_trait]
79impl<R: ProcessRunner> GitApi for Git<R> {
80    async fn run(&self, args: &[String]) -> Result<String> {
81        self.core.text(self.core.command(args)).await
82    }
83
84    async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>> {
85        self.core.capture(self.core.command(args)).await
86    }
87
88    async fn version(&self) -> Result<String> {
89        self.core.text(self.core.command(["--version"])).await
90    }
91
92    async fn status(&self, dir: &Path) -> Result<Vec<StatusEntry>> {
93        self.core
94            .parse(
95                self.core
96                    .command_in(dir, ["status", "--porcelain=v1", "-z"]),
97                parse::parse_porcelain,
98            )
99            .await
100    }
101
102    async fn current_branch(&self, dir: &Path) -> Result<String> {
103        self.core
104            .text(
105                self.core
106                    .command_in(dir, ["rev-parse", "--abbrev-ref", "HEAD"]),
107            )
108            .await
109    }
110
111    async fn branches(&self, dir: &Path) -> Result<Vec<Branch>> {
112        self.core
113            .parse(self.core.command_in(dir, ["branch"]), parse::parse_branches)
114            .await
115    }
116
117    async fn log(&self, dir: &Path, max: usize) -> Result<Vec<Commit>> {
118        let n = format!("-n{max}");
119        self.core
120            .parse(
121                self.core.command_in(
122                    dir,
123                    [
124                        "log",
125                        n.as_str(),
126                        "-z",
127                        "--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s",
128                    ],
129                ),
130                parse::parse_log,
131            )
132            .await
133    }
134
135    async fn rev_parse(&self, dir: &Path, rev: &str) -> Result<String> {
136        self.core
137            .text(self.core.command_in(dir, ["rev-parse", rev]))
138            .await
139    }
140
141    async fn init(&self, dir: &Path) -> Result<()> {
142        self.core.unit(self.core.command_in(dir, ["init"])).await
143    }
144
145    async fn add(&self, dir: &Path, paths: &[PathBuf]) -> Result<()> {
146        // `--` separates the pathspecs so a path can never be read as an option.
147        let mut command = self.core.command_in(dir, ["add", "--"]);
148        for path in paths {
149            command = command.arg(path);
150        }
151        self.core.unit(command).await
152    }
153
154    async fn commit(&self, dir: &Path, message: &str) -> Result<()> {
155        self.core
156            .unit(self.core.command_in(dir, ["commit", "-m", message]))
157            .await
158    }
159
160    async fn create_branch(&self, dir: &Path, name: &str) -> Result<()> {
161        self.core
162            .unit(self.core.command_in(dir, ["branch", name]))
163            .await
164    }
165
166    async fn checkout(&self, dir: &Path, reference: &str) -> Result<()> {
167        self.core
168            .unit(self.core.command_in(dir, ["checkout", reference]))
169            .await
170    }
171
172    async fn diff_is_empty(&self, dir: &Path) -> Result<bool> {
173        // `git diff --quiet` is an exit-code answer: 0 = clean, 1 = dirty.
174        // `code` still surfaces spawn/timeout/signal failures for us.
175        match self
176            .core
177            .code(self.core.command_in(dir, ["diff", "--quiet"]))
178            .await?
179        {
180            0 => Ok(true),
181            1 => Ok(false),
182            other => Err(Error::Exit {
183                program: BINARY.to_string(),
184                code: other,
185                stderr: String::new(),
186            }),
187        }
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194    use processkit::{Reply, ScriptedRunner};
195
196    #[test]
197    fn binary_name_is_git() {
198        assert_eq!(BINARY, "git");
199    }
200
201    // Hermetic: the real status() command-building + porcelain parsing run
202    // against a scripted runner — no `git` binary needed, so this runs on CI.
203    #[tokio::test]
204    async fn status_parses_scripted_output() {
205        // `-z` output: NUL-delimited records, raw paths.
206        let git =
207            Git::with_runner(ScriptedRunner::new().on(["status"], Reply::ok(" M a.rs\0?? b.rs\0")));
208        let entries = git.status(Path::new(".")).await.expect("status");
209        assert_eq!(entries.len(), 2);
210        assert_eq!(entries[0].code, " M");
211        assert_eq!(entries[1].path, "b.rs");
212    }
213
214    // A non-zero exit surfaces as a structured `Error::Exit`.
215    #[tokio::test]
216    async fn nonzero_exit_is_structured_error() {
217        let git = Git::with_runner(
218            ScriptedRunner::new().on(["status"], Reply::fail(128, "not a git repository")),
219        );
220        match git.status(Path::new(".")).await.unwrap_err() {
221            Error::Exit { code, stderr, .. } => {
222                assert_eq!(code, 128);
223                assert!(stderr.contains("not a git repository"), "{stderr}");
224            }
225            other => panic!("expected Exit, got {other:?}"),
226        }
227    }
228
229    // diff_is_empty maps the raw exit code itself: 0 → clean, 1 → dirty, and
230    // anything else is a real failure surfaced as Error::Exit.
231    #[tokio::test]
232    async fn diff_is_empty_maps_exit_codes() {
233        let clean = Git::with_runner(ScriptedRunner::new().on(["diff", "--quiet"], Reply::ok("")));
234        assert!(clean.diff_is_empty(Path::new(".")).await.unwrap());
235
236        let dirty =
237            Git::with_runner(ScriptedRunner::new().on(["diff", "--quiet"], Reply::fail(1, "")));
238        assert!(!dirty.diff_is_empty(Path::new(".")).await.unwrap());
239
240        let broken = Git::with_runner(
241            ScriptedRunner::new().on(["diff", "--quiet"], Reply::fail(128, "fatal: not a repo")),
242        );
243        assert!(matches!(
244            broken.diff_is_empty(Path::new(".")).await.unwrap_err(),
245            Error::Exit { code: 128, .. }
246        ));
247    }
248
249    // `add` must insert `--` before the pathspecs so a path can never be parsed
250    // as an option. No fallback rule: the run only matches if `add --` was built.
251    #[tokio::test]
252    async fn add_inserts_pathspec_separator() {
253        let git = Git::with_runner(ScriptedRunner::new().on(["add", "--"], Reply::ok("")));
254        git.add(Path::new("."), &[PathBuf::from("f.rs")])
255            .await
256            .expect("add should build `add -- <paths>`");
257    }
258
259    // The consumer-facing mock seam: a function depending on `&dyn GitApi` is
260    // tested with a generated mock.
261    #[cfg(feature = "mock")]
262    #[tokio::test]
263    async fn consumer_mocks_the_interface() {
264        async fn on_branch(git: &dyn GitApi, want: &str) -> bool {
265            git.current_branch(Path::new(".")).await.unwrap() == want
266        }
267        let mut mock = MockGitApi::new();
268        mock.expect_current_branch()
269            .returning(|_| Ok("main".to_string()));
270        assert!(on_branch(&mock, "main").await);
271    }
272}