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 FetchOwed,
399 FetchNow,
404}
405
406pub struct Core {
414 table: Arc<RwLock<Table>>,
415 overrides: Arc<Vec<ResolvedOverride>>,
419 exclusions: Arc<RwLock<Vec<ResolvedExclusion>>>,
427 set: SetSpec,
435 discovery_manual: Arc<AtomicBool>,
440 discovery_warn_after: Duration,
443 discovery_abandon_after: Arc<AtomicU64>,
448 show_submodules: Arc<AtomicBool>,
455 settle_gate: Arc<SettleGate>,
456 control: Sender<ClockControl>,
457 clock_thread: Option<JoinHandle<()>>,
458 discovery_warning: Arc<Mutex<Option<String>>>,
464 #[allow(dead_code)] default_branch_chain_reads: Arc<AtomicUsize>,
476 #[allow(dead_code)] patch_identity_reads: Arc<AtomicUsize>,
486 #[allow(dead_code)] patch_scan_bounds: Arc<Mutex<Vec<Option<gix::ObjectId>>>>,
494 action_lifecycle: Arc<Mutex<ActionLifecycle>>,
500 #[allow(dead_code)] dispatch_log: Arc<Mutex<Vec<EntityKey>>>,
509 #[allow(dead_code)] phase_c_gates: Arc<Mutex<HashMap<EntityKey, PhaseCGateHandle>>>,
519 status_stale_after: Duration,
525 #[allow(dead_code)] poll_reprobed: Arc<Mutex<Vec<EntityKey>>>,
531 #[allow(dead_code)] poll_sweep_count: Arc<AtomicUsize>,
538 #[allow(dead_code)] fetch_cycle_count: Arc<AtomicUsize>,
545 network_default_branch: Arc<Mutex<HashMap<PathBuf, Arc<str>>>>,
556 fetch_failures: Arc<Mutex<FetchFailures>>,
560 fetch_running: Arc<AtomicBool>,
565 turnstile: Arc<DispatchTurnstile>,
568 discovery_gate: Option<DiscoveryGate>,
570 #[cfg(test)]
573 action_completion_boundary: Arc<ActionCompletionBoundary>,
574 #[cfg(test)]
577 fetch_boundary: Arc<FetchBoundary>,
578}
579
580#[derive(Default)]
583struct PhaseCGate {
584 cheap_landed: bool,
586 may_proceed: bool,
589 finished: bool,
592}
593
594type PhaseCGateHandle = Arc<(Mutex<PhaseCGate>, Condvar)>;
597
598#[derive(Default)]
607struct ActionLifecycle {
608 live: Option<Arc<executor::RunControl>>,
617}
618
619impl ActionLifecycle {
620 fn admit(&mut self, control: Arc<executor::RunControl>) -> bool {
625 if self.live.is_some() {
626 return false;
627 }
628 self.live = Some(control);
629 true
630 }
631
632 fn complete(&mut self) {
634 self.live = None;
635 }
636}
637
638struct RunCompletion {
646 lifecycle: Arc<Mutex<ActionLifecycle>>,
647 #[cfg(test)]
649 boundary: Arc<ActionCompletionBoundary>,
650}
651
652impl Drop for RunCompletion {
653 fn drop(&mut self) {
654 #[cfg(test)]
656 self.boundary.hold();
657 self.lifecycle.lock().unwrap().complete();
658 }
659}
660
661#[cfg(test)]
670#[derive(Default)]
671pub(crate) struct ActionCompletionBoundary {
672 state: Mutex<BoundaryState>,
673 changed: Condvar,
674}
675
676#[cfg(test)]
678#[derive(Default)]
679struct BoundaryState {
680 armed: bool,
682 reached: bool,
684 released: bool,
686}
687
688#[cfg(test)]
689impl ActionCompletionBoundary {
690 pub(crate) fn arm(self: &Arc<Self>) -> ArmedBoundary {
693 self.state.lock().unwrap().armed = true;
694 ArmedBoundary(Arc::clone(self))
695 }
696
697 fn hold(&self) {
699 let mut state = self.state.lock().unwrap();
700 if !state.armed {
701 return;
702 }
703 state.reached = true;
704 self.changed.notify_all();
705 let (state, expiry) = self
706 .changed
707 .wait_timeout_while(state, liveness::BACKSTOP, |state| !state.released)
708 .unwrap();
709 drop(state);
710 if expiry.timed_out() {
711 liveness::expired(
712 liveness::BACKSTOP,
713 "a test to release the Action completion boundary",
714 "",
715 );
716 }
717 }
718}
719
720#[cfg(test)]
724pub(crate) struct ArmedBoundary(Arc<ActionCompletionBoundary>);
725
726#[cfg(test)]
727impl ArmedBoundary {
728 pub(crate) fn wait_until_reached(&self) {
730 let (state, expiry) = self
731 .0
732 .changed
733 .wait_timeout_while(self.0.state.lock().unwrap(), liveness::BACKSTOP, |state| {
734 !state.reached
735 })
736 .unwrap();
737 drop(state);
738 if expiry.timed_out() {
739 liveness::expired(
740 liveness::BACKSTOP,
741 "a completion to reach the Action completion boundary",
742 "",
743 );
744 }
745 }
746}
747
748#[cfg(test)]
749impl Drop for ArmedBoundary {
750 fn drop(&mut self) {
751 let mut state = self.0.state.lock().unwrap();
752 state.released = true;
753 self.0.changed.notify_all();
754 }
755}
756
757#[cfg(test)]
768#[derive(Default)]
769pub(crate) struct FetchBoundary {
770 state: Mutex<FetchBoundaryState>,
771 changed: Condvar,
772}
773
774#[cfg(test)]
776#[derive(Default)]
777struct FetchBoundaryState {
778 armed: bool,
780 reached: bool,
782 released: bool,
784 cancelled: bool,
786}
787
788#[cfg(test)]
789impl FetchBoundary {
790 pub(crate) fn arm(self: &Arc<Self>) -> ArmedFetchBoundary {
794 *self.state.lock().unwrap() = FetchBoundaryState {
795 armed: true,
796 ..FetchBoundaryState::default()
797 };
798 ArmedFetchBoundary(Arc::clone(self))
799 }
800
801 fn hold(&self) {
807 let mut state = self.state.lock().unwrap();
808 if !state.armed {
809 return;
810 }
811 state.reached = true;
812 self.changed.notify_all();
813 drop(
814 self.changed
815 .wait_while(state, |state| !state.released)
816 .unwrap(),
817 );
818 }
819
820 fn cancelled(&self) {
823 self.state.lock().unwrap().cancelled = true;
824 self.changed.notify_all();
825 }
826}
827
828#[cfg(test)]
831pub(crate) struct ArmedFetchBoundary(Arc<FetchBoundary>);
832
833#[cfg(test)]
834impl ArmedFetchBoundary {
835 pub(crate) fn wait_until_reached(&self) {
837 self.wait_until("a fetch to reach the fetch boundary", |state| state.reached);
838 }
839
840 pub(crate) fn wait_until_cancelled(&self) {
842 self.wait_until("the held cycle's own cancellation", |state| state.cancelled);
843 }
844
845 fn wait_until(&self, property: &str, held: impl Fn(&FetchBoundaryState) -> bool) {
846 let (state, expiry) = self
847 .0
848 .changed
849 .wait_timeout_while(self.0.state.lock().unwrap(), liveness::BACKSTOP, |state| {
850 !held(state)
851 })
852 .unwrap();
853 drop(state);
854 if expiry.timed_out() {
855 liveness::expired(liveness::BACKSTOP, property, "");
856 }
857 }
858}
859
860#[cfg(test)]
861impl Drop for ArmedFetchBoundary {
862 fn drop(&mut self) {
863 let mut state = self.0.state.lock().unwrap();
864 state.released = true;
865 self.0.changed.notify_all();
866 }
867}
868
869impl Core {
870 pub fn start(spec: CoreSpec) -> Core {
880 Self::start_watched(spec).core
881 }
882
883 fn start_watched(spec: CoreSpec) -> StartForTest {
885 let interval = spec.poll_interval.max(Duration::from_nanos(1));
886 let ticks = crossbeam_channel::tick(interval);
887 let alive = Arc::new(AtomicBool::new(true));
888 let fetch_start = FetchStart {
889 enabled: spec.fetch.enabled,
890 concurrency: spec.fetch.concurrency.max(1),
891 ticks: if spec.fetch.enabled {
892 crossbeam_channel::tick(spec.fetch.interval.max(Duration::from_nanos(1)))
893 } else {
894 crossbeam_channel::never()
895 },
896 };
897 start_internal(
898 spec,
899 Duration::from_secs(1),
900 discovery::ABANDON_AFTER,
901 ticks,
902 fetch_start,
903 alive,
904 None,
905 )
906 }
907
908 #[cfg(any(test, feature = "test-util"))]
919 pub fn start_discovered(spec: CoreSpec) -> Core {
920 let mut started = Self::start_watched(spec);
921 if let Some(handle) = started.initial_discovery.take() {
922 handle
923 .join()
924 .expect("the first discovery thread should not panic");
925 }
926 started.core
927 }
928
929 pub fn refresh(&self, order: &[EntityKey]) -> Generation {
934 self.refresh_handles().dispatch(order)
935 }
936
937 pub fn refresh_all(&self) -> Generation {
949 self.refresh_handles().dispatch_over_everything()
950 }
951
952 pub fn rederive_default_branches(&self, keys: &[EntityKey]) -> Generation {
977 let generation = {
978 let mut table = self.table.write().unwrap();
979 table.generation += 1;
980 Generation::new(table.generation)
981 };
982
983 let dispatched: Vec<RederiveCandidate> = {
984 let mut table = self.table.write().unwrap();
985 let mut dispatched = Vec::new();
986 for key in keys {
987 let Some(&idx) = table.index.get(key) else {
988 continue;
989 };
990 table.entities[idx].default_branch.begin_probe();
991 let common_dir = Arc::clone(&table.entities[idx].common_dir);
992 let override_branch = find_entry(&self.overrides, key.path(), &common_dir)
993 .and_then(|entry| entry.default_branch.clone());
994 let repo = table.repos.get(key).cloned();
995 let kind = table.entities[idx].kind;
996 dispatched.push(RederiveCandidate {
997 key: key.clone(),
998 path: key.path().to_path_buf(),
999 common_dir,
1000 repo,
1001 override_branch,
1002 kind,
1003 });
1004 }
1005 dispatched
1006 };
1007
1008 if dispatched.is_empty() {
1009 return generation;
1010 }
1011
1012 begin_probes_owed(&self.settle_gate, dispatched.len());
1013
1014 let table = Arc::clone(&self.table);
1015 let settle_gate = Arc::clone(&self.settle_gate);
1016 let network_default_branch = Arc::clone(&self.network_default_branch);
1017 thread::spawn(move || {
1018 let common_dirs: HashSet<Arc<Path>> = dispatched
1019 .iter()
1020 .map(|candidate| Arc::clone(&candidate.common_dir))
1021 .collect();
1022 probe_network_default_branches(&common_dirs, &network_default_branch);
1023
1024 let chain_cache: ChainFactsCache = Mutex::new(HashMap::new());
1029 let chain_reads = AtomicUsize::new(0);
1030 let never_cancelled = AtomicBool::new(false);
1031
1032 for candidate in dispatched {
1033 let RederiveCandidate {
1034 key,
1035 path,
1036 common_dir,
1037 repo,
1038 override_branch,
1039 kind,
1040 } = candidate;
1041 let network_branch = network_branch_for(&network_default_branch, &common_dir);
1042 let resolution = probe_default_branch_memoised(
1043 &path,
1044 repo.as_deref(),
1045 &common_dir,
1046 DefaultBranchHints {
1047 override_branch: override_branch.as_deref(),
1048 network_branch: network_branch.as_deref(),
1049 },
1050 kind,
1051 &never_cancelled,
1052 &ChainFactsMemo {
1053 cache: &chain_cache,
1054 reads: &chain_reads,
1055 },
1056 );
1057 {
1058 let mut table = table.write().unwrap();
1059 if let (Some(&idx), Some(resolution)) = (table.index.get(&key), resolution) {
1060 table.entities[idx].apply_default_branch_resolution(generation, resolution);
1061 }
1062 }
1063 complete_one(&settle_gate);
1064 }
1065 });
1066
1067 generation
1068 }
1069
1070 fn refresh_handles(&self) -> RefreshHandles {
1079 RefreshHandles {
1080 table: Arc::clone(&self.table),
1081 overrides: Arc::clone(&self.overrides),
1082 exclusions: Arc::clone(&self.exclusions),
1083 set: self.set.clone(),
1084 discovery_manual: Arc::clone(&self.discovery_manual),
1085 discovery_warn_after: self.discovery_warn_after,
1086 discovery_abandon_after: Arc::clone(&self.discovery_abandon_after),
1087 discovery_warning: Arc::clone(&self.discovery_warning),
1088 show_submodules: Arc::clone(&self.show_submodules),
1089 settle_gate: Arc::clone(&self.settle_gate),
1090 default_branch_chain_reads: Arc::clone(&self.default_branch_chain_reads),
1091 patch_identity_reads: Arc::clone(&self.patch_identity_reads),
1092 patch_scan_bounds: Arc::clone(&self.patch_scan_bounds),
1093 dispatch_log: Arc::clone(&self.dispatch_log),
1094 phase_c_gates: Arc::clone(&self.phase_c_gates),
1095 network_default_branch: Arc::clone(&self.network_default_branch),
1096 turnstile: Arc::clone(&self.turnstile),
1097 discovery_gate: self.discovery_gate.clone(),
1098 }
1099 }
1100
1101 pub fn probe_now(&self, key: &EntityKey) -> EntityState {
1106 let never_cancelled = Arc::new(AtomicBool::new(false));
1110 let (cached_repo, common_dir_hint, probes_state, probes_base, kind) = {
1111 let table = self.table.read().unwrap();
1112 let repo = table.repos.get(key).cloned();
1113 let common_dir = table
1114 .index
1115 .get(key)
1116 .map(|&idx| Arc::clone(&table.entities[idx].common_dir));
1117 let probes_state = table
1122 .index
1123 .get(key)
1124 .map(|&idx| table.entities[idx].probes_state())
1125 .unwrap_or(false);
1126 let probes_base = table
1127 .index
1128 .get(key)
1129 .map(|&idx| table.entities[idx].probes_base())
1130 .unwrap_or(true);
1131 let kind = table
1134 .index
1135 .get(key)
1136 .map(|&idx| table.entities[idx].kind)
1137 .unwrap_or(Kind::Repo);
1138 (repo, common_dir, probes_state, probes_base, kind)
1139 };
1140 let common_dir_hint = common_dir_hint.unwrap_or_else(|| Arc::from(key.path().join(".git")));
1141 let override_branch = find_entry(&self.overrides, key.path(), &common_dir_hint)
1142 .and_then(|entry| entry.default_branch.clone());
1143 let excluded = excluded_by(
1144 &self.exclusions.read().unwrap(),
1145 key.path(),
1146 &common_dir_hint,
1147 );
1148
1149 let branch_outcome =
1150 probe_branch(key.path(), cached_repo.as_deref(), kind, &never_cancelled);
1151 let sync_outcome = probe_sync(
1152 key.path(),
1153 cached_repo.as_deref(),
1154 branch_outcome.as_ref().map(|(settled, ..)| settled),
1155 kind,
1156 &never_cancelled,
1157 );
1158 let default_branch_outcome = probe_default_branch(
1159 key.path(),
1160 cached_repo.as_deref(),
1161 DefaultBranchHints {
1162 override_branch: override_branch.as_deref(),
1163 network_branch: network_branch_for(&self.network_default_branch, &common_dir_hint)
1164 .as_deref(),
1165 },
1166 kind,
1167 &never_cancelled,
1168 );
1169 let base_outcome = if probes_base {
1170 probe_base(
1171 key.path(),
1172 cached_repo.as_deref(),
1173 branch_outcome.as_ref().map(|(settled, ..)| settled),
1174 default_branch_outcome.as_ref().map(|r| &r.settled),
1175 &never_cancelled,
1176 )
1177 } else {
1178 None
1179 };
1180 let state_outcome = if probes_state {
1181 let patch_cache: PatchIdentityCache = Mutex::new(HashMap::new());
1186 let patch_reads = AtomicUsize::new(0);
1187 let patch_scan_bounds = Mutex::new(Vec::new());
1188 let gate = BoundGate::new(1);
1189 let mut report = GateReport::new(&gate);
1190 let memo = PatchEquivalenceMemo {
1191 cache: &patch_cache,
1192 reads: &patch_reads,
1193 scan_bounds: &patch_scan_bounds,
1194 };
1195 probe_worktree_state(
1196 key.path(),
1197 cached_repo.as_deref(),
1198 default_branch_outcome.as_ref().map(|r| &r.settled),
1199 &common_dir_hint,
1200 &never_cancelled,
1201 &memo,
1202 &mut report,
1203 )
1204 } else {
1205 None
1206 };
1207 let dirty_outcome =
1208 probe_status(key.path(), cached_repo.as_deref(), kind, &never_cancelled);
1209
1210 let mut table = self.table.write().unwrap();
1211 let generation = Generation::new(table.generation);
1212 let idx = match table.index.get(key).copied() {
1213 Some(idx) => idx,
1214 None => {
1215 let name = display_name(key.path());
1216 table.entities.push(EntityState::new(
1217 key.clone(),
1218 name,
1219 common_dir_hint,
1220 Kind::Repo,
1221 ));
1222 let idx = table.entities.len() - 1;
1223 table.index.insert(key.clone(), idx);
1224 idx
1225 }
1226 };
1227 table.entities[idx].excluded = excluded;
1228 if let Some((settled, in_progress, recent)) = branch_outcome {
1229 table.entities[idx].apply_branch_probe(generation, settled, in_progress, recent);
1230 }
1231 if let Some(settled) = sync_outcome {
1232 table.entities[idx].sync.settle(generation, settled);
1233 }
1234 if let Some(settled) = base_outcome {
1235 table.entities[idx].base.settle(generation, settled);
1236 }
1237 if let Some(resolution) = default_branch_outcome {
1238 table.entities[idx].apply_default_branch_resolution(generation, resolution);
1239 }
1240 if let Some(settled) = state_outcome {
1241 table.entities[idx].state.settle(generation, settled);
1242 }
1243 if let Some(settled) = dirty_outcome {
1244 table.entities[idx].dirty.settle(generation, settled);
1245 }
1246 table.entities[idx].clone()
1247 }
1248
1249 pub fn snapshot(&self) -> Snapshot {
1255 let table = self.table.read().unwrap();
1256 let mut entities = table.entities.clone();
1257 for entity in &mut entities {
1258 entity.age_status_cells(self.status_stale_after);
1259 }
1260 Snapshot {
1261 generation: Generation::new(table.generation),
1262 discovered_at: table.discovered_at,
1263 entities,
1264 }
1265 }
1266
1267 pub fn try_settle(&self, within: Duration) -> Result<Snapshot, Snapshot> {
1277 let (lock, cvar) = &*self.settle_gate;
1278 let guard = lock.lock().unwrap();
1279 let (guard, timeout) = cvar
1280 .wait_timeout_while(guard, within, |counts| !counts.is_settled())
1281 .unwrap();
1282 drop(guard);
1285 let snapshot = self.snapshot();
1286 if timeout.timed_out() {
1287 Err(snapshot)
1288 } else {
1289 Ok(snapshot)
1290 }
1291 }
1292
1293 #[cfg(any(test, feature = "test-util"))]
1304 pub fn settle(&self) -> Snapshot {
1305 self.settle_within(liveness::BACKSTOP)
1306 }
1307
1308 #[cfg(any(test, feature = "test-util"))]
1312 fn settle_within(&self, deadline: Duration) -> Snapshot {
1313 self.try_settle(deadline).unwrap_or_else(|_| {
1314 let (probes, dispatches) = {
1318 let counts = self.settle_gate.0.lock().unwrap();
1319 (counts.probes, counts.dispatches)
1320 };
1321 liveness::expired(
1322 deadline,
1323 "everything this Core has in flight to land",
1324 &format!("{probes} probe(s) and {dispatches} dispatch(es) still outstanding"),
1325 )
1326 })
1327 }
1328
1329 pub fn delete_risk(&self, key: &EntityKey) -> Result<DeleteRisk, git::ProbeError> {
1345 let repo = git::open_thread_safe(key.path())?.to_thread_local();
1346 let dirty = git::dirty_counts(&repo, Arc::new(AtomicBool::new(false)))?;
1347 let staged = git::staged_changes(&repo)?;
1348 let (unpushed_commits, unpushed_branches) = git::unpushed(&repo)?;
1349 let linked_worktrees = git::linked_worktrees(&repo)?;
1350 Ok(DeleteRisk {
1351 uncommitted: dirty.total() > 0 || staged,
1352 unpushed_commits,
1353 unpushed_branches,
1354 linked_worktrees,
1355 })
1356 }
1357
1358 pub fn worktree_admin_dir(&self, key: &EntityKey) -> Result<PathBuf, git::ProbeError> {
1366 let repo = git::open_thread_safe(key.path())?.to_thread_local();
1367 Ok(git::worktree_admin_dir(&repo))
1368 }
1369
1370 pub fn linked_worktree_paths(&self, key: &EntityKey) -> Result<Vec<PathBuf>, git::ProbeError> {
1377 let repo = git::open_thread_safe(key.path())?.to_thread_local();
1378 git::linked_worktree_paths(&repo)
1379 }
1380
1381 pub fn ignored_directories_for_deletion(
1389 &self,
1390 path: &Path,
1391 ) -> Result<Vec<PathBuf>, git::ProbeError> {
1392 let repo = git::open_thread_safe(path)?.to_thread_local();
1393 git::ignored_directories_for_deletion(&repo)
1394 }
1395
1396 pub fn attempt_auto_update(&self, key: &EntityKey) -> AutoUpdateAttempt {
1403 match crate::auto_update::attempt(key.path()) {
1404 crate::auto_update::Outcome::Ineligible(crate::auto_update::Ineligible::NotClean) => {
1405 AutoUpdateAttempt::NotClean
1406 }
1407 crate::auto_update::Outcome::Ineligible(crate::auto_update::Ineligible::NoUpstream) => {
1408 AutoUpdateAttempt::NoUpstream
1409 }
1410 crate::auto_update::Outcome::Ineligible(crate::auto_update::Ineligible::NotBehind) => {
1411 AutoUpdateAttempt::NotBehind
1412 }
1413 crate::auto_update::Outcome::Ineligible(
1414 crate::auto_update::Ineligible::NotFastForward,
1415 ) => AutoUpdateAttempt::NotFastForward,
1416 crate::auto_update::Outcome::Updated { .. } => AutoUpdateAttempt::Updated,
1417 crate::auto_update::Outcome::Failed(error) => AutoUpdateAttempt::Failed(error),
1418 }
1419 }
1420
1421 pub fn run_action_for_entity_blocking(
1434 &self,
1435 action: &ActionSpec,
1436 key: &EntityKey,
1437 ) -> Option<ActionReceipt> {
1438 let entity = {
1439 let table = self.table.read().unwrap();
1440 let idx = *table.index.get(key)?;
1441 table.entities[idx].clone()
1442 };
1443 let control = executor::RunControl::new();
1444 Some(run_action_for_entity(&entity, action, &control, &|_| {}))
1445 }
1446
1447 pub fn management_handle(&self) -> ManagementHandle {
1453 ManagementHandle {
1454 table: Arc::clone(&self.table),
1455 }
1456 }
1457
1458 pub fn dismiss(&self, key: &EntityKey) {
1460 let mut table = self.table.write().unwrap();
1461 if let Some(idx) = table.index.remove(key) {
1462 table.entities.remove(idx);
1463 for position in table.index.values_mut() {
1464 if *position > idx {
1465 *position -= 1;
1466 }
1467 }
1468 }
1469 table.poll_fingerprints.remove(key);
1470 if let Some(in_flight) = table.in_flight.remove(key) {
1471 in_flight.cancel.store(true, Ordering::Release);
1472 drop(table);
1473 complete_one(&self.settle_gate);
1474 }
1475 }
1476
1477 fn partition_operable(&self, order: &[EntityKey]) -> (Vec<EntityState>, Vec<EntityState>) {
1489 let table = self.table.read().unwrap();
1490 order
1491 .iter()
1492 .filter_map(|key| table.index.get(key).map(|&idx| table.entities[idx].clone()))
1493 .partition(|entity| !entity.excluded)
1494 }
1495
1496 pub fn operable_count(&self, order: &[EntityKey]) -> usize {
1503 self.partition_operable(order).0.len()
1504 }
1505
1506 pub fn vanished_count(&self) -> usize {
1510 self.table
1511 .read()
1512 .unwrap()
1513 .entities
1514 .iter()
1515 .filter(|entity| entity.presence == Presence::Vanished)
1516 .count()
1517 }
1518
1519 pub fn applicability(&self, order: &[EntityKey], when: &Filter) -> Applicability {
1533 when.applicability(self.partition_operable(order).0.iter())
1534 }
1535
1536 pub fn action_running(&self) -> bool {
1543 self.action_lifecycle.lock().unwrap().live.is_some()
1544 }
1545
1546 pub fn refresh_running(&self) -> bool {
1556 let (lock, _cvar) = &*self.settle_gate;
1557 !lock.lock().unwrap().is_settled()
1558 }
1559
1560 pub fn fetch_running(&self) -> bool {
1564 self.fetch_running.load(Ordering::Acquire)
1565 }
1566
1567 pub fn run_action(&self, action: ActionSpec, order: &[EntityKey]) -> bool {
1615 let control = executor::RunControl::new();
1620 if !self
1621 .action_lifecycle
1622 .lock()
1623 .unwrap()
1624 .admit(Arc::clone(&control))
1625 {
1626 return false;
1627 }
1628
1629 cancel_in_flight(&self.table, &self.settle_gate);
1632
1633 let (operable, excluded) = self.partition_operable(order);
1634
1635 let write_skip_receipts = |entities: &[EntityState], skip: Skip| {
1636 if entities.is_empty() {
1637 return;
1638 }
1639 let finished_at = Timestamp::now();
1640 let mut table = self.table.write().unwrap();
1641 for entity in entities {
1642 if let Some(&idx) = table.index.get(&entity.key) {
1643 table.entities[idx].last_action = Some(ActionReceipt {
1644 label: Arc::clone(&action.label),
1645 steps: Arc::from(Vec::new()),
1646 skip: Some(skip),
1647 finished_at,
1648 running: None,
1649 });
1650 }
1651 }
1652 };
1653
1654 write_skip_receipts(&excluded, Skip::Excluded);
1655
1656 let included = match &action.when {
1657 Some(when) => {
1658 let Partition {
1659 applicable,
1660 inapplicable,
1661 unresolved,
1662 } = when.partition(operable);
1663 write_skip_receipts(&inapplicable, Skip::Inapplicable);
1664 write_skip_receipts(&unresolved, Skip::Unresolved);
1665 applicable
1666 }
1667 None => operable,
1668 };
1669
1670 let table_handle = Arc::clone(&self.table);
1671 let refresh_handles = self.refresh_handles();
1672 let action_lifecycle = Arc::clone(&self.action_lifecycle);
1673 #[cfg(test)]
1674 let completion_boundary = Arc::clone(&self.action_completion_boundary);
1675 let concurrency = action.concurrency.max(1) as usize;
1682
1683 thread::spawn(move || {
1689 let pool = rayon::ThreadPoolBuilder::new()
1690 .num_threads(concurrency)
1691 .build()
1692 .expect("build the Action fan-out's own dedicated pool");
1693
1694 let fan_out = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1700 pool.install(|| {
1701 included.into_par_iter().for_each(|entity| {
1702 let write_receipt = |receipt: ActionReceipt| {
1703 let mut table = table_handle.write().unwrap();
1704 if let Some(&idx) = table.index.get(&entity.key) {
1705 table.entities[idx].last_action = Some(receipt);
1706 }
1707 };
1708 let receipt =
1709 run_action_for_entity(&entity, &action, &control, &write_receipt);
1710 write_receipt(receipt);
1711 });
1712 });
1713 }));
1714
1715 let completion = RunCompletion {
1726 lifecycle: action_lifecycle,
1727 #[cfg(test)]
1728 boundary: completion_boundary,
1729 };
1730
1731 let Ok(()) = fan_out else {
1736 return;
1737 };
1738
1739 let all_keys: Vec<EntityKey> = table_handle
1742 .read()
1743 .unwrap()
1744 .entities
1745 .iter()
1746 .map(|entity| entity.key.clone())
1747 .collect();
1748 refresh_handles.dispatch(&all_keys);
1749 drop(completion);
1750 });
1752
1753 true
1754 }
1755
1756 pub fn hold_action(&self) {
1763 if let Some(control) = self.live_action_control() {
1764 control.hold();
1765 }
1766 }
1767
1768 pub fn continue_action(&self) {
1771 if let Some(control) = self.live_action_control() {
1772 control.continue_run();
1773 }
1774 }
1775
1776 pub fn stop_action(&self) {
1785 if let Some(control) = self.live_action_control() {
1786 control.cancel();
1787 }
1788 }
1789
1790 fn live_action_control(&self) -> Option<Arc<executor::RunControl>> {
1794 self.action_lifecycle.lock().unwrap().live.clone()
1795 }
1796
1797 pub fn pause(&self) {
1800 let _ = self.control.send(ClockControl::Pause);
1801 }
1802
1803 pub fn resume(&self) {
1807 let _ = self.control.send(ClockControl::Resume);
1808 }
1809
1810 pub fn fetch_now(&self) {
1820 let _ = self.control.send(ClockControl::FetchNow);
1821 }
1822
1823 pub fn discovery_warning(&self) -> Option<String> {
1829 self.discovery_warning.lock().unwrap().clone()
1830 }
1831
1832 pub fn fetch_failures(&self) -> FetchFailures {
1839 self.fetch_failures.lock().unwrap().clone()
1840 }
1841
1842 pub fn set_show_submodules(&self, show_submodules: bool) {
1849 self.show_submodules
1850 .store(show_submodules, Ordering::Release);
1851 }
1852
1853 pub fn record_own_work(&self, label: &str, results: &[(EntityKey, OwnWork, Duration)]) {
1873 let label: Arc<str> = Arc::from(label);
1874 let finished_at = Timestamp::now();
1875 let mut table = self.table.write().unwrap();
1876 for (key, work, elapsed) in results {
1877 let Some(&idx) = table.index.get(key) else {
1878 continue;
1879 };
1880 table.entities[idx].last_action = Some(ActionReceipt {
1881 label: Arc::clone(&label),
1882 steps: Arc::from(vec![StepResult {
1883 label: Arc::clone(&label),
1884 outcome: StepOutcome::OwnWork(work.clone()),
1885 output: Arc::from(&b""[..]),
1886 elapsed: *elapsed,
1887 elision: None,
1888 shell: false,
1889 interactive: false,
1890 }]),
1891 skip: None,
1892 finished_at,
1893 running: None,
1894 });
1895 }
1896 }
1897
1898 pub fn set_exclusions(&self, overrides: &[RepoOverride]) {
1909 let (_, resolved) = resolve_entries(overrides);
1910 {
1913 let mut exclusions = self.exclusions.write().unwrap();
1914 *exclusions = resolved.clone();
1915 }
1916 let mut table = self.table.write().unwrap();
1917 for entity in &mut table.entities {
1918 entity.excluded = excluded_by(&resolved, entity.key.path(), &entity.common_dir);
1919 }
1920 }
1921}
1922
1923#[derive(Clone)]
1932pub struct ManagementHandle {
1933 table: Arc<RwLock<Table>>,
1934}
1935
1936impl ManagementHandle {
1937 pub fn worktree_admin_dir(&self, key: &EntityKey) -> Result<PathBuf, git::ProbeError> {
1939 let repo = git::open_thread_safe(key.path())?.to_thread_local();
1940 Ok(git::worktree_admin_dir(&repo))
1941 }
1942
1943 pub fn linked_worktree_paths(&self, key: &EntityKey) -> Result<Vec<PathBuf>, git::ProbeError> {
1945 let repo = git::open_thread_safe(key.path())?.to_thread_local();
1946 git::linked_worktree_paths(&repo)
1947 }
1948
1949 pub fn ignored_directories_for_deletion(
1952 &self,
1953 path: &Path,
1954 ) -> Result<Vec<PathBuf>, git::ProbeError> {
1955 let repo = git::open_thread_safe(path)?.to_thread_local();
1956 git::ignored_directories_for_deletion(&repo)
1957 }
1958
1959 pub fn attempt_auto_update(&self, key: &EntityKey) -> AutoUpdateAttempt {
1961 match crate::auto_update::attempt(key.path()) {
1962 crate::auto_update::Outcome::Ineligible(crate::auto_update::Ineligible::NotClean) => {
1963 AutoUpdateAttempt::NotClean
1964 }
1965 crate::auto_update::Outcome::Ineligible(crate::auto_update::Ineligible::NoUpstream) => {
1966 AutoUpdateAttempt::NoUpstream
1967 }
1968 crate::auto_update::Outcome::Ineligible(crate::auto_update::Ineligible::NotBehind) => {
1969 AutoUpdateAttempt::NotBehind
1970 }
1971 crate::auto_update::Outcome::Ineligible(
1972 crate::auto_update::Ineligible::NotFastForward,
1973 ) => AutoUpdateAttempt::NotFastForward,
1974 crate::auto_update::Outcome::Updated { .. } => AutoUpdateAttempt::Updated,
1975 crate::auto_update::Outcome::Failed(error) => AutoUpdateAttempt::Failed(error),
1976 }
1977 }
1978
1979 pub fn run_action_for_entity_blocking(
1982 &self,
1983 action: &ActionSpec,
1984 key: &EntityKey,
1985 ) -> Option<ActionReceipt> {
1986 let entity = {
1987 let table = self.table.read().unwrap();
1988 let idx = *table.index.get(key)?;
1989 table.entities[idx].clone()
1990 };
1991 let control = executor::RunControl::new();
1992 Some(run_action_for_entity(&entity, action, &control, &|_| {}))
1993 }
1994}
1995
1996#[derive(Clone)]
2006struct RefreshHandles {
2007 table: Arc<RwLock<Table>>,
2008 overrides: Arc<Vec<ResolvedOverride>>,
2009 exclusions: Arc<RwLock<Vec<ResolvedExclusion>>>,
2012 set: SetSpec,
2013 discovery_manual: Arc<AtomicBool>,
2014 discovery_warn_after: Duration,
2015 discovery_abandon_after: Arc<AtomicU64>,
2016 discovery_warning: Arc<Mutex<Option<String>>>,
2017 show_submodules: Arc<AtomicBool>,
2018 settle_gate: Arc<SettleGate>,
2019 default_branch_chain_reads: Arc<AtomicUsize>,
2020 patch_identity_reads: Arc<AtomicUsize>,
2021 patch_scan_bounds: Arc<Mutex<Vec<Option<gix::ObjectId>>>>,
2022 dispatch_log: Arc<Mutex<Vec<EntityKey>>>,
2023 phase_c_gates: Arc<Mutex<HashMap<EntityKey, PhaseCGateHandle>>>,
2024 network_default_branch: Arc<Mutex<HashMap<PathBuf, Arc<str>>>>,
2028 turnstile: Arc<DispatchTurnstile>,
2031 discovery_gate: Option<DiscoveryGate>,
2033}
2034
2035#[derive(Default)]
2044struct DispatchTurnstile {
2045 serving: Mutex<u64>,
2047 ready: Condvar,
2048 next: AtomicU64,
2052}
2053
2054impl DispatchTurnstile {
2055 fn reserve(&self) -> u64 {
2056 self.next.fetch_add(1, Ordering::AcqRel)
2057 }
2058
2059 fn take(&self, ticket: u64) -> DispatchTurn<'_> {
2063 let serving = self.serving.lock().unwrap();
2064 drop(
2065 self.ready
2066 .wait_while(serving, |serving| *serving != ticket)
2067 .unwrap(),
2068 );
2069 DispatchTurn {
2070 turnstile: self,
2071 ticket,
2072 }
2073 }
2074}
2075
2076struct DispatchTurn<'a> {
2078 turnstile: &'a DispatchTurnstile,
2079 ticket: u64,
2080}
2081
2082impl Drop for DispatchTurn<'_> {
2083 fn drop(&mut self) {
2084 let mut serving = self.turnstile.serving.lock().unwrap();
2085 *serving = self.ticket + 1;
2086 self.turnstile.ready.notify_all();
2087 }
2088}
2089
2090impl RefreshHandles {
2091 fn dispatch(&self, order: &[EntityKey]) -> Generation {
2100 let (generation, ticket) = self.reserve_generation();
2101 begin_dispatch(&self.settle_gate);
2102 let handles = self.clone();
2103 let order = order.to_vec();
2104 thread::spawn(move || {
2105 let _turn = handles.turnstile.take(ticket);
2106 handles.run_generation(&order, generation);
2107 finish_dispatch(&handles.settle_gate);
2108 });
2109 generation
2110 }
2111
2112 fn dispatch_over_everything(&self) -> Generation {
2116 let (generation, ticket) = self.reserve_generation();
2117 begin_dispatch(&self.settle_gate);
2118 let handles = self.clone();
2119 thread::spawn(move || {
2120 let _turn = handles.turnstile.take(ticket);
2121 handles.rediscover();
2122 let order: Vec<EntityKey> = handles
2123 .table
2124 .read()
2125 .unwrap()
2126 .entities
2127 .iter()
2128 .map(|entity| entity.key.clone())
2129 .collect();
2130 handles.dispatch_probes(&order, generation);
2131 finish_dispatch(&handles.settle_gate);
2132 });
2133 generation
2134 }
2135
2136 fn reserve_generation(&self) -> (Generation, u64) {
2139 let mut table = self.table.write().unwrap();
2140 table.generation += 1;
2141 (Generation::new(table.generation), self.turnstile.reserve())
2142 }
2143
2144 fn run_generation(&self, order: &[EntityKey], generation: Generation) {
2147 self.rediscover();
2148 self.dispatch_probes(order, generation);
2149 }
2150
2151 fn rediscover(&self) {
2157 if !self.discovery_manual.load(Ordering::Acquire) {
2158 self.rerun_discovery();
2159 }
2160 }
2161
2162 fn dispatch_probes(&self, order: &[EntityKey], generation: Generation) {
2168 self.default_branch_chain_reads.store(0, Ordering::Release);
2173 self.patch_identity_reads.store(0, Ordering::Release);
2174 self.patch_scan_bounds.lock().unwrap().clear();
2175 self.dispatch_log.lock().unwrap().clear();
2176
2177 let generation_number = generation.value();
2178 let mut table = self.table.write().unwrap();
2179 table
2180 .generation_started_at
2181 .insert(generation_number, Instant::now());
2182
2183 let show_submodules = self.show_submodules.load(Ordering::Acquire);
2184 let mut dispatched = Vec::new();
2185 for key in order {
2186 let Some(&idx) = table.index.get(key) else {
2187 continue;
2188 };
2189 if !dispatches_kind(table.entities[idx].kind, show_submodules) {
2190 continue;
2194 }
2195 if let Some(previous) = table.in_flight.remove(key) {
2196 previous.cancel.store(true, Ordering::Release);
2197 }
2198 let cancel = Arc::new(AtomicBool::new(false));
2199 table.in_flight.insert(
2200 key.clone(),
2201 InFlight {
2202 generation: generation_number,
2203 cancel: Arc::clone(&cancel),
2204 },
2205 );
2206 begin_probes(&mut table.entities[idx]);
2207 dispatched.push((key.clone(), cancel));
2208 }
2209
2210 if dispatched.is_empty() {
2211 return;
2212 }
2213
2214 begin_probes_owed(&self.settle_gate, dispatched.len());
2215 let repos: Vec<Option<Arc<gix::ThreadSafeRepository>>> = dispatched
2216 .iter()
2217 .map(|(key, _)| table.repos.get(key).cloned())
2218 .collect();
2219 let override_branches: Vec<Option<String>> = dispatched
2220 .iter()
2221 .map(|(key, _)| {
2222 let idx = table.index[key];
2223 let common_dir = &table.entities[idx].common_dir;
2224 find_entry(&self.overrides, key.path(), common_dir)
2225 .and_then(|entry| entry.default_branch.clone())
2226 })
2227 .collect();
2228 let network_branches: Vec<Option<Arc<str>>> = dispatched
2229 .iter()
2230 .map(|(key, _)| {
2231 let idx = table.index[key];
2232 let common_dir = &table.entities[idx].common_dir;
2233 network_branch_for(&self.network_default_branch, common_dir)
2234 })
2235 .collect();
2236 let common_dirs: Vec<Arc<Path>> = dispatched
2237 .iter()
2238 .map(|(key, _)| Arc::clone(&table.entities[table.index[key]].common_dir))
2239 .collect();
2240 let probes_state: Vec<bool> = dispatched
2241 .iter()
2242 .map(|(key, _)| table.entities[table.index[key]].probes_state())
2243 .collect();
2244 let probes_base: Vec<bool> = dispatched
2245 .iter()
2246 .map(|(key, _)| table.entities[table.index[key]].probes_base())
2247 .collect();
2248 let kinds: Vec<Kind> = dispatched
2249 .iter()
2250 .map(|(key, _)| table.entities[table.index[key]].kind)
2251 .collect();
2252 drop(table);
2253
2254 let chain_cache: Arc<ChainFactsCache> = Arc::new(Mutex::new(HashMap::new()));
2258 let patch_cache: Arc<PatchIdentityCache> = Arc::new(Mutex::new(HashMap::new()));
2262 let bound_gates: Arc<HashMap<Arc<Path>, BoundGate>> = Arc::new({
2267 let mut counts: HashMap<Arc<Path>, usize> = HashMap::new();
2268 for (common_dir, probes_state) in common_dirs.iter().zip(&probes_state) {
2269 if *probes_state {
2270 *counts.entry(Arc::clone(common_dir)).or_insert(0) += 1;
2271 }
2272 }
2273 counts
2274 .into_iter()
2275 .map(|(dir, count)| (dir, BoundGate::new(count)))
2276 .collect()
2277 });
2278
2279 for (
2280 (
2281 (
2282 (((((key, cancel), repo), override_branch), network_branch), common_dir),
2283 probes_state,
2284 ),
2285 probes_base,
2286 ),
2287 kind,
2288 ) in dispatched
2289 .into_iter()
2290 .zip(repos)
2291 .zip(override_branches)
2292 .zip(network_branches)
2293 .zip(common_dirs)
2294 .zip(probes_state)
2295 .zip(probes_base)
2296 .zip(kinds)
2297 {
2298 self.dispatch_log.lock().unwrap().push(key.clone());
2303 let path = key.path().to_path_buf();
2304 let table_handle = Arc::clone(&self.table);
2305 let settle_gate = Arc::clone(&self.settle_gate);
2306 let chain_cache = Arc::clone(&chain_cache);
2307 let chain_reads = Arc::clone(&self.default_branch_chain_reads);
2308 let patch_cache = Arc::clone(&patch_cache);
2309 let patch_reads = Arc::clone(&self.patch_identity_reads);
2310 let patch_scan_bounds = Arc::clone(&self.patch_scan_bounds);
2311 let bound_gates = Arc::clone(&bound_gates);
2312 let held_gate = self.phase_c_gates.lock().unwrap().get(&key).cloned();
2317 rayon::spawn(move || {
2325 let branch_outcome = probe_branch(&path, repo.as_deref(), kind, &cancel);
2326 let sync_outcome = probe_sync(
2327 &path,
2328 repo.as_deref(),
2329 branch_outcome.as_ref().map(|(settled, ..)| settled),
2330 kind,
2331 &cancel,
2332 );
2333 let default_branch_outcome = probe_default_branch_memoised(
2334 &path,
2335 repo.as_deref(),
2336 &common_dir,
2337 DefaultBranchHints {
2338 override_branch: override_branch.as_deref(),
2339 network_branch: network_branch.as_deref(),
2340 },
2341 kind,
2342 &cancel,
2343 &ChainFactsMemo {
2344 cache: &chain_cache,
2345 reads: &chain_reads,
2346 },
2347 );
2348 let base_outcome = if probes_base {
2349 probe_base(
2350 &path,
2351 repo.as_deref(),
2352 branch_outcome.as_ref().map(|(settled, ..)| settled),
2353 default_branch_outcome.as_ref().map(|r| &r.settled),
2354 &cancel,
2355 )
2356 } else {
2357 None
2358 };
2359
2360 apply_cheap_probe_outcomes(
2366 &table_handle,
2367 &key,
2368 generation,
2369 CheapProbeOutcomes {
2370 branch: branch_outcome,
2371 sync: sync_outcome,
2372 base: base_outcome,
2373 default_branch: default_branch_outcome.clone(),
2374 },
2375 );
2376
2377 if let Some(gate) = &held_gate {
2382 let (lock, cvar) = &**gate;
2383 let mut state = lock.lock().unwrap();
2384 state.cheap_landed = true;
2385 cvar.notify_all();
2386 state = cvar.wait_while(state, |state| !state.may_proceed).unwrap();
2387 drop(state);
2388 }
2389
2390 let state_outcome = if probes_state {
2391 let gate = bound_gates
2392 .get(&common_dir)
2393 .expect("every probes_state entity's common dir has a gate sized for it");
2394 let mut report = GateReport::new(gate);
2395 let memo = PatchEquivalenceMemo {
2396 cache: &patch_cache,
2397 reads: &patch_reads,
2398 scan_bounds: &patch_scan_bounds,
2399 };
2400 probe_worktree_state(
2401 &path,
2402 repo.as_deref(),
2403 default_branch_outcome.as_ref().map(|r| &r.settled),
2404 &common_dir,
2405 &cancel,
2406 &memo,
2407 &mut report,
2408 )
2409 } else {
2410 None
2411 };
2412 let dirty_outcome = probe_status(&path, repo.as_deref(), kind, &cancel);
2413 apply_probe_outcome(
2414 &table_handle,
2415 &settle_gate,
2416 &key,
2417 generation,
2418 ProbeOutcomes {
2419 state: state_outcome,
2420 dirty: dirty_outcome,
2421 },
2422 );
2423
2424 if let Some(gate) = &held_gate {
2427 let (lock, cvar) = &**gate;
2428 let mut state = lock.lock().unwrap();
2429 state.finished = true;
2430 cvar.notify_all();
2431 }
2432 });
2433 }
2435 }
2436
2437 fn rerun_discovery(&self) {
2446 let repos_cache: HashMap<EntityKey, Arc<gix::ThreadSafeRepository>> =
2447 self.table.read().unwrap().repos.clone();
2448
2449 wait_for_discovery_gate(self.discovery_gate.as_ref());
2450 let (watch, _watcher) = spawn_discovery_watcher(
2453 self.set.roots.clone(),
2454 &self.discovery_warning,
2455 self.discovery_warn_after,
2456 );
2457 let discovery = run_watched_discovery(
2458 &watch,
2459 &self.set,
2460 &self.discovery_warning,
2461 Duration::from_nanos(self.discovery_abandon_after.load(Ordering::Acquire)),
2462 );
2463 if discovery.abandoned {
2464 self.discovery_manual.store(true, Ordering::Release);
2465 }
2466
2467 let (discovered, gitmodules_failures) =
2468 discovery::resolve_with_cache(&self.set, &discovery.entities, &repos_cache);
2469
2470 let exclusions = self.exclusions.read().unwrap().clone();
2474 let mut table = self.table.write().unwrap();
2475 table.discovered_at = Timestamp::now();
2476 let cancelled = merge_discovery(&mut table, &exclusions, discovered, gitmodules_failures);
2477 drop(table);
2478 if cancelled > 0 {
2479 complete_many(&self.settle_gate, cancelled);
2480 }
2481 }
2482}
2483
2484impl Drop for Core {
2485 fn drop(&mut self) {
2496 cancel_in_flight(&self.table, &self.settle_gate);
2497 let _ = self.control.send(ClockControl::Shutdown);
2498 if let Some(handle) = self.clock_thread.take() {
2499 let _ = handle.join();
2500 }
2501 }
2502}
2503
2504pub(crate) struct StartForTest {
2509 pub core: Core,
2510 #[allow(dead_code)] pub clock_alive: Arc<AtomicBool>,
2512 #[allow(dead_code)] pub discovery_watcher: JoinHandle<()>,
2514 #[allow(dead_code)] pub initial_discovery: Option<JoinHandle<()>>,
2519 #[allow(dead_code)] pub fetch_cycles_taken_back: Arc<AtomicUsize>,
2525}
2526
2527#[cfg(test)]
2528impl StartForTest {
2529 fn discovered(mut self) -> Self {
2533 if let Some(handle) = self.initial_discovery.take() {
2534 handle
2535 .join()
2536 .expect("the first discovery thread should not panic");
2537 }
2538 self
2539 }
2540}
2541
2542impl Core {
2543 #[cfg(any(test, feature = "test-util"))]
2554 pub fn begin_untracked_probe_for_test(&self, key: &EntityKey) -> Arc<AtomicBool> {
2555 let mut table = self.table.write().unwrap();
2556 table.generation += 1;
2557 let generation_number = table.generation;
2558 table
2559 .generation_started_at
2560 .insert(generation_number, Instant::now());
2561 if let Some(&idx) = table.index.get(key) {
2562 begin_probes(&mut table.entities[idx]);
2563 }
2564 let cancel = Arc::new(AtomicBool::new(false));
2565 table.in_flight.insert(
2566 key.clone(),
2567 InFlight {
2568 generation: generation_number,
2569 cancel: Arc::clone(&cancel),
2570 },
2571 );
2572 begin_probes_owed(&self.settle_gate, 1);
2573 cancel
2574 }
2575}
2576
2577#[cfg(test)]
2580pub(crate) struct SharedGeneration {
2581 pub generation: Generation,
2584 pub cancels: HashMap<EntityKey, Arc<AtomicBool>>,
2586}
2587
2588#[cfg(test)]
2589impl Core {
2590 pub(crate) fn cached_repo_handle_for_test(
2595 &self,
2596 key: &EntityKey,
2597 ) -> Option<Arc<gix::ThreadSafeRepository>> {
2598 self.table.read().unwrap().repos.get(key).cloned()
2599 }
2600
2601 pub(crate) fn default_branch_chain_reads_for_test(&self) -> usize {
2608 self.default_branch_chain_reads.load(Ordering::Acquire)
2609 }
2610
2611 pub(crate) fn patch_identity_reads_for_test(&self) -> usize {
2617 self.patch_identity_reads.load(Ordering::Acquire)
2618 }
2619
2620 pub(crate) fn patch_scan_bounds_for_test(&self) -> Vec<Option<gix::ObjectId>> {
2627 self.patch_scan_bounds.lock().unwrap().clone()
2628 }
2629
2630 pub(crate) fn dispatch_log_for_test(&self) -> Vec<EntityKey> {
2634 self.dispatch_log.lock().unwrap().clone()
2635 }
2636
2637 pub(crate) fn poll_once_for_test(&self) {
2642 run_poll_sweep(
2643 &self.table,
2644 &self.overrides,
2645 &self.show_submodules,
2646 &self.poll_reprobed,
2647 &self.poll_sweep_count,
2648 &self.network_default_branch,
2649 );
2650 }
2651
2652 pub(crate) fn poll_reprobed_for_test(&self) -> Vec<EntityKey> {
2657 self.poll_reprobed.lock().unwrap().clone()
2658 }
2659
2660 pub(crate) fn poll_sweep_count_for_test(&self) -> usize {
2664 self.poll_sweep_count.load(Ordering::Acquire)
2665 }
2666
2667 #[cfg(test)]
2670 pub(crate) fn action_completion_boundary(&self) -> Arc<ActionCompletionBoundary> {
2671 Arc::clone(&self.action_completion_boundary)
2672 }
2673
2674 #[cfg(test)]
2676 pub(crate) fn fetch_boundary(&self) -> Arc<FetchBoundary> {
2677 Arc::clone(&self.fetch_boundary)
2678 }
2679
2680 pub(crate) fn hold_phase_c_for_test(&self, key: &EntityKey) {
2686 self.phase_c_gates.lock().unwrap().insert(
2687 key.clone(),
2688 Arc::new((Mutex::new(PhaseCGate::default()), Condvar::new())),
2689 );
2690 }
2691
2692 pub(crate) fn wait_phase_c_landed_for_test(&self, key: &EntityKey) {
2696 let gate = self
2697 .phase_c_gates
2698 .lock()
2699 .unwrap()
2700 .get(key)
2701 .cloned()
2702 .expect("hold_phase_c_for_test must be called before waiting on its gate");
2703 let (lock, cvar) = &*gate;
2704 let guard = lock.lock().unwrap();
2705 drop(cvar.wait_while(guard, |state| !state.cheap_landed).unwrap());
2706 }
2707
2708 pub(crate) fn release_phase_c_for_test(&self, key: &EntityKey) {
2711 let gate = self
2712 .phase_c_gates
2713 .lock()
2714 .unwrap()
2715 .get(key)
2716 .cloned()
2717 .expect("hold_phase_c_for_test must be called before releasing its gate");
2718 let (lock, cvar) = &*gate;
2719 let mut state = lock.lock().unwrap();
2720 state.may_proceed = true;
2721 cvar.notify_all();
2722 }
2723
2724 pub(crate) fn wait_phase_c_finished_for_test(&self, key: &EntityKey) {
2727 let gate = self
2728 .phase_c_gates
2729 .lock()
2730 .unwrap()
2731 .get(key)
2732 .cloned()
2733 .expect("hold_phase_c_for_test must be called before waiting on its gate");
2734 let (lock, cvar) = &*gate;
2735 let guard = lock.lock().unwrap();
2736 drop(cvar.wait_while(guard, |state| !state.finished).unwrap());
2737 }
2738
2739 pub(crate) fn wait_dispatched_for_test(&self) {
2744 let (lock, cvar) = &*self.settle_gate;
2745 let guard = lock.lock().unwrap();
2746 drop(
2747 cvar.wait_while(guard, |counts| counts.dispatches > 0)
2748 .unwrap(),
2749 );
2750 }
2751
2752 pub(crate) fn settle_gate_count_for_test(&self) -> usize {
2756 self.settle_gate.0.lock().unwrap().probes
2757 }
2758
2759 pub(crate) fn start_for_test(
2763 spec: CoreSpec,
2764 warn_after: Duration,
2765 ticks: Receiver<Instant>,
2766 ) -> StartForTest {
2767 Self::start_for_test_with_discovery_abandon(
2768 spec,
2769 warn_after,
2770 discovery::ABANDON_AFTER,
2771 ticks,
2772 )
2773 }
2774
2775 pub(crate) fn start_for_test_with_discovery_abandon(
2782 spec: CoreSpec,
2783 warn_after: Duration,
2784 discovery_abandon_after: Duration,
2785 ticks: Receiver<Instant>,
2786 ) -> StartForTest {
2787 Self::start_for_test_gated(spec, warn_after, discovery_abandon_after, ticks, None)
2788 }
2789
2790 pub(crate) fn start_for_test_gated(
2794 spec: CoreSpec,
2795 warn_after: Duration,
2796 discovery_abandon_after: Duration,
2797 ticks: Receiver<Instant>,
2798 discovery_gate: Option<DiscoveryGate>,
2799 ) -> StartForTest {
2800 let alive = Arc::new(AtomicBool::new(true));
2801 start_internal(
2802 spec,
2803 warn_after,
2804 discovery_abandon_after,
2805 ticks,
2806 FetchStart {
2807 enabled: false,
2808 concurrency: 1,
2809 ticks: crossbeam_channel::never(),
2810 },
2811 alive,
2812 discovery_gate,
2813 )
2814 }
2815
2816 pub(crate) fn start_for_test_with_fetch(
2822 spec: CoreSpec,
2823 warn_after: Duration,
2824 ticks: Receiver<Instant>,
2825 fetch_ticks: Receiver<Instant>,
2826 ) -> StartForTest {
2827 Self::start_for_test_with_fetch_gated(spec, warn_after, ticks, fetch_ticks, None)
2828 }
2829
2830 pub(crate) fn start_for_test_with_fetch_gated(
2834 spec: CoreSpec,
2835 warn_after: Duration,
2836 ticks: Receiver<Instant>,
2837 fetch_ticks: Receiver<Instant>,
2838 discovery_gate: Option<DiscoveryGate>,
2839 ) -> StartForTest {
2840 let alive = Arc::new(AtomicBool::new(true));
2841 let fetch_start = FetchStart {
2842 enabled: spec.fetch.enabled,
2843 concurrency: spec.fetch.concurrency.max(1),
2844 ticks: fetch_ticks,
2845 };
2846 start_internal(
2847 spec,
2848 warn_after,
2849 discovery::ABANDON_AFTER,
2850 ticks,
2851 fetch_start,
2852 alive,
2853 discovery_gate,
2854 )
2855 }
2856
2857 pub(crate) fn fetch_cycle_count_for_test(&self) -> usize {
2861 self.fetch_cycle_count.load(Ordering::Acquire)
2862 }
2863
2864 #[cfg(test)]
2871 pub(crate) fn set_discovery_abandon_after_for_test(&self, after: Duration) {
2872 self.discovery_abandon_after
2873 .store(after.as_nanos() as u64, Ordering::Release);
2874 }
2875
2876 pub(crate) fn discovery_manual_for_test(&self) -> bool {
2877 self.discovery_manual.load(Ordering::Acquire)
2878 }
2879
2880 pub(crate) fn begin_shared_generation_for_test(&self, keys: &[EntityKey]) -> SharedGeneration {
2890 let mut table = self.table.write().unwrap();
2891 table.generation += 1;
2892 let generation_number = table.generation;
2893 table
2894 .generation_started_at
2895 .insert(generation_number, Instant::now());
2896 let mut cancels = HashMap::new();
2897 for key in keys {
2898 if let Some(&idx) = table.index.get(key) {
2899 table.entities[idx].branch.begin_probe();
2900 }
2901 let cancel = Arc::new(AtomicBool::new(false));
2902 table.in_flight.insert(
2903 key.clone(),
2904 InFlight {
2905 generation: generation_number,
2906 cancel: Arc::clone(&cancel),
2907 },
2908 );
2909 cancels.insert(key.clone(), cancel);
2910 }
2911 SharedGeneration {
2912 generation: Generation::new(generation_number),
2913 cancels,
2914 }
2915 }
2916
2917 pub(crate) fn apply_probe_result_for_test(
2923 &self,
2924 key: &EntityKey,
2925 generation: Generation,
2926 settled: Settled<Head>,
2927 ) {
2928 apply_cheap_probe_outcomes(
2929 &self.table,
2930 key,
2931 generation,
2932 CheapProbeOutcomes {
2933 branch: Some((settled, None, Vec::new())),
2934 sync: None,
2935 base: None,
2936 default_branch: None,
2937 },
2938 );
2939 }
2940
2941 pub(crate) fn set_last_action_for_test(
2945 &self,
2946 key: &EntityKey,
2947 receipt: crate::entity::ActionReceipt,
2948 ) {
2949 let mut table = self.table.write().unwrap();
2950 if let Some(&idx) = table.index.get(key) {
2951 table.entities[idx].last_action = Some(receipt);
2952 }
2953 }
2954}
2955
2956fn run_action_for_entity(
2980 entity: &EntityState,
2981 action: &ActionSpec,
2982 control: &Arc<executor::RunControl>,
2983 report: &dyn Fn(ActionReceipt),
2984) -> ActionReceipt {
2985 let base_env = environment::environment(entity, action.name.as_deref());
2986 let mut failed = false;
2987 let mut cancelled = false;
2988 let mut results: Vec<StepResult> = Vec::with_capacity(action.steps.len());
2989 for step in &action.steps {
2990 if failed || cancelled || control.is_cancelled() {
2991 cancelled = cancelled || control.is_cancelled();
2992 results.push(StepResult {
2993 label: Arc::from(step.argv.join(" ")),
2994 outcome: if cancelled {
2995 StepOutcome::Cancelled
2996 } else {
2997 StepOutcome::NotRun
2998 },
2999 output: Arc::from(&b""[..]),
3000 elapsed: Duration::ZERO,
3001 elision: None,
3002 shell: step.shell,
3003 interactive: step.interactive,
3004 });
3005 continue;
3006 }
3007 let label: Arc<str> = Arc::from(step.argv.join(" "));
3008 report(ActionReceipt {
3009 label: Arc::clone(&action.label),
3010 steps: Arc::from(results.clone()),
3011 skip: None,
3012 finished_at: Timestamp::now(),
3013 running: Some(RunningStep {
3014 label: Arc::clone(&label),
3015 started_at: Timestamp::now(),
3016 shell: step.shell,
3017 interactive: step.interactive,
3018 }),
3019 });
3020 let mut env = base_env.clone();
3025 env.extend(
3026 step.env
3027 .iter()
3028 .map(|(name, value)| (name.clone(), Some(value.clone()))),
3029 );
3030 let mut result = executor::run_step(
3031 &step.argv,
3032 step.shell,
3033 step.interactive,
3034 entity.key.path(),
3035 &env,
3036 control,
3037 );
3038 if control.is_cancelled() {
3039 result.outcome = StepOutcome::Cancelled;
3040 cancelled = true;
3041 } else {
3042 failed = result.outcome.is_failure();
3043 }
3044 results.push(result);
3045 }
3046 ActionReceipt {
3047 label: Arc::clone(&action.label),
3048 steps: Arc::from(results),
3049 skip: None,
3050 finished_at: Timestamp::now(),
3051 running: None,
3052 }
3053}
3054
3055type DiscoveryGate = Arc<(Mutex<bool>, Condvar)>;
3060
3061fn wait_for_discovery_gate(gate: Option<&DiscoveryGate>) {
3064 let Some(gate) = gate else {
3065 return;
3066 };
3067 let (lock, cvar) = &**gate;
3068 let open = lock.lock().unwrap();
3069 drop(cvar.wait_while(open, |open| !*open).unwrap());
3070}
3071
3072#[cfg(test)]
3074fn set_discovery_gate(gate: &DiscoveryGate, open: bool) {
3075 let (lock, cvar) = &**gate;
3076 *lock.lock().unwrap() = open;
3077 cvar.notify_all();
3078}
3079
3080struct DiscoveryWatch {
3083 progress: Arc<AtomicUsize>,
3084 finished: Arc<AtomicBool>,
3085}
3086
3087fn spawn_discovery_watcher(
3093 roots: Vec<PathBuf>,
3094 discovery_warning: &Arc<Mutex<Option<String>>>,
3095 warn_after: Duration,
3096) -> (DiscoveryWatch, JoinHandle<()>) {
3097 let progress = Arc::new(AtomicUsize::new(0));
3098 let finished = Arc::new(AtomicBool::new(false));
3099 let watcher = thread::spawn({
3100 let progress = Arc::clone(&progress);
3101 let finished = Arc::clone(&finished);
3102 let warning_slot = Arc::clone(discovery_warning);
3103 move || {
3104 if let Some(message) = watch_for_slow_discovery(progress, finished, roots, warn_after) {
3105 *warning_slot.lock().unwrap() = Some(message);
3106 }
3107 }
3108 });
3109 (DiscoveryWatch { progress, finished }, watcher)
3110}
3111
3112fn run_watched_discovery(
3118 watch: &DiscoveryWatch,
3119 set: &SetSpec,
3120 discovery_warning: &Arc<Mutex<Option<String>>>,
3121 abandon_after: Duration,
3122) -> discovery::Discovery {
3123 let discovery =
3124 discovery::discover_watched_with_deadline(set, Arc::clone(&watch.progress), abandon_after);
3125 watch.finished.store(true, Ordering::Release);
3126
3127 if discovery.abandoned {
3128 *discovery_warning.lock().unwrap() =
3129 Some(abandoned_discovery_message(discovery.directories_visited));
3130 }
3131
3132 discovery
3133}
3134
3135fn start_internal(
3138 spec: CoreSpec,
3139 warn_after: Duration,
3140 discovery_abandon_after: Duration,
3141 ticks: Receiver<Instant>,
3142 fetch_start: FetchStart,
3143 alive: Arc<AtomicBool>,
3144 discovery_gate: Option<DiscoveryGate>,
3145) -> StartForTest {
3146 let FetchStart {
3147 enabled: fetch_enabled,
3148 concurrency: fetch_concurrency,
3149 ticks: fetch_ticks,
3150 } = fetch_start;
3151 let discovery_warning = Arc::new(Mutex::new(None));
3152 let discovery_manual = Arc::new(AtomicBool::new(false));
3153
3154 let (overrides, resolved_exclusions) = resolve_entries(&spec.overrides);
3155 let overrides = Arc::new(overrides);
3156 let exclusions = Arc::new(RwLock::new(resolved_exclusions));
3157 let show_submodules = Arc::new(AtomicBool::new(spec.show_submodules));
3158
3159 let table = Arc::new(RwLock::new(Table {
3160 generation: 0,
3161 discovered_at: Timestamp::now(),
3162 entities: Vec::new(),
3163 index: HashMap::new(),
3164 in_flight: HashMap::new(),
3165 generation_started_at: HashMap::new(),
3166 repos: HashMap::new(),
3167 poll_fingerprints: HashMap::new(),
3168 }));
3169
3170 let settle_gate: Arc<SettleGate> =
3171 Arc::new((Mutex::new(SettleCounts::default()), Condvar::new()));
3172 let poll_reprobed = Arc::new(Mutex::new(Vec::new()));
3173 let poll_sweep_count = Arc::new(AtomicUsize::new(0));
3174 let network_default_branch = Arc::new(Mutex::new(HashMap::new()));
3175 let (control, control_rx) = crossbeam_channel::unbounded();
3176 let poll_handles = PollHandles {
3177 overrides: Arc::clone(&overrides),
3178 show_submodules: Arc::clone(&show_submodules),
3179 poll_reprobed: Arc::clone(&poll_reprobed),
3180 poll_sweep_count: Arc::clone(&poll_sweep_count),
3181 network_default_branch: Arc::clone(&network_default_branch),
3182 };
3183
3184 let discovery_abandon_after_atomic =
3188 Arc::new(AtomicU64::new(discovery_abandon_after.as_nanos() as u64));
3189 let default_branch_chain_reads = Arc::new(AtomicUsize::new(0));
3190 let patch_identity_reads = Arc::new(AtomicUsize::new(0));
3191 let patch_scan_bounds = Arc::new(Mutex::new(Vec::new()));
3192 let dispatch_log = Arc::new(Mutex::new(Vec::new()));
3193 let phase_c_gates = Arc::new(Mutex::new(HashMap::new()));
3194 let fetch_cycle_count = Arc::new(AtomicUsize::new(0));
3195 let fetch_failures = Arc::new(Mutex::new(FetchFailures::default()));
3196 let fetch_cycles_taken_back = Arc::new(AtomicUsize::new(0));
3197 let fetch_running = Arc::new(AtomicBool::new(false));
3198 let (fetch_finished_tx, fetch_finished_rx) = crossbeam_channel::unbounded();
3199 #[cfg(test)]
3200 let fetch_boundary = Arc::new(FetchBoundary::default());
3201 let turnstile = Arc::new(DispatchTurnstile::default());
3202
3203 let fetch_refresh_handles = RefreshHandles {
3204 table: Arc::clone(&table),
3205 overrides: Arc::clone(&overrides),
3206 exclusions: Arc::clone(&exclusions),
3207 set: spec.set.clone(),
3208 discovery_manual: Arc::clone(&discovery_manual),
3209 discovery_warn_after: warn_after,
3210 discovery_abandon_after: Arc::clone(&discovery_abandon_after_atomic),
3211 discovery_warning: Arc::clone(&discovery_warning),
3212 show_submodules: Arc::clone(&show_submodules),
3213 settle_gate: Arc::clone(&settle_gate),
3214 default_branch_chain_reads: Arc::clone(&default_branch_chain_reads),
3215 patch_identity_reads: Arc::clone(&patch_identity_reads),
3216 patch_scan_bounds: Arc::clone(&patch_scan_bounds),
3217 dispatch_log: Arc::clone(&dispatch_log),
3218 phase_c_gates: Arc::clone(&phase_c_gates),
3219 network_default_branch: Arc::clone(&network_default_branch),
3220 turnstile: Arc::clone(&turnstile),
3221 discovery_gate: discovery_gate.clone(),
3222 };
3223 let auto_update_enabled = spec.auto_update.enabled;
3224 let fetch_schedule = FetchSchedule {
3225 concurrency: fetch_concurrency,
3226 ticks: fetch_ticks,
3227 refresh: fetch_refresh_handles.clone(),
3228 cycle_count: Arc::clone(&fetch_cycle_count),
3229 failures: Arc::clone(&fetch_failures),
3230 auto_update_enabled,
3231 finished: fetch_finished_rx,
3232 finished_tx: fetch_finished_tx,
3233 taken_back_count: Arc::clone(&fetch_cycles_taken_back),
3234 running: Arc::clone(&fetch_running),
3235 #[cfg(test)]
3236 boundary: Arc::clone(&fetch_boundary),
3237 };
3238
3239 let clock_thread = spawn_clock_thread(
3240 Arc::clone(&table),
3241 poll_handles,
3242 fetch_schedule,
3243 Arc::clone(&settle_gate),
3244 spec.generation_deadline,
3245 ClockChannels {
3246 control: control_rx,
3247 ticks,
3248 alive: Arc::clone(&alive),
3249 },
3250 );
3251
3252 let (startup_generation, startup_ticket) = fetch_refresh_handles.reserve_generation();
3262 begin_dispatch(&settle_gate);
3263 let (watch, discovery_watcher) =
3264 spawn_discovery_watcher(spec.set.roots.clone(), &discovery_warning, warn_after);
3265 let initial_discovery = thread::spawn({
3266 let set = spec.set.clone();
3267 let discovery_warning = Arc::clone(&discovery_warning);
3268 let discovery_manual = Arc::clone(&discovery_manual);
3269 let exclusions = Arc::clone(&exclusions);
3270 let table = Arc::clone(&table);
3271 let settle_gate = Arc::clone(&settle_gate);
3272 let fetch_refresh_handles = fetch_refresh_handles.clone();
3273 let control = control.clone();
3274 let discovery_gate = discovery_gate.clone();
3275 move || {
3276 let turn = fetch_refresh_handles.turnstile.take(startup_ticket);
3277 wait_for_discovery_gate(discovery_gate.as_ref());
3278 let discovery =
3279 run_watched_discovery(&watch, &set, &discovery_warning, discovery_abandon_after);
3280 if discovery.abandoned {
3281 discovery_manual.store(true, Ordering::Release);
3282 }
3283
3284 let (discovered, gitmodules_failures) = discovery::resolve(&set, &discovery.entities);
3289 let resolved_exclusions = exclusions.read().unwrap().clone();
3290 let order: Vec<EntityKey> = {
3291 let mut table = table.write().unwrap();
3292 merge_discovery(
3296 &mut table,
3297 &resolved_exclusions,
3298 discovered,
3299 gitmodules_failures,
3300 );
3301 table.discovered_at = Timestamp::now();
3302 table
3303 .entities
3304 .iter()
3305 .map(|entity| entity.key.clone())
3306 .collect()
3307 };
3308 fetch_refresh_handles.dispatch_probes(&order, startup_generation);
3312 finish_dispatch(&settle_gate);
3313 drop(turn);
3316
3317 if fetch_enabled {
3326 let _ = control.send(ClockControl::FetchOwed);
3327 }
3328 }
3329 });
3330
3331 StartForTest {
3332 core: Core {
3333 table,
3334 overrides,
3335 exclusions,
3336 set: spec.set,
3337 discovery_manual,
3338 discovery_warn_after: warn_after,
3339 discovery_abandon_after: discovery_abandon_after_atomic,
3340 show_submodules,
3341 settle_gate,
3342 control,
3343 clock_thread: Some(clock_thread),
3344 discovery_warning,
3345 default_branch_chain_reads,
3346 patch_identity_reads,
3347 patch_scan_bounds,
3348 action_lifecycle: Arc::new(Mutex::new(ActionLifecycle::default())),
3349 dispatch_log,
3350 phase_c_gates,
3351 status_stale_after: spec.status_stale_after,
3352 poll_reprobed,
3353 poll_sweep_count,
3354 fetch_cycle_count,
3355 network_default_branch,
3356 fetch_failures,
3357 fetch_running,
3358 turnstile,
3359 discovery_gate,
3360 #[cfg(test)]
3361 action_completion_boundary: Arc::new(ActionCompletionBoundary::default()),
3362 #[cfg(test)]
3363 fetch_boundary: Arc::clone(&fetch_boundary),
3364 },
3365 clock_alive: alive,
3366 discovery_watcher,
3367 initial_discovery: Some(initial_discovery),
3368 fetch_cycles_taken_back,
3369 }
3370}
3371
3372struct PollHandles {
3376 overrides: Arc<Vec<ResolvedOverride>>,
3377 show_submodules: Arc<AtomicBool>,
3378 poll_reprobed: Arc<Mutex<Vec<EntityKey>>>,
3379 poll_sweep_count: Arc<AtomicUsize>,
3380 network_default_branch: Arc<Mutex<HashMap<PathBuf, Arc<str>>>>,
3384}
3385
3386struct FetchStart {
3390 enabled: bool,
3391 concurrency: usize,
3392 ticks: Receiver<Instant>,
3393}
3394
3395struct FetchSchedule {
3402 concurrency: usize,
3403 ticks: Receiver<Instant>,
3404 refresh: RefreshHandles,
3405 cycle_count: Arc<AtomicUsize>,
3406 failures: Arc<Mutex<FetchFailures>>,
3407 auto_update_enabled: bool,
3411 finished: Receiver<()>,
3416 finished_tx: Sender<()>,
3417 taken_back_count: Arc<AtomicUsize>,
3420 running: Arc<AtomicBool>,
3422 #[cfg(test)]
3425 boundary: Arc<FetchBoundary>,
3426}
3427
3428struct ClockChannels {
3435 control: Receiver<ClockControl>,
3436 ticks: Receiver<Instant>,
3437 alive: Arc<AtomicBool>,
3438}
3439
3440fn spawn_clock_thread(
3458 table: Arc<RwLock<Table>>,
3459 poll: PollHandles,
3460 fetch: FetchSchedule,
3461 settle_gate: Arc<SettleGate>,
3462 generation_deadline: Duration,
3463 channels: ClockChannels,
3464) -> JoinHandle<()> {
3465 let ClockChannels {
3466 control,
3467 ticks,
3468 alive,
3469 } = channels;
3470 thread::spawn(move || {
3471 let mut paused = false;
3472 let mut cycle: Option<FetchCycle> = None;
3473 let mut immediate_cycle_owed = false;
3474 loop {
3475 select! {
3476 recv(control) -> message => match message {
3477 Ok(ClockControl::Pause) => {
3478 paused = true;
3479 cancel_in_flight(&table, &settle_gate);
3480 if let Some(cycle) = &cycle {
3481 cycle.cancel();
3482 }
3483 }
3484 Ok(ClockControl::Resume) => paused = false,
3485 Ok(ClockControl::FetchOwed) => immediate_cycle_owed = true,
3486 Ok(ClockControl::FetchNow) => {
3487 if !paused && cycle.is_none() {
3488 cycle = Some(start_fetch_cycle(&table, &fetch));
3489 }
3490 }
3491 Ok(ClockControl::Shutdown) | Err(_) => break,
3492 },
3493 recv(ticks) -> tick => {
3494 if tick.is_err() {
3495 break;
3496 }
3497 if !paused {
3498 run_poll_sweep(
3499 &table,
3500 &poll.overrides,
3501 &poll.show_submodules,
3502 &poll.poll_reprobed,
3503 &poll.poll_sweep_count,
3504 &poll.network_default_branch,
3505 );
3506 sweep_deadline(&table, &settle_gate, generation_deadline);
3507 }
3508 }
3509 recv(fetch.ticks) -> tick => {
3510 if tick.is_err() {
3511 break;
3512 }
3513 if !paused && cycle.is_none() {
3518 cycle = Some(start_fetch_cycle(&table, &fetch));
3519 }
3520 }
3521 recv(fetch.finished) -> _ => {
3522 if let Some(finished) = cycle.take() {
3523 let cancelled = finished.cancelled();
3524 finished.join();
3525 if !cancelled {
3526 dispatch_fetch_completion(&table, &fetch.refresh);
3527 }
3528 fetch.taken_back_count.fetch_add(1, Ordering::Release);
3529 fetch.running.store(false, Ordering::Release);
3530 }
3531 }
3532 }
3533 if immediate_cycle_owed && !paused && cycle.is_none() {
3536 immediate_cycle_owed = false;
3537 cycle = Some(start_fetch_cycle(&table, &fetch));
3538 }
3539 }
3540 if let Some(cycle) = cycle.take() {
3545 cycle.cancel();
3546 cycle.join();
3547 fetch.taken_back_count.fetch_add(1, Ordering::Release);
3548 fetch.running.store(false, Ordering::Release);
3549 }
3550 alive.store(false, Ordering::Release);
3551 })
3552}
3553
3554struct FetchCycle {
3561 cancel: Arc<AtomicBool>,
3562 worker: JoinHandle<()>,
3563 #[cfg(test)]
3565 boundary: Arc<FetchBoundary>,
3566}
3567
3568impl FetchCycle {
3569 fn cancel(&self) {
3576 self.cancel.store(true, Ordering::Release);
3577 #[cfg(test)]
3578 self.boundary.cancelled();
3579 }
3580
3581 fn cancelled(&self) -> bool {
3582 self.cancel.load(Ordering::Acquire)
3583 }
3584
3585 fn join(self) {
3587 let _ = self.worker.join();
3588 }
3589}
3590
3591fn start_fetch_cycle(table: &Arc<RwLock<Table>>, fetch: &FetchSchedule) -> FetchCycle {
3599 fetch.running.store(true, Ordering::Release);
3600 let cancel = Arc::new(AtomicBool::new(false));
3601 let work = FetchCycleWork {
3602 table: Arc::clone(table),
3603 concurrency: fetch.concurrency,
3604 cancel: Arc::clone(&cancel),
3605 network_default_branch: Arc::clone(&fetch.refresh.network_default_branch),
3606 cycle_count: Arc::clone(&fetch.cycle_count),
3607 failures: Arc::clone(&fetch.failures),
3608 auto_update_enabled: fetch.auto_update_enabled,
3609 #[cfg(test)]
3610 boundary: Arc::clone(&fetch.boundary),
3611 };
3612 #[cfg(test)]
3613 let boundary = Arc::clone(&work.boundary);
3614 let finished = fetch.finished_tx.clone();
3615 let worker = thread::spawn(move || {
3616 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
3617 run_fetch_cycle(&work);
3618 }));
3619 let _ = finished.send(());
3620 });
3621 FetchCycle {
3622 cancel,
3623 worker,
3624 #[cfg(test)]
3625 boundary,
3626 }
3627}
3628
3629fn dispatch_fetch_completion(table: &Arc<RwLock<Table>>, refresh: &RefreshHandles) {
3634 let all_keys: Vec<EntityKey> = table
3635 .read()
3636 .unwrap()
3637 .entities
3638 .iter()
3639 .map(|entity| entity.key.clone())
3640 .collect();
3641 refresh.dispatch(&all_keys);
3642}
3643
3644struct FetchCycleWork {
3649 table: Arc<RwLock<Table>>,
3650 concurrency: usize,
3651 cancel: Arc<AtomicBool>,
3652 network_default_branch: Arc<Mutex<HashMap<PathBuf, Arc<str>>>>,
3653 cycle_count: Arc<AtomicUsize>,
3654 failures: Arc<Mutex<FetchFailures>>,
3655 auto_update_enabled: bool,
3656 #[cfg(test)]
3659 boundary: Arc<FetchBoundary>,
3660}
3661
3662fn run_fetch_cycle(work: &FetchCycleWork) {
3682 let FetchCycleWork {
3683 table,
3684 concurrency,
3685 cancel,
3686 network_default_branch,
3687 cycle_count,
3688 failures,
3689 auto_update_enabled,
3690 #[cfg(test)]
3691 boundary,
3692 } = work;
3693 let auto_update_enabled = *auto_update_enabled;
3694 cycle_count.fetch_add(1, Ordering::Release);
3695
3696 let common_dirs = distinct_fetchable_common_dirs(table);
3697 let failed: Mutex<Vec<(PathBuf, String)>> = Mutex::new(Vec::new());
3698 crate::fetch::run_bounded(common_dirs, (*concurrency).max(1), |common_dir| {
3699 #[cfg(test)]
3701 boundary.hold();
3702 if cancel.load(Ordering::Acquire) {
3705 return;
3706 }
3707 match crate::fetch::fetch_and_prune(&common_dir, cancel) {
3713 Ok(outcome) => {
3714 if let Some(crate::fetch::AdvertisedDefaultBranch::Branch(name)) =
3723 outcome.advertised_default_branch
3724 {
3725 network_default_branch
3726 .lock()
3727 .unwrap()
3728 .insert(common_dir.clone(), Arc::from(name));
3729 }
3730 }
3731 Err(error) => {
3732 failed
3733 .lock()
3734 .unwrap()
3735 .push((common_dir.clone(), error.to_string()));
3736 }
3737 }
3738 });
3739 if !cancel.load(Ordering::Acquire) {
3743 *failures.lock().unwrap() = FetchFailures {
3744 failed: failed.into_inner().unwrap(),
3745 };
3746 }
3747
3748 if auto_update_enabled {
3757 for repo_path in repos_eligible_for_auto_update_attempt(table) {
3758 if cancel.load(Ordering::Acquire) {
3762 break;
3763 }
3764 let _ = crate::auto_update::attempt(&repo_path);
3767 }
3768 }
3769}
3770
3771fn repos_eligible_for_auto_update_attempt(table: &Arc<RwLock<Table>>) -> Vec<PathBuf> {
3780 table
3781 .read()
3782 .unwrap()
3783 .entities
3784 .iter()
3785 .filter(|entity| entity.kind == Kind::Repo && !entity.excluded)
3786 .map(|entity| entity.key.path().to_path_buf())
3787 .collect()
3788}
3789
3790fn distinct_fetchable_common_dirs(table: &Arc<RwLock<Table>>) -> Vec<PathBuf> {
3797 let table = table.read().unwrap();
3798 let mut seen: HashMap<PathBuf, bool> = HashMap::new();
3799 for entity in &table.entities {
3800 let common_dir = entity.common_dir.to_path_buf();
3801 let operable = seen.entry(common_dir).or_insert(false);
3802 *operable = *operable || !entity.excluded;
3803 }
3804 seen.into_iter()
3805 .filter(|(_, operable)| *operable)
3806 .map(|(common_dir, _)| common_dir)
3807 .collect()
3808}
3809
3810fn probe_network_default_branches(
3816 common_dirs: &HashSet<Arc<Path>>,
3817 network_default_branch: &Mutex<HashMap<PathBuf, Arc<str>>>,
3818) {
3819 for common_dir in common_dirs {
3820 if let Ok(Some(crate::fetch::AdvertisedDefaultBranch::Branch(name))) =
3821 crate::fetch::probe_remote_head(common_dir)
3822 {
3823 network_default_branch
3824 .lock()
3825 .unwrap()
3826 .insert(common_dir.to_path_buf(), Arc::from(name));
3827 }
3828 }
3829}
3830
3831struct RederiveCandidate {
3836 key: EntityKey,
3837 path: PathBuf,
3838 common_dir: Arc<Path>,
3839 repo: Option<Arc<gix::ThreadSafeRepository>>,
3840 override_branch: Option<String>,
3841 kind: Kind,
3842}
3843
3844struct PollCandidate {
3847 key: EntityKey,
3848 path: PathBuf,
3849 common_dir: Arc<Path>,
3850 kind: Kind,
3851 cached_repo: Option<Arc<gix::ThreadSafeRepository>>,
3852 probes_base: bool,
3853}
3854
3855fn run_poll_sweep(
3874 table: &Arc<RwLock<Table>>,
3875 overrides: &Arc<Vec<ResolvedOverride>>,
3876 show_submodules: &Arc<AtomicBool>,
3877 poll_reprobed: &Arc<Mutex<Vec<EntityKey>>>,
3878 poll_sweep_count: &Arc<AtomicUsize>,
3879 network_default_branch: &Mutex<HashMap<PathBuf, Arc<str>>>,
3880) {
3881 poll_sweep_count.fetch_add(1, Ordering::Release);
3882 poll_reprobed.lock().unwrap().clear();
3883 let show_submodules = show_submodules.load(Ordering::Acquire);
3884
3885 let candidates: Vec<PollCandidate> = {
3886 let table = table.read().unwrap();
3887 table
3888 .entities
3889 .iter()
3890 .filter(|entity| dispatches_kind(entity.kind, show_submodules))
3891 .map(|entity| PollCandidate {
3892 key: entity.key.clone(),
3893 path: entity.key.path().to_path_buf(),
3894 common_dir: Arc::clone(&entity.common_dir),
3895 kind: entity.kind,
3896 cached_repo: table.repos.get(&entity.key).cloned(),
3897 probes_base: entity.probes_base(),
3898 })
3899 .collect()
3900 };
3901
3902 for candidate in candidates {
3903 let opened;
3909 let repo = match candidate.cached_repo.as_deref() {
3910 Some(repo) => Some(repo),
3911 None => match git::open_thread_safe(&candidate.path) {
3912 Ok(repo) => {
3913 opened = repo;
3914 Some(&opened)
3915 }
3916 Err(_) => None,
3917 },
3918 };
3919 let gitdir = repo
3920 .map(|repo| repo.git_dir().to_path_buf())
3921 .unwrap_or_else(|| candidate.common_dir.to_path_buf());
3922
3923 let current = poll::fingerprint(&gitdir);
3924 let moved = {
3925 let mut table = table.write().unwrap();
3926 let previous = table
3927 .poll_fingerprints
3928 .insert(candidate.key.clone(), current);
3929 previous.is_some_and(|previous| poll::moved(&previous, ¤t))
3930 };
3931 if !moved {
3932 continue;
3933 }
3934
3935 {
3936 let mut table = table.write().unwrap();
3937 if let Some(&idx) = table.index.get(&candidate.key) {
3938 table.entities[idx].force_stale_status_cells();
3939 }
3940 }
3941
3942 let override_branch = find_entry(overrides, &candidate.path, &candidate.common_dir)
3943 .and_then(|entry| entry.default_branch.clone());
3944 let never_cancelled = AtomicBool::new(false);
3945 let chain_cache: ChainFactsCache = Mutex::new(HashMap::new());
3946 let chain_reads = AtomicUsize::new(0);
3947
3948 let branch_outcome = probe_branch(&candidate.path, repo, candidate.kind, &never_cancelled);
3949 let sync_outcome = probe_sync(
3950 &candidate.path,
3951 repo,
3952 branch_outcome.as_ref().map(|(settled, ..)| settled),
3953 candidate.kind,
3954 &never_cancelled,
3955 );
3956 let default_branch_outcome = probe_default_branch_memoised(
3957 &candidate.path,
3958 repo,
3959 &candidate.common_dir,
3960 DefaultBranchHints {
3961 override_branch: override_branch.as_deref(),
3962 network_branch: network_branch_for(network_default_branch, &candidate.common_dir)
3963 .as_deref(),
3964 },
3965 candidate.kind,
3966 &never_cancelled,
3967 &ChainFactsMemo {
3968 cache: &chain_cache,
3969 reads: &chain_reads,
3970 },
3971 );
3972 let base_outcome = if candidate.probes_base {
3973 probe_base(
3974 &candidate.path,
3975 repo,
3976 branch_outcome.as_ref().map(|(settled, ..)| settled),
3977 default_branch_outcome.as_ref().map(|r| &r.settled),
3978 &never_cancelled,
3979 )
3980 } else {
3981 None
3982 };
3983
3984 let generation = {
3985 let mut table = table.write().unwrap();
3986 table.generation += 1;
3987 Generation::new(table.generation)
3988 };
3989 apply_cheap_probe_outcomes(
3990 table,
3991 &candidate.key,
3992 generation,
3993 CheapProbeOutcomes {
3994 branch: branch_outcome,
3995 sync: sync_outcome,
3996 base: base_outcome,
3997 default_branch: default_branch_outcome,
3998 },
3999 );
4000 poll_reprobed.lock().unwrap().push(candidate.key);
4001 }
4002}
4003
4004fn cancel_in_flight(table: &Arc<RwLock<Table>>, settle_gate: &Arc<SettleGate>) {
4009 let mut table = table.write().unwrap();
4010 let cancelled = table.in_flight.len();
4011 for in_flight in table.in_flight.values() {
4012 in_flight.cancel.store(true, Ordering::Release);
4013 }
4014 table.in_flight.clear();
4015 table.generation_started_at.clear();
4016 drop(table);
4017 if cancelled > 0 {
4018 complete_many(settle_gate, cancelled);
4019 }
4020}
4021
4022trait TimeoutableCell {
4028 fn is_in_flight(&self) -> bool;
4029 fn time_out(&mut self, generation: Generation);
4032}
4033
4034impl<T> TimeoutableCell for Cell<T> {
4035 fn is_in_flight(&self) -> bool {
4036 Cell::is_in_flight(self)
4037 }
4038
4039 fn time_out(&mut self, generation: Generation) {
4040 self.settle(generation, Settled::Unknown(Unknown::TimedOut));
4041 }
4042}
4043
4044fn sweep_deadline(table: &Arc<RwLock<Table>>, settle_gate: &Arc<SettleGate>, deadline: Duration) {
4049 let mut table = table.write().unwrap();
4050 let now = Instant::now();
4051 let mut timed_out = Vec::new();
4052 for (key, in_flight) in table.in_flight.iter() {
4053 let started = table
4054 .generation_started_at
4055 .get(&in_flight.generation)
4056 .copied()
4057 .unwrap_or(now);
4058 if now.duration_since(started) >= deadline {
4059 timed_out.push((key.clone(), Generation::new(in_flight.generation)));
4060 }
4061 }
4062 for (key, generation) in &timed_out {
4063 if let Some(&idx) = table.index.get(key) {
4064 let EntityState {
4067 key: _,
4068 name: _,
4069 common_dir: _,
4070 kind: _,
4071 branch,
4072 sync,
4073 base,
4074 dirty,
4075 state,
4076 default_branch,
4077 diagnostics: _,
4078 last_action: _,
4079 presence: _,
4080 excluded: _,
4081 in_progress_operation: _,
4082 recent_commits: _,
4083 } = &mut table.entities[idx];
4084 let cells: [&mut dyn TimeoutableCell; 6] =
4085 [branch, sync, base, dirty, state, default_branch];
4086 for cell in cells {
4087 if cell.is_in_flight() {
4092 cell.time_out(*generation);
4093 }
4094 }
4095 }
4096 table.in_flight.remove(key);
4097 }
4098 let live_generations: std::collections::HashSet<u64> =
4099 table.in_flight.values().map(|f| f.generation).collect();
4100 table
4101 .generation_started_at
4102 .retain(|generation, _| live_generations.contains(generation));
4103 drop(table);
4104 if !timed_out.is_empty() {
4105 complete_many(settle_gate, timed_out.len());
4106 }
4107}
4108
4109fn begin_probes(entity: &mut EntityState) {
4116 let probes_state = entity.probes_state();
4117 let EntityState {
4118 key: _,
4119 name: _,
4120 common_dir: _,
4121 kind: _,
4122 branch,
4123 sync: _,
4124 base: _,
4125 dirty,
4126 state,
4127 default_branch,
4128 diagnostics: _,
4129 last_action: _,
4130 presence: _,
4131 excluded: _,
4132 in_progress_operation: _,
4133 recent_commits: _,
4134 } = entity;
4135 branch.begin_probe();
4136 default_branch.begin_probe();
4137 dirty.begin_probe();
4141 if probes_state {
4146 state.begin_probe();
4147 }
4148}
4149
4150type SettleGate = (Mutex<SettleCounts>, Condvar);
4153
4154#[derive(Default)]
4161struct SettleCounts {
4162 probes: usize,
4165 dispatches: usize,
4168}
4169
4170impl SettleCounts {
4171 fn is_settled(&self) -> bool {
4177 let SettleCounts { probes, dispatches } = self;
4178 *probes == 0 && *dispatches == 0
4179 }
4180}
4181
4182fn begin_dispatch(settle_gate: &SettleGate) {
4185 let (lock, _cvar) = settle_gate;
4186 lock.lock().unwrap().dispatches += 1;
4187}
4188
4189fn finish_dispatch(settle_gate: &SettleGate) {
4192 let (lock, cvar) = settle_gate;
4193 let mut counts = lock.lock().unwrap();
4194 counts.dispatches = counts.dispatches.saturating_sub(1);
4195 drop(counts);
4196 cvar.notify_all();
4199}
4200
4201fn begin_probes_owed(settle_gate: &SettleGate, owed: usize) {
4202 let (lock, _cvar) = settle_gate;
4203 lock.lock().unwrap().probes += owed;
4204}
4205
4206fn complete_one(settle_gate: &SettleGate) {
4207 complete_many(settle_gate, 1);
4208}
4209
4210fn complete_many(settle_gate: &SettleGate, finished: usize) {
4211 let (lock, cvar) = settle_gate;
4212 let mut counts = lock.lock().unwrap();
4213 counts.probes = counts.probes.saturating_sub(finished);
4214 if counts.is_settled() {
4215 cvar.notify_all();
4216 }
4217}
4218
4219const RECENT_COMMITS_LIMIT: usize = 5;
4238
4239fn submodule_open_failure<T>(kind: Kind, error: git::ProbeError) -> Settled<T> {
4246 match kind {
4247 Kind::Repo | Kind::Worktree => Settled::Failed(error),
4248 Kind::Submodule => Settled::Unknown(Unknown::SubmoduleUninitialized),
4249 }
4250}
4251
4252fn probe_branch(
4253 path: &Path,
4254 repo: Option<&gix::ThreadSafeRepository>,
4255 kind: Kind,
4256 cancel: &AtomicBool,
4257) -> Option<(
4258 Settled<Head>,
4259 Option<git::InProgressOperation>,
4260 Vec<git::RecentCommit>,
4261)> {
4262 if cancel.load(Ordering::Acquire) {
4263 return None;
4264 }
4265 let opened;
4266 let repo = match repo {
4267 Some(repo) => repo,
4268 None => match git::open_thread_safe(path) {
4269 Ok(repo) => {
4270 opened = repo;
4271 &opened
4272 }
4273 Err(error) => return Some((submodule_open_failure(kind, error), None, Vec::new())),
4274 },
4275 };
4276 let local = repo.to_thread_local();
4277 let settled = match git::head_shape(&local) {
4278 Ok(head) => Settled::Known {
4279 value: head,
4280 at: Timestamp::now(),
4281 stale: false,
4282 },
4283 Err(error) => Settled::Failed(error),
4284 };
4285 let in_progress = git::in_progress_operation(&local);
4286 let recent = git::recent_commits(&local, RECENT_COMMITS_LIMIT);
4287 Some((settled, in_progress, recent))
4288}
4289
4290fn probe_sync(
4301 path: &Path,
4302 repo: Option<&gix::ThreadSafeRepository>,
4303 branch_settled: Option<&Settled<Head>>,
4304 kind: Kind,
4305 cancel: &AtomicBool,
4306) -> Option<Settled<SyncState>> {
4307 if cancel.load(Ordering::Acquire) {
4308 return None;
4309 }
4310 let head = match branch_settled? {
4311 Settled::Known {
4312 value,
4313 at: _,
4314 stale: _,
4315 } => Some(value),
4316 Settled::Failed(error) => return Some(Settled::Failed(error.clone())),
4317 Settled::Unknown(_) | Settled::NotApplicable => None,
4318 };
4319 let opened;
4320 let repo = match repo {
4321 Some(repo) => repo,
4322 None => match git::open_thread_safe(path) {
4323 Ok(repo) => {
4324 opened = repo;
4325 &opened
4326 }
4327 Err(error) => return Some(submodule_open_failure(kind, error)),
4328 },
4329 };
4330 let local = repo.to_thread_local();
4331 let settled = match git::resolve_sync(&local, head) {
4332 Ok(value) => Settled::Known {
4333 value,
4334 at: Timestamp::now(),
4335 stale: false,
4336 },
4337 Err(error) => Settled::Failed(error),
4338 };
4339 Some(settled)
4340}
4341
4342fn probe_base(
4354 path: &Path,
4355 repo: Option<&gix::ThreadSafeRepository>,
4356 branch_settled: Option<&Settled<Head>>,
4357 default_branch_settled: Option<&Settled<DefaultBranch>>,
4358 cancel: &AtomicBool,
4359) -> Option<Settled<u32>> {
4360 if cancel.load(Ordering::Acquire) {
4361 return None;
4362 }
4363 let head = match branch_settled? {
4364 Settled::Known {
4365 value,
4366 at: _,
4367 stale: _,
4368 } => value,
4369 Settled::Failed(error) => return Some(Settled::Failed(error.clone())),
4370 Settled::Unknown(_) | Settled::NotApplicable => return None,
4371 };
4372 let default_branch_settled = default_branch_settled?;
4373 let opened;
4374 let repo = match repo {
4375 Some(repo) => repo,
4376 None => match git::open_thread_safe(path) {
4377 Ok(repo) => {
4378 opened = repo;
4379 &opened
4380 }
4381 Err(error) => return Some(Settled::Failed(error)),
4382 },
4383 };
4384 let local = repo.to_thread_local();
4385 Some(base::probe(&local, head, default_branch_settled))
4386}
4387
4388fn probe_status(
4398 path: &Path,
4399 repo: Option<&gix::ThreadSafeRepository>,
4400 kind: Kind,
4401 cancel: &Arc<AtomicBool>,
4402) -> Option<Settled<DirtyCounts>> {
4403 if cancel.load(Ordering::Acquire) {
4404 return None;
4405 }
4406 let opened;
4407 let repo = match repo {
4408 Some(repo) => repo,
4409 None => match git::open_thread_safe(path) {
4410 Ok(repo) => {
4411 opened = repo;
4412 &opened
4413 }
4414 Err(error) => return Some(submodule_open_failure(kind, error)),
4415 },
4416 };
4417 let local = repo.to_thread_local();
4418 classify_status_result(git::dirty_counts(&local, Arc::clone(cancel)), cancel)
4419}
4420
4421fn classify_status_result(
4437 result: Result<DirtyCounts, git::ProbeError>,
4438 cancel: &AtomicBool,
4439) -> Option<Settled<DirtyCounts>> {
4440 match result {
4441 Ok(_) if cancel.load(Ordering::Acquire) => None,
4442 Ok(value) => Some(Settled::Known {
4443 value,
4444 at: Timestamp::now(),
4445 stale: false,
4446 }),
4447 Err(_) if cancel.load(Ordering::Acquire) => None,
4448 Err(error) => Some(Settled::Failed(error)),
4449 }
4450}
4451
4452struct DefaultBranchHints<'a> {
4458 override_branch: Option<&'a str>,
4461 network_branch: Option<&'a str>,
4465}
4466
4467fn network_branch_for(
4471 network_default_branch: &Mutex<HashMap<PathBuf, Arc<str>>>,
4472 common_dir: &Path,
4473) -> Option<Arc<str>> {
4474 network_default_branch
4475 .lock()
4476 .unwrap()
4477 .get(common_dir)
4478 .cloned()
4479}
4480
4481fn supersede_with_network(
4490 mut resolution: default_branch::Resolution,
4491 network_branch: Option<&str>,
4492) -> default_branch::Resolution {
4493 if let Some(name) = network_branch {
4494 resolution.settled = Settled::Known {
4495 value: DefaultBranch::new(name.into()),
4496 at: Timestamp::now(),
4497 stale: false,
4498 };
4499 }
4500 resolution
4501}
4502
4503fn probe_default_branch(
4511 path: &Path,
4512 repo: Option<&gix::ThreadSafeRepository>,
4513 hints: DefaultBranchHints<'_>,
4514 kind: Kind,
4515 cancel: &AtomicBool,
4516) -> Option<default_branch::Resolution> {
4517 if cancel.load(Ordering::Acquire) {
4518 return None;
4519 }
4520 let opened;
4521 let repo = match repo {
4522 Some(repo) => repo,
4523 None => match git::open_thread_safe(path) {
4524 Ok(repo) => {
4525 opened = repo;
4526 &opened
4527 }
4528 Err(error) => {
4529 return Some(match kind {
4530 Kind::Repo | Kind::Worktree => default_branch::Resolution::failed(error),
4531 Kind::Submodule => default_branch::Resolution::submodule_uninitialized(),
4532 });
4533 }
4534 },
4535 };
4536 Some(supersede_with_network(
4537 default_branch::resolve(&repo.to_thread_local(), hints.override_branch),
4538 hints.network_branch,
4539 ))
4540}
4541
4542struct BoundGate {
4555 state: Mutex<BoundGateState>,
4556 condvar: Condvar,
4557 bound: OnceLock<Option<gix::ObjectId>>,
4558}
4559
4560struct BoundGateState {
4561 remaining: usize,
4562 candidates: Vec<gix::ObjectId>,
4563}
4564
4565impl BoundGate {
4566 fn new(remaining: usize) -> Self {
4567 Self {
4568 state: Mutex::new(BoundGateState {
4569 remaining,
4570 candidates: Vec::new(),
4571 }),
4572 condvar: Condvar::new(),
4573 bound: OnceLock::new(),
4574 }
4575 }
4576
4577 fn report(&self, candidate: Option<gix::ObjectId>) {
4583 let mut state = self.state.lock().unwrap();
4584 if let Some(candidate) = candidate {
4585 state.candidates.push(candidate);
4586 }
4587 state.remaining -= 1;
4588 if state.remaining == 0 {
4589 self.condvar.notify_all();
4590 }
4591 }
4592
4593 fn deepest(&self, repo: &gix::Repository) -> Option<gix::ObjectId> {
4603 let mut state = self.state.lock().unwrap();
4604 while state.remaining != 0 {
4605 state = self.condvar.wait(state).unwrap();
4606 }
4607 let candidates = std::mem::take(&mut state.candidates);
4608 *self
4609 .bound
4610 .get_or_init(|| deepest_merge_base(repo, &candidates))
4611 }
4612}
4613
4614fn deepest_merge_base(
4621 repo: &gix::Repository,
4622 candidates: &[gix::ObjectId],
4623) -> Option<gix::ObjectId> {
4624 let mut candidates = candidates.iter().copied();
4625 let mut deepest = candidates.next()?;
4626 for candidate in candidates {
4627 deepest = git::checked_merge_base(repo, deepest, candidate)
4628 .ok()
4629 .flatten()
4630 .unwrap_or(deepest);
4631 }
4632 Some(deepest)
4633}
4634
4635struct GateReport<'a> {
4641 gate: &'a BoundGate,
4642 reported: bool,
4643}
4644
4645impl<'a> GateReport<'a> {
4646 fn new(gate: &'a BoundGate) -> Self {
4647 Self {
4648 gate,
4649 reported: false,
4650 }
4651 }
4652
4653 fn report_now(&mut self, candidate: Option<gix::ObjectId>) {
4658 self.gate.report(candidate);
4659 self.reported = true;
4660 }
4661}
4662
4663impl Drop for GateReport<'_> {
4664 fn drop(&mut self) {
4665 if !self.reported {
4666 self.gate.report(None);
4667 }
4668 }
4669}
4670
4671struct PatchEquivalenceMemo<'a> {
4675 cache: &'a PatchIdentityCache,
4676 reads: &'a AtomicUsize,
4677 scan_bounds: &'a Mutex<Vec<Option<gix::ObjectId>>>,
4680}
4681
4682fn probe_worktree_state(
4691 path: &Path,
4692 repo: Option<&gix::ThreadSafeRepository>,
4693 default_branch_settled: Option<&Settled<DefaultBranch>>,
4694 common_dir: &Arc<Path>,
4695 cancel: &AtomicBool,
4696 memo: &PatchEquivalenceMemo<'_>,
4697 report: &mut GateReport<'_>,
4698) -> Option<Settled<WorktreeState>> {
4699 if cancel.load(Ordering::Acquire) {
4700 return None;
4701 }
4702 let default_branch_settled = default_branch_settled?;
4703 let opened;
4704 let repo = match repo {
4705 Some(repo) => repo,
4706 None => match git::open_thread_safe(path) {
4707 Ok(repo) => {
4708 opened = repo;
4709 &opened
4710 }
4711 Err(error) => return Some(Settled::Failed(error)),
4712 },
4713 };
4714 let local = repo.to_thread_local();
4715 match landing::probe(&local, default_branch_settled) {
4716 landing::Outcome::Settle(settled) => Some(settled),
4717 landing::Outcome::Outstanding(outstanding) => {
4718 probe_patch_equivalence(&local, &outstanding, common_dir, cancel, memo, report)
4719 }
4720 }
4721}
4722
4723fn probe_patch_equivalence(
4731 repo: &gix::Repository,
4732 outstanding: &landing::Outstanding,
4733 common_dir: &Arc<Path>,
4734 cancel: &AtomicBool,
4735 memo: &PatchEquivalenceMemo<'_>,
4736 report: &mut GateReport<'_>,
4737) -> Option<Settled<WorktreeState>> {
4738 if cancel.load(Ordering::Acquire) {
4739 return None;
4740 }
4741 let landing::Outstanding {
4742 entity_tip,
4743 default_tip,
4744 merge_base,
4745 } = *outstanding;
4746 let Some(merge_base) = merge_base else {
4747 report.report_now(None);
4753 return Some(patch_equivalence::probe(
4754 repo,
4755 entity_tip,
4756 None,
4757 &patch_equivalence::PatchIdentitySet::new(),
4758 ));
4759 };
4760 report.report_now(Some(merge_base));
4764 let bound = report.gate.deepest(repo);
4765 let shared = match patch_identities_for(memo.cache, common_dir, memo.reads, || {
4766 memo.scan_bounds.lock().unwrap().push(bound);
4771 patch_equivalence::scan_default_branch(repo, default_tip, bound)
4772 }) {
4773 Ok(shared) => shared,
4774 Err(error) => return Some(Settled::Failed(error)),
4775 };
4776 Some(patch_equivalence::probe(
4777 repo,
4778 entity_tip,
4779 Some(merge_base),
4780 &shared,
4781 ))
4782}
4783
4784type PatchIdentityCache = Mutex<
4792 HashMap<Arc<Path>, Arc<OnceLock<Result<patch_equivalence::PatchIdentitySet, git::ProbeError>>>>,
4793>;
4794
4795fn patch_identities_for(
4805 cache: &PatchIdentityCache,
4806 common_dir: &Arc<Path>,
4807 reads: &AtomicUsize,
4808 compute: impl FnOnce() -> Result<patch_equivalence::PatchIdentitySet, git::ProbeError>,
4809) -> Result<patch_equivalence::PatchIdentitySet, git::ProbeError> {
4810 let cell = {
4811 let mut cache = cache.lock().unwrap();
4812 Arc::clone(
4813 cache
4814 .entry(Arc::clone(common_dir))
4815 .or_insert_with(|| Arc::new(OnceLock::new())),
4816 )
4817 };
4818 cell.get_or_init(|| {
4819 reads.fetch_add(1, Ordering::Relaxed);
4820 compute()
4821 })
4822 .clone()
4823}
4824
4825type ChainFactsCache = Mutex<HashMap<Arc<Path>, Arc<OnceLock<default_branch::ChainFacts>>>>;
4829
4830fn chain_facts_for(
4837 cache: &ChainFactsCache,
4838 common_dir: &Arc<Path>,
4839 reads: &AtomicUsize,
4840 compute: impl FnOnce() -> default_branch::ChainFacts,
4841) -> default_branch::ChainFacts {
4842 let cell = {
4843 let mut cache = cache.lock().unwrap();
4844 Arc::clone(
4845 cache
4846 .entry(Arc::clone(common_dir))
4847 .or_insert_with(|| Arc::new(OnceLock::new())),
4848 )
4849 };
4850 cell.get_or_init(|| {
4851 reads.fetch_add(1, Ordering::Relaxed);
4852 compute()
4853 })
4854 .clone()
4855}
4856
4857struct ChainFactsMemo<'a> {
4868 cache: &'a ChainFactsCache,
4869 reads: &'a AtomicUsize,
4870}
4871
4872fn probe_default_branch_memoised(
4873 path: &Path,
4874 repo: Option<&gix::ThreadSafeRepository>,
4875 common_dir: &Arc<Path>,
4876 hints: DefaultBranchHints<'_>,
4877 kind: Kind,
4878 cancel: &AtomicBool,
4879 memo: &ChainFactsMemo<'_>,
4880) -> Option<default_branch::Resolution> {
4881 if cancel.load(Ordering::Acquire) {
4882 return None;
4883 }
4884 let opened;
4885 let repo = match repo {
4886 Some(repo) => repo,
4887 None => match git::open_thread_safe(path) {
4888 Ok(repo) => {
4889 opened = repo;
4890 &opened
4891 }
4892 Err(error) => {
4893 return Some(match kind {
4894 Kind::Repo | Kind::Worktree => default_branch::Resolution::failed(error),
4895 Kind::Submodule => default_branch::Resolution::submodule_uninitialized(),
4896 });
4897 }
4898 },
4899 };
4900 let local = repo.to_thread_local();
4901 let facts = chain_facts_for(memo.cache, common_dir, memo.reads, || {
4902 default_branch::ChainFacts::resolve(&local)
4903 });
4904 Some(supersede_with_network(
4905 default_branch::resolve_with_facts(&facts, hints.override_branch),
4906 hints.network_branch,
4907 ))
4908}
4909
4910struct CheapProbeOutcomes {
4915 branch: Option<(
4916 Settled<Head>,
4917 Option<git::InProgressOperation>,
4918 Vec<git::RecentCommit>,
4919 )>,
4920 sync: Option<Settled<SyncState>>,
4921 base: Option<Settled<u32>>,
4922 default_branch: Option<default_branch::Resolution>,
4923}
4924
4925fn apply_cheap_probe_outcomes(
4934 table: &Arc<RwLock<Table>>,
4935 key: &EntityKey,
4936 generation: Generation,
4937 outcomes: CheapProbeOutcomes,
4938) {
4939 let CheapProbeOutcomes {
4940 branch: branch_outcome,
4941 sync: sync_outcome,
4942 base: base_outcome,
4943 default_branch: default_branch_outcome,
4944 } = outcomes;
4945 let mut table = table.write().unwrap();
4946 if let Some(&idx) = table.index.get(key) {
4947 if let Some((settled, in_progress, recent)) = branch_outcome {
4948 table.entities[idx].apply_branch_probe(generation, settled, in_progress, recent);
4949 }
4950 if let Some(settled) = sync_outcome {
4951 table.entities[idx].sync.settle(generation, settled);
4952 }
4953 if let Some(settled) = base_outcome {
4954 table.entities[idx].base.settle(generation, settled);
4955 }
4956 if let Some(resolution) = default_branch_outcome {
4957 table.entities[idx].apply_default_branch_resolution(generation, resolution);
4958 }
4959 }
4960}
4961
4962struct ProbeOutcomes {
4966 state: Option<Settled<WorktreeState>>,
4967 dirty: Option<Settled<DirtyCounts>>,
4968}
4969
4970fn apply_probe_outcome(
4984 table: &Arc<RwLock<Table>>,
4985 settle_gate: &Arc<SettleGate>,
4986 key: &EntityKey,
4987 generation: Generation,
4988 outcomes: ProbeOutcomes,
4989) {
4990 let ProbeOutcomes {
4991 state: state_outcome,
4992 dirty: dirty_outcome,
4993 } = outcomes;
4994 let mut table = table.write().unwrap();
4995 if let Some(&idx) = table.index.get(key) {
4996 if let Some(settled) = state_outcome {
4997 table.entities[idx].state.settle(generation, settled);
4998 }
4999 if let Some(settled) = dirty_outcome {
5000 table.entities[idx].dirty.settle(generation, settled);
5001 }
5002 }
5003 if table
5011 .in_flight
5012 .get(key)
5013 .is_some_and(|in_flight| in_flight.generation == generation.value())
5014 {
5015 table.in_flight.remove(key);
5016 }
5017 drop(table);
5018 complete_one(settle_gate);
5019}
5020
5021fn merge_discovery(
5027 table: &mut Table,
5028 exclusions: &[ResolvedExclusion],
5029 discovered: Vec<discovery::DiscoveredEntity>,
5030 gitmodules_failures: Vec<(EntityKey, String)>,
5031) -> usize {
5032 let mut found: HashSet<EntityKey> = HashSet::with_capacity(discovered.len());
5033
5034 for discovered in discovered {
5035 found.insert(discovered.key.clone());
5036 match table.index.get(&discovered.key).copied() {
5037 Some(idx) => {
5038 table.entities[idx].presence = Presence::Present;
5039 if let Some(repo) = discovered.repo {
5040 table.repos.insert(discovered.key.clone(), repo);
5041 }
5042 }
5043 None => {
5044 let name = discovered
5045 .display_name_override
5046 .clone()
5047 .unwrap_or_else(|| display_name(discovered.key.path()));
5048 let mut entity = EntityState::new(
5049 discovered.key.clone(),
5050 name,
5051 Arc::clone(&discovered.common_dir),
5052 discovered.kind,
5053 );
5054 entity.excluded =
5055 excluded_by(exclusions, discovered.key.path(), &discovered.common_dir);
5056 if let Some(repo) = discovered.repo {
5057 table.repos.insert(discovered.key.clone(), repo);
5058 }
5059 let idx = table.entities.len();
5060 table.index.insert(discovered.key, idx);
5061 table.entities.push(entity);
5062 }
5063 }
5064 }
5065
5066 let now_failing: HashMap<EntityKey, String> = gitmodules_failures.into_iter().collect();
5070 for key in &found {
5071 if let Some(&idx) = table.index.get(key) {
5072 table.entities[idx].diagnostics.gitmodules_failed = now_failing
5073 .get(key)
5074 .map(|message| Arc::from(message.as_str()));
5075 }
5076 }
5077
5078 let missing: Vec<EntityKey> = table
5079 .index
5080 .keys()
5081 .filter(|key| !found.contains(*key))
5082 .cloned()
5083 .collect();
5084 let mut cancelled = 0usize;
5085 for key in missing {
5086 if let Some(&idx) = table.index.get(&key) {
5087 table.entities[idx].mark_vanished();
5088 }
5089 if let Some(in_flight) = table.in_flight.remove(&key) {
5090 in_flight.cancel.store(true, Ordering::Release);
5091 cancelled += 1;
5092 }
5093 }
5094
5095 cancelled
5096}
5097
5098fn display_name(path: &Path) -> Arc<str> {
5107 Arc::from(
5108 path.file_name()
5109 .and_then(|name| name.to_str())
5110 .unwrap_or("?"),
5111 )
5112}
5113
5114fn watch_for_slow_discovery(
5120 progress: Arc<AtomicUsize>,
5121 finished: Arc<AtomicBool>,
5122 roots: Vec<PathBuf>,
5123 warn_after: Duration,
5124) -> Option<String> {
5125 thread::sleep(warn_after);
5126 if finished.load(Ordering::Acquire) {
5127 return None;
5128 }
5129 Some(still_walking_message(
5130 progress.load(Ordering::Acquire),
5131 &roots,
5132 ))
5133}
5134
5135fn still_walking_message(directories_visited: usize, roots: &[PathBuf]) -> String {
5136 let roots = roots
5137 .iter()
5138 .map(|root| root.display().to_string())
5139 .collect::<Vec<_>>()
5140 .join(", ");
5141 format!("discovery: still walking, {directories_visited} directories reached under {roots}")
5142}
5143
5144fn abandoned_discovery_message(directories_visited: usize) -> String {
5149 format!("discovery: stopped at {directories_visited} directories")
5150}
5151
5152#[allow(dead_code)] pub(crate) fn run_while_not_cancelled(
5160 cancel: &AtomicBool,
5161 mut step: impl FnMut() -> bool,
5162) -> usize {
5163 let mut ran = 0;
5164 while !cancel.load(Ordering::Acquire) {
5165 if !step() {
5166 break;
5167 }
5168 ran += 1;
5169 }
5170 ran
5171}
5172
5173#[cfg(test)]
5174mod tests {
5175 use std::fs;
5176 use std::process::Command;
5177 use std::sync::mpsc;
5178
5179 use super::*;
5180 use crate::entity::{AheadBehind, DefaultBranchStopped, WorktreeState};
5181 use crate::liveness::{BACKSTOP, FIXTURE_LIFETIME, wait_for};
5182 use crate::snapshot::{RowSummary, summary};
5183 use crate::test_support::{git, head_sha, loose_object_count};
5184
5185 fn init_repo_with_a_commit(path: &Path) {
5186 fs::create_dir_all(path).expect("create repo dir");
5187 gix::init(path).expect("init repo");
5188 let status = Command::new("git")
5189 .arg("-C")
5190 .arg(path)
5191 .args(["-c", "user.email=test@example.com", "-c", "user.name=Test"])
5192 .args(["commit", "--allow-empty", "-m", "first"])
5193 .status()
5194 .expect("run git commit");
5195 assert!(status.success());
5196 }
5197
5198 fn commit_a_change(path: &Path, message: &str) {
5208 let gitdir = gitdir_of(path);
5209 let before = poll::fingerprint(&gitdir);
5210
5211 std::fs::write(path.join(format!("{message}.txt")), message.as_bytes())
5212 .expect("write a file to commit");
5213 let added = Command::new("git")
5214 .arg("-C")
5215 .arg(path)
5216 .args(["add", "-A"])
5217 .status()
5218 .expect("run git add");
5219 assert!(added.success());
5220 commit(path, message, &["-m", message]);
5221
5222 assert!(
5228 poll::moved(&before, &poll::fingerprint(&gitdir)),
5229 "committing in {} moved none of the polled paths under {}, so this fixture cannot \
5230 show the poll anything",
5231 path.display(),
5232 gitdir.display()
5233 );
5234 }
5235
5236 fn gitdir_of(work_dir: &Path) -> PathBuf {
5239 let output = Command::new("git")
5240 .arg("-C")
5241 .arg(work_dir)
5242 .args(["rev-parse", "--absolute-git-dir"])
5243 .output()
5244 .expect("run git rev-parse");
5245 assert!(
5246 output.status.success(),
5247 "resolve the gitdir of {}",
5248 work_dir.display()
5249 );
5250 PathBuf::from(
5251 std::str::from_utf8(&output.stdout)
5252 .expect("a utf-8 gitdir path")
5253 .trim(),
5254 )
5255 }
5256
5257 fn commit(path: &Path, message: &str, args: &[&str]) {
5259 let status = Command::new("git")
5260 .arg("-C")
5261 .arg(path)
5262 .args(["-c", "user.email=test@example.com", "-c", "user.name=Test"])
5263 .arg("commit")
5264 .args(args)
5265 .status()
5266 .unwrap_or_else(|error| panic!("run git commit {message}: {error}"));
5267 assert!(status.success());
5268 }
5269
5270 fn fetch_spec_for_test() -> FetchSpec {
5274 FetchSpec {
5275 enabled: false,
5276 interval: Duration::from_secs(3600),
5277 concurrency: 4,
5278 }
5279 }
5280
5281 fn auto_update_spec_for_test() -> AutoUpdateSpec {
5286 AutoUpdateSpec { enabled: false }
5287 }
5288
5289 fn spec(roots: Vec<PathBuf>) -> CoreSpec {
5290 CoreSpec {
5291 set: SetSpec {
5292 name: "test".to_string(),
5293 roots,
5294 include: Vec::new(),
5295 exclude: Vec::new(),
5296 },
5297 overrides: Vec::new(),
5298 poll_interval: Duration::from_secs(3600),
5299 status_stale_after: Duration::from_secs(3600),
5300 generation_deadline: Duration::from_secs(3600),
5301 show_submodules: false,
5302 fetch: fetch_spec_for_test(),
5303 auto_update: auto_update_spec_for_test(),
5304 }
5305 }
5306
5307 #[test]
5318 fn core_spec_carries_no_scoping_field_scope_is_never_a_dial() {
5319 let CoreSpec {
5320 set: _,
5321 overrides: _,
5322 poll_interval: _,
5323 status_stale_after: _,
5324 generation_deadline: _,
5325 show_submodules: _,
5326 fetch: _,
5327 auto_update: _,
5328 } = spec(Vec::new());
5329 }
5330
5331 fn root_of(dir: &tempfile::TempDir) -> PathBuf {
5332 dir.path().canonicalize().expect("canonicalize temp dir")
5333 }
5334
5335 fn settle_launch(core: &Core) -> Snapshot {
5344 let launched = core.settle();
5345 assert_eq!(
5346 core.settle_gate_count_for_test(),
5347 0,
5348 "launch's own Generation never settled, so nothing after this is starting from \
5349 the point it claims to"
5350 );
5351 launched
5352 }
5353
5354 fn started_and_settled(spec: CoreSpec) -> (Core, Snapshot) {
5357 let core = Core::start_discovered(spec);
5358 let launched = settle_launch(&core);
5359 (core, launched)
5360 }
5361
5362 fn backdate_polled_entries(work_dir: &Path) {
5369 let gitdir = gitdir_of(work_dir);
5370
5371 let past = std::time::SystemTime::now() - Duration::from_secs(10);
5372 let mut touched = 0;
5373 for name in poll::POLLED_GITDIR_ENTRIES {
5374 let path = gitdir.join(name);
5375 if path.exists() {
5376 set_mtime_to(&path, past);
5377 touched += 1;
5378 }
5379 }
5380 assert!(
5381 touched > 0,
5382 "backdated nothing under {}; the gitdir holds none of the polled entries and the \
5383 baseline this sets up would not be older than what follows",
5384 gitdir.display()
5385 );
5386 }
5387
5388 fn set_mtime_to(path: &Path, at: std::time::SystemTime) {
5390 use std::os::unix::ffi::OsStrExt;
5391
5392 let secs = at
5393 .duration_since(std::time::SystemTime::UNIX_EPOCH)
5394 .expect("a time after the epoch")
5395 .as_secs() as libc::time_t;
5396 let times = [
5397 libc::timespec {
5398 tv_sec: secs,
5399 tv_nsec: 0,
5400 },
5401 libc::timespec {
5402 tv_sec: secs,
5403 tv_nsec: 0,
5404 },
5405 ];
5406 let c_path =
5407 std::ffi::CString::new(path.as_os_str().as_bytes()).expect("a path with no NUL");
5408 let rc = unsafe { libc::utimensat(libc::AT_FDCWD, c_path.as_ptr(), times.as_ptr(), 0) };
5409 assert_eq!(
5410 rc,
5411 0,
5412 "set mtime on {}: {}",
5413 path.display(),
5414 std::io::Error::last_os_error()
5415 );
5416 }
5417
5418 fn step(argv: &[&str]) -> Step {
5419 Step {
5420 argv: argv.iter().map(|s| s.to_string()).collect(),
5421 shell: false,
5422 interactive: false,
5423 env: Vec::new(),
5424 }
5425 }
5426
5427 fn shell_step(command: &str) -> Step {
5429 Step {
5430 argv: vec![command.to_string()],
5431 shell: true,
5432 interactive: false,
5433 env: Vec::new(),
5434 }
5435 }
5436
5437 fn interactive_shell_step(command: &str) -> Step {
5440 Step {
5441 argv: vec![command.to_string()],
5442 shell: true,
5443 interactive: true,
5444 env: Vec::new(),
5445 }
5446 }
5447
5448 fn receipt_labelled(core: &Core, key: &EntityKey, label: &str) -> Option<ActionReceipt> {
5452 core.snapshot()
5453 .entities
5454 .iter()
5455 .find(|entity| entity.key == *key)
5456 .and_then(|entity| entity.last_action.clone())
5457 .filter(|receipt| &*receipt.label == label)
5458 }
5459
5460 fn action(label: &str, steps: Vec<Step>) -> ActionSpec {
5461 ActionSpec {
5462 label: Arc::from(label),
5463 name: Some(Arc::from(label)),
5464 steps,
5465 concurrency: 4,
5466 when: None,
5467 }
5468 }
5469
5470 fn action_with_when(label: &str, steps: Vec<Step>, when: &str) -> ActionSpec {
5473 ActionSpec {
5474 when: Some(Filter::parse(when)),
5475 ..action(label, steps)
5476 }
5477 }
5478
5479 #[test]
5484 fn refresh_and_settle_populate_real_cells_without_the_caller_spawning_a_thread() {
5485 let dir = tempfile::tempdir().expect("temp dir");
5486 let root = root_of(&dir);
5487 let repo = root.join("repo");
5488 init_repo_with_a_commit(&repo);
5489
5490 let core = Core::start_discovered(spec(vec![root]));
5491 let keys: Vec<EntityKey> = core
5492 .snapshot()
5493 .entities
5494 .iter()
5495 .map(|entity| entity.key.clone())
5496 .collect();
5497 assert_eq!(keys.len(), 1);
5498
5499 core.refresh(&keys);
5500 let settled = core.settle();
5501
5502 let entity = &settled.entities[0];
5503 match entity.branch.settled() {
5504 Some(Settled::Known {
5505 value: Head::Branch { .. },
5506 at: _,
5507 stale: _,
5508 }) => {}
5509 other => panic!("expected an attached branch, got {other:?}"),
5510 }
5511 }
5512
5513 fn spec_refresh_md() -> String {
5518 let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
5519 std::fs::read_to_string(manifest_dir.join("../../docs/spec/refresh.md"))
5520 .expect("read docs/spec/refresh.md")
5521 }
5522
5523 fn spec_first_frame_budgets_ms(spec: &str) -> (u64, u64) {
5524 let anchor = "rows with names on screen within ";
5525 let after = spec
5526 .split(anchor)
5527 .nth(1)
5528 .expect("the first-frame budget sentence is present");
5529 let mut parts = after.splitn(2, "ms, every cheap column filled within ");
5530 let names: u64 = parts
5531 .next()
5532 .expect("a names-on-screen budget")
5533 .parse()
5534 .expect("the names-on-screen budget is an integer");
5535 let after_cheap = parts.next().expect("a cheap-column budget and beyond");
5536 let cheap_columns: u64 = after_cheap
5537 .split("ms,")
5538 .next()
5539 .expect("a cheap-column budget")
5540 .parse()
5541 .expect("the cheap-column budget is an integer");
5542 (names, cheap_columns)
5543 }
5544
5545 #[test]
5549 fn first_frame_budget_constants_match_the_spec_of_record() {
5550 let spec = spec_refresh_md();
5551 let (names_ms, cheap_columns_ms) = spec_first_frame_budgets_ms(&spec);
5552 assert_eq!(names_ms, FIRST_FRAME_NAMES_BUDGET_MS);
5553 assert_eq!(cheap_columns_ms, FIRST_FRAME_CHEAP_COLUMNS_BUDGET_MS);
5554 }
5555
5556 #[test]
5564 fn every_dispatched_entity_gets_its_dirty_cell_settled_not_a_subset() {
5565 let dir = tempfile::tempdir().expect("temp dir");
5566 let root = root_of(&dir);
5567 const ENTITY_COUNT: usize = 16;
5568 for index in 0..ENTITY_COUNT {
5569 init_repo_with_a_commit(&root.join(format!("repo-{index}")));
5570 }
5571
5572 let core = Core::start_discovered(spec(vec![root]));
5573 let keys: Vec<EntityKey> = core
5574 .snapshot()
5575 .entities
5576 .iter()
5577 .map(|entity| entity.key.clone())
5578 .collect();
5579 assert_eq!(keys.len(), ENTITY_COUNT, "expected every repo discovered");
5580
5581 core.refresh(&keys);
5582 let settled = core.settle();
5583
5584 for entity in &settled.entities {
5585 assert!(
5586 matches!(
5587 entity.dirty.settled(),
5588 Some(Settled::Known {
5589 value: _,
5590 at: _,
5591 stale: _
5592 })
5593 ),
5594 "entity {:?} was left without a settled dirty cell, which is exactly what a \
5595 visibility-scoped dispatch would leave behind on the entities it skipped: \
5596 got {:?}",
5597 entity.name,
5598 entity.dirty.settled()
5599 );
5600 }
5601 }
5602
5603 #[test]
5618 fn cheap_outcomes_land_before_a_held_phase_c_settles() {
5619 let dir = tempfile::tempdir().expect("temp dir");
5620 let root = root_of(&dir);
5621 let repo = root.join("repo");
5622 init_repo_with_a_commit(&repo);
5623
5624 let (core, launched) = started_and_settled(spec(vec![root]));
5625 let key = launched.entities[0].key.clone();
5626 assert_eq!(
5627 dirty_total(&launched.entities[0]),
5628 0,
5629 "the fixture starts clean, which is the value the held phase C must still be \
5630 reading once the working tree below has moved"
5631 );
5632
5633 git(&repo, &["checkout", "-b", "held"]);
5637 fs::write(repo.join("untracked.txt"), b"uncommitted")
5638 .expect("write an untracked file into the fixture");
5639
5640 core.hold_phase_c_for_test(&key);
5641 core.refresh(std::slice::from_ref(&key));
5642 core.wait_phase_c_landed_for_test(&key);
5643
5644 let mid_flight = core.snapshot();
5645 let entity = mid_flight
5646 .entities
5647 .iter()
5648 .find(|entity| entity.key == key)
5649 .expect("entity present");
5650 assert!(
5651 matches!(
5652 entity.branch.settled(),
5653 Some(Settled::Known {
5654 value: Head::Branch { name, .. },
5655 at: _,
5656 stale: _
5657 }) if &**name == "held"
5658 ),
5659 "the cheap branch cell must carry this Generation's own answer while phase C is \
5660 still held open, got {:?}",
5661 entity.branch.settled()
5662 );
5663 assert!(
5664 entity.dirty.is_in_flight() && dirty_total(entity) == 0,
5665 "phase C is deliberately held open here; a bundled apply would already have \
5666 written this cell's new count alongside branch, got {:?}",
5667 entity.dirty.settled()
5668 );
5669
5670 core.release_phase_c_for_test(&key);
5671 core.wait_phase_c_finished_for_test(&key);
5672
5673 let settled = core.snapshot();
5674 let entity = settled
5675 .entities
5676 .iter()
5677 .find(|entity| entity.key == key)
5678 .expect("entity present");
5679 assert_eq!(
5680 dirty_total(entity),
5681 1,
5682 "phase C must settle its own count once released, got {:?}",
5683 entity.dirty.settled()
5684 );
5685 }
5686
5687 fn dirty_total(entity: &EntityState) -> u32 {
5691 match entity.dirty.settled() {
5692 Some(Settled::Known {
5693 value,
5694 at: _,
5695 stale: _,
5696 }) => value.total(),
5697 other => panic!("expected a settled dirty count, got {other:?}"),
5698 }
5699 }
5700
5701 #[test]
5709 fn splitting_the_probe_write_signals_settle_gate_exactly_once_per_entity() {
5710 let dir = tempfile::tempdir().expect("temp dir");
5711 let root = root_of(&dir);
5712 init_repo_with_a_commit(&root.join("a"));
5713 init_repo_with_a_commit(&root.join("b"));
5714
5715 let (core, snapshot) = started_and_settled(spec(vec![root]));
5716 let key_a = snapshot
5717 .entities
5718 .iter()
5719 .find(|entity| &*entity.name == "a")
5720 .expect("entity a present")
5721 .key
5722 .clone();
5723 let key_b = snapshot
5724 .entities
5725 .iter()
5726 .find(|entity| &*entity.name == "b")
5727 .expect("entity b present")
5728 .key
5729 .clone();
5730
5731 core.hold_phase_c_for_test(&key_a);
5732 core.hold_phase_c_for_test(&key_b);
5733 core.refresh(&[key_a.clone(), key_b.clone()]);
5734 core.wait_dispatched_for_test();
5738 assert_eq!(
5739 core.settle_gate_count_for_test(),
5740 2,
5741 "dispatching two entities must add exactly two to the settle gate"
5742 );
5743
5744 core.wait_phase_c_landed_for_test(&key_a);
5745 core.wait_phase_c_landed_for_test(&key_b);
5746 assert_eq!(
5747 core.settle_gate_count_for_test(),
5748 2,
5749 "the cheap apply must never touch the settle gate: both entities' cheap \
5750 outcomes have landed and neither has finished phase C yet"
5751 );
5752
5753 core.release_phase_c_for_test(&key_a);
5754 core.wait_phase_c_finished_for_test(&key_a);
5755 assert_eq!(
5756 core.settle_gate_count_for_test(),
5757 1,
5758 "exactly one entity finished, so the gate must fall by exactly one, not two \
5759 (double-counted) and not zero (left short)"
5760 );
5761
5762 core.release_phase_c_for_test(&key_b);
5763 core.wait_phase_c_finished_for_test(&key_b);
5764 assert_eq!(
5765 core.settle_gate_count_for_test(),
5766 0,
5767 "both entities finished, so the gate must be fully drained"
5768 );
5769 }
5770
5771 fn registered_gate(core: &Core, key: &EntityKey) -> PhaseCGateHandle {
5774 core.phase_c_gates
5775 .lock()
5776 .unwrap()
5777 .get(key)
5778 .cloned()
5779 .expect("hold_phase_c_for_test must be called before reading its gate")
5780 }
5781
5782 fn release_gate(gate: &PhaseCGateHandle) {
5785 let (lock, cvar) = &**gate;
5786 lock.lock().unwrap().may_proceed = true;
5787 cvar.notify_all();
5788 }
5789
5790 fn gate_is_finished(gate: &PhaseCGateHandle) -> bool {
5791 gate.0.lock().unwrap().finished
5792 }
5793
5794 #[test]
5806 fn a_probe_signals_the_phase_c_gate_its_own_generation_was_dispatched_against() {
5807 let dir = tempfile::tempdir().expect("temp dir");
5808 let root = root_of(&dir);
5809 init_repo_with_a_commit(&root.join("repo"));
5810
5811 let (core, launched) = started_and_settled(spec(vec![root]));
5812 let key = launched.entities[0].key.clone();
5813
5814 core.hold_phase_c_for_test(&key);
5815 let dispatched_against = registered_gate(&core, &key);
5816 core.refresh(std::slice::from_ref(&key));
5817 core.wait_phase_c_landed_for_test(&key);
5818
5819 core.hold_phase_c_for_test(&key);
5820 let registered_later = registered_gate(&core, &key);
5821 release_gate(&dispatched_against);
5822
5823 wait_for(
5824 "the held probe to signal the gate its own Generation was dispatched against",
5825 || gate_is_finished(&dispatched_against),
5826 );
5827 assert!(
5828 !gate_is_finished(®istered_later),
5829 "a gate registered after this Generation dispatched must never be marked \
5830 finished by it: a test waiting on that gate would return before this \
5831 Generation had applied its outcome or decremented the settle gate"
5832 );
5833 }
5834
5835 #[test]
5847 fn a_probe_finishing_clears_only_its_own_generations_in_flight_entry() {
5848 let dir = tempfile::tempdir().expect("temp dir");
5849 let root = root_of(&dir);
5850 init_repo_with_a_commit(&root.join("repo"));
5851
5852 let (core, launched) = started_and_settled(spec(vec![root]));
5853 let key = launched.entities[0].key.clone();
5854
5855 core.hold_phase_c_for_test(&key);
5856 core.refresh(std::slice::from_ref(&key));
5857 core.wait_phase_c_landed_for_test(&key);
5858
5859 let superseding = core.begin_shared_generation_for_test(std::slice::from_ref(&key));
5862
5863 core.release_phase_c_for_test(&key);
5864 core.wait_phase_c_finished_for_test(&key);
5865
5866 core.refresh(std::slice::from_ref(&key));
5867 core.wait_dispatched_for_test();
5868
5869 assert!(
5870 superseding.cancels[&key].load(Ordering::Acquire),
5871 "a probe from a Generation that has already been superseded must leave the \
5872 live Generation's in-flight entry alone, or the Generation after it has \
5873 nothing to interrupt"
5874 );
5875 }
5876
5877 #[test]
5892 fn refresh_dispatches_phase_c_in_exactly_the_order_it_is_given() {
5893 let dir = tempfile::tempdir().expect("temp dir");
5894 let root = root_of(&dir);
5895 const ENTITY_COUNT: usize = 6;
5896 for index in 0..ENTITY_COUNT {
5897 init_repo_with_a_commit(&root.join(format!("repo-{index}")));
5898 }
5899
5900 let (core, launched) = started_and_settled(spec(vec![root]));
5901 let discovery_order: Vec<EntityKey> = launched
5902 .entities
5903 .iter()
5904 .map(|entity| entity.key.clone())
5905 .collect();
5906 assert_eq!(
5907 discovery_order.len(),
5908 ENTITY_COUNT,
5909 "expected every repo discovered"
5910 );
5911
5912 let cursor = discovery_order[3].clone();
5916 let visible = [discovery_order[1].clone(), discovery_order[4].clone()];
5917 let mut three_tier_order = vec![cursor.clone()];
5918 three_tier_order.extend(visible.iter().cloned());
5919 for key in &discovery_order {
5920 if *key != cursor && !visible.contains(key) {
5921 three_tier_order.push(key.clone());
5922 }
5923 }
5924 assert_eq!(
5925 three_tier_order.len(),
5926 ENTITY_COUNT,
5927 "sanity check: the hand-built order must cover every discovered entity exactly \
5928 once"
5929 );
5930
5931 core.refresh(&three_tier_order);
5932 core.settle();
5933
5934 assert_eq!(
5935 core.dispatch_log_for_test(),
5936 three_tier_order,
5937 "refresh must dispatch phase C in exactly the order it was given: the cursor \
5938 row, then the visible rows, then the rest in discovery order"
5939 );
5940 }
5941
5942 #[test]
5947 fn refresh_reuses_the_cached_repository_handle_rather_than_reopening_it() {
5948 let dir = tempfile::tempdir().expect("temp dir");
5949 let root = root_of(&dir);
5950 let repo = root.join("repo");
5951 init_repo_with_a_commit(&repo);
5952
5953 let core = Core::start_discovered(spec(vec![root]));
5954 let key = core.snapshot().entities[0].key.clone();
5955 let before = core
5956 .cached_repo_handle_for_test(&key)
5957 .expect("discovery should have cached a handle");
5958
5959 core.refresh(std::slice::from_ref(&key));
5960 core.settle();
5961
5962 let after = core
5963 .cached_repo_handle_for_test(&key)
5964 .expect("the cached handle should still be there after a refresh");
5965 assert!(
5966 Arc::ptr_eq(&before, &after),
5967 "a refresh must reuse the cached handle, not replace it with a new one"
5968 );
5969 }
5970
5971 #[test]
5977 fn refresh_running_reads_true_the_instant_refresh_returns_and_false_once_it_settles() {
5978 let dir = tempfile::tempdir().expect("temp dir");
5979 let root = root_of(&dir);
5980 init_repo_with_a_commit(&root.join("repo"));
5981
5982 let core = Core::start_discovered(spec(vec![root]));
5983 core.settle();
5984 assert!(
5985 !core.refresh_running(),
5986 "sanity: nothing outstanding once startup has settled"
5987 );
5988
5989 let keys: Vec<EntityKey> = core
5990 .snapshot()
5991 .entities
5992 .iter()
5993 .map(|entity| entity.key.clone())
5994 .collect();
5995 core.refresh(&keys);
5996 assert!(
5997 core.refresh_running(),
5998 "refresh reserves its Generation and records the dispatch debt before it \
5999 returns, so this must already read true"
6000 );
6001
6002 core.settle();
6003 assert!(
6004 !core.refresh_running(),
6005 "settle blocks until nothing is outstanding, so this must read false once it \
6006 returns"
6007 );
6008 }
6009
6010 #[test]
6014 fn probing_a_key_with_no_cached_handle_still_opens_the_repository_itself() {
6015 let dir = tempfile::tempdir().expect("temp dir");
6016 let root = root_of(&dir);
6017 let repo = root.join("repo");
6018 init_repo_with_a_commit(&repo);
6019
6020 let empty_root = root_of(&tempfile::tempdir().expect("temp dir"));
6022 let core = Core::start_discovered(spec(vec![empty_root]));
6023 let key = EntityKey::new(Arc::from(repo.as_path()));
6024 assert!(core.cached_repo_handle_for_test(&key).is_none());
6025
6026 let entity = core.probe_now(&key);
6027
6028 assert!(matches!(
6029 entity.branch.settled(),
6030 Some(Settled::Known {
6031 value: Head::Branch { .. },
6032 at: _,
6033 stale: _
6034 })
6035 ));
6036 }
6037
6038 #[test]
6042 fn an_empty_order_dispatches_nothing_and_settle_returns_immediately() {
6043 let dir = tempfile::tempdir().expect("temp dir");
6044 let root = root_of(&dir);
6045 let repo = root.join("repo");
6046 init_repo_with_a_commit(&repo);
6047
6048 let (core, _launched) = started_and_settled(spec(vec![root]));
6049 assert!(
6050 !core.dispatch_log_for_test().is_empty(),
6051 "launch dispatched nothing, so an empty log below would say nothing about the \
6052 empty order"
6053 );
6054
6055 core.refresh(&[]);
6056 core.wait_dispatched_for_test();
6057
6058 assert_eq!(
6059 core.dispatch_log_for_test(),
6060 Vec::new(),
6061 "an empty order must dispatch no probe"
6062 );
6063 let settled = core
6067 .try_settle(Duration::from_millis(50))
6068 .expect("an empty order raises no probe, so the settle gate is already at zero");
6069 assert!(!settled.entities[0].branch.is_in_flight());
6070 }
6071
6072 fn one_probe_owed_that_never_lands(
6080 dir: &tempfile::TempDir,
6081 ) -> (Core, crossbeam_channel::Sender<Instant>) {
6082 let root = root_of(dir);
6083 init_repo_with_a_commit(&root.join("repo"));
6084 let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
6085 let core = Core::start_for_test(spec(vec![root]), Duration::from_secs(3600), tick_rx)
6086 .discovered()
6087 .core;
6088 let key = settle_launch(&core).entities[0].key.clone();
6089 core.begin_untracked_probe_for_test(&key);
6090 (core, tick_tx)
6091 }
6092
6093 #[test]
6101 #[should_panic(expected = "waiting for everything this Core has in flight to land")]
6102 fn a_settle_that_expires_reports_at_the_wait_rather_than_returning_the_table() {
6103 let dir = tempfile::tempdir().expect("temp dir");
6104 let (core, _tick_tx) = one_probe_owed_that_never_lands(&dir);
6105
6106 core.settle_within(Duration::from_millis(20));
6107 }
6108
6109 #[test]
6113 fn try_settle_hands_an_expiry_back_as_an_error_carrying_the_table_it_gave_up_on() {
6114 let dir = tempfile::tempdir().expect("temp dir");
6115 let (core, _tick_tx) = one_probe_owed_that_never_lands(&dir);
6116
6117 let unsettled = core
6118 .try_settle(Duration::from_millis(20))
6119 .expect_err("a probe nothing will ever complete cannot settle");
6120
6121 assert!(
6122 unsettled.entities[0].branch.is_in_flight(),
6123 "the Err arm must still carry the table as it stood, so a caller that degrades \
6124 deliberately has something to degrade with"
6125 );
6126 }
6127
6128 #[test]
6131 fn try_settle_hands_a_generation_that_really_landed_back_as_ok() {
6132 let dir = tempfile::tempdir().expect("temp dir");
6133 let root = root_of(&dir);
6134 init_repo_with_a_commit(&root.join("repo"));
6135
6136 let (core, launched) = started_and_settled(spec(vec![root]));
6137 let key = launched.entities[0].key.clone();
6138 core.refresh(std::slice::from_ref(&key));
6139
6140 let settled = core
6141 .try_settle(BACKSTOP)
6142 .expect("a dispatched Generation must land inside the backstop");
6143
6144 assert!(!settled.entities[0].branch.is_in_flight());
6145 }
6146
6147 #[test]
6151 fn probe_now_settles_the_sync_cell_as_well_as_the_branch_it_depends_on() {
6152 let dir = tempfile::tempdir().expect("temp dir");
6153 let root = root_of(&dir);
6154 let repo = root.join("repo");
6155 init_repo_with_a_commit(&repo);
6156
6157 let core = Core::start_discovered(spec(vec![root]));
6158 let key = core.snapshot().entities[0].key.clone();
6159
6160 let entity = core.probe_now(&key);
6161
6162 assert!(
6163 matches!(
6164 entity.sync.settled(),
6165 Some(Settled::Known {
6166 value: SyncState::NoRemote,
6167 at: _,
6168 stale: _
6169 })
6170 ),
6171 "expected probe_now to settle sync, got {:?}",
6172 entity.sync.settled()
6173 );
6174 }
6175
6176 #[test]
6179 fn probe_now_settles_the_base_cell_as_well_as_the_branch_it_depends_on() {
6180 let dir = tempfile::tempdir().expect("temp dir");
6181 let root = root_of(&dir);
6182 let repo = root.join("repo");
6183 init_repo_with_a_commit(&repo);
6184
6185 let core = Core::start_discovered(spec(vec![root]));
6186 let key = core.snapshot().entities[0].key.clone();
6187
6188 let entity = core.probe_now(&key);
6189
6190 assert!(
6191 matches!(entity.base.settled(), Some(Settled::NotApplicable)),
6192 "expected probe_now to settle base Not applicable for a Repo with no remote, \
6193 got {:?}",
6194 entity.base.settled()
6195 );
6196 }
6197
6198 #[test]
6202 fn refresh_settles_a_real_base_count_against_the_resolved_default_branch() {
6203 let dir = tempfile::tempdir().expect("temp dir");
6204 let root = root_of(&dir);
6205 let repo = root.join("repo");
6206 init_repo_with_a_commit(&repo);
6207 git(
6208 &repo,
6209 &[
6210 "remote",
6211 "add",
6212 "origin",
6213 "https://example.invalid/repo.git",
6214 ],
6215 );
6216 let root_sha = head_sha(&repo);
6217 git(&repo, &["commit", "--allow-empty", "-m", "second"]);
6223 let tip_sha = head_sha(&repo);
6224 git(&repo, &["reset", "--hard", &root_sha]);
6225 git(&repo, &["update-ref", "refs/remotes/origin/main", &tip_sha]);
6226
6227 let core = Core::start_discovered(spec(vec![root]));
6228 let key = core.snapshot().entities[0].key.clone();
6229
6230 core.refresh(std::slice::from_ref(&key));
6231 let settled = core.settle();
6232
6233 assert!(
6234 matches!(
6235 settled.entities[0].base.settled(),
6236 Some(Settled::Known {
6237 value: 1,
6238 at: _,
6239 stale: _
6240 })
6241 ),
6242 "expected a real refresh to settle base's live count against the resolved \
6243 default branch, got {:?}",
6244 settled.entities[0].base.settled()
6245 );
6246 }
6247
6248 #[test]
6254 fn probe_now_settles_the_dirty_cell_with_the_counts_it_probed() {
6255 let dir = tempfile::tempdir().expect("temp dir");
6256 let root = root_of(&dir);
6257 let repo = root.join("repo");
6258 init_repo_with_a_commit(&repo);
6259 fs::write(repo.join("untracked.txt"), "x").expect("write untracked file");
6260
6261 let core = Core::start_discovered(spec(vec![root]));
6262 let key = core.snapshot().entities[0].key.clone();
6263
6264 let entity = core.probe_now(&key);
6265
6266 assert!(
6267 matches!(
6268 entity.dirty.settled(),
6269 Some(Settled::Known {
6270 value: DirtyCounts {
6271 modified: 0,
6272 untracked: 1,
6273 deleted: 0,
6274 },
6275 at: _,
6276 stale: _
6277 })
6278 ),
6279 "expected probe_now to settle dirty with the one untracked path, got {:?}",
6280 entity.dirty.settled()
6281 );
6282 }
6283
6284 #[test]
6285 fn probe_now_updates_the_entity_synchronously_with_no_refresh_call() {
6286 let dir = tempfile::tempdir().expect("temp dir");
6287 let root = root_of(&dir);
6288 let repo = root.join("repo");
6289 init_repo_with_a_commit(&repo);
6290
6291 let core = Core::start_discovered(spec(vec![root]));
6292 let key = core.snapshot().entities[0].key.clone();
6293
6294 let entity = core.probe_now(&key);
6295
6296 assert!(matches!(
6297 entity.branch.settled(),
6298 Some(Settled::Known {
6299 value: Head::Branch { .. },
6300 at: _,
6301 stale: _
6302 })
6303 ));
6304 }
6305
6306 #[test]
6312 fn the_display_name_agrees_between_discovery_and_probe_nows_fallback_insert() {
6313 let dir = tempfile::tempdir().expect("temp dir");
6314 let root = root_of(&dir);
6315 let repo = root.join("named-repo");
6316 init_repo_with_a_commit(&repo);
6317
6318 let core = Core::start_discovered(spec(vec![root]));
6319 let discovered = core.snapshot().entities[0].clone();
6320 assert_eq!(&*discovered.name, "named-repo");
6321
6322 core.dismiss(&discovered.key);
6323 assert!(core.snapshot().entities.is_empty());
6324
6325 let reinserted = core.probe_now(&discovered.key);
6326
6327 assert_eq!(
6328 reinserted.name, discovered.name,
6329 "the name discovery assigned and the name probe_now's fallback insert \
6330 assigns for the same path must be byte-identical"
6331 );
6332 }
6333
6334 #[test]
6335 fn dismiss_removes_the_entity_from_the_snapshot() {
6336 let dir = tempfile::tempdir().expect("temp dir");
6337 let root = root_of(&dir);
6338 let repo = root.join("repo");
6339 init_repo_with_a_commit(&repo);
6340
6341 let core = Core::start_discovered(spec(vec![root]));
6342 let key = core.snapshot().entities[0].key.clone();
6343
6344 core.dismiss(&key);
6345
6346 assert!(core.snapshot().entities.is_empty());
6347 }
6348
6349 #[test]
6362 fn an_entitys_steps_run_in_order_and_a_failure_marks_every_later_step_not_run() {
6363 let dir = tempfile::tempdir().expect("temp dir");
6364 let root = root_of(&dir);
6365 let repo = root.join("repo");
6366 init_repo_with_a_commit(&repo);
6367 let marker = repo.join("step-three-ran");
6368
6369 let core = Core::start_discovered(spec(vec![root]));
6370 let key = core.snapshot().entities[0].key.clone();
6371 let steps = vec![
6372 step(&["true"]),
6373 step(&["sh", "-c", "exit 7"]),
6374 step(&["touch", "step-three-ran"]),
6375 ];
6376
6377 let started = core.run_action(action("reinstall", steps), std::slice::from_ref(&key));
6378
6379 assert!(started);
6380 wait_for("the fan-out to finish and write a receipt", || {
6381 !core.action_running()
6382 });
6383 let receipt = core.snapshot().entities[0]
6384 .last_action
6385 .clone()
6386 .expect("receipt written");
6387 assert_eq!(receipt.steps.len(), 3);
6388 assert_eq!(receipt.steps[0].outcome, StepOutcome::Ok);
6389 assert_eq!(receipt.steps[1].outcome, StepOutcome::Failed(7));
6390 assert_eq!(
6391 receipt.steps[2].outcome,
6392 StepOutcome::NotRun,
6393 "a step after a failure must be recorded NotRun, not silently dropped or run anyway"
6394 );
6395 assert!(
6396 !marker.exists(),
6397 "the third step's own `touch` must never have run: its marker file exists, so \
6398 the step ran despite being recorded NotRun"
6399 );
6400 }
6401
6402 #[test]
6407 fn steps_run_in_the_order_theyre_declared_not_some_other_order() {
6408 let dir = tempfile::tempdir().expect("temp dir");
6409 let root = root_of(&dir);
6410 let repo = root.join("repo");
6411 init_repo_with_a_commit(&repo);
6412 let order_log = repo.join("order.log");
6413
6414 let core = Core::start_discovered(spec(vec![root]));
6415 let key = core.snapshot().entities[0].key.clone();
6416 let steps = vec![
6417 step(&["sh", "-c", "printf 1 >> order.log"]),
6418 step(&["sh", "-c", "printf 2 >> order.log"]),
6419 step(&["sh", "-c", "printf 3 >> order.log"]),
6420 ];
6421
6422 let started = core.run_action(action("ordering", steps), std::slice::from_ref(&key));
6423
6424 assert!(started);
6425 wait_for("the fan-out to finish and write a receipt", || {
6426 !core.action_running()
6427 });
6428 let receipt = core.snapshot().entities[0]
6429 .last_action
6430 .clone()
6431 .expect("receipt written");
6432 assert_eq!(receipt.steps.len(), 3);
6433 assert!(
6434 receipt
6435 .steps
6436 .iter()
6437 .all(|result| result.outcome == StepOutcome::Ok),
6438 "every step here always exits zero; this test isolates ordering from gating"
6439 );
6440 let content = fs::read_to_string(&order_log).expect("order.log written by the steps");
6441 assert_eq!(
6442 content, "123",
6443 "the file's content pins actual execution order; running the steps out of \
6444 declaration order would produce a different digit sequence here even though \
6445 every step still succeeds"
6446 );
6447 }
6448
6449 #[test]
6457 fn a_still_running_actions_finished_step_and_its_currently_executing_one_are_both_visible_before_the_whole_run_ends()
6458 {
6459 let dir = tempfile::tempdir().expect("temp dir");
6460 let root = root_of(&dir);
6461 let repo = root.join("repo");
6462 init_repo_with_a_commit(&repo);
6463
6464 let core = Core::start_discovered(spec(vec![root]));
6465 let key = core.snapshot().entities[0].key.clone();
6466 let steps = vec![step(&["true"]), step(&["sh", "-c", "sleep 0.5"])];
6467
6468 let started = core.run_action(action("reinstall", steps), std::slice::from_ref(&key));
6469 assert!(started);
6470
6471 wait_for(
6476 "a receipt naming the second step running before the run finished",
6477 || {
6478 core.snapshot().entities[0]
6479 .last_action
6480 .as_ref()
6481 .and_then(|receipt| receipt.running.as_ref())
6482 .is_some_and(|running| running.label.contains("sleep"))
6483 },
6484 );
6485 let mid_run = core.snapshot().entities[0]
6486 .last_action
6487 .clone()
6488 .expect("receipt written");
6489 assert_eq!(
6490 mid_run.steps.len(),
6491 1,
6492 "the first, already-finished step must already be in `steps`"
6493 );
6494 assert_eq!(mid_run.steps[0].outcome, StepOutcome::Ok);
6495 let running = mid_run.running.expect("a step must be recorded running");
6496 assert!(
6497 running.label.contains("sleep"),
6498 "expected the running step's own label, got {:?}",
6499 running.label
6500 );
6501
6502 wait_for("the fan-out to finish", || !core.action_running());
6503 let finished = core.snapshot().entities[0]
6504 .last_action
6505 .clone()
6506 .expect("receipt written");
6507 assert!(
6508 finished.running.is_none(),
6509 "a finished receipt must carry no running step"
6510 );
6511 assert_eq!(finished.steps.len(), 2);
6512 }
6513
6514 #[test]
6523 fn a_shell_true_step_runs_through_shell_c_with_repon_as_its_own_dollar_zero() {
6524 let dir = tempfile::tempdir().expect("temp dir");
6525 let root = root_of(&dir);
6526 let repo = root.join("repo");
6527 init_repo_with_a_commit(&repo);
6528
6529 let core = Core::start_discovered(spec(vec![root]));
6530 let key = core.snapshot().entities[0].key.clone();
6531 let steps = vec![shell_step("echo \"[$0]\"")];
6532
6533 let started = core.run_action(action("shell-step", steps), std::slice::from_ref(&key));
6534
6535 assert!(started);
6536 wait_for("the fan-out to finish and write a receipt", || {
6537 !core.action_running()
6538 });
6539 let receipt = core.snapshot().entities[0]
6540 .last_action
6541 .clone()
6542 .expect("receipt written");
6543 assert_eq!(receipt.steps.len(), 1);
6544 assert_eq!(receipt.steps[0].outcome, StepOutcome::Ok);
6545 assert_eq!(&*receipt.steps[0].output, b"[repon]\n");
6546 assert!(
6547 receipt.steps[0].shell,
6548 "the receipt's own StepResult::shell must carry the mode the step ran under"
6549 );
6550 }
6551
6552 #[test]
6559 fn an_interactive_shell_true_step_runs_through_run_action_with_interactive_on_its_receipt() {
6560 let dir = tempfile::tempdir().expect("temp dir");
6561 let root = root_of(&dir);
6562 let repo = root.join("repo");
6563 init_repo_with_a_commit(&repo);
6564
6565 let core = Core::start_discovered(spec(vec![root]));
6566 let key = core.snapshot().entities[0].key.clone();
6567 let steps = vec![interactive_shell_step("true")];
6568
6569 let started = core.run_action(
6570 action("interactive-step", steps),
6571 std::slice::from_ref(&key),
6572 );
6573
6574 assert!(started);
6575 wait_for("the fan-out to finish and write a receipt", || {
6576 !core.action_running()
6577 });
6578 let receipt = core.snapshot().entities[0]
6579 .last_action
6580 .clone()
6581 .expect("receipt written");
6582 assert_eq!(receipt.steps.len(), 1);
6583 assert_eq!(receipt.steps[0].outcome, StepOutcome::Ok);
6584 assert!(
6585 receipt.steps[0].shell,
6586 "an interactive step is still a shell step"
6587 );
6588 assert!(
6589 receipt.steps[0].interactive,
6590 "the receipt's own StepResult::interactive must carry the mode the step ran under"
6591 );
6592 }
6593
6594 #[test]
6598 fn an_argv_step_runs_through_run_action_with_shell_false_on_its_receipt() {
6599 let dir = tempfile::tempdir().expect("temp dir");
6600 let root = root_of(&dir);
6601 let repo = root.join("repo");
6602 init_repo_with_a_commit(&repo);
6603
6604 let core = Core::start_discovered(spec(vec![root]));
6605 let key = core.snapshot().entities[0].key.clone();
6606 let steps = vec![Step {
6607 argv: vec!["true".to_string()],
6608 shell: false,
6609 interactive: false,
6610 env: Vec::new(),
6611 }];
6612
6613 let started = core.run_action(action("argv-step", steps), std::slice::from_ref(&key));
6614
6615 assert!(started);
6616 wait_for("the fan-out to finish and write a receipt", || {
6617 !core.action_running()
6618 });
6619 let receipt = core.snapshot().entities[0]
6620 .last_action
6621 .clone()
6622 .expect("receipt written");
6623 assert!(!receipt.steps[0].shell);
6624 }
6625
6626 #[test]
6632 fn starting_an_action_cancels_any_generation_already_in_flight() {
6633 let dir = tempfile::tempdir().expect("temp dir");
6634 let root = root_of(&dir);
6635 let repo = root.join("repo");
6636 init_repo_with_a_commit(&repo);
6637
6638 let core = Core::start_discovered(spec(vec![root]));
6639 let key = core.snapshot().entities[0].key.clone();
6640 let in_flight = core.begin_shared_generation_for_test(std::slice::from_ref(&key));
6641 let cancel = in_flight
6642 .cancels
6643 .get(&key)
6644 .expect("the in-flight entity has a cancel flag")
6645 .clone();
6646 assert!(!cancel.load(Ordering::Acquire));
6647
6648 let started = core.run_action(
6649 action("reinstall", vec![step(&["true"])]),
6650 std::slice::from_ref(&key),
6651 );
6652
6653 assert!(started);
6654 assert!(
6655 cancel.load(Ordering::Acquire),
6656 "starting an Action must cancel a Generation already in flight, not share \
6657 execution with it"
6658 );
6659 wait_for("the fan-out and its completion refresh to drain", || {
6662 !core.action_running()
6663 });
6664 }
6665
6666 #[test]
6676 fn a_finished_action_starts_exactly_one_generation_over_every_known_entity() {
6677 let dir = tempfile::tempdir().expect("temp dir");
6678 let root = root_of(&dir);
6679 let acted_on = root.join("acted-on");
6680 let untouched = root.join("untouched");
6681 init_repo_with_a_commit(&acted_on);
6682 init_repo_with_a_commit(&untouched);
6683
6684 let (core, before) = started_and_settled(spec(vec![root]));
6685 let acted_key = before
6686 .entities
6687 .iter()
6688 .find(|entity| entity.key.path() == acted_on)
6689 .expect("the acted-on entity is discovered")
6690 .key
6691 .clone();
6692
6693 let started = core.run_action(
6694 action("reinstall", vec![step(&["true"])]),
6695 std::slice::from_ref(&acted_key),
6696 );
6697
6698 assert!(started);
6699 wait_for(
6700 "the completion Generation to probe every known entity, including the one the \
6701 Action never touched",
6702 || {
6703 let snapshot = core.snapshot();
6704 snapshot.generation != before.generation
6705 && snapshot.entities.iter().all(|entity| {
6706 matches!(
6707 entity.branch.settled(),
6708 Some(Settled::Known {
6709 value: _,
6710 at: _,
6711 stale: _
6712 })
6713 )
6714 })
6715 },
6716 );
6717 assert_eq!(
6718 core.settle().generation,
6719 before.generation.successor(),
6720 "completion must start exactly one Generation: not zero (no refresh at all) and \
6721 not two (a double refresh)"
6722 );
6723 }
6724
6725 #[test]
6735 fn a_completion_dispatches_its_generation_before_releasing_its_run() {
6736 let dir = tempfile::tempdir().expect("temp dir");
6737 let root = root_of(&dir);
6738 let repo = root.join("repo");
6739 init_repo_with_a_commit(&repo);
6740
6741 let (core, before) = started_and_settled(spec(vec![root]));
6742 let key = before.entities[0].key.clone();
6743 let armed = core.action_completion_boundary().arm();
6744
6745 assert!(core.run_action(
6746 action("finishing", vec![step(&["true"])]),
6747 std::slice::from_ref(&key)
6748 ));
6749 armed.wait_until_reached();
6750
6751 assert_eq!(
6752 core.snapshot().generation,
6753 before.generation.successor(),
6754 "the completion Generation must be dispatched before the run releases its \
6755 admission"
6756 );
6757 assert!(
6758 !core.run_action(
6759 action("racing", vec![step(&["true"])]),
6760 std::slice::from_ref(&key)
6761 ),
6762 "a submission before that release must be refused, so what a run cancels on the \
6763 way in is never a Generation the run it replaced has yet to dispatch"
6764 );
6765
6766 drop(armed);
6767 wait_for("the finished run to release its admission", || {
6768 !core.action_running()
6769 });
6770 }
6771
6772 #[test]
6777 fn an_excluded_row_swept_into_an_action_gets_a_not_applicable_receipt_and_no_other_path_does() {
6778 let dir = tempfile::tempdir().expect("temp dir");
6779 let root = root_of(&dir);
6780 let excluded_repo = root.join("excluded");
6781 let normal_repo = root.join("normal");
6782 init_repo_with_a_commit(&excluded_repo);
6783 init_repo_with_a_commit(&normal_repo);
6784
6785 let core = Core::start_discovered(spec_with_overrides(
6786 vec![root],
6787 vec![RepoOverride {
6788 path: excluded_repo.clone(),
6789 default_branch: None,
6790 excluded: true,
6791 }],
6792 ));
6793 let snapshot = core.snapshot();
6794 let find = |path: &Path| {
6795 snapshot
6796 .entities
6797 .iter()
6798 .find(|entity| entity.key.path() == path)
6799 .unwrap_or_else(|| panic!("entity at {path:?} present"))
6800 .key
6801 .clone()
6802 };
6803 let excluded_key = find(&excluded_repo);
6804 let normal_key = find(&normal_repo);
6805 assert!(
6806 snapshot
6807 .entities
6808 .iter()
6809 .find(|entity| entity.key == excluded_key)
6810 .unwrap()
6811 .excluded
6812 );
6813
6814 let started = core.run_action(
6815 action("reinstall", vec![step(&["sh", "-c", "exit 3"])]),
6816 &[excluded_key.clone(), normal_key.clone()],
6817 );
6818
6819 assert!(started);
6820 wait_for("the fan-out to finish", || !core.action_running());
6826
6827 let after = core.snapshot();
6828 let receipt_of = |key: &EntityKey| {
6829 after
6830 .entities
6831 .iter()
6832 .find(|entity| entity.key == *key)
6833 .unwrap()
6834 .last_action
6835 .clone()
6836 .unwrap()
6837 };
6838 let excluded_receipt = receipt_of(&excluded_key);
6839 assert!(excluded_receipt.not_applicable());
6840 assert!(excluded_receipt.steps.is_empty());
6841
6842 let normal_receipt = receipt_of(&normal_key);
6843 assert!(
6844 !normal_receipt.not_applicable(),
6845 "a row that actually ran a step, even a failing one, must never read as \
6846 not_applicable: an excluded row is the one legitimate producer of that outcome"
6847 );
6848 assert!(!normal_receipt.steps.is_empty());
6849 assert!(normal_receipt.failed());
6850 }
6851
6852 #[test]
6859 fn operable_count_matches_how_many_entities_run_action_actually_runs_a_step_against() {
6860 let dir = tempfile::tempdir().expect("temp dir");
6861 let root = root_of(&dir);
6862 let excluded_repo = root.join("excluded");
6863 let normal_repo = root.join("normal");
6864 init_repo_with_a_commit(&excluded_repo);
6865 init_repo_with_a_commit(&normal_repo);
6866
6867 let core = Core::start_discovered(spec_with_overrides(
6868 vec![root],
6869 vec![RepoOverride {
6870 path: excluded_repo.clone(),
6871 default_branch: None,
6872 excluded: true,
6873 }],
6874 ));
6875 let snapshot = core.snapshot();
6876 let find = |path: &Path| {
6877 snapshot
6878 .entities
6879 .iter()
6880 .find(|entity| entity.key.path() == path)
6881 .unwrap_or_else(|| panic!("entity at {path:?} present"))
6882 .key
6883 .clone()
6884 };
6885 let order = [find(&excluded_repo), find(&normal_repo)];
6886
6887 assert_eq!(
6888 core.operable_count(&order),
6889 1,
6890 "one of the two rows is excluded, so exactly one is operable"
6891 );
6892
6893 let started = core.run_action(action("reinstall", vec![step(&["true"])]), &order);
6894 assert!(started);
6895
6896 wait_for("every entity in the order to carry a receipt", || {
6897 let snapshot = core.snapshot();
6898 order.iter().all(|key| {
6899 snapshot
6900 .entities
6901 .iter()
6902 .find(|entity| entity.key == *key)
6903 .and_then(|entity| entity.last_action.as_ref())
6904 .is_some()
6905 })
6906 });
6907
6908 let after = core.snapshot();
6909 let actually_ran = after
6910 .entities
6911 .iter()
6912 .filter(|entity| order.contains(&entity.key))
6913 .filter(|entity| {
6914 entity
6915 .last_action
6916 .as_ref()
6917 .is_some_and(|receipt| !receipt.not_applicable())
6918 })
6919 .count();
6920
6921 assert_eq!(
6922 core.operable_count(&order),
6923 actually_ran,
6924 "operable_count must report exactly how many rows run_action actually ran a \
6925 step against, not merely how many keys resolved"
6926 );
6927 }
6928
6929 #[test]
6934 fn run_action_for_entity_blocking_returns_the_finished_receipt_on_the_calling_thread() {
6935 let dir = tempfile::tempdir().expect("temp dir");
6936 let root = root_of(&dir);
6937 let repo = root.join("repo");
6938 init_repo_with_a_commit(&repo);
6939 let marker = repo.join("hook-ran");
6940
6941 let core = Core::start_discovered(spec_with_overrides(vec![root], Vec::new()));
6942 let key = core
6943 .snapshot()
6944 .entities
6945 .iter()
6946 .find(|entity| entity.key.path() == repo)
6947 .expect("the repo is discovered")
6948 .key
6949 .clone();
6950
6951 let receipt = core
6952 .run_action_for_entity_blocking(
6953 &action("hook", vec![step(&["touch", "hook-ran"])]),
6954 &key,
6955 )
6956 .expect("the entity is known");
6957
6958 assert!(
6959 marker.exists(),
6960 "the step must have already run by the time this call returns"
6961 );
6962 assert_eq!(receipt.steps.len(), 1);
6963 assert_eq!(receipt.steps[0].outcome, StepOutcome::Ok);
6964 }
6965
6966 fn step_that_cannot_prepare(argv: &[&str], resource: &str) -> Step {
6970 Step {
6971 env: vec![(
6972 executor::SETUP_FAILURE_VARIABLE.to_string(),
6973 resource.to_string(),
6974 )],
6975 ..step(argv)
6976 }
6977 }
6978
6979 #[test]
6985 fn a_step_whose_pty_setup_fails_finishes_the_run_and_leaves_a_later_action_working() {
6986 let dir = tempfile::tempdir().expect("temp dir");
6987 let root = root_of(&dir);
6988 let repo = root.join("repo");
6989 init_repo_with_a_commit(&repo);
6990
6991 let core = Core::start_discovered(spec_with_overrides(vec![root], Vec::new()));
6992 let key = core
6993 .snapshot()
6994 .entities
6995 .iter()
6996 .find(|entity| entity.key.path() == repo)
6997 .expect("the repo is discovered")
6998 .key
6999 .clone();
7000
7001 let (tx, rx) = mpsc::channel();
7002 thread::spawn(move || {
7003 let faulted = core.run_action_for_entity_blocking(
7004 &action(
7005 "hook",
7006 vec![
7007 step_that_cannot_prepare(&["touch", "first-ran"], "notify-pipe"),
7008 step(&["touch", "second-ran"]),
7009 ],
7010 ),
7011 &key,
7012 );
7013 let later = core.run_action_for_entity_blocking(
7014 &action("hook", vec![step(&["touch", "later-ran"])]),
7015 &key,
7016 );
7017 let _ = tx.send((faulted, later));
7018 });
7019 let (faulted, later) = rx
7020 .recv_timeout(BACKSTOP)
7021 .expect("a run whose first step cannot prepare its pty must still hand back receipts");
7022
7023 let faulted = faulted.expect("the entity is known");
7024 assert!(
7025 matches!(faulted.steps[0].outcome, StepOutcome::Failed(code) if code != 0),
7026 "expected the first step to fail, got {:?}",
7027 faulted.steps[0].outcome
7028 );
7029 let detail = String::from_utf8_lossy(&faulted.steps[0].output).to_string();
7030 assert!(
7031 detail.contains("pipe that notices"),
7032 "expected the receipt to name the resource that failed, got {detail:?}"
7033 );
7034 assert_eq!(faulted.steps[1].outcome, StepOutcome::NotRun);
7035 assert!(
7036 !repo.join("first-ran").exists() && !repo.join("second-ran").exists(),
7037 "a step that never prepared its pty must never have run its command"
7038 );
7039
7040 let later = later.expect("the entity is known");
7041 assert_eq!(later.steps[0].outcome, StepOutcome::Ok);
7042 assert!(
7043 repo.join("later-ran").exists(),
7044 "a later Action must still run its own command"
7045 );
7046 }
7047
7048 #[test]
7053 fn run_action_for_entity_blocking_answers_none_for_an_unknown_key() {
7054 let dir = tempfile::tempdir().expect("temp dir");
7055 let root = root_of(&dir);
7056 let core = Core::start_discovered(spec_with_overrides(vec![root.clone()], Vec::new()));
7057
7058 let unknown = EntityKey::new(Arc::from(root.join("never-discovered").as_path()));
7059
7060 assert!(
7061 core.run_action_for_entity_blocking(&action("hook", vec![step(&["true"])]), &unknown)
7062 .is_none()
7063 );
7064 }
7065
7066 #[test]
7073 fn run_action_skips_a_row_its_when_predicate_disproves_rather_than_running_it_anyway() {
7074 let dir = tempfile::tempdir().expect("temp dir");
7075 let root = root_of(&dir);
7076 let proved_repo = root.join("alpha");
7077 let disproved_repo = root.join("beta");
7078 init_repo_with_a_commit(&proved_repo);
7079 init_repo_with_a_commit(&disproved_repo);
7080
7081 let core = Core::start_discovered(spec(vec![root]));
7082 let snapshot = core.snapshot();
7083 let find = |path: &Path| {
7084 snapshot
7085 .entities
7086 .iter()
7087 .find(|entity| entity.key.path() == path)
7088 .unwrap_or_else(|| panic!("entity at {path:?} present"))
7089 .key
7090 .clone()
7091 };
7092 let proved_key = find(&proved_repo);
7093 let disproved_key = find(&disproved_repo);
7094 let order = [proved_key.clone(), disproved_key.clone()];
7095
7096 let started = core.run_action(
7099 action_with_when(
7100 "reinstall",
7101 vec![step(&["sh", "-c", "exit 3"])],
7102 "name:alpha",
7103 ),
7104 &order,
7105 );
7106 assert!(started);
7107 wait_for("the fan-out to finish", || !core.action_running());
7108
7109 let after = core.snapshot();
7110 let receipt_of = |key: &EntityKey| {
7111 after
7112 .entities
7113 .iter()
7114 .find(|entity| entity.key == *key)
7115 .unwrap()
7116 .last_action
7117 .clone()
7118 .unwrap()
7119 };
7120
7121 let proved_receipt = receipt_of(&proved_key);
7122 assert_eq!(
7123 proved_receipt.skip, None,
7124 "the row the predicate proved must actually run"
7125 );
7126 assert!(proved_receipt.failed(), "its own step still ran and failed");
7127
7128 let disproved_receipt = receipt_of(&disproved_key);
7129 assert!(
7130 disproved_receipt.inapplicable(),
7131 "the row the predicate disproved must be skipped rather than run"
7132 );
7133 assert!(disproved_receipt.steps.is_empty());
7134 assert!(
7135 !disproved_receipt.failed(),
7136 "a skipped row never ran a step, so it cannot have failed one"
7137 );
7138 }
7139
7140 #[test]
7149 fn applicability_subtracts_an_excluded_row_before_the_predicate_reads_it() {
7150 let dir = tempfile::tempdir().expect("temp dir");
7151 let root = root_of(&dir);
7152 let excluded_repo = root.join("excluded");
7153 let normal_repo = root.join("normal");
7154 init_repo_with_a_commit(&excluded_repo);
7155 init_repo_with_a_commit(&normal_repo);
7156
7157 let core = Core::start_discovered(spec_with_overrides(
7158 vec![root],
7159 vec![RepoOverride {
7160 path: excluded_repo.clone(),
7161 default_branch: None,
7162 excluded: true,
7163 }],
7164 ));
7165 let order: Vec<EntityKey> = core
7166 .snapshot()
7167 .entities
7168 .iter()
7169 .map(|entity| entity.key.clone())
7170 .collect();
7171 assert_eq!(order.len(), 2, "the fixture must discover both repos");
7172
7173 let counts = core.applicability(&order, &Filter::parse("kind:repo"));
7174
7175 assert_eq!(
7176 counts.total(),
7177 core.operable_count(&order),
7178 "the predicate must be counted over exactly the rows `operable_count` keeps"
7179 );
7180 assert_eq!(
7181 counts,
7182 Applicability {
7183 applicable: 1,
7184 inapplicable: 0,
7185 unresolved: 0,
7186 }
7187 );
7188 }
7189
7190 #[test]
7194 fn operable_count_silently_drops_a_key_that_no_longer_resolves() {
7195 let dir = tempfile::tempdir().expect("temp dir");
7196 let root = root_of(&dir);
7197 let repo = root.join("repo");
7198 init_repo_with_a_commit(&repo);
7199
7200 let core = Core::start_discovered(spec(vec![root]));
7201 let real_key = core.snapshot().entities[0].key.clone();
7202 let unknown_key = EntityKey::new(Arc::from(dir.path().join("never-discovered")));
7203
7204 assert_eq!(core.operable_count(&[real_key, unknown_key]), 1);
7205 }
7206
7207 #[test]
7211 fn only_one_action_fan_out_runs_at_a_time_a_second_call_is_rejected_while_one_is_live() {
7212 let dir = tempfile::tempdir().expect("temp dir");
7213 let root = root_of(&dir);
7214 let repo = root.join("repo");
7215 init_repo_with_a_commit(&repo);
7216
7217 let core = Core::start_discovered(spec(vec![root]));
7218 let key = core.snapshot().entities[0].key.clone();
7219 let slow = action("first", vec![step(&["sh", "-c", "sleep 0.3"])]);
7220 let fast = action("second", vec![step(&["true"])]);
7221
7222 let first_started = core.run_action(slow, std::slice::from_ref(&key));
7223 let second_started = core.run_action(fast, std::slice::from_ref(&key));
7224
7225 assert!(first_started);
7226 assert!(
7227 !second_started,
7228 "a second run_action call must be rejected while the first is still in flight"
7229 );
7230 wait_for("the accepted first fan-out to finish", || {
7231 !core.action_running()
7232 });
7233 let receipt = core.snapshot().entities[0].last_action.clone().unwrap();
7234 assert_eq!(
7235 &*receipt.label, "first",
7236 "the surviving receipt must be the accepted first run's, never the rejected second"
7237 );
7238 }
7239
7240 #[test]
7249 fn a_refused_second_submission_leaves_the_first_action_still_stoppable() {
7250 let dir = tempfile::tempdir().expect("temp dir");
7251 let root = root_of(&dir);
7252 let repo = root.join("repo");
7253 init_repo_with_a_commit(&repo);
7254
7255 let core = Core::start_discovered(spec(vec![root]));
7256 let key = core.snapshot().entities[0].key.clone();
7257 let sleep_past_the_backstop = format!("sleep {}", FIXTURE_LIFETIME.as_secs());
7258 let live = action(
7259 "live",
7260 vec![
7261 step(&["sh", "-c", &sleep_past_the_backstop]),
7262 step(&["sh", "-c", &sleep_past_the_backstop]),
7263 ],
7264 );
7265
7266 assert!(core.run_action(live, std::slice::from_ref(&key)));
7267 wait_for("the live run's own first step to start", || {
7268 core.snapshot().entities[0]
7269 .last_action
7270 .as_ref()
7271 .is_some_and(|receipt| receipt.running.is_some())
7272 });
7273
7274 assert!(
7275 !core.run_action(
7276 action("refused", vec![step(&["true"])]),
7277 std::slice::from_ref(&key)
7278 ),
7279 "a second submission must be refused while one run is still live"
7280 );
7281
7282 core.stop_action();
7283
7284 wait_for("the still-controllable run to come down", || {
7285 !core.action_running()
7286 });
7287 let receipt = core.snapshot().entities[0].last_action.clone().unwrap();
7288 assert_eq!(&*receipt.label, "live");
7289 assert_eq!(
7290 receipt.steps[0].outcome,
7291 StepOutcome::Cancelled,
7292 "the refused submission must leave the live run's own control in place, so \
7293 stop_action still reaches the step it was running"
7294 );
7295 assert_eq!(
7296 receipt.steps[1].outcome,
7297 StepOutcome::Cancelled,
7298 "a step that had not started when the run was cancelled must read Cancelled too"
7299 );
7300 }
7301
7302 #[test]
7313 fn a_run_accepted_once_a_completion_releases_its_admission_is_still_stoppable() {
7314 let dir = tempfile::tempdir().expect("temp dir");
7315 let root = root_of(&dir);
7316 let repo = root.join("repo");
7317 init_repo_with_a_commit(&repo);
7318
7319 let core = Core::start_discovered(spec(vec![root]));
7320 let key = core.snapshot().entities[0].key.clone();
7321 let armed = core.action_completion_boundary().arm();
7322
7323 assert!(core.run_action(
7324 action("finishing", vec![step(&["true"])]),
7325 std::slice::from_ref(&key)
7326 ));
7327 armed.wait_until_reached();
7328 assert!(
7329 !core.run_action(
7330 action("early", vec![step(&["true"])]),
7331 std::slice::from_ref(&key)
7332 ),
7333 "a submission made before the completion releases its admission must be refused"
7334 );
7335 drop(armed);
7336 wait_for("the finished run to release its admission", || {
7337 !core.action_running()
7338 });
7339
7340 let sleep_past_the_backstop = format!("sleep {}", FIXTURE_LIFETIME.as_secs());
7341 let following = action(
7342 "following",
7343 vec![
7344 step(&["sh", "-c", &sleep_past_the_backstop]),
7345 step(&["sh", "-c", &sleep_past_the_backstop]),
7346 ],
7347 );
7348 assert!(
7349 core.run_action(following, std::slice::from_ref(&key)),
7350 "a submission made once that release has happened must be accepted"
7351 );
7352 wait_for("the following run's own first step to start", || {
7353 receipt_labelled(&core, &key, "following")
7354 .is_some_and(|receipt| receipt.running.is_some())
7355 });
7356
7357 core.stop_action();
7358
7359 wait_for("the cancelled run to come down", || !core.action_running());
7360 let receipt =
7361 receipt_labelled(&core, &key, "following").expect("the following run's receipt");
7362 assert_eq!(
7363 receipt.steps[0].outcome,
7364 StepOutcome::Cancelled,
7365 "the completion this run followed must leave stop_action still reaching it"
7366 );
7367 assert_eq!(
7368 receipt.steps[1].outcome,
7369 StepOutcome::Cancelled,
7370 "a cancelled run's remaining step must never start, so it reads Cancelled"
7371 );
7372 }
7373
7374 #[test]
7388 fn hold_action_genuinely_pauses_a_running_steps_progress_and_continue_action_resumes_it() {
7389 let dir = tempfile::tempdir().expect("temp dir");
7390 let root = root_of(&dir);
7391 let repo = root.join("repo");
7392 init_repo_with_a_commit(&repo);
7393
7394 let core = Core::start_discovered(spec(vec![root]));
7395 let key = core.snapshot().entities[0].key.clone();
7396 let two_seconds = action("brief", vec![step(&["sh", "-c", "sleep 2"])]);
7397
7398 assert!(core.run_action(two_seconds, std::slice::from_ref(&key)));
7399 wait_for("the two-second step to actually start running", || {
7400 core.snapshot().entities[0]
7401 .last_action
7402 .as_ref()
7403 .is_some_and(|receipt| receipt.running.is_some())
7404 });
7405
7406 for _ in 0..20 {
7414 core.hold_action();
7415 thread::sleep(Duration::from_millis(20));
7416 }
7417
7418 thread::sleep(Duration::from_millis(1_800));
7419 assert!(
7420 core.action_running(),
7421 "a genuinely held step must not have finished on its own well past its own 2s \
7422 sleep; a no-op hold_action would already show this false here"
7423 );
7424
7425 core.continue_action();
7426 wait_for("continue_action to let the held step finish", || {
7427 !core.action_running()
7428 });
7429 let receipt = core.snapshot().entities[0].last_action.clone().unwrap();
7430 assert_eq!(receipt.steps[0].outcome, StepOutcome::Ok);
7431 }
7432
7433 #[test]
7437 fn hold_continue_and_stop_action_are_no_ops_with_no_fan_out_running() {
7438 let dir = tempfile::tempdir().expect("temp dir");
7439 let root = root_of(&dir);
7440 let repo = root.join("repo");
7441 init_repo_with_a_commit(&repo);
7442
7443 let core = Core::start_discovered(spec(vec![root]));
7444
7445 core.hold_action();
7446 core.continue_action();
7447 core.stop_action();
7448
7449 assert!(!core.action_running());
7450 }
7451
7452 #[test]
7474 fn stop_action_escalates_from_sigterm_to_sigkill_against_a_trapping_step() {
7475 let dir = tempfile::tempdir().expect("temp dir");
7476 let root = root_of(&dir);
7477 let repo = root.join("repo");
7478 init_repo_with_a_commit(&repo);
7479
7480 let core = Core::start_discovered(spec(vec![root]));
7481 let key = core.snapshot().entities[0].key.clone();
7482 let sleep_past_the_backstop = format!("trap '' TERM; sleep {}", FIXTURE_LIFETIME.as_secs());
7483 let trapping = action(
7484 "trapping",
7485 vec![step(&["sh", "-c", &sleep_past_the_backstop])],
7486 );
7487
7488 assert!(core.run_action(trapping, std::slice::from_ref(&key)));
7489 wait_for("the trapping step to actually start running", || {
7490 core.snapshot().entities[0]
7491 .last_action
7492 .as_ref()
7493 .is_some_and(|receipt| receipt.running.is_some())
7494 });
7495 thread::sleep(Duration::from_millis(100));
7498
7499 core.stop_action();
7500
7501 wait_for(
7502 "a SIGTERM-trapping step to come down from the follow-up SIGKILL",
7503 || !core.action_running(),
7504 );
7505 let receipt = core.snapshot().entities[0].last_action.clone().unwrap();
7506 assert_eq!(receipt.steps.len(), 1);
7507 assert_eq!(
7508 receipt.steps[0].outcome,
7509 StepOutcome::Cancelled,
7510 "a step running when the run was cancelled must read Cancelled, never Failed"
7511 );
7512 }
7513
7514 #[test]
7529 fn cancelled_and_not_run_are_distinct_outcomes_shown_together_in_one_run() {
7530 let dir = tempfile::tempdir().expect("temp dir");
7531 let root = root_of(&dir);
7532 init_repo_with_a_commit(&root.join("fail"));
7533 init_repo_with_a_commit(&root.join("slow"));
7534
7535 let core = Core::start_discovered(spec(vec![root]));
7536 let snapshot = core.snapshot();
7537 let fail_key = snapshot
7538 .entities
7539 .iter()
7540 .find(|entity| &*entity.name == "fail")
7541 .expect("the fail entity is present")
7542 .key
7543 .clone();
7544 let slow_key = snapshot
7545 .entities
7546 .iter()
7547 .find(|entity| &*entity.name == "slow")
7548 .expect("the slow entity is present")
7549 .key
7550 .clone();
7551
7552 let branch_on_the_entity_name = format!(
7560 "case \"$(basename \"$PWD\")\" in fail) exit 1 ;; *) sleep {} ;; esac",
7561 FIXTURE_LIFETIME.as_secs()
7562 );
7563 let steps = vec![
7564 step(&["sh", "-c", &branch_on_the_entity_name]),
7565 step(&["true"]),
7566 ];
7567 let mut action_spec = action("mixed", steps);
7568 action_spec.concurrency = 2;
7569
7570 assert!(core.run_action(action_spec, &[fail_key.clone(), slow_key.clone()]));
7571
7572 wait_for(
7576 "`fail` finished and `slow` still running before cancelling",
7577 || {
7578 let snapshot = core.snapshot();
7579 let fail_done = snapshot
7580 .entities
7581 .iter()
7582 .find(|entity| entity.key == fail_key)
7583 .and_then(|entity| entity.last_action.as_ref())
7584 .is_some_and(|receipt| receipt.steps.len() == 2);
7585 let slow_running = snapshot
7586 .entities
7587 .iter()
7588 .find(|entity| entity.key == slow_key)
7589 .and_then(|entity| entity.last_action.as_ref())
7590 .is_some_and(|receipt| receipt.running.is_some());
7591 fail_done && slow_running
7592 },
7593 );
7594
7595 core.stop_action();
7596 wait_for("the fan-out to finish once cancelled", || {
7597 !core.action_running()
7598 });
7599
7600 let snapshot = core.snapshot();
7601 let fail_receipt = snapshot
7602 .entities
7603 .iter()
7604 .find(|entity| entity.key == fail_key)
7605 .and_then(|entity| entity.last_action.clone())
7606 .expect("fail's own receipt");
7607 assert_eq!(fail_receipt.steps[0].outcome, StepOutcome::Failed(1));
7608 assert_eq!(
7609 fail_receipt.steps[1].outcome,
7610 StepOutcome::NotRun,
7611 "blocked by fail's own earlier failure, not by the later cancellation"
7612 );
7613
7614 let slow_receipt = snapshot
7615 .entities
7616 .iter()
7617 .find(|entity| entity.key == slow_key)
7618 .and_then(|entity| entity.last_action.clone())
7619 .expect("slow's own receipt");
7620 assert_eq!(
7621 slow_receipt.steps[0].outcome,
7622 StepOutcome::Cancelled,
7623 "a step running when the run was cancelled must read Cancelled"
7624 );
7625 assert_eq!(
7626 slow_receipt.steps[1].outcome,
7627 StepOutcome::Cancelled,
7628 "a step that had not started when the run was cancelled must also read \
7629 Cancelled, never NotRun, which stays reserved for an earlier failure"
7630 );
7631 }
7632
7633 #[test]
7641 fn a_panicking_fan_out_still_resets_action_running_so_a_later_action_can_start() {
7642 let dir = tempfile::tempdir().expect("temp dir");
7643 let root = root_of(&dir);
7644 let repo = root.join("repo");
7645 init_repo_with_a_commit(&repo);
7646
7647 let (core, launched) = started_and_settled(spec(vec![root]));
7651 let key = launched.entities[0].key.clone();
7652
7653 let started = core.run_action(
7660 action("boom", vec![step(&["sh", "-c", "sleep 0.3"])]),
7661 std::slice::from_ref(&key),
7662 );
7663 assert!(started);
7664
7665 let table = Arc::clone(&core.table);
7666 thread::spawn(move || {
7667 let _guard = table.write().unwrap();
7668 panic!("deliberately poison the table lock for this test");
7669 })
7670 .join()
7671 .expect_err("the poisoning thread must itself panic to poison the lock");
7672
7673 wait_for(
7678 "a panicking fan-out to end its run rather than leave it reading as live",
7679 || !core.action_running(),
7680 );
7681
7682 core.table.clear_poison();
7687
7688 let second_started = core.run_action(
7689 action("second", vec![step(&["true"])]),
7690 std::slice::from_ref(&key),
7691 );
7692 assert!(
7693 second_started,
7694 "a later Action must be able to start once the panicking one has finished"
7695 );
7696 wait_for("the second Action to run to completion", || {
7697 core.snapshot()
7698 .entities
7699 .iter()
7700 .find(|entity| entity.key == key)
7701 .and_then(|entity| entity.last_action.as_ref())
7702 .is_some_and(|receipt| &*receipt.label == "second")
7703 });
7704 }
7705
7706 fn assert_vanished_with_stale_branch(entity: &EntityState, expected_branch: &str) {
7712 assert_eq!(entity.presence, crate::entity::Presence::Vanished);
7713 match entity.branch.settled() {
7714 Some(Settled::Known {
7715 value: Head::Branch { name, .. },
7716 stale: true,
7717 at: _,
7718 }) => assert_eq!(
7719 &**name, expected_branch,
7720 "a Vanished entity must keep its last known branch value"
7721 ),
7722 other => panic!(
7723 "expected the branch cell to keep its Known value and go stale, got {other:?}"
7724 ),
7725 }
7726 }
7727
7728 #[test]
7734 fn a_repo_removed_from_disk_stays_in_the_table_vanished_with_its_last_values() {
7735 let dir = tempfile::tempdir().expect("temp dir");
7736 let root = root_of(&dir);
7737 let repo = root.join("repo");
7738 init_repo_with_a_commit(&repo);
7739
7740 let core = Core::start_discovered(spec(vec![root]));
7741 let key = core.snapshot().entities[0].key.clone();
7742 core.refresh(std::slice::from_ref(&key));
7743 let before = core.settle();
7744 let branch_name = match before.entities[0].branch.settled() {
7745 Some(Settled::Known {
7746 value: Head::Branch { name, .. },
7747 at: _,
7748 stale: _,
7749 }) => name.to_string(),
7750 other => panic!("expected the first refresh to settle a branch, got {other:?}"),
7751 };
7752
7753 fs::remove_dir_all(&repo).expect("remove the repo from disk");
7754
7755 core.refresh(&[]);
7756 let after = core.settle();
7757
7758 assert_eq!(
7759 after.entities.len(),
7760 1,
7761 "a vanished entity must stay in the snapshot, not disappear from it"
7762 );
7763 assert_vanished_with_stale_branch(&after.entities[0], &branch_name);
7764 }
7765
7766 #[test]
7770 fn a_vanished_entitys_action_receipt_survives_the_vanished_staleness_pass_untouched() {
7771 let dir = tempfile::tempdir().expect("temp dir");
7772 let root = root_of(&dir);
7773 let repo = root.join("repo");
7774 init_repo_with_a_commit(&repo);
7775
7776 let core = Core::start_discovered(spec(vec![root]));
7777 let key = core.snapshot().entities[0].key.clone();
7778 let receipt = crate::entity::ActionReceipt {
7779 label: Arc::from("reinstall"),
7780 steps: Arc::from(vec![crate::entity::StepResult {
7781 label: Arc::from("pnpm install"),
7782 outcome: crate::entity::StepOutcome::Ok,
7783 output: Arc::from(&b""[..]),
7784 elapsed: Duration::from_millis(1),
7785 elision: None,
7786 shell: false,
7787 interactive: false,
7788 }]),
7789 skip: None,
7790 finished_at: Timestamp::now(),
7791 running: None,
7792 };
7793 core.set_last_action_for_test(&key, receipt.clone());
7794
7795 fs::remove_dir_all(&repo).expect("remove the repo from disk");
7796 core.refresh(&[]);
7797 let after = core.settle();
7798
7799 let entity = &after.entities[0];
7800 assert_eq!(entity.presence, crate::entity::Presence::Vanished);
7801 assert_eq!(entity.last_action, Some(receipt));
7802 }
7803
7804 #[test]
7812 fn two_snapshots_of_an_entity_share_its_last_actions_label_and_steps_by_pointer() {
7813 let dir = tempfile::tempdir().expect("temp dir");
7814 let root = root_of(&dir);
7815 let repo = root.join("repo");
7816 init_repo_with_a_commit(&repo);
7817
7818 let core = Core::start_discovered(spec(vec![root]));
7819 let key = core.snapshot().entities[0].key.clone();
7820 let receipt = crate::entity::ActionReceipt {
7821 label: Arc::from("reinstall"),
7822 steps: Arc::from(vec![crate::entity::StepResult {
7823 label: Arc::from("pnpm install"),
7824 outcome: crate::entity::StepOutcome::Failed(1),
7825 output: Arc::from(&b""[..]),
7826 elapsed: Duration::from_millis(1),
7827 elision: None,
7828 shell: false,
7829 interactive: false,
7830 }]),
7831 skip: None,
7832 finished_at: Timestamp::now(),
7833 running: None,
7834 };
7835 core.set_last_action_for_test(&key, receipt);
7836
7837 let first = core.snapshot();
7838 let second = core.snapshot();
7839 let first_receipt = first.entities[0]
7840 .last_action
7841 .as_ref()
7842 .expect("receipt was set");
7843 let second_receipt = second.entities[0]
7844 .last_action
7845 .as_ref()
7846 .expect("receipt was set");
7847
7848 assert!(
7849 Arc::ptr_eq(&first_receipt.label, &second_receipt.label),
7850 "two snapshots of the same receipt must share the label's allocation, not \
7851 re-copy it"
7852 );
7853 assert!(
7854 Arc::ptr_eq(&first_receipt.steps, &second_receipt.steps),
7855 "two snapshots of the same receipt must share the steps slice's allocation, not \
7856 re-copy it, which is also what shares every step's own captured output"
7857 );
7858 }
7859
7860 #[test]
7866 fn a_submodule_removed_from_gitmodules_vanishes_by_the_same_rule_as_a_repo() {
7867 let dir = tempfile::tempdir().expect("temp dir");
7868 let root = root_of(&dir);
7869 let parent = root.join("parent");
7870 init_repo_with_a_commit(&parent);
7871 fs::write(
7872 parent.join(".gitmodules"),
7873 "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
7874 )
7875 .expect("write .gitmodules");
7876 let submodule_path = parent.join("vendor").join("lib");
7877 init_repo_with_a_commit(&submodule_path);
7878
7879 let mut core_spec = spec(vec![root]);
7882 core_spec.show_submodules = true;
7883 let core = Core::start_discovered(core_spec);
7884 let snapshot = core.snapshot();
7885 let submodule_key = snapshot
7886 .entities
7887 .iter()
7888 .find(|entity| matches!(entity.kind, Kind::Submodule))
7889 .expect("submodule discovered")
7890 .key
7891 .clone();
7892 core.refresh(std::slice::from_ref(&submodule_key));
7893 let before = core.settle();
7894 let submodule_before = before
7895 .entities
7896 .iter()
7897 .find(|entity| entity.key == submodule_key)
7898 .expect("submodule present");
7899 let branch_name = match submodule_before.branch.settled() {
7900 Some(Settled::Known {
7901 value: Head::Branch { name, .. },
7902 at: _,
7903 stale: _,
7904 }) => name.to_string(),
7905 other => {
7906 panic!("expected the submodule's first refresh to settle a branch, got {other:?}")
7907 }
7908 };
7909
7910 fs::write(parent.join(".gitmodules"), "").expect("clear .gitmodules");
7914
7915 core.refresh(&[]);
7916 let after = core.settle();
7917
7918 let submodule_after = after
7919 .entities
7920 .iter()
7921 .find(|entity| entity.key == submodule_key)
7922 .expect("the vanished submodule must stay in the snapshot");
7923 assert_vanished_with_stale_branch(submodule_after, &branch_name);
7924 }
7925
7926 #[test]
7931 fn dismissal_persists_nothing_across_a_fresh_core() {
7932 let dir = tempfile::tempdir().expect("temp dir");
7933 let root = root_of(&dir);
7934 let repo = root.join("repo");
7935 init_repo_with_a_commit(&repo);
7936
7937 let first_core = Core::start_discovered(spec(vec![root.clone()]));
7938 let key = first_core.snapshot().entities[0].key.clone();
7939 first_core.dismiss(&key);
7940 assert!(first_core.snapshot().entities.is_empty());
7941 drop(first_core);
7942
7943 let second_core = Core::start_discovered(spec(vec![root]));
7944 let snapshot = second_core.snapshot();
7945
7946 assert_eq!(
7947 snapshot.entities.len(),
7948 1,
7949 "a fresh Core must discover the repo again"
7950 );
7951 assert_eq!(
7952 snapshot.entities[0].presence,
7953 crate::entity::Presence::Present,
7954 "nothing from the dismissing Core's lifetime may be persisted, so the \
7955 repo must come back Present, never restored as Vanished"
7956 );
7957 }
7958
7959 #[test]
7963 fn a_repo_that_moves_reads_as_vanished_plus_new() {
7964 let dir = tempfile::tempdir().expect("temp dir");
7965 let root = root_of(&dir);
7966 let original_path = root.join("original-name");
7967 init_repo_with_a_commit(&original_path);
7968
7969 let core = Core::start_discovered(spec(vec![root.clone()]));
7970 let original_key = core.snapshot().entities[0].key.clone();
7971 core.refresh(std::slice::from_ref(&original_key));
7972 let before = core.settle();
7973 let branch_name = match before.entities[0].branch.settled() {
7974 Some(Settled::Known {
7975 value: Head::Branch { name, .. },
7976 at: _,
7977 stale: _,
7978 }) => name.to_string(),
7979 other => panic!("expected the first refresh to settle a branch, got {other:?}"),
7980 };
7981
7982 let moved_path = root.join("new-name");
7983 fs::rename(&original_path, &moved_path).expect("move the repo on disk");
7984
7985 core.refresh(&[]);
7986 let after = core.settle();
7987
7988 assert_eq!(
7989 after.entities.len(),
7990 2,
7991 "a moved entity must read as the old key vanished plus a new one present, \
7992 never as one renamed entity"
7993 );
7994 let old_entity = after
7995 .entities
7996 .iter()
7997 .find(|entity| entity.key == original_key)
7998 .expect("the old key must stay in the table");
7999 assert_vanished_with_stale_branch(old_entity, &branch_name);
8000 let new_entity = after
8001 .entities
8002 .iter()
8003 .find(|entity| entity.key != original_key)
8004 .expect("a new entity at the moved path must be present");
8005 assert_eq!(new_entity.presence, crate::entity::Presence::Present);
8006 assert_eq!(new_entity.key.path(), moved_path);
8007 }
8008
8009 #[test]
8013 fn a_vanished_repo_recreated_on_disk_reads_present_on_the_next_refresh() {
8014 let dir = tempfile::tempdir().expect("temp dir");
8015 let root = root_of(&dir);
8016 let repo = root.join("repo");
8017 init_repo_with_a_commit(&repo);
8018
8019 let core = Core::start_discovered(spec(vec![root]));
8020 let key = core.snapshot().entities[0].key.clone();
8021
8022 fs::remove_dir_all(&repo).expect("remove the repo from disk");
8023 core.refresh(&[]);
8024 let vanished = core.settle();
8025 assert_eq!(
8026 vanished.entities[0].presence,
8027 crate::entity::Presence::Vanished,
8028 "the repo must read Vanished once removed from disk"
8029 );
8030
8031 init_repo_with_a_commit(&repo);
8032 core.refresh(&[]);
8033 let recreated = core.settle();
8034
8035 let entity = recreated
8036 .entities
8037 .iter()
8038 .find(|entity| entity.key == key)
8039 .expect("the recreated repo must still resolve to the same entity key");
8040 assert_eq!(
8041 entity.presence,
8042 crate::entity::Presence::Present,
8043 "an entity discovery finds again after it vanished must read Present, \
8044 not stay stuck Vanished forever"
8045 );
8046 }
8047
8048 #[test]
8053 fn a_new_repo_created_after_start_is_discovered_by_the_next_refresh() {
8054 let dir = tempfile::tempdir().expect("temp dir");
8055 let root = root_of(&dir);
8056 init_repo_with_a_commit(&root.join("first"));
8057
8058 let core = Core::start_discovered(spec(vec![root.clone()]));
8059 assert_eq!(core.snapshot().entities.len(), 1);
8060
8061 init_repo_with_a_commit(&root.join("second"));
8062 core.refresh(&[]);
8063 let after = core.settle();
8064
8065 assert_eq!(
8066 after.entities.len(),
8067 2,
8068 "a new repo created after start must be found by the next refresh's own discovery"
8069 );
8070
8071 let new_key = after
8074 .entities
8075 .iter()
8076 .find(|entity| &*entity.name == "second")
8077 .expect("the newly discovered repo must be named by the walk")
8078 .key
8079 .clone();
8080 core.refresh(std::slice::from_ref(&new_key));
8081 let probed = core.settle();
8082 let new_entity = probed
8083 .entities
8084 .iter()
8085 .find(|entity| entity.key == new_key)
8086 .expect("the newly discovered repo must still be present");
8087 assert!(
8088 matches!(
8089 new_entity.branch.settled(),
8090 Some(Settled::Known {
8091 value: _,
8092 at: _,
8093 stale: _
8094 })
8095 ),
8096 "a refresh naming the newly discovered repo's key must actually probe \
8097 it and settle its branch cell, got {:?}",
8098 new_entity.branch.settled()
8099 );
8100 }
8101
8102 #[test]
8107 fn an_abandoned_discovery_stops_riding_later_refreshes() {
8108 let dir = tempfile::tempdir().expect("temp dir");
8109 let root = root_of(&dir);
8110 let decoys = root.join("decoys");
8117 for i in 0..4_000 {
8118 fs::create_dir(decoys.join(format!("decoy-{i}")))
8119 .or_else(|_| fs::create_dir_all(decoys.join(format!("decoy-{i}"))))
8120 .expect("create decoy dir");
8121 }
8122 let (_tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
8123
8124 let started = Core::start_for_test_with_discovery_abandon(
8125 spec(vec![root.clone()]),
8126 Duration::from_secs(3600),
8127 Duration::from_micros(500),
8128 tick_rx,
8129 )
8130 .discovered();
8131 let core = started.core;
8132 assert!(
8133 core.discovery_manual_for_test(),
8134 "walking 4,000 decoy directories against a 500 microsecond deadline \
8135 must have abandoned and taken the Set manual"
8136 );
8137
8138 fs::remove_dir_all(&decoys).expect("remove decoy directories");
8143 init_repo_with_a_commit(&root.join("second"));
8144
8145 core.refresh(&[]);
8146 let after = core.settle();
8147
8148 assert!(
8149 !after
8150 .entities
8151 .iter()
8152 .any(|entity| &*entity.name == "second"),
8153 "once discovery has abandoned, a later refresh must not re-run it, so a \
8154 repo created afterward, on a tree that would now resolve quickly, \
8155 must still never appear"
8156 );
8157 }
8158
8159 #[test]
8168 fn a_refresh_triggered_discovery_abandon_sets_manual_and_warns() {
8169 let dir = tempfile::tempdir().expect("temp dir");
8170 let root = root_of(&dir);
8171 init_repo_with_a_commit(&root.join("first"));
8172 let (_tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
8173
8174 let started = Core::start_for_test_with_discovery_abandon(
8180 spec(vec![root.clone()]),
8181 Duration::from_secs(3600),
8182 Duration::from_secs(3600),
8183 tick_rx,
8184 )
8185 .discovered();
8186 let core = started.core;
8187 assert!(
8188 !core.discovery_manual_for_test(),
8189 "an hour-long deadline must leave the first walk automatic"
8190 );
8191
8192 let decoys = root.join("decoys");
8196 for i in 0..4_000 {
8197 fs::create_dir(decoys.join(format!("decoy-{i}")))
8198 .or_else(|_| fs::create_dir_all(decoys.join(format!("decoy-{i}"))))
8199 .expect("create decoy dir");
8200 }
8201 core.set_discovery_abandon_after_for_test(Duration::from_micros(500));
8202
8203 core.refresh(&[]);
8204 core.wait_dispatched_for_test();
8207
8208 assert!(
8209 core.discovery_manual_for_test(),
8210 "refresh's own rerun_discovery must abandon against the newly-grown \
8211 tree and take the Set manual, the same as an abandon at start does"
8212 );
8213 let warning = core.discovery_warning();
8214 assert!(
8215 warning
8216 .as_deref()
8217 .is_some_and(|message| message.starts_with("discovery: stopped at")),
8218 "refresh's rerun_discovery must leave the abandoned-discovery warning \
8219 behind, not merely flip the manual flag: got {warning:?}"
8220 );
8221 }
8222
8223 #[test]
8229 fn a_fresh_core_over_different_roots_is_unaffected_by_another_cores_abandoned_discovery() {
8230 let abandoned_dir = tempfile::tempdir().expect("temp dir");
8231 let abandoned_root = root_of(&abandoned_dir);
8232 init_repo_with_a_commit(&abandoned_root.join("first"));
8233 let (_tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
8234 let started = Core::start_for_test_with_discovery_abandon(
8235 spec(vec![abandoned_root]),
8236 Duration::from_secs(3600),
8237 Duration::ZERO,
8238 tick_rx,
8239 )
8240 .discovered();
8241 started.core.refresh(&[]);
8242 started.core.settle();
8243 assert!(
8244 started.core.discovery_manual_for_test(),
8245 "the zero-length abandon deadline must have already taken this Core manual"
8246 );
8247 drop(started.core);
8248
8249 let fresh_dir = tempfile::tempdir().expect("temp dir");
8250 let fresh_root = root_of(&fresh_dir);
8251 init_repo_with_a_commit(&fresh_root.join("first"));
8252 let fresh_core = Core::start_discovered(spec(vec![fresh_root.clone()]));
8253 assert_eq!(fresh_core.snapshot().entities.len(), 1);
8254
8255 init_repo_with_a_commit(&fresh_root.join("second"));
8256 fresh_core.refresh(&[]);
8257 let after = fresh_core.settle();
8258
8259 assert_eq!(
8260 after.entities.len(),
8261 2,
8262 "a fresh Core, standing in for the Set's roots changing, must discover \
8263 normally regardless of an earlier, unrelated Core having gone manual"
8264 );
8265 }
8266
8267 #[test]
8272 fn dropping_the_core_joins_the_dedicated_thread_before_returning() {
8273 let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
8274 let dir = tempfile::tempdir().expect("temp dir");
8275 let root = root_of(&dir);
8276
8277 let started =
8278 Core::start_for_test(spec(vec![root]), Duration::from_secs(3600), tick_rx).discovered();
8279 assert!(started.clock_alive.load(Ordering::Acquire));
8280
8281 drop(started.core);
8282
8283 assert!(
8284 !started.clock_alive.load(Ordering::Acquire),
8285 "the dedicated thread should have exited, and cleared this flag, before drop returned"
8286 );
8287 drop(tick_tx);
8288 }
8289
8290 #[test]
8295 fn the_deadline_sweep_runs_only_when_a_tick_arrives() {
8296 let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
8297 let dir = tempfile::tempdir().expect("temp dir");
8298 let root = root_of(&dir);
8299 let repo = root.join("repo");
8300 init_repo_with_a_commit(&repo);
8301
8302 let mut spec = spec(vec![root]);
8303 spec.generation_deadline = Duration::ZERO;
8304 let started = Core::start_for_test(spec, Duration::from_secs(3600), tick_rx).discovered();
8305 let core = started.core;
8306 let key = settle_launch(&core).entities[0].key.clone();
8309
8310 core.begin_untracked_probe_for_test(&key);
8311
8312 let before = core.snapshot();
8315 assert!(
8316 matches!(
8317 before.entities[0].branch.settled(),
8318 Some(Settled::Known {
8319 value: _,
8320 at: _,
8321 stale: _
8322 })
8323 ),
8324 "the cell still holds launch's own answer here, so the Unknown below is the \
8325 sweep's write rather than a cell that was already empty"
8326 );
8327 assert!(before.entities[0].branch.is_in_flight());
8328
8329 tick_tx.send(Instant::now()).expect("send one tick");
8330 let after = core.settle();
8331
8332 assert!(matches!(
8333 after.entities[0].branch.settled(),
8334 Some(Settled::Unknown(Unknown::TimedOut))
8335 ));
8336 }
8337
8338 #[test]
8347 fn a_real_tick_through_the_dedicated_thread_reaches_the_poll_sweep_and_reprobes_a_moved_entity()
8348 {
8349 let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
8350 let dir = tempfile::tempdir().expect("temp dir");
8351 let root = root_of(&dir);
8352 let repo = root.join("repo");
8353 init_repo_with_a_commit(&repo);
8354
8355 let started =
8356 Core::start_for_test(spec(vec![root]), Duration::from_secs(3600), tick_rx).discovered();
8357 let core = started.core;
8358 let key = core.snapshot().entities[0].key.clone();
8359
8360 backdate_polled_entries(&repo);
8361
8362 tick_tx
8365 .send(Instant::now())
8366 .expect("send the baseline tick");
8367 wait_for(
8368 "a tick sent on the real channel to reach the poll sweep",
8369 || core.poll_sweep_count_for_test() >= 1,
8370 );
8371 assert!(core.poll_reprobed_for_test().is_empty());
8372
8373 commit_a_change(&repo, "second");
8374
8375 tick_tx
8376 .send(Instant::now())
8377 .expect("send the movement tick");
8378 wait_for(
8379 "the real tick channel to reach the poll sweep and reprobe the moved entity",
8380 || core.poll_reprobed_for_test() == vec![key.clone()],
8381 );
8382 drop(tick_tx);
8383 }
8384
8385 #[test]
8393 fn poll_reprobe_touches_only_the_moved_entity_and_never_runs_a_status_probe() {
8394 let dir = tempfile::tempdir().expect("temp dir");
8395 let root = root_of(&dir);
8396 let repo_a = root.join("repo-a");
8397 let repo_b = root.join("repo-b");
8398 init_repo_with_a_commit(&repo_a);
8399 init_repo_with_a_commit(&repo_b);
8400
8401 let core = Core::start_discovered(spec(vec![root]));
8402 let snapshot = core.snapshot();
8403 let key_a = snapshot
8404 .entities
8405 .iter()
8406 .find(|entity| entity.key.path() == repo_a)
8407 .expect("repo-a discovered")
8408 .key
8409 .clone();
8410 let key_b = snapshot
8411 .entities
8412 .iter()
8413 .find(|entity| entity.key.path() == repo_b)
8414 .expect("repo-b discovered")
8415 .key
8416 .clone();
8417
8418 core.refresh(&[key_a.clone(), key_b.clone()]);
8419 let landed = core.settle();
8420 let entity_of = |snapshot: &Snapshot, key: &EntityKey| {
8421 snapshot
8422 .entities
8423 .iter()
8424 .find(|entity| &entity.key == key)
8425 .expect("entity present")
8426 .clone()
8427 };
8428 let a_before = entity_of(&landed, &key_a);
8429 let b_before = entity_of(&landed, &key_b);
8430 let branch_at = |entity: &EntityState| match entity.branch.settled() {
8431 Some(Settled::Known {
8432 at,
8433 value: _,
8434 stale: _,
8435 }) => *at,
8436 other => panic!("expected a landed branch, got {other:?}"),
8437 };
8438 let dirty_state = |entity: &EntityState| match entity.dirty.settled() {
8439 Some(Settled::Known { value, at, stale }) => (*value, *at, *stale),
8440 other => panic!("expected a landed dirty count, got {other:?}"),
8441 };
8442 let (a_dirty_value_before, a_dirty_at_before, a_dirty_stale_before) =
8443 dirty_state(&a_before);
8444 assert!(
8445 !a_dirty_stale_before,
8446 "the fresh refresh must land dirty as not stale"
8447 );
8448
8449 backdate_polled_entries(&repo_a);
8450
8451 backdate_polled_entries(&repo_b);
8452
8453 core.poll_once_for_test();
8454 assert!(
8455 core.poll_reprobed_for_test().is_empty(),
8456 "a first sweep has nothing to compare against, so it must report no movement"
8457 );
8458
8459 commit_a_change(&repo_a, "second");
8460 core.poll_once_for_test();
8461
8462 assert_eq!(
8463 core.poll_reprobed_for_test(),
8464 vec![key_a.clone()],
8465 "only the entity whose gitdir actually moved must be re-probed"
8466 );
8467
8468 let after = core.snapshot();
8469 let a_after = entity_of(&after, &key_a);
8470 let b_after = entity_of(&after, &key_b);
8471
8472 assert_ne!(
8473 branch_at(&a_after),
8474 branch_at(&a_before),
8475 "the moved entity's branch must carry a fresh timestamp from the re-probe"
8476 );
8477 let (a_dirty_value_after, a_dirty_at_after, a_dirty_stale_after) = dirty_state(&a_after);
8478 assert_eq!(
8479 a_dirty_value_after, a_dirty_value_before,
8480 "no status probe ran, so dirty's value must be exactly what the last real refresh \
8481 landed"
8482 );
8483 assert_eq!(
8484 a_dirty_at_after, a_dirty_at_before,
8485 "no status probe ran, so dirty's timestamp must be untouched, only its stale flag \
8486 set"
8487 );
8488 assert!(
8489 a_dirty_stale_after,
8490 "the moved entity's dirty cell must go stale on poll evidence"
8491 );
8492
8493 assert_eq!(
8494 branch_at(&b_after),
8495 branch_at(&b_before),
8496 "the untouched entity's branch must be exactly as the prior refresh left it"
8497 );
8498 let (b_dirty_value_after, b_dirty_at_after, b_dirty_stale_after) = dirty_state(&b_after);
8499 let (b_dirty_value_before, b_dirty_at_before, b_dirty_stale_before) =
8500 dirty_state(&b_before);
8501 assert_eq!(b_dirty_value_after, b_dirty_value_before);
8502 assert_eq!(b_dirty_at_after, b_dirty_at_before);
8503 assert_eq!(
8504 b_dirty_stale_after, b_dirty_stale_before,
8505 "an entity the sweep found unmoved must never go stale"
8506 );
8507 }
8508
8509 #[test]
8514 fn poll_detects_an_attached_commit_through_index_while_head_itself_never_moves() {
8515 let dir = tempfile::tempdir().expect("temp dir");
8516 let root = root_of(&dir);
8517 let repo = root.join("repo");
8518 init_repo_with_a_commit(&repo);
8519
8520 let core = Core::start_discovered(spec(vec![root]));
8521 let key = core.snapshot().entities[0].key.clone();
8522 backdate_polled_entries(&repo);
8523 core.poll_once_for_test();
8524 assert!(core.poll_reprobed_for_test().is_empty());
8525
8526 let head_path = repo.join(".git").join("HEAD");
8527 let head_mtime_before = fs::metadata(&head_path)
8528 .expect("stat HEAD")
8529 .modified()
8530 .expect("HEAD mtime");
8531
8532 commit_a_change(&repo, "second");
8533
8534 let head_mtime_after = fs::metadata(&head_path)
8535 .expect("stat HEAD")
8536 .modified()
8537 .expect("HEAD mtime");
8538 assert_eq!(
8539 head_mtime_before, head_mtime_after,
8540 "a commit on an attached HEAD must never touch HEAD itself"
8541 );
8542
8543 core.poll_once_for_test();
8544 assert_eq!(
8545 core.poll_reprobed_for_test(),
8546 vec![key],
8547 "the poll must still detect the attached commit, through index rather than HEAD"
8548 );
8549 }
8550
8551 #[test]
8558 fn poll_detects_a_detached_commit_through_the_per_worktree_head_file() {
8559 let dir = tempfile::tempdir().expect("temp dir");
8560 let root = root_of(&dir);
8561 let parent = root.join("parent");
8562 init_repo_with_a_commit(&parent);
8563 let worktree_path = root.join("detached-worktree");
8564 let status = Command::new("git")
8565 .arg("-C")
8566 .arg(&parent)
8567 .args([
8568 "worktree",
8569 "add",
8570 "--detach",
8571 worktree_path.to_str().expect("utf8 path"),
8572 ])
8573 .status()
8574 .expect("run git worktree add");
8575 assert!(status.success());
8576
8577 let core = Core::start_discovered(spec(vec![root]));
8578 let snapshot = core.snapshot();
8579 let worktree_key = snapshot
8580 .entities
8581 .iter()
8582 .find(|entity| matches!(entity.kind, Kind::Worktree))
8583 .expect("worktree discovered")
8584 .key
8585 .clone();
8586
8587 backdate_polled_entries(&parent);
8588 backdate_polled_entries(&worktree_path);
8589
8590 core.poll_once_for_test();
8591 assert!(core.poll_reprobed_for_test().is_empty());
8592
8593 let worktree_head_path = parent
8594 .join(".git")
8595 .join("worktrees")
8596 .join("detached-worktree")
8597 .join("HEAD");
8598 let head_mtime_before = fs::metadata(&worktree_head_path)
8599 .expect("stat the per-worktree HEAD")
8600 .modified()
8601 .expect("HEAD mtime");
8602
8603 commit_a_change(&worktree_path, "on the detached worktree");
8604
8605 let head_mtime_after = fs::metadata(&worktree_head_path)
8606 .expect("stat the per-worktree HEAD")
8607 .modified()
8608 .expect("HEAD mtime");
8609 assert_ne!(
8610 head_mtime_before, head_mtime_after,
8611 "a commit on a detached HEAD must write the new object id straight into its own \
8612 HEAD file"
8613 );
8614
8615 core.poll_once_for_test();
8616 assert_eq!(
8617 core.poll_reprobed_for_test(),
8618 vec![worktree_key],
8619 "the poll must detect the detached commit via the per-worktree HEAD file"
8620 );
8621 }
8622
8623 #[test]
8630 fn snapshot_ages_a_freshly_landed_dirty_cell_stale_once_status_stale_after_has_elapsed() {
8631 let dir = tempfile::tempdir().expect("temp dir");
8632 let root = root_of(&dir);
8633 let repo = root.join("repo");
8634 init_repo_with_a_commit(&repo);
8635
8636 let mut short_lived = spec(vec![root]);
8637 short_lived.status_stale_after = Duration::from_nanos(1);
8638 let core = Core::start_discovered(short_lived);
8639 let key = core.snapshot().entities[0].key.clone();
8640 core.refresh(std::slice::from_ref(&key));
8641 core.settle();
8642
8643 let aged = core.snapshot();
8644 match aged.entities[0].dirty.settled() {
8645 Some(Settled::Known {
8646 stale: true,
8647 value: _,
8648 at: _,
8649 }) => {}
8650 other => panic!(
8651 "expected a landed dirty cell to have already aged past a one-nanosecond \
8652 threshold, got {other:?}"
8653 ),
8654 }
8655 }
8656
8657 #[test]
8661 fn snapshot_leaves_a_freshly_landed_dirty_cell_fresh_under_a_large_status_stale_after() {
8662 let dir = tempfile::tempdir().expect("temp dir");
8663 let root = root_of(&dir);
8664 let repo = root.join("repo");
8665 init_repo_with_a_commit(&repo);
8666
8667 let core = Core::start_discovered(spec(vec![root]));
8668 let key = core.snapshot().entities[0].key.clone();
8669 core.refresh(std::slice::from_ref(&key));
8670 core.settle();
8671
8672 let fresh = core.snapshot();
8673 match fresh.entities[0].dirty.settled() {
8674 Some(Settled::Known {
8675 stale: false,
8676 value: _,
8677 at: _,
8678 }) => {}
8679 other => panic!("expected a freshly landed dirty cell to stay fresh, got {other:?}"),
8680 }
8681 }
8682
8683 #[test]
8689 fn hidden_submodules_are_never_polled_but_shown_ones_are() {
8690 let dir = tempfile::tempdir().expect("temp dir");
8691 let root = root_of(&dir);
8692 let parent = root.join("parent");
8693 init_repo_with_a_commit(&parent);
8694 fs::write(
8695 parent.join(".gitmodules"),
8696 "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
8697 )
8698 .expect("write .gitmodules");
8699 let submodule_path = parent.join("vendor").join("lib");
8700 init_repo_with_a_commit(&submodule_path);
8701
8702 let mut hidden_spec = spec(vec![root.clone()]);
8703 hidden_spec.show_submodules = false;
8704 let hidden_core = Core::start_discovered(hidden_spec);
8705 let hidden_submodule_key = hidden_core
8710 .snapshot()
8711 .entities
8712 .iter()
8713 .find(|entity| matches!(entity.kind, Kind::Submodule))
8714 .expect("the submodule is discovered regardless of show_submodules")
8715 .key
8716 .clone();
8717 backdate_polled_entries(&submodule_path);
8718 hidden_core.poll_once_for_test();
8719 commit_a_change(&submodule_path, "into the hidden submodule");
8720 hidden_core.poll_once_for_test();
8721 assert!(
8722 !hidden_core
8723 .poll_reprobed_for_test()
8724 .contains(&hidden_submodule_key),
8725 "a hidden Submodule must never be re-probed by the poll, since it was never \
8726 polled at all"
8727 );
8728 drop(hidden_core);
8729
8730 let mut shown_spec = spec(vec![root]);
8731 shown_spec.show_submodules = true;
8732 let shown_core = Core::start_discovered(shown_spec);
8733 let submodule_key = shown_core
8734 .snapshot()
8735 .entities
8736 .iter()
8737 .find(|entity| matches!(entity.kind, Kind::Submodule))
8738 .expect("the submodule is discovered regardless of show_submodules")
8739 .key
8740 .clone();
8741 backdate_polled_entries(&submodule_path);
8742 shown_core.poll_once_for_test();
8743 commit_a_change(&submodule_path, "into the shown submodule");
8744 shown_core.poll_once_for_test();
8745 assert_eq!(
8746 shown_core.poll_reprobed_for_test(),
8747 vec![submodule_key],
8748 "a shown Submodule must be polled and re-probed exactly like any other row"
8749 );
8750 }
8751
8752 #[test]
8758 fn pause_cancels_every_in_flight_entity_and_releases_a_pending_settle() {
8759 let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
8760 let dir = tempfile::tempdir().expect("temp dir");
8761 let root = root_of(&dir);
8762 let repo = root.join("repo");
8763 init_repo_with_a_commit(&repo);
8764
8765 let started =
8766 Core::start_for_test(spec(vec![root]), Duration::from_secs(3600), tick_rx).discovered();
8767 let core = started.core;
8768 let key = settle_launch(&core).entities[0].key.clone();
8770 let cancel = core.begin_untracked_probe_for_test(&key);
8771 assert!(!cancel.load(Ordering::Acquire));
8772
8773 core.pause();
8774 let settled = core.settle();
8775
8776 assert!(
8777 cancel.load(Ordering::Acquire),
8778 "pause should cancel the entity that was in flight"
8779 );
8780 assert!(settled.entities[0].branch.is_in_flight());
8781 drop(tick_tx);
8782 }
8783
8784 #[test]
8793 fn a_launch_is_one_generation_over_every_row_its_own_walk_found() {
8794 let dir = tempfile::tempdir().expect("temp dir");
8795 let root = root_of(&dir);
8796 init_repo_with_a_commit(&root.join("first"));
8797 init_repo_with_a_commit(&root.join("second"));
8798
8799 let (_core, launched) = started_and_settled(spec(vec![root]));
8800
8801 assert_eq!(
8802 launched.generation,
8803 Generation::default().successor(),
8804 "a launch must settle on the first Generation a fresh `Core` mints; a second \
8805 walk of the same tree would be a second Generation"
8806 );
8807 let mut named: Vec<String> = launched
8808 .entities
8809 .iter()
8810 .filter(|entity| entity.branch.settled().is_some())
8811 .map(|entity| entity.name.to_string())
8812 .collect();
8813 named.sort();
8814 assert_eq!(
8815 named,
8816 vec!["first".to_string(), "second".to_string()],
8817 "that one Generation must cover every row its own walk found, or the walk it \
8818 saved would have to be paid by a second one"
8819 );
8820 }
8821
8822 #[test]
8829 fn dropping_a_core_cancels_every_entity_it_still_has_in_flight() {
8830 let dir = tempfile::tempdir().expect("temp dir");
8831 let root = root_of(&dir);
8832 init_repo_with_a_commit(&root.join("repo"));
8833
8834 let (core, launched) = started_and_settled(spec(vec![root]));
8835 let key = launched.entities[0].key.clone();
8836 let cancel = core.begin_untracked_probe_for_test(&key);
8837 assert!(!cancel.load(Ordering::Acquire));
8838
8839 drop(core);
8840
8841 assert!(
8842 cancel.load(Ordering::Acquire),
8843 "a dropped Core must cancel the Generation it still has in flight rather than \
8844 leave it running against a Set nothing will read again"
8845 );
8846 }
8847
8848 #[test]
8878 fn a_selection_scoped_refresh_supersedes_only_the_entity_it_covers() {
8879 let dir = tempfile::tempdir().expect("temp dir");
8880 let root = root_of(&dir);
8881 init_repo_with_a_commit(&root.join("a"));
8882 init_repo_with_a_commit(&root.join("b"));
8883
8884 let (core, snapshot) = started_and_settled(spec(vec![root]));
8885 let key_a = snapshot
8886 .entities
8887 .iter()
8888 .find(|entity| &*entity.name == "a")
8889 .expect("entity a discovered")
8890 .key
8891 .clone();
8892 let key_b = snapshot
8893 .entities
8894 .iter()
8895 .find(|entity| &*entity.name == "b")
8896 .expect("entity b discovered")
8897 .key
8898 .clone();
8899
8900 let older = core.begin_shared_generation_for_test(&[key_a.clone(), key_b.clone()]);
8904
8905 let newer = core.refresh(std::slice::from_ref(&key_a));
8908 assert_eq!(
8909 newer,
8910 older.generation.successor(),
8911 "the Selection-scoped refresh must be the Generation immediately after the one \
8912 still in flight, with nothing minted in between"
8913 );
8914
8915 core.wait_dispatched_for_test();
8920 assert!(
8921 older.cancels[&key_a].load(Ordering::Acquire),
8922 "the entity the new Generation covers must have its old interrupt flag set"
8923 );
8924 assert!(
8925 !older.cancels[&key_b].load(Ordering::Acquire),
8926 "an entity the new Generation does not cover must be left running, untouched"
8927 );
8928
8929 let after_refresh = core.settle();
8933
8934 let a_after_gen2 = after_refresh
8935 .entities
8936 .iter()
8937 .find(|entity| entity.key == key_a)
8938 .expect("entity a present");
8939 assert!(
8940 matches!(
8941 a_after_gen2.branch.settled(),
8942 Some(Settled::Known {
8943 value: Head::Branch { .. },
8944 at: _,
8945 stale: _
8946 })
8947 ),
8948 "the newer Generation's real probe should have written A's cell by now"
8949 );
8950
8951 core.apply_probe_result_for_test(
8955 &key_a,
8956 older.generation,
8957 Settled::Known {
8958 value: Head::Branch {
8959 name: Arc::from("stale-from-generation-one"),
8960 commit: gix::hash::Kind::Sha1.null(),
8961 },
8962 at: Timestamp::now(),
8963 stale: false,
8964 },
8965 );
8966 let after_stale_write = core.snapshot();
8967 let a_final = after_stale_write
8968 .entities
8969 .iter()
8970 .find(|entity| entity.key == key_a)
8971 .expect("entity a present");
8972 match a_final.branch.settled() {
8973 Some(Settled::Known {
8974 value: Head::Branch { name, .. },
8975 at: _,
8976 stale: _,
8977 }) => assert_ne!(
8978 &**name, "stale-from-generation-one",
8979 "a lower-Generation result must be dropped at the cell it would write"
8980 ),
8981 other => panic!("expected A to still hold the newer Generation's value, got {other:?}"),
8982 }
8983
8984 core.apply_probe_result_for_test(
8987 &key_b,
8988 older.generation,
8989 Settled::Known {
8990 value: Head::Branch {
8991 name: Arc::from("b-generation-one-result"),
8992 commit: gix::hash::Kind::Sha1.null(),
8993 },
8994 at: Timestamp::now(),
8995 stale: false,
8996 },
8997 );
8998 let final_snapshot = core.snapshot();
8999 let b_final = final_snapshot
9000 .entities
9001 .iter()
9002 .find(|entity| entity.key == key_b)
9003 .expect("entity b present");
9004 match b_final.branch.settled() {
9005 Some(Settled::Known {
9006 value: Head::Branch { name, .. },
9007 at: _,
9008 stale: _,
9009 }) => assert_eq!(
9010 &**name, "b-generation-one-result",
9011 "an entity the new Generation never covered must still accept its own result"
9012 ),
9013 other => {
9014 panic!("expected B's un-superseded older result to be accepted, got {other:?}")
9015 }
9016 }
9017 }
9018
9019 #[test]
9024 fn the_deadline_sweep_keeps_already_settled_cells_and_only_times_out_what_is_still_loading() {
9025 let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
9026 let dir = tempfile::tempdir().expect("temp dir");
9027 let root = root_of(&dir);
9028 init_repo_with_a_commit(&root.join("a"));
9029 init_repo_with_a_commit(&root.join("b"));
9030
9031 let mut spec = spec(vec![root]);
9032 spec.generation_deadline = Duration::ZERO;
9033 let started = Core::start_for_test(spec, Duration::from_secs(3600), tick_rx).discovered();
9034 let core = started.core;
9035 let snapshot = settle_launch(&core);
9038 let key_a = snapshot
9039 .entities
9040 .iter()
9041 .find(|entity| &*entity.name == "a")
9042 .expect("entity a discovered")
9043 .key
9044 .clone();
9045 let key_b = snapshot
9046 .entities
9047 .iter()
9048 .find(|entity| &*entity.name == "b")
9049 .expect("entity b discovered")
9050 .key
9051 .clone();
9052
9053 let a_settled = core.probe_now(&key_a);
9056 let a_value_before = match a_settled.branch.settled() {
9057 Some(Settled::Known {
9058 value: Head::Branch { name, .. },
9059 at: _,
9060 stale: _,
9061 }) => Arc::clone(name),
9062 other => panic!("expected A's synchronous probe to settle a branch, got {other:?}"),
9063 };
9064
9065 let cancel_b = core.begin_untracked_probe_for_test(&key_b);
9069 let before_tick = core.snapshot();
9070 let b_before = before_tick
9071 .entities
9072 .iter()
9073 .find(|entity| entity.key == key_b)
9074 .expect("entity b present");
9075 assert!(
9076 b_before.branch.is_in_flight(),
9077 "B must be mid-flight when the sweep fires; that is the only shape the sweep \
9078 may touch"
9079 );
9080 assert!(
9081 matches!(
9082 b_before.branch.settled(),
9083 Some(Settled::Known {
9084 value: _,
9085 at: _,
9086 stale: _
9087 })
9088 ),
9089 "B still carries launch's own answer here, so the Unknown below is a write the \
9090 sweep made rather than a cell that was already empty, got {:?}",
9091 b_before.branch.settled()
9092 );
9093
9094 tick_tx.send(Instant::now()).expect("send one tick");
9095 let after_sweep = core.settle();
9096
9097 let a_after = after_sweep
9098 .entities
9099 .iter()
9100 .find(|entity| entity.key == key_a)
9101 .expect("entity a present");
9102 match a_after.branch.settled() {
9103 Some(Settled::Known {
9104 value: Head::Branch { name, .. },
9105 at: _,
9106 stale: _,
9107 }) => assert_eq!(
9108 name, &a_value_before,
9109 "an already-settled cell must keep its value when the deadline sweep runs, not be blanked"
9110 ),
9111 other => panic!("expected A's settled value to survive the sweep, got {other:?}"),
9112 }
9113
9114 let b_after = after_sweep
9115 .entities
9116 .iter()
9117 .find(|entity| entity.key == key_b)
9118 .expect("entity b present");
9119 assert!(matches!(
9120 b_after.branch.settled(),
9121 Some(Settled::Unknown(Unknown::TimedOut))
9122 ));
9123 assert!(
9124 !cancel_b.load(Ordering::Acquire),
9125 "the deadline sweep marks a cell Unknown; it never sets the entity's own \
9126 cancel flag, since the underlying probe (nonexistent here) is left to keep running"
9127 );
9128 }
9129
9130 #[test]
9138 fn the_deadline_sweep_times_out_a_worktrees_outstanding_state_but_leaves_a_repos_not_applicable_one_alone()
9139 {
9140 let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
9141 let dir = tempfile::tempdir().expect("temp dir");
9142 let root = root_of(&dir);
9143 let parent = root.join("parent");
9144 init_repo_with_a_commit(&parent);
9145 let worktree_path = root.join("feature-worktree");
9146 git(
9147 &parent,
9148 &[
9149 "worktree",
9150 "add",
9151 "-b",
9152 "feature",
9153 worktree_path.to_str().expect("utf8 path"),
9154 ],
9155 );
9156
9157 let mut spec = spec(vec![root]);
9158 spec.generation_deadline = Duration::ZERO;
9159 let started = Core::start_for_test(spec, Duration::from_secs(3600), tick_rx).discovered();
9160 let core = started.core;
9161 let snapshot = settle_launch(&core);
9168 let repo_key = snapshot
9169 .entities
9170 .iter()
9171 .find(|entity| matches!(entity.kind, Kind::Repo))
9172 .expect("repo entity present")
9173 .key
9174 .clone();
9175 let worktree_key = snapshot
9176 .entities
9177 .iter()
9178 .find(|entity| matches!(entity.kind, Kind::Worktree))
9179 .expect("worktree entity present")
9180 .key
9181 .clone();
9182
9183 core.begin_untracked_probe_for_test(&repo_key);
9189 core.begin_untracked_probe_for_test(&worktree_key);
9190
9191 tick_tx.send(Instant::now()).expect("send one tick");
9192 let after_sweep = core.settle();
9193
9194 let worktree_after = after_sweep
9195 .entities
9196 .iter()
9197 .find(|entity| entity.key == worktree_key)
9198 .expect("worktree entity present");
9199 assert!(
9200 matches!(
9201 worktree_after.state.settled(),
9202 Some(Settled::Unknown(Unknown::TimedOut))
9203 ),
9204 "expected the outstanding state cell to time out, got {:?}",
9205 worktree_after.state.settled()
9206 );
9207
9208 let repo_after = after_sweep
9209 .entities
9210 .iter()
9211 .find(|entity| entity.key == repo_key)
9212 .expect("repo entity present");
9213 assert!(
9214 matches!(repo_after.state.settled(), Some(Settled::NotApplicable)),
9215 "a Repo's Not applicable state must survive the sweep untouched, got {:?}",
9216 repo_after.state.settled()
9217 );
9218 }
9219
9220 #[test]
9225 fn the_deadline_sweeps_poll_never_touches_an_entitys_action_receipt() {
9226 let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
9227 let dir = tempfile::tempdir().expect("temp dir");
9228 let root = root_of(&dir);
9229 let repo = root.join("repo");
9230 init_repo_with_a_commit(&repo);
9231
9232 let mut spec = spec(vec![root]);
9233 spec.generation_deadline = Duration::ZERO;
9234 let started = Core::start_for_test(spec, Duration::from_secs(3600), tick_rx).discovered();
9235 let core = started.core;
9236 let key = settle_launch(&core).entities[0].key.clone();
9238
9239 let receipt = crate::entity::ActionReceipt {
9240 label: Arc::from("reinstall"),
9241 steps: Arc::from(vec![crate::entity::StepResult {
9242 label: Arc::from("pnpm install"),
9243 outcome: crate::entity::StepOutcome::Ok,
9244 output: Arc::from(&b""[..]),
9245 elapsed: Duration::from_millis(1),
9246 elision: None,
9247 shell: false,
9248 interactive: false,
9249 }]),
9250 skip: None,
9251 finished_at: Timestamp::now(),
9252 running: None,
9253 };
9254 core.set_last_action_for_test(&key, receipt.clone());
9255
9256 core.begin_untracked_probe_for_test(&key);
9259 tick_tx.send(Instant::now()).expect("send one tick");
9260 let after = core.settle();
9261
9262 let entity = after
9263 .entities
9264 .iter()
9265 .find(|entity| entity.key == key)
9266 .expect("entity present");
9267 assert!(
9268 matches!(
9269 entity.branch.settled(),
9270 Some(Settled::Unknown(Unknown::TimedOut))
9271 ),
9272 "sanity check: the sweep must have actually timed out the in-flight cell, got {:?}",
9273 entity.branch.settled()
9274 );
9275 assert_eq!(entity.last_action, Some(receipt));
9276 }
9277
9278 #[test]
9288 fn a_cancelled_probe_never_opens_the_repository_at_all() {
9289 let cancel = AtomicBool::new(true);
9290
9291 let outcome = probe_branch(
9292 Path::new("/nonexistent/nowhere-at-all"),
9293 None,
9294 Kind::Repo,
9295 &cancel,
9296 );
9297
9298 assert!(
9299 outcome.is_none(),
9300 "a probe observing cancellation before its first read must do no work \
9301 at all, not attempt the read and fail having tried it"
9302 );
9303 }
9304
9305 #[test]
9315 fn classify_status_result_drops_an_error_once_cancel_reads_true() {
9316 let cancel = AtomicBool::new(true);
9317
9318 let outcome = classify_status_result(
9319 Err(crate::git::ProbeError::Status(Arc::from("boom"))),
9320 &cancel,
9321 );
9322
9323 assert!(
9324 outcome.is_none(),
9325 "an error alongside a cancel flag already set must read as cancelled, not \
9326 Failed, got {outcome:?}"
9327 );
9328 }
9329
9330 #[test]
9333 fn classify_status_result_settles_failed_when_cancel_never_fired() {
9334 let cancel = AtomicBool::new(false);
9335
9336 let outcome = classify_status_result(
9337 Err(crate::git::ProbeError::Status(Arc::from("boom"))),
9338 &cancel,
9339 );
9340
9341 assert!(
9342 matches!(outcome, Some(Settled::Failed(git::ProbeError::Status(_)))),
9343 "a genuine error with no cancellation must settle Failed, got {outcome:?}"
9344 );
9345 }
9346
9347 #[test]
9356 fn classify_status_result_drops_an_ok_once_cancel_reads_true() {
9357 let cancel = AtomicBool::new(true);
9358
9359 let outcome = classify_status_result(Ok(DirtyCounts::default()), &cancel);
9360
9361 assert!(
9362 outcome.is_none(),
9363 "an Ok value that raced ahead of a cancel flag now set must read as cancelled, \
9364 not be settled Known, got {outcome:?}"
9365 );
9366 }
9367
9368 #[test]
9371 fn classify_status_result_settles_known_when_cancel_never_fired() {
9372 let cancel = AtomicBool::new(false);
9373 let counts = DirtyCounts {
9374 modified: 1,
9375 untracked: 2,
9376 deleted: 3,
9377 };
9378
9379 let outcome = classify_status_result(Ok(counts), &cancel);
9380
9381 assert!(
9382 matches!(
9383 outcome,
9384 Some(Settled::Known {
9385 value,
9386 at: _,
9387 stale: _
9388 }) if value == counts
9389 ),
9390 "a genuine completed read with no cancellation must settle Known, got {outcome:?}"
9391 );
9392 }
9393
9394 #[test]
9400 fn a_linked_worktree_is_its_own_entity_and_never_doubles_as_a_repo() {
9401 let dir = tempfile::tempdir().expect("temp dir");
9402 let root = root_of(&dir);
9403 let parent = root.join("parent");
9404 init_repo_with_a_commit(&parent);
9405 let worktree_path = root.join("feature-worktree");
9406 let status = Command::new("git")
9407 .arg("-C")
9408 .arg(&parent)
9409 .args([
9410 "worktree",
9411 "add",
9412 "-b",
9413 "feature",
9414 worktree_path.to_str().expect("utf8 path"),
9415 ])
9416 .status()
9417 .expect("run git worktree add");
9418 assert!(status.success());
9419
9420 let core = Core::start_discovered(spec(vec![root]));
9421 let snapshot = core.snapshot();
9422
9423 assert_eq!(
9424 snapshot.entities.len(),
9425 2,
9426 "expected the parent plus one Worktree, not two Repos"
9427 );
9428 let repo_count = snapshot
9429 .entities
9430 .iter()
9431 .filter(|entity| matches!(entity.kind, Kind::Repo))
9432 .count();
9433 let worktree_count = snapshot
9434 .entities
9435 .iter()
9436 .filter(|entity| matches!(entity.kind, Kind::Worktree))
9437 .count();
9438 assert_eq!(
9439 repo_count, 1,
9440 "the parent must be counted as exactly one Repo"
9441 );
9442 assert_eq!(
9443 worktree_count, 1,
9444 "the linked worktree must be counted as exactly one Worktree"
9445 );
9446
9447 let worktree_entity = snapshot
9448 .entities
9449 .iter()
9450 .find(|entity| matches!(entity.kind, Kind::Worktree))
9451 .expect("worktree entity present");
9452 let repo_entity = snapshot
9453 .entities
9454 .iter()
9455 .find(|entity| matches!(entity.kind, Kind::Repo))
9456 .expect("repo entity present");
9457 assert_eq!(worktree_entity.common_dir, repo_entity.common_dir);
9458
9459 let repo_branch = core.probe_now(&repo_entity.key);
9462 let worktree_branch = core.probe_now(&worktree_entity.key);
9463 match (
9464 repo_branch.branch.settled(),
9465 worktree_branch.branch.settled(),
9466 ) {
9467 (
9468 Some(Settled::Known {
9469 value:
9470 Head::Branch {
9471 name: repo_name, ..
9472 },
9473 at: _,
9474 stale: _,
9475 }),
9476 Some(Settled::Known {
9477 value:
9478 Head::Branch {
9479 name: worktree_name,
9480 ..
9481 },
9482 at: _,
9483 stale: _,
9484 }),
9485 ) => {
9486 assert_ne!(repo_name, worktree_name);
9487 assert_eq!(&**worktree_name, "feature");
9488 }
9489 other => panic!("expected both entities to read an attached branch, got {other:?}"),
9490 }
9491 }
9492
9493 #[test]
9497 fn a_worktrees_branch_that_is_an_ancestor_of_the_default_branch_reads_merged_after_a_refresh() {
9498 let dir = tempfile::tempdir().expect("temp dir");
9499 let root = root_of(&dir);
9500 let parent = root.join("parent");
9501 init_repo_with_a_commit(&parent);
9502 git(
9503 &parent,
9504 &[
9505 "remote",
9506 "add",
9507 "origin",
9508 "https://example.invalid/repo.git",
9509 ],
9510 );
9511 let sha = head_sha(&parent);
9512 git(&parent, &["update-ref", "refs/remotes/origin/main", &sha]);
9513 let worktree_path = root.join("feature-worktree");
9514 git(
9515 &parent,
9516 &[
9517 "worktree",
9518 "add",
9519 "-b",
9520 "feature",
9521 worktree_path.to_str().expect("utf8 path"),
9522 ],
9523 );
9524
9525 let core = Core::start_discovered(spec(vec![root]));
9526 let keys: Vec<EntityKey> = core
9527 .snapshot()
9528 .entities
9529 .iter()
9530 .map(|entity| entity.key.clone())
9531 .collect();
9532
9533 core.refresh(&keys);
9534 let settled = core.settle();
9535
9536 let worktree_entity = settled
9537 .entities
9538 .iter()
9539 .find(|entity| matches!(entity.kind, Kind::Worktree))
9540 .expect("worktree entity present");
9541 assert!(
9542 matches!(
9543 worktree_entity.state.settled(),
9544 Some(Settled::Known {
9545 value: WorktreeState::Merged,
9546 at: _,
9547 stale: _
9548 })
9549 ),
9550 "expected the worktree, at the same commit as the default branch, to read Merged, got {:?}",
9551 worktree_entity.state.settled()
9552 );
9553 }
9554
9555 #[test]
9564 fn a_squash_merged_worktree_branch_reads_merged_after_a_refresh() {
9565 let dir = tempfile::tempdir().expect("temp dir");
9566 let root = root_of(&dir);
9567 let parent = root.join("parent");
9568 init_repo_with_a_commit(&parent);
9569 git(
9570 &parent,
9571 &[
9572 "remote",
9573 "add",
9574 "origin",
9575 "https://example.invalid/repo.git",
9576 ],
9577 );
9578 let worktree_path = root.join("feature-worktree");
9579 git(
9580 &parent,
9581 &[
9582 "worktree",
9583 "add",
9584 "-b",
9585 "feature",
9586 worktree_path.to_str().expect("utf8 path"),
9587 ],
9588 );
9589 fs::write(worktree_path.join("a.txt"), "one\n").expect("write a.txt");
9590 git(&worktree_path, &["add", "a.txt"]);
9591 git(&worktree_path, &["commit", "-m", "add a"]);
9592 fs::write(worktree_path.join("b.txt"), "two\n").expect("write b.txt");
9593 git(&worktree_path, &["add", "b.txt"]);
9594 git(&worktree_path, &["commit", "-m", "add b"]);
9595 let feature_sha = head_sha(&worktree_path);
9596
9597 git(&parent, &["merge", "--squash", "feature"]);
9600 git(&parent, &["commit", "-m", "squashed feature"]);
9601 let main_sha = head_sha(&parent);
9602 git(
9603 &parent,
9604 &["update-ref", "refs/remotes/origin/main", &main_sha],
9605 );
9606
9607 git(&parent, &["config", "branch.feature.remote", "origin"]);
9610 git(
9611 &parent,
9612 &["config", "branch.feature.merge", "refs/heads/feature"],
9613 );
9614 git(
9615 &parent,
9616 &["update-ref", "refs/remotes/origin/feature", &feature_sha],
9617 );
9618
9619 let core = Core::start_discovered(spec(vec![root]));
9620 let keys: Vec<EntityKey> = core
9621 .snapshot()
9622 .entities
9623 .iter()
9624 .map(|entity| entity.key.clone())
9625 .collect();
9626
9627 core.refresh(&keys);
9628 let settled = core.settle();
9629
9630 let worktree_entity = settled
9631 .entities
9632 .iter()
9633 .find(|entity| matches!(entity.kind, Kind::Worktree))
9634 .expect("worktree entity present");
9635 assert!(
9636 matches!(
9637 worktree_entity.state.settled(),
9638 Some(Settled::Known {
9639 value: WorktreeState::Merged,
9640 at: _,
9641 stale: _
9642 })
9643 ),
9644 "expected a squash-merged worktree branch to read Merged, got {:?}",
9645 worktree_entity.state.settled()
9646 );
9647 }
9648
9649 #[test]
9657 fn patch_equivalence_never_runs_for_an_entity_ancestry_already_settled() {
9658 let dir = tempfile::tempdir().expect("temp dir");
9659 let root = root_of(&dir);
9660 let parent = root.join("parent");
9661 init_repo_with_a_commit(&parent);
9662 git(
9663 &parent,
9664 &[
9665 "remote",
9666 "add",
9667 "origin",
9668 "https://example.invalid/repo.git",
9669 ],
9670 );
9671 let sha = head_sha(&parent);
9672 git(&parent, &["update-ref", "refs/remotes/origin/main", &sha]);
9673 let worktree_path = root.join("feature-worktree");
9674 git(
9675 &parent,
9676 &[
9677 "worktree",
9678 "add",
9679 "-b",
9680 "feature",
9681 worktree_path.to_str().expect("utf8 path"),
9682 ],
9683 );
9684
9685 let (core, launched) = started_and_settled(spec(vec![root]));
9686 let keys: Vec<EntityKey> = launched
9687 .entities
9688 .iter()
9689 .map(|entity| entity.key.clone())
9690 .collect();
9691
9692 core.refresh(&keys);
9693 let settled = core.settle();
9694
9695 let worktree_entity = settled
9696 .entities
9697 .iter()
9698 .find(|entity| matches!(entity.kind, Kind::Worktree))
9699 .expect("worktree entity present");
9700 assert!(
9701 matches!(
9702 worktree_entity.state.settled(),
9703 Some(Settled::Known {
9704 value: WorktreeState::Merged,
9705 at: _,
9706 stale: _
9707 })
9708 ),
9709 "expected ancestry alone to settle Merged here, got {:?}",
9710 worktree_entity.state.settled()
9711 );
9712 assert_eq!(
9713 core.patch_identity_reads_for_test(),
9714 0,
9715 "ancestry already settled this entity, so patch equivalence's shared \
9716 scan must never run for its common dir at all"
9717 );
9718 }
9719
9720 #[test]
9728 fn a_full_refresh_reaching_patch_equivalence_writes_no_loose_objects() {
9729 let dir = tempfile::tempdir().expect("temp dir");
9730 let root = root_of(&dir);
9731 let parent = root.join("parent");
9732 init_repo_with_a_commit(&parent);
9733 git(
9734 &parent,
9735 &[
9736 "remote",
9737 "add",
9738 "origin",
9739 "https://example.invalid/repo.git",
9740 ],
9741 );
9742 let worktree_path = root.join("feature-worktree");
9743 git(
9744 &parent,
9745 &[
9746 "worktree",
9747 "add",
9748 "-b",
9749 "feature",
9750 worktree_path.to_str().expect("utf8 path"),
9751 ],
9752 );
9753 fs::write(worktree_path.join("a.txt"), "one\n").expect("write a.txt");
9754 git(&worktree_path, &["add", "a.txt"]);
9755 git(&worktree_path, &["commit", "-m", "add a"]);
9756 fs::write(worktree_path.join("b.txt"), "two\n").expect("write b.txt");
9757 git(&worktree_path, &["add", "b.txt"]);
9758 git(&worktree_path, &["commit", "-m", "add b"]);
9759 let feature_sha = head_sha(&worktree_path);
9760
9761 git(&parent, &["merge", "--squash", "feature"]);
9762 git(&parent, &["commit", "-m", "squashed feature"]);
9763 let main_sha = head_sha(&parent);
9764 git(
9765 &parent,
9766 &["update-ref", "refs/remotes/origin/main", &main_sha],
9767 );
9768 git(&parent, &["config", "branch.feature.remote", "origin"]);
9769 git(
9770 &parent,
9771 &["config", "branch.feature.merge", "refs/heads/feature"],
9772 );
9773 git(
9774 &parent,
9775 &["update-ref", "refs/remotes/origin/feature", &feature_sha],
9776 );
9777
9778 let core = Core::start_discovered(spec(vec![root]));
9779 let keys: Vec<EntityKey> = core
9780 .snapshot()
9781 .entities
9782 .iter()
9783 .map(|entity| entity.key.clone())
9784 .collect();
9785
9786 let before = loose_object_count(&parent);
9787 core.refresh(&keys);
9788 let settled = core.settle();
9789 let after = loose_object_count(&parent);
9790
9791 let worktree_entity = settled
9792 .entities
9793 .iter()
9794 .find(|entity| matches!(entity.kind, Kind::Worktree))
9795 .expect("worktree entity present");
9796 assert!(
9797 matches!(
9798 worktree_entity.state.settled(),
9799 Some(Settled::Known {
9800 value: WorktreeState::Merged,
9801 at: _,
9802 stale: _
9803 })
9804 ),
9805 "expected this refresh to actually reach patch equivalence and settle \
9806 Merged, got {:?}",
9807 worktree_entity.state.settled()
9808 );
9809 assert_eq!(
9810 before, after,
9811 "a full refresh reaching patch equivalence must never write a loose \
9812 object to the repository"
9813 );
9814 }
9815
9816 #[test]
9824 fn a_diverged_worktree_with_a_live_upstream_and_genuinely_unmerged_work_settles_active_after_a_refresh()
9825 {
9826 let dir = tempfile::tempdir().expect("temp dir");
9827 let root = root_of(&dir);
9828 let parent = root.join("parent");
9829 init_repo_with_a_commit(&parent);
9830 let base_sha = head_sha(&parent);
9831 git(
9832 &parent,
9833 &[
9834 "remote",
9835 "add",
9836 "origin",
9837 "https://example.invalid/repo.git",
9838 ],
9839 );
9840 git(
9841 &parent,
9842 &["update-ref", "refs/remotes/origin/main", &base_sha],
9843 );
9844 let worktree_path = root.join("feature-worktree");
9845 git(
9846 &parent,
9847 &[
9848 "worktree",
9849 "add",
9850 "-b",
9851 "feature",
9852 worktree_path.to_str().expect("utf8 path"),
9853 ],
9854 );
9855 fs::write(worktree_path.join("feature.txt"), "unmerged work\n").expect("write feature.txt");
9858 git(&worktree_path, &["add", "feature.txt"]);
9859 git(&worktree_path, &["commit", "-m", "unmerged"]);
9860 let feature_sha = head_sha(&worktree_path);
9861 git(&parent, &["config", "branch.feature.remote", "origin"]);
9864 git(
9865 &parent,
9866 &["config", "branch.feature.merge", "refs/heads/feature"],
9867 );
9868 git(
9869 &parent,
9870 &["update-ref", "refs/remotes/origin/feature", &feature_sha],
9871 );
9872
9873 let core = Core::start_discovered(spec(vec![root]));
9874 let keys: Vec<EntityKey> = core
9875 .snapshot()
9876 .entities
9877 .iter()
9878 .map(|entity| entity.key.clone())
9879 .collect();
9880
9881 core.refresh(&keys);
9882 let settled = core.settle();
9883
9884 let worktree_entity = settled
9885 .entities
9886 .iter()
9887 .find(|entity| matches!(entity.kind, Kind::Worktree))
9888 .expect("worktree entity present");
9889 assert!(
9890 matches!(
9891 worktree_entity.state.settled(),
9892 Some(Settled::Known {
9893 value: WorktreeState::Active,
9894 at: _,
9895 stale: _
9896 })
9897 ),
9898 "expected genuinely unmerged work with a live upstream to settle Active, got {:?}",
9899 worktree_entity.state.settled()
9900 );
9901 }
9902
9903 #[test]
9910 fn a_submodule_is_in_the_snapshot_even_though_hidden_by_the_default_preference() {
9911 let dir = tempfile::tempdir().expect("temp dir");
9912 let root = root_of(&dir);
9913 let parent = root.join("parent");
9914 init_repo_with_a_commit(&parent);
9915 fs::write(
9916 parent.join(".gitmodules"),
9917 "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
9918 )
9919 .expect("write .gitmodules");
9920 fs::create_dir_all(parent.join("vendor").join("lib")).expect("create submodule dir");
9921
9922 let core = Core::start_discovered(spec(vec![root]));
9923 let snapshot = core.snapshot();
9924
9925 assert!(
9926 snapshot
9927 .entities
9928 .iter()
9929 .any(|entity| matches!(entity.kind, Kind::Submodule)),
9930 "a discovered Submodule must be in the snapshot even while show_submodules is off"
9931 );
9932 }
9933
9934 #[test]
9944 fn a_submodules_state_and_base_cells_stay_unknown_through_a_real_refresh() {
9945 let dir = tempfile::tempdir().expect("temp dir");
9946 let root = root_of(&dir);
9947 let parent = root.join("parent");
9948 init_repo_with_a_commit(&parent);
9949 fs::write(
9950 parent.join(".gitmodules"),
9951 "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
9952 )
9953 .expect("write .gitmodules");
9954 let submodule = parent.join("vendor").join("lib");
9955 init_repo_with_a_commit(&submodule);
9956 git(
9957 &submodule,
9958 &["remote", "add", "origin", "https://example.invalid/lib.git"],
9959 );
9960 let root_sha = head_sha(&submodule);
9961 git(&submodule, &["commit", "--allow-empty", "-m", "second"]);
9962 let tip_sha = head_sha(&submodule);
9963 git(&submodule, &["reset", "--hard", &root_sha]);
9964 git(
9965 &submodule,
9966 &["update-ref", "refs/remotes/origin/main", &tip_sha],
9967 );
9968
9969 let mut core_spec = spec(vec![root]);
9972 core_spec.show_submodules = true;
9973 let core = Core::start_discovered(core_spec);
9974 let key = core
9975 .snapshot()
9976 .entities
9977 .iter()
9978 .find(|entity| matches!(entity.kind, Kind::Submodule))
9979 .expect("a discovered Submodule")
9980 .key
9981 .clone();
9982
9983 core.refresh(std::slice::from_ref(&key));
9984 let settled = core.settle();
9985 let submodule_entity = settled
9986 .entities
9987 .iter()
9988 .find(|entity| entity.key == key)
9989 .expect("the Submodule entity");
9990
9991 assert!(
9992 matches!(
9993 submodule_entity.base.settled(),
9994 Some(Settled::Unknown(Unknown::NoDefaultBranch))
9995 ),
9996 "expected a Submodule's base to stay Unknown through a real refresh, \
9997 got {:?}",
9998 submodule_entity.base.settled()
9999 );
10000 assert!(
10001 matches!(
10002 submodule_entity.state.settled(),
10003 Some(Settled::Unknown(Unknown::NoDefaultBranch))
10004 ),
10005 "expected a Submodule's state to stay Unknown through a real refresh, \
10006 rather than settling Merged off an untrusted default branch, got {:?}",
10007 submodule_entity.state.settled()
10008 );
10009 }
10010
10011 #[test]
10016 fn a_submodules_entity_name_is_its_relative_path_not_its_basename() {
10017 let dir = tempfile::tempdir().expect("temp dir");
10018 let root = root_of(&dir);
10019 let parent = root.join("parent");
10020 init_repo_with_a_commit(&parent);
10021 fs::write(
10022 parent.join(".gitmodules"),
10023 "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
10024 )
10025 .expect("write .gitmodules");
10026 fs::create_dir_all(parent.join("vendor").join("lib")).expect("create submodule dir");
10027
10028 let core = Core::start_discovered(spec(vec![root]));
10029 let submodule = core
10030 .snapshot()
10031 .entities
10032 .into_iter()
10033 .find(|entity| matches!(entity.kind, Kind::Submodule))
10034 .expect("a discovered Submodule");
10035
10036 assert_eq!(
10037 submodule.name.as_ref(),
10038 "vendor/lib",
10039 "expected the declared relative path, not the basename `lib`"
10040 );
10041 }
10042
10043 #[test]
10052 fn an_uninitialised_submodules_probed_cells_settle_unknown_not_failed() {
10053 let dir = tempfile::tempdir().expect("temp dir");
10054 let root = root_of(&dir);
10055 let parent = root.join("parent");
10056 init_repo_with_a_commit(&parent);
10057 fs::write(
10058 parent.join(".gitmodules"),
10059 "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
10060 )
10061 .expect("write .gitmodules");
10062 let mut core_spec = spec(vec![root]);
10066 core_spec.show_submodules = true;
10067 let core = Core::start_discovered(core_spec);
10068 let key = core
10069 .snapshot()
10070 .entities
10071 .iter()
10072 .find(|entity| matches!(entity.kind, Kind::Submodule))
10073 .expect("a discovered Submodule")
10074 .key
10075 .clone();
10076
10077 core.refresh(std::slice::from_ref(&key));
10078 let settled = core.settle();
10079 let submodule = settled
10080 .entities
10081 .iter()
10082 .find(|entity| entity.key == key)
10083 .expect("the Submodule entity");
10084
10085 assert!(
10086 matches!(
10087 submodule.branch.settled(),
10088 Some(Settled::Unknown(Unknown::SubmoduleUninitialized))
10089 ),
10090 "expected branch to settle Unknown(SubmoduleUninitialized), got {:?}",
10091 submodule.branch.settled()
10092 );
10093 assert!(
10094 matches!(
10095 submodule.sync.settled(),
10096 Some(Settled::Unknown(Unknown::SubmoduleUninitialized))
10097 ),
10098 "expected sync to settle Unknown(SubmoduleUninitialized), got {:?}",
10099 submodule.sync.settled()
10100 );
10101 assert!(
10102 matches!(
10103 submodule.dirty.settled(),
10104 Some(Settled::Unknown(Unknown::SubmoduleUninitialized))
10105 ),
10106 "expected dirty to settle Unknown(SubmoduleUninitialized), got {:?}",
10107 submodule.dirty.settled()
10108 );
10109 assert_eq!(
10110 summary(submodule),
10111 RowSummary::Unknown,
10112 "expected the row's own gutter fold to read Unknown, not Failed"
10113 );
10114 }
10115
10116 #[test]
10122 fn dispatch_skips_probing_a_hidden_submodule_while_probing_the_same_one_shown() {
10123 let dir = tempfile::tempdir().expect("temp dir");
10124 let root = root_of(&dir);
10125 let parent = root.join("parent");
10126 init_repo_with_a_commit(&parent);
10127 fs::write(
10128 parent.join(".gitmodules"),
10129 "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
10130 )
10131 .expect("write .gitmodules");
10132 init_repo_with_a_commit(&parent.join("vendor").join("lib"));
10133
10134 let core = Core::start_discovered(spec(vec![root]));
10136 let key = core
10137 .snapshot()
10138 .entities
10139 .iter()
10140 .find(|entity| matches!(entity.kind, Kind::Submodule))
10141 .expect("a discovered Submodule")
10142 .key
10143 .clone();
10144
10145 core.refresh(std::slice::from_ref(&key));
10147 let while_hidden = core.settle();
10148 let hidden_entity = while_hidden
10149 .entities
10150 .iter()
10151 .find(|entity| entity.key == key)
10152 .expect("submodule entity");
10153 assert!(
10154 hidden_entity.branch.settled().is_none(),
10155 "a Submodule dispatched while hidden must never even reach probe_branch, \
10156 so its cell stays never-settled rather than holding any value at all, got {:?}",
10157 hidden_entity.branch.settled()
10158 );
10159
10160 core.set_show_submodules(true);
10164 core.refresh(std::slice::from_ref(&key));
10165 let while_shown = core.settle();
10166 let shown_entity = while_shown
10167 .entities
10168 .iter()
10169 .find(|entity| entity.key == key)
10170 .expect("submodule entity");
10171 assert!(
10172 matches!(
10173 shown_entity.branch.settled(),
10174 Some(Settled::Known {
10175 value: _,
10176 at: _,
10177 stale: _
10178 })
10179 ),
10180 "expected the same Submodule's branch to settle a real value once shown, got {:?}",
10181 shown_entity.branch.settled()
10182 );
10183 }
10184
10185 #[test]
10191 fn toggling_show_submodules_starts_no_new_generation_and_dispatches_nothing() {
10192 let dir = tempfile::tempdir().expect("temp dir");
10193 let root = root_of(&dir);
10194 init_repo_with_a_commit(&root.join("repo-a"));
10195
10196 let (core, launched) = started_and_settled(spec(vec![root]));
10198 let before = launched.generation;
10199 let dispatched_before = core.dispatch_log_for_test();
10200 assert!(
10201 !dispatched_before.is_empty(),
10202 "launch dispatched nothing, so the comparison below would hold however much a \
10203 toggle dispatched"
10204 );
10205
10206 core.set_show_submodules(true);
10207 core.set_show_submodules(false);
10208
10209 assert_eq!(
10210 core.snapshot().generation,
10211 before,
10212 "toggling show_submodules must start no Generation of its own"
10213 );
10214 assert_eq!(
10215 core.dispatch_log_for_test(),
10216 dispatched_before,
10217 "toggling show_submodules must dispatch no probe of its own, leaving the last \
10218 Generation's own log exactly as it found it"
10219 );
10220 }
10221
10222 #[test]
10229 fn a_malformed_gitmodules_file_still_fails_the_parent_while_submodules_are_hidden() {
10230 let dir = tempfile::tempdir().expect("temp dir");
10231 let root = root_of(&dir);
10232 let parent = root.join("parent");
10233 init_repo_with_a_commit(&parent);
10234 fs::write(
10235 parent.join(".gitmodules"),
10236 "[submodule \"lib\"\n\tpath = lib\n",
10237 )
10238 .expect("write malformed .gitmodules");
10239
10240 let core = Core::start_discovered(spec(vec![root]));
10241 let key = core
10242 .snapshot()
10243 .entities
10244 .iter()
10245 .find(|entity| entity.key.path() == parent)
10246 .expect("the parent entity")
10247 .key
10248 .clone();
10249 core.refresh(std::slice::from_ref(&key));
10253 let settled = core.settle();
10254 let parent_entity = settled
10255 .entities
10256 .iter()
10257 .find(|entity| entity.key == key)
10258 .expect("the parent entity");
10259
10260 assert_eq!(
10261 summary(parent_entity),
10262 RowSummary::Failed,
10263 "expected the parent to fold Failed even with Submodules hidden"
10264 );
10265 assert!(
10266 parent_entity.diagnostics.gitmodules_failed.is_some(),
10267 "expected the failure recorded in Diagnostics for the detail pane"
10268 );
10269 assert!(
10270 !settled
10271 .entities
10272 .iter()
10273 .any(|entity| matches!(entity.kind, Kind::Submodule)),
10274 "an unparseable .gitmodules yields no Submodule rows for that parent"
10275 );
10276 }
10277
10278 #[test]
10279 fn count_matches_a_plain_discoverys_entity_count() {
10280 let dir = tempfile::tempdir().expect("temp dir");
10281 let root = root_of(&dir);
10282 init_repo_with_a_commit(&root.join("one"));
10283 init_repo_with_a_commit(&root.join("two"));
10284
10285 let set = SetSpec {
10286 name: "test".to_string(),
10287 roots: vec![root],
10288 include: Vec::new(),
10289 exclude: Vec::new(),
10290 };
10291
10292 assert_eq!(discovery::count(&set), 2);
10293 }
10294
10295 #[test]
10296 fn the_slow_discovery_watcher_warns_with_the_count_reached_and_the_roots() {
10297 let progress = Arc::new(AtomicUsize::new(42));
10298 let finished = Arc::new(AtomicBool::new(false));
10299 let roots = vec![PathBuf::from("/repos/a"), PathBuf::from("/repos/b")];
10300
10301 let warning = watch_for_slow_discovery(progress, finished, roots, Duration::from_millis(1));
10302
10303 let message = warning.expect("a walk that has not finished should warn");
10304 assert!(message.contains("42"));
10305 assert!(message.contains("/repos/a"));
10306 assert!(message.contains("/repos/b"));
10307 }
10308
10309 #[test]
10310 fn the_slow_discovery_watcher_is_silent_once_the_walk_has_already_finished() {
10311 let progress = Arc::new(AtomicUsize::new(7));
10312 let finished = Arc::new(AtomicBool::new(true));
10313
10314 let warning =
10315 watch_for_slow_discovery(progress, finished, Vec::new(), Duration::from_millis(1));
10316
10317 assert!(warning.is_none());
10318 }
10319
10320 #[test]
10328 fn a_fast_discovery_leaves_no_warning_once_the_watcher_has_run() {
10329 let dir = tempfile::tempdir().expect("temp dir");
10330 let root = root_of(&dir);
10331 init_repo_with_a_commit(&root.join("repo"));
10332 let (_tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
10333
10334 let started =
10335 Core::start_for_test(spec(vec![root]), Duration::from_secs(1), tick_rx).discovered();
10336 started
10337 .discovery_watcher
10338 .join()
10339 .expect("watcher thread should not panic");
10340
10341 assert!(started.core.discovery_warning().is_none());
10342 }
10343
10344 fn gate_opened_on_signal(open: bool) -> (DiscoveryGate, Sender<()>, JoinHandle<()>) {
10352 let gate: DiscoveryGate = Arc::new((Mutex::new(open), Condvar::new()));
10353 let (returned_tx, returned_rx) = crossbeam_channel::bounded::<()>(1);
10354 let opener = thread::spawn({
10355 let gate = Arc::clone(&gate);
10356 move || {
10357 let _ = returned_rx.recv_timeout(crate::liveness::BACKSTOP);
10358 set_discovery_gate(&gate, true);
10359 }
10360 });
10361 (gate, returned_tx, opener)
10362 }
10363
10364 #[test]
10376 fn start_returns_against_an_empty_table_and_the_rows_land_when_discovery_does() {
10377 let dir = tempfile::tempdir().expect("temp dir");
10378 let root = root_of(&dir);
10379 let repo = root.join("repo");
10380 init_repo_with_a_commit(&repo);
10381 let (_tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
10382 let (gate, start_returned, opener) = gate_opened_on_signal(false);
10383
10384 let started = Core::start_for_test_gated(
10385 spec(vec![root]),
10386 Duration::from_secs(3600),
10387 discovery::ABANDON_AFTER,
10388 tick_rx,
10389 Some(Arc::clone(&gate)),
10390 );
10391 let at_start = started.core.snapshot();
10392 let key = EntityKey::new(Arc::from(repo.as_path()));
10393 started.core.hold_phase_c_for_test(&key);
10394 start_returned.send(()).expect("the opener is listening");
10395 opener.join().expect("the opener thread should not panic");
10396 let started = started.discovered();
10397
10398 assert!(
10399 at_start.entities.is_empty(),
10400 "`Core::start` must return before discovery has finished, against the empty \
10401 table a consumer draws its first frame from, got {:?}",
10402 at_start
10403 .entities
10404 .iter()
10405 .map(|entity| entity.name.to_string())
10406 .collect::<Vec<_>>()
10407 );
10408
10409 let landed = started.core.snapshot();
10410 assert_eq!(
10411 landed
10412 .entities
10413 .iter()
10414 .map(|entity| entity.name.to_string())
10415 .collect::<Vec<_>>(),
10416 vec!["repo".to_string()],
10417 "the row must land on the table as soon as discovery does"
10418 );
10419 assert!(
10420 landed.entities[0].dirty.settled().is_none() && landed.entities[0].dirty.is_in_flight(),
10421 "discovery lands the row alone: launch's own Generation is already covering it \
10422 and its Cells stay unsettled until that Generation answers, which is what the \
10423 spinner sits behind"
10424 );
10425
10426 started.core.release_phase_c_for_test(&key);
10427 started.core.wait_phase_c_finished_for_test(&key);
10428 }
10429
10430 #[test]
10439 fn refresh_all_covers_every_row_its_own_discovery_found() {
10440 let dir = tempfile::tempdir().expect("temp dir");
10441 let root = root_of(&dir);
10442 init_repo_with_a_commit(&root.join("repo"));
10443
10444 let (core, launched) = started_and_settled(spec(vec![root.clone()]));
10445 assert_eq!(
10446 launched
10447 .entities
10448 .iter()
10449 .map(|entity| entity.name.to_string())
10450 .collect::<Vec<_>>(),
10451 vec!["repo".to_string()],
10452 "launch's own walk must have landed and covered exactly the one row that \
10453 existed when it ran"
10454 );
10455 init_repo_with_a_commit(&root.join("late"));
10459
10460 assert_eq!(
10461 core.refresh_all(),
10462 launched.generation.successor(),
10463 "`refresh_all` must be the Generation immediately after the one already on the \
10464 table"
10465 );
10466 let settled = core.settle();
10467
10468 let mut named: Vec<String> = settled
10469 .entities
10470 .iter()
10471 .filter(|entity| entity.branch.settled().is_some())
10472 .map(|entity| entity.name.to_string())
10473 .collect();
10474 named.sort();
10475 assert_eq!(
10476 named,
10477 vec!["late".to_string(), "repo".to_string()],
10478 "the Generation must cover every row its own discovery found, including one the \
10479 caller had no key for"
10480 );
10481 }
10482
10483 #[test]
10493 fn refresh_returns_before_its_own_generations_discovery_has_run() {
10494 let dir = tempfile::tempdir().expect("temp dir");
10495 let root = root_of(&dir);
10496 init_repo_with_a_commit(&root.join("repo"));
10497 let (_tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
10498 let (gate, walk_may_run, opener) = gate_opened_on_signal(true);
10499
10500 let started = Core::start_for_test_gated(
10501 spec(vec![root.clone()]),
10502 Duration::from_secs(3600),
10503 discovery::ABANDON_AFTER,
10504 tick_rx,
10505 Some(Arc::clone(&gate)),
10506 )
10507 .discovered();
10508 let core = started.core;
10509 let launched = settle_launch(&core);
10511 let keys: Vec<EntityKey> = launched
10512 .entities
10513 .iter()
10514 .map(|entity| entity.key.clone())
10515 .collect();
10516 init_repo_with_a_commit(&root.join("late"));
10517
10518 set_discovery_gate(&gate, false);
10519 let generation = core.refresh(&keys);
10520 let while_held = core.snapshot();
10521 let dispatched_while_held = core.settle_gate_count_for_test();
10522 walk_may_run.send(()).expect("the opener is listening");
10523 opener.join().expect("the opener thread should not panic");
10524
10525 assert_eq!(
10526 generation,
10527 launched.generation.successor(),
10528 "`refresh` must return its own Generation's number, the one immediately after \
10529 the table's, before that Generation has done any of its work"
10530 );
10531 assert!(
10532 !while_held
10533 .entities
10534 .iter()
10535 .any(|entity| &*entity.name == "late"),
10536 "`refresh` must return before its own Generation's walk has run, so a Repo \
10537 created after the previous walk is not on the table it returned against"
10538 );
10539 assert_eq!(
10540 dispatched_while_held, 0,
10541 "`refresh` returned before its Generation reached the table at all, so nothing \
10542 is dispatched yet"
10543 );
10544
10545 core.wait_dispatched_for_test();
10546 let settled = core.settle();
10547
10548 assert!(
10549 settled
10550 .entities
10551 .iter()
10552 .any(|entity| &*entity.name == "late"),
10553 "the deferred Generation must still run its own walk once it is let through: \
10554 deferred, never dropped"
10555 );
10556 }
10557
10558 #[test]
10569 fn a_dispatch_body_waits_for_every_earlier_reserved_generation() {
10570 let turnstile = Arc::new(DispatchTurnstile::default());
10571 let earlier = turnstile.reserve();
10572 let later = turnstile.reserve();
10573 let order = Arc::new(Mutex::new(Vec::new()));
10574
10575 let earlier_body = thread::spawn({
10576 let turnstile = Arc::clone(&turnstile);
10577 let order = Arc::clone(&order);
10578 move || {
10579 let _turn = turnstile.take(earlier);
10580 order.lock().unwrap().push(earlier);
10581 }
10582 });
10583
10584 {
10585 let _turn = turnstile.take(later);
10586 order.lock().unwrap().push(later);
10587 }
10588 earlier_body
10589 .join()
10590 .expect("the earlier body should not panic");
10591
10592 assert_eq!(
10593 *order.lock().unwrap(),
10594 vec![earlier, later],
10595 "a dispatch body must run in the order its Generation was reserved"
10596 );
10597 }
10598
10599 #[test]
10604 fn run_while_not_cancelled_stops_at_the_next_check_rather_than_running_forever() {
10605 let cancel = Arc::new(AtomicBool::new(false));
10606 let worker_cancel = Arc::clone(&cancel);
10607 let (step_started_tx, step_started_rx) = crossbeam_channel::bounded::<()>(0);
10608 let (proceed_tx, proceed_rx) = crossbeam_channel::bounded::<()>(0);
10609
10610 let worker = thread::spawn(move || {
10611 run_while_not_cancelled(&worker_cancel, || {
10612 step_started_tx.send(()).expect("test should be listening");
10613 proceed_rx.recv().is_ok()
10614 })
10615 });
10616
10617 for _ in 0..2 {
10618 step_started_rx
10619 .recv()
10620 .expect("worker should announce each step");
10621 proceed_tx.send(()).expect("let the step finish");
10622 }
10623 step_started_rx
10624 .recv()
10625 .expect("worker should announce its third step");
10626 cancel.store(true, Ordering::Release);
10627 proceed_tx.send(()).expect("let the third step finish");
10628
10629 let ran = worker.join().expect("worker thread should not panic");
10630
10631 assert_eq!(
10632 ran, 3,
10633 "expected cancellation to stop the loop after its third step"
10634 );
10635 }
10636
10637 fn benchmark_identity_phase(
10645 population: Vec<crate::discovery::DiscoveredEntity>,
10646 ) -> (Duration, Vec<Duration>) {
10647 let (tx, rx) = crossbeam_channel::unbounded();
10648 let started = Instant::now();
10649 crate::fanout::scatter(population, tx, |entity| {
10650 let task_started = Instant::now();
10651 let repo = match &entity.repo {
10652 Some(repo) => repo.to_thread_local(),
10653 None => match git::open_thread_safe(entity.key.path()) {
10654 Ok(repo) => repo.to_thread_local(),
10655 Err(_) => return None,
10656 },
10657 };
10658 let _ = git::head_shape(&repo);
10659 Some(task_started.elapsed())
10660 });
10661 let wall = started.elapsed();
10662 let durations: Vec<Duration> = rx.into_iter().flatten().collect();
10663 (wall, durations)
10664 }
10665
10666 fn real_corpus_roots() -> Vec<PathBuf> {
10670 let Some(home) = std::env::var_os("HOME") else {
10671 return Vec::new();
10672 };
10673 let home = PathBuf::from(home);
10674 ["dev", "dev-misc"]
10675 .into_iter()
10676 .map(|leaf| home.join(leaf))
10677 .filter(|root| root.is_dir())
10678 .collect()
10679 }
10680
10681 fn generated_fixture_corpus(size: usize) -> tempfile::TempDir {
10686 let root = tempfile::tempdir().expect("temp dir for generated fixture corpus");
10687 for i in 0..size {
10688 let repo = root.path().join(format!("fixture-repo-{i}"));
10689 fs::create_dir_all(&repo).expect("create fixture repo dir");
10690 gix::init(&repo).expect("init fixture repo");
10691 let status = Command::new("git")
10692 .arg("-C")
10693 .arg(&repo)
10694 .args(["-c", "user.email=test@example.com", "-c", "user.name=Test"])
10695 .args(["commit", "--allow-empty", "-m", &format!("commit {i}")])
10696 .status()
10697 .expect("run git commit");
10698 assert!(status.success());
10699 }
10700 root
10701 }
10702
10703 fn percentile(sorted: &[Duration], p: usize) -> Duration {
10705 let index = (sorted.len() - 1) * p / 100;
10706 sorted[index]
10707 }
10708
10709 fn extra_excluded_names() -> Vec<String> {
10716 parse_excluded_names(&std::env::var("REPON_BENCHMARK_EXCLUDE_NAMES").unwrap_or_default())
10717 }
10718
10719 fn parse_excluded_names(raw: &str) -> Vec<String> {
10724 raw.split(',')
10725 .map(str::trim)
10726 .filter(|name| !name.is_empty())
10727 .map(str::to_string)
10728 .collect()
10729 }
10730
10731 fn discover_population(
10739 roots: Vec<PathBuf>,
10740 excluded_names: &[String],
10741 ) -> (Vec<crate::discovery::DiscoveredEntity>, Duration) {
10742 let set = SetSpec {
10743 name: "identity-probe-benchmark".to_string(),
10744 roots,
10745 include: Vec::new(),
10746 exclude: Vec::new(),
10747 };
10748 let started = Instant::now();
10749 let discovery = discovery::discover(&set);
10750 let (discovered, _) = discovery::resolve(&set, &discovery.entities);
10751 let elapsed = started.elapsed();
10752 let population = discovered
10753 .into_iter()
10754 .filter(|entity| {
10755 !entity.key.path().components().any(|component| {
10756 excluded_names
10757 .iter()
10758 .any(|name| component.as_os_str() == name.as_str())
10759 })
10760 })
10761 .collect();
10762 (population, elapsed)
10763 }
10764
10765 #[test]
10769 fn a_boundary_whose_path_matches_an_excluded_name_is_left_out_of_the_population() {
10770 let fixture = generated_fixture_corpus(3);
10771 let excluded = vec!["fixture-repo-1".to_string()];
10772
10773 let (population, _) = discover_population(vec![fixture.path().to_path_buf()], &excluded);
10774
10775 assert_eq!(population.len(), 2);
10776 assert!(
10777 population
10778 .iter()
10779 .all(|entity| entity.key.path().file_name().unwrap() != "fixture-repo-1"),
10780 "the excluded name must never appear in the population discovery returns"
10781 );
10782 }
10783
10784 #[test]
10785 fn excluded_names_parses_a_comma_separated_list_and_ignores_blanks() {
10786 assert_eq!(
10787 parse_excluded_names("foo, bar ,,baz"),
10788 vec!["foo".to_string(), "bar".to_string(), "baz".to_string()]
10789 );
10790 assert!(parse_excluded_names("").is_empty());
10791 assert!(parse_excluded_names(" ").is_empty());
10792 }
10793
10794 #[test]
10809 #[ignore = "hand-run against the owner's real corpus; see docs/spec/refresh.md for the recorded figures"]
10810 fn identity_probe_benchmark() {
10811 let excluded_names = extra_excluded_names();
10812
10813 let mut _fixture: Option<tempfile::TempDir> = None;
10817
10818 let (real_population, real_discovery_wall) =
10819 discover_population(real_corpus_roots(), &excluded_names);
10820 let (population, using_fixture, discovery_wall) = if real_population.len() >= 20 {
10821 (real_population, false, real_discovery_wall)
10822 } else {
10823 println!(
10824 "real corpus absent or too small to be meaningful ({} entities); \
10825 using a generated fixture instead",
10826 real_population.len()
10827 );
10828 let fixture = generated_fixture_corpus(300);
10829 let (population, fixture_discovery_wall) =
10830 discover_population(vec![fixture.path().to_path_buf()], &excluded_names);
10831 _fixture = Some(fixture);
10832 (population, true, fixture_discovery_wall)
10833 };
10834
10835 let population_size = population.len();
10836 assert!(
10837 population_size > 0,
10838 "neither a real corpus root nor the generated fixture produced any entities"
10839 );
10840
10841 let (wall, mut durations) = benchmark_identity_phase(population);
10842 durations.sort();
10843
10844 println!(
10845 "identity probe benchmark: corpus = {}, population = {population_size}",
10846 if using_fixture {
10847 "generated fixture"
10848 } else {
10849 "real corpus"
10850 }
10851 );
10852 println!(
10853 "discovery + first open (serial, every entity's own gix::open): {discovery_wall:?}"
10854 );
10855 println!("identity phase, warm, parallel (HEAD re-read from the cached handle): {wall:?}");
10856 println!(
10857 "identity phase per entity: p50 {:?}, p90 {:?}, max {:?}",
10858 percentile(&durations, 50),
10859 percentile(&durations, 90),
10860 durations.last().copied().unwrap_or_default(),
10861 );
10862 }
10863
10864 fn spec_with_overrides(roots: Vec<PathBuf>, overrides: Vec<RepoOverride>) -> CoreSpec {
10865 let mut spec = spec(roots);
10866 spec.overrides = overrides;
10867 spec
10868 }
10869
10870 #[test]
10875 fn a_per_repo_override_resolves_the_default_branch_at_rung_one_through_a_real_refresh() {
10876 let dir = tempfile::tempdir().expect("temp dir");
10877 let root = root_of(&dir);
10878 let repo = root.join("repo");
10879 init_repo_with_a_commit(&repo);
10880 git(
10881 &repo,
10882 &[
10883 "remote",
10884 "add",
10885 "origin",
10886 "https://example.invalid/repo.git",
10887 ],
10888 );
10889 let sha = head_sha(&repo);
10890 git(&repo, &["update-ref", "refs/remotes/origin/main", &sha]);
10891 let remote_refs_dir = repo
10892 .join(".git")
10893 .join("refs")
10894 .join("remotes")
10895 .join("origin");
10896 fs::create_dir_all(&remote_refs_dir).expect("create refs/remotes/origin dir");
10897 fs::write(
10898 remote_refs_dir.join("HEAD"),
10899 "ref: refs/remotes/origin/main\n",
10900 )
10901 .expect("write HEAD");
10902
10903 let core = Core::start_discovered(spec_with_overrides(
10904 vec![root],
10905 vec![RepoOverride {
10906 path: repo.clone(),
10907 default_branch: Some("develop".to_string()),
10908 excluded: false,
10909 }],
10910 ));
10911 let key = core.snapshot().entities[0].key.clone();
10912
10913 core.refresh(std::slice::from_ref(&key));
10914 let settled = core.settle();
10915 let entity = &settled.entities[0];
10916
10917 match entity.default_branch.settled() {
10918 Some(Settled::Known {
10919 value,
10920 at: _,
10921 stale: _,
10922 }) => assert_eq!(
10923 value.name(),
10924 "origin/develop",
10925 "the override must win even though origin/HEAD names a different branch"
10926 ),
10927 other => panic!("expected the override's own answer, got {other:?}"),
10928 }
10929 assert_eq!(
10930 entity.diagnostics.default_branch_rung,
10931 Some(1),
10932 "an override must be recorded as rung 1"
10933 );
10934 }
10935
10936 #[test]
10940 fn a_per_repo_override_also_resolves_through_probe_now() {
10941 let dir = tempfile::tempdir().expect("temp dir");
10942 let root = root_of(&dir);
10943 let repo = root.join("repo");
10944 init_repo_with_a_commit(&repo);
10945
10946 let core = Core::start_discovered(spec_with_overrides(
10947 vec![root],
10948 vec![RepoOverride {
10949 path: repo.clone(),
10950 default_branch: Some("release".to_string()),
10951 excluded: false,
10952 }],
10953 ));
10954 let key = core.snapshot().entities[0].key.clone();
10955
10956 let entity = core.probe_now(&key);
10957
10958 match entity.default_branch.settled() {
10959 Some(Settled::Known {
10961 value,
10962 at: _,
10963 stale: _,
10964 }) => assert_eq!(value.name(), "release"),
10965 other => panic!("expected the override's own answer, got {other:?}"),
10966 }
10967 assert_eq!(entity.diagnostics.default_branch_rung, Some(1));
10968 }
10969
10970 #[test]
10975 fn reaching_rung_four_with_no_remote_at_all_records_why() {
10976 let dir = tempfile::tempdir().expect("temp dir");
10977 let root = root_of(&dir);
10978 let repo = root.join("repo");
10979 init_repo_with_a_commit(&repo);
10980
10981 let core = Core::start_discovered(spec(vec![root]));
10982 let key = core.snapshot().entities[0].key.clone();
10983
10984 core.refresh(std::slice::from_ref(&key));
10985 let settled = core.settle();
10986 let entity = &settled.entities[0];
10987
10988 assert_eq!(entity.diagnostics.default_branch_rung, Some(4));
10989 assert_eq!(
10990 entity.diagnostics.default_branch_stopped,
10991 Some(DefaultBranchStopped::NoRemote)
10992 );
10993 }
10994
10995 #[test]
10996 fn reaching_rung_four_with_two_unnamed_remotes_records_why() {
10997 let dir = tempfile::tempdir().expect("temp dir");
10998 let root = root_of(&dir);
10999 let repo = root.join("repo");
11000 init_repo_with_a_commit(&repo);
11001 git(
11002 &repo,
11003 &[
11004 "remote",
11005 "add",
11006 "fork-one",
11007 "https://example.invalid/one.git",
11008 ],
11009 );
11010 git(
11011 &repo,
11012 &[
11013 "remote",
11014 "add",
11015 "fork-two",
11016 "https://example.invalid/two.git",
11017 ],
11018 );
11019
11020 let core = Core::start_discovered(spec(vec![root]));
11021 let key = core.snapshot().entities[0].key.clone();
11022
11023 core.refresh(std::slice::from_ref(&key));
11024 let settled = core.settle();
11025 let entity = &settled.entities[0];
11026
11027 assert_eq!(entity.diagnostics.default_branch_rung, Some(4));
11028 assert_eq!(
11029 entity.diagnostics.default_branch_stopped,
11030 Some(DefaultBranchStopped::AmbiguousRemote)
11031 );
11032 }
11033
11034 #[test]
11035 fn reaching_rung_four_with_a_chosen_remote_and_no_matching_ref_records_why() {
11036 let dir = tempfile::tempdir().expect("temp dir");
11037 let root = root_of(&dir);
11038 let repo = root.join("repo");
11039 init_repo_with_a_commit(&repo);
11040 git(
11041 &repo,
11042 &[
11043 "remote",
11044 "add",
11045 "origin",
11046 "https://example.invalid/repo.git",
11047 ],
11048 );
11049 let sha = head_sha(&repo);
11052 git(&repo, &["update-ref", "refs/remotes/origin/feature", &sha]);
11053
11054 let core = Core::start_discovered(spec(vec![root]));
11055 let key = core.snapshot().entities[0].key.clone();
11056
11057 core.refresh(std::slice::from_ref(&key));
11058 let settled = core.settle();
11059 let entity = &settled.entities[0];
11060
11061 assert_eq!(entity.diagnostics.default_branch_rung, Some(4));
11062 assert_eq!(
11063 entity.diagnostics.default_branch_stopped,
11064 Some(DefaultBranchStopped::NameListExhausted)
11065 );
11066 }
11067
11068 #[test]
11071 fn a_repo_with_nothing_to_resolve_settles_unknown_never_failed() {
11072 let dir = tempfile::tempdir().expect("temp dir");
11073 let root = root_of(&dir);
11074 let repo = root.join("repo");
11075 init_repo_with_a_commit(&repo);
11076
11077 let core = Core::start_discovered(spec(vec![root]));
11078 let key = core.snapshot().entities[0].key.clone();
11079
11080 core.refresh(std::slice::from_ref(&key));
11081 let settled = core.settle();
11082 let entity = &settled.entities[0];
11083
11084 assert!(matches!(
11085 entity.default_branch.settled(),
11086 Some(Settled::Unknown(Unknown::NoDefaultBranch))
11087 ));
11088 assert_eq!(entity.diagnostics.default_branch_rung, Some(4));
11089 }
11090
11091 #[test]
11097 fn a_stale_remote_head_is_recorded_in_diagnostics_through_a_real_refresh() {
11098 let dir = tempfile::tempdir().expect("temp dir");
11099 let root = root_of(&dir);
11100 let repo = root.join("repo");
11101 init_repo_with_a_commit(&repo);
11102 git(
11103 &repo,
11104 &[
11105 "remote",
11106 "add",
11107 "origin",
11108 "https://example.invalid/repo.git",
11109 ],
11110 );
11111 let sha = head_sha(&repo);
11112 git(&repo, &["update-ref", "refs/remotes/origin/trunk", &sha]);
11113 let remote_refs_dir = repo
11114 .join(".git")
11115 .join("refs")
11116 .join("remotes")
11117 .join("origin");
11118 fs::create_dir_all(&remote_refs_dir).expect("create refs/remotes/origin dir");
11119 fs::write(
11121 remote_refs_dir.join("HEAD"),
11122 "ref: refs/remotes/origin/main\n",
11123 )
11124 .expect("write HEAD");
11125
11126 let core = Core::start_discovered(spec(vec![root]));
11127 let key = core.snapshot().entities[0].key.clone();
11128
11129 core.refresh(std::slice::from_ref(&key));
11130 let settled = core.settle();
11131 let entity = &settled.entities[0];
11132
11133 match entity.default_branch.settled() {
11134 Some(Settled::Known {
11135 value,
11136 at: _,
11137 stale: _,
11138 }) => {
11139 assert_eq!(value.name(), "origin/trunk")
11140 }
11141 other => panic!("expected the name list's answer, got {other:?}"),
11142 }
11143 assert!(
11144 entity.diagnostics.default_branch_rung_two_stale,
11145 "a stale origin/HEAD target must be recorded on the entity's diagnostics"
11146 );
11147 }
11148
11149 #[test]
11152 fn a_resolvable_remote_head_is_not_recorded_as_stale() {
11153 let dir = tempfile::tempdir().expect("temp dir");
11154 let root = root_of(&dir);
11155 let repo = root.join("repo");
11156 init_repo_with_a_commit(&repo);
11157 git(
11158 &repo,
11159 &[
11160 "remote",
11161 "add",
11162 "origin",
11163 "https://example.invalid/repo.git",
11164 ],
11165 );
11166 let sha = head_sha(&repo);
11167 git(&repo, &["update-ref", "refs/remotes/origin/main", &sha]);
11168 let remote_refs_dir = repo
11169 .join(".git")
11170 .join("refs")
11171 .join("remotes")
11172 .join("origin");
11173 fs::create_dir_all(&remote_refs_dir).expect("create refs/remotes/origin dir");
11174 fs::write(
11175 remote_refs_dir.join("HEAD"),
11176 "ref: refs/remotes/origin/main\n",
11177 )
11178 .expect("write HEAD");
11179
11180 let core = Core::start_discovered(spec(vec![root]));
11181 let key = core.snapshot().entities[0].key.clone();
11182
11183 core.refresh(std::slice::from_ref(&key));
11184 let settled = core.settle();
11185 let entity = &settled.entities[0];
11186
11187 assert!(!entity.diagnostics.default_branch_rung_two_stale);
11188 }
11189
11190 #[test]
11195 fn one_override_on_a_repos_path_covers_a_worktree_sharing_its_common_dir() {
11196 let dir = tempfile::tempdir().expect("temp dir");
11197 let root = root_of(&dir);
11198 let parent = root.join("parent");
11199 init_repo_with_a_commit(&parent);
11200 let worktree = root.join("worktree");
11201 git(
11202 &parent,
11203 &[
11204 "worktree",
11205 "add",
11206 "-b",
11207 "feature",
11208 worktree.to_str().expect("utf8 path"),
11209 ],
11210 );
11211
11212 let core = Core::start_discovered(spec_with_overrides(
11213 vec![root],
11214 vec![RepoOverride {
11215 path: parent.clone(),
11216 default_branch: None,
11217 excluded: true,
11218 }],
11219 ));
11220 let snapshot = core.snapshot();
11221
11222 for entity in &snapshot.entities {
11223 assert!(
11224 entity.excluded,
11225 "both the Repo and its Worktree must inherit the entry declared on the Repo's own path, entity: {:?}",
11226 entity.key
11227 );
11228 }
11229 assert_eq!(
11230 snapshot.entities.len(),
11231 2,
11232 "expected the parent plus its worktree"
11233 );
11234 }
11235
11236 #[test]
11240 fn an_entry_naming_a_worktrees_own_path_beats_the_inherited_one() {
11241 let dir = tempfile::tempdir().expect("temp dir");
11242 let root = root_of(&dir);
11243 let parent = root.join("parent");
11244 init_repo_with_a_commit(&parent);
11245 let worktree_own = root.join("worktree-own");
11246 let worktree_inherits = root.join("worktree-inherits");
11247 git(
11248 &parent,
11249 &[
11250 "worktree",
11251 "add",
11252 "-b",
11253 "feature-own",
11254 worktree_own.to_str().expect("utf8 path"),
11255 ],
11256 );
11257 git(
11258 &parent,
11259 &[
11260 "worktree",
11261 "add",
11262 "-b",
11263 "feature-inherits",
11264 worktree_inherits.to_str().expect("utf8 path"),
11265 ],
11266 );
11267
11268 let core = Core::start_discovered(spec_with_overrides(
11269 vec![root],
11270 vec![
11271 RepoOverride {
11272 path: parent.clone(),
11273 default_branch: None,
11274 excluded: true,
11275 },
11276 RepoOverride {
11277 path: worktree_own.clone(),
11278 default_branch: None,
11279 excluded: false,
11280 },
11281 ],
11282 ));
11283 let snapshot = core.snapshot();
11284
11285 let find = |path: &Path| {
11286 snapshot
11287 .entities
11288 .iter()
11289 .find(|entity| entity.key.path() == path)
11290 .unwrap_or_else(|| panic!("entity at {path:?} present"))
11291 };
11292
11293 assert!(
11294 find(&parent).excluded,
11295 "the parent Repo has no entry of its own and inherits the excluding one"
11296 );
11297 assert!(
11298 !find(&worktree_own).excluded,
11299 "the Worktree named directly by its own path must use its own entry, not the inherited one"
11300 );
11301 assert!(
11302 find(&worktree_inherits).excluded,
11303 "a sibling Worktree with no entry of its own still inherits the Repo's entry"
11304 );
11305 }
11306
11307 #[test]
11312 fn an_override_on_the_parents_path_never_excludes_its_submodule() {
11313 let dir = tempfile::tempdir().expect("temp dir");
11314 let root = root_of(&dir);
11315 let parent = root.join("parent");
11316 init_repo_with_a_commit(&parent);
11317 fs::write(
11318 parent.join(".gitmodules"),
11319 "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
11320 )
11321 .expect("write .gitmodules");
11322 fs::create_dir_all(parent.join("vendor").join("lib")).expect("create submodule dir");
11323
11324 let core = Core::start_discovered(spec_with_overrides(
11325 vec![root],
11326 vec![RepoOverride {
11327 path: parent.clone(),
11328 default_branch: None,
11329 excluded: true,
11330 }],
11331 ));
11332 let snapshot = core.snapshot();
11333
11334 let submodule = snapshot
11335 .entities
11336 .iter()
11337 .find(|entity| matches!(entity.kind, Kind::Submodule))
11338 .expect("the submodule is still discovered and listed");
11339 assert!(
11340 !submodule.excluded,
11341 "an entry naming only the parent's path must never reach a Submodule, \
11342 whose own common dir differs from its parent's"
11343 );
11344 }
11345
11346 #[test]
11363 fn the_default_branch_chain_is_memoised_once_per_common_dir_per_generation() {
11364 let dir = tempfile::tempdir().expect("temp dir");
11365 let root = root_of(&dir);
11366 let parent = root.join("parent");
11367 init_repo_with_a_commit(&parent);
11368 for name in ["wt-a", "wt-b", "wt-c"] {
11369 let worktree = root.join(name);
11370 git(
11371 &parent,
11372 &[
11373 "worktree",
11374 "add",
11375 "-b",
11376 name,
11377 worktree.to_str().expect("utf8 path"),
11378 ],
11379 );
11380 }
11381 let other_repo = root.join("other");
11382 init_repo_with_a_commit(&other_repo);
11383
11384 let (core, launched) = started_and_settled(spec(vec![root]));
11385 let keys: Vec<EntityKey> = launched
11386 .entities
11387 .iter()
11388 .map(|entity| entity.key.clone())
11389 .collect();
11390 assert_eq!(
11391 keys.len(),
11392 5,
11393 "expected the parent, its three worktrees and the unrelated repo"
11394 );
11395
11396 core.refresh(&keys);
11397 core.settle();
11398
11399 assert_eq!(
11400 core.default_branch_chain_reads_for_test(),
11401 2,
11402 "four entities span exactly two common dirs; a memoised chain reads \
11403 each common dir once, not once per entity"
11404 );
11405
11406 core.refresh(&keys);
11410 core.settle();
11411 assert_eq!(
11412 core.default_branch_chain_reads_for_test(),
11413 2,
11414 "the memo lives inside one Generation's dispatch; the next Generation \
11415 recomputes rather than inheriting it"
11416 );
11417 }
11418
11419 #[test]
11429 fn patch_equivalence_is_memoised_once_per_common_dir_per_generation() {
11430 let dir = tempfile::tempdir().expect("temp dir");
11431 let root = root_of(&dir);
11432 let parent = root.join("parent");
11433 init_repo_with_a_commit(&parent);
11434 git(
11435 &parent,
11436 &[
11437 "remote",
11438 "add",
11439 "origin",
11440 "https://example.invalid/repo.git",
11441 ],
11442 );
11443 let base_sha = head_sha(&parent);
11444 git(
11445 &parent,
11446 &["update-ref", "refs/remotes/origin/main", &base_sha],
11447 );
11448 for name in ["feature-x", "feature-y"] {
11449 let worktree = root.join(name);
11450 git(
11451 &parent,
11452 &[
11453 "worktree",
11454 "add",
11455 "-b",
11456 name,
11457 worktree.to_str().expect("utf8 path"),
11458 ],
11459 );
11460 fs::write(worktree.join(format!("{name}.txt")), "unmerged\n")
11461 .expect("write worktree file");
11462 git(&worktree, &["add", "."]);
11463 git(&worktree, &["commit", "-m", "unmerged work"]);
11464 let tip_sha = head_sha(&worktree);
11465 git(
11466 &parent,
11467 &["config", &format!("branch.{name}.remote"), "origin"],
11468 );
11469 git(
11470 &parent,
11471 &[
11472 "config",
11473 &format!("branch.{name}.merge"),
11474 &format!("refs/heads/{name}"),
11475 ],
11476 );
11477 git(
11478 &parent,
11479 &[
11480 "update-ref",
11481 &format!("refs/remotes/origin/{name}"),
11482 &tip_sha,
11483 ],
11484 );
11485 }
11486
11487 let other_parent = root.join("other");
11488 init_repo_with_a_commit(&other_parent);
11489 git(
11490 &other_parent,
11491 &[
11492 "remote",
11493 "add",
11494 "origin",
11495 "https://example.invalid/other.git",
11496 ],
11497 );
11498 let other_base_sha = head_sha(&other_parent);
11499 git(
11500 &other_parent,
11501 &["update-ref", "refs/remotes/origin/main", &other_base_sha],
11502 );
11503 let other_worktree = root.join("other-feature");
11504 git(
11505 &other_parent,
11506 &[
11507 "worktree",
11508 "add",
11509 "-b",
11510 "other-feature",
11511 other_worktree.to_str().expect("utf8 path"),
11512 ],
11513 );
11514 fs::write(other_worktree.join("other.txt"), "unmerged\n").expect("write worktree file");
11515 git(&other_worktree, &["add", "."]);
11516 git(&other_worktree, &["commit", "-m", "unmerged work"]);
11517 let other_tip_sha = head_sha(&other_worktree);
11518 git(
11519 &other_parent,
11520 &["config", "branch.other-feature.remote", "origin"],
11521 );
11522 git(
11523 &other_parent,
11524 &[
11525 "config",
11526 "branch.other-feature.merge",
11527 "refs/heads/other-feature",
11528 ],
11529 );
11530 git(
11531 &other_parent,
11532 &[
11533 "update-ref",
11534 "refs/remotes/origin/other-feature",
11535 &other_tip_sha,
11536 ],
11537 );
11538
11539 let (core, launched) = started_and_settled(spec(vec![root]));
11540 let keys: Vec<EntityKey> = launched
11541 .entities
11542 .iter()
11543 .map(|entity| entity.key.clone())
11544 .collect();
11545 assert_eq!(
11546 keys.len(),
11547 5,
11548 "expected two parents plus their three worktrees"
11549 );
11550
11551 core.refresh(&keys);
11552 let settled = core.settle();
11553
11554 let worktree_states: Vec<_> = settled
11555 .entities
11556 .iter()
11557 .filter(|entity| matches!(entity.kind, Kind::Worktree))
11558 .map(|entity| entity.state.settled())
11559 .collect();
11560 assert_eq!(worktree_states.len(), 3, "expected three worktree rows");
11561 for settled_state in &worktree_states {
11562 assert!(
11563 matches!(
11564 settled_state,
11565 Some(Settled::Known {
11566 value: WorktreeState::Active,
11567 at: _,
11568 stale: _
11569 })
11570 ),
11571 "expected every worktree's genuinely unmerged work to settle Active, got {settled_state:?}"
11572 );
11573 }
11574
11575 assert_eq!(
11576 core.patch_identity_reads_for_test(),
11577 2,
11578 "two worktrees share one common dir and must scan its default-branch \
11579 history once between them, not once per entity; the unrelated repo's \
11580 own worktree pays for a second scan"
11581 );
11582
11583 core.refresh(&keys);
11586 core.settle();
11587 assert_eq!(
11588 core.patch_identity_reads_for_test(),
11589 2,
11590 "the memo lives inside one Generation's dispatch; the next Generation \
11591 recomputes rather than inheriting it"
11592 );
11593 }
11594
11595 #[test]
11614 fn an_entity_whose_merge_base_is_deeper_than_its_siblings_widens_the_shared_scan() {
11615 let dir = tempfile::tempdir().expect("temp dir");
11616 let root = root_of(&dir);
11617 let parent = root.join("parent");
11618 init_repo_with_a_commit(&parent);
11619 git(
11620 &parent,
11621 &[
11622 "remote",
11623 "add",
11624 "origin",
11625 "https://example.invalid/repo.git",
11626 ],
11627 );
11628 let deep_fork_sha = head_sha(&parent);
11629
11630 git(&parent, &["branch", "feature-deep"]);
11631 let deep_worktree = root.join("feature-deep");
11632 git(
11633 &parent,
11634 &[
11635 "worktree",
11636 "add",
11637 deep_worktree.to_str().expect("utf8 path"),
11638 "feature-deep",
11639 ],
11640 );
11641 fs::write(deep_worktree.join("deep.txt"), "deep work\n").expect("write deep.txt");
11642 git(&deep_worktree, &["add", "."]);
11643 git(&deep_worktree, &["commit", "-m", "deep work"]);
11644 let deep_tip_sha = head_sha(&deep_worktree);
11645
11646 git(&parent, &["merge", "--squash", "feature-deep"]);
11647 git(&parent, &["commit", "-m", "squashed deep"]);
11648 let shallow_fork_sha = head_sha(&parent);
11649
11650 git(&parent, &["branch", "feature-shallow"]);
11651 let shallow_worktree = root.join("feature-shallow");
11652 git(
11653 &parent,
11654 &[
11655 "worktree",
11656 "add",
11657 shallow_worktree.to_str().expect("utf8 path"),
11658 "feature-shallow",
11659 ],
11660 );
11661 fs::write(shallow_worktree.join("shallow.txt"), "shallow work\n")
11662 .expect("write shallow.txt");
11663 git(&shallow_worktree, &["add", "."]);
11664 git(&shallow_worktree, &["commit", "-m", "shallow work"]);
11665 let shallow_tip_sha = head_sha(&shallow_worktree);
11666
11667 git(&parent, &["merge", "--squash", "feature-shallow"]);
11668 git(&parent, &["commit", "-m", "squashed shallow"]);
11669 let main_tip_sha = head_sha(&parent);
11670 assert_ne!(
11671 deep_fork_sha, shallow_fork_sha,
11672 "the two siblings must fork at genuinely different commits"
11673 );
11674
11675 git(
11676 &parent,
11677 &["update-ref", "refs/remotes/origin/main", &main_tip_sha],
11678 );
11679 for (name, tip_sha) in [
11680 ("feature-deep", &deep_tip_sha),
11681 ("feature-shallow", &shallow_tip_sha),
11682 ] {
11683 git(
11684 &parent,
11685 &["config", &format!("branch.{name}.remote"), "origin"],
11686 );
11687 git(
11688 &parent,
11689 &[
11690 "config",
11691 &format!("branch.{name}.merge"),
11692 &format!("refs/heads/{name}"),
11693 ],
11694 );
11695 git(
11696 &parent,
11697 &[
11698 "update-ref",
11699 &format!("refs/remotes/origin/{name}"),
11700 tip_sha,
11701 ],
11702 );
11703 }
11704
11705 let (core, snapshot) = started_and_settled(spec(vec![root]));
11706 let deep_key = snapshot
11707 .entities
11708 .iter()
11709 .find(|entity| entity.key.path() == deep_worktree)
11710 .expect("feature-deep worktree discovered")
11711 .key
11712 .clone();
11713 let shallow_key = snapshot
11714 .entities
11715 .iter()
11716 .find(|entity| entity.key.path() == shallow_worktree)
11717 .expect("feature-shallow worktree discovered")
11718 .key
11719 .clone();
11720 let parent_key = snapshot
11721 .entities
11722 .iter()
11723 .find(|entity| entity.key.path() == parent)
11724 .expect("parent repo discovered")
11725 .key
11726 .clone();
11727 let order = vec![parent_key, shallow_key.clone(), deep_key.clone()];
11731
11732 core.refresh(&order);
11733 let settled = core.settle();
11734
11735 let state_of = |key: &EntityKey| {
11736 settled
11737 .entities
11738 .iter()
11739 .find(|entity| &entity.key == key)
11740 .and_then(|entity| entity.state.settled())
11741 .cloned()
11742 };
11743 assert!(
11744 matches!(
11745 state_of(&deep_key),
11746 Some(Settled::Known {
11747 value: WorktreeState::Merged,
11748 at: _,
11749 stale: _
11750 })
11751 ),
11752 "expected the deepest sibling's own squash commit to be found once the scan is \
11753 bounded by the deepest merge base, got {:?}",
11754 state_of(&deep_key)
11755 );
11756 assert!(
11757 matches!(
11758 state_of(&shallow_key),
11759 Some(Settled::Known {
11760 value: WorktreeState::Merged,
11761 at: _,
11762 stale: _
11763 })
11764 ),
11765 "expected the shallow sibling to settle Merged too, got {:?}",
11766 state_of(&shallow_key)
11767 );
11768 assert_eq!(
11769 core.patch_identity_reads_for_test(),
11770 1,
11771 "both worktrees share one common dir and must still scan its default-branch \
11772 history once between them, not once per entity"
11773 );
11774 assert_eq!(
11775 core.patch_scan_bounds_for_test(),
11776 vec![Some(id(&deep_fork_sha))],
11777 "the one shared scan that ran must have been bounded by the deepest sibling's own \
11778 merge base, not the shallower one's"
11779 );
11780 }
11781
11782 fn id(sha: &str) -> gix::ObjectId {
11783 gix::ObjectId::from_hex(sha.as_bytes()).expect("parse sha")
11784 }
11785
11786 #[test]
11793 fn bound_gate_deepest_folds_every_candidate_regardless_of_report_order() {
11794 let dir = tempfile::tempdir().expect("temp dir");
11795 let repo_path = root_of(&dir).join("repo");
11796 init_repo_with_a_commit(&repo_path);
11797 let deep_sha = id(&head_sha(&repo_path));
11798 fs::write(repo_path.join("child.txt"), "child\n").expect("write child.txt");
11799 git(&repo_path, &["add", "."]);
11800 git(&repo_path, &["commit", "-m", "child of deep"]);
11801 let shallow_sha = id(&head_sha(&repo_path));
11802
11803 let repo = gix::open(&repo_path).expect("open repo");
11804 let gate = BoundGate::new(2);
11805 gate.report(Some(shallow_sha));
11806 gate.report(Some(deep_sha));
11807
11808 assert_eq!(
11809 gate.deepest(&repo),
11810 Some(deep_sha),
11811 "the deepest candidate must win even though the shallower one reported first"
11812 );
11813 }
11814
11815 #[test]
11832 fn probe_patch_equivalence_bounds_the_scan_by_the_gates_deepest_not_its_own_merge_base() {
11833 let dir = tempfile::tempdir().expect("temp dir");
11834 let repo_path = root_of(&dir).join("repo");
11835 init_repo_with_a_commit(&repo_path);
11836 let deep_sha = id(&head_sha(&repo_path));
11837 fs::write(repo_path.join("child.txt"), "child\n").expect("write child.txt");
11838 git(&repo_path, &["add", "."]);
11839 git(&repo_path, &["commit", "-m", "child of deep"]);
11840 let shallow_sha_hex = head_sha(&repo_path);
11841 let shallow_sha = id(&shallow_sha_hex);
11842 fs::write(repo_path.join("tip.txt"), "tip\n").expect("write tip.txt");
11843 git(&repo_path, &["add", "."]);
11844 git(&repo_path, &["commit", "-m", "default tip"]);
11845 let default_tip_hex = head_sha(&repo_path);
11846
11847 let repo = gix::open(&repo_path).expect("open repo");
11848 let outstanding = landing::Outstanding {
11851 entity_tip: shallow_sha,
11852 default_tip: id(&default_tip_hex),
11853 merge_base: Some(shallow_sha),
11854 };
11855 let common_dir: Arc<Path> = Arc::from(repo_path.join(".git"));
11856 let cancel = AtomicBool::new(false);
11857 let patch_cache: PatchIdentityCache = Mutex::new(HashMap::new());
11858 let patch_reads = AtomicUsize::new(0);
11859 let patch_scan_bounds: Mutex<Vec<Option<gix::ObjectId>>> = Mutex::new(Vec::new());
11860 let memo = PatchEquivalenceMemo {
11861 cache: &patch_cache,
11862 reads: &patch_reads,
11863 scan_bounds: &patch_scan_bounds,
11864 };
11865 let gate = BoundGate::new(2);
11869 gate.report(Some(deep_sha));
11870 let mut report = GateReport::new(&gate);
11871
11872 probe_patch_equivalence(
11873 &repo,
11874 &outstanding,
11875 &common_dir,
11876 &cancel,
11877 &memo,
11878 &mut report,
11879 );
11880
11881 assert_eq!(
11882 patch_scan_bounds.lock().unwrap().as_slice(),
11883 [Some(deep_sha)],
11884 "the scan must be bounded by the deepest sibling's merge base, not shallow's own \
11885 ({shallow_sha:?})"
11886 );
11887 }
11888
11889 #[test]
11897 fn probe_patch_equivalence_diffs_from_the_merge_base_it_was_handed() {
11898 let dir = tempfile::tempdir().expect("temp dir");
11899 let repo_path = root_of(&dir).join("repo");
11900 init_repo_with_a_commit(&repo_path);
11901 let fork_point_hex = head_sha(&repo_path);
11902 git(&repo_path, &["checkout", "-b", "feature"]);
11903 fs::write(repo_path.join("a.txt"), "one\n").expect("write a.txt");
11904 git(&repo_path, &["add", "a.txt"]);
11905 git(&repo_path, &["commit", "-m", "add a"]);
11906 let mid_sha = id(&head_sha(&repo_path));
11907 fs::write(repo_path.join("b.txt"), "two\n").expect("write b.txt");
11908 git(&repo_path, &["add", "b.txt"]);
11909 git(&repo_path, &["commit", "-m", "add b"]);
11910 let feature_sha = id(&head_sha(&repo_path));
11911 git(&repo_path, &["checkout", "-B", "main", &fork_point_hex]);
11912 git(&repo_path, &["merge", "--squash", "feature"]);
11913 git(&repo_path, &["commit", "-m", "squashed feature"]);
11914 let main_sha = id(&head_sha(&repo_path));
11915
11916 let repo = gix::open(&repo_path).expect("open repo");
11917 let outstanding = landing::Outstanding {
11920 entity_tip: feature_sha,
11921 default_tip: main_sha,
11922 merge_base: Some(mid_sha),
11923 };
11924 let common_dir: Arc<Path> = Arc::from(repo_path.join(".git"));
11925 let cancel = AtomicBool::new(false);
11926 let patch_cache: PatchIdentityCache = Mutex::new(HashMap::new());
11927 let patch_reads = AtomicUsize::new(0);
11928 let patch_scan_bounds: Mutex<Vec<Option<gix::ObjectId>>> = Mutex::new(Vec::new());
11929 let memo = PatchEquivalenceMemo {
11930 cache: &patch_cache,
11931 reads: &patch_reads,
11932 scan_bounds: &patch_scan_bounds,
11933 };
11934 let gate = BoundGate::new(1);
11935 let mut report = GateReport::new(&gate);
11936
11937 let settled = probe_patch_equivalence(
11938 &repo,
11939 &outstanding,
11940 &common_dir,
11941 &cancel,
11942 &memo,
11943 &mut report,
11944 );
11945
11946 assert!(
11947 matches!(
11948 settled,
11949 Some(Settled::Known {
11950 value: WorktreeState::Active,
11951 at: _,
11952 stale: _
11953 })
11954 ),
11955 "the range must be measured from the handed-in base ({mid_sha:?}), whose only \
11956 change the squash commit does not match, got {settled:?}"
11957 );
11958 }
11959
11960 #[test]
11967 fn bound_gate_deepest_with_no_candidates_leaves_the_scan_unbounded() {
11968 let dir = tempfile::tempdir().expect("temp dir");
11969 let repo_path = root_of(&dir).join("repo");
11970 gix::init(&repo_path).expect("init repo");
11971 let repo = gix::open(&repo_path).expect("open repo");
11972
11973 let gate = BoundGate::new(2);
11974 gate.report(None);
11975 gate.report(None);
11976
11977 assert_eq!(
11978 gate.deepest(&repo),
11979 None,
11980 "no contributed candidate must leave the scan unbounded"
11981 );
11982 }
11983
11984 #[test]
11994 fn an_outstanding_entity_with_no_shared_history_settles_active_without_the_shared_scan() {
11995 let dir = tempfile::tempdir().expect("temp dir");
11996 let root = root_of(&dir);
11997 let parent = root.join("parent");
11998 init_repo_with_a_commit(&parent);
11999 git(&parent, &["branch", "-M", "main"]);
12000 git(
12001 &parent,
12002 &[
12003 "remote",
12004 "add",
12005 "origin",
12006 "https://example.invalid/repo.git",
12007 ],
12008 );
12009 let main_sha = head_sha(&parent);
12010 git(
12011 &parent,
12012 &["update-ref", "refs/remotes/origin/main", &main_sha],
12013 );
12014
12015 git(&parent, &["checkout", "--orphan", "unrelated"]);
12016 git(
12017 &parent,
12018 &["commit", "--allow-empty", "-m", "unrelated root"],
12019 );
12020 let unrelated_sha = head_sha(&parent);
12021 git(&parent, &["checkout", "main"]);
12022
12023 let worktree = root.join("unrelated");
12024 git(
12025 &parent,
12026 &[
12027 "worktree",
12028 "add",
12029 worktree.to_str().expect("utf8 path"),
12030 "unrelated",
12031 ],
12032 );
12033 git(&parent, &["config", "branch.unrelated.remote", "origin"]);
12034 git(
12035 &parent,
12036 &["config", "branch.unrelated.merge", "refs/heads/unrelated"],
12037 );
12038 git(
12039 &parent,
12040 &[
12041 "update-ref",
12042 "refs/remotes/origin/unrelated",
12043 &unrelated_sha,
12044 ],
12045 );
12046
12047 let (core, snapshot) = started_and_settled(spec(vec![root]));
12048 let worktree_key = snapshot
12049 .entities
12050 .iter()
12051 .find(|entity| entity.key.path() == worktree)
12052 .expect("unrelated worktree discovered")
12053 .key
12054 .clone();
12055
12056 core.refresh(std::slice::from_ref(&worktree_key));
12057 let settled = core.settle();
12058
12059 let state = settled
12060 .entities
12061 .iter()
12062 .find(|entity| entity.key == worktree_key)
12063 .and_then(|entity| entity.state.settled())
12064 .cloned();
12065 assert!(
12066 matches!(
12067 state,
12068 Some(Settled::Known {
12069 value: WorktreeState::Active,
12070 at: _,
12071 stale: _
12072 })
12073 ),
12074 "expected an Outstanding entity with no shared history to settle Active via the \
12075 bypass, got {state:?}"
12076 );
12077 assert_eq!(
12078 core.patch_identity_reads_for_test(),
12079 0,
12080 "the bypass must settle without ever running the shared scan"
12081 );
12082 }
12083
12084 fn add_origin_remote(path: &Path) {
12089 git(
12090 path,
12091 &[
12092 "remote",
12093 "add",
12094 "origin",
12095 "https://example.invalid/repo.git",
12096 ],
12097 );
12098 }
12099
12100 fn set_upstream(path: &Path, branch: &str, upstream_sha: &str) {
12104 git(
12105 path,
12106 &["config", &format!("branch.{branch}.remote"), "origin"],
12107 );
12108 git(
12109 path,
12110 &[
12111 "config",
12112 &format!("branch.{branch}.merge"),
12113 &format!("refs/heads/{branch}"),
12114 ],
12115 );
12116 git(
12117 path,
12118 &[
12119 "update-ref",
12120 &format!("refs/remotes/origin/{branch}"),
12121 upstream_sha,
12122 ],
12123 );
12124 }
12125
12126 fn refresh_and_settle(core: &Core) -> crate::snapshot::Snapshot {
12127 let keys: Vec<EntityKey> = core
12128 .snapshot()
12129 .entities
12130 .iter()
12131 .map(|entity| entity.key.clone())
12132 .collect();
12133 core.refresh(&keys);
12134 core.settle()
12135 }
12136
12137 fn sync_of<'a>(
12138 snapshot: &'a crate::snapshot::Snapshot,
12139 path: &Path,
12140 ) -> Option<&'a Settled<SyncState>> {
12141 snapshot
12142 .entities
12143 .iter()
12144 .find(|entity| entity.key.path() == path)
12145 .unwrap_or_else(|| panic!("no entity for {}", path.display()))
12146 .sync
12147 .settled()
12148 }
12149
12150 #[test]
12152 fn an_attached_branch_ahead_of_its_upstream_reads_the_ahead_count() {
12153 let dir = tempfile::tempdir().expect("temp dir");
12154 let root = root_of(&dir);
12155 let repo = root.join("repo");
12156 init_repo_with_a_commit(&repo);
12157 let fork_sha = head_sha(&repo);
12158 add_origin_remote(&repo);
12159 set_upstream(&repo, "main", &fork_sha);
12160 git(&repo, &["commit", "--allow-empty", "-m", "local work"]);
12161
12162 let core = Core::start_discovered(spec(vec![root]));
12163 let settled = refresh_and_settle(&core);
12164
12165 match sync_of(&settled, &repo) {
12166 Some(Settled::Known {
12167 value: SyncState::Tracking(AheadBehind { ahead, behind }),
12168 at: _,
12169 stale: _,
12170 }) => {
12171 assert_eq!(*ahead, 1);
12172 assert_eq!(*behind, 0);
12173 }
12174 other => panic!("expected 1 ahead, 0 behind, got {other:?}"),
12175 }
12176 }
12177
12178 #[test]
12180 fn an_attached_branch_behind_its_upstream_reads_the_behind_count() {
12181 let dir = tempfile::tempdir().expect("temp dir");
12182 let root = root_of(&dir);
12183 let repo = root.join("repo");
12184 init_repo_with_a_commit(&repo);
12185 git(&repo, &["checkout", "-b", "temp"]);
12186 git(&repo, &["commit", "--allow-empty", "-m", "upstream work"]);
12187 let upstream_sha = head_sha(&repo);
12188 git(&repo, &["checkout", "main"]);
12189 git(&repo, &["branch", "-D", "temp"]);
12190 add_origin_remote(&repo);
12191 set_upstream(&repo, "main", &upstream_sha);
12192
12193 let core = Core::start_discovered(spec(vec![root]));
12194 let settled = refresh_and_settle(&core);
12195
12196 match sync_of(&settled, &repo) {
12197 Some(Settled::Known {
12198 value: SyncState::Tracking(AheadBehind { ahead, behind }),
12199 at: _,
12200 stale: _,
12201 }) => {
12202 assert_eq!(*ahead, 0);
12203 assert_eq!(*behind, 1);
12204 }
12205 other => panic!("expected 0 ahead, 1 behind, got {other:?}"),
12206 }
12207 }
12208
12209 #[test]
12211 fn an_attached_branch_level_with_its_upstream_reads_in_sync() {
12212 let dir = tempfile::tempdir().expect("temp dir");
12213 let root = root_of(&dir);
12214 let repo = root.join("repo");
12215 init_repo_with_a_commit(&repo);
12216 let sha = head_sha(&repo);
12217 add_origin_remote(&repo);
12218 set_upstream(&repo, "main", &sha);
12219
12220 let core = Core::start_discovered(spec(vec![root]));
12221 let settled = refresh_and_settle(&core);
12222
12223 match sync_of(&settled, &repo) {
12224 Some(Settled::Known {
12225 value:
12226 SyncState::Tracking(AheadBehind {
12227 ahead: 0,
12228 behind: 0,
12229 }),
12230 at: _,
12231 stale: _,
12232 }) => {}
12233 other => panic!("expected level with its upstream, got {other:?}"),
12234 }
12235 }
12236
12237 #[test]
12241 fn an_attached_branch_tracking_nothing_reads_no_upstream() {
12242 let dir = tempfile::tempdir().expect("temp dir");
12243 let root = root_of(&dir);
12244 let repo = root.join("repo");
12245 init_repo_with_a_commit(&repo);
12246 add_origin_remote(&repo);
12247
12248 let core = Core::start_discovered(spec(vec![root]));
12249 let settled = refresh_and_settle(&core);
12250
12251 match sync_of(&settled, &repo) {
12252 Some(Settled::Known {
12253 value: SyncState::NoUpstream,
12254 at: _,
12255 stale: _,
12256 }) => {}
12257 other => panic!("expected no upstream configured, got {other:?}"),
12258 }
12259 }
12260
12261 #[test]
12264 fn a_detached_row_reads_no_upstream() {
12265 let dir = tempfile::tempdir().expect("temp dir");
12266 let root = root_of(&dir);
12267 let repo = root.join("repo");
12268 init_repo_with_a_commit(&repo);
12269 let first_sha = head_sha(&repo);
12270 git(&repo, &["commit", "--allow-empty", "-m", "second"]);
12271 git(&repo, &["checkout", "--detach", &first_sha]);
12272 add_origin_remote(&repo);
12273
12274 let core = Core::start_discovered(spec(vec![root]));
12275 let settled = refresh_and_settle(&core);
12276
12277 match sync_of(&settled, &repo) {
12278 Some(Settled::Known {
12279 value: SyncState::NoUpstream,
12280 at: _,
12281 stale: _,
12282 }) => {}
12283 other => panic!("expected a detached row to read no upstream, got {other:?}"),
12284 }
12285 }
12286
12287 #[test]
12292 fn a_repo_with_no_remote_reads_no_remote_on_itself_and_every_worktree() {
12293 let dir = tempfile::tempdir().expect("temp dir");
12294 let root = root_of(&dir);
12295 let parent = root.join("parent");
12296 init_repo_with_a_commit(&parent);
12297 let worktree = root.join("feature");
12298 git(
12299 &parent,
12300 &[
12301 "worktree",
12302 "add",
12303 "-b",
12304 "feature",
12305 worktree.to_str().expect("utf8 path"),
12306 ],
12307 );
12308
12309 let core = Core::start_discovered(spec(vec![root]));
12310 let settled = refresh_and_settle(&core);
12311
12312 assert_eq!(
12313 settled.entities.len(),
12314 2,
12315 "expected the parent Repo and its one linked Worktree"
12316 );
12317 for path in [&parent, &worktree] {
12318 match sync_of(&settled, path) {
12319 Some(Settled::Known {
12320 value: SyncState::NoRemote,
12321 at: _,
12322 stale: _,
12323 }) => {}
12324 other => panic!(
12325 "expected {} to read no remote at all, got {other:?}",
12326 path.display()
12327 ),
12328 }
12329 }
12330 }
12331
12332 #[test]
12338 fn sync_is_computed_for_every_entity_dispatched_this_generation_not_only_one() {
12339 let dir = tempfile::tempdir().expect("temp dir");
12340 let root = root_of(&dir);
12341 let parent = root.join("parent");
12342 init_repo_with_a_commit(&parent);
12343 let fork_sha = head_sha(&parent);
12344 add_origin_remote(&parent);
12345
12346 let ahead_worktree = root.join("feature-ahead");
12347 git(
12348 &parent,
12349 &[
12350 "worktree",
12351 "add",
12352 "-b",
12353 "feature-ahead",
12354 ahead_worktree.to_str().expect("utf8 path"),
12355 ],
12356 );
12357 set_upstream(&parent, "feature-ahead", &fork_sha);
12358 git(
12359 &ahead_worktree,
12360 &["commit", "--allow-empty", "-m", "unpushed"],
12361 );
12362
12363 let behind_worktree = root.join("feature-behind");
12364 git(
12365 &parent,
12366 &[
12367 "worktree",
12368 "add",
12369 "-b",
12370 "feature-behind",
12371 behind_worktree.to_str().expect("utf8 path"),
12372 ],
12373 );
12374 git(
12375 &behind_worktree,
12376 &["commit", "--allow-empty", "-m", "on the remote only"],
12377 );
12378 let ahead_of_behind_sha = head_sha(&behind_worktree);
12379 git(&behind_worktree, &["reset", "--hard", "HEAD~1"]);
12380 set_upstream(&parent, "feature-behind", &ahead_of_behind_sha);
12381
12382 let core = Core::start_discovered(spec(vec![root]));
12383 let settled = refresh_and_settle(&core);
12384
12385 match sync_of(&settled, &ahead_worktree) {
12386 Some(Settled::Known {
12387 value:
12388 SyncState::Tracking(AheadBehind {
12389 ahead: 1,
12390 behind: 0,
12391 }),
12392 at: _,
12393 stale: _,
12394 }) => {}
12395 other => panic!("expected feature-ahead to read 1 ahead, got {other:?}"),
12396 }
12397 match sync_of(&settled, &behind_worktree) {
12398 Some(Settled::Known {
12399 value:
12400 SyncState::Tracking(AheadBehind {
12401 ahead: 0,
12402 behind: 1,
12403 }),
12404 at: _,
12405 stale: _,
12406 }) => {}
12407 other => panic!("expected feature-behind to read 1 behind, got {other:?}"),
12408 }
12409 }
12410
12411 #[test]
12417 fn sync_recomputes_on_a_second_generation_not_only_the_first() {
12418 let dir = tempfile::tempdir().expect("temp dir");
12419 let root = root_of(&dir);
12420 let repo = root.join("repo");
12421 init_repo_with_a_commit(&repo);
12422 let fork_sha = head_sha(&repo);
12423 add_origin_remote(&repo);
12424 set_upstream(&repo, "main", &fork_sha);
12425
12426 let core = Core::start_discovered(spec(vec![root]));
12427 let first = refresh_and_settle(&core);
12428 match sync_of(&first, &repo) {
12429 Some(Settled::Known {
12430 value:
12431 SyncState::Tracking(AheadBehind {
12432 ahead: 0,
12433 behind: 0,
12434 }),
12435 at: _,
12436 stale: _,
12437 }) => {}
12438 other => panic!("expected the first Generation level with its upstream, got {other:?}"),
12439 }
12440
12441 git(
12442 &repo,
12443 &[
12444 "commit",
12445 "--allow-empty",
12446 "-m",
12447 "second Generation's own work",
12448 ],
12449 );
12450 let second = refresh_and_settle(&core);
12451 match sync_of(&second, &repo) {
12452 Some(Settled::Known {
12453 value:
12454 SyncState::Tracking(AheadBehind {
12455 ahead: 1,
12456 behind: 0,
12457 }),
12458 at: _,
12459 stale: _,
12460 }) => {}
12461 other => panic!(
12462 "expected the second Generation to recompute and read 1 ahead, got {other:?}"
12463 ),
12464 }
12465 }
12466
12467 #[test]
12483 fn worktrees_now_behind_a_moved_default_branch_are_reported_by_name() {
12484 let dir = tempfile::tempdir().expect("temp dir");
12485 let root = root_of(&dir);
12486 let repo = root.join("repo");
12487 init_repo_with_a_commit(&repo);
12488 let sha_a = head_sha(&repo);
12489 add_origin_remote(&repo);
12490 set_upstream(&repo, "main", &sha_a);
12491
12492 let behind_path = root.join("wt-behind");
12493 git(
12494 &repo,
12495 &[
12496 "worktree",
12497 "add",
12498 "-b",
12499 "topic-behind",
12500 behind_path.to_str().expect("utf8 path"),
12501 "main",
12502 ],
12503 );
12504
12505 git(&repo, &["checkout", "-b", "scratch"]);
12510 git(&repo, &["commit", "--allow-empty", "-m", "second"]);
12511 let sha_b = head_sha(&repo);
12512 git(&repo, &["checkout", "main"]);
12513 git(&repo, &["update-ref", "refs/remotes/origin/main", &sha_b]);
12514 git(&repo, &["branch", "-D", "scratch"]);
12515
12516 let caught_up_path = root.join("wt-caught-up");
12522 git(
12523 &repo,
12524 &[
12525 "worktree",
12526 "add",
12527 "-b",
12528 "topic-caught-up",
12529 caught_up_path.to_str().expect("utf8 path"),
12530 &sha_b,
12531 ],
12532 );
12533
12534 let core = Core::start_discovered(spec(vec![root]));
12535 let snapshot = refresh_and_settle(&core);
12536
12537 let base_of = |name: &str| -> u32 {
12538 let entity = snapshot
12539 .entities
12540 .iter()
12541 .find(|entity| &*entity.name == name)
12542 .unwrap_or_else(|| panic!("no entity named {name} in {snapshot:?}"));
12543 match entity.base.settled() {
12544 Some(Settled::Known {
12545 value,
12546 at: _,
12547 stale: _,
12548 }) => *value,
12549 other => panic!("expected a known base count for {name}, got {other:?}"),
12550 }
12551 };
12552
12553 assert!(
12554 base_of("wt-behind") > 0,
12555 "a Worktree branched before the default branch moved must be reported behind"
12556 );
12557 assert_eq!(
12558 base_of("wt-caught-up"),
12559 0,
12560 "a Worktree branched from the new tip must not be reported behind"
12561 );
12562 }
12563
12564 mod fetch_scheduler {
12570 use super::*;
12571 use crate::liveness::wait_for_or;
12572
12573 fn fetch_spec(enabled: bool, root: PathBuf) -> CoreSpec {
12574 let mut spec = spec(vec![root]);
12575 spec.fetch = FetchSpec {
12576 enabled,
12577 interval: Duration::from_secs(3600),
12578 concurrency: 4,
12579 };
12580 spec
12581 }
12582
12583 fn seeded_remote() -> tempfile::TempDir {
12586 let remote = tempfile::tempdir().expect("temp dir");
12587 crate::test_support::init_bare(remote.path());
12588 crate::test_support::push_new_commit(remote.path(), "README.md", "seed\n");
12589 remote
12590 }
12591
12592 fn clone_into(remote: &Path, dest: &Path) {
12593 let status = Command::new("git")
12594 .arg("clone")
12595 .arg(remote)
12596 .arg(dest)
12597 .status()
12598 .expect("run git clone");
12599 assert!(status.success());
12600 crate::test_support::set_identity(dest);
12601 }
12602
12603 #[test]
12610 fn enabling_the_periodic_fetch_runs_one_cycle_before_any_tick_arrives() {
12611 let remote = seeded_remote();
12612 let root = tempfile::tempdir().expect("temp dir");
12613 let root_path = root_of(&root);
12614 clone_into(remote.path(), &root_path.join("parent"));
12615
12616 let fetch_ticks: Receiver<Instant> = crossbeam_channel::never();
12617 let started = Core::start_for_test_with_fetch(
12618 fetch_spec(true, root_path),
12619 Duration::from_secs(3600),
12620 crossbeam_channel::never(),
12621 fetch_ticks,
12622 )
12623 .discovered();
12624 let core = started.core;
12625
12626 wait_for(
12627 "the periodic fetch to run its first cycle without waiting for a tick",
12628 || core.fetch_cycle_count_for_test() >= 1,
12629 );
12630 }
12631
12632 #[test]
12637 fn fetch_now_runs_a_cycle_even_though_the_periodic_fetch_is_disabled() {
12638 let remote = seeded_remote();
12639 let root = tempfile::tempdir().expect("temp dir");
12640 let root_path = root_of(&root);
12641 clone_into(remote.path(), &root_path.join("parent"));
12642
12643 let started = Core::start_for_test_with_fetch(
12644 fetch_spec(false, root_path),
12645 Duration::from_secs(3600),
12646 crossbeam_channel::never(),
12647 crossbeam_channel::never(),
12648 )
12649 .discovered();
12650 let core = started.core;
12651
12652 core.fetch_now();
12653
12654 wait_for("the on-demand fetch to run a cycle", || {
12655 core.fetch_cycle_count_for_test() >= 1
12656 });
12657 }
12658
12659 #[test]
12667 fn a_second_fetch_now_while_one_is_in_flight_is_refused_rather_than_queued() {
12668 let remote = seeded_remote();
12669 let root = tempfile::tempdir().expect("temp dir");
12670 let root_path = root_of(&root);
12671 clone_into(remote.path(), &root_path.join("parent"));
12672
12673 let started = Core::start_for_test_with_fetch(
12674 fetch_spec(false, root_path),
12675 Duration::from_secs(3600),
12676 crossbeam_channel::never(),
12677 crossbeam_channel::never(),
12678 )
12679 .discovered();
12680 let core = started.core;
12681 let taken_back = Arc::clone(&started.fetch_cycles_taken_back);
12682
12683 let held = core.fetch_boundary().arm();
12684 core.fetch_now();
12685 held.wait_until_reached();
12686 core.fetch_now();
12687 drop(held);
12688
12689 wait_for("the first cycle to be taken back", || {
12690 taken_back.load(Ordering::Acquire) >= 1
12691 });
12692 drop(core);
12693
12694 assert_eq!(
12695 taken_back.load(Ordering::Acquire),
12696 1,
12697 "the second press must have started no cycle of its own"
12698 );
12699 }
12700
12701 #[test]
12707 fn fetch_running_holds_while_a_cycle_is_in_flight_and_clears_once_it_is_taken_back() {
12708 let remote = seeded_remote();
12709 let root = tempfile::tempdir().expect("temp dir");
12710 let root_path = root_of(&root);
12711 clone_into(remote.path(), &root_path.join("parent"));
12712
12713 let started = Core::start_for_test_with_fetch(
12714 fetch_spec(false, root_path),
12715 Duration::from_secs(3600),
12716 crossbeam_channel::never(),
12717 crossbeam_channel::never(),
12718 )
12719 .discovered();
12720 let core = started.core;
12721 assert!(
12722 !core.fetch_running(),
12723 "sanity: no cycle has been asked for yet"
12724 );
12725
12726 let held = core.fetch_boundary().arm();
12727 core.fetch_now();
12728 held.wait_until_reached();
12729 assert!(
12730 core.fetch_running(),
12731 "a cycle parked mid-fetch is still in flight"
12732 );
12733
12734 drop(held);
12735 wait_for(
12736 "the cycle to be taken back and fetch_running to clear",
12737 || !core.fetch_running(),
12738 );
12739 }
12740
12741 #[test]
12746 fn an_on_demand_cycle_counts_a_repository_it_could_not_reach_and_fetches_the_rest() {
12747 let good_remote = seeded_remote();
12748 let bad_remote = seeded_remote();
12749 let root = tempfile::tempdir().expect("temp dir");
12750 let root_path = root_of(&root);
12751 let good = root_path.join("good");
12752 let bad = root_path.join("bad");
12753 clone_into(good_remote.path(), &good);
12754 clone_into(bad_remote.path(), &bad);
12755 break_remote(&bad);
12756
12757 crate::test_support::push_new_commit(good_remote.path(), "second.txt", "second\n");
12758 let good_remote_tip = rev_parse(good_remote.path(), "refs/heads/main");
12759
12760 let started = Core::start_for_test_with_fetch(
12761 fetch_spec(false, root_path),
12762 Duration::from_secs(3600),
12763 crossbeam_channel::never(),
12764 crossbeam_channel::never(),
12765 )
12766 .discovered();
12767 let core = started.core;
12768
12769 core.fetch_now();
12770
12771 wait_for(
12772 "the on-demand cycle to count the one repository it could not fetch",
12773 || core.fetch_failures().failed.len() == 1,
12774 );
12775 let failures = core.fetch_failures();
12776 assert!(
12777 failures.failed[0].0.to_string_lossy().contains("bad"),
12778 "the counted failure must name the repository that actually failed, got: {:?}",
12779 failures.failed
12780 );
12781
12782 wait_for(
12783 "the sibling repository to still fetch despite the other one failing",
12784 || rev_parse(&good, "refs/remotes/origin/main") == good_remote_tip,
12785 );
12786 }
12787
12788 #[test]
12795 fn a_tick_on_the_fetch_channel_runs_another_cycle() {
12796 let remote = seeded_remote();
12797 let root = tempfile::tempdir().expect("temp dir");
12798 let root_path = root_of(&root);
12799 clone_into(remote.path(), &root_path.join("parent"));
12800
12801 let (fetch_tick_tx, fetch_tick_rx) = crossbeam_channel::unbounded();
12802 let started = Core::start_for_test_with_fetch(
12803 fetch_spec(true, root_path),
12804 Duration::from_secs(3600),
12805 crossbeam_channel::never(),
12806 fetch_tick_rx,
12807 )
12808 .discovered();
12809 let core = started.core;
12810
12811 wait_for(
12812 "the immediate cycle to have run and been taken back first",
12813 || started.fetch_cycles_taken_back.load(Ordering::Acquire) >= 1,
12814 );
12815
12816 fetch_tick_tx
12817 .send(Instant::now())
12818 .expect("send a fetch tick");
12819
12820 wait_for("a tick on the fetch channel to run a second cycle", || {
12821 core.fetch_cycle_count_for_test() >= 2
12822 });
12823 }
12824
12825 #[test]
12831 fn a_deadline_tick_still_times_out_a_pending_probe_while_a_fetch_is_held() {
12832 let remote = seeded_remote();
12833 let root = tempfile::tempdir().expect("temp dir");
12834 let root_path = root_of(&root);
12835 clone_into(remote.path(), &root_path.join("parent"));
12836
12837 let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
12838 let (fetch_tick_tx, fetch_tick_rx) = crossbeam_channel::unbounded::<Instant>();
12839 let mut spec = fetch_spec(false, root_path);
12840 spec.generation_deadline = Duration::ZERO;
12841 let started = Core::start_for_test_with_fetch(
12842 spec,
12843 Duration::from_secs(3600),
12844 tick_rx,
12845 fetch_tick_rx,
12846 )
12847 .discovered();
12848 let core = started.core;
12849 let key = core.settle().entities[0].key.clone();
12850
12851 let held = core.fetch_boundary().arm();
12852 fetch_tick_tx
12853 .send(Instant::now())
12854 .expect("send a fetch tick");
12855 held.wait_until_reached();
12856
12857 core.begin_untracked_probe_for_test(&key);
12858 tick_tx.send(Instant::now()).expect("send one tick");
12859 let after = core.settle();
12860
12861 assert!(
12862 matches!(
12863 after.entities[0].branch.settled(),
12864 Some(Settled::Unknown(Unknown::TimedOut))
12865 ),
12866 "the deadline sweep must still run while a fetch is held, got: {:?}",
12867 after.entities[0].branch.settled()
12868 );
12869 }
12870
12871 #[test]
12880 fn pause_cancels_a_held_cycle_so_it_neither_auto_updates_nor_dispatches_its_generation() {
12881 let remote = seeded_remote();
12882 let root = tempfile::tempdir().expect("temp dir");
12883 let root_path = root_of(&root);
12884 let parent = root_path.join("parent");
12885 let stale = root_path.join("stale");
12886 clone_into(remote.path(), &parent);
12887 clone_into(remote.path(), &stale);
12888 crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
12889 git(&parent, &["fetch", "origin"]);
12890 let before_tip = rev_parse(&parent, "refs/heads/main");
12891 let stale_before = rev_parse(&stale, "refs/remotes/origin/main");
12892
12893 let (fetch_tick_tx, fetch_tick_rx) = crossbeam_channel::unbounded();
12894 let started = Core::start_for_test_with_fetch(
12895 spec_with_auto_update(false, true, root_path),
12896 Duration::from_secs(3600),
12897 crossbeam_channel::never(),
12898 fetch_tick_rx,
12899 )
12900 .discovered();
12901 let core = started.core;
12902 let before = core.settle().generation;
12903
12904 let held = core.fetch_boundary().arm();
12905 fetch_tick_tx
12906 .send(Instant::now())
12907 .expect("send a fetch tick");
12908 held.wait_until_reached();
12909
12910 core.pause();
12911 held.wait_until_cancelled();
12912 drop(held);
12913 wait_for("the cancelled cycle to be taken back by the clock", || {
12914 started.fetch_cycles_taken_back.load(Ordering::Acquire) >= 1
12915 });
12916
12917 assert_eq!(
12918 rev_parse(&parent, "refs/heads/main"),
12919 before_tip,
12920 "a cancelled cycle must not fast-forward a Repo its auto-update would \
12921 otherwise have moved"
12922 );
12923 assert_eq!(
12924 rev_parse(&stale, "refs/remotes/origin/main"),
12925 stale_before,
12926 "a cancelled cycle must land no fetch beyond the one it was holding"
12927 );
12928 assert_eq!(
12929 core.snapshot().generation,
12930 before,
12931 "releasing a cancelled fetch must not dispatch the completion Generation \
12932 its cycle would otherwise have owed"
12933 );
12934 }
12935
12936 #[test]
12942 fn a_fetch_tick_taken_while_a_cycle_is_live_starts_no_second_cycle() {
12943 let remote = seeded_remote();
12944 let root = tempfile::tempdir().expect("temp dir");
12945 let root_path = root_of(&root);
12946 clone_into(remote.path(), &root_path.join("parent"));
12947
12948 let (fetch_tick_tx, fetch_tick_rx) = crossbeam_channel::unbounded();
12949 let pending_ticks = fetch_tick_rx.clone();
12952 let started = Core::start_for_test_with_fetch(
12953 fetch_spec(false, root_path),
12954 Duration::from_secs(3600),
12955 crossbeam_channel::never(),
12956 fetch_tick_rx,
12957 )
12958 .discovered();
12959 let core = started.core;
12960
12961 let held = core.fetch_boundary().arm();
12962 fetch_tick_tx
12963 .send(Instant::now())
12964 .expect("send the tick that starts the cycle");
12965 held.wait_until_reached();
12966
12967 for _ in 0..2 {
12968 fetch_tick_tx
12969 .send(Instant::now())
12970 .expect("send a tick while the cycle is live");
12971 }
12972 wait_for("the clock to take both further ticks", || {
12973 pending_ticks.is_empty()
12974 });
12975
12976 drop(held);
12977 wait_for("the released cycle to be taken back by the clock", || {
12978 started.fetch_cycles_taken_back.load(Ordering::Acquire) >= 1
12979 });
12980
12981 assert_eq!(
12982 core.fetch_cycle_count_for_test(),
12983 1,
12984 "two ticks taken while a cycle was held must have started no cycle of their \
12985 own"
12986 );
12987 }
12988
12989 #[test]
12996 fn a_cancelled_cycle_leaves_the_completed_cycles_failures_standing() {
12997 let remote = seeded_remote();
12998 let root = tempfile::tempdir().expect("temp dir");
12999 let root_path = root_of(&root);
13000 let broken = root_path.join("broken");
13001 clone_into(remote.path(), &broken);
13002 break_remote(&broken);
13003
13004 let (fetch_tick_tx, fetch_tick_rx) = crossbeam_channel::unbounded();
13005 let started = Core::start_for_test_with_fetch(
13006 fetch_spec(true, root_path),
13007 Duration::from_secs(3600),
13008 crossbeam_channel::never(),
13009 fetch_tick_rx,
13010 )
13011 .discovered();
13012 let core = started.core;
13013
13014 wait_for("the immediate cycle to complete and be taken back", || {
13015 started.fetch_cycles_taken_back.load(Ordering::Acquire) >= 1
13016 });
13017 assert_eq!(
13018 core.fetch_failures().failed.len(),
13019 1,
13020 "the completed cycle must have counted its one broken remote, got: {:?}",
13021 core.fetch_failures().failed
13022 );
13023
13024 let held = core.fetch_boundary().arm();
13025 fetch_tick_tx
13026 .send(Instant::now())
13027 .expect("send a fetch tick");
13028 held.wait_until_reached();
13029 core.pause();
13030 held.wait_until_cancelled();
13031 drop(held);
13032 wait_for("the cancelled cycle to be taken back by the clock", || {
13033 started.fetch_cycles_taken_back.load(Ordering::Acquire) >= 2
13034 });
13035
13036 assert_eq!(
13037 core.fetch_failures().failed.len(),
13038 1,
13039 "a cancelled cycle must leave the completed cycle's own count standing, \
13040 got: {:?}",
13041 core.fetch_failures().failed
13042 );
13043 }
13044
13045 #[test]
13053 fn a_pause_landing_before_the_immediate_cycle_holds_it_until_resume() {
13054 let remote = seeded_remote();
13055 let root = tempfile::tempdir().expect("temp dir");
13056 let root_path = root_of(&root);
13057 clone_into(remote.path(), &root_path.join("parent"));
13058
13059 let (gate, walk_may_run, opener) = gate_opened_on_signal(false);
13060 let started = Core::start_for_test_with_fetch_gated(
13061 fetch_spec(true, root_path),
13062 Duration::from_secs(3600),
13063 crossbeam_channel::never(),
13064 crossbeam_channel::never(),
13065 Some(gate),
13066 );
13067 started.core.pause();
13068 walk_may_run.send(()).expect("the opener is listening");
13069 opener.join().expect("the opener thread should not panic");
13070 let core = started.discovered().core;
13071
13072 core.resume();
13073
13074 wait_for(
13075 "the held immediate cycle to run once the clock resumes",
13076 || core.fetch_cycle_count_for_test() >= 1,
13077 );
13078 }
13079
13080 #[test]
13090 fn dropping_the_core_cancels_and_joins_a_held_fetch_cycle_before_returning() {
13091 let remote = seeded_remote();
13092 let root = tempfile::tempdir().expect("temp dir");
13093 let root_path = root_of(&root);
13094 let parent = root_path.join("parent");
13095 clone_into(remote.path(), &parent);
13096 crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
13097 git(&parent, &["fetch", "origin"]);
13098 let before_tip = rev_parse(&parent, "refs/heads/main");
13099
13100 let (fetch_tick_tx, fetch_tick_rx) = crossbeam_channel::unbounded();
13101 let started = Core::start_for_test_with_fetch(
13102 spec_with_auto_update(false, true, root_path),
13103 Duration::from_secs(3600),
13104 crossbeam_channel::never(),
13105 fetch_tick_rx,
13106 )
13107 .discovered();
13108 let core = started.core;
13109
13110 let held = core.fetch_boundary().arm();
13111 fetch_tick_tx
13112 .send(Instant::now())
13113 .expect("send a fetch tick");
13114 held.wait_until_reached();
13115
13116 let (returned_tx, returned_rx) = crossbeam_channel::bounded::<()>(1);
13117 let teardown = thread::spawn(move || {
13118 drop(core);
13119 let _ = returned_tx.send(());
13120 });
13121
13122 held.wait_until_cancelled();
13123 assert!(
13127 returned_rx
13128 .recv_timeout(Duration::from_millis(200))
13129 .is_err(),
13130 "teardown must still be waiting on the worker it cancelled, not have \
13131 detached it"
13132 );
13133
13134 drop(held);
13135 returned_rx
13136 .recv_timeout(liveness::BACKSTOP)
13137 .expect("teardown returns once the worker it joined has stopped");
13138 teardown
13139 .join()
13140 .expect("the teardown thread should not panic");
13141
13142 assert_eq!(
13143 started.fetch_cycles_taken_back.load(Ordering::Acquire),
13144 1,
13145 "teardown must have taken its own cycle back rather than left it running"
13146 );
13147 assert_eq!(
13148 rev_parse(&parent, "refs/heads/main"),
13149 before_tip,
13150 "no worker may still be fast-forwarding a repository once teardown has \
13151 returned"
13152 );
13153 }
13154
13155 fn break_remote(repo: &Path) {
13161 let status = Command::new("git")
13162 .arg("-C")
13163 .arg(repo)
13164 .args(["remote", "set-url", "origin", "/nonexistent-remote-282"])
13165 .status()
13166 .expect("run git remote set-url");
13167 assert!(status.success());
13168 }
13169
13170 #[test]
13172 fn a_cycle_in_which_every_fetch_succeeds_reports_no_failures() {
13173 let remote = seeded_remote();
13174 let root = tempfile::tempdir().expect("temp dir");
13175 let root_path = root_of(&root);
13176 clone_into(remote.path(), &root_path.join("parent"));
13177
13178 let fetch_ticks: Receiver<Instant> = crossbeam_channel::never();
13179 let started = Core::start_for_test_with_fetch(
13180 fetch_spec(true, root_path),
13181 Duration::from_secs(3600),
13182 crossbeam_channel::never(),
13183 fetch_ticks,
13184 )
13185 .discovered();
13186 let core = started.core;
13187
13188 wait_for("the periodic fetch to run its first cycle", || {
13189 core.fetch_cycle_count_for_test() >= 1
13190 });
13191
13192 assert!(
13193 core.fetch_failures().failed.is_empty(),
13194 "a cycle where every fetch succeeds must report no failures, got: {:?}",
13195 core.fetch_failures().failed
13196 );
13197 }
13198
13199 #[test]
13203 fn a_repository_that_cannot_be_fetched_is_counted_while_its_sibling_still_fetches() {
13204 let good_remote = seeded_remote();
13205 let bad_remote = seeded_remote();
13206 let root = tempfile::tempdir().expect("temp dir");
13207 let root_path = root_of(&root);
13208 let good = root_path.join("good");
13209 let bad = root_path.join("bad");
13210 clone_into(good_remote.path(), &good);
13211 clone_into(bad_remote.path(), &bad);
13212 break_remote(&bad);
13213
13214 crate::test_support::push_new_commit(good_remote.path(), "second.txt", "second\n");
13215 let good_remote_tip = rev_parse(good_remote.path(), "refs/heads/main");
13216
13217 let fetch_ticks: Receiver<Instant> = crossbeam_channel::never();
13218 let started = Core::start_for_test_with_fetch(
13219 fetch_spec(true, root_path),
13220 Duration::from_secs(3600),
13221 crossbeam_channel::never(),
13222 fetch_ticks,
13223 )
13224 .discovered();
13225 let core = started.core;
13226
13227 wait_for(
13228 "the cycle to run and count the one repository it could not fetch",
13229 || core.fetch_failures().failed.len() == 1,
13230 );
13231
13232 let failures = core.fetch_failures();
13233 assert_eq!(
13234 failures.failed.len(),
13235 1,
13236 "exactly one repository failed, so exactly one failure must be counted, \
13237 got: {:?}",
13238 failures.failed
13239 );
13240 assert!(
13241 failures.failed[0].0.to_string_lossy().contains("bad"),
13242 "the counted failure must name the repository that actually failed, \
13243 got: {:?}",
13244 failures.failed
13245 );
13246
13247 wait_for(
13248 "the sibling repository to still fetch despite the other one failing",
13249 || rev_parse(&good, "refs/remotes/origin/main") == good_remote_tip,
13250 );
13251 }
13252
13253 fn push_new_commit_on_branch(remote: &Path, branch: &str, name: &str, contents: &str) {
13257 let contributor = tempfile::tempdir().expect("temp dir");
13258 let status = Command::new("git")
13259 .arg("clone")
13260 .arg("--branch")
13261 .arg(branch)
13262 .arg(remote)
13263 .arg(contributor.path())
13264 .status()
13265 .expect("run git clone");
13266 assert!(status.success());
13267 std::fs::write(contributor.path().join(name), contents).expect("write fixture file");
13268 git(contributor.path(), &["add", name]);
13269 git(contributor.path(), &["commit", "-m", "extra work on topic"]);
13270 git(contributor.path(), &["push", "origin", branch]);
13271 }
13272
13273 #[test]
13281 fn a_finished_fetch_prunes_and_starts_its_own_generation_that_lands_gone() {
13282 let remote = seeded_remote();
13283 let root = tempfile::tempdir().expect("temp dir");
13284 let root_path = root_of(&root);
13285 let parent = root_path.join("parent");
13286 clone_into(remote.path(), &parent);
13287
13288 git(remote.path(), &["branch", "topic"]);
13289 push_new_commit_on_branch(remote.path(), "topic", "topic.txt", "extra work\n");
13290
13291 git(&parent, &["fetch", "origin"]);
13297
13298 let worktree_path = root_path.join("topic-worktree");
13299 git(
13300 &parent,
13301 &[
13302 "worktree",
13303 "add",
13304 "-b",
13305 "topic",
13306 worktree_path.to_str().expect("utf8 path"),
13307 "origin/topic",
13308 ],
13309 );
13310
13311 git(remote.path(), &["branch", "-D", "topic"]);
13315
13316 let fetch_ticks: Receiver<Instant> = crossbeam_channel::never();
13317 let started = Core::start_for_test_with_fetch(
13318 fetch_spec(true, root_path),
13319 Duration::from_secs(3600),
13320 crossbeam_channel::never(),
13321 fetch_ticks,
13322 )
13323 .discovered();
13324 let core = started.core;
13325
13326 wait_for_or(
13327 "a finished fetch's own Generation to land the pruned Worktree as Gone \
13328 without the test ever calling refresh",
13329 || {
13330 core.snapshot()
13331 .entities
13332 .iter()
13333 .filter(|entity| matches!(entity.kind, Kind::Worktree))
13334 .any(|entity| {
13335 matches!(
13336 entity.state.settled(),
13337 Some(Settled::Known {
13338 value: WorktreeState::Gone,
13339 at: _,
13340 stale: _,
13341 })
13342 )
13343 })
13344 },
13345 || {
13346 format!(
13347 "snapshot: {:?}",
13348 core.snapshot()
13349 .entities
13350 .iter()
13351 .map(|entity| (entity.kind, entity.state.settled().cloned()))
13352 .collect::<Vec<_>>()
13353 )
13354 },
13355 );
13356 }
13357
13358 #[test]
13364 fn an_on_demand_fetch_lands_a_new_behind_count_through_the_generation_it_dispatches() {
13365 let remote = seeded_remote();
13366 let root = tempfile::tempdir().expect("temp dir");
13367 let root_path = root_of(&root);
13368 let parent = root_path.join("parent");
13369 clone_into(remote.path(), &parent);
13370
13371 crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
13372
13373 let started = Core::start_for_test_with_fetch(
13374 spec_with_auto_update(false, false, root_path),
13375 Duration::from_secs(3600),
13376 crossbeam_channel::never(),
13377 crossbeam_channel::never(),
13378 )
13379 .discovered();
13380 let core = started.core;
13381
13382 core.fetch_now();
13383
13384 wait_for("the on-demand fetch to land a behind count of 1", || {
13385 matches!(
13386 sync_of(&core.snapshot(), &parent),
13387 Some(Settled::Known {
13388 value: SyncState::Tracking(AheadBehind {
13389 ahead: 0,
13390 behind: 1
13391 }),
13392 at: _,
13393 stale: _,
13394 })
13395 )
13396 });
13397 }
13398
13399 #[test]
13404 fn auto_update_rides_an_on_demand_cycle_the_same_way_it_rides_a_tick() {
13405 let remote = seeded_remote();
13406 let root = tempfile::tempdir().expect("temp dir");
13407 let root_path = root_of(&root);
13408 let parent = root_path.join("parent");
13409 clone_into(remote.path(), &parent);
13410
13411 crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
13412 let remote_tip = rev_parse(remote.path(), "refs/heads/main");
13413
13414 let started = Core::start_for_test_with_fetch(
13415 spec_with_auto_update(false, true, root_path),
13416 Duration::from_secs(3600),
13417 crossbeam_channel::never(),
13418 crossbeam_channel::never(),
13419 )
13420 .discovered();
13421 let core = started.core;
13422
13423 core.fetch_now();
13424
13425 wait_for(
13426 "the eligible branch to fast-forward on the on-demand cycle",
13427 || rev_parse(&parent, "refs/heads/main") == remote_tip,
13428 );
13429 }
13430
13431 #[test]
13435 fn an_on_demand_fetch_moves_no_branch_while_auto_update_is_disabled() {
13436 let remote = seeded_remote();
13437 let root = tempfile::tempdir().expect("temp dir");
13438 let root_path = root_of(&root);
13439 let parent = root_path.join("parent");
13440 clone_into(remote.path(), &parent);
13441 let before = rev_parse(&parent, "refs/heads/main");
13442
13443 crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
13444
13445 let started = Core::start_for_test_with_fetch(
13446 spec_with_auto_update(false, false, root_path),
13447 Duration::from_secs(3600),
13448 crossbeam_channel::never(),
13449 crossbeam_channel::never(),
13450 )
13451 .discovered();
13452 let core = started.core;
13453
13454 core.fetch_now();
13455
13456 wait_for("the on-demand cycle to have run", || {
13457 core.fetch_cycle_count_for_test() >= 1
13458 });
13459 assert_eq!(
13460 rev_parse(&parent, "refs/heads/main"),
13461 before,
13462 "an on-demand fetch must move no branch while auto_update.enabled is false"
13463 );
13464 }
13465
13466 fn spec_with_auto_update(
13467 fetch_enabled: bool,
13468 auto_update_enabled: bool,
13469 root: PathBuf,
13470 ) -> CoreSpec {
13471 let mut spec = fetch_spec(fetch_enabled, root);
13472 spec.auto_update = AutoUpdateSpec {
13473 enabled: auto_update_enabled,
13474 };
13475 spec
13476 }
13477
13478 fn rev_parse(path: &Path, rev: &str) -> String {
13479 let output = Command::new("git")
13480 .arg("-C")
13481 .arg(path)
13482 .args(["rev-parse", rev])
13483 .output()
13484 .expect("run git rev-parse");
13485 assert!(output.status.success(), "git rev-parse {rev} failed");
13486 String::from_utf8(output.stdout)
13487 .expect("utf8 sha")
13488 .trim()
13489 .to_string()
13490 }
13491
13492 #[test]
13499 fn auto_update_is_off_by_default_even_with_fetch_enabled() {
13500 let remote = seeded_remote();
13501 let root = tempfile::tempdir().expect("temp dir");
13502 let root_path = root_of(&root);
13503 let parent = root_path.join("parent");
13504 clone_into(remote.path(), &parent);
13505 let before = rev_parse(&parent, "refs/heads/main");
13506
13507 crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
13508
13509 let fetch_ticks: Receiver<Instant> = crossbeam_channel::never();
13510 let started = Core::start_for_test_with_fetch(
13511 spec_with_auto_update(true, false, root_path),
13512 Duration::from_secs(3600),
13513 crossbeam_channel::never(),
13514 fetch_ticks,
13515 )
13516 .discovered();
13517 let core = started.core;
13518
13519 wait_for(
13520 "the periodic fetch to still run its immediate cycle",
13521 || core.fetch_cycle_count_for_test() >= 1,
13522 );
13523 assert_eq!(
13524 rev_parse(&parent, "refs/heads/main"),
13525 before,
13526 "an eligible branch must not move while auto_update.enabled is false, \
13527 even though fetch.enabled is true"
13528 );
13529 }
13530
13531 #[test]
13538 fn auto_update_enabled_rides_the_immediate_fetch_cycle_with_no_timer_of_its_own() {
13539 let remote = seeded_remote();
13540 let root = tempfile::tempdir().expect("temp dir");
13541 let root_path = root_of(&root);
13542 let parent = root_path.join("parent");
13543 clone_into(remote.path(), &parent);
13544
13545 crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
13546 let remote_tip = rev_parse(remote.path(), "refs/heads/main");
13547
13548 let fetch_ticks: Receiver<Instant> = crossbeam_channel::never();
13549 let started = Core::start_for_test_with_fetch(
13550 spec_with_auto_update(true, true, root_path),
13551 Duration::from_secs(3600),
13552 crossbeam_channel::never(),
13553 fetch_ticks,
13554 )
13555 .discovered();
13556 let _core = started.core;
13559
13560 wait_for(
13561 "the eligible branch to fast-forward on the immediate cycle alone, with no \
13562 fetch tick and no auto-update tick of its own",
13563 || rev_parse(&parent, "refs/heads/main") == remote_tip,
13564 );
13565 }
13566 }
13567
13568 mod attempt_auto_update {
13578 use super::*;
13579
13580 fn seeded_remote() -> tempfile::TempDir {
13581 let remote = tempfile::tempdir().expect("temp dir");
13582 crate::test_support::init_bare(remote.path());
13583 crate::test_support::push_new_commit(remote.path(), "README.md", "seed\n");
13584 remote
13585 }
13586
13587 fn clone_into(remote: &Path, dest: &Path) {
13588 let status = Command::new("git")
13589 .arg("clone")
13590 .arg(remote)
13591 .arg(dest)
13592 .status()
13593 .expect("run git clone");
13594 assert!(status.success());
13595 crate::test_support::set_identity(dest);
13596 }
13597
13598 fn discover_repo(root: &Path) -> (Core, EntityKey) {
13603 let core = Core::start_discovered(spec(vec![root.to_path_buf()]));
13604 let key = core
13605 .settle()
13606 .entities
13607 .into_iter()
13608 .find(|entity| entity.kind == Kind::Repo)
13609 .expect("the Repo row is discovered")
13610 .key;
13611 (core, key)
13612 }
13613
13614 #[test]
13617 fn an_eligible_repo_fast_forwards_through_the_wrapper_too() {
13618 let remote = seeded_remote();
13619 let root = tempfile::tempdir().expect("temp dir");
13620 let root_path = root_of(&root);
13621 let repo = root_path.join("repo");
13622 clone_into(remote.path(), &repo);
13623 crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
13624 crate::test_support::git(&repo, &["fetch", "origin"]);
13625
13626 let (core, key) = discover_repo(&root_path);
13627
13628 assert_eq!(core.attempt_auto_update(&key), AutoUpdateAttempt::Updated);
13629 assert!(
13630 repo.join("second.txt").exists(),
13631 "the fast-forward must reach the working tree through the wrapper too"
13632 );
13633 }
13634
13635 #[test]
13637 fn a_dirty_repo_is_reported_not_clean_through_the_wrapper_too() {
13638 let remote = seeded_remote();
13639 let root = tempfile::tempdir().expect("temp dir");
13640 let root_path = root_of(&root);
13641 let repo = root_path.join("repo");
13642 clone_into(remote.path(), &repo);
13643 crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
13644 crate::test_support::git(&repo, &["fetch", "origin"]);
13645 fs::write(repo.join("stray.txt"), "uncommitted\n").expect("write a stray file");
13646
13647 let (core, key) = discover_repo(&root_path);
13648
13649 assert_eq!(core.attempt_auto_update(&key), AutoUpdateAttempt::NotClean);
13650 }
13651
13652 #[test]
13654 fn an_up_to_date_repo_is_reported_not_behind_through_the_wrapper_too() {
13655 let remote = seeded_remote();
13656 let root = tempfile::tempdir().expect("temp dir");
13657 let root_path = root_of(&root);
13658 let repo = root_path.join("repo");
13659 clone_into(remote.path(), &repo);
13660 crate::test_support::git(&repo, &["fetch", "origin"]);
13661
13662 let (core, key) = discover_repo(&root_path);
13663
13664 assert_eq!(core.attempt_auto_update(&key), AutoUpdateAttempt::NotBehind);
13665 }
13666
13667 #[test]
13669 fn an_unpublished_local_commit_is_reported_not_fast_forward_through_the_wrapper_too() {
13670 let remote = seeded_remote();
13671 let root = tempfile::tempdir().expect("temp dir");
13672 let root_path = root_of(&root);
13673 let repo = root_path.join("repo");
13674 clone_into(remote.path(), &repo);
13675 crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
13676 crate::test_support::git(&repo, &["fetch", "origin"]);
13677 crate::test_support::commit_file(&repo, "local-only.txt", "never pushed\n");
13678
13679 let (core, key) = discover_repo(&root_path);
13680
13681 assert_eq!(
13682 core.attempt_auto_update(&key),
13683 AutoUpdateAttempt::NotFastForward
13684 );
13685 }
13686
13687 #[test]
13689 fn a_branch_with_no_upstream_is_reported_through_the_wrapper_too() {
13690 let remote = seeded_remote();
13691 let root = tempfile::tempdir().expect("temp dir");
13692 let root_path = root_of(&root);
13693 let repo = root_path.join("repo");
13694 clone_into(remote.path(), &repo);
13695 crate::test_support::git(&repo, &["checkout", "-b", "untracked-branch"]);
13696
13697 let (core, key) = discover_repo(&root_path);
13698
13699 assert_eq!(
13700 core.attempt_auto_update(&key),
13701 AutoUpdateAttempt::NoUpstream
13702 );
13703 }
13704 }
13705
13706 mod network_default_branch {
13713 use super::*;
13714
13715 fn seeded_remote() -> tempfile::TempDir {
13716 let remote = tempfile::tempdir().expect("temp dir");
13717 crate::test_support::init_bare(remote.path());
13718 crate::test_support::push_new_commit(remote.path(), "README.md", "seed\n");
13719 remote
13720 }
13721
13722 fn clone_into(remote: &Path, dest: &Path) {
13723 let status = Command::new("git")
13724 .arg("clone")
13725 .arg(remote)
13726 .arg(dest)
13727 .status()
13728 .expect("run git clone");
13729 assert!(status.success());
13730 crate::test_support::set_identity(dest);
13731 }
13732
13733 fn set_remote_head(path: &Path, branch: &str) {
13736 git(
13737 path,
13738 &["symbolic-ref", "HEAD", &format!("refs/heads/{branch}")],
13739 );
13740 }
13741
13742 fn rev_parse(path: &Path, rev: &str) -> String {
13743 let output = Command::new("git")
13744 .arg("-C")
13745 .arg(path)
13746 .args(["rev-parse", rev])
13747 .output()
13748 .expect("run git rev-parse");
13749 assert!(output.status.success());
13750 String::from_utf8(output.stdout)
13751 .expect("utf8 sha")
13752 .trim()
13753 .to_string()
13754 }
13755
13756 fn default_branch_name(entity: &EntityState) -> Option<String> {
13757 match entity.default_branch.settled() {
13758 Some(Settled::Known {
13759 value,
13760 at: _,
13761 stale: _,
13762 }) => Some(value.name().to_string()),
13763 _ => None,
13764 }
13765 }
13766
13767 #[test]
13777 fn the_local_chain_answers_first_and_only_a_later_network_round_trip_supersedes_it() {
13778 let remote = seeded_remote();
13779 let root = tempfile::tempdir().expect("temp dir");
13780 let root_path = root_of(&root);
13781 let repo_path = root_path.join("repo");
13782 clone_into(remote.path(), &repo_path);
13783
13784 git(remote.path(), &["branch", "trunk"]);
13787 set_remote_head(remote.path(), "trunk");
13788
13789 let core = Core::start_discovered(spec(vec![root_path]));
13790 let key = core.snapshot().entities[0].key.clone();
13791
13792 core.refresh(std::slice::from_ref(&key));
13793 let settled = core.settle();
13794 assert_eq!(
13795 default_branch_name(&settled.entities[0]),
13796 Some("origin/main".to_string()),
13797 "a plain refresh must answer from the local chain alone, unaffected by the \
13798 remote's own current (but not yet asked) truth"
13799 );
13800
13801 core.rederive_default_branches(std::slice::from_ref(&key));
13802 let settled = core.settle();
13803 assert_eq!(
13804 default_branch_name(&settled.entities[0]),
13805 Some("origin/trunk".to_string()),
13806 "once the network round trip actually ran, its own differing answer must \
13807 supersede the local chain's"
13808 );
13809 }
13810
13811 #[test]
13821 fn rederive_default_branches_never_fetches_and_leaves_a_row_outside_it_untouched() {
13822 let remote = seeded_remote();
13823 let root = tempfile::tempdir().expect("temp dir");
13824 let root_path = root_of(&root);
13825 let selected_path = root_path.join("selected");
13826 let outside_path = root_path.join("outside");
13827 clone_into(remote.path(), &selected_path);
13828 init_repo_with_a_commit(&outside_path);
13829
13830 git(remote.path(), &["branch", "trunk"]);
13831 crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
13832 set_remote_head(remote.path(), "trunk");
13833 let before_tracking = rev_parse(&selected_path, "refs/remotes/origin/main");
13834
13835 let core = Core::start_discovered(spec(vec![root_path]));
13836 let snapshot = core.snapshot();
13837 let selected_key = snapshot
13838 .entities
13839 .iter()
13840 .find(|entity| entity.key.path() == selected_path)
13841 .expect("discovered the selected repo")
13842 .key
13843 .clone();
13844 let outside_key = snapshot
13845 .entities
13846 .iter()
13847 .find(|entity| entity.key.path() == outside_path)
13848 .expect("discovered the outside repo")
13849 .key
13850 .clone();
13851
13852 core.refresh(&[selected_key.clone(), outside_key.clone()]);
13853 let settled = core.settle();
13854 let outside_before = format!(
13855 "{:?}",
13856 settled
13857 .entities
13858 .iter()
13859 .find(|entity| entity.key == outside_key)
13860 .expect("outside entity present")
13861 );
13862
13863 core.rederive_default_branches(std::slice::from_ref(&selected_key));
13864 let settled = core.settle();
13865
13866 let selected_after = settled
13867 .entities
13868 .iter()
13869 .find(|entity| entity.key == selected_key)
13870 .expect("selected entity present");
13871 assert_eq!(
13872 default_branch_name(selected_after),
13873 Some("origin/trunk".to_string()),
13874 "the rederive must have reached the remote's own current, differing answer"
13875 );
13876
13877 let after_tracking = rev_parse(&selected_path, "refs/remotes/origin/main");
13878 assert_eq!(
13879 before_tracking, after_tracking,
13880 "a rederive must never fetch: the remote-tracking ref must not have moved \
13881 even though the remote gained a new commit"
13882 );
13883
13884 let outside_after = format!(
13885 "{:?}",
13886 settled
13887 .entities
13888 .iter()
13889 .find(|entity| entity.key == outside_key)
13890 .expect("outside entity present")
13891 );
13892 assert_eq!(
13893 outside_before, outside_after,
13894 "a row outside the rederive's own keys must be left exactly as it was, not \
13895 only on its default_branch cell"
13896 );
13897 }
13898 }
13899
13900 #[test]
13908 fn set_exclusions_excludes_a_row_already_in_the_table_with_no_rebuild() {
13909 let dir = tempfile::tempdir().expect("temp dir");
13910 let root = root_of(&dir);
13911 let repo = root.join("repo");
13912 init_repo_with_a_commit(&repo);
13913
13914 let core = Core::start_discovered(spec(vec![root]));
13915 let snapshot = core.settle();
13916 let key = snapshot.entities[0].key.clone();
13917 let generation_before = snapshot.generation;
13918 assert!(
13919 !snapshot.entities[0].excluded,
13920 "nothing excludes it to start with"
13921 );
13922 assert_eq!(core.operable_count(std::slice::from_ref(&key)), 1);
13923
13924 core.set_exclusions(&[RepoOverride {
13925 path: repo.clone(),
13926 default_branch: None,
13927 excluded: true,
13928 }]);
13929
13930 let after = core.snapshot();
13931 assert!(
13932 after.entities[0].excluded,
13933 "the row the write named is excluded in the very next snapshot"
13934 );
13935 assert_eq!(
13936 core.operable_count(&[key]),
13937 0,
13938 "an excluded row is subtracted from what an operation may reach"
13939 );
13940 assert_eq!(
13941 after.generation, generation_before,
13942 "re-applying an operate-time filter must start no Generation of its own"
13943 );
13944 }
13945
13946 #[test]
13949 fn set_exclusions_clears_the_flag_when_the_entry_is_gone() {
13950 let dir = tempfile::tempdir().expect("temp dir");
13951 let root = root_of(&dir);
13952 let repo = root.join("repo");
13953 init_repo_with_a_commit(&repo);
13954
13955 let core = Core::start_discovered(spec_with_overrides(
13956 vec![root],
13957 vec![RepoOverride {
13958 path: repo.clone(),
13959 default_branch: None,
13960 excluded: true,
13961 }],
13962 ));
13963 assert!(
13964 core.settle().entities[0].excluded,
13965 "the starting override excludes it"
13966 );
13967
13968 core.set_exclusions(&[]);
13969
13970 assert!(
13971 !core.snapshot().entities[0].excluded,
13972 "removing the entry unexcludes the row in the very next snapshot"
13973 );
13974 }
13975
13976 #[test]
13981 fn set_exclusions_moves_exclude_alone_and_never_the_default_branch_override() {
13982 let dir = tempfile::tempdir().expect("temp dir");
13983 let root = root_of(&dir);
13984 let repo = root.join("repo");
13985 init_repo_with_a_commit(&repo);
13986 crate::test_support::git(&repo, &["branch", "trunk"]);
13987
13988 let core = Core::start_discovered(spec(vec![root]));
13989 let key = core.settle().entities[0].key.clone();
13990 core.refresh(std::slice::from_ref(&key));
13991 let before = format!("{:?}", core.settle().entities[0].default_branch.settled());
13992
13993 core.set_exclusions(&[RepoOverride {
13994 path: repo.clone(),
13995 default_branch: Some("trunk".to_string()),
13996 excluded: true,
13997 }]);
13998 core.refresh(&[key]);
13999 core.settle();
14000
14001 let after = core.snapshot();
14002 assert!(after.entities[0].excluded, "exclude took effect");
14003 assert_eq!(
14004 format!("{:?}", after.entities[0].default_branch.settled()),
14005 before,
14006 "a default_branch override reaches a session only through a rebuilt Core"
14007 );
14008 }
14009
14010 #[test]
14018 fn record_own_work_leaves_one_receipt_per_row_it_names_and_none_elsewhere() {
14019 let dir = tempfile::tempdir().expect("temp dir");
14020 let root = root_of(&dir);
14021 init_repo_with_a_commit(&root.join("repo-a"));
14022 init_repo_with_a_commit(&root.join("repo-b"));
14023
14024 let core = Core::start_discovered(spec(vec![root]));
14025 let entities = core.settle().entities;
14026 let named = entities
14027 .iter()
14028 .find(|entity| &*entity.name == "repo-a")
14029 .expect("repo-a is discovered")
14030 .key
14031 .clone();
14032
14033 core.record_own_work(
14034 "ignore",
14035 &[(
14036 named.clone(),
14037 OwnWork::Refused(Arc::from("refused, already ignored")),
14038 Duration::from_millis(7),
14039 )],
14040 );
14041
14042 let after = core.snapshot().entities;
14043 let receipt = after
14044 .iter()
14045 .find(|entity| entity.key == named)
14046 .and_then(|entity| entity.last_action.clone())
14047 .expect("the row it named carries a receipt");
14048 assert_eq!(&*receipt.label, "ignore");
14049 assert!(
14050 !receipt.not_applicable(),
14051 "a refusal is not an excluded row"
14052 );
14053 assert!(receipt.running.is_none(), "the work is already done");
14054 assert_eq!(receipt.steps.len(), 1, "one act, not an ordered list");
14055 assert_eq!(&*receipt.steps[0].label, "ignore");
14056 assert_eq!(receipt.steps[0].elapsed, Duration::from_millis(7));
14057 assert!(receipt.steps[0].output.is_empty(), "nothing to quote");
14058 assert!(receipt.steps[0].elision.is_none());
14059 assert_eq!(
14060 receipt.steps[0].outcome,
14061 StepOutcome::OwnWork(OwnWork::Refused(Arc::from("refused, already ignored"))),
14062 );
14063 assert!(
14064 after
14065 .iter()
14066 .filter(|entity| entity.key != named)
14067 .all(|entity| entity.last_action.is_none()),
14068 "no row this did not name takes a receipt"
14069 );
14070 }
14071
14072 #[test]
14076 fn record_own_work_skips_a_key_the_table_no_longer_holds() {
14077 let dir = tempfile::tempdir().expect("temp dir");
14078 let root = root_of(&dir);
14079 init_repo_with_a_commit(&root.join("repo-a"));
14080
14081 let core = Core::start_discovered(spec(vec![root]));
14082 let entities = core.settle().entities;
14083 let stranger = EntityKey::new(Arc::from(std::path::Path::new("/nowhere/at/all")));
14084
14085 core.record_own_work(
14086 "delete",
14087 &[(stranger, OwnWork::Did(Arc::from("gone")), Duration::ZERO)],
14088 );
14089
14090 assert!(
14091 core.snapshot()
14092 .entities
14093 .iter()
14094 .all(|entity| entity.last_action.is_none()),
14095 "an unknown key writes nothing anywhere"
14096 );
14097 assert_eq!(core.snapshot().entities.len(), entities.len());
14098 }
14099
14100 #[test]
14109 fn delete_risk_reads_all_three_facts_the_confirm_gate_names() {
14110 let dir = tempfile::tempdir().expect("temp dir");
14111 let root = root_of(&dir);
14112 let repo = root.join("repo");
14113 init_repo_with_a_commit(&repo);
14114 fs::write(repo.join("uncommitted.txt"), "not staged\n").expect("write a stray file");
14115 crate::test_support::git(
14116 &repo,
14117 &["worktree", "add", "-b", "sidecar", "../sidecar-worktree"],
14118 );
14119
14120 let core = Core::start_discovered(spec(vec![root]));
14121 let key = core
14125 .settle()
14126 .entities
14127 .into_iter()
14128 .find(|entity| entity.kind == Kind::Repo)
14129 .expect("the Repo row is discovered")
14130 .key;
14131
14132 let risk = core.delete_risk(&key).expect("read the risk");
14133
14134 assert!(risk.uncommitted, "the stray file makes the tree dirty");
14135 assert!(
14136 risk.unpushed_commits > 0 && risk.unpushed_branches > 0,
14137 "no remote-tracking ref carries any of this Repo's commits, got {risk:?}"
14138 );
14139 assert_eq!(
14140 risk.linked_worktrees, 1,
14141 "the one linked Worktree pointing into this Repo is counted, got {risk:?}"
14142 );
14143 }
14144
14145 #[test]
14151 fn every_kind_of_work_that_is_not_in_a_commit_makes_the_gate_say_uncommitted() {
14152 for kind in ["modified", "deleted", "untracked", "staged"] {
14153 let dir = tempfile::tempdir().expect("temp dir");
14154 let root = root_of(&dir);
14155 let repo = root.join("repo");
14156 init_repo_with_a_commit(&repo);
14157 fs::write(repo.join("tracked.txt"), "first\n").expect("write a tracked file");
14158 crate::test_support::git(&repo, &["add", "tracked.txt"]);
14159 crate::test_support::git(&repo, &["commit", "-m", "add tracked"]);
14160 let sha = crate::test_support::head_sha(&repo);
14161 crate::test_support::git(&repo, &["update-ref", "refs/remotes/origin/main", &sha]);
14162
14163 match kind {
14164 "modified" => fs::write(repo.join("tracked.txt"), "second\n").expect("modify it"),
14165 "deleted" => fs::remove_file(repo.join("tracked.txt")).expect("delete it"),
14166 "untracked" => fs::write(repo.join("stray.txt"), "new\n").expect("write a stray"),
14167 "staged" => {
14168 fs::write(repo.join("staged.txt"), "new\n").expect("write a new file");
14169 crate::test_support::git(&repo, &["add", "staged.txt"]);
14170 }
14171 other => unreachable!("unhandled kind {other}"),
14172 }
14173
14174 let core = Core::start_discovered(spec(vec![root]));
14175 let key = core.settle().entities[0].key.clone();
14176
14177 let risk = core.delete_risk(&key).expect("read the risk");
14178
14179 assert!(
14180 risk.uncommitted,
14181 "a {kind} change is work that is not in a commit, got {risk:?}"
14182 );
14183 }
14184 }
14185
14186 #[test]
14193 fn staged_work_reads_clean_to_the_dirty_column_and_uncommitted_to_the_delete_gate() {
14194 let dir = tempfile::tempdir().expect("temp dir");
14195 let root = root_of(&dir);
14196 let repo = root.join("repo");
14197 init_repo_with_a_commit(&repo);
14198 let sha = crate::test_support::head_sha(&repo);
14199 crate::test_support::git(&repo, &["update-ref", "refs/remotes/origin/main", &sha]);
14200 fs::write(repo.join("staged.txt"), "staged\n").expect("write a new file");
14201 crate::test_support::git(&repo, &["add", "staged.txt"]);
14202
14203 let core = Core::start_discovered(spec(vec![root]));
14204 let key = core.settle().entities[0].key.clone();
14205
14206 let opened = git::open_thread_safe(repo.as_path())
14207 .expect("open the repo")
14208 .to_thread_local();
14209 let dirty = git::dirty_counts(&opened, Arc::new(AtomicBool::new(false)))
14210 .expect("read the dirty counts");
14211 assert_eq!(
14212 dirty.total(),
14213 0,
14214 "the dirty column stays an index-to-worktree comparison, got {dirty:?}"
14215 );
14216
14217 let risk = core.delete_risk(&key).expect("read the risk");
14218 assert!(
14219 risk.uncommitted,
14220 "a Repo whose only work is staged must never be listed plainly, got {risk:?}"
14221 );
14222 }
14223
14224 #[test]
14228 fn unpushed_commits_and_unpushed_branches_are_counted_into_their_own_fields() {
14229 let dir = tempfile::tempdir().expect("temp dir");
14230 let root = root_of(&dir);
14231 let repo = root.join("repo");
14232 init_repo_with_a_commit(&repo);
14233 let sha = crate::test_support::head_sha(&repo);
14234 crate::test_support::git(&repo, &["update-ref", "refs/remotes/origin/main", &sha]);
14235 for nth in 0..3 {
14236 fs::write(repo.join(format!("file-{nth}.txt")), "x\n").expect("write a file");
14237 crate::test_support::git(&repo, &["add", "."]);
14238 crate::test_support::git(&repo, &["commit", "-m", "unpushed"]);
14239 }
14240 crate::test_support::git(&repo, &["checkout", "."]);
14241
14242 let core = Core::start_discovered(spec(vec![root]));
14243 let key = core.settle().entities[0].key.clone();
14244
14245 let risk = core.delete_risk(&key).expect("read the risk");
14246
14247 assert_eq!(
14248 (risk.unpushed_commits, risk.unpushed_branches),
14249 (3, 1),
14250 "three commits on one branch, each in its own field, got {risk:?}"
14251 );
14252 }
14253
14254 #[test]
14258 fn a_linked_worktree_outside_the_sets_roots_is_still_counted_by_the_gate() {
14259 let dir = tempfile::tempdir().expect("temp dir");
14260 let base = root_of(&dir);
14261 let inside = base.join("inside");
14262 let outside = base.join("outside");
14263 fs::create_dir_all(&outside).expect("create the outside dir");
14264 let repo = inside.join("repo");
14265 init_repo_with_a_commit(&repo);
14266 crate::test_support::git(
14267 &repo,
14268 &["worktree", "add", "-b", "sidecar", "../../outside/sidecar"],
14269 );
14270 assert!(
14271 outside.join("sidecar").exists(),
14272 "the harness really created a linked Worktree outside the Set's roots"
14273 );
14274
14275 let core = Core::start_discovered(spec(vec![inside]));
14277 let snapshot = core.settle();
14278 assert!(
14279 snapshot
14280 .entities
14281 .iter()
14282 .all(|entity| entity.kind != Kind::Worktree),
14283 "the Worktree is outside the roots and so is not discovered, got {:?}",
14284 snapshot.entities.iter().map(|e| e.kind).collect::<Vec<_>>()
14285 );
14286 let key = snapshot
14287 .entities
14288 .into_iter()
14289 .find(|entity| entity.kind == Kind::Repo)
14290 .expect("the Repo row is discovered")
14291 .key;
14292
14293 let risk = core.delete_risk(&key).expect("read the risk");
14294
14295 assert_eq!(
14296 risk.linked_worktrees, 1,
14297 "the gate must name the linked Worktree deleting this Repo would orphan, got {risk:?}"
14298 );
14299 }
14300
14301 #[test]
14306 fn delete_risk_on_a_clean_fully_pushed_repo_with_no_worktrees_reports_nothing() {
14307 let dir = tempfile::tempdir().expect("temp dir");
14308 let root = root_of(&dir);
14309 let repo = root.join("repo");
14310 init_repo_with_a_commit(&repo);
14311 let sha = crate::test_support::head_sha(&repo);
14312 crate::test_support::git(&repo, &["update-ref", "refs/remotes/origin/main", &sha]);
14313
14314 let core = Core::start_discovered(spec(vec![root]));
14315 let key = core.settle().entities[0].key.clone();
14316
14317 let risk = core.delete_risk(&key).expect("read the risk");
14318
14319 assert_eq!(
14320 risk,
14321 DeleteRisk {
14322 uncommitted: false,
14323 unpushed_commits: 0,
14324 unpushed_branches: 0,
14325 linked_worktrees: 0,
14326 }
14327 );
14328 }
14329
14330 #[test]
14339 fn worktree_admin_dir_names_the_entry_git_worktree_list_forgets_once_it_is_removed() {
14340 let dir = tempfile::tempdir().expect("temp dir");
14341 let root = root_of(&dir);
14342 let repo = root.join("repo");
14343 init_repo_with_a_commit(&repo);
14344 let worktree = root.join("sidecar");
14345 crate::test_support::git(
14346 &repo,
14347 &[
14348 "worktree",
14349 "add",
14350 "-b",
14351 "sidecar",
14352 worktree.to_str().expect("utf8 path"),
14353 ],
14354 );
14355
14356 let core = Core::start_discovered(spec(vec![root]));
14357 let key = core
14358 .settle()
14359 .entities
14360 .into_iter()
14361 .find(|entity| entity.kind == Kind::Worktree)
14362 .expect("the Worktree row is discovered")
14363 .key;
14364
14365 let admin_dir = core.worktree_admin_dir(&key).expect("read the admin dir");
14366 fs::remove_dir_all(&admin_dir).expect("remove the admin dir by hand");
14367
14368 let reopened = git::open_thread_safe(&repo)
14369 .expect("reopen the repo")
14370 .to_thread_local();
14371 assert_eq!(
14372 git::linked_worktrees(&reopened).expect("count"),
14373 0,
14374 "removing the admin dir alone must be what git's own register stops naming"
14375 );
14376 }
14377
14378 #[test]
14382 fn worktree_admin_dir_errors_when_the_path_cannot_be_opened_as_a_repository() {
14383 let dir = tempfile::tempdir().expect("temp dir");
14384 let root = root_of(&dir);
14385 let not_a_repo = root.join("plain-directory");
14386 fs::create_dir_all(¬_a_repo).expect("create it");
14387
14388 let core = Core::start_discovered(spec(vec![root]));
14389 core.settle();
14390 let key = EntityKey::new(Arc::from(not_a_repo.as_path()));
14391
14392 assert!(core.worktree_admin_dir(&key).is_err());
14393 }
14394
14395 #[test]
14398 fn linked_worktree_paths_names_every_linked_worktrees_own_directory() {
14399 let dir = tempfile::tempdir().expect("temp dir");
14400 let root = root_of(&dir);
14401 let repo = root.join("repo");
14402 init_repo_with_a_commit(&repo);
14403 let first = root.join("first-worktree");
14404 let second = root.join("second-worktree");
14405 crate::test_support::git(
14406 &repo,
14407 &[
14408 "worktree",
14409 "add",
14410 "-b",
14411 "one",
14412 first.to_str().expect("utf8 path"),
14413 ],
14414 );
14415 crate::test_support::git(
14416 &repo,
14417 &[
14418 "worktree",
14419 "add",
14420 "-b",
14421 "two",
14422 second.to_str().expect("utf8 path"),
14423 ],
14424 );
14425
14426 let core = Core::start_discovered(spec(vec![root]));
14427 let key = core
14428 .settle()
14429 .entities
14430 .into_iter()
14431 .find(|entity| entity.kind == Kind::Repo)
14432 .expect("the Repo row is discovered")
14433 .key;
14434
14435 let mut paths = core
14436 .linked_worktree_paths(&key)
14437 .expect("read the linked worktree paths");
14438 paths.sort();
14439 let mut expected = vec![
14440 first.canonicalize().expect("canonicalize first"),
14441 second.canonicalize().expect("canonicalize second"),
14442 ];
14443 expected.sort();
14444
14445 assert_eq!(paths, expected);
14446 }
14447}