if_changed/engine/
git.rs

1use std::{
2    borrow::{BorrowMut, Cow},
3    path::{Path, PathBuf, MAIN_SEPARATOR_STR},
4    str::FromStr as _,
5};
6
7use bstr::ByteSlice;
8use genawaiter::{rc::gen, yield_};
9
10use super::Engine;
11
12const IF_CHANGED_IGNORE_TRAILER: &[u8] = b"ignore-if-changed";
13
14pub struct GitEngine<'repo> {
15    ignore_pathspec: Option<git2::Pathspec>,
16    repository: &'repo git2::Repository,
17    from_tree: Option<git2::Tree<'repo>>,
18    to_tree: Option<git2::Tree<'repo>>,
19}
20
21impl<'repo> GitEngine<'repo> {
22    #[allow(clippy::new_ret_no_self)]
23    pub fn new(
24        repository: &'repo git2::Repository,
25        from_ref: Option<&str>,
26        to_ref: Option<&str>,
27    ) -> impl Engine + 'repo {
28        let ignore_pathspec = ignore_pathspec(to_ref, repository);
29
30        let (from_tree, to_tree) = match (from_ref, to_ref) {
31            (None, None) => (
32                repository
33                    .head()
34                    .ok()
35                    .map(|head| head.peel_to_tree().unwrap()),
36                None,
37            ),
38            (None, Some(to_ref)) => {
39                let to_commit = repository
40                    .revparse_single(to_ref)
41                    .expect("to_ref is not a valid revision")
42                    .peel_to_commit()
43                    .expect("to_ref does not point to a commit");
44                (
45                    to_commit
46                        .parents()
47                        .next()
48                        .map(|commit| commit.tree().unwrap()),
49                    Some(to_commit.tree().unwrap()),
50                )
51            }
52            (Some(from_ref), to_ref) => (
53                Some(
54                    repository
55                        .revparse_single(from_ref)
56                        .expect("to_ref is not a valid revision")
57                        .peel_to_tree()
58                        .expect("to_ref does not point to a tree"),
59                ),
60                to_ref.map(|to_ref| {
61                    repository
62                        .revparse_single(to_ref)
63                        .expect("to_ref is not a valid revision")
64                        .peel_to_tree()
65                        .expect("to_ref does not point to a tree")
66                }),
67            ),
68        };
69
70        Self {
71            ignore_pathspec,
72            repository,
73            from_tree,
74            to_tree,
75        }
76    }
77
78    /// Get the diff of a file, if any.
79    fn diff(&self, mut options: impl BorrowMut<git2::DiffOptions>) -> git2::Diff {
80        match &self.to_tree {
81            Some(to_tree) => self.repository.diff_tree_to_tree(
82                self.from_tree.as_ref(),
83                Some(to_tree),
84                Some(options.borrow_mut()),
85            ),
86            None => self.repository.diff_tree_to_workdir_with_index(
87                self.from_tree.as_ref(),
88                Some(options.borrow_mut().include_untracked(true)),
89            ),
90        }
91        .unwrap()
92    }
93
94    /// Get the patch of a file, if any.
95    fn patch(&self, path: &Path) -> Option<git2::Patch> {
96        git2::Patch::from_diff(
97            &self.diff(
98                git2::DiffOptions::new()
99                    .pathspec(path)
100                    .disable_pathspec_match(true),
101            ),
102            0,
103        )
104        .ok()
105        .flatten()
106    }
107}
108
109impl Engine for GitEngine<'_> {
110    fn matches(
111        &self,
112        patterns: impl IntoIterator<Item = impl AsRef<Path>>,
113    ) -> impl Iterator<Item = Result<PathBuf, PathBuf>> {
114        let mut patterns = patterns
115            .into_iter()
116            .map(|pattern| {
117                let pattern = pattern.as_ref();
118                pattern
119                    .strip_prefix(MAIN_SEPARATOR_STR)
120                    .unwrap_or(pattern)
121                    .to_owned()
122            })
123            .collect::<Vec<_>>();
124
125        // Need to reverse the pathspecs to match in `.gitignore` order.
126        patterns.reverse();
127
128        let diff = self.diff(git2::DiffOptions::new());
129        gen!({
130            if patterns.is_empty() {
131                for delta in diff.deltas() {
132                    yield_!(Ok(delta.new_file().path().unwrap().to_owned()))
133                }
134                return;
135            }
136
137            let pathspec = git2::Pathspec::new(patterns).unwrap();
138            let matches = pathspec
139                .match_diff(&diff, git2::PathspecFlags::FIND_FAILURES)
140                .expect("bare repos are not supported");
141            for delta in matches.diff_entries() {
142                yield_!(Ok(delta.new_file().path().unwrap().to_owned()))
143            }
144            for entry in matches.failed_entries() {
145                yield_!(Err(PathBuf::from_str(&entry.to_str_lossy()).unwrap()))
146            }
147        })
148        .into_iter()
149    }
150
151    fn resolve(&self, path: impl AsRef<Path>) -> PathBuf {
152        self.repository
153            .workdir()
154            .expect("bare repos are not supported")
155            .canonicalize()
156            .unwrap()
157            .join(path.as_ref())
158    }
159
160    fn is_ignored(&self, path: impl AsRef<Path>) -> bool {
161        let Some(pathspec) = &self.ignore_pathspec else {
162            return false;
163        };
164        pathspec.matches_path(path.as_ref(), git2::PathspecFlags::DEFAULT)
165    }
166
167    fn is_range_modified(&self, path: impl AsRef<Path>, range: (usize, usize)) -> bool {
168        let Some(patch) = self.patch(path.as_ref()) else {
169            return false;
170        };
171        // Special case for untracked files. They are always considered modified.
172        if patch.delta().status() == git2::Delta::Untracked {
173            return true;
174        }
175        for (hunk_index, hunk) in (0..patch.num_hunks()).map(|i| (i, patch.hunk(i).unwrap().0)) {
176            if usize::try_from(hunk.new_start()).unwrap() > range.1 {
177                break;
178            }
179            if usize::try_from(hunk.new_start() + hunk.new_lines()).unwrap() < range.0 {
180                continue;
181            }
182            for line in (0..patch.num_lines_in_hunk(hunk_index).unwrap())
183                .map(|i| patch.line_in_hunk(hunk_index, i).unwrap())
184            {
185                match line.origin() {
186                    '+' if {
187                        let line_no = usize::try_from(line.new_lineno().unwrap()).unwrap();
188                        line_no >= range.0 && line_no <= range.1
189                    } =>
190                    {
191                        return true;
192                    }
193                    '-' if {
194                        let line_no = usize::try_from(line.old_lineno().unwrap()).unwrap();
195                        line_no >= range.0 && line_no <= range.1
196                    } =>
197                    {
198                        return true;
199                    }
200                    _ => {
201                        continue;
202                    }
203                }
204            }
205        }
206        false
207    }
208}
209
210fn ignore_pathspec(to_ref: Option<&str>, repository: &git2::Repository) -> Option<git2::Pathspec> {
211    let to_ref = to_ref?;
212
213    let commit = repository
214        .revparse_single(to_ref)
215        .ok()?
216        .peel_to_commit()
217        .ok()?;
218    let trailers = git2::message_trailers_bytes(commit.message_bytes()).ok()?;
219    let patterns = trailers
220        .iter()
221        .filter(|(name, _)| name.to_ascii_lowercase() == IF_CHANGED_IGNORE_TRAILER)
222        .flat_map(|(_, value)| split_patterns(value))
223        .map(|pattern| PathBuf::from_str(&pattern).unwrap())
224        .collect::<Vec<_>>();
225    if patterns.is_empty() {
226        None
227    } else {
228        Some(git2::Pathspec::new(patterns.iter().rev()).expect("Ignore-if-changed is invalid."))
229    }
230}
231
232fn split_patterns(value: &[u8]) -> impl Iterator<Item = Cow<str>> {
233    value
234        .split_once_str(b"--")
235        .unwrap_or((value, b""))
236        .0
237        .split_str(b",")
238        .map(|s| s.trim().to_str_lossy())
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244    use crate::testing::git_test;
245
246    macro_rules! extract_pathspec_test {
247        ($name:ident, $val:expr, @$exp:literal) => {
248            #[test]
249            fn $name() {
250                insta::assert_compact_json_snapshot!(split_patterns($val)
251                    .collect::<Vec<_>>(), @$exp);
252            }
253        };
254    }
255
256    extract_pathspec_test!(test_basic_pathspec, b"a", @r###"["a"]"###);
257    extract_pathspec_test!(test_multiple_pathspec, b"a/b, b/c", @r###"["a/b", "b/c"]"###);
258    extract_pathspec_test!(
259        test_multiple_pathspec_with_comment,
260        b"a/b, b/c -- Hello world!", @r###"["a/b", "b/c"]"###
261    );
262    extract_pathspec_test!(test_multiple_pathspec_with_empty_comment, b"a/b, b/c --", @r###"["a/b", "b/c"]"###);
263
264    #[test]
265    fn test_git() {
266        let (tempdir, repo) = git_test! {
267            "initial commit": ["a" => "a", "b" => "b"]
268        };
269
270        let engine = GitEngine::new(&repo, None, None);
271        assert_eq!(engine.resolve(""), tempdir.path().canonicalize().unwrap());
272
273        insta::assert_compact_json_snapshot!(engine.matches(["";0]).collect::<Vec<_>>(), @"[]");
274        insta::assert_compact_json_snapshot!(engine.matches(&["a"]).collect::<Vec<_>>(), @r###"[{"Err": "a"}]"###);
275        assert!(!engine.is_ignored(Path::new("a")));
276    }
277
278    #[test]
279    fn test_git_without_head() {
280        let (tempdir, repo) = git_test! {
281            staged: ["a" => "a", "b" => "b"]
282        };
283
284        let engine = GitEngine::new(&repo, None, None);
285        assert_eq!(engine.resolve(""), tempdir.path().canonicalize().unwrap());
286
287        insta::assert_compact_json_snapshot!(engine.matches(["";0]).collect::<Vec<_>>(), @r###"[{"Ok": "a"}, {"Ok": "b"}]"###);
288        insta::assert_compact_json_snapshot!(engine.matches(&["a"]).collect::<Vec<_>>(), @r###"[{"Ok": "a"}]"###);
289        assert!(!engine.is_ignored(Path::new("a")));
290    }
291
292    #[test]
293    fn test_matches() {
294        let (tempdir, repo) = git_test! {
295            staged: ["a" => "a", "c/a" => "a", "c/b" => "b", "d/b" => "b"]
296        };
297
298        let engine = GitEngine::new(&repo, None, None);
299        assert_eq!(engine.resolve(""), tempdir.path().canonicalize().unwrap());
300
301        insta::assert_compact_json_snapshot!(engine.matches(&["b"]).collect::<Vec<_>>(), @r###"[{"Err": "b"}]"###);
302        insta::assert_compact_json_snapshot!(engine.matches(&["a"]).collect::<Vec<_>>(), @r###"[{"Ok": "a"}]"###);
303        insta::assert_compact_json_snapshot!(engine.matches(&["/a"]).collect::<Vec<_>>(), @r###"[{"Ok": "a"}]"###);
304        insta::assert_compact_json_snapshot!(engine.matches(&["*/a"]).collect::<Vec<_>>(), @r###"[{"Ok": "c/a"}]"###);
305        insta::assert_compact_json_snapshot!(engine.matches(&["*a"]).collect::<Vec<_>>(), @r###"[{"Ok": "a"}, {"Ok": "c/a"}]"###);
306        insta::assert_compact_json_snapshot!(engine.matches(&["*/b"]).collect::<Vec<_>>(), @r###"[{"Ok": "c/b"}, {"Ok": "d/b"}]"###);
307        insta::assert_compact_json_snapshot!(engine.matches(&["c/*"]).collect::<Vec<_>>(), @r###"[{"Ok": "c/a"}, {"Ok": "c/b"}]"###);
308        insta::assert_compact_json_snapshot!(engine.matches(&["c/*", "!c/b", "!c/c"]).collect::<Vec<_>>(), @r###"[{"Ok": "c/a"}, {"Err": "c/c"}]"###);
309    }
310
311    #[test]
312    fn test_changes() {
313        let (tempdir, repo) = git_test! {
314            "initial commit": ["a" => "a", "c/a" => "a", "c/b" => "b", "d/b" => "b"]
315            staged: ["a" => "b"]
316            working: ["c/a" => "b"]
317        };
318
319        let engine = GitEngine::new(&repo, None, None);
320        assert_eq!(engine.resolve(""), tempdir.path().canonicalize().unwrap());
321
322        insta::assert_compact_json_snapshot!(engine.matches([""; 0]).collect::<Vec<_>>(), @r###"[{"Ok": "a"}, {"Ok": "c/a"}]"###);
323    }
324
325    #[test]
326    fn test_changes_staged_only() {
327        let (tempdir, repo) = git_test! {
328            "initial commit": ["a" => "a", "c/a" => "a", "c/b" => "b", "d/b" => "b"]
329            staged: ["a" => "b"]
330        };
331
332        let engine = GitEngine::new(&repo, None, None);
333        assert_eq!(engine.resolve(""), tempdir.path().canonicalize().unwrap());
334
335        insta::assert_compact_json_snapshot!(engine.matches(["";0]).collect::<Vec<_>>(), @r###"[{"Ok": "a"}]"###);
336    }
337
338    #[test]
339    fn test_changes_working_only() {
340        let (tempdir, repo) = git_test! {
341            "initial commit": ["a" => "a", "c/a" => "a", "c/b" => "b", "d/b" => "b"]
342            working: ["a" => "b"]
343        };
344
345        let engine = GitEngine::new(&repo, None, None);
346        assert_eq!(engine.resolve(""), tempdir.path().canonicalize().unwrap());
347
348        insta::assert_compact_json_snapshot!(engine.matches(["";0]).collect::<Vec<_>>(), @r###"[{"Ok": "a"}]"###);
349    }
350
351    #[test]
352    fn test_without_if_changed_ignore_trailer() {
353        let (tempdir, repo) = git_test! {
354            "initial commit": ["a" => "a", "c/a" => "a", "c/b" => "b", "d/b" => "b"]
355            "second commit": ["a" => "b"]
356        };
357
358        let engine = GitEngine::new(&repo, Some("HEAD~1"), Some("HEAD"));
359        assert_eq!(engine.resolve(""), tempdir.path().canonicalize().unwrap());
360
361        assert!(!engine.is_ignored(Path::new("a")));
362        assert!(!engine.is_ignored(Path::new("c/a")));
363    }
364
365    #[test]
366    fn test_with_if_changed_ignore_trailer() {
367        let (tempdir, repo) = git_test! {
368            "initial commit": ["a" => "a", "c/a" => "a", "c/b" => "b", "d/b" => "b"]
369            "second commit\n\nignore-if-changed: c/a": ["a" => "b"]
370        };
371
372        let engine = GitEngine::new(&repo, Some("HEAD~1"), Some("HEAD"));
373        assert_eq!(engine.resolve(""), tempdir.path().canonicalize().unwrap());
374
375        assert!(!engine.is_ignored(Path::new("a")));
376        assert!(engine.is_ignored(Path::new("c/a")));
377    }
378}