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