1use std::path::{Path, PathBuf};
5use std::process::{Command, Output};
6
7use thiserror::Error;
8
9#[derive(Debug, Error)]
10pub enum GitError {
11 #[error("'{path}' is not inside a git repository (--diff requires one)")]
12 NotARepository { path: String },
13
14 #[error("failed to run git: {source}")]
15 Spawn {
16 #[source]
17 source: std::io::Error,
18 },
19
20 #[error("unknown git ref '{base}': {stderr}")]
21 BadRef { base: String, stderr: String },
22
23 #[error("git {args} failed: {stderr}")]
24 Command { args: String, stderr: String },
25}
26
27const EMPTY_TREE: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
30
31const DIFF_FILTER: &str = "--diff-filter=ACMR";
34
35fn run_git(dir: &Path, args: &[&str]) -> Result<Output, GitError> {
36 Command::new("git")
37 .current_dir(dir)
38 .args(args)
39 .output()
40 .map_err(|source| GitError::Spawn { source })
41}
42
43fn stderr_of(output: &Output) -> String {
44 String::from_utf8_lossy(&output.stderr).trim().to_string()
45}
46
47pub fn repo_root(dir: &Path) -> Result<PathBuf, GitError> {
49 let output = run_git(dir, &["rev-parse", "--show-toplevel"])?;
50 if !output.status.success() {
51 return Err(GitError::NotARepository {
52 path: dir.display().to_string(),
53 });
54 }
55 let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
56 Ok(PathBuf::from(root))
57}
58
59pub fn changed_files(dir: &Path, base: Option<&str>) -> Result<Vec<PathBuf>, GitError> {
67 let root = repo_root(dir)?;
68
69 if let Some(b) = base {
70 let probe = run_git(
71 dir,
72 &[
73 "rev-parse",
74 "--verify",
75 "--quiet",
76 &format!("{b}^{{commit}}"),
77 ],
78 )?;
79 if !probe.status.success() {
80 let stderr = stderr_of(&probe);
81 return Err(GitError::BadRef {
82 base: b.to_string(),
83 stderr: if stderr.is_empty() {
84 "no such ref".to_string()
85 } else {
86 stderr
87 },
88 });
89 }
90 }
91
92 let rev = match base {
93 Some(b) => format!("{b}...HEAD"),
94 None => {
95 let head = run_git(dir, &["rev-parse", "--verify", "--quiet", "HEAD"])?;
96 if head.status.success() {
97 "HEAD".to_string()
98 } else {
99 EMPTY_TREE.to_string()
100 }
101 }
102 };
103
104 let output = run_git(dir, &["diff", "--name-only", "-z", DIFF_FILTER, &rev])?;
107 if !output.status.success() {
108 return Err(GitError::Command {
109 args: format!("diff --name-only {rev}"),
110 stderr: stderr_of(&output),
111 });
112 }
113
114 let stdout = String::from_utf8_lossy(&output.stdout);
115 let mut files: Vec<PathBuf> = stdout
116 .split('\0')
117 .filter(|name| !name.is_empty())
118 .map(|name| root.join(name))
120 .filter(|path| path.is_file())
121 .collect();
122 files.sort();
123 files.dedup();
124 Ok(files)
125}
126
127pub fn dirty_files(dir: &Path) -> Result<Vec<PathBuf>, GitError> {
134 let root = repo_root(dir)?;
135 let output = run_git(dir, &["status", "--porcelain", "-z"])?;
136 if !output.status.success() {
137 return Err(GitError::Command {
138 args: "status --porcelain".to_string(),
139 stderr: stderr_of(&output),
140 });
141 }
142
143 let stdout = String::from_utf8_lossy(&output.stdout);
146 let mut fields = stdout.split('\0').filter(|f| !f.is_empty()).peekable();
147 let mut files = Vec::new();
148 while let Some(entry) = fields.next() {
149 if entry.len() < 3 {
150 continue;
151 }
152 let status = &entry[..2];
153 let path = &entry[3..];
154 if status.starts_with('R') {
156 fields.next();
157 }
158 if status.contains('D') {
160 continue;
161 }
162 files.push(root.join(path));
163 }
164 files.sort();
165 files.dedup();
166 Ok(files)
167}
168
169#[cfg(test)]
170mod tests {
171 use std::fs::{create_dir_all, write};
172
173 use tempfile::tempdir;
174
175 use super::*;
176
177 fn git(dir: &Path, args: &[&str]) -> Output {
178 let output = Command::new("git")
179 .current_dir(dir)
180 .args(args)
181 .output()
182 .unwrap();
183 assert!(
184 output.status.success(),
185 "git {args:?} failed: {}",
186 String::from_utf8_lossy(&output.stderr)
187 );
188 output
189 }
190
191 fn init_repo(dir: &Path) {
192 git(dir, &["init", "-q", "-b", "main"]);
193 git(dir, &["config", "user.email", "test@example.com"]);
194 git(dir, &["config", "user.name", "Test"]);
195 git(dir, &["config", "commit.gpgsign", "false"]);
196 }
197
198 fn commit(dir: &Path, msg: &str) {
199 git(dir, &["add", "-A"]);
200 git(dir, &["commit", "-q", "-m", msg]);
201 }
202
203 fn names(files: &[PathBuf]) -> Vec<String> {
204 files
205 .iter()
206 .filter_map(|p| p.file_name().and_then(|n| n.to_str()))
207 .map(String::from)
208 .collect()
209 }
210
211 #[test]
212 fn not_a_git_repo_is_an_error() {
213 let dir = tempdir().unwrap();
214 match changed_files(dir.path(), None) {
215 Err(GitError::NotARepository { .. }) => {}
216 other => panic!("expected NotARepository, got {other:?}"),
217 }
218 }
219
220 #[test]
221 fn staged_and_unstaged_files_are_reported() {
222 let dir = tempdir().unwrap();
223 init_repo(dir.path());
224 write(dir.path().join("a.rs"), "fn a() {}\n").unwrap();
225 commit(dir.path(), "init");
226
227 write(dir.path().join("a.rs"), "fn a() { let x = 1; }\n").unwrap();
228 write(dir.path().join("b.rs"), "fn b() {}\n").unwrap();
229 git(dir.path(), &["add", "b.rs"]);
230
231 let files = changed_files(dir.path(), None).unwrap();
232 assert!(files.iter().all(|p| p.is_absolute()), "{files:?}");
233 let names = names(&files);
234 assert!(names.contains(&"a.rs".to_string()), "{names:?}");
235 assert!(names.contains(&"b.rs".to_string()), "{names:?}");
236 }
237
238 #[test]
239 fn deleted_file_is_excluded() {
240 let dir = tempdir().unwrap();
241 init_repo(dir.path());
242 write(dir.path().join("a.rs"), "fn a() {}\n").unwrap();
243 write(dir.path().join("b.rs"), "fn b() {}\n").unwrap();
244 commit(dir.path(), "init");
245
246 git(dir.path(), &["rm", "-q", "b.rs"]);
247
248 let files = changed_files(dir.path(), None).unwrap();
249 let names = names(&files);
250 assert!(!names.contains(&"b.rs".to_string()), "{names:?}");
251 }
252
253 #[test]
254 fn renamed_file_reported_under_new_name() {
255 let dir = tempdir().unwrap();
256 init_repo(dir.path());
257 write(dir.path().join("a.rs"), "fn a() {}\n").unwrap();
258 commit(dir.path(), "init");
259
260 git(dir.path(), &["mv", "a.rs", "b.rs"]);
261
262 let files = changed_files(dir.path(), None).unwrap();
263 let names = names(&files);
264 assert!(names.contains(&"b.rs".to_string()), "{names:?}");
265 assert!(!names.contains(&"a.rs".to_string()), "{names:?}");
266 }
267
268 #[test]
269 fn base_ref_uses_merge_base() {
270 let dir = tempdir().unwrap();
271 init_repo(dir.path());
272 write(dir.path().join("base.rs"), "fn base() {}\n").unwrap();
273 commit(dir.path(), "base");
274
275 git(dir.path(), &["checkout", "-q", "-b", "feature"]);
276 write(dir.path().join("feature.rs"), "fn feature() {}\n").unwrap();
277 commit(dir.path(), "feature");
278
279 let files = changed_files(dir.path(), Some("main")).unwrap();
280 assert_eq!(names(&files), vec!["feature.rs".to_string()]);
281 }
282
283 #[test]
284 fn unknown_base_ref_is_an_error() {
285 let dir = tempdir().unwrap();
286 init_repo(dir.path());
287 write(dir.path().join("a.rs"), "fn a() {}\n").unwrap();
288 commit(dir.path(), "init");
289
290 match changed_files(dir.path(), Some("no-such-ref")) {
291 Err(GitError::BadRef { base, .. }) => assert_eq!(base, "no-such-ref"),
292 other => panic!("expected BadRef, got {other:?}"),
293 }
294 }
295
296 #[test]
297 fn repository_without_commit_reports_staged_files() {
298 let dir = tempdir().unwrap();
299 init_repo(dir.path());
300 write(dir.path().join("a.rs"), "fn a() {}\n").unwrap();
301 git(dir.path(), &["add", "a.rs"]);
302
303 let files = changed_files(dir.path(), None).unwrap();
304 assert_eq!(names(&files), vec!["a.rs".to_string()]);
305 }
306
307 #[test]
308 fn dirty_files_reports_modified_and_untracked_not_clean() {
309 let dir = tempdir().unwrap();
310 init_repo(dir.path());
311 write(dir.path().join("committed.rs"), "fn a() {}\n").unwrap();
312 commit(dir.path(), "init");
313
314 write(dir.path().join("committed.rs"), "fn a() { let x = 1; }\n").unwrap();
316 write(dir.path().join("new.rs"), "fn b() {}\n").unwrap();
317
318 let dirty = dirty_files(dir.path()).unwrap();
319 let names = names(&dirty);
320 assert!(names.contains(&"committed.rs".to_string()), "{names:?}");
321 assert!(names.contains(&"new.rs".to_string()), "{names:?}");
322 assert!(dirty.iter().all(|p| p.is_absolute()), "{dirty:?}");
323 }
324
325 #[test]
326 fn dirty_files_empty_on_clean_tree() {
327 let dir = tempdir().unwrap();
328 init_repo(dir.path());
329 write(dir.path().join("a.rs"), "fn a() {}\n").unwrap();
330 commit(dir.path(), "init");
331
332 assert!(dirty_files(dir.path()).unwrap().is_empty());
333 }
334
335 #[test]
336 fn paths_are_absolute_when_run_from_a_subdirectory() {
337 let dir = tempdir().unwrap();
338 init_repo(dir.path());
339 let src = dir.path().join("src");
340 create_dir_all(&src).unwrap();
341 write(src.join("lib.rs"), "fn a() {}\n").unwrap();
342 commit(dir.path(), "init");
343
344 write(src.join("lib.rs"), "fn a() { let x = 1; }\n").unwrap();
345
346 let files = changed_files(&src, None).unwrap();
347 assert_eq!(files.len(), 1, "{files:?}");
348 assert!(files[0].is_absolute(), "{files:?}");
349 assert!(files[0].is_file(), "{files:?}");
350 }
351}