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