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