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>> {
154    let line_spec = format!("{line},+1");
155    let output = Command::new("git")
156        .arg("-C")
157        .arg(repo_path)
158        .args([
159            "blame", "-L", &line_spec, "-l", "-p", "-M", "-C", "-C", rev, "--",
160        ])
161        .arg(filename)
162        .output()
163        .await
164        .context("running git blame")?;
165
166    let stderr = String::from_utf8_lossy(&output.stderr);
167    if !output.status.success() {
168        warn!(repo_path = %repo_path.display(), filename = %filename.display(), %stderr, "git blame failed");
169        return Ok(None);
170    }
171
172    let stdout = String::from_utf8_lossy(&output.stdout);
173    parse_blame_porcelain(&stdout)
174}
175
176pub async fn get_modified_filenames(
177    repo_path: &Path,
178    commit_sha: &str,
179) -> Result<Vec<PathBuf>, GitCallError> {
180    let mut git = Command::new("git");
181    git.current_dir(repo_path).args([
182        "diff",
183        "--name-only",
184        "--end-of-options",
185        &format!("{commit_sha}..HEAD"),
186    ]);
187    let output = call::run("diff", &mut git).await?;
188
189    Ok(output
190        .stdout
191        .split(|byte| *byte == b'\n')
192        .filter(|line| !line.is_empty())
193        .map(path_from_bytes)
194        .collect())
195}
196
197pub async fn is_commit_in_branch(
198    repo_path: &Path,
199    branch: &str,
200    commit_sha: &str,
201) -> Result<bool, GitCallError> {
202    let mut git = Command::new("git");
203    // A missing path fails to start, as it did under the python's cwd.
204    git.current_dir(repo_path).args([
205        "merge-base",
206        "--is-ancestor",
207        "--end-of-options",
208        commit_sha,
209        &format!("refs/heads/{branch}"),
210    ]);
211    match call::run("merge-base", &mut git).await {
212        Ok(_) => Ok(true),
213        Err(GitCallError::Refused { status, .. }) if status.code() == Some(1) => Ok(false),
214        Err(error) => Err(error),
215    }
216}
217
218pub async fn show_file_at_rev(
219    repo_path: &Path,
220    rev: &str,
221    file_path: &Path,
222) -> Result<Option<String>> {
223    let mut rev_path = OsString::from(format!("{rev}:"));
224    rev_path.push(file_path);
225    let output = Command::new("git")
226        .arg("-C")
227        .arg(repo_path)
228        .arg("show")
229        .arg(&rev_path)
230        .output()
231        .await
232        .context("running git show")?;
233
234    if !output.status.success() {
235        return Ok(None);
236    }
237
238    Ok(Some(String::from_utf8_lossy(&output.stdout).to_string()))
239}
240
241#[allow(clippy::too_many_arguments)]
242pub async fn blame_reverse(
243    repo_path: &Path,
244    file_path: &Path,
245    line: usize,
246    rev_a: &str,
247    rev_b: &str,
248    enable_copy_detection: bool,
249) -> Result<Option<BlameResult>> {
250    let line_spec = format!("{line},+1");
251    let rev_range = format!("{rev_a}..{rev_b}");
252
253    let mut args: Vec<&OsStr> = vec![
254        OsStr::new("-C"),
255        repo_path.as_os_str(),
256        OsStr::new("-c"),
257        OsStr::new("core.quotepath=off"),
258        OsStr::new("blame"),
259        OsStr::new("-p"),
260        OsStr::new("-M"),
261    ];
262    if enable_copy_detection {
263        args.push(OsStr::new("-C"));
264    }
265    args.extend([
266        OsStr::new("-L"),
267        OsStr::new(&line_spec),
268        OsStr::new("--reverse"),
269        OsStr::new(&rev_range),
270        OsStr::new("--"),
271        file_path.as_os_str(),
272    ]);
273
274    let output = Command::new("git")
275        .args(&args)
276        .output()
277        .await
278        .context("running git blame --reverse")?;
279
280    if !output.status.success() {
281        warn!(
282            repo_path = %repo_path.display(),
283            file_path = %file_path.display(),
284            line,
285            rev_range = %rev_range,
286            stderr = %String::from_utf8_lossy(&output.stderr),
287            "git blame --reverse failed",
288        );
289        return Ok(None);
290    }
291
292    Ok(parse_blame_reverse_porcelain(&output.stdout))
293}
294
295pub(crate) fn parse_blame_porcelain(output: &str) -> Result<Option<CommitInfo>> {
296    let mut sha = String::new();
297    let mut author_mail = String::new();
298    let mut committer_time: Option<&str> = None;
299
300    for line in output.lines() {
301        if sha.is_empty() && line.len() >= 40 {
302            line.split_whitespace()
303                .next()
304                .unwrap_or("")
305                .clone_into(&mut sha);
306        } else if let Some(val) = line.strip_prefix("author-mail <") {
307            val.trim_end_matches('>').clone_into(&mut author_mail);
308        } else if let Some(val) = line.strip_prefix("committer-time ") {
309            committer_time = Some(val);
310        }
311    }
312
313    if sha.is_empty() {
314        return Ok(None);
315    }
316
317    let date = match committer_time {
318        None => DateTime::UNIX_EPOCH.fixed_offset(),
319        Some(seconds) => seconds
320            .parse::<i64>()
321            .ok()
322            .and_then(|seconds| DateTime::from_timestamp(seconds, 0))
323            .context("parsing the committer time of git blame")?
324            .fixed_offset(),
325    };
326
327    Ok(Some(CommitInfo {
328        sha,
329        author_email: author_mail,
330        date,
331    }))
332}
333
334fn read_octal_escape(
335    first: u8,
336    bytes: &mut std::iter::Peekable<std::iter::Copied<std::slice::Iter<'_, u8>>>,
337) -> u8 {
338    let mut code = u32::from(first.saturating_sub(b'0'));
339    for _ in 0..2 {
340        let Some(&digit @ b'0'..=b'7') = bytes.peek() else {
341            break;
342        };
343        bytes.next();
344        code = code
345            .saturating_mul(8)
346            .saturating_add(u32::from(digit.saturating_sub(b'0')));
347    }
348    u8::try_from(code).unwrap_or(b'?')
349}
350
351fn unquote_path(value: &[u8]) -> PathBuf {
352    let Some(inner) = value
353        .strip_prefix(b"\"")
354        .and_then(|rest| rest.strip_suffix(b"\""))
355    else {
356        return path_from_bytes(value);
357    };
358
359    let mut out: Vec<u8> = Vec::with_capacity(inner.len());
360    let mut bytes = inner.iter().copied().peekable();
361    while let Some(byte) = bytes.next() {
362        if byte != b'\\' {
363            out.push(byte);
364            continue;
365        }
366        match bytes.next() {
367            Some(b'n') => out.push(b'\n'),
368            Some(b't') => out.push(b'\t'),
369            Some(b'r') => out.push(b'\r'),
370            Some(b'a') => out.push(0x07),
371            Some(b'b') => out.push(0x08),
372            Some(b'f') => out.push(0x0c),
373            Some(b'v') => out.push(0x0b),
374            Some(first @ b'0'..=b'7') => out.push(read_octal_escape(first, &mut bytes)),
375            Some(other) => out.push(other),
376            None => out.push(b'\\'),
377        }
378    }
379
380    path_from_bytes(&out)
381}
382
383pub(crate) fn parse_blame_reverse_porcelain(output: &[u8]) -> Option<BlameResult> {
384    let mut rev = String::new();
385    let mut line_num: usize = 0;
386    let mut path = PathBuf::new();
387
388    for line in output.split(|byte| *byte == b'\n') {
389        if rev.is_empty() && line.len() >= 40 {
390            let header = String::from_utf8_lossy(line);
391            let parts: Vec<&str> = header.split_whitespace().collect();
392            if let Some(hash) = parts.first() {
393                (*hash).clone_into(&mut rev);
394            }
395            if let Some(ln) = parts.get(1) {
396                line_num = ln.parse().unwrap_or(0);
397            }
398        } else if let Some(val) = line.strip_prefix(b"filename ") {
399            path = unquote_path(val);
400        }
401    }
402
403    if rev.is_empty() {
404        return None;
405    }
406
407    Some(BlameResult {
408        rev,
409        line: line_num,
410        path,
411    })
412}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417
418    #[test]
419    fn test_parse_blame_porcelain_valid() {
420        let output = "\
421abc1234567890abc1234567890abc1234567890ab 1 1 1\n\
422author Test\n\
423author-mail <dev@example.com>\n\
424author-time 1700000000\n\
425author-tz +0000\n\
426committer Test\n\
427committer-mail <dev@example.com>\n\
428committer-time 1700000000\n\
429committer-tz +0000\n\
430summary test commit\n\
431filename file.txt\n\
432\tcode line\n";
433        let result = parse_blame_porcelain(output).unwrap().unwrap();
434        assert_eq!(result.sha, "abc1234567890abc1234567890abc1234567890ab");
435        assert_eq!(result.author_email, "dev@example.com");
436        assert_eq!(result.date.timestamp(), 1_700_000_000);
437    }
438
439    #[test]
440    fn test_parse_blame_porcelain_without_a_committer_time() {
441        let output = "\
442abc1234567890abc1234567890abc1234567890ab 1 1 1\n\
443author-mail <dev@example.com>\n\
444filename file.txt\n\
445\tcode line\n";
446        let result = parse_blame_porcelain(output).unwrap().unwrap();
447        assert_eq!(result.sha, "abc1234567890abc1234567890abc1234567890ab");
448        assert_eq!(result.author_email, "dev@example.com");
449        assert_eq!(result.date, DateTime::UNIX_EPOCH.fixed_offset());
450    }
451
452    #[test]
453    fn test_parse_blame_porcelain_with_an_unparsable_committer_time() {
454        let output = "\
455abc1234567890abc1234567890abc1234567890ab 1 1 1\n\
456author-mail <dev@example.com>\n\
457committer-time 99999999999999\n\
458filename file.txt\n\
459\tcode line\n";
460        assert!(parse_blame_porcelain(output).is_err());
461    }
462
463    #[test]
464    fn test_parse_blame_porcelain_empty() {
465        assert!(parse_blame_porcelain("").unwrap().is_none());
466    }
467
468    #[test]
469    fn test_parse_blame_reverse_porcelain_valid() {
470        let output = b"\
471abc1234567890abc1234567890abc1234567890ab 7 5 1\n\
472author Test\n\
473filename renamed.py\n";
474        let result = parse_blame_reverse_porcelain(output).unwrap();
475        assert_eq!(result.rev, "abc1234567890abc1234567890abc1234567890ab");
476        assert_eq!(result.line, 7);
477        assert_eq!(result.path, Path::new("renamed.py"));
478    }
479
480    #[test]
481    fn a_path_git_escaped_is_read_back_as_the_name_on_disk() {
482        let output = b"\
483abc1234567890abc1234567890abc1234567890ab 7 5 1\n\
484filename \"se\\303\\261al.txt\"\n";
485        let result = parse_blame_reverse_porcelain(output).unwrap();
486        assert_eq!(result.path, Path::new("seƱal.txt"));
487    }
488
489    #[test]
490    #[cfg(unix)]
491    fn a_path_that_is_not_utf8_keeps_the_bytes_it_had_on_disk() {
492        use std::os::unix::ffi::OsStrExt;
493
494        let output = b"\
495abc1234567890abc1234567890abc1234567890ab 7 5 1\n\
496filename sel\xe1l.txt\n";
497        let result = parse_blame_reverse_porcelain(output).unwrap();
498        assert_eq!(result.path.as_os_str().as_bytes(), b"sel\xe1l.txt");
499    }
500
501    #[test]
502    fn a_path_git_left_alone_is_taken_as_it_is() {
503        assert_eq!(unquote_path(b"src/main.rs"), Path::new("src/main.rs"));
504        assert_eq!(unquote_path(b"\"a\\\\b\""), Path::new("a\\b"));
505        assert_eq!(unquote_path(b"\"a\\\"b\""), Path::new("a\"b"));
506    }
507
508    #[test]
509    fn test_parse_blame_reverse_porcelain_empty() {
510        assert!(parse_blame_reverse_porcelain(b"").is_none());
511    }
512
513    fn git(repo: &Path, args: &[&str]) -> String {
514        let output = std::process::Command::new("git")
515            .arg("-C")
516            .arg(repo)
517            .args([
518                "-c",
519                "user.email=dev@fluidattacks.com",
520                "-c",
521                "user.name=dev",
522            ])
523            .args(args)
524            .output()
525            .unwrap();
526        assert!(output.status.success());
527        String::from_utf8(output.stdout).unwrap().trim().to_owned()
528    }
529
530    #[tokio::test]
531    async fn a_warning_from_git_does_not_hide_its_answer() {
532        let dir = tempfile::tempdir().unwrap();
533        std::env::set_var("GIT_CONFIG_GLOBAL", dir.path().join("gitconfig"));
534
535        let repo = dir.path().join("repo");
536        std::fs::create_dir_all(&repo).unwrap();
537        git(&repo, &["init", "-q", "-b", "main"]);
538        std::fs::write(repo.join("f.txt"), b"first").unwrap();
539        git(&repo, &["add", "-A"]);
540        git(&repo, &["commit", "-q", "-m", "first"]);
541        let first = git(&repo, &["rev-parse", "HEAD"]);
542        std::fs::write(repo.join("g.txt"), b"second").unwrap();
543        git(&repo, &["add", "-A"]);
544        git(&repo, &["commit", "-q", "-m", "second"]);
545
546        // Both refs make git warn that a refname is ambiguous and still exit 0
547        git(&repo, &["update-ref", "refs/heads/HEAD", "HEAD"]);
548        git(
549            &repo,
550            &["update-ref", &format!("refs/heads/{first}"), &first],
551        );
552
553        let last = get_last_commit_info(&repo, Path::new("f.txt"))
554            .await
555            .unwrap();
556        assert_eq!(last.map(|info| info.sha), Some(first.clone()));
557
558        let author = get_line_author(&repo, Path::new("f.txt"), 1, "HEAD")
559            .await
560            .unwrap();
561        assert_eq!(author.map(|info| info.sha), Some(first.clone()));
562
563        let modified = get_modified_filenames(&repo, &first).await.unwrap();
564        assert_eq!(modified, vec![PathBuf::from("g.txt")]);
565
566        assert!(is_commit_in_branch(&repo, "main", &first).await.unwrap());
567    }
568
569    fn two_branches(repo: &Path) -> (String, String) {
570        std::fs::create_dir_all(repo).unwrap();
571        git(repo, &["init", "-q", "-b", "main"]);
572        git(repo, &["commit", "-q", "--allow-empty", "-m", "on main"]);
573        let on_main = git(repo, &["rev-parse", "HEAD"]);
574        git(repo, &["checkout", "-q", "-b", "main-old"]);
575        git(
576            repo,
577            &["commit", "-q", "--allow-empty", "-m", "on main-old"],
578        );
579        let on_main_old = git(repo, &["rev-parse", "HEAD"]);
580        (on_main, on_main_old)
581    }
582
583    #[tokio::test]
584    async fn a_commit_is_in_the_branch_it_was_made_on_and_not_in_one_named_alike() {
585        let dir = tempfile::tempdir().unwrap();
586        let repo = dir.path().join("repo");
587        let (on_main, on_main_old) = two_branches(&repo);
588
589        assert!(is_commit_in_branch(&repo, "main", &on_main).await.unwrap());
590        assert!(!is_commit_in_branch(&repo, "main", &on_main_old)
591            .await
592            .unwrap());
593    }
594
595    #[tokio::test]
596    async fn a_commit_or_a_branch_the_repository_lacks_is_refused() {
597        let dir = tempfile::tempdir().unwrap();
598        let repo = dir.path().join("repo");
599        let (on_main, _) = two_branches(&repo);
600
601        for (branch, commit) in [("main", "0".repeat(40)), ("gone", on_main)] {
602            let outcome = is_commit_in_branch(&repo, branch, &commit).await;
603
604            assert!(matches!(
605                outcome,
606                Err(GitCallError::Refused { command: "merge-base", status, .. })
607                    if status.code() == Some(128)
608            ));
609        }
610    }
611
612    #[tokio::test]
613    async fn a_diff_from_a_commit_the_repository_lacks_is_refused() {
614        let dir = tempfile::tempdir().unwrap();
615        let repo = dir.path().join("repo");
616        two_branches(&repo);
617
618        let outcome = get_modified_filenames(&repo, &"0".repeat(40)).await;
619
620        assert!(matches!(
621            outcome,
622            Err(GitCallError::Refused { command: "diff", status, .. }) if status.code() == Some(128)
623        ));
624    }
625
626    #[test]
627    fn a_last_commit_is_read_from_its_three_lines() {
628        let info = parse_last_commit(b"abc\ndev@example.com\n2024-01-01T10:00:00-05:00\n")
629            .unwrap()
630            .unwrap();
631
632        assert_eq!(info.sha, "abc");
633        assert_eq!(info.author_email, "dev@example.com");
634        assert_eq!(info.date.to_rfc3339(), "2024-01-01T10:00:00-05:00");
635    }
636
637    #[test]
638    fn a_file_without_commits_has_no_last_commit() {
639        assert!(parse_last_commit(b"").unwrap().is_none());
640    }
641
642    #[test]
643    fn a_log_that_is_cut_short_or_misdated_is_malformed() {
644        for output in [&b"abc\n"[..], &b"abc\ndev@example.com\nnot-a-date\n"[..]] {
645            assert!(matches!(
646                parse_last_commit(output),
647                Err(GitCallError::Malformed { command: "log", .. })
648            ));
649        }
650    }
651
652    #[tokio::test]
653    async fn a_log_of_a_repository_without_commits_is_refused() {
654        let dir = tempfile::tempdir().unwrap();
655        git(dir.path(), &["init", "-q", "-b", "main"]);
656
657        let outcome = get_last_commit_info(dir.path(), Path::new("f.txt")).await;
658
659        assert!(matches!(
660            outcome,
661            Err(GitCallError::Refused { command: "log", status, .. }) if status.code() == Some(128)
662        ));
663    }
664
665    #[tokio::test]
666    async fn a_commit_that_looks_like_an_option_is_not_read_as_one() {
667        let dir = tempfile::tempdir().unwrap();
668        let repo = dir.path().join("repo");
669        two_branches(&repo);
670        let leak = dir.path().join("leak");
671
672        let outcome = get_modified_filenames(&repo, &format!("--output={}", leak.display())).await;
673
674        assert!(matches!(
675            outcome,
676            Err(GitCallError::Refused { command: "diff", status, .. }) if status.code() == Some(128)
677        ));
678        assert!(!dir.path().join("leak..HEAD").exists());
679    }
680
681    #[tokio::test]
682    async fn a_modified_name_is_cut_only_at_a_newline() {
683        let dir = tempfile::tempdir().unwrap();
684        let repo = dir.path().join("repo");
685        let (on_main, _) = two_branches(&repo);
686        git(&repo, &["config", "core.quotepath", "off"]);
687        std::fs::write(repo.join("a\u{85}b.txt"), b"content").unwrap();
688        git(&repo, &["add", "-A"]);
689        git(&repo, &["commit", "-q", "-m", "next line in a name"]);
690
691        let modified = get_modified_filenames(&repo, &on_main).await.unwrap();
692
693        assert_eq!(modified, vec![PathBuf::from("a\u{85}b.txt")]);
694    }
695
696    #[tokio::test]
697    async fn a_repository_path_that_does_not_exist_does_not_start() {
698        let dir = tempfile::tempdir().unwrap();
699        let missing = dir.path().join("missing");
700
701        let in_branch = is_commit_in_branch(&missing, "main", "HEAD").await;
702        let modified = get_modified_filenames(&missing, "HEAD").await;
703        let last_commit = get_last_commit_info(&missing, Path::new("f.txt")).await;
704
705        assert!(matches!(
706            in_branch,
707            Err(GitCallError::NotStarted { source, .. }) if source.kind() == std::io::ErrorKind::NotFound
708        ));
709        assert!(matches!(
710            modified,
711            Err(GitCallError::NotStarted { source, .. }) if source.kind() == std::io::ErrorKind::NotFound
712        ));
713        assert!(matches!(
714            last_commit,
715            Err(GitCallError::NotStarted { source, .. }) if source.kind() == std::io::ErrorKind::NotFound
716        ));
717    }
718
719    #[tokio::test]
720    async fn quotepath_is_turned_off_in_the_git_dir_given() {
721        let dir = tempfile::tempdir().unwrap();
722        git(dir.path(), &["init", "-q", "-b", "main"]);
723
724        disable_quotepath(&dir.path().join(".git")).await.unwrap();
725
726        assert_eq!(git(dir.path(), &["config", "core.quotepath"]), "off");
727    }
728
729    #[tokio::test]
730    async fn quotepath_in_a_folder_that_is_not_a_git_dir_is_refused() {
731        let dir = tempfile::tempdir().unwrap();
732
733        let outcome = disable_quotepath(dir.path()).await;
734
735        assert!(matches!(
736            outcome,
737            Err(GitCallError::Refused { command: "config", status, .. }) if status.code() == Some(128)
738        ));
739    }
740}