Skip to main content

wt/git/
cli.rs

1//! The `git` subprocess boundary (spec §4): state-mutating and network
2//! operations shell out to `git`. The [`GitCli`] trait isolates this so tests
3//! can inject a fake; [`RealGit`] is the production implementation.
4
5use std::path::Path;
6use std::process::Command;
7
8use crate::error::{Error, Result};
9
10/// The captured result of running a `git` subprocess.
11#[derive(Debug, Clone)]
12pub struct GitOutput {
13    /// Whether the process exited successfully (status `0`).
14    pub success: bool,
15    /// Captured standard output.
16    pub stdout: String,
17    /// Captured standard error.
18    pub stderr: String,
19}
20
21/// Runs `git` subcommands in a repository. Mutations and network operations go
22/// through this boundary (reads use `gix` where possible).
23pub trait GitCli {
24    /// Runs `git -C <repo> <args>`, capturing output. Returns the captured
25    /// [`GitOutput`] even on a non-zero exit; errors only if the process cannot
26    /// be spawned.
27    fn run_raw(&self, repo: &Path, args: &[&str]) -> Result<GitOutput>;
28
29    /// Runs `git`, returning stdout on success or an [`Error::Subprocess`] with
30    /// captured stderr on failure. Lock contention is annotated with a hint
31    /// (spec §12).
32    fn run(&self, repo: &Path, args: &[&str]) -> Result<String> {
33        let out = self.run_raw(repo, args)?;
34        if out.success {
35            return Ok(out.stdout);
36        }
37        let mut stderr = out.stderr.trim_end().to_string();
38        if stderr.contains(".lock") {
39            stderr.push_str("\n(hint: another git process holds the worktree lock)");
40        }
41        Err(Error::Subprocess {
42            program: "git".into(),
43            stderr,
44        })
45    }
46}
47
48/// The production [`GitCli`] that spawns the real `git` binary.
49#[derive(Debug, Clone, Copy, Default)]
50pub struct RealGit;
51
52impl GitCli for RealGit {
53    fn run_raw(&self, repo: &Path, args: &[&str]) -> Result<GitOutput> {
54        let output = Command::new("git")
55            .arg("-C")
56            .arg(repo)
57            .args(args)
58            .output()
59            .map_err(|e| Error::Subprocess {
60                program: "git".into(),
61                stderr: format!("failed to run git: {e}"),
62            })?;
63        Ok(GitOutput {
64            success: output.status.success(),
65            stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
66            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
67        })
68    }
69}
70
71#[cfg(test)]
72mod tests {
73    use super::*;
74
75    /// A `GitCli` returning canned output, for testing the `run` wrapper.
76    struct Canned(GitOutput);
77    impl GitCli for Canned {
78        fn run_raw(&self, _repo: &Path, _args: &[&str]) -> Result<GitOutput> {
79            Ok(self.0.clone())
80        }
81    }
82
83    #[test]
84    fn run_returns_stdout_on_success() {
85        let git = Canned(GitOutput {
86            success: true,
87            stdout: "ok\n".into(),
88            stderr: String::new(),
89        });
90        assert_eq!(git.run(Path::new("/r"), &["status"]).unwrap(), "ok\n");
91    }
92
93    #[test]
94    fn run_surfaces_stderr_on_failure() {
95        let git = Canned(GitOutput {
96            success: false,
97            stdout: String::new(),
98            stderr: "fatal: nope\n".into(),
99        });
100        let err = git.run(Path::new("/r"), &["x"]).unwrap_err();
101        match err {
102            Error::Subprocess { program, stderr } => {
103                assert_eq!(program, "git");
104                assert_eq!(stderr, "fatal: nope");
105            }
106            other => panic!("expected subprocess error, got {other:?}"),
107        }
108    }
109
110    #[test]
111    fn run_annotates_lock_contention() {
112        let git = Canned(GitOutput {
113            success: false,
114            stdout: String::new(),
115            stderr: "fatal: could not lock .git/worktrees/x/HEAD.lock".into(),
116        });
117        let err = git.run(Path::new("/r"), &["worktree", "add"]).unwrap_err();
118        assert!(
119            err.to_string()
120                .contains("another git process holds the worktree lock")
121        );
122    }
123
124    #[test]
125    fn real_git_runs_version() {
126        // Smoke test that the real binary is reachable in the test environment.
127        let out = RealGit.run_raw(Path::new("."), &["--version"]).unwrap();
128        assert!(out.success);
129        assert!(out.stdout.contains("git version"));
130    }
131}