Skip to main content

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}