1use std::io;
2use std::path::Path;
3use std::process::Command;
4
5use thiserror::Error;
6
7#[derive(Debug, Error)]
8pub enum GitError {
9 #[error("`git` is not installed or not on PATH")]
10 NotInstalled,
11
12 #[error("git {operation} failed (exit {code:?})")]
13 Failed {
14 operation: &'static str,
15 code: Option<i32>,
16 },
17
18 #[error(transparent)]
19 Io(io::Error),
20}
21
22pub fn clone(url: &str, dest: &Path) -> Result<(), GitError> {
23 let status = Command::new("git")
24 .arg("clone")
25 .arg("--quiet")
26 .arg(url)
27 .arg(dest)
28 .status()
29 .map_err(spawn_error)?;
30
31 if !status.success() {
32 return Err(GitError::Failed {
33 operation: "clone",
34 code: status.code(),
35 });
36 }
37 Ok(())
38}
39
40pub fn pull(repo_dir: &Path) -> Result<(), GitError> {
41 let status = Command::new("git")
42 .arg("pull")
43 .arg("--ff-only")
44 .arg("--quiet")
45 .current_dir(repo_dir)
46 .status()
47 .map_err(spawn_error)?;
48
49 if !status.success() {
50 return Err(GitError::Failed {
51 operation: "pull",
52 code: status.code(),
53 });
54 }
55 Ok(())
56}
57
58pub fn fetch(repo_dir: &Path) -> Result<(), GitError> {
59 let status = Command::new("git")
60 .arg("fetch")
61 .arg("--quiet")
62 .current_dir(repo_dir)
63 .status()
64 .map_err(spawn_error)?;
65 if !status.success() {
66 return Err(GitError::Failed {
67 operation: "fetch",
68 code: status.code(),
69 });
70 }
71 Ok(())
72}
73
74pub fn head_commit(repo_dir: &Path) -> Result<String, GitError> {
75 let output = Command::new("git")
76 .arg("rev-parse")
77 .arg("--short")
78 .arg("HEAD")
79 .current_dir(repo_dir)
80 .output()
81 .map_err(spawn_error)?;
82
83 if !output.status.success() {
84 return Err(GitError::Failed {
85 operation: "rev-parse HEAD",
86 code: output.status.code(),
87 });
88 }
89
90 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
91}
92
93pub fn upstream_commit(repo_dir: &Path) -> Result<String, GitError> {
94 let output = Command::new("git")
95 .arg("rev-parse")
96 .arg("--short")
97 .arg("@{u}")
98 .current_dir(repo_dir)
99 .output()
100 .map_err(spawn_error)?;
101 if !output.status.success() {
102 return Err(GitError::Failed {
103 operation: "rev-parse @{u}",
104 code: output.status.code(),
105 });
106 }
107 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
108}
109
110pub fn log_between(repo_dir: &Path, from: &str, to: &str) -> Result<Vec<String>, GitError> {
111 let output = Command::new("git")
112 .arg("log")
113 .arg(format!("{from}..{to}"))
114 .arg("--pretty=format:%s")
115 .arg("--no-decorate")
116 .current_dir(repo_dir)
117 .output()
118 .map_err(spawn_error)?;
119
120 if !output.status.success() {
121 return Err(GitError::Failed {
122 operation: "log",
123 code: output.status.code(),
124 });
125 }
126
127 let stdout = String::from_utf8_lossy(&output.stdout);
128 Ok(stdout.lines().map(str::to_string).collect())
129}
130
131pub fn diffstat(repo_dir: &Path, from: &str, to: &str) -> Result<(usize, usize, usize), GitError> {
132 let output = Command::new("git")
133 .arg("diff")
134 .arg("--numstat")
135 .arg(format!("{from}..{to}"))
136 .current_dir(repo_dir)
137 .output()
138 .map_err(spawn_error)?;
139
140 if !output.status.success() {
141 return Err(GitError::Failed {
142 operation: "diff --numstat",
143 code: output.status.code(),
144 });
145 }
146
147 let stdout = String::from_utf8_lossy(&output.stdout);
148 let mut files = 0usize;
149 let mut insertions = 0usize;
150 let mut deletions = 0usize;
151 for line in stdout.lines() {
152 let mut parts = line.split('\t');
153 let adds = parts.next().unwrap_or("");
154 let dels = parts.next().unwrap_or("");
155 if let Ok(n) = adds.parse::<usize>() {
157 insertions += n;
158 }
159 if let Ok(n) = dels.parse::<usize>() {
160 deletions += n;
161 }
162 files += 1;
163 }
164
165 Ok((files, insertions, deletions))
166}
167
168fn spawn_error(e: io::Error) -> GitError {
169 if e.kind() == io::ErrorKind::NotFound {
170 GitError::NotInstalled
171 } else {
172 GitError::Io(e)
173 }
174}
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179
180 fn init_repo() -> tempfile::TempDir {
181 let dir = tempfile::tempdir().unwrap();
182 let p = dir.path();
183
184 let run = |args: &[&str]| {
185 let status = Command::new("git")
186 .args(args)
187 .current_dir(p)
188 .status()
189 .unwrap();
190 assert!(status.success(), "git {args:?} failed");
191 };
192
193 run(&["init", "--quiet", "--initial-branch=main"]);
194 run(&["config", "user.email", "test@example.com"]);
195 run(&["config", "user.name", "test"]);
196 run(&["config", "commit.gpgsign", "false"]);
197 std::fs::write(p.join("README"), "hello").unwrap();
198 run(&["add", "."]);
199 run(&["commit", "--quiet", "-m", "initial"]);
200
201 dir
202 }
203
204 #[test]
205 fn head_commit_returns_short_hash() {
206 let repo = init_repo();
207 let commit = head_commit(repo.path()).unwrap();
208 assert!(
209 commit.len() >= 7 && commit.len() <= 40,
210 "expected short hash, got {commit:?}"
211 );
212 assert!(
213 commit.chars().all(|c| c.is_ascii_hexdigit()),
214 "expected hex, got {commit:?}"
215 );
216 }
217
218 #[test]
219 fn head_commit_fails_outside_a_repo() {
220 let dir = tempfile::tempdir().unwrap();
221 let err = head_commit(dir.path()).unwrap_err();
222 assert!(matches!(
223 err,
224 GitError::Failed {
225 operation: "rev-parse HEAD",
226 ..
227 }
228 ));
229 }
230
231 #[test]
232 fn upstream_commit_fails_without_upstream() {
233 let repo = init_repo();
234 let err = upstream_commit(repo.path()).unwrap_err();
235 assert!(matches!(
236 err,
237 GitError::Failed {
238 operation: "rev-parse @{u}",
239 ..
240 }
241 ));
242 }
243
244 fn git_in(p: &Path, args: &[&str]) {
245 let status = Command::new("git")
246 .args(args)
247 .current_dir(p)
248 .status()
249 .unwrap();
250 assert!(status.success(), "git {args:?} failed");
251 }
252
253 fn commit_file(p: &Path, name: &str, contents: &str, message: &str) -> String {
254 std::fs::write(p.join(name), contents).unwrap();
255 git_in(p, &["add", "."]);
256 git_in(p, &["commit", "--quiet", "-m", message]);
257 head_commit(p).unwrap()
258 }
259
260 #[test]
261 fn log_between_is_empty_when_from_equals_to() {
262 let repo = init_repo();
263 let head = head_commit(repo.path()).unwrap();
264 let commits = log_between(repo.path(), &head, &head).unwrap();
265 assert!(commits.is_empty());
266 }
267
268 #[test]
269 fn log_between_returns_subjects_in_reverse_chronological_order() {
270 let repo = init_repo();
271 let initial = head_commit(repo.path()).unwrap();
272 let _ = commit_file(repo.path(), "a.txt", "a\n", "second");
273 let third = commit_file(repo.path(), "b.txt", "b\n", "third");
274
275 let subjects = log_between(repo.path(), &initial, &third).unwrap();
276 assert_eq!(subjects, vec!["third".to_string(), "second".to_string()]);
277 }
278
279 #[test]
280 fn diffstat_is_all_zeros_when_from_equals_to() {
281 let repo = init_repo();
282 let head = head_commit(repo.path()).unwrap();
283 let (files, ins, dels) = diffstat(repo.path(), &head, &head).unwrap();
284 assert_eq!((files, ins, dels), (0, 0, 0));
285 }
286
287 #[test]
288 fn diffstat_counts_added_files_and_lines() {
289 let repo = init_repo();
290 let initial = head_commit(repo.path()).unwrap();
291 let head = commit_file(repo.path(), "new.txt", "line1\nline2\nline3\n", "add new");
292
293 let (files, ins, dels) = diffstat(repo.path(), &initial, &head).unwrap();
294 assert_eq!(files, 1);
295 assert_eq!(ins, 3);
296 assert_eq!(dels, 0);
297 }
298}