Skip to main content

fluidattacks_core_git/
cli.rs

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
12/// Matches the deadline `run_git` applied to the same call in the python.
13const GIT_CONFIG_TIMEOUT_SECS: u64 = 20;
14
15/// Git writes paths as the bytes on disk once `core.quotepath` is off, and those
16/// bytes are not always UTF-8. Decoding them loses the name of the file.
17pub(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
29/// The bytes git reads as `path`, which it separates with `/` on every system.
30pub(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    // show-ref takes the exact ref, never a revision like `main^`, and refuses
45    // one whose object is missing.
46    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
56/// Honors the repository's own config, filter drivers included, so it must be trusted.
57pub 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    // A missing path fails to start, as it did under the python's cwd.
232    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    // Once options end, blame reads `--` as a revision, so the path follows the range bare.
278    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        // Both refs make git warn that a refname is ambiguous and still exit 0
508        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}