1use anyhow::{bail, Context, Result};
2use chrono::DateTime;
3use std::ffi::{OsStr, OsString};
4use std::path::{Path, PathBuf};
5use std::time::Duration;
6use tokio::process::Command;
7use tracing::warn;
8
9use super::types::{BlameResult, CommitInfo};
10use crate::call;
11use crate::errors::GitCallError;
12
13const GIT_CONFIG_TIMEOUT_SECS: u64 = 20;
15
16pub(crate) fn path_from_bytes(bytes: &[u8]) -> PathBuf {
19 #[cfg(unix)]
20 {
21 use std::os::unix::ffi::OsStrExt;
22 PathBuf::from(OsStr::from_bytes(bytes))
23 }
24 #[cfg(not(unix))]
25 {
26 PathBuf::from(String::from_utf8_lossy(bytes).into_owned())
27 }
28}
29
30pub(crate) fn path_to_bytes(path: &Path) -> Vec<u8> {
32 #[cfg(unix)]
33 {
34 use std::os::unix::ffi::OsStrExt;
35 path.as_os_str().as_bytes().to_vec()
36 }
37 #[cfg(not(unix))]
38 {
39 path.to_string_lossy().replace('\\', "/").into_bytes()
40 }
41}
42
43pub async fn get_head_commit(repo_path: &Path, branch: &str) -> Result<String> {
44 let reference = format!("refs/heads/{branch}");
45 let output = Command::new("git")
46 .arg("-C")
47 .arg(repo_path)
48 .args(["rev-parse", &reference])
49 .output()
50 .await
51 .context("running git rev-parse")?;
52
53 if !output.status.success() {
54 bail!(
55 "git rev-parse failed: {}",
56 String::from_utf8_lossy(&output.stderr)
57 );
58 }
59
60 Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
61}
62
63pub async fn reset_hard(repo_path: &Path) -> Result<()> {
65 let root = repo_path
66 .canonicalize()
67 .with_context(|| format!("resolving {}", repo_path.display()))?;
68 let mut safe_directory = OsString::from("safe.directory=");
69 safe_directory.push(&root);
70 let output = Command::new("git")
71 .arg("-c")
72 .arg(&safe_directory)
73 .args([
74 "-c",
75 "core.fsmonitor=false",
76 "-c",
77 "core.hooksPath=/dev/null",
78 ])
79 .arg("-C")
80 .arg(&root)
81 .args(["reset", "--hard", "HEAD"])
82 .output()
83 .await
84 .context("running git reset --hard")?;
85
86 if !output.status.success() {
87 bail!(
88 "git reset --hard failed: {}",
89 String::from_utf8_lossy(&output.stderr)
90 );
91 }
92 Ok(())
93}
94
95pub async fn disable_quotepath(repo_path: &Path) -> Result<()> {
96 let mut git_dir = OsString::from("--git-dir=");
97 git_dir.push(repo_path);
98 let output = tokio::time::timeout(
99 Duration::from_secs(GIT_CONFIG_TIMEOUT_SECS),
100 Command::new("git")
101 .arg(&git_dir)
102 .args(["config", "core.quotepath", "off"])
103 .kill_on_drop(true)
104 .output(),
105 )
106 .await
107 .context("git config core.quotepath timed out")?
108 .context("running git config core.quotepath")?;
109
110 if !output.status.success() {
111 bail!(
112 "git config core.quotepath failed: {}",
113 String::from_utf8_lossy(&output.stderr)
114 );
115 }
116 Ok(())
117}
118
119pub async fn get_last_commit_info(
120 repo_path: &Path,
121 filename: &Path,
122) -> Result<Option<CommitInfo>, GitCallError> {
123 let mut git = Command::new("git");
124 git.current_dir(repo_path)
125 .args(["log", "--max-count", "1", "--format=%H%n%ce%n%cI", "--"])
126 .arg(filename);
127 let output = call::run("log", &mut git).await?;
128 parse_last_commit(&output.stdout)
129}
130
131fn parse_last_commit(stdout: &[u8]) -> Result<Option<CommitInfo>, GitCallError> {
132 let malformed = |reason: String| GitCallError::Malformed {
133 command: "log",
134 reason,
135 };
136 let stdout = String::from_utf8_lossy(stdout);
137 let stdout = stdout.trim();
138 if stdout.is_empty() {
139 return Ok(None);
140 }
141 let mut lines = stdout.lines();
142 let (Some(sha), Some(author_email), Some(date)) = (lines.next(), lines.next(), lines.next())
143 else {
144 return Err(malformed(format!("expected three lines, got {stdout:?}")));
145 };
146 let date = DateTime::parse_from_rfc3339(date)
147 .map_err(|error| malformed(format!("committer date {date:?}: {error}")))?;
148 Ok(Some(CommitInfo {
149 sha: sha.to_owned(),
150 author_email: author_email.to_owned(),
151 date,
152 }))
153}
154
155pub async fn get_line_author(
156 repo_path: &Path,
157 filename: &Path,
158 line: usize,
159 rev: &str,
160) -> Result<Option<CommitInfo>> {
161 let line_spec = format!("{line},+1");
162 let output = Command::new("git")
163 .arg("-C")
164 .arg(repo_path)
165 .args([
166 "blame", "-L", &line_spec, "-l", "-p", "-M", "-C", "-C", rev, "--",
167 ])
168 .arg(filename)
169 .output()
170 .await
171 .context("running git blame")?;
172
173 let stderr = String::from_utf8_lossy(&output.stderr);
174 if !output.status.success() {
175 warn!(repo_path = %repo_path.display(), filename = %filename.display(), %stderr, "git blame failed");
176 return Ok(None);
177 }
178
179 let stdout = String::from_utf8_lossy(&output.stdout);
180 parse_blame_porcelain(&stdout)
181}
182
183pub async fn get_modified_filenames(
184 repo_path: &Path,
185 commit_sha: &str,
186) -> Result<Vec<PathBuf>, GitCallError> {
187 let mut git = Command::new("git");
188 git.current_dir(repo_path).args([
189 "diff",
190 "--name-only",
191 "--end-of-options",
192 &format!("{commit_sha}..HEAD"),
193 ]);
194 let output = call::run("diff", &mut git).await?;
195
196 Ok(output
197 .stdout
198 .split(|byte| *byte == b'\n')
199 .filter(|line| !line.is_empty())
200 .map(path_from_bytes)
201 .collect())
202}
203
204pub async fn is_commit_in_branch(
205 repo_path: &Path,
206 branch: &str,
207 commit_sha: &str,
208) -> Result<bool, GitCallError> {
209 let mut git = Command::new("git");
210 git.current_dir(repo_path).args([
212 "merge-base",
213 "--is-ancestor",
214 "--end-of-options",
215 commit_sha,
216 &format!("refs/heads/{branch}"),
217 ]);
218 match call::run("merge-base", &mut git).await {
219 Ok(_) => Ok(true),
220 Err(GitCallError::Refused { status, .. }) if status.code() == Some(1) => Ok(false),
221 Err(error) => Err(error),
222 }
223}
224
225pub async fn show_file_at_rev(
226 repo_path: &Path,
227 rev: &str,
228 file_path: &Path,
229) -> Result<Option<String>> {
230 let mut rev_path = OsString::from(format!("{rev}:"));
231 rev_path.push(file_path);
232 let output = Command::new("git")
233 .arg("-C")
234 .arg(repo_path)
235 .arg("show")
236 .arg(&rev_path)
237 .output()
238 .await
239 .context("running git show")?;
240
241 if !output.status.success() {
242 return Ok(None);
243 }
244
245 Ok(Some(String::from_utf8_lossy(&output.stdout).to_string()))
246}
247
248#[allow(clippy::too_many_arguments)]
249pub async fn blame_reverse(
250 repo_path: &Path,
251 file_path: &Path,
252 line: usize,
253 rev_a: &str,
254 rev_b: &str,
255 enable_copy_detection: bool,
256) -> Result<Option<BlameResult>> {
257 let line_spec = format!("{line},+1");
258 let rev_range = format!("{rev_a}..{rev_b}");
259
260 let mut args: Vec<&OsStr> = vec![
261 OsStr::new("-C"),
262 repo_path.as_os_str(),
263 OsStr::new("-c"),
264 OsStr::new("core.quotepath=off"),
265 OsStr::new("blame"),
266 OsStr::new("-p"),
267 OsStr::new("-M"),
268 ];
269 if enable_copy_detection {
270 args.push(OsStr::new("-C"));
271 }
272 args.extend([
273 OsStr::new("-L"),
274 OsStr::new(&line_spec),
275 OsStr::new("--reverse"),
276 OsStr::new(&rev_range),
277 OsStr::new("--"),
278 file_path.as_os_str(),
279 ]);
280
281 let output = Command::new("git")
282 .args(&args)
283 .output()
284 .await
285 .context("running git blame --reverse")?;
286
287 if !output.status.success() {
288 warn!(
289 repo_path = %repo_path.display(),
290 file_path = %file_path.display(),
291 line,
292 rev_range = %rev_range,
293 stderr = %String::from_utf8_lossy(&output.stderr),
294 "git blame --reverse failed",
295 );
296 return Ok(None);
297 }
298
299 Ok(parse_blame_reverse_porcelain(&output.stdout))
300}
301
302pub(crate) fn parse_blame_porcelain(output: &str) -> Result<Option<CommitInfo>> {
303 let mut sha = String::new();
304 let mut author_mail = String::new();
305 let mut committer_time: Option<&str> = None;
306
307 for line in output.lines() {
308 if sha.is_empty() && line.len() >= 40 {
309 line.split_whitespace()
310 .next()
311 .unwrap_or("")
312 .clone_into(&mut sha);
313 } else if let Some(val) = line.strip_prefix("author-mail <") {
314 val.trim_end_matches('>').clone_into(&mut author_mail);
315 } else if let Some(val) = line.strip_prefix("committer-time ") {
316 committer_time = Some(val);
317 }
318 }
319
320 if sha.is_empty() {
321 return Ok(None);
322 }
323
324 let date = match committer_time {
325 None => DateTime::UNIX_EPOCH.fixed_offset(),
326 Some(seconds) => seconds
327 .parse::<i64>()
328 .ok()
329 .and_then(|seconds| DateTime::from_timestamp(seconds, 0))
330 .context("parsing the committer time of git blame")?
331 .fixed_offset(),
332 };
333
334 Ok(Some(CommitInfo {
335 sha,
336 author_email: author_mail,
337 date,
338 }))
339}
340
341fn read_octal_escape(
342 first: u8,
343 bytes: &mut std::iter::Peekable<std::iter::Copied<std::slice::Iter<'_, u8>>>,
344) -> u8 {
345 let mut code = u32::from(first.saturating_sub(b'0'));
346 for _ in 0..2 {
347 let Some(&digit @ b'0'..=b'7') = bytes.peek() else {
348 break;
349 };
350 bytes.next();
351 code = code
352 .saturating_mul(8)
353 .saturating_add(u32::from(digit.saturating_sub(b'0')));
354 }
355 u8::try_from(code).unwrap_or(b'?')
356}
357
358fn unquote_path(value: &[u8]) -> PathBuf {
359 let Some(inner) = value
360 .strip_prefix(b"\"")
361 .and_then(|rest| rest.strip_suffix(b"\""))
362 else {
363 return path_from_bytes(value);
364 };
365
366 let mut out: Vec<u8> = Vec::with_capacity(inner.len());
367 let mut bytes = inner.iter().copied().peekable();
368 while let Some(byte) = bytes.next() {
369 if byte != b'\\' {
370 out.push(byte);
371 continue;
372 }
373 match bytes.next() {
374 Some(b'n') => out.push(b'\n'),
375 Some(b't') => out.push(b'\t'),
376 Some(b'r') => out.push(b'\r'),
377 Some(b'a') => out.push(0x07),
378 Some(b'b') => out.push(0x08),
379 Some(b'f') => out.push(0x0c),
380 Some(b'v') => out.push(0x0b),
381 Some(first @ b'0'..=b'7') => out.push(read_octal_escape(first, &mut bytes)),
382 Some(other) => out.push(other),
383 None => out.push(b'\\'),
384 }
385 }
386
387 path_from_bytes(&out)
388}
389
390pub(crate) fn parse_blame_reverse_porcelain(output: &[u8]) -> Option<BlameResult> {
391 let mut rev = String::new();
392 let mut line_num: usize = 0;
393 let mut path = PathBuf::new();
394
395 for line in output.split(|byte| *byte == b'\n') {
396 if rev.is_empty() && line.len() >= 40 {
397 let header = String::from_utf8_lossy(line);
398 let parts: Vec<&str> = header.split_whitespace().collect();
399 if let Some(hash) = parts.first() {
400 (*hash).clone_into(&mut rev);
401 }
402 if let Some(ln) = parts.get(1) {
403 line_num = ln.parse().unwrap_or(0);
404 }
405 } else if let Some(val) = line.strip_prefix(b"filename ") {
406 path = unquote_path(val);
407 }
408 }
409
410 if rev.is_empty() {
411 return None;
412 }
413
414 Some(BlameResult {
415 rev,
416 line: line_num,
417 path,
418 })
419}
420
421#[cfg(test)]
422mod tests {
423 use super::*;
424
425 #[test]
426 fn test_parse_blame_porcelain_valid() {
427 let output = "\
428abc1234567890abc1234567890abc1234567890ab 1 1 1\n\
429author Test\n\
430author-mail <dev@example.com>\n\
431author-time 1700000000\n\
432author-tz +0000\n\
433committer Test\n\
434committer-mail <dev@example.com>\n\
435committer-time 1700000000\n\
436committer-tz +0000\n\
437summary test commit\n\
438filename file.txt\n\
439\tcode line\n";
440 let result = parse_blame_porcelain(output).unwrap().unwrap();
441 assert_eq!(result.sha, "abc1234567890abc1234567890abc1234567890ab");
442 assert_eq!(result.author_email, "dev@example.com");
443 assert_eq!(result.date.timestamp(), 1_700_000_000);
444 }
445
446 #[test]
447 fn test_parse_blame_porcelain_without_a_committer_time() {
448 let output = "\
449abc1234567890abc1234567890abc1234567890ab 1 1 1\n\
450author-mail <dev@example.com>\n\
451filename file.txt\n\
452\tcode line\n";
453 let result = parse_blame_porcelain(output).unwrap().unwrap();
454 assert_eq!(result.sha, "abc1234567890abc1234567890abc1234567890ab");
455 assert_eq!(result.author_email, "dev@example.com");
456 assert_eq!(result.date, DateTime::UNIX_EPOCH.fixed_offset());
457 }
458
459 #[test]
460 fn test_parse_blame_porcelain_with_an_unparsable_committer_time() {
461 let output = "\
462abc1234567890abc1234567890abc1234567890ab 1 1 1\n\
463author-mail <dev@example.com>\n\
464committer-time 99999999999999\n\
465filename file.txt\n\
466\tcode line\n";
467 assert!(parse_blame_porcelain(output).is_err());
468 }
469
470 #[test]
471 fn test_parse_blame_porcelain_empty() {
472 assert!(parse_blame_porcelain("").unwrap().is_none());
473 }
474
475 #[test]
476 fn test_parse_blame_reverse_porcelain_valid() {
477 let output = b"\
478abc1234567890abc1234567890abc1234567890ab 7 5 1\n\
479author Test\n\
480filename renamed.py\n";
481 let result = parse_blame_reverse_porcelain(output).unwrap();
482 assert_eq!(result.rev, "abc1234567890abc1234567890abc1234567890ab");
483 assert_eq!(result.line, 7);
484 assert_eq!(result.path, Path::new("renamed.py"));
485 }
486
487 #[test]
488 fn a_path_git_escaped_is_read_back_as_the_name_on_disk() {
489 let output = b"\
490abc1234567890abc1234567890abc1234567890ab 7 5 1\n\
491filename \"se\\303\\261al.txt\"\n";
492 let result = parse_blame_reverse_porcelain(output).unwrap();
493 assert_eq!(result.path, Path::new("seƱal.txt"));
494 }
495
496 #[test]
497 #[cfg(unix)]
498 fn a_path_that_is_not_utf8_keeps_the_bytes_it_had_on_disk() {
499 use std::os::unix::ffi::OsStrExt;
500
501 let output = b"\
502abc1234567890abc1234567890abc1234567890ab 7 5 1\n\
503filename sel\xe1l.txt\n";
504 let result = parse_blame_reverse_porcelain(output).unwrap();
505 assert_eq!(result.path.as_os_str().as_bytes(), b"sel\xe1l.txt");
506 }
507
508 #[test]
509 fn a_path_git_left_alone_is_taken_as_it_is() {
510 assert_eq!(unquote_path(b"src/main.rs"), Path::new("src/main.rs"));
511 assert_eq!(unquote_path(b"\"a\\\\b\""), Path::new("a\\b"));
512 assert_eq!(unquote_path(b"\"a\\\"b\""), Path::new("a\"b"));
513 }
514
515 #[test]
516 fn test_parse_blame_reverse_porcelain_empty() {
517 assert!(parse_blame_reverse_porcelain(b"").is_none());
518 }
519
520 fn git(repo: &Path, args: &[&str]) -> String {
521 let output = std::process::Command::new("git")
522 .arg("-C")
523 .arg(repo)
524 .args([
525 "-c",
526 "user.email=dev@fluidattacks.com",
527 "-c",
528 "user.name=dev",
529 ])
530 .args(args)
531 .output()
532 .unwrap();
533 assert!(output.status.success());
534 String::from_utf8(output.stdout).unwrap().trim().to_owned()
535 }
536
537 #[tokio::test]
538 async fn a_warning_from_git_does_not_hide_its_answer() {
539 let dir = tempfile::tempdir().unwrap();
540 std::env::set_var("GIT_CONFIG_GLOBAL", dir.path().join("gitconfig"));
541
542 let repo = dir.path().join("repo");
543 std::fs::create_dir_all(&repo).unwrap();
544 git(&repo, &["init", "-q", "-b", "main"]);
545 std::fs::write(repo.join("f.txt"), b"first").unwrap();
546 git(&repo, &["add", "-A"]);
547 git(&repo, &["commit", "-q", "-m", "first"]);
548 let first = git(&repo, &["rev-parse", "HEAD"]);
549 std::fs::write(repo.join("g.txt"), b"second").unwrap();
550 git(&repo, &["add", "-A"]);
551 git(&repo, &["commit", "-q", "-m", "second"]);
552
553 git(&repo, &["update-ref", "refs/heads/HEAD", "HEAD"]);
555 git(
556 &repo,
557 &["update-ref", &format!("refs/heads/{first}"), &first],
558 );
559
560 let last = get_last_commit_info(&repo, Path::new("f.txt"))
561 .await
562 .unwrap();
563 assert_eq!(last.map(|info| info.sha), Some(first.clone()));
564
565 let author = get_line_author(&repo, Path::new("f.txt"), 1, "HEAD")
566 .await
567 .unwrap();
568 assert_eq!(author.map(|info| info.sha), Some(first.clone()));
569
570 let modified = get_modified_filenames(&repo, &first).await.unwrap();
571 assert_eq!(modified, vec![PathBuf::from("g.txt")]);
572
573 assert!(is_commit_in_branch(&repo, "main", &first).await.unwrap());
574 }
575
576 fn two_branches(repo: &Path) -> (String, String) {
577 std::fs::create_dir_all(repo).unwrap();
578 git(repo, &["init", "-q", "-b", "main"]);
579 git(repo, &["commit", "-q", "--allow-empty", "-m", "on main"]);
580 let on_main = git(repo, &["rev-parse", "HEAD"]);
581 git(repo, &["checkout", "-q", "-b", "main-old"]);
582 git(
583 repo,
584 &["commit", "-q", "--allow-empty", "-m", "on main-old"],
585 );
586 let on_main_old = git(repo, &["rev-parse", "HEAD"]);
587 (on_main, on_main_old)
588 }
589
590 #[tokio::test]
591 async fn a_commit_is_in_the_branch_it_was_made_on_and_not_in_one_named_alike() {
592 let dir = tempfile::tempdir().unwrap();
593 let repo = dir.path().join("repo");
594 let (on_main, on_main_old) = two_branches(&repo);
595
596 assert!(is_commit_in_branch(&repo, "main", &on_main).await.unwrap());
597 assert!(!is_commit_in_branch(&repo, "main", &on_main_old)
598 .await
599 .unwrap());
600 }
601
602 #[tokio::test]
603 async fn a_commit_or_a_branch_the_repository_lacks_is_refused() {
604 let dir = tempfile::tempdir().unwrap();
605 let repo = dir.path().join("repo");
606 let (on_main, _) = two_branches(&repo);
607
608 for (branch, commit) in [("main", "0".repeat(40)), ("gone", on_main)] {
609 let outcome = is_commit_in_branch(&repo, branch, &commit).await;
610
611 assert!(matches!(
612 outcome,
613 Err(GitCallError::Refused { command: "merge-base", status, .. })
614 if status.code() == Some(128)
615 ));
616 }
617 }
618
619 #[tokio::test]
620 async fn a_diff_from_a_commit_the_repository_lacks_is_refused() {
621 let dir = tempfile::tempdir().unwrap();
622 let repo = dir.path().join("repo");
623 two_branches(&repo);
624
625 let outcome = get_modified_filenames(&repo, &"0".repeat(40)).await;
626
627 assert!(matches!(
628 outcome,
629 Err(GitCallError::Refused { command: "diff", status, .. }) if status.code() == Some(128)
630 ));
631 }
632
633 #[test]
634 fn a_last_commit_is_read_from_its_three_lines() {
635 let info = parse_last_commit(b"abc\ndev@example.com\n2024-01-01T10:00:00-05:00\n")
636 .unwrap()
637 .unwrap();
638
639 assert_eq!(info.sha, "abc");
640 assert_eq!(info.author_email, "dev@example.com");
641 assert_eq!(info.date.to_rfc3339(), "2024-01-01T10:00:00-05:00");
642 }
643
644 #[test]
645 fn a_file_without_commits_has_no_last_commit() {
646 assert!(parse_last_commit(b"").unwrap().is_none());
647 }
648
649 #[test]
650 fn a_log_that_is_cut_short_or_misdated_is_malformed() {
651 for output in [&b"abc\n"[..], &b"abc\ndev@example.com\nnot-a-date\n"[..]] {
652 assert!(matches!(
653 parse_last_commit(output),
654 Err(GitCallError::Malformed { command: "log", .. })
655 ));
656 }
657 }
658
659 #[tokio::test]
660 async fn a_log_of_a_repository_without_commits_is_refused() {
661 let dir = tempfile::tempdir().unwrap();
662 git(dir.path(), &["init", "-q", "-b", "main"]);
663
664 let outcome = get_last_commit_info(dir.path(), Path::new("f.txt")).await;
665
666 assert!(matches!(
667 outcome,
668 Err(GitCallError::Refused { command: "log", status, .. }) if status.code() == Some(128)
669 ));
670 }
671
672 #[tokio::test]
673 async fn a_commit_that_looks_like_an_option_is_not_read_as_one() {
674 let dir = tempfile::tempdir().unwrap();
675 let repo = dir.path().join("repo");
676 two_branches(&repo);
677 let leak = dir.path().join("leak");
678
679 let outcome = get_modified_filenames(&repo, &format!("--output={}", leak.display())).await;
680
681 assert!(matches!(
682 outcome,
683 Err(GitCallError::Refused { command: "diff", status, .. }) if status.code() == Some(128)
684 ));
685 assert!(!dir.path().join("leak..HEAD").exists());
686 }
687
688 #[tokio::test]
689 async fn a_modified_name_is_cut_only_at_a_newline() {
690 let dir = tempfile::tempdir().unwrap();
691 let repo = dir.path().join("repo");
692 let (on_main, _) = two_branches(&repo);
693 git(&repo, &["config", "core.quotepath", "off"]);
694 std::fs::write(repo.join("a\u{85}b.txt"), b"content").unwrap();
695 git(&repo, &["add", "-A"]);
696 git(&repo, &["commit", "-q", "-m", "next line in a name"]);
697
698 let modified = get_modified_filenames(&repo, &on_main).await.unwrap();
699
700 assert_eq!(modified, vec![PathBuf::from("a\u{85}b.txt")]);
701 }
702
703 #[tokio::test]
704 async fn a_repository_path_that_does_not_exist_does_not_start() {
705 let dir = tempfile::tempdir().unwrap();
706 let missing = dir.path().join("missing");
707
708 let in_branch = is_commit_in_branch(&missing, "main", "HEAD").await;
709 let modified = get_modified_filenames(&missing, "HEAD").await;
710 let last_commit = get_last_commit_info(&missing, Path::new("f.txt")).await;
711
712 assert!(matches!(
713 in_branch,
714 Err(GitCallError::NotStarted { source, .. }) if source.kind() == std::io::ErrorKind::NotFound
715 ));
716 assert!(matches!(
717 modified,
718 Err(GitCallError::NotStarted { source, .. }) if source.kind() == std::io::ErrorKind::NotFound
719 ));
720 assert!(matches!(
721 last_commit,
722 Err(GitCallError::NotStarted { source, .. }) if source.kind() == std::io::ErrorKind::NotFound
723 ));
724 }
725}