Skip to main content

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