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 use std::sync::mpsc;
4818
4819 use super::*;
4820 use crate::entity::{AheadBehind, DefaultBranchStopped, WorktreeState};
4821 use crate::liveness::{BACKSTOP, FIXTURE_LIFETIME, wait_for};
4822 use crate::snapshot::{RowSummary, summary};
4823 use crate::test_support::{git, head_sha, loose_object_count};
4824
4825 fn init_repo_with_a_commit(path: &Path) {
4826 fs::create_dir_all(path).expect("create repo dir");
4827 gix::init(path).expect("init repo");
4828 let status = Command::new("git")
4829 .arg("-C")
4830 .arg(path)
4831 .args(["-c", "user.email=test@example.com", "-c", "user.name=Test"])
4832 .args(["commit", "--allow-empty", "-m", "first"])
4833 .status()
4834 .expect("run git commit");
4835 assert!(status.success());
4836 }
4837
4838 fn commit_a_change(path: &Path, message: &str) {
4848 let gitdir = gitdir_of(path);
4849 let before = poll::fingerprint(&gitdir);
4850
4851 std::fs::write(path.join(format!("{message}.txt")), message.as_bytes())
4852 .expect("write a file to commit");
4853 let added = Command::new("git")
4854 .arg("-C")
4855 .arg(path)
4856 .args(["add", "-A"])
4857 .status()
4858 .expect("run git add");
4859 assert!(added.success());
4860 commit(path, message, &["-m", message]);
4861
4862 assert!(
4868 poll::moved(&before, &poll::fingerprint(&gitdir)),
4869 "committing in {} moved none of the polled paths under {}, so this fixture cannot \
4870 show the poll anything",
4871 path.display(),
4872 gitdir.display()
4873 );
4874 }
4875
4876 fn gitdir_of(work_dir: &Path) -> PathBuf {
4879 let output = Command::new("git")
4880 .arg("-C")
4881 .arg(work_dir)
4882 .args(["rev-parse", "--absolute-git-dir"])
4883 .output()
4884 .expect("run git rev-parse");
4885 assert!(
4886 output.status.success(),
4887 "resolve the gitdir of {}",
4888 work_dir.display()
4889 );
4890 PathBuf::from(
4891 std::str::from_utf8(&output.stdout)
4892 .expect("a utf-8 gitdir path")
4893 .trim(),
4894 )
4895 }
4896
4897 fn commit(path: &Path, message: &str, args: &[&str]) {
4899 let status = Command::new("git")
4900 .arg("-C")
4901 .arg(path)
4902 .args(["-c", "user.email=test@example.com", "-c", "user.name=Test"])
4903 .arg("commit")
4904 .args(args)
4905 .status()
4906 .unwrap_or_else(|error| panic!("run git commit {message}: {error}"));
4907 assert!(status.success());
4908 }
4909
4910 fn fetch_spec_for_test() -> FetchSpec {
4914 FetchSpec {
4915 enabled: false,
4916 interval: Duration::from_secs(3600),
4917 concurrency: 4,
4918 }
4919 }
4920
4921 fn auto_update_spec_for_test() -> AutoUpdateSpec {
4926 AutoUpdateSpec { enabled: false }
4927 }
4928
4929 fn spec(roots: Vec<PathBuf>) -> CoreSpec {
4930 CoreSpec {
4931 set: SetSpec {
4932 name: "test".to_string(),
4933 roots,
4934 include: Vec::new(),
4935 exclude: Vec::new(),
4936 },
4937 overrides: Vec::new(),
4938 poll_interval: Duration::from_secs(3600),
4939 status_stale_after: Duration::from_secs(3600),
4940 generation_deadline: Duration::from_secs(3600),
4941 show_submodules: false,
4942 fetch: fetch_spec_for_test(),
4943 auto_update: auto_update_spec_for_test(),
4944 }
4945 }
4946
4947 #[test]
4958 fn core_spec_carries_no_scoping_field_scope_is_never_a_dial() {
4959 let CoreSpec {
4960 set: _,
4961 overrides: _,
4962 poll_interval: _,
4963 status_stale_after: _,
4964 generation_deadline: _,
4965 show_submodules: _,
4966 fetch: _,
4967 auto_update: _,
4968 } = spec(Vec::new());
4969 }
4970
4971 fn root_of(dir: &tempfile::TempDir) -> PathBuf {
4972 dir.path().canonicalize().expect("canonicalize temp dir")
4973 }
4974
4975 fn settle_launch(core: &Core) -> Snapshot {
4984 let launched = core.settle();
4985 assert_eq!(
4986 core.settle_gate_count_for_test(),
4987 0,
4988 "launch's own Generation never settled, so nothing after this is starting from \
4989 the point it claims to"
4990 );
4991 launched
4992 }
4993
4994 fn started_and_settled(spec: CoreSpec) -> (Core, Snapshot) {
4997 let core = Core::start_discovered(spec);
4998 let launched = settle_launch(&core);
4999 (core, launched)
5000 }
5001
5002 fn backdate_polled_entries(work_dir: &Path) {
5009 let gitdir = gitdir_of(work_dir);
5010
5011 let past = std::time::SystemTime::now() - Duration::from_secs(10);
5012 let mut touched = 0;
5013 for name in poll::POLLED_GITDIR_ENTRIES {
5014 let path = gitdir.join(name);
5015 if path.exists() {
5016 set_mtime_to(&path, past);
5017 touched += 1;
5018 }
5019 }
5020 assert!(
5021 touched > 0,
5022 "backdated nothing under {}; the gitdir holds none of the polled entries and the \
5023 baseline this sets up would not be older than what follows",
5024 gitdir.display()
5025 );
5026 }
5027
5028 fn set_mtime_to(path: &Path, at: std::time::SystemTime) {
5030 use std::os::unix::ffi::OsStrExt;
5031
5032 let secs = at
5033 .duration_since(std::time::SystemTime::UNIX_EPOCH)
5034 .expect("a time after the epoch")
5035 .as_secs() as libc::time_t;
5036 let times = [
5037 libc::timespec {
5038 tv_sec: secs,
5039 tv_nsec: 0,
5040 },
5041 libc::timespec {
5042 tv_sec: secs,
5043 tv_nsec: 0,
5044 },
5045 ];
5046 let c_path =
5047 std::ffi::CString::new(path.as_os_str().as_bytes()).expect("a path with no NUL");
5048 let rc = unsafe { libc::utimensat(libc::AT_FDCWD, c_path.as_ptr(), times.as_ptr(), 0) };
5049 assert_eq!(
5050 rc,
5051 0,
5052 "set mtime on {}: {}",
5053 path.display(),
5054 std::io::Error::last_os_error()
5055 );
5056 }
5057
5058 fn step(argv: &[&str]) -> Step {
5059 Step {
5060 argv: argv.iter().map(|s| s.to_string()).collect(),
5061 shell: false,
5062 interactive: false,
5063 env: Vec::new(),
5064 }
5065 }
5066
5067 fn shell_step(command: &str) -> Step {
5069 Step {
5070 argv: vec![command.to_string()],
5071 shell: true,
5072 interactive: false,
5073 env: Vec::new(),
5074 }
5075 }
5076
5077 fn interactive_shell_step(command: &str) -> Step {
5080 Step {
5081 argv: vec![command.to_string()],
5082 shell: true,
5083 interactive: true,
5084 env: Vec::new(),
5085 }
5086 }
5087
5088 fn receipt_labelled(core: &Core, key: &EntityKey, label: &str) -> Option<ActionReceipt> {
5092 core.snapshot()
5093 .entities
5094 .iter()
5095 .find(|entity| entity.key == *key)
5096 .and_then(|entity| entity.last_action.clone())
5097 .filter(|receipt| &*receipt.label == label)
5098 }
5099
5100 fn action(label: &str, steps: Vec<Step>) -> ActionSpec {
5101 ActionSpec {
5102 label: Arc::from(label),
5103 name: Some(Arc::from(label)),
5104 steps,
5105 concurrency: 4,
5106 when: None,
5107 }
5108 }
5109
5110 fn action_with_when(label: &str, steps: Vec<Step>, when: &str) -> ActionSpec {
5113 ActionSpec {
5114 when: Some(Filter::parse(when)),
5115 ..action(label, steps)
5116 }
5117 }
5118
5119 #[test]
5124 fn refresh_and_settle_populate_real_cells_without_the_caller_spawning_a_thread() {
5125 let dir = tempfile::tempdir().expect("temp dir");
5126 let root = root_of(&dir);
5127 let repo = root.join("repo");
5128 init_repo_with_a_commit(&repo);
5129
5130 let core = Core::start_discovered(spec(vec![root]));
5131 let keys: Vec<EntityKey> = core
5132 .snapshot()
5133 .entities
5134 .iter()
5135 .map(|entity| entity.key.clone())
5136 .collect();
5137 assert_eq!(keys.len(), 1);
5138
5139 core.refresh(&keys);
5140 let settled = core.settle();
5141
5142 let entity = &settled.entities[0];
5143 match entity.branch.settled() {
5144 Some(Settled::Known {
5145 value: Head::Branch { .. },
5146 at: _,
5147 stale: _,
5148 }) => {}
5149 other => panic!("expected an attached branch, got {other:?}"),
5150 }
5151 }
5152
5153 fn spec_refresh_md() -> String {
5158 let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
5159 std::fs::read_to_string(manifest_dir.join("../../docs/spec/refresh.md"))
5160 .expect("read docs/spec/refresh.md")
5161 }
5162
5163 fn spec_first_frame_budgets_ms(spec: &str) -> (u64, u64) {
5164 let anchor = "rows with names on screen within ";
5165 let after = spec
5166 .split(anchor)
5167 .nth(1)
5168 .expect("the first-frame budget sentence is present");
5169 let mut parts = after.splitn(2, "ms, every cheap column filled within ");
5170 let names: u64 = parts
5171 .next()
5172 .expect("a names-on-screen budget")
5173 .parse()
5174 .expect("the names-on-screen budget is an integer");
5175 let after_cheap = parts.next().expect("a cheap-column budget and beyond");
5176 let cheap_columns: u64 = after_cheap
5177 .split("ms,")
5178 .next()
5179 .expect("a cheap-column budget")
5180 .parse()
5181 .expect("the cheap-column budget is an integer");
5182 (names, cheap_columns)
5183 }
5184
5185 #[test]
5189 fn first_frame_budget_constants_match_the_spec_of_record() {
5190 let spec = spec_refresh_md();
5191 let (names_ms, cheap_columns_ms) = spec_first_frame_budgets_ms(&spec);
5192 assert_eq!(names_ms, FIRST_FRAME_NAMES_BUDGET_MS);
5193 assert_eq!(cheap_columns_ms, FIRST_FRAME_CHEAP_COLUMNS_BUDGET_MS);
5194 }
5195
5196 #[test]
5204 fn every_dispatched_entity_gets_its_dirty_cell_settled_not_a_subset() {
5205 let dir = tempfile::tempdir().expect("temp dir");
5206 let root = root_of(&dir);
5207 const ENTITY_COUNT: usize = 16;
5208 for index in 0..ENTITY_COUNT {
5209 init_repo_with_a_commit(&root.join(format!("repo-{index}")));
5210 }
5211
5212 let core = Core::start_discovered(spec(vec![root]));
5213 let keys: Vec<EntityKey> = core
5214 .snapshot()
5215 .entities
5216 .iter()
5217 .map(|entity| entity.key.clone())
5218 .collect();
5219 assert_eq!(keys.len(), ENTITY_COUNT, "expected every repo discovered");
5220
5221 core.refresh(&keys);
5222 let settled = core.settle();
5223
5224 for entity in &settled.entities {
5225 assert!(
5226 matches!(
5227 entity.dirty.settled(),
5228 Some(Settled::Known {
5229 value: _,
5230 at: _,
5231 stale: _
5232 })
5233 ),
5234 "entity {:?} was left without a settled dirty cell, which is exactly what a \
5235 visibility-scoped dispatch would leave behind on the entities it skipped: \
5236 got {:?}",
5237 entity.name,
5238 entity.dirty.settled()
5239 );
5240 }
5241 }
5242
5243 #[test]
5258 fn cheap_outcomes_land_before_a_held_phase_c_settles() {
5259 let dir = tempfile::tempdir().expect("temp dir");
5260 let root = root_of(&dir);
5261 let repo = root.join("repo");
5262 init_repo_with_a_commit(&repo);
5263
5264 let (core, launched) = started_and_settled(spec(vec![root]));
5265 let key = launched.entities[0].key.clone();
5266 assert_eq!(
5267 dirty_total(&launched.entities[0]),
5268 0,
5269 "the fixture starts clean, which is the value the held phase C must still be \
5270 reading once the working tree below has moved"
5271 );
5272
5273 git(&repo, &["checkout", "-b", "held"]);
5277 fs::write(repo.join("untracked.txt"), b"uncommitted")
5278 .expect("write an untracked file into the fixture");
5279
5280 core.hold_phase_c_for_test(&key);
5281 core.refresh(std::slice::from_ref(&key));
5282 core.wait_phase_c_landed_for_test(&key);
5283
5284 let mid_flight = core.snapshot();
5285 let entity = mid_flight
5286 .entities
5287 .iter()
5288 .find(|entity| entity.key == key)
5289 .expect("entity present");
5290 assert!(
5291 matches!(
5292 entity.branch.settled(),
5293 Some(Settled::Known {
5294 value: Head::Branch { name, .. },
5295 at: _,
5296 stale: _
5297 }) if &**name == "held"
5298 ),
5299 "the cheap branch cell must carry this Generation's own answer while phase C is \
5300 still held open, got {:?}",
5301 entity.branch.settled()
5302 );
5303 assert!(
5304 entity.dirty.is_in_flight() && dirty_total(entity) == 0,
5305 "phase C is deliberately held open here; a bundled apply would already have \
5306 written this cell's new count alongside branch, got {:?}",
5307 entity.dirty.settled()
5308 );
5309
5310 core.release_phase_c_for_test(&key);
5311 core.wait_phase_c_finished_for_test(&key);
5312
5313 let settled = core.snapshot();
5314 let entity = settled
5315 .entities
5316 .iter()
5317 .find(|entity| entity.key == key)
5318 .expect("entity present");
5319 assert_eq!(
5320 dirty_total(entity),
5321 1,
5322 "phase C must settle its own count once released, got {:?}",
5323 entity.dirty.settled()
5324 );
5325 }
5326
5327 fn dirty_total(entity: &EntityState) -> u32 {
5331 match entity.dirty.settled() {
5332 Some(Settled::Known {
5333 value,
5334 at: _,
5335 stale: _,
5336 }) => value.total(),
5337 other => panic!("expected a settled dirty count, got {other:?}"),
5338 }
5339 }
5340
5341 #[test]
5349 fn splitting_the_probe_write_signals_settle_gate_exactly_once_per_entity() {
5350 let dir = tempfile::tempdir().expect("temp dir");
5351 let root = root_of(&dir);
5352 init_repo_with_a_commit(&root.join("a"));
5353 init_repo_with_a_commit(&root.join("b"));
5354
5355 let (core, snapshot) = started_and_settled(spec(vec![root]));
5356 let key_a = snapshot
5357 .entities
5358 .iter()
5359 .find(|entity| &*entity.name == "a")
5360 .expect("entity a present")
5361 .key
5362 .clone();
5363 let key_b = snapshot
5364 .entities
5365 .iter()
5366 .find(|entity| &*entity.name == "b")
5367 .expect("entity b present")
5368 .key
5369 .clone();
5370
5371 core.hold_phase_c_for_test(&key_a);
5372 core.hold_phase_c_for_test(&key_b);
5373 core.refresh(&[key_a.clone(), key_b.clone()]);
5374 core.wait_dispatched_for_test();
5378 assert_eq!(
5379 core.settle_gate_count_for_test(),
5380 2,
5381 "dispatching two entities must add exactly two to the settle gate"
5382 );
5383
5384 core.wait_phase_c_landed_for_test(&key_a);
5385 core.wait_phase_c_landed_for_test(&key_b);
5386 assert_eq!(
5387 core.settle_gate_count_for_test(),
5388 2,
5389 "the cheap apply must never touch the settle gate: both entities' cheap \
5390 outcomes have landed and neither has finished phase C yet"
5391 );
5392
5393 core.release_phase_c_for_test(&key_a);
5394 core.wait_phase_c_finished_for_test(&key_a);
5395 assert_eq!(
5396 core.settle_gate_count_for_test(),
5397 1,
5398 "exactly one entity finished, so the gate must fall by exactly one, not two \
5399 (double-counted) and not zero (left short)"
5400 );
5401
5402 core.release_phase_c_for_test(&key_b);
5403 core.wait_phase_c_finished_for_test(&key_b);
5404 assert_eq!(
5405 core.settle_gate_count_for_test(),
5406 0,
5407 "both entities finished, so the gate must be fully drained"
5408 );
5409 }
5410
5411 fn registered_gate(core: &Core, key: &EntityKey) -> PhaseCGateHandle {
5414 core.phase_c_gates
5415 .lock()
5416 .unwrap()
5417 .get(key)
5418 .cloned()
5419 .expect("hold_phase_c_for_test must be called before reading its gate")
5420 }
5421
5422 fn release_gate(gate: &PhaseCGateHandle) {
5425 let (lock, cvar) = &**gate;
5426 lock.lock().unwrap().may_proceed = true;
5427 cvar.notify_all();
5428 }
5429
5430 fn gate_is_finished(gate: &PhaseCGateHandle) -> bool {
5431 gate.0.lock().unwrap().finished
5432 }
5433
5434 #[test]
5446 fn a_probe_signals_the_phase_c_gate_its_own_generation_was_dispatched_against() {
5447 let dir = tempfile::tempdir().expect("temp dir");
5448 let root = root_of(&dir);
5449 init_repo_with_a_commit(&root.join("repo"));
5450
5451 let (core, launched) = started_and_settled(spec(vec![root]));
5452 let key = launched.entities[0].key.clone();
5453
5454 core.hold_phase_c_for_test(&key);
5455 let dispatched_against = registered_gate(&core, &key);
5456 core.refresh(std::slice::from_ref(&key));
5457 core.wait_phase_c_landed_for_test(&key);
5458
5459 core.hold_phase_c_for_test(&key);
5460 let registered_later = registered_gate(&core, &key);
5461 release_gate(&dispatched_against);
5462
5463 wait_for(
5464 "the held probe to signal the gate its own Generation was dispatched against",
5465 || gate_is_finished(&dispatched_against),
5466 );
5467 assert!(
5468 !gate_is_finished(®istered_later),
5469 "a gate registered after this Generation dispatched must never be marked \
5470 finished by it: a test waiting on that gate would return before this \
5471 Generation had applied its outcome or decremented the settle gate"
5472 );
5473 }
5474
5475 #[test]
5487 fn a_probe_finishing_clears_only_its_own_generations_in_flight_entry() {
5488 let dir = tempfile::tempdir().expect("temp dir");
5489 let root = root_of(&dir);
5490 init_repo_with_a_commit(&root.join("repo"));
5491
5492 let (core, launched) = started_and_settled(spec(vec![root]));
5493 let key = launched.entities[0].key.clone();
5494
5495 core.hold_phase_c_for_test(&key);
5496 core.refresh(std::slice::from_ref(&key));
5497 core.wait_phase_c_landed_for_test(&key);
5498
5499 let superseding = core.begin_shared_generation_for_test(std::slice::from_ref(&key));
5502
5503 core.release_phase_c_for_test(&key);
5504 core.wait_phase_c_finished_for_test(&key);
5505
5506 core.refresh(std::slice::from_ref(&key));
5507 core.wait_dispatched_for_test();
5508
5509 assert!(
5510 superseding.cancels[&key].load(Ordering::Acquire),
5511 "a probe from a Generation that has already been superseded must leave the \
5512 live Generation's in-flight entry alone, or the Generation after it has \
5513 nothing to interrupt"
5514 );
5515 }
5516
5517 #[test]
5532 fn refresh_dispatches_phase_c_in_exactly_the_order_it_is_given() {
5533 let dir = tempfile::tempdir().expect("temp dir");
5534 let root = root_of(&dir);
5535 const ENTITY_COUNT: usize = 6;
5536 for index in 0..ENTITY_COUNT {
5537 init_repo_with_a_commit(&root.join(format!("repo-{index}")));
5538 }
5539
5540 let (core, launched) = started_and_settled(spec(vec![root]));
5541 let discovery_order: Vec<EntityKey> = launched
5542 .entities
5543 .iter()
5544 .map(|entity| entity.key.clone())
5545 .collect();
5546 assert_eq!(
5547 discovery_order.len(),
5548 ENTITY_COUNT,
5549 "expected every repo discovered"
5550 );
5551
5552 let cursor = discovery_order[3].clone();
5556 let visible = [discovery_order[1].clone(), discovery_order[4].clone()];
5557 let mut three_tier_order = vec![cursor.clone()];
5558 three_tier_order.extend(visible.iter().cloned());
5559 for key in &discovery_order {
5560 if *key != cursor && !visible.contains(key) {
5561 three_tier_order.push(key.clone());
5562 }
5563 }
5564 assert_eq!(
5565 three_tier_order.len(),
5566 ENTITY_COUNT,
5567 "sanity check: the hand-built order must cover every discovered entity exactly \
5568 once"
5569 );
5570
5571 core.refresh(&three_tier_order);
5572 core.settle();
5573
5574 assert_eq!(
5575 core.dispatch_log_for_test(),
5576 three_tier_order,
5577 "refresh must dispatch phase C in exactly the order it was given: the cursor \
5578 row, then the visible rows, then the rest in discovery order"
5579 );
5580 }
5581
5582 #[test]
5587 fn refresh_reuses_the_cached_repository_handle_rather_than_reopening_it() {
5588 let dir = tempfile::tempdir().expect("temp dir");
5589 let root = root_of(&dir);
5590 let repo = root.join("repo");
5591 init_repo_with_a_commit(&repo);
5592
5593 let core = Core::start_discovered(spec(vec![root]));
5594 let key = core.snapshot().entities[0].key.clone();
5595 let before = core
5596 .cached_repo_handle_for_test(&key)
5597 .expect("discovery should have cached a handle");
5598
5599 core.refresh(std::slice::from_ref(&key));
5600 core.settle();
5601
5602 let after = core
5603 .cached_repo_handle_for_test(&key)
5604 .expect("the cached handle should still be there after a refresh");
5605 assert!(
5606 Arc::ptr_eq(&before, &after),
5607 "a refresh must reuse the cached handle, not replace it with a new one"
5608 );
5609 }
5610
5611 #[test]
5617 fn refresh_running_reads_true_the_instant_refresh_returns_and_false_once_it_settles() {
5618 let dir = tempfile::tempdir().expect("temp dir");
5619 let root = root_of(&dir);
5620 init_repo_with_a_commit(&root.join("repo"));
5621
5622 let core = Core::start_discovered(spec(vec![root]));
5623 core.settle();
5624 assert!(
5625 !core.refresh_running(),
5626 "sanity: nothing outstanding once startup has settled"
5627 );
5628
5629 let keys: Vec<EntityKey> = core
5630 .snapshot()
5631 .entities
5632 .iter()
5633 .map(|entity| entity.key.clone())
5634 .collect();
5635 core.refresh(&keys);
5636 assert!(
5637 core.refresh_running(),
5638 "refresh reserves its Generation and records the dispatch debt before it \
5639 returns, so this must already read true"
5640 );
5641
5642 core.settle();
5643 assert!(
5644 !core.refresh_running(),
5645 "settle blocks until nothing is outstanding, so this must read false once it \
5646 returns"
5647 );
5648 }
5649
5650 #[test]
5654 fn probing_a_key_with_no_cached_handle_still_opens_the_repository_itself() {
5655 let dir = tempfile::tempdir().expect("temp dir");
5656 let root = root_of(&dir);
5657 let repo = root.join("repo");
5658 init_repo_with_a_commit(&repo);
5659
5660 let empty_root = root_of(&tempfile::tempdir().expect("temp dir"));
5662 let core = Core::start_discovered(spec(vec![empty_root]));
5663 let key = EntityKey::new(Arc::from(repo.as_path()));
5664 assert!(core.cached_repo_handle_for_test(&key).is_none());
5665
5666 let entity = core.probe_now(&key);
5667
5668 assert!(matches!(
5669 entity.branch.settled(),
5670 Some(Settled::Known {
5671 value: Head::Branch { .. },
5672 at: _,
5673 stale: _
5674 })
5675 ));
5676 }
5677
5678 #[test]
5682 fn an_empty_order_dispatches_nothing_and_settle_returns_immediately() {
5683 let dir = tempfile::tempdir().expect("temp dir");
5684 let root = root_of(&dir);
5685 let repo = root.join("repo");
5686 init_repo_with_a_commit(&repo);
5687
5688 let (core, _launched) = started_and_settled(spec(vec![root]));
5689 assert!(
5690 !core.dispatch_log_for_test().is_empty(),
5691 "launch dispatched nothing, so an empty log below would say nothing about the \
5692 empty order"
5693 );
5694
5695 core.refresh(&[]);
5696 core.wait_dispatched_for_test();
5697
5698 assert_eq!(
5699 core.dispatch_log_for_test(),
5700 Vec::new(),
5701 "an empty order must dispatch no probe"
5702 );
5703 let settled = core
5707 .try_settle(Duration::from_millis(50))
5708 .expect("an empty order raises no probe, so the settle gate is already at zero");
5709 assert!(!settled.entities[0].branch.is_in_flight());
5710 }
5711
5712 fn one_probe_owed_that_never_lands(
5720 dir: &tempfile::TempDir,
5721 ) -> (Core, crossbeam_channel::Sender<Instant>) {
5722 let root = root_of(dir);
5723 init_repo_with_a_commit(&root.join("repo"));
5724 let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
5725 let core = Core::start_for_test(spec(vec![root]), Duration::from_secs(3600), tick_rx)
5726 .discovered()
5727 .core;
5728 let key = settle_launch(&core).entities[0].key.clone();
5729 core.begin_untracked_probe_for_test(&key);
5730 (core, tick_tx)
5731 }
5732
5733 #[test]
5741 #[should_panic(expected = "waiting for everything this Core has in flight to land")]
5742 fn a_settle_that_expires_reports_at_the_wait_rather_than_returning_the_table() {
5743 let dir = tempfile::tempdir().expect("temp dir");
5744 let (core, _tick_tx) = one_probe_owed_that_never_lands(&dir);
5745
5746 core.settle_within(Duration::from_millis(20));
5747 }
5748
5749 #[test]
5753 fn try_settle_hands_an_expiry_back_as_an_error_carrying_the_table_it_gave_up_on() {
5754 let dir = tempfile::tempdir().expect("temp dir");
5755 let (core, _tick_tx) = one_probe_owed_that_never_lands(&dir);
5756
5757 let unsettled = core
5758 .try_settle(Duration::from_millis(20))
5759 .expect_err("a probe nothing will ever complete cannot settle");
5760
5761 assert!(
5762 unsettled.entities[0].branch.is_in_flight(),
5763 "the Err arm must still carry the table as it stood, so a caller that degrades \
5764 deliberately has something to degrade with"
5765 );
5766 }
5767
5768 #[test]
5771 fn try_settle_hands_a_generation_that_really_landed_back_as_ok() {
5772 let dir = tempfile::tempdir().expect("temp dir");
5773 let root = root_of(&dir);
5774 init_repo_with_a_commit(&root.join("repo"));
5775
5776 let (core, launched) = started_and_settled(spec(vec![root]));
5777 let key = launched.entities[0].key.clone();
5778 core.refresh(std::slice::from_ref(&key));
5779
5780 let settled = core
5781 .try_settle(BACKSTOP)
5782 .expect("a dispatched Generation must land inside the backstop");
5783
5784 assert!(!settled.entities[0].branch.is_in_flight());
5785 }
5786
5787 #[test]
5791 fn probe_now_settles_the_sync_cell_as_well_as_the_branch_it_depends_on() {
5792 let dir = tempfile::tempdir().expect("temp dir");
5793 let root = root_of(&dir);
5794 let repo = root.join("repo");
5795 init_repo_with_a_commit(&repo);
5796
5797 let core = Core::start_discovered(spec(vec![root]));
5798 let key = core.snapshot().entities[0].key.clone();
5799
5800 let entity = core.probe_now(&key);
5801
5802 assert!(
5803 matches!(
5804 entity.sync.settled(),
5805 Some(Settled::Known {
5806 value: SyncState::NoRemote,
5807 at: _,
5808 stale: _
5809 })
5810 ),
5811 "expected probe_now to settle sync, got {:?}",
5812 entity.sync.settled()
5813 );
5814 }
5815
5816 #[test]
5819 fn probe_now_settles_the_base_cell_as_well_as_the_branch_it_depends_on() {
5820 let dir = tempfile::tempdir().expect("temp dir");
5821 let root = root_of(&dir);
5822 let repo = root.join("repo");
5823 init_repo_with_a_commit(&repo);
5824
5825 let core = Core::start_discovered(spec(vec![root]));
5826 let key = core.snapshot().entities[0].key.clone();
5827
5828 let entity = core.probe_now(&key);
5829
5830 assert!(
5831 matches!(entity.base.settled(), Some(Settled::NotApplicable)),
5832 "expected probe_now to settle base Not applicable for a Repo with no remote, \
5833 got {:?}",
5834 entity.base.settled()
5835 );
5836 }
5837
5838 #[test]
5842 fn refresh_settles_a_real_base_count_against_the_resolved_default_branch() {
5843 let dir = tempfile::tempdir().expect("temp dir");
5844 let root = root_of(&dir);
5845 let repo = root.join("repo");
5846 init_repo_with_a_commit(&repo);
5847 git(
5848 &repo,
5849 &[
5850 "remote",
5851 "add",
5852 "origin",
5853 "https://example.invalid/repo.git",
5854 ],
5855 );
5856 let root_sha = head_sha(&repo);
5857 git(&repo, &["commit", "--allow-empty", "-m", "second"]);
5863 let tip_sha = head_sha(&repo);
5864 git(&repo, &["reset", "--hard", &root_sha]);
5865 git(&repo, &["update-ref", "refs/remotes/origin/main", &tip_sha]);
5866
5867 let core = Core::start_discovered(spec(vec![root]));
5868 let key = core.snapshot().entities[0].key.clone();
5869
5870 core.refresh(std::slice::from_ref(&key));
5871 let settled = core.settle();
5872
5873 assert!(
5874 matches!(
5875 settled.entities[0].base.settled(),
5876 Some(Settled::Known {
5877 value: 1,
5878 at: _,
5879 stale: _
5880 })
5881 ),
5882 "expected a real refresh to settle base's live count against the resolved \
5883 default branch, got {:?}",
5884 settled.entities[0].base.settled()
5885 );
5886 }
5887
5888 #[test]
5894 fn probe_now_settles_the_dirty_cell_with_the_counts_it_probed() {
5895 let dir = tempfile::tempdir().expect("temp dir");
5896 let root = root_of(&dir);
5897 let repo = root.join("repo");
5898 init_repo_with_a_commit(&repo);
5899 fs::write(repo.join("untracked.txt"), "x").expect("write untracked file");
5900
5901 let core = Core::start_discovered(spec(vec![root]));
5902 let key = core.snapshot().entities[0].key.clone();
5903
5904 let entity = core.probe_now(&key);
5905
5906 assert!(
5907 matches!(
5908 entity.dirty.settled(),
5909 Some(Settled::Known {
5910 value: DirtyCounts {
5911 modified: 0,
5912 untracked: 1,
5913 deleted: 0,
5914 },
5915 at: _,
5916 stale: _
5917 })
5918 ),
5919 "expected probe_now to settle dirty with the one untracked path, got {:?}",
5920 entity.dirty.settled()
5921 );
5922 }
5923
5924 #[test]
5925 fn probe_now_updates_the_entity_synchronously_with_no_refresh_call() {
5926 let dir = tempfile::tempdir().expect("temp dir");
5927 let root = root_of(&dir);
5928 let repo = root.join("repo");
5929 init_repo_with_a_commit(&repo);
5930
5931 let core = Core::start_discovered(spec(vec![root]));
5932 let key = core.snapshot().entities[0].key.clone();
5933
5934 let entity = core.probe_now(&key);
5935
5936 assert!(matches!(
5937 entity.branch.settled(),
5938 Some(Settled::Known {
5939 value: Head::Branch { .. },
5940 at: _,
5941 stale: _
5942 })
5943 ));
5944 }
5945
5946 #[test]
5952 fn the_display_name_agrees_between_discovery_and_probe_nows_fallback_insert() {
5953 let dir = tempfile::tempdir().expect("temp dir");
5954 let root = root_of(&dir);
5955 let repo = root.join("named-repo");
5956 init_repo_with_a_commit(&repo);
5957
5958 let core = Core::start_discovered(spec(vec![root]));
5959 let discovered = core.snapshot().entities[0].clone();
5960 assert_eq!(&*discovered.name, "named-repo");
5961
5962 core.dismiss(&discovered.key);
5963 assert!(core.snapshot().entities.is_empty());
5964
5965 let reinserted = core.probe_now(&discovered.key);
5966
5967 assert_eq!(
5968 reinserted.name, discovered.name,
5969 "the name discovery assigned and the name probe_now's fallback insert \
5970 assigns for the same path must be byte-identical"
5971 );
5972 }
5973
5974 #[test]
5975 fn dismiss_removes_the_entity_from_the_snapshot() {
5976 let dir = tempfile::tempdir().expect("temp dir");
5977 let root = root_of(&dir);
5978 let repo = root.join("repo");
5979 init_repo_with_a_commit(&repo);
5980
5981 let core = Core::start_discovered(spec(vec![root]));
5982 let key = core.snapshot().entities[0].key.clone();
5983
5984 core.dismiss(&key);
5985
5986 assert!(core.snapshot().entities.is_empty());
5987 }
5988
5989 #[test]
6002 fn an_entitys_steps_run_in_order_and_a_failure_marks_every_later_step_not_run() {
6003 let dir = tempfile::tempdir().expect("temp dir");
6004 let root = root_of(&dir);
6005 let repo = root.join("repo");
6006 init_repo_with_a_commit(&repo);
6007 let marker = repo.join("step-three-ran");
6008
6009 let core = Core::start_discovered(spec(vec![root]));
6010 let key = core.snapshot().entities[0].key.clone();
6011 let steps = vec![
6012 step(&["true"]),
6013 step(&["sh", "-c", "exit 7"]),
6014 step(&["touch", "step-three-ran"]),
6015 ];
6016
6017 let started = core.run_action(action("reinstall", steps), std::slice::from_ref(&key));
6018
6019 assert!(started);
6020 wait_for("the fan-out to finish and write a receipt", || {
6021 !core.action_running()
6022 });
6023 let receipt = core.snapshot().entities[0]
6024 .last_action
6025 .clone()
6026 .expect("receipt written");
6027 assert_eq!(receipt.steps.len(), 3);
6028 assert_eq!(receipt.steps[0].outcome, StepOutcome::Ok);
6029 assert_eq!(receipt.steps[1].outcome, StepOutcome::Failed(7));
6030 assert_eq!(
6031 receipt.steps[2].outcome,
6032 StepOutcome::NotRun,
6033 "a step after a failure must be recorded NotRun, not silently dropped or run anyway"
6034 );
6035 assert!(
6036 !marker.exists(),
6037 "the third step's own `touch` must never have run: its marker file exists, so \
6038 the step ran despite being recorded NotRun"
6039 );
6040 }
6041
6042 #[test]
6047 fn steps_run_in_the_order_theyre_declared_not_some_other_order() {
6048 let dir = tempfile::tempdir().expect("temp dir");
6049 let root = root_of(&dir);
6050 let repo = root.join("repo");
6051 init_repo_with_a_commit(&repo);
6052 let order_log = repo.join("order.log");
6053
6054 let core = Core::start_discovered(spec(vec![root]));
6055 let key = core.snapshot().entities[0].key.clone();
6056 let steps = vec![
6057 step(&["sh", "-c", "printf 1 >> order.log"]),
6058 step(&["sh", "-c", "printf 2 >> order.log"]),
6059 step(&["sh", "-c", "printf 3 >> order.log"]),
6060 ];
6061
6062 let started = core.run_action(action("ordering", steps), std::slice::from_ref(&key));
6063
6064 assert!(started);
6065 wait_for("the fan-out to finish and write a receipt", || {
6066 !core.action_running()
6067 });
6068 let receipt = core.snapshot().entities[0]
6069 .last_action
6070 .clone()
6071 .expect("receipt written");
6072 assert_eq!(receipt.steps.len(), 3);
6073 assert!(
6074 receipt
6075 .steps
6076 .iter()
6077 .all(|result| result.outcome == StepOutcome::Ok),
6078 "every step here always exits zero; this test isolates ordering from gating"
6079 );
6080 let content = fs::read_to_string(&order_log).expect("order.log written by the steps");
6081 assert_eq!(
6082 content, "123",
6083 "the file's content pins actual execution order; running the steps out of \
6084 declaration order would produce a different digit sequence here even though \
6085 every step still succeeds"
6086 );
6087 }
6088
6089 #[test]
6097 fn a_still_running_actions_finished_step_and_its_currently_executing_one_are_both_visible_before_the_whole_run_ends()
6098 {
6099 let dir = tempfile::tempdir().expect("temp dir");
6100 let root = root_of(&dir);
6101 let repo = root.join("repo");
6102 init_repo_with_a_commit(&repo);
6103
6104 let core = Core::start_discovered(spec(vec![root]));
6105 let key = core.snapshot().entities[0].key.clone();
6106 let steps = vec![step(&["true"]), step(&["sh", "-c", "sleep 0.5"])];
6107
6108 let started = core.run_action(action("reinstall", steps), std::slice::from_ref(&key));
6109 assert!(started);
6110
6111 wait_for(
6116 "a receipt naming the second step running before the run finished",
6117 || {
6118 core.snapshot().entities[0]
6119 .last_action
6120 .as_ref()
6121 .and_then(|receipt| receipt.running.as_ref())
6122 .is_some_and(|running| running.label.contains("sleep"))
6123 },
6124 );
6125 let mid_run = core.snapshot().entities[0]
6126 .last_action
6127 .clone()
6128 .expect("receipt written");
6129 assert_eq!(
6130 mid_run.steps.len(),
6131 1,
6132 "the first, already-finished step must already be in `steps`"
6133 );
6134 assert_eq!(mid_run.steps[0].outcome, StepOutcome::Ok);
6135 let running = mid_run.running.expect("a step must be recorded running");
6136 assert!(
6137 running.label.contains("sleep"),
6138 "expected the running step's own label, got {:?}",
6139 running.label
6140 );
6141
6142 wait_for("the fan-out to finish", || !core.action_running());
6143 let finished = core.snapshot().entities[0]
6144 .last_action
6145 .clone()
6146 .expect("receipt written");
6147 assert!(
6148 finished.running.is_none(),
6149 "a finished receipt must carry no running step"
6150 );
6151 assert_eq!(finished.steps.len(), 2);
6152 }
6153
6154 #[test]
6163 fn a_shell_true_step_runs_through_shell_c_with_repon_as_its_own_dollar_zero() {
6164 let dir = tempfile::tempdir().expect("temp dir");
6165 let root = root_of(&dir);
6166 let repo = root.join("repo");
6167 init_repo_with_a_commit(&repo);
6168
6169 let core = Core::start_discovered(spec(vec![root]));
6170 let key = core.snapshot().entities[0].key.clone();
6171 let steps = vec![shell_step("echo \"[$0]\"")];
6172
6173 let started = core.run_action(action("shell-step", steps), std::slice::from_ref(&key));
6174
6175 assert!(started);
6176 wait_for("the fan-out to finish and write a receipt", || {
6177 !core.action_running()
6178 });
6179 let receipt = core.snapshot().entities[0]
6180 .last_action
6181 .clone()
6182 .expect("receipt written");
6183 assert_eq!(receipt.steps.len(), 1);
6184 assert_eq!(receipt.steps[0].outcome, StepOutcome::Ok);
6185 assert_eq!(&*receipt.steps[0].output, b"[repon]\n");
6186 assert!(
6187 receipt.steps[0].shell,
6188 "the receipt's own StepResult::shell must carry the mode the step ran under"
6189 );
6190 }
6191
6192 #[test]
6199 fn an_interactive_shell_true_step_runs_through_run_action_with_interactive_on_its_receipt() {
6200 let dir = tempfile::tempdir().expect("temp dir");
6201 let root = root_of(&dir);
6202 let repo = root.join("repo");
6203 init_repo_with_a_commit(&repo);
6204
6205 let core = Core::start_discovered(spec(vec![root]));
6206 let key = core.snapshot().entities[0].key.clone();
6207 let steps = vec![interactive_shell_step("true")];
6208
6209 let started = core.run_action(
6210 action("interactive-step", steps),
6211 std::slice::from_ref(&key),
6212 );
6213
6214 assert!(started);
6215 wait_for("the fan-out to finish and write a receipt", || {
6216 !core.action_running()
6217 });
6218 let receipt = core.snapshot().entities[0]
6219 .last_action
6220 .clone()
6221 .expect("receipt written");
6222 assert_eq!(receipt.steps.len(), 1);
6223 assert_eq!(receipt.steps[0].outcome, StepOutcome::Ok);
6224 assert!(
6225 receipt.steps[0].shell,
6226 "an interactive step is still a shell step"
6227 );
6228 assert!(
6229 receipt.steps[0].interactive,
6230 "the receipt's own StepResult::interactive must carry the mode the step ran under"
6231 );
6232 }
6233
6234 #[test]
6238 fn an_argv_step_runs_through_run_action_with_shell_false_on_its_receipt() {
6239 let dir = tempfile::tempdir().expect("temp dir");
6240 let root = root_of(&dir);
6241 let repo = root.join("repo");
6242 init_repo_with_a_commit(&repo);
6243
6244 let core = Core::start_discovered(spec(vec![root]));
6245 let key = core.snapshot().entities[0].key.clone();
6246 let steps = vec![Step {
6247 argv: vec!["true".to_string()],
6248 shell: false,
6249 interactive: false,
6250 env: Vec::new(),
6251 }];
6252
6253 let started = core.run_action(action("argv-step", steps), std::slice::from_ref(&key));
6254
6255 assert!(started);
6256 wait_for("the fan-out to finish and write a receipt", || {
6257 !core.action_running()
6258 });
6259 let receipt = core.snapshot().entities[0]
6260 .last_action
6261 .clone()
6262 .expect("receipt written");
6263 assert!(!receipt.steps[0].shell);
6264 }
6265
6266 #[test]
6272 fn starting_an_action_cancels_any_generation_already_in_flight() {
6273 let dir = tempfile::tempdir().expect("temp dir");
6274 let root = root_of(&dir);
6275 let repo = root.join("repo");
6276 init_repo_with_a_commit(&repo);
6277
6278 let core = Core::start_discovered(spec(vec![root]));
6279 let key = core.snapshot().entities[0].key.clone();
6280 let in_flight = core.begin_shared_generation_for_test(std::slice::from_ref(&key));
6281 let cancel = in_flight
6282 .cancels
6283 .get(&key)
6284 .expect("the in-flight entity has a cancel flag")
6285 .clone();
6286 assert!(!cancel.load(Ordering::Acquire));
6287
6288 let started = core.run_action(
6289 action("reinstall", vec![step(&["true"])]),
6290 std::slice::from_ref(&key),
6291 );
6292
6293 assert!(started);
6294 assert!(
6295 cancel.load(Ordering::Acquire),
6296 "starting an Action must cancel a Generation already in flight, not share \
6297 execution with it"
6298 );
6299 wait_for("the fan-out and its completion refresh to drain", || {
6302 !core.action_running()
6303 });
6304 }
6305
6306 #[test]
6316 fn a_finished_action_starts_exactly_one_generation_over_every_known_entity() {
6317 let dir = tempfile::tempdir().expect("temp dir");
6318 let root = root_of(&dir);
6319 let acted_on = root.join("acted-on");
6320 let untouched = root.join("untouched");
6321 init_repo_with_a_commit(&acted_on);
6322 init_repo_with_a_commit(&untouched);
6323
6324 let (core, before) = started_and_settled(spec(vec![root]));
6325 let acted_key = before
6326 .entities
6327 .iter()
6328 .find(|entity| entity.key.path() == acted_on)
6329 .expect("the acted-on entity is discovered")
6330 .key
6331 .clone();
6332
6333 let started = core.run_action(
6334 action("reinstall", vec![step(&["true"])]),
6335 std::slice::from_ref(&acted_key),
6336 );
6337
6338 assert!(started);
6339 wait_for(
6340 "the completion Generation to probe every known entity, including the one the \
6341 Action never touched",
6342 || {
6343 let snapshot = core.snapshot();
6344 snapshot.generation != before.generation
6345 && snapshot.entities.iter().all(|entity| {
6346 matches!(
6347 entity.branch.settled(),
6348 Some(Settled::Known {
6349 value: _,
6350 at: _,
6351 stale: _
6352 })
6353 )
6354 })
6355 },
6356 );
6357 assert_eq!(
6358 core.settle().generation,
6359 before.generation.successor(),
6360 "completion must start exactly one Generation: not zero (no refresh at all) and \
6361 not two (a double refresh)"
6362 );
6363 }
6364
6365 #[test]
6375 fn a_completion_dispatches_its_generation_before_releasing_its_run() {
6376 let dir = tempfile::tempdir().expect("temp dir");
6377 let root = root_of(&dir);
6378 let repo = root.join("repo");
6379 init_repo_with_a_commit(&repo);
6380
6381 let (core, before) = started_and_settled(spec(vec![root]));
6382 let key = before.entities[0].key.clone();
6383 let armed = core.action_completion_boundary().arm();
6384
6385 assert!(core.run_action(
6386 action("finishing", vec![step(&["true"])]),
6387 std::slice::from_ref(&key)
6388 ));
6389 armed.wait_until_reached();
6390
6391 assert_eq!(
6392 core.snapshot().generation,
6393 before.generation.successor(),
6394 "the completion Generation must be dispatched before the run releases its \
6395 admission"
6396 );
6397 assert!(
6398 !core.run_action(
6399 action("racing", vec![step(&["true"])]),
6400 std::slice::from_ref(&key)
6401 ),
6402 "a submission before that release must be refused, so what a run cancels on the \
6403 way in is never a Generation the run it replaced has yet to dispatch"
6404 );
6405
6406 drop(armed);
6407 wait_for("the finished run to release its admission", || {
6408 !core.action_running()
6409 });
6410 }
6411
6412 #[test]
6417 fn an_excluded_row_swept_into_an_action_gets_a_not_applicable_receipt_and_no_other_path_does() {
6418 let dir = tempfile::tempdir().expect("temp dir");
6419 let root = root_of(&dir);
6420 let excluded_repo = root.join("excluded");
6421 let normal_repo = root.join("normal");
6422 init_repo_with_a_commit(&excluded_repo);
6423 init_repo_with_a_commit(&normal_repo);
6424
6425 let core = Core::start_discovered(spec_with_overrides(
6426 vec![root],
6427 vec![RepoOverride {
6428 path: excluded_repo.clone(),
6429 default_branch: None,
6430 excluded: true,
6431 }],
6432 ));
6433 let snapshot = core.snapshot();
6434 let find = |path: &Path| {
6435 snapshot
6436 .entities
6437 .iter()
6438 .find(|entity| entity.key.path() == path)
6439 .unwrap_or_else(|| panic!("entity at {path:?} present"))
6440 .key
6441 .clone()
6442 };
6443 let excluded_key = find(&excluded_repo);
6444 let normal_key = find(&normal_repo);
6445 assert!(
6446 snapshot
6447 .entities
6448 .iter()
6449 .find(|entity| entity.key == excluded_key)
6450 .unwrap()
6451 .excluded
6452 );
6453
6454 let started = core.run_action(
6455 action("reinstall", vec![step(&["sh", "-c", "exit 3"])]),
6456 &[excluded_key.clone(), normal_key.clone()],
6457 );
6458
6459 assert!(started);
6460 wait_for("the fan-out to finish", || !core.action_running());
6466
6467 let after = core.snapshot();
6468 let receipt_of = |key: &EntityKey| {
6469 after
6470 .entities
6471 .iter()
6472 .find(|entity| entity.key == *key)
6473 .unwrap()
6474 .last_action
6475 .clone()
6476 .unwrap()
6477 };
6478 let excluded_receipt = receipt_of(&excluded_key);
6479 assert!(excluded_receipt.not_applicable());
6480 assert!(excluded_receipt.steps.is_empty());
6481
6482 let normal_receipt = receipt_of(&normal_key);
6483 assert!(
6484 !normal_receipt.not_applicable(),
6485 "a row that actually ran a step, even a failing one, must never read as \
6486 not_applicable: an excluded row is the one legitimate producer of that outcome"
6487 );
6488 assert!(!normal_receipt.steps.is_empty());
6489 assert!(normal_receipt.failed());
6490 }
6491
6492 #[test]
6499 fn operable_count_matches_how_many_entities_run_action_actually_runs_a_step_against() {
6500 let dir = tempfile::tempdir().expect("temp dir");
6501 let root = root_of(&dir);
6502 let excluded_repo = root.join("excluded");
6503 let normal_repo = root.join("normal");
6504 init_repo_with_a_commit(&excluded_repo);
6505 init_repo_with_a_commit(&normal_repo);
6506
6507 let core = Core::start_discovered(spec_with_overrides(
6508 vec![root],
6509 vec![RepoOverride {
6510 path: excluded_repo.clone(),
6511 default_branch: None,
6512 excluded: true,
6513 }],
6514 ));
6515 let snapshot = core.snapshot();
6516 let find = |path: &Path| {
6517 snapshot
6518 .entities
6519 .iter()
6520 .find(|entity| entity.key.path() == path)
6521 .unwrap_or_else(|| panic!("entity at {path:?} present"))
6522 .key
6523 .clone()
6524 };
6525 let order = [find(&excluded_repo), find(&normal_repo)];
6526
6527 assert_eq!(
6528 core.operable_count(&order),
6529 1,
6530 "one of the two rows is excluded, so exactly one is operable"
6531 );
6532
6533 let started = core.run_action(action("reinstall", vec![step(&["true"])]), &order);
6534 assert!(started);
6535
6536 wait_for("every entity in the order to carry a receipt", || {
6537 let snapshot = core.snapshot();
6538 order.iter().all(|key| {
6539 snapshot
6540 .entities
6541 .iter()
6542 .find(|entity| entity.key == *key)
6543 .and_then(|entity| entity.last_action.as_ref())
6544 .is_some()
6545 })
6546 });
6547
6548 let after = core.snapshot();
6549 let actually_ran = after
6550 .entities
6551 .iter()
6552 .filter(|entity| order.contains(&entity.key))
6553 .filter(|entity| {
6554 entity
6555 .last_action
6556 .as_ref()
6557 .is_some_and(|receipt| !receipt.not_applicable())
6558 })
6559 .count();
6560
6561 assert_eq!(
6562 core.operable_count(&order),
6563 actually_ran,
6564 "operable_count must report exactly how many rows run_action actually ran a \
6565 step against, not merely how many keys resolved"
6566 );
6567 }
6568
6569 #[test]
6574 fn run_action_for_entity_blocking_returns_the_finished_receipt_on_the_calling_thread() {
6575 let dir = tempfile::tempdir().expect("temp dir");
6576 let root = root_of(&dir);
6577 let repo = root.join("repo");
6578 init_repo_with_a_commit(&repo);
6579 let marker = repo.join("hook-ran");
6580
6581 let core = Core::start_discovered(spec_with_overrides(vec![root], Vec::new()));
6582 let key = core
6583 .snapshot()
6584 .entities
6585 .iter()
6586 .find(|entity| entity.key.path() == repo)
6587 .expect("the repo is discovered")
6588 .key
6589 .clone();
6590
6591 let receipt = core
6592 .run_action_for_entity_blocking(
6593 &action("hook", vec![step(&["touch", "hook-ran"])]),
6594 &key,
6595 )
6596 .expect("the entity is known");
6597
6598 assert!(
6599 marker.exists(),
6600 "the step must have already run by the time this call returns"
6601 );
6602 assert_eq!(receipt.steps.len(), 1);
6603 assert_eq!(receipt.steps[0].outcome, StepOutcome::Ok);
6604 }
6605
6606 fn step_that_cannot_prepare(argv: &[&str], resource: &str) -> Step {
6610 Step {
6611 env: vec![(
6612 executor::SETUP_FAILURE_VARIABLE.to_string(),
6613 resource.to_string(),
6614 )],
6615 ..step(argv)
6616 }
6617 }
6618
6619 #[test]
6625 fn a_step_whose_pty_setup_fails_finishes_the_run_and_leaves_a_later_action_working() {
6626 let dir = tempfile::tempdir().expect("temp dir");
6627 let root = root_of(&dir);
6628 let repo = root.join("repo");
6629 init_repo_with_a_commit(&repo);
6630
6631 let core = Core::start_discovered(spec_with_overrides(vec![root], Vec::new()));
6632 let key = core
6633 .snapshot()
6634 .entities
6635 .iter()
6636 .find(|entity| entity.key.path() == repo)
6637 .expect("the repo is discovered")
6638 .key
6639 .clone();
6640
6641 let (tx, rx) = mpsc::channel();
6642 thread::spawn(move || {
6643 let faulted = core.run_action_for_entity_blocking(
6644 &action(
6645 "hook",
6646 vec![
6647 step_that_cannot_prepare(&["touch", "first-ran"], "notify-pipe"),
6648 step(&["touch", "second-ran"]),
6649 ],
6650 ),
6651 &key,
6652 );
6653 let later = core.run_action_for_entity_blocking(
6654 &action("hook", vec![step(&["touch", "later-ran"])]),
6655 &key,
6656 );
6657 let _ = tx.send((faulted, later));
6658 });
6659 let (faulted, later) = rx
6660 .recv_timeout(BACKSTOP)
6661 .expect("a run whose first step cannot prepare its pty must still hand back receipts");
6662
6663 let faulted = faulted.expect("the entity is known");
6664 assert!(
6665 matches!(faulted.steps[0].outcome, StepOutcome::Failed(code) if code != 0),
6666 "expected the first step to fail, got {:?}",
6667 faulted.steps[0].outcome
6668 );
6669 let detail = String::from_utf8_lossy(&faulted.steps[0].output).to_string();
6670 assert!(
6671 detail.contains("pipe that notices"),
6672 "expected the receipt to name the resource that failed, got {detail:?}"
6673 );
6674 assert_eq!(faulted.steps[1].outcome, StepOutcome::NotRun);
6675 assert!(
6676 !repo.join("first-ran").exists() && !repo.join("second-ran").exists(),
6677 "a step that never prepared its pty must never have run its command"
6678 );
6679
6680 let later = later.expect("the entity is known");
6681 assert_eq!(later.steps[0].outcome, StepOutcome::Ok);
6682 assert!(
6683 repo.join("later-ran").exists(),
6684 "a later Action must still run its own command"
6685 );
6686 }
6687
6688 #[test]
6693 fn run_action_for_entity_blocking_answers_none_for_an_unknown_key() {
6694 let dir = tempfile::tempdir().expect("temp dir");
6695 let root = root_of(&dir);
6696 let core = Core::start_discovered(spec_with_overrides(vec![root.clone()], Vec::new()));
6697
6698 let unknown = EntityKey::new(Arc::from(root.join("never-discovered").as_path()));
6699
6700 assert!(
6701 core.run_action_for_entity_blocking(&action("hook", vec![step(&["true"])]), &unknown)
6702 .is_none()
6703 );
6704 }
6705
6706 #[test]
6713 fn run_action_skips_a_row_its_when_predicate_disproves_rather_than_running_it_anyway() {
6714 let dir = tempfile::tempdir().expect("temp dir");
6715 let root = root_of(&dir);
6716 let proved_repo = root.join("alpha");
6717 let disproved_repo = root.join("beta");
6718 init_repo_with_a_commit(&proved_repo);
6719 init_repo_with_a_commit(&disproved_repo);
6720
6721 let core = Core::start_discovered(spec(vec![root]));
6722 let snapshot = core.snapshot();
6723 let find = |path: &Path| {
6724 snapshot
6725 .entities
6726 .iter()
6727 .find(|entity| entity.key.path() == path)
6728 .unwrap_or_else(|| panic!("entity at {path:?} present"))
6729 .key
6730 .clone()
6731 };
6732 let proved_key = find(&proved_repo);
6733 let disproved_key = find(&disproved_repo);
6734 let order = [proved_key.clone(), disproved_key.clone()];
6735
6736 let started = core.run_action(
6739 action_with_when(
6740 "reinstall",
6741 vec![step(&["sh", "-c", "exit 3"])],
6742 "name:alpha",
6743 ),
6744 &order,
6745 );
6746 assert!(started);
6747 wait_for("the fan-out to finish", || !core.action_running());
6748
6749 let after = core.snapshot();
6750 let receipt_of = |key: &EntityKey| {
6751 after
6752 .entities
6753 .iter()
6754 .find(|entity| entity.key == *key)
6755 .unwrap()
6756 .last_action
6757 .clone()
6758 .unwrap()
6759 };
6760
6761 let proved_receipt = receipt_of(&proved_key);
6762 assert_eq!(
6763 proved_receipt.skip, None,
6764 "the row the predicate proved must actually run"
6765 );
6766 assert!(proved_receipt.failed(), "its own step still ran and failed");
6767
6768 let disproved_receipt = receipt_of(&disproved_key);
6769 assert!(
6770 disproved_receipt.inapplicable(),
6771 "the row the predicate disproved must be skipped rather than run"
6772 );
6773 assert!(disproved_receipt.steps.is_empty());
6774 assert!(
6775 !disproved_receipt.failed(),
6776 "a skipped row never ran a step, so it cannot have failed one"
6777 );
6778 }
6779
6780 #[test]
6789 fn applicability_subtracts_an_excluded_row_before_the_predicate_reads_it() {
6790 let dir = tempfile::tempdir().expect("temp dir");
6791 let root = root_of(&dir);
6792 let excluded_repo = root.join("excluded");
6793 let normal_repo = root.join("normal");
6794 init_repo_with_a_commit(&excluded_repo);
6795 init_repo_with_a_commit(&normal_repo);
6796
6797 let core = Core::start_discovered(spec_with_overrides(
6798 vec![root],
6799 vec![RepoOverride {
6800 path: excluded_repo.clone(),
6801 default_branch: None,
6802 excluded: true,
6803 }],
6804 ));
6805 let order: Vec<EntityKey> = core
6806 .snapshot()
6807 .entities
6808 .iter()
6809 .map(|entity| entity.key.clone())
6810 .collect();
6811 assert_eq!(order.len(), 2, "the fixture must discover both repos");
6812
6813 let counts = core.applicability(&order, &Filter::parse("kind:repo"));
6814
6815 assert_eq!(
6816 counts.total(),
6817 core.operable_count(&order),
6818 "the predicate must be counted over exactly the rows `operable_count` keeps"
6819 );
6820 assert_eq!(
6821 counts,
6822 Applicability {
6823 applicable: 1,
6824 inapplicable: 0,
6825 unresolved: 0,
6826 }
6827 );
6828 }
6829
6830 #[test]
6834 fn operable_count_silently_drops_a_key_that_no_longer_resolves() {
6835 let dir = tempfile::tempdir().expect("temp dir");
6836 let root = root_of(&dir);
6837 let repo = root.join("repo");
6838 init_repo_with_a_commit(&repo);
6839
6840 let core = Core::start_discovered(spec(vec![root]));
6841 let real_key = core.snapshot().entities[0].key.clone();
6842 let unknown_key = EntityKey::new(Arc::from(dir.path().join("never-discovered")));
6843
6844 assert_eq!(core.operable_count(&[real_key, unknown_key]), 1);
6845 }
6846
6847 #[test]
6851 fn only_one_action_fan_out_runs_at_a_time_a_second_call_is_rejected_while_one_is_live() {
6852 let dir = tempfile::tempdir().expect("temp dir");
6853 let root = root_of(&dir);
6854 let repo = root.join("repo");
6855 init_repo_with_a_commit(&repo);
6856
6857 let core = Core::start_discovered(spec(vec![root]));
6858 let key = core.snapshot().entities[0].key.clone();
6859 let slow = action("first", vec![step(&["sh", "-c", "sleep 0.3"])]);
6860 let fast = action("second", vec![step(&["true"])]);
6861
6862 let first_started = core.run_action(slow, std::slice::from_ref(&key));
6863 let second_started = core.run_action(fast, std::slice::from_ref(&key));
6864
6865 assert!(first_started);
6866 assert!(
6867 !second_started,
6868 "a second run_action call must be rejected while the first is still in flight"
6869 );
6870 wait_for("the accepted first fan-out to finish", || {
6871 !core.action_running()
6872 });
6873 let receipt = core.snapshot().entities[0].last_action.clone().unwrap();
6874 assert_eq!(
6875 &*receipt.label, "first",
6876 "the surviving receipt must be the accepted first run's, never the rejected second"
6877 );
6878 }
6879
6880 #[test]
6889 fn a_refused_second_submission_leaves_the_first_action_still_stoppable() {
6890 let dir = tempfile::tempdir().expect("temp dir");
6891 let root = root_of(&dir);
6892 let repo = root.join("repo");
6893 init_repo_with_a_commit(&repo);
6894
6895 let core = Core::start_discovered(spec(vec![root]));
6896 let key = core.snapshot().entities[0].key.clone();
6897 let sleep_past_the_backstop = format!("sleep {}", FIXTURE_LIFETIME.as_secs());
6898 let live = action(
6899 "live",
6900 vec![
6901 step(&["sh", "-c", &sleep_past_the_backstop]),
6902 step(&["sh", "-c", &sleep_past_the_backstop]),
6903 ],
6904 );
6905
6906 assert!(core.run_action(live, std::slice::from_ref(&key)));
6907 wait_for("the live run's own first step to start", || {
6908 core.snapshot().entities[0]
6909 .last_action
6910 .as_ref()
6911 .is_some_and(|receipt| receipt.running.is_some())
6912 });
6913
6914 assert!(
6915 !core.run_action(
6916 action("refused", vec![step(&["true"])]),
6917 std::slice::from_ref(&key)
6918 ),
6919 "a second submission must be refused while one run is still live"
6920 );
6921
6922 core.stop_action();
6923
6924 wait_for("the still-controllable run to come down", || {
6925 !core.action_running()
6926 });
6927 let receipt = core.snapshot().entities[0].last_action.clone().unwrap();
6928 assert_eq!(&*receipt.label, "live");
6929 assert_eq!(
6930 receipt.steps[0].outcome,
6931 StepOutcome::Cancelled,
6932 "the refused submission must leave the live run's own control in place, so \
6933 stop_action still reaches the step it was running"
6934 );
6935 assert_eq!(
6936 receipt.steps[1].outcome,
6937 StepOutcome::Cancelled,
6938 "a step that had not started when the run was cancelled must read Cancelled too"
6939 );
6940 }
6941
6942 #[test]
6953 fn a_run_accepted_once_a_completion_releases_its_admission_is_still_stoppable() {
6954 let dir = tempfile::tempdir().expect("temp dir");
6955 let root = root_of(&dir);
6956 let repo = root.join("repo");
6957 init_repo_with_a_commit(&repo);
6958
6959 let core = Core::start_discovered(spec(vec![root]));
6960 let key = core.snapshot().entities[0].key.clone();
6961 let armed = core.action_completion_boundary().arm();
6962
6963 assert!(core.run_action(
6964 action("finishing", vec![step(&["true"])]),
6965 std::slice::from_ref(&key)
6966 ));
6967 armed.wait_until_reached();
6968 assert!(
6969 !core.run_action(
6970 action("early", vec![step(&["true"])]),
6971 std::slice::from_ref(&key)
6972 ),
6973 "a submission made before the completion releases its admission must be refused"
6974 );
6975 drop(armed);
6976 wait_for("the finished run to release its admission", || {
6977 !core.action_running()
6978 });
6979
6980 let sleep_past_the_backstop = format!("sleep {}", FIXTURE_LIFETIME.as_secs());
6981 let following = action(
6982 "following",
6983 vec![
6984 step(&["sh", "-c", &sleep_past_the_backstop]),
6985 step(&["sh", "-c", &sleep_past_the_backstop]),
6986 ],
6987 );
6988 assert!(
6989 core.run_action(following, std::slice::from_ref(&key)),
6990 "a submission made once that release has happened must be accepted"
6991 );
6992 wait_for("the following run's own first step to start", || {
6993 receipt_labelled(&core, &key, "following")
6994 .is_some_and(|receipt| receipt.running.is_some())
6995 });
6996
6997 core.stop_action();
6998
6999 wait_for("the cancelled run to come down", || !core.action_running());
7000 let receipt =
7001 receipt_labelled(&core, &key, "following").expect("the following run's receipt");
7002 assert_eq!(
7003 receipt.steps[0].outcome,
7004 StepOutcome::Cancelled,
7005 "the completion this run followed must leave stop_action still reaching it"
7006 );
7007 assert_eq!(
7008 receipt.steps[1].outcome,
7009 StepOutcome::Cancelled,
7010 "a cancelled run's remaining step must never start, so it reads Cancelled"
7011 );
7012 }
7013
7014 #[test]
7028 fn hold_action_genuinely_pauses_a_running_steps_progress_and_continue_action_resumes_it() {
7029 let dir = tempfile::tempdir().expect("temp dir");
7030 let root = root_of(&dir);
7031 let repo = root.join("repo");
7032 init_repo_with_a_commit(&repo);
7033
7034 let core = Core::start_discovered(spec(vec![root]));
7035 let key = core.snapshot().entities[0].key.clone();
7036 let two_seconds = action("brief", vec![step(&["sh", "-c", "sleep 2"])]);
7037
7038 assert!(core.run_action(two_seconds, std::slice::from_ref(&key)));
7039 wait_for("the two-second step to actually start running", || {
7040 core.snapshot().entities[0]
7041 .last_action
7042 .as_ref()
7043 .is_some_and(|receipt| receipt.running.is_some())
7044 });
7045
7046 for _ in 0..20 {
7054 core.hold_action();
7055 thread::sleep(Duration::from_millis(20));
7056 }
7057
7058 thread::sleep(Duration::from_millis(1_800));
7059 assert!(
7060 core.action_running(),
7061 "a genuinely held step must not have finished on its own well past its own 2s \
7062 sleep; a no-op hold_action would already show this false here"
7063 );
7064
7065 core.continue_action();
7066 wait_for("continue_action to let the held step finish", || {
7067 !core.action_running()
7068 });
7069 let receipt = core.snapshot().entities[0].last_action.clone().unwrap();
7070 assert_eq!(receipt.steps[0].outcome, StepOutcome::Ok);
7071 }
7072
7073 #[test]
7077 fn hold_continue_and_stop_action_are_no_ops_with_no_fan_out_running() {
7078 let dir = tempfile::tempdir().expect("temp dir");
7079 let root = root_of(&dir);
7080 let repo = root.join("repo");
7081 init_repo_with_a_commit(&repo);
7082
7083 let core = Core::start_discovered(spec(vec![root]));
7084
7085 core.hold_action();
7086 core.continue_action();
7087 core.stop_action();
7088
7089 assert!(!core.action_running());
7090 }
7091
7092 #[test]
7114 fn stop_action_escalates_from_sigterm_to_sigkill_against_a_trapping_step() {
7115 let dir = tempfile::tempdir().expect("temp dir");
7116 let root = root_of(&dir);
7117 let repo = root.join("repo");
7118 init_repo_with_a_commit(&repo);
7119
7120 let core = Core::start_discovered(spec(vec![root]));
7121 let key = core.snapshot().entities[0].key.clone();
7122 let sleep_past_the_backstop = format!("trap '' TERM; sleep {}", FIXTURE_LIFETIME.as_secs());
7123 let trapping = action(
7124 "trapping",
7125 vec![step(&["sh", "-c", &sleep_past_the_backstop])],
7126 );
7127
7128 assert!(core.run_action(trapping, std::slice::from_ref(&key)));
7129 wait_for("the trapping step to actually start running", || {
7130 core.snapshot().entities[0]
7131 .last_action
7132 .as_ref()
7133 .is_some_and(|receipt| receipt.running.is_some())
7134 });
7135 thread::sleep(Duration::from_millis(100));
7138
7139 core.stop_action();
7140
7141 wait_for(
7142 "a SIGTERM-trapping step to come down from the follow-up SIGKILL",
7143 || !core.action_running(),
7144 );
7145 let receipt = core.snapshot().entities[0].last_action.clone().unwrap();
7146 assert_eq!(receipt.steps.len(), 1);
7147 assert_eq!(
7148 receipt.steps[0].outcome,
7149 StepOutcome::Cancelled,
7150 "a step running when the run was cancelled must read Cancelled, never Failed"
7151 );
7152 }
7153
7154 #[test]
7169 fn cancelled_and_not_run_are_distinct_outcomes_shown_together_in_one_run() {
7170 let dir = tempfile::tempdir().expect("temp dir");
7171 let root = root_of(&dir);
7172 init_repo_with_a_commit(&root.join("fail"));
7173 init_repo_with_a_commit(&root.join("slow"));
7174
7175 let core = Core::start_discovered(spec(vec![root]));
7176 let snapshot = core.snapshot();
7177 let fail_key = snapshot
7178 .entities
7179 .iter()
7180 .find(|entity| &*entity.name == "fail")
7181 .expect("the fail entity is present")
7182 .key
7183 .clone();
7184 let slow_key = snapshot
7185 .entities
7186 .iter()
7187 .find(|entity| &*entity.name == "slow")
7188 .expect("the slow entity is present")
7189 .key
7190 .clone();
7191
7192 let branch_on_the_entity_name = format!(
7200 "case \"$(basename \"$PWD\")\" in fail) exit 1 ;; *) sleep {} ;; esac",
7201 FIXTURE_LIFETIME.as_secs()
7202 );
7203 let steps = vec![
7204 step(&["sh", "-c", &branch_on_the_entity_name]),
7205 step(&["true"]),
7206 ];
7207 let mut action_spec = action("mixed", steps);
7208 action_spec.concurrency = 2;
7209
7210 assert!(core.run_action(action_spec, &[fail_key.clone(), slow_key.clone()]));
7211
7212 wait_for(
7216 "`fail` finished and `slow` still running before cancelling",
7217 || {
7218 let snapshot = core.snapshot();
7219 let fail_done = snapshot
7220 .entities
7221 .iter()
7222 .find(|entity| entity.key == fail_key)
7223 .and_then(|entity| entity.last_action.as_ref())
7224 .is_some_and(|receipt| receipt.steps.len() == 2);
7225 let slow_running = snapshot
7226 .entities
7227 .iter()
7228 .find(|entity| entity.key == slow_key)
7229 .and_then(|entity| entity.last_action.as_ref())
7230 .is_some_and(|receipt| receipt.running.is_some());
7231 fail_done && slow_running
7232 },
7233 );
7234
7235 core.stop_action();
7236 wait_for("the fan-out to finish once cancelled", || {
7237 !core.action_running()
7238 });
7239
7240 let snapshot = core.snapshot();
7241 let fail_receipt = snapshot
7242 .entities
7243 .iter()
7244 .find(|entity| entity.key == fail_key)
7245 .and_then(|entity| entity.last_action.clone())
7246 .expect("fail's own receipt");
7247 assert_eq!(fail_receipt.steps[0].outcome, StepOutcome::Failed(1));
7248 assert_eq!(
7249 fail_receipt.steps[1].outcome,
7250 StepOutcome::NotRun,
7251 "blocked by fail's own earlier failure, not by the later cancellation"
7252 );
7253
7254 let slow_receipt = snapshot
7255 .entities
7256 .iter()
7257 .find(|entity| entity.key == slow_key)
7258 .and_then(|entity| entity.last_action.clone())
7259 .expect("slow's own receipt");
7260 assert_eq!(
7261 slow_receipt.steps[0].outcome,
7262 StepOutcome::Cancelled,
7263 "a step running when the run was cancelled must read Cancelled"
7264 );
7265 assert_eq!(
7266 slow_receipt.steps[1].outcome,
7267 StepOutcome::Cancelled,
7268 "a step that had not started when the run was cancelled must also read \
7269 Cancelled, never NotRun, which stays reserved for an earlier failure"
7270 );
7271 }
7272
7273 #[test]
7281 fn a_panicking_fan_out_still_resets_action_running_so_a_later_action_can_start() {
7282 let dir = tempfile::tempdir().expect("temp dir");
7283 let root = root_of(&dir);
7284 let repo = root.join("repo");
7285 init_repo_with_a_commit(&repo);
7286
7287 let (core, launched) = started_and_settled(spec(vec![root]));
7291 let key = launched.entities[0].key.clone();
7292
7293 let started = core.run_action(
7300 action("boom", vec![step(&["sh", "-c", "sleep 0.3"])]),
7301 std::slice::from_ref(&key),
7302 );
7303 assert!(started);
7304
7305 let table = Arc::clone(&core.table);
7306 thread::spawn(move || {
7307 let _guard = table.write().unwrap();
7308 panic!("deliberately poison the table lock for this test");
7309 })
7310 .join()
7311 .expect_err("the poisoning thread must itself panic to poison the lock");
7312
7313 wait_for(
7318 "a panicking fan-out to end its run rather than leave it reading as live",
7319 || !core.action_running(),
7320 );
7321
7322 core.table.clear_poison();
7327
7328 let second_started = core.run_action(
7329 action("second", vec![step(&["true"])]),
7330 std::slice::from_ref(&key),
7331 );
7332 assert!(
7333 second_started,
7334 "a later Action must be able to start once the panicking one has finished"
7335 );
7336 wait_for("the second Action to run to completion", || {
7337 core.snapshot()
7338 .entities
7339 .iter()
7340 .find(|entity| entity.key == key)
7341 .and_then(|entity| entity.last_action.as_ref())
7342 .is_some_and(|receipt| &*receipt.label == "second")
7343 });
7344 }
7345
7346 fn assert_vanished_with_stale_branch(entity: &EntityState, expected_branch: &str) {
7352 assert_eq!(entity.presence, crate::entity::Presence::Vanished);
7353 match entity.branch.settled() {
7354 Some(Settled::Known {
7355 value: Head::Branch { name, .. },
7356 stale: true,
7357 at: _,
7358 }) => assert_eq!(
7359 &**name, expected_branch,
7360 "a Vanished entity must keep its last known branch value"
7361 ),
7362 other => panic!(
7363 "expected the branch cell to keep its Known value and go stale, got {other:?}"
7364 ),
7365 }
7366 }
7367
7368 #[test]
7374 fn a_repo_removed_from_disk_stays_in_the_table_vanished_with_its_last_values() {
7375 let dir = tempfile::tempdir().expect("temp dir");
7376 let root = root_of(&dir);
7377 let repo = root.join("repo");
7378 init_repo_with_a_commit(&repo);
7379
7380 let core = Core::start_discovered(spec(vec![root]));
7381 let key = core.snapshot().entities[0].key.clone();
7382 core.refresh(std::slice::from_ref(&key));
7383 let before = core.settle();
7384 let branch_name = match before.entities[0].branch.settled() {
7385 Some(Settled::Known {
7386 value: Head::Branch { name, .. },
7387 at: _,
7388 stale: _,
7389 }) => name.to_string(),
7390 other => panic!("expected the first refresh to settle a branch, got {other:?}"),
7391 };
7392
7393 fs::remove_dir_all(&repo).expect("remove the repo from disk");
7394
7395 core.refresh(&[]);
7396 let after = core.settle();
7397
7398 assert_eq!(
7399 after.entities.len(),
7400 1,
7401 "a vanished entity must stay in the snapshot, not disappear from it"
7402 );
7403 assert_vanished_with_stale_branch(&after.entities[0], &branch_name);
7404 }
7405
7406 #[test]
7410 fn a_vanished_entitys_action_receipt_survives_the_vanished_staleness_pass_untouched() {
7411 let dir = tempfile::tempdir().expect("temp dir");
7412 let root = root_of(&dir);
7413 let repo = root.join("repo");
7414 init_repo_with_a_commit(&repo);
7415
7416 let core = Core::start_discovered(spec(vec![root]));
7417 let key = core.snapshot().entities[0].key.clone();
7418 let receipt = crate::entity::ActionReceipt {
7419 label: Arc::from("reinstall"),
7420 steps: Arc::from(vec![crate::entity::StepResult {
7421 label: Arc::from("pnpm install"),
7422 outcome: crate::entity::StepOutcome::Ok,
7423 output: Arc::from(&b""[..]),
7424 elapsed: Duration::from_millis(1),
7425 elision: None,
7426 shell: false,
7427 interactive: false,
7428 }]),
7429 skip: None,
7430 finished_at: Timestamp::now(),
7431 running: None,
7432 };
7433 core.set_last_action_for_test(&key, receipt.clone());
7434
7435 fs::remove_dir_all(&repo).expect("remove the repo from disk");
7436 core.refresh(&[]);
7437 let after = core.settle();
7438
7439 let entity = &after.entities[0];
7440 assert_eq!(entity.presence, crate::entity::Presence::Vanished);
7441 assert_eq!(entity.last_action, Some(receipt));
7442 }
7443
7444 #[test]
7452 fn two_snapshots_of_an_entity_share_its_last_actions_label_and_steps_by_pointer() {
7453 let dir = tempfile::tempdir().expect("temp dir");
7454 let root = root_of(&dir);
7455 let repo = root.join("repo");
7456 init_repo_with_a_commit(&repo);
7457
7458 let core = Core::start_discovered(spec(vec![root]));
7459 let key = core.snapshot().entities[0].key.clone();
7460 let receipt = crate::entity::ActionReceipt {
7461 label: Arc::from("reinstall"),
7462 steps: Arc::from(vec![crate::entity::StepResult {
7463 label: Arc::from("pnpm install"),
7464 outcome: crate::entity::StepOutcome::Failed(1),
7465 output: Arc::from(&b""[..]),
7466 elapsed: Duration::from_millis(1),
7467 elision: None,
7468 shell: false,
7469 interactive: false,
7470 }]),
7471 skip: None,
7472 finished_at: Timestamp::now(),
7473 running: None,
7474 };
7475 core.set_last_action_for_test(&key, receipt);
7476
7477 let first = core.snapshot();
7478 let second = core.snapshot();
7479 let first_receipt = first.entities[0]
7480 .last_action
7481 .as_ref()
7482 .expect("receipt was set");
7483 let second_receipt = second.entities[0]
7484 .last_action
7485 .as_ref()
7486 .expect("receipt was set");
7487
7488 assert!(
7489 Arc::ptr_eq(&first_receipt.label, &second_receipt.label),
7490 "two snapshots of the same receipt must share the label's allocation, not \
7491 re-copy it"
7492 );
7493 assert!(
7494 Arc::ptr_eq(&first_receipt.steps, &second_receipt.steps),
7495 "two snapshots of the same receipt must share the steps slice's allocation, not \
7496 re-copy it, which is also what shares every step's own captured output"
7497 );
7498 }
7499
7500 #[test]
7506 fn a_submodule_removed_from_gitmodules_vanishes_by_the_same_rule_as_a_repo() {
7507 let dir = tempfile::tempdir().expect("temp dir");
7508 let root = root_of(&dir);
7509 let parent = root.join("parent");
7510 init_repo_with_a_commit(&parent);
7511 fs::write(
7512 parent.join(".gitmodules"),
7513 "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
7514 )
7515 .expect("write .gitmodules");
7516 let submodule_path = parent.join("vendor").join("lib");
7517 init_repo_with_a_commit(&submodule_path);
7518
7519 let mut core_spec = spec(vec![root]);
7522 core_spec.show_submodules = true;
7523 let core = Core::start_discovered(core_spec);
7524 let snapshot = core.snapshot();
7525 let submodule_key = snapshot
7526 .entities
7527 .iter()
7528 .find(|entity| matches!(entity.kind, Kind::Submodule))
7529 .expect("submodule discovered")
7530 .key
7531 .clone();
7532 core.refresh(std::slice::from_ref(&submodule_key));
7533 let before = core.settle();
7534 let submodule_before = before
7535 .entities
7536 .iter()
7537 .find(|entity| entity.key == submodule_key)
7538 .expect("submodule present");
7539 let branch_name = match submodule_before.branch.settled() {
7540 Some(Settled::Known {
7541 value: Head::Branch { name, .. },
7542 at: _,
7543 stale: _,
7544 }) => name.to_string(),
7545 other => {
7546 panic!("expected the submodule's first refresh to settle a branch, got {other:?}")
7547 }
7548 };
7549
7550 fs::write(parent.join(".gitmodules"), "").expect("clear .gitmodules");
7554
7555 core.refresh(&[]);
7556 let after = core.settle();
7557
7558 let submodule_after = after
7559 .entities
7560 .iter()
7561 .find(|entity| entity.key == submodule_key)
7562 .expect("the vanished submodule must stay in the snapshot");
7563 assert_vanished_with_stale_branch(submodule_after, &branch_name);
7564 }
7565
7566 #[test]
7571 fn dismissal_persists_nothing_across_a_fresh_core() {
7572 let dir = tempfile::tempdir().expect("temp dir");
7573 let root = root_of(&dir);
7574 let repo = root.join("repo");
7575 init_repo_with_a_commit(&repo);
7576
7577 let first_core = Core::start_discovered(spec(vec![root.clone()]));
7578 let key = first_core.snapshot().entities[0].key.clone();
7579 first_core.dismiss(&key);
7580 assert!(first_core.snapshot().entities.is_empty());
7581 drop(first_core);
7582
7583 let second_core = Core::start_discovered(spec(vec![root]));
7584 let snapshot = second_core.snapshot();
7585
7586 assert_eq!(
7587 snapshot.entities.len(),
7588 1,
7589 "a fresh Core must discover the repo again"
7590 );
7591 assert_eq!(
7592 snapshot.entities[0].presence,
7593 crate::entity::Presence::Present,
7594 "nothing from the dismissing Core's lifetime may be persisted, so the \
7595 repo must come back Present, never restored as Vanished"
7596 );
7597 }
7598
7599 #[test]
7603 fn a_repo_that_moves_reads_as_vanished_plus_new() {
7604 let dir = tempfile::tempdir().expect("temp dir");
7605 let root = root_of(&dir);
7606 let original_path = root.join("original-name");
7607 init_repo_with_a_commit(&original_path);
7608
7609 let core = Core::start_discovered(spec(vec![root.clone()]));
7610 let original_key = core.snapshot().entities[0].key.clone();
7611 core.refresh(std::slice::from_ref(&original_key));
7612 let before = core.settle();
7613 let branch_name = match before.entities[0].branch.settled() {
7614 Some(Settled::Known {
7615 value: Head::Branch { name, .. },
7616 at: _,
7617 stale: _,
7618 }) => name.to_string(),
7619 other => panic!("expected the first refresh to settle a branch, got {other:?}"),
7620 };
7621
7622 let moved_path = root.join("new-name");
7623 fs::rename(&original_path, &moved_path).expect("move the repo on disk");
7624
7625 core.refresh(&[]);
7626 let after = core.settle();
7627
7628 assert_eq!(
7629 after.entities.len(),
7630 2,
7631 "a moved entity must read as the old key vanished plus a new one present, \
7632 never as one renamed entity"
7633 );
7634 let old_entity = after
7635 .entities
7636 .iter()
7637 .find(|entity| entity.key == original_key)
7638 .expect("the old key must stay in the table");
7639 assert_vanished_with_stale_branch(old_entity, &branch_name);
7640 let new_entity = after
7641 .entities
7642 .iter()
7643 .find(|entity| entity.key != original_key)
7644 .expect("a new entity at the moved path must be present");
7645 assert_eq!(new_entity.presence, crate::entity::Presence::Present);
7646 assert_eq!(new_entity.key.path(), moved_path);
7647 }
7648
7649 #[test]
7653 fn a_vanished_repo_recreated_on_disk_reads_present_on_the_next_refresh() {
7654 let dir = tempfile::tempdir().expect("temp dir");
7655 let root = root_of(&dir);
7656 let repo = root.join("repo");
7657 init_repo_with_a_commit(&repo);
7658
7659 let core = Core::start_discovered(spec(vec![root]));
7660 let key = core.snapshot().entities[0].key.clone();
7661
7662 fs::remove_dir_all(&repo).expect("remove the repo from disk");
7663 core.refresh(&[]);
7664 let vanished = core.settle();
7665 assert_eq!(
7666 vanished.entities[0].presence,
7667 crate::entity::Presence::Vanished,
7668 "the repo must read Vanished once removed from disk"
7669 );
7670
7671 init_repo_with_a_commit(&repo);
7672 core.refresh(&[]);
7673 let recreated = core.settle();
7674
7675 let entity = recreated
7676 .entities
7677 .iter()
7678 .find(|entity| entity.key == key)
7679 .expect("the recreated repo must still resolve to the same entity key");
7680 assert_eq!(
7681 entity.presence,
7682 crate::entity::Presence::Present,
7683 "an entity discovery finds again after it vanished must read Present, \
7684 not stay stuck Vanished forever"
7685 );
7686 }
7687
7688 #[test]
7693 fn a_new_repo_created_after_start_is_discovered_by_the_next_refresh() {
7694 let dir = tempfile::tempdir().expect("temp dir");
7695 let root = root_of(&dir);
7696 init_repo_with_a_commit(&root.join("first"));
7697
7698 let core = Core::start_discovered(spec(vec![root.clone()]));
7699 assert_eq!(core.snapshot().entities.len(), 1);
7700
7701 init_repo_with_a_commit(&root.join("second"));
7702 core.refresh(&[]);
7703 let after = core.settle();
7704
7705 assert_eq!(
7706 after.entities.len(),
7707 2,
7708 "a new repo created after start must be found by the next refresh's own discovery"
7709 );
7710
7711 let new_key = after
7714 .entities
7715 .iter()
7716 .find(|entity| &*entity.name == "second")
7717 .expect("the newly discovered repo must be named by the walk")
7718 .key
7719 .clone();
7720 core.refresh(std::slice::from_ref(&new_key));
7721 let probed = core.settle();
7722 let new_entity = probed
7723 .entities
7724 .iter()
7725 .find(|entity| entity.key == new_key)
7726 .expect("the newly discovered repo must still be present");
7727 assert!(
7728 matches!(
7729 new_entity.branch.settled(),
7730 Some(Settled::Known {
7731 value: _,
7732 at: _,
7733 stale: _
7734 })
7735 ),
7736 "a refresh naming the newly discovered repo's key must actually probe \
7737 it and settle its branch cell, got {:?}",
7738 new_entity.branch.settled()
7739 );
7740 }
7741
7742 #[test]
7747 fn an_abandoned_discovery_stops_riding_later_refreshes() {
7748 let dir = tempfile::tempdir().expect("temp dir");
7749 let root = root_of(&dir);
7750 let decoys = root.join("decoys");
7757 for i in 0..4_000 {
7758 fs::create_dir(decoys.join(format!("decoy-{i}")))
7759 .or_else(|_| fs::create_dir_all(decoys.join(format!("decoy-{i}"))))
7760 .expect("create decoy dir");
7761 }
7762 let (_tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
7763
7764 let started = Core::start_for_test_with_discovery_abandon(
7765 spec(vec![root.clone()]),
7766 Duration::from_secs(3600),
7767 Duration::from_micros(500),
7768 tick_rx,
7769 )
7770 .discovered();
7771 let core = started.core;
7772 assert!(
7773 core.discovery_manual_for_test(),
7774 "walking 4,000 decoy directories against a 500 microsecond deadline \
7775 must have abandoned and taken the Set manual"
7776 );
7777
7778 fs::remove_dir_all(&decoys).expect("remove decoy directories");
7783 init_repo_with_a_commit(&root.join("second"));
7784
7785 core.refresh(&[]);
7786 let after = core.settle();
7787
7788 assert!(
7789 !after
7790 .entities
7791 .iter()
7792 .any(|entity| &*entity.name == "second"),
7793 "once discovery has abandoned, a later refresh must not re-run it, so a \
7794 repo created afterward, on a tree that would now resolve quickly, \
7795 must still never appear"
7796 );
7797 }
7798
7799 #[test]
7808 fn a_refresh_triggered_discovery_abandon_sets_manual_and_warns() {
7809 let dir = tempfile::tempdir().expect("temp dir");
7810 let root = root_of(&dir);
7811 init_repo_with_a_commit(&root.join("first"));
7812 let (_tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
7813
7814 let started = Core::start_for_test_with_discovery_abandon(
7820 spec(vec![root.clone()]),
7821 Duration::from_secs(3600),
7822 Duration::from_secs(3600),
7823 tick_rx,
7824 )
7825 .discovered();
7826 let core = started.core;
7827 assert!(
7828 !core.discovery_manual_for_test(),
7829 "an hour-long deadline must leave the first walk automatic"
7830 );
7831
7832 let decoys = root.join("decoys");
7836 for i in 0..4_000 {
7837 fs::create_dir(decoys.join(format!("decoy-{i}")))
7838 .or_else(|_| fs::create_dir_all(decoys.join(format!("decoy-{i}"))))
7839 .expect("create decoy dir");
7840 }
7841 core.set_discovery_abandon_after_for_test(Duration::from_micros(500));
7842
7843 core.refresh(&[]);
7844 core.wait_dispatched_for_test();
7847
7848 assert!(
7849 core.discovery_manual_for_test(),
7850 "refresh's own rerun_discovery must abandon against the newly-grown \
7851 tree and take the Set manual, the same as an abandon at start does"
7852 );
7853 let warning = core.discovery_warning();
7854 assert!(
7855 warning
7856 .as_deref()
7857 .is_some_and(|message| message.starts_with("discovery: stopped at")),
7858 "refresh's rerun_discovery must leave the abandoned-discovery warning \
7859 behind, not merely flip the manual flag: got {warning:?}"
7860 );
7861 }
7862
7863 #[test]
7869 fn a_fresh_core_over_different_roots_is_unaffected_by_another_cores_abandoned_discovery() {
7870 let abandoned_dir = tempfile::tempdir().expect("temp dir");
7871 let abandoned_root = root_of(&abandoned_dir);
7872 init_repo_with_a_commit(&abandoned_root.join("first"));
7873 let (_tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
7874 let started = Core::start_for_test_with_discovery_abandon(
7875 spec(vec![abandoned_root]),
7876 Duration::from_secs(3600),
7877 Duration::ZERO,
7878 tick_rx,
7879 )
7880 .discovered();
7881 started.core.refresh(&[]);
7882 started.core.settle();
7883 assert!(
7884 started.core.discovery_manual_for_test(),
7885 "the zero-length abandon deadline must have already taken this Core manual"
7886 );
7887 drop(started.core);
7888
7889 let fresh_dir = tempfile::tempdir().expect("temp dir");
7890 let fresh_root = root_of(&fresh_dir);
7891 init_repo_with_a_commit(&fresh_root.join("first"));
7892 let fresh_core = Core::start_discovered(spec(vec![fresh_root.clone()]));
7893 assert_eq!(fresh_core.snapshot().entities.len(), 1);
7894
7895 init_repo_with_a_commit(&fresh_root.join("second"));
7896 fresh_core.refresh(&[]);
7897 let after = fresh_core.settle();
7898
7899 assert_eq!(
7900 after.entities.len(),
7901 2,
7902 "a fresh Core, standing in for the Set's roots changing, must discover \
7903 normally regardless of an earlier, unrelated Core having gone manual"
7904 );
7905 }
7906
7907 #[test]
7912 fn dropping_the_core_joins_the_dedicated_thread_before_returning() {
7913 let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
7914 let dir = tempfile::tempdir().expect("temp dir");
7915 let root = root_of(&dir);
7916
7917 let started =
7918 Core::start_for_test(spec(vec![root]), Duration::from_secs(3600), tick_rx).discovered();
7919 assert!(started.clock_alive.load(Ordering::Acquire));
7920
7921 drop(started.core);
7922
7923 assert!(
7924 !started.clock_alive.load(Ordering::Acquire),
7925 "the dedicated thread should have exited, and cleared this flag, before drop returned"
7926 );
7927 drop(tick_tx);
7928 }
7929
7930 #[test]
7935 fn the_deadline_sweep_runs_only_when_a_tick_arrives() {
7936 let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
7937 let dir = tempfile::tempdir().expect("temp dir");
7938 let root = root_of(&dir);
7939 let repo = root.join("repo");
7940 init_repo_with_a_commit(&repo);
7941
7942 let mut spec = spec(vec![root]);
7943 spec.generation_deadline = Duration::ZERO;
7944 let started = Core::start_for_test(spec, Duration::from_secs(3600), tick_rx).discovered();
7945 let core = started.core;
7946 let key = settle_launch(&core).entities[0].key.clone();
7949
7950 core.begin_untracked_probe_for_test(&key);
7951
7952 let before = core.snapshot();
7955 assert!(
7956 matches!(
7957 before.entities[0].branch.settled(),
7958 Some(Settled::Known {
7959 value: _,
7960 at: _,
7961 stale: _
7962 })
7963 ),
7964 "the cell still holds launch's own answer here, so the Unknown below is the \
7965 sweep's write rather than a cell that was already empty"
7966 );
7967 assert!(before.entities[0].branch.is_in_flight());
7968
7969 tick_tx.send(Instant::now()).expect("send one tick");
7970 let after = core.settle();
7971
7972 assert!(matches!(
7973 after.entities[0].branch.settled(),
7974 Some(Settled::Unknown(Unknown::TimedOut))
7975 ));
7976 }
7977
7978 #[test]
7987 fn a_real_tick_through_the_dedicated_thread_reaches_the_poll_sweep_and_reprobes_a_moved_entity()
7988 {
7989 let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
7990 let dir = tempfile::tempdir().expect("temp dir");
7991 let root = root_of(&dir);
7992 let repo = root.join("repo");
7993 init_repo_with_a_commit(&repo);
7994
7995 let started =
7996 Core::start_for_test(spec(vec![root]), Duration::from_secs(3600), tick_rx).discovered();
7997 let core = started.core;
7998 let key = core.snapshot().entities[0].key.clone();
7999
8000 backdate_polled_entries(&repo);
8001
8002 tick_tx
8005 .send(Instant::now())
8006 .expect("send the baseline tick");
8007 wait_for(
8008 "a tick sent on the real channel to reach the poll sweep",
8009 || core.poll_sweep_count_for_test() >= 1,
8010 );
8011 assert!(core.poll_reprobed_for_test().is_empty());
8012
8013 commit_a_change(&repo, "second");
8014
8015 tick_tx
8016 .send(Instant::now())
8017 .expect("send the movement tick");
8018 wait_for(
8019 "the real tick channel to reach the poll sweep and reprobe the moved entity",
8020 || core.poll_reprobed_for_test() == vec![key.clone()],
8021 );
8022 drop(tick_tx);
8023 }
8024
8025 #[test]
8033 fn poll_reprobe_touches_only_the_moved_entity_and_never_runs_a_status_probe() {
8034 let dir = tempfile::tempdir().expect("temp dir");
8035 let root = root_of(&dir);
8036 let repo_a = root.join("repo-a");
8037 let repo_b = root.join("repo-b");
8038 init_repo_with_a_commit(&repo_a);
8039 init_repo_with_a_commit(&repo_b);
8040
8041 let core = Core::start_discovered(spec(vec![root]));
8042 let snapshot = core.snapshot();
8043 let key_a = snapshot
8044 .entities
8045 .iter()
8046 .find(|entity| entity.key.path() == repo_a)
8047 .expect("repo-a discovered")
8048 .key
8049 .clone();
8050 let key_b = snapshot
8051 .entities
8052 .iter()
8053 .find(|entity| entity.key.path() == repo_b)
8054 .expect("repo-b discovered")
8055 .key
8056 .clone();
8057
8058 core.refresh(&[key_a.clone(), key_b.clone()]);
8059 let landed = core.settle();
8060 let entity_of = |snapshot: &Snapshot, key: &EntityKey| {
8061 snapshot
8062 .entities
8063 .iter()
8064 .find(|entity| &entity.key == key)
8065 .expect("entity present")
8066 .clone()
8067 };
8068 let a_before = entity_of(&landed, &key_a);
8069 let b_before = entity_of(&landed, &key_b);
8070 let branch_at = |entity: &EntityState| match entity.branch.settled() {
8071 Some(Settled::Known {
8072 at,
8073 value: _,
8074 stale: _,
8075 }) => *at,
8076 other => panic!("expected a landed branch, got {other:?}"),
8077 };
8078 let dirty_state = |entity: &EntityState| match entity.dirty.settled() {
8079 Some(Settled::Known { value, at, stale }) => (*value, *at, *stale),
8080 other => panic!("expected a landed dirty count, got {other:?}"),
8081 };
8082 let (a_dirty_value_before, a_dirty_at_before, a_dirty_stale_before) =
8083 dirty_state(&a_before);
8084 assert!(
8085 !a_dirty_stale_before,
8086 "the fresh refresh must land dirty as not stale"
8087 );
8088
8089 backdate_polled_entries(&repo_a);
8090
8091 backdate_polled_entries(&repo_b);
8092
8093 core.poll_once_for_test();
8094 assert!(
8095 core.poll_reprobed_for_test().is_empty(),
8096 "a first sweep has nothing to compare against, so it must report no movement"
8097 );
8098
8099 commit_a_change(&repo_a, "second");
8100 core.poll_once_for_test();
8101
8102 assert_eq!(
8103 core.poll_reprobed_for_test(),
8104 vec![key_a.clone()],
8105 "only the entity whose gitdir actually moved must be re-probed"
8106 );
8107
8108 let after = core.snapshot();
8109 let a_after = entity_of(&after, &key_a);
8110 let b_after = entity_of(&after, &key_b);
8111
8112 assert_ne!(
8113 branch_at(&a_after),
8114 branch_at(&a_before),
8115 "the moved entity's branch must carry a fresh timestamp from the re-probe"
8116 );
8117 let (a_dirty_value_after, a_dirty_at_after, a_dirty_stale_after) = dirty_state(&a_after);
8118 assert_eq!(
8119 a_dirty_value_after, a_dirty_value_before,
8120 "no status probe ran, so dirty's value must be exactly what the last real refresh \
8121 landed"
8122 );
8123 assert_eq!(
8124 a_dirty_at_after, a_dirty_at_before,
8125 "no status probe ran, so dirty's timestamp must be untouched, only its stale flag \
8126 set"
8127 );
8128 assert!(
8129 a_dirty_stale_after,
8130 "the moved entity's dirty cell must go stale on poll evidence"
8131 );
8132
8133 assert_eq!(
8134 branch_at(&b_after),
8135 branch_at(&b_before),
8136 "the untouched entity's branch must be exactly as the prior refresh left it"
8137 );
8138 let (b_dirty_value_after, b_dirty_at_after, b_dirty_stale_after) = dirty_state(&b_after);
8139 let (b_dirty_value_before, b_dirty_at_before, b_dirty_stale_before) =
8140 dirty_state(&b_before);
8141 assert_eq!(b_dirty_value_after, b_dirty_value_before);
8142 assert_eq!(b_dirty_at_after, b_dirty_at_before);
8143 assert_eq!(
8144 b_dirty_stale_after, b_dirty_stale_before,
8145 "an entity the sweep found unmoved must never go stale"
8146 );
8147 }
8148
8149 #[test]
8154 fn poll_detects_an_attached_commit_through_index_while_head_itself_never_moves() {
8155 let dir = tempfile::tempdir().expect("temp dir");
8156 let root = root_of(&dir);
8157 let repo = root.join("repo");
8158 init_repo_with_a_commit(&repo);
8159
8160 let core = Core::start_discovered(spec(vec![root]));
8161 let key = core.snapshot().entities[0].key.clone();
8162 backdate_polled_entries(&repo);
8163 core.poll_once_for_test();
8164 assert!(core.poll_reprobed_for_test().is_empty());
8165
8166 let head_path = repo.join(".git").join("HEAD");
8167 let head_mtime_before = fs::metadata(&head_path)
8168 .expect("stat HEAD")
8169 .modified()
8170 .expect("HEAD mtime");
8171
8172 commit_a_change(&repo, "second");
8173
8174 let head_mtime_after = fs::metadata(&head_path)
8175 .expect("stat HEAD")
8176 .modified()
8177 .expect("HEAD mtime");
8178 assert_eq!(
8179 head_mtime_before, head_mtime_after,
8180 "a commit on an attached HEAD must never touch HEAD itself"
8181 );
8182
8183 core.poll_once_for_test();
8184 assert_eq!(
8185 core.poll_reprobed_for_test(),
8186 vec![key],
8187 "the poll must still detect the attached commit, through index rather than HEAD"
8188 );
8189 }
8190
8191 #[test]
8198 fn poll_detects_a_detached_commit_through_the_per_worktree_head_file() {
8199 let dir = tempfile::tempdir().expect("temp dir");
8200 let root = root_of(&dir);
8201 let parent = root.join("parent");
8202 init_repo_with_a_commit(&parent);
8203 let worktree_path = root.join("detached-worktree");
8204 let status = Command::new("git")
8205 .arg("-C")
8206 .arg(&parent)
8207 .args([
8208 "worktree",
8209 "add",
8210 "--detach",
8211 worktree_path.to_str().expect("utf8 path"),
8212 ])
8213 .status()
8214 .expect("run git worktree add");
8215 assert!(status.success());
8216
8217 let core = Core::start_discovered(spec(vec![root]));
8218 let snapshot = core.snapshot();
8219 let worktree_key = snapshot
8220 .entities
8221 .iter()
8222 .find(|entity| matches!(entity.kind, Kind::Worktree))
8223 .expect("worktree discovered")
8224 .key
8225 .clone();
8226
8227 backdate_polled_entries(&parent);
8228 backdate_polled_entries(&worktree_path);
8229
8230 core.poll_once_for_test();
8231 assert!(core.poll_reprobed_for_test().is_empty());
8232
8233 let worktree_head_path = parent
8234 .join(".git")
8235 .join("worktrees")
8236 .join("detached-worktree")
8237 .join("HEAD");
8238 let head_mtime_before = fs::metadata(&worktree_head_path)
8239 .expect("stat the per-worktree HEAD")
8240 .modified()
8241 .expect("HEAD mtime");
8242
8243 commit_a_change(&worktree_path, "on the detached worktree");
8244
8245 let head_mtime_after = fs::metadata(&worktree_head_path)
8246 .expect("stat the per-worktree HEAD")
8247 .modified()
8248 .expect("HEAD mtime");
8249 assert_ne!(
8250 head_mtime_before, head_mtime_after,
8251 "a commit on a detached HEAD must write the new object id straight into its own \
8252 HEAD file"
8253 );
8254
8255 core.poll_once_for_test();
8256 assert_eq!(
8257 core.poll_reprobed_for_test(),
8258 vec![worktree_key],
8259 "the poll must detect the detached commit via the per-worktree HEAD file"
8260 );
8261 }
8262
8263 #[test]
8270 fn snapshot_ages_a_freshly_landed_dirty_cell_stale_once_status_stale_after_has_elapsed() {
8271 let dir = tempfile::tempdir().expect("temp dir");
8272 let root = root_of(&dir);
8273 let repo = root.join("repo");
8274 init_repo_with_a_commit(&repo);
8275
8276 let mut short_lived = spec(vec![root]);
8277 short_lived.status_stale_after = Duration::from_nanos(1);
8278 let core = Core::start_discovered(short_lived);
8279 let key = core.snapshot().entities[0].key.clone();
8280 core.refresh(std::slice::from_ref(&key));
8281 core.settle();
8282
8283 let aged = core.snapshot();
8284 match aged.entities[0].dirty.settled() {
8285 Some(Settled::Known {
8286 stale: true,
8287 value: _,
8288 at: _,
8289 }) => {}
8290 other => panic!(
8291 "expected a landed dirty cell to have already aged past a one-nanosecond \
8292 threshold, got {other:?}"
8293 ),
8294 }
8295 }
8296
8297 #[test]
8301 fn snapshot_leaves_a_freshly_landed_dirty_cell_fresh_under_a_large_status_stale_after() {
8302 let dir = tempfile::tempdir().expect("temp dir");
8303 let root = root_of(&dir);
8304 let repo = root.join("repo");
8305 init_repo_with_a_commit(&repo);
8306
8307 let core = Core::start_discovered(spec(vec![root]));
8308 let key = core.snapshot().entities[0].key.clone();
8309 core.refresh(std::slice::from_ref(&key));
8310 core.settle();
8311
8312 let fresh = core.snapshot();
8313 match fresh.entities[0].dirty.settled() {
8314 Some(Settled::Known {
8315 stale: false,
8316 value: _,
8317 at: _,
8318 }) => {}
8319 other => panic!("expected a freshly landed dirty cell to stay fresh, got {other:?}"),
8320 }
8321 }
8322
8323 #[test]
8329 fn hidden_submodules_are_never_polled_but_shown_ones_are() {
8330 let dir = tempfile::tempdir().expect("temp dir");
8331 let root = root_of(&dir);
8332 let parent = root.join("parent");
8333 init_repo_with_a_commit(&parent);
8334 fs::write(
8335 parent.join(".gitmodules"),
8336 "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
8337 )
8338 .expect("write .gitmodules");
8339 let submodule_path = parent.join("vendor").join("lib");
8340 init_repo_with_a_commit(&submodule_path);
8341
8342 let mut hidden_spec = spec(vec![root.clone()]);
8343 hidden_spec.show_submodules = false;
8344 let hidden_core = Core::start_discovered(hidden_spec);
8345 let hidden_submodule_key = hidden_core
8350 .snapshot()
8351 .entities
8352 .iter()
8353 .find(|entity| matches!(entity.kind, Kind::Submodule))
8354 .expect("the submodule is discovered regardless of show_submodules")
8355 .key
8356 .clone();
8357 backdate_polled_entries(&submodule_path);
8358 hidden_core.poll_once_for_test();
8359 commit_a_change(&submodule_path, "into the hidden submodule");
8360 hidden_core.poll_once_for_test();
8361 assert!(
8362 !hidden_core
8363 .poll_reprobed_for_test()
8364 .contains(&hidden_submodule_key),
8365 "a hidden Submodule must never be re-probed by the poll, since it was never \
8366 polled at all"
8367 );
8368 drop(hidden_core);
8369
8370 let mut shown_spec = spec(vec![root]);
8371 shown_spec.show_submodules = true;
8372 let shown_core = Core::start_discovered(shown_spec);
8373 let submodule_key = shown_core
8374 .snapshot()
8375 .entities
8376 .iter()
8377 .find(|entity| matches!(entity.kind, Kind::Submodule))
8378 .expect("the submodule is discovered regardless of show_submodules")
8379 .key
8380 .clone();
8381 backdate_polled_entries(&submodule_path);
8382 shown_core.poll_once_for_test();
8383 commit_a_change(&submodule_path, "into the shown submodule");
8384 shown_core.poll_once_for_test();
8385 assert_eq!(
8386 shown_core.poll_reprobed_for_test(),
8387 vec![submodule_key],
8388 "a shown Submodule must be polled and re-probed exactly like any other row"
8389 );
8390 }
8391
8392 #[test]
8398 fn pause_cancels_every_in_flight_entity_and_releases_a_pending_settle() {
8399 let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
8400 let dir = tempfile::tempdir().expect("temp dir");
8401 let root = root_of(&dir);
8402 let repo = root.join("repo");
8403 init_repo_with_a_commit(&repo);
8404
8405 let started =
8406 Core::start_for_test(spec(vec![root]), Duration::from_secs(3600), tick_rx).discovered();
8407 let core = started.core;
8408 let key = settle_launch(&core).entities[0].key.clone();
8410 let cancel = core.begin_untracked_probe_for_test(&key);
8411 assert!(!cancel.load(Ordering::Acquire));
8412
8413 core.pause();
8414 let settled = core.settle();
8415
8416 assert!(
8417 cancel.load(Ordering::Acquire),
8418 "pause should cancel the entity that was in flight"
8419 );
8420 assert!(settled.entities[0].branch.is_in_flight());
8421 drop(tick_tx);
8422 }
8423
8424 #[test]
8433 fn a_launch_is_one_generation_over_every_row_its_own_walk_found() {
8434 let dir = tempfile::tempdir().expect("temp dir");
8435 let root = root_of(&dir);
8436 init_repo_with_a_commit(&root.join("first"));
8437 init_repo_with_a_commit(&root.join("second"));
8438
8439 let (_core, launched) = started_and_settled(spec(vec![root]));
8440
8441 assert_eq!(
8442 launched.generation,
8443 Generation::default().successor(),
8444 "a launch must settle on the first Generation a fresh `Core` mints; a second \
8445 walk of the same tree would be a second Generation"
8446 );
8447 let mut named: Vec<String> = launched
8448 .entities
8449 .iter()
8450 .filter(|entity| entity.branch.settled().is_some())
8451 .map(|entity| entity.name.to_string())
8452 .collect();
8453 named.sort();
8454 assert_eq!(
8455 named,
8456 vec!["first".to_string(), "second".to_string()],
8457 "that one Generation must cover every row its own walk found, or the walk it \
8458 saved would have to be paid by a second one"
8459 );
8460 }
8461
8462 #[test]
8469 fn dropping_a_core_cancels_every_entity_it_still_has_in_flight() {
8470 let dir = tempfile::tempdir().expect("temp dir");
8471 let root = root_of(&dir);
8472 init_repo_with_a_commit(&root.join("repo"));
8473
8474 let (core, launched) = started_and_settled(spec(vec![root]));
8475 let key = launched.entities[0].key.clone();
8476 let cancel = core.begin_untracked_probe_for_test(&key);
8477 assert!(!cancel.load(Ordering::Acquire));
8478
8479 drop(core);
8480
8481 assert!(
8482 cancel.load(Ordering::Acquire),
8483 "a dropped Core must cancel the Generation it still has in flight rather than \
8484 leave it running against a Set nothing will read again"
8485 );
8486 }
8487
8488 #[test]
8518 fn a_selection_scoped_refresh_supersedes_only_the_entity_it_covers() {
8519 let dir = tempfile::tempdir().expect("temp dir");
8520 let root = root_of(&dir);
8521 init_repo_with_a_commit(&root.join("a"));
8522 init_repo_with_a_commit(&root.join("b"));
8523
8524 let (core, snapshot) = started_and_settled(spec(vec![root]));
8525 let key_a = snapshot
8526 .entities
8527 .iter()
8528 .find(|entity| &*entity.name == "a")
8529 .expect("entity a discovered")
8530 .key
8531 .clone();
8532 let key_b = snapshot
8533 .entities
8534 .iter()
8535 .find(|entity| &*entity.name == "b")
8536 .expect("entity b discovered")
8537 .key
8538 .clone();
8539
8540 let older = core.begin_shared_generation_for_test(&[key_a.clone(), key_b.clone()]);
8544
8545 let newer = core.refresh(std::slice::from_ref(&key_a));
8548 assert_eq!(
8549 newer,
8550 older.generation.successor(),
8551 "the Selection-scoped refresh must be the Generation immediately after the one \
8552 still in flight, with nothing minted in between"
8553 );
8554
8555 core.wait_dispatched_for_test();
8560 assert!(
8561 older.cancels[&key_a].load(Ordering::Acquire),
8562 "the entity the new Generation covers must have its old interrupt flag set"
8563 );
8564 assert!(
8565 !older.cancels[&key_b].load(Ordering::Acquire),
8566 "an entity the new Generation does not cover must be left running, untouched"
8567 );
8568
8569 let after_refresh = core.settle();
8573
8574 let a_after_gen2 = after_refresh
8575 .entities
8576 .iter()
8577 .find(|entity| entity.key == key_a)
8578 .expect("entity a present");
8579 assert!(
8580 matches!(
8581 a_after_gen2.branch.settled(),
8582 Some(Settled::Known {
8583 value: Head::Branch { .. },
8584 at: _,
8585 stale: _
8586 })
8587 ),
8588 "the newer Generation's real probe should have written A's cell by now"
8589 );
8590
8591 core.apply_probe_result_for_test(
8595 &key_a,
8596 older.generation,
8597 Settled::Known {
8598 value: Head::Branch {
8599 name: Arc::from("stale-from-generation-one"),
8600 commit: gix::hash::Kind::Sha1.null(),
8601 },
8602 at: Timestamp::now(),
8603 stale: false,
8604 },
8605 );
8606 let after_stale_write = core.snapshot();
8607 let a_final = after_stale_write
8608 .entities
8609 .iter()
8610 .find(|entity| entity.key == key_a)
8611 .expect("entity a present");
8612 match a_final.branch.settled() {
8613 Some(Settled::Known {
8614 value: Head::Branch { name, .. },
8615 at: _,
8616 stale: _,
8617 }) => assert_ne!(
8618 &**name, "stale-from-generation-one",
8619 "a lower-Generation result must be dropped at the cell it would write"
8620 ),
8621 other => panic!("expected A to still hold the newer Generation's value, got {other:?}"),
8622 }
8623
8624 core.apply_probe_result_for_test(
8627 &key_b,
8628 older.generation,
8629 Settled::Known {
8630 value: Head::Branch {
8631 name: Arc::from("b-generation-one-result"),
8632 commit: gix::hash::Kind::Sha1.null(),
8633 },
8634 at: Timestamp::now(),
8635 stale: false,
8636 },
8637 );
8638 let final_snapshot = core.snapshot();
8639 let b_final = final_snapshot
8640 .entities
8641 .iter()
8642 .find(|entity| entity.key == key_b)
8643 .expect("entity b present");
8644 match b_final.branch.settled() {
8645 Some(Settled::Known {
8646 value: Head::Branch { name, .. },
8647 at: _,
8648 stale: _,
8649 }) => assert_eq!(
8650 &**name, "b-generation-one-result",
8651 "an entity the new Generation never covered must still accept its own result"
8652 ),
8653 other => {
8654 panic!("expected B's un-superseded older result to be accepted, got {other:?}")
8655 }
8656 }
8657 }
8658
8659 #[test]
8664 fn the_deadline_sweep_keeps_already_settled_cells_and_only_times_out_what_is_still_loading() {
8665 let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
8666 let dir = tempfile::tempdir().expect("temp dir");
8667 let root = root_of(&dir);
8668 init_repo_with_a_commit(&root.join("a"));
8669 init_repo_with_a_commit(&root.join("b"));
8670
8671 let mut spec = spec(vec![root]);
8672 spec.generation_deadline = Duration::ZERO;
8673 let started = Core::start_for_test(spec, Duration::from_secs(3600), tick_rx).discovered();
8674 let core = started.core;
8675 let snapshot = settle_launch(&core);
8678 let key_a = snapshot
8679 .entities
8680 .iter()
8681 .find(|entity| &*entity.name == "a")
8682 .expect("entity a discovered")
8683 .key
8684 .clone();
8685 let key_b = snapshot
8686 .entities
8687 .iter()
8688 .find(|entity| &*entity.name == "b")
8689 .expect("entity b discovered")
8690 .key
8691 .clone();
8692
8693 let a_settled = core.probe_now(&key_a);
8696 let a_value_before = match a_settled.branch.settled() {
8697 Some(Settled::Known {
8698 value: Head::Branch { name, .. },
8699 at: _,
8700 stale: _,
8701 }) => Arc::clone(name),
8702 other => panic!("expected A's synchronous probe to settle a branch, got {other:?}"),
8703 };
8704
8705 let cancel_b = core.begin_untracked_probe_for_test(&key_b);
8709 let before_tick = core.snapshot();
8710 let b_before = before_tick
8711 .entities
8712 .iter()
8713 .find(|entity| entity.key == key_b)
8714 .expect("entity b present");
8715 assert!(
8716 b_before.branch.is_in_flight(),
8717 "B must be mid-flight when the sweep fires; that is the only shape the sweep \
8718 may touch"
8719 );
8720 assert!(
8721 matches!(
8722 b_before.branch.settled(),
8723 Some(Settled::Known {
8724 value: _,
8725 at: _,
8726 stale: _
8727 })
8728 ),
8729 "B still carries launch's own answer here, so the Unknown below is a write the \
8730 sweep made rather than a cell that was already empty, got {:?}",
8731 b_before.branch.settled()
8732 );
8733
8734 tick_tx.send(Instant::now()).expect("send one tick");
8735 let after_sweep = core.settle();
8736
8737 let a_after = after_sweep
8738 .entities
8739 .iter()
8740 .find(|entity| entity.key == key_a)
8741 .expect("entity a present");
8742 match a_after.branch.settled() {
8743 Some(Settled::Known {
8744 value: Head::Branch { name, .. },
8745 at: _,
8746 stale: _,
8747 }) => assert_eq!(
8748 name, &a_value_before,
8749 "an already-settled cell must keep its value when the deadline sweep runs, not be blanked"
8750 ),
8751 other => panic!("expected A's settled value to survive the sweep, got {other:?}"),
8752 }
8753
8754 let b_after = after_sweep
8755 .entities
8756 .iter()
8757 .find(|entity| entity.key == key_b)
8758 .expect("entity b present");
8759 assert!(matches!(
8760 b_after.branch.settled(),
8761 Some(Settled::Unknown(Unknown::TimedOut))
8762 ));
8763 assert!(
8764 !cancel_b.load(Ordering::Acquire),
8765 "the deadline sweep marks a cell Unknown; it never sets the entity's own \
8766 cancel flag, since the underlying probe (nonexistent here) is left to keep running"
8767 );
8768 }
8769
8770 #[test]
8778 fn the_deadline_sweep_times_out_a_worktrees_outstanding_state_but_leaves_a_repos_not_applicable_one_alone()
8779 {
8780 let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
8781 let dir = tempfile::tempdir().expect("temp dir");
8782 let root = root_of(&dir);
8783 let parent = root.join("parent");
8784 init_repo_with_a_commit(&parent);
8785 let worktree_path = root.join("feature-worktree");
8786 git(
8787 &parent,
8788 &[
8789 "worktree",
8790 "add",
8791 "-b",
8792 "feature",
8793 worktree_path.to_str().expect("utf8 path"),
8794 ],
8795 );
8796
8797 let mut spec = spec(vec![root]);
8798 spec.generation_deadline = Duration::ZERO;
8799 let started = Core::start_for_test(spec, Duration::from_secs(3600), tick_rx).discovered();
8800 let core = started.core;
8801 let snapshot = settle_launch(&core);
8808 let repo_key = snapshot
8809 .entities
8810 .iter()
8811 .find(|entity| matches!(entity.kind, Kind::Repo))
8812 .expect("repo entity present")
8813 .key
8814 .clone();
8815 let worktree_key = snapshot
8816 .entities
8817 .iter()
8818 .find(|entity| matches!(entity.kind, Kind::Worktree))
8819 .expect("worktree entity present")
8820 .key
8821 .clone();
8822
8823 core.begin_untracked_probe_for_test(&repo_key);
8829 core.begin_untracked_probe_for_test(&worktree_key);
8830
8831 tick_tx.send(Instant::now()).expect("send one tick");
8832 let after_sweep = core.settle();
8833
8834 let worktree_after = after_sweep
8835 .entities
8836 .iter()
8837 .find(|entity| entity.key == worktree_key)
8838 .expect("worktree entity present");
8839 assert!(
8840 matches!(
8841 worktree_after.state.settled(),
8842 Some(Settled::Unknown(Unknown::TimedOut))
8843 ),
8844 "expected the outstanding state cell to time out, got {:?}",
8845 worktree_after.state.settled()
8846 );
8847
8848 let repo_after = after_sweep
8849 .entities
8850 .iter()
8851 .find(|entity| entity.key == repo_key)
8852 .expect("repo entity present");
8853 assert!(
8854 matches!(repo_after.state.settled(), Some(Settled::NotApplicable)),
8855 "a Repo's Not applicable state must survive the sweep untouched, got {:?}",
8856 repo_after.state.settled()
8857 );
8858 }
8859
8860 #[test]
8865 fn the_deadline_sweeps_poll_never_touches_an_entitys_action_receipt() {
8866 let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
8867 let dir = tempfile::tempdir().expect("temp dir");
8868 let root = root_of(&dir);
8869 let repo = root.join("repo");
8870 init_repo_with_a_commit(&repo);
8871
8872 let mut spec = spec(vec![root]);
8873 spec.generation_deadline = Duration::ZERO;
8874 let started = Core::start_for_test(spec, Duration::from_secs(3600), tick_rx).discovered();
8875 let core = started.core;
8876 let key = settle_launch(&core).entities[0].key.clone();
8878
8879 let receipt = crate::entity::ActionReceipt {
8880 label: Arc::from("reinstall"),
8881 steps: Arc::from(vec![crate::entity::StepResult {
8882 label: Arc::from("pnpm install"),
8883 outcome: crate::entity::StepOutcome::Ok,
8884 output: Arc::from(&b""[..]),
8885 elapsed: Duration::from_millis(1),
8886 elision: None,
8887 shell: false,
8888 interactive: false,
8889 }]),
8890 skip: None,
8891 finished_at: Timestamp::now(),
8892 running: None,
8893 };
8894 core.set_last_action_for_test(&key, receipt.clone());
8895
8896 core.begin_untracked_probe_for_test(&key);
8899 tick_tx.send(Instant::now()).expect("send one tick");
8900 let after = core.settle();
8901
8902 let entity = after
8903 .entities
8904 .iter()
8905 .find(|entity| entity.key == key)
8906 .expect("entity present");
8907 assert!(
8908 matches!(
8909 entity.branch.settled(),
8910 Some(Settled::Unknown(Unknown::TimedOut))
8911 ),
8912 "sanity check: the sweep must have actually timed out the in-flight cell, got {:?}",
8913 entity.branch.settled()
8914 );
8915 assert_eq!(entity.last_action, Some(receipt));
8916 }
8917
8918 #[test]
8928 fn a_cancelled_probe_never_opens_the_repository_at_all() {
8929 let cancel = AtomicBool::new(true);
8930
8931 let outcome = probe_branch(
8932 Path::new("/nonexistent/nowhere-at-all"),
8933 None,
8934 Kind::Repo,
8935 &cancel,
8936 );
8937
8938 assert!(
8939 outcome.is_none(),
8940 "a probe observing cancellation before its first read must do no work \
8941 at all, not attempt the read and fail having tried it"
8942 );
8943 }
8944
8945 #[test]
8955 fn classify_status_result_drops_an_error_once_cancel_reads_true() {
8956 let cancel = AtomicBool::new(true);
8957
8958 let outcome = classify_status_result(
8959 Err(crate::git::ProbeError::Status(Arc::from("boom"))),
8960 &cancel,
8961 );
8962
8963 assert!(
8964 outcome.is_none(),
8965 "an error alongside a cancel flag already set must read as cancelled, not \
8966 Failed, got {outcome:?}"
8967 );
8968 }
8969
8970 #[test]
8973 fn classify_status_result_settles_failed_when_cancel_never_fired() {
8974 let cancel = AtomicBool::new(false);
8975
8976 let outcome = classify_status_result(
8977 Err(crate::git::ProbeError::Status(Arc::from("boom"))),
8978 &cancel,
8979 );
8980
8981 assert!(
8982 matches!(outcome, Some(Settled::Failed(git::ProbeError::Status(_)))),
8983 "a genuine error with no cancellation must settle Failed, got {outcome:?}"
8984 );
8985 }
8986
8987 #[test]
8996 fn classify_status_result_drops_an_ok_once_cancel_reads_true() {
8997 let cancel = AtomicBool::new(true);
8998
8999 let outcome = classify_status_result(Ok(DirtyCounts::default()), &cancel);
9000
9001 assert!(
9002 outcome.is_none(),
9003 "an Ok value that raced ahead of a cancel flag now set must read as cancelled, \
9004 not be settled Known, got {outcome:?}"
9005 );
9006 }
9007
9008 #[test]
9011 fn classify_status_result_settles_known_when_cancel_never_fired() {
9012 let cancel = AtomicBool::new(false);
9013 let counts = DirtyCounts {
9014 modified: 1,
9015 untracked: 2,
9016 deleted: 3,
9017 };
9018
9019 let outcome = classify_status_result(Ok(counts), &cancel);
9020
9021 assert!(
9022 matches!(
9023 outcome,
9024 Some(Settled::Known {
9025 value,
9026 at: _,
9027 stale: _
9028 }) if value == counts
9029 ),
9030 "a genuine completed read with no cancellation must settle Known, got {outcome:?}"
9031 );
9032 }
9033
9034 #[test]
9040 fn a_linked_worktree_is_its_own_entity_and_never_doubles_as_a_repo() {
9041 let dir = tempfile::tempdir().expect("temp dir");
9042 let root = root_of(&dir);
9043 let parent = root.join("parent");
9044 init_repo_with_a_commit(&parent);
9045 let worktree_path = root.join("feature-worktree");
9046 let status = Command::new("git")
9047 .arg("-C")
9048 .arg(&parent)
9049 .args([
9050 "worktree",
9051 "add",
9052 "-b",
9053 "feature",
9054 worktree_path.to_str().expect("utf8 path"),
9055 ])
9056 .status()
9057 .expect("run git worktree add");
9058 assert!(status.success());
9059
9060 let core = Core::start_discovered(spec(vec![root]));
9061 let snapshot = core.snapshot();
9062
9063 assert_eq!(
9064 snapshot.entities.len(),
9065 2,
9066 "expected the parent plus one Worktree, not two Repos"
9067 );
9068 let repo_count = snapshot
9069 .entities
9070 .iter()
9071 .filter(|entity| matches!(entity.kind, Kind::Repo))
9072 .count();
9073 let worktree_count = snapshot
9074 .entities
9075 .iter()
9076 .filter(|entity| matches!(entity.kind, Kind::Worktree))
9077 .count();
9078 assert_eq!(
9079 repo_count, 1,
9080 "the parent must be counted as exactly one Repo"
9081 );
9082 assert_eq!(
9083 worktree_count, 1,
9084 "the linked worktree must be counted as exactly one Worktree"
9085 );
9086
9087 let worktree_entity = snapshot
9088 .entities
9089 .iter()
9090 .find(|entity| matches!(entity.kind, Kind::Worktree))
9091 .expect("worktree entity present");
9092 let repo_entity = snapshot
9093 .entities
9094 .iter()
9095 .find(|entity| matches!(entity.kind, Kind::Repo))
9096 .expect("repo entity present");
9097 assert_eq!(worktree_entity.common_dir, repo_entity.common_dir);
9098
9099 let repo_branch = core.probe_now(&repo_entity.key);
9102 let worktree_branch = core.probe_now(&worktree_entity.key);
9103 match (
9104 repo_branch.branch.settled(),
9105 worktree_branch.branch.settled(),
9106 ) {
9107 (
9108 Some(Settled::Known {
9109 value:
9110 Head::Branch {
9111 name: repo_name, ..
9112 },
9113 at: _,
9114 stale: _,
9115 }),
9116 Some(Settled::Known {
9117 value:
9118 Head::Branch {
9119 name: worktree_name,
9120 ..
9121 },
9122 at: _,
9123 stale: _,
9124 }),
9125 ) => {
9126 assert_ne!(repo_name, worktree_name);
9127 assert_eq!(&**worktree_name, "feature");
9128 }
9129 other => panic!("expected both entities to read an attached branch, got {other:?}"),
9130 }
9131 }
9132
9133 #[test]
9137 fn a_worktrees_branch_that_is_an_ancestor_of_the_default_branch_reads_merged_after_a_refresh() {
9138 let dir = tempfile::tempdir().expect("temp dir");
9139 let root = root_of(&dir);
9140 let parent = root.join("parent");
9141 init_repo_with_a_commit(&parent);
9142 git(
9143 &parent,
9144 &[
9145 "remote",
9146 "add",
9147 "origin",
9148 "https://example.invalid/repo.git",
9149 ],
9150 );
9151 let sha = head_sha(&parent);
9152 git(&parent, &["update-ref", "refs/remotes/origin/main", &sha]);
9153 let worktree_path = root.join("feature-worktree");
9154 git(
9155 &parent,
9156 &[
9157 "worktree",
9158 "add",
9159 "-b",
9160 "feature",
9161 worktree_path.to_str().expect("utf8 path"),
9162 ],
9163 );
9164
9165 let core = Core::start_discovered(spec(vec![root]));
9166 let keys: Vec<EntityKey> = core
9167 .snapshot()
9168 .entities
9169 .iter()
9170 .map(|entity| entity.key.clone())
9171 .collect();
9172
9173 core.refresh(&keys);
9174 let settled = core.settle();
9175
9176 let worktree_entity = settled
9177 .entities
9178 .iter()
9179 .find(|entity| matches!(entity.kind, Kind::Worktree))
9180 .expect("worktree entity present");
9181 assert!(
9182 matches!(
9183 worktree_entity.state.settled(),
9184 Some(Settled::Known {
9185 value: WorktreeState::Merged,
9186 at: _,
9187 stale: _
9188 })
9189 ),
9190 "expected the worktree, at the same commit as the default branch, to read Merged, got {:?}",
9191 worktree_entity.state.settled()
9192 );
9193 }
9194
9195 #[test]
9204 fn a_squash_merged_worktree_branch_reads_merged_after_a_refresh() {
9205 let dir = tempfile::tempdir().expect("temp dir");
9206 let root = root_of(&dir);
9207 let parent = root.join("parent");
9208 init_repo_with_a_commit(&parent);
9209 git(
9210 &parent,
9211 &[
9212 "remote",
9213 "add",
9214 "origin",
9215 "https://example.invalid/repo.git",
9216 ],
9217 );
9218 let worktree_path = root.join("feature-worktree");
9219 git(
9220 &parent,
9221 &[
9222 "worktree",
9223 "add",
9224 "-b",
9225 "feature",
9226 worktree_path.to_str().expect("utf8 path"),
9227 ],
9228 );
9229 fs::write(worktree_path.join("a.txt"), "one\n").expect("write a.txt");
9230 git(&worktree_path, &["add", "a.txt"]);
9231 git(&worktree_path, &["commit", "-m", "add a"]);
9232 fs::write(worktree_path.join("b.txt"), "two\n").expect("write b.txt");
9233 git(&worktree_path, &["add", "b.txt"]);
9234 git(&worktree_path, &["commit", "-m", "add b"]);
9235 let feature_sha = head_sha(&worktree_path);
9236
9237 git(&parent, &["merge", "--squash", "feature"]);
9240 git(&parent, &["commit", "-m", "squashed feature"]);
9241 let main_sha = head_sha(&parent);
9242 git(
9243 &parent,
9244 &["update-ref", "refs/remotes/origin/main", &main_sha],
9245 );
9246
9247 git(&parent, &["config", "branch.feature.remote", "origin"]);
9250 git(
9251 &parent,
9252 &["config", "branch.feature.merge", "refs/heads/feature"],
9253 );
9254 git(
9255 &parent,
9256 &["update-ref", "refs/remotes/origin/feature", &feature_sha],
9257 );
9258
9259 let core = Core::start_discovered(spec(vec![root]));
9260 let keys: Vec<EntityKey> = core
9261 .snapshot()
9262 .entities
9263 .iter()
9264 .map(|entity| entity.key.clone())
9265 .collect();
9266
9267 core.refresh(&keys);
9268 let settled = core.settle();
9269
9270 let worktree_entity = settled
9271 .entities
9272 .iter()
9273 .find(|entity| matches!(entity.kind, Kind::Worktree))
9274 .expect("worktree entity present");
9275 assert!(
9276 matches!(
9277 worktree_entity.state.settled(),
9278 Some(Settled::Known {
9279 value: WorktreeState::Merged,
9280 at: _,
9281 stale: _
9282 })
9283 ),
9284 "expected a squash-merged worktree branch to read Merged, got {:?}",
9285 worktree_entity.state.settled()
9286 );
9287 }
9288
9289 #[test]
9297 fn patch_equivalence_never_runs_for_an_entity_ancestry_already_settled() {
9298 let dir = tempfile::tempdir().expect("temp dir");
9299 let root = root_of(&dir);
9300 let parent = root.join("parent");
9301 init_repo_with_a_commit(&parent);
9302 git(
9303 &parent,
9304 &[
9305 "remote",
9306 "add",
9307 "origin",
9308 "https://example.invalid/repo.git",
9309 ],
9310 );
9311 let sha = head_sha(&parent);
9312 git(&parent, &["update-ref", "refs/remotes/origin/main", &sha]);
9313 let worktree_path = root.join("feature-worktree");
9314 git(
9315 &parent,
9316 &[
9317 "worktree",
9318 "add",
9319 "-b",
9320 "feature",
9321 worktree_path.to_str().expect("utf8 path"),
9322 ],
9323 );
9324
9325 let (core, launched) = started_and_settled(spec(vec![root]));
9326 let keys: Vec<EntityKey> = launched
9327 .entities
9328 .iter()
9329 .map(|entity| entity.key.clone())
9330 .collect();
9331
9332 core.refresh(&keys);
9333 let settled = core.settle();
9334
9335 let worktree_entity = settled
9336 .entities
9337 .iter()
9338 .find(|entity| matches!(entity.kind, Kind::Worktree))
9339 .expect("worktree entity present");
9340 assert!(
9341 matches!(
9342 worktree_entity.state.settled(),
9343 Some(Settled::Known {
9344 value: WorktreeState::Merged,
9345 at: _,
9346 stale: _
9347 })
9348 ),
9349 "expected ancestry alone to settle Merged here, got {:?}",
9350 worktree_entity.state.settled()
9351 );
9352 assert_eq!(
9353 core.patch_identity_reads_for_test(),
9354 0,
9355 "ancestry already settled this entity, so patch equivalence's shared \
9356 scan must never run for its common dir at all"
9357 );
9358 }
9359
9360 #[test]
9368 fn a_full_refresh_reaching_patch_equivalence_writes_no_loose_objects() {
9369 let dir = tempfile::tempdir().expect("temp dir");
9370 let root = root_of(&dir);
9371 let parent = root.join("parent");
9372 init_repo_with_a_commit(&parent);
9373 git(
9374 &parent,
9375 &[
9376 "remote",
9377 "add",
9378 "origin",
9379 "https://example.invalid/repo.git",
9380 ],
9381 );
9382 let worktree_path = root.join("feature-worktree");
9383 git(
9384 &parent,
9385 &[
9386 "worktree",
9387 "add",
9388 "-b",
9389 "feature",
9390 worktree_path.to_str().expect("utf8 path"),
9391 ],
9392 );
9393 fs::write(worktree_path.join("a.txt"), "one\n").expect("write a.txt");
9394 git(&worktree_path, &["add", "a.txt"]);
9395 git(&worktree_path, &["commit", "-m", "add a"]);
9396 fs::write(worktree_path.join("b.txt"), "two\n").expect("write b.txt");
9397 git(&worktree_path, &["add", "b.txt"]);
9398 git(&worktree_path, &["commit", "-m", "add b"]);
9399 let feature_sha = head_sha(&worktree_path);
9400
9401 git(&parent, &["merge", "--squash", "feature"]);
9402 git(&parent, &["commit", "-m", "squashed feature"]);
9403 let main_sha = head_sha(&parent);
9404 git(
9405 &parent,
9406 &["update-ref", "refs/remotes/origin/main", &main_sha],
9407 );
9408 git(&parent, &["config", "branch.feature.remote", "origin"]);
9409 git(
9410 &parent,
9411 &["config", "branch.feature.merge", "refs/heads/feature"],
9412 );
9413 git(
9414 &parent,
9415 &["update-ref", "refs/remotes/origin/feature", &feature_sha],
9416 );
9417
9418 let core = Core::start_discovered(spec(vec![root]));
9419 let keys: Vec<EntityKey> = core
9420 .snapshot()
9421 .entities
9422 .iter()
9423 .map(|entity| entity.key.clone())
9424 .collect();
9425
9426 let before = loose_object_count(&parent);
9427 core.refresh(&keys);
9428 let settled = core.settle();
9429 let after = loose_object_count(&parent);
9430
9431 let worktree_entity = settled
9432 .entities
9433 .iter()
9434 .find(|entity| matches!(entity.kind, Kind::Worktree))
9435 .expect("worktree entity present");
9436 assert!(
9437 matches!(
9438 worktree_entity.state.settled(),
9439 Some(Settled::Known {
9440 value: WorktreeState::Merged,
9441 at: _,
9442 stale: _
9443 })
9444 ),
9445 "expected this refresh to actually reach patch equivalence and settle \
9446 Merged, got {:?}",
9447 worktree_entity.state.settled()
9448 );
9449 assert_eq!(
9450 before, after,
9451 "a full refresh reaching patch equivalence must never write a loose \
9452 object to the repository"
9453 );
9454 }
9455
9456 #[test]
9464 fn a_diverged_worktree_with_a_live_upstream_and_genuinely_unmerged_work_settles_active_after_a_refresh()
9465 {
9466 let dir = tempfile::tempdir().expect("temp dir");
9467 let root = root_of(&dir);
9468 let parent = root.join("parent");
9469 init_repo_with_a_commit(&parent);
9470 let base_sha = head_sha(&parent);
9471 git(
9472 &parent,
9473 &[
9474 "remote",
9475 "add",
9476 "origin",
9477 "https://example.invalid/repo.git",
9478 ],
9479 );
9480 git(
9481 &parent,
9482 &["update-ref", "refs/remotes/origin/main", &base_sha],
9483 );
9484 let worktree_path = root.join("feature-worktree");
9485 git(
9486 &parent,
9487 &[
9488 "worktree",
9489 "add",
9490 "-b",
9491 "feature",
9492 worktree_path.to_str().expect("utf8 path"),
9493 ],
9494 );
9495 fs::write(worktree_path.join("feature.txt"), "unmerged work\n").expect("write feature.txt");
9498 git(&worktree_path, &["add", "feature.txt"]);
9499 git(&worktree_path, &["commit", "-m", "unmerged"]);
9500 let feature_sha = head_sha(&worktree_path);
9501 git(&parent, &["config", "branch.feature.remote", "origin"]);
9504 git(
9505 &parent,
9506 &["config", "branch.feature.merge", "refs/heads/feature"],
9507 );
9508 git(
9509 &parent,
9510 &["update-ref", "refs/remotes/origin/feature", &feature_sha],
9511 );
9512
9513 let core = Core::start_discovered(spec(vec![root]));
9514 let keys: Vec<EntityKey> = core
9515 .snapshot()
9516 .entities
9517 .iter()
9518 .map(|entity| entity.key.clone())
9519 .collect();
9520
9521 core.refresh(&keys);
9522 let settled = core.settle();
9523
9524 let worktree_entity = settled
9525 .entities
9526 .iter()
9527 .find(|entity| matches!(entity.kind, Kind::Worktree))
9528 .expect("worktree entity present");
9529 assert!(
9530 matches!(
9531 worktree_entity.state.settled(),
9532 Some(Settled::Known {
9533 value: WorktreeState::Active,
9534 at: _,
9535 stale: _
9536 })
9537 ),
9538 "expected genuinely unmerged work with a live upstream to settle Active, got {:?}",
9539 worktree_entity.state.settled()
9540 );
9541 }
9542
9543 #[test]
9550 fn a_submodule_is_in_the_snapshot_even_though_hidden_by_the_default_preference() {
9551 let dir = tempfile::tempdir().expect("temp dir");
9552 let root = root_of(&dir);
9553 let parent = root.join("parent");
9554 init_repo_with_a_commit(&parent);
9555 fs::write(
9556 parent.join(".gitmodules"),
9557 "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
9558 )
9559 .expect("write .gitmodules");
9560 fs::create_dir_all(parent.join("vendor").join("lib")).expect("create submodule dir");
9561
9562 let core = Core::start_discovered(spec(vec![root]));
9563 let snapshot = core.snapshot();
9564
9565 assert!(
9566 snapshot
9567 .entities
9568 .iter()
9569 .any(|entity| matches!(entity.kind, Kind::Submodule)),
9570 "a discovered Submodule must be in the snapshot even while show_submodules is off"
9571 );
9572 }
9573
9574 #[test]
9584 fn a_submodules_state_and_base_cells_stay_unknown_through_a_real_refresh() {
9585 let dir = tempfile::tempdir().expect("temp dir");
9586 let root = root_of(&dir);
9587 let parent = root.join("parent");
9588 init_repo_with_a_commit(&parent);
9589 fs::write(
9590 parent.join(".gitmodules"),
9591 "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
9592 )
9593 .expect("write .gitmodules");
9594 let submodule = parent.join("vendor").join("lib");
9595 init_repo_with_a_commit(&submodule);
9596 git(
9597 &submodule,
9598 &["remote", "add", "origin", "https://example.invalid/lib.git"],
9599 );
9600 let root_sha = head_sha(&submodule);
9601 git(&submodule, &["commit", "--allow-empty", "-m", "second"]);
9602 let tip_sha = head_sha(&submodule);
9603 git(&submodule, &["reset", "--hard", &root_sha]);
9604 git(
9605 &submodule,
9606 &["update-ref", "refs/remotes/origin/main", &tip_sha],
9607 );
9608
9609 let mut core_spec = spec(vec![root]);
9612 core_spec.show_submodules = true;
9613 let core = Core::start_discovered(core_spec);
9614 let key = core
9615 .snapshot()
9616 .entities
9617 .iter()
9618 .find(|entity| matches!(entity.kind, Kind::Submodule))
9619 .expect("a discovered Submodule")
9620 .key
9621 .clone();
9622
9623 core.refresh(std::slice::from_ref(&key));
9624 let settled = core.settle();
9625 let submodule_entity = settled
9626 .entities
9627 .iter()
9628 .find(|entity| entity.key == key)
9629 .expect("the Submodule entity");
9630
9631 assert!(
9632 matches!(
9633 submodule_entity.base.settled(),
9634 Some(Settled::Unknown(Unknown::NoDefaultBranch))
9635 ),
9636 "expected a Submodule's base to stay Unknown through a real refresh, \
9637 got {:?}",
9638 submodule_entity.base.settled()
9639 );
9640 assert!(
9641 matches!(
9642 submodule_entity.state.settled(),
9643 Some(Settled::Unknown(Unknown::NoDefaultBranch))
9644 ),
9645 "expected a Submodule's state to stay Unknown through a real refresh, \
9646 rather than settling Merged off an untrusted default branch, got {:?}",
9647 submodule_entity.state.settled()
9648 );
9649 }
9650
9651 #[test]
9656 fn a_submodules_entity_name_is_its_relative_path_not_its_basename() {
9657 let dir = tempfile::tempdir().expect("temp dir");
9658 let root = root_of(&dir);
9659 let parent = root.join("parent");
9660 init_repo_with_a_commit(&parent);
9661 fs::write(
9662 parent.join(".gitmodules"),
9663 "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
9664 )
9665 .expect("write .gitmodules");
9666 fs::create_dir_all(parent.join("vendor").join("lib")).expect("create submodule dir");
9667
9668 let core = Core::start_discovered(spec(vec![root]));
9669 let submodule = core
9670 .snapshot()
9671 .entities
9672 .into_iter()
9673 .find(|entity| matches!(entity.kind, Kind::Submodule))
9674 .expect("a discovered Submodule");
9675
9676 assert_eq!(
9677 submodule.name.as_ref(),
9678 "vendor/lib",
9679 "expected the declared relative path, not the basename `lib`"
9680 );
9681 }
9682
9683 #[test]
9692 fn an_uninitialised_submodules_probed_cells_settle_unknown_not_failed() {
9693 let dir = tempfile::tempdir().expect("temp dir");
9694 let root = root_of(&dir);
9695 let parent = root.join("parent");
9696 init_repo_with_a_commit(&parent);
9697 fs::write(
9698 parent.join(".gitmodules"),
9699 "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
9700 )
9701 .expect("write .gitmodules");
9702 let mut core_spec = spec(vec![root]);
9706 core_spec.show_submodules = true;
9707 let core = Core::start_discovered(core_spec);
9708 let key = core
9709 .snapshot()
9710 .entities
9711 .iter()
9712 .find(|entity| matches!(entity.kind, Kind::Submodule))
9713 .expect("a discovered Submodule")
9714 .key
9715 .clone();
9716
9717 core.refresh(std::slice::from_ref(&key));
9718 let settled = core.settle();
9719 let submodule = settled
9720 .entities
9721 .iter()
9722 .find(|entity| entity.key == key)
9723 .expect("the Submodule entity");
9724
9725 assert!(
9726 matches!(
9727 submodule.branch.settled(),
9728 Some(Settled::Unknown(Unknown::SubmoduleUninitialized))
9729 ),
9730 "expected branch to settle Unknown(SubmoduleUninitialized), got {:?}",
9731 submodule.branch.settled()
9732 );
9733 assert!(
9734 matches!(
9735 submodule.sync.settled(),
9736 Some(Settled::Unknown(Unknown::SubmoduleUninitialized))
9737 ),
9738 "expected sync to settle Unknown(SubmoduleUninitialized), got {:?}",
9739 submodule.sync.settled()
9740 );
9741 assert!(
9742 matches!(
9743 submodule.dirty.settled(),
9744 Some(Settled::Unknown(Unknown::SubmoduleUninitialized))
9745 ),
9746 "expected dirty to settle Unknown(SubmoduleUninitialized), got {:?}",
9747 submodule.dirty.settled()
9748 );
9749 assert_eq!(
9750 summary(submodule),
9751 RowSummary::Unknown,
9752 "expected the row's own gutter fold to read Unknown, not Failed"
9753 );
9754 }
9755
9756 #[test]
9762 fn dispatch_skips_probing_a_hidden_submodule_while_probing_the_same_one_shown() {
9763 let dir = tempfile::tempdir().expect("temp dir");
9764 let root = root_of(&dir);
9765 let parent = root.join("parent");
9766 init_repo_with_a_commit(&parent);
9767 fs::write(
9768 parent.join(".gitmodules"),
9769 "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
9770 )
9771 .expect("write .gitmodules");
9772 init_repo_with_a_commit(&parent.join("vendor").join("lib"));
9773
9774 let core = Core::start_discovered(spec(vec![root]));
9776 let key = core
9777 .snapshot()
9778 .entities
9779 .iter()
9780 .find(|entity| matches!(entity.kind, Kind::Submodule))
9781 .expect("a discovered Submodule")
9782 .key
9783 .clone();
9784
9785 core.refresh(std::slice::from_ref(&key));
9787 let while_hidden = core.settle();
9788 let hidden_entity = while_hidden
9789 .entities
9790 .iter()
9791 .find(|entity| entity.key == key)
9792 .expect("submodule entity");
9793 assert!(
9794 hidden_entity.branch.settled().is_none(),
9795 "a Submodule dispatched while hidden must never even reach probe_branch, \
9796 so its cell stays never-settled rather than holding any value at all, got {:?}",
9797 hidden_entity.branch.settled()
9798 );
9799
9800 core.set_show_submodules(true);
9804 core.refresh(std::slice::from_ref(&key));
9805 let while_shown = core.settle();
9806 let shown_entity = while_shown
9807 .entities
9808 .iter()
9809 .find(|entity| entity.key == key)
9810 .expect("submodule entity");
9811 assert!(
9812 matches!(
9813 shown_entity.branch.settled(),
9814 Some(Settled::Known {
9815 value: _,
9816 at: _,
9817 stale: _
9818 })
9819 ),
9820 "expected the same Submodule's branch to settle a real value once shown, got {:?}",
9821 shown_entity.branch.settled()
9822 );
9823 }
9824
9825 #[test]
9831 fn toggling_show_submodules_starts_no_new_generation_and_dispatches_nothing() {
9832 let dir = tempfile::tempdir().expect("temp dir");
9833 let root = root_of(&dir);
9834 init_repo_with_a_commit(&root.join("repo-a"));
9835
9836 let (core, launched) = started_and_settled(spec(vec![root]));
9838 let before = launched.generation;
9839 let dispatched_before = core.dispatch_log_for_test();
9840 assert!(
9841 !dispatched_before.is_empty(),
9842 "launch dispatched nothing, so the comparison below would hold however much a \
9843 toggle dispatched"
9844 );
9845
9846 core.set_show_submodules(true);
9847 core.set_show_submodules(false);
9848
9849 assert_eq!(
9850 core.snapshot().generation,
9851 before,
9852 "toggling show_submodules must start no Generation of its own"
9853 );
9854 assert_eq!(
9855 core.dispatch_log_for_test(),
9856 dispatched_before,
9857 "toggling show_submodules must dispatch no probe of its own, leaving the last \
9858 Generation's own log exactly as it found it"
9859 );
9860 }
9861
9862 #[test]
9869 fn a_malformed_gitmodules_file_still_fails_the_parent_while_submodules_are_hidden() {
9870 let dir = tempfile::tempdir().expect("temp dir");
9871 let root = root_of(&dir);
9872 let parent = root.join("parent");
9873 init_repo_with_a_commit(&parent);
9874 fs::write(
9875 parent.join(".gitmodules"),
9876 "[submodule \"lib\"\n\tpath = lib\n",
9877 )
9878 .expect("write malformed .gitmodules");
9879
9880 let core = Core::start_discovered(spec(vec![root]));
9881 let key = core
9882 .snapshot()
9883 .entities
9884 .iter()
9885 .find(|entity| entity.key.path() == parent)
9886 .expect("the parent entity")
9887 .key
9888 .clone();
9889 core.refresh(std::slice::from_ref(&key));
9893 let settled = core.settle();
9894 let parent_entity = settled
9895 .entities
9896 .iter()
9897 .find(|entity| entity.key == key)
9898 .expect("the parent entity");
9899
9900 assert_eq!(
9901 summary(parent_entity),
9902 RowSummary::Failed,
9903 "expected the parent to fold Failed even with Submodules hidden"
9904 );
9905 assert!(
9906 parent_entity.diagnostics.gitmodules_failed.is_some(),
9907 "expected the failure recorded in Diagnostics for the detail pane"
9908 );
9909 assert!(
9910 !settled
9911 .entities
9912 .iter()
9913 .any(|entity| matches!(entity.kind, Kind::Submodule)),
9914 "an unparseable .gitmodules yields no Submodule rows for that parent"
9915 );
9916 }
9917
9918 #[test]
9919 fn count_matches_a_plain_discoverys_entity_count() {
9920 let dir = tempfile::tempdir().expect("temp dir");
9921 let root = root_of(&dir);
9922 init_repo_with_a_commit(&root.join("one"));
9923 init_repo_with_a_commit(&root.join("two"));
9924
9925 let set = SetSpec {
9926 name: "test".to_string(),
9927 roots: vec![root],
9928 include: Vec::new(),
9929 exclude: Vec::new(),
9930 };
9931
9932 assert_eq!(discovery::count(&set), 2);
9933 }
9934
9935 #[test]
9936 fn the_slow_discovery_watcher_warns_with_the_count_reached_and_the_roots() {
9937 let progress = Arc::new(AtomicUsize::new(42));
9938 let finished = Arc::new(AtomicBool::new(false));
9939 let roots = vec![PathBuf::from("/repos/a"), PathBuf::from("/repos/b")];
9940
9941 let warning = watch_for_slow_discovery(progress, finished, roots, Duration::from_millis(1));
9942
9943 let message = warning.expect("a walk that has not finished should warn");
9944 assert!(message.contains("42"));
9945 assert!(message.contains("/repos/a"));
9946 assert!(message.contains("/repos/b"));
9947 }
9948
9949 #[test]
9950 fn the_slow_discovery_watcher_is_silent_once_the_walk_has_already_finished() {
9951 let progress = Arc::new(AtomicUsize::new(7));
9952 let finished = Arc::new(AtomicBool::new(true));
9953
9954 let warning =
9955 watch_for_slow_discovery(progress, finished, Vec::new(), Duration::from_millis(1));
9956
9957 assert!(warning.is_none());
9958 }
9959
9960 #[test]
9968 fn a_fast_discovery_leaves_no_warning_once_the_watcher_has_run() {
9969 let dir = tempfile::tempdir().expect("temp dir");
9970 let root = root_of(&dir);
9971 init_repo_with_a_commit(&root.join("repo"));
9972 let (_tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
9973
9974 let started =
9975 Core::start_for_test(spec(vec![root]), Duration::from_secs(1), tick_rx).discovered();
9976 started
9977 .discovery_watcher
9978 .join()
9979 .expect("watcher thread should not panic");
9980
9981 assert!(started.core.discovery_warning().is_none());
9982 }
9983
9984 fn gate_opened_on_signal(open: bool) -> (DiscoveryGate, Sender<()>, JoinHandle<()>) {
9992 let gate: DiscoveryGate = Arc::new((Mutex::new(open), Condvar::new()));
9993 let (returned_tx, returned_rx) = crossbeam_channel::bounded::<()>(1);
9994 let opener = thread::spawn({
9995 let gate = Arc::clone(&gate);
9996 move || {
9997 let _ = returned_rx.recv_timeout(crate::liveness::BACKSTOP);
9998 set_discovery_gate(&gate, true);
9999 }
10000 });
10001 (gate, returned_tx, opener)
10002 }
10003
10004 #[test]
10016 fn start_returns_against_an_empty_table_and_the_rows_land_when_discovery_does() {
10017 let dir = tempfile::tempdir().expect("temp dir");
10018 let root = root_of(&dir);
10019 let repo = root.join("repo");
10020 init_repo_with_a_commit(&repo);
10021 let (_tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
10022 let (gate, start_returned, opener) = gate_opened_on_signal(false);
10023
10024 let started = Core::start_for_test_gated(
10025 spec(vec![root]),
10026 Duration::from_secs(3600),
10027 discovery::ABANDON_AFTER,
10028 tick_rx,
10029 Some(Arc::clone(&gate)),
10030 );
10031 let at_start = started.core.snapshot();
10032 let key = EntityKey::new(Arc::from(repo.as_path()));
10033 started.core.hold_phase_c_for_test(&key);
10034 start_returned.send(()).expect("the opener is listening");
10035 opener.join().expect("the opener thread should not panic");
10036 let started = started.discovered();
10037
10038 assert!(
10039 at_start.entities.is_empty(),
10040 "`Core::start` must return before discovery has finished, against the empty \
10041 table a consumer draws its first frame from, got {:?}",
10042 at_start
10043 .entities
10044 .iter()
10045 .map(|entity| entity.name.to_string())
10046 .collect::<Vec<_>>()
10047 );
10048
10049 let landed = started.core.snapshot();
10050 assert_eq!(
10051 landed
10052 .entities
10053 .iter()
10054 .map(|entity| entity.name.to_string())
10055 .collect::<Vec<_>>(),
10056 vec!["repo".to_string()],
10057 "the row must land on the table as soon as discovery does"
10058 );
10059 assert!(
10060 landed.entities[0].dirty.settled().is_none() && landed.entities[0].dirty.is_in_flight(),
10061 "discovery lands the row alone: launch's own Generation is already covering it \
10062 and its Cells stay unsettled until that Generation answers, which is what the \
10063 spinner sits behind"
10064 );
10065
10066 started.core.release_phase_c_for_test(&key);
10067 started.core.wait_phase_c_finished_for_test(&key);
10068 }
10069
10070 #[test]
10079 fn refresh_all_covers_every_row_its_own_discovery_found() {
10080 let dir = tempfile::tempdir().expect("temp dir");
10081 let root = root_of(&dir);
10082 init_repo_with_a_commit(&root.join("repo"));
10083
10084 let (core, launched) = started_and_settled(spec(vec![root.clone()]));
10085 assert_eq!(
10086 launched
10087 .entities
10088 .iter()
10089 .map(|entity| entity.name.to_string())
10090 .collect::<Vec<_>>(),
10091 vec!["repo".to_string()],
10092 "launch's own walk must have landed and covered exactly the one row that \
10093 existed when it ran"
10094 );
10095 init_repo_with_a_commit(&root.join("late"));
10099
10100 assert_eq!(
10101 core.refresh_all(),
10102 launched.generation.successor(),
10103 "`refresh_all` must be the Generation immediately after the one already on the \
10104 table"
10105 );
10106 let settled = core.settle();
10107
10108 let mut named: Vec<String> = settled
10109 .entities
10110 .iter()
10111 .filter(|entity| entity.branch.settled().is_some())
10112 .map(|entity| entity.name.to_string())
10113 .collect();
10114 named.sort();
10115 assert_eq!(
10116 named,
10117 vec!["late".to_string(), "repo".to_string()],
10118 "the Generation must cover every row its own discovery found, including one the \
10119 caller had no key for"
10120 );
10121 }
10122
10123 #[test]
10133 fn refresh_returns_before_its_own_generations_discovery_has_run() {
10134 let dir = tempfile::tempdir().expect("temp dir");
10135 let root = root_of(&dir);
10136 init_repo_with_a_commit(&root.join("repo"));
10137 let (_tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
10138 let (gate, walk_may_run, opener) = gate_opened_on_signal(true);
10139
10140 let started = Core::start_for_test_gated(
10141 spec(vec![root.clone()]),
10142 Duration::from_secs(3600),
10143 discovery::ABANDON_AFTER,
10144 tick_rx,
10145 Some(Arc::clone(&gate)),
10146 )
10147 .discovered();
10148 let core = started.core;
10149 let launched = settle_launch(&core);
10151 let keys: Vec<EntityKey> = launched
10152 .entities
10153 .iter()
10154 .map(|entity| entity.key.clone())
10155 .collect();
10156 init_repo_with_a_commit(&root.join("late"));
10157
10158 set_discovery_gate(&gate, false);
10159 let generation = core.refresh(&keys);
10160 let while_held = core.snapshot();
10161 let dispatched_while_held = core.settle_gate_count_for_test();
10162 walk_may_run.send(()).expect("the opener is listening");
10163 opener.join().expect("the opener thread should not panic");
10164
10165 assert_eq!(
10166 generation,
10167 launched.generation.successor(),
10168 "`refresh` must return its own Generation's number, the one immediately after \
10169 the table's, before that Generation has done any of its work"
10170 );
10171 assert!(
10172 !while_held
10173 .entities
10174 .iter()
10175 .any(|entity| &*entity.name == "late"),
10176 "`refresh` must return before its own Generation's walk has run, so a Repo \
10177 created after the previous walk is not on the table it returned against"
10178 );
10179 assert_eq!(
10180 dispatched_while_held, 0,
10181 "`refresh` returned before its Generation reached the table at all, so nothing \
10182 is dispatched yet"
10183 );
10184
10185 core.wait_dispatched_for_test();
10186 let settled = core.settle();
10187
10188 assert!(
10189 settled
10190 .entities
10191 .iter()
10192 .any(|entity| &*entity.name == "late"),
10193 "the deferred Generation must still run its own walk once it is let through: \
10194 deferred, never dropped"
10195 );
10196 }
10197
10198 #[test]
10209 fn a_dispatch_body_waits_for_every_earlier_reserved_generation() {
10210 let turnstile = Arc::new(DispatchTurnstile::default());
10211 let earlier = turnstile.reserve();
10212 let later = turnstile.reserve();
10213 let order = Arc::new(Mutex::new(Vec::new()));
10214
10215 let earlier_body = thread::spawn({
10216 let turnstile = Arc::clone(&turnstile);
10217 let order = Arc::clone(&order);
10218 move || {
10219 let _turn = turnstile.take(earlier);
10220 order.lock().unwrap().push(earlier);
10221 }
10222 });
10223
10224 {
10225 let _turn = turnstile.take(later);
10226 order.lock().unwrap().push(later);
10227 }
10228 earlier_body
10229 .join()
10230 .expect("the earlier body should not panic");
10231
10232 assert_eq!(
10233 *order.lock().unwrap(),
10234 vec![earlier, later],
10235 "a dispatch body must run in the order its Generation was reserved"
10236 );
10237 }
10238
10239 #[test]
10244 fn run_while_not_cancelled_stops_at_the_next_check_rather_than_running_forever() {
10245 let cancel = Arc::new(AtomicBool::new(false));
10246 let worker_cancel = Arc::clone(&cancel);
10247 let (step_started_tx, step_started_rx) = crossbeam_channel::bounded::<()>(0);
10248 let (proceed_tx, proceed_rx) = crossbeam_channel::bounded::<()>(0);
10249
10250 let worker = thread::spawn(move || {
10251 run_while_not_cancelled(&worker_cancel, || {
10252 step_started_tx.send(()).expect("test should be listening");
10253 proceed_rx.recv().is_ok()
10254 })
10255 });
10256
10257 for _ in 0..2 {
10258 step_started_rx
10259 .recv()
10260 .expect("worker should announce each step");
10261 proceed_tx.send(()).expect("let the step finish");
10262 }
10263 step_started_rx
10264 .recv()
10265 .expect("worker should announce its third step");
10266 cancel.store(true, Ordering::Release);
10267 proceed_tx.send(()).expect("let the third step finish");
10268
10269 let ran = worker.join().expect("worker thread should not panic");
10270
10271 assert_eq!(
10272 ran, 3,
10273 "expected cancellation to stop the loop after its third step"
10274 );
10275 }
10276
10277 fn benchmark_identity_phase(
10285 population: Vec<crate::discovery::DiscoveredEntity>,
10286 ) -> (Duration, Vec<Duration>) {
10287 let (tx, rx) = crossbeam_channel::unbounded();
10288 let started = Instant::now();
10289 crate::fanout::scatter(population, tx, |entity| {
10290 let task_started = Instant::now();
10291 let repo = match &entity.repo {
10292 Some(repo) => repo.to_thread_local(),
10293 None => match git::open_thread_safe(entity.key.path()) {
10294 Ok(repo) => repo.to_thread_local(),
10295 Err(_) => return None,
10296 },
10297 };
10298 let _ = git::head_shape(&repo);
10299 Some(task_started.elapsed())
10300 });
10301 let wall = started.elapsed();
10302 let durations: Vec<Duration> = rx.into_iter().flatten().collect();
10303 (wall, durations)
10304 }
10305
10306 fn real_corpus_roots() -> Vec<PathBuf> {
10310 let Some(home) = std::env::var_os("HOME") else {
10311 return Vec::new();
10312 };
10313 let home = PathBuf::from(home);
10314 ["dev", "dev-misc"]
10315 .into_iter()
10316 .map(|leaf| home.join(leaf))
10317 .filter(|root| root.is_dir())
10318 .collect()
10319 }
10320
10321 fn generated_fixture_corpus(size: usize) -> tempfile::TempDir {
10326 let root = tempfile::tempdir().expect("temp dir for generated fixture corpus");
10327 for i in 0..size {
10328 let repo = root.path().join(format!("fixture-repo-{i}"));
10329 fs::create_dir_all(&repo).expect("create fixture repo dir");
10330 gix::init(&repo).expect("init fixture repo");
10331 let status = Command::new("git")
10332 .arg("-C")
10333 .arg(&repo)
10334 .args(["-c", "user.email=test@example.com", "-c", "user.name=Test"])
10335 .args(["commit", "--allow-empty", "-m", &format!("commit {i}")])
10336 .status()
10337 .expect("run git commit");
10338 assert!(status.success());
10339 }
10340 root
10341 }
10342
10343 fn percentile(sorted: &[Duration], p: usize) -> Duration {
10345 let index = (sorted.len() - 1) * p / 100;
10346 sorted[index]
10347 }
10348
10349 fn extra_excluded_names() -> Vec<String> {
10356 parse_excluded_names(&std::env::var("REPON_BENCHMARK_EXCLUDE_NAMES").unwrap_or_default())
10357 }
10358
10359 fn parse_excluded_names(raw: &str) -> Vec<String> {
10364 raw.split(',')
10365 .map(str::trim)
10366 .filter(|name| !name.is_empty())
10367 .map(str::to_string)
10368 .collect()
10369 }
10370
10371 fn discover_population(
10379 roots: Vec<PathBuf>,
10380 excluded_names: &[String],
10381 ) -> (Vec<crate::discovery::DiscoveredEntity>, Duration) {
10382 let set = SetSpec {
10383 name: "identity-probe-benchmark".to_string(),
10384 roots,
10385 include: Vec::new(),
10386 exclude: Vec::new(),
10387 };
10388 let started = Instant::now();
10389 let discovery = discovery::discover(&set);
10390 let (discovered, _) = discovery::resolve(&set, &discovery.entities);
10391 let elapsed = started.elapsed();
10392 let population = discovered
10393 .into_iter()
10394 .filter(|entity| {
10395 !entity.key.path().components().any(|component| {
10396 excluded_names
10397 .iter()
10398 .any(|name| component.as_os_str() == name.as_str())
10399 })
10400 })
10401 .collect();
10402 (population, elapsed)
10403 }
10404
10405 #[test]
10409 fn a_boundary_whose_path_matches_an_excluded_name_is_left_out_of_the_population() {
10410 let fixture = generated_fixture_corpus(3);
10411 let excluded = vec!["fixture-repo-1".to_string()];
10412
10413 let (population, _) = discover_population(vec![fixture.path().to_path_buf()], &excluded);
10414
10415 assert_eq!(population.len(), 2);
10416 assert!(
10417 population
10418 .iter()
10419 .all(|entity| entity.key.path().file_name().unwrap() != "fixture-repo-1"),
10420 "the excluded name must never appear in the population discovery returns"
10421 );
10422 }
10423
10424 #[test]
10425 fn excluded_names_parses_a_comma_separated_list_and_ignores_blanks() {
10426 assert_eq!(
10427 parse_excluded_names("foo, bar ,,baz"),
10428 vec!["foo".to_string(), "bar".to_string(), "baz".to_string()]
10429 );
10430 assert!(parse_excluded_names("").is_empty());
10431 assert!(parse_excluded_names(" ").is_empty());
10432 }
10433
10434 #[test]
10449 #[ignore = "hand-run against the owner's real corpus; see docs/spec/refresh.md for the recorded figures"]
10450 fn identity_probe_benchmark() {
10451 let excluded_names = extra_excluded_names();
10452
10453 let mut _fixture: Option<tempfile::TempDir> = None;
10457
10458 let (real_population, real_discovery_wall) =
10459 discover_population(real_corpus_roots(), &excluded_names);
10460 let (population, using_fixture, discovery_wall) = if real_population.len() >= 20 {
10461 (real_population, false, real_discovery_wall)
10462 } else {
10463 println!(
10464 "real corpus absent or too small to be meaningful ({} entities); \
10465 using a generated fixture instead",
10466 real_population.len()
10467 );
10468 let fixture = generated_fixture_corpus(300);
10469 let (population, fixture_discovery_wall) =
10470 discover_population(vec![fixture.path().to_path_buf()], &excluded_names);
10471 _fixture = Some(fixture);
10472 (population, true, fixture_discovery_wall)
10473 };
10474
10475 let population_size = population.len();
10476 assert!(
10477 population_size > 0,
10478 "neither a real corpus root nor the generated fixture produced any entities"
10479 );
10480
10481 let (wall, mut durations) = benchmark_identity_phase(population);
10482 durations.sort();
10483
10484 println!(
10485 "identity probe benchmark: corpus = {}, population = {population_size}",
10486 if using_fixture {
10487 "generated fixture"
10488 } else {
10489 "real corpus"
10490 }
10491 );
10492 println!(
10493 "discovery + first open (serial, every entity's own gix::open): {discovery_wall:?}"
10494 );
10495 println!("identity phase, warm, parallel (HEAD re-read from the cached handle): {wall:?}");
10496 println!(
10497 "identity phase per entity: p50 {:?}, p90 {:?}, max {:?}",
10498 percentile(&durations, 50),
10499 percentile(&durations, 90),
10500 durations.last().copied().unwrap_or_default(),
10501 );
10502 }
10503
10504 fn spec_with_overrides(roots: Vec<PathBuf>, overrides: Vec<RepoOverride>) -> CoreSpec {
10505 let mut spec = spec(roots);
10506 spec.overrides = overrides;
10507 spec
10508 }
10509
10510 #[test]
10515 fn a_per_repo_override_resolves_the_default_branch_at_rung_one_through_a_real_refresh() {
10516 let dir = tempfile::tempdir().expect("temp dir");
10517 let root = root_of(&dir);
10518 let repo = root.join("repo");
10519 init_repo_with_a_commit(&repo);
10520 git(
10521 &repo,
10522 &[
10523 "remote",
10524 "add",
10525 "origin",
10526 "https://example.invalid/repo.git",
10527 ],
10528 );
10529 let sha = head_sha(&repo);
10530 git(&repo, &["update-ref", "refs/remotes/origin/main", &sha]);
10531 let remote_refs_dir = repo
10532 .join(".git")
10533 .join("refs")
10534 .join("remotes")
10535 .join("origin");
10536 fs::create_dir_all(&remote_refs_dir).expect("create refs/remotes/origin dir");
10537 fs::write(
10538 remote_refs_dir.join("HEAD"),
10539 "ref: refs/remotes/origin/main\n",
10540 )
10541 .expect("write HEAD");
10542
10543 let core = Core::start_discovered(spec_with_overrides(
10544 vec![root],
10545 vec![RepoOverride {
10546 path: repo.clone(),
10547 default_branch: Some("develop".to_string()),
10548 excluded: false,
10549 }],
10550 ));
10551 let key = core.snapshot().entities[0].key.clone();
10552
10553 core.refresh(std::slice::from_ref(&key));
10554 let settled = core.settle();
10555 let entity = &settled.entities[0];
10556
10557 match entity.default_branch.settled() {
10558 Some(Settled::Known {
10559 value,
10560 at: _,
10561 stale: _,
10562 }) => assert_eq!(
10563 value.name(),
10564 "origin/develop",
10565 "the override must win even though origin/HEAD names a different branch"
10566 ),
10567 other => panic!("expected the override's own answer, got {other:?}"),
10568 }
10569 assert_eq!(
10570 entity.diagnostics.default_branch_rung,
10571 Some(1),
10572 "an override must be recorded as rung 1"
10573 );
10574 }
10575
10576 #[test]
10580 fn a_per_repo_override_also_resolves_through_probe_now() {
10581 let dir = tempfile::tempdir().expect("temp dir");
10582 let root = root_of(&dir);
10583 let repo = root.join("repo");
10584 init_repo_with_a_commit(&repo);
10585
10586 let core = Core::start_discovered(spec_with_overrides(
10587 vec![root],
10588 vec![RepoOverride {
10589 path: repo.clone(),
10590 default_branch: Some("release".to_string()),
10591 excluded: false,
10592 }],
10593 ));
10594 let key = core.snapshot().entities[0].key.clone();
10595
10596 let entity = core.probe_now(&key);
10597
10598 match entity.default_branch.settled() {
10599 Some(Settled::Known {
10601 value,
10602 at: _,
10603 stale: _,
10604 }) => assert_eq!(value.name(), "release"),
10605 other => panic!("expected the override's own answer, got {other:?}"),
10606 }
10607 assert_eq!(entity.diagnostics.default_branch_rung, Some(1));
10608 }
10609
10610 #[test]
10615 fn reaching_rung_four_with_no_remote_at_all_records_why() {
10616 let dir = tempfile::tempdir().expect("temp dir");
10617 let root = root_of(&dir);
10618 let repo = root.join("repo");
10619 init_repo_with_a_commit(&repo);
10620
10621 let core = Core::start_discovered(spec(vec![root]));
10622 let key = core.snapshot().entities[0].key.clone();
10623
10624 core.refresh(std::slice::from_ref(&key));
10625 let settled = core.settle();
10626 let entity = &settled.entities[0];
10627
10628 assert_eq!(entity.diagnostics.default_branch_rung, Some(4));
10629 assert_eq!(
10630 entity.diagnostics.default_branch_stopped,
10631 Some(DefaultBranchStopped::NoRemote)
10632 );
10633 }
10634
10635 #[test]
10636 fn reaching_rung_four_with_two_unnamed_remotes_records_why() {
10637 let dir = tempfile::tempdir().expect("temp dir");
10638 let root = root_of(&dir);
10639 let repo = root.join("repo");
10640 init_repo_with_a_commit(&repo);
10641 git(
10642 &repo,
10643 &[
10644 "remote",
10645 "add",
10646 "fork-one",
10647 "https://example.invalid/one.git",
10648 ],
10649 );
10650 git(
10651 &repo,
10652 &[
10653 "remote",
10654 "add",
10655 "fork-two",
10656 "https://example.invalid/two.git",
10657 ],
10658 );
10659
10660 let core = Core::start_discovered(spec(vec![root]));
10661 let key = core.snapshot().entities[0].key.clone();
10662
10663 core.refresh(std::slice::from_ref(&key));
10664 let settled = core.settle();
10665 let entity = &settled.entities[0];
10666
10667 assert_eq!(entity.diagnostics.default_branch_rung, Some(4));
10668 assert_eq!(
10669 entity.diagnostics.default_branch_stopped,
10670 Some(DefaultBranchStopped::AmbiguousRemote)
10671 );
10672 }
10673
10674 #[test]
10675 fn reaching_rung_four_with_a_chosen_remote_and_no_matching_ref_records_why() {
10676 let dir = tempfile::tempdir().expect("temp dir");
10677 let root = root_of(&dir);
10678 let repo = root.join("repo");
10679 init_repo_with_a_commit(&repo);
10680 git(
10681 &repo,
10682 &[
10683 "remote",
10684 "add",
10685 "origin",
10686 "https://example.invalid/repo.git",
10687 ],
10688 );
10689 let sha = head_sha(&repo);
10692 git(&repo, &["update-ref", "refs/remotes/origin/feature", &sha]);
10693
10694 let core = Core::start_discovered(spec(vec![root]));
10695 let key = core.snapshot().entities[0].key.clone();
10696
10697 core.refresh(std::slice::from_ref(&key));
10698 let settled = core.settle();
10699 let entity = &settled.entities[0];
10700
10701 assert_eq!(entity.diagnostics.default_branch_rung, Some(4));
10702 assert_eq!(
10703 entity.diagnostics.default_branch_stopped,
10704 Some(DefaultBranchStopped::NameListExhausted)
10705 );
10706 }
10707
10708 #[test]
10711 fn a_repo_with_nothing_to_resolve_settles_unknown_never_failed() {
10712 let dir = tempfile::tempdir().expect("temp dir");
10713 let root = root_of(&dir);
10714 let repo = root.join("repo");
10715 init_repo_with_a_commit(&repo);
10716
10717 let core = Core::start_discovered(spec(vec![root]));
10718 let key = core.snapshot().entities[0].key.clone();
10719
10720 core.refresh(std::slice::from_ref(&key));
10721 let settled = core.settle();
10722 let entity = &settled.entities[0];
10723
10724 assert!(matches!(
10725 entity.default_branch.settled(),
10726 Some(Settled::Unknown(Unknown::NoDefaultBranch))
10727 ));
10728 assert_eq!(entity.diagnostics.default_branch_rung, Some(4));
10729 }
10730
10731 #[test]
10737 fn a_stale_remote_head_is_recorded_in_diagnostics_through_a_real_refresh() {
10738 let dir = tempfile::tempdir().expect("temp dir");
10739 let root = root_of(&dir);
10740 let repo = root.join("repo");
10741 init_repo_with_a_commit(&repo);
10742 git(
10743 &repo,
10744 &[
10745 "remote",
10746 "add",
10747 "origin",
10748 "https://example.invalid/repo.git",
10749 ],
10750 );
10751 let sha = head_sha(&repo);
10752 git(&repo, &["update-ref", "refs/remotes/origin/trunk", &sha]);
10753 let remote_refs_dir = repo
10754 .join(".git")
10755 .join("refs")
10756 .join("remotes")
10757 .join("origin");
10758 fs::create_dir_all(&remote_refs_dir).expect("create refs/remotes/origin dir");
10759 fs::write(
10761 remote_refs_dir.join("HEAD"),
10762 "ref: refs/remotes/origin/main\n",
10763 )
10764 .expect("write HEAD");
10765
10766 let core = Core::start_discovered(spec(vec![root]));
10767 let key = core.snapshot().entities[0].key.clone();
10768
10769 core.refresh(std::slice::from_ref(&key));
10770 let settled = core.settle();
10771 let entity = &settled.entities[0];
10772
10773 match entity.default_branch.settled() {
10774 Some(Settled::Known {
10775 value,
10776 at: _,
10777 stale: _,
10778 }) => {
10779 assert_eq!(value.name(), "origin/trunk")
10780 }
10781 other => panic!("expected the name list's answer, got {other:?}"),
10782 }
10783 assert!(
10784 entity.diagnostics.default_branch_rung_two_stale,
10785 "a stale origin/HEAD target must be recorded on the entity's diagnostics"
10786 );
10787 }
10788
10789 #[test]
10792 fn a_resolvable_remote_head_is_not_recorded_as_stale() {
10793 let dir = tempfile::tempdir().expect("temp dir");
10794 let root = root_of(&dir);
10795 let repo = root.join("repo");
10796 init_repo_with_a_commit(&repo);
10797 git(
10798 &repo,
10799 &[
10800 "remote",
10801 "add",
10802 "origin",
10803 "https://example.invalid/repo.git",
10804 ],
10805 );
10806 let sha = head_sha(&repo);
10807 git(&repo, &["update-ref", "refs/remotes/origin/main", &sha]);
10808 let remote_refs_dir = repo
10809 .join(".git")
10810 .join("refs")
10811 .join("remotes")
10812 .join("origin");
10813 fs::create_dir_all(&remote_refs_dir).expect("create refs/remotes/origin dir");
10814 fs::write(
10815 remote_refs_dir.join("HEAD"),
10816 "ref: refs/remotes/origin/main\n",
10817 )
10818 .expect("write HEAD");
10819
10820 let core = Core::start_discovered(spec(vec![root]));
10821 let key = core.snapshot().entities[0].key.clone();
10822
10823 core.refresh(std::slice::from_ref(&key));
10824 let settled = core.settle();
10825 let entity = &settled.entities[0];
10826
10827 assert!(!entity.diagnostics.default_branch_rung_two_stale);
10828 }
10829
10830 #[test]
10835 fn one_override_on_a_repos_path_covers_a_worktree_sharing_its_common_dir() {
10836 let dir = tempfile::tempdir().expect("temp dir");
10837 let root = root_of(&dir);
10838 let parent = root.join("parent");
10839 init_repo_with_a_commit(&parent);
10840 let worktree = root.join("worktree");
10841 git(
10842 &parent,
10843 &[
10844 "worktree",
10845 "add",
10846 "-b",
10847 "feature",
10848 worktree.to_str().expect("utf8 path"),
10849 ],
10850 );
10851
10852 let core = Core::start_discovered(spec_with_overrides(
10853 vec![root],
10854 vec![RepoOverride {
10855 path: parent.clone(),
10856 default_branch: None,
10857 excluded: true,
10858 }],
10859 ));
10860 let snapshot = core.snapshot();
10861
10862 for entity in &snapshot.entities {
10863 assert!(
10864 entity.excluded,
10865 "both the Repo and its Worktree must inherit the entry declared on the Repo's own path, entity: {:?}",
10866 entity.key
10867 );
10868 }
10869 assert_eq!(
10870 snapshot.entities.len(),
10871 2,
10872 "expected the parent plus its worktree"
10873 );
10874 }
10875
10876 #[test]
10880 fn an_entry_naming_a_worktrees_own_path_beats_the_inherited_one() {
10881 let dir = tempfile::tempdir().expect("temp dir");
10882 let root = root_of(&dir);
10883 let parent = root.join("parent");
10884 init_repo_with_a_commit(&parent);
10885 let worktree_own = root.join("worktree-own");
10886 let worktree_inherits = root.join("worktree-inherits");
10887 git(
10888 &parent,
10889 &[
10890 "worktree",
10891 "add",
10892 "-b",
10893 "feature-own",
10894 worktree_own.to_str().expect("utf8 path"),
10895 ],
10896 );
10897 git(
10898 &parent,
10899 &[
10900 "worktree",
10901 "add",
10902 "-b",
10903 "feature-inherits",
10904 worktree_inherits.to_str().expect("utf8 path"),
10905 ],
10906 );
10907
10908 let core = Core::start_discovered(spec_with_overrides(
10909 vec![root],
10910 vec![
10911 RepoOverride {
10912 path: parent.clone(),
10913 default_branch: None,
10914 excluded: true,
10915 },
10916 RepoOverride {
10917 path: worktree_own.clone(),
10918 default_branch: None,
10919 excluded: false,
10920 },
10921 ],
10922 ));
10923 let snapshot = core.snapshot();
10924
10925 let find = |path: &Path| {
10926 snapshot
10927 .entities
10928 .iter()
10929 .find(|entity| entity.key.path() == path)
10930 .unwrap_or_else(|| panic!("entity at {path:?} present"))
10931 };
10932
10933 assert!(
10934 find(&parent).excluded,
10935 "the parent Repo has no entry of its own and inherits the excluding one"
10936 );
10937 assert!(
10938 !find(&worktree_own).excluded,
10939 "the Worktree named directly by its own path must use its own entry, not the inherited one"
10940 );
10941 assert!(
10942 find(&worktree_inherits).excluded,
10943 "a sibling Worktree with no entry of its own still inherits the Repo's entry"
10944 );
10945 }
10946
10947 #[test]
10952 fn an_override_on_the_parents_path_never_excludes_its_submodule() {
10953 let dir = tempfile::tempdir().expect("temp dir");
10954 let root = root_of(&dir);
10955 let parent = root.join("parent");
10956 init_repo_with_a_commit(&parent);
10957 fs::write(
10958 parent.join(".gitmodules"),
10959 "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
10960 )
10961 .expect("write .gitmodules");
10962 fs::create_dir_all(parent.join("vendor").join("lib")).expect("create submodule dir");
10963
10964 let core = Core::start_discovered(spec_with_overrides(
10965 vec![root],
10966 vec![RepoOverride {
10967 path: parent.clone(),
10968 default_branch: None,
10969 excluded: true,
10970 }],
10971 ));
10972 let snapshot = core.snapshot();
10973
10974 let submodule = snapshot
10975 .entities
10976 .iter()
10977 .find(|entity| matches!(entity.kind, Kind::Submodule))
10978 .expect("the submodule is still discovered and listed");
10979 assert!(
10980 !submodule.excluded,
10981 "an entry naming only the parent's path must never reach a Submodule, \
10982 whose own common dir differs from its parent's"
10983 );
10984 }
10985
10986 #[test]
11003 fn the_default_branch_chain_is_memoised_once_per_common_dir_per_generation() {
11004 let dir = tempfile::tempdir().expect("temp dir");
11005 let root = root_of(&dir);
11006 let parent = root.join("parent");
11007 init_repo_with_a_commit(&parent);
11008 for name in ["wt-a", "wt-b", "wt-c"] {
11009 let worktree = root.join(name);
11010 git(
11011 &parent,
11012 &[
11013 "worktree",
11014 "add",
11015 "-b",
11016 name,
11017 worktree.to_str().expect("utf8 path"),
11018 ],
11019 );
11020 }
11021 let other_repo = root.join("other");
11022 init_repo_with_a_commit(&other_repo);
11023
11024 let (core, launched) = started_and_settled(spec(vec![root]));
11025 let keys: Vec<EntityKey> = launched
11026 .entities
11027 .iter()
11028 .map(|entity| entity.key.clone())
11029 .collect();
11030 assert_eq!(
11031 keys.len(),
11032 5,
11033 "expected the parent, its three worktrees and the unrelated repo"
11034 );
11035
11036 core.refresh(&keys);
11037 core.settle();
11038
11039 assert_eq!(
11040 core.default_branch_chain_reads_for_test(),
11041 2,
11042 "four entities span exactly two common dirs; a memoised chain reads \
11043 each common dir once, not once per entity"
11044 );
11045
11046 core.refresh(&keys);
11050 core.settle();
11051 assert_eq!(
11052 core.default_branch_chain_reads_for_test(),
11053 2,
11054 "the memo lives inside one Generation's dispatch; the next Generation \
11055 recomputes rather than inheriting it"
11056 );
11057 }
11058
11059 #[test]
11069 fn patch_equivalence_is_memoised_once_per_common_dir_per_generation() {
11070 let dir = tempfile::tempdir().expect("temp dir");
11071 let root = root_of(&dir);
11072 let parent = root.join("parent");
11073 init_repo_with_a_commit(&parent);
11074 git(
11075 &parent,
11076 &[
11077 "remote",
11078 "add",
11079 "origin",
11080 "https://example.invalid/repo.git",
11081 ],
11082 );
11083 let base_sha = head_sha(&parent);
11084 git(
11085 &parent,
11086 &["update-ref", "refs/remotes/origin/main", &base_sha],
11087 );
11088 for name in ["feature-x", "feature-y"] {
11089 let worktree = root.join(name);
11090 git(
11091 &parent,
11092 &[
11093 "worktree",
11094 "add",
11095 "-b",
11096 name,
11097 worktree.to_str().expect("utf8 path"),
11098 ],
11099 );
11100 fs::write(worktree.join(format!("{name}.txt")), "unmerged\n")
11101 .expect("write worktree file");
11102 git(&worktree, &["add", "."]);
11103 git(&worktree, &["commit", "-m", "unmerged work"]);
11104 let tip_sha = head_sha(&worktree);
11105 git(
11106 &parent,
11107 &["config", &format!("branch.{name}.remote"), "origin"],
11108 );
11109 git(
11110 &parent,
11111 &[
11112 "config",
11113 &format!("branch.{name}.merge"),
11114 &format!("refs/heads/{name}"),
11115 ],
11116 );
11117 git(
11118 &parent,
11119 &[
11120 "update-ref",
11121 &format!("refs/remotes/origin/{name}"),
11122 &tip_sha,
11123 ],
11124 );
11125 }
11126
11127 let other_parent = root.join("other");
11128 init_repo_with_a_commit(&other_parent);
11129 git(
11130 &other_parent,
11131 &[
11132 "remote",
11133 "add",
11134 "origin",
11135 "https://example.invalid/other.git",
11136 ],
11137 );
11138 let other_base_sha = head_sha(&other_parent);
11139 git(
11140 &other_parent,
11141 &["update-ref", "refs/remotes/origin/main", &other_base_sha],
11142 );
11143 let other_worktree = root.join("other-feature");
11144 git(
11145 &other_parent,
11146 &[
11147 "worktree",
11148 "add",
11149 "-b",
11150 "other-feature",
11151 other_worktree.to_str().expect("utf8 path"),
11152 ],
11153 );
11154 fs::write(other_worktree.join("other.txt"), "unmerged\n").expect("write worktree file");
11155 git(&other_worktree, &["add", "."]);
11156 git(&other_worktree, &["commit", "-m", "unmerged work"]);
11157 let other_tip_sha = head_sha(&other_worktree);
11158 git(
11159 &other_parent,
11160 &["config", "branch.other-feature.remote", "origin"],
11161 );
11162 git(
11163 &other_parent,
11164 &[
11165 "config",
11166 "branch.other-feature.merge",
11167 "refs/heads/other-feature",
11168 ],
11169 );
11170 git(
11171 &other_parent,
11172 &[
11173 "update-ref",
11174 "refs/remotes/origin/other-feature",
11175 &other_tip_sha,
11176 ],
11177 );
11178
11179 let (core, launched) = started_and_settled(spec(vec![root]));
11180 let keys: Vec<EntityKey> = launched
11181 .entities
11182 .iter()
11183 .map(|entity| entity.key.clone())
11184 .collect();
11185 assert_eq!(
11186 keys.len(),
11187 5,
11188 "expected two parents plus their three worktrees"
11189 );
11190
11191 core.refresh(&keys);
11192 let settled = core.settle();
11193
11194 let worktree_states: Vec<_> = settled
11195 .entities
11196 .iter()
11197 .filter(|entity| matches!(entity.kind, Kind::Worktree))
11198 .map(|entity| entity.state.settled())
11199 .collect();
11200 assert_eq!(worktree_states.len(), 3, "expected three worktree rows");
11201 for settled_state in &worktree_states {
11202 assert!(
11203 matches!(
11204 settled_state,
11205 Some(Settled::Known {
11206 value: WorktreeState::Active,
11207 at: _,
11208 stale: _
11209 })
11210 ),
11211 "expected every worktree's genuinely unmerged work to settle Active, got {settled_state:?}"
11212 );
11213 }
11214
11215 assert_eq!(
11216 core.patch_identity_reads_for_test(),
11217 2,
11218 "two worktrees share one common dir and must scan its default-branch \
11219 history once between them, not once per entity; the unrelated repo's \
11220 own worktree pays for a second scan"
11221 );
11222
11223 core.refresh(&keys);
11226 core.settle();
11227 assert_eq!(
11228 core.patch_identity_reads_for_test(),
11229 2,
11230 "the memo lives inside one Generation's dispatch; the next Generation \
11231 recomputes rather than inheriting it"
11232 );
11233 }
11234
11235 #[test]
11254 fn an_entity_whose_merge_base_is_deeper_than_its_siblings_widens_the_shared_scan() {
11255 let dir = tempfile::tempdir().expect("temp dir");
11256 let root = root_of(&dir);
11257 let parent = root.join("parent");
11258 init_repo_with_a_commit(&parent);
11259 git(
11260 &parent,
11261 &[
11262 "remote",
11263 "add",
11264 "origin",
11265 "https://example.invalid/repo.git",
11266 ],
11267 );
11268 let deep_fork_sha = head_sha(&parent);
11269
11270 git(&parent, &["branch", "feature-deep"]);
11271 let deep_worktree = root.join("feature-deep");
11272 git(
11273 &parent,
11274 &[
11275 "worktree",
11276 "add",
11277 deep_worktree.to_str().expect("utf8 path"),
11278 "feature-deep",
11279 ],
11280 );
11281 fs::write(deep_worktree.join("deep.txt"), "deep work\n").expect("write deep.txt");
11282 git(&deep_worktree, &["add", "."]);
11283 git(&deep_worktree, &["commit", "-m", "deep work"]);
11284 let deep_tip_sha = head_sha(&deep_worktree);
11285
11286 git(&parent, &["merge", "--squash", "feature-deep"]);
11287 git(&parent, &["commit", "-m", "squashed deep"]);
11288 let shallow_fork_sha = head_sha(&parent);
11289
11290 git(&parent, &["branch", "feature-shallow"]);
11291 let shallow_worktree = root.join("feature-shallow");
11292 git(
11293 &parent,
11294 &[
11295 "worktree",
11296 "add",
11297 shallow_worktree.to_str().expect("utf8 path"),
11298 "feature-shallow",
11299 ],
11300 );
11301 fs::write(shallow_worktree.join("shallow.txt"), "shallow work\n")
11302 .expect("write shallow.txt");
11303 git(&shallow_worktree, &["add", "."]);
11304 git(&shallow_worktree, &["commit", "-m", "shallow work"]);
11305 let shallow_tip_sha = head_sha(&shallow_worktree);
11306
11307 git(&parent, &["merge", "--squash", "feature-shallow"]);
11308 git(&parent, &["commit", "-m", "squashed shallow"]);
11309 let main_tip_sha = head_sha(&parent);
11310 assert_ne!(
11311 deep_fork_sha, shallow_fork_sha,
11312 "the two siblings must fork at genuinely different commits"
11313 );
11314
11315 git(
11316 &parent,
11317 &["update-ref", "refs/remotes/origin/main", &main_tip_sha],
11318 );
11319 for (name, tip_sha) in [
11320 ("feature-deep", &deep_tip_sha),
11321 ("feature-shallow", &shallow_tip_sha),
11322 ] {
11323 git(
11324 &parent,
11325 &["config", &format!("branch.{name}.remote"), "origin"],
11326 );
11327 git(
11328 &parent,
11329 &[
11330 "config",
11331 &format!("branch.{name}.merge"),
11332 &format!("refs/heads/{name}"),
11333 ],
11334 );
11335 git(
11336 &parent,
11337 &[
11338 "update-ref",
11339 &format!("refs/remotes/origin/{name}"),
11340 tip_sha,
11341 ],
11342 );
11343 }
11344
11345 let (core, snapshot) = started_and_settled(spec(vec![root]));
11346 let deep_key = snapshot
11347 .entities
11348 .iter()
11349 .find(|entity| entity.key.path() == deep_worktree)
11350 .expect("feature-deep worktree discovered")
11351 .key
11352 .clone();
11353 let shallow_key = snapshot
11354 .entities
11355 .iter()
11356 .find(|entity| entity.key.path() == shallow_worktree)
11357 .expect("feature-shallow worktree discovered")
11358 .key
11359 .clone();
11360 let parent_key = snapshot
11361 .entities
11362 .iter()
11363 .find(|entity| entity.key.path() == parent)
11364 .expect("parent repo discovered")
11365 .key
11366 .clone();
11367 let order = vec![parent_key, shallow_key.clone(), deep_key.clone()];
11371
11372 core.refresh(&order);
11373 let settled = core.settle();
11374
11375 let state_of = |key: &EntityKey| {
11376 settled
11377 .entities
11378 .iter()
11379 .find(|entity| &entity.key == key)
11380 .and_then(|entity| entity.state.settled())
11381 .cloned()
11382 };
11383 assert!(
11384 matches!(
11385 state_of(&deep_key),
11386 Some(Settled::Known {
11387 value: WorktreeState::Merged,
11388 at: _,
11389 stale: _
11390 })
11391 ),
11392 "expected the deepest sibling's own squash commit to be found once the scan is \
11393 bounded by the deepest merge base, got {:?}",
11394 state_of(&deep_key)
11395 );
11396 assert!(
11397 matches!(
11398 state_of(&shallow_key),
11399 Some(Settled::Known {
11400 value: WorktreeState::Merged,
11401 at: _,
11402 stale: _
11403 })
11404 ),
11405 "expected the shallow sibling to settle Merged too, got {:?}",
11406 state_of(&shallow_key)
11407 );
11408 assert_eq!(
11409 core.patch_identity_reads_for_test(),
11410 1,
11411 "both worktrees share one common dir and must still scan its default-branch \
11412 history once between them, not once per entity"
11413 );
11414 assert_eq!(
11415 core.patch_scan_bounds_for_test(),
11416 vec![Some(id(&deep_fork_sha))],
11417 "the one shared scan that ran must have been bounded by the deepest sibling's own \
11418 merge base, not the shallower one's"
11419 );
11420 }
11421
11422 fn id(sha: &str) -> gix::ObjectId {
11423 gix::ObjectId::from_hex(sha.as_bytes()).expect("parse sha")
11424 }
11425
11426 #[test]
11433 fn bound_gate_deepest_folds_every_candidate_regardless_of_report_order() {
11434 let dir = tempfile::tempdir().expect("temp dir");
11435 let repo_path = root_of(&dir).join("repo");
11436 init_repo_with_a_commit(&repo_path);
11437 let deep_sha = id(&head_sha(&repo_path));
11438 fs::write(repo_path.join("child.txt"), "child\n").expect("write child.txt");
11439 git(&repo_path, &["add", "."]);
11440 git(&repo_path, &["commit", "-m", "child of deep"]);
11441 let shallow_sha = id(&head_sha(&repo_path));
11442
11443 let repo = gix::open(&repo_path).expect("open repo");
11444 let gate = BoundGate::new(2);
11445 gate.report(Some(shallow_sha));
11446 gate.report(Some(deep_sha));
11447
11448 assert_eq!(
11449 gate.deepest(&repo),
11450 Some(deep_sha),
11451 "the deepest candidate must win even though the shallower one reported first"
11452 );
11453 }
11454
11455 #[test]
11472 fn probe_patch_equivalence_bounds_the_scan_by_the_gates_deepest_not_its_own_merge_base() {
11473 let dir = tempfile::tempdir().expect("temp dir");
11474 let repo_path = root_of(&dir).join("repo");
11475 init_repo_with_a_commit(&repo_path);
11476 let deep_sha = id(&head_sha(&repo_path));
11477 fs::write(repo_path.join("child.txt"), "child\n").expect("write child.txt");
11478 git(&repo_path, &["add", "."]);
11479 git(&repo_path, &["commit", "-m", "child of deep"]);
11480 let shallow_sha_hex = head_sha(&repo_path);
11481 let shallow_sha = id(&shallow_sha_hex);
11482 fs::write(repo_path.join("tip.txt"), "tip\n").expect("write tip.txt");
11483 git(&repo_path, &["add", "."]);
11484 git(&repo_path, &["commit", "-m", "default tip"]);
11485 let default_tip_hex = head_sha(&repo_path);
11486
11487 let repo = gix::open(&repo_path).expect("open repo");
11488 let outstanding = landing::Outstanding {
11491 entity_tip: shallow_sha,
11492 default_tip: id(&default_tip_hex),
11493 merge_base: Some(shallow_sha),
11494 };
11495 let common_dir: Arc<Path> = Arc::from(repo_path.join(".git"));
11496 let cancel = AtomicBool::new(false);
11497 let patch_cache: PatchIdentityCache = Mutex::new(HashMap::new());
11498 let patch_reads = AtomicUsize::new(0);
11499 let patch_scan_bounds: Mutex<Vec<Option<gix::ObjectId>>> = Mutex::new(Vec::new());
11500 let memo = PatchEquivalenceMemo {
11501 cache: &patch_cache,
11502 reads: &patch_reads,
11503 scan_bounds: &patch_scan_bounds,
11504 };
11505 let gate = BoundGate::new(2);
11509 gate.report(Some(deep_sha));
11510 let mut report = GateReport::new(&gate);
11511
11512 probe_patch_equivalence(
11513 &repo,
11514 &outstanding,
11515 &common_dir,
11516 &cancel,
11517 &memo,
11518 &mut report,
11519 );
11520
11521 assert_eq!(
11522 patch_scan_bounds.lock().unwrap().as_slice(),
11523 [Some(deep_sha)],
11524 "the scan must be bounded by the deepest sibling's merge base, not shallow's own \
11525 ({shallow_sha:?})"
11526 );
11527 }
11528
11529 #[test]
11537 fn probe_patch_equivalence_diffs_from_the_merge_base_it_was_handed() {
11538 let dir = tempfile::tempdir().expect("temp dir");
11539 let repo_path = root_of(&dir).join("repo");
11540 init_repo_with_a_commit(&repo_path);
11541 let fork_point_hex = head_sha(&repo_path);
11542 git(&repo_path, &["checkout", "-b", "feature"]);
11543 fs::write(repo_path.join("a.txt"), "one\n").expect("write a.txt");
11544 git(&repo_path, &["add", "a.txt"]);
11545 git(&repo_path, &["commit", "-m", "add a"]);
11546 let mid_sha = id(&head_sha(&repo_path));
11547 fs::write(repo_path.join("b.txt"), "two\n").expect("write b.txt");
11548 git(&repo_path, &["add", "b.txt"]);
11549 git(&repo_path, &["commit", "-m", "add b"]);
11550 let feature_sha = id(&head_sha(&repo_path));
11551 git(&repo_path, &["checkout", "-B", "main", &fork_point_hex]);
11552 git(&repo_path, &["merge", "--squash", "feature"]);
11553 git(&repo_path, &["commit", "-m", "squashed feature"]);
11554 let main_sha = id(&head_sha(&repo_path));
11555
11556 let repo = gix::open(&repo_path).expect("open repo");
11557 let outstanding = landing::Outstanding {
11560 entity_tip: feature_sha,
11561 default_tip: main_sha,
11562 merge_base: Some(mid_sha),
11563 };
11564 let common_dir: Arc<Path> = Arc::from(repo_path.join(".git"));
11565 let cancel = AtomicBool::new(false);
11566 let patch_cache: PatchIdentityCache = Mutex::new(HashMap::new());
11567 let patch_reads = AtomicUsize::new(0);
11568 let patch_scan_bounds: Mutex<Vec<Option<gix::ObjectId>>> = Mutex::new(Vec::new());
11569 let memo = PatchEquivalenceMemo {
11570 cache: &patch_cache,
11571 reads: &patch_reads,
11572 scan_bounds: &patch_scan_bounds,
11573 };
11574 let gate = BoundGate::new(1);
11575 let mut report = GateReport::new(&gate);
11576
11577 let settled = probe_patch_equivalence(
11578 &repo,
11579 &outstanding,
11580 &common_dir,
11581 &cancel,
11582 &memo,
11583 &mut report,
11584 );
11585
11586 assert!(
11587 matches!(
11588 settled,
11589 Some(Settled::Known {
11590 value: WorktreeState::Active,
11591 at: _,
11592 stale: _
11593 })
11594 ),
11595 "the range must be measured from the handed-in base ({mid_sha:?}), whose only \
11596 change the squash commit does not match, got {settled:?}"
11597 );
11598 }
11599
11600 #[test]
11607 fn bound_gate_deepest_with_no_candidates_leaves_the_scan_unbounded() {
11608 let dir = tempfile::tempdir().expect("temp dir");
11609 let repo_path = root_of(&dir).join("repo");
11610 gix::init(&repo_path).expect("init repo");
11611 let repo = gix::open(&repo_path).expect("open repo");
11612
11613 let gate = BoundGate::new(2);
11614 gate.report(None);
11615 gate.report(None);
11616
11617 assert_eq!(
11618 gate.deepest(&repo),
11619 None,
11620 "no contributed candidate must leave the scan unbounded"
11621 );
11622 }
11623
11624 #[test]
11634 fn an_outstanding_entity_with_no_shared_history_settles_active_without_the_shared_scan() {
11635 let dir = tempfile::tempdir().expect("temp dir");
11636 let root = root_of(&dir);
11637 let parent = root.join("parent");
11638 init_repo_with_a_commit(&parent);
11639 git(&parent, &["branch", "-M", "main"]);
11640 git(
11641 &parent,
11642 &[
11643 "remote",
11644 "add",
11645 "origin",
11646 "https://example.invalid/repo.git",
11647 ],
11648 );
11649 let main_sha = head_sha(&parent);
11650 git(
11651 &parent,
11652 &["update-ref", "refs/remotes/origin/main", &main_sha],
11653 );
11654
11655 git(&parent, &["checkout", "--orphan", "unrelated"]);
11656 git(
11657 &parent,
11658 &["commit", "--allow-empty", "-m", "unrelated root"],
11659 );
11660 let unrelated_sha = head_sha(&parent);
11661 git(&parent, &["checkout", "main"]);
11662
11663 let worktree = root.join("unrelated");
11664 git(
11665 &parent,
11666 &[
11667 "worktree",
11668 "add",
11669 worktree.to_str().expect("utf8 path"),
11670 "unrelated",
11671 ],
11672 );
11673 git(&parent, &["config", "branch.unrelated.remote", "origin"]);
11674 git(
11675 &parent,
11676 &["config", "branch.unrelated.merge", "refs/heads/unrelated"],
11677 );
11678 git(
11679 &parent,
11680 &[
11681 "update-ref",
11682 "refs/remotes/origin/unrelated",
11683 &unrelated_sha,
11684 ],
11685 );
11686
11687 let (core, snapshot) = started_and_settled(spec(vec![root]));
11688 let worktree_key = snapshot
11689 .entities
11690 .iter()
11691 .find(|entity| entity.key.path() == worktree)
11692 .expect("unrelated worktree discovered")
11693 .key
11694 .clone();
11695
11696 core.refresh(std::slice::from_ref(&worktree_key));
11697 let settled = core.settle();
11698
11699 let state = settled
11700 .entities
11701 .iter()
11702 .find(|entity| entity.key == worktree_key)
11703 .and_then(|entity| entity.state.settled())
11704 .cloned();
11705 assert!(
11706 matches!(
11707 state,
11708 Some(Settled::Known {
11709 value: WorktreeState::Active,
11710 at: _,
11711 stale: _
11712 })
11713 ),
11714 "expected an Outstanding entity with no shared history to settle Active via the \
11715 bypass, got {state:?}"
11716 );
11717 assert_eq!(
11718 core.patch_identity_reads_for_test(),
11719 0,
11720 "the bypass must settle without ever running the shared scan"
11721 );
11722 }
11723
11724 fn add_origin_remote(path: &Path) {
11729 git(
11730 path,
11731 &[
11732 "remote",
11733 "add",
11734 "origin",
11735 "https://example.invalid/repo.git",
11736 ],
11737 );
11738 }
11739
11740 fn set_upstream(path: &Path, branch: &str, upstream_sha: &str) {
11744 git(
11745 path,
11746 &["config", &format!("branch.{branch}.remote"), "origin"],
11747 );
11748 git(
11749 path,
11750 &[
11751 "config",
11752 &format!("branch.{branch}.merge"),
11753 &format!("refs/heads/{branch}"),
11754 ],
11755 );
11756 git(
11757 path,
11758 &[
11759 "update-ref",
11760 &format!("refs/remotes/origin/{branch}"),
11761 upstream_sha,
11762 ],
11763 );
11764 }
11765
11766 fn refresh_and_settle(core: &Core) -> crate::snapshot::Snapshot {
11767 let keys: Vec<EntityKey> = core
11768 .snapshot()
11769 .entities
11770 .iter()
11771 .map(|entity| entity.key.clone())
11772 .collect();
11773 core.refresh(&keys);
11774 core.settle()
11775 }
11776
11777 fn sync_of<'a>(
11778 snapshot: &'a crate::snapshot::Snapshot,
11779 path: &Path,
11780 ) -> Option<&'a Settled<SyncState>> {
11781 snapshot
11782 .entities
11783 .iter()
11784 .find(|entity| entity.key.path() == path)
11785 .unwrap_or_else(|| panic!("no entity for {}", path.display()))
11786 .sync
11787 .settled()
11788 }
11789
11790 #[test]
11792 fn an_attached_branch_ahead_of_its_upstream_reads_the_ahead_count() {
11793 let dir = tempfile::tempdir().expect("temp dir");
11794 let root = root_of(&dir);
11795 let repo = root.join("repo");
11796 init_repo_with_a_commit(&repo);
11797 let fork_sha = head_sha(&repo);
11798 add_origin_remote(&repo);
11799 set_upstream(&repo, "main", &fork_sha);
11800 git(&repo, &["commit", "--allow-empty", "-m", "local work"]);
11801
11802 let core = Core::start_discovered(spec(vec![root]));
11803 let settled = refresh_and_settle(&core);
11804
11805 match sync_of(&settled, &repo) {
11806 Some(Settled::Known {
11807 value: SyncState::Tracking(AheadBehind { ahead, behind }),
11808 at: _,
11809 stale: _,
11810 }) => {
11811 assert_eq!(*ahead, 1);
11812 assert_eq!(*behind, 0);
11813 }
11814 other => panic!("expected 1 ahead, 0 behind, got {other:?}"),
11815 }
11816 }
11817
11818 #[test]
11820 fn an_attached_branch_behind_its_upstream_reads_the_behind_count() {
11821 let dir = tempfile::tempdir().expect("temp dir");
11822 let root = root_of(&dir);
11823 let repo = root.join("repo");
11824 init_repo_with_a_commit(&repo);
11825 git(&repo, &["checkout", "-b", "temp"]);
11826 git(&repo, &["commit", "--allow-empty", "-m", "upstream work"]);
11827 let upstream_sha = head_sha(&repo);
11828 git(&repo, &["checkout", "main"]);
11829 git(&repo, &["branch", "-D", "temp"]);
11830 add_origin_remote(&repo);
11831 set_upstream(&repo, "main", &upstream_sha);
11832
11833 let core = Core::start_discovered(spec(vec![root]));
11834 let settled = refresh_and_settle(&core);
11835
11836 match sync_of(&settled, &repo) {
11837 Some(Settled::Known {
11838 value: SyncState::Tracking(AheadBehind { ahead, behind }),
11839 at: _,
11840 stale: _,
11841 }) => {
11842 assert_eq!(*ahead, 0);
11843 assert_eq!(*behind, 1);
11844 }
11845 other => panic!("expected 0 ahead, 1 behind, got {other:?}"),
11846 }
11847 }
11848
11849 #[test]
11851 fn an_attached_branch_level_with_its_upstream_reads_in_sync() {
11852 let dir = tempfile::tempdir().expect("temp dir");
11853 let root = root_of(&dir);
11854 let repo = root.join("repo");
11855 init_repo_with_a_commit(&repo);
11856 let sha = head_sha(&repo);
11857 add_origin_remote(&repo);
11858 set_upstream(&repo, "main", &sha);
11859
11860 let core = Core::start_discovered(spec(vec![root]));
11861 let settled = refresh_and_settle(&core);
11862
11863 match sync_of(&settled, &repo) {
11864 Some(Settled::Known {
11865 value:
11866 SyncState::Tracking(AheadBehind {
11867 ahead: 0,
11868 behind: 0,
11869 }),
11870 at: _,
11871 stale: _,
11872 }) => {}
11873 other => panic!("expected level with its upstream, got {other:?}"),
11874 }
11875 }
11876
11877 #[test]
11881 fn an_attached_branch_tracking_nothing_reads_no_upstream() {
11882 let dir = tempfile::tempdir().expect("temp dir");
11883 let root = root_of(&dir);
11884 let repo = root.join("repo");
11885 init_repo_with_a_commit(&repo);
11886 add_origin_remote(&repo);
11887
11888 let core = Core::start_discovered(spec(vec![root]));
11889 let settled = refresh_and_settle(&core);
11890
11891 match sync_of(&settled, &repo) {
11892 Some(Settled::Known {
11893 value: SyncState::NoUpstream,
11894 at: _,
11895 stale: _,
11896 }) => {}
11897 other => panic!("expected no upstream configured, got {other:?}"),
11898 }
11899 }
11900
11901 #[test]
11904 fn a_detached_row_reads_no_upstream() {
11905 let dir = tempfile::tempdir().expect("temp dir");
11906 let root = root_of(&dir);
11907 let repo = root.join("repo");
11908 init_repo_with_a_commit(&repo);
11909 let first_sha = head_sha(&repo);
11910 git(&repo, &["commit", "--allow-empty", "-m", "second"]);
11911 git(&repo, &["checkout", "--detach", &first_sha]);
11912 add_origin_remote(&repo);
11913
11914 let core = Core::start_discovered(spec(vec![root]));
11915 let settled = refresh_and_settle(&core);
11916
11917 match sync_of(&settled, &repo) {
11918 Some(Settled::Known {
11919 value: SyncState::NoUpstream,
11920 at: _,
11921 stale: _,
11922 }) => {}
11923 other => panic!("expected a detached row to read no upstream, got {other:?}"),
11924 }
11925 }
11926
11927 #[test]
11932 fn a_repo_with_no_remote_reads_no_remote_on_itself_and_every_worktree() {
11933 let dir = tempfile::tempdir().expect("temp dir");
11934 let root = root_of(&dir);
11935 let parent = root.join("parent");
11936 init_repo_with_a_commit(&parent);
11937 let worktree = root.join("feature");
11938 git(
11939 &parent,
11940 &[
11941 "worktree",
11942 "add",
11943 "-b",
11944 "feature",
11945 worktree.to_str().expect("utf8 path"),
11946 ],
11947 );
11948
11949 let core = Core::start_discovered(spec(vec![root]));
11950 let settled = refresh_and_settle(&core);
11951
11952 assert_eq!(
11953 settled.entities.len(),
11954 2,
11955 "expected the parent Repo and its one linked Worktree"
11956 );
11957 for path in [&parent, &worktree] {
11958 match sync_of(&settled, path) {
11959 Some(Settled::Known {
11960 value: SyncState::NoRemote,
11961 at: _,
11962 stale: _,
11963 }) => {}
11964 other => panic!(
11965 "expected {} to read no remote at all, got {other:?}",
11966 path.display()
11967 ),
11968 }
11969 }
11970 }
11971
11972 #[test]
11978 fn sync_is_computed_for_every_entity_dispatched_this_generation_not_only_one() {
11979 let dir = tempfile::tempdir().expect("temp dir");
11980 let root = root_of(&dir);
11981 let parent = root.join("parent");
11982 init_repo_with_a_commit(&parent);
11983 let fork_sha = head_sha(&parent);
11984 add_origin_remote(&parent);
11985
11986 let ahead_worktree = root.join("feature-ahead");
11987 git(
11988 &parent,
11989 &[
11990 "worktree",
11991 "add",
11992 "-b",
11993 "feature-ahead",
11994 ahead_worktree.to_str().expect("utf8 path"),
11995 ],
11996 );
11997 set_upstream(&parent, "feature-ahead", &fork_sha);
11998 git(
11999 &ahead_worktree,
12000 &["commit", "--allow-empty", "-m", "unpushed"],
12001 );
12002
12003 let behind_worktree = root.join("feature-behind");
12004 git(
12005 &parent,
12006 &[
12007 "worktree",
12008 "add",
12009 "-b",
12010 "feature-behind",
12011 behind_worktree.to_str().expect("utf8 path"),
12012 ],
12013 );
12014 git(
12015 &behind_worktree,
12016 &["commit", "--allow-empty", "-m", "on the remote only"],
12017 );
12018 let ahead_of_behind_sha = head_sha(&behind_worktree);
12019 git(&behind_worktree, &["reset", "--hard", "HEAD~1"]);
12020 set_upstream(&parent, "feature-behind", &ahead_of_behind_sha);
12021
12022 let core = Core::start_discovered(spec(vec![root]));
12023 let settled = refresh_and_settle(&core);
12024
12025 match sync_of(&settled, &ahead_worktree) {
12026 Some(Settled::Known {
12027 value:
12028 SyncState::Tracking(AheadBehind {
12029 ahead: 1,
12030 behind: 0,
12031 }),
12032 at: _,
12033 stale: _,
12034 }) => {}
12035 other => panic!("expected feature-ahead to read 1 ahead, got {other:?}"),
12036 }
12037 match sync_of(&settled, &behind_worktree) {
12038 Some(Settled::Known {
12039 value:
12040 SyncState::Tracking(AheadBehind {
12041 ahead: 0,
12042 behind: 1,
12043 }),
12044 at: _,
12045 stale: _,
12046 }) => {}
12047 other => panic!("expected feature-behind to read 1 behind, got {other:?}"),
12048 }
12049 }
12050
12051 #[test]
12057 fn sync_recomputes_on_a_second_generation_not_only_the_first() {
12058 let dir = tempfile::tempdir().expect("temp dir");
12059 let root = root_of(&dir);
12060 let repo = root.join("repo");
12061 init_repo_with_a_commit(&repo);
12062 let fork_sha = head_sha(&repo);
12063 add_origin_remote(&repo);
12064 set_upstream(&repo, "main", &fork_sha);
12065
12066 let core = Core::start_discovered(spec(vec![root]));
12067 let first = refresh_and_settle(&core);
12068 match sync_of(&first, &repo) {
12069 Some(Settled::Known {
12070 value:
12071 SyncState::Tracking(AheadBehind {
12072 ahead: 0,
12073 behind: 0,
12074 }),
12075 at: _,
12076 stale: _,
12077 }) => {}
12078 other => panic!("expected the first Generation level with its upstream, got {other:?}"),
12079 }
12080
12081 git(
12082 &repo,
12083 &[
12084 "commit",
12085 "--allow-empty",
12086 "-m",
12087 "second Generation's own work",
12088 ],
12089 );
12090 let second = refresh_and_settle(&core);
12091 match sync_of(&second, &repo) {
12092 Some(Settled::Known {
12093 value:
12094 SyncState::Tracking(AheadBehind {
12095 ahead: 1,
12096 behind: 0,
12097 }),
12098 at: _,
12099 stale: _,
12100 }) => {}
12101 other => panic!(
12102 "expected the second Generation to recompute and read 1 ahead, got {other:?}"
12103 ),
12104 }
12105 }
12106
12107 #[test]
12123 fn worktrees_now_behind_a_moved_default_branch_are_reported_by_name() {
12124 let dir = tempfile::tempdir().expect("temp dir");
12125 let root = root_of(&dir);
12126 let repo = root.join("repo");
12127 init_repo_with_a_commit(&repo);
12128 let sha_a = head_sha(&repo);
12129 add_origin_remote(&repo);
12130 set_upstream(&repo, "main", &sha_a);
12131
12132 let behind_path = root.join("wt-behind");
12133 git(
12134 &repo,
12135 &[
12136 "worktree",
12137 "add",
12138 "-b",
12139 "topic-behind",
12140 behind_path.to_str().expect("utf8 path"),
12141 "main",
12142 ],
12143 );
12144
12145 git(&repo, &["checkout", "-b", "scratch"]);
12150 git(&repo, &["commit", "--allow-empty", "-m", "second"]);
12151 let sha_b = head_sha(&repo);
12152 git(&repo, &["checkout", "main"]);
12153 git(&repo, &["update-ref", "refs/remotes/origin/main", &sha_b]);
12154 git(&repo, &["branch", "-D", "scratch"]);
12155
12156 let caught_up_path = root.join("wt-caught-up");
12162 git(
12163 &repo,
12164 &[
12165 "worktree",
12166 "add",
12167 "-b",
12168 "topic-caught-up",
12169 caught_up_path.to_str().expect("utf8 path"),
12170 &sha_b,
12171 ],
12172 );
12173
12174 let core = Core::start_discovered(spec(vec![root]));
12175 let snapshot = refresh_and_settle(&core);
12176
12177 let base_of = |name: &str| -> u32 {
12178 let entity = snapshot
12179 .entities
12180 .iter()
12181 .find(|entity| &*entity.name == name)
12182 .unwrap_or_else(|| panic!("no entity named {name} in {snapshot:?}"));
12183 match entity.base.settled() {
12184 Some(Settled::Known {
12185 value,
12186 at: _,
12187 stale: _,
12188 }) => *value,
12189 other => panic!("expected a known base count for {name}, got {other:?}"),
12190 }
12191 };
12192
12193 assert!(
12194 base_of("wt-behind") > 0,
12195 "a Worktree branched before the default branch moved must be reported behind"
12196 );
12197 assert_eq!(
12198 base_of("wt-caught-up"),
12199 0,
12200 "a Worktree branched from the new tip must not be reported behind"
12201 );
12202 }
12203
12204 mod fetch_scheduler {
12210 use super::*;
12211 use crate::liveness::wait_for_or;
12212
12213 fn fetch_spec(enabled: bool, root: PathBuf) -> CoreSpec {
12214 let mut spec = spec(vec![root]);
12215 spec.fetch = FetchSpec {
12216 enabled,
12217 interval: Duration::from_secs(3600),
12218 concurrency: 4,
12219 };
12220 spec
12221 }
12222
12223 fn seeded_remote() -> tempfile::TempDir {
12226 let remote = tempfile::tempdir().expect("temp dir");
12227 crate::test_support::init_bare(remote.path());
12228 crate::test_support::push_new_commit(remote.path(), "README.md", "seed\n");
12229 remote
12230 }
12231
12232 fn clone_into(remote: &Path, dest: &Path) {
12233 let status = Command::new("git")
12234 .arg("clone")
12235 .arg(remote)
12236 .arg(dest)
12237 .status()
12238 .expect("run git clone");
12239 assert!(status.success());
12240 crate::test_support::set_identity(dest);
12241 }
12242
12243 #[test]
12250 fn enabling_the_periodic_fetch_runs_one_cycle_before_any_tick_arrives() {
12251 let remote = seeded_remote();
12252 let root = tempfile::tempdir().expect("temp dir");
12253 let root_path = root_of(&root);
12254 clone_into(remote.path(), &root_path.join("parent"));
12255
12256 let fetch_ticks: Receiver<Instant> = crossbeam_channel::never();
12257 let started = Core::start_for_test_with_fetch(
12258 fetch_spec(true, root_path),
12259 Duration::from_secs(3600),
12260 crossbeam_channel::never(),
12261 fetch_ticks,
12262 )
12263 .discovered();
12264 let core = started.core;
12265
12266 wait_for(
12267 "the periodic fetch to run its first cycle without waiting for a tick",
12268 || core.fetch_cycle_count_for_test() >= 1,
12269 );
12270 }
12271
12272 #[test]
12276 fn a_tick_on_the_fetch_channel_runs_another_cycle() {
12277 let remote = seeded_remote();
12278 let root = tempfile::tempdir().expect("temp dir");
12279 let root_path = root_of(&root);
12280 clone_into(remote.path(), &root_path.join("parent"));
12281
12282 let (fetch_tick_tx, fetch_tick_rx) = crossbeam_channel::unbounded();
12283 let started = Core::start_for_test_with_fetch(
12284 fetch_spec(true, root_path),
12285 Duration::from_secs(3600),
12286 crossbeam_channel::never(),
12287 fetch_tick_rx,
12288 )
12289 .discovered();
12290 let core = started.core;
12291
12292 wait_for("the immediate cycle to have run first", || {
12293 core.fetch_cycle_count_for_test() >= 1
12294 });
12295
12296 fetch_tick_tx
12297 .send(Instant::now())
12298 .expect("send a fetch tick");
12299
12300 wait_for("a tick on the fetch channel to run a second cycle", || {
12301 core.fetch_cycle_count_for_test() >= 2
12302 });
12303 }
12304
12305 fn break_remote(repo: &Path) {
12311 let status = Command::new("git")
12312 .arg("-C")
12313 .arg(repo)
12314 .args(["remote", "set-url", "origin", "/nonexistent-remote-282"])
12315 .status()
12316 .expect("run git remote set-url");
12317 assert!(status.success());
12318 }
12319
12320 #[test]
12322 fn a_cycle_in_which_every_fetch_succeeds_reports_no_failures() {
12323 let remote = seeded_remote();
12324 let root = tempfile::tempdir().expect("temp dir");
12325 let root_path = root_of(&root);
12326 clone_into(remote.path(), &root_path.join("parent"));
12327
12328 let fetch_ticks: Receiver<Instant> = crossbeam_channel::never();
12329 let started = Core::start_for_test_with_fetch(
12330 fetch_spec(true, root_path),
12331 Duration::from_secs(3600),
12332 crossbeam_channel::never(),
12333 fetch_ticks,
12334 )
12335 .discovered();
12336 let core = started.core;
12337
12338 wait_for("the periodic fetch to run its first cycle", || {
12339 core.fetch_cycle_count_for_test() >= 1
12340 });
12341
12342 assert!(
12343 core.fetch_failures().failed.is_empty(),
12344 "a cycle where every fetch succeeds must report no failures, got: {:?}",
12345 core.fetch_failures().failed
12346 );
12347 }
12348
12349 #[test]
12353 fn a_repository_that_cannot_be_fetched_is_counted_while_its_sibling_still_fetches() {
12354 let good_remote = seeded_remote();
12355 let bad_remote = seeded_remote();
12356 let root = tempfile::tempdir().expect("temp dir");
12357 let root_path = root_of(&root);
12358 let good = root_path.join("good");
12359 let bad = root_path.join("bad");
12360 clone_into(good_remote.path(), &good);
12361 clone_into(bad_remote.path(), &bad);
12362 break_remote(&bad);
12363
12364 crate::test_support::push_new_commit(good_remote.path(), "second.txt", "second\n");
12365 let good_remote_tip = rev_parse(good_remote.path(), "refs/heads/main");
12366
12367 let fetch_ticks: Receiver<Instant> = crossbeam_channel::never();
12368 let started = Core::start_for_test_with_fetch(
12369 fetch_spec(true, root_path),
12370 Duration::from_secs(3600),
12371 crossbeam_channel::never(),
12372 fetch_ticks,
12373 )
12374 .discovered();
12375 let core = started.core;
12376
12377 wait_for(
12378 "the cycle to run and count the one repository it could not fetch",
12379 || core.fetch_failures().failed.len() == 1,
12380 );
12381
12382 let failures = core.fetch_failures();
12383 assert_eq!(
12384 failures.failed.len(),
12385 1,
12386 "exactly one repository failed, so exactly one failure must be counted, \
12387 got: {:?}",
12388 failures.failed
12389 );
12390 assert!(
12391 failures.failed[0].0.to_string_lossy().contains("bad"),
12392 "the counted failure must name the repository that actually failed, \
12393 got: {:?}",
12394 failures.failed
12395 );
12396
12397 wait_for(
12398 "the sibling repository to still fetch despite the other one failing",
12399 || rev_parse(&good, "refs/remotes/origin/main") == good_remote_tip,
12400 );
12401 }
12402
12403 fn push_new_commit_on_branch(remote: &Path, branch: &str, name: &str, contents: &str) {
12407 let contributor = tempfile::tempdir().expect("temp dir");
12408 let status = Command::new("git")
12409 .arg("clone")
12410 .arg("--branch")
12411 .arg(branch)
12412 .arg(remote)
12413 .arg(contributor.path())
12414 .status()
12415 .expect("run git clone");
12416 assert!(status.success());
12417 std::fs::write(contributor.path().join(name), contents).expect("write fixture file");
12418 git(contributor.path(), &["add", name]);
12419 git(contributor.path(), &["commit", "-m", "extra work on topic"]);
12420 git(contributor.path(), &["push", "origin", branch]);
12421 }
12422
12423 #[test]
12431 fn a_finished_fetch_prunes_and_starts_its_own_generation_that_lands_gone() {
12432 let remote = seeded_remote();
12433 let root = tempfile::tempdir().expect("temp dir");
12434 let root_path = root_of(&root);
12435 let parent = root_path.join("parent");
12436 clone_into(remote.path(), &parent);
12437
12438 git(remote.path(), &["branch", "topic"]);
12439 push_new_commit_on_branch(remote.path(), "topic", "topic.txt", "extra work\n");
12440
12441 git(&parent, &["fetch", "origin"]);
12447
12448 let worktree_path = root_path.join("topic-worktree");
12449 git(
12450 &parent,
12451 &[
12452 "worktree",
12453 "add",
12454 "-b",
12455 "topic",
12456 worktree_path.to_str().expect("utf8 path"),
12457 "origin/topic",
12458 ],
12459 );
12460
12461 git(remote.path(), &["branch", "-D", "topic"]);
12465
12466 let fetch_ticks: Receiver<Instant> = crossbeam_channel::never();
12467 let started = Core::start_for_test_with_fetch(
12468 fetch_spec(true, root_path),
12469 Duration::from_secs(3600),
12470 crossbeam_channel::never(),
12471 fetch_ticks,
12472 )
12473 .discovered();
12474 let core = started.core;
12475
12476 wait_for_or(
12477 "a finished fetch's own Generation to land the pruned Worktree as Gone \
12478 without the test ever calling refresh",
12479 || {
12480 core.snapshot()
12481 .entities
12482 .iter()
12483 .filter(|entity| matches!(entity.kind, Kind::Worktree))
12484 .any(|entity| {
12485 matches!(
12486 entity.state.settled(),
12487 Some(Settled::Known {
12488 value: WorktreeState::Gone,
12489 at: _,
12490 stale: _,
12491 })
12492 )
12493 })
12494 },
12495 || {
12496 format!(
12497 "snapshot: {:?}",
12498 core.snapshot()
12499 .entities
12500 .iter()
12501 .map(|entity| (entity.kind, entity.state.settled().cloned()))
12502 .collect::<Vec<_>>()
12503 )
12504 },
12505 );
12506 }
12507
12508 fn spec_with_auto_update(
12509 fetch_enabled: bool,
12510 auto_update_enabled: bool,
12511 root: PathBuf,
12512 ) -> CoreSpec {
12513 let mut spec = fetch_spec(fetch_enabled, root);
12514 spec.auto_update = AutoUpdateSpec {
12515 enabled: auto_update_enabled,
12516 };
12517 spec
12518 }
12519
12520 fn rev_parse(path: &Path, rev: &str) -> String {
12521 let output = Command::new("git")
12522 .arg("-C")
12523 .arg(path)
12524 .args(["rev-parse", rev])
12525 .output()
12526 .expect("run git rev-parse");
12527 assert!(output.status.success(), "git rev-parse {rev} failed");
12528 String::from_utf8(output.stdout)
12529 .expect("utf8 sha")
12530 .trim()
12531 .to_string()
12532 }
12533
12534 #[test]
12541 fn auto_update_is_off_by_default_even_with_fetch_enabled() {
12542 let remote = seeded_remote();
12543 let root = tempfile::tempdir().expect("temp dir");
12544 let root_path = root_of(&root);
12545 let parent = root_path.join("parent");
12546 clone_into(remote.path(), &parent);
12547 let before = rev_parse(&parent, "refs/heads/main");
12548
12549 crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
12550
12551 let fetch_ticks: Receiver<Instant> = crossbeam_channel::never();
12552 let started = Core::start_for_test_with_fetch(
12553 spec_with_auto_update(true, false, root_path),
12554 Duration::from_secs(3600),
12555 crossbeam_channel::never(),
12556 fetch_ticks,
12557 )
12558 .discovered();
12559 let core = started.core;
12560
12561 wait_for(
12562 "the periodic fetch to still run its immediate cycle",
12563 || core.fetch_cycle_count_for_test() >= 1,
12564 );
12565 assert_eq!(
12566 rev_parse(&parent, "refs/heads/main"),
12567 before,
12568 "an eligible branch must not move while auto_update.enabled is false, \
12569 even though fetch.enabled is true"
12570 );
12571 }
12572
12573 #[test]
12580 fn auto_update_enabled_rides_the_immediate_fetch_cycle_with_no_timer_of_its_own() {
12581 let remote = seeded_remote();
12582 let root = tempfile::tempdir().expect("temp dir");
12583 let root_path = root_of(&root);
12584 let parent = root_path.join("parent");
12585 clone_into(remote.path(), &parent);
12586
12587 crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
12588 let remote_tip = rev_parse(remote.path(), "refs/heads/main");
12589
12590 let fetch_ticks: Receiver<Instant> = crossbeam_channel::never();
12591 let started = Core::start_for_test_with_fetch(
12592 spec_with_auto_update(true, true, root_path),
12593 Duration::from_secs(3600),
12594 crossbeam_channel::never(),
12595 fetch_ticks,
12596 )
12597 .discovered();
12598 let _core = started.core;
12601
12602 wait_for(
12603 "the eligible branch to fast-forward on the immediate cycle alone, with no \
12604 fetch tick and no auto-update tick of its own",
12605 || rev_parse(&parent, "refs/heads/main") == remote_tip,
12606 );
12607 }
12608 }
12609
12610 mod attempt_auto_update {
12620 use super::*;
12621
12622 fn seeded_remote() -> tempfile::TempDir {
12623 let remote = tempfile::tempdir().expect("temp dir");
12624 crate::test_support::init_bare(remote.path());
12625 crate::test_support::push_new_commit(remote.path(), "README.md", "seed\n");
12626 remote
12627 }
12628
12629 fn clone_into(remote: &Path, dest: &Path) {
12630 let status = Command::new("git")
12631 .arg("clone")
12632 .arg(remote)
12633 .arg(dest)
12634 .status()
12635 .expect("run git clone");
12636 assert!(status.success());
12637 crate::test_support::set_identity(dest);
12638 }
12639
12640 fn discover_repo(root: &Path) -> (Core, EntityKey) {
12645 let core = Core::start_discovered(spec(vec![root.to_path_buf()]));
12646 let key = core
12647 .settle()
12648 .entities
12649 .into_iter()
12650 .find(|entity| entity.kind == Kind::Repo)
12651 .expect("the Repo row is discovered")
12652 .key;
12653 (core, key)
12654 }
12655
12656 #[test]
12659 fn an_eligible_repo_fast_forwards_through_the_wrapper_too() {
12660 let remote = seeded_remote();
12661 let root = tempfile::tempdir().expect("temp dir");
12662 let root_path = root_of(&root);
12663 let repo = root_path.join("repo");
12664 clone_into(remote.path(), &repo);
12665 crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
12666 crate::test_support::git(&repo, &["fetch", "origin"]);
12667
12668 let (core, key) = discover_repo(&root_path);
12669
12670 assert_eq!(core.attempt_auto_update(&key), AutoUpdateAttempt::Updated);
12671 assert!(
12672 repo.join("second.txt").exists(),
12673 "the fast-forward must reach the working tree through the wrapper too"
12674 );
12675 }
12676
12677 #[test]
12679 fn a_dirty_repo_is_reported_not_clean_through_the_wrapper_too() {
12680 let remote = seeded_remote();
12681 let root = tempfile::tempdir().expect("temp dir");
12682 let root_path = root_of(&root);
12683 let repo = root_path.join("repo");
12684 clone_into(remote.path(), &repo);
12685 crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
12686 crate::test_support::git(&repo, &["fetch", "origin"]);
12687 fs::write(repo.join("stray.txt"), "uncommitted\n").expect("write a stray file");
12688
12689 let (core, key) = discover_repo(&root_path);
12690
12691 assert_eq!(core.attempt_auto_update(&key), AutoUpdateAttempt::NotClean);
12692 }
12693
12694 #[test]
12696 fn an_up_to_date_repo_is_reported_not_behind_through_the_wrapper_too() {
12697 let remote = seeded_remote();
12698 let root = tempfile::tempdir().expect("temp dir");
12699 let root_path = root_of(&root);
12700 let repo = root_path.join("repo");
12701 clone_into(remote.path(), &repo);
12702 crate::test_support::git(&repo, &["fetch", "origin"]);
12703
12704 let (core, key) = discover_repo(&root_path);
12705
12706 assert_eq!(core.attempt_auto_update(&key), AutoUpdateAttempt::NotBehind);
12707 }
12708
12709 #[test]
12711 fn an_unpublished_local_commit_is_reported_not_fast_forward_through_the_wrapper_too() {
12712 let remote = seeded_remote();
12713 let root = tempfile::tempdir().expect("temp dir");
12714 let root_path = root_of(&root);
12715 let repo = root_path.join("repo");
12716 clone_into(remote.path(), &repo);
12717 crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
12718 crate::test_support::git(&repo, &["fetch", "origin"]);
12719 crate::test_support::commit_file(&repo, "local-only.txt", "never pushed\n");
12720
12721 let (core, key) = discover_repo(&root_path);
12722
12723 assert_eq!(
12724 core.attempt_auto_update(&key),
12725 AutoUpdateAttempt::NotFastForward
12726 );
12727 }
12728
12729 #[test]
12731 fn a_branch_with_no_upstream_is_reported_through_the_wrapper_too() {
12732 let remote = seeded_remote();
12733 let root = tempfile::tempdir().expect("temp dir");
12734 let root_path = root_of(&root);
12735 let repo = root_path.join("repo");
12736 clone_into(remote.path(), &repo);
12737 crate::test_support::git(&repo, &["checkout", "-b", "untracked-branch"]);
12738
12739 let (core, key) = discover_repo(&root_path);
12740
12741 assert_eq!(
12742 core.attempt_auto_update(&key),
12743 AutoUpdateAttempt::NoUpstream
12744 );
12745 }
12746 }
12747
12748 mod network_default_branch {
12755 use super::*;
12756
12757 fn seeded_remote() -> tempfile::TempDir {
12758 let remote = tempfile::tempdir().expect("temp dir");
12759 crate::test_support::init_bare(remote.path());
12760 crate::test_support::push_new_commit(remote.path(), "README.md", "seed\n");
12761 remote
12762 }
12763
12764 fn clone_into(remote: &Path, dest: &Path) {
12765 let status = Command::new("git")
12766 .arg("clone")
12767 .arg(remote)
12768 .arg(dest)
12769 .status()
12770 .expect("run git clone");
12771 assert!(status.success());
12772 crate::test_support::set_identity(dest);
12773 }
12774
12775 fn set_remote_head(path: &Path, branch: &str) {
12778 git(
12779 path,
12780 &["symbolic-ref", "HEAD", &format!("refs/heads/{branch}")],
12781 );
12782 }
12783
12784 fn rev_parse(path: &Path, rev: &str) -> String {
12785 let output = Command::new("git")
12786 .arg("-C")
12787 .arg(path)
12788 .args(["rev-parse", rev])
12789 .output()
12790 .expect("run git rev-parse");
12791 assert!(output.status.success());
12792 String::from_utf8(output.stdout)
12793 .expect("utf8 sha")
12794 .trim()
12795 .to_string()
12796 }
12797
12798 fn default_branch_name(entity: &EntityState) -> Option<String> {
12799 match entity.default_branch.settled() {
12800 Some(Settled::Known {
12801 value,
12802 at: _,
12803 stale: _,
12804 }) => Some(value.name().to_string()),
12805 _ => None,
12806 }
12807 }
12808
12809 #[test]
12819 fn the_local_chain_answers_first_and_only_a_later_network_round_trip_supersedes_it() {
12820 let remote = seeded_remote();
12821 let root = tempfile::tempdir().expect("temp dir");
12822 let root_path = root_of(&root);
12823 let repo_path = root_path.join("repo");
12824 clone_into(remote.path(), &repo_path);
12825
12826 git(remote.path(), &["branch", "trunk"]);
12829 set_remote_head(remote.path(), "trunk");
12830
12831 let core = Core::start_discovered(spec(vec![root_path]));
12832 let key = core.snapshot().entities[0].key.clone();
12833
12834 core.refresh(std::slice::from_ref(&key));
12835 let settled = core.settle();
12836 assert_eq!(
12837 default_branch_name(&settled.entities[0]),
12838 Some("origin/main".to_string()),
12839 "a plain refresh must answer from the local chain alone, unaffected by the \
12840 remote's own current (but not yet asked) truth"
12841 );
12842
12843 core.rederive_default_branches(std::slice::from_ref(&key));
12844 let settled = core.settle();
12845 assert_eq!(
12846 default_branch_name(&settled.entities[0]),
12847 Some("origin/trunk".to_string()),
12848 "once the network round trip actually ran, its own differing answer must \
12849 supersede the local chain's"
12850 );
12851 }
12852
12853 #[test]
12863 fn rederive_default_branches_never_fetches_and_leaves_a_row_outside_it_untouched() {
12864 let remote = seeded_remote();
12865 let root = tempfile::tempdir().expect("temp dir");
12866 let root_path = root_of(&root);
12867 let selected_path = root_path.join("selected");
12868 let outside_path = root_path.join("outside");
12869 clone_into(remote.path(), &selected_path);
12870 init_repo_with_a_commit(&outside_path);
12871
12872 git(remote.path(), &["branch", "trunk"]);
12873 crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
12874 set_remote_head(remote.path(), "trunk");
12875 let before_tracking = rev_parse(&selected_path, "refs/remotes/origin/main");
12876
12877 let core = Core::start_discovered(spec(vec![root_path]));
12878 let snapshot = core.snapshot();
12879 let selected_key = snapshot
12880 .entities
12881 .iter()
12882 .find(|entity| entity.key.path() == selected_path)
12883 .expect("discovered the selected repo")
12884 .key
12885 .clone();
12886 let outside_key = snapshot
12887 .entities
12888 .iter()
12889 .find(|entity| entity.key.path() == outside_path)
12890 .expect("discovered the outside repo")
12891 .key
12892 .clone();
12893
12894 core.refresh(&[selected_key.clone(), outside_key.clone()]);
12895 let settled = core.settle();
12896 let outside_before = format!(
12897 "{:?}",
12898 settled
12899 .entities
12900 .iter()
12901 .find(|entity| entity.key == outside_key)
12902 .expect("outside entity present")
12903 );
12904
12905 core.rederive_default_branches(std::slice::from_ref(&selected_key));
12906 let settled = core.settle();
12907
12908 let selected_after = settled
12909 .entities
12910 .iter()
12911 .find(|entity| entity.key == selected_key)
12912 .expect("selected entity present");
12913 assert_eq!(
12914 default_branch_name(selected_after),
12915 Some("origin/trunk".to_string()),
12916 "the rederive must have reached the remote's own current, differing answer"
12917 );
12918
12919 let after_tracking = rev_parse(&selected_path, "refs/remotes/origin/main");
12920 assert_eq!(
12921 before_tracking, after_tracking,
12922 "a rederive must never fetch: the remote-tracking ref must not have moved \
12923 even though the remote gained a new commit"
12924 );
12925
12926 let outside_after = format!(
12927 "{:?}",
12928 settled
12929 .entities
12930 .iter()
12931 .find(|entity| entity.key == outside_key)
12932 .expect("outside entity present")
12933 );
12934 assert_eq!(
12935 outside_before, outside_after,
12936 "a row outside the rederive's own keys must be left exactly as it was, not \
12937 only on its default_branch cell"
12938 );
12939 }
12940 }
12941
12942 #[test]
12950 fn set_exclusions_excludes_a_row_already_in_the_table_with_no_rebuild() {
12951 let dir = tempfile::tempdir().expect("temp dir");
12952 let root = root_of(&dir);
12953 let repo = root.join("repo");
12954 init_repo_with_a_commit(&repo);
12955
12956 let core = Core::start_discovered(spec(vec![root]));
12957 let snapshot = core.settle();
12958 let key = snapshot.entities[0].key.clone();
12959 let generation_before = snapshot.generation;
12960 assert!(
12961 !snapshot.entities[0].excluded,
12962 "nothing excludes it to start with"
12963 );
12964 assert_eq!(core.operable_count(std::slice::from_ref(&key)), 1);
12965
12966 core.set_exclusions(&[RepoOverride {
12967 path: repo.clone(),
12968 default_branch: None,
12969 excluded: true,
12970 }]);
12971
12972 let after = core.snapshot();
12973 assert!(
12974 after.entities[0].excluded,
12975 "the row the write named is excluded in the very next snapshot"
12976 );
12977 assert_eq!(
12978 core.operable_count(&[key]),
12979 0,
12980 "an excluded row is subtracted from what an operation may reach"
12981 );
12982 assert_eq!(
12983 after.generation, generation_before,
12984 "re-applying an operate-time filter must start no Generation of its own"
12985 );
12986 }
12987
12988 #[test]
12991 fn set_exclusions_clears_the_flag_when_the_entry_is_gone() {
12992 let dir = tempfile::tempdir().expect("temp dir");
12993 let root = root_of(&dir);
12994 let repo = root.join("repo");
12995 init_repo_with_a_commit(&repo);
12996
12997 let core = Core::start_discovered(spec_with_overrides(
12998 vec![root],
12999 vec![RepoOverride {
13000 path: repo.clone(),
13001 default_branch: None,
13002 excluded: true,
13003 }],
13004 ));
13005 assert!(
13006 core.settle().entities[0].excluded,
13007 "the starting override excludes it"
13008 );
13009
13010 core.set_exclusions(&[]);
13011
13012 assert!(
13013 !core.snapshot().entities[0].excluded,
13014 "removing the entry unexcludes the row in the very next snapshot"
13015 );
13016 }
13017
13018 #[test]
13023 fn set_exclusions_moves_exclude_alone_and_never_the_default_branch_override() {
13024 let dir = tempfile::tempdir().expect("temp dir");
13025 let root = root_of(&dir);
13026 let repo = root.join("repo");
13027 init_repo_with_a_commit(&repo);
13028 crate::test_support::git(&repo, &["branch", "trunk"]);
13029
13030 let core = Core::start_discovered(spec(vec![root]));
13031 let key = core.settle().entities[0].key.clone();
13032 core.refresh(std::slice::from_ref(&key));
13033 let before = format!("{:?}", core.settle().entities[0].default_branch.settled());
13034
13035 core.set_exclusions(&[RepoOverride {
13036 path: repo.clone(),
13037 default_branch: Some("trunk".to_string()),
13038 excluded: true,
13039 }]);
13040 core.refresh(&[key]);
13041 core.settle();
13042
13043 let after = core.snapshot();
13044 assert!(after.entities[0].excluded, "exclude took effect");
13045 assert_eq!(
13046 format!("{:?}", after.entities[0].default_branch.settled()),
13047 before,
13048 "a default_branch override reaches a session only through a rebuilt Core"
13049 );
13050 }
13051
13052 #[test]
13060 fn record_own_work_leaves_one_receipt_per_row_it_names_and_none_elsewhere() {
13061 let dir = tempfile::tempdir().expect("temp dir");
13062 let root = root_of(&dir);
13063 init_repo_with_a_commit(&root.join("repo-a"));
13064 init_repo_with_a_commit(&root.join("repo-b"));
13065
13066 let core = Core::start_discovered(spec(vec![root]));
13067 let entities = core.settle().entities;
13068 let named = entities
13069 .iter()
13070 .find(|entity| &*entity.name == "repo-a")
13071 .expect("repo-a is discovered")
13072 .key
13073 .clone();
13074
13075 core.record_own_work(
13076 "ignore",
13077 &[(
13078 named.clone(),
13079 OwnWork::Refused(Arc::from("refused, already ignored")),
13080 Duration::from_millis(7),
13081 )],
13082 );
13083
13084 let after = core.snapshot().entities;
13085 let receipt = after
13086 .iter()
13087 .find(|entity| entity.key == named)
13088 .and_then(|entity| entity.last_action.clone())
13089 .expect("the row it named carries a receipt");
13090 assert_eq!(&*receipt.label, "ignore");
13091 assert!(
13092 !receipt.not_applicable(),
13093 "a refusal is not an excluded row"
13094 );
13095 assert!(receipt.running.is_none(), "the work is already done");
13096 assert_eq!(receipt.steps.len(), 1, "one act, not an ordered list");
13097 assert_eq!(&*receipt.steps[0].label, "ignore");
13098 assert_eq!(receipt.steps[0].elapsed, Duration::from_millis(7));
13099 assert!(receipt.steps[0].output.is_empty(), "nothing to quote");
13100 assert!(receipt.steps[0].elision.is_none());
13101 assert_eq!(
13102 receipt.steps[0].outcome,
13103 StepOutcome::OwnWork(OwnWork::Refused(Arc::from("refused, already ignored"))),
13104 );
13105 assert!(
13106 after
13107 .iter()
13108 .filter(|entity| entity.key != named)
13109 .all(|entity| entity.last_action.is_none()),
13110 "no row this did not name takes a receipt"
13111 );
13112 }
13113
13114 #[test]
13118 fn record_own_work_skips_a_key_the_table_no_longer_holds() {
13119 let dir = tempfile::tempdir().expect("temp dir");
13120 let root = root_of(&dir);
13121 init_repo_with_a_commit(&root.join("repo-a"));
13122
13123 let core = Core::start_discovered(spec(vec![root]));
13124 let entities = core.settle().entities;
13125 let stranger = EntityKey::new(Arc::from(std::path::Path::new("/nowhere/at/all")));
13126
13127 core.record_own_work(
13128 "delete",
13129 &[(stranger, OwnWork::Did(Arc::from("gone")), Duration::ZERO)],
13130 );
13131
13132 assert!(
13133 core.snapshot()
13134 .entities
13135 .iter()
13136 .all(|entity| entity.last_action.is_none()),
13137 "an unknown key writes nothing anywhere"
13138 );
13139 assert_eq!(core.snapshot().entities.len(), entities.len());
13140 }
13141
13142 #[test]
13151 fn delete_risk_reads_all_three_facts_the_confirm_gate_names() {
13152 let dir = tempfile::tempdir().expect("temp dir");
13153 let root = root_of(&dir);
13154 let repo = root.join("repo");
13155 init_repo_with_a_commit(&repo);
13156 fs::write(repo.join("uncommitted.txt"), "not staged\n").expect("write a stray file");
13157 crate::test_support::git(
13158 &repo,
13159 &["worktree", "add", "-b", "sidecar", "../sidecar-worktree"],
13160 );
13161
13162 let core = Core::start_discovered(spec(vec![root]));
13163 let key = core
13167 .settle()
13168 .entities
13169 .into_iter()
13170 .find(|entity| entity.kind == Kind::Repo)
13171 .expect("the Repo row is discovered")
13172 .key;
13173
13174 let risk = core.delete_risk(&key).expect("read the risk");
13175
13176 assert!(risk.uncommitted, "the stray file makes the tree dirty");
13177 assert!(
13178 risk.unpushed_commits > 0 && risk.unpushed_branches > 0,
13179 "no remote-tracking ref carries any of this Repo's commits, got {risk:?}"
13180 );
13181 assert_eq!(
13182 risk.linked_worktrees, 1,
13183 "the one linked Worktree pointing into this Repo is counted, got {risk:?}"
13184 );
13185 }
13186
13187 #[test]
13193 fn every_kind_of_work_that_is_not_in_a_commit_makes_the_gate_say_uncommitted() {
13194 for kind in ["modified", "deleted", "untracked", "staged"] {
13195 let dir = tempfile::tempdir().expect("temp dir");
13196 let root = root_of(&dir);
13197 let repo = root.join("repo");
13198 init_repo_with_a_commit(&repo);
13199 fs::write(repo.join("tracked.txt"), "first\n").expect("write a tracked file");
13200 crate::test_support::git(&repo, &["add", "tracked.txt"]);
13201 crate::test_support::git(&repo, &["commit", "-m", "add tracked"]);
13202 let sha = crate::test_support::head_sha(&repo);
13203 crate::test_support::git(&repo, &["update-ref", "refs/remotes/origin/main", &sha]);
13204
13205 match kind {
13206 "modified" => fs::write(repo.join("tracked.txt"), "second\n").expect("modify it"),
13207 "deleted" => fs::remove_file(repo.join("tracked.txt")).expect("delete it"),
13208 "untracked" => fs::write(repo.join("stray.txt"), "new\n").expect("write a stray"),
13209 "staged" => {
13210 fs::write(repo.join("staged.txt"), "new\n").expect("write a new file");
13211 crate::test_support::git(&repo, &["add", "staged.txt"]);
13212 }
13213 other => unreachable!("unhandled kind {other}"),
13214 }
13215
13216 let core = Core::start_discovered(spec(vec![root]));
13217 let key = core.settle().entities[0].key.clone();
13218
13219 let risk = core.delete_risk(&key).expect("read the risk");
13220
13221 assert!(
13222 risk.uncommitted,
13223 "a {kind} change is work that is not in a commit, got {risk:?}"
13224 );
13225 }
13226 }
13227
13228 #[test]
13235 fn staged_work_reads_clean_to_the_dirty_column_and_uncommitted_to_the_delete_gate() {
13236 let dir = tempfile::tempdir().expect("temp dir");
13237 let root = root_of(&dir);
13238 let repo = root.join("repo");
13239 init_repo_with_a_commit(&repo);
13240 let sha = crate::test_support::head_sha(&repo);
13241 crate::test_support::git(&repo, &["update-ref", "refs/remotes/origin/main", &sha]);
13242 fs::write(repo.join("staged.txt"), "staged\n").expect("write a new file");
13243 crate::test_support::git(&repo, &["add", "staged.txt"]);
13244
13245 let core = Core::start_discovered(spec(vec![root]));
13246 let key = core.settle().entities[0].key.clone();
13247
13248 let opened = git::open_thread_safe(repo.as_path())
13249 .expect("open the repo")
13250 .to_thread_local();
13251 let dirty = git::dirty_counts(&opened, Arc::new(AtomicBool::new(false)))
13252 .expect("read the dirty counts");
13253 assert_eq!(
13254 dirty.total(),
13255 0,
13256 "the dirty column stays an index-to-worktree comparison, got {dirty:?}"
13257 );
13258
13259 let risk = core.delete_risk(&key).expect("read the risk");
13260 assert!(
13261 risk.uncommitted,
13262 "a Repo whose only work is staged must never be listed plainly, got {risk:?}"
13263 );
13264 }
13265
13266 #[test]
13270 fn unpushed_commits_and_unpushed_branches_are_counted_into_their_own_fields() {
13271 let dir = tempfile::tempdir().expect("temp dir");
13272 let root = root_of(&dir);
13273 let repo = root.join("repo");
13274 init_repo_with_a_commit(&repo);
13275 let sha = crate::test_support::head_sha(&repo);
13276 crate::test_support::git(&repo, &["update-ref", "refs/remotes/origin/main", &sha]);
13277 for nth in 0..3 {
13278 fs::write(repo.join(format!("file-{nth}.txt")), "x\n").expect("write a file");
13279 crate::test_support::git(&repo, &["add", "."]);
13280 crate::test_support::git(&repo, &["commit", "-m", "unpushed"]);
13281 }
13282 crate::test_support::git(&repo, &["checkout", "."]);
13283
13284 let core = Core::start_discovered(spec(vec![root]));
13285 let key = core.settle().entities[0].key.clone();
13286
13287 let risk = core.delete_risk(&key).expect("read the risk");
13288
13289 assert_eq!(
13290 (risk.unpushed_commits, risk.unpushed_branches),
13291 (3, 1),
13292 "three commits on one branch, each in its own field, got {risk:?}"
13293 );
13294 }
13295
13296 #[test]
13300 fn a_linked_worktree_outside_the_sets_roots_is_still_counted_by_the_gate() {
13301 let dir = tempfile::tempdir().expect("temp dir");
13302 let base = root_of(&dir);
13303 let inside = base.join("inside");
13304 let outside = base.join("outside");
13305 fs::create_dir_all(&outside).expect("create the outside dir");
13306 let repo = inside.join("repo");
13307 init_repo_with_a_commit(&repo);
13308 crate::test_support::git(
13309 &repo,
13310 &["worktree", "add", "-b", "sidecar", "../../outside/sidecar"],
13311 );
13312 assert!(
13313 outside.join("sidecar").exists(),
13314 "the harness really created a linked Worktree outside the Set's roots"
13315 );
13316
13317 let core = Core::start_discovered(spec(vec![inside]));
13319 let snapshot = core.settle();
13320 assert!(
13321 snapshot
13322 .entities
13323 .iter()
13324 .all(|entity| entity.kind != Kind::Worktree),
13325 "the Worktree is outside the roots and so is not discovered, got {:?}",
13326 snapshot.entities.iter().map(|e| e.kind).collect::<Vec<_>>()
13327 );
13328 let key = snapshot
13329 .entities
13330 .into_iter()
13331 .find(|entity| entity.kind == Kind::Repo)
13332 .expect("the Repo row is discovered")
13333 .key;
13334
13335 let risk = core.delete_risk(&key).expect("read the risk");
13336
13337 assert_eq!(
13338 risk.linked_worktrees, 1,
13339 "the gate must name the linked Worktree deleting this Repo would orphan, got {risk:?}"
13340 );
13341 }
13342
13343 #[test]
13348 fn delete_risk_on_a_clean_fully_pushed_repo_with_no_worktrees_reports_nothing() {
13349 let dir = tempfile::tempdir().expect("temp dir");
13350 let root = root_of(&dir);
13351 let repo = root.join("repo");
13352 init_repo_with_a_commit(&repo);
13353 let sha = crate::test_support::head_sha(&repo);
13354 crate::test_support::git(&repo, &["update-ref", "refs/remotes/origin/main", &sha]);
13355
13356 let core = Core::start_discovered(spec(vec![root]));
13357 let key = core.settle().entities[0].key.clone();
13358
13359 let risk = core.delete_risk(&key).expect("read the risk");
13360
13361 assert_eq!(
13362 risk,
13363 DeleteRisk {
13364 uncommitted: false,
13365 unpushed_commits: 0,
13366 unpushed_branches: 0,
13367 linked_worktrees: 0,
13368 }
13369 );
13370 }
13371
13372 #[test]
13381 fn worktree_admin_dir_names_the_entry_git_worktree_list_forgets_once_it_is_removed() {
13382 let dir = tempfile::tempdir().expect("temp dir");
13383 let root = root_of(&dir);
13384 let repo = root.join("repo");
13385 init_repo_with_a_commit(&repo);
13386 let worktree = root.join("sidecar");
13387 crate::test_support::git(
13388 &repo,
13389 &[
13390 "worktree",
13391 "add",
13392 "-b",
13393 "sidecar",
13394 worktree.to_str().expect("utf8 path"),
13395 ],
13396 );
13397
13398 let core = Core::start_discovered(spec(vec![root]));
13399 let key = core
13400 .settle()
13401 .entities
13402 .into_iter()
13403 .find(|entity| entity.kind == Kind::Worktree)
13404 .expect("the Worktree row is discovered")
13405 .key;
13406
13407 let admin_dir = core.worktree_admin_dir(&key).expect("read the admin dir");
13408 fs::remove_dir_all(&admin_dir).expect("remove the admin dir by hand");
13409
13410 let reopened = git::open_thread_safe(&repo)
13411 .expect("reopen the repo")
13412 .to_thread_local();
13413 assert_eq!(
13414 git::linked_worktrees(&reopened).expect("count"),
13415 0,
13416 "removing the admin dir alone must be what git's own register stops naming"
13417 );
13418 }
13419
13420 #[test]
13424 fn worktree_admin_dir_errors_when_the_path_cannot_be_opened_as_a_repository() {
13425 let dir = tempfile::tempdir().expect("temp dir");
13426 let root = root_of(&dir);
13427 let not_a_repo = root.join("plain-directory");
13428 fs::create_dir_all(¬_a_repo).expect("create it");
13429
13430 let core = Core::start_discovered(spec(vec![root]));
13431 core.settle();
13432 let key = EntityKey::new(Arc::from(not_a_repo.as_path()));
13433
13434 assert!(core.worktree_admin_dir(&key).is_err());
13435 }
13436
13437 #[test]
13440 fn linked_worktree_paths_names_every_linked_worktrees_own_directory() {
13441 let dir = tempfile::tempdir().expect("temp dir");
13442 let root = root_of(&dir);
13443 let repo = root.join("repo");
13444 init_repo_with_a_commit(&repo);
13445 let first = root.join("first-worktree");
13446 let second = root.join("second-worktree");
13447 crate::test_support::git(
13448 &repo,
13449 &[
13450 "worktree",
13451 "add",
13452 "-b",
13453 "one",
13454 first.to_str().expect("utf8 path"),
13455 ],
13456 );
13457 crate::test_support::git(
13458 &repo,
13459 &[
13460 "worktree",
13461 "add",
13462 "-b",
13463 "two",
13464 second.to_str().expect("utf8 path"),
13465 ],
13466 );
13467
13468 let core = Core::start_discovered(spec(vec![root]));
13469 let key = core
13470 .settle()
13471 .entities
13472 .into_iter()
13473 .find(|entity| entity.kind == Kind::Repo)
13474 .expect("the Repo row is discovered")
13475 .key;
13476
13477 let mut paths = core
13478 .linked_worktree_paths(&key)
13479 .expect("read the linked worktree paths");
13480 paths.sort();
13481 let mut expected = vec![
13482 first.canonicalize().expect("canonicalize first"),
13483 second.canonicalize().expect("canonicalize second"),
13484 ];
13485 expected.sort();
13486
13487 assert_eq!(paths, expected);
13488 }
13489}