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