Skip to main content

if_changed/engine/
git.rs

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