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 FetchNow,
399}
400
401pub struct Core {
409 table: Arc<RwLock<Table>>,
410 overrides: Arc<Vec<ResolvedOverride>>,
414 exclusions: Arc<RwLock<Vec<ResolvedExclusion>>>,
422 set: SetSpec,
430 discovery_manual: Arc<AtomicBool>,
435 discovery_warn_after: Duration,
438 discovery_abandon_after: Arc<AtomicU64>,
443 show_submodules: Arc<AtomicBool>,
450 settle_gate: Arc<SettleGate>,
451 control: Sender<ClockControl>,
452 clock_thread: Option<JoinHandle<()>>,
453 discovery_warning: Arc<Mutex<Option<String>>>,
459 #[allow(dead_code)] default_branch_chain_reads: Arc<AtomicUsize>,
471 #[allow(dead_code)] patch_identity_reads: Arc<AtomicUsize>,
481 #[allow(dead_code)] patch_scan_bounds: Arc<Mutex<Vec<Option<gix::ObjectId>>>>,
489 action_lifecycle: Arc<Mutex<ActionLifecycle>>,
495 #[allow(dead_code)] dispatch_log: Arc<Mutex<Vec<EntityKey>>>,
504 #[allow(dead_code)] phase_c_gates: Arc<Mutex<HashMap<EntityKey, PhaseCGateHandle>>>,
514 status_stale_after: Duration,
520 #[allow(dead_code)] poll_reprobed: Arc<Mutex<Vec<EntityKey>>>,
526 #[allow(dead_code)] poll_sweep_count: Arc<AtomicUsize>,
533 #[allow(dead_code)] fetch_cycle_count: Arc<AtomicUsize>,
540 network_default_branch: Arc<Mutex<HashMap<PathBuf, Arc<str>>>>,
551 fetch_failures: Arc<Mutex<FetchFailures>>,
555 turnstile: Arc<DispatchTurnstile>,
558 discovery_gate: Option<DiscoveryGate>,
560 #[cfg(test)]
563 action_completion_boundary: Arc<ActionCompletionBoundary>,
564 #[cfg(test)]
567 fetch_boundary: Arc<FetchBoundary>,
568}
569
570#[derive(Default)]
573struct PhaseCGate {
574 cheap_landed: bool,
576 may_proceed: bool,
579 finished: bool,
582}
583
584type PhaseCGateHandle = Arc<(Mutex<PhaseCGate>, Condvar)>;
587
588#[derive(Default)]
597struct ActionLifecycle {
598 live: Option<Arc<executor::RunControl>>,
607}
608
609impl ActionLifecycle {
610 fn admit(&mut self, control: Arc<executor::RunControl>) -> bool {
615 if self.live.is_some() {
616 return false;
617 }
618 self.live = Some(control);
619 true
620 }
621
622 fn complete(&mut self) {
624 self.live = None;
625 }
626}
627
628struct RunCompletion {
636 lifecycle: Arc<Mutex<ActionLifecycle>>,
637 #[cfg(test)]
639 boundary: Arc<ActionCompletionBoundary>,
640}
641
642impl Drop for RunCompletion {
643 fn drop(&mut self) {
644 #[cfg(test)]
646 self.boundary.hold();
647 self.lifecycle.lock().unwrap().complete();
648 }
649}
650
651#[cfg(test)]
660#[derive(Default)]
661pub(crate) struct ActionCompletionBoundary {
662 state: Mutex<BoundaryState>,
663 changed: Condvar,
664}
665
666#[cfg(test)]
668#[derive(Default)]
669struct BoundaryState {
670 armed: bool,
672 reached: bool,
674 released: bool,
676}
677
678#[cfg(test)]
679impl ActionCompletionBoundary {
680 pub(crate) fn arm(self: &Arc<Self>) -> ArmedBoundary {
683 self.state.lock().unwrap().armed = true;
684 ArmedBoundary(Arc::clone(self))
685 }
686
687 fn hold(&self) {
689 let mut state = self.state.lock().unwrap();
690 if !state.armed {
691 return;
692 }
693 state.reached = true;
694 self.changed.notify_all();
695 let (state, expiry) = self
696 .changed
697 .wait_timeout_while(state, liveness::BACKSTOP, |state| !state.released)
698 .unwrap();
699 drop(state);
700 if expiry.timed_out() {
701 liveness::expired(
702 liveness::BACKSTOP,
703 "a test to release the Action completion boundary",
704 "",
705 );
706 }
707 }
708}
709
710#[cfg(test)]
714pub(crate) struct ArmedBoundary(Arc<ActionCompletionBoundary>);
715
716#[cfg(test)]
717impl ArmedBoundary {
718 pub(crate) fn wait_until_reached(&self) {
720 let (state, expiry) = self
721 .0
722 .changed
723 .wait_timeout_while(self.0.state.lock().unwrap(), liveness::BACKSTOP, |state| {
724 !state.reached
725 })
726 .unwrap();
727 drop(state);
728 if expiry.timed_out() {
729 liveness::expired(
730 liveness::BACKSTOP,
731 "a completion to reach the Action completion boundary",
732 "",
733 );
734 }
735 }
736}
737
738#[cfg(test)]
739impl Drop for ArmedBoundary {
740 fn drop(&mut self) {
741 let mut state = self.0.state.lock().unwrap();
742 state.released = true;
743 self.0.changed.notify_all();
744 }
745}
746
747#[cfg(test)]
758#[derive(Default)]
759pub(crate) struct FetchBoundary {
760 state: Mutex<FetchBoundaryState>,
761 changed: Condvar,
762}
763
764#[cfg(test)]
766#[derive(Default)]
767struct FetchBoundaryState {
768 armed: bool,
770 reached: bool,
772 released: bool,
774 cancelled: bool,
776}
777
778#[cfg(test)]
779impl FetchBoundary {
780 pub(crate) fn arm(self: &Arc<Self>) -> ArmedFetchBoundary {
784 *self.state.lock().unwrap() = FetchBoundaryState {
785 armed: true,
786 ..FetchBoundaryState::default()
787 };
788 ArmedFetchBoundary(Arc::clone(self))
789 }
790
791 fn hold(&self) {
797 let mut state = self.state.lock().unwrap();
798 if !state.armed {
799 return;
800 }
801 state.reached = true;
802 self.changed.notify_all();
803 drop(
804 self.changed
805 .wait_while(state, |state| !state.released)
806 .unwrap(),
807 );
808 }
809
810 fn cancelled(&self) {
813 self.state.lock().unwrap().cancelled = true;
814 self.changed.notify_all();
815 }
816}
817
818#[cfg(test)]
821pub(crate) struct ArmedFetchBoundary(Arc<FetchBoundary>);
822
823#[cfg(test)]
824impl ArmedFetchBoundary {
825 pub(crate) fn wait_until_reached(&self) {
827 self.wait_until("a fetch to reach the fetch boundary", |state| state.reached);
828 }
829
830 pub(crate) fn wait_until_cancelled(&self) {
832 self.wait_until("the held cycle's own cancellation", |state| state.cancelled);
833 }
834
835 fn wait_until(&self, property: &str, held: impl Fn(&FetchBoundaryState) -> bool) {
836 let (state, expiry) = self
837 .0
838 .changed
839 .wait_timeout_while(self.0.state.lock().unwrap(), liveness::BACKSTOP, |state| {
840 !held(state)
841 })
842 .unwrap();
843 drop(state);
844 if expiry.timed_out() {
845 liveness::expired(liveness::BACKSTOP, property, "");
846 }
847 }
848}
849
850#[cfg(test)]
851impl Drop for ArmedFetchBoundary {
852 fn drop(&mut self) {
853 let mut state = self.0.state.lock().unwrap();
854 state.released = true;
855 self.0.changed.notify_all();
856 }
857}
858
859impl Core {
860 pub fn start(spec: CoreSpec) -> Core {
870 Self::start_watched(spec).core
871 }
872
873 fn start_watched(spec: CoreSpec) -> StartForTest {
875 let interval = spec.poll_interval.max(Duration::from_nanos(1));
876 let ticks = crossbeam_channel::tick(interval);
877 let alive = Arc::new(AtomicBool::new(true));
878 let fetch_start = FetchStart {
879 enabled: spec.fetch.enabled,
880 concurrency: spec.fetch.concurrency.max(1),
881 ticks: if spec.fetch.enabled {
882 crossbeam_channel::tick(spec.fetch.interval.max(Duration::from_nanos(1)))
883 } else {
884 crossbeam_channel::never()
885 },
886 };
887 start_internal(
888 spec,
889 Duration::from_secs(1),
890 discovery::ABANDON_AFTER,
891 ticks,
892 fetch_start,
893 alive,
894 None,
895 )
896 }
897
898 #[cfg(any(test, feature = "test-util"))]
909 pub fn start_discovered(spec: CoreSpec) -> Core {
910 let mut started = Self::start_watched(spec);
911 if let Some(handle) = started.initial_discovery.take() {
912 handle
913 .join()
914 .expect("the first discovery thread should not panic");
915 }
916 started.core
917 }
918
919 pub fn refresh(&self, order: &[EntityKey]) -> Generation {
924 self.refresh_handles().dispatch(order)
925 }
926
927 pub fn refresh_all(&self) -> Generation {
939 self.refresh_handles().dispatch_over_everything()
940 }
941
942 pub fn rederive_default_branches(&self, keys: &[EntityKey]) -> Generation {
967 let generation = {
968 let mut table = self.table.write().unwrap();
969 table.generation += 1;
970 Generation::new(table.generation)
971 };
972
973 let dispatched: Vec<RederiveCandidate> = {
974 let mut table = self.table.write().unwrap();
975 let mut dispatched = Vec::new();
976 for key in keys {
977 let Some(&idx) = table.index.get(key) else {
978 continue;
979 };
980 table.entities[idx].default_branch.begin_probe();
981 let common_dir = Arc::clone(&table.entities[idx].common_dir);
982 let override_branch = find_entry(&self.overrides, key.path(), &common_dir)
983 .and_then(|entry| entry.default_branch.clone());
984 let repo = table.repos.get(key).cloned();
985 let kind = table.entities[idx].kind;
986 dispatched.push(RederiveCandidate {
987 key: key.clone(),
988 path: key.path().to_path_buf(),
989 common_dir,
990 repo,
991 override_branch,
992 kind,
993 });
994 }
995 dispatched
996 };
997
998 if dispatched.is_empty() {
999 return generation;
1000 }
1001
1002 begin_probes_owed(&self.settle_gate, dispatched.len());
1003
1004 let table = Arc::clone(&self.table);
1005 let settle_gate = Arc::clone(&self.settle_gate);
1006 let network_default_branch = Arc::clone(&self.network_default_branch);
1007 thread::spawn(move || {
1008 let common_dirs: HashSet<Arc<Path>> = dispatched
1009 .iter()
1010 .map(|candidate| Arc::clone(&candidate.common_dir))
1011 .collect();
1012 probe_network_default_branches(&common_dirs, &network_default_branch);
1013
1014 let chain_cache: ChainFactsCache = Mutex::new(HashMap::new());
1019 let chain_reads = AtomicUsize::new(0);
1020 let never_cancelled = AtomicBool::new(false);
1021
1022 for candidate in dispatched {
1023 let RederiveCandidate {
1024 key,
1025 path,
1026 common_dir,
1027 repo,
1028 override_branch,
1029 kind,
1030 } = candidate;
1031 let network_branch = network_branch_for(&network_default_branch, &common_dir);
1032 let resolution = probe_default_branch_memoised(
1033 &path,
1034 repo.as_deref(),
1035 &common_dir,
1036 DefaultBranchHints {
1037 override_branch: override_branch.as_deref(),
1038 network_branch: network_branch.as_deref(),
1039 },
1040 kind,
1041 &never_cancelled,
1042 &ChainFactsMemo {
1043 cache: &chain_cache,
1044 reads: &chain_reads,
1045 },
1046 );
1047 {
1048 let mut table = table.write().unwrap();
1049 if let (Some(&idx), Some(resolution)) = (table.index.get(&key), resolution) {
1050 table.entities[idx].apply_default_branch_resolution(generation, resolution);
1051 }
1052 }
1053 complete_one(&settle_gate);
1054 }
1055 });
1056
1057 generation
1058 }
1059
1060 fn refresh_handles(&self) -> RefreshHandles {
1069 RefreshHandles {
1070 table: Arc::clone(&self.table),
1071 overrides: Arc::clone(&self.overrides),
1072 exclusions: Arc::clone(&self.exclusions),
1073 set: self.set.clone(),
1074 discovery_manual: Arc::clone(&self.discovery_manual),
1075 discovery_warn_after: self.discovery_warn_after,
1076 discovery_abandon_after: Arc::clone(&self.discovery_abandon_after),
1077 discovery_warning: Arc::clone(&self.discovery_warning),
1078 show_submodules: Arc::clone(&self.show_submodules),
1079 settle_gate: Arc::clone(&self.settle_gate),
1080 default_branch_chain_reads: Arc::clone(&self.default_branch_chain_reads),
1081 patch_identity_reads: Arc::clone(&self.patch_identity_reads),
1082 patch_scan_bounds: Arc::clone(&self.patch_scan_bounds),
1083 dispatch_log: Arc::clone(&self.dispatch_log),
1084 phase_c_gates: Arc::clone(&self.phase_c_gates),
1085 network_default_branch: Arc::clone(&self.network_default_branch),
1086 turnstile: Arc::clone(&self.turnstile),
1087 discovery_gate: self.discovery_gate.clone(),
1088 }
1089 }
1090
1091 pub fn probe_now(&self, key: &EntityKey) -> EntityState {
1096 let never_cancelled = Arc::new(AtomicBool::new(false));
1100 let (cached_repo, common_dir_hint, probes_state, probes_base, kind) = {
1101 let table = self.table.read().unwrap();
1102 let repo = table.repos.get(key).cloned();
1103 let common_dir = table
1104 .index
1105 .get(key)
1106 .map(|&idx| Arc::clone(&table.entities[idx].common_dir));
1107 let probes_state = table
1112 .index
1113 .get(key)
1114 .map(|&idx| table.entities[idx].probes_state())
1115 .unwrap_or(false);
1116 let probes_base = table
1117 .index
1118 .get(key)
1119 .map(|&idx| table.entities[idx].probes_base())
1120 .unwrap_or(true);
1121 let kind = table
1124 .index
1125 .get(key)
1126 .map(|&idx| table.entities[idx].kind)
1127 .unwrap_or(Kind::Repo);
1128 (repo, common_dir, probes_state, probes_base, kind)
1129 };
1130 let common_dir_hint = common_dir_hint.unwrap_or_else(|| Arc::from(key.path().join(".git")));
1131 let override_branch = find_entry(&self.overrides, key.path(), &common_dir_hint)
1132 .and_then(|entry| entry.default_branch.clone());
1133 let excluded = excluded_by(
1134 &self.exclusions.read().unwrap(),
1135 key.path(),
1136 &common_dir_hint,
1137 );
1138
1139 let branch_outcome =
1140 probe_branch(key.path(), cached_repo.as_deref(), kind, &never_cancelled);
1141 let sync_outcome = probe_sync(
1142 key.path(),
1143 cached_repo.as_deref(),
1144 branch_outcome.as_ref().map(|(settled, ..)| settled),
1145 kind,
1146 &never_cancelled,
1147 );
1148 let default_branch_outcome = probe_default_branch(
1149 key.path(),
1150 cached_repo.as_deref(),
1151 DefaultBranchHints {
1152 override_branch: override_branch.as_deref(),
1153 network_branch: network_branch_for(&self.network_default_branch, &common_dir_hint)
1154 .as_deref(),
1155 },
1156 kind,
1157 &never_cancelled,
1158 );
1159 let base_outcome = if probes_base {
1160 probe_base(
1161 key.path(),
1162 cached_repo.as_deref(),
1163 branch_outcome.as_ref().map(|(settled, ..)| settled),
1164 default_branch_outcome.as_ref().map(|r| &r.settled),
1165 &never_cancelled,
1166 )
1167 } else {
1168 None
1169 };
1170 let state_outcome = if probes_state {
1171 let patch_cache: PatchIdentityCache = Mutex::new(HashMap::new());
1176 let patch_reads = AtomicUsize::new(0);
1177 let patch_scan_bounds = Mutex::new(Vec::new());
1178 let gate = BoundGate::new(1);
1179 let mut report = GateReport::new(&gate);
1180 let memo = PatchEquivalenceMemo {
1181 cache: &patch_cache,
1182 reads: &patch_reads,
1183 scan_bounds: &patch_scan_bounds,
1184 };
1185 probe_worktree_state(
1186 key.path(),
1187 cached_repo.as_deref(),
1188 default_branch_outcome.as_ref().map(|r| &r.settled),
1189 &common_dir_hint,
1190 &never_cancelled,
1191 &memo,
1192 &mut report,
1193 )
1194 } else {
1195 None
1196 };
1197 let dirty_outcome =
1198 probe_status(key.path(), cached_repo.as_deref(), kind, &never_cancelled);
1199
1200 let mut table = self.table.write().unwrap();
1201 let generation = Generation::new(table.generation);
1202 let idx = match table.index.get(key).copied() {
1203 Some(idx) => idx,
1204 None => {
1205 let name = display_name(key.path());
1206 table.entities.push(EntityState::new(
1207 key.clone(),
1208 name,
1209 common_dir_hint,
1210 Kind::Repo,
1211 ));
1212 let idx = table.entities.len() - 1;
1213 table.index.insert(key.clone(), idx);
1214 idx
1215 }
1216 };
1217 table.entities[idx].excluded = excluded;
1218 if let Some((settled, in_progress, recent)) = branch_outcome {
1219 table.entities[idx].apply_branch_probe(generation, settled, in_progress, recent);
1220 }
1221 if let Some(settled) = sync_outcome {
1222 table.entities[idx].sync.settle(generation, settled);
1223 }
1224 if let Some(settled) = base_outcome {
1225 table.entities[idx].base.settle(generation, settled);
1226 }
1227 if let Some(resolution) = default_branch_outcome {
1228 table.entities[idx].apply_default_branch_resolution(generation, resolution);
1229 }
1230 if let Some(settled) = state_outcome {
1231 table.entities[idx].state.settle(generation, settled);
1232 }
1233 if let Some(settled) = dirty_outcome {
1234 table.entities[idx].dirty.settle(generation, settled);
1235 }
1236 table.entities[idx].clone()
1237 }
1238
1239 pub fn snapshot(&self) -> Snapshot {
1245 let table = self.table.read().unwrap();
1246 let mut entities = table.entities.clone();
1247 for entity in &mut entities {
1248 entity.age_status_cells(self.status_stale_after);
1249 }
1250 Snapshot {
1251 generation: Generation::new(table.generation),
1252 discovered_at: table.discovered_at,
1253 entities,
1254 }
1255 }
1256
1257 pub fn try_settle(&self, within: Duration) -> Result<Snapshot, Snapshot> {
1267 let (lock, cvar) = &*self.settle_gate;
1268 let guard = lock.lock().unwrap();
1269 let (guard, timeout) = cvar
1270 .wait_timeout_while(guard, within, |counts| !counts.is_settled())
1271 .unwrap();
1272 drop(guard);
1275 let snapshot = self.snapshot();
1276 if timeout.timed_out() {
1277 Err(snapshot)
1278 } else {
1279 Ok(snapshot)
1280 }
1281 }
1282
1283 #[cfg(any(test, feature = "test-util"))]
1294 pub fn settle(&self) -> Snapshot {
1295 self.settle_within(liveness::BACKSTOP)
1296 }
1297
1298 #[cfg(any(test, feature = "test-util"))]
1302 fn settle_within(&self, deadline: Duration) -> Snapshot {
1303 self.try_settle(deadline).unwrap_or_else(|_| {
1304 let (probes, dispatches) = {
1308 let counts = self.settle_gate.0.lock().unwrap();
1309 (counts.probes, counts.dispatches)
1310 };
1311 liveness::expired(
1312 deadline,
1313 "everything this Core has in flight to land",
1314 &format!("{probes} probe(s) and {dispatches} dispatch(es) still outstanding"),
1315 )
1316 })
1317 }
1318
1319 pub fn delete_risk(&self, key: &EntityKey) -> Result<DeleteRisk, git::ProbeError> {
1335 let repo = git::open_thread_safe(key.path())?.to_thread_local();
1336 let dirty = git::dirty_counts(&repo, Arc::new(AtomicBool::new(false)))?;
1337 let staged = git::staged_changes(&repo)?;
1338 let (unpushed_commits, unpushed_branches) = git::unpushed(&repo)?;
1339 let linked_worktrees = git::linked_worktrees(&repo)?;
1340 Ok(DeleteRisk {
1341 uncommitted: dirty.total() > 0 || staged,
1342 unpushed_commits,
1343 unpushed_branches,
1344 linked_worktrees,
1345 })
1346 }
1347
1348 pub fn worktree_admin_dir(&self, key: &EntityKey) -> Result<PathBuf, git::ProbeError> {
1356 let repo = git::open_thread_safe(key.path())?.to_thread_local();
1357 Ok(git::worktree_admin_dir(&repo))
1358 }
1359
1360 pub fn linked_worktree_paths(&self, key: &EntityKey) -> Result<Vec<PathBuf>, git::ProbeError> {
1367 let repo = git::open_thread_safe(key.path())?.to_thread_local();
1368 git::linked_worktree_paths(&repo)
1369 }
1370
1371 pub fn ignored_directories_for_deletion(
1379 &self,
1380 path: &Path,
1381 ) -> Result<Vec<PathBuf>, git::ProbeError> {
1382 let repo = git::open_thread_safe(path)?.to_thread_local();
1383 git::ignored_directories_for_deletion(&repo)
1384 }
1385
1386 pub fn attempt_auto_update(&self, key: &EntityKey) -> AutoUpdateAttempt {
1393 match crate::auto_update::attempt(key.path()) {
1394 crate::auto_update::Outcome::Ineligible(crate::auto_update::Ineligible::NotClean) => {
1395 AutoUpdateAttempt::NotClean
1396 }
1397 crate::auto_update::Outcome::Ineligible(crate::auto_update::Ineligible::NoUpstream) => {
1398 AutoUpdateAttempt::NoUpstream
1399 }
1400 crate::auto_update::Outcome::Ineligible(crate::auto_update::Ineligible::NotBehind) => {
1401 AutoUpdateAttempt::NotBehind
1402 }
1403 crate::auto_update::Outcome::Ineligible(
1404 crate::auto_update::Ineligible::NotFastForward,
1405 ) => AutoUpdateAttempt::NotFastForward,
1406 crate::auto_update::Outcome::Updated { .. } => AutoUpdateAttempt::Updated,
1407 crate::auto_update::Outcome::Failed(error) => AutoUpdateAttempt::Failed(error),
1408 }
1409 }
1410
1411 pub fn run_action_for_entity_blocking(
1424 &self,
1425 action: &ActionSpec,
1426 key: &EntityKey,
1427 ) -> Option<ActionReceipt> {
1428 let entity = {
1429 let table = self.table.read().unwrap();
1430 let idx = *table.index.get(key)?;
1431 table.entities[idx].clone()
1432 };
1433 let control = executor::RunControl::new();
1434 Some(run_action_for_entity(&entity, action, &control, &|_| {}))
1435 }
1436
1437 pub fn management_handle(&self) -> ManagementHandle {
1443 ManagementHandle {
1444 table: Arc::clone(&self.table),
1445 }
1446 }
1447
1448 pub fn dismiss(&self, key: &EntityKey) {
1450 let mut table = self.table.write().unwrap();
1451 if let Some(idx) = table.index.remove(key) {
1452 table.entities.remove(idx);
1453 for position in table.index.values_mut() {
1454 if *position > idx {
1455 *position -= 1;
1456 }
1457 }
1458 }
1459 table.poll_fingerprints.remove(key);
1460 if let Some(in_flight) = table.in_flight.remove(key) {
1461 in_flight.cancel.store(true, Ordering::Release);
1462 drop(table);
1463 complete_one(&self.settle_gate);
1464 }
1465 }
1466
1467 fn partition_operable(&self, order: &[EntityKey]) -> (Vec<EntityState>, Vec<EntityState>) {
1479 let table = self.table.read().unwrap();
1480 order
1481 .iter()
1482 .filter_map(|key| table.index.get(key).map(|&idx| table.entities[idx].clone()))
1483 .partition(|entity| !entity.excluded)
1484 }
1485
1486 pub fn operable_count(&self, order: &[EntityKey]) -> usize {
1493 self.partition_operable(order).0.len()
1494 }
1495
1496 pub fn vanished_count(&self) -> usize {
1500 self.table
1501 .read()
1502 .unwrap()
1503 .entities
1504 .iter()
1505 .filter(|entity| entity.presence == Presence::Vanished)
1506 .count()
1507 }
1508
1509 pub fn applicability(&self, order: &[EntityKey], when: &Filter) -> Applicability {
1523 when.applicability(self.partition_operable(order).0.iter())
1524 }
1525
1526 pub fn action_running(&self) -> bool {
1533 self.action_lifecycle.lock().unwrap().live.is_some()
1534 }
1535
1536 pub fn refresh_running(&self) -> bool {
1546 let (lock, _cvar) = &*self.settle_gate;
1547 !lock.lock().unwrap().is_settled()
1548 }
1549
1550 pub fn run_action(&self, action: ActionSpec, order: &[EntityKey]) -> bool {
1598 let control = executor::RunControl::new();
1603 if !self
1604 .action_lifecycle
1605 .lock()
1606 .unwrap()
1607 .admit(Arc::clone(&control))
1608 {
1609 return false;
1610 }
1611
1612 cancel_in_flight(&self.table, &self.settle_gate);
1615
1616 let (operable, excluded) = self.partition_operable(order);
1617
1618 let write_skip_receipts = |entities: &[EntityState], skip: Skip| {
1619 if entities.is_empty() {
1620 return;
1621 }
1622 let finished_at = Timestamp::now();
1623 let mut table = self.table.write().unwrap();
1624 for entity in entities {
1625 if let Some(&idx) = table.index.get(&entity.key) {
1626 table.entities[idx].last_action = Some(ActionReceipt {
1627 label: Arc::clone(&action.label),
1628 steps: Arc::from(Vec::new()),
1629 skip: Some(skip),
1630 finished_at,
1631 running: None,
1632 });
1633 }
1634 }
1635 };
1636
1637 write_skip_receipts(&excluded, Skip::Excluded);
1638
1639 let included = match &action.when {
1640 Some(when) => {
1641 let Partition {
1642 applicable,
1643 inapplicable,
1644 unresolved,
1645 } = when.partition(operable);
1646 write_skip_receipts(&inapplicable, Skip::Inapplicable);
1647 write_skip_receipts(&unresolved, Skip::Unresolved);
1648 applicable
1649 }
1650 None => operable,
1651 };
1652
1653 let table_handle = Arc::clone(&self.table);
1654 let refresh_handles = self.refresh_handles();
1655 let action_lifecycle = Arc::clone(&self.action_lifecycle);
1656 #[cfg(test)]
1657 let completion_boundary = Arc::clone(&self.action_completion_boundary);
1658 let concurrency = action.concurrency.max(1) as usize;
1665
1666 thread::spawn(move || {
1672 let pool = rayon::ThreadPoolBuilder::new()
1673 .num_threads(concurrency)
1674 .build()
1675 .expect("build the Action fan-out's own dedicated pool");
1676
1677 let fan_out = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1683 pool.install(|| {
1684 included.into_par_iter().for_each(|entity| {
1685 let write_receipt = |receipt: ActionReceipt| {
1686 let mut table = table_handle.write().unwrap();
1687 if let Some(&idx) = table.index.get(&entity.key) {
1688 table.entities[idx].last_action = Some(receipt);
1689 }
1690 };
1691 let receipt =
1692 run_action_for_entity(&entity, &action, &control, &write_receipt);
1693 write_receipt(receipt);
1694 });
1695 });
1696 }));
1697
1698 let completion = RunCompletion {
1709 lifecycle: action_lifecycle,
1710 #[cfg(test)]
1711 boundary: completion_boundary,
1712 };
1713
1714 let Ok(()) = fan_out else {
1719 return;
1720 };
1721
1722 let all_keys: Vec<EntityKey> = table_handle
1725 .read()
1726 .unwrap()
1727 .entities
1728 .iter()
1729 .map(|entity| entity.key.clone())
1730 .collect();
1731 refresh_handles.dispatch(&all_keys);
1732 drop(completion);
1733 });
1735
1736 true
1737 }
1738
1739 pub fn hold_action(&self) {
1746 if let Some(control) = self.live_action_control() {
1747 control.hold();
1748 }
1749 }
1750
1751 pub fn continue_action(&self) {
1754 if let Some(control) = self.live_action_control() {
1755 control.continue_run();
1756 }
1757 }
1758
1759 pub fn stop_action(&self) {
1768 if let Some(control) = self.live_action_control() {
1769 control.cancel();
1770 }
1771 }
1772
1773 fn live_action_control(&self) -> Option<Arc<executor::RunControl>> {
1777 self.action_lifecycle.lock().unwrap().live.clone()
1778 }
1779
1780 pub fn pause(&self) {
1783 let _ = self.control.send(ClockControl::Pause);
1784 }
1785
1786 pub fn resume(&self) {
1790 let _ = self.control.send(ClockControl::Resume);
1791 }
1792
1793 pub fn discovery_warning(&self) -> Option<String> {
1799 self.discovery_warning.lock().unwrap().clone()
1800 }
1801
1802 pub fn fetch_failures(&self) -> FetchFailures {
1809 self.fetch_failures.lock().unwrap().clone()
1810 }
1811
1812 pub fn set_show_submodules(&self, show_submodules: bool) {
1819 self.show_submodules
1820 .store(show_submodules, Ordering::Release);
1821 }
1822
1823 pub fn record_own_work(&self, label: &str, results: &[(EntityKey, OwnWork, Duration)]) {
1843 let label: Arc<str> = Arc::from(label);
1844 let finished_at = Timestamp::now();
1845 let mut table = self.table.write().unwrap();
1846 for (key, work, elapsed) in results {
1847 let Some(&idx) = table.index.get(key) else {
1848 continue;
1849 };
1850 table.entities[idx].last_action = Some(ActionReceipt {
1851 label: Arc::clone(&label),
1852 steps: Arc::from(vec![StepResult {
1853 label: Arc::clone(&label),
1854 outcome: StepOutcome::OwnWork(work.clone()),
1855 output: Arc::from(&b""[..]),
1856 elapsed: *elapsed,
1857 elision: None,
1858 shell: false,
1859 interactive: false,
1860 }]),
1861 skip: None,
1862 finished_at,
1863 running: None,
1864 });
1865 }
1866 }
1867
1868 pub fn set_exclusions(&self, overrides: &[RepoOverride]) {
1879 let (_, resolved) = resolve_entries(overrides);
1880 {
1883 let mut exclusions = self.exclusions.write().unwrap();
1884 *exclusions = resolved.clone();
1885 }
1886 let mut table = self.table.write().unwrap();
1887 for entity in &mut table.entities {
1888 entity.excluded = excluded_by(&resolved, entity.key.path(), &entity.common_dir);
1889 }
1890 }
1891}
1892
1893#[derive(Clone)]
1902pub struct ManagementHandle {
1903 table: Arc<RwLock<Table>>,
1904}
1905
1906impl ManagementHandle {
1907 pub fn worktree_admin_dir(&self, key: &EntityKey) -> Result<PathBuf, git::ProbeError> {
1909 let repo = git::open_thread_safe(key.path())?.to_thread_local();
1910 Ok(git::worktree_admin_dir(&repo))
1911 }
1912
1913 pub fn linked_worktree_paths(&self, key: &EntityKey) -> Result<Vec<PathBuf>, git::ProbeError> {
1915 let repo = git::open_thread_safe(key.path())?.to_thread_local();
1916 git::linked_worktree_paths(&repo)
1917 }
1918
1919 pub fn ignored_directories_for_deletion(
1922 &self,
1923 path: &Path,
1924 ) -> Result<Vec<PathBuf>, git::ProbeError> {
1925 let repo = git::open_thread_safe(path)?.to_thread_local();
1926 git::ignored_directories_for_deletion(&repo)
1927 }
1928
1929 pub fn attempt_auto_update(&self, key: &EntityKey) -> AutoUpdateAttempt {
1931 match crate::auto_update::attempt(key.path()) {
1932 crate::auto_update::Outcome::Ineligible(crate::auto_update::Ineligible::NotClean) => {
1933 AutoUpdateAttempt::NotClean
1934 }
1935 crate::auto_update::Outcome::Ineligible(crate::auto_update::Ineligible::NoUpstream) => {
1936 AutoUpdateAttempt::NoUpstream
1937 }
1938 crate::auto_update::Outcome::Ineligible(crate::auto_update::Ineligible::NotBehind) => {
1939 AutoUpdateAttempt::NotBehind
1940 }
1941 crate::auto_update::Outcome::Ineligible(
1942 crate::auto_update::Ineligible::NotFastForward,
1943 ) => AutoUpdateAttempt::NotFastForward,
1944 crate::auto_update::Outcome::Updated { .. } => AutoUpdateAttempt::Updated,
1945 crate::auto_update::Outcome::Failed(error) => AutoUpdateAttempt::Failed(error),
1946 }
1947 }
1948
1949 pub fn run_action_for_entity_blocking(
1952 &self,
1953 action: &ActionSpec,
1954 key: &EntityKey,
1955 ) -> Option<ActionReceipt> {
1956 let entity = {
1957 let table = self.table.read().unwrap();
1958 let idx = *table.index.get(key)?;
1959 table.entities[idx].clone()
1960 };
1961 let control = executor::RunControl::new();
1962 Some(run_action_for_entity(&entity, action, &control, &|_| {}))
1963 }
1964}
1965
1966#[derive(Clone)]
1976struct RefreshHandles {
1977 table: Arc<RwLock<Table>>,
1978 overrides: Arc<Vec<ResolvedOverride>>,
1979 exclusions: Arc<RwLock<Vec<ResolvedExclusion>>>,
1982 set: SetSpec,
1983 discovery_manual: Arc<AtomicBool>,
1984 discovery_warn_after: Duration,
1985 discovery_abandon_after: Arc<AtomicU64>,
1986 discovery_warning: Arc<Mutex<Option<String>>>,
1987 show_submodules: Arc<AtomicBool>,
1988 settle_gate: Arc<SettleGate>,
1989 default_branch_chain_reads: Arc<AtomicUsize>,
1990 patch_identity_reads: Arc<AtomicUsize>,
1991 patch_scan_bounds: Arc<Mutex<Vec<Option<gix::ObjectId>>>>,
1992 dispatch_log: Arc<Mutex<Vec<EntityKey>>>,
1993 phase_c_gates: Arc<Mutex<HashMap<EntityKey, PhaseCGateHandle>>>,
1994 network_default_branch: Arc<Mutex<HashMap<PathBuf, Arc<str>>>>,
1998 turnstile: Arc<DispatchTurnstile>,
2001 discovery_gate: Option<DiscoveryGate>,
2003}
2004
2005#[derive(Default)]
2014struct DispatchTurnstile {
2015 serving: Mutex<u64>,
2017 ready: Condvar,
2018 next: AtomicU64,
2022}
2023
2024impl DispatchTurnstile {
2025 fn reserve(&self) -> u64 {
2026 self.next.fetch_add(1, Ordering::AcqRel)
2027 }
2028
2029 fn take(&self, ticket: u64) -> DispatchTurn<'_> {
2033 let serving = self.serving.lock().unwrap();
2034 drop(
2035 self.ready
2036 .wait_while(serving, |serving| *serving != ticket)
2037 .unwrap(),
2038 );
2039 DispatchTurn {
2040 turnstile: self,
2041 ticket,
2042 }
2043 }
2044}
2045
2046struct DispatchTurn<'a> {
2048 turnstile: &'a DispatchTurnstile,
2049 ticket: u64,
2050}
2051
2052impl Drop for DispatchTurn<'_> {
2053 fn drop(&mut self) {
2054 let mut serving = self.turnstile.serving.lock().unwrap();
2055 *serving = self.ticket + 1;
2056 self.turnstile.ready.notify_all();
2057 }
2058}
2059
2060impl RefreshHandles {
2061 fn dispatch(&self, order: &[EntityKey]) -> Generation {
2070 let (generation, ticket) = self.reserve_generation();
2071 begin_dispatch(&self.settle_gate);
2072 let handles = self.clone();
2073 let order = order.to_vec();
2074 thread::spawn(move || {
2075 let _turn = handles.turnstile.take(ticket);
2076 handles.run_generation(&order, generation);
2077 finish_dispatch(&handles.settle_gate);
2078 });
2079 generation
2080 }
2081
2082 fn dispatch_over_everything(&self) -> Generation {
2086 let (generation, ticket) = self.reserve_generation();
2087 begin_dispatch(&self.settle_gate);
2088 let handles = self.clone();
2089 thread::spawn(move || {
2090 let _turn = handles.turnstile.take(ticket);
2091 handles.rediscover();
2092 let order: Vec<EntityKey> = handles
2093 .table
2094 .read()
2095 .unwrap()
2096 .entities
2097 .iter()
2098 .map(|entity| entity.key.clone())
2099 .collect();
2100 handles.dispatch_probes(&order, generation);
2101 finish_dispatch(&handles.settle_gate);
2102 });
2103 generation
2104 }
2105
2106 fn reserve_generation(&self) -> (Generation, u64) {
2109 let mut table = self.table.write().unwrap();
2110 table.generation += 1;
2111 (Generation::new(table.generation), self.turnstile.reserve())
2112 }
2113
2114 fn run_generation(&self, order: &[EntityKey], generation: Generation) {
2117 self.rediscover();
2118 self.dispatch_probes(order, generation);
2119 }
2120
2121 fn rediscover(&self) {
2127 if !self.discovery_manual.load(Ordering::Acquire) {
2128 self.rerun_discovery();
2129 }
2130 }
2131
2132 fn dispatch_probes(&self, order: &[EntityKey], generation: Generation) {
2138 self.default_branch_chain_reads.store(0, Ordering::Release);
2143 self.patch_identity_reads.store(0, Ordering::Release);
2144 self.patch_scan_bounds.lock().unwrap().clear();
2145 self.dispatch_log.lock().unwrap().clear();
2146
2147 let generation_number = generation.value();
2148 let mut table = self.table.write().unwrap();
2149 table
2150 .generation_started_at
2151 .insert(generation_number, Instant::now());
2152
2153 let show_submodules = self.show_submodules.load(Ordering::Acquire);
2154 let mut dispatched = Vec::new();
2155 for key in order {
2156 let Some(&idx) = table.index.get(key) else {
2157 continue;
2158 };
2159 if !dispatches_kind(table.entities[idx].kind, show_submodules) {
2160 continue;
2164 }
2165 if let Some(previous) = table.in_flight.remove(key) {
2166 previous.cancel.store(true, Ordering::Release);
2167 }
2168 let cancel = Arc::new(AtomicBool::new(false));
2169 table.in_flight.insert(
2170 key.clone(),
2171 InFlight {
2172 generation: generation_number,
2173 cancel: Arc::clone(&cancel),
2174 },
2175 );
2176 begin_probes(&mut table.entities[idx]);
2177 dispatched.push((key.clone(), cancel));
2178 }
2179
2180 if dispatched.is_empty() {
2181 return;
2182 }
2183
2184 begin_probes_owed(&self.settle_gate, dispatched.len());
2185 let repos: Vec<Option<Arc<gix::ThreadSafeRepository>>> = dispatched
2186 .iter()
2187 .map(|(key, _)| table.repos.get(key).cloned())
2188 .collect();
2189 let override_branches: Vec<Option<String>> = dispatched
2190 .iter()
2191 .map(|(key, _)| {
2192 let idx = table.index[key];
2193 let common_dir = &table.entities[idx].common_dir;
2194 find_entry(&self.overrides, key.path(), common_dir)
2195 .and_then(|entry| entry.default_branch.clone())
2196 })
2197 .collect();
2198 let network_branches: Vec<Option<Arc<str>>> = dispatched
2199 .iter()
2200 .map(|(key, _)| {
2201 let idx = table.index[key];
2202 let common_dir = &table.entities[idx].common_dir;
2203 network_branch_for(&self.network_default_branch, common_dir)
2204 })
2205 .collect();
2206 let common_dirs: Vec<Arc<Path>> = dispatched
2207 .iter()
2208 .map(|(key, _)| Arc::clone(&table.entities[table.index[key]].common_dir))
2209 .collect();
2210 let probes_state: Vec<bool> = dispatched
2211 .iter()
2212 .map(|(key, _)| table.entities[table.index[key]].probes_state())
2213 .collect();
2214 let probes_base: Vec<bool> = dispatched
2215 .iter()
2216 .map(|(key, _)| table.entities[table.index[key]].probes_base())
2217 .collect();
2218 let kinds: Vec<Kind> = dispatched
2219 .iter()
2220 .map(|(key, _)| table.entities[table.index[key]].kind)
2221 .collect();
2222 drop(table);
2223
2224 let chain_cache: Arc<ChainFactsCache> = Arc::new(Mutex::new(HashMap::new()));
2228 let patch_cache: Arc<PatchIdentityCache> = Arc::new(Mutex::new(HashMap::new()));
2232 let bound_gates: Arc<HashMap<Arc<Path>, BoundGate>> = Arc::new({
2237 let mut counts: HashMap<Arc<Path>, usize> = HashMap::new();
2238 for (common_dir, probes_state) in common_dirs.iter().zip(&probes_state) {
2239 if *probes_state {
2240 *counts.entry(Arc::clone(common_dir)).or_insert(0) += 1;
2241 }
2242 }
2243 counts
2244 .into_iter()
2245 .map(|(dir, count)| (dir, BoundGate::new(count)))
2246 .collect()
2247 });
2248
2249 for (
2250 (
2251 (
2252 (((((key, cancel), repo), override_branch), network_branch), common_dir),
2253 probes_state,
2254 ),
2255 probes_base,
2256 ),
2257 kind,
2258 ) in dispatched
2259 .into_iter()
2260 .zip(repos)
2261 .zip(override_branches)
2262 .zip(network_branches)
2263 .zip(common_dirs)
2264 .zip(probes_state)
2265 .zip(probes_base)
2266 .zip(kinds)
2267 {
2268 self.dispatch_log.lock().unwrap().push(key.clone());
2273 let path = key.path().to_path_buf();
2274 let table_handle = Arc::clone(&self.table);
2275 let settle_gate = Arc::clone(&self.settle_gate);
2276 let chain_cache = Arc::clone(&chain_cache);
2277 let chain_reads = Arc::clone(&self.default_branch_chain_reads);
2278 let patch_cache = Arc::clone(&patch_cache);
2279 let patch_reads = Arc::clone(&self.patch_identity_reads);
2280 let patch_scan_bounds = Arc::clone(&self.patch_scan_bounds);
2281 let bound_gates = Arc::clone(&bound_gates);
2282 let held_gate = self.phase_c_gates.lock().unwrap().get(&key).cloned();
2287 rayon::spawn(move || {
2295 let branch_outcome = probe_branch(&path, repo.as_deref(), kind, &cancel);
2296 let sync_outcome = probe_sync(
2297 &path,
2298 repo.as_deref(),
2299 branch_outcome.as_ref().map(|(settled, ..)| settled),
2300 kind,
2301 &cancel,
2302 );
2303 let default_branch_outcome = probe_default_branch_memoised(
2304 &path,
2305 repo.as_deref(),
2306 &common_dir,
2307 DefaultBranchHints {
2308 override_branch: override_branch.as_deref(),
2309 network_branch: network_branch.as_deref(),
2310 },
2311 kind,
2312 &cancel,
2313 &ChainFactsMemo {
2314 cache: &chain_cache,
2315 reads: &chain_reads,
2316 },
2317 );
2318 let base_outcome = if probes_base {
2319 probe_base(
2320 &path,
2321 repo.as_deref(),
2322 branch_outcome.as_ref().map(|(settled, ..)| settled),
2323 default_branch_outcome.as_ref().map(|r| &r.settled),
2324 &cancel,
2325 )
2326 } else {
2327 None
2328 };
2329
2330 apply_cheap_probe_outcomes(
2336 &table_handle,
2337 &key,
2338 generation,
2339 CheapProbeOutcomes {
2340 branch: branch_outcome,
2341 sync: sync_outcome,
2342 base: base_outcome,
2343 default_branch: default_branch_outcome.clone(),
2344 },
2345 );
2346
2347 if let Some(gate) = &held_gate {
2352 let (lock, cvar) = &**gate;
2353 let mut state = lock.lock().unwrap();
2354 state.cheap_landed = true;
2355 cvar.notify_all();
2356 state = cvar.wait_while(state, |state| !state.may_proceed).unwrap();
2357 drop(state);
2358 }
2359
2360 let state_outcome = if probes_state {
2361 let gate = bound_gates
2362 .get(&common_dir)
2363 .expect("every probes_state entity's common dir has a gate sized for it");
2364 let mut report = GateReport::new(gate);
2365 let memo = PatchEquivalenceMemo {
2366 cache: &patch_cache,
2367 reads: &patch_reads,
2368 scan_bounds: &patch_scan_bounds,
2369 };
2370 probe_worktree_state(
2371 &path,
2372 repo.as_deref(),
2373 default_branch_outcome.as_ref().map(|r| &r.settled),
2374 &common_dir,
2375 &cancel,
2376 &memo,
2377 &mut report,
2378 )
2379 } else {
2380 None
2381 };
2382 let dirty_outcome = probe_status(&path, repo.as_deref(), kind, &cancel);
2383 apply_probe_outcome(
2384 &table_handle,
2385 &settle_gate,
2386 &key,
2387 generation,
2388 ProbeOutcomes {
2389 state: state_outcome,
2390 dirty: dirty_outcome,
2391 },
2392 );
2393
2394 if let Some(gate) = &held_gate {
2397 let (lock, cvar) = &**gate;
2398 let mut state = lock.lock().unwrap();
2399 state.finished = true;
2400 cvar.notify_all();
2401 }
2402 });
2403 }
2405 }
2406
2407 fn rerun_discovery(&self) {
2416 let repos_cache: HashMap<EntityKey, Arc<gix::ThreadSafeRepository>> =
2417 self.table.read().unwrap().repos.clone();
2418
2419 wait_for_discovery_gate(self.discovery_gate.as_ref());
2420 let (watch, _watcher) = spawn_discovery_watcher(
2423 self.set.roots.clone(),
2424 &self.discovery_warning,
2425 self.discovery_warn_after,
2426 );
2427 let discovery = run_watched_discovery(
2428 &watch,
2429 &self.set,
2430 &self.discovery_warning,
2431 Duration::from_nanos(self.discovery_abandon_after.load(Ordering::Acquire)),
2432 );
2433 if discovery.abandoned {
2434 self.discovery_manual.store(true, Ordering::Release);
2435 }
2436
2437 let (discovered, gitmodules_failures) =
2438 discovery::resolve_with_cache(&self.set, &discovery.entities, &repos_cache);
2439
2440 let exclusions = self.exclusions.read().unwrap().clone();
2444 let mut table = self.table.write().unwrap();
2445 table.discovered_at = Timestamp::now();
2446 let cancelled = merge_discovery(&mut table, &exclusions, discovered, gitmodules_failures);
2447 drop(table);
2448 if cancelled > 0 {
2449 complete_many(&self.settle_gate, cancelled);
2450 }
2451 }
2452}
2453
2454impl Drop for Core {
2455 fn drop(&mut self) {
2466 cancel_in_flight(&self.table, &self.settle_gate);
2467 let _ = self.control.send(ClockControl::Shutdown);
2468 if let Some(handle) = self.clock_thread.take() {
2469 let _ = handle.join();
2470 }
2471 }
2472}
2473
2474pub(crate) struct StartForTest {
2479 pub core: Core,
2480 #[allow(dead_code)] pub clock_alive: Arc<AtomicBool>,
2482 #[allow(dead_code)] pub discovery_watcher: JoinHandle<()>,
2484 #[allow(dead_code)] pub initial_discovery: Option<JoinHandle<()>>,
2489 #[allow(dead_code)] pub fetch_cycles_taken_back: Arc<AtomicUsize>,
2495}
2496
2497#[cfg(test)]
2498impl StartForTest {
2499 fn discovered(mut self) -> Self {
2503 if let Some(handle) = self.initial_discovery.take() {
2504 handle
2505 .join()
2506 .expect("the first discovery thread should not panic");
2507 }
2508 self
2509 }
2510}
2511
2512impl Core {
2513 #[cfg(any(test, feature = "test-util"))]
2524 pub fn begin_untracked_probe_for_test(&self, key: &EntityKey) -> Arc<AtomicBool> {
2525 let mut table = self.table.write().unwrap();
2526 table.generation += 1;
2527 let generation_number = table.generation;
2528 table
2529 .generation_started_at
2530 .insert(generation_number, Instant::now());
2531 if let Some(&idx) = table.index.get(key) {
2532 begin_probes(&mut table.entities[idx]);
2533 }
2534 let cancel = Arc::new(AtomicBool::new(false));
2535 table.in_flight.insert(
2536 key.clone(),
2537 InFlight {
2538 generation: generation_number,
2539 cancel: Arc::clone(&cancel),
2540 },
2541 );
2542 begin_probes_owed(&self.settle_gate, 1);
2543 cancel
2544 }
2545}
2546
2547#[cfg(test)]
2550pub(crate) struct SharedGeneration {
2551 pub generation: Generation,
2554 pub cancels: HashMap<EntityKey, Arc<AtomicBool>>,
2556}
2557
2558#[cfg(test)]
2559impl Core {
2560 pub(crate) fn cached_repo_handle_for_test(
2565 &self,
2566 key: &EntityKey,
2567 ) -> Option<Arc<gix::ThreadSafeRepository>> {
2568 self.table.read().unwrap().repos.get(key).cloned()
2569 }
2570
2571 pub(crate) fn default_branch_chain_reads_for_test(&self) -> usize {
2578 self.default_branch_chain_reads.load(Ordering::Acquire)
2579 }
2580
2581 pub(crate) fn patch_identity_reads_for_test(&self) -> usize {
2587 self.patch_identity_reads.load(Ordering::Acquire)
2588 }
2589
2590 pub(crate) fn patch_scan_bounds_for_test(&self) -> Vec<Option<gix::ObjectId>> {
2597 self.patch_scan_bounds.lock().unwrap().clone()
2598 }
2599
2600 pub(crate) fn dispatch_log_for_test(&self) -> Vec<EntityKey> {
2604 self.dispatch_log.lock().unwrap().clone()
2605 }
2606
2607 pub(crate) fn poll_once_for_test(&self) {
2612 run_poll_sweep(
2613 &self.table,
2614 &self.overrides,
2615 &self.show_submodules,
2616 &self.poll_reprobed,
2617 &self.poll_sweep_count,
2618 &self.network_default_branch,
2619 );
2620 }
2621
2622 pub(crate) fn poll_reprobed_for_test(&self) -> Vec<EntityKey> {
2627 self.poll_reprobed.lock().unwrap().clone()
2628 }
2629
2630 pub(crate) fn poll_sweep_count_for_test(&self) -> usize {
2634 self.poll_sweep_count.load(Ordering::Acquire)
2635 }
2636
2637 #[cfg(test)]
2640 pub(crate) fn action_completion_boundary(&self) -> Arc<ActionCompletionBoundary> {
2641 Arc::clone(&self.action_completion_boundary)
2642 }
2643
2644 #[cfg(test)]
2646 pub(crate) fn fetch_boundary(&self) -> Arc<FetchBoundary> {
2647 Arc::clone(&self.fetch_boundary)
2648 }
2649
2650 pub(crate) fn hold_phase_c_for_test(&self, key: &EntityKey) {
2656 self.phase_c_gates.lock().unwrap().insert(
2657 key.clone(),
2658 Arc::new((Mutex::new(PhaseCGate::default()), Condvar::new())),
2659 );
2660 }
2661
2662 pub(crate) fn wait_phase_c_landed_for_test(&self, key: &EntityKey) {
2666 let gate = self
2667 .phase_c_gates
2668 .lock()
2669 .unwrap()
2670 .get(key)
2671 .cloned()
2672 .expect("hold_phase_c_for_test must be called before waiting on its gate");
2673 let (lock, cvar) = &*gate;
2674 let guard = lock.lock().unwrap();
2675 drop(cvar.wait_while(guard, |state| !state.cheap_landed).unwrap());
2676 }
2677
2678 pub(crate) fn release_phase_c_for_test(&self, key: &EntityKey) {
2681 let gate = self
2682 .phase_c_gates
2683 .lock()
2684 .unwrap()
2685 .get(key)
2686 .cloned()
2687 .expect("hold_phase_c_for_test must be called before releasing its gate");
2688 let (lock, cvar) = &*gate;
2689 let mut state = lock.lock().unwrap();
2690 state.may_proceed = true;
2691 cvar.notify_all();
2692 }
2693
2694 pub(crate) fn wait_phase_c_finished_for_test(&self, key: &EntityKey) {
2697 let gate = self
2698 .phase_c_gates
2699 .lock()
2700 .unwrap()
2701 .get(key)
2702 .cloned()
2703 .expect("hold_phase_c_for_test must be called before waiting on its gate");
2704 let (lock, cvar) = &*gate;
2705 let guard = lock.lock().unwrap();
2706 drop(cvar.wait_while(guard, |state| !state.finished).unwrap());
2707 }
2708
2709 pub(crate) fn wait_dispatched_for_test(&self) {
2714 let (lock, cvar) = &*self.settle_gate;
2715 let guard = lock.lock().unwrap();
2716 drop(
2717 cvar.wait_while(guard, |counts| counts.dispatches > 0)
2718 .unwrap(),
2719 );
2720 }
2721
2722 pub(crate) fn settle_gate_count_for_test(&self) -> usize {
2726 self.settle_gate.0.lock().unwrap().probes
2727 }
2728
2729 pub(crate) fn start_for_test(
2733 spec: CoreSpec,
2734 warn_after: Duration,
2735 ticks: Receiver<Instant>,
2736 ) -> StartForTest {
2737 Self::start_for_test_with_discovery_abandon(
2738 spec,
2739 warn_after,
2740 discovery::ABANDON_AFTER,
2741 ticks,
2742 )
2743 }
2744
2745 pub(crate) fn start_for_test_with_discovery_abandon(
2752 spec: CoreSpec,
2753 warn_after: Duration,
2754 discovery_abandon_after: Duration,
2755 ticks: Receiver<Instant>,
2756 ) -> StartForTest {
2757 Self::start_for_test_gated(spec, warn_after, discovery_abandon_after, ticks, None)
2758 }
2759
2760 pub(crate) fn start_for_test_gated(
2764 spec: CoreSpec,
2765 warn_after: Duration,
2766 discovery_abandon_after: Duration,
2767 ticks: Receiver<Instant>,
2768 discovery_gate: Option<DiscoveryGate>,
2769 ) -> StartForTest {
2770 let alive = Arc::new(AtomicBool::new(true));
2771 start_internal(
2772 spec,
2773 warn_after,
2774 discovery_abandon_after,
2775 ticks,
2776 FetchStart {
2777 enabled: false,
2778 concurrency: 1,
2779 ticks: crossbeam_channel::never(),
2780 },
2781 alive,
2782 discovery_gate,
2783 )
2784 }
2785
2786 pub(crate) fn start_for_test_with_fetch(
2792 spec: CoreSpec,
2793 warn_after: Duration,
2794 ticks: Receiver<Instant>,
2795 fetch_ticks: Receiver<Instant>,
2796 ) -> StartForTest {
2797 Self::start_for_test_with_fetch_gated(spec, warn_after, ticks, fetch_ticks, None)
2798 }
2799
2800 pub(crate) fn start_for_test_with_fetch_gated(
2804 spec: CoreSpec,
2805 warn_after: Duration,
2806 ticks: Receiver<Instant>,
2807 fetch_ticks: Receiver<Instant>,
2808 discovery_gate: Option<DiscoveryGate>,
2809 ) -> StartForTest {
2810 let alive = Arc::new(AtomicBool::new(true));
2811 let fetch_start = FetchStart {
2812 enabled: spec.fetch.enabled,
2813 concurrency: spec.fetch.concurrency.max(1),
2814 ticks: fetch_ticks,
2815 };
2816 start_internal(
2817 spec,
2818 warn_after,
2819 discovery::ABANDON_AFTER,
2820 ticks,
2821 fetch_start,
2822 alive,
2823 discovery_gate,
2824 )
2825 }
2826
2827 pub(crate) fn fetch_cycle_count_for_test(&self) -> usize {
2831 self.fetch_cycle_count.load(Ordering::Acquire)
2832 }
2833
2834 #[cfg(test)]
2841 pub(crate) fn set_discovery_abandon_after_for_test(&self, after: Duration) {
2842 self.discovery_abandon_after
2843 .store(after.as_nanos() as u64, Ordering::Release);
2844 }
2845
2846 pub(crate) fn discovery_manual_for_test(&self) -> bool {
2847 self.discovery_manual.load(Ordering::Acquire)
2848 }
2849
2850 pub(crate) fn begin_shared_generation_for_test(&self, keys: &[EntityKey]) -> SharedGeneration {
2860 let mut table = self.table.write().unwrap();
2861 table.generation += 1;
2862 let generation_number = table.generation;
2863 table
2864 .generation_started_at
2865 .insert(generation_number, Instant::now());
2866 let mut cancels = HashMap::new();
2867 for key in keys {
2868 if let Some(&idx) = table.index.get(key) {
2869 table.entities[idx].branch.begin_probe();
2870 }
2871 let cancel = Arc::new(AtomicBool::new(false));
2872 table.in_flight.insert(
2873 key.clone(),
2874 InFlight {
2875 generation: generation_number,
2876 cancel: Arc::clone(&cancel),
2877 },
2878 );
2879 cancels.insert(key.clone(), cancel);
2880 }
2881 SharedGeneration {
2882 generation: Generation::new(generation_number),
2883 cancels,
2884 }
2885 }
2886
2887 pub(crate) fn apply_probe_result_for_test(
2893 &self,
2894 key: &EntityKey,
2895 generation: Generation,
2896 settled: Settled<Head>,
2897 ) {
2898 apply_cheap_probe_outcomes(
2899 &self.table,
2900 key,
2901 generation,
2902 CheapProbeOutcomes {
2903 branch: Some((settled, None, Vec::new())),
2904 sync: None,
2905 base: None,
2906 default_branch: None,
2907 },
2908 );
2909 }
2910
2911 pub(crate) fn set_last_action_for_test(
2915 &self,
2916 key: &EntityKey,
2917 receipt: crate::entity::ActionReceipt,
2918 ) {
2919 let mut table = self.table.write().unwrap();
2920 if let Some(&idx) = table.index.get(key) {
2921 table.entities[idx].last_action = Some(receipt);
2922 }
2923 }
2924}
2925
2926fn run_action_for_entity(
2950 entity: &EntityState,
2951 action: &ActionSpec,
2952 control: &Arc<executor::RunControl>,
2953 report: &dyn Fn(ActionReceipt),
2954) -> ActionReceipt {
2955 let base_env = environment::environment(entity, action.name.as_deref());
2956 let mut failed = false;
2957 let mut cancelled = false;
2958 let mut results: Vec<StepResult> = Vec::with_capacity(action.steps.len());
2959 for step in &action.steps {
2960 if failed || cancelled || control.is_cancelled() {
2961 cancelled = cancelled || control.is_cancelled();
2962 results.push(StepResult {
2963 label: Arc::from(step.argv.join(" ")),
2964 outcome: if cancelled {
2965 StepOutcome::Cancelled
2966 } else {
2967 StepOutcome::NotRun
2968 },
2969 output: Arc::from(&b""[..]),
2970 elapsed: Duration::ZERO,
2971 elision: None,
2972 shell: step.shell,
2973 interactive: step.interactive,
2974 });
2975 continue;
2976 }
2977 let label: Arc<str> = Arc::from(step.argv.join(" "));
2978 report(ActionReceipt {
2979 label: Arc::clone(&action.label),
2980 steps: Arc::from(results.clone()),
2981 skip: None,
2982 finished_at: Timestamp::now(),
2983 running: Some(RunningStep {
2984 label: Arc::clone(&label),
2985 started_at: Timestamp::now(),
2986 shell: step.shell,
2987 interactive: step.interactive,
2988 }),
2989 });
2990 let mut env = base_env.clone();
2995 env.extend(
2996 step.env
2997 .iter()
2998 .map(|(name, value)| (name.clone(), Some(value.clone()))),
2999 );
3000 let mut result = executor::run_step(
3001 &step.argv,
3002 step.shell,
3003 step.interactive,
3004 entity.key.path(),
3005 &env,
3006 control,
3007 );
3008 if control.is_cancelled() {
3009 result.outcome = StepOutcome::Cancelled;
3010 cancelled = true;
3011 } else {
3012 failed = result.outcome.is_failure();
3013 }
3014 results.push(result);
3015 }
3016 ActionReceipt {
3017 label: Arc::clone(&action.label),
3018 steps: Arc::from(results),
3019 skip: None,
3020 finished_at: Timestamp::now(),
3021 running: None,
3022 }
3023}
3024
3025type DiscoveryGate = Arc<(Mutex<bool>, Condvar)>;
3030
3031fn wait_for_discovery_gate(gate: Option<&DiscoveryGate>) {
3034 let Some(gate) = gate else {
3035 return;
3036 };
3037 let (lock, cvar) = &**gate;
3038 let open = lock.lock().unwrap();
3039 drop(cvar.wait_while(open, |open| !*open).unwrap());
3040}
3041
3042#[cfg(test)]
3044fn set_discovery_gate(gate: &DiscoveryGate, open: bool) {
3045 let (lock, cvar) = &**gate;
3046 *lock.lock().unwrap() = open;
3047 cvar.notify_all();
3048}
3049
3050struct DiscoveryWatch {
3053 progress: Arc<AtomicUsize>,
3054 finished: Arc<AtomicBool>,
3055}
3056
3057fn spawn_discovery_watcher(
3063 roots: Vec<PathBuf>,
3064 discovery_warning: &Arc<Mutex<Option<String>>>,
3065 warn_after: Duration,
3066) -> (DiscoveryWatch, JoinHandle<()>) {
3067 let progress = Arc::new(AtomicUsize::new(0));
3068 let finished = Arc::new(AtomicBool::new(false));
3069 let watcher = thread::spawn({
3070 let progress = Arc::clone(&progress);
3071 let finished = Arc::clone(&finished);
3072 let warning_slot = Arc::clone(discovery_warning);
3073 move || {
3074 if let Some(message) = watch_for_slow_discovery(progress, finished, roots, warn_after) {
3075 *warning_slot.lock().unwrap() = Some(message);
3076 }
3077 }
3078 });
3079 (DiscoveryWatch { progress, finished }, watcher)
3080}
3081
3082fn run_watched_discovery(
3088 watch: &DiscoveryWatch,
3089 set: &SetSpec,
3090 discovery_warning: &Arc<Mutex<Option<String>>>,
3091 abandon_after: Duration,
3092) -> discovery::Discovery {
3093 let discovery =
3094 discovery::discover_watched_with_deadline(set, Arc::clone(&watch.progress), abandon_after);
3095 watch.finished.store(true, Ordering::Release);
3096
3097 if discovery.abandoned {
3098 *discovery_warning.lock().unwrap() =
3099 Some(abandoned_discovery_message(discovery.directories_visited));
3100 }
3101
3102 discovery
3103}
3104
3105fn start_internal(
3108 spec: CoreSpec,
3109 warn_after: Duration,
3110 discovery_abandon_after: Duration,
3111 ticks: Receiver<Instant>,
3112 fetch_start: FetchStart,
3113 alive: Arc<AtomicBool>,
3114 discovery_gate: Option<DiscoveryGate>,
3115) -> StartForTest {
3116 let FetchStart {
3117 enabled: fetch_enabled,
3118 concurrency: fetch_concurrency,
3119 ticks: fetch_ticks,
3120 } = fetch_start;
3121 let discovery_warning = Arc::new(Mutex::new(None));
3122 let discovery_manual = Arc::new(AtomicBool::new(false));
3123
3124 let (overrides, resolved_exclusions) = resolve_entries(&spec.overrides);
3125 let overrides = Arc::new(overrides);
3126 let exclusions = Arc::new(RwLock::new(resolved_exclusions));
3127 let show_submodules = Arc::new(AtomicBool::new(spec.show_submodules));
3128
3129 let table = Arc::new(RwLock::new(Table {
3130 generation: 0,
3131 discovered_at: Timestamp::now(),
3132 entities: Vec::new(),
3133 index: HashMap::new(),
3134 in_flight: HashMap::new(),
3135 generation_started_at: HashMap::new(),
3136 repos: HashMap::new(),
3137 poll_fingerprints: HashMap::new(),
3138 }));
3139
3140 let settle_gate: Arc<SettleGate> =
3141 Arc::new((Mutex::new(SettleCounts::default()), Condvar::new()));
3142 let poll_reprobed = Arc::new(Mutex::new(Vec::new()));
3143 let poll_sweep_count = Arc::new(AtomicUsize::new(0));
3144 let network_default_branch = Arc::new(Mutex::new(HashMap::new()));
3145 let (control, control_rx) = crossbeam_channel::unbounded();
3146 let poll_handles = PollHandles {
3147 overrides: Arc::clone(&overrides),
3148 show_submodules: Arc::clone(&show_submodules),
3149 poll_reprobed: Arc::clone(&poll_reprobed),
3150 poll_sweep_count: Arc::clone(&poll_sweep_count),
3151 network_default_branch: Arc::clone(&network_default_branch),
3152 };
3153
3154 let discovery_abandon_after_atomic =
3158 Arc::new(AtomicU64::new(discovery_abandon_after.as_nanos() as u64));
3159 let default_branch_chain_reads = Arc::new(AtomicUsize::new(0));
3160 let patch_identity_reads = Arc::new(AtomicUsize::new(0));
3161 let patch_scan_bounds = Arc::new(Mutex::new(Vec::new()));
3162 let dispatch_log = Arc::new(Mutex::new(Vec::new()));
3163 let phase_c_gates = Arc::new(Mutex::new(HashMap::new()));
3164 let fetch_cycle_count = Arc::new(AtomicUsize::new(0));
3165 let fetch_failures = Arc::new(Mutex::new(FetchFailures::default()));
3166 let fetch_cycles_taken_back = Arc::new(AtomicUsize::new(0));
3167 let (fetch_finished_tx, fetch_finished_rx) = crossbeam_channel::unbounded();
3168 #[cfg(test)]
3169 let fetch_boundary = Arc::new(FetchBoundary::default());
3170 let turnstile = Arc::new(DispatchTurnstile::default());
3171
3172 let fetch_refresh_handles = RefreshHandles {
3173 table: Arc::clone(&table),
3174 overrides: Arc::clone(&overrides),
3175 exclusions: Arc::clone(&exclusions),
3176 set: spec.set.clone(),
3177 discovery_manual: Arc::clone(&discovery_manual),
3178 discovery_warn_after: warn_after,
3179 discovery_abandon_after: Arc::clone(&discovery_abandon_after_atomic),
3180 discovery_warning: Arc::clone(&discovery_warning),
3181 show_submodules: Arc::clone(&show_submodules),
3182 settle_gate: Arc::clone(&settle_gate),
3183 default_branch_chain_reads: Arc::clone(&default_branch_chain_reads),
3184 patch_identity_reads: Arc::clone(&patch_identity_reads),
3185 patch_scan_bounds: Arc::clone(&patch_scan_bounds),
3186 dispatch_log: Arc::clone(&dispatch_log),
3187 phase_c_gates: Arc::clone(&phase_c_gates),
3188 network_default_branch: Arc::clone(&network_default_branch),
3189 turnstile: Arc::clone(&turnstile),
3190 discovery_gate: discovery_gate.clone(),
3191 };
3192 let auto_update_enabled = spec.auto_update.enabled;
3193 let fetch_schedule = FetchSchedule {
3194 concurrency: fetch_concurrency,
3195 ticks: fetch_ticks,
3196 refresh: fetch_refresh_handles.clone(),
3197 cycle_count: Arc::clone(&fetch_cycle_count),
3198 failures: Arc::clone(&fetch_failures),
3199 auto_update_enabled,
3200 finished: fetch_finished_rx,
3201 finished_tx: fetch_finished_tx,
3202 taken_back_count: Arc::clone(&fetch_cycles_taken_back),
3203 #[cfg(test)]
3204 boundary: Arc::clone(&fetch_boundary),
3205 };
3206
3207 let clock_thread = spawn_clock_thread(
3208 Arc::clone(&table),
3209 poll_handles,
3210 fetch_schedule,
3211 Arc::clone(&settle_gate),
3212 spec.generation_deadline,
3213 ClockChannels {
3214 control: control_rx,
3215 ticks,
3216 alive: Arc::clone(&alive),
3217 },
3218 );
3219
3220 let (startup_generation, startup_ticket) = fetch_refresh_handles.reserve_generation();
3230 begin_dispatch(&settle_gate);
3231 let (watch, discovery_watcher) =
3232 spawn_discovery_watcher(spec.set.roots.clone(), &discovery_warning, warn_after);
3233 let initial_discovery = thread::spawn({
3234 let set = spec.set.clone();
3235 let discovery_warning = Arc::clone(&discovery_warning);
3236 let discovery_manual = Arc::clone(&discovery_manual);
3237 let exclusions = Arc::clone(&exclusions);
3238 let table = Arc::clone(&table);
3239 let settle_gate = Arc::clone(&settle_gate);
3240 let fetch_refresh_handles = fetch_refresh_handles.clone();
3241 let control = control.clone();
3242 let discovery_gate = discovery_gate.clone();
3243 move || {
3244 let turn = fetch_refresh_handles.turnstile.take(startup_ticket);
3245 wait_for_discovery_gate(discovery_gate.as_ref());
3246 let discovery =
3247 run_watched_discovery(&watch, &set, &discovery_warning, discovery_abandon_after);
3248 if discovery.abandoned {
3249 discovery_manual.store(true, Ordering::Release);
3250 }
3251
3252 let (discovered, gitmodules_failures) = discovery::resolve(&set, &discovery.entities);
3257 let resolved_exclusions = exclusions.read().unwrap().clone();
3258 let order: Vec<EntityKey> = {
3259 let mut table = table.write().unwrap();
3260 merge_discovery(
3264 &mut table,
3265 &resolved_exclusions,
3266 discovered,
3267 gitmodules_failures,
3268 );
3269 table.discovered_at = Timestamp::now();
3270 table
3271 .entities
3272 .iter()
3273 .map(|entity| entity.key.clone())
3274 .collect()
3275 };
3276 fetch_refresh_handles.dispatch_probes(&order, startup_generation);
3280 finish_dispatch(&settle_gate);
3281 drop(turn);
3284
3285 if fetch_enabled {
3294 let _ = control.send(ClockControl::FetchNow);
3295 }
3296 }
3297 });
3298
3299 StartForTest {
3300 core: Core {
3301 table,
3302 overrides,
3303 exclusions,
3304 set: spec.set,
3305 discovery_manual,
3306 discovery_warn_after: warn_after,
3307 discovery_abandon_after: discovery_abandon_after_atomic,
3308 show_submodules,
3309 settle_gate,
3310 control,
3311 clock_thread: Some(clock_thread),
3312 discovery_warning,
3313 default_branch_chain_reads,
3314 patch_identity_reads,
3315 patch_scan_bounds,
3316 action_lifecycle: Arc::new(Mutex::new(ActionLifecycle::default())),
3317 dispatch_log,
3318 phase_c_gates,
3319 status_stale_after: spec.status_stale_after,
3320 poll_reprobed,
3321 poll_sweep_count,
3322 fetch_cycle_count,
3323 network_default_branch,
3324 fetch_failures,
3325 turnstile,
3326 discovery_gate,
3327 #[cfg(test)]
3328 action_completion_boundary: Arc::new(ActionCompletionBoundary::default()),
3329 #[cfg(test)]
3330 fetch_boundary: Arc::clone(&fetch_boundary),
3331 },
3332 clock_alive: alive,
3333 discovery_watcher,
3334 initial_discovery: Some(initial_discovery),
3335 fetch_cycles_taken_back,
3336 }
3337}
3338
3339struct PollHandles {
3343 overrides: Arc<Vec<ResolvedOverride>>,
3344 show_submodules: Arc<AtomicBool>,
3345 poll_reprobed: Arc<Mutex<Vec<EntityKey>>>,
3346 poll_sweep_count: Arc<AtomicUsize>,
3347 network_default_branch: Arc<Mutex<HashMap<PathBuf, Arc<str>>>>,
3351}
3352
3353struct FetchStart {
3357 enabled: bool,
3358 concurrency: usize,
3359 ticks: Receiver<Instant>,
3360}
3361
3362struct FetchSchedule {
3369 concurrency: usize,
3370 ticks: Receiver<Instant>,
3371 refresh: RefreshHandles,
3372 cycle_count: Arc<AtomicUsize>,
3373 failures: Arc<Mutex<FetchFailures>>,
3374 auto_update_enabled: bool,
3378 finished: Receiver<()>,
3383 finished_tx: Sender<()>,
3384 taken_back_count: Arc<AtomicUsize>,
3387 #[cfg(test)]
3390 boundary: Arc<FetchBoundary>,
3391}
3392
3393struct ClockChannels {
3400 control: Receiver<ClockControl>,
3401 ticks: Receiver<Instant>,
3402 alive: Arc<AtomicBool>,
3403}
3404
3405fn spawn_clock_thread(
3423 table: Arc<RwLock<Table>>,
3424 poll: PollHandles,
3425 fetch: FetchSchedule,
3426 settle_gate: Arc<SettleGate>,
3427 generation_deadline: Duration,
3428 channels: ClockChannels,
3429) -> JoinHandle<()> {
3430 let ClockChannels {
3431 control,
3432 ticks,
3433 alive,
3434 } = channels;
3435 thread::spawn(move || {
3436 let mut paused = false;
3437 let mut cycle: Option<FetchCycle> = None;
3438 let mut immediate_cycle_owed = false;
3439 loop {
3440 select! {
3441 recv(control) -> message => match message {
3442 Ok(ClockControl::Pause) => {
3443 paused = true;
3444 cancel_in_flight(&table, &settle_gate);
3445 if let Some(cycle) = &cycle {
3446 cycle.cancel();
3447 }
3448 }
3449 Ok(ClockControl::Resume) => paused = false,
3450 Ok(ClockControl::FetchNow) => immediate_cycle_owed = true,
3451 Ok(ClockControl::Shutdown) | Err(_) => break,
3452 },
3453 recv(ticks) -> tick => {
3454 if tick.is_err() {
3455 break;
3456 }
3457 if !paused {
3458 run_poll_sweep(
3459 &table,
3460 &poll.overrides,
3461 &poll.show_submodules,
3462 &poll.poll_reprobed,
3463 &poll.poll_sweep_count,
3464 &poll.network_default_branch,
3465 );
3466 sweep_deadline(&table, &settle_gate, generation_deadline);
3467 }
3468 }
3469 recv(fetch.ticks) -> tick => {
3470 if tick.is_err() {
3471 break;
3472 }
3473 if !paused && cycle.is_none() {
3478 cycle = Some(start_fetch_cycle(&table, &fetch));
3479 }
3480 }
3481 recv(fetch.finished) -> _ => {
3482 if let Some(finished) = cycle.take() {
3483 let cancelled = finished.cancelled();
3484 finished.join();
3485 if !cancelled {
3486 dispatch_fetch_completion(&table, &fetch.refresh);
3487 }
3488 fetch.taken_back_count.fetch_add(1, Ordering::Release);
3489 }
3490 }
3491 }
3492 if immediate_cycle_owed && !paused && cycle.is_none() {
3495 immediate_cycle_owed = false;
3496 cycle = Some(start_fetch_cycle(&table, &fetch));
3497 }
3498 }
3499 if let Some(cycle) = cycle.take() {
3504 cycle.cancel();
3505 cycle.join();
3506 fetch.taken_back_count.fetch_add(1, Ordering::Release);
3507 }
3508 alive.store(false, Ordering::Release);
3509 })
3510}
3511
3512struct FetchCycle {
3519 cancel: Arc<AtomicBool>,
3520 worker: JoinHandle<()>,
3521 #[cfg(test)]
3523 boundary: Arc<FetchBoundary>,
3524}
3525
3526impl FetchCycle {
3527 fn cancel(&self) {
3534 self.cancel.store(true, Ordering::Release);
3535 #[cfg(test)]
3536 self.boundary.cancelled();
3537 }
3538
3539 fn cancelled(&self) -> bool {
3540 self.cancel.load(Ordering::Acquire)
3541 }
3542
3543 fn join(self) {
3545 let _ = self.worker.join();
3546 }
3547}
3548
3549fn start_fetch_cycle(table: &Arc<RwLock<Table>>, fetch: &FetchSchedule) -> FetchCycle {
3557 let cancel = Arc::new(AtomicBool::new(false));
3558 let work = FetchCycleWork {
3559 table: Arc::clone(table),
3560 concurrency: fetch.concurrency,
3561 cancel: Arc::clone(&cancel),
3562 network_default_branch: Arc::clone(&fetch.refresh.network_default_branch),
3563 cycle_count: Arc::clone(&fetch.cycle_count),
3564 failures: Arc::clone(&fetch.failures),
3565 auto_update_enabled: fetch.auto_update_enabled,
3566 #[cfg(test)]
3567 boundary: Arc::clone(&fetch.boundary),
3568 };
3569 #[cfg(test)]
3570 let boundary = Arc::clone(&work.boundary);
3571 let finished = fetch.finished_tx.clone();
3572 let worker = thread::spawn(move || {
3573 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
3574 run_fetch_cycle(&work);
3575 }));
3576 let _ = finished.send(());
3577 });
3578 FetchCycle {
3579 cancel,
3580 worker,
3581 #[cfg(test)]
3582 boundary,
3583 }
3584}
3585
3586fn dispatch_fetch_completion(table: &Arc<RwLock<Table>>, refresh: &RefreshHandles) {
3591 let all_keys: Vec<EntityKey> = table
3592 .read()
3593 .unwrap()
3594 .entities
3595 .iter()
3596 .map(|entity| entity.key.clone())
3597 .collect();
3598 refresh.dispatch(&all_keys);
3599}
3600
3601struct FetchCycleWork {
3606 table: Arc<RwLock<Table>>,
3607 concurrency: usize,
3608 cancel: Arc<AtomicBool>,
3609 network_default_branch: Arc<Mutex<HashMap<PathBuf, Arc<str>>>>,
3610 cycle_count: Arc<AtomicUsize>,
3611 failures: Arc<Mutex<FetchFailures>>,
3612 auto_update_enabled: bool,
3613 #[cfg(test)]
3616 boundary: Arc<FetchBoundary>,
3617}
3618
3619fn run_fetch_cycle(work: &FetchCycleWork) {
3639 let FetchCycleWork {
3640 table,
3641 concurrency,
3642 cancel,
3643 network_default_branch,
3644 cycle_count,
3645 failures,
3646 auto_update_enabled,
3647 #[cfg(test)]
3648 boundary,
3649 } = work;
3650 let auto_update_enabled = *auto_update_enabled;
3651 cycle_count.fetch_add(1, Ordering::Release);
3652
3653 let common_dirs = distinct_fetchable_common_dirs(table);
3654 let failed: Mutex<Vec<(PathBuf, String)>> = Mutex::new(Vec::new());
3655 crate::fetch::run_bounded(common_dirs, (*concurrency).max(1), |common_dir| {
3656 #[cfg(test)]
3658 boundary.hold();
3659 if cancel.load(Ordering::Acquire) {
3662 return;
3663 }
3664 match crate::fetch::fetch_and_prune(&common_dir, cancel) {
3670 Ok(outcome) => {
3671 if let Some(crate::fetch::AdvertisedDefaultBranch::Branch(name)) =
3680 outcome.advertised_default_branch
3681 {
3682 network_default_branch
3683 .lock()
3684 .unwrap()
3685 .insert(common_dir.clone(), Arc::from(name));
3686 }
3687 }
3688 Err(error) => {
3689 failed
3690 .lock()
3691 .unwrap()
3692 .push((common_dir.clone(), error.to_string()));
3693 }
3694 }
3695 });
3696 if !cancel.load(Ordering::Acquire) {
3700 *failures.lock().unwrap() = FetchFailures {
3701 failed: failed.into_inner().unwrap(),
3702 };
3703 }
3704
3705 if auto_update_enabled {
3714 for repo_path in repos_eligible_for_auto_update_attempt(table) {
3715 if cancel.load(Ordering::Acquire) {
3719 break;
3720 }
3721 let _ = crate::auto_update::attempt(&repo_path);
3724 }
3725 }
3726}
3727
3728fn repos_eligible_for_auto_update_attempt(table: &Arc<RwLock<Table>>) -> Vec<PathBuf> {
3737 table
3738 .read()
3739 .unwrap()
3740 .entities
3741 .iter()
3742 .filter(|entity| entity.kind == Kind::Repo && !entity.excluded)
3743 .map(|entity| entity.key.path().to_path_buf())
3744 .collect()
3745}
3746
3747fn distinct_fetchable_common_dirs(table: &Arc<RwLock<Table>>) -> Vec<PathBuf> {
3754 let table = table.read().unwrap();
3755 let mut seen: HashMap<PathBuf, bool> = HashMap::new();
3756 for entity in &table.entities {
3757 let common_dir = entity.common_dir.to_path_buf();
3758 let operable = seen.entry(common_dir).or_insert(false);
3759 *operable = *operable || !entity.excluded;
3760 }
3761 seen.into_iter()
3762 .filter(|(_, operable)| *operable)
3763 .map(|(common_dir, _)| common_dir)
3764 .collect()
3765}
3766
3767fn probe_network_default_branches(
3773 common_dirs: &HashSet<Arc<Path>>,
3774 network_default_branch: &Mutex<HashMap<PathBuf, Arc<str>>>,
3775) {
3776 for common_dir in common_dirs {
3777 if let Ok(Some(crate::fetch::AdvertisedDefaultBranch::Branch(name))) =
3778 crate::fetch::probe_remote_head(common_dir)
3779 {
3780 network_default_branch
3781 .lock()
3782 .unwrap()
3783 .insert(common_dir.to_path_buf(), Arc::from(name));
3784 }
3785 }
3786}
3787
3788struct RederiveCandidate {
3793 key: EntityKey,
3794 path: PathBuf,
3795 common_dir: Arc<Path>,
3796 repo: Option<Arc<gix::ThreadSafeRepository>>,
3797 override_branch: Option<String>,
3798 kind: Kind,
3799}
3800
3801struct PollCandidate {
3804 key: EntityKey,
3805 path: PathBuf,
3806 common_dir: Arc<Path>,
3807 kind: Kind,
3808 cached_repo: Option<Arc<gix::ThreadSafeRepository>>,
3809 probes_base: bool,
3810}
3811
3812fn run_poll_sweep(
3831 table: &Arc<RwLock<Table>>,
3832 overrides: &Arc<Vec<ResolvedOverride>>,
3833 show_submodules: &Arc<AtomicBool>,
3834 poll_reprobed: &Arc<Mutex<Vec<EntityKey>>>,
3835 poll_sweep_count: &Arc<AtomicUsize>,
3836 network_default_branch: &Mutex<HashMap<PathBuf, Arc<str>>>,
3837) {
3838 poll_sweep_count.fetch_add(1, Ordering::Release);
3839 poll_reprobed.lock().unwrap().clear();
3840 let show_submodules = show_submodules.load(Ordering::Acquire);
3841
3842 let candidates: Vec<PollCandidate> = {
3843 let table = table.read().unwrap();
3844 table
3845 .entities
3846 .iter()
3847 .filter(|entity| dispatches_kind(entity.kind, show_submodules))
3848 .map(|entity| PollCandidate {
3849 key: entity.key.clone(),
3850 path: entity.key.path().to_path_buf(),
3851 common_dir: Arc::clone(&entity.common_dir),
3852 kind: entity.kind,
3853 cached_repo: table.repos.get(&entity.key).cloned(),
3854 probes_base: entity.probes_base(),
3855 })
3856 .collect()
3857 };
3858
3859 for candidate in candidates {
3860 let opened;
3866 let repo = match candidate.cached_repo.as_deref() {
3867 Some(repo) => Some(repo),
3868 None => match git::open_thread_safe(&candidate.path) {
3869 Ok(repo) => {
3870 opened = repo;
3871 Some(&opened)
3872 }
3873 Err(_) => None,
3874 },
3875 };
3876 let gitdir = repo
3877 .map(|repo| repo.git_dir().to_path_buf())
3878 .unwrap_or_else(|| candidate.common_dir.to_path_buf());
3879
3880 let current = poll::fingerprint(&gitdir);
3881 let moved = {
3882 let mut table = table.write().unwrap();
3883 let previous = table
3884 .poll_fingerprints
3885 .insert(candidate.key.clone(), current);
3886 previous.is_some_and(|previous| poll::moved(&previous, ¤t))
3887 };
3888 if !moved {
3889 continue;
3890 }
3891
3892 {
3893 let mut table = table.write().unwrap();
3894 if let Some(&idx) = table.index.get(&candidate.key) {
3895 table.entities[idx].force_stale_status_cells();
3896 }
3897 }
3898
3899 let override_branch = find_entry(overrides, &candidate.path, &candidate.common_dir)
3900 .and_then(|entry| entry.default_branch.clone());
3901 let never_cancelled = AtomicBool::new(false);
3902 let chain_cache: ChainFactsCache = Mutex::new(HashMap::new());
3903 let chain_reads = AtomicUsize::new(0);
3904
3905 let branch_outcome = probe_branch(&candidate.path, repo, candidate.kind, &never_cancelled);
3906 let sync_outcome = probe_sync(
3907 &candidate.path,
3908 repo,
3909 branch_outcome.as_ref().map(|(settled, ..)| settled),
3910 candidate.kind,
3911 &never_cancelled,
3912 );
3913 let default_branch_outcome = probe_default_branch_memoised(
3914 &candidate.path,
3915 repo,
3916 &candidate.common_dir,
3917 DefaultBranchHints {
3918 override_branch: override_branch.as_deref(),
3919 network_branch: network_branch_for(network_default_branch, &candidate.common_dir)
3920 .as_deref(),
3921 },
3922 candidate.kind,
3923 &never_cancelled,
3924 &ChainFactsMemo {
3925 cache: &chain_cache,
3926 reads: &chain_reads,
3927 },
3928 );
3929 let base_outcome = if candidate.probes_base {
3930 probe_base(
3931 &candidate.path,
3932 repo,
3933 branch_outcome.as_ref().map(|(settled, ..)| settled),
3934 default_branch_outcome.as_ref().map(|r| &r.settled),
3935 &never_cancelled,
3936 )
3937 } else {
3938 None
3939 };
3940
3941 let generation = {
3942 let mut table = table.write().unwrap();
3943 table.generation += 1;
3944 Generation::new(table.generation)
3945 };
3946 apply_cheap_probe_outcomes(
3947 table,
3948 &candidate.key,
3949 generation,
3950 CheapProbeOutcomes {
3951 branch: branch_outcome,
3952 sync: sync_outcome,
3953 base: base_outcome,
3954 default_branch: default_branch_outcome,
3955 },
3956 );
3957 poll_reprobed.lock().unwrap().push(candidate.key);
3958 }
3959}
3960
3961fn cancel_in_flight(table: &Arc<RwLock<Table>>, settle_gate: &Arc<SettleGate>) {
3966 let mut table = table.write().unwrap();
3967 let cancelled = table.in_flight.len();
3968 for in_flight in table.in_flight.values() {
3969 in_flight.cancel.store(true, Ordering::Release);
3970 }
3971 table.in_flight.clear();
3972 table.generation_started_at.clear();
3973 drop(table);
3974 if cancelled > 0 {
3975 complete_many(settle_gate, cancelled);
3976 }
3977}
3978
3979trait TimeoutableCell {
3985 fn is_in_flight(&self) -> bool;
3986 fn time_out(&mut self, generation: Generation);
3989}
3990
3991impl<T> TimeoutableCell for Cell<T> {
3992 fn is_in_flight(&self) -> bool {
3993 Cell::is_in_flight(self)
3994 }
3995
3996 fn time_out(&mut self, generation: Generation) {
3997 self.settle(generation, Settled::Unknown(Unknown::TimedOut));
3998 }
3999}
4000
4001fn sweep_deadline(table: &Arc<RwLock<Table>>, settle_gate: &Arc<SettleGate>, deadline: Duration) {
4006 let mut table = table.write().unwrap();
4007 let now = Instant::now();
4008 let mut timed_out = Vec::new();
4009 for (key, in_flight) in table.in_flight.iter() {
4010 let started = table
4011 .generation_started_at
4012 .get(&in_flight.generation)
4013 .copied()
4014 .unwrap_or(now);
4015 if now.duration_since(started) >= deadline {
4016 timed_out.push((key.clone(), Generation::new(in_flight.generation)));
4017 }
4018 }
4019 for (key, generation) in &timed_out {
4020 if let Some(&idx) = table.index.get(key) {
4021 let EntityState {
4024 key: _,
4025 name: _,
4026 common_dir: _,
4027 kind: _,
4028 branch,
4029 sync,
4030 base,
4031 dirty,
4032 state,
4033 default_branch,
4034 diagnostics: _,
4035 last_action: _,
4036 presence: _,
4037 excluded: _,
4038 in_progress_operation: _,
4039 recent_commits: _,
4040 } = &mut table.entities[idx];
4041 let cells: [&mut dyn TimeoutableCell; 6] =
4042 [branch, sync, base, dirty, state, default_branch];
4043 for cell in cells {
4044 if cell.is_in_flight() {
4049 cell.time_out(*generation);
4050 }
4051 }
4052 }
4053 table.in_flight.remove(key);
4054 }
4055 let live_generations: std::collections::HashSet<u64> =
4056 table.in_flight.values().map(|f| f.generation).collect();
4057 table
4058 .generation_started_at
4059 .retain(|generation, _| live_generations.contains(generation));
4060 drop(table);
4061 if !timed_out.is_empty() {
4062 complete_many(settle_gate, timed_out.len());
4063 }
4064}
4065
4066fn begin_probes(entity: &mut EntityState) {
4073 let probes_state = entity.probes_state();
4074 let EntityState {
4075 key: _,
4076 name: _,
4077 common_dir: _,
4078 kind: _,
4079 branch,
4080 sync: _,
4081 base: _,
4082 dirty,
4083 state,
4084 default_branch,
4085 diagnostics: _,
4086 last_action: _,
4087 presence: _,
4088 excluded: _,
4089 in_progress_operation: _,
4090 recent_commits: _,
4091 } = entity;
4092 branch.begin_probe();
4093 default_branch.begin_probe();
4094 dirty.begin_probe();
4098 if probes_state {
4103 state.begin_probe();
4104 }
4105}
4106
4107type SettleGate = (Mutex<SettleCounts>, Condvar);
4110
4111#[derive(Default)]
4118struct SettleCounts {
4119 probes: usize,
4122 dispatches: usize,
4125}
4126
4127impl SettleCounts {
4128 fn is_settled(&self) -> bool {
4134 let SettleCounts { probes, dispatches } = self;
4135 *probes == 0 && *dispatches == 0
4136 }
4137}
4138
4139fn begin_dispatch(settle_gate: &SettleGate) {
4142 let (lock, _cvar) = settle_gate;
4143 lock.lock().unwrap().dispatches += 1;
4144}
4145
4146fn finish_dispatch(settle_gate: &SettleGate) {
4149 let (lock, cvar) = settle_gate;
4150 let mut counts = lock.lock().unwrap();
4151 counts.dispatches = counts.dispatches.saturating_sub(1);
4152 drop(counts);
4153 cvar.notify_all();
4156}
4157
4158fn begin_probes_owed(settle_gate: &SettleGate, owed: usize) {
4159 let (lock, _cvar) = settle_gate;
4160 lock.lock().unwrap().probes += owed;
4161}
4162
4163fn complete_one(settle_gate: &SettleGate) {
4164 complete_many(settle_gate, 1);
4165}
4166
4167fn complete_many(settle_gate: &SettleGate, finished: usize) {
4168 let (lock, cvar) = settle_gate;
4169 let mut counts = lock.lock().unwrap();
4170 counts.probes = counts.probes.saturating_sub(finished);
4171 if counts.is_settled() {
4172 cvar.notify_all();
4173 }
4174}
4175
4176const RECENT_COMMITS_LIMIT: usize = 5;
4195
4196fn submodule_open_failure<T>(kind: Kind, error: git::ProbeError) -> Settled<T> {
4203 match kind {
4204 Kind::Repo | Kind::Worktree => Settled::Failed(error),
4205 Kind::Submodule => Settled::Unknown(Unknown::SubmoduleUninitialized),
4206 }
4207}
4208
4209fn probe_branch(
4210 path: &Path,
4211 repo: Option<&gix::ThreadSafeRepository>,
4212 kind: Kind,
4213 cancel: &AtomicBool,
4214) -> Option<(
4215 Settled<Head>,
4216 Option<git::InProgressOperation>,
4217 Vec<git::RecentCommit>,
4218)> {
4219 if cancel.load(Ordering::Acquire) {
4220 return None;
4221 }
4222 let opened;
4223 let repo = match repo {
4224 Some(repo) => repo,
4225 None => match git::open_thread_safe(path) {
4226 Ok(repo) => {
4227 opened = repo;
4228 &opened
4229 }
4230 Err(error) => return Some((submodule_open_failure(kind, error), None, Vec::new())),
4231 },
4232 };
4233 let local = repo.to_thread_local();
4234 let settled = match git::head_shape(&local) {
4235 Ok(head) => Settled::Known {
4236 value: head,
4237 at: Timestamp::now(),
4238 stale: false,
4239 },
4240 Err(error) => Settled::Failed(error),
4241 };
4242 let in_progress = git::in_progress_operation(&local);
4243 let recent = git::recent_commits(&local, RECENT_COMMITS_LIMIT);
4244 Some((settled, in_progress, recent))
4245}
4246
4247fn probe_sync(
4258 path: &Path,
4259 repo: Option<&gix::ThreadSafeRepository>,
4260 branch_settled: Option<&Settled<Head>>,
4261 kind: Kind,
4262 cancel: &AtomicBool,
4263) -> Option<Settled<SyncState>> {
4264 if cancel.load(Ordering::Acquire) {
4265 return None;
4266 }
4267 let head = match branch_settled? {
4268 Settled::Known {
4269 value,
4270 at: _,
4271 stale: _,
4272 } => Some(value),
4273 Settled::Failed(error) => return Some(Settled::Failed(error.clone())),
4274 Settled::Unknown(_) | Settled::NotApplicable => None,
4275 };
4276 let opened;
4277 let repo = match repo {
4278 Some(repo) => repo,
4279 None => match git::open_thread_safe(path) {
4280 Ok(repo) => {
4281 opened = repo;
4282 &opened
4283 }
4284 Err(error) => return Some(submodule_open_failure(kind, error)),
4285 },
4286 };
4287 let local = repo.to_thread_local();
4288 let settled = match git::resolve_sync(&local, head) {
4289 Ok(value) => Settled::Known {
4290 value,
4291 at: Timestamp::now(),
4292 stale: false,
4293 },
4294 Err(error) => Settled::Failed(error),
4295 };
4296 Some(settled)
4297}
4298
4299fn probe_base(
4311 path: &Path,
4312 repo: Option<&gix::ThreadSafeRepository>,
4313 branch_settled: Option<&Settled<Head>>,
4314 default_branch_settled: Option<&Settled<DefaultBranch>>,
4315 cancel: &AtomicBool,
4316) -> Option<Settled<u32>> {
4317 if cancel.load(Ordering::Acquire) {
4318 return None;
4319 }
4320 let head = match branch_settled? {
4321 Settled::Known {
4322 value,
4323 at: _,
4324 stale: _,
4325 } => value,
4326 Settled::Failed(error) => return Some(Settled::Failed(error.clone())),
4327 Settled::Unknown(_) | Settled::NotApplicable => return None,
4328 };
4329 let default_branch_settled = default_branch_settled?;
4330 let opened;
4331 let repo = match repo {
4332 Some(repo) => repo,
4333 None => match git::open_thread_safe(path) {
4334 Ok(repo) => {
4335 opened = repo;
4336 &opened
4337 }
4338 Err(error) => return Some(Settled::Failed(error)),
4339 },
4340 };
4341 let local = repo.to_thread_local();
4342 Some(base::probe(&local, head, default_branch_settled))
4343}
4344
4345fn probe_status(
4355 path: &Path,
4356 repo: Option<&gix::ThreadSafeRepository>,
4357 kind: Kind,
4358 cancel: &Arc<AtomicBool>,
4359) -> Option<Settled<DirtyCounts>> {
4360 if cancel.load(Ordering::Acquire) {
4361 return None;
4362 }
4363 let opened;
4364 let repo = match repo {
4365 Some(repo) => repo,
4366 None => match git::open_thread_safe(path) {
4367 Ok(repo) => {
4368 opened = repo;
4369 &opened
4370 }
4371 Err(error) => return Some(submodule_open_failure(kind, error)),
4372 },
4373 };
4374 let local = repo.to_thread_local();
4375 classify_status_result(git::dirty_counts(&local, Arc::clone(cancel)), cancel)
4376}
4377
4378fn classify_status_result(
4394 result: Result<DirtyCounts, git::ProbeError>,
4395 cancel: &AtomicBool,
4396) -> Option<Settled<DirtyCounts>> {
4397 match result {
4398 Ok(_) if cancel.load(Ordering::Acquire) => None,
4399 Ok(value) => Some(Settled::Known {
4400 value,
4401 at: Timestamp::now(),
4402 stale: false,
4403 }),
4404 Err(_) if cancel.load(Ordering::Acquire) => None,
4405 Err(error) => Some(Settled::Failed(error)),
4406 }
4407}
4408
4409struct DefaultBranchHints<'a> {
4415 override_branch: Option<&'a str>,
4418 network_branch: Option<&'a str>,
4422}
4423
4424fn network_branch_for(
4428 network_default_branch: &Mutex<HashMap<PathBuf, Arc<str>>>,
4429 common_dir: &Path,
4430) -> Option<Arc<str>> {
4431 network_default_branch
4432 .lock()
4433 .unwrap()
4434 .get(common_dir)
4435 .cloned()
4436}
4437
4438fn supersede_with_network(
4447 mut resolution: default_branch::Resolution,
4448 network_branch: Option<&str>,
4449) -> default_branch::Resolution {
4450 if let Some(name) = network_branch {
4451 resolution.settled = Settled::Known {
4452 value: DefaultBranch::new(name.into()),
4453 at: Timestamp::now(),
4454 stale: false,
4455 };
4456 }
4457 resolution
4458}
4459
4460fn probe_default_branch(
4468 path: &Path,
4469 repo: Option<&gix::ThreadSafeRepository>,
4470 hints: DefaultBranchHints<'_>,
4471 kind: Kind,
4472 cancel: &AtomicBool,
4473) -> Option<default_branch::Resolution> {
4474 if cancel.load(Ordering::Acquire) {
4475 return None;
4476 }
4477 let opened;
4478 let repo = match repo {
4479 Some(repo) => repo,
4480 None => match git::open_thread_safe(path) {
4481 Ok(repo) => {
4482 opened = repo;
4483 &opened
4484 }
4485 Err(error) => {
4486 return Some(match kind {
4487 Kind::Repo | Kind::Worktree => default_branch::Resolution::failed(error),
4488 Kind::Submodule => default_branch::Resolution::submodule_uninitialized(),
4489 });
4490 }
4491 },
4492 };
4493 Some(supersede_with_network(
4494 default_branch::resolve(&repo.to_thread_local(), hints.override_branch),
4495 hints.network_branch,
4496 ))
4497}
4498
4499struct BoundGate {
4512 state: Mutex<BoundGateState>,
4513 condvar: Condvar,
4514 bound: OnceLock<Option<gix::ObjectId>>,
4515}
4516
4517struct BoundGateState {
4518 remaining: usize,
4519 candidates: Vec<gix::ObjectId>,
4520}
4521
4522impl BoundGate {
4523 fn new(remaining: usize) -> Self {
4524 Self {
4525 state: Mutex::new(BoundGateState {
4526 remaining,
4527 candidates: Vec::new(),
4528 }),
4529 condvar: Condvar::new(),
4530 bound: OnceLock::new(),
4531 }
4532 }
4533
4534 fn report(&self, candidate: Option<gix::ObjectId>) {
4540 let mut state = self.state.lock().unwrap();
4541 if let Some(candidate) = candidate {
4542 state.candidates.push(candidate);
4543 }
4544 state.remaining -= 1;
4545 if state.remaining == 0 {
4546 self.condvar.notify_all();
4547 }
4548 }
4549
4550 fn deepest(&self, repo: &gix::Repository) -> Option<gix::ObjectId> {
4560 let mut state = self.state.lock().unwrap();
4561 while state.remaining != 0 {
4562 state = self.condvar.wait(state).unwrap();
4563 }
4564 let candidates = std::mem::take(&mut state.candidates);
4565 *self
4566 .bound
4567 .get_or_init(|| deepest_merge_base(repo, &candidates))
4568 }
4569}
4570
4571fn deepest_merge_base(
4578 repo: &gix::Repository,
4579 candidates: &[gix::ObjectId],
4580) -> Option<gix::ObjectId> {
4581 let mut candidates = candidates.iter().copied();
4582 let mut deepest = candidates.next()?;
4583 for candidate in candidates {
4584 deepest = git::checked_merge_base(repo, deepest, candidate)
4585 .ok()
4586 .flatten()
4587 .unwrap_or(deepest);
4588 }
4589 Some(deepest)
4590}
4591
4592struct GateReport<'a> {
4598 gate: &'a BoundGate,
4599 reported: bool,
4600}
4601
4602impl<'a> GateReport<'a> {
4603 fn new(gate: &'a BoundGate) -> Self {
4604 Self {
4605 gate,
4606 reported: false,
4607 }
4608 }
4609
4610 fn report_now(&mut self, candidate: Option<gix::ObjectId>) {
4615 self.gate.report(candidate);
4616 self.reported = true;
4617 }
4618}
4619
4620impl Drop for GateReport<'_> {
4621 fn drop(&mut self) {
4622 if !self.reported {
4623 self.gate.report(None);
4624 }
4625 }
4626}
4627
4628struct PatchEquivalenceMemo<'a> {
4632 cache: &'a PatchIdentityCache,
4633 reads: &'a AtomicUsize,
4634 scan_bounds: &'a Mutex<Vec<Option<gix::ObjectId>>>,
4637}
4638
4639fn probe_worktree_state(
4648 path: &Path,
4649 repo: Option<&gix::ThreadSafeRepository>,
4650 default_branch_settled: Option<&Settled<DefaultBranch>>,
4651 common_dir: &Arc<Path>,
4652 cancel: &AtomicBool,
4653 memo: &PatchEquivalenceMemo<'_>,
4654 report: &mut GateReport<'_>,
4655) -> Option<Settled<WorktreeState>> {
4656 if cancel.load(Ordering::Acquire) {
4657 return None;
4658 }
4659 let default_branch_settled = default_branch_settled?;
4660 let opened;
4661 let repo = match repo {
4662 Some(repo) => repo,
4663 None => match git::open_thread_safe(path) {
4664 Ok(repo) => {
4665 opened = repo;
4666 &opened
4667 }
4668 Err(error) => return Some(Settled::Failed(error)),
4669 },
4670 };
4671 let local = repo.to_thread_local();
4672 match landing::probe(&local, default_branch_settled) {
4673 landing::Outcome::Settle(settled) => Some(settled),
4674 landing::Outcome::Outstanding(outstanding) => {
4675 probe_patch_equivalence(&local, &outstanding, common_dir, cancel, memo, report)
4676 }
4677 }
4678}
4679
4680fn probe_patch_equivalence(
4688 repo: &gix::Repository,
4689 outstanding: &landing::Outstanding,
4690 common_dir: &Arc<Path>,
4691 cancel: &AtomicBool,
4692 memo: &PatchEquivalenceMemo<'_>,
4693 report: &mut GateReport<'_>,
4694) -> Option<Settled<WorktreeState>> {
4695 if cancel.load(Ordering::Acquire) {
4696 return None;
4697 }
4698 let landing::Outstanding {
4699 entity_tip,
4700 default_tip,
4701 merge_base,
4702 } = *outstanding;
4703 let Some(merge_base) = merge_base else {
4704 report.report_now(None);
4710 return Some(patch_equivalence::probe(
4711 repo,
4712 entity_tip,
4713 None,
4714 &patch_equivalence::PatchIdentitySet::new(),
4715 ));
4716 };
4717 report.report_now(Some(merge_base));
4721 let bound = report.gate.deepest(repo);
4722 let shared = match patch_identities_for(memo.cache, common_dir, memo.reads, || {
4723 memo.scan_bounds.lock().unwrap().push(bound);
4728 patch_equivalence::scan_default_branch(repo, default_tip, bound)
4729 }) {
4730 Ok(shared) => shared,
4731 Err(error) => return Some(Settled::Failed(error)),
4732 };
4733 Some(patch_equivalence::probe(
4734 repo,
4735 entity_tip,
4736 Some(merge_base),
4737 &shared,
4738 ))
4739}
4740
4741type PatchIdentityCache = Mutex<
4749 HashMap<Arc<Path>, Arc<OnceLock<Result<patch_equivalence::PatchIdentitySet, git::ProbeError>>>>,
4750>;
4751
4752fn patch_identities_for(
4762 cache: &PatchIdentityCache,
4763 common_dir: &Arc<Path>,
4764 reads: &AtomicUsize,
4765 compute: impl FnOnce() -> Result<patch_equivalence::PatchIdentitySet, git::ProbeError>,
4766) -> Result<patch_equivalence::PatchIdentitySet, git::ProbeError> {
4767 let cell = {
4768 let mut cache = cache.lock().unwrap();
4769 Arc::clone(
4770 cache
4771 .entry(Arc::clone(common_dir))
4772 .or_insert_with(|| Arc::new(OnceLock::new())),
4773 )
4774 };
4775 cell.get_or_init(|| {
4776 reads.fetch_add(1, Ordering::Relaxed);
4777 compute()
4778 })
4779 .clone()
4780}
4781
4782type ChainFactsCache = Mutex<HashMap<Arc<Path>, Arc<OnceLock<default_branch::ChainFacts>>>>;
4786
4787fn chain_facts_for(
4794 cache: &ChainFactsCache,
4795 common_dir: &Arc<Path>,
4796 reads: &AtomicUsize,
4797 compute: impl FnOnce() -> default_branch::ChainFacts,
4798) -> default_branch::ChainFacts {
4799 let cell = {
4800 let mut cache = cache.lock().unwrap();
4801 Arc::clone(
4802 cache
4803 .entry(Arc::clone(common_dir))
4804 .or_insert_with(|| Arc::new(OnceLock::new())),
4805 )
4806 };
4807 cell.get_or_init(|| {
4808 reads.fetch_add(1, Ordering::Relaxed);
4809 compute()
4810 })
4811 .clone()
4812}
4813
4814struct ChainFactsMemo<'a> {
4825 cache: &'a ChainFactsCache,
4826 reads: &'a AtomicUsize,
4827}
4828
4829fn probe_default_branch_memoised(
4830 path: &Path,
4831 repo: Option<&gix::ThreadSafeRepository>,
4832 common_dir: &Arc<Path>,
4833 hints: DefaultBranchHints<'_>,
4834 kind: Kind,
4835 cancel: &AtomicBool,
4836 memo: &ChainFactsMemo<'_>,
4837) -> Option<default_branch::Resolution> {
4838 if cancel.load(Ordering::Acquire) {
4839 return None;
4840 }
4841 let opened;
4842 let repo = match repo {
4843 Some(repo) => repo,
4844 None => match git::open_thread_safe(path) {
4845 Ok(repo) => {
4846 opened = repo;
4847 &opened
4848 }
4849 Err(error) => {
4850 return Some(match kind {
4851 Kind::Repo | Kind::Worktree => default_branch::Resolution::failed(error),
4852 Kind::Submodule => default_branch::Resolution::submodule_uninitialized(),
4853 });
4854 }
4855 },
4856 };
4857 let local = repo.to_thread_local();
4858 let facts = chain_facts_for(memo.cache, common_dir, memo.reads, || {
4859 default_branch::ChainFacts::resolve(&local)
4860 });
4861 Some(supersede_with_network(
4862 default_branch::resolve_with_facts(&facts, hints.override_branch),
4863 hints.network_branch,
4864 ))
4865}
4866
4867struct CheapProbeOutcomes {
4872 branch: Option<(
4873 Settled<Head>,
4874 Option<git::InProgressOperation>,
4875 Vec<git::RecentCommit>,
4876 )>,
4877 sync: Option<Settled<SyncState>>,
4878 base: Option<Settled<u32>>,
4879 default_branch: Option<default_branch::Resolution>,
4880}
4881
4882fn apply_cheap_probe_outcomes(
4891 table: &Arc<RwLock<Table>>,
4892 key: &EntityKey,
4893 generation: Generation,
4894 outcomes: CheapProbeOutcomes,
4895) {
4896 let CheapProbeOutcomes {
4897 branch: branch_outcome,
4898 sync: sync_outcome,
4899 base: base_outcome,
4900 default_branch: default_branch_outcome,
4901 } = outcomes;
4902 let mut table = table.write().unwrap();
4903 if let Some(&idx) = table.index.get(key) {
4904 if let Some((settled, in_progress, recent)) = branch_outcome {
4905 table.entities[idx].apply_branch_probe(generation, settled, in_progress, recent);
4906 }
4907 if let Some(settled) = sync_outcome {
4908 table.entities[idx].sync.settle(generation, settled);
4909 }
4910 if let Some(settled) = base_outcome {
4911 table.entities[idx].base.settle(generation, settled);
4912 }
4913 if let Some(resolution) = default_branch_outcome {
4914 table.entities[idx].apply_default_branch_resolution(generation, resolution);
4915 }
4916 }
4917}
4918
4919struct ProbeOutcomes {
4923 state: Option<Settled<WorktreeState>>,
4924 dirty: Option<Settled<DirtyCounts>>,
4925}
4926
4927fn apply_probe_outcome(
4941 table: &Arc<RwLock<Table>>,
4942 settle_gate: &Arc<SettleGate>,
4943 key: &EntityKey,
4944 generation: Generation,
4945 outcomes: ProbeOutcomes,
4946) {
4947 let ProbeOutcomes {
4948 state: state_outcome,
4949 dirty: dirty_outcome,
4950 } = outcomes;
4951 let mut table = table.write().unwrap();
4952 if let Some(&idx) = table.index.get(key) {
4953 if let Some(settled) = state_outcome {
4954 table.entities[idx].state.settle(generation, settled);
4955 }
4956 if let Some(settled) = dirty_outcome {
4957 table.entities[idx].dirty.settle(generation, settled);
4958 }
4959 }
4960 if table
4968 .in_flight
4969 .get(key)
4970 .is_some_and(|in_flight| in_flight.generation == generation.value())
4971 {
4972 table.in_flight.remove(key);
4973 }
4974 drop(table);
4975 complete_one(settle_gate);
4976}
4977
4978fn merge_discovery(
4984 table: &mut Table,
4985 exclusions: &[ResolvedExclusion],
4986 discovered: Vec<discovery::DiscoveredEntity>,
4987 gitmodules_failures: Vec<(EntityKey, String)>,
4988) -> usize {
4989 let mut found: HashSet<EntityKey> = HashSet::with_capacity(discovered.len());
4990
4991 for discovered in discovered {
4992 found.insert(discovered.key.clone());
4993 match table.index.get(&discovered.key).copied() {
4994 Some(idx) => {
4995 table.entities[idx].presence = Presence::Present;
4996 if let Some(repo) = discovered.repo {
4997 table.repos.insert(discovered.key.clone(), repo);
4998 }
4999 }
5000 None => {
5001 let name = discovered
5002 .display_name_override
5003 .clone()
5004 .unwrap_or_else(|| display_name(discovered.key.path()));
5005 let mut entity = EntityState::new(
5006 discovered.key.clone(),
5007 name,
5008 Arc::clone(&discovered.common_dir),
5009 discovered.kind,
5010 );
5011 entity.excluded =
5012 excluded_by(exclusions, discovered.key.path(), &discovered.common_dir);
5013 if let Some(repo) = discovered.repo {
5014 table.repos.insert(discovered.key.clone(), repo);
5015 }
5016 let idx = table.entities.len();
5017 table.index.insert(discovered.key, idx);
5018 table.entities.push(entity);
5019 }
5020 }
5021 }
5022
5023 let now_failing: HashMap<EntityKey, String> = gitmodules_failures.into_iter().collect();
5027 for key in &found {
5028 if let Some(&idx) = table.index.get(key) {
5029 table.entities[idx].diagnostics.gitmodules_failed = now_failing
5030 .get(key)
5031 .map(|message| Arc::from(message.as_str()));
5032 }
5033 }
5034
5035 let missing: Vec<EntityKey> = table
5036 .index
5037 .keys()
5038 .filter(|key| !found.contains(*key))
5039 .cloned()
5040 .collect();
5041 let mut cancelled = 0usize;
5042 for key in missing {
5043 if let Some(&idx) = table.index.get(&key) {
5044 table.entities[idx].mark_vanished();
5045 }
5046 if let Some(in_flight) = table.in_flight.remove(&key) {
5047 in_flight.cancel.store(true, Ordering::Release);
5048 cancelled += 1;
5049 }
5050 }
5051
5052 cancelled
5053}
5054
5055fn display_name(path: &Path) -> Arc<str> {
5064 Arc::from(
5065 path.file_name()
5066 .and_then(|name| name.to_str())
5067 .unwrap_or("?"),
5068 )
5069}
5070
5071fn watch_for_slow_discovery(
5077 progress: Arc<AtomicUsize>,
5078 finished: Arc<AtomicBool>,
5079 roots: Vec<PathBuf>,
5080 warn_after: Duration,
5081) -> Option<String> {
5082 thread::sleep(warn_after);
5083 if finished.load(Ordering::Acquire) {
5084 return None;
5085 }
5086 Some(still_walking_message(
5087 progress.load(Ordering::Acquire),
5088 &roots,
5089 ))
5090}
5091
5092fn still_walking_message(directories_visited: usize, roots: &[PathBuf]) -> String {
5093 let roots = roots
5094 .iter()
5095 .map(|root| root.display().to_string())
5096 .collect::<Vec<_>>()
5097 .join(", ");
5098 format!("discovery: still walking, {directories_visited} directories reached under {roots}")
5099}
5100
5101fn abandoned_discovery_message(directories_visited: usize) -> String {
5106 format!("discovery: stopped at {directories_visited} directories")
5107}
5108
5109#[allow(dead_code)] pub(crate) fn run_while_not_cancelled(
5117 cancel: &AtomicBool,
5118 mut step: impl FnMut() -> bool,
5119) -> usize {
5120 let mut ran = 0;
5121 while !cancel.load(Ordering::Acquire) {
5122 if !step() {
5123 break;
5124 }
5125 ran += 1;
5126 }
5127 ran
5128}
5129
5130#[cfg(test)]
5131mod tests {
5132 use std::fs;
5133 use std::process::Command;
5134 use std::sync::mpsc;
5135
5136 use super::*;
5137 use crate::entity::{AheadBehind, DefaultBranchStopped, WorktreeState};
5138 use crate::liveness::{BACKSTOP, FIXTURE_LIFETIME, wait_for};
5139 use crate::snapshot::{RowSummary, summary};
5140 use crate::test_support::{git, head_sha, loose_object_count};
5141
5142 fn init_repo_with_a_commit(path: &Path) {
5143 fs::create_dir_all(path).expect("create repo dir");
5144 gix::init(path).expect("init repo");
5145 let status = Command::new("git")
5146 .arg("-C")
5147 .arg(path)
5148 .args(["-c", "user.email=test@example.com", "-c", "user.name=Test"])
5149 .args(["commit", "--allow-empty", "-m", "first"])
5150 .status()
5151 .expect("run git commit");
5152 assert!(status.success());
5153 }
5154
5155 fn commit_a_change(path: &Path, message: &str) {
5165 let gitdir = gitdir_of(path);
5166 let before = poll::fingerprint(&gitdir);
5167
5168 std::fs::write(path.join(format!("{message}.txt")), message.as_bytes())
5169 .expect("write a file to commit");
5170 let added = Command::new("git")
5171 .arg("-C")
5172 .arg(path)
5173 .args(["add", "-A"])
5174 .status()
5175 .expect("run git add");
5176 assert!(added.success());
5177 commit(path, message, &["-m", message]);
5178
5179 assert!(
5185 poll::moved(&before, &poll::fingerprint(&gitdir)),
5186 "committing in {} moved none of the polled paths under {}, so this fixture cannot \
5187 show the poll anything",
5188 path.display(),
5189 gitdir.display()
5190 );
5191 }
5192
5193 fn gitdir_of(work_dir: &Path) -> PathBuf {
5196 let output = Command::new("git")
5197 .arg("-C")
5198 .arg(work_dir)
5199 .args(["rev-parse", "--absolute-git-dir"])
5200 .output()
5201 .expect("run git rev-parse");
5202 assert!(
5203 output.status.success(),
5204 "resolve the gitdir of {}",
5205 work_dir.display()
5206 );
5207 PathBuf::from(
5208 std::str::from_utf8(&output.stdout)
5209 .expect("a utf-8 gitdir path")
5210 .trim(),
5211 )
5212 }
5213
5214 fn commit(path: &Path, message: &str, args: &[&str]) {
5216 let status = Command::new("git")
5217 .arg("-C")
5218 .arg(path)
5219 .args(["-c", "user.email=test@example.com", "-c", "user.name=Test"])
5220 .arg("commit")
5221 .args(args)
5222 .status()
5223 .unwrap_or_else(|error| panic!("run git commit {message}: {error}"));
5224 assert!(status.success());
5225 }
5226
5227 fn fetch_spec_for_test() -> FetchSpec {
5231 FetchSpec {
5232 enabled: false,
5233 interval: Duration::from_secs(3600),
5234 concurrency: 4,
5235 }
5236 }
5237
5238 fn auto_update_spec_for_test() -> AutoUpdateSpec {
5243 AutoUpdateSpec { enabled: false }
5244 }
5245
5246 fn spec(roots: Vec<PathBuf>) -> CoreSpec {
5247 CoreSpec {
5248 set: SetSpec {
5249 name: "test".to_string(),
5250 roots,
5251 include: Vec::new(),
5252 exclude: Vec::new(),
5253 },
5254 overrides: Vec::new(),
5255 poll_interval: Duration::from_secs(3600),
5256 status_stale_after: Duration::from_secs(3600),
5257 generation_deadline: Duration::from_secs(3600),
5258 show_submodules: false,
5259 fetch: fetch_spec_for_test(),
5260 auto_update: auto_update_spec_for_test(),
5261 }
5262 }
5263
5264 #[test]
5275 fn core_spec_carries_no_scoping_field_scope_is_never_a_dial() {
5276 let CoreSpec {
5277 set: _,
5278 overrides: _,
5279 poll_interval: _,
5280 status_stale_after: _,
5281 generation_deadline: _,
5282 show_submodules: _,
5283 fetch: _,
5284 auto_update: _,
5285 } = spec(Vec::new());
5286 }
5287
5288 fn root_of(dir: &tempfile::TempDir) -> PathBuf {
5289 dir.path().canonicalize().expect("canonicalize temp dir")
5290 }
5291
5292 fn settle_launch(core: &Core) -> Snapshot {
5301 let launched = core.settle();
5302 assert_eq!(
5303 core.settle_gate_count_for_test(),
5304 0,
5305 "launch's own Generation never settled, so nothing after this is starting from \
5306 the point it claims to"
5307 );
5308 launched
5309 }
5310
5311 fn started_and_settled(spec: CoreSpec) -> (Core, Snapshot) {
5314 let core = Core::start_discovered(spec);
5315 let launched = settle_launch(&core);
5316 (core, launched)
5317 }
5318
5319 fn backdate_polled_entries(work_dir: &Path) {
5326 let gitdir = gitdir_of(work_dir);
5327
5328 let past = std::time::SystemTime::now() - Duration::from_secs(10);
5329 let mut touched = 0;
5330 for name in poll::POLLED_GITDIR_ENTRIES {
5331 let path = gitdir.join(name);
5332 if path.exists() {
5333 set_mtime_to(&path, past);
5334 touched += 1;
5335 }
5336 }
5337 assert!(
5338 touched > 0,
5339 "backdated nothing under {}; the gitdir holds none of the polled entries and the \
5340 baseline this sets up would not be older than what follows",
5341 gitdir.display()
5342 );
5343 }
5344
5345 fn set_mtime_to(path: &Path, at: std::time::SystemTime) {
5347 use std::os::unix::ffi::OsStrExt;
5348
5349 let secs = at
5350 .duration_since(std::time::SystemTime::UNIX_EPOCH)
5351 .expect("a time after the epoch")
5352 .as_secs() as libc::time_t;
5353 let times = [
5354 libc::timespec {
5355 tv_sec: secs,
5356 tv_nsec: 0,
5357 },
5358 libc::timespec {
5359 tv_sec: secs,
5360 tv_nsec: 0,
5361 },
5362 ];
5363 let c_path =
5364 std::ffi::CString::new(path.as_os_str().as_bytes()).expect("a path with no NUL");
5365 let rc = unsafe { libc::utimensat(libc::AT_FDCWD, c_path.as_ptr(), times.as_ptr(), 0) };
5366 assert_eq!(
5367 rc,
5368 0,
5369 "set mtime on {}: {}",
5370 path.display(),
5371 std::io::Error::last_os_error()
5372 );
5373 }
5374
5375 fn step(argv: &[&str]) -> Step {
5376 Step {
5377 argv: argv.iter().map(|s| s.to_string()).collect(),
5378 shell: false,
5379 interactive: false,
5380 env: Vec::new(),
5381 }
5382 }
5383
5384 fn shell_step(command: &str) -> Step {
5386 Step {
5387 argv: vec![command.to_string()],
5388 shell: true,
5389 interactive: false,
5390 env: Vec::new(),
5391 }
5392 }
5393
5394 fn interactive_shell_step(command: &str) -> Step {
5397 Step {
5398 argv: vec![command.to_string()],
5399 shell: true,
5400 interactive: true,
5401 env: Vec::new(),
5402 }
5403 }
5404
5405 fn receipt_labelled(core: &Core, key: &EntityKey, label: &str) -> Option<ActionReceipt> {
5409 core.snapshot()
5410 .entities
5411 .iter()
5412 .find(|entity| entity.key == *key)
5413 .and_then(|entity| entity.last_action.clone())
5414 .filter(|receipt| &*receipt.label == label)
5415 }
5416
5417 fn action(label: &str, steps: Vec<Step>) -> ActionSpec {
5418 ActionSpec {
5419 label: Arc::from(label),
5420 name: Some(Arc::from(label)),
5421 steps,
5422 concurrency: 4,
5423 when: None,
5424 }
5425 }
5426
5427 fn action_with_when(label: &str, steps: Vec<Step>, when: &str) -> ActionSpec {
5430 ActionSpec {
5431 when: Some(Filter::parse(when)),
5432 ..action(label, steps)
5433 }
5434 }
5435
5436 #[test]
5441 fn refresh_and_settle_populate_real_cells_without_the_caller_spawning_a_thread() {
5442 let dir = tempfile::tempdir().expect("temp dir");
5443 let root = root_of(&dir);
5444 let repo = root.join("repo");
5445 init_repo_with_a_commit(&repo);
5446
5447 let core = Core::start_discovered(spec(vec![root]));
5448 let keys: Vec<EntityKey> = core
5449 .snapshot()
5450 .entities
5451 .iter()
5452 .map(|entity| entity.key.clone())
5453 .collect();
5454 assert_eq!(keys.len(), 1);
5455
5456 core.refresh(&keys);
5457 let settled = core.settle();
5458
5459 let entity = &settled.entities[0];
5460 match entity.branch.settled() {
5461 Some(Settled::Known {
5462 value: Head::Branch { .. },
5463 at: _,
5464 stale: _,
5465 }) => {}
5466 other => panic!("expected an attached branch, got {other:?}"),
5467 }
5468 }
5469
5470 fn spec_refresh_md() -> String {
5475 let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
5476 std::fs::read_to_string(manifest_dir.join("../../docs/spec/refresh.md"))
5477 .expect("read docs/spec/refresh.md")
5478 }
5479
5480 fn spec_first_frame_budgets_ms(spec: &str) -> (u64, u64) {
5481 let anchor = "rows with names on screen within ";
5482 let after = spec
5483 .split(anchor)
5484 .nth(1)
5485 .expect("the first-frame budget sentence is present");
5486 let mut parts = after.splitn(2, "ms, every cheap column filled within ");
5487 let names: u64 = parts
5488 .next()
5489 .expect("a names-on-screen budget")
5490 .parse()
5491 .expect("the names-on-screen budget is an integer");
5492 let after_cheap = parts.next().expect("a cheap-column budget and beyond");
5493 let cheap_columns: u64 = after_cheap
5494 .split("ms,")
5495 .next()
5496 .expect("a cheap-column budget")
5497 .parse()
5498 .expect("the cheap-column budget is an integer");
5499 (names, cheap_columns)
5500 }
5501
5502 #[test]
5506 fn first_frame_budget_constants_match_the_spec_of_record() {
5507 let spec = spec_refresh_md();
5508 let (names_ms, cheap_columns_ms) = spec_first_frame_budgets_ms(&spec);
5509 assert_eq!(names_ms, FIRST_FRAME_NAMES_BUDGET_MS);
5510 assert_eq!(cheap_columns_ms, FIRST_FRAME_CHEAP_COLUMNS_BUDGET_MS);
5511 }
5512
5513 #[test]
5521 fn every_dispatched_entity_gets_its_dirty_cell_settled_not_a_subset() {
5522 let dir = tempfile::tempdir().expect("temp dir");
5523 let root = root_of(&dir);
5524 const ENTITY_COUNT: usize = 16;
5525 for index in 0..ENTITY_COUNT {
5526 init_repo_with_a_commit(&root.join(format!("repo-{index}")));
5527 }
5528
5529 let core = Core::start_discovered(spec(vec![root]));
5530 let keys: Vec<EntityKey> = core
5531 .snapshot()
5532 .entities
5533 .iter()
5534 .map(|entity| entity.key.clone())
5535 .collect();
5536 assert_eq!(keys.len(), ENTITY_COUNT, "expected every repo discovered");
5537
5538 core.refresh(&keys);
5539 let settled = core.settle();
5540
5541 for entity in &settled.entities {
5542 assert!(
5543 matches!(
5544 entity.dirty.settled(),
5545 Some(Settled::Known {
5546 value: _,
5547 at: _,
5548 stale: _
5549 })
5550 ),
5551 "entity {:?} was left without a settled dirty cell, which is exactly what a \
5552 visibility-scoped dispatch would leave behind on the entities it skipped: \
5553 got {:?}",
5554 entity.name,
5555 entity.dirty.settled()
5556 );
5557 }
5558 }
5559
5560 #[test]
5575 fn cheap_outcomes_land_before_a_held_phase_c_settles() {
5576 let dir = tempfile::tempdir().expect("temp dir");
5577 let root = root_of(&dir);
5578 let repo = root.join("repo");
5579 init_repo_with_a_commit(&repo);
5580
5581 let (core, launched) = started_and_settled(spec(vec![root]));
5582 let key = launched.entities[0].key.clone();
5583 assert_eq!(
5584 dirty_total(&launched.entities[0]),
5585 0,
5586 "the fixture starts clean, which is the value the held phase C must still be \
5587 reading once the working tree below has moved"
5588 );
5589
5590 git(&repo, &["checkout", "-b", "held"]);
5594 fs::write(repo.join("untracked.txt"), b"uncommitted")
5595 .expect("write an untracked file into the fixture");
5596
5597 core.hold_phase_c_for_test(&key);
5598 core.refresh(std::slice::from_ref(&key));
5599 core.wait_phase_c_landed_for_test(&key);
5600
5601 let mid_flight = core.snapshot();
5602 let entity = mid_flight
5603 .entities
5604 .iter()
5605 .find(|entity| entity.key == key)
5606 .expect("entity present");
5607 assert!(
5608 matches!(
5609 entity.branch.settled(),
5610 Some(Settled::Known {
5611 value: Head::Branch { name, .. },
5612 at: _,
5613 stale: _
5614 }) if &**name == "held"
5615 ),
5616 "the cheap branch cell must carry this Generation's own answer while phase C is \
5617 still held open, got {:?}",
5618 entity.branch.settled()
5619 );
5620 assert!(
5621 entity.dirty.is_in_flight() && dirty_total(entity) == 0,
5622 "phase C is deliberately held open here; a bundled apply would already have \
5623 written this cell's new count alongside branch, got {:?}",
5624 entity.dirty.settled()
5625 );
5626
5627 core.release_phase_c_for_test(&key);
5628 core.wait_phase_c_finished_for_test(&key);
5629
5630 let settled = core.snapshot();
5631 let entity = settled
5632 .entities
5633 .iter()
5634 .find(|entity| entity.key == key)
5635 .expect("entity present");
5636 assert_eq!(
5637 dirty_total(entity),
5638 1,
5639 "phase C must settle its own count once released, got {:?}",
5640 entity.dirty.settled()
5641 );
5642 }
5643
5644 fn dirty_total(entity: &EntityState) -> u32 {
5648 match entity.dirty.settled() {
5649 Some(Settled::Known {
5650 value,
5651 at: _,
5652 stale: _,
5653 }) => value.total(),
5654 other => panic!("expected a settled dirty count, got {other:?}"),
5655 }
5656 }
5657
5658 #[test]
5666 fn splitting_the_probe_write_signals_settle_gate_exactly_once_per_entity() {
5667 let dir = tempfile::tempdir().expect("temp dir");
5668 let root = root_of(&dir);
5669 init_repo_with_a_commit(&root.join("a"));
5670 init_repo_with_a_commit(&root.join("b"));
5671
5672 let (core, snapshot) = started_and_settled(spec(vec![root]));
5673 let key_a = snapshot
5674 .entities
5675 .iter()
5676 .find(|entity| &*entity.name == "a")
5677 .expect("entity a present")
5678 .key
5679 .clone();
5680 let key_b = snapshot
5681 .entities
5682 .iter()
5683 .find(|entity| &*entity.name == "b")
5684 .expect("entity b present")
5685 .key
5686 .clone();
5687
5688 core.hold_phase_c_for_test(&key_a);
5689 core.hold_phase_c_for_test(&key_b);
5690 core.refresh(&[key_a.clone(), key_b.clone()]);
5691 core.wait_dispatched_for_test();
5695 assert_eq!(
5696 core.settle_gate_count_for_test(),
5697 2,
5698 "dispatching two entities must add exactly two to the settle gate"
5699 );
5700
5701 core.wait_phase_c_landed_for_test(&key_a);
5702 core.wait_phase_c_landed_for_test(&key_b);
5703 assert_eq!(
5704 core.settle_gate_count_for_test(),
5705 2,
5706 "the cheap apply must never touch the settle gate: both entities' cheap \
5707 outcomes have landed and neither has finished phase C yet"
5708 );
5709
5710 core.release_phase_c_for_test(&key_a);
5711 core.wait_phase_c_finished_for_test(&key_a);
5712 assert_eq!(
5713 core.settle_gate_count_for_test(),
5714 1,
5715 "exactly one entity finished, so the gate must fall by exactly one, not two \
5716 (double-counted) and not zero (left short)"
5717 );
5718
5719 core.release_phase_c_for_test(&key_b);
5720 core.wait_phase_c_finished_for_test(&key_b);
5721 assert_eq!(
5722 core.settle_gate_count_for_test(),
5723 0,
5724 "both entities finished, so the gate must be fully drained"
5725 );
5726 }
5727
5728 fn registered_gate(core: &Core, key: &EntityKey) -> PhaseCGateHandle {
5731 core.phase_c_gates
5732 .lock()
5733 .unwrap()
5734 .get(key)
5735 .cloned()
5736 .expect("hold_phase_c_for_test must be called before reading its gate")
5737 }
5738
5739 fn release_gate(gate: &PhaseCGateHandle) {
5742 let (lock, cvar) = &**gate;
5743 lock.lock().unwrap().may_proceed = true;
5744 cvar.notify_all();
5745 }
5746
5747 fn gate_is_finished(gate: &PhaseCGateHandle) -> bool {
5748 gate.0.lock().unwrap().finished
5749 }
5750
5751 #[test]
5763 fn a_probe_signals_the_phase_c_gate_its_own_generation_was_dispatched_against() {
5764 let dir = tempfile::tempdir().expect("temp dir");
5765 let root = root_of(&dir);
5766 init_repo_with_a_commit(&root.join("repo"));
5767
5768 let (core, launched) = started_and_settled(spec(vec![root]));
5769 let key = launched.entities[0].key.clone();
5770
5771 core.hold_phase_c_for_test(&key);
5772 let dispatched_against = registered_gate(&core, &key);
5773 core.refresh(std::slice::from_ref(&key));
5774 core.wait_phase_c_landed_for_test(&key);
5775
5776 core.hold_phase_c_for_test(&key);
5777 let registered_later = registered_gate(&core, &key);
5778 release_gate(&dispatched_against);
5779
5780 wait_for(
5781 "the held probe to signal the gate its own Generation was dispatched against",
5782 || gate_is_finished(&dispatched_against),
5783 );
5784 assert!(
5785 !gate_is_finished(®istered_later),
5786 "a gate registered after this Generation dispatched must never be marked \
5787 finished by it: a test waiting on that gate would return before this \
5788 Generation had applied its outcome or decremented the settle gate"
5789 );
5790 }
5791
5792 #[test]
5804 fn a_probe_finishing_clears_only_its_own_generations_in_flight_entry() {
5805 let dir = tempfile::tempdir().expect("temp dir");
5806 let root = root_of(&dir);
5807 init_repo_with_a_commit(&root.join("repo"));
5808
5809 let (core, launched) = started_and_settled(spec(vec![root]));
5810 let key = launched.entities[0].key.clone();
5811
5812 core.hold_phase_c_for_test(&key);
5813 core.refresh(std::slice::from_ref(&key));
5814 core.wait_phase_c_landed_for_test(&key);
5815
5816 let superseding = core.begin_shared_generation_for_test(std::slice::from_ref(&key));
5819
5820 core.release_phase_c_for_test(&key);
5821 core.wait_phase_c_finished_for_test(&key);
5822
5823 core.refresh(std::slice::from_ref(&key));
5824 core.wait_dispatched_for_test();
5825
5826 assert!(
5827 superseding.cancels[&key].load(Ordering::Acquire),
5828 "a probe from a Generation that has already been superseded must leave the \
5829 live Generation's in-flight entry alone, or the Generation after it has \
5830 nothing to interrupt"
5831 );
5832 }
5833
5834 #[test]
5849 fn refresh_dispatches_phase_c_in_exactly_the_order_it_is_given() {
5850 let dir = tempfile::tempdir().expect("temp dir");
5851 let root = root_of(&dir);
5852 const ENTITY_COUNT: usize = 6;
5853 for index in 0..ENTITY_COUNT {
5854 init_repo_with_a_commit(&root.join(format!("repo-{index}")));
5855 }
5856
5857 let (core, launched) = started_and_settled(spec(vec![root]));
5858 let discovery_order: Vec<EntityKey> = launched
5859 .entities
5860 .iter()
5861 .map(|entity| entity.key.clone())
5862 .collect();
5863 assert_eq!(
5864 discovery_order.len(),
5865 ENTITY_COUNT,
5866 "expected every repo discovered"
5867 );
5868
5869 let cursor = discovery_order[3].clone();
5873 let visible = [discovery_order[1].clone(), discovery_order[4].clone()];
5874 let mut three_tier_order = vec![cursor.clone()];
5875 three_tier_order.extend(visible.iter().cloned());
5876 for key in &discovery_order {
5877 if *key != cursor && !visible.contains(key) {
5878 three_tier_order.push(key.clone());
5879 }
5880 }
5881 assert_eq!(
5882 three_tier_order.len(),
5883 ENTITY_COUNT,
5884 "sanity check: the hand-built order must cover every discovered entity exactly \
5885 once"
5886 );
5887
5888 core.refresh(&three_tier_order);
5889 core.settle();
5890
5891 assert_eq!(
5892 core.dispatch_log_for_test(),
5893 three_tier_order,
5894 "refresh must dispatch phase C in exactly the order it was given: the cursor \
5895 row, then the visible rows, then the rest in discovery order"
5896 );
5897 }
5898
5899 #[test]
5904 fn refresh_reuses_the_cached_repository_handle_rather_than_reopening_it() {
5905 let dir = tempfile::tempdir().expect("temp dir");
5906 let root = root_of(&dir);
5907 let repo = root.join("repo");
5908 init_repo_with_a_commit(&repo);
5909
5910 let core = Core::start_discovered(spec(vec![root]));
5911 let key = core.snapshot().entities[0].key.clone();
5912 let before = core
5913 .cached_repo_handle_for_test(&key)
5914 .expect("discovery should have cached a handle");
5915
5916 core.refresh(std::slice::from_ref(&key));
5917 core.settle();
5918
5919 let after = core
5920 .cached_repo_handle_for_test(&key)
5921 .expect("the cached handle should still be there after a refresh");
5922 assert!(
5923 Arc::ptr_eq(&before, &after),
5924 "a refresh must reuse the cached handle, not replace it with a new one"
5925 );
5926 }
5927
5928 #[test]
5934 fn refresh_running_reads_true_the_instant_refresh_returns_and_false_once_it_settles() {
5935 let dir = tempfile::tempdir().expect("temp dir");
5936 let root = root_of(&dir);
5937 init_repo_with_a_commit(&root.join("repo"));
5938
5939 let core = Core::start_discovered(spec(vec![root]));
5940 core.settle();
5941 assert!(
5942 !core.refresh_running(),
5943 "sanity: nothing outstanding once startup has settled"
5944 );
5945
5946 let keys: Vec<EntityKey> = core
5947 .snapshot()
5948 .entities
5949 .iter()
5950 .map(|entity| entity.key.clone())
5951 .collect();
5952 core.refresh(&keys);
5953 assert!(
5954 core.refresh_running(),
5955 "refresh reserves its Generation and records the dispatch debt before it \
5956 returns, so this must already read true"
5957 );
5958
5959 core.settle();
5960 assert!(
5961 !core.refresh_running(),
5962 "settle blocks until nothing is outstanding, so this must read false once it \
5963 returns"
5964 );
5965 }
5966
5967 #[test]
5971 fn probing_a_key_with_no_cached_handle_still_opens_the_repository_itself() {
5972 let dir = tempfile::tempdir().expect("temp dir");
5973 let root = root_of(&dir);
5974 let repo = root.join("repo");
5975 init_repo_with_a_commit(&repo);
5976
5977 let empty_root = root_of(&tempfile::tempdir().expect("temp dir"));
5979 let core = Core::start_discovered(spec(vec![empty_root]));
5980 let key = EntityKey::new(Arc::from(repo.as_path()));
5981 assert!(core.cached_repo_handle_for_test(&key).is_none());
5982
5983 let entity = core.probe_now(&key);
5984
5985 assert!(matches!(
5986 entity.branch.settled(),
5987 Some(Settled::Known {
5988 value: Head::Branch { .. },
5989 at: _,
5990 stale: _
5991 })
5992 ));
5993 }
5994
5995 #[test]
5999 fn an_empty_order_dispatches_nothing_and_settle_returns_immediately() {
6000 let dir = tempfile::tempdir().expect("temp dir");
6001 let root = root_of(&dir);
6002 let repo = root.join("repo");
6003 init_repo_with_a_commit(&repo);
6004
6005 let (core, _launched) = started_and_settled(spec(vec![root]));
6006 assert!(
6007 !core.dispatch_log_for_test().is_empty(),
6008 "launch dispatched nothing, so an empty log below would say nothing about the \
6009 empty order"
6010 );
6011
6012 core.refresh(&[]);
6013 core.wait_dispatched_for_test();
6014
6015 assert_eq!(
6016 core.dispatch_log_for_test(),
6017 Vec::new(),
6018 "an empty order must dispatch no probe"
6019 );
6020 let settled = core
6024 .try_settle(Duration::from_millis(50))
6025 .expect("an empty order raises no probe, so the settle gate is already at zero");
6026 assert!(!settled.entities[0].branch.is_in_flight());
6027 }
6028
6029 fn one_probe_owed_that_never_lands(
6037 dir: &tempfile::TempDir,
6038 ) -> (Core, crossbeam_channel::Sender<Instant>) {
6039 let root = root_of(dir);
6040 init_repo_with_a_commit(&root.join("repo"));
6041 let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
6042 let core = Core::start_for_test(spec(vec![root]), Duration::from_secs(3600), tick_rx)
6043 .discovered()
6044 .core;
6045 let key = settle_launch(&core).entities[0].key.clone();
6046 core.begin_untracked_probe_for_test(&key);
6047 (core, tick_tx)
6048 }
6049
6050 #[test]
6058 #[should_panic(expected = "waiting for everything this Core has in flight to land")]
6059 fn a_settle_that_expires_reports_at_the_wait_rather_than_returning_the_table() {
6060 let dir = tempfile::tempdir().expect("temp dir");
6061 let (core, _tick_tx) = one_probe_owed_that_never_lands(&dir);
6062
6063 core.settle_within(Duration::from_millis(20));
6064 }
6065
6066 #[test]
6070 fn try_settle_hands_an_expiry_back_as_an_error_carrying_the_table_it_gave_up_on() {
6071 let dir = tempfile::tempdir().expect("temp dir");
6072 let (core, _tick_tx) = one_probe_owed_that_never_lands(&dir);
6073
6074 let unsettled = core
6075 .try_settle(Duration::from_millis(20))
6076 .expect_err("a probe nothing will ever complete cannot settle");
6077
6078 assert!(
6079 unsettled.entities[0].branch.is_in_flight(),
6080 "the Err arm must still carry the table as it stood, so a caller that degrades \
6081 deliberately has something to degrade with"
6082 );
6083 }
6084
6085 #[test]
6088 fn try_settle_hands_a_generation_that_really_landed_back_as_ok() {
6089 let dir = tempfile::tempdir().expect("temp dir");
6090 let root = root_of(&dir);
6091 init_repo_with_a_commit(&root.join("repo"));
6092
6093 let (core, launched) = started_and_settled(spec(vec![root]));
6094 let key = launched.entities[0].key.clone();
6095 core.refresh(std::slice::from_ref(&key));
6096
6097 let settled = core
6098 .try_settle(BACKSTOP)
6099 .expect("a dispatched Generation must land inside the backstop");
6100
6101 assert!(!settled.entities[0].branch.is_in_flight());
6102 }
6103
6104 #[test]
6108 fn probe_now_settles_the_sync_cell_as_well_as_the_branch_it_depends_on() {
6109 let dir = tempfile::tempdir().expect("temp dir");
6110 let root = root_of(&dir);
6111 let repo = root.join("repo");
6112 init_repo_with_a_commit(&repo);
6113
6114 let core = Core::start_discovered(spec(vec![root]));
6115 let key = core.snapshot().entities[0].key.clone();
6116
6117 let entity = core.probe_now(&key);
6118
6119 assert!(
6120 matches!(
6121 entity.sync.settled(),
6122 Some(Settled::Known {
6123 value: SyncState::NoRemote,
6124 at: _,
6125 stale: _
6126 })
6127 ),
6128 "expected probe_now to settle sync, got {:?}",
6129 entity.sync.settled()
6130 );
6131 }
6132
6133 #[test]
6136 fn probe_now_settles_the_base_cell_as_well_as_the_branch_it_depends_on() {
6137 let dir = tempfile::tempdir().expect("temp dir");
6138 let root = root_of(&dir);
6139 let repo = root.join("repo");
6140 init_repo_with_a_commit(&repo);
6141
6142 let core = Core::start_discovered(spec(vec![root]));
6143 let key = core.snapshot().entities[0].key.clone();
6144
6145 let entity = core.probe_now(&key);
6146
6147 assert!(
6148 matches!(entity.base.settled(), Some(Settled::NotApplicable)),
6149 "expected probe_now to settle base Not applicable for a Repo with no remote, \
6150 got {:?}",
6151 entity.base.settled()
6152 );
6153 }
6154
6155 #[test]
6159 fn refresh_settles_a_real_base_count_against_the_resolved_default_branch() {
6160 let dir = tempfile::tempdir().expect("temp dir");
6161 let root = root_of(&dir);
6162 let repo = root.join("repo");
6163 init_repo_with_a_commit(&repo);
6164 git(
6165 &repo,
6166 &[
6167 "remote",
6168 "add",
6169 "origin",
6170 "https://example.invalid/repo.git",
6171 ],
6172 );
6173 let root_sha = head_sha(&repo);
6174 git(&repo, &["commit", "--allow-empty", "-m", "second"]);
6180 let tip_sha = head_sha(&repo);
6181 git(&repo, &["reset", "--hard", &root_sha]);
6182 git(&repo, &["update-ref", "refs/remotes/origin/main", &tip_sha]);
6183
6184 let core = Core::start_discovered(spec(vec![root]));
6185 let key = core.snapshot().entities[0].key.clone();
6186
6187 core.refresh(std::slice::from_ref(&key));
6188 let settled = core.settle();
6189
6190 assert!(
6191 matches!(
6192 settled.entities[0].base.settled(),
6193 Some(Settled::Known {
6194 value: 1,
6195 at: _,
6196 stale: _
6197 })
6198 ),
6199 "expected a real refresh to settle base's live count against the resolved \
6200 default branch, got {:?}",
6201 settled.entities[0].base.settled()
6202 );
6203 }
6204
6205 #[test]
6211 fn probe_now_settles_the_dirty_cell_with_the_counts_it_probed() {
6212 let dir = tempfile::tempdir().expect("temp dir");
6213 let root = root_of(&dir);
6214 let repo = root.join("repo");
6215 init_repo_with_a_commit(&repo);
6216 fs::write(repo.join("untracked.txt"), "x").expect("write untracked file");
6217
6218 let core = Core::start_discovered(spec(vec![root]));
6219 let key = core.snapshot().entities[0].key.clone();
6220
6221 let entity = core.probe_now(&key);
6222
6223 assert!(
6224 matches!(
6225 entity.dirty.settled(),
6226 Some(Settled::Known {
6227 value: DirtyCounts {
6228 modified: 0,
6229 untracked: 1,
6230 deleted: 0,
6231 },
6232 at: _,
6233 stale: _
6234 })
6235 ),
6236 "expected probe_now to settle dirty with the one untracked path, got {:?}",
6237 entity.dirty.settled()
6238 );
6239 }
6240
6241 #[test]
6242 fn probe_now_updates_the_entity_synchronously_with_no_refresh_call() {
6243 let dir = tempfile::tempdir().expect("temp dir");
6244 let root = root_of(&dir);
6245 let repo = root.join("repo");
6246 init_repo_with_a_commit(&repo);
6247
6248 let core = Core::start_discovered(spec(vec![root]));
6249 let key = core.snapshot().entities[0].key.clone();
6250
6251 let entity = core.probe_now(&key);
6252
6253 assert!(matches!(
6254 entity.branch.settled(),
6255 Some(Settled::Known {
6256 value: Head::Branch { .. },
6257 at: _,
6258 stale: _
6259 })
6260 ));
6261 }
6262
6263 #[test]
6269 fn the_display_name_agrees_between_discovery_and_probe_nows_fallback_insert() {
6270 let dir = tempfile::tempdir().expect("temp dir");
6271 let root = root_of(&dir);
6272 let repo = root.join("named-repo");
6273 init_repo_with_a_commit(&repo);
6274
6275 let core = Core::start_discovered(spec(vec![root]));
6276 let discovered = core.snapshot().entities[0].clone();
6277 assert_eq!(&*discovered.name, "named-repo");
6278
6279 core.dismiss(&discovered.key);
6280 assert!(core.snapshot().entities.is_empty());
6281
6282 let reinserted = core.probe_now(&discovered.key);
6283
6284 assert_eq!(
6285 reinserted.name, discovered.name,
6286 "the name discovery assigned and the name probe_now's fallback insert \
6287 assigns for the same path must be byte-identical"
6288 );
6289 }
6290
6291 #[test]
6292 fn dismiss_removes_the_entity_from_the_snapshot() {
6293 let dir = tempfile::tempdir().expect("temp dir");
6294 let root = root_of(&dir);
6295 let repo = root.join("repo");
6296 init_repo_with_a_commit(&repo);
6297
6298 let core = Core::start_discovered(spec(vec![root]));
6299 let key = core.snapshot().entities[0].key.clone();
6300
6301 core.dismiss(&key);
6302
6303 assert!(core.snapshot().entities.is_empty());
6304 }
6305
6306 #[test]
6319 fn an_entitys_steps_run_in_order_and_a_failure_marks_every_later_step_not_run() {
6320 let dir = tempfile::tempdir().expect("temp dir");
6321 let root = root_of(&dir);
6322 let repo = root.join("repo");
6323 init_repo_with_a_commit(&repo);
6324 let marker = repo.join("step-three-ran");
6325
6326 let core = Core::start_discovered(spec(vec![root]));
6327 let key = core.snapshot().entities[0].key.clone();
6328 let steps = vec![
6329 step(&["true"]),
6330 step(&["sh", "-c", "exit 7"]),
6331 step(&["touch", "step-three-ran"]),
6332 ];
6333
6334 let started = core.run_action(action("reinstall", steps), std::slice::from_ref(&key));
6335
6336 assert!(started);
6337 wait_for("the fan-out to finish and write a receipt", || {
6338 !core.action_running()
6339 });
6340 let receipt = core.snapshot().entities[0]
6341 .last_action
6342 .clone()
6343 .expect("receipt written");
6344 assert_eq!(receipt.steps.len(), 3);
6345 assert_eq!(receipt.steps[0].outcome, StepOutcome::Ok);
6346 assert_eq!(receipt.steps[1].outcome, StepOutcome::Failed(7));
6347 assert_eq!(
6348 receipt.steps[2].outcome,
6349 StepOutcome::NotRun,
6350 "a step after a failure must be recorded NotRun, not silently dropped or run anyway"
6351 );
6352 assert!(
6353 !marker.exists(),
6354 "the third step's own `touch` must never have run: its marker file exists, so \
6355 the step ran despite being recorded NotRun"
6356 );
6357 }
6358
6359 #[test]
6364 fn steps_run_in_the_order_theyre_declared_not_some_other_order() {
6365 let dir = tempfile::tempdir().expect("temp dir");
6366 let root = root_of(&dir);
6367 let repo = root.join("repo");
6368 init_repo_with_a_commit(&repo);
6369 let order_log = repo.join("order.log");
6370
6371 let core = Core::start_discovered(spec(vec![root]));
6372 let key = core.snapshot().entities[0].key.clone();
6373 let steps = vec![
6374 step(&["sh", "-c", "printf 1 >> order.log"]),
6375 step(&["sh", "-c", "printf 2 >> order.log"]),
6376 step(&["sh", "-c", "printf 3 >> order.log"]),
6377 ];
6378
6379 let started = core.run_action(action("ordering", steps), std::slice::from_ref(&key));
6380
6381 assert!(started);
6382 wait_for("the fan-out to finish and write a receipt", || {
6383 !core.action_running()
6384 });
6385 let receipt = core.snapshot().entities[0]
6386 .last_action
6387 .clone()
6388 .expect("receipt written");
6389 assert_eq!(receipt.steps.len(), 3);
6390 assert!(
6391 receipt
6392 .steps
6393 .iter()
6394 .all(|result| result.outcome == StepOutcome::Ok),
6395 "every step here always exits zero; this test isolates ordering from gating"
6396 );
6397 let content = fs::read_to_string(&order_log).expect("order.log written by the steps");
6398 assert_eq!(
6399 content, "123",
6400 "the file's content pins actual execution order; running the steps out of \
6401 declaration order would produce a different digit sequence here even though \
6402 every step still succeeds"
6403 );
6404 }
6405
6406 #[test]
6414 fn a_still_running_actions_finished_step_and_its_currently_executing_one_are_both_visible_before_the_whole_run_ends()
6415 {
6416 let dir = tempfile::tempdir().expect("temp dir");
6417 let root = root_of(&dir);
6418 let repo = root.join("repo");
6419 init_repo_with_a_commit(&repo);
6420
6421 let core = Core::start_discovered(spec(vec![root]));
6422 let key = core.snapshot().entities[0].key.clone();
6423 let steps = vec![step(&["true"]), step(&["sh", "-c", "sleep 0.5"])];
6424
6425 let started = core.run_action(action("reinstall", steps), std::slice::from_ref(&key));
6426 assert!(started);
6427
6428 wait_for(
6433 "a receipt naming the second step running before the run finished",
6434 || {
6435 core.snapshot().entities[0]
6436 .last_action
6437 .as_ref()
6438 .and_then(|receipt| receipt.running.as_ref())
6439 .is_some_and(|running| running.label.contains("sleep"))
6440 },
6441 );
6442 let mid_run = core.snapshot().entities[0]
6443 .last_action
6444 .clone()
6445 .expect("receipt written");
6446 assert_eq!(
6447 mid_run.steps.len(),
6448 1,
6449 "the first, already-finished step must already be in `steps`"
6450 );
6451 assert_eq!(mid_run.steps[0].outcome, StepOutcome::Ok);
6452 let running = mid_run.running.expect("a step must be recorded running");
6453 assert!(
6454 running.label.contains("sleep"),
6455 "expected the running step's own label, got {:?}",
6456 running.label
6457 );
6458
6459 wait_for("the fan-out to finish", || !core.action_running());
6460 let finished = core.snapshot().entities[0]
6461 .last_action
6462 .clone()
6463 .expect("receipt written");
6464 assert!(
6465 finished.running.is_none(),
6466 "a finished receipt must carry no running step"
6467 );
6468 assert_eq!(finished.steps.len(), 2);
6469 }
6470
6471 #[test]
6480 fn a_shell_true_step_runs_through_shell_c_with_repon_as_its_own_dollar_zero() {
6481 let dir = tempfile::tempdir().expect("temp dir");
6482 let root = root_of(&dir);
6483 let repo = root.join("repo");
6484 init_repo_with_a_commit(&repo);
6485
6486 let core = Core::start_discovered(spec(vec![root]));
6487 let key = core.snapshot().entities[0].key.clone();
6488 let steps = vec![shell_step("echo \"[$0]\"")];
6489
6490 let started = core.run_action(action("shell-step", steps), std::slice::from_ref(&key));
6491
6492 assert!(started);
6493 wait_for("the fan-out to finish and write a receipt", || {
6494 !core.action_running()
6495 });
6496 let receipt = core.snapshot().entities[0]
6497 .last_action
6498 .clone()
6499 .expect("receipt written");
6500 assert_eq!(receipt.steps.len(), 1);
6501 assert_eq!(receipt.steps[0].outcome, StepOutcome::Ok);
6502 assert_eq!(&*receipt.steps[0].output, b"[repon]\n");
6503 assert!(
6504 receipt.steps[0].shell,
6505 "the receipt's own StepResult::shell must carry the mode the step ran under"
6506 );
6507 }
6508
6509 #[test]
6516 fn an_interactive_shell_true_step_runs_through_run_action_with_interactive_on_its_receipt() {
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 key = core.snapshot().entities[0].key.clone();
6524 let steps = vec![interactive_shell_step("true")];
6525
6526 let started = core.run_action(
6527 action("interactive-step", steps),
6528 std::slice::from_ref(&key),
6529 );
6530
6531 assert!(started);
6532 wait_for("the fan-out to finish and write a receipt", || {
6533 !core.action_running()
6534 });
6535 let receipt = core.snapshot().entities[0]
6536 .last_action
6537 .clone()
6538 .expect("receipt written");
6539 assert_eq!(receipt.steps.len(), 1);
6540 assert_eq!(receipt.steps[0].outcome, StepOutcome::Ok);
6541 assert!(
6542 receipt.steps[0].shell,
6543 "an interactive step is still a shell step"
6544 );
6545 assert!(
6546 receipt.steps[0].interactive,
6547 "the receipt's own StepResult::interactive must carry the mode the step ran under"
6548 );
6549 }
6550
6551 #[test]
6555 fn an_argv_step_runs_through_run_action_with_shell_false_on_its_receipt() {
6556 let dir = tempfile::tempdir().expect("temp dir");
6557 let root = root_of(&dir);
6558 let repo = root.join("repo");
6559 init_repo_with_a_commit(&repo);
6560
6561 let core = Core::start_discovered(spec(vec![root]));
6562 let key = core.snapshot().entities[0].key.clone();
6563 let steps = vec![Step {
6564 argv: vec!["true".to_string()],
6565 shell: false,
6566 interactive: false,
6567 env: Vec::new(),
6568 }];
6569
6570 let started = core.run_action(action("argv-step", steps), std::slice::from_ref(&key));
6571
6572 assert!(started);
6573 wait_for("the fan-out to finish and write a receipt", || {
6574 !core.action_running()
6575 });
6576 let receipt = core.snapshot().entities[0]
6577 .last_action
6578 .clone()
6579 .expect("receipt written");
6580 assert!(!receipt.steps[0].shell);
6581 }
6582
6583 #[test]
6589 fn starting_an_action_cancels_any_generation_already_in_flight() {
6590 let dir = tempfile::tempdir().expect("temp dir");
6591 let root = root_of(&dir);
6592 let repo = root.join("repo");
6593 init_repo_with_a_commit(&repo);
6594
6595 let core = Core::start_discovered(spec(vec![root]));
6596 let key = core.snapshot().entities[0].key.clone();
6597 let in_flight = core.begin_shared_generation_for_test(std::slice::from_ref(&key));
6598 let cancel = in_flight
6599 .cancels
6600 .get(&key)
6601 .expect("the in-flight entity has a cancel flag")
6602 .clone();
6603 assert!(!cancel.load(Ordering::Acquire));
6604
6605 let started = core.run_action(
6606 action("reinstall", vec![step(&["true"])]),
6607 std::slice::from_ref(&key),
6608 );
6609
6610 assert!(started);
6611 assert!(
6612 cancel.load(Ordering::Acquire),
6613 "starting an Action must cancel a Generation already in flight, not share \
6614 execution with it"
6615 );
6616 wait_for("the fan-out and its completion refresh to drain", || {
6619 !core.action_running()
6620 });
6621 }
6622
6623 #[test]
6633 fn a_finished_action_starts_exactly_one_generation_over_every_known_entity() {
6634 let dir = tempfile::tempdir().expect("temp dir");
6635 let root = root_of(&dir);
6636 let acted_on = root.join("acted-on");
6637 let untouched = root.join("untouched");
6638 init_repo_with_a_commit(&acted_on);
6639 init_repo_with_a_commit(&untouched);
6640
6641 let (core, before) = started_and_settled(spec(vec![root]));
6642 let acted_key = before
6643 .entities
6644 .iter()
6645 .find(|entity| entity.key.path() == acted_on)
6646 .expect("the acted-on entity is discovered")
6647 .key
6648 .clone();
6649
6650 let started = core.run_action(
6651 action("reinstall", vec![step(&["true"])]),
6652 std::slice::from_ref(&acted_key),
6653 );
6654
6655 assert!(started);
6656 wait_for(
6657 "the completion Generation to probe every known entity, including the one the \
6658 Action never touched",
6659 || {
6660 let snapshot = core.snapshot();
6661 snapshot.generation != before.generation
6662 && snapshot.entities.iter().all(|entity| {
6663 matches!(
6664 entity.branch.settled(),
6665 Some(Settled::Known {
6666 value: _,
6667 at: _,
6668 stale: _
6669 })
6670 )
6671 })
6672 },
6673 );
6674 assert_eq!(
6675 core.settle().generation,
6676 before.generation.successor(),
6677 "completion must start exactly one Generation: not zero (no refresh at all) and \
6678 not two (a double refresh)"
6679 );
6680 }
6681
6682 #[test]
6692 fn a_completion_dispatches_its_generation_before_releasing_its_run() {
6693 let dir = tempfile::tempdir().expect("temp dir");
6694 let root = root_of(&dir);
6695 let repo = root.join("repo");
6696 init_repo_with_a_commit(&repo);
6697
6698 let (core, before) = started_and_settled(spec(vec![root]));
6699 let key = before.entities[0].key.clone();
6700 let armed = core.action_completion_boundary().arm();
6701
6702 assert!(core.run_action(
6703 action("finishing", vec![step(&["true"])]),
6704 std::slice::from_ref(&key)
6705 ));
6706 armed.wait_until_reached();
6707
6708 assert_eq!(
6709 core.snapshot().generation,
6710 before.generation.successor(),
6711 "the completion Generation must be dispatched before the run releases its \
6712 admission"
6713 );
6714 assert!(
6715 !core.run_action(
6716 action("racing", vec![step(&["true"])]),
6717 std::slice::from_ref(&key)
6718 ),
6719 "a submission before that release must be refused, so what a run cancels on the \
6720 way in is never a Generation the run it replaced has yet to dispatch"
6721 );
6722
6723 drop(armed);
6724 wait_for("the finished run to release its admission", || {
6725 !core.action_running()
6726 });
6727 }
6728
6729 #[test]
6734 fn an_excluded_row_swept_into_an_action_gets_a_not_applicable_receipt_and_no_other_path_does() {
6735 let dir = tempfile::tempdir().expect("temp dir");
6736 let root = root_of(&dir);
6737 let excluded_repo = root.join("excluded");
6738 let normal_repo = root.join("normal");
6739 init_repo_with_a_commit(&excluded_repo);
6740 init_repo_with_a_commit(&normal_repo);
6741
6742 let core = Core::start_discovered(spec_with_overrides(
6743 vec![root],
6744 vec![RepoOverride {
6745 path: excluded_repo.clone(),
6746 default_branch: None,
6747 excluded: true,
6748 }],
6749 ));
6750 let snapshot = core.snapshot();
6751 let find = |path: &Path| {
6752 snapshot
6753 .entities
6754 .iter()
6755 .find(|entity| entity.key.path() == path)
6756 .unwrap_or_else(|| panic!("entity at {path:?} present"))
6757 .key
6758 .clone()
6759 };
6760 let excluded_key = find(&excluded_repo);
6761 let normal_key = find(&normal_repo);
6762 assert!(
6763 snapshot
6764 .entities
6765 .iter()
6766 .find(|entity| entity.key == excluded_key)
6767 .unwrap()
6768 .excluded
6769 );
6770
6771 let started = core.run_action(
6772 action("reinstall", vec![step(&["sh", "-c", "exit 3"])]),
6773 &[excluded_key.clone(), normal_key.clone()],
6774 );
6775
6776 assert!(started);
6777 wait_for("the fan-out to finish", || !core.action_running());
6783
6784 let after = core.snapshot();
6785 let receipt_of = |key: &EntityKey| {
6786 after
6787 .entities
6788 .iter()
6789 .find(|entity| entity.key == *key)
6790 .unwrap()
6791 .last_action
6792 .clone()
6793 .unwrap()
6794 };
6795 let excluded_receipt = receipt_of(&excluded_key);
6796 assert!(excluded_receipt.not_applicable());
6797 assert!(excluded_receipt.steps.is_empty());
6798
6799 let normal_receipt = receipt_of(&normal_key);
6800 assert!(
6801 !normal_receipt.not_applicable(),
6802 "a row that actually ran a step, even a failing one, must never read as \
6803 not_applicable: an excluded row is the one legitimate producer of that outcome"
6804 );
6805 assert!(!normal_receipt.steps.is_empty());
6806 assert!(normal_receipt.failed());
6807 }
6808
6809 #[test]
6816 fn operable_count_matches_how_many_entities_run_action_actually_runs_a_step_against() {
6817 let dir = tempfile::tempdir().expect("temp dir");
6818 let root = root_of(&dir);
6819 let excluded_repo = root.join("excluded");
6820 let normal_repo = root.join("normal");
6821 init_repo_with_a_commit(&excluded_repo);
6822 init_repo_with_a_commit(&normal_repo);
6823
6824 let core = Core::start_discovered(spec_with_overrides(
6825 vec![root],
6826 vec![RepoOverride {
6827 path: excluded_repo.clone(),
6828 default_branch: None,
6829 excluded: true,
6830 }],
6831 ));
6832 let snapshot = core.snapshot();
6833 let find = |path: &Path| {
6834 snapshot
6835 .entities
6836 .iter()
6837 .find(|entity| entity.key.path() == path)
6838 .unwrap_or_else(|| panic!("entity at {path:?} present"))
6839 .key
6840 .clone()
6841 };
6842 let order = [find(&excluded_repo), find(&normal_repo)];
6843
6844 assert_eq!(
6845 core.operable_count(&order),
6846 1,
6847 "one of the two rows is excluded, so exactly one is operable"
6848 );
6849
6850 let started = core.run_action(action("reinstall", vec![step(&["true"])]), &order);
6851 assert!(started);
6852
6853 wait_for("every entity in the order to carry a receipt", || {
6854 let snapshot = core.snapshot();
6855 order.iter().all(|key| {
6856 snapshot
6857 .entities
6858 .iter()
6859 .find(|entity| entity.key == *key)
6860 .and_then(|entity| entity.last_action.as_ref())
6861 .is_some()
6862 })
6863 });
6864
6865 let after = core.snapshot();
6866 let actually_ran = after
6867 .entities
6868 .iter()
6869 .filter(|entity| order.contains(&entity.key))
6870 .filter(|entity| {
6871 entity
6872 .last_action
6873 .as_ref()
6874 .is_some_and(|receipt| !receipt.not_applicable())
6875 })
6876 .count();
6877
6878 assert_eq!(
6879 core.operable_count(&order),
6880 actually_ran,
6881 "operable_count must report exactly how many rows run_action actually ran a \
6882 step against, not merely how many keys resolved"
6883 );
6884 }
6885
6886 #[test]
6891 fn run_action_for_entity_blocking_returns_the_finished_receipt_on_the_calling_thread() {
6892 let dir = tempfile::tempdir().expect("temp dir");
6893 let root = root_of(&dir);
6894 let repo = root.join("repo");
6895 init_repo_with_a_commit(&repo);
6896 let marker = repo.join("hook-ran");
6897
6898 let core = Core::start_discovered(spec_with_overrides(vec![root], Vec::new()));
6899 let key = core
6900 .snapshot()
6901 .entities
6902 .iter()
6903 .find(|entity| entity.key.path() == repo)
6904 .expect("the repo is discovered")
6905 .key
6906 .clone();
6907
6908 let receipt = core
6909 .run_action_for_entity_blocking(
6910 &action("hook", vec![step(&["touch", "hook-ran"])]),
6911 &key,
6912 )
6913 .expect("the entity is known");
6914
6915 assert!(
6916 marker.exists(),
6917 "the step must have already run by the time this call returns"
6918 );
6919 assert_eq!(receipt.steps.len(), 1);
6920 assert_eq!(receipt.steps[0].outcome, StepOutcome::Ok);
6921 }
6922
6923 fn step_that_cannot_prepare(argv: &[&str], resource: &str) -> Step {
6927 Step {
6928 env: vec![(
6929 executor::SETUP_FAILURE_VARIABLE.to_string(),
6930 resource.to_string(),
6931 )],
6932 ..step(argv)
6933 }
6934 }
6935
6936 #[test]
6942 fn a_step_whose_pty_setup_fails_finishes_the_run_and_leaves_a_later_action_working() {
6943 let dir = tempfile::tempdir().expect("temp dir");
6944 let root = root_of(&dir);
6945 let repo = root.join("repo");
6946 init_repo_with_a_commit(&repo);
6947
6948 let core = Core::start_discovered(spec_with_overrides(vec![root], Vec::new()));
6949 let key = core
6950 .snapshot()
6951 .entities
6952 .iter()
6953 .find(|entity| entity.key.path() == repo)
6954 .expect("the repo is discovered")
6955 .key
6956 .clone();
6957
6958 let (tx, rx) = mpsc::channel();
6959 thread::spawn(move || {
6960 let faulted = core.run_action_for_entity_blocking(
6961 &action(
6962 "hook",
6963 vec![
6964 step_that_cannot_prepare(&["touch", "first-ran"], "notify-pipe"),
6965 step(&["touch", "second-ran"]),
6966 ],
6967 ),
6968 &key,
6969 );
6970 let later = core.run_action_for_entity_blocking(
6971 &action("hook", vec![step(&["touch", "later-ran"])]),
6972 &key,
6973 );
6974 let _ = tx.send((faulted, later));
6975 });
6976 let (faulted, later) = rx
6977 .recv_timeout(BACKSTOP)
6978 .expect("a run whose first step cannot prepare its pty must still hand back receipts");
6979
6980 let faulted = faulted.expect("the entity is known");
6981 assert!(
6982 matches!(faulted.steps[0].outcome, StepOutcome::Failed(code) if code != 0),
6983 "expected the first step to fail, got {:?}",
6984 faulted.steps[0].outcome
6985 );
6986 let detail = String::from_utf8_lossy(&faulted.steps[0].output).to_string();
6987 assert!(
6988 detail.contains("pipe that notices"),
6989 "expected the receipt to name the resource that failed, got {detail:?}"
6990 );
6991 assert_eq!(faulted.steps[1].outcome, StepOutcome::NotRun);
6992 assert!(
6993 !repo.join("first-ran").exists() && !repo.join("second-ran").exists(),
6994 "a step that never prepared its pty must never have run its command"
6995 );
6996
6997 let later = later.expect("the entity is known");
6998 assert_eq!(later.steps[0].outcome, StepOutcome::Ok);
6999 assert!(
7000 repo.join("later-ran").exists(),
7001 "a later Action must still run its own command"
7002 );
7003 }
7004
7005 #[test]
7010 fn run_action_for_entity_blocking_answers_none_for_an_unknown_key() {
7011 let dir = tempfile::tempdir().expect("temp dir");
7012 let root = root_of(&dir);
7013 let core = Core::start_discovered(spec_with_overrides(vec![root.clone()], Vec::new()));
7014
7015 let unknown = EntityKey::new(Arc::from(root.join("never-discovered").as_path()));
7016
7017 assert!(
7018 core.run_action_for_entity_blocking(&action("hook", vec![step(&["true"])]), &unknown)
7019 .is_none()
7020 );
7021 }
7022
7023 #[test]
7030 fn run_action_skips_a_row_its_when_predicate_disproves_rather_than_running_it_anyway() {
7031 let dir = tempfile::tempdir().expect("temp dir");
7032 let root = root_of(&dir);
7033 let proved_repo = root.join("alpha");
7034 let disproved_repo = root.join("beta");
7035 init_repo_with_a_commit(&proved_repo);
7036 init_repo_with_a_commit(&disproved_repo);
7037
7038 let core = Core::start_discovered(spec(vec![root]));
7039 let snapshot = core.snapshot();
7040 let find = |path: &Path| {
7041 snapshot
7042 .entities
7043 .iter()
7044 .find(|entity| entity.key.path() == path)
7045 .unwrap_or_else(|| panic!("entity at {path:?} present"))
7046 .key
7047 .clone()
7048 };
7049 let proved_key = find(&proved_repo);
7050 let disproved_key = find(&disproved_repo);
7051 let order = [proved_key.clone(), disproved_key.clone()];
7052
7053 let started = core.run_action(
7056 action_with_when(
7057 "reinstall",
7058 vec![step(&["sh", "-c", "exit 3"])],
7059 "name:alpha",
7060 ),
7061 &order,
7062 );
7063 assert!(started);
7064 wait_for("the fan-out to finish", || !core.action_running());
7065
7066 let after = core.snapshot();
7067 let receipt_of = |key: &EntityKey| {
7068 after
7069 .entities
7070 .iter()
7071 .find(|entity| entity.key == *key)
7072 .unwrap()
7073 .last_action
7074 .clone()
7075 .unwrap()
7076 };
7077
7078 let proved_receipt = receipt_of(&proved_key);
7079 assert_eq!(
7080 proved_receipt.skip, None,
7081 "the row the predicate proved must actually run"
7082 );
7083 assert!(proved_receipt.failed(), "its own step still ran and failed");
7084
7085 let disproved_receipt = receipt_of(&disproved_key);
7086 assert!(
7087 disproved_receipt.inapplicable(),
7088 "the row the predicate disproved must be skipped rather than run"
7089 );
7090 assert!(disproved_receipt.steps.is_empty());
7091 assert!(
7092 !disproved_receipt.failed(),
7093 "a skipped row never ran a step, so it cannot have failed one"
7094 );
7095 }
7096
7097 #[test]
7106 fn applicability_subtracts_an_excluded_row_before_the_predicate_reads_it() {
7107 let dir = tempfile::tempdir().expect("temp dir");
7108 let root = root_of(&dir);
7109 let excluded_repo = root.join("excluded");
7110 let normal_repo = root.join("normal");
7111 init_repo_with_a_commit(&excluded_repo);
7112 init_repo_with_a_commit(&normal_repo);
7113
7114 let core = Core::start_discovered(spec_with_overrides(
7115 vec![root],
7116 vec![RepoOverride {
7117 path: excluded_repo.clone(),
7118 default_branch: None,
7119 excluded: true,
7120 }],
7121 ));
7122 let order: Vec<EntityKey> = core
7123 .snapshot()
7124 .entities
7125 .iter()
7126 .map(|entity| entity.key.clone())
7127 .collect();
7128 assert_eq!(order.len(), 2, "the fixture must discover both repos");
7129
7130 let counts = core.applicability(&order, &Filter::parse("kind:repo"));
7131
7132 assert_eq!(
7133 counts.total(),
7134 core.operable_count(&order),
7135 "the predicate must be counted over exactly the rows `operable_count` keeps"
7136 );
7137 assert_eq!(
7138 counts,
7139 Applicability {
7140 applicable: 1,
7141 inapplicable: 0,
7142 unresolved: 0,
7143 }
7144 );
7145 }
7146
7147 #[test]
7151 fn operable_count_silently_drops_a_key_that_no_longer_resolves() {
7152 let dir = tempfile::tempdir().expect("temp dir");
7153 let root = root_of(&dir);
7154 let repo = root.join("repo");
7155 init_repo_with_a_commit(&repo);
7156
7157 let core = Core::start_discovered(spec(vec![root]));
7158 let real_key = core.snapshot().entities[0].key.clone();
7159 let unknown_key = EntityKey::new(Arc::from(dir.path().join("never-discovered")));
7160
7161 assert_eq!(core.operable_count(&[real_key, unknown_key]), 1);
7162 }
7163
7164 #[test]
7168 fn only_one_action_fan_out_runs_at_a_time_a_second_call_is_rejected_while_one_is_live() {
7169 let dir = tempfile::tempdir().expect("temp dir");
7170 let root = root_of(&dir);
7171 let repo = root.join("repo");
7172 init_repo_with_a_commit(&repo);
7173
7174 let core = Core::start_discovered(spec(vec![root]));
7175 let key = core.snapshot().entities[0].key.clone();
7176 let slow = action("first", vec![step(&["sh", "-c", "sleep 0.3"])]);
7177 let fast = action("second", vec![step(&["true"])]);
7178
7179 let first_started = core.run_action(slow, std::slice::from_ref(&key));
7180 let second_started = core.run_action(fast, std::slice::from_ref(&key));
7181
7182 assert!(first_started);
7183 assert!(
7184 !second_started,
7185 "a second run_action call must be rejected while the first is still in flight"
7186 );
7187 wait_for("the accepted first fan-out to finish", || {
7188 !core.action_running()
7189 });
7190 let receipt = core.snapshot().entities[0].last_action.clone().unwrap();
7191 assert_eq!(
7192 &*receipt.label, "first",
7193 "the surviving receipt must be the accepted first run's, never the rejected second"
7194 );
7195 }
7196
7197 #[test]
7206 fn a_refused_second_submission_leaves_the_first_action_still_stoppable() {
7207 let dir = tempfile::tempdir().expect("temp dir");
7208 let root = root_of(&dir);
7209 let repo = root.join("repo");
7210 init_repo_with_a_commit(&repo);
7211
7212 let core = Core::start_discovered(spec(vec![root]));
7213 let key = core.snapshot().entities[0].key.clone();
7214 let sleep_past_the_backstop = format!("sleep {}", FIXTURE_LIFETIME.as_secs());
7215 let live = action(
7216 "live",
7217 vec![
7218 step(&["sh", "-c", &sleep_past_the_backstop]),
7219 step(&["sh", "-c", &sleep_past_the_backstop]),
7220 ],
7221 );
7222
7223 assert!(core.run_action(live, std::slice::from_ref(&key)));
7224 wait_for("the live run's own first step to start", || {
7225 core.snapshot().entities[0]
7226 .last_action
7227 .as_ref()
7228 .is_some_and(|receipt| receipt.running.is_some())
7229 });
7230
7231 assert!(
7232 !core.run_action(
7233 action("refused", vec![step(&["true"])]),
7234 std::slice::from_ref(&key)
7235 ),
7236 "a second submission must be refused while one run is still live"
7237 );
7238
7239 core.stop_action();
7240
7241 wait_for("the still-controllable run to come down", || {
7242 !core.action_running()
7243 });
7244 let receipt = core.snapshot().entities[0].last_action.clone().unwrap();
7245 assert_eq!(&*receipt.label, "live");
7246 assert_eq!(
7247 receipt.steps[0].outcome,
7248 StepOutcome::Cancelled,
7249 "the refused submission must leave the live run's own control in place, so \
7250 stop_action still reaches the step it was running"
7251 );
7252 assert_eq!(
7253 receipt.steps[1].outcome,
7254 StepOutcome::Cancelled,
7255 "a step that had not started when the run was cancelled must read Cancelled too"
7256 );
7257 }
7258
7259 #[test]
7270 fn a_run_accepted_once_a_completion_releases_its_admission_is_still_stoppable() {
7271 let dir = tempfile::tempdir().expect("temp dir");
7272 let root = root_of(&dir);
7273 let repo = root.join("repo");
7274 init_repo_with_a_commit(&repo);
7275
7276 let core = Core::start_discovered(spec(vec![root]));
7277 let key = core.snapshot().entities[0].key.clone();
7278 let armed = core.action_completion_boundary().arm();
7279
7280 assert!(core.run_action(
7281 action("finishing", vec![step(&["true"])]),
7282 std::slice::from_ref(&key)
7283 ));
7284 armed.wait_until_reached();
7285 assert!(
7286 !core.run_action(
7287 action("early", vec![step(&["true"])]),
7288 std::slice::from_ref(&key)
7289 ),
7290 "a submission made before the completion releases its admission must be refused"
7291 );
7292 drop(armed);
7293 wait_for("the finished run to release its admission", || {
7294 !core.action_running()
7295 });
7296
7297 let sleep_past_the_backstop = format!("sleep {}", FIXTURE_LIFETIME.as_secs());
7298 let following = action(
7299 "following",
7300 vec![
7301 step(&["sh", "-c", &sleep_past_the_backstop]),
7302 step(&["sh", "-c", &sleep_past_the_backstop]),
7303 ],
7304 );
7305 assert!(
7306 core.run_action(following, std::slice::from_ref(&key)),
7307 "a submission made once that release has happened must be accepted"
7308 );
7309 wait_for("the following run's own first step to start", || {
7310 receipt_labelled(&core, &key, "following")
7311 .is_some_and(|receipt| receipt.running.is_some())
7312 });
7313
7314 core.stop_action();
7315
7316 wait_for("the cancelled run to come down", || !core.action_running());
7317 let receipt =
7318 receipt_labelled(&core, &key, "following").expect("the following run's receipt");
7319 assert_eq!(
7320 receipt.steps[0].outcome,
7321 StepOutcome::Cancelled,
7322 "the completion this run followed must leave stop_action still reaching it"
7323 );
7324 assert_eq!(
7325 receipt.steps[1].outcome,
7326 StepOutcome::Cancelled,
7327 "a cancelled run's remaining step must never start, so it reads Cancelled"
7328 );
7329 }
7330
7331 #[test]
7345 fn hold_action_genuinely_pauses_a_running_steps_progress_and_continue_action_resumes_it() {
7346 let dir = tempfile::tempdir().expect("temp dir");
7347 let root = root_of(&dir);
7348 let repo = root.join("repo");
7349 init_repo_with_a_commit(&repo);
7350
7351 let core = Core::start_discovered(spec(vec![root]));
7352 let key = core.snapshot().entities[0].key.clone();
7353 let two_seconds = action("brief", vec![step(&["sh", "-c", "sleep 2"])]);
7354
7355 assert!(core.run_action(two_seconds, std::slice::from_ref(&key)));
7356 wait_for("the two-second step to actually start running", || {
7357 core.snapshot().entities[0]
7358 .last_action
7359 .as_ref()
7360 .is_some_and(|receipt| receipt.running.is_some())
7361 });
7362
7363 for _ in 0..20 {
7371 core.hold_action();
7372 thread::sleep(Duration::from_millis(20));
7373 }
7374
7375 thread::sleep(Duration::from_millis(1_800));
7376 assert!(
7377 core.action_running(),
7378 "a genuinely held step must not have finished on its own well past its own 2s \
7379 sleep; a no-op hold_action would already show this false here"
7380 );
7381
7382 core.continue_action();
7383 wait_for("continue_action to let the held step finish", || {
7384 !core.action_running()
7385 });
7386 let receipt = core.snapshot().entities[0].last_action.clone().unwrap();
7387 assert_eq!(receipt.steps[0].outcome, StepOutcome::Ok);
7388 }
7389
7390 #[test]
7394 fn hold_continue_and_stop_action_are_no_ops_with_no_fan_out_running() {
7395 let dir = tempfile::tempdir().expect("temp dir");
7396 let root = root_of(&dir);
7397 let repo = root.join("repo");
7398 init_repo_with_a_commit(&repo);
7399
7400 let core = Core::start_discovered(spec(vec![root]));
7401
7402 core.hold_action();
7403 core.continue_action();
7404 core.stop_action();
7405
7406 assert!(!core.action_running());
7407 }
7408
7409 #[test]
7431 fn stop_action_escalates_from_sigterm_to_sigkill_against_a_trapping_step() {
7432 let dir = tempfile::tempdir().expect("temp dir");
7433 let root = root_of(&dir);
7434 let repo = root.join("repo");
7435 init_repo_with_a_commit(&repo);
7436
7437 let core = Core::start_discovered(spec(vec![root]));
7438 let key = core.snapshot().entities[0].key.clone();
7439 let sleep_past_the_backstop = format!("trap '' TERM; sleep {}", FIXTURE_LIFETIME.as_secs());
7440 let trapping = action(
7441 "trapping",
7442 vec![step(&["sh", "-c", &sleep_past_the_backstop])],
7443 );
7444
7445 assert!(core.run_action(trapping, std::slice::from_ref(&key)));
7446 wait_for("the trapping step to actually start running", || {
7447 core.snapshot().entities[0]
7448 .last_action
7449 .as_ref()
7450 .is_some_and(|receipt| receipt.running.is_some())
7451 });
7452 thread::sleep(Duration::from_millis(100));
7455
7456 core.stop_action();
7457
7458 wait_for(
7459 "a SIGTERM-trapping step to come down from the follow-up SIGKILL",
7460 || !core.action_running(),
7461 );
7462 let receipt = core.snapshot().entities[0].last_action.clone().unwrap();
7463 assert_eq!(receipt.steps.len(), 1);
7464 assert_eq!(
7465 receipt.steps[0].outcome,
7466 StepOutcome::Cancelled,
7467 "a step running when the run was cancelled must read Cancelled, never Failed"
7468 );
7469 }
7470
7471 #[test]
7486 fn cancelled_and_not_run_are_distinct_outcomes_shown_together_in_one_run() {
7487 let dir = tempfile::tempdir().expect("temp dir");
7488 let root = root_of(&dir);
7489 init_repo_with_a_commit(&root.join("fail"));
7490 init_repo_with_a_commit(&root.join("slow"));
7491
7492 let core = Core::start_discovered(spec(vec![root]));
7493 let snapshot = core.snapshot();
7494 let fail_key = snapshot
7495 .entities
7496 .iter()
7497 .find(|entity| &*entity.name == "fail")
7498 .expect("the fail entity is present")
7499 .key
7500 .clone();
7501 let slow_key = snapshot
7502 .entities
7503 .iter()
7504 .find(|entity| &*entity.name == "slow")
7505 .expect("the slow entity is present")
7506 .key
7507 .clone();
7508
7509 let branch_on_the_entity_name = format!(
7517 "case \"$(basename \"$PWD\")\" in fail) exit 1 ;; *) sleep {} ;; esac",
7518 FIXTURE_LIFETIME.as_secs()
7519 );
7520 let steps = vec![
7521 step(&["sh", "-c", &branch_on_the_entity_name]),
7522 step(&["true"]),
7523 ];
7524 let mut action_spec = action("mixed", steps);
7525 action_spec.concurrency = 2;
7526
7527 assert!(core.run_action(action_spec, &[fail_key.clone(), slow_key.clone()]));
7528
7529 wait_for(
7533 "`fail` finished and `slow` still running before cancelling",
7534 || {
7535 let snapshot = core.snapshot();
7536 let fail_done = snapshot
7537 .entities
7538 .iter()
7539 .find(|entity| entity.key == fail_key)
7540 .and_then(|entity| entity.last_action.as_ref())
7541 .is_some_and(|receipt| receipt.steps.len() == 2);
7542 let slow_running = snapshot
7543 .entities
7544 .iter()
7545 .find(|entity| entity.key == slow_key)
7546 .and_then(|entity| entity.last_action.as_ref())
7547 .is_some_and(|receipt| receipt.running.is_some());
7548 fail_done && slow_running
7549 },
7550 );
7551
7552 core.stop_action();
7553 wait_for("the fan-out to finish once cancelled", || {
7554 !core.action_running()
7555 });
7556
7557 let snapshot = core.snapshot();
7558 let fail_receipt = snapshot
7559 .entities
7560 .iter()
7561 .find(|entity| entity.key == fail_key)
7562 .and_then(|entity| entity.last_action.clone())
7563 .expect("fail's own receipt");
7564 assert_eq!(fail_receipt.steps[0].outcome, StepOutcome::Failed(1));
7565 assert_eq!(
7566 fail_receipt.steps[1].outcome,
7567 StepOutcome::NotRun,
7568 "blocked by fail's own earlier failure, not by the later cancellation"
7569 );
7570
7571 let slow_receipt = snapshot
7572 .entities
7573 .iter()
7574 .find(|entity| entity.key == slow_key)
7575 .and_then(|entity| entity.last_action.clone())
7576 .expect("slow's own receipt");
7577 assert_eq!(
7578 slow_receipt.steps[0].outcome,
7579 StepOutcome::Cancelled,
7580 "a step running when the run was cancelled must read Cancelled"
7581 );
7582 assert_eq!(
7583 slow_receipt.steps[1].outcome,
7584 StepOutcome::Cancelled,
7585 "a step that had not started when the run was cancelled must also read \
7586 Cancelled, never NotRun, which stays reserved for an earlier failure"
7587 );
7588 }
7589
7590 #[test]
7598 fn a_panicking_fan_out_still_resets_action_running_so_a_later_action_can_start() {
7599 let dir = tempfile::tempdir().expect("temp dir");
7600 let root = root_of(&dir);
7601 let repo = root.join("repo");
7602 init_repo_with_a_commit(&repo);
7603
7604 let (core, launched) = started_and_settled(spec(vec![root]));
7608 let key = launched.entities[0].key.clone();
7609
7610 let started = core.run_action(
7617 action("boom", vec![step(&["sh", "-c", "sleep 0.3"])]),
7618 std::slice::from_ref(&key),
7619 );
7620 assert!(started);
7621
7622 let table = Arc::clone(&core.table);
7623 thread::spawn(move || {
7624 let _guard = table.write().unwrap();
7625 panic!("deliberately poison the table lock for this test");
7626 })
7627 .join()
7628 .expect_err("the poisoning thread must itself panic to poison the lock");
7629
7630 wait_for(
7635 "a panicking fan-out to end its run rather than leave it reading as live",
7636 || !core.action_running(),
7637 );
7638
7639 core.table.clear_poison();
7644
7645 let second_started = core.run_action(
7646 action("second", vec![step(&["true"])]),
7647 std::slice::from_ref(&key),
7648 );
7649 assert!(
7650 second_started,
7651 "a later Action must be able to start once the panicking one has finished"
7652 );
7653 wait_for("the second Action to run to completion", || {
7654 core.snapshot()
7655 .entities
7656 .iter()
7657 .find(|entity| entity.key == key)
7658 .and_then(|entity| entity.last_action.as_ref())
7659 .is_some_and(|receipt| &*receipt.label == "second")
7660 });
7661 }
7662
7663 fn assert_vanished_with_stale_branch(entity: &EntityState, expected_branch: &str) {
7669 assert_eq!(entity.presence, crate::entity::Presence::Vanished);
7670 match entity.branch.settled() {
7671 Some(Settled::Known {
7672 value: Head::Branch { name, .. },
7673 stale: true,
7674 at: _,
7675 }) => assert_eq!(
7676 &**name, expected_branch,
7677 "a Vanished entity must keep its last known branch value"
7678 ),
7679 other => panic!(
7680 "expected the branch cell to keep its Known value and go stale, got {other:?}"
7681 ),
7682 }
7683 }
7684
7685 #[test]
7691 fn a_repo_removed_from_disk_stays_in_the_table_vanished_with_its_last_values() {
7692 let dir = tempfile::tempdir().expect("temp dir");
7693 let root = root_of(&dir);
7694 let repo = root.join("repo");
7695 init_repo_with_a_commit(&repo);
7696
7697 let core = Core::start_discovered(spec(vec![root]));
7698 let key = core.snapshot().entities[0].key.clone();
7699 core.refresh(std::slice::from_ref(&key));
7700 let before = core.settle();
7701 let branch_name = match before.entities[0].branch.settled() {
7702 Some(Settled::Known {
7703 value: Head::Branch { name, .. },
7704 at: _,
7705 stale: _,
7706 }) => name.to_string(),
7707 other => panic!("expected the first refresh to settle a branch, got {other:?}"),
7708 };
7709
7710 fs::remove_dir_all(&repo).expect("remove the repo from disk");
7711
7712 core.refresh(&[]);
7713 let after = core.settle();
7714
7715 assert_eq!(
7716 after.entities.len(),
7717 1,
7718 "a vanished entity must stay in the snapshot, not disappear from it"
7719 );
7720 assert_vanished_with_stale_branch(&after.entities[0], &branch_name);
7721 }
7722
7723 #[test]
7727 fn a_vanished_entitys_action_receipt_survives_the_vanished_staleness_pass_untouched() {
7728 let dir = tempfile::tempdir().expect("temp dir");
7729 let root = root_of(&dir);
7730 let repo = root.join("repo");
7731 init_repo_with_a_commit(&repo);
7732
7733 let core = Core::start_discovered(spec(vec![root]));
7734 let key = core.snapshot().entities[0].key.clone();
7735 let receipt = crate::entity::ActionReceipt {
7736 label: Arc::from("reinstall"),
7737 steps: Arc::from(vec![crate::entity::StepResult {
7738 label: Arc::from("pnpm install"),
7739 outcome: crate::entity::StepOutcome::Ok,
7740 output: Arc::from(&b""[..]),
7741 elapsed: Duration::from_millis(1),
7742 elision: None,
7743 shell: false,
7744 interactive: false,
7745 }]),
7746 skip: None,
7747 finished_at: Timestamp::now(),
7748 running: None,
7749 };
7750 core.set_last_action_for_test(&key, receipt.clone());
7751
7752 fs::remove_dir_all(&repo).expect("remove the repo from disk");
7753 core.refresh(&[]);
7754 let after = core.settle();
7755
7756 let entity = &after.entities[0];
7757 assert_eq!(entity.presence, crate::entity::Presence::Vanished);
7758 assert_eq!(entity.last_action, Some(receipt));
7759 }
7760
7761 #[test]
7769 fn two_snapshots_of_an_entity_share_its_last_actions_label_and_steps_by_pointer() {
7770 let dir = tempfile::tempdir().expect("temp dir");
7771 let root = root_of(&dir);
7772 let repo = root.join("repo");
7773 init_repo_with_a_commit(&repo);
7774
7775 let core = Core::start_discovered(spec(vec![root]));
7776 let key = core.snapshot().entities[0].key.clone();
7777 let receipt = crate::entity::ActionReceipt {
7778 label: Arc::from("reinstall"),
7779 steps: Arc::from(vec![crate::entity::StepResult {
7780 label: Arc::from("pnpm install"),
7781 outcome: crate::entity::StepOutcome::Failed(1),
7782 output: Arc::from(&b""[..]),
7783 elapsed: Duration::from_millis(1),
7784 elision: None,
7785 shell: false,
7786 interactive: false,
7787 }]),
7788 skip: None,
7789 finished_at: Timestamp::now(),
7790 running: None,
7791 };
7792 core.set_last_action_for_test(&key, receipt);
7793
7794 let first = core.snapshot();
7795 let second = core.snapshot();
7796 let first_receipt = first.entities[0]
7797 .last_action
7798 .as_ref()
7799 .expect("receipt was set");
7800 let second_receipt = second.entities[0]
7801 .last_action
7802 .as_ref()
7803 .expect("receipt was set");
7804
7805 assert!(
7806 Arc::ptr_eq(&first_receipt.label, &second_receipt.label),
7807 "two snapshots of the same receipt must share the label's allocation, not \
7808 re-copy it"
7809 );
7810 assert!(
7811 Arc::ptr_eq(&first_receipt.steps, &second_receipt.steps),
7812 "two snapshots of the same receipt must share the steps slice's allocation, not \
7813 re-copy it, which is also what shares every step's own captured output"
7814 );
7815 }
7816
7817 #[test]
7823 fn a_submodule_removed_from_gitmodules_vanishes_by_the_same_rule_as_a_repo() {
7824 let dir = tempfile::tempdir().expect("temp dir");
7825 let root = root_of(&dir);
7826 let parent = root.join("parent");
7827 init_repo_with_a_commit(&parent);
7828 fs::write(
7829 parent.join(".gitmodules"),
7830 "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
7831 )
7832 .expect("write .gitmodules");
7833 let submodule_path = parent.join("vendor").join("lib");
7834 init_repo_with_a_commit(&submodule_path);
7835
7836 let mut core_spec = spec(vec![root]);
7839 core_spec.show_submodules = true;
7840 let core = Core::start_discovered(core_spec);
7841 let snapshot = core.snapshot();
7842 let submodule_key = snapshot
7843 .entities
7844 .iter()
7845 .find(|entity| matches!(entity.kind, Kind::Submodule))
7846 .expect("submodule discovered")
7847 .key
7848 .clone();
7849 core.refresh(std::slice::from_ref(&submodule_key));
7850 let before = core.settle();
7851 let submodule_before = before
7852 .entities
7853 .iter()
7854 .find(|entity| entity.key == submodule_key)
7855 .expect("submodule present");
7856 let branch_name = match submodule_before.branch.settled() {
7857 Some(Settled::Known {
7858 value: Head::Branch { name, .. },
7859 at: _,
7860 stale: _,
7861 }) => name.to_string(),
7862 other => {
7863 panic!("expected the submodule's first refresh to settle a branch, got {other:?}")
7864 }
7865 };
7866
7867 fs::write(parent.join(".gitmodules"), "").expect("clear .gitmodules");
7871
7872 core.refresh(&[]);
7873 let after = core.settle();
7874
7875 let submodule_after = after
7876 .entities
7877 .iter()
7878 .find(|entity| entity.key == submodule_key)
7879 .expect("the vanished submodule must stay in the snapshot");
7880 assert_vanished_with_stale_branch(submodule_after, &branch_name);
7881 }
7882
7883 #[test]
7888 fn dismissal_persists_nothing_across_a_fresh_core() {
7889 let dir = tempfile::tempdir().expect("temp dir");
7890 let root = root_of(&dir);
7891 let repo = root.join("repo");
7892 init_repo_with_a_commit(&repo);
7893
7894 let first_core = Core::start_discovered(spec(vec![root.clone()]));
7895 let key = first_core.snapshot().entities[0].key.clone();
7896 first_core.dismiss(&key);
7897 assert!(first_core.snapshot().entities.is_empty());
7898 drop(first_core);
7899
7900 let second_core = Core::start_discovered(spec(vec![root]));
7901 let snapshot = second_core.snapshot();
7902
7903 assert_eq!(
7904 snapshot.entities.len(),
7905 1,
7906 "a fresh Core must discover the repo again"
7907 );
7908 assert_eq!(
7909 snapshot.entities[0].presence,
7910 crate::entity::Presence::Present,
7911 "nothing from the dismissing Core's lifetime may be persisted, so the \
7912 repo must come back Present, never restored as Vanished"
7913 );
7914 }
7915
7916 #[test]
7920 fn a_repo_that_moves_reads_as_vanished_plus_new() {
7921 let dir = tempfile::tempdir().expect("temp dir");
7922 let root = root_of(&dir);
7923 let original_path = root.join("original-name");
7924 init_repo_with_a_commit(&original_path);
7925
7926 let core = Core::start_discovered(spec(vec![root.clone()]));
7927 let original_key = core.snapshot().entities[0].key.clone();
7928 core.refresh(std::slice::from_ref(&original_key));
7929 let before = core.settle();
7930 let branch_name = match before.entities[0].branch.settled() {
7931 Some(Settled::Known {
7932 value: Head::Branch { name, .. },
7933 at: _,
7934 stale: _,
7935 }) => name.to_string(),
7936 other => panic!("expected the first refresh to settle a branch, got {other:?}"),
7937 };
7938
7939 let moved_path = root.join("new-name");
7940 fs::rename(&original_path, &moved_path).expect("move the repo on disk");
7941
7942 core.refresh(&[]);
7943 let after = core.settle();
7944
7945 assert_eq!(
7946 after.entities.len(),
7947 2,
7948 "a moved entity must read as the old key vanished plus a new one present, \
7949 never as one renamed entity"
7950 );
7951 let old_entity = after
7952 .entities
7953 .iter()
7954 .find(|entity| entity.key == original_key)
7955 .expect("the old key must stay in the table");
7956 assert_vanished_with_stale_branch(old_entity, &branch_name);
7957 let new_entity = after
7958 .entities
7959 .iter()
7960 .find(|entity| entity.key != original_key)
7961 .expect("a new entity at the moved path must be present");
7962 assert_eq!(new_entity.presence, crate::entity::Presence::Present);
7963 assert_eq!(new_entity.key.path(), moved_path);
7964 }
7965
7966 #[test]
7970 fn a_vanished_repo_recreated_on_disk_reads_present_on_the_next_refresh() {
7971 let dir = tempfile::tempdir().expect("temp dir");
7972 let root = root_of(&dir);
7973 let repo = root.join("repo");
7974 init_repo_with_a_commit(&repo);
7975
7976 let core = Core::start_discovered(spec(vec![root]));
7977 let key = core.snapshot().entities[0].key.clone();
7978
7979 fs::remove_dir_all(&repo).expect("remove the repo from disk");
7980 core.refresh(&[]);
7981 let vanished = core.settle();
7982 assert_eq!(
7983 vanished.entities[0].presence,
7984 crate::entity::Presence::Vanished,
7985 "the repo must read Vanished once removed from disk"
7986 );
7987
7988 init_repo_with_a_commit(&repo);
7989 core.refresh(&[]);
7990 let recreated = core.settle();
7991
7992 let entity = recreated
7993 .entities
7994 .iter()
7995 .find(|entity| entity.key == key)
7996 .expect("the recreated repo must still resolve to the same entity key");
7997 assert_eq!(
7998 entity.presence,
7999 crate::entity::Presence::Present,
8000 "an entity discovery finds again after it vanished must read Present, \
8001 not stay stuck Vanished forever"
8002 );
8003 }
8004
8005 #[test]
8010 fn a_new_repo_created_after_start_is_discovered_by_the_next_refresh() {
8011 let dir = tempfile::tempdir().expect("temp dir");
8012 let root = root_of(&dir);
8013 init_repo_with_a_commit(&root.join("first"));
8014
8015 let core = Core::start_discovered(spec(vec![root.clone()]));
8016 assert_eq!(core.snapshot().entities.len(), 1);
8017
8018 init_repo_with_a_commit(&root.join("second"));
8019 core.refresh(&[]);
8020 let after = core.settle();
8021
8022 assert_eq!(
8023 after.entities.len(),
8024 2,
8025 "a new repo created after start must be found by the next refresh's own discovery"
8026 );
8027
8028 let new_key = after
8031 .entities
8032 .iter()
8033 .find(|entity| &*entity.name == "second")
8034 .expect("the newly discovered repo must be named by the walk")
8035 .key
8036 .clone();
8037 core.refresh(std::slice::from_ref(&new_key));
8038 let probed = core.settle();
8039 let new_entity = probed
8040 .entities
8041 .iter()
8042 .find(|entity| entity.key == new_key)
8043 .expect("the newly discovered repo must still be present");
8044 assert!(
8045 matches!(
8046 new_entity.branch.settled(),
8047 Some(Settled::Known {
8048 value: _,
8049 at: _,
8050 stale: _
8051 })
8052 ),
8053 "a refresh naming the newly discovered repo's key must actually probe \
8054 it and settle its branch cell, got {:?}",
8055 new_entity.branch.settled()
8056 );
8057 }
8058
8059 #[test]
8064 fn an_abandoned_discovery_stops_riding_later_refreshes() {
8065 let dir = tempfile::tempdir().expect("temp dir");
8066 let root = root_of(&dir);
8067 let decoys = root.join("decoys");
8074 for i in 0..4_000 {
8075 fs::create_dir(decoys.join(format!("decoy-{i}")))
8076 .or_else(|_| fs::create_dir_all(decoys.join(format!("decoy-{i}"))))
8077 .expect("create decoy dir");
8078 }
8079 let (_tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
8080
8081 let started = Core::start_for_test_with_discovery_abandon(
8082 spec(vec![root.clone()]),
8083 Duration::from_secs(3600),
8084 Duration::from_micros(500),
8085 tick_rx,
8086 )
8087 .discovered();
8088 let core = started.core;
8089 assert!(
8090 core.discovery_manual_for_test(),
8091 "walking 4,000 decoy directories against a 500 microsecond deadline \
8092 must have abandoned and taken the Set manual"
8093 );
8094
8095 fs::remove_dir_all(&decoys).expect("remove decoy directories");
8100 init_repo_with_a_commit(&root.join("second"));
8101
8102 core.refresh(&[]);
8103 let after = core.settle();
8104
8105 assert!(
8106 !after
8107 .entities
8108 .iter()
8109 .any(|entity| &*entity.name == "second"),
8110 "once discovery has abandoned, a later refresh must not re-run it, so a \
8111 repo created afterward, on a tree that would now resolve quickly, \
8112 must still never appear"
8113 );
8114 }
8115
8116 #[test]
8125 fn a_refresh_triggered_discovery_abandon_sets_manual_and_warns() {
8126 let dir = tempfile::tempdir().expect("temp dir");
8127 let root = root_of(&dir);
8128 init_repo_with_a_commit(&root.join("first"));
8129 let (_tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
8130
8131 let started = Core::start_for_test_with_discovery_abandon(
8137 spec(vec![root.clone()]),
8138 Duration::from_secs(3600),
8139 Duration::from_secs(3600),
8140 tick_rx,
8141 )
8142 .discovered();
8143 let core = started.core;
8144 assert!(
8145 !core.discovery_manual_for_test(),
8146 "an hour-long deadline must leave the first walk automatic"
8147 );
8148
8149 let decoys = root.join("decoys");
8153 for i in 0..4_000 {
8154 fs::create_dir(decoys.join(format!("decoy-{i}")))
8155 .or_else(|_| fs::create_dir_all(decoys.join(format!("decoy-{i}"))))
8156 .expect("create decoy dir");
8157 }
8158 core.set_discovery_abandon_after_for_test(Duration::from_micros(500));
8159
8160 core.refresh(&[]);
8161 core.wait_dispatched_for_test();
8164
8165 assert!(
8166 core.discovery_manual_for_test(),
8167 "refresh's own rerun_discovery must abandon against the newly-grown \
8168 tree and take the Set manual, the same as an abandon at start does"
8169 );
8170 let warning = core.discovery_warning();
8171 assert!(
8172 warning
8173 .as_deref()
8174 .is_some_and(|message| message.starts_with("discovery: stopped at")),
8175 "refresh's rerun_discovery must leave the abandoned-discovery warning \
8176 behind, not merely flip the manual flag: got {warning:?}"
8177 );
8178 }
8179
8180 #[test]
8186 fn a_fresh_core_over_different_roots_is_unaffected_by_another_cores_abandoned_discovery() {
8187 let abandoned_dir = tempfile::tempdir().expect("temp dir");
8188 let abandoned_root = root_of(&abandoned_dir);
8189 init_repo_with_a_commit(&abandoned_root.join("first"));
8190 let (_tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
8191 let started = Core::start_for_test_with_discovery_abandon(
8192 spec(vec![abandoned_root]),
8193 Duration::from_secs(3600),
8194 Duration::ZERO,
8195 tick_rx,
8196 )
8197 .discovered();
8198 started.core.refresh(&[]);
8199 started.core.settle();
8200 assert!(
8201 started.core.discovery_manual_for_test(),
8202 "the zero-length abandon deadline must have already taken this Core manual"
8203 );
8204 drop(started.core);
8205
8206 let fresh_dir = tempfile::tempdir().expect("temp dir");
8207 let fresh_root = root_of(&fresh_dir);
8208 init_repo_with_a_commit(&fresh_root.join("first"));
8209 let fresh_core = Core::start_discovered(spec(vec![fresh_root.clone()]));
8210 assert_eq!(fresh_core.snapshot().entities.len(), 1);
8211
8212 init_repo_with_a_commit(&fresh_root.join("second"));
8213 fresh_core.refresh(&[]);
8214 let after = fresh_core.settle();
8215
8216 assert_eq!(
8217 after.entities.len(),
8218 2,
8219 "a fresh Core, standing in for the Set's roots changing, must discover \
8220 normally regardless of an earlier, unrelated Core having gone manual"
8221 );
8222 }
8223
8224 #[test]
8229 fn dropping_the_core_joins_the_dedicated_thread_before_returning() {
8230 let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
8231 let dir = tempfile::tempdir().expect("temp dir");
8232 let root = root_of(&dir);
8233
8234 let started =
8235 Core::start_for_test(spec(vec![root]), Duration::from_secs(3600), tick_rx).discovered();
8236 assert!(started.clock_alive.load(Ordering::Acquire));
8237
8238 drop(started.core);
8239
8240 assert!(
8241 !started.clock_alive.load(Ordering::Acquire),
8242 "the dedicated thread should have exited, and cleared this flag, before drop returned"
8243 );
8244 drop(tick_tx);
8245 }
8246
8247 #[test]
8252 fn the_deadline_sweep_runs_only_when_a_tick_arrives() {
8253 let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
8254 let dir = tempfile::tempdir().expect("temp dir");
8255 let root = root_of(&dir);
8256 let repo = root.join("repo");
8257 init_repo_with_a_commit(&repo);
8258
8259 let mut spec = spec(vec![root]);
8260 spec.generation_deadline = Duration::ZERO;
8261 let started = Core::start_for_test(spec, Duration::from_secs(3600), tick_rx).discovered();
8262 let core = started.core;
8263 let key = settle_launch(&core).entities[0].key.clone();
8266
8267 core.begin_untracked_probe_for_test(&key);
8268
8269 let before = core.snapshot();
8272 assert!(
8273 matches!(
8274 before.entities[0].branch.settled(),
8275 Some(Settled::Known {
8276 value: _,
8277 at: _,
8278 stale: _
8279 })
8280 ),
8281 "the cell still holds launch's own answer here, so the Unknown below is the \
8282 sweep's write rather than a cell that was already empty"
8283 );
8284 assert!(before.entities[0].branch.is_in_flight());
8285
8286 tick_tx.send(Instant::now()).expect("send one tick");
8287 let after = core.settle();
8288
8289 assert!(matches!(
8290 after.entities[0].branch.settled(),
8291 Some(Settled::Unknown(Unknown::TimedOut))
8292 ));
8293 }
8294
8295 #[test]
8304 fn a_real_tick_through_the_dedicated_thread_reaches_the_poll_sweep_and_reprobes_a_moved_entity()
8305 {
8306 let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
8307 let dir = tempfile::tempdir().expect("temp dir");
8308 let root = root_of(&dir);
8309 let repo = root.join("repo");
8310 init_repo_with_a_commit(&repo);
8311
8312 let started =
8313 Core::start_for_test(spec(vec![root]), Duration::from_secs(3600), tick_rx).discovered();
8314 let core = started.core;
8315 let key = core.snapshot().entities[0].key.clone();
8316
8317 backdate_polled_entries(&repo);
8318
8319 tick_tx
8322 .send(Instant::now())
8323 .expect("send the baseline tick");
8324 wait_for(
8325 "a tick sent on the real channel to reach the poll sweep",
8326 || core.poll_sweep_count_for_test() >= 1,
8327 );
8328 assert!(core.poll_reprobed_for_test().is_empty());
8329
8330 commit_a_change(&repo, "second");
8331
8332 tick_tx
8333 .send(Instant::now())
8334 .expect("send the movement tick");
8335 wait_for(
8336 "the real tick channel to reach the poll sweep and reprobe the moved entity",
8337 || core.poll_reprobed_for_test() == vec![key.clone()],
8338 );
8339 drop(tick_tx);
8340 }
8341
8342 #[test]
8350 fn poll_reprobe_touches_only_the_moved_entity_and_never_runs_a_status_probe() {
8351 let dir = tempfile::tempdir().expect("temp dir");
8352 let root = root_of(&dir);
8353 let repo_a = root.join("repo-a");
8354 let repo_b = root.join("repo-b");
8355 init_repo_with_a_commit(&repo_a);
8356 init_repo_with_a_commit(&repo_b);
8357
8358 let core = Core::start_discovered(spec(vec![root]));
8359 let snapshot = core.snapshot();
8360 let key_a = snapshot
8361 .entities
8362 .iter()
8363 .find(|entity| entity.key.path() == repo_a)
8364 .expect("repo-a discovered")
8365 .key
8366 .clone();
8367 let key_b = snapshot
8368 .entities
8369 .iter()
8370 .find(|entity| entity.key.path() == repo_b)
8371 .expect("repo-b discovered")
8372 .key
8373 .clone();
8374
8375 core.refresh(&[key_a.clone(), key_b.clone()]);
8376 let landed = core.settle();
8377 let entity_of = |snapshot: &Snapshot, key: &EntityKey| {
8378 snapshot
8379 .entities
8380 .iter()
8381 .find(|entity| &entity.key == key)
8382 .expect("entity present")
8383 .clone()
8384 };
8385 let a_before = entity_of(&landed, &key_a);
8386 let b_before = entity_of(&landed, &key_b);
8387 let branch_at = |entity: &EntityState| match entity.branch.settled() {
8388 Some(Settled::Known {
8389 at,
8390 value: _,
8391 stale: _,
8392 }) => *at,
8393 other => panic!("expected a landed branch, got {other:?}"),
8394 };
8395 let dirty_state = |entity: &EntityState| match entity.dirty.settled() {
8396 Some(Settled::Known { value, at, stale }) => (*value, *at, *stale),
8397 other => panic!("expected a landed dirty count, got {other:?}"),
8398 };
8399 let (a_dirty_value_before, a_dirty_at_before, a_dirty_stale_before) =
8400 dirty_state(&a_before);
8401 assert!(
8402 !a_dirty_stale_before,
8403 "the fresh refresh must land dirty as not stale"
8404 );
8405
8406 backdate_polled_entries(&repo_a);
8407
8408 backdate_polled_entries(&repo_b);
8409
8410 core.poll_once_for_test();
8411 assert!(
8412 core.poll_reprobed_for_test().is_empty(),
8413 "a first sweep has nothing to compare against, so it must report no movement"
8414 );
8415
8416 commit_a_change(&repo_a, "second");
8417 core.poll_once_for_test();
8418
8419 assert_eq!(
8420 core.poll_reprobed_for_test(),
8421 vec![key_a.clone()],
8422 "only the entity whose gitdir actually moved must be re-probed"
8423 );
8424
8425 let after = core.snapshot();
8426 let a_after = entity_of(&after, &key_a);
8427 let b_after = entity_of(&after, &key_b);
8428
8429 assert_ne!(
8430 branch_at(&a_after),
8431 branch_at(&a_before),
8432 "the moved entity's branch must carry a fresh timestamp from the re-probe"
8433 );
8434 let (a_dirty_value_after, a_dirty_at_after, a_dirty_stale_after) = dirty_state(&a_after);
8435 assert_eq!(
8436 a_dirty_value_after, a_dirty_value_before,
8437 "no status probe ran, so dirty's value must be exactly what the last real refresh \
8438 landed"
8439 );
8440 assert_eq!(
8441 a_dirty_at_after, a_dirty_at_before,
8442 "no status probe ran, so dirty's timestamp must be untouched, only its stale flag \
8443 set"
8444 );
8445 assert!(
8446 a_dirty_stale_after,
8447 "the moved entity's dirty cell must go stale on poll evidence"
8448 );
8449
8450 assert_eq!(
8451 branch_at(&b_after),
8452 branch_at(&b_before),
8453 "the untouched entity's branch must be exactly as the prior refresh left it"
8454 );
8455 let (b_dirty_value_after, b_dirty_at_after, b_dirty_stale_after) = dirty_state(&b_after);
8456 let (b_dirty_value_before, b_dirty_at_before, b_dirty_stale_before) =
8457 dirty_state(&b_before);
8458 assert_eq!(b_dirty_value_after, b_dirty_value_before);
8459 assert_eq!(b_dirty_at_after, b_dirty_at_before);
8460 assert_eq!(
8461 b_dirty_stale_after, b_dirty_stale_before,
8462 "an entity the sweep found unmoved must never go stale"
8463 );
8464 }
8465
8466 #[test]
8471 fn poll_detects_an_attached_commit_through_index_while_head_itself_never_moves() {
8472 let dir = tempfile::tempdir().expect("temp dir");
8473 let root = root_of(&dir);
8474 let repo = root.join("repo");
8475 init_repo_with_a_commit(&repo);
8476
8477 let core = Core::start_discovered(spec(vec![root]));
8478 let key = core.snapshot().entities[0].key.clone();
8479 backdate_polled_entries(&repo);
8480 core.poll_once_for_test();
8481 assert!(core.poll_reprobed_for_test().is_empty());
8482
8483 let head_path = repo.join(".git").join("HEAD");
8484 let head_mtime_before = fs::metadata(&head_path)
8485 .expect("stat HEAD")
8486 .modified()
8487 .expect("HEAD mtime");
8488
8489 commit_a_change(&repo, "second");
8490
8491 let head_mtime_after = fs::metadata(&head_path)
8492 .expect("stat HEAD")
8493 .modified()
8494 .expect("HEAD mtime");
8495 assert_eq!(
8496 head_mtime_before, head_mtime_after,
8497 "a commit on an attached HEAD must never touch HEAD itself"
8498 );
8499
8500 core.poll_once_for_test();
8501 assert_eq!(
8502 core.poll_reprobed_for_test(),
8503 vec![key],
8504 "the poll must still detect the attached commit, through index rather than HEAD"
8505 );
8506 }
8507
8508 #[test]
8515 fn poll_detects_a_detached_commit_through_the_per_worktree_head_file() {
8516 let dir = tempfile::tempdir().expect("temp dir");
8517 let root = root_of(&dir);
8518 let parent = root.join("parent");
8519 init_repo_with_a_commit(&parent);
8520 let worktree_path = root.join("detached-worktree");
8521 let status = Command::new("git")
8522 .arg("-C")
8523 .arg(&parent)
8524 .args([
8525 "worktree",
8526 "add",
8527 "--detach",
8528 worktree_path.to_str().expect("utf8 path"),
8529 ])
8530 .status()
8531 .expect("run git worktree add");
8532 assert!(status.success());
8533
8534 let core = Core::start_discovered(spec(vec![root]));
8535 let snapshot = core.snapshot();
8536 let worktree_key = snapshot
8537 .entities
8538 .iter()
8539 .find(|entity| matches!(entity.kind, Kind::Worktree))
8540 .expect("worktree discovered")
8541 .key
8542 .clone();
8543
8544 backdate_polled_entries(&parent);
8545 backdate_polled_entries(&worktree_path);
8546
8547 core.poll_once_for_test();
8548 assert!(core.poll_reprobed_for_test().is_empty());
8549
8550 let worktree_head_path = parent
8551 .join(".git")
8552 .join("worktrees")
8553 .join("detached-worktree")
8554 .join("HEAD");
8555 let head_mtime_before = fs::metadata(&worktree_head_path)
8556 .expect("stat the per-worktree HEAD")
8557 .modified()
8558 .expect("HEAD mtime");
8559
8560 commit_a_change(&worktree_path, "on the detached worktree");
8561
8562 let head_mtime_after = fs::metadata(&worktree_head_path)
8563 .expect("stat the per-worktree HEAD")
8564 .modified()
8565 .expect("HEAD mtime");
8566 assert_ne!(
8567 head_mtime_before, head_mtime_after,
8568 "a commit on a detached HEAD must write the new object id straight into its own \
8569 HEAD file"
8570 );
8571
8572 core.poll_once_for_test();
8573 assert_eq!(
8574 core.poll_reprobed_for_test(),
8575 vec![worktree_key],
8576 "the poll must detect the detached commit via the per-worktree HEAD file"
8577 );
8578 }
8579
8580 #[test]
8587 fn snapshot_ages_a_freshly_landed_dirty_cell_stale_once_status_stale_after_has_elapsed() {
8588 let dir = tempfile::tempdir().expect("temp dir");
8589 let root = root_of(&dir);
8590 let repo = root.join("repo");
8591 init_repo_with_a_commit(&repo);
8592
8593 let mut short_lived = spec(vec![root]);
8594 short_lived.status_stale_after = Duration::from_nanos(1);
8595 let core = Core::start_discovered(short_lived);
8596 let key = core.snapshot().entities[0].key.clone();
8597 core.refresh(std::slice::from_ref(&key));
8598 core.settle();
8599
8600 let aged = core.snapshot();
8601 match aged.entities[0].dirty.settled() {
8602 Some(Settled::Known {
8603 stale: true,
8604 value: _,
8605 at: _,
8606 }) => {}
8607 other => panic!(
8608 "expected a landed dirty cell to have already aged past a one-nanosecond \
8609 threshold, got {other:?}"
8610 ),
8611 }
8612 }
8613
8614 #[test]
8618 fn snapshot_leaves_a_freshly_landed_dirty_cell_fresh_under_a_large_status_stale_after() {
8619 let dir = tempfile::tempdir().expect("temp dir");
8620 let root = root_of(&dir);
8621 let repo = root.join("repo");
8622 init_repo_with_a_commit(&repo);
8623
8624 let core = Core::start_discovered(spec(vec![root]));
8625 let key = core.snapshot().entities[0].key.clone();
8626 core.refresh(std::slice::from_ref(&key));
8627 core.settle();
8628
8629 let fresh = core.snapshot();
8630 match fresh.entities[0].dirty.settled() {
8631 Some(Settled::Known {
8632 stale: false,
8633 value: _,
8634 at: _,
8635 }) => {}
8636 other => panic!("expected a freshly landed dirty cell to stay fresh, got {other:?}"),
8637 }
8638 }
8639
8640 #[test]
8646 fn hidden_submodules_are_never_polled_but_shown_ones_are() {
8647 let dir = tempfile::tempdir().expect("temp dir");
8648 let root = root_of(&dir);
8649 let parent = root.join("parent");
8650 init_repo_with_a_commit(&parent);
8651 fs::write(
8652 parent.join(".gitmodules"),
8653 "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
8654 )
8655 .expect("write .gitmodules");
8656 let submodule_path = parent.join("vendor").join("lib");
8657 init_repo_with_a_commit(&submodule_path);
8658
8659 let mut hidden_spec = spec(vec![root.clone()]);
8660 hidden_spec.show_submodules = false;
8661 let hidden_core = Core::start_discovered(hidden_spec);
8662 let hidden_submodule_key = hidden_core
8667 .snapshot()
8668 .entities
8669 .iter()
8670 .find(|entity| matches!(entity.kind, Kind::Submodule))
8671 .expect("the submodule is discovered regardless of show_submodules")
8672 .key
8673 .clone();
8674 backdate_polled_entries(&submodule_path);
8675 hidden_core.poll_once_for_test();
8676 commit_a_change(&submodule_path, "into the hidden submodule");
8677 hidden_core.poll_once_for_test();
8678 assert!(
8679 !hidden_core
8680 .poll_reprobed_for_test()
8681 .contains(&hidden_submodule_key),
8682 "a hidden Submodule must never be re-probed by the poll, since it was never \
8683 polled at all"
8684 );
8685 drop(hidden_core);
8686
8687 let mut shown_spec = spec(vec![root]);
8688 shown_spec.show_submodules = true;
8689 let shown_core = Core::start_discovered(shown_spec);
8690 let submodule_key = shown_core
8691 .snapshot()
8692 .entities
8693 .iter()
8694 .find(|entity| matches!(entity.kind, Kind::Submodule))
8695 .expect("the submodule is discovered regardless of show_submodules")
8696 .key
8697 .clone();
8698 backdate_polled_entries(&submodule_path);
8699 shown_core.poll_once_for_test();
8700 commit_a_change(&submodule_path, "into the shown submodule");
8701 shown_core.poll_once_for_test();
8702 assert_eq!(
8703 shown_core.poll_reprobed_for_test(),
8704 vec![submodule_key],
8705 "a shown Submodule must be polled and re-probed exactly like any other row"
8706 );
8707 }
8708
8709 #[test]
8715 fn pause_cancels_every_in_flight_entity_and_releases_a_pending_settle() {
8716 let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
8717 let dir = tempfile::tempdir().expect("temp dir");
8718 let root = root_of(&dir);
8719 let repo = root.join("repo");
8720 init_repo_with_a_commit(&repo);
8721
8722 let started =
8723 Core::start_for_test(spec(vec![root]), Duration::from_secs(3600), tick_rx).discovered();
8724 let core = started.core;
8725 let key = settle_launch(&core).entities[0].key.clone();
8727 let cancel = core.begin_untracked_probe_for_test(&key);
8728 assert!(!cancel.load(Ordering::Acquire));
8729
8730 core.pause();
8731 let settled = core.settle();
8732
8733 assert!(
8734 cancel.load(Ordering::Acquire),
8735 "pause should cancel the entity that was in flight"
8736 );
8737 assert!(settled.entities[0].branch.is_in_flight());
8738 drop(tick_tx);
8739 }
8740
8741 #[test]
8750 fn a_launch_is_one_generation_over_every_row_its_own_walk_found() {
8751 let dir = tempfile::tempdir().expect("temp dir");
8752 let root = root_of(&dir);
8753 init_repo_with_a_commit(&root.join("first"));
8754 init_repo_with_a_commit(&root.join("second"));
8755
8756 let (_core, launched) = started_and_settled(spec(vec![root]));
8757
8758 assert_eq!(
8759 launched.generation,
8760 Generation::default().successor(),
8761 "a launch must settle on the first Generation a fresh `Core` mints; a second \
8762 walk of the same tree would be a second Generation"
8763 );
8764 let mut named: Vec<String> = launched
8765 .entities
8766 .iter()
8767 .filter(|entity| entity.branch.settled().is_some())
8768 .map(|entity| entity.name.to_string())
8769 .collect();
8770 named.sort();
8771 assert_eq!(
8772 named,
8773 vec!["first".to_string(), "second".to_string()],
8774 "that one Generation must cover every row its own walk found, or the walk it \
8775 saved would have to be paid by a second one"
8776 );
8777 }
8778
8779 #[test]
8786 fn dropping_a_core_cancels_every_entity_it_still_has_in_flight() {
8787 let dir = tempfile::tempdir().expect("temp dir");
8788 let root = root_of(&dir);
8789 init_repo_with_a_commit(&root.join("repo"));
8790
8791 let (core, launched) = started_and_settled(spec(vec![root]));
8792 let key = launched.entities[0].key.clone();
8793 let cancel = core.begin_untracked_probe_for_test(&key);
8794 assert!(!cancel.load(Ordering::Acquire));
8795
8796 drop(core);
8797
8798 assert!(
8799 cancel.load(Ordering::Acquire),
8800 "a dropped Core must cancel the Generation it still has in flight rather than \
8801 leave it running against a Set nothing will read again"
8802 );
8803 }
8804
8805 #[test]
8835 fn a_selection_scoped_refresh_supersedes_only_the_entity_it_covers() {
8836 let dir = tempfile::tempdir().expect("temp dir");
8837 let root = root_of(&dir);
8838 init_repo_with_a_commit(&root.join("a"));
8839 init_repo_with_a_commit(&root.join("b"));
8840
8841 let (core, snapshot) = started_and_settled(spec(vec![root]));
8842 let key_a = snapshot
8843 .entities
8844 .iter()
8845 .find(|entity| &*entity.name == "a")
8846 .expect("entity a discovered")
8847 .key
8848 .clone();
8849 let key_b = snapshot
8850 .entities
8851 .iter()
8852 .find(|entity| &*entity.name == "b")
8853 .expect("entity b discovered")
8854 .key
8855 .clone();
8856
8857 let older = core.begin_shared_generation_for_test(&[key_a.clone(), key_b.clone()]);
8861
8862 let newer = core.refresh(std::slice::from_ref(&key_a));
8865 assert_eq!(
8866 newer,
8867 older.generation.successor(),
8868 "the Selection-scoped refresh must be the Generation immediately after the one \
8869 still in flight, with nothing minted in between"
8870 );
8871
8872 core.wait_dispatched_for_test();
8877 assert!(
8878 older.cancels[&key_a].load(Ordering::Acquire),
8879 "the entity the new Generation covers must have its old interrupt flag set"
8880 );
8881 assert!(
8882 !older.cancels[&key_b].load(Ordering::Acquire),
8883 "an entity the new Generation does not cover must be left running, untouched"
8884 );
8885
8886 let after_refresh = core.settle();
8890
8891 let a_after_gen2 = after_refresh
8892 .entities
8893 .iter()
8894 .find(|entity| entity.key == key_a)
8895 .expect("entity a present");
8896 assert!(
8897 matches!(
8898 a_after_gen2.branch.settled(),
8899 Some(Settled::Known {
8900 value: Head::Branch { .. },
8901 at: _,
8902 stale: _
8903 })
8904 ),
8905 "the newer Generation's real probe should have written A's cell by now"
8906 );
8907
8908 core.apply_probe_result_for_test(
8912 &key_a,
8913 older.generation,
8914 Settled::Known {
8915 value: Head::Branch {
8916 name: Arc::from("stale-from-generation-one"),
8917 commit: gix::hash::Kind::Sha1.null(),
8918 },
8919 at: Timestamp::now(),
8920 stale: false,
8921 },
8922 );
8923 let after_stale_write = core.snapshot();
8924 let a_final = after_stale_write
8925 .entities
8926 .iter()
8927 .find(|entity| entity.key == key_a)
8928 .expect("entity a present");
8929 match a_final.branch.settled() {
8930 Some(Settled::Known {
8931 value: Head::Branch { name, .. },
8932 at: _,
8933 stale: _,
8934 }) => assert_ne!(
8935 &**name, "stale-from-generation-one",
8936 "a lower-Generation result must be dropped at the cell it would write"
8937 ),
8938 other => panic!("expected A to still hold the newer Generation's value, got {other:?}"),
8939 }
8940
8941 core.apply_probe_result_for_test(
8944 &key_b,
8945 older.generation,
8946 Settled::Known {
8947 value: Head::Branch {
8948 name: Arc::from("b-generation-one-result"),
8949 commit: gix::hash::Kind::Sha1.null(),
8950 },
8951 at: Timestamp::now(),
8952 stale: false,
8953 },
8954 );
8955 let final_snapshot = core.snapshot();
8956 let b_final = final_snapshot
8957 .entities
8958 .iter()
8959 .find(|entity| entity.key == key_b)
8960 .expect("entity b present");
8961 match b_final.branch.settled() {
8962 Some(Settled::Known {
8963 value: Head::Branch { name, .. },
8964 at: _,
8965 stale: _,
8966 }) => assert_eq!(
8967 &**name, "b-generation-one-result",
8968 "an entity the new Generation never covered must still accept its own result"
8969 ),
8970 other => {
8971 panic!("expected B's un-superseded older result to be accepted, got {other:?}")
8972 }
8973 }
8974 }
8975
8976 #[test]
8981 fn the_deadline_sweep_keeps_already_settled_cells_and_only_times_out_what_is_still_loading() {
8982 let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
8983 let dir = tempfile::tempdir().expect("temp dir");
8984 let root = root_of(&dir);
8985 init_repo_with_a_commit(&root.join("a"));
8986 init_repo_with_a_commit(&root.join("b"));
8987
8988 let mut spec = spec(vec![root]);
8989 spec.generation_deadline = Duration::ZERO;
8990 let started = Core::start_for_test(spec, Duration::from_secs(3600), tick_rx).discovered();
8991 let core = started.core;
8992 let snapshot = settle_launch(&core);
8995 let key_a = snapshot
8996 .entities
8997 .iter()
8998 .find(|entity| &*entity.name == "a")
8999 .expect("entity a discovered")
9000 .key
9001 .clone();
9002 let key_b = snapshot
9003 .entities
9004 .iter()
9005 .find(|entity| &*entity.name == "b")
9006 .expect("entity b discovered")
9007 .key
9008 .clone();
9009
9010 let a_settled = core.probe_now(&key_a);
9013 let a_value_before = match a_settled.branch.settled() {
9014 Some(Settled::Known {
9015 value: Head::Branch { name, .. },
9016 at: _,
9017 stale: _,
9018 }) => Arc::clone(name),
9019 other => panic!("expected A's synchronous probe to settle a branch, got {other:?}"),
9020 };
9021
9022 let cancel_b = core.begin_untracked_probe_for_test(&key_b);
9026 let before_tick = core.snapshot();
9027 let b_before = before_tick
9028 .entities
9029 .iter()
9030 .find(|entity| entity.key == key_b)
9031 .expect("entity b present");
9032 assert!(
9033 b_before.branch.is_in_flight(),
9034 "B must be mid-flight when the sweep fires; that is the only shape the sweep \
9035 may touch"
9036 );
9037 assert!(
9038 matches!(
9039 b_before.branch.settled(),
9040 Some(Settled::Known {
9041 value: _,
9042 at: _,
9043 stale: _
9044 })
9045 ),
9046 "B still carries launch's own answer here, so the Unknown below is a write the \
9047 sweep made rather than a cell that was already empty, got {:?}",
9048 b_before.branch.settled()
9049 );
9050
9051 tick_tx.send(Instant::now()).expect("send one tick");
9052 let after_sweep = core.settle();
9053
9054 let a_after = after_sweep
9055 .entities
9056 .iter()
9057 .find(|entity| entity.key == key_a)
9058 .expect("entity a present");
9059 match a_after.branch.settled() {
9060 Some(Settled::Known {
9061 value: Head::Branch { name, .. },
9062 at: _,
9063 stale: _,
9064 }) => assert_eq!(
9065 name, &a_value_before,
9066 "an already-settled cell must keep its value when the deadline sweep runs, not be blanked"
9067 ),
9068 other => panic!("expected A's settled value to survive the sweep, got {other:?}"),
9069 }
9070
9071 let b_after = after_sweep
9072 .entities
9073 .iter()
9074 .find(|entity| entity.key == key_b)
9075 .expect("entity b present");
9076 assert!(matches!(
9077 b_after.branch.settled(),
9078 Some(Settled::Unknown(Unknown::TimedOut))
9079 ));
9080 assert!(
9081 !cancel_b.load(Ordering::Acquire),
9082 "the deadline sweep marks a cell Unknown; it never sets the entity's own \
9083 cancel flag, since the underlying probe (nonexistent here) is left to keep running"
9084 );
9085 }
9086
9087 #[test]
9095 fn the_deadline_sweep_times_out_a_worktrees_outstanding_state_but_leaves_a_repos_not_applicable_one_alone()
9096 {
9097 let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
9098 let dir = tempfile::tempdir().expect("temp dir");
9099 let root = root_of(&dir);
9100 let parent = root.join("parent");
9101 init_repo_with_a_commit(&parent);
9102 let worktree_path = root.join("feature-worktree");
9103 git(
9104 &parent,
9105 &[
9106 "worktree",
9107 "add",
9108 "-b",
9109 "feature",
9110 worktree_path.to_str().expect("utf8 path"),
9111 ],
9112 );
9113
9114 let mut spec = spec(vec![root]);
9115 spec.generation_deadline = Duration::ZERO;
9116 let started = Core::start_for_test(spec, Duration::from_secs(3600), tick_rx).discovered();
9117 let core = started.core;
9118 let snapshot = settle_launch(&core);
9125 let repo_key = snapshot
9126 .entities
9127 .iter()
9128 .find(|entity| matches!(entity.kind, Kind::Repo))
9129 .expect("repo entity present")
9130 .key
9131 .clone();
9132 let worktree_key = snapshot
9133 .entities
9134 .iter()
9135 .find(|entity| matches!(entity.kind, Kind::Worktree))
9136 .expect("worktree entity present")
9137 .key
9138 .clone();
9139
9140 core.begin_untracked_probe_for_test(&repo_key);
9146 core.begin_untracked_probe_for_test(&worktree_key);
9147
9148 tick_tx.send(Instant::now()).expect("send one tick");
9149 let after_sweep = core.settle();
9150
9151 let worktree_after = after_sweep
9152 .entities
9153 .iter()
9154 .find(|entity| entity.key == worktree_key)
9155 .expect("worktree entity present");
9156 assert!(
9157 matches!(
9158 worktree_after.state.settled(),
9159 Some(Settled::Unknown(Unknown::TimedOut))
9160 ),
9161 "expected the outstanding state cell to time out, got {:?}",
9162 worktree_after.state.settled()
9163 );
9164
9165 let repo_after = after_sweep
9166 .entities
9167 .iter()
9168 .find(|entity| entity.key == repo_key)
9169 .expect("repo entity present");
9170 assert!(
9171 matches!(repo_after.state.settled(), Some(Settled::NotApplicable)),
9172 "a Repo's Not applicable state must survive the sweep untouched, got {:?}",
9173 repo_after.state.settled()
9174 );
9175 }
9176
9177 #[test]
9182 fn the_deadline_sweeps_poll_never_touches_an_entitys_action_receipt() {
9183 let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
9184 let dir = tempfile::tempdir().expect("temp dir");
9185 let root = root_of(&dir);
9186 let repo = root.join("repo");
9187 init_repo_with_a_commit(&repo);
9188
9189 let mut spec = spec(vec![root]);
9190 spec.generation_deadline = Duration::ZERO;
9191 let started = Core::start_for_test(spec, Duration::from_secs(3600), tick_rx).discovered();
9192 let core = started.core;
9193 let key = settle_launch(&core).entities[0].key.clone();
9195
9196 let receipt = crate::entity::ActionReceipt {
9197 label: Arc::from("reinstall"),
9198 steps: Arc::from(vec![crate::entity::StepResult {
9199 label: Arc::from("pnpm install"),
9200 outcome: crate::entity::StepOutcome::Ok,
9201 output: Arc::from(&b""[..]),
9202 elapsed: Duration::from_millis(1),
9203 elision: None,
9204 shell: false,
9205 interactive: false,
9206 }]),
9207 skip: None,
9208 finished_at: Timestamp::now(),
9209 running: None,
9210 };
9211 core.set_last_action_for_test(&key, receipt.clone());
9212
9213 core.begin_untracked_probe_for_test(&key);
9216 tick_tx.send(Instant::now()).expect("send one tick");
9217 let after = core.settle();
9218
9219 let entity = after
9220 .entities
9221 .iter()
9222 .find(|entity| entity.key == key)
9223 .expect("entity present");
9224 assert!(
9225 matches!(
9226 entity.branch.settled(),
9227 Some(Settled::Unknown(Unknown::TimedOut))
9228 ),
9229 "sanity check: the sweep must have actually timed out the in-flight cell, got {:?}",
9230 entity.branch.settled()
9231 );
9232 assert_eq!(entity.last_action, Some(receipt));
9233 }
9234
9235 #[test]
9245 fn a_cancelled_probe_never_opens_the_repository_at_all() {
9246 let cancel = AtomicBool::new(true);
9247
9248 let outcome = probe_branch(
9249 Path::new("/nonexistent/nowhere-at-all"),
9250 None,
9251 Kind::Repo,
9252 &cancel,
9253 );
9254
9255 assert!(
9256 outcome.is_none(),
9257 "a probe observing cancellation before its first read must do no work \
9258 at all, not attempt the read and fail having tried it"
9259 );
9260 }
9261
9262 #[test]
9272 fn classify_status_result_drops_an_error_once_cancel_reads_true() {
9273 let cancel = AtomicBool::new(true);
9274
9275 let outcome = classify_status_result(
9276 Err(crate::git::ProbeError::Status(Arc::from("boom"))),
9277 &cancel,
9278 );
9279
9280 assert!(
9281 outcome.is_none(),
9282 "an error alongside a cancel flag already set must read as cancelled, not \
9283 Failed, got {outcome:?}"
9284 );
9285 }
9286
9287 #[test]
9290 fn classify_status_result_settles_failed_when_cancel_never_fired() {
9291 let cancel = AtomicBool::new(false);
9292
9293 let outcome = classify_status_result(
9294 Err(crate::git::ProbeError::Status(Arc::from("boom"))),
9295 &cancel,
9296 );
9297
9298 assert!(
9299 matches!(outcome, Some(Settled::Failed(git::ProbeError::Status(_)))),
9300 "a genuine error with no cancellation must settle Failed, got {outcome:?}"
9301 );
9302 }
9303
9304 #[test]
9313 fn classify_status_result_drops_an_ok_once_cancel_reads_true() {
9314 let cancel = AtomicBool::new(true);
9315
9316 let outcome = classify_status_result(Ok(DirtyCounts::default()), &cancel);
9317
9318 assert!(
9319 outcome.is_none(),
9320 "an Ok value that raced ahead of a cancel flag now set must read as cancelled, \
9321 not be settled Known, got {outcome:?}"
9322 );
9323 }
9324
9325 #[test]
9328 fn classify_status_result_settles_known_when_cancel_never_fired() {
9329 let cancel = AtomicBool::new(false);
9330 let counts = DirtyCounts {
9331 modified: 1,
9332 untracked: 2,
9333 deleted: 3,
9334 };
9335
9336 let outcome = classify_status_result(Ok(counts), &cancel);
9337
9338 assert!(
9339 matches!(
9340 outcome,
9341 Some(Settled::Known {
9342 value,
9343 at: _,
9344 stale: _
9345 }) if value == counts
9346 ),
9347 "a genuine completed read with no cancellation must settle Known, got {outcome:?}"
9348 );
9349 }
9350
9351 #[test]
9357 fn a_linked_worktree_is_its_own_entity_and_never_doubles_as_a_repo() {
9358 let dir = tempfile::tempdir().expect("temp dir");
9359 let root = root_of(&dir);
9360 let parent = root.join("parent");
9361 init_repo_with_a_commit(&parent);
9362 let worktree_path = root.join("feature-worktree");
9363 let status = Command::new("git")
9364 .arg("-C")
9365 .arg(&parent)
9366 .args([
9367 "worktree",
9368 "add",
9369 "-b",
9370 "feature",
9371 worktree_path.to_str().expect("utf8 path"),
9372 ])
9373 .status()
9374 .expect("run git worktree add");
9375 assert!(status.success());
9376
9377 let core = Core::start_discovered(spec(vec![root]));
9378 let snapshot = core.snapshot();
9379
9380 assert_eq!(
9381 snapshot.entities.len(),
9382 2,
9383 "expected the parent plus one Worktree, not two Repos"
9384 );
9385 let repo_count = snapshot
9386 .entities
9387 .iter()
9388 .filter(|entity| matches!(entity.kind, Kind::Repo))
9389 .count();
9390 let worktree_count = snapshot
9391 .entities
9392 .iter()
9393 .filter(|entity| matches!(entity.kind, Kind::Worktree))
9394 .count();
9395 assert_eq!(
9396 repo_count, 1,
9397 "the parent must be counted as exactly one Repo"
9398 );
9399 assert_eq!(
9400 worktree_count, 1,
9401 "the linked worktree must be counted as exactly one Worktree"
9402 );
9403
9404 let worktree_entity = snapshot
9405 .entities
9406 .iter()
9407 .find(|entity| matches!(entity.kind, Kind::Worktree))
9408 .expect("worktree entity present");
9409 let repo_entity = snapshot
9410 .entities
9411 .iter()
9412 .find(|entity| matches!(entity.kind, Kind::Repo))
9413 .expect("repo entity present");
9414 assert_eq!(worktree_entity.common_dir, repo_entity.common_dir);
9415
9416 let repo_branch = core.probe_now(&repo_entity.key);
9419 let worktree_branch = core.probe_now(&worktree_entity.key);
9420 match (
9421 repo_branch.branch.settled(),
9422 worktree_branch.branch.settled(),
9423 ) {
9424 (
9425 Some(Settled::Known {
9426 value:
9427 Head::Branch {
9428 name: repo_name, ..
9429 },
9430 at: _,
9431 stale: _,
9432 }),
9433 Some(Settled::Known {
9434 value:
9435 Head::Branch {
9436 name: worktree_name,
9437 ..
9438 },
9439 at: _,
9440 stale: _,
9441 }),
9442 ) => {
9443 assert_ne!(repo_name, worktree_name);
9444 assert_eq!(&**worktree_name, "feature");
9445 }
9446 other => panic!("expected both entities to read an attached branch, got {other:?}"),
9447 }
9448 }
9449
9450 #[test]
9454 fn a_worktrees_branch_that_is_an_ancestor_of_the_default_branch_reads_merged_after_a_refresh() {
9455 let dir = tempfile::tempdir().expect("temp dir");
9456 let root = root_of(&dir);
9457 let parent = root.join("parent");
9458 init_repo_with_a_commit(&parent);
9459 git(
9460 &parent,
9461 &[
9462 "remote",
9463 "add",
9464 "origin",
9465 "https://example.invalid/repo.git",
9466 ],
9467 );
9468 let sha = head_sha(&parent);
9469 git(&parent, &["update-ref", "refs/remotes/origin/main", &sha]);
9470 let worktree_path = root.join("feature-worktree");
9471 git(
9472 &parent,
9473 &[
9474 "worktree",
9475 "add",
9476 "-b",
9477 "feature",
9478 worktree_path.to_str().expect("utf8 path"),
9479 ],
9480 );
9481
9482 let core = Core::start_discovered(spec(vec![root]));
9483 let keys: Vec<EntityKey> = core
9484 .snapshot()
9485 .entities
9486 .iter()
9487 .map(|entity| entity.key.clone())
9488 .collect();
9489
9490 core.refresh(&keys);
9491 let settled = core.settle();
9492
9493 let worktree_entity = settled
9494 .entities
9495 .iter()
9496 .find(|entity| matches!(entity.kind, Kind::Worktree))
9497 .expect("worktree entity present");
9498 assert!(
9499 matches!(
9500 worktree_entity.state.settled(),
9501 Some(Settled::Known {
9502 value: WorktreeState::Merged,
9503 at: _,
9504 stale: _
9505 })
9506 ),
9507 "expected the worktree, at the same commit as the default branch, to read Merged, got {:?}",
9508 worktree_entity.state.settled()
9509 );
9510 }
9511
9512 #[test]
9521 fn a_squash_merged_worktree_branch_reads_merged_after_a_refresh() {
9522 let dir = tempfile::tempdir().expect("temp dir");
9523 let root = root_of(&dir);
9524 let parent = root.join("parent");
9525 init_repo_with_a_commit(&parent);
9526 git(
9527 &parent,
9528 &[
9529 "remote",
9530 "add",
9531 "origin",
9532 "https://example.invalid/repo.git",
9533 ],
9534 );
9535 let worktree_path = root.join("feature-worktree");
9536 git(
9537 &parent,
9538 &[
9539 "worktree",
9540 "add",
9541 "-b",
9542 "feature",
9543 worktree_path.to_str().expect("utf8 path"),
9544 ],
9545 );
9546 fs::write(worktree_path.join("a.txt"), "one\n").expect("write a.txt");
9547 git(&worktree_path, &["add", "a.txt"]);
9548 git(&worktree_path, &["commit", "-m", "add a"]);
9549 fs::write(worktree_path.join("b.txt"), "two\n").expect("write b.txt");
9550 git(&worktree_path, &["add", "b.txt"]);
9551 git(&worktree_path, &["commit", "-m", "add b"]);
9552 let feature_sha = head_sha(&worktree_path);
9553
9554 git(&parent, &["merge", "--squash", "feature"]);
9557 git(&parent, &["commit", "-m", "squashed feature"]);
9558 let main_sha = head_sha(&parent);
9559 git(
9560 &parent,
9561 &["update-ref", "refs/remotes/origin/main", &main_sha],
9562 );
9563
9564 git(&parent, &["config", "branch.feature.remote", "origin"]);
9567 git(
9568 &parent,
9569 &["config", "branch.feature.merge", "refs/heads/feature"],
9570 );
9571 git(
9572 &parent,
9573 &["update-ref", "refs/remotes/origin/feature", &feature_sha],
9574 );
9575
9576 let core = Core::start_discovered(spec(vec![root]));
9577 let keys: Vec<EntityKey> = core
9578 .snapshot()
9579 .entities
9580 .iter()
9581 .map(|entity| entity.key.clone())
9582 .collect();
9583
9584 core.refresh(&keys);
9585 let settled = core.settle();
9586
9587 let worktree_entity = settled
9588 .entities
9589 .iter()
9590 .find(|entity| matches!(entity.kind, Kind::Worktree))
9591 .expect("worktree entity present");
9592 assert!(
9593 matches!(
9594 worktree_entity.state.settled(),
9595 Some(Settled::Known {
9596 value: WorktreeState::Merged,
9597 at: _,
9598 stale: _
9599 })
9600 ),
9601 "expected a squash-merged worktree branch to read Merged, got {:?}",
9602 worktree_entity.state.settled()
9603 );
9604 }
9605
9606 #[test]
9614 fn patch_equivalence_never_runs_for_an_entity_ancestry_already_settled() {
9615 let dir = tempfile::tempdir().expect("temp dir");
9616 let root = root_of(&dir);
9617 let parent = root.join("parent");
9618 init_repo_with_a_commit(&parent);
9619 git(
9620 &parent,
9621 &[
9622 "remote",
9623 "add",
9624 "origin",
9625 "https://example.invalid/repo.git",
9626 ],
9627 );
9628 let sha = head_sha(&parent);
9629 git(&parent, &["update-ref", "refs/remotes/origin/main", &sha]);
9630 let worktree_path = root.join("feature-worktree");
9631 git(
9632 &parent,
9633 &[
9634 "worktree",
9635 "add",
9636 "-b",
9637 "feature",
9638 worktree_path.to_str().expect("utf8 path"),
9639 ],
9640 );
9641
9642 let (core, launched) = started_and_settled(spec(vec![root]));
9643 let keys: Vec<EntityKey> = launched
9644 .entities
9645 .iter()
9646 .map(|entity| entity.key.clone())
9647 .collect();
9648
9649 core.refresh(&keys);
9650 let settled = core.settle();
9651
9652 let worktree_entity = settled
9653 .entities
9654 .iter()
9655 .find(|entity| matches!(entity.kind, Kind::Worktree))
9656 .expect("worktree entity present");
9657 assert!(
9658 matches!(
9659 worktree_entity.state.settled(),
9660 Some(Settled::Known {
9661 value: WorktreeState::Merged,
9662 at: _,
9663 stale: _
9664 })
9665 ),
9666 "expected ancestry alone to settle Merged here, got {:?}",
9667 worktree_entity.state.settled()
9668 );
9669 assert_eq!(
9670 core.patch_identity_reads_for_test(),
9671 0,
9672 "ancestry already settled this entity, so patch equivalence's shared \
9673 scan must never run for its common dir at all"
9674 );
9675 }
9676
9677 #[test]
9685 fn a_full_refresh_reaching_patch_equivalence_writes_no_loose_objects() {
9686 let dir = tempfile::tempdir().expect("temp dir");
9687 let root = root_of(&dir);
9688 let parent = root.join("parent");
9689 init_repo_with_a_commit(&parent);
9690 git(
9691 &parent,
9692 &[
9693 "remote",
9694 "add",
9695 "origin",
9696 "https://example.invalid/repo.git",
9697 ],
9698 );
9699 let worktree_path = root.join("feature-worktree");
9700 git(
9701 &parent,
9702 &[
9703 "worktree",
9704 "add",
9705 "-b",
9706 "feature",
9707 worktree_path.to_str().expect("utf8 path"),
9708 ],
9709 );
9710 fs::write(worktree_path.join("a.txt"), "one\n").expect("write a.txt");
9711 git(&worktree_path, &["add", "a.txt"]);
9712 git(&worktree_path, &["commit", "-m", "add a"]);
9713 fs::write(worktree_path.join("b.txt"), "two\n").expect("write b.txt");
9714 git(&worktree_path, &["add", "b.txt"]);
9715 git(&worktree_path, &["commit", "-m", "add b"]);
9716 let feature_sha = head_sha(&worktree_path);
9717
9718 git(&parent, &["merge", "--squash", "feature"]);
9719 git(&parent, &["commit", "-m", "squashed feature"]);
9720 let main_sha = head_sha(&parent);
9721 git(
9722 &parent,
9723 &["update-ref", "refs/remotes/origin/main", &main_sha],
9724 );
9725 git(&parent, &["config", "branch.feature.remote", "origin"]);
9726 git(
9727 &parent,
9728 &["config", "branch.feature.merge", "refs/heads/feature"],
9729 );
9730 git(
9731 &parent,
9732 &["update-ref", "refs/remotes/origin/feature", &feature_sha],
9733 );
9734
9735 let core = Core::start_discovered(spec(vec![root]));
9736 let keys: Vec<EntityKey> = core
9737 .snapshot()
9738 .entities
9739 .iter()
9740 .map(|entity| entity.key.clone())
9741 .collect();
9742
9743 let before = loose_object_count(&parent);
9744 core.refresh(&keys);
9745 let settled = core.settle();
9746 let after = loose_object_count(&parent);
9747
9748 let worktree_entity = settled
9749 .entities
9750 .iter()
9751 .find(|entity| matches!(entity.kind, Kind::Worktree))
9752 .expect("worktree entity present");
9753 assert!(
9754 matches!(
9755 worktree_entity.state.settled(),
9756 Some(Settled::Known {
9757 value: WorktreeState::Merged,
9758 at: _,
9759 stale: _
9760 })
9761 ),
9762 "expected this refresh to actually reach patch equivalence and settle \
9763 Merged, got {:?}",
9764 worktree_entity.state.settled()
9765 );
9766 assert_eq!(
9767 before, after,
9768 "a full refresh reaching patch equivalence must never write a loose \
9769 object to the repository"
9770 );
9771 }
9772
9773 #[test]
9781 fn a_diverged_worktree_with_a_live_upstream_and_genuinely_unmerged_work_settles_active_after_a_refresh()
9782 {
9783 let dir = tempfile::tempdir().expect("temp dir");
9784 let root = root_of(&dir);
9785 let parent = root.join("parent");
9786 init_repo_with_a_commit(&parent);
9787 let base_sha = head_sha(&parent);
9788 git(
9789 &parent,
9790 &[
9791 "remote",
9792 "add",
9793 "origin",
9794 "https://example.invalid/repo.git",
9795 ],
9796 );
9797 git(
9798 &parent,
9799 &["update-ref", "refs/remotes/origin/main", &base_sha],
9800 );
9801 let worktree_path = root.join("feature-worktree");
9802 git(
9803 &parent,
9804 &[
9805 "worktree",
9806 "add",
9807 "-b",
9808 "feature",
9809 worktree_path.to_str().expect("utf8 path"),
9810 ],
9811 );
9812 fs::write(worktree_path.join("feature.txt"), "unmerged work\n").expect("write feature.txt");
9815 git(&worktree_path, &["add", "feature.txt"]);
9816 git(&worktree_path, &["commit", "-m", "unmerged"]);
9817 let feature_sha = head_sha(&worktree_path);
9818 git(&parent, &["config", "branch.feature.remote", "origin"]);
9821 git(
9822 &parent,
9823 &["config", "branch.feature.merge", "refs/heads/feature"],
9824 );
9825 git(
9826 &parent,
9827 &["update-ref", "refs/remotes/origin/feature", &feature_sha],
9828 );
9829
9830 let core = Core::start_discovered(spec(vec![root]));
9831 let keys: Vec<EntityKey> = core
9832 .snapshot()
9833 .entities
9834 .iter()
9835 .map(|entity| entity.key.clone())
9836 .collect();
9837
9838 core.refresh(&keys);
9839 let settled = core.settle();
9840
9841 let worktree_entity = settled
9842 .entities
9843 .iter()
9844 .find(|entity| matches!(entity.kind, Kind::Worktree))
9845 .expect("worktree entity present");
9846 assert!(
9847 matches!(
9848 worktree_entity.state.settled(),
9849 Some(Settled::Known {
9850 value: WorktreeState::Active,
9851 at: _,
9852 stale: _
9853 })
9854 ),
9855 "expected genuinely unmerged work with a live upstream to settle Active, got {:?}",
9856 worktree_entity.state.settled()
9857 );
9858 }
9859
9860 #[test]
9867 fn a_submodule_is_in_the_snapshot_even_though_hidden_by_the_default_preference() {
9868 let dir = tempfile::tempdir().expect("temp dir");
9869 let root = root_of(&dir);
9870 let parent = root.join("parent");
9871 init_repo_with_a_commit(&parent);
9872 fs::write(
9873 parent.join(".gitmodules"),
9874 "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
9875 )
9876 .expect("write .gitmodules");
9877 fs::create_dir_all(parent.join("vendor").join("lib")).expect("create submodule dir");
9878
9879 let core = Core::start_discovered(spec(vec![root]));
9880 let snapshot = core.snapshot();
9881
9882 assert!(
9883 snapshot
9884 .entities
9885 .iter()
9886 .any(|entity| matches!(entity.kind, Kind::Submodule)),
9887 "a discovered Submodule must be in the snapshot even while show_submodules is off"
9888 );
9889 }
9890
9891 #[test]
9901 fn a_submodules_state_and_base_cells_stay_unknown_through_a_real_refresh() {
9902 let dir = tempfile::tempdir().expect("temp dir");
9903 let root = root_of(&dir);
9904 let parent = root.join("parent");
9905 init_repo_with_a_commit(&parent);
9906 fs::write(
9907 parent.join(".gitmodules"),
9908 "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
9909 )
9910 .expect("write .gitmodules");
9911 let submodule = parent.join("vendor").join("lib");
9912 init_repo_with_a_commit(&submodule);
9913 git(
9914 &submodule,
9915 &["remote", "add", "origin", "https://example.invalid/lib.git"],
9916 );
9917 let root_sha = head_sha(&submodule);
9918 git(&submodule, &["commit", "--allow-empty", "-m", "second"]);
9919 let tip_sha = head_sha(&submodule);
9920 git(&submodule, &["reset", "--hard", &root_sha]);
9921 git(
9922 &submodule,
9923 &["update-ref", "refs/remotes/origin/main", &tip_sha],
9924 );
9925
9926 let mut core_spec = spec(vec![root]);
9929 core_spec.show_submodules = true;
9930 let core = Core::start_discovered(core_spec);
9931 let key = core
9932 .snapshot()
9933 .entities
9934 .iter()
9935 .find(|entity| matches!(entity.kind, Kind::Submodule))
9936 .expect("a discovered Submodule")
9937 .key
9938 .clone();
9939
9940 core.refresh(std::slice::from_ref(&key));
9941 let settled = core.settle();
9942 let submodule_entity = settled
9943 .entities
9944 .iter()
9945 .find(|entity| entity.key == key)
9946 .expect("the Submodule entity");
9947
9948 assert!(
9949 matches!(
9950 submodule_entity.base.settled(),
9951 Some(Settled::Unknown(Unknown::NoDefaultBranch))
9952 ),
9953 "expected a Submodule's base to stay Unknown through a real refresh, \
9954 got {:?}",
9955 submodule_entity.base.settled()
9956 );
9957 assert!(
9958 matches!(
9959 submodule_entity.state.settled(),
9960 Some(Settled::Unknown(Unknown::NoDefaultBranch))
9961 ),
9962 "expected a Submodule's state to stay Unknown through a real refresh, \
9963 rather than settling Merged off an untrusted default branch, got {:?}",
9964 submodule_entity.state.settled()
9965 );
9966 }
9967
9968 #[test]
9973 fn a_submodules_entity_name_is_its_relative_path_not_its_basename() {
9974 let dir = tempfile::tempdir().expect("temp dir");
9975 let root = root_of(&dir);
9976 let parent = root.join("parent");
9977 init_repo_with_a_commit(&parent);
9978 fs::write(
9979 parent.join(".gitmodules"),
9980 "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
9981 )
9982 .expect("write .gitmodules");
9983 fs::create_dir_all(parent.join("vendor").join("lib")).expect("create submodule dir");
9984
9985 let core = Core::start_discovered(spec(vec![root]));
9986 let submodule = core
9987 .snapshot()
9988 .entities
9989 .into_iter()
9990 .find(|entity| matches!(entity.kind, Kind::Submodule))
9991 .expect("a discovered Submodule");
9992
9993 assert_eq!(
9994 submodule.name.as_ref(),
9995 "vendor/lib",
9996 "expected the declared relative path, not the basename `lib`"
9997 );
9998 }
9999
10000 #[test]
10009 fn an_uninitialised_submodules_probed_cells_settle_unknown_not_failed() {
10010 let dir = tempfile::tempdir().expect("temp dir");
10011 let root = root_of(&dir);
10012 let parent = root.join("parent");
10013 init_repo_with_a_commit(&parent);
10014 fs::write(
10015 parent.join(".gitmodules"),
10016 "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
10017 )
10018 .expect("write .gitmodules");
10019 let mut core_spec = spec(vec![root]);
10023 core_spec.show_submodules = true;
10024 let core = Core::start_discovered(core_spec);
10025 let key = core
10026 .snapshot()
10027 .entities
10028 .iter()
10029 .find(|entity| matches!(entity.kind, Kind::Submodule))
10030 .expect("a discovered Submodule")
10031 .key
10032 .clone();
10033
10034 core.refresh(std::slice::from_ref(&key));
10035 let settled = core.settle();
10036 let submodule = settled
10037 .entities
10038 .iter()
10039 .find(|entity| entity.key == key)
10040 .expect("the Submodule entity");
10041
10042 assert!(
10043 matches!(
10044 submodule.branch.settled(),
10045 Some(Settled::Unknown(Unknown::SubmoduleUninitialized))
10046 ),
10047 "expected branch to settle Unknown(SubmoduleUninitialized), got {:?}",
10048 submodule.branch.settled()
10049 );
10050 assert!(
10051 matches!(
10052 submodule.sync.settled(),
10053 Some(Settled::Unknown(Unknown::SubmoduleUninitialized))
10054 ),
10055 "expected sync to settle Unknown(SubmoduleUninitialized), got {:?}",
10056 submodule.sync.settled()
10057 );
10058 assert!(
10059 matches!(
10060 submodule.dirty.settled(),
10061 Some(Settled::Unknown(Unknown::SubmoduleUninitialized))
10062 ),
10063 "expected dirty to settle Unknown(SubmoduleUninitialized), got {:?}",
10064 submodule.dirty.settled()
10065 );
10066 assert_eq!(
10067 summary(submodule),
10068 RowSummary::Unknown,
10069 "expected the row's own gutter fold to read Unknown, not Failed"
10070 );
10071 }
10072
10073 #[test]
10079 fn dispatch_skips_probing_a_hidden_submodule_while_probing_the_same_one_shown() {
10080 let dir = tempfile::tempdir().expect("temp dir");
10081 let root = root_of(&dir);
10082 let parent = root.join("parent");
10083 init_repo_with_a_commit(&parent);
10084 fs::write(
10085 parent.join(".gitmodules"),
10086 "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
10087 )
10088 .expect("write .gitmodules");
10089 init_repo_with_a_commit(&parent.join("vendor").join("lib"));
10090
10091 let core = Core::start_discovered(spec(vec![root]));
10093 let key = core
10094 .snapshot()
10095 .entities
10096 .iter()
10097 .find(|entity| matches!(entity.kind, Kind::Submodule))
10098 .expect("a discovered Submodule")
10099 .key
10100 .clone();
10101
10102 core.refresh(std::slice::from_ref(&key));
10104 let while_hidden = core.settle();
10105 let hidden_entity = while_hidden
10106 .entities
10107 .iter()
10108 .find(|entity| entity.key == key)
10109 .expect("submodule entity");
10110 assert!(
10111 hidden_entity.branch.settled().is_none(),
10112 "a Submodule dispatched while hidden must never even reach probe_branch, \
10113 so its cell stays never-settled rather than holding any value at all, got {:?}",
10114 hidden_entity.branch.settled()
10115 );
10116
10117 core.set_show_submodules(true);
10121 core.refresh(std::slice::from_ref(&key));
10122 let while_shown = core.settle();
10123 let shown_entity = while_shown
10124 .entities
10125 .iter()
10126 .find(|entity| entity.key == key)
10127 .expect("submodule entity");
10128 assert!(
10129 matches!(
10130 shown_entity.branch.settled(),
10131 Some(Settled::Known {
10132 value: _,
10133 at: _,
10134 stale: _
10135 })
10136 ),
10137 "expected the same Submodule's branch to settle a real value once shown, got {:?}",
10138 shown_entity.branch.settled()
10139 );
10140 }
10141
10142 #[test]
10148 fn toggling_show_submodules_starts_no_new_generation_and_dispatches_nothing() {
10149 let dir = tempfile::tempdir().expect("temp dir");
10150 let root = root_of(&dir);
10151 init_repo_with_a_commit(&root.join("repo-a"));
10152
10153 let (core, launched) = started_and_settled(spec(vec![root]));
10155 let before = launched.generation;
10156 let dispatched_before = core.dispatch_log_for_test();
10157 assert!(
10158 !dispatched_before.is_empty(),
10159 "launch dispatched nothing, so the comparison below would hold however much a \
10160 toggle dispatched"
10161 );
10162
10163 core.set_show_submodules(true);
10164 core.set_show_submodules(false);
10165
10166 assert_eq!(
10167 core.snapshot().generation,
10168 before,
10169 "toggling show_submodules must start no Generation of its own"
10170 );
10171 assert_eq!(
10172 core.dispatch_log_for_test(),
10173 dispatched_before,
10174 "toggling show_submodules must dispatch no probe of its own, leaving the last \
10175 Generation's own log exactly as it found it"
10176 );
10177 }
10178
10179 #[test]
10186 fn a_malformed_gitmodules_file_still_fails_the_parent_while_submodules_are_hidden() {
10187 let dir = tempfile::tempdir().expect("temp dir");
10188 let root = root_of(&dir);
10189 let parent = root.join("parent");
10190 init_repo_with_a_commit(&parent);
10191 fs::write(
10192 parent.join(".gitmodules"),
10193 "[submodule \"lib\"\n\tpath = lib\n",
10194 )
10195 .expect("write malformed .gitmodules");
10196
10197 let core = Core::start_discovered(spec(vec![root]));
10198 let key = core
10199 .snapshot()
10200 .entities
10201 .iter()
10202 .find(|entity| entity.key.path() == parent)
10203 .expect("the parent entity")
10204 .key
10205 .clone();
10206 core.refresh(std::slice::from_ref(&key));
10210 let settled = core.settle();
10211 let parent_entity = settled
10212 .entities
10213 .iter()
10214 .find(|entity| entity.key == key)
10215 .expect("the parent entity");
10216
10217 assert_eq!(
10218 summary(parent_entity),
10219 RowSummary::Failed,
10220 "expected the parent to fold Failed even with Submodules hidden"
10221 );
10222 assert!(
10223 parent_entity.diagnostics.gitmodules_failed.is_some(),
10224 "expected the failure recorded in Diagnostics for the detail pane"
10225 );
10226 assert!(
10227 !settled
10228 .entities
10229 .iter()
10230 .any(|entity| matches!(entity.kind, Kind::Submodule)),
10231 "an unparseable .gitmodules yields no Submodule rows for that parent"
10232 );
10233 }
10234
10235 #[test]
10236 fn count_matches_a_plain_discoverys_entity_count() {
10237 let dir = tempfile::tempdir().expect("temp dir");
10238 let root = root_of(&dir);
10239 init_repo_with_a_commit(&root.join("one"));
10240 init_repo_with_a_commit(&root.join("two"));
10241
10242 let set = SetSpec {
10243 name: "test".to_string(),
10244 roots: vec![root],
10245 include: Vec::new(),
10246 exclude: Vec::new(),
10247 };
10248
10249 assert_eq!(discovery::count(&set), 2);
10250 }
10251
10252 #[test]
10253 fn the_slow_discovery_watcher_warns_with_the_count_reached_and_the_roots() {
10254 let progress = Arc::new(AtomicUsize::new(42));
10255 let finished = Arc::new(AtomicBool::new(false));
10256 let roots = vec![PathBuf::from("/repos/a"), PathBuf::from("/repos/b")];
10257
10258 let warning = watch_for_slow_discovery(progress, finished, roots, Duration::from_millis(1));
10259
10260 let message = warning.expect("a walk that has not finished should warn");
10261 assert!(message.contains("42"));
10262 assert!(message.contains("/repos/a"));
10263 assert!(message.contains("/repos/b"));
10264 }
10265
10266 #[test]
10267 fn the_slow_discovery_watcher_is_silent_once_the_walk_has_already_finished() {
10268 let progress = Arc::new(AtomicUsize::new(7));
10269 let finished = Arc::new(AtomicBool::new(true));
10270
10271 let warning =
10272 watch_for_slow_discovery(progress, finished, Vec::new(), Duration::from_millis(1));
10273
10274 assert!(warning.is_none());
10275 }
10276
10277 #[test]
10285 fn a_fast_discovery_leaves_no_warning_once_the_watcher_has_run() {
10286 let dir = tempfile::tempdir().expect("temp dir");
10287 let root = root_of(&dir);
10288 init_repo_with_a_commit(&root.join("repo"));
10289 let (_tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
10290
10291 let started =
10292 Core::start_for_test(spec(vec![root]), Duration::from_secs(1), tick_rx).discovered();
10293 started
10294 .discovery_watcher
10295 .join()
10296 .expect("watcher thread should not panic");
10297
10298 assert!(started.core.discovery_warning().is_none());
10299 }
10300
10301 fn gate_opened_on_signal(open: bool) -> (DiscoveryGate, Sender<()>, JoinHandle<()>) {
10309 let gate: DiscoveryGate = Arc::new((Mutex::new(open), Condvar::new()));
10310 let (returned_tx, returned_rx) = crossbeam_channel::bounded::<()>(1);
10311 let opener = thread::spawn({
10312 let gate = Arc::clone(&gate);
10313 move || {
10314 let _ = returned_rx.recv_timeout(crate::liveness::BACKSTOP);
10315 set_discovery_gate(&gate, true);
10316 }
10317 });
10318 (gate, returned_tx, opener)
10319 }
10320
10321 #[test]
10333 fn start_returns_against_an_empty_table_and_the_rows_land_when_discovery_does() {
10334 let dir = tempfile::tempdir().expect("temp dir");
10335 let root = root_of(&dir);
10336 let repo = root.join("repo");
10337 init_repo_with_a_commit(&repo);
10338 let (_tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
10339 let (gate, start_returned, opener) = gate_opened_on_signal(false);
10340
10341 let started = Core::start_for_test_gated(
10342 spec(vec![root]),
10343 Duration::from_secs(3600),
10344 discovery::ABANDON_AFTER,
10345 tick_rx,
10346 Some(Arc::clone(&gate)),
10347 );
10348 let at_start = started.core.snapshot();
10349 let key = EntityKey::new(Arc::from(repo.as_path()));
10350 started.core.hold_phase_c_for_test(&key);
10351 start_returned.send(()).expect("the opener is listening");
10352 opener.join().expect("the opener thread should not panic");
10353 let started = started.discovered();
10354
10355 assert!(
10356 at_start.entities.is_empty(),
10357 "`Core::start` must return before discovery has finished, against the empty \
10358 table a consumer draws its first frame from, got {:?}",
10359 at_start
10360 .entities
10361 .iter()
10362 .map(|entity| entity.name.to_string())
10363 .collect::<Vec<_>>()
10364 );
10365
10366 let landed = started.core.snapshot();
10367 assert_eq!(
10368 landed
10369 .entities
10370 .iter()
10371 .map(|entity| entity.name.to_string())
10372 .collect::<Vec<_>>(),
10373 vec!["repo".to_string()],
10374 "the row must land on the table as soon as discovery does"
10375 );
10376 assert!(
10377 landed.entities[0].dirty.settled().is_none() && landed.entities[0].dirty.is_in_flight(),
10378 "discovery lands the row alone: launch's own Generation is already covering it \
10379 and its Cells stay unsettled until that Generation answers, which is what the \
10380 spinner sits behind"
10381 );
10382
10383 started.core.release_phase_c_for_test(&key);
10384 started.core.wait_phase_c_finished_for_test(&key);
10385 }
10386
10387 #[test]
10396 fn refresh_all_covers_every_row_its_own_discovery_found() {
10397 let dir = tempfile::tempdir().expect("temp dir");
10398 let root = root_of(&dir);
10399 init_repo_with_a_commit(&root.join("repo"));
10400
10401 let (core, launched) = started_and_settled(spec(vec![root.clone()]));
10402 assert_eq!(
10403 launched
10404 .entities
10405 .iter()
10406 .map(|entity| entity.name.to_string())
10407 .collect::<Vec<_>>(),
10408 vec!["repo".to_string()],
10409 "launch's own walk must have landed and covered exactly the one row that \
10410 existed when it ran"
10411 );
10412 init_repo_with_a_commit(&root.join("late"));
10416
10417 assert_eq!(
10418 core.refresh_all(),
10419 launched.generation.successor(),
10420 "`refresh_all` must be the Generation immediately after the one already on the \
10421 table"
10422 );
10423 let settled = core.settle();
10424
10425 let mut named: Vec<String> = settled
10426 .entities
10427 .iter()
10428 .filter(|entity| entity.branch.settled().is_some())
10429 .map(|entity| entity.name.to_string())
10430 .collect();
10431 named.sort();
10432 assert_eq!(
10433 named,
10434 vec!["late".to_string(), "repo".to_string()],
10435 "the Generation must cover every row its own discovery found, including one the \
10436 caller had no key for"
10437 );
10438 }
10439
10440 #[test]
10450 fn refresh_returns_before_its_own_generations_discovery_has_run() {
10451 let dir = tempfile::tempdir().expect("temp dir");
10452 let root = root_of(&dir);
10453 init_repo_with_a_commit(&root.join("repo"));
10454 let (_tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
10455 let (gate, walk_may_run, opener) = gate_opened_on_signal(true);
10456
10457 let started = Core::start_for_test_gated(
10458 spec(vec![root.clone()]),
10459 Duration::from_secs(3600),
10460 discovery::ABANDON_AFTER,
10461 tick_rx,
10462 Some(Arc::clone(&gate)),
10463 )
10464 .discovered();
10465 let core = started.core;
10466 let launched = settle_launch(&core);
10468 let keys: Vec<EntityKey> = launched
10469 .entities
10470 .iter()
10471 .map(|entity| entity.key.clone())
10472 .collect();
10473 init_repo_with_a_commit(&root.join("late"));
10474
10475 set_discovery_gate(&gate, false);
10476 let generation = core.refresh(&keys);
10477 let while_held = core.snapshot();
10478 let dispatched_while_held = core.settle_gate_count_for_test();
10479 walk_may_run.send(()).expect("the opener is listening");
10480 opener.join().expect("the opener thread should not panic");
10481
10482 assert_eq!(
10483 generation,
10484 launched.generation.successor(),
10485 "`refresh` must return its own Generation's number, the one immediately after \
10486 the table's, before that Generation has done any of its work"
10487 );
10488 assert!(
10489 !while_held
10490 .entities
10491 .iter()
10492 .any(|entity| &*entity.name == "late"),
10493 "`refresh` must return before its own Generation's walk has run, so a Repo \
10494 created after the previous walk is not on the table it returned against"
10495 );
10496 assert_eq!(
10497 dispatched_while_held, 0,
10498 "`refresh` returned before its Generation reached the table at all, so nothing \
10499 is dispatched yet"
10500 );
10501
10502 core.wait_dispatched_for_test();
10503 let settled = core.settle();
10504
10505 assert!(
10506 settled
10507 .entities
10508 .iter()
10509 .any(|entity| &*entity.name == "late"),
10510 "the deferred Generation must still run its own walk once it is let through: \
10511 deferred, never dropped"
10512 );
10513 }
10514
10515 #[test]
10526 fn a_dispatch_body_waits_for_every_earlier_reserved_generation() {
10527 let turnstile = Arc::new(DispatchTurnstile::default());
10528 let earlier = turnstile.reserve();
10529 let later = turnstile.reserve();
10530 let order = Arc::new(Mutex::new(Vec::new()));
10531
10532 let earlier_body = thread::spawn({
10533 let turnstile = Arc::clone(&turnstile);
10534 let order = Arc::clone(&order);
10535 move || {
10536 let _turn = turnstile.take(earlier);
10537 order.lock().unwrap().push(earlier);
10538 }
10539 });
10540
10541 {
10542 let _turn = turnstile.take(later);
10543 order.lock().unwrap().push(later);
10544 }
10545 earlier_body
10546 .join()
10547 .expect("the earlier body should not panic");
10548
10549 assert_eq!(
10550 *order.lock().unwrap(),
10551 vec![earlier, later],
10552 "a dispatch body must run in the order its Generation was reserved"
10553 );
10554 }
10555
10556 #[test]
10561 fn run_while_not_cancelled_stops_at_the_next_check_rather_than_running_forever() {
10562 let cancel = Arc::new(AtomicBool::new(false));
10563 let worker_cancel = Arc::clone(&cancel);
10564 let (step_started_tx, step_started_rx) = crossbeam_channel::bounded::<()>(0);
10565 let (proceed_tx, proceed_rx) = crossbeam_channel::bounded::<()>(0);
10566
10567 let worker = thread::spawn(move || {
10568 run_while_not_cancelled(&worker_cancel, || {
10569 step_started_tx.send(()).expect("test should be listening");
10570 proceed_rx.recv().is_ok()
10571 })
10572 });
10573
10574 for _ in 0..2 {
10575 step_started_rx
10576 .recv()
10577 .expect("worker should announce each step");
10578 proceed_tx.send(()).expect("let the step finish");
10579 }
10580 step_started_rx
10581 .recv()
10582 .expect("worker should announce its third step");
10583 cancel.store(true, Ordering::Release);
10584 proceed_tx.send(()).expect("let the third step finish");
10585
10586 let ran = worker.join().expect("worker thread should not panic");
10587
10588 assert_eq!(
10589 ran, 3,
10590 "expected cancellation to stop the loop after its third step"
10591 );
10592 }
10593
10594 fn benchmark_identity_phase(
10602 population: Vec<crate::discovery::DiscoveredEntity>,
10603 ) -> (Duration, Vec<Duration>) {
10604 let (tx, rx) = crossbeam_channel::unbounded();
10605 let started = Instant::now();
10606 crate::fanout::scatter(population, tx, |entity| {
10607 let task_started = Instant::now();
10608 let repo = match &entity.repo {
10609 Some(repo) => repo.to_thread_local(),
10610 None => match git::open_thread_safe(entity.key.path()) {
10611 Ok(repo) => repo.to_thread_local(),
10612 Err(_) => return None,
10613 },
10614 };
10615 let _ = git::head_shape(&repo);
10616 Some(task_started.elapsed())
10617 });
10618 let wall = started.elapsed();
10619 let durations: Vec<Duration> = rx.into_iter().flatten().collect();
10620 (wall, durations)
10621 }
10622
10623 fn real_corpus_roots() -> Vec<PathBuf> {
10627 let Some(home) = std::env::var_os("HOME") else {
10628 return Vec::new();
10629 };
10630 let home = PathBuf::from(home);
10631 ["dev", "dev-misc"]
10632 .into_iter()
10633 .map(|leaf| home.join(leaf))
10634 .filter(|root| root.is_dir())
10635 .collect()
10636 }
10637
10638 fn generated_fixture_corpus(size: usize) -> tempfile::TempDir {
10643 let root = tempfile::tempdir().expect("temp dir for generated fixture corpus");
10644 for i in 0..size {
10645 let repo = root.path().join(format!("fixture-repo-{i}"));
10646 fs::create_dir_all(&repo).expect("create fixture repo dir");
10647 gix::init(&repo).expect("init fixture repo");
10648 let status = Command::new("git")
10649 .arg("-C")
10650 .arg(&repo)
10651 .args(["-c", "user.email=test@example.com", "-c", "user.name=Test"])
10652 .args(["commit", "--allow-empty", "-m", &format!("commit {i}")])
10653 .status()
10654 .expect("run git commit");
10655 assert!(status.success());
10656 }
10657 root
10658 }
10659
10660 fn percentile(sorted: &[Duration], p: usize) -> Duration {
10662 let index = (sorted.len() - 1) * p / 100;
10663 sorted[index]
10664 }
10665
10666 fn extra_excluded_names() -> Vec<String> {
10673 parse_excluded_names(&std::env::var("REPON_BENCHMARK_EXCLUDE_NAMES").unwrap_or_default())
10674 }
10675
10676 fn parse_excluded_names(raw: &str) -> Vec<String> {
10681 raw.split(',')
10682 .map(str::trim)
10683 .filter(|name| !name.is_empty())
10684 .map(str::to_string)
10685 .collect()
10686 }
10687
10688 fn discover_population(
10696 roots: Vec<PathBuf>,
10697 excluded_names: &[String],
10698 ) -> (Vec<crate::discovery::DiscoveredEntity>, Duration) {
10699 let set = SetSpec {
10700 name: "identity-probe-benchmark".to_string(),
10701 roots,
10702 include: Vec::new(),
10703 exclude: Vec::new(),
10704 };
10705 let started = Instant::now();
10706 let discovery = discovery::discover(&set);
10707 let (discovered, _) = discovery::resolve(&set, &discovery.entities);
10708 let elapsed = started.elapsed();
10709 let population = discovered
10710 .into_iter()
10711 .filter(|entity| {
10712 !entity.key.path().components().any(|component| {
10713 excluded_names
10714 .iter()
10715 .any(|name| component.as_os_str() == name.as_str())
10716 })
10717 })
10718 .collect();
10719 (population, elapsed)
10720 }
10721
10722 #[test]
10726 fn a_boundary_whose_path_matches_an_excluded_name_is_left_out_of_the_population() {
10727 let fixture = generated_fixture_corpus(3);
10728 let excluded = vec!["fixture-repo-1".to_string()];
10729
10730 let (population, _) = discover_population(vec![fixture.path().to_path_buf()], &excluded);
10731
10732 assert_eq!(population.len(), 2);
10733 assert!(
10734 population
10735 .iter()
10736 .all(|entity| entity.key.path().file_name().unwrap() != "fixture-repo-1"),
10737 "the excluded name must never appear in the population discovery returns"
10738 );
10739 }
10740
10741 #[test]
10742 fn excluded_names_parses_a_comma_separated_list_and_ignores_blanks() {
10743 assert_eq!(
10744 parse_excluded_names("foo, bar ,,baz"),
10745 vec!["foo".to_string(), "bar".to_string(), "baz".to_string()]
10746 );
10747 assert!(parse_excluded_names("").is_empty());
10748 assert!(parse_excluded_names(" ").is_empty());
10749 }
10750
10751 #[test]
10766 #[ignore = "hand-run against the owner's real corpus; see docs/spec/refresh.md for the recorded figures"]
10767 fn identity_probe_benchmark() {
10768 let excluded_names = extra_excluded_names();
10769
10770 let mut _fixture: Option<tempfile::TempDir> = None;
10774
10775 let (real_population, real_discovery_wall) =
10776 discover_population(real_corpus_roots(), &excluded_names);
10777 let (population, using_fixture, discovery_wall) = if real_population.len() >= 20 {
10778 (real_population, false, real_discovery_wall)
10779 } else {
10780 println!(
10781 "real corpus absent or too small to be meaningful ({} entities); \
10782 using a generated fixture instead",
10783 real_population.len()
10784 );
10785 let fixture = generated_fixture_corpus(300);
10786 let (population, fixture_discovery_wall) =
10787 discover_population(vec![fixture.path().to_path_buf()], &excluded_names);
10788 _fixture = Some(fixture);
10789 (population, true, fixture_discovery_wall)
10790 };
10791
10792 let population_size = population.len();
10793 assert!(
10794 population_size > 0,
10795 "neither a real corpus root nor the generated fixture produced any entities"
10796 );
10797
10798 let (wall, mut durations) = benchmark_identity_phase(population);
10799 durations.sort();
10800
10801 println!(
10802 "identity probe benchmark: corpus = {}, population = {population_size}",
10803 if using_fixture {
10804 "generated fixture"
10805 } else {
10806 "real corpus"
10807 }
10808 );
10809 println!(
10810 "discovery + first open (serial, every entity's own gix::open): {discovery_wall:?}"
10811 );
10812 println!("identity phase, warm, parallel (HEAD re-read from the cached handle): {wall:?}");
10813 println!(
10814 "identity phase per entity: p50 {:?}, p90 {:?}, max {:?}",
10815 percentile(&durations, 50),
10816 percentile(&durations, 90),
10817 durations.last().copied().unwrap_or_default(),
10818 );
10819 }
10820
10821 fn spec_with_overrides(roots: Vec<PathBuf>, overrides: Vec<RepoOverride>) -> CoreSpec {
10822 let mut spec = spec(roots);
10823 spec.overrides = overrides;
10824 spec
10825 }
10826
10827 #[test]
10832 fn a_per_repo_override_resolves_the_default_branch_at_rung_one_through_a_real_refresh() {
10833 let dir = tempfile::tempdir().expect("temp dir");
10834 let root = root_of(&dir);
10835 let repo = root.join("repo");
10836 init_repo_with_a_commit(&repo);
10837 git(
10838 &repo,
10839 &[
10840 "remote",
10841 "add",
10842 "origin",
10843 "https://example.invalid/repo.git",
10844 ],
10845 );
10846 let sha = head_sha(&repo);
10847 git(&repo, &["update-ref", "refs/remotes/origin/main", &sha]);
10848 let remote_refs_dir = repo
10849 .join(".git")
10850 .join("refs")
10851 .join("remotes")
10852 .join("origin");
10853 fs::create_dir_all(&remote_refs_dir).expect("create refs/remotes/origin dir");
10854 fs::write(
10855 remote_refs_dir.join("HEAD"),
10856 "ref: refs/remotes/origin/main\n",
10857 )
10858 .expect("write HEAD");
10859
10860 let core = Core::start_discovered(spec_with_overrides(
10861 vec![root],
10862 vec![RepoOverride {
10863 path: repo.clone(),
10864 default_branch: Some("develop".to_string()),
10865 excluded: false,
10866 }],
10867 ));
10868 let key = core.snapshot().entities[0].key.clone();
10869
10870 core.refresh(std::slice::from_ref(&key));
10871 let settled = core.settle();
10872 let entity = &settled.entities[0];
10873
10874 match entity.default_branch.settled() {
10875 Some(Settled::Known {
10876 value,
10877 at: _,
10878 stale: _,
10879 }) => assert_eq!(
10880 value.name(),
10881 "origin/develop",
10882 "the override must win even though origin/HEAD names a different branch"
10883 ),
10884 other => panic!("expected the override's own answer, got {other:?}"),
10885 }
10886 assert_eq!(
10887 entity.diagnostics.default_branch_rung,
10888 Some(1),
10889 "an override must be recorded as rung 1"
10890 );
10891 }
10892
10893 #[test]
10897 fn a_per_repo_override_also_resolves_through_probe_now() {
10898 let dir = tempfile::tempdir().expect("temp dir");
10899 let root = root_of(&dir);
10900 let repo = root.join("repo");
10901 init_repo_with_a_commit(&repo);
10902
10903 let core = Core::start_discovered(spec_with_overrides(
10904 vec![root],
10905 vec![RepoOverride {
10906 path: repo.clone(),
10907 default_branch: Some("release".to_string()),
10908 excluded: false,
10909 }],
10910 ));
10911 let key = core.snapshot().entities[0].key.clone();
10912
10913 let entity = core.probe_now(&key);
10914
10915 match entity.default_branch.settled() {
10916 Some(Settled::Known {
10918 value,
10919 at: _,
10920 stale: _,
10921 }) => assert_eq!(value.name(), "release"),
10922 other => panic!("expected the override's own answer, got {other:?}"),
10923 }
10924 assert_eq!(entity.diagnostics.default_branch_rung, Some(1));
10925 }
10926
10927 #[test]
10932 fn reaching_rung_four_with_no_remote_at_all_records_why() {
10933 let dir = tempfile::tempdir().expect("temp dir");
10934 let root = root_of(&dir);
10935 let repo = root.join("repo");
10936 init_repo_with_a_commit(&repo);
10937
10938 let core = Core::start_discovered(spec(vec![root]));
10939 let key = core.snapshot().entities[0].key.clone();
10940
10941 core.refresh(std::slice::from_ref(&key));
10942 let settled = core.settle();
10943 let entity = &settled.entities[0];
10944
10945 assert_eq!(entity.diagnostics.default_branch_rung, Some(4));
10946 assert_eq!(
10947 entity.diagnostics.default_branch_stopped,
10948 Some(DefaultBranchStopped::NoRemote)
10949 );
10950 }
10951
10952 #[test]
10953 fn reaching_rung_four_with_two_unnamed_remotes_records_why() {
10954 let dir = tempfile::tempdir().expect("temp dir");
10955 let root = root_of(&dir);
10956 let repo = root.join("repo");
10957 init_repo_with_a_commit(&repo);
10958 git(
10959 &repo,
10960 &[
10961 "remote",
10962 "add",
10963 "fork-one",
10964 "https://example.invalid/one.git",
10965 ],
10966 );
10967 git(
10968 &repo,
10969 &[
10970 "remote",
10971 "add",
10972 "fork-two",
10973 "https://example.invalid/two.git",
10974 ],
10975 );
10976
10977 let core = Core::start_discovered(spec(vec![root]));
10978 let key = core.snapshot().entities[0].key.clone();
10979
10980 core.refresh(std::slice::from_ref(&key));
10981 let settled = core.settle();
10982 let entity = &settled.entities[0];
10983
10984 assert_eq!(entity.diagnostics.default_branch_rung, Some(4));
10985 assert_eq!(
10986 entity.diagnostics.default_branch_stopped,
10987 Some(DefaultBranchStopped::AmbiguousRemote)
10988 );
10989 }
10990
10991 #[test]
10992 fn reaching_rung_four_with_a_chosen_remote_and_no_matching_ref_records_why() {
10993 let dir = tempfile::tempdir().expect("temp dir");
10994 let root = root_of(&dir);
10995 let repo = root.join("repo");
10996 init_repo_with_a_commit(&repo);
10997 git(
10998 &repo,
10999 &[
11000 "remote",
11001 "add",
11002 "origin",
11003 "https://example.invalid/repo.git",
11004 ],
11005 );
11006 let sha = head_sha(&repo);
11009 git(&repo, &["update-ref", "refs/remotes/origin/feature", &sha]);
11010
11011 let core = Core::start_discovered(spec(vec![root]));
11012 let key = core.snapshot().entities[0].key.clone();
11013
11014 core.refresh(std::slice::from_ref(&key));
11015 let settled = core.settle();
11016 let entity = &settled.entities[0];
11017
11018 assert_eq!(entity.diagnostics.default_branch_rung, Some(4));
11019 assert_eq!(
11020 entity.diagnostics.default_branch_stopped,
11021 Some(DefaultBranchStopped::NameListExhausted)
11022 );
11023 }
11024
11025 #[test]
11028 fn a_repo_with_nothing_to_resolve_settles_unknown_never_failed() {
11029 let dir = tempfile::tempdir().expect("temp dir");
11030 let root = root_of(&dir);
11031 let repo = root.join("repo");
11032 init_repo_with_a_commit(&repo);
11033
11034 let core = Core::start_discovered(spec(vec![root]));
11035 let key = core.snapshot().entities[0].key.clone();
11036
11037 core.refresh(std::slice::from_ref(&key));
11038 let settled = core.settle();
11039 let entity = &settled.entities[0];
11040
11041 assert!(matches!(
11042 entity.default_branch.settled(),
11043 Some(Settled::Unknown(Unknown::NoDefaultBranch))
11044 ));
11045 assert_eq!(entity.diagnostics.default_branch_rung, Some(4));
11046 }
11047
11048 #[test]
11054 fn a_stale_remote_head_is_recorded_in_diagnostics_through_a_real_refresh() {
11055 let dir = tempfile::tempdir().expect("temp dir");
11056 let root = root_of(&dir);
11057 let repo = root.join("repo");
11058 init_repo_with_a_commit(&repo);
11059 git(
11060 &repo,
11061 &[
11062 "remote",
11063 "add",
11064 "origin",
11065 "https://example.invalid/repo.git",
11066 ],
11067 );
11068 let sha = head_sha(&repo);
11069 git(&repo, &["update-ref", "refs/remotes/origin/trunk", &sha]);
11070 let remote_refs_dir = repo
11071 .join(".git")
11072 .join("refs")
11073 .join("remotes")
11074 .join("origin");
11075 fs::create_dir_all(&remote_refs_dir).expect("create refs/remotes/origin dir");
11076 fs::write(
11078 remote_refs_dir.join("HEAD"),
11079 "ref: refs/remotes/origin/main\n",
11080 )
11081 .expect("write HEAD");
11082
11083 let core = Core::start_discovered(spec(vec![root]));
11084 let key = core.snapshot().entities[0].key.clone();
11085
11086 core.refresh(std::slice::from_ref(&key));
11087 let settled = core.settle();
11088 let entity = &settled.entities[0];
11089
11090 match entity.default_branch.settled() {
11091 Some(Settled::Known {
11092 value,
11093 at: _,
11094 stale: _,
11095 }) => {
11096 assert_eq!(value.name(), "origin/trunk")
11097 }
11098 other => panic!("expected the name list's answer, got {other:?}"),
11099 }
11100 assert!(
11101 entity.diagnostics.default_branch_rung_two_stale,
11102 "a stale origin/HEAD target must be recorded on the entity's diagnostics"
11103 );
11104 }
11105
11106 #[test]
11109 fn a_resolvable_remote_head_is_not_recorded_as_stale() {
11110 let dir = tempfile::tempdir().expect("temp dir");
11111 let root = root_of(&dir);
11112 let repo = root.join("repo");
11113 init_repo_with_a_commit(&repo);
11114 git(
11115 &repo,
11116 &[
11117 "remote",
11118 "add",
11119 "origin",
11120 "https://example.invalid/repo.git",
11121 ],
11122 );
11123 let sha = head_sha(&repo);
11124 git(&repo, &["update-ref", "refs/remotes/origin/main", &sha]);
11125 let remote_refs_dir = repo
11126 .join(".git")
11127 .join("refs")
11128 .join("remotes")
11129 .join("origin");
11130 fs::create_dir_all(&remote_refs_dir).expect("create refs/remotes/origin dir");
11131 fs::write(
11132 remote_refs_dir.join("HEAD"),
11133 "ref: refs/remotes/origin/main\n",
11134 )
11135 .expect("write HEAD");
11136
11137 let core = Core::start_discovered(spec(vec![root]));
11138 let key = core.snapshot().entities[0].key.clone();
11139
11140 core.refresh(std::slice::from_ref(&key));
11141 let settled = core.settle();
11142 let entity = &settled.entities[0];
11143
11144 assert!(!entity.diagnostics.default_branch_rung_two_stale);
11145 }
11146
11147 #[test]
11152 fn one_override_on_a_repos_path_covers_a_worktree_sharing_its_common_dir() {
11153 let dir = tempfile::tempdir().expect("temp dir");
11154 let root = root_of(&dir);
11155 let parent = root.join("parent");
11156 init_repo_with_a_commit(&parent);
11157 let worktree = root.join("worktree");
11158 git(
11159 &parent,
11160 &[
11161 "worktree",
11162 "add",
11163 "-b",
11164 "feature",
11165 worktree.to_str().expect("utf8 path"),
11166 ],
11167 );
11168
11169 let core = Core::start_discovered(spec_with_overrides(
11170 vec![root],
11171 vec![RepoOverride {
11172 path: parent.clone(),
11173 default_branch: None,
11174 excluded: true,
11175 }],
11176 ));
11177 let snapshot = core.snapshot();
11178
11179 for entity in &snapshot.entities {
11180 assert!(
11181 entity.excluded,
11182 "both the Repo and its Worktree must inherit the entry declared on the Repo's own path, entity: {:?}",
11183 entity.key
11184 );
11185 }
11186 assert_eq!(
11187 snapshot.entities.len(),
11188 2,
11189 "expected the parent plus its worktree"
11190 );
11191 }
11192
11193 #[test]
11197 fn an_entry_naming_a_worktrees_own_path_beats_the_inherited_one() {
11198 let dir = tempfile::tempdir().expect("temp dir");
11199 let root = root_of(&dir);
11200 let parent = root.join("parent");
11201 init_repo_with_a_commit(&parent);
11202 let worktree_own = root.join("worktree-own");
11203 let worktree_inherits = root.join("worktree-inherits");
11204 git(
11205 &parent,
11206 &[
11207 "worktree",
11208 "add",
11209 "-b",
11210 "feature-own",
11211 worktree_own.to_str().expect("utf8 path"),
11212 ],
11213 );
11214 git(
11215 &parent,
11216 &[
11217 "worktree",
11218 "add",
11219 "-b",
11220 "feature-inherits",
11221 worktree_inherits.to_str().expect("utf8 path"),
11222 ],
11223 );
11224
11225 let core = Core::start_discovered(spec_with_overrides(
11226 vec![root],
11227 vec![
11228 RepoOverride {
11229 path: parent.clone(),
11230 default_branch: None,
11231 excluded: true,
11232 },
11233 RepoOverride {
11234 path: worktree_own.clone(),
11235 default_branch: None,
11236 excluded: false,
11237 },
11238 ],
11239 ));
11240 let snapshot = core.snapshot();
11241
11242 let find = |path: &Path| {
11243 snapshot
11244 .entities
11245 .iter()
11246 .find(|entity| entity.key.path() == path)
11247 .unwrap_or_else(|| panic!("entity at {path:?} present"))
11248 };
11249
11250 assert!(
11251 find(&parent).excluded,
11252 "the parent Repo has no entry of its own and inherits the excluding one"
11253 );
11254 assert!(
11255 !find(&worktree_own).excluded,
11256 "the Worktree named directly by its own path must use its own entry, not the inherited one"
11257 );
11258 assert!(
11259 find(&worktree_inherits).excluded,
11260 "a sibling Worktree with no entry of its own still inherits the Repo's entry"
11261 );
11262 }
11263
11264 #[test]
11269 fn an_override_on_the_parents_path_never_excludes_its_submodule() {
11270 let dir = tempfile::tempdir().expect("temp dir");
11271 let root = root_of(&dir);
11272 let parent = root.join("parent");
11273 init_repo_with_a_commit(&parent);
11274 fs::write(
11275 parent.join(".gitmodules"),
11276 "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
11277 )
11278 .expect("write .gitmodules");
11279 fs::create_dir_all(parent.join("vendor").join("lib")).expect("create submodule dir");
11280
11281 let core = Core::start_discovered(spec_with_overrides(
11282 vec![root],
11283 vec![RepoOverride {
11284 path: parent.clone(),
11285 default_branch: None,
11286 excluded: true,
11287 }],
11288 ));
11289 let snapshot = core.snapshot();
11290
11291 let submodule = snapshot
11292 .entities
11293 .iter()
11294 .find(|entity| matches!(entity.kind, Kind::Submodule))
11295 .expect("the submodule is still discovered and listed");
11296 assert!(
11297 !submodule.excluded,
11298 "an entry naming only the parent's path must never reach a Submodule, \
11299 whose own common dir differs from its parent's"
11300 );
11301 }
11302
11303 #[test]
11320 fn the_default_branch_chain_is_memoised_once_per_common_dir_per_generation() {
11321 let dir = tempfile::tempdir().expect("temp dir");
11322 let root = root_of(&dir);
11323 let parent = root.join("parent");
11324 init_repo_with_a_commit(&parent);
11325 for name in ["wt-a", "wt-b", "wt-c"] {
11326 let worktree = root.join(name);
11327 git(
11328 &parent,
11329 &[
11330 "worktree",
11331 "add",
11332 "-b",
11333 name,
11334 worktree.to_str().expect("utf8 path"),
11335 ],
11336 );
11337 }
11338 let other_repo = root.join("other");
11339 init_repo_with_a_commit(&other_repo);
11340
11341 let (core, launched) = started_and_settled(spec(vec![root]));
11342 let keys: Vec<EntityKey> = launched
11343 .entities
11344 .iter()
11345 .map(|entity| entity.key.clone())
11346 .collect();
11347 assert_eq!(
11348 keys.len(),
11349 5,
11350 "expected the parent, its three worktrees and the unrelated repo"
11351 );
11352
11353 core.refresh(&keys);
11354 core.settle();
11355
11356 assert_eq!(
11357 core.default_branch_chain_reads_for_test(),
11358 2,
11359 "four entities span exactly two common dirs; a memoised chain reads \
11360 each common dir once, not once per entity"
11361 );
11362
11363 core.refresh(&keys);
11367 core.settle();
11368 assert_eq!(
11369 core.default_branch_chain_reads_for_test(),
11370 2,
11371 "the memo lives inside one Generation's dispatch; the next Generation \
11372 recomputes rather than inheriting it"
11373 );
11374 }
11375
11376 #[test]
11386 fn patch_equivalence_is_memoised_once_per_common_dir_per_generation() {
11387 let dir = tempfile::tempdir().expect("temp dir");
11388 let root = root_of(&dir);
11389 let parent = root.join("parent");
11390 init_repo_with_a_commit(&parent);
11391 git(
11392 &parent,
11393 &[
11394 "remote",
11395 "add",
11396 "origin",
11397 "https://example.invalid/repo.git",
11398 ],
11399 );
11400 let base_sha = head_sha(&parent);
11401 git(
11402 &parent,
11403 &["update-ref", "refs/remotes/origin/main", &base_sha],
11404 );
11405 for name in ["feature-x", "feature-y"] {
11406 let worktree = root.join(name);
11407 git(
11408 &parent,
11409 &[
11410 "worktree",
11411 "add",
11412 "-b",
11413 name,
11414 worktree.to_str().expect("utf8 path"),
11415 ],
11416 );
11417 fs::write(worktree.join(format!("{name}.txt")), "unmerged\n")
11418 .expect("write worktree file");
11419 git(&worktree, &["add", "."]);
11420 git(&worktree, &["commit", "-m", "unmerged work"]);
11421 let tip_sha = head_sha(&worktree);
11422 git(
11423 &parent,
11424 &["config", &format!("branch.{name}.remote"), "origin"],
11425 );
11426 git(
11427 &parent,
11428 &[
11429 "config",
11430 &format!("branch.{name}.merge"),
11431 &format!("refs/heads/{name}"),
11432 ],
11433 );
11434 git(
11435 &parent,
11436 &[
11437 "update-ref",
11438 &format!("refs/remotes/origin/{name}"),
11439 &tip_sha,
11440 ],
11441 );
11442 }
11443
11444 let other_parent = root.join("other");
11445 init_repo_with_a_commit(&other_parent);
11446 git(
11447 &other_parent,
11448 &[
11449 "remote",
11450 "add",
11451 "origin",
11452 "https://example.invalid/other.git",
11453 ],
11454 );
11455 let other_base_sha = head_sha(&other_parent);
11456 git(
11457 &other_parent,
11458 &["update-ref", "refs/remotes/origin/main", &other_base_sha],
11459 );
11460 let other_worktree = root.join("other-feature");
11461 git(
11462 &other_parent,
11463 &[
11464 "worktree",
11465 "add",
11466 "-b",
11467 "other-feature",
11468 other_worktree.to_str().expect("utf8 path"),
11469 ],
11470 );
11471 fs::write(other_worktree.join("other.txt"), "unmerged\n").expect("write worktree file");
11472 git(&other_worktree, &["add", "."]);
11473 git(&other_worktree, &["commit", "-m", "unmerged work"]);
11474 let other_tip_sha = head_sha(&other_worktree);
11475 git(
11476 &other_parent,
11477 &["config", "branch.other-feature.remote", "origin"],
11478 );
11479 git(
11480 &other_parent,
11481 &[
11482 "config",
11483 "branch.other-feature.merge",
11484 "refs/heads/other-feature",
11485 ],
11486 );
11487 git(
11488 &other_parent,
11489 &[
11490 "update-ref",
11491 "refs/remotes/origin/other-feature",
11492 &other_tip_sha,
11493 ],
11494 );
11495
11496 let (core, launched) = started_and_settled(spec(vec![root]));
11497 let keys: Vec<EntityKey> = launched
11498 .entities
11499 .iter()
11500 .map(|entity| entity.key.clone())
11501 .collect();
11502 assert_eq!(
11503 keys.len(),
11504 5,
11505 "expected two parents plus their three worktrees"
11506 );
11507
11508 core.refresh(&keys);
11509 let settled = core.settle();
11510
11511 let worktree_states: Vec<_> = settled
11512 .entities
11513 .iter()
11514 .filter(|entity| matches!(entity.kind, Kind::Worktree))
11515 .map(|entity| entity.state.settled())
11516 .collect();
11517 assert_eq!(worktree_states.len(), 3, "expected three worktree rows");
11518 for settled_state in &worktree_states {
11519 assert!(
11520 matches!(
11521 settled_state,
11522 Some(Settled::Known {
11523 value: WorktreeState::Active,
11524 at: _,
11525 stale: _
11526 })
11527 ),
11528 "expected every worktree's genuinely unmerged work to settle Active, got {settled_state:?}"
11529 );
11530 }
11531
11532 assert_eq!(
11533 core.patch_identity_reads_for_test(),
11534 2,
11535 "two worktrees share one common dir and must scan its default-branch \
11536 history once between them, not once per entity; the unrelated repo's \
11537 own worktree pays for a second scan"
11538 );
11539
11540 core.refresh(&keys);
11543 core.settle();
11544 assert_eq!(
11545 core.patch_identity_reads_for_test(),
11546 2,
11547 "the memo lives inside one Generation's dispatch; the next Generation \
11548 recomputes rather than inheriting it"
11549 );
11550 }
11551
11552 #[test]
11571 fn an_entity_whose_merge_base_is_deeper_than_its_siblings_widens_the_shared_scan() {
11572 let dir = tempfile::tempdir().expect("temp dir");
11573 let root = root_of(&dir);
11574 let parent = root.join("parent");
11575 init_repo_with_a_commit(&parent);
11576 git(
11577 &parent,
11578 &[
11579 "remote",
11580 "add",
11581 "origin",
11582 "https://example.invalid/repo.git",
11583 ],
11584 );
11585 let deep_fork_sha = head_sha(&parent);
11586
11587 git(&parent, &["branch", "feature-deep"]);
11588 let deep_worktree = root.join("feature-deep");
11589 git(
11590 &parent,
11591 &[
11592 "worktree",
11593 "add",
11594 deep_worktree.to_str().expect("utf8 path"),
11595 "feature-deep",
11596 ],
11597 );
11598 fs::write(deep_worktree.join("deep.txt"), "deep work\n").expect("write deep.txt");
11599 git(&deep_worktree, &["add", "."]);
11600 git(&deep_worktree, &["commit", "-m", "deep work"]);
11601 let deep_tip_sha = head_sha(&deep_worktree);
11602
11603 git(&parent, &["merge", "--squash", "feature-deep"]);
11604 git(&parent, &["commit", "-m", "squashed deep"]);
11605 let shallow_fork_sha = head_sha(&parent);
11606
11607 git(&parent, &["branch", "feature-shallow"]);
11608 let shallow_worktree = root.join("feature-shallow");
11609 git(
11610 &parent,
11611 &[
11612 "worktree",
11613 "add",
11614 shallow_worktree.to_str().expect("utf8 path"),
11615 "feature-shallow",
11616 ],
11617 );
11618 fs::write(shallow_worktree.join("shallow.txt"), "shallow work\n")
11619 .expect("write shallow.txt");
11620 git(&shallow_worktree, &["add", "."]);
11621 git(&shallow_worktree, &["commit", "-m", "shallow work"]);
11622 let shallow_tip_sha = head_sha(&shallow_worktree);
11623
11624 git(&parent, &["merge", "--squash", "feature-shallow"]);
11625 git(&parent, &["commit", "-m", "squashed shallow"]);
11626 let main_tip_sha = head_sha(&parent);
11627 assert_ne!(
11628 deep_fork_sha, shallow_fork_sha,
11629 "the two siblings must fork at genuinely different commits"
11630 );
11631
11632 git(
11633 &parent,
11634 &["update-ref", "refs/remotes/origin/main", &main_tip_sha],
11635 );
11636 for (name, tip_sha) in [
11637 ("feature-deep", &deep_tip_sha),
11638 ("feature-shallow", &shallow_tip_sha),
11639 ] {
11640 git(
11641 &parent,
11642 &["config", &format!("branch.{name}.remote"), "origin"],
11643 );
11644 git(
11645 &parent,
11646 &[
11647 "config",
11648 &format!("branch.{name}.merge"),
11649 &format!("refs/heads/{name}"),
11650 ],
11651 );
11652 git(
11653 &parent,
11654 &[
11655 "update-ref",
11656 &format!("refs/remotes/origin/{name}"),
11657 tip_sha,
11658 ],
11659 );
11660 }
11661
11662 let (core, snapshot) = started_and_settled(spec(vec![root]));
11663 let deep_key = snapshot
11664 .entities
11665 .iter()
11666 .find(|entity| entity.key.path() == deep_worktree)
11667 .expect("feature-deep worktree discovered")
11668 .key
11669 .clone();
11670 let shallow_key = snapshot
11671 .entities
11672 .iter()
11673 .find(|entity| entity.key.path() == shallow_worktree)
11674 .expect("feature-shallow worktree discovered")
11675 .key
11676 .clone();
11677 let parent_key = snapshot
11678 .entities
11679 .iter()
11680 .find(|entity| entity.key.path() == parent)
11681 .expect("parent repo discovered")
11682 .key
11683 .clone();
11684 let order = vec![parent_key, shallow_key.clone(), deep_key.clone()];
11688
11689 core.refresh(&order);
11690 let settled = core.settle();
11691
11692 let state_of = |key: &EntityKey| {
11693 settled
11694 .entities
11695 .iter()
11696 .find(|entity| &entity.key == key)
11697 .and_then(|entity| entity.state.settled())
11698 .cloned()
11699 };
11700 assert!(
11701 matches!(
11702 state_of(&deep_key),
11703 Some(Settled::Known {
11704 value: WorktreeState::Merged,
11705 at: _,
11706 stale: _
11707 })
11708 ),
11709 "expected the deepest sibling's own squash commit to be found once the scan is \
11710 bounded by the deepest merge base, got {:?}",
11711 state_of(&deep_key)
11712 );
11713 assert!(
11714 matches!(
11715 state_of(&shallow_key),
11716 Some(Settled::Known {
11717 value: WorktreeState::Merged,
11718 at: _,
11719 stale: _
11720 })
11721 ),
11722 "expected the shallow sibling to settle Merged too, got {:?}",
11723 state_of(&shallow_key)
11724 );
11725 assert_eq!(
11726 core.patch_identity_reads_for_test(),
11727 1,
11728 "both worktrees share one common dir and must still scan its default-branch \
11729 history once between them, not once per entity"
11730 );
11731 assert_eq!(
11732 core.patch_scan_bounds_for_test(),
11733 vec![Some(id(&deep_fork_sha))],
11734 "the one shared scan that ran must have been bounded by the deepest sibling's own \
11735 merge base, not the shallower one's"
11736 );
11737 }
11738
11739 fn id(sha: &str) -> gix::ObjectId {
11740 gix::ObjectId::from_hex(sha.as_bytes()).expect("parse sha")
11741 }
11742
11743 #[test]
11750 fn bound_gate_deepest_folds_every_candidate_regardless_of_report_order() {
11751 let dir = tempfile::tempdir().expect("temp dir");
11752 let repo_path = root_of(&dir).join("repo");
11753 init_repo_with_a_commit(&repo_path);
11754 let deep_sha = id(&head_sha(&repo_path));
11755 fs::write(repo_path.join("child.txt"), "child\n").expect("write child.txt");
11756 git(&repo_path, &["add", "."]);
11757 git(&repo_path, &["commit", "-m", "child of deep"]);
11758 let shallow_sha = id(&head_sha(&repo_path));
11759
11760 let repo = gix::open(&repo_path).expect("open repo");
11761 let gate = BoundGate::new(2);
11762 gate.report(Some(shallow_sha));
11763 gate.report(Some(deep_sha));
11764
11765 assert_eq!(
11766 gate.deepest(&repo),
11767 Some(deep_sha),
11768 "the deepest candidate must win even though the shallower one reported first"
11769 );
11770 }
11771
11772 #[test]
11789 fn probe_patch_equivalence_bounds_the_scan_by_the_gates_deepest_not_its_own_merge_base() {
11790 let dir = tempfile::tempdir().expect("temp dir");
11791 let repo_path = root_of(&dir).join("repo");
11792 init_repo_with_a_commit(&repo_path);
11793 let deep_sha = id(&head_sha(&repo_path));
11794 fs::write(repo_path.join("child.txt"), "child\n").expect("write child.txt");
11795 git(&repo_path, &["add", "."]);
11796 git(&repo_path, &["commit", "-m", "child of deep"]);
11797 let shallow_sha_hex = head_sha(&repo_path);
11798 let shallow_sha = id(&shallow_sha_hex);
11799 fs::write(repo_path.join("tip.txt"), "tip\n").expect("write tip.txt");
11800 git(&repo_path, &["add", "."]);
11801 git(&repo_path, &["commit", "-m", "default tip"]);
11802 let default_tip_hex = head_sha(&repo_path);
11803
11804 let repo = gix::open(&repo_path).expect("open repo");
11805 let outstanding = landing::Outstanding {
11808 entity_tip: shallow_sha,
11809 default_tip: id(&default_tip_hex),
11810 merge_base: Some(shallow_sha),
11811 };
11812 let common_dir: Arc<Path> = Arc::from(repo_path.join(".git"));
11813 let cancel = AtomicBool::new(false);
11814 let patch_cache: PatchIdentityCache = Mutex::new(HashMap::new());
11815 let patch_reads = AtomicUsize::new(0);
11816 let patch_scan_bounds: Mutex<Vec<Option<gix::ObjectId>>> = Mutex::new(Vec::new());
11817 let memo = PatchEquivalenceMemo {
11818 cache: &patch_cache,
11819 reads: &patch_reads,
11820 scan_bounds: &patch_scan_bounds,
11821 };
11822 let gate = BoundGate::new(2);
11826 gate.report(Some(deep_sha));
11827 let mut report = GateReport::new(&gate);
11828
11829 probe_patch_equivalence(
11830 &repo,
11831 &outstanding,
11832 &common_dir,
11833 &cancel,
11834 &memo,
11835 &mut report,
11836 );
11837
11838 assert_eq!(
11839 patch_scan_bounds.lock().unwrap().as_slice(),
11840 [Some(deep_sha)],
11841 "the scan must be bounded by the deepest sibling's merge base, not shallow's own \
11842 ({shallow_sha:?})"
11843 );
11844 }
11845
11846 #[test]
11854 fn probe_patch_equivalence_diffs_from_the_merge_base_it_was_handed() {
11855 let dir = tempfile::tempdir().expect("temp dir");
11856 let repo_path = root_of(&dir).join("repo");
11857 init_repo_with_a_commit(&repo_path);
11858 let fork_point_hex = head_sha(&repo_path);
11859 git(&repo_path, &["checkout", "-b", "feature"]);
11860 fs::write(repo_path.join("a.txt"), "one\n").expect("write a.txt");
11861 git(&repo_path, &["add", "a.txt"]);
11862 git(&repo_path, &["commit", "-m", "add a"]);
11863 let mid_sha = id(&head_sha(&repo_path));
11864 fs::write(repo_path.join("b.txt"), "two\n").expect("write b.txt");
11865 git(&repo_path, &["add", "b.txt"]);
11866 git(&repo_path, &["commit", "-m", "add b"]);
11867 let feature_sha = id(&head_sha(&repo_path));
11868 git(&repo_path, &["checkout", "-B", "main", &fork_point_hex]);
11869 git(&repo_path, &["merge", "--squash", "feature"]);
11870 git(&repo_path, &["commit", "-m", "squashed feature"]);
11871 let main_sha = id(&head_sha(&repo_path));
11872
11873 let repo = gix::open(&repo_path).expect("open repo");
11874 let outstanding = landing::Outstanding {
11877 entity_tip: feature_sha,
11878 default_tip: main_sha,
11879 merge_base: Some(mid_sha),
11880 };
11881 let common_dir: Arc<Path> = Arc::from(repo_path.join(".git"));
11882 let cancel = AtomicBool::new(false);
11883 let patch_cache: PatchIdentityCache = Mutex::new(HashMap::new());
11884 let patch_reads = AtomicUsize::new(0);
11885 let patch_scan_bounds: Mutex<Vec<Option<gix::ObjectId>>> = Mutex::new(Vec::new());
11886 let memo = PatchEquivalenceMemo {
11887 cache: &patch_cache,
11888 reads: &patch_reads,
11889 scan_bounds: &patch_scan_bounds,
11890 };
11891 let gate = BoundGate::new(1);
11892 let mut report = GateReport::new(&gate);
11893
11894 let settled = probe_patch_equivalence(
11895 &repo,
11896 &outstanding,
11897 &common_dir,
11898 &cancel,
11899 &memo,
11900 &mut report,
11901 );
11902
11903 assert!(
11904 matches!(
11905 settled,
11906 Some(Settled::Known {
11907 value: WorktreeState::Active,
11908 at: _,
11909 stale: _
11910 })
11911 ),
11912 "the range must be measured from the handed-in base ({mid_sha:?}), whose only \
11913 change the squash commit does not match, got {settled:?}"
11914 );
11915 }
11916
11917 #[test]
11924 fn bound_gate_deepest_with_no_candidates_leaves_the_scan_unbounded() {
11925 let dir = tempfile::tempdir().expect("temp dir");
11926 let repo_path = root_of(&dir).join("repo");
11927 gix::init(&repo_path).expect("init repo");
11928 let repo = gix::open(&repo_path).expect("open repo");
11929
11930 let gate = BoundGate::new(2);
11931 gate.report(None);
11932 gate.report(None);
11933
11934 assert_eq!(
11935 gate.deepest(&repo),
11936 None,
11937 "no contributed candidate must leave the scan unbounded"
11938 );
11939 }
11940
11941 #[test]
11951 fn an_outstanding_entity_with_no_shared_history_settles_active_without_the_shared_scan() {
11952 let dir = tempfile::tempdir().expect("temp dir");
11953 let root = root_of(&dir);
11954 let parent = root.join("parent");
11955 init_repo_with_a_commit(&parent);
11956 git(&parent, &["branch", "-M", "main"]);
11957 git(
11958 &parent,
11959 &[
11960 "remote",
11961 "add",
11962 "origin",
11963 "https://example.invalid/repo.git",
11964 ],
11965 );
11966 let main_sha = head_sha(&parent);
11967 git(
11968 &parent,
11969 &["update-ref", "refs/remotes/origin/main", &main_sha],
11970 );
11971
11972 git(&parent, &["checkout", "--orphan", "unrelated"]);
11973 git(
11974 &parent,
11975 &["commit", "--allow-empty", "-m", "unrelated root"],
11976 );
11977 let unrelated_sha = head_sha(&parent);
11978 git(&parent, &["checkout", "main"]);
11979
11980 let worktree = root.join("unrelated");
11981 git(
11982 &parent,
11983 &[
11984 "worktree",
11985 "add",
11986 worktree.to_str().expect("utf8 path"),
11987 "unrelated",
11988 ],
11989 );
11990 git(&parent, &["config", "branch.unrelated.remote", "origin"]);
11991 git(
11992 &parent,
11993 &["config", "branch.unrelated.merge", "refs/heads/unrelated"],
11994 );
11995 git(
11996 &parent,
11997 &[
11998 "update-ref",
11999 "refs/remotes/origin/unrelated",
12000 &unrelated_sha,
12001 ],
12002 );
12003
12004 let (core, snapshot) = started_and_settled(spec(vec![root]));
12005 let worktree_key = snapshot
12006 .entities
12007 .iter()
12008 .find(|entity| entity.key.path() == worktree)
12009 .expect("unrelated worktree discovered")
12010 .key
12011 .clone();
12012
12013 core.refresh(std::slice::from_ref(&worktree_key));
12014 let settled = core.settle();
12015
12016 let state = settled
12017 .entities
12018 .iter()
12019 .find(|entity| entity.key == worktree_key)
12020 .and_then(|entity| entity.state.settled())
12021 .cloned();
12022 assert!(
12023 matches!(
12024 state,
12025 Some(Settled::Known {
12026 value: WorktreeState::Active,
12027 at: _,
12028 stale: _
12029 })
12030 ),
12031 "expected an Outstanding entity with no shared history to settle Active via the \
12032 bypass, got {state:?}"
12033 );
12034 assert_eq!(
12035 core.patch_identity_reads_for_test(),
12036 0,
12037 "the bypass must settle without ever running the shared scan"
12038 );
12039 }
12040
12041 fn add_origin_remote(path: &Path) {
12046 git(
12047 path,
12048 &[
12049 "remote",
12050 "add",
12051 "origin",
12052 "https://example.invalid/repo.git",
12053 ],
12054 );
12055 }
12056
12057 fn set_upstream(path: &Path, branch: &str, upstream_sha: &str) {
12061 git(
12062 path,
12063 &["config", &format!("branch.{branch}.remote"), "origin"],
12064 );
12065 git(
12066 path,
12067 &[
12068 "config",
12069 &format!("branch.{branch}.merge"),
12070 &format!("refs/heads/{branch}"),
12071 ],
12072 );
12073 git(
12074 path,
12075 &[
12076 "update-ref",
12077 &format!("refs/remotes/origin/{branch}"),
12078 upstream_sha,
12079 ],
12080 );
12081 }
12082
12083 fn refresh_and_settle(core: &Core) -> crate::snapshot::Snapshot {
12084 let keys: Vec<EntityKey> = core
12085 .snapshot()
12086 .entities
12087 .iter()
12088 .map(|entity| entity.key.clone())
12089 .collect();
12090 core.refresh(&keys);
12091 core.settle()
12092 }
12093
12094 fn sync_of<'a>(
12095 snapshot: &'a crate::snapshot::Snapshot,
12096 path: &Path,
12097 ) -> Option<&'a Settled<SyncState>> {
12098 snapshot
12099 .entities
12100 .iter()
12101 .find(|entity| entity.key.path() == path)
12102 .unwrap_or_else(|| panic!("no entity for {}", path.display()))
12103 .sync
12104 .settled()
12105 }
12106
12107 #[test]
12109 fn an_attached_branch_ahead_of_its_upstream_reads_the_ahead_count() {
12110 let dir = tempfile::tempdir().expect("temp dir");
12111 let root = root_of(&dir);
12112 let repo = root.join("repo");
12113 init_repo_with_a_commit(&repo);
12114 let fork_sha = head_sha(&repo);
12115 add_origin_remote(&repo);
12116 set_upstream(&repo, "main", &fork_sha);
12117 git(&repo, &["commit", "--allow-empty", "-m", "local work"]);
12118
12119 let core = Core::start_discovered(spec(vec![root]));
12120 let settled = refresh_and_settle(&core);
12121
12122 match sync_of(&settled, &repo) {
12123 Some(Settled::Known {
12124 value: SyncState::Tracking(AheadBehind { ahead, behind }),
12125 at: _,
12126 stale: _,
12127 }) => {
12128 assert_eq!(*ahead, 1);
12129 assert_eq!(*behind, 0);
12130 }
12131 other => panic!("expected 1 ahead, 0 behind, got {other:?}"),
12132 }
12133 }
12134
12135 #[test]
12137 fn an_attached_branch_behind_its_upstream_reads_the_behind_count() {
12138 let dir = tempfile::tempdir().expect("temp dir");
12139 let root = root_of(&dir);
12140 let repo = root.join("repo");
12141 init_repo_with_a_commit(&repo);
12142 git(&repo, &["checkout", "-b", "temp"]);
12143 git(&repo, &["commit", "--allow-empty", "-m", "upstream work"]);
12144 let upstream_sha = head_sha(&repo);
12145 git(&repo, &["checkout", "main"]);
12146 git(&repo, &["branch", "-D", "temp"]);
12147 add_origin_remote(&repo);
12148 set_upstream(&repo, "main", &upstream_sha);
12149
12150 let core = Core::start_discovered(spec(vec![root]));
12151 let settled = refresh_and_settle(&core);
12152
12153 match sync_of(&settled, &repo) {
12154 Some(Settled::Known {
12155 value: SyncState::Tracking(AheadBehind { ahead, behind }),
12156 at: _,
12157 stale: _,
12158 }) => {
12159 assert_eq!(*ahead, 0);
12160 assert_eq!(*behind, 1);
12161 }
12162 other => panic!("expected 0 ahead, 1 behind, got {other:?}"),
12163 }
12164 }
12165
12166 #[test]
12168 fn an_attached_branch_level_with_its_upstream_reads_in_sync() {
12169 let dir = tempfile::tempdir().expect("temp dir");
12170 let root = root_of(&dir);
12171 let repo = root.join("repo");
12172 init_repo_with_a_commit(&repo);
12173 let sha = head_sha(&repo);
12174 add_origin_remote(&repo);
12175 set_upstream(&repo, "main", &sha);
12176
12177 let core = Core::start_discovered(spec(vec![root]));
12178 let settled = refresh_and_settle(&core);
12179
12180 match sync_of(&settled, &repo) {
12181 Some(Settled::Known {
12182 value:
12183 SyncState::Tracking(AheadBehind {
12184 ahead: 0,
12185 behind: 0,
12186 }),
12187 at: _,
12188 stale: _,
12189 }) => {}
12190 other => panic!("expected level with its upstream, got {other:?}"),
12191 }
12192 }
12193
12194 #[test]
12198 fn an_attached_branch_tracking_nothing_reads_no_upstream() {
12199 let dir = tempfile::tempdir().expect("temp dir");
12200 let root = root_of(&dir);
12201 let repo = root.join("repo");
12202 init_repo_with_a_commit(&repo);
12203 add_origin_remote(&repo);
12204
12205 let core = Core::start_discovered(spec(vec![root]));
12206 let settled = refresh_and_settle(&core);
12207
12208 match sync_of(&settled, &repo) {
12209 Some(Settled::Known {
12210 value: SyncState::NoUpstream,
12211 at: _,
12212 stale: _,
12213 }) => {}
12214 other => panic!("expected no upstream configured, got {other:?}"),
12215 }
12216 }
12217
12218 #[test]
12221 fn a_detached_row_reads_no_upstream() {
12222 let dir = tempfile::tempdir().expect("temp dir");
12223 let root = root_of(&dir);
12224 let repo = root.join("repo");
12225 init_repo_with_a_commit(&repo);
12226 let first_sha = head_sha(&repo);
12227 git(&repo, &["commit", "--allow-empty", "-m", "second"]);
12228 git(&repo, &["checkout", "--detach", &first_sha]);
12229 add_origin_remote(&repo);
12230
12231 let core = Core::start_discovered(spec(vec![root]));
12232 let settled = refresh_and_settle(&core);
12233
12234 match sync_of(&settled, &repo) {
12235 Some(Settled::Known {
12236 value: SyncState::NoUpstream,
12237 at: _,
12238 stale: _,
12239 }) => {}
12240 other => panic!("expected a detached row to read no upstream, got {other:?}"),
12241 }
12242 }
12243
12244 #[test]
12249 fn a_repo_with_no_remote_reads_no_remote_on_itself_and_every_worktree() {
12250 let dir = tempfile::tempdir().expect("temp dir");
12251 let root = root_of(&dir);
12252 let parent = root.join("parent");
12253 init_repo_with_a_commit(&parent);
12254 let worktree = root.join("feature");
12255 git(
12256 &parent,
12257 &[
12258 "worktree",
12259 "add",
12260 "-b",
12261 "feature",
12262 worktree.to_str().expect("utf8 path"),
12263 ],
12264 );
12265
12266 let core = Core::start_discovered(spec(vec![root]));
12267 let settled = refresh_and_settle(&core);
12268
12269 assert_eq!(
12270 settled.entities.len(),
12271 2,
12272 "expected the parent Repo and its one linked Worktree"
12273 );
12274 for path in [&parent, &worktree] {
12275 match sync_of(&settled, path) {
12276 Some(Settled::Known {
12277 value: SyncState::NoRemote,
12278 at: _,
12279 stale: _,
12280 }) => {}
12281 other => panic!(
12282 "expected {} to read no remote at all, got {other:?}",
12283 path.display()
12284 ),
12285 }
12286 }
12287 }
12288
12289 #[test]
12295 fn sync_is_computed_for_every_entity_dispatched_this_generation_not_only_one() {
12296 let dir = tempfile::tempdir().expect("temp dir");
12297 let root = root_of(&dir);
12298 let parent = root.join("parent");
12299 init_repo_with_a_commit(&parent);
12300 let fork_sha = head_sha(&parent);
12301 add_origin_remote(&parent);
12302
12303 let ahead_worktree = root.join("feature-ahead");
12304 git(
12305 &parent,
12306 &[
12307 "worktree",
12308 "add",
12309 "-b",
12310 "feature-ahead",
12311 ahead_worktree.to_str().expect("utf8 path"),
12312 ],
12313 );
12314 set_upstream(&parent, "feature-ahead", &fork_sha);
12315 git(
12316 &ahead_worktree,
12317 &["commit", "--allow-empty", "-m", "unpushed"],
12318 );
12319
12320 let behind_worktree = root.join("feature-behind");
12321 git(
12322 &parent,
12323 &[
12324 "worktree",
12325 "add",
12326 "-b",
12327 "feature-behind",
12328 behind_worktree.to_str().expect("utf8 path"),
12329 ],
12330 );
12331 git(
12332 &behind_worktree,
12333 &["commit", "--allow-empty", "-m", "on the remote only"],
12334 );
12335 let ahead_of_behind_sha = head_sha(&behind_worktree);
12336 git(&behind_worktree, &["reset", "--hard", "HEAD~1"]);
12337 set_upstream(&parent, "feature-behind", &ahead_of_behind_sha);
12338
12339 let core = Core::start_discovered(spec(vec![root]));
12340 let settled = refresh_and_settle(&core);
12341
12342 match sync_of(&settled, &ahead_worktree) {
12343 Some(Settled::Known {
12344 value:
12345 SyncState::Tracking(AheadBehind {
12346 ahead: 1,
12347 behind: 0,
12348 }),
12349 at: _,
12350 stale: _,
12351 }) => {}
12352 other => panic!("expected feature-ahead to read 1 ahead, got {other:?}"),
12353 }
12354 match sync_of(&settled, &behind_worktree) {
12355 Some(Settled::Known {
12356 value:
12357 SyncState::Tracking(AheadBehind {
12358 ahead: 0,
12359 behind: 1,
12360 }),
12361 at: _,
12362 stale: _,
12363 }) => {}
12364 other => panic!("expected feature-behind to read 1 behind, got {other:?}"),
12365 }
12366 }
12367
12368 #[test]
12374 fn sync_recomputes_on_a_second_generation_not_only_the_first() {
12375 let dir = tempfile::tempdir().expect("temp dir");
12376 let root = root_of(&dir);
12377 let repo = root.join("repo");
12378 init_repo_with_a_commit(&repo);
12379 let fork_sha = head_sha(&repo);
12380 add_origin_remote(&repo);
12381 set_upstream(&repo, "main", &fork_sha);
12382
12383 let core = Core::start_discovered(spec(vec![root]));
12384 let first = refresh_and_settle(&core);
12385 match sync_of(&first, &repo) {
12386 Some(Settled::Known {
12387 value:
12388 SyncState::Tracking(AheadBehind {
12389 ahead: 0,
12390 behind: 0,
12391 }),
12392 at: _,
12393 stale: _,
12394 }) => {}
12395 other => panic!("expected the first Generation level with its upstream, got {other:?}"),
12396 }
12397
12398 git(
12399 &repo,
12400 &[
12401 "commit",
12402 "--allow-empty",
12403 "-m",
12404 "second Generation's own work",
12405 ],
12406 );
12407 let second = refresh_and_settle(&core);
12408 match sync_of(&second, &repo) {
12409 Some(Settled::Known {
12410 value:
12411 SyncState::Tracking(AheadBehind {
12412 ahead: 1,
12413 behind: 0,
12414 }),
12415 at: _,
12416 stale: _,
12417 }) => {}
12418 other => panic!(
12419 "expected the second Generation to recompute and read 1 ahead, got {other:?}"
12420 ),
12421 }
12422 }
12423
12424 #[test]
12440 fn worktrees_now_behind_a_moved_default_branch_are_reported_by_name() {
12441 let dir = tempfile::tempdir().expect("temp dir");
12442 let root = root_of(&dir);
12443 let repo = root.join("repo");
12444 init_repo_with_a_commit(&repo);
12445 let sha_a = head_sha(&repo);
12446 add_origin_remote(&repo);
12447 set_upstream(&repo, "main", &sha_a);
12448
12449 let behind_path = root.join("wt-behind");
12450 git(
12451 &repo,
12452 &[
12453 "worktree",
12454 "add",
12455 "-b",
12456 "topic-behind",
12457 behind_path.to_str().expect("utf8 path"),
12458 "main",
12459 ],
12460 );
12461
12462 git(&repo, &["checkout", "-b", "scratch"]);
12467 git(&repo, &["commit", "--allow-empty", "-m", "second"]);
12468 let sha_b = head_sha(&repo);
12469 git(&repo, &["checkout", "main"]);
12470 git(&repo, &["update-ref", "refs/remotes/origin/main", &sha_b]);
12471 git(&repo, &["branch", "-D", "scratch"]);
12472
12473 let caught_up_path = root.join("wt-caught-up");
12479 git(
12480 &repo,
12481 &[
12482 "worktree",
12483 "add",
12484 "-b",
12485 "topic-caught-up",
12486 caught_up_path.to_str().expect("utf8 path"),
12487 &sha_b,
12488 ],
12489 );
12490
12491 let core = Core::start_discovered(spec(vec![root]));
12492 let snapshot = refresh_and_settle(&core);
12493
12494 let base_of = |name: &str| -> u32 {
12495 let entity = snapshot
12496 .entities
12497 .iter()
12498 .find(|entity| &*entity.name == name)
12499 .unwrap_or_else(|| panic!("no entity named {name} in {snapshot:?}"));
12500 match entity.base.settled() {
12501 Some(Settled::Known {
12502 value,
12503 at: _,
12504 stale: _,
12505 }) => *value,
12506 other => panic!("expected a known base count for {name}, got {other:?}"),
12507 }
12508 };
12509
12510 assert!(
12511 base_of("wt-behind") > 0,
12512 "a Worktree branched before the default branch moved must be reported behind"
12513 );
12514 assert_eq!(
12515 base_of("wt-caught-up"),
12516 0,
12517 "a Worktree branched from the new tip must not be reported behind"
12518 );
12519 }
12520
12521 mod fetch_scheduler {
12527 use super::*;
12528 use crate::liveness::wait_for_or;
12529
12530 fn fetch_spec(enabled: bool, root: PathBuf) -> CoreSpec {
12531 let mut spec = spec(vec![root]);
12532 spec.fetch = FetchSpec {
12533 enabled,
12534 interval: Duration::from_secs(3600),
12535 concurrency: 4,
12536 };
12537 spec
12538 }
12539
12540 fn seeded_remote() -> tempfile::TempDir {
12543 let remote = tempfile::tempdir().expect("temp dir");
12544 crate::test_support::init_bare(remote.path());
12545 crate::test_support::push_new_commit(remote.path(), "README.md", "seed\n");
12546 remote
12547 }
12548
12549 fn clone_into(remote: &Path, dest: &Path) {
12550 let status = Command::new("git")
12551 .arg("clone")
12552 .arg(remote)
12553 .arg(dest)
12554 .status()
12555 .expect("run git clone");
12556 assert!(status.success());
12557 crate::test_support::set_identity(dest);
12558 }
12559
12560 #[test]
12567 fn enabling_the_periodic_fetch_runs_one_cycle_before_any_tick_arrives() {
12568 let remote = seeded_remote();
12569 let root = tempfile::tempdir().expect("temp dir");
12570 let root_path = root_of(&root);
12571 clone_into(remote.path(), &root_path.join("parent"));
12572
12573 let fetch_ticks: Receiver<Instant> = crossbeam_channel::never();
12574 let started = Core::start_for_test_with_fetch(
12575 fetch_spec(true, root_path),
12576 Duration::from_secs(3600),
12577 crossbeam_channel::never(),
12578 fetch_ticks,
12579 )
12580 .discovered();
12581 let core = started.core;
12582
12583 wait_for(
12584 "the periodic fetch to run its first cycle without waiting for a tick",
12585 || core.fetch_cycle_count_for_test() >= 1,
12586 );
12587 }
12588
12589 #[test]
12596 fn a_tick_on_the_fetch_channel_runs_another_cycle() {
12597 let remote = seeded_remote();
12598 let root = tempfile::tempdir().expect("temp dir");
12599 let root_path = root_of(&root);
12600 clone_into(remote.path(), &root_path.join("parent"));
12601
12602 let (fetch_tick_tx, fetch_tick_rx) = crossbeam_channel::unbounded();
12603 let started = Core::start_for_test_with_fetch(
12604 fetch_spec(true, root_path),
12605 Duration::from_secs(3600),
12606 crossbeam_channel::never(),
12607 fetch_tick_rx,
12608 )
12609 .discovered();
12610 let core = started.core;
12611
12612 wait_for(
12613 "the immediate cycle to have run and been taken back first",
12614 || started.fetch_cycles_taken_back.load(Ordering::Acquire) >= 1,
12615 );
12616
12617 fetch_tick_tx
12618 .send(Instant::now())
12619 .expect("send a fetch tick");
12620
12621 wait_for("a tick on the fetch channel to run a second cycle", || {
12622 core.fetch_cycle_count_for_test() >= 2
12623 });
12624 }
12625
12626 #[test]
12632 fn a_deadline_tick_still_times_out_a_pending_probe_while_a_fetch_is_held() {
12633 let remote = seeded_remote();
12634 let root = tempfile::tempdir().expect("temp dir");
12635 let root_path = root_of(&root);
12636 clone_into(remote.path(), &root_path.join("parent"));
12637
12638 let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
12639 let (fetch_tick_tx, fetch_tick_rx) = crossbeam_channel::unbounded::<Instant>();
12640 let mut spec = fetch_spec(false, root_path);
12641 spec.generation_deadline = Duration::ZERO;
12642 let started = Core::start_for_test_with_fetch(
12643 spec,
12644 Duration::from_secs(3600),
12645 tick_rx,
12646 fetch_tick_rx,
12647 )
12648 .discovered();
12649 let core = started.core;
12650 let key = core.settle().entities[0].key.clone();
12651
12652 let held = core.fetch_boundary().arm();
12653 fetch_tick_tx
12654 .send(Instant::now())
12655 .expect("send a fetch tick");
12656 held.wait_until_reached();
12657
12658 core.begin_untracked_probe_for_test(&key);
12659 tick_tx.send(Instant::now()).expect("send one tick");
12660 let after = core.settle();
12661
12662 assert!(
12663 matches!(
12664 after.entities[0].branch.settled(),
12665 Some(Settled::Unknown(Unknown::TimedOut))
12666 ),
12667 "the deadline sweep must still run while a fetch is held, got: {:?}",
12668 after.entities[0].branch.settled()
12669 );
12670 }
12671
12672 #[test]
12681 fn pause_cancels_a_held_cycle_so_it_neither_auto_updates_nor_dispatches_its_generation() {
12682 let remote = seeded_remote();
12683 let root = tempfile::tempdir().expect("temp dir");
12684 let root_path = root_of(&root);
12685 let parent = root_path.join("parent");
12686 let stale = root_path.join("stale");
12687 clone_into(remote.path(), &parent);
12688 clone_into(remote.path(), &stale);
12689 crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
12690 git(&parent, &["fetch", "origin"]);
12691 let before_tip = rev_parse(&parent, "refs/heads/main");
12692 let stale_before = rev_parse(&stale, "refs/remotes/origin/main");
12693
12694 let (fetch_tick_tx, fetch_tick_rx) = crossbeam_channel::unbounded();
12695 let started = Core::start_for_test_with_fetch(
12696 spec_with_auto_update(false, true, root_path),
12697 Duration::from_secs(3600),
12698 crossbeam_channel::never(),
12699 fetch_tick_rx,
12700 )
12701 .discovered();
12702 let core = started.core;
12703 let before = core.settle().generation;
12704
12705 let held = core.fetch_boundary().arm();
12706 fetch_tick_tx
12707 .send(Instant::now())
12708 .expect("send a fetch tick");
12709 held.wait_until_reached();
12710
12711 core.pause();
12712 held.wait_until_cancelled();
12713 drop(held);
12714 wait_for("the cancelled cycle to be taken back by the clock", || {
12715 started.fetch_cycles_taken_back.load(Ordering::Acquire) >= 1
12716 });
12717
12718 assert_eq!(
12719 rev_parse(&parent, "refs/heads/main"),
12720 before_tip,
12721 "a cancelled cycle must not fast-forward a Repo its auto-update would \
12722 otherwise have moved"
12723 );
12724 assert_eq!(
12725 rev_parse(&stale, "refs/remotes/origin/main"),
12726 stale_before,
12727 "a cancelled cycle must land no fetch beyond the one it was holding"
12728 );
12729 assert_eq!(
12730 core.snapshot().generation,
12731 before,
12732 "releasing a cancelled fetch must not dispatch the completion Generation \
12733 its cycle would otherwise have owed"
12734 );
12735 }
12736
12737 #[test]
12743 fn a_fetch_tick_taken_while_a_cycle_is_live_starts_no_second_cycle() {
12744 let remote = seeded_remote();
12745 let root = tempfile::tempdir().expect("temp dir");
12746 let root_path = root_of(&root);
12747 clone_into(remote.path(), &root_path.join("parent"));
12748
12749 let (fetch_tick_tx, fetch_tick_rx) = crossbeam_channel::unbounded();
12750 let pending_ticks = fetch_tick_rx.clone();
12753 let started = Core::start_for_test_with_fetch(
12754 fetch_spec(false, root_path),
12755 Duration::from_secs(3600),
12756 crossbeam_channel::never(),
12757 fetch_tick_rx,
12758 )
12759 .discovered();
12760 let core = started.core;
12761
12762 let held = core.fetch_boundary().arm();
12763 fetch_tick_tx
12764 .send(Instant::now())
12765 .expect("send the tick that starts the cycle");
12766 held.wait_until_reached();
12767
12768 for _ in 0..2 {
12769 fetch_tick_tx
12770 .send(Instant::now())
12771 .expect("send a tick while the cycle is live");
12772 }
12773 wait_for("the clock to take both further ticks", || {
12774 pending_ticks.is_empty()
12775 });
12776
12777 drop(held);
12778 wait_for("the released cycle to be taken back by the clock", || {
12779 started.fetch_cycles_taken_back.load(Ordering::Acquire) >= 1
12780 });
12781
12782 assert_eq!(
12783 core.fetch_cycle_count_for_test(),
12784 1,
12785 "two ticks taken while a cycle was held must have started no cycle of their \
12786 own"
12787 );
12788 }
12789
12790 #[test]
12797 fn a_cancelled_cycle_leaves_the_completed_cycles_failures_standing() {
12798 let remote = seeded_remote();
12799 let root = tempfile::tempdir().expect("temp dir");
12800 let root_path = root_of(&root);
12801 let broken = root_path.join("broken");
12802 clone_into(remote.path(), &broken);
12803 break_remote(&broken);
12804
12805 let (fetch_tick_tx, fetch_tick_rx) = crossbeam_channel::unbounded();
12806 let started = Core::start_for_test_with_fetch(
12807 fetch_spec(true, root_path),
12808 Duration::from_secs(3600),
12809 crossbeam_channel::never(),
12810 fetch_tick_rx,
12811 )
12812 .discovered();
12813 let core = started.core;
12814
12815 wait_for("the immediate cycle to complete and be taken back", || {
12816 started.fetch_cycles_taken_back.load(Ordering::Acquire) >= 1
12817 });
12818 assert_eq!(
12819 core.fetch_failures().failed.len(),
12820 1,
12821 "the completed cycle must have counted its one broken remote, got: {:?}",
12822 core.fetch_failures().failed
12823 );
12824
12825 let held = core.fetch_boundary().arm();
12826 fetch_tick_tx
12827 .send(Instant::now())
12828 .expect("send a fetch tick");
12829 held.wait_until_reached();
12830 core.pause();
12831 held.wait_until_cancelled();
12832 drop(held);
12833 wait_for("the cancelled cycle to be taken back by the clock", || {
12834 started.fetch_cycles_taken_back.load(Ordering::Acquire) >= 2
12835 });
12836
12837 assert_eq!(
12838 core.fetch_failures().failed.len(),
12839 1,
12840 "a cancelled cycle must leave the completed cycle's own count standing, \
12841 got: {:?}",
12842 core.fetch_failures().failed
12843 );
12844 }
12845
12846 #[test]
12854 fn a_pause_landing_before_the_immediate_cycle_holds_it_until_resume() {
12855 let remote = seeded_remote();
12856 let root = tempfile::tempdir().expect("temp dir");
12857 let root_path = root_of(&root);
12858 clone_into(remote.path(), &root_path.join("parent"));
12859
12860 let (gate, walk_may_run, opener) = gate_opened_on_signal(false);
12861 let started = Core::start_for_test_with_fetch_gated(
12862 fetch_spec(true, root_path),
12863 Duration::from_secs(3600),
12864 crossbeam_channel::never(),
12865 crossbeam_channel::never(),
12866 Some(gate),
12867 );
12868 started.core.pause();
12869 walk_may_run.send(()).expect("the opener is listening");
12870 opener.join().expect("the opener thread should not panic");
12871 let core = started.discovered().core;
12872
12873 core.resume();
12874
12875 wait_for(
12876 "the held immediate cycle to run once the clock resumes",
12877 || core.fetch_cycle_count_for_test() >= 1,
12878 );
12879 }
12880
12881 #[test]
12891 fn dropping_the_core_cancels_and_joins_a_held_fetch_cycle_before_returning() {
12892 let remote = seeded_remote();
12893 let root = tempfile::tempdir().expect("temp dir");
12894 let root_path = root_of(&root);
12895 let parent = root_path.join("parent");
12896 clone_into(remote.path(), &parent);
12897 crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
12898 git(&parent, &["fetch", "origin"]);
12899 let before_tip = rev_parse(&parent, "refs/heads/main");
12900
12901 let (fetch_tick_tx, fetch_tick_rx) = crossbeam_channel::unbounded();
12902 let started = Core::start_for_test_with_fetch(
12903 spec_with_auto_update(false, true, root_path),
12904 Duration::from_secs(3600),
12905 crossbeam_channel::never(),
12906 fetch_tick_rx,
12907 )
12908 .discovered();
12909 let core = started.core;
12910
12911 let held = core.fetch_boundary().arm();
12912 fetch_tick_tx
12913 .send(Instant::now())
12914 .expect("send a fetch tick");
12915 held.wait_until_reached();
12916
12917 let (returned_tx, returned_rx) = crossbeam_channel::bounded::<()>(1);
12918 let teardown = thread::spawn(move || {
12919 drop(core);
12920 let _ = returned_tx.send(());
12921 });
12922
12923 held.wait_until_cancelled();
12924 assert!(
12928 returned_rx
12929 .recv_timeout(Duration::from_millis(200))
12930 .is_err(),
12931 "teardown must still be waiting on the worker it cancelled, not have \
12932 detached it"
12933 );
12934
12935 drop(held);
12936 returned_rx
12937 .recv_timeout(liveness::BACKSTOP)
12938 .expect("teardown returns once the worker it joined has stopped");
12939 teardown
12940 .join()
12941 .expect("the teardown thread should not panic");
12942
12943 assert_eq!(
12944 started.fetch_cycles_taken_back.load(Ordering::Acquire),
12945 1,
12946 "teardown must have taken its own cycle back rather than left it running"
12947 );
12948 assert_eq!(
12949 rev_parse(&parent, "refs/heads/main"),
12950 before_tip,
12951 "no worker may still be fast-forwarding a repository once teardown has \
12952 returned"
12953 );
12954 }
12955
12956 fn break_remote(repo: &Path) {
12962 let status = Command::new("git")
12963 .arg("-C")
12964 .arg(repo)
12965 .args(["remote", "set-url", "origin", "/nonexistent-remote-282"])
12966 .status()
12967 .expect("run git remote set-url");
12968 assert!(status.success());
12969 }
12970
12971 #[test]
12973 fn a_cycle_in_which_every_fetch_succeeds_reports_no_failures() {
12974 let remote = seeded_remote();
12975 let root = tempfile::tempdir().expect("temp dir");
12976 let root_path = root_of(&root);
12977 clone_into(remote.path(), &root_path.join("parent"));
12978
12979 let fetch_ticks: Receiver<Instant> = crossbeam_channel::never();
12980 let started = Core::start_for_test_with_fetch(
12981 fetch_spec(true, root_path),
12982 Duration::from_secs(3600),
12983 crossbeam_channel::never(),
12984 fetch_ticks,
12985 )
12986 .discovered();
12987 let core = started.core;
12988
12989 wait_for("the periodic fetch to run its first cycle", || {
12990 core.fetch_cycle_count_for_test() >= 1
12991 });
12992
12993 assert!(
12994 core.fetch_failures().failed.is_empty(),
12995 "a cycle where every fetch succeeds must report no failures, got: {:?}",
12996 core.fetch_failures().failed
12997 );
12998 }
12999
13000 #[test]
13004 fn a_repository_that_cannot_be_fetched_is_counted_while_its_sibling_still_fetches() {
13005 let good_remote = seeded_remote();
13006 let bad_remote = seeded_remote();
13007 let root = tempfile::tempdir().expect("temp dir");
13008 let root_path = root_of(&root);
13009 let good = root_path.join("good");
13010 let bad = root_path.join("bad");
13011 clone_into(good_remote.path(), &good);
13012 clone_into(bad_remote.path(), &bad);
13013 break_remote(&bad);
13014
13015 crate::test_support::push_new_commit(good_remote.path(), "second.txt", "second\n");
13016 let good_remote_tip = rev_parse(good_remote.path(), "refs/heads/main");
13017
13018 let fetch_ticks: Receiver<Instant> = crossbeam_channel::never();
13019 let started = Core::start_for_test_with_fetch(
13020 fetch_spec(true, root_path),
13021 Duration::from_secs(3600),
13022 crossbeam_channel::never(),
13023 fetch_ticks,
13024 )
13025 .discovered();
13026 let core = started.core;
13027
13028 wait_for(
13029 "the cycle to run and count the one repository it could not fetch",
13030 || core.fetch_failures().failed.len() == 1,
13031 );
13032
13033 let failures = core.fetch_failures();
13034 assert_eq!(
13035 failures.failed.len(),
13036 1,
13037 "exactly one repository failed, so exactly one failure must be counted, \
13038 got: {:?}",
13039 failures.failed
13040 );
13041 assert!(
13042 failures.failed[0].0.to_string_lossy().contains("bad"),
13043 "the counted failure must name the repository that actually failed, \
13044 got: {:?}",
13045 failures.failed
13046 );
13047
13048 wait_for(
13049 "the sibling repository to still fetch despite the other one failing",
13050 || rev_parse(&good, "refs/remotes/origin/main") == good_remote_tip,
13051 );
13052 }
13053
13054 fn push_new_commit_on_branch(remote: &Path, branch: &str, name: &str, contents: &str) {
13058 let contributor = tempfile::tempdir().expect("temp dir");
13059 let status = Command::new("git")
13060 .arg("clone")
13061 .arg("--branch")
13062 .arg(branch)
13063 .arg(remote)
13064 .arg(contributor.path())
13065 .status()
13066 .expect("run git clone");
13067 assert!(status.success());
13068 std::fs::write(contributor.path().join(name), contents).expect("write fixture file");
13069 git(contributor.path(), &["add", name]);
13070 git(contributor.path(), &["commit", "-m", "extra work on topic"]);
13071 git(contributor.path(), &["push", "origin", branch]);
13072 }
13073
13074 #[test]
13082 fn a_finished_fetch_prunes_and_starts_its_own_generation_that_lands_gone() {
13083 let remote = seeded_remote();
13084 let root = tempfile::tempdir().expect("temp dir");
13085 let root_path = root_of(&root);
13086 let parent = root_path.join("parent");
13087 clone_into(remote.path(), &parent);
13088
13089 git(remote.path(), &["branch", "topic"]);
13090 push_new_commit_on_branch(remote.path(), "topic", "topic.txt", "extra work\n");
13091
13092 git(&parent, &["fetch", "origin"]);
13098
13099 let worktree_path = root_path.join("topic-worktree");
13100 git(
13101 &parent,
13102 &[
13103 "worktree",
13104 "add",
13105 "-b",
13106 "topic",
13107 worktree_path.to_str().expect("utf8 path"),
13108 "origin/topic",
13109 ],
13110 );
13111
13112 git(remote.path(), &["branch", "-D", "topic"]);
13116
13117 let fetch_ticks: Receiver<Instant> = crossbeam_channel::never();
13118 let started = Core::start_for_test_with_fetch(
13119 fetch_spec(true, root_path),
13120 Duration::from_secs(3600),
13121 crossbeam_channel::never(),
13122 fetch_ticks,
13123 )
13124 .discovered();
13125 let core = started.core;
13126
13127 wait_for_or(
13128 "a finished fetch's own Generation to land the pruned Worktree as Gone \
13129 without the test ever calling refresh",
13130 || {
13131 core.snapshot()
13132 .entities
13133 .iter()
13134 .filter(|entity| matches!(entity.kind, Kind::Worktree))
13135 .any(|entity| {
13136 matches!(
13137 entity.state.settled(),
13138 Some(Settled::Known {
13139 value: WorktreeState::Gone,
13140 at: _,
13141 stale: _,
13142 })
13143 )
13144 })
13145 },
13146 || {
13147 format!(
13148 "snapshot: {:?}",
13149 core.snapshot()
13150 .entities
13151 .iter()
13152 .map(|entity| (entity.kind, entity.state.settled().cloned()))
13153 .collect::<Vec<_>>()
13154 )
13155 },
13156 );
13157 }
13158
13159 fn spec_with_auto_update(
13160 fetch_enabled: bool,
13161 auto_update_enabled: bool,
13162 root: PathBuf,
13163 ) -> CoreSpec {
13164 let mut spec = fetch_spec(fetch_enabled, root);
13165 spec.auto_update = AutoUpdateSpec {
13166 enabled: auto_update_enabled,
13167 };
13168 spec
13169 }
13170
13171 fn rev_parse(path: &Path, rev: &str) -> String {
13172 let output = Command::new("git")
13173 .arg("-C")
13174 .arg(path)
13175 .args(["rev-parse", rev])
13176 .output()
13177 .expect("run git rev-parse");
13178 assert!(output.status.success(), "git rev-parse {rev} failed");
13179 String::from_utf8(output.stdout)
13180 .expect("utf8 sha")
13181 .trim()
13182 .to_string()
13183 }
13184
13185 #[test]
13192 fn auto_update_is_off_by_default_even_with_fetch_enabled() {
13193 let remote = seeded_remote();
13194 let root = tempfile::tempdir().expect("temp dir");
13195 let root_path = root_of(&root);
13196 let parent = root_path.join("parent");
13197 clone_into(remote.path(), &parent);
13198 let before = rev_parse(&parent, "refs/heads/main");
13199
13200 crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
13201
13202 let fetch_ticks: Receiver<Instant> = crossbeam_channel::never();
13203 let started = Core::start_for_test_with_fetch(
13204 spec_with_auto_update(true, false, root_path),
13205 Duration::from_secs(3600),
13206 crossbeam_channel::never(),
13207 fetch_ticks,
13208 )
13209 .discovered();
13210 let core = started.core;
13211
13212 wait_for(
13213 "the periodic fetch to still run its immediate cycle",
13214 || core.fetch_cycle_count_for_test() >= 1,
13215 );
13216 assert_eq!(
13217 rev_parse(&parent, "refs/heads/main"),
13218 before,
13219 "an eligible branch must not move while auto_update.enabled is false, \
13220 even though fetch.enabled is true"
13221 );
13222 }
13223
13224 #[test]
13231 fn auto_update_enabled_rides_the_immediate_fetch_cycle_with_no_timer_of_its_own() {
13232 let remote = seeded_remote();
13233 let root = tempfile::tempdir().expect("temp dir");
13234 let root_path = root_of(&root);
13235 let parent = root_path.join("parent");
13236 clone_into(remote.path(), &parent);
13237
13238 crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
13239 let remote_tip = rev_parse(remote.path(), "refs/heads/main");
13240
13241 let fetch_ticks: Receiver<Instant> = crossbeam_channel::never();
13242 let started = Core::start_for_test_with_fetch(
13243 spec_with_auto_update(true, true, root_path),
13244 Duration::from_secs(3600),
13245 crossbeam_channel::never(),
13246 fetch_ticks,
13247 )
13248 .discovered();
13249 let _core = started.core;
13252
13253 wait_for(
13254 "the eligible branch to fast-forward on the immediate cycle alone, with no \
13255 fetch tick and no auto-update tick of its own",
13256 || rev_parse(&parent, "refs/heads/main") == remote_tip,
13257 );
13258 }
13259 }
13260
13261 mod attempt_auto_update {
13271 use super::*;
13272
13273 fn seeded_remote() -> tempfile::TempDir {
13274 let remote = tempfile::tempdir().expect("temp dir");
13275 crate::test_support::init_bare(remote.path());
13276 crate::test_support::push_new_commit(remote.path(), "README.md", "seed\n");
13277 remote
13278 }
13279
13280 fn clone_into(remote: &Path, dest: &Path) {
13281 let status = Command::new("git")
13282 .arg("clone")
13283 .arg(remote)
13284 .arg(dest)
13285 .status()
13286 .expect("run git clone");
13287 assert!(status.success());
13288 crate::test_support::set_identity(dest);
13289 }
13290
13291 fn discover_repo(root: &Path) -> (Core, EntityKey) {
13296 let core = Core::start_discovered(spec(vec![root.to_path_buf()]));
13297 let key = core
13298 .settle()
13299 .entities
13300 .into_iter()
13301 .find(|entity| entity.kind == Kind::Repo)
13302 .expect("the Repo row is discovered")
13303 .key;
13304 (core, key)
13305 }
13306
13307 #[test]
13310 fn an_eligible_repo_fast_forwards_through_the_wrapper_too() {
13311 let remote = seeded_remote();
13312 let root = tempfile::tempdir().expect("temp dir");
13313 let root_path = root_of(&root);
13314 let repo = root_path.join("repo");
13315 clone_into(remote.path(), &repo);
13316 crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
13317 crate::test_support::git(&repo, &["fetch", "origin"]);
13318
13319 let (core, key) = discover_repo(&root_path);
13320
13321 assert_eq!(core.attempt_auto_update(&key), AutoUpdateAttempt::Updated);
13322 assert!(
13323 repo.join("second.txt").exists(),
13324 "the fast-forward must reach the working tree through the wrapper too"
13325 );
13326 }
13327
13328 #[test]
13330 fn a_dirty_repo_is_reported_not_clean_through_the_wrapper_too() {
13331 let remote = seeded_remote();
13332 let root = tempfile::tempdir().expect("temp dir");
13333 let root_path = root_of(&root);
13334 let repo = root_path.join("repo");
13335 clone_into(remote.path(), &repo);
13336 crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
13337 crate::test_support::git(&repo, &["fetch", "origin"]);
13338 fs::write(repo.join("stray.txt"), "uncommitted\n").expect("write a stray file");
13339
13340 let (core, key) = discover_repo(&root_path);
13341
13342 assert_eq!(core.attempt_auto_update(&key), AutoUpdateAttempt::NotClean);
13343 }
13344
13345 #[test]
13347 fn an_up_to_date_repo_is_reported_not_behind_through_the_wrapper_too() {
13348 let remote = seeded_remote();
13349 let root = tempfile::tempdir().expect("temp dir");
13350 let root_path = root_of(&root);
13351 let repo = root_path.join("repo");
13352 clone_into(remote.path(), &repo);
13353 crate::test_support::git(&repo, &["fetch", "origin"]);
13354
13355 let (core, key) = discover_repo(&root_path);
13356
13357 assert_eq!(core.attempt_auto_update(&key), AutoUpdateAttempt::NotBehind);
13358 }
13359
13360 #[test]
13362 fn an_unpublished_local_commit_is_reported_not_fast_forward_through_the_wrapper_too() {
13363 let remote = seeded_remote();
13364 let root = tempfile::tempdir().expect("temp dir");
13365 let root_path = root_of(&root);
13366 let repo = root_path.join("repo");
13367 clone_into(remote.path(), &repo);
13368 crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
13369 crate::test_support::git(&repo, &["fetch", "origin"]);
13370 crate::test_support::commit_file(&repo, "local-only.txt", "never pushed\n");
13371
13372 let (core, key) = discover_repo(&root_path);
13373
13374 assert_eq!(
13375 core.attempt_auto_update(&key),
13376 AutoUpdateAttempt::NotFastForward
13377 );
13378 }
13379
13380 #[test]
13382 fn a_branch_with_no_upstream_is_reported_through_the_wrapper_too() {
13383 let remote = seeded_remote();
13384 let root = tempfile::tempdir().expect("temp dir");
13385 let root_path = root_of(&root);
13386 let repo = root_path.join("repo");
13387 clone_into(remote.path(), &repo);
13388 crate::test_support::git(&repo, &["checkout", "-b", "untracked-branch"]);
13389
13390 let (core, key) = discover_repo(&root_path);
13391
13392 assert_eq!(
13393 core.attempt_auto_update(&key),
13394 AutoUpdateAttempt::NoUpstream
13395 );
13396 }
13397 }
13398
13399 mod network_default_branch {
13406 use super::*;
13407
13408 fn seeded_remote() -> tempfile::TempDir {
13409 let remote = tempfile::tempdir().expect("temp dir");
13410 crate::test_support::init_bare(remote.path());
13411 crate::test_support::push_new_commit(remote.path(), "README.md", "seed\n");
13412 remote
13413 }
13414
13415 fn clone_into(remote: &Path, dest: &Path) {
13416 let status = Command::new("git")
13417 .arg("clone")
13418 .arg(remote)
13419 .arg(dest)
13420 .status()
13421 .expect("run git clone");
13422 assert!(status.success());
13423 crate::test_support::set_identity(dest);
13424 }
13425
13426 fn set_remote_head(path: &Path, branch: &str) {
13429 git(
13430 path,
13431 &["symbolic-ref", "HEAD", &format!("refs/heads/{branch}")],
13432 );
13433 }
13434
13435 fn rev_parse(path: &Path, rev: &str) -> String {
13436 let output = Command::new("git")
13437 .arg("-C")
13438 .arg(path)
13439 .args(["rev-parse", rev])
13440 .output()
13441 .expect("run git rev-parse");
13442 assert!(output.status.success());
13443 String::from_utf8(output.stdout)
13444 .expect("utf8 sha")
13445 .trim()
13446 .to_string()
13447 }
13448
13449 fn default_branch_name(entity: &EntityState) -> Option<String> {
13450 match entity.default_branch.settled() {
13451 Some(Settled::Known {
13452 value,
13453 at: _,
13454 stale: _,
13455 }) => Some(value.name().to_string()),
13456 _ => None,
13457 }
13458 }
13459
13460 #[test]
13470 fn the_local_chain_answers_first_and_only_a_later_network_round_trip_supersedes_it() {
13471 let remote = seeded_remote();
13472 let root = tempfile::tempdir().expect("temp dir");
13473 let root_path = root_of(&root);
13474 let repo_path = root_path.join("repo");
13475 clone_into(remote.path(), &repo_path);
13476
13477 git(remote.path(), &["branch", "trunk"]);
13480 set_remote_head(remote.path(), "trunk");
13481
13482 let core = Core::start_discovered(spec(vec![root_path]));
13483 let key = core.snapshot().entities[0].key.clone();
13484
13485 core.refresh(std::slice::from_ref(&key));
13486 let settled = core.settle();
13487 assert_eq!(
13488 default_branch_name(&settled.entities[0]),
13489 Some("origin/main".to_string()),
13490 "a plain refresh must answer from the local chain alone, unaffected by the \
13491 remote's own current (but not yet asked) truth"
13492 );
13493
13494 core.rederive_default_branches(std::slice::from_ref(&key));
13495 let settled = core.settle();
13496 assert_eq!(
13497 default_branch_name(&settled.entities[0]),
13498 Some("origin/trunk".to_string()),
13499 "once the network round trip actually ran, its own differing answer must \
13500 supersede the local chain's"
13501 );
13502 }
13503
13504 #[test]
13514 fn rederive_default_branches_never_fetches_and_leaves_a_row_outside_it_untouched() {
13515 let remote = seeded_remote();
13516 let root = tempfile::tempdir().expect("temp dir");
13517 let root_path = root_of(&root);
13518 let selected_path = root_path.join("selected");
13519 let outside_path = root_path.join("outside");
13520 clone_into(remote.path(), &selected_path);
13521 init_repo_with_a_commit(&outside_path);
13522
13523 git(remote.path(), &["branch", "trunk"]);
13524 crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
13525 set_remote_head(remote.path(), "trunk");
13526 let before_tracking = rev_parse(&selected_path, "refs/remotes/origin/main");
13527
13528 let core = Core::start_discovered(spec(vec![root_path]));
13529 let snapshot = core.snapshot();
13530 let selected_key = snapshot
13531 .entities
13532 .iter()
13533 .find(|entity| entity.key.path() == selected_path)
13534 .expect("discovered the selected repo")
13535 .key
13536 .clone();
13537 let outside_key = snapshot
13538 .entities
13539 .iter()
13540 .find(|entity| entity.key.path() == outside_path)
13541 .expect("discovered the outside repo")
13542 .key
13543 .clone();
13544
13545 core.refresh(&[selected_key.clone(), outside_key.clone()]);
13546 let settled = core.settle();
13547 let outside_before = format!(
13548 "{:?}",
13549 settled
13550 .entities
13551 .iter()
13552 .find(|entity| entity.key == outside_key)
13553 .expect("outside entity present")
13554 );
13555
13556 core.rederive_default_branches(std::slice::from_ref(&selected_key));
13557 let settled = core.settle();
13558
13559 let selected_after = settled
13560 .entities
13561 .iter()
13562 .find(|entity| entity.key == selected_key)
13563 .expect("selected entity present");
13564 assert_eq!(
13565 default_branch_name(selected_after),
13566 Some("origin/trunk".to_string()),
13567 "the rederive must have reached the remote's own current, differing answer"
13568 );
13569
13570 let after_tracking = rev_parse(&selected_path, "refs/remotes/origin/main");
13571 assert_eq!(
13572 before_tracking, after_tracking,
13573 "a rederive must never fetch: the remote-tracking ref must not have moved \
13574 even though the remote gained a new commit"
13575 );
13576
13577 let outside_after = format!(
13578 "{:?}",
13579 settled
13580 .entities
13581 .iter()
13582 .find(|entity| entity.key == outside_key)
13583 .expect("outside entity present")
13584 );
13585 assert_eq!(
13586 outside_before, outside_after,
13587 "a row outside the rederive's own keys must be left exactly as it was, not \
13588 only on its default_branch cell"
13589 );
13590 }
13591 }
13592
13593 #[test]
13601 fn set_exclusions_excludes_a_row_already_in_the_table_with_no_rebuild() {
13602 let dir = tempfile::tempdir().expect("temp dir");
13603 let root = root_of(&dir);
13604 let repo = root.join("repo");
13605 init_repo_with_a_commit(&repo);
13606
13607 let core = Core::start_discovered(spec(vec![root]));
13608 let snapshot = core.settle();
13609 let key = snapshot.entities[0].key.clone();
13610 let generation_before = snapshot.generation;
13611 assert!(
13612 !snapshot.entities[0].excluded,
13613 "nothing excludes it to start with"
13614 );
13615 assert_eq!(core.operable_count(std::slice::from_ref(&key)), 1);
13616
13617 core.set_exclusions(&[RepoOverride {
13618 path: repo.clone(),
13619 default_branch: None,
13620 excluded: true,
13621 }]);
13622
13623 let after = core.snapshot();
13624 assert!(
13625 after.entities[0].excluded,
13626 "the row the write named is excluded in the very next snapshot"
13627 );
13628 assert_eq!(
13629 core.operable_count(&[key]),
13630 0,
13631 "an excluded row is subtracted from what an operation may reach"
13632 );
13633 assert_eq!(
13634 after.generation, generation_before,
13635 "re-applying an operate-time filter must start no Generation of its own"
13636 );
13637 }
13638
13639 #[test]
13642 fn set_exclusions_clears_the_flag_when_the_entry_is_gone() {
13643 let dir = tempfile::tempdir().expect("temp dir");
13644 let root = root_of(&dir);
13645 let repo = root.join("repo");
13646 init_repo_with_a_commit(&repo);
13647
13648 let core = Core::start_discovered(spec_with_overrides(
13649 vec![root],
13650 vec![RepoOverride {
13651 path: repo.clone(),
13652 default_branch: None,
13653 excluded: true,
13654 }],
13655 ));
13656 assert!(
13657 core.settle().entities[0].excluded,
13658 "the starting override excludes it"
13659 );
13660
13661 core.set_exclusions(&[]);
13662
13663 assert!(
13664 !core.snapshot().entities[0].excluded,
13665 "removing the entry unexcludes the row in the very next snapshot"
13666 );
13667 }
13668
13669 #[test]
13674 fn set_exclusions_moves_exclude_alone_and_never_the_default_branch_override() {
13675 let dir = tempfile::tempdir().expect("temp dir");
13676 let root = root_of(&dir);
13677 let repo = root.join("repo");
13678 init_repo_with_a_commit(&repo);
13679 crate::test_support::git(&repo, &["branch", "trunk"]);
13680
13681 let core = Core::start_discovered(spec(vec![root]));
13682 let key = core.settle().entities[0].key.clone();
13683 core.refresh(std::slice::from_ref(&key));
13684 let before = format!("{:?}", core.settle().entities[0].default_branch.settled());
13685
13686 core.set_exclusions(&[RepoOverride {
13687 path: repo.clone(),
13688 default_branch: Some("trunk".to_string()),
13689 excluded: true,
13690 }]);
13691 core.refresh(&[key]);
13692 core.settle();
13693
13694 let after = core.snapshot();
13695 assert!(after.entities[0].excluded, "exclude took effect");
13696 assert_eq!(
13697 format!("{:?}", after.entities[0].default_branch.settled()),
13698 before,
13699 "a default_branch override reaches a session only through a rebuilt Core"
13700 );
13701 }
13702
13703 #[test]
13711 fn record_own_work_leaves_one_receipt_per_row_it_names_and_none_elsewhere() {
13712 let dir = tempfile::tempdir().expect("temp dir");
13713 let root = root_of(&dir);
13714 init_repo_with_a_commit(&root.join("repo-a"));
13715 init_repo_with_a_commit(&root.join("repo-b"));
13716
13717 let core = Core::start_discovered(spec(vec![root]));
13718 let entities = core.settle().entities;
13719 let named = entities
13720 .iter()
13721 .find(|entity| &*entity.name == "repo-a")
13722 .expect("repo-a is discovered")
13723 .key
13724 .clone();
13725
13726 core.record_own_work(
13727 "ignore",
13728 &[(
13729 named.clone(),
13730 OwnWork::Refused(Arc::from("refused, already ignored")),
13731 Duration::from_millis(7),
13732 )],
13733 );
13734
13735 let after = core.snapshot().entities;
13736 let receipt = after
13737 .iter()
13738 .find(|entity| entity.key == named)
13739 .and_then(|entity| entity.last_action.clone())
13740 .expect("the row it named carries a receipt");
13741 assert_eq!(&*receipt.label, "ignore");
13742 assert!(
13743 !receipt.not_applicable(),
13744 "a refusal is not an excluded row"
13745 );
13746 assert!(receipt.running.is_none(), "the work is already done");
13747 assert_eq!(receipt.steps.len(), 1, "one act, not an ordered list");
13748 assert_eq!(&*receipt.steps[0].label, "ignore");
13749 assert_eq!(receipt.steps[0].elapsed, Duration::from_millis(7));
13750 assert!(receipt.steps[0].output.is_empty(), "nothing to quote");
13751 assert!(receipt.steps[0].elision.is_none());
13752 assert_eq!(
13753 receipt.steps[0].outcome,
13754 StepOutcome::OwnWork(OwnWork::Refused(Arc::from("refused, already ignored"))),
13755 );
13756 assert!(
13757 after
13758 .iter()
13759 .filter(|entity| entity.key != named)
13760 .all(|entity| entity.last_action.is_none()),
13761 "no row this did not name takes a receipt"
13762 );
13763 }
13764
13765 #[test]
13769 fn record_own_work_skips_a_key_the_table_no_longer_holds() {
13770 let dir = tempfile::tempdir().expect("temp dir");
13771 let root = root_of(&dir);
13772 init_repo_with_a_commit(&root.join("repo-a"));
13773
13774 let core = Core::start_discovered(spec(vec![root]));
13775 let entities = core.settle().entities;
13776 let stranger = EntityKey::new(Arc::from(std::path::Path::new("/nowhere/at/all")));
13777
13778 core.record_own_work(
13779 "delete",
13780 &[(stranger, OwnWork::Did(Arc::from("gone")), Duration::ZERO)],
13781 );
13782
13783 assert!(
13784 core.snapshot()
13785 .entities
13786 .iter()
13787 .all(|entity| entity.last_action.is_none()),
13788 "an unknown key writes nothing anywhere"
13789 );
13790 assert_eq!(core.snapshot().entities.len(), entities.len());
13791 }
13792
13793 #[test]
13802 fn delete_risk_reads_all_three_facts_the_confirm_gate_names() {
13803 let dir = tempfile::tempdir().expect("temp dir");
13804 let root = root_of(&dir);
13805 let repo = root.join("repo");
13806 init_repo_with_a_commit(&repo);
13807 fs::write(repo.join("uncommitted.txt"), "not staged\n").expect("write a stray file");
13808 crate::test_support::git(
13809 &repo,
13810 &["worktree", "add", "-b", "sidecar", "../sidecar-worktree"],
13811 );
13812
13813 let core = Core::start_discovered(spec(vec![root]));
13814 let key = core
13818 .settle()
13819 .entities
13820 .into_iter()
13821 .find(|entity| entity.kind == Kind::Repo)
13822 .expect("the Repo row is discovered")
13823 .key;
13824
13825 let risk = core.delete_risk(&key).expect("read the risk");
13826
13827 assert!(risk.uncommitted, "the stray file makes the tree dirty");
13828 assert!(
13829 risk.unpushed_commits > 0 && risk.unpushed_branches > 0,
13830 "no remote-tracking ref carries any of this Repo's commits, got {risk:?}"
13831 );
13832 assert_eq!(
13833 risk.linked_worktrees, 1,
13834 "the one linked Worktree pointing into this Repo is counted, got {risk:?}"
13835 );
13836 }
13837
13838 #[test]
13844 fn every_kind_of_work_that_is_not_in_a_commit_makes_the_gate_say_uncommitted() {
13845 for kind in ["modified", "deleted", "untracked", "staged"] {
13846 let dir = tempfile::tempdir().expect("temp dir");
13847 let root = root_of(&dir);
13848 let repo = root.join("repo");
13849 init_repo_with_a_commit(&repo);
13850 fs::write(repo.join("tracked.txt"), "first\n").expect("write a tracked file");
13851 crate::test_support::git(&repo, &["add", "tracked.txt"]);
13852 crate::test_support::git(&repo, &["commit", "-m", "add tracked"]);
13853 let sha = crate::test_support::head_sha(&repo);
13854 crate::test_support::git(&repo, &["update-ref", "refs/remotes/origin/main", &sha]);
13855
13856 match kind {
13857 "modified" => fs::write(repo.join("tracked.txt"), "second\n").expect("modify it"),
13858 "deleted" => fs::remove_file(repo.join("tracked.txt")).expect("delete it"),
13859 "untracked" => fs::write(repo.join("stray.txt"), "new\n").expect("write a stray"),
13860 "staged" => {
13861 fs::write(repo.join("staged.txt"), "new\n").expect("write a new file");
13862 crate::test_support::git(&repo, &["add", "staged.txt"]);
13863 }
13864 other => unreachable!("unhandled kind {other}"),
13865 }
13866
13867 let core = Core::start_discovered(spec(vec![root]));
13868 let key = core.settle().entities[0].key.clone();
13869
13870 let risk = core.delete_risk(&key).expect("read the risk");
13871
13872 assert!(
13873 risk.uncommitted,
13874 "a {kind} change is work that is not in a commit, got {risk:?}"
13875 );
13876 }
13877 }
13878
13879 #[test]
13886 fn staged_work_reads_clean_to_the_dirty_column_and_uncommitted_to_the_delete_gate() {
13887 let dir = tempfile::tempdir().expect("temp dir");
13888 let root = root_of(&dir);
13889 let repo = root.join("repo");
13890 init_repo_with_a_commit(&repo);
13891 let sha = crate::test_support::head_sha(&repo);
13892 crate::test_support::git(&repo, &["update-ref", "refs/remotes/origin/main", &sha]);
13893 fs::write(repo.join("staged.txt"), "staged\n").expect("write a new file");
13894 crate::test_support::git(&repo, &["add", "staged.txt"]);
13895
13896 let core = Core::start_discovered(spec(vec![root]));
13897 let key = core.settle().entities[0].key.clone();
13898
13899 let opened = git::open_thread_safe(repo.as_path())
13900 .expect("open the repo")
13901 .to_thread_local();
13902 let dirty = git::dirty_counts(&opened, Arc::new(AtomicBool::new(false)))
13903 .expect("read the dirty counts");
13904 assert_eq!(
13905 dirty.total(),
13906 0,
13907 "the dirty column stays an index-to-worktree comparison, got {dirty:?}"
13908 );
13909
13910 let risk = core.delete_risk(&key).expect("read the risk");
13911 assert!(
13912 risk.uncommitted,
13913 "a Repo whose only work is staged must never be listed plainly, got {risk:?}"
13914 );
13915 }
13916
13917 #[test]
13921 fn unpushed_commits_and_unpushed_branches_are_counted_into_their_own_fields() {
13922 let dir = tempfile::tempdir().expect("temp dir");
13923 let root = root_of(&dir);
13924 let repo = root.join("repo");
13925 init_repo_with_a_commit(&repo);
13926 let sha = crate::test_support::head_sha(&repo);
13927 crate::test_support::git(&repo, &["update-ref", "refs/remotes/origin/main", &sha]);
13928 for nth in 0..3 {
13929 fs::write(repo.join(format!("file-{nth}.txt")), "x\n").expect("write a file");
13930 crate::test_support::git(&repo, &["add", "."]);
13931 crate::test_support::git(&repo, &["commit", "-m", "unpushed"]);
13932 }
13933 crate::test_support::git(&repo, &["checkout", "."]);
13934
13935 let core = Core::start_discovered(spec(vec![root]));
13936 let key = core.settle().entities[0].key.clone();
13937
13938 let risk = core.delete_risk(&key).expect("read the risk");
13939
13940 assert_eq!(
13941 (risk.unpushed_commits, risk.unpushed_branches),
13942 (3, 1),
13943 "three commits on one branch, each in its own field, got {risk:?}"
13944 );
13945 }
13946
13947 #[test]
13951 fn a_linked_worktree_outside_the_sets_roots_is_still_counted_by_the_gate() {
13952 let dir = tempfile::tempdir().expect("temp dir");
13953 let base = root_of(&dir);
13954 let inside = base.join("inside");
13955 let outside = base.join("outside");
13956 fs::create_dir_all(&outside).expect("create the outside dir");
13957 let repo = inside.join("repo");
13958 init_repo_with_a_commit(&repo);
13959 crate::test_support::git(
13960 &repo,
13961 &["worktree", "add", "-b", "sidecar", "../../outside/sidecar"],
13962 );
13963 assert!(
13964 outside.join("sidecar").exists(),
13965 "the harness really created a linked Worktree outside the Set's roots"
13966 );
13967
13968 let core = Core::start_discovered(spec(vec![inside]));
13970 let snapshot = core.settle();
13971 assert!(
13972 snapshot
13973 .entities
13974 .iter()
13975 .all(|entity| entity.kind != Kind::Worktree),
13976 "the Worktree is outside the roots and so is not discovered, got {:?}",
13977 snapshot.entities.iter().map(|e| e.kind).collect::<Vec<_>>()
13978 );
13979 let key = snapshot
13980 .entities
13981 .into_iter()
13982 .find(|entity| entity.kind == Kind::Repo)
13983 .expect("the Repo row is discovered")
13984 .key;
13985
13986 let risk = core.delete_risk(&key).expect("read the risk");
13987
13988 assert_eq!(
13989 risk.linked_worktrees, 1,
13990 "the gate must name the linked Worktree deleting this Repo would orphan, got {risk:?}"
13991 );
13992 }
13993
13994 #[test]
13999 fn delete_risk_on_a_clean_fully_pushed_repo_with_no_worktrees_reports_nothing() {
14000 let dir = tempfile::tempdir().expect("temp dir");
14001 let root = root_of(&dir);
14002 let repo = root.join("repo");
14003 init_repo_with_a_commit(&repo);
14004 let sha = crate::test_support::head_sha(&repo);
14005 crate::test_support::git(&repo, &["update-ref", "refs/remotes/origin/main", &sha]);
14006
14007 let core = Core::start_discovered(spec(vec![root]));
14008 let key = core.settle().entities[0].key.clone();
14009
14010 let risk = core.delete_risk(&key).expect("read the risk");
14011
14012 assert_eq!(
14013 risk,
14014 DeleteRisk {
14015 uncommitted: false,
14016 unpushed_commits: 0,
14017 unpushed_branches: 0,
14018 linked_worktrees: 0,
14019 }
14020 );
14021 }
14022
14023 #[test]
14032 fn worktree_admin_dir_names_the_entry_git_worktree_list_forgets_once_it_is_removed() {
14033 let dir = tempfile::tempdir().expect("temp dir");
14034 let root = root_of(&dir);
14035 let repo = root.join("repo");
14036 init_repo_with_a_commit(&repo);
14037 let worktree = root.join("sidecar");
14038 crate::test_support::git(
14039 &repo,
14040 &[
14041 "worktree",
14042 "add",
14043 "-b",
14044 "sidecar",
14045 worktree.to_str().expect("utf8 path"),
14046 ],
14047 );
14048
14049 let core = Core::start_discovered(spec(vec![root]));
14050 let key = core
14051 .settle()
14052 .entities
14053 .into_iter()
14054 .find(|entity| entity.kind == Kind::Worktree)
14055 .expect("the Worktree row is discovered")
14056 .key;
14057
14058 let admin_dir = core.worktree_admin_dir(&key).expect("read the admin dir");
14059 fs::remove_dir_all(&admin_dir).expect("remove the admin dir by hand");
14060
14061 let reopened = git::open_thread_safe(&repo)
14062 .expect("reopen the repo")
14063 .to_thread_local();
14064 assert_eq!(
14065 git::linked_worktrees(&reopened).expect("count"),
14066 0,
14067 "removing the admin dir alone must be what git's own register stops naming"
14068 );
14069 }
14070
14071 #[test]
14075 fn worktree_admin_dir_errors_when_the_path_cannot_be_opened_as_a_repository() {
14076 let dir = tempfile::tempdir().expect("temp dir");
14077 let root = root_of(&dir);
14078 let not_a_repo = root.join("plain-directory");
14079 fs::create_dir_all(¬_a_repo).expect("create it");
14080
14081 let core = Core::start_discovered(spec(vec![root]));
14082 core.settle();
14083 let key = EntityKey::new(Arc::from(not_a_repo.as_path()));
14084
14085 assert!(core.worktree_admin_dir(&key).is_err());
14086 }
14087
14088 #[test]
14091 fn linked_worktree_paths_names_every_linked_worktrees_own_directory() {
14092 let dir = tempfile::tempdir().expect("temp dir");
14093 let root = root_of(&dir);
14094 let repo = root.join("repo");
14095 init_repo_with_a_commit(&repo);
14096 let first = root.join("first-worktree");
14097 let second = root.join("second-worktree");
14098 crate::test_support::git(
14099 &repo,
14100 &[
14101 "worktree",
14102 "add",
14103 "-b",
14104 "one",
14105 first.to_str().expect("utf8 path"),
14106 ],
14107 );
14108 crate::test_support::git(
14109 &repo,
14110 &[
14111 "worktree",
14112 "add",
14113 "-b",
14114 "two",
14115 second.to_str().expect("utf8 path"),
14116 ],
14117 );
14118
14119 let core = Core::start_discovered(spec(vec![root]));
14120 let key = core
14121 .settle()
14122 .entities
14123 .into_iter()
14124 .find(|entity| entity.kind == Kind::Repo)
14125 .expect("the Repo row is discovered")
14126 .key;
14127
14128 let mut paths = core
14129 .linked_worktree_paths(&key)
14130 .expect("read the linked worktree paths");
14131 paths.sort();
14132 let mut expected = vec![
14133 first.canonicalize().expect("canonicalize first"),
14134 second.canonicalize().expect("canonicalize second"),
14135 ];
14136 expected.sort();
14137
14138 assert_eq!(paths, expected);
14139 }
14140}