Skip to main content

majordomus_cli/git/
mod.rs

1//! A minimal, optional, read-only view of git.
2//!
3//! Where the work tree is, what HEAD is, and which tracked files match a pathspec — and
4//! nothing else. Everything shells out to the `git` binary; no library is linked, nothing
5//! is written, and GitHub is not involved at any point.
6//!
7//! # Optional means optional
8//!
9//! Every function here answers when git is absent, when the directory is not a repository,
10//! and when the repository has no commits. [`inspect`] returns a [`GitState`] that says
11//! which of those it is rather than an error, because a repository of the layer does not
12//! have to be version controlled — discovery falls back to a filesystem walk with the same
13//! glob semantics, and the index says which mode it used.
14//!
15//! ```
16//! use majordomus_cli::git::{inspect, GitState};
17//!
18//! // a directory that is not a work tree is a state with a reason, not a failure
19//! let plain = tempfile::tempdir().unwrap();
20//! match inspect(plain.path()) {
21//!     GitState::Unavailable { reason } => assert!(!reason.is_empty(), "it says why"),
22//!     GitState::Available(_) => panic!("a fresh temporary directory is not a work tree"),
23//! }
24//! ```
25
26use std::path::{Path, PathBuf};
27use std::process::Command;
28
29use schemars::JsonSchema;
30use serde::{Deserialize, Serialize};
31
32use crate::error::{Error, Result};
33
34/// What git says about the repository, or why it could not be asked.
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
36#[serde(tag = "state", rename_all = "lowercase")]
37pub enum GitState {
38    /// `git` answered; the facts follow.
39    Available(GitInfo),
40    /// `git` could not be asked, or the root is not a work tree; the reason says which.
41    Unavailable {
42        /// What `git` said, or why it could not be run.
43        reason: String,
44    },
45}
46
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
48/// What `git` said about the repository.
49pub struct GitInfo {
50    /// Absolute path of the work tree top level.
51    pub toplevel: PathBuf,
52    /// Full commit id of HEAD, or `None` in an unborn repository.
53    pub head: Option<String>,
54    /// Branch name, or `None` when detached or unborn.
55    pub branch: Option<String>,
56    /// `clean` or `dirty`, from `git status --porcelain`.
57    pub working_tree: String,
58}
59
60/// Ask git about `root`. Never fails: a missing `git` or a directory that is not a work
61/// tree becomes [`GitState::Unavailable`] with the reason.
62pub fn inspect(root: &Path) -> GitState {
63    let toplevel = match run(root, &["rev-parse", "--show-toplevel"]) {
64        Ok(s) => PathBuf::from(s.trim_end()),
65        Err(e) => {
66            return GitState::Unavailable {
67                reason: e.to_string(),
68            }
69        }
70    };
71    let head = run(root, &["rev-parse", "--verify", "-q", "HEAD"])
72        .ok()
73        .map(|s| s.trim().to_string());
74    let branch = run(root, &["symbolic-ref", "-q", "--short", "HEAD"])
75        .ok()
76        .map(|s| s.trim().to_string());
77    let working_tree = match run(root, &["status", "--porcelain"]) {
78        Ok(s) if s.trim().is_empty() => "clean",
79        Ok(_) => "dirty",
80        Err(_) => "unknown",
81    }
82    .to_string();
83    GitState::Available(GitInfo {
84        toplevel,
85        head,
86        branch,
87        working_tree,
88    })
89}
90
91/// `clean`, `dirty` or `unknown` as [`inspect`] decides it, except that a change to one of
92/// the given repository-relative paths does not make the tree dirty.
93///
94/// One caller needs this: recording a run writes the evidence ledger, so the second
95/// recording in a session would be stamped `dirty` by its predecessor's own bookkeeping —
96/// about a tree in which nothing the run measured had changed. "Dirty only because of the
97/// ledger" is not dirty; every other path still is.
98///
99/// ```
100/// use majordomus_cli::git::working_tree_ignoring;
101/// use std::process::Command;
102///
103/// let dir = tempfile::tempdir().unwrap();
104/// let git = |args: &[&str]| {
105///     Command::new("git").arg("-C").arg(dir.path()).args(args).output().unwrap()
106/// };
107/// git(&["init", "-q"]);
108/// git(&["config", "user.email", "t@example.com"]);
109/// git(&["config", "user.name", "t"]);
110/// std::fs::write(dir.path().join("ledger.json"), "{}").unwrap();
111/// std::fs::write(dir.path().join("a.md"), "a").unwrap();
112/// git(&["add", "-A"]);
113/// git(&["commit", "-qm", "init"]);
114///
115/// std::fs::write(dir.path().join("ledger.json"), "{\"v\":1}").unwrap();
116/// assert_eq!(working_tree_ignoring(dir.path(), &["ledger.json"]), "clean");
117///
118/// // anything else is still dirty
119/// std::fs::write(dir.path().join("a.md"), "b").unwrap();
120/// assert_eq!(working_tree_ignoring(dir.path(), &["ledger.json"]), "dirty");
121/// ```
122pub fn working_tree_ignoring(root: &Path, ignore: &[&str]) -> String {
123    working_tree_excluding(root, ignore, &[])
124}
125
126/// `clean`, `dirty` or `unknown`, as [`working_tree_ignoring`] decides it, except that an
127/// untracked path equal to one of `outputs`, or under one of them, is not a change either.
128/// An output never hides a tracked change.
129///
130/// This is the one tree measurement: a run's stamp and the presented tree both go through
131/// it. A run writes its own report and build products into the checkout; those are not a
132/// change to what it tested, but only while they are untracked. A tracked file under an
133/// output is still a change, so an output cannot be used to make a modified checkout read
134/// clean. Outputs are compared by path segment (`dist` covers `dist/out.bin`, never
135/// `distx/y`), after a leading `./` and a trailing `/` are removed; no pathspec is ever
136/// built from them.
137///
138/// ```
139/// use majordomus_cli::git::working_tree_excluding;
140/// use std::process::Command;
141///
142/// let dir = tempfile::tempdir().unwrap();
143/// let git = |args: &[&str]| {
144///     Command::new("git").arg("-C").arg(dir.path()).args(args).output().unwrap()
145/// };
146/// git(&["init", "-q"]);
147/// git(&["config", "user.email", "t@example.com"]);
148/// git(&["config", "user.name", "t"]);
149/// std::fs::write(dir.path().join("a.md"), "a").unwrap();
150/// git(&["add", "-A"]);
151/// git(&["commit", "-qm", "init"]);
152///
153/// std::fs::create_dir(dir.path().join("dist")).unwrap();
154/// std::fs::write(dir.path().join("dist/out.bin"), "x").unwrap();
155/// assert_eq!(working_tree_excluding(dir.path(), &[], &["dist"]), "clean");
156/// assert_eq!(working_tree_excluding(dir.path(), &[], &[]), "dirty");
157///
158/// // an output never hides a tracked change
159/// std::fs::write(dir.path().join("a.md"), "b").unwrap();
160/// assert_eq!(working_tree_excluding(dir.path(), &[], &["dist", "a.md"]), "dirty");
161/// ```
162pub fn working_tree_excluding(root: &Path, ignore: &[&str], outputs: &[&str]) -> String {
163    let outputs: Vec<&str> = outputs.iter().map(|o| normalise_output(o)).collect();
164    let is_output = |path: &str| {
165        outputs.iter().any(|o| {
166            !o.is_empty()
167                && (path == *o || path.strip_prefix(o).is_some_and(|r| r.starts_with('/')))
168        })
169    };
170    // `--untracked-files=all` because the default collapses an untracked directory to the
171    // directory's own name: a repository that does not yet track its ledger would be
172    // reported dirty for `.ai/`, which is not a path any caller can name.
173    match run(root, &["status", "--porcelain", "--untracked-files=all"]) {
174        Ok(s) => {
175            let change = s.lines().filter(|l| !l.trim().is_empty()).any(|l| {
176                let path = porcelain_path(l);
177                let ignored = ignore.contains(&path);
178                let untracked_output = l.starts_with("??") && is_output(path);
179                !(ignored || untracked_output)
180            });
181            if change {
182                "dirty"
183            } else {
184                "clean"
185            }
186        }
187        Err(_) => "unknown",
188    }
189    .to_string()
190}
191
192/// An output as the caller wrote it, without any leading `./` or trailing `/`.
193fn normalise_output(output: &str) -> &str {
194    let mut o = output;
195    while let Some(rest) = o.strip_prefix("./") {
196        o = rest;
197    }
198    o.trim_end_matches('/')
199}
200
201/// The repository-relative path of one `git status --porcelain` line: two status columns, a
202/// space, then the path — or `orig -> new` for a rename, of which the new name is the one
203/// the tree carries.
204fn porcelain_path(line: &str) -> &str {
205    let rest = line.get(3..).unwrap_or("").trim();
206    match rest.split_once(" -> ") {
207        Some((_, new)) => new.trim_matches('"'),
208        None => rest.trim_matches('"'),
209    }
210}
211
212/// Every tracked file under `root`, repository-relative, in the byte order the index keeps
213/// them in: one subprocess per build, matched against each class's pathspec in process.
214pub fn ls_files_all(root: &Path) -> Result<Vec<String>> {
215    ls_files_with(root, &[])
216}
217
218/// Tracked files under `root` matching one pathspec, repository-relative, in the byte order
219/// the index keeps them in. The pathspec is passed to git verbatim.
220pub fn ls_files(root: &Path, pathspec: &str) -> Result<Vec<String>> {
221    ls_files_with(root, &[pathspec])
222}
223
224/// Tracked files under `root` matching any of several pathspecs, repository-relative, in
225/// the byte order the index keeps them in — one subprocess, one pass, each file once.
226///
227/// Asking per pathspec and concatenating would give neither: a file two pathspecs both
228/// select would appear twice, and the order would be the caller's rather than git's. A
229/// hash taken over the result depends on both, which is why this exists as its own call.
230///
231/// ```
232/// use majordomus_cli::git::ls_files_any;
233/// use std::process::Command;
234///
235/// let dir = tempfile::tempdir().unwrap();
236/// let git = |args: &[&str]| {
237///     Command::new("git").arg("-C").arg(dir.path()).args(args).output().unwrap()
238/// };
239/// git(&["init", "-q"]);
240/// std::fs::create_dir(dir.path().join("lib")).unwrap();
241/// std::fs::write(dir.path().join("lib/b.sh"), "b").unwrap();
242/// std::fs::write(dir.path().join("a.md"), "a").unwrap();
243/// git(&["add", "-A"]);
244///
245/// // git's own index order, and each file once however many pathspecs select it
246/// let files = ls_files_any(dir.path(), &["a.md", "lib/**", "*.md"]).unwrap();
247/// assert_eq!(files, vec!["a.md".to_string(), "lib/b.sh".to_string()]);
248/// ```
249pub fn ls_files_any(root: &Path, pathspecs: &[&str]) -> Result<Vec<String>> {
250    ls_files_with(root, pathspecs)
251}
252
253/// A `git` invocation against `root` that only ever reads: the one constructor every
254/// read in this crate goes through.
255///
256/// # Why a reader needs a constructor at all
257///
258/// `git status` refreshes the staging index as a side effect — when an entry's recorded
259/// stat data no longer matches the file, or is *racily* close to the index's own
260/// modification time, git rewrites `.git/index` with the refreshed data. Nothing about
261/// the repository changed; the reader changed the file it read.
262///
263/// That file is one of the six [`crate::live::WORKTREE_FILES`] the shared server stamps to
264/// decide whether the repository has moved. So a served page that asked git for the state
265/// of the working tree moved the stamp, and the *next* request found a stamp it did not
266/// recognise and paid a whole [`crate::app::App::load`] — discovery, every declared file
267/// read and validated, the registry, the Why catalogue, the product model, the web
268/// topology — to rebuild a picture that was already current. An observer that disturbs
269/// what it observes, and then charges the next caller for the disturbance.
270///
271/// `GIT_OPTIONAL_LOCKS=0` is git's own name for this: complete the request without any
272/// optional sub-operation that would take a lock, which is exactly the index write-back.
273/// Locks a command genuinely requires — `commit`, `add` — are unaffected, so this is safe
274/// on any invocation and is not a reason to keep two constructors.
275///
276/// `GIT_DIR` and `GIT_WORK_TREE` are removed for a second reason: inherited from a hook's
277/// environment they would silently redirect `-C root` at another repository.
278///
279/// ```
280/// use majordomus_cli::git::read_only;
281/// use std::process::Command;
282///
283/// let dir = tempfile::tempdir().unwrap();
284/// let git = |args: &[&str]| {
285///     Command::new("git").arg("-C").arg(dir.path()).args(args).output().unwrap()
286/// };
287/// git(&["init", "-q"]);
288/// git(&["config", "user.email", "t@example.com"]);
289/// git(&["config", "user.name", "t"]);
290/// std::fs::write(dir.path().join("a"), "a").unwrap();
291/// git(&["add", "-A"]);
292/// git(&["commit", "-qm", "one"]);
293///
294/// // the same answer a plain `git status` gives, with the staging index left alone —
295/// // which is the whole difference, since that file is what the server watches
296/// let index = dir.path().join(".git/index");
297/// let before = std::fs::metadata(&index).unwrap().modified().unwrap();
298/// let out = read_only(dir.path()).args(["status", "--porcelain"]).output().unwrap();
299/// assert!(out.status.success());
300/// assert!(out.stdout.is_empty(), "a committed tree is clean");
301/// assert_eq!(
302///     std::fs::metadata(&index).unwrap().modified().unwrap(),
303///     before,
304///     "reading the working tree must not rewrite the staging index"
305/// );
306/// ```
307pub fn read_only(root: &Path) -> Command {
308    let mut cmd = Command::new("git");
309    cmd.env("GIT_OPTIONAL_LOCKS", "0");
310    cmd.env_remove("GIT_DIR");
311    cmd.env_remove("GIT_WORK_TREE");
312    cmd.arg("-C").arg(root);
313    cmd
314}
315
316fn ls_files_with(root: &Path, pathspecs: &[&str]) -> Result<Vec<String>> {
317    let out = read_only(root)
318        .args(["ls-files", "-z", "--"])
319        .args(pathspecs)
320        .output()
321        .map_err(|e| Error::Git {
322            reason: format!("cannot run git: {e}"),
323        })?;
324    if !out.status.success() {
325        return Err(Error::Git {
326            reason: format!(
327                "git ls-files -- {}: {}",
328                pathspecs.join(" "),
329                String::from_utf8_lossy(&out.stderr).trim()
330            ),
331        });
332    }
333    let mut files = Vec::new();
334    for raw in out.stdout.split(|b| *b == 0).filter(|s| !s.is_empty()) {
335        match std::str::from_utf8(raw) {
336            Ok(s) => files.push(s.to_string()),
337            Err(_) => {
338                return Err(Error::Git {
339                    reason: format!(
340                        "a tracked path is not UTF-8: {}",
341                        String::from_utf8_lossy(raw)
342                    ),
343                })
344            }
345        }
346    }
347    Ok(files)
348}
349
350/// Is `ancestor` reachable from `descendant`? `None` when git cannot answer at all — a
351/// missing executable, a commit this clone does not have — which is a third answer and not
352/// a `false`: "not an ancestor" and "unknown ancestry" lead a reader to different actions,
353/// and collapsing them is how a record from a rewritten history gets read as current.
354///
355/// Exposed rather than left to `run` because ancestry is the one git question the
356/// divergence label depends on, and a caller that had the whole command runner in order to
357/// ask it could ask anything.
358pub fn is_ancestor(root: &Path, ancestor: &str, descendant: &str) -> Option<bool> {
359    let out = read_only(root)
360        .args(["merge-base", "--is-ancestor", ancestor, descendant])
361        .output()
362        .ok()?;
363    match out.status.code() {
364        Some(0) => Some(true),
365        // 1 is "no". Anything else git exits with — 128 for an object this clone does not
366        // have, for instance — is also not a yes, and answering `None` there would be
367        // generous in the one direction that costs something: a record naming a commit
368        // this repository has never seen is exactly the record that must not be read as
369        // current. `None` is reserved for git not running at all.
370        Some(_) => Some(false),
371        None => None,
372    }
373}
374
375/// Whether one commit contains another: three answers, because "no" and "this clone cannot
376/// say" send a reader to different places.
377///
378/// Evidence recorded on a commit the presented revision does not contain is evidence about
379/// some other history, however empty a diff between the two happens to be. And a commit this
380/// clone does not have — a rewritten branch, a shallow fetch, a typo — is neither a yes nor a
381/// no, so it is not collapsed into either.
382///
383/// This is the one definition. PR #600 carries a private copy in `delivery::revision`, with
384/// the same variants and meaning; it imports this one and deletes its copy.
385///
386/// ```
387/// use majordomus_cli::git::Containment;
388///
389/// // only one of the three answers is a yes
390/// let answers = [
391///     Containment::Contains,
392///     Containment::DoesNotContain,
393///     Containment::CommitUnknown,
394/// ];
395/// assert_eq!(answers.iter().filter(|a| **a == Containment::Contains).count(), 1);
396/// assert_ne!(Containment::DoesNotContain, Containment::CommitUnknown);
397/// ```
398#[derive(Debug, Clone, Copy, PartialEq, Eq)]
399pub enum Containment {
400    /// The ancestor is reachable from the descendant.
401    Contains,
402    /// Both commits are known here, and the ancestor is not reachable from the descendant.
403    DoesNotContain,
404    /// This clone does not have one of the two commits, or git could not be asked, so the
405    /// question has no answer here.
406    CommitUnknown,
407}
408
409/// Does `rev` name a commit this clone has?
410///
411/// `git rev-parse --verify --quiet <rev>^{commit}`, through [`read_only`]: a branch, a tag, a
412/// full or abbreviated object name, `HEAD`. Forty hex digits that no object has do not
413/// resolve, which is the difference between a well-formed commit id and a commit.
414///
415/// ```
416/// use majordomus_cli::git::resolves;
417/// use std::process::Command;
418///
419/// let dir = tempfile::tempdir().unwrap();
420/// let git = |args: &[&str]| {
421///     Command::new("git").arg("-C").arg(dir.path()).args(args).output().unwrap()
422/// };
423/// git(&["init", "-q"]);
424/// // an unborn repository has no HEAD to name
425/// assert!(!resolves(dir.path(), "HEAD"));
426///
427/// git(&["config", "user.email", "t@example.com"]);
428/// git(&["config", "user.name", "t"]);
429/// git(&["commit", "-q", "--allow-empty", "-m", "one"]);
430/// assert!(resolves(dir.path(), "HEAD"));
431/// assert!(!resolves(dir.path(), &"0".repeat(40)), "well-formed is not the same as present");
432/// ```
433pub fn resolves(root: &Path, rev: &str) -> bool {
434    read_only(root)
435        .args([
436            "rev-parse",
437            "--verify",
438            "--quiet",
439            &format!("{rev}^{{commit}}"),
440        ])
441        .output()
442        .map(|o| o.status.success())
443        .unwrap_or(false)
444}
445
446/// Does `descendant` contain `ancestor`?
447///
448/// [`Containment::CommitUnknown`] unless both commits resolve here; otherwise
449/// [`is_ancestor`] decides. The resolution comes first because [`is_ancestor`] answers
450/// `Some(false)` for an object this clone does not have, which is the right answer for its
451/// own caller and the wrong one here: a record naming a commit nobody can find is not a
452/// record from a known other history.
453///
454/// ```
455/// use majordomus_cli::git::{contains, Containment};
456/// use std::process::Command;
457///
458/// let dir = tempfile::tempdir().unwrap();
459/// let git = |args: &[&str]| {
460///     let out = Command::new("git").arg("-C").arg(dir.path()).args(args).output().unwrap();
461///     String::from_utf8_lossy(&out.stdout).trim().to_string()
462/// };
463/// git(&["init", "-q"]);
464/// git(&["config", "user.email", "t@example.com"]);
465/// git(&["config", "user.name", "t"]);
466/// git(&["commit", "-q", "--allow-empty", "-m", "one"]);
467/// let old = git(&["rev-parse", "HEAD"]);
468/// git(&["commit", "-q", "--allow-empty", "-m", "two"]);
469/// let new = git(&["rev-parse", "HEAD"]);
470///
471/// assert_eq!(contains(dir.path(), &new, &old), Containment::Contains);
472/// assert_eq!(contains(dir.path(), &old, &new), Containment::DoesNotContain);
473/// // a commit contains itself
474/// assert_eq!(contains(dir.path(), &new, &new), Containment::Contains);
475/// assert_eq!(
476///     contains(dir.path(), &new, &"0".repeat(40)),
477///     Containment::CommitUnknown
478/// );
479/// ```
480pub fn contains(root: &Path, descendant: &str, ancestor: &str) -> Containment {
481    if !resolves(root, descendant) || !resolves(root, ancestor) {
482        return Containment::CommitUnknown;
483    }
484    // git not running at all is the one `None`, and it is not an answer either way
485    is_ancestor(root, ancestor, descendant).map_or(Containment::CommitUnknown, |yes| {
486        if yes {
487            Containment::Contains
488        } else {
489            Containment::DoesNotContain
490        }
491    })
492}
493
494fn run(root: &Path, args: &[&str]) -> Result<String> {
495    let out = read_only(root)
496        .args(args)
497        .output()
498        .map_err(|e| Error::Git {
499            reason: format!("cannot run git: {e}"),
500        })?;
501    if !out.status.success() {
502        return Err(Error::Git {
503            reason: format!(
504                "git {}: {}",
505                args.join(" "),
506                String::from_utf8_lossy(&out.stderr).trim()
507            ),
508        });
509    }
510    String::from_utf8(out.stdout).map_err(|e| Error::Git {
511        reason: e.to_string(),
512    })
513}
514
515#[cfg(test)]
516mod tests {
517    use super::*;
518    use std::process::Command;
519
520    fn repo(files: &[&str]) -> tempfile::TempDir {
521        let dir = tempfile::tempdir().unwrap();
522        let git = |args: &[&str]| {
523            let out = Command::new("git")
524                .arg("-C")
525                .arg(dir.path())
526                .args(args)
527                .output();
528            assert!(out.unwrap().status.success(), "git {args:?}");
529        };
530        git(&["init", "-q"]);
531        git(&["config", "user.email", "t@example.com"]);
532        git(&["config", "user.name", "t"]);
533        for f in files {
534            let p = dir.path().join(f);
535            std::fs::create_dir_all(p.parent().unwrap()).unwrap();
536            std::fs::write(p, "committed").unwrap();
537        }
538        git(&["add", "-A"]);
539        git(&["commit", "-qm", "init"]);
540        dir
541    }
542
543    fn write(dir: &tempfile::TempDir, path: &str) {
544        let p = dir.path().join(path);
545        std::fs::create_dir_all(p.parent().unwrap()).unwrap();
546        std::fs::write(p, "changed").unwrap();
547    }
548
549    #[test]
550    fn an_output_never_hides_a_tracked_change() {
551        let dir = repo(&["dist/keep.txt", "a.md"]);
552        write(&dir, "dist/keep.txt");
553        assert_eq!(working_tree_excluding(dir.path(), &[], &["dist"]), "dirty");
554        assert_eq!(
555            working_tree_excluding(dir.path(), &[], &["./dist/"]),
556            "dirty"
557        );
558        assert_eq!(
559            working_tree_excluding(dir.path(), &[], &["dist/keep.txt"]),
560            "dirty"
561        );
562    }
563
564    #[test]
565    fn an_untracked_output_directory_is_excluded_by_segment() {
566        let dir = repo(&["a.md"]);
567        write(&dir, "dist/out.bin");
568        assert_eq!(working_tree_excluding(dir.path(), &[], &["dist"]), "clean");
569        assert_eq!(
570            working_tree_excluding(dir.path(), &[], &["./dist/"]),
571            "clean"
572        );
573        write(&dir, "distx/y");
574        assert_eq!(working_tree_excluding(dir.path(), &[], &["dist"]), "dirty");
575        assert_eq!(
576            working_tree_excluding(dir.path(), &[], &["dist", "distx"]),
577            "clean"
578        );
579    }
580
581    #[test]
582    fn ignoring_is_excluding_with_no_outputs() {
583        let ledger = "ledger.json";
584        let states: [(&str, Option<&str>); 5] = [
585            ("clean", None),
586            ("a modified ledger", Some(ledger)),
587            ("a modified other file", Some("a.md")),
588            ("an untracked ledger", Some("sub/ledger.json")),
589            ("an untracked other file", Some("new.md")),
590        ];
591        for (what, change) in states {
592            let dir = repo(&[ledger, "a.md"]);
593            if let Some(path) = change {
594                write(&dir, path);
595            }
596            for ignore in [&[][..], &[ledger][..], &["sub/ledger.json"][..]] {
597                assert_eq!(
598                    working_tree_ignoring(dir.path(), ignore),
599                    working_tree_excluding(dir.path(), ignore, &[]),
600                    "{what}, ignoring {ignore:?}"
601                );
602            }
603        }
604    }
605
606    #[test]
607    fn a_path_that_is_not_a_work_tree_is_unknown() {
608        let dir = tempfile::tempdir().unwrap();
609        let missing = dir.path().join("absent");
610        assert_eq!(working_tree_excluding(&missing, &[], &["dist"]), "unknown");
611    }
612}