majordomus_cli/worktree/uncommitted.rs
1//! Uncommitted work in the linked work trees, and whether anything still holds it (I1995).
2//!
3//! Twice in two days finished work existed on one disk only: 45 uncommitted files in the
4//! work tree of a session that had ended on 2026-10-07, 21 staged files in another on
5//! 2026-10-08. Neither had a commit or a remote ref, and both were found because somebody
6//! happened to read that directory. `doctor` named branches whose commits reach no remote and
7//! said nothing about an index that holds work no commit carries.
8//!
9//! This is the reading that names them. For every linked work tree with staged, unstaged,
10//! untracked or conflicted paths it says how many, how old the oldest change is, and what
11//! holds the work — and from those, whether it is abandoned:
12//!
13//! * **held** — an active task record (`.ai/local/state/current.yaml` with outcome
14//! `active`, the record `start` writes and `finish` closes), an open episode
15//! (`.ai/local/state/sessions-open/`) that is not stranded, or a process whose working
16//! directory is inside it. The first two are the lifecycle's own records, read with the
17//! readers that already exist for them ([`crate::session::open_episodes`],
18//! [`Episode::stranded_after`](crate::session::Episode::stranded_after)); the third is the
19//! `lsof` reading `worktree cleanup` refuses a removal on. Nothing new is stored.
20//! * **abandoned** — nothing holds it and its oldest change is older than the threshold.
21//! * **recent** — nothing holds it and it is younger than that.
22//!
23//! The threshold is `session.stranded_after` from the policy: the silence after which an
24//! episode's client is taken to be gone. One number answers both questions it is asked
25//! here — whether an open episode still holds the work, and whether work nobody holds has
26//! waited long enough to be called abandoned — so there is no second number to drift from
27//! it. A caller with better evidence than a clock names its own (`older_than`).
28//!
29//! The reading writes nothing. Every `git status` runs under `GIT_OPTIONAL_LOCKS=0`, so no
30//! index refresh and no lock reaches any work tree, and another work tree's `.ai/local/` is
31//! read, never written. What it suggests for an abandoned work tree — the command that puts
32//! the work on a remote ref — is a suggestion; nothing here runs it.
33//!
34//! # The lifecycle, end to end
35//!
36//! A linked work tree gets a staged file; the same work is `recent` against a day and
37//! `abandoned` against an hour once its file is years old; and an active task record then
38//! holds it. That the readings leave every index byte-identical is the module's own test.
39//!
40//! ```
41//! use majordomus_cli::worktree::{Threshold, WorkStanding, WorktreeService};
42//! use std::path::Path;
43//! use std::process::Command;
44//!
45//! let dir = tempfile::tempdir().unwrap();
46//! let git = |cwd: &Path, args: &[&str]| {
47//! let ok = Command::new("git")
48//! .args(["-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false"])
49//! .args(args)
50//! .current_dir(cwd)
51//! .status()
52//! .unwrap();
53//! assert!(ok.success(), "git {args:?}");
54//! };
55//! let repo = dir.path().join("repo");
56//! std::fs::create_dir_all(&repo).unwrap();
57//! git(&repo, &["init", "-q"]);
58//! git(&repo, &["commit", "-q", "--allow-empty", "-m", "init"]);
59//! let left = dir.path().join("left");
60//! git(&repo, &["worktree", "add", "-q", "-b", "work/left", left.to_str().unwrap()]);
61//! std::fs::write(left.join("a.txt"), "a\n").unwrap();
62//! git(&left, &["add", "a.txt"]);
63//!
64//! let service = WorktreeService::open(&repo).unwrap();
65//! let day = Threshold::resolve(None, Some("1d")).unwrap();
66//! let fresh = service.uncommitted(&day).unwrap();
67//! assert_eq!(fresh.tallies.uncommitted, 1, "the primary checkout is never listed");
68//! assert_eq!(fresh.worktrees[0].work.staged, 1);
69//! assert_eq!(fresh.worktrees[0].standing, WorkStanding::Recent);
70//!
71//! // the file is made years old: against an hour, nothing holding it, it is abandoned
72//! let touched = Command::new("touch")
73//! .args(["-t", "202001010000"])
74//! .arg(left.join("a.txt"))
75//! .status()
76//! .unwrap();
77//! assert!(touched.success());
78//! let hour = Threshold::resolve(None, Some("1h")).unwrap();
79//! let old = service.uncommitted(&hour).unwrap();
80//! assert_eq!(old.worktrees[0].standing, WorkStanding::Abandoned);
81//! assert!(old.worktrees[0].remedy.contains("HEAD:refs/heads/rescue/work-left-"));
82//!
83//! // an active task record holds it, however old it is
84//! let state = left.join(".ai/local/state");
85//! std::fs::create_dir_all(&state).unwrap();
86//! std::fs::write(state.join("current.yaml"), "id: t-1\noutcome: active\n").unwrap();
87//! let held = service.uncommitted(&hour).unwrap();
88//! let entry = held.worktrees.iter().find(|w| w.label == "work/left").unwrap();
89//! assert_eq!(entry.standing, WorkStanding::Held);
90//! assert_eq!(entry.task.as_deref(), Some("t-1"));
91//! ```
92
93use std::path::Path;
94use std::time::{SystemTime, UNIX_EPOCH};
95
96use schemars::JsonSchema;
97use serde::{Deserialize, Serialize};
98
99use super::error::Result;
100use super::model::DirtyState;
101use super::path::detached_label;
102use super::service::WorktreeService;
103use super::state;
104
105/// The schema of [`UncommittedReport`].
106pub const UNCOMMITTED_SCHEMA: &str = "majordomus-worktree-uncommitted/v1";
107
108/// The policy key the threshold is read from.
109pub const THRESHOLD_KEY: &str = "session.stranded_after";
110
111/// Where a work tree's active task record lives, relative to it.
112const TASK_RECORD: &str = ".ai/local/state/current.yaml";
113
114/// The age past which uncommitted work nothing holds is abandoned, and where it came from.
115///
116/// It is reported with every reading, so a reader can see what the verdicts were judged
117/// against; a threshold nothing declares is carried as absent, not filled in.
118///
119/// ```
120/// use majordomus_cli::worktree::Threshold;
121/// let declared = Threshold::resolve(Some("12h"), None).unwrap();
122/// let wire = serde_json::to_value(&declared).unwrap();
123/// assert_eq!(wire["declared"], "12h");
124/// assert_eq!(wire["seconds"], 43_200);
125/// let absent: Threshold =
126/// serde_json::from_value(serde_json::json!({"source": "absent"})).unwrap();
127/// assert_eq!((absent.declared, absent.seconds), (None, None), "absent is not a default");
128/// ```
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
130pub struct Threshold {
131 #[serde(default, skip_serializing_if = "Option::is_none")]
132 /// The value as written (`12h`). Absent when nothing declares one.
133 pub declared: Option<String>,
134 #[serde(default, skip_serializing_if = "Option::is_none")]
135 /// The same value, in seconds.
136 pub seconds: Option<u64>,
137 /// [`THRESHOLD_KEY`], `older_than` when the caller named one, or `absent`.
138 pub source: String,
139}
140
141impl Threshold {
142 /// The threshold from the caller's own value when it gave one, else from the policy's.
143 /// A value that is not a duration is refused rather than read as none: a reading that
144 /// judged against nothing would call no work abandoned and say so with confidence.
145 ///
146 /// ```
147 /// use majordomus_cli::worktree::Threshold;
148 /// let t = Threshold::resolve(Some("12h"), None).unwrap();
149 /// assert_eq!((t.seconds, t.source.as_str()), (Some(43_200), "session.stranded_after"));
150 /// let o = Threshold::resolve(Some("12h"), Some("30m")).unwrap();
151 /// assert_eq!((o.seconds, o.source.as_str()), (Some(1_800), "older_than"));
152 /// let none = Threshold::resolve(None, None).unwrap();
153 /// assert_eq!((none.seconds, none.source.as_str()), (None, "absent"));
154 /// assert!(Threshold::resolve(Some("soon"), None).is_err());
155 /// ```
156 pub fn resolve(
157 declared: Option<&str>,
158 older_than: Option<&str>,
159 ) -> std::result::Result<Threshold, String> {
160 let (value, source) = match (older_than, declared) {
161 (Some(v), _) => (v, "older_than"),
162 (None, Some(v)) => (v, THRESHOLD_KEY),
163 (None, None) => {
164 return Ok(Threshold {
165 declared: None,
166 seconds: None,
167 source: "absent".into(),
168 })
169 }
170 };
171 let seconds = crate::cli::parse_duration(value).map_err(|e| format!("{source}: {e}"))?;
172 Ok(Threshold {
173 declared: Some(value.to_string()),
174 seconds: Some(seconds),
175 source: source.into(),
176 })
177 }
178
179 /// The threshold a repository declares, or the caller's own: the policy of the
180 /// checkout at `root` is read for [`THRESHOLD_KEY`], and a policy that cannot be read
181 /// declares nothing.
182 ///
183 /// ```
184 /// use majordomus_cli::worktree::Threshold;
185 /// // a directory with no layer declares nothing, and the caller's own value still counts
186 /// let plain = tempfile::tempdir().unwrap();
187 /// let none = Threshold::declared_at(plain.path(), None).unwrap();
188 /// assert_eq!((none.seconds, none.source.as_str()), (None, "absent"));
189 /// let own = Threshold::declared_at(plain.path(), Some("2h")).unwrap();
190 /// assert_eq!((own.seconds, own.source.as_str()), (Some(7_200), "older_than"));
191 /// assert!(Threshold::declared_at(plain.path(), Some("later")).is_err());
192 /// ```
193 pub fn declared_at(root: &Path, older_than: Option<&str>) -> std::result::Result<Self, String> {
194 let declared = crate::repository::Repository::discover(root)
195 .ok()
196 .and_then(|r| crate::policy::LoadedPolicy::load(&r).ok())
197 .and_then(|p| p.policy.session.stranded_after);
198 Threshold::resolve(declared.as_deref(), older_than)
199 }
200}
201
202/// What a work tree's uncommitted work is, judged: held by something, recent, or abandoned.
203/// [`judge`] is the one place it is decided, and `doctor` warns only on `abandoned`.
204///
205/// ```
206/// use majordomus_cli::worktree::uncommitted::{judge, WorkStanding};
207/// let standing = judge(false, Some(90_000), Some(43_200));
208/// assert_eq!(standing, WorkStanding::Abandoned);
209/// assert_eq!(serde_json::to_value(standing).unwrap(), "abandoned");
210/// ```
211#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
212#[serde(rename_all = "snake_case")]
213pub enum WorkStanding {
214 /// A task, an episode or a process holds it.
215 Held,
216 /// Nothing holds it, and it is younger than the threshold.
217 Recent,
218 /// Nothing holds it, and it is older than the threshold — or its age cannot be read.
219 Abandoned,
220}
221
222impl WorkStanding {
223 /// The standing as one lowercase word, the same word the JSON answer carries, for the
224 /// text rendering of `worktree uncommitted` and for doctor's selection of what to warn on.
225 ///
226 /// ```
227 /// use majordomus_cli::worktree::WorkStanding;
228 /// for s in [WorkStanding::Held, WorkStanding::Recent, WorkStanding::Abandoned] {
229 /// assert_eq!(serde_json::to_value(s).unwrap(), s.as_str(), "one word on both surfaces");
230 /// }
231 /// ```
232 pub fn as_str(self) -> &'static str {
233 match self {
234 WorkStanding::Held => "held",
235 WorkStanding::Recent => "recent",
236 WorkStanding::Abandoned => "abandoned",
237 }
238 }
239}
240
241/// An open episode in a work tree, as far as holding its work goes: it holds the work only
242/// while `stranded` is `Some(false)`; a stranded or unjudgeable one holds nothing.
243///
244/// ```
245/// use majordomus_cli::worktree::OpenEpisode;
246/// let unjudged = OpenEpisode {
247/// id: "s-20260101000000-0a0a".into(),
248/// last_sign_of_life: None,
249/// stranded: None,
250/// };
251/// let wire = serde_json::to_value(&unjudged).unwrap();
252/// assert!(wire.get("stranded").is_none(), "what could not be judged is absent, not false");
253/// let back: OpenEpisode = serde_json::from_value(wire).unwrap();
254/// assert_eq!(back, unjudged);
255/// ```
256#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
257pub struct OpenEpisode {
258 /// The episode.
259 pub id: String,
260 #[serde(default, skip_serializing_if = "Option::is_none")]
261 /// Its newest sign of life, RFC 3339 in UTC.
262 pub last_sign_of_life: Option<String>,
263 #[serde(default, skip_serializing_if = "Option::is_none")]
264 /// Silent longer than the threshold. Absent when that cannot be judged — no threshold,
265 /// or no readable sign of life — and then it holds nothing.
266 pub stranded: Option<bool>,
267}
268
269/// One linked work tree holding uncommitted work: how much, how old, what holds it, the
270/// verdict, and the command that would preserve it. A listing is in canonical order —
271/// abandoned first, then recent, then held, the oldest first inside each.
272///
273/// ```
274/// use majordomus_cli::worktree::{DirtyState, UncommittedWorktree, WorkStanding};
275/// let entry = |path: &str, standing: WorkStanding, age: u64| UncommittedWorktree {
276/// path: path.into(),
277/// branch: None,
278/// label: path.into(),
279/// work: DirtyState { staged: 1, ..DirtyState::default() },
280/// paths: 1,
281/// oldest_change: None,
282/// age_seconds: Some(age),
283/// age: None,
284/// task: None,
285/// episodes: Vec::new(),
286/// process_inside: None,
287/// held_by: Vec::new(),
288/// standing,
289/// remedy: String::new(),
290/// };
291/// let mut listing = vec![
292/// entry("/wt/held-old", WorkStanding::Held, 900_000),
293/// entry("/wt/young", WorkStanding::Abandoned, 50_000),
294/// entry("/wt/older", WorkStanding::Abandoned, 90_000),
295/// ];
296/// majordomus_cli::order::canonical(&mut listing);
297/// let paths: Vec<&str> = listing.iter().map(|w| w.path.as_str()).collect();
298/// assert_eq!(paths, ["/wt/older", "/wt/young", "/wt/held-old"]);
299/// ```
300#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
301pub struct UncommittedWorktree {
302 /// Absolute, as git holds it.
303 pub path: String,
304 #[serde(default, skip_serializing_if = "Option::is_none")]
305 /// The branch checked out there. Absent when detached.
306 pub branch: Option<String>,
307 /// The branch, or `detached/<short commit>`.
308 pub label: String,
309 /// The counts, as `worktree status` gives them.
310 pub work: DirtyState,
311 /// Every changed path: staged, unstaged, untracked and conflicted together.
312 pub paths: usize,
313 #[serde(default, skip_serializing_if = "Option::is_none")]
314 /// When the oldest change was made: the earliest modification time among the changed
315 /// paths, the index file's standing in for a path that no longer exists. RFC 3339.
316 pub oldest_change: Option<String>,
317 #[serde(default, skip_serializing_if = "Option::is_none")]
318 /// Seconds since then.
319 pub age_seconds: Option<u64>,
320 #[serde(default, skip_serializing_if = "Option::is_none")]
321 /// The same, for a person (`3d`, `5h`, `40m`).
322 pub age: Option<String>,
323 #[serde(default, skip_serializing_if = "Option::is_none")]
324 /// The active task recorded there.
325 pub task: Option<String>,
326 /// The episodes open there.
327 pub episodes: Vec<OpenEpisode>,
328 #[serde(default, skip_serializing_if = "Option::is_none")]
329 /// A process has its working directory inside it. Absent when that could not be read.
330 pub process_inside: Option<bool>,
331 /// What holds it, in words; empty when nothing does.
332 pub held_by: Vec<String>,
333 /// The verdict.
334 pub standing: WorkStanding,
335 /// The command that would put this work on a remote ref. Suggested, never run.
336 pub remedy: String,
337}
338
339/// Abandoned work first, then recent, then held; inside each, the oldest first; the path
340/// breaks a tie, so two readings of one disk list it in one order.
341impl crate::order::Ordered for UncommittedWorktree {
342 fn order_key(&self) -> crate::order::OrderKey<'_> {
343 // a standing outweighs any age: a century is ~3.2e9 seconds
344 const STANDING: i64 = 1_000_000_000_000;
345 let standing = match self.standing {
346 WorkStanding::Abandoned => 0,
347 WorkStanding::Recent => 1,
348 WorkStanding::Held => 2,
349 };
350 let age = self.age_seconds.map_or(0, |a| {
351 i64::try_from(a).unwrap_or(i64::MAX).min(STANDING - 1)
352 });
353 crate::order::OrderKey::plain(&self.path, &self.path).ranked(standing * STANDING - age)
354 }
355}
356
357/// The counts of an [`UncommittedReport`]: how many linked work trees were read, how many
358/// hold uncommitted work, and how those split into held, recent and abandoned. The three
359/// standings always add up to `uncommitted`; `doctor` reads `abandoned` to decide whether it
360/// has anything to warn about.
361///
362/// ```
363/// use majordomus_cli::worktree::UncommittedTallies;
364/// let wire = serde_json::json!(
365/// {"measured": 4, "uncommitted": 3, "held": 1, "recent": 1, "abandoned": 1}
366/// );
367/// let t: UncommittedTallies = serde_json::from_value(wire.clone()).unwrap();
368/// assert_eq!(t.held + t.recent + t.abandoned, t.uncommitted);
369/// assert_eq!(serde_json::to_value(&t).unwrap(), wire, "the shape doctor's jq reads");
370/// assert_eq!(UncommittedTallies::default().abandoned, 0, "nothing read, nothing abandoned");
371/// ```
372#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
373pub struct UncommittedTallies {
374 /// Linked work trees whose status was read.
375 pub measured: usize,
376 /// Of those, the ones holding uncommitted work: every entry of the listing.
377 pub uncommitted: usize,
378 /// Held by a task, an episode or a process.
379 pub held: usize,
380 /// Held by nothing, younger than the threshold.
381 pub recent: usize,
382 /// Held by nothing, older than the threshold.
383 pub abandoned: usize,
384}
385
386/// Every linked work tree holding uncommitted work, with what holds it: the answer of
387/// `worktree.uncommitted` on every surface, and what `doctor` renders.
388///
389/// ```
390/// use majordomus_cli::worktree::uncommitted::UNCOMMITTED_SCHEMA;
391/// use majordomus_cli::worktree::{Threshold, UncommittedReport, WorktreeService};
392/// let dir = tempfile::tempdir().unwrap();
393/// let init = std::process::Command::new("git")
394/// .args(["init", "-q"])
395/// .current_dir(dir.path())
396/// .status()
397/// .unwrap();
398/// assert!(init.success());
399/// // a repository with no linked work tree, and a policy that declares no threshold
400/// let threshold = Threshold::resolve(None, None).unwrap();
401/// let report: UncommittedReport =
402/// WorktreeService::open(dir.path()).unwrap().uncommitted(&threshold).unwrap();
403/// assert_eq!(report.schema, UNCOMMITTED_SCHEMA);
404/// assert!(report.worktrees.is_empty());
405/// assert_eq!(report.tallies.measured, 0);
406/// assert_eq!(report.findings.len(), 1, "a missing threshold is said, not assumed");
407/// ```
408#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
409pub struct UncommittedReport {
410 /// [`UNCOMMITTED_SCHEMA`].
411 pub schema: String,
412 /// When the reading was taken, RFC 3339 in UTC.
413 pub observed_at: String,
414 /// The threshold it was judged against.
415 pub threshold: Threshold,
416 /// Every linked work tree with uncommitted work, abandoned first, then oldest first.
417 pub worktrees: Vec<UncommittedWorktree>,
418 /// The counts.
419 pub tallies: UncommittedTallies,
420 /// What could not be read or judged, in words.
421 pub findings: Vec<String>,
422}
423
424/// A path quoted for a POSIX shell.
425///
426/// ```
427/// use majordomus_cli::worktree::uncommitted::shell_quote;
428/// assert_eq!(shell_quote("/a b/c"), "'/a b/c'");
429/// assert_eq!(shell_quote("it's"), r"'it'\''s'");
430/// ```
431pub fn shell_quote(s: &str) -> String {
432 format!("'{}'", s.replace('\'', r"'\''"))
433}
434
435/// The command that puts a work tree's uncommitted work on a remote ref named
436/// `rescue/<label>-<YYYYMMDD>`: stage everything, commit it there, and push that commit to a
437/// branch of its own, so that the branch the work tree holds is not pushed over. The shape
438/// the two rescues of October 2026 took (`origin/rescue/completion-doctrine-20261008`).
439///
440/// ```
441/// use majordomus_cli::worktree::uncommitted::rescue_command;
442/// let c = rescue_command("/r-wt/feature/x", "feature/x", "2026-10-08T10:00:00Z");
443/// assert_eq!(
444/// c,
445/// "git -C '/r-wt/feature/x' add -A && git -C '/r-wt/feature/x' commit -m 'wip: rescue uncommitted work' \
446/// && git -C '/r-wt/feature/x' push origin HEAD:refs/heads/rescue/feature-x-20261008"
447/// );
448/// ```
449pub fn rescue_command(path: &str, label: &str, now: &str) -> String {
450 let day: String = now.chars().take(10).filter(|c| *c != '-').collect();
451 let name: String = label
452 .chars()
453 .map(|c| {
454 if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
455 c
456 } else {
457 '-'
458 }
459 })
460 .collect();
461 let p = shell_quote(path);
462 format!(
463 "git -C {p} add -A && git -C {p} commit -m 'wip: rescue uncommitted work' \
464 && git -C {p} push origin HEAD:refs/heads/rescue/{name}-{day}"
465 )
466}
467
468fn epoch(t: SystemTime) -> Option<u64> {
469 t.duration_since(UNIX_EPOCH).ok().map(|d| d.as_secs())
470}
471
472/// Judge one work tree's work from what holds it, its age and the threshold. The one place
473/// the verdict is decided.
474///
475/// ```
476/// use majordomus_cli::worktree::uncommitted::{judge, WorkStanding};
477/// assert_eq!(judge(true, Some(10), Some(5)), WorkStanding::Held, "held is held, however old");
478/// assert_eq!(judge(false, Some(10), Some(5)), WorkStanding::Abandoned);
479/// assert_eq!(judge(false, Some(3), Some(5)), WorkStanding::Recent);
480/// assert_eq!(judge(false, None, Some(5)), WorkStanding::Abandoned, "an unreadable age is not young");
481/// assert_eq!(judge(false, Some(10), None), WorkStanding::Recent, "no threshold judges nothing old");
482/// ```
483pub fn judge(held: bool, age_seconds: Option<u64>, threshold: Option<u64>) -> WorkStanding {
484 if held {
485 return WorkStanding::Held;
486 }
487 match (age_seconds, threshold) {
488 (_, None) => WorkStanding::Recent,
489 (None, Some(_)) => WorkStanding::Abandoned,
490 (Some(age), Some(t)) if age > t => WorkStanding::Abandoned,
491 _ => WorkStanding::Recent,
492 }
493}
494
495/// What a reading could not read or judge, in words, in the order a reader meets it: a
496/// threshold nothing declares, work trees `git status` would not answer for, and whether a
497/// process works inside one of the `dirty` work trees when that could not be read
498/// (`processes_unread`); with no dirty work tree there was nothing to ask about.
499fn reading_findings(
500 threshold_declared: bool,
501 unread: usize,
502 processes_unread: bool,
503 dirty: usize,
504) -> Vec<String> {
505 let mut findings = Vec::new();
506 if !threshold_declared {
507 findings.push(format!(
508 "the policy declares no {THRESHOLD_KEY}, so no work is judged abandoned and no \
509 episode stranded; every listing below is reported as found"
510 ));
511 }
512 if unread > 0 {
513 findings.push(format!(
514 "{unread} linked work tree(s) would not answer `git status`; their work is not listed"
515 ));
516 }
517 if processes_unread && dirty > 0 {
518 findings.push(
519 "whether a process works inside a work tree could not be read (no lsof); \
520 only task and episode records were taken as holding work"
521 .into(),
522 );
523 }
524 findings
525}
526
527/// A work tree's label: its branch, or `detached/<short commit>`, or `detached` when not
528/// even the commit is known.
529fn label_of(branch: Option<&str>, head: Option<&str>) -> String {
530 match (branch, head) {
531 (Some(b), _) => b.to_string(),
532 (None, Some(h)) => detached_label(h),
533 (None, None) => "detached".into(),
534 }
535}
536
537/// What holds a work tree's work, in words: its active task, every open episode that is
538/// not stranded, and a process working inside it. Empty when nothing does.
539fn holders(
540 task: Option<&str>,
541 episodes: &[OpenEpisode],
542 process_inside: Option<bool>,
543) -> Vec<String> {
544 let mut held_by = Vec::new();
545 if let Some(t) = task {
546 held_by.push(format!("task {t}"));
547 }
548 for e in episodes.iter().filter(|e| e.stranded == Some(false)) {
549 held_by.push(format!("episode {}", e.id));
550 }
551 if process_inside == Some(true) {
552 held_by.push("a process working inside".into());
553 }
554 held_by
555}
556
557impl WorktreeService {
558 /// Every linked work tree holding uncommitted work, with how much, how old, what holds
559 /// it and whether it is abandoned against `threshold`. Reads every work tree and writes
560 /// none: see the module documentation.
561 ///
562 /// ```
563 /// use majordomus_cli::worktree::{Threshold, WorkStanding, WorktreeService};
564 /// use std::path::Path;
565 /// use std::process::Command;
566 /// let dir = tempfile::tempdir().unwrap();
567 /// let git = |cwd: &Path, args: &[&str]| {
568 /// let ok = Command::new("git")
569 /// .args(["-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false"])
570 /// .args(args)
571 /// .current_dir(cwd)
572 /// .status()
573 /// .unwrap();
574 /// assert!(ok.success(), "git {args:?}");
575 /// };
576 /// let repo = dir.path().join("repo");
577 /// std::fs::create_dir_all(&repo).unwrap();
578 /// git(&repo, &["init", "-q"]);
579 /// git(&repo, &["commit", "-q", "--allow-empty", "-m", "init"]);
580 /// let (clean, dirty) = (dir.path().join("clean"), dir.path().join("dirty"));
581 /// git(&repo, &["worktree", "add", "-q", "-b", "work/clean", clean.to_str().unwrap()]);
582 /// git(&repo, &["worktree", "add", "-q", "-b", "work/dirty", dirty.to_str().unwrap()]);
583 /// std::fs::write(dirty.join("new.txt"), "work\n").unwrap();
584 ///
585 /// let threshold = Threshold::resolve(Some("12h"), None).unwrap();
586 /// let report = WorktreeService::open(&repo).unwrap().uncommitted(&threshold).unwrap();
587 /// assert_eq!(report.tallies.measured, 2, "both linked work trees were read");
588 /// assert_eq!(report.worktrees.len(), 1, "only the one with work is listed");
589 /// let w = &report.worktrees[0];
590 /// assert_eq!((w.label.as_str(), w.paths, w.work.untracked), ("work/dirty", 1, 1));
591 /// assert_eq!(w.standing, WorkStanding::Recent, "written a moment ago");
592 /// ```
593 pub fn uncommitted(&self, threshold: &Threshold) -> Result<UncommittedReport> {
594 let now = SystemTime::now();
595 let now_secs = epoch(now).unwrap_or(0);
596 let observed_at = crate::peers::rfc3339(now);
597 let records: Vec<_> = self
598 .identity()
599 .registered_worktrees()
600 .iter()
601 .filter(|r| !r.bare && !self.identity().is_primary(r) && r.path.is_dir())
602 .collect();
603 let probes: Vec<Option<&Path>> = records.iter().map(|r| Some(r.path.as_path())).collect();
604 let measured = state::probe_all(&probes, |p| state::aged_dirty_state(p).ok());
605
606 let unread = measured.iter().filter(|m| m.is_none()).count();
607
608 let dirty: Vec<(
609 &super::topology::WorktreeRecord,
610 DirtyState,
611 Option<SystemTime>,
612 )> = records
613 .iter()
614 .zip(measured)
615 .filter_map(|(r, m)| m.map(|(d, t)| (*r, d, t)))
616 .filter(|(_, d, _)| !d.clean)
617 .collect();
618 let dirs: Vec<&Path> = dirty.iter().map(|(r, _, _)| r.path.as_path()).collect();
619 let inside = state::processes_inside(&dirs);
620 let findings = reading_findings(
621 threshold.seconds.is_some(),
622 unread,
623 inside.is_none(),
624 dirs.len(),
625 );
626
627 let mut worktrees: Vec<UncommittedWorktree> = Vec::new();
628 for (i, (record, work, oldest)) in dirty.into_iter().enumerate() {
629 let path = super::service::display(&record.path);
630 let label = label_of(record.branch.as_deref(), record.head.as_deref());
631 let task =
632 crate::capability::builtin::continuity::read_task(&record.path.join(TASK_RECORD))
633 .filter(|t| t.outcome == "active")
634 .map(|t| t.id);
635 let episodes: Vec<OpenEpisode> = crate::session::open_episodes(&record.path)
636 .into_iter()
637 .map(|e| OpenEpisode {
638 id: e.id.as_str().to_string(),
639 last_sign_of_life: e.last_sign_of_life.and_then(|s| u64::try_from(s).ok()).map(
640 |s| crate::peers::rfc3339(UNIX_EPOCH + std::time::Duration::from_secs(s)),
641 ),
642 stranded: threshold.seconds.and_then(|t| {
643 e.stranded_after(
644 i64::try_from(now_secs).unwrap_or(i64::MAX),
645 i64::try_from(t).unwrap_or(i64::MAX),
646 )
647 }),
648 })
649 .collect();
650 let process_inside = inside.as_ref().and_then(|v| v.get(i).copied());
651
652 let held_by = holders(task.as_deref(), &episodes, process_inside);
653
654 let age_seconds = oldest.and_then(epoch).map(|t| now_secs.saturating_sub(t));
655 let standing = judge(!held_by.is_empty(), age_seconds, threshold.seconds);
656 worktrees.push(UncommittedWorktree {
657 remedy: rescue_command(&path, &label, &observed_at),
658 path,
659 branch: record.branch.clone(),
660 label,
661 paths: work.staged + work.unstaged + work.untracked + work.conflicted,
662 work,
663 oldest_change: oldest.map(crate::peers::rfc3339),
664 age: age_seconds.map(|s| {
665 crate::capability::builtin::continuity::span(
666 i64::try_from(s / 60).unwrap_or(i64::MAX),
667 )
668 }),
669 age_seconds,
670 task,
671 episodes,
672 process_inside,
673 held_by,
674 standing,
675 });
676 }
677 crate::order::canonical(&mut worktrees);
678
679 let count = |s: WorkStanding| worktrees.iter().filter(|w| w.standing == s).count();
680 let tallies = UncommittedTallies {
681 measured: records.len() - unread,
682 uncommitted: worktrees.len(),
683 held: count(WorkStanding::Held),
684 recent: count(WorkStanding::Recent),
685 abandoned: count(WorkStanding::Abandoned),
686 };
687 Ok(UncommittedReport {
688 schema: UNCOMMITTED_SCHEMA.into(),
689 observed_at,
690 threshold: threshold.clone(),
691 worktrees,
692 tallies,
693 findings,
694 })
695 }
696}
697
698#[cfg(test)]
699mod tests {
700 use super::*;
701 use std::process::Command;
702
703 fn git(cwd: &Path, args: &[&str]) {
704 let ok = Command::new("git")
705 .args([
706 "-c",
707 "user.name=t",
708 "-c",
709 "user.email=t@t",
710 "-c",
711 "commit.gpgsign=false",
712 ])
713 .args(args)
714 .current_dir(cwd)
715 .status()
716 .expect("run git");
717 assert!(ok.success(), "git {args:?}");
718 }
719
720 fn age_years(path: &Path) {
721 let ok = Command::new("touch")
722 .args(["-t", "202001010000"])
723 .arg(path)
724 .status()
725 .expect("run touch");
726 assert!(ok.success());
727 }
728
729 /// A repository with three linked work trees: staged work nobody holds, an old change an
730 /// active task holds, and an old untracked file whose only episode has been silent since
731 /// 2020. The first and third are abandoned, the second is held, and no index moved.
732 #[test]
733 fn work_nothing_holds_is_abandoned_and_the_reading_moves_no_index() {
734 let dir = tempfile::tempdir().unwrap();
735 let repo = dir.path().join("repo");
736 std::fs::create_dir_all(&repo).unwrap();
737 git(&repo, &["init", "-q"]);
738 std::fs::write(repo.join("tracked.txt"), "one\n").unwrap();
739 git(&repo, &["add", "tracked.txt"]);
740 git(&repo, &["commit", "-q", "-m", "init"]);
741 let wt = |name: &str| {
742 let p = dir.path().join(name);
743 git(
744 &repo,
745 &[
746 "worktree",
747 "add",
748 "-q",
749 "-b",
750 &format!("work/{name}"),
751 p.to_str().unwrap(),
752 ],
753 );
754 p
755 };
756 let (staged, tasked, silent) = (wt("staged"), wt("tasked"), wt("silent"));
757
758 std::fs::write(staged.join("a.txt"), "a\n").unwrap();
759 git(&staged, &["add", "a.txt"]);
760 age_years(&staged.join("a.txt"));
761
762 std::fs::write(tasked.join("tracked.txt"), "two\n").unwrap();
763 age_years(&tasked.join("tracked.txt"));
764 std::fs::create_dir_all(tasked.join(".ai/local/state")).unwrap();
765 std::fs::write(
766 tasked.join(TASK_RECORD),
767 "id: t-20260101000000-beef\noutcome: active\n",
768 )
769 .unwrap();
770
771 std::fs::write(silent.join("c.txt"), "c\n").unwrap();
772 age_years(&silent.join("c.txt"));
773 let open = silent.join(crate::session::store::OPEN_DIR);
774 std::fs::create_dir_all(&open).unwrap();
775 std::fs::write(
776 open.join("gone.yaml"),
777 "session_id: s-20200101000000-0a0a\nstarted_at: 2020-01-01T00:00:00Z\n",
778 )
779 .unwrap();
780
781 let indexes: Vec<_> = [&staged, &tasked, &silent]
782 .iter()
783 .map(|w| state::git_dir_of(w).unwrap().join("index"))
784 .collect();
785 let before: Vec<_> = indexes.iter().map(|i| std::fs::read(i).ok()).collect();
786
787 let service = WorktreeService::open(&repo).unwrap();
788 let threshold = Threshold::resolve(Some("12h"), None).unwrap();
789 let report = service.uncommitted(&threshold).unwrap();
790
791 let by = |label: &str| {
792 report
793 .worktrees
794 .iter()
795 .find(|w| w.label == label)
796 .unwrap_or_else(|| panic!("{label} is listed: {report:#?}"))
797 };
798 let s = by("work/staged");
799 assert_eq!(s.standing, WorkStanding::Abandoned);
800 assert_eq!((s.paths, s.work.staged), (1, 1));
801 assert!(s.age_seconds.unwrap() > 43_200);
802 assert!(s.held_by.is_empty());
803 let t = by("work/tasked");
804 assert_eq!(t.standing, WorkStanding::Held);
805 assert_eq!(t.task.as_deref(), Some("t-20260101000000-beef"));
806 assert_eq!(t.held_by, ["task t-20260101000000-beef"]);
807 let g = by("work/silent");
808 assert_eq!(g.standing, WorkStanding::Abandoned, "{g:#?}");
809 assert_eq!(g.episodes.len(), 1);
810 assert_eq!(g.episodes[0].stranded, Some(true), "silent since 2020");
811
812 assert_eq!(report.tallies.measured, 3);
813 assert_eq!(
814 (
815 report.tallies.abandoned,
816 report.tallies.held,
817 report.tallies.recent
818 ),
819 (2, 1, 0)
820 );
821 assert_ne!(
822 report.worktrees.last().map(|w| w.standing),
823 Some(WorkStanding::Abandoned),
824 "held work is listed after abandoned work"
825 );
826 let after: Vec<_> = indexes.iter().map(|i| std::fs::read(i).ok()).collect();
827 assert_eq!(before, after, "the reading wrote an index");
828 }
829
830 /// The caller's own threshold answers both questions the policy's does: against a
831 /// hundred thousand days nothing is old, and the episode silent since 2020 holds its work.
832 #[test]
833 fn the_callers_threshold_also_decides_whether_an_episode_holds_the_work() {
834 let dir = tempfile::tempdir().unwrap();
835 let repo = dir.path().join("repo");
836 std::fs::create_dir_all(&repo).unwrap();
837 git(&repo, &["init", "-q"]);
838 git(&repo, &["commit", "-q", "--allow-empty", "-m", "init"]);
839 let silent = dir.path().join("silent");
840 git(
841 &repo,
842 &[
843 "worktree",
844 "add",
845 "-q",
846 "-b",
847 "work/silent",
848 silent.to_str().unwrap(),
849 ],
850 );
851 std::fs::write(silent.join("c.txt"), "c\n").unwrap();
852 age_years(&silent.join("c.txt"));
853 let open = silent.join(crate::session::store::OPEN_DIR);
854 std::fs::create_dir_all(&open).unwrap();
855 std::fs::write(
856 open.join("gone.yaml"),
857 "session_id: s-20200101000000-0a0a\nstarted_at: 2020-01-01T00:00:00Z\n",
858 )
859 .unwrap();
860
861 let service = WorktreeService::open(&repo).unwrap();
862 let generous = Threshold::resolve(Some("12h"), Some("100000d")).unwrap();
863 let report = service.uncommitted(&generous).unwrap();
864 let w = &report.worktrees[0];
865 assert_eq!(w.episodes[0].stranded, Some(false));
866 assert_eq!(w.standing, WorkStanding::Held);
867 assert_eq!(w.held_by, ["episode s-20200101000000-0a0a"]);
868
869 // no threshold at all: nothing is judged old, nothing stranded, and it is said
870 let none = service
871 .uncommitted(&Threshold::resolve(None, None).unwrap())
872 .unwrap();
873 assert_eq!(none.worktrees[0].standing, WorkStanding::Recent);
874 assert_eq!(none.worktrees[0].episodes[0].stranded, None);
875 assert!(none.findings[0].contains(THRESHOLD_KEY));
876 }
877
878 #[test]
879 fn a_rescue_names_a_ref_of_its_own_and_quotes_the_path() {
880 let c = rescue_command("/a b/it's", "fix/x y", "2026-10-14T16:00:00Z");
881 assert!(c.starts_with(r"git -C '/a b/it'\''s' add -A && "), "{c}");
882 assert!(
883 c.ends_with("push origin HEAD:refs/heads/rescue/fix-x-y-20261014"),
884 "{c}"
885 );
886 }
887
888 #[test]
889 fn a_threshold_that_is_not_a_duration_is_refused_by_its_source() {
890 let policy = Threshold::resolve(Some("soon"), None).unwrap_err();
891 assert!(policy.starts_with(THRESHOLD_KEY), "{policy}");
892 let own = Threshold::resolve(Some("12h"), Some("later")).unwrap_err();
893 assert!(own.starts_with("older_than"), "{own}");
894 }
895
896 #[test]
897 fn every_standing_has_its_one_word() {
898 for (s, word) in [
899 (WorkStanding::Held, "held"),
900 (WorkStanding::Recent, "recent"),
901 (WorkStanding::Abandoned, "abandoned"),
902 ] {
903 assert_eq!(s.as_str(), word);
904 assert_eq!(serde_json::to_value(s).unwrap(), word);
905 }
906 }
907
908 #[test]
909 fn the_verdict_has_one_answer_for_every_combination() {
910 assert_eq!(judge(true, None, None), WorkStanding::Held);
911 assert_eq!(judge(false, Some(10), None), WorkStanding::Recent);
912 assert_eq!(judge(false, None, Some(5)), WorkStanding::Abandoned);
913 assert_eq!(judge(false, Some(10), Some(5)), WorkStanding::Abandoned);
914 assert_eq!(judge(false, Some(5), Some(5)), WorkStanding::Recent);
915 assert_eq!(judge(false, Some(3), Some(5)), WorkStanding::Recent);
916 }
917
918 fn entry(path: &str, standing: WorkStanding, age: Option<u64>) -> UncommittedWorktree {
919 UncommittedWorktree {
920 path: path.into(),
921 branch: None,
922 label: path.into(),
923 work: DirtyState::default(),
924 paths: 1,
925 oldest_change: None,
926 age_seconds: age,
927 age: None,
928 task: None,
929 episodes: Vec::new(),
930 process_inside: None,
931 held_by: Vec::new(),
932 standing,
933 remedy: String::new(),
934 }
935 }
936
937 #[test]
938 fn a_listing_is_abandoned_then_recent_then_held_and_oldest_first_inside_each() {
939 let mut listing = vec![
940 entry("/wt/held", WorkStanding::Held, Some(u64::MAX)),
941 entry("/wt/recent-young", WorkStanding::Recent, Some(10)),
942 entry("/wt/recent-unaged", WorkStanding::Recent, None),
943 entry("/wt/recent-old", WorkStanding::Recent, Some(500)),
944 entry("/wt/abandoned", WorkStanding::Abandoned, Some(1)),
945 ];
946 crate::order::canonical(&mut listing);
947 let paths: Vec<&str> = listing.iter().map(|w| w.path.as_str()).collect();
948 assert_eq!(
949 paths,
950 [
951 "/wt/abandoned",
952 "/wt/recent-old",
953 "/wt/recent-young",
954 "/wt/recent-unaged",
955 "/wt/held"
956 ]
957 );
958 }
959
960 #[test]
961 fn a_reading_says_what_it_could_not_read_in_order() {
962 assert!(reading_findings(true, 0, false, 3).is_empty());
963 let all = reading_findings(false, 2, true, 1);
964 assert_eq!(all.len(), 3, "{all:?}");
965 assert!(all[0].contains(THRESHOLD_KEY), "{all:?}");
966 assert!(all[1].starts_with("2 linked work tree(s)"), "{all:?}");
967 assert!(all[2].contains("no lsof"), "{all:?}");
968 assert_eq!(reading_findings(true, 1, false, 0).len(), 1);
969 assert_eq!(
970 reading_findings(true, 0, true, 2),
971 [all[2].clone()],
972 "an unknown process reading is said alone"
973 );
974 }
975
976 /// Whether `lsof` could be read only matters when there was a dirty work tree to ask
977 /// about: both sides of that decision, fed directly, so no platform's `lsof` decides
978 /// which one a test takes.
979 #[test]
980 fn an_unread_process_reading_is_said_only_when_there_was_work_to_ask_about() {
981 assert!(
982 reading_findings(true, 0, true, 0).is_empty(),
983 "no dirty work tree: nothing was asked, nothing is missing"
984 );
985 assert_eq!(reading_findings(true, 0, true, 1).len(), 1);
986 assert!(reading_findings(true, 0, false, 1).is_empty());
987 assert!(reading_findings(true, 0, false, 0).is_empty());
988 }
989
990 #[test]
991 fn a_label_is_the_branch_or_the_detached_commit() {
992 assert_eq!(label_of(Some("feature/x"), Some("abc")), "feature/x");
993 assert_eq!(
994 label_of(None, Some("0123456789abcdef0123")),
995 "detached/0123456789ab"
996 );
997 assert_eq!(label_of(None, None), "detached");
998 }
999
1000 #[test]
1001 fn a_task_a_live_episode_and_a_process_each_hold_the_work() {
1002 let episode = |id: &str, stranded: Option<bool>| OpenEpisode {
1003 id: id.into(),
1004 last_sign_of_life: None,
1005 stranded,
1006 };
1007 let episodes = [
1008 episode("s-live", Some(false)),
1009 episode("s-gone", Some(true)),
1010 episode("s-unjudged", None),
1011 ];
1012 assert_eq!(
1013 holders(Some("t-1"), &episodes, Some(true)),
1014 ["task t-1", "episode s-live", "a process working inside"]
1015 );
1016 assert!(holders(None, &episodes[1..], Some(false)).is_empty());
1017 assert!(holders(None, &[], None).is_empty());
1018 }
1019
1020 /// A detached linked work tree is listed under its short commit, and one whose status
1021 /// git refuses is counted as unread and said, not listed.
1022 #[test]
1023 fn a_detached_work_tree_is_labelled_by_its_commit_and_an_unreadable_one_is_said() {
1024 let dir = tempfile::tempdir().unwrap();
1025 let repo = dir.path().join("repo");
1026 std::fs::create_dir_all(&repo).unwrap();
1027 git(&repo, &["init", "-q"]);
1028 git(&repo, &["commit", "-q", "--allow-empty", "-m", "init"]);
1029 let detached = dir.path().join("detached");
1030 git(
1031 &repo,
1032 &[
1033 "worktree",
1034 "add",
1035 "-q",
1036 "--detach",
1037 detached.to_str().unwrap(),
1038 ],
1039 );
1040 std::fs::write(detached.join("d.txt"), "d\n").unwrap();
1041 let broken = dir.path().join("broken");
1042 git(
1043 &repo,
1044 &[
1045 "worktree",
1046 "add",
1047 "-q",
1048 "-b",
1049 "topic",
1050 broken.to_str().unwrap(),
1051 ],
1052 );
1053 // the link back to the repository names nothing git can read
1054 std::fs::write(
1055 broken.join(".git"),
1056 "gitdir: /nonexistent/majordomus-test\n",
1057 )
1058 .unwrap();
1059
1060 let service = WorktreeService::open(&repo).unwrap();
1061 let report = service
1062 .uncommitted(&Threshold::resolve(Some("12h"), None).unwrap())
1063 .unwrap();
1064 assert_eq!(report.worktrees.len(), 1, "{report:#?}");
1065 assert!(
1066 report.worktrees[0].label.starts_with("detached/"),
1067 "{report:#?}"
1068 );
1069 assert_eq!(report.worktrees[0].branch, None);
1070 assert_eq!(report.tallies.measured, 1, "{report:#?}");
1071 assert!(
1072 report
1073 .findings
1074 .iter()
1075 .any(|f| f.starts_with("1 linked work tree(s) would not answer")),
1076 "{report:#?}"
1077 );
1078 }
1079}