1use std::collections::{HashMap, HashSet};
35use std::path::{Path, PathBuf};
36use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
37use std::sync::{Arc, Condvar, Mutex, OnceLock, RwLock};
38use std::thread::{self, JoinHandle};
39use std::time::{Duration, Instant};
40
41use crossbeam_channel::{Receiver, Sender, select};
42use rayon::iter::{IntoParallelIterator, ParallelIterator};
43
44use crate::base;
45use crate::cell::{Cell, Generation, Settled, Timestamp, Unknown};
46use crate::default_branch;
47use crate::discovery::{self, SetSpec};
48use crate::entity::{
49 ActionReceipt, DefaultBranch, DeleteRisk, DirtyCounts, EntityKey, EntityState, Head, Kind,
50 OwnWork, Presence, RunningStep, Skip, StepOutcome, StepResult, SyncState, WorktreeState,
51};
52use crate::environment;
53use crate::executor;
54use crate::filter::{Applicability, Filter, Partition};
55use crate::git;
56use crate::landing;
57#[cfg(any(test, feature = "test-util"))]
58use crate::liveness;
59use crate::patch_equivalence;
60use crate::poll;
61use crate::snapshot::Snapshot;
62
63#[allow(dead_code)] const FIRST_FRAME_NAMES_BUDGET_MS: u64 = 50;
70
71#[allow(dead_code)] const FIRST_FRAME_CHEAP_COLUMNS_BUDGET_MS: u64 = 200;
77
78#[derive(Debug, Clone)]
87pub struct RepoOverride {
88 pub path: PathBuf,
89 pub default_branch: Option<String>,
90 pub excluded: bool,
91}
92
93#[derive(Debug, Clone)]
106pub struct Step {
107 pub argv: Vec<String>,
108 pub shell: bool,
109 pub interactive: bool,
115 pub env: Vec<(String, String)>,
116}
117
118#[derive(Debug, Clone)]
130pub struct ActionSpec {
131 pub label: Arc<str>,
132 pub name: Option<Arc<str>>,
133 pub steps: Vec<Step>,
134 pub concurrency: u32,
135 pub when: Option<Filter>,
136}
137
138#[derive(Debug, Clone)]
144struct ResolvedOverride {
145 path: PathBuf,
146 common_dir: PathBuf,
147 default_branch: Option<String>,
148}
149
150#[derive(Debug, Clone)]
156struct ResolvedExclusion {
157 path: PathBuf,
158 common_dir: PathBuf,
159 excluded: bool,
160}
161
162trait ResolvedEntry {
165 fn path(&self) -> &Path;
166 fn common_dir(&self) -> &Path;
167}
168
169impl ResolvedEntry for ResolvedOverride {
170 fn path(&self) -> &Path {
171 &self.path
172 }
173
174 fn common_dir(&self) -> &Path {
175 &self.common_dir
176 }
177}
178
179impl ResolvedEntry for ResolvedExclusion {
180 fn path(&self) -> &Path {
181 &self.path
182 }
183
184 fn common_dir(&self) -> &Path {
185 &self.common_dir
186 }
187}
188
189fn resolve_entries(overrides: &[RepoOverride]) -> (Vec<ResolvedOverride>, Vec<ResolvedExclusion>) {
196 overrides
197 .iter()
198 .filter_map(|entry| {
199 let common_dir = git::common_dir_of(&entry.path).ok()?;
200 Some((
201 ResolvedOverride {
202 path: entry.path.clone(),
203 common_dir: common_dir.to_path_buf(),
204 default_branch: entry.default_branch.clone(),
205 },
206 ResolvedExclusion {
207 path: entry.path.clone(),
208 common_dir: common_dir.to_path_buf(),
209 excluded: entry.excluded,
210 },
211 ))
212 })
213 .unzip()
214}
215
216fn find_entry<'a, T: ResolvedEntry>(
229 entries: &'a [T],
230 path: &Path,
231 common_dir: &Path,
232) -> Option<&'a T> {
233 entries
234 .iter()
235 .find(|entry| entry.path() == path)
236 .or_else(|| {
237 entries
238 .iter()
239 .find(|entry| entry.common_dir() == common_dir)
240 })
241}
242
243fn excluded_by(exclusions: &[ResolvedExclusion], path: &Path, common_dir: &Path) -> bool {
247 find_entry(exclusions, path, common_dir).is_some_and(|entry| entry.excluded)
248}
249
250fn dispatches_kind(kind: Kind, show_submodules: bool) -> bool {
257 match kind {
258 Kind::Repo | Kind::Worktree => true,
259 Kind::Submodule => show_submodules,
260 }
261}
262
263#[derive(Debug, Clone)]
269pub struct FetchSpec {
270 pub enabled: bool,
271 pub interval: Duration,
272 pub concurrency: usize,
273}
274
275#[derive(Debug, Clone, Copy)]
284pub struct AutoUpdateSpec {
285 pub enabled: bool,
286}
287
288#[derive(Debug, Clone, Default, PartialEq, Eq)]
301pub struct FetchFailures {
302 pub failed: Vec<(PathBuf, String)>,
303}
304
305#[derive(Debug, Clone, PartialEq, Eq)]
312pub enum AutoUpdateAttempt {
313 NotClean,
315 NoUpstream,
317 NotBehind,
319 NotFastForward,
321 Updated,
323 Failed(String),
325}
326
327#[derive(Debug, Clone)]
331pub struct CoreSpec {
332 pub set: SetSpec,
333 pub overrides: Vec<RepoOverride>,
334 pub poll_interval: Duration,
335 pub status_stale_after: Duration,
336 pub generation_deadline: Duration,
337 pub show_submodules: bool,
343 pub fetch: FetchSpec,
345 pub auto_update: AutoUpdateSpec,
350}
351
352struct InFlight {
357 generation: u64,
358 cancel: Arc<AtomicBool>,
359}
360
361struct Table {
364 generation: u64,
365 discovered_at: Timestamp,
366 entities: Vec<EntityState>,
367 index: HashMap<EntityKey, usize>,
368 in_flight: HashMap<EntityKey, InFlight>,
369 generation_started_at: HashMap<u64, Instant>,
372 repos: HashMap<EntityKey, Arc<gix::ThreadSafeRepository>>,
378 poll_fingerprints: HashMap<EntityKey, poll::GitdirFingerprint>,
385}
386
387enum ClockControl {
389 Pause,
390 Resume,
391 Shutdown,
392}
393
394pub struct Core {
402 table: Arc<RwLock<Table>>,
403 overrides: Arc<Vec<ResolvedOverride>>,
407 exclusions: Arc<RwLock<Vec<ResolvedExclusion>>>,
415 set: SetSpec,
423 discovery_manual: Arc<AtomicBool>,
428 discovery_warn_after: Duration,
431 discovery_abandon_after: Arc<AtomicU64>,
436 show_submodules: Arc<AtomicBool>,
443 settle_gate: Arc<SettleGate>,
444 control: Sender<ClockControl>,
445 clock_thread: Option<JoinHandle<()>>,
446 discovery_warning: Arc<Mutex<Option<String>>>,
452 #[allow(dead_code)] default_branch_chain_reads: Arc<AtomicUsize>,
464 #[allow(dead_code)] patch_identity_reads: Arc<AtomicUsize>,
474 #[allow(dead_code)] patch_scan_bounds: Arc<Mutex<Vec<Option<gix::ObjectId>>>>,
482 action_running: Arc<AtomicBool>,
487 action_control: Arc<Mutex<Option<Arc<executor::RunControl>>>>,
496 #[allow(dead_code)] dispatch_log: Arc<Mutex<Vec<EntityKey>>>,
505 #[allow(dead_code)] phase_c_gates: Arc<Mutex<HashMap<EntityKey, PhaseCGateHandle>>>,
515 status_stale_after: Duration,
521 #[allow(dead_code)] poll_reprobed: Arc<Mutex<Vec<EntityKey>>>,
527 #[allow(dead_code)] poll_sweep_count: Arc<AtomicUsize>,
534 #[allow(dead_code)] fetch_cycle_count: Arc<AtomicUsize>,
541 network_default_branch: Arc<Mutex<HashMap<PathBuf, Arc<str>>>>,
552 fetch_failures: Arc<Mutex<FetchFailures>>,
556 turnstile: Arc<DispatchTurnstile>,
559 discovery_gate: Option<DiscoveryGate>,
561}
562
563#[derive(Default)]
566struct PhaseCGate {
567 cheap_landed: bool,
569 may_proceed: bool,
572 finished: bool,
575}
576
577type PhaseCGateHandle = Arc<(Mutex<PhaseCGate>, Condvar)>;
580
581impl Core {
582 pub fn start(spec: CoreSpec) -> Core {
592 Self::start_watched(spec).core
593 }
594
595 fn start_watched(spec: CoreSpec) -> StartForTest {
597 let interval = spec.poll_interval.max(Duration::from_nanos(1));
598 let ticks = crossbeam_channel::tick(interval);
599 let alive = Arc::new(AtomicBool::new(true));
600 let fetch_start = FetchStart {
601 enabled: spec.fetch.enabled,
602 concurrency: spec.fetch.concurrency.max(1),
603 ticks: if spec.fetch.enabled {
604 crossbeam_channel::tick(spec.fetch.interval.max(Duration::from_nanos(1)))
605 } else {
606 crossbeam_channel::never()
607 },
608 };
609 start_internal(
610 spec,
611 Duration::from_secs(1),
612 discovery::ABANDON_AFTER,
613 ticks,
614 fetch_start,
615 alive,
616 None,
617 )
618 }
619
620 #[cfg(any(test, feature = "test-util"))]
631 pub fn start_discovered(spec: CoreSpec) -> Core {
632 let mut started = Self::start_watched(spec);
633 if let Some(handle) = started.initial_discovery.take() {
634 handle
635 .join()
636 .expect("the first discovery thread should not panic");
637 }
638 started.core
639 }
640
641 pub fn refresh(&self, order: &[EntityKey]) -> Generation {
646 self.refresh_handles().dispatch(order)
647 }
648
649 pub fn refresh_all(&self) -> Generation {
661 self.refresh_handles().dispatch_over_everything()
662 }
663
664 pub fn rederive_default_branches(&self, keys: &[EntityKey]) -> Generation {
689 let generation = {
690 let mut table = self.table.write().unwrap();
691 table.generation += 1;
692 Generation::new(table.generation)
693 };
694
695 let dispatched: Vec<RederiveCandidate> = {
696 let mut table = self.table.write().unwrap();
697 let mut dispatched = Vec::new();
698 for key in keys {
699 let Some(&idx) = table.index.get(key) else {
700 continue;
701 };
702 table.entities[idx].default_branch.begin_probe();
703 let common_dir = Arc::clone(&table.entities[idx].common_dir);
704 let override_branch = find_entry(&self.overrides, key.path(), &common_dir)
705 .and_then(|entry| entry.default_branch.clone());
706 let repo = table.repos.get(key).cloned();
707 let kind = table.entities[idx].kind;
708 dispatched.push(RederiveCandidate {
709 key: key.clone(),
710 path: key.path().to_path_buf(),
711 common_dir,
712 repo,
713 override_branch,
714 kind,
715 });
716 }
717 dispatched
718 };
719
720 if dispatched.is_empty() {
721 return generation;
722 }
723
724 begin_probes_owed(&self.settle_gate, dispatched.len());
725
726 let table = Arc::clone(&self.table);
727 let settle_gate = Arc::clone(&self.settle_gate);
728 let network_default_branch = Arc::clone(&self.network_default_branch);
729 thread::spawn(move || {
730 let common_dirs: HashSet<Arc<Path>> = dispatched
731 .iter()
732 .map(|candidate| Arc::clone(&candidate.common_dir))
733 .collect();
734 probe_network_default_branches(&common_dirs, &network_default_branch);
735
736 let chain_cache: ChainFactsCache = Mutex::new(HashMap::new());
741 let chain_reads = AtomicUsize::new(0);
742 let never_cancelled = AtomicBool::new(false);
743
744 for candidate in dispatched {
745 let RederiveCandidate {
746 key,
747 path,
748 common_dir,
749 repo,
750 override_branch,
751 kind,
752 } = candidate;
753 let network_branch = network_branch_for(&network_default_branch, &common_dir);
754 let resolution = probe_default_branch_memoised(
755 &path,
756 repo.as_deref(),
757 &common_dir,
758 DefaultBranchHints {
759 override_branch: override_branch.as_deref(),
760 network_branch: network_branch.as_deref(),
761 },
762 kind,
763 &never_cancelled,
764 &ChainFactsMemo {
765 cache: &chain_cache,
766 reads: &chain_reads,
767 },
768 );
769 {
770 let mut table = table.write().unwrap();
771 if let (Some(&idx), Some(resolution)) = (table.index.get(&key), resolution) {
772 table.entities[idx].apply_default_branch_resolution(generation, resolution);
773 }
774 }
775 complete_one(&settle_gate);
776 }
777 });
778
779 generation
780 }
781
782 fn refresh_handles(&self) -> RefreshHandles {
791 RefreshHandles {
792 table: Arc::clone(&self.table),
793 overrides: Arc::clone(&self.overrides),
794 exclusions: Arc::clone(&self.exclusions),
795 set: self.set.clone(),
796 discovery_manual: Arc::clone(&self.discovery_manual),
797 discovery_warn_after: self.discovery_warn_after,
798 discovery_abandon_after: Arc::clone(&self.discovery_abandon_after),
799 discovery_warning: Arc::clone(&self.discovery_warning),
800 show_submodules: Arc::clone(&self.show_submodules),
801 settle_gate: Arc::clone(&self.settle_gate),
802 default_branch_chain_reads: Arc::clone(&self.default_branch_chain_reads),
803 patch_identity_reads: Arc::clone(&self.patch_identity_reads),
804 patch_scan_bounds: Arc::clone(&self.patch_scan_bounds),
805 dispatch_log: Arc::clone(&self.dispatch_log),
806 phase_c_gates: Arc::clone(&self.phase_c_gates),
807 network_default_branch: Arc::clone(&self.network_default_branch),
808 turnstile: Arc::clone(&self.turnstile),
809 discovery_gate: self.discovery_gate.clone(),
810 }
811 }
812
813 pub fn probe_now(&self, key: &EntityKey) -> EntityState {
818 let never_cancelled = Arc::new(AtomicBool::new(false));
822 let (cached_repo, common_dir_hint, probes_state, probes_base, kind) = {
823 let table = self.table.read().unwrap();
824 let repo = table.repos.get(key).cloned();
825 let common_dir = table
826 .index
827 .get(key)
828 .map(|&idx| Arc::clone(&table.entities[idx].common_dir));
829 let probes_state = table
834 .index
835 .get(key)
836 .map(|&idx| table.entities[idx].probes_state())
837 .unwrap_or(false);
838 let probes_base = table
839 .index
840 .get(key)
841 .map(|&idx| table.entities[idx].probes_base())
842 .unwrap_or(true);
843 let kind = table
846 .index
847 .get(key)
848 .map(|&idx| table.entities[idx].kind)
849 .unwrap_or(Kind::Repo);
850 (repo, common_dir, probes_state, probes_base, kind)
851 };
852 let common_dir_hint = common_dir_hint.unwrap_or_else(|| Arc::from(key.path().join(".git")));
853 let override_branch = find_entry(&self.overrides, key.path(), &common_dir_hint)
854 .and_then(|entry| entry.default_branch.clone());
855 let excluded = excluded_by(
856 &self.exclusions.read().unwrap(),
857 key.path(),
858 &common_dir_hint,
859 );
860
861 let branch_outcome =
862 probe_branch(key.path(), cached_repo.as_deref(), kind, &never_cancelled);
863 let sync_outcome = probe_sync(
864 key.path(),
865 cached_repo.as_deref(),
866 branch_outcome.as_ref().map(|(settled, ..)| settled),
867 kind,
868 &never_cancelled,
869 );
870 let default_branch_outcome = probe_default_branch(
871 key.path(),
872 cached_repo.as_deref(),
873 DefaultBranchHints {
874 override_branch: override_branch.as_deref(),
875 network_branch: network_branch_for(&self.network_default_branch, &common_dir_hint)
876 .as_deref(),
877 },
878 kind,
879 &never_cancelled,
880 );
881 let base_outcome = if probes_base {
882 probe_base(
883 key.path(),
884 cached_repo.as_deref(),
885 branch_outcome.as_ref().map(|(settled, ..)| settled),
886 default_branch_outcome.as_ref().map(|r| &r.settled),
887 &never_cancelled,
888 )
889 } else {
890 None
891 };
892 let state_outcome = if probes_state {
893 let patch_cache: PatchIdentityCache = Mutex::new(HashMap::new());
898 let patch_reads = AtomicUsize::new(0);
899 let patch_scan_bounds = Mutex::new(Vec::new());
900 let gate = BoundGate::new(1);
901 let mut report = GateReport::new(&gate);
902 let memo = PatchEquivalenceMemo {
903 cache: &patch_cache,
904 reads: &patch_reads,
905 scan_bounds: &patch_scan_bounds,
906 };
907 probe_worktree_state(
908 key.path(),
909 cached_repo.as_deref(),
910 default_branch_outcome.as_ref().map(|r| &r.settled),
911 &common_dir_hint,
912 &never_cancelled,
913 &memo,
914 &mut report,
915 )
916 } else {
917 None
918 };
919 let dirty_outcome =
920 probe_status(key.path(), cached_repo.as_deref(), kind, &never_cancelled);
921
922 let mut table = self.table.write().unwrap();
923 let generation = Generation::new(table.generation);
924 let idx = match table.index.get(key).copied() {
925 Some(idx) => idx,
926 None => {
927 let name = display_name(key.path());
928 table.entities.push(EntityState::new(
929 key.clone(),
930 name,
931 common_dir_hint,
932 Kind::Repo,
933 ));
934 let idx = table.entities.len() - 1;
935 table.index.insert(key.clone(), idx);
936 idx
937 }
938 };
939 table.entities[idx].excluded = excluded;
940 if let Some((settled, in_progress, recent)) = branch_outcome {
941 table.entities[idx].apply_branch_probe(generation, settled, in_progress, recent);
942 }
943 if let Some(settled) = sync_outcome {
944 table.entities[idx].sync.settle(generation, settled);
945 }
946 if let Some(settled) = base_outcome {
947 table.entities[idx].base.settle(generation, settled);
948 }
949 if let Some(resolution) = default_branch_outcome {
950 table.entities[idx].apply_default_branch_resolution(generation, resolution);
951 }
952 if let Some(settled) = state_outcome {
953 table.entities[idx].state.settle(generation, settled);
954 }
955 if let Some(settled) = dirty_outcome {
956 table.entities[idx].dirty.settle(generation, settled);
957 }
958 table.entities[idx].clone()
959 }
960
961 pub fn snapshot(&self) -> Snapshot {
967 let table = self.table.read().unwrap();
968 let mut entities = table.entities.clone();
969 for entity in &mut entities {
970 entity.age_status_cells(self.status_stale_after);
971 }
972 Snapshot {
973 generation: Generation::new(table.generation),
974 discovered_at: table.discovered_at,
975 entities,
976 }
977 }
978
979 pub fn try_settle(&self, within: Duration) -> Result<Snapshot, Snapshot> {
989 let (lock, cvar) = &*self.settle_gate;
990 let guard = lock.lock().unwrap();
991 let (guard, timeout) = cvar
992 .wait_timeout_while(guard, within, |counts| !counts.is_settled())
993 .unwrap();
994 drop(guard);
997 let snapshot = self.snapshot();
998 if timeout.timed_out() {
999 Err(snapshot)
1000 } else {
1001 Ok(snapshot)
1002 }
1003 }
1004
1005 #[cfg(any(test, feature = "test-util"))]
1016 pub fn settle(&self) -> Snapshot {
1017 self.settle_within(liveness::BACKSTOP)
1018 }
1019
1020 #[cfg(any(test, feature = "test-util"))]
1024 fn settle_within(&self, deadline: Duration) -> Snapshot {
1025 self.try_settle(deadline).unwrap_or_else(|_| {
1026 let (probes, dispatches) = {
1030 let counts = self.settle_gate.0.lock().unwrap();
1031 (counts.probes, counts.dispatches)
1032 };
1033 liveness::expired(
1034 deadline,
1035 "everything this Core has in flight to land",
1036 &format!("{probes} probe(s) and {dispatches} dispatch(es) still outstanding"),
1037 )
1038 })
1039 }
1040
1041 pub fn delete_risk(&self, key: &EntityKey) -> Result<DeleteRisk, git::ProbeError> {
1057 let repo = git::open_thread_safe(key.path())?.to_thread_local();
1058 let dirty = git::dirty_counts(&repo, Arc::new(AtomicBool::new(false)))?;
1059 let staged = git::staged_changes(&repo)?;
1060 let (unpushed_commits, unpushed_branches) = git::unpushed(&repo)?;
1061 let linked_worktrees = git::linked_worktrees(&repo)?;
1062 Ok(DeleteRisk {
1063 uncommitted: dirty.total() > 0 || staged,
1064 unpushed_commits,
1065 unpushed_branches,
1066 linked_worktrees,
1067 })
1068 }
1069
1070 pub fn worktree_admin_dir(&self, key: &EntityKey) -> Result<PathBuf, git::ProbeError> {
1078 let repo = git::open_thread_safe(key.path())?.to_thread_local();
1079 Ok(git::worktree_admin_dir(&repo))
1080 }
1081
1082 pub fn linked_worktree_paths(&self, key: &EntityKey) -> Result<Vec<PathBuf>, git::ProbeError> {
1089 let repo = git::open_thread_safe(key.path())?.to_thread_local();
1090 git::linked_worktree_paths(&repo)
1091 }
1092
1093 pub fn ignored_directories_for_deletion(
1101 &self,
1102 path: &Path,
1103 ) -> Result<Vec<PathBuf>, git::ProbeError> {
1104 let repo = git::open_thread_safe(path)?.to_thread_local();
1105 git::ignored_directories_for_deletion(&repo)
1106 }
1107
1108 pub fn attempt_auto_update(&self, key: &EntityKey) -> AutoUpdateAttempt {
1115 match crate::auto_update::attempt(key.path()) {
1116 crate::auto_update::Outcome::Ineligible(crate::auto_update::Ineligible::NotClean) => {
1117 AutoUpdateAttempt::NotClean
1118 }
1119 crate::auto_update::Outcome::Ineligible(crate::auto_update::Ineligible::NoUpstream) => {
1120 AutoUpdateAttempt::NoUpstream
1121 }
1122 crate::auto_update::Outcome::Ineligible(crate::auto_update::Ineligible::NotBehind) => {
1123 AutoUpdateAttempt::NotBehind
1124 }
1125 crate::auto_update::Outcome::Ineligible(
1126 crate::auto_update::Ineligible::NotFastForward,
1127 ) => AutoUpdateAttempt::NotFastForward,
1128 crate::auto_update::Outcome::Updated { .. } => AutoUpdateAttempt::Updated,
1129 crate::auto_update::Outcome::Failed(error) => AutoUpdateAttempt::Failed(error),
1130 }
1131 }
1132
1133 pub fn run_action_for_entity_blocking(
1145 &self,
1146 action: &ActionSpec,
1147 key: &EntityKey,
1148 ) -> Option<ActionReceipt> {
1149 let entity = {
1150 let table = self.table.read().unwrap();
1151 let idx = *table.index.get(key)?;
1152 table.entities[idx].clone()
1153 };
1154 let control = executor::RunControl::new();
1155 Some(run_action_for_entity(&entity, action, &control, &|_| {}))
1156 }
1157
1158 pub fn management_handle(&self) -> ManagementHandle {
1164 ManagementHandle {
1165 table: Arc::clone(&self.table),
1166 }
1167 }
1168
1169 pub fn dismiss(&self, key: &EntityKey) {
1171 let mut table = self.table.write().unwrap();
1172 if let Some(idx) = table.index.remove(key) {
1173 table.entities.remove(idx);
1174 for position in table.index.values_mut() {
1175 if *position > idx {
1176 *position -= 1;
1177 }
1178 }
1179 }
1180 table.poll_fingerprints.remove(key);
1181 if let Some(in_flight) = table.in_flight.remove(key) {
1182 in_flight.cancel.store(true, Ordering::Release);
1183 drop(table);
1184 complete_one(&self.settle_gate);
1185 }
1186 }
1187
1188 fn partition_operable(&self, order: &[EntityKey]) -> (Vec<EntityState>, Vec<EntityState>) {
1200 let table = self.table.read().unwrap();
1201 order
1202 .iter()
1203 .filter_map(|key| table.index.get(key).map(|&idx| table.entities[idx].clone()))
1204 .partition(|entity| !entity.excluded)
1205 }
1206
1207 pub fn operable_count(&self, order: &[EntityKey]) -> usize {
1214 self.partition_operable(order).0.len()
1215 }
1216
1217 pub fn vanished_count(&self) -> usize {
1221 self.table
1222 .read()
1223 .unwrap()
1224 .entities
1225 .iter()
1226 .filter(|entity| entity.presence == Presence::Vanished)
1227 .count()
1228 }
1229
1230 pub fn applicability(&self, order: &[EntityKey], when: &Filter) -> Applicability {
1244 when.applicability(self.partition_operable(order).0.iter())
1245 }
1246
1247 pub fn action_running(&self) -> bool {
1253 self.action_running.load(Ordering::Acquire)
1254 }
1255
1256 pub fn refresh_running(&self) -> bool {
1266 let (lock, _cvar) = &*self.settle_gate;
1267 !lock.lock().unwrap().is_settled()
1268 }
1269
1270 pub fn run_action(&self, action: ActionSpec, order: &[EntityKey]) -> bool {
1318 if self
1319 .action_running
1320 .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
1321 .is_err()
1322 {
1323 return false;
1324 }
1325
1326 cancel_in_flight(&self.table, &self.settle_gate);
1329
1330 let (operable, excluded) = self.partition_operable(order);
1331
1332 let write_skip_receipts = |entities: &[EntityState], skip: Skip| {
1333 if entities.is_empty() {
1334 return;
1335 }
1336 let finished_at = Timestamp::now();
1337 let mut table = self.table.write().unwrap();
1338 for entity in entities {
1339 if let Some(&idx) = table.index.get(&entity.key) {
1340 table.entities[idx].last_action = Some(ActionReceipt {
1341 label: Arc::clone(&action.label),
1342 steps: Arc::from(Vec::new()),
1343 skip: Some(skip),
1344 finished_at,
1345 running: None,
1346 });
1347 }
1348 }
1349 };
1350
1351 write_skip_receipts(&excluded, Skip::Excluded);
1352
1353 let included = match &action.when {
1354 Some(when) => {
1355 let Partition {
1356 applicable,
1357 inapplicable,
1358 unresolved,
1359 } = when.partition(operable);
1360 write_skip_receipts(&inapplicable, Skip::Inapplicable);
1361 write_skip_receipts(&unresolved, Skip::Unresolved);
1362 applicable
1363 }
1364 None => operable,
1365 };
1366
1367 let table_handle = Arc::clone(&self.table);
1368 let action_running = Arc::clone(&self.action_running);
1369 let refresh_handles = self.refresh_handles();
1370 let control = executor::RunControl::new();
1374 *self.action_control.lock().unwrap() = Some(Arc::clone(&control));
1375 let action_control = Arc::clone(&self.action_control);
1376 let concurrency = action.concurrency.max(1) as usize;
1383
1384 thread::spawn(move || {
1390 let pool = rayon::ThreadPoolBuilder::new()
1391 .num_threads(concurrency)
1392 .build()
1393 .expect("build the Action fan-out's own dedicated pool");
1394
1395 let fan_out = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1401 pool.install(|| {
1402 included.into_par_iter().for_each(|entity| {
1403 let write_receipt = |receipt: ActionReceipt| {
1404 let mut table = table_handle.write().unwrap();
1405 if let Some(&idx) = table.index.get(&entity.key) {
1406 table.entities[idx].last_action = Some(receipt);
1407 }
1408 };
1409 let receipt =
1410 run_action_for_entity(&entity, &action, &control, &write_receipt);
1411 write_receipt(receipt);
1412 });
1413 });
1414 }));
1415
1416 action_running.store(false, Ordering::Release);
1425 *action_control.lock().unwrap() = None;
1429
1430 let Ok(()) = fan_out else {
1436 return;
1437 };
1438
1439 let all_keys: Vec<EntityKey> = table_handle
1442 .read()
1443 .unwrap()
1444 .entities
1445 .iter()
1446 .map(|entity| entity.key.clone())
1447 .collect();
1448 refresh_handles.dispatch(&all_keys);
1449 });
1451
1452 true
1453 }
1454
1455 pub fn hold_action(&self) {
1462 if let Some(control) = self.action_control.lock().unwrap().as_ref() {
1463 control.hold();
1464 }
1465 }
1466
1467 pub fn continue_action(&self) {
1470 if let Some(control) = self.action_control.lock().unwrap().as_ref() {
1471 control.continue_run();
1472 }
1473 }
1474
1475 pub fn stop_action(&self) {
1484 if let Some(control) = self.action_control.lock().unwrap().as_ref() {
1485 control.cancel();
1486 }
1487 }
1488
1489 pub fn pause(&self) {
1492 let _ = self.control.send(ClockControl::Pause);
1493 }
1494
1495 pub fn resume(&self) {
1498 let _ = self.control.send(ClockControl::Resume);
1499 }
1500
1501 pub fn discovery_warning(&self) -> Option<String> {
1507 self.discovery_warning.lock().unwrap().clone()
1508 }
1509
1510 pub fn fetch_failures(&self) -> FetchFailures {
1517 self.fetch_failures.lock().unwrap().clone()
1518 }
1519
1520 pub fn set_show_submodules(&self, show_submodules: bool) {
1527 self.show_submodules
1528 .store(show_submodules, Ordering::Release);
1529 }
1530
1531 pub fn record_own_work(&self, label: &str, results: &[(EntityKey, OwnWork, Duration)]) {
1551 let label: Arc<str> = Arc::from(label);
1552 let finished_at = Timestamp::now();
1553 let mut table = self.table.write().unwrap();
1554 for (key, work, elapsed) in results {
1555 let Some(&idx) = table.index.get(key) else {
1556 continue;
1557 };
1558 table.entities[idx].last_action = Some(ActionReceipt {
1559 label: Arc::clone(&label),
1560 steps: Arc::from(vec![StepResult {
1561 label: Arc::clone(&label),
1562 outcome: StepOutcome::OwnWork(work.clone()),
1563 output: Arc::from(&b""[..]),
1564 elapsed: *elapsed,
1565 elision: None,
1566 shell: false,
1567 interactive: false,
1568 }]),
1569 skip: None,
1570 finished_at,
1571 running: None,
1572 });
1573 }
1574 }
1575
1576 pub fn set_exclusions(&self, overrides: &[RepoOverride]) {
1587 let (_, resolved) = resolve_entries(overrides);
1588 {
1591 let mut exclusions = self.exclusions.write().unwrap();
1592 *exclusions = resolved.clone();
1593 }
1594 let mut table = self.table.write().unwrap();
1595 for entity in &mut table.entities {
1596 entity.excluded = excluded_by(&resolved, entity.key.path(), &entity.common_dir);
1597 }
1598 }
1599}
1600
1601#[derive(Clone)]
1610pub struct ManagementHandle {
1611 table: Arc<RwLock<Table>>,
1612}
1613
1614impl ManagementHandle {
1615 pub fn worktree_admin_dir(&self, key: &EntityKey) -> Result<PathBuf, git::ProbeError> {
1617 let repo = git::open_thread_safe(key.path())?.to_thread_local();
1618 Ok(git::worktree_admin_dir(&repo))
1619 }
1620
1621 pub fn linked_worktree_paths(&self, key: &EntityKey) -> Result<Vec<PathBuf>, git::ProbeError> {
1623 let repo = git::open_thread_safe(key.path())?.to_thread_local();
1624 git::linked_worktree_paths(&repo)
1625 }
1626
1627 pub fn ignored_directories_for_deletion(
1630 &self,
1631 path: &Path,
1632 ) -> Result<Vec<PathBuf>, git::ProbeError> {
1633 let repo = git::open_thread_safe(path)?.to_thread_local();
1634 git::ignored_directories_for_deletion(&repo)
1635 }
1636
1637 pub fn attempt_auto_update(&self, key: &EntityKey) -> AutoUpdateAttempt {
1639 match crate::auto_update::attempt(key.path()) {
1640 crate::auto_update::Outcome::Ineligible(crate::auto_update::Ineligible::NotClean) => {
1641 AutoUpdateAttempt::NotClean
1642 }
1643 crate::auto_update::Outcome::Ineligible(crate::auto_update::Ineligible::NoUpstream) => {
1644 AutoUpdateAttempt::NoUpstream
1645 }
1646 crate::auto_update::Outcome::Ineligible(crate::auto_update::Ineligible::NotBehind) => {
1647 AutoUpdateAttempt::NotBehind
1648 }
1649 crate::auto_update::Outcome::Ineligible(
1650 crate::auto_update::Ineligible::NotFastForward,
1651 ) => AutoUpdateAttempt::NotFastForward,
1652 crate::auto_update::Outcome::Updated { .. } => AutoUpdateAttempt::Updated,
1653 crate::auto_update::Outcome::Failed(error) => AutoUpdateAttempt::Failed(error),
1654 }
1655 }
1656
1657 pub fn run_action_for_entity_blocking(
1660 &self,
1661 action: &ActionSpec,
1662 key: &EntityKey,
1663 ) -> Option<ActionReceipt> {
1664 let entity = {
1665 let table = self.table.read().unwrap();
1666 let idx = *table.index.get(key)?;
1667 table.entities[idx].clone()
1668 };
1669 let control = executor::RunControl::new();
1670 Some(run_action_for_entity(&entity, action, &control, &|_| {}))
1671 }
1672}
1673
1674#[derive(Clone)]
1684struct RefreshHandles {
1685 table: Arc<RwLock<Table>>,
1686 overrides: Arc<Vec<ResolvedOverride>>,
1687 exclusions: Arc<RwLock<Vec<ResolvedExclusion>>>,
1690 set: SetSpec,
1691 discovery_manual: Arc<AtomicBool>,
1692 discovery_warn_after: Duration,
1693 discovery_abandon_after: Arc<AtomicU64>,
1694 discovery_warning: Arc<Mutex<Option<String>>>,
1695 show_submodules: Arc<AtomicBool>,
1696 settle_gate: Arc<SettleGate>,
1697 default_branch_chain_reads: Arc<AtomicUsize>,
1698 patch_identity_reads: Arc<AtomicUsize>,
1699 patch_scan_bounds: Arc<Mutex<Vec<Option<gix::ObjectId>>>>,
1700 dispatch_log: Arc<Mutex<Vec<EntityKey>>>,
1701 phase_c_gates: Arc<Mutex<HashMap<EntityKey, PhaseCGateHandle>>>,
1702 network_default_branch: Arc<Mutex<HashMap<PathBuf, Arc<str>>>>,
1706 turnstile: Arc<DispatchTurnstile>,
1709 discovery_gate: Option<DiscoveryGate>,
1711}
1712
1713#[derive(Default)]
1722struct DispatchTurnstile {
1723 serving: Mutex<u64>,
1725 ready: Condvar,
1726 next: AtomicU64,
1730}
1731
1732impl DispatchTurnstile {
1733 fn reserve(&self) -> u64 {
1734 self.next.fetch_add(1, Ordering::AcqRel)
1735 }
1736
1737 fn take(&self, ticket: u64) -> DispatchTurn<'_> {
1741 let serving = self.serving.lock().unwrap();
1742 drop(
1743 self.ready
1744 .wait_while(serving, |serving| *serving != ticket)
1745 .unwrap(),
1746 );
1747 DispatchTurn {
1748 turnstile: self,
1749 ticket,
1750 }
1751 }
1752}
1753
1754struct DispatchTurn<'a> {
1756 turnstile: &'a DispatchTurnstile,
1757 ticket: u64,
1758}
1759
1760impl Drop for DispatchTurn<'_> {
1761 fn drop(&mut self) {
1762 let mut serving = self.turnstile.serving.lock().unwrap();
1763 *serving = self.ticket + 1;
1764 self.turnstile.ready.notify_all();
1765 }
1766}
1767
1768impl RefreshHandles {
1769 fn dispatch(&self, order: &[EntityKey]) -> Generation {
1778 let (generation, ticket) = self.reserve_generation();
1779 begin_dispatch(&self.settle_gate);
1780 let handles = self.clone();
1781 let order = order.to_vec();
1782 thread::spawn(move || {
1783 let _turn = handles.turnstile.take(ticket);
1784 handles.run_generation(&order, generation);
1785 finish_dispatch(&handles.settle_gate);
1786 });
1787 generation
1788 }
1789
1790 fn dispatch_over_everything(&self) -> Generation {
1794 let (generation, ticket) = self.reserve_generation();
1795 begin_dispatch(&self.settle_gate);
1796 let handles = self.clone();
1797 thread::spawn(move || {
1798 let _turn = handles.turnstile.take(ticket);
1799 handles.rediscover();
1800 let order: Vec<EntityKey> = handles
1801 .table
1802 .read()
1803 .unwrap()
1804 .entities
1805 .iter()
1806 .map(|entity| entity.key.clone())
1807 .collect();
1808 handles.dispatch_probes(&order, generation);
1809 finish_dispatch(&handles.settle_gate);
1810 });
1811 generation
1812 }
1813
1814 fn reserve_generation(&self) -> (Generation, u64) {
1817 let mut table = self.table.write().unwrap();
1818 table.generation += 1;
1819 (Generation::new(table.generation), self.turnstile.reserve())
1820 }
1821
1822 fn run_generation(&self, order: &[EntityKey], generation: Generation) {
1825 self.rediscover();
1826 self.dispatch_probes(order, generation);
1827 }
1828
1829 fn rediscover(&self) {
1835 if !self.discovery_manual.load(Ordering::Acquire) {
1836 self.rerun_discovery();
1837 }
1838 }
1839
1840 fn dispatch_probes(&self, order: &[EntityKey], generation: Generation) {
1846 self.default_branch_chain_reads.store(0, Ordering::Release);
1851 self.patch_identity_reads.store(0, Ordering::Release);
1852 self.patch_scan_bounds.lock().unwrap().clear();
1853 self.dispatch_log.lock().unwrap().clear();
1854
1855 let generation_number = generation.value();
1856 let mut table = self.table.write().unwrap();
1857 table
1858 .generation_started_at
1859 .insert(generation_number, Instant::now());
1860
1861 let show_submodules = self.show_submodules.load(Ordering::Acquire);
1862 let mut dispatched = Vec::new();
1863 for key in order {
1864 let Some(&idx) = table.index.get(key) else {
1865 continue;
1866 };
1867 if !dispatches_kind(table.entities[idx].kind, show_submodules) {
1868 continue;
1872 }
1873 if let Some(previous) = table.in_flight.remove(key) {
1874 previous.cancel.store(true, Ordering::Release);
1875 }
1876 let cancel = Arc::new(AtomicBool::new(false));
1877 table.in_flight.insert(
1878 key.clone(),
1879 InFlight {
1880 generation: generation_number,
1881 cancel: Arc::clone(&cancel),
1882 },
1883 );
1884 begin_probes(&mut table.entities[idx]);
1885 dispatched.push((key.clone(), cancel));
1886 }
1887
1888 if dispatched.is_empty() {
1889 return;
1890 }
1891
1892 begin_probes_owed(&self.settle_gate, dispatched.len());
1893 let repos: Vec<Option<Arc<gix::ThreadSafeRepository>>> = dispatched
1894 .iter()
1895 .map(|(key, _)| table.repos.get(key).cloned())
1896 .collect();
1897 let override_branches: Vec<Option<String>> = dispatched
1898 .iter()
1899 .map(|(key, _)| {
1900 let idx = table.index[key];
1901 let common_dir = &table.entities[idx].common_dir;
1902 find_entry(&self.overrides, key.path(), common_dir)
1903 .and_then(|entry| entry.default_branch.clone())
1904 })
1905 .collect();
1906 let network_branches: Vec<Option<Arc<str>>> = dispatched
1907 .iter()
1908 .map(|(key, _)| {
1909 let idx = table.index[key];
1910 let common_dir = &table.entities[idx].common_dir;
1911 network_branch_for(&self.network_default_branch, common_dir)
1912 })
1913 .collect();
1914 let common_dirs: Vec<Arc<Path>> = dispatched
1915 .iter()
1916 .map(|(key, _)| Arc::clone(&table.entities[table.index[key]].common_dir))
1917 .collect();
1918 let probes_state: Vec<bool> = dispatched
1919 .iter()
1920 .map(|(key, _)| table.entities[table.index[key]].probes_state())
1921 .collect();
1922 let probes_base: Vec<bool> = dispatched
1923 .iter()
1924 .map(|(key, _)| table.entities[table.index[key]].probes_base())
1925 .collect();
1926 let kinds: Vec<Kind> = dispatched
1927 .iter()
1928 .map(|(key, _)| table.entities[table.index[key]].kind)
1929 .collect();
1930 drop(table);
1931
1932 let chain_cache: Arc<ChainFactsCache> = Arc::new(Mutex::new(HashMap::new()));
1936 let patch_cache: Arc<PatchIdentityCache> = Arc::new(Mutex::new(HashMap::new()));
1940 let bound_gates: Arc<HashMap<Arc<Path>, BoundGate>> = Arc::new({
1945 let mut counts: HashMap<Arc<Path>, usize> = HashMap::new();
1946 for (common_dir, probes_state) in common_dirs.iter().zip(&probes_state) {
1947 if *probes_state {
1948 *counts.entry(Arc::clone(common_dir)).or_insert(0) += 1;
1949 }
1950 }
1951 counts
1952 .into_iter()
1953 .map(|(dir, count)| (dir, BoundGate::new(count)))
1954 .collect()
1955 });
1956
1957 for (
1958 (
1959 (
1960 (((((key, cancel), repo), override_branch), network_branch), common_dir),
1961 probes_state,
1962 ),
1963 probes_base,
1964 ),
1965 kind,
1966 ) in dispatched
1967 .into_iter()
1968 .zip(repos)
1969 .zip(override_branches)
1970 .zip(network_branches)
1971 .zip(common_dirs)
1972 .zip(probes_state)
1973 .zip(probes_base)
1974 .zip(kinds)
1975 {
1976 self.dispatch_log.lock().unwrap().push(key.clone());
1981 let path = key.path().to_path_buf();
1982 let table_handle = Arc::clone(&self.table);
1983 let settle_gate = Arc::clone(&self.settle_gate);
1984 let chain_cache = Arc::clone(&chain_cache);
1985 let chain_reads = Arc::clone(&self.default_branch_chain_reads);
1986 let patch_cache = Arc::clone(&patch_cache);
1987 let patch_reads = Arc::clone(&self.patch_identity_reads);
1988 let patch_scan_bounds = Arc::clone(&self.patch_scan_bounds);
1989 let bound_gates = Arc::clone(&bound_gates);
1990 let held_gate = self.phase_c_gates.lock().unwrap().get(&key).cloned();
1995 rayon::spawn(move || {
2003 let branch_outcome = probe_branch(&path, repo.as_deref(), kind, &cancel);
2004 let sync_outcome = probe_sync(
2005 &path,
2006 repo.as_deref(),
2007 branch_outcome.as_ref().map(|(settled, ..)| settled),
2008 kind,
2009 &cancel,
2010 );
2011 let default_branch_outcome = probe_default_branch_memoised(
2012 &path,
2013 repo.as_deref(),
2014 &common_dir,
2015 DefaultBranchHints {
2016 override_branch: override_branch.as_deref(),
2017 network_branch: network_branch.as_deref(),
2018 },
2019 kind,
2020 &cancel,
2021 &ChainFactsMemo {
2022 cache: &chain_cache,
2023 reads: &chain_reads,
2024 },
2025 );
2026 let base_outcome = if probes_base {
2027 probe_base(
2028 &path,
2029 repo.as_deref(),
2030 branch_outcome.as_ref().map(|(settled, ..)| settled),
2031 default_branch_outcome.as_ref().map(|r| &r.settled),
2032 &cancel,
2033 )
2034 } else {
2035 None
2036 };
2037
2038 apply_cheap_probe_outcomes(
2044 &table_handle,
2045 &key,
2046 generation,
2047 CheapProbeOutcomes {
2048 branch: branch_outcome,
2049 sync: sync_outcome,
2050 base: base_outcome,
2051 default_branch: default_branch_outcome.clone(),
2052 },
2053 );
2054
2055 if let Some(gate) = &held_gate {
2060 let (lock, cvar) = &**gate;
2061 let mut state = lock.lock().unwrap();
2062 state.cheap_landed = true;
2063 cvar.notify_all();
2064 state = cvar.wait_while(state, |state| !state.may_proceed).unwrap();
2065 drop(state);
2066 }
2067
2068 let state_outcome = if probes_state {
2069 let gate = bound_gates
2070 .get(&common_dir)
2071 .expect("every probes_state entity's common dir has a gate sized for it");
2072 let mut report = GateReport::new(gate);
2073 let memo = PatchEquivalenceMemo {
2074 cache: &patch_cache,
2075 reads: &patch_reads,
2076 scan_bounds: &patch_scan_bounds,
2077 };
2078 probe_worktree_state(
2079 &path,
2080 repo.as_deref(),
2081 default_branch_outcome.as_ref().map(|r| &r.settled),
2082 &common_dir,
2083 &cancel,
2084 &memo,
2085 &mut report,
2086 )
2087 } else {
2088 None
2089 };
2090 let dirty_outcome = probe_status(&path, repo.as_deref(), kind, &cancel);
2091 apply_probe_outcome(
2092 &table_handle,
2093 &settle_gate,
2094 &key,
2095 generation,
2096 ProbeOutcomes {
2097 state: state_outcome,
2098 dirty: dirty_outcome,
2099 },
2100 );
2101
2102 if let Some(gate) = &held_gate {
2105 let (lock, cvar) = &**gate;
2106 let mut state = lock.lock().unwrap();
2107 state.finished = true;
2108 cvar.notify_all();
2109 }
2110 });
2111 }
2113 }
2114
2115 fn rerun_discovery(&self) {
2124 let repos_cache: HashMap<EntityKey, Arc<gix::ThreadSafeRepository>> =
2125 self.table.read().unwrap().repos.clone();
2126
2127 wait_for_discovery_gate(self.discovery_gate.as_ref());
2128 let (watch, _watcher) = spawn_discovery_watcher(
2131 self.set.roots.clone(),
2132 &self.discovery_warning,
2133 self.discovery_warn_after,
2134 );
2135 let discovery = run_watched_discovery(
2136 &watch,
2137 &self.set,
2138 &self.discovery_warning,
2139 Duration::from_nanos(self.discovery_abandon_after.load(Ordering::Acquire)),
2140 );
2141 if discovery.abandoned {
2142 self.discovery_manual.store(true, Ordering::Release);
2143 }
2144
2145 let (discovered, gitmodules_failures) =
2146 discovery::resolve_with_cache(&self.set, &discovery.entities, &repos_cache);
2147
2148 let exclusions = self.exclusions.read().unwrap().clone();
2152 let mut table = self.table.write().unwrap();
2153 table.discovered_at = Timestamp::now();
2154 let cancelled = merge_discovery(&mut table, &exclusions, discovered, gitmodules_failures);
2155 drop(table);
2156 if cancelled > 0 {
2157 complete_many(&self.settle_gate, cancelled);
2158 }
2159 }
2160}
2161
2162impl Drop for Core {
2163 fn drop(&mut self) {
2174 cancel_in_flight(&self.table, &self.settle_gate);
2175 let _ = self.control.send(ClockControl::Shutdown);
2176 if let Some(handle) = self.clock_thread.take() {
2177 let _ = handle.join();
2178 }
2179 }
2180}
2181
2182pub(crate) struct StartForTest {
2187 pub core: Core,
2188 #[allow(dead_code)] pub clock_alive: Arc<AtomicBool>,
2190 #[allow(dead_code)] pub discovery_watcher: JoinHandle<()>,
2192 #[allow(dead_code)] pub initial_discovery: Option<JoinHandle<()>>,
2197}
2198
2199#[cfg(test)]
2200impl StartForTest {
2201 fn discovered(mut self) -> Self {
2205 if let Some(handle) = self.initial_discovery.take() {
2206 handle
2207 .join()
2208 .expect("the first discovery thread should not panic");
2209 }
2210 self
2211 }
2212}
2213
2214impl Core {
2215 #[cfg(any(test, feature = "test-util"))]
2226 pub fn begin_untracked_probe_for_test(&self, key: &EntityKey) -> Arc<AtomicBool> {
2227 let mut table = self.table.write().unwrap();
2228 table.generation += 1;
2229 let generation_number = table.generation;
2230 table
2231 .generation_started_at
2232 .insert(generation_number, Instant::now());
2233 if let Some(&idx) = table.index.get(key) {
2234 begin_probes(&mut table.entities[idx]);
2235 }
2236 let cancel = Arc::new(AtomicBool::new(false));
2237 table.in_flight.insert(
2238 key.clone(),
2239 InFlight {
2240 generation: generation_number,
2241 cancel: Arc::clone(&cancel),
2242 },
2243 );
2244 begin_probes_owed(&self.settle_gate, 1);
2245 cancel
2246 }
2247}
2248
2249#[cfg(test)]
2252pub(crate) struct SharedGeneration {
2253 pub generation: Generation,
2256 pub cancels: HashMap<EntityKey, Arc<AtomicBool>>,
2258}
2259
2260#[cfg(test)]
2261impl Core {
2262 pub(crate) fn cached_repo_handle_for_test(
2267 &self,
2268 key: &EntityKey,
2269 ) -> Option<Arc<gix::ThreadSafeRepository>> {
2270 self.table.read().unwrap().repos.get(key).cloned()
2271 }
2272
2273 pub(crate) fn default_branch_chain_reads_for_test(&self) -> usize {
2280 self.default_branch_chain_reads.load(Ordering::Acquire)
2281 }
2282
2283 pub(crate) fn patch_identity_reads_for_test(&self) -> usize {
2289 self.patch_identity_reads.load(Ordering::Acquire)
2290 }
2291
2292 pub(crate) fn patch_scan_bounds_for_test(&self) -> Vec<Option<gix::ObjectId>> {
2299 self.patch_scan_bounds.lock().unwrap().clone()
2300 }
2301
2302 pub(crate) fn dispatch_log_for_test(&self) -> Vec<EntityKey> {
2306 self.dispatch_log.lock().unwrap().clone()
2307 }
2308
2309 pub(crate) fn poll_once_for_test(&self) {
2314 run_poll_sweep(
2315 &self.table,
2316 &self.overrides,
2317 &self.show_submodules,
2318 &self.poll_reprobed,
2319 &self.poll_sweep_count,
2320 &self.network_default_branch,
2321 );
2322 }
2323
2324 pub(crate) fn poll_reprobed_for_test(&self) -> Vec<EntityKey> {
2329 self.poll_reprobed.lock().unwrap().clone()
2330 }
2331
2332 pub(crate) fn poll_sweep_count_for_test(&self) -> usize {
2336 self.poll_sweep_count.load(Ordering::Acquire)
2337 }
2338
2339 pub(crate) fn hold_phase_c_for_test(&self, key: &EntityKey) {
2345 self.phase_c_gates.lock().unwrap().insert(
2346 key.clone(),
2347 Arc::new((Mutex::new(PhaseCGate::default()), Condvar::new())),
2348 );
2349 }
2350
2351 pub(crate) fn wait_phase_c_landed_for_test(&self, key: &EntityKey) {
2355 let gate = self
2356 .phase_c_gates
2357 .lock()
2358 .unwrap()
2359 .get(key)
2360 .cloned()
2361 .expect("hold_phase_c_for_test must be called before waiting on its gate");
2362 let (lock, cvar) = &*gate;
2363 let guard = lock.lock().unwrap();
2364 drop(cvar.wait_while(guard, |state| !state.cheap_landed).unwrap());
2365 }
2366
2367 pub(crate) fn release_phase_c_for_test(&self, key: &EntityKey) {
2370 let gate = self
2371 .phase_c_gates
2372 .lock()
2373 .unwrap()
2374 .get(key)
2375 .cloned()
2376 .expect("hold_phase_c_for_test must be called before releasing its gate");
2377 let (lock, cvar) = &*gate;
2378 let mut state = lock.lock().unwrap();
2379 state.may_proceed = true;
2380 cvar.notify_all();
2381 }
2382
2383 pub(crate) fn wait_phase_c_finished_for_test(&self, key: &EntityKey) {
2386 let gate = self
2387 .phase_c_gates
2388 .lock()
2389 .unwrap()
2390 .get(key)
2391 .cloned()
2392 .expect("hold_phase_c_for_test must be called before waiting on its gate");
2393 let (lock, cvar) = &*gate;
2394 let guard = lock.lock().unwrap();
2395 drop(cvar.wait_while(guard, |state| !state.finished).unwrap());
2396 }
2397
2398 pub(crate) fn wait_dispatched_for_test(&self) {
2403 let (lock, cvar) = &*self.settle_gate;
2404 let guard = lock.lock().unwrap();
2405 drop(
2406 cvar.wait_while(guard, |counts| counts.dispatches > 0)
2407 .unwrap(),
2408 );
2409 }
2410
2411 pub(crate) fn settle_gate_count_for_test(&self) -> usize {
2415 self.settle_gate.0.lock().unwrap().probes
2416 }
2417
2418 pub(crate) fn start_for_test(
2422 spec: CoreSpec,
2423 warn_after: Duration,
2424 ticks: Receiver<Instant>,
2425 ) -> StartForTest {
2426 Self::start_for_test_with_discovery_abandon(
2427 spec,
2428 warn_after,
2429 discovery::ABANDON_AFTER,
2430 ticks,
2431 )
2432 }
2433
2434 pub(crate) fn start_for_test_with_discovery_abandon(
2441 spec: CoreSpec,
2442 warn_after: Duration,
2443 discovery_abandon_after: Duration,
2444 ticks: Receiver<Instant>,
2445 ) -> StartForTest {
2446 Self::start_for_test_gated(spec, warn_after, discovery_abandon_after, ticks, None)
2447 }
2448
2449 pub(crate) fn start_for_test_gated(
2453 spec: CoreSpec,
2454 warn_after: Duration,
2455 discovery_abandon_after: Duration,
2456 ticks: Receiver<Instant>,
2457 discovery_gate: Option<DiscoveryGate>,
2458 ) -> StartForTest {
2459 let alive = Arc::new(AtomicBool::new(true));
2460 start_internal(
2461 spec,
2462 warn_after,
2463 discovery_abandon_after,
2464 ticks,
2465 FetchStart {
2466 enabled: false,
2467 concurrency: 1,
2468 ticks: crossbeam_channel::never(),
2469 },
2470 alive,
2471 discovery_gate,
2472 )
2473 }
2474
2475 pub(crate) fn start_for_test_with_fetch(
2481 spec: CoreSpec,
2482 warn_after: Duration,
2483 ticks: Receiver<Instant>,
2484 fetch_ticks: Receiver<Instant>,
2485 ) -> StartForTest {
2486 let alive = Arc::new(AtomicBool::new(true));
2487 let fetch_start = FetchStart {
2488 enabled: spec.fetch.enabled,
2489 concurrency: spec.fetch.concurrency.max(1),
2490 ticks: fetch_ticks,
2491 };
2492 start_internal(
2493 spec,
2494 warn_after,
2495 discovery::ABANDON_AFTER,
2496 ticks,
2497 fetch_start,
2498 alive,
2499 None,
2500 )
2501 }
2502
2503 pub(crate) fn fetch_cycle_count_for_test(&self) -> usize {
2507 self.fetch_cycle_count.load(Ordering::Acquire)
2508 }
2509
2510 #[cfg(test)]
2517 pub(crate) fn set_discovery_abandon_after_for_test(&self, after: Duration) {
2518 self.discovery_abandon_after
2519 .store(after.as_nanos() as u64, Ordering::Release);
2520 }
2521
2522 pub(crate) fn discovery_manual_for_test(&self) -> bool {
2523 self.discovery_manual.load(Ordering::Acquire)
2524 }
2525
2526 pub(crate) fn begin_shared_generation_for_test(&self, keys: &[EntityKey]) -> SharedGeneration {
2536 let mut table = self.table.write().unwrap();
2537 table.generation += 1;
2538 let generation_number = table.generation;
2539 table
2540 .generation_started_at
2541 .insert(generation_number, Instant::now());
2542 let mut cancels = HashMap::new();
2543 for key in keys {
2544 if let Some(&idx) = table.index.get(key) {
2545 table.entities[idx].branch.begin_probe();
2546 }
2547 let cancel = Arc::new(AtomicBool::new(false));
2548 table.in_flight.insert(
2549 key.clone(),
2550 InFlight {
2551 generation: generation_number,
2552 cancel: Arc::clone(&cancel),
2553 },
2554 );
2555 cancels.insert(key.clone(), cancel);
2556 }
2557 SharedGeneration {
2558 generation: Generation::new(generation_number),
2559 cancels,
2560 }
2561 }
2562
2563 pub(crate) fn apply_probe_result_for_test(
2569 &self,
2570 key: &EntityKey,
2571 generation: Generation,
2572 settled: Settled<Head>,
2573 ) {
2574 apply_cheap_probe_outcomes(
2575 &self.table,
2576 key,
2577 generation,
2578 CheapProbeOutcomes {
2579 branch: Some((settled, None, Vec::new())),
2580 sync: None,
2581 base: None,
2582 default_branch: None,
2583 },
2584 );
2585 }
2586
2587 pub(crate) fn set_last_action_for_test(
2591 &self,
2592 key: &EntityKey,
2593 receipt: crate::entity::ActionReceipt,
2594 ) {
2595 let mut table = self.table.write().unwrap();
2596 if let Some(&idx) = table.index.get(key) {
2597 table.entities[idx].last_action = Some(receipt);
2598 }
2599 }
2600}
2601
2602fn run_action_for_entity(
2626 entity: &EntityState,
2627 action: &ActionSpec,
2628 control: &Arc<executor::RunControl>,
2629 report: &dyn Fn(ActionReceipt),
2630) -> ActionReceipt {
2631 let base_env = environment::environment(entity, action.name.as_deref());
2632 let mut failed = false;
2633 let mut cancelled = false;
2634 let mut results: Vec<StepResult> = Vec::with_capacity(action.steps.len());
2635 for step in &action.steps {
2636 if failed || cancelled || control.is_cancelled() {
2637 cancelled = cancelled || control.is_cancelled();
2638 results.push(StepResult {
2639 label: Arc::from(step.argv.join(" ")),
2640 outcome: if cancelled {
2641 StepOutcome::Cancelled
2642 } else {
2643 StepOutcome::NotRun
2644 },
2645 output: Arc::from(&b""[..]),
2646 elapsed: Duration::ZERO,
2647 elision: None,
2648 shell: step.shell,
2649 interactive: step.interactive,
2650 });
2651 continue;
2652 }
2653 let label: Arc<str> = Arc::from(step.argv.join(" "));
2654 report(ActionReceipt {
2655 label: Arc::clone(&action.label),
2656 steps: Arc::from(results.clone()),
2657 skip: None,
2658 finished_at: Timestamp::now(),
2659 running: Some(RunningStep {
2660 label: Arc::clone(&label),
2661 started_at: Timestamp::now(),
2662 shell: step.shell,
2663 interactive: step.interactive,
2664 }),
2665 });
2666 let mut env = base_env.clone();
2671 env.extend(
2672 step.env
2673 .iter()
2674 .map(|(name, value)| (name.clone(), Some(value.clone()))),
2675 );
2676 let mut result = executor::run_step(
2677 &step.argv,
2678 step.shell,
2679 step.interactive,
2680 entity.key.path(),
2681 &env,
2682 control,
2683 );
2684 if control.is_cancelled() {
2685 result.outcome = StepOutcome::Cancelled;
2686 cancelled = true;
2687 } else {
2688 failed = result.outcome.is_failure();
2689 }
2690 results.push(result);
2691 }
2692 ActionReceipt {
2693 label: Arc::clone(&action.label),
2694 steps: Arc::from(results),
2695 skip: None,
2696 finished_at: Timestamp::now(),
2697 running: None,
2698 }
2699}
2700
2701type DiscoveryGate = Arc<(Mutex<bool>, Condvar)>;
2706
2707fn wait_for_discovery_gate(gate: Option<&DiscoveryGate>) {
2710 let Some(gate) = gate else {
2711 return;
2712 };
2713 let (lock, cvar) = &**gate;
2714 let open = lock.lock().unwrap();
2715 drop(cvar.wait_while(open, |open| !*open).unwrap());
2716}
2717
2718#[cfg(test)]
2720fn set_discovery_gate(gate: &DiscoveryGate, open: bool) {
2721 let (lock, cvar) = &**gate;
2722 *lock.lock().unwrap() = open;
2723 cvar.notify_all();
2724}
2725
2726struct DiscoveryWatch {
2729 progress: Arc<AtomicUsize>,
2730 finished: Arc<AtomicBool>,
2731}
2732
2733fn spawn_discovery_watcher(
2739 roots: Vec<PathBuf>,
2740 discovery_warning: &Arc<Mutex<Option<String>>>,
2741 warn_after: Duration,
2742) -> (DiscoveryWatch, JoinHandle<()>) {
2743 let progress = Arc::new(AtomicUsize::new(0));
2744 let finished = Arc::new(AtomicBool::new(false));
2745 let watcher = thread::spawn({
2746 let progress = Arc::clone(&progress);
2747 let finished = Arc::clone(&finished);
2748 let warning_slot = Arc::clone(discovery_warning);
2749 move || {
2750 if let Some(message) = watch_for_slow_discovery(progress, finished, roots, warn_after) {
2751 *warning_slot.lock().unwrap() = Some(message);
2752 }
2753 }
2754 });
2755 (DiscoveryWatch { progress, finished }, watcher)
2756}
2757
2758fn run_watched_discovery(
2764 watch: &DiscoveryWatch,
2765 set: &SetSpec,
2766 discovery_warning: &Arc<Mutex<Option<String>>>,
2767 abandon_after: Duration,
2768) -> discovery::Discovery {
2769 let discovery =
2770 discovery::discover_watched_with_deadline(set, Arc::clone(&watch.progress), abandon_after);
2771 watch.finished.store(true, Ordering::Release);
2772
2773 if discovery.abandoned {
2774 *discovery_warning.lock().unwrap() =
2775 Some(abandoned_discovery_message(discovery.directories_visited));
2776 }
2777
2778 discovery
2779}
2780
2781fn start_internal(
2784 spec: CoreSpec,
2785 warn_after: Duration,
2786 discovery_abandon_after: Duration,
2787 ticks: Receiver<Instant>,
2788 fetch_start: FetchStart,
2789 alive: Arc<AtomicBool>,
2790 discovery_gate: Option<DiscoveryGate>,
2791) -> StartForTest {
2792 let FetchStart {
2793 enabled: fetch_enabled,
2794 concurrency: fetch_concurrency,
2795 ticks: fetch_ticks,
2796 } = fetch_start;
2797 let discovery_warning = Arc::new(Mutex::new(None));
2798 let discovery_manual = Arc::new(AtomicBool::new(false));
2799
2800 let (overrides, resolved_exclusions) = resolve_entries(&spec.overrides);
2801 let overrides = Arc::new(overrides);
2802 let exclusions = Arc::new(RwLock::new(resolved_exclusions));
2803 let show_submodules = Arc::new(AtomicBool::new(spec.show_submodules));
2804
2805 let table = Arc::new(RwLock::new(Table {
2806 generation: 0,
2807 discovered_at: Timestamp::now(),
2808 entities: Vec::new(),
2809 index: HashMap::new(),
2810 in_flight: HashMap::new(),
2811 generation_started_at: HashMap::new(),
2812 repos: HashMap::new(),
2813 poll_fingerprints: HashMap::new(),
2814 }));
2815
2816 let settle_gate: Arc<SettleGate> =
2817 Arc::new((Mutex::new(SettleCounts::default()), Condvar::new()));
2818 let poll_reprobed = Arc::new(Mutex::new(Vec::new()));
2819 let poll_sweep_count = Arc::new(AtomicUsize::new(0));
2820 let network_default_branch = Arc::new(Mutex::new(HashMap::new()));
2821 let (control, control_rx) = crossbeam_channel::unbounded();
2822 let poll_handles = PollHandles {
2823 overrides: Arc::clone(&overrides),
2824 show_submodules: Arc::clone(&show_submodules),
2825 poll_reprobed: Arc::clone(&poll_reprobed),
2826 poll_sweep_count: Arc::clone(&poll_sweep_count),
2827 network_default_branch: Arc::clone(&network_default_branch),
2828 };
2829
2830 let discovery_abandon_after_atomic =
2834 Arc::new(AtomicU64::new(discovery_abandon_after.as_nanos() as u64));
2835 let default_branch_chain_reads = Arc::new(AtomicUsize::new(0));
2836 let patch_identity_reads = Arc::new(AtomicUsize::new(0));
2837 let patch_scan_bounds = Arc::new(Mutex::new(Vec::new()));
2838 let dispatch_log = Arc::new(Mutex::new(Vec::new()));
2839 let phase_c_gates = Arc::new(Mutex::new(HashMap::new()));
2840 let fetch_cycle_count = Arc::new(AtomicUsize::new(0));
2841 let fetch_failures = Arc::new(Mutex::new(FetchFailures::default()));
2842 let turnstile = Arc::new(DispatchTurnstile::default());
2843
2844 let fetch_refresh_handles = RefreshHandles {
2845 table: Arc::clone(&table),
2846 overrides: Arc::clone(&overrides),
2847 exclusions: Arc::clone(&exclusions),
2848 set: spec.set.clone(),
2849 discovery_manual: Arc::clone(&discovery_manual),
2850 discovery_warn_after: warn_after,
2851 discovery_abandon_after: Arc::clone(&discovery_abandon_after_atomic),
2852 discovery_warning: Arc::clone(&discovery_warning),
2853 show_submodules: Arc::clone(&show_submodules),
2854 settle_gate: Arc::clone(&settle_gate),
2855 default_branch_chain_reads: Arc::clone(&default_branch_chain_reads),
2856 patch_identity_reads: Arc::clone(&patch_identity_reads),
2857 patch_scan_bounds: Arc::clone(&patch_scan_bounds),
2858 dispatch_log: Arc::clone(&dispatch_log),
2859 phase_c_gates: Arc::clone(&phase_c_gates),
2860 network_default_branch: Arc::clone(&network_default_branch),
2861 turnstile: Arc::clone(&turnstile),
2862 discovery_gate: discovery_gate.clone(),
2863 };
2864 let auto_update_enabled = spec.auto_update.enabled;
2865 let fetch_schedule = FetchSchedule {
2866 concurrency: fetch_concurrency,
2867 ticks: fetch_ticks,
2868 refresh: fetch_refresh_handles.clone(),
2869 cycle_count: Arc::clone(&fetch_cycle_count),
2870 failures: Arc::clone(&fetch_failures),
2871 auto_update_enabled,
2872 };
2873
2874 let clock_thread = spawn_clock_thread(
2875 Arc::clone(&table),
2876 poll_handles,
2877 fetch_schedule,
2878 Arc::clone(&settle_gate),
2879 spec.generation_deadline,
2880 ClockChannels {
2881 control: control_rx,
2882 ticks,
2883 alive: Arc::clone(&alive),
2884 },
2885 );
2886
2887 let (startup_generation, startup_ticket) = fetch_refresh_handles.reserve_generation();
2897 begin_dispatch(&settle_gate);
2898 let (watch, discovery_watcher) =
2899 spawn_discovery_watcher(spec.set.roots.clone(), &discovery_warning, warn_after);
2900 let initial_discovery = thread::spawn({
2901 let set = spec.set.clone();
2902 let discovery_warning = Arc::clone(&discovery_warning);
2903 let discovery_manual = Arc::clone(&discovery_manual);
2904 let exclusions = Arc::clone(&exclusions);
2905 let table = Arc::clone(&table);
2906 let settle_gate = Arc::clone(&settle_gate);
2907 let fetch_refresh_handles = fetch_refresh_handles.clone();
2908 let fetch_cycle_count = Arc::clone(&fetch_cycle_count);
2909 let fetch_failures = Arc::clone(&fetch_failures);
2910 let discovery_gate = discovery_gate.clone();
2911 move || {
2912 let turn = fetch_refresh_handles.turnstile.take(startup_ticket);
2913 wait_for_discovery_gate(discovery_gate.as_ref());
2914 let discovery =
2915 run_watched_discovery(&watch, &set, &discovery_warning, discovery_abandon_after);
2916 if discovery.abandoned {
2917 discovery_manual.store(true, Ordering::Release);
2918 }
2919
2920 let (discovered, gitmodules_failures) = discovery::resolve(&set, &discovery.entities);
2925 let resolved_exclusions = exclusions.read().unwrap().clone();
2926 let order: Vec<EntityKey> = {
2927 let mut table = table.write().unwrap();
2928 merge_discovery(
2932 &mut table,
2933 &resolved_exclusions,
2934 discovered,
2935 gitmodules_failures,
2936 );
2937 table.discovered_at = Timestamp::now();
2938 table
2939 .entities
2940 .iter()
2941 .map(|entity| entity.key.clone())
2942 .collect()
2943 };
2944 fetch_refresh_handles.dispatch_probes(&order, startup_generation);
2948 finish_dispatch(&settle_gate);
2949 drop(turn);
2952
2953 if fetch_enabled {
2963 let table = Arc::clone(&table);
2964 thread::spawn(move || {
2965 run_fetch_cycle(
2966 &table,
2967 fetch_concurrency,
2968 &fetch_refresh_handles,
2969 &fetch_cycle_count,
2970 &fetch_failures,
2971 auto_update_enabled,
2972 );
2973 });
2974 }
2975 }
2976 });
2977
2978 StartForTest {
2979 core: Core {
2980 table,
2981 overrides,
2982 exclusions,
2983 set: spec.set,
2984 discovery_manual,
2985 discovery_warn_after: warn_after,
2986 discovery_abandon_after: discovery_abandon_after_atomic,
2987 show_submodules,
2988 settle_gate,
2989 control,
2990 clock_thread: Some(clock_thread),
2991 discovery_warning,
2992 default_branch_chain_reads,
2993 patch_identity_reads,
2994 patch_scan_bounds,
2995 action_running: Arc::new(AtomicBool::new(false)),
2996 action_control: Arc::new(Mutex::new(None)),
2997 dispatch_log,
2998 phase_c_gates,
2999 status_stale_after: spec.status_stale_after,
3000 poll_reprobed,
3001 poll_sweep_count,
3002 fetch_cycle_count,
3003 network_default_branch,
3004 fetch_failures,
3005 turnstile,
3006 discovery_gate,
3007 },
3008 clock_alive: alive,
3009 discovery_watcher,
3010 initial_discovery: Some(initial_discovery),
3011 }
3012}
3013
3014struct PollHandles {
3018 overrides: Arc<Vec<ResolvedOverride>>,
3019 show_submodules: Arc<AtomicBool>,
3020 poll_reprobed: Arc<Mutex<Vec<EntityKey>>>,
3021 poll_sweep_count: Arc<AtomicUsize>,
3022 network_default_branch: Arc<Mutex<HashMap<PathBuf, Arc<str>>>>,
3026}
3027
3028struct FetchStart {
3032 enabled: bool,
3033 concurrency: usize,
3034 ticks: Receiver<Instant>,
3035}
3036
3037struct FetchSchedule {
3044 concurrency: usize,
3045 ticks: Receiver<Instant>,
3046 refresh: RefreshHandles,
3047 cycle_count: Arc<AtomicUsize>,
3048 failures: Arc<Mutex<FetchFailures>>,
3049 auto_update_enabled: bool,
3053}
3054
3055struct ClockChannels {
3062 control: Receiver<ClockControl>,
3063 ticks: Receiver<Instant>,
3064 alive: Arc<AtomicBool>,
3065}
3066
3067fn spawn_clock_thread(
3077 table: Arc<RwLock<Table>>,
3078 poll: PollHandles,
3079 fetch: FetchSchedule,
3080 settle_gate: Arc<SettleGate>,
3081 generation_deadline: Duration,
3082 channels: ClockChannels,
3083) -> JoinHandle<()> {
3084 let ClockChannels {
3085 control,
3086 ticks,
3087 alive,
3088 } = channels;
3089 thread::spawn(move || {
3090 let mut paused = false;
3091 loop {
3092 select! {
3093 recv(control) -> message => match message {
3094 Ok(ClockControl::Pause) => {
3095 paused = true;
3096 cancel_in_flight(&table, &settle_gate);
3097 }
3098 Ok(ClockControl::Resume) => paused = false,
3099 Ok(ClockControl::Shutdown) | Err(_) => break,
3100 },
3101 recv(ticks) -> tick => {
3102 if tick.is_err() {
3103 break;
3104 }
3105 if !paused {
3106 run_poll_sweep(
3107 &table,
3108 &poll.overrides,
3109 &poll.show_submodules,
3110 &poll.poll_reprobed,
3111 &poll.poll_sweep_count,
3112 &poll.network_default_branch,
3113 );
3114 sweep_deadline(&table, &settle_gate, generation_deadline);
3115 }
3116 }
3117 recv(fetch.ticks) -> tick => {
3118 if tick.is_err() {
3119 break;
3120 }
3121 if !paused {
3122 run_fetch_cycle(
3123 &table,
3124 fetch.concurrency,
3125 &fetch.refresh,
3126 &fetch.cycle_count,
3127 &fetch.failures,
3128 fetch.auto_update_enabled,
3129 );
3130 }
3131 }
3132 }
3133 }
3134 alive.store(false, Ordering::Release);
3135 })
3136}
3137
3138fn run_fetch_cycle(
3159 table: &Arc<RwLock<Table>>,
3160 concurrency: usize,
3161 refresh: &RefreshHandles,
3162 cycle_count: &Arc<AtomicUsize>,
3163 failures: &Arc<Mutex<FetchFailures>>,
3164 auto_update_enabled: bool,
3165) {
3166 cycle_count.fetch_add(1, Ordering::Release);
3167
3168 let common_dirs = distinct_fetchable_common_dirs(table);
3169 let failed: Mutex<Vec<(PathBuf, String)>> = Mutex::new(Vec::new());
3170 crate::fetch::run_bounded(common_dirs, concurrency.max(1), |common_dir| {
3171 let cancel = AtomicBool::new(false);
3172 match crate::fetch::fetch_and_prune(&common_dir, &cancel) {
3178 Ok(outcome) => {
3179 if let Some(crate::fetch::AdvertisedDefaultBranch::Branch(name)) =
3188 outcome.advertised_default_branch
3189 {
3190 refresh
3191 .network_default_branch
3192 .lock()
3193 .unwrap()
3194 .insert(common_dir.clone(), Arc::from(name));
3195 }
3196 }
3197 Err(error) => {
3198 failed
3199 .lock()
3200 .unwrap()
3201 .push((common_dir.clone(), error.to_string()));
3202 }
3203 }
3204 });
3205 *failures.lock().unwrap() = FetchFailures {
3206 failed: failed.into_inner().unwrap(),
3207 };
3208
3209 if auto_update_enabled {
3218 for repo_path in repos_eligible_for_auto_update_attempt(table) {
3219 let _ = crate::auto_update::attempt(&repo_path);
3222 }
3223 }
3224
3225 let all_keys: Vec<EntityKey> = table
3226 .read()
3227 .unwrap()
3228 .entities
3229 .iter()
3230 .map(|entity| entity.key.clone())
3231 .collect();
3232 refresh.dispatch(&all_keys);
3233}
3234
3235fn repos_eligible_for_auto_update_attempt(table: &Arc<RwLock<Table>>) -> Vec<PathBuf> {
3244 table
3245 .read()
3246 .unwrap()
3247 .entities
3248 .iter()
3249 .filter(|entity| entity.kind == Kind::Repo && !entity.excluded)
3250 .map(|entity| entity.key.path().to_path_buf())
3251 .collect()
3252}
3253
3254fn distinct_fetchable_common_dirs(table: &Arc<RwLock<Table>>) -> Vec<PathBuf> {
3261 let table = table.read().unwrap();
3262 let mut seen: HashMap<PathBuf, bool> = HashMap::new();
3263 for entity in &table.entities {
3264 let common_dir = entity.common_dir.to_path_buf();
3265 let operable = seen.entry(common_dir).or_insert(false);
3266 *operable = *operable || !entity.excluded;
3267 }
3268 seen.into_iter()
3269 .filter(|(_, operable)| *operable)
3270 .map(|(common_dir, _)| common_dir)
3271 .collect()
3272}
3273
3274fn probe_network_default_branches(
3280 common_dirs: &HashSet<Arc<Path>>,
3281 network_default_branch: &Mutex<HashMap<PathBuf, Arc<str>>>,
3282) {
3283 for common_dir in common_dirs {
3284 if let Ok(Some(crate::fetch::AdvertisedDefaultBranch::Branch(name))) =
3285 crate::fetch::probe_remote_head(common_dir)
3286 {
3287 network_default_branch
3288 .lock()
3289 .unwrap()
3290 .insert(common_dir.to_path_buf(), Arc::from(name));
3291 }
3292 }
3293}
3294
3295struct RederiveCandidate {
3300 key: EntityKey,
3301 path: PathBuf,
3302 common_dir: Arc<Path>,
3303 repo: Option<Arc<gix::ThreadSafeRepository>>,
3304 override_branch: Option<String>,
3305 kind: Kind,
3306}
3307
3308struct PollCandidate {
3311 key: EntityKey,
3312 path: PathBuf,
3313 common_dir: Arc<Path>,
3314 kind: Kind,
3315 cached_repo: Option<Arc<gix::ThreadSafeRepository>>,
3316 probes_base: bool,
3317}
3318
3319fn run_poll_sweep(
3338 table: &Arc<RwLock<Table>>,
3339 overrides: &Arc<Vec<ResolvedOverride>>,
3340 show_submodules: &Arc<AtomicBool>,
3341 poll_reprobed: &Arc<Mutex<Vec<EntityKey>>>,
3342 poll_sweep_count: &Arc<AtomicUsize>,
3343 network_default_branch: &Mutex<HashMap<PathBuf, Arc<str>>>,
3344) {
3345 poll_sweep_count.fetch_add(1, Ordering::Release);
3346 poll_reprobed.lock().unwrap().clear();
3347 let show_submodules = show_submodules.load(Ordering::Acquire);
3348
3349 let candidates: Vec<PollCandidate> = {
3350 let table = table.read().unwrap();
3351 table
3352 .entities
3353 .iter()
3354 .filter(|entity| dispatches_kind(entity.kind, show_submodules))
3355 .map(|entity| PollCandidate {
3356 key: entity.key.clone(),
3357 path: entity.key.path().to_path_buf(),
3358 common_dir: Arc::clone(&entity.common_dir),
3359 kind: entity.kind,
3360 cached_repo: table.repos.get(&entity.key).cloned(),
3361 probes_base: entity.probes_base(),
3362 })
3363 .collect()
3364 };
3365
3366 for candidate in candidates {
3367 let opened;
3373 let repo = match candidate.cached_repo.as_deref() {
3374 Some(repo) => Some(repo),
3375 None => match git::open_thread_safe(&candidate.path) {
3376 Ok(repo) => {
3377 opened = repo;
3378 Some(&opened)
3379 }
3380 Err(_) => None,
3381 },
3382 };
3383 let gitdir = repo
3384 .map(|repo| repo.git_dir().to_path_buf())
3385 .unwrap_or_else(|| candidate.common_dir.to_path_buf());
3386
3387 let current = poll::fingerprint(&gitdir);
3388 let moved = {
3389 let mut table = table.write().unwrap();
3390 let previous = table
3391 .poll_fingerprints
3392 .insert(candidate.key.clone(), current);
3393 previous.is_some_and(|previous| poll::moved(&previous, ¤t))
3394 };
3395 if !moved {
3396 continue;
3397 }
3398
3399 {
3400 let mut table = table.write().unwrap();
3401 if let Some(&idx) = table.index.get(&candidate.key) {
3402 table.entities[idx].force_stale_status_cells();
3403 }
3404 }
3405
3406 let override_branch = find_entry(overrides, &candidate.path, &candidate.common_dir)
3407 .and_then(|entry| entry.default_branch.clone());
3408 let never_cancelled = AtomicBool::new(false);
3409 let chain_cache: ChainFactsCache = Mutex::new(HashMap::new());
3410 let chain_reads = AtomicUsize::new(0);
3411
3412 let branch_outcome = probe_branch(&candidate.path, repo, candidate.kind, &never_cancelled);
3413 let sync_outcome = probe_sync(
3414 &candidate.path,
3415 repo,
3416 branch_outcome.as_ref().map(|(settled, ..)| settled),
3417 candidate.kind,
3418 &never_cancelled,
3419 );
3420 let default_branch_outcome = probe_default_branch_memoised(
3421 &candidate.path,
3422 repo,
3423 &candidate.common_dir,
3424 DefaultBranchHints {
3425 override_branch: override_branch.as_deref(),
3426 network_branch: network_branch_for(network_default_branch, &candidate.common_dir)
3427 .as_deref(),
3428 },
3429 candidate.kind,
3430 &never_cancelled,
3431 &ChainFactsMemo {
3432 cache: &chain_cache,
3433 reads: &chain_reads,
3434 },
3435 );
3436 let base_outcome = if candidate.probes_base {
3437 probe_base(
3438 &candidate.path,
3439 repo,
3440 branch_outcome.as_ref().map(|(settled, ..)| settled),
3441 default_branch_outcome.as_ref().map(|r| &r.settled),
3442 &never_cancelled,
3443 )
3444 } else {
3445 None
3446 };
3447
3448 let generation = {
3449 let mut table = table.write().unwrap();
3450 table.generation += 1;
3451 Generation::new(table.generation)
3452 };
3453 apply_cheap_probe_outcomes(
3454 table,
3455 &candidate.key,
3456 generation,
3457 CheapProbeOutcomes {
3458 branch: branch_outcome,
3459 sync: sync_outcome,
3460 base: base_outcome,
3461 default_branch: default_branch_outcome,
3462 },
3463 );
3464 poll_reprobed.lock().unwrap().push(candidate.key);
3465 }
3466}
3467
3468fn cancel_in_flight(table: &Arc<RwLock<Table>>, settle_gate: &Arc<SettleGate>) {
3473 let mut table = table.write().unwrap();
3474 let cancelled = table.in_flight.len();
3475 for in_flight in table.in_flight.values() {
3476 in_flight.cancel.store(true, Ordering::Release);
3477 }
3478 table.in_flight.clear();
3479 table.generation_started_at.clear();
3480 drop(table);
3481 if cancelled > 0 {
3482 complete_many(settle_gate, cancelled);
3483 }
3484}
3485
3486trait TimeoutableCell {
3492 fn is_in_flight(&self) -> bool;
3493 fn time_out(&mut self, generation: Generation);
3496}
3497
3498impl<T> TimeoutableCell for Cell<T> {
3499 fn is_in_flight(&self) -> bool {
3500 Cell::is_in_flight(self)
3501 }
3502
3503 fn time_out(&mut self, generation: Generation) {
3504 self.settle(generation, Settled::Unknown(Unknown::TimedOut));
3505 }
3506}
3507
3508fn sweep_deadline(table: &Arc<RwLock<Table>>, settle_gate: &Arc<SettleGate>, deadline: Duration) {
3513 let mut table = table.write().unwrap();
3514 let now = Instant::now();
3515 let mut timed_out = Vec::new();
3516 for (key, in_flight) in table.in_flight.iter() {
3517 let started = table
3518 .generation_started_at
3519 .get(&in_flight.generation)
3520 .copied()
3521 .unwrap_or(now);
3522 if now.duration_since(started) >= deadline {
3523 timed_out.push((key.clone(), Generation::new(in_flight.generation)));
3524 }
3525 }
3526 for (key, generation) in &timed_out {
3527 if let Some(&idx) = table.index.get(key) {
3528 let EntityState {
3531 key: _,
3532 name: _,
3533 common_dir: _,
3534 kind: _,
3535 branch,
3536 sync,
3537 base,
3538 dirty,
3539 state,
3540 default_branch,
3541 diagnostics: _,
3542 last_action: _,
3543 presence: _,
3544 excluded: _,
3545 in_progress_operation: _,
3546 recent_commits: _,
3547 } = &mut table.entities[idx];
3548 let cells: [&mut dyn TimeoutableCell; 6] =
3549 [branch, sync, base, dirty, state, default_branch];
3550 for cell in cells {
3551 if cell.is_in_flight() {
3556 cell.time_out(*generation);
3557 }
3558 }
3559 }
3560 table.in_flight.remove(key);
3561 }
3562 let live_generations: std::collections::HashSet<u64> =
3563 table.in_flight.values().map(|f| f.generation).collect();
3564 table
3565 .generation_started_at
3566 .retain(|generation, _| live_generations.contains(generation));
3567 drop(table);
3568 if !timed_out.is_empty() {
3569 complete_many(settle_gate, timed_out.len());
3570 }
3571}
3572
3573fn begin_probes(entity: &mut EntityState) {
3580 let probes_state = entity.probes_state();
3581 let EntityState {
3582 key: _,
3583 name: _,
3584 common_dir: _,
3585 kind: _,
3586 branch,
3587 sync: _,
3588 base: _,
3589 dirty,
3590 state,
3591 default_branch,
3592 diagnostics: _,
3593 last_action: _,
3594 presence: _,
3595 excluded: _,
3596 in_progress_operation: _,
3597 recent_commits: _,
3598 } = entity;
3599 branch.begin_probe();
3600 default_branch.begin_probe();
3601 dirty.begin_probe();
3605 if probes_state {
3610 state.begin_probe();
3611 }
3612}
3613
3614type SettleGate = (Mutex<SettleCounts>, Condvar);
3617
3618#[derive(Default)]
3625struct SettleCounts {
3626 probes: usize,
3629 dispatches: usize,
3632}
3633
3634impl SettleCounts {
3635 fn is_settled(&self) -> bool {
3641 let SettleCounts { probes, dispatches } = self;
3642 *probes == 0 && *dispatches == 0
3643 }
3644}
3645
3646fn begin_dispatch(settle_gate: &SettleGate) {
3649 let (lock, _cvar) = settle_gate;
3650 lock.lock().unwrap().dispatches += 1;
3651}
3652
3653fn finish_dispatch(settle_gate: &SettleGate) {
3656 let (lock, cvar) = settle_gate;
3657 let mut counts = lock.lock().unwrap();
3658 counts.dispatches = counts.dispatches.saturating_sub(1);
3659 drop(counts);
3660 cvar.notify_all();
3663}
3664
3665fn begin_probes_owed(settle_gate: &SettleGate, owed: usize) {
3666 let (lock, _cvar) = settle_gate;
3667 lock.lock().unwrap().probes += owed;
3668}
3669
3670fn complete_one(settle_gate: &SettleGate) {
3671 complete_many(settle_gate, 1);
3672}
3673
3674fn complete_many(settle_gate: &SettleGate, finished: usize) {
3675 let (lock, cvar) = settle_gate;
3676 let mut counts = lock.lock().unwrap();
3677 counts.probes = counts.probes.saturating_sub(finished);
3678 if counts.is_settled() {
3679 cvar.notify_all();
3680 }
3681}
3682
3683const RECENT_COMMITS_LIMIT: usize = 5;
3702
3703fn submodule_open_failure<T>(kind: Kind, error: git::ProbeError) -> Settled<T> {
3710 match kind {
3711 Kind::Repo | Kind::Worktree => Settled::Failed(error),
3712 Kind::Submodule => Settled::Unknown(Unknown::SubmoduleUninitialized),
3713 }
3714}
3715
3716fn probe_branch(
3717 path: &Path,
3718 repo: Option<&gix::ThreadSafeRepository>,
3719 kind: Kind,
3720 cancel: &AtomicBool,
3721) -> Option<(
3722 Settled<Head>,
3723 Option<git::InProgressOperation>,
3724 Vec<git::RecentCommit>,
3725)> {
3726 if cancel.load(Ordering::Acquire) {
3727 return None;
3728 }
3729 let opened;
3730 let repo = match repo {
3731 Some(repo) => repo,
3732 None => match git::open_thread_safe(path) {
3733 Ok(repo) => {
3734 opened = repo;
3735 &opened
3736 }
3737 Err(error) => return Some((submodule_open_failure(kind, error), None, Vec::new())),
3738 },
3739 };
3740 let local = repo.to_thread_local();
3741 let settled = match git::head_shape(&local) {
3742 Ok(head) => Settled::Known {
3743 value: head,
3744 at: Timestamp::now(),
3745 stale: false,
3746 },
3747 Err(error) => Settled::Failed(error),
3748 };
3749 let in_progress = git::in_progress_operation(&local);
3750 let recent = git::recent_commits(&local, RECENT_COMMITS_LIMIT);
3751 Some((settled, in_progress, recent))
3752}
3753
3754fn probe_sync(
3765 path: &Path,
3766 repo: Option<&gix::ThreadSafeRepository>,
3767 branch_settled: Option<&Settled<Head>>,
3768 kind: Kind,
3769 cancel: &AtomicBool,
3770) -> Option<Settled<SyncState>> {
3771 if cancel.load(Ordering::Acquire) {
3772 return None;
3773 }
3774 let head = match branch_settled? {
3775 Settled::Known {
3776 value,
3777 at: _,
3778 stale: _,
3779 } => Some(value),
3780 Settled::Failed(error) => return Some(Settled::Failed(error.clone())),
3781 Settled::Unknown(_) | Settled::NotApplicable => None,
3782 };
3783 let opened;
3784 let repo = match repo {
3785 Some(repo) => repo,
3786 None => match git::open_thread_safe(path) {
3787 Ok(repo) => {
3788 opened = repo;
3789 &opened
3790 }
3791 Err(error) => return Some(submodule_open_failure(kind, error)),
3792 },
3793 };
3794 let local = repo.to_thread_local();
3795 let settled = match git::resolve_sync(&local, head) {
3796 Ok(value) => Settled::Known {
3797 value,
3798 at: Timestamp::now(),
3799 stale: false,
3800 },
3801 Err(error) => Settled::Failed(error),
3802 };
3803 Some(settled)
3804}
3805
3806fn probe_base(
3818 path: &Path,
3819 repo: Option<&gix::ThreadSafeRepository>,
3820 branch_settled: Option<&Settled<Head>>,
3821 default_branch_settled: Option<&Settled<DefaultBranch>>,
3822 cancel: &AtomicBool,
3823) -> Option<Settled<u32>> {
3824 if cancel.load(Ordering::Acquire) {
3825 return None;
3826 }
3827 let head = match branch_settled? {
3828 Settled::Known {
3829 value,
3830 at: _,
3831 stale: _,
3832 } => value,
3833 Settled::Failed(error) => return Some(Settled::Failed(error.clone())),
3834 Settled::Unknown(_) | Settled::NotApplicable => return None,
3835 };
3836 let default_branch_settled = default_branch_settled?;
3837 let opened;
3838 let repo = match repo {
3839 Some(repo) => repo,
3840 None => match git::open_thread_safe(path) {
3841 Ok(repo) => {
3842 opened = repo;
3843 &opened
3844 }
3845 Err(error) => return Some(Settled::Failed(error)),
3846 },
3847 };
3848 let local = repo.to_thread_local();
3849 Some(base::probe(&local, head, default_branch_settled))
3850}
3851
3852fn probe_status(
3862 path: &Path,
3863 repo: Option<&gix::ThreadSafeRepository>,
3864 kind: Kind,
3865 cancel: &Arc<AtomicBool>,
3866) -> Option<Settled<DirtyCounts>> {
3867 if cancel.load(Ordering::Acquire) {
3868 return None;
3869 }
3870 let opened;
3871 let repo = match repo {
3872 Some(repo) => repo,
3873 None => match git::open_thread_safe(path) {
3874 Ok(repo) => {
3875 opened = repo;
3876 &opened
3877 }
3878 Err(error) => return Some(submodule_open_failure(kind, error)),
3879 },
3880 };
3881 let local = repo.to_thread_local();
3882 classify_status_result(git::dirty_counts(&local, Arc::clone(cancel)), cancel)
3883}
3884
3885fn classify_status_result(
3901 result: Result<DirtyCounts, git::ProbeError>,
3902 cancel: &AtomicBool,
3903) -> Option<Settled<DirtyCounts>> {
3904 match result {
3905 Ok(_) if cancel.load(Ordering::Acquire) => None,
3906 Ok(value) => Some(Settled::Known {
3907 value,
3908 at: Timestamp::now(),
3909 stale: false,
3910 }),
3911 Err(_) if cancel.load(Ordering::Acquire) => None,
3912 Err(error) => Some(Settled::Failed(error)),
3913 }
3914}
3915
3916struct DefaultBranchHints<'a> {
3922 override_branch: Option<&'a str>,
3925 network_branch: Option<&'a str>,
3929}
3930
3931fn network_branch_for(
3935 network_default_branch: &Mutex<HashMap<PathBuf, Arc<str>>>,
3936 common_dir: &Path,
3937) -> Option<Arc<str>> {
3938 network_default_branch
3939 .lock()
3940 .unwrap()
3941 .get(common_dir)
3942 .cloned()
3943}
3944
3945fn supersede_with_network(
3954 mut resolution: default_branch::Resolution,
3955 network_branch: Option<&str>,
3956) -> default_branch::Resolution {
3957 if let Some(name) = network_branch {
3958 resolution.settled = Settled::Known {
3959 value: DefaultBranch::new(name.into()),
3960 at: Timestamp::now(),
3961 stale: false,
3962 };
3963 }
3964 resolution
3965}
3966
3967fn probe_default_branch(
3975 path: &Path,
3976 repo: Option<&gix::ThreadSafeRepository>,
3977 hints: DefaultBranchHints<'_>,
3978 kind: Kind,
3979 cancel: &AtomicBool,
3980) -> Option<default_branch::Resolution> {
3981 if cancel.load(Ordering::Acquire) {
3982 return None;
3983 }
3984 let opened;
3985 let repo = match repo {
3986 Some(repo) => repo,
3987 None => match git::open_thread_safe(path) {
3988 Ok(repo) => {
3989 opened = repo;
3990 &opened
3991 }
3992 Err(error) => {
3993 return Some(match kind {
3994 Kind::Repo | Kind::Worktree => default_branch::Resolution::failed(error),
3995 Kind::Submodule => default_branch::Resolution::submodule_uninitialized(),
3996 });
3997 }
3998 },
3999 };
4000 Some(supersede_with_network(
4001 default_branch::resolve(&repo.to_thread_local(), hints.override_branch),
4002 hints.network_branch,
4003 ))
4004}
4005
4006struct BoundGate {
4019 state: Mutex<BoundGateState>,
4020 condvar: Condvar,
4021 bound: OnceLock<Option<gix::ObjectId>>,
4022}
4023
4024struct BoundGateState {
4025 remaining: usize,
4026 candidates: Vec<gix::ObjectId>,
4027}
4028
4029impl BoundGate {
4030 fn new(remaining: usize) -> Self {
4031 Self {
4032 state: Mutex::new(BoundGateState {
4033 remaining,
4034 candidates: Vec::new(),
4035 }),
4036 condvar: Condvar::new(),
4037 bound: OnceLock::new(),
4038 }
4039 }
4040
4041 fn report(&self, candidate: Option<gix::ObjectId>) {
4047 let mut state = self.state.lock().unwrap();
4048 if let Some(candidate) = candidate {
4049 state.candidates.push(candidate);
4050 }
4051 state.remaining -= 1;
4052 if state.remaining == 0 {
4053 self.condvar.notify_all();
4054 }
4055 }
4056
4057 fn deepest(&self, repo: &gix::Repository) -> Option<gix::ObjectId> {
4067 let mut state = self.state.lock().unwrap();
4068 while state.remaining != 0 {
4069 state = self.condvar.wait(state).unwrap();
4070 }
4071 let candidates = std::mem::take(&mut state.candidates);
4072 *self
4073 .bound
4074 .get_or_init(|| deepest_merge_base(repo, &candidates))
4075 }
4076}
4077
4078fn deepest_merge_base(
4085 repo: &gix::Repository,
4086 candidates: &[gix::ObjectId],
4087) -> Option<gix::ObjectId> {
4088 let mut candidates = candidates.iter().copied();
4089 let mut deepest = candidates.next()?;
4090 for candidate in candidates {
4091 deepest = git::checked_merge_base(repo, deepest, candidate)
4092 .ok()
4093 .flatten()
4094 .unwrap_or(deepest);
4095 }
4096 Some(deepest)
4097}
4098
4099struct GateReport<'a> {
4105 gate: &'a BoundGate,
4106 reported: bool,
4107}
4108
4109impl<'a> GateReport<'a> {
4110 fn new(gate: &'a BoundGate) -> Self {
4111 Self {
4112 gate,
4113 reported: false,
4114 }
4115 }
4116
4117 fn report_now(&mut self, candidate: Option<gix::ObjectId>) {
4122 self.gate.report(candidate);
4123 self.reported = true;
4124 }
4125}
4126
4127impl Drop for GateReport<'_> {
4128 fn drop(&mut self) {
4129 if !self.reported {
4130 self.gate.report(None);
4131 }
4132 }
4133}
4134
4135struct PatchEquivalenceMemo<'a> {
4139 cache: &'a PatchIdentityCache,
4140 reads: &'a AtomicUsize,
4141 scan_bounds: &'a Mutex<Vec<Option<gix::ObjectId>>>,
4144}
4145
4146fn probe_worktree_state(
4155 path: &Path,
4156 repo: Option<&gix::ThreadSafeRepository>,
4157 default_branch_settled: Option<&Settled<DefaultBranch>>,
4158 common_dir: &Arc<Path>,
4159 cancel: &AtomicBool,
4160 memo: &PatchEquivalenceMemo<'_>,
4161 report: &mut GateReport<'_>,
4162) -> Option<Settled<WorktreeState>> {
4163 if cancel.load(Ordering::Acquire) {
4164 return None;
4165 }
4166 let default_branch_settled = default_branch_settled?;
4167 let opened;
4168 let repo = match repo {
4169 Some(repo) => repo,
4170 None => match git::open_thread_safe(path) {
4171 Ok(repo) => {
4172 opened = repo;
4173 &opened
4174 }
4175 Err(error) => return Some(Settled::Failed(error)),
4176 },
4177 };
4178 let local = repo.to_thread_local();
4179 match landing::probe(&local, default_branch_settled) {
4180 landing::Outcome::Settle(settled) => Some(settled),
4181 landing::Outcome::Outstanding(outstanding) => {
4182 probe_patch_equivalence(&local, &outstanding, common_dir, cancel, memo, report)
4183 }
4184 }
4185}
4186
4187fn probe_patch_equivalence(
4195 repo: &gix::Repository,
4196 outstanding: &landing::Outstanding,
4197 common_dir: &Arc<Path>,
4198 cancel: &AtomicBool,
4199 memo: &PatchEquivalenceMemo<'_>,
4200 report: &mut GateReport<'_>,
4201) -> Option<Settled<WorktreeState>> {
4202 if cancel.load(Ordering::Acquire) {
4203 return None;
4204 }
4205 let landing::Outstanding {
4206 entity_tip,
4207 default_tip,
4208 merge_base,
4209 } = *outstanding;
4210 let Some(merge_base) = merge_base else {
4211 report.report_now(None);
4217 return Some(patch_equivalence::probe(
4218 repo,
4219 entity_tip,
4220 None,
4221 &patch_equivalence::PatchIdentitySet::new(),
4222 ));
4223 };
4224 report.report_now(Some(merge_base));
4228 let bound = report.gate.deepest(repo);
4229 let shared = match patch_identities_for(memo.cache, common_dir, memo.reads, || {
4230 memo.scan_bounds.lock().unwrap().push(bound);
4235 patch_equivalence::scan_default_branch(repo, default_tip, bound)
4236 }) {
4237 Ok(shared) => shared,
4238 Err(error) => return Some(Settled::Failed(error)),
4239 };
4240 Some(patch_equivalence::probe(
4241 repo,
4242 entity_tip,
4243 Some(merge_base),
4244 &shared,
4245 ))
4246}
4247
4248type PatchIdentityCache = Mutex<
4256 HashMap<Arc<Path>, Arc<OnceLock<Result<patch_equivalence::PatchIdentitySet, git::ProbeError>>>>,
4257>;
4258
4259fn patch_identities_for(
4269 cache: &PatchIdentityCache,
4270 common_dir: &Arc<Path>,
4271 reads: &AtomicUsize,
4272 compute: impl FnOnce() -> Result<patch_equivalence::PatchIdentitySet, git::ProbeError>,
4273) -> Result<patch_equivalence::PatchIdentitySet, git::ProbeError> {
4274 let cell = {
4275 let mut cache = cache.lock().unwrap();
4276 Arc::clone(
4277 cache
4278 .entry(Arc::clone(common_dir))
4279 .or_insert_with(|| Arc::new(OnceLock::new())),
4280 )
4281 };
4282 cell.get_or_init(|| {
4283 reads.fetch_add(1, Ordering::Relaxed);
4284 compute()
4285 })
4286 .clone()
4287}
4288
4289type ChainFactsCache = Mutex<HashMap<Arc<Path>, Arc<OnceLock<default_branch::ChainFacts>>>>;
4293
4294fn chain_facts_for(
4301 cache: &ChainFactsCache,
4302 common_dir: &Arc<Path>,
4303 reads: &AtomicUsize,
4304 compute: impl FnOnce() -> default_branch::ChainFacts,
4305) -> default_branch::ChainFacts {
4306 let cell = {
4307 let mut cache = cache.lock().unwrap();
4308 Arc::clone(
4309 cache
4310 .entry(Arc::clone(common_dir))
4311 .or_insert_with(|| Arc::new(OnceLock::new())),
4312 )
4313 };
4314 cell.get_or_init(|| {
4315 reads.fetch_add(1, Ordering::Relaxed);
4316 compute()
4317 })
4318 .clone()
4319}
4320
4321struct ChainFactsMemo<'a> {
4332 cache: &'a ChainFactsCache,
4333 reads: &'a AtomicUsize,
4334}
4335
4336fn probe_default_branch_memoised(
4337 path: &Path,
4338 repo: Option<&gix::ThreadSafeRepository>,
4339 common_dir: &Arc<Path>,
4340 hints: DefaultBranchHints<'_>,
4341 kind: Kind,
4342 cancel: &AtomicBool,
4343 memo: &ChainFactsMemo<'_>,
4344) -> Option<default_branch::Resolution> {
4345 if cancel.load(Ordering::Acquire) {
4346 return None;
4347 }
4348 let opened;
4349 let repo = match repo {
4350 Some(repo) => repo,
4351 None => match git::open_thread_safe(path) {
4352 Ok(repo) => {
4353 opened = repo;
4354 &opened
4355 }
4356 Err(error) => {
4357 return Some(match kind {
4358 Kind::Repo | Kind::Worktree => default_branch::Resolution::failed(error),
4359 Kind::Submodule => default_branch::Resolution::submodule_uninitialized(),
4360 });
4361 }
4362 },
4363 };
4364 let local = repo.to_thread_local();
4365 let facts = chain_facts_for(memo.cache, common_dir, memo.reads, || {
4366 default_branch::ChainFacts::resolve(&local)
4367 });
4368 Some(supersede_with_network(
4369 default_branch::resolve_with_facts(&facts, hints.override_branch),
4370 hints.network_branch,
4371 ))
4372}
4373
4374struct CheapProbeOutcomes {
4379 branch: Option<(
4380 Settled<Head>,
4381 Option<git::InProgressOperation>,
4382 Vec<git::RecentCommit>,
4383 )>,
4384 sync: Option<Settled<SyncState>>,
4385 base: Option<Settled<u32>>,
4386 default_branch: Option<default_branch::Resolution>,
4387}
4388
4389fn apply_cheap_probe_outcomes(
4398 table: &Arc<RwLock<Table>>,
4399 key: &EntityKey,
4400 generation: Generation,
4401 outcomes: CheapProbeOutcomes,
4402) {
4403 let CheapProbeOutcomes {
4404 branch: branch_outcome,
4405 sync: sync_outcome,
4406 base: base_outcome,
4407 default_branch: default_branch_outcome,
4408 } = outcomes;
4409 let mut table = table.write().unwrap();
4410 if let Some(&idx) = table.index.get(key) {
4411 if let Some((settled, in_progress, recent)) = branch_outcome {
4412 table.entities[idx].apply_branch_probe(generation, settled, in_progress, recent);
4413 }
4414 if let Some(settled) = sync_outcome {
4415 table.entities[idx].sync.settle(generation, settled);
4416 }
4417 if let Some(settled) = base_outcome {
4418 table.entities[idx].base.settle(generation, settled);
4419 }
4420 if let Some(resolution) = default_branch_outcome {
4421 table.entities[idx].apply_default_branch_resolution(generation, resolution);
4422 }
4423 }
4424}
4425
4426struct ProbeOutcomes {
4430 state: Option<Settled<WorktreeState>>,
4431 dirty: Option<Settled<DirtyCounts>>,
4432}
4433
4434fn apply_probe_outcome(
4448 table: &Arc<RwLock<Table>>,
4449 settle_gate: &Arc<SettleGate>,
4450 key: &EntityKey,
4451 generation: Generation,
4452 outcomes: ProbeOutcomes,
4453) {
4454 let ProbeOutcomes {
4455 state: state_outcome,
4456 dirty: dirty_outcome,
4457 } = outcomes;
4458 let mut table = table.write().unwrap();
4459 if let Some(&idx) = table.index.get(key) {
4460 if let Some(settled) = state_outcome {
4461 table.entities[idx].state.settle(generation, settled);
4462 }
4463 if let Some(settled) = dirty_outcome {
4464 table.entities[idx].dirty.settle(generation, settled);
4465 }
4466 }
4467 if table
4475 .in_flight
4476 .get(key)
4477 .is_some_and(|in_flight| in_flight.generation == generation.value())
4478 {
4479 table.in_flight.remove(key);
4480 }
4481 drop(table);
4482 complete_one(settle_gate);
4483}
4484
4485fn merge_discovery(
4491 table: &mut Table,
4492 exclusions: &[ResolvedExclusion],
4493 discovered: Vec<discovery::DiscoveredEntity>,
4494 gitmodules_failures: Vec<(EntityKey, String)>,
4495) -> usize {
4496 let mut found: HashSet<EntityKey> = HashSet::with_capacity(discovered.len());
4497
4498 for discovered in discovered {
4499 found.insert(discovered.key.clone());
4500 match table.index.get(&discovered.key).copied() {
4501 Some(idx) => {
4502 table.entities[idx].presence = Presence::Present;
4503 if let Some(repo) = discovered.repo {
4504 table.repos.insert(discovered.key.clone(), repo);
4505 }
4506 }
4507 None => {
4508 let name = discovered
4509 .display_name_override
4510 .clone()
4511 .unwrap_or_else(|| display_name(discovered.key.path()));
4512 let mut entity = EntityState::new(
4513 discovered.key.clone(),
4514 name,
4515 Arc::clone(&discovered.common_dir),
4516 discovered.kind,
4517 );
4518 entity.excluded =
4519 excluded_by(exclusions, discovered.key.path(), &discovered.common_dir);
4520 if let Some(repo) = discovered.repo {
4521 table.repos.insert(discovered.key.clone(), repo);
4522 }
4523 let idx = table.entities.len();
4524 table.index.insert(discovered.key, idx);
4525 table.entities.push(entity);
4526 }
4527 }
4528 }
4529
4530 let now_failing: HashMap<EntityKey, String> = gitmodules_failures.into_iter().collect();
4534 for key in &found {
4535 if let Some(&idx) = table.index.get(key) {
4536 table.entities[idx].diagnostics.gitmodules_failed = now_failing
4537 .get(key)
4538 .map(|message| Arc::from(message.as_str()));
4539 }
4540 }
4541
4542 let missing: Vec<EntityKey> = table
4543 .index
4544 .keys()
4545 .filter(|key| !found.contains(*key))
4546 .cloned()
4547 .collect();
4548 let mut cancelled = 0usize;
4549 for key in missing {
4550 if let Some(&idx) = table.index.get(&key) {
4551 table.entities[idx].mark_vanished();
4552 }
4553 if let Some(in_flight) = table.in_flight.remove(&key) {
4554 in_flight.cancel.store(true, Ordering::Release);
4555 cancelled += 1;
4556 }
4557 }
4558
4559 cancelled
4560}
4561
4562fn display_name(path: &Path) -> Arc<str> {
4571 Arc::from(
4572 path.file_name()
4573 .and_then(|name| name.to_str())
4574 .unwrap_or("?"),
4575 )
4576}
4577
4578fn watch_for_slow_discovery(
4584 progress: Arc<AtomicUsize>,
4585 finished: Arc<AtomicBool>,
4586 roots: Vec<PathBuf>,
4587 warn_after: Duration,
4588) -> Option<String> {
4589 thread::sleep(warn_after);
4590 if finished.load(Ordering::Acquire) {
4591 return None;
4592 }
4593 Some(still_walking_message(
4594 progress.load(Ordering::Acquire),
4595 &roots,
4596 ))
4597}
4598
4599fn still_walking_message(directories_visited: usize, roots: &[PathBuf]) -> String {
4600 let roots = roots
4601 .iter()
4602 .map(|root| root.display().to_string())
4603 .collect::<Vec<_>>()
4604 .join(", ");
4605 format!("discovery: still walking, {directories_visited} directories reached under {roots}")
4606}
4607
4608fn abandoned_discovery_message(directories_visited: usize) -> String {
4613 format!("discovery: stopped at {directories_visited} directories")
4614}
4615
4616#[allow(dead_code)] pub(crate) fn run_while_not_cancelled(
4624 cancel: &AtomicBool,
4625 mut step: impl FnMut() -> bool,
4626) -> usize {
4627 let mut ran = 0;
4628 while !cancel.load(Ordering::Acquire) {
4629 if !step() {
4630 break;
4631 }
4632 ran += 1;
4633 }
4634 ran
4635}
4636
4637#[cfg(test)]
4638mod tests {
4639 use std::fs;
4640 use std::process::Command;
4641
4642 use super::*;
4643 use crate::entity::{AheadBehind, DefaultBranchStopped, WorktreeState};
4644 use crate::liveness::{BACKSTOP, FIXTURE_LIFETIME, wait_for};
4645 use crate::snapshot::{RowSummary, summary};
4646 use crate::test_support::{git, head_sha, loose_object_count};
4647
4648 fn init_repo_with_a_commit(path: &Path) {
4649 fs::create_dir_all(path).expect("create repo dir");
4650 gix::init(path).expect("init repo");
4651 let status = Command::new("git")
4652 .arg("-C")
4653 .arg(path)
4654 .args(["-c", "user.email=test@example.com", "-c", "user.name=Test"])
4655 .args(["commit", "--allow-empty", "-m", "first"])
4656 .status()
4657 .expect("run git commit");
4658 assert!(status.success());
4659 }
4660
4661 fn commit_a_change(path: &Path, message: &str) {
4671 let gitdir = gitdir_of(path);
4672 let before = poll::fingerprint(&gitdir);
4673
4674 std::fs::write(path.join(format!("{message}.txt")), message.as_bytes())
4675 .expect("write a file to commit");
4676 let added = Command::new("git")
4677 .arg("-C")
4678 .arg(path)
4679 .args(["add", "-A"])
4680 .status()
4681 .expect("run git add");
4682 assert!(added.success());
4683 commit(path, message, &["-m", message]);
4684
4685 assert!(
4691 poll::moved(&before, &poll::fingerprint(&gitdir)),
4692 "committing in {} moved none of the polled paths under {}, so this fixture cannot \
4693 show the poll anything",
4694 path.display(),
4695 gitdir.display()
4696 );
4697 }
4698
4699 fn gitdir_of(work_dir: &Path) -> PathBuf {
4702 let output = Command::new("git")
4703 .arg("-C")
4704 .arg(work_dir)
4705 .args(["rev-parse", "--absolute-git-dir"])
4706 .output()
4707 .expect("run git rev-parse");
4708 assert!(
4709 output.status.success(),
4710 "resolve the gitdir of {}",
4711 work_dir.display()
4712 );
4713 PathBuf::from(
4714 std::str::from_utf8(&output.stdout)
4715 .expect("a utf-8 gitdir path")
4716 .trim(),
4717 )
4718 }
4719
4720 fn commit(path: &Path, message: &str, args: &[&str]) {
4722 let status = Command::new("git")
4723 .arg("-C")
4724 .arg(path)
4725 .args(["-c", "user.email=test@example.com", "-c", "user.name=Test"])
4726 .arg("commit")
4727 .args(args)
4728 .status()
4729 .unwrap_or_else(|error| panic!("run git commit {message}: {error}"));
4730 assert!(status.success());
4731 }
4732
4733 fn fetch_spec_for_test() -> FetchSpec {
4737 FetchSpec {
4738 enabled: false,
4739 interval: Duration::from_secs(3600),
4740 concurrency: 4,
4741 }
4742 }
4743
4744 fn auto_update_spec_for_test() -> AutoUpdateSpec {
4749 AutoUpdateSpec { enabled: false }
4750 }
4751
4752 fn spec(roots: Vec<PathBuf>) -> CoreSpec {
4753 CoreSpec {
4754 set: SetSpec {
4755 name: "test".to_string(),
4756 roots,
4757 include: Vec::new(),
4758 exclude: Vec::new(),
4759 },
4760 overrides: Vec::new(),
4761 poll_interval: Duration::from_secs(3600),
4762 status_stale_after: Duration::from_secs(3600),
4763 generation_deadline: Duration::from_secs(3600),
4764 show_submodules: false,
4765 fetch: fetch_spec_for_test(),
4766 auto_update: auto_update_spec_for_test(),
4767 }
4768 }
4769
4770 #[test]
4781 fn core_spec_carries_no_scoping_field_scope_is_never_a_dial() {
4782 let CoreSpec {
4783 set: _,
4784 overrides: _,
4785 poll_interval: _,
4786 status_stale_after: _,
4787 generation_deadline: _,
4788 show_submodules: _,
4789 fetch: _,
4790 auto_update: _,
4791 } = spec(Vec::new());
4792 }
4793
4794 fn root_of(dir: &tempfile::TempDir) -> PathBuf {
4795 dir.path().canonicalize().expect("canonicalize temp dir")
4796 }
4797
4798 fn settle_launch(core: &Core) -> Snapshot {
4807 let launched = core.settle();
4808 assert_eq!(
4809 core.settle_gate_count_for_test(),
4810 0,
4811 "launch's own Generation never settled, so nothing after this is starting from \
4812 the point it claims to"
4813 );
4814 launched
4815 }
4816
4817 fn started_and_settled(spec: CoreSpec) -> (Core, Snapshot) {
4820 let core = Core::start_discovered(spec);
4821 let launched = settle_launch(&core);
4822 (core, launched)
4823 }
4824
4825 fn backdate_polled_entries(work_dir: &Path) {
4832 let gitdir = gitdir_of(work_dir);
4833
4834 let past = std::time::SystemTime::now() - Duration::from_secs(10);
4835 let mut touched = 0;
4836 for name in poll::POLLED_GITDIR_ENTRIES {
4837 let path = gitdir.join(name);
4838 if path.exists() {
4839 set_mtime_to(&path, past);
4840 touched += 1;
4841 }
4842 }
4843 assert!(
4844 touched > 0,
4845 "backdated nothing under {}; the gitdir holds none of the polled entries and the \
4846 baseline this sets up would not be older than what follows",
4847 gitdir.display()
4848 );
4849 }
4850
4851 fn set_mtime_to(path: &Path, at: std::time::SystemTime) {
4853 use std::os::unix::ffi::OsStrExt;
4854
4855 let secs = at
4856 .duration_since(std::time::SystemTime::UNIX_EPOCH)
4857 .expect("a time after the epoch")
4858 .as_secs() as libc::time_t;
4859 let times = [
4860 libc::timespec {
4861 tv_sec: secs,
4862 tv_nsec: 0,
4863 },
4864 libc::timespec {
4865 tv_sec: secs,
4866 tv_nsec: 0,
4867 },
4868 ];
4869 let c_path =
4870 std::ffi::CString::new(path.as_os_str().as_bytes()).expect("a path with no NUL");
4871 let rc = unsafe { libc::utimensat(libc::AT_FDCWD, c_path.as_ptr(), times.as_ptr(), 0) };
4872 assert_eq!(
4873 rc,
4874 0,
4875 "set mtime on {}: {}",
4876 path.display(),
4877 std::io::Error::last_os_error()
4878 );
4879 }
4880
4881 fn step(argv: &[&str]) -> Step {
4882 Step {
4883 argv: argv.iter().map(|s| s.to_string()).collect(),
4884 shell: false,
4885 interactive: false,
4886 env: Vec::new(),
4887 }
4888 }
4889
4890 fn shell_step(command: &str) -> Step {
4892 Step {
4893 argv: vec![command.to_string()],
4894 shell: true,
4895 interactive: false,
4896 env: Vec::new(),
4897 }
4898 }
4899
4900 fn interactive_shell_step(command: &str) -> Step {
4903 Step {
4904 argv: vec![command.to_string()],
4905 shell: true,
4906 interactive: true,
4907 env: Vec::new(),
4908 }
4909 }
4910
4911 fn action(label: &str, steps: Vec<Step>) -> ActionSpec {
4912 ActionSpec {
4913 label: Arc::from(label),
4914 name: Some(Arc::from(label)),
4915 steps,
4916 concurrency: 4,
4917 when: None,
4918 }
4919 }
4920
4921 fn action_with_when(label: &str, steps: Vec<Step>, when: &str) -> ActionSpec {
4924 ActionSpec {
4925 when: Some(Filter::parse(when)),
4926 ..action(label, steps)
4927 }
4928 }
4929
4930 #[test]
4935 fn refresh_and_settle_populate_real_cells_without_the_caller_spawning_a_thread() {
4936 let dir = tempfile::tempdir().expect("temp dir");
4937 let root = root_of(&dir);
4938 let repo = root.join("repo");
4939 init_repo_with_a_commit(&repo);
4940
4941 let core = Core::start_discovered(spec(vec![root]));
4942 let keys: Vec<EntityKey> = core
4943 .snapshot()
4944 .entities
4945 .iter()
4946 .map(|entity| entity.key.clone())
4947 .collect();
4948 assert_eq!(keys.len(), 1);
4949
4950 core.refresh(&keys);
4951 let settled = core.settle();
4952
4953 let entity = &settled.entities[0];
4954 match entity.branch.settled() {
4955 Some(Settled::Known {
4956 value: Head::Branch { .. },
4957 at: _,
4958 stale: _,
4959 }) => {}
4960 other => panic!("expected an attached branch, got {other:?}"),
4961 }
4962 }
4963
4964 fn spec_refresh_md() -> String {
4969 let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
4970 std::fs::read_to_string(manifest_dir.join("../../docs/spec/refresh.md"))
4971 .expect("read docs/spec/refresh.md")
4972 }
4973
4974 fn spec_first_frame_budgets_ms(spec: &str) -> (u64, u64) {
4975 let anchor = "rows with names on screen within ";
4976 let after = spec
4977 .split(anchor)
4978 .nth(1)
4979 .expect("the first-frame budget sentence is present");
4980 let mut parts = after.splitn(2, "ms, every cheap column filled within ");
4981 let names: u64 = parts
4982 .next()
4983 .expect("a names-on-screen budget")
4984 .parse()
4985 .expect("the names-on-screen budget is an integer");
4986 let after_cheap = parts.next().expect("a cheap-column budget and beyond");
4987 let cheap_columns: u64 = after_cheap
4988 .split("ms,")
4989 .next()
4990 .expect("a cheap-column budget")
4991 .parse()
4992 .expect("the cheap-column budget is an integer");
4993 (names, cheap_columns)
4994 }
4995
4996 #[test]
5000 fn first_frame_budget_constants_match_the_spec_of_record() {
5001 let spec = spec_refresh_md();
5002 let (names_ms, cheap_columns_ms) = spec_first_frame_budgets_ms(&spec);
5003 assert_eq!(names_ms, FIRST_FRAME_NAMES_BUDGET_MS);
5004 assert_eq!(cheap_columns_ms, FIRST_FRAME_CHEAP_COLUMNS_BUDGET_MS);
5005 }
5006
5007 #[test]
5015 fn every_dispatched_entity_gets_its_dirty_cell_settled_not_a_subset() {
5016 let dir = tempfile::tempdir().expect("temp dir");
5017 let root = root_of(&dir);
5018 const ENTITY_COUNT: usize = 16;
5019 for index in 0..ENTITY_COUNT {
5020 init_repo_with_a_commit(&root.join(format!("repo-{index}")));
5021 }
5022
5023 let core = Core::start_discovered(spec(vec![root]));
5024 let keys: Vec<EntityKey> = core
5025 .snapshot()
5026 .entities
5027 .iter()
5028 .map(|entity| entity.key.clone())
5029 .collect();
5030 assert_eq!(keys.len(), ENTITY_COUNT, "expected every repo discovered");
5031
5032 core.refresh(&keys);
5033 let settled = core.settle();
5034
5035 for entity in &settled.entities {
5036 assert!(
5037 matches!(
5038 entity.dirty.settled(),
5039 Some(Settled::Known {
5040 value: _,
5041 at: _,
5042 stale: _
5043 })
5044 ),
5045 "entity {:?} was left without a settled dirty cell, which is exactly what a \
5046 visibility-scoped dispatch would leave behind on the entities it skipped: \
5047 got {:?}",
5048 entity.name,
5049 entity.dirty.settled()
5050 );
5051 }
5052 }
5053
5054 #[test]
5069 fn cheap_outcomes_land_before_a_held_phase_c_settles() {
5070 let dir = tempfile::tempdir().expect("temp dir");
5071 let root = root_of(&dir);
5072 let repo = root.join("repo");
5073 init_repo_with_a_commit(&repo);
5074
5075 let (core, launched) = started_and_settled(spec(vec![root]));
5076 let key = launched.entities[0].key.clone();
5077 assert_eq!(
5078 dirty_total(&launched.entities[0]),
5079 0,
5080 "the fixture starts clean, which is the value the held phase C must still be \
5081 reading once the working tree below has moved"
5082 );
5083
5084 git(&repo, &["checkout", "-b", "held"]);
5088 fs::write(repo.join("untracked.txt"), b"uncommitted")
5089 .expect("write an untracked file into the fixture");
5090
5091 core.hold_phase_c_for_test(&key);
5092 core.refresh(std::slice::from_ref(&key));
5093 core.wait_phase_c_landed_for_test(&key);
5094
5095 let mid_flight = core.snapshot();
5096 let entity = mid_flight
5097 .entities
5098 .iter()
5099 .find(|entity| entity.key == key)
5100 .expect("entity present");
5101 assert!(
5102 matches!(
5103 entity.branch.settled(),
5104 Some(Settled::Known {
5105 value: Head::Branch { name, .. },
5106 at: _,
5107 stale: _
5108 }) if &**name == "held"
5109 ),
5110 "the cheap branch cell must carry this Generation's own answer while phase C is \
5111 still held open, got {:?}",
5112 entity.branch.settled()
5113 );
5114 assert!(
5115 entity.dirty.is_in_flight() && dirty_total(entity) == 0,
5116 "phase C is deliberately held open here; a bundled apply would already have \
5117 written this cell's new count alongside branch, got {:?}",
5118 entity.dirty.settled()
5119 );
5120
5121 core.release_phase_c_for_test(&key);
5122 core.wait_phase_c_finished_for_test(&key);
5123
5124 let settled = core.snapshot();
5125 let entity = settled
5126 .entities
5127 .iter()
5128 .find(|entity| entity.key == key)
5129 .expect("entity present");
5130 assert_eq!(
5131 dirty_total(entity),
5132 1,
5133 "phase C must settle its own count once released, got {:?}",
5134 entity.dirty.settled()
5135 );
5136 }
5137
5138 fn dirty_total(entity: &EntityState) -> u32 {
5142 match entity.dirty.settled() {
5143 Some(Settled::Known {
5144 value,
5145 at: _,
5146 stale: _,
5147 }) => value.total(),
5148 other => panic!("expected a settled dirty count, got {other:?}"),
5149 }
5150 }
5151
5152 #[test]
5160 fn splitting_the_probe_write_signals_settle_gate_exactly_once_per_entity() {
5161 let dir = tempfile::tempdir().expect("temp dir");
5162 let root = root_of(&dir);
5163 init_repo_with_a_commit(&root.join("a"));
5164 init_repo_with_a_commit(&root.join("b"));
5165
5166 let (core, snapshot) = started_and_settled(spec(vec![root]));
5167 let key_a = snapshot
5168 .entities
5169 .iter()
5170 .find(|entity| &*entity.name == "a")
5171 .expect("entity a present")
5172 .key
5173 .clone();
5174 let key_b = snapshot
5175 .entities
5176 .iter()
5177 .find(|entity| &*entity.name == "b")
5178 .expect("entity b present")
5179 .key
5180 .clone();
5181
5182 core.hold_phase_c_for_test(&key_a);
5183 core.hold_phase_c_for_test(&key_b);
5184 core.refresh(&[key_a.clone(), key_b.clone()]);
5185 core.wait_dispatched_for_test();
5189 assert_eq!(
5190 core.settle_gate_count_for_test(),
5191 2,
5192 "dispatching two entities must add exactly two to the settle gate"
5193 );
5194
5195 core.wait_phase_c_landed_for_test(&key_a);
5196 core.wait_phase_c_landed_for_test(&key_b);
5197 assert_eq!(
5198 core.settle_gate_count_for_test(),
5199 2,
5200 "the cheap apply must never touch the settle gate: both entities' cheap \
5201 outcomes have landed and neither has finished phase C yet"
5202 );
5203
5204 core.release_phase_c_for_test(&key_a);
5205 core.wait_phase_c_finished_for_test(&key_a);
5206 assert_eq!(
5207 core.settle_gate_count_for_test(),
5208 1,
5209 "exactly one entity finished, so the gate must fall by exactly one, not two \
5210 (double-counted) and not zero (left short)"
5211 );
5212
5213 core.release_phase_c_for_test(&key_b);
5214 core.wait_phase_c_finished_for_test(&key_b);
5215 assert_eq!(
5216 core.settle_gate_count_for_test(),
5217 0,
5218 "both entities finished, so the gate must be fully drained"
5219 );
5220 }
5221
5222 fn registered_gate(core: &Core, key: &EntityKey) -> PhaseCGateHandle {
5225 core.phase_c_gates
5226 .lock()
5227 .unwrap()
5228 .get(key)
5229 .cloned()
5230 .expect("hold_phase_c_for_test must be called before reading its gate")
5231 }
5232
5233 fn release_gate(gate: &PhaseCGateHandle) {
5236 let (lock, cvar) = &**gate;
5237 lock.lock().unwrap().may_proceed = true;
5238 cvar.notify_all();
5239 }
5240
5241 fn gate_is_finished(gate: &PhaseCGateHandle) -> bool {
5242 gate.0.lock().unwrap().finished
5243 }
5244
5245 #[test]
5257 fn a_probe_signals_the_phase_c_gate_its_own_generation_was_dispatched_against() {
5258 let dir = tempfile::tempdir().expect("temp dir");
5259 let root = root_of(&dir);
5260 init_repo_with_a_commit(&root.join("repo"));
5261
5262 let (core, launched) = started_and_settled(spec(vec![root]));
5263 let key = launched.entities[0].key.clone();
5264
5265 core.hold_phase_c_for_test(&key);
5266 let dispatched_against = registered_gate(&core, &key);
5267 core.refresh(std::slice::from_ref(&key));
5268 core.wait_phase_c_landed_for_test(&key);
5269
5270 core.hold_phase_c_for_test(&key);
5271 let registered_later = registered_gate(&core, &key);
5272 release_gate(&dispatched_against);
5273
5274 wait_for(
5275 "the held probe to signal the gate its own Generation was dispatched against",
5276 || gate_is_finished(&dispatched_against),
5277 );
5278 assert!(
5279 !gate_is_finished(®istered_later),
5280 "a gate registered after this Generation dispatched must never be marked \
5281 finished by it: a test waiting on that gate would return before this \
5282 Generation had applied its outcome or decremented the settle gate"
5283 );
5284 }
5285
5286 #[test]
5298 fn a_probe_finishing_clears_only_its_own_generations_in_flight_entry() {
5299 let dir = tempfile::tempdir().expect("temp dir");
5300 let root = root_of(&dir);
5301 init_repo_with_a_commit(&root.join("repo"));
5302
5303 let (core, launched) = started_and_settled(spec(vec![root]));
5304 let key = launched.entities[0].key.clone();
5305
5306 core.hold_phase_c_for_test(&key);
5307 core.refresh(std::slice::from_ref(&key));
5308 core.wait_phase_c_landed_for_test(&key);
5309
5310 let superseding = core.begin_shared_generation_for_test(std::slice::from_ref(&key));
5313
5314 core.release_phase_c_for_test(&key);
5315 core.wait_phase_c_finished_for_test(&key);
5316
5317 core.refresh(std::slice::from_ref(&key));
5318 core.wait_dispatched_for_test();
5319
5320 assert!(
5321 superseding.cancels[&key].load(Ordering::Acquire),
5322 "a probe from a Generation that has already been superseded must leave the \
5323 live Generation's in-flight entry alone, or the Generation after it has \
5324 nothing to interrupt"
5325 );
5326 }
5327
5328 #[test]
5343 fn refresh_dispatches_phase_c_in_exactly_the_order_it_is_given() {
5344 let dir = tempfile::tempdir().expect("temp dir");
5345 let root = root_of(&dir);
5346 const ENTITY_COUNT: usize = 6;
5347 for index in 0..ENTITY_COUNT {
5348 init_repo_with_a_commit(&root.join(format!("repo-{index}")));
5349 }
5350
5351 let (core, launched) = started_and_settled(spec(vec![root]));
5352 let discovery_order: Vec<EntityKey> = launched
5353 .entities
5354 .iter()
5355 .map(|entity| entity.key.clone())
5356 .collect();
5357 assert_eq!(
5358 discovery_order.len(),
5359 ENTITY_COUNT,
5360 "expected every repo discovered"
5361 );
5362
5363 let cursor = discovery_order[3].clone();
5367 let visible = [discovery_order[1].clone(), discovery_order[4].clone()];
5368 let mut three_tier_order = vec![cursor.clone()];
5369 three_tier_order.extend(visible.iter().cloned());
5370 for key in &discovery_order {
5371 if *key != cursor && !visible.contains(key) {
5372 three_tier_order.push(key.clone());
5373 }
5374 }
5375 assert_eq!(
5376 three_tier_order.len(),
5377 ENTITY_COUNT,
5378 "sanity check: the hand-built order must cover every discovered entity exactly \
5379 once"
5380 );
5381
5382 core.refresh(&three_tier_order);
5383 core.settle();
5384
5385 assert_eq!(
5386 core.dispatch_log_for_test(),
5387 three_tier_order,
5388 "refresh must dispatch phase C in exactly the order it was given: the cursor \
5389 row, then the visible rows, then the rest in discovery order"
5390 );
5391 }
5392
5393 #[test]
5398 fn refresh_reuses_the_cached_repository_handle_rather_than_reopening_it() {
5399 let dir = tempfile::tempdir().expect("temp dir");
5400 let root = root_of(&dir);
5401 let repo = root.join("repo");
5402 init_repo_with_a_commit(&repo);
5403
5404 let core = Core::start_discovered(spec(vec![root]));
5405 let key = core.snapshot().entities[0].key.clone();
5406 let before = core
5407 .cached_repo_handle_for_test(&key)
5408 .expect("discovery should have cached a handle");
5409
5410 core.refresh(std::slice::from_ref(&key));
5411 core.settle();
5412
5413 let after = core
5414 .cached_repo_handle_for_test(&key)
5415 .expect("the cached handle should still be there after a refresh");
5416 assert!(
5417 Arc::ptr_eq(&before, &after),
5418 "a refresh must reuse the cached handle, not replace it with a new one"
5419 );
5420 }
5421
5422 #[test]
5428 fn refresh_running_reads_true_the_instant_refresh_returns_and_false_once_it_settles() {
5429 let dir = tempfile::tempdir().expect("temp dir");
5430 let root = root_of(&dir);
5431 init_repo_with_a_commit(&root.join("repo"));
5432
5433 let core = Core::start_discovered(spec(vec![root]));
5434 core.settle();
5435 assert!(
5436 !core.refresh_running(),
5437 "sanity: nothing outstanding once startup has settled"
5438 );
5439
5440 let keys: Vec<EntityKey> = core
5441 .snapshot()
5442 .entities
5443 .iter()
5444 .map(|entity| entity.key.clone())
5445 .collect();
5446 core.refresh(&keys);
5447 assert!(
5448 core.refresh_running(),
5449 "refresh reserves its Generation and records the dispatch debt before it \
5450 returns, so this must already read true"
5451 );
5452
5453 core.settle();
5454 assert!(
5455 !core.refresh_running(),
5456 "settle blocks until nothing is outstanding, so this must read false once it \
5457 returns"
5458 );
5459 }
5460
5461 #[test]
5465 fn probing_a_key_with_no_cached_handle_still_opens_the_repository_itself() {
5466 let dir = tempfile::tempdir().expect("temp dir");
5467 let root = root_of(&dir);
5468 let repo = root.join("repo");
5469 init_repo_with_a_commit(&repo);
5470
5471 let empty_root = root_of(&tempfile::tempdir().expect("temp dir"));
5473 let core = Core::start_discovered(spec(vec![empty_root]));
5474 let key = EntityKey::new(Arc::from(repo.as_path()));
5475 assert!(core.cached_repo_handle_for_test(&key).is_none());
5476
5477 let entity = core.probe_now(&key);
5478
5479 assert!(matches!(
5480 entity.branch.settled(),
5481 Some(Settled::Known {
5482 value: Head::Branch { .. },
5483 at: _,
5484 stale: _
5485 })
5486 ));
5487 }
5488
5489 #[test]
5493 fn an_empty_order_dispatches_nothing_and_settle_returns_immediately() {
5494 let dir = tempfile::tempdir().expect("temp dir");
5495 let root = root_of(&dir);
5496 let repo = root.join("repo");
5497 init_repo_with_a_commit(&repo);
5498
5499 let (core, _launched) = started_and_settled(spec(vec![root]));
5500 assert!(
5501 !core.dispatch_log_for_test().is_empty(),
5502 "launch dispatched nothing, so an empty log below would say nothing about the \
5503 empty order"
5504 );
5505
5506 core.refresh(&[]);
5507 core.wait_dispatched_for_test();
5508
5509 assert_eq!(
5510 core.dispatch_log_for_test(),
5511 Vec::new(),
5512 "an empty order must dispatch no probe"
5513 );
5514 let settled = core
5518 .try_settle(Duration::from_millis(50))
5519 .expect("an empty order raises no probe, so the settle gate is already at zero");
5520 assert!(!settled.entities[0].branch.is_in_flight());
5521 }
5522
5523 fn one_probe_owed_that_never_lands(
5531 dir: &tempfile::TempDir,
5532 ) -> (Core, crossbeam_channel::Sender<Instant>) {
5533 let root = root_of(dir);
5534 init_repo_with_a_commit(&root.join("repo"));
5535 let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
5536 let core = Core::start_for_test(spec(vec![root]), Duration::from_secs(3600), tick_rx)
5537 .discovered()
5538 .core;
5539 let key = settle_launch(&core).entities[0].key.clone();
5540 core.begin_untracked_probe_for_test(&key);
5541 (core, tick_tx)
5542 }
5543
5544 #[test]
5552 #[should_panic(expected = "waiting for everything this Core has in flight to land")]
5553 fn a_settle_that_expires_reports_at_the_wait_rather_than_returning_the_table() {
5554 let dir = tempfile::tempdir().expect("temp dir");
5555 let (core, _tick_tx) = one_probe_owed_that_never_lands(&dir);
5556
5557 core.settle_within(Duration::from_millis(20));
5558 }
5559
5560 #[test]
5564 fn try_settle_hands_an_expiry_back_as_an_error_carrying_the_table_it_gave_up_on() {
5565 let dir = tempfile::tempdir().expect("temp dir");
5566 let (core, _tick_tx) = one_probe_owed_that_never_lands(&dir);
5567
5568 let unsettled = core
5569 .try_settle(Duration::from_millis(20))
5570 .expect_err("a probe nothing will ever complete cannot settle");
5571
5572 assert!(
5573 unsettled.entities[0].branch.is_in_flight(),
5574 "the Err arm must still carry the table as it stood, so a caller that degrades \
5575 deliberately has something to degrade with"
5576 );
5577 }
5578
5579 #[test]
5582 fn try_settle_hands_a_generation_that_really_landed_back_as_ok() {
5583 let dir = tempfile::tempdir().expect("temp dir");
5584 let root = root_of(&dir);
5585 init_repo_with_a_commit(&root.join("repo"));
5586
5587 let (core, launched) = started_and_settled(spec(vec![root]));
5588 let key = launched.entities[0].key.clone();
5589 core.refresh(std::slice::from_ref(&key));
5590
5591 let settled = core
5592 .try_settle(BACKSTOP)
5593 .expect("a dispatched Generation must land inside the backstop");
5594
5595 assert!(!settled.entities[0].branch.is_in_flight());
5596 }
5597
5598 #[test]
5602 fn probe_now_settles_the_sync_cell_as_well_as_the_branch_it_depends_on() {
5603 let dir = tempfile::tempdir().expect("temp dir");
5604 let root = root_of(&dir);
5605 let repo = root.join("repo");
5606 init_repo_with_a_commit(&repo);
5607
5608 let core = Core::start_discovered(spec(vec![root]));
5609 let key = core.snapshot().entities[0].key.clone();
5610
5611 let entity = core.probe_now(&key);
5612
5613 assert!(
5614 matches!(
5615 entity.sync.settled(),
5616 Some(Settled::Known {
5617 value: SyncState::NoRemote,
5618 at: _,
5619 stale: _
5620 })
5621 ),
5622 "expected probe_now to settle sync, got {:?}",
5623 entity.sync.settled()
5624 );
5625 }
5626
5627 #[test]
5630 fn probe_now_settles_the_base_cell_as_well_as_the_branch_it_depends_on() {
5631 let dir = tempfile::tempdir().expect("temp dir");
5632 let root = root_of(&dir);
5633 let repo = root.join("repo");
5634 init_repo_with_a_commit(&repo);
5635
5636 let core = Core::start_discovered(spec(vec![root]));
5637 let key = core.snapshot().entities[0].key.clone();
5638
5639 let entity = core.probe_now(&key);
5640
5641 assert!(
5642 matches!(entity.base.settled(), Some(Settled::NotApplicable)),
5643 "expected probe_now to settle base Not applicable for a Repo with no remote, \
5644 got {:?}",
5645 entity.base.settled()
5646 );
5647 }
5648
5649 #[test]
5653 fn refresh_settles_a_real_base_count_against_the_resolved_default_branch() {
5654 let dir = tempfile::tempdir().expect("temp dir");
5655 let root = root_of(&dir);
5656 let repo = root.join("repo");
5657 init_repo_with_a_commit(&repo);
5658 git(
5659 &repo,
5660 &[
5661 "remote",
5662 "add",
5663 "origin",
5664 "https://example.invalid/repo.git",
5665 ],
5666 );
5667 let root_sha = head_sha(&repo);
5668 git(&repo, &["commit", "--allow-empty", "-m", "second"]);
5674 let tip_sha = head_sha(&repo);
5675 git(&repo, &["reset", "--hard", &root_sha]);
5676 git(&repo, &["update-ref", "refs/remotes/origin/main", &tip_sha]);
5677
5678 let core = Core::start_discovered(spec(vec![root]));
5679 let key = core.snapshot().entities[0].key.clone();
5680
5681 core.refresh(std::slice::from_ref(&key));
5682 let settled = core.settle();
5683
5684 assert!(
5685 matches!(
5686 settled.entities[0].base.settled(),
5687 Some(Settled::Known {
5688 value: 1,
5689 at: _,
5690 stale: _
5691 })
5692 ),
5693 "expected a real refresh to settle base's live count against the resolved \
5694 default branch, got {:?}",
5695 settled.entities[0].base.settled()
5696 );
5697 }
5698
5699 #[test]
5705 fn probe_now_settles_the_dirty_cell_with_the_counts_it_probed() {
5706 let dir = tempfile::tempdir().expect("temp dir");
5707 let root = root_of(&dir);
5708 let repo = root.join("repo");
5709 init_repo_with_a_commit(&repo);
5710 fs::write(repo.join("untracked.txt"), "x").expect("write untracked file");
5711
5712 let core = Core::start_discovered(spec(vec![root]));
5713 let key = core.snapshot().entities[0].key.clone();
5714
5715 let entity = core.probe_now(&key);
5716
5717 assert!(
5718 matches!(
5719 entity.dirty.settled(),
5720 Some(Settled::Known {
5721 value: DirtyCounts {
5722 modified: 0,
5723 untracked: 1,
5724 deleted: 0,
5725 },
5726 at: _,
5727 stale: _
5728 })
5729 ),
5730 "expected probe_now to settle dirty with the one untracked path, got {:?}",
5731 entity.dirty.settled()
5732 );
5733 }
5734
5735 #[test]
5736 fn probe_now_updates_the_entity_synchronously_with_no_refresh_call() {
5737 let dir = tempfile::tempdir().expect("temp dir");
5738 let root = root_of(&dir);
5739 let repo = root.join("repo");
5740 init_repo_with_a_commit(&repo);
5741
5742 let core = Core::start_discovered(spec(vec![root]));
5743 let key = core.snapshot().entities[0].key.clone();
5744
5745 let entity = core.probe_now(&key);
5746
5747 assert!(matches!(
5748 entity.branch.settled(),
5749 Some(Settled::Known {
5750 value: Head::Branch { .. },
5751 at: _,
5752 stale: _
5753 })
5754 ));
5755 }
5756
5757 #[test]
5763 fn the_display_name_agrees_between_discovery_and_probe_nows_fallback_insert() {
5764 let dir = tempfile::tempdir().expect("temp dir");
5765 let root = root_of(&dir);
5766 let repo = root.join("named-repo");
5767 init_repo_with_a_commit(&repo);
5768
5769 let core = Core::start_discovered(spec(vec![root]));
5770 let discovered = core.snapshot().entities[0].clone();
5771 assert_eq!(&*discovered.name, "named-repo");
5772
5773 core.dismiss(&discovered.key);
5774 assert!(core.snapshot().entities.is_empty());
5775
5776 let reinserted = core.probe_now(&discovered.key);
5777
5778 assert_eq!(
5779 reinserted.name, discovered.name,
5780 "the name discovery assigned and the name probe_now's fallback insert \
5781 assigns for the same path must be byte-identical"
5782 );
5783 }
5784
5785 #[test]
5786 fn dismiss_removes_the_entity_from_the_snapshot() {
5787 let dir = tempfile::tempdir().expect("temp dir");
5788 let root = root_of(&dir);
5789 let repo = root.join("repo");
5790 init_repo_with_a_commit(&repo);
5791
5792 let core = Core::start_discovered(spec(vec![root]));
5793 let key = core.snapshot().entities[0].key.clone();
5794
5795 core.dismiss(&key);
5796
5797 assert!(core.snapshot().entities.is_empty());
5798 }
5799
5800 #[test]
5813 fn an_entitys_steps_run_in_order_and_a_failure_marks_every_later_step_not_run() {
5814 let dir = tempfile::tempdir().expect("temp dir");
5815 let root = root_of(&dir);
5816 let repo = root.join("repo");
5817 init_repo_with_a_commit(&repo);
5818 let marker = repo.join("step-three-ran");
5819
5820 let core = Core::start_discovered(spec(vec![root]));
5821 let key = core.snapshot().entities[0].key.clone();
5822 let steps = vec![
5823 step(&["true"]),
5824 step(&["sh", "-c", "exit 7"]),
5825 step(&["touch", "step-three-ran"]),
5826 ];
5827
5828 let started = core.run_action(action("reinstall", steps), std::slice::from_ref(&key));
5829
5830 assert!(started);
5831 wait_for("the fan-out to finish and write a receipt", || {
5832 !core.action_running()
5833 });
5834 let receipt = core.snapshot().entities[0]
5835 .last_action
5836 .clone()
5837 .expect("receipt written");
5838 assert_eq!(receipt.steps.len(), 3);
5839 assert_eq!(receipt.steps[0].outcome, StepOutcome::Ok);
5840 assert_eq!(receipt.steps[1].outcome, StepOutcome::Failed(7));
5841 assert_eq!(
5842 receipt.steps[2].outcome,
5843 StepOutcome::NotRun,
5844 "a step after a failure must be recorded NotRun, not silently dropped or run anyway"
5845 );
5846 assert!(
5847 !marker.exists(),
5848 "the third step's own `touch` must never have run: its marker file exists, so \
5849 the step ran despite being recorded NotRun"
5850 );
5851 }
5852
5853 #[test]
5858 fn steps_run_in_the_order_theyre_declared_not_some_other_order() {
5859 let dir = tempfile::tempdir().expect("temp dir");
5860 let root = root_of(&dir);
5861 let repo = root.join("repo");
5862 init_repo_with_a_commit(&repo);
5863 let order_log = repo.join("order.log");
5864
5865 let core = Core::start_discovered(spec(vec![root]));
5866 let key = core.snapshot().entities[0].key.clone();
5867 let steps = vec![
5868 step(&["sh", "-c", "printf 1 >> order.log"]),
5869 step(&["sh", "-c", "printf 2 >> order.log"]),
5870 step(&["sh", "-c", "printf 3 >> order.log"]),
5871 ];
5872
5873 let started = core.run_action(action("ordering", steps), std::slice::from_ref(&key));
5874
5875 assert!(started);
5876 wait_for("the fan-out to finish and write a receipt", || {
5877 !core.action_running()
5878 });
5879 let receipt = core.snapshot().entities[0]
5880 .last_action
5881 .clone()
5882 .expect("receipt written");
5883 assert_eq!(receipt.steps.len(), 3);
5884 assert!(
5885 receipt
5886 .steps
5887 .iter()
5888 .all(|result| result.outcome == StepOutcome::Ok),
5889 "every step here always exits zero; this test isolates ordering from gating"
5890 );
5891 let content = fs::read_to_string(&order_log).expect("order.log written by the steps");
5892 assert_eq!(
5893 content, "123",
5894 "the file's content pins actual execution order; running the steps out of \
5895 declaration order would produce a different digit sequence here even though \
5896 every step still succeeds"
5897 );
5898 }
5899
5900 #[test]
5908 fn a_still_running_actions_finished_step_and_its_currently_executing_one_are_both_visible_before_the_whole_run_ends()
5909 {
5910 let dir = tempfile::tempdir().expect("temp dir");
5911 let root = root_of(&dir);
5912 let repo = root.join("repo");
5913 init_repo_with_a_commit(&repo);
5914
5915 let core = Core::start_discovered(spec(vec![root]));
5916 let key = core.snapshot().entities[0].key.clone();
5917 let steps = vec![step(&["true"]), step(&["sh", "-c", "sleep 0.5"])];
5918
5919 let started = core.run_action(action("reinstall", steps), std::slice::from_ref(&key));
5920 assert!(started);
5921
5922 wait_for(
5927 "a receipt naming the second step running before the run finished",
5928 || {
5929 core.snapshot().entities[0]
5930 .last_action
5931 .as_ref()
5932 .and_then(|receipt| receipt.running.as_ref())
5933 .is_some_and(|running| running.label.contains("sleep"))
5934 },
5935 );
5936 let mid_run = core.snapshot().entities[0]
5937 .last_action
5938 .clone()
5939 .expect("receipt written");
5940 assert_eq!(
5941 mid_run.steps.len(),
5942 1,
5943 "the first, already-finished step must already be in `steps`"
5944 );
5945 assert_eq!(mid_run.steps[0].outcome, StepOutcome::Ok);
5946 let running = mid_run.running.expect("a step must be recorded running");
5947 assert!(
5948 running.label.contains("sleep"),
5949 "expected the running step's own label, got {:?}",
5950 running.label
5951 );
5952
5953 wait_for("the fan-out to finish", || !core.action_running());
5954 let finished = core.snapshot().entities[0]
5955 .last_action
5956 .clone()
5957 .expect("receipt written");
5958 assert!(
5959 finished.running.is_none(),
5960 "a finished receipt must carry no running step"
5961 );
5962 assert_eq!(finished.steps.len(), 2);
5963 }
5964
5965 #[test]
5974 fn a_shell_true_step_runs_through_shell_c_with_repon_as_its_own_dollar_zero() {
5975 let dir = tempfile::tempdir().expect("temp dir");
5976 let root = root_of(&dir);
5977 let repo = root.join("repo");
5978 init_repo_with_a_commit(&repo);
5979
5980 let core = Core::start_discovered(spec(vec![root]));
5981 let key = core.snapshot().entities[0].key.clone();
5982 let steps = vec![shell_step("echo \"[$0]\"")];
5983
5984 let started = core.run_action(action("shell-step", steps), std::slice::from_ref(&key));
5985
5986 assert!(started);
5987 wait_for("the fan-out to finish and write a receipt", || {
5988 !core.action_running()
5989 });
5990 let receipt = core.snapshot().entities[0]
5991 .last_action
5992 .clone()
5993 .expect("receipt written");
5994 assert_eq!(receipt.steps.len(), 1);
5995 assert_eq!(receipt.steps[0].outcome, StepOutcome::Ok);
5996 assert_eq!(&*receipt.steps[0].output, b"[repon]\n");
5997 assert!(
5998 receipt.steps[0].shell,
5999 "the receipt's own StepResult::shell must carry the mode the step ran under"
6000 );
6001 }
6002
6003 #[test]
6010 fn an_interactive_shell_true_step_runs_through_run_action_with_interactive_on_its_receipt() {
6011 let dir = tempfile::tempdir().expect("temp dir");
6012 let root = root_of(&dir);
6013 let repo = root.join("repo");
6014 init_repo_with_a_commit(&repo);
6015
6016 let core = Core::start_discovered(spec(vec![root]));
6017 let key = core.snapshot().entities[0].key.clone();
6018 let steps = vec![interactive_shell_step("true")];
6019
6020 let started = core.run_action(
6021 action("interactive-step", steps),
6022 std::slice::from_ref(&key),
6023 );
6024
6025 assert!(started);
6026 wait_for("the fan-out to finish and write a receipt", || {
6027 !core.action_running()
6028 });
6029 let receipt = core.snapshot().entities[0]
6030 .last_action
6031 .clone()
6032 .expect("receipt written");
6033 assert_eq!(receipt.steps.len(), 1);
6034 assert_eq!(receipt.steps[0].outcome, StepOutcome::Ok);
6035 assert!(
6036 receipt.steps[0].shell,
6037 "an interactive step is still a shell step"
6038 );
6039 assert!(
6040 receipt.steps[0].interactive,
6041 "the receipt's own StepResult::interactive must carry the mode the step ran under"
6042 );
6043 }
6044
6045 #[test]
6049 fn an_argv_step_runs_through_run_action_with_shell_false_on_its_receipt() {
6050 let dir = tempfile::tempdir().expect("temp dir");
6051 let root = root_of(&dir);
6052 let repo = root.join("repo");
6053 init_repo_with_a_commit(&repo);
6054
6055 let core = Core::start_discovered(spec(vec![root]));
6056 let key = core.snapshot().entities[0].key.clone();
6057 let steps = vec![Step {
6058 argv: vec!["true".to_string()],
6059 shell: false,
6060 interactive: false,
6061 env: Vec::new(),
6062 }];
6063
6064 let started = core.run_action(action("argv-step", steps), std::slice::from_ref(&key));
6065
6066 assert!(started);
6067 wait_for("the fan-out to finish and write a receipt", || {
6068 !core.action_running()
6069 });
6070 let receipt = core.snapshot().entities[0]
6071 .last_action
6072 .clone()
6073 .expect("receipt written");
6074 assert!(!receipt.steps[0].shell);
6075 }
6076
6077 #[test]
6083 fn starting_an_action_cancels_any_generation_already_in_flight() {
6084 let dir = tempfile::tempdir().expect("temp dir");
6085 let root = root_of(&dir);
6086 let repo = root.join("repo");
6087 init_repo_with_a_commit(&repo);
6088
6089 let core = Core::start_discovered(spec(vec![root]));
6090 let key = core.snapshot().entities[0].key.clone();
6091 let in_flight = core.begin_shared_generation_for_test(std::slice::from_ref(&key));
6092 let cancel = in_flight
6093 .cancels
6094 .get(&key)
6095 .expect("the in-flight entity has a cancel flag")
6096 .clone();
6097 assert!(!cancel.load(Ordering::Acquire));
6098
6099 let started = core.run_action(
6100 action("reinstall", vec![step(&["true"])]),
6101 std::slice::from_ref(&key),
6102 );
6103
6104 assert!(started);
6105 assert!(
6106 cancel.load(Ordering::Acquire),
6107 "starting an Action must cancel a Generation already in flight, not share \
6108 execution with it"
6109 );
6110 wait_for("the fan-out and its completion refresh to drain", || {
6113 !core.action_running()
6114 });
6115 }
6116
6117 #[test]
6127 fn a_finished_action_starts_exactly_one_generation_over_every_known_entity() {
6128 let dir = tempfile::tempdir().expect("temp dir");
6129 let root = root_of(&dir);
6130 let acted_on = root.join("acted-on");
6131 let untouched = root.join("untouched");
6132 init_repo_with_a_commit(&acted_on);
6133 init_repo_with_a_commit(&untouched);
6134
6135 let (core, before) = started_and_settled(spec(vec![root]));
6136 let acted_key = before
6137 .entities
6138 .iter()
6139 .find(|entity| entity.key.path() == acted_on)
6140 .expect("the acted-on entity is discovered")
6141 .key
6142 .clone();
6143
6144 let started = core.run_action(
6145 action("reinstall", vec![step(&["true"])]),
6146 std::slice::from_ref(&acted_key),
6147 );
6148
6149 assert!(started);
6150 wait_for(
6151 "the completion Generation to probe every known entity, including the one the \
6152 Action never touched",
6153 || {
6154 let snapshot = core.snapshot();
6155 snapshot.generation != before.generation
6156 && snapshot.entities.iter().all(|entity| {
6157 matches!(
6158 entity.branch.settled(),
6159 Some(Settled::Known {
6160 value: _,
6161 at: _,
6162 stale: _
6163 })
6164 )
6165 })
6166 },
6167 );
6168 assert_eq!(
6169 core.settle().generation,
6170 before.generation.successor(),
6171 "completion must start exactly one Generation: not zero (no refresh at all) and \
6172 not two (a double refresh)"
6173 );
6174 }
6175
6176 #[test]
6181 fn an_excluded_row_swept_into_an_action_gets_a_not_applicable_receipt_and_no_other_path_does() {
6182 let dir = tempfile::tempdir().expect("temp dir");
6183 let root = root_of(&dir);
6184 let excluded_repo = root.join("excluded");
6185 let normal_repo = root.join("normal");
6186 init_repo_with_a_commit(&excluded_repo);
6187 init_repo_with_a_commit(&normal_repo);
6188
6189 let core = Core::start_discovered(spec_with_overrides(
6190 vec![root],
6191 vec![RepoOverride {
6192 path: excluded_repo.clone(),
6193 default_branch: None,
6194 excluded: true,
6195 }],
6196 ));
6197 let snapshot = core.snapshot();
6198 let find = |path: &Path| {
6199 snapshot
6200 .entities
6201 .iter()
6202 .find(|entity| entity.key.path() == path)
6203 .unwrap_or_else(|| panic!("entity at {path:?} present"))
6204 .key
6205 .clone()
6206 };
6207 let excluded_key = find(&excluded_repo);
6208 let normal_key = find(&normal_repo);
6209 assert!(
6210 snapshot
6211 .entities
6212 .iter()
6213 .find(|entity| entity.key == excluded_key)
6214 .unwrap()
6215 .excluded
6216 );
6217
6218 let started = core.run_action(
6219 action("reinstall", vec![step(&["sh", "-c", "exit 3"])]),
6220 &[excluded_key.clone(), normal_key.clone()],
6221 );
6222
6223 assert!(started);
6224 wait_for("the fan-out to finish", || !core.action_running());
6230
6231 let after = core.snapshot();
6232 let receipt_of = |key: &EntityKey| {
6233 after
6234 .entities
6235 .iter()
6236 .find(|entity| entity.key == *key)
6237 .unwrap()
6238 .last_action
6239 .clone()
6240 .unwrap()
6241 };
6242 let excluded_receipt = receipt_of(&excluded_key);
6243 assert!(excluded_receipt.not_applicable());
6244 assert!(excluded_receipt.steps.is_empty());
6245
6246 let normal_receipt = receipt_of(&normal_key);
6247 assert!(
6248 !normal_receipt.not_applicable(),
6249 "a row that actually ran a step, even a failing one, must never read as \
6250 not_applicable: an excluded row is the one legitimate producer of that outcome"
6251 );
6252 assert!(!normal_receipt.steps.is_empty());
6253 assert!(normal_receipt.failed());
6254 }
6255
6256 #[test]
6263 fn operable_count_matches_how_many_entities_run_action_actually_runs_a_step_against() {
6264 let dir = tempfile::tempdir().expect("temp dir");
6265 let root = root_of(&dir);
6266 let excluded_repo = root.join("excluded");
6267 let normal_repo = root.join("normal");
6268 init_repo_with_a_commit(&excluded_repo);
6269 init_repo_with_a_commit(&normal_repo);
6270
6271 let core = Core::start_discovered(spec_with_overrides(
6272 vec![root],
6273 vec![RepoOverride {
6274 path: excluded_repo.clone(),
6275 default_branch: None,
6276 excluded: true,
6277 }],
6278 ));
6279 let snapshot = core.snapshot();
6280 let find = |path: &Path| {
6281 snapshot
6282 .entities
6283 .iter()
6284 .find(|entity| entity.key.path() == path)
6285 .unwrap_or_else(|| panic!("entity at {path:?} present"))
6286 .key
6287 .clone()
6288 };
6289 let order = [find(&excluded_repo), find(&normal_repo)];
6290
6291 assert_eq!(
6292 core.operable_count(&order),
6293 1,
6294 "one of the two rows is excluded, so exactly one is operable"
6295 );
6296
6297 let started = core.run_action(action("reinstall", vec![step(&["true"])]), &order);
6298 assert!(started);
6299
6300 wait_for("every entity in the order to carry a receipt", || {
6301 let snapshot = core.snapshot();
6302 order.iter().all(|key| {
6303 snapshot
6304 .entities
6305 .iter()
6306 .find(|entity| entity.key == *key)
6307 .and_then(|entity| entity.last_action.as_ref())
6308 .is_some()
6309 })
6310 });
6311
6312 let after = core.snapshot();
6313 let actually_ran = after
6314 .entities
6315 .iter()
6316 .filter(|entity| order.contains(&entity.key))
6317 .filter(|entity| {
6318 entity
6319 .last_action
6320 .as_ref()
6321 .is_some_and(|receipt| !receipt.not_applicable())
6322 })
6323 .count();
6324
6325 assert_eq!(
6326 core.operable_count(&order),
6327 actually_ran,
6328 "operable_count must report exactly how many rows run_action actually ran a \
6329 step against, not merely how many keys resolved"
6330 );
6331 }
6332
6333 #[test]
6338 fn run_action_for_entity_blocking_returns_the_finished_receipt_on_the_calling_thread() {
6339 let dir = tempfile::tempdir().expect("temp dir");
6340 let root = root_of(&dir);
6341 let repo = root.join("repo");
6342 init_repo_with_a_commit(&repo);
6343 let marker = repo.join("hook-ran");
6344
6345 let core = Core::start_discovered(spec_with_overrides(vec![root], Vec::new()));
6346 let key = core
6347 .snapshot()
6348 .entities
6349 .iter()
6350 .find(|entity| entity.key.path() == repo)
6351 .expect("the repo is discovered")
6352 .key
6353 .clone();
6354
6355 let receipt = core
6356 .run_action_for_entity_blocking(
6357 &action("hook", vec![step(&["touch", "hook-ran"])]),
6358 &key,
6359 )
6360 .expect("the entity is known");
6361
6362 assert!(
6363 marker.exists(),
6364 "the step must have already run by the time this call returns"
6365 );
6366 assert_eq!(receipt.steps.len(), 1);
6367 assert_eq!(receipt.steps[0].outcome, StepOutcome::Ok);
6368 }
6369
6370 #[test]
6375 fn run_action_for_entity_blocking_answers_none_for_an_unknown_key() {
6376 let dir = tempfile::tempdir().expect("temp dir");
6377 let root = root_of(&dir);
6378 let core = Core::start_discovered(spec_with_overrides(vec![root.clone()], Vec::new()));
6379
6380 let unknown = EntityKey::new(Arc::from(root.join("never-discovered").as_path()));
6381
6382 assert!(
6383 core.run_action_for_entity_blocking(&action("hook", vec![step(&["true"])]), &unknown)
6384 .is_none()
6385 );
6386 }
6387
6388 #[test]
6395 fn run_action_skips_a_row_its_when_predicate_disproves_rather_than_running_it_anyway() {
6396 let dir = tempfile::tempdir().expect("temp dir");
6397 let root = root_of(&dir);
6398 let proved_repo = root.join("alpha");
6399 let disproved_repo = root.join("beta");
6400 init_repo_with_a_commit(&proved_repo);
6401 init_repo_with_a_commit(&disproved_repo);
6402
6403 let core = Core::start_discovered(spec(vec![root]));
6404 let snapshot = core.snapshot();
6405 let find = |path: &Path| {
6406 snapshot
6407 .entities
6408 .iter()
6409 .find(|entity| entity.key.path() == path)
6410 .unwrap_or_else(|| panic!("entity at {path:?} present"))
6411 .key
6412 .clone()
6413 };
6414 let proved_key = find(&proved_repo);
6415 let disproved_key = find(&disproved_repo);
6416 let order = [proved_key.clone(), disproved_key.clone()];
6417
6418 let started = core.run_action(
6421 action_with_when(
6422 "reinstall",
6423 vec![step(&["sh", "-c", "exit 3"])],
6424 "name:alpha",
6425 ),
6426 &order,
6427 );
6428 assert!(started);
6429 wait_for("the fan-out to finish", || !core.action_running());
6430
6431 let after = core.snapshot();
6432 let receipt_of = |key: &EntityKey| {
6433 after
6434 .entities
6435 .iter()
6436 .find(|entity| entity.key == *key)
6437 .unwrap()
6438 .last_action
6439 .clone()
6440 .unwrap()
6441 };
6442
6443 let proved_receipt = receipt_of(&proved_key);
6444 assert_eq!(
6445 proved_receipt.skip, None,
6446 "the row the predicate proved must actually run"
6447 );
6448 assert!(proved_receipt.failed(), "its own step still ran and failed");
6449
6450 let disproved_receipt = receipt_of(&disproved_key);
6451 assert!(
6452 disproved_receipt.inapplicable(),
6453 "the row the predicate disproved must be skipped rather than run"
6454 );
6455 assert!(disproved_receipt.steps.is_empty());
6456 assert!(
6457 !disproved_receipt.failed(),
6458 "a skipped row never ran a step, so it cannot have failed one"
6459 );
6460 }
6461
6462 #[test]
6471 fn applicability_subtracts_an_excluded_row_before_the_predicate_reads_it() {
6472 let dir = tempfile::tempdir().expect("temp dir");
6473 let root = root_of(&dir);
6474 let excluded_repo = root.join("excluded");
6475 let normal_repo = root.join("normal");
6476 init_repo_with_a_commit(&excluded_repo);
6477 init_repo_with_a_commit(&normal_repo);
6478
6479 let core = Core::start_discovered(spec_with_overrides(
6480 vec![root],
6481 vec![RepoOverride {
6482 path: excluded_repo.clone(),
6483 default_branch: None,
6484 excluded: true,
6485 }],
6486 ));
6487 let order: Vec<EntityKey> = core
6488 .snapshot()
6489 .entities
6490 .iter()
6491 .map(|entity| entity.key.clone())
6492 .collect();
6493 assert_eq!(order.len(), 2, "the fixture must discover both repos");
6494
6495 let counts = core.applicability(&order, &Filter::parse("kind:repo"));
6496
6497 assert_eq!(
6498 counts.total(),
6499 core.operable_count(&order),
6500 "the predicate must be counted over exactly the rows `operable_count` keeps"
6501 );
6502 assert_eq!(
6503 counts,
6504 Applicability {
6505 applicable: 1,
6506 inapplicable: 0,
6507 unresolved: 0,
6508 }
6509 );
6510 }
6511
6512 #[test]
6516 fn operable_count_silently_drops_a_key_that_no_longer_resolves() {
6517 let dir = tempfile::tempdir().expect("temp dir");
6518 let root = root_of(&dir);
6519 let repo = root.join("repo");
6520 init_repo_with_a_commit(&repo);
6521
6522 let core = Core::start_discovered(spec(vec![root]));
6523 let real_key = core.snapshot().entities[0].key.clone();
6524 let unknown_key = EntityKey::new(Arc::from(dir.path().join("never-discovered")));
6525
6526 assert_eq!(core.operable_count(&[real_key, unknown_key]), 1);
6527 }
6528
6529 #[test]
6533 fn only_one_action_fan_out_runs_at_a_time_a_second_call_is_rejected_while_one_is_live() {
6534 let dir = tempfile::tempdir().expect("temp dir");
6535 let root = root_of(&dir);
6536 let repo = root.join("repo");
6537 init_repo_with_a_commit(&repo);
6538
6539 let core = Core::start_discovered(spec(vec![root]));
6540 let key = core.snapshot().entities[0].key.clone();
6541 let slow = action("first", vec![step(&["sh", "-c", "sleep 0.3"])]);
6542 let fast = action("second", vec![step(&["true"])]);
6543
6544 let first_started = core.run_action(slow, std::slice::from_ref(&key));
6545 let second_started = core.run_action(fast, std::slice::from_ref(&key));
6546
6547 assert!(first_started);
6548 assert!(
6549 !second_started,
6550 "a second run_action call must be rejected while the first is still in flight"
6551 );
6552 wait_for("the accepted first fan-out to finish", || {
6553 !core.action_running()
6554 });
6555 let receipt = core.snapshot().entities[0].last_action.clone().unwrap();
6556 assert_eq!(
6557 &*receipt.label, "first",
6558 "the surviving receipt must be the accepted first run's, never the rejected second"
6559 );
6560 }
6561
6562 #[test]
6576 fn hold_action_genuinely_pauses_a_running_steps_progress_and_continue_action_resumes_it() {
6577 let dir = tempfile::tempdir().expect("temp dir");
6578 let root = root_of(&dir);
6579 let repo = root.join("repo");
6580 init_repo_with_a_commit(&repo);
6581
6582 let core = Core::start_discovered(spec(vec![root]));
6583 let key = core.snapshot().entities[0].key.clone();
6584 let two_seconds = action("brief", vec![step(&["sh", "-c", "sleep 2"])]);
6585
6586 assert!(core.run_action(two_seconds, std::slice::from_ref(&key)));
6587 wait_for("the two-second step to actually start running", || {
6588 core.snapshot().entities[0]
6589 .last_action
6590 .as_ref()
6591 .is_some_and(|receipt| receipt.running.is_some())
6592 });
6593
6594 for _ in 0..20 {
6602 core.hold_action();
6603 thread::sleep(Duration::from_millis(20));
6604 }
6605
6606 thread::sleep(Duration::from_millis(1_800));
6607 assert!(
6608 core.action_running(),
6609 "a genuinely held step must not have finished on its own well past its own 2s \
6610 sleep; a no-op hold_action would already show this false here"
6611 );
6612
6613 core.continue_action();
6614 wait_for("continue_action to let the held step finish", || {
6615 !core.action_running()
6616 });
6617 let receipt = core.snapshot().entities[0].last_action.clone().unwrap();
6618 assert_eq!(receipt.steps[0].outcome, StepOutcome::Ok);
6619 }
6620
6621 #[test]
6625 fn hold_continue_and_stop_action_are_no_ops_with_no_fan_out_running() {
6626 let dir = tempfile::tempdir().expect("temp dir");
6627 let root = root_of(&dir);
6628 let repo = root.join("repo");
6629 init_repo_with_a_commit(&repo);
6630
6631 let core = Core::start_discovered(spec(vec![root]));
6632
6633 core.hold_action();
6634 core.continue_action();
6635 core.stop_action();
6636
6637 assert!(!core.action_running());
6638 }
6639
6640 #[test]
6662 fn stop_action_escalates_from_sigterm_to_sigkill_against_a_trapping_step() {
6663 let dir = tempfile::tempdir().expect("temp dir");
6664 let root = root_of(&dir);
6665 let repo = root.join("repo");
6666 init_repo_with_a_commit(&repo);
6667
6668 let core = Core::start_discovered(spec(vec![root]));
6669 let key = core.snapshot().entities[0].key.clone();
6670 let sleep_past_the_backstop = format!("trap '' TERM; sleep {}", FIXTURE_LIFETIME.as_secs());
6671 let trapping = action(
6672 "trapping",
6673 vec![step(&["sh", "-c", &sleep_past_the_backstop])],
6674 );
6675
6676 assert!(core.run_action(trapping, std::slice::from_ref(&key)));
6677 wait_for("the trapping step to actually start running", || {
6678 core.snapshot().entities[0]
6679 .last_action
6680 .as_ref()
6681 .is_some_and(|receipt| receipt.running.is_some())
6682 });
6683 thread::sleep(Duration::from_millis(100));
6686
6687 core.stop_action();
6688
6689 wait_for(
6690 "a SIGTERM-trapping step to come down from the follow-up SIGKILL",
6691 || !core.action_running(),
6692 );
6693 let receipt = core.snapshot().entities[0].last_action.clone().unwrap();
6694 assert_eq!(receipt.steps.len(), 1);
6695 assert_eq!(
6696 receipt.steps[0].outcome,
6697 StepOutcome::Cancelled,
6698 "a step running when the run was cancelled must read Cancelled, never Failed"
6699 );
6700 }
6701
6702 #[test]
6717 fn cancelled_and_not_run_are_distinct_outcomes_shown_together_in_one_run() {
6718 let dir = tempfile::tempdir().expect("temp dir");
6719 let root = root_of(&dir);
6720 init_repo_with_a_commit(&root.join("fail"));
6721 init_repo_with_a_commit(&root.join("slow"));
6722
6723 let core = Core::start_discovered(spec(vec![root]));
6724 let snapshot = core.snapshot();
6725 let fail_key = snapshot
6726 .entities
6727 .iter()
6728 .find(|entity| &*entity.name == "fail")
6729 .expect("the fail entity is present")
6730 .key
6731 .clone();
6732 let slow_key = snapshot
6733 .entities
6734 .iter()
6735 .find(|entity| &*entity.name == "slow")
6736 .expect("the slow entity is present")
6737 .key
6738 .clone();
6739
6740 let branch_on_the_entity_name = format!(
6748 "case \"$(basename \"$PWD\")\" in fail) exit 1 ;; *) sleep {} ;; esac",
6749 FIXTURE_LIFETIME.as_secs()
6750 );
6751 let steps = vec![
6752 step(&["sh", "-c", &branch_on_the_entity_name]),
6753 step(&["true"]),
6754 ];
6755 let mut action_spec = action("mixed", steps);
6756 action_spec.concurrency = 2;
6757
6758 assert!(core.run_action(action_spec, &[fail_key.clone(), slow_key.clone()]));
6759
6760 wait_for(
6764 "`fail` finished and `slow` still running before cancelling",
6765 || {
6766 let snapshot = core.snapshot();
6767 let fail_done = snapshot
6768 .entities
6769 .iter()
6770 .find(|entity| entity.key == fail_key)
6771 .and_then(|entity| entity.last_action.as_ref())
6772 .is_some_and(|receipt| receipt.steps.len() == 2);
6773 let slow_running = snapshot
6774 .entities
6775 .iter()
6776 .find(|entity| entity.key == slow_key)
6777 .and_then(|entity| entity.last_action.as_ref())
6778 .is_some_and(|receipt| receipt.running.is_some());
6779 fail_done && slow_running
6780 },
6781 );
6782
6783 core.stop_action();
6784 wait_for("the fan-out to finish once cancelled", || {
6785 !core.action_running()
6786 });
6787
6788 let snapshot = core.snapshot();
6789 let fail_receipt = snapshot
6790 .entities
6791 .iter()
6792 .find(|entity| entity.key == fail_key)
6793 .and_then(|entity| entity.last_action.clone())
6794 .expect("fail's own receipt");
6795 assert_eq!(fail_receipt.steps[0].outcome, StepOutcome::Failed(1));
6796 assert_eq!(
6797 fail_receipt.steps[1].outcome,
6798 StepOutcome::NotRun,
6799 "blocked by fail's own earlier failure, not by the later cancellation"
6800 );
6801
6802 let slow_receipt = snapshot
6803 .entities
6804 .iter()
6805 .find(|entity| entity.key == slow_key)
6806 .and_then(|entity| entity.last_action.clone())
6807 .expect("slow's own receipt");
6808 assert_eq!(
6809 slow_receipt.steps[0].outcome,
6810 StepOutcome::Cancelled,
6811 "a step running when the run was cancelled must read Cancelled"
6812 );
6813 assert_eq!(
6814 slow_receipt.steps[1].outcome,
6815 StepOutcome::Cancelled,
6816 "a step that had not started when the run was cancelled must also read \
6817 Cancelled, never NotRun, which stays reserved for an earlier failure"
6818 );
6819 }
6820
6821 #[test]
6829 fn a_panicking_fan_out_still_resets_action_running_so_a_later_action_can_start() {
6830 let dir = tempfile::tempdir().expect("temp dir");
6831 let root = root_of(&dir);
6832 let repo = root.join("repo");
6833 init_repo_with_a_commit(&repo);
6834
6835 let (core, launched) = started_and_settled(spec(vec![root]));
6839 let key = launched.entities[0].key.clone();
6840
6841 let started = core.run_action(
6848 action("boom", vec![step(&["sh", "-c", "sleep 0.3"])]),
6849 std::slice::from_ref(&key),
6850 );
6851 assert!(started);
6852
6853 let table = Arc::clone(&core.table);
6854 thread::spawn(move || {
6855 let _guard = table.write().unwrap();
6856 panic!("deliberately poison the table lock for this test");
6857 })
6858 .join()
6859 .expect_err("the poisoning thread must itself panic to poison the lock");
6860
6861 wait_for(
6866 "a panicking fan-out to reset action_running rather than leave it stuck true",
6867 || !core.action_running.load(Ordering::Acquire),
6868 );
6869
6870 core.table.clear_poison();
6875
6876 let second_started = core.run_action(
6877 action("second", vec![step(&["true"])]),
6878 std::slice::from_ref(&key),
6879 );
6880 assert!(
6881 second_started,
6882 "a later Action must be able to start once the panicking one has finished"
6883 );
6884 wait_for("the second Action to run to completion", || {
6885 core.snapshot()
6886 .entities
6887 .iter()
6888 .find(|entity| entity.key == key)
6889 .and_then(|entity| entity.last_action.as_ref())
6890 .is_some_and(|receipt| &*receipt.label == "second")
6891 });
6892 }
6893
6894 fn assert_vanished_with_stale_branch(entity: &EntityState, expected_branch: &str) {
6900 assert_eq!(entity.presence, crate::entity::Presence::Vanished);
6901 match entity.branch.settled() {
6902 Some(Settled::Known {
6903 value: Head::Branch { name, .. },
6904 stale: true,
6905 at: _,
6906 }) => assert_eq!(
6907 &**name, expected_branch,
6908 "a Vanished entity must keep its last known branch value"
6909 ),
6910 other => panic!(
6911 "expected the branch cell to keep its Known value and go stale, got {other:?}"
6912 ),
6913 }
6914 }
6915
6916 #[test]
6922 fn a_repo_removed_from_disk_stays_in_the_table_vanished_with_its_last_values() {
6923 let dir = tempfile::tempdir().expect("temp dir");
6924 let root = root_of(&dir);
6925 let repo = root.join("repo");
6926 init_repo_with_a_commit(&repo);
6927
6928 let core = Core::start_discovered(spec(vec![root]));
6929 let key = core.snapshot().entities[0].key.clone();
6930 core.refresh(std::slice::from_ref(&key));
6931 let before = core.settle();
6932 let branch_name = match before.entities[0].branch.settled() {
6933 Some(Settled::Known {
6934 value: Head::Branch { name, .. },
6935 at: _,
6936 stale: _,
6937 }) => name.to_string(),
6938 other => panic!("expected the first refresh to settle a branch, got {other:?}"),
6939 };
6940
6941 fs::remove_dir_all(&repo).expect("remove the repo from disk");
6942
6943 core.refresh(&[]);
6944 let after = core.settle();
6945
6946 assert_eq!(
6947 after.entities.len(),
6948 1,
6949 "a vanished entity must stay in the snapshot, not disappear from it"
6950 );
6951 assert_vanished_with_stale_branch(&after.entities[0], &branch_name);
6952 }
6953
6954 #[test]
6958 fn a_vanished_entitys_action_receipt_survives_the_vanished_staleness_pass_untouched() {
6959 let dir = tempfile::tempdir().expect("temp dir");
6960 let root = root_of(&dir);
6961 let repo = root.join("repo");
6962 init_repo_with_a_commit(&repo);
6963
6964 let core = Core::start_discovered(spec(vec![root]));
6965 let key = core.snapshot().entities[0].key.clone();
6966 let receipt = crate::entity::ActionReceipt {
6967 label: Arc::from("reinstall"),
6968 steps: Arc::from(vec![crate::entity::StepResult {
6969 label: Arc::from("pnpm install"),
6970 outcome: crate::entity::StepOutcome::Ok,
6971 output: Arc::from(&b""[..]),
6972 elapsed: Duration::from_millis(1),
6973 elision: None,
6974 shell: false,
6975 interactive: false,
6976 }]),
6977 skip: None,
6978 finished_at: Timestamp::now(),
6979 running: None,
6980 };
6981 core.set_last_action_for_test(&key, receipt.clone());
6982
6983 fs::remove_dir_all(&repo).expect("remove the repo from disk");
6984 core.refresh(&[]);
6985 let after = core.settle();
6986
6987 let entity = &after.entities[0];
6988 assert_eq!(entity.presence, crate::entity::Presence::Vanished);
6989 assert_eq!(entity.last_action, Some(receipt));
6990 }
6991
6992 #[test]
7000 fn two_snapshots_of_an_entity_share_its_last_actions_label_and_steps_by_pointer() {
7001 let dir = tempfile::tempdir().expect("temp dir");
7002 let root = root_of(&dir);
7003 let repo = root.join("repo");
7004 init_repo_with_a_commit(&repo);
7005
7006 let core = Core::start_discovered(spec(vec![root]));
7007 let key = core.snapshot().entities[0].key.clone();
7008 let receipt = crate::entity::ActionReceipt {
7009 label: Arc::from("reinstall"),
7010 steps: Arc::from(vec![crate::entity::StepResult {
7011 label: Arc::from("pnpm install"),
7012 outcome: crate::entity::StepOutcome::Failed(1),
7013 output: Arc::from(&b""[..]),
7014 elapsed: Duration::from_millis(1),
7015 elision: None,
7016 shell: false,
7017 interactive: false,
7018 }]),
7019 skip: None,
7020 finished_at: Timestamp::now(),
7021 running: None,
7022 };
7023 core.set_last_action_for_test(&key, receipt);
7024
7025 let first = core.snapshot();
7026 let second = core.snapshot();
7027 let first_receipt = first.entities[0]
7028 .last_action
7029 .as_ref()
7030 .expect("receipt was set");
7031 let second_receipt = second.entities[0]
7032 .last_action
7033 .as_ref()
7034 .expect("receipt was set");
7035
7036 assert!(
7037 Arc::ptr_eq(&first_receipt.label, &second_receipt.label),
7038 "two snapshots of the same receipt must share the label's allocation, not \
7039 re-copy it"
7040 );
7041 assert!(
7042 Arc::ptr_eq(&first_receipt.steps, &second_receipt.steps),
7043 "two snapshots of the same receipt must share the steps slice's allocation, not \
7044 re-copy it, which is also what shares every step's own captured output"
7045 );
7046 }
7047
7048 #[test]
7054 fn a_submodule_removed_from_gitmodules_vanishes_by_the_same_rule_as_a_repo() {
7055 let dir = tempfile::tempdir().expect("temp dir");
7056 let root = root_of(&dir);
7057 let parent = root.join("parent");
7058 init_repo_with_a_commit(&parent);
7059 fs::write(
7060 parent.join(".gitmodules"),
7061 "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
7062 )
7063 .expect("write .gitmodules");
7064 let submodule_path = parent.join("vendor").join("lib");
7065 init_repo_with_a_commit(&submodule_path);
7066
7067 let mut core_spec = spec(vec![root]);
7070 core_spec.show_submodules = true;
7071 let core = Core::start_discovered(core_spec);
7072 let snapshot = core.snapshot();
7073 let submodule_key = snapshot
7074 .entities
7075 .iter()
7076 .find(|entity| matches!(entity.kind, Kind::Submodule))
7077 .expect("submodule discovered")
7078 .key
7079 .clone();
7080 core.refresh(std::slice::from_ref(&submodule_key));
7081 let before = core.settle();
7082 let submodule_before = before
7083 .entities
7084 .iter()
7085 .find(|entity| entity.key == submodule_key)
7086 .expect("submodule present");
7087 let branch_name = match submodule_before.branch.settled() {
7088 Some(Settled::Known {
7089 value: Head::Branch { name, .. },
7090 at: _,
7091 stale: _,
7092 }) => name.to_string(),
7093 other => {
7094 panic!("expected the submodule's first refresh to settle a branch, got {other:?}")
7095 }
7096 };
7097
7098 fs::write(parent.join(".gitmodules"), "").expect("clear .gitmodules");
7102
7103 core.refresh(&[]);
7104 let after = core.settle();
7105
7106 let submodule_after = after
7107 .entities
7108 .iter()
7109 .find(|entity| entity.key == submodule_key)
7110 .expect("the vanished submodule must stay in the snapshot");
7111 assert_vanished_with_stale_branch(submodule_after, &branch_name);
7112 }
7113
7114 #[test]
7119 fn dismissal_persists_nothing_across_a_fresh_core() {
7120 let dir = tempfile::tempdir().expect("temp dir");
7121 let root = root_of(&dir);
7122 let repo = root.join("repo");
7123 init_repo_with_a_commit(&repo);
7124
7125 let first_core = Core::start_discovered(spec(vec![root.clone()]));
7126 let key = first_core.snapshot().entities[0].key.clone();
7127 first_core.dismiss(&key);
7128 assert!(first_core.snapshot().entities.is_empty());
7129 drop(first_core);
7130
7131 let second_core = Core::start_discovered(spec(vec![root]));
7132 let snapshot = second_core.snapshot();
7133
7134 assert_eq!(
7135 snapshot.entities.len(),
7136 1,
7137 "a fresh Core must discover the repo again"
7138 );
7139 assert_eq!(
7140 snapshot.entities[0].presence,
7141 crate::entity::Presence::Present,
7142 "nothing from the dismissing Core's lifetime may be persisted, so the \
7143 repo must come back Present, never restored as Vanished"
7144 );
7145 }
7146
7147 #[test]
7151 fn a_repo_that_moves_reads_as_vanished_plus_new() {
7152 let dir = tempfile::tempdir().expect("temp dir");
7153 let root = root_of(&dir);
7154 let original_path = root.join("original-name");
7155 init_repo_with_a_commit(&original_path);
7156
7157 let core = Core::start_discovered(spec(vec![root.clone()]));
7158 let original_key = core.snapshot().entities[0].key.clone();
7159 core.refresh(std::slice::from_ref(&original_key));
7160 let before = core.settle();
7161 let branch_name = match before.entities[0].branch.settled() {
7162 Some(Settled::Known {
7163 value: Head::Branch { name, .. },
7164 at: _,
7165 stale: _,
7166 }) => name.to_string(),
7167 other => panic!("expected the first refresh to settle a branch, got {other:?}"),
7168 };
7169
7170 let moved_path = root.join("new-name");
7171 fs::rename(&original_path, &moved_path).expect("move the repo on disk");
7172
7173 core.refresh(&[]);
7174 let after = core.settle();
7175
7176 assert_eq!(
7177 after.entities.len(),
7178 2,
7179 "a moved entity must read as the old key vanished plus a new one present, \
7180 never as one renamed entity"
7181 );
7182 let old_entity = after
7183 .entities
7184 .iter()
7185 .find(|entity| entity.key == original_key)
7186 .expect("the old key must stay in the table");
7187 assert_vanished_with_stale_branch(old_entity, &branch_name);
7188 let new_entity = after
7189 .entities
7190 .iter()
7191 .find(|entity| entity.key != original_key)
7192 .expect("a new entity at the moved path must be present");
7193 assert_eq!(new_entity.presence, crate::entity::Presence::Present);
7194 assert_eq!(new_entity.key.path(), moved_path);
7195 }
7196
7197 #[test]
7201 fn a_vanished_repo_recreated_on_disk_reads_present_on_the_next_refresh() {
7202 let dir = tempfile::tempdir().expect("temp dir");
7203 let root = root_of(&dir);
7204 let repo = root.join("repo");
7205 init_repo_with_a_commit(&repo);
7206
7207 let core = Core::start_discovered(spec(vec![root]));
7208 let key = core.snapshot().entities[0].key.clone();
7209
7210 fs::remove_dir_all(&repo).expect("remove the repo from disk");
7211 core.refresh(&[]);
7212 let vanished = core.settle();
7213 assert_eq!(
7214 vanished.entities[0].presence,
7215 crate::entity::Presence::Vanished,
7216 "the repo must read Vanished once removed from disk"
7217 );
7218
7219 init_repo_with_a_commit(&repo);
7220 core.refresh(&[]);
7221 let recreated = core.settle();
7222
7223 let entity = recreated
7224 .entities
7225 .iter()
7226 .find(|entity| entity.key == key)
7227 .expect("the recreated repo must still resolve to the same entity key");
7228 assert_eq!(
7229 entity.presence,
7230 crate::entity::Presence::Present,
7231 "an entity discovery finds again after it vanished must read Present, \
7232 not stay stuck Vanished forever"
7233 );
7234 }
7235
7236 #[test]
7241 fn a_new_repo_created_after_start_is_discovered_by_the_next_refresh() {
7242 let dir = tempfile::tempdir().expect("temp dir");
7243 let root = root_of(&dir);
7244 init_repo_with_a_commit(&root.join("first"));
7245
7246 let core = Core::start_discovered(spec(vec![root.clone()]));
7247 assert_eq!(core.snapshot().entities.len(), 1);
7248
7249 init_repo_with_a_commit(&root.join("second"));
7250 core.refresh(&[]);
7251 let after = core.settle();
7252
7253 assert_eq!(
7254 after.entities.len(),
7255 2,
7256 "a new repo created after start must be found by the next refresh's own discovery"
7257 );
7258
7259 let new_key = after
7262 .entities
7263 .iter()
7264 .find(|entity| &*entity.name == "second")
7265 .expect("the newly discovered repo must be named by the walk")
7266 .key
7267 .clone();
7268 core.refresh(std::slice::from_ref(&new_key));
7269 let probed = core.settle();
7270 let new_entity = probed
7271 .entities
7272 .iter()
7273 .find(|entity| entity.key == new_key)
7274 .expect("the newly discovered repo must still be present");
7275 assert!(
7276 matches!(
7277 new_entity.branch.settled(),
7278 Some(Settled::Known {
7279 value: _,
7280 at: _,
7281 stale: _
7282 })
7283 ),
7284 "a refresh naming the newly discovered repo's key must actually probe \
7285 it and settle its branch cell, got {:?}",
7286 new_entity.branch.settled()
7287 );
7288 }
7289
7290 #[test]
7295 fn an_abandoned_discovery_stops_riding_later_refreshes() {
7296 let dir = tempfile::tempdir().expect("temp dir");
7297 let root = root_of(&dir);
7298 let decoys = root.join("decoys");
7305 for i in 0..4_000 {
7306 fs::create_dir(decoys.join(format!("decoy-{i}")))
7307 .or_else(|_| fs::create_dir_all(decoys.join(format!("decoy-{i}"))))
7308 .expect("create decoy dir");
7309 }
7310 let (_tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
7311
7312 let started = Core::start_for_test_with_discovery_abandon(
7313 spec(vec![root.clone()]),
7314 Duration::from_secs(3600),
7315 Duration::from_micros(500),
7316 tick_rx,
7317 )
7318 .discovered();
7319 let core = started.core;
7320 assert!(
7321 core.discovery_manual_for_test(),
7322 "walking 4,000 decoy directories against a 500 microsecond deadline \
7323 must have abandoned and taken the Set manual"
7324 );
7325
7326 fs::remove_dir_all(&decoys).expect("remove decoy directories");
7331 init_repo_with_a_commit(&root.join("second"));
7332
7333 core.refresh(&[]);
7334 let after = core.settle();
7335
7336 assert!(
7337 !after
7338 .entities
7339 .iter()
7340 .any(|entity| &*entity.name == "second"),
7341 "once discovery has abandoned, a later refresh must not re-run it, so a \
7342 repo created afterward, on a tree that would now resolve quickly, \
7343 must still never appear"
7344 );
7345 }
7346
7347 #[test]
7356 fn a_refresh_triggered_discovery_abandon_sets_manual_and_warns() {
7357 let dir = tempfile::tempdir().expect("temp dir");
7358 let root = root_of(&dir);
7359 init_repo_with_a_commit(&root.join("first"));
7360 let (_tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
7361
7362 let started = Core::start_for_test_with_discovery_abandon(
7368 spec(vec![root.clone()]),
7369 Duration::from_secs(3600),
7370 Duration::from_secs(3600),
7371 tick_rx,
7372 )
7373 .discovered();
7374 let core = started.core;
7375 assert!(
7376 !core.discovery_manual_for_test(),
7377 "an hour-long deadline must leave the first walk automatic"
7378 );
7379
7380 let decoys = root.join("decoys");
7384 for i in 0..4_000 {
7385 fs::create_dir(decoys.join(format!("decoy-{i}")))
7386 .or_else(|_| fs::create_dir_all(decoys.join(format!("decoy-{i}"))))
7387 .expect("create decoy dir");
7388 }
7389 core.set_discovery_abandon_after_for_test(Duration::from_micros(500));
7390
7391 core.refresh(&[]);
7392 core.wait_dispatched_for_test();
7395
7396 assert!(
7397 core.discovery_manual_for_test(),
7398 "refresh's own rerun_discovery must abandon against the newly-grown \
7399 tree and take the Set manual, the same as an abandon at start does"
7400 );
7401 let warning = core.discovery_warning();
7402 assert!(
7403 warning
7404 .as_deref()
7405 .is_some_and(|message| message.starts_with("discovery: stopped at")),
7406 "refresh's rerun_discovery must leave the abandoned-discovery warning \
7407 behind, not merely flip the manual flag: got {warning:?}"
7408 );
7409 }
7410
7411 #[test]
7417 fn a_fresh_core_over_different_roots_is_unaffected_by_another_cores_abandoned_discovery() {
7418 let abandoned_dir = tempfile::tempdir().expect("temp dir");
7419 let abandoned_root = root_of(&abandoned_dir);
7420 init_repo_with_a_commit(&abandoned_root.join("first"));
7421 let (_tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
7422 let started = Core::start_for_test_with_discovery_abandon(
7423 spec(vec![abandoned_root]),
7424 Duration::from_secs(3600),
7425 Duration::ZERO,
7426 tick_rx,
7427 )
7428 .discovered();
7429 started.core.refresh(&[]);
7430 started.core.settle();
7431 assert!(
7432 started.core.discovery_manual_for_test(),
7433 "the zero-length abandon deadline must have already taken this Core manual"
7434 );
7435 drop(started.core);
7436
7437 let fresh_dir = tempfile::tempdir().expect("temp dir");
7438 let fresh_root = root_of(&fresh_dir);
7439 init_repo_with_a_commit(&fresh_root.join("first"));
7440 let fresh_core = Core::start_discovered(spec(vec![fresh_root.clone()]));
7441 assert_eq!(fresh_core.snapshot().entities.len(), 1);
7442
7443 init_repo_with_a_commit(&fresh_root.join("second"));
7444 fresh_core.refresh(&[]);
7445 let after = fresh_core.settle();
7446
7447 assert_eq!(
7448 after.entities.len(),
7449 2,
7450 "a fresh Core, standing in for the Set's roots changing, must discover \
7451 normally regardless of an earlier, unrelated Core having gone manual"
7452 );
7453 }
7454
7455 #[test]
7460 fn dropping_the_core_joins_the_dedicated_thread_before_returning() {
7461 let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
7462 let dir = tempfile::tempdir().expect("temp dir");
7463 let root = root_of(&dir);
7464
7465 let started =
7466 Core::start_for_test(spec(vec![root]), Duration::from_secs(3600), tick_rx).discovered();
7467 assert!(started.clock_alive.load(Ordering::Acquire));
7468
7469 drop(started.core);
7470
7471 assert!(
7472 !started.clock_alive.load(Ordering::Acquire),
7473 "the dedicated thread should have exited, and cleared this flag, before drop returned"
7474 );
7475 drop(tick_tx);
7476 }
7477
7478 #[test]
7483 fn the_deadline_sweep_runs_only_when_a_tick_arrives() {
7484 let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
7485 let dir = tempfile::tempdir().expect("temp dir");
7486 let root = root_of(&dir);
7487 let repo = root.join("repo");
7488 init_repo_with_a_commit(&repo);
7489
7490 let mut spec = spec(vec![root]);
7491 spec.generation_deadline = Duration::ZERO;
7492 let started = Core::start_for_test(spec, Duration::from_secs(3600), tick_rx).discovered();
7493 let core = started.core;
7494 let key = settle_launch(&core).entities[0].key.clone();
7497
7498 core.begin_untracked_probe_for_test(&key);
7499
7500 let before = core.snapshot();
7503 assert!(
7504 matches!(
7505 before.entities[0].branch.settled(),
7506 Some(Settled::Known {
7507 value: _,
7508 at: _,
7509 stale: _
7510 })
7511 ),
7512 "the cell still holds launch's own answer here, so the Unknown below is the \
7513 sweep's write rather than a cell that was already empty"
7514 );
7515 assert!(before.entities[0].branch.is_in_flight());
7516
7517 tick_tx.send(Instant::now()).expect("send one tick");
7518 let after = core.settle();
7519
7520 assert!(matches!(
7521 after.entities[0].branch.settled(),
7522 Some(Settled::Unknown(Unknown::TimedOut))
7523 ));
7524 }
7525
7526 #[test]
7535 fn a_real_tick_through_the_dedicated_thread_reaches_the_poll_sweep_and_reprobes_a_moved_entity()
7536 {
7537 let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
7538 let dir = tempfile::tempdir().expect("temp dir");
7539 let root = root_of(&dir);
7540 let repo = root.join("repo");
7541 init_repo_with_a_commit(&repo);
7542
7543 let started =
7544 Core::start_for_test(spec(vec![root]), Duration::from_secs(3600), tick_rx).discovered();
7545 let core = started.core;
7546 let key = core.snapshot().entities[0].key.clone();
7547
7548 backdate_polled_entries(&repo);
7549
7550 tick_tx
7553 .send(Instant::now())
7554 .expect("send the baseline tick");
7555 wait_for(
7556 "a tick sent on the real channel to reach the poll sweep",
7557 || core.poll_sweep_count_for_test() >= 1,
7558 );
7559 assert!(core.poll_reprobed_for_test().is_empty());
7560
7561 commit_a_change(&repo, "second");
7562
7563 tick_tx
7564 .send(Instant::now())
7565 .expect("send the movement tick");
7566 wait_for(
7567 "the real tick channel to reach the poll sweep and reprobe the moved entity",
7568 || core.poll_reprobed_for_test() == vec![key.clone()],
7569 );
7570 drop(tick_tx);
7571 }
7572
7573 #[test]
7581 fn poll_reprobe_touches_only_the_moved_entity_and_never_runs_a_status_probe() {
7582 let dir = tempfile::tempdir().expect("temp dir");
7583 let root = root_of(&dir);
7584 let repo_a = root.join("repo-a");
7585 let repo_b = root.join("repo-b");
7586 init_repo_with_a_commit(&repo_a);
7587 init_repo_with_a_commit(&repo_b);
7588
7589 let core = Core::start_discovered(spec(vec![root]));
7590 let snapshot = core.snapshot();
7591 let key_a = snapshot
7592 .entities
7593 .iter()
7594 .find(|entity| entity.key.path() == repo_a)
7595 .expect("repo-a discovered")
7596 .key
7597 .clone();
7598 let key_b = snapshot
7599 .entities
7600 .iter()
7601 .find(|entity| entity.key.path() == repo_b)
7602 .expect("repo-b discovered")
7603 .key
7604 .clone();
7605
7606 core.refresh(&[key_a.clone(), key_b.clone()]);
7607 let landed = core.settle();
7608 let entity_of = |snapshot: &Snapshot, key: &EntityKey| {
7609 snapshot
7610 .entities
7611 .iter()
7612 .find(|entity| &entity.key == key)
7613 .expect("entity present")
7614 .clone()
7615 };
7616 let a_before = entity_of(&landed, &key_a);
7617 let b_before = entity_of(&landed, &key_b);
7618 let branch_at = |entity: &EntityState| match entity.branch.settled() {
7619 Some(Settled::Known {
7620 at,
7621 value: _,
7622 stale: _,
7623 }) => *at,
7624 other => panic!("expected a landed branch, got {other:?}"),
7625 };
7626 let dirty_state = |entity: &EntityState| match entity.dirty.settled() {
7627 Some(Settled::Known { value, at, stale }) => (*value, *at, *stale),
7628 other => panic!("expected a landed dirty count, got {other:?}"),
7629 };
7630 let (a_dirty_value_before, a_dirty_at_before, a_dirty_stale_before) =
7631 dirty_state(&a_before);
7632 assert!(
7633 !a_dirty_stale_before,
7634 "the fresh refresh must land dirty as not stale"
7635 );
7636
7637 backdate_polled_entries(&repo_a);
7638
7639 backdate_polled_entries(&repo_b);
7640
7641 core.poll_once_for_test();
7642 assert!(
7643 core.poll_reprobed_for_test().is_empty(),
7644 "a first sweep has nothing to compare against, so it must report no movement"
7645 );
7646
7647 commit_a_change(&repo_a, "second");
7648 core.poll_once_for_test();
7649
7650 assert_eq!(
7651 core.poll_reprobed_for_test(),
7652 vec![key_a.clone()],
7653 "only the entity whose gitdir actually moved must be re-probed"
7654 );
7655
7656 let after = core.snapshot();
7657 let a_after = entity_of(&after, &key_a);
7658 let b_after = entity_of(&after, &key_b);
7659
7660 assert_ne!(
7661 branch_at(&a_after),
7662 branch_at(&a_before),
7663 "the moved entity's branch must carry a fresh timestamp from the re-probe"
7664 );
7665 let (a_dirty_value_after, a_dirty_at_after, a_dirty_stale_after) = dirty_state(&a_after);
7666 assert_eq!(
7667 a_dirty_value_after, a_dirty_value_before,
7668 "no status probe ran, so dirty's value must be exactly what the last real refresh \
7669 landed"
7670 );
7671 assert_eq!(
7672 a_dirty_at_after, a_dirty_at_before,
7673 "no status probe ran, so dirty's timestamp must be untouched, only its stale flag \
7674 set"
7675 );
7676 assert!(
7677 a_dirty_stale_after,
7678 "the moved entity's dirty cell must go stale on poll evidence"
7679 );
7680
7681 assert_eq!(
7682 branch_at(&b_after),
7683 branch_at(&b_before),
7684 "the untouched entity's branch must be exactly as the prior refresh left it"
7685 );
7686 let (b_dirty_value_after, b_dirty_at_after, b_dirty_stale_after) = dirty_state(&b_after);
7687 let (b_dirty_value_before, b_dirty_at_before, b_dirty_stale_before) =
7688 dirty_state(&b_before);
7689 assert_eq!(b_dirty_value_after, b_dirty_value_before);
7690 assert_eq!(b_dirty_at_after, b_dirty_at_before);
7691 assert_eq!(
7692 b_dirty_stale_after, b_dirty_stale_before,
7693 "an entity the sweep found unmoved must never go stale"
7694 );
7695 }
7696
7697 #[test]
7702 fn poll_detects_an_attached_commit_through_index_while_head_itself_never_moves() {
7703 let dir = tempfile::tempdir().expect("temp dir");
7704 let root = root_of(&dir);
7705 let repo = root.join("repo");
7706 init_repo_with_a_commit(&repo);
7707
7708 let core = Core::start_discovered(spec(vec![root]));
7709 let key = core.snapshot().entities[0].key.clone();
7710 backdate_polled_entries(&repo);
7711 core.poll_once_for_test();
7712 assert!(core.poll_reprobed_for_test().is_empty());
7713
7714 let head_path = repo.join(".git").join("HEAD");
7715 let head_mtime_before = fs::metadata(&head_path)
7716 .expect("stat HEAD")
7717 .modified()
7718 .expect("HEAD mtime");
7719
7720 commit_a_change(&repo, "second");
7721
7722 let head_mtime_after = fs::metadata(&head_path)
7723 .expect("stat HEAD")
7724 .modified()
7725 .expect("HEAD mtime");
7726 assert_eq!(
7727 head_mtime_before, head_mtime_after,
7728 "a commit on an attached HEAD must never touch HEAD itself"
7729 );
7730
7731 core.poll_once_for_test();
7732 assert_eq!(
7733 core.poll_reprobed_for_test(),
7734 vec![key],
7735 "the poll must still detect the attached commit, through index rather than HEAD"
7736 );
7737 }
7738
7739 #[test]
7746 fn poll_detects_a_detached_commit_through_the_per_worktree_head_file() {
7747 let dir = tempfile::tempdir().expect("temp dir");
7748 let root = root_of(&dir);
7749 let parent = root.join("parent");
7750 init_repo_with_a_commit(&parent);
7751 let worktree_path = root.join("detached-worktree");
7752 let status = Command::new("git")
7753 .arg("-C")
7754 .arg(&parent)
7755 .args([
7756 "worktree",
7757 "add",
7758 "--detach",
7759 worktree_path.to_str().expect("utf8 path"),
7760 ])
7761 .status()
7762 .expect("run git worktree add");
7763 assert!(status.success());
7764
7765 let core = Core::start_discovered(spec(vec![root]));
7766 let snapshot = core.snapshot();
7767 let worktree_key = snapshot
7768 .entities
7769 .iter()
7770 .find(|entity| matches!(entity.kind, Kind::Worktree))
7771 .expect("worktree discovered")
7772 .key
7773 .clone();
7774
7775 backdate_polled_entries(&parent);
7776 backdate_polled_entries(&worktree_path);
7777
7778 core.poll_once_for_test();
7779 assert!(core.poll_reprobed_for_test().is_empty());
7780
7781 let worktree_head_path = parent
7782 .join(".git")
7783 .join("worktrees")
7784 .join("detached-worktree")
7785 .join("HEAD");
7786 let head_mtime_before = fs::metadata(&worktree_head_path)
7787 .expect("stat the per-worktree HEAD")
7788 .modified()
7789 .expect("HEAD mtime");
7790
7791 commit_a_change(&worktree_path, "on the detached worktree");
7792
7793 let head_mtime_after = fs::metadata(&worktree_head_path)
7794 .expect("stat the per-worktree HEAD")
7795 .modified()
7796 .expect("HEAD mtime");
7797 assert_ne!(
7798 head_mtime_before, head_mtime_after,
7799 "a commit on a detached HEAD must write the new object id straight into its own \
7800 HEAD file"
7801 );
7802
7803 core.poll_once_for_test();
7804 assert_eq!(
7805 core.poll_reprobed_for_test(),
7806 vec![worktree_key],
7807 "the poll must detect the detached commit via the per-worktree HEAD file"
7808 );
7809 }
7810
7811 #[test]
7818 fn snapshot_ages_a_freshly_landed_dirty_cell_stale_once_status_stale_after_has_elapsed() {
7819 let dir = tempfile::tempdir().expect("temp dir");
7820 let root = root_of(&dir);
7821 let repo = root.join("repo");
7822 init_repo_with_a_commit(&repo);
7823
7824 let mut short_lived = spec(vec![root]);
7825 short_lived.status_stale_after = Duration::from_nanos(1);
7826 let core = Core::start_discovered(short_lived);
7827 let key = core.snapshot().entities[0].key.clone();
7828 core.refresh(std::slice::from_ref(&key));
7829 core.settle();
7830
7831 let aged = core.snapshot();
7832 match aged.entities[0].dirty.settled() {
7833 Some(Settled::Known {
7834 stale: true,
7835 value: _,
7836 at: _,
7837 }) => {}
7838 other => panic!(
7839 "expected a landed dirty cell to have already aged past a one-nanosecond \
7840 threshold, got {other:?}"
7841 ),
7842 }
7843 }
7844
7845 #[test]
7849 fn snapshot_leaves_a_freshly_landed_dirty_cell_fresh_under_a_large_status_stale_after() {
7850 let dir = tempfile::tempdir().expect("temp dir");
7851 let root = root_of(&dir);
7852 let repo = root.join("repo");
7853 init_repo_with_a_commit(&repo);
7854
7855 let core = Core::start_discovered(spec(vec![root]));
7856 let key = core.snapshot().entities[0].key.clone();
7857 core.refresh(std::slice::from_ref(&key));
7858 core.settle();
7859
7860 let fresh = core.snapshot();
7861 match fresh.entities[0].dirty.settled() {
7862 Some(Settled::Known {
7863 stale: false,
7864 value: _,
7865 at: _,
7866 }) => {}
7867 other => panic!("expected a freshly landed dirty cell to stay fresh, got {other:?}"),
7868 }
7869 }
7870
7871 #[test]
7877 fn hidden_submodules_are_never_polled_but_shown_ones_are() {
7878 let dir = tempfile::tempdir().expect("temp dir");
7879 let root = root_of(&dir);
7880 let parent = root.join("parent");
7881 init_repo_with_a_commit(&parent);
7882 fs::write(
7883 parent.join(".gitmodules"),
7884 "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
7885 )
7886 .expect("write .gitmodules");
7887 let submodule_path = parent.join("vendor").join("lib");
7888 init_repo_with_a_commit(&submodule_path);
7889
7890 let mut hidden_spec = spec(vec![root.clone()]);
7891 hidden_spec.show_submodules = false;
7892 let hidden_core = Core::start_discovered(hidden_spec);
7893 let hidden_submodule_key = hidden_core
7898 .snapshot()
7899 .entities
7900 .iter()
7901 .find(|entity| matches!(entity.kind, Kind::Submodule))
7902 .expect("the submodule is discovered regardless of show_submodules")
7903 .key
7904 .clone();
7905 backdate_polled_entries(&submodule_path);
7906 hidden_core.poll_once_for_test();
7907 commit_a_change(&submodule_path, "into the hidden submodule");
7908 hidden_core.poll_once_for_test();
7909 assert!(
7910 !hidden_core
7911 .poll_reprobed_for_test()
7912 .contains(&hidden_submodule_key),
7913 "a hidden Submodule must never be re-probed by the poll, since it was never \
7914 polled at all"
7915 );
7916 drop(hidden_core);
7917
7918 let mut shown_spec = spec(vec![root]);
7919 shown_spec.show_submodules = true;
7920 let shown_core = Core::start_discovered(shown_spec);
7921 let submodule_key = shown_core
7922 .snapshot()
7923 .entities
7924 .iter()
7925 .find(|entity| matches!(entity.kind, Kind::Submodule))
7926 .expect("the submodule is discovered regardless of show_submodules")
7927 .key
7928 .clone();
7929 backdate_polled_entries(&submodule_path);
7930 shown_core.poll_once_for_test();
7931 commit_a_change(&submodule_path, "into the shown submodule");
7932 shown_core.poll_once_for_test();
7933 assert_eq!(
7934 shown_core.poll_reprobed_for_test(),
7935 vec![submodule_key],
7936 "a shown Submodule must be polled and re-probed exactly like any other row"
7937 );
7938 }
7939
7940 #[test]
7946 fn pause_cancels_every_in_flight_entity_and_releases_a_pending_settle() {
7947 let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
7948 let dir = tempfile::tempdir().expect("temp dir");
7949 let root = root_of(&dir);
7950 let repo = root.join("repo");
7951 init_repo_with_a_commit(&repo);
7952
7953 let started =
7954 Core::start_for_test(spec(vec![root]), Duration::from_secs(3600), tick_rx).discovered();
7955 let core = started.core;
7956 let key = settle_launch(&core).entities[0].key.clone();
7958 let cancel = core.begin_untracked_probe_for_test(&key);
7959 assert!(!cancel.load(Ordering::Acquire));
7960
7961 core.pause();
7962 let settled = core.settle();
7963
7964 assert!(
7965 cancel.load(Ordering::Acquire),
7966 "pause should cancel the entity that was in flight"
7967 );
7968 assert!(settled.entities[0].branch.is_in_flight());
7969 drop(tick_tx);
7970 }
7971
7972 #[test]
7981 fn a_launch_is_one_generation_over_every_row_its_own_walk_found() {
7982 let dir = tempfile::tempdir().expect("temp dir");
7983 let root = root_of(&dir);
7984 init_repo_with_a_commit(&root.join("first"));
7985 init_repo_with_a_commit(&root.join("second"));
7986
7987 let (_core, launched) = started_and_settled(spec(vec![root]));
7988
7989 assert_eq!(
7990 launched.generation,
7991 Generation::default().successor(),
7992 "a launch must settle on the first Generation a fresh `Core` mints; a second \
7993 walk of the same tree would be a second Generation"
7994 );
7995 let mut named: Vec<String> = launched
7996 .entities
7997 .iter()
7998 .filter(|entity| entity.branch.settled().is_some())
7999 .map(|entity| entity.name.to_string())
8000 .collect();
8001 named.sort();
8002 assert_eq!(
8003 named,
8004 vec!["first".to_string(), "second".to_string()],
8005 "that one Generation must cover every row its own walk found, or the walk it \
8006 saved would have to be paid by a second one"
8007 );
8008 }
8009
8010 #[test]
8017 fn dropping_a_core_cancels_every_entity_it_still_has_in_flight() {
8018 let dir = tempfile::tempdir().expect("temp dir");
8019 let root = root_of(&dir);
8020 init_repo_with_a_commit(&root.join("repo"));
8021
8022 let (core, launched) = started_and_settled(spec(vec![root]));
8023 let key = launched.entities[0].key.clone();
8024 let cancel = core.begin_untracked_probe_for_test(&key);
8025 assert!(!cancel.load(Ordering::Acquire));
8026
8027 drop(core);
8028
8029 assert!(
8030 cancel.load(Ordering::Acquire),
8031 "a dropped Core must cancel the Generation it still has in flight rather than \
8032 leave it running against a Set nothing will read again"
8033 );
8034 }
8035
8036 #[test]
8066 fn a_selection_scoped_refresh_supersedes_only_the_entity_it_covers() {
8067 let dir = tempfile::tempdir().expect("temp dir");
8068 let root = root_of(&dir);
8069 init_repo_with_a_commit(&root.join("a"));
8070 init_repo_with_a_commit(&root.join("b"));
8071
8072 let (core, snapshot) = started_and_settled(spec(vec![root]));
8073 let key_a = snapshot
8074 .entities
8075 .iter()
8076 .find(|entity| &*entity.name == "a")
8077 .expect("entity a discovered")
8078 .key
8079 .clone();
8080 let key_b = snapshot
8081 .entities
8082 .iter()
8083 .find(|entity| &*entity.name == "b")
8084 .expect("entity b discovered")
8085 .key
8086 .clone();
8087
8088 let older = core.begin_shared_generation_for_test(&[key_a.clone(), key_b.clone()]);
8092
8093 let newer = core.refresh(std::slice::from_ref(&key_a));
8096 assert_eq!(
8097 newer,
8098 older.generation.successor(),
8099 "the Selection-scoped refresh must be the Generation immediately after the one \
8100 still in flight, with nothing minted in between"
8101 );
8102
8103 core.wait_dispatched_for_test();
8108 assert!(
8109 older.cancels[&key_a].load(Ordering::Acquire),
8110 "the entity the new Generation covers must have its old interrupt flag set"
8111 );
8112 assert!(
8113 !older.cancels[&key_b].load(Ordering::Acquire),
8114 "an entity the new Generation does not cover must be left running, untouched"
8115 );
8116
8117 let after_refresh = core.settle();
8121
8122 let a_after_gen2 = after_refresh
8123 .entities
8124 .iter()
8125 .find(|entity| entity.key == key_a)
8126 .expect("entity a present");
8127 assert!(
8128 matches!(
8129 a_after_gen2.branch.settled(),
8130 Some(Settled::Known {
8131 value: Head::Branch { .. },
8132 at: _,
8133 stale: _
8134 })
8135 ),
8136 "the newer Generation's real probe should have written A's cell by now"
8137 );
8138
8139 core.apply_probe_result_for_test(
8143 &key_a,
8144 older.generation,
8145 Settled::Known {
8146 value: Head::Branch {
8147 name: Arc::from("stale-from-generation-one"),
8148 commit: gix::hash::Kind::Sha1.null(),
8149 },
8150 at: Timestamp::now(),
8151 stale: false,
8152 },
8153 );
8154 let after_stale_write = core.snapshot();
8155 let a_final = after_stale_write
8156 .entities
8157 .iter()
8158 .find(|entity| entity.key == key_a)
8159 .expect("entity a present");
8160 match a_final.branch.settled() {
8161 Some(Settled::Known {
8162 value: Head::Branch { name, .. },
8163 at: _,
8164 stale: _,
8165 }) => assert_ne!(
8166 &**name, "stale-from-generation-one",
8167 "a lower-Generation result must be dropped at the cell it would write"
8168 ),
8169 other => panic!("expected A to still hold the newer Generation's value, got {other:?}"),
8170 }
8171
8172 core.apply_probe_result_for_test(
8175 &key_b,
8176 older.generation,
8177 Settled::Known {
8178 value: Head::Branch {
8179 name: Arc::from("b-generation-one-result"),
8180 commit: gix::hash::Kind::Sha1.null(),
8181 },
8182 at: Timestamp::now(),
8183 stale: false,
8184 },
8185 );
8186 let final_snapshot = core.snapshot();
8187 let b_final = final_snapshot
8188 .entities
8189 .iter()
8190 .find(|entity| entity.key == key_b)
8191 .expect("entity b present");
8192 match b_final.branch.settled() {
8193 Some(Settled::Known {
8194 value: Head::Branch { name, .. },
8195 at: _,
8196 stale: _,
8197 }) => assert_eq!(
8198 &**name, "b-generation-one-result",
8199 "an entity the new Generation never covered must still accept its own result"
8200 ),
8201 other => {
8202 panic!("expected B's un-superseded older result to be accepted, got {other:?}")
8203 }
8204 }
8205 }
8206
8207 #[test]
8212 fn the_deadline_sweep_keeps_already_settled_cells_and_only_times_out_what_is_still_loading() {
8213 let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
8214 let dir = tempfile::tempdir().expect("temp dir");
8215 let root = root_of(&dir);
8216 init_repo_with_a_commit(&root.join("a"));
8217 init_repo_with_a_commit(&root.join("b"));
8218
8219 let mut spec = spec(vec![root]);
8220 spec.generation_deadline = Duration::ZERO;
8221 let started = Core::start_for_test(spec, Duration::from_secs(3600), tick_rx).discovered();
8222 let core = started.core;
8223 let snapshot = settle_launch(&core);
8226 let key_a = snapshot
8227 .entities
8228 .iter()
8229 .find(|entity| &*entity.name == "a")
8230 .expect("entity a discovered")
8231 .key
8232 .clone();
8233 let key_b = snapshot
8234 .entities
8235 .iter()
8236 .find(|entity| &*entity.name == "b")
8237 .expect("entity b discovered")
8238 .key
8239 .clone();
8240
8241 let a_settled = core.probe_now(&key_a);
8244 let a_value_before = match a_settled.branch.settled() {
8245 Some(Settled::Known {
8246 value: Head::Branch { name, .. },
8247 at: _,
8248 stale: _,
8249 }) => Arc::clone(name),
8250 other => panic!("expected A's synchronous probe to settle a branch, got {other:?}"),
8251 };
8252
8253 let cancel_b = core.begin_untracked_probe_for_test(&key_b);
8257 let before_tick = core.snapshot();
8258 let b_before = before_tick
8259 .entities
8260 .iter()
8261 .find(|entity| entity.key == key_b)
8262 .expect("entity b present");
8263 assert!(
8264 b_before.branch.is_in_flight(),
8265 "B must be mid-flight when the sweep fires; that is the only shape the sweep \
8266 may touch"
8267 );
8268 assert!(
8269 matches!(
8270 b_before.branch.settled(),
8271 Some(Settled::Known {
8272 value: _,
8273 at: _,
8274 stale: _
8275 })
8276 ),
8277 "B still carries launch's own answer here, so the Unknown below is a write the \
8278 sweep made rather than a cell that was already empty, got {:?}",
8279 b_before.branch.settled()
8280 );
8281
8282 tick_tx.send(Instant::now()).expect("send one tick");
8283 let after_sweep = core.settle();
8284
8285 let a_after = after_sweep
8286 .entities
8287 .iter()
8288 .find(|entity| entity.key == key_a)
8289 .expect("entity a present");
8290 match a_after.branch.settled() {
8291 Some(Settled::Known {
8292 value: Head::Branch { name, .. },
8293 at: _,
8294 stale: _,
8295 }) => assert_eq!(
8296 name, &a_value_before,
8297 "an already-settled cell must keep its value when the deadline sweep runs, not be blanked"
8298 ),
8299 other => panic!("expected A's settled value to survive the sweep, got {other:?}"),
8300 }
8301
8302 let b_after = after_sweep
8303 .entities
8304 .iter()
8305 .find(|entity| entity.key == key_b)
8306 .expect("entity b present");
8307 assert!(matches!(
8308 b_after.branch.settled(),
8309 Some(Settled::Unknown(Unknown::TimedOut))
8310 ));
8311 assert!(
8312 !cancel_b.load(Ordering::Acquire),
8313 "the deadline sweep marks a cell Unknown; it never sets the entity's own \
8314 cancel flag, since the underlying probe (nonexistent here) is left to keep running"
8315 );
8316 }
8317
8318 #[test]
8326 fn the_deadline_sweep_times_out_a_worktrees_outstanding_state_but_leaves_a_repos_not_applicable_one_alone()
8327 {
8328 let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
8329 let dir = tempfile::tempdir().expect("temp dir");
8330 let root = root_of(&dir);
8331 let parent = root.join("parent");
8332 init_repo_with_a_commit(&parent);
8333 let worktree_path = root.join("feature-worktree");
8334 git(
8335 &parent,
8336 &[
8337 "worktree",
8338 "add",
8339 "-b",
8340 "feature",
8341 worktree_path.to_str().expect("utf8 path"),
8342 ],
8343 );
8344
8345 let mut spec = spec(vec![root]);
8346 spec.generation_deadline = Duration::ZERO;
8347 let started = Core::start_for_test(spec, Duration::from_secs(3600), tick_rx).discovered();
8348 let core = started.core;
8349 let snapshot = settle_launch(&core);
8356 let repo_key = snapshot
8357 .entities
8358 .iter()
8359 .find(|entity| matches!(entity.kind, Kind::Repo))
8360 .expect("repo entity present")
8361 .key
8362 .clone();
8363 let worktree_key = snapshot
8364 .entities
8365 .iter()
8366 .find(|entity| matches!(entity.kind, Kind::Worktree))
8367 .expect("worktree entity present")
8368 .key
8369 .clone();
8370
8371 core.begin_untracked_probe_for_test(&repo_key);
8377 core.begin_untracked_probe_for_test(&worktree_key);
8378
8379 tick_tx.send(Instant::now()).expect("send one tick");
8380 let after_sweep = core.settle();
8381
8382 let worktree_after = after_sweep
8383 .entities
8384 .iter()
8385 .find(|entity| entity.key == worktree_key)
8386 .expect("worktree entity present");
8387 assert!(
8388 matches!(
8389 worktree_after.state.settled(),
8390 Some(Settled::Unknown(Unknown::TimedOut))
8391 ),
8392 "expected the outstanding state cell to time out, got {:?}",
8393 worktree_after.state.settled()
8394 );
8395
8396 let repo_after = after_sweep
8397 .entities
8398 .iter()
8399 .find(|entity| entity.key == repo_key)
8400 .expect("repo entity present");
8401 assert!(
8402 matches!(repo_after.state.settled(), Some(Settled::NotApplicable)),
8403 "a Repo's Not applicable state must survive the sweep untouched, got {:?}",
8404 repo_after.state.settled()
8405 );
8406 }
8407
8408 #[test]
8413 fn the_deadline_sweeps_poll_never_touches_an_entitys_action_receipt() {
8414 let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
8415 let dir = tempfile::tempdir().expect("temp dir");
8416 let root = root_of(&dir);
8417 let repo = root.join("repo");
8418 init_repo_with_a_commit(&repo);
8419
8420 let mut spec = spec(vec![root]);
8421 spec.generation_deadline = Duration::ZERO;
8422 let started = Core::start_for_test(spec, Duration::from_secs(3600), tick_rx).discovered();
8423 let core = started.core;
8424 let key = settle_launch(&core).entities[0].key.clone();
8426
8427 let receipt = crate::entity::ActionReceipt {
8428 label: Arc::from("reinstall"),
8429 steps: Arc::from(vec![crate::entity::StepResult {
8430 label: Arc::from("pnpm install"),
8431 outcome: crate::entity::StepOutcome::Ok,
8432 output: Arc::from(&b""[..]),
8433 elapsed: Duration::from_millis(1),
8434 elision: None,
8435 shell: false,
8436 interactive: false,
8437 }]),
8438 skip: None,
8439 finished_at: Timestamp::now(),
8440 running: None,
8441 };
8442 core.set_last_action_for_test(&key, receipt.clone());
8443
8444 core.begin_untracked_probe_for_test(&key);
8447 tick_tx.send(Instant::now()).expect("send one tick");
8448 let after = core.settle();
8449
8450 let entity = after
8451 .entities
8452 .iter()
8453 .find(|entity| entity.key == key)
8454 .expect("entity present");
8455 assert!(
8456 matches!(
8457 entity.branch.settled(),
8458 Some(Settled::Unknown(Unknown::TimedOut))
8459 ),
8460 "sanity check: the sweep must have actually timed out the in-flight cell, got {:?}",
8461 entity.branch.settled()
8462 );
8463 assert_eq!(entity.last_action, Some(receipt));
8464 }
8465
8466 #[test]
8476 fn a_cancelled_probe_never_opens_the_repository_at_all() {
8477 let cancel = AtomicBool::new(true);
8478
8479 let outcome = probe_branch(
8480 Path::new("/nonexistent/nowhere-at-all"),
8481 None,
8482 Kind::Repo,
8483 &cancel,
8484 );
8485
8486 assert!(
8487 outcome.is_none(),
8488 "a probe observing cancellation before its first read must do no work \
8489 at all, not attempt the read and fail having tried it"
8490 );
8491 }
8492
8493 #[test]
8503 fn classify_status_result_drops_an_error_once_cancel_reads_true() {
8504 let cancel = AtomicBool::new(true);
8505
8506 let outcome = classify_status_result(
8507 Err(crate::git::ProbeError::Status(Arc::from("boom"))),
8508 &cancel,
8509 );
8510
8511 assert!(
8512 outcome.is_none(),
8513 "an error alongside a cancel flag already set must read as cancelled, not \
8514 Failed, got {outcome:?}"
8515 );
8516 }
8517
8518 #[test]
8521 fn classify_status_result_settles_failed_when_cancel_never_fired() {
8522 let cancel = AtomicBool::new(false);
8523
8524 let outcome = classify_status_result(
8525 Err(crate::git::ProbeError::Status(Arc::from("boom"))),
8526 &cancel,
8527 );
8528
8529 assert!(
8530 matches!(outcome, Some(Settled::Failed(git::ProbeError::Status(_)))),
8531 "a genuine error with no cancellation must settle Failed, got {outcome:?}"
8532 );
8533 }
8534
8535 #[test]
8544 fn classify_status_result_drops_an_ok_once_cancel_reads_true() {
8545 let cancel = AtomicBool::new(true);
8546
8547 let outcome = classify_status_result(Ok(DirtyCounts::default()), &cancel);
8548
8549 assert!(
8550 outcome.is_none(),
8551 "an Ok value that raced ahead of a cancel flag now set must read as cancelled, \
8552 not be settled Known, got {outcome:?}"
8553 );
8554 }
8555
8556 #[test]
8559 fn classify_status_result_settles_known_when_cancel_never_fired() {
8560 let cancel = AtomicBool::new(false);
8561 let counts = DirtyCounts {
8562 modified: 1,
8563 untracked: 2,
8564 deleted: 3,
8565 };
8566
8567 let outcome = classify_status_result(Ok(counts), &cancel);
8568
8569 assert!(
8570 matches!(
8571 outcome,
8572 Some(Settled::Known {
8573 value,
8574 at: _,
8575 stale: _
8576 }) if value == counts
8577 ),
8578 "a genuine completed read with no cancellation must settle Known, got {outcome:?}"
8579 );
8580 }
8581
8582 #[test]
8588 fn a_linked_worktree_is_its_own_entity_and_never_doubles_as_a_repo() {
8589 let dir = tempfile::tempdir().expect("temp dir");
8590 let root = root_of(&dir);
8591 let parent = root.join("parent");
8592 init_repo_with_a_commit(&parent);
8593 let worktree_path = root.join("feature-worktree");
8594 let status = Command::new("git")
8595 .arg("-C")
8596 .arg(&parent)
8597 .args([
8598 "worktree",
8599 "add",
8600 "-b",
8601 "feature",
8602 worktree_path.to_str().expect("utf8 path"),
8603 ])
8604 .status()
8605 .expect("run git worktree add");
8606 assert!(status.success());
8607
8608 let core = Core::start_discovered(spec(vec![root]));
8609 let snapshot = core.snapshot();
8610
8611 assert_eq!(
8612 snapshot.entities.len(),
8613 2,
8614 "expected the parent plus one Worktree, not two Repos"
8615 );
8616 let repo_count = snapshot
8617 .entities
8618 .iter()
8619 .filter(|entity| matches!(entity.kind, Kind::Repo))
8620 .count();
8621 let worktree_count = snapshot
8622 .entities
8623 .iter()
8624 .filter(|entity| matches!(entity.kind, Kind::Worktree))
8625 .count();
8626 assert_eq!(
8627 repo_count, 1,
8628 "the parent must be counted as exactly one Repo"
8629 );
8630 assert_eq!(
8631 worktree_count, 1,
8632 "the linked worktree must be counted as exactly one Worktree"
8633 );
8634
8635 let worktree_entity = snapshot
8636 .entities
8637 .iter()
8638 .find(|entity| matches!(entity.kind, Kind::Worktree))
8639 .expect("worktree entity present");
8640 let repo_entity = snapshot
8641 .entities
8642 .iter()
8643 .find(|entity| matches!(entity.kind, Kind::Repo))
8644 .expect("repo entity present");
8645 assert_eq!(worktree_entity.common_dir, repo_entity.common_dir);
8646
8647 let repo_branch = core.probe_now(&repo_entity.key);
8650 let worktree_branch = core.probe_now(&worktree_entity.key);
8651 match (
8652 repo_branch.branch.settled(),
8653 worktree_branch.branch.settled(),
8654 ) {
8655 (
8656 Some(Settled::Known {
8657 value:
8658 Head::Branch {
8659 name: repo_name, ..
8660 },
8661 at: _,
8662 stale: _,
8663 }),
8664 Some(Settled::Known {
8665 value:
8666 Head::Branch {
8667 name: worktree_name,
8668 ..
8669 },
8670 at: _,
8671 stale: _,
8672 }),
8673 ) => {
8674 assert_ne!(repo_name, worktree_name);
8675 assert_eq!(&**worktree_name, "feature");
8676 }
8677 other => panic!("expected both entities to read an attached branch, got {other:?}"),
8678 }
8679 }
8680
8681 #[test]
8685 fn a_worktrees_branch_that_is_an_ancestor_of_the_default_branch_reads_merged_after_a_refresh() {
8686 let dir = tempfile::tempdir().expect("temp dir");
8687 let root = root_of(&dir);
8688 let parent = root.join("parent");
8689 init_repo_with_a_commit(&parent);
8690 git(
8691 &parent,
8692 &[
8693 "remote",
8694 "add",
8695 "origin",
8696 "https://example.invalid/repo.git",
8697 ],
8698 );
8699 let sha = head_sha(&parent);
8700 git(&parent, &["update-ref", "refs/remotes/origin/main", &sha]);
8701 let worktree_path = root.join("feature-worktree");
8702 git(
8703 &parent,
8704 &[
8705 "worktree",
8706 "add",
8707 "-b",
8708 "feature",
8709 worktree_path.to_str().expect("utf8 path"),
8710 ],
8711 );
8712
8713 let core = Core::start_discovered(spec(vec![root]));
8714 let keys: Vec<EntityKey> = core
8715 .snapshot()
8716 .entities
8717 .iter()
8718 .map(|entity| entity.key.clone())
8719 .collect();
8720
8721 core.refresh(&keys);
8722 let settled = core.settle();
8723
8724 let worktree_entity = settled
8725 .entities
8726 .iter()
8727 .find(|entity| matches!(entity.kind, Kind::Worktree))
8728 .expect("worktree entity present");
8729 assert!(
8730 matches!(
8731 worktree_entity.state.settled(),
8732 Some(Settled::Known {
8733 value: WorktreeState::Merged,
8734 at: _,
8735 stale: _
8736 })
8737 ),
8738 "expected the worktree, at the same commit as the default branch, to read Merged, got {:?}",
8739 worktree_entity.state.settled()
8740 );
8741 }
8742
8743 #[test]
8752 fn a_squash_merged_worktree_branch_reads_merged_after_a_refresh() {
8753 let dir = tempfile::tempdir().expect("temp dir");
8754 let root = root_of(&dir);
8755 let parent = root.join("parent");
8756 init_repo_with_a_commit(&parent);
8757 git(
8758 &parent,
8759 &[
8760 "remote",
8761 "add",
8762 "origin",
8763 "https://example.invalid/repo.git",
8764 ],
8765 );
8766 let worktree_path = root.join("feature-worktree");
8767 git(
8768 &parent,
8769 &[
8770 "worktree",
8771 "add",
8772 "-b",
8773 "feature",
8774 worktree_path.to_str().expect("utf8 path"),
8775 ],
8776 );
8777 fs::write(worktree_path.join("a.txt"), "one\n").expect("write a.txt");
8778 git(&worktree_path, &["add", "a.txt"]);
8779 git(&worktree_path, &["commit", "-m", "add a"]);
8780 fs::write(worktree_path.join("b.txt"), "two\n").expect("write b.txt");
8781 git(&worktree_path, &["add", "b.txt"]);
8782 git(&worktree_path, &["commit", "-m", "add b"]);
8783 let feature_sha = head_sha(&worktree_path);
8784
8785 git(&parent, &["merge", "--squash", "feature"]);
8788 git(&parent, &["commit", "-m", "squashed feature"]);
8789 let main_sha = head_sha(&parent);
8790 git(
8791 &parent,
8792 &["update-ref", "refs/remotes/origin/main", &main_sha],
8793 );
8794
8795 git(&parent, &["config", "branch.feature.remote", "origin"]);
8798 git(
8799 &parent,
8800 &["config", "branch.feature.merge", "refs/heads/feature"],
8801 );
8802 git(
8803 &parent,
8804 &["update-ref", "refs/remotes/origin/feature", &feature_sha],
8805 );
8806
8807 let core = Core::start_discovered(spec(vec![root]));
8808 let keys: Vec<EntityKey> = core
8809 .snapshot()
8810 .entities
8811 .iter()
8812 .map(|entity| entity.key.clone())
8813 .collect();
8814
8815 core.refresh(&keys);
8816 let settled = core.settle();
8817
8818 let worktree_entity = settled
8819 .entities
8820 .iter()
8821 .find(|entity| matches!(entity.kind, Kind::Worktree))
8822 .expect("worktree entity present");
8823 assert!(
8824 matches!(
8825 worktree_entity.state.settled(),
8826 Some(Settled::Known {
8827 value: WorktreeState::Merged,
8828 at: _,
8829 stale: _
8830 })
8831 ),
8832 "expected a squash-merged worktree branch to read Merged, got {:?}",
8833 worktree_entity.state.settled()
8834 );
8835 }
8836
8837 #[test]
8845 fn patch_equivalence_never_runs_for_an_entity_ancestry_already_settled() {
8846 let dir = tempfile::tempdir().expect("temp dir");
8847 let root = root_of(&dir);
8848 let parent = root.join("parent");
8849 init_repo_with_a_commit(&parent);
8850 git(
8851 &parent,
8852 &[
8853 "remote",
8854 "add",
8855 "origin",
8856 "https://example.invalid/repo.git",
8857 ],
8858 );
8859 let sha = head_sha(&parent);
8860 git(&parent, &["update-ref", "refs/remotes/origin/main", &sha]);
8861 let worktree_path = root.join("feature-worktree");
8862 git(
8863 &parent,
8864 &[
8865 "worktree",
8866 "add",
8867 "-b",
8868 "feature",
8869 worktree_path.to_str().expect("utf8 path"),
8870 ],
8871 );
8872
8873 let (core, launched) = started_and_settled(spec(vec![root]));
8874 let keys: Vec<EntityKey> = launched
8875 .entities
8876 .iter()
8877 .map(|entity| entity.key.clone())
8878 .collect();
8879
8880 core.refresh(&keys);
8881 let settled = core.settle();
8882
8883 let worktree_entity = settled
8884 .entities
8885 .iter()
8886 .find(|entity| matches!(entity.kind, Kind::Worktree))
8887 .expect("worktree entity present");
8888 assert!(
8889 matches!(
8890 worktree_entity.state.settled(),
8891 Some(Settled::Known {
8892 value: WorktreeState::Merged,
8893 at: _,
8894 stale: _
8895 })
8896 ),
8897 "expected ancestry alone to settle Merged here, got {:?}",
8898 worktree_entity.state.settled()
8899 );
8900 assert_eq!(
8901 core.patch_identity_reads_for_test(),
8902 0,
8903 "ancestry already settled this entity, so patch equivalence's shared \
8904 scan must never run for its common dir at all"
8905 );
8906 }
8907
8908 #[test]
8916 fn a_full_refresh_reaching_patch_equivalence_writes_no_loose_objects() {
8917 let dir = tempfile::tempdir().expect("temp dir");
8918 let root = root_of(&dir);
8919 let parent = root.join("parent");
8920 init_repo_with_a_commit(&parent);
8921 git(
8922 &parent,
8923 &[
8924 "remote",
8925 "add",
8926 "origin",
8927 "https://example.invalid/repo.git",
8928 ],
8929 );
8930 let worktree_path = root.join("feature-worktree");
8931 git(
8932 &parent,
8933 &[
8934 "worktree",
8935 "add",
8936 "-b",
8937 "feature",
8938 worktree_path.to_str().expect("utf8 path"),
8939 ],
8940 );
8941 fs::write(worktree_path.join("a.txt"), "one\n").expect("write a.txt");
8942 git(&worktree_path, &["add", "a.txt"]);
8943 git(&worktree_path, &["commit", "-m", "add a"]);
8944 fs::write(worktree_path.join("b.txt"), "two\n").expect("write b.txt");
8945 git(&worktree_path, &["add", "b.txt"]);
8946 git(&worktree_path, &["commit", "-m", "add b"]);
8947 let feature_sha = head_sha(&worktree_path);
8948
8949 git(&parent, &["merge", "--squash", "feature"]);
8950 git(&parent, &["commit", "-m", "squashed feature"]);
8951 let main_sha = head_sha(&parent);
8952 git(
8953 &parent,
8954 &["update-ref", "refs/remotes/origin/main", &main_sha],
8955 );
8956 git(&parent, &["config", "branch.feature.remote", "origin"]);
8957 git(
8958 &parent,
8959 &["config", "branch.feature.merge", "refs/heads/feature"],
8960 );
8961 git(
8962 &parent,
8963 &["update-ref", "refs/remotes/origin/feature", &feature_sha],
8964 );
8965
8966 let core = Core::start_discovered(spec(vec![root]));
8967 let keys: Vec<EntityKey> = core
8968 .snapshot()
8969 .entities
8970 .iter()
8971 .map(|entity| entity.key.clone())
8972 .collect();
8973
8974 let before = loose_object_count(&parent);
8975 core.refresh(&keys);
8976 let settled = core.settle();
8977 let after = loose_object_count(&parent);
8978
8979 let worktree_entity = settled
8980 .entities
8981 .iter()
8982 .find(|entity| matches!(entity.kind, Kind::Worktree))
8983 .expect("worktree entity present");
8984 assert!(
8985 matches!(
8986 worktree_entity.state.settled(),
8987 Some(Settled::Known {
8988 value: WorktreeState::Merged,
8989 at: _,
8990 stale: _
8991 })
8992 ),
8993 "expected this refresh to actually reach patch equivalence and settle \
8994 Merged, got {:?}",
8995 worktree_entity.state.settled()
8996 );
8997 assert_eq!(
8998 before, after,
8999 "a full refresh reaching patch equivalence must never write a loose \
9000 object to the repository"
9001 );
9002 }
9003
9004 #[test]
9012 fn a_diverged_worktree_with_a_live_upstream_and_genuinely_unmerged_work_settles_active_after_a_refresh()
9013 {
9014 let dir = tempfile::tempdir().expect("temp dir");
9015 let root = root_of(&dir);
9016 let parent = root.join("parent");
9017 init_repo_with_a_commit(&parent);
9018 let base_sha = head_sha(&parent);
9019 git(
9020 &parent,
9021 &[
9022 "remote",
9023 "add",
9024 "origin",
9025 "https://example.invalid/repo.git",
9026 ],
9027 );
9028 git(
9029 &parent,
9030 &["update-ref", "refs/remotes/origin/main", &base_sha],
9031 );
9032 let worktree_path = root.join("feature-worktree");
9033 git(
9034 &parent,
9035 &[
9036 "worktree",
9037 "add",
9038 "-b",
9039 "feature",
9040 worktree_path.to_str().expect("utf8 path"),
9041 ],
9042 );
9043 fs::write(worktree_path.join("feature.txt"), "unmerged work\n").expect("write feature.txt");
9046 git(&worktree_path, &["add", "feature.txt"]);
9047 git(&worktree_path, &["commit", "-m", "unmerged"]);
9048 let feature_sha = head_sha(&worktree_path);
9049 git(&parent, &["config", "branch.feature.remote", "origin"]);
9052 git(
9053 &parent,
9054 &["config", "branch.feature.merge", "refs/heads/feature"],
9055 );
9056 git(
9057 &parent,
9058 &["update-ref", "refs/remotes/origin/feature", &feature_sha],
9059 );
9060
9061 let core = Core::start_discovered(spec(vec![root]));
9062 let keys: Vec<EntityKey> = core
9063 .snapshot()
9064 .entities
9065 .iter()
9066 .map(|entity| entity.key.clone())
9067 .collect();
9068
9069 core.refresh(&keys);
9070 let settled = core.settle();
9071
9072 let worktree_entity = settled
9073 .entities
9074 .iter()
9075 .find(|entity| matches!(entity.kind, Kind::Worktree))
9076 .expect("worktree entity present");
9077 assert!(
9078 matches!(
9079 worktree_entity.state.settled(),
9080 Some(Settled::Known {
9081 value: WorktreeState::Active,
9082 at: _,
9083 stale: _
9084 })
9085 ),
9086 "expected genuinely unmerged work with a live upstream to settle Active, got {:?}",
9087 worktree_entity.state.settled()
9088 );
9089 }
9090
9091 #[test]
9098 fn a_submodule_is_in_the_snapshot_even_though_hidden_by_the_default_preference() {
9099 let dir = tempfile::tempdir().expect("temp dir");
9100 let root = root_of(&dir);
9101 let parent = root.join("parent");
9102 init_repo_with_a_commit(&parent);
9103 fs::write(
9104 parent.join(".gitmodules"),
9105 "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
9106 )
9107 .expect("write .gitmodules");
9108 fs::create_dir_all(parent.join("vendor").join("lib")).expect("create submodule dir");
9109
9110 let core = Core::start_discovered(spec(vec![root]));
9111 let snapshot = core.snapshot();
9112
9113 assert!(
9114 snapshot
9115 .entities
9116 .iter()
9117 .any(|entity| matches!(entity.kind, Kind::Submodule)),
9118 "a discovered Submodule must be in the snapshot even while show_submodules is off"
9119 );
9120 }
9121
9122 #[test]
9132 fn a_submodules_state_and_base_cells_stay_unknown_through_a_real_refresh() {
9133 let dir = tempfile::tempdir().expect("temp dir");
9134 let root = root_of(&dir);
9135 let parent = root.join("parent");
9136 init_repo_with_a_commit(&parent);
9137 fs::write(
9138 parent.join(".gitmodules"),
9139 "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
9140 )
9141 .expect("write .gitmodules");
9142 let submodule = parent.join("vendor").join("lib");
9143 init_repo_with_a_commit(&submodule);
9144 git(
9145 &submodule,
9146 &["remote", "add", "origin", "https://example.invalid/lib.git"],
9147 );
9148 let root_sha = head_sha(&submodule);
9149 git(&submodule, &["commit", "--allow-empty", "-m", "second"]);
9150 let tip_sha = head_sha(&submodule);
9151 git(&submodule, &["reset", "--hard", &root_sha]);
9152 git(
9153 &submodule,
9154 &["update-ref", "refs/remotes/origin/main", &tip_sha],
9155 );
9156
9157 let mut core_spec = spec(vec![root]);
9160 core_spec.show_submodules = true;
9161 let core = Core::start_discovered(core_spec);
9162 let key = core
9163 .snapshot()
9164 .entities
9165 .iter()
9166 .find(|entity| matches!(entity.kind, Kind::Submodule))
9167 .expect("a discovered Submodule")
9168 .key
9169 .clone();
9170
9171 core.refresh(std::slice::from_ref(&key));
9172 let settled = core.settle();
9173 let submodule_entity = settled
9174 .entities
9175 .iter()
9176 .find(|entity| entity.key == key)
9177 .expect("the Submodule entity");
9178
9179 assert!(
9180 matches!(
9181 submodule_entity.base.settled(),
9182 Some(Settled::Unknown(Unknown::NoDefaultBranch))
9183 ),
9184 "expected a Submodule's base to stay Unknown through a real refresh, \
9185 got {:?}",
9186 submodule_entity.base.settled()
9187 );
9188 assert!(
9189 matches!(
9190 submodule_entity.state.settled(),
9191 Some(Settled::Unknown(Unknown::NoDefaultBranch))
9192 ),
9193 "expected a Submodule's state to stay Unknown through a real refresh, \
9194 rather than settling Merged off an untrusted default branch, got {:?}",
9195 submodule_entity.state.settled()
9196 );
9197 }
9198
9199 #[test]
9204 fn a_submodules_entity_name_is_its_relative_path_not_its_basename() {
9205 let dir = tempfile::tempdir().expect("temp dir");
9206 let root = root_of(&dir);
9207 let parent = root.join("parent");
9208 init_repo_with_a_commit(&parent);
9209 fs::write(
9210 parent.join(".gitmodules"),
9211 "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
9212 )
9213 .expect("write .gitmodules");
9214 fs::create_dir_all(parent.join("vendor").join("lib")).expect("create submodule dir");
9215
9216 let core = Core::start_discovered(spec(vec![root]));
9217 let submodule = core
9218 .snapshot()
9219 .entities
9220 .into_iter()
9221 .find(|entity| matches!(entity.kind, Kind::Submodule))
9222 .expect("a discovered Submodule");
9223
9224 assert_eq!(
9225 submodule.name.as_ref(),
9226 "vendor/lib",
9227 "expected the declared relative path, not the basename `lib`"
9228 );
9229 }
9230
9231 #[test]
9240 fn an_uninitialised_submodules_probed_cells_settle_unknown_not_failed() {
9241 let dir = tempfile::tempdir().expect("temp dir");
9242 let root = root_of(&dir);
9243 let parent = root.join("parent");
9244 init_repo_with_a_commit(&parent);
9245 fs::write(
9246 parent.join(".gitmodules"),
9247 "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
9248 )
9249 .expect("write .gitmodules");
9250 let mut core_spec = spec(vec![root]);
9254 core_spec.show_submodules = true;
9255 let core = Core::start_discovered(core_spec);
9256 let key = core
9257 .snapshot()
9258 .entities
9259 .iter()
9260 .find(|entity| matches!(entity.kind, Kind::Submodule))
9261 .expect("a discovered Submodule")
9262 .key
9263 .clone();
9264
9265 core.refresh(std::slice::from_ref(&key));
9266 let settled = core.settle();
9267 let submodule = settled
9268 .entities
9269 .iter()
9270 .find(|entity| entity.key == key)
9271 .expect("the Submodule entity");
9272
9273 assert!(
9274 matches!(
9275 submodule.branch.settled(),
9276 Some(Settled::Unknown(Unknown::SubmoduleUninitialized))
9277 ),
9278 "expected branch to settle Unknown(SubmoduleUninitialized), got {:?}",
9279 submodule.branch.settled()
9280 );
9281 assert!(
9282 matches!(
9283 submodule.sync.settled(),
9284 Some(Settled::Unknown(Unknown::SubmoduleUninitialized))
9285 ),
9286 "expected sync to settle Unknown(SubmoduleUninitialized), got {:?}",
9287 submodule.sync.settled()
9288 );
9289 assert!(
9290 matches!(
9291 submodule.dirty.settled(),
9292 Some(Settled::Unknown(Unknown::SubmoduleUninitialized))
9293 ),
9294 "expected dirty to settle Unknown(SubmoduleUninitialized), got {:?}",
9295 submodule.dirty.settled()
9296 );
9297 assert_eq!(
9298 summary(submodule),
9299 RowSummary::Unknown,
9300 "expected the row's own gutter fold to read Unknown, not Failed"
9301 );
9302 }
9303
9304 #[test]
9310 fn dispatch_skips_probing_a_hidden_submodule_while_probing_the_same_one_shown() {
9311 let dir = tempfile::tempdir().expect("temp dir");
9312 let root = root_of(&dir);
9313 let parent = root.join("parent");
9314 init_repo_with_a_commit(&parent);
9315 fs::write(
9316 parent.join(".gitmodules"),
9317 "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
9318 )
9319 .expect("write .gitmodules");
9320 init_repo_with_a_commit(&parent.join("vendor").join("lib"));
9321
9322 let core = Core::start_discovered(spec(vec![root]));
9324 let key = core
9325 .snapshot()
9326 .entities
9327 .iter()
9328 .find(|entity| matches!(entity.kind, Kind::Submodule))
9329 .expect("a discovered Submodule")
9330 .key
9331 .clone();
9332
9333 core.refresh(std::slice::from_ref(&key));
9335 let while_hidden = core.settle();
9336 let hidden_entity = while_hidden
9337 .entities
9338 .iter()
9339 .find(|entity| entity.key == key)
9340 .expect("submodule entity");
9341 assert!(
9342 hidden_entity.branch.settled().is_none(),
9343 "a Submodule dispatched while hidden must never even reach probe_branch, \
9344 so its cell stays never-settled rather than holding any value at all, got {:?}",
9345 hidden_entity.branch.settled()
9346 );
9347
9348 core.set_show_submodules(true);
9352 core.refresh(std::slice::from_ref(&key));
9353 let while_shown = core.settle();
9354 let shown_entity = while_shown
9355 .entities
9356 .iter()
9357 .find(|entity| entity.key == key)
9358 .expect("submodule entity");
9359 assert!(
9360 matches!(
9361 shown_entity.branch.settled(),
9362 Some(Settled::Known {
9363 value: _,
9364 at: _,
9365 stale: _
9366 })
9367 ),
9368 "expected the same Submodule's branch to settle a real value once shown, got {:?}",
9369 shown_entity.branch.settled()
9370 );
9371 }
9372
9373 #[test]
9379 fn toggling_show_submodules_starts_no_new_generation_and_dispatches_nothing() {
9380 let dir = tempfile::tempdir().expect("temp dir");
9381 let root = root_of(&dir);
9382 init_repo_with_a_commit(&root.join("repo-a"));
9383
9384 let (core, launched) = started_and_settled(spec(vec![root]));
9386 let before = launched.generation;
9387 let dispatched_before = core.dispatch_log_for_test();
9388 assert!(
9389 !dispatched_before.is_empty(),
9390 "launch dispatched nothing, so the comparison below would hold however much a \
9391 toggle dispatched"
9392 );
9393
9394 core.set_show_submodules(true);
9395 core.set_show_submodules(false);
9396
9397 assert_eq!(
9398 core.snapshot().generation,
9399 before,
9400 "toggling show_submodules must start no Generation of its own"
9401 );
9402 assert_eq!(
9403 core.dispatch_log_for_test(),
9404 dispatched_before,
9405 "toggling show_submodules must dispatch no probe of its own, leaving the last \
9406 Generation's own log exactly as it found it"
9407 );
9408 }
9409
9410 #[test]
9417 fn a_malformed_gitmodules_file_still_fails_the_parent_while_submodules_are_hidden() {
9418 let dir = tempfile::tempdir().expect("temp dir");
9419 let root = root_of(&dir);
9420 let parent = root.join("parent");
9421 init_repo_with_a_commit(&parent);
9422 fs::write(
9423 parent.join(".gitmodules"),
9424 "[submodule \"lib\"\n\tpath = lib\n",
9425 )
9426 .expect("write malformed .gitmodules");
9427
9428 let core = Core::start_discovered(spec(vec![root]));
9429 let key = core
9430 .snapshot()
9431 .entities
9432 .iter()
9433 .find(|entity| entity.key.path() == parent)
9434 .expect("the parent entity")
9435 .key
9436 .clone();
9437 core.refresh(std::slice::from_ref(&key));
9441 let settled = core.settle();
9442 let parent_entity = settled
9443 .entities
9444 .iter()
9445 .find(|entity| entity.key == key)
9446 .expect("the parent entity");
9447
9448 assert_eq!(
9449 summary(parent_entity),
9450 RowSummary::Failed,
9451 "expected the parent to fold Failed even with Submodules hidden"
9452 );
9453 assert!(
9454 parent_entity.diagnostics.gitmodules_failed.is_some(),
9455 "expected the failure recorded in Diagnostics for the detail pane"
9456 );
9457 assert!(
9458 !settled
9459 .entities
9460 .iter()
9461 .any(|entity| matches!(entity.kind, Kind::Submodule)),
9462 "an unparseable .gitmodules yields no Submodule rows for that parent"
9463 );
9464 }
9465
9466 #[test]
9467 fn count_matches_a_plain_discoverys_entity_count() {
9468 let dir = tempfile::tempdir().expect("temp dir");
9469 let root = root_of(&dir);
9470 init_repo_with_a_commit(&root.join("one"));
9471 init_repo_with_a_commit(&root.join("two"));
9472
9473 let set = SetSpec {
9474 name: "test".to_string(),
9475 roots: vec![root],
9476 include: Vec::new(),
9477 exclude: Vec::new(),
9478 };
9479
9480 assert_eq!(discovery::count(&set), 2);
9481 }
9482
9483 #[test]
9484 fn the_slow_discovery_watcher_warns_with_the_count_reached_and_the_roots() {
9485 let progress = Arc::new(AtomicUsize::new(42));
9486 let finished = Arc::new(AtomicBool::new(false));
9487 let roots = vec![PathBuf::from("/repos/a"), PathBuf::from("/repos/b")];
9488
9489 let warning = watch_for_slow_discovery(progress, finished, roots, Duration::from_millis(1));
9490
9491 let message = warning.expect("a walk that has not finished should warn");
9492 assert!(message.contains("42"));
9493 assert!(message.contains("/repos/a"));
9494 assert!(message.contains("/repos/b"));
9495 }
9496
9497 #[test]
9498 fn the_slow_discovery_watcher_is_silent_once_the_walk_has_already_finished() {
9499 let progress = Arc::new(AtomicUsize::new(7));
9500 let finished = Arc::new(AtomicBool::new(true));
9501
9502 let warning =
9503 watch_for_slow_discovery(progress, finished, Vec::new(), Duration::from_millis(1));
9504
9505 assert!(warning.is_none());
9506 }
9507
9508 #[test]
9516 fn a_fast_discovery_leaves_no_warning_once_the_watcher_has_run() {
9517 let dir = tempfile::tempdir().expect("temp dir");
9518 let root = root_of(&dir);
9519 init_repo_with_a_commit(&root.join("repo"));
9520 let (_tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
9521
9522 let started =
9523 Core::start_for_test(spec(vec![root]), Duration::from_secs(1), tick_rx).discovered();
9524 started
9525 .discovery_watcher
9526 .join()
9527 .expect("watcher thread should not panic");
9528
9529 assert!(started.core.discovery_warning().is_none());
9530 }
9531
9532 fn gate_opened_on_signal(open: bool) -> (DiscoveryGate, Sender<()>, JoinHandle<()>) {
9540 let gate: DiscoveryGate = Arc::new((Mutex::new(open), Condvar::new()));
9541 let (returned_tx, returned_rx) = crossbeam_channel::bounded::<()>(1);
9542 let opener = thread::spawn({
9543 let gate = Arc::clone(&gate);
9544 move || {
9545 let _ = returned_rx.recv_timeout(crate::liveness::BACKSTOP);
9546 set_discovery_gate(&gate, true);
9547 }
9548 });
9549 (gate, returned_tx, opener)
9550 }
9551
9552 #[test]
9564 fn start_returns_against_an_empty_table_and_the_rows_land_when_discovery_does() {
9565 let dir = tempfile::tempdir().expect("temp dir");
9566 let root = root_of(&dir);
9567 let repo = root.join("repo");
9568 init_repo_with_a_commit(&repo);
9569 let (_tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
9570 let (gate, start_returned, opener) = gate_opened_on_signal(false);
9571
9572 let started = Core::start_for_test_gated(
9573 spec(vec![root]),
9574 Duration::from_secs(3600),
9575 discovery::ABANDON_AFTER,
9576 tick_rx,
9577 Some(Arc::clone(&gate)),
9578 );
9579 let at_start = started.core.snapshot();
9580 let key = EntityKey::new(Arc::from(repo.as_path()));
9581 started.core.hold_phase_c_for_test(&key);
9582 start_returned.send(()).expect("the opener is listening");
9583 opener.join().expect("the opener thread should not panic");
9584 let started = started.discovered();
9585
9586 assert!(
9587 at_start.entities.is_empty(),
9588 "`Core::start` must return before discovery has finished, against the empty \
9589 table a consumer draws its first frame from, got {:?}",
9590 at_start
9591 .entities
9592 .iter()
9593 .map(|entity| entity.name.to_string())
9594 .collect::<Vec<_>>()
9595 );
9596
9597 let landed = started.core.snapshot();
9598 assert_eq!(
9599 landed
9600 .entities
9601 .iter()
9602 .map(|entity| entity.name.to_string())
9603 .collect::<Vec<_>>(),
9604 vec!["repo".to_string()],
9605 "the row must land on the table as soon as discovery does"
9606 );
9607 assert!(
9608 landed.entities[0].dirty.settled().is_none() && landed.entities[0].dirty.is_in_flight(),
9609 "discovery lands the row alone: launch's own Generation is already covering it \
9610 and its Cells stay unsettled until that Generation answers, which is what the \
9611 spinner sits behind"
9612 );
9613
9614 started.core.release_phase_c_for_test(&key);
9615 started.core.wait_phase_c_finished_for_test(&key);
9616 }
9617
9618 #[test]
9627 fn refresh_all_covers_every_row_its_own_discovery_found() {
9628 let dir = tempfile::tempdir().expect("temp dir");
9629 let root = root_of(&dir);
9630 init_repo_with_a_commit(&root.join("repo"));
9631
9632 let (core, launched) = started_and_settled(spec(vec![root.clone()]));
9633 assert_eq!(
9634 launched
9635 .entities
9636 .iter()
9637 .map(|entity| entity.name.to_string())
9638 .collect::<Vec<_>>(),
9639 vec!["repo".to_string()],
9640 "launch's own walk must have landed and covered exactly the one row that \
9641 existed when it ran"
9642 );
9643 init_repo_with_a_commit(&root.join("late"));
9647
9648 assert_eq!(
9649 core.refresh_all(),
9650 launched.generation.successor(),
9651 "`refresh_all` must be the Generation immediately after the one already on the \
9652 table"
9653 );
9654 let settled = core.settle();
9655
9656 let mut named: Vec<String> = settled
9657 .entities
9658 .iter()
9659 .filter(|entity| entity.branch.settled().is_some())
9660 .map(|entity| entity.name.to_string())
9661 .collect();
9662 named.sort();
9663 assert_eq!(
9664 named,
9665 vec!["late".to_string(), "repo".to_string()],
9666 "the Generation must cover every row its own discovery found, including one the \
9667 caller had no key for"
9668 );
9669 }
9670
9671 #[test]
9681 fn refresh_returns_before_its_own_generations_discovery_has_run() {
9682 let dir = tempfile::tempdir().expect("temp dir");
9683 let root = root_of(&dir);
9684 init_repo_with_a_commit(&root.join("repo"));
9685 let (_tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
9686 let (gate, walk_may_run, opener) = gate_opened_on_signal(true);
9687
9688 let started = Core::start_for_test_gated(
9689 spec(vec![root.clone()]),
9690 Duration::from_secs(3600),
9691 discovery::ABANDON_AFTER,
9692 tick_rx,
9693 Some(Arc::clone(&gate)),
9694 )
9695 .discovered();
9696 let core = started.core;
9697 let launched = settle_launch(&core);
9699 let keys: Vec<EntityKey> = launched
9700 .entities
9701 .iter()
9702 .map(|entity| entity.key.clone())
9703 .collect();
9704 init_repo_with_a_commit(&root.join("late"));
9705
9706 set_discovery_gate(&gate, false);
9707 let generation = core.refresh(&keys);
9708 let while_held = core.snapshot();
9709 let dispatched_while_held = core.settle_gate_count_for_test();
9710 walk_may_run.send(()).expect("the opener is listening");
9711 opener.join().expect("the opener thread should not panic");
9712
9713 assert_eq!(
9714 generation,
9715 launched.generation.successor(),
9716 "`refresh` must return its own Generation's number, the one immediately after \
9717 the table's, before that Generation has done any of its work"
9718 );
9719 assert!(
9720 !while_held
9721 .entities
9722 .iter()
9723 .any(|entity| &*entity.name == "late"),
9724 "`refresh` must return before its own Generation's walk has run, so a Repo \
9725 created after the previous walk is not on the table it returned against"
9726 );
9727 assert_eq!(
9728 dispatched_while_held, 0,
9729 "`refresh` returned before its Generation reached the table at all, so nothing \
9730 is dispatched yet"
9731 );
9732
9733 core.wait_dispatched_for_test();
9734 let settled = core.settle();
9735
9736 assert!(
9737 settled
9738 .entities
9739 .iter()
9740 .any(|entity| &*entity.name == "late"),
9741 "the deferred Generation must still run its own walk once it is let through: \
9742 deferred, never dropped"
9743 );
9744 }
9745
9746 #[test]
9757 fn a_dispatch_body_waits_for_every_earlier_reserved_generation() {
9758 let turnstile = Arc::new(DispatchTurnstile::default());
9759 let earlier = turnstile.reserve();
9760 let later = turnstile.reserve();
9761 let order = Arc::new(Mutex::new(Vec::new()));
9762
9763 let earlier_body = thread::spawn({
9764 let turnstile = Arc::clone(&turnstile);
9765 let order = Arc::clone(&order);
9766 move || {
9767 let _turn = turnstile.take(earlier);
9768 order.lock().unwrap().push(earlier);
9769 }
9770 });
9771
9772 {
9773 let _turn = turnstile.take(later);
9774 order.lock().unwrap().push(later);
9775 }
9776 earlier_body
9777 .join()
9778 .expect("the earlier body should not panic");
9779
9780 assert_eq!(
9781 *order.lock().unwrap(),
9782 vec![earlier, later],
9783 "a dispatch body must run in the order its Generation was reserved"
9784 );
9785 }
9786
9787 #[test]
9792 fn run_while_not_cancelled_stops_at_the_next_check_rather_than_running_forever() {
9793 let cancel = Arc::new(AtomicBool::new(false));
9794 let worker_cancel = Arc::clone(&cancel);
9795 let (step_started_tx, step_started_rx) = crossbeam_channel::bounded::<()>(0);
9796 let (proceed_tx, proceed_rx) = crossbeam_channel::bounded::<()>(0);
9797
9798 let worker = thread::spawn(move || {
9799 run_while_not_cancelled(&worker_cancel, || {
9800 step_started_tx.send(()).expect("test should be listening");
9801 proceed_rx.recv().is_ok()
9802 })
9803 });
9804
9805 for _ in 0..2 {
9806 step_started_rx
9807 .recv()
9808 .expect("worker should announce each step");
9809 proceed_tx.send(()).expect("let the step finish");
9810 }
9811 step_started_rx
9812 .recv()
9813 .expect("worker should announce its third step");
9814 cancel.store(true, Ordering::Release);
9815 proceed_tx.send(()).expect("let the third step finish");
9816
9817 let ran = worker.join().expect("worker thread should not panic");
9818
9819 assert_eq!(
9820 ran, 3,
9821 "expected cancellation to stop the loop after its third step"
9822 );
9823 }
9824
9825 fn benchmark_identity_phase(
9833 population: Vec<crate::discovery::DiscoveredEntity>,
9834 ) -> (Duration, Vec<Duration>) {
9835 let (tx, rx) = crossbeam_channel::unbounded();
9836 let started = Instant::now();
9837 crate::fanout::scatter(population, tx, |entity| {
9838 let task_started = Instant::now();
9839 let repo = match &entity.repo {
9840 Some(repo) => repo.to_thread_local(),
9841 None => match git::open_thread_safe(entity.key.path()) {
9842 Ok(repo) => repo.to_thread_local(),
9843 Err(_) => return None,
9844 },
9845 };
9846 let _ = git::head_shape(&repo);
9847 Some(task_started.elapsed())
9848 });
9849 let wall = started.elapsed();
9850 let durations: Vec<Duration> = rx.into_iter().flatten().collect();
9851 (wall, durations)
9852 }
9853
9854 fn real_corpus_roots() -> Vec<PathBuf> {
9858 let Some(home) = std::env::var_os("HOME") else {
9859 return Vec::new();
9860 };
9861 let home = PathBuf::from(home);
9862 ["dev", "dev-misc"]
9863 .into_iter()
9864 .map(|leaf| home.join(leaf))
9865 .filter(|root| root.is_dir())
9866 .collect()
9867 }
9868
9869 fn generated_fixture_corpus(size: usize) -> tempfile::TempDir {
9874 let root = tempfile::tempdir().expect("temp dir for generated fixture corpus");
9875 for i in 0..size {
9876 let repo = root.path().join(format!("fixture-repo-{i}"));
9877 fs::create_dir_all(&repo).expect("create fixture repo dir");
9878 gix::init(&repo).expect("init fixture repo");
9879 let status = Command::new("git")
9880 .arg("-C")
9881 .arg(&repo)
9882 .args(["-c", "user.email=test@example.com", "-c", "user.name=Test"])
9883 .args(["commit", "--allow-empty", "-m", &format!("commit {i}")])
9884 .status()
9885 .expect("run git commit");
9886 assert!(status.success());
9887 }
9888 root
9889 }
9890
9891 fn percentile(sorted: &[Duration], p: usize) -> Duration {
9893 let index = (sorted.len() - 1) * p / 100;
9894 sorted[index]
9895 }
9896
9897 fn extra_excluded_names() -> Vec<String> {
9904 parse_excluded_names(&std::env::var("REPON_BENCHMARK_EXCLUDE_NAMES").unwrap_or_default())
9905 }
9906
9907 fn parse_excluded_names(raw: &str) -> Vec<String> {
9912 raw.split(',')
9913 .map(str::trim)
9914 .filter(|name| !name.is_empty())
9915 .map(str::to_string)
9916 .collect()
9917 }
9918
9919 fn discover_population(
9927 roots: Vec<PathBuf>,
9928 excluded_names: &[String],
9929 ) -> (Vec<crate::discovery::DiscoveredEntity>, Duration) {
9930 let set = SetSpec {
9931 name: "identity-probe-benchmark".to_string(),
9932 roots,
9933 include: Vec::new(),
9934 exclude: Vec::new(),
9935 };
9936 let started = Instant::now();
9937 let discovery = discovery::discover(&set);
9938 let (discovered, _) = discovery::resolve(&set, &discovery.entities);
9939 let elapsed = started.elapsed();
9940 let population = discovered
9941 .into_iter()
9942 .filter(|entity| {
9943 !entity.key.path().components().any(|component| {
9944 excluded_names
9945 .iter()
9946 .any(|name| component.as_os_str() == name.as_str())
9947 })
9948 })
9949 .collect();
9950 (population, elapsed)
9951 }
9952
9953 #[test]
9957 fn a_boundary_whose_path_matches_an_excluded_name_is_left_out_of_the_population() {
9958 let fixture = generated_fixture_corpus(3);
9959 let excluded = vec!["fixture-repo-1".to_string()];
9960
9961 let (population, _) = discover_population(vec![fixture.path().to_path_buf()], &excluded);
9962
9963 assert_eq!(population.len(), 2);
9964 assert!(
9965 population
9966 .iter()
9967 .all(|entity| entity.key.path().file_name().unwrap() != "fixture-repo-1"),
9968 "the excluded name must never appear in the population discovery returns"
9969 );
9970 }
9971
9972 #[test]
9973 fn excluded_names_parses_a_comma_separated_list_and_ignores_blanks() {
9974 assert_eq!(
9975 parse_excluded_names("foo, bar ,,baz"),
9976 vec!["foo".to_string(), "bar".to_string(), "baz".to_string()]
9977 );
9978 assert!(parse_excluded_names("").is_empty());
9979 assert!(parse_excluded_names(" ").is_empty());
9980 }
9981
9982 #[test]
9997 #[ignore = "hand-run against the owner's real corpus; see docs/spec/refresh.md for the recorded figures"]
9998 fn identity_probe_benchmark() {
9999 let excluded_names = extra_excluded_names();
10000
10001 let mut _fixture: Option<tempfile::TempDir> = None;
10005
10006 let (real_population, real_discovery_wall) =
10007 discover_population(real_corpus_roots(), &excluded_names);
10008 let (population, using_fixture, discovery_wall) = if real_population.len() >= 20 {
10009 (real_population, false, real_discovery_wall)
10010 } else {
10011 println!(
10012 "real corpus absent or too small to be meaningful ({} entities); \
10013 using a generated fixture instead",
10014 real_population.len()
10015 );
10016 let fixture = generated_fixture_corpus(300);
10017 let (population, fixture_discovery_wall) =
10018 discover_population(vec![fixture.path().to_path_buf()], &excluded_names);
10019 _fixture = Some(fixture);
10020 (population, true, fixture_discovery_wall)
10021 };
10022
10023 let population_size = population.len();
10024 assert!(
10025 population_size > 0,
10026 "neither a real corpus root nor the generated fixture produced any entities"
10027 );
10028
10029 let (wall, mut durations) = benchmark_identity_phase(population);
10030 durations.sort();
10031
10032 println!(
10033 "identity probe benchmark: corpus = {}, population = {population_size}",
10034 if using_fixture {
10035 "generated fixture"
10036 } else {
10037 "real corpus"
10038 }
10039 );
10040 println!(
10041 "discovery + first open (serial, every entity's own gix::open): {discovery_wall:?}"
10042 );
10043 println!("identity phase, warm, parallel (HEAD re-read from the cached handle): {wall:?}");
10044 println!(
10045 "identity phase per entity: p50 {:?}, p90 {:?}, max {:?}",
10046 percentile(&durations, 50),
10047 percentile(&durations, 90),
10048 durations.last().copied().unwrap_or_default(),
10049 );
10050 }
10051
10052 fn spec_with_overrides(roots: Vec<PathBuf>, overrides: Vec<RepoOverride>) -> CoreSpec {
10053 let mut spec = spec(roots);
10054 spec.overrides = overrides;
10055 spec
10056 }
10057
10058 #[test]
10063 fn a_per_repo_override_resolves_the_default_branch_at_rung_one_through_a_real_refresh() {
10064 let dir = tempfile::tempdir().expect("temp dir");
10065 let root = root_of(&dir);
10066 let repo = root.join("repo");
10067 init_repo_with_a_commit(&repo);
10068 git(
10069 &repo,
10070 &[
10071 "remote",
10072 "add",
10073 "origin",
10074 "https://example.invalid/repo.git",
10075 ],
10076 );
10077 let sha = head_sha(&repo);
10078 git(&repo, &["update-ref", "refs/remotes/origin/main", &sha]);
10079 let remote_refs_dir = repo
10080 .join(".git")
10081 .join("refs")
10082 .join("remotes")
10083 .join("origin");
10084 fs::create_dir_all(&remote_refs_dir).expect("create refs/remotes/origin dir");
10085 fs::write(
10086 remote_refs_dir.join("HEAD"),
10087 "ref: refs/remotes/origin/main\n",
10088 )
10089 .expect("write HEAD");
10090
10091 let core = Core::start_discovered(spec_with_overrides(
10092 vec![root],
10093 vec![RepoOverride {
10094 path: repo.clone(),
10095 default_branch: Some("develop".to_string()),
10096 excluded: false,
10097 }],
10098 ));
10099 let key = core.snapshot().entities[0].key.clone();
10100
10101 core.refresh(std::slice::from_ref(&key));
10102 let settled = core.settle();
10103 let entity = &settled.entities[0];
10104
10105 match entity.default_branch.settled() {
10106 Some(Settled::Known {
10107 value,
10108 at: _,
10109 stale: _,
10110 }) => assert_eq!(
10111 value.name(),
10112 "origin/develop",
10113 "the override must win even though origin/HEAD names a different branch"
10114 ),
10115 other => panic!("expected the override's own answer, got {other:?}"),
10116 }
10117 assert_eq!(
10118 entity.diagnostics.default_branch_rung,
10119 Some(1),
10120 "an override must be recorded as rung 1"
10121 );
10122 }
10123
10124 #[test]
10128 fn a_per_repo_override_also_resolves_through_probe_now() {
10129 let dir = tempfile::tempdir().expect("temp dir");
10130 let root = root_of(&dir);
10131 let repo = root.join("repo");
10132 init_repo_with_a_commit(&repo);
10133
10134 let core = Core::start_discovered(spec_with_overrides(
10135 vec![root],
10136 vec![RepoOverride {
10137 path: repo.clone(),
10138 default_branch: Some("release".to_string()),
10139 excluded: false,
10140 }],
10141 ));
10142 let key = core.snapshot().entities[0].key.clone();
10143
10144 let entity = core.probe_now(&key);
10145
10146 match entity.default_branch.settled() {
10147 Some(Settled::Known {
10149 value,
10150 at: _,
10151 stale: _,
10152 }) => assert_eq!(value.name(), "release"),
10153 other => panic!("expected the override's own answer, got {other:?}"),
10154 }
10155 assert_eq!(entity.diagnostics.default_branch_rung, Some(1));
10156 }
10157
10158 #[test]
10163 fn reaching_rung_four_with_no_remote_at_all_records_why() {
10164 let dir = tempfile::tempdir().expect("temp dir");
10165 let root = root_of(&dir);
10166 let repo = root.join("repo");
10167 init_repo_with_a_commit(&repo);
10168
10169 let core = Core::start_discovered(spec(vec![root]));
10170 let key = core.snapshot().entities[0].key.clone();
10171
10172 core.refresh(std::slice::from_ref(&key));
10173 let settled = core.settle();
10174 let entity = &settled.entities[0];
10175
10176 assert_eq!(entity.diagnostics.default_branch_rung, Some(4));
10177 assert_eq!(
10178 entity.diagnostics.default_branch_stopped,
10179 Some(DefaultBranchStopped::NoRemote)
10180 );
10181 }
10182
10183 #[test]
10184 fn reaching_rung_four_with_two_unnamed_remotes_records_why() {
10185 let dir = tempfile::tempdir().expect("temp dir");
10186 let root = root_of(&dir);
10187 let repo = root.join("repo");
10188 init_repo_with_a_commit(&repo);
10189 git(
10190 &repo,
10191 &[
10192 "remote",
10193 "add",
10194 "fork-one",
10195 "https://example.invalid/one.git",
10196 ],
10197 );
10198 git(
10199 &repo,
10200 &[
10201 "remote",
10202 "add",
10203 "fork-two",
10204 "https://example.invalid/two.git",
10205 ],
10206 );
10207
10208 let core = Core::start_discovered(spec(vec![root]));
10209 let key = core.snapshot().entities[0].key.clone();
10210
10211 core.refresh(std::slice::from_ref(&key));
10212 let settled = core.settle();
10213 let entity = &settled.entities[0];
10214
10215 assert_eq!(entity.diagnostics.default_branch_rung, Some(4));
10216 assert_eq!(
10217 entity.diagnostics.default_branch_stopped,
10218 Some(DefaultBranchStopped::AmbiguousRemote)
10219 );
10220 }
10221
10222 #[test]
10223 fn reaching_rung_four_with_a_chosen_remote_and_no_matching_ref_records_why() {
10224 let dir = tempfile::tempdir().expect("temp dir");
10225 let root = root_of(&dir);
10226 let repo = root.join("repo");
10227 init_repo_with_a_commit(&repo);
10228 git(
10229 &repo,
10230 &[
10231 "remote",
10232 "add",
10233 "origin",
10234 "https://example.invalid/repo.git",
10235 ],
10236 );
10237 let sha = head_sha(&repo);
10240 git(&repo, &["update-ref", "refs/remotes/origin/feature", &sha]);
10241
10242 let core = Core::start_discovered(spec(vec![root]));
10243 let key = core.snapshot().entities[0].key.clone();
10244
10245 core.refresh(std::slice::from_ref(&key));
10246 let settled = core.settle();
10247 let entity = &settled.entities[0];
10248
10249 assert_eq!(entity.diagnostics.default_branch_rung, Some(4));
10250 assert_eq!(
10251 entity.diagnostics.default_branch_stopped,
10252 Some(DefaultBranchStopped::NameListExhausted)
10253 );
10254 }
10255
10256 #[test]
10259 fn a_repo_with_nothing_to_resolve_settles_unknown_never_failed() {
10260 let dir = tempfile::tempdir().expect("temp dir");
10261 let root = root_of(&dir);
10262 let repo = root.join("repo");
10263 init_repo_with_a_commit(&repo);
10264
10265 let core = Core::start_discovered(spec(vec![root]));
10266 let key = core.snapshot().entities[0].key.clone();
10267
10268 core.refresh(std::slice::from_ref(&key));
10269 let settled = core.settle();
10270 let entity = &settled.entities[0];
10271
10272 assert!(matches!(
10273 entity.default_branch.settled(),
10274 Some(Settled::Unknown(Unknown::NoDefaultBranch))
10275 ));
10276 assert_eq!(entity.diagnostics.default_branch_rung, Some(4));
10277 }
10278
10279 #[test]
10285 fn a_stale_remote_head_is_recorded_in_diagnostics_through_a_real_refresh() {
10286 let dir = tempfile::tempdir().expect("temp dir");
10287 let root = root_of(&dir);
10288 let repo = root.join("repo");
10289 init_repo_with_a_commit(&repo);
10290 git(
10291 &repo,
10292 &[
10293 "remote",
10294 "add",
10295 "origin",
10296 "https://example.invalid/repo.git",
10297 ],
10298 );
10299 let sha = head_sha(&repo);
10300 git(&repo, &["update-ref", "refs/remotes/origin/trunk", &sha]);
10301 let remote_refs_dir = repo
10302 .join(".git")
10303 .join("refs")
10304 .join("remotes")
10305 .join("origin");
10306 fs::create_dir_all(&remote_refs_dir).expect("create refs/remotes/origin dir");
10307 fs::write(
10309 remote_refs_dir.join("HEAD"),
10310 "ref: refs/remotes/origin/main\n",
10311 )
10312 .expect("write HEAD");
10313
10314 let core = Core::start_discovered(spec(vec![root]));
10315 let key = core.snapshot().entities[0].key.clone();
10316
10317 core.refresh(std::slice::from_ref(&key));
10318 let settled = core.settle();
10319 let entity = &settled.entities[0];
10320
10321 match entity.default_branch.settled() {
10322 Some(Settled::Known {
10323 value,
10324 at: _,
10325 stale: _,
10326 }) => {
10327 assert_eq!(value.name(), "origin/trunk")
10328 }
10329 other => panic!("expected the name list's answer, got {other:?}"),
10330 }
10331 assert!(
10332 entity.diagnostics.default_branch_rung_two_stale,
10333 "a stale origin/HEAD target must be recorded on the entity's diagnostics"
10334 );
10335 }
10336
10337 #[test]
10340 fn a_resolvable_remote_head_is_not_recorded_as_stale() {
10341 let dir = tempfile::tempdir().expect("temp dir");
10342 let root = root_of(&dir);
10343 let repo = root.join("repo");
10344 init_repo_with_a_commit(&repo);
10345 git(
10346 &repo,
10347 &[
10348 "remote",
10349 "add",
10350 "origin",
10351 "https://example.invalid/repo.git",
10352 ],
10353 );
10354 let sha = head_sha(&repo);
10355 git(&repo, &["update-ref", "refs/remotes/origin/main", &sha]);
10356 let remote_refs_dir = repo
10357 .join(".git")
10358 .join("refs")
10359 .join("remotes")
10360 .join("origin");
10361 fs::create_dir_all(&remote_refs_dir).expect("create refs/remotes/origin dir");
10362 fs::write(
10363 remote_refs_dir.join("HEAD"),
10364 "ref: refs/remotes/origin/main\n",
10365 )
10366 .expect("write HEAD");
10367
10368 let core = Core::start_discovered(spec(vec![root]));
10369 let key = core.snapshot().entities[0].key.clone();
10370
10371 core.refresh(std::slice::from_ref(&key));
10372 let settled = core.settle();
10373 let entity = &settled.entities[0];
10374
10375 assert!(!entity.diagnostics.default_branch_rung_two_stale);
10376 }
10377
10378 #[test]
10383 fn one_override_on_a_repos_path_covers_a_worktree_sharing_its_common_dir() {
10384 let dir = tempfile::tempdir().expect("temp dir");
10385 let root = root_of(&dir);
10386 let parent = root.join("parent");
10387 init_repo_with_a_commit(&parent);
10388 let worktree = root.join("worktree");
10389 git(
10390 &parent,
10391 &[
10392 "worktree",
10393 "add",
10394 "-b",
10395 "feature",
10396 worktree.to_str().expect("utf8 path"),
10397 ],
10398 );
10399
10400 let core = Core::start_discovered(spec_with_overrides(
10401 vec![root],
10402 vec![RepoOverride {
10403 path: parent.clone(),
10404 default_branch: None,
10405 excluded: true,
10406 }],
10407 ));
10408 let snapshot = core.snapshot();
10409
10410 for entity in &snapshot.entities {
10411 assert!(
10412 entity.excluded,
10413 "both the Repo and its Worktree must inherit the entry declared on the Repo's own path, entity: {:?}",
10414 entity.key
10415 );
10416 }
10417 assert_eq!(
10418 snapshot.entities.len(),
10419 2,
10420 "expected the parent plus its worktree"
10421 );
10422 }
10423
10424 #[test]
10428 fn an_entry_naming_a_worktrees_own_path_beats_the_inherited_one() {
10429 let dir = tempfile::tempdir().expect("temp dir");
10430 let root = root_of(&dir);
10431 let parent = root.join("parent");
10432 init_repo_with_a_commit(&parent);
10433 let worktree_own = root.join("worktree-own");
10434 let worktree_inherits = root.join("worktree-inherits");
10435 git(
10436 &parent,
10437 &[
10438 "worktree",
10439 "add",
10440 "-b",
10441 "feature-own",
10442 worktree_own.to_str().expect("utf8 path"),
10443 ],
10444 );
10445 git(
10446 &parent,
10447 &[
10448 "worktree",
10449 "add",
10450 "-b",
10451 "feature-inherits",
10452 worktree_inherits.to_str().expect("utf8 path"),
10453 ],
10454 );
10455
10456 let core = Core::start_discovered(spec_with_overrides(
10457 vec![root],
10458 vec![
10459 RepoOverride {
10460 path: parent.clone(),
10461 default_branch: None,
10462 excluded: true,
10463 },
10464 RepoOverride {
10465 path: worktree_own.clone(),
10466 default_branch: None,
10467 excluded: false,
10468 },
10469 ],
10470 ));
10471 let snapshot = core.snapshot();
10472
10473 let find = |path: &Path| {
10474 snapshot
10475 .entities
10476 .iter()
10477 .find(|entity| entity.key.path() == path)
10478 .unwrap_or_else(|| panic!("entity at {path:?} present"))
10479 };
10480
10481 assert!(
10482 find(&parent).excluded,
10483 "the parent Repo has no entry of its own and inherits the excluding one"
10484 );
10485 assert!(
10486 !find(&worktree_own).excluded,
10487 "the Worktree named directly by its own path must use its own entry, not the inherited one"
10488 );
10489 assert!(
10490 find(&worktree_inherits).excluded,
10491 "a sibling Worktree with no entry of its own still inherits the Repo's entry"
10492 );
10493 }
10494
10495 #[test]
10500 fn an_override_on_the_parents_path_never_excludes_its_submodule() {
10501 let dir = tempfile::tempdir().expect("temp dir");
10502 let root = root_of(&dir);
10503 let parent = root.join("parent");
10504 init_repo_with_a_commit(&parent);
10505 fs::write(
10506 parent.join(".gitmodules"),
10507 "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
10508 )
10509 .expect("write .gitmodules");
10510 fs::create_dir_all(parent.join("vendor").join("lib")).expect("create submodule dir");
10511
10512 let core = Core::start_discovered(spec_with_overrides(
10513 vec![root],
10514 vec![RepoOverride {
10515 path: parent.clone(),
10516 default_branch: None,
10517 excluded: true,
10518 }],
10519 ));
10520 let snapshot = core.snapshot();
10521
10522 let submodule = snapshot
10523 .entities
10524 .iter()
10525 .find(|entity| matches!(entity.kind, Kind::Submodule))
10526 .expect("the submodule is still discovered and listed");
10527 assert!(
10528 !submodule.excluded,
10529 "an entry naming only the parent's path must never reach a Submodule, \
10530 whose own common dir differs from its parent's"
10531 );
10532 }
10533
10534 #[test]
10551 fn the_default_branch_chain_is_memoised_once_per_common_dir_per_generation() {
10552 let dir = tempfile::tempdir().expect("temp dir");
10553 let root = root_of(&dir);
10554 let parent = root.join("parent");
10555 init_repo_with_a_commit(&parent);
10556 for name in ["wt-a", "wt-b", "wt-c"] {
10557 let worktree = root.join(name);
10558 git(
10559 &parent,
10560 &[
10561 "worktree",
10562 "add",
10563 "-b",
10564 name,
10565 worktree.to_str().expect("utf8 path"),
10566 ],
10567 );
10568 }
10569 let other_repo = root.join("other");
10570 init_repo_with_a_commit(&other_repo);
10571
10572 let (core, launched) = started_and_settled(spec(vec![root]));
10573 let keys: Vec<EntityKey> = launched
10574 .entities
10575 .iter()
10576 .map(|entity| entity.key.clone())
10577 .collect();
10578 assert_eq!(
10579 keys.len(),
10580 5,
10581 "expected the parent, its three worktrees and the unrelated repo"
10582 );
10583
10584 core.refresh(&keys);
10585 core.settle();
10586
10587 assert_eq!(
10588 core.default_branch_chain_reads_for_test(),
10589 2,
10590 "four entities span exactly two common dirs; a memoised chain reads \
10591 each common dir once, not once per entity"
10592 );
10593
10594 core.refresh(&keys);
10598 core.settle();
10599 assert_eq!(
10600 core.default_branch_chain_reads_for_test(),
10601 2,
10602 "the memo lives inside one Generation's dispatch; the next Generation \
10603 recomputes rather than inheriting it"
10604 );
10605 }
10606
10607 #[test]
10617 fn patch_equivalence_is_memoised_once_per_common_dir_per_generation() {
10618 let dir = tempfile::tempdir().expect("temp dir");
10619 let root = root_of(&dir);
10620 let parent = root.join("parent");
10621 init_repo_with_a_commit(&parent);
10622 git(
10623 &parent,
10624 &[
10625 "remote",
10626 "add",
10627 "origin",
10628 "https://example.invalid/repo.git",
10629 ],
10630 );
10631 let base_sha = head_sha(&parent);
10632 git(
10633 &parent,
10634 &["update-ref", "refs/remotes/origin/main", &base_sha],
10635 );
10636 for name in ["feature-x", "feature-y"] {
10637 let worktree = root.join(name);
10638 git(
10639 &parent,
10640 &[
10641 "worktree",
10642 "add",
10643 "-b",
10644 name,
10645 worktree.to_str().expect("utf8 path"),
10646 ],
10647 );
10648 fs::write(worktree.join(format!("{name}.txt")), "unmerged\n")
10649 .expect("write worktree file");
10650 git(&worktree, &["add", "."]);
10651 git(&worktree, &["commit", "-m", "unmerged work"]);
10652 let tip_sha = head_sha(&worktree);
10653 git(
10654 &parent,
10655 &["config", &format!("branch.{name}.remote"), "origin"],
10656 );
10657 git(
10658 &parent,
10659 &[
10660 "config",
10661 &format!("branch.{name}.merge"),
10662 &format!("refs/heads/{name}"),
10663 ],
10664 );
10665 git(
10666 &parent,
10667 &[
10668 "update-ref",
10669 &format!("refs/remotes/origin/{name}"),
10670 &tip_sha,
10671 ],
10672 );
10673 }
10674
10675 let other_parent = root.join("other");
10676 init_repo_with_a_commit(&other_parent);
10677 git(
10678 &other_parent,
10679 &[
10680 "remote",
10681 "add",
10682 "origin",
10683 "https://example.invalid/other.git",
10684 ],
10685 );
10686 let other_base_sha = head_sha(&other_parent);
10687 git(
10688 &other_parent,
10689 &["update-ref", "refs/remotes/origin/main", &other_base_sha],
10690 );
10691 let other_worktree = root.join("other-feature");
10692 git(
10693 &other_parent,
10694 &[
10695 "worktree",
10696 "add",
10697 "-b",
10698 "other-feature",
10699 other_worktree.to_str().expect("utf8 path"),
10700 ],
10701 );
10702 fs::write(other_worktree.join("other.txt"), "unmerged\n").expect("write worktree file");
10703 git(&other_worktree, &["add", "."]);
10704 git(&other_worktree, &["commit", "-m", "unmerged work"]);
10705 let other_tip_sha = head_sha(&other_worktree);
10706 git(
10707 &other_parent,
10708 &["config", "branch.other-feature.remote", "origin"],
10709 );
10710 git(
10711 &other_parent,
10712 &[
10713 "config",
10714 "branch.other-feature.merge",
10715 "refs/heads/other-feature",
10716 ],
10717 );
10718 git(
10719 &other_parent,
10720 &[
10721 "update-ref",
10722 "refs/remotes/origin/other-feature",
10723 &other_tip_sha,
10724 ],
10725 );
10726
10727 let (core, launched) = started_and_settled(spec(vec![root]));
10728 let keys: Vec<EntityKey> = launched
10729 .entities
10730 .iter()
10731 .map(|entity| entity.key.clone())
10732 .collect();
10733 assert_eq!(
10734 keys.len(),
10735 5,
10736 "expected two parents plus their three worktrees"
10737 );
10738
10739 core.refresh(&keys);
10740 let settled = core.settle();
10741
10742 let worktree_states: Vec<_> = settled
10743 .entities
10744 .iter()
10745 .filter(|entity| matches!(entity.kind, Kind::Worktree))
10746 .map(|entity| entity.state.settled())
10747 .collect();
10748 assert_eq!(worktree_states.len(), 3, "expected three worktree rows");
10749 for settled_state in &worktree_states {
10750 assert!(
10751 matches!(
10752 settled_state,
10753 Some(Settled::Known {
10754 value: WorktreeState::Active,
10755 at: _,
10756 stale: _
10757 })
10758 ),
10759 "expected every worktree's genuinely unmerged work to settle Active, got {settled_state:?}"
10760 );
10761 }
10762
10763 assert_eq!(
10764 core.patch_identity_reads_for_test(),
10765 2,
10766 "two worktrees share one common dir and must scan its default-branch \
10767 history once between them, not once per entity; the unrelated repo's \
10768 own worktree pays for a second scan"
10769 );
10770
10771 core.refresh(&keys);
10774 core.settle();
10775 assert_eq!(
10776 core.patch_identity_reads_for_test(),
10777 2,
10778 "the memo lives inside one Generation's dispatch; the next Generation \
10779 recomputes rather than inheriting it"
10780 );
10781 }
10782
10783 #[test]
10802 fn an_entity_whose_merge_base_is_deeper_than_its_siblings_widens_the_shared_scan() {
10803 let dir = tempfile::tempdir().expect("temp dir");
10804 let root = root_of(&dir);
10805 let parent = root.join("parent");
10806 init_repo_with_a_commit(&parent);
10807 git(
10808 &parent,
10809 &[
10810 "remote",
10811 "add",
10812 "origin",
10813 "https://example.invalid/repo.git",
10814 ],
10815 );
10816 let deep_fork_sha = head_sha(&parent);
10817
10818 git(&parent, &["branch", "feature-deep"]);
10819 let deep_worktree = root.join("feature-deep");
10820 git(
10821 &parent,
10822 &[
10823 "worktree",
10824 "add",
10825 deep_worktree.to_str().expect("utf8 path"),
10826 "feature-deep",
10827 ],
10828 );
10829 fs::write(deep_worktree.join("deep.txt"), "deep work\n").expect("write deep.txt");
10830 git(&deep_worktree, &["add", "."]);
10831 git(&deep_worktree, &["commit", "-m", "deep work"]);
10832 let deep_tip_sha = head_sha(&deep_worktree);
10833
10834 git(&parent, &["merge", "--squash", "feature-deep"]);
10835 git(&parent, &["commit", "-m", "squashed deep"]);
10836 let shallow_fork_sha = head_sha(&parent);
10837
10838 git(&parent, &["branch", "feature-shallow"]);
10839 let shallow_worktree = root.join("feature-shallow");
10840 git(
10841 &parent,
10842 &[
10843 "worktree",
10844 "add",
10845 shallow_worktree.to_str().expect("utf8 path"),
10846 "feature-shallow",
10847 ],
10848 );
10849 fs::write(shallow_worktree.join("shallow.txt"), "shallow work\n")
10850 .expect("write shallow.txt");
10851 git(&shallow_worktree, &["add", "."]);
10852 git(&shallow_worktree, &["commit", "-m", "shallow work"]);
10853 let shallow_tip_sha = head_sha(&shallow_worktree);
10854
10855 git(&parent, &["merge", "--squash", "feature-shallow"]);
10856 git(&parent, &["commit", "-m", "squashed shallow"]);
10857 let main_tip_sha = head_sha(&parent);
10858 assert_ne!(
10859 deep_fork_sha, shallow_fork_sha,
10860 "the two siblings must fork at genuinely different commits"
10861 );
10862
10863 git(
10864 &parent,
10865 &["update-ref", "refs/remotes/origin/main", &main_tip_sha],
10866 );
10867 for (name, tip_sha) in [
10868 ("feature-deep", &deep_tip_sha),
10869 ("feature-shallow", &shallow_tip_sha),
10870 ] {
10871 git(
10872 &parent,
10873 &["config", &format!("branch.{name}.remote"), "origin"],
10874 );
10875 git(
10876 &parent,
10877 &[
10878 "config",
10879 &format!("branch.{name}.merge"),
10880 &format!("refs/heads/{name}"),
10881 ],
10882 );
10883 git(
10884 &parent,
10885 &[
10886 "update-ref",
10887 &format!("refs/remotes/origin/{name}"),
10888 tip_sha,
10889 ],
10890 );
10891 }
10892
10893 let (core, snapshot) = started_and_settled(spec(vec![root]));
10894 let deep_key = snapshot
10895 .entities
10896 .iter()
10897 .find(|entity| entity.key.path() == deep_worktree)
10898 .expect("feature-deep worktree discovered")
10899 .key
10900 .clone();
10901 let shallow_key = snapshot
10902 .entities
10903 .iter()
10904 .find(|entity| entity.key.path() == shallow_worktree)
10905 .expect("feature-shallow worktree discovered")
10906 .key
10907 .clone();
10908 let parent_key = snapshot
10909 .entities
10910 .iter()
10911 .find(|entity| entity.key.path() == parent)
10912 .expect("parent repo discovered")
10913 .key
10914 .clone();
10915 let order = vec![parent_key, shallow_key.clone(), deep_key.clone()];
10919
10920 core.refresh(&order);
10921 let settled = core.settle();
10922
10923 let state_of = |key: &EntityKey| {
10924 settled
10925 .entities
10926 .iter()
10927 .find(|entity| &entity.key == key)
10928 .and_then(|entity| entity.state.settled())
10929 .cloned()
10930 };
10931 assert!(
10932 matches!(
10933 state_of(&deep_key),
10934 Some(Settled::Known {
10935 value: WorktreeState::Merged,
10936 at: _,
10937 stale: _
10938 })
10939 ),
10940 "expected the deepest sibling's own squash commit to be found once the scan is \
10941 bounded by the deepest merge base, got {:?}",
10942 state_of(&deep_key)
10943 );
10944 assert!(
10945 matches!(
10946 state_of(&shallow_key),
10947 Some(Settled::Known {
10948 value: WorktreeState::Merged,
10949 at: _,
10950 stale: _
10951 })
10952 ),
10953 "expected the shallow sibling to settle Merged too, got {:?}",
10954 state_of(&shallow_key)
10955 );
10956 assert_eq!(
10957 core.patch_identity_reads_for_test(),
10958 1,
10959 "both worktrees share one common dir and must still scan its default-branch \
10960 history once between them, not once per entity"
10961 );
10962 assert_eq!(
10963 core.patch_scan_bounds_for_test(),
10964 vec![Some(id(&deep_fork_sha))],
10965 "the one shared scan that ran must have been bounded by the deepest sibling's own \
10966 merge base, not the shallower one's"
10967 );
10968 }
10969
10970 fn id(sha: &str) -> gix::ObjectId {
10971 gix::ObjectId::from_hex(sha.as_bytes()).expect("parse sha")
10972 }
10973
10974 #[test]
10981 fn bound_gate_deepest_folds_every_candidate_regardless_of_report_order() {
10982 let dir = tempfile::tempdir().expect("temp dir");
10983 let repo_path = root_of(&dir).join("repo");
10984 init_repo_with_a_commit(&repo_path);
10985 let deep_sha = id(&head_sha(&repo_path));
10986 fs::write(repo_path.join("child.txt"), "child\n").expect("write child.txt");
10987 git(&repo_path, &["add", "."]);
10988 git(&repo_path, &["commit", "-m", "child of deep"]);
10989 let shallow_sha = id(&head_sha(&repo_path));
10990
10991 let repo = gix::open(&repo_path).expect("open repo");
10992 let gate = BoundGate::new(2);
10993 gate.report(Some(shallow_sha));
10994 gate.report(Some(deep_sha));
10995
10996 assert_eq!(
10997 gate.deepest(&repo),
10998 Some(deep_sha),
10999 "the deepest candidate must win even though the shallower one reported first"
11000 );
11001 }
11002
11003 #[test]
11020 fn probe_patch_equivalence_bounds_the_scan_by_the_gates_deepest_not_its_own_merge_base() {
11021 let dir = tempfile::tempdir().expect("temp dir");
11022 let repo_path = root_of(&dir).join("repo");
11023 init_repo_with_a_commit(&repo_path);
11024 let deep_sha = id(&head_sha(&repo_path));
11025 fs::write(repo_path.join("child.txt"), "child\n").expect("write child.txt");
11026 git(&repo_path, &["add", "."]);
11027 git(&repo_path, &["commit", "-m", "child of deep"]);
11028 let shallow_sha_hex = head_sha(&repo_path);
11029 let shallow_sha = id(&shallow_sha_hex);
11030 fs::write(repo_path.join("tip.txt"), "tip\n").expect("write tip.txt");
11031 git(&repo_path, &["add", "."]);
11032 git(&repo_path, &["commit", "-m", "default tip"]);
11033 let default_tip_hex = head_sha(&repo_path);
11034
11035 let repo = gix::open(&repo_path).expect("open repo");
11036 let outstanding = landing::Outstanding {
11039 entity_tip: shallow_sha,
11040 default_tip: id(&default_tip_hex),
11041 merge_base: Some(shallow_sha),
11042 };
11043 let common_dir: Arc<Path> = Arc::from(repo_path.join(".git"));
11044 let cancel = AtomicBool::new(false);
11045 let patch_cache: PatchIdentityCache = Mutex::new(HashMap::new());
11046 let patch_reads = AtomicUsize::new(0);
11047 let patch_scan_bounds: Mutex<Vec<Option<gix::ObjectId>>> = Mutex::new(Vec::new());
11048 let memo = PatchEquivalenceMemo {
11049 cache: &patch_cache,
11050 reads: &patch_reads,
11051 scan_bounds: &patch_scan_bounds,
11052 };
11053 let gate = BoundGate::new(2);
11057 gate.report(Some(deep_sha));
11058 let mut report = GateReport::new(&gate);
11059
11060 probe_patch_equivalence(
11061 &repo,
11062 &outstanding,
11063 &common_dir,
11064 &cancel,
11065 &memo,
11066 &mut report,
11067 );
11068
11069 assert_eq!(
11070 patch_scan_bounds.lock().unwrap().as_slice(),
11071 [Some(deep_sha)],
11072 "the scan must be bounded by the deepest sibling's merge base, not shallow's own \
11073 ({shallow_sha:?})"
11074 );
11075 }
11076
11077 #[test]
11085 fn probe_patch_equivalence_diffs_from_the_merge_base_it_was_handed() {
11086 let dir = tempfile::tempdir().expect("temp dir");
11087 let repo_path = root_of(&dir).join("repo");
11088 init_repo_with_a_commit(&repo_path);
11089 let fork_point_hex = head_sha(&repo_path);
11090 git(&repo_path, &["checkout", "-b", "feature"]);
11091 fs::write(repo_path.join("a.txt"), "one\n").expect("write a.txt");
11092 git(&repo_path, &["add", "a.txt"]);
11093 git(&repo_path, &["commit", "-m", "add a"]);
11094 let mid_sha = id(&head_sha(&repo_path));
11095 fs::write(repo_path.join("b.txt"), "two\n").expect("write b.txt");
11096 git(&repo_path, &["add", "b.txt"]);
11097 git(&repo_path, &["commit", "-m", "add b"]);
11098 let feature_sha = id(&head_sha(&repo_path));
11099 git(&repo_path, &["checkout", "-B", "main", &fork_point_hex]);
11100 git(&repo_path, &["merge", "--squash", "feature"]);
11101 git(&repo_path, &["commit", "-m", "squashed feature"]);
11102 let main_sha = id(&head_sha(&repo_path));
11103
11104 let repo = gix::open(&repo_path).expect("open repo");
11105 let outstanding = landing::Outstanding {
11108 entity_tip: feature_sha,
11109 default_tip: main_sha,
11110 merge_base: Some(mid_sha),
11111 };
11112 let common_dir: Arc<Path> = Arc::from(repo_path.join(".git"));
11113 let cancel = AtomicBool::new(false);
11114 let patch_cache: PatchIdentityCache = Mutex::new(HashMap::new());
11115 let patch_reads = AtomicUsize::new(0);
11116 let patch_scan_bounds: Mutex<Vec<Option<gix::ObjectId>>> = Mutex::new(Vec::new());
11117 let memo = PatchEquivalenceMemo {
11118 cache: &patch_cache,
11119 reads: &patch_reads,
11120 scan_bounds: &patch_scan_bounds,
11121 };
11122 let gate = BoundGate::new(1);
11123 let mut report = GateReport::new(&gate);
11124
11125 let settled = probe_patch_equivalence(
11126 &repo,
11127 &outstanding,
11128 &common_dir,
11129 &cancel,
11130 &memo,
11131 &mut report,
11132 );
11133
11134 assert!(
11135 matches!(
11136 settled,
11137 Some(Settled::Known {
11138 value: WorktreeState::Active,
11139 at: _,
11140 stale: _
11141 })
11142 ),
11143 "the range must be measured from the handed-in base ({mid_sha:?}), whose only \
11144 change the squash commit does not match, got {settled:?}"
11145 );
11146 }
11147
11148 #[test]
11155 fn bound_gate_deepest_with_no_candidates_leaves_the_scan_unbounded() {
11156 let dir = tempfile::tempdir().expect("temp dir");
11157 let repo_path = root_of(&dir).join("repo");
11158 gix::init(&repo_path).expect("init repo");
11159 let repo = gix::open(&repo_path).expect("open repo");
11160
11161 let gate = BoundGate::new(2);
11162 gate.report(None);
11163 gate.report(None);
11164
11165 assert_eq!(
11166 gate.deepest(&repo),
11167 None,
11168 "no contributed candidate must leave the scan unbounded"
11169 );
11170 }
11171
11172 #[test]
11182 fn an_outstanding_entity_with_no_shared_history_settles_active_without_the_shared_scan() {
11183 let dir = tempfile::tempdir().expect("temp dir");
11184 let root = root_of(&dir);
11185 let parent = root.join("parent");
11186 init_repo_with_a_commit(&parent);
11187 git(&parent, &["branch", "-M", "main"]);
11188 git(
11189 &parent,
11190 &[
11191 "remote",
11192 "add",
11193 "origin",
11194 "https://example.invalid/repo.git",
11195 ],
11196 );
11197 let main_sha = head_sha(&parent);
11198 git(
11199 &parent,
11200 &["update-ref", "refs/remotes/origin/main", &main_sha],
11201 );
11202
11203 git(&parent, &["checkout", "--orphan", "unrelated"]);
11204 git(
11205 &parent,
11206 &["commit", "--allow-empty", "-m", "unrelated root"],
11207 );
11208 let unrelated_sha = head_sha(&parent);
11209 git(&parent, &["checkout", "main"]);
11210
11211 let worktree = root.join("unrelated");
11212 git(
11213 &parent,
11214 &[
11215 "worktree",
11216 "add",
11217 worktree.to_str().expect("utf8 path"),
11218 "unrelated",
11219 ],
11220 );
11221 git(&parent, &["config", "branch.unrelated.remote", "origin"]);
11222 git(
11223 &parent,
11224 &["config", "branch.unrelated.merge", "refs/heads/unrelated"],
11225 );
11226 git(
11227 &parent,
11228 &[
11229 "update-ref",
11230 "refs/remotes/origin/unrelated",
11231 &unrelated_sha,
11232 ],
11233 );
11234
11235 let (core, snapshot) = started_and_settled(spec(vec![root]));
11236 let worktree_key = snapshot
11237 .entities
11238 .iter()
11239 .find(|entity| entity.key.path() == worktree)
11240 .expect("unrelated worktree discovered")
11241 .key
11242 .clone();
11243
11244 core.refresh(std::slice::from_ref(&worktree_key));
11245 let settled = core.settle();
11246
11247 let state = settled
11248 .entities
11249 .iter()
11250 .find(|entity| entity.key == worktree_key)
11251 .and_then(|entity| entity.state.settled())
11252 .cloned();
11253 assert!(
11254 matches!(
11255 state,
11256 Some(Settled::Known {
11257 value: WorktreeState::Active,
11258 at: _,
11259 stale: _
11260 })
11261 ),
11262 "expected an Outstanding entity with no shared history to settle Active via the \
11263 bypass, got {state:?}"
11264 );
11265 assert_eq!(
11266 core.patch_identity_reads_for_test(),
11267 0,
11268 "the bypass must settle without ever running the shared scan"
11269 );
11270 }
11271
11272 fn add_origin_remote(path: &Path) {
11277 git(
11278 path,
11279 &[
11280 "remote",
11281 "add",
11282 "origin",
11283 "https://example.invalid/repo.git",
11284 ],
11285 );
11286 }
11287
11288 fn set_upstream(path: &Path, branch: &str, upstream_sha: &str) {
11292 git(
11293 path,
11294 &["config", &format!("branch.{branch}.remote"), "origin"],
11295 );
11296 git(
11297 path,
11298 &[
11299 "config",
11300 &format!("branch.{branch}.merge"),
11301 &format!("refs/heads/{branch}"),
11302 ],
11303 );
11304 git(
11305 path,
11306 &[
11307 "update-ref",
11308 &format!("refs/remotes/origin/{branch}"),
11309 upstream_sha,
11310 ],
11311 );
11312 }
11313
11314 fn refresh_and_settle(core: &Core) -> crate::snapshot::Snapshot {
11315 let keys: Vec<EntityKey> = core
11316 .snapshot()
11317 .entities
11318 .iter()
11319 .map(|entity| entity.key.clone())
11320 .collect();
11321 core.refresh(&keys);
11322 core.settle()
11323 }
11324
11325 fn sync_of<'a>(
11326 snapshot: &'a crate::snapshot::Snapshot,
11327 path: &Path,
11328 ) -> Option<&'a Settled<SyncState>> {
11329 snapshot
11330 .entities
11331 .iter()
11332 .find(|entity| entity.key.path() == path)
11333 .unwrap_or_else(|| panic!("no entity for {}", path.display()))
11334 .sync
11335 .settled()
11336 }
11337
11338 #[test]
11340 fn an_attached_branch_ahead_of_its_upstream_reads_the_ahead_count() {
11341 let dir = tempfile::tempdir().expect("temp dir");
11342 let root = root_of(&dir);
11343 let repo = root.join("repo");
11344 init_repo_with_a_commit(&repo);
11345 let fork_sha = head_sha(&repo);
11346 add_origin_remote(&repo);
11347 set_upstream(&repo, "main", &fork_sha);
11348 git(&repo, &["commit", "--allow-empty", "-m", "local work"]);
11349
11350 let core = Core::start_discovered(spec(vec![root]));
11351 let settled = refresh_and_settle(&core);
11352
11353 match sync_of(&settled, &repo) {
11354 Some(Settled::Known {
11355 value: SyncState::Tracking(AheadBehind { ahead, behind }),
11356 at: _,
11357 stale: _,
11358 }) => {
11359 assert_eq!(*ahead, 1);
11360 assert_eq!(*behind, 0);
11361 }
11362 other => panic!("expected 1 ahead, 0 behind, got {other:?}"),
11363 }
11364 }
11365
11366 #[test]
11368 fn an_attached_branch_behind_its_upstream_reads_the_behind_count() {
11369 let dir = tempfile::tempdir().expect("temp dir");
11370 let root = root_of(&dir);
11371 let repo = root.join("repo");
11372 init_repo_with_a_commit(&repo);
11373 git(&repo, &["checkout", "-b", "temp"]);
11374 git(&repo, &["commit", "--allow-empty", "-m", "upstream work"]);
11375 let upstream_sha = head_sha(&repo);
11376 git(&repo, &["checkout", "main"]);
11377 git(&repo, &["branch", "-D", "temp"]);
11378 add_origin_remote(&repo);
11379 set_upstream(&repo, "main", &upstream_sha);
11380
11381 let core = Core::start_discovered(spec(vec![root]));
11382 let settled = refresh_and_settle(&core);
11383
11384 match sync_of(&settled, &repo) {
11385 Some(Settled::Known {
11386 value: SyncState::Tracking(AheadBehind { ahead, behind }),
11387 at: _,
11388 stale: _,
11389 }) => {
11390 assert_eq!(*ahead, 0);
11391 assert_eq!(*behind, 1);
11392 }
11393 other => panic!("expected 0 ahead, 1 behind, got {other:?}"),
11394 }
11395 }
11396
11397 #[test]
11399 fn an_attached_branch_level_with_its_upstream_reads_in_sync() {
11400 let dir = tempfile::tempdir().expect("temp dir");
11401 let root = root_of(&dir);
11402 let repo = root.join("repo");
11403 init_repo_with_a_commit(&repo);
11404 let sha = head_sha(&repo);
11405 add_origin_remote(&repo);
11406 set_upstream(&repo, "main", &sha);
11407
11408 let core = Core::start_discovered(spec(vec![root]));
11409 let settled = refresh_and_settle(&core);
11410
11411 match sync_of(&settled, &repo) {
11412 Some(Settled::Known {
11413 value:
11414 SyncState::Tracking(AheadBehind {
11415 ahead: 0,
11416 behind: 0,
11417 }),
11418 at: _,
11419 stale: _,
11420 }) => {}
11421 other => panic!("expected level with its upstream, got {other:?}"),
11422 }
11423 }
11424
11425 #[test]
11429 fn an_attached_branch_tracking_nothing_reads_no_upstream() {
11430 let dir = tempfile::tempdir().expect("temp dir");
11431 let root = root_of(&dir);
11432 let repo = root.join("repo");
11433 init_repo_with_a_commit(&repo);
11434 add_origin_remote(&repo);
11435
11436 let core = Core::start_discovered(spec(vec![root]));
11437 let settled = refresh_and_settle(&core);
11438
11439 match sync_of(&settled, &repo) {
11440 Some(Settled::Known {
11441 value: SyncState::NoUpstream,
11442 at: _,
11443 stale: _,
11444 }) => {}
11445 other => panic!("expected no upstream configured, got {other:?}"),
11446 }
11447 }
11448
11449 #[test]
11452 fn a_detached_row_reads_no_upstream() {
11453 let dir = tempfile::tempdir().expect("temp dir");
11454 let root = root_of(&dir);
11455 let repo = root.join("repo");
11456 init_repo_with_a_commit(&repo);
11457 let first_sha = head_sha(&repo);
11458 git(&repo, &["commit", "--allow-empty", "-m", "second"]);
11459 git(&repo, &["checkout", "--detach", &first_sha]);
11460 add_origin_remote(&repo);
11461
11462 let core = Core::start_discovered(spec(vec![root]));
11463 let settled = refresh_and_settle(&core);
11464
11465 match sync_of(&settled, &repo) {
11466 Some(Settled::Known {
11467 value: SyncState::NoUpstream,
11468 at: _,
11469 stale: _,
11470 }) => {}
11471 other => panic!("expected a detached row to read no upstream, got {other:?}"),
11472 }
11473 }
11474
11475 #[test]
11480 fn a_repo_with_no_remote_reads_no_remote_on_itself_and_every_worktree() {
11481 let dir = tempfile::tempdir().expect("temp dir");
11482 let root = root_of(&dir);
11483 let parent = root.join("parent");
11484 init_repo_with_a_commit(&parent);
11485 let worktree = root.join("feature");
11486 git(
11487 &parent,
11488 &[
11489 "worktree",
11490 "add",
11491 "-b",
11492 "feature",
11493 worktree.to_str().expect("utf8 path"),
11494 ],
11495 );
11496
11497 let core = Core::start_discovered(spec(vec![root]));
11498 let settled = refresh_and_settle(&core);
11499
11500 assert_eq!(
11501 settled.entities.len(),
11502 2,
11503 "expected the parent Repo and its one linked Worktree"
11504 );
11505 for path in [&parent, &worktree] {
11506 match sync_of(&settled, path) {
11507 Some(Settled::Known {
11508 value: SyncState::NoRemote,
11509 at: _,
11510 stale: _,
11511 }) => {}
11512 other => panic!(
11513 "expected {} to read no remote at all, got {other:?}",
11514 path.display()
11515 ),
11516 }
11517 }
11518 }
11519
11520 #[test]
11526 fn sync_is_computed_for_every_entity_dispatched_this_generation_not_only_one() {
11527 let dir = tempfile::tempdir().expect("temp dir");
11528 let root = root_of(&dir);
11529 let parent = root.join("parent");
11530 init_repo_with_a_commit(&parent);
11531 let fork_sha = head_sha(&parent);
11532 add_origin_remote(&parent);
11533
11534 let ahead_worktree = root.join("feature-ahead");
11535 git(
11536 &parent,
11537 &[
11538 "worktree",
11539 "add",
11540 "-b",
11541 "feature-ahead",
11542 ahead_worktree.to_str().expect("utf8 path"),
11543 ],
11544 );
11545 set_upstream(&parent, "feature-ahead", &fork_sha);
11546 git(
11547 &ahead_worktree,
11548 &["commit", "--allow-empty", "-m", "unpushed"],
11549 );
11550
11551 let behind_worktree = root.join("feature-behind");
11552 git(
11553 &parent,
11554 &[
11555 "worktree",
11556 "add",
11557 "-b",
11558 "feature-behind",
11559 behind_worktree.to_str().expect("utf8 path"),
11560 ],
11561 );
11562 git(
11563 &behind_worktree,
11564 &["commit", "--allow-empty", "-m", "on the remote only"],
11565 );
11566 let ahead_of_behind_sha = head_sha(&behind_worktree);
11567 git(&behind_worktree, &["reset", "--hard", "HEAD~1"]);
11568 set_upstream(&parent, "feature-behind", &ahead_of_behind_sha);
11569
11570 let core = Core::start_discovered(spec(vec![root]));
11571 let settled = refresh_and_settle(&core);
11572
11573 match sync_of(&settled, &ahead_worktree) {
11574 Some(Settled::Known {
11575 value:
11576 SyncState::Tracking(AheadBehind {
11577 ahead: 1,
11578 behind: 0,
11579 }),
11580 at: _,
11581 stale: _,
11582 }) => {}
11583 other => panic!("expected feature-ahead to read 1 ahead, got {other:?}"),
11584 }
11585 match sync_of(&settled, &behind_worktree) {
11586 Some(Settled::Known {
11587 value:
11588 SyncState::Tracking(AheadBehind {
11589 ahead: 0,
11590 behind: 1,
11591 }),
11592 at: _,
11593 stale: _,
11594 }) => {}
11595 other => panic!("expected feature-behind to read 1 behind, got {other:?}"),
11596 }
11597 }
11598
11599 #[test]
11605 fn sync_recomputes_on_a_second_generation_not_only_the_first() {
11606 let dir = tempfile::tempdir().expect("temp dir");
11607 let root = root_of(&dir);
11608 let repo = root.join("repo");
11609 init_repo_with_a_commit(&repo);
11610 let fork_sha = head_sha(&repo);
11611 add_origin_remote(&repo);
11612 set_upstream(&repo, "main", &fork_sha);
11613
11614 let core = Core::start_discovered(spec(vec![root]));
11615 let first = refresh_and_settle(&core);
11616 match sync_of(&first, &repo) {
11617 Some(Settled::Known {
11618 value:
11619 SyncState::Tracking(AheadBehind {
11620 ahead: 0,
11621 behind: 0,
11622 }),
11623 at: _,
11624 stale: _,
11625 }) => {}
11626 other => panic!("expected the first Generation level with its upstream, got {other:?}"),
11627 }
11628
11629 git(
11630 &repo,
11631 &[
11632 "commit",
11633 "--allow-empty",
11634 "-m",
11635 "second Generation's own work",
11636 ],
11637 );
11638 let second = refresh_and_settle(&core);
11639 match sync_of(&second, &repo) {
11640 Some(Settled::Known {
11641 value:
11642 SyncState::Tracking(AheadBehind {
11643 ahead: 1,
11644 behind: 0,
11645 }),
11646 at: _,
11647 stale: _,
11648 }) => {}
11649 other => panic!(
11650 "expected the second Generation to recompute and read 1 ahead, got {other:?}"
11651 ),
11652 }
11653 }
11654
11655 #[test]
11671 fn worktrees_now_behind_a_moved_default_branch_are_reported_by_name() {
11672 let dir = tempfile::tempdir().expect("temp dir");
11673 let root = root_of(&dir);
11674 let repo = root.join("repo");
11675 init_repo_with_a_commit(&repo);
11676 let sha_a = head_sha(&repo);
11677 add_origin_remote(&repo);
11678 set_upstream(&repo, "main", &sha_a);
11679
11680 let behind_path = root.join("wt-behind");
11681 git(
11682 &repo,
11683 &[
11684 "worktree",
11685 "add",
11686 "-b",
11687 "topic-behind",
11688 behind_path.to_str().expect("utf8 path"),
11689 "main",
11690 ],
11691 );
11692
11693 git(&repo, &["checkout", "-b", "scratch"]);
11698 git(&repo, &["commit", "--allow-empty", "-m", "second"]);
11699 let sha_b = head_sha(&repo);
11700 git(&repo, &["checkout", "main"]);
11701 git(&repo, &["update-ref", "refs/remotes/origin/main", &sha_b]);
11702 git(&repo, &["branch", "-D", "scratch"]);
11703
11704 let caught_up_path = root.join("wt-caught-up");
11710 git(
11711 &repo,
11712 &[
11713 "worktree",
11714 "add",
11715 "-b",
11716 "topic-caught-up",
11717 caught_up_path.to_str().expect("utf8 path"),
11718 &sha_b,
11719 ],
11720 );
11721
11722 let core = Core::start_discovered(spec(vec![root]));
11723 let snapshot = refresh_and_settle(&core);
11724
11725 let base_of = |name: &str| -> u32 {
11726 let entity = snapshot
11727 .entities
11728 .iter()
11729 .find(|entity| &*entity.name == name)
11730 .unwrap_or_else(|| panic!("no entity named {name} in {snapshot:?}"));
11731 match entity.base.settled() {
11732 Some(Settled::Known {
11733 value,
11734 at: _,
11735 stale: _,
11736 }) => *value,
11737 other => panic!("expected a known base count for {name}, got {other:?}"),
11738 }
11739 };
11740
11741 assert!(
11742 base_of("wt-behind") > 0,
11743 "a Worktree branched before the default branch moved must be reported behind"
11744 );
11745 assert_eq!(
11746 base_of("wt-caught-up"),
11747 0,
11748 "a Worktree branched from the new tip must not be reported behind"
11749 );
11750 }
11751
11752 mod fetch_scheduler {
11758 use super::*;
11759 use crate::liveness::wait_for_or;
11760
11761 fn fetch_spec(enabled: bool, root: PathBuf) -> CoreSpec {
11762 let mut spec = spec(vec![root]);
11763 spec.fetch = FetchSpec {
11764 enabled,
11765 interval: Duration::from_secs(3600),
11766 concurrency: 4,
11767 };
11768 spec
11769 }
11770
11771 fn seeded_remote() -> tempfile::TempDir {
11774 let remote = tempfile::tempdir().expect("temp dir");
11775 crate::test_support::init_bare(remote.path());
11776 crate::test_support::push_new_commit(remote.path(), "README.md", "seed\n");
11777 remote
11778 }
11779
11780 fn clone_into(remote: &Path, dest: &Path) {
11781 let status = Command::new("git")
11782 .arg("clone")
11783 .arg(remote)
11784 .arg(dest)
11785 .status()
11786 .expect("run git clone");
11787 assert!(status.success());
11788 crate::test_support::set_identity(dest);
11789 }
11790
11791 #[test]
11798 fn enabling_the_periodic_fetch_runs_one_cycle_before_any_tick_arrives() {
11799 let remote = seeded_remote();
11800 let root = tempfile::tempdir().expect("temp dir");
11801 let root_path = root_of(&root);
11802 clone_into(remote.path(), &root_path.join("parent"));
11803
11804 let fetch_ticks: Receiver<Instant> = crossbeam_channel::never();
11805 let started = Core::start_for_test_with_fetch(
11806 fetch_spec(true, root_path),
11807 Duration::from_secs(3600),
11808 crossbeam_channel::never(),
11809 fetch_ticks,
11810 )
11811 .discovered();
11812 let core = started.core;
11813
11814 wait_for(
11815 "the periodic fetch to run its first cycle without waiting for a tick",
11816 || core.fetch_cycle_count_for_test() >= 1,
11817 );
11818 }
11819
11820 #[test]
11824 fn a_tick_on_the_fetch_channel_runs_another_cycle() {
11825 let remote = seeded_remote();
11826 let root = tempfile::tempdir().expect("temp dir");
11827 let root_path = root_of(&root);
11828 clone_into(remote.path(), &root_path.join("parent"));
11829
11830 let (fetch_tick_tx, fetch_tick_rx) = crossbeam_channel::unbounded();
11831 let started = Core::start_for_test_with_fetch(
11832 fetch_spec(true, root_path),
11833 Duration::from_secs(3600),
11834 crossbeam_channel::never(),
11835 fetch_tick_rx,
11836 )
11837 .discovered();
11838 let core = started.core;
11839
11840 wait_for("the immediate cycle to have run first", || {
11841 core.fetch_cycle_count_for_test() >= 1
11842 });
11843
11844 fetch_tick_tx
11845 .send(Instant::now())
11846 .expect("send a fetch tick");
11847
11848 wait_for("a tick on the fetch channel to run a second cycle", || {
11849 core.fetch_cycle_count_for_test() >= 2
11850 });
11851 }
11852
11853 fn break_remote(repo: &Path) {
11859 let status = Command::new("git")
11860 .arg("-C")
11861 .arg(repo)
11862 .args(["remote", "set-url", "origin", "/nonexistent-remote-282"])
11863 .status()
11864 .expect("run git remote set-url");
11865 assert!(status.success());
11866 }
11867
11868 #[test]
11870 fn a_cycle_in_which_every_fetch_succeeds_reports_no_failures() {
11871 let remote = seeded_remote();
11872 let root = tempfile::tempdir().expect("temp dir");
11873 let root_path = root_of(&root);
11874 clone_into(remote.path(), &root_path.join("parent"));
11875
11876 let fetch_ticks: Receiver<Instant> = crossbeam_channel::never();
11877 let started = Core::start_for_test_with_fetch(
11878 fetch_spec(true, root_path),
11879 Duration::from_secs(3600),
11880 crossbeam_channel::never(),
11881 fetch_ticks,
11882 )
11883 .discovered();
11884 let core = started.core;
11885
11886 wait_for("the periodic fetch to run its first cycle", || {
11887 core.fetch_cycle_count_for_test() >= 1
11888 });
11889
11890 assert!(
11891 core.fetch_failures().failed.is_empty(),
11892 "a cycle where every fetch succeeds must report no failures, got: {:?}",
11893 core.fetch_failures().failed
11894 );
11895 }
11896
11897 #[test]
11901 fn a_repository_that_cannot_be_fetched_is_counted_while_its_sibling_still_fetches() {
11902 let good_remote = seeded_remote();
11903 let bad_remote = seeded_remote();
11904 let root = tempfile::tempdir().expect("temp dir");
11905 let root_path = root_of(&root);
11906 let good = root_path.join("good");
11907 let bad = root_path.join("bad");
11908 clone_into(good_remote.path(), &good);
11909 clone_into(bad_remote.path(), &bad);
11910 break_remote(&bad);
11911
11912 crate::test_support::push_new_commit(good_remote.path(), "second.txt", "second\n");
11913 let good_remote_tip = rev_parse(good_remote.path(), "refs/heads/main");
11914
11915 let fetch_ticks: Receiver<Instant> = crossbeam_channel::never();
11916 let started = Core::start_for_test_with_fetch(
11917 fetch_spec(true, root_path),
11918 Duration::from_secs(3600),
11919 crossbeam_channel::never(),
11920 fetch_ticks,
11921 )
11922 .discovered();
11923 let core = started.core;
11924
11925 wait_for(
11926 "the cycle to run and count the one repository it could not fetch",
11927 || core.fetch_failures().failed.len() == 1,
11928 );
11929
11930 let failures = core.fetch_failures();
11931 assert_eq!(
11932 failures.failed.len(),
11933 1,
11934 "exactly one repository failed, so exactly one failure must be counted, \
11935 got: {:?}",
11936 failures.failed
11937 );
11938 assert!(
11939 failures.failed[0].0.to_string_lossy().contains("bad"),
11940 "the counted failure must name the repository that actually failed, \
11941 got: {:?}",
11942 failures.failed
11943 );
11944
11945 wait_for(
11946 "the sibling repository to still fetch despite the other one failing",
11947 || rev_parse(&good, "refs/remotes/origin/main") == good_remote_tip,
11948 );
11949 }
11950
11951 fn push_new_commit_on_branch(remote: &Path, branch: &str, name: &str, contents: &str) {
11955 let contributor = tempfile::tempdir().expect("temp dir");
11956 let status = Command::new("git")
11957 .arg("clone")
11958 .arg("--branch")
11959 .arg(branch)
11960 .arg(remote)
11961 .arg(contributor.path())
11962 .status()
11963 .expect("run git clone");
11964 assert!(status.success());
11965 std::fs::write(contributor.path().join(name), contents).expect("write fixture file");
11966 git(contributor.path(), &["add", name]);
11967 git(contributor.path(), &["commit", "-m", "extra work on topic"]);
11968 git(contributor.path(), &["push", "origin", branch]);
11969 }
11970
11971 #[test]
11979 fn a_finished_fetch_prunes_and_starts_its_own_generation_that_lands_gone() {
11980 let remote = seeded_remote();
11981 let root = tempfile::tempdir().expect("temp dir");
11982 let root_path = root_of(&root);
11983 let parent = root_path.join("parent");
11984 clone_into(remote.path(), &parent);
11985
11986 git(remote.path(), &["branch", "topic"]);
11987 push_new_commit_on_branch(remote.path(), "topic", "topic.txt", "extra work\n");
11988
11989 git(&parent, &["fetch", "origin"]);
11995
11996 let worktree_path = root_path.join("topic-worktree");
11997 git(
11998 &parent,
11999 &[
12000 "worktree",
12001 "add",
12002 "-b",
12003 "topic",
12004 worktree_path.to_str().expect("utf8 path"),
12005 "origin/topic",
12006 ],
12007 );
12008
12009 git(remote.path(), &["branch", "-D", "topic"]);
12013
12014 let fetch_ticks: Receiver<Instant> = crossbeam_channel::never();
12015 let started = Core::start_for_test_with_fetch(
12016 fetch_spec(true, root_path),
12017 Duration::from_secs(3600),
12018 crossbeam_channel::never(),
12019 fetch_ticks,
12020 )
12021 .discovered();
12022 let core = started.core;
12023
12024 wait_for_or(
12025 "a finished fetch's own Generation to land the pruned Worktree as Gone \
12026 without the test ever calling refresh",
12027 || {
12028 core.snapshot()
12029 .entities
12030 .iter()
12031 .filter(|entity| matches!(entity.kind, Kind::Worktree))
12032 .any(|entity| {
12033 matches!(
12034 entity.state.settled(),
12035 Some(Settled::Known {
12036 value: WorktreeState::Gone,
12037 at: _,
12038 stale: _,
12039 })
12040 )
12041 })
12042 },
12043 || {
12044 format!(
12045 "snapshot: {:?}",
12046 core.snapshot()
12047 .entities
12048 .iter()
12049 .map(|entity| (entity.kind, entity.state.settled().cloned()))
12050 .collect::<Vec<_>>()
12051 )
12052 },
12053 );
12054 }
12055
12056 fn spec_with_auto_update(
12057 fetch_enabled: bool,
12058 auto_update_enabled: bool,
12059 root: PathBuf,
12060 ) -> CoreSpec {
12061 let mut spec = fetch_spec(fetch_enabled, root);
12062 spec.auto_update = AutoUpdateSpec {
12063 enabled: auto_update_enabled,
12064 };
12065 spec
12066 }
12067
12068 fn rev_parse(path: &Path, rev: &str) -> String {
12069 let output = Command::new("git")
12070 .arg("-C")
12071 .arg(path)
12072 .args(["rev-parse", rev])
12073 .output()
12074 .expect("run git rev-parse");
12075 assert!(output.status.success(), "git rev-parse {rev} failed");
12076 String::from_utf8(output.stdout)
12077 .expect("utf8 sha")
12078 .trim()
12079 .to_string()
12080 }
12081
12082 #[test]
12089 fn auto_update_is_off_by_default_even_with_fetch_enabled() {
12090 let remote = seeded_remote();
12091 let root = tempfile::tempdir().expect("temp dir");
12092 let root_path = root_of(&root);
12093 let parent = root_path.join("parent");
12094 clone_into(remote.path(), &parent);
12095 let before = rev_parse(&parent, "refs/heads/main");
12096
12097 crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
12098
12099 let fetch_ticks: Receiver<Instant> = crossbeam_channel::never();
12100 let started = Core::start_for_test_with_fetch(
12101 spec_with_auto_update(true, false, root_path),
12102 Duration::from_secs(3600),
12103 crossbeam_channel::never(),
12104 fetch_ticks,
12105 )
12106 .discovered();
12107 let core = started.core;
12108
12109 wait_for(
12110 "the periodic fetch to still run its immediate cycle",
12111 || core.fetch_cycle_count_for_test() >= 1,
12112 );
12113 assert_eq!(
12114 rev_parse(&parent, "refs/heads/main"),
12115 before,
12116 "an eligible branch must not move while auto_update.enabled is false, \
12117 even though fetch.enabled is true"
12118 );
12119 }
12120
12121 #[test]
12128 fn auto_update_enabled_rides_the_immediate_fetch_cycle_with_no_timer_of_its_own() {
12129 let remote = seeded_remote();
12130 let root = tempfile::tempdir().expect("temp dir");
12131 let root_path = root_of(&root);
12132 let parent = root_path.join("parent");
12133 clone_into(remote.path(), &parent);
12134
12135 crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
12136 let remote_tip = rev_parse(remote.path(), "refs/heads/main");
12137
12138 let fetch_ticks: Receiver<Instant> = crossbeam_channel::never();
12139 let started = Core::start_for_test_with_fetch(
12140 spec_with_auto_update(true, true, root_path),
12141 Duration::from_secs(3600),
12142 crossbeam_channel::never(),
12143 fetch_ticks,
12144 )
12145 .discovered();
12146 let _core = started.core;
12149
12150 wait_for(
12151 "the eligible branch to fast-forward on the immediate cycle alone, with no \
12152 fetch tick and no auto-update tick of its own",
12153 || rev_parse(&parent, "refs/heads/main") == remote_tip,
12154 );
12155 }
12156 }
12157
12158 mod attempt_auto_update {
12168 use super::*;
12169
12170 fn seeded_remote() -> tempfile::TempDir {
12171 let remote = tempfile::tempdir().expect("temp dir");
12172 crate::test_support::init_bare(remote.path());
12173 crate::test_support::push_new_commit(remote.path(), "README.md", "seed\n");
12174 remote
12175 }
12176
12177 fn clone_into(remote: &Path, dest: &Path) {
12178 let status = Command::new("git")
12179 .arg("clone")
12180 .arg(remote)
12181 .arg(dest)
12182 .status()
12183 .expect("run git clone");
12184 assert!(status.success());
12185 crate::test_support::set_identity(dest);
12186 }
12187
12188 fn discover_repo(root: &Path) -> (Core, EntityKey) {
12193 let core = Core::start_discovered(spec(vec![root.to_path_buf()]));
12194 let key = core
12195 .settle()
12196 .entities
12197 .into_iter()
12198 .find(|entity| entity.kind == Kind::Repo)
12199 .expect("the Repo row is discovered")
12200 .key;
12201 (core, key)
12202 }
12203
12204 #[test]
12207 fn an_eligible_repo_fast_forwards_through_the_wrapper_too() {
12208 let remote = seeded_remote();
12209 let root = tempfile::tempdir().expect("temp dir");
12210 let root_path = root_of(&root);
12211 let repo = root_path.join("repo");
12212 clone_into(remote.path(), &repo);
12213 crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
12214 crate::test_support::git(&repo, &["fetch", "origin"]);
12215
12216 let (core, key) = discover_repo(&root_path);
12217
12218 assert_eq!(core.attempt_auto_update(&key), AutoUpdateAttempt::Updated);
12219 assert!(
12220 repo.join("second.txt").exists(),
12221 "the fast-forward must reach the working tree through the wrapper too"
12222 );
12223 }
12224
12225 #[test]
12227 fn a_dirty_repo_is_reported_not_clean_through_the_wrapper_too() {
12228 let remote = seeded_remote();
12229 let root = tempfile::tempdir().expect("temp dir");
12230 let root_path = root_of(&root);
12231 let repo = root_path.join("repo");
12232 clone_into(remote.path(), &repo);
12233 crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
12234 crate::test_support::git(&repo, &["fetch", "origin"]);
12235 fs::write(repo.join("stray.txt"), "uncommitted\n").expect("write a stray file");
12236
12237 let (core, key) = discover_repo(&root_path);
12238
12239 assert_eq!(core.attempt_auto_update(&key), AutoUpdateAttempt::NotClean);
12240 }
12241
12242 #[test]
12244 fn an_up_to_date_repo_is_reported_not_behind_through_the_wrapper_too() {
12245 let remote = seeded_remote();
12246 let root = tempfile::tempdir().expect("temp dir");
12247 let root_path = root_of(&root);
12248 let repo = root_path.join("repo");
12249 clone_into(remote.path(), &repo);
12250 crate::test_support::git(&repo, &["fetch", "origin"]);
12251
12252 let (core, key) = discover_repo(&root_path);
12253
12254 assert_eq!(core.attempt_auto_update(&key), AutoUpdateAttempt::NotBehind);
12255 }
12256
12257 #[test]
12259 fn an_unpublished_local_commit_is_reported_not_fast_forward_through_the_wrapper_too() {
12260 let remote = seeded_remote();
12261 let root = tempfile::tempdir().expect("temp dir");
12262 let root_path = root_of(&root);
12263 let repo = root_path.join("repo");
12264 clone_into(remote.path(), &repo);
12265 crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
12266 crate::test_support::git(&repo, &["fetch", "origin"]);
12267 crate::test_support::commit_file(&repo, "local-only.txt", "never pushed\n");
12268
12269 let (core, key) = discover_repo(&root_path);
12270
12271 assert_eq!(
12272 core.attempt_auto_update(&key),
12273 AutoUpdateAttempt::NotFastForward
12274 );
12275 }
12276
12277 #[test]
12279 fn a_branch_with_no_upstream_is_reported_through_the_wrapper_too() {
12280 let remote = seeded_remote();
12281 let root = tempfile::tempdir().expect("temp dir");
12282 let root_path = root_of(&root);
12283 let repo = root_path.join("repo");
12284 clone_into(remote.path(), &repo);
12285 crate::test_support::git(&repo, &["checkout", "-b", "untracked-branch"]);
12286
12287 let (core, key) = discover_repo(&root_path);
12288
12289 assert_eq!(
12290 core.attempt_auto_update(&key),
12291 AutoUpdateAttempt::NoUpstream
12292 );
12293 }
12294 }
12295
12296 mod network_default_branch {
12303 use super::*;
12304
12305 fn seeded_remote() -> tempfile::TempDir {
12306 let remote = tempfile::tempdir().expect("temp dir");
12307 crate::test_support::init_bare(remote.path());
12308 crate::test_support::push_new_commit(remote.path(), "README.md", "seed\n");
12309 remote
12310 }
12311
12312 fn clone_into(remote: &Path, dest: &Path) {
12313 let status = Command::new("git")
12314 .arg("clone")
12315 .arg(remote)
12316 .arg(dest)
12317 .status()
12318 .expect("run git clone");
12319 assert!(status.success());
12320 crate::test_support::set_identity(dest);
12321 }
12322
12323 fn set_remote_head(path: &Path, branch: &str) {
12326 git(
12327 path,
12328 &["symbolic-ref", "HEAD", &format!("refs/heads/{branch}")],
12329 );
12330 }
12331
12332 fn rev_parse(path: &Path, rev: &str) -> String {
12333 let output = Command::new("git")
12334 .arg("-C")
12335 .arg(path)
12336 .args(["rev-parse", rev])
12337 .output()
12338 .expect("run git rev-parse");
12339 assert!(output.status.success());
12340 String::from_utf8(output.stdout)
12341 .expect("utf8 sha")
12342 .trim()
12343 .to_string()
12344 }
12345
12346 fn default_branch_name(entity: &EntityState) -> Option<String> {
12347 match entity.default_branch.settled() {
12348 Some(Settled::Known {
12349 value,
12350 at: _,
12351 stale: _,
12352 }) => Some(value.name().to_string()),
12353 _ => None,
12354 }
12355 }
12356
12357 #[test]
12367 fn the_local_chain_answers_first_and_only_a_later_network_round_trip_supersedes_it() {
12368 let remote = seeded_remote();
12369 let root = tempfile::tempdir().expect("temp dir");
12370 let root_path = root_of(&root);
12371 let repo_path = root_path.join("repo");
12372 clone_into(remote.path(), &repo_path);
12373
12374 git(remote.path(), &["branch", "trunk"]);
12377 set_remote_head(remote.path(), "trunk");
12378
12379 let core = Core::start_discovered(spec(vec![root_path]));
12380 let key = core.snapshot().entities[0].key.clone();
12381
12382 core.refresh(std::slice::from_ref(&key));
12383 let settled = core.settle();
12384 assert_eq!(
12385 default_branch_name(&settled.entities[0]),
12386 Some("origin/main".to_string()),
12387 "a plain refresh must answer from the local chain alone, unaffected by the \
12388 remote's own current (but not yet asked) truth"
12389 );
12390
12391 core.rederive_default_branches(std::slice::from_ref(&key));
12392 let settled = core.settle();
12393 assert_eq!(
12394 default_branch_name(&settled.entities[0]),
12395 Some("origin/trunk".to_string()),
12396 "once the network round trip actually ran, its own differing answer must \
12397 supersede the local chain's"
12398 );
12399 }
12400
12401 #[test]
12411 fn rederive_default_branches_never_fetches_and_leaves_a_row_outside_it_untouched() {
12412 let remote = seeded_remote();
12413 let root = tempfile::tempdir().expect("temp dir");
12414 let root_path = root_of(&root);
12415 let selected_path = root_path.join("selected");
12416 let outside_path = root_path.join("outside");
12417 clone_into(remote.path(), &selected_path);
12418 init_repo_with_a_commit(&outside_path);
12419
12420 git(remote.path(), &["branch", "trunk"]);
12421 crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
12422 set_remote_head(remote.path(), "trunk");
12423 let before_tracking = rev_parse(&selected_path, "refs/remotes/origin/main");
12424
12425 let core = Core::start_discovered(spec(vec![root_path]));
12426 let snapshot = core.snapshot();
12427 let selected_key = snapshot
12428 .entities
12429 .iter()
12430 .find(|entity| entity.key.path() == selected_path)
12431 .expect("discovered the selected repo")
12432 .key
12433 .clone();
12434 let outside_key = snapshot
12435 .entities
12436 .iter()
12437 .find(|entity| entity.key.path() == outside_path)
12438 .expect("discovered the outside repo")
12439 .key
12440 .clone();
12441
12442 core.refresh(&[selected_key.clone(), outside_key.clone()]);
12443 let settled = core.settle();
12444 let outside_before = format!(
12445 "{:?}",
12446 settled
12447 .entities
12448 .iter()
12449 .find(|entity| entity.key == outside_key)
12450 .expect("outside entity present")
12451 );
12452
12453 core.rederive_default_branches(std::slice::from_ref(&selected_key));
12454 let settled = core.settle();
12455
12456 let selected_after = settled
12457 .entities
12458 .iter()
12459 .find(|entity| entity.key == selected_key)
12460 .expect("selected entity present");
12461 assert_eq!(
12462 default_branch_name(selected_after),
12463 Some("origin/trunk".to_string()),
12464 "the rederive must have reached the remote's own current, differing answer"
12465 );
12466
12467 let after_tracking = rev_parse(&selected_path, "refs/remotes/origin/main");
12468 assert_eq!(
12469 before_tracking, after_tracking,
12470 "a rederive must never fetch: the remote-tracking ref must not have moved \
12471 even though the remote gained a new commit"
12472 );
12473
12474 let outside_after = format!(
12475 "{:?}",
12476 settled
12477 .entities
12478 .iter()
12479 .find(|entity| entity.key == outside_key)
12480 .expect("outside entity present")
12481 );
12482 assert_eq!(
12483 outside_before, outside_after,
12484 "a row outside the rederive's own keys must be left exactly as it was, not \
12485 only on its default_branch cell"
12486 );
12487 }
12488 }
12489
12490 #[test]
12498 fn set_exclusions_excludes_a_row_already_in_the_table_with_no_rebuild() {
12499 let dir = tempfile::tempdir().expect("temp dir");
12500 let root = root_of(&dir);
12501 let repo = root.join("repo");
12502 init_repo_with_a_commit(&repo);
12503
12504 let core = Core::start_discovered(spec(vec![root]));
12505 let snapshot = core.settle();
12506 let key = snapshot.entities[0].key.clone();
12507 let generation_before = snapshot.generation;
12508 assert!(
12509 !snapshot.entities[0].excluded,
12510 "nothing excludes it to start with"
12511 );
12512 assert_eq!(core.operable_count(std::slice::from_ref(&key)), 1);
12513
12514 core.set_exclusions(&[RepoOverride {
12515 path: repo.clone(),
12516 default_branch: None,
12517 excluded: true,
12518 }]);
12519
12520 let after = core.snapshot();
12521 assert!(
12522 after.entities[0].excluded,
12523 "the row the write named is excluded in the very next snapshot"
12524 );
12525 assert_eq!(
12526 core.operable_count(&[key]),
12527 0,
12528 "an excluded row is subtracted from what an operation may reach"
12529 );
12530 assert_eq!(
12531 after.generation, generation_before,
12532 "re-applying an operate-time filter must start no Generation of its own"
12533 );
12534 }
12535
12536 #[test]
12539 fn set_exclusions_clears_the_flag_when_the_entry_is_gone() {
12540 let dir = tempfile::tempdir().expect("temp dir");
12541 let root = root_of(&dir);
12542 let repo = root.join("repo");
12543 init_repo_with_a_commit(&repo);
12544
12545 let core = Core::start_discovered(spec_with_overrides(
12546 vec![root],
12547 vec![RepoOverride {
12548 path: repo.clone(),
12549 default_branch: None,
12550 excluded: true,
12551 }],
12552 ));
12553 assert!(
12554 core.settle().entities[0].excluded,
12555 "the starting override excludes it"
12556 );
12557
12558 core.set_exclusions(&[]);
12559
12560 assert!(
12561 !core.snapshot().entities[0].excluded,
12562 "removing the entry unexcludes the row in the very next snapshot"
12563 );
12564 }
12565
12566 #[test]
12571 fn set_exclusions_moves_exclude_alone_and_never_the_default_branch_override() {
12572 let dir = tempfile::tempdir().expect("temp dir");
12573 let root = root_of(&dir);
12574 let repo = root.join("repo");
12575 init_repo_with_a_commit(&repo);
12576 crate::test_support::git(&repo, &["branch", "trunk"]);
12577
12578 let core = Core::start_discovered(spec(vec![root]));
12579 let key = core.settle().entities[0].key.clone();
12580 core.refresh(std::slice::from_ref(&key));
12581 let before = format!("{:?}", core.settle().entities[0].default_branch.settled());
12582
12583 core.set_exclusions(&[RepoOverride {
12584 path: repo.clone(),
12585 default_branch: Some("trunk".to_string()),
12586 excluded: true,
12587 }]);
12588 core.refresh(&[key]);
12589 core.settle();
12590
12591 let after = core.snapshot();
12592 assert!(after.entities[0].excluded, "exclude took effect");
12593 assert_eq!(
12594 format!("{:?}", after.entities[0].default_branch.settled()),
12595 before,
12596 "a default_branch override reaches a session only through a rebuilt Core"
12597 );
12598 }
12599
12600 #[test]
12608 fn record_own_work_leaves_one_receipt_per_row_it_names_and_none_elsewhere() {
12609 let dir = tempfile::tempdir().expect("temp dir");
12610 let root = root_of(&dir);
12611 init_repo_with_a_commit(&root.join("repo-a"));
12612 init_repo_with_a_commit(&root.join("repo-b"));
12613
12614 let core = Core::start_discovered(spec(vec![root]));
12615 let entities = core.settle().entities;
12616 let named = entities
12617 .iter()
12618 .find(|entity| &*entity.name == "repo-a")
12619 .expect("repo-a is discovered")
12620 .key
12621 .clone();
12622
12623 core.record_own_work(
12624 "ignore",
12625 &[(
12626 named.clone(),
12627 OwnWork::Refused(Arc::from("refused, already ignored")),
12628 Duration::from_millis(7),
12629 )],
12630 );
12631
12632 let after = core.snapshot().entities;
12633 let receipt = after
12634 .iter()
12635 .find(|entity| entity.key == named)
12636 .and_then(|entity| entity.last_action.clone())
12637 .expect("the row it named carries a receipt");
12638 assert_eq!(&*receipt.label, "ignore");
12639 assert!(
12640 !receipt.not_applicable(),
12641 "a refusal is not an excluded row"
12642 );
12643 assert!(receipt.running.is_none(), "the work is already done");
12644 assert_eq!(receipt.steps.len(), 1, "one act, not an ordered list");
12645 assert_eq!(&*receipt.steps[0].label, "ignore");
12646 assert_eq!(receipt.steps[0].elapsed, Duration::from_millis(7));
12647 assert!(receipt.steps[0].output.is_empty(), "nothing to quote");
12648 assert!(receipt.steps[0].elision.is_none());
12649 assert_eq!(
12650 receipt.steps[0].outcome,
12651 StepOutcome::OwnWork(OwnWork::Refused(Arc::from("refused, already ignored"))),
12652 );
12653 assert!(
12654 after
12655 .iter()
12656 .filter(|entity| entity.key != named)
12657 .all(|entity| entity.last_action.is_none()),
12658 "no row this did not name takes a receipt"
12659 );
12660 }
12661
12662 #[test]
12666 fn record_own_work_skips_a_key_the_table_no_longer_holds() {
12667 let dir = tempfile::tempdir().expect("temp dir");
12668 let root = root_of(&dir);
12669 init_repo_with_a_commit(&root.join("repo-a"));
12670
12671 let core = Core::start_discovered(spec(vec![root]));
12672 let entities = core.settle().entities;
12673 let stranger = EntityKey::new(Arc::from(std::path::Path::new("/nowhere/at/all")));
12674
12675 core.record_own_work(
12676 "delete",
12677 &[(stranger, OwnWork::Did(Arc::from("gone")), Duration::ZERO)],
12678 );
12679
12680 assert!(
12681 core.snapshot()
12682 .entities
12683 .iter()
12684 .all(|entity| entity.last_action.is_none()),
12685 "an unknown key writes nothing anywhere"
12686 );
12687 assert_eq!(core.snapshot().entities.len(), entities.len());
12688 }
12689
12690 #[test]
12699 fn delete_risk_reads_all_three_facts_the_confirm_gate_names() {
12700 let dir = tempfile::tempdir().expect("temp dir");
12701 let root = root_of(&dir);
12702 let repo = root.join("repo");
12703 init_repo_with_a_commit(&repo);
12704 fs::write(repo.join("uncommitted.txt"), "not staged\n").expect("write a stray file");
12705 crate::test_support::git(
12706 &repo,
12707 &["worktree", "add", "-b", "sidecar", "../sidecar-worktree"],
12708 );
12709
12710 let core = Core::start_discovered(spec(vec![root]));
12711 let key = core
12715 .settle()
12716 .entities
12717 .into_iter()
12718 .find(|entity| entity.kind == Kind::Repo)
12719 .expect("the Repo row is discovered")
12720 .key;
12721
12722 let risk = core.delete_risk(&key).expect("read the risk");
12723
12724 assert!(risk.uncommitted, "the stray file makes the tree dirty");
12725 assert!(
12726 risk.unpushed_commits > 0 && risk.unpushed_branches > 0,
12727 "no remote-tracking ref carries any of this Repo's commits, got {risk:?}"
12728 );
12729 assert_eq!(
12730 risk.linked_worktrees, 1,
12731 "the one linked Worktree pointing into this Repo is counted, got {risk:?}"
12732 );
12733 }
12734
12735 #[test]
12741 fn every_kind_of_work_that_is_not_in_a_commit_makes_the_gate_say_uncommitted() {
12742 for kind in ["modified", "deleted", "untracked", "staged"] {
12743 let dir = tempfile::tempdir().expect("temp dir");
12744 let root = root_of(&dir);
12745 let repo = root.join("repo");
12746 init_repo_with_a_commit(&repo);
12747 fs::write(repo.join("tracked.txt"), "first\n").expect("write a tracked file");
12748 crate::test_support::git(&repo, &["add", "tracked.txt"]);
12749 crate::test_support::git(&repo, &["commit", "-m", "add tracked"]);
12750 let sha = crate::test_support::head_sha(&repo);
12751 crate::test_support::git(&repo, &["update-ref", "refs/remotes/origin/main", &sha]);
12752
12753 match kind {
12754 "modified" => fs::write(repo.join("tracked.txt"), "second\n").expect("modify it"),
12755 "deleted" => fs::remove_file(repo.join("tracked.txt")).expect("delete it"),
12756 "untracked" => fs::write(repo.join("stray.txt"), "new\n").expect("write a stray"),
12757 "staged" => {
12758 fs::write(repo.join("staged.txt"), "new\n").expect("write a new file");
12759 crate::test_support::git(&repo, &["add", "staged.txt"]);
12760 }
12761 other => unreachable!("unhandled kind {other}"),
12762 }
12763
12764 let core = Core::start_discovered(spec(vec![root]));
12765 let key = core.settle().entities[0].key.clone();
12766
12767 let risk = core.delete_risk(&key).expect("read the risk");
12768
12769 assert!(
12770 risk.uncommitted,
12771 "a {kind} change is work that is not in a commit, got {risk:?}"
12772 );
12773 }
12774 }
12775
12776 #[test]
12783 fn staged_work_reads_clean_to_the_dirty_column_and_uncommitted_to_the_delete_gate() {
12784 let dir = tempfile::tempdir().expect("temp dir");
12785 let root = root_of(&dir);
12786 let repo = root.join("repo");
12787 init_repo_with_a_commit(&repo);
12788 let sha = crate::test_support::head_sha(&repo);
12789 crate::test_support::git(&repo, &["update-ref", "refs/remotes/origin/main", &sha]);
12790 fs::write(repo.join("staged.txt"), "staged\n").expect("write a new file");
12791 crate::test_support::git(&repo, &["add", "staged.txt"]);
12792
12793 let core = Core::start_discovered(spec(vec![root]));
12794 let key = core.settle().entities[0].key.clone();
12795
12796 let opened = git::open_thread_safe(repo.as_path())
12797 .expect("open the repo")
12798 .to_thread_local();
12799 let dirty = git::dirty_counts(&opened, Arc::new(AtomicBool::new(false)))
12800 .expect("read the dirty counts");
12801 assert_eq!(
12802 dirty.total(),
12803 0,
12804 "the dirty column stays an index-to-worktree comparison, got {dirty:?}"
12805 );
12806
12807 let risk = core.delete_risk(&key).expect("read the risk");
12808 assert!(
12809 risk.uncommitted,
12810 "a Repo whose only work is staged must never be listed plainly, got {risk:?}"
12811 );
12812 }
12813
12814 #[test]
12818 fn unpushed_commits_and_unpushed_branches_are_counted_into_their_own_fields() {
12819 let dir = tempfile::tempdir().expect("temp dir");
12820 let root = root_of(&dir);
12821 let repo = root.join("repo");
12822 init_repo_with_a_commit(&repo);
12823 let sha = crate::test_support::head_sha(&repo);
12824 crate::test_support::git(&repo, &["update-ref", "refs/remotes/origin/main", &sha]);
12825 for nth in 0..3 {
12826 fs::write(repo.join(format!("file-{nth}.txt")), "x\n").expect("write a file");
12827 crate::test_support::git(&repo, &["add", "."]);
12828 crate::test_support::git(&repo, &["commit", "-m", "unpushed"]);
12829 }
12830 crate::test_support::git(&repo, &["checkout", "."]);
12831
12832 let core = Core::start_discovered(spec(vec![root]));
12833 let key = core.settle().entities[0].key.clone();
12834
12835 let risk = core.delete_risk(&key).expect("read the risk");
12836
12837 assert_eq!(
12838 (risk.unpushed_commits, risk.unpushed_branches),
12839 (3, 1),
12840 "three commits on one branch, each in its own field, got {risk:?}"
12841 );
12842 }
12843
12844 #[test]
12848 fn a_linked_worktree_outside_the_sets_roots_is_still_counted_by_the_gate() {
12849 let dir = tempfile::tempdir().expect("temp dir");
12850 let base = root_of(&dir);
12851 let inside = base.join("inside");
12852 let outside = base.join("outside");
12853 fs::create_dir_all(&outside).expect("create the outside dir");
12854 let repo = inside.join("repo");
12855 init_repo_with_a_commit(&repo);
12856 crate::test_support::git(
12857 &repo,
12858 &["worktree", "add", "-b", "sidecar", "../../outside/sidecar"],
12859 );
12860 assert!(
12861 outside.join("sidecar").exists(),
12862 "the harness really created a linked Worktree outside the Set's roots"
12863 );
12864
12865 let core = Core::start_discovered(spec(vec![inside]));
12867 let snapshot = core.settle();
12868 assert!(
12869 snapshot
12870 .entities
12871 .iter()
12872 .all(|entity| entity.kind != Kind::Worktree),
12873 "the Worktree is outside the roots and so is not discovered, got {:?}",
12874 snapshot.entities.iter().map(|e| e.kind).collect::<Vec<_>>()
12875 );
12876 let key = snapshot
12877 .entities
12878 .into_iter()
12879 .find(|entity| entity.kind == Kind::Repo)
12880 .expect("the Repo row is discovered")
12881 .key;
12882
12883 let risk = core.delete_risk(&key).expect("read the risk");
12884
12885 assert_eq!(
12886 risk.linked_worktrees, 1,
12887 "the gate must name the linked Worktree deleting this Repo would orphan, got {risk:?}"
12888 );
12889 }
12890
12891 #[test]
12896 fn delete_risk_on_a_clean_fully_pushed_repo_with_no_worktrees_reports_nothing() {
12897 let dir = tempfile::tempdir().expect("temp dir");
12898 let root = root_of(&dir);
12899 let repo = root.join("repo");
12900 init_repo_with_a_commit(&repo);
12901 let sha = crate::test_support::head_sha(&repo);
12902 crate::test_support::git(&repo, &["update-ref", "refs/remotes/origin/main", &sha]);
12903
12904 let core = Core::start_discovered(spec(vec![root]));
12905 let key = core.settle().entities[0].key.clone();
12906
12907 let risk = core.delete_risk(&key).expect("read the risk");
12908
12909 assert_eq!(
12910 risk,
12911 DeleteRisk {
12912 uncommitted: false,
12913 unpushed_commits: 0,
12914 unpushed_branches: 0,
12915 linked_worktrees: 0,
12916 }
12917 );
12918 }
12919
12920 #[test]
12929 fn worktree_admin_dir_names_the_entry_git_worktree_list_forgets_once_it_is_removed() {
12930 let dir = tempfile::tempdir().expect("temp dir");
12931 let root = root_of(&dir);
12932 let repo = root.join("repo");
12933 init_repo_with_a_commit(&repo);
12934 let worktree = root.join("sidecar");
12935 crate::test_support::git(
12936 &repo,
12937 &[
12938 "worktree",
12939 "add",
12940 "-b",
12941 "sidecar",
12942 worktree.to_str().expect("utf8 path"),
12943 ],
12944 );
12945
12946 let core = Core::start_discovered(spec(vec![root]));
12947 let key = core
12948 .settle()
12949 .entities
12950 .into_iter()
12951 .find(|entity| entity.kind == Kind::Worktree)
12952 .expect("the Worktree row is discovered")
12953 .key;
12954
12955 let admin_dir = core.worktree_admin_dir(&key).expect("read the admin dir");
12956 fs::remove_dir_all(&admin_dir).expect("remove the admin dir by hand");
12957
12958 let reopened = git::open_thread_safe(&repo)
12959 .expect("reopen the repo")
12960 .to_thread_local();
12961 assert_eq!(
12962 git::linked_worktrees(&reopened).expect("count"),
12963 0,
12964 "removing the admin dir alone must be what git's own register stops naming"
12965 );
12966 }
12967
12968 #[test]
12972 fn worktree_admin_dir_errors_when_the_path_cannot_be_opened_as_a_repository() {
12973 let dir = tempfile::tempdir().expect("temp dir");
12974 let root = root_of(&dir);
12975 let not_a_repo = root.join("plain-directory");
12976 fs::create_dir_all(¬_a_repo).expect("create it");
12977
12978 let core = Core::start_discovered(spec(vec![root]));
12979 core.settle();
12980 let key = EntityKey::new(Arc::from(not_a_repo.as_path()));
12981
12982 assert!(core.worktree_admin_dir(&key).is_err());
12983 }
12984
12985 #[test]
12988 fn linked_worktree_paths_names_every_linked_worktrees_own_directory() {
12989 let dir = tempfile::tempdir().expect("temp dir");
12990 let root = root_of(&dir);
12991 let repo = root.join("repo");
12992 init_repo_with_a_commit(&repo);
12993 let first = root.join("first-worktree");
12994 let second = root.join("second-worktree");
12995 crate::test_support::git(
12996 &repo,
12997 &[
12998 "worktree",
12999 "add",
13000 "-b",
13001 "one",
13002 first.to_str().expect("utf8 path"),
13003 ],
13004 );
13005 crate::test_support::git(
13006 &repo,
13007 &[
13008 "worktree",
13009 "add",
13010 "-b",
13011 "two",
13012 second.to_str().expect("utf8 path"),
13013 ],
13014 );
13015
13016 let core = Core::start_discovered(spec(vec![root]));
13017 let key = core
13018 .settle()
13019 .entities
13020 .into_iter()
13021 .find(|entity| entity.kind == Kind::Repo)
13022 .expect("the Repo row is discovered")
13023 .key;
13024
13025 let mut paths = core
13026 .linked_worktree_paths(&key)
13027 .expect("read the linked worktree paths");
13028 paths.sort();
13029 let mut expected = vec![
13030 first.canonicalize().expect("canonicalize first"),
13031 second.canonicalize().expect("canonicalize second"),
13032 ];
13033 expected.sort();
13034
13035 assert_eq!(paths, expected);
13036 }
13037}