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