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#[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}