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}