1use crate::cell::{Cell, Generation, Settled, Timestamp};
11use crate::entity::{ActionReceipt, Diagnostics, EntityState};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum RowSummary {
16 Fresh,
17 Stale,
18 Unknown,
19 Failed,
20 InFlight,
21}
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
27enum Settledness {
28 Fresh,
29 Stale,
30 Unknown,
31 Failed,
32}
33
34trait FoldableCell {
37 fn settledness(&self) -> Option<Settledness>;
38 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 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
86pub fn summary(entity: &EntityState) -> RowSummary {
118 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 let holds_no_values = cells.iter().all(|cell| !cell.holds_a_value());
159 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#[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 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 #[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 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 #[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 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 #[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 #[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 #[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 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 #[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 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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 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 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 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}