Skip to main content

agentmux_srv/backend/fs_ops/
git.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! `fs.git_status`: what git says about the entries of a listed folder, for
5//! the Files pane's markers (modified, untracked, ignored...) and its status
6//! line (branch, ahead/behind).
7//!
8//! Running git inside a folder the user merely browsed must not run code the
9//! folder chose. A repository's own config can name programs that `git
10//! status` executes: `core.fsmonitor`, and a `filter.<name>.clean` (or
11//! `.process`) applied through its `.gitattributes` whenever a file's stat
12//! data differs from the index. Command-line `-c` settings win over every
13//! config file, so the fsmonitor is turned off and every filter the
14//! repository itself defines is blanked; the user's own system and global
15//! filters (Git LFS) are left alone. Submodules, which have configs of their
16//! own, are not entered. The status never writes the index
17//! (`--no-optional-locks`), prompts for nothing, and is killed after a few
18//! seconds on a huge repository.
19//!
20//! Spec: docs/specs/SPEC_FILE_BROWSER_PANE_2026_10_01.md §12 (Phase 2, git
21//! decorations).
22
23use std::collections::HashMap;
24use std::time::Duration;
25
26use agentmux_common::win32::NoWindow;
27
28use crate::backend::rpc_types::{FsGitEntry, FsGitState, FsGitStatus};
29
30use super::platform::display_path;
31
32/// A status that takes longer than this is abandoned: the pane shows no
33/// markers rather than waiting.
34const GIT_TIMEOUT: Duration = Duration::from_secs(4);
35
36/// Settings forced on every git run here, ahead of any config file.
37const SAFE_CONFIG: &[&str] = &[
38    // A repository's config could name a program to run (see module docs).
39    "-c",
40    "core.fsmonitor=false",
41    // Don't write the untracked cache into the repository.
42    "-c",
43    "core.untrackedCache=false",
44];
45
46/// `fs.git_status` for `raw` (a folder the pane listed).
47pub async fn git_status(raw: &str) -> FsGitStatus {
48    let dir = match super::resolve_request_path(raw).and_then(|p| p.canonicalize().map_err(|e| e.to_string())) {
49        Ok(d) if d.is_dir() => d,
50        _ => return FsGitStatus::default(),
51    };
52    let dir_arg = display_path(&dir);
53
54    // Where the folder sits in its repository ("" at the top). Failing here
55    // is the ordinary "not a repository", or no git at all: no markers.
56    let prefix = match run_git(&dir_arg, &["rev-parse", "--show-prefix"]).await {
57        Ok(out) => String::from_utf8_lossy(&out).trim_end_matches(['\r', '\n']).to_string(),
58        Err(_) => return FsGitStatus::default(),
59    };
60    let status = ["--ignored=matching", "--", "."];
61    let out = match safe_status_porcelain(&dir_arg, &status).await {
62        Ok(out) => out,
63        Err(StatusError::Settings(what)) => {
64            return FsGitStatus {
65                in_repo: true,
66                error: Some(format!("{what}, so git markers are off here.")),
67                ..Default::default()
68            };
69        }
70        Err(StatusError::Git(e)) => return FsGitStatus { in_repo: true, error: Some(e), ..Default::default() },
71    };
72    summarize(&parse_porcelain_v2(&out), &prefix)
73}
74
75/// Why [`safe_status_porcelain`] gave no status.
76#[derive(Debug)]
77pub(crate) enum StatusError {
78    /// The repository's settings couldn't be read safely: git status was
79    /// not run at all. The text completes "…, so …".
80    Settings(&'static str),
81    /// git itself failed or timed out, in a sentence.
82    Git(String),
83}
84
85/// `git status --porcelain=v2 -z --branch --untracked-files=normal
86/// --ignore-submodules=all <extra>` in `dir`, with the repository's own
87/// filters blanked (see module docs). Shared by the Files pane and the
88/// agents' work facts (`backend::work_facts`).
89pub(crate) async fn safe_status_porcelain(dir: &str, extra: &[&str]) -> Result<Vec<u8>, StatusError> {
90    // The repository's own filters, blanked for this run (see module docs).
91    // Fails closed: if the config can't be read, git status doesn't run
92    // (#4223): unblanked filters would run.
93    let Ok(filters) = repo_filters(dir).await else {
94        return Err(StatusError::Settings("Couldn't read this repository's settings"));
95    };
96    // `-c` splits at the first `=`, so a filter named `x=y` can't be
97    // blanked that way (#4223). Such a name is never needed: don't run git
98    // there at all.
99    if filters.iter().any(|n| n.contains('=')) {
100        return Err(StatusError::Settings("This repository's settings can't be read safely"));
101    }
102    let mut args: Vec<String> = Vec::new();
103    for name in filters {
104        for key in ["clean", "smudge", "process"] {
105            args.push("-c".into());
106            args.push(format!("filter.{name}.{key}="));
107        }
108        args.push("-c".into());
109        args.push(format!("filter.{name}.required=false"));
110    }
111    args.extend(
112        ["status", "--porcelain=v2", "-z", "--branch", "--untracked-files=normal", "--ignore-submodules=all"]
113            .map(String::from),
114    );
115    args.extend(extra.iter().map(|s| s.to_string()));
116    let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
117    run_git(dir, &arg_refs).await.map_err(|e| StatusError::Git(e.message()))
118}
119
120/// Run a read-only git command (`rev-parse`, `remote get-url`) in `dir` with
121/// the same safe settings and timeout as the status above: its trimmed
122/// stdout, or `None` on any failure.
123pub(crate) async fn safe_git_line(dir: &str, args: &[&str]) -> Option<String> {
124    let out = run_git(dir, args).await.ok()?;
125    let line = String::from_utf8_lossy(&out).trim_end_matches(['\r', '\n']).to_string();
126    (!line.is_empty()).then_some(line)
127}
128
129/// The names of the filters a repository's own config defines (its
130/// `.git/config`, worktree config, and what they include): every
131/// `filter.<name>.*` key whose scope isn't the user's system or global
132/// config. An older git without `--show-scope` gets every filter blanked.
133/// `Err` when the config couldn't be read: the caller must not run git
134/// status then. Only `--get-regexp`'s exit 1 ("no match") means none.
135async fn repo_filters(dir: &str) -> Result<Vec<String>, ()> {
136    let scoped = run_git(dir, &["config", "--show-scope", "--name-only", "--get-regexp", r"^filter\."]).await;
137    let (out, scoped) = match scoped {
138        Ok(out) => (out, true),
139        Err(GitError::Exit(1)) => return Ok(Vec::new()),
140        // An older git without `--show-scope`: every filter, unscoped.
141        Err(_) => match run_git(dir, &["config", "--name-only", "--get-regexp", r"^filter\."]).await {
142            Ok(out) => (out, false),
143            Err(GitError::Exit(1)) => return Ok(Vec::new()),
144            Err(_) => return Err(()),
145        },
146    };
147    Ok(filter_names(&String::from_utf8_lossy(&out), scoped))
148}
149
150/// Filter names from `git config [--show-scope] --name-only --get-regexp
151/// ^filter\.` output; with scopes, only those outside system and global.
152pub fn filter_names(out: &str, scoped: bool) -> Vec<String> {
153    let mut names: Vec<String> = Vec::new();
154    for line in out.lines() {
155        let (scope, key) = if scoped {
156            match line.split_once(|c: char| c.is_whitespace()) {
157                Some((s, k)) => (s, k.trim()),
158                None => continue,
159            }
160        } else {
161            ("", line.trim())
162        };
163        if scoped && (scope == "system" || scope == "global") {
164            continue;
165        }
166        // filter.<name>.<key>; a name may itself contain dots.
167        let Some(rest) = key.strip_prefix("filter.") else { continue };
168        let Some((name, _)) = rest.rsplit_once('.') else { continue };
169        if !name.is_empty() && !names.iter().any(|n| n == name) {
170            names.push(name.to_string());
171        }
172    }
173    names
174}
175
176/// Run git in `dir` with the safe settings; its stdout, or a sentence.
177async fn run_git(dir: &str, args: &[&str]) -> Result<Vec<u8>, GitError> {
178    let mut cmd = tokio::process::Command::new("git");
179    cmd.arg("-C").arg(dir).args(SAFE_CONFIG).arg("--no-optional-locks").args(args);
180    cmd.env("GIT_OPTIONAL_LOCKS", "0").env("GIT_TERMINAL_PROMPT", "0");
181    cmd.stdin(std::process::Stdio::null())
182        .stdout(std::process::Stdio::piped())
183        .stderr(std::process::Stdio::piped())
184        .kill_on_drop(true)
185        .no_window();
186    // git needs nothing of this instance's identity.
187    crate::backend::pane_env::sanitize_external_command(&mut cmd);
188    let child = cmd.spawn().map_err(|e| GitError::Failed(format!("Couldn't run git: {e}")))?;
189    match tokio::time::timeout(GIT_TIMEOUT, child.wait_with_output()).await {
190        Err(_) => Err(GitError::Failed("git took too long to answer.".to_string())),
191        Ok(Err(e)) => Err(GitError::Failed(format!("git failed: {e}"))),
192        Ok(Ok(out)) if out.status.success() => Ok(out.stdout),
193        Ok(Ok(out)) => match out.status.code() {
194            Some(code) if out.stderr.is_empty() => Err(GitError::Exit(code)),
195            _ => Err(GitError::Failed(String::from_utf8_lossy(&out.stderr).trim().to_string())),
196        },
197    }
198}
199
200#[derive(Debug)]
201enum GitError {
202    /// git exited with this code and said nothing.
203    Exit(i32),
204    Failed(String),
205}
206
207impl GitError {
208    fn message(self) -> String {
209        match self {
210            GitError::Exit(code) => format!("git exited with status {code}."),
211            GitError::Failed(m) => m,
212        }
213    }
214}
215
216/// One path git reported, relative to the repository's top.
217#[derive(Debug, Clone, PartialEq, Eq)]
218pub struct GitRecord {
219    pub path: String,
220    pub state: FsGitState,
221}
222
223/// What `git status --porcelain=v2 -z --branch` printed.
224#[derive(Debug, Default, PartialEq, Eq)]
225pub struct Porcelain {
226    pub branch: Option<String>,
227    pub ahead: Option<u64>,
228    pub behind: Option<u64>,
229    pub records: Vec<GitRecord>,
230}
231
232/// The state an ordinary (`1`) or rename/copy (`2`) record's XY code means:
233/// the most pressing of the staged (X) and unstaged (Y) changes.
234fn state_of_xy(xy: &str) -> FsGitState {
235    let mut state = FsGitState::Added;
236    let mut seen = false;
237    for c in xy.chars() {
238        let s = match c {
239            'M' | 'T' => FsGitState::Modified,
240            'D' => FsGitState::Deleted,
241            'R' | 'C' => FsGitState::Renamed,
242            'A' => FsGitState::Added,
243            _ => continue,
244        };
245        state = if seen { state.max(s) } else { s };
246        seen = true;
247    }
248    state
249}
250
251/// Parse porcelain v2 output (`-z`: NUL-terminated records; a rename's
252/// original path follows it as its own NUL-terminated field).
253pub fn parse_porcelain_v2(out: &[u8]) -> Porcelain {
254    let mut p = Porcelain::default();
255    let mut fields = out.split(|b| *b == 0).map(|f| String::from_utf8_lossy(f).into_owned());
256    while let Some(line) = fields.next() {
257        if line.is_empty() {
258            continue;
259        }
260        let kind = line.as_bytes()[0];
261        match kind {
262            b'#' => {
263                if let Some(head) = line.strip_prefix("# branch.head ") {
264                    if head != "(detached)" {
265                        p.branch = Some(head.to_string());
266                    }
267                } else if let Some(ab) = line.strip_prefix("# branch.ab ") {
268                    let mut it = ab.split(' ');
269                    p.ahead = it.next().and_then(|a| a.trim_start_matches('+').parse().ok());
270                    p.behind = it.next().and_then(|b| b.trim_start_matches('-').parse().ok());
271                }
272            }
273            b'1' => {
274                // 1 XY sub mH mI mW hH hI path
275                let parts: Vec<&str> = line.splitn(9, ' ').collect();
276                if parts.len() == 9 {
277                    p.records.push(GitRecord { path: parts[8].to_string(), state: state_of_xy(parts[1]) });
278                }
279            }
280            b'2' => {
281                // 2 XY sub mH mI mW hH hI Xscore path, then the original path.
282                let parts: Vec<&str> = line.splitn(10, ' ').collect();
283                let _orig = fields.next();
284                if parts.len() == 10 {
285                    p.records.push(GitRecord { path: parts[9].to_string(), state: state_of_xy(parts[1]).max(FsGitState::Renamed) });
286                }
287            }
288            b'u' => {
289                // u XY sub m1 m2 m3 mW h1 h2 h3 path
290                let parts: Vec<&str> = line.splitn(11, ' ').collect();
291                if parts.len() == 11 {
292                    p.records.push(GitRecord { path: parts[10].to_string(), state: FsGitState::Conflicted });
293                }
294            }
295            b'?' | b'!' => {
296                if let Some(path) = line.get(2..) {
297                    let state = if kind == b'?' { FsGitState::Untracked } else { FsGitState::Ignored };
298                    p.records.push(GitRecord { path: path.to_string(), state });
299                }
300            }
301            _ => {}
302        }
303    }
304    p
305}
306
307/// Fold the records under `prefix` (the listed folder, relative to the
308/// repository's top, `/`-separated with a trailing `/`, or empty) into one
309/// state per direct child. A folder takes the most pressing state of what's
310/// inside it, except that a folder is only "ignored" when git ignores the
311/// folder itself.
312pub fn summarize(p: &Porcelain, prefix: &str) -> FsGitStatus {
313    let mut by_child: HashMap<String, FsGitState> = HashMap::new();
314    let mut changes = 0u64;
315    for r in &p.records {
316        let Some(rel) = r.path.strip_prefix(prefix) else { continue };
317        let rel = rel.trim_end_matches('/');
318        if rel.is_empty() {
319            continue;
320        }
321        if r.state != FsGitState::Ignored {
322            changes += 1;
323        }
324        let (child, nested) = match rel.split_once('/') {
325            Some((c, _)) => (c, true),
326            None => (rel, false),
327        };
328        // Something ignored deep inside doesn't make its folder look ignored.
329        if nested && r.state == FsGitState::Ignored {
330            continue;
331        }
332        by_child.entry(child.to_string()).and_modify(|s| *s = (*s).max(r.state)).or_insert(r.state);
333    }
334    let mut entries: Vec<FsGitEntry> = by_child.into_iter().map(|(name, state)| FsGitEntry { name, state }).collect();
335    entries.sort_by(|a, b| a.name.cmp(&b.name));
336    FsGitStatus {
337        in_repo: true,
338        branch: p.branch.clone(),
339        ahead: p.ahead,
340        behind: p.behind,
341        entries,
342        changes,
343        error: None,
344    }
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350
351    fn z(records: &[&str]) -> Vec<u8> {
352        let mut out = Vec::new();
353        for r in records {
354            out.extend_from_slice(r.as_bytes());
355            out.push(0);
356        }
357        out
358    }
359
360    #[test]
361    fn parses_branch_ahead_and_behind() {
362        let p = parse_porcelain_v2(&z(&["# branch.oid abc", "# branch.head main", "# branch.upstream origin/main", "# branch.ab +2 -5"]));
363        assert_eq!(p.branch.as_deref(), Some("main"));
364        assert_eq!((p.ahead, p.behind), (Some(2), Some(5)));
365        let detached = parse_porcelain_v2(&z(&["# branch.head (detached)"]));
366        assert_eq!(detached.branch, None);
367    }
368
369    #[test]
370    fn parses_every_record_kind_including_spaces_and_renames() {
371        let out = z(&[
372            "1 .M N... 100644 100644 100644 aaa bbb src/a file.ts",
373            "1 A. N... 000000 100644 100644 000 ccc new.ts",
374            "1 D. N... 100644 000000 000000 ddd 000 gone.ts",
375            "2 R. N... 100644 100644 100644 eee eee R100 docs/new name.md",
376            "docs/old name.md",
377            "u UU N... 100644 100644 100644 100644 a b c conflict.rs",
378            "? notes.txt",
379            "! target/",
380        ]);
381        let p = parse_porcelain_v2(&out);
382        let got: Vec<(&str, FsGitState)> = p.records.iter().map(|r| (r.path.as_str(), r.state)).collect();
383        assert_eq!(
384            got,
385            vec![
386                ("src/a file.ts", FsGitState::Modified),
387                ("new.ts", FsGitState::Added),
388                ("gone.ts", FsGitState::Deleted),
389                ("docs/new name.md", FsGitState::Renamed),
390                ("conflict.rs", FsGitState::Conflicted),
391                ("notes.txt", FsGitState::Untracked),
392                ("target/", FsGitState::Ignored),
393            ]
394        );
395    }
396
397    #[test]
398    fn a_staged_add_with_later_edits_reads_as_modified() {
399        assert_eq!(state_of_xy("AM"), FsGitState::Modified);
400        assert_eq!(state_of_xy("MD"), FsGitState::Modified);
401        assert_eq!(state_of_xy("R."), FsGitState::Renamed);
402    }
403
404    #[test]
405    fn folds_records_into_the_listed_folders_children() {
406        let p = parse_porcelain_v2(&z(&[
407            "# branch.head main",
408            "1 .M N... 1 1 1 a b app/src/x.ts",
409            "? app/src/new/",
410            "1 .M N... 1 1 1 a b app/README.md",
411            "! app/node_modules/",
412            "! app/src/cache/",
413            "? app/notes.txt",
414            "1 .M N... 1 1 1 a b other/y.ts",
415        ]));
416        let s = summarize(&p, "app/");
417        let got: Vec<(&str, FsGitState)> = s.entries.iter().map(|e| (e.name.as_str(), e.state)).collect();
418        assert_eq!(
419            got,
420            vec![
421                ("README.md", FsGitState::Modified),
422                ("node_modules", FsGitState::Ignored),
423                ("notes.txt", FsGitState::Untracked),
424                // Modified beats untracked; the ignored cache inside doesn't count.
425                ("src", FsGitState::Modified),
426            ]
427        );
428        assert_eq!(s.changes, 4);
429        assert_eq!(s.branch.as_deref(), Some("main"));
430    }
431
432    #[test]
433    fn at_the_top_the_prefix_is_empty() {
434        let p = parse_porcelain_v2(&z(&["? a.txt", "1 .M N... 1 1 1 a b dir/b.txt"]));
435        let s = summarize(&p, "");
436        assert_eq!(s.entries.len(), 2);
437    }
438
439    /// The real thing, when git is installed: a repo whose config asks for an
440    /// fsmonitor program gets none run, and the markers come back.
441    #[tokio::test]
442    async fn runs_git_without_the_repositorys_fsmonitor() {
443        let dir = tempfile::tempdir().unwrap();
444        let git = |args: &[&str]| {
445            std::process::Command::new("git")
446                .args(["-c", "user.email=a@b", "-c", "user.name=a"])
447                .args(args)
448                .current_dir(dir.path())
449                .no_window()
450                .output()
451        };
452        if git(&["init", "-q"]).map(|o| !o.status.success()).unwrap_or(true) {
453            return; // No git on this machine.
454        }
455        std::fs::write(dir.path().join("tracked.txt"), "a").unwrap();
456        let _ = git(&["add", "."]);
457        let _ = git(&["commit", "-qm", "x"]);
458        std::fs::write(dir.path().join("tracked.txt"), "b").unwrap();
459        std::fs::write(dir.path().join("new.txt"), "n").unwrap();
460        // A program git would run for core.fsmonitor: it would leave a mark.
461        let mark = dir.path().join("fsmonitor-ran");
462        let _ = git(&["config", "core.fsmonitor", &format!("touch '{}'", mark.display())]);
463
464        let s = git_status(&dir.path().to_string_lossy()).await;
465        assert!(s.in_repo, "{s:?}");
466        let get = |n: &str| s.entries.iter().find(|e| e.name == n).map(|e| e.state);
467        assert_eq!(get("tracked.txt"), Some(FsGitState::Modified));
468        assert_eq!(get("new.txt"), Some(FsGitState::Untracked));
469        assert!(!mark.exists(), "the repository's fsmonitor program ran");
470    }
471
472    #[test]
473    fn blanks_the_repositorys_filters_and_keeps_the_users() {
474        let out = "system\tfilter.lfs.clean\nglobal\tfilter.lfs.process\nlocal\tfilter.evil.clean\nlocal\tfilter.evil.process\nworktree\tfilter.a.b.smudge\n";
475        assert_eq!(filter_names(out, true), vec!["evil".to_string(), "a.b".to_string()]);
476        // Without scopes (an older git), every filter.
477        assert_eq!(filter_names("filter.lfs.clean\nfilter.x.process\n", false), vec!["lfs".to_string(), "x".to_string()]);
478    }
479
480    /// The other route git status has to a repository's code: a clean filter
481    /// named in `.git/config` and applied by `.gitattributes`, run when a
482    /// file's stat data differs from the index. ReAgent on #4223.
483    #[tokio::test]
484    async fn runs_git_without_the_repositorys_clean_filter() {
485        let dir = tempfile::tempdir().unwrap();
486        let git = |args: &[&str]| {
487            std::process::Command::new("git")
488                .args(["-c", "user.email=a@b", "-c", "user.name=a"])
489                .args(args)
490                .current_dir(dir.path())
491                .no_window()
492                .output()
493        };
494        if git(&["init", "-q"]).map(|o| !o.status.success()).unwrap_or(true) {
495            return; // No git on this machine.
496        }
497        std::fs::write(dir.path().join(".gitattributes"), "*.txt filter=evil\n").unwrap();
498        std::fs::write(dir.path().join("t.txt"), "a\n").unwrap();
499        let _ = git(&["add", "."]);
500        let _ = git(&["commit", "-qm", "x"]);
501        let mark = dir.path().join("filter-ran");
502        let mark_arg = mark.display().to_string().replace('\\', "/");
503        let _ = git(&["config", "filter.evil.clean", &format!("sh -c 'touch \"{mark_arg}\"; cat'")]);
504        // New stat data, same content: git must look at the content, through
505        // the filter if it were allowed to.
506        std::thread::sleep(std::time::Duration::from_millis(1100));
507        std::fs::write(dir.path().join("t.txt"), "a\n").unwrap();
508
509        let s = git_status(&dir.path().to_string_lossy()).await;
510        assert!(s.in_repo && s.error.is_none(), "{s:?}");
511        assert!(!mark.exists(), "the repository's clean filter ran");
512    }
513
514    /// `-c filter.x=y.clean=` would set `filter.x`, not blank `x=y`'s clean
515    /// command: a repository with such a filter gets no git run at all.
516    #[tokio::test]
517    async fn refuses_a_filter_name_that_c_cannot_blank() {
518        let dir = tempfile::tempdir().unwrap();
519        let git = |args: &[&str]| {
520            std::process::Command::new("git")
521                .args(["-c", "user.email=a@b", "-c", "user.name=a"])
522                .args(args)
523                .current_dir(dir.path())
524                .no_window()
525                .output()
526        };
527        if git(&["init", "-q"]).map(|o| !o.status.success()).unwrap_or(true) {
528            return; // No git on this machine.
529        }
530        std::fs::write(dir.path().join(".gitattributes"), "*.txt filter=x=y\n").unwrap();
531        std::fs::write(dir.path().join("t.txt"), "a\n").unwrap();
532        let _ = git(&["add", "."]);
533        let _ = git(&["commit", "-qm", "x"]);
534        let mark = dir.path().join("filter-ran");
535        let mark_arg = mark.display().to_string().replace('\\', "/");
536        let _ = git(&["config", "filter.x=y.clean", &format!("sh -c 'touch \"{mark_arg}\"; cat'")]);
537        std::thread::sleep(std::time::Duration::from_millis(1100));
538        std::fs::write(dir.path().join("t.txt"), "a\n").unwrap();
539
540        let s = git_status(&dir.path().to_string_lossy()).await;
541        assert!(s.entries.is_empty() && s.error.is_some(), "{s:?}");
542        assert!(!mark.exists(), "the repository's clean filter ran");
543    }
544
545    /// A config git can't parse makes the filter lookup fail: then git
546    /// status must not run (it would run with nothing blanked).
547    #[tokio::test]
548    async fn an_unreadable_config_turns_markers_off() {
549        let dir = tempfile::tempdir().unwrap();
550        let ok = std::process::Command::new("git").args(["init", "-q"]).current_dir(dir.path()).no_window().output();
551        if ok.map(|o| !o.status.success()).unwrap_or(true) {
552            return; // No git on this machine.
553        }
554        let config = dir.path().join(".git").join("config");
555        let mut text = std::fs::read_to_string(&config).unwrap();
556        text.push_str("\n[filter \"evil\"\n\tclean = broken\n");
557        std::fs::write(&config, text).unwrap();
558        // git refuses the repository at the first step, or the filter lookup
559        // fails: either way no status runs and there are no markers.
560        let s = git_status(&dir.path().to_string_lossy()).await;
561        assert!(s.entries.is_empty(), "{s:?}");
562        assert!(!s.in_repo || s.error.is_some(), "{s:?}");
563    }
564
565    #[tokio::test]
566    async fn a_folder_outside_any_repository_has_no_markers() {
567        let dir = tempfile::tempdir().unwrap();
568        let s = git_status(&dir.path().to_string_lossy()).await;
569        // A temp dir can sit inside a repository on some machines; then it has
570        // markers, which is also right. What must hold: no error either way.
571        assert!(s.error.is_none(), "{s:?}");
572    }
573}