Skip to main content

repon_core/
snapshot.rs

1//! The row summary fold and the Snapshot a consumer reads.
2//!
3//! See `docs/spec/core-api.md`'s "The row summary" and "The snapshot" sections, and
4//! [ADR 0015](https://github.com/paulchiu/repon/blob/main/docs/adr/0015-the-core-owns-the-table.md)
5//! for why this is a clone read rather than a channel of cell updates: the terminal
6//! interface's event loop is a blocking receive on one channel already, so a second
7//! channel would not wake it, and a full-table clone measures in microseconds
8//! against a 16.7 millisecond frame.
9
10use crate::cell::{Cell, Generation, Settled, Timestamp};
11use crate::entity::{ActionReceipt, Diagnostics, EntityState};
12
13/// The one state a row's Cells fold into, for the gutter.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum RowSummary {
16    Fresh,
17    Stale,
18    Unknown,
19    Failed,
20    InFlight,
21}
22
23/// Where one Cell sits on the settledness scale `summary` folds over.
24/// `NotApplicable` cells never reach this: they are excluded before folding.
25/// Declared worst-last, so `Ord` gives the least settled cell as the maximum.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
27enum Settledness {
28    Fresh,
29    Stale,
30    Unknown,
31    Failed,
32}
33
34/// Reads a [`Cell<T>`]'s contribution to the fold, uniformly across every payload
35/// type `EntityState` carries, without a shared payload trait.
36trait FoldableCell {
37    fn settledness(&self) -> Option<Settledness>;
38    /// Whether this Cell has ever settled to a genuine value: `Known`, `Unknown`
39    /// or `Failed`. Never-probed and `NotApplicable` both read `false`, since
40    /// neither is a value the row's first-probe spinner rule should treat as
41    /// already shown.
42    fn holds_a_value(&self) -> bool;
43}
44
45impl<T> FoldableCell for Cell<T> {
46    fn settledness(&self) -> Option<Settledness> {
47        match self.settled() {
48            Some(Settled::NotApplicable) => None,
49            Some(Settled::Known {
50                stale: false,
51                value: _,
52                at: _,
53            }) => Some(Settledness::Fresh),
54            Some(Settled::Known {
55                stale: true,
56                value: _,
57                at: _,
58            }) => Some(Settledness::Stale),
59            Some(Settled::Unknown(_)) => Some(Settledness::Unknown),
60            Some(Settled::Failed(_)) => Some(Settledness::Failed),
61            // Nothing has settled this Cell yet, whether a probe is currently
62            // running against it or none has been dispatched at all: it carries
63            // no settled fact for the fold to weigh, the same exclusion
64            // `NotApplicable` gets, rather than the `Unknown` this used to read
65            // as. `docs/spec/refresh.md`'s "What the gutter and the cells show"
66            // is what this excludes for: once the row holds another value, an
67            // outstanding Cell shows its own loading mark rather than dragging
68            // the row's gutter to `?`.
69            None => None,
70        }
71    }
72
73    fn holds_a_value(&self) -> bool {
74        matches!(
75            self.settled(),
76            Some(Settled::Known {
77                value: _,
78                at: _,
79                stale: _
80            }) | Some(Settled::Unknown(_))
81                | Some(Settled::Failed(_))
82        )
83    }
84}
85
86/// Folds one Entity's Cells into the state its row's gutter shows.
87///
88/// In-flight outranks the least-settled summary while the row holds no values
89/// at all, its first probe, regardless of whether a probe happens to be
90/// dispatched against it yet: `docs/spec/core-api.md`'s `Cell` carries the same
91/// "nothing has looked at this yet" fact either way, and
92/// `docs/spec/refresh.md`'s "Startup is Generation 1 with an empty prior state"
93/// is what makes that fact Loading rather than Unknown. Once any Cell has
94/// settled to something, the gutter falls back to the row's least-settled
95/// *settled* state instead, and a Cell nothing has settled yet is excluded from
96/// that fold exactly like `NotApplicable`: its own loading mark, drawn by the
97/// consumer, is what says a value is still coming (`docs/spec/refresh.md`'s
98/// "What the gutter and the cells show", amended by ADR 0013). A `NotApplicable`
99/// Cell is excluded from the fold entirely too, which is what lets a Repo row
100/// (Worktree state Not applicable by kind) or a Worktree row on its own default
101/// branch (`base` Not applicable) read Fresh, or still show the first-probe
102/// spinner, on cells that simply do not apply. A Submodule row's `state` and
103/// `base` are `Unknown` rather than `NotApplicable`, per
104/// [ADR 0017](https://github.com/paulchiu/repon/blob/main/docs/adr/0017-discovery-stops-at-the-repo-boundary.md)
105/// as amended, so they do fold in, and with the periodic fetch off (the
106/// default) that is what puts `?` in a Submodule row's gutter rather than a
107/// space. Otherwise the row shows its least settled Cell, widened by two
108/// entity-level derivations that are not Cells at all: an unparseable
109/// `.gitmodules` and a failed last Action both drive the row to `Failed` even
110/// when every Cell reads fine. Before any of that, though, `last_action.running` being
111/// `Some` also reads `InFlight`, outranking a `Failed` Cell and the same receipt's own
112/// past failures: `Core::run_action` writes it once per step while a run is on this row
113/// right now (`docs/spec/actions.md`'s "The run on screen"), so a row being retried is
114/// in-flight rather than still reporting the failure it is retrying. The default branch's
115/// rung and its disagreement stay out, being metadata about how a value was obtained
116/// rather than a value that can itself fail.
117pub fn summary(entity: &EntityState) -> RowSummary {
118    // Exhaustive: a Cell or derivation source added to EntityState or Diagnostics
119    // later must be named here or the pattern fails to compile, so it cannot be
120    // silently left out of the fold below. This is also the only compile-time stop
121    // for the exit-code predicate behind `repon status`, which folds the same Cells
122    // by hand because the terminal crate forbids naming the in-progress-operation
123    // field outside its detail pane; a Cell added here belongs in that fold too.
124    let EntityState {
125        key: _,
126        name: _,
127        common_dir: _,
128        kind: _,
129        branch,
130        sync,
131        base,
132        dirty,
133        state,
134        default_branch,
135        diagnostics,
136        last_action,
137        presence: _,
138        excluded: _,
139        in_progress_operation: _,
140        recent_commits: _,
141    } = entity;
142    let Diagnostics {
143        default_branch_rung: _,
144        default_branch_rung_disagreement: _,
145        default_branch_rung_two_stale: _,
146        default_branch_stopped: _,
147        gitmodules_failed,
148    } = diagnostics;
149
150    let cells: [&dyn FoldableCell; 6] = [branch, sync, base, dirty, state, default_branch];
151
152    // No dependence on `is_in_flight` here, deliberately: "nothing has looked at
153    // this Cell yet" and "a probe is running against it right now" are the same
154    // "no prior state" fact from a reader's point of view, and both must read
155    // Loading rather than Unknown (criterion 3). A row a Generation has not
156    // reached yet and a row whose first probe is already running therefore fold
157    // identically.
158    let holds_no_values = cells.iter().all(|cell| !cell.holds_a_value());
159    // A running Action step outranks everything below it, a failed Cell and the same
160    // receipt's own past failures included: a row being retried right now is in-flight, and
161    // reporting the old failure while the retry runs is the wrong answer.
162    let action_running = last_action
163        .as_ref()
164        .is_some_and(|receipt| receipt.running.is_some());
165    if holds_no_values || action_running {
166        return RowSummary::InFlight;
167    }
168
169    let derivation_failed =
170        gitmodules_failed.is_some() || last_action.as_ref().is_some_and(ActionReceipt::failed);
171
172    let worst = cells
173        .iter()
174        .filter_map(|cell| cell.settledness())
175        .chain(derivation_failed.then_some(Settledness::Failed))
176        .max();
177
178    match worst {
179        None => RowSummary::Fresh,
180        Some(Settledness::Fresh) => RowSummary::Fresh,
181        Some(Settledness::Stale) => RowSummary::Stale,
182        Some(Settledness::Unknown) => RowSummary::Unknown,
183        Some(Settledness::Failed) => RowSummary::Failed,
184    }
185}
186
187/// The whole table, as a consumer reads it. `Core::snapshot` clones this every
188/// frame, so every field here, and everything reachable from it, is `Clone`, and
189/// every text-bearing value on an [`EntityState`] is an `Arc<str>` rather than a
190/// `String` precisely because of that per-frame clone.
191///
192/// There is no notification channel, update stream or callback anywhere on this
193/// crate's public surface: a consumer reads a `Snapshot` when it decides to, it
194/// never gets pushed one.
195#[derive(Debug, Clone)]
196#[cfg_attr(feature = "serde", derive(serde::Serialize))]
197pub struct Snapshot {
198    pub generation: Generation,
199    pub discovered_at: Timestamp,
200    pub entities: Vec<EntityState>,
201}
202
203#[cfg(test)]
204mod tests {
205    use std::path::Path;
206    use std::sync::Arc;
207
208    use super::*;
209    use crate::cell::Unknown;
210    use crate::entity::{
211        AheadBehind, DefaultBranch, DirtyCounts, EntityKey, Head, Kind, OwnWork, StepOutcome,
212        StepResult, SyncState, WorktreeState,
213    };
214
215    /// One receipt, one step per outcome given, in order: enough for the fold's own tests,
216    /// which only ever ask whether *some* step failed, never which one or what it printed.
217    fn receipt_with_steps(outcomes: Vec<StepOutcome>) -> ActionReceipt {
218        let steps = outcomes
219            .into_iter()
220            .enumerate()
221            .map(|(index, outcome)| StepResult {
222                label: Arc::from(format!("step {index}")),
223                outcome,
224                output: Arc::from(&b""[..]),
225                elapsed: std::time::Duration::from_millis(1),
226                elision: None,
227                shell: false,
228                interactive: false,
229            })
230            .collect::<Vec<_>>();
231        ActionReceipt {
232            label: Arc::from("action"),
233            steps: Arc::from(steps),
234            skip: None,
235            finished_at: Timestamp::now(),
236            running: None,
237        }
238    }
239
240    fn fresh_entity(name: &str) -> EntityState {
241        let mut entity = EntityState::new(
242            EntityKey::new(Arc::from(Path::new(name))),
243            Arc::from(name),
244            Arc::from(Path::new(name)),
245            Kind::Repo,
246        );
247        let generation = Generation::new(1);
248        entity.branch.settle(
249            generation,
250            Settled::Known {
251                value: Head::Branch {
252                    name: Arc::from("main"),
253                    commit: gix::hash::Kind::Sha1.null(),
254                },
255                at: Timestamp::now(),
256                stale: false,
257            },
258        );
259        entity.sync.settle(
260            generation,
261            Settled::Known {
262                value: SyncState::Tracking(AheadBehind {
263                    ahead: 0,
264                    behind: 0,
265                }),
266                at: Timestamp::now(),
267                stale: false,
268            },
269        );
270        entity.base.settle(
271            generation,
272            Settled::Known {
273                value: 0,
274                at: Timestamp::now(),
275                stale: false,
276            },
277        );
278        entity.dirty.settle(
279            generation,
280            Settled::Known {
281                value: DirtyCounts::default(),
282                at: Timestamp::now(),
283                stale: false,
284            },
285        );
286        entity.state.settle(
287            generation,
288            Settled::Known {
289                value: WorktreeState::Active,
290                at: Timestamp::now(),
291                stale: false,
292            },
293        );
294        entity.default_branch.settle(
295            generation,
296            Settled::Known {
297                value: DefaultBranch::new(Arc::from("main")),
298                at: Timestamp::now(),
299                stale: false,
300            },
301        );
302        entity
303    }
304
305    #[test]
306    fn an_entity_with_every_cell_fresh_summarises_fresh() {
307        let entity = fresh_entity("repo");
308
309        assert_eq!(summary(&entity), RowSummary::Fresh);
310    }
311
312    /// ADR 0019: an in-progress git operation is not a state and not a gutter mark, read by
313    /// the detail pane alone. A row stopped mid-rebase and the same row idle summarise
314    /// identically here, which is what "not a gutter mark" actually means: not merely that no
315    /// existing branch of `summary` happens to read the field, but that setting it never
316    /// changes the fold's answer at all.
317    #[test]
318    fn an_in_progress_git_operation_never_changes_the_row_summary() {
319        let idle = fresh_entity("repo-idle");
320        let mut rebasing = fresh_entity("repo-rebasing");
321        rebasing.in_progress_operation = Some(crate::git::InProgressOperation::Rebase);
322
323        assert_eq!(summary(&idle), summary(&rebasing));
324        assert_eq!(summary(&rebasing), RowSummary::Fresh);
325    }
326
327    #[test]
328    fn a_not_applicable_cell_is_excluded_rather_than_dragging_the_row_down() {
329        // A freshly constructed Repo has `state` Not-applicable by kind
330        // (`EntityState::new`); `base` is settled Not-applicable by hand below,
331        // simulating a Repo with no remote, so both of `NotApplicable`'s named
332        // producers are exercised in one row. If Not-applicable were not
333        // excluded, the row would still read Unknown here too, so this only
334        // distinguishes a correct fold from a naive one once the other four
335        // cells are made Fresh.
336        let mut entity = EntityState::new(
337            EntityKey::new(Arc::from(Path::new("/repo"))),
338            Arc::from("repo"),
339            Arc::from(Path::new("/repo/.git")),
340            Kind::Repo,
341        );
342        let generation = Generation::new(1);
343        entity.base.settle(generation, Settled::NotApplicable);
344        entity.branch.settle(
345            generation,
346            Settled::Known {
347                value: Head::Branch {
348                    name: Arc::from("main"),
349                    commit: gix::hash::Kind::Sha1.null(),
350                },
351                at: Timestamp::now(),
352                stale: false,
353            },
354        );
355        entity.sync.settle(
356            generation,
357            Settled::Known {
358                value: SyncState::Tracking(AheadBehind {
359                    ahead: 0,
360                    behind: 0,
361                }),
362                at: Timestamp::now(),
363                stale: false,
364            },
365        );
366        entity.dirty.settle(
367            generation,
368            Settled::Known {
369                value: DirtyCounts::default(),
370                at: Timestamp::now(),
371                stale: false,
372            },
373        );
374        entity.default_branch.settle(
375            generation,
376            Settled::Known {
377                value: DefaultBranch::new(Arc::from("main")),
378                at: Timestamp::now(),
379                stale: false,
380            },
381        );
382
383        assert_eq!(summary(&entity), RowSummary::Fresh);
384    }
385
386    /// A freshly constructed Repo's `state` cell is `NotApplicable` rather than
387    /// merely never probed, so it is excluded from the fold rather than dragging
388    /// the row to Unknown. Every other cell is made Fresh here so this only tells
389    /// a correct fold from a naive one once nothing else is outstanding: a
390    /// genuinely never-settled Cell (as opposed to `NotApplicable`) would also
391    /// be excluded now, per `a_cell_nothing_has_ever_settled_is_excluded_from_the_fold_once_the_row_holds_other_values`
392    /// below, but this test is about the `NotApplicable` producer specifically.
393    #[test]
394    fn a_repo_rows_worktree_state_is_excluded_so_the_gutter_never_shows_a_question_mark() {
395        let mut entity = EntityState::new(
396            EntityKey::new(Arc::from(Path::new("/repo"))),
397            Arc::from("repo"),
398            Arc::from(Path::new("/repo/.git")),
399            Kind::Repo,
400        );
401        let generation = Generation::new(1);
402        entity.branch.settle(
403            generation,
404            Settled::Known {
405                value: Head::Branch {
406                    name: Arc::from("main"),
407                    commit: gix::hash::Kind::Sha1.null(),
408                },
409                at: Timestamp::now(),
410                stale: false,
411            },
412        );
413        entity.sync.settle(
414            generation,
415            Settled::Known {
416                value: SyncState::Tracking(AheadBehind {
417                    ahead: 0,
418                    behind: 0,
419                }),
420                at: Timestamp::now(),
421                stale: false,
422            },
423        );
424        entity.base.settle(
425            generation,
426            Settled::Known {
427                value: 0,
428                at: Timestamp::now(),
429                stale: false,
430            },
431        );
432        entity.dirty.settle(
433            generation,
434            Settled::Known {
435                value: DirtyCounts::default(),
436                at: Timestamp::now(),
437                stale: false,
438            },
439        );
440        entity.default_branch.settle(
441            generation,
442            Settled::Known {
443                value: DefaultBranch::new(Arc::from("main")),
444                at: Timestamp::now(),
445                stale: false,
446            },
447        );
448        // `state` is left exactly as construction set it: never settled again here.
449
450        assert_eq!(summary(&entity), RowSummary::Fresh);
451    }
452
453    #[test]
454    fn one_failed_cell_outranks_every_other_fresh_cell() {
455        let mut entity = fresh_entity("repo");
456        entity.dirty.settle(
457            Generation::new(2),
458            Settled::Failed(crate::git::ProbeError::Read(Arc::from("boom"))),
459        );
460
461        assert_eq!(summary(&entity), RowSummary::Failed);
462    }
463
464    #[test]
465    fn once_a_row_holds_values_a_failed_cell_outranks_an_in_flight_one() {
466        let mut entity = fresh_entity("repo");
467        entity.dirty.settle(
468            Generation::new(2),
469            Settled::Failed(crate::git::ProbeError::Read(Arc::from("boom"))),
470        );
471        entity.branch.begin_probe();
472
473        assert_eq!(summary(&entity), RowSummary::Failed);
474    }
475
476    #[test]
477    fn a_freshly_discovered_row_shows_in_flight_while_it_holds_no_values_at_all() {
478        let mut entity = EntityState::new(
479            EntityKey::new(Arc::from(Path::new("repo"))),
480            Arc::from("repo"),
481            Arc::from(Path::new("repo")),
482            Kind::Repo,
483        );
484
485        entity.branch.begin_probe();
486
487        assert_eq!(summary(&entity), RowSummary::InFlight);
488    }
489
490    /// A Submodule is constructed with `state` and `base` already `Unknown`
491    /// (see `EntityState::new`), and `Unknown` is a genuine settled fact rather
492    /// than "nothing has looked at this yet", so [`FoldableCell::holds_a_value`]
493    /// correctly counts it: the row reads `?` from the moment it is discovered,
494    /// before `branch`, `sync`, `dirty` or `default_branch` have been probed at
495    /// all, rather than showing the first-probe spinner in the meantime. Those
496    /// four cells still show their own per-cell loading mark while they settle,
497    /// the same fallback an ordinary partially-probed row already gets.
498    #[test]
499    fn a_freshly_discovered_submodule_reads_unknown_before_any_other_cell_is_probed() {
500        let entity = EntityState::new(
501            EntityKey::new(Arc::from(Path::new("/repo/vendor/lib"))),
502            Arc::from("lib"),
503            Arc::from(Path::new("/repo/.git")),
504            Kind::Submodule,
505        );
506        assert!(matches!(
507            entity.state.settled(),
508            Some(Settled::Unknown(Unknown::NoDefaultBranch))
509        ));
510        assert!(matches!(
511            entity.base.settled(),
512            Some(Settled::Unknown(Unknown::NoDefaultBranch))
513        ));
514
515        assert_eq!(summary(&entity), RowSummary::Unknown);
516    }
517
518    /// Criterion 3's "no prior state" case, constructed so the two readings this ticket
519    /// exists to tell apart would actually differ: nothing has ever settled this row *and*
520    /// no probe has even been dispatched yet (no `begin_probe` call anywhere), which is
521    /// exactly the state `docs/spec/core-api.md`'s `Cell` doc says "only happens before the
522    /// first Generation covers it". A fold that required `is_in_flight` to read InFlight
523    /// would read Unknown here instead, since nothing is in flight; `docs/spec/refresh.md`'s
524    /// "Startup is Generation 1 with an empty prior state" is why that would be wrong.
525    #[test]
526    fn a_row_with_no_prior_state_at_all_reads_in_flight_even_before_any_probe_is_dispatched() {
527        let entity = EntityState::new(
528            EntityKey::new(Arc::from(Path::new("repo"))),
529            Arc::from("repo"),
530            Arc::from(Path::new("repo")),
531            Kind::Repo,
532        );
533        assert!(
534            !entity.branch.is_in_flight(),
535            "sanity check: nothing must be in flight yet"
536        );
537
538        assert_eq!(summary(&entity), RowSummary::InFlight);
539    }
540
541    /// Criterion 2's outstanding-cell case: a Cell nothing has ever settled must not drag an
542    /// otherwise-settled row down to Unknown once another Cell already holds a value, or
543    /// every row would read `?` forever behind any column a probe has not reached (today,
544    /// `sync`, `base` and `dirty`), rather than showing that column's own loading mark and
545    /// leaving the gutter to read the row's least-settled *settled* state instead
546    /// (`docs/spec/refresh.md`'s "What the gutter and the cells show"). A version of this
547    /// fold that only excluded `NotApplicable` and still read a bare `None` as Unknown would
548    /// fail exactly this case.
549    #[test]
550    fn a_cell_nothing_has_ever_settled_is_excluded_from_the_fold_once_the_row_holds_other_values() {
551        let mut entity = EntityState::new(
552            EntityKey::new(Arc::from(Path::new("repo"))),
553            Arc::from("repo"),
554            Arc::from(Path::new("repo")),
555            Kind::Repo,
556        );
557        entity.branch.settle(
558            Generation::new(1),
559            Settled::Known {
560                value: Head::Branch {
561                    name: Arc::from("main"),
562                    commit: gix::hash::Kind::Sha1.null(),
563                },
564                at: Timestamp::now(),
565                stale: false,
566            },
567        );
568        // `sync`, `base` and `dirty` are left exactly as construction left them: never
569        // settled, and no probe dispatched against them either.
570
571        assert_eq!(
572            summary(&entity),
573            RowSummary::Fresh,
574            "a Cell nothing has ever settled must not drag an otherwise-settled row to Unknown"
575        );
576    }
577
578    /// The "truly fully populated" counterpart to the render layer's own predecessor-defect
579    /// test (`crates/repon/src/components/list.rs`'s
580    /// `a_row_that_already_shows_its_cheap_columns_still_animates_its_outstanding_cell_on_refresh`),
581    /// exercised here because only this crate can settle every one of the six Cells
582    /// `docs/spec/core-api.md`'s `EntityState` carries: `Cell::begin_probe` and `Cell::settle`
583    /// are `pub(crate)`. A row where every Cell already holds a Known value, reprobed on every
584    /// Cell at once, must keep exactly the same fold: `docs/spec/refresh.md`'s "re-probing
585    /// keeps the previous value" means a Cell that already answered shows that answer, not a
586    /// spinner, until a *new* answer lands, so the gutter must not move either. This is the
587    /// mirror of the other tests above: there, an in-flight Cell that already held a value was
588    /// shown not to elevate the row past a Failed one; here, refreshing *every* Cell of an
589    /// all-Fresh row is shown not to move it at all.
590    #[test]
591    fn reprobing_every_cell_of_an_already_fully_settled_row_never_changes_its_summary() {
592        let entity = fresh_entity("repo");
593        let before = summary(&entity);
594        assert_eq!(
595            before,
596            RowSummary::Fresh,
597            "sanity check: fresh_entity settles every Cell"
598        );
599
600        let mut reprobing = entity.clone();
601        reprobing.branch.begin_probe();
602        reprobing.sync.begin_probe();
603        reprobing.base.begin_probe();
604        reprobing.dirty.begin_probe();
605        reprobing.default_branch.begin_probe();
606        // `state` is excluded from a Repo row's fold (`NotApplicable`), so `begin_probe`
607        // is deliberately not called on it here: nothing would ever `settle` it back.
608        assert!(reprobing.branch.is_in_flight());
609
610        assert_eq!(
611            summary(&reprobing),
612            before,
613            "reprobing every already-settled Cell must not move the row's summary until a \
614             new answer actually lands"
615        );
616    }
617
618    #[test]
619    fn stale_outranks_fresh_but_not_unknown() {
620        let mut entity = fresh_entity("repo");
621        entity.dirty.settle(
622            Generation::new(2),
623            Settled::Known {
624                value: DirtyCounts {
625                    modified: 3,
626                    untracked: 0,
627                    deleted: 0,
628                },
629                at: Timestamp::now(),
630                stale: true,
631            },
632        );
633
634        assert_eq!(summary(&entity), RowSummary::Stale);
635
636        entity.base.settle(
637            Generation::new(2),
638            Settled::Unknown(crate::cell::Unknown::TimedOut),
639        );
640
641        assert_eq!(summary(&entity), RowSummary::Unknown);
642    }
643
644    /// Every unordered pair of settledness cases, applied to two different Cells
645    /// on one otherwise-fresh entity, so the fold sees both at once. No other test
646    /// puts, say, an Unknown cell and a Failed cell on the same row, so an
647    /// accidental reorder of `Settledness`'s declaration (its `Ord` is derived
648    /// from that order) would still pass every test that only ever compares one
649    /// settledness against a Fresh baseline.
650    #[test]
651    fn every_pair_of_cell_settlednesses_folds_to_the_worse_of_the_two() {
652        #[derive(Clone, Copy)]
653        enum Case {
654            Fresh,
655            Stale,
656            Unknown,
657            Failed,
658        }
659
660        fn settle<T: Default>(cell: &mut Cell<T>, generation: Generation, case: Case) {
661            let settled = match case {
662                Case::Fresh => Settled::Known {
663                    value: T::default(),
664                    at: Timestamp::now(),
665                    stale: false,
666                },
667                Case::Stale => Settled::Known {
668                    value: T::default(),
669                    at: Timestamp::now(),
670                    stale: true,
671                },
672                Case::Unknown => Settled::Unknown(crate::cell::Unknown::TimedOut),
673                Case::Failed => Settled::Failed(crate::git::ProbeError::Read(Arc::from("boom"))),
674            };
675            cell.settle(generation, settled);
676        }
677
678        fn rank(summary: RowSummary) -> u8 {
679            match summary {
680                RowSummary::Fresh => 0,
681                RowSummary::Stale => 1,
682                RowSummary::Unknown => 2,
683                RowSummary::Failed => 3,
684                RowSummary::InFlight => 4,
685            }
686        }
687
688        let cases = [
689            ("fresh", Case::Fresh, RowSummary::Fresh),
690            ("stale", Case::Stale, RowSummary::Stale),
691            ("unknown", Case::Unknown, RowSummary::Unknown),
692            ("failed", Case::Failed, RowSummary::Failed),
693        ];
694        let generation = Generation::new(2);
695
696        for &(label_a, case_a, rank_a) in &cases {
697            for &(label_b, case_b, rank_b) in &cases {
698                let mut entity = fresh_entity("repo");
699                settle(&mut entity.dirty, generation, case_a);
700                settle(&mut entity.base, generation, case_b);
701
702                let expected = if rank(rank_a) >= rank(rank_b) {
703                    rank_a
704                } else {
705                    rank_b
706                };
707
708                assert_eq!(
709                    summary(&entity),
710                    expected,
711                    "case: dirty={label_a}, base={label_b}"
712                );
713            }
714        }
715    }
716
717    #[test]
718    fn an_unparseable_gitmodules_drives_the_row_to_failed_even_though_every_cell_is_fine() {
719        let mut entity = fresh_entity("repo");
720        entity.diagnostics.gitmodules_failed = Some(Arc::from("unexpected EOF"));
721
722        assert_eq!(summary(&entity), RowSummary::Failed);
723    }
724
725    /// Criterion 8's whole point, not merely that the fold reacts to `failed`: every Cell
726    /// here is fine (`fresh_entity` settles all six), and the mark comes from the receipt
727    /// alone. A worthless version of this test would also fail a Cell, which would pass even
728    /// if the receipt were never read.
729    #[test]
730    fn a_failed_last_action_drives_the_row_to_failed_even_though_every_cell_is_fine() {
731        let mut entity = fresh_entity("repo");
732        assert_eq!(
733            summary(&entity),
734            RowSummary::Fresh,
735            "sanity check: every cell must already read fine before the receipt is added"
736        );
737        entity.last_action = Some(receipt_with_steps(vec![
738            StepOutcome::Ok,
739            StepOutcome::Failed(1),
740        ]));
741
742        assert_eq!(summary(&entity), RowSummary::Failed);
743    }
744
745    /// The first-probe spinner outranks a failed receipt, which is why a consumer waiting
746    /// for a row to read Failed cannot stop at "the fan-out finished".
747    ///
748    /// A row on which nothing has settled yet reads InFlight whatever else is true of it,
749    /// so an Action that has already failed on such a row is invisible until the Generation
750    /// covering it lands. `repon`'s `run_failing_action_on` fixture waits on the row reading
751    /// Failed for exactly this reason, rather than on the fan-out being over; this is the
752    /// fold that makes the two different.
753    #[test]
754    fn a_failed_last_action_is_outranked_while_the_row_still_holds_no_values() {
755        let mut entity = EntityState::new(
756            EntityKey::new(Arc::from(Path::new("repo"))),
757            Arc::from("repo"),
758            Arc::from(Path::new("repo")),
759            Kind::Repo,
760        );
761        entity.last_action = Some(receipt_with_steps(vec![StepOutcome::Failed(1)]));
762
763        assert_eq!(
764            summary(&entity),
765            RowSummary::InFlight,
766            "a row holding no values yet must still read InFlight, receipt or no receipt"
767        );
768
769        // The same entity once one Generation has settled a single Cell: the receipt is
770        // read from that point on, so the assertion above is about the ordering of the two
771        // rather than about the receipt being ignored outright.
772        entity.branch.settle(
773            Generation::new(1),
774            Settled::Known {
775                value: Head::Branch {
776                    name: Arc::from("main"),
777                    commit: gix::hash::Kind::Sha1.null(),
778                },
779                at: Timestamp::now(),
780                stale: false,
781            },
782        );
783
784        assert_eq!(summary(&entity), RowSummary::Failed);
785    }
786
787    /// The defect this ticket fixes: `EntityState::last_action.running` is what
788    /// `Core::run_action` writes once per step while a run is on this row right now
789    /// (`docs/spec/actions.md`'s "The run on screen"), and the fold must widen to
790    /// `InFlight` for it, not merely name it to skip it.
791    #[test]
792    fn a_row_with_a_running_action_step_reads_in_flight() {
793        let mut entity = fresh_entity("repo");
794        entity.last_action = Some(ActionReceipt {
795            label: Arc::from("action"),
796            steps: Arc::from(Vec::new()),
797            skip: None,
798            finished_at: Timestamp::now(),
799            running: Some(crate::entity::RunningStep {
800                label: Arc::from("pnpm install"),
801                shell: false,
802                interactive: false,
803                started_at: Timestamp::now(),
804            }),
805        });
806
807        assert_eq!(summary(&entity), RowSummary::InFlight);
808    }
809
810    /// Settles the issue's own open question: a running Action outranks a failed one, because a
811    /// row retrying a previously failed step is in-flight right now, and reporting the old
812    /// failure while the retry runs is the wrong answer.
813    #[test]
814    fn a_running_action_step_outranks_the_same_receipts_own_failed_steps() {
815        let mut entity = fresh_entity("repo");
816        entity.last_action = Some(ActionReceipt {
817            label: Arc::from("action"),
818            steps: Arc::from(vec![StepResult {
819                label: Arc::from("step 0"),
820                outcome: StepOutcome::Failed(1),
821                output: Arc::from(&b""[..]),
822                elapsed: std::time::Duration::from_millis(1),
823                elision: None,
824                shell: false,
825                interactive: false,
826            }]),
827            skip: None,
828            finished_at: Timestamp::now(),
829            running: Some(crate::entity::RunningStep {
830                shell: false,
831                interactive: false,
832                label: Arc::from("step 1"),
833                started_at: Timestamp::now(),
834            }),
835        });
836
837        assert_eq!(summary(&entity), RowSummary::InFlight);
838    }
839
840    /// A running Action outranks a genuinely failed Cell too, not only its own receipt's past
841    /// steps: while a row is being retried right now, that is the fact worth showing over a
842    /// probe failure from before the retry started.
843    #[test]
844    fn a_running_action_step_outranks_a_failed_cell() {
845        let mut entity = fresh_entity("repo");
846        entity.dirty.settle(
847            Generation::new(2),
848            Settled::Failed(crate::git::ProbeError::Read(Arc::from("boom"))),
849        );
850        entity.last_action = Some(ActionReceipt {
851            label: Arc::from("action"),
852            steps: Arc::from(Vec::new()),
853            skip: None,
854            finished_at: Timestamp::now(),
855            running: Some(crate::entity::RunningStep {
856                shell: false,
857                interactive: false,
858                label: Arc::from("step 0"),
859                started_at: Timestamp::now(),
860            }),
861        });
862
863        assert_eq!(summary(&entity), RowSummary::InFlight);
864    }
865
866    #[test]
867    fn a_successful_last_action_does_not_drag_an_otherwise_fresh_row_down() {
868        let mut entity = fresh_entity("repo");
869        entity.last_action = Some(receipt_with_steps(vec![StepOutcome::Ok, StepOutcome::Ok]));
870
871        assert_eq!(summary(&entity), RowSummary::Fresh);
872    }
873
874    /// `Cancelled` is not a failure ([`docs/spec/actions.md`]'s "Step outcomes"), and that
875    /// classification has to hold inside the fold too, not just on `StepOutcome::is_failure`
876    /// in isolation: a cancelled run must never turn an otherwise-fine row `!`.
877    #[test]
878    fn a_cancelled_last_action_does_not_drive_the_row_to_failed() {
879        let mut entity = fresh_entity("repo");
880        entity.last_action = Some(receipt_with_steps(vec![
881            StepOutcome::Ok,
882            StepOutcome::Cancelled,
883        ]));
884
885        assert_eq!(summary(&entity), RowSummary::Fresh);
886    }
887
888    /// The same classification for a step Repon performed itself: a Management operation that
889    /// refused is not a failure and must leave the gutter alone, or a Repo that reads
890    /// perfectly well takes `!` for having been declined
891    /// (`docs/spec/actions.md`'s "Why the set grew from four to five").
892    #[test]
893    fn own_work_repon_refused_leaves_the_row_fresh_and_work_it_could_not_finish_does_not() {
894        let mut refused = fresh_entity("repo-refused");
895        refused.last_action = Some(receipt_with_steps(vec![StepOutcome::OwnWork(
896            OwnWork::Refused(Arc::from("refused, already ignored")),
897        )]));
898
899        let mut could_not = fresh_entity("repo-could-not");
900        could_not.last_action = Some(receipt_with_steps(vec![StepOutcome::OwnWork(
901            OwnWork::CouldNotAct(Arc::from("failed, permission denied")),
902        )]));
903
904        let mut did = fresh_entity("repo-did");
905        did.last_action = Some(receipt_with_steps(vec![StepOutcome::OwnWork(
906            OwnWork::Did(Arc::from("ignored")),
907        )]));
908
909        assert_eq!(summary(&refused), RowSummary::Fresh);
910        assert_eq!(summary(&did), RowSummary::Fresh);
911        assert_eq!(summary(&could_not), RowSummary::Failed);
912    }
913
914    /// Reinforces criterion 1's exclusion from the Cell machinery: `FoldableCell` is a
915    /// per-cell mechanism, private to this module and implemented exactly once, generically,
916    /// for `Cell<T>` alone; `EntityState::last_action` is a plain `Option<ActionReceipt>`,
917    /// never a `Cell<ActionReceipt>`, so it cannot become `&dyn FoldableCell` and cannot join
918    /// `summary`'s six-element `cells` array. What that guarantee predicts, and what this
919    /// test actually drives: the fold's verdict on a failed receipt reads only
920    /// `ActionReceipt::failed`'s single bool, never the receipt's own step count or shape.
921    #[test]
922    fn the_folds_verdict_on_a_failed_receipt_does_not_depend_on_how_many_steps_it_has() {
923        let mut one_step = fresh_entity("repo-one");
924        one_step.last_action = Some(receipt_with_steps(vec![StepOutcome::Failed(1)]));
925
926        let mut many_steps = fresh_entity("repo-many");
927        let mut outcomes = vec![StepOutcome::Ok; 20];
928        outcomes.push(StepOutcome::Failed(1));
929        many_steps.last_action = Some(receipt_with_steps(outcomes));
930
931        assert_eq!(summary(&one_step), RowSummary::Failed);
932        assert_eq!(summary(&one_step), summary(&many_steps));
933    }
934
935    #[test]
936    fn the_default_branchs_rung_and_its_disagreement_never_enter_the_fold() {
937        let mut entity = fresh_entity("repo");
938        entity.diagnostics.default_branch_rung = Some(2);
939        entity.diagnostics.default_branch_rung_disagreement = true;
940        entity.diagnostics.default_branch_rung_two_stale = true;
941        entity.diagnostics.default_branch_stopped =
942            Some(crate::entity::DefaultBranchStopped::NameListExhausted);
943
944        assert_eq!(summary(&entity), RowSummary::Fresh);
945    }
946
947    #[test]
948    fn a_repo_row_whose_state_cell_is_not_applicable_folds_to_fresh_rather_than_unknown() {
949        let mut entity = fresh_entity("repo");
950        assert_eq!(entity.kind, Kind::Repo);
951        entity
952            .state
953            .settle(Generation::new(2), Settled::NotApplicable);
954
955        assert_eq!(summary(&entity), RowSummary::Fresh);
956    }
957
958    #[test]
959    fn a_detached_row_whose_state_cell_is_not_applicable_folds_to_fresh_rather_than_unknown() {
960        let mut entity = EntityState::new(
961            EntityKey::new(Arc::from(Path::new("/repo-pr-1"))),
962            Arc::from("repo-pr-1"),
963            Arc::from(Path::new("/repo/.git")),
964            Kind::Worktree,
965        );
966        let generation = Generation::new(1);
967        entity.branch.settle(
968            generation,
969            Settled::Known {
970                value: Head::Detached(gix::hash::Kind::Sha1.null()),
971                at: Timestamp::now(),
972                stale: false,
973            },
974        );
975        entity.sync.settle(
976            generation,
977            Settled::Unknown(crate::cell::Unknown::NoDefaultBranch),
978        );
979        entity.dirty.settle(
980            generation,
981            Settled::Known {
982                value: DirtyCounts::default(),
983                at: Timestamp::now(),
984                stale: false,
985            },
986        );
987        entity.default_branch.settle(
988            generation,
989            Settled::Known {
990                value: DefaultBranch::new(Arc::from("main")),
991                at: Timestamp::now(),
992                stale: false,
993            },
994        );
995        // The Merged proof found neither ancestry nor patch equivalence against the
996        // default branch, so `state` is Not applicable rather than a fifth exclusive
997        // state (ADR 0019).
998        entity.state.settle(generation, Settled::NotApplicable);
999        entity.base.settle(
1000            generation,
1001            Settled::Known {
1002                value: 46,
1003                at: Timestamp::now(),
1004                stale: false,
1005            },
1006        );
1007
1008        assert_eq!(
1009            summary(&entity),
1010            RowSummary::Unknown,
1011            "sanity check: an Unknown sync cell should still win over the excluded \
1012             Not-applicable state cell"
1013        );
1014
1015        entity.sync.settle(
1016            generation,
1017            Settled::Known {
1018                value: SyncState::Tracking(AheadBehind {
1019                    ahead: 0,
1020                    behind: 0,
1021                }),
1022                at: Timestamp::now(),
1023                stale: false,
1024            },
1025        );
1026
1027        assert_eq!(summary(&entity), RowSummary::Fresh);
1028    }
1029
1030    /// One ordering case: a label, a mutation applied to an otherwise-fresh entity,
1031    /// and the expected fold.
1032    type OrderingCase = (&'static str, fn(&mut EntityState, Generation), RowSummary);
1033
1034    #[test]
1035    fn row_summary_follows_the_documented_ordering_over_every_settledness() {
1036        let generation = Generation::new(2);
1037        let cases: [OrderingCase; 6] = [
1038            (
1039                "every cell fresh",
1040                |_entity, _generation| {},
1041                RowSummary::Fresh,
1042            ),
1043            (
1044                "one cell stale",
1045                |entity, generation| {
1046                    entity.dirty.settle(
1047                        generation,
1048                        Settled::Known {
1049                            value: DirtyCounts {
1050                                modified: 3,
1051                                untracked: 0,
1052                                deleted: 0,
1053                            },
1054                            at: Timestamp::now(),
1055                            stale: true,
1056                        },
1057                    );
1058                },
1059                RowSummary::Stale,
1060            ),
1061            (
1062                "one cell unknown",
1063                |entity, generation| {
1064                    entity
1065                        .dirty
1066                        .settle(generation, Settled::Unknown(crate::cell::Unknown::TimedOut));
1067                },
1068                RowSummary::Unknown,
1069            ),
1070            (
1071                "one cell failed",
1072                |entity, generation| {
1073                    entity.dirty.settle(
1074                        generation,
1075                        Settled::Failed(crate::git::ProbeError::Read(Arc::from("boom"))),
1076                    );
1077                },
1078                RowSummary::Failed,
1079            ),
1080            (
1081                "a failed cell outranks an in-flight one once the row already holds values",
1082                |entity, generation| {
1083                    entity.dirty.settle(
1084                        generation,
1085                        Settled::Failed(crate::git::ProbeError::Read(Arc::from("boom"))),
1086                    );
1087                    entity.branch.begin_probe();
1088                },
1089                RowSummary::Failed,
1090            ),
1091            (
1092                "a Not-applicable cell would have been the worst cell had it been \
1093                 counted, but is excluded, so the row is fresh",
1094                |entity, generation| {
1095                    entity.state.settle(generation, Settled::NotApplicable);
1096                },
1097                RowSummary::Fresh,
1098            ),
1099        ];
1100
1101        for (label, mutate, expected) in cases {
1102            let mut entity = fresh_entity("repo");
1103            mutate(&mut entity, generation);
1104            assert_eq!(summary(&entity), expected, "case: {label}");
1105        }
1106    }
1107
1108    #[test]
1109    fn cloning_a_snapshot_of_five_hundred_entities_stays_far_inside_a_frame_budget() {
1110        let entities: Vec<EntityState> = (0..500)
1111            .map(|index| fresh_entity(&format!("repo-{index}")))
1112            .collect();
1113        let snapshot = Snapshot {
1114            generation: Generation::new(1),
1115            discovered_at: Timestamp::now(),
1116            entities,
1117        };
1118
1119        let iterations = 200;
1120        let start = std::time::Instant::now();
1121        for _ in 0..iterations {
1122            std::hint::black_box(snapshot.clone());
1123        }
1124        let per_clone = start.elapsed() / iterations;
1125
1126        // 16.7ms is one frame at 60fps; a clone this cheap (Arc bumps and a Vec
1127        // copy) should sit at a small fraction of it, not merely under it.
1128        let frame_budget = std::time::Duration::from_micros(16_700);
1129        assert!(
1130            per_clone < frame_budget / 4,
1131            "one snapshot clone of 500 entities averaged {per_clone:?} across {iterations} runs, expected well under a quarter of the {frame_budget:?} frame budget"
1132        );
1133    }
1134}