Skip to main content

repon_core/
entity.rs

1//! One row of the table: an Entity's identity and its state.
2//!
3//! See `docs/spec/core-api.md`'s "The entity key" and "An entity's state" sections,
4//! and [ADR 0019](https://github.com/paulchiu/repon/blob/main/docs/adr/0019-a-detached-head-is-a-shape-of-head-not-a-worktree-state.md)
5//! for [`Head`]'s three shapes.
6
7use std::path::Path;
8use std::sync::Arc;
9use std::time::Duration;
10
11use crate::cell::{Cell, Generation, Settled, Timestamp, Unknown};
12use crate::default_branch;
13use crate::git::{InProgressOperation, RecentCommit};
14
15/// An Entity's identity: a newtype over its own resolved absolute working
16/// directory.
17///
18/// Not the name, which collides across the population; not an integer, which means
19/// nothing across Generations because discovery re-runs at the head of each one;
20/// not the git common dir, which one Repo shares with every Worktree attached to
21/// it. An Entity that moves between Generations therefore reads as vanished plus
22/// new rather than renamed, the same trade session state already takes when it
23/// restores the Selection by name.
24#[derive(Debug, Clone, PartialEq, Eq, Hash)]
25#[cfg_attr(feature = "serde", derive(serde::Serialize))]
26pub struct EntityKey(Arc<Path>);
27
28impl EntityKey {
29    /// Wraps an already-resolved absolute working directory.
30    pub fn new(path: Arc<Path>) -> Self {
31        EntityKey(path)
32    }
33
34    /// The resolved absolute working directory this key identifies.
35    pub fn path(&self) -> &Path {
36        &self.0
37    }
38}
39
40/// Which of the three domain objects an Entity is.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42#[cfg_attr(feature = "serde", derive(serde::Serialize))]
43pub enum Kind {
44    Repo,
45    Worktree,
46    Submodule,
47}
48
49/// HEAD's three shapes, one to one with gix's `head::Kind`.
50///
51/// `Detached` carries the commit and no name; `Unborn` carries the name and no
52/// commit; a bare `Cell<Arc<str>>` could hold neither distinction. `Branch`
53/// carries both, because an attached, born HEAD always has one: the environment
54/// contract's `REPON_HEAD` needs the resolved commit on this shape too, not only
55/// on `Detached`.
56///
57/// Deferred: on the wire, `gix::ObjectId`'s own `Serialize` impl writes a commit as a raw
58/// `{"Sha1":[..20 numbers..]}` array rather than a hex string, because gix does not offer a
59/// hex encoding and adding one here would mean either a hand-written `Serialize` impl or
60/// another crate on the allowlist for one field's cosmetics. Functionally complete, not
61/// pretty; nothing in this ticket's acceptance criteria asks for a particular encoding.
62#[derive(Debug, Clone, PartialEq, Eq)]
63#[cfg_attr(feature = "serde", derive(serde::Serialize))]
64pub enum Head {
65    /// Attached to a branch, which points at a commit.
66    Branch {
67        name: Arc<str>,
68        commit: gix::ObjectId,
69    },
70    /// Detached at a commit, with no branch name.
71    Detached(gix::ObjectId),
72    /// A branch with no commit yet: `## No commits yet on <name>`.
73    Unborn(Arc<str>),
74}
75
76/// Commit counts ahead and behind an Entity's branch's upstream.
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78#[cfg_attr(feature = "serde", derive(serde::Serialize))]
79pub struct AheadBehind {
80    pub ahead: u32,
81    pub behind: u32,
82}
83
84/// The `dirty` cell's settled value: phase C's typed counts, per
85/// [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s "The
86/// phases". A boolean `is_dirty` check was measured and rejected there: proving clean costs
87/// the same as counting, and a boolean cannot answer the untracked count at all, so this
88/// carries all three rather than folding them into one number at the probe.
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
90#[cfg_attr(feature = "serde", derive(serde::Serialize))]
91pub struct DirtyCounts {
92    /// Tracked paths whose content, mode or type changed against the index.
93    pub modified: u32,
94    /// Paths present in the working tree that the index does not track.
95    pub untracked: u32,
96    /// Tracked paths the index has and the working tree no longer does.
97    pub deleted: u32,
98}
99
100impl DirtyCounts {
101    /// The single number the list column and the detail pane both show: the row is clean
102    /// only when every one of the three counts is zero.
103    pub fn total(&self) -> u32 {
104        self.modified + self.untracked + self.deleted
105    }
106}
107
108/// The `sync` cell's settled value: a live upstream's ahead/behind counts, or one of
109/// two facts that preclude a count. `NoRemote` outranks `NoUpstream`, since a Repo
110/// with no remote at all makes every one of its rows, branch or not, unable to have
111/// an upstream in the first place
112/// ([layout-and-provenance.md](https://github.com/paulchiu/repon/blob/main/docs/spec/layout-and-provenance.md)'s
113/// "Glyphs").
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115#[cfg_attr(feature = "serde", derive(serde::Serialize))]
116pub enum SyncState {
117    /// A live upstream: its ahead/behind counts.
118    Tracking(AheadBehind),
119    /// No branch at all, or a branch with no upstream configured.
120    NoUpstream,
121    /// The Repo has no remote at all, on this row and every one of its Worktree
122    /// rows.
123    NoRemote,
124}
125
126/// The four mutually exclusive Worktree states, proven by ancestry or patch
127/// equivalence. `Dirty` is a separate, orthogonal cell, not a fifth arm here.
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129#[cfg_attr(feature = "serde", derive(serde::Serialize))]
130pub enum WorktreeState {
131    Merged,
132    Gone,
133    LocalOnly,
134    Active,
135}
136
137/// The default branch's resolved name.
138///
139/// The rung that answered and any rung-2/rung-3 disagreement live on
140/// [`Diagnostics`], not here, because those are facts about how the value was
141/// obtained rather than the value itself.
142#[derive(Debug, Clone, PartialEq, Eq)]
143#[cfg_attr(feature = "serde", derive(serde::Serialize))]
144pub struct DefaultBranch(Arc<str>);
145
146impl DefaultBranch {
147    /// Wraps an already-resolved default branch name.
148    pub fn new(name: Arc<str>) -> Self {
149        DefaultBranch(name)
150    }
151
152    /// The resolved default branch name.
153    pub fn name(&self) -> &str {
154        &self.0
155    }
156}
157
158/// Why the default branch resolution chain reached rung 4, from facts the chain
159/// already has at no extra cost: which of gix's own remote enumeration, or rung
160/// 3's own name list, came up empty.
161#[derive(Debug, Clone, Copy, PartialEq, Eq)]
162#[cfg_attr(feature = "serde", derive(serde::Serialize))]
163pub enum DefaultBranchStopped {
164    /// The repository has no remote at all.
165    NoRemote,
166    /// Two or more remotes exist and none is named `origin`, so gix's own
167    /// fetch-default refuses to guess.
168    AmbiguousRemote,
169    /// A remote was chosen, but neither `origin/HEAD` nor the name list named a
170    /// ref that still resolves.
171    NameListExhausted,
172}
173
174/// Per-Entity facts that are not Cells: which rung of the default branch
175/// resolution chain answered, whether rung 2 and rung 3 disagreed, why
176/// resolution stopped when it did not settle, and whether `.gitmodules` failed to
177/// read or parse.
178///
179/// Every field but `gitmodules_failed` reaches the detail pane and stays out of
180/// the row summary fold, describing how a value was obtained rather than a value
181/// that can itself fail; `gitmodules_failed` is the one exception the fold reads.
182#[derive(Debug, Clone, Default, PartialEq, Eq)]
183#[cfg_attr(feature = "serde", derive(serde::Serialize))]
184pub struct Diagnostics {
185    /// The rung (1 to 4) that resolved the default branch, once resolution has run.
186    pub default_branch_rung: Option<u8>,
187    /// Whether rung 2's answer disagreed with rung 3's.
188    pub default_branch_rung_disagreement: bool,
189    /// Whether rung 2 read a symbolic `origin/HEAD` whose target no longer
190    /// resolves, the stale-but-successful case neither `git symbolic-ref` nor
191    /// gix's own `target()` check for on their own.
192    pub default_branch_rung_two_stale: bool,
193    /// Why resolution reached rung 4, once it has; `None` while a rung 1 to 3
194    /// answer stands.
195    pub default_branch_stopped: Option<DefaultBranchStopped>,
196    /// Why this entity's own `.gitmodules` would not read or parse, if it has one
197    /// and it failed; `None` covers both "no `.gitmodules`" and "read cleanly".
198    pub gitmodules_failed: Option<Arc<str>>,
199}
200
201/// What a step Repon performed itself came to, in Repon's own words
202/// ([`docs/spec/actions.md`](https://github.com/paulchiu/repon/blob/main/docs/spec/actions.md)'s
203/// "Why the set grew from four to five"): three grades, matched exhaustively wherever a
204/// [`StepOutcome::OwnWork`] is opened.
205///
206/// Words rather than a code, because Repon knows what it did and can say so, where an exit
207/// code is a number only a child process could have produced. The sentence is the consumer's
208/// to author, the same standing [`ActionReceipt::label`] and [`StepResult::label`] already
209/// have.
210#[derive(Debug, Clone, PartialEq, Eq)]
211#[cfg_attr(feature = "serde", derive(serde::Serialize))]
212pub enum OwnWork {
213    /// Repon did the work, and this is what it did.
214    Did(Arc<str>),
215    /// Repon would not act, and this is why. Not a failure: nothing went wrong, so it never
216    /// widens the row summary fold, for the same reason `Cancelled` does not.
217    Refused(Arc<str>),
218    /// Repon tried and could not finish, and this is what stopped it. The one grade that is
219    /// a failure.
220    CouldNotAct(Arc<str>),
221}
222
223impl OwnWork {
224    /// Repon's own words for this outcome, whichever grade it is.
225    pub fn said(&self) -> &Arc<str> {
226        match self {
227            OwnWork::Did(said) | OwnWork::Refused(said) | OwnWork::CouldNotAct(said) => said,
228        }
229    }
230}
231
232/// One step's own outcome: a closed set of exactly five
233/// ([`docs/spec/actions.md`](https://github.com/paulchiu/repon/blob/main/docs/spec/actions.md)'s
234/// "Step outcomes"). No wildcard arm ever matches this: a sixth variant must be named at
235/// every match site or the crate fails to compile.
236///
237/// The first four are a child process's. `OwnWork` is the step Repon performed itself, with
238/// no child process anywhere in it, which is what lets a Management operation leave a receipt
239/// instead of borrowing an outcome that means something else
240/// ([repo-management.md](https://github.com/paulchiu/repon/blob/main/docs/spec/repo-management.md)'s
241/// "Receipts").
242///
243/// `Cancelled` is explicitly not a failure and is never themed as one, following
244/// [ADR 0013](https://github.com/paulchiu/repon/blob/main/docs/adr/0013-no-filesystem-watching-a-refresh-is-a-cancellable-generation.md)'s
245/// precedent that interrupted work becomes Unknown rather than Failed; [`Self::is_failure`]
246/// is the one place that classification lives; a match on the five variants should still
247/// name each one rather than borrow this method to skip a step, since the two calls answer
248/// different questions.
249#[derive(Debug, Clone, PartialEq, Eq)]
250#[cfg_attr(feature = "serde", derive(serde::Serialize))]
251pub enum StepOutcome {
252    /// Ran and exited zero.
253    Ok,
254    /// Ran and exited nonzero; the code is carried.
255    Failed(i32),
256    /// An earlier step failed, so this one never started.
257    NotRun,
258    /// The run was cancelled before this step finished, or before it started.
259    Cancelled,
260    /// No child process ran: Repon did this step itself, and says so in its own words.
261    OwnWork(OwnWork),
262}
263
264impl StepOutcome {
265    /// Whether this outcome counts as a failure for the row summary fold: `Failed` and own
266    /// work Repon could not finish, never `Cancelled`, `NotRun`, `Ok` or a refusal. One arm
267    /// per variant, no catch-all, so a sixth variant must be classified here or the crate
268    /// fails to compile, rather than silently falling through as a non-failure.
269    pub fn is_failure(&self) -> bool {
270        match self {
271            StepOutcome::Ok => false,
272            StepOutcome::Failed(_) => true,
273            StepOutcome::NotRun => false,
274            StepOutcome::Cancelled => false,
275            StepOutcome::OwnWork(OwnWork::Did(_)) => false,
276            StepOutcome::OwnWork(OwnWork::Refused(_)) => false,
277            StepOutcome::OwnWork(OwnWork::CouldNotAct(_)) => true,
278        }
279    }
280
281    /// Whether this outcome is a step Repon performed itself that would not act. Kept beside
282    /// [`Self::is_failure`] so the two classifications the fold, the pane and the `action:`
283    /// Filter term all read live in one place and cannot disagree.
284    pub fn is_refusal(&self) -> bool {
285        match self {
286            StepOutcome::Ok => false,
287            StepOutcome::Failed(_) => false,
288            StepOutcome::NotRun => false,
289            StepOutcome::Cancelled => false,
290            StepOutcome::OwnWork(OwnWork::Did(_)) => false,
291            StepOutcome::OwnWork(OwnWork::Refused(_)) => true,
292            StepOutcome::OwnWork(OwnWork::CouldNotAct(_)) => false,
293        }
294    }
295}
296
297/// What a step's captured output lost to the head-plus-tail bound
298/// ([`docs/spec/actions.md`](https://github.com/paulchiu/repon/blob/main/docs/spec/actions.md)'s
299/// "Capture"): two counts and no mark.
300///
301/// The mark that draws the gap is the consumer's, chosen from its live glyph set at render
302/// time, so an `ascii` reader gets an ascii mark; this is the same split
303/// [ADR 0010](https://github.com/paulchiu/repon/blob/main/docs/adr/0010-provenance-renders-as-a-row-gutter-and-blank-cells.md)
304/// makes between a provenance state and the glyph that renders it. Writing a formatted line
305/// into [`StepResult::output`] instead would leave the consumer string-matching its own
306/// quotation of another program's screen, which a step printing that same text defeats.
307#[derive(Debug, Clone, Copy, PartialEq, Eq)]
308#[cfg_attr(feature = "serde", derive(serde::Serialize))]
309pub struct CaptureElision {
310    /// How many lines the bound dropped.
311    pub dropped_lines: usize,
312    /// How many of [`StepResult::output`]'s own lines precede the gap, so a renderer draws
313    /// its mark after that many lines and before the kept tail.
314    pub kept_head_lines: usize,
315}
316
317/// One step's own result within an [`ActionReceipt`]: its label, its outcome, its
318/// captured output and its elapsed time
319/// ([`docs/spec/actions.md`](https://github.com/paulchiu/repon/blob/main/docs/spec/actions.md)'s
320/// "Where the result lives").
321///
322/// `label` and `output` are `Arc` rather than `String`/`Vec<u8>` for the same reason
323/// every text-bearing value on [`EntityState`] is: `Core::snapshot` clones the whole table
324/// every frame, and an `Arc` clone is a refcount bump rather than a copy of the bytes.
325#[derive(Debug, Clone, PartialEq, Eq)]
326#[cfg_attr(feature = "serde", derive(serde::Serialize))]
327pub struct StepResult {
328    /// The step's argv, or the operation for a step Repon performed itself, rendered for
329    /// display.
330    pub label: Arc<str>,
331    pub outcome: StepOutcome,
332    /// Raw bytes, bounded, never interpreted here. Empty for a step Repon performed itself,
333    /// which has no other program's screen to quote.
334    pub output: Arc<[u8]>,
335    pub elapsed: Duration,
336    /// What the bound dropped out of `output`, or `None` when the output fitted whole.
337    pub elision: Option<CaptureElision>,
338    /// Whether `label` ran through `$SHELL -c` rather than as a literal argv
339    /// ([`docs/spec/actions.md`](https://github.com/paulchiu/repon/blob/main/docs/spec/actions.md)'s
340    /// "The Selection and the gate"). `label` alone answers "what ran" for an argv step, but
341    /// not for a shell one, whose string is an interpreter's input rather than its own
342    /// finished command line; this is the mode that input was read under. `false` for a step
343    /// Repon performed itself, which ran through no interpreter at all.
344    pub shell: bool,
345    /// Whether `label` ran through `$SHELL -ic` rather than `$SHELL -c`
346    /// ([config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md)'s
347    /// `interactive` key). Meaningless unless `shell` is also `true`; always `false`
348    /// otherwise, since a non-shell or own-work step read no interpreter flag at all.
349    pub interactive: bool,
350}
351
352/// The step an [`ActionReceipt`] is executing right now, present only while its run has not
353/// yet finished ([`docs/spec/actions.md`](https://github.com/paulchiu/repon/blob/main/docs/spec/actions.md)'s
354/// "The run on screen": "a running step carries the spinner in the same position the step
355/// number's outcome will occupy").
356///
357/// `started_at` is a real timestamp rather than a stored `Duration`, so a renderer computes
358/// live elapsed time with [`Timestamp::elapsed`] on every draw instead of this value being
359/// rewritten every frame.
360#[derive(Debug, Clone, PartialEq, Eq)]
361#[cfg_attr(feature = "serde", derive(serde::Serialize))]
362pub struct RunningStep {
363    /// The step's argv, rendered for display, the same text [`StepResult::label`] carries
364    /// once this step finishes.
365    pub label: Arc<str>,
366    pub started_at: Timestamp,
367    /// [`StepResult::shell`]'s own claim, carried here too so a running step's mode is on
368    /// screen from the moment it starts rather than only once it finishes.
369    pub shell: bool,
370    /// [`StepResult::interactive`]'s own claim, carried here too for the same reason.
371    pub interactive: bool,
372}
373
374/// The most recent Action run against this Entity: a receipt of something Repon did,
375/// not a reading of the world, read by the row summary fold
376/// ([`docs/spec/actions.md`](https://github.com/paulchiu/repon/blob/main/docs/spec/actions.md),
377/// [ADR 0018](https://github.com/paulchiu/repon/blob/main/docs/adr/0018-an-action-is-a-fanout-of-pty-backed-steps.md)).
378///
379/// Deliberately outside the Cell machinery this module otherwise builds on: `crate::snapshot`'s
380/// private `FoldableCell` trait, the only way a value ever joins the row fold's Cell array, is
381/// implemented once, generically, for `Cell<T>` alone, and is not visible outside `crate::snapshot`
382/// at all, so nothing in this module could implement it even by mistake. A receipt carries no
383/// [`Generation`], never goes stale on the metadata poll, is never superseded (there is no older
384/// or newer receipt to compare against, only the latest one), and the vanished-staleness
385/// path's own exhaustive destructure names this field only to skip it (`last_action: _`),
386/// leaving it exactly as it was. It also never persists: it lives in memory for the session
387/// and dies with the process, which satisfies "keep until the next run" with no key, no clock
388/// and no expiry; the
389/// configurable-expiry half of the recorded requirement is dropped outright on the startup-cost
390/// grounds `docs/spec/actions.md` measures, not deferred.
391#[derive(Debug, Clone, PartialEq, Eq)]
392#[cfg_attr(feature = "serde", derive(serde::Serialize))]
393pub struct ActionReceipt {
394    /// The Action's name, or the typed command string.
395    pub label: Arc<str>,
396    /// The steps that have finished so far, in order. Empty when `skip` is `Some`, and not
397    /// yet the whole Action's step list while `running` is `Some`: a step neither finished
398    /// nor currently executing has no representation here at all
399    /// (`docs/spec/actions.md`'s "The run on screen").
400    pub steps: Arc<[StepResult]>,
401    /// Why this row carries no steps, or `None` for a row that actually ran
402    /// ([`Skip`], `docs/spec/actions.md`'s "The Selection and the gate").
403    pub skip: Option<Skip>,
404    pub finished_at: Timestamp,
405    /// The step executing right now, or `None` once every step has finished (or none ever
406    /// ran, as for a receipt carrying `skip`). `Core::run_action` writes this receipt to
407    /// the table once per step, so a reader sees it update as the run progresses rather
408    /// than only once at the very end.
409    ///
410    /// The grain is the step, not the byte: a running step's own captured output is not
411    /// here, because `executor::run_step` returns it only once the child has exited. A
412    /// reader sees a step's label, its spinner and its live elapsed time immediately, and
413    /// its output the instant that step ends, rather than mid-step. Streaming that would
414    /// mean `drain_until_exit` publishing incremental snapshots.
415    ///
416    /// `steps` therefore holds only finished steps while this is `Some`, which is what
417    /// keeps [`ActionReceipt::failed`] honest mid-run. Nothing may read this receipt's
418    /// presence as "the run is over"; read `running.is_none()` for that.
419    pub running: Option<RunningStep>,
420}
421
422/// Why a row's Action receipt carries no steps and was never operated on, the three ways
423/// `Core::run_action` decides a row out before the fan-out starts
424/// (`docs/spec/actions.md`'s "The Selection and the gate"). Closed and matched exhaustively
425/// rather than three booleans, so a fourth reason to skip a row is a compile error here
426/// rather than a silent omission.
427#[derive(Debug, Clone, Copy, PartialEq, Eq)]
428#[cfg_attr(feature = "serde", derive(serde::Serialize))]
429pub enum Skip {
430    /// A `[[repo]]` entry with `exclude = true`: the row was in the Selection and never
431    /// operated on. The one legitimate producer of `Not applicable`.
432    Excluded,
433    /// The Action's own `when` predicate disproved this row: it was operable, but `when`
434    /// said the row does not apply, so it was never handed a step.
435    Inapplicable,
436    /// The Action's own `when` predicate could not settle on this row, because a Cell it
437    /// reads has not settled. Not excluded and not disproved: a run has no basis to touch
438    /// an unprovable row, so it is skipped exactly as a disproved one is.
439    Unresolved,
440}
441
442impl ActionReceipt {
443    /// Whether any step in this run failed, which is what widens the row summary fold
444    /// even though every Cell reads fine
445    /// (`docs/spec/core-api.md`'s "row summary", `docs/spec/actions.md`'s "Where the
446    /// result lives"). A `NotRun` or `Cancelled` step never counts, only a genuine
447    /// `Failed` one.
448    pub fn failed(&self) -> bool {
449        self.steps.iter().any(|step| step.outcome.is_failure())
450    }
451
452    /// An excluded row that was in the Selection: nothing failed and nothing was blocked,
453    /// the row was simply never operated on. The one legitimate producer of `Not
454    /// applicable` (`docs/spec/actions.md`'s "The Selection and the gate").
455    pub fn not_applicable(&self) -> bool {
456        self.skip == Some(Skip::Excluded)
457    }
458
459    /// The Action's own `when` disproved this row, so it never ran.
460    pub fn inapplicable(&self) -> bool {
461        self.skip == Some(Skip::Inapplicable)
462    }
463
464    /// The Action's own `when` could not settle on this row, so it never ran either: not
465    /// excluded, not disproved, and not operated on.
466    pub fn unresolved(&self) -> bool {
467        self.skip == Some(Skip::Unresolved)
468    }
469
470    /// Whether this run refused rather than acted: a step Repon performed itself would not
471    /// act and none failed. Neither a success nor a failure, so it leaves the row summary
472    /// fold alone and earns its own word in the detail pane and its own `action:` Filter
473    /// value ([`docs/spec/actions.md`](https://github.com/paulchiu/repon/blob/main/docs/spec/actions.md)'s
474    /// `OwnWork`).
475    pub fn refused(&self) -> bool {
476        !self.failed() && self.steps.iter().any(|step| step.outcome.is_refusal())
477    }
478}
479
480/// What accepting a `delete` confirm gate will destroy in one Repo or Worktree, per
481/// [repo-management.md](https://github.com/paulchiu/repon/blob/main/docs/spec/repo-management.md)'s
482/// "The confirm gate": the facts the gate names per row, read fresh at the moment the gate is
483/// built rather than folded out of Cells a Generation may have left stale. `linked_worktrees`
484/// is meaningful on a Repo row alone; a Worktree row's own gate line never names it, since
485/// deleting one Worktree never touches its siblings.
486///
487/// Not a Cell and not part of the row summary fold: it is a fact of the instant it was read,
488/// the same standing [`crate::InProgressOperation`] has, and it never reaches the table at
489/// all. A row with `uncommitted` false and every count zero is the "listed plainly" case the
490/// gate names with no risk line of its own.
491#[derive(Debug, Clone, Copy, PartialEq, Eq)]
492pub struct DeleteRisk {
493    /// Work that is not in a commit: anything [`DirtyCounts`] counts between the index and
494    /// the working tree, or anything staged between `HEAD` and the index. The second half is
495    /// what the dirty column does not ask about and what a `delete` most easily loses
496    /// ([repo-management.md](https://github.com/paulchiu/repon/blob/main/docs/spec/repo-management.md)'s
497    /// confirm gate).
498    pub uncommitted: bool,
499    /// Commits on this Repo's own local branches that no remote-tracking ref carries.
500    pub unpushed_commits: u32,
501    /// How many of its local branches carry at least one of those commits.
502    pub unpushed_branches: u32,
503    /// How many linked Worktrees point into this Repo, which deleting its working tree
504    /// destroys along with it.
505    pub linked_worktrees: u32,
506}
507
508/// Whether an Entity was found by the Refresh that just ran.
509///
510/// A Vanished row keeps `~`: every `Known` cell goes stale, which is both what
511/// the fold already produces and true, since those values are old and nothing
512/// will fix them. The condition itself is carried by a Warning rather than a
513/// fifth gutter mark, because the gutter summarises provenance rather than
514/// presence, and because a mark on a row cannot tell a reader the row is there.
515/// While probing is still limited to `branch` and `default_branch`, a Vanished
516/// Entity's other, never-yet-probed cells fold as Unknown instead and can
517/// outrank that stale mark, so the row may render `?` rather than `~` until
518/// every phase is probing; that is a transient of incomplete probing, not a
519/// second answer. Dismissal needs no undo, since session state never persists
520/// and a Repo that returns is rediscovered. Still open: the progressive-fill
521/// timing a Vanished row's redraw should honour.
522#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
523#[cfg_attr(feature = "serde", derive(serde::Serialize))]
524pub enum Presence {
525    #[default]
526    Present,
527    Vanished,
528}
529
530/// One Entity's state: a struct of named Cells rather than a map, because the
531/// grid is not rectangular and each column carries its own payload type.
532#[derive(Debug, Clone)]
533#[cfg_attr(feature = "serde", derive(serde::Serialize))]
534pub struct EntityState {
535    pub key: EntityKey,
536    pub name: Arc<str>,
537    pub common_dir: Arc<Path>,
538    pub kind: Kind,
539    pub branch: Cell<Head>,
540    pub sync: Cell<SyncState>,
541    pub base: Cell<u32>,
542    pub dirty: Cell<DirtyCounts>,
543    pub state: Cell<WorktreeState>,
544    pub default_branch: Cell<DefaultBranch>,
545    pub diagnostics: Diagnostics,
546    pub last_action: Option<ActionReceipt>,
547    pub presence: Presence,
548    /// Listed, never operated on, per a matching `[[repo]]` entry's `exclude = true`
549    /// ([config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md#per-repo-entries)).
550    /// Distinct from a Set's exclude glob, which keeps an entity out of discovery
551    /// entirely: an excluded entity is still a row here, still selectable, and this
552    /// is the fact a row count or a confirm gate subtracts it against.
553    pub excluded: bool,
554    /// The in-progress git operation read from this entity's own repository
555    /// state, if any: not a Cell, not part of the row summary fold, and read by
556    /// the detail pane alone
557    /// ([ADR 0019](https://github.com/paulchiu/repon/blob/main/docs/adr/0019-a-detached-head-is-a-shape-of-head-not-a-worktree-state.md)).
558    pub in_progress_operation: Option<InProgressOperation>,
559    /// Up to a fixed handful of this entity's most recent commits, most recent
560    /// first. Empty before the first probe, or when HEAD is unborn.
561    pub recent_commits: Vec<RecentCommit>,
562}
563
564impl EntityState {
565    /// A freshly discovered Entity: every Cell unset, Present, no last run.
566    ///
567    /// A Submodule is constructed with `state` and `base` already
568    /// `Unknown(NoDefaultBranch)`: [ADR 0012](https://github.com/paulchiu/repon/blob/main/docs/adr/0012-the-default-branch-is-a-remote-tracking-ref.md)
569    /// records that population's default branch as known-wrong with no local
570    /// detector, so a proof computed against it would be a confident lie, and
571    /// that is a question with an answer Repon cannot stand behind rather than a
572    /// question with no meaning on the row
573    /// (see [ADR 0017](https://github.com/paulchiu/repon/blob/main/docs/adr/0017-discovery-stops-at-the-repo-boundary.md),
574    /// as amended). A Repo is constructed with `state` already
575    /// `NotApplicable`: the four Worktree states describe a Worktree's own
576    /// branch, and a Repo row has none to describe, by kind rather than by HEAD's
577    /// shape. Leaving `state` unset instead would fold the row to Unknown rather
578    /// than excluding the cell, which is what would put a question mark in the
579    /// gutter of every Repo row on screen.
580    pub fn new(key: EntityKey, name: Arc<str>, common_dir: Arc<Path>, kind: Kind) -> Self {
581        let mut entity = EntityState {
582            key,
583            name,
584            common_dir,
585            kind,
586            branch: Cell::default(),
587            sync: Cell::default(),
588            base: Cell::default(),
589            dirty: Cell::default(),
590            state: Cell::default(),
591            default_branch: Cell::default(),
592            diagnostics: Diagnostics::default(),
593            last_action: None,
594            presence: Presence::default(),
595            excluded: false,
596            in_progress_operation: None,
597            recent_commits: Vec::new(),
598        };
599
600        if matches!(entity.kind, Kind::Repo) {
601            entity
602                .state
603                .settle(Generation::default(), Settled::NotApplicable);
604        }
605        if matches!(entity.kind, Kind::Submodule) {
606            entity.state.settle(
607                Generation::default(),
608                Settled::Unknown(Unknown::NoDefaultBranch),
609            );
610            entity.base.settle(
611                Generation::default(),
612                Settled::Unknown(Unknown::NoDefaultBranch),
613            );
614        }
615
616        entity
617    }
618
619    /// Settles `resolution` onto this entity's `default_branch` cell for
620    /// `generation`, and, only if that write actually applied (was not
621    /// superseded by a newer Generation already recorded there), records its
622    /// diagnostics beside it. The one place that write happens: a write the
623    /// supersession check rejects must never leave diagnostics describing an
624    /// answer the cell itself discarded.
625    pub(crate) fn apply_default_branch_resolution(
626        &mut self,
627        generation: Generation,
628        resolution: default_branch::Resolution,
629    ) {
630        let rung = resolution.rung;
631        let disagreement = resolution.disagreement;
632        let stale_remote_head = resolution.stale_remote_head;
633        let stopped = resolution.stopped;
634        let applied = self.default_branch.settle(generation, resolution.settled);
635        if applied {
636            self.diagnostics.default_branch_rung = Some(rung);
637            self.diagnostics.default_branch_rung_disagreement = disagreement;
638            self.diagnostics.default_branch_rung_two_stale = stale_remote_head;
639            self.diagnostics.default_branch_stopped = stopped;
640        }
641    }
642
643    /// Settles `branch` onto this entity's `branch` cell for `generation`, and,
644    /// only if that write actually applied, records the in-progress operation
645    /// and recent commits read alongside it. The same supersession-gated pattern
646    /// as [`Self::apply_default_branch_resolution`]: neither of these two facts is
647    /// a Cell in its own right, so without this gate a probe result landing out
648    /// of Generation order could overwrite a newer branch read's own facts with
649    /// an older read's.
650    pub(crate) fn apply_branch_probe(
651        &mut self,
652        generation: Generation,
653        branch: Settled<Head>,
654        in_progress_operation: Option<InProgressOperation>,
655        recent_commits: Vec<RecentCommit>,
656    ) -> bool {
657        let applied = self.branch.settle(generation, branch);
658        if applied {
659            self.in_progress_operation = in_progress_operation;
660            self.recent_commits = recent_commits;
661        }
662        applied
663    }
664
665    /// Whether this Entity's `state` cell is ever (re)probed: `false` for a Repo,
666    /// whose `state` construction settled `NotApplicable`, and `false` for a
667    /// Submodule too, even though construction settled its `state` `Unknown`
668    /// rather than `NotApplicable`: a Submodule's default branch has no local
669    /// detector for the confident-wrong case
670    /// ([ADR 0012](https://github.com/paulchiu/repon/blob/main/docs/adr/0012-the-default-branch-is-a-remote-tracking-ref.md)),
671    /// so re-probing could settle a confident lie even on a Generation where the
672    /// entity's own `default_branch` cell happens to resolve. `true` for a
673    /// Worktree. `kind` is checked explicitly here rather than read off the
674    /// cell's own settled state, because `Unknown` is also the shape a Worktree's
675    /// `state` legitimately settles to (its own default branch unresolved) and
676    /// must go on being re-probed.
677    pub(crate) fn probes_state(&self) -> bool {
678        !matches!(self.kind, Kind::Submodule)
679            && !matches!(self.state.settled(), Some(Settled::NotApplicable))
680    }
681
682    /// Whether this Entity's `base` cell is ever (re)probed: `false` for a
683    /// Submodule, for the same reason and by the same `kind` check as
684    /// [`Self::probes_state`]. Otherwise unchanged: `true` for a Repo or a
685    /// Worktree until `base` itself settles `NotApplicable` (a row on its own
686    /// default branch, or a Repo with no remote), the same as before this
687    /// method also checked `kind`.
688    pub(crate) fn probes_base(&self) -> bool {
689        !matches!(self.kind, Kind::Submodule)
690            && !matches!(self.base.settled(), Some(Settled::NotApplicable))
691    }
692
693    /// Marks this Entity Vanished: it stays in the table with its last known
694    /// values, and every Cell's `Known` value is forced stale rather than
695    /// blanked. The same call for a Repo, a Worktree or a Submodule alike; a
696    /// Cell already `NotApplicable`, `Unknown` or `Failed` is untouched.
697    ///
698    /// Destructures `self` exhaustively so a Cell added to `EntityState` later
699    /// fails to compile here rather than silently never going stale.
700    pub(crate) fn mark_vanished(&mut self) {
701        self.presence = Presence::Vanished;
702        let EntityState {
703            key: _,
704            name: _,
705            common_dir: _,
706            kind: _,
707            branch,
708            sync,
709            base,
710            dirty,
711            state,
712            default_branch,
713            diagnostics: _,
714            last_action: _,
715            presence: _,
716            excluded: _,
717            in_progress_operation: _,
718            recent_commits: _,
719        } = self;
720        branch.force_stale();
721        sync.force_stale();
722        base.force_stale();
723        dirty.force_stale();
724        state.force_stale();
725        default_branch.force_stale();
726    }
727
728    /// Marks `dirty` and `state`, the two cells with no cheap poll evidence, Stale
729    /// in place: the metadata poll's own writer, called the moment it sees gitdir
730    /// movement it re-runs branch and sync for rather than re-probing itself
731    /// ([refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
732    /// "The poll"). [`Self::age_status_cells`] writes the same two cells' `stale`
733    /// field on elapsed time instead, which is staleness's other writer
734    /// ([core-api.md](https://github.com/paulchiu/repon/blob/main/docs/spec/core-api.md)'s
735    /// "Staleness"). Destructures `self` exhaustively so a Cell added later is
736    /// named here, even as `_`, rather than silently never going stale on
737    /// movement.
738    pub(crate) fn force_stale_status_cells(&mut self) {
739        let EntityState {
740            key: _,
741            name: _,
742            common_dir: _,
743            kind: _,
744            branch: _,
745            sync: _,
746            base: _,
747            dirty,
748            state,
749            default_branch: _,
750            diagnostics: _,
751            last_action: _,
752            presence: _,
753            excluded: _,
754            in_progress_operation: _,
755            recent_commits: _,
756        } = self;
757        dirty.force_stale();
758        state.force_stale();
759    }
760
761    /// Marks `dirty` and `state` Stale once their last known value is at least
762    /// `threshold` old: the elapsed-age writer for the same field
763    /// [`Self::force_stale_status_cells`] writes on poll evidence, so a consumer
764    /// reading either cell never sees a threshold, only the one stored boolean
765    /// either writer produces. Exhaustive for the same reason.
766    pub(crate) fn age_status_cells(&mut self, threshold: Duration) {
767        let EntityState {
768            key: _,
769            name: _,
770            common_dir: _,
771            kind: _,
772            branch: _,
773            sync: _,
774            base: _,
775            dirty,
776            state,
777            default_branch: _,
778            diagnostics: _,
779            last_action: _,
780            presence: _,
781            excluded: _,
782            in_progress_operation: _,
783            recent_commits: _,
784        } = self;
785        dirty.age_into_stale(threshold);
786        state.age_into_stale(threshold);
787    }
788}
789
790#[cfg(test)]
791mod tests {
792    use super::*;
793    use crate::cell::Timestamp;
794
795    fn key(path: &str) -> EntityKey {
796        EntityKey::new(Arc::from(Path::new(path)))
797    }
798
799    /// [ADR 0017](https://github.com/paulchiu/repon/blob/main/docs/adr/0017-discovery-stops-at-the-repo-boundary.md),
800    /// as amended: a Submodule's default branch has no local detector for its
801    /// known-wrong case, so a proof against it would be a confident lie. That is
802    /// a question with an answer Repon cannot stand behind, `Unknown`, not a
803    /// question with no meaning on the row, `NotApplicable`.
804    #[test]
805    fn a_submodule_is_constructed_with_state_and_base_unknown() {
806        let entity = EntityState::new(
807            key("/repo/vendor/lib"),
808            Arc::from("lib"),
809            Arc::from(Path::new("/repo/.git")),
810            Kind::Submodule,
811        );
812
813        assert!(matches!(
814            entity.state.settled(),
815            Some(Settled::Unknown(Unknown::NoDefaultBranch))
816        ));
817        assert!(matches!(
818            entity.base.settled(),
819            Some(Settled::Unknown(Unknown::NoDefaultBranch))
820        ));
821    }
822
823    /// One of `NotApplicable`'s named producers, alongside the two `base`
824    /// exemptions [`crate::cell::Settled`] documents: Worktree state describes a
825    /// Worktree's own branch, and a Repo row has none, by kind rather than by
826    /// HEAD's shape. Left unset instead, the cell would fold to Unknown rather
827    /// than being excluded, putting a question mark in the gutter of every Repo
828    /// row on screen.
829    #[test]
830    fn a_repo_rows_worktree_state_is_not_applicable_so_no_parent_row_carries_a_question_mark() {
831        let entity = EntityState::new(
832            key("/repo"),
833            Arc::from("repo"),
834            Arc::from(Path::new("/repo/.git")),
835            Kind::Repo,
836        );
837
838        assert!(matches!(
839            entity.state.settled(),
840            Some(Settled::NotApplicable)
841        ));
842        assert!(entity.base.settled().is_none());
843    }
844
845    /// Absence claim: the four Worktree states are the whole set. This match has
846    /// no wildcard arm, so a fifth variant added to `WorktreeState` fails to
847    /// compile here rather than silently falling through an `_`.
848    #[test]
849    fn worktree_state_is_exactly_four_mutually_exclusive_variants() {
850        fn name(state: WorktreeState) -> &'static str {
851            match state {
852                WorktreeState::Merged => "merged",
853                WorktreeState::Gone => "gone",
854                WorktreeState::LocalOnly => "local_only",
855                WorktreeState::Active => "active",
856            }
857        }
858
859        assert_eq!(name(WorktreeState::Merged), "merged");
860        assert_eq!(name(WorktreeState::Gone), "gone");
861        assert_eq!(name(WorktreeState::LocalOnly), "local_only");
862        assert_eq!(name(WorktreeState::Active), "active");
863    }
864
865    /// Absence claim: the three `SyncState` variants are the whole set. This match has no
866    /// wildcard arm, so a fourth variant added to `SyncState` fails to compile
867    /// here rather than silently falling through an `_`.
868    #[test]
869    fn sync_state_is_exactly_three_mutually_exclusive_variants() {
870        fn name(value: SyncState) -> &'static str {
871            match value {
872                SyncState::Tracking(_) => "tracking",
873                SyncState::NoUpstream => "no_upstream",
874                SyncState::NoRemote => "no_remote",
875            }
876        }
877
878        assert_eq!(
879            name(SyncState::Tracking(AheadBehind {
880                ahead: 0,
881                behind: 0
882            })),
883            "tracking"
884        );
885        assert_eq!(name(SyncState::NoUpstream), "no_upstream");
886        assert_eq!(name(SyncState::NoRemote), "no_remote");
887    }
888
889    // --- StepOutcome / StepResult / ActionReceipt: the receipt widening, docs/spec/actions.md ---
890
891    /// Absence claim: the five `StepOutcome` variants are the whole set, and the three
892    /// `OwnWork` grades are the whole of that. Neither match has a wildcard arm, so a variant
893    /// added to either fails to compile here rather than silently falling through an `_`.
894    #[test]
895    fn step_outcome_is_exactly_five_mutually_exclusive_variants() {
896        fn name(outcome: &StepOutcome) -> &'static str {
897            match outcome {
898                StepOutcome::Ok => "ok",
899                StepOutcome::Failed(_) => "failed",
900                StepOutcome::NotRun => "not_run",
901                StepOutcome::Cancelled => "cancelled",
902                StepOutcome::OwnWork(work) => match work {
903                    OwnWork::Did(_) => "own_work_did",
904                    OwnWork::Refused(_) => "own_work_refused",
905                    OwnWork::CouldNotAct(_) => "own_work_could_not_act",
906                },
907            }
908        }
909
910        assert_eq!(name(&StepOutcome::Ok), "ok");
911        assert_eq!(name(&StepOutcome::Failed(1)), "failed");
912        assert_eq!(name(&StepOutcome::NotRun), "not_run");
913        assert_eq!(name(&StepOutcome::Cancelled), "cancelled");
914        assert_eq!(name(&did("ignored")), "own_work_did");
915        assert_eq!(name(&refused("already ignored")), "own_work_refused");
916        assert_eq!(
917            name(&could_not_act("no such file")),
918            "own_work_could_not_act"
919        );
920    }
921
922    fn did(said: &str) -> StepOutcome {
923        StepOutcome::OwnWork(OwnWork::Did(Arc::from(said)))
924    }
925
926    fn refused(said: &str) -> StepOutcome {
927        StepOutcome::OwnWork(OwnWork::Refused(Arc::from(said)))
928    }
929
930    fn could_not_act(said: &str) -> StepOutcome {
931        StepOutcome::OwnWork(OwnWork::CouldNotAct(Arc::from(said)))
932    }
933
934    /// `Cancelled` is explicitly not a failure, tested apart from the shape above: the closed
935    /// set's arity says nothing about which of the five count as failing, and a naive
936    /// classification (anything but `Ok` fails) would wrongly colour a cancelled step as one.
937    /// Scoped to the four a child process produces; the own-work grades are classified below.
938    #[test]
939    fn among_the_child_process_outcomes_only_failed_is_a_failure() {
940        assert!(!StepOutcome::Ok.is_failure());
941        assert!(StepOutcome::Failed(1).is_failure());
942        assert!(!StepOutcome::NotRun.is_failure());
943        assert!(!StepOutcome::Cancelled.is_failure());
944    }
945
946    /// The grade of own work decides the classification, and a refusal is deliberately not a
947    /// failure: `docs/spec/actions.md` gives `Refused` the `dim` role, so a Repo that Repon
948    /// declined to act on must not take the gutter's `!` alongside a Repo that would not read.
949    #[test]
950    fn only_own_work_repon_could_not_finish_is_a_failure_and_only_a_refusal_is_a_refusal() {
951        assert!(!did("ignored").is_failure());
952        assert!(!refused("already ignored").is_failure());
953        assert!(could_not_act("permission denied").is_failure());
954
955        assert!(!did("ignored").is_refusal());
956        assert!(refused("already ignored").is_refusal());
957        assert!(!could_not_act("permission denied").is_refusal());
958        assert!(!StepOutcome::Cancelled.is_refusal());
959        assert!(!StepOutcome::Failed(1).is_refusal());
960    }
961
962    /// Every grade hands back the words it was built with, so nothing has to open the enum a
963    /// second time to read them.
964    #[test]
965    fn own_work_says_its_own_words_whichever_grade_it_is() {
966        let words = |outcome: StepOutcome| match outcome {
967            StepOutcome::OwnWork(work) => work.said().to_string(),
968            StepOutcome::Ok
969            | StepOutcome::Failed(_)
970            | StepOutcome::NotRun
971            | StepOutcome::Cancelled => panic!("built as own work"),
972        };
973
974        assert_eq!(words(did("ignored")), "ignored");
975        assert_eq!(words(refused("already ignored")), "already ignored");
976        assert_eq!(words(could_not_act("boom")), "boom");
977    }
978
979    /// A receipt made of own work reads failed, refused or neither, and the two questions are
980    /// exclusive: a run that failed is never also reported as a refusal, so the pane and the
981    /// `action:` term never have to decide an order of their own.
982    #[test]
983    fn a_receipt_of_own_work_reads_failed_or_refused_but_never_both() {
984        let one = |outcome: StepOutcome| {
985            receipt(
986                "ignore",
987                vec![StepResult {
988                    label: Arc::from("ignore"),
989                    outcome,
990                    output: Arc::from(&b""[..]),
991                    elapsed: Duration::from_millis(1),
992                    elision: None,
993                    shell: false,
994                    interactive: false,
995                }],
996            )
997        };
998
999        assert!(!one(did("ignored")).failed());
1000        assert!(!one(did("ignored")).refused());
1001        assert!(!one(refused("already ignored")).failed());
1002        assert!(one(refused("already ignored")).refused());
1003        assert!(one(could_not_act("boom")).failed());
1004        assert!(!one(could_not_act("boom")).refused());
1005    }
1006
1007    fn ok_step(label: &str) -> StepResult {
1008        StepResult {
1009            label: Arc::from(label),
1010            outcome: StepOutcome::Ok,
1011            output: Arc::from(&b""[..]),
1012            elapsed: Duration::from_millis(1),
1013            elision: None,
1014            shell: false,
1015            interactive: false,
1016        }
1017    }
1018
1019    fn failed_step(label: &str, code: i32) -> StepResult {
1020        StepResult {
1021            label: Arc::from(label),
1022            outcome: StepOutcome::Failed(code),
1023            output: Arc::from(&b"boom"[..]),
1024            elapsed: Duration::from_millis(2),
1025            elision: None,
1026            shell: false,
1027            interactive: false,
1028        }
1029    }
1030
1031    fn receipt(label: &str, steps: Vec<StepResult>) -> ActionReceipt {
1032        ActionReceipt {
1033            label: Arc::from(label),
1034            steps: Arc::from(steps),
1035            skip: None,
1036            finished_at: Timestamp::now(),
1037            running: None,
1038        }
1039    }
1040
1041    /// Two absence claims read off one exhaustive destructure: no `Generation` field (a
1042    /// receipt is not superseded, so it carries none to compare) and no success-condition
1043    /// field (the whole gating mechanism is "stop at the first failure", never a schema flag).
1044    /// A field added under either name, or any other, later fails to compile here rather than
1045    /// silently landing unacknowledged.
1046    #[test]
1047    fn action_receipt_and_step_result_carry_no_generation_and_no_success_condition_field() {
1048        let original = receipt("reinstall", vec![ok_step("rm -rf node_modules")]);
1049        let ActionReceipt {
1050            label,
1051            steps,
1052            skip,
1053            finished_at: _,
1054            running: _,
1055        } = original;
1056        let StepResult {
1057            label: step_label,
1058            outcome,
1059            shell: _,
1060            interactive: _,
1061            output: _,
1062            elapsed: _,
1063            elision: _,
1064        } = steps[0].clone();
1065
1066        assert_eq!(&*label, "reinstall");
1067        assert_eq!(skip, None);
1068        assert_eq!(&*step_label, "rm -rf node_modules");
1069        assert_eq!(outcome, StepOutcome::Ok);
1070    }
1071
1072    // Criterion 6's own proof, "cloning a receipt shares rather than copies", moved to
1073    // `core.rs`'s `two_snapshots_of_an_entity_share_its_last_actions_label_and_steps_by_pointer`:
1074    // a bare `ActionReceipt::clone()` here only proves `Arc::clone` shares, which holds by
1075    // definition and says nothing about this design, whereas the reason the criterion gives
1076    // ("the snapshot is cloned every frame") is provable through a real `Core::snapshot`.
1077
1078    /// `ActionReceipt::failed` is what widens the row summary fold; proven at the unit level,
1079    /// distinct from `snapshot.rs`'s own fold tests, which cover only the fold's own reaction.
1080    #[test]
1081    fn action_receipt_failed_is_true_only_when_a_step_actually_failed() {
1082        assert!(!receipt("ok", vec![ok_step("a")]).failed());
1083        assert!(receipt("broken", vec![ok_step("a"), failed_step("b", 1)]).failed());
1084        assert!(
1085            !receipt(
1086                "cancelled",
1087                vec![StepResult {
1088                    label: Arc::from("a"),
1089                    outcome: StepOutcome::Cancelled,
1090                    output: Arc::from(&b""[..]),
1091                    elapsed: Duration::from_millis(1),
1092                    elision: None,
1093                    shell: false,
1094                    interactive: false,
1095                }]
1096            )
1097            .failed(),
1098            "a cancelled step must never read as a failure"
1099        );
1100    }
1101
1102    #[test]
1103    fn a_worktree_is_constructed_with_state_and_base_unset() {
1104        let entity = EntityState::new(
1105            key("/repo-wt"),
1106            Arc::from("repo-wt"),
1107            Arc::from(Path::new("/repo/.git")),
1108            Kind::Worktree,
1109        );
1110
1111        assert!(entity.state.settled().is_none());
1112        assert!(entity.base.settled().is_none());
1113    }
1114
1115    /// The defining behaviour a naive implementation gets wrong: marking an
1116    /// Entity Vanished must keep every Cell's own value while forcing every one
1117    /// stale, not blank them. Every readable Cell carries a distinct value here
1118    /// so a bug that clobbers even one of them shows up, and `NotApplicable`
1119    /// cells (a Submodule's `state` and `base`) must survive untouched rather
1120    /// than being forced into some other shape.
1121    #[test]
1122    fn marking_an_entity_vanished_keeps_every_cells_value_and_forces_every_one_stale() {
1123        let mut entity = EntityState::new(
1124            key("/repo"),
1125            Arc::from("repo"),
1126            Arc::from(Path::new("/repo/.git")),
1127            Kind::Repo,
1128        );
1129        let generation = Generation::default();
1130        entity.branch.settle(
1131            generation,
1132            Settled::Known {
1133                value: Head::Branch {
1134                    name: Arc::from("main"),
1135                    commit: gix::hash::Kind::Sha1.null(),
1136                },
1137                at: Timestamp::now(),
1138                stale: false,
1139            },
1140        );
1141        entity.sync.settle(
1142            generation,
1143            Settled::Known {
1144                value: SyncState::Tracking(AheadBehind {
1145                    ahead: 1,
1146                    behind: 2,
1147                }),
1148                at: Timestamp::now(),
1149                stale: false,
1150            },
1151        );
1152        entity.base.settle(
1153            generation,
1154            Settled::Known {
1155                value: 3,
1156                at: Timestamp::now(),
1157                stale: false,
1158            },
1159        );
1160        entity.dirty.settle(
1161            generation,
1162            Settled::Known {
1163                value: DirtyCounts {
1164                    modified: 4,
1165                    untracked: 1,
1166                    deleted: 2,
1167                },
1168                at: Timestamp::now(),
1169                stale: false,
1170            },
1171        );
1172        entity.state.settle(
1173            generation,
1174            Settled::Known {
1175                value: WorktreeState::Active,
1176                at: Timestamp::now(),
1177                stale: false,
1178            },
1179        );
1180        entity.default_branch.settle(
1181            generation,
1182            Settled::Known {
1183                value: DefaultBranch::new(Arc::from("main")),
1184                at: Timestamp::now(),
1185                stale: false,
1186            },
1187        );
1188
1189        entity.mark_vanished();
1190
1191        assert_eq!(entity.presence, Presence::Vanished);
1192        match entity.branch.settled() {
1193            Some(Settled::Known {
1194                value: Head::Branch { name, .. },
1195                stale: true,
1196                at: _,
1197            }) => assert_eq!(&**name, "main"),
1198            other => panic!("expected branch to keep its value and go stale, got {other:?}"),
1199        }
1200        match entity.sync.settled() {
1201            Some(Settled::Known {
1202                value: SyncState::Tracking(AheadBehind { ahead, behind }),
1203                stale: true,
1204                at: _,
1205            }) => {
1206                assert_eq!(*ahead, 1);
1207                assert_eq!(*behind, 2);
1208            }
1209            other => panic!("expected sync to keep its value and go stale, got {other:?}"),
1210        }
1211        match entity.base.settled() {
1212            Some(Settled::Known {
1213                value: 3,
1214                stale: true,
1215                at: _,
1216            }) => {}
1217            other => panic!("expected base to keep its value and go stale, got {other:?}"),
1218        }
1219        match entity.dirty.settled() {
1220            Some(Settled::Known {
1221                value:
1222                    DirtyCounts {
1223                        modified: 4,
1224                        untracked: 1,
1225                        deleted: 2,
1226                    },
1227                stale: true,
1228                at: _,
1229            }) => {}
1230            other => panic!("expected dirty to keep its value and go stale, got {other:?}"),
1231        }
1232        match entity.state.settled() {
1233            Some(Settled::Known {
1234                value: WorktreeState::Active,
1235                stale: true,
1236                at: _,
1237            }) => {}
1238            other => panic!("expected state to keep its value and go stale, got {other:?}"),
1239        }
1240        match entity.default_branch.settled() {
1241            Some(Settled::Known {
1242                value,
1243                stale: true,
1244                at: _,
1245            }) => assert_eq!(value.name(), "main"),
1246            other => {
1247                panic!("expected default_branch to keep its value and go stale, got {other:?}")
1248            }
1249        }
1250    }
1251
1252    /// A Submodule's construction-time `Unknown` cells must survive being
1253    /// marked Vanished untouched: forcing staleness applies only to a settled
1254    /// `Known` value, never to a settled fact this crate has no better answer for.
1255    #[test]
1256    fn marking_a_submodule_vanished_leaves_its_unknown_cells_untouched() {
1257        let mut entity = EntityState::new(
1258            key("/repo/vendor/lib"),
1259            Arc::from("lib"),
1260            Arc::from(Path::new("/repo/.git")),
1261            Kind::Submodule,
1262        );
1263
1264        entity.mark_vanished();
1265
1266        assert_eq!(entity.presence, Presence::Vanished);
1267        assert!(matches!(
1268            entity.state.settled(),
1269            Some(Settled::Unknown(Unknown::NoDefaultBranch))
1270        ));
1271        assert!(matches!(
1272            entity.base.settled(),
1273            Some(Settled::Unknown(Unknown::NoDefaultBranch))
1274        ));
1275    }
1276
1277    /// The metadata poll's own writer: on movement it force-stales `dirty` and
1278    /// `state`, the two cells with no cheap detector, and touches nothing else.
1279    /// The discriminator is `branch` and `sync` staying fresh, since those are
1280    /// [`Self::apply_branch_probe`]/`sync.settle`'s own job to refresh, never this
1281    /// method's.
1282    #[test]
1283    fn force_stale_status_cells_stales_only_dirty_and_state() {
1284        let mut entity = EntityState::new(
1285            key("/repo"),
1286            Arc::from("repo"),
1287            Arc::from(Path::new("/repo/.git")),
1288            Kind::Worktree,
1289        );
1290        let generation = Generation::new(1);
1291        entity.branch.settle(
1292            generation,
1293            Settled::Known {
1294                value: Head::Branch {
1295                    name: Arc::from("main"),
1296                    commit: gix::ObjectId::null(gix::hash::Kind::Sha1),
1297                },
1298                at: Timestamp::now(),
1299                stale: false,
1300            },
1301        );
1302        entity.dirty.settle(
1303            generation,
1304            Settled::Known {
1305                value: DirtyCounts {
1306                    modified: 1,
1307                    untracked: 0,
1308                    deleted: 0,
1309                },
1310                at: Timestamp::now(),
1311                stale: false,
1312            },
1313        );
1314        entity.state.settle(
1315            generation,
1316            Settled::Known {
1317                value: WorktreeState::Active,
1318                at: Timestamp::now(),
1319                stale: false,
1320            },
1321        );
1322
1323        entity.force_stale_status_cells();
1324
1325        match entity.branch.settled() {
1326            Some(Settled::Known {
1327                stale: false,
1328                value: _,
1329                at: _,
1330            }) => {}
1331            other => panic!("expected branch to stay fresh, got {other:?}"),
1332        }
1333        match entity.dirty.settled() {
1334            Some(Settled::Known {
1335                stale: true,
1336                value: _,
1337                at: _,
1338            }) => {}
1339            other => panic!("expected dirty to go stale, got {other:?}"),
1340        }
1341        match entity.state.settled() {
1342            Some(Settled::Known {
1343                stale: true,
1344                value: _,
1345                at: _,
1346            }) => {}
1347            other => panic!("expected state to go stale, got {other:?}"),
1348        }
1349    }
1350
1351    /// Staleness's other writer: elapsed age stales `dirty` and `state` once their
1352    /// value is older than the threshold, and leaves a value settled moments ago
1353    /// alone, both cells at once, matching `force_stale_status_cells`'s pair.
1354    #[test]
1355    fn age_status_cells_stales_dirty_and_state_once_old_enough() {
1356        let mut old_entity = EntityState::new(
1357            key("/repo"),
1358            Arc::from("repo"),
1359            Arc::from(Path::new("/repo/.git")),
1360            Kind::Worktree,
1361        );
1362        let generation = Generation::new(1);
1363        let old_at = Timestamp::at(std::time::SystemTime::now() - Duration::from_secs(600));
1364        old_entity.dirty.settle(
1365            generation,
1366            Settled::Known {
1367                value: DirtyCounts {
1368                    modified: 1,
1369                    untracked: 0,
1370                    deleted: 0,
1371                },
1372                at: old_at,
1373                stale: false,
1374            },
1375        );
1376        old_entity.state.settle(
1377            generation,
1378            Settled::Known {
1379                value: WorktreeState::Active,
1380                at: old_at,
1381                stale: false,
1382            },
1383        );
1384
1385        old_entity.age_status_cells(Duration::from_secs(300));
1386
1387        match old_entity.dirty.settled() {
1388            Some(Settled::Known {
1389                stale: true,
1390                value: _,
1391                at: _,
1392            }) => {}
1393            other => panic!("expected an old dirty value to age into stale, got {other:?}"),
1394        }
1395        match old_entity.state.settled() {
1396            Some(Settled::Known {
1397                stale: true,
1398                value: _,
1399                at: _,
1400            }) => {}
1401            other => panic!("expected an old state value to age into stale, got {other:?}"),
1402        }
1403
1404        let mut fresh_entity = EntityState::new(
1405            key("/repo"),
1406            Arc::from("repo"),
1407            Arc::from(Path::new("/repo/.git")),
1408            Kind::Worktree,
1409        );
1410        fresh_entity.dirty.settle(
1411            generation,
1412            Settled::Known {
1413                value: DirtyCounts {
1414                    modified: 1,
1415                    untracked: 0,
1416                    deleted: 0,
1417                },
1418                at: Timestamp::now(),
1419                stale: false,
1420            },
1421        );
1422
1423        fresh_entity.age_status_cells(Duration::from_secs(300));
1424
1425        match fresh_entity.dirty.settled() {
1426            Some(Settled::Known {
1427                stale: false,
1428                value: _,
1429                at: _,
1430            }) => {}
1431            other => panic!("expected a fresh dirty value to stay fresh, got {other:?}"),
1432        }
1433    }
1434
1435    /// The vanished-staleness path is one of `ActionReceipt`'s absence claims made
1436    /// behavioural: `mark_vanished` forces every settled Cell stale, but a receipt is not a
1437    /// Cell and carries no staleness of its own, so driving this pass must leave it byte for
1438    /// byte as it was, not merely leave some field unnamed.
1439    #[test]
1440    fn marking_an_entity_vanished_leaves_its_action_receipt_untouched() {
1441        let mut entity = EntityState::new(
1442            key("/repo"),
1443            Arc::from("repo"),
1444            Arc::from(Path::new("/repo/.git")),
1445            Kind::Repo,
1446        );
1447        let original = ActionReceipt {
1448            label: Arc::from("reinstall"),
1449            steps: Arc::from(vec![StepResult {
1450                label: Arc::from("pnpm install"),
1451                outcome: StepOutcome::Ok,
1452                output: Arc::from(&b""[..]),
1453                elapsed: Duration::from_millis(1),
1454                elision: None,
1455                shell: false,
1456                interactive: false,
1457            }]),
1458            skip: None,
1459            finished_at: Timestamp::now(),
1460            running: None,
1461        };
1462        entity.last_action = Some(original.clone());
1463
1464        entity.mark_vanished();
1465
1466        assert_eq!(entity.last_action, Some(original));
1467    }
1468
1469    // --- apply_branch_probe: the pipe between a branch read and the pane's own facts ---
1470
1471    fn known_branch(name: &str) -> Settled<Head> {
1472        Settled::Known {
1473            value: Head::Branch {
1474                name: Arc::from(name),
1475                commit: gix::hash::Kind::Sha1.null(),
1476            },
1477            at: Timestamp::now(),
1478            stale: false,
1479        }
1480    }
1481
1482    fn commit(short_id: &str, summary: &str) -> RecentCommit {
1483        RecentCommit {
1484            short_id: Arc::from(short_id),
1485            summary: Arc::from(summary),
1486        }
1487    }
1488
1489    /// Half of [`EntityState::apply_branch_probe`]'s own doc comment: a write that applies
1490    /// (nothing newer already recorded on `branch`) stores the in-progress operation and the
1491    /// recent commits it was handed, not only the branch cell itself.
1492    #[test]
1493    fn a_branch_probe_that_applies_stores_its_in_progress_operation_and_recent_commits() {
1494        let mut entity = EntityState::new(
1495            key("/repo"),
1496            Arc::from("repo"),
1497            Arc::from(Path::new("/repo/.git")),
1498            Kind::Worktree,
1499        );
1500        let commits = vec![commit("abc1234", "a commit")];
1501
1502        let applied = entity.apply_branch_probe(
1503            Generation::default(),
1504            known_branch("main"),
1505            Some(InProgressOperation::Rebase),
1506            commits.clone(),
1507        );
1508
1509        assert!(applied);
1510        assert_eq!(
1511            entity.in_progress_operation,
1512            Some(InProgressOperation::Rebase)
1513        );
1514        assert_eq!(entity.recent_commits, commits);
1515    }
1516
1517    /// The other half: a probe superseded by Generation order (older than the branch cell's
1518    /// own already-recorded Generation) applies neither fact, leaving the newer read's own
1519    /// in-progress operation and commits exactly as they were.
1520    #[test]
1521    fn a_superseded_branch_probe_leaves_the_newer_reads_facts_intact() {
1522        let mut entity = EntityState::new(
1523            key("/repo"),
1524            Arc::from("repo"),
1525            Arc::from(Path::new("/repo/.git")),
1526            Kind::Worktree,
1527        );
1528        let newer_commits = vec![commit("newer12", "the newer read")];
1529        let applied_first = entity.apply_branch_probe(
1530            Generation::new(5),
1531            known_branch("main"),
1532            Some(InProgressOperation::Merge),
1533            newer_commits.clone(),
1534        );
1535        assert!(
1536            applied_first,
1537            "the first write, at Generation 5, must apply"
1538        );
1539
1540        let older_commits = vec![commit("older12", "a stale read")];
1541        let applied_second = entity.apply_branch_probe(
1542            Generation::new(2),
1543            known_branch("main"),
1544            Some(InProgressOperation::Rebase),
1545            older_commits,
1546        );
1547
1548        assert!(
1549            !applied_second,
1550            "a write at an older Generation must not apply"
1551        );
1552        assert_eq!(
1553            entity.in_progress_operation,
1554            Some(InProgressOperation::Merge)
1555        );
1556        assert_eq!(entity.recent_commits, newer_commits);
1557    }
1558
1559    #[test]
1560    fn the_entity_key_is_not_the_common_dir() {
1561        let entity = EntityState::new(
1562            key("/repo-wt"),
1563            Arc::from("repo-wt"),
1564            Arc::from(Path::new("/repo/.git")),
1565            Kind::Worktree,
1566        );
1567
1568        assert_ne!(entity.key.path(), &*entity.common_dir);
1569    }
1570}