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, GitCallError> {
44 let mut git = Command::new("git");
45 git.arg("-C").arg(repo_path).args([
48 "show-ref",
49 "--verify",
50 "--hash",
51 &format!("refs/heads/{branch}"),
52 ]);
53 let output = call::run("show-ref", &mut git).await?;
54 Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
55}
56
57pub async fn reset_hard(repo_path: &Path) -> Result<()> {
59 let root = repo_path
60 .canonicalize()
61 .with_context(|| format!("resolving {}", repo_path.display()))?;
62 let mut safe_directory = OsString::from("safe.directory=");
63 safe_directory.push(&root);
64 let output = Command::new("git")
65 .arg("-c")
66 .arg(&safe_directory)
67 .args([
68 "-c",
69 "core.fsmonitor=false",
70 "-c",
71 "core.hooksPath=/dev/null",
72 ])
73 .arg("-C")
74 .arg(&root)
75 .args(["reset", "--hard", "HEAD"])
76 .output()
77 .await
78 .context("running git reset --hard")?;
79
80 if !output.status.success() {
81 bail!(
82 "git reset --hard failed: {}",
83 String::from_utf8_lossy(&output.stderr)
84 );
85 }
86 Ok(())
87}
88
89pub async fn disable_quotepath(repo_path: &Path) -> Result<(), GitCallError> {
90 let mut git_dir = OsString::from("--git-dir=");
91 git_dir.push(repo_path);
92 let mut git = Command::new("git");
93 git.arg(&git_dir)
94 .args(["config", "core.quotepath", "off"])
95 .kill_on_drop(true);
96 let after = Duration::from_secs(GIT_CONFIG_TIMEOUT_SECS);
97 tokio::time::timeout(after, call::run("config", &mut git))
98 .await
99 .map_err(|_| GitCallError::TimedOut {
100 command: "config",
101 after,
102 })??;
103 Ok(())
104}
105
106pub async fn get_last_commit_info(
107 repo_path: &Path,
108 filename: &Path,
109) -> Result<Option<CommitInfo>, GitCallError> {
110 let mut git = Command::new("git");
111 git.current_dir(repo_path)
112 .args(["log", "--max-count", "1", "--format=%H%n%ce%n%cI", "--"])
113 .arg(filename);
114 let output = call::run("log", &mut git).await?;
115 parse_last_commit(&output.stdout)
116}
117
118fn parse_last_commit(stdout: &[u8]) -> Result<Option<CommitInfo>, GitCallError> {
119 let malformed = |reason: String| GitCallError::Malformed {
120 command: "log",
121 reason,
122 };
123 let stdout = String::from_utf8_lossy(stdout);
124 let stdout = stdout.trim();
125 if stdout.is_empty() {
126 return Ok(None);
127 }
128 let mut lines = stdout.lines();
129 let (Some(sha), Some(author_email), Some(date)) = (lines.next(), lines.next(), lines.next())
130 else {
131 return Err(malformed(format!("expected three lines, got {stdout:?}")));
132 };
133 let date = DateTime::parse_from_rfc3339(date)
134 .map_err(|error| malformed(format!("committer date {date:?}: {error}")))?;
135 Ok(Some(CommitInfo {
136 sha: sha.to_owned(),
137 author_email: author_email.to_owned(),
138 date,
139 }))
140}
141
142pub async fn get_line_author(
143 repo_path: &Path,
144 filename: &Path,
145 line: usize,
146 rev: &str,
147) -> Result<Option<CommitInfo>, GitCallError> {
148 let mut git = Command::new("git");
149 git.current_dir(repo_path)
150 .args([
151 "blame",
152 "-L",
153 &format!("{line},+1"),
154 "-l",
155 "-p",
156 "-M",
157 "-C",
158 "-C",
159 rev,
160 "--",
161 ])
162 .arg(filename);
163 let output = call::run("blame", &mut git).await?;
164 parse_blame(&output.stdout)
165}
166
167fn parse_blame(stdout: &[u8]) -> Result<Option<CommitInfo>, GitCallError> {
168 let malformed = |reason: String| GitCallError::Malformed {
169 command: "blame",
170 reason,
171 };
172 let stdout = String::from_utf8_lossy(stdout);
173 if stdout.trim().is_empty() {
174 return Ok(None);
175 }
176 let mut lines = stdout.split('\n');
177 let sha = lines
178 .next()
179 .and_then(|header| header.split(' ').next())
180 .unwrap_or_default();
181 let mut author_email = "";
182 let mut committer_time = None;
183 for line in lines.take_while(|line| !line.starts_with('\t')) {
184 if let Some(value) = line.strip_prefix("author-mail <") {
185 author_email = value.split_once('>').map_or("", |(email, _)| email);
186 } else if let Some(value) = line.strip_prefix("committer-time ") {
187 committer_time = Some(value);
188 }
189 }
190 let date = match committer_time {
191 None => DateTime::UNIX_EPOCH,
192 Some(seconds) => seconds
193 .parse()
194 .ok()
195 .and_then(|seconds| DateTime::from_timestamp(seconds, 0))
196 .ok_or_else(|| malformed(format!("committer time {seconds:?}")))?,
197 };
198 Ok(Some(CommitInfo {
199 sha: sha.to_owned(),
200 author_email: author_email.to_owned(),
201 date: date.fixed_offset(),
202 }))
203}
204
205pub async fn get_modified_filenames(
206 repo_path: &Path,
207 commit_sha: &str,
208) -> Result<Vec<PathBuf>, GitCallError> {
209 let mut git = Command::new("git");
210 git.current_dir(repo_path).args([
211 "diff",
212 "--name-only",
213 "--end-of-options",
214 &format!("{commit_sha}..HEAD"),
215 ]);
216 let output = call::run("diff", &mut git).await?;
217
218 Ok(output
219 .stdout
220 .split(|byte| *byte == b'\n')
221 .filter(|line| !line.is_empty())
222 .map(path_from_bytes)
223 .collect())
224}
225
226pub async fn is_commit_in_branch(
227 repo_path: &Path,
228 branch: &str,
229 commit_sha: &str,
230) -> Result<bool, GitCallError> {
231 let mut git = Command::new("git");
232 git.current_dir(repo_path).args([
234 "merge-base",
235 "--is-ancestor",
236 "--end-of-options",
237 commit_sha,
238 &format!("refs/heads/{branch}"),
239 ]);
240 match call::run("merge-base", &mut git).await {
241 Ok(_) => Ok(true),
242 Err(GitCallError::Refused { status, .. }) if status.code() == Some(1) => Ok(false),
243 Err(error) => Err(error),
244 }
245}
246
247pub async fn show_file_at_rev(
248 repo_path: &Path,
249 rev: &str,
250 file_path: &Path,
251) -> Result<Option<String>> {
252 let mut rev_path = OsString::from(format!("{rev}:"));
253 rev_path.push(file_path);
254 let output = Command::new("git")
255 .arg("-C")
256 .arg(repo_path)
257 .arg("show")
258 .arg(&rev_path)
259 .output()
260 .await
261 .context("running git show")?;
262
263 if !output.status.success() {
264 return Ok(None);
265 }
266
267 Ok(Some(String::from_utf8_lossy(&output.stdout).to_string()))
268}
269
270#[allow(clippy::too_many_arguments)]
271pub async fn blame_reverse(
272 repo_path: &Path,
273 file_path: &Path,
274 line: usize,
275 rev_a: &str,
276 rev_b: &str,
277 enable_copy_detection: bool,
278) -> Result<Option<BlameResult>> {
279 let line_spec = format!("{line},+1");
280 let rev_range = format!("{rev_a}..{rev_b}");
281
282 let mut args: Vec<&OsStr> = vec![
283 OsStr::new("-C"),
284 repo_path.as_os_str(),
285 OsStr::new("-c"),
286 OsStr::new("core.quotepath=off"),
287 OsStr::new("blame"),
288 OsStr::new("-p"),
289 OsStr::new("-M"),
290 ];
291 if enable_copy_detection {
292 args.push(OsStr::new("-C"));
293 }
294 args.extend([
295 OsStr::new("-L"),
296 OsStr::new(&line_spec),
297 OsStr::new("--reverse"),
298 OsStr::new(&rev_range),
299 OsStr::new("--"),
300 file_path.as_os_str(),
301 ]);
302
303 let output = Command::new("git")
304 .args(&args)
305 .output()
306 .await
307 .context("running git blame --reverse")?;
308
309 if !output.status.success() {
310 warn!(
311 repo_path = %repo_path.display(),
312 file_path = %file_path.display(),
313 line,
314 rev_range = %rev_range,
315 stderr = %String::from_utf8_lossy(&output.stderr),
316 "git blame --reverse failed",
317 );
318 return Ok(None);
319 }
320
321 Ok(parse_blame_reverse_porcelain(&output.stdout))
322}
323
324fn read_octal_escape(
325 first: u8,
326 bytes: &mut std::iter::Peekable<std::iter::Copied<std::slice::Iter<'_, u8>>>,
327) -> u8 {
328 let mut code = u32::from(first.saturating_sub(b'0'));
329 for _ in 0..2 {
330 let Some(&digit @ b'0'..=b'7') = bytes.peek() else {
331 break;
332 };
333 bytes.next();
334 code = code
335 .saturating_mul(8)
336 .saturating_add(u32::from(digit.saturating_sub(b'0')));
337 }
338 u8::try_from(code).unwrap_or(b'?')
339}
340
341fn unquote_path(value: &[u8]) -> PathBuf {
342 let Some(inner) = value
343 .strip_prefix(b"\"")
344 .and_then(|rest| rest.strip_suffix(b"\""))
345 else {
346 return path_from_bytes(value);
347 };
348
349 let mut out: Vec<u8> = Vec::with_capacity(inner.len());
350 let mut bytes = inner.iter().copied().peekable();
351 while let Some(byte) = bytes.next() {
352 if byte != b'\\' {
353 out.push(byte);
354 continue;
355 }
356 match bytes.next() {
357 Some(b'n') => out.push(b'\n'),
358 Some(b't') => out.push(b'\t'),
359 Some(b'r') => out.push(b'\r'),
360 Some(b'a') => out.push(0x07),
361 Some(b'b') => out.push(0x08),
362 Some(b'f') => out.push(0x0c),
363 Some(b'v') => out.push(0x0b),
364 Some(first @ b'0'..=b'7') => out.push(read_octal_escape(first, &mut bytes)),
365 Some(other) => out.push(other),
366 None => out.push(b'\\'),
367 }
368 }
369
370 path_from_bytes(&out)
371}
372
373pub(crate) fn parse_blame_reverse_porcelain(output: &[u8]) -> Option<BlameResult> {
374 let mut rev = String::new();
375 let mut line_num: usize = 0;
376 let mut path = PathBuf::new();
377
378 for line in output.split(|byte| *byte == b'\n') {
379 if rev.is_empty() && line.len() >= 40 {
380 let header = String::from_utf8_lossy(line);
381 let parts: Vec<&str> = header.split_whitespace().collect();
382 if let Some(hash) = parts.first() {
383 (*hash).clone_into(&mut rev);
384 }
385 if let Some(ln) = parts.get(1) {
386 line_num = ln.parse().unwrap_or(0);
387 }
388 } else if let Some(val) = line.strip_prefix(b"filename ") {
389 path = unquote_path(val);
390 }
391 }
392
393 if rev.is_empty() {
394 return None;
395 }
396
397 Some(BlameResult {
398 rev,
399 line: line_num,
400 path,
401 })
402}
403
404#[cfg(test)]
405mod tests {
406 use super::*;
407
408 #[test]
409 fn a_line_author_is_read_from_the_headers_before_the_line() {
410 let output = b"\
411abc1234567890abc1234567890abc1234567890ab 1 1 1\n\
412author Test\n\
413author-mail <dev@example.com>\n\
414committer-time 1700000000\n\
415filename file.txt\n\
416\tauthor-mail <code@example.com>\n";
417
418 let info = parse_blame(output).unwrap().unwrap();
419
420 assert_eq!(info.sha, "abc1234567890abc1234567890abc1234567890ab");
421 assert_eq!(info.author_email, "dev@example.com");
422 assert_eq!(info.date.timestamp(), 1_700_000_000);
423 }
424
425 #[test]
426 fn a_blame_without_a_committer_time_is_dated_at_the_epoch() {
427 let output = b"abc 1 1 1\nauthor-mail <dev@example.com>\n\tcode\n";
428
429 let info = parse_blame(output).unwrap().unwrap();
430
431 assert_eq!(info.date, DateTime::UNIX_EPOCH.fixed_offset());
432 }
433
434 #[test]
435 fn a_blame_with_an_unreadable_committer_time_is_malformed() {
436 for time in ["", "soon", "99999999999999"] {
437 let output = format!("abc 1 1 1\ncommitter-time {time}\n\tcode\n");
438
439 assert!(matches!(
440 parse_blame(output.as_bytes()),
441 Err(GitCallError::Malformed {
442 command: "blame",
443 ..
444 })
445 ));
446 }
447 }
448
449 #[test]
450 fn an_empty_blame_has_no_line_author() {
451 assert!(parse_blame(b"").unwrap().is_none());
452 }
453
454 #[test]
455 fn test_parse_blame_reverse_porcelain_valid() {
456 let output = b"\
457abc1234567890abc1234567890abc1234567890ab 7 5 1\n\
458author Test\n\
459filename renamed.py\n";
460 let result = parse_blame_reverse_porcelain(output).unwrap();
461 assert_eq!(result.rev, "abc1234567890abc1234567890abc1234567890ab");
462 assert_eq!(result.line, 7);
463 assert_eq!(result.path, Path::new("renamed.py"));
464 }
465
466 #[test]
467 fn a_path_git_escaped_is_read_back_as_the_name_on_disk() {
468 let output = b"\
469abc1234567890abc1234567890abc1234567890ab 7 5 1\n\
470filename \"se\\303\\261al.txt\"\n";
471 let result = parse_blame_reverse_porcelain(output).unwrap();
472 assert_eq!(result.path, Path::new("seƱal.txt"));
473 }
474
475 #[test]
476 #[cfg(unix)]
477 fn a_path_that_is_not_utf8_keeps_the_bytes_it_had_on_disk() {
478 use std::os::unix::ffi::OsStrExt;
479
480 let output = b"\
481abc1234567890abc1234567890abc1234567890ab 7 5 1\n\
482filename sel\xe1l.txt\n";
483 let result = parse_blame_reverse_porcelain(output).unwrap();
484 assert_eq!(result.path.as_os_str().as_bytes(), b"sel\xe1l.txt");
485 }
486
487 #[test]
488 fn a_path_git_left_alone_is_taken_as_it_is() {
489 assert_eq!(unquote_path(b"src/main.rs"), Path::new("src/main.rs"));
490 assert_eq!(unquote_path(b"\"a\\\\b\""), Path::new("a\\b"));
491 assert_eq!(unquote_path(b"\"a\\\"b\""), Path::new("a\"b"));
492 }
493
494 #[test]
495 fn test_parse_blame_reverse_porcelain_empty() {
496 assert!(parse_blame_reverse_porcelain(b"").is_none());
497 }
498
499 fn git(repo: &Path, args: &[&str]) -> String {
500 let output = std::process::Command::new("git")
501 .arg("-C")
502 .arg(repo)
503 .args([
504 "-c",
505 "user.email=dev@fluidattacks.com",
506 "-c",
507 "user.name=dev",
508 ])
509 .args(args)
510 .output()
511 .unwrap();
512 assert!(output.status.success());
513 String::from_utf8(output.stdout).unwrap().trim().to_owned()
514 }
515
516 #[tokio::test]
517 async fn a_warning_from_git_does_not_hide_its_answer() {
518 let dir = tempfile::tempdir().unwrap();
519 std::env::set_var("GIT_CONFIG_GLOBAL", dir.path().join("gitconfig"));
520
521 let repo = dir.path().join("repo");
522 std::fs::create_dir_all(&repo).unwrap();
523 git(&repo, &["init", "-q", "-b", "main"]);
524 std::fs::write(repo.join("f.txt"), b"first").unwrap();
525 git(&repo, &["add", "-A"]);
526 git(&repo, &["commit", "-q", "-m", "first"]);
527 let first = git(&repo, &["rev-parse", "HEAD"]);
528 std::fs::write(repo.join("g.txt"), b"second").unwrap();
529 git(&repo, &["add", "-A"]);
530 git(&repo, &["commit", "-q", "-m", "second"]);
531
532 git(&repo, &["update-ref", "refs/heads/HEAD", "HEAD"]);
534 git(
535 &repo,
536 &["update-ref", &format!("refs/heads/{first}"), &first],
537 );
538
539 let last = get_last_commit_info(&repo, Path::new("f.txt"))
540 .await
541 .unwrap();
542 assert_eq!(last.map(|info| info.sha), Some(first.clone()));
543
544 let author = get_line_author(&repo, Path::new("f.txt"), 1, "HEAD")
545 .await
546 .unwrap();
547 assert_eq!(author.map(|info| info.sha), Some(first.clone()));
548
549 let modified = get_modified_filenames(&repo, &first).await.unwrap();
550 assert_eq!(modified, vec![PathBuf::from("g.txt")]);
551
552 assert!(is_commit_in_branch(&repo, "main", &first).await.unwrap());
553 }
554
555 fn two_branches(repo: &Path) -> (String, String) {
556 std::fs::create_dir_all(repo).unwrap();
557 git(repo, &["init", "-q", "-b", "main"]);
558 git(repo, &["commit", "-q", "--allow-empty", "-m", "on main"]);
559 let on_main = git(repo, &["rev-parse", "HEAD"]);
560 git(repo, &["checkout", "-q", "-b", "main-old"]);
561 git(
562 repo,
563 &["commit", "-q", "--allow-empty", "-m", "on main-old"],
564 );
565 let on_main_old = git(repo, &["rev-parse", "HEAD"]);
566 (on_main, on_main_old)
567 }
568
569 #[tokio::test]
570 async fn a_commit_is_in_the_branch_it_was_made_on_and_not_in_one_named_alike() {
571 let dir = tempfile::tempdir().unwrap();
572 let repo = dir.path().join("repo");
573 let (on_main, on_main_old) = two_branches(&repo);
574
575 assert!(is_commit_in_branch(&repo, "main", &on_main).await.unwrap());
576 assert!(!is_commit_in_branch(&repo, "main", &on_main_old)
577 .await
578 .unwrap());
579 }
580
581 #[tokio::test]
582 async fn a_commit_or_a_branch_the_repository_lacks_is_refused() {
583 let dir = tempfile::tempdir().unwrap();
584 let repo = dir.path().join("repo");
585 let (on_main, _) = two_branches(&repo);
586
587 for (branch, commit) in [("main", "0".repeat(40)), ("gone", on_main)] {
588 let outcome = is_commit_in_branch(&repo, branch, &commit).await;
589
590 assert!(matches!(
591 outcome,
592 Err(GitCallError::Refused { command: "merge-base", status, .. })
593 if status.code() == Some(128)
594 ));
595 }
596 }
597
598 #[tokio::test]
599 async fn a_branch_is_read_by_its_exact_name_and_never_as_a_revision() {
600 let dir = tempfile::tempdir().unwrap();
601 let repo = dir.path().join("repo");
602 let (_, on_main_old) = two_branches(&repo);
603
604 assert_eq!(
605 get_head_commit(&repo, "main-old").await.unwrap(),
606 on_main_old
607 );
608 for revision in ["main-old^", "main-old~1", "main..main-old"] {
609 assert!(matches!(
610 get_head_commit(&repo, revision).await,
611 Err(GitCallError::Refused {
612 command: "show-ref",
613 ..
614 })
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 let line_author = get_line_author(&missing, Path::new("f.txt"), 1, "HEAD").await;
712
713 assert!(matches!(
714 in_branch,
715 Err(GitCallError::NotStarted { source, .. }) if source.kind() == std::io::ErrorKind::NotFound
716 ));
717 assert!(matches!(
718 modified,
719 Err(GitCallError::NotStarted { source, .. }) if source.kind() == std::io::ErrorKind::NotFound
720 ));
721 assert!(matches!(
722 last_commit,
723 Err(GitCallError::NotStarted { source, .. }) if source.kind() == std::io::ErrorKind::NotFound
724 ));
725 assert!(matches!(
726 line_author,
727 Err(GitCallError::NotStarted { source, .. }) if source.kind() == std::io::ErrorKind::NotFound
728 ));
729 }
730
731 #[tokio::test]
732 async fn a_blame_at_a_revision_the_repository_lacks_is_refused() {
733 let dir = tempfile::tempdir().unwrap();
734 git(dir.path(), &["init", "-q", "-b", "main"]);
735 git(
736 dir.path(),
737 &["commit", "-q", "--allow-empty", "-m", "first"],
738 );
739
740 let outcome = get_line_author(dir.path(), Path::new("f.txt"), 1, &"0".repeat(40)).await;
741
742 assert!(matches!(
743 outcome,
744 Err(GitCallError::Refused { command: "blame", status, .. }) if status.code() == Some(128)
745 ));
746 }
747
748 #[tokio::test]
749 async fn quotepath_is_turned_off_in_the_git_dir_given() {
750 let dir = tempfile::tempdir().unwrap();
751 git(dir.path(), &["init", "-q", "-b", "main"]);
752
753 disable_quotepath(&dir.path().join(".git")).await.unwrap();
754
755 assert_eq!(git(dir.path(), &["config", "core.quotepath"]), "off");
756 }
757
758 #[tokio::test]
759 async fn quotepath_in_a_folder_that_is_not_a_git_dir_is_refused() {
760 let dir = tempfile::tempdir().unwrap();
761
762 let outcome = disable_quotepath(dir.path()).await;
763
764 assert!(matches!(
765 outcome,
766 Err(GitCallError::Refused { command: "config", status, .. }) if status.code() == Some(128)
767 ));
768 }
769}