1use std::path::{Path, PathBuf};
22
23use processkit::ProcessRunner;
24pub use processkit::{Error, ProcessResult, Result};
28
29mod parse;
30pub use parse::{Branch, Commit, StatusEntry};
31
32pub const BINARY: &str = "git";
34
35#[cfg_attr(feature = "mock", mockall::automock)]
38#[async_trait::async_trait]
39pub trait GitApi: Send + Sync {
40 async fn run(&self, args: &[String]) -> Result<String>;
43 async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>>;
46 async fn version(&self) -> Result<String>;
48 async fn status(&self, dir: &Path) -> Result<Vec<StatusEntry>>;
50 async fn current_branch(&self, dir: &Path) -> Result<String>;
52 async fn branches(&self, dir: &Path) -> Result<Vec<Branch>>;
54 async fn log(&self, dir: &Path, max: usize) -> Result<Vec<Commit>>;
56 async fn rev_parse(&self, dir: &Path, rev: &str) -> Result<String>;
58 async fn init(&self, dir: &Path) -> Result<()>;
60 async fn add(&self, dir: &Path, paths: &[PathBuf]) -> Result<()>;
62 async fn commit(&self, dir: &Path, message: &str) -> Result<()>;
64 async fn create_branch(&self, dir: &Path, name: &str) -> Result<()>;
66 async fn checkout(&self, dir: &Path, reference: &str) -> Result<()>;
68 async fn diff_is_empty(&self, dir: &Path) -> Result<bool>;
70}
71
72processkit::cli_client!(
73 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 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 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 #[tokio::test]
204 async fn status_parses_scripted_output() {
205 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 #[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 #[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 #[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 #[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}