1use std::path::Path;
6use std::process::Command;
7
8use crate::error::{Error, Result};
9
10#[derive(Debug, Clone)]
12pub struct GitOutput {
13 pub success: bool,
15 pub stdout: String,
17 pub stderr: String,
19}
20
21pub trait GitCli {
24 fn run_raw(&self, repo: &Path, args: &[&str]) -> Result<GitOutput>;
28
29 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#[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 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 let out = RealGit.run_raw(Path::new("."), &["--version"]).unwrap();
128 assert!(out.success);
129 assert!(out.stdout.contains("git version"));
130 }
131}