1use std::collections::{HashMap, HashSet};
35use std::path::{Path, PathBuf};
36use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
37use std::sync::{Arc, Condvar, Mutex, OnceLock, RwLock};
38use std::thread::{self, JoinHandle};
39use std::time::{Duration, Instant};
40
41use crossbeam_channel::{Receiver, Sender, select};
42use rayon::iter::{IntoParallelIterator, ParallelIterator};
43
44use crate::base;
45use crate::cell::{Cell, Generation, Settled, Timestamp, Unknown};
46use crate::default_branch;
47use crate::discovery::{self, SetSpec};
48use crate::entity::{
49 ActionReceipt, DefaultBranch, DeleteRisk, DirtyCounts, EntityKey, EntityState, Head, Kind,
50 OwnWork, Presence, RunningStep, Skip, StepOutcome, StepResult, SyncState, WorktreeState,
51};
52use crate::environment;
53use crate::executor;
54use crate::filter::{Applicability, Filter, Partition};
55use crate::git;
56use crate::landing;
57#[cfg(any(test, feature = "test-util"))]
58use crate::liveness;
59use crate::patch_equivalence;
60use crate::poll;
61use crate::snapshot::Snapshot;
62
63#[allow(dead_code)] const FIRST_FRAME_NAMES_BUDGET_MS: u64 = 50;
70
71#[allow(dead_code)] const FIRST_FRAME_CHEAP_COLUMNS_BUDGET_MS: u64 = 200;
77
78#[derive(Debug, Clone)]
87pub struct RepoOverride {
88 pub path: PathBuf,
89 pub default_branch: Option<String>,
90 pub excluded: bool,
91}
92
93#[derive(Debug, Clone)]
106pub struct Step {
107 pub argv: Vec<String>,
108 pub shell: bool,
109 pub interactive: bool,
115 pub env: Vec<(String, String)>,
116}
117
118#[derive(Debug, Clone)]
130pub struct ActionSpec {
131 pub label: Arc<str>,
132 pub name: Option<Arc<str>>,
133 pub steps: Vec<Step>,
134 pub concurrency: u32,
135 pub when: Option<Filter>,
136}
137
138#[derive(Debug, Clone)]
144struct ResolvedOverride {
145 path: PathBuf,
146 common_dir: PathBuf,
147 default_branch: Option<String>,
148}
149
150#[derive(Debug, Clone)]
156struct ResolvedExclusion {
157 path: PathBuf,
158 common_dir: PathBuf,
159 excluded: bool,
160}
161
162trait ResolvedEntry {
165 fn path(&self) -> &Path;
166 fn common_dir(&self) -> &Path;
167}
168
169impl ResolvedEntry for ResolvedOverride {
170 fn path(&self) -> &Path {
171 &self.path
172 }
173
174 fn common_dir(&self) -> &Path {
175 &self.common_dir
176 }
177}
178
179impl ResolvedEntry for ResolvedExclusion {
180 fn path(&self) -> &Path {
181 &self.path
182 }
183
184 fn common_dir(&self) -> &Path {
185 &self.common_dir
186 }
187}
188
189fn resolve_entries(overrides: &[RepoOverride]) -> (Vec<ResolvedOverride>, Vec<ResolvedExclusion>) {
196 overrides
197 .iter()
198 .filter_map(|entry| {
199 let common_dir = git::common_dir_of(&entry.path).ok()?;
200 Some((
201 ResolvedOverride {
202 path: entry.path.clone(),
203 common_dir: common_dir.to_path_buf(),
204 default_branch: entry.default_branch.clone(),
205 },
206 ResolvedExclusion {
207 path: entry.path.clone(),
208 common_dir: common_dir.to_path_buf(),
209 excluded: entry.excluded,
210 },
211 ))
212 })
213 .unzip()
214}
215
216fn find_entry<'a, T: ResolvedEntry>(
229 entries: &'a [T],
230 path: &Path,
231 common_dir: &Path,
232) -> Option<&'a T> {
233 entries
234 .iter()
235 .find(|entry| entry.path() == path)
236 .or_else(|| {
237 entries
238 .iter()
239 .find(|entry| entry.common_dir() == common_dir)
240 })
241}
242
243fn excluded_by(exclusions: &[ResolvedExclusion], path: &Path, common_dir: &Path) -> bool {
247 find_entry(exclusions, path, common_dir).is_some_and(|entry| entry.excluded)
248}
249
250fn dispatches_kind(kind: Kind, show_submodules: bool) -> bool {
257 match kind {
258 Kind::Repo | Kind::Worktree => true,
259 Kind::Submodule => show_submodules,
260 }
261}
262
263#[derive(Debug, Clone)]
269pub struct FetchSpec {
270 pub enabled: bool,
271 pub interval: Duration,
272 pub concurrency: usize,
273}
274
275#[derive(Debug, Clone, Copy)]
284pub struct AutoUpdateSpec {
285 pub enabled: bool,
286}
287
288#[derive(Debug, Clone, Default, PartialEq, Eq)]
301pub struct FetchFailures {
302 pub failed: Vec<(PathBuf, String)>,
303}
304
305#[derive(Debug, Clone, PartialEq, Eq)]
312pub enum AutoUpdateAttempt {
313 NotClean,
315 NoUpstream,
317 NotBehind,
319 NotFastForward,
321 Updated,
323 Failed(String),
325}
326
327#[derive(Debug, Clone)]
331pub struct CoreSpec {
332 pub set: SetSpec,
333 pub overrides: Vec<RepoOverride>,
334 pub poll_interval: Duration,
335 pub status_stale_after: Duration,
336 pub generation_deadline: Duration,
337 pub show_submodules: bool,
343 pub fetch: FetchSpec,
345 pub auto_update: AutoUpdateSpec,
350}
351
352struct InFlight {
357 generation: u64,
358 cancel: Arc<AtomicBool>,
359}
360
361struct Table {
364 generation: u64,
365 discovered_at: Timestamp,
366 entities: Vec<EntityState>,
367 index: HashMap<EntityKey, usize>,
368 in_flight: HashMap<EntityKey, InFlight>,
369 generation_started_at: HashMap<u64, Instant>,
372 repos: HashMap<EntityKey, Arc<gix::ThreadSafeRepository>>,
378 poll_fingerprints: HashMap<EntityKey, poll::GitdirFingerprint>,
385}
386
387enum ClockControl {
389 Pause,
390 Resume,
391 Shutdown,
392}
393
394pub struct Core {
402 table: Arc<RwLock<Table>>,
403 overrides: Arc<Vec<ResolvedOverride>>,
407 exclusions: Arc<RwLock<Vec<ResolvedExclusion>>>,
415 set: SetSpec,
423 discovery_manual: Arc<AtomicBool>,
428 discovery_warn_after: Duration,
431 discovery_abandon_after: Arc<AtomicU64>,
436 show_submodules: Arc<AtomicBool>,
443 settle_gate: Arc<SettleGate>,
444 control: Sender<ClockControl>,
445 clock_thread: Option<JoinHandle<()>>,
446 discovery_warning: Arc<Mutex<Option<String>>>,
452 #[allow(dead_code)] default_branch_chain_reads: Arc<AtomicUsize>,
464 #[allow(dead_code)] patch_identity_reads: Arc<AtomicUsize>,
474 #[allow(dead_code)] patch_scan_bounds: Arc<Mutex<Vec<Option<gix::ObjectId>>>>,
482 action_lifecycle: Arc<Mutex<ActionLifecycle>>,
488 #[allow(dead_code)] dispatch_log: Arc<Mutex<Vec<EntityKey>>>,
497 #[allow(dead_code)] phase_c_gates: Arc<Mutex<HashMap<EntityKey, PhaseCGateHandle>>>,
507 status_stale_after: Duration,
513 #[allow(dead_code)] poll_reprobed: Arc<Mutex<Vec<EntityKey>>>,
519 #[allow(dead_code)] poll_sweep_count: Arc<AtomicUsize>,
526 #[allow(dead_code)] fetch_cycle_count: Arc<AtomicUsize>,
533 network_default_branch: Arc<Mutex<HashMap<PathBuf, Arc<str>>>>,
544 fetch_failures: Arc<Mutex<FetchFailures>>,
548 turnstile: Arc<DispatchTurnstile>,
551 discovery_gate: Option<DiscoveryGate>,
553 #[cfg(test)]
556 action_completion_boundary: Arc<ActionCompletionBoundary>,
557}
558
559#[derive(Default)]
562struct PhaseCGate {
563 cheap_landed: bool,
565 may_proceed: bool,
568 finished: bool,
571}
572
573type PhaseCGateHandle = Arc<(Mutex<PhaseCGate>, Condvar)>;
576
577#[derive(Default)]
586struct ActionLifecycle {
587 live: Option<Arc<executor::RunControl>>,
596}
597
598impl ActionLifecycle {
599 fn admit(&mut self, control: Arc<executor::RunControl>) -> bool {
604 if self.live.is_some() {
605 return false;
606 }
607 self.live = Some(control);
608 true
609 }
610
611 fn complete(&mut self) {
613 self.live = None;
614 }
615}
616
617struct RunCompletion {
625 lifecycle: Arc<Mutex<ActionLifecycle>>,
626 #[cfg(test)]
628 boundary: Arc<ActionCompletionBoundary>,
629}
630
631impl Drop for RunCompletion {
632 fn drop(&mut self) {
633 #[cfg(test)]
635 self.boundary.hold();
636 self.lifecycle.lock().unwrap().complete();
637 }
638}
639
640#[cfg(test)]
649#[derive(Default)]
650pub(crate) struct ActionCompletionBoundary {
651 state: Mutex<BoundaryState>,
652 changed: Condvar,
653}
654
655#[cfg(test)]
657#[derive(Default)]
658struct BoundaryState {
659 armed: bool,
661 reached: bool,
663 released: bool,
665}
666
667#[cfg(test)]
668impl ActionCompletionBoundary {
669 pub(crate) fn arm(self: &Arc<Self>) -> ArmedBoundary {
672 self.state.lock().unwrap().armed = true;
673 ArmedBoundary(Arc::clone(self))
674 }
675
676 fn hold(&self) {
678 let mut state = self.state.lock().unwrap();
679 if !state.armed {
680 return;
681 }
682 state.reached = true;
683 self.changed.notify_all();
684 let (state, expiry) = self
685 .changed
686 .wait_timeout_while(state, liveness::BACKSTOP, |state| !state.released)
687 .unwrap();
688 drop(state);
689 if expiry.timed_out() {
690 liveness::expired(
691 liveness::BACKSTOP,
692 "a test to release the Action completion boundary",
693 "",
694 );
695 }
696 }
697}
698
699#[cfg(test)]
703pub(crate) struct ArmedBoundary(Arc<ActionCompletionBoundary>);
704
705#[cfg(test)]
706impl ArmedBoundary {
707 pub(crate) fn wait_until_reached(&self) {
709 let (state, expiry) = self
710 .0
711 .changed
712 .wait_timeout_while(self.0.state.lock().unwrap(), liveness::BACKSTOP, |state| {
713 !state.reached
714 })
715 .unwrap();
716 drop(state);
717 if expiry.timed_out() {
718 liveness::expired(
719 liveness::BACKSTOP,
720 "a completion to reach the Action completion boundary",
721 "",
722 );
723 }
724 }
725}
726
727#[cfg(test)]
728impl Drop for ArmedBoundary {
729 fn drop(&mut self) {
730 let mut state = self.0.state.lock().unwrap();
731 state.released = true;
732 self.0.changed.notify_all();
733 }
734}
735
736impl Core {
737 pub fn start(spec: CoreSpec) -> Core {
747 Self::start_watched(spec).core
748 }
749
750 fn start_watched(spec: CoreSpec) -> StartForTest {
752 let interval = spec.poll_interval.max(Duration::from_nanos(1));
753 let ticks = crossbeam_channel::tick(interval);
754 let alive = Arc::new(AtomicBool::new(true));
755 let fetch_start = FetchStart {
756 enabled: spec.fetch.enabled,
757 concurrency: spec.fetch.concurrency.max(1),
758 ticks: if spec.fetch.enabled {
759 crossbeam_channel::tick(spec.fetch.interval.max(Duration::from_nanos(1)))
760 } else {
761 crossbeam_channel::never()
762 },
763 };
764 start_internal(
765 spec,
766 Duration::from_secs(1),
767 discovery::ABANDON_AFTER,
768 ticks,
769 fetch_start,
770 alive,
771 None,
772 )
773 }
774
775 #[cfg(any(test, feature = "test-util"))]
786 pub fn start_discovered(spec: CoreSpec) -> Core {
787 let mut started = Self::start_watched(spec);
788 if let Some(handle) = started.initial_discovery.take() {
789 handle
790 .join()
791 .expect("the first discovery thread should not panic");
792 }
793 started.core
794 }
795
796 pub fn refresh(&self, order: &[EntityKey]) -> Generation {
801 self.refresh_handles().dispatch(order)
802 }
803
804 pub fn refresh_all(&self) -> Generation {
816 self.refresh_handles().dispatch_over_everything()
817 }
818
819 pub fn rederive_default_branches(&self, keys: &[EntityKey]) -> Generation {
844 let generation = {
845 let mut table = self.table.write().unwrap();
846 table.generation += 1;
847 Generation::new(table.generation)
848 };
849
850 let dispatched: Vec<RederiveCandidate> = {
851 let mut table = self.table.write().unwrap();
852 let mut dispatched = Vec::new();
853 for key in keys {
854 let Some(&idx) = table.index.get(key) else {
855 continue;
856 };
857 table.entities[idx].default_branch.begin_probe();
858 let common_dir = Arc::clone(&table.entities[idx].common_dir);
859 let override_branch = find_entry(&self.overrides, key.path(), &common_dir)
860 .and_then(|entry| entry.default_branch.clone());
861 let repo = table.repos.get(key).cloned();
862 let kind = table.entities[idx].kind;
863 dispatched.push(RederiveCandidate {
864 key: key.clone(),
865 path: key.path().to_path_buf(),
866 common_dir,
867 repo,
868 override_branch,
869 kind,
870 });
871 }
872 dispatched
873 };
874
875 if dispatched.is_empty() {
876 return generation;
877 }
878
879 begin_probes_owed(&self.settle_gate, dispatched.len());
880
881 let table = Arc::clone(&self.table);
882 let settle_gate = Arc::clone(&self.settle_gate);
883 let network_default_branch = Arc::clone(&self.network_default_branch);
884 thread::spawn(move || {
885 let common_dirs: HashSet<Arc<Path>> = dispatched
886 .iter()
887 .map(|candidate| Arc::clone(&candidate.common_dir))
888 .collect();
889 probe_network_default_branches(&common_dirs, &network_default_branch);
890
891 let chain_cache: ChainFactsCache = Mutex::new(HashMap::new());
896 let chain_reads = AtomicUsize::new(0);
897 let never_cancelled = AtomicBool::new(false);
898
899 for candidate in dispatched {
900 let RederiveCandidate {
901 key,
902 path,
903 common_dir,
904 repo,
905 override_branch,
906 kind,
907 } = candidate;
908 let network_branch = network_branch_for(&network_default_branch, &common_dir);
909 let resolution = probe_default_branch_memoised(
910 &path,
911 repo.as_deref(),
912 &common_dir,
913 DefaultBranchHints {
914 override_branch: override_branch.as_deref(),
915 network_branch: network_branch.as_deref(),
916 },
917 kind,
918 &never_cancelled,
919 &ChainFactsMemo {
920 cache: &chain_cache,
921 reads: &chain_reads,
922 },
923 );
924 {
925 let mut table = table.write().unwrap();
926 if let (Some(&idx), Some(resolution)) = (table.index.get(&key), resolution) {
927 table.entities[idx].apply_default_branch_resolution(generation, resolution);
928 }
929 }
930 complete_one(&settle_gate);
931 }
932 });
933
934 generation
935 }
936
937 fn refresh_handles(&self) -> RefreshHandles {
946 RefreshHandles {
947 table: Arc::clone(&self.table),
948 overrides: Arc::clone(&self.overrides),
949 exclusions: Arc::clone(&self.exclusions),
950 set: self.set.clone(),
951 discovery_manual: Arc::clone(&self.discovery_manual),
952 discovery_warn_after: self.discovery_warn_after,
953 discovery_abandon_after: Arc::clone(&self.discovery_abandon_after),
954 discovery_warning: Arc::clone(&self.discovery_warning),
955 show_submodules: Arc::clone(&self.show_submodules),
956 settle_gate: Arc::clone(&self.settle_gate),
957 default_branch_chain_reads: Arc::clone(&self.default_branch_chain_reads),
958 patch_identity_reads: Arc::clone(&self.patch_identity_reads),
959 patch_scan_bounds: Arc::clone(&self.patch_scan_bounds),
960 dispatch_log: Arc::clone(&self.dispatch_log),
961 phase_c_gates: Arc::clone(&self.phase_c_gates),
962 network_default_branch: Arc::clone(&self.network_default_branch),
963 turnstile: Arc::clone(&self.turnstile),
964 discovery_gate: self.discovery_gate.clone(),
965 }
966 }
967
968 pub fn probe_now(&self, key: &EntityKey) -> EntityState {
973 let never_cancelled = Arc::new(AtomicBool::new(false));
977 let (cached_repo, common_dir_hint, probes_state, probes_base, kind) = {
978 let table = self.table.read().unwrap();
979 let repo = table.repos.get(key).cloned();
980 let common_dir = table
981 .index
982 .get(key)
983 .map(|&idx| Arc::clone(&table.entities[idx].common_dir));
984 let probes_state = table
989 .index
990 .get(key)
991 .map(|&idx| table.entities[idx].probes_state())
992 .unwrap_or(false);
993 let probes_base = table
994 .index
995 .get(key)
996 .map(|&idx| table.entities[idx].probes_base())
997 .unwrap_or(true);
998 let kind = table
1001 .index
1002 .get(key)
1003 .map(|&idx| table.entities[idx].kind)
1004 .unwrap_or(Kind::Repo);
1005 (repo, common_dir, probes_state, probes_base, kind)
1006 };
1007 let common_dir_hint = common_dir_hint.unwrap_or_else(|| Arc::from(key.path().join(".git")));
1008 let override_branch = find_entry(&self.overrides, key.path(), &common_dir_hint)
1009 .and_then(|entry| entry.default_branch.clone());
1010 let excluded = excluded_by(
1011 &self.exclusions.read().unwrap(),
1012 key.path(),
1013 &common_dir_hint,
1014 );
1015
1016 let branch_outcome =
1017 probe_branch(key.path(), cached_repo.as_deref(), kind, &never_cancelled);
1018 let sync_outcome = probe_sync(
1019 key.path(),
1020 cached_repo.as_deref(),
1021 branch_outcome.as_ref().map(|(settled, ..)| settled),
1022 kind,
1023 &never_cancelled,
1024 );
1025 let default_branch_outcome = probe_default_branch(
1026 key.path(),
1027 cached_repo.as_deref(),
1028 DefaultBranchHints {
1029 override_branch: override_branch.as_deref(),
1030 network_branch: network_branch_for(&self.network_default_branch, &common_dir_hint)
1031 .as_deref(),
1032 },
1033 kind,
1034 &never_cancelled,
1035 );
1036 let base_outcome = if probes_base {
1037 probe_base(
1038 key.path(),
1039 cached_repo.as_deref(),
1040 branch_outcome.as_ref().map(|(settled, ..)| settled),
1041 default_branch_outcome.as_ref().map(|r| &r.settled),
1042 &never_cancelled,
1043 )
1044 } else {
1045 None
1046 };
1047 let state_outcome = if probes_state {
1048 let patch_cache: PatchIdentityCache = Mutex::new(HashMap::new());
1053 let patch_reads = AtomicUsize::new(0);
1054 let patch_scan_bounds = Mutex::new(Vec::new());
1055 let gate = BoundGate::new(1);
1056 let mut report = GateReport::new(&gate);
1057 let memo = PatchEquivalenceMemo {
1058 cache: &patch_cache,
1059 reads: &patch_reads,
1060 scan_bounds: &patch_scan_bounds,
1061 };
1062 probe_worktree_state(
1063 key.path(),
1064 cached_repo.as_deref(),
1065 default_branch_outcome.as_ref().map(|r| &r.settled),
1066 &common_dir_hint,
1067 &never_cancelled,
1068 &memo,
1069 &mut report,
1070 )
1071 } else {
1072 None
1073 };
1074 let dirty_outcome =
1075 probe_status(key.path(), cached_repo.as_deref(), kind, &never_cancelled);
1076
1077 let mut table = self.table.write().unwrap();
1078 let generation = Generation::new(table.generation);
1079 let idx = match table.index.get(key).copied() {
1080 Some(idx) => idx,
1081 None => {
1082 let name = display_name(key.path());
1083 table.entities.push(EntityState::new(
1084 key.clone(),
1085 name,
1086 common_dir_hint,
1087 Kind::Repo,
1088 ));
1089 let idx = table.entities.len() - 1;
1090 table.index.insert(key.clone(), idx);
1091 idx
1092 }
1093 };
1094 table.entities[idx].excluded = excluded;
1095 if let Some((settled, in_progress, recent)) = branch_outcome {
1096 table.entities[idx].apply_branch_probe(generation, settled, in_progress, recent);
1097 }
1098 if let Some(settled) = sync_outcome {
1099 table.entities[idx].sync.settle(generation, settled);
1100 }
1101 if let Some(settled) = base_outcome {
1102 table.entities[idx].base.settle(generation, settled);
1103 }
1104 if let Some(resolution) = default_branch_outcome {
1105 table.entities[idx].apply_default_branch_resolution(generation, resolution);
1106 }
1107 if let Some(settled) = state_outcome {
1108 table.entities[idx].state.settle(generation, settled);
1109 }
1110 if let Some(settled) = dirty_outcome {
1111 table.entities[idx].dirty.settle(generation, settled);
1112 }
1113 table.entities[idx].clone()
1114 }
1115
1116 pub fn snapshot(&self) -> Snapshot {
1122 let table = self.table.read().unwrap();
1123 let mut entities = table.entities.clone();
1124 for entity in &mut entities {
1125 entity.age_status_cells(self.status_stale_after);
1126 }
1127 Snapshot {
1128 generation: Generation::new(table.generation),
1129 discovered_at: table.discovered_at,
1130 entities,
1131 }
1132 }
1133
1134 pub fn try_settle(&self, within: Duration) -> Result<Snapshot, Snapshot> {
1144 let (lock, cvar) = &*self.settle_gate;
1145 let guard = lock.lock().unwrap();
1146 let (guard, timeout) = cvar
1147 .wait_timeout_while(guard, within, |counts| !counts.is_settled())
1148 .unwrap();
1149 drop(guard);
1152 let snapshot = self.snapshot();
1153 if timeout.timed_out() {
1154 Err(snapshot)
1155 } else {
1156 Ok(snapshot)
1157 }
1158 }
1159
1160 #[cfg(any(test, feature = "test-util"))]
1171 pub fn settle(&self) -> Snapshot {
1172 self.settle_within(liveness::BACKSTOP)
1173 }
1174
1175 #[cfg(any(test, feature = "test-util"))]
1179 fn settle_within(&self, deadline: Duration) -> Snapshot {
1180 self.try_settle(deadline).unwrap_or_else(|_| {
1181 let (probes, dispatches) = {
1185 let counts = self.settle_gate.0.lock().unwrap();
1186 (counts.probes, counts.dispatches)
1187 };
1188 liveness::expired(
1189 deadline,
1190 "everything this Core has in flight to land",
1191 &format!("{probes} probe(s) and {dispatches} dispatch(es) still outstanding"),
1192 )
1193 })
1194 }
1195
1196 pub fn delete_risk(&self, key: &EntityKey) -> Result<DeleteRisk, git::ProbeError> {
1212 let repo = git::open_thread_safe(key.path())?.to_thread_local();
1213 let dirty = git::dirty_counts(&repo, Arc::new(AtomicBool::new(false)))?;
1214 let staged = git::staged_changes(&repo)?;
1215 let (unpushed_commits, unpushed_branches) = git::unpushed(&repo)?;
1216 let linked_worktrees = git::linked_worktrees(&repo)?;
1217 Ok(DeleteRisk {
1218 uncommitted: dirty.total() > 0 || staged,
1219 unpushed_commits,
1220 unpushed_branches,
1221 linked_worktrees,
1222 })
1223 }
1224
1225 pub fn worktree_admin_dir(&self, key: &EntityKey) -> Result<PathBuf, git::ProbeError> {
1233 let repo = git::open_thread_safe(key.path())?.to_thread_local();
1234 Ok(git::worktree_admin_dir(&repo))
1235 }
1236
1237 pub fn linked_worktree_paths(&self, key: &EntityKey) -> Result<Vec<PathBuf>, git::ProbeError> {
1244 let repo = git::open_thread_safe(key.path())?.to_thread_local();
1245 git::linked_worktree_paths(&repo)
1246 }
1247
1248 pub fn ignored_directories_for_deletion(
1256 &self,
1257 path: &Path,
1258 ) -> Result<Vec<PathBuf>, git::ProbeError> {
1259 let repo = git::open_thread_safe(path)?.to_thread_local();
1260 git::ignored_directories_for_deletion(&repo)
1261 }
1262
1263 pub fn attempt_auto_update(&self, key: &EntityKey) -> AutoUpdateAttempt {
1270 match crate::auto_update::attempt(key.path()) {
1271 crate::auto_update::Outcome::Ineligible(crate::auto_update::Ineligible::NotClean) => {
1272 AutoUpdateAttempt::NotClean
1273 }
1274 crate::auto_update::Outcome::Ineligible(crate::auto_update::Ineligible::NoUpstream) => {
1275 AutoUpdateAttempt::NoUpstream
1276 }
1277 crate::auto_update::Outcome::Ineligible(crate::auto_update::Ineligible::NotBehind) => {
1278 AutoUpdateAttempt::NotBehind
1279 }
1280 crate::auto_update::Outcome::Ineligible(
1281 crate::auto_update::Ineligible::NotFastForward,
1282 ) => AutoUpdateAttempt::NotFastForward,
1283 crate::auto_update::Outcome::Updated { .. } => AutoUpdateAttempt::Updated,
1284 crate::auto_update::Outcome::Failed(error) => AutoUpdateAttempt::Failed(error),
1285 }
1286 }
1287
1288 pub fn run_action_for_entity_blocking(
1301 &self,
1302 action: &ActionSpec,
1303 key: &EntityKey,
1304 ) -> Option<ActionReceipt> {
1305 let entity = {
1306 let table = self.table.read().unwrap();
1307 let idx = *table.index.get(key)?;
1308 table.entities[idx].clone()
1309 };
1310 let control = executor::RunControl::new();
1311 Some(run_action_for_entity(&entity, action, &control, &|_| {}))
1312 }
1313
1314 pub fn management_handle(&self) -> ManagementHandle {
1320 ManagementHandle {
1321 table: Arc::clone(&self.table),
1322 }
1323 }
1324
1325 pub fn dismiss(&self, key: &EntityKey) {
1327 let mut table = self.table.write().unwrap();
1328 if let Some(idx) = table.index.remove(key) {
1329 table.entities.remove(idx);
1330 for position in table.index.values_mut() {
1331 if *position > idx {
1332 *position -= 1;
1333 }
1334 }
1335 }
1336 table.poll_fingerprints.remove(key);
1337 if let Some(in_flight) = table.in_flight.remove(key) {
1338 in_flight.cancel.store(true, Ordering::Release);
1339 drop(table);
1340 complete_one(&self.settle_gate);
1341 }
1342 }
1343
1344 fn partition_operable(&self, order: &[EntityKey]) -> (Vec<EntityState>, Vec<EntityState>) {
1356 let table = self.table.read().unwrap();
1357 order
1358 .iter()
1359 .filter_map(|key| table.index.get(key).map(|&idx| table.entities[idx].clone()))
1360 .partition(|entity| !entity.excluded)
1361 }
1362
1363 pub fn operable_count(&self, order: &[EntityKey]) -> usize {
1370 self.partition_operable(order).0.len()
1371 }
1372
1373 pub fn vanished_count(&self) -> usize {
1377 self.table
1378 .read()
1379 .unwrap()
1380 .entities
1381 .iter()
1382 .filter(|entity| entity.presence == Presence::Vanished)
1383 .count()
1384 }
1385
1386 pub fn applicability(&self, order: &[EntityKey], when: &Filter) -> Applicability {
1400 when.applicability(self.partition_operable(order).0.iter())
1401 }
1402
1403 pub fn action_running(&self) -> bool {
1410 self.action_lifecycle.lock().unwrap().live.is_some()
1411 }
1412
1413 pub fn refresh_running(&self) -> bool {
1423 let (lock, _cvar) = &*self.settle_gate;
1424 !lock.lock().unwrap().is_settled()
1425 }
1426
1427 pub fn run_action(&self, action: ActionSpec, order: &[EntityKey]) -> bool {
1475 let control = executor::RunControl::new();
1480 if !self
1481 .action_lifecycle
1482 .lock()
1483 .unwrap()
1484 .admit(Arc::clone(&control))
1485 {
1486 return false;
1487 }
1488
1489 cancel_in_flight(&self.table, &self.settle_gate);
1492
1493 let (operable, excluded) = self.partition_operable(order);
1494
1495 let write_skip_receipts = |entities: &[EntityState], skip: Skip| {
1496 if entities.is_empty() {
1497 return;
1498 }
1499 let finished_at = Timestamp::now();
1500 let mut table = self.table.write().unwrap();
1501 for entity in entities {
1502 if let Some(&idx) = table.index.get(&entity.key) {
1503 table.entities[idx].last_action = Some(ActionReceipt {
1504 label: Arc::clone(&action.label),
1505 steps: Arc::from(Vec::new()),
1506 skip: Some(skip),
1507 finished_at,
1508 running: None,
1509 });
1510 }
1511 }
1512 };
1513
1514 write_skip_receipts(&excluded, Skip::Excluded);
1515
1516 let included = match &action.when {
1517 Some(when) => {
1518 let Partition {
1519 applicable,
1520 inapplicable,
1521 unresolved,
1522 } = when.partition(operable);
1523 write_skip_receipts(&inapplicable, Skip::Inapplicable);
1524 write_skip_receipts(&unresolved, Skip::Unresolved);
1525 applicable
1526 }
1527 None => operable,
1528 };
1529
1530 let table_handle = Arc::clone(&self.table);
1531 let refresh_handles = self.refresh_handles();
1532 let action_lifecycle = Arc::clone(&self.action_lifecycle);
1533 #[cfg(test)]
1534 let completion_boundary = Arc::clone(&self.action_completion_boundary);
1535 let concurrency = action.concurrency.max(1) as usize;
1542
1543 thread::spawn(move || {
1549 let pool = rayon::ThreadPoolBuilder::new()
1550 .num_threads(concurrency)
1551 .build()
1552 .expect("build the Action fan-out's own dedicated pool");
1553
1554 let fan_out = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1560 pool.install(|| {
1561 included.into_par_iter().for_each(|entity| {
1562 let write_receipt = |receipt: ActionReceipt| {
1563 let mut table = table_handle.write().unwrap();
1564 if let Some(&idx) = table.index.get(&entity.key) {
1565 table.entities[idx].last_action = Some(receipt);
1566 }
1567 };
1568 let receipt =
1569 run_action_for_entity(&entity, &action, &control, &write_receipt);
1570 write_receipt(receipt);
1571 });
1572 });
1573 }));
1574
1575 let completion = RunCompletion {
1586 lifecycle: action_lifecycle,
1587 #[cfg(test)]
1588 boundary: completion_boundary,
1589 };
1590
1591 let Ok(()) = fan_out else {
1596 return;
1597 };
1598
1599 let all_keys: Vec<EntityKey> = table_handle
1602 .read()
1603 .unwrap()
1604 .entities
1605 .iter()
1606 .map(|entity| entity.key.clone())
1607 .collect();
1608 refresh_handles.dispatch(&all_keys);
1609 drop(completion);
1610 });
1612
1613 true
1614 }
1615
1616 pub fn hold_action(&self) {
1623 if let Some(control) = self.live_action_control() {
1624 control.hold();
1625 }
1626 }
1627
1628 pub fn continue_action(&self) {
1631 if let Some(control) = self.live_action_control() {
1632 control.continue_run();
1633 }
1634 }
1635
1636 pub fn stop_action(&self) {
1645 if let Some(control) = self.live_action_control() {
1646 control.cancel();
1647 }
1648 }
1649
1650 fn live_action_control(&self) -> Option<Arc<executor::RunControl>> {
1654 self.action_lifecycle.lock().unwrap().live.clone()
1655 }
1656
1657 pub fn pause(&self) {
1660 let _ = self.control.send(ClockControl::Pause);
1661 }
1662
1663 pub fn resume(&self) {
1666 let _ = self.control.send(ClockControl::Resume);
1667 }
1668
1669 pub fn discovery_warning(&self) -> Option<String> {
1675 self.discovery_warning.lock().unwrap().clone()
1676 }
1677
1678 pub fn fetch_failures(&self) -> FetchFailures {
1685 self.fetch_failures.lock().unwrap().clone()
1686 }
1687
1688 pub fn set_show_submodules(&self, show_submodules: bool) {
1695 self.show_submodules
1696 .store(show_submodules, Ordering::Release);
1697 }
1698
1699 pub fn record_own_work(&self, label: &str, results: &[(EntityKey, OwnWork, Duration)]) {
1719 let label: Arc<str> = Arc::from(label);
1720 let finished_at = Timestamp::now();
1721 let mut table = self.table.write().unwrap();
1722 for (key, work, elapsed) in results {
1723 let Some(&idx) = table.index.get(key) else {
1724 continue;
1725 };
1726 table.entities[idx].last_action = Some(ActionReceipt {
1727 label: Arc::clone(&label),
1728 steps: Arc::from(vec![StepResult {
1729 label: Arc::clone(&label),
1730 outcome: StepOutcome::OwnWork(work.clone()),
1731 output: Arc::from(&b""[..]),
1732 elapsed: *elapsed,
1733 elision: None,
1734 shell: false,
1735 interactive: false,
1736 }]),
1737 skip: None,
1738 finished_at,
1739 running: None,
1740 });
1741 }
1742 }
1743
1744 pub fn set_exclusions(&self, overrides: &[RepoOverride]) {
1755 let (_, resolved) = resolve_entries(overrides);
1756 {
1759 let mut exclusions = self.exclusions.write().unwrap();
1760 *exclusions = resolved.clone();
1761 }
1762 let mut table = self.table.write().unwrap();
1763 for entity in &mut table.entities {
1764 entity.excluded = excluded_by(&resolved, entity.key.path(), &entity.common_dir);
1765 }
1766 }
1767}
1768
1769#[derive(Clone)]
1778pub struct ManagementHandle {
1779 table: Arc<RwLock<Table>>,
1780}
1781
1782impl ManagementHandle {
1783 pub fn worktree_admin_dir(&self, key: &EntityKey) -> Result<PathBuf, git::ProbeError> {
1785 let repo = git::open_thread_safe(key.path())?.to_thread_local();
1786 Ok(git::worktree_admin_dir(&repo))
1787 }
1788
1789 pub fn linked_worktree_paths(&self, key: &EntityKey) -> Result<Vec<PathBuf>, git::ProbeError> {
1791 let repo = git::open_thread_safe(key.path())?.to_thread_local();
1792 git::linked_worktree_paths(&repo)
1793 }
1794
1795 pub fn ignored_directories_for_deletion(
1798 &self,
1799 path: &Path,
1800 ) -> Result<Vec<PathBuf>, git::ProbeError> {
1801 let repo = git::open_thread_safe(path)?.to_thread_local();
1802 git::ignored_directories_for_deletion(&repo)
1803 }
1804
1805 pub fn attempt_auto_update(&self, key: &EntityKey) -> AutoUpdateAttempt {
1807 match crate::auto_update::attempt(key.path()) {
1808 crate::auto_update::Outcome::Ineligible(crate::auto_update::Ineligible::NotClean) => {
1809 AutoUpdateAttempt::NotClean
1810 }
1811 crate::auto_update::Outcome::Ineligible(crate::auto_update::Ineligible::NoUpstream) => {
1812 AutoUpdateAttempt::NoUpstream
1813 }
1814 crate::auto_update::Outcome::Ineligible(crate::auto_update::Ineligible::NotBehind) => {
1815 AutoUpdateAttempt::NotBehind
1816 }
1817 crate::auto_update::Outcome::Ineligible(
1818 crate::auto_update::Ineligible::NotFastForward,
1819 ) => AutoUpdateAttempt::NotFastForward,
1820 crate::auto_update::Outcome::Updated { .. } => AutoUpdateAttempt::Updated,
1821 crate::auto_update::Outcome::Failed(error) => AutoUpdateAttempt::Failed(error),
1822 }
1823 }
1824
1825 pub fn run_action_for_entity_blocking(
1828 &self,
1829 action: &ActionSpec,
1830 key: &EntityKey,
1831 ) -> Option<ActionReceipt> {
1832 let entity = {
1833 let table = self.table.read().unwrap();
1834 let idx = *table.index.get(key)?;
1835 table.entities[idx].clone()
1836 };
1837 let control = executor::RunControl::new();
1838 Some(run_action_for_entity(&entity, action, &control, &|_| {}))
1839 }
1840}
1841
1842#[derive(Clone)]
1852struct RefreshHandles {
1853 table: Arc<RwLock<Table>>,
1854 overrides: Arc<Vec<ResolvedOverride>>,
1855 exclusions: Arc<RwLock<Vec<ResolvedExclusion>>>,
1858 set: SetSpec,
1859 discovery_manual: Arc<AtomicBool>,
1860 discovery_warn_after: Duration,
1861 discovery_abandon_after: Arc<AtomicU64>,
1862 discovery_warning: Arc<Mutex<Option<String>>>,
1863 show_submodules: Arc<AtomicBool>,
1864 settle_gate: Arc<SettleGate>,
1865 default_branch_chain_reads: Arc<AtomicUsize>,
1866 patch_identity_reads: Arc<AtomicUsize>,
1867 patch_scan_bounds: Arc<Mutex<Vec<Option<gix::ObjectId>>>>,
1868 dispatch_log: Arc<Mutex<Vec<EntityKey>>>,
1869 phase_c_gates: Arc<Mutex<HashMap<EntityKey, PhaseCGateHandle>>>,
1870 network_default_branch: Arc<Mutex<HashMap<PathBuf, Arc<str>>>>,
1874 turnstile: Arc<DispatchTurnstile>,
1877 discovery_gate: Option<DiscoveryGate>,
1879}
1880
1881#[derive(Default)]
1890struct DispatchTurnstile {
1891 serving: Mutex<u64>,
1893 ready: Condvar,
1894 next: AtomicU64,
1898}
1899
1900impl DispatchTurnstile {
1901 fn reserve(&self) -> u64 {
1902 self.next.fetch_add(1, Ordering::AcqRel)
1903 }
1904
1905 fn take(&self, ticket: u64) -> DispatchTurn<'_> {
1909 let serving = self.serving.lock().unwrap();
1910 drop(
1911 self.ready
1912 .wait_while(serving, |serving| *serving != ticket)
1913 .unwrap(),
1914 );
1915 DispatchTurn {
1916 turnstile: self,
1917 ticket,
1918 }
1919 }
1920}
1921
1922struct DispatchTurn<'a> {
1924 turnstile: &'a DispatchTurnstile,
1925 ticket: u64,
1926}
1927
1928impl Drop for DispatchTurn<'_> {
1929 fn drop(&mut self) {
1930 let mut serving = self.turnstile.serving.lock().unwrap();
1931 *serving = self.ticket + 1;
1932 self.turnstile.ready.notify_all();
1933 }
1934}
1935
1936impl RefreshHandles {
1937 fn dispatch(&self, order: &[EntityKey]) -> Generation {
1946 let (generation, ticket) = self.reserve_generation();
1947 begin_dispatch(&self.settle_gate);
1948 let handles = self.clone();
1949 let order = order.to_vec();
1950 thread::spawn(move || {
1951 let _turn = handles.turnstile.take(ticket);
1952 handles.run_generation(&order, generation);
1953 finish_dispatch(&handles.settle_gate);
1954 });
1955 generation
1956 }
1957
1958 fn dispatch_over_everything(&self) -> Generation {
1962 let (generation, ticket) = self.reserve_generation();
1963 begin_dispatch(&self.settle_gate);
1964 let handles = self.clone();
1965 thread::spawn(move || {
1966 let _turn = handles.turnstile.take(ticket);
1967 handles.rediscover();
1968 let order: Vec<EntityKey> = handles
1969 .table
1970 .read()
1971 .unwrap()
1972 .entities
1973 .iter()
1974 .map(|entity| entity.key.clone())
1975 .collect();
1976 handles.dispatch_probes(&order, generation);
1977 finish_dispatch(&handles.settle_gate);
1978 });
1979 generation
1980 }
1981
1982 fn reserve_generation(&self) -> (Generation, u64) {
1985 let mut table = self.table.write().unwrap();
1986 table.generation += 1;
1987 (Generation::new(table.generation), self.turnstile.reserve())
1988 }
1989
1990 fn run_generation(&self, order: &[EntityKey], generation: Generation) {
1993 self.rediscover();
1994 self.dispatch_probes(order, generation);
1995 }
1996
1997 fn rediscover(&self) {
2003 if !self.discovery_manual.load(Ordering::Acquire) {
2004 self.rerun_discovery();
2005 }
2006 }
2007
2008 fn dispatch_probes(&self, order: &[EntityKey], generation: Generation) {
2014 self.default_branch_chain_reads.store(0, Ordering::Release);
2019 self.patch_identity_reads.store(0, Ordering::Release);
2020 self.patch_scan_bounds.lock().unwrap().clear();
2021 self.dispatch_log.lock().unwrap().clear();
2022
2023 let generation_number = generation.value();
2024 let mut table = self.table.write().unwrap();
2025 table
2026 .generation_started_at
2027 .insert(generation_number, Instant::now());
2028
2029 let show_submodules = self.show_submodules.load(Ordering::Acquire);
2030 let mut dispatched = Vec::new();
2031 for key in order {
2032 let Some(&idx) = table.index.get(key) else {
2033 continue;
2034 };
2035 if !dispatches_kind(table.entities[idx].kind, show_submodules) {
2036 continue;
2040 }
2041 if let Some(previous) = table.in_flight.remove(key) {
2042 previous.cancel.store(true, Ordering::Release);
2043 }
2044 let cancel = Arc::new(AtomicBool::new(false));
2045 table.in_flight.insert(
2046 key.clone(),
2047 InFlight {
2048 generation: generation_number,
2049 cancel: Arc::clone(&cancel),
2050 },
2051 );
2052 begin_probes(&mut table.entities[idx]);
2053 dispatched.push((key.clone(), cancel));
2054 }
2055
2056 if dispatched.is_empty() {
2057 return;
2058 }
2059
2060 begin_probes_owed(&self.settle_gate, dispatched.len());
2061 let repos: Vec<Option<Arc<gix::ThreadSafeRepository>>> = dispatched
2062 .iter()
2063 .map(|(key, _)| table.repos.get(key).cloned())
2064 .collect();
2065 let override_branches: Vec<Option<String>> = dispatched
2066 .iter()
2067 .map(|(key, _)| {
2068 let idx = table.index[key];
2069 let common_dir = &table.entities[idx].common_dir;
2070 find_entry(&self.overrides, key.path(), common_dir)
2071 .and_then(|entry| entry.default_branch.clone())
2072 })
2073 .collect();
2074 let network_branches: Vec<Option<Arc<str>>> = dispatched
2075 .iter()
2076 .map(|(key, _)| {
2077 let idx = table.index[key];
2078 let common_dir = &table.entities[idx].common_dir;
2079 network_branch_for(&self.network_default_branch, common_dir)
2080 })
2081 .collect();
2082 let common_dirs: Vec<Arc<Path>> = dispatched
2083 .iter()
2084 .map(|(key, _)| Arc::clone(&table.entities[table.index[key]].common_dir))
2085 .collect();
2086 let probes_state: Vec<bool> = dispatched
2087 .iter()
2088 .map(|(key, _)| table.entities[table.index[key]].probes_state())
2089 .collect();
2090 let probes_base: Vec<bool> = dispatched
2091 .iter()
2092 .map(|(key, _)| table.entities[table.index[key]].probes_base())
2093 .collect();
2094 let kinds: Vec<Kind> = dispatched
2095 .iter()
2096 .map(|(key, _)| table.entities[table.index[key]].kind)
2097 .collect();
2098 drop(table);
2099
2100 let chain_cache: Arc<ChainFactsCache> = Arc::new(Mutex::new(HashMap::new()));
2104 let patch_cache: Arc<PatchIdentityCache> = Arc::new(Mutex::new(HashMap::new()));
2108 let bound_gates: Arc<HashMap<Arc<Path>, BoundGate>> = Arc::new({
2113 let mut counts: HashMap<Arc<Path>, usize> = HashMap::new();
2114 for (common_dir, probes_state) in common_dirs.iter().zip(&probes_state) {
2115 if *probes_state {
2116 *counts.entry(Arc::clone(common_dir)).or_insert(0) += 1;
2117 }
2118 }
2119 counts
2120 .into_iter()
2121 .map(|(dir, count)| (dir, BoundGate::new(count)))
2122 .collect()
2123 });
2124
2125 for (
2126 (
2127 (
2128 (((((key, cancel), repo), override_branch), network_branch), common_dir),
2129 probes_state,
2130 ),
2131 probes_base,
2132 ),
2133 kind,
2134 ) in dispatched
2135 .into_iter()
2136 .zip(repos)
2137 .zip(override_branches)
2138 .zip(network_branches)
2139 .zip(common_dirs)
2140 .zip(probes_state)
2141 .zip(probes_base)
2142 .zip(kinds)
2143 {
2144 self.dispatch_log.lock().unwrap().push(key.clone());
2149 let path = key.path().to_path_buf();
2150 let table_handle = Arc::clone(&self.table);
2151 let settle_gate = Arc::clone(&self.settle_gate);
2152 let chain_cache = Arc::clone(&chain_cache);
2153 let chain_reads = Arc::clone(&self.default_branch_chain_reads);
2154 let patch_cache = Arc::clone(&patch_cache);
2155 let patch_reads = Arc::clone(&self.patch_identity_reads);
2156 let patch_scan_bounds = Arc::clone(&self.patch_scan_bounds);
2157 let bound_gates = Arc::clone(&bound_gates);
2158 let held_gate = self.phase_c_gates.lock().unwrap().get(&key).cloned();
2163 rayon::spawn(move || {
2171 let branch_outcome = probe_branch(&path, repo.as_deref(), kind, &cancel);
2172 let sync_outcome = probe_sync(
2173 &path,
2174 repo.as_deref(),
2175 branch_outcome.as_ref().map(|(settled, ..)| settled),
2176 kind,
2177 &cancel,
2178 );
2179 let default_branch_outcome = probe_default_branch_memoised(
2180 &path,
2181 repo.as_deref(),
2182 &common_dir,
2183 DefaultBranchHints {
2184 override_branch: override_branch.as_deref(),
2185 network_branch: network_branch.as_deref(),
2186 },
2187 kind,
2188 &cancel,
2189 &ChainFactsMemo {
2190 cache: &chain_cache,
2191 reads: &chain_reads,
2192 },
2193 );
2194 let base_outcome = if probes_base {
2195 probe_base(
2196 &path,
2197 repo.as_deref(),
2198 branch_outcome.as_ref().map(|(settled, ..)| settled),
2199 default_branch_outcome.as_ref().map(|r| &r.settled),
2200 &cancel,
2201 )
2202 } else {
2203 None
2204 };
2205
2206 apply_cheap_probe_outcomes(
2212 &table_handle,
2213 &key,
2214 generation,
2215 CheapProbeOutcomes {
2216 branch: branch_outcome,
2217 sync: sync_outcome,
2218 base: base_outcome,
2219 default_branch: default_branch_outcome.clone(),
2220 },
2221 );
2222
2223 if let Some(gate) = &held_gate {
2228 let (lock, cvar) = &**gate;
2229 let mut state = lock.lock().unwrap();
2230 state.cheap_landed = true;
2231 cvar.notify_all();
2232 state = cvar.wait_while(state, |state| !state.may_proceed).unwrap();
2233 drop(state);
2234 }
2235
2236 let state_outcome = if probes_state {
2237 let gate = bound_gates
2238 .get(&common_dir)
2239 .expect("every probes_state entity's common dir has a gate sized for it");
2240 let mut report = GateReport::new(gate);
2241 let memo = PatchEquivalenceMemo {
2242 cache: &patch_cache,
2243 reads: &patch_reads,
2244 scan_bounds: &patch_scan_bounds,
2245 };
2246 probe_worktree_state(
2247 &path,
2248 repo.as_deref(),
2249 default_branch_outcome.as_ref().map(|r| &r.settled),
2250 &common_dir,
2251 &cancel,
2252 &memo,
2253 &mut report,
2254 )
2255 } else {
2256 None
2257 };
2258 let dirty_outcome = probe_status(&path, repo.as_deref(), kind, &cancel);
2259 apply_probe_outcome(
2260 &table_handle,
2261 &settle_gate,
2262 &key,
2263 generation,
2264 ProbeOutcomes {
2265 state: state_outcome,
2266 dirty: dirty_outcome,
2267 },
2268 );
2269
2270 if let Some(gate) = &held_gate {
2273 let (lock, cvar) = &**gate;
2274 let mut state = lock.lock().unwrap();
2275 state.finished = true;
2276 cvar.notify_all();
2277 }
2278 });
2279 }
2281 }
2282
2283 fn rerun_discovery(&self) {
2292 let repos_cache: HashMap<EntityKey, Arc<gix::ThreadSafeRepository>> =
2293 self.table.read().unwrap().repos.clone();
2294
2295 wait_for_discovery_gate(self.discovery_gate.as_ref());
2296 let (watch, _watcher) = spawn_discovery_watcher(
2299 self.set.roots.clone(),
2300 &self.discovery_warning,
2301 self.discovery_warn_after,
2302 );
2303 let discovery = run_watched_discovery(
2304 &watch,
2305 &self.set,
2306 &self.discovery_warning,
2307 Duration::from_nanos(self.discovery_abandon_after.load(Ordering::Acquire)),
2308 );
2309 if discovery.abandoned {
2310 self.discovery_manual.store(true, Ordering::Release);
2311 }
2312
2313 let (discovered, gitmodules_failures) =
2314 discovery::resolve_with_cache(&self.set, &discovery.entities, &repos_cache);
2315
2316 let exclusions = self.exclusions.read().unwrap().clone();
2320 let mut table = self.table.write().unwrap();
2321 table.discovered_at = Timestamp::now();
2322 let cancelled = merge_discovery(&mut table, &exclusions, discovered, gitmodules_failures);
2323 drop(table);
2324 if cancelled > 0 {
2325 complete_many(&self.settle_gate, cancelled);
2326 }
2327 }
2328}
2329
2330impl Drop for Core {
2331 fn drop(&mut self) {
2342 cancel_in_flight(&self.table, &self.settle_gate);
2343 let _ = self.control.send(ClockControl::Shutdown);
2344 if let Some(handle) = self.clock_thread.take() {
2345 let _ = handle.join();
2346 }
2347 }
2348}
2349
2350pub(crate) struct StartForTest {
2355 pub core: Core,
2356 #[allow(dead_code)] pub clock_alive: Arc<AtomicBool>,
2358 #[allow(dead_code)] pub discovery_watcher: JoinHandle<()>,
2360 #[allow(dead_code)] pub initial_discovery: Option<JoinHandle<()>>,
2365}
2366
2367#[cfg(test)]
2368impl StartForTest {
2369 fn discovered(mut self) -> Self {
2373 if let Some(handle) = self.initial_discovery.take() {
2374 handle
2375 .join()
2376 .expect("the first discovery thread should not panic");
2377 }
2378 self
2379 }
2380}
2381
2382impl Core {
2383 #[cfg(any(test, feature = "test-util"))]
2394 pub fn begin_untracked_probe_for_test(&self, key: &EntityKey) -> Arc<AtomicBool> {
2395 let mut table = self.table.write().unwrap();
2396 table.generation += 1;
2397 let generation_number = table.generation;
2398 table
2399 .generation_started_at
2400 .insert(generation_number, Instant::now());
2401 if let Some(&idx) = table.index.get(key) {
2402 begin_probes(&mut table.entities[idx]);
2403 }
2404 let cancel = Arc::new(AtomicBool::new(false));
2405 table.in_flight.insert(
2406 key.clone(),
2407 InFlight {
2408 generation: generation_number,
2409 cancel: Arc::clone(&cancel),
2410 },
2411 );
2412 begin_probes_owed(&self.settle_gate, 1);
2413 cancel
2414 }
2415}
2416
2417#[cfg(test)]
2420pub(crate) struct SharedGeneration {
2421 pub generation: Generation,
2424 pub cancels: HashMap<EntityKey, Arc<AtomicBool>>,
2426}
2427
2428#[cfg(test)]
2429impl Core {
2430 pub(crate) fn cached_repo_handle_for_test(
2435 &self,
2436 key: &EntityKey,
2437 ) -> Option<Arc<gix::ThreadSafeRepository>> {
2438 self.table.read().unwrap().repos.get(key).cloned()
2439 }
2440
2441 pub(crate) fn default_branch_chain_reads_for_test(&self) -> usize {
2448 self.default_branch_chain_reads.load(Ordering::Acquire)
2449 }
2450
2451 pub(crate) fn patch_identity_reads_for_test(&self) -> usize {
2457 self.patch_identity_reads.load(Ordering::Acquire)
2458 }
2459
2460 pub(crate) fn patch_scan_bounds_for_test(&self) -> Vec<Option<gix::ObjectId>> {
2467 self.patch_scan_bounds.lock().unwrap().clone()
2468 }
2469
2470 pub(crate) fn dispatch_log_for_test(&self) -> Vec<EntityKey> {
2474 self.dispatch_log.lock().unwrap().clone()
2475 }
2476
2477 pub(crate) fn poll_once_for_test(&self) {
2482 run_poll_sweep(
2483 &self.table,
2484 &self.overrides,
2485 &self.show_submodules,
2486 &self.poll_reprobed,
2487 &self.poll_sweep_count,
2488 &self.network_default_branch,
2489 );
2490 }
2491
2492 pub(crate) fn poll_reprobed_for_test(&self) -> Vec<EntityKey> {
2497 self.poll_reprobed.lock().unwrap().clone()
2498 }
2499
2500 pub(crate) fn poll_sweep_count_for_test(&self) -> usize {
2504 self.poll_sweep_count.load(Ordering::Acquire)
2505 }
2506
2507 #[cfg(test)]
2510 pub(crate) fn action_completion_boundary(&self) -> Arc<ActionCompletionBoundary> {
2511 Arc::clone(&self.action_completion_boundary)
2512 }
2513
2514 pub(crate) fn hold_phase_c_for_test(&self, key: &EntityKey) {
2520 self.phase_c_gates.lock().unwrap().insert(
2521 key.clone(),
2522 Arc::new((Mutex::new(PhaseCGate::default()), Condvar::new())),
2523 );
2524 }
2525
2526 pub(crate) fn wait_phase_c_landed_for_test(&self, key: &EntityKey) {
2530 let gate = self
2531 .phase_c_gates
2532 .lock()
2533 .unwrap()
2534 .get(key)
2535 .cloned()
2536 .expect("hold_phase_c_for_test must be called before waiting on its gate");
2537 let (lock, cvar) = &*gate;
2538 let guard = lock.lock().unwrap();
2539 drop(cvar.wait_while(guard, |state| !state.cheap_landed).unwrap());
2540 }
2541
2542 pub(crate) fn release_phase_c_for_test(&self, key: &EntityKey) {
2545 let gate = self
2546 .phase_c_gates
2547 .lock()
2548 .unwrap()
2549 .get(key)
2550 .cloned()
2551 .expect("hold_phase_c_for_test must be called before releasing its gate");
2552 let (lock, cvar) = &*gate;
2553 let mut state = lock.lock().unwrap();
2554 state.may_proceed = true;
2555 cvar.notify_all();
2556 }
2557
2558 pub(crate) fn wait_phase_c_finished_for_test(&self, key: &EntityKey) {
2561 let gate = self
2562 .phase_c_gates
2563 .lock()
2564 .unwrap()
2565 .get(key)
2566 .cloned()
2567 .expect("hold_phase_c_for_test must be called before waiting on its gate");
2568 let (lock, cvar) = &*gate;
2569 let guard = lock.lock().unwrap();
2570 drop(cvar.wait_while(guard, |state| !state.finished).unwrap());
2571 }
2572
2573 pub(crate) fn wait_dispatched_for_test(&self) {
2578 let (lock, cvar) = &*self.settle_gate;
2579 let guard = lock.lock().unwrap();
2580 drop(
2581 cvar.wait_while(guard, |counts| counts.dispatches > 0)
2582 .unwrap(),
2583 );
2584 }
2585
2586 pub(crate) fn settle_gate_count_for_test(&self) -> usize {
2590 self.settle_gate.0.lock().unwrap().probes
2591 }
2592
2593 pub(crate) fn start_for_test(
2597 spec: CoreSpec,
2598 warn_after: Duration,
2599 ticks: Receiver<Instant>,
2600 ) -> StartForTest {
2601 Self::start_for_test_with_discovery_abandon(
2602 spec,
2603 warn_after,
2604 discovery::ABANDON_AFTER,
2605 ticks,
2606 )
2607 }
2608
2609 pub(crate) fn start_for_test_with_discovery_abandon(
2616 spec: CoreSpec,
2617 warn_after: Duration,
2618 discovery_abandon_after: Duration,
2619 ticks: Receiver<Instant>,
2620 ) -> StartForTest {
2621 Self::start_for_test_gated(spec, warn_after, discovery_abandon_after, ticks, None)
2622 }
2623
2624 pub(crate) fn start_for_test_gated(
2628 spec: CoreSpec,
2629 warn_after: Duration,
2630 discovery_abandon_after: Duration,
2631 ticks: Receiver<Instant>,
2632 discovery_gate: Option<DiscoveryGate>,
2633 ) -> StartForTest {
2634 let alive = Arc::new(AtomicBool::new(true));
2635 start_internal(
2636 spec,
2637 warn_after,
2638 discovery_abandon_after,
2639 ticks,
2640 FetchStart {
2641 enabled: false,
2642 concurrency: 1,
2643 ticks: crossbeam_channel::never(),
2644 },
2645 alive,
2646 discovery_gate,
2647 )
2648 }
2649
2650 pub(crate) fn start_for_test_with_fetch(
2656 spec: CoreSpec,
2657 warn_after: Duration,
2658 ticks: Receiver<Instant>,
2659 fetch_ticks: Receiver<Instant>,
2660 ) -> StartForTest {
2661 let alive = Arc::new(AtomicBool::new(true));
2662 let fetch_start = FetchStart {
2663 enabled: spec.fetch.enabled,
2664 concurrency: spec.fetch.concurrency.max(1),
2665 ticks: fetch_ticks,
2666 };
2667 start_internal(
2668 spec,
2669 warn_after,
2670 discovery::ABANDON_AFTER,
2671 ticks,
2672 fetch_start,
2673 alive,
2674 None,
2675 )
2676 }
2677
2678 pub(crate) fn fetch_cycle_count_for_test(&self) -> usize {
2682 self.fetch_cycle_count.load(Ordering::Acquire)
2683 }
2684
2685 #[cfg(test)]
2692 pub(crate) fn set_discovery_abandon_after_for_test(&self, after: Duration) {
2693 self.discovery_abandon_after
2694 .store(after.as_nanos() as u64, Ordering::Release);
2695 }
2696
2697 pub(crate) fn discovery_manual_for_test(&self) -> bool {
2698 self.discovery_manual.load(Ordering::Acquire)
2699 }
2700
2701 pub(crate) fn begin_shared_generation_for_test(&self, keys: &[EntityKey]) -> SharedGeneration {
2711 let mut table = self.table.write().unwrap();
2712 table.generation += 1;
2713 let generation_number = table.generation;
2714 table
2715 .generation_started_at
2716 .insert(generation_number, Instant::now());
2717 let mut cancels = HashMap::new();
2718 for key in keys {
2719 if let Some(&idx) = table.index.get(key) {
2720 table.entities[idx].branch.begin_probe();
2721 }
2722 let cancel = Arc::new(AtomicBool::new(false));
2723 table.in_flight.insert(
2724 key.clone(),
2725 InFlight {
2726 generation: generation_number,
2727 cancel: Arc::clone(&cancel),
2728 },
2729 );
2730 cancels.insert(key.clone(), cancel);
2731 }
2732 SharedGeneration {
2733 generation: Generation::new(generation_number),
2734 cancels,
2735 }
2736 }
2737
2738 pub(crate) fn apply_probe_result_for_test(
2744 &self,
2745 key: &EntityKey,
2746 generation: Generation,
2747 settled: Settled<Head>,
2748 ) {
2749 apply_cheap_probe_outcomes(
2750 &self.table,
2751 key,
2752 generation,
2753 CheapProbeOutcomes {
2754 branch: Some((settled, None, Vec::new())),
2755 sync: None,
2756 base: None,
2757 default_branch: None,
2758 },
2759 );
2760 }
2761
2762 pub(crate) fn set_last_action_for_test(
2766 &self,
2767 key: &EntityKey,
2768 receipt: crate::entity::ActionReceipt,
2769 ) {
2770 let mut table = self.table.write().unwrap();
2771 if let Some(&idx) = table.index.get(key) {
2772 table.entities[idx].last_action = Some(receipt);
2773 }
2774 }
2775}
2776
2777fn run_action_for_entity(
2801 entity: &EntityState,
2802 action: &ActionSpec,
2803 control: &Arc<executor::RunControl>,
2804 report: &dyn Fn(ActionReceipt),
2805) -> ActionReceipt {
2806 let base_env = environment::environment(entity, action.name.as_deref());
2807 let mut failed = false;
2808 let mut cancelled = false;
2809 let mut results: Vec<StepResult> = Vec::with_capacity(action.steps.len());
2810 for step in &action.steps {
2811 if failed || cancelled || control.is_cancelled() {
2812 cancelled = cancelled || control.is_cancelled();
2813 results.push(StepResult {
2814 label: Arc::from(step.argv.join(" ")),
2815 outcome: if cancelled {
2816 StepOutcome::Cancelled
2817 } else {
2818 StepOutcome::NotRun
2819 },
2820 output: Arc::from(&b""[..]),
2821 elapsed: Duration::ZERO,
2822 elision: None,
2823 shell: step.shell,
2824 interactive: step.interactive,
2825 });
2826 continue;
2827 }
2828 let label: Arc<str> = Arc::from(step.argv.join(" "));
2829 report(ActionReceipt {
2830 label: Arc::clone(&action.label),
2831 steps: Arc::from(results.clone()),
2832 skip: None,
2833 finished_at: Timestamp::now(),
2834 running: Some(RunningStep {
2835 label: Arc::clone(&label),
2836 started_at: Timestamp::now(),
2837 shell: step.shell,
2838 interactive: step.interactive,
2839 }),
2840 });
2841 let mut env = base_env.clone();
2846 env.extend(
2847 step.env
2848 .iter()
2849 .map(|(name, value)| (name.clone(), Some(value.clone()))),
2850 );
2851 let mut result = executor::run_step(
2852 &step.argv,
2853 step.shell,
2854 step.interactive,
2855 entity.key.path(),
2856 &env,
2857 control,
2858 );
2859 if control.is_cancelled() {
2860 result.outcome = StepOutcome::Cancelled;
2861 cancelled = true;
2862 } else {
2863 failed = result.outcome.is_failure();
2864 }
2865 results.push(result);
2866 }
2867 ActionReceipt {
2868 label: Arc::clone(&action.label),
2869 steps: Arc::from(results),
2870 skip: None,
2871 finished_at: Timestamp::now(),
2872 running: None,
2873 }
2874}
2875
2876type DiscoveryGate = Arc<(Mutex<bool>, Condvar)>;
2881
2882fn wait_for_discovery_gate(gate: Option<&DiscoveryGate>) {
2885 let Some(gate) = gate else {
2886 return;
2887 };
2888 let (lock, cvar) = &**gate;
2889 let open = lock.lock().unwrap();
2890 drop(cvar.wait_while(open, |open| !*open).unwrap());
2891}
2892
2893#[cfg(test)]
2895fn set_discovery_gate(gate: &DiscoveryGate, open: bool) {
2896 let (lock, cvar) = &**gate;
2897 *lock.lock().unwrap() = open;
2898 cvar.notify_all();
2899}
2900
2901struct DiscoveryWatch {
2904 progress: Arc<AtomicUsize>,
2905 finished: Arc<AtomicBool>,
2906}
2907
2908fn spawn_discovery_watcher(
2914 roots: Vec<PathBuf>,
2915 discovery_warning: &Arc<Mutex<Option<String>>>,
2916 warn_after: Duration,
2917) -> (DiscoveryWatch, JoinHandle<()>) {
2918 let progress = Arc::new(AtomicUsize::new(0));
2919 let finished = Arc::new(AtomicBool::new(false));
2920 let watcher = thread::spawn({
2921 let progress = Arc::clone(&progress);
2922 let finished = Arc::clone(&finished);
2923 let warning_slot = Arc::clone(discovery_warning);
2924 move || {
2925 if let Some(message) = watch_for_slow_discovery(progress, finished, roots, warn_after) {
2926 *warning_slot.lock().unwrap() = Some(message);
2927 }
2928 }
2929 });
2930 (DiscoveryWatch { progress, finished }, watcher)
2931}
2932
2933fn run_watched_discovery(
2939 watch: &DiscoveryWatch,
2940 set: &SetSpec,
2941 discovery_warning: &Arc<Mutex<Option<String>>>,
2942 abandon_after: Duration,
2943) -> discovery::Discovery {
2944 let discovery =
2945 discovery::discover_watched_with_deadline(set, Arc::clone(&watch.progress), abandon_after);
2946 watch.finished.store(true, Ordering::Release);
2947
2948 if discovery.abandoned {
2949 *discovery_warning.lock().unwrap() =
2950 Some(abandoned_discovery_message(discovery.directories_visited));
2951 }
2952
2953 discovery
2954}
2955
2956fn start_internal(
2959 spec: CoreSpec,
2960 warn_after: Duration,
2961 discovery_abandon_after: Duration,
2962 ticks: Receiver<Instant>,
2963 fetch_start: FetchStart,
2964 alive: Arc<AtomicBool>,
2965 discovery_gate: Option<DiscoveryGate>,
2966) -> StartForTest {
2967 let FetchStart {
2968 enabled: fetch_enabled,
2969 concurrency: fetch_concurrency,
2970 ticks: fetch_ticks,
2971 } = fetch_start;
2972 let discovery_warning = Arc::new(Mutex::new(None));
2973 let discovery_manual = Arc::new(AtomicBool::new(false));
2974
2975 let (overrides, resolved_exclusions) = resolve_entries(&spec.overrides);
2976 let overrides = Arc::new(overrides);
2977 let exclusions = Arc::new(RwLock::new(resolved_exclusions));
2978 let show_submodules = Arc::new(AtomicBool::new(spec.show_submodules));
2979
2980 let table = Arc::new(RwLock::new(Table {
2981 generation: 0,
2982 discovered_at: Timestamp::now(),
2983 entities: Vec::new(),
2984 index: HashMap::new(),
2985 in_flight: HashMap::new(),
2986 generation_started_at: HashMap::new(),
2987 repos: HashMap::new(),
2988 poll_fingerprints: HashMap::new(),
2989 }));
2990
2991 let settle_gate: Arc<SettleGate> =
2992 Arc::new((Mutex::new(SettleCounts::default()), Condvar::new()));
2993 let poll_reprobed = Arc::new(Mutex::new(Vec::new()));
2994 let poll_sweep_count = Arc::new(AtomicUsize::new(0));
2995 let network_default_branch = Arc::new(Mutex::new(HashMap::new()));
2996 let (control, control_rx) = crossbeam_channel::unbounded();
2997 let poll_handles = PollHandles {
2998 overrides: Arc::clone(&overrides),
2999 show_submodules: Arc::clone(&show_submodules),
3000 poll_reprobed: Arc::clone(&poll_reprobed),
3001 poll_sweep_count: Arc::clone(&poll_sweep_count),
3002 network_default_branch: Arc::clone(&network_default_branch),
3003 };
3004
3005 let discovery_abandon_after_atomic =
3009 Arc::new(AtomicU64::new(discovery_abandon_after.as_nanos() as u64));
3010 let default_branch_chain_reads = Arc::new(AtomicUsize::new(0));
3011 let patch_identity_reads = Arc::new(AtomicUsize::new(0));
3012 let patch_scan_bounds = Arc::new(Mutex::new(Vec::new()));
3013 let dispatch_log = Arc::new(Mutex::new(Vec::new()));
3014 let phase_c_gates = Arc::new(Mutex::new(HashMap::new()));
3015 let fetch_cycle_count = Arc::new(AtomicUsize::new(0));
3016 let fetch_failures = Arc::new(Mutex::new(FetchFailures::default()));
3017 let turnstile = Arc::new(DispatchTurnstile::default());
3018
3019 let fetch_refresh_handles = RefreshHandles {
3020 table: Arc::clone(&table),
3021 overrides: Arc::clone(&overrides),
3022 exclusions: Arc::clone(&exclusions),
3023 set: spec.set.clone(),
3024 discovery_manual: Arc::clone(&discovery_manual),
3025 discovery_warn_after: warn_after,
3026 discovery_abandon_after: Arc::clone(&discovery_abandon_after_atomic),
3027 discovery_warning: Arc::clone(&discovery_warning),
3028 show_submodules: Arc::clone(&show_submodules),
3029 settle_gate: Arc::clone(&settle_gate),
3030 default_branch_chain_reads: Arc::clone(&default_branch_chain_reads),
3031 patch_identity_reads: Arc::clone(&patch_identity_reads),
3032 patch_scan_bounds: Arc::clone(&patch_scan_bounds),
3033 dispatch_log: Arc::clone(&dispatch_log),
3034 phase_c_gates: Arc::clone(&phase_c_gates),
3035 network_default_branch: Arc::clone(&network_default_branch),
3036 turnstile: Arc::clone(&turnstile),
3037 discovery_gate: discovery_gate.clone(),
3038 };
3039 let auto_update_enabled = spec.auto_update.enabled;
3040 let fetch_schedule = FetchSchedule {
3041 concurrency: fetch_concurrency,
3042 ticks: fetch_ticks,
3043 refresh: fetch_refresh_handles.clone(),
3044 cycle_count: Arc::clone(&fetch_cycle_count),
3045 failures: Arc::clone(&fetch_failures),
3046 auto_update_enabled,
3047 };
3048
3049 let clock_thread = spawn_clock_thread(
3050 Arc::clone(&table),
3051 poll_handles,
3052 fetch_schedule,
3053 Arc::clone(&settle_gate),
3054 spec.generation_deadline,
3055 ClockChannels {
3056 control: control_rx,
3057 ticks,
3058 alive: Arc::clone(&alive),
3059 },
3060 );
3061
3062 let (startup_generation, startup_ticket) = fetch_refresh_handles.reserve_generation();
3072 begin_dispatch(&settle_gate);
3073 let (watch, discovery_watcher) =
3074 spawn_discovery_watcher(spec.set.roots.clone(), &discovery_warning, warn_after);
3075 let initial_discovery = thread::spawn({
3076 let set = spec.set.clone();
3077 let discovery_warning = Arc::clone(&discovery_warning);
3078 let discovery_manual = Arc::clone(&discovery_manual);
3079 let exclusions = Arc::clone(&exclusions);
3080 let table = Arc::clone(&table);
3081 let settle_gate = Arc::clone(&settle_gate);
3082 let fetch_refresh_handles = fetch_refresh_handles.clone();
3083 let fetch_cycle_count = Arc::clone(&fetch_cycle_count);
3084 let fetch_failures = Arc::clone(&fetch_failures);
3085 let discovery_gate = discovery_gate.clone();
3086 move || {
3087 let turn = fetch_refresh_handles.turnstile.take(startup_ticket);
3088 wait_for_discovery_gate(discovery_gate.as_ref());
3089 let discovery =
3090 run_watched_discovery(&watch, &set, &discovery_warning, discovery_abandon_after);
3091 if discovery.abandoned {
3092 discovery_manual.store(true, Ordering::Release);
3093 }
3094
3095 let (discovered, gitmodules_failures) = discovery::resolve(&set, &discovery.entities);
3100 let resolved_exclusions = exclusions.read().unwrap().clone();
3101 let order: Vec<EntityKey> = {
3102 let mut table = table.write().unwrap();
3103 merge_discovery(
3107 &mut table,
3108 &resolved_exclusions,
3109 discovered,
3110 gitmodules_failures,
3111 );
3112 table.discovered_at = Timestamp::now();
3113 table
3114 .entities
3115 .iter()
3116 .map(|entity| entity.key.clone())
3117 .collect()
3118 };
3119 fetch_refresh_handles.dispatch_probes(&order, startup_generation);
3123 finish_dispatch(&settle_gate);
3124 drop(turn);
3127
3128 if fetch_enabled {
3138 let table = Arc::clone(&table);
3139 thread::spawn(move || {
3140 run_fetch_cycle(
3141 &table,
3142 fetch_concurrency,
3143 &fetch_refresh_handles,
3144 &fetch_cycle_count,
3145 &fetch_failures,
3146 auto_update_enabled,
3147 );
3148 });
3149 }
3150 }
3151 });
3152
3153 StartForTest {
3154 core: Core {
3155 table,
3156 overrides,
3157 exclusions,
3158 set: spec.set,
3159 discovery_manual,
3160 discovery_warn_after: warn_after,
3161 discovery_abandon_after: discovery_abandon_after_atomic,
3162 show_submodules,
3163 settle_gate,
3164 control,
3165 clock_thread: Some(clock_thread),
3166 discovery_warning,
3167 default_branch_chain_reads,
3168 patch_identity_reads,
3169 patch_scan_bounds,
3170 action_lifecycle: Arc::new(Mutex::new(ActionLifecycle::default())),
3171 dispatch_log,
3172 phase_c_gates,
3173 status_stale_after: spec.status_stale_after,
3174 poll_reprobed,
3175 poll_sweep_count,
3176 fetch_cycle_count,
3177 network_default_branch,
3178 fetch_failures,
3179 turnstile,
3180 discovery_gate,
3181 #[cfg(test)]
3182 action_completion_boundary: Arc::new(ActionCompletionBoundary::default()),
3183 },
3184 clock_alive: alive,
3185 discovery_watcher,
3186 initial_discovery: Some(initial_discovery),
3187 }
3188}
3189
3190struct PollHandles {
3194 overrides: Arc<Vec<ResolvedOverride>>,
3195 show_submodules: Arc<AtomicBool>,
3196 poll_reprobed: Arc<Mutex<Vec<EntityKey>>>,
3197 poll_sweep_count: Arc<AtomicUsize>,
3198 network_default_branch: Arc<Mutex<HashMap<PathBuf, Arc<str>>>>,
3202}
3203
3204struct FetchStart {
3208 enabled: bool,
3209 concurrency: usize,
3210 ticks: Receiver<Instant>,
3211}
3212
3213struct FetchSchedule {
3220 concurrency: usize,
3221 ticks: Receiver<Instant>,
3222 refresh: RefreshHandles,
3223 cycle_count: Arc<AtomicUsize>,
3224 failures: Arc<Mutex<FetchFailures>>,
3225 auto_update_enabled: bool,
3229}
3230
3231struct ClockChannels {
3238 control: Receiver<ClockControl>,
3239 ticks: Receiver<Instant>,
3240 alive: Arc<AtomicBool>,
3241}
3242
3243fn spawn_clock_thread(
3253 table: Arc<RwLock<Table>>,
3254 poll: PollHandles,
3255 fetch: FetchSchedule,
3256 settle_gate: Arc<SettleGate>,
3257 generation_deadline: Duration,
3258 channels: ClockChannels,
3259) -> JoinHandle<()> {
3260 let ClockChannels {
3261 control,
3262 ticks,
3263 alive,
3264 } = channels;
3265 thread::spawn(move || {
3266 let mut paused = false;
3267 loop {
3268 select! {
3269 recv(control) -> message => match message {
3270 Ok(ClockControl::Pause) => {
3271 paused = true;
3272 cancel_in_flight(&table, &settle_gate);
3273 }
3274 Ok(ClockControl::Resume) => paused = false,
3275 Ok(ClockControl::Shutdown) | Err(_) => break,
3276 },
3277 recv(ticks) -> tick => {
3278 if tick.is_err() {
3279 break;
3280 }
3281 if !paused {
3282 run_poll_sweep(
3283 &table,
3284 &poll.overrides,
3285 &poll.show_submodules,
3286 &poll.poll_reprobed,
3287 &poll.poll_sweep_count,
3288 &poll.network_default_branch,
3289 );
3290 sweep_deadline(&table, &settle_gate, generation_deadline);
3291 }
3292 }
3293 recv(fetch.ticks) -> tick => {
3294 if tick.is_err() {
3295 break;
3296 }
3297 if !paused {
3298 run_fetch_cycle(
3299 &table,
3300 fetch.concurrency,
3301 &fetch.refresh,
3302 &fetch.cycle_count,
3303 &fetch.failures,
3304 fetch.auto_update_enabled,
3305 );
3306 }
3307 }
3308 }
3309 }
3310 alive.store(false, Ordering::Release);
3311 })
3312}
3313
3314fn run_fetch_cycle(
3335 table: &Arc<RwLock<Table>>,
3336 concurrency: usize,
3337 refresh: &RefreshHandles,
3338 cycle_count: &Arc<AtomicUsize>,
3339 failures: &Arc<Mutex<FetchFailures>>,
3340 auto_update_enabled: bool,
3341) {
3342 cycle_count.fetch_add(1, Ordering::Release);
3343
3344 let common_dirs = distinct_fetchable_common_dirs(table);
3345 let failed: Mutex<Vec<(PathBuf, String)>> = Mutex::new(Vec::new());
3346 crate::fetch::run_bounded(common_dirs, concurrency.max(1), |common_dir| {
3347 let cancel = AtomicBool::new(false);
3348 match crate::fetch::fetch_and_prune(&common_dir, &cancel) {
3354 Ok(outcome) => {
3355 if let Some(crate::fetch::AdvertisedDefaultBranch::Branch(name)) =
3364 outcome.advertised_default_branch
3365 {
3366 refresh
3367 .network_default_branch
3368 .lock()
3369 .unwrap()
3370 .insert(common_dir.clone(), Arc::from(name));
3371 }
3372 }
3373 Err(error) => {
3374 failed
3375 .lock()
3376 .unwrap()
3377 .push((common_dir.clone(), error.to_string()));
3378 }
3379 }
3380 });
3381 *failures.lock().unwrap() = FetchFailures {
3382 failed: failed.into_inner().unwrap(),
3383 };
3384
3385 if auto_update_enabled {
3394 for repo_path in repos_eligible_for_auto_update_attempt(table) {
3395 let _ = crate::auto_update::attempt(&repo_path);
3398 }
3399 }
3400
3401 let all_keys: Vec<EntityKey> = table
3402 .read()
3403 .unwrap()
3404 .entities
3405 .iter()
3406 .map(|entity| entity.key.clone())
3407 .collect();
3408 refresh.dispatch(&all_keys);
3409}
3410
3411fn repos_eligible_for_auto_update_attempt(table: &Arc<RwLock<Table>>) -> Vec<PathBuf> {
3420 table
3421 .read()
3422 .unwrap()
3423 .entities
3424 .iter()
3425 .filter(|entity| entity.kind == Kind::Repo && !entity.excluded)
3426 .map(|entity| entity.key.path().to_path_buf())
3427 .collect()
3428}
3429
3430fn distinct_fetchable_common_dirs(table: &Arc<RwLock<Table>>) -> Vec<PathBuf> {
3437 let table = table.read().unwrap();
3438 let mut seen: HashMap<PathBuf, bool> = HashMap::new();
3439 for entity in &table.entities {
3440 let common_dir = entity.common_dir.to_path_buf();
3441 let operable = seen.entry(common_dir).or_insert(false);
3442 *operable = *operable || !entity.excluded;
3443 }
3444 seen.into_iter()
3445 .filter(|(_, operable)| *operable)
3446 .map(|(common_dir, _)| common_dir)
3447 .collect()
3448}
3449
3450fn probe_network_default_branches(
3456 common_dirs: &HashSet<Arc<Path>>,
3457 network_default_branch: &Mutex<HashMap<PathBuf, Arc<str>>>,
3458) {
3459 for common_dir in common_dirs {
3460 if let Ok(Some(crate::fetch::AdvertisedDefaultBranch::Branch(name))) =
3461 crate::fetch::probe_remote_head(common_dir)
3462 {
3463 network_default_branch
3464 .lock()
3465 .unwrap()
3466 .insert(common_dir.to_path_buf(), Arc::from(name));
3467 }
3468 }
3469}
3470
3471struct RederiveCandidate {
3476 key: EntityKey,
3477 path: PathBuf,
3478 common_dir: Arc<Path>,
3479 repo: Option<Arc<gix::ThreadSafeRepository>>,
3480 override_branch: Option<String>,
3481 kind: Kind,
3482}
3483
3484struct PollCandidate {
3487 key: EntityKey,
3488 path: PathBuf,
3489 common_dir: Arc<Path>,
3490 kind: Kind,
3491 cached_repo: Option<Arc<gix::ThreadSafeRepository>>,
3492 probes_base: bool,
3493}
3494
3495fn run_poll_sweep(
3514 table: &Arc<RwLock<Table>>,
3515 overrides: &Arc<Vec<ResolvedOverride>>,
3516 show_submodules: &Arc<AtomicBool>,
3517 poll_reprobed: &Arc<Mutex<Vec<EntityKey>>>,
3518 poll_sweep_count: &Arc<AtomicUsize>,
3519 network_default_branch: &Mutex<HashMap<PathBuf, Arc<str>>>,
3520) {
3521 poll_sweep_count.fetch_add(1, Ordering::Release);
3522 poll_reprobed.lock().unwrap().clear();
3523 let show_submodules = show_submodules.load(Ordering::Acquire);
3524
3525 let candidates: Vec<PollCandidate> = {
3526 let table = table.read().unwrap();
3527 table
3528 .entities
3529 .iter()
3530 .filter(|entity| dispatches_kind(entity.kind, show_submodules))
3531 .map(|entity| PollCandidate {
3532 key: entity.key.clone(),
3533 path: entity.key.path().to_path_buf(),
3534 common_dir: Arc::clone(&entity.common_dir),
3535 kind: entity.kind,
3536 cached_repo: table.repos.get(&entity.key).cloned(),
3537 probes_base: entity.probes_base(),
3538 })
3539 .collect()
3540 };
3541
3542 for candidate in candidates {
3543 let opened;
3549 let repo = match candidate.cached_repo.as_deref() {
3550 Some(repo) => Some(repo),
3551 None => match git::open_thread_safe(&candidate.path) {
3552 Ok(repo) => {
3553 opened = repo;
3554 Some(&opened)
3555 }
3556 Err(_) => None,
3557 },
3558 };
3559 let gitdir = repo
3560 .map(|repo| repo.git_dir().to_path_buf())
3561 .unwrap_or_else(|| candidate.common_dir.to_path_buf());
3562
3563 let current = poll::fingerprint(&gitdir);
3564 let moved = {
3565 let mut table = table.write().unwrap();
3566 let previous = table
3567 .poll_fingerprints
3568 .insert(candidate.key.clone(), current);
3569 previous.is_some_and(|previous| poll::moved(&previous, ¤t))
3570 };
3571 if !moved {
3572 continue;
3573 }
3574
3575 {
3576 let mut table = table.write().unwrap();
3577 if let Some(&idx) = table.index.get(&candidate.key) {
3578 table.entities[idx].force_stale_status_cells();
3579 }
3580 }
3581
3582 let override_branch = find_entry(overrides, &candidate.path, &candidate.common_dir)
3583 .and_then(|entry| entry.default_branch.clone());
3584 let never_cancelled = AtomicBool::new(false);
3585 let chain_cache: ChainFactsCache = Mutex::new(HashMap::new());
3586 let chain_reads = AtomicUsize::new(0);
3587
3588 let branch_outcome = probe_branch(&candidate.path, repo, candidate.kind, &never_cancelled);
3589 let sync_outcome = probe_sync(
3590 &candidate.path,
3591 repo,
3592 branch_outcome.as_ref().map(|(settled, ..)| settled),
3593 candidate.kind,
3594 &never_cancelled,
3595 );
3596 let default_branch_outcome = probe_default_branch_memoised(
3597 &candidate.path,
3598 repo,
3599 &candidate.common_dir,
3600 DefaultBranchHints {
3601 override_branch: override_branch.as_deref(),
3602 network_branch: network_branch_for(network_default_branch, &candidate.common_dir)
3603 .as_deref(),
3604 },
3605 candidate.kind,
3606 &never_cancelled,
3607 &ChainFactsMemo {
3608 cache: &chain_cache,
3609 reads: &chain_reads,
3610 },
3611 );
3612 let base_outcome = if candidate.probes_base {
3613 probe_base(
3614 &candidate.path,
3615 repo,
3616 branch_outcome.as_ref().map(|(settled, ..)| settled),
3617 default_branch_outcome.as_ref().map(|r| &r.settled),
3618 &never_cancelled,
3619 )
3620 } else {
3621 None
3622 };
3623
3624 let generation = {
3625 let mut table = table.write().unwrap();
3626 table.generation += 1;
3627 Generation::new(table.generation)
3628 };
3629 apply_cheap_probe_outcomes(
3630 table,
3631 &candidate.key,
3632 generation,
3633 CheapProbeOutcomes {
3634 branch: branch_outcome,
3635 sync: sync_outcome,
3636 base: base_outcome,
3637 default_branch: default_branch_outcome,
3638 },
3639 );
3640 poll_reprobed.lock().unwrap().push(candidate.key);
3641 }
3642}
3643
3644fn cancel_in_flight(table: &Arc<RwLock<Table>>, settle_gate: &Arc<SettleGate>) {
3649 let mut table = table.write().unwrap();
3650 let cancelled = table.in_flight.len();
3651 for in_flight in table.in_flight.values() {
3652 in_flight.cancel.store(true, Ordering::Release);
3653 }
3654 table.in_flight.clear();
3655 table.generation_started_at.clear();
3656 drop(table);
3657 if cancelled > 0 {
3658 complete_many(settle_gate, cancelled);
3659 }
3660}
3661
3662trait TimeoutableCell {
3668 fn is_in_flight(&self) -> bool;
3669 fn time_out(&mut self, generation: Generation);
3672}
3673
3674impl<T> TimeoutableCell for Cell<T> {
3675 fn is_in_flight(&self) -> bool {
3676 Cell::is_in_flight(self)
3677 }
3678
3679 fn time_out(&mut self, generation: Generation) {
3680 self.settle(generation, Settled::Unknown(Unknown::TimedOut));
3681 }
3682}
3683
3684fn sweep_deadline(table: &Arc<RwLock<Table>>, settle_gate: &Arc<SettleGate>, deadline: Duration) {
3689 let mut table = table.write().unwrap();
3690 let now = Instant::now();
3691 let mut timed_out = Vec::new();
3692 for (key, in_flight) in table.in_flight.iter() {
3693 let started = table
3694 .generation_started_at
3695 .get(&in_flight.generation)
3696 .copied()
3697 .unwrap_or(now);
3698 if now.duration_since(started) >= deadline {
3699 timed_out.push((key.clone(), Generation::new(in_flight.generation)));
3700 }
3701 }
3702 for (key, generation) in &timed_out {
3703 if let Some(&idx) = table.index.get(key) {
3704 let EntityState {
3707 key: _,
3708 name: _,
3709 common_dir: _,
3710 kind: _,
3711 branch,
3712 sync,
3713 base,
3714 dirty,
3715 state,
3716 default_branch,
3717 diagnostics: _,
3718 last_action: _,
3719 presence: _,
3720 excluded: _,
3721 in_progress_operation: _,
3722 recent_commits: _,
3723 } = &mut table.entities[idx];
3724 let cells: [&mut dyn TimeoutableCell; 6] =
3725 [branch, sync, base, dirty, state, default_branch];
3726 for cell in cells {
3727 if cell.is_in_flight() {
3732 cell.time_out(*generation);
3733 }
3734 }
3735 }
3736 table.in_flight.remove(key);
3737 }
3738 let live_generations: std::collections::HashSet<u64> =
3739 table.in_flight.values().map(|f| f.generation).collect();
3740 table
3741 .generation_started_at
3742 .retain(|generation, _| live_generations.contains(generation));
3743 drop(table);
3744 if !timed_out.is_empty() {
3745 complete_many(settle_gate, timed_out.len());
3746 }
3747}
3748
3749fn begin_probes(entity: &mut EntityState) {
3756 let probes_state = entity.probes_state();
3757 let EntityState {
3758 key: _,
3759 name: _,
3760 common_dir: _,
3761 kind: _,
3762 branch,
3763 sync: _,
3764 base: _,
3765 dirty,
3766 state,
3767 default_branch,
3768 diagnostics: _,
3769 last_action: _,
3770 presence: _,
3771 excluded: _,
3772 in_progress_operation: _,
3773 recent_commits: _,
3774 } = entity;
3775 branch.begin_probe();
3776 default_branch.begin_probe();
3777 dirty.begin_probe();
3781 if probes_state {
3786 state.begin_probe();
3787 }
3788}
3789
3790type SettleGate = (Mutex<SettleCounts>, Condvar);
3793
3794#[derive(Default)]
3801struct SettleCounts {
3802 probes: usize,
3805 dispatches: usize,
3808}
3809
3810impl SettleCounts {
3811 fn is_settled(&self) -> bool {
3817 let SettleCounts { probes, dispatches } = self;
3818 *probes == 0 && *dispatches == 0
3819 }
3820}
3821
3822fn begin_dispatch(settle_gate: &SettleGate) {
3825 let (lock, _cvar) = settle_gate;
3826 lock.lock().unwrap().dispatches += 1;
3827}
3828
3829fn finish_dispatch(settle_gate: &SettleGate) {
3832 let (lock, cvar) = settle_gate;
3833 let mut counts = lock.lock().unwrap();
3834 counts.dispatches = counts.dispatches.saturating_sub(1);
3835 drop(counts);
3836 cvar.notify_all();
3839}
3840
3841fn begin_probes_owed(settle_gate: &SettleGate, owed: usize) {
3842 let (lock, _cvar) = settle_gate;
3843 lock.lock().unwrap().probes += owed;
3844}
3845
3846fn complete_one(settle_gate: &SettleGate) {
3847 complete_many(settle_gate, 1);
3848}
3849
3850fn complete_many(settle_gate: &SettleGate, finished: usize) {
3851 let (lock, cvar) = settle_gate;
3852 let mut counts = lock.lock().unwrap();
3853 counts.probes = counts.probes.saturating_sub(finished);
3854 if counts.is_settled() {
3855 cvar.notify_all();
3856 }
3857}
3858
3859const RECENT_COMMITS_LIMIT: usize = 5;
3878
3879fn submodule_open_failure<T>(kind: Kind, error: git::ProbeError) -> Settled<T> {
3886 match kind {
3887 Kind::Repo | Kind::Worktree => Settled::Failed(error),
3888 Kind::Submodule => Settled::Unknown(Unknown::SubmoduleUninitialized),
3889 }
3890}
3891
3892fn probe_branch(
3893 path: &Path,
3894 repo: Option<&gix::ThreadSafeRepository>,
3895 kind: Kind,
3896 cancel: &AtomicBool,
3897) -> Option<(
3898 Settled<Head>,
3899 Option<git::InProgressOperation>,
3900 Vec<git::RecentCommit>,
3901)> {
3902 if cancel.load(Ordering::Acquire) {
3903 return None;
3904 }
3905 let opened;
3906 let repo = match repo {
3907 Some(repo) => repo,
3908 None => match git::open_thread_safe(path) {
3909 Ok(repo) => {
3910 opened = repo;
3911 &opened
3912 }
3913 Err(error) => return Some((submodule_open_failure(kind, error), None, Vec::new())),
3914 },
3915 };
3916 let local = repo.to_thread_local();
3917 let settled = match git::head_shape(&local) {
3918 Ok(head) => Settled::Known {
3919 value: head,
3920 at: Timestamp::now(),
3921 stale: false,
3922 },
3923 Err(error) => Settled::Failed(error),
3924 };
3925 let in_progress = git::in_progress_operation(&local);
3926 let recent = git::recent_commits(&local, RECENT_COMMITS_LIMIT);
3927 Some((settled, in_progress, recent))
3928}
3929
3930fn probe_sync(
3941 path: &Path,
3942 repo: Option<&gix::ThreadSafeRepository>,
3943 branch_settled: Option<&Settled<Head>>,
3944 kind: Kind,
3945 cancel: &AtomicBool,
3946) -> Option<Settled<SyncState>> {
3947 if cancel.load(Ordering::Acquire) {
3948 return None;
3949 }
3950 let head = match branch_settled? {
3951 Settled::Known {
3952 value,
3953 at: _,
3954 stale: _,
3955 } => Some(value),
3956 Settled::Failed(error) => return Some(Settled::Failed(error.clone())),
3957 Settled::Unknown(_) | Settled::NotApplicable => None,
3958 };
3959 let opened;
3960 let repo = match repo {
3961 Some(repo) => repo,
3962 None => match git::open_thread_safe(path) {
3963 Ok(repo) => {
3964 opened = repo;
3965 &opened
3966 }
3967 Err(error) => return Some(submodule_open_failure(kind, error)),
3968 },
3969 };
3970 let local = repo.to_thread_local();
3971 let settled = match git::resolve_sync(&local, head) {
3972 Ok(value) => Settled::Known {
3973 value,
3974 at: Timestamp::now(),
3975 stale: false,
3976 },
3977 Err(error) => Settled::Failed(error),
3978 };
3979 Some(settled)
3980}
3981
3982fn probe_base(
3994 path: &Path,
3995 repo: Option<&gix::ThreadSafeRepository>,
3996 branch_settled: Option<&Settled<Head>>,
3997 default_branch_settled: Option<&Settled<DefaultBranch>>,
3998 cancel: &AtomicBool,
3999) -> Option<Settled<u32>> {
4000 if cancel.load(Ordering::Acquire) {
4001 return None;
4002 }
4003 let head = match branch_settled? {
4004 Settled::Known {
4005 value,
4006 at: _,
4007 stale: _,
4008 } => value,
4009 Settled::Failed(error) => return Some(Settled::Failed(error.clone())),
4010 Settled::Unknown(_) | Settled::NotApplicable => return None,
4011 };
4012 let default_branch_settled = default_branch_settled?;
4013 let opened;
4014 let repo = match repo {
4015 Some(repo) => repo,
4016 None => match git::open_thread_safe(path) {
4017 Ok(repo) => {
4018 opened = repo;
4019 &opened
4020 }
4021 Err(error) => return Some(Settled::Failed(error)),
4022 },
4023 };
4024 let local = repo.to_thread_local();
4025 Some(base::probe(&local, head, default_branch_settled))
4026}
4027
4028fn probe_status(
4038 path: &Path,
4039 repo: Option<&gix::ThreadSafeRepository>,
4040 kind: Kind,
4041 cancel: &Arc<AtomicBool>,
4042) -> Option<Settled<DirtyCounts>> {
4043 if cancel.load(Ordering::Acquire) {
4044 return None;
4045 }
4046 let opened;
4047 let repo = match repo {
4048 Some(repo) => repo,
4049 None => match git::open_thread_safe(path) {
4050 Ok(repo) => {
4051 opened = repo;
4052 &opened
4053 }
4054 Err(error) => return Some(submodule_open_failure(kind, error)),
4055 },
4056 };
4057 let local = repo.to_thread_local();
4058 classify_status_result(git::dirty_counts(&local, Arc::clone(cancel)), cancel)
4059}
4060
4061fn classify_status_result(
4077 result: Result<DirtyCounts, git::ProbeError>,
4078 cancel: &AtomicBool,
4079) -> Option<Settled<DirtyCounts>> {
4080 match result {
4081 Ok(_) if cancel.load(Ordering::Acquire) => None,
4082 Ok(value) => Some(Settled::Known {
4083 value,
4084 at: Timestamp::now(),
4085 stale: false,
4086 }),
4087 Err(_) if cancel.load(Ordering::Acquire) => None,
4088 Err(error) => Some(Settled::Failed(error)),
4089 }
4090}
4091
4092struct DefaultBranchHints<'a> {
4098 override_branch: Option<&'a str>,
4101 network_branch: Option<&'a str>,
4105}
4106
4107fn network_branch_for(
4111 network_default_branch: &Mutex<HashMap<PathBuf, Arc<str>>>,
4112 common_dir: &Path,
4113) -> Option<Arc<str>> {
4114 network_default_branch
4115 .lock()
4116 .unwrap()
4117 .get(common_dir)
4118 .cloned()
4119}
4120
4121fn supersede_with_network(
4130 mut resolution: default_branch::Resolution,
4131 network_branch: Option<&str>,
4132) -> default_branch::Resolution {
4133 if let Some(name) = network_branch {
4134 resolution.settled = Settled::Known {
4135 value: DefaultBranch::new(name.into()),
4136 at: Timestamp::now(),
4137 stale: false,
4138 };
4139 }
4140 resolution
4141}
4142
4143fn probe_default_branch(
4151 path: &Path,
4152 repo: Option<&gix::ThreadSafeRepository>,
4153 hints: DefaultBranchHints<'_>,
4154 kind: Kind,
4155 cancel: &AtomicBool,
4156) -> Option<default_branch::Resolution> {
4157 if cancel.load(Ordering::Acquire) {
4158 return None;
4159 }
4160 let opened;
4161 let repo = match repo {
4162 Some(repo) => repo,
4163 None => match git::open_thread_safe(path) {
4164 Ok(repo) => {
4165 opened = repo;
4166 &opened
4167 }
4168 Err(error) => {
4169 return Some(match kind {
4170 Kind::Repo | Kind::Worktree => default_branch::Resolution::failed(error),
4171 Kind::Submodule => default_branch::Resolution::submodule_uninitialized(),
4172 });
4173 }
4174 },
4175 };
4176 Some(supersede_with_network(
4177 default_branch::resolve(&repo.to_thread_local(), hints.override_branch),
4178 hints.network_branch,
4179 ))
4180}
4181
4182struct BoundGate {
4195 state: Mutex<BoundGateState>,
4196 condvar: Condvar,
4197 bound: OnceLock<Option<gix::ObjectId>>,
4198}
4199
4200struct BoundGateState {
4201 remaining: usize,
4202 candidates: Vec<gix::ObjectId>,
4203}
4204
4205impl BoundGate {
4206 fn new(remaining: usize) -> Self {
4207 Self {
4208 state: Mutex::new(BoundGateState {
4209 remaining,
4210 candidates: Vec::new(),
4211 }),
4212 condvar: Condvar::new(),
4213 bound: OnceLock::new(),
4214 }
4215 }
4216
4217 fn report(&self, candidate: Option<gix::ObjectId>) {
4223 let mut state = self.state.lock().unwrap();
4224 if let Some(candidate) = candidate {
4225 state.candidates.push(candidate);
4226 }
4227 state.remaining -= 1;
4228 if state.remaining == 0 {
4229 self.condvar.notify_all();
4230 }
4231 }
4232
4233 fn deepest(&self, repo: &gix::Repository) -> Option<gix::ObjectId> {
4243 let mut state = self.state.lock().unwrap();
4244 while state.remaining != 0 {
4245 state = self.condvar.wait(state).unwrap();
4246 }
4247 let candidates = std::mem::take(&mut state.candidates);
4248 *self
4249 .bound
4250 .get_or_init(|| deepest_merge_base(repo, &candidates))
4251 }
4252}
4253
4254fn deepest_merge_base(
4261 repo: &gix::Repository,
4262 candidates: &[gix::ObjectId],
4263) -> Option<gix::ObjectId> {
4264 let mut candidates = candidates.iter().copied();
4265 let mut deepest = candidates.next()?;
4266 for candidate in candidates {
4267 deepest = git::checked_merge_base(repo, deepest, candidate)
4268 .ok()
4269 .flatten()
4270 .unwrap_or(deepest);
4271 }
4272 Some(deepest)
4273}
4274
4275struct GateReport<'a> {
4281 gate: &'a BoundGate,
4282 reported: bool,
4283}
4284
4285impl<'a> GateReport<'a> {
4286 fn new(gate: &'a BoundGate) -> Self {
4287 Self {
4288 gate,
4289 reported: false,
4290 }
4291 }
4292
4293 fn report_now(&mut self, candidate: Option<gix::ObjectId>) {
4298 self.gate.report(candidate);
4299 self.reported = true;
4300 }
4301}
4302
4303impl Drop for GateReport<'_> {
4304 fn drop(&mut self) {
4305 if !self.reported {
4306 self.gate.report(None);
4307 }
4308 }
4309}
4310
4311struct PatchEquivalenceMemo<'a> {
4315 cache: &'a PatchIdentityCache,
4316 reads: &'a AtomicUsize,
4317 scan_bounds: &'a Mutex<Vec<Option<gix::ObjectId>>>,
4320}
4321
4322fn probe_worktree_state(
4331 path: &Path,
4332 repo: Option<&gix::ThreadSafeRepository>,
4333 default_branch_settled: Option<&Settled<DefaultBranch>>,
4334 common_dir: &Arc<Path>,
4335 cancel: &AtomicBool,
4336 memo: &PatchEquivalenceMemo<'_>,
4337 report: &mut GateReport<'_>,
4338) -> Option<Settled<WorktreeState>> {
4339 if cancel.load(Ordering::Acquire) {
4340 return None;
4341 }
4342 let default_branch_settled = default_branch_settled?;
4343 let opened;
4344 let repo = match repo {
4345 Some(repo) => repo,
4346 None => match git::open_thread_safe(path) {
4347 Ok(repo) => {
4348 opened = repo;
4349 &opened
4350 }
4351 Err(error) => return Some(Settled::Failed(error)),
4352 },
4353 };
4354 let local = repo.to_thread_local();
4355 match landing::probe(&local, default_branch_settled) {
4356 landing::Outcome::Settle(settled) => Some(settled),
4357 landing::Outcome::Outstanding(outstanding) => {
4358 probe_patch_equivalence(&local, &outstanding, common_dir, cancel, memo, report)
4359 }
4360 }
4361}
4362
4363fn probe_patch_equivalence(
4371 repo: &gix::Repository,
4372 outstanding: &landing::Outstanding,
4373 common_dir: &Arc<Path>,
4374 cancel: &AtomicBool,
4375 memo: &PatchEquivalenceMemo<'_>,
4376 report: &mut GateReport<'_>,
4377) -> Option<Settled<WorktreeState>> {
4378 if cancel.load(Ordering::Acquire) {
4379 return None;
4380 }
4381 let landing::Outstanding {
4382 entity_tip,
4383 default_tip,
4384 merge_base,
4385 } = *outstanding;
4386 let Some(merge_base) = merge_base else {
4387 report.report_now(None);
4393 return Some(patch_equivalence::probe(
4394 repo,
4395 entity_tip,
4396 None,
4397 &patch_equivalence::PatchIdentitySet::new(),
4398 ));
4399 };
4400 report.report_now(Some(merge_base));
4404 let bound = report.gate.deepest(repo);
4405 let shared = match patch_identities_for(memo.cache, common_dir, memo.reads, || {
4406 memo.scan_bounds.lock().unwrap().push(bound);
4411 patch_equivalence::scan_default_branch(repo, default_tip, bound)
4412 }) {
4413 Ok(shared) => shared,
4414 Err(error) => return Some(Settled::Failed(error)),
4415 };
4416 Some(patch_equivalence::probe(
4417 repo,
4418 entity_tip,
4419 Some(merge_base),
4420 &shared,
4421 ))
4422}
4423
4424type PatchIdentityCache = Mutex<
4432 HashMap<Arc<Path>, Arc<OnceLock<Result<patch_equivalence::PatchIdentitySet, git::ProbeError>>>>,
4433>;
4434
4435fn patch_identities_for(
4445 cache: &PatchIdentityCache,
4446 common_dir: &Arc<Path>,
4447 reads: &AtomicUsize,
4448 compute: impl FnOnce() -> Result<patch_equivalence::PatchIdentitySet, git::ProbeError>,
4449) -> Result<patch_equivalence::PatchIdentitySet, git::ProbeError> {
4450 let cell = {
4451 let mut cache = cache.lock().unwrap();
4452 Arc::clone(
4453 cache
4454 .entry(Arc::clone(common_dir))
4455 .or_insert_with(|| Arc::new(OnceLock::new())),
4456 )
4457 };
4458 cell.get_or_init(|| {
4459 reads.fetch_add(1, Ordering::Relaxed);
4460 compute()
4461 })
4462 .clone()
4463}
4464
4465type ChainFactsCache = Mutex<HashMap<Arc<Path>, Arc<OnceLock<default_branch::ChainFacts>>>>;
4469
4470fn chain_facts_for(
4477 cache: &ChainFactsCache,
4478 common_dir: &Arc<Path>,
4479 reads: &AtomicUsize,
4480 compute: impl FnOnce() -> default_branch::ChainFacts,
4481) -> default_branch::ChainFacts {
4482 let cell = {
4483 let mut cache = cache.lock().unwrap();
4484 Arc::clone(
4485 cache
4486 .entry(Arc::clone(common_dir))
4487 .or_insert_with(|| Arc::new(OnceLock::new())),
4488 )
4489 };
4490 cell.get_or_init(|| {
4491 reads.fetch_add(1, Ordering::Relaxed);
4492 compute()
4493 })
4494 .clone()
4495}
4496
4497struct ChainFactsMemo<'a> {
4508 cache: &'a ChainFactsCache,
4509 reads: &'a AtomicUsize,
4510}
4511
4512fn probe_default_branch_memoised(
4513 path: &Path,
4514 repo: Option<&gix::ThreadSafeRepository>,
4515 common_dir: &Arc<Path>,
4516 hints: DefaultBranchHints<'_>,
4517 kind: Kind,
4518 cancel: &AtomicBool,
4519 memo: &ChainFactsMemo<'_>,
4520) -> Option<default_branch::Resolution> {
4521 if cancel.load(Ordering::Acquire) {
4522 return None;
4523 }
4524 let opened;
4525 let repo = match repo {
4526 Some(repo) => repo,
4527 None => match git::open_thread_safe(path) {
4528 Ok(repo) => {
4529 opened = repo;
4530 &opened
4531 }
4532 Err(error) => {
4533 return Some(match kind {
4534 Kind::Repo | Kind::Worktree => default_branch::Resolution::failed(error),
4535 Kind::Submodule => default_branch::Resolution::submodule_uninitialized(),
4536 });
4537 }
4538 },
4539 };
4540 let local = repo.to_thread_local();
4541 let facts = chain_facts_for(memo.cache, common_dir, memo.reads, || {
4542 default_branch::ChainFacts::resolve(&local)
4543 });
4544 Some(supersede_with_network(
4545 default_branch::resolve_with_facts(&facts, hints.override_branch),
4546 hints.network_branch,
4547 ))
4548}
4549
4550struct CheapProbeOutcomes {
4555 branch: Option<(
4556 Settled<Head>,
4557 Option<git::InProgressOperation>,
4558 Vec<git::RecentCommit>,
4559 )>,
4560 sync: Option<Settled<SyncState>>,
4561 base: Option<Settled<u32>>,
4562 default_branch: Option<default_branch::Resolution>,
4563}
4564
4565fn apply_cheap_probe_outcomes(
4574 table: &Arc<RwLock<Table>>,
4575 key: &EntityKey,
4576 generation: Generation,
4577 outcomes: CheapProbeOutcomes,
4578) {
4579 let CheapProbeOutcomes {
4580 branch: branch_outcome,
4581 sync: sync_outcome,
4582 base: base_outcome,
4583 default_branch: default_branch_outcome,
4584 } = outcomes;
4585 let mut table = table.write().unwrap();
4586 if let Some(&idx) = table.index.get(key) {
4587 if let Some((settled, in_progress, recent)) = branch_outcome {
4588 table.entities[idx].apply_branch_probe(generation, settled, in_progress, recent);
4589 }
4590 if let Some(settled) = sync_outcome {
4591 table.entities[idx].sync.settle(generation, settled);
4592 }
4593 if let Some(settled) = base_outcome {
4594 table.entities[idx].base.settle(generation, settled);
4595 }
4596 if let Some(resolution) = default_branch_outcome {
4597 table.entities[idx].apply_default_branch_resolution(generation, resolution);
4598 }
4599 }
4600}
4601
4602struct ProbeOutcomes {
4606 state: Option<Settled<WorktreeState>>,
4607 dirty: Option<Settled<DirtyCounts>>,
4608}
4609
4610fn apply_probe_outcome(
4624 table: &Arc<RwLock<Table>>,
4625 settle_gate: &Arc<SettleGate>,
4626 key: &EntityKey,
4627 generation: Generation,
4628 outcomes: ProbeOutcomes,
4629) {
4630 let ProbeOutcomes {
4631 state: state_outcome,
4632 dirty: dirty_outcome,
4633 } = outcomes;
4634 let mut table = table.write().unwrap();
4635 if let Some(&idx) = table.index.get(key) {
4636 if let Some(settled) = state_outcome {
4637 table.entities[idx].state.settle(generation, settled);
4638 }
4639 if let Some(settled) = dirty_outcome {
4640 table.entities[idx].dirty.settle(generation, settled);
4641 }
4642 }
4643 if table
4651 .in_flight
4652 .get(key)
4653 .is_some_and(|in_flight| in_flight.generation == generation.value())
4654 {
4655 table.in_flight.remove(key);
4656 }
4657 drop(table);
4658 complete_one(settle_gate);
4659}
4660
4661fn merge_discovery(
4667 table: &mut Table,
4668 exclusions: &[ResolvedExclusion],
4669 discovered: Vec<discovery::DiscoveredEntity>,
4670 gitmodules_failures: Vec<(EntityKey, String)>,
4671) -> usize {
4672 let mut found: HashSet<EntityKey> = HashSet::with_capacity(discovered.len());
4673
4674 for discovered in discovered {
4675 found.insert(discovered.key.clone());
4676 match table.index.get(&discovered.key).copied() {
4677 Some(idx) => {
4678 table.entities[idx].presence = Presence::Present;
4679 if let Some(repo) = discovered.repo {
4680 table.repos.insert(discovered.key.clone(), repo);
4681 }
4682 }
4683 None => {
4684 let name = discovered
4685 .display_name_override
4686 .clone()
4687 .unwrap_or_else(|| display_name(discovered.key.path()));
4688 let mut entity = EntityState::new(
4689 discovered.key.clone(),
4690 name,
4691 Arc::clone(&discovered.common_dir),
4692 discovered.kind,
4693 );
4694 entity.excluded =
4695 excluded_by(exclusions, discovered.key.path(), &discovered.common_dir);
4696 if let Some(repo) = discovered.repo {
4697 table.repos.insert(discovered.key.clone(), repo);
4698 }
4699 let idx = table.entities.len();
4700 table.index.insert(discovered.key, idx);
4701 table.entities.push(entity);
4702 }
4703 }
4704 }
4705
4706 let now_failing: HashMap<EntityKey, String> = gitmodules_failures.into_iter().collect();
4710 for key in &found {
4711 if let Some(&idx) = table.index.get(key) {
4712 table.entities[idx].diagnostics.gitmodules_failed = now_failing
4713 .get(key)
4714 .map(|message| Arc::from(message.as_str()));
4715 }
4716 }
4717
4718 let missing: Vec<EntityKey> = table
4719 .index
4720 .keys()
4721 .filter(|key| !found.contains(*key))
4722 .cloned()
4723 .collect();
4724 let mut cancelled = 0usize;
4725 for key in missing {
4726 if let Some(&idx) = table.index.get(&key) {
4727 table.entities[idx].mark_vanished();
4728 }
4729 if let Some(in_flight) = table.in_flight.remove(&key) {
4730 in_flight.cancel.store(true, Ordering::Release);
4731 cancelled += 1;
4732 }
4733 }
4734
4735 cancelled
4736}
4737
4738fn display_name(path: &Path) -> Arc<str> {
4747 Arc::from(
4748 path.file_name()
4749 .and_then(|name| name.to_str())
4750 .unwrap_or("?"),
4751 )
4752}
4753
4754fn watch_for_slow_discovery(
4760 progress: Arc<AtomicUsize>,
4761 finished: Arc<AtomicBool>,
4762 roots: Vec<PathBuf>,
4763 warn_after: Duration,
4764) -> Option<String> {
4765 thread::sleep(warn_after);
4766 if finished.load(Ordering::Acquire) {
4767 return None;
4768 }
4769 Some(still_walking_message(
4770 progress.load(Ordering::Acquire),
4771 &roots,
4772 ))
4773}
4774
4775fn still_walking_message(directories_visited: usize, roots: &[PathBuf]) -> String {
4776 let roots = roots
4777 .iter()
4778 .map(|root| root.display().to_string())
4779 .collect::<Vec<_>>()
4780 .join(", ");
4781 format!("discovery: still walking, {directories_visited} directories reached under {roots}")
4782}
4783
4784fn abandoned_discovery_message(directories_visited: usize) -> String {
4789 format!("discovery: stopped at {directories_visited} directories")
4790}
4791
4792#[allow(dead_code)] pub(crate) fn run_while_not_cancelled(
4800 cancel: &AtomicBool,
4801 mut step: impl FnMut() -> bool,
4802) -> usize {
4803 let mut ran = 0;
4804 while !cancel.load(Ordering::Acquire) {
4805 if !step() {
4806 break;
4807 }
4808 ran += 1;
4809 }
4810 ran
4811}
4812
4813#[cfg(test)]
4814mod tests {
4815 use std::fs;
4816 use std::process::Command;
4817
4818 use super::*;
4819 use crate::entity::{AheadBehind, DefaultBranchStopped, WorktreeState};
4820 use crate::liveness::{BACKSTOP, FIXTURE_LIFETIME, wait_for};
4821 use crate::snapshot::{RowSummary, summary};
4822 use crate::test_support::{git, head_sha, loose_object_count};
4823
4824 fn init_repo_with_a_commit(path: &Path) {
4825 fs::create_dir_all(path).expect("create repo dir");
4826 gix::init(path).expect("init repo");
4827 let status = Command::new("git")
4828 .arg("-C")
4829 .arg(path)
4830 .args(["-c", "user.email=test@example.com", "-c", "user.name=Test"])
4831 .args(["commit", "--allow-empty", "-m", "first"])
4832 .status()
4833 .expect("run git commit");
4834 assert!(status.success());
4835 }
4836
4837 fn commit_a_change(path: &Path, message: &str) {
4847 let gitdir = gitdir_of(path);
4848 let before = poll::fingerprint(&gitdir);
4849
4850 std::fs::write(path.join(format!("{message}.txt")), message.as_bytes())
4851 .expect("write a file to commit");
4852 let added = Command::new("git")
4853 .arg("-C")
4854 .arg(path)
4855 .args(["add", "-A"])
4856 .status()
4857 .expect("run git add");
4858 assert!(added.success());
4859 commit(path, message, &["-m", message]);
4860
4861 assert!(
4867 poll::moved(&before, &poll::fingerprint(&gitdir)),
4868 "committing in {} moved none of the polled paths under {}, so this fixture cannot \
4869 show the poll anything",
4870 path.display(),
4871 gitdir.display()
4872 );
4873 }
4874
4875 fn gitdir_of(work_dir: &Path) -> PathBuf {
4878 let output = Command::new("git")
4879 .arg("-C")
4880 .arg(work_dir)
4881 .args(["rev-parse", "--absolute-git-dir"])
4882 .output()
4883 .expect("run git rev-parse");
4884 assert!(
4885 output.status.success(),
4886 "resolve the gitdir of {}",
4887 work_dir.display()
4888 );
4889 PathBuf::from(
4890 std::str::from_utf8(&output.stdout)
4891 .expect("a utf-8 gitdir path")
4892 .trim(),
4893 )
4894 }
4895
4896 fn commit(path: &Path, message: &str, args: &[&str]) {
4898 let status = Command::new("git")
4899 .arg("-C")
4900 .arg(path)
4901 .args(["-c", "user.email=test@example.com", "-c", "user.name=Test"])
4902 .arg("commit")
4903 .args(args)
4904 .status()
4905 .unwrap_or_else(|error| panic!("run git commit {message}: {error}"));
4906 assert!(status.success());
4907 }
4908
4909 fn fetch_spec_for_test() -> FetchSpec {
4913 FetchSpec {
4914 enabled: false,
4915 interval: Duration::from_secs(3600),
4916 concurrency: 4,
4917 }
4918 }
4919
4920 fn auto_update_spec_for_test() -> AutoUpdateSpec {
4925 AutoUpdateSpec { enabled: false }
4926 }
4927
4928 fn spec(roots: Vec<PathBuf>) -> CoreSpec {
4929 CoreSpec {
4930 set: SetSpec {
4931 name: "test".to_string(),
4932 roots,
4933 include: Vec::new(),
4934 exclude: Vec::new(),
4935 },
4936 overrides: Vec::new(),
4937 poll_interval: Duration::from_secs(3600),
4938 status_stale_after: Duration::from_secs(3600),
4939 generation_deadline: Duration::from_secs(3600),
4940 show_submodules: false,
4941 fetch: fetch_spec_for_test(),
4942 auto_update: auto_update_spec_for_test(),
4943 }
4944 }
4945
4946 #[test]
4957 fn core_spec_carries_no_scoping_field_scope_is_never_a_dial() {
4958 let CoreSpec {
4959 set: _,
4960 overrides: _,
4961 poll_interval: _,
4962 status_stale_after: _,
4963 generation_deadline: _,
4964 show_submodules: _,
4965 fetch: _,
4966 auto_update: _,
4967 } = spec(Vec::new());
4968 }
4969
4970 fn root_of(dir: &tempfile::TempDir) -> PathBuf {
4971 dir.path().canonicalize().expect("canonicalize temp dir")
4972 }
4973
4974 fn settle_launch(core: &Core) -> Snapshot {
4983 let launched = core.settle();
4984 assert_eq!(
4985 core.settle_gate_count_for_test(),
4986 0,
4987 "launch's own Generation never settled, so nothing after this is starting from \
4988 the point it claims to"
4989 );
4990 launched
4991 }
4992
4993 fn started_and_settled(spec: CoreSpec) -> (Core, Snapshot) {
4996 let core = Core::start_discovered(spec);
4997 let launched = settle_launch(&core);
4998 (core, launched)
4999 }
5000
5001 fn backdate_polled_entries(work_dir: &Path) {
5008 let gitdir = gitdir_of(work_dir);
5009
5010 let past = std::time::SystemTime::now() - Duration::from_secs(10);
5011 let mut touched = 0;
5012 for name in poll::POLLED_GITDIR_ENTRIES {
5013 let path = gitdir.join(name);
5014 if path.exists() {
5015 set_mtime_to(&path, past);
5016 touched += 1;
5017 }
5018 }
5019 assert!(
5020 touched > 0,
5021 "backdated nothing under {}; the gitdir holds none of the polled entries and the \
5022 baseline this sets up would not be older than what follows",
5023 gitdir.display()
5024 );
5025 }
5026
5027 fn set_mtime_to(path: &Path, at: std::time::SystemTime) {
5029 use std::os::unix::ffi::OsStrExt;
5030
5031 let secs = at
5032 .duration_since(std::time::SystemTime::UNIX_EPOCH)
5033 .expect("a time after the epoch")
5034 .as_secs() as libc::time_t;
5035 let times = [
5036 libc::timespec {
5037 tv_sec: secs,
5038 tv_nsec: 0,
5039 },
5040 libc::timespec {
5041 tv_sec: secs,
5042 tv_nsec: 0,
5043 },
5044 ];
5045 let c_path =
5046 std::ffi::CString::new(path.as_os_str().as_bytes()).expect("a path with no NUL");
5047 let rc = unsafe { libc::utimensat(libc::AT_FDCWD, c_path.as_ptr(), times.as_ptr(), 0) };
5048 assert_eq!(
5049 rc,
5050 0,
5051 "set mtime on {}: {}",
5052 path.display(),
5053 std::io::Error::last_os_error()
5054 );
5055 }
5056
5057 fn step(argv: &[&str]) -> Step {
5058 Step {
5059 argv: argv.iter().map(|s| s.to_string()).collect(),
5060 shell: false,
5061 interactive: false,
5062 env: Vec::new(),
5063 }
5064 }
5065
5066 fn shell_step(command: &str) -> Step {
5068 Step {
5069 argv: vec![command.to_string()],
5070 shell: true,
5071 interactive: false,
5072 env: Vec::new(),
5073 }
5074 }
5075
5076 fn interactive_shell_step(command: &str) -> Step {
5079 Step {
5080 argv: vec![command.to_string()],
5081 shell: true,
5082 interactive: true,
5083 env: Vec::new(),
5084 }
5085 }
5086
5087 fn receipt_labelled(core: &Core, key: &EntityKey, label: &str) -> Option<ActionReceipt> {
5091 core.snapshot()
5092 .entities
5093 .iter()
5094 .find(|entity| entity.key == *key)
5095 .and_then(|entity| entity.last_action.clone())
5096 .filter(|receipt| &*receipt.label == label)
5097 }
5098
5099 fn action(label: &str, steps: Vec<Step>) -> ActionSpec {
5100 ActionSpec {
5101 label: Arc::from(label),
5102 name: Some(Arc::from(label)),
5103 steps,
5104 concurrency: 4,
5105 when: None,
5106 }
5107 }
5108
5109 fn action_with_when(label: &str, steps: Vec<Step>, when: &str) -> ActionSpec {
5112 ActionSpec {
5113 when: Some(Filter::parse(when)),
5114 ..action(label, steps)
5115 }
5116 }
5117
5118 #[test]
5123 fn refresh_and_settle_populate_real_cells_without_the_caller_spawning_a_thread() {
5124 let dir = tempfile::tempdir().expect("temp dir");
5125 let root = root_of(&dir);
5126 let repo = root.join("repo");
5127 init_repo_with_a_commit(&repo);
5128
5129 let core = Core::start_discovered(spec(vec![root]));
5130 let keys: Vec<EntityKey> = core
5131 .snapshot()
5132 .entities
5133 .iter()
5134 .map(|entity| entity.key.clone())
5135 .collect();
5136 assert_eq!(keys.len(), 1);
5137
5138 core.refresh(&keys);
5139 let settled = core.settle();
5140
5141 let entity = &settled.entities[0];
5142 match entity.branch.settled() {
5143 Some(Settled::Known {
5144 value: Head::Branch { .. },
5145 at: _,
5146 stale: _,
5147 }) => {}
5148 other => panic!("expected an attached branch, got {other:?}"),
5149 }
5150 }
5151
5152 fn spec_refresh_md() -> String {
5157 let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
5158 std::fs::read_to_string(manifest_dir.join("../../docs/spec/refresh.md"))
5159 .expect("read docs/spec/refresh.md")
5160 }
5161
5162 fn spec_first_frame_budgets_ms(spec: &str) -> (u64, u64) {
5163 let anchor = "rows with names on screen within ";
5164 let after = spec
5165 .split(anchor)
5166 .nth(1)
5167 .expect("the first-frame budget sentence is present");
5168 let mut parts = after.splitn(2, "ms, every cheap column filled within ");
5169 let names: u64 = parts
5170 .next()
5171 .expect("a names-on-screen budget")
5172 .parse()
5173 .expect("the names-on-screen budget is an integer");
5174 let after_cheap = parts.next().expect("a cheap-column budget and beyond");
5175 let cheap_columns: u64 = after_cheap
5176 .split("ms,")
5177 .next()
5178 .expect("a cheap-column budget")
5179 .parse()
5180 .expect("the cheap-column budget is an integer");
5181 (names, cheap_columns)
5182 }
5183
5184 #[test]
5188 fn first_frame_budget_constants_match_the_spec_of_record() {
5189 let spec = spec_refresh_md();
5190 let (names_ms, cheap_columns_ms) = spec_first_frame_budgets_ms(&spec);
5191 assert_eq!(names_ms, FIRST_FRAME_NAMES_BUDGET_MS);
5192 assert_eq!(cheap_columns_ms, FIRST_FRAME_CHEAP_COLUMNS_BUDGET_MS);
5193 }
5194
5195 #[test]
5203 fn every_dispatched_entity_gets_its_dirty_cell_settled_not_a_subset() {
5204 let dir = tempfile::tempdir().expect("temp dir");
5205 let root = root_of(&dir);
5206 const ENTITY_COUNT: usize = 16;
5207 for index in 0..ENTITY_COUNT {
5208 init_repo_with_a_commit(&root.join(format!("repo-{index}")));
5209 }
5210
5211 let core = Core::start_discovered(spec(vec![root]));
5212 let keys: Vec<EntityKey> = core
5213 .snapshot()
5214 .entities
5215 .iter()
5216 .map(|entity| entity.key.clone())
5217 .collect();
5218 assert_eq!(keys.len(), ENTITY_COUNT, "expected every repo discovered");
5219
5220 core.refresh(&keys);
5221 let settled = core.settle();
5222
5223 for entity in &settled.entities {
5224 assert!(
5225 matches!(
5226 entity.dirty.settled(),
5227 Some(Settled::Known {
5228 value: _,
5229 at: _,
5230 stale: _
5231 })
5232 ),
5233 "entity {:?} was left without a settled dirty cell, which is exactly what a \
5234 visibility-scoped dispatch would leave behind on the entities it skipped: \
5235 got {:?}",
5236 entity.name,
5237 entity.dirty.settled()
5238 );
5239 }
5240 }
5241
5242 #[test]
5257 fn cheap_outcomes_land_before_a_held_phase_c_settles() {
5258 let dir = tempfile::tempdir().expect("temp dir");
5259 let root = root_of(&dir);
5260 let repo = root.join("repo");
5261 init_repo_with_a_commit(&repo);
5262
5263 let (core, launched) = started_and_settled(spec(vec![root]));
5264 let key = launched.entities[0].key.clone();
5265 assert_eq!(
5266 dirty_total(&launched.entities[0]),
5267 0,
5268 "the fixture starts clean, which is the value the held phase C must still be \
5269 reading once the working tree below has moved"
5270 );
5271
5272 git(&repo, &["checkout", "-b", "held"]);
5276 fs::write(repo.join("untracked.txt"), b"uncommitted")
5277 .expect("write an untracked file into the fixture");
5278
5279 core.hold_phase_c_for_test(&key);
5280 core.refresh(std::slice::from_ref(&key));
5281 core.wait_phase_c_landed_for_test(&key);
5282
5283 let mid_flight = core.snapshot();
5284 let entity = mid_flight
5285 .entities
5286 .iter()
5287 .find(|entity| entity.key == key)
5288 .expect("entity present");
5289 assert!(
5290 matches!(
5291 entity.branch.settled(),
5292 Some(Settled::Known {
5293 value: Head::Branch { name, .. },
5294 at: _,
5295 stale: _
5296 }) if &**name == "held"
5297 ),
5298 "the cheap branch cell must carry this Generation's own answer while phase C is \
5299 still held open, got {:?}",
5300 entity.branch.settled()
5301 );
5302 assert!(
5303 entity.dirty.is_in_flight() && dirty_total(entity) == 0,
5304 "phase C is deliberately held open here; a bundled apply would already have \
5305 written this cell's new count alongside branch, got {:?}",
5306 entity.dirty.settled()
5307 );
5308
5309 core.release_phase_c_for_test(&key);
5310 core.wait_phase_c_finished_for_test(&key);
5311
5312 let settled = core.snapshot();
5313 let entity = settled
5314 .entities
5315 .iter()
5316 .find(|entity| entity.key == key)
5317 .expect("entity present");
5318 assert_eq!(
5319 dirty_total(entity),
5320 1,
5321 "phase C must settle its own count once released, got {:?}",
5322 entity.dirty.settled()
5323 );
5324 }
5325
5326 fn dirty_total(entity: &EntityState) -> u32 {
5330 match entity.dirty.settled() {
5331 Some(Settled::Known {
5332 value,
5333 at: _,
5334 stale: _,
5335 }) => value.total(),
5336 other => panic!("expected a settled dirty count, got {other:?}"),
5337 }
5338 }
5339
5340 #[test]
5348 fn splitting_the_probe_write_signals_settle_gate_exactly_once_per_entity() {
5349 let dir = tempfile::tempdir().expect("temp dir");
5350 let root = root_of(&dir);
5351 init_repo_with_a_commit(&root.join("a"));
5352 init_repo_with_a_commit(&root.join("b"));
5353
5354 let (core, snapshot) = started_and_settled(spec(vec![root]));
5355 let key_a = snapshot
5356 .entities
5357 .iter()
5358 .find(|entity| &*entity.name == "a")
5359 .expect("entity a present")
5360 .key
5361 .clone();
5362 let key_b = snapshot
5363 .entities
5364 .iter()
5365 .find(|entity| &*entity.name == "b")
5366 .expect("entity b present")
5367 .key
5368 .clone();
5369
5370 core.hold_phase_c_for_test(&key_a);
5371 core.hold_phase_c_for_test(&key_b);
5372 core.refresh(&[key_a.clone(), key_b.clone()]);
5373 core.wait_dispatched_for_test();
5377 assert_eq!(
5378 core.settle_gate_count_for_test(),
5379 2,
5380 "dispatching two entities must add exactly two to the settle gate"
5381 );
5382
5383 core.wait_phase_c_landed_for_test(&key_a);
5384 core.wait_phase_c_landed_for_test(&key_b);
5385 assert_eq!(
5386 core.settle_gate_count_for_test(),
5387 2,
5388 "the cheap apply must never touch the settle gate: both entities' cheap \
5389 outcomes have landed and neither has finished phase C yet"
5390 );
5391
5392 core.release_phase_c_for_test(&key_a);
5393 core.wait_phase_c_finished_for_test(&key_a);
5394 assert_eq!(
5395 core.settle_gate_count_for_test(),
5396 1,
5397 "exactly one entity finished, so the gate must fall by exactly one, not two \
5398 (double-counted) and not zero (left short)"
5399 );
5400
5401 core.release_phase_c_for_test(&key_b);
5402 core.wait_phase_c_finished_for_test(&key_b);
5403 assert_eq!(
5404 core.settle_gate_count_for_test(),
5405 0,
5406 "both entities finished, so the gate must be fully drained"
5407 );
5408 }
5409
5410 fn registered_gate(core: &Core, key: &EntityKey) -> PhaseCGateHandle {
5413 core.phase_c_gates
5414 .lock()
5415 .unwrap()
5416 .get(key)
5417 .cloned()
5418 .expect("hold_phase_c_for_test must be called before reading its gate")
5419 }
5420
5421 fn release_gate(gate: &PhaseCGateHandle) {
5424 let (lock, cvar) = &**gate;
5425 lock.lock().unwrap().may_proceed = true;
5426 cvar.notify_all();
5427 }
5428
5429 fn gate_is_finished(gate: &PhaseCGateHandle) -> bool {
5430 gate.0.lock().unwrap().finished
5431 }
5432
5433 #[test]
5445 fn a_probe_signals_the_phase_c_gate_its_own_generation_was_dispatched_against() {
5446 let dir = tempfile::tempdir().expect("temp dir");
5447 let root = root_of(&dir);
5448 init_repo_with_a_commit(&root.join("repo"));
5449
5450 let (core, launched) = started_and_settled(spec(vec![root]));
5451 let key = launched.entities[0].key.clone();
5452
5453 core.hold_phase_c_for_test(&key);
5454 let dispatched_against = registered_gate(&core, &key);
5455 core.refresh(std::slice::from_ref(&key));
5456 core.wait_phase_c_landed_for_test(&key);
5457
5458 core.hold_phase_c_for_test(&key);
5459 let registered_later = registered_gate(&core, &key);
5460 release_gate(&dispatched_against);
5461
5462 wait_for(
5463 "the held probe to signal the gate its own Generation was dispatched against",
5464 || gate_is_finished(&dispatched_against),
5465 );
5466 assert!(
5467 !gate_is_finished(®istered_later),
5468 "a gate registered after this Generation dispatched must never be marked \
5469 finished by it: a test waiting on that gate would return before this \
5470 Generation had applied its outcome or decremented the settle gate"
5471 );
5472 }
5473
5474 #[test]
5486 fn a_probe_finishing_clears_only_its_own_generations_in_flight_entry() {
5487 let dir = tempfile::tempdir().expect("temp dir");
5488 let root = root_of(&dir);
5489 init_repo_with_a_commit(&root.join("repo"));
5490
5491 let (core, launched) = started_and_settled(spec(vec![root]));
5492 let key = launched.entities[0].key.clone();
5493
5494 core.hold_phase_c_for_test(&key);
5495 core.refresh(std::slice::from_ref(&key));
5496 core.wait_phase_c_landed_for_test(&key);
5497
5498 let superseding = core.begin_shared_generation_for_test(std::slice::from_ref(&key));
5501
5502 core.release_phase_c_for_test(&key);
5503 core.wait_phase_c_finished_for_test(&key);
5504
5505 core.refresh(std::slice::from_ref(&key));
5506 core.wait_dispatched_for_test();
5507
5508 assert!(
5509 superseding.cancels[&key].load(Ordering::Acquire),
5510 "a probe from a Generation that has already been superseded must leave the \
5511 live Generation's in-flight entry alone, or the Generation after it has \
5512 nothing to interrupt"
5513 );
5514 }
5515
5516 #[test]
5531 fn refresh_dispatches_phase_c_in_exactly_the_order_it_is_given() {
5532 let dir = tempfile::tempdir().expect("temp dir");
5533 let root = root_of(&dir);
5534 const ENTITY_COUNT: usize = 6;
5535 for index in 0..ENTITY_COUNT {
5536 init_repo_with_a_commit(&root.join(format!("repo-{index}")));
5537 }
5538
5539 let (core, launched) = started_and_settled(spec(vec![root]));
5540 let discovery_order: Vec<EntityKey> = launched
5541 .entities
5542 .iter()
5543 .map(|entity| entity.key.clone())
5544 .collect();
5545 assert_eq!(
5546 discovery_order.len(),
5547 ENTITY_COUNT,
5548 "expected every repo discovered"
5549 );
5550
5551 let cursor = discovery_order[3].clone();
5555 let visible = [discovery_order[1].clone(), discovery_order[4].clone()];
5556 let mut three_tier_order = vec![cursor.clone()];
5557 three_tier_order.extend(visible.iter().cloned());
5558 for key in &discovery_order {
5559 if *key != cursor && !visible.contains(key) {
5560 three_tier_order.push(key.clone());
5561 }
5562 }
5563 assert_eq!(
5564 three_tier_order.len(),
5565 ENTITY_COUNT,
5566 "sanity check: the hand-built order must cover every discovered entity exactly \
5567 once"
5568 );
5569
5570 core.refresh(&three_tier_order);
5571 core.settle();
5572
5573 assert_eq!(
5574 core.dispatch_log_for_test(),
5575 three_tier_order,
5576 "refresh must dispatch phase C in exactly the order it was given: the cursor \
5577 row, then the visible rows, then the rest in discovery order"
5578 );
5579 }
5580
5581 #[test]
5586 fn refresh_reuses_the_cached_repository_handle_rather_than_reopening_it() {
5587 let dir = tempfile::tempdir().expect("temp dir");
5588 let root = root_of(&dir);
5589 let repo = root.join("repo");
5590 init_repo_with_a_commit(&repo);
5591
5592 let core = Core::start_discovered(spec(vec![root]));
5593 let key = core.snapshot().entities[0].key.clone();
5594 let before = core
5595 .cached_repo_handle_for_test(&key)
5596 .expect("discovery should have cached a handle");
5597
5598 core.refresh(std::slice::from_ref(&key));
5599 core.settle();
5600
5601 let after = core
5602 .cached_repo_handle_for_test(&key)
5603 .expect("the cached handle should still be there after a refresh");
5604 assert!(
5605 Arc::ptr_eq(&before, &after),
5606 "a refresh must reuse the cached handle, not replace it with a new one"
5607 );
5608 }
5609
5610 #[test]
5616 fn refresh_running_reads_true_the_instant_refresh_returns_and_false_once_it_settles() {
5617 let dir = tempfile::tempdir().expect("temp dir");
5618 let root = root_of(&dir);
5619 init_repo_with_a_commit(&root.join("repo"));
5620
5621 let core = Core::start_discovered(spec(vec![root]));
5622 core.settle();
5623 assert!(
5624 !core.refresh_running(),
5625 "sanity: nothing outstanding once startup has settled"
5626 );
5627
5628 let keys: Vec<EntityKey> = core
5629 .snapshot()
5630 .entities
5631 .iter()
5632 .map(|entity| entity.key.clone())
5633 .collect();
5634 core.refresh(&keys);
5635 assert!(
5636 core.refresh_running(),
5637 "refresh reserves its Generation and records the dispatch debt before it \
5638 returns, so this must already read true"
5639 );
5640
5641 core.settle();
5642 assert!(
5643 !core.refresh_running(),
5644 "settle blocks until nothing is outstanding, so this must read false once it \
5645 returns"
5646 );
5647 }
5648
5649 #[test]
5653 fn probing_a_key_with_no_cached_handle_still_opens_the_repository_itself() {
5654 let dir = tempfile::tempdir().expect("temp dir");
5655 let root = root_of(&dir);
5656 let repo = root.join("repo");
5657 init_repo_with_a_commit(&repo);
5658
5659 let empty_root = root_of(&tempfile::tempdir().expect("temp dir"));
5661 let core = Core::start_discovered(spec(vec![empty_root]));
5662 let key = EntityKey::new(Arc::from(repo.as_path()));
5663 assert!(core.cached_repo_handle_for_test(&key).is_none());
5664
5665 let entity = core.probe_now(&key);
5666
5667 assert!(matches!(
5668 entity.branch.settled(),
5669 Some(Settled::Known {
5670 value: Head::Branch { .. },
5671 at: _,
5672 stale: _
5673 })
5674 ));
5675 }
5676
5677 #[test]
5681 fn an_empty_order_dispatches_nothing_and_settle_returns_immediately() {
5682 let dir = tempfile::tempdir().expect("temp dir");
5683 let root = root_of(&dir);
5684 let repo = root.join("repo");
5685 init_repo_with_a_commit(&repo);
5686
5687 let (core, _launched) = started_and_settled(spec(vec![root]));
5688 assert!(
5689 !core.dispatch_log_for_test().is_empty(),
5690 "launch dispatched nothing, so an empty log below would say nothing about the \
5691 empty order"
5692 );
5693
5694 core.refresh(&[]);
5695 core.wait_dispatched_for_test();
5696
5697 assert_eq!(
5698 core.dispatch_log_for_test(),
5699 Vec::new(),
5700 "an empty order must dispatch no probe"
5701 );
5702 let settled = core
5706 .try_settle(Duration::from_millis(50))
5707 .expect("an empty order raises no probe, so the settle gate is already at zero");
5708 assert!(!settled.entities[0].branch.is_in_flight());
5709 }
5710
5711 fn one_probe_owed_that_never_lands(
5719 dir: &tempfile::TempDir,
5720 ) -> (Core, crossbeam_channel::Sender<Instant>) {
5721 let root = root_of(dir);
5722 init_repo_with_a_commit(&root.join("repo"));
5723 let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
5724 let core = Core::start_for_test(spec(vec![root]), Duration::from_secs(3600), tick_rx)
5725 .discovered()
5726 .core;
5727 let key = settle_launch(&core).entities[0].key.clone();
5728 core.begin_untracked_probe_for_test(&key);
5729 (core, tick_tx)
5730 }
5731
5732 #[test]
5740 #[should_panic(expected = "waiting for everything this Core has in flight to land")]
5741 fn a_settle_that_expires_reports_at_the_wait_rather_than_returning_the_table() {
5742 let dir = tempfile::tempdir().expect("temp dir");
5743 let (core, _tick_tx) = one_probe_owed_that_never_lands(&dir);
5744
5745 core.settle_within(Duration::from_millis(20));
5746 }
5747
5748 #[test]
5752 fn try_settle_hands_an_expiry_back_as_an_error_carrying_the_table_it_gave_up_on() {
5753 let dir = tempfile::tempdir().expect("temp dir");
5754 let (core, _tick_tx) = one_probe_owed_that_never_lands(&dir);
5755
5756 let unsettled = core
5757 .try_settle(Duration::from_millis(20))
5758 .expect_err("a probe nothing will ever complete cannot settle");
5759
5760 assert!(
5761 unsettled.entities[0].branch.is_in_flight(),
5762 "the Err arm must still carry the table as it stood, so a caller that degrades \
5763 deliberately has something to degrade with"
5764 );
5765 }
5766
5767 #[test]
5770 fn try_settle_hands_a_generation_that_really_landed_back_as_ok() {
5771 let dir = tempfile::tempdir().expect("temp dir");
5772 let root = root_of(&dir);
5773 init_repo_with_a_commit(&root.join("repo"));
5774
5775 let (core, launched) = started_and_settled(spec(vec![root]));
5776 let key = launched.entities[0].key.clone();
5777 core.refresh(std::slice::from_ref(&key));
5778
5779 let settled = core
5780 .try_settle(BACKSTOP)
5781 .expect("a dispatched Generation must land inside the backstop");
5782
5783 assert!(!settled.entities[0].branch.is_in_flight());
5784 }
5785
5786 #[test]
5790 fn probe_now_settles_the_sync_cell_as_well_as_the_branch_it_depends_on() {
5791 let dir = tempfile::tempdir().expect("temp dir");
5792 let root = root_of(&dir);
5793 let repo = root.join("repo");
5794 init_repo_with_a_commit(&repo);
5795
5796 let core = Core::start_discovered(spec(vec![root]));
5797 let key = core.snapshot().entities[0].key.clone();
5798
5799 let entity = core.probe_now(&key);
5800
5801 assert!(
5802 matches!(
5803 entity.sync.settled(),
5804 Some(Settled::Known {
5805 value: SyncState::NoRemote,
5806 at: _,
5807 stale: _
5808 })
5809 ),
5810 "expected probe_now to settle sync, got {:?}",
5811 entity.sync.settled()
5812 );
5813 }
5814
5815 #[test]
5818 fn probe_now_settles_the_base_cell_as_well_as_the_branch_it_depends_on() {
5819 let dir = tempfile::tempdir().expect("temp dir");
5820 let root = root_of(&dir);
5821 let repo = root.join("repo");
5822 init_repo_with_a_commit(&repo);
5823
5824 let core = Core::start_discovered(spec(vec![root]));
5825 let key = core.snapshot().entities[0].key.clone();
5826
5827 let entity = core.probe_now(&key);
5828
5829 assert!(
5830 matches!(entity.base.settled(), Some(Settled::NotApplicable)),
5831 "expected probe_now to settle base Not applicable for a Repo with no remote, \
5832 got {:?}",
5833 entity.base.settled()
5834 );
5835 }
5836
5837 #[test]
5841 fn refresh_settles_a_real_base_count_against_the_resolved_default_branch() {
5842 let dir = tempfile::tempdir().expect("temp dir");
5843 let root = root_of(&dir);
5844 let repo = root.join("repo");
5845 init_repo_with_a_commit(&repo);
5846 git(
5847 &repo,
5848 &[
5849 "remote",
5850 "add",
5851 "origin",
5852 "https://example.invalid/repo.git",
5853 ],
5854 );
5855 let root_sha = head_sha(&repo);
5856 git(&repo, &["commit", "--allow-empty", "-m", "second"]);
5862 let tip_sha = head_sha(&repo);
5863 git(&repo, &["reset", "--hard", &root_sha]);
5864 git(&repo, &["update-ref", "refs/remotes/origin/main", &tip_sha]);
5865
5866 let core = Core::start_discovered(spec(vec![root]));
5867 let key = core.snapshot().entities[0].key.clone();
5868
5869 core.refresh(std::slice::from_ref(&key));
5870 let settled = core.settle();
5871
5872 assert!(
5873 matches!(
5874 settled.entities[0].base.settled(),
5875 Some(Settled::Known {
5876 value: 1,
5877 at: _,
5878 stale: _
5879 })
5880 ),
5881 "expected a real refresh to settle base's live count against the resolved \
5882 default branch, got {:?}",
5883 settled.entities[0].base.settled()
5884 );
5885 }
5886
5887 #[test]
5893 fn probe_now_settles_the_dirty_cell_with_the_counts_it_probed() {
5894 let dir = tempfile::tempdir().expect("temp dir");
5895 let root = root_of(&dir);
5896 let repo = root.join("repo");
5897 init_repo_with_a_commit(&repo);
5898 fs::write(repo.join("untracked.txt"), "x").expect("write untracked file");
5899
5900 let core = Core::start_discovered(spec(vec![root]));
5901 let key = core.snapshot().entities[0].key.clone();
5902
5903 let entity = core.probe_now(&key);
5904
5905 assert!(
5906 matches!(
5907 entity.dirty.settled(),
5908 Some(Settled::Known {
5909 value: DirtyCounts {
5910 modified: 0,
5911 untracked: 1,
5912 deleted: 0,
5913 },
5914 at: _,
5915 stale: _
5916 })
5917 ),
5918 "expected probe_now to settle dirty with the one untracked path, got {:?}",
5919 entity.dirty.settled()
5920 );
5921 }
5922
5923 #[test]
5924 fn probe_now_updates_the_entity_synchronously_with_no_refresh_call() {
5925 let dir = tempfile::tempdir().expect("temp dir");
5926 let root = root_of(&dir);
5927 let repo = root.join("repo");
5928 init_repo_with_a_commit(&repo);
5929
5930 let core = Core::start_discovered(spec(vec![root]));
5931 let key = core.snapshot().entities[0].key.clone();
5932
5933 let entity = core.probe_now(&key);
5934
5935 assert!(matches!(
5936 entity.branch.settled(),
5937 Some(Settled::Known {
5938 value: Head::Branch { .. },
5939 at: _,
5940 stale: _
5941 })
5942 ));
5943 }
5944
5945 #[test]
5951 fn the_display_name_agrees_between_discovery_and_probe_nows_fallback_insert() {
5952 let dir = tempfile::tempdir().expect("temp dir");
5953 let root = root_of(&dir);
5954 let repo = root.join("named-repo");
5955 init_repo_with_a_commit(&repo);
5956
5957 let core = Core::start_discovered(spec(vec![root]));
5958 let discovered = core.snapshot().entities[0].clone();
5959 assert_eq!(&*discovered.name, "named-repo");
5960
5961 core.dismiss(&discovered.key);
5962 assert!(core.snapshot().entities.is_empty());
5963
5964 let reinserted = core.probe_now(&discovered.key);
5965
5966 assert_eq!(
5967 reinserted.name, discovered.name,
5968 "the name discovery assigned and the name probe_now's fallback insert \
5969 assigns for the same path must be byte-identical"
5970 );
5971 }
5972
5973 #[test]
5974 fn dismiss_removes_the_entity_from_the_snapshot() {
5975 let dir = tempfile::tempdir().expect("temp dir");
5976 let root = root_of(&dir);
5977 let repo = root.join("repo");
5978 init_repo_with_a_commit(&repo);
5979
5980 let core = Core::start_discovered(spec(vec![root]));
5981 let key = core.snapshot().entities[0].key.clone();
5982
5983 core.dismiss(&key);
5984
5985 assert!(core.snapshot().entities.is_empty());
5986 }
5987
5988 #[test]
6001 fn an_entitys_steps_run_in_order_and_a_failure_marks_every_later_step_not_run() {
6002 let dir = tempfile::tempdir().expect("temp dir");
6003 let root = root_of(&dir);
6004 let repo = root.join("repo");
6005 init_repo_with_a_commit(&repo);
6006 let marker = repo.join("step-three-ran");
6007
6008 let core = Core::start_discovered(spec(vec![root]));
6009 let key = core.snapshot().entities[0].key.clone();
6010 let steps = vec![
6011 step(&["true"]),
6012 step(&["sh", "-c", "exit 7"]),
6013 step(&["touch", "step-three-ran"]),
6014 ];
6015
6016 let started = core.run_action(action("reinstall", steps), std::slice::from_ref(&key));
6017
6018 assert!(started);
6019 wait_for("the fan-out to finish and write a receipt", || {
6020 !core.action_running()
6021 });
6022 let receipt = core.snapshot().entities[0]
6023 .last_action
6024 .clone()
6025 .expect("receipt written");
6026 assert_eq!(receipt.steps.len(), 3);
6027 assert_eq!(receipt.steps[0].outcome, StepOutcome::Ok);
6028 assert_eq!(receipt.steps[1].outcome, StepOutcome::Failed(7));
6029 assert_eq!(
6030 receipt.steps[2].outcome,
6031 StepOutcome::NotRun,
6032 "a step after a failure must be recorded NotRun, not silently dropped or run anyway"
6033 );
6034 assert!(
6035 !marker.exists(),
6036 "the third step's own `touch` must never have run: its marker file exists, so \
6037 the step ran despite being recorded NotRun"
6038 );
6039 }
6040
6041 #[test]
6046 fn steps_run_in_the_order_theyre_declared_not_some_other_order() {
6047 let dir = tempfile::tempdir().expect("temp dir");
6048 let root = root_of(&dir);
6049 let repo = root.join("repo");
6050 init_repo_with_a_commit(&repo);
6051 let order_log = repo.join("order.log");
6052
6053 let core = Core::start_discovered(spec(vec![root]));
6054 let key = core.snapshot().entities[0].key.clone();
6055 let steps = vec![
6056 step(&["sh", "-c", "printf 1 >> order.log"]),
6057 step(&["sh", "-c", "printf 2 >> order.log"]),
6058 step(&["sh", "-c", "printf 3 >> order.log"]),
6059 ];
6060
6061 let started = core.run_action(action("ordering", steps), std::slice::from_ref(&key));
6062
6063 assert!(started);
6064 wait_for("the fan-out to finish and write a receipt", || {
6065 !core.action_running()
6066 });
6067 let receipt = core.snapshot().entities[0]
6068 .last_action
6069 .clone()
6070 .expect("receipt written");
6071 assert_eq!(receipt.steps.len(), 3);
6072 assert!(
6073 receipt
6074 .steps
6075 .iter()
6076 .all(|result| result.outcome == StepOutcome::Ok),
6077 "every step here always exits zero; this test isolates ordering from gating"
6078 );
6079 let content = fs::read_to_string(&order_log).expect("order.log written by the steps");
6080 assert_eq!(
6081 content, "123",
6082 "the file's content pins actual execution order; running the steps out of \
6083 declaration order would produce a different digit sequence here even though \
6084 every step still succeeds"
6085 );
6086 }
6087
6088 #[test]
6096 fn a_still_running_actions_finished_step_and_its_currently_executing_one_are_both_visible_before_the_whole_run_ends()
6097 {
6098 let dir = tempfile::tempdir().expect("temp dir");
6099 let root = root_of(&dir);
6100 let repo = root.join("repo");
6101 init_repo_with_a_commit(&repo);
6102
6103 let core = Core::start_discovered(spec(vec![root]));
6104 let key = core.snapshot().entities[0].key.clone();
6105 let steps = vec![step(&["true"]), step(&["sh", "-c", "sleep 0.5"])];
6106
6107 let started = core.run_action(action("reinstall", steps), std::slice::from_ref(&key));
6108 assert!(started);
6109
6110 wait_for(
6115 "a receipt naming the second step running before the run finished",
6116 || {
6117 core.snapshot().entities[0]
6118 .last_action
6119 .as_ref()
6120 .and_then(|receipt| receipt.running.as_ref())
6121 .is_some_and(|running| running.label.contains("sleep"))
6122 },
6123 );
6124 let mid_run = core.snapshot().entities[0]
6125 .last_action
6126 .clone()
6127 .expect("receipt written");
6128 assert_eq!(
6129 mid_run.steps.len(),
6130 1,
6131 "the first, already-finished step must already be in `steps`"
6132 );
6133 assert_eq!(mid_run.steps[0].outcome, StepOutcome::Ok);
6134 let running = mid_run.running.expect("a step must be recorded running");
6135 assert!(
6136 running.label.contains("sleep"),
6137 "expected the running step's own label, got {:?}",
6138 running.label
6139 );
6140
6141 wait_for("the fan-out to finish", || !core.action_running());
6142 let finished = core.snapshot().entities[0]
6143 .last_action
6144 .clone()
6145 .expect("receipt written");
6146 assert!(
6147 finished.running.is_none(),
6148 "a finished receipt must carry no running step"
6149 );
6150 assert_eq!(finished.steps.len(), 2);
6151 }
6152
6153 #[test]
6162 fn a_shell_true_step_runs_through_shell_c_with_repon_as_its_own_dollar_zero() {
6163 let dir = tempfile::tempdir().expect("temp dir");
6164 let root = root_of(&dir);
6165 let repo = root.join("repo");
6166 init_repo_with_a_commit(&repo);
6167
6168 let core = Core::start_discovered(spec(vec![root]));
6169 let key = core.snapshot().entities[0].key.clone();
6170 let steps = vec![shell_step("echo \"[$0]\"")];
6171
6172 let started = core.run_action(action("shell-step", steps), std::slice::from_ref(&key));
6173
6174 assert!(started);
6175 wait_for("the fan-out to finish and write a receipt", || {
6176 !core.action_running()
6177 });
6178 let receipt = core.snapshot().entities[0]
6179 .last_action
6180 .clone()
6181 .expect("receipt written");
6182 assert_eq!(receipt.steps.len(), 1);
6183 assert_eq!(receipt.steps[0].outcome, StepOutcome::Ok);
6184 assert_eq!(&*receipt.steps[0].output, b"[repon]\n");
6185 assert!(
6186 receipt.steps[0].shell,
6187 "the receipt's own StepResult::shell must carry the mode the step ran under"
6188 );
6189 }
6190
6191 #[test]
6198 fn an_interactive_shell_true_step_runs_through_run_action_with_interactive_on_its_receipt() {
6199 let dir = tempfile::tempdir().expect("temp dir");
6200 let root = root_of(&dir);
6201 let repo = root.join("repo");
6202 init_repo_with_a_commit(&repo);
6203
6204 let core = Core::start_discovered(spec(vec![root]));
6205 let key = core.snapshot().entities[0].key.clone();
6206 let steps = vec![interactive_shell_step("true")];
6207
6208 let started = core.run_action(
6209 action("interactive-step", steps),
6210 std::slice::from_ref(&key),
6211 );
6212
6213 assert!(started);
6214 wait_for("the fan-out to finish and write a receipt", || {
6215 !core.action_running()
6216 });
6217 let receipt = core.snapshot().entities[0]
6218 .last_action
6219 .clone()
6220 .expect("receipt written");
6221 assert_eq!(receipt.steps.len(), 1);
6222 assert_eq!(receipt.steps[0].outcome, StepOutcome::Ok);
6223 assert!(
6224 receipt.steps[0].shell,
6225 "an interactive step is still a shell step"
6226 );
6227 assert!(
6228 receipt.steps[0].interactive,
6229 "the receipt's own StepResult::interactive must carry the mode the step ran under"
6230 );
6231 }
6232
6233 #[test]
6237 fn an_argv_step_runs_through_run_action_with_shell_false_on_its_receipt() {
6238 let dir = tempfile::tempdir().expect("temp dir");
6239 let root = root_of(&dir);
6240 let repo = root.join("repo");
6241 init_repo_with_a_commit(&repo);
6242
6243 let core = Core::start_discovered(spec(vec![root]));
6244 let key = core.snapshot().entities[0].key.clone();
6245 let steps = vec![Step {
6246 argv: vec!["true".to_string()],
6247 shell: false,
6248 interactive: false,
6249 env: Vec::new(),
6250 }];
6251
6252 let started = core.run_action(action("argv-step", steps), std::slice::from_ref(&key));
6253
6254 assert!(started);
6255 wait_for("the fan-out to finish and write a receipt", || {
6256 !core.action_running()
6257 });
6258 let receipt = core.snapshot().entities[0]
6259 .last_action
6260 .clone()
6261 .expect("receipt written");
6262 assert!(!receipt.steps[0].shell);
6263 }
6264
6265 #[test]
6271 fn starting_an_action_cancels_any_generation_already_in_flight() {
6272 let dir = tempfile::tempdir().expect("temp dir");
6273 let root = root_of(&dir);
6274 let repo = root.join("repo");
6275 init_repo_with_a_commit(&repo);
6276
6277 let core = Core::start_discovered(spec(vec![root]));
6278 let key = core.snapshot().entities[0].key.clone();
6279 let in_flight = core.begin_shared_generation_for_test(std::slice::from_ref(&key));
6280 let cancel = in_flight
6281 .cancels
6282 .get(&key)
6283 .expect("the in-flight entity has a cancel flag")
6284 .clone();
6285 assert!(!cancel.load(Ordering::Acquire));
6286
6287 let started = core.run_action(
6288 action("reinstall", vec![step(&["true"])]),
6289 std::slice::from_ref(&key),
6290 );
6291
6292 assert!(started);
6293 assert!(
6294 cancel.load(Ordering::Acquire),
6295 "starting an Action must cancel a Generation already in flight, not share \
6296 execution with it"
6297 );
6298 wait_for("the fan-out and its completion refresh to drain", || {
6301 !core.action_running()
6302 });
6303 }
6304
6305 #[test]
6315 fn a_finished_action_starts_exactly_one_generation_over_every_known_entity() {
6316 let dir = tempfile::tempdir().expect("temp dir");
6317 let root = root_of(&dir);
6318 let acted_on = root.join("acted-on");
6319 let untouched = root.join("untouched");
6320 init_repo_with_a_commit(&acted_on);
6321 init_repo_with_a_commit(&untouched);
6322
6323 let (core, before) = started_and_settled(spec(vec![root]));
6324 let acted_key = before
6325 .entities
6326 .iter()
6327 .find(|entity| entity.key.path() == acted_on)
6328 .expect("the acted-on entity is discovered")
6329 .key
6330 .clone();
6331
6332 let started = core.run_action(
6333 action("reinstall", vec![step(&["true"])]),
6334 std::slice::from_ref(&acted_key),
6335 );
6336
6337 assert!(started);
6338 wait_for(
6339 "the completion Generation to probe every known entity, including the one the \
6340 Action never touched",
6341 || {
6342 let snapshot = core.snapshot();
6343 snapshot.generation != before.generation
6344 && snapshot.entities.iter().all(|entity| {
6345 matches!(
6346 entity.branch.settled(),
6347 Some(Settled::Known {
6348 value: _,
6349 at: _,
6350 stale: _
6351 })
6352 )
6353 })
6354 },
6355 );
6356 assert_eq!(
6357 core.settle().generation,
6358 before.generation.successor(),
6359 "completion must start exactly one Generation: not zero (no refresh at all) and \
6360 not two (a double refresh)"
6361 );
6362 }
6363
6364 #[test]
6374 fn a_completion_dispatches_its_generation_before_releasing_its_run() {
6375 let dir = tempfile::tempdir().expect("temp dir");
6376 let root = root_of(&dir);
6377 let repo = root.join("repo");
6378 init_repo_with_a_commit(&repo);
6379
6380 let (core, before) = started_and_settled(spec(vec![root]));
6381 let key = before.entities[0].key.clone();
6382 let armed = core.action_completion_boundary().arm();
6383
6384 assert!(core.run_action(
6385 action("finishing", vec![step(&["true"])]),
6386 std::slice::from_ref(&key)
6387 ));
6388 armed.wait_until_reached();
6389
6390 assert_eq!(
6391 core.snapshot().generation,
6392 before.generation.successor(),
6393 "the completion Generation must be dispatched before the run releases its \
6394 admission"
6395 );
6396 assert!(
6397 !core.run_action(
6398 action("racing", vec![step(&["true"])]),
6399 std::slice::from_ref(&key)
6400 ),
6401 "a submission before that release must be refused, so what a run cancels on the \
6402 way in is never a Generation the run it replaced has yet to dispatch"
6403 );
6404
6405 drop(armed);
6406 wait_for("the finished run to release its admission", || {
6407 !core.action_running()
6408 });
6409 }
6410
6411 #[test]
6416 fn an_excluded_row_swept_into_an_action_gets_a_not_applicable_receipt_and_no_other_path_does() {
6417 let dir = tempfile::tempdir().expect("temp dir");
6418 let root = root_of(&dir);
6419 let excluded_repo = root.join("excluded");
6420 let normal_repo = root.join("normal");
6421 init_repo_with_a_commit(&excluded_repo);
6422 init_repo_with_a_commit(&normal_repo);
6423
6424 let core = Core::start_discovered(spec_with_overrides(
6425 vec![root],
6426 vec![RepoOverride {
6427 path: excluded_repo.clone(),
6428 default_branch: None,
6429 excluded: true,
6430 }],
6431 ));
6432 let snapshot = core.snapshot();
6433 let find = |path: &Path| {
6434 snapshot
6435 .entities
6436 .iter()
6437 .find(|entity| entity.key.path() == path)
6438 .unwrap_or_else(|| panic!("entity at {path:?} present"))
6439 .key
6440 .clone()
6441 };
6442 let excluded_key = find(&excluded_repo);
6443 let normal_key = find(&normal_repo);
6444 assert!(
6445 snapshot
6446 .entities
6447 .iter()
6448 .find(|entity| entity.key == excluded_key)
6449 .unwrap()
6450 .excluded
6451 );
6452
6453 let started = core.run_action(
6454 action("reinstall", vec![step(&["sh", "-c", "exit 3"])]),
6455 &[excluded_key.clone(), normal_key.clone()],
6456 );
6457
6458 assert!(started);
6459 wait_for("the fan-out to finish", || !core.action_running());
6465
6466 let after = core.snapshot();
6467 let receipt_of = |key: &EntityKey| {
6468 after
6469 .entities
6470 .iter()
6471 .find(|entity| entity.key == *key)
6472 .unwrap()
6473 .last_action
6474 .clone()
6475 .unwrap()
6476 };
6477 let excluded_receipt = receipt_of(&excluded_key);
6478 assert!(excluded_receipt.not_applicable());
6479 assert!(excluded_receipt.steps.is_empty());
6480
6481 let normal_receipt = receipt_of(&normal_key);
6482 assert!(
6483 !normal_receipt.not_applicable(),
6484 "a row that actually ran a step, even a failing one, must never read as \
6485 not_applicable: an excluded row is the one legitimate producer of that outcome"
6486 );
6487 assert!(!normal_receipt.steps.is_empty());
6488 assert!(normal_receipt.failed());
6489 }
6490
6491 #[test]
6498 fn operable_count_matches_how_many_entities_run_action_actually_runs_a_step_against() {
6499 let dir = tempfile::tempdir().expect("temp dir");
6500 let root = root_of(&dir);
6501 let excluded_repo = root.join("excluded");
6502 let normal_repo = root.join("normal");
6503 init_repo_with_a_commit(&excluded_repo);
6504 init_repo_with_a_commit(&normal_repo);
6505
6506 let core = Core::start_discovered(spec_with_overrides(
6507 vec![root],
6508 vec![RepoOverride {
6509 path: excluded_repo.clone(),
6510 default_branch: None,
6511 excluded: true,
6512 }],
6513 ));
6514 let snapshot = core.snapshot();
6515 let find = |path: &Path| {
6516 snapshot
6517 .entities
6518 .iter()
6519 .find(|entity| entity.key.path() == path)
6520 .unwrap_or_else(|| panic!("entity at {path:?} present"))
6521 .key
6522 .clone()
6523 };
6524 let order = [find(&excluded_repo), find(&normal_repo)];
6525
6526 assert_eq!(
6527 core.operable_count(&order),
6528 1,
6529 "one of the two rows is excluded, so exactly one is operable"
6530 );
6531
6532 let started = core.run_action(action("reinstall", vec![step(&["true"])]), &order);
6533 assert!(started);
6534
6535 wait_for("every entity in the order to carry a receipt", || {
6536 let snapshot = core.snapshot();
6537 order.iter().all(|key| {
6538 snapshot
6539 .entities
6540 .iter()
6541 .find(|entity| entity.key == *key)
6542 .and_then(|entity| entity.last_action.as_ref())
6543 .is_some()
6544 })
6545 });
6546
6547 let after = core.snapshot();
6548 let actually_ran = after
6549 .entities
6550 .iter()
6551 .filter(|entity| order.contains(&entity.key))
6552 .filter(|entity| {
6553 entity
6554 .last_action
6555 .as_ref()
6556 .is_some_and(|receipt| !receipt.not_applicable())
6557 })
6558 .count();
6559
6560 assert_eq!(
6561 core.operable_count(&order),
6562 actually_ran,
6563 "operable_count must report exactly how many rows run_action actually ran a \
6564 step against, not merely how many keys resolved"
6565 );
6566 }
6567
6568 #[test]
6573 fn run_action_for_entity_blocking_returns_the_finished_receipt_on_the_calling_thread() {
6574 let dir = tempfile::tempdir().expect("temp dir");
6575 let root = root_of(&dir);
6576 let repo = root.join("repo");
6577 init_repo_with_a_commit(&repo);
6578 let marker = repo.join("hook-ran");
6579
6580 let core = Core::start_discovered(spec_with_overrides(vec![root], Vec::new()));
6581 let key = core
6582 .snapshot()
6583 .entities
6584 .iter()
6585 .find(|entity| entity.key.path() == repo)
6586 .expect("the repo is discovered")
6587 .key
6588 .clone();
6589
6590 let receipt = core
6591 .run_action_for_entity_blocking(
6592 &action("hook", vec![step(&["touch", "hook-ran"])]),
6593 &key,
6594 )
6595 .expect("the entity is known");
6596
6597 assert!(
6598 marker.exists(),
6599 "the step must have already run by the time this call returns"
6600 );
6601 assert_eq!(receipt.steps.len(), 1);
6602 assert_eq!(receipt.steps[0].outcome, StepOutcome::Ok);
6603 }
6604
6605 #[test]
6610 fn run_action_for_entity_blocking_answers_none_for_an_unknown_key() {
6611 let dir = tempfile::tempdir().expect("temp dir");
6612 let root = root_of(&dir);
6613 let core = Core::start_discovered(spec_with_overrides(vec![root.clone()], Vec::new()));
6614
6615 let unknown = EntityKey::new(Arc::from(root.join("never-discovered").as_path()));
6616
6617 assert!(
6618 core.run_action_for_entity_blocking(&action("hook", vec![step(&["true"])]), &unknown)
6619 .is_none()
6620 );
6621 }
6622
6623 #[test]
6630 fn run_action_skips_a_row_its_when_predicate_disproves_rather_than_running_it_anyway() {
6631 let dir = tempfile::tempdir().expect("temp dir");
6632 let root = root_of(&dir);
6633 let proved_repo = root.join("alpha");
6634 let disproved_repo = root.join("beta");
6635 init_repo_with_a_commit(&proved_repo);
6636 init_repo_with_a_commit(&disproved_repo);
6637
6638 let core = Core::start_discovered(spec(vec![root]));
6639 let snapshot = core.snapshot();
6640 let find = |path: &Path| {
6641 snapshot
6642 .entities
6643 .iter()
6644 .find(|entity| entity.key.path() == path)
6645 .unwrap_or_else(|| panic!("entity at {path:?} present"))
6646 .key
6647 .clone()
6648 };
6649 let proved_key = find(&proved_repo);
6650 let disproved_key = find(&disproved_repo);
6651 let order = [proved_key.clone(), disproved_key.clone()];
6652
6653 let started = core.run_action(
6656 action_with_when(
6657 "reinstall",
6658 vec![step(&["sh", "-c", "exit 3"])],
6659 "name:alpha",
6660 ),
6661 &order,
6662 );
6663 assert!(started);
6664 wait_for("the fan-out to finish", || !core.action_running());
6665
6666 let after = core.snapshot();
6667 let receipt_of = |key: &EntityKey| {
6668 after
6669 .entities
6670 .iter()
6671 .find(|entity| entity.key == *key)
6672 .unwrap()
6673 .last_action
6674 .clone()
6675 .unwrap()
6676 };
6677
6678 let proved_receipt = receipt_of(&proved_key);
6679 assert_eq!(
6680 proved_receipt.skip, None,
6681 "the row the predicate proved must actually run"
6682 );
6683 assert!(proved_receipt.failed(), "its own step still ran and failed");
6684
6685 let disproved_receipt = receipt_of(&disproved_key);
6686 assert!(
6687 disproved_receipt.inapplicable(),
6688 "the row the predicate disproved must be skipped rather than run"
6689 );
6690 assert!(disproved_receipt.steps.is_empty());
6691 assert!(
6692 !disproved_receipt.failed(),
6693 "a skipped row never ran a step, so it cannot have failed one"
6694 );
6695 }
6696
6697 #[test]
6706 fn applicability_subtracts_an_excluded_row_before_the_predicate_reads_it() {
6707 let dir = tempfile::tempdir().expect("temp dir");
6708 let root = root_of(&dir);
6709 let excluded_repo = root.join("excluded");
6710 let normal_repo = root.join("normal");
6711 init_repo_with_a_commit(&excluded_repo);
6712 init_repo_with_a_commit(&normal_repo);
6713
6714 let core = Core::start_discovered(spec_with_overrides(
6715 vec![root],
6716 vec![RepoOverride {
6717 path: excluded_repo.clone(),
6718 default_branch: None,
6719 excluded: true,
6720 }],
6721 ));
6722 let order: Vec<EntityKey> = core
6723 .snapshot()
6724 .entities
6725 .iter()
6726 .map(|entity| entity.key.clone())
6727 .collect();
6728 assert_eq!(order.len(), 2, "the fixture must discover both repos");
6729
6730 let counts = core.applicability(&order, &Filter::parse("kind:repo"));
6731
6732 assert_eq!(
6733 counts.total(),
6734 core.operable_count(&order),
6735 "the predicate must be counted over exactly the rows `operable_count` keeps"
6736 );
6737 assert_eq!(
6738 counts,
6739 Applicability {
6740 applicable: 1,
6741 inapplicable: 0,
6742 unresolved: 0,
6743 }
6744 );
6745 }
6746
6747 #[test]
6751 fn operable_count_silently_drops_a_key_that_no_longer_resolves() {
6752 let dir = tempfile::tempdir().expect("temp dir");
6753 let root = root_of(&dir);
6754 let repo = root.join("repo");
6755 init_repo_with_a_commit(&repo);
6756
6757 let core = Core::start_discovered(spec(vec![root]));
6758 let real_key = core.snapshot().entities[0].key.clone();
6759 let unknown_key = EntityKey::new(Arc::from(dir.path().join("never-discovered")));
6760
6761 assert_eq!(core.operable_count(&[real_key, unknown_key]), 1);
6762 }
6763
6764 #[test]
6768 fn only_one_action_fan_out_runs_at_a_time_a_second_call_is_rejected_while_one_is_live() {
6769 let dir = tempfile::tempdir().expect("temp dir");
6770 let root = root_of(&dir);
6771 let repo = root.join("repo");
6772 init_repo_with_a_commit(&repo);
6773
6774 let core = Core::start_discovered(spec(vec![root]));
6775 let key = core.snapshot().entities[0].key.clone();
6776 let slow = action("first", vec![step(&["sh", "-c", "sleep 0.3"])]);
6777 let fast = action("second", vec![step(&["true"])]);
6778
6779 let first_started = core.run_action(slow, std::slice::from_ref(&key));
6780 let second_started = core.run_action(fast, std::slice::from_ref(&key));
6781
6782 assert!(first_started);
6783 assert!(
6784 !second_started,
6785 "a second run_action call must be rejected while the first is still in flight"
6786 );
6787 wait_for("the accepted first fan-out to finish", || {
6788 !core.action_running()
6789 });
6790 let receipt = core.snapshot().entities[0].last_action.clone().unwrap();
6791 assert_eq!(
6792 &*receipt.label, "first",
6793 "the surviving receipt must be the accepted first run's, never the rejected second"
6794 );
6795 }
6796
6797 #[test]
6806 fn a_refused_second_submission_leaves_the_first_action_still_stoppable() {
6807 let dir = tempfile::tempdir().expect("temp dir");
6808 let root = root_of(&dir);
6809 let repo = root.join("repo");
6810 init_repo_with_a_commit(&repo);
6811
6812 let core = Core::start_discovered(spec(vec![root]));
6813 let key = core.snapshot().entities[0].key.clone();
6814 let sleep_past_the_backstop = format!("sleep {}", FIXTURE_LIFETIME.as_secs());
6815 let live = action(
6816 "live",
6817 vec![
6818 step(&["sh", "-c", &sleep_past_the_backstop]),
6819 step(&["sh", "-c", &sleep_past_the_backstop]),
6820 ],
6821 );
6822
6823 assert!(core.run_action(live, std::slice::from_ref(&key)));
6824 wait_for("the live run's own first step to start", || {
6825 core.snapshot().entities[0]
6826 .last_action
6827 .as_ref()
6828 .is_some_and(|receipt| receipt.running.is_some())
6829 });
6830
6831 assert!(
6832 !core.run_action(
6833 action("refused", vec![step(&["true"])]),
6834 std::slice::from_ref(&key)
6835 ),
6836 "a second submission must be refused while one run is still live"
6837 );
6838
6839 core.stop_action();
6840
6841 wait_for("the still-controllable run to come down", || {
6842 !core.action_running()
6843 });
6844 let receipt = core.snapshot().entities[0].last_action.clone().unwrap();
6845 assert_eq!(&*receipt.label, "live");
6846 assert_eq!(
6847 receipt.steps[0].outcome,
6848 StepOutcome::Cancelled,
6849 "the refused submission must leave the live run's own control in place, so \
6850 stop_action still reaches the step it was running"
6851 );
6852 assert_eq!(
6853 receipt.steps[1].outcome,
6854 StepOutcome::Cancelled,
6855 "a step that had not started when the run was cancelled must read Cancelled too"
6856 );
6857 }
6858
6859 #[test]
6870 fn a_run_accepted_once_a_completion_releases_its_admission_is_still_stoppable() {
6871 let dir = tempfile::tempdir().expect("temp dir");
6872 let root = root_of(&dir);
6873 let repo = root.join("repo");
6874 init_repo_with_a_commit(&repo);
6875
6876 let core = Core::start_discovered(spec(vec![root]));
6877 let key = core.snapshot().entities[0].key.clone();
6878 let armed = core.action_completion_boundary().arm();
6879
6880 assert!(core.run_action(
6881 action("finishing", vec![step(&["true"])]),
6882 std::slice::from_ref(&key)
6883 ));
6884 armed.wait_until_reached();
6885 assert!(
6886 !core.run_action(
6887 action("early", vec![step(&["true"])]),
6888 std::slice::from_ref(&key)
6889 ),
6890 "a submission made before the completion releases its admission must be refused"
6891 );
6892 drop(armed);
6893 wait_for("the finished run to release its admission", || {
6894 !core.action_running()
6895 });
6896
6897 let sleep_past_the_backstop = format!("sleep {}", FIXTURE_LIFETIME.as_secs());
6898 let following = action(
6899 "following",
6900 vec![
6901 step(&["sh", "-c", &sleep_past_the_backstop]),
6902 step(&["sh", "-c", &sleep_past_the_backstop]),
6903 ],
6904 );
6905 assert!(
6906 core.run_action(following, std::slice::from_ref(&key)),
6907 "a submission made once that release has happened must be accepted"
6908 );
6909 wait_for("the following run's own first step to start", || {
6910 receipt_labelled(&core, &key, "following")
6911 .is_some_and(|receipt| receipt.running.is_some())
6912 });
6913
6914 core.stop_action();
6915
6916 wait_for("the cancelled run to come down", || !core.action_running());
6917 let receipt =
6918 receipt_labelled(&core, &key, "following").expect("the following run's receipt");
6919 assert_eq!(
6920 receipt.steps[0].outcome,
6921 StepOutcome::Cancelled,
6922 "the completion this run followed must leave stop_action still reaching it"
6923 );
6924 assert_eq!(
6925 receipt.steps[1].outcome,
6926 StepOutcome::Cancelled,
6927 "a cancelled run's remaining step must never start, so it reads Cancelled"
6928 );
6929 }
6930
6931 #[test]
6945 fn hold_action_genuinely_pauses_a_running_steps_progress_and_continue_action_resumes_it() {
6946 let dir = tempfile::tempdir().expect("temp dir");
6947 let root = root_of(&dir);
6948 let repo = root.join("repo");
6949 init_repo_with_a_commit(&repo);
6950
6951 let core = Core::start_discovered(spec(vec![root]));
6952 let key = core.snapshot().entities[0].key.clone();
6953 let two_seconds = action("brief", vec![step(&["sh", "-c", "sleep 2"])]);
6954
6955 assert!(core.run_action(two_seconds, std::slice::from_ref(&key)));
6956 wait_for("the two-second step to actually start running", || {
6957 core.snapshot().entities[0]
6958 .last_action
6959 .as_ref()
6960 .is_some_and(|receipt| receipt.running.is_some())
6961 });
6962
6963 for _ in 0..20 {
6971 core.hold_action();
6972 thread::sleep(Duration::from_millis(20));
6973 }
6974
6975 thread::sleep(Duration::from_millis(1_800));
6976 assert!(
6977 core.action_running(),
6978 "a genuinely held step must not have finished on its own well past its own 2s \
6979 sleep; a no-op hold_action would already show this false here"
6980 );
6981
6982 core.continue_action();
6983 wait_for("continue_action to let the held step finish", || {
6984 !core.action_running()
6985 });
6986 let receipt = core.snapshot().entities[0].last_action.clone().unwrap();
6987 assert_eq!(receipt.steps[0].outcome, StepOutcome::Ok);
6988 }
6989
6990 #[test]
6994 fn hold_continue_and_stop_action_are_no_ops_with_no_fan_out_running() {
6995 let dir = tempfile::tempdir().expect("temp dir");
6996 let root = root_of(&dir);
6997 let repo = root.join("repo");
6998 init_repo_with_a_commit(&repo);
6999
7000 let core = Core::start_discovered(spec(vec![root]));
7001
7002 core.hold_action();
7003 core.continue_action();
7004 core.stop_action();
7005
7006 assert!(!core.action_running());
7007 }
7008
7009 #[test]
7031 fn stop_action_escalates_from_sigterm_to_sigkill_against_a_trapping_step() {
7032 let dir = tempfile::tempdir().expect("temp dir");
7033 let root = root_of(&dir);
7034 let repo = root.join("repo");
7035 init_repo_with_a_commit(&repo);
7036
7037 let core = Core::start_discovered(spec(vec![root]));
7038 let key = core.snapshot().entities[0].key.clone();
7039 let sleep_past_the_backstop = format!("trap '' TERM; sleep {}", FIXTURE_LIFETIME.as_secs());
7040 let trapping = action(
7041 "trapping",
7042 vec![step(&["sh", "-c", &sleep_past_the_backstop])],
7043 );
7044
7045 assert!(core.run_action(trapping, std::slice::from_ref(&key)));
7046 wait_for("the trapping step to actually start running", || {
7047 core.snapshot().entities[0]
7048 .last_action
7049 .as_ref()
7050 .is_some_and(|receipt| receipt.running.is_some())
7051 });
7052 thread::sleep(Duration::from_millis(100));
7055
7056 core.stop_action();
7057
7058 wait_for(
7059 "a SIGTERM-trapping step to come down from the follow-up SIGKILL",
7060 || !core.action_running(),
7061 );
7062 let receipt = core.snapshot().entities[0].last_action.clone().unwrap();
7063 assert_eq!(receipt.steps.len(), 1);
7064 assert_eq!(
7065 receipt.steps[0].outcome,
7066 StepOutcome::Cancelled,
7067 "a step running when the run was cancelled must read Cancelled, never Failed"
7068 );
7069 }
7070
7071 #[test]
7086 fn cancelled_and_not_run_are_distinct_outcomes_shown_together_in_one_run() {
7087 let dir = tempfile::tempdir().expect("temp dir");
7088 let root = root_of(&dir);
7089 init_repo_with_a_commit(&root.join("fail"));
7090 init_repo_with_a_commit(&root.join("slow"));
7091
7092 let core = Core::start_discovered(spec(vec![root]));
7093 let snapshot = core.snapshot();
7094 let fail_key = snapshot
7095 .entities
7096 .iter()
7097 .find(|entity| &*entity.name == "fail")
7098 .expect("the fail entity is present")
7099 .key
7100 .clone();
7101 let slow_key = snapshot
7102 .entities
7103 .iter()
7104 .find(|entity| &*entity.name == "slow")
7105 .expect("the slow entity is present")
7106 .key
7107 .clone();
7108
7109 let branch_on_the_entity_name = format!(
7117 "case \"$(basename \"$PWD\")\" in fail) exit 1 ;; *) sleep {} ;; esac",
7118 FIXTURE_LIFETIME.as_secs()
7119 );
7120 let steps = vec![
7121 step(&["sh", "-c", &branch_on_the_entity_name]),
7122 step(&["true"]),
7123 ];
7124 let mut action_spec = action("mixed", steps);
7125 action_spec.concurrency = 2;
7126
7127 assert!(core.run_action(action_spec, &[fail_key.clone(), slow_key.clone()]));
7128
7129 wait_for(
7133 "`fail` finished and `slow` still running before cancelling",
7134 || {
7135 let snapshot = core.snapshot();
7136 let fail_done = snapshot
7137 .entities
7138 .iter()
7139 .find(|entity| entity.key == fail_key)
7140 .and_then(|entity| entity.last_action.as_ref())
7141 .is_some_and(|receipt| receipt.steps.len() == 2);
7142 let slow_running = snapshot
7143 .entities
7144 .iter()
7145 .find(|entity| entity.key == slow_key)
7146 .and_then(|entity| entity.last_action.as_ref())
7147 .is_some_and(|receipt| receipt.running.is_some());
7148 fail_done && slow_running
7149 },
7150 );
7151
7152 core.stop_action();
7153 wait_for("the fan-out to finish once cancelled", || {
7154 !core.action_running()
7155 });
7156
7157 let snapshot = core.snapshot();
7158 let fail_receipt = snapshot
7159 .entities
7160 .iter()
7161 .find(|entity| entity.key == fail_key)
7162 .and_then(|entity| entity.last_action.clone())
7163 .expect("fail's own receipt");
7164 assert_eq!(fail_receipt.steps[0].outcome, StepOutcome::Failed(1));
7165 assert_eq!(
7166 fail_receipt.steps[1].outcome,
7167 StepOutcome::NotRun,
7168 "blocked by fail's own earlier failure, not by the later cancellation"
7169 );
7170
7171 let slow_receipt = snapshot
7172 .entities
7173 .iter()
7174 .find(|entity| entity.key == slow_key)
7175 .and_then(|entity| entity.last_action.clone())
7176 .expect("slow's own receipt");
7177 assert_eq!(
7178 slow_receipt.steps[0].outcome,
7179 StepOutcome::Cancelled,
7180 "a step running when the run was cancelled must read Cancelled"
7181 );
7182 assert_eq!(
7183 slow_receipt.steps[1].outcome,
7184 StepOutcome::Cancelled,
7185 "a step that had not started when the run was cancelled must also read \
7186 Cancelled, never NotRun, which stays reserved for an earlier failure"
7187 );
7188 }
7189
7190 #[test]
7198 fn a_panicking_fan_out_still_resets_action_running_so_a_later_action_can_start() {
7199 let dir = tempfile::tempdir().expect("temp dir");
7200 let root = root_of(&dir);
7201 let repo = root.join("repo");
7202 init_repo_with_a_commit(&repo);
7203
7204 let (core, launched) = started_and_settled(spec(vec![root]));
7208 let key = launched.entities[0].key.clone();
7209
7210 let started = core.run_action(
7217 action("boom", vec![step(&["sh", "-c", "sleep 0.3"])]),
7218 std::slice::from_ref(&key),
7219 );
7220 assert!(started);
7221
7222 let table = Arc::clone(&core.table);
7223 thread::spawn(move || {
7224 let _guard = table.write().unwrap();
7225 panic!("deliberately poison the table lock for this test");
7226 })
7227 .join()
7228 .expect_err("the poisoning thread must itself panic to poison the lock");
7229
7230 wait_for(
7235 "a panicking fan-out to end its run rather than leave it reading as live",
7236 || !core.action_running(),
7237 );
7238
7239 core.table.clear_poison();
7244
7245 let second_started = core.run_action(
7246 action("second", vec![step(&["true"])]),
7247 std::slice::from_ref(&key),
7248 );
7249 assert!(
7250 second_started,
7251 "a later Action must be able to start once the panicking one has finished"
7252 );
7253 wait_for("the second Action to run to completion", || {
7254 core.snapshot()
7255 .entities
7256 .iter()
7257 .find(|entity| entity.key == key)
7258 .and_then(|entity| entity.last_action.as_ref())
7259 .is_some_and(|receipt| &*receipt.label == "second")
7260 });
7261 }
7262
7263 fn assert_vanished_with_stale_branch(entity: &EntityState, expected_branch: &str) {
7269 assert_eq!(entity.presence, crate::entity::Presence::Vanished);
7270 match entity.branch.settled() {
7271 Some(Settled::Known {
7272 value: Head::Branch { name, .. },
7273 stale: true,
7274 at: _,
7275 }) => assert_eq!(
7276 &**name, expected_branch,
7277 "a Vanished entity must keep its last known branch value"
7278 ),
7279 other => panic!(
7280 "expected the branch cell to keep its Known value and go stale, got {other:?}"
7281 ),
7282 }
7283 }
7284
7285 #[test]
7291 fn a_repo_removed_from_disk_stays_in_the_table_vanished_with_its_last_values() {
7292 let dir = tempfile::tempdir().expect("temp dir");
7293 let root = root_of(&dir);
7294 let repo = root.join("repo");
7295 init_repo_with_a_commit(&repo);
7296
7297 let core = Core::start_discovered(spec(vec![root]));
7298 let key = core.snapshot().entities[0].key.clone();
7299 core.refresh(std::slice::from_ref(&key));
7300 let before = core.settle();
7301 let branch_name = match before.entities[0].branch.settled() {
7302 Some(Settled::Known {
7303 value: Head::Branch { name, .. },
7304 at: _,
7305 stale: _,
7306 }) => name.to_string(),
7307 other => panic!("expected the first refresh to settle a branch, got {other:?}"),
7308 };
7309
7310 fs::remove_dir_all(&repo).expect("remove the repo from disk");
7311
7312 core.refresh(&[]);
7313 let after = core.settle();
7314
7315 assert_eq!(
7316 after.entities.len(),
7317 1,
7318 "a vanished entity must stay in the snapshot, not disappear from it"
7319 );
7320 assert_vanished_with_stale_branch(&after.entities[0], &branch_name);
7321 }
7322
7323 #[test]
7327 fn a_vanished_entitys_action_receipt_survives_the_vanished_staleness_pass_untouched() {
7328 let dir = tempfile::tempdir().expect("temp dir");
7329 let root = root_of(&dir);
7330 let repo = root.join("repo");
7331 init_repo_with_a_commit(&repo);
7332
7333 let core = Core::start_discovered(spec(vec![root]));
7334 let key = core.snapshot().entities[0].key.clone();
7335 let receipt = crate::entity::ActionReceipt {
7336 label: Arc::from("reinstall"),
7337 steps: Arc::from(vec![crate::entity::StepResult {
7338 label: Arc::from("pnpm install"),
7339 outcome: crate::entity::StepOutcome::Ok,
7340 output: Arc::from(&b""[..]),
7341 elapsed: Duration::from_millis(1),
7342 elision: None,
7343 shell: false,
7344 interactive: false,
7345 }]),
7346 skip: None,
7347 finished_at: Timestamp::now(),
7348 running: None,
7349 };
7350 core.set_last_action_for_test(&key, receipt.clone());
7351
7352 fs::remove_dir_all(&repo).expect("remove the repo from disk");
7353 core.refresh(&[]);
7354 let after = core.settle();
7355
7356 let entity = &after.entities[0];
7357 assert_eq!(entity.presence, crate::entity::Presence::Vanished);
7358 assert_eq!(entity.last_action, Some(receipt));
7359 }
7360
7361 #[test]
7369 fn two_snapshots_of_an_entity_share_its_last_actions_label_and_steps_by_pointer() {
7370 let dir = tempfile::tempdir().expect("temp dir");
7371 let root = root_of(&dir);
7372 let repo = root.join("repo");
7373 init_repo_with_a_commit(&repo);
7374
7375 let core = Core::start_discovered(spec(vec![root]));
7376 let key = core.snapshot().entities[0].key.clone();
7377 let receipt = crate::entity::ActionReceipt {
7378 label: Arc::from("reinstall"),
7379 steps: Arc::from(vec![crate::entity::StepResult {
7380 label: Arc::from("pnpm install"),
7381 outcome: crate::entity::StepOutcome::Failed(1),
7382 output: Arc::from(&b""[..]),
7383 elapsed: Duration::from_millis(1),
7384 elision: None,
7385 shell: false,
7386 interactive: false,
7387 }]),
7388 skip: None,
7389 finished_at: Timestamp::now(),
7390 running: None,
7391 };
7392 core.set_last_action_for_test(&key, receipt);
7393
7394 let first = core.snapshot();
7395 let second = core.snapshot();
7396 let first_receipt = first.entities[0]
7397 .last_action
7398 .as_ref()
7399 .expect("receipt was set");
7400 let second_receipt = second.entities[0]
7401 .last_action
7402 .as_ref()
7403 .expect("receipt was set");
7404
7405 assert!(
7406 Arc::ptr_eq(&first_receipt.label, &second_receipt.label),
7407 "two snapshots of the same receipt must share the label's allocation, not \
7408 re-copy it"
7409 );
7410 assert!(
7411 Arc::ptr_eq(&first_receipt.steps, &second_receipt.steps),
7412 "two snapshots of the same receipt must share the steps slice's allocation, not \
7413 re-copy it, which is also what shares every step's own captured output"
7414 );
7415 }
7416
7417 #[test]
7423 fn a_submodule_removed_from_gitmodules_vanishes_by_the_same_rule_as_a_repo() {
7424 let dir = tempfile::tempdir().expect("temp dir");
7425 let root = root_of(&dir);
7426 let parent = root.join("parent");
7427 init_repo_with_a_commit(&parent);
7428 fs::write(
7429 parent.join(".gitmodules"),
7430 "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
7431 )
7432 .expect("write .gitmodules");
7433 let submodule_path = parent.join("vendor").join("lib");
7434 init_repo_with_a_commit(&submodule_path);
7435
7436 let mut core_spec = spec(vec![root]);
7439 core_spec.show_submodules = true;
7440 let core = Core::start_discovered(core_spec);
7441 let snapshot = core.snapshot();
7442 let submodule_key = snapshot
7443 .entities
7444 .iter()
7445 .find(|entity| matches!(entity.kind, Kind::Submodule))
7446 .expect("submodule discovered")
7447 .key
7448 .clone();
7449 core.refresh(std::slice::from_ref(&submodule_key));
7450 let before = core.settle();
7451 let submodule_before = before
7452 .entities
7453 .iter()
7454 .find(|entity| entity.key == submodule_key)
7455 .expect("submodule present");
7456 let branch_name = match submodule_before.branch.settled() {
7457 Some(Settled::Known {
7458 value: Head::Branch { name, .. },
7459 at: _,
7460 stale: _,
7461 }) => name.to_string(),
7462 other => {
7463 panic!("expected the submodule's first refresh to settle a branch, got {other:?}")
7464 }
7465 };
7466
7467 fs::write(parent.join(".gitmodules"), "").expect("clear .gitmodules");
7471
7472 core.refresh(&[]);
7473 let after = core.settle();
7474
7475 let submodule_after = after
7476 .entities
7477 .iter()
7478 .find(|entity| entity.key == submodule_key)
7479 .expect("the vanished submodule must stay in the snapshot");
7480 assert_vanished_with_stale_branch(submodule_after, &branch_name);
7481 }
7482
7483 #[test]
7488 fn dismissal_persists_nothing_across_a_fresh_core() {
7489 let dir = tempfile::tempdir().expect("temp dir");
7490 let root = root_of(&dir);
7491 let repo = root.join("repo");
7492 init_repo_with_a_commit(&repo);
7493
7494 let first_core = Core::start_discovered(spec(vec![root.clone()]));
7495 let key = first_core.snapshot().entities[0].key.clone();
7496 first_core.dismiss(&key);
7497 assert!(first_core.snapshot().entities.is_empty());
7498 drop(first_core);
7499
7500 let second_core = Core::start_discovered(spec(vec![root]));
7501 let snapshot = second_core.snapshot();
7502
7503 assert_eq!(
7504 snapshot.entities.len(),
7505 1,
7506 "a fresh Core must discover the repo again"
7507 );
7508 assert_eq!(
7509 snapshot.entities[0].presence,
7510 crate::entity::Presence::Present,
7511 "nothing from the dismissing Core's lifetime may be persisted, so the \
7512 repo must come back Present, never restored as Vanished"
7513 );
7514 }
7515
7516 #[test]
7520 fn a_repo_that_moves_reads_as_vanished_plus_new() {
7521 let dir = tempfile::tempdir().expect("temp dir");
7522 let root = root_of(&dir);
7523 let original_path = root.join("original-name");
7524 init_repo_with_a_commit(&original_path);
7525
7526 let core = Core::start_discovered(spec(vec![root.clone()]));
7527 let original_key = core.snapshot().entities[0].key.clone();
7528 core.refresh(std::slice::from_ref(&original_key));
7529 let before = core.settle();
7530 let branch_name = match before.entities[0].branch.settled() {
7531 Some(Settled::Known {
7532 value: Head::Branch { name, .. },
7533 at: _,
7534 stale: _,
7535 }) => name.to_string(),
7536 other => panic!("expected the first refresh to settle a branch, got {other:?}"),
7537 };
7538
7539 let moved_path = root.join("new-name");
7540 fs::rename(&original_path, &moved_path).expect("move the repo on disk");
7541
7542 core.refresh(&[]);
7543 let after = core.settle();
7544
7545 assert_eq!(
7546 after.entities.len(),
7547 2,
7548 "a moved entity must read as the old key vanished plus a new one present, \
7549 never as one renamed entity"
7550 );
7551 let old_entity = after
7552 .entities
7553 .iter()
7554 .find(|entity| entity.key == original_key)
7555 .expect("the old key must stay in the table");
7556 assert_vanished_with_stale_branch(old_entity, &branch_name);
7557 let new_entity = after
7558 .entities
7559 .iter()
7560 .find(|entity| entity.key != original_key)
7561 .expect("a new entity at the moved path must be present");
7562 assert_eq!(new_entity.presence, crate::entity::Presence::Present);
7563 assert_eq!(new_entity.key.path(), moved_path);
7564 }
7565
7566 #[test]
7570 fn a_vanished_repo_recreated_on_disk_reads_present_on_the_next_refresh() {
7571 let dir = tempfile::tempdir().expect("temp dir");
7572 let root = root_of(&dir);
7573 let repo = root.join("repo");
7574 init_repo_with_a_commit(&repo);
7575
7576 let core = Core::start_discovered(spec(vec![root]));
7577 let key = core.snapshot().entities[0].key.clone();
7578
7579 fs::remove_dir_all(&repo).expect("remove the repo from disk");
7580 core.refresh(&[]);
7581 let vanished = core.settle();
7582 assert_eq!(
7583 vanished.entities[0].presence,
7584 crate::entity::Presence::Vanished,
7585 "the repo must read Vanished once removed from disk"
7586 );
7587
7588 init_repo_with_a_commit(&repo);
7589 core.refresh(&[]);
7590 let recreated = core.settle();
7591
7592 let entity = recreated
7593 .entities
7594 .iter()
7595 .find(|entity| entity.key == key)
7596 .expect("the recreated repo must still resolve to the same entity key");
7597 assert_eq!(
7598 entity.presence,
7599 crate::entity::Presence::Present,
7600 "an entity discovery finds again after it vanished must read Present, \
7601 not stay stuck Vanished forever"
7602 );
7603 }
7604
7605 #[test]
7610 fn a_new_repo_created_after_start_is_discovered_by_the_next_refresh() {
7611 let dir = tempfile::tempdir().expect("temp dir");
7612 let root = root_of(&dir);
7613 init_repo_with_a_commit(&root.join("first"));
7614
7615 let core = Core::start_discovered(spec(vec![root.clone()]));
7616 assert_eq!(core.snapshot().entities.len(), 1);
7617
7618 init_repo_with_a_commit(&root.join("second"));
7619 core.refresh(&[]);
7620 let after = core.settle();
7621
7622 assert_eq!(
7623 after.entities.len(),
7624 2,
7625 "a new repo created after start must be found by the next refresh's own discovery"
7626 );
7627
7628 let new_key = after
7631 .entities
7632 .iter()
7633 .find(|entity| &*entity.name == "second")
7634 .expect("the newly discovered repo must be named by the walk")
7635 .key
7636 .clone();
7637 core.refresh(std::slice::from_ref(&new_key));
7638 let probed = core.settle();
7639 let new_entity = probed
7640 .entities
7641 .iter()
7642 .find(|entity| entity.key == new_key)
7643 .expect("the newly discovered repo must still be present");
7644 assert!(
7645 matches!(
7646 new_entity.branch.settled(),
7647 Some(Settled::Known {
7648 value: _,
7649 at: _,
7650 stale: _
7651 })
7652 ),
7653 "a refresh naming the newly discovered repo's key must actually probe \
7654 it and settle its branch cell, got {:?}",
7655 new_entity.branch.settled()
7656 );
7657 }
7658
7659 #[test]
7664 fn an_abandoned_discovery_stops_riding_later_refreshes() {
7665 let dir = tempfile::tempdir().expect("temp dir");
7666 let root = root_of(&dir);
7667 let decoys = root.join("decoys");
7674 for i in 0..4_000 {
7675 fs::create_dir(decoys.join(format!("decoy-{i}")))
7676 .or_else(|_| fs::create_dir_all(decoys.join(format!("decoy-{i}"))))
7677 .expect("create decoy dir");
7678 }
7679 let (_tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
7680
7681 let started = Core::start_for_test_with_discovery_abandon(
7682 spec(vec![root.clone()]),
7683 Duration::from_secs(3600),
7684 Duration::from_micros(500),
7685 tick_rx,
7686 )
7687 .discovered();
7688 let core = started.core;
7689 assert!(
7690 core.discovery_manual_for_test(),
7691 "walking 4,000 decoy directories against a 500 microsecond deadline \
7692 must have abandoned and taken the Set manual"
7693 );
7694
7695 fs::remove_dir_all(&decoys).expect("remove decoy directories");
7700 init_repo_with_a_commit(&root.join("second"));
7701
7702 core.refresh(&[]);
7703 let after = core.settle();
7704
7705 assert!(
7706 !after
7707 .entities
7708 .iter()
7709 .any(|entity| &*entity.name == "second"),
7710 "once discovery has abandoned, a later refresh must not re-run it, so a \
7711 repo created afterward, on a tree that would now resolve quickly, \
7712 must still never appear"
7713 );
7714 }
7715
7716 #[test]
7725 fn a_refresh_triggered_discovery_abandon_sets_manual_and_warns() {
7726 let dir = tempfile::tempdir().expect("temp dir");
7727 let root = root_of(&dir);
7728 init_repo_with_a_commit(&root.join("first"));
7729 let (_tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
7730
7731 let started = Core::start_for_test_with_discovery_abandon(
7737 spec(vec![root.clone()]),
7738 Duration::from_secs(3600),
7739 Duration::from_secs(3600),
7740 tick_rx,
7741 )
7742 .discovered();
7743 let core = started.core;
7744 assert!(
7745 !core.discovery_manual_for_test(),
7746 "an hour-long deadline must leave the first walk automatic"
7747 );
7748
7749 let decoys = root.join("decoys");
7753 for i in 0..4_000 {
7754 fs::create_dir(decoys.join(format!("decoy-{i}")))
7755 .or_else(|_| fs::create_dir_all(decoys.join(format!("decoy-{i}"))))
7756 .expect("create decoy dir");
7757 }
7758 core.set_discovery_abandon_after_for_test(Duration::from_micros(500));
7759
7760 core.refresh(&[]);
7761 core.wait_dispatched_for_test();
7764
7765 assert!(
7766 core.discovery_manual_for_test(),
7767 "refresh's own rerun_discovery must abandon against the newly-grown \
7768 tree and take the Set manual, the same as an abandon at start does"
7769 );
7770 let warning = core.discovery_warning();
7771 assert!(
7772 warning
7773 .as_deref()
7774 .is_some_and(|message| message.starts_with("discovery: stopped at")),
7775 "refresh's rerun_discovery must leave the abandoned-discovery warning \
7776 behind, not merely flip the manual flag: got {warning:?}"
7777 );
7778 }
7779
7780 #[test]
7786 fn a_fresh_core_over_different_roots_is_unaffected_by_another_cores_abandoned_discovery() {
7787 let abandoned_dir = tempfile::tempdir().expect("temp dir");
7788 let abandoned_root = root_of(&abandoned_dir);
7789 init_repo_with_a_commit(&abandoned_root.join("first"));
7790 let (_tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
7791 let started = Core::start_for_test_with_discovery_abandon(
7792 spec(vec![abandoned_root]),
7793 Duration::from_secs(3600),
7794 Duration::ZERO,
7795 tick_rx,
7796 )
7797 .discovered();
7798 started.core.refresh(&[]);
7799 started.core.settle();
7800 assert!(
7801 started.core.discovery_manual_for_test(),
7802 "the zero-length abandon deadline must have already taken this Core manual"
7803 );
7804 drop(started.core);
7805
7806 let fresh_dir = tempfile::tempdir().expect("temp dir");
7807 let fresh_root = root_of(&fresh_dir);
7808 init_repo_with_a_commit(&fresh_root.join("first"));
7809 let fresh_core = Core::start_discovered(spec(vec![fresh_root.clone()]));
7810 assert_eq!(fresh_core.snapshot().entities.len(), 1);
7811
7812 init_repo_with_a_commit(&fresh_root.join("second"));
7813 fresh_core.refresh(&[]);
7814 let after = fresh_core.settle();
7815
7816 assert_eq!(
7817 after.entities.len(),
7818 2,
7819 "a fresh Core, standing in for the Set's roots changing, must discover \
7820 normally regardless of an earlier, unrelated Core having gone manual"
7821 );
7822 }
7823
7824 #[test]
7829 fn dropping_the_core_joins_the_dedicated_thread_before_returning() {
7830 let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
7831 let dir = tempfile::tempdir().expect("temp dir");
7832 let root = root_of(&dir);
7833
7834 let started =
7835 Core::start_for_test(spec(vec![root]), Duration::from_secs(3600), tick_rx).discovered();
7836 assert!(started.clock_alive.load(Ordering::Acquire));
7837
7838 drop(started.core);
7839
7840 assert!(
7841 !started.clock_alive.load(Ordering::Acquire),
7842 "the dedicated thread should have exited, and cleared this flag, before drop returned"
7843 );
7844 drop(tick_tx);
7845 }
7846
7847 #[test]
7852 fn the_deadline_sweep_runs_only_when_a_tick_arrives() {
7853 let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
7854 let dir = tempfile::tempdir().expect("temp dir");
7855 let root = root_of(&dir);
7856 let repo = root.join("repo");
7857 init_repo_with_a_commit(&repo);
7858
7859 let mut spec = spec(vec![root]);
7860 spec.generation_deadline = Duration::ZERO;
7861 let started = Core::start_for_test(spec, Duration::from_secs(3600), tick_rx).discovered();
7862 let core = started.core;
7863 let key = settle_launch(&core).entities[0].key.clone();
7866
7867 core.begin_untracked_probe_for_test(&key);
7868
7869 let before = core.snapshot();
7872 assert!(
7873 matches!(
7874 before.entities[0].branch.settled(),
7875 Some(Settled::Known {
7876 value: _,
7877 at: _,
7878 stale: _
7879 })
7880 ),
7881 "the cell still holds launch's own answer here, so the Unknown below is the \
7882 sweep's write rather than a cell that was already empty"
7883 );
7884 assert!(before.entities[0].branch.is_in_flight());
7885
7886 tick_tx.send(Instant::now()).expect("send one tick");
7887 let after = core.settle();
7888
7889 assert!(matches!(
7890 after.entities[0].branch.settled(),
7891 Some(Settled::Unknown(Unknown::TimedOut))
7892 ));
7893 }
7894
7895 #[test]
7904 fn a_real_tick_through_the_dedicated_thread_reaches_the_poll_sweep_and_reprobes_a_moved_entity()
7905 {
7906 let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
7907 let dir = tempfile::tempdir().expect("temp dir");
7908 let root = root_of(&dir);
7909 let repo = root.join("repo");
7910 init_repo_with_a_commit(&repo);
7911
7912 let started =
7913 Core::start_for_test(spec(vec![root]), Duration::from_secs(3600), tick_rx).discovered();
7914 let core = started.core;
7915 let key = core.snapshot().entities[0].key.clone();
7916
7917 backdate_polled_entries(&repo);
7918
7919 tick_tx
7922 .send(Instant::now())
7923 .expect("send the baseline tick");
7924 wait_for(
7925 "a tick sent on the real channel to reach the poll sweep",
7926 || core.poll_sweep_count_for_test() >= 1,
7927 );
7928 assert!(core.poll_reprobed_for_test().is_empty());
7929
7930 commit_a_change(&repo, "second");
7931
7932 tick_tx
7933 .send(Instant::now())
7934 .expect("send the movement tick");
7935 wait_for(
7936 "the real tick channel to reach the poll sweep and reprobe the moved entity",
7937 || core.poll_reprobed_for_test() == vec![key.clone()],
7938 );
7939 drop(tick_tx);
7940 }
7941
7942 #[test]
7950 fn poll_reprobe_touches_only_the_moved_entity_and_never_runs_a_status_probe() {
7951 let dir = tempfile::tempdir().expect("temp dir");
7952 let root = root_of(&dir);
7953 let repo_a = root.join("repo-a");
7954 let repo_b = root.join("repo-b");
7955 init_repo_with_a_commit(&repo_a);
7956 init_repo_with_a_commit(&repo_b);
7957
7958 let core = Core::start_discovered(spec(vec![root]));
7959 let snapshot = core.snapshot();
7960 let key_a = snapshot
7961 .entities
7962 .iter()
7963 .find(|entity| entity.key.path() == repo_a)
7964 .expect("repo-a discovered")
7965 .key
7966 .clone();
7967 let key_b = snapshot
7968 .entities
7969 .iter()
7970 .find(|entity| entity.key.path() == repo_b)
7971 .expect("repo-b discovered")
7972 .key
7973 .clone();
7974
7975 core.refresh(&[key_a.clone(), key_b.clone()]);
7976 let landed = core.settle();
7977 let entity_of = |snapshot: &Snapshot, key: &EntityKey| {
7978 snapshot
7979 .entities
7980 .iter()
7981 .find(|entity| &entity.key == key)
7982 .expect("entity present")
7983 .clone()
7984 };
7985 let a_before = entity_of(&landed, &key_a);
7986 let b_before = entity_of(&landed, &key_b);
7987 let branch_at = |entity: &EntityState| match entity.branch.settled() {
7988 Some(Settled::Known {
7989 at,
7990 value: _,
7991 stale: _,
7992 }) => *at,
7993 other => panic!("expected a landed branch, got {other:?}"),
7994 };
7995 let dirty_state = |entity: &EntityState| match entity.dirty.settled() {
7996 Some(Settled::Known { value, at, stale }) => (*value, *at, *stale),
7997 other => panic!("expected a landed dirty count, got {other:?}"),
7998 };
7999 let (a_dirty_value_before, a_dirty_at_before, a_dirty_stale_before) =
8000 dirty_state(&a_before);
8001 assert!(
8002 !a_dirty_stale_before,
8003 "the fresh refresh must land dirty as not stale"
8004 );
8005
8006 backdate_polled_entries(&repo_a);
8007
8008 backdate_polled_entries(&repo_b);
8009
8010 core.poll_once_for_test();
8011 assert!(
8012 core.poll_reprobed_for_test().is_empty(),
8013 "a first sweep has nothing to compare against, so it must report no movement"
8014 );
8015
8016 commit_a_change(&repo_a, "second");
8017 core.poll_once_for_test();
8018
8019 assert_eq!(
8020 core.poll_reprobed_for_test(),
8021 vec![key_a.clone()],
8022 "only the entity whose gitdir actually moved must be re-probed"
8023 );
8024
8025 let after = core.snapshot();
8026 let a_after = entity_of(&after, &key_a);
8027 let b_after = entity_of(&after, &key_b);
8028
8029 assert_ne!(
8030 branch_at(&a_after),
8031 branch_at(&a_before),
8032 "the moved entity's branch must carry a fresh timestamp from the re-probe"
8033 );
8034 let (a_dirty_value_after, a_dirty_at_after, a_dirty_stale_after) = dirty_state(&a_after);
8035 assert_eq!(
8036 a_dirty_value_after, a_dirty_value_before,
8037 "no status probe ran, so dirty's value must be exactly what the last real refresh \
8038 landed"
8039 );
8040 assert_eq!(
8041 a_dirty_at_after, a_dirty_at_before,
8042 "no status probe ran, so dirty's timestamp must be untouched, only its stale flag \
8043 set"
8044 );
8045 assert!(
8046 a_dirty_stale_after,
8047 "the moved entity's dirty cell must go stale on poll evidence"
8048 );
8049
8050 assert_eq!(
8051 branch_at(&b_after),
8052 branch_at(&b_before),
8053 "the untouched entity's branch must be exactly as the prior refresh left it"
8054 );
8055 let (b_dirty_value_after, b_dirty_at_after, b_dirty_stale_after) = dirty_state(&b_after);
8056 let (b_dirty_value_before, b_dirty_at_before, b_dirty_stale_before) =
8057 dirty_state(&b_before);
8058 assert_eq!(b_dirty_value_after, b_dirty_value_before);
8059 assert_eq!(b_dirty_at_after, b_dirty_at_before);
8060 assert_eq!(
8061 b_dirty_stale_after, b_dirty_stale_before,
8062 "an entity the sweep found unmoved must never go stale"
8063 );
8064 }
8065
8066 #[test]
8071 fn poll_detects_an_attached_commit_through_index_while_head_itself_never_moves() {
8072 let dir = tempfile::tempdir().expect("temp dir");
8073 let root = root_of(&dir);
8074 let repo = root.join("repo");
8075 init_repo_with_a_commit(&repo);
8076
8077 let core = Core::start_discovered(spec(vec![root]));
8078 let key = core.snapshot().entities[0].key.clone();
8079 backdate_polled_entries(&repo);
8080 core.poll_once_for_test();
8081 assert!(core.poll_reprobed_for_test().is_empty());
8082
8083 let head_path = repo.join(".git").join("HEAD");
8084 let head_mtime_before = fs::metadata(&head_path)
8085 .expect("stat HEAD")
8086 .modified()
8087 .expect("HEAD mtime");
8088
8089 commit_a_change(&repo, "second");
8090
8091 let head_mtime_after = fs::metadata(&head_path)
8092 .expect("stat HEAD")
8093 .modified()
8094 .expect("HEAD mtime");
8095 assert_eq!(
8096 head_mtime_before, head_mtime_after,
8097 "a commit on an attached HEAD must never touch HEAD itself"
8098 );
8099
8100 core.poll_once_for_test();
8101 assert_eq!(
8102 core.poll_reprobed_for_test(),
8103 vec![key],
8104 "the poll must still detect the attached commit, through index rather than HEAD"
8105 );
8106 }
8107
8108 #[test]
8115 fn poll_detects_a_detached_commit_through_the_per_worktree_head_file() {
8116 let dir = tempfile::tempdir().expect("temp dir");
8117 let root = root_of(&dir);
8118 let parent = root.join("parent");
8119 init_repo_with_a_commit(&parent);
8120 let worktree_path = root.join("detached-worktree");
8121 let status = Command::new("git")
8122 .arg("-C")
8123 .arg(&parent)
8124 .args([
8125 "worktree",
8126 "add",
8127 "--detach",
8128 worktree_path.to_str().expect("utf8 path"),
8129 ])
8130 .status()
8131 .expect("run git worktree add");
8132 assert!(status.success());
8133
8134 let core = Core::start_discovered(spec(vec![root]));
8135 let snapshot = core.snapshot();
8136 let worktree_key = snapshot
8137 .entities
8138 .iter()
8139 .find(|entity| matches!(entity.kind, Kind::Worktree))
8140 .expect("worktree discovered")
8141 .key
8142 .clone();
8143
8144 backdate_polled_entries(&parent);
8145 backdate_polled_entries(&worktree_path);
8146
8147 core.poll_once_for_test();
8148 assert!(core.poll_reprobed_for_test().is_empty());
8149
8150 let worktree_head_path = parent
8151 .join(".git")
8152 .join("worktrees")
8153 .join("detached-worktree")
8154 .join("HEAD");
8155 let head_mtime_before = fs::metadata(&worktree_head_path)
8156 .expect("stat the per-worktree HEAD")
8157 .modified()
8158 .expect("HEAD mtime");
8159
8160 commit_a_change(&worktree_path, "on the detached worktree");
8161
8162 let head_mtime_after = fs::metadata(&worktree_head_path)
8163 .expect("stat the per-worktree HEAD")
8164 .modified()
8165 .expect("HEAD mtime");
8166 assert_ne!(
8167 head_mtime_before, head_mtime_after,
8168 "a commit on a detached HEAD must write the new object id straight into its own \
8169 HEAD file"
8170 );
8171
8172 core.poll_once_for_test();
8173 assert_eq!(
8174 core.poll_reprobed_for_test(),
8175 vec![worktree_key],
8176 "the poll must detect the detached commit via the per-worktree HEAD file"
8177 );
8178 }
8179
8180 #[test]
8187 fn snapshot_ages_a_freshly_landed_dirty_cell_stale_once_status_stale_after_has_elapsed() {
8188 let dir = tempfile::tempdir().expect("temp dir");
8189 let root = root_of(&dir);
8190 let repo = root.join("repo");
8191 init_repo_with_a_commit(&repo);
8192
8193 let mut short_lived = spec(vec![root]);
8194 short_lived.status_stale_after = Duration::from_nanos(1);
8195 let core = Core::start_discovered(short_lived);
8196 let key = core.snapshot().entities[0].key.clone();
8197 core.refresh(std::slice::from_ref(&key));
8198 core.settle();
8199
8200 let aged = core.snapshot();
8201 match aged.entities[0].dirty.settled() {
8202 Some(Settled::Known {
8203 stale: true,
8204 value: _,
8205 at: _,
8206 }) => {}
8207 other => panic!(
8208 "expected a landed dirty cell to have already aged past a one-nanosecond \
8209 threshold, got {other:?}"
8210 ),
8211 }
8212 }
8213
8214 #[test]
8218 fn snapshot_leaves_a_freshly_landed_dirty_cell_fresh_under_a_large_status_stale_after() {
8219 let dir = tempfile::tempdir().expect("temp dir");
8220 let root = root_of(&dir);
8221 let repo = root.join("repo");
8222 init_repo_with_a_commit(&repo);
8223
8224 let core = Core::start_discovered(spec(vec![root]));
8225 let key = core.snapshot().entities[0].key.clone();
8226 core.refresh(std::slice::from_ref(&key));
8227 core.settle();
8228
8229 let fresh = core.snapshot();
8230 match fresh.entities[0].dirty.settled() {
8231 Some(Settled::Known {
8232 stale: false,
8233 value: _,
8234 at: _,
8235 }) => {}
8236 other => panic!("expected a freshly landed dirty cell to stay fresh, got {other:?}"),
8237 }
8238 }
8239
8240 #[test]
8246 fn hidden_submodules_are_never_polled_but_shown_ones_are() {
8247 let dir = tempfile::tempdir().expect("temp dir");
8248 let root = root_of(&dir);
8249 let parent = root.join("parent");
8250 init_repo_with_a_commit(&parent);
8251 fs::write(
8252 parent.join(".gitmodules"),
8253 "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
8254 )
8255 .expect("write .gitmodules");
8256 let submodule_path = parent.join("vendor").join("lib");
8257 init_repo_with_a_commit(&submodule_path);
8258
8259 let mut hidden_spec = spec(vec![root.clone()]);
8260 hidden_spec.show_submodules = false;
8261 let hidden_core = Core::start_discovered(hidden_spec);
8262 let hidden_submodule_key = hidden_core
8267 .snapshot()
8268 .entities
8269 .iter()
8270 .find(|entity| matches!(entity.kind, Kind::Submodule))
8271 .expect("the submodule is discovered regardless of show_submodules")
8272 .key
8273 .clone();
8274 backdate_polled_entries(&submodule_path);
8275 hidden_core.poll_once_for_test();
8276 commit_a_change(&submodule_path, "into the hidden submodule");
8277 hidden_core.poll_once_for_test();
8278 assert!(
8279 !hidden_core
8280 .poll_reprobed_for_test()
8281 .contains(&hidden_submodule_key),
8282 "a hidden Submodule must never be re-probed by the poll, since it was never \
8283 polled at all"
8284 );
8285 drop(hidden_core);
8286
8287 let mut shown_spec = spec(vec![root]);
8288 shown_spec.show_submodules = true;
8289 let shown_core = Core::start_discovered(shown_spec);
8290 let submodule_key = shown_core
8291 .snapshot()
8292 .entities
8293 .iter()
8294 .find(|entity| matches!(entity.kind, Kind::Submodule))
8295 .expect("the submodule is discovered regardless of show_submodules")
8296 .key
8297 .clone();
8298 backdate_polled_entries(&submodule_path);
8299 shown_core.poll_once_for_test();
8300 commit_a_change(&submodule_path, "into the shown submodule");
8301 shown_core.poll_once_for_test();
8302 assert_eq!(
8303 shown_core.poll_reprobed_for_test(),
8304 vec![submodule_key],
8305 "a shown Submodule must be polled and re-probed exactly like any other row"
8306 );
8307 }
8308
8309 #[test]
8315 fn pause_cancels_every_in_flight_entity_and_releases_a_pending_settle() {
8316 let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
8317 let dir = tempfile::tempdir().expect("temp dir");
8318 let root = root_of(&dir);
8319 let repo = root.join("repo");
8320 init_repo_with_a_commit(&repo);
8321
8322 let started =
8323 Core::start_for_test(spec(vec![root]), Duration::from_secs(3600), tick_rx).discovered();
8324 let core = started.core;
8325 let key = settle_launch(&core).entities[0].key.clone();
8327 let cancel = core.begin_untracked_probe_for_test(&key);
8328 assert!(!cancel.load(Ordering::Acquire));
8329
8330 core.pause();
8331 let settled = core.settle();
8332
8333 assert!(
8334 cancel.load(Ordering::Acquire),
8335 "pause should cancel the entity that was in flight"
8336 );
8337 assert!(settled.entities[0].branch.is_in_flight());
8338 drop(tick_tx);
8339 }
8340
8341 #[test]
8350 fn a_launch_is_one_generation_over_every_row_its_own_walk_found() {
8351 let dir = tempfile::tempdir().expect("temp dir");
8352 let root = root_of(&dir);
8353 init_repo_with_a_commit(&root.join("first"));
8354 init_repo_with_a_commit(&root.join("second"));
8355
8356 let (_core, launched) = started_and_settled(spec(vec![root]));
8357
8358 assert_eq!(
8359 launched.generation,
8360 Generation::default().successor(),
8361 "a launch must settle on the first Generation a fresh `Core` mints; a second \
8362 walk of the same tree would be a second Generation"
8363 );
8364 let mut named: Vec<String> = launched
8365 .entities
8366 .iter()
8367 .filter(|entity| entity.branch.settled().is_some())
8368 .map(|entity| entity.name.to_string())
8369 .collect();
8370 named.sort();
8371 assert_eq!(
8372 named,
8373 vec!["first".to_string(), "second".to_string()],
8374 "that one Generation must cover every row its own walk found, or the walk it \
8375 saved would have to be paid by a second one"
8376 );
8377 }
8378
8379 #[test]
8386 fn dropping_a_core_cancels_every_entity_it_still_has_in_flight() {
8387 let dir = tempfile::tempdir().expect("temp dir");
8388 let root = root_of(&dir);
8389 init_repo_with_a_commit(&root.join("repo"));
8390
8391 let (core, launched) = started_and_settled(spec(vec![root]));
8392 let key = launched.entities[0].key.clone();
8393 let cancel = core.begin_untracked_probe_for_test(&key);
8394 assert!(!cancel.load(Ordering::Acquire));
8395
8396 drop(core);
8397
8398 assert!(
8399 cancel.load(Ordering::Acquire),
8400 "a dropped Core must cancel the Generation it still has in flight rather than \
8401 leave it running against a Set nothing will read again"
8402 );
8403 }
8404
8405 #[test]
8435 fn a_selection_scoped_refresh_supersedes_only_the_entity_it_covers() {
8436 let dir = tempfile::tempdir().expect("temp dir");
8437 let root = root_of(&dir);
8438 init_repo_with_a_commit(&root.join("a"));
8439 init_repo_with_a_commit(&root.join("b"));
8440
8441 let (core, snapshot) = started_and_settled(spec(vec![root]));
8442 let key_a = snapshot
8443 .entities
8444 .iter()
8445 .find(|entity| &*entity.name == "a")
8446 .expect("entity a discovered")
8447 .key
8448 .clone();
8449 let key_b = snapshot
8450 .entities
8451 .iter()
8452 .find(|entity| &*entity.name == "b")
8453 .expect("entity b discovered")
8454 .key
8455 .clone();
8456
8457 let older = core.begin_shared_generation_for_test(&[key_a.clone(), key_b.clone()]);
8461
8462 let newer = core.refresh(std::slice::from_ref(&key_a));
8465 assert_eq!(
8466 newer,
8467 older.generation.successor(),
8468 "the Selection-scoped refresh must be the Generation immediately after the one \
8469 still in flight, with nothing minted in between"
8470 );
8471
8472 core.wait_dispatched_for_test();
8477 assert!(
8478 older.cancels[&key_a].load(Ordering::Acquire),
8479 "the entity the new Generation covers must have its old interrupt flag set"
8480 );
8481 assert!(
8482 !older.cancels[&key_b].load(Ordering::Acquire),
8483 "an entity the new Generation does not cover must be left running, untouched"
8484 );
8485
8486 let after_refresh = core.settle();
8490
8491 let a_after_gen2 = after_refresh
8492 .entities
8493 .iter()
8494 .find(|entity| entity.key == key_a)
8495 .expect("entity a present");
8496 assert!(
8497 matches!(
8498 a_after_gen2.branch.settled(),
8499 Some(Settled::Known {
8500 value: Head::Branch { .. },
8501 at: _,
8502 stale: _
8503 })
8504 ),
8505 "the newer Generation's real probe should have written A's cell by now"
8506 );
8507
8508 core.apply_probe_result_for_test(
8512 &key_a,
8513 older.generation,
8514 Settled::Known {
8515 value: Head::Branch {
8516 name: Arc::from("stale-from-generation-one"),
8517 commit: gix::hash::Kind::Sha1.null(),
8518 },
8519 at: Timestamp::now(),
8520 stale: false,
8521 },
8522 );
8523 let after_stale_write = core.snapshot();
8524 let a_final = after_stale_write
8525 .entities
8526 .iter()
8527 .find(|entity| entity.key == key_a)
8528 .expect("entity a present");
8529 match a_final.branch.settled() {
8530 Some(Settled::Known {
8531 value: Head::Branch { name, .. },
8532 at: _,
8533 stale: _,
8534 }) => assert_ne!(
8535 &**name, "stale-from-generation-one",
8536 "a lower-Generation result must be dropped at the cell it would write"
8537 ),
8538 other => panic!("expected A to still hold the newer Generation's value, got {other:?}"),
8539 }
8540
8541 core.apply_probe_result_for_test(
8544 &key_b,
8545 older.generation,
8546 Settled::Known {
8547 value: Head::Branch {
8548 name: Arc::from("b-generation-one-result"),
8549 commit: gix::hash::Kind::Sha1.null(),
8550 },
8551 at: Timestamp::now(),
8552 stale: false,
8553 },
8554 );
8555 let final_snapshot = core.snapshot();
8556 let b_final = final_snapshot
8557 .entities
8558 .iter()
8559 .find(|entity| entity.key == key_b)
8560 .expect("entity b present");
8561 match b_final.branch.settled() {
8562 Some(Settled::Known {
8563 value: Head::Branch { name, .. },
8564 at: _,
8565 stale: _,
8566 }) => assert_eq!(
8567 &**name, "b-generation-one-result",
8568 "an entity the new Generation never covered must still accept its own result"
8569 ),
8570 other => {
8571 panic!("expected B's un-superseded older result to be accepted, got {other:?}")
8572 }
8573 }
8574 }
8575
8576 #[test]
8581 fn the_deadline_sweep_keeps_already_settled_cells_and_only_times_out_what_is_still_loading() {
8582 let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
8583 let dir = tempfile::tempdir().expect("temp dir");
8584 let root = root_of(&dir);
8585 init_repo_with_a_commit(&root.join("a"));
8586 init_repo_with_a_commit(&root.join("b"));
8587
8588 let mut spec = spec(vec![root]);
8589 spec.generation_deadline = Duration::ZERO;
8590 let started = Core::start_for_test(spec, Duration::from_secs(3600), tick_rx).discovered();
8591 let core = started.core;
8592 let snapshot = settle_launch(&core);
8595 let key_a = snapshot
8596 .entities
8597 .iter()
8598 .find(|entity| &*entity.name == "a")
8599 .expect("entity a discovered")
8600 .key
8601 .clone();
8602 let key_b = snapshot
8603 .entities
8604 .iter()
8605 .find(|entity| &*entity.name == "b")
8606 .expect("entity b discovered")
8607 .key
8608 .clone();
8609
8610 let a_settled = core.probe_now(&key_a);
8613 let a_value_before = match a_settled.branch.settled() {
8614 Some(Settled::Known {
8615 value: Head::Branch { name, .. },
8616 at: _,
8617 stale: _,
8618 }) => Arc::clone(name),
8619 other => panic!("expected A's synchronous probe to settle a branch, got {other:?}"),
8620 };
8621
8622 let cancel_b = core.begin_untracked_probe_for_test(&key_b);
8626 let before_tick = core.snapshot();
8627 let b_before = before_tick
8628 .entities
8629 .iter()
8630 .find(|entity| entity.key == key_b)
8631 .expect("entity b present");
8632 assert!(
8633 b_before.branch.is_in_flight(),
8634 "B must be mid-flight when the sweep fires; that is the only shape the sweep \
8635 may touch"
8636 );
8637 assert!(
8638 matches!(
8639 b_before.branch.settled(),
8640 Some(Settled::Known {
8641 value: _,
8642 at: _,
8643 stale: _
8644 })
8645 ),
8646 "B still carries launch's own answer here, so the Unknown below is a write the \
8647 sweep made rather than a cell that was already empty, got {:?}",
8648 b_before.branch.settled()
8649 );
8650
8651 tick_tx.send(Instant::now()).expect("send one tick");
8652 let after_sweep = core.settle();
8653
8654 let a_after = after_sweep
8655 .entities
8656 .iter()
8657 .find(|entity| entity.key == key_a)
8658 .expect("entity a present");
8659 match a_after.branch.settled() {
8660 Some(Settled::Known {
8661 value: Head::Branch { name, .. },
8662 at: _,
8663 stale: _,
8664 }) => assert_eq!(
8665 name, &a_value_before,
8666 "an already-settled cell must keep its value when the deadline sweep runs, not be blanked"
8667 ),
8668 other => panic!("expected A's settled value to survive the sweep, got {other:?}"),
8669 }
8670
8671 let b_after = after_sweep
8672 .entities
8673 .iter()
8674 .find(|entity| entity.key == key_b)
8675 .expect("entity b present");
8676 assert!(matches!(
8677 b_after.branch.settled(),
8678 Some(Settled::Unknown(Unknown::TimedOut))
8679 ));
8680 assert!(
8681 !cancel_b.load(Ordering::Acquire),
8682 "the deadline sweep marks a cell Unknown; it never sets the entity's own \
8683 cancel flag, since the underlying probe (nonexistent here) is left to keep running"
8684 );
8685 }
8686
8687 #[test]
8695 fn the_deadline_sweep_times_out_a_worktrees_outstanding_state_but_leaves_a_repos_not_applicable_one_alone()
8696 {
8697 let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
8698 let dir = tempfile::tempdir().expect("temp dir");
8699 let root = root_of(&dir);
8700 let parent = root.join("parent");
8701 init_repo_with_a_commit(&parent);
8702 let worktree_path = root.join("feature-worktree");
8703 git(
8704 &parent,
8705 &[
8706 "worktree",
8707 "add",
8708 "-b",
8709 "feature",
8710 worktree_path.to_str().expect("utf8 path"),
8711 ],
8712 );
8713
8714 let mut spec = spec(vec![root]);
8715 spec.generation_deadline = Duration::ZERO;
8716 let started = Core::start_for_test(spec, Duration::from_secs(3600), tick_rx).discovered();
8717 let core = started.core;
8718 let snapshot = settle_launch(&core);
8725 let repo_key = snapshot
8726 .entities
8727 .iter()
8728 .find(|entity| matches!(entity.kind, Kind::Repo))
8729 .expect("repo entity present")
8730 .key
8731 .clone();
8732 let worktree_key = snapshot
8733 .entities
8734 .iter()
8735 .find(|entity| matches!(entity.kind, Kind::Worktree))
8736 .expect("worktree entity present")
8737 .key
8738 .clone();
8739
8740 core.begin_untracked_probe_for_test(&repo_key);
8746 core.begin_untracked_probe_for_test(&worktree_key);
8747
8748 tick_tx.send(Instant::now()).expect("send one tick");
8749 let after_sweep = core.settle();
8750
8751 let worktree_after = after_sweep
8752 .entities
8753 .iter()
8754 .find(|entity| entity.key == worktree_key)
8755 .expect("worktree entity present");
8756 assert!(
8757 matches!(
8758 worktree_after.state.settled(),
8759 Some(Settled::Unknown(Unknown::TimedOut))
8760 ),
8761 "expected the outstanding state cell to time out, got {:?}",
8762 worktree_after.state.settled()
8763 );
8764
8765 let repo_after = after_sweep
8766 .entities
8767 .iter()
8768 .find(|entity| entity.key == repo_key)
8769 .expect("repo entity present");
8770 assert!(
8771 matches!(repo_after.state.settled(), Some(Settled::NotApplicable)),
8772 "a Repo's Not applicable state must survive the sweep untouched, got {:?}",
8773 repo_after.state.settled()
8774 );
8775 }
8776
8777 #[test]
8782 fn the_deadline_sweeps_poll_never_touches_an_entitys_action_receipt() {
8783 let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
8784 let dir = tempfile::tempdir().expect("temp dir");
8785 let root = root_of(&dir);
8786 let repo = root.join("repo");
8787 init_repo_with_a_commit(&repo);
8788
8789 let mut spec = spec(vec![root]);
8790 spec.generation_deadline = Duration::ZERO;
8791 let started = Core::start_for_test(spec, Duration::from_secs(3600), tick_rx).discovered();
8792 let core = started.core;
8793 let key = settle_launch(&core).entities[0].key.clone();
8795
8796 let receipt = crate::entity::ActionReceipt {
8797 label: Arc::from("reinstall"),
8798 steps: Arc::from(vec![crate::entity::StepResult {
8799 label: Arc::from("pnpm install"),
8800 outcome: crate::entity::StepOutcome::Ok,
8801 output: Arc::from(&b""[..]),
8802 elapsed: Duration::from_millis(1),
8803 elision: None,
8804 shell: false,
8805 interactive: false,
8806 }]),
8807 skip: None,
8808 finished_at: Timestamp::now(),
8809 running: None,
8810 };
8811 core.set_last_action_for_test(&key, receipt.clone());
8812
8813 core.begin_untracked_probe_for_test(&key);
8816 tick_tx.send(Instant::now()).expect("send one tick");
8817 let after = core.settle();
8818
8819 let entity = after
8820 .entities
8821 .iter()
8822 .find(|entity| entity.key == key)
8823 .expect("entity present");
8824 assert!(
8825 matches!(
8826 entity.branch.settled(),
8827 Some(Settled::Unknown(Unknown::TimedOut))
8828 ),
8829 "sanity check: the sweep must have actually timed out the in-flight cell, got {:?}",
8830 entity.branch.settled()
8831 );
8832 assert_eq!(entity.last_action, Some(receipt));
8833 }
8834
8835 #[test]
8845 fn a_cancelled_probe_never_opens_the_repository_at_all() {
8846 let cancel = AtomicBool::new(true);
8847
8848 let outcome = probe_branch(
8849 Path::new("/nonexistent/nowhere-at-all"),
8850 None,
8851 Kind::Repo,
8852 &cancel,
8853 );
8854
8855 assert!(
8856 outcome.is_none(),
8857 "a probe observing cancellation before its first read must do no work \
8858 at all, not attempt the read and fail having tried it"
8859 );
8860 }
8861
8862 #[test]
8872 fn classify_status_result_drops_an_error_once_cancel_reads_true() {
8873 let cancel = AtomicBool::new(true);
8874
8875 let outcome = classify_status_result(
8876 Err(crate::git::ProbeError::Status(Arc::from("boom"))),
8877 &cancel,
8878 );
8879
8880 assert!(
8881 outcome.is_none(),
8882 "an error alongside a cancel flag already set must read as cancelled, not \
8883 Failed, got {outcome:?}"
8884 );
8885 }
8886
8887 #[test]
8890 fn classify_status_result_settles_failed_when_cancel_never_fired() {
8891 let cancel = AtomicBool::new(false);
8892
8893 let outcome = classify_status_result(
8894 Err(crate::git::ProbeError::Status(Arc::from("boom"))),
8895 &cancel,
8896 );
8897
8898 assert!(
8899 matches!(outcome, Some(Settled::Failed(git::ProbeError::Status(_)))),
8900 "a genuine error with no cancellation must settle Failed, got {outcome:?}"
8901 );
8902 }
8903
8904 #[test]
8913 fn classify_status_result_drops_an_ok_once_cancel_reads_true() {
8914 let cancel = AtomicBool::new(true);
8915
8916 let outcome = classify_status_result(Ok(DirtyCounts::default()), &cancel);
8917
8918 assert!(
8919 outcome.is_none(),
8920 "an Ok value that raced ahead of a cancel flag now set must read as cancelled, \
8921 not be settled Known, got {outcome:?}"
8922 );
8923 }
8924
8925 #[test]
8928 fn classify_status_result_settles_known_when_cancel_never_fired() {
8929 let cancel = AtomicBool::new(false);
8930 let counts = DirtyCounts {
8931 modified: 1,
8932 untracked: 2,
8933 deleted: 3,
8934 };
8935
8936 let outcome = classify_status_result(Ok(counts), &cancel);
8937
8938 assert!(
8939 matches!(
8940 outcome,
8941 Some(Settled::Known {
8942 value,
8943 at: _,
8944 stale: _
8945 }) if value == counts
8946 ),
8947 "a genuine completed read with no cancellation must settle Known, got {outcome:?}"
8948 );
8949 }
8950
8951 #[test]
8957 fn a_linked_worktree_is_its_own_entity_and_never_doubles_as_a_repo() {
8958 let dir = tempfile::tempdir().expect("temp dir");
8959 let root = root_of(&dir);
8960 let parent = root.join("parent");
8961 init_repo_with_a_commit(&parent);
8962 let worktree_path = root.join("feature-worktree");
8963 let status = Command::new("git")
8964 .arg("-C")
8965 .arg(&parent)
8966 .args([
8967 "worktree",
8968 "add",
8969 "-b",
8970 "feature",
8971 worktree_path.to_str().expect("utf8 path"),
8972 ])
8973 .status()
8974 .expect("run git worktree add");
8975 assert!(status.success());
8976
8977 let core = Core::start_discovered(spec(vec![root]));
8978 let snapshot = core.snapshot();
8979
8980 assert_eq!(
8981 snapshot.entities.len(),
8982 2,
8983 "expected the parent plus one Worktree, not two Repos"
8984 );
8985 let repo_count = snapshot
8986 .entities
8987 .iter()
8988 .filter(|entity| matches!(entity.kind, Kind::Repo))
8989 .count();
8990 let worktree_count = snapshot
8991 .entities
8992 .iter()
8993 .filter(|entity| matches!(entity.kind, Kind::Worktree))
8994 .count();
8995 assert_eq!(
8996 repo_count, 1,
8997 "the parent must be counted as exactly one Repo"
8998 );
8999 assert_eq!(
9000 worktree_count, 1,
9001 "the linked worktree must be counted as exactly one Worktree"
9002 );
9003
9004 let worktree_entity = snapshot
9005 .entities
9006 .iter()
9007 .find(|entity| matches!(entity.kind, Kind::Worktree))
9008 .expect("worktree entity present");
9009 let repo_entity = snapshot
9010 .entities
9011 .iter()
9012 .find(|entity| matches!(entity.kind, Kind::Repo))
9013 .expect("repo entity present");
9014 assert_eq!(worktree_entity.common_dir, repo_entity.common_dir);
9015
9016 let repo_branch = core.probe_now(&repo_entity.key);
9019 let worktree_branch = core.probe_now(&worktree_entity.key);
9020 match (
9021 repo_branch.branch.settled(),
9022 worktree_branch.branch.settled(),
9023 ) {
9024 (
9025 Some(Settled::Known {
9026 value:
9027 Head::Branch {
9028 name: repo_name, ..
9029 },
9030 at: _,
9031 stale: _,
9032 }),
9033 Some(Settled::Known {
9034 value:
9035 Head::Branch {
9036 name: worktree_name,
9037 ..
9038 },
9039 at: _,
9040 stale: _,
9041 }),
9042 ) => {
9043 assert_ne!(repo_name, worktree_name);
9044 assert_eq!(&**worktree_name, "feature");
9045 }
9046 other => panic!("expected both entities to read an attached branch, got {other:?}"),
9047 }
9048 }
9049
9050 #[test]
9054 fn a_worktrees_branch_that_is_an_ancestor_of_the_default_branch_reads_merged_after_a_refresh() {
9055 let dir = tempfile::tempdir().expect("temp dir");
9056 let root = root_of(&dir);
9057 let parent = root.join("parent");
9058 init_repo_with_a_commit(&parent);
9059 git(
9060 &parent,
9061 &[
9062 "remote",
9063 "add",
9064 "origin",
9065 "https://example.invalid/repo.git",
9066 ],
9067 );
9068 let sha = head_sha(&parent);
9069 git(&parent, &["update-ref", "refs/remotes/origin/main", &sha]);
9070 let worktree_path = root.join("feature-worktree");
9071 git(
9072 &parent,
9073 &[
9074 "worktree",
9075 "add",
9076 "-b",
9077 "feature",
9078 worktree_path.to_str().expect("utf8 path"),
9079 ],
9080 );
9081
9082 let core = Core::start_discovered(spec(vec![root]));
9083 let keys: Vec<EntityKey> = core
9084 .snapshot()
9085 .entities
9086 .iter()
9087 .map(|entity| entity.key.clone())
9088 .collect();
9089
9090 core.refresh(&keys);
9091 let settled = core.settle();
9092
9093 let worktree_entity = settled
9094 .entities
9095 .iter()
9096 .find(|entity| matches!(entity.kind, Kind::Worktree))
9097 .expect("worktree entity present");
9098 assert!(
9099 matches!(
9100 worktree_entity.state.settled(),
9101 Some(Settled::Known {
9102 value: WorktreeState::Merged,
9103 at: _,
9104 stale: _
9105 })
9106 ),
9107 "expected the worktree, at the same commit as the default branch, to read Merged, got {:?}",
9108 worktree_entity.state.settled()
9109 );
9110 }
9111
9112 #[test]
9121 fn a_squash_merged_worktree_branch_reads_merged_after_a_refresh() {
9122 let dir = tempfile::tempdir().expect("temp dir");
9123 let root = root_of(&dir);
9124 let parent = root.join("parent");
9125 init_repo_with_a_commit(&parent);
9126 git(
9127 &parent,
9128 &[
9129 "remote",
9130 "add",
9131 "origin",
9132 "https://example.invalid/repo.git",
9133 ],
9134 );
9135 let worktree_path = root.join("feature-worktree");
9136 git(
9137 &parent,
9138 &[
9139 "worktree",
9140 "add",
9141 "-b",
9142 "feature",
9143 worktree_path.to_str().expect("utf8 path"),
9144 ],
9145 );
9146 fs::write(worktree_path.join("a.txt"), "one\n").expect("write a.txt");
9147 git(&worktree_path, &["add", "a.txt"]);
9148 git(&worktree_path, &["commit", "-m", "add a"]);
9149 fs::write(worktree_path.join("b.txt"), "two\n").expect("write b.txt");
9150 git(&worktree_path, &["add", "b.txt"]);
9151 git(&worktree_path, &["commit", "-m", "add b"]);
9152 let feature_sha = head_sha(&worktree_path);
9153
9154 git(&parent, &["merge", "--squash", "feature"]);
9157 git(&parent, &["commit", "-m", "squashed feature"]);
9158 let main_sha = head_sha(&parent);
9159 git(
9160 &parent,
9161 &["update-ref", "refs/remotes/origin/main", &main_sha],
9162 );
9163
9164 git(&parent, &["config", "branch.feature.remote", "origin"]);
9167 git(
9168 &parent,
9169 &["config", "branch.feature.merge", "refs/heads/feature"],
9170 );
9171 git(
9172 &parent,
9173 &["update-ref", "refs/remotes/origin/feature", &feature_sha],
9174 );
9175
9176 let core = Core::start_discovered(spec(vec![root]));
9177 let keys: Vec<EntityKey> = core
9178 .snapshot()
9179 .entities
9180 .iter()
9181 .map(|entity| entity.key.clone())
9182 .collect();
9183
9184 core.refresh(&keys);
9185 let settled = core.settle();
9186
9187 let worktree_entity = settled
9188 .entities
9189 .iter()
9190 .find(|entity| matches!(entity.kind, Kind::Worktree))
9191 .expect("worktree entity present");
9192 assert!(
9193 matches!(
9194 worktree_entity.state.settled(),
9195 Some(Settled::Known {
9196 value: WorktreeState::Merged,
9197 at: _,
9198 stale: _
9199 })
9200 ),
9201 "expected a squash-merged worktree branch to read Merged, got {:?}",
9202 worktree_entity.state.settled()
9203 );
9204 }
9205
9206 #[test]
9214 fn patch_equivalence_never_runs_for_an_entity_ancestry_already_settled() {
9215 let dir = tempfile::tempdir().expect("temp dir");
9216 let root = root_of(&dir);
9217 let parent = root.join("parent");
9218 init_repo_with_a_commit(&parent);
9219 git(
9220 &parent,
9221 &[
9222 "remote",
9223 "add",
9224 "origin",
9225 "https://example.invalid/repo.git",
9226 ],
9227 );
9228 let sha = head_sha(&parent);
9229 git(&parent, &["update-ref", "refs/remotes/origin/main", &sha]);
9230 let worktree_path = root.join("feature-worktree");
9231 git(
9232 &parent,
9233 &[
9234 "worktree",
9235 "add",
9236 "-b",
9237 "feature",
9238 worktree_path.to_str().expect("utf8 path"),
9239 ],
9240 );
9241
9242 let (core, launched) = started_and_settled(spec(vec![root]));
9243 let keys: Vec<EntityKey> = launched
9244 .entities
9245 .iter()
9246 .map(|entity| entity.key.clone())
9247 .collect();
9248
9249 core.refresh(&keys);
9250 let settled = core.settle();
9251
9252 let worktree_entity = settled
9253 .entities
9254 .iter()
9255 .find(|entity| matches!(entity.kind, Kind::Worktree))
9256 .expect("worktree entity present");
9257 assert!(
9258 matches!(
9259 worktree_entity.state.settled(),
9260 Some(Settled::Known {
9261 value: WorktreeState::Merged,
9262 at: _,
9263 stale: _
9264 })
9265 ),
9266 "expected ancestry alone to settle Merged here, got {:?}",
9267 worktree_entity.state.settled()
9268 );
9269 assert_eq!(
9270 core.patch_identity_reads_for_test(),
9271 0,
9272 "ancestry already settled this entity, so patch equivalence's shared \
9273 scan must never run for its common dir at all"
9274 );
9275 }
9276
9277 #[test]
9285 fn a_full_refresh_reaching_patch_equivalence_writes_no_loose_objects() {
9286 let dir = tempfile::tempdir().expect("temp dir");
9287 let root = root_of(&dir);
9288 let parent = root.join("parent");
9289 init_repo_with_a_commit(&parent);
9290 git(
9291 &parent,
9292 &[
9293 "remote",
9294 "add",
9295 "origin",
9296 "https://example.invalid/repo.git",
9297 ],
9298 );
9299 let worktree_path = root.join("feature-worktree");
9300 git(
9301 &parent,
9302 &[
9303 "worktree",
9304 "add",
9305 "-b",
9306 "feature",
9307 worktree_path.to_str().expect("utf8 path"),
9308 ],
9309 );
9310 fs::write(worktree_path.join("a.txt"), "one\n").expect("write a.txt");
9311 git(&worktree_path, &["add", "a.txt"]);
9312 git(&worktree_path, &["commit", "-m", "add a"]);
9313 fs::write(worktree_path.join("b.txt"), "two\n").expect("write b.txt");
9314 git(&worktree_path, &["add", "b.txt"]);
9315 git(&worktree_path, &["commit", "-m", "add b"]);
9316 let feature_sha = head_sha(&worktree_path);
9317
9318 git(&parent, &["merge", "--squash", "feature"]);
9319 git(&parent, &["commit", "-m", "squashed feature"]);
9320 let main_sha = head_sha(&parent);
9321 git(
9322 &parent,
9323 &["update-ref", "refs/remotes/origin/main", &main_sha],
9324 );
9325 git(&parent, &["config", "branch.feature.remote", "origin"]);
9326 git(
9327 &parent,
9328 &["config", "branch.feature.merge", "refs/heads/feature"],
9329 );
9330 git(
9331 &parent,
9332 &["update-ref", "refs/remotes/origin/feature", &feature_sha],
9333 );
9334
9335 let core = Core::start_discovered(spec(vec![root]));
9336 let keys: Vec<EntityKey> = core
9337 .snapshot()
9338 .entities
9339 .iter()
9340 .map(|entity| entity.key.clone())
9341 .collect();
9342
9343 let before = loose_object_count(&parent);
9344 core.refresh(&keys);
9345 let settled = core.settle();
9346 let after = loose_object_count(&parent);
9347
9348 let worktree_entity = settled
9349 .entities
9350 .iter()
9351 .find(|entity| matches!(entity.kind, Kind::Worktree))
9352 .expect("worktree entity present");
9353 assert!(
9354 matches!(
9355 worktree_entity.state.settled(),
9356 Some(Settled::Known {
9357 value: WorktreeState::Merged,
9358 at: _,
9359 stale: _
9360 })
9361 ),
9362 "expected this refresh to actually reach patch equivalence and settle \
9363 Merged, got {:?}",
9364 worktree_entity.state.settled()
9365 );
9366 assert_eq!(
9367 before, after,
9368 "a full refresh reaching patch equivalence must never write a loose \
9369 object to the repository"
9370 );
9371 }
9372
9373 #[test]
9381 fn a_diverged_worktree_with_a_live_upstream_and_genuinely_unmerged_work_settles_active_after_a_refresh()
9382 {
9383 let dir = tempfile::tempdir().expect("temp dir");
9384 let root = root_of(&dir);
9385 let parent = root.join("parent");
9386 init_repo_with_a_commit(&parent);
9387 let base_sha = head_sha(&parent);
9388 git(
9389 &parent,
9390 &[
9391 "remote",
9392 "add",
9393 "origin",
9394 "https://example.invalid/repo.git",
9395 ],
9396 );
9397 git(
9398 &parent,
9399 &["update-ref", "refs/remotes/origin/main", &base_sha],
9400 );
9401 let worktree_path = root.join("feature-worktree");
9402 git(
9403 &parent,
9404 &[
9405 "worktree",
9406 "add",
9407 "-b",
9408 "feature",
9409 worktree_path.to_str().expect("utf8 path"),
9410 ],
9411 );
9412 fs::write(worktree_path.join("feature.txt"), "unmerged work\n").expect("write feature.txt");
9415 git(&worktree_path, &["add", "feature.txt"]);
9416 git(&worktree_path, &["commit", "-m", "unmerged"]);
9417 let feature_sha = head_sha(&worktree_path);
9418 git(&parent, &["config", "branch.feature.remote", "origin"]);
9421 git(
9422 &parent,
9423 &["config", "branch.feature.merge", "refs/heads/feature"],
9424 );
9425 git(
9426 &parent,
9427 &["update-ref", "refs/remotes/origin/feature", &feature_sha],
9428 );
9429
9430 let core = Core::start_discovered(spec(vec![root]));
9431 let keys: Vec<EntityKey> = core
9432 .snapshot()
9433 .entities
9434 .iter()
9435 .map(|entity| entity.key.clone())
9436 .collect();
9437
9438 core.refresh(&keys);
9439 let settled = core.settle();
9440
9441 let worktree_entity = settled
9442 .entities
9443 .iter()
9444 .find(|entity| matches!(entity.kind, Kind::Worktree))
9445 .expect("worktree entity present");
9446 assert!(
9447 matches!(
9448 worktree_entity.state.settled(),
9449 Some(Settled::Known {
9450 value: WorktreeState::Active,
9451 at: _,
9452 stale: _
9453 })
9454 ),
9455 "expected genuinely unmerged work with a live upstream to settle Active, got {:?}",
9456 worktree_entity.state.settled()
9457 );
9458 }
9459
9460 #[test]
9467 fn a_submodule_is_in_the_snapshot_even_though_hidden_by_the_default_preference() {
9468 let dir = tempfile::tempdir().expect("temp dir");
9469 let root = root_of(&dir);
9470 let parent = root.join("parent");
9471 init_repo_with_a_commit(&parent);
9472 fs::write(
9473 parent.join(".gitmodules"),
9474 "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
9475 )
9476 .expect("write .gitmodules");
9477 fs::create_dir_all(parent.join("vendor").join("lib")).expect("create submodule dir");
9478
9479 let core = Core::start_discovered(spec(vec![root]));
9480 let snapshot = core.snapshot();
9481
9482 assert!(
9483 snapshot
9484 .entities
9485 .iter()
9486 .any(|entity| matches!(entity.kind, Kind::Submodule)),
9487 "a discovered Submodule must be in the snapshot even while show_submodules is off"
9488 );
9489 }
9490
9491 #[test]
9501 fn a_submodules_state_and_base_cells_stay_unknown_through_a_real_refresh() {
9502 let dir = tempfile::tempdir().expect("temp dir");
9503 let root = root_of(&dir);
9504 let parent = root.join("parent");
9505 init_repo_with_a_commit(&parent);
9506 fs::write(
9507 parent.join(".gitmodules"),
9508 "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
9509 )
9510 .expect("write .gitmodules");
9511 let submodule = parent.join("vendor").join("lib");
9512 init_repo_with_a_commit(&submodule);
9513 git(
9514 &submodule,
9515 &["remote", "add", "origin", "https://example.invalid/lib.git"],
9516 );
9517 let root_sha = head_sha(&submodule);
9518 git(&submodule, &["commit", "--allow-empty", "-m", "second"]);
9519 let tip_sha = head_sha(&submodule);
9520 git(&submodule, &["reset", "--hard", &root_sha]);
9521 git(
9522 &submodule,
9523 &["update-ref", "refs/remotes/origin/main", &tip_sha],
9524 );
9525
9526 let mut core_spec = spec(vec![root]);
9529 core_spec.show_submodules = true;
9530 let core = Core::start_discovered(core_spec);
9531 let key = core
9532 .snapshot()
9533 .entities
9534 .iter()
9535 .find(|entity| matches!(entity.kind, Kind::Submodule))
9536 .expect("a discovered Submodule")
9537 .key
9538 .clone();
9539
9540 core.refresh(std::slice::from_ref(&key));
9541 let settled = core.settle();
9542 let submodule_entity = settled
9543 .entities
9544 .iter()
9545 .find(|entity| entity.key == key)
9546 .expect("the Submodule entity");
9547
9548 assert!(
9549 matches!(
9550 submodule_entity.base.settled(),
9551 Some(Settled::Unknown(Unknown::NoDefaultBranch))
9552 ),
9553 "expected a Submodule's base to stay Unknown through a real refresh, \
9554 got {:?}",
9555 submodule_entity.base.settled()
9556 );
9557 assert!(
9558 matches!(
9559 submodule_entity.state.settled(),
9560 Some(Settled::Unknown(Unknown::NoDefaultBranch))
9561 ),
9562 "expected a Submodule's state to stay Unknown through a real refresh, \
9563 rather than settling Merged off an untrusted default branch, got {:?}",
9564 submodule_entity.state.settled()
9565 );
9566 }
9567
9568 #[test]
9573 fn a_submodules_entity_name_is_its_relative_path_not_its_basename() {
9574 let dir = tempfile::tempdir().expect("temp dir");
9575 let root = root_of(&dir);
9576 let parent = root.join("parent");
9577 init_repo_with_a_commit(&parent);
9578 fs::write(
9579 parent.join(".gitmodules"),
9580 "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
9581 )
9582 .expect("write .gitmodules");
9583 fs::create_dir_all(parent.join("vendor").join("lib")).expect("create submodule dir");
9584
9585 let core = Core::start_discovered(spec(vec![root]));
9586 let submodule = core
9587 .snapshot()
9588 .entities
9589 .into_iter()
9590 .find(|entity| matches!(entity.kind, Kind::Submodule))
9591 .expect("a discovered Submodule");
9592
9593 assert_eq!(
9594 submodule.name.as_ref(),
9595 "vendor/lib",
9596 "expected the declared relative path, not the basename `lib`"
9597 );
9598 }
9599
9600 #[test]
9609 fn an_uninitialised_submodules_probed_cells_settle_unknown_not_failed() {
9610 let dir = tempfile::tempdir().expect("temp dir");
9611 let root = root_of(&dir);
9612 let parent = root.join("parent");
9613 init_repo_with_a_commit(&parent);
9614 fs::write(
9615 parent.join(".gitmodules"),
9616 "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
9617 )
9618 .expect("write .gitmodules");
9619 let mut core_spec = spec(vec![root]);
9623 core_spec.show_submodules = true;
9624 let core = Core::start_discovered(core_spec);
9625 let key = core
9626 .snapshot()
9627 .entities
9628 .iter()
9629 .find(|entity| matches!(entity.kind, Kind::Submodule))
9630 .expect("a discovered Submodule")
9631 .key
9632 .clone();
9633
9634 core.refresh(std::slice::from_ref(&key));
9635 let settled = core.settle();
9636 let submodule = settled
9637 .entities
9638 .iter()
9639 .find(|entity| entity.key == key)
9640 .expect("the Submodule entity");
9641
9642 assert!(
9643 matches!(
9644 submodule.branch.settled(),
9645 Some(Settled::Unknown(Unknown::SubmoduleUninitialized))
9646 ),
9647 "expected branch to settle Unknown(SubmoduleUninitialized), got {:?}",
9648 submodule.branch.settled()
9649 );
9650 assert!(
9651 matches!(
9652 submodule.sync.settled(),
9653 Some(Settled::Unknown(Unknown::SubmoduleUninitialized))
9654 ),
9655 "expected sync to settle Unknown(SubmoduleUninitialized), got {:?}",
9656 submodule.sync.settled()
9657 );
9658 assert!(
9659 matches!(
9660 submodule.dirty.settled(),
9661 Some(Settled::Unknown(Unknown::SubmoduleUninitialized))
9662 ),
9663 "expected dirty to settle Unknown(SubmoduleUninitialized), got {:?}",
9664 submodule.dirty.settled()
9665 );
9666 assert_eq!(
9667 summary(submodule),
9668 RowSummary::Unknown,
9669 "expected the row's own gutter fold to read Unknown, not Failed"
9670 );
9671 }
9672
9673 #[test]
9679 fn dispatch_skips_probing_a_hidden_submodule_while_probing_the_same_one_shown() {
9680 let dir = tempfile::tempdir().expect("temp dir");
9681 let root = root_of(&dir);
9682 let parent = root.join("parent");
9683 init_repo_with_a_commit(&parent);
9684 fs::write(
9685 parent.join(".gitmodules"),
9686 "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
9687 )
9688 .expect("write .gitmodules");
9689 init_repo_with_a_commit(&parent.join("vendor").join("lib"));
9690
9691 let core = Core::start_discovered(spec(vec![root]));
9693 let key = core
9694 .snapshot()
9695 .entities
9696 .iter()
9697 .find(|entity| matches!(entity.kind, Kind::Submodule))
9698 .expect("a discovered Submodule")
9699 .key
9700 .clone();
9701
9702 core.refresh(std::slice::from_ref(&key));
9704 let while_hidden = core.settle();
9705 let hidden_entity = while_hidden
9706 .entities
9707 .iter()
9708 .find(|entity| entity.key == key)
9709 .expect("submodule entity");
9710 assert!(
9711 hidden_entity.branch.settled().is_none(),
9712 "a Submodule dispatched while hidden must never even reach probe_branch, \
9713 so its cell stays never-settled rather than holding any value at all, got {:?}",
9714 hidden_entity.branch.settled()
9715 );
9716
9717 core.set_show_submodules(true);
9721 core.refresh(std::slice::from_ref(&key));
9722 let while_shown = core.settle();
9723 let shown_entity = while_shown
9724 .entities
9725 .iter()
9726 .find(|entity| entity.key == key)
9727 .expect("submodule entity");
9728 assert!(
9729 matches!(
9730 shown_entity.branch.settled(),
9731 Some(Settled::Known {
9732 value: _,
9733 at: _,
9734 stale: _
9735 })
9736 ),
9737 "expected the same Submodule's branch to settle a real value once shown, got {:?}",
9738 shown_entity.branch.settled()
9739 );
9740 }
9741
9742 #[test]
9748 fn toggling_show_submodules_starts_no_new_generation_and_dispatches_nothing() {
9749 let dir = tempfile::tempdir().expect("temp dir");
9750 let root = root_of(&dir);
9751 init_repo_with_a_commit(&root.join("repo-a"));
9752
9753 let (core, launched) = started_and_settled(spec(vec![root]));
9755 let before = launched.generation;
9756 let dispatched_before = core.dispatch_log_for_test();
9757 assert!(
9758 !dispatched_before.is_empty(),
9759 "launch dispatched nothing, so the comparison below would hold however much a \
9760 toggle dispatched"
9761 );
9762
9763 core.set_show_submodules(true);
9764 core.set_show_submodules(false);
9765
9766 assert_eq!(
9767 core.snapshot().generation,
9768 before,
9769 "toggling show_submodules must start no Generation of its own"
9770 );
9771 assert_eq!(
9772 core.dispatch_log_for_test(),
9773 dispatched_before,
9774 "toggling show_submodules must dispatch no probe of its own, leaving the last \
9775 Generation's own log exactly as it found it"
9776 );
9777 }
9778
9779 #[test]
9786 fn a_malformed_gitmodules_file_still_fails_the_parent_while_submodules_are_hidden() {
9787 let dir = tempfile::tempdir().expect("temp dir");
9788 let root = root_of(&dir);
9789 let parent = root.join("parent");
9790 init_repo_with_a_commit(&parent);
9791 fs::write(
9792 parent.join(".gitmodules"),
9793 "[submodule \"lib\"\n\tpath = lib\n",
9794 )
9795 .expect("write malformed .gitmodules");
9796
9797 let core = Core::start_discovered(spec(vec![root]));
9798 let key = core
9799 .snapshot()
9800 .entities
9801 .iter()
9802 .find(|entity| entity.key.path() == parent)
9803 .expect("the parent entity")
9804 .key
9805 .clone();
9806 core.refresh(std::slice::from_ref(&key));
9810 let settled = core.settle();
9811 let parent_entity = settled
9812 .entities
9813 .iter()
9814 .find(|entity| entity.key == key)
9815 .expect("the parent entity");
9816
9817 assert_eq!(
9818 summary(parent_entity),
9819 RowSummary::Failed,
9820 "expected the parent to fold Failed even with Submodules hidden"
9821 );
9822 assert!(
9823 parent_entity.diagnostics.gitmodules_failed.is_some(),
9824 "expected the failure recorded in Diagnostics for the detail pane"
9825 );
9826 assert!(
9827 !settled
9828 .entities
9829 .iter()
9830 .any(|entity| matches!(entity.kind, Kind::Submodule)),
9831 "an unparseable .gitmodules yields no Submodule rows for that parent"
9832 );
9833 }
9834
9835 #[test]
9836 fn count_matches_a_plain_discoverys_entity_count() {
9837 let dir = tempfile::tempdir().expect("temp dir");
9838 let root = root_of(&dir);
9839 init_repo_with_a_commit(&root.join("one"));
9840 init_repo_with_a_commit(&root.join("two"));
9841
9842 let set = SetSpec {
9843 name: "test".to_string(),
9844 roots: vec![root],
9845 include: Vec::new(),
9846 exclude: Vec::new(),
9847 };
9848
9849 assert_eq!(discovery::count(&set), 2);
9850 }
9851
9852 #[test]
9853 fn the_slow_discovery_watcher_warns_with_the_count_reached_and_the_roots() {
9854 let progress = Arc::new(AtomicUsize::new(42));
9855 let finished = Arc::new(AtomicBool::new(false));
9856 let roots = vec![PathBuf::from("/repos/a"), PathBuf::from("/repos/b")];
9857
9858 let warning = watch_for_slow_discovery(progress, finished, roots, Duration::from_millis(1));
9859
9860 let message = warning.expect("a walk that has not finished should warn");
9861 assert!(message.contains("42"));
9862 assert!(message.contains("/repos/a"));
9863 assert!(message.contains("/repos/b"));
9864 }
9865
9866 #[test]
9867 fn the_slow_discovery_watcher_is_silent_once_the_walk_has_already_finished() {
9868 let progress = Arc::new(AtomicUsize::new(7));
9869 let finished = Arc::new(AtomicBool::new(true));
9870
9871 let warning =
9872 watch_for_slow_discovery(progress, finished, Vec::new(), Duration::from_millis(1));
9873
9874 assert!(warning.is_none());
9875 }
9876
9877 #[test]
9885 fn a_fast_discovery_leaves_no_warning_once_the_watcher_has_run() {
9886 let dir = tempfile::tempdir().expect("temp dir");
9887 let root = root_of(&dir);
9888 init_repo_with_a_commit(&root.join("repo"));
9889 let (_tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
9890
9891 let started =
9892 Core::start_for_test(spec(vec![root]), Duration::from_secs(1), tick_rx).discovered();
9893 started
9894 .discovery_watcher
9895 .join()
9896 .expect("watcher thread should not panic");
9897
9898 assert!(started.core.discovery_warning().is_none());
9899 }
9900
9901 fn gate_opened_on_signal(open: bool) -> (DiscoveryGate, Sender<()>, JoinHandle<()>) {
9909 let gate: DiscoveryGate = Arc::new((Mutex::new(open), Condvar::new()));
9910 let (returned_tx, returned_rx) = crossbeam_channel::bounded::<()>(1);
9911 let opener = thread::spawn({
9912 let gate = Arc::clone(&gate);
9913 move || {
9914 let _ = returned_rx.recv_timeout(crate::liveness::BACKSTOP);
9915 set_discovery_gate(&gate, true);
9916 }
9917 });
9918 (gate, returned_tx, opener)
9919 }
9920
9921 #[test]
9933 fn start_returns_against_an_empty_table_and_the_rows_land_when_discovery_does() {
9934 let dir = tempfile::tempdir().expect("temp dir");
9935 let root = root_of(&dir);
9936 let repo = root.join("repo");
9937 init_repo_with_a_commit(&repo);
9938 let (_tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
9939 let (gate, start_returned, opener) = gate_opened_on_signal(false);
9940
9941 let started = Core::start_for_test_gated(
9942 spec(vec![root]),
9943 Duration::from_secs(3600),
9944 discovery::ABANDON_AFTER,
9945 tick_rx,
9946 Some(Arc::clone(&gate)),
9947 );
9948 let at_start = started.core.snapshot();
9949 let key = EntityKey::new(Arc::from(repo.as_path()));
9950 started.core.hold_phase_c_for_test(&key);
9951 start_returned.send(()).expect("the opener is listening");
9952 opener.join().expect("the opener thread should not panic");
9953 let started = started.discovered();
9954
9955 assert!(
9956 at_start.entities.is_empty(),
9957 "`Core::start` must return before discovery has finished, against the empty \
9958 table a consumer draws its first frame from, got {:?}",
9959 at_start
9960 .entities
9961 .iter()
9962 .map(|entity| entity.name.to_string())
9963 .collect::<Vec<_>>()
9964 );
9965
9966 let landed = started.core.snapshot();
9967 assert_eq!(
9968 landed
9969 .entities
9970 .iter()
9971 .map(|entity| entity.name.to_string())
9972 .collect::<Vec<_>>(),
9973 vec!["repo".to_string()],
9974 "the row must land on the table as soon as discovery does"
9975 );
9976 assert!(
9977 landed.entities[0].dirty.settled().is_none() && landed.entities[0].dirty.is_in_flight(),
9978 "discovery lands the row alone: launch's own Generation is already covering it \
9979 and its Cells stay unsettled until that Generation answers, which is what the \
9980 spinner sits behind"
9981 );
9982
9983 started.core.release_phase_c_for_test(&key);
9984 started.core.wait_phase_c_finished_for_test(&key);
9985 }
9986
9987 #[test]
9996 fn refresh_all_covers_every_row_its_own_discovery_found() {
9997 let dir = tempfile::tempdir().expect("temp dir");
9998 let root = root_of(&dir);
9999 init_repo_with_a_commit(&root.join("repo"));
10000
10001 let (core, launched) = started_and_settled(spec(vec![root.clone()]));
10002 assert_eq!(
10003 launched
10004 .entities
10005 .iter()
10006 .map(|entity| entity.name.to_string())
10007 .collect::<Vec<_>>(),
10008 vec!["repo".to_string()],
10009 "launch's own walk must have landed and covered exactly the one row that \
10010 existed when it ran"
10011 );
10012 init_repo_with_a_commit(&root.join("late"));
10016
10017 assert_eq!(
10018 core.refresh_all(),
10019 launched.generation.successor(),
10020 "`refresh_all` must be the Generation immediately after the one already on the \
10021 table"
10022 );
10023 let settled = core.settle();
10024
10025 let mut named: Vec<String> = settled
10026 .entities
10027 .iter()
10028 .filter(|entity| entity.branch.settled().is_some())
10029 .map(|entity| entity.name.to_string())
10030 .collect();
10031 named.sort();
10032 assert_eq!(
10033 named,
10034 vec!["late".to_string(), "repo".to_string()],
10035 "the Generation must cover every row its own discovery found, including one the \
10036 caller had no key for"
10037 );
10038 }
10039
10040 #[test]
10050 fn refresh_returns_before_its_own_generations_discovery_has_run() {
10051 let dir = tempfile::tempdir().expect("temp dir");
10052 let root = root_of(&dir);
10053 init_repo_with_a_commit(&root.join("repo"));
10054 let (_tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
10055 let (gate, walk_may_run, opener) = gate_opened_on_signal(true);
10056
10057 let started = Core::start_for_test_gated(
10058 spec(vec![root.clone()]),
10059 Duration::from_secs(3600),
10060 discovery::ABANDON_AFTER,
10061 tick_rx,
10062 Some(Arc::clone(&gate)),
10063 )
10064 .discovered();
10065 let core = started.core;
10066 let launched = settle_launch(&core);
10068 let keys: Vec<EntityKey> = launched
10069 .entities
10070 .iter()
10071 .map(|entity| entity.key.clone())
10072 .collect();
10073 init_repo_with_a_commit(&root.join("late"));
10074
10075 set_discovery_gate(&gate, false);
10076 let generation = core.refresh(&keys);
10077 let while_held = core.snapshot();
10078 let dispatched_while_held = core.settle_gate_count_for_test();
10079 walk_may_run.send(()).expect("the opener is listening");
10080 opener.join().expect("the opener thread should not panic");
10081
10082 assert_eq!(
10083 generation,
10084 launched.generation.successor(),
10085 "`refresh` must return its own Generation's number, the one immediately after \
10086 the table's, before that Generation has done any of its work"
10087 );
10088 assert!(
10089 !while_held
10090 .entities
10091 .iter()
10092 .any(|entity| &*entity.name == "late"),
10093 "`refresh` must return before its own Generation's walk has run, so a Repo \
10094 created after the previous walk is not on the table it returned against"
10095 );
10096 assert_eq!(
10097 dispatched_while_held, 0,
10098 "`refresh` returned before its Generation reached the table at all, so nothing \
10099 is dispatched yet"
10100 );
10101
10102 core.wait_dispatched_for_test();
10103 let settled = core.settle();
10104
10105 assert!(
10106 settled
10107 .entities
10108 .iter()
10109 .any(|entity| &*entity.name == "late"),
10110 "the deferred Generation must still run its own walk once it is let through: \
10111 deferred, never dropped"
10112 );
10113 }
10114
10115 #[test]
10126 fn a_dispatch_body_waits_for_every_earlier_reserved_generation() {
10127 let turnstile = Arc::new(DispatchTurnstile::default());
10128 let earlier = turnstile.reserve();
10129 let later = turnstile.reserve();
10130 let order = Arc::new(Mutex::new(Vec::new()));
10131
10132 let earlier_body = thread::spawn({
10133 let turnstile = Arc::clone(&turnstile);
10134 let order = Arc::clone(&order);
10135 move || {
10136 let _turn = turnstile.take(earlier);
10137 order.lock().unwrap().push(earlier);
10138 }
10139 });
10140
10141 {
10142 let _turn = turnstile.take(later);
10143 order.lock().unwrap().push(later);
10144 }
10145 earlier_body
10146 .join()
10147 .expect("the earlier body should not panic");
10148
10149 assert_eq!(
10150 *order.lock().unwrap(),
10151 vec![earlier, later],
10152 "a dispatch body must run in the order its Generation was reserved"
10153 );
10154 }
10155
10156 #[test]
10161 fn run_while_not_cancelled_stops_at_the_next_check_rather_than_running_forever() {
10162 let cancel = Arc::new(AtomicBool::new(false));
10163 let worker_cancel = Arc::clone(&cancel);
10164 let (step_started_tx, step_started_rx) = crossbeam_channel::bounded::<()>(0);
10165 let (proceed_tx, proceed_rx) = crossbeam_channel::bounded::<()>(0);
10166
10167 let worker = thread::spawn(move || {
10168 run_while_not_cancelled(&worker_cancel, || {
10169 step_started_tx.send(()).expect("test should be listening");
10170 proceed_rx.recv().is_ok()
10171 })
10172 });
10173
10174 for _ in 0..2 {
10175 step_started_rx
10176 .recv()
10177 .expect("worker should announce each step");
10178 proceed_tx.send(()).expect("let the step finish");
10179 }
10180 step_started_rx
10181 .recv()
10182 .expect("worker should announce its third step");
10183 cancel.store(true, Ordering::Release);
10184 proceed_tx.send(()).expect("let the third step finish");
10185
10186 let ran = worker.join().expect("worker thread should not panic");
10187
10188 assert_eq!(
10189 ran, 3,
10190 "expected cancellation to stop the loop after its third step"
10191 );
10192 }
10193
10194 fn benchmark_identity_phase(
10202 population: Vec<crate::discovery::DiscoveredEntity>,
10203 ) -> (Duration, Vec<Duration>) {
10204 let (tx, rx) = crossbeam_channel::unbounded();
10205 let started = Instant::now();
10206 crate::fanout::scatter(population, tx, |entity| {
10207 let task_started = Instant::now();
10208 let repo = match &entity.repo {
10209 Some(repo) => repo.to_thread_local(),
10210 None => match git::open_thread_safe(entity.key.path()) {
10211 Ok(repo) => repo.to_thread_local(),
10212 Err(_) => return None,
10213 },
10214 };
10215 let _ = git::head_shape(&repo);
10216 Some(task_started.elapsed())
10217 });
10218 let wall = started.elapsed();
10219 let durations: Vec<Duration> = rx.into_iter().flatten().collect();
10220 (wall, durations)
10221 }
10222
10223 fn real_corpus_roots() -> Vec<PathBuf> {
10227 let Some(home) = std::env::var_os("HOME") else {
10228 return Vec::new();
10229 };
10230 let home = PathBuf::from(home);
10231 ["dev", "dev-misc"]
10232 .into_iter()
10233 .map(|leaf| home.join(leaf))
10234 .filter(|root| root.is_dir())
10235 .collect()
10236 }
10237
10238 fn generated_fixture_corpus(size: usize) -> tempfile::TempDir {
10243 let root = tempfile::tempdir().expect("temp dir for generated fixture corpus");
10244 for i in 0..size {
10245 let repo = root.path().join(format!("fixture-repo-{i}"));
10246 fs::create_dir_all(&repo).expect("create fixture repo dir");
10247 gix::init(&repo).expect("init fixture repo");
10248 let status = Command::new("git")
10249 .arg("-C")
10250 .arg(&repo)
10251 .args(["-c", "user.email=test@example.com", "-c", "user.name=Test"])
10252 .args(["commit", "--allow-empty", "-m", &format!("commit {i}")])
10253 .status()
10254 .expect("run git commit");
10255 assert!(status.success());
10256 }
10257 root
10258 }
10259
10260 fn percentile(sorted: &[Duration], p: usize) -> Duration {
10262 let index = (sorted.len() - 1) * p / 100;
10263 sorted[index]
10264 }
10265
10266 fn extra_excluded_names() -> Vec<String> {
10273 parse_excluded_names(&std::env::var("REPON_BENCHMARK_EXCLUDE_NAMES").unwrap_or_default())
10274 }
10275
10276 fn parse_excluded_names(raw: &str) -> Vec<String> {
10281 raw.split(',')
10282 .map(str::trim)
10283 .filter(|name| !name.is_empty())
10284 .map(str::to_string)
10285 .collect()
10286 }
10287
10288 fn discover_population(
10296 roots: Vec<PathBuf>,
10297 excluded_names: &[String],
10298 ) -> (Vec<crate::discovery::DiscoveredEntity>, Duration) {
10299 let set = SetSpec {
10300 name: "identity-probe-benchmark".to_string(),
10301 roots,
10302 include: Vec::new(),
10303 exclude: Vec::new(),
10304 };
10305 let started = Instant::now();
10306 let discovery = discovery::discover(&set);
10307 let (discovered, _) = discovery::resolve(&set, &discovery.entities);
10308 let elapsed = started.elapsed();
10309 let population = discovered
10310 .into_iter()
10311 .filter(|entity| {
10312 !entity.key.path().components().any(|component| {
10313 excluded_names
10314 .iter()
10315 .any(|name| component.as_os_str() == name.as_str())
10316 })
10317 })
10318 .collect();
10319 (population, elapsed)
10320 }
10321
10322 #[test]
10326 fn a_boundary_whose_path_matches_an_excluded_name_is_left_out_of_the_population() {
10327 let fixture = generated_fixture_corpus(3);
10328 let excluded = vec!["fixture-repo-1".to_string()];
10329
10330 let (population, _) = discover_population(vec![fixture.path().to_path_buf()], &excluded);
10331
10332 assert_eq!(population.len(), 2);
10333 assert!(
10334 population
10335 .iter()
10336 .all(|entity| entity.key.path().file_name().unwrap() != "fixture-repo-1"),
10337 "the excluded name must never appear in the population discovery returns"
10338 );
10339 }
10340
10341 #[test]
10342 fn excluded_names_parses_a_comma_separated_list_and_ignores_blanks() {
10343 assert_eq!(
10344 parse_excluded_names("foo, bar ,,baz"),
10345 vec!["foo".to_string(), "bar".to_string(), "baz".to_string()]
10346 );
10347 assert!(parse_excluded_names("").is_empty());
10348 assert!(parse_excluded_names(" ").is_empty());
10349 }
10350
10351 #[test]
10366 #[ignore = "hand-run against the owner's real corpus; see docs/spec/refresh.md for the recorded figures"]
10367 fn identity_probe_benchmark() {
10368 let excluded_names = extra_excluded_names();
10369
10370 let mut _fixture: Option<tempfile::TempDir> = None;
10374
10375 let (real_population, real_discovery_wall) =
10376 discover_population(real_corpus_roots(), &excluded_names);
10377 let (population, using_fixture, discovery_wall) = if real_population.len() >= 20 {
10378 (real_population, false, real_discovery_wall)
10379 } else {
10380 println!(
10381 "real corpus absent or too small to be meaningful ({} entities); \
10382 using a generated fixture instead",
10383 real_population.len()
10384 );
10385 let fixture = generated_fixture_corpus(300);
10386 let (population, fixture_discovery_wall) =
10387 discover_population(vec![fixture.path().to_path_buf()], &excluded_names);
10388 _fixture = Some(fixture);
10389 (population, true, fixture_discovery_wall)
10390 };
10391
10392 let population_size = population.len();
10393 assert!(
10394 population_size > 0,
10395 "neither a real corpus root nor the generated fixture produced any entities"
10396 );
10397
10398 let (wall, mut durations) = benchmark_identity_phase(population);
10399 durations.sort();
10400
10401 println!(
10402 "identity probe benchmark: corpus = {}, population = {population_size}",
10403 if using_fixture {
10404 "generated fixture"
10405 } else {
10406 "real corpus"
10407 }
10408 );
10409 println!(
10410 "discovery + first open (serial, every entity's own gix::open): {discovery_wall:?}"
10411 );
10412 println!("identity phase, warm, parallel (HEAD re-read from the cached handle): {wall:?}");
10413 println!(
10414 "identity phase per entity: p50 {:?}, p90 {:?}, max {:?}",
10415 percentile(&durations, 50),
10416 percentile(&durations, 90),
10417 durations.last().copied().unwrap_or_default(),
10418 );
10419 }
10420
10421 fn spec_with_overrides(roots: Vec<PathBuf>, overrides: Vec<RepoOverride>) -> CoreSpec {
10422 let mut spec = spec(roots);
10423 spec.overrides = overrides;
10424 spec
10425 }
10426
10427 #[test]
10432 fn a_per_repo_override_resolves_the_default_branch_at_rung_one_through_a_real_refresh() {
10433 let dir = tempfile::tempdir().expect("temp dir");
10434 let root = root_of(&dir);
10435 let repo = root.join("repo");
10436 init_repo_with_a_commit(&repo);
10437 git(
10438 &repo,
10439 &[
10440 "remote",
10441 "add",
10442 "origin",
10443 "https://example.invalid/repo.git",
10444 ],
10445 );
10446 let sha = head_sha(&repo);
10447 git(&repo, &["update-ref", "refs/remotes/origin/main", &sha]);
10448 let remote_refs_dir = repo
10449 .join(".git")
10450 .join("refs")
10451 .join("remotes")
10452 .join("origin");
10453 fs::create_dir_all(&remote_refs_dir).expect("create refs/remotes/origin dir");
10454 fs::write(
10455 remote_refs_dir.join("HEAD"),
10456 "ref: refs/remotes/origin/main\n",
10457 )
10458 .expect("write HEAD");
10459
10460 let core = Core::start_discovered(spec_with_overrides(
10461 vec![root],
10462 vec![RepoOverride {
10463 path: repo.clone(),
10464 default_branch: Some("develop".to_string()),
10465 excluded: false,
10466 }],
10467 ));
10468 let key = core.snapshot().entities[0].key.clone();
10469
10470 core.refresh(std::slice::from_ref(&key));
10471 let settled = core.settle();
10472 let entity = &settled.entities[0];
10473
10474 match entity.default_branch.settled() {
10475 Some(Settled::Known {
10476 value,
10477 at: _,
10478 stale: _,
10479 }) => assert_eq!(
10480 value.name(),
10481 "origin/develop",
10482 "the override must win even though origin/HEAD names a different branch"
10483 ),
10484 other => panic!("expected the override's own answer, got {other:?}"),
10485 }
10486 assert_eq!(
10487 entity.diagnostics.default_branch_rung,
10488 Some(1),
10489 "an override must be recorded as rung 1"
10490 );
10491 }
10492
10493 #[test]
10497 fn a_per_repo_override_also_resolves_through_probe_now() {
10498 let dir = tempfile::tempdir().expect("temp dir");
10499 let root = root_of(&dir);
10500 let repo = root.join("repo");
10501 init_repo_with_a_commit(&repo);
10502
10503 let core = Core::start_discovered(spec_with_overrides(
10504 vec![root],
10505 vec![RepoOverride {
10506 path: repo.clone(),
10507 default_branch: Some("release".to_string()),
10508 excluded: false,
10509 }],
10510 ));
10511 let key = core.snapshot().entities[0].key.clone();
10512
10513 let entity = core.probe_now(&key);
10514
10515 match entity.default_branch.settled() {
10516 Some(Settled::Known {
10518 value,
10519 at: _,
10520 stale: _,
10521 }) => assert_eq!(value.name(), "release"),
10522 other => panic!("expected the override's own answer, got {other:?}"),
10523 }
10524 assert_eq!(entity.diagnostics.default_branch_rung, Some(1));
10525 }
10526
10527 #[test]
10532 fn reaching_rung_four_with_no_remote_at_all_records_why() {
10533 let dir = tempfile::tempdir().expect("temp dir");
10534 let root = root_of(&dir);
10535 let repo = root.join("repo");
10536 init_repo_with_a_commit(&repo);
10537
10538 let core = Core::start_discovered(spec(vec![root]));
10539 let key = core.snapshot().entities[0].key.clone();
10540
10541 core.refresh(std::slice::from_ref(&key));
10542 let settled = core.settle();
10543 let entity = &settled.entities[0];
10544
10545 assert_eq!(entity.diagnostics.default_branch_rung, Some(4));
10546 assert_eq!(
10547 entity.diagnostics.default_branch_stopped,
10548 Some(DefaultBranchStopped::NoRemote)
10549 );
10550 }
10551
10552 #[test]
10553 fn reaching_rung_four_with_two_unnamed_remotes_records_why() {
10554 let dir = tempfile::tempdir().expect("temp dir");
10555 let root = root_of(&dir);
10556 let repo = root.join("repo");
10557 init_repo_with_a_commit(&repo);
10558 git(
10559 &repo,
10560 &[
10561 "remote",
10562 "add",
10563 "fork-one",
10564 "https://example.invalid/one.git",
10565 ],
10566 );
10567 git(
10568 &repo,
10569 &[
10570 "remote",
10571 "add",
10572 "fork-two",
10573 "https://example.invalid/two.git",
10574 ],
10575 );
10576
10577 let core = Core::start_discovered(spec(vec![root]));
10578 let key = core.snapshot().entities[0].key.clone();
10579
10580 core.refresh(std::slice::from_ref(&key));
10581 let settled = core.settle();
10582 let entity = &settled.entities[0];
10583
10584 assert_eq!(entity.diagnostics.default_branch_rung, Some(4));
10585 assert_eq!(
10586 entity.diagnostics.default_branch_stopped,
10587 Some(DefaultBranchStopped::AmbiguousRemote)
10588 );
10589 }
10590
10591 #[test]
10592 fn reaching_rung_four_with_a_chosen_remote_and_no_matching_ref_records_why() {
10593 let dir = tempfile::tempdir().expect("temp dir");
10594 let root = root_of(&dir);
10595 let repo = root.join("repo");
10596 init_repo_with_a_commit(&repo);
10597 git(
10598 &repo,
10599 &[
10600 "remote",
10601 "add",
10602 "origin",
10603 "https://example.invalid/repo.git",
10604 ],
10605 );
10606 let sha = head_sha(&repo);
10609 git(&repo, &["update-ref", "refs/remotes/origin/feature", &sha]);
10610
10611 let core = Core::start_discovered(spec(vec![root]));
10612 let key = core.snapshot().entities[0].key.clone();
10613
10614 core.refresh(std::slice::from_ref(&key));
10615 let settled = core.settle();
10616 let entity = &settled.entities[0];
10617
10618 assert_eq!(entity.diagnostics.default_branch_rung, Some(4));
10619 assert_eq!(
10620 entity.diagnostics.default_branch_stopped,
10621 Some(DefaultBranchStopped::NameListExhausted)
10622 );
10623 }
10624
10625 #[test]
10628 fn a_repo_with_nothing_to_resolve_settles_unknown_never_failed() {
10629 let dir = tempfile::tempdir().expect("temp dir");
10630 let root = root_of(&dir);
10631 let repo = root.join("repo");
10632 init_repo_with_a_commit(&repo);
10633
10634 let core = Core::start_discovered(spec(vec![root]));
10635 let key = core.snapshot().entities[0].key.clone();
10636
10637 core.refresh(std::slice::from_ref(&key));
10638 let settled = core.settle();
10639 let entity = &settled.entities[0];
10640
10641 assert!(matches!(
10642 entity.default_branch.settled(),
10643 Some(Settled::Unknown(Unknown::NoDefaultBranch))
10644 ));
10645 assert_eq!(entity.diagnostics.default_branch_rung, Some(4));
10646 }
10647
10648 #[test]
10654 fn a_stale_remote_head_is_recorded_in_diagnostics_through_a_real_refresh() {
10655 let dir = tempfile::tempdir().expect("temp dir");
10656 let root = root_of(&dir);
10657 let repo = root.join("repo");
10658 init_repo_with_a_commit(&repo);
10659 git(
10660 &repo,
10661 &[
10662 "remote",
10663 "add",
10664 "origin",
10665 "https://example.invalid/repo.git",
10666 ],
10667 );
10668 let sha = head_sha(&repo);
10669 git(&repo, &["update-ref", "refs/remotes/origin/trunk", &sha]);
10670 let remote_refs_dir = repo
10671 .join(".git")
10672 .join("refs")
10673 .join("remotes")
10674 .join("origin");
10675 fs::create_dir_all(&remote_refs_dir).expect("create refs/remotes/origin dir");
10676 fs::write(
10678 remote_refs_dir.join("HEAD"),
10679 "ref: refs/remotes/origin/main\n",
10680 )
10681 .expect("write HEAD");
10682
10683 let core = Core::start_discovered(spec(vec![root]));
10684 let key = core.snapshot().entities[0].key.clone();
10685
10686 core.refresh(std::slice::from_ref(&key));
10687 let settled = core.settle();
10688 let entity = &settled.entities[0];
10689
10690 match entity.default_branch.settled() {
10691 Some(Settled::Known {
10692 value,
10693 at: _,
10694 stale: _,
10695 }) => {
10696 assert_eq!(value.name(), "origin/trunk")
10697 }
10698 other => panic!("expected the name list's answer, got {other:?}"),
10699 }
10700 assert!(
10701 entity.diagnostics.default_branch_rung_two_stale,
10702 "a stale origin/HEAD target must be recorded on the entity's diagnostics"
10703 );
10704 }
10705
10706 #[test]
10709 fn a_resolvable_remote_head_is_not_recorded_as_stale() {
10710 let dir = tempfile::tempdir().expect("temp dir");
10711 let root = root_of(&dir);
10712 let repo = root.join("repo");
10713 init_repo_with_a_commit(&repo);
10714 git(
10715 &repo,
10716 &[
10717 "remote",
10718 "add",
10719 "origin",
10720 "https://example.invalid/repo.git",
10721 ],
10722 );
10723 let sha = head_sha(&repo);
10724 git(&repo, &["update-ref", "refs/remotes/origin/main", &sha]);
10725 let remote_refs_dir = repo
10726 .join(".git")
10727 .join("refs")
10728 .join("remotes")
10729 .join("origin");
10730 fs::create_dir_all(&remote_refs_dir).expect("create refs/remotes/origin dir");
10731 fs::write(
10732 remote_refs_dir.join("HEAD"),
10733 "ref: refs/remotes/origin/main\n",
10734 )
10735 .expect("write HEAD");
10736
10737 let core = Core::start_discovered(spec(vec![root]));
10738 let key = core.snapshot().entities[0].key.clone();
10739
10740 core.refresh(std::slice::from_ref(&key));
10741 let settled = core.settle();
10742 let entity = &settled.entities[0];
10743
10744 assert!(!entity.diagnostics.default_branch_rung_two_stale);
10745 }
10746
10747 #[test]
10752 fn one_override_on_a_repos_path_covers_a_worktree_sharing_its_common_dir() {
10753 let dir = tempfile::tempdir().expect("temp dir");
10754 let root = root_of(&dir);
10755 let parent = root.join("parent");
10756 init_repo_with_a_commit(&parent);
10757 let worktree = root.join("worktree");
10758 git(
10759 &parent,
10760 &[
10761 "worktree",
10762 "add",
10763 "-b",
10764 "feature",
10765 worktree.to_str().expect("utf8 path"),
10766 ],
10767 );
10768
10769 let core = Core::start_discovered(spec_with_overrides(
10770 vec![root],
10771 vec![RepoOverride {
10772 path: parent.clone(),
10773 default_branch: None,
10774 excluded: true,
10775 }],
10776 ));
10777 let snapshot = core.snapshot();
10778
10779 for entity in &snapshot.entities {
10780 assert!(
10781 entity.excluded,
10782 "both the Repo and its Worktree must inherit the entry declared on the Repo's own path, entity: {:?}",
10783 entity.key
10784 );
10785 }
10786 assert_eq!(
10787 snapshot.entities.len(),
10788 2,
10789 "expected the parent plus its worktree"
10790 );
10791 }
10792
10793 #[test]
10797 fn an_entry_naming_a_worktrees_own_path_beats_the_inherited_one() {
10798 let dir = tempfile::tempdir().expect("temp dir");
10799 let root = root_of(&dir);
10800 let parent = root.join("parent");
10801 init_repo_with_a_commit(&parent);
10802 let worktree_own = root.join("worktree-own");
10803 let worktree_inherits = root.join("worktree-inherits");
10804 git(
10805 &parent,
10806 &[
10807 "worktree",
10808 "add",
10809 "-b",
10810 "feature-own",
10811 worktree_own.to_str().expect("utf8 path"),
10812 ],
10813 );
10814 git(
10815 &parent,
10816 &[
10817 "worktree",
10818 "add",
10819 "-b",
10820 "feature-inherits",
10821 worktree_inherits.to_str().expect("utf8 path"),
10822 ],
10823 );
10824
10825 let core = Core::start_discovered(spec_with_overrides(
10826 vec![root],
10827 vec![
10828 RepoOverride {
10829 path: parent.clone(),
10830 default_branch: None,
10831 excluded: true,
10832 },
10833 RepoOverride {
10834 path: worktree_own.clone(),
10835 default_branch: None,
10836 excluded: false,
10837 },
10838 ],
10839 ));
10840 let snapshot = core.snapshot();
10841
10842 let find = |path: &Path| {
10843 snapshot
10844 .entities
10845 .iter()
10846 .find(|entity| entity.key.path() == path)
10847 .unwrap_or_else(|| panic!("entity at {path:?} present"))
10848 };
10849
10850 assert!(
10851 find(&parent).excluded,
10852 "the parent Repo has no entry of its own and inherits the excluding one"
10853 );
10854 assert!(
10855 !find(&worktree_own).excluded,
10856 "the Worktree named directly by its own path must use its own entry, not the inherited one"
10857 );
10858 assert!(
10859 find(&worktree_inherits).excluded,
10860 "a sibling Worktree with no entry of its own still inherits the Repo's entry"
10861 );
10862 }
10863
10864 #[test]
10869 fn an_override_on_the_parents_path_never_excludes_its_submodule() {
10870 let dir = tempfile::tempdir().expect("temp dir");
10871 let root = root_of(&dir);
10872 let parent = root.join("parent");
10873 init_repo_with_a_commit(&parent);
10874 fs::write(
10875 parent.join(".gitmodules"),
10876 "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
10877 )
10878 .expect("write .gitmodules");
10879 fs::create_dir_all(parent.join("vendor").join("lib")).expect("create submodule dir");
10880
10881 let core = Core::start_discovered(spec_with_overrides(
10882 vec![root],
10883 vec![RepoOverride {
10884 path: parent.clone(),
10885 default_branch: None,
10886 excluded: true,
10887 }],
10888 ));
10889 let snapshot = core.snapshot();
10890
10891 let submodule = snapshot
10892 .entities
10893 .iter()
10894 .find(|entity| matches!(entity.kind, Kind::Submodule))
10895 .expect("the submodule is still discovered and listed");
10896 assert!(
10897 !submodule.excluded,
10898 "an entry naming only the parent's path must never reach a Submodule, \
10899 whose own common dir differs from its parent's"
10900 );
10901 }
10902
10903 #[test]
10920 fn the_default_branch_chain_is_memoised_once_per_common_dir_per_generation() {
10921 let dir = tempfile::tempdir().expect("temp dir");
10922 let root = root_of(&dir);
10923 let parent = root.join("parent");
10924 init_repo_with_a_commit(&parent);
10925 for name in ["wt-a", "wt-b", "wt-c"] {
10926 let worktree = root.join(name);
10927 git(
10928 &parent,
10929 &[
10930 "worktree",
10931 "add",
10932 "-b",
10933 name,
10934 worktree.to_str().expect("utf8 path"),
10935 ],
10936 );
10937 }
10938 let other_repo = root.join("other");
10939 init_repo_with_a_commit(&other_repo);
10940
10941 let (core, launched) = started_and_settled(spec(vec![root]));
10942 let keys: Vec<EntityKey> = launched
10943 .entities
10944 .iter()
10945 .map(|entity| entity.key.clone())
10946 .collect();
10947 assert_eq!(
10948 keys.len(),
10949 5,
10950 "expected the parent, its three worktrees and the unrelated repo"
10951 );
10952
10953 core.refresh(&keys);
10954 core.settle();
10955
10956 assert_eq!(
10957 core.default_branch_chain_reads_for_test(),
10958 2,
10959 "four entities span exactly two common dirs; a memoised chain reads \
10960 each common dir once, not once per entity"
10961 );
10962
10963 core.refresh(&keys);
10967 core.settle();
10968 assert_eq!(
10969 core.default_branch_chain_reads_for_test(),
10970 2,
10971 "the memo lives inside one Generation's dispatch; the next Generation \
10972 recomputes rather than inheriting it"
10973 );
10974 }
10975
10976 #[test]
10986 fn patch_equivalence_is_memoised_once_per_common_dir_per_generation() {
10987 let dir = tempfile::tempdir().expect("temp dir");
10988 let root = root_of(&dir);
10989 let parent = root.join("parent");
10990 init_repo_with_a_commit(&parent);
10991 git(
10992 &parent,
10993 &[
10994 "remote",
10995 "add",
10996 "origin",
10997 "https://example.invalid/repo.git",
10998 ],
10999 );
11000 let base_sha = head_sha(&parent);
11001 git(
11002 &parent,
11003 &["update-ref", "refs/remotes/origin/main", &base_sha],
11004 );
11005 for name in ["feature-x", "feature-y"] {
11006 let worktree = root.join(name);
11007 git(
11008 &parent,
11009 &[
11010 "worktree",
11011 "add",
11012 "-b",
11013 name,
11014 worktree.to_str().expect("utf8 path"),
11015 ],
11016 );
11017 fs::write(worktree.join(format!("{name}.txt")), "unmerged\n")
11018 .expect("write worktree file");
11019 git(&worktree, &["add", "."]);
11020 git(&worktree, &["commit", "-m", "unmerged work"]);
11021 let tip_sha = head_sha(&worktree);
11022 git(
11023 &parent,
11024 &["config", &format!("branch.{name}.remote"), "origin"],
11025 );
11026 git(
11027 &parent,
11028 &[
11029 "config",
11030 &format!("branch.{name}.merge"),
11031 &format!("refs/heads/{name}"),
11032 ],
11033 );
11034 git(
11035 &parent,
11036 &[
11037 "update-ref",
11038 &format!("refs/remotes/origin/{name}"),
11039 &tip_sha,
11040 ],
11041 );
11042 }
11043
11044 let other_parent = root.join("other");
11045 init_repo_with_a_commit(&other_parent);
11046 git(
11047 &other_parent,
11048 &[
11049 "remote",
11050 "add",
11051 "origin",
11052 "https://example.invalid/other.git",
11053 ],
11054 );
11055 let other_base_sha = head_sha(&other_parent);
11056 git(
11057 &other_parent,
11058 &["update-ref", "refs/remotes/origin/main", &other_base_sha],
11059 );
11060 let other_worktree = root.join("other-feature");
11061 git(
11062 &other_parent,
11063 &[
11064 "worktree",
11065 "add",
11066 "-b",
11067 "other-feature",
11068 other_worktree.to_str().expect("utf8 path"),
11069 ],
11070 );
11071 fs::write(other_worktree.join("other.txt"), "unmerged\n").expect("write worktree file");
11072 git(&other_worktree, &["add", "."]);
11073 git(&other_worktree, &["commit", "-m", "unmerged work"]);
11074 let other_tip_sha = head_sha(&other_worktree);
11075 git(
11076 &other_parent,
11077 &["config", "branch.other-feature.remote", "origin"],
11078 );
11079 git(
11080 &other_parent,
11081 &[
11082 "config",
11083 "branch.other-feature.merge",
11084 "refs/heads/other-feature",
11085 ],
11086 );
11087 git(
11088 &other_parent,
11089 &[
11090 "update-ref",
11091 "refs/remotes/origin/other-feature",
11092 &other_tip_sha,
11093 ],
11094 );
11095
11096 let (core, launched) = started_and_settled(spec(vec![root]));
11097 let keys: Vec<EntityKey> = launched
11098 .entities
11099 .iter()
11100 .map(|entity| entity.key.clone())
11101 .collect();
11102 assert_eq!(
11103 keys.len(),
11104 5,
11105 "expected two parents plus their three worktrees"
11106 );
11107
11108 core.refresh(&keys);
11109 let settled = core.settle();
11110
11111 let worktree_states: Vec<_> = settled
11112 .entities
11113 .iter()
11114 .filter(|entity| matches!(entity.kind, Kind::Worktree))
11115 .map(|entity| entity.state.settled())
11116 .collect();
11117 assert_eq!(worktree_states.len(), 3, "expected three worktree rows");
11118 for settled_state in &worktree_states {
11119 assert!(
11120 matches!(
11121 settled_state,
11122 Some(Settled::Known {
11123 value: WorktreeState::Active,
11124 at: _,
11125 stale: _
11126 })
11127 ),
11128 "expected every worktree's genuinely unmerged work to settle Active, got {settled_state:?}"
11129 );
11130 }
11131
11132 assert_eq!(
11133 core.patch_identity_reads_for_test(),
11134 2,
11135 "two worktrees share one common dir and must scan its default-branch \
11136 history once between them, not once per entity; the unrelated repo's \
11137 own worktree pays for a second scan"
11138 );
11139
11140 core.refresh(&keys);
11143 core.settle();
11144 assert_eq!(
11145 core.patch_identity_reads_for_test(),
11146 2,
11147 "the memo lives inside one Generation's dispatch; the next Generation \
11148 recomputes rather than inheriting it"
11149 );
11150 }
11151
11152 #[test]
11171 fn an_entity_whose_merge_base_is_deeper_than_its_siblings_widens_the_shared_scan() {
11172 let dir = tempfile::tempdir().expect("temp dir");
11173 let root = root_of(&dir);
11174 let parent = root.join("parent");
11175 init_repo_with_a_commit(&parent);
11176 git(
11177 &parent,
11178 &[
11179 "remote",
11180 "add",
11181 "origin",
11182 "https://example.invalid/repo.git",
11183 ],
11184 );
11185 let deep_fork_sha = head_sha(&parent);
11186
11187 git(&parent, &["branch", "feature-deep"]);
11188 let deep_worktree = root.join("feature-deep");
11189 git(
11190 &parent,
11191 &[
11192 "worktree",
11193 "add",
11194 deep_worktree.to_str().expect("utf8 path"),
11195 "feature-deep",
11196 ],
11197 );
11198 fs::write(deep_worktree.join("deep.txt"), "deep work\n").expect("write deep.txt");
11199 git(&deep_worktree, &["add", "."]);
11200 git(&deep_worktree, &["commit", "-m", "deep work"]);
11201 let deep_tip_sha = head_sha(&deep_worktree);
11202
11203 git(&parent, &["merge", "--squash", "feature-deep"]);
11204 git(&parent, &["commit", "-m", "squashed deep"]);
11205 let shallow_fork_sha = head_sha(&parent);
11206
11207 git(&parent, &["branch", "feature-shallow"]);
11208 let shallow_worktree = root.join("feature-shallow");
11209 git(
11210 &parent,
11211 &[
11212 "worktree",
11213 "add",
11214 shallow_worktree.to_str().expect("utf8 path"),
11215 "feature-shallow",
11216 ],
11217 );
11218 fs::write(shallow_worktree.join("shallow.txt"), "shallow work\n")
11219 .expect("write shallow.txt");
11220 git(&shallow_worktree, &["add", "."]);
11221 git(&shallow_worktree, &["commit", "-m", "shallow work"]);
11222 let shallow_tip_sha = head_sha(&shallow_worktree);
11223
11224 git(&parent, &["merge", "--squash", "feature-shallow"]);
11225 git(&parent, &["commit", "-m", "squashed shallow"]);
11226 let main_tip_sha = head_sha(&parent);
11227 assert_ne!(
11228 deep_fork_sha, shallow_fork_sha,
11229 "the two siblings must fork at genuinely different commits"
11230 );
11231
11232 git(
11233 &parent,
11234 &["update-ref", "refs/remotes/origin/main", &main_tip_sha],
11235 );
11236 for (name, tip_sha) in [
11237 ("feature-deep", &deep_tip_sha),
11238 ("feature-shallow", &shallow_tip_sha),
11239 ] {
11240 git(
11241 &parent,
11242 &["config", &format!("branch.{name}.remote"), "origin"],
11243 );
11244 git(
11245 &parent,
11246 &[
11247 "config",
11248 &format!("branch.{name}.merge"),
11249 &format!("refs/heads/{name}"),
11250 ],
11251 );
11252 git(
11253 &parent,
11254 &[
11255 "update-ref",
11256 &format!("refs/remotes/origin/{name}"),
11257 tip_sha,
11258 ],
11259 );
11260 }
11261
11262 let (core, snapshot) = started_and_settled(spec(vec![root]));
11263 let deep_key = snapshot
11264 .entities
11265 .iter()
11266 .find(|entity| entity.key.path() == deep_worktree)
11267 .expect("feature-deep worktree discovered")
11268 .key
11269 .clone();
11270 let shallow_key = snapshot
11271 .entities
11272 .iter()
11273 .find(|entity| entity.key.path() == shallow_worktree)
11274 .expect("feature-shallow worktree discovered")
11275 .key
11276 .clone();
11277 let parent_key = snapshot
11278 .entities
11279 .iter()
11280 .find(|entity| entity.key.path() == parent)
11281 .expect("parent repo discovered")
11282 .key
11283 .clone();
11284 let order = vec![parent_key, shallow_key.clone(), deep_key.clone()];
11288
11289 core.refresh(&order);
11290 let settled = core.settle();
11291
11292 let state_of = |key: &EntityKey| {
11293 settled
11294 .entities
11295 .iter()
11296 .find(|entity| &entity.key == key)
11297 .and_then(|entity| entity.state.settled())
11298 .cloned()
11299 };
11300 assert!(
11301 matches!(
11302 state_of(&deep_key),
11303 Some(Settled::Known {
11304 value: WorktreeState::Merged,
11305 at: _,
11306 stale: _
11307 })
11308 ),
11309 "expected the deepest sibling's own squash commit to be found once the scan is \
11310 bounded by the deepest merge base, got {:?}",
11311 state_of(&deep_key)
11312 );
11313 assert!(
11314 matches!(
11315 state_of(&shallow_key),
11316 Some(Settled::Known {
11317 value: WorktreeState::Merged,
11318 at: _,
11319 stale: _
11320 })
11321 ),
11322 "expected the shallow sibling to settle Merged too, got {:?}",
11323 state_of(&shallow_key)
11324 );
11325 assert_eq!(
11326 core.patch_identity_reads_for_test(),
11327 1,
11328 "both worktrees share one common dir and must still scan its default-branch \
11329 history once between them, not once per entity"
11330 );
11331 assert_eq!(
11332 core.patch_scan_bounds_for_test(),
11333 vec![Some(id(&deep_fork_sha))],
11334 "the one shared scan that ran must have been bounded by the deepest sibling's own \
11335 merge base, not the shallower one's"
11336 );
11337 }
11338
11339 fn id(sha: &str) -> gix::ObjectId {
11340 gix::ObjectId::from_hex(sha.as_bytes()).expect("parse sha")
11341 }
11342
11343 #[test]
11350 fn bound_gate_deepest_folds_every_candidate_regardless_of_report_order() {
11351 let dir = tempfile::tempdir().expect("temp dir");
11352 let repo_path = root_of(&dir).join("repo");
11353 init_repo_with_a_commit(&repo_path);
11354 let deep_sha = id(&head_sha(&repo_path));
11355 fs::write(repo_path.join("child.txt"), "child\n").expect("write child.txt");
11356 git(&repo_path, &["add", "."]);
11357 git(&repo_path, &["commit", "-m", "child of deep"]);
11358 let shallow_sha = id(&head_sha(&repo_path));
11359
11360 let repo = gix::open(&repo_path).expect("open repo");
11361 let gate = BoundGate::new(2);
11362 gate.report(Some(shallow_sha));
11363 gate.report(Some(deep_sha));
11364
11365 assert_eq!(
11366 gate.deepest(&repo),
11367 Some(deep_sha),
11368 "the deepest candidate must win even though the shallower one reported first"
11369 );
11370 }
11371
11372 #[test]
11389 fn probe_patch_equivalence_bounds_the_scan_by_the_gates_deepest_not_its_own_merge_base() {
11390 let dir = tempfile::tempdir().expect("temp dir");
11391 let repo_path = root_of(&dir).join("repo");
11392 init_repo_with_a_commit(&repo_path);
11393 let deep_sha = id(&head_sha(&repo_path));
11394 fs::write(repo_path.join("child.txt"), "child\n").expect("write child.txt");
11395 git(&repo_path, &["add", "."]);
11396 git(&repo_path, &["commit", "-m", "child of deep"]);
11397 let shallow_sha_hex = head_sha(&repo_path);
11398 let shallow_sha = id(&shallow_sha_hex);
11399 fs::write(repo_path.join("tip.txt"), "tip\n").expect("write tip.txt");
11400 git(&repo_path, &["add", "."]);
11401 git(&repo_path, &["commit", "-m", "default tip"]);
11402 let default_tip_hex = head_sha(&repo_path);
11403
11404 let repo = gix::open(&repo_path).expect("open repo");
11405 let outstanding = landing::Outstanding {
11408 entity_tip: shallow_sha,
11409 default_tip: id(&default_tip_hex),
11410 merge_base: Some(shallow_sha),
11411 };
11412 let common_dir: Arc<Path> = Arc::from(repo_path.join(".git"));
11413 let cancel = AtomicBool::new(false);
11414 let patch_cache: PatchIdentityCache = Mutex::new(HashMap::new());
11415 let patch_reads = AtomicUsize::new(0);
11416 let patch_scan_bounds: Mutex<Vec<Option<gix::ObjectId>>> = Mutex::new(Vec::new());
11417 let memo = PatchEquivalenceMemo {
11418 cache: &patch_cache,
11419 reads: &patch_reads,
11420 scan_bounds: &patch_scan_bounds,
11421 };
11422 let gate = BoundGate::new(2);
11426 gate.report(Some(deep_sha));
11427 let mut report = GateReport::new(&gate);
11428
11429 probe_patch_equivalence(
11430 &repo,
11431 &outstanding,
11432 &common_dir,
11433 &cancel,
11434 &memo,
11435 &mut report,
11436 );
11437
11438 assert_eq!(
11439 patch_scan_bounds.lock().unwrap().as_slice(),
11440 [Some(deep_sha)],
11441 "the scan must be bounded by the deepest sibling's merge base, not shallow's own \
11442 ({shallow_sha:?})"
11443 );
11444 }
11445
11446 #[test]
11454 fn probe_patch_equivalence_diffs_from_the_merge_base_it_was_handed() {
11455 let dir = tempfile::tempdir().expect("temp dir");
11456 let repo_path = root_of(&dir).join("repo");
11457 init_repo_with_a_commit(&repo_path);
11458 let fork_point_hex = head_sha(&repo_path);
11459 git(&repo_path, &["checkout", "-b", "feature"]);
11460 fs::write(repo_path.join("a.txt"), "one\n").expect("write a.txt");
11461 git(&repo_path, &["add", "a.txt"]);
11462 git(&repo_path, &["commit", "-m", "add a"]);
11463 let mid_sha = id(&head_sha(&repo_path));
11464 fs::write(repo_path.join("b.txt"), "two\n").expect("write b.txt");
11465 git(&repo_path, &["add", "b.txt"]);
11466 git(&repo_path, &["commit", "-m", "add b"]);
11467 let feature_sha = id(&head_sha(&repo_path));
11468 git(&repo_path, &["checkout", "-B", "main", &fork_point_hex]);
11469 git(&repo_path, &["merge", "--squash", "feature"]);
11470 git(&repo_path, &["commit", "-m", "squashed feature"]);
11471 let main_sha = id(&head_sha(&repo_path));
11472
11473 let repo = gix::open(&repo_path).expect("open repo");
11474 let outstanding = landing::Outstanding {
11477 entity_tip: feature_sha,
11478 default_tip: main_sha,
11479 merge_base: Some(mid_sha),
11480 };
11481 let common_dir: Arc<Path> = Arc::from(repo_path.join(".git"));
11482 let cancel = AtomicBool::new(false);
11483 let patch_cache: PatchIdentityCache = Mutex::new(HashMap::new());
11484 let patch_reads = AtomicUsize::new(0);
11485 let patch_scan_bounds: Mutex<Vec<Option<gix::ObjectId>>> = Mutex::new(Vec::new());
11486 let memo = PatchEquivalenceMemo {
11487 cache: &patch_cache,
11488 reads: &patch_reads,
11489 scan_bounds: &patch_scan_bounds,
11490 };
11491 let gate = BoundGate::new(1);
11492 let mut report = GateReport::new(&gate);
11493
11494 let settled = probe_patch_equivalence(
11495 &repo,
11496 &outstanding,
11497 &common_dir,
11498 &cancel,
11499 &memo,
11500 &mut report,
11501 );
11502
11503 assert!(
11504 matches!(
11505 settled,
11506 Some(Settled::Known {
11507 value: WorktreeState::Active,
11508 at: _,
11509 stale: _
11510 })
11511 ),
11512 "the range must be measured from the handed-in base ({mid_sha:?}), whose only \
11513 change the squash commit does not match, got {settled:?}"
11514 );
11515 }
11516
11517 #[test]
11524 fn bound_gate_deepest_with_no_candidates_leaves_the_scan_unbounded() {
11525 let dir = tempfile::tempdir().expect("temp dir");
11526 let repo_path = root_of(&dir).join("repo");
11527 gix::init(&repo_path).expect("init repo");
11528 let repo = gix::open(&repo_path).expect("open repo");
11529
11530 let gate = BoundGate::new(2);
11531 gate.report(None);
11532 gate.report(None);
11533
11534 assert_eq!(
11535 gate.deepest(&repo),
11536 None,
11537 "no contributed candidate must leave the scan unbounded"
11538 );
11539 }
11540
11541 #[test]
11551 fn an_outstanding_entity_with_no_shared_history_settles_active_without_the_shared_scan() {
11552 let dir = tempfile::tempdir().expect("temp dir");
11553 let root = root_of(&dir);
11554 let parent = root.join("parent");
11555 init_repo_with_a_commit(&parent);
11556 git(&parent, &["branch", "-M", "main"]);
11557 git(
11558 &parent,
11559 &[
11560 "remote",
11561 "add",
11562 "origin",
11563 "https://example.invalid/repo.git",
11564 ],
11565 );
11566 let main_sha = head_sha(&parent);
11567 git(
11568 &parent,
11569 &["update-ref", "refs/remotes/origin/main", &main_sha],
11570 );
11571
11572 git(&parent, &["checkout", "--orphan", "unrelated"]);
11573 git(
11574 &parent,
11575 &["commit", "--allow-empty", "-m", "unrelated root"],
11576 );
11577 let unrelated_sha = head_sha(&parent);
11578 git(&parent, &["checkout", "main"]);
11579
11580 let worktree = root.join("unrelated");
11581 git(
11582 &parent,
11583 &[
11584 "worktree",
11585 "add",
11586 worktree.to_str().expect("utf8 path"),
11587 "unrelated",
11588 ],
11589 );
11590 git(&parent, &["config", "branch.unrelated.remote", "origin"]);
11591 git(
11592 &parent,
11593 &["config", "branch.unrelated.merge", "refs/heads/unrelated"],
11594 );
11595 git(
11596 &parent,
11597 &[
11598 "update-ref",
11599 "refs/remotes/origin/unrelated",
11600 &unrelated_sha,
11601 ],
11602 );
11603
11604 let (core, snapshot) = started_and_settled(spec(vec![root]));
11605 let worktree_key = snapshot
11606 .entities
11607 .iter()
11608 .find(|entity| entity.key.path() == worktree)
11609 .expect("unrelated worktree discovered")
11610 .key
11611 .clone();
11612
11613 core.refresh(std::slice::from_ref(&worktree_key));
11614 let settled = core.settle();
11615
11616 let state = settled
11617 .entities
11618 .iter()
11619 .find(|entity| entity.key == worktree_key)
11620 .and_then(|entity| entity.state.settled())
11621 .cloned();
11622 assert!(
11623 matches!(
11624 state,
11625 Some(Settled::Known {
11626 value: WorktreeState::Active,
11627 at: _,
11628 stale: _
11629 })
11630 ),
11631 "expected an Outstanding entity with no shared history to settle Active via the \
11632 bypass, got {state:?}"
11633 );
11634 assert_eq!(
11635 core.patch_identity_reads_for_test(),
11636 0,
11637 "the bypass must settle without ever running the shared scan"
11638 );
11639 }
11640
11641 fn add_origin_remote(path: &Path) {
11646 git(
11647 path,
11648 &[
11649 "remote",
11650 "add",
11651 "origin",
11652 "https://example.invalid/repo.git",
11653 ],
11654 );
11655 }
11656
11657 fn set_upstream(path: &Path, branch: &str, upstream_sha: &str) {
11661 git(
11662 path,
11663 &["config", &format!("branch.{branch}.remote"), "origin"],
11664 );
11665 git(
11666 path,
11667 &[
11668 "config",
11669 &format!("branch.{branch}.merge"),
11670 &format!("refs/heads/{branch}"),
11671 ],
11672 );
11673 git(
11674 path,
11675 &[
11676 "update-ref",
11677 &format!("refs/remotes/origin/{branch}"),
11678 upstream_sha,
11679 ],
11680 );
11681 }
11682
11683 fn refresh_and_settle(core: &Core) -> crate::snapshot::Snapshot {
11684 let keys: Vec<EntityKey> = core
11685 .snapshot()
11686 .entities
11687 .iter()
11688 .map(|entity| entity.key.clone())
11689 .collect();
11690 core.refresh(&keys);
11691 core.settle()
11692 }
11693
11694 fn sync_of<'a>(
11695 snapshot: &'a crate::snapshot::Snapshot,
11696 path: &Path,
11697 ) -> Option<&'a Settled<SyncState>> {
11698 snapshot
11699 .entities
11700 .iter()
11701 .find(|entity| entity.key.path() == path)
11702 .unwrap_or_else(|| panic!("no entity for {}", path.display()))
11703 .sync
11704 .settled()
11705 }
11706
11707 #[test]
11709 fn an_attached_branch_ahead_of_its_upstream_reads_the_ahead_count() {
11710 let dir = tempfile::tempdir().expect("temp dir");
11711 let root = root_of(&dir);
11712 let repo = root.join("repo");
11713 init_repo_with_a_commit(&repo);
11714 let fork_sha = head_sha(&repo);
11715 add_origin_remote(&repo);
11716 set_upstream(&repo, "main", &fork_sha);
11717 git(&repo, &["commit", "--allow-empty", "-m", "local work"]);
11718
11719 let core = Core::start_discovered(spec(vec![root]));
11720 let settled = refresh_and_settle(&core);
11721
11722 match sync_of(&settled, &repo) {
11723 Some(Settled::Known {
11724 value: SyncState::Tracking(AheadBehind { ahead, behind }),
11725 at: _,
11726 stale: _,
11727 }) => {
11728 assert_eq!(*ahead, 1);
11729 assert_eq!(*behind, 0);
11730 }
11731 other => panic!("expected 1 ahead, 0 behind, got {other:?}"),
11732 }
11733 }
11734
11735 #[test]
11737 fn an_attached_branch_behind_its_upstream_reads_the_behind_count() {
11738 let dir = tempfile::tempdir().expect("temp dir");
11739 let root = root_of(&dir);
11740 let repo = root.join("repo");
11741 init_repo_with_a_commit(&repo);
11742 git(&repo, &["checkout", "-b", "temp"]);
11743 git(&repo, &["commit", "--allow-empty", "-m", "upstream work"]);
11744 let upstream_sha = head_sha(&repo);
11745 git(&repo, &["checkout", "main"]);
11746 git(&repo, &["branch", "-D", "temp"]);
11747 add_origin_remote(&repo);
11748 set_upstream(&repo, "main", &upstream_sha);
11749
11750 let core = Core::start_discovered(spec(vec![root]));
11751 let settled = refresh_and_settle(&core);
11752
11753 match sync_of(&settled, &repo) {
11754 Some(Settled::Known {
11755 value: SyncState::Tracking(AheadBehind { ahead, behind }),
11756 at: _,
11757 stale: _,
11758 }) => {
11759 assert_eq!(*ahead, 0);
11760 assert_eq!(*behind, 1);
11761 }
11762 other => panic!("expected 0 ahead, 1 behind, got {other:?}"),
11763 }
11764 }
11765
11766 #[test]
11768 fn an_attached_branch_level_with_its_upstream_reads_in_sync() {
11769 let dir = tempfile::tempdir().expect("temp dir");
11770 let root = root_of(&dir);
11771 let repo = root.join("repo");
11772 init_repo_with_a_commit(&repo);
11773 let sha = head_sha(&repo);
11774 add_origin_remote(&repo);
11775 set_upstream(&repo, "main", &sha);
11776
11777 let core = Core::start_discovered(spec(vec![root]));
11778 let settled = refresh_and_settle(&core);
11779
11780 match sync_of(&settled, &repo) {
11781 Some(Settled::Known {
11782 value:
11783 SyncState::Tracking(AheadBehind {
11784 ahead: 0,
11785 behind: 0,
11786 }),
11787 at: _,
11788 stale: _,
11789 }) => {}
11790 other => panic!("expected level with its upstream, got {other:?}"),
11791 }
11792 }
11793
11794 #[test]
11798 fn an_attached_branch_tracking_nothing_reads_no_upstream() {
11799 let dir = tempfile::tempdir().expect("temp dir");
11800 let root = root_of(&dir);
11801 let repo = root.join("repo");
11802 init_repo_with_a_commit(&repo);
11803 add_origin_remote(&repo);
11804
11805 let core = Core::start_discovered(spec(vec![root]));
11806 let settled = refresh_and_settle(&core);
11807
11808 match sync_of(&settled, &repo) {
11809 Some(Settled::Known {
11810 value: SyncState::NoUpstream,
11811 at: _,
11812 stale: _,
11813 }) => {}
11814 other => panic!("expected no upstream configured, got {other:?}"),
11815 }
11816 }
11817
11818 #[test]
11821 fn a_detached_row_reads_no_upstream() {
11822 let dir = tempfile::tempdir().expect("temp dir");
11823 let root = root_of(&dir);
11824 let repo = root.join("repo");
11825 init_repo_with_a_commit(&repo);
11826 let first_sha = head_sha(&repo);
11827 git(&repo, &["commit", "--allow-empty", "-m", "second"]);
11828 git(&repo, &["checkout", "--detach", &first_sha]);
11829 add_origin_remote(&repo);
11830
11831 let core = Core::start_discovered(spec(vec![root]));
11832 let settled = refresh_and_settle(&core);
11833
11834 match sync_of(&settled, &repo) {
11835 Some(Settled::Known {
11836 value: SyncState::NoUpstream,
11837 at: _,
11838 stale: _,
11839 }) => {}
11840 other => panic!("expected a detached row to read no upstream, got {other:?}"),
11841 }
11842 }
11843
11844 #[test]
11849 fn a_repo_with_no_remote_reads_no_remote_on_itself_and_every_worktree() {
11850 let dir = tempfile::tempdir().expect("temp dir");
11851 let root = root_of(&dir);
11852 let parent = root.join("parent");
11853 init_repo_with_a_commit(&parent);
11854 let worktree = root.join("feature");
11855 git(
11856 &parent,
11857 &[
11858 "worktree",
11859 "add",
11860 "-b",
11861 "feature",
11862 worktree.to_str().expect("utf8 path"),
11863 ],
11864 );
11865
11866 let core = Core::start_discovered(spec(vec![root]));
11867 let settled = refresh_and_settle(&core);
11868
11869 assert_eq!(
11870 settled.entities.len(),
11871 2,
11872 "expected the parent Repo and its one linked Worktree"
11873 );
11874 for path in [&parent, &worktree] {
11875 match sync_of(&settled, path) {
11876 Some(Settled::Known {
11877 value: SyncState::NoRemote,
11878 at: _,
11879 stale: _,
11880 }) => {}
11881 other => panic!(
11882 "expected {} to read no remote at all, got {other:?}",
11883 path.display()
11884 ),
11885 }
11886 }
11887 }
11888
11889 #[test]
11895 fn sync_is_computed_for_every_entity_dispatched_this_generation_not_only_one() {
11896 let dir = tempfile::tempdir().expect("temp dir");
11897 let root = root_of(&dir);
11898 let parent = root.join("parent");
11899 init_repo_with_a_commit(&parent);
11900 let fork_sha = head_sha(&parent);
11901 add_origin_remote(&parent);
11902
11903 let ahead_worktree = root.join("feature-ahead");
11904 git(
11905 &parent,
11906 &[
11907 "worktree",
11908 "add",
11909 "-b",
11910 "feature-ahead",
11911 ahead_worktree.to_str().expect("utf8 path"),
11912 ],
11913 );
11914 set_upstream(&parent, "feature-ahead", &fork_sha);
11915 git(
11916 &ahead_worktree,
11917 &["commit", "--allow-empty", "-m", "unpushed"],
11918 );
11919
11920 let behind_worktree = root.join("feature-behind");
11921 git(
11922 &parent,
11923 &[
11924 "worktree",
11925 "add",
11926 "-b",
11927 "feature-behind",
11928 behind_worktree.to_str().expect("utf8 path"),
11929 ],
11930 );
11931 git(
11932 &behind_worktree,
11933 &["commit", "--allow-empty", "-m", "on the remote only"],
11934 );
11935 let ahead_of_behind_sha = head_sha(&behind_worktree);
11936 git(&behind_worktree, &["reset", "--hard", "HEAD~1"]);
11937 set_upstream(&parent, "feature-behind", &ahead_of_behind_sha);
11938
11939 let core = Core::start_discovered(spec(vec![root]));
11940 let settled = refresh_and_settle(&core);
11941
11942 match sync_of(&settled, &ahead_worktree) {
11943 Some(Settled::Known {
11944 value:
11945 SyncState::Tracking(AheadBehind {
11946 ahead: 1,
11947 behind: 0,
11948 }),
11949 at: _,
11950 stale: _,
11951 }) => {}
11952 other => panic!("expected feature-ahead to read 1 ahead, got {other:?}"),
11953 }
11954 match sync_of(&settled, &behind_worktree) {
11955 Some(Settled::Known {
11956 value:
11957 SyncState::Tracking(AheadBehind {
11958 ahead: 0,
11959 behind: 1,
11960 }),
11961 at: _,
11962 stale: _,
11963 }) => {}
11964 other => panic!("expected feature-behind to read 1 behind, got {other:?}"),
11965 }
11966 }
11967
11968 #[test]
11974 fn sync_recomputes_on_a_second_generation_not_only_the_first() {
11975 let dir = tempfile::tempdir().expect("temp dir");
11976 let root = root_of(&dir);
11977 let repo = root.join("repo");
11978 init_repo_with_a_commit(&repo);
11979 let fork_sha = head_sha(&repo);
11980 add_origin_remote(&repo);
11981 set_upstream(&repo, "main", &fork_sha);
11982
11983 let core = Core::start_discovered(spec(vec![root]));
11984 let first = refresh_and_settle(&core);
11985 match sync_of(&first, &repo) {
11986 Some(Settled::Known {
11987 value:
11988 SyncState::Tracking(AheadBehind {
11989 ahead: 0,
11990 behind: 0,
11991 }),
11992 at: _,
11993 stale: _,
11994 }) => {}
11995 other => panic!("expected the first Generation level with its upstream, got {other:?}"),
11996 }
11997
11998 git(
11999 &repo,
12000 &[
12001 "commit",
12002 "--allow-empty",
12003 "-m",
12004 "second Generation's own work",
12005 ],
12006 );
12007 let second = refresh_and_settle(&core);
12008 match sync_of(&second, &repo) {
12009 Some(Settled::Known {
12010 value:
12011 SyncState::Tracking(AheadBehind {
12012 ahead: 1,
12013 behind: 0,
12014 }),
12015 at: _,
12016 stale: _,
12017 }) => {}
12018 other => panic!(
12019 "expected the second Generation to recompute and read 1 ahead, got {other:?}"
12020 ),
12021 }
12022 }
12023
12024 #[test]
12040 fn worktrees_now_behind_a_moved_default_branch_are_reported_by_name() {
12041 let dir = tempfile::tempdir().expect("temp dir");
12042 let root = root_of(&dir);
12043 let repo = root.join("repo");
12044 init_repo_with_a_commit(&repo);
12045 let sha_a = head_sha(&repo);
12046 add_origin_remote(&repo);
12047 set_upstream(&repo, "main", &sha_a);
12048
12049 let behind_path = root.join("wt-behind");
12050 git(
12051 &repo,
12052 &[
12053 "worktree",
12054 "add",
12055 "-b",
12056 "topic-behind",
12057 behind_path.to_str().expect("utf8 path"),
12058 "main",
12059 ],
12060 );
12061
12062 git(&repo, &["checkout", "-b", "scratch"]);
12067 git(&repo, &["commit", "--allow-empty", "-m", "second"]);
12068 let sha_b = head_sha(&repo);
12069 git(&repo, &["checkout", "main"]);
12070 git(&repo, &["update-ref", "refs/remotes/origin/main", &sha_b]);
12071 git(&repo, &["branch", "-D", "scratch"]);
12072
12073 let caught_up_path = root.join("wt-caught-up");
12079 git(
12080 &repo,
12081 &[
12082 "worktree",
12083 "add",
12084 "-b",
12085 "topic-caught-up",
12086 caught_up_path.to_str().expect("utf8 path"),
12087 &sha_b,
12088 ],
12089 );
12090
12091 let core = Core::start_discovered(spec(vec![root]));
12092 let snapshot = refresh_and_settle(&core);
12093
12094 let base_of = |name: &str| -> u32 {
12095 let entity = snapshot
12096 .entities
12097 .iter()
12098 .find(|entity| &*entity.name == name)
12099 .unwrap_or_else(|| panic!("no entity named {name} in {snapshot:?}"));
12100 match entity.base.settled() {
12101 Some(Settled::Known {
12102 value,
12103 at: _,
12104 stale: _,
12105 }) => *value,
12106 other => panic!("expected a known base count for {name}, got {other:?}"),
12107 }
12108 };
12109
12110 assert!(
12111 base_of("wt-behind") > 0,
12112 "a Worktree branched before the default branch moved must be reported behind"
12113 );
12114 assert_eq!(
12115 base_of("wt-caught-up"),
12116 0,
12117 "a Worktree branched from the new tip must not be reported behind"
12118 );
12119 }
12120
12121 mod fetch_scheduler {
12127 use super::*;
12128 use crate::liveness::wait_for_or;
12129
12130 fn fetch_spec(enabled: bool, root: PathBuf) -> CoreSpec {
12131 let mut spec = spec(vec![root]);
12132 spec.fetch = FetchSpec {
12133 enabled,
12134 interval: Duration::from_secs(3600),
12135 concurrency: 4,
12136 };
12137 spec
12138 }
12139
12140 fn seeded_remote() -> tempfile::TempDir {
12143 let remote = tempfile::tempdir().expect("temp dir");
12144 crate::test_support::init_bare(remote.path());
12145 crate::test_support::push_new_commit(remote.path(), "README.md", "seed\n");
12146 remote
12147 }
12148
12149 fn clone_into(remote: &Path, dest: &Path) {
12150 let status = Command::new("git")
12151 .arg("clone")
12152 .arg(remote)
12153 .arg(dest)
12154 .status()
12155 .expect("run git clone");
12156 assert!(status.success());
12157 crate::test_support::set_identity(dest);
12158 }
12159
12160 #[test]
12167 fn enabling_the_periodic_fetch_runs_one_cycle_before_any_tick_arrives() {
12168 let remote = seeded_remote();
12169 let root = tempfile::tempdir().expect("temp dir");
12170 let root_path = root_of(&root);
12171 clone_into(remote.path(), &root_path.join("parent"));
12172
12173 let fetch_ticks: Receiver<Instant> = crossbeam_channel::never();
12174 let started = Core::start_for_test_with_fetch(
12175 fetch_spec(true, root_path),
12176 Duration::from_secs(3600),
12177 crossbeam_channel::never(),
12178 fetch_ticks,
12179 )
12180 .discovered();
12181 let core = started.core;
12182
12183 wait_for(
12184 "the periodic fetch to run its first cycle without waiting for a tick",
12185 || core.fetch_cycle_count_for_test() >= 1,
12186 );
12187 }
12188
12189 #[test]
12193 fn a_tick_on_the_fetch_channel_runs_another_cycle() {
12194 let remote = seeded_remote();
12195 let root = tempfile::tempdir().expect("temp dir");
12196 let root_path = root_of(&root);
12197 clone_into(remote.path(), &root_path.join("parent"));
12198
12199 let (fetch_tick_tx, fetch_tick_rx) = crossbeam_channel::unbounded();
12200 let started = Core::start_for_test_with_fetch(
12201 fetch_spec(true, root_path),
12202 Duration::from_secs(3600),
12203 crossbeam_channel::never(),
12204 fetch_tick_rx,
12205 )
12206 .discovered();
12207 let core = started.core;
12208
12209 wait_for("the immediate cycle to have run first", || {
12210 core.fetch_cycle_count_for_test() >= 1
12211 });
12212
12213 fetch_tick_tx
12214 .send(Instant::now())
12215 .expect("send a fetch tick");
12216
12217 wait_for("a tick on the fetch channel to run a second cycle", || {
12218 core.fetch_cycle_count_for_test() >= 2
12219 });
12220 }
12221
12222 fn break_remote(repo: &Path) {
12228 let status = Command::new("git")
12229 .arg("-C")
12230 .arg(repo)
12231 .args(["remote", "set-url", "origin", "/nonexistent-remote-282"])
12232 .status()
12233 .expect("run git remote set-url");
12234 assert!(status.success());
12235 }
12236
12237 #[test]
12239 fn a_cycle_in_which_every_fetch_succeeds_reports_no_failures() {
12240 let remote = seeded_remote();
12241 let root = tempfile::tempdir().expect("temp dir");
12242 let root_path = root_of(&root);
12243 clone_into(remote.path(), &root_path.join("parent"));
12244
12245 let fetch_ticks: Receiver<Instant> = crossbeam_channel::never();
12246 let started = Core::start_for_test_with_fetch(
12247 fetch_spec(true, root_path),
12248 Duration::from_secs(3600),
12249 crossbeam_channel::never(),
12250 fetch_ticks,
12251 )
12252 .discovered();
12253 let core = started.core;
12254
12255 wait_for("the periodic fetch to run its first cycle", || {
12256 core.fetch_cycle_count_for_test() >= 1
12257 });
12258
12259 assert!(
12260 core.fetch_failures().failed.is_empty(),
12261 "a cycle where every fetch succeeds must report no failures, got: {:?}",
12262 core.fetch_failures().failed
12263 );
12264 }
12265
12266 #[test]
12270 fn a_repository_that_cannot_be_fetched_is_counted_while_its_sibling_still_fetches() {
12271 let good_remote = seeded_remote();
12272 let bad_remote = seeded_remote();
12273 let root = tempfile::tempdir().expect("temp dir");
12274 let root_path = root_of(&root);
12275 let good = root_path.join("good");
12276 let bad = root_path.join("bad");
12277 clone_into(good_remote.path(), &good);
12278 clone_into(bad_remote.path(), &bad);
12279 break_remote(&bad);
12280
12281 crate::test_support::push_new_commit(good_remote.path(), "second.txt", "second\n");
12282 let good_remote_tip = rev_parse(good_remote.path(), "refs/heads/main");
12283
12284 let fetch_ticks: Receiver<Instant> = crossbeam_channel::never();
12285 let started = Core::start_for_test_with_fetch(
12286 fetch_spec(true, root_path),
12287 Duration::from_secs(3600),
12288 crossbeam_channel::never(),
12289 fetch_ticks,
12290 )
12291 .discovered();
12292 let core = started.core;
12293
12294 wait_for(
12295 "the cycle to run and count the one repository it could not fetch",
12296 || core.fetch_failures().failed.len() == 1,
12297 );
12298
12299 let failures = core.fetch_failures();
12300 assert_eq!(
12301 failures.failed.len(),
12302 1,
12303 "exactly one repository failed, so exactly one failure must be counted, \
12304 got: {:?}",
12305 failures.failed
12306 );
12307 assert!(
12308 failures.failed[0].0.to_string_lossy().contains("bad"),
12309 "the counted failure must name the repository that actually failed, \
12310 got: {:?}",
12311 failures.failed
12312 );
12313
12314 wait_for(
12315 "the sibling repository to still fetch despite the other one failing",
12316 || rev_parse(&good, "refs/remotes/origin/main") == good_remote_tip,
12317 );
12318 }
12319
12320 fn push_new_commit_on_branch(remote: &Path, branch: &str, name: &str, contents: &str) {
12324 let contributor = tempfile::tempdir().expect("temp dir");
12325 let status = Command::new("git")
12326 .arg("clone")
12327 .arg("--branch")
12328 .arg(branch)
12329 .arg(remote)
12330 .arg(contributor.path())
12331 .status()
12332 .expect("run git clone");
12333 assert!(status.success());
12334 std::fs::write(contributor.path().join(name), contents).expect("write fixture file");
12335 git(contributor.path(), &["add", name]);
12336 git(contributor.path(), &["commit", "-m", "extra work on topic"]);
12337 git(contributor.path(), &["push", "origin", branch]);
12338 }
12339
12340 #[test]
12348 fn a_finished_fetch_prunes_and_starts_its_own_generation_that_lands_gone() {
12349 let remote = seeded_remote();
12350 let root = tempfile::tempdir().expect("temp dir");
12351 let root_path = root_of(&root);
12352 let parent = root_path.join("parent");
12353 clone_into(remote.path(), &parent);
12354
12355 git(remote.path(), &["branch", "topic"]);
12356 push_new_commit_on_branch(remote.path(), "topic", "topic.txt", "extra work\n");
12357
12358 git(&parent, &["fetch", "origin"]);
12364
12365 let worktree_path = root_path.join("topic-worktree");
12366 git(
12367 &parent,
12368 &[
12369 "worktree",
12370 "add",
12371 "-b",
12372 "topic",
12373 worktree_path.to_str().expect("utf8 path"),
12374 "origin/topic",
12375 ],
12376 );
12377
12378 git(remote.path(), &["branch", "-D", "topic"]);
12382
12383 let fetch_ticks: Receiver<Instant> = crossbeam_channel::never();
12384 let started = Core::start_for_test_with_fetch(
12385 fetch_spec(true, root_path),
12386 Duration::from_secs(3600),
12387 crossbeam_channel::never(),
12388 fetch_ticks,
12389 )
12390 .discovered();
12391 let core = started.core;
12392
12393 wait_for_or(
12394 "a finished fetch's own Generation to land the pruned Worktree as Gone \
12395 without the test ever calling refresh",
12396 || {
12397 core.snapshot()
12398 .entities
12399 .iter()
12400 .filter(|entity| matches!(entity.kind, Kind::Worktree))
12401 .any(|entity| {
12402 matches!(
12403 entity.state.settled(),
12404 Some(Settled::Known {
12405 value: WorktreeState::Gone,
12406 at: _,
12407 stale: _,
12408 })
12409 )
12410 })
12411 },
12412 || {
12413 format!(
12414 "snapshot: {:?}",
12415 core.snapshot()
12416 .entities
12417 .iter()
12418 .map(|entity| (entity.kind, entity.state.settled().cloned()))
12419 .collect::<Vec<_>>()
12420 )
12421 },
12422 );
12423 }
12424
12425 fn spec_with_auto_update(
12426 fetch_enabled: bool,
12427 auto_update_enabled: bool,
12428 root: PathBuf,
12429 ) -> CoreSpec {
12430 let mut spec = fetch_spec(fetch_enabled, root);
12431 spec.auto_update = AutoUpdateSpec {
12432 enabled: auto_update_enabled,
12433 };
12434 spec
12435 }
12436
12437 fn rev_parse(path: &Path, rev: &str) -> String {
12438 let output = Command::new("git")
12439 .arg("-C")
12440 .arg(path)
12441 .args(["rev-parse", rev])
12442 .output()
12443 .expect("run git rev-parse");
12444 assert!(output.status.success(), "git rev-parse {rev} failed");
12445 String::from_utf8(output.stdout)
12446 .expect("utf8 sha")
12447 .trim()
12448 .to_string()
12449 }
12450
12451 #[test]
12458 fn auto_update_is_off_by_default_even_with_fetch_enabled() {
12459 let remote = seeded_remote();
12460 let root = tempfile::tempdir().expect("temp dir");
12461 let root_path = root_of(&root);
12462 let parent = root_path.join("parent");
12463 clone_into(remote.path(), &parent);
12464 let before = rev_parse(&parent, "refs/heads/main");
12465
12466 crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
12467
12468 let fetch_ticks: Receiver<Instant> = crossbeam_channel::never();
12469 let started = Core::start_for_test_with_fetch(
12470 spec_with_auto_update(true, false, root_path),
12471 Duration::from_secs(3600),
12472 crossbeam_channel::never(),
12473 fetch_ticks,
12474 )
12475 .discovered();
12476 let core = started.core;
12477
12478 wait_for(
12479 "the periodic fetch to still run its immediate cycle",
12480 || core.fetch_cycle_count_for_test() >= 1,
12481 );
12482 assert_eq!(
12483 rev_parse(&parent, "refs/heads/main"),
12484 before,
12485 "an eligible branch must not move while auto_update.enabled is false, \
12486 even though fetch.enabled is true"
12487 );
12488 }
12489
12490 #[test]
12497 fn auto_update_enabled_rides_the_immediate_fetch_cycle_with_no_timer_of_its_own() {
12498 let remote = seeded_remote();
12499 let root = tempfile::tempdir().expect("temp dir");
12500 let root_path = root_of(&root);
12501 let parent = root_path.join("parent");
12502 clone_into(remote.path(), &parent);
12503
12504 crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
12505 let remote_tip = rev_parse(remote.path(), "refs/heads/main");
12506
12507 let fetch_ticks: Receiver<Instant> = crossbeam_channel::never();
12508 let started = Core::start_for_test_with_fetch(
12509 spec_with_auto_update(true, true, root_path),
12510 Duration::from_secs(3600),
12511 crossbeam_channel::never(),
12512 fetch_ticks,
12513 )
12514 .discovered();
12515 let _core = started.core;
12518
12519 wait_for(
12520 "the eligible branch to fast-forward on the immediate cycle alone, with no \
12521 fetch tick and no auto-update tick of its own",
12522 || rev_parse(&parent, "refs/heads/main") == remote_tip,
12523 );
12524 }
12525 }
12526
12527 mod attempt_auto_update {
12537 use super::*;
12538
12539 fn seeded_remote() -> tempfile::TempDir {
12540 let remote = tempfile::tempdir().expect("temp dir");
12541 crate::test_support::init_bare(remote.path());
12542 crate::test_support::push_new_commit(remote.path(), "README.md", "seed\n");
12543 remote
12544 }
12545
12546 fn clone_into(remote: &Path, dest: &Path) {
12547 let status = Command::new("git")
12548 .arg("clone")
12549 .arg(remote)
12550 .arg(dest)
12551 .status()
12552 .expect("run git clone");
12553 assert!(status.success());
12554 crate::test_support::set_identity(dest);
12555 }
12556
12557 fn discover_repo(root: &Path) -> (Core, EntityKey) {
12562 let core = Core::start_discovered(spec(vec![root.to_path_buf()]));
12563 let key = core
12564 .settle()
12565 .entities
12566 .into_iter()
12567 .find(|entity| entity.kind == Kind::Repo)
12568 .expect("the Repo row is discovered")
12569 .key;
12570 (core, key)
12571 }
12572
12573 #[test]
12576 fn an_eligible_repo_fast_forwards_through_the_wrapper_too() {
12577 let remote = seeded_remote();
12578 let root = tempfile::tempdir().expect("temp dir");
12579 let root_path = root_of(&root);
12580 let repo = root_path.join("repo");
12581 clone_into(remote.path(), &repo);
12582 crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
12583 crate::test_support::git(&repo, &["fetch", "origin"]);
12584
12585 let (core, key) = discover_repo(&root_path);
12586
12587 assert_eq!(core.attempt_auto_update(&key), AutoUpdateAttempt::Updated);
12588 assert!(
12589 repo.join("second.txt").exists(),
12590 "the fast-forward must reach the working tree through the wrapper too"
12591 );
12592 }
12593
12594 #[test]
12596 fn a_dirty_repo_is_reported_not_clean_through_the_wrapper_too() {
12597 let remote = seeded_remote();
12598 let root = tempfile::tempdir().expect("temp dir");
12599 let root_path = root_of(&root);
12600 let repo = root_path.join("repo");
12601 clone_into(remote.path(), &repo);
12602 crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
12603 crate::test_support::git(&repo, &["fetch", "origin"]);
12604 fs::write(repo.join("stray.txt"), "uncommitted\n").expect("write a stray file");
12605
12606 let (core, key) = discover_repo(&root_path);
12607
12608 assert_eq!(core.attempt_auto_update(&key), AutoUpdateAttempt::NotClean);
12609 }
12610
12611 #[test]
12613 fn an_up_to_date_repo_is_reported_not_behind_through_the_wrapper_too() {
12614 let remote = seeded_remote();
12615 let root = tempfile::tempdir().expect("temp dir");
12616 let root_path = root_of(&root);
12617 let repo = root_path.join("repo");
12618 clone_into(remote.path(), &repo);
12619 crate::test_support::git(&repo, &["fetch", "origin"]);
12620
12621 let (core, key) = discover_repo(&root_path);
12622
12623 assert_eq!(core.attempt_auto_update(&key), AutoUpdateAttempt::NotBehind);
12624 }
12625
12626 #[test]
12628 fn an_unpublished_local_commit_is_reported_not_fast_forward_through_the_wrapper_too() {
12629 let remote = seeded_remote();
12630 let root = tempfile::tempdir().expect("temp dir");
12631 let root_path = root_of(&root);
12632 let repo = root_path.join("repo");
12633 clone_into(remote.path(), &repo);
12634 crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
12635 crate::test_support::git(&repo, &["fetch", "origin"]);
12636 crate::test_support::commit_file(&repo, "local-only.txt", "never pushed\n");
12637
12638 let (core, key) = discover_repo(&root_path);
12639
12640 assert_eq!(
12641 core.attempt_auto_update(&key),
12642 AutoUpdateAttempt::NotFastForward
12643 );
12644 }
12645
12646 #[test]
12648 fn a_branch_with_no_upstream_is_reported_through_the_wrapper_too() {
12649 let remote = seeded_remote();
12650 let root = tempfile::tempdir().expect("temp dir");
12651 let root_path = root_of(&root);
12652 let repo = root_path.join("repo");
12653 clone_into(remote.path(), &repo);
12654 crate::test_support::git(&repo, &["checkout", "-b", "untracked-branch"]);
12655
12656 let (core, key) = discover_repo(&root_path);
12657
12658 assert_eq!(
12659 core.attempt_auto_update(&key),
12660 AutoUpdateAttempt::NoUpstream
12661 );
12662 }
12663 }
12664
12665 mod network_default_branch {
12672 use super::*;
12673
12674 fn seeded_remote() -> tempfile::TempDir {
12675 let remote = tempfile::tempdir().expect("temp dir");
12676 crate::test_support::init_bare(remote.path());
12677 crate::test_support::push_new_commit(remote.path(), "README.md", "seed\n");
12678 remote
12679 }
12680
12681 fn clone_into(remote: &Path, dest: &Path) {
12682 let status = Command::new("git")
12683 .arg("clone")
12684 .arg(remote)
12685 .arg(dest)
12686 .status()
12687 .expect("run git clone");
12688 assert!(status.success());
12689 crate::test_support::set_identity(dest);
12690 }
12691
12692 fn set_remote_head(path: &Path, branch: &str) {
12695 git(
12696 path,
12697 &["symbolic-ref", "HEAD", &format!("refs/heads/{branch}")],
12698 );
12699 }
12700
12701 fn rev_parse(path: &Path, rev: &str) -> String {
12702 let output = Command::new("git")
12703 .arg("-C")
12704 .arg(path)
12705 .args(["rev-parse", rev])
12706 .output()
12707 .expect("run git rev-parse");
12708 assert!(output.status.success());
12709 String::from_utf8(output.stdout)
12710 .expect("utf8 sha")
12711 .trim()
12712 .to_string()
12713 }
12714
12715 fn default_branch_name(entity: &EntityState) -> Option<String> {
12716 match entity.default_branch.settled() {
12717 Some(Settled::Known {
12718 value,
12719 at: _,
12720 stale: _,
12721 }) => Some(value.name().to_string()),
12722 _ => None,
12723 }
12724 }
12725
12726 #[test]
12736 fn the_local_chain_answers_first_and_only_a_later_network_round_trip_supersedes_it() {
12737 let remote = seeded_remote();
12738 let root = tempfile::tempdir().expect("temp dir");
12739 let root_path = root_of(&root);
12740 let repo_path = root_path.join("repo");
12741 clone_into(remote.path(), &repo_path);
12742
12743 git(remote.path(), &["branch", "trunk"]);
12746 set_remote_head(remote.path(), "trunk");
12747
12748 let core = Core::start_discovered(spec(vec![root_path]));
12749 let key = core.snapshot().entities[0].key.clone();
12750
12751 core.refresh(std::slice::from_ref(&key));
12752 let settled = core.settle();
12753 assert_eq!(
12754 default_branch_name(&settled.entities[0]),
12755 Some("origin/main".to_string()),
12756 "a plain refresh must answer from the local chain alone, unaffected by the \
12757 remote's own current (but not yet asked) truth"
12758 );
12759
12760 core.rederive_default_branches(std::slice::from_ref(&key));
12761 let settled = core.settle();
12762 assert_eq!(
12763 default_branch_name(&settled.entities[0]),
12764 Some("origin/trunk".to_string()),
12765 "once the network round trip actually ran, its own differing answer must \
12766 supersede the local chain's"
12767 );
12768 }
12769
12770 #[test]
12780 fn rederive_default_branches_never_fetches_and_leaves_a_row_outside_it_untouched() {
12781 let remote = seeded_remote();
12782 let root = tempfile::tempdir().expect("temp dir");
12783 let root_path = root_of(&root);
12784 let selected_path = root_path.join("selected");
12785 let outside_path = root_path.join("outside");
12786 clone_into(remote.path(), &selected_path);
12787 init_repo_with_a_commit(&outside_path);
12788
12789 git(remote.path(), &["branch", "trunk"]);
12790 crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
12791 set_remote_head(remote.path(), "trunk");
12792 let before_tracking = rev_parse(&selected_path, "refs/remotes/origin/main");
12793
12794 let core = Core::start_discovered(spec(vec![root_path]));
12795 let snapshot = core.snapshot();
12796 let selected_key = snapshot
12797 .entities
12798 .iter()
12799 .find(|entity| entity.key.path() == selected_path)
12800 .expect("discovered the selected repo")
12801 .key
12802 .clone();
12803 let outside_key = snapshot
12804 .entities
12805 .iter()
12806 .find(|entity| entity.key.path() == outside_path)
12807 .expect("discovered the outside repo")
12808 .key
12809 .clone();
12810
12811 core.refresh(&[selected_key.clone(), outside_key.clone()]);
12812 let settled = core.settle();
12813 let outside_before = format!(
12814 "{:?}",
12815 settled
12816 .entities
12817 .iter()
12818 .find(|entity| entity.key == outside_key)
12819 .expect("outside entity present")
12820 );
12821
12822 core.rederive_default_branches(std::slice::from_ref(&selected_key));
12823 let settled = core.settle();
12824
12825 let selected_after = settled
12826 .entities
12827 .iter()
12828 .find(|entity| entity.key == selected_key)
12829 .expect("selected entity present");
12830 assert_eq!(
12831 default_branch_name(selected_after),
12832 Some("origin/trunk".to_string()),
12833 "the rederive must have reached the remote's own current, differing answer"
12834 );
12835
12836 let after_tracking = rev_parse(&selected_path, "refs/remotes/origin/main");
12837 assert_eq!(
12838 before_tracking, after_tracking,
12839 "a rederive must never fetch: the remote-tracking ref must not have moved \
12840 even though the remote gained a new commit"
12841 );
12842
12843 let outside_after = format!(
12844 "{:?}",
12845 settled
12846 .entities
12847 .iter()
12848 .find(|entity| entity.key == outside_key)
12849 .expect("outside entity present")
12850 );
12851 assert_eq!(
12852 outside_before, outside_after,
12853 "a row outside the rederive's own keys must be left exactly as it was, not \
12854 only on its default_branch cell"
12855 );
12856 }
12857 }
12858
12859 #[test]
12867 fn set_exclusions_excludes_a_row_already_in_the_table_with_no_rebuild() {
12868 let dir = tempfile::tempdir().expect("temp dir");
12869 let root = root_of(&dir);
12870 let repo = root.join("repo");
12871 init_repo_with_a_commit(&repo);
12872
12873 let core = Core::start_discovered(spec(vec![root]));
12874 let snapshot = core.settle();
12875 let key = snapshot.entities[0].key.clone();
12876 let generation_before = snapshot.generation;
12877 assert!(
12878 !snapshot.entities[0].excluded,
12879 "nothing excludes it to start with"
12880 );
12881 assert_eq!(core.operable_count(std::slice::from_ref(&key)), 1);
12882
12883 core.set_exclusions(&[RepoOverride {
12884 path: repo.clone(),
12885 default_branch: None,
12886 excluded: true,
12887 }]);
12888
12889 let after = core.snapshot();
12890 assert!(
12891 after.entities[0].excluded,
12892 "the row the write named is excluded in the very next snapshot"
12893 );
12894 assert_eq!(
12895 core.operable_count(&[key]),
12896 0,
12897 "an excluded row is subtracted from what an operation may reach"
12898 );
12899 assert_eq!(
12900 after.generation, generation_before,
12901 "re-applying an operate-time filter must start no Generation of its own"
12902 );
12903 }
12904
12905 #[test]
12908 fn set_exclusions_clears_the_flag_when_the_entry_is_gone() {
12909 let dir = tempfile::tempdir().expect("temp dir");
12910 let root = root_of(&dir);
12911 let repo = root.join("repo");
12912 init_repo_with_a_commit(&repo);
12913
12914 let core = Core::start_discovered(spec_with_overrides(
12915 vec![root],
12916 vec![RepoOverride {
12917 path: repo.clone(),
12918 default_branch: None,
12919 excluded: true,
12920 }],
12921 ));
12922 assert!(
12923 core.settle().entities[0].excluded,
12924 "the starting override excludes it"
12925 );
12926
12927 core.set_exclusions(&[]);
12928
12929 assert!(
12930 !core.snapshot().entities[0].excluded,
12931 "removing the entry unexcludes the row in the very next snapshot"
12932 );
12933 }
12934
12935 #[test]
12940 fn set_exclusions_moves_exclude_alone_and_never_the_default_branch_override() {
12941 let dir = tempfile::tempdir().expect("temp dir");
12942 let root = root_of(&dir);
12943 let repo = root.join("repo");
12944 init_repo_with_a_commit(&repo);
12945 crate::test_support::git(&repo, &["branch", "trunk"]);
12946
12947 let core = Core::start_discovered(spec(vec![root]));
12948 let key = core.settle().entities[0].key.clone();
12949 core.refresh(std::slice::from_ref(&key));
12950 let before = format!("{:?}", core.settle().entities[0].default_branch.settled());
12951
12952 core.set_exclusions(&[RepoOverride {
12953 path: repo.clone(),
12954 default_branch: Some("trunk".to_string()),
12955 excluded: true,
12956 }]);
12957 core.refresh(&[key]);
12958 core.settle();
12959
12960 let after = core.snapshot();
12961 assert!(after.entities[0].excluded, "exclude took effect");
12962 assert_eq!(
12963 format!("{:?}", after.entities[0].default_branch.settled()),
12964 before,
12965 "a default_branch override reaches a session only through a rebuilt Core"
12966 );
12967 }
12968
12969 #[test]
12977 fn record_own_work_leaves_one_receipt_per_row_it_names_and_none_elsewhere() {
12978 let dir = tempfile::tempdir().expect("temp dir");
12979 let root = root_of(&dir);
12980 init_repo_with_a_commit(&root.join("repo-a"));
12981 init_repo_with_a_commit(&root.join("repo-b"));
12982
12983 let core = Core::start_discovered(spec(vec![root]));
12984 let entities = core.settle().entities;
12985 let named = entities
12986 .iter()
12987 .find(|entity| &*entity.name == "repo-a")
12988 .expect("repo-a is discovered")
12989 .key
12990 .clone();
12991
12992 core.record_own_work(
12993 "ignore",
12994 &[(
12995 named.clone(),
12996 OwnWork::Refused(Arc::from("refused, already ignored")),
12997 Duration::from_millis(7),
12998 )],
12999 );
13000
13001 let after = core.snapshot().entities;
13002 let receipt = after
13003 .iter()
13004 .find(|entity| entity.key == named)
13005 .and_then(|entity| entity.last_action.clone())
13006 .expect("the row it named carries a receipt");
13007 assert_eq!(&*receipt.label, "ignore");
13008 assert!(
13009 !receipt.not_applicable(),
13010 "a refusal is not an excluded row"
13011 );
13012 assert!(receipt.running.is_none(), "the work is already done");
13013 assert_eq!(receipt.steps.len(), 1, "one act, not an ordered list");
13014 assert_eq!(&*receipt.steps[0].label, "ignore");
13015 assert_eq!(receipt.steps[0].elapsed, Duration::from_millis(7));
13016 assert!(receipt.steps[0].output.is_empty(), "nothing to quote");
13017 assert!(receipt.steps[0].elision.is_none());
13018 assert_eq!(
13019 receipt.steps[0].outcome,
13020 StepOutcome::OwnWork(OwnWork::Refused(Arc::from("refused, already ignored"))),
13021 );
13022 assert!(
13023 after
13024 .iter()
13025 .filter(|entity| entity.key != named)
13026 .all(|entity| entity.last_action.is_none()),
13027 "no row this did not name takes a receipt"
13028 );
13029 }
13030
13031 #[test]
13035 fn record_own_work_skips_a_key_the_table_no_longer_holds() {
13036 let dir = tempfile::tempdir().expect("temp dir");
13037 let root = root_of(&dir);
13038 init_repo_with_a_commit(&root.join("repo-a"));
13039
13040 let core = Core::start_discovered(spec(vec![root]));
13041 let entities = core.settle().entities;
13042 let stranger = EntityKey::new(Arc::from(std::path::Path::new("/nowhere/at/all")));
13043
13044 core.record_own_work(
13045 "delete",
13046 &[(stranger, OwnWork::Did(Arc::from("gone")), Duration::ZERO)],
13047 );
13048
13049 assert!(
13050 core.snapshot()
13051 .entities
13052 .iter()
13053 .all(|entity| entity.last_action.is_none()),
13054 "an unknown key writes nothing anywhere"
13055 );
13056 assert_eq!(core.snapshot().entities.len(), entities.len());
13057 }
13058
13059 #[test]
13068 fn delete_risk_reads_all_three_facts_the_confirm_gate_names() {
13069 let dir = tempfile::tempdir().expect("temp dir");
13070 let root = root_of(&dir);
13071 let repo = root.join("repo");
13072 init_repo_with_a_commit(&repo);
13073 fs::write(repo.join("uncommitted.txt"), "not staged\n").expect("write a stray file");
13074 crate::test_support::git(
13075 &repo,
13076 &["worktree", "add", "-b", "sidecar", "../sidecar-worktree"],
13077 );
13078
13079 let core = Core::start_discovered(spec(vec![root]));
13080 let key = core
13084 .settle()
13085 .entities
13086 .into_iter()
13087 .find(|entity| entity.kind == Kind::Repo)
13088 .expect("the Repo row is discovered")
13089 .key;
13090
13091 let risk = core.delete_risk(&key).expect("read the risk");
13092
13093 assert!(risk.uncommitted, "the stray file makes the tree dirty");
13094 assert!(
13095 risk.unpushed_commits > 0 && risk.unpushed_branches > 0,
13096 "no remote-tracking ref carries any of this Repo's commits, got {risk:?}"
13097 );
13098 assert_eq!(
13099 risk.linked_worktrees, 1,
13100 "the one linked Worktree pointing into this Repo is counted, got {risk:?}"
13101 );
13102 }
13103
13104 #[test]
13110 fn every_kind_of_work_that_is_not_in_a_commit_makes_the_gate_say_uncommitted() {
13111 for kind in ["modified", "deleted", "untracked", "staged"] {
13112 let dir = tempfile::tempdir().expect("temp dir");
13113 let root = root_of(&dir);
13114 let repo = root.join("repo");
13115 init_repo_with_a_commit(&repo);
13116 fs::write(repo.join("tracked.txt"), "first\n").expect("write a tracked file");
13117 crate::test_support::git(&repo, &["add", "tracked.txt"]);
13118 crate::test_support::git(&repo, &["commit", "-m", "add tracked"]);
13119 let sha = crate::test_support::head_sha(&repo);
13120 crate::test_support::git(&repo, &["update-ref", "refs/remotes/origin/main", &sha]);
13121
13122 match kind {
13123 "modified" => fs::write(repo.join("tracked.txt"), "second\n").expect("modify it"),
13124 "deleted" => fs::remove_file(repo.join("tracked.txt")).expect("delete it"),
13125 "untracked" => fs::write(repo.join("stray.txt"), "new\n").expect("write a stray"),
13126 "staged" => {
13127 fs::write(repo.join("staged.txt"), "new\n").expect("write a new file");
13128 crate::test_support::git(&repo, &["add", "staged.txt"]);
13129 }
13130 other => unreachable!("unhandled kind {other}"),
13131 }
13132
13133 let core = Core::start_discovered(spec(vec![root]));
13134 let key = core.settle().entities[0].key.clone();
13135
13136 let risk = core.delete_risk(&key).expect("read the risk");
13137
13138 assert!(
13139 risk.uncommitted,
13140 "a {kind} change is work that is not in a commit, got {risk:?}"
13141 );
13142 }
13143 }
13144
13145 #[test]
13152 fn staged_work_reads_clean_to_the_dirty_column_and_uncommitted_to_the_delete_gate() {
13153 let dir = tempfile::tempdir().expect("temp dir");
13154 let root = root_of(&dir);
13155 let repo = root.join("repo");
13156 init_repo_with_a_commit(&repo);
13157 let sha = crate::test_support::head_sha(&repo);
13158 crate::test_support::git(&repo, &["update-ref", "refs/remotes/origin/main", &sha]);
13159 fs::write(repo.join("staged.txt"), "staged\n").expect("write a new file");
13160 crate::test_support::git(&repo, &["add", "staged.txt"]);
13161
13162 let core = Core::start_discovered(spec(vec![root]));
13163 let key = core.settle().entities[0].key.clone();
13164
13165 let opened = git::open_thread_safe(repo.as_path())
13166 .expect("open the repo")
13167 .to_thread_local();
13168 let dirty = git::dirty_counts(&opened, Arc::new(AtomicBool::new(false)))
13169 .expect("read the dirty counts");
13170 assert_eq!(
13171 dirty.total(),
13172 0,
13173 "the dirty column stays an index-to-worktree comparison, got {dirty:?}"
13174 );
13175
13176 let risk = core.delete_risk(&key).expect("read the risk");
13177 assert!(
13178 risk.uncommitted,
13179 "a Repo whose only work is staged must never be listed plainly, got {risk:?}"
13180 );
13181 }
13182
13183 #[test]
13187 fn unpushed_commits_and_unpushed_branches_are_counted_into_their_own_fields() {
13188 let dir = tempfile::tempdir().expect("temp dir");
13189 let root = root_of(&dir);
13190 let repo = root.join("repo");
13191 init_repo_with_a_commit(&repo);
13192 let sha = crate::test_support::head_sha(&repo);
13193 crate::test_support::git(&repo, &["update-ref", "refs/remotes/origin/main", &sha]);
13194 for nth in 0..3 {
13195 fs::write(repo.join(format!("file-{nth}.txt")), "x\n").expect("write a file");
13196 crate::test_support::git(&repo, &["add", "."]);
13197 crate::test_support::git(&repo, &["commit", "-m", "unpushed"]);
13198 }
13199 crate::test_support::git(&repo, &["checkout", "."]);
13200
13201 let core = Core::start_discovered(spec(vec![root]));
13202 let key = core.settle().entities[0].key.clone();
13203
13204 let risk = core.delete_risk(&key).expect("read the risk");
13205
13206 assert_eq!(
13207 (risk.unpushed_commits, risk.unpushed_branches),
13208 (3, 1),
13209 "three commits on one branch, each in its own field, got {risk:?}"
13210 );
13211 }
13212
13213 #[test]
13217 fn a_linked_worktree_outside_the_sets_roots_is_still_counted_by_the_gate() {
13218 let dir = tempfile::tempdir().expect("temp dir");
13219 let base = root_of(&dir);
13220 let inside = base.join("inside");
13221 let outside = base.join("outside");
13222 fs::create_dir_all(&outside).expect("create the outside dir");
13223 let repo = inside.join("repo");
13224 init_repo_with_a_commit(&repo);
13225 crate::test_support::git(
13226 &repo,
13227 &["worktree", "add", "-b", "sidecar", "../../outside/sidecar"],
13228 );
13229 assert!(
13230 outside.join("sidecar").exists(),
13231 "the harness really created a linked Worktree outside the Set's roots"
13232 );
13233
13234 let core = Core::start_discovered(spec(vec![inside]));
13236 let snapshot = core.settle();
13237 assert!(
13238 snapshot
13239 .entities
13240 .iter()
13241 .all(|entity| entity.kind != Kind::Worktree),
13242 "the Worktree is outside the roots and so is not discovered, got {:?}",
13243 snapshot.entities.iter().map(|e| e.kind).collect::<Vec<_>>()
13244 );
13245 let key = snapshot
13246 .entities
13247 .into_iter()
13248 .find(|entity| entity.kind == Kind::Repo)
13249 .expect("the Repo row is discovered")
13250 .key;
13251
13252 let risk = core.delete_risk(&key).expect("read the risk");
13253
13254 assert_eq!(
13255 risk.linked_worktrees, 1,
13256 "the gate must name the linked Worktree deleting this Repo would orphan, got {risk:?}"
13257 );
13258 }
13259
13260 #[test]
13265 fn delete_risk_on_a_clean_fully_pushed_repo_with_no_worktrees_reports_nothing() {
13266 let dir = tempfile::tempdir().expect("temp dir");
13267 let root = root_of(&dir);
13268 let repo = root.join("repo");
13269 init_repo_with_a_commit(&repo);
13270 let sha = crate::test_support::head_sha(&repo);
13271 crate::test_support::git(&repo, &["update-ref", "refs/remotes/origin/main", &sha]);
13272
13273 let core = Core::start_discovered(spec(vec![root]));
13274 let key = core.settle().entities[0].key.clone();
13275
13276 let risk = core.delete_risk(&key).expect("read the risk");
13277
13278 assert_eq!(
13279 risk,
13280 DeleteRisk {
13281 uncommitted: false,
13282 unpushed_commits: 0,
13283 unpushed_branches: 0,
13284 linked_worktrees: 0,
13285 }
13286 );
13287 }
13288
13289 #[test]
13298 fn worktree_admin_dir_names_the_entry_git_worktree_list_forgets_once_it_is_removed() {
13299 let dir = tempfile::tempdir().expect("temp dir");
13300 let root = root_of(&dir);
13301 let repo = root.join("repo");
13302 init_repo_with_a_commit(&repo);
13303 let worktree = root.join("sidecar");
13304 crate::test_support::git(
13305 &repo,
13306 &[
13307 "worktree",
13308 "add",
13309 "-b",
13310 "sidecar",
13311 worktree.to_str().expect("utf8 path"),
13312 ],
13313 );
13314
13315 let core = Core::start_discovered(spec(vec![root]));
13316 let key = core
13317 .settle()
13318 .entities
13319 .into_iter()
13320 .find(|entity| entity.kind == Kind::Worktree)
13321 .expect("the Worktree row is discovered")
13322 .key;
13323
13324 let admin_dir = core.worktree_admin_dir(&key).expect("read the admin dir");
13325 fs::remove_dir_all(&admin_dir).expect("remove the admin dir by hand");
13326
13327 let reopened = git::open_thread_safe(&repo)
13328 .expect("reopen the repo")
13329 .to_thread_local();
13330 assert_eq!(
13331 git::linked_worktrees(&reopened).expect("count"),
13332 0,
13333 "removing the admin dir alone must be what git's own register stops naming"
13334 );
13335 }
13336
13337 #[test]
13341 fn worktree_admin_dir_errors_when_the_path_cannot_be_opened_as_a_repository() {
13342 let dir = tempfile::tempdir().expect("temp dir");
13343 let root = root_of(&dir);
13344 let not_a_repo = root.join("plain-directory");
13345 fs::create_dir_all(¬_a_repo).expect("create it");
13346
13347 let core = Core::start_discovered(spec(vec![root]));
13348 core.settle();
13349 let key = EntityKey::new(Arc::from(not_a_repo.as_path()));
13350
13351 assert!(core.worktree_admin_dir(&key).is_err());
13352 }
13353
13354 #[test]
13357 fn linked_worktree_paths_names_every_linked_worktrees_own_directory() {
13358 let dir = tempfile::tempdir().expect("temp dir");
13359 let root = root_of(&dir);
13360 let repo = root.join("repo");
13361 init_repo_with_a_commit(&repo);
13362 let first = root.join("first-worktree");
13363 let second = root.join("second-worktree");
13364 crate::test_support::git(
13365 &repo,
13366 &[
13367 "worktree",
13368 "add",
13369 "-b",
13370 "one",
13371 first.to_str().expect("utf8 path"),
13372 ],
13373 );
13374 crate::test_support::git(
13375 &repo,
13376 &[
13377 "worktree",
13378 "add",
13379 "-b",
13380 "two",
13381 second.to_str().expect("utf8 path"),
13382 ],
13383 );
13384
13385 let core = Core::start_discovered(spec(vec![root]));
13386 let key = core
13387 .settle()
13388 .entities
13389 .into_iter()
13390 .find(|entity| entity.kind == Kind::Repo)
13391 .expect("the Repo row is discovered")
13392 .key;
13393
13394 let mut paths = core
13395 .linked_worktree_paths(&key)
13396 .expect("read the linked worktree paths");
13397 paths.sort();
13398 let mut expected = vec![
13399 first.canonicalize().expect("canonicalize first"),
13400 second.canonicalize().expect("canonicalize second"),
13401 ];
13402 expected.sort();
13403
13404 assert_eq!(paths, expected);
13405 }
13406}