Skip to main content

fluidattacks_core_git/
cli.rs

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