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
127#[cfg(test)]
128mod tests {
129 use std::fs::{create_dir_all, write};
130
131 use tempfile::tempdir;
132
133 use super::*;
134
135 fn git(dir: &Path, args: &[&str]) -> Output {
136 let output = Command::new("git")
137 .current_dir(dir)
138 .args(args)
139 .output()
140 .unwrap();
141 assert!(
142 output.status.success(),
143 "git {args:?} failed: {}",
144 String::from_utf8_lossy(&output.stderr)
145 );
146 output
147 }
148
149 fn init_repo(dir: &Path) {
150 git(dir, &["init", "-q", "-b", "main"]);
151 git(dir, &["config", "user.email", "test@example.com"]);
152 git(dir, &["config", "user.name", "Test"]);
153 git(dir, &["config", "commit.gpgsign", "false"]);
154 }
155
156 fn commit(dir: &Path, msg: &str) {
157 git(dir, &["add", "-A"]);
158 git(dir, &["commit", "-q", "-m", msg]);
159 }
160
161 fn names(files: &[PathBuf]) -> Vec<String> {
162 files
163 .iter()
164 .filter_map(|p| p.file_name().and_then(|n| n.to_str()))
165 .map(String::from)
166 .collect()
167 }
168
169 #[test]
170 fn not_a_git_repo_is_an_error() {
171 let dir = tempdir().unwrap();
172 match changed_files(dir.path(), None) {
173 Err(GitError::NotARepository { .. }) => {}
174 other => panic!("expected NotARepository, got {other:?}"),
175 }
176 }
177
178 #[test]
179 fn staged_and_unstaged_files_are_reported() {
180 let dir = tempdir().unwrap();
181 init_repo(dir.path());
182 write(dir.path().join("a.rs"), "fn a() {}\n").unwrap();
183 commit(dir.path(), "init");
184
185 write(dir.path().join("a.rs"), "fn a() { let x = 1; }\n").unwrap();
186 write(dir.path().join("b.rs"), "fn b() {}\n").unwrap();
187 git(dir.path(), &["add", "b.rs"]);
188
189 let files = changed_files(dir.path(), None).unwrap();
190 assert!(files.iter().all(|p| p.is_absolute()), "{files:?}");
191 let names = names(&files);
192 assert!(names.contains(&"a.rs".to_string()), "{names:?}");
193 assert!(names.contains(&"b.rs".to_string()), "{names:?}");
194 }
195
196 #[test]
197 fn deleted_file_is_excluded() {
198 let dir = tempdir().unwrap();
199 init_repo(dir.path());
200 write(dir.path().join("a.rs"), "fn a() {}\n").unwrap();
201 write(dir.path().join("b.rs"), "fn b() {}\n").unwrap();
202 commit(dir.path(), "init");
203
204 git(dir.path(), &["rm", "-q", "b.rs"]);
205
206 let files = changed_files(dir.path(), None).unwrap();
207 let names = names(&files);
208 assert!(!names.contains(&"b.rs".to_string()), "{names:?}");
209 }
210
211 #[test]
212 fn renamed_file_reported_under_new_name() {
213 let dir = tempdir().unwrap();
214 init_repo(dir.path());
215 write(dir.path().join("a.rs"), "fn a() {}\n").unwrap();
216 commit(dir.path(), "init");
217
218 git(dir.path(), &["mv", "a.rs", "b.rs"]);
219
220 let files = changed_files(dir.path(), None).unwrap();
221 let names = names(&files);
222 assert!(names.contains(&"b.rs".to_string()), "{names:?}");
223 assert!(!names.contains(&"a.rs".to_string()), "{names:?}");
224 }
225
226 #[test]
227 fn base_ref_uses_merge_base() {
228 let dir = tempdir().unwrap();
229 init_repo(dir.path());
230 write(dir.path().join("base.rs"), "fn base() {}\n").unwrap();
231 commit(dir.path(), "base");
232
233 git(dir.path(), &["checkout", "-q", "-b", "feature"]);
234 write(dir.path().join("feature.rs"), "fn feature() {}\n").unwrap();
235 commit(dir.path(), "feature");
236
237 let files = changed_files(dir.path(), Some("main")).unwrap();
238 assert_eq!(names(&files), vec!["feature.rs".to_string()]);
239 }
240
241 #[test]
242 fn unknown_base_ref_is_an_error() {
243 let dir = tempdir().unwrap();
244 init_repo(dir.path());
245 write(dir.path().join("a.rs"), "fn a() {}\n").unwrap();
246 commit(dir.path(), "init");
247
248 match changed_files(dir.path(), Some("no-such-ref")) {
249 Err(GitError::BadRef { base, .. }) => assert_eq!(base, "no-such-ref"),
250 other => panic!("expected BadRef, got {other:?}"),
251 }
252 }
253
254 #[test]
255 fn repository_without_commit_reports_staged_files() {
256 let dir = tempdir().unwrap();
257 init_repo(dir.path());
258 write(dir.path().join("a.rs"), "fn a() {}\n").unwrap();
259 git(dir.path(), &["add", "a.rs"]);
260
261 let files = changed_files(dir.path(), None).unwrap();
262 assert_eq!(names(&files), vec!["a.rs".to_string()]);
263 }
264
265 #[test]
266 fn paths_are_absolute_when_run_from_a_subdirectory() {
267 let dir = tempdir().unwrap();
268 init_repo(dir.path());
269 let src = dir.path().join("src");
270 create_dir_all(&src).unwrap();
271 write(src.join("lib.rs"), "fn a() {}\n").unwrap();
272 commit(dir.path(), "init");
273
274 write(src.join("lib.rs"), "fn a() { let x = 1; }\n").unwrap();
275
276 let files = changed_files(&src, None).unwrap();
277 assert_eq!(files.len(), 1, "{files:?}");
278 assert!(files[0].is_absolute(), "{files:?}");
279 assert!(files[0].is_file(), "{files:?}");
280 }
281}