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