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}