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