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}