Skip to main content

majordomus_cli/worktree/
state.rs

1//! What git says about the state of a work tree and of every branch: uncommitted work from
2//! `status --porcelain`, and every local branch with its upstream, its distance from it and
3//! the work tree holding it from one `for-each-ref`. Nothing here fetches.
4
5use std::collections::BTreeSet;
6use std::path::{Path, PathBuf};
7use std::sync::atomic::{AtomicUsize, Ordering};
8use std::sync::mpsc;
9use std::time::SystemTime;
10
11use super::error::Result;
12use super::git;
13use super::model::{DirtyState, UpstreamState};
14
15/// Count the entries of `git status --porcelain -z` for a work tree. Untracked files are
16/// counted as files (`--untracked-files=all`), because a directory of untracked work is the
17/// thing a move must not lose and "1 untracked" for a directory of forty files would
18/// understate it.
19pub fn dirty_state(worktree: &Path) -> Result<DirtyState> {
20    let bytes = git::status_porcelain_z(worktree, "all")?;
21    let mut state = parse_status_z(&bytes);
22    state.in_progress = operation_in_progress(worktree);
23    Ok(state)
24}
25
26/// The dirty state of many work trees, measured at once.
27///
28/// Each measurement is a `git status` subprocess of a few tens of milliseconds and none of
29/// them depends on another, so they are taken on a small pool of threads rather than one
30/// after the next. The measurement that motivated this: on a repository with 128
31/// registered work trees the sequence cost 6.2–9.9 s (n=3, shared machine), which was
32/// most of what the whole topology cost.
33///
34/// A `None` in the input means "do not measure this one"; a `None` in the output means it
35/// was not measured, or git refused to say. The answers come back in the order the work
36/// trees were given, whatever order they finished in, so what this feeds is byte-identical
37/// to reading them one at a time.
38///
39/// ```
40/// use majordomus_cli::worktree::state::dirty_states;
41/// let repo = tempfile::tempdir().unwrap();
42/// let init = std::process::Command::new("git")
43///     .args(["init", "-q"])
44///     .current_dir(repo.path())
45///     .status()
46///     .unwrap();
47/// assert!(init.success());
48/// std::fs::write(repo.path().join("new.txt"), "work").unwrap();
49///
50/// let states = dirty_states(&[Some(repo.path()), None, Some(repo.path())]);
51/// assert_eq!(states.len(), 3);
52/// assert!(states[1].is_none(), "a None in is a None out");
53/// let first = states[0].as_ref().expect("a git work tree is measured");
54/// assert_eq!(first.untracked, 1);
55/// assert!(!first.clean);
56/// assert_eq!(states[2], states[0], "answers come back in input order");
57/// ```
58pub fn dirty_states(worktrees: &[Option<&Path>]) -> Vec<Option<DirtyState>> {
59    probe_all(worktrees, |p| dirty_state(p).ok())
60}
61
62/// Take one measurement of each work tree on the pool [`dirty_states`] describes, answering
63/// in the order the work trees were given. A `None` in is a `None` out; a `None` from
64/// `measure` is a work tree git would not answer for. The one pool, so that every reading
65/// that asks each work tree a question costs the machine the same.
66pub(crate) fn probe_all<T, F>(worktrees: &[Option<&Path>], measure: F) -> Vec<Option<T>>
67where
68    T: Send,
69    F: Fn(&Path) -> Option<T> + Sync,
70{
71    let mut out: Vec<Option<T>> = Vec::with_capacity(worktrees.len());
72    out.resize_with(worktrees.len(), || None);
73    let wanted: Vec<usize> = worktrees
74        .iter()
75        .enumerate()
76        .filter_map(|(i, w)| w.map(|_| i))
77        .collect();
78    // One measurement is not worth a thread, and none is not worth a channel.
79    if wanted.len() < 2 {
80        for i in wanted {
81            out[i] = worktrees[i].and_then(&measure);
82        }
83        return out;
84    }
85    let next = AtomicUsize::new(0);
86    let (tx, rx) = mpsc::channel();
87    std::thread::scope(|scope| {
88        for _ in 0..probe_threads(wanted.len()) {
89            let tx = tx.clone();
90            let next = &next;
91            let wanted = &wanted;
92            let measure = &measure;
93            scope.spawn(move || loop {
94                let Some(&i) = wanted.get(next.fetch_add(1, Ordering::Relaxed)) else {
95                    return;
96                };
97                let measured = worktrees[i].and_then(measure);
98                if tx.send((i, measured)).is_err() {
99                    return;
100                }
101            });
102        }
103        // The receiver below ends when the last sender is gone, so this one must go first.
104        drop(tx);
105        for (i, measured) in rx {
106            out[i] = measured;
107        }
108    });
109    out
110}
111
112/// Uncommitted work in a work tree, counted as [`dirty_state`] counts it, with the moment
113/// of its oldest change: the earliest modification time among the paths `git status`
114/// names. A changed path that no longer exists on disk — a deletion — has no time of its
115/// own, and the index file's modification time stands in for it, as it does for a work
116/// tree whose paths cannot be read at all. `None` only when neither can be read.
117///
118/// The status is the one [`dirty_state`] takes, under `GIT_OPTIONAL_LOCKS=0`,
119/// so measuring writes nothing: not the index, not a lock, not a stat cache.
120///
121/// ```
122/// use majordomus_cli::worktree::state::aged_dirty_state;
123/// let repo = tempfile::tempdir().unwrap();
124/// let init = std::process::Command::new("git")
125///     .args(["init", "-q"])
126///     .current_dir(repo.path())
127///     .status()
128///     .unwrap();
129/// assert!(init.success());
130/// let (clean, none) = aged_dirty_state(repo.path()).unwrap();
131/// assert!(clean.clean && none.is_none(), "a clean work tree has no oldest change");
132/// std::fs::write(repo.path().join("new.txt"), "work").unwrap();
133/// let (dirty, oldest) = aged_dirty_state(repo.path()).unwrap();
134/// assert_eq!(dirty.untracked, 1);
135/// assert!(oldest.is_some(), "the file's own time is the change's");
136/// ```
137pub fn aged_dirty_state(worktree: &Path) -> Result<(DirtyState, Option<SystemTime>)> {
138    let bytes = git::status_porcelain_z(worktree, "all")?;
139    let mut state = parse_status_z(&bytes);
140    state.in_progress = operation_in_progress(worktree);
141    if state.clean {
142        return Ok((state, None));
143    }
144    Ok((state, oldest_change(worktree, &bytes)))
145}
146
147/// The earliest modification time among the paths of a `status --porcelain -z` answer for
148/// `worktree`, ignored entries left out, with the index file's time standing in for a path
149/// that cannot be read — or for all of them. `None` only when neither can be read.
150fn oldest_change(worktree: &Path, bytes: &[u8]) -> Option<SystemTime> {
151    let mut oldest: Option<SystemTime> = None;
152    let mut unreadable = false;
153    for entry in bytes.split(|b| *b == 0).filter(|e| e.len() >= 4) {
154        if entry.starts_with(b"!!") {
155            continue;
156        }
157        let rel = String::from_utf8_lossy(&entry[3..]);
158        let rel = rel.trim_end_matches('/');
159        match std::fs::symlink_metadata(worktree.join(rel)).and_then(|m| m.modified()) {
160            Ok(t) => oldest = Some(oldest.map_or(t, |o| o.min(t))),
161            Err(_) => unreadable = true,
162        }
163    }
164    if unreadable || oldest.is_none() {
165        let index = git_dir_of(worktree)
166            .map(|d| d.join("index"))
167            .and_then(|i| std::fs::metadata(i).and_then(|m| m.modified()).ok());
168        if let Some(t) = index {
169            oldest = Some(oldest.map_or(t, |o| o.min(t)));
170        }
171    }
172    oldest
173}
174
175/// Which of these directories has a process working inside it right now, one answer per
176/// directory in the order given.
177///
178/// `lsof -a -d cwd` asks the kernel which processes have their working directory where,
179/// which is the only reading that is true at the moment it is taken: the modification time
180/// of a work tree is not, and two of the four swept on 2026-09-15 looked untouched since the
181/// 12th with live processes inside them. One `lsof` answers for every directory. `None` is
182/// not knowing — no `lsof`, or a directory that cannot be resolved — and never "nothing is
183/// running".
184///
185/// ```
186/// use majordomus_cli::worktree::state::processes_inside;
187/// let here = std::env::current_dir().unwrap();
188/// if let Some(answers) = processes_inside(&[here.as_path()]) {
189///     assert_eq!(answers, vec![true], "this process works inside its own directory");
190/// }
191/// ```
192pub fn processes_inside(dirs: &[&Path]) -> Option<Vec<bool>> {
193    let real: Vec<String> = dirs
194        .iter()
195        .map(|d| {
196            std::fs::canonicalize(d)
197                .ok()
198                .map(|p| p.to_string_lossy().to_string())
199        })
200        .collect::<Option<Vec<_>>>()?;
201    if real.is_empty() {
202        return Some(Vec::new());
203    }
204    inside_by(&real, "lsof")
205}
206
207/// [`processes_inside`] for directories already resolved, asking the `lsof` at `program`:
208/// `None` when it cannot be run.
209fn inside_by(real: &[String], program: &str) -> Option<Vec<bool>> {
210    let out = std::process::Command::new(program)
211        .args(["-a", "-d", "cwd", "-Fn"])
212        .output()
213        .ok()?;
214    let text = String::from_utf8_lossy(&out.stdout);
215    let cwds: Vec<&str> = text.lines().filter_map(|l| l.strip_prefix('n')).collect();
216    Some(
217        real.iter()
218            .map(|r| {
219                let below = format!("{r}/");
220                cwds.iter().any(|c| *c == r || c.starts_with(&below))
221            })
222            .collect(),
223    )
224}
225
226/// How many work trees to measure at once. Each measurement waits on a git subprocess
227/// rather than on this process's CPU, but the pool is still the machine's parallelism and
228/// no more: a development machine runs other people's sessions, and a pool that
229/// oversubscribes it makes their commands slower to make this one faster.
230fn probe_threads(work: usize) -> usize {
231    std::thread::available_parallelism()
232        .map(|n| n.get())
233        .unwrap_or(4)
234        .clamp(1, 16)
235        .min(work)
236}
237
238/// Parse the NUL-separated porcelain v1 status. Renames are disabled by the caller
239/// (`--no-renames`), so every entry is `XY path\0` and no entry is followed by a second
240/// path.
241///
242/// ```
243/// use majordomus_cli::worktree::state::parse_status_z;
244/// let s = parse_status_z(b"M  a\0 M b\0?? c\0UU d\0MM e\0");
245/// assert_eq!((s.staged, s.unstaged, s.untracked, s.conflicted), (2, 2, 1, 1));
246/// assert!(!s.clean);
247/// assert!(parse_status_z(b"").clean);
248/// ```
249pub fn parse_status_z(bytes: &[u8]) -> DirtyState {
250    let mut state = DirtyState::default();
251    for entry in bytes.split(|b| *b == 0).filter(|e| e.len() >= 3) {
252        let x = entry[0] as char;
253        let y = entry[1] as char;
254        match (x, y) {
255            ('?', '?') => state.untracked += 1,
256            ('!', '!') => {}
257            ('U', _) | (_, 'U') | ('A', 'A') | ('D', 'D') => state.conflicted += 1,
258            _ => {
259                if x != ' ' {
260                    state.staged += 1;
261                }
262                if y != ' ' {
263                    state.unstaged += 1;
264                }
265            }
266        }
267    }
268    state.clean =
269        state.staged == 0 && state.unstaged == 0 && state.untracked == 0 && state.conflicted == 0;
270    state
271}
272
273/// The operation git is in the middle of in this work tree, if any: what `git status`
274/// would report as "rebase in progress" and so on, read from the per-worktree git
275/// directory the way git itself does.
276pub fn operation_in_progress(worktree: &Path) -> Option<String> {
277    let dir = git_dir_of(worktree)?;
278    let probes: &[(&str, &str)] = &[
279        ("rebase-merge", "rebase"),
280        ("rebase-apply", "rebase"),
281        ("MERGE_HEAD", "merge"),
282        ("CHERRY_PICK_HEAD", "cherry-pick"),
283        ("REVERT_HEAD", "revert"),
284        ("BISECT_LOG", "bisect"),
285    ];
286    probes
287        .iter()
288        .find(|(file, _)| dir.join(file).exists())
289        .map(|(_, op)| (*op).to_string())
290}
291
292/// The git directory of one work tree, read the way git records it rather than by asking
293/// git for it: `.git` is that directory in the primary checkout and a file holding
294/// `gitdir: <path>` in a linked one. `git rev-parse --absolute-git-dir` answers the same
295/// question, but it is a subprocess, and a subprocess is what this costs — 128 of the
296/// cheapest possible `git rev-parse` took 1.5–2.2 s on this machine (n=3) — paid once per
297/// work tree for a question one `stat` and one small read answer. Git is asked only when
298/// `.git` is not there to read, so a checkout arranged some other way still gets an answer.
299pub(crate) fn git_dir_of(worktree: &Path) -> Option<PathBuf> {
300    let dot = worktree.join(".git");
301    match std::fs::metadata(&dot) {
302        Ok(m) if m.is_dir() => return Some(dot),
303        Ok(_) => {
304            if let Some(p) = std::fs::read_to_string(&dot)
305                .ok()
306                .as_deref()
307                .and_then(gitdir_link)
308            {
309                return Some(if p.is_absolute() { p } else { worktree.join(p) });
310            }
311        }
312        Err(_) => {}
313    }
314    let out = git::try_run(worktree, &["rev-parse", "--absolute-git-dir"]).ok()?;
315    if out.status != Some(0) {
316        return None;
317    }
318    Some(PathBuf::from(out.text().ok()?))
319}
320
321/// The path a linked work tree's `.git` file names, when it names one. Git writes exactly
322/// one line, `gitdir: <path>`, and the path is normally absolute.
323///
324/// ```
325/// use majordomus_cli::worktree::state::gitdir_link;
326/// use std::path::PathBuf;
327/// assert_eq!(gitdir_link("gitdir: /a/.git/worktrees/x\n"), Some(PathBuf::from("/a/.git/worktrees/x")));
328/// assert_eq!(gitdir_link("gitdir: ../elsewhere"), Some(PathBuf::from("../elsewhere")));
329/// assert_eq!(gitdir_link("gitdir:\n"), None);
330/// assert_eq!(gitdir_link("something else\n"), None);
331/// assert_eq!(gitdir_link(""), None);
332/// ```
333pub fn gitdir_link(text: &str) -> Option<PathBuf> {
334    let rest = text.lines().next()?.strip_prefix("gitdir:")?.trim();
335    if rest.is_empty() {
336        None
337    } else {
338        Some(PathBuf::from(rest))
339    }
340}
341
342/// One local branch as `for-each-ref` reports it.
343#[derive(Debug, Clone, PartialEq, Eq)]
344pub struct BranchRef {
345    /// Short name.
346    pub name: String,
347    /// The commit.
348    pub head: String,
349    /// The upstream, when configured.
350    pub upstream: Option<UpstreamState>,
351    /// The work tree holding it, when one does.
352    pub worktree: Option<PathBuf>,
353}
354
355/// Every local branch, in one subprocess: name, commit, upstream with its ahead/behind
356/// distance, and the work tree holding it.
357pub fn branches(primary: &Path) -> Result<Vec<BranchRef>> {
358    let out = git::run(primary, &["for-each-ref", "refs/heads", BRANCH_FORMAT])?;
359    Ok(parse_branches(&String::from_utf8_lossy(&out.stdout)))
360}
361
362/// One local branch, read now: the same fields as [`branches`], for a caller that must not act
363/// on a reading taken earlier. `None` when the branch no longer exists.
364///
365/// `for-each-ref refs/heads/<name>` also matches the branches below `<name>/`, so the result is
366/// filtered to the exact name.
367///
368/// ```
369/// use majordomus_cli::worktree::state::branch;
370/// let repo = tempfile::tempdir().unwrap();
371/// let git = |args: &[&str]| {
372///     let ok = std::process::Command::new("git")
373///         .args(["-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false"])
374///         .args(args)
375///         .current_dir(repo.path())
376///         .status()
377///         .unwrap();
378///     assert!(ok.success(), "git {args:?}");
379/// };
380/// git(&["init", "-q", "-b", "trunk"]);
381/// git(&["commit", "-q", "--allow-empty", "-m", "root"]);
382/// git(&["branch", "feat/sub"]);
383///
384/// // `refs/heads/feat` matches feat/sub as well; the exact-name filter drops it.
385/// assert!(branch(repo.path(), "feat").unwrap().is_none());
386/// let feat = branch(repo.path(), "feat/sub").unwrap().expect("feat/sub exists");
387/// assert_eq!(feat.name, "feat/sub");
388/// assert!(feat.upstream.is_none());
389/// assert_eq!(feat.head.len(), 40);
390/// assert!(branch(repo.path(), "trunk").unwrap().unwrap().worktree.is_some());
391/// assert!(branch(repo.path(), "gone").unwrap().is_none());
392/// ```
393pub fn branch(primary: &Path, name: &str) -> Result<Option<BranchRef>> {
394    let pattern = format!("refs/heads/{name}");
395    let out = git::run(primary, &["for-each-ref", pattern.as_str(), BRANCH_FORMAT])?;
396    Ok(parse_branches(&String::from_utf8_lossy(&out.stdout))
397        .into_iter()
398        .find(|b| b.name == name))
399}
400
401/// The `for-each-ref` format [`parse_branches`] reads.
402const BRANCH_FORMAT: &str = "--format=%(refname:short)%00%(objectname)%00%(upstream:short)%00%(upstream:track,nobracket)%00%(worktreepath)%00";
403
404/// Parse the format above: five NUL-terminated fields per branch, one branch per line.
405///
406/// ```
407/// use majordomus_cli::worktree::state::parse_branches;
408/// let b = parse_branches("feature/x\0abc\0origin/feature/x\0ahead 2, behind 1\0/a/foo-wt/feature/x\0\nmain\0def\0\0\0/a/foo\0\n");
409/// assert_eq!(b.len(), 2);
410/// let u = b[0].upstream.as_ref().unwrap();
411/// assert_eq!((u.ahead, u.behind, u.gone), (Some(2), Some(1), false));
412/// assert!(b[1].upstream.is_none());
413/// ```
414pub fn parse_branches(text: &str) -> Vec<BranchRef> {
415    let mut out = Vec::new();
416    for line in text.split('\n') {
417        let fields: Vec<&str> = line.split('\0').collect();
418        if fields.len() < 5 || fields[0].is_empty() {
419            continue;
420        }
421        let upstream = if fields[2].is_empty() {
422            None
423        } else {
424            let track = fields[3].trim();
425            let mut ahead = None;
426            let mut behind = None;
427            let mut gone = false;
428            for part in track.split(',').map(str::trim) {
429                if part == "gone" {
430                    gone = true;
431                } else if let Some(n) = part.strip_prefix("ahead ") {
432                    ahead = n.trim().parse().ok();
433                } else if let Some(n) = part.strip_prefix("behind ") {
434                    behind = n.trim().parse().ok();
435                }
436            }
437            if !gone {
438                ahead = ahead.or(Some(0));
439                behind = behind.or(Some(0));
440            }
441            Some(UpstreamState {
442                name: fields[2].to_string(),
443                ahead,
444                behind,
445                gone,
446            })
447        };
448        out.push(BranchRef {
449            name: fields[0].to_string(),
450            head: fields[1].to_string(),
451            upstream,
452            worktree: if fields[4].is_empty() {
453                None
454            } else {
455                Some(PathBuf::from(fields[4]))
456            },
457        });
458    }
459    out
460}
461
462/// Every local branch reachable from `trunk` or from the remote-tracking branch `trunk`
463/// follows. A local trunk that has fallen behind its remote — a primary checkout nobody has
464/// pulled, because a session holds uncommitted work in it — would otherwise call every branch
465/// that landed since unmerged, and cleanup would offer nothing; a branch the remote trunk
466/// contains is published and integrated, which is the safety cleanup asks for.
467///
468/// ```
469/// use majordomus_cli::worktree::state::merged_into_trunk;
470/// use std::process::Command;
471/// let dir = tempfile::tempdir().unwrap();
472/// let git = |args: &[&str]| {
473///     let ok = Command::new("git")
474///         .arg("-C")
475///         .arg(dir.path())
476///         .args(args)
477///         .env("GIT_AUTHOR_NAME", "t")
478///         .env("GIT_AUTHOR_EMAIL", "t@example.com")
479///         .env("GIT_COMMITTER_NAME", "t")
480///         .env("GIT_COMMITTER_EMAIL", "t@example.com")
481///         .status()
482///         .unwrap()
483///         .success();
484///     assert!(ok, "git {args:?}");
485/// };
486/// git(&["init", "-q", "-b", "main"]);
487/// git(&["commit", "-q", "--allow-empty", "-m", "base"]);
488/// git(&["branch", "landed"]);
489/// // no upstream here: the local trunk alone decides, and it contains `landed`
490/// let merged = merged_into_trunk(dir.path(), "main").unwrap();
491/// assert!(merged.contains("landed"));
492/// ```
493pub fn merged_into_trunk(primary: &Path, trunk: &str) -> Result<BTreeSet<String>> {
494    let mut merged = merged_into(primary, trunk)?;
495    let upstream = format!("{trunk}@{{upstream}}");
496    if let Ok(out) = git::run(
497        primary,
498        &[
499            "rev-parse",
500            "--abbrev-ref",
501            "--symbolic-full-name",
502            &upstream,
503        ],
504    ) {
505        let name = String::from_utf8_lossy(&out.stdout).trim().to_string();
506        if !name.is_empty() {
507            merged.extend(merged_into(primary, &name)?);
508        }
509    }
510    Ok(merged)
511}
512
513/// Every local branch reachable from `trunk`, in one subprocess.
514pub fn merged_into(primary: &Path, trunk: &str) -> Result<BTreeSet<String>> {
515    let out = git::run(
516        primary,
517        &[
518            "branch",
519            "--list",
520            "--merged",
521            trunk,
522            "--format=%(refname:short)",
523        ],
524    )?;
525    Ok(String::from_utf8_lossy(&out.stdout)
526        .lines()
527        .map(str::trim)
528        .filter(|l| !l.is_empty())
529        .map(str::to_string)
530        .collect())
531}
532
533/// The issue ids this repository's project model declares: the file names under
534/// `.ai/repo/project/issues/`, read from the primary checkout. A filesystem read, not an
535/// index build, so the topology stays cheap.
536pub fn issue_ids(primary: &Path) -> Vec<String> {
537    let dir = primary.join(".ai/repo/project/issues");
538    let Ok(entries) = std::fs::read_dir(dir) else {
539        return Vec::new();
540    };
541    let mut ids: Vec<String> = entries
542        .filter_map(|e| e.ok())
543        .filter_map(|e| {
544            let name = e.file_name().to_string_lossy().to_string();
545            name.strip_suffix(".yaml").map(str::to_string)
546        })
547        .filter(|id| !id.eq_ignore_ascii_case("readme"))
548        .collect();
549    crate::order::canonical(&mut ids);
550    ids
551}
552
553/// The issue a branch provably names: a path component equal to an issue id, or beginning
554/// with the id followed by `-`. Nothing else counts.
555///
556/// ```
557/// use majordomus_cli::worktree::state::issue_of;
558/// let ids = vec!["I0042".to_string(), "I1003".to_string()];
559/// assert_eq!(issue_of("feature/I1003-graph-kinds", &ids).as_deref(), Some("I1003"));
560/// assert_eq!(issue_of("fix/I0042", &ids).as_deref(), Some("I0042"));
561/// assert_eq!(issue_of("feature/graph-kinds", &ids), None);
562/// assert_eq!(issue_of("feature/I10030", &ids), None);
563/// ```
564pub fn issue_of(branch: &str, ids: &[String]) -> Option<String> {
565    for component in branch.split('/') {
566        for id in ids {
567            if component == id
568                || component
569                    .strip_prefix(id.as_str())
570                    .is_some_and(|rest| rest.starts_with('-'))
571            {
572                return Some(id.clone());
573            }
574        }
575    }
576    None
577}
578
579#[cfg(test)]
580mod tests {
581    use super::*;
582
583    #[test]
584    fn a_gone_upstream_has_no_distance() {
585        let b = parse_branches("x\0abc\0origin/x\0gone\0\0\n");
586        let u = b[0].upstream.as_ref().unwrap();
587        assert!(u.gone);
588        assert_eq!(u.ahead, None);
589    }
590
591    #[test]
592    fn an_up_to_date_upstream_is_zero_and_zero_not_unknown() {
593        let b = parse_branches("x\0abc\0origin/x\0\0\0\n");
594        let u = b[0].upstream.as_ref().unwrap();
595        assert_eq!((u.ahead, u.behind), (Some(0), Some(0)));
596    }
597
598    fn git(cwd: &Path, args: &[&str]) {
599        let ok = std::process::Command::new("git")
600            .args(["-c", "user.name=t", "-c", "user.email=t@t"])
601            .args(["-c", "commit.gpgsign=false"])
602            .args(args)
603            .current_dir(cwd)
604            .status()
605            .expect("run git");
606        assert!(ok.success(), "git {args:?}");
607    }
608
609    #[test]
610    fn a_directory_git_does_not_answer_for_has_no_aged_state() {
611        let plain = tempfile::tempdir().unwrap();
612        assert!(aged_dirty_state(plain.path()).is_err());
613    }
614
615    /// An ignored entry is not a change; a changed path that is gone takes the index's time;
616    /// a path that is there takes its own.
617    #[test]
618    fn the_oldest_change_skips_ignored_entries_and_a_gone_path_takes_the_index_time() {
619        let repo = tempfile::tempdir().unwrap();
620        git(repo.path(), &["init", "-q"]);
621        std::fs::write(repo.path().join("kept.txt"), "k").unwrap();
622        git(repo.path(), &["add", "kept.txt"]);
623        let index = std::fs::metadata(repo.path().join(".git/index"))
624            .and_then(|m| m.modified())
625            .unwrap();
626        let kept = std::fs::metadata(repo.path().join("kept.txt"))
627            .and_then(|m| m.modified())
628            .unwrap();
629
630        // only an ignored entry: nothing of its own counts, and the index stands in
631        let ignored = oldest_change(repo.path(), b"!! build/\0");
632        assert_eq!(ignored, Some(index));
633        // a present path counts by its own time
634        assert_eq!(oldest_change(repo.path(), b"A  kept.txt\0"), Some(kept));
635        // a gone path brings the index in beside the present one
636        let both = oldest_change(repo.path(), b"A  kept.txt\0 D gone.txt\0");
637        assert_eq!(both, Some(kept.min(index)));
638
639        // no repository at all: a gone path and no index answer nothing
640        let plain = tempfile::tempdir().unwrap();
641        assert_eq!(oldest_change(plain.path(), b" D gone.txt\0"), None);
642    }
643
644    #[test]
645    fn a_directory_that_cannot_be_resolved_or_an_absent_lsof_is_not_knowing() {
646        let gone = Path::new("/nonexistent/majordomus-test/worktree");
647        assert_eq!(processes_inside(&[gone]), None);
648        assert_eq!(processes_inside(&[]), Some(Vec::new()));
649        let real = ["/nonexistent".to_string()];
650        assert_eq!(inside_by(&real, "/nonexistent/majordomus-test/lsof"), None);
651    }
652
653    #[test]
654    fn status_counts_each_column_once() {
655        let s = parse_status_z(b"A  new\0D  gone\0 D removed\0?? x/y\0");
656        assert_eq!((s.staged, s.unstaged, s.untracked), (2, 1, 1));
657    }
658}