use crate::cell::{Cell, Generation, Settled, Timestamp};
use crate::entity::{ActionReceipt, Diagnostics, EntityState};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RowSummary {
Fresh,
Stale,
Unknown,
Failed,
InFlight,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum Settledness {
Fresh,
Stale,
Unknown,
Failed,
}
trait FoldableCell {
fn settledness(&self) -> Option<Settledness>;
fn holds_a_value(&self) -> bool;
}
impl<T> FoldableCell for Cell<T> {
fn settledness(&self) -> Option<Settledness> {
match self.settled() {
Some(Settled::NotApplicable) => None,
Some(Settled::Known {
stale: false,
value: _,
at: _,
}) => Some(Settledness::Fresh),
Some(Settled::Known {
stale: true,
value: _,
at: _,
}) => Some(Settledness::Stale),
Some(Settled::Unknown(_)) => Some(Settledness::Unknown),
Some(Settled::Failed(_)) => Some(Settledness::Failed),
None => None,
}
}
fn holds_a_value(&self) -> bool {
matches!(
self.settled(),
Some(Settled::Known {
value: _,
at: _,
stale: _
}) | Some(Settled::Unknown(_))
| Some(Settled::Failed(_))
)
}
}
pub fn summary(entity: &EntityState) -> RowSummary {
let EntityState {
key: _,
name: _,
common_dir: _,
kind: _,
branch,
sync,
base,
dirty,
state,
default_branch,
diagnostics,
last_action,
presence: _,
excluded: _,
in_progress_operation: _,
recent_commits: _,
} = entity;
let Diagnostics {
default_branch_rung: _,
default_branch_rung_disagreement: _,
default_branch_rung_two_stale: _,
default_branch_stopped: _,
gitmodules_failed,
} = diagnostics;
let cells: [&dyn FoldableCell; 6] = [branch, sync, base, dirty, state, default_branch];
let holds_no_values = cells.iter().all(|cell| !cell.holds_a_value());
let action_running = last_action
.as_ref()
.is_some_and(|receipt| receipt.running.is_some());
if holds_no_values || action_running {
return RowSummary::InFlight;
}
let derivation_failed =
gitmodules_failed.is_some() || last_action.as_ref().is_some_and(ActionReceipt::failed);
let worst = cells
.iter()
.filter_map(|cell| cell.settledness())
.chain(derivation_failed.then_some(Settledness::Failed))
.max();
match worst {
None => RowSummary::Fresh,
Some(Settledness::Fresh) => RowSummary::Fresh,
Some(Settledness::Stale) => RowSummary::Stale,
Some(Settledness::Unknown) => RowSummary::Unknown,
Some(Settledness::Failed) => RowSummary::Failed,
}
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct Snapshot {
pub generation: Generation,
pub discovered_at: Timestamp,
pub entities: Vec<EntityState>,
}
#[cfg(test)]
mod tests {
use std::path::Path;
use std::sync::Arc;
use super::*;
use crate::cell::Unknown;
use crate::entity::{
AheadBehind, DefaultBranch, DirtyCounts, EntityKey, Head, Kind, OwnWork, StepOutcome,
StepResult, SyncState, WorktreeState,
};
fn receipt_with_steps(outcomes: Vec<StepOutcome>) -> ActionReceipt {
let steps = outcomes
.into_iter()
.enumerate()
.map(|(index, outcome)| StepResult {
label: Arc::from(format!("step {index}")),
outcome,
output: Arc::from(&b""[..]),
elapsed: std::time::Duration::from_millis(1),
elision: None,
shell: false,
interactive: false,
})
.collect::<Vec<_>>();
ActionReceipt {
label: Arc::from("action"),
steps: Arc::from(steps),
skip: None,
finished_at: Timestamp::now(),
running: None,
}
}
fn fresh_entity(name: &str) -> EntityState {
let mut entity = EntityState::new(
EntityKey::new(Arc::from(Path::new(name))),
Arc::from(name),
Arc::from(Path::new(name)),
Kind::Repo,
);
let generation = Generation::new(1);
entity.branch.settle(
generation,
Settled::Known {
value: Head::Branch {
name: Arc::from("main"),
commit: gix::hash::Kind::Sha1.null(),
},
at: Timestamp::now(),
stale: false,
},
);
entity.sync.settle(
generation,
Settled::Known {
value: SyncState::Tracking(AheadBehind {
ahead: 0,
behind: 0,
}),
at: Timestamp::now(),
stale: false,
},
);
entity.base.settle(
generation,
Settled::Known {
value: 0,
at: Timestamp::now(),
stale: false,
},
);
entity.dirty.settle(
generation,
Settled::Known {
value: DirtyCounts::default(),
at: Timestamp::now(),
stale: false,
},
);
entity.state.settle(
generation,
Settled::Known {
value: WorktreeState::Active,
at: Timestamp::now(),
stale: false,
},
);
entity.default_branch.settle(
generation,
Settled::Known {
value: DefaultBranch::new(Arc::from("main")),
at: Timestamp::now(),
stale: false,
},
);
entity
}
#[test]
fn an_entity_with_every_cell_fresh_summarises_fresh() {
let entity = fresh_entity("repo");
assert_eq!(summary(&entity), RowSummary::Fresh);
}
#[test]
fn an_in_progress_git_operation_never_changes_the_row_summary() {
let idle = fresh_entity("repo-idle");
let mut rebasing = fresh_entity("repo-rebasing");
rebasing.in_progress_operation = Some(crate::git::InProgressOperation::Rebase);
assert_eq!(summary(&idle), summary(&rebasing));
assert_eq!(summary(&rebasing), RowSummary::Fresh);
}
#[test]
fn a_not_applicable_cell_is_excluded_rather_than_dragging_the_row_down() {
let mut entity = EntityState::new(
EntityKey::new(Arc::from(Path::new("/repo"))),
Arc::from("repo"),
Arc::from(Path::new("/repo/.git")),
Kind::Repo,
);
let generation = Generation::new(1);
entity.base.settle(generation, Settled::NotApplicable);
entity.branch.settle(
generation,
Settled::Known {
value: Head::Branch {
name: Arc::from("main"),
commit: gix::hash::Kind::Sha1.null(),
},
at: Timestamp::now(),
stale: false,
},
);
entity.sync.settle(
generation,
Settled::Known {
value: SyncState::Tracking(AheadBehind {
ahead: 0,
behind: 0,
}),
at: Timestamp::now(),
stale: false,
},
);
entity.dirty.settle(
generation,
Settled::Known {
value: DirtyCounts::default(),
at: Timestamp::now(),
stale: false,
},
);
entity.default_branch.settle(
generation,
Settled::Known {
value: DefaultBranch::new(Arc::from("main")),
at: Timestamp::now(),
stale: false,
},
);
assert_eq!(summary(&entity), RowSummary::Fresh);
}
#[test]
fn a_repo_rows_worktree_state_is_excluded_so_the_gutter_never_shows_a_question_mark() {
let mut entity = EntityState::new(
EntityKey::new(Arc::from(Path::new("/repo"))),
Arc::from("repo"),
Arc::from(Path::new("/repo/.git")),
Kind::Repo,
);
let generation = Generation::new(1);
entity.branch.settle(
generation,
Settled::Known {
value: Head::Branch {
name: Arc::from("main"),
commit: gix::hash::Kind::Sha1.null(),
},
at: Timestamp::now(),
stale: false,
},
);
entity.sync.settle(
generation,
Settled::Known {
value: SyncState::Tracking(AheadBehind {
ahead: 0,
behind: 0,
}),
at: Timestamp::now(),
stale: false,
},
);
entity.base.settle(
generation,
Settled::Known {
value: 0,
at: Timestamp::now(),
stale: false,
},
);
entity.dirty.settle(
generation,
Settled::Known {
value: DirtyCounts::default(),
at: Timestamp::now(),
stale: false,
},
);
entity.default_branch.settle(
generation,
Settled::Known {
value: DefaultBranch::new(Arc::from("main")),
at: Timestamp::now(),
stale: false,
},
);
assert_eq!(summary(&entity), RowSummary::Fresh);
}
#[test]
fn one_failed_cell_outranks_every_other_fresh_cell() {
let mut entity = fresh_entity("repo");
entity.dirty.settle(
Generation::new(2),
Settled::Failed(crate::git::ProbeError::Read(Arc::from("boom"))),
);
assert_eq!(summary(&entity), RowSummary::Failed);
}
#[test]
fn once_a_row_holds_values_a_failed_cell_outranks_an_in_flight_one() {
let mut entity = fresh_entity("repo");
entity.dirty.settle(
Generation::new(2),
Settled::Failed(crate::git::ProbeError::Read(Arc::from("boom"))),
);
entity.branch.begin_probe();
assert_eq!(summary(&entity), RowSummary::Failed);
}
#[test]
fn a_freshly_discovered_row_shows_in_flight_while_it_holds_no_values_at_all() {
let mut entity = EntityState::new(
EntityKey::new(Arc::from(Path::new("repo"))),
Arc::from("repo"),
Arc::from(Path::new("repo")),
Kind::Repo,
);
entity.branch.begin_probe();
assert_eq!(summary(&entity), RowSummary::InFlight);
}
#[test]
fn a_freshly_discovered_submodule_reads_unknown_before_any_other_cell_is_probed() {
let entity = EntityState::new(
EntityKey::new(Arc::from(Path::new("/repo/vendor/lib"))),
Arc::from("lib"),
Arc::from(Path::new("/repo/.git")),
Kind::Submodule,
);
assert!(matches!(
entity.state.settled(),
Some(Settled::Unknown(Unknown::NoDefaultBranch))
));
assert!(matches!(
entity.base.settled(),
Some(Settled::Unknown(Unknown::NoDefaultBranch))
));
assert_eq!(summary(&entity), RowSummary::Unknown);
}
#[test]
fn a_row_with_no_prior_state_at_all_reads_in_flight_even_before_any_probe_is_dispatched() {
let entity = EntityState::new(
EntityKey::new(Arc::from(Path::new("repo"))),
Arc::from("repo"),
Arc::from(Path::new("repo")),
Kind::Repo,
);
assert!(
!entity.branch.is_in_flight(),
"sanity check: nothing must be in flight yet"
);
assert_eq!(summary(&entity), RowSummary::InFlight);
}
#[test]
fn a_cell_nothing_has_ever_settled_is_excluded_from_the_fold_once_the_row_holds_other_values() {
let mut entity = EntityState::new(
EntityKey::new(Arc::from(Path::new("repo"))),
Arc::from("repo"),
Arc::from(Path::new("repo")),
Kind::Repo,
);
entity.branch.settle(
Generation::new(1),
Settled::Known {
value: Head::Branch {
name: Arc::from("main"),
commit: gix::hash::Kind::Sha1.null(),
},
at: Timestamp::now(),
stale: false,
},
);
assert_eq!(
summary(&entity),
RowSummary::Fresh,
"a Cell nothing has ever settled must not drag an otherwise-settled row to Unknown"
);
}
#[test]
fn reprobing_every_cell_of_an_already_fully_settled_row_never_changes_its_summary() {
let entity = fresh_entity("repo");
let before = summary(&entity);
assert_eq!(
before,
RowSummary::Fresh,
"sanity check: fresh_entity settles every Cell"
);
let mut reprobing = entity.clone();
reprobing.branch.begin_probe();
reprobing.sync.begin_probe();
reprobing.base.begin_probe();
reprobing.dirty.begin_probe();
reprobing.default_branch.begin_probe();
assert!(reprobing.branch.is_in_flight());
assert_eq!(
summary(&reprobing),
before,
"reprobing every already-settled Cell must not move the row's summary until a \
new answer actually lands"
);
}
#[test]
fn stale_outranks_fresh_but_not_unknown() {
let mut entity = fresh_entity("repo");
entity.dirty.settle(
Generation::new(2),
Settled::Known {
value: DirtyCounts {
modified: 3,
untracked: 0,
deleted: 0,
},
at: Timestamp::now(),
stale: true,
},
);
assert_eq!(summary(&entity), RowSummary::Stale);
entity.base.settle(
Generation::new(2),
Settled::Unknown(crate::cell::Unknown::TimedOut),
);
assert_eq!(summary(&entity), RowSummary::Unknown);
}
#[test]
fn every_pair_of_cell_settlednesses_folds_to_the_worse_of_the_two() {
#[derive(Clone, Copy)]
enum Case {
Fresh,
Stale,
Unknown,
Failed,
}
fn settle<T: Default>(cell: &mut Cell<T>, generation: Generation, case: Case) {
let settled = match case {
Case::Fresh => Settled::Known {
value: T::default(),
at: Timestamp::now(),
stale: false,
},
Case::Stale => Settled::Known {
value: T::default(),
at: Timestamp::now(),
stale: true,
},
Case::Unknown => Settled::Unknown(crate::cell::Unknown::TimedOut),
Case::Failed => Settled::Failed(crate::git::ProbeError::Read(Arc::from("boom"))),
};
cell.settle(generation, settled);
}
fn rank(summary: RowSummary) -> u8 {
match summary {
RowSummary::Fresh => 0,
RowSummary::Stale => 1,
RowSummary::Unknown => 2,
RowSummary::Failed => 3,
RowSummary::InFlight => 4,
}
}
let cases = [
("fresh", Case::Fresh, RowSummary::Fresh),
("stale", Case::Stale, RowSummary::Stale),
("unknown", Case::Unknown, RowSummary::Unknown),
("failed", Case::Failed, RowSummary::Failed),
];
let generation = Generation::new(2);
for &(label_a, case_a, rank_a) in &cases {
for &(label_b, case_b, rank_b) in &cases {
let mut entity = fresh_entity("repo");
settle(&mut entity.dirty, generation, case_a);
settle(&mut entity.base, generation, case_b);
let expected = if rank(rank_a) >= rank(rank_b) {
rank_a
} else {
rank_b
};
assert_eq!(
summary(&entity),
expected,
"case: dirty={label_a}, base={label_b}"
);
}
}
}
#[test]
fn an_unparseable_gitmodules_drives_the_row_to_failed_even_though_every_cell_is_fine() {
let mut entity = fresh_entity("repo");
entity.diagnostics.gitmodules_failed = Some(Arc::from("unexpected EOF"));
assert_eq!(summary(&entity), RowSummary::Failed);
}
#[test]
fn a_failed_last_action_drives_the_row_to_failed_even_though_every_cell_is_fine() {
let mut entity = fresh_entity("repo");
assert_eq!(
summary(&entity),
RowSummary::Fresh,
"sanity check: every cell must already read fine before the receipt is added"
);
entity.last_action = Some(receipt_with_steps(vec![
StepOutcome::Ok,
StepOutcome::Failed(1),
]));
assert_eq!(summary(&entity), RowSummary::Failed);
}
#[test]
fn a_failed_last_action_is_outranked_while_the_row_still_holds_no_values() {
let mut entity = EntityState::new(
EntityKey::new(Arc::from(Path::new("repo"))),
Arc::from("repo"),
Arc::from(Path::new("repo")),
Kind::Repo,
);
entity.last_action = Some(receipt_with_steps(vec![StepOutcome::Failed(1)]));
assert_eq!(
summary(&entity),
RowSummary::InFlight,
"a row holding no values yet must still read InFlight, receipt or no receipt"
);
entity.branch.settle(
Generation::new(1),
Settled::Known {
value: Head::Branch {
name: Arc::from("main"),
commit: gix::hash::Kind::Sha1.null(),
},
at: Timestamp::now(),
stale: false,
},
);
assert_eq!(summary(&entity), RowSummary::Failed);
}
#[test]
fn a_row_with_a_running_action_step_reads_in_flight() {
let mut entity = fresh_entity("repo");
entity.last_action = Some(ActionReceipt {
label: Arc::from("action"),
steps: Arc::from(Vec::new()),
skip: None,
finished_at: Timestamp::now(),
running: Some(crate::entity::RunningStep {
label: Arc::from("pnpm install"),
shell: false,
interactive: false,
started_at: Timestamp::now(),
}),
});
assert_eq!(summary(&entity), RowSummary::InFlight);
}
#[test]
fn a_running_action_step_outranks_the_same_receipts_own_failed_steps() {
let mut entity = fresh_entity("repo");
entity.last_action = Some(ActionReceipt {
label: Arc::from("action"),
steps: Arc::from(vec![StepResult {
label: Arc::from("step 0"),
outcome: StepOutcome::Failed(1),
output: Arc::from(&b""[..]),
elapsed: std::time::Duration::from_millis(1),
elision: None,
shell: false,
interactive: false,
}]),
skip: None,
finished_at: Timestamp::now(),
running: Some(crate::entity::RunningStep {
shell: false,
interactive: false,
label: Arc::from("step 1"),
started_at: Timestamp::now(),
}),
});
assert_eq!(summary(&entity), RowSummary::InFlight);
}
#[test]
fn a_running_action_step_outranks_a_failed_cell() {
let mut entity = fresh_entity("repo");
entity.dirty.settle(
Generation::new(2),
Settled::Failed(crate::git::ProbeError::Read(Arc::from("boom"))),
);
entity.last_action = Some(ActionReceipt {
label: Arc::from("action"),
steps: Arc::from(Vec::new()),
skip: None,
finished_at: Timestamp::now(),
running: Some(crate::entity::RunningStep {
shell: false,
interactive: false,
label: Arc::from("step 0"),
started_at: Timestamp::now(),
}),
});
assert_eq!(summary(&entity), RowSummary::InFlight);
}
#[test]
fn a_successful_last_action_does_not_drag_an_otherwise_fresh_row_down() {
let mut entity = fresh_entity("repo");
entity.last_action = Some(receipt_with_steps(vec![StepOutcome::Ok, StepOutcome::Ok]));
assert_eq!(summary(&entity), RowSummary::Fresh);
}
#[test]
fn a_cancelled_last_action_does_not_drive_the_row_to_failed() {
let mut entity = fresh_entity("repo");
entity.last_action = Some(receipt_with_steps(vec![
StepOutcome::Ok,
StepOutcome::Cancelled,
]));
assert_eq!(summary(&entity), RowSummary::Fresh);
}
#[test]
fn own_work_repon_refused_leaves_the_row_fresh_and_work_it_could_not_finish_does_not() {
let mut refused = fresh_entity("repo-refused");
refused.last_action = Some(receipt_with_steps(vec![StepOutcome::OwnWork(
OwnWork::Refused(Arc::from("refused, already ignored")),
)]));
let mut could_not = fresh_entity("repo-could-not");
could_not.last_action = Some(receipt_with_steps(vec![StepOutcome::OwnWork(
OwnWork::CouldNotAct(Arc::from("failed, permission denied")),
)]));
let mut did = fresh_entity("repo-did");
did.last_action = Some(receipt_with_steps(vec![StepOutcome::OwnWork(
OwnWork::Did(Arc::from("ignored")),
)]));
assert_eq!(summary(&refused), RowSummary::Fresh);
assert_eq!(summary(&did), RowSummary::Fresh);
assert_eq!(summary(&could_not), RowSummary::Failed);
}
#[test]
fn the_folds_verdict_on_a_failed_receipt_does_not_depend_on_how_many_steps_it_has() {
let mut one_step = fresh_entity("repo-one");
one_step.last_action = Some(receipt_with_steps(vec![StepOutcome::Failed(1)]));
let mut many_steps = fresh_entity("repo-many");
let mut outcomes = vec![StepOutcome::Ok; 20];
outcomes.push(StepOutcome::Failed(1));
many_steps.last_action = Some(receipt_with_steps(outcomes));
assert_eq!(summary(&one_step), RowSummary::Failed);
assert_eq!(summary(&one_step), summary(&many_steps));
}
#[test]
fn the_default_branchs_rung_and_its_disagreement_never_enter_the_fold() {
let mut entity = fresh_entity("repo");
entity.diagnostics.default_branch_rung = Some(2);
entity.diagnostics.default_branch_rung_disagreement = true;
entity.diagnostics.default_branch_rung_two_stale = true;
entity.diagnostics.default_branch_stopped =
Some(crate::entity::DefaultBranchStopped::NameListExhausted);
assert_eq!(summary(&entity), RowSummary::Fresh);
}
#[test]
fn a_repo_row_whose_state_cell_is_not_applicable_folds_to_fresh_rather_than_unknown() {
let mut entity = fresh_entity("repo");
assert_eq!(entity.kind, Kind::Repo);
entity
.state
.settle(Generation::new(2), Settled::NotApplicable);
assert_eq!(summary(&entity), RowSummary::Fresh);
}
#[test]
fn a_detached_row_whose_state_cell_is_not_applicable_folds_to_fresh_rather_than_unknown() {
let mut entity = EntityState::new(
EntityKey::new(Arc::from(Path::new("/repo-pr-1"))),
Arc::from("repo-pr-1"),
Arc::from(Path::new("/repo/.git")),
Kind::Worktree,
);
let generation = Generation::new(1);
entity.branch.settle(
generation,
Settled::Known {
value: Head::Detached(gix::hash::Kind::Sha1.null()),
at: Timestamp::now(),
stale: false,
},
);
entity.sync.settle(
generation,
Settled::Unknown(crate::cell::Unknown::NoDefaultBranch),
);
entity.dirty.settle(
generation,
Settled::Known {
value: DirtyCounts::default(),
at: Timestamp::now(),
stale: false,
},
);
entity.default_branch.settle(
generation,
Settled::Known {
value: DefaultBranch::new(Arc::from("main")),
at: Timestamp::now(),
stale: false,
},
);
entity.state.settle(generation, Settled::NotApplicable);
entity.base.settle(
generation,
Settled::Known {
value: 46,
at: Timestamp::now(),
stale: false,
},
);
assert_eq!(
summary(&entity),
RowSummary::Unknown,
"sanity check: an Unknown sync cell should still win over the excluded \
Not-applicable state cell"
);
entity.sync.settle(
generation,
Settled::Known {
value: SyncState::Tracking(AheadBehind {
ahead: 0,
behind: 0,
}),
at: Timestamp::now(),
stale: false,
},
);
assert_eq!(summary(&entity), RowSummary::Fresh);
}
type OrderingCase = (&'static str, fn(&mut EntityState, Generation), RowSummary);
#[test]
fn row_summary_follows_the_documented_ordering_over_every_settledness() {
let generation = Generation::new(2);
let cases: [OrderingCase; 6] = [
(
"every cell fresh",
|_entity, _generation| {},
RowSummary::Fresh,
),
(
"one cell stale",
|entity, generation| {
entity.dirty.settle(
generation,
Settled::Known {
value: DirtyCounts {
modified: 3,
untracked: 0,
deleted: 0,
},
at: Timestamp::now(),
stale: true,
},
);
},
RowSummary::Stale,
),
(
"one cell unknown",
|entity, generation| {
entity
.dirty
.settle(generation, Settled::Unknown(crate::cell::Unknown::TimedOut));
},
RowSummary::Unknown,
),
(
"one cell failed",
|entity, generation| {
entity.dirty.settle(
generation,
Settled::Failed(crate::git::ProbeError::Read(Arc::from("boom"))),
);
},
RowSummary::Failed,
),
(
"a failed cell outranks an in-flight one once the row already holds values",
|entity, generation| {
entity.dirty.settle(
generation,
Settled::Failed(crate::git::ProbeError::Read(Arc::from("boom"))),
);
entity.branch.begin_probe();
},
RowSummary::Failed,
),
(
"a Not-applicable cell would have been the worst cell had it been \
counted, but is excluded, so the row is fresh",
|entity, generation| {
entity.state.settle(generation, Settled::NotApplicable);
},
RowSummary::Fresh,
),
];
for (label, mutate, expected) in cases {
let mut entity = fresh_entity("repo");
mutate(&mut entity, generation);
assert_eq!(summary(&entity), expected, "case: {label}");
}
}
#[test]
fn cloning_a_snapshot_of_five_hundred_entities_stays_far_inside_a_frame_budget() {
let entities: Vec<EntityState> = (0..500)
.map(|index| fresh_entity(&format!("repo-{index}")))
.collect();
let snapshot = Snapshot {
generation: Generation::new(1),
discovered_at: Timestamp::now(),
entities,
};
let iterations = 200;
let start = std::time::Instant::now();
for _ in 0..iterations {
std::hint::black_box(snapshot.clone());
}
let per_clone = start.elapsed() / iterations;
let frame_budget = std::time::Duration::from_micros(16_700);
assert!(
per_clone < frame_budget / 4,
"one snapshot clone of 500 entities averaged {per_clone:?} across {iterations} runs, expected well under a quarter of the {frame_budget:?} frame budget"
);
}
}