Skip to main content

slopguard_core/
git.rs

1//! Git integration for diff-aware scanning: resolve the files changed against
2//! a ref so `scan --diff` only looks at touched code.
3
4use 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
27/// The well-known SHA of git's empty tree, used to diff a repository that has
28/// no commit yet.
29const EMPTY_TREE: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
30
31/// Added, Copied, Modified, Renamed. Deletions (D) are excluded: there is no
32/// file left to scan. Renames are reported under their new name.
33const 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
47/// Absolute path of the repository root containing `dir`.
48pub 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
59/// Files added, modified, copied or renamed relative to `base` (or to `HEAD`
60/// when `base` is `None`, covering both staged and unstaged changes).
61///
62/// Paths are returned absolute, sorted and deduplicated. Deleted files are
63/// excluded; renamed files appear under their new name. Untracked files are
64/// not included: they are neither staged nor tracked, so `git diff` does not
65/// see them.
66pub 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    // `-z` keeps paths NUL-separated, so non-ASCII names and spaces survive
105    // whatever `core.quotepath` is set to.
106    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        // git prints paths relative to the repository root, not to `dir`.
119        .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/// Absolute paths of files with uncommitted changes (staged, unstaged or
128/// untracked) anywhere in the repository containing `dir`.
129///
130/// Used by `scan --fix` to refuse rewriting a dirty tree unless `--allow-dirty`.
131/// Renames are reported under their new name; deletions are excluded since there
132/// is no file left to rewrite.
133pub 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    // `-z` output: each entry is `XY <path>\0`, and a rename adds a second
144    // `<old>\0` field. Two status columns plus a space precede the path.
145    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        // A rename ("R") carries the original path as the next field; skip it.
155        if status.starts_with('R') {
156            fields.next();
157        }
158        // Deletions leave nothing to rewrite.
159        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        // one modified tracked file, one brand-new untracked file
315        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}