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