Skip to main content

repon_core/
core.rs

1//! `Core::start` and the threads and clocks it owns.
2//!
3//! See `docs/spec/core-api.md`'s "Threads and lifecycle" and "The entry points",
4//! [ADR 0006](https://github.com/paulchiu/repon/blob/main/docs/adr/0006-no-git-state-cache-session-state-by-name.md)
5//! and [ADR 0015](https://github.com/paulchiu/repon/blob/main/docs/adr/0015-the-core-owns-the-table.md).
6//!
7//! One dedicated thread runs the two second metadata poll and the thirty second
8//! Generation deadline sweep on a shared interval loop; probes go on rayon's global
9//! pool, one task per entity, which is infrastructure this crate already shares
10//! rather than a thread `Core` itself owns. `Core::start` is the only thing that
11//! spawns the dedicated thread, and `Drop` joins it, so a consumer never spawns one
12//! of its own. The dedicated thread's ticking source is an injected channel rather
13//! than a bare `thread::sleep`, which is what lets a test drive the poll and
14//! deadline cadence deterministically instead of sleeping and hoping.
15//!
16//! Discovery, both halves, re-runs at the head of every Generation
17//! ([refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md),
18//! [discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md)):
19//! an Entity a later walk does not find goes [`crate::entity::Presence::Vanished`]
20//! rather than disappearing, and one an abandoned walk cannot finish in time takes
21//! its Set out of this automatic path until a fresh `Core` starts over different
22//! roots. Phase C, status, is later work; this module already dispatches identity
23//! (`branch`), `default_branch`, Phase B's own comparison (`sync`) and Phase D's
24//! landing pass (`state`), the reads [`crate::git::head_shape`],
25//! [`crate::default_branch::resolve`], [`crate::git::resolve_sync`] and
26//! [`crate::landing::probe`] already do correctly, so the threading and
27//! supersession machinery has a real payload to move rather than a stub. Nothing
28//! here is written to or read from disk, so
29//! every `start` recomputes from scratch; the consequence is that first-frame
30//! speed has to come from progressive loading rather than a cache, which is
31//! future work this crate does not yet do (`start` blocks on its one discovery
32//! walk, at 20ms for the measured population).
33
34use 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/// Budget within which rows with names must be on screen at first frame
64/// (`docs/spec/refresh.md`'s "The first frame"). Nothing reads this yet; discovery's own
65/// wall time is the thing that would have to stay under it once first-frame timing is
66/// enforced rather than merely stated. Read only by
67/// `first_frame_budget_constants_match_the_spec_of_record`.
68#[allow(dead_code)] // read only by first_frame_budget_constants_match_the_spec_of_record
69const FIRST_FRAME_NAMES_BUDGET_MS: u64 = 50;
70
71/// Budget within which every cheap column (phase A/B) must be filled at first frame
72/// (`docs/spec/refresh.md`'s "The first frame"). Nothing reads this yet; the identity and
73/// comparison probes' combined wall time is what would have to stay under it. Read only by
74/// `first_frame_budget_constants_match_the_spec_of_record`.
75#[allow(dead_code)] // read only by first_frame_budget_constants_match_the_spec_of_record
76const FIRST_FRAME_CHEAP_COLUMNS_BUDGET_MS: u64 = 200;
77
78/// One Repo's config-level override, crossing from the consumer as plain data: no
79/// TOML type, no `~` expansion left undone. [config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md)
80/// owns parsing it; the core only ever receives the result, and resolves `path` to
81/// the git common dir itself, since opening a repository is the core's own work.
82///
83/// Keyed on `path` rather than the common dir `docs/spec/core-api.md` first named,
84/// which this amends: a Worktree and its parent Repo share one common dir, so only
85/// the entry's own path can outrank an entry reached by inheritance.
86#[derive(Debug, Clone)]
87pub struct RepoOverride {
88    pub path: PathBuf,
89    pub default_branch: Option<String>,
90    pub excluded: bool,
91}
92
93/// One command in an Action's ordered list, crossing from the consumer as plain data:
94/// `from_env` already resolved, so this crate never learns what that means
95/// ([config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md)'s
96/// "Actions", "Launchers" carries the same split for a Launcher's own argv). `shell`
97/// crosses over unresolved, because resolving it is `executor::run_step`'s own job,
98/// the same convention the `repon` crate's Launcher `shell = true` uses: with
99/// `shell` set, `argv` holds exactly one element, the whole command string, per
100/// [config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md)'s
101/// "Launchers". `env`'s pairs are applied after
102/// [`environment::environment`]'s own set-or-unset pairs, so a step's own `env` table
103/// overrides the guaranteed set exactly as [config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md)'s
104/// Launcher `env` field already does.
105#[derive(Debug, Clone)]
106pub struct Step {
107    pub argv: Vec<String>,
108    pub shell: bool,
109    /// Runs `argv` through `$SHELL -ic` rather than `$SHELL -c`, sourcing the user's own rc
110    /// file first ([config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md)'s
111    /// `interactive` key). Meaningless, and always `false`, unless `shell` is also set; the
112    /// consumer rejects `interactive` without `shell` at config load rather than this crate
113    /// silently ignoring it.
114    pub interactive: bool,
115    pub env: Vec<(String, String)>,
116}
117
118/// One Action fan-out, crossing from the consumer as plain data: no TOML type, no
119/// confirm gate, no palette. `label` is what a receipt's own label carries: the
120/// Action's name, or the ad hoc command string typed into the palette. `name` is
121/// `REPON_ACTION`'s own value and is `None` for an ad hoc run, exactly as
122/// [config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md)'s
123/// environment contract already treats a Launcher's absent `REPON_ACTION`. `when` is
124/// `None` for a built-in, a typed command or a configured entry that declares none, in
125/// which case every operable row runs; `Some` is what [`Core::run_action`] itself now
126/// decides the fan-out by, not only what a palette reports about it
127/// ([`docs/spec/actions.md`](https://github.com/paulchiu/repon/blob/main/docs/spec/actions.md)'s
128/// "The Selection and the gate").
129#[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/// A [`RepoOverride`]'s probe-input half with its common dir already resolved, built
139/// once at `Core::start` and frozen for this `Core`'s life: `default_branch` is read
140/// while probing, so moving it needs the rediscovery a rebuild does
141/// ([repo-management.md](https://github.com/paulchiu/repon/blob/main/docs/spec/repo-management.md)'s
142/// "Writing config").
143#[derive(Debug, Clone)]
144struct ResolvedOverride {
145    path: PathBuf,
146    common_dir: PathBuf,
147    default_branch: Option<String>,
148}
149
150/// A [`RepoOverride`]'s `exclude` with its common dir already resolved, held apart from
151/// [`ResolvedOverride`] because it re-applies live: `exclude` decides only whether an
152/// operation may reach a row that is discovered, probed and listed either way
153/// ([config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md)'s "listed,
154/// never operated on"), so [`Core::set_exclusions`] replaces it with no rebuild at all.
155#[derive(Debug, Clone)]
156struct ResolvedExclusion {
157    path: PathBuf,
158    common_dir: PathBuf,
159    excluded: bool,
160}
161
162/// The two path fields [`find_entry`]'s match rule reads, so the rule itself is written
163/// once for both halves a `[[repo]]` entry resolves into.
164trait 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
189/// Opens every override's own `path` to learn its common dir, once, and splits the result
190/// into the half frozen for this `Core`'s life and the half [`Core::set_exclusions`] can
191/// replace live. Silently drops an entry that will not even open: a path that matches no
192/// discovered entity already gets its own warning on the consumer's side
193/// ([config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md#cross-key-validity)),
194/// and the core raises no second one for the same fact.
195fn 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
216/// The entry that applies to an entity at `path` sharing `common_dir`: one naming
217/// `path` itself, or else the first declared entry sharing `common_dir`, which is
218/// what lets one entry cover a Repo and every Worktree attached to it while a
219/// Worktree named directly by its own path still beats the entry it would
220/// otherwise inherit ([config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md#per-repo-entries)).
221///
222/// A consequence worth stating rather than working around here: a Submodule's own
223/// common dir (`<parent common dir>/modules/<name>`, per `discovery.rs`'s
224/// `resolve`) is never equal to its parent's, so one entry naming the parent's path
225/// can never also exclude the parent's Submodules. The documented workaround is a
226/// Set's own `exclude` glob over the subtree, which keeps the Submodule out of
227/// discovery entirely rather than merely marking it excluded here.
228fn 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
243/// Whether an entity at `path` sharing `common_dir` is excluded by `exclusions`: the one
244/// place the table's own [`EntityState::excluded`] is derived, read at entity creation, at
245/// [`Core::probe_now`] and again whenever [`Core::set_exclusions`] replaces the list.
246fn 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
250/// Whether a Generation's dispatch probes `kind` at all: always for a Repo or a
251/// Worktree, only while `show_submodules` is on for a Submodule
252/// ([discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md)'s
253/// "Showing Submodules": "the flag decides... whether they are probed"). Exhaustive over
254/// `Kind`, so a fourth variant added later must be named here rather than silently
255/// falling into either branch.
256fn 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/// The periodic fetch's own crossing data
264/// ([config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md)'s
265/// `[fetch]` table): whether it runs at all, its cadence, and how many run at once.
266/// Plain bounding data, not the mutating mechanism itself: `enabled: false` here is
267/// what keeps the cycle from running, not the absence of any machinery to run it.
268#[derive(Debug, Clone)]
269pub struct FetchSpec {
270    pub enabled: bool,
271    pub interval: Duration,
272    pub concurrency: usize,
273}
274
275/// The fast-forward-only auto-update's own crossing data
276/// ([config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md)'s
277/// `[auto_update]` table): whether it runs at all.
278///
279/// Plain bounding data, not the mutating mechanism, for the same reason [`FetchSpec`]
280/// is. Carries no interval or concurrency of its own, because it never ticks on its
281/// own clock; it rides the periodic fetch cycle [`FetchSpec`] already schedules, so
282/// `enabled: true` here is inert while `FetchSpec::enabled` is false.
283#[derive(Debug, Clone, Copy)]
284pub struct AutoUpdateSpec {
285    pub enabled: bool,
286}
287
288/// The most recent periodic-fetch cycle's own failures, read fresh through
289/// [`Core::fetch_failures`] the same way [`Core::discovery_warning`] and
290/// [`Core::vanished_count`] are: never latched, so a cycle where every fetch
291/// succeeds leaves this empty rather than carrying a stale failure forward from an
292/// earlier one. Per-repository independence is unaffected: one entry here is one
293/// repository `run_fetch_cycle` could not reach, never a reason another repository's
294/// own fetch was skipped.
295///
296/// Carries the path and the underlying `FetchError`'s own text, for
297/// a consumer's log; a consumer's own screen-facing warning is expected to surface
298/// only `failed.len()`, the precedent `warnings.rs`'s `OnRefreshFailed` already sets
299/// for never putting remote-supplied text on screen.
300#[derive(Debug, Clone, Default, PartialEq, Eq)]
301pub struct FetchFailures {
302    pub failed: Vec<(PathBuf, String)>,
303}
304
305/// One [`Core::attempt_auto_update`] result on a single Repo: the fast-forward-only
306/// auto-update's own five eligibility rules
307/// ([config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md)'s
308/// eligibility rule), flattened into one return type so the built-in `sync` action
309/// ([repo-management.md](https://github.com/paulchiu/repon/blob/main/docs/spec/repo-management.md))
310/// can report every ineligible reason to the user rather than a bare "did nothing".
311#[derive(Debug, Clone, PartialEq, Eq)]
312pub enum AutoUpdateAttempt {
313    /// The working tree or index carried a change of its own.
314    NotClean,
315    /// No branch, no remote, or a branch with no upstream configured.
316    NoUpstream,
317    /// Already level with its upstream: nothing to move.
318    NotBehind,
319    /// The local branch has a commit its upstream does not, so no fast-forward exists.
320    NotFastForward,
321    /// The branch fast-forwarded to its upstream.
322    Updated,
323    /// A git read or write failed partway through.
324    Failed(String),
325}
326
327/// Everything `Core::start` needs, handed as plain data. The core reads no file, no
328/// path and no environment variable: this is the whole crossing, per
329/// `docs/spec/core-api.md`'s "What crosses from config".
330#[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    /// The initial reading of the show-submodules preference
338    /// ([discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md)'s
339    /// "Showing Submodules"): whether a dispatched Generation probes a Submodule at all.
340    /// Live-updatable afterwards through [`Core::set_show_submodules`], which is what lets
341    /// toggling it skip a Core rebuild and the rediscovery that would come with one.
342    pub show_submodules: bool,
343    /// The periodic fetch's own bounding data. See [`FetchSpec`].
344    pub fetch: FetchSpec,
345    /// The fast-forward-only auto-update's own bounding data. See [`AutoUpdateSpec`]:
346    /// inert while `fetch.enabled` is itself `false`
347    /// (`Warning::AutoUpdateWithoutFetch` is what tells a config author that
348    /// combination can never fire).
349    pub auto_update: AutoUpdateSpec,
350}
351
352/// One entity's in-flight probe: which Generation dispatched it, and the flag that
353/// generation owns to cancel it. [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)
354/// fixes one `Arc<AtomicBool>` per in-flight entity, passed as `should_interrupt`;
355/// `gix::interrupt::IS_INTERRUPTED` is a process-global static and is never used.
356struct InFlight {
357    generation: u64,
358    cancel: Arc<AtomicBool>,
359}
360
361/// The assembled entity table `Core` owns: the only place a Generation's per-cell
362/// supersession check happens, per ADR 0015.
363struct Table {
364    generation: u64,
365    discovered_at: Timestamp,
366    entities: Vec<EntityState>,
367    index: HashMap<EntityKey, usize>,
368    in_flight: HashMap<EntityKey, InFlight>,
369    /// When each still-live Generation's dispatch started, for the deadline sweep.
370    /// Pruned once nothing is left in flight for a Generation.
371    generation_started_at: HashMap<u64, Instant>,
372    /// Each entity's thread-safe repository handle, opened once by discovery.
373    /// A probe task clones the `Arc` (cheap, a refcount bump) and derives its own
374    /// `Repository` from it via `to_thread_local`, so no task ever shares a
375    /// `Repository` with another one; a missing entry (a Submodule, or a boundary
376    /// that would not open) falls back to opening fresh at probe time.
377    repos: HashMap<EntityKey, Arc<gix::ThreadSafeRepository>>,
378    /// Each entity's gitdir reading as of the previous metadata poll sweep, so the
379    /// next sweep can tell whether any of [`poll::POLLED_GITDIR_ENTRIES`] moved
380    /// ([refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
381    /// "The poll"). Absent for an entity the sweep has never yet reached, which is
382    /// what lets a first sweep record a baseline rather than reporting the whole
383    /// population as having just moved.
384    poll_fingerprints: HashMap<EntityKey, poll::GitdirFingerprint>,
385}
386
387/// A message the dedicated thread's control channel carries; distinct from a tick.
388enum ClockControl {
389    Pause,
390    Resume,
391    Shutdown,
392}
393
394/// A running core: its own table, its own dedicated thread, and the rayon pool it
395/// shares with the rest of the process for probes.
396///
397/// Construction is `start`, never a plain constructor, because it spawns; `Drop`
398/// joins every thread it spawned. The public entry points are exactly `start`,
399/// `refresh`, `probe_now`, `snapshot`, `try_settle`, `dismiss`, `pause`, `resume`,
400/// `discovery_warning` and `run_action` (see its own doc comment).
401pub struct Core {
402    table: Arc<RwLock<Table>>,
403    /// Resolved once at `start` and never mutated afterwards: `default_branch` is a probe
404    /// input, so moving it needs the rediscovery a rebuilt `Core` does
405    /// ([config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md#reload)).
406    overrides: Arc<Vec<ResolvedOverride>>,
407    /// The live `exclude` half of the same `[[repo]]` entries, replaced wholesale by
408    /// [`Core::set_exclusions`] with no rebuild and no rediscovery, the same shape
409    /// `show_submodules` already has: `exclude` decides only whether an operation may reach
410    /// a row that is discovered and listed either way, so it is an operate-time filter over
411    /// a table that is already correct
412    /// ([repo-management.md](https://github.com/paulchiu/repon/blob/main/docs/spec/repo-management.md)'s
413    /// "Writing config").
414    exclusions: Arc<RwLock<Vec<ResolvedExclusion>>>,
415    /// The Set `start` was given, retained so `refresh` can re-run discovery over
416    /// the same bounding specification at the head of every Generation
417    /// ([discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md),
418    /// [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)).
419    /// Immutable for the same reason `overrides` is: a Set's `roots` or globs
420    /// changing is a config reload, which re-derives a whole new `Core` rather
421    /// than mutating this one in place.
422    set: SetSpec,
423    /// Set once discovery abandons a walk, and never cleared for the life of this
424    /// `Core`: it takes the Set out of the automatic refresh path, since
425    /// re-running a thirty-second walk at the head of every Generation is not a
426    /// degraded mode worth paying for.
427    discovery_manual: Arc<AtomicBool>,
428    /// How long a re-run discovery walk may run before the still-walking warning
429    /// fires; real value is one second outside a test.
430    discovery_warn_after: Duration,
431    /// How long a re-run discovery walk may run before it is abandoned, in nanoseconds;
432    /// real value is [`discovery::ABANDON_AFTER`] outside a test. Shared and atomic so a
433    /// test can tighten it after `start`, rather than racing one deadline against both a
434    /// walk that must survive and a walk that must not.
435    discovery_abandon_after: Arc<AtomicU64>,
436    /// The live show-submodules preference a dispatched Generation reads: `true` once
437    /// [`Core::set_show_submodules`] last set it that way, `CoreSpec::show_submodules` until
438    /// then. Atomic and shared with every `RefreshHandles` clone so toggling it needs no
439    /// rebuild and dispatches nothing of its own
440    /// ([discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md)'s
441    /// "Showing Submodules": "toggling is instant, because nothing needs discovering").
442    show_submodules: Arc<AtomicBool>,
443    settle_gate: Arc<SettleGate>,
444    control: Sender<ClockControl>,
445    clock_thread: Option<JoinHandle<()>>,
446    /// Set by the dedicated thread's discovery-slow watcher if `start`'s one walk
447    /// ran a full second without finishing, and by a later re-run's own abandon
448    /// path. Read through [`Core::discovery_warning`], the UI's shared warning
449    /// slot's one entry point onto discovery, per
450    /// [discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md).
451    discovery_warning: Arc<Mutex<Option<String>>>,
452    /// Reset to zero at the start of every `refresh`, then incremented once per
453    /// distinct common dir among that Generation's dispatched entities whose
454    /// default-branch chain facts are actually computed, as opposed to reused from
455    /// another entity sharing the same common dir. Never persisted across
456    /// Generations, per [ADR 0006](https://github.com/paulchiu/repon/blob/main/docs/adr/0006-no-git-state-cache-session-state-by-name.md):
457    /// the memo cache itself lives only for the lifetime of one `refresh` call.
458    /// Read only by `default_branch_chain_reads_for_test`, which is what proves
459    /// [default-branch.md](https://github.com/paulchiu/repon/blob/main/docs/spec/default-branch.md)'s
460    /// per-common-dir memoisation actually ran rather than merely agreeing by
461    /// coincidence.
462    #[allow(dead_code)] // read only by default_branch_chain_reads_for_test
463    default_branch_chain_reads: Arc<AtomicUsize>,
464    /// The same counter as `default_branch_chain_reads`, for patch equivalence's
465    /// own expensive half ([`patch_equivalence::scan_default_branch`]) instead of
466    /// the default-branch chain's: reset to zero at the start of every `refresh`,
467    /// incremented once per distinct common dir whose default-branch commit
468    /// history is actually scanned, as opposed to reused from another entity
469    /// sharing the same common dir this Generation. Never persisted across
470    /// Generations, per [ADR 0006](https://github.com/paulchiu/repon/blob/main/docs/adr/0006-no-git-state-cache-session-state-by-name.md).
471    /// Read only by `patch_identity_reads_for_test`.
472    #[allow(dead_code)] // read only by patch_identity_reads_for_test
473    patch_identity_reads: Arc<AtomicUsize>,
474    /// The bound each actually-run [`patch_equivalence::scan_default_branch`] call
475    /// this Generation was passed, one entry per common dir it ran for, in the
476    /// order those scans ran; cleared at the start of every `refresh`. Recorded
477    /// from inside `patch_identities_for`'s `compute` closure, so this is the value
478    /// the production call site used, not a value a test recomputes independently.
479    /// Read only by `patch_scan_bounds_for_test`.
480    #[allow(dead_code)] // read only by patch_scan_bounds_for_test
481    patch_scan_bounds: Arc<Mutex<Vec<Option<gix::ObjectId>>>>,
482    /// Which Action run is admitted and what reaches its steps' children: see
483    /// [`ActionLifecycle`]. What [`Core::run_action`]'s own entry is guarded by, what
484    /// [`Core::action_running`] reads, and where [`Core::hold_action`],
485    /// [`Core::continue_action`] and [`Core::stop_action`] each find the control they
486    /// signal.
487    action_lifecycle: Arc<Mutex<ActionLifecycle>>,
488    /// Every key `refresh`'s own sequential dispatch loop iterated, in the order it iterated
489    /// them, cleared at the start of every call: this is dispatch order, not completion
490    /// order, recorded synchronously in the loop that decides it, before any `rayon::spawn`
491    /// closure ever runs. [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
492    /// "Scope and order" fixes dispatch order as the one dial phase C has; completion order
493    /// on a concurrent pool is a different, non-deterministic fact this field does not claim
494    /// to answer. Read only by `dispatch_log_for_test`.
495    #[allow(dead_code)] // read only by dispatch_log_for_test
496    dispatch_log: Arc<Mutex<Vec<EntityKey>>>,
497    /// Test-only synchronisation points, keyed by entity, letting a test hold the
498    /// dispatch loop's state and dirty probes open after that same entity's cheap
499    /// outcomes (branch, sync, default branch) have already landed on the table,
500    /// so [`refresh`]'s two applies can be proven independent with a blocking wait
501    /// rather than a sleep. Always present and normally empty: a Generation reads it
502    /// once per entity as it dispatches that entity, and one never registered here
503    /// resolves to nothing and proceeds exactly as if this field did not exist.
504    /// Registered and read only by the `_for_test` methods below.
505    #[allow(dead_code)] // populated and read only by tests
506    phase_c_gates: Arc<Mutex<HashMap<EntityKey, PhaseCGateHandle>>>,
507    /// The age past which a `Known` `dirty` or `state` cell reads Stale even though
508    /// nothing probed it again: `CoreSpec::status_stale_after`'s own copy, applied
509    /// inside [`Core::snapshot`] rather than by a background sweep, since
510    /// [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
511    /// "Staleness" rules out a global clock-driven one.
512    status_stale_after: Duration,
513    /// Every key the metadata poll's most recent sweep actually re-ran phases A
514    /// and B for, in the order it found them moved, cleared at the start of every
515    /// sweep. Read only by `poll_reprobed_for_test`, which is what proves a
516    /// sweep re-probes the moved entity alone rather than the whole population.
517    #[allow(dead_code)] // read only by poll_reprobed_for_test
518    poll_reprobed: Arc<Mutex<Vec<EntityKey>>>,
519    /// How many metadata-poll sweeps have run in total, whether or not any entity
520    /// had moved. Read only by `poll_sweep_count_for_test`, which is what proves a
521    /// real tick sent through the dedicated thread's own channel reaches the
522    /// sweep at all, distinct from `poll_reprobed` proving what a sweep that found
523    /// movement then did.
524    #[allow(dead_code)] // read only by poll_sweep_count_for_test
525    poll_sweep_count: Arc<AtomicUsize>,
526    /// How many periodic-fetch cycles have run in total, whether or not any
527    /// repository had a remote to fetch: the immediate first cycle plus one per
528    /// `fetch.interval` tick since. Read only by `fetch_cycle_count_for_test`,
529    /// which is what proves the immediate cycle ran without waiting on the
530    /// recurring cadence at all.
531    #[allow(dead_code)] // read only by fetch_cycle_count_for_test
532    fetch_cycle_count: Arc<AtomicUsize>,
533    /// The network's advertised default branch, per common dir, read from a fetch
534    /// handshake's own advertised HEAD alone
535    /// ([default-branch.md](https://github.com/paulchiu/repon/blob/main/docs/spec/default-branch.md)'s
536    /// "The network"): present only once the periodic fetch or
537    /// [`Core::rederive_default_branches`] has actually reached that remote.
538    /// Superseded there, never here on read; consulted by every default-branch
539    /// probe this crate runs, so an answer landed by one persists across every
540    /// later Generation for the life of this `Core`, which is what "supersedes
541    /// the local one for that session" means: never written back to any
542    /// reference, and gone the moment this `Core` is dropped, per ADR 0012.
543    network_default_branch: Arc<Mutex<HashMap<PathBuf, Arc<str>>>>,
544    /// The most recently completed periodic-fetch cycle's own failures, replaced
545    /// wholesale by [`run_fetch_cycle`] every time it runs. Read through
546    /// [`Core::fetch_failures`].
547    fetch_failures: Arc<Mutex<FetchFailures>>,
548    /// Orders every spawned dispatch body this `Core` starts; see
549    /// [`DispatchTurnstile`].
550    turnstile: Arc<DispatchTurnstile>,
551    /// See [`DiscoveryGate`]. `None` on every production path.
552    discovery_gate: Option<DiscoveryGate>,
553    /// See [`ActionCompletionBoundary`]. Disarmed unless a test arms it, and off the
554    /// default build entirely.
555    #[cfg(test)]
556    action_completion_boundary: Arc<ActionCompletionBoundary>,
557}
558
559/// One entity's phase C test gate state, guarded by the paired [`Condvar`] stored
560/// alongside it in [`Core::phase_c_gates`].
561#[derive(Default)]
562struct PhaseCGate {
563    /// Set once this entity's cheap outcomes have been applied to the table.
564    cheap_landed: bool,
565    /// Set by a test once it has observed `cheap_landed` and wants phase C (and
566    /// D) to proceed.
567    may_proceed: bool,
568    /// Set once this entity's phase C/D outcomes have been applied to the table
569    /// and the settle gate decremented for it.
570    finished: bool,
571}
572
573/// A [`PhaseCGate`] shared between the dispatch loop and the `_for_test` methods
574/// that register, wait on and release it.
575type PhaseCGateHandle = Arc<(Mutex<PhaseCGate>, Condvar)>;
576
577/// The Action lifecycle's one owned value: the run admitted right now, if any, together
578/// with the reach into that run's steps' children.
579///
580/// Admission and completion are each one transition on this one value, so a run's control
581/// arrives and leaves with its admission rather than through a second write a later run can
582/// land between. Every critical section here is a read or a single field write, so the lock
583/// is never held across a wait on a child process, across git, or across anything that can
584/// panic and poison it.
585#[derive(Default)]
586struct ActionLifecycle {
587    /// The admitted run's own reach into its steps' children, `None` between runs: what
588    /// [`Core::hold_action`], [`Core::continue_action`] and [`Core::stop_action`] each look
589    /// up before doing anything, so all three are no-ops with no fan-out live. Deliberately
590    /// its own value rather than folded into `pause`/`resume`'s machinery, per
591    /// [ADR 0018](https://github.com/paulchiu/repon/blob/main/docs/adr/0018-an-action-is-a-fanout-of-pty-backed-steps.md)'s
592    /// own hold and stop verbs: the core is contractually not told why background work
593    /// stopped, and a step's child needs SIGSTOP/SIGTERM/SIGKILL, information `pause` must
594    /// never carry.
595    live: Option<Arc<executor::RunControl>>,
596}
597
598impl ActionLifecycle {
599    /// Admits a run and registers its control together, or refuses because one is already
600    /// live: only one fan-out runs at a time, per
601    /// [ADR 0018](https://github.com/paulchiu/repon/blob/main/docs/adr/0018-an-action-is-a-fanout-of-pty-backed-steps.md)'s
602    /// "One Action runs at a time".
603    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    /// Releases the admitted run, the last thing [`RunCompletion`] does.
612    fn complete(&mut self) {
613        self.live = None;
614    }
615}
616
617/// Releases a finished run's admission however its fan-out thread ends, so a panic past the
618/// fan-out can never leave a `Core` reading that run as live for the rest of its life.
619///
620/// Dropped after the completion Generation has been dispatched, which is what orders the
621/// two: the next run is refused until this one has started the Generation it owes, so what
622/// that run cancels on the way in can never be a Generation the run it replaced has yet to
623/// dispatch.
624struct RunCompletion {
625    lifecycle: Arc<Mutex<ActionLifecycle>>,
626    /// See [`ActionCompletionBoundary`].
627    #[cfg(test)]
628    boundary: Arc<ActionCompletionBoundary>,
629}
630
631impl Drop for RunCompletion {
632    fn drop(&mut self) {
633        // Nothing parks here unless a test armed this boundary.
634        #[cfg(test)]
635        self.boundary.hold();
636        self.lifecycle.lock().unwrap().complete();
637    }
638}
639
640/// A park in the one statement between a completion dispatching its Generation and
641/// [`RunCompletion`] releasing the run, for a test.
642///
643/// Neither half of that ordering is observable from outside without holding the completion
644/// there: the two are adjacent statements, and a test racing them reads whichever it
645/// happened to catch. One per `Core` and disarmed until a test arms it, so a run nobody is
646/// watching reads one bool and carries on, and the whole affordance is gated off the
647/// default build.
648#[cfg(test)]
649#[derive(Default)]
650pub(crate) struct ActionCompletionBoundary {
651    state: Mutex<BoundaryState>,
652    changed: Condvar,
653}
654
655/// [`ActionCompletionBoundary`]'s own state, guarded by its `Condvar`.
656#[cfg(test)]
657#[derive(Default)]
658struct BoundaryState {
659    /// Set by a test before the run whose completion it wants held.
660    armed: bool,
661    /// Set by the completion that parked at an armed boundary.
662    reached: bool,
663    /// Set when the [`ArmedBoundary`] drops.
664    released: bool,
665}
666
667#[cfg(test)]
668impl ActionCompletionBoundary {
669    /// Holds the next completion to reach this boundary until the returned value drops. For
670    /// a test, before the run whose completion it wants held.
671    pub(crate) fn arm(self: &Arc<Self>) -> ArmedBoundary {
672        self.state.lock().unwrap().armed = true;
673        ArmedBoundary(Arc::clone(self))
674    }
675
676    /// Parks a completion here while an armed boundary holds it.
677    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/// One armed [`ActionCompletionBoundary`], released when this drops so an assertion failing
700/// inside the window reports itself rather than leaving a completion parked for
701/// [`liveness::BACKSTOP`].
702#[cfg(test)]
703pub(crate) struct ArmedBoundary(Arc<ActionCompletionBoundary>);
704
705#[cfg(test)]
706impl ArmedBoundary {
707    /// Blocks until a completion has parked at this boundary. For a test.
708    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    /// Spawns the dedicated thread, starts the first discovery walk on a thread of
738    /// its own, and returns a running core at once.
739    ///
740    /// The table it returns is empty: discovery lands its rows afterwards, which is what
741    /// lets a consumer claim the terminal and draw a first frame without waiting out a
742    /// walk (refresh.md's "The first frame"). That walk is refresh.md's "Startup"
743    /// Generation as well, dispatched over what it found, so a consumer probes its rows
744    /// by starting a `Core` and never by asking for a second walk of the same tree.
745    /// [`Self::try_settle`] waits for it the way it waits for any other Generation.
746    pub fn start(spec: CoreSpec) -> Core {
747        Self::start_watched(spec).core
748    }
749
750    /// [`Self::start`], keeping the handles `start_internal` hands back.
751    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    /// [`Self::start`], blocked until the first discovery has landed on the table.
776    ///
777    /// For a test, and for nothing else: `start` returns against an empty table
778    /// now, so a test that reads the table straight afterwards needs this
779    /// rendezvous. It is a join on the discovery thread rather than a poll or a
780    /// sleep, so it carries no deadline of its own.
781    ///
782    /// Gated behind `test-util` (on by default under `cfg(test)` for this crate's own
783    /// tests) so a test-only affordance never ships on the default published surface,
784    /// per [ADR 0021](https://github.com/paulchiu/repon/blob/main/docs/adr/0021-a-release-is-what-the-tag-pipeline-publishes.md).
785    #[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    /// Starts a new Generation, dispatching a probe for every key in `order` that
797    /// the table already knows, in that order. An empty or unknown-only `order`
798    /// dispatches nothing and carries no other meaning. Returns immediately: the
799    /// probes run on rayon's global pool.
800    pub fn refresh(&self, order: &[EntityKey]) -> Generation {
801        self.refresh_handles().dispatch(order)
802    }
803
804    /// Starts a new Generation over every entity this Generation's own discovery
805    /// leaves in the table, in discovery order.
806    ///
807    /// A Set switch's Generation, per
808    /// [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
809    /// "Switching Set": the caller has just discarded the old Set's rows, so it has no
810    /// order to compute and no keys to name. Unlike [`Self::refresh`], which resolves
811    /// the order the caller handed it, this resolves the order after discovery has run,
812    /// which is what lets it cover rows the caller could not have named. Startup needs
813    /// none of this: [`Self::start`]'s own walk is that Generation. Returns
814    /// immediately, the same way `refresh` does.
815    pub fn refresh_all(&self) -> Generation {
816        self.refresh_handles().dispatch_over_everything()
817    }
818
819    /// Re-derives `default_branch` alone for every key in `keys` already known to
820    /// the table, in a fresh Generation, per
821    /// [default-branch.md](https://github.com/paulchiu/repon/blob/main/docs/spec/default-branch.md)'s
822    /// "A user-triggered re-derive over the Selection ... on demand" and
823    /// [keybindings.md](https://github.com/paulchiu/repon/blob/main/docs/spec/keybindings.md)'s
824    /// `b`. Unlike [`Self::refresh`], this never re-runs discovery and never
825    /// touches any other cell on any entity, known or not: a key outside `keys`
826    /// is left exactly as it was, and so is every cell but `default_branch` on a
827    /// key inside it.
828    ///
829    /// Runs the local chain exactly as any other refresh would, then a
830    /// handshake-only network probe per distinct common dir among `keys`
831    /// (`fetch::probe_remote_head`): no pack requested and no ref updated, which is
832    /// "without fetching". Its answer, once landed on `network_default_branch`, is
833    /// what `supersede_with_network` applies here and on every later probe of that
834    /// common dir for the life of this `Core`.
835    ///
836    /// Returns immediately, which is also why a stalled remote has nothing to end
837    /// it here: the deadline sweep is per entity, not per cell, so this is on the
838    /// open-questions register rather than closed. The probes run on a plain thread, never rayon's
839    /// global pool, for the reason `fetch::run_bounded`'s own doc comment gives
840    /// the periodic fetch's identical choice: a remote blocked on the network
841    /// for seconds must never take a worker away from the pool every other
842    /// probe shares.
843    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            // Scoped to this one call, never shared with a concurrent `refresh`'s own
892            // memo: the local chain's own per-common-dir facts are cheap enough
893            // (`default-branch.md`'s "about 20ms") that a fresh cache here costs this
894            // call nothing a shared one would have saved.
895            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    /// Clones out every `Arc` a Generation's dispatch reads, plus the plain data
938    /// ([`SetSpec`], the two durations) it cannot share by reference: a handful of
939    /// refcount bumps, never a copy of the table itself. This is what lets
940    /// [`run_action`](Core::run_action)'s completion, which runs on a plain thread
941    /// this `Core` does not own and outlives the `&self` borrow that started it,
942    /// start the one normal Generation `docs/spec/actions.md`'s "Refreshing around a
943    /// run" promises through the exact same [`RefreshHandles::dispatch`] `refresh`
944    /// itself calls, rather than a second, drifting copy of its body.
945    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    /// Re-probes one entity synchronously against the table's current Generation,
969    /// which is what a Launcher return needs before a normal Generation starts.
970    /// Inserts a fresh entity for an unknown key rather than panicking, since a
971    /// caller can otherwise only reach this with a key `snapshot` just handed it.
972    pub fn probe_now(&self, key: &EntityKey) -> EntityState {
973        // An `Arc` rather than a bare flag: [`probe_status`] hands gix an owned clone of
974        // its cancel token the way `refresh`'s own dispatch does, and every other probe
975        // below still takes it as `&AtomicBool` through the same deref coercion.
976        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            // An unknown key has no entity yet to ask, and falls back to `false`,
985            // matching the fallback insert below: a freshly inserted `Kind::Repo`
986            // entity's `state` is `NotApplicable` from construction too, and its
987            // `base` is not (only a Submodule's is).
988            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            // Same fallback as `probes_state`/`probes_base`: an unknown key falls back to
999            // the `Kind::Repo` the insert below actually gives it.
1000            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            // A single synchronous re-probe shares nothing with any Generation's
1049            // dispatch, so a throwaway cache is exactly as much sharing as this
1050            // one call needs. Its bound gate has exactly one entity to hear
1051            // from: itself, so it never actually waits.
1052            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    /// Clones the whole table now, without waiting for anything in flight. Ages
1117    /// every entity's `dirty` and `state` cells into Stale here, on the clone
1118    /// rather than the stored table, so a snapshot stays a pure read: the other
1119    /// staleness writer, poll evidence, does mutate the stored table, because a
1120    /// detected move is itself a fact worth keeping, but elapsed time is not.
1121    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    /// Blocks until nothing is in flight or `within` elapses, then returns a snapshot.
1135    /// The machine-readable consumer's whole loop.
1136    ///
1137    /// `Ok` is a table that actually settled. `Err` is the wait giving up, carrying the
1138    /// snapshot as it stood at that moment so a caller that means to degrade still has
1139    /// something to degrade with. The two are separate arms rather than one return value
1140    /// because they are separate facts: a half-populated table read as a settled one is a
1141    /// wrong answer, not a late one, and it reads as a defect several steps downstream with
1142    /// nothing left naming the wait.
1143    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        // Released before the snapshot below, which takes the table lock: holding both at
1150        // once is a lock order nothing else in this file takes.
1151        drop(guard);
1152        let snapshot = self.snapshot();
1153        if timeout.timed_out() {
1154            Err(snapshot)
1155        } else {
1156            Ok(snapshot)
1157        }
1158    }
1159
1160    /// Blocks until nothing is in flight, panicking once [`liveness::BACKSTOP`] expires.
1161    /// For a test.
1162    ///
1163    /// Takes no deadline, unlike [`Self::try_settle`], because every deadline this ever
1164    /// took was a number guessed against the machine its author had: the wait is on a
1165    /// liveness property ("the Generation I just dispatched lands"), which carries no
1166    /// wall-clock bound of its own, so the only honest bound is the shared backstop.
1167    /// A wait whose *number* is the claim ("nothing arrives within 200ms") is a different
1168    /// wait and belongs on [`Self::try_settle`], which reports an expiry rather than
1169    /// panicking on one.
1170    #[cfg(any(test, feature = "test-util"))]
1171    pub fn settle(&self) -> Snapshot {
1172        self.settle_within(liveness::BACKSTOP)
1173    }
1174
1175    /// [`Self::settle`] against an explicit deadline, so this crate's own tests can
1176    /// exercise the expiry path without waiting out a real backstop. The same seam
1177    /// `liveness::wait_within` gives its module.
1178    #[cfg(any(test, feature = "test-util"))]
1179    fn settle_within(&self, deadline: Duration) -> Snapshot {
1180        self.try_settle(deadline).unwrap_or_else(|_| {
1181            // Read out and released before the panic below: unwinding out of a held guard
1182            // poisons the gate, and every later `lock().unwrap()` on it, `Drop`'s included,
1183            // then panics on the way out and turns a named report into an abort.
1184            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    /// What deleting `key`'s working tree destroys, read fresh right now
1197    /// ([repo-management.md](https://github.com/paulchiu/repon/blob/main/docs/spec/repo-management.md)'s
1198    /// "The confirm gate"). Every one of the three is a git read rather than a fold over this
1199    /// entity's Cells or over the table: the gate is answering "what will accepting this
1200    /// destroy", a Cell carries whatever the last Generation left there, and the table is
1201    /// bounded by the active Set's roots, so a linked Worktree outside them would go
1202    /// unnamed. Both are the wrong tense, or the wrong scope, for a question with no undo.
1203    ///
1204    /// `uncommitted` is both halves of "not in a commit": the index against the working tree
1205    /// (`git::dirty_counts`) and `HEAD` against the index (`git::staged_changes`). The
1206    /// second is the one a `git add` with no commit lands in, and the one the dirty column
1207    /// deliberately never asks about.
1208    ///
1209    /// Errors rather than reporting zero when any read fails, so a gate never says "nothing
1210    /// to lose" because it could not look.
1211    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    /// The administrative directory `git worktree remove` deletes for `key`'s own linked
1226    /// Worktree, read fresh right now. `Err` when `key`'s own path cannot even be opened as
1227    /// a git repository, which is what "the parent Repo is gone or unreadable" means for a
1228    /// `delete` on a Worktree row
1229    /// ([repo-management.md](https://github.com/paulchiu/repon/blob/main/docs/spec/repo-management.md)'s
1230    /// "What `delete` does to a Worktree"): the caller falls back to removing the working
1231    /// directory alone.
1232    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    /// Every linked Worktree's own working directory pointing into `key`'s Repo, read
1238    /// fresh right now: what deleting a Repo needs to also remove, since each linked
1239    /// Worktree's directory sits outside the Repo's own and is untouched by removing that
1240    /// alone
1241    /// ([repo-management.md](https://github.com/paulchiu/repon/blob/main/docs/spec/repo-management.md)'s
1242    /// "Deleting a Repo also takes its linked Worktrees with it").
1243    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    /// `delete`'s phase 1: the ignored directories inside the working tree at `path`, read
1249    /// fresh right now
1250    /// ([repo-management.md](https://github.com/paulchiu/repon/blob/main/docs/spec/repo-management.md)'s
1251    /// "Deleting a working tree"). `path` rather than an [`EntityKey`] because a Repo
1252    /// `delete` runs this once for its own working tree and once more for each linked
1253    /// Worktree [`Self::linked_worktree_paths`] names, and only the first of those has a Set
1254    /// row of its own.
1255    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    /// Attempts the fast-forward-only auto-update on `key`'s own Repo, on demand: exactly
1264    /// `crate::auto_update::attempt`'s own five rules and its own fast-forward, reused
1265    /// rather than a second implementation for the built-in `sync` action to call by hand
1266    /// ([repo-management.md](https://github.com/paulchiu/repon/blob/main/docs/spec/repo-management.md)).
1267    /// Read fresh right now, the same tense [`Self::delete_risk`] reads in: eligibility can
1268    /// change between the gate and the run, so this is never answered from a Cell.
1269    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    /// Runs `action`'s own steps against one Entity, on the calling thread, blocking until
1289    /// they finish rather than handing the run off the way [`Core::run_action`]'s async
1290    /// fan-out does. A pre or post hook wrapping a built-in ([0032](https://github.com/paulchiu/repon/blob/main/docs/adr/0032-hooks-around-a-built-in-fire-on-its-own-confirm-gate-never-its-completion.md))
1291    /// needs the outcome before the built-in can proceed or report, which nothing running
1292    /// off this thread can give in time. Reuses `run_action_for_entity`, the identical
1293    /// per-step execution `run_action`'s fan-out gives every entity, so a hook and a
1294    /// configured `[[action]]` never diverge in what a step means; writes nothing to the
1295    /// table and touches none of `run_action`'s own state (the one admitted run and its
1296    /// controls), since a hook is a distinct concern from the one fan-out the palette
1297    /// tracks.
1298    ///
1299    /// `None` when `key` names no Entity this table currently knows.
1300    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    /// Vends a [`ManagementHandle`]: the `Send + 'static` seam a management run's own
1315    /// per-row work moves onto a background thread through, so it stops blocking the caller
1316    /// the way [`Self::run_action`]'s own fan-out already moves an `Arc<RwLock<Table>>`
1317    /// clone onto its own thread
1318    /// ([0033](https://github.com/paulchiu/repon/blob/main/docs/adr/0033-a-management-run-moves-off-the-calling-thread-and-cancels-between-rows.md)).
1319    pub fn management_handle(&self) -> ManagementHandle {
1320        ManagementHandle {
1321            table: Arc::clone(&self.table),
1322        }
1323    }
1324
1325    /// Drops one entity from the table, cancelling any probe in flight against it.
1326    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    /// Resolves `order` against the table this instant and splits it into the entities
1345    /// that will actually run and the ones a matching `[[repo]]` `exclude = true`
1346    /// override sweeps in and skips
1347    /// ([config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md#per-repo-entries)).
1348    /// [`Self::run_action`] and [`Self::operable_count`] both call this rather than
1349    /// each keeping its own copy of the `!entity.excluded` test, so a consumer's confirm
1350    /// gate or palette border can never show a count a real run then contradicts
1351    /// ([`docs/spec/actions.md`](https://github.com/paulchiu/repon/blob/main/docs/spec/actions.md)'s
1352    /// "The Selection and the gate": "a wrong count would lie twice"). A key `order`
1353    /// names that no longer resolves (already dismissed, or never discovered) is
1354    /// silently dropped from both halves.
1355    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    /// How many of `order` are operable, i.e. not excluded: [`Self::run_action`]'s own
1364    /// first move is the identical partition this method itself calls, so this is the one
1365    /// number a confirm gate and a palette border can both read without either ever
1366    /// drifting from what that first move keeps. Not the final count a run acts on once
1367    /// `action.when` is `Some`: [`Self::applicability`] narrows this same set further, and
1368    /// [`Self::run_action`] itself only ever runs the rows that narrowing proves.
1369    pub fn operable_count(&self, order: &[EntityKey]) -> usize {
1370        self.partition_operable(order).0.len()
1371    }
1372
1373    /// How many Entities in the live table are Vanished. Reads the table in place rather
1374    /// than through [`Self::snapshot`], so a caller needing only the count does not pay for
1375    /// a clone of the whole table and its staleness pass on every frame.
1376    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    /// How an Action's `when` predicate divides the very rows [`Self::operable_count`]
1387    /// counts: the identical partition runs first, so an excluded row is subtracted before
1388    /// the predicate ever sees it and `when` narrows what is left rather than replacing that
1389    /// subtraction
1390    /// ([`docs/spec/actions.md`](https://github.com/paulchiu/repon/blob/main/docs/spec/actions.md)'s
1391    /// "The Selection and the gate"). A palette calls this ahead of time, to report a count
1392    /// before a choice is even made; [`Self::run_action`] runs the identical classification
1393    /// against the identical rows once a choice is confirmed, over `ActionSpec::when` rather
1394    /// than an argument of its own, so a preview and a real run can never disagree.
1395    ///
1396    /// The tally lives here rather than in the consumer for that reason alone:
1397    /// `partition_operable` is this type's own, so a caller cannot count applicability over
1398    /// a set the run would not act on.
1399    pub fn applicability(&self, order: &[EntityKey], when: &Filter) -> Applicability {
1400        when.applicability(self.partition_operable(order).0.iter())
1401    }
1402
1403    /// `true` from an Action run's admission until its completion has dispatched the
1404    /// Generation it owes, the consumer-facing read of the one admitted run
1405    /// ([ADR 0018](https://github.com/paulchiu/repon/blob/main/docs/adr/0018-an-action-is-a-fanout-of-pty-backed-steps.md)'s
1406    /// "One Action runs at a time"): what a TUI gates `;`, `s`, `1` to `9` and `Ctrl+R`
1407    /// against while a run is in flight
1408    /// ([ADR 0023](https://github.com/paulchiu/repon/blob/main/docs/adr/0023-an-unbuilt-binding-is-not-advertised-and-an-unavailable-one-answers-on-press.md)).
1409    pub fn action_running(&self) -> bool {
1410        self.action_lifecycle.lock().unwrap().live.is_some()
1411    }
1412
1413    /// `true` while any refresh-shaped dispatch this `Core` started still owes the table
1414    /// work: a Generation reserved and not yet raised the probes it dispatches, or probes
1415    /// raised and not yet landed, cancelled or timed out. The same gate [`Core::try_settle`]
1416    /// blocks on, read here without blocking, so a consumer can report a Refresh's own
1417    /// progress on screen while it runs rather than waiting for it to finish
1418    /// ([refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)).
1419    /// Covers `refresh`, `refresh_all`, `rederive_default_branches`, `probe_now` and the
1420    /// startup walk alike; an Action's own fan-out never touches this gate, which is what
1421    /// `action_running` reads instead.
1422    pub fn refresh_running(&self) -> bool {
1423        let (lock, _cvar) = &*self.settle_gate;
1424        !lock.lock().unwrap().is_settled()
1425    }
1426
1427    /// Runs `action` across every key in `order` that the table currently knows: each
1428    /// entity's own steps run in order and stop at that entity's first failure, exactly
1429    /// as [config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md)'s
1430    /// "Actions" fixes, with later steps recorded `NotRun` rather than silently skipped.
1431    /// Cross-entity concurrency is bounded by `action.concurrency`, on a
1432    /// `rayon::ThreadPool` this call builds and owns for the run alone, never rayon's
1433    /// global pool the probe fan-out shares: a step blocked in `wait()` removes a
1434    /// worker from whichever pool holds it, and the global pool has none to spare
1435    /// without starving a refresh in flight
1436    /// ([`docs/spec/actions.md`](https://github.com/paulchiu/repon/blob/main/docs/spec/actions.md)'s
1437    /// "The fan-out"). Returns immediately; every step's own child, and this run's
1438    /// completion, run off the calling thread.
1439    ///
1440    /// Returns `false` and touches nothing if a fan-out is already running: only one
1441    /// runs at a time
1442    /// ([ADR 0018](https://github.com/paulchiu/repon/blob/main/docs/adr/0018-an-action-is-a-fanout-of-pty-backed-steps.md)'s
1443    /// "One Action runs at a time"), but the spec settles only that the *palette* goes
1444    /// inert while one is live, never what a second, concurrent call to this seam itself
1445    /// should do. Rejecting outright, rather than queuing, is this call's own choice: a
1446    /// queue needs its own ordering and cancellation story that no acceptance criterion
1447    /// here asks for.
1448    ///
1449    /// An entity in `order` carrying a matching `[[repo]]` `exclude = true`
1450    /// ([config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md#per-repo-entries))
1451    /// never runs a step: it receives a [`Skip::Excluded`] receipt with an empty step list
1452    /// immediately, the one legitimate producer of `Not applicable`
1453    /// ([`docs/spec/actions.md`](https://github.com/paulchiu/repon/blob/main/docs/spec/actions.md)'s
1454    /// "The Selection and the gate"). An unknown key in `order` (already dismissed, or
1455    /// never discovered) is silently skipped, the same fallback `refresh` gives one.
1456    ///
1457    /// `action.when`, once every excluded row is already subtracted, decides what runs
1458    /// rather than only what a palette reported about it: a row it proves is handed a
1459    /// step, a row it disproves gets a [`Skip::Inapplicable`] receipt instead, and a row it
1460    /// cannot settle (a Cell it reads has not settled) gets [`Skip::Unresolved`], since an
1461    /// unprovable row is not a provable one and a run has no basis to touch it either
1462    /// (`docs/spec/actions.md`'s "The Selection and the gate", reversing what that
1463    /// paragraph originally decided). `None` runs every operable row, exactly as before
1464    /// `when` reached this call.
1465    ///
1466    /// Starting a run cancels any in-flight Generation outright rather than sharing
1467    /// execution with it, and completion starts exactly one normal Generation over
1468    /// every entity the table currently knows, not only the ones this run touched.
1469    /// Explicitly not done, for the same reason: re-probing each affected entity
1470    /// synchronously first, the way a Launcher return does with [`Core::probe_now`].
1471    /// Both choices, and their measured cost, are
1472    /// [ADR 0018](https://github.com/paulchiu/repon/blob/main/docs/adr/0018-an-action-is-a-fanout-of-pty-backed-steps.md)'s
1473    /// ("Refreshing around a run").
1474    pub fn run_action(&self, action: ActionSpec, order: &[EntityKey]) -> bool {
1475        // Built before admission rather than after it, so the run a caller is told started
1476        // is admitted with its own reach into its children already attached: a
1477        // `stop_action` the instant this returns `true` can never find no control, and a
1478        // completion racing in can never find someone else's.
1479        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        // Criterion 3's first half: starting a run cancels any in-flight Generation
1490        // outright, never sharing the machine with it.
1491        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        // At least one worker regardless of what `action.concurrency` says: 0 has no
1536        // sensible reading as "run nothing" here (the schema has no floor, only an
1537        // explicit absence of a *ceiling*, `docs/spec/actions.md`'s "The fan-out"), and
1538        // `rayon::ThreadPoolBuilder::num_threads(0)` means "let rayon choose" rather
1539        // than zero workers, which would silently hand this run back to a pool sized by
1540        // something other than `concurrency`.
1541        let concurrency = action.concurrency.max(1) as usize;
1542
1543        // A plain OS thread, never a job on either rayon pool: `RefreshHandles::dispatch`
1544        // below calls `rayon::spawn`, which targets whichever pool the *calling* thread
1545        // already belongs to, so running this orchestration from inside the dedicated
1546        // pool built below would misroute the completion Generation's own probes onto
1547        // it instead of the global pool every other probe uses.
1548        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            // Caught rather than left to unwind straight out of this thread: a poisoned
1555            // `RwLock` from an unrelated earlier panic is enough to panic the
1556            // `table_handle.write().unwrap()` below, and without `catch_unwind` that
1557            // would unwind past the `RunCompletion` just beyond it before that guard
1558            // exists, leaving this `Core` reading its run as live for the rest of its life.
1559            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            // scan: action-completion-path begin -- criterion 4: nothing from here to the
1576            // matching end marker below may re-probe an affected entity synchronously the
1577            // way a Launcher return does with `probe_now`; scoped this narrowly (rather
1578            // than a whole-crate scan) because a legitimate Launcher-return caller lives
1579            // in an unrelated call site the same absence claim must not forbid.
1580            // Criterion 6: the fan-out's own steps are over here, panic or not, and this
1581            // run stays admitted only until `completion` drops one statement past the
1582            // Generation below. `hold_action`, `continue_action` and `stop_action` are
1583            // no-ops again from that point, and a second `run_action` before it is refused
1584            // rather than left to race the Generation this run still owes.
1585            let completion = RunCompletion {
1586                lifecycle: action_lifecycle,
1587                #[cfg(test)]
1588                boundary: completion_boundary,
1589            };
1590
1591            // A panicked fan-out never finished cleanly, so it earns no completion
1592            // Generation. Swallowed rather than resumed: the default panic hook already
1593            // printed it to stderr before `catch_unwind` returned, and this crate carries
1594            // no logger to hand it to instead.
1595            let Ok(()) = fan_out else {
1596                return;
1597            };
1598
1599            // Criterion 3's second half: completion starts one normal Generation over
1600            // every entity currently known, not only the ones this run acted on.
1601            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            // scan: action-completion-path end
1611        });
1612
1613        true
1614    }
1615
1616    /// SIGSTOPs every currently live step's process group in the fan-out `run_action`
1617    /// started, reversible with [`Self::continue_action`]: suspending a run is reversible,
1618    /// where cancelling one is not
1619    /// ([`docs/spec/actions.md`](https://github.com/paulchiu/repon/blob/main/docs/spec/actions.md)'s
1620    /// "Cancellation and quit"). A no-op while no fan-out is running. Its own verb,
1621    /// kept apart from [`Self::pause`], which stays ignorant of why background work stopped.
1622    pub fn hold_action(&self) {
1623        if let Some(control) = self.live_action_control() {
1624            control.hold();
1625        }
1626    }
1627
1628    /// SIGCONTs every currently live step's process group, undoing [`Self::hold_action`]. A
1629    /// no-op while no fan-out is running.
1630    pub fn continue_action(&self) {
1631        if let Some(control) = self.live_action_control() {
1632            control.continue_run();
1633        }
1634    }
1635
1636    /// Cancels the fan-out `run_action` started: SIGTERM now to every step's process group
1637    /// still live, SIGKILL after a grace to whichever of those have not exited by then,
1638    /// because SIGTERM is trappable and SIGKILL is not. A step already running when this is
1639    /// called becomes `Cancelled`; so does a step, or a whole entity's run, that had not
1640    /// started, which stays distinct from `NotRun`
1641    /// ([`docs/spec/actions.md`](https://github.com/paulchiu/repon/blob/main/docs/spec/actions.md)'s
1642    /// "Cancellation and quit"). A no-op while no fan-out is running. Its own verb,
1643    /// kept apart from [`Self::pause`] for the same reason [`Self::hold_action`] is.
1644    pub fn stop_action(&self) {
1645        if let Some(control) = self.live_action_control() {
1646            control.cancel();
1647        }
1648    }
1649
1650    /// The live run's reach into its steps' children, cloned out so the three verbs above
1651    /// signal a process group with [`Core::action_lifecycle`]'s lock already released.
1652    /// `None` with no fan-out live, which is what makes each of them a no-op then.
1653    fn live_action_control(&self) -> Option<Arc<executor::RunControl>> {
1654        self.action_lifecycle.lock().unwrap().live.clone()
1655    }
1656
1657    /// Stops all background work: the dedicated thread stops ticking and every
1658    /// probe currently in flight is cancelled. The core is never told why.
1659    pub fn pause(&self) {
1660        let _ = self.control.send(ClockControl::Pause);
1661    }
1662
1663    /// Restarts the dedicated thread's ticking. Nothing is queued to fire on
1664    /// resume; a normal Generation is the consumer's decision, not this call's.
1665    pub fn resume(&self) {
1666        let _ = self.control.send(ClockControl::Resume);
1667    }
1668
1669    /// The persistent warning a re-run discovery walk leaves behind once it abandons, or
1670    /// `None` while none has. Never cleared once set, the same as `discovery_manual`: the
1671    /// Set stays out of the automatic refresh path for the life of this `Core`. The UI's
1672    /// shared warning slot polls this every frame, since it can turn from `None` to `Some`
1673    /// at any point in the run with no reload involved.
1674    pub fn discovery_warning(&self) -> Option<String> {
1675        self.discovery_warning.lock().unwrap().clone()
1676    }
1677
1678    /// The most recently completed periodic-fetch cycle's own failures, or an
1679    /// empty [`FetchFailures`] once every fetch in that cycle succeeded, or the
1680    /// cycle has never run. The UI's shared warning slot polls this every frame,
1681    /// the same way it polls [`Self::discovery_warning`] and
1682    /// [`Self::vanished_count`], since a later cycle can replace this at any point
1683    /// in the run with no reload involved.
1684    pub fn fetch_failures(&self) -> FetchFailures {
1685        self.fetch_failures.lock().unwrap().clone()
1686    }
1687
1688    /// Sets the live show-submodules preference a Generation's dispatch reads from this
1689    /// point on: whether a Kind::Submodule entity is probed at all
1690    /// ([discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md)'s
1691    /// "Showing Submodules"). Takes effect on the next `refresh`, dispatches nothing of its
1692    /// own and starts no Generation, which is what makes toggling this instant rather than a
1693    /// rebuild: `CoreSpec`'s own `show_submodules` is only this flag's starting value.
1694    pub fn set_show_submodules(&self, show_submodules: bool) {
1695        self.show_submodules
1696            .store(show_submodules, Ordering::Release);
1697    }
1698
1699    /// Writes one receipt per row for work Repon did itself, with no child process anywhere
1700    /// in it: what a Management operation leaves behind
1701    /// ([repo-management.md](https://github.com/paulchiu/repon/blob/main/docs/spec/repo-management.md)'s
1702    /// "Receipts", [`docs/spec/actions.md`](https://github.com/paulchiu/repon/blob/main/docs/spec/actions.md)'s
1703    /// `OwnWork`).
1704    ///
1705    /// The receipt is built here rather than handed in whole, so a consumer supplies only
1706    /// what Repon did and the words for it: `skip` stays `None`, since a refusal is a row
1707    /// that was operated on rather than one of the three ways a row is skipped, `running`
1708    /// stays `None`, since the work is already done, and the step count stays one, since the
1709    /// operation is one act rather than an ordered list.
1710    /// `label` is the operation's own name and doubles as the single step's label; the step's
1711    /// captured output is empty, there being no other program's screen to quote.
1712    ///
1713    /// Starts no Generation and dispatches nothing, for the same reason
1714    /// [`Core::set_exclusions`] does not: a receipt is something Repon did rather than a
1715    /// reading of the world, so nothing here can make a cell any more or less true. A key the
1716    /// table no longer holds is skipped, the same fallback every key-addressed entry point
1717    /// here gives one.
1718    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    /// Replaces the live `exclude` half of `[[repo]]` and re-applies it over every row the
1745    /// table already holds, so the next [`Core::snapshot`] answers with the new reading
1746    /// ([repo-management.md](https://github.com/paulchiu/repon/blob/main/docs/spec/repo-management.md)'s
1747    /// "Writing config": an `ignore` takes effect as soon as this call returns).
1748    ///
1749    /// Starts no Generation, dispatches nothing and rediscovers nothing, for the same reason
1750    /// [`Core::set_show_submodules`] does not: `exclude` decides only whether an operation
1751    /// may reach a row, never what discovery finds or what a probe reads. `default_branch`,
1752    /// the other key a `[[repo]]` entry may carry, is a probe input and is deliberately not
1753    /// moved here; it still needs a rebuilt `Core`.
1754    pub fn set_exclusions(&self, overrides: &[RepoOverride]) {
1755        let (_, resolved) = resolve_entries(overrides);
1756        // Written and released before the table lock is taken, never held across it:
1757        // `rerun_discovery` reads these two in the opposite order.
1758        {
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/// The read-only, path-driven operations a management run's per-row work needs
1770/// (`crates/repon/src/management.rs`'s own `run_one_record`), cloned out of
1771/// [`Core::management_handle`] rather than borrowed from a live `Core`: `Send + 'static`, so
1772/// a caller can move it onto a background thread the way [`Core::run_action`]'s own fan-out
1773/// thread already moves its `Arc<RwLock<Table>>` clone there. Grants none of `Core`'s other
1774/// state (the one admitted Action run and its controls, the clock thread): a management run
1775/// is a distinct concern from the one fan-out those track, and this handle's own methods touch
1776/// only the table, exactly as [`Core::run_action_for_entity_blocking`] already does.
1777#[derive(Clone)]
1778pub struct ManagementHandle {
1779    table: Arc<RwLock<Table>>,
1780}
1781
1782impl ManagementHandle {
1783    /// Identical to [`Core::worktree_admin_dir`], against this handle's own table clone.
1784    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    /// Identical to [`Core::linked_worktree_paths`], against this handle's own table clone.
1790    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    /// Identical to [`Core::ignored_directories_for_deletion`], against this handle's own
1796    /// table clone.
1797    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    /// Identical to [`Core::attempt_auto_update`].
1806    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    /// Identical to [`Core::run_action_for_entity_blocking`], against this handle's own
1826    /// table clone rather than a live `Core`.
1827    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/// Every `Arc` and plain-data field a Generation's dispatch reads, owned rather than
1843/// borrowed: [`Core::refresh_handles`] is the only constructor once a `Core` exists,
1844/// and its own doc comment carries the reason this exists at all. `start_internal`
1845/// builds one directly, since the periodic fetch's own completion Generation needs
1846/// this before there is a `Core` to ask; `Clone` is what lets that one value serve
1847/// both the recurring cadence and the immediate first cycle without a second,
1848/// drifting construction. Field names and types mirror `Core`'s own exactly, so
1849/// [`Self::dispatch`] and [`Self::rerun_discovery`] are `refresh` and
1850/// `rerun_discovery`'s bodies moved verbatim, `self.field` unchanged.
1851#[derive(Clone)]
1852struct RefreshHandles {
1853    table: Arc<RwLock<Table>>,
1854    overrides: Arc<Vec<ResolvedOverride>>,
1855    /// [`Core::exclusions`]'s own clone, so a re-run discovery's newly found rows take
1856    /// whatever `exclude` says right now rather than whatever it said at `start`.
1857    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    /// [`Core::network_default_branch`]'s own clone: [`run_fetch_cycle`] writes
1871    /// into it once a fetch's own handshake advertises a HEAD, and this
1872    /// dispatch's own default-branch probes read it back the same Generation.
1873    network_default_branch: Arc<Mutex<HashMap<PathBuf, Arc<str>>>>,
1874    /// [`Core::turnstile`]'s own clone, so every dispatch this `Core` starts,
1875    /// wherever it is called from, queues in the one order.
1876    turnstile: Arc<DispatchTurnstile>,
1877    /// [`Core::discovery_gate`]'s own clone; `None` on every production path.
1878    discovery_gate: Option<DiscoveryGate>,
1879}
1880
1881/// Runs the spawned dispatch bodies in the order their Generations were reserved.
1882///
1883/// Reserving the number is what a caller waits for; everything after it happens on
1884/// a thread of its own, and two of those threads reaching the table out of order
1885/// would let an older Generation cancel a newer one's in-flight entries and then
1886/// record itself as the live one, which is refresh.md's supersession rule read
1887/// backwards. A ticket taken under the same lock that mints the Generation, and
1888/// served in ticket order, is what stops that.
1889#[derive(Default)]
1890struct DispatchTurnstile {
1891    /// The ticket whose body may run, and the [`Condvar`] every waiting body sleeps on.
1892    serving: Mutex<u64>,
1893    ready: Condvar,
1894    /// The next ticket to hand out. Only ever read under the table write lock
1895    /// [`RefreshHandles::reserve_generation`] holds, so tickets and Generations
1896    /// are issued in the one order.
1897    next: AtomicU64,
1898}
1899
1900impl DispatchTurnstile {
1901    fn reserve(&self) -> u64 {
1902        self.next.fetch_add(1, Ordering::AcqRel)
1903    }
1904
1905    /// Blocks until `ticket` is the one being served. The returned guard releases
1906    /// the next ticket when it drops, panic included, so one body that unwinds
1907    /// cannot wedge every dispatch after it.
1908    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
1922/// One body's turn at the [`DispatchTurnstile`], held for as long as that body runs.
1923struct 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    /// `Core::refresh`'s whole body, moved here so `run_action`'s completion can call
1938    /// the identical dispatch from a thread that owns no reference to `Core` itself.
1939    ///
1940    /// Reserves this Generation's number and its turnstile place on the calling
1941    /// thread and does everything else, discovery's own walk included, on a thread
1942    /// of its own, the shape [`Core::rederive_default_branches`] already takes: no
1943    /// caller waits out a walk, and every one of them is fire and forget past the
1944    /// number this returns.
1945    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    /// [`Core::refresh_all`]'s whole body: the same reservation and the same spawned
1959    /// shape as [`Self::dispatch`], with the order read off the table this
1960    /// Generation's own discovery just reconciled rather than taken from a caller.
1961    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    /// Takes this Generation's number and its turnstile ticket under one hold of
1983    /// the table lock, so the two orders can never disagree.
1984    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    /// [`Self::dispatch`]'s spawned body: both halves of discovery, then the probe
1991    /// fan-out for `order`.
1992    fn run_generation(&self, order: &[EntityKey], generation: Generation) {
1993        self.rediscover();
1994        self.dispatch_probes(order, generation);
1995    }
1996
1997    /// Both halves of discovery at the head of one Generation, per refresh.md and
1998    /// discovery.md: an entity no longer found becomes Vanished, and one found again
1999    /// (new, or previously Vanished) is Present. Skipped once an earlier walk has
2000    /// abandoned, which takes the Set out of this automatic path until a fresh `Core`
2001    /// starts over different roots.
2002    fn rediscover(&self) {
2003        if !self.discovery_manual.load(Ordering::Acquire) {
2004            self.rerun_discovery();
2005        }
2006    }
2007
2008    /// The probe fan-out alone, against the table as it stands: one rayon task per
2009    /// dispatched entity, exactly as before this Generation's discovery moved off
2010    /// the calling thread. Split out from [`Self::run_generation`] so
2011    /// [`Self::dispatch_over_everything`], which has to resolve its order between the walk
2012    /// and the fan-out, shares this body rather than keeping a second copy of it.
2013    fn dispatch_probes(&self, order: &[EntityKey], generation: Generation) {
2014        // Scoped to this one Generation, per default-branch.md's "memoised per
2015        // common dir within a single refresh generation": a fresh cache every
2016        // call, never carried over, never touched by the previous Generation's
2017        // still-finishing tasks holding their own clone of the old one.
2018        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                // Narrows the work, not merely the view: a hidden Submodule's Cells are
2037                // left exactly as this Generation found them, so a normal Generation pays
2038                // nothing for it (`docs/spec/discovery.md`'s "Showing Submodules").
2039                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        // Scoped to this dispatch alone: every task below gets its own clone of
2101        // this `Arc`, and once they all finish and drop it, the cache and every
2102        // `ChainFacts` it holds are freed. Nothing here outlives one Generation.
2103        let chain_cache: Arc<ChainFactsCache> = Arc::new(Mutex::new(HashMap::new()));
2104        // Same lifetime as `chain_cache`, one dispatch's worth: patch
2105        // equivalence's own per-common-dir memo, per default-branch.md's "Two
2106        // passes on screen".
2107        let patch_cache: Arc<PatchIdentityCache> = Arc::new(Mutex::new(HashMap::new()));
2108        // One gate per common dir with at least one entity that will run
2109        // `landing::probe` this Generation, sized up front so it is known
2110        // exactly how many entities owe it a report before any of them run;
2111        // see `BoundGate`.
2112        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            // Recorded here, in this loop's own sequential iteration, rather than in the
2145            // one above: this is the loop whose order a future change (a sort by predicted
2146            // cost, say) would actually be tempted to touch, since it is the one that decides
2147            // each entity's `rayon::spawn` call, not merely which entities were dispatched.
2148            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            // Resolved once here and moved into the task, which holds no handle on the
2159            // map itself: a probe signals the gate its own Generation was dispatched
2160            // against, so one still running from an earlier Generation can never signal a
2161            // gate registered after that Generation dispatched.
2162            let held_gate = self.phase_c_gates.lock().unwrap().get(&key).cloned();
2163            // scan: probe-fanout-pool begin -- rayon's global pool, not a dedicated one:
2164            // docs/adr/0013's sweep found the width a dedicated pool would need to pick is
2165            // a broad plateau that the global pool's own free default already sits inside
2166            // at every corpus size tried, and is the only width that stayed competitive
2167            // across idle, fetch-sized and Action-sized concurrent load. A dedicated pool
2168            // would cost a second idle thread pool's worth of memory and startup time to
2169            // land somewhere this measurement found no better than free.
2170            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                // Phases A and B land the moment they answer, per refresh.md's "The
2207                // first frame": every cheap column filled within 200ms, never gated
2208                // on phase C or D's much slower answers below. `default_branch_outcome`
2209                // is cloned here rather than moved, since phase D's landing probe
2210                // below still needs to read it.
2211                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                // Test-only: let a test hold phase C and D open here, after the cheap
2224                // outcomes above are already visible on the table, so the two applies'
2225                // independence can be proven by blocking on a Condvar rather than by racing
2226                // a sleep against a probe.
2227                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                // The same handle the cheap gate above blocked on, never a second lookup:
2271                // see where it is resolved.
2272                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            // scan: probe-fanout-pool end
2280        }
2281    }
2282
2283    /// Re-runs both halves of discovery over `self.set` and reconciles the
2284    /// result into the live table, per [discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md)
2285    /// and [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md).
2286    /// The walk and resolve run outside the table lock, since an abandoned walk
2287    /// can take up to thirty seconds; only reconciling the result briefly holds
2288    /// the write lock. Already-known boundaries reuse their cached repository
2289    /// handle rather than reopening it, which is what keeps re-running discovery
2290    /// every Generation from paying every entity's open cost again.
2291    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        // The watcher is left detached, as it always has been here: nothing on this
2297        // path reads its handle.
2298        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        // Copied out before the table lock is taken, never read through it: `set_exclusions`
2317        // takes these two locks in the opposite order, and holding one while asking for the
2318        // other is what would let the two deadlock.
2319        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    /// Cancels whatever this `Core` still has in flight, then joins the dedicated thread.
2332    ///
2333    /// The cancel is what [`Core::pause`] already does, for the same reason
2334    /// [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
2335    /// "Cancellation" gives: an abandoned Generation is cancelled rather than left to
2336    /// finish, since a Set switch rebuilds the `Core` and the outgoing one's fan-out
2337    /// would otherwise contend for the same cores as the incoming one's. A probe already
2338    /// past its own cancel check still runs to completion on rayon's global pool, which
2339    /// is shared process-wide infrastructure rather than a thread this core spawned, so
2340    /// it is not joined here.
2341    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
2350/// `start_internal`'s result: the running core, plus the three handles a test needs
2351/// to make its threading deterministic instead of sleeping. `Core::start` only
2352/// ever reads `core` out of it; the other three fields exist for
2353/// `Core::start_for_test`.
2354pub(crate) struct StartForTest {
2355    pub core: Core,
2356    #[allow(dead_code)] // read only by tests; the plain lib target never builds them
2357    pub clock_alive: Arc<AtomicBool>,
2358    #[allow(dead_code)] // read only by tests; the plain lib target never builds them
2359    pub discovery_watcher: JoinHandle<()>,
2360    /// The thread the first discovery runs on. Joining it is the rendezvous that
2361    /// says the walk finished and its rows reached the table, with no sleep and no
2362    /// poll anywhere in the wait.
2363    #[allow(dead_code)] // read only by tests; the plain lib target never builds them
2364    pub initial_discovery: Option<JoinHandle<()>>,
2365}
2366
2367#[cfg(test)]
2368impl StartForTest {
2369    /// Blocks until the first discovery has landed on the table, then hands this
2370    /// back so a test reads a populated table rather than the empty one `start`
2371    /// itself returns.
2372    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    /// Puts one already-known entity into the in-flight state a real `refresh`
2384    /// dispatch would, without spawning anything to complete it, so a test can
2385    /// drive the deadline sweep through the tick channel alone and prove the sweep
2386    /// runs on a tick rather than on a clock of its own, or prove that `pause`
2387    /// cancels a real in-flight entry from outside this crate.
2388    ///
2389    /// Gated behind `test-util` (on by default under `cfg(test)` for this crate's own
2390    /// tests) so a test-only affordance never ships on the default published surface,
2391    /// per [ADR 0021](https://github.com/paulchiu/repon/blob/main/docs/adr/0021-a-release-is-what-the-tag-pipeline-publishes.md),
2392    /// the same reason `Timestamp::at` is gated.
2393    #[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/// One simulated in-flight Generation, as [`Core::begin_shared_generation_for_test`]
2418/// left it: the Generation itself, and one interrupt flag per key it covers.
2419#[cfg(test)]
2420pub(crate) struct SharedGeneration {
2421    /// The Generation this simulation minted, so a test can name it and its successor
2422    /// rather than the counter values they happen to hold.
2423    pub generation: Generation,
2424    /// One `cancel` flag per covered key, the same handle a real dispatch would hold.
2425    pub cancels: HashMap<EntityKey, Arc<AtomicBool>>,
2426}
2427
2428#[cfg(test)]
2429impl Core {
2430    /// The cached thread-safe repository handle discovery left for `key`, if any,
2431    /// so a test can prove the cache was actually populated and, by comparing
2432    /// `Arc::ptr_eq` across two reads, that a probe reused it rather than
2433    /// replacing it with a freshly opened one.
2434    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    /// How many times the most recent `refresh` actually computed the
2442    /// default-branch chain's per-common-dir facts, as opposed to reusing an
2443    /// already-computed answer for a common dir another dispatched entity already
2444    /// paid for. What proves the per-common-dir memoisation ran at all: two
2445    /// entities agreeing on their resolved default branch proves nothing on its
2446    /// own, since two distinct common dirs can legitimately agree too.
2447    pub(crate) fn default_branch_chain_reads_for_test(&self) -> usize {
2448        self.default_branch_chain_reads.load(Ordering::Acquire)
2449    }
2450
2451    /// How many times the most recent `refresh` actually scanned a common dir's
2452    /// default-branch commit history for patch equivalence, as opposed to
2453    /// reusing an already-computed scan for a common dir another dispatched
2454    /// entity already paid for. The same proof `default_branch_chain_reads_for_test`
2455    /// gives the default-branch chain, for patch equivalence's own memo.
2456    pub(crate) fn patch_identity_reads_for_test(&self) -> usize {
2457        self.patch_identity_reads.load(Ordering::Acquire)
2458    }
2459
2460    /// The bound each actually-run `scan_default_branch` call this Generation
2461    /// used, one entry per common dir it ran for, in run order. Unlike
2462    /// `patch_identity_reads_for_test`, which only proves a scan ran once per
2463    /// common dir, this proves *what* it was bounded by: the deepest merge base
2464    /// among the dispatched siblings, per `BoundGate::deepest`, rather than
2465    /// whichever entity's own merge base happened to reach the scan first.
2466    pub(crate) fn patch_scan_bounds_for_test(&self) -> Vec<Option<gix::ObjectId>> {
2467        self.patch_scan_bounds.lock().unwrap().clone()
2468    }
2469
2470    /// Every key the most recent `refresh` call's own sequential dispatch loop iterated,
2471    /// in that order: dispatch order, proven directly rather than inferred from completion,
2472    /// which a concurrent pool never guarantees (criterion 5's honest half).
2473    pub(crate) fn dispatch_log_for_test(&self) -> Vec<EntityKey> {
2474        self.dispatch_log.lock().unwrap().clone()
2475    }
2476
2477    /// Runs one metadata-poll sweep synchronously on the calling thread: the exact
2478    /// work the dedicated thread's tick arm performs, called directly so a test can
2479    /// prove the sweep's own effects without racing the injected tick channel's
2480    /// delivery to that other thread.
2481    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    /// Every key the most recent `poll_once_for_test` call actually re-ran phases A
2493    /// and B for, in the order it found them moved: proves "for that entity only"
2494    /// by naming exactly which entities were touched, not merely that one of them
2495    /// was.
2496    pub(crate) fn poll_reprobed_for_test(&self) -> Vec<EntityKey> {
2497        self.poll_reprobed.lock().unwrap().clone()
2498    }
2499
2500    /// How many metadata-poll sweeps have run in total, so a test driving the real
2501    /// dedicated thread through its injected tick channel can prove a tick reached
2502    /// the sweep at all, not only what the sweep did once it ran.
2503    pub(crate) fn poll_sweep_count_for_test(&self) -> usize {
2504        self.poll_sweep_count.load(Ordering::Acquire)
2505    }
2506
2507    /// This `Core`'s own [`ActionCompletionBoundary`], to arm before the run whose
2508    /// completion a test wants held open. For a test.
2509    #[cfg(test)]
2510    pub(crate) fn action_completion_boundary(&self) -> Arc<ActionCompletionBoundary> {
2511        Arc::clone(&self.action_completion_boundary)
2512    }
2513
2514    /// Registers a closed phase C/D gate for `key`, so the next `refresh` that
2515    /// dispatches it will land its cheap outcomes, then block before touching
2516    /// phase C or D until [`Core::release_phase_c_for_test`] opens the gate.
2517    /// Must be called before the dispatching `refresh`, since a Generation resolves
2518    /// each entity's gate as it dispatches it and its probes signal that one alone.
2519    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    /// Blocks the calling thread, with no sleep or poll, until `key`'s cheap
2527    /// outcomes have landed on the table. Panics if `key` has no gate
2528    /// registered, since that means the test forgot [`Core::hold_phase_c_for_test`].
2529    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    /// Lets `key`'s held phase C and D proceed. Does not itself wait for them to
2543    /// finish; pair with [`Core::wait_phase_c_finished_for_test`].
2544    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    /// Blocks the calling thread, with no sleep or poll, until `key`'s phase C/D
2559    /// outcome has been applied and the settle gate decremented for it.
2560    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    /// Blocks the calling thread, with no sleep and no poll, until every Generation
2574    /// reserved so far has finished dispatching: what a test waits on before reading
2575    /// a count a dispatch raises, now that a Generation reserves its number on the
2576    /// calling thread and raises that count on one of its own.
2577    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    /// The settle gate's raw outstanding count, so a test can prove a single
2587    /// dispatched entity's split write decrements it exactly once overall,
2588    /// neither twice (an early `settle`) nor zero times (a `settle` that hangs).
2589    pub(crate) fn settle_gate_count_for_test(&self) -> usize {
2590        self.settle_gate.0.lock().unwrap().probes
2591    }
2592
2593    /// `start`, with the tick source and the discovery-slow warning's threshold
2594    /// injected rather than real, so a test drives the dedicated thread's cadence
2595    /// through a channel it controls and never waits out a real second.
2596    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    /// `start_for_test`, with the discovery abandon deadline also injected, so a
2610    /// test can force a walk to abandon deterministically instead of running one
2611    /// for the real thirty seconds. The periodic fetch is always off here: a test
2612    /// that wants it runs [`Core::start_for_test_with_fetch`] instead, which is
2613    /// what keeps this constructor's own signature free of a feature-gated
2614    /// parameter.
2615    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    /// `start_for_test_with_discovery_abandon`, with the discovery gate injected: with a
2625    /// closed one, every walk this `Core` starts blocks before it begins, so a caller's own
2626    /// return is observed against a walk that provably has not run.
2627    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    /// `start_for_test_with_discovery_abandon`, with the periodic fetch's own tick
2651    /// channel injected too, so a test can prove the recurring cadence without
2652    /// waiting out a real `fetch.interval`. `spec.fetch.enabled` still governs
2653    /// whether the immediate first cycle fires; `fetch_ticks` governs every cycle
2654    /// after that.
2655    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    /// How many periodic-fetch cycles have run in total: the immediate first one
2679    /// plus one per `fetch.interval` tick since, whether or not any repository had
2680    /// a remote to fetch.
2681    pub(crate) fn fetch_cycle_count_for_test(&self) -> usize {
2682        self.fetch_cycle_count.load(Ordering::Acquire)
2683    }
2684
2685    /// Whether an abandoned discovery has already taken this `Core` out of the
2686    /// automatic refresh path, so a test can assert the precondition explicitly
2687    /// rather than infer it from a later refresh's behaviour alone.
2688    /// Tightens the abandon deadline after `start`, so a test can let the first walk
2689    /// finish under a deadline it cannot lose against and still force a later walk to
2690    /// abandon.
2691    #[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    /// Puts several already-known entities into the in-flight state of one shared
2702    /// Generation, without spawning anything to complete them and without
2703    /// touching the settle gate, so a test can drive per-entity supersession
2704    /// directly: which keys a later real `refresh` does and does not cover, and
2705    /// what happens to each one's own cancel flag and eventual result.
2706    ///
2707    /// Hands back the Generation it minted rather than only the flags, so the test
2708    /// names that Generation and its successor instead of the counter values they
2709    /// happen to hold.
2710    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    /// Lands one branch probe result for `key` at `generation` through the exact
2739    /// same path a real dispatched probe's cheap outcomes take
2740    /// ([`apply_cheap_probe_outcomes`]), so a test can simulate a result arriving
2741    /// late, out of Generation order, without a second, weaker implementation of
2742    /// the write-time supersession check.
2743    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    /// Writes `receipt` directly onto `key`'s `last_action`, bypassing `run_action`
2763    /// entirely: lets a test put an exact, hand-built receipt on a live `Core`'s table
2764    /// without spawning any real child process.
2765    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
2777/// One entity's whole Action run: every step in `action.steps`, in order, stopping at
2778/// the first failure, with every step after it recorded `NotRun` rather than silently
2779/// skipped ([config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md)'s
2780/// "Actions", `docs/spec/actions.md`'s "Step outcomes"). Never called for an excluded,
2781/// inapplicable or unresolved entity: [`Core::run_action`] gives those their own `Skip`
2782/// receipt itself and never reaches this function for them.
2783///
2784/// `control` is the same `RunControl` every other entity's run in this fan-out shares:
2785/// checked before every step starts, so a step not yet reached when `control.cancel` fires
2786/// becomes `Cancelled` rather than ever spawning, and again the instant a spawned step's
2787/// `run_step` call returns, so a step that was actually running when cancellation fired
2788/// becomes `Cancelled` regardless of the exit `run_step` itself observed (a signalled child
2789/// has no clean outcome of its own to report). `Cancelled` and `NotRun` are deliberately
2790/// kept apart here: once cancellation is seen, every remaining step (including a step
2791/// already past the "before it starts" check but not yet run) is `Cancelled`, never
2792/// `NotRun`, which stays reserved for being blocked by an earlier failure
2793/// (`docs/spec/actions.md`'s "Step outcomes").
2794///
2795/// `report` is called once per step, immediately before that step starts, with a receipt
2796/// whose `running` names it: the caller writes this straight onto the table, which is what
2797/// lets a still-running step's own label and elapsed time reach a reader before the whole
2798/// entity's run has finished (`docs/spec/actions.md`'s "The run on screen"). The final
2799/// return value is the same shape with `running: None`, the caller's job to write once more.
2800fn 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        // The step's own `env` table is applied after the environment contract's
2842        // set-or-unset pairs, so it overrides the guaranteed set exactly as a
2843        // Launcher's own `env` field already does (`docs/spec/config.md`'s
2844        // "Launchers").
2845        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
2876/// A gate a test closes to hold every discovery walk this `Core` starts, at the
2877/// point before the walk begins, so a caller's own return can be observed against a
2878/// walk that provably has not run. `None` on every production path, the same way
2879/// `Core::phase_c_gates` is empty on one.
2880type DiscoveryGate = Arc<(Mutex<bool>, Condvar)>;
2881
2882/// Blocks while `gate` is closed, and returns at once when there is none, which is
2883/// every production path.
2884fn 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/// Opens or closes a [`DiscoveryGate`], waking whatever walk is held on it.
2894#[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
2901/// What one watched discovery walk and the thread watching it share: the counter
2902/// the walk bumps as it goes, and the flag it sets on finishing.
2903struct DiscoveryWatch {
2904    progress: Arc<AtomicUsize>,
2905    finished: Arc<AtomicBool>,
2906}
2907
2908/// Arms the still-walking watcher for a walk that has not started yet, leaving the
2909/// still-walking warning behind in `discovery_warning` if that walk outruns
2910/// `warn_after`. Separate from [`run_watched_discovery`] so `start_internal` can arm
2911/// it on the calling thread, and hand a test its handle, while the walk it watches
2912/// runs on a thread of its own.
2913fn 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
2933/// Runs one discovery boundary walk against `set` under an already-armed `watch`,
2934/// leaving the abandoned-discovery warning in `discovery_warning` if the walk
2935/// abandons past `abandon_after`. Shared by `start_internal`'s first walk and
2936/// `rerun_discovery`'s later ones, so a refresh-triggered abandon runs the same
2937/// wiring `start`'s own walk does, never a parallel copy of it.
2938fn 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
2956/// Shared body of `start` and `start_for_test`: builds the empty table, spawns the
2957/// dedicated thread, and starts the first discovery on a thread of its own.
2958fn 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    // Hoisted out of the `Core` struct literal below, rather than built inline
3006    // there as before this field existed: `RefreshHandles` needs its own clone of
3007    // each of these, constructed before `Core` takes ownership of the originals.
3008    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    // Discovery runs here rather than on the calling thread, so `Core::start`
3063    // returns against the empty table above and the consumer can claim the terminal
3064    // and draw before the walk has finished (ADR 0015's "a constructor that spawns
3065    // threads is not a surprise"). This walk is also refresh.md's "Startup"
3066    // Generation, so a launch walks the tree once: the number and the turnstile place
3067    // are reserved here on the calling thread, exactly as every later Generation
3068    // reserves its own, and the walk and the fan-out it orders both run on the
3069    // spawned thread. The debt is recorded before the spawn, so a `settle` called in
3070    // between waits for this Generation rather than returning on an empty table.
3071    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            // Discovery's second half: every boundary the walk just found becomes a
3096            // Repo or a Worktree, and each one's own `.gitmodules` (never recursed
3097            // into) names its Submodules. One combined list, with nothing recording
3098            // which half produced a given entry.
3099            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                // A fresh table has nothing in flight yet, so nothing here is ever
3104                // cancelled: the same reconciliation `refresh` uses later, run once
3105                // against an empty starting point.
3106                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            // Read off the table this walk just reconciled, the same way
3120            // `dispatch_over_everything` resolves its own order: nobody holding the
3121            // empty table `start` returned has a key to name yet.
3122            fetch_refresh_handles.dispatch_probes(&order, startup_generation);
3123            finish_dispatch(&settle_gate);
3124            // Released here rather than at thread exit: the first fetch cycle spawned
3125            // below is not part of this Generation's body.
3126            drop(turn);
3127
3128            // "Fires immediately on being enabled rather than waiting for the first
3129            // tick" ([refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
3130            // "The periodic fetch"): the recurring cadence only ever fires after a full
3131            // `fetch.interval` has elapsed, so the first cycle is dispatched here, once,
3132            // on its own plain thread rather than on the dedicated clock thread, which
3133            // must stay free to keep polling and sweeping deadlines while this cycle
3134            // runs. From inside this thread rather than beside it, because a cycle reads
3135            // the table to know what to fetch and the walk above is what puts anything
3136            // in it.
3137            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
3190/// Everything the dedicated thread's tick arm needs for [`run_poll_sweep`] beyond
3191/// the table it already takes, bundled so `spawn_clock_thread` stays within
3192/// clippy's argument limit.
3193struct 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    /// [`Core::network_default_branch`]'s own clone, so a poll-triggered re-probe
3199    /// still reflects an already-superseded default branch rather than reverting
3200    /// to the local chain's own answer until the next full refresh.
3201    network_default_branch: Arc<Mutex<HashMap<PathBuf, Arc<str>>>>,
3202}
3203
3204/// What [`start_internal`] needs from `CoreSpec::fetch` to schedule the periodic fetch,
3205/// bundled into one argument rather than three so this crate's own `clippy::too_many_arguments`
3206/// budget has room for it: extracted once at each of `Core::start`'s two callers.
3207struct FetchStart {
3208    enabled: bool,
3209    concurrency: usize,
3210    ticks: Receiver<Instant>,
3211}
3212
3213/// The periodic fetch's own scheduling inputs, threaded through [`start_internal`]
3214/// and [`spawn_clock_thread`] as plain values rather than reading `CoreSpec::fetch`
3215/// directly: extracted once at each of the two callers. Carries no `enabled` flag
3216/// of its own: `ticks` is [`crossbeam_channel::never`] whenever the periodic fetch
3217/// is off, so the arm that reads it simply never fires, the same way the poll's
3218/// own `ticks` does when a test has no interest in it.
3219struct FetchSchedule {
3220    concurrency: usize,
3221    ticks: Receiver<Instant>,
3222    refresh: RefreshHandles,
3223    cycle_count: Arc<AtomicUsize>,
3224    failures: Arc<Mutex<FetchFailures>>,
3225    /// `CoreSpec::auto_update`'s own `enabled` flag, read once at `start` like every
3226    /// other field on [`FetchSchedule`]: the fast-forward-only update carries no
3227    /// interval of its own, so there is no separate tick to gate it on, only this.
3228    auto_update_enabled: bool,
3229}
3230
3231/// The dedicated thread's own control-plane wiring, bundled into one argument so
3232/// [`spawn_clock_thread`] stays within clippy's argument limit: `control` is the
3233/// pause/resume/shutdown channel every `Core` method sends into, `ticks` drives the
3234/// poll and deadline sweep, and `alive` is the flag the thread clears on its way out
3235/// (both for a test to observe and for nothing else, since `Drop` joins the handle
3236/// directly rather than polling this).
3237struct ClockChannels {
3238    control: Receiver<ClockControl>,
3239    ticks: Receiver<Instant>,
3240    alive: Arc<AtomicBool>,
3241}
3242
3243/// The dedicated thread: the metadata poll tick, the Generation deadline sweep and
3244/// the periodic fetch's own tick share this one interval loop, separate from the
3245/// probe pool and from any render loop, so suspending the terminal reschedules
3246/// none of it. Driven by `ticks` and `fetch.ticks` rather than a bare
3247/// `thread::sleep`, which is what a test replaces to make the cadence
3248/// deterministic. The poll and deadline sweep run first on every `ticks` tick,
3249/// both while `!paused`; a fetch cycle runs on every `fetch.ticks` tick, also only
3250/// while `!paused`, so a suspended Repon neither sweeps nor fetches while the user
3251/// is in a Launcher.
3252fn 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
3314/// One periodic-fetch cycle: every distinct git common dir this table currently
3315/// knows, not excluded, fetched with pruning, bounded to `concurrency` at once,
3316/// then one normal Generation over every entity the table now knows
3317/// ([refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
3318/// "The periodic fetch": "a finished fetch starts a normal generation"), the exact
3319/// completion path [`Core::run_action`] already uses. `cycle_count` counts every
3320/// call, whether or not any repository had a remote to fetch, so a test driving
3321/// the dedicated thread's own tick channel can prove a tick reached this function
3322/// at all, the same proof [`Core::poll_sweep_count_for_test`] gives the poll.
3323///
3324/// Two things worth recording beside this scheduler rather than only in
3325/// [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md):
3326/// `Gone` is systematically under-reported without this cycle running, because a
3327/// remote-tracking ref only disappears once a prune removes it
3328/// ([`crate::landing`]'s `classify_unmerged_branch` doc comment), so a Repo with
3329/// `fetch.enabled = false` can carry a stale upstream indefinitely and never show
3330/// it. And the cadence itself is unresolved: `fetch.interval`'s default of five
3331/// minutes is [config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md)'s
3332/// stated number, not one this crate has measured against a real population the
3333/// way the poll interval and the generation deadline were.
3334fn 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        // Every repository's own fetch result is independent: one credential
3349        // failure or one unreachable remote must never stop the rest of the
3350        // cycle from running, so a per-repository error is swallowed here
3351        // rather than aborting the whole cycle. It is still counted below,
3352        // which is the count this cycle's own [`FetchFailures`] carries.
3353        match crate::fetch::fetch_and_prune(&common_dir, &cancel) {
3354            Ok(outcome) => {
3355                // The handshake this fetch already paid for is what
3356                // [default-branch.md](https://github.com/paulchiu/repon/blob/main/docs/spec/default-branch.md)'s
3357                // "The network" means by "arrives inside a round trip already being
3358                // paid for": landed here, before `refresh.dispatch` below re-runs
3359                // the local chain, so the local answer always computes first and
3360                // this only ever supersedes it. `Unborn` and a missing answer both
3361                // leave any earlier session answer for this common dir untouched,
3362                // since neither is itself a fact worth overwriting one with.
3363                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    // The fast-forward-only auto-update rides this cycle rather than a timer of its
3386    // own, per `docs/spec/config.md`'s "Refresh, fetch and auto-update": it can only
3387    // ever act on what the fetch just above learned, so it runs here, after every
3388    // fetch has settled and before the one Generation below reports the result.
3389    // Sequential rather than `fetch::run_bounded`'s own concurrency, since this is a
3390    // mutating pass over a Repo's own working tree and index, not a read against a
3391    // remote: ADR 0002's narrowest-safe-operation rule favours a simple, serial pass
3392    // over throughput a mutation has no need of.
3393    if auto_update_enabled {
3394        for repo_path in repos_eligible_for_auto_update_attempt(table) {
3395            // One Repo's ineligibility or failure never stops another's: the same
3396            // independence the fetch loop above already gives each repository.
3397            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
3411/// Every non-excluded Repo's own working directory, one per distinct common dir the
3412/// table currently knows: the auto-update acts on a Repo's own row, per
3413/// `docs/spec/config.md`'s "acts only on a Repo", so a Worktree sharing that common
3414/// dir is never a candidate here even though it is `distinct_fetchable_common_dirs`'s
3415/// own definition of "fetchable" for the read-only fetch above. Listed, never
3416/// operated on, mirrors the same `excluded` rule the fetch loop's own common-dir
3417/// filter applies, checked here against the Repo entity's own flag rather than any
3418/// Worktree that happens to share its common dir.
3419fn 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
3430/// Every distinct git common dir a fetch cycle should fetch: deduplicated across
3431/// every entity sharing one (a Repo and its linked Worktrees), and skipped only
3432/// when every entity sharing that common dir is excluded
3433/// ([config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md#per-repo-entries)'s
3434/// "listed, never operated on"), since a Worktree named directly by its own path
3435/// can carry a different `excluded` than an entry it would otherwise inherit.
3436fn 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
3450/// [`Core::rederive_default_branches`]'s own network half: a handshake-only probe
3451/// per `common_dir`, landing a `Branch` answer on `network_default_branch` for
3452/// [`supersede_with_network`] to read back. `Unborn` and a probe failure both
3453/// leave any earlier session answer for that common dir untouched, the same
3454/// convention [`run_fetch_cycle`] already follows.
3455fn 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
3471/// One entity [`Core::rederive_default_branches`] gathered under the table lock,
3472/// everything its own spawned thread needs to re-run the default-branch chain
3473/// without holding that lock while it does: a plain struct rather than a tuple,
3474/// per this crate's own `clippy::type_complexity` budget.
3475struct 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
3484/// One entity as the metadata poll sweep found it, everything gathered under one
3485/// read lock so the filesystem stats and any re-probe below run outside it.
3486struct 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
3495/// One metadata-poll sweep ([refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
3496/// "The poll"): for every entity a Generation's dispatch would also cover (a
3497/// hidden Submodule is skipped by the same [`dispatches_kind`] rule), stats
3498/// [`poll::POLLED_GITDIR_ENTRIES`] in its own gitdir. That gitdir is the cached
3499/// [`gix::ThreadSafeRepository`] handle's own `git_dir()` where discovery cached
3500/// one (the per-worktree location a linked Worktree's `HEAD` and `index` actually
3501/// live at), or else a fresh open's `git_dir()`, the same fallback every other
3502/// probe in this module already takes for a Submodule, which discovery never
3503/// opens. A first sweep for a newly discovered entity has nothing to compare
3504/// against yet, so it only records a baseline and reports no movement.
3505///
3506/// On movement it force-stales `dirty` and `state`, the two cells with no cheap
3507/// detector, then re-runs phases A and B for that entity alone and lets their own
3508/// supersession land the fresh values; it never starts a status probe of its own.
3509/// `poll_reprobed` is cleared and refilled with exactly the keys this call
3510/// actually re-ran, in the order it found them moved. `poll_sweep_count` counts
3511/// every call, whether or not anything moved, so a test can prove a real tick
3512/// reached this function at all.
3513fn 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        // A fresh open, never cached across sweeps: this is the same cost every
3544        // other probe in this module already pays for an entity discovery left
3545        // no handle for (always true of a Submodule), and reusing the handle it
3546        // returns for the re-probe below saves a second open on the one path
3547        // that actually detected movement.
3548        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, &current))
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
3644/// Cancels every probe currently in flight and drops the table's record of them,
3645/// which is what suspension does: the in-flight Generation is cancelled outright
3646/// rather than left to finish. Releases a pending `settle` too, since nothing is
3647/// now going to finish it.
3648fn 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
3662/// A `Cell<T>`'s in-flight and timeout behaviour, uniform across every payload
3663/// type `EntityState` carries, so [`sweep_deadline`] can sweep every cell
3664/// through one array rather than one hand-written branch per cell: a cell only
3665/// ever times out if it was actually marked in flight, which is what lets the
3666/// sweep apply to all of them without asking what `Kind` owns them.
3667trait TimeoutableCell {
3668    fn is_in_flight(&self) -> bool;
3669    /// Settles this cell `Unknown(TimedOut)` for `generation`, subject to the
3670    /// same supersession `Cell::settle` already enforces.
3671    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
3684/// Marks every cell still in flight past its own Generation's deadline `Unknown`,
3685/// per [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md):
3686/// there is no per-cell timeout, only this sweep, and it never interrupts the
3687/// underlying probe, which keeps running; the sweep only stops waiting on it.
3688fn 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            // Exhaustive: a Cell added to `EntityState` later must be named here
3705            // or this fails to compile, so it cannot silently time out never.
3706            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                // Only a cell actually marked in flight times out: a Repo's or a
3728                // Submodule's `state` (never probed, by `EntityState::probes_state`)
3729                // and any cell no probe yet reaches (`sync`, `base`) are never in
3730                // flight, so this never overwrites them with a lie.
3731                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
3749/// Marks the cells this Generation's dispatch is about to probe as in flight,
3750/// via an exhaustive destructure of `EntityState`'s cells: a cell added later
3751/// must be named here (`_` if it is not yet probed) or this fails to compile,
3752/// which is what stops a cell [`apply_probe_outcome`] settles from going
3753/// in-flight silently forgotten, and reading wrong on `is_in_flight` for the
3754/// whole dispatch.
3755fn 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    // Phase C runs against every dispatched entity, Repo, Worktree or Submodule alike:
3778    // refresh.md's "Scope and order" makes scope never a partial dial, so `dirty` carries
3779    // no `probes_state`-style condition of its own.
3780    dirty.begin_probe();
3781    // Only a Worktree's `state` is ever (re)probed: a Repo's is `NotApplicable`
3782    // and a Submodule's is `Unknown` from construction, neither ever revisited
3783    // (`EntityState::probes_state`), and marking either in flight here would
3784    // leave it in-flight forever, since nothing would ever call `settle` on it.
3785    if probes_state {
3786        state.begin_probe();
3787    }
3788}
3789
3790/// What [`Core::try_settle`] waits on, and the one lock every count it waits on lives
3791/// under, so a settle can never observe one of them without the other.
3792type SettleGate = (Mutex<SettleCounts>, Condvar);
3793
3794/// The two outstanding counts [`Core::try_settle`] blocks on.
3795///
3796/// `dispatches` exists because a Generation reserves its number on the calling
3797/// thread and does everything else on one of its own: between those two moments
3798/// `probes` has not been raised yet, so a settle reading `probes` alone would
3799/// return on a table nothing has started writing to.
3800#[derive(Default)]
3801struct SettleCounts {
3802    /// Dispatched entities that have yet to land a phase C/D outcome, be cancelled
3803    /// or time out.
3804    probes: usize,
3805    /// Generations whose number is reserved and whose own dispatch body has not
3806    /// finished raising `probes` for what it dispatches.
3807    dispatches: usize,
3808}
3809
3810impl SettleCounts {
3811    /// Whether nothing this `Core` has started is still owed to the table.
3812    ///
3813    /// An exhaustive destructure: a third count added to this struct must be named here
3814    /// or this fails to compile, rather than being silently left out of what a settle
3815    /// waits for.
3816    fn is_settled(&self) -> bool {
3817        let SettleCounts { probes, dispatches } = self;
3818        *probes == 0 && *dispatches == 0
3819    }
3820}
3821
3822/// Records one reserved Generation as owed, before the thread that will dispatch
3823/// it has started. Paired with exactly one [`finish_dispatch`].
3824fn begin_dispatch(settle_gate: &SettleGate) {
3825    let (lock, _cvar) = settle_gate;
3826    lock.lock().unwrap().dispatches += 1;
3827}
3828
3829/// Releases the debt [`begin_dispatch`] recorded, once that Generation's own
3830/// dispatch has raised `probes` for everything it dispatched.
3831fn 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    // Unconditionally, unlike `complete_many`: a waiter watching `dispatches` alone
3837    // would never be woken by a change that leaves `probes` outstanding.
3838    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
3859/// Reads one entity's HEAD shape, or `None` if `cancel` was already set before the
3860/// read started. The one check this crate makes today: `git::head_shape` itself has
3861/// no interruption point to check `cancel` against mid-read, unlike the later
3862/// phases [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)
3863/// describes gix taking it through directly.
3864///
3865/// `repo` is the entity's cached thread-safe handle when discovery already opened
3866/// one; this task derives its own `Repository` from it via `to_thread_local`
3867/// rather than sharing that derived handle with any other task. `None` (a
3868/// Submodule, or a boundary discovery could not open) falls back to opening fresh,
3869/// which is where an unreadable repository's `ProbeError::Open` still surfaces.
3870///
3871/// Also reads the entity's in-progress git operation and recent commits off the
3872/// same open handle, since both ride along at negligible extra cost
3873/// ([ADR 0019](https://github.com/paulchiu/repon/blob/main/docs/adr/0019-a-detached-head-is-a-shape-of-head-not-a-worktree-state.md)).
3874/// Neither is a Cell in its own right, so both travel with the branch read they
3875/// were taken alongside rather than getting independent supersession of their
3876/// own; [`EntityState::apply_branch_probe`] is where that pairing lands.
3877const RECENT_COMMITS_LIMIT: usize = 5;
3878
3879/// What an open-repository failure means for `kind`: a genuine Probe error for a Repo or a
3880/// Worktree, but for a Submodule the far more common, expected shape of "never `git
3881/// submodule update --init`-ed" ([discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md)'s
3882/// "The Submodule row": "An uninitialised Submodule is a row with every cell blank and `?`
3883/// in the gutter"). Exhaustive over `Kind` rather than a wildcard, so a fourth variant added
3884/// later must decide which grade it gets rather than silently inheriting one.
3885fn 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
3930/// Phase B's comparison: the `sync` cell's ahead/behind counts against the
3931/// branch's upstream, for every entity whose HEAD carries a branch, every
3932/// Generation ([refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)).
3933/// `None` if `cancel` was already set, or if `branch_settled` is itself `None`
3934/// because the branch probe it depends on was cancelled first. A `Failed` branch
3935/// read fails `sync` the same way, rather than guessing at a HEAD shape the
3936/// branch probe itself could not read; every other shape (a live branch, a
3937/// detached or unborn HEAD) is handed to [`git::resolve_sync`], which is where
3938/// "no branch" and "no remote at all" settle to their own values. `repo` follows
3939/// the same cached-handle convention as [`probe_branch`].
3940fn 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
3982/// Phase B's second rev-walk: the `base` cell's count behind the resolved default
3983/// branch, for every entity [`crate::base::probe`] does not exempt
3984/// ([default-branch.md](https://github.com/paulchiu/repon/blob/main/docs/spec/default-branch.md)'s
3985/// "The two behind counts"). `None` if `cancel` was already set, or if either
3986/// `branch_settled` or `default_branch_settled` is itself `None` because the probe
3987/// it depends on was cancelled first; [`crate::base::probe`] itself always settles
3988/// once reached. A `Failed` or not-yet-`Known` `branch_settled` carries no commit to
3989/// compare, so it is treated the same "nothing to settle yet" way, except a genuine
3990/// `Failed` branch read, which propagates onto `base` too: a row whose HEAD could
3991/// not be read has nothing to compute behind anything. `repo` follows the same
3992/// cached-handle convention as [`probe_branch`].
3993fn 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
4028/// Phase C's typed counts, dispatched over every entity in a Generation with no
4029/// scoping of its own: [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
4030/// "Scope and order" makes scope never a partial dial, only order, so this carries
4031/// no visibility filter and no cost heuristic; the caller's dispatch order is the
4032/// only dial, expressed entirely by the position `path` already holds in
4033/// `Core::refresh`'s `order`. `None` if `cancel` was already set before the read
4034/// started; unlike the cheaper phases above, `cancel` is also handed straight
4035/// into gix, which checks it while the read is under way rather than only before
4036/// it starts, since this is the one phase long enough for that to matter.
4037fn 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
4061/// Folds [`git::dirty_counts`]'s result into [`probe_status`]'s outcome. Split out as its own
4062/// function so the one case a live probe cannot reproduce deterministically, cancellation
4063/// observed genuinely mid-read, is directly testable: gix's own error carries no typed "this
4064/// was cancelled" case (its interrupt point reports through a bare `io::Error`, same as any
4065/// other I/O failure), so `cancel` itself, which this task alone owns for the duration of its
4066/// probe, is the answer. An error alongside a cancel flag now set is what an interruption
4067/// mid-read looks like, and [ADR 0013](https://github.com/paulchiu/repon/blob/main/docs/adr/0013-no-filesystem-watching-a-refresh-is-a-cancellable-generation.md)'s
4068/// precedent is that interrupted work is dropped rather than settled `Failed`, the same as
4069/// every cheaper phase's pre-check already does.
4070///
4071/// gix checks `should_interrupt` per index entry rather than before every read, so a walk
4072/// short enough to run out of entries to check between the flag flipping and the walk
4073/// finishing can still return `Ok`. `cancel` is re-checked on that arm too, and an `Ok` that
4074/// raced ahead of it is dropped the same way an `Err` alongside it already is, so a cancelled
4075/// generation never lands a value regardless of which side of that race gix landed on.
4076fn 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
4092/// Rung 1's config override and the network's session-held answer, bundled into
4093/// one argument the way [`ChainFactsMemo`] bundles its own two: both
4094/// [`probe_default_branch`] and [`probe_default_branch_memoised`] already sit at
4095/// clippy's argument limit, and the two hints always travel together, one per
4096/// dispatched entity.
4097struct DefaultBranchHints<'a> {
4098    /// Matched by common dir before this is called; `None` when no `[[repo]]`
4099    /// entry names this entity's own default branch.
4100    override_branch: Option<&'a str>,
4101    /// [`network_branch_for`]'s own answer for this entity's common dir; `None`
4102    /// until a fetch handshake or [`Core::rederive_default_branches`] has
4103    /// actually reached that remote this session.
4104    network_branch: Option<&'a str>,
4105}
4106
4107/// [`Core::network_default_branch`]'s own lookup, by common dir: a small helper
4108/// so every probe site reads it the same way rather than repeating the lock and
4109/// clone.
4110fn 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
4121/// Supersedes `resolution`'s own settled value with `network_branch`, if given,
4122/// per [default-branch.md](https://github.com/paulchiu/repon/blob/main/docs/spec/default-branch.md)'s
4123/// "The network": never the primary source, so `resolution` is always the local
4124/// chain's own complete answer, computed unconditionally by the caller before
4125/// this ever runs. This is the one place ADR 0012's stated ceiling is actually
4126/// closed: on a Repo where rung 2 and rung 3 agree and are both wrong (the
4127/// hidden-Submodule case the ADR measures), no local rung can ever correct
4128/// itself, and only a reachable remote's own answer, landed here, can.
4129fn 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
4143/// Runs the four-rung default branch chain against `path`, or `None` if `cancel`
4144/// was already set before the read started, then [`supersede_with_network`]s the
4145/// result with `hints.network_branch`.
4146///
4147/// `repo` follows the same cached-handle convention as [`probe_branch`]: `None`
4148/// falls back to opening fresh, which is where an unreadable repository surfaces
4149/// as [`default_branch::Resolution::failed`] rather than a settled Unknown.
4150fn 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
4182/// Coordinates one common dir's Outstanding entities so every one of their own
4183/// merge bases against the default branch is known before the shared scan
4184/// runs, per [default-branch.md](https://github.com/paulchiu/repon/blob/main/docs/spec/default-branch.md)'s
4185/// requirement that the bound be *collected*, not computed lazily on whichever
4186/// entity happens to arrive first. `remaining` starts at the number of
4187/// dispatched entities in this common dir that will call [`GateReport::report`]
4188/// this Generation (every entity `landing::probe` runs for, whether it settles
4189/// immediately or reaches patch equivalence); `deepest` blocks until all of
4190/// them have, then folds their contributed merge bases pairwise via
4191/// [`git::checked_merge_base`] so the result is an ancestor of (at least as
4192/// deep as) every one of them, and memoises that answer for every later caller
4193/// sharing this dir.
4194struct 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    /// One entity's contribution: `Some(base)` when it reached patch
4218    /// equivalence and had a merge base to offer, `None` otherwise (it settled
4219    /// by ancestry, was cancelled, failed to read, or shared no history with
4220    /// the default branch at all). Wakes every task blocked in [`Self::deepest`]
4221    /// once every entity counted in `remaining` has reported.
4222    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    /// Blocks until every entity sharing this common dir has reported, then
4234    /// returns the deepest merge base among their contributions (`None` if
4235    /// none contributed one, so the scan is left unbounded). The candidates are
4236    /// taken and folded into `bound` inside the same critical section, so
4237    /// whichever call is first to finish waiting is guaranteed to be the one
4238    /// that computes the memoised answer from them; computing outside the lock
4239    /// would let a later call, left holding an empty list by
4240    /// [`std::mem::take`], win the race into [`OnceLock::get_or_init`] and
4241    /// memoise `None` regardless of what the first call actually contributed.
4242    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
4254/// Folds `candidates` pairwise via [`git::checked_merge_base`] into the one
4255/// deepest among them: when two candidates are ancestor and descendant, their
4256/// own merge base is exactly the ancestor, so the fold converges on whichever
4257/// candidate is deepest; two on unrelated lines of history fold to their own
4258/// common ancestor instead, which is still a safe (if not the tightest
4259/// possible) lower bound for the scan.
4260fn 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
4275/// Reports exactly once to a [`BoundGate`], on drop if [`Self::report_now`] was
4276/// never called explicitly: every exit path out of [`probe_worktree_state`]
4277/// and [`probe_patch_equivalence`] must release its common dir's gate, since a
4278/// path that forgot to would deadlock every sibling still waiting in
4279/// [`BoundGate::deepest`].
4280struct 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    /// Reports `candidate` immediately rather than waiting for drop: the one
4294    /// path that goes on to call [`BoundGate::deepest`] must report its own
4295    /// contribution first, or it would wait on a count that can never reach
4296    /// zero without its own report.
4297    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
4311/// The per-common-dir patch-equivalence memo plumbing, bundled into one
4312/// argument so [`probe_worktree_state`] and [`probe_patch_equivalence`] each
4313/// take it as a single parameter rather than three loose ones.
4314struct PatchEquivalenceMemo<'a> {
4315    cache: &'a PatchIdentityCache,
4316    reads: &'a AtomicUsize,
4317    /// Where [`probe_patch_equivalence`] records the bound it actually passed to
4318    /// [`patch_equivalence::scan_default_branch`], for `Core::patch_scan_bounds_for_test`.
4319    scan_bounds: &'a Mutex<Vec<Option<gix::ObjectId>>>,
4320}
4321
4322/// Runs both of Phase D's passes for one Worktree entity: `landing::probe`'s
4323/// ancestry check, then, only when it answers `Outstanding`,
4324/// [`probe_patch_equivalence`]'s content check. `None` if `cancel` was already
4325/// set, or if `default_branch_settled` is itself `None` because the
4326/// default-branch probe it depends on was cancelled first. `repo` follows the
4327/// same cached-handle convention as [`probe_branch`]. `report` always reports
4328/// exactly once to this entity's common dir's `BoundGate`, on every path
4329/// through this function, via its own `Drop`.
4330fn 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
4363/// Phase D's expensive half, reached only when `landing::probe` answered
4364/// `Outstanding`: this is the seam that keeps patch equivalence off every
4365/// entity ancestry already settled. Reports the merge base the first pass
4366/// already walked to `report` *before* asking for the shared scan, then checks
4367/// patch equivalence against `memo`'s per-common-dir cache, per
4368/// [default-branch.md](https://github.com/paulchiu/repon/blob/main/docs/spec/default-branch.md)'s
4369/// "Two passes on screen" and its bound on the scan's own depth.
4370fn 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        // No shared history at all: a real negative the first pass already
4388        // established. This entity needs no bound and no shared scan, so it
4389        // reports and settles without waiting on either; the empty set is never
4390        // actually consulted, since `probe` returns `Active` for a `None` merge
4391        // base before it would look.
4392        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    // Reported now, not left to `report`'s `Drop`: the wait just below blocks
4401    // on every entity sharing this common dir having reported, this entity
4402    // included, so reporting late here would deadlock on its own wait.
4403    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        // Recorded here, inside the closure that only ever runs for whichever
4407        // entity's task is first to reach `patch_identities_for` for this common
4408        // dir, so this is the bound the one real `scan_default_branch` call for
4409        // it actually used, not a value a test recomputes independently.
4410        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
4424/// One Generation's patch-equivalence memo: at most one
4425/// [`patch_equivalence::PatchIdentitySet`] per common dir, shared by every
4426/// dispatched entity `landing::probe` answered `Outstanding` for. Built fresh
4427/// in [`Core::refresh`] and dropped once every task from that dispatch has
4428/// finished, the same lifetime `ChainFactsCache` has. The computed `Result` is
4429/// itself cached, since a common dir a scan fails against fails identically
4430/// for every entity sharing it this Generation.
4431type PatchIdentityCache = Mutex<
4432    HashMap<Arc<Path>, Arc<OnceLock<Result<patch_equivalence::PatchIdentitySet, git::ProbeError>>>>,
4433>;
4434
4435/// The per-common-dir half of [`probe_patch_equivalence`]: returns the
4436/// already-computed scan for `common_dir` if another entity in this
4437/// Generation's dispatch already ran it, blocking until that computation
4438/// finishes if it is still running; otherwise runs `compute` itself, caches the
4439/// result, and increments `reads` exactly once for the common dir this call is
4440/// the first to reach. Structurally identical to [`chain_facts_for`]; kept
4441/// separate rather than made generic over it, since the two caches are keyed by
4442/// different Generations' worth of dispatch and sharing one would blur which
4443/// pass a given read counted for.
4444fn 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
4465/// One Generation's default-branch chain memo: at most one [`default_branch::ChainFacts`]
4466/// per common dir, shared by every dispatched entity that names it. Built fresh in
4467/// [`Core::refresh`] and dropped once every task from that dispatch has finished.
4468type ChainFactsCache = Mutex<HashMap<Arc<Path>, Arc<OnceLock<default_branch::ChainFacts>>>>;
4469
4470/// The per-common-dir half of [`probe_default_branch_memoised`]: returns the
4471/// already-cached facts for `common_dir` if another entity in this Generation's
4472/// dispatch already computed them, blocking until that computation finishes if it
4473/// is still running; otherwise runs `compute` itself, caches the result, and
4474/// increments `reads` exactly once for the common dir this call is the first to
4475/// reach.
4476fn 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
4497/// Runs the four-rung default branch chain against `path`, memoising rungs 2 and
4498/// 3's own per-common-dir facts in `cache` so every entity sharing `common_dir`
4499/// within the same dispatch reads the loose file and its reference lookups once
4500/// rather than once per entity, per [default-branch.md](https://github.com/paulchiu/repon/blob/main/docs/spec/default-branch.md)'s
4501/// "Memoised per common dir within a single refresh generation". `None` if
4502/// `cancel` was already set before the read started; `override_branch` is rung 1's
4503/// own entity-specific value, never memoised because it is not a common-dir fact.
4504/// [`chain_facts_for`]'s own two collaborators, bundled so
4505/// [`probe_default_branch_memoised`] stays within clippy's argument limit: the two always
4506/// travel together, one dispatch's worth of both, per [`Core::refresh_handles`].
4507struct 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
4550/// Phase A and B's per-cell outcomes, landed as soon as they are computed via
4551/// [`apply_cheap_probe_outcomes`], well before phase C or D answer. Named rather
4552/// than positional so a transposed pair of trailing `None`s cannot compile
4553/// silently into the wrong cell.
4554struct 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
4565/// Writes phase A and B's cells for `key` at `generation`, subject to the
4566/// per-cell supersession `Cell::settle` already enforces, and records the
4567/// default-branch diagnostics only on the write that actually won. Deliberately
4568/// does not touch `in_flight` or `settle_gate`: those belong to whichever apply
4569/// closes out the entity's dispatch, which per
4570/// [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
4571/// "The first frame" is this call's whole point, since a slow phase C or D must
4572/// never hold these cells off the table.
4573fn 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
4602/// Phase C and D's per-cell outcomes, landed once they answer, via
4603/// [`apply_probe_outcome`]: named rather than positional for the same reason as
4604/// [`CheapProbeOutcomes`].
4605struct ProbeOutcomes {
4606    state: Option<Settled<WorktreeState>>,
4607    dirty: Option<Settled<DirtyCounts>>,
4608}
4609
4610/// Lands one probe's phase C/D outcome for `key` at `generation`: writes the
4611/// `state` and `dirty` cells subject to the per-cell supersession `Cell::settle`
4612/// already enforces, then clears `key`'s in-flight entry if `generation` still
4613/// owns it and signals `settle_gate` once for the whole entity. This is the one
4614/// write that closes out a dispatched entity, whether or not
4615/// [`apply_cheap_probe_outcomes`] already landed that same entity's cheap cells;
4616/// a test's simulated late result goes through the same path so it does not
4617/// duplicate this bookkeeping.
4618///
4619/// `outcomes.state` being `None` writes nothing at all: the `state` cell is left
4620/// exactly as unsettled as `begin_probe` alone leaves it, which is what an
4621/// attached branch with a live upstream ancestry could not clear, and that
4622/// `probe_patch_equivalence` was itself cancelled before answering, still shows.
4623fn 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    // By Generation as well as by key. Cancellation is cooperative, so a superseded
4644    // probe still runs to completion and arrives here after the Generation that
4645    // superseded it has already put its own entry under this key; clearing by key
4646    // alone would delete that live entry, leaving the entity with nothing for the
4647    // next Generation to interrupt and nothing for the deadline sweep to time out.
4648    // The settle gate is signalled either way, since the debt belongs to the probe
4649    // rather than to the entry.
4650    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
4661/// Reconciles one discovery result into `table`: a found entity is inserted or
4662/// marked Present again, even if it was Vanished, and one no longer found is
4663/// marked Vanished via [`EntityState::mark_vanished`]. Returns how many
4664/// in-flight probes were cancelled by a newly Vanished entity, for the caller
4665/// to signal `settle_gate`.
4666fn 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    // A boundary's `.gitmodules` failure is re-derived from this pass alone,
4707    // never carried over from a previous one: a failure that was fixed since the
4708    // last Generation must clear, not stay stuck forever.
4709    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
4738/// A basename read from the entity's own resolved path. A real display name has
4739/// collision handling that belongs to [config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md);
4740/// this is a placeholder good enough to populate the table.
4741///
4742/// This is the one function that computes it: `start_internal`'s discovery loop
4743/// and `probe_now`'s fallback insert for an unknown key both call it rather than
4744/// formatting a name of their own, which is what keeps the name shown on screen
4745/// and the name a future state file would key by byte-identical.
4746fn 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
4754/// Sleeps for `warn_after`, then reports `progress`'s count and `roots` if the walk
4755/// still has not finished, per [discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md):
4756/// the one-second still-walking warning needs a timer watching an in-flight walk
4757/// from outside it, since discovery itself has no callback and no notion of "still
4758/// running". `None` once the walk has already finished.
4759fn 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
4784/// The persistent warning left once a walk abandons, per
4785/// [config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md#discovery-bounds):
4786/// unlike the still-walking warning, this one never clears itself, since the Set
4787/// stays out of the automatic refresh path for the life of this `Core`.
4788fn abandoned_discovery_message(directories_visited: usize) -> String {
4789    format!("discovery: stopped at {directories_visited} directories")
4790}
4791
4792/// Runs `step` until it says it is done or `cancel` is observed set, checked before
4793/// every call. Returns how many times `step` actually ran, which is what lets a
4794/// test prove a cancelled loop stopped mid-flight rather than merely having a flag
4795/// set on it somewhere. Not yet called from a real probe: `git::head_shape` has no
4796/// loop to interrupt, so this is the shape a later, genuinely interruptible phase
4797/// (gix `status`, taking `should_interrupt` directly) will use.
4798#[allow(dead_code)] // exercised by its own test; no interruptible probe calls it yet
4799pub(crate) fn run_while_not_cancelled(
4800    cancel: &AtomicBool,
4801    mut step: impl FnMut() -> bool,
4802) -> usize {
4803    let mut ran = 0;
4804    while !cancel.load(Ordering::Acquire) {
4805        if !step() {
4806            break;
4807        }
4808        ran += 1;
4809    }
4810    ran
4811}
4812
4813#[cfg(test)]
4814mod tests {
4815    use std::fs;
4816    use std::process::Command;
4817
4818    use super::*;
4819    use crate::entity::{AheadBehind, DefaultBranchStopped, WorktreeState};
4820    use crate::liveness::{BACKSTOP, FIXTURE_LIFETIME, wait_for};
4821    use crate::snapshot::{RowSummary, summary};
4822    use crate::test_support::{git, head_sha, loose_object_count};
4823
4824    fn init_repo_with_a_commit(path: &Path) {
4825        fs::create_dir_all(path).expect("create repo dir");
4826        gix::init(path).expect("init repo");
4827        let status = Command::new("git")
4828            .arg("-C")
4829            .arg(path)
4830            .args(["-c", "user.email=test@example.com", "-c", "user.name=Test"])
4831            .args(["commit", "--allow-empty", "-m", "first"])
4832            .status()
4833            .expect("run git commit");
4834        assert!(status.success());
4835    }
4836
4837    /// A second (or later) commit against an already-initialised repo at `path`,
4838    /// with the same explicit identity `init_repo_with_a_commit` supplies: never
4839    /// relying on a global git identity, which a machine running CI has none of.
4840    /// Commits a real change, which is what the poll's own user story is about and what an
4841    /// empty commit is not: `git add` rewrites `.git/index` unconditionally, while whether a
4842    /// commit with nothing staged rewrites it is left to git's racy-entry heuristic and
4843    /// differs between platforms. `index` is the only one of the polled paths a commit on an
4844    /// attached HEAD moves, so a test that depends on an empty commit moving it is testing
4845    /// that heuristic rather than the poll.
4846    fn commit_a_change(path: &Path, message: &str) {
4847        let gitdir = gitdir_of(path);
4848        let before = poll::fingerprint(&gitdir);
4849
4850        std::fs::write(path.join(format!("{message}.txt")), message.as_bytes())
4851            .expect("write a file to commit");
4852        let added = Command::new("git")
4853            .arg("-C")
4854            .arg(path)
4855            .args(["add", "-A"])
4856            .status()
4857            .expect("run git add");
4858        assert!(added.success());
4859        commit(path, message, &["-m", message]);
4860
4861        // The fixture's own premise, asserted rather than assumed: a commit on an attached
4862        // HEAD moves none of the polled paths except `index` (`HEAD` is untouched, and
4863        // rewriting `refs/heads/<branch>` does not move `refs/` itself), so if git leaves
4864        // `index` alone here there is nothing for the poll to see and the failure belongs to
4865        // this fixture, not to the sweep it is setting up.
4866        assert!(
4867            poll::moved(&before, &poll::fingerprint(&gitdir)),
4868            "committing in {} moved none of the polled paths under {}, so this fixture cannot \
4869             show the poll anything",
4870            path.display(),
4871            gitdir.display()
4872        );
4873    }
4874
4875    /// The absolute gitdir git itself reports, which for a linked Worktree is its own
4876    /// `.git/worktrees/<name>` rather than the `.git` file beside the checkout.
4877    fn gitdir_of(work_dir: &Path) -> PathBuf {
4878        let output = Command::new("git")
4879            .arg("-C")
4880            .arg(work_dir)
4881            .args(["rev-parse", "--absolute-git-dir"])
4882            .output()
4883            .expect("run git rev-parse");
4884        assert!(
4885            output.status.success(),
4886            "resolve the gitdir of {}",
4887            work_dir.display()
4888        );
4889        PathBuf::from(
4890            std::str::from_utf8(&output.stdout)
4891                .expect("a utf-8 gitdir path")
4892                .trim(),
4893        )
4894    }
4895
4896    /// The shared tail of the commit helpers.
4897    fn commit(path: &Path, message: &str, args: &[&str]) {
4898        let status = Command::new("git")
4899            .arg("-C")
4900            .arg(path)
4901            .args(["-c", "user.email=test@example.com", "-c", "user.name=Test"])
4902            .arg("commit")
4903            .args(args)
4904            .status()
4905            .unwrap_or_else(|error| panic!("run git commit {message}: {error}"));
4906        assert!(status.success());
4907    }
4908
4909    /// A `FetchSpec` that never fires on its own: `enabled: false`, so every
4910    /// existing test that does not care about the periodic fetch keeps behaving
4911    /// exactly as it did before this field existed.
4912    fn fetch_spec_for_test() -> FetchSpec {
4913        FetchSpec {
4914            enabled: false,
4915            interval: Duration::from_secs(3600),
4916            concurrency: 4,
4917        }
4918    }
4919
4920    /// An `AutoUpdateSpec` that never fires on its own, the same reason
4921    /// [`fetch_spec_for_test`] never does: every existing test that does not care
4922    /// about the auto-update keeps behaving exactly as it did before this field
4923    /// existed.
4924    fn auto_update_spec_for_test() -> AutoUpdateSpec {
4925        AutoUpdateSpec { enabled: false }
4926    }
4927
4928    fn spec(roots: Vec<PathBuf>) -> CoreSpec {
4929        CoreSpec {
4930            set: SetSpec {
4931                name: "test".to_string(),
4932                roots,
4933                include: Vec::new(),
4934                exclude: Vec::new(),
4935            },
4936            overrides: Vec::new(),
4937            poll_interval: Duration::from_secs(3600),
4938            status_stale_after: Duration::from_secs(3600),
4939            generation_deadline: Duration::from_secs(3600),
4940            show_submodules: false,
4941            fetch: fetch_spec_for_test(),
4942            auto_update: auto_update_spec_for_test(),
4943        }
4944    }
4945
4946    /// Criterion 2's "no field" half: scope is never a partial dial, not even as a field
4947    /// on the plain-data struct crossing into the core. An exhaustive destructure names
4948    /// every field `CoreSpec` has; a scoping field added under any name fails to compile
4949    /// this test rather than landing unacknowledged. `show_submodules` is named here too,
4950    /// deliberately: it narrows probing and rendering, never what discovery bounds, so it
4951    /// is not the scoping field this test guards against
4952    /// ([discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md)'s
4953    /// "narrows the view rather than bounding the work"). `fetch` and `auto_update` are
4954    /// excluded from that same guard for the same reason: they narrow what the periodic
4955    /// fetch and the fast-forward-only update touch, never what discovery bounds.
4956    #[test]
4957    fn core_spec_carries_no_scoping_field_scope_is_never_a_dial() {
4958        let CoreSpec {
4959            set: _,
4960            overrides: _,
4961            poll_interval: _,
4962            status_stale_after: _,
4963            generation_deadline: _,
4964            show_submodules: _,
4965            fetch: _,
4966            auto_update: _,
4967        } = spec(Vec::new());
4968    }
4969
4970    fn root_of(dir: &tempfile::TempDir) -> PathBuf {
4971        dir.path().canonicalize().expect("canonicalize temp dir")
4972    }
4973
4974    /// Blocks until `core`'s launch Generation has settled, and hands back what it settled
4975    /// to.
4976    ///
4977    /// `Core::start`'s own first walk is that `Core`'s Generation 1 and probes every row it
4978    /// finds, so a test that counts what a later Generation did, or that watches a cell
4979    /// only its own Generation may write, has to begin from a table launch has already
4980    /// finished with. [`BACKSTOP`] rather than a budget, and the gate is read afterwards so
4981    /// an expired wait fails here by name instead of downstream as a wrong value.
4982    fn settle_launch(core: &Core) -> Snapshot {
4983        let launched = core.settle();
4984        assert_eq!(
4985            core.settle_gate_count_for_test(),
4986            0,
4987            "launch's own Generation never settled, so nothing after this is starting from \
4988             the point it claims to"
4989        );
4990        launched
4991    }
4992
4993    /// [`settle_launch`] over a `Core` built the ordinary way, for the many tests that want
4994    /// nothing else from the constructor.
4995    fn started_and_settled(spec: CoreSpec) -> (Core, Snapshot) {
4996        let core = Core::start_discovered(spec);
4997        let launched = settle_launch(&core);
4998        (core, launched)
4999    }
5000
5001    /// Sets every polled gitdir entry's modification time ten seconds into the past, so any
5002    /// write that follows reads as newer than the baseline by more than a filesystem's
5003    /// timestamp granularity. Without it a commit made microseconds after the baseline sweep
5004    /// lands in the same coarse tick on Linux and reads as no movement at all, which is a race
5005    /// in the harness rather than in the poll: real sweeps are a configured interval apart.
5006    /// Reads the polled names from [`poll::POLLED_GITDIR_ENTRIES`] rather than restating them.
5007    fn backdate_polled_entries(work_dir: &Path) {
5008        let gitdir = gitdir_of(work_dir);
5009
5010        let past = std::time::SystemTime::now() - Duration::from_secs(10);
5011        let mut touched = 0;
5012        for name in poll::POLLED_GITDIR_ENTRIES {
5013            let path = gitdir.join(name);
5014            if path.exists() {
5015                set_mtime_to(&path, past);
5016                touched += 1;
5017            }
5018        }
5019        assert!(
5020            touched > 0,
5021            "backdated nothing under {}; the gitdir holds none of the polled entries and the \
5022             baseline this sets up would not be older than what follows",
5023            gitdir.display()
5024        );
5025    }
5026
5027    /// `utimensat`, since a plain file handle cannot set a directory's time and `refs` is one.
5028    fn set_mtime_to(path: &Path, at: std::time::SystemTime) {
5029        use std::os::unix::ffi::OsStrExt;
5030
5031        let secs = at
5032            .duration_since(std::time::SystemTime::UNIX_EPOCH)
5033            .expect("a time after the epoch")
5034            .as_secs() as libc::time_t;
5035        let times = [
5036            libc::timespec {
5037                tv_sec: secs,
5038                tv_nsec: 0,
5039            },
5040            libc::timespec {
5041                tv_sec: secs,
5042                tv_nsec: 0,
5043            },
5044        ];
5045        let c_path =
5046            std::ffi::CString::new(path.as_os_str().as_bytes()).expect("a path with no NUL");
5047        let rc = unsafe { libc::utimensat(libc::AT_FDCWD, c_path.as_ptr(), times.as_ptr(), 0) };
5048        assert_eq!(
5049            rc,
5050            0,
5051            "set mtime on {}: {}",
5052            path.display(),
5053            std::io::Error::last_os_error()
5054        );
5055    }
5056
5057    fn step(argv: &[&str]) -> Step {
5058        Step {
5059            argv: argv.iter().map(|s| s.to_string()).collect(),
5060            shell: false,
5061            interactive: false,
5062            env: Vec::new(),
5063        }
5064    }
5065
5066    /// `shell = true`'s own convention: one argv element, the whole command string.
5067    fn shell_step(command: &str) -> Step {
5068        Step {
5069            argv: vec![command.to_string()],
5070            shell: true,
5071            interactive: false,
5072            env: Vec::new(),
5073        }
5074    }
5075
5076    /// `shell = true` plus `interactive = true`: the same convention, run through
5077    /// `$SHELL -ic` instead of `$SHELL -c`.
5078    fn interactive_shell_step(command: &str) -> Step {
5079        Step {
5080            argv: vec![command.to_string()],
5081            shell: true,
5082            interactive: true,
5083            env: Vec::new(),
5084        }
5085    }
5086
5087    /// The one entity's Action receipt, if the run that wrote it is the one `label` names:
5088    /// a run that replaced an earlier run's receipt on the same row is what these reads are
5089    /// distinguishing.
5090    fn receipt_labelled(core: &Core, key: &EntityKey, label: &str) -> Option<ActionReceipt> {
5091        core.snapshot()
5092            .entities
5093            .iter()
5094            .find(|entity| entity.key == *key)
5095            .and_then(|entity| entity.last_action.clone())
5096            .filter(|receipt| &*receipt.label == label)
5097    }
5098
5099    fn action(label: &str, steps: Vec<Step>) -> ActionSpec {
5100        ActionSpec {
5101            label: Arc::from(label),
5102            name: Some(Arc::from(label)),
5103            steps,
5104            concurrency: 4,
5105            when: None,
5106        }
5107    }
5108
5109    /// [`action`], narrowed by `when`, a Filter grammar predicate
5110    /// (`docs/spec/actions.md`'s "The Selection and the gate").
5111    fn action_with_when(label: &str, steps: Vec<Step>, when: &str) -> ActionSpec {
5112        ActionSpec {
5113            when: Some(Filter::parse(when)),
5114            ..action(label, steps)
5115        }
5116    }
5117
5118    /// End-to-end: the test thread never spawns anything itself, only calls
5119    /// `Core`'s public methods, and real branch data still lands in the snapshot.
5120    /// That is the proof that the core owns the threads doing the work, not the
5121    /// consumer.
5122    #[test]
5123    fn refresh_and_settle_populate_real_cells_without_the_caller_spawning_a_thread() {
5124        let dir = tempfile::tempdir().expect("temp dir");
5125        let root = root_of(&dir);
5126        let repo = root.join("repo");
5127        init_repo_with_a_commit(&repo);
5128
5129        let core = Core::start_discovered(spec(vec![root]));
5130        let keys: Vec<EntityKey> = core
5131            .snapshot()
5132            .entities
5133            .iter()
5134            .map(|entity| entity.key.clone())
5135            .collect();
5136        assert_eq!(keys.len(), 1);
5137
5138        core.refresh(&keys);
5139        let settled = core.settle();
5140
5141        let entity = &settled.entities[0];
5142        match entity.branch.settled() {
5143            Some(Settled::Known {
5144                value: Head::Branch { .. },
5145                at: _,
5146                stale: _,
5147            }) => {}
5148            other => panic!("expected an attached branch, got {other:?}"),
5149        }
5150    }
5151
5152    // --- Single source of truth: read the first-frame budgets from the spec itself,
5153    // the same pattern `executor.rs` already uses for its PTY width and capture bounds
5154    // against `docs/spec/actions.md`. ---
5155
5156    fn spec_refresh_md() -> String {
5157        let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
5158        std::fs::read_to_string(manifest_dir.join("../../docs/spec/refresh.md"))
5159            .expect("read docs/spec/refresh.md")
5160    }
5161
5162    fn spec_first_frame_budgets_ms(spec: &str) -> (u64, u64) {
5163        let anchor = "rows with names on screen within ";
5164        let after = spec
5165            .split(anchor)
5166            .nth(1)
5167            .expect("the first-frame budget sentence is present");
5168        let mut parts = after.splitn(2, "ms, every cheap column filled within ");
5169        let names: u64 = parts
5170            .next()
5171            .expect("a names-on-screen budget")
5172            .parse()
5173            .expect("the names-on-screen budget is an integer");
5174        let after_cheap = parts.next().expect("a cheap-column budget and beyond");
5175        let cheap_columns: u64 = after_cheap
5176            .split("ms,")
5177            .next()
5178            .expect("a cheap-column budget")
5179            .parse()
5180            .expect("the cheap-column budget is an integer");
5181        (names, cheap_columns)
5182    }
5183
5184    /// Criterion 1: the two budgets `refresh.md`'s "The first frame" states are declared
5185    /// once as named constants and cross-checked against the spec sentence here, so the
5186    /// spec and the code cannot drift apart silently.
5187    #[test]
5188    fn first_frame_budget_constants_match_the_spec_of_record() {
5189        let spec = spec_refresh_md();
5190        let (names_ms, cheap_columns_ms) = spec_first_frame_budgets_ms(&spec);
5191        assert_eq!(names_ms, FIRST_FRAME_NAMES_BUDGET_MS);
5192        assert_eq!(cheap_columns_ms, FIRST_FRAME_CHEAP_COLUMNS_BUDGET_MS);
5193    }
5194
5195    /// Criterion 2: every entity a Generation is dispatched over gets its phase C read,
5196    /// never a subset. `refresh.md`'s "Scope and order" makes scope never a partial dial,
5197    /// so this proves it against a population wide enough that a mistaken "first K" or
5198    /// "last K" scoping mistake would leave a visible gap: sixteen real repos, dispatched in
5199    /// one Generation, every one of them still `dirty: Known` once settled, position sixteen
5200    /// exactly as covered as position one. A mutation that scoped phase C to, say, the first
5201    /// ten dispatched entities fails this directly.
5202    #[test]
5203    fn every_dispatched_entity_gets_its_dirty_cell_settled_not_a_subset() {
5204        let dir = tempfile::tempdir().expect("temp dir");
5205        let root = root_of(&dir);
5206        const ENTITY_COUNT: usize = 16;
5207        for index in 0..ENTITY_COUNT {
5208            init_repo_with_a_commit(&root.join(format!("repo-{index}")));
5209        }
5210
5211        let core = Core::start_discovered(spec(vec![root]));
5212        let keys: Vec<EntityKey> = core
5213            .snapshot()
5214            .entities
5215            .iter()
5216            .map(|entity| entity.key.clone())
5217            .collect();
5218        assert_eq!(keys.len(), ENTITY_COUNT, "expected every repo discovered");
5219
5220        core.refresh(&keys);
5221        let settled = core.settle();
5222
5223        for entity in &settled.entities {
5224            assert!(
5225                matches!(
5226                    entity.dirty.settled(),
5227                    Some(Settled::Known {
5228                        value: _,
5229                        at: _,
5230                        stale: _
5231                    })
5232                ),
5233                "entity {:?} was left without a settled dirty cell, which is exactly what a \
5234                 visibility-scoped dispatch would leave behind on the entities it skipped: \
5235                 got {:?}",
5236                entity.name,
5237                entity.dirty.settled()
5238            );
5239        }
5240    }
5241
5242    /// refresh.md's "The first frame" budget (cheap columns filled within 200ms) is
5243    /// unreachable if the cheap outcomes wait behind phase C, so this proves the two
5244    /// applies are independent with a blocking seam rather than a sleep or a wall-clock
5245    /// deadline: `Core::hold_phase_c_for_test` holds phase C (and D) open after the cheap
5246    /// outcomes have already landed, and the test observes `branch` carrying this
5247    /// Generation's answer while `dirty` still carries the previous one. Run this against
5248    /// a version that bundles every outcome into one apply placed after phase C computes
5249    /// (this ticket's regression) and it fails, since nothing writes `branch` until that
5250    /// single bundled apply lands alongside `dirty`.
5251    ///
5252    /// Launch's own Generation is drained first and both cells are then moved, so each is
5253    /// read on the value it holds rather than on being blank: a table that has already
5254    /// been probed once is the only starting point available now that `Core::start` runs
5255    /// a Generation of its own, and reading values is the stronger claim anyway.
5256    #[test]
5257    fn cheap_outcomes_land_before_a_held_phase_c_settles() {
5258        let dir = tempfile::tempdir().expect("temp dir");
5259        let root = root_of(&dir);
5260        let repo = root.join("repo");
5261        init_repo_with_a_commit(&repo);
5262
5263        let (core, launched) = started_and_settled(spec(vec![root]));
5264        let key = launched.entities[0].key.clone();
5265        assert_eq!(
5266            dirty_total(&launched.entities[0]),
5267            0,
5268            "the fixture starts clean, which is the value the held phase C must still be \
5269             reading once the working tree below has moved"
5270        );
5271
5272        // One move per phase, so neither cell can be read on absence: `branch` is phase A
5273        // and must carry the new name while phase C is held, `dirty` is phase C and must
5274        // still carry launch's own clean count until it is released.
5275        git(&repo, &["checkout", "-b", "held"]);
5276        fs::write(repo.join("untracked.txt"), b"uncommitted")
5277            .expect("write an untracked file into the fixture");
5278
5279        core.hold_phase_c_for_test(&key);
5280        core.refresh(std::slice::from_ref(&key));
5281        core.wait_phase_c_landed_for_test(&key);
5282
5283        let mid_flight = core.snapshot();
5284        let entity = mid_flight
5285            .entities
5286            .iter()
5287            .find(|entity| entity.key == key)
5288            .expect("entity present");
5289        assert!(
5290            matches!(
5291                entity.branch.settled(),
5292                Some(Settled::Known {
5293                    value: Head::Branch { name, .. },
5294                    at: _,
5295                    stale: _
5296                }) if &**name == "held"
5297            ),
5298            "the cheap branch cell must carry this Generation's own answer while phase C is \
5299             still held open, got {:?}",
5300            entity.branch.settled()
5301        );
5302        assert!(
5303            entity.dirty.is_in_flight() && dirty_total(entity) == 0,
5304            "phase C is deliberately held open here; a bundled apply would already have \
5305             written this cell's new count alongside branch, got {:?}",
5306            entity.dirty.settled()
5307        );
5308
5309        core.release_phase_c_for_test(&key);
5310        core.wait_phase_c_finished_for_test(&key);
5311
5312        let settled = core.snapshot();
5313        let entity = settled
5314            .entities
5315            .iter()
5316            .find(|entity| entity.key == key)
5317            .expect("entity present");
5318        assert_eq!(
5319            dirty_total(entity),
5320            1,
5321            "phase C must settle its own count once released, got {:?}",
5322            entity.dirty.settled()
5323        );
5324    }
5325
5326    /// One entity's settled dirty count, or a panic naming what it read instead. Lets a
5327    /// test that has to distinguish two Generations by value say "still zero" and "now
5328    /// one" without repeating the match on every read.
5329    fn dirty_total(entity: &EntityState) -> u32 {
5330        match entity.dirty.settled() {
5331            Some(Settled::Known {
5332                value,
5333                at: _,
5334                stale: _,
5335            }) => value.total(),
5336            other => panic!("expected a settled dirty count, got {other:?}"),
5337        }
5338    }
5339
5340    /// Splitting one dispatched entity's write into a cheap apply and a phase C/D apply
5341    /// must still signal `settle_gate` exactly once per entity, or `settle` hangs (never
5342    /// decremented enough) or returns early (decremented twice). Two entities held open
5343    /// together prove the exact count at each step: a mutation that also decrements the
5344    /// gate from the cheap apply leaves it at 0 instead of 2 after both entities' cheap
5345    /// outcomes land, and a mutation that drops the decrement from the phase C/D apply
5346    /// leaves it at 2, never 1, once only the first entity finishes.
5347    #[test]
5348    fn splitting_the_probe_write_signals_settle_gate_exactly_once_per_entity() {
5349        let dir = tempfile::tempdir().expect("temp dir");
5350        let root = root_of(&dir);
5351        init_repo_with_a_commit(&root.join("a"));
5352        init_repo_with_a_commit(&root.join("b"));
5353
5354        let (core, snapshot) = started_and_settled(spec(vec![root]));
5355        let key_a = snapshot
5356            .entities
5357            .iter()
5358            .find(|entity| &*entity.name == "a")
5359            .expect("entity a present")
5360            .key
5361            .clone();
5362        let key_b = snapshot
5363            .entities
5364            .iter()
5365            .find(|entity| &*entity.name == "b")
5366            .expect("entity b present")
5367            .key
5368            .clone();
5369
5370        core.hold_phase_c_for_test(&key_a);
5371        core.hold_phase_c_for_test(&key_b);
5372        core.refresh(&[key_a.clone(), key_b.clone()]);
5373        // A Generation reserves its number on this thread and raises the gate on one of
5374        // its own, so this is the rendezvous that says the raise has happened. A join,
5375        // never a sleep.
5376        core.wait_dispatched_for_test();
5377        assert_eq!(
5378            core.settle_gate_count_for_test(),
5379            2,
5380            "dispatching two entities must add exactly two to the settle gate"
5381        );
5382
5383        core.wait_phase_c_landed_for_test(&key_a);
5384        core.wait_phase_c_landed_for_test(&key_b);
5385        assert_eq!(
5386            core.settle_gate_count_for_test(),
5387            2,
5388            "the cheap apply must never touch the settle gate: both entities' cheap \
5389             outcomes have landed and neither has finished phase C yet"
5390        );
5391
5392        core.release_phase_c_for_test(&key_a);
5393        core.wait_phase_c_finished_for_test(&key_a);
5394        assert_eq!(
5395            core.settle_gate_count_for_test(),
5396            1,
5397            "exactly one entity finished, so the gate must fall by exactly one, not two \
5398             (double-counted) and not zero (left short)"
5399        );
5400
5401        core.release_phase_c_for_test(&key_b);
5402        core.wait_phase_c_finished_for_test(&key_b);
5403        assert_eq!(
5404            core.settle_gate_count_for_test(),
5405            0,
5406            "both entities finished, so the gate must be fully drained"
5407        );
5408    }
5409
5410    /// The gate [`Core::hold_phase_c_for_test`] last registered for `key`, so a test can
5411    /// still name one a later registration for the same entity has replaced in the map.
5412    fn registered_gate(core: &Core, key: &EntityKey) -> PhaseCGateHandle {
5413        core.phase_c_gates
5414            .lock()
5415            .unwrap()
5416            .get(key)
5417            .cloned()
5418            .expect("hold_phase_c_for_test must be called before reading its gate")
5419    }
5420
5421    /// Opens `gate` directly rather than through [`Core::release_phase_c_for_test`], which
5422    /// resolves by key and so cannot name a gate a later registration has replaced.
5423    fn release_gate(gate: &PhaseCGateHandle) {
5424        let (lock, cvar) = &**gate;
5425        lock.lock().unwrap().may_proceed = true;
5426        cvar.notify_all();
5427    }
5428
5429    fn gate_is_finished(gate: &PhaseCGateHandle) -> bool {
5430        gate.0.lock().unwrap().finished
5431    }
5432
5433    /// A probe signals the phase C gate its own Generation was dispatched against, never
5434    /// whatever gate the map holds by the time that probe finishes.
5435    ///
5436    /// Reading the map twice per probe, once before phase C and once after, made the gate
5437    /// a probe signalled a function of when it got there: a probe from an already-settled
5438    /// Generation, past its own first read but not yet past its second, would find a gate
5439    /// registered in between and mark it finished, so the wait a later Generation was
5440    /// making returned before that Generation had applied anything or touched the settle
5441    /// gate. Registering a second gate for the same entity while the first is still held
5442    /// open is that interleaving with the timing taken out of it: the parked probe took
5443    /// the first gate, and the map holds the second by the time it finishes.
5444    #[test]
5445    fn a_probe_signals_the_phase_c_gate_its_own_generation_was_dispatched_against() {
5446        let dir = tempfile::tempdir().expect("temp dir");
5447        let root = root_of(&dir);
5448        init_repo_with_a_commit(&root.join("repo"));
5449
5450        let (core, launched) = started_and_settled(spec(vec![root]));
5451        let key = launched.entities[0].key.clone();
5452
5453        core.hold_phase_c_for_test(&key);
5454        let dispatched_against = registered_gate(&core, &key);
5455        core.refresh(std::slice::from_ref(&key));
5456        core.wait_phase_c_landed_for_test(&key);
5457
5458        core.hold_phase_c_for_test(&key);
5459        let registered_later = registered_gate(&core, &key);
5460        release_gate(&dispatched_against);
5461
5462        wait_for(
5463            "the held probe to signal the gate its own Generation was dispatched against",
5464            || gate_is_finished(&dispatched_against),
5465        );
5466        assert!(
5467            !gate_is_finished(&registered_later),
5468            "a gate registered after this Generation dispatched must never be marked \
5469             finished by it: a test waiting on that gate would return before this \
5470             Generation had applied its outcome or decremented the settle gate"
5471        );
5472    }
5473
5474    /// A probe finishing clears its own Generation's in-flight entry, never whatever the
5475    /// table holds under that key by the time it gets there.
5476    ///
5477    /// Cancellation is cooperative (refresh.md's "Cancellation"), so a superseded probe
5478    /// runs to completion and reaches `apply_probe_outcome` after the Generation that
5479    /// superseded it has already put its own entry under the same key. Clearing by key
5480    /// alone deleted that live entry, and refresh.md's "Supersession" then had nothing to
5481    /// set: the Generation after it found no previous entry, so the entity's interrupt
5482    /// flag stayed false and its probe ran on uncancelled, which is the 1.79x ADR 0013
5483    /// measured. Parking a probe at its phase C gate and superseding it while it is held
5484    /// is that interleaving with the timing taken out of it.
5485    #[test]
5486    fn a_probe_finishing_clears_only_its_own_generations_in_flight_entry() {
5487        let dir = tempfile::tempdir().expect("temp dir");
5488        let root = root_of(&dir);
5489        init_repo_with_a_commit(&root.join("repo"));
5490
5491        let (core, launched) = started_and_settled(spec(vec![root]));
5492        let key = launched.entities[0].key.clone();
5493
5494        core.hold_phase_c_for_test(&key);
5495        core.refresh(std::slice::from_ref(&key));
5496        core.wait_phase_c_landed_for_test(&key);
5497
5498        // The Generation that supersedes the parked probe, holding the interrupt flag the
5499        // `refresh` below has to be able to find and set.
5500        let superseding = core.begin_shared_generation_for_test(std::slice::from_ref(&key));
5501
5502        core.release_phase_c_for_test(&key);
5503        core.wait_phase_c_finished_for_test(&key);
5504
5505        core.refresh(std::slice::from_ref(&key));
5506        core.wait_dispatched_for_test();
5507
5508        assert!(
5509            superseding.cancels[&key].load(Ordering::Acquire),
5510            "a probe from a Generation that has already been superseded must leave the \
5511             live Generation's in-flight entry alone, or the Generation after it has \
5512             nothing to interrupt"
5513        );
5514    }
5515
5516    /// Criterion 5, the honest half: a concurrent pool's *completion* order is not
5517    /// dispatch order and asserting it would make this test flaky in exact proportion to
5518    /// how well rayon's scheduler works, so this asserts *dispatch* order instead, which is
5519    /// deterministic because `refresh`'s own dispatch loop is a single sequential pass over
5520    /// `order` that spawns work without ever waiting on it. `dispatch_order` itself, the
5521    /// function that actually builds the cursor-then-visible-then-rest sequence
5522    /// `refresh.md`'s "Scope and order" names, lives in the `repon` crate and is tested
5523    /// there: `core-api.md`'s ownership table gives that computation to the consumer, never
5524    /// to this crate. What this test proves on the core side is the half core-api.md commits
5525    /// to: `refresh` dispatches in exactly the order it is handed, position for position,
5526    /// never reordered by any heuristic of its own (never, per `refresh.md`, by predicted
5527    /// cost). A hand-built three-tier order stands in for what `dispatch_order` would
5528    /// produce, six entities discovered, one named cursor, two named visible, three left
5529    /// over in discovery order.
5530    #[test]
5531    fn refresh_dispatches_phase_c_in_exactly_the_order_it_is_given() {
5532        let dir = tempfile::tempdir().expect("temp dir");
5533        let root = root_of(&dir);
5534        const ENTITY_COUNT: usize = 6;
5535        for index in 0..ENTITY_COUNT {
5536            init_repo_with_a_commit(&root.join(format!("repo-{index}")));
5537        }
5538
5539        let (core, launched) = started_and_settled(spec(vec![root]));
5540        let discovery_order: Vec<EntityKey> = launched
5541            .entities
5542            .iter()
5543            .map(|entity| entity.key.clone())
5544            .collect();
5545        assert_eq!(
5546            discovery_order.len(),
5547            ENTITY_COUNT,
5548            "expected every repo discovered"
5549        );
5550
5551        // The cursor row, then the visible rows (never the cursor's own row twice), then
5552        // everything else in discovery order: refresh.md's own three tiers, hand-assembled
5553        // the way `dispatch_order` would.
5554        let cursor = discovery_order[3].clone();
5555        let visible = [discovery_order[1].clone(), discovery_order[4].clone()];
5556        let mut three_tier_order = vec![cursor.clone()];
5557        three_tier_order.extend(visible.iter().cloned());
5558        for key in &discovery_order {
5559            if *key != cursor && !visible.contains(key) {
5560                three_tier_order.push(key.clone());
5561            }
5562        }
5563        assert_eq!(
5564            three_tier_order.len(),
5565            ENTITY_COUNT,
5566            "sanity check: the hand-built order must cover every discovered entity exactly \
5567             once"
5568        );
5569
5570        core.refresh(&three_tier_order);
5571        core.settle();
5572
5573        assert_eq!(
5574            core.dispatch_log_for_test(),
5575            three_tier_order,
5576            "refresh must dispatch phase C in exactly the order it was given: the cursor \
5577             row, then the visible rows, then the rest in discovery order"
5578        );
5579    }
5580
5581    /// The defining behaviour for the shared-handle probe path: discovery leaves
5582    /// one thread-safe handle per entity, and a `refresh` reuses that same `Arc`
5583    /// rather than opening the repository again, proven by pointer identity
5584    /// surviving a probe rather than by inference from timing.
5585    #[test]
5586    fn refresh_reuses_the_cached_repository_handle_rather_than_reopening_it() {
5587        let dir = tempfile::tempdir().expect("temp dir");
5588        let root = root_of(&dir);
5589        let repo = root.join("repo");
5590        init_repo_with_a_commit(&repo);
5591
5592        let core = Core::start_discovered(spec(vec![root]));
5593        let key = core.snapshot().entities[0].key.clone();
5594        let before = core
5595            .cached_repo_handle_for_test(&key)
5596            .expect("discovery should have cached a handle");
5597
5598        core.refresh(std::slice::from_ref(&key));
5599        core.settle();
5600
5601        let after = core
5602            .cached_repo_handle_for_test(&key)
5603            .expect("the cached handle should still be there after a refresh");
5604        assert!(
5605            Arc::ptr_eq(&before, &after),
5606            "a refresh must reuse the cached handle, not replace it with a new one"
5607        );
5608    }
5609
5610    /// `refresh_running` reads true from the instant `refresh` returns, before its spawned
5611    /// dispatch has raised a single probe: `refresh` reserves the Generation and records the
5612    /// dispatch debt on the calling thread, so a caller reading this the same frame it
5613    /// dispatched must never see a false "nothing outstanding". It reads false again once
5614    /// the Generation has fully landed.
5615    #[test]
5616    fn refresh_running_reads_true_the_instant_refresh_returns_and_false_once_it_settles() {
5617        let dir = tempfile::tempdir().expect("temp dir");
5618        let root = root_of(&dir);
5619        init_repo_with_a_commit(&root.join("repo"));
5620
5621        let core = Core::start_discovered(spec(vec![root]));
5622        core.settle();
5623        assert!(
5624            !core.refresh_running(),
5625            "sanity: nothing outstanding once startup has settled"
5626        );
5627
5628        let keys: Vec<EntityKey> = core
5629            .snapshot()
5630            .entities
5631            .iter()
5632            .map(|entity| entity.key.clone())
5633            .collect();
5634        core.refresh(&keys);
5635        assert!(
5636            core.refresh_running(),
5637            "refresh reserves its Generation and records the dispatch debt before it \
5638             returns, so this must already read true"
5639        );
5640
5641        core.settle();
5642        assert!(
5643            !core.refresh_running(),
5644            "settle blocks until nothing is outstanding, so this must read false once it \
5645             returns"
5646        );
5647    }
5648
5649    /// A key with no cached handle, either because it was never discovered or
5650    /// because discovery could not open it, still gets a real answer: the probe
5651    /// falls back to opening the repository itself rather than failing outright.
5652    #[test]
5653    fn probing_a_key_with_no_cached_handle_still_opens_the_repository_itself() {
5654        let dir = tempfile::tempdir().expect("temp dir");
5655        let root = root_of(&dir);
5656        let repo = root.join("repo");
5657        init_repo_with_a_commit(&repo);
5658
5659        // A core discovering an unrelated, empty root, so `repo` is never cached.
5660        let empty_root = root_of(&tempfile::tempdir().expect("temp dir"));
5661        let core = Core::start_discovered(spec(vec![empty_root]));
5662        let key = EntityKey::new(Arc::from(repo.as_path()));
5663        assert!(core.cached_repo_handle_for_test(&key).is_none());
5664
5665        let entity = core.probe_now(&key);
5666
5667        assert!(matches!(
5668            entity.branch.settled(),
5669            Some(Settled::Known {
5670                value: Head::Branch { .. },
5671                at: _,
5672                stale: _
5673            })
5674        ));
5675    }
5676
5677    /// An empty order names no key, so the Generation it starts must reach no entity at
5678    /// all. Read off the dispatch log and the in-flight flag rather than off an unprobed
5679    /// cell, since launch's own Generation has already filled every cell by here.
5680    #[test]
5681    fn an_empty_order_dispatches_nothing_and_settle_returns_immediately() {
5682        let dir = tempfile::tempdir().expect("temp dir");
5683        let root = root_of(&dir);
5684        let repo = root.join("repo");
5685        init_repo_with_a_commit(&repo);
5686
5687        let (core, _launched) = started_and_settled(spec(vec![root]));
5688        assert!(
5689            !core.dispatch_log_for_test().is_empty(),
5690            "launch dispatched nothing, so an empty log below would say nothing about the \
5691             empty order"
5692        );
5693
5694        core.refresh(&[]);
5695        core.wait_dispatched_for_test();
5696
5697        assert_eq!(
5698            core.dispatch_log_for_test(),
5699            Vec::new(),
5700            "an empty order must dispatch no probe"
5701        );
5702        // The number is the claim here, not a backstop: an order naming nobody raises no
5703        // probe, so the gate is already at zero and this must come back settled at once
5704        // rather than eventually.
5705        let settled = core
5706            .try_settle(Duration::from_millis(50))
5707            .expect("an empty order raises no probe, so the settle gate is already at zero");
5708        assert!(!settled.entities[0].branch.is_in_flight());
5709    }
5710
5711    /// One entity left owing a probe that nothing will ever complete: no tick is sent, so
5712    /// the deadline sweep that would otherwise time the cell out never runs, and the settle
5713    /// gate stays above zero for as long as anyone waits on it.
5714    ///
5715    /// Returns the live `Core` and the tick sender, which the caller must hold: dropping it
5716    /// stops the dedicated thread's own select arm, and a `Core` whose thread has gone is a
5717    /// different fixture from the one these waits mean to test.
5718    fn one_probe_owed_that_never_lands(
5719        dir: &tempfile::TempDir,
5720    ) -> (Core, crossbeam_channel::Sender<Instant>) {
5721        let root = root_of(dir);
5722        init_repo_with_a_commit(&root.join("repo"));
5723        let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
5724        let core = Core::start_for_test(spec(vec![root]), Duration::from_secs(3600), tick_rx)
5725            .discovered()
5726            .core;
5727        let key = settle_launch(&core).entities[0].key.clone();
5728        core.begin_untracked_probe_for_test(&key);
5729        (core, tick_tx)
5730    }
5731
5732    /// The defect this pair exists for: a settle that gives up used to be indistinguishable
5733    /// from one that succeeded, so the table it handed back was read as an answer and the
5734    /// run failed several steps downstream with nothing left naming the wait.
5735    ///
5736    /// [`Core::settle`]'s half is to report at the wait, the way `liveness::wait_for` does.
5737    /// Driven through `settle_within` rather than `settle` so the expiry path is exercised
5738    /// without waiting out a real backstop.
5739    #[test]
5740    #[should_panic(expected = "waiting for everything this Core has in flight to land")]
5741    fn a_settle_that_expires_reports_at_the_wait_rather_than_returning_the_table() {
5742        let dir = tempfile::tempdir().expect("temp dir");
5743        let (core, _tick_tx) = one_probe_owed_that_never_lands(&dir);
5744
5745        core.settle_within(Duration::from_millis(20));
5746    }
5747
5748    /// [`Core::try_settle`]'s half of the same claim, for the callers that mean to degrade
5749    /// rather than fail: the expiry comes back as `Err`, so the unsettled table can only be
5750    /// reached by a caller that has already acknowledged the wait gave up.
5751    #[test]
5752    fn try_settle_hands_an_expiry_back_as_an_error_carrying_the_table_it_gave_up_on() {
5753        let dir = tempfile::tempdir().expect("temp dir");
5754        let (core, _tick_tx) = one_probe_owed_that_never_lands(&dir);
5755
5756        let unsettled = core
5757            .try_settle(Duration::from_millis(20))
5758            .expect_err("a probe nothing will ever complete cannot settle");
5759
5760        assert!(
5761            unsettled.entities[0].branch.is_in_flight(),
5762            "the Err arm must still carry the table as it stood, so a caller that degrades \
5763             deliberately has something to degrade with"
5764        );
5765    }
5766
5767    /// The other arm, so the two are told apart by what actually happened rather than by
5768    /// `Err` being the only reachable answer.
5769    #[test]
5770    fn try_settle_hands_a_generation_that_really_landed_back_as_ok() {
5771        let dir = tempfile::tempdir().expect("temp dir");
5772        let root = root_of(&dir);
5773        init_repo_with_a_commit(&root.join("repo"));
5774
5775        let (core, launched) = started_and_settled(spec(vec![root]));
5776        let key = launched.entities[0].key.clone();
5777        core.refresh(std::slice::from_ref(&key));
5778
5779        let settled = core
5780            .try_settle(BACKSTOP)
5781            .expect("a dispatched Generation must land inside the backstop");
5782
5783        assert!(!settled.entities[0].branch.is_in_flight());
5784    }
5785
5786    /// A Launcher return re-probes one entity through `probe_now`, so every cell a
5787    /// Generation settles must settle here too. `sync` is the one most recently added and
5788    /// the one a merge is most likely to drop, since no other test reads it off this path.
5789    #[test]
5790    fn probe_now_settles_the_sync_cell_as_well_as_the_branch_it_depends_on() {
5791        let dir = tempfile::tempdir().expect("temp dir");
5792        let root = root_of(&dir);
5793        let repo = root.join("repo");
5794        init_repo_with_a_commit(&repo);
5795
5796        let core = Core::start_discovered(spec(vec![root]));
5797        let key = core.snapshot().entities[0].key.clone();
5798
5799        let entity = core.probe_now(&key);
5800
5801        assert!(
5802            matches!(
5803                entity.sync.settled(),
5804                Some(Settled::Known {
5805                    value: SyncState::NoRemote,
5806                    at: _,
5807                    stale: _
5808                })
5809            ),
5810            "expected probe_now to settle sync, got {:?}",
5811            entity.sync.settled()
5812        );
5813    }
5814
5815    /// The same guard as the `sync` one above, for `base`: `probe_now` must settle it
5816    /// too, not only the dispatch loop `refresh` drives.
5817    #[test]
5818    fn probe_now_settles_the_base_cell_as_well_as_the_branch_it_depends_on() {
5819        let dir = tempfile::tempdir().expect("temp dir");
5820        let root = root_of(&dir);
5821        let repo = root.join("repo");
5822        init_repo_with_a_commit(&repo);
5823
5824        let core = Core::start_discovered(spec(vec![root]));
5825        let key = core.snapshot().entities[0].key.clone();
5826
5827        let entity = core.probe_now(&key);
5828
5829        assert!(
5830            matches!(entity.base.settled(), Some(Settled::NotApplicable)),
5831            "expected probe_now to settle base Not applicable for a Repo with no remote, \
5832             got {:?}",
5833            entity.base.settled()
5834        );
5835    }
5836
5837    /// The end-to-end wiring `probe_now`'s own guard above cannot prove: a real
5838    /// `refresh` dispatch, through `CheapProbeOutcomes`, must land a genuine
5839    /// computed `base` count on the table, not just a Not-applicable fallback.
5840    #[test]
5841    fn refresh_settles_a_real_base_count_against_the_resolved_default_branch() {
5842        let dir = tempfile::tempdir().expect("temp dir");
5843        let root = root_of(&dir);
5844        let repo = root.join("repo");
5845        init_repo_with_a_commit(&repo);
5846        git(
5847            &repo,
5848            &[
5849                "remote",
5850                "add",
5851                "origin",
5852                "https://example.invalid/repo.git",
5853            ],
5854        );
5855        let root_sha = head_sha(&repo);
5856        // The default branch (`origin/main`, resolved through rung 3's name list
5857        // since no `origin/HEAD` exists) moves one commit ahead of this Repo's own
5858        // checked-out branch, which never gets its own upstream configured, so
5859        // `sync` reads `-` while `base` still has a resolved default branch to
5860        // count behind.
5861        git(&repo, &["commit", "--allow-empty", "-m", "second"]);
5862        let tip_sha = head_sha(&repo);
5863        git(&repo, &["reset", "--hard", &root_sha]);
5864        git(&repo, &["update-ref", "refs/remotes/origin/main", &tip_sha]);
5865
5866        let core = Core::start_discovered(spec(vec![root]));
5867        let key = core.snapshot().entities[0].key.clone();
5868
5869        core.refresh(std::slice::from_ref(&key));
5870        let settled = core.settle();
5871
5872        assert!(
5873            matches!(
5874                settled.entities[0].base.settled(),
5875                Some(Settled::Known {
5876                    value: 1,
5877                    at: _,
5878                    stale: _
5879                })
5880            ),
5881            "expected a real refresh to settle base's live count against the resolved \
5882             default branch, got {:?}",
5883            settled.entities[0].base.settled()
5884        );
5885    }
5886
5887    /// The same guard as the `sync` one above, for `dirty`: it is the cell most recently
5888    /// added to this path, and dropping its settle here leaves every other test green.
5889    /// The repo carries one untracked file so a settled cell has to hold the counted
5890    /// value, not a zeroed placeholder that a default-constructed `DirtyCounts` would
5891    /// also satisfy.
5892    #[test]
5893    fn probe_now_settles_the_dirty_cell_with_the_counts_it_probed() {
5894        let dir = tempfile::tempdir().expect("temp dir");
5895        let root = root_of(&dir);
5896        let repo = root.join("repo");
5897        init_repo_with_a_commit(&repo);
5898        fs::write(repo.join("untracked.txt"), "x").expect("write untracked file");
5899
5900        let core = Core::start_discovered(spec(vec![root]));
5901        let key = core.snapshot().entities[0].key.clone();
5902
5903        let entity = core.probe_now(&key);
5904
5905        assert!(
5906            matches!(
5907                entity.dirty.settled(),
5908                Some(Settled::Known {
5909                    value: DirtyCounts {
5910                        modified: 0,
5911                        untracked: 1,
5912                        deleted: 0,
5913                    },
5914                    at: _,
5915                    stale: _
5916                })
5917            ),
5918            "expected probe_now to settle dirty with the one untracked path, got {:?}",
5919            entity.dirty.settled()
5920        );
5921    }
5922
5923    #[test]
5924    fn probe_now_updates_the_entity_synchronously_with_no_refresh_call() {
5925        let dir = tempfile::tempdir().expect("temp dir");
5926        let root = root_of(&dir);
5927        let repo = root.join("repo");
5928        init_repo_with_a_commit(&repo);
5929
5930        let core = Core::start_discovered(spec(vec![root]));
5931        let key = core.snapshot().entities[0].key.clone();
5932
5933        let entity = core.probe_now(&key);
5934
5935        assert!(matches!(
5936            entity.branch.settled(),
5937            Some(Settled::Known {
5938                value: Head::Branch { .. },
5939                at: _,
5940                stale: _
5941            })
5942        ));
5943    }
5944
5945    /// The one-function guarantee: whether an entity's name is set by discovery at
5946    /// `Core::start` or by `probe_now`'s fallback insert for a key the table did
5947    /// not already know, both routes must produce the same string for the same
5948    /// path, since a future state file keys the Selection by this name and a
5949    /// second formatting of it would silently break restoring by name.
5950    #[test]
5951    fn the_display_name_agrees_between_discovery_and_probe_nows_fallback_insert() {
5952        let dir = tempfile::tempdir().expect("temp dir");
5953        let root = root_of(&dir);
5954        let repo = root.join("named-repo");
5955        init_repo_with_a_commit(&repo);
5956
5957        let core = Core::start_discovered(spec(vec![root]));
5958        let discovered = core.snapshot().entities[0].clone();
5959        assert_eq!(&*discovered.name, "named-repo");
5960
5961        core.dismiss(&discovered.key);
5962        assert!(core.snapshot().entities.is_empty());
5963
5964        let reinserted = core.probe_now(&discovered.key);
5965
5966        assert_eq!(
5967            reinserted.name, discovered.name,
5968            "the name discovery assigned and the name probe_now's fallback insert \
5969             assigns for the same path must be byte-identical"
5970        );
5971    }
5972
5973    #[test]
5974    fn dismiss_removes_the_entity_from_the_snapshot() {
5975        let dir = tempfile::tempdir().expect("temp dir");
5976        let root = root_of(&dir);
5977        let repo = root.join("repo");
5978        init_repo_with_a_commit(&repo);
5979
5980        let core = Core::start_discovered(spec(vec![root]));
5981        let key = core.snapshot().entities[0].key.clone();
5982
5983        core.dismiss(&key);
5984
5985        assert!(core.snapshot().entities.is_empty());
5986    }
5987
5988    /// Foundation for every criterion below: one entity's own steps run in order and a
5989    /// failure marks every later step `NotRun` rather than silently skipping it or
5990    /// running it anyway, exactly the closed set of four outcomes
5991    /// [config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md)'s
5992    /// "Actions" and `docs/spec/actions.md`'s "Step outcomes" both fix.
5993    ///
5994    /// The third step would succeed if it ran (`true` always exits zero), so its being
5995    /// stopped is what this test observes, not an accident of a step that would have
5996    /// failed anyway. It also writes a marker file rather than only exiting zero: a
5997    /// receipt correctly labelled `NotRun` is not, by itself, proof the step never ran
5998    /// (an implementation could execute a step and then paper over its result), so the
5999    /// missing file is evidence the receipt cannot fake.
6000    #[test]
6001    fn an_entitys_steps_run_in_order_and_a_failure_marks_every_later_step_not_run() {
6002        let dir = tempfile::tempdir().expect("temp dir");
6003        let root = root_of(&dir);
6004        let repo = root.join("repo");
6005        init_repo_with_a_commit(&repo);
6006        let marker = repo.join("step-three-ran");
6007
6008        let core = Core::start_discovered(spec(vec![root]));
6009        let key = core.snapshot().entities[0].key.clone();
6010        let steps = vec![
6011            step(&["true"]),
6012            step(&["sh", "-c", "exit 7"]),
6013            step(&["touch", "step-three-ran"]),
6014        ];
6015
6016        let started = core.run_action(action("reinstall", steps), std::slice::from_ref(&key));
6017
6018        assert!(started);
6019        wait_for("the fan-out to finish and write a receipt", || {
6020            !core.action_running()
6021        });
6022        let receipt = core.snapshot().entities[0]
6023            .last_action
6024            .clone()
6025            .expect("receipt written");
6026        assert_eq!(receipt.steps.len(), 3);
6027        assert_eq!(receipt.steps[0].outcome, StepOutcome::Ok);
6028        assert_eq!(receipt.steps[1].outcome, StepOutcome::Failed(7));
6029        assert_eq!(
6030            receipt.steps[2].outcome,
6031            StepOutcome::NotRun,
6032            "a step after a failure must be recorded NotRun, not silently dropped or run anyway"
6033        );
6034        assert!(
6035            !marker.exists(),
6036            "the third step's own `touch` must never have run: its marker file exists, so \
6037             the step ran despite being recorded NotRun"
6038        );
6039    }
6040
6041    /// Independent of stopping at a failure: three always-succeeding steps each append
6042    /// their own digit to the same file, so the file's final content pins the actual
6043    /// execution order rather than trusting that a linear scan of `action.steps` runs
6044    /// them in the sequence they were declared in.
6045    #[test]
6046    fn steps_run_in_the_order_theyre_declared_not_some_other_order() {
6047        let dir = tempfile::tempdir().expect("temp dir");
6048        let root = root_of(&dir);
6049        let repo = root.join("repo");
6050        init_repo_with_a_commit(&repo);
6051        let order_log = repo.join("order.log");
6052
6053        let core = Core::start_discovered(spec(vec![root]));
6054        let key = core.snapshot().entities[0].key.clone();
6055        let steps = vec![
6056            step(&["sh", "-c", "printf 1 >> order.log"]),
6057            step(&["sh", "-c", "printf 2 >> order.log"]),
6058            step(&["sh", "-c", "printf 3 >> order.log"]),
6059        ];
6060
6061        let started = core.run_action(action("ordering", steps), std::slice::from_ref(&key));
6062
6063        assert!(started);
6064        wait_for("the fan-out to finish and write a receipt", || {
6065            !core.action_running()
6066        });
6067        let receipt = core.snapshot().entities[0]
6068            .last_action
6069            .clone()
6070            .expect("receipt written");
6071        assert_eq!(receipt.steps.len(), 3);
6072        assert!(
6073            receipt
6074                .steps
6075                .iter()
6076                .all(|result| result.outcome == StepOutcome::Ok),
6077            "every step here always exits zero; this test isolates ordering from gating"
6078        );
6079        let content = fs::read_to_string(&order_log).expect("order.log written by the steps");
6080        assert_eq!(
6081            content, "123",
6082            "the file's content pins actual execution order; running the steps out of \
6083             declaration order would produce a different digit sequence here even though \
6084             every step still succeeds"
6085        );
6086    }
6087
6088    /// `docs/spec/actions.md`'s "The run on screen": a reader must see a step's own
6089    /// finished output "as it arrives", not only once the whole entity's run has ended.
6090    /// The second step sleeps long enough to give a poll a real window to observe the
6091    /// receipt mid-run; a version of `run_action_for_entity` that only wrote once, at the
6092    /// end, would never let this test observe `running: Some(_)` at all; it would either
6093    /// see no receipt (before) or the whole finished one (after), never the state in
6094    /// between where the first step is done and the second is still going.
6095    #[test]
6096    fn a_still_running_actions_finished_step_and_its_currently_executing_one_are_both_visible_before_the_whole_run_ends()
6097     {
6098        let dir = tempfile::tempdir().expect("temp dir");
6099        let root = root_of(&dir);
6100        let repo = root.join("repo");
6101        init_repo_with_a_commit(&repo);
6102
6103        let core = Core::start_discovered(spec(vec![root]));
6104        let key = core.snapshot().entities[0].key.clone();
6105        let steps = vec![step(&["true"]), step(&["sh", "-c", "sleep 0.5"])];
6106
6107        let started = core.run_action(action("reinstall", steps), std::slice::from_ref(&key));
6108        assert!(started);
6109
6110        // Waits specifically for the *second* step's own running receipt, not merely any
6111        // one: under a slow or busy machine the first step (`true`) can still be the one
6112        // reported running the first time this poll checks, which would assert the wrong
6113        // step's own shape below rather than a flaky pass.
6114        wait_for(
6115            "a receipt naming the second step running before the run finished",
6116            || {
6117                core.snapshot().entities[0]
6118                    .last_action
6119                    .as_ref()
6120                    .and_then(|receipt| receipt.running.as_ref())
6121                    .is_some_and(|running| running.label.contains("sleep"))
6122            },
6123        );
6124        let mid_run = core.snapshot().entities[0]
6125            .last_action
6126            .clone()
6127            .expect("receipt written");
6128        assert_eq!(
6129            mid_run.steps.len(),
6130            1,
6131            "the first, already-finished step must already be in `steps`"
6132        );
6133        assert_eq!(mid_run.steps[0].outcome, StepOutcome::Ok);
6134        let running = mid_run.running.expect("a step must be recorded running");
6135        assert!(
6136            running.label.contains("sleep"),
6137            "expected the running step's own label, got {:?}",
6138            running.label
6139        );
6140
6141        wait_for("the fan-out to finish", || !core.action_running());
6142        let finished = core.snapshot().entities[0]
6143            .last_action
6144            .clone()
6145            .expect("receipt written");
6146        assert!(
6147            finished.running.is_none(),
6148            "a finished receipt must carry no running step"
6149        );
6150        assert_eq!(finished.steps.len(), 2);
6151    }
6152
6153    /// `Step::shell` must actually reach the child, end to end through `run_action`,
6154    /// not merely be a field that parses. Prints `$0` inside the step's own
6155    /// command string: `sh -c <string>` with no third argument would leave `$0` reading
6156    /// whatever the shell defaults it to, never the literal `repon`
6157    /// [config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md)'s
6158    /// `shell = true` sentence requires. `executor.rs`'s own unit tests cover `run_step`
6159    /// directly; this proves `core.rs` actually sets `shell` on the `Step` it builds and
6160    /// passes it through.
6161    #[test]
6162    fn a_shell_true_step_runs_through_shell_c_with_repon_as_its_own_dollar_zero() {
6163        let dir = tempfile::tempdir().expect("temp dir");
6164        let root = root_of(&dir);
6165        let repo = root.join("repo");
6166        init_repo_with_a_commit(&repo);
6167
6168        let core = Core::start_discovered(spec(vec![root]));
6169        let key = core.snapshot().entities[0].key.clone();
6170        let steps = vec![shell_step("echo \"[$0]\"")];
6171
6172        let started = core.run_action(action("shell-step", steps), std::slice::from_ref(&key));
6173
6174        assert!(started);
6175        wait_for("the fan-out to finish and write a receipt", || {
6176            !core.action_running()
6177        });
6178        let receipt = core.snapshot().entities[0]
6179            .last_action
6180            .clone()
6181            .expect("receipt written");
6182        assert_eq!(receipt.steps.len(), 1);
6183        assert_eq!(receipt.steps[0].outcome, StepOutcome::Ok);
6184        assert_eq!(&*receipt.steps[0].output, b"[repon]\n");
6185        assert!(
6186            receipt.steps[0].shell,
6187            "the receipt's own StepResult::shell must carry the mode the step ran under"
6188        );
6189    }
6190
6191    /// `Step::interactive` must actually reach `run_step` end to end through `run_action`,
6192    /// the same proof `a_shell_true_step_runs_through_shell_c_with_repon_as_its_own_dollar_zero`
6193    /// already gives `shell`: this asserts `core.rs` sets `interactive` on the `Step` it
6194    /// builds and that the receipt carries it back, not the shell's own rc-sourcing
6195    /// behaviour, which `executor.rs`'s own `shell_argv` unit test already covers on the
6196    /// constructed argv.
6197    #[test]
6198    fn an_interactive_shell_true_step_runs_through_run_action_with_interactive_on_its_receipt() {
6199        let dir = tempfile::tempdir().expect("temp dir");
6200        let root = root_of(&dir);
6201        let repo = root.join("repo");
6202        init_repo_with_a_commit(&repo);
6203
6204        let core = Core::start_discovered(spec(vec![root]));
6205        let key = core.snapshot().entities[0].key.clone();
6206        let steps = vec![interactive_shell_step("true")];
6207
6208        let started = core.run_action(
6209            action("interactive-step", steps),
6210            std::slice::from_ref(&key),
6211        );
6212
6213        assert!(started);
6214        wait_for("the fan-out to finish and write a receipt", || {
6215            !core.action_running()
6216        });
6217        let receipt = core.snapshot().entities[0]
6218            .last_action
6219            .clone()
6220            .expect("receipt written");
6221        assert_eq!(receipt.steps.len(), 1);
6222        assert_eq!(receipt.steps[0].outcome, StepOutcome::Ok);
6223        assert!(
6224            receipt.steps[0].shell,
6225            "an interactive step is still a shell step"
6226        );
6227        assert!(
6228            receipt.steps[0].interactive,
6229            "the receipt's own StepResult::interactive must carry the mode the step ran under"
6230        );
6231    }
6232
6233    /// [`StepResult::shell`]'s own claim on the plain argv side, so the two modes are
6234    /// proven end to end through `run_action` rather than only `shell = true`: an ordinary
6235    /// step's receipt must read `false`, not merely default to it by construction.
6236    #[test]
6237    fn an_argv_step_runs_through_run_action_with_shell_false_on_its_receipt() {
6238        let dir = tempfile::tempdir().expect("temp dir");
6239        let root = root_of(&dir);
6240        let repo = root.join("repo");
6241        init_repo_with_a_commit(&repo);
6242
6243        let core = Core::start_discovered(spec(vec![root]));
6244        let key = core.snapshot().entities[0].key.clone();
6245        let steps = vec![Step {
6246            argv: vec!["true".to_string()],
6247            shell: false,
6248            interactive: false,
6249            env: Vec::new(),
6250        }];
6251
6252        let started = core.run_action(action("argv-step", steps), std::slice::from_ref(&key));
6253
6254        assert!(started);
6255        wait_for("the fan-out to finish and write a receipt", || {
6256            !core.action_running()
6257        });
6258        let receipt = core.snapshot().entities[0]
6259            .last_action
6260            .clone()
6261            .expect("receipt written");
6262        assert!(!receipt.steps[0].shell);
6263    }
6264
6265    /// Criterion 3's first half. `begin_shared_generation_for_test` puts the entity
6266    /// in flight against a Generation of its own, exactly as a real `refresh` would;
6267    /// this proves `run_action` cancels that Generation's own flag rather than merely
6268    /// starting alongside it, which is the difference between the 0.85s and 3.14s
6269    /// measurements `docs/spec/actions.md`'s "Refreshing around a run" reports.
6270    #[test]
6271    fn starting_an_action_cancels_any_generation_already_in_flight() {
6272        let dir = tempfile::tempdir().expect("temp dir");
6273        let root = root_of(&dir);
6274        let repo = root.join("repo");
6275        init_repo_with_a_commit(&repo);
6276
6277        let core = Core::start_discovered(spec(vec![root]));
6278        let key = core.snapshot().entities[0].key.clone();
6279        let in_flight = core.begin_shared_generation_for_test(std::slice::from_ref(&key));
6280        let cancel = in_flight
6281            .cancels
6282            .get(&key)
6283            .expect("the in-flight entity has a cancel flag")
6284            .clone();
6285        assert!(!cancel.load(Ordering::Acquire));
6286
6287        let started = core.run_action(
6288            action("reinstall", vec![step(&["true"])]),
6289            std::slice::from_ref(&key),
6290        );
6291
6292        assert!(started);
6293        assert!(
6294            cancel.load(Ordering::Acquire),
6295            "starting an Action must cancel a Generation already in flight, not share \
6296             execution with it"
6297        );
6298        // Drain the fan-out and its completion refresh so this test's background
6299        // thread does not outlive it.
6300        wait_for("the fan-out and its completion refresh to drain", || {
6301            !core.action_running()
6302        });
6303    }
6304
6305    /// Criterion 3's second half, and the double-refresh mutation this test is written
6306    /// to catch: a completed Action starting its own Generation *and* a second one
6307    /// left over from a naive implementation that also called `refresh` directly would
6308    /// both leave every entity settled, so counting settled entities alone cannot tell
6309    /// zero, one and two apart. Reading the table's own `generation` number after
6310    /// completion can: it must be the Generation immediately after the settled table
6311    /// this Action ran against, covering both entities although the Action only ever
6312    /// named one of them. Named by its order rather than by a number, so what launch
6313    /// itself mints cannot renumber the claim.
6314    #[test]
6315    fn a_finished_action_starts_exactly_one_generation_over_every_known_entity() {
6316        let dir = tempfile::tempdir().expect("temp dir");
6317        let root = root_of(&dir);
6318        let acted_on = root.join("acted-on");
6319        let untouched = root.join("untouched");
6320        init_repo_with_a_commit(&acted_on);
6321        init_repo_with_a_commit(&untouched);
6322
6323        let (core, before) = started_and_settled(spec(vec![root]));
6324        let acted_key = before
6325            .entities
6326            .iter()
6327            .find(|entity| entity.key.path() == acted_on)
6328            .expect("the acted-on entity is discovered")
6329            .key
6330            .clone();
6331
6332        let started = core.run_action(
6333            action("reinstall", vec![step(&["true"])]),
6334            std::slice::from_ref(&acted_key),
6335        );
6336
6337        assert!(started);
6338        wait_for(
6339            "the completion Generation to probe every known entity, including the one the \
6340             Action never touched",
6341            || {
6342                let snapshot = core.snapshot();
6343                snapshot.generation != before.generation
6344                    && snapshot.entities.iter().all(|entity| {
6345                        matches!(
6346                            entity.branch.settled(),
6347                            Some(Settled::Known {
6348                                value: _,
6349                                at: _,
6350                                stale: _
6351                            })
6352                        )
6353                    })
6354            },
6355        );
6356        assert_eq!(
6357            core.settle().generation,
6358            before.generation.successor(),
6359            "completion must start exactly one Generation: not zero (no refresh at all) and \
6360             not two (a double refresh)"
6361        );
6362    }
6363
6364    /// A completion dispatches its Generation while its own run is still admitted, and a
6365    /// submission arriving before that release is refused. Together those are what keeps a
6366    /// completion from dispatching over a run that replaced it: the next run's admission,
6367    /// and the cancellation it performs on the way in, can only ever follow a Generation
6368    /// this one has already started.
6369    ///
6370    /// [`Core::action_completion_boundary`] holds the completion between the two, the one
6371    /// place either half is observable: they are adjacent statements, so a test racing them
6372    /// reads whichever it happened to catch.
6373    #[test]
6374    fn a_completion_dispatches_its_generation_before_releasing_its_run() {
6375        let dir = tempfile::tempdir().expect("temp dir");
6376        let root = root_of(&dir);
6377        let repo = root.join("repo");
6378        init_repo_with_a_commit(&repo);
6379
6380        let (core, before) = started_and_settled(spec(vec![root]));
6381        let key = before.entities[0].key.clone();
6382        let armed = core.action_completion_boundary().arm();
6383
6384        assert!(core.run_action(
6385            action("finishing", vec![step(&["true"])]),
6386            std::slice::from_ref(&key)
6387        ));
6388        armed.wait_until_reached();
6389
6390        assert_eq!(
6391            core.snapshot().generation,
6392            before.generation.successor(),
6393            "the completion Generation must be dispatched before the run releases its \
6394             admission"
6395        );
6396        assert!(
6397            !core.run_action(
6398                action("racing", vec![step(&["true"])]),
6399                std::slice::from_ref(&key)
6400            ),
6401            "a submission before that release must be refused, so what a run cancels on the \
6402             way in is never a Generation the run it replaced has yet to dispatch"
6403        );
6404
6405        drop(armed);
6406        wait_for("the finished run to release its admission", || {
6407            !core.action_running()
6408        });
6409    }
6410
6411    /// Criterion 5. The excluded row gets the one legitimate `not_applicable` receipt
6412    /// with no steps; the acted-on row's own step is made to fail, which is the strong
6413    /// half of the claim: a receipt with steps that failed is still not the
6414    /// `not_applicable` shape, so nothing but an excluded row can ever produce it.
6415    #[test]
6416    fn an_excluded_row_swept_into_an_action_gets_a_not_applicable_receipt_and_no_other_path_does() {
6417        let dir = tempfile::tempdir().expect("temp dir");
6418        let root = root_of(&dir);
6419        let excluded_repo = root.join("excluded");
6420        let normal_repo = root.join("normal");
6421        init_repo_with_a_commit(&excluded_repo);
6422        init_repo_with_a_commit(&normal_repo);
6423
6424        let core = Core::start_discovered(spec_with_overrides(
6425            vec![root],
6426            vec![RepoOverride {
6427                path: excluded_repo.clone(),
6428                default_branch: None,
6429                excluded: true,
6430            }],
6431        ));
6432        let snapshot = core.snapshot();
6433        let find = |path: &Path| {
6434            snapshot
6435                .entities
6436                .iter()
6437                .find(|entity| entity.key.path() == path)
6438                .unwrap_or_else(|| panic!("entity at {path:?} present"))
6439                .key
6440                .clone()
6441        };
6442        let excluded_key = find(&excluded_repo);
6443        let normal_key = find(&normal_repo);
6444        assert!(
6445            snapshot
6446                .entities
6447                .iter()
6448                .find(|entity| entity.key == excluded_key)
6449                .unwrap()
6450                .excluded
6451        );
6452
6453        let started = core.run_action(
6454            action("reinstall", vec![step(&["sh", "-c", "exit 3"])]),
6455            &[excluded_key.clone(), normal_key.clone()],
6456        );
6457
6458        assert!(started);
6459        // `!core.action_running()`, not merely "both entities have some receipt": a
6460        // still-running entity now writes an intermediate receipt naming its currently
6461        // executing step before it finishes (`docs/spec/actions.md`'s "The run on screen"),
6462        // so `last_action.is_some()` alone can be true well before `normal_key`'s own step
6463        // has actually run.
6464        wait_for("the fan-out to finish", || !core.action_running());
6465
6466        let after = core.snapshot();
6467        let receipt_of = |key: &EntityKey| {
6468            after
6469                .entities
6470                .iter()
6471                .find(|entity| entity.key == *key)
6472                .unwrap()
6473                .last_action
6474                .clone()
6475                .unwrap()
6476        };
6477        let excluded_receipt = receipt_of(&excluded_key);
6478        assert!(excluded_receipt.not_applicable());
6479        assert!(excluded_receipt.steps.is_empty());
6480
6481        let normal_receipt = receipt_of(&normal_key);
6482        assert!(
6483            !normal_receipt.not_applicable(),
6484            "a row that actually ran a step, even a failing one, must never read as \
6485             not_applicable: an excluded row is the one legitimate producer of that outcome"
6486        );
6487        assert!(!normal_receipt.steps.is_empty());
6488        assert!(normal_receipt.failed());
6489    }
6490
6491    /// Criterion 4: `operable_count` and `run_action`'s own partition must be one
6492    /// computation, not two that happen to agree today. Proven against independent
6493    /// evidence, the same way the test above does: run an Action over one excluded and
6494    /// one normal entity, then check `operable_count`'s answer against how many of the
6495    /// two actually got a real (not `not_applicable`) receipt, rather than against a
6496    /// second hand-written copy of the exclusion rule.
6497    #[test]
6498    fn operable_count_matches_how_many_entities_run_action_actually_runs_a_step_against() {
6499        let dir = tempfile::tempdir().expect("temp dir");
6500        let root = root_of(&dir);
6501        let excluded_repo = root.join("excluded");
6502        let normal_repo = root.join("normal");
6503        init_repo_with_a_commit(&excluded_repo);
6504        init_repo_with_a_commit(&normal_repo);
6505
6506        let core = Core::start_discovered(spec_with_overrides(
6507            vec![root],
6508            vec![RepoOverride {
6509                path: excluded_repo.clone(),
6510                default_branch: None,
6511                excluded: true,
6512            }],
6513        ));
6514        let snapshot = core.snapshot();
6515        let find = |path: &Path| {
6516            snapshot
6517                .entities
6518                .iter()
6519                .find(|entity| entity.key.path() == path)
6520                .unwrap_or_else(|| panic!("entity at {path:?} present"))
6521                .key
6522                .clone()
6523        };
6524        let order = [find(&excluded_repo), find(&normal_repo)];
6525
6526        assert_eq!(
6527            core.operable_count(&order),
6528            1,
6529            "one of the two rows is excluded, so exactly one is operable"
6530        );
6531
6532        let started = core.run_action(action("reinstall", vec![step(&["true"])]), &order);
6533        assert!(started);
6534
6535        wait_for("every entity in the order to carry a receipt", || {
6536            let snapshot = core.snapshot();
6537            order.iter().all(|key| {
6538                snapshot
6539                    .entities
6540                    .iter()
6541                    .find(|entity| entity.key == *key)
6542                    .and_then(|entity| entity.last_action.as_ref())
6543                    .is_some()
6544            })
6545        });
6546
6547        let after = core.snapshot();
6548        let actually_ran = after
6549            .entities
6550            .iter()
6551            .filter(|entity| order.contains(&entity.key))
6552            .filter(|entity| {
6553                entity
6554                    .last_action
6555                    .as_ref()
6556                    .is_some_and(|receipt| !receipt.not_applicable())
6557            })
6558            .count();
6559
6560        assert_eq!(
6561            core.operable_count(&order),
6562            actually_ran,
6563            "operable_count must report exactly how many rows run_action actually ran a \
6564             step against, not merely how many keys resolved"
6565        );
6566    }
6567
6568    /// [`Core::run_action_for_entity_blocking`]'s own reason to exist: it returns the
6569    /// finished receipt on the calling thread rather than handing the run off, so a caller
6570    /// needs no `wait_for` at all to see the step's own effect, unlike every `run_action`
6571    /// test above.
6572    #[test]
6573    fn run_action_for_entity_blocking_returns_the_finished_receipt_on_the_calling_thread() {
6574        let dir = tempfile::tempdir().expect("temp dir");
6575        let root = root_of(&dir);
6576        let repo = root.join("repo");
6577        init_repo_with_a_commit(&repo);
6578        let marker = repo.join("hook-ran");
6579
6580        let core = Core::start_discovered(spec_with_overrides(vec![root], Vec::new()));
6581        let key = core
6582            .snapshot()
6583            .entities
6584            .iter()
6585            .find(|entity| entity.key.path() == repo)
6586            .expect("the repo is discovered")
6587            .key
6588            .clone();
6589
6590        let receipt = core
6591            .run_action_for_entity_blocking(
6592                &action("hook", vec![step(&["touch", "hook-ran"])]),
6593                &key,
6594            )
6595            .expect("the entity is known");
6596
6597        assert!(
6598            marker.exists(),
6599            "the step must have already run by the time this call returns"
6600        );
6601        assert_eq!(receipt.steps.len(), 1);
6602        assert_eq!(receipt.steps[0].outcome, StepOutcome::Ok);
6603    }
6604
6605    /// `None` rather than a receipt for a key the table does not know: the same fallback
6606    /// every other key-addressed `Core` entry point gives one, and the caller's own signal
6607    /// for "no hook to consult" when a hook names a row `sync`'s own eligibility has already
6608    /// dropped.
6609    #[test]
6610    fn run_action_for_entity_blocking_answers_none_for_an_unknown_key() {
6611        let dir = tempfile::tempdir().expect("temp dir");
6612        let root = root_of(&dir);
6613        let core = Core::start_discovered(spec_with_overrides(vec![root.clone()], Vec::new()));
6614
6615        let unknown = EntityKey::new(Arc::from(root.join("never-discovered").as_path()));
6616
6617        assert!(
6618            core.run_action_for_entity_blocking(&action("hook", vec![step(&["true"])]), &unknown)
6619                .is_none()
6620        );
6621    }
6622
6623    /// The reversed decision itself: `when` now decides what runs, not only what a palette
6624    /// reports about it. A row the predicate proves runs a real step; a row it disproves
6625    /// gets a `Skip::Inapplicable` receipt with no steps and never spawns a child process at
6626    /// all, which the failing command below would have surfaced as a `Failed` step had it
6627    /// run (`docs/spec/actions.md`'s "The Selection and the gate", reversing what that
6628    /// section originally decided).
6629    #[test]
6630    fn run_action_skips_a_row_its_when_predicate_disproves_rather_than_running_it_anyway() {
6631        let dir = tempfile::tempdir().expect("temp dir");
6632        let root = root_of(&dir);
6633        let proved_repo = root.join("alpha");
6634        let disproved_repo = root.join("beta");
6635        init_repo_with_a_commit(&proved_repo);
6636        init_repo_with_a_commit(&disproved_repo);
6637
6638        let core = Core::start_discovered(spec(vec![root]));
6639        let snapshot = core.snapshot();
6640        let find = |path: &Path| {
6641            snapshot
6642                .entities
6643                .iter()
6644                .find(|entity| entity.key.path() == path)
6645                .unwrap_or_else(|| panic!("entity at {path:?} present"))
6646                .key
6647                .clone()
6648        };
6649        let proved_key = find(&proved_repo);
6650        let disproved_key = find(&disproved_repo);
6651        let order = [proved_key.clone(), disproved_key.clone()];
6652
6653        // A command that would mark a real run `Failed` if it ever ran, so a disproved row
6654        // that wrongly ran a step is caught by its own outcome rather than only by `skip`.
6655        let started = core.run_action(
6656            action_with_when(
6657                "reinstall",
6658                vec![step(&["sh", "-c", "exit 3"])],
6659                "name:alpha",
6660            ),
6661            &order,
6662        );
6663        assert!(started);
6664        wait_for("the fan-out to finish", || !core.action_running());
6665
6666        let after = core.snapshot();
6667        let receipt_of = |key: &EntityKey| {
6668            after
6669                .entities
6670                .iter()
6671                .find(|entity| entity.key == *key)
6672                .unwrap()
6673                .last_action
6674                .clone()
6675                .unwrap()
6676        };
6677
6678        let proved_receipt = receipt_of(&proved_key);
6679        assert_eq!(
6680            proved_receipt.skip, None,
6681            "the row the predicate proved must actually run"
6682        );
6683        assert!(proved_receipt.failed(), "its own step still ran and failed");
6684
6685        let disproved_receipt = receipt_of(&disproved_key);
6686        assert!(
6687            disproved_receipt.inapplicable(),
6688            "the row the predicate disproved must be skipped rather than run"
6689        );
6690        assert!(disproved_receipt.steps.is_empty());
6691        assert!(
6692            !disproved_receipt.failed(),
6693            "a skipped row never ran a step, so it cannot have failed one"
6694        );
6695    }
6696
6697    /// An excluded row is subtracted before an Action's `when` ever sees it, so the
6698    /// predicate narrows what is left rather than replacing that subtraction
6699    /// (`docs/spec/actions.md`'s "The Selection and the gate").
6700    ///
6701    /// Proven against `operable_count` itself rather than against a hand-written expectation:
6702    /// a predicate every remaining row satisfies must leave a total identical to that count,
6703    /// which it cannot do if the excluded row reached the tally under any of the three
6704    /// headings.
6705    #[test]
6706    fn applicability_subtracts_an_excluded_row_before_the_predicate_reads_it() {
6707        let dir = tempfile::tempdir().expect("temp dir");
6708        let root = root_of(&dir);
6709        let excluded_repo = root.join("excluded");
6710        let normal_repo = root.join("normal");
6711        init_repo_with_a_commit(&excluded_repo);
6712        init_repo_with_a_commit(&normal_repo);
6713
6714        let core = Core::start_discovered(spec_with_overrides(
6715            vec![root],
6716            vec![RepoOverride {
6717                path: excluded_repo.clone(),
6718                default_branch: None,
6719                excluded: true,
6720            }],
6721        ));
6722        let order: Vec<EntityKey> = core
6723            .snapshot()
6724            .entities
6725            .iter()
6726            .map(|entity| entity.key.clone())
6727            .collect();
6728        assert_eq!(order.len(), 2, "the fixture must discover both repos");
6729
6730        let counts = core.applicability(&order, &Filter::parse("kind:repo"));
6731
6732        assert_eq!(
6733            counts.total(),
6734            core.operable_count(&order),
6735            "the predicate must be counted over exactly the rows `operable_count` keeps"
6736        );
6737        assert_eq!(
6738            counts,
6739            Applicability {
6740                applicable: 1,
6741                inapplicable: 0,
6742                unresolved: 0,
6743            }
6744        );
6745    }
6746
6747    /// An unknown key (already dismissed, or never discovered) is silently dropped from
6748    /// the count, the same fallback `run_action` gives one: this is the half of
6749    /// `partition_operable` no fixture above exercises, since every key there resolves.
6750    #[test]
6751    fn operable_count_silently_drops_a_key_that_no_longer_resolves() {
6752        let dir = tempfile::tempdir().expect("temp dir");
6753        let root = root_of(&dir);
6754        let repo = root.join("repo");
6755        init_repo_with_a_commit(&repo);
6756
6757        let core = Core::start_discovered(spec(vec![root]));
6758        let real_key = core.snapshot().entities[0].key.clone();
6759        let unknown_key = EntityKey::new(Arc::from(dir.path().join("never-discovered")));
6760
6761        assert_eq!(core.operable_count(&[real_key, unknown_key]), 1);
6762    }
6763
6764    /// Criterion 6. The second call is rejected synchronously (admission refuses it before
6765    /// anything else runs), so this needs no waiting to observe; only the cleanup wait at
6766    /// the end needs [`wait_for`].
6767    #[test]
6768    fn only_one_action_fan_out_runs_at_a_time_a_second_call_is_rejected_while_one_is_live() {
6769        let dir = tempfile::tempdir().expect("temp dir");
6770        let root = root_of(&dir);
6771        let repo = root.join("repo");
6772        init_repo_with_a_commit(&repo);
6773
6774        let core = Core::start_discovered(spec(vec![root]));
6775        let key = core.snapshot().entities[0].key.clone();
6776        let slow = action("first", vec![step(&["sh", "-c", "sleep 0.3"])]);
6777        let fast = action("second", vec![step(&["true"])]);
6778
6779        let first_started = core.run_action(slow, std::slice::from_ref(&key));
6780        let second_started = core.run_action(fast, std::slice::from_ref(&key));
6781
6782        assert!(first_started);
6783        assert!(
6784            !second_started,
6785            "a second run_action call must be rejected while the first is still in flight"
6786        );
6787        wait_for("the accepted first fan-out to finish", || {
6788            !core.action_running()
6789        });
6790        let receipt = core.snapshot().entities[0].last_action.clone().unwrap();
6791        assert_eq!(
6792            &*receipt.label, "first",
6793            "the surviving receipt must be the accepted first run's, never the rejected second"
6794        );
6795    }
6796
6797    /// Refusing a submission must leave the live run exactly as it was: the refused call
6798    /// registers no control of its own, so the run already in flight is still the one
6799    /// `stop_action` reaches.
6800    ///
6801    /// A guard on the refusal path rather than a reproduction of anything: refusing has
6802    /// always returned before touching a control, and this pins that it still does. Both
6803    /// steps sleep [`FIXTURE_LIFETIME`], since the outcomes below cannot tell a cancelled
6804    /// step from one that reached its own end inside the wait watching it.
6805    #[test]
6806    fn a_refused_second_submission_leaves_the_first_action_still_stoppable() {
6807        let dir = tempfile::tempdir().expect("temp dir");
6808        let root = root_of(&dir);
6809        let repo = root.join("repo");
6810        init_repo_with_a_commit(&repo);
6811
6812        let core = Core::start_discovered(spec(vec![root]));
6813        let key = core.snapshot().entities[0].key.clone();
6814        let sleep_past_the_backstop = format!("sleep {}", FIXTURE_LIFETIME.as_secs());
6815        let live = action(
6816            "live",
6817            vec![
6818                step(&["sh", "-c", &sleep_past_the_backstop]),
6819                step(&["sh", "-c", &sleep_past_the_backstop]),
6820            ],
6821        );
6822
6823        assert!(core.run_action(live, std::slice::from_ref(&key)));
6824        wait_for("the live run's own first step to start", || {
6825            core.snapshot().entities[0]
6826                .last_action
6827                .as_ref()
6828                .is_some_and(|receipt| receipt.running.is_some())
6829        });
6830
6831        assert!(
6832            !core.run_action(
6833                action("refused", vec![step(&["true"])]),
6834                std::slice::from_ref(&key)
6835            ),
6836            "a second submission must be refused while one run is still live"
6837        );
6838
6839        core.stop_action();
6840
6841        wait_for("the still-controllable run to come down", || {
6842            !core.action_running()
6843        });
6844        let receipt = core.snapshot().entities[0].last_action.clone().unwrap();
6845        assert_eq!(&*receipt.label, "live");
6846        assert_eq!(
6847            receipt.steps[0].outcome,
6848            StepOutcome::Cancelled,
6849            "the refused submission must leave the live run's own control in place, so \
6850             stop_action still reaches the step it was running"
6851        );
6852        assert_eq!(
6853            receipt.steps[1].outcome,
6854            StepOutcome::Cancelled,
6855            "a step that had not started when the run was cancelled must read Cancelled too"
6856        );
6857    }
6858
6859    /// A run accepted the moment a completion releases its admission owns the controls for
6860    /// the rest of its life: that completion has nothing left to register by then, so
6861    /// `stop_action` still reaches this run's own steps.
6862    ///
6863    /// [`Core::action_completion_boundary`] pins "the moment" rather than approximating it:
6864    /// the submission made while the completion is parked must be refused, and the one made
6865    /// once it is released must be accepted, so what is stopped below is a run accepted at
6866    /// the earliest point one can be. Both of its steps sleep [`FIXTURE_LIFETIME`], since
6867    /// the outcomes asserted cannot tell a cancelled step from one that reached its own end
6868    /// inside the wait watching it.
6869    #[test]
6870    fn a_run_accepted_once_a_completion_releases_its_admission_is_still_stoppable() {
6871        let dir = tempfile::tempdir().expect("temp dir");
6872        let root = root_of(&dir);
6873        let repo = root.join("repo");
6874        init_repo_with_a_commit(&repo);
6875
6876        let core = Core::start_discovered(spec(vec![root]));
6877        let key = core.snapshot().entities[0].key.clone();
6878        let armed = core.action_completion_boundary().arm();
6879
6880        assert!(core.run_action(
6881            action("finishing", vec![step(&["true"])]),
6882            std::slice::from_ref(&key)
6883        ));
6884        armed.wait_until_reached();
6885        assert!(
6886            !core.run_action(
6887                action("early", vec![step(&["true"])]),
6888                std::slice::from_ref(&key)
6889            ),
6890            "a submission made before the completion releases its admission must be refused"
6891        );
6892        drop(armed);
6893        wait_for("the finished run to release its admission", || {
6894            !core.action_running()
6895        });
6896
6897        let sleep_past_the_backstop = format!("sleep {}", FIXTURE_LIFETIME.as_secs());
6898        let following = action(
6899            "following",
6900            vec![
6901                step(&["sh", "-c", &sleep_past_the_backstop]),
6902                step(&["sh", "-c", &sleep_past_the_backstop]),
6903            ],
6904        );
6905        assert!(
6906            core.run_action(following, std::slice::from_ref(&key)),
6907            "a submission made once that release has happened must be accepted"
6908        );
6909        wait_for("the following run's own first step to start", || {
6910            receipt_labelled(&core, &key, "following")
6911                .is_some_and(|receipt| receipt.running.is_some())
6912        });
6913
6914        core.stop_action();
6915
6916        wait_for("the cancelled run to come down", || !core.action_running());
6917        let receipt =
6918            receipt_labelled(&core, &key, "following").expect("the following run's receipt");
6919        assert_eq!(
6920            receipt.steps[0].outcome,
6921            StepOutcome::Cancelled,
6922            "the completion this run followed must leave stop_action still reaching it"
6923        );
6924        assert_eq!(
6925            receipt.steps[1].outcome,
6926            StepOutcome::Cancelled,
6927            "a cancelled run's remaining step must never start, so it reads Cancelled"
6928        );
6929    }
6930
6931    // =====================================================================================
6932    // Criteria 3 and 4: `Core::hold_action`/`Core::continue_action` are their own verbs on
6933    // the core, kept apart from the generic `pause`/`resume` the probes use, and suspending
6934    // a fan-out is reversible: a held step's own progress genuinely pauses, and resumes
6935    // exactly where it left off, rather than the run merely finishing on its own regardless.
6936    // =====================================================================================
6937
6938    /// A black-box proof through the public API alone, with no reach into the step's own
6939    /// pid: a one-second step, held for 1.5s (comfortably longer than the step would ever
6940    /// take unheld) and then continued. If `hold_action` were a no-op, the step would
6941    /// already have finished on its own well before this test ever calls
6942    /// `continue_action`, and `action_running` would already read `false` at the
6943    /// mid-hold checkpoint below; that is the exact mutation this test is written to catch.
6944    #[test]
6945    fn hold_action_genuinely_pauses_a_running_steps_progress_and_continue_action_resumes_it() {
6946        let dir = tempfile::tempdir().expect("temp dir");
6947        let root = root_of(&dir);
6948        let repo = root.join("repo");
6949        init_repo_with_a_commit(&repo);
6950
6951        let core = Core::start_discovered(spec(vec![root]));
6952        let key = core.snapshot().entities[0].key.clone();
6953        let two_seconds = action("brief", vec![step(&["sh", "-c", "sleep 2"])]);
6954
6955        assert!(core.run_action(two_seconds, std::slice::from_ref(&key)));
6956        wait_for("the two-second step to actually start running", || {
6957            core.snapshot().entities[0]
6958                .last_action
6959                .as_ref()
6960                .is_some_and(|receipt| receipt.running.is_some())
6961        });
6962
6963        // The receipt's own `running: Some(_)` is written just before `run_step` is even
6964        // called, so it can race that call's own spawn, which is when the step's process
6965        // group is actually registered. SIGSTOP is idempotent, so pulsing `hold_action`
6966        // over a short bounded window (well inside the step's own 2s) is what makes that
6967        // race resolve deterministically rather than flakily, without ever risking a hang:
6968        // a stuck `hold_action` here fails this loop's own fixed iteration count, not this
6969        // test's wall clock.
6970        for _ in 0..20 {
6971            core.hold_action();
6972            thread::sleep(Duration::from_millis(20));
6973        }
6974
6975        thread::sleep(Duration::from_millis(1_800));
6976        assert!(
6977            core.action_running(),
6978            "a genuinely held step must not have finished on its own well past its own 2s \
6979             sleep; a no-op hold_action would already show this false here"
6980        );
6981
6982        core.continue_action();
6983        wait_for("continue_action to let the held step finish", || {
6984            !core.action_running()
6985        });
6986        let receipt = core.snapshot().entities[0].last_action.clone().unwrap();
6987        assert_eq!(receipt.steps[0].outcome, StepOutcome::Ok);
6988    }
6989
6990    /// `hold_action`, `continue_action` and `stop_action` must all be safe to call with no
6991    /// fan-out live: nothing to signal, so each is a plain no-op rather than a panic or a
6992    /// stray signal to nothing.
6993    #[test]
6994    fn hold_continue_and_stop_action_are_no_ops_with_no_fan_out_running() {
6995        let dir = tempfile::tempdir().expect("temp dir");
6996        let root = root_of(&dir);
6997        let repo = root.join("repo");
6998        init_repo_with_a_commit(&repo);
6999
7000        let core = Core::start_discovered(spec(vec![root]));
7001
7002        core.hold_action();
7003        core.continue_action();
7004        core.stop_action();
7005
7006        assert!(!core.action_running());
7007    }
7008
7009    // =====================================================================================
7010    // Criterion 1: Escape (`Core::stop_action`) cancels the fan-out with two signals, the
7011    // terminating one and then the uncatchable one after a grace, because the first is
7012    // trappable. Exercised through the real public seam, never by calling `RunControl`
7013    // directly, so this is `stop_action` end to end rather than only its own primitive.
7014    // =====================================================================================
7015
7016    /// A child that traps and ignores SIGTERM is the only fixture that actually
7017    /// discriminates the two-signal design from a one-signal one: a child that dies on
7018    /// SIGTERM alone would pass this test even if `stop_action` were mutated to drop its
7019    /// own SIGKILL follow-up entirely, which is exactly the regression this criterion
7020    /// exists to catch.
7021    ///
7022    /// The step sleeps [`FIXTURE_LIFETIME`], ten times the backstop every wait below
7023    /// carries, so a `stop_action` that stops working reads back as a named wait giving up
7024    /// rather than as the step ending on its own inside the wait watching it. That margin is
7025    /// the whole discrimination here, because the outcome assertion cannot supply it:
7026    /// `run_action_for_entity` stamps `Cancelled` on whatever was running the moment the run
7027    /// was cancelled, however the step actually ended. A run that does fail here leaves the
7028    /// trapping child alive until its own sleep ends, which is the price of a fixture the
7029    /// wait cannot outlast.
7030    #[test]
7031    fn stop_action_escalates_from_sigterm_to_sigkill_against_a_trapping_step() {
7032        let dir = tempfile::tempdir().expect("temp dir");
7033        let root = root_of(&dir);
7034        let repo = root.join("repo");
7035        init_repo_with_a_commit(&repo);
7036
7037        let core = Core::start_discovered(spec(vec![root]));
7038        let key = core.snapshot().entities[0].key.clone();
7039        let sleep_past_the_backstop = format!("trap '' TERM; sleep {}", FIXTURE_LIFETIME.as_secs());
7040        let trapping = action(
7041            "trapping",
7042            vec![step(&["sh", "-c", &sleep_past_the_backstop])],
7043        );
7044
7045        assert!(core.run_action(trapping, std::slice::from_ref(&key)));
7046        wait_for("the trapping step to actually start running", || {
7047            core.snapshot().entities[0]
7048                .last_action
7049                .as_ref()
7050                .is_some_and(|receipt| receipt.running.is_some())
7051        });
7052        // Gives the shell time to install its own trap before any signal can arrive; the
7053        // outcome asserted below is the actual proof, not this fixed delay.
7054        thread::sleep(Duration::from_millis(100));
7055
7056        core.stop_action();
7057
7058        wait_for(
7059            "a SIGTERM-trapping step to come down from the follow-up SIGKILL",
7060            || !core.action_running(),
7061        );
7062        let receipt = core.snapshot().entities[0].last_action.clone().unwrap();
7063        assert_eq!(receipt.steps.len(), 1);
7064        assert_eq!(
7065            receipt.steps[0].outcome,
7066            StepOutcome::Cancelled,
7067            "a step running when the run was cancelled must read Cancelled, never Failed"
7068        );
7069    }
7070
7071    // =====================================================================================
7072    // Criterion 2: cancellation produces `Cancelled`, never `NotRun`, which stays reserved
7073    // for being blocked by an earlier failure. Both outcomes are shown live in the same
7074    // run, on different entities, so they can be told apart rather than merely observed
7075    // one at a time.
7076    // =====================================================================================
7077
7078    /// One Action, two entities, dispatched together at `concurrency: 2`: `fail`'s own
7079    /// first step exits nonzero well before the run is ever cancelled, so its second step
7080    /// is a genuine `NotRun`; `slow`'s own first step is still sleeping when
7081    /// `stop_action` fires, so both of its steps read `Cancelled`. A test that only ever
7082    /// produced one of the two outcomes could not prove they are told apart; this fixture
7083    /// has both live in the same receipt set, so a mutation that collapsed one into the
7084    /// other would be caught by whichever entity it broke.
7085    #[test]
7086    fn cancelled_and_not_run_are_distinct_outcomes_shown_together_in_one_run() {
7087        let dir = tempfile::tempdir().expect("temp dir");
7088        let root = root_of(&dir);
7089        init_repo_with_a_commit(&root.join("fail"));
7090        init_repo_with_a_commit(&root.join("slow"));
7091
7092        let core = Core::start_discovered(spec(vec![root]));
7093        let snapshot = core.snapshot();
7094        let fail_key = snapshot
7095            .entities
7096            .iter()
7097            .find(|entity| &*entity.name == "fail")
7098            .expect("the fail entity is present")
7099            .key
7100            .clone();
7101        let slow_key = snapshot
7102            .entities
7103            .iter()
7104            .find(|entity| &*entity.name == "slow")
7105            .expect("the slow entity is present")
7106            .key
7107            .clone();
7108
7109        // One step list run against both entities: behaviour branches on the entity's own
7110        // directory name, which is `$PWD`'s basename in each entity's own working
7111        // directory, so `fail` fails immediately and `slow` is still running when this
7112        // test cancels the whole run.
7113        // `slow`'s branch sleeps `FIXTURE_LIFETIME` rather than a number of its own: the
7114        // wait below is on cancellation bringing the fan-out down, which a step that ends by
7115        // itself inside the backstop would satisfy without cancellation working at all.
7116        let branch_on_the_entity_name = format!(
7117            "case \"$(basename \"$PWD\")\" in fail) exit 1 ;; *) sleep {} ;; esac",
7118            FIXTURE_LIFETIME.as_secs()
7119        );
7120        let steps = vec![
7121            step(&["sh", "-c", &branch_on_the_entity_name]),
7122            step(&["true"]),
7123        ];
7124        let mut action_spec = action("mixed", steps);
7125        action_spec.concurrency = 2;
7126
7127        assert!(core.run_action(action_spec, &[fail_key.clone(), slow_key.clone()]));
7128
7129        // `fail` must have already finished (both its steps recorded) while `slow` is
7130        // still running its own first step: the two entities' own outcomes are captured
7131        // at the same moment, which is what makes them "shown together".
7132        wait_for(
7133            "`fail` finished and `slow` still running before cancelling",
7134            || {
7135                let snapshot = core.snapshot();
7136                let fail_done = snapshot
7137                    .entities
7138                    .iter()
7139                    .find(|entity| entity.key == fail_key)
7140                    .and_then(|entity| entity.last_action.as_ref())
7141                    .is_some_and(|receipt| receipt.steps.len() == 2);
7142                let slow_running = snapshot
7143                    .entities
7144                    .iter()
7145                    .find(|entity| entity.key == slow_key)
7146                    .and_then(|entity| entity.last_action.as_ref())
7147                    .is_some_and(|receipt| receipt.running.is_some());
7148                fail_done && slow_running
7149            },
7150        );
7151
7152        core.stop_action();
7153        wait_for("the fan-out to finish once cancelled", || {
7154            !core.action_running()
7155        });
7156
7157        let snapshot = core.snapshot();
7158        let fail_receipt = snapshot
7159            .entities
7160            .iter()
7161            .find(|entity| entity.key == fail_key)
7162            .and_then(|entity| entity.last_action.clone())
7163            .expect("fail's own receipt");
7164        assert_eq!(fail_receipt.steps[0].outcome, StepOutcome::Failed(1));
7165        assert_eq!(
7166            fail_receipt.steps[1].outcome,
7167            StepOutcome::NotRun,
7168            "blocked by fail's own earlier failure, not by the later cancellation"
7169        );
7170
7171        let slow_receipt = snapshot
7172            .entities
7173            .iter()
7174            .find(|entity| entity.key == slow_key)
7175            .and_then(|entity| entity.last_action.clone())
7176            .expect("slow's own receipt");
7177        assert_eq!(
7178            slow_receipt.steps[0].outcome,
7179            StepOutcome::Cancelled,
7180            "a step running when the run was cancelled must read Cancelled"
7181        );
7182        assert_eq!(
7183            slow_receipt.steps[1].outcome,
7184            StepOutcome::Cancelled,
7185            "a step that had not started when the run was cancelled must also read \
7186             Cancelled, never NotRun, which stays reserved for an earlier failure"
7187        );
7188    }
7189
7190    /// A panic anywhere inside the fan-out, a poisoned `RwLock` from an unrelated
7191    /// earlier panic is enough, must not leave this `Core` reading a run as live for the
7192    /// rest of its life. Poisons the table lock directly rather than
7193    /// injecting a fault into `run_action_for_entity`, which runs a real child process
7194    /// and has no seam for one: the fan-out's own `table_handle.write().unwrap()` then
7195    /// panics on the poisoned lock exactly the way an unrelated earlier panic would in
7196    /// production.
7197    #[test]
7198    fn a_panicking_fan_out_still_resets_action_running_so_a_later_action_can_start() {
7199        let dir = tempfile::tempdir().expect("temp dir");
7200        let root = root_of(&dir);
7201        let repo = root.join("repo");
7202        init_repo_with_a_commit(&repo);
7203
7204        // Drained before the table lock is poisoned below: a probe still in flight would
7205        // take the poison too, and a panic in one of rayon's global workers aborts the
7206        // process rather than unwinding.
7207        let (core, launched) = started_and_settled(spec(vec![root]));
7208        let key = launched.entities[0].key.clone();
7209
7210        // A step slow enough that the fan-out's own write of `last_action` cannot have
7211        // happened yet by the time the poisoning below completes: `run_action`'s own
7212        // synchronous prefix (admission, `cancel_in_flight`, the read that builds
7213        // `included`) is already finished by the time this call returns,
7214        // so poisoning the lock afterwards can only reach the fan-out's own write,
7215        // inside its own spawned thread.
7216        let started = core.run_action(
7217            action("boom", vec![step(&["sh", "-c", "sleep 0.3"])]),
7218            std::slice::from_ref(&key),
7219        );
7220        assert!(started);
7221
7222        let table = Arc::clone(&core.table);
7223        thread::spawn(move || {
7224            let _guard = table.write().unwrap();
7225            panic!("deliberately poison the table lock for this test");
7226        })
7227        .join()
7228        .expect_err("the poisoning thread must itself panic to poison the lock");
7229
7230        // Without `catch_unwind` around the fan-out this never becomes false: its own write
7231        // panics on the now-poisoned lock, unwinds out of `pool.install` and skips the
7232        // completion transition just past it, leaving this `Core` reading its run as live
7233        // for ever.
7234        wait_for(
7235            "a panicking fan-out to end its run rather than leave it reading as live",
7236            || !core.action_running(),
7237        );
7238
7239        // Clears the poison this test itself introduced to force the panic, an
7240        // artifact of the test rather than anything production code ever does, so a
7241        // real, full `run_action` call below proves the ended run actually lets another
7242        // Action run to completion, not merely that one private read flipped.
7243        core.table.clear_poison();
7244
7245        let second_started = core.run_action(
7246            action("second", vec![step(&["true"])]),
7247            std::slice::from_ref(&key),
7248        );
7249        assert!(
7250            second_started,
7251            "a later Action must be able to start once the panicking one has finished"
7252        );
7253        wait_for("the second Action to run to completion", || {
7254            core.snapshot()
7255                .entities
7256                .iter()
7257                .find(|entity| entity.key == key)
7258                .and_then(|entity| entity.last_action.as_ref())
7259                .is_some_and(|receipt| &*receipt.label == "second")
7260        });
7261    }
7262
7263    /// Asserts `entity` reads exactly as a Vanished row must: still in the table,
7264    /// its last known branch value untouched, and that same cell's staleness
7265    /// forced on. Shared by the Repo and the Submodule vanish tests so both
7266    /// exercise the identical assertion rather than a Repo-shaped one and a
7267    /// Submodule-shaped one that only look alike.
7268    fn assert_vanished_with_stale_branch(entity: &EntityState, expected_branch: &str) {
7269        assert_eq!(entity.presence, crate::entity::Presence::Vanished);
7270        match entity.branch.settled() {
7271            Some(Settled::Known {
7272                value: Head::Branch { name, .. },
7273                stale: true,
7274                at: _,
7275            }) => assert_eq!(
7276                &**name, expected_branch,
7277                "a Vanished entity must keep its last known branch value"
7278            ),
7279            other => panic!(
7280                "expected the branch cell to keep its Known value and go stale, got {other:?}"
7281            ),
7282        }
7283    }
7284
7285    /// The central behaviour this ticket adds: an entity discovery no longer
7286    /// finds stays in the table with its last known values, every cell forced
7287    /// stale, rather than disappearing. Proven end to end through `refresh` and
7288    /// `settle`, which is what proves discovery itself re-ran rather than the
7289    /// entity merely being left alone.
7290    #[test]
7291    fn a_repo_removed_from_disk_stays_in_the_table_vanished_with_its_last_values() {
7292        let dir = tempfile::tempdir().expect("temp dir");
7293        let root = root_of(&dir);
7294        let repo = root.join("repo");
7295        init_repo_with_a_commit(&repo);
7296
7297        let core = Core::start_discovered(spec(vec![root]));
7298        let key = core.snapshot().entities[0].key.clone();
7299        core.refresh(std::slice::from_ref(&key));
7300        let before = core.settle();
7301        let branch_name = match before.entities[0].branch.settled() {
7302            Some(Settled::Known {
7303                value: Head::Branch { name, .. },
7304                at: _,
7305                stale: _,
7306            }) => name.to_string(),
7307            other => panic!("expected the first refresh to settle a branch, got {other:?}"),
7308        };
7309
7310        fs::remove_dir_all(&repo).expect("remove the repo from disk");
7311
7312        core.refresh(&[]);
7313        let after = core.settle();
7314
7315        assert_eq!(
7316            after.entities.len(),
7317            1,
7318            "a vanished entity must stay in the snapshot, not disappear from it"
7319        );
7320        assert_vanished_with_stale_branch(&after.entities[0], &branch_name);
7321    }
7322
7323    /// Criterion 2's "untouched by the vanished-staleness path" made behavioural, through a
7324    /// real `Core::refresh` rather than calling `mark_vanished` directly: the same pass that
7325    /// forces every settled Cell stale on this entity must leave its receipt exactly as it was.
7326    #[test]
7327    fn a_vanished_entitys_action_receipt_survives_the_vanished_staleness_pass_untouched() {
7328        let dir = tempfile::tempdir().expect("temp dir");
7329        let root = root_of(&dir);
7330        let repo = root.join("repo");
7331        init_repo_with_a_commit(&repo);
7332
7333        let core = Core::start_discovered(spec(vec![root]));
7334        let key = core.snapshot().entities[0].key.clone();
7335        let receipt = crate::entity::ActionReceipt {
7336            label: Arc::from("reinstall"),
7337            steps: Arc::from(vec![crate::entity::StepResult {
7338                label: Arc::from("pnpm install"),
7339                outcome: crate::entity::StepOutcome::Ok,
7340                output: Arc::from(&b""[..]),
7341                elapsed: Duration::from_millis(1),
7342                elision: None,
7343                shell: false,
7344                interactive: false,
7345            }]),
7346            skip: None,
7347            finished_at: Timestamp::now(),
7348            running: None,
7349        };
7350        core.set_last_action_for_test(&key, receipt.clone());
7351
7352        fs::remove_dir_all(&repo).expect("remove the repo from disk");
7353        core.refresh(&[]);
7354        let after = core.settle();
7355
7356        let entity = &after.entities[0];
7357        assert_eq!(entity.presence, crate::entity::Presence::Vanished);
7358        assert_eq!(entity.last_action, Some(receipt));
7359    }
7360
7361    /// Criterion 6's reason for `ActionReceipt` sharing rather than copying is "the snapshot
7362    /// is cloned every frame"; a bare `ActionReceipt::clone()` only proves `Arc::clone` shares,
7363    /// which holds by definition and says nothing about this design. Proven instead through
7364    /// `Core::snapshot` itself: put a receipt on a live `Core`'s table, take two snapshots, and
7365    /// assert the label and steps are the same allocation across them, not merely equal. This
7366    /// passes as written, since the sharing does hold end to end; it exists to fail if some
7367    /// intermediate step ever re-materialised the receipt's bytes.
7368    #[test]
7369    fn two_snapshots_of_an_entity_share_its_last_actions_label_and_steps_by_pointer() {
7370        let dir = tempfile::tempdir().expect("temp dir");
7371        let root = root_of(&dir);
7372        let repo = root.join("repo");
7373        init_repo_with_a_commit(&repo);
7374
7375        let core = Core::start_discovered(spec(vec![root]));
7376        let key = core.snapshot().entities[0].key.clone();
7377        let receipt = crate::entity::ActionReceipt {
7378            label: Arc::from("reinstall"),
7379            steps: Arc::from(vec![crate::entity::StepResult {
7380                label: Arc::from("pnpm install"),
7381                outcome: crate::entity::StepOutcome::Failed(1),
7382                output: Arc::from(&b""[..]),
7383                elapsed: Duration::from_millis(1),
7384                elision: None,
7385                shell: false,
7386                interactive: false,
7387            }]),
7388            skip: None,
7389            finished_at: Timestamp::now(),
7390            running: None,
7391        };
7392        core.set_last_action_for_test(&key, receipt);
7393
7394        let first = core.snapshot();
7395        let second = core.snapshot();
7396        let first_receipt = first.entities[0]
7397            .last_action
7398            .as_ref()
7399            .expect("receipt was set");
7400        let second_receipt = second.entities[0]
7401            .last_action
7402            .as_ref()
7403            .expect("receipt was set");
7404
7405        assert!(
7406            Arc::ptr_eq(&first_receipt.label, &second_receipt.label),
7407            "two snapshots of the same receipt must share the label's allocation, not \
7408             re-copy it"
7409        );
7410        assert!(
7411            Arc::ptr_eq(&first_receipt.steps, &second_receipt.steps),
7412            "two snapshots of the same receipt must share the steps slice's allocation, not \
7413             re-copy it, which is also what shares every step's own captured output"
7414        );
7415    }
7416
7417    /// A Submodule vanishes by exactly the same rule as a Repo: no code path here
7418    /// is specific to which half of discovery produced the entry. Driven through
7419    /// the Submodule half (removing its declaration from `.gitmodules`, never
7420    /// touched by the boundary walk) and asserted with the very same helper the
7421    /// Repo test above uses.
7422    #[test]
7423    fn a_submodule_removed_from_gitmodules_vanishes_by_the_same_rule_as_a_repo() {
7424        let dir = tempfile::tempdir().expect("temp dir");
7425        let root = root_of(&dir);
7426        let parent = root.join("parent");
7427        init_repo_with_a_commit(&parent);
7428        fs::write(
7429            parent.join(".gitmodules"),
7430            "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
7431        )
7432        .expect("write .gitmodules");
7433        let submodule_path = parent.join("vendor").join("lib");
7434        init_repo_with_a_commit(&submodule_path);
7435
7436        // Shown, so the explicit `refresh` just below actually dispatches a probe against
7437        // it: this test is about the Vanished rule, not about `show_submodules` gating.
7438        let mut core_spec = spec(vec![root]);
7439        core_spec.show_submodules = true;
7440        let core = Core::start_discovered(core_spec);
7441        let snapshot = core.snapshot();
7442        let submodule_key = snapshot
7443            .entities
7444            .iter()
7445            .find(|entity| matches!(entity.kind, Kind::Submodule))
7446            .expect("submodule discovered")
7447            .key
7448            .clone();
7449        core.refresh(std::slice::from_ref(&submodule_key));
7450        let before = core.settle();
7451        let submodule_before = before
7452            .entities
7453            .iter()
7454            .find(|entity| entity.key == submodule_key)
7455            .expect("submodule present");
7456        let branch_name = match submodule_before.branch.settled() {
7457            Some(Settled::Known {
7458                value: Head::Branch { name, .. },
7459                at: _,
7460                stale: _,
7461            }) => name.to_string(),
7462            other => {
7463                panic!("expected the submodule's first refresh to settle a branch, got {other:?}")
7464            }
7465        };
7466
7467        // The submodule is no longer declared: discovery's second half will no
7468        // longer produce this entry, exactly as removing the parent's own `.git`
7469        // boundary would remove a Repo's entry.
7470        fs::write(parent.join(".gitmodules"), "").expect("clear .gitmodules");
7471
7472        core.refresh(&[]);
7473        let after = core.settle();
7474
7475        let submodule_after = after
7476            .entities
7477            .iter()
7478            .find(|entity| entity.key == submodule_key)
7479            .expect("the vanished submodule must stay in the snapshot");
7480        assert_vanished_with_stale_branch(submodule_after, &branch_name);
7481    }
7482
7483    /// Dismissal writes nothing to disk, so a Repo dismissed from one `Core`
7484    /// reads as an ordinary, freshly discovered Present entity on a brand new
7485    /// `Core` over the same roots, never as a restored Vanished row: startup is
7486    /// always a Generation with an empty prior state.
7487    #[test]
7488    fn dismissal_persists_nothing_across_a_fresh_core() {
7489        let dir = tempfile::tempdir().expect("temp dir");
7490        let root = root_of(&dir);
7491        let repo = root.join("repo");
7492        init_repo_with_a_commit(&repo);
7493
7494        let first_core = Core::start_discovered(spec(vec![root.clone()]));
7495        let key = first_core.snapshot().entities[0].key.clone();
7496        first_core.dismiss(&key);
7497        assert!(first_core.snapshot().entities.is_empty());
7498        drop(first_core);
7499
7500        let second_core = Core::start_discovered(spec(vec![root]));
7501        let snapshot = second_core.snapshot();
7502
7503        assert_eq!(
7504            snapshot.entities.len(),
7505            1,
7506            "a fresh Core must discover the repo again"
7507        );
7508        assert_eq!(
7509            snapshot.entities[0].presence,
7510            crate::entity::Presence::Present,
7511            "nothing from the dismissing Core's lifetime may be persisted, so the \
7512             repo must come back Present, never restored as Vanished"
7513        );
7514    }
7515
7516    /// An entity that moves reads as vanished plus new: its old key stays in the
7517    /// table Vanished with its last values, and a brand new entity appears at the
7518    /// new path, rather than the move being recognised as a rename.
7519    #[test]
7520    fn a_repo_that_moves_reads_as_vanished_plus_new() {
7521        let dir = tempfile::tempdir().expect("temp dir");
7522        let root = root_of(&dir);
7523        let original_path = root.join("original-name");
7524        init_repo_with_a_commit(&original_path);
7525
7526        let core = Core::start_discovered(spec(vec![root.clone()]));
7527        let original_key = core.snapshot().entities[0].key.clone();
7528        core.refresh(std::slice::from_ref(&original_key));
7529        let before = core.settle();
7530        let branch_name = match before.entities[0].branch.settled() {
7531            Some(Settled::Known {
7532                value: Head::Branch { name, .. },
7533                at: _,
7534                stale: _,
7535            }) => name.to_string(),
7536            other => panic!("expected the first refresh to settle a branch, got {other:?}"),
7537        };
7538
7539        let moved_path = root.join("new-name");
7540        fs::rename(&original_path, &moved_path).expect("move the repo on disk");
7541
7542        core.refresh(&[]);
7543        let after = core.settle();
7544
7545        assert_eq!(
7546            after.entities.len(),
7547            2,
7548            "a moved entity must read as the old key vanished plus a new one present, \
7549             never as one renamed entity"
7550        );
7551        let old_entity = after
7552            .entities
7553            .iter()
7554            .find(|entity| entity.key == original_key)
7555            .expect("the old key must stay in the table");
7556        assert_vanished_with_stale_branch(old_entity, &branch_name);
7557        let new_entity = after
7558            .entities
7559            .iter()
7560            .find(|entity| entity.key != original_key)
7561            .expect("a new entity at the moved path must be present");
7562        assert_eq!(new_entity.presence, crate::entity::Presence::Present);
7563        assert_eq!(new_entity.key.path(), moved_path);
7564    }
7565
7566    /// Reappearance is vanishing's mirror: an entity discovery stops finding, and
7567    /// then finds again, must come back Present rather than staying stuck
7568    /// Vanished forever.
7569    #[test]
7570    fn a_vanished_repo_recreated_on_disk_reads_present_on_the_next_refresh() {
7571        let dir = tempfile::tempdir().expect("temp dir");
7572        let root = root_of(&dir);
7573        let repo = root.join("repo");
7574        init_repo_with_a_commit(&repo);
7575
7576        let core = Core::start_discovered(spec(vec![root]));
7577        let key = core.snapshot().entities[0].key.clone();
7578
7579        fs::remove_dir_all(&repo).expect("remove the repo from disk");
7580        core.refresh(&[]);
7581        let vanished = core.settle();
7582        assert_eq!(
7583            vanished.entities[0].presence,
7584            crate::entity::Presence::Vanished,
7585            "the repo must read Vanished once removed from disk"
7586        );
7587
7588        init_repo_with_a_commit(&repo);
7589        core.refresh(&[]);
7590        let recreated = core.settle();
7591
7592        let entity = recreated
7593            .entities
7594            .iter()
7595            .find(|entity| entity.key == key)
7596            .expect("the recreated repo must still resolve to the same entity key");
7597        assert_eq!(
7598            entity.presence,
7599            crate::entity::Presence::Present,
7600            "an entity discovery finds again after it vanished must read Present, \
7601             not stay stuck Vanished forever"
7602        );
7603    }
7604
7605    /// Discovery riding the refresh is what lets a brand new entity appear
7606    /// without a fresh `Core::start`: a repo created after `start` is picked up
7607    /// by the very next `refresh`, even though the caller's `order` cannot yet
7608    /// name a key it never saw.
7609    #[test]
7610    fn a_new_repo_created_after_start_is_discovered_by_the_next_refresh() {
7611        let dir = tempfile::tempdir().expect("temp dir");
7612        let root = root_of(&dir);
7613        init_repo_with_a_commit(&root.join("first"));
7614
7615        let core = Core::start_discovered(spec(vec![root.clone()]));
7616        assert_eq!(core.snapshot().entities.len(), 1);
7617
7618        init_repo_with_a_commit(&root.join("second"));
7619        core.refresh(&[]);
7620        let after = core.settle();
7621
7622        assert_eq!(
7623            after.entities.len(),
7624            2,
7625            "a new repo created after start must be found by the next refresh's own discovery"
7626        );
7627
7628        // The entity is usable, not merely counted: a refresh that names its key
7629        // actually probes it and settles a real cell.
7630        let new_key = after
7631            .entities
7632            .iter()
7633            .find(|entity| &*entity.name == "second")
7634            .expect("the newly discovered repo must be named by the walk")
7635            .key
7636            .clone();
7637        core.refresh(std::slice::from_ref(&new_key));
7638        let probed = core.settle();
7639        let new_entity = probed
7640            .entities
7641            .iter()
7642            .find(|entity| entity.key == new_key)
7643            .expect("the newly discovered repo must still be present");
7644        assert!(
7645            matches!(
7646                new_entity.branch.settled(),
7647                Some(Settled::Known {
7648                    value: _,
7649                    at: _,
7650                    stale: _
7651                })
7652            ),
7653            "a refresh naming the newly discovered repo's key must actually probe \
7654             it and settle its branch cell, got {:?}",
7655            new_entity.branch.settled()
7656        );
7657    }
7658
7659    /// The abandon path takes the Set out of the automatic refresh path: once one
7660    /// discovery invocation abandons, a later `refresh` does not re-run discovery
7661    /// at all, proven by a repo created afterward never appearing, not merely by
7662    /// reading an internal flag.
7663    #[test]
7664    fn an_abandoned_discovery_stops_riding_later_refreshes() {
7665        let dir = tempfile::tempdir().expect("temp dir");
7666        let root = root_of(&dir);
7667        // A wide fan of plain directories, real enough for the walk to measurably
7668        // outrun a millisecond-scale deadline, so `start`'s own discovery
7669        // abandons rather than merely being told to (`Duration::ZERO` would trip
7670        // on the very first directory regardless of what is actually here, which
7671        // could never distinguish a guarded `refresh` from an unguarded one that
7672        // simply keeps re-abandoning against the same still-huge tree).
7673        let decoys = root.join("decoys");
7674        for i in 0..4_000 {
7675            fs::create_dir(decoys.join(format!("decoy-{i}")))
7676                .or_else(|_| fs::create_dir_all(decoys.join(format!("decoy-{i}"))))
7677                .expect("create decoy dir");
7678        }
7679        let (_tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
7680
7681        let started = Core::start_for_test_with_discovery_abandon(
7682            spec(vec![root.clone()]),
7683            Duration::from_secs(3600),
7684            Duration::from_micros(500),
7685            tick_rx,
7686        )
7687        .discovered();
7688        let core = started.core;
7689        assert!(
7690            core.discovery_manual_for_test(),
7691            "walking 4,000 decoy directories against a 500 microsecond deadline \
7692             must have abandoned and taken the Set manual"
7693        );
7694
7695        // The tree shrinks back to nothing slow: if `refresh` were still (wrongly)
7696        // re-running discovery, this walk would finish comfortably inside the
7697        // same deadline and find the new repo below. Only the manual guard can
7698        // account for it staying undiscovered.
7699        fs::remove_dir_all(&decoys).expect("remove decoy directories");
7700        init_repo_with_a_commit(&root.join("second"));
7701
7702        core.refresh(&[]);
7703        let after = core.settle();
7704
7705        assert!(
7706            !after
7707                .entities
7708                .iter()
7709                .any(|entity| &*entity.name == "second"),
7710            "once discovery has abandoned, a later refresh must not re-run it, so a \
7711             repo created afterward, on a tree that would now resolve quickly, \
7712             must still never appear"
7713        );
7714    }
7715
7716    /// `rerun_discovery`'s own abandon handling, exercised by a walk that only
7717    /// abandons on a later `refresh`, never on `start`'s: the first walk, over a
7718    /// tree small enough to finish comfortably inside the deadline, must leave
7719    /// the Set automatic, and only the second walk, once the same tree has grown
7720    /// a wide fan of decoys, may flip the manual flag and leave the abandoned
7721    /// warning. Both existing abandon tests force the abandon inside `start`'s
7722    /// own walk, which can never reach this block: `refresh` gates
7723    /// `rerun_discovery` behind the manual flag `start` already set.
7724    #[test]
7725    fn a_refresh_triggered_discovery_abandon_sets_manual_and_warns() {
7726        let dir = tempfile::tempdir().expect("temp dir");
7727        let root = root_of(&dir);
7728        init_repo_with_a_commit(&root.join("first"));
7729        let (_tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
7730
7731        // The first walk runs under a deadline it cannot lose against, so this
7732        // precondition is not a race. Tightening the deadline afterwards is what
7733        // separates the walk that must survive from the walk that must abandon:
7734        // one deadline serving both is a knife edge, and scheduling latency on a
7735        // loaded machine erases any margin a wall-clock figure can buy.
7736        let started = Core::start_for_test_with_discovery_abandon(
7737            spec(vec![root.clone()]),
7738            Duration::from_secs(3600),
7739            Duration::from_secs(3600),
7740            tick_rx,
7741        )
7742        .discovered();
7743        let core = started.core;
7744        assert!(
7745            !core.discovery_manual_for_test(),
7746            "an hour-long deadline must leave the first walk automatic"
7747        );
7748
7749        // Grown only after the first walk has finished (`discovered` above joined it),
7750        // so this fan of decoys is invisible to that walk and can only be reached by a
7751        // walk `refresh` triggers itself.
7752        let decoys = root.join("decoys");
7753        for i in 0..4_000 {
7754            fs::create_dir(decoys.join(format!("decoy-{i}")))
7755                .or_else(|_| fs::create_dir_all(decoys.join(format!("decoy-{i}"))))
7756                .expect("create decoy dir");
7757        }
7758        core.set_discovery_abandon_after_for_test(Duration::from_micros(500));
7759
7760        core.refresh(&[]);
7761        // `refresh` returns the moment it has reserved its Generation; this is the
7762        // rendezvous that says its own walk has run.
7763        core.wait_dispatched_for_test();
7764
7765        assert!(
7766            core.discovery_manual_for_test(),
7767            "refresh's own rerun_discovery must abandon against the newly-grown \
7768             tree and take the Set manual, the same as an abandon at start does"
7769        );
7770        let warning = core.discovery_warning();
7771        assert!(
7772            warning
7773                .as_deref()
7774                .is_some_and(|message| message.starts_with("discovery: stopped at")),
7775            "refresh's rerun_discovery must leave the abandoned-discovery warning \
7776             behind, not merely flip the manual flag: got {warning:?}"
7777        );
7778    }
7779
7780    /// The other half: an abandoned Set going manual must not leak into a
7781    /// different `Core`. The only way this crate can express "the Set's roots or
7782    /// globs changed" today is a fresh `Core::start` (a live in-place reload has
7783    /// no entry point in `Core` yet), so this proves the manual flag lives on one
7784    /// `Core` instance rather than anywhere global.
7785    #[test]
7786    fn a_fresh_core_over_different_roots_is_unaffected_by_another_cores_abandoned_discovery() {
7787        let abandoned_dir = tempfile::tempdir().expect("temp dir");
7788        let abandoned_root = root_of(&abandoned_dir);
7789        init_repo_with_a_commit(&abandoned_root.join("first"));
7790        let (_tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
7791        let started = Core::start_for_test_with_discovery_abandon(
7792            spec(vec![abandoned_root]),
7793            Duration::from_secs(3600),
7794            Duration::ZERO,
7795            tick_rx,
7796        )
7797        .discovered();
7798        started.core.refresh(&[]);
7799        started.core.settle();
7800        assert!(
7801            started.core.discovery_manual_for_test(),
7802            "the zero-length abandon deadline must have already taken this Core manual"
7803        );
7804        drop(started.core);
7805
7806        let fresh_dir = tempfile::tempdir().expect("temp dir");
7807        let fresh_root = root_of(&fresh_dir);
7808        init_repo_with_a_commit(&fresh_root.join("first"));
7809        let fresh_core = Core::start_discovered(spec(vec![fresh_root.clone()]));
7810        assert_eq!(fresh_core.snapshot().entities.len(), 1);
7811
7812        init_repo_with_a_commit(&fresh_root.join("second"));
7813        fresh_core.refresh(&[]);
7814        let after = fresh_core.settle();
7815
7816        assert_eq!(
7817            after.entities.len(),
7818            2,
7819            "a fresh Core, standing in for the Set's roots changing, must discover \
7820             normally regardless of an earlier, unrelated Core having gone manual"
7821        );
7822    }
7823
7824    /// Proves shutdown is clean: dropping the core blocks until the dedicated
7825    /// thread has actually returned, not merely until a message was sent to it.
7826    /// The tick sender is kept alive for the whole test, so the only way the
7827    /// thread can have stopped is the shutdown message `Drop` sends.
7828    #[test]
7829    fn dropping_the_core_joins_the_dedicated_thread_before_returning() {
7830        let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
7831        let dir = tempfile::tempdir().expect("temp dir");
7832        let root = root_of(&dir);
7833
7834        let started =
7835            Core::start_for_test(spec(vec![root]), Duration::from_secs(3600), tick_rx).discovered();
7836        assert!(started.clock_alive.load(Ordering::Acquire));
7837
7838        drop(started.core);
7839
7840        assert!(
7841            !started.clock_alive.load(Ordering::Acquire),
7842            "the dedicated thread should have exited, and cleared this flag, before drop returned"
7843        );
7844        drop(tick_tx);
7845    }
7846
7847    /// Cadence is driven entirely by the injected tick channel, never by a clock of
7848    /// the loop's own: with a zero deadline, the sweep is provably ready to fire
7849    /// the instant it runs, so whether it has run is exactly whether a tick has
7850    /// been sent, proven with no sleep on either side.
7851    #[test]
7852    fn the_deadline_sweep_runs_only_when_a_tick_arrives() {
7853        let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
7854        let dir = tempfile::tempdir().expect("temp dir");
7855        let root = root_of(&dir);
7856        let repo = root.join("repo");
7857        init_repo_with_a_commit(&repo);
7858
7859        let mut spec = spec(vec![root]);
7860        spec.generation_deadline = Duration::ZERO;
7861        let started = Core::start_for_test(spec, Duration::from_secs(3600), tick_rx).discovered();
7862        let core = started.core;
7863        // Drained, so the only entry in flight below is this test's own and only the sweep
7864        // can settle it again.
7865        let key = settle_launch(&core).entities[0].key.clone();
7866
7867        core.begin_untracked_probe_for_test(&key);
7868
7869        // No tick has been sent: the sweep has not run even though the (zero)
7870        // deadline has already elapsed in real time.
7871        let before = core.snapshot();
7872        assert!(
7873            matches!(
7874                before.entities[0].branch.settled(),
7875                Some(Settled::Known {
7876                    value: _,
7877                    at: _,
7878                    stale: _
7879                })
7880            ),
7881            "the cell still holds launch's own answer here, so the Unknown below is the \
7882             sweep's write rather than a cell that was already empty"
7883        );
7884        assert!(before.entities[0].branch.is_in_flight());
7885
7886        tick_tx.send(Instant::now()).expect("send one tick");
7887        let after = core.settle();
7888
7889        assert!(matches!(
7890            after.entities[0].branch.settled(),
7891            Some(Settled::Unknown(Unknown::TimedOut))
7892        ));
7893    }
7894
7895    /// Proves the real dedicated thread's tick arm actually reaches
7896    /// [`run_poll_sweep`], not merely that [`Core::poll_once_for_test`]'s direct
7897    /// call does the right thing: a mutation deleting the call inside
7898    /// `spawn_clock_thread` would leave every other poll test in this file green
7899    /// while failing only this one. [`wait_for`] backstops the wait rather than
7900    /// asserting any particular latency: the two ticks are sent from this thread
7901    /// and merely need to be picked up by the idle dedicated thread, not to land
7902    /// within a stated budget.
7903    #[test]
7904    fn a_real_tick_through_the_dedicated_thread_reaches_the_poll_sweep_and_reprobes_a_moved_entity()
7905    {
7906        let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
7907        let dir = tempfile::tempdir().expect("temp dir");
7908        let root = root_of(&dir);
7909        let repo = root.join("repo");
7910        init_repo_with_a_commit(&repo);
7911
7912        let started =
7913            Core::start_for_test(spec(vec![root]), Duration::from_secs(3600), tick_rx).discovered();
7914        let core = started.core;
7915        let key = core.snapshot().entities[0].key.clone();
7916
7917        backdate_polled_entries(&repo);
7918
7919        // The first tick only records a baseline: nothing has moved yet against a
7920        // fingerprint that did not exist before this tick.
7921        tick_tx
7922            .send(Instant::now())
7923            .expect("send the baseline tick");
7924        wait_for(
7925            "a tick sent on the real channel to reach the poll sweep",
7926            || core.poll_sweep_count_for_test() >= 1,
7927        );
7928        assert!(core.poll_reprobed_for_test().is_empty());
7929
7930        commit_a_change(&repo, "second");
7931
7932        tick_tx
7933            .send(Instant::now())
7934            .expect("send the movement tick");
7935        wait_for(
7936            "the real tick channel to reach the poll sweep and reprobe the moved entity",
7937            || core.poll_reprobed_for_test() == vec![key.clone()],
7938        );
7939        drop(tick_tx);
7940    }
7941
7942    /// Criterion 2's whole claim, over two entities so "for that entity only" has
7943    /// something to discriminate against: committing into one of two Repos and
7944    /// running one poll sweep re-probes branch/sync/base for the moved Repo alone
7945    /// (`poll_reprobed_for_test` names exactly it, never the other), force-stales
7946    /// its `dirty` and `state` without changing their value or timestamp (the
7947    /// absence claim that no status probe ran), and leaves the untouched Repo's
7948    /// cells byte-for-byte as the prior real `refresh` left them.
7949    #[test]
7950    fn poll_reprobe_touches_only_the_moved_entity_and_never_runs_a_status_probe() {
7951        let dir = tempfile::tempdir().expect("temp dir");
7952        let root = root_of(&dir);
7953        let repo_a = root.join("repo-a");
7954        let repo_b = root.join("repo-b");
7955        init_repo_with_a_commit(&repo_a);
7956        init_repo_with_a_commit(&repo_b);
7957
7958        let core = Core::start_discovered(spec(vec![root]));
7959        let snapshot = core.snapshot();
7960        let key_a = snapshot
7961            .entities
7962            .iter()
7963            .find(|entity| entity.key.path() == repo_a)
7964            .expect("repo-a discovered")
7965            .key
7966            .clone();
7967        let key_b = snapshot
7968            .entities
7969            .iter()
7970            .find(|entity| entity.key.path() == repo_b)
7971            .expect("repo-b discovered")
7972            .key
7973            .clone();
7974
7975        core.refresh(&[key_a.clone(), key_b.clone()]);
7976        let landed = core.settle();
7977        let entity_of = |snapshot: &Snapshot, key: &EntityKey| {
7978            snapshot
7979                .entities
7980                .iter()
7981                .find(|entity| &entity.key == key)
7982                .expect("entity present")
7983                .clone()
7984        };
7985        let a_before = entity_of(&landed, &key_a);
7986        let b_before = entity_of(&landed, &key_b);
7987        let branch_at = |entity: &EntityState| match entity.branch.settled() {
7988            Some(Settled::Known {
7989                at,
7990                value: _,
7991                stale: _,
7992            }) => *at,
7993            other => panic!("expected a landed branch, got {other:?}"),
7994        };
7995        let dirty_state = |entity: &EntityState| match entity.dirty.settled() {
7996            Some(Settled::Known { value, at, stale }) => (*value, *at, *stale),
7997            other => panic!("expected a landed dirty count, got {other:?}"),
7998        };
7999        let (a_dirty_value_before, a_dirty_at_before, a_dirty_stale_before) =
8000            dirty_state(&a_before);
8001        assert!(
8002            !a_dirty_stale_before,
8003            "the fresh refresh must land dirty as not stale"
8004        );
8005
8006        backdate_polled_entries(&repo_a);
8007
8008        backdate_polled_entries(&repo_b);
8009
8010        core.poll_once_for_test();
8011        assert!(
8012            core.poll_reprobed_for_test().is_empty(),
8013            "a first sweep has nothing to compare against, so it must report no movement"
8014        );
8015
8016        commit_a_change(&repo_a, "second");
8017        core.poll_once_for_test();
8018
8019        assert_eq!(
8020            core.poll_reprobed_for_test(),
8021            vec![key_a.clone()],
8022            "only the entity whose gitdir actually moved must be re-probed"
8023        );
8024
8025        let after = core.snapshot();
8026        let a_after = entity_of(&after, &key_a);
8027        let b_after = entity_of(&after, &key_b);
8028
8029        assert_ne!(
8030            branch_at(&a_after),
8031            branch_at(&a_before),
8032            "the moved entity's branch must carry a fresh timestamp from the re-probe"
8033        );
8034        let (a_dirty_value_after, a_dirty_at_after, a_dirty_stale_after) = dirty_state(&a_after);
8035        assert_eq!(
8036            a_dirty_value_after, a_dirty_value_before,
8037            "no status probe ran, so dirty's value must be exactly what the last real refresh \
8038             landed"
8039        );
8040        assert_eq!(
8041            a_dirty_at_after, a_dirty_at_before,
8042            "no status probe ran, so dirty's timestamp must be untouched, only its stale flag \
8043             set"
8044        );
8045        assert!(
8046            a_dirty_stale_after,
8047            "the moved entity's dirty cell must go stale on poll evidence"
8048        );
8049
8050        assert_eq!(
8051            branch_at(&b_after),
8052            branch_at(&b_before),
8053            "the untouched entity's branch must be exactly as the prior refresh left it"
8054        );
8055        let (b_dirty_value_after, b_dirty_at_after, b_dirty_stale_after) = dirty_state(&b_after);
8056        let (b_dirty_value_before, b_dirty_at_before, b_dirty_stale_before) =
8057            dirty_state(&b_before);
8058        assert_eq!(b_dirty_value_after, b_dirty_value_before);
8059        assert_eq!(b_dirty_at_after, b_dirty_at_before);
8060        assert_eq!(
8061            b_dirty_stale_after, b_dirty_stale_before,
8062            "an entity the sweep found unmoved must never go stale"
8063        );
8064    }
8065
8066    /// Criterion 3's attached half, and one of `refresh.md`'s two named traps: a
8067    /// commit on an attached HEAD never touches `.git/HEAD` at all, only
8068    /// `.git/logs/HEAD`. The poll must still see the commit, through `index`
8069    /// rather than through `HEAD`.
8070    #[test]
8071    fn poll_detects_an_attached_commit_through_index_while_head_itself_never_moves() {
8072        let dir = tempfile::tempdir().expect("temp dir");
8073        let root = root_of(&dir);
8074        let repo = root.join("repo");
8075        init_repo_with_a_commit(&repo);
8076
8077        let core = Core::start_discovered(spec(vec![root]));
8078        let key = core.snapshot().entities[0].key.clone();
8079        backdate_polled_entries(&repo);
8080        core.poll_once_for_test();
8081        assert!(core.poll_reprobed_for_test().is_empty());
8082
8083        let head_path = repo.join(".git").join("HEAD");
8084        let head_mtime_before = fs::metadata(&head_path)
8085            .expect("stat HEAD")
8086            .modified()
8087            .expect("HEAD mtime");
8088
8089        commit_a_change(&repo, "second");
8090
8091        let head_mtime_after = fs::metadata(&head_path)
8092            .expect("stat HEAD")
8093            .modified()
8094            .expect("HEAD mtime");
8095        assert_eq!(
8096            head_mtime_before, head_mtime_after,
8097            "a commit on an attached HEAD must never touch HEAD itself"
8098        );
8099
8100        core.poll_once_for_test();
8101        assert_eq!(
8102            core.poll_reprobed_for_test(),
8103            vec![key],
8104            "the poll must still detect the attached commit, through index rather than HEAD"
8105        );
8106    }
8107
8108    /// Criterion 3's detached half: [head.md](https://github.com/paulchiu/repon/blob/main/docs/spec/head.md)'s
8109    /// claim that a detached row's evidence is better than an attached row's,
8110    /// because a commit on a detached HEAD writes the new object id straight into
8111    /// the per-worktree `HEAD` file itself. Run against a real linked Worktree,
8112    /// never the main working tree, since that per-worktree file is exactly what
8113    /// distinguishes this case from the attached one above.
8114    #[test]
8115    fn poll_detects_a_detached_commit_through_the_per_worktree_head_file() {
8116        let dir = tempfile::tempdir().expect("temp dir");
8117        let root = root_of(&dir);
8118        let parent = root.join("parent");
8119        init_repo_with_a_commit(&parent);
8120        let worktree_path = root.join("detached-worktree");
8121        let status = Command::new("git")
8122            .arg("-C")
8123            .arg(&parent)
8124            .args([
8125                "worktree",
8126                "add",
8127                "--detach",
8128                worktree_path.to_str().expect("utf8 path"),
8129            ])
8130            .status()
8131            .expect("run git worktree add");
8132        assert!(status.success());
8133
8134        let core = Core::start_discovered(spec(vec![root]));
8135        let snapshot = core.snapshot();
8136        let worktree_key = snapshot
8137            .entities
8138            .iter()
8139            .find(|entity| matches!(entity.kind, Kind::Worktree))
8140            .expect("worktree discovered")
8141            .key
8142            .clone();
8143
8144        backdate_polled_entries(&parent);
8145        backdate_polled_entries(&worktree_path);
8146
8147        core.poll_once_for_test();
8148        assert!(core.poll_reprobed_for_test().is_empty());
8149
8150        let worktree_head_path = parent
8151            .join(".git")
8152            .join("worktrees")
8153            .join("detached-worktree")
8154            .join("HEAD");
8155        let head_mtime_before = fs::metadata(&worktree_head_path)
8156            .expect("stat the per-worktree HEAD")
8157            .modified()
8158            .expect("HEAD mtime");
8159
8160        commit_a_change(&worktree_path, "on the detached worktree");
8161
8162        let head_mtime_after = fs::metadata(&worktree_head_path)
8163            .expect("stat the per-worktree HEAD")
8164            .modified()
8165            .expect("HEAD mtime");
8166        assert_ne!(
8167            head_mtime_before, head_mtime_after,
8168            "a commit on a detached HEAD must write the new object id straight into its own \
8169             HEAD file"
8170        );
8171
8172        core.poll_once_for_test();
8173        assert_eq!(
8174            core.poll_reprobed_for_test(),
8175            vec![worktree_key],
8176            "the poll must detect the detached commit via the per-worktree HEAD file"
8177        );
8178    }
8179
8180    /// Criterion 4's elapsed-age writer, wired through `Core::snapshot` end to end:
8181    /// `status_stale_after` from `CoreSpec` is what decides whether a freshly
8182    /// landed `dirty` cell already reads Stale. A `Duration::from_nanos(1)`
8183    /// threshold has necessarily already elapsed by the time `snapshot` runs
8184    /// afterwards, so this needs no sleep and depends on no stated latency budget,
8185    /// only on real wall-clock time having advanced at all between two calls.
8186    #[test]
8187    fn snapshot_ages_a_freshly_landed_dirty_cell_stale_once_status_stale_after_has_elapsed() {
8188        let dir = tempfile::tempdir().expect("temp dir");
8189        let root = root_of(&dir);
8190        let repo = root.join("repo");
8191        init_repo_with_a_commit(&repo);
8192
8193        let mut short_lived = spec(vec![root]);
8194        short_lived.status_stale_after = Duration::from_nanos(1);
8195        let core = Core::start_discovered(short_lived);
8196        let key = core.snapshot().entities[0].key.clone();
8197        core.refresh(std::slice::from_ref(&key));
8198        core.settle();
8199
8200        let aged = core.snapshot();
8201        match aged.entities[0].dirty.settled() {
8202            Some(Settled::Known {
8203                stale: true,
8204                value: _,
8205                at: _,
8206            }) => {}
8207            other => panic!(
8208                "expected a landed dirty cell to have already aged past a one-nanosecond \
8209                 threshold, got {other:?}"
8210            ),
8211        }
8212    }
8213
8214    /// The same wiring's other side: a landed `dirty` cell stays fresh under a
8215    /// large `status_stale_after`, so the wiring is genuinely reading the
8216    /// threshold rather than always staling.
8217    #[test]
8218    fn snapshot_leaves_a_freshly_landed_dirty_cell_fresh_under_a_large_status_stale_after() {
8219        let dir = tempfile::tempdir().expect("temp dir");
8220        let root = root_of(&dir);
8221        let repo = root.join("repo");
8222        init_repo_with_a_commit(&repo);
8223
8224        let core = Core::start_discovered(spec(vec![root]));
8225        let key = core.snapshot().entities[0].key.clone();
8226        core.refresh(std::slice::from_ref(&key));
8227        core.settle();
8228
8229        let fresh = core.snapshot();
8230        match fresh.entities[0].dirty.settled() {
8231            Some(Settled::Known {
8232                stale: false,
8233                value: _,
8234                at: _,
8235            }) => {}
8236            other => panic!("expected a freshly landed dirty cell to stay fresh, got {other:?}"),
8237        }
8238    }
8239
8240    /// Criterion 5's absence claim: a hidden Submodule (`show_submodules` off) is
8241    /// never in the poll's own candidate set, so a commit into it is never
8242    /// detected, while the identical commit against the same Submodule shown is.
8243    /// Run as one test over the same fixture with the flag flipped, rather than
8244    /// two, so the only variable between the two sweeps is the flag itself.
8245    #[test]
8246    fn hidden_submodules_are_never_polled_but_shown_ones_are() {
8247        let dir = tempfile::tempdir().expect("temp dir");
8248        let root = root_of(&dir);
8249        let parent = root.join("parent");
8250        init_repo_with_a_commit(&parent);
8251        fs::write(
8252            parent.join(".gitmodules"),
8253            "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
8254        )
8255        .expect("write .gitmodules");
8256        let submodule_path = parent.join("vendor").join("lib");
8257        init_repo_with_a_commit(&submodule_path);
8258
8259        let mut hidden_spec = spec(vec![root.clone()]);
8260        hidden_spec.show_submodules = false;
8261        let hidden_core = Core::start_discovered(hidden_spec);
8262        // Discovery's own pass always runs regardless of the flag
8263        // (discovery.md's "Showing Submodules": "the pass always runs, so
8264        // Submodules are always known"), so the row exists; only probing and the
8265        // poll are gated on it.
8266        let hidden_submodule_key = hidden_core
8267            .snapshot()
8268            .entities
8269            .iter()
8270            .find(|entity| matches!(entity.kind, Kind::Submodule))
8271            .expect("the submodule is discovered regardless of show_submodules")
8272            .key
8273            .clone();
8274        backdate_polled_entries(&submodule_path);
8275        hidden_core.poll_once_for_test();
8276        commit_a_change(&submodule_path, "into the hidden submodule");
8277        hidden_core.poll_once_for_test();
8278        assert!(
8279            !hidden_core
8280                .poll_reprobed_for_test()
8281                .contains(&hidden_submodule_key),
8282            "a hidden Submodule must never be re-probed by the poll, since it was never \
8283             polled at all"
8284        );
8285        drop(hidden_core);
8286
8287        let mut shown_spec = spec(vec![root]);
8288        shown_spec.show_submodules = true;
8289        let shown_core = Core::start_discovered(shown_spec);
8290        let submodule_key = shown_core
8291            .snapshot()
8292            .entities
8293            .iter()
8294            .find(|entity| matches!(entity.kind, Kind::Submodule))
8295            .expect("the submodule is discovered regardless of show_submodules")
8296            .key
8297            .clone();
8298        backdate_polled_entries(&submodule_path);
8299        shown_core.poll_once_for_test();
8300        commit_a_change(&submodule_path, "into the shown submodule");
8301        shown_core.poll_once_for_test();
8302        assert_eq!(
8303            shown_core.poll_reprobed_for_test(),
8304            vec![submodule_key],
8305            "a shown Submodule must be polled and re-probed exactly like any other row"
8306        );
8307    }
8308
8309    /// Pause cancels a real in-flight entry (not merely stores a flag nobody
8310    /// reads): the cancel flag `begin_untracked_probe_for_test` returns is
8311    /// observed `true` afterward, and `settle` unblocks because pause released it,
8312    /// which is only possible if pause's handler on the dedicated thread actually
8313    /// ran.
8314    #[test]
8315    fn pause_cancels_every_in_flight_entity_and_releases_a_pending_settle() {
8316        let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
8317        let dir = tempfile::tempdir().expect("temp dir");
8318        let root = root_of(&dir);
8319        let repo = root.join("repo");
8320        init_repo_with_a_commit(&repo);
8321
8322        let started =
8323            Core::start_for_test(spec(vec![root]), Duration::from_secs(3600), tick_rx).discovered();
8324        let core = started.core;
8325        // Drained, so the only entry in flight below is the one this test puts there.
8326        let key = settle_launch(&core).entities[0].key.clone();
8327        let cancel = core.begin_untracked_probe_for_test(&key);
8328        assert!(!cancel.load(Ordering::Acquire));
8329
8330        core.pause();
8331        let settled = core.settle();
8332
8333        assert!(
8334            cancel.load(Ordering::Acquire),
8335            "pause should cancel the entity that was in flight"
8336        );
8337        assert!(settled.entities[0].branch.is_in_flight());
8338        drop(tick_tx);
8339    }
8340
8341    /// A launch walks the tree once.
8342    ///
8343    /// Discovery rides on every Generation
8344    /// ([refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
8345    /// "Discovery is never on the calling thread"), so counting a launch's walks is
8346    /// counting its Generations: one walk means the very first Generation a fresh `Core`
8347    /// mints is the only one a settled launch has, and that it already covers every row
8348    /// the walk found. A second walk would be a second Generation and would read here.
8349    #[test]
8350    fn a_launch_is_one_generation_over_every_row_its_own_walk_found() {
8351        let dir = tempfile::tempdir().expect("temp dir");
8352        let root = root_of(&dir);
8353        init_repo_with_a_commit(&root.join("first"));
8354        init_repo_with_a_commit(&root.join("second"));
8355
8356        let (_core, launched) = started_and_settled(spec(vec![root]));
8357
8358        assert_eq!(
8359            launched.generation,
8360            Generation::default().successor(),
8361            "a launch must settle on the first Generation a fresh `Core` mints; a second \
8362             walk of the same tree would be a second Generation"
8363        );
8364        let mut named: Vec<String> = launched
8365            .entities
8366            .iter()
8367            .filter(|entity| entity.branch.settled().is_some())
8368            .map(|entity| entity.name.to_string())
8369            .collect();
8370        named.sort();
8371        assert_eq!(
8372            named,
8373            vec!["first".to_string(), "second".to_string()],
8374            "that one Generation must cover every row its own walk found, or the walk it \
8375             saved would have to be paid by a second one"
8376        );
8377    }
8378
8379    /// A `Core` going away cancels what it still has in flight, the same way `pause` does.
8380    ///
8381    /// [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
8382    /// "Cancellation": an abandoned Generation is cancelled rather than left to finish,
8383    /// because both would contend for the same cores. A Set switch is where that bites,
8384    /// rebuilding the `Core` while the outgoing one's fan-out is still running.
8385    #[test]
8386    fn dropping_a_core_cancels_every_entity_it_still_has_in_flight() {
8387        let dir = tempfile::tempdir().expect("temp dir");
8388        let root = root_of(&dir);
8389        init_repo_with_a_commit(&root.join("repo"));
8390
8391        let (core, launched) = started_and_settled(spec(vec![root]));
8392        let key = launched.entities[0].key.clone();
8393        let cancel = core.begin_untracked_probe_for_test(&key);
8394        assert!(!cancel.load(Ordering::Acquire));
8395
8396        drop(core);
8397
8398        assert!(
8399            cancel.load(Ordering::Acquire),
8400            "a dropped Core must cancel the Generation it still has in flight rather than \
8401             leave it running against a Set nothing will read again"
8402        );
8403    }
8404
8405    /// Per-entity supersession, not global. An older Generation covers two entities,
8406    /// A and B, both simulated as still in flight. A Selection-scoped newer
8407    /// Generation covers only A: A's own older interrupt flag must be set, and B's
8408    /// must not, since the newer one never mentions B. Once the newer Generation has
8409    /// written A's cell, A's slow older result finally arrives and must be dropped
8410    /// there; B's own older result, arriving after everything else, must still be
8411    /// accepted, because the newer Generation never superseded it.
8412    ///
8413    /// The two are named by their order, never by their counter values, so a
8414    /// Generation minted earlier in the crate cannot renumber this test out from
8415    /// under itself.
8416    ///
8417    /// This is exactly the distinction a global-current-Generation comparison
8418    /// would get wrong: such a check compares every write against the table's one
8419    /// counter, which the Selection-scoped refresh has already advanced, so B's
8420    /// older result would be wrongly dropped even though nothing ever superseded B
8421    /// specifically. Before `Cell::settle`'s comparison was wired
8422    /// against the cell's own recorded Generation this test failed exactly there:
8423    /// B's late result was rejected, which is precisely the "cannot strand the
8424    /// rows it never spoke for" defect the ticket names.
8425    ///
8426    /// This test read A's interrupt flag intermittently false under load. The cause was
8427    /// `apply_probe_outcome` clearing the in-flight entry by key alone: launch's own
8428    /// Generation was left undrained here, so one of its probes could finish after the
8429    /// simulated older Generation had put its flags under the same keys and delete the
8430    /// entry holding them, leaving the Selection-scoped refresh nothing to supersede.
8431    /// Launch is drained first now, and the entry is cleared by Generation as well as by
8432    /// key, which `a_probe_finishing_clears_only_its_own_generations_in_flight_entry`
8433    /// pins directly.
8434    #[test]
8435    fn a_selection_scoped_refresh_supersedes_only_the_entity_it_covers() {
8436        let dir = tempfile::tempdir().expect("temp dir");
8437        let root = root_of(&dir);
8438        init_repo_with_a_commit(&root.join("a"));
8439        init_repo_with_a_commit(&root.join("b"));
8440
8441        let (core, snapshot) = started_and_settled(spec(vec![root]));
8442        let key_a = snapshot
8443            .entities
8444            .iter()
8445            .find(|entity| &*entity.name == "a")
8446            .expect("entity a discovered")
8447            .key
8448            .clone();
8449        let key_b = snapshot
8450            .entities
8451            .iter()
8452            .find(|entity| &*entity.name == "b")
8453            .expect("entity b discovered")
8454            .key
8455            .clone();
8456
8457        // The older Generation, simulated: both A and B are mid-flight, with nothing
8458        // spawned to complete either one, so the test controls exactly when each
8459        // one's result lands.
8460        let older = core.begin_shared_generation_for_test(&[key_a.clone(), key_b.clone()]);
8461
8462        // A Selection-scoped refresh over A alone, the very next Generation after the
8463        // one still in flight.
8464        let newer = core.refresh(std::slice::from_ref(&key_a));
8465        assert_eq!(
8466            newer,
8467            older.generation.successor(),
8468            "the Selection-scoped refresh must be the Generation immediately after the one \
8469             still in flight, with nothing minted in between"
8470        );
8471
8472        // Supersession happens on the new Generation's own thread, behind its walk, so
8473        // this is the rendezvous that says it has happened. A join, never a deadline: no
8474        // production rule bounds how long that walk takes short of the thirty seconds at
8475        // which discovery is abandoned.
8476        core.wait_dispatched_for_test();
8477        assert!(
8478            older.cancels[&key_a].load(Ordering::Acquire),
8479            "the entity the new Generation covers must have its old interrupt flag set"
8480        );
8481        assert!(
8482            !older.cancels[&key_b].load(Ordering::Acquire),
8483            "an entity the new Generation does not cover must be left running, untouched"
8484        );
8485
8486        // [`BACKSTOP`] rather than a budget: what follows reads the cell the new
8487        // Generation's own probe writes, which is a liveness property with no wall-clock
8488        // bound of its own.
8489        let after_refresh = core.settle();
8490
8491        let a_after_gen2 = after_refresh
8492            .entities
8493            .iter()
8494            .find(|entity| entity.key == key_a)
8495            .expect("entity a present");
8496        assert!(
8497            matches!(
8498                a_after_gen2.branch.settled(),
8499                Some(Settled::Known {
8500                    value: Head::Branch { .. },
8501                    at: _,
8502                    stale: _
8503                })
8504            ),
8505            "the newer Generation's real probe should have written A's cell by now"
8506        );
8507
8508        // A's slow older result finally arrives, after the newer Generation has
8509        // already written the cell: dropped, since it is lower than the Generation
8510        // already recorded there.
8511        core.apply_probe_result_for_test(
8512            &key_a,
8513            older.generation,
8514            Settled::Known {
8515                value: Head::Branch {
8516                    name: Arc::from("stale-from-generation-one"),
8517                    commit: gix::hash::Kind::Sha1.null(),
8518                },
8519                at: Timestamp::now(),
8520                stale: false,
8521            },
8522        );
8523        let after_stale_write = core.snapshot();
8524        let a_final = after_stale_write
8525            .entities
8526            .iter()
8527            .find(|entity| entity.key == key_a)
8528            .expect("entity a present");
8529        match a_final.branch.settled() {
8530            Some(Settled::Known {
8531                value: Head::Branch { name, .. },
8532                at: _,
8533                stale: _,
8534            }) => assert_ne!(
8535                &**name, "stale-from-generation-one",
8536                "a lower-Generation result must be dropped at the cell it would write"
8537            ),
8538            other => panic!("expected A to still hold the newer Generation's value, got {other:?}"),
8539        }
8540
8541        // B's own older result, landing last of all, is still accepted: the newer
8542        // Generation never covered B, so nothing superseded it.
8543        core.apply_probe_result_for_test(
8544            &key_b,
8545            older.generation,
8546            Settled::Known {
8547                value: Head::Branch {
8548                    name: Arc::from("b-generation-one-result"),
8549                    commit: gix::hash::Kind::Sha1.null(),
8550                },
8551                at: Timestamp::now(),
8552                stale: false,
8553            },
8554        );
8555        let final_snapshot = core.snapshot();
8556        let b_final = final_snapshot
8557            .entities
8558            .iter()
8559            .find(|entity| entity.key == key_b)
8560            .expect("entity b present");
8561        match b_final.branch.settled() {
8562            Some(Settled::Known {
8563                value: Head::Branch { name, .. },
8564                at: _,
8565                stale: _,
8566            }) => assert_eq!(
8567                &**name, "b-generation-one-result",
8568                "an entity the new Generation never covered must still accept its own result"
8569            ),
8570            other => {
8571                panic!("expected B's un-superseded older result to be accepted, got {other:?}")
8572            }
8573        }
8574    }
8575
8576    /// The deadline sweep abandons only what is still Loading when it fires. An
8577    /// entity already settled by the time the deadline sweep runs keeps its value
8578    /// untouched, blanking nothing, while a different entity still mid-flight in
8579    /// the same sweep becomes Unknown with the timed-out reason.
8580    #[test]
8581    fn the_deadline_sweep_keeps_already_settled_cells_and_only_times_out_what_is_still_loading() {
8582        let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
8583        let dir = tempfile::tempdir().expect("temp dir");
8584        let root = root_of(&dir);
8585        init_repo_with_a_commit(&root.join("a"));
8586        init_repo_with_a_commit(&root.join("b"));
8587
8588        let mut spec = spec(vec![root]);
8589        spec.generation_deadline = Duration::ZERO;
8590        let started = Core::start_for_test(spec, Duration::from_secs(3600), tick_rx).discovered();
8591        let core = started.core;
8592        // Drained, so the only cell still loading when the sweep fires is the one this
8593        // test puts in flight.
8594        let snapshot = settle_launch(&core);
8595        let key_a = snapshot
8596            .entities
8597            .iter()
8598            .find(|entity| &*entity.name == "a")
8599            .expect("entity a discovered")
8600            .key
8601            .clone();
8602        let key_b = snapshot
8603            .entities
8604            .iter()
8605            .find(|entity| &*entity.name == "b")
8606            .expect("entity b discovered")
8607            .key
8608            .clone();
8609
8610        // A is already settled, synchronously, before the deadline ever has a
8611        // chance to fire.
8612        let a_settled = core.probe_now(&key_a);
8613        let a_value_before = match a_settled.branch.settled() {
8614            Some(Settled::Known {
8615                value: Head::Branch { name, .. },
8616                at: _,
8617                stale: _,
8618            }) => Arc::clone(name),
8619            other => panic!("expected A's synchronous probe to settle a branch, got {other:?}"),
8620        };
8621
8622        // B is left mid-flight, in a Generation whose (zero) deadline has already
8623        // elapsed in real time, but the sweep has not run yet: no tick has been
8624        // sent.
8625        let cancel_b = core.begin_untracked_probe_for_test(&key_b);
8626        let before_tick = core.snapshot();
8627        let b_before = before_tick
8628            .entities
8629            .iter()
8630            .find(|entity| entity.key == key_b)
8631            .expect("entity b present");
8632        assert!(
8633            b_before.branch.is_in_flight(),
8634            "B must be mid-flight when the sweep fires; that is the only shape the sweep \
8635             may touch"
8636        );
8637        assert!(
8638            matches!(
8639                b_before.branch.settled(),
8640                Some(Settled::Known {
8641                    value: _,
8642                    at: _,
8643                    stale: _
8644                })
8645            ),
8646            "B still carries launch's own answer here, so the Unknown below is a write the \
8647             sweep made rather than a cell that was already empty, got {:?}",
8648            b_before.branch.settled()
8649        );
8650
8651        tick_tx.send(Instant::now()).expect("send one tick");
8652        let after_sweep = core.settle();
8653
8654        let a_after = after_sweep
8655            .entities
8656            .iter()
8657            .find(|entity| entity.key == key_a)
8658            .expect("entity a present");
8659        match a_after.branch.settled() {
8660            Some(Settled::Known {
8661                value: Head::Branch { name, .. },
8662                at: _,
8663                stale: _,
8664            }) => assert_eq!(
8665                name, &a_value_before,
8666                "an already-settled cell must keep its value when the deadline sweep runs, not be blanked"
8667            ),
8668            other => panic!("expected A's settled value to survive the sweep, got {other:?}"),
8669        }
8670
8671        let b_after = after_sweep
8672            .entities
8673            .iter()
8674            .find(|entity| entity.key == key_b)
8675            .expect("entity b present");
8676        assert!(matches!(
8677            b_after.branch.settled(),
8678            Some(Settled::Unknown(Unknown::TimedOut))
8679        ));
8680        assert!(
8681            !cancel_b.load(Ordering::Acquire),
8682            "the deadline sweep marks a cell Unknown; it never sets the entity's own \
8683             cancel flag, since the underlying probe (nonexistent here) is left to keep running"
8684        );
8685    }
8686
8687    /// The deadline sweep must reach a Worktree's outstanding `state` cell the
8688    /// same way it already reaches `branch` and `default_branch`: asking and
8689    /// getting nothing back is Unknown, not a cell stuck in-flight forever once
8690    /// the Generation that would have answered it is gone. A Repo's `state`,
8691    /// `NotApplicable` from construction and never in flight, must survive the
8692    /// same sweep untouched, proving the sweep only times out a cell actually
8693    /// marked in flight rather than blanket-settling every entity's `state` cell.
8694    #[test]
8695    fn the_deadline_sweep_times_out_a_worktrees_outstanding_state_but_leaves_a_repos_not_applicable_one_alone()
8696     {
8697        let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
8698        let dir = tempfile::tempdir().expect("temp dir");
8699        let root = root_of(&dir);
8700        let parent = root.join("parent");
8701        init_repo_with_a_commit(&parent);
8702        let worktree_path = root.join("feature-worktree");
8703        git(
8704            &parent,
8705            &[
8706                "worktree",
8707                "add",
8708                "-b",
8709                "feature",
8710                worktree_path.to_str().expect("utf8 path"),
8711            ],
8712        );
8713
8714        let mut spec = spec(vec![root]);
8715        spec.generation_deadline = Duration::ZERO;
8716        let started = Core::start_for_test(spec, Duration::from_secs(3600), tick_rx).discovered();
8717        let core = started.core;
8718        // Drained, so launch's own real refresh has already landed on every cell before
8719        // either `begin_untracked_probe_for_test` call below puts one artificially back in
8720        // flight: skipping this left a real probe for the same cell free to settle it
8721        // between that call and the sweep, which turns the sweep's own `is_in_flight`
8722        // guard (working exactly as designed, since a cell no longer loading is not the
8723        // sweep's to touch) into a race the assertion below loses however rarely.
8724        let snapshot = settle_launch(&core);
8725        let repo_key = snapshot
8726            .entities
8727            .iter()
8728            .find(|entity| matches!(entity.kind, Kind::Repo))
8729            .expect("repo entity present")
8730            .key
8731            .clone();
8732        let worktree_key = snapshot
8733            .entities
8734            .iter()
8735            .find(|entity| matches!(entity.kind, Kind::Worktree))
8736            .expect("worktree entity present")
8737            .key
8738            .clone();
8739
8740        // Both left mid-flight in a Generation whose (zero) deadline has already
8741        // elapsed, with no tick sent yet, mirroring how `Core::refresh` begins a
8742        // Worktree's `state` probe alongside `branch`. The Repo is in flight too
8743        // (on `branch` only, per the same gate), so the sweep actually reaches
8744        // it and the guard has something real to prove.
8745        core.begin_untracked_probe_for_test(&repo_key);
8746        core.begin_untracked_probe_for_test(&worktree_key);
8747
8748        tick_tx.send(Instant::now()).expect("send one tick");
8749        let after_sweep = core.settle();
8750
8751        let worktree_after = after_sweep
8752            .entities
8753            .iter()
8754            .find(|entity| entity.key == worktree_key)
8755            .expect("worktree entity present");
8756        assert!(
8757            matches!(
8758                worktree_after.state.settled(),
8759                Some(Settled::Unknown(Unknown::TimedOut))
8760            ),
8761            "expected the outstanding state cell to time out, got {:?}",
8762            worktree_after.state.settled()
8763        );
8764
8765        let repo_after = after_sweep
8766            .entities
8767            .iter()
8768            .find(|entity| entity.key == repo_key)
8769            .expect("repo entity present");
8770        assert!(
8771            matches!(repo_after.state.settled(), Some(Settled::NotApplicable)),
8772            "a Repo's Not applicable state must survive the sweep untouched, got {:?}",
8773            repo_after.state.settled()
8774        );
8775    }
8776
8777    /// Criterion 2's "never goes stale on a poll" made behavioural: the dedicated thread's
8778    /// tick-driven sweep is what a poll is in this codebase today (`spawn_clock_thread` calls
8779    /// [`sweep_deadline`] on every tick), and it must leave a receipt exactly as it was even
8780    /// while it is busy timing out a genuinely outstanding Cell on the very same entity.
8781    #[test]
8782    fn the_deadline_sweeps_poll_never_touches_an_entitys_action_receipt() {
8783        let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
8784        let dir = tempfile::tempdir().expect("temp dir");
8785        let root = root_of(&dir);
8786        let repo = root.join("repo");
8787        init_repo_with_a_commit(&repo);
8788
8789        let mut spec = spec(vec![root]);
8790        spec.generation_deadline = Duration::ZERO;
8791        let started = Core::start_for_test(spec, Duration::from_secs(3600), tick_rx).discovered();
8792        let core = started.core;
8793        // Drained, so the only entry the sweep below finds in flight is this test's own.
8794        let key = settle_launch(&core).entities[0].key.clone();
8795
8796        let receipt = crate::entity::ActionReceipt {
8797            label: Arc::from("reinstall"),
8798            steps: Arc::from(vec![crate::entity::StepResult {
8799                label: Arc::from("pnpm install"),
8800                outcome: crate::entity::StepOutcome::Ok,
8801                output: Arc::from(&b""[..]),
8802                elapsed: Duration::from_millis(1),
8803                elision: None,
8804                shell: false,
8805                interactive: false,
8806            }]),
8807            skip: None,
8808            finished_at: Timestamp::now(),
8809            running: None,
8810        };
8811        core.set_last_action_for_test(&key, receipt.clone());
8812
8813        // Left mid-flight in a Generation whose (zero) deadline has already elapsed, so the
8814        // sweep this tick triggers has a real Cell to time out on this very entity.
8815        core.begin_untracked_probe_for_test(&key);
8816        tick_tx.send(Instant::now()).expect("send one tick");
8817        let after = core.settle();
8818
8819        let entity = after
8820            .entities
8821            .iter()
8822            .find(|entity| entity.key == key)
8823            .expect("entity present");
8824        assert!(
8825            matches!(
8826                entity.branch.settled(),
8827                Some(Settled::Unknown(Unknown::TimedOut))
8828            ),
8829            "sanity check: the sweep must have actually timed out the in-flight cell, got {:?}",
8830            entity.branch.settled()
8831        );
8832        assert_eq!(entity.last_action, Some(receipt));
8833    }
8834
8835    /// Cancellation observed before a probe's very first read stops it from ever
8836    /// opening the repository at all, proven behaviourally rather than by
8837    /// re-reading the flag: a path that does not exist would settle as
8838    /// `Failed(Open(_))` if the open call actually ran, so getting `None` back
8839    /// instead is only possible if the read never started. This is the honest
8840    /// limit of what phase A can prove: `git::head_shape` is one syscall with no
8841    /// interruption point mid-read, so cancellation here stops work that has not
8842    /// started rather than work already running. [`classify_status_result_drops_an_error_once_cancel_reads_true`]
8843    /// covers the genuinely interruptible phase this crate now has.
8844    #[test]
8845    fn a_cancelled_probe_never_opens_the_repository_at_all() {
8846        let cancel = AtomicBool::new(true);
8847
8848        let outcome = probe_branch(
8849            Path::new("/nonexistent/nowhere-at-all"),
8850            None,
8851            Kind::Repo,
8852            &cancel,
8853        );
8854
8855        assert!(
8856            outcome.is_none(),
8857            "a probe observing cancellation before its first read must do no work \
8858             at all, not attempt the read and fail having tried it"
8859        );
8860    }
8861
8862    /// Phase C's own cancellation shape, distinct from phase A and B's "before the read
8863    /// starts" check: gix can report a genuinely mid-read cancellation as an `Err`
8864    /// (`dirty_counts_threads_the_cancel_flag_into_gix` in `git.rs` proves the flag actually
8865    /// reaches gix, which is what makes that `Err` possible at all), and this test covers the
8866    /// half that lives here, that `classify_status_result` folds that error back to `None`
8867    /// rather than `Settled::Failed` once `cancel` reads `true`, per ADR 0013's "interrupted
8868    /// work becomes Unknown rather than Failed". A mutation that dropped the `cancel`-aware
8869    /// arm (always settling `Failed` on any error, the way the cheaper phases' own errors do)
8870    /// fails this directly.
8871    #[test]
8872    fn classify_status_result_drops_an_error_once_cancel_reads_true() {
8873        let cancel = AtomicBool::new(true);
8874
8875        let outcome = classify_status_result(
8876            Err(crate::git::ProbeError::Status(Arc::from("boom"))),
8877            &cancel,
8878        );
8879
8880        assert!(
8881            outcome.is_none(),
8882            "an error alongside a cancel flag already set must read as cancelled, not \
8883             Failed, got {outcome:?}"
8884        );
8885    }
8886
8887    /// The other side of the same fold: an error with `cancel` still `false` is a genuine
8888    /// failure and must settle `Failed`, not be silently dropped the way a cancelled read is.
8889    #[test]
8890    fn classify_status_result_settles_failed_when_cancel_never_fired() {
8891        let cancel = AtomicBool::new(false);
8892
8893        let outcome = classify_status_result(
8894            Err(crate::git::ProbeError::Status(Arc::from("boom"))),
8895            &cancel,
8896        );
8897
8898        assert!(
8899            matches!(outcome, Some(Settled::Failed(git::ProbeError::Status(_)))),
8900            "a genuine error with no cancellation must settle Failed, got {outcome:?}"
8901        );
8902    }
8903
8904    /// gix polls `should_interrupt` per index entry rather than before every read, so a walk
8905    /// short enough to finish between checks (or with nothing left to check against) can
8906    /// complete and return `Ok` even though `cancel` was set part way through it. Settling
8907    /// that `Ok` anyway would let a cancelled generation write a value, exactly the outcome
8908    /// [refresh.md's "Cancellation"](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)
8909    /// says cancellation prevents. `classify_status_result` must re-check the same flag it
8910    /// owns on the `Ok` arm too, not only on `Err`, and drop the value the same way a
8911    /// cancelled `Err` is already dropped.
8912    #[test]
8913    fn classify_status_result_drops_an_ok_once_cancel_reads_true() {
8914        let cancel = AtomicBool::new(true);
8915
8916        let outcome = classify_status_result(Ok(DirtyCounts::default()), &cancel);
8917
8918        assert!(
8919            outcome.is_none(),
8920            "an Ok value that raced ahead of a cancel flag now set must read as cancelled, \
8921             not be settled Known, got {outcome:?}"
8922        );
8923    }
8924
8925    /// The other side of the same fold: an `Ok` with `cancel` still `false` is a genuine
8926    /// completed read and must settle `Known`, not be silently dropped.
8927    #[test]
8928    fn classify_status_result_settles_known_when_cancel_never_fired() {
8929        let cancel = AtomicBool::new(false);
8930        let counts = DirtyCounts {
8931            modified: 1,
8932            untracked: 2,
8933            deleted: 3,
8934        };
8935
8936        let outcome = classify_status_result(Ok(counts), &cancel);
8937
8938        assert!(
8939            matches!(
8940                outcome,
8941                Some(Settled::Known {
8942                    value,
8943                    at: _,
8944                    stale: _
8945                }) if value == counts
8946            ),
8947            "a genuine completed read with no cancellation must settle Known, got {outcome:?}"
8948        );
8949    }
8950
8951    /// The defining behaviour: a linked Worktree shares its parent's object store
8952    /// and remotes, but `Core` must still surface it as its own row rather than
8953    /// folding it into the Repo it is attached to. A real `git worktree add` is run
8954    /// against a genuine parent so the proof covers git's actual on-disk shape, not
8955    /// a hand-built stand-in for it.
8956    #[test]
8957    fn a_linked_worktree_is_its_own_entity_and_never_doubles_as_a_repo() {
8958        let dir = tempfile::tempdir().expect("temp dir");
8959        let root = root_of(&dir);
8960        let parent = root.join("parent");
8961        init_repo_with_a_commit(&parent);
8962        let worktree_path = root.join("feature-worktree");
8963        let status = Command::new("git")
8964            .arg("-C")
8965            .arg(&parent)
8966            .args([
8967                "worktree",
8968                "add",
8969                "-b",
8970                "feature",
8971                worktree_path.to_str().expect("utf8 path"),
8972            ])
8973            .status()
8974            .expect("run git worktree add");
8975        assert!(status.success());
8976
8977        let core = Core::start_discovered(spec(vec![root]));
8978        let snapshot = core.snapshot();
8979
8980        assert_eq!(
8981            snapshot.entities.len(),
8982            2,
8983            "expected the parent plus one Worktree, not two Repos"
8984        );
8985        let repo_count = snapshot
8986            .entities
8987            .iter()
8988            .filter(|entity| matches!(entity.kind, Kind::Repo))
8989            .count();
8990        let worktree_count = snapshot
8991            .entities
8992            .iter()
8993            .filter(|entity| matches!(entity.kind, Kind::Worktree))
8994            .count();
8995        assert_eq!(
8996            repo_count, 1,
8997            "the parent must be counted as exactly one Repo"
8998        );
8999        assert_eq!(
9000            worktree_count, 1,
9001            "the linked worktree must be counted as exactly one Worktree"
9002        );
9003
9004        let worktree_entity = snapshot
9005            .entities
9006            .iter()
9007            .find(|entity| matches!(entity.kind, Kind::Worktree))
9008            .expect("worktree entity present");
9009        let repo_entity = snapshot
9010            .entities
9011            .iter()
9012            .find(|entity| matches!(entity.kind, Kind::Repo))
9013            .expect("repo entity present");
9014        assert_eq!(worktree_entity.common_dir, repo_entity.common_dir);
9015
9016        // Each carries its own branch: the parent stayed on its default branch and
9017        // the worktree checked out `feature`.
9018        let repo_branch = core.probe_now(&repo_entity.key);
9019        let worktree_branch = core.probe_now(&worktree_entity.key);
9020        match (
9021            repo_branch.branch.settled(),
9022            worktree_branch.branch.settled(),
9023        ) {
9024            (
9025                Some(Settled::Known {
9026                    value:
9027                        Head::Branch {
9028                            name: repo_name, ..
9029                        },
9030                    at: _,
9031                    stale: _,
9032                }),
9033                Some(Settled::Known {
9034                    value:
9035                        Head::Branch {
9036                            name: worktree_name,
9037                            ..
9038                        },
9039                    at: _,
9040                    stale: _,
9041                }),
9042            ) => {
9043                assert_ne!(repo_name, worktree_name);
9044                assert_eq!(&**worktree_name, "feature");
9045            }
9046            other => panic!("expected both entities to read an attached branch, got {other:?}"),
9047        }
9048    }
9049
9050    /// End-to-end proof that `state` is actually wired into a real Generation:
9051    /// a linked Worktree whose branch is an ancestor of the default branch reads
9052    /// `Merged` after a real `refresh`, not merely in `landing`'s own unit tests.
9053    #[test]
9054    fn a_worktrees_branch_that_is_an_ancestor_of_the_default_branch_reads_merged_after_a_refresh() {
9055        let dir = tempfile::tempdir().expect("temp dir");
9056        let root = root_of(&dir);
9057        let parent = root.join("parent");
9058        init_repo_with_a_commit(&parent);
9059        git(
9060            &parent,
9061            &[
9062                "remote",
9063                "add",
9064                "origin",
9065                "https://example.invalid/repo.git",
9066            ],
9067        );
9068        let sha = head_sha(&parent);
9069        git(&parent, &["update-ref", "refs/remotes/origin/main", &sha]);
9070        let worktree_path = root.join("feature-worktree");
9071        git(
9072            &parent,
9073            &[
9074                "worktree",
9075                "add",
9076                "-b",
9077                "feature",
9078                worktree_path.to_str().expect("utf8 path"),
9079            ],
9080        );
9081
9082        let core = Core::start_discovered(spec(vec![root]));
9083        let keys: Vec<EntityKey> = core
9084            .snapshot()
9085            .entities
9086            .iter()
9087            .map(|entity| entity.key.clone())
9088            .collect();
9089
9090        core.refresh(&keys);
9091        let settled = core.settle();
9092
9093        let worktree_entity = settled
9094            .entities
9095            .iter()
9096            .find(|entity| matches!(entity.kind, Kind::Worktree))
9097            .expect("worktree entity present");
9098        assert!(
9099            matches!(
9100                worktree_entity.state.settled(),
9101                Some(Settled::Known {
9102                    value: WorktreeState::Merged,
9103                    at: _,
9104                    stale: _
9105                })
9106            ),
9107            "expected the worktree, at the same commit as the default branch, to read Merged, got {:?}",
9108            worktree_entity.state.settled()
9109        );
9110    }
9111
9112    /// The squash merge this whole ticket is named for, proven end to end
9113    /// through a real `refresh`: `feature`'s two commits are squashed into one
9114    /// commit on the default branch, so ancestry cannot see it (`feature`'s tip
9115    /// never becomes an ancestor), and only patch equivalence can. Its upstream
9116    /// tracking ref still resolves, matching the moment right after a squash
9117    /// merge and before the next prune removes it, which is what routes this
9118    /// entity through `Outstanding` into the second pass rather than settling
9119    /// `Gone` at the first.
9120    #[test]
9121    fn a_squash_merged_worktree_branch_reads_merged_after_a_refresh() {
9122        let dir = tempfile::tempdir().expect("temp dir");
9123        let root = root_of(&dir);
9124        let parent = root.join("parent");
9125        init_repo_with_a_commit(&parent);
9126        git(
9127            &parent,
9128            &[
9129                "remote",
9130                "add",
9131                "origin",
9132                "https://example.invalid/repo.git",
9133            ],
9134        );
9135        let worktree_path = root.join("feature-worktree");
9136        git(
9137            &parent,
9138            &[
9139                "worktree",
9140                "add",
9141                "-b",
9142                "feature",
9143                worktree_path.to_str().expect("utf8 path"),
9144            ],
9145        );
9146        fs::write(worktree_path.join("a.txt"), "one\n").expect("write a.txt");
9147        git(&worktree_path, &["add", "a.txt"]);
9148        git(&worktree_path, &["commit", "-m", "add a"]);
9149        fs::write(worktree_path.join("b.txt"), "two\n").expect("write b.txt");
9150        git(&worktree_path, &["add", "b.txt"]);
9151        git(&worktree_path, &["commit", "-m", "add b"]);
9152        let feature_sha = head_sha(&worktree_path);
9153
9154        // Squashed into the parent's own checkout, which is what the default
9155        // branch resolves against.
9156        git(&parent, &["merge", "--squash", "feature"]);
9157        git(&parent, &["commit", "-m", "squashed feature"]);
9158        let main_sha = head_sha(&parent);
9159        git(
9160            &parent,
9161            &["update-ref", "refs/remotes/origin/main", &main_sha],
9162        );
9163
9164        // `feature`'s own upstream, still resolving: the moment before a prune
9165        // removes it.
9166        git(&parent, &["config", "branch.feature.remote", "origin"]);
9167        git(
9168            &parent,
9169            &["config", "branch.feature.merge", "refs/heads/feature"],
9170        );
9171        git(
9172            &parent,
9173            &["update-ref", "refs/remotes/origin/feature", &feature_sha],
9174        );
9175
9176        let core = Core::start_discovered(spec(vec![root]));
9177        let keys: Vec<EntityKey> = core
9178            .snapshot()
9179            .entities
9180            .iter()
9181            .map(|entity| entity.key.clone())
9182            .collect();
9183
9184        core.refresh(&keys);
9185        let settled = core.settle();
9186
9187        let worktree_entity = settled
9188            .entities
9189            .iter()
9190            .find(|entity| matches!(entity.kind, Kind::Worktree))
9191            .expect("worktree entity present");
9192        assert!(
9193            matches!(
9194                worktree_entity.state.settled(),
9195                Some(Settled::Known {
9196                    value: WorktreeState::Merged,
9197                    at: _,
9198                    stale: _
9199                })
9200            ),
9201            "expected a squash-merged worktree branch to read Merged, got {:?}",
9202            worktree_entity.state.settled()
9203        );
9204    }
9205
9206    /// Proves the negative the state cell alone cannot: patch equivalence's
9207    /// expensive scan must never even start for an entity ancestry already
9208    /// settled. A Worktree whose branch is an ancestor of the default branch
9209    /// settles `Merged` at the first pass, so the only common dir in this test
9210    /// must show zero scans; a `state`-only assertion would still pass an
9211    /// implementation that ran the second pass over every entity and discarded
9212    /// whichever answer ancestry had already provided.
9213    #[test]
9214    fn patch_equivalence_never_runs_for_an_entity_ancestry_already_settled() {
9215        let dir = tempfile::tempdir().expect("temp dir");
9216        let root = root_of(&dir);
9217        let parent = root.join("parent");
9218        init_repo_with_a_commit(&parent);
9219        git(
9220            &parent,
9221            &[
9222                "remote",
9223                "add",
9224                "origin",
9225                "https://example.invalid/repo.git",
9226            ],
9227        );
9228        let sha = head_sha(&parent);
9229        git(&parent, &["update-ref", "refs/remotes/origin/main", &sha]);
9230        let worktree_path = root.join("feature-worktree");
9231        git(
9232            &parent,
9233            &[
9234                "worktree",
9235                "add",
9236                "-b",
9237                "feature",
9238                worktree_path.to_str().expect("utf8 path"),
9239            ],
9240        );
9241
9242        let (core, launched) = started_and_settled(spec(vec![root]));
9243        let keys: Vec<EntityKey> = launched
9244            .entities
9245            .iter()
9246            .map(|entity| entity.key.clone())
9247            .collect();
9248
9249        core.refresh(&keys);
9250        let settled = core.settle();
9251
9252        let worktree_entity = settled
9253            .entities
9254            .iter()
9255            .find(|entity| matches!(entity.kind, Kind::Worktree))
9256            .expect("worktree entity present");
9257        assert!(
9258            matches!(
9259                worktree_entity.state.settled(),
9260                Some(Settled::Known {
9261                    value: WorktreeState::Merged,
9262                    at: _,
9263                    stale: _
9264                })
9265            ),
9266            "expected ancestry alone to settle Merged here, got {:?}",
9267            worktree_entity.state.settled()
9268        );
9269        assert_eq!(
9270            core.patch_identity_reads_for_test(),
9271            0,
9272            "ancestry already settled this entity, so patch equivalence's shared \
9273             scan must never run for its common dir at all"
9274        );
9275    }
9276
9277    /// [`patch_equivalence`]'s own unit test proves the module itself writes no
9278    /// loose object; this proves the same through the real dispatch path a
9279    /// user's refresh actually runs, so a write introduced in `core.rs`'s glue
9280    /// rather than in the module would be caught too. Reuses the squash-merge
9281    /// fixture that routes a real `Core::refresh` into patch equivalence's
9282    /// second pass, and counts loose objects in the parent repository, since a
9283    /// linked Worktree shares its object database with its common dir.
9284    #[test]
9285    fn a_full_refresh_reaching_patch_equivalence_writes_no_loose_objects() {
9286        let dir = tempfile::tempdir().expect("temp dir");
9287        let root = root_of(&dir);
9288        let parent = root.join("parent");
9289        init_repo_with_a_commit(&parent);
9290        git(
9291            &parent,
9292            &[
9293                "remote",
9294                "add",
9295                "origin",
9296                "https://example.invalid/repo.git",
9297            ],
9298        );
9299        let worktree_path = root.join("feature-worktree");
9300        git(
9301            &parent,
9302            &[
9303                "worktree",
9304                "add",
9305                "-b",
9306                "feature",
9307                worktree_path.to_str().expect("utf8 path"),
9308            ],
9309        );
9310        fs::write(worktree_path.join("a.txt"), "one\n").expect("write a.txt");
9311        git(&worktree_path, &["add", "a.txt"]);
9312        git(&worktree_path, &["commit", "-m", "add a"]);
9313        fs::write(worktree_path.join("b.txt"), "two\n").expect("write b.txt");
9314        git(&worktree_path, &["add", "b.txt"]);
9315        git(&worktree_path, &["commit", "-m", "add b"]);
9316        let feature_sha = head_sha(&worktree_path);
9317
9318        git(&parent, &["merge", "--squash", "feature"]);
9319        git(&parent, &["commit", "-m", "squashed feature"]);
9320        let main_sha = head_sha(&parent);
9321        git(
9322            &parent,
9323            &["update-ref", "refs/remotes/origin/main", &main_sha],
9324        );
9325        git(&parent, &["config", "branch.feature.remote", "origin"]);
9326        git(
9327            &parent,
9328            &["config", "branch.feature.merge", "refs/heads/feature"],
9329        );
9330        git(
9331            &parent,
9332            &["update-ref", "refs/remotes/origin/feature", &feature_sha],
9333        );
9334
9335        let core = Core::start_discovered(spec(vec![root]));
9336        let keys: Vec<EntityKey> = core
9337            .snapshot()
9338            .entities
9339            .iter()
9340            .map(|entity| entity.key.clone())
9341            .collect();
9342
9343        let before = loose_object_count(&parent);
9344        core.refresh(&keys);
9345        let settled = core.settle();
9346        let after = loose_object_count(&parent);
9347
9348        let worktree_entity = settled
9349            .entities
9350            .iter()
9351            .find(|entity| matches!(entity.kind, Kind::Worktree))
9352            .expect("worktree entity present");
9353        assert!(
9354            matches!(
9355                worktree_entity.state.settled(),
9356                Some(Settled::Known {
9357                    value: WorktreeState::Merged,
9358                    at: _,
9359                    stale: _
9360                })
9361            ),
9362            "expected this refresh to actually reach patch equivalence and settle \
9363             Merged, got {:?}",
9364            worktree_entity.state.settled()
9365        );
9366        assert_eq!(
9367            before, after,
9368            "a full refresh reaching patch equivalence must never write a loose \
9369             object to the repository"
9370        );
9371    }
9372
9373    /// With patch equivalence now built, a diverged attached branch with a live
9374    /// upstream no longer stays outstanding forever: once ancestry says no,
9375    /// the second pass gets a real answer, and genuinely unmerged work (a real
9376    /// file change with no counterpart on the default branch, not merely an
9377    /// empty marker commit) settles `Active` rather than `Gone` or `Merged`,
9378    /// proven through the real dispatch path rather than either pass in
9379    /// isolation.
9380    #[test]
9381    fn a_diverged_worktree_with_a_live_upstream_and_genuinely_unmerged_work_settles_active_after_a_refresh()
9382     {
9383        let dir = tempfile::tempdir().expect("temp dir");
9384        let root = root_of(&dir);
9385        let parent = root.join("parent");
9386        init_repo_with_a_commit(&parent);
9387        let base_sha = head_sha(&parent);
9388        git(
9389            &parent,
9390            &[
9391                "remote",
9392                "add",
9393                "origin",
9394                "https://example.invalid/repo.git",
9395            ],
9396        );
9397        git(
9398            &parent,
9399            &["update-ref", "refs/remotes/origin/main", &base_sha],
9400        );
9401        let worktree_path = root.join("feature-worktree");
9402        git(
9403            &parent,
9404            &[
9405                "worktree",
9406                "add",
9407                "-b",
9408                "feature",
9409                worktree_path.to_str().expect("utf8 path"),
9410            ],
9411        );
9412        // Unmerged work: a real file change feature has that main (and
9413        // origin/main) do not, and that main never gains by any other means.
9414        fs::write(worktree_path.join("feature.txt"), "unmerged work\n").expect("write feature.txt");
9415        git(&worktree_path, &["add", "feature.txt"]);
9416        git(&worktree_path, &["commit", "-m", "unmerged"]);
9417        let feature_sha = head_sha(&worktree_path);
9418        // `feature`'s own upstream, live: the common dir's shared config and refs
9419        // make this visible from the worktree's own probe too.
9420        git(&parent, &["config", "branch.feature.remote", "origin"]);
9421        git(
9422            &parent,
9423            &["config", "branch.feature.merge", "refs/heads/feature"],
9424        );
9425        git(
9426            &parent,
9427            &["update-ref", "refs/remotes/origin/feature", &feature_sha],
9428        );
9429
9430        let core = Core::start_discovered(spec(vec![root]));
9431        let keys: Vec<EntityKey> = core
9432            .snapshot()
9433            .entities
9434            .iter()
9435            .map(|entity| entity.key.clone())
9436            .collect();
9437
9438        core.refresh(&keys);
9439        let settled = core.settle();
9440
9441        let worktree_entity = settled
9442            .entities
9443            .iter()
9444            .find(|entity| matches!(entity.kind, Kind::Worktree))
9445            .expect("worktree entity present");
9446        assert!(
9447            matches!(
9448                worktree_entity.state.settled(),
9449                Some(Settled::Known {
9450                    value: WorktreeState::Active,
9451                    at: _,
9452                    stale: _
9453                })
9454            ),
9455            "expected genuinely unmerged work with a live upstream to settle Active, got {:?}",
9456            worktree_entity.state.settled()
9457        );
9458    }
9459
9460    /// `CoreSpec::show_submodules` gates probing and dispatch, never Snapshot membership:
9461    /// a discovered Submodule is always part of the snapshot `Core::start` builds, shown or
9462    /// not, because the module pass that finds it always runs
9463    /// ([discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md)'s
9464    /// "the pass always runs, so Submodules are always known"). Built with the default,
9465    /// hidden reading precisely to prove that.
9466    #[test]
9467    fn a_submodule_is_in_the_snapshot_even_though_hidden_by_the_default_preference() {
9468        let dir = tempfile::tempdir().expect("temp dir");
9469        let root = root_of(&dir);
9470        let parent = root.join("parent");
9471        init_repo_with_a_commit(&parent);
9472        fs::write(
9473            parent.join(".gitmodules"),
9474            "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
9475        )
9476        .expect("write .gitmodules");
9477        fs::create_dir_all(parent.join("vendor").join("lib")).expect("create submodule dir");
9478
9479        let core = Core::start_discovered(spec(vec![root]));
9480        let snapshot = core.snapshot();
9481
9482        assert!(
9483            snapshot
9484                .entities
9485                .iter()
9486                .any(|entity| matches!(entity.kind, Kind::Submodule)),
9487            "a discovered Submodule must be in the snapshot even while show_submodules is off"
9488        );
9489    }
9490
9491    /// A Submodule's `state` and `base` cells must stay `Unknown` through a real
9492    /// refresh cycle, not only at construction:
9493    /// [`EntityState::probes_state`] and [`EntityState::probes_base`] are what
9494    /// stop `refresh`'s dispatch from ever calling `landing::probe` or
9495    /// `probe_base` for it again. The Submodule here is a real, valid repository
9496    /// with a real remote and a resolvable default branch ahead of its own tip
9497    /// (in fact an ancestor of it, so ancestry alone would prove `Merged`), so if
9498    /// either gate were missing this would settle a genuine live answer rather
9499    /// than merely fail to open.
9500    #[test]
9501    fn a_submodules_state_and_base_cells_stay_unknown_through_a_real_refresh() {
9502        let dir = tempfile::tempdir().expect("temp dir");
9503        let root = root_of(&dir);
9504        let parent = root.join("parent");
9505        init_repo_with_a_commit(&parent);
9506        fs::write(
9507            parent.join(".gitmodules"),
9508            "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
9509        )
9510        .expect("write .gitmodules");
9511        let submodule = parent.join("vendor").join("lib");
9512        init_repo_with_a_commit(&submodule);
9513        git(
9514            &submodule,
9515            &["remote", "add", "origin", "https://example.invalid/lib.git"],
9516        );
9517        let root_sha = head_sha(&submodule);
9518        git(&submodule, &["commit", "--allow-empty", "-m", "second"]);
9519        let tip_sha = head_sha(&submodule);
9520        git(&submodule, &["reset", "--hard", &root_sha]);
9521        git(
9522            &submodule,
9523            &["update-ref", "refs/remotes/origin/main", &tip_sha],
9524        );
9525
9526        // Shown, so the explicit `refresh` below actually dispatches a probe against it:
9527        // this test is about `probes_base`'s own gate, not about `show_submodules`'s.
9528        let mut core_spec = spec(vec![root]);
9529        core_spec.show_submodules = true;
9530        let core = Core::start_discovered(core_spec);
9531        let key = core
9532            .snapshot()
9533            .entities
9534            .iter()
9535            .find(|entity| matches!(entity.kind, Kind::Submodule))
9536            .expect("a discovered Submodule")
9537            .key
9538            .clone();
9539
9540        core.refresh(std::slice::from_ref(&key));
9541        let settled = core.settle();
9542        let submodule_entity = settled
9543            .entities
9544            .iter()
9545            .find(|entity| entity.key == key)
9546            .expect("the Submodule entity");
9547
9548        assert!(
9549            matches!(
9550                submodule_entity.base.settled(),
9551                Some(Settled::Unknown(Unknown::NoDefaultBranch))
9552            ),
9553            "expected a Submodule's base to stay Unknown through a real refresh, \
9554             got {:?}",
9555            submodule_entity.base.settled()
9556        );
9557        assert!(
9558            matches!(
9559                submodule_entity.state.settled(),
9560                Some(Settled::Unknown(Unknown::NoDefaultBranch))
9561            ),
9562            "expected a Submodule's state to stay Unknown through a real refresh, \
9563             rather than settling Merged off an untrusted default branch, got {:?}",
9564            submodule_entity.state.settled()
9565        );
9566    }
9567
9568    /// [discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md)'s
9569    /// "The Submodule row" fixes `name` as "the submodule path"; this proves the fact lands
9570    /// on the real `EntityState` `Core::start` builds, not only on the intermediate
9571    /// `DiscoveredEntity` `discovery::tests` already covers.
9572    #[test]
9573    fn a_submodules_entity_name_is_its_relative_path_not_its_basename() {
9574        let dir = tempfile::tempdir().expect("temp dir");
9575        let root = root_of(&dir);
9576        let parent = root.join("parent");
9577        init_repo_with_a_commit(&parent);
9578        fs::write(
9579            parent.join(".gitmodules"),
9580            "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
9581        )
9582        .expect("write .gitmodules");
9583        fs::create_dir_all(parent.join("vendor").join("lib")).expect("create submodule dir");
9584
9585        let core = Core::start_discovered(spec(vec![root]));
9586        let submodule = core
9587            .snapshot()
9588            .entities
9589            .into_iter()
9590            .find(|entity| matches!(entity.kind, Kind::Submodule))
9591            .expect("a discovered Submodule");
9592
9593        assert_eq!(
9594            submodule.name.as_ref(),
9595            "vendor/lib",
9596            "expected the declared relative path, not the basename `lib`"
9597        );
9598    }
9599
9600    /// AC3's negative case: an uninitialised Submodule (never `git submodule update
9601    /// --init`-ed, so its own path holds no `.git` at all) settles every cell a probe would
9602    /// otherwise open a repository for `Unknown(SubmoduleUninitialized)`, never `Failed`,
9603    /// because not being there yet is the normal, expected shape
9604    /// ([discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md)'s
9605    /// "An uninitialised Submodule is a row with every cell blank and `?` in the gutter").
9606    /// The row still exists (the assertion below finds it), so the row itself is not the
9607    /// mutation this covers; `probe_branch`/`probe_sync`/`probe_status`'s classification is.
9608    #[test]
9609    fn an_uninitialised_submodules_probed_cells_settle_unknown_not_failed() {
9610        let dir = tempfile::tempdir().expect("temp dir");
9611        let root = root_of(&dir);
9612        let parent = root.join("parent");
9613        init_repo_with_a_commit(&parent);
9614        fs::write(
9615            parent.join(".gitmodules"),
9616            "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
9617        )
9618        .expect("write .gitmodules");
9619        // Deliberately never initialised: no directory at all at the declared path, the
9620        // shape a plain `git clone` (no `--recurse-submodules`) leaves behind.
9621
9622        let mut core_spec = spec(vec![root]);
9623        core_spec.show_submodules = true;
9624        let core = Core::start_discovered(core_spec);
9625        let key = core
9626            .snapshot()
9627            .entities
9628            .iter()
9629            .find(|entity| matches!(entity.kind, Kind::Submodule))
9630            .expect("a discovered Submodule")
9631            .key
9632            .clone();
9633
9634        core.refresh(std::slice::from_ref(&key));
9635        let settled = core.settle();
9636        let submodule = settled
9637            .entities
9638            .iter()
9639            .find(|entity| entity.key == key)
9640            .expect("the Submodule entity");
9641
9642        assert!(
9643            matches!(
9644                submodule.branch.settled(),
9645                Some(Settled::Unknown(Unknown::SubmoduleUninitialized))
9646            ),
9647            "expected branch to settle Unknown(SubmoduleUninitialized), got {:?}",
9648            submodule.branch.settled()
9649        );
9650        assert!(
9651            matches!(
9652                submodule.sync.settled(),
9653                Some(Settled::Unknown(Unknown::SubmoduleUninitialized))
9654            ),
9655            "expected sync to settle Unknown(SubmoduleUninitialized), got {:?}",
9656            submodule.sync.settled()
9657        );
9658        assert!(
9659            matches!(
9660                submodule.dirty.settled(),
9661                Some(Settled::Unknown(Unknown::SubmoduleUninitialized))
9662            ),
9663            "expected dirty to settle Unknown(SubmoduleUninitialized), got {:?}",
9664            submodule.dirty.settled()
9665        );
9666        assert_eq!(
9667            summary(submodule),
9668            RowSummary::Unknown,
9669            "expected the row's own gutter fold to read Unknown, not Failed"
9670        );
9671    }
9672
9673    /// AC4's cost half: `show_submodules` off means a dispatched Generation never even
9674    /// opens a shown Submodule's own repository, while a shown one right beside it is
9675    /// probed normally in the very same Generation. Both submodules are real, valid
9676    /// repositories, so a probed-but-ignored implementation and a never-dispatched one are
9677    /// distinguishable only by whether the hidden one's cells ever leave "never settled".
9678    #[test]
9679    fn dispatch_skips_probing_a_hidden_submodule_while_probing_the_same_one_shown() {
9680        let dir = tempfile::tempdir().expect("temp dir");
9681        let root = root_of(&dir);
9682        let parent = root.join("parent");
9683        init_repo_with_a_commit(&parent);
9684        fs::write(
9685            parent.join(".gitmodules"),
9686            "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
9687        )
9688        .expect("write .gitmodules");
9689        init_repo_with_a_commit(&parent.join("vendor").join("lib"));
9690
9691        // `spec`'s own default: `show_submodules: false`.
9692        let core = Core::start_discovered(spec(vec![root]));
9693        let key = core
9694            .snapshot()
9695            .entities
9696            .iter()
9697            .find(|entity| matches!(entity.kind, Kind::Submodule))
9698            .expect("a discovered Submodule")
9699            .key
9700            .clone();
9701
9702        // First Generation, dispatched while hidden: `dispatch` must skip it outright.
9703        core.refresh(std::slice::from_ref(&key));
9704        let while_hidden = core.settle();
9705        let hidden_entity = while_hidden
9706            .entities
9707            .iter()
9708            .find(|entity| entity.key == key)
9709            .expect("submodule entity");
9710        assert!(
9711            hidden_entity.branch.settled().is_none(),
9712            "a Submodule dispatched while hidden must never even reach probe_branch, \
9713             so its cell stays never-settled rather than holding any value at all, got {:?}",
9714            hidden_entity.branch.settled()
9715        );
9716
9717        // Toggled live, no rebuild, then the very same key is handed to `refresh` again:
9718        // the second Generation is what proves the flag narrows the work rather than the
9719        // key, since nothing about the key or the `Core` itself changed in between.
9720        core.set_show_submodules(true);
9721        core.refresh(std::slice::from_ref(&key));
9722        let while_shown = core.settle();
9723        let shown_entity = while_shown
9724            .entities
9725            .iter()
9726            .find(|entity| entity.key == key)
9727            .expect("submodule entity");
9728        assert!(
9729            matches!(
9730                shown_entity.branch.settled(),
9731                Some(Settled::Known {
9732                    value: _,
9733                    at: _,
9734                    stale: _
9735                })
9736            ),
9737            "expected the same Submodule's branch to settle a real value once shown, got {:?}",
9738            shown_entity.branch.settled()
9739        );
9740    }
9741
9742    /// AC4's other half: toggling the live preference is free. Proven the same way
9743    /// `reload_with_the_same_active_set_leaves_discovery_and_its_generation_untouched`
9744    /// proves a same-Set reload never rebuilds `Core`: a Generation counter a rediscovery
9745    /// or a dispatch would have to move, checked before and after the toggle with nothing
9746    /// else run in between.
9747    #[test]
9748    fn toggling_show_submodules_starts_no_new_generation_and_dispatches_nothing() {
9749        let dir = tempfile::tempdir().expect("temp dir");
9750        let root = root_of(&dir);
9751        init_repo_with_a_commit(&root.join("repo-a"));
9752
9753        // Drained, so the two readings below differ only by whatever the toggles did.
9754        let (core, launched) = started_and_settled(spec(vec![root]));
9755        let before = launched.generation;
9756        let dispatched_before = core.dispatch_log_for_test();
9757        assert!(
9758            !dispatched_before.is_empty(),
9759            "launch dispatched nothing, so the comparison below would hold however much a \
9760             toggle dispatched"
9761        );
9762
9763        core.set_show_submodules(true);
9764        core.set_show_submodules(false);
9765
9766        assert_eq!(
9767            core.snapshot().generation,
9768            before,
9769            "toggling show_submodules must start no Generation of its own"
9770        );
9771        assert_eq!(
9772            core.dispatch_log_for_test(),
9773            dispatched_before,
9774            "toggling show_submodules must dispatch no probe of its own, leaving the last \
9775             Generation's own log exactly as it found it"
9776        );
9777    }
9778
9779    /// AC5: a `.gitmodules` parse failure marks the parent Repo's row Failed whether or not
9780    /// Submodules are shown, because the module pass that finds the failure runs either way
9781    /// ([discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md)'s
9782    /// "Failure": "The mark appears whether or not `show_submodules` is on, because the pass
9783    /// ran either way"). `spec`'s own default is already `show_submodules: false`, which is
9784    /// what makes this a real proof rather than a coincidence of some other default.
9785    #[test]
9786    fn a_malformed_gitmodules_file_still_fails_the_parent_while_submodules_are_hidden() {
9787        let dir = tempfile::tempdir().expect("temp dir");
9788        let root = root_of(&dir);
9789        let parent = root.join("parent");
9790        init_repo_with_a_commit(&parent);
9791        fs::write(
9792            parent.join(".gitmodules"),
9793            "[submodule \"lib\"\n\tpath = lib\n",
9794        )
9795        .expect("write malformed .gitmodules");
9796
9797        let core = Core::start_discovered(spec(vec![root]));
9798        let key = core
9799            .snapshot()
9800            .entities
9801            .iter()
9802            .find(|entity| entity.key.path() == parent)
9803            .expect("the parent entity")
9804            .key
9805            .clone();
9806        // The fold reads Failed only once the row holds some probed value at all: a
9807        // Generation's own dispatch is what proves the mark survives real probing, not
9808        // merely discovery's own construction-time diagnostics write.
9809        core.refresh(std::slice::from_ref(&key));
9810        let settled = core.settle();
9811        let parent_entity = settled
9812            .entities
9813            .iter()
9814            .find(|entity| entity.key == key)
9815            .expect("the parent entity");
9816
9817        assert_eq!(
9818            summary(parent_entity),
9819            RowSummary::Failed,
9820            "expected the parent to fold Failed even with Submodules hidden"
9821        );
9822        assert!(
9823            parent_entity.diagnostics.gitmodules_failed.is_some(),
9824            "expected the failure recorded in Diagnostics for the detail pane"
9825        );
9826        assert!(
9827            !settled
9828                .entities
9829                .iter()
9830                .any(|entity| matches!(entity.kind, Kind::Submodule)),
9831            "an unparseable .gitmodules yields no Submodule rows for that parent"
9832        );
9833    }
9834
9835    #[test]
9836    fn count_matches_a_plain_discoverys_entity_count() {
9837        let dir = tempfile::tempdir().expect("temp dir");
9838        let root = root_of(&dir);
9839        init_repo_with_a_commit(&root.join("one"));
9840        init_repo_with_a_commit(&root.join("two"));
9841
9842        let set = SetSpec {
9843            name: "test".to_string(),
9844            roots: vec![root],
9845            include: Vec::new(),
9846            exclude: Vec::new(),
9847        };
9848
9849        assert_eq!(discovery::count(&set), 2);
9850    }
9851
9852    #[test]
9853    fn the_slow_discovery_watcher_warns_with_the_count_reached_and_the_roots() {
9854        let progress = Arc::new(AtomicUsize::new(42));
9855        let finished = Arc::new(AtomicBool::new(false));
9856        let roots = vec![PathBuf::from("/repos/a"), PathBuf::from("/repos/b")];
9857
9858        let warning = watch_for_slow_discovery(progress, finished, roots, Duration::from_millis(1));
9859
9860        let message = warning.expect("a walk that has not finished should warn");
9861        assert!(message.contains("42"));
9862        assert!(message.contains("/repos/a"));
9863        assert!(message.contains("/repos/b"));
9864    }
9865
9866    #[test]
9867    fn the_slow_discovery_watcher_is_silent_once_the_walk_has_already_finished() {
9868        let progress = Arc::new(AtomicUsize::new(7));
9869        let finished = Arc::new(AtomicBool::new(true));
9870
9871        let warning =
9872            watch_for_slow_discovery(progress, finished, Vec::new(), Duration::from_millis(1));
9873
9874        assert!(warning.is_none());
9875    }
9876
9877    /// The same watcher `start_internal` wires in: on a fast, already-finished
9878    /// walk (the common case), joining its handle proves it ran and recorded no
9879    /// warning, exercised through `Core::start` itself rather than in isolation.
9880    /// `warn_after` is one second, the real production threshold, rather than a
9881    /// margin picked for speed: a one-repository walk finishes orders of
9882    /// magnitude faster than that even on a loaded machine, so this proves the
9883    /// fast path without racing a real walk the way a millisecond threshold did.
9884    #[test]
9885    fn a_fast_discovery_leaves_no_warning_once_the_watcher_has_run() {
9886        let dir = tempfile::tempdir().expect("temp dir");
9887        let root = root_of(&dir);
9888        init_repo_with_a_commit(&root.join("repo"));
9889        let (_tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
9890
9891        let started =
9892            Core::start_for_test(spec(vec![root]), Duration::from_secs(1), tick_rx).discovered();
9893        started
9894            .discovery_watcher
9895            .join()
9896            .expect("watcher thread should not panic");
9897
9898        assert!(started.core.discovery_warning().is_none());
9899    }
9900
9901    /// A [`DiscoveryGate`] starting `open`, and the channel that opens it once the call
9902    /// under test has returned.
9903    ///
9904    /// The gate is what makes "before its walk has run" a rendezvous rather than a
9905    /// margin. The channel is what makes an implementation that walks inline fail its
9906    /// assertion instead of wedging the run: nothing else would ever open the gate for
9907    /// it, so the backstop below is its only release, and the assertion then reports.
9908    fn gate_opened_on_signal(open: bool) -> (DiscoveryGate, Sender<()>, JoinHandle<()>) {
9909        let gate: DiscoveryGate = Arc::new((Mutex::new(open), Condvar::new()));
9910        let (returned_tx, returned_rx) = crossbeam_channel::bounded::<()>(1);
9911        let opener = thread::spawn({
9912            let gate = Arc::clone(&gate);
9913            move || {
9914                let _ = returned_rx.recv_timeout(crate::liveness::BACKSTOP);
9915                set_discovery_gate(&gate, true);
9916            }
9917        });
9918        (gate, returned_tx, opener)
9919    }
9920
9921    /// Criterion 1: `Core::start` returns before discovery has finished, and the rows
9922    /// land when discovery does.
9923    ///
9924    /// The walk is held closed before the `Core` is built, so the empty table below is
9925    /// the table `start` actually returned rather than one this test raced it to. Joining
9926    /// the harness's own `initial_discovery` handle afterwards is the rendezvous that says
9927    /// the walk landed: no sleep and no poll on either side.
9928    ///
9929    /// The row's phase C is held from before the walk is let go, so the cell read below
9930    /// is read at a point this test fixes rather than at whatever point launch's own
9931    /// Generation happened to have reached.
9932    #[test]
9933    fn start_returns_against_an_empty_table_and_the_rows_land_when_discovery_does() {
9934        let dir = tempfile::tempdir().expect("temp dir");
9935        let root = root_of(&dir);
9936        let repo = root.join("repo");
9937        init_repo_with_a_commit(&repo);
9938        let (_tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
9939        let (gate, start_returned, opener) = gate_opened_on_signal(false);
9940
9941        let started = Core::start_for_test_gated(
9942            spec(vec![root]),
9943            Duration::from_secs(3600),
9944            discovery::ABANDON_AFTER,
9945            tick_rx,
9946            Some(Arc::clone(&gate)),
9947        );
9948        let at_start = started.core.snapshot();
9949        let key = EntityKey::new(Arc::from(repo.as_path()));
9950        started.core.hold_phase_c_for_test(&key);
9951        start_returned.send(()).expect("the opener is listening");
9952        opener.join().expect("the opener thread should not panic");
9953        let started = started.discovered();
9954
9955        assert!(
9956            at_start.entities.is_empty(),
9957            "`Core::start` must return before discovery has finished, against the empty \
9958             table a consumer draws its first frame from, got {:?}",
9959            at_start
9960                .entities
9961                .iter()
9962                .map(|entity| entity.name.to_string())
9963                .collect::<Vec<_>>()
9964        );
9965
9966        let landed = started.core.snapshot();
9967        assert_eq!(
9968            landed
9969                .entities
9970                .iter()
9971                .map(|entity| entity.name.to_string())
9972                .collect::<Vec<_>>(),
9973            vec!["repo".to_string()],
9974            "the row must land on the table as soon as discovery does"
9975        );
9976        assert!(
9977            landed.entities[0].dirty.settled().is_none() && landed.entities[0].dirty.is_in_flight(),
9978            "discovery lands the row alone: launch's own Generation is already covering it \
9979             and its Cells stay unsettled until that Generation answers, which is what the \
9980             spinner sits behind"
9981        );
9982
9983        started.core.release_phase_c_for_test(&key);
9984        started.core.wait_phase_c_finished_for_test(&key);
9985    }
9986
9987    /// Criterion 2: a Generation that resolves its own order after its own discovery
9988    /// covers every row that walk found, including the ones the caller could not have
9989    /// named, and fills their Cells.
9990    ///
9991    /// `refresh_all` rather than `refresh`, because a caller that has just discarded the
9992    /// old Set's rows has no key to order by; the row below is discovered by this
9993    /// Generation's own walk and probed by the same Generation. Named by its order after
9994    /// launch's own Generation rather than by a number.
9995    #[test]
9996    fn refresh_all_covers_every_row_its_own_discovery_found() {
9997        let dir = tempfile::tempdir().expect("temp dir");
9998        let root = root_of(&dir);
9999        init_repo_with_a_commit(&root.join("repo"));
10000
10001        let (core, launched) = started_and_settled(spec(vec![root.clone()]));
10002        assert_eq!(
10003            launched
10004                .entities
10005                .iter()
10006                .map(|entity| entity.name.to_string())
10007                .collect::<Vec<_>>(),
10008            vec!["repo".to_string()],
10009            "launch's own walk must have landed and covered exactly the one row that \
10010             existed when it ran"
10011        );
10012        // Created after that walk finished, so this row exists in no snapshot the caller
10013        // could have read: only a Generation that resolves its own order after its own
10014        // discovery reaches it.
10015        init_repo_with_a_commit(&root.join("late"));
10016
10017        assert_eq!(
10018            core.refresh_all(),
10019            launched.generation.successor(),
10020            "`refresh_all` must be the Generation immediately after the one already on the \
10021             table"
10022        );
10023        let settled = core.settle();
10024
10025        let mut named: Vec<String> = settled
10026            .entities
10027            .iter()
10028            .filter(|entity| entity.branch.settled().is_some())
10029            .map(|entity| entity.name.to_string())
10030            .collect();
10031        named.sort();
10032        assert_eq!(
10033            named,
10034            vec!["late".to_string(), "repo".to_string()],
10035            "the Generation must cover every row its own discovery found, including one the \
10036             caller had no key for"
10037        );
10038    }
10039
10040    /// Criterion 3: `r`, focus gained and resume all reach `Core::refresh`, and it
10041    /// returns before its own Generation's discovery has run, so none of them holds the
10042    /// event loop for the length of a walk.
10043    ///
10044    /// `late` is created after the first walk has already finished, so only this
10045    /// `refresh`'s own walk could ever find it: its absence from the table `refresh`
10046    /// returned against is what says that walk had not run. Opening the gate afterwards
10047    /// lets the same Generation finish, which is what proves the work was deferred rather
10048    /// than dropped.
10049    #[test]
10050    fn refresh_returns_before_its_own_generations_discovery_has_run() {
10051        let dir = tempfile::tempdir().expect("temp dir");
10052        let root = root_of(&dir);
10053        init_repo_with_a_commit(&root.join("repo"));
10054        let (_tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
10055        let (gate, walk_may_run, opener) = gate_opened_on_signal(true);
10056
10057        let started = Core::start_for_test_gated(
10058            spec(vec![root.clone()]),
10059            Duration::from_secs(3600),
10060            discovery::ABANDON_AFTER,
10061            tick_rx,
10062            Some(Arc::clone(&gate)),
10063        )
10064        .discovered();
10065        let core = started.core;
10066        // Drained, so the settle-gate reading below is this `refresh`'s alone.
10067        let launched = settle_launch(&core);
10068        let keys: Vec<EntityKey> = launched
10069            .entities
10070            .iter()
10071            .map(|entity| entity.key.clone())
10072            .collect();
10073        init_repo_with_a_commit(&root.join("late"));
10074
10075        set_discovery_gate(&gate, false);
10076        let generation = core.refresh(&keys);
10077        let while_held = core.snapshot();
10078        let dispatched_while_held = core.settle_gate_count_for_test();
10079        walk_may_run.send(()).expect("the opener is listening");
10080        opener.join().expect("the opener thread should not panic");
10081
10082        assert_eq!(
10083            generation,
10084            launched.generation.successor(),
10085            "`refresh` must return its own Generation's number, the one immediately after \
10086             the table's, before that Generation has done any of its work"
10087        );
10088        assert!(
10089            !while_held
10090                .entities
10091                .iter()
10092                .any(|entity| &*entity.name == "late"),
10093            "`refresh` must return before its own Generation's walk has run, so a Repo \
10094             created after the previous walk is not on the table it returned against"
10095        );
10096        assert_eq!(
10097            dispatched_while_held, 0,
10098            "`refresh` returned before its Generation reached the table at all, so nothing \
10099             is dispatched yet"
10100        );
10101
10102        core.wait_dispatched_for_test();
10103        let settled = core.settle();
10104
10105        assert!(
10106            settled
10107                .entities
10108                .iter()
10109                .any(|entity| &*entity.name == "late"),
10110            "the deferred Generation must still run its own walk once it is let through: \
10111             deferred, never dropped"
10112        );
10113    }
10114
10115    /// The turnstile's whole claim: a Generation reserved second cannot reach the table
10116    /// before the one reserved first, whatever the two threads' own scheduling does.
10117    ///
10118    /// Without it a `refresh` whose walk finished quickly could insert its in-flight
10119    /// entries ahead of an older Generation's, leaving the older one to cancel the newer
10120    /// one and record itself as the live one, which is
10121    /// [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
10122    /// "Supersession" read backwards. The later ticket is taken on this thread, so it can
10123    /// only ever record itself after the earlier body has recorded and released; an
10124    /// implementation that did not wait would record the later one first.
10125    #[test]
10126    fn a_dispatch_body_waits_for_every_earlier_reserved_generation() {
10127        let turnstile = Arc::new(DispatchTurnstile::default());
10128        let earlier = turnstile.reserve();
10129        let later = turnstile.reserve();
10130        let order = Arc::new(Mutex::new(Vec::new()));
10131
10132        let earlier_body = thread::spawn({
10133            let turnstile = Arc::clone(&turnstile);
10134            let order = Arc::clone(&order);
10135            move || {
10136                let _turn = turnstile.take(earlier);
10137                order.lock().unwrap().push(earlier);
10138            }
10139        });
10140
10141        {
10142            let _turn = turnstile.take(later);
10143            order.lock().unwrap().push(later);
10144        }
10145        earlier_body
10146            .join()
10147            .expect("the earlier body should not panic");
10148
10149        assert_eq!(
10150            *order.lock().unwrap(),
10151            vec![earlier, later],
10152            "a dispatch body must run in the order its Generation was reserved"
10153        );
10154    }
10155
10156    /// The generic cancellation primitive stops a loop the instant `cancel` is
10157    /// observed, proven with a channel rendezvous rather than a sleep: `cancel` is
10158    /// set only after the worker's third step has genuinely completed, so a fourth
10159    /// step running at all would mean the flag was set but never actually checked.
10160    #[test]
10161    fn run_while_not_cancelled_stops_at_the_next_check_rather_than_running_forever() {
10162        let cancel = Arc::new(AtomicBool::new(false));
10163        let worker_cancel = Arc::clone(&cancel);
10164        let (step_started_tx, step_started_rx) = crossbeam_channel::bounded::<()>(0);
10165        let (proceed_tx, proceed_rx) = crossbeam_channel::bounded::<()>(0);
10166
10167        let worker = thread::spawn(move || {
10168            run_while_not_cancelled(&worker_cancel, || {
10169                step_started_tx.send(()).expect("test should be listening");
10170                proceed_rx.recv().is_ok()
10171            })
10172        });
10173
10174        for _ in 0..2 {
10175            step_started_rx
10176                .recv()
10177                .expect("worker should announce each step");
10178            proceed_tx.send(()).expect("let the step finish");
10179        }
10180        step_started_rx
10181            .recv()
10182            .expect("worker should announce its third step");
10183        cancel.store(true, Ordering::Release);
10184        proceed_tx.send(()).expect("let the third step finish");
10185
10186        let ran = worker.join().expect("worker thread should not panic");
10187
10188        assert_eq!(
10189            ran, 3,
10190            "expected cancellation to stop the loop after its third step"
10191        );
10192    }
10193
10194    /// Phase A's own per-entity timing distribution: opens (or reuses a cached
10195    /// handle for) every entity in `population` and reads `HEAD` from it, exactly
10196    /// the work `probe_branch` does, one rayon task per entity via `fanout::scatter`
10197    /// rather than `Core::refresh`, so the timing is not entangled with the
10198    /// settle-gate bookkeeping a full `Core` also pays for. Returns one
10199    /// [`Duration`] per entity actually probed, so a caller reports a real
10200    /// distribution rather than a total divided by a count.
10201    fn benchmark_identity_phase(
10202        population: Vec<crate::discovery::DiscoveredEntity>,
10203    ) -> (Duration, Vec<Duration>) {
10204        let (tx, rx) = crossbeam_channel::unbounded();
10205        let started = Instant::now();
10206        crate::fanout::scatter(population, tx, |entity| {
10207            let task_started = Instant::now();
10208            let repo = match &entity.repo {
10209                Some(repo) => repo.to_thread_local(),
10210                None => match git::open_thread_safe(entity.key.path()) {
10211                    Ok(repo) => repo.to_thread_local(),
10212                    Err(_) => return None,
10213                },
10214            };
10215            let _ = git::head_shape(&repo);
10216            Some(task_started.elapsed())
10217        });
10218        let wall = started.elapsed();
10219        let durations: Vec<Duration> = rx.into_iter().flatten().collect();
10220        (wall, durations)
10221    }
10222
10223    /// Every root this machine actually has of the two the owner's real corpus
10224    /// lives under. Read from `$HOME` at run time rather than a literal in this
10225    /// file, so no personal path is ever recorded in committed source.
10226    fn real_corpus_roots() -> Vec<PathBuf> {
10227        let Some(home) = std::env::var_os("HOME") else {
10228            return Vec::new();
10229        };
10230        let home = PathBuf::from(home);
10231        ["dev", "dev-misc"]
10232            .into_iter()
10233            .map(|leaf| home.join(leaf))
10234            .filter(|root| root.is_dir())
10235            .collect()
10236    }
10237
10238    /// A `.git`-committed disposable repository per index, standing in for the
10239    /// real corpus when it is absent or too small to be meaningful. Each one gets
10240    /// a distinct commit so opening it is not a single cached filesystem page for
10241    /// every entity.
10242    fn generated_fixture_corpus(size: usize) -> tempfile::TempDir {
10243        let root = tempfile::tempdir().expect("temp dir for generated fixture corpus");
10244        for i in 0..size {
10245            let repo = root.path().join(format!("fixture-repo-{i}"));
10246            fs::create_dir_all(&repo).expect("create fixture repo dir");
10247            gix::init(&repo).expect("init fixture repo");
10248            let status = Command::new("git")
10249                .arg("-C")
10250                .arg(&repo)
10251                .args(["-c", "user.email=test@example.com", "-c", "user.name=Test"])
10252                .args(["commit", "--allow-empty", "-m", &format!("commit {i}")])
10253                .status()
10254                .expect("run git commit");
10255            assert!(status.success());
10256        }
10257        root
10258    }
10259
10260    /// Percentile `p` (0 to 100) of an already-sorted, non-empty slice.
10261    fn percentile(sorted: &[Duration], p: usize) -> Duration {
10262        let index = (sorted.len() - 1) * p / 100;
10263        sorted[index]
10264    }
10265
10266    /// Path-component names to keep out of the benchmark's population entirely,
10267    /// read from an environment variable rather than a literal in this file: a
10268    /// standing project rule keeps certain names out of committed source, so a
10269    /// real run supplies them at invocation time
10270    /// (`REPON_BENCHMARK_EXCLUDE_NAMES=name-one,name-two`) instead of this file
10271    /// ever spelling one out. Empty, and therefore excluding nothing, when unset.
10272    fn extra_excluded_names() -> Vec<String> {
10273        parse_excluded_names(&std::env::var("REPON_BENCHMARK_EXCLUDE_NAMES").unwrap_or_default())
10274    }
10275
10276    /// The comma-separated parsing `extra_excluded_names` applies to whatever the
10277    /// environment variable holds, split out so it can be proven against a literal
10278    /// string rather than by mutating process environment state a parallel test
10279    /// run could race on.
10280    fn parse_excluded_names(raw: &str) -> Vec<String> {
10281        raw.split(',')
10282            .map(str::trim)
10283            .filter(|name| !name.is_empty())
10284            .map(str::to_string)
10285            .collect()
10286    }
10287
10288    /// Discovers, resolves and excluded-name-filters one root list into a
10289    /// population, without opening anything `excluded_names` names at any depth.
10290    /// Returns the wall time of discovery and resolution alongside the
10291    /// population, since resolution is where every entity's repository is
10292    /// actually opened the first time ([`git::resolve_boundary`]); the identity
10293    /// phase timed afterwards only re-reads `HEAD` from the handle that step
10294    /// already cached.
10295    fn discover_population(
10296        roots: Vec<PathBuf>,
10297        excluded_names: &[String],
10298    ) -> (Vec<crate::discovery::DiscoveredEntity>, Duration) {
10299        let set = SetSpec {
10300            name: "identity-probe-benchmark".to_string(),
10301            roots,
10302            include: Vec::new(),
10303            exclude: Vec::new(),
10304        };
10305        let started = Instant::now();
10306        let discovery = discovery::discover(&set);
10307        let (discovered, _) = discovery::resolve(&set, &discovery.entities);
10308        let elapsed = started.elapsed();
10309        let population = discovered
10310            .into_iter()
10311            .filter(|entity| {
10312                !entity.key.path().components().any(|component| {
10313                    excluded_names
10314                        .iter()
10315                        .any(|name| component.as_os_str() == name.as_str())
10316                })
10317            })
10318            .collect();
10319        (population, elapsed)
10320    }
10321
10322    /// The exclusion mechanism proven against a fixture: a name present nowhere
10323    /// but this test's own excluded-names list still keeps a matching boundary
10324    /// out of the discovered population, and its two siblings still get through.
10325    #[test]
10326    fn a_boundary_whose_path_matches_an_excluded_name_is_left_out_of_the_population() {
10327        let fixture = generated_fixture_corpus(3);
10328        let excluded = vec!["fixture-repo-1".to_string()];
10329
10330        let (population, _) = discover_population(vec![fixture.path().to_path_buf()], &excluded);
10331
10332        assert_eq!(population.len(), 2);
10333        assert!(
10334            population
10335                .iter()
10336                .all(|entity| entity.key.path().file_name().unwrap() != "fixture-repo-1"),
10337            "the excluded name must never appear in the population discovery returns"
10338        );
10339    }
10340
10341    #[test]
10342    fn excluded_names_parses_a_comma_separated_list_and_ignores_blanks() {
10343        assert_eq!(
10344            parse_excluded_names("foo, bar ,,baz"),
10345            vec!["foo".to_string(), "bar".to_string(), "baz".to_string()]
10346        );
10347        assert!(parse_excluded_names("").is_empty());
10348        assert!(parse_excluded_names("   ").is_empty());
10349    }
10350
10351    /// Benchmarks the identity probe (phase A: open the repository, read `HEAD`)
10352    /// against the owner's real corpus under `$HOME/dev` and `$HOME/dev-misc`,
10353    /// falling back to a generated fixture when the real corpus is absent or too
10354    /// small to be meaningful (fewer than 20 entities). Never run by `just ci`:
10355    /// this is a hand-run measurement, per this project's convention of recording
10356    /// hand-run figures with the date, machine and toolchain rather than asserting
10357    /// a timing budget in a committed test. Run it with:
10358    /// `cargo test -p repon-core --release -- --ignored --nocapture identity_probe_benchmark`
10359    ///
10360    /// Read-only throughout: discovery only stats for a `.git` entry and phase A
10361    /// only reads `HEAD`. Any boundary whose path has a component named by
10362    /// `REPON_BENCHMARK_EXCLUDE_NAMES` is dropped before discovery's second half
10363    /// would ever open it, which is how a standing exclusion is honoured without
10364    /// this file naming what it excludes.
10365    #[test]
10366    #[ignore = "hand-run against the owner's real corpus; see docs/spec/refresh.md for the recorded figures"]
10367    fn identity_probe_benchmark() {
10368        let excluded_names = extra_excluded_names();
10369
10370        // `_fixture` is held for the rest of the test whenever a fixture is used,
10371        // so its directories still exist when the identity phase opens them; it is
10372        // simply never populated on the real-corpus path.
10373        let mut _fixture: Option<tempfile::TempDir> = None;
10374
10375        let (real_population, real_discovery_wall) =
10376            discover_population(real_corpus_roots(), &excluded_names);
10377        let (population, using_fixture, discovery_wall) = if real_population.len() >= 20 {
10378            (real_population, false, real_discovery_wall)
10379        } else {
10380            println!(
10381                "real corpus absent or too small to be meaningful ({} entities); \
10382                 using a generated fixture instead",
10383                real_population.len()
10384            );
10385            let fixture = generated_fixture_corpus(300);
10386            let (population, fixture_discovery_wall) =
10387                discover_population(vec![fixture.path().to_path_buf()], &excluded_names);
10388            _fixture = Some(fixture);
10389            (population, true, fixture_discovery_wall)
10390        };
10391
10392        let population_size = population.len();
10393        assert!(
10394            population_size > 0,
10395            "neither a real corpus root nor the generated fixture produced any entities"
10396        );
10397
10398        let (wall, mut durations) = benchmark_identity_phase(population);
10399        durations.sort();
10400
10401        println!(
10402            "identity probe benchmark: corpus = {}, population = {population_size}",
10403            if using_fixture {
10404                "generated fixture"
10405            } else {
10406                "real corpus"
10407            }
10408        );
10409        println!(
10410            "discovery + first open (serial, every entity's own gix::open): {discovery_wall:?}"
10411        );
10412        println!("identity phase, warm, parallel (HEAD re-read from the cached handle): {wall:?}");
10413        println!(
10414            "identity phase per entity: p50 {:?}, p90 {:?}, max {:?}",
10415            percentile(&durations, 50),
10416            percentile(&durations, 90),
10417            durations.last().copied().unwrap_or_default(),
10418        );
10419    }
10420
10421    fn spec_with_overrides(roots: Vec<PathBuf>, overrides: Vec<RepoOverride>) -> CoreSpec {
10422        let mut spec = spec(roots);
10423        spec.overrides = overrides;
10424        spec
10425    }
10426
10427    /// The seam this proves: an explicit per-Repo override reaches all the way
10428    /// through `Core::refresh` and `settle` into the `default_branch` cell as
10429    /// rung 1, recorded in diagnostics, even though `origin/HEAD` and the name
10430    /// list would both answer differently if asked.
10431    #[test]
10432    fn a_per_repo_override_resolves_the_default_branch_at_rung_one_through_a_real_refresh() {
10433        let dir = tempfile::tempdir().expect("temp dir");
10434        let root = root_of(&dir);
10435        let repo = root.join("repo");
10436        init_repo_with_a_commit(&repo);
10437        git(
10438            &repo,
10439            &[
10440                "remote",
10441                "add",
10442                "origin",
10443                "https://example.invalid/repo.git",
10444            ],
10445        );
10446        let sha = head_sha(&repo);
10447        git(&repo, &["update-ref", "refs/remotes/origin/main", &sha]);
10448        let remote_refs_dir = repo
10449            .join(".git")
10450            .join("refs")
10451            .join("remotes")
10452            .join("origin");
10453        fs::create_dir_all(&remote_refs_dir).expect("create refs/remotes/origin dir");
10454        fs::write(
10455            remote_refs_dir.join("HEAD"),
10456            "ref: refs/remotes/origin/main\n",
10457        )
10458        .expect("write HEAD");
10459
10460        let core = Core::start_discovered(spec_with_overrides(
10461            vec![root],
10462            vec![RepoOverride {
10463                path: repo.clone(),
10464                default_branch: Some("develop".to_string()),
10465                excluded: false,
10466            }],
10467        ));
10468        let key = core.snapshot().entities[0].key.clone();
10469
10470        core.refresh(std::slice::from_ref(&key));
10471        let settled = core.settle();
10472        let entity = &settled.entities[0];
10473
10474        match entity.default_branch.settled() {
10475            Some(Settled::Known {
10476                value,
10477                at: _,
10478                stale: _,
10479            }) => assert_eq!(
10480                value.name(),
10481                "origin/develop",
10482                "the override must win even though origin/HEAD names a different branch"
10483            ),
10484            other => panic!("expected the override's own answer, got {other:?}"),
10485        }
10486        assert_eq!(
10487            entity.diagnostics.default_branch_rung,
10488            Some(1),
10489            "an override must be recorded as rung 1"
10490        );
10491    }
10492
10493    /// `probe_now`'s synchronous path carries the same override wiring as
10494    /// `refresh`, proven directly since a Launcher return uses it without ever
10495    /// calling `refresh` first.
10496    #[test]
10497    fn a_per_repo_override_also_resolves_through_probe_now() {
10498        let dir = tempfile::tempdir().expect("temp dir");
10499        let root = root_of(&dir);
10500        let repo = root.join("repo");
10501        init_repo_with_a_commit(&repo);
10502
10503        let core = Core::start_discovered(spec_with_overrides(
10504            vec![root],
10505            vec![RepoOverride {
10506                path: repo.clone(),
10507                default_branch: Some("release".to_string()),
10508                excluded: false,
10509            }],
10510        ));
10511        let key = core.snapshot().entities[0].key.clone();
10512
10513        let entity = core.probe_now(&key);
10514
10515        match entity.default_branch.settled() {
10516            // No remote at all: the override still answers, using the bare name.
10517            Some(Settled::Known {
10518                value,
10519                at: _,
10520                stale: _,
10521            }) => assert_eq!(value.name(), "release"),
10522            other => panic!("expected the override's own answer, got {other:?}"),
10523        }
10524        assert_eq!(entity.diagnostics.default_branch_rung, Some(1));
10525    }
10526
10527    /// The three named ways rung 4 is reached are recorded distinctly, not merged
10528    /// into one opaque "gave up" fact: no remote at all, two or more remotes with
10529    /// none named `origin`, and a chosen remote whose tracking refs matched
10530    /// nothing in the name list.
10531    #[test]
10532    fn reaching_rung_four_with_no_remote_at_all_records_why() {
10533        let dir = tempfile::tempdir().expect("temp dir");
10534        let root = root_of(&dir);
10535        let repo = root.join("repo");
10536        init_repo_with_a_commit(&repo);
10537
10538        let core = Core::start_discovered(spec(vec![root]));
10539        let key = core.snapshot().entities[0].key.clone();
10540
10541        core.refresh(std::slice::from_ref(&key));
10542        let settled = core.settle();
10543        let entity = &settled.entities[0];
10544
10545        assert_eq!(entity.diagnostics.default_branch_rung, Some(4));
10546        assert_eq!(
10547            entity.diagnostics.default_branch_stopped,
10548            Some(DefaultBranchStopped::NoRemote)
10549        );
10550    }
10551
10552    #[test]
10553    fn reaching_rung_four_with_two_unnamed_remotes_records_why() {
10554        let dir = tempfile::tempdir().expect("temp dir");
10555        let root = root_of(&dir);
10556        let repo = root.join("repo");
10557        init_repo_with_a_commit(&repo);
10558        git(
10559            &repo,
10560            &[
10561                "remote",
10562                "add",
10563                "fork-one",
10564                "https://example.invalid/one.git",
10565            ],
10566        );
10567        git(
10568            &repo,
10569            &[
10570                "remote",
10571                "add",
10572                "fork-two",
10573                "https://example.invalid/two.git",
10574            ],
10575        );
10576
10577        let core = Core::start_discovered(spec(vec![root]));
10578        let key = core.snapshot().entities[0].key.clone();
10579
10580        core.refresh(std::slice::from_ref(&key));
10581        let settled = core.settle();
10582        let entity = &settled.entities[0];
10583
10584        assert_eq!(entity.diagnostics.default_branch_rung, Some(4));
10585        assert_eq!(
10586            entity.diagnostics.default_branch_stopped,
10587            Some(DefaultBranchStopped::AmbiguousRemote)
10588        );
10589    }
10590
10591    #[test]
10592    fn reaching_rung_four_with_a_chosen_remote_and_no_matching_ref_records_why() {
10593        let dir = tempfile::tempdir().expect("temp dir");
10594        let root = root_of(&dir);
10595        let repo = root.join("repo");
10596        init_repo_with_a_commit(&repo);
10597        git(
10598            &repo,
10599            &[
10600                "remote",
10601                "add",
10602                "origin",
10603                "https://example.invalid/repo.git",
10604            ],
10605        );
10606        // A remote-tracking ref exists, but under a name outside rung 3's list, and
10607        // there is no origin/HEAD at all.
10608        let sha = head_sha(&repo);
10609        git(&repo, &["update-ref", "refs/remotes/origin/feature", &sha]);
10610
10611        let core = Core::start_discovered(spec(vec![root]));
10612        let key = core.snapshot().entities[0].key.clone();
10613
10614        core.refresh(std::slice::from_ref(&key));
10615        let settled = core.settle();
10616        let entity = &settled.entities[0];
10617
10618        assert_eq!(entity.diagnostics.default_branch_rung, Some(4));
10619        assert_eq!(
10620            entity.diagnostics.default_branch_stopped,
10621            Some(DefaultBranchStopped::NameListExhausted)
10622        );
10623    }
10624
10625    /// A Repo with no override and no resolvable remote reaches rung 4: Unknown,
10626    /// never Failed, which stays reserved for a git error.
10627    #[test]
10628    fn a_repo_with_nothing_to_resolve_settles_unknown_never_failed() {
10629        let dir = tempfile::tempdir().expect("temp dir");
10630        let root = root_of(&dir);
10631        let repo = root.join("repo");
10632        init_repo_with_a_commit(&repo);
10633
10634        let core = Core::start_discovered(spec(vec![root]));
10635        let key = core.snapshot().entities[0].key.clone();
10636
10637        core.refresh(std::slice::from_ref(&key));
10638        let settled = core.settle();
10639        let entity = &settled.entities[0];
10640
10641        assert!(matches!(
10642            entity.default_branch.settled(),
10643            Some(Settled::Unknown(Unknown::NoDefaultBranch))
10644        ));
10645        assert_eq!(entity.diagnostics.default_branch_rung, Some(4));
10646    }
10647
10648    /// The seam this proves: a stale symbolic `origin/HEAD` reaches all the way
10649    /// through `Core::refresh` and `settle` into `Diagnostics`, not just the
10650    /// fallen-through rung 3 answer, since the spec requires recording that the
10651    /// stale case is what happened rather than leaving the same trail a merely
10652    /// absent `origin/HEAD` would.
10653    #[test]
10654    fn a_stale_remote_head_is_recorded_in_diagnostics_through_a_real_refresh() {
10655        let dir = tempfile::tempdir().expect("temp dir");
10656        let root = root_of(&dir);
10657        let repo = root.join("repo");
10658        init_repo_with_a_commit(&repo);
10659        git(
10660            &repo,
10661            &[
10662                "remote",
10663                "add",
10664                "origin",
10665                "https://example.invalid/repo.git",
10666            ],
10667        );
10668        let sha = head_sha(&repo);
10669        git(&repo, &["update-ref", "refs/remotes/origin/trunk", &sha]);
10670        let remote_refs_dir = repo
10671            .join(".git")
10672            .join("refs")
10673            .join("remotes")
10674            .join("origin");
10675        fs::create_dir_all(&remote_refs_dir).expect("create refs/remotes/origin dir");
10676        // Points at a name never created as a ref: the stale case, not merely absent.
10677        fs::write(
10678            remote_refs_dir.join("HEAD"),
10679            "ref: refs/remotes/origin/main\n",
10680        )
10681        .expect("write HEAD");
10682
10683        let core = Core::start_discovered(spec(vec![root]));
10684        let key = core.snapshot().entities[0].key.clone();
10685
10686        core.refresh(std::slice::from_ref(&key));
10687        let settled = core.settle();
10688        let entity = &settled.entities[0];
10689
10690        match entity.default_branch.settled() {
10691            Some(Settled::Known {
10692                value,
10693                at: _,
10694                stale: _,
10695            }) => {
10696                assert_eq!(value.name(), "origin/trunk")
10697            }
10698            other => panic!("expected the name list's answer, got {other:?}"),
10699        }
10700        assert!(
10701            entity.diagnostics.default_branch_rung_two_stale,
10702            "a stale origin/HEAD target must be recorded on the entity's diagnostics"
10703        );
10704    }
10705
10706    /// A resolvable `origin/HEAD` must never be marked stale, so the flag actually
10707    /// distinguishes the two cases rather than always being set once rung 2 runs.
10708    #[test]
10709    fn a_resolvable_remote_head_is_not_recorded_as_stale() {
10710        let dir = tempfile::tempdir().expect("temp dir");
10711        let root = root_of(&dir);
10712        let repo = root.join("repo");
10713        init_repo_with_a_commit(&repo);
10714        git(
10715            &repo,
10716            &[
10717                "remote",
10718                "add",
10719                "origin",
10720                "https://example.invalid/repo.git",
10721            ],
10722        );
10723        let sha = head_sha(&repo);
10724        git(&repo, &["update-ref", "refs/remotes/origin/main", &sha]);
10725        let remote_refs_dir = repo
10726            .join(".git")
10727            .join("refs")
10728            .join("remotes")
10729            .join("origin");
10730        fs::create_dir_all(&remote_refs_dir).expect("create refs/remotes/origin dir");
10731        fs::write(
10732            remote_refs_dir.join("HEAD"),
10733            "ref: refs/remotes/origin/main\n",
10734        )
10735        .expect("write HEAD");
10736
10737        let core = Core::start_discovered(spec(vec![root]));
10738        let key = core.snapshot().entities[0].key.clone();
10739
10740        core.refresh(std::slice::from_ref(&key));
10741        let settled = core.settle();
10742        let entity = &settled.entities[0];
10743
10744        assert!(!entity.diagnostics.default_branch_rung_two_stale);
10745    }
10746
10747    /// The defining behaviour for per-Repo matching: one `[[repo]]` entry naming
10748    /// only the parent Repo's own path still applies to a linked Worktree sharing
10749    /// its common dir, proven against a real `git worktree add` rather than a
10750    /// hand-built stand-in for the on-disk relationship.
10751    #[test]
10752    fn one_override_on_a_repos_path_covers_a_worktree_sharing_its_common_dir() {
10753        let dir = tempfile::tempdir().expect("temp dir");
10754        let root = root_of(&dir);
10755        let parent = root.join("parent");
10756        init_repo_with_a_commit(&parent);
10757        let worktree = root.join("worktree");
10758        git(
10759            &parent,
10760            &[
10761                "worktree",
10762                "add",
10763                "-b",
10764                "feature",
10765                worktree.to_str().expect("utf8 path"),
10766            ],
10767        );
10768
10769        let core = Core::start_discovered(spec_with_overrides(
10770            vec![root],
10771            vec![RepoOverride {
10772                path: parent.clone(),
10773                default_branch: None,
10774                excluded: true,
10775            }],
10776        ));
10777        let snapshot = core.snapshot();
10778
10779        for entity in &snapshot.entities {
10780            assert!(
10781                entity.excluded,
10782                "both the Repo and its Worktree must inherit the entry declared on the Repo's own path, entity: {:?}",
10783                entity.key
10784            );
10785        }
10786        assert_eq!(
10787            snapshot.entities.len(),
10788            2,
10789            "expected the parent plus its worktree"
10790        );
10791    }
10792
10793    /// The other direction: an entry naming a Worktree's own path beats the entry
10794    /// it would otherwise inherit from the Repo it shares a common dir with, while
10795    /// a second Worktree with no entry of its own still inherits the Repo's entry.
10796    #[test]
10797    fn an_entry_naming_a_worktrees_own_path_beats_the_inherited_one() {
10798        let dir = tempfile::tempdir().expect("temp dir");
10799        let root = root_of(&dir);
10800        let parent = root.join("parent");
10801        init_repo_with_a_commit(&parent);
10802        let worktree_own = root.join("worktree-own");
10803        let worktree_inherits = root.join("worktree-inherits");
10804        git(
10805            &parent,
10806            &[
10807                "worktree",
10808                "add",
10809                "-b",
10810                "feature-own",
10811                worktree_own.to_str().expect("utf8 path"),
10812            ],
10813        );
10814        git(
10815            &parent,
10816            &[
10817                "worktree",
10818                "add",
10819                "-b",
10820                "feature-inherits",
10821                worktree_inherits.to_str().expect("utf8 path"),
10822            ],
10823        );
10824
10825        let core = Core::start_discovered(spec_with_overrides(
10826            vec![root],
10827            vec![
10828                RepoOverride {
10829                    path: parent.clone(),
10830                    default_branch: None,
10831                    excluded: true,
10832                },
10833                RepoOverride {
10834                    path: worktree_own.clone(),
10835                    default_branch: None,
10836                    excluded: false,
10837                },
10838            ],
10839        ));
10840        let snapshot = core.snapshot();
10841
10842        let find = |path: &Path| {
10843            snapshot
10844                .entities
10845                .iter()
10846                .find(|entity| entity.key.path() == path)
10847                .unwrap_or_else(|| panic!("entity at {path:?} present"))
10848        };
10849
10850        assert!(
10851            find(&parent).excluded,
10852            "the parent Repo has no entry of its own and inherits the excluding one"
10853        );
10854        assert!(
10855            !find(&worktree_own).excluded,
10856            "the Worktree named directly by its own path must use its own entry, not the inherited one"
10857        );
10858        assert!(
10859            find(&worktree_inherits).excluded,
10860            "a sibling Worktree with no entry of its own still inherits the Repo's entry"
10861        );
10862    }
10863
10864    /// A Submodule's own common dir differs from its parent's
10865    /// (`<parent common dir>/modules/<name>`), so an entry naming only the
10866    /// parent's path can never also exclude the parent's Submodule: the entry
10867    /// covers the parent and its Worktrees, never a Submodule reached through it.
10868    #[test]
10869    fn an_override_on_the_parents_path_never_excludes_its_submodule() {
10870        let dir = tempfile::tempdir().expect("temp dir");
10871        let root = root_of(&dir);
10872        let parent = root.join("parent");
10873        init_repo_with_a_commit(&parent);
10874        fs::write(
10875            parent.join(".gitmodules"),
10876            "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
10877        )
10878        .expect("write .gitmodules");
10879        fs::create_dir_all(parent.join("vendor").join("lib")).expect("create submodule dir");
10880
10881        let core = Core::start_discovered(spec_with_overrides(
10882            vec![root],
10883            vec![RepoOverride {
10884                path: parent.clone(),
10885                default_branch: None,
10886                excluded: true,
10887            }],
10888        ));
10889        let snapshot = core.snapshot();
10890
10891        let submodule = snapshot
10892            .entities
10893            .iter()
10894            .find(|entity| matches!(entity.kind, Kind::Submodule))
10895            .expect("the submodule is still discovered and listed");
10896        assert!(
10897            !submodule.excluded,
10898            "an entry naming only the parent's path must never reach a Submodule, \
10899             whose own common dir differs from its parent's"
10900        );
10901    }
10902
10903    /// The seam this proves: `Core::default_branch_chain_reads_for_test` counts
10904    /// how many times a `refresh` actually computed the default-branch chain's
10905    /// per-common-dir facts (`default_branch::ChainFacts::resolve`, the loose-file
10906    /// read plus the reference lookups), rather than reusing an already-computed
10907    /// answer for a common dir another entity in the same Generation already paid
10908    /// for. Reading the count off `Core` this way is the seam, not an internal:
10909    /// it is a named, stable test-only entry point in the same
10910    /// `#[cfg(test)] impl Core` family as `cached_repo_handle_for_test`, which
10911    /// already proves a different sharing question the same way. There is no
10912    /// black-box way to observe "how many times an internal read ran" through
10913    /// `Snapshot` alone, since two different common dirs can legitimately answer
10914    /// with the same branch name.
10915    ///
10916    /// Three Worktrees share one common dir with their Repo (four entities); a
10917    /// second, unrelated Repo has its own. Memoised, the count is 2, the number of
10918    /// distinct common dirs; unmemoised, it is 4, the number of entities.
10919    #[test]
10920    fn the_default_branch_chain_is_memoised_once_per_common_dir_per_generation() {
10921        let dir = tempfile::tempdir().expect("temp dir");
10922        let root = root_of(&dir);
10923        let parent = root.join("parent");
10924        init_repo_with_a_commit(&parent);
10925        for name in ["wt-a", "wt-b", "wt-c"] {
10926            let worktree = root.join(name);
10927            git(
10928                &parent,
10929                &[
10930                    "worktree",
10931                    "add",
10932                    "-b",
10933                    name,
10934                    worktree.to_str().expect("utf8 path"),
10935                ],
10936            );
10937        }
10938        let other_repo = root.join("other");
10939        init_repo_with_a_commit(&other_repo);
10940
10941        let (core, launched) = started_and_settled(spec(vec![root]));
10942        let keys: Vec<EntityKey> = launched
10943            .entities
10944            .iter()
10945            .map(|entity| entity.key.clone())
10946            .collect();
10947        assert_eq!(
10948            keys.len(),
10949            5,
10950            "expected the parent, its three worktrees and the unrelated repo"
10951        );
10952
10953        core.refresh(&keys);
10954        core.settle();
10955
10956        assert_eq!(
10957            core.default_branch_chain_reads_for_test(),
10958            2,
10959            "four entities span exactly two common dirs; a memoised chain reads \
10960             each common dir once, not once per entity"
10961        );
10962
10963        // A second Generation pays the same two reads again. A cache hoisted onto
10964        // `Core` would answer this refresh for free and read 0, which is the
10965        // persistence ADR 0006 refuses.
10966        core.refresh(&keys);
10967        core.settle();
10968        assert_eq!(
10969            core.default_branch_chain_reads_for_test(),
10970            2,
10971            "the memo lives inside one Generation's dispatch; the next Generation \
10972             recomputes rather than inheriting it"
10973        );
10974    }
10975
10976    /// The same proof as `the_default_branch_chain_is_memoised_once_per_common_dir_per_generation`,
10977    /// for patch equivalence's own expensive half: two sibling Worktrees, each
10978    /// with a live upstream and unmerged work of its own, share one common dir
10979    /// and must scan its default-branch history once between them, not twice;
10980    /// an unrelated Repo's own Worktree, in its own common dir, pays for a
10981    /// second scan. Both entities settling (`Active`, since neither's work
10982    /// actually landed) is what proves the second pass ran for both rather than
10983    /// one being cancelled or skipped, which would otherwise let a
10984    /// once-per-entity implementation coincidentally also read 2.
10985    #[test]
10986    fn patch_equivalence_is_memoised_once_per_common_dir_per_generation() {
10987        let dir = tempfile::tempdir().expect("temp dir");
10988        let root = root_of(&dir);
10989        let parent = root.join("parent");
10990        init_repo_with_a_commit(&parent);
10991        git(
10992            &parent,
10993            &[
10994                "remote",
10995                "add",
10996                "origin",
10997                "https://example.invalid/repo.git",
10998            ],
10999        );
11000        let base_sha = head_sha(&parent);
11001        git(
11002            &parent,
11003            &["update-ref", "refs/remotes/origin/main", &base_sha],
11004        );
11005        for name in ["feature-x", "feature-y"] {
11006            let worktree = root.join(name);
11007            git(
11008                &parent,
11009                &[
11010                    "worktree",
11011                    "add",
11012                    "-b",
11013                    name,
11014                    worktree.to_str().expect("utf8 path"),
11015                ],
11016            );
11017            fs::write(worktree.join(format!("{name}.txt")), "unmerged\n")
11018                .expect("write worktree file");
11019            git(&worktree, &["add", "."]);
11020            git(&worktree, &["commit", "-m", "unmerged work"]);
11021            let tip_sha = head_sha(&worktree);
11022            git(
11023                &parent,
11024                &["config", &format!("branch.{name}.remote"), "origin"],
11025            );
11026            git(
11027                &parent,
11028                &[
11029                    "config",
11030                    &format!("branch.{name}.merge"),
11031                    &format!("refs/heads/{name}"),
11032                ],
11033            );
11034            git(
11035                &parent,
11036                &[
11037                    "update-ref",
11038                    &format!("refs/remotes/origin/{name}"),
11039                    &tip_sha,
11040                ],
11041            );
11042        }
11043
11044        let other_parent = root.join("other");
11045        init_repo_with_a_commit(&other_parent);
11046        git(
11047            &other_parent,
11048            &[
11049                "remote",
11050                "add",
11051                "origin",
11052                "https://example.invalid/other.git",
11053            ],
11054        );
11055        let other_base_sha = head_sha(&other_parent);
11056        git(
11057            &other_parent,
11058            &["update-ref", "refs/remotes/origin/main", &other_base_sha],
11059        );
11060        let other_worktree = root.join("other-feature");
11061        git(
11062            &other_parent,
11063            &[
11064                "worktree",
11065                "add",
11066                "-b",
11067                "other-feature",
11068                other_worktree.to_str().expect("utf8 path"),
11069            ],
11070        );
11071        fs::write(other_worktree.join("other.txt"), "unmerged\n").expect("write worktree file");
11072        git(&other_worktree, &["add", "."]);
11073        git(&other_worktree, &["commit", "-m", "unmerged work"]);
11074        let other_tip_sha = head_sha(&other_worktree);
11075        git(
11076            &other_parent,
11077            &["config", "branch.other-feature.remote", "origin"],
11078        );
11079        git(
11080            &other_parent,
11081            &[
11082                "config",
11083                "branch.other-feature.merge",
11084                "refs/heads/other-feature",
11085            ],
11086        );
11087        git(
11088            &other_parent,
11089            &[
11090                "update-ref",
11091                "refs/remotes/origin/other-feature",
11092                &other_tip_sha,
11093            ],
11094        );
11095
11096        let (core, launched) = started_and_settled(spec(vec![root]));
11097        let keys: Vec<EntityKey> = launched
11098            .entities
11099            .iter()
11100            .map(|entity| entity.key.clone())
11101            .collect();
11102        assert_eq!(
11103            keys.len(),
11104            5,
11105            "expected two parents plus their three worktrees"
11106        );
11107
11108        core.refresh(&keys);
11109        let settled = core.settle();
11110
11111        let worktree_states: Vec<_> = settled
11112            .entities
11113            .iter()
11114            .filter(|entity| matches!(entity.kind, Kind::Worktree))
11115            .map(|entity| entity.state.settled())
11116            .collect();
11117        assert_eq!(worktree_states.len(), 3, "expected three worktree rows");
11118        for settled_state in &worktree_states {
11119            assert!(
11120                matches!(
11121                    settled_state,
11122                    Some(Settled::Known {
11123                        value: WorktreeState::Active,
11124                        at: _,
11125                        stale: _
11126                    })
11127                ),
11128                "expected every worktree's genuinely unmerged work to settle Active, got {settled_state:?}"
11129            );
11130        }
11131
11132        assert_eq!(
11133            core.patch_identity_reads_for_test(),
11134            2,
11135            "two worktrees share one common dir and must scan its default-branch \
11136             history once between them, not once per entity; the unrelated repo's \
11137             own worktree pays for a second scan"
11138        );
11139
11140        // A second Generation pays for the same two scans again: a cache hoisted
11141        // onto `Core` would answer this refresh for free and read 0.
11142        core.refresh(&keys);
11143        core.settle();
11144        assert_eq!(
11145            core.patch_identity_reads_for_test(),
11146            2,
11147            "the memo lives inside one Generation's dispatch; the next Generation \
11148             recomputes rather than inheriting it"
11149        );
11150    }
11151
11152    /// Criterion 3's widen direction, end to end: `feature-deep` forks at the
11153    /// parent commit `deep_fork_sha` and is squashed into main immediately
11154    /// afterwards; `feature-shallow` forks at that squash commit (strictly more
11155    /// recent, so its own merge base is shallower) and is squashed in turn to
11156    /// produce `main`'s tip. The deepest merge base among the two siblings is
11157    /// `feature-deep`'s own, `deep_fork_sha`, not `feature-shallow`'s.
11158    ///
11159    /// A scan bounded by the *shallowest* sibling's merge base instead of the
11160    /// deepest would stop before reaching the commit that squashed
11161    /// `feature-deep` in, since that commit sits strictly between the two
11162    /// bounds: `feature-deep` would then settle `Active` instead of `Merged`.
11163    /// This is a smoke test for that outcome through the real dispatch
11164    /// pipeline, not a proof: rayon's work stealing gives dispatch `order` no
11165    /// ordering guarantee, so `feature-deep` landing last here is a nudge
11166    /// towards, never proof of, exercising a lazy first-arrival bound.
11167    /// `bound_gate_deepest_folds_every_candidate_regardless_of_report_order`
11168    /// below is what deterministically proves the bound is collected from
11169    /// every sibling rather than computed lazily from whichever arrives first.
11170    #[test]
11171    fn an_entity_whose_merge_base_is_deeper_than_its_siblings_widens_the_shared_scan() {
11172        let dir = tempfile::tempdir().expect("temp dir");
11173        let root = root_of(&dir);
11174        let parent = root.join("parent");
11175        init_repo_with_a_commit(&parent);
11176        git(
11177            &parent,
11178            &[
11179                "remote",
11180                "add",
11181                "origin",
11182                "https://example.invalid/repo.git",
11183            ],
11184        );
11185        let deep_fork_sha = head_sha(&parent);
11186
11187        git(&parent, &["branch", "feature-deep"]);
11188        let deep_worktree = root.join("feature-deep");
11189        git(
11190            &parent,
11191            &[
11192                "worktree",
11193                "add",
11194                deep_worktree.to_str().expect("utf8 path"),
11195                "feature-deep",
11196            ],
11197        );
11198        fs::write(deep_worktree.join("deep.txt"), "deep work\n").expect("write deep.txt");
11199        git(&deep_worktree, &["add", "."]);
11200        git(&deep_worktree, &["commit", "-m", "deep work"]);
11201        let deep_tip_sha = head_sha(&deep_worktree);
11202
11203        git(&parent, &["merge", "--squash", "feature-deep"]);
11204        git(&parent, &["commit", "-m", "squashed deep"]);
11205        let shallow_fork_sha = head_sha(&parent);
11206
11207        git(&parent, &["branch", "feature-shallow"]);
11208        let shallow_worktree = root.join("feature-shallow");
11209        git(
11210            &parent,
11211            &[
11212                "worktree",
11213                "add",
11214                shallow_worktree.to_str().expect("utf8 path"),
11215                "feature-shallow",
11216            ],
11217        );
11218        fs::write(shallow_worktree.join("shallow.txt"), "shallow work\n")
11219            .expect("write shallow.txt");
11220        git(&shallow_worktree, &["add", "."]);
11221        git(&shallow_worktree, &["commit", "-m", "shallow work"]);
11222        let shallow_tip_sha = head_sha(&shallow_worktree);
11223
11224        git(&parent, &["merge", "--squash", "feature-shallow"]);
11225        git(&parent, &["commit", "-m", "squashed shallow"]);
11226        let main_tip_sha = head_sha(&parent);
11227        assert_ne!(
11228            deep_fork_sha, shallow_fork_sha,
11229            "the two siblings must fork at genuinely different commits"
11230        );
11231
11232        git(
11233            &parent,
11234            &["update-ref", "refs/remotes/origin/main", &main_tip_sha],
11235        );
11236        for (name, tip_sha) in [
11237            ("feature-deep", &deep_tip_sha),
11238            ("feature-shallow", &shallow_tip_sha),
11239        ] {
11240            git(
11241                &parent,
11242                &["config", &format!("branch.{name}.remote"), "origin"],
11243            );
11244            git(
11245                &parent,
11246                &[
11247                    "config",
11248                    &format!("branch.{name}.merge"),
11249                    &format!("refs/heads/{name}"),
11250                ],
11251            );
11252            git(
11253                &parent,
11254                &[
11255                    "update-ref",
11256                    &format!("refs/remotes/origin/{name}"),
11257                    tip_sha,
11258                ],
11259            );
11260        }
11261
11262        let (core, snapshot) = started_and_settled(spec(vec![root]));
11263        let deep_key = snapshot
11264            .entities
11265            .iter()
11266            .find(|entity| entity.key.path() == deep_worktree)
11267            .expect("feature-deep worktree discovered")
11268            .key
11269            .clone();
11270        let shallow_key = snapshot
11271            .entities
11272            .iter()
11273            .find(|entity| entity.key.path() == shallow_worktree)
11274            .expect("feature-shallow worktree discovered")
11275            .key
11276            .clone();
11277        let parent_key = snapshot
11278            .entities
11279            .iter()
11280            .find(|entity| entity.key.path() == parent)
11281            .expect("parent repo discovered")
11282            .key
11283            .clone();
11284        // The deepest sibling dispatched last, so a lazy bound computed from
11285        // whichever entity arrives first would reach for the shallow sibling's
11286        // own narrower merge base instead.
11287        let order = vec![parent_key, shallow_key.clone(), deep_key.clone()];
11288
11289        core.refresh(&order);
11290        let settled = core.settle();
11291
11292        let state_of = |key: &EntityKey| {
11293            settled
11294                .entities
11295                .iter()
11296                .find(|entity| &entity.key == key)
11297                .and_then(|entity| entity.state.settled())
11298                .cloned()
11299        };
11300        assert!(
11301            matches!(
11302                state_of(&deep_key),
11303                Some(Settled::Known {
11304                    value: WorktreeState::Merged,
11305                    at: _,
11306                    stale: _
11307                })
11308            ),
11309            "expected the deepest sibling's own squash commit to be found once the scan is \
11310             bounded by the deepest merge base, got {:?}",
11311            state_of(&deep_key)
11312        );
11313        assert!(
11314            matches!(
11315                state_of(&shallow_key),
11316                Some(Settled::Known {
11317                    value: WorktreeState::Merged,
11318                    at: _,
11319                    stale: _
11320                })
11321            ),
11322            "expected the shallow sibling to settle Merged too, got {:?}",
11323            state_of(&shallow_key)
11324        );
11325        assert_eq!(
11326            core.patch_identity_reads_for_test(),
11327            1,
11328            "both worktrees share one common dir and must still scan its default-branch \
11329             history once between them, not once per entity"
11330        );
11331        assert_eq!(
11332            core.patch_scan_bounds_for_test(),
11333            vec![Some(id(&deep_fork_sha))],
11334            "the one shared scan that ran must have been bounded by the deepest sibling's own \
11335             merge base, not the shallower one's"
11336        );
11337    }
11338
11339    fn id(sha: &str) -> gix::ObjectId {
11340        gix::ObjectId::from_hex(sha.as_bytes()).expect("parse sha")
11341    }
11342
11343    /// Criterion 1, proved at the barrier itself rather than through rayon's
11344    /// unordered dispatch: `shallow` is reported before `deep` on purpose, so a
11345    /// lazy first-arrival implementation (answer with whichever candidate
11346    /// showed up first, rather than collecting every sibling's own merge base)
11347    /// would settle on `shallow` and fail this assertion. `deep` is an ancestor
11348    /// of `shallow`, so the correct fold finds it regardless of report order.
11349    #[test]
11350    fn bound_gate_deepest_folds_every_candidate_regardless_of_report_order() {
11351        let dir = tempfile::tempdir().expect("temp dir");
11352        let repo_path = root_of(&dir).join("repo");
11353        init_repo_with_a_commit(&repo_path);
11354        let deep_sha = id(&head_sha(&repo_path));
11355        fs::write(repo_path.join("child.txt"), "child\n").expect("write child.txt");
11356        git(&repo_path, &["add", "."]);
11357        git(&repo_path, &["commit", "-m", "child of deep"]);
11358        let shallow_sha = id(&head_sha(&repo_path));
11359
11360        let repo = gix::open(&repo_path).expect("open repo");
11361        let gate = BoundGate::new(2);
11362        gate.report(Some(shallow_sha));
11363        gate.report(Some(deep_sha));
11364
11365        assert_eq!(
11366            gate.deepest(&repo),
11367            Some(deep_sha),
11368            "the deepest candidate must win even though the shallower one reported first"
11369        );
11370    }
11371
11372    /// Deterministic proof that [`probe_patch_equivalence`] itself consults
11373    /// [`BoundGate::deepest`] for the bound it hands to
11374    /// [`patch_equivalence::scan_default_branch`], rather than reaching for its
11375    /// own entity's merge base. Unlike
11376    /// `bound_gate_deepest_folds_every_candidate_regardless_of_report_order`,
11377    /// which proves `BoundGate` and `deepest_merge_base` correct in isolation,
11378    /// this drives `probe_patch_equivalence` itself and inspects what it
11379    /// actually recorded into `memo.scan_bounds`. `deep_sha`'s contribution is
11380    /// pre-reported by hand, standing in for a sibling entity that already ran
11381    /// this Generation; the one entity this test drives through the real
11382    /// function arrives at `shallow_sha`, so its own merge base against
11383    /// `default_tip` is `shallow_sha`, strictly shallower than `deep_sha`. A
11384    /// regression that bounds the scan by the arriving entity's own merge base
11385    /// instead of the gate's answer would record `shallow_sha` here, and would
11386    /// do so every single run: unlike the integration smoke test below, there
11387    /// is no rayon dispatch order here to sometimes get it right by accident.
11388    #[test]
11389    fn probe_patch_equivalence_bounds_the_scan_by_the_gates_deepest_not_its_own_merge_base() {
11390        let dir = tempfile::tempdir().expect("temp dir");
11391        let repo_path = root_of(&dir).join("repo");
11392        init_repo_with_a_commit(&repo_path);
11393        let deep_sha = id(&head_sha(&repo_path));
11394        fs::write(repo_path.join("child.txt"), "child\n").expect("write child.txt");
11395        git(&repo_path, &["add", "."]);
11396        git(&repo_path, &["commit", "-m", "child of deep"]);
11397        let shallow_sha_hex = head_sha(&repo_path);
11398        let shallow_sha = id(&shallow_sha_hex);
11399        fs::write(repo_path.join("tip.txt"), "tip\n").expect("write tip.txt");
11400        git(&repo_path, &["add", "."]);
11401        git(&repo_path, &["commit", "-m", "default tip"]);
11402        let default_tip_hex = head_sha(&repo_path);
11403
11404        let repo = gix::open(&repo_path).expect("open repo");
11405        // What `landing::probe` hands over for a Worktree entity sitting at
11406        // `shallow`, whose own tip is not main's actual tip.
11407        let outstanding = landing::Outstanding {
11408            entity_tip: shallow_sha,
11409            default_tip: id(&default_tip_hex),
11410            merge_base: Some(shallow_sha),
11411        };
11412        let common_dir: Arc<Path> = Arc::from(repo_path.join(".git"));
11413        let cancel = AtomicBool::new(false);
11414        let patch_cache: PatchIdentityCache = Mutex::new(HashMap::new());
11415        let patch_reads = AtomicUsize::new(0);
11416        let patch_scan_bounds: Mutex<Vec<Option<gix::ObjectId>>> = Mutex::new(Vec::new());
11417        let memo = PatchEquivalenceMemo {
11418            cache: &patch_cache,
11419            reads: &patch_reads,
11420            scan_bounds: &patch_scan_bounds,
11421        };
11422        // Two entities share this common dir this Generation: `deep_sha` stands
11423        // in for a sibling that already reported its own, deeper merge base;
11424        // `shallow` is the one entity driven through the real function below.
11425        let gate = BoundGate::new(2);
11426        gate.report(Some(deep_sha));
11427        let mut report = GateReport::new(&gate);
11428
11429        probe_patch_equivalence(
11430            &repo,
11431            &outstanding,
11432            &common_dir,
11433            &cancel,
11434            &memo,
11435            &mut report,
11436        );
11437
11438        assert_eq!(
11439            patch_scan_bounds.lock().unwrap().as_slice(),
11440            [Some(deep_sha)],
11441            "the scan must be bounded by the deepest sibling's merge base, not shallow's own \
11442             ({shallow_sha:?})"
11443        );
11444    }
11445
11446    /// The carry itself: [`probe_patch_equivalence`] diffs the entity's own
11447    /// range from the merge base `landing::probe` handed over, rather than
11448    /// walking the same commit pair a second time. `mid_sha` is a real commit
11449    /// on `feature` but not its fork point, so the two answers differ: from the
11450    /// fork point the range is the whole squashed change and settles `Merged`,
11451    /// from `mid_sha` it is only `b.txt` and settles `Active`. A regression that
11452    /// recomputed the base here would answer `Merged` and fail this test.
11453    #[test]
11454    fn probe_patch_equivalence_diffs_from_the_merge_base_it_was_handed() {
11455        let dir = tempfile::tempdir().expect("temp dir");
11456        let repo_path = root_of(&dir).join("repo");
11457        init_repo_with_a_commit(&repo_path);
11458        let fork_point_hex = head_sha(&repo_path);
11459        git(&repo_path, &["checkout", "-b", "feature"]);
11460        fs::write(repo_path.join("a.txt"), "one\n").expect("write a.txt");
11461        git(&repo_path, &["add", "a.txt"]);
11462        git(&repo_path, &["commit", "-m", "add a"]);
11463        let mid_sha = id(&head_sha(&repo_path));
11464        fs::write(repo_path.join("b.txt"), "two\n").expect("write b.txt");
11465        git(&repo_path, &["add", "b.txt"]);
11466        git(&repo_path, &["commit", "-m", "add b"]);
11467        let feature_sha = id(&head_sha(&repo_path));
11468        git(&repo_path, &["checkout", "-B", "main", &fork_point_hex]);
11469        git(&repo_path, &["merge", "--squash", "feature"]);
11470        git(&repo_path, &["commit", "-m", "squashed feature"]);
11471        let main_sha = id(&head_sha(&repo_path));
11472
11473        let repo = gix::open(&repo_path).expect("open repo");
11474        // What `landing::probe` hands over, with a base halfway along the
11475        // branch standing in for one only this pass could know.
11476        let outstanding = landing::Outstanding {
11477            entity_tip: feature_sha,
11478            default_tip: main_sha,
11479            merge_base: Some(mid_sha),
11480        };
11481        let common_dir: Arc<Path> = Arc::from(repo_path.join(".git"));
11482        let cancel = AtomicBool::new(false);
11483        let patch_cache: PatchIdentityCache = Mutex::new(HashMap::new());
11484        let patch_reads = AtomicUsize::new(0);
11485        let patch_scan_bounds: Mutex<Vec<Option<gix::ObjectId>>> = Mutex::new(Vec::new());
11486        let memo = PatchEquivalenceMemo {
11487            cache: &patch_cache,
11488            reads: &patch_reads,
11489            scan_bounds: &patch_scan_bounds,
11490        };
11491        let gate = BoundGate::new(1);
11492        let mut report = GateReport::new(&gate);
11493
11494        let settled = probe_patch_equivalence(
11495            &repo,
11496            &outstanding,
11497            &common_dir,
11498            &cancel,
11499            &memo,
11500            &mut report,
11501        );
11502
11503        assert!(
11504            matches!(
11505                settled,
11506                Some(Settled::Known {
11507                    value: WorktreeState::Active,
11508                    at: _,
11509                    stale: _
11510                })
11511            ),
11512            "the range must be measured from the handed-in base ({mid_sha:?}), whose only \
11513             change the squash commit does not match, got {settled:?}"
11514        );
11515    }
11516
11517    /// The edge [`deepest_merge_base`] exists for: no entity sharing a common
11518    /// dir ever had a merge base to offer (every one settled by ancestry, was
11519    /// cancelled, or shared no history with the default branch at all), so the
11520    /// scan is left unbounded. `deepest_merge_base` returns before its first
11521    /// candidate lookup here, which is what lets this fixture skip building any
11522    /// commit history at all.
11523    #[test]
11524    fn bound_gate_deepest_with_no_candidates_leaves_the_scan_unbounded() {
11525        let dir = tempfile::tempdir().expect("temp dir");
11526        let repo_path = root_of(&dir).join("repo");
11527        gix::init(&repo_path).expect("init repo");
11528        let repo = gix::open(&repo_path).expect("open repo");
11529
11530        let gate = BoundGate::new(2);
11531        gate.report(None);
11532        gate.report(None);
11533
11534        assert_eq!(
11535            gate.deepest(&repo),
11536            None,
11537            "no contributed candidate must leave the scan unbounded"
11538        );
11539    }
11540
11541    /// `probe_patch_equivalence`'s `Ok(None)` arm bypasses the shared scan for
11542    /// an Outstanding entity with no shared history at all. `unrelated` is a
11543    /// real branch, with a live upstream so `landing::probe`
11544    /// leaves it `Outstanding`, whose own root commit shares no history with
11545    /// `main`'s, driven through `Core` end to end rather than by calling
11546    /// `probe_patch_equivalence` or `patch_equivalence::probe` directly, so a
11547    /// removed bypass (the shared scan run unconditionally instead) is
11548    /// exercised for real: `BoundGate::deepest` would then block forever on a
11549    /// scan this entity never asked for.
11550    #[test]
11551    fn an_outstanding_entity_with_no_shared_history_settles_active_without_the_shared_scan() {
11552        let dir = tempfile::tempdir().expect("temp dir");
11553        let root = root_of(&dir);
11554        let parent = root.join("parent");
11555        init_repo_with_a_commit(&parent);
11556        git(&parent, &["branch", "-M", "main"]);
11557        git(
11558            &parent,
11559            &[
11560                "remote",
11561                "add",
11562                "origin",
11563                "https://example.invalid/repo.git",
11564            ],
11565        );
11566        let main_sha = head_sha(&parent);
11567        git(
11568            &parent,
11569            &["update-ref", "refs/remotes/origin/main", &main_sha],
11570        );
11571
11572        git(&parent, &["checkout", "--orphan", "unrelated"]);
11573        git(
11574            &parent,
11575            &["commit", "--allow-empty", "-m", "unrelated root"],
11576        );
11577        let unrelated_sha = head_sha(&parent);
11578        git(&parent, &["checkout", "main"]);
11579
11580        let worktree = root.join("unrelated");
11581        git(
11582            &parent,
11583            &[
11584                "worktree",
11585                "add",
11586                worktree.to_str().expect("utf8 path"),
11587                "unrelated",
11588            ],
11589        );
11590        git(&parent, &["config", "branch.unrelated.remote", "origin"]);
11591        git(
11592            &parent,
11593            &["config", "branch.unrelated.merge", "refs/heads/unrelated"],
11594        );
11595        git(
11596            &parent,
11597            &[
11598                "update-ref",
11599                "refs/remotes/origin/unrelated",
11600                &unrelated_sha,
11601            ],
11602        );
11603
11604        let (core, snapshot) = started_and_settled(spec(vec![root]));
11605        let worktree_key = snapshot
11606            .entities
11607            .iter()
11608            .find(|entity| entity.key.path() == worktree)
11609            .expect("unrelated worktree discovered")
11610            .key
11611            .clone();
11612
11613        core.refresh(std::slice::from_ref(&worktree_key));
11614        let settled = core.settle();
11615
11616        let state = settled
11617            .entities
11618            .iter()
11619            .find(|entity| entity.key == worktree_key)
11620            .and_then(|entity| entity.state.settled())
11621            .cloned();
11622        assert!(
11623            matches!(
11624                state,
11625                Some(Settled::Known {
11626                    value: WorktreeState::Active,
11627                    at: _,
11628                    stale: _
11629                })
11630            ),
11631            "expected an Outstanding entity with no shared history to settle Active via the \
11632             bypass, got {state:?}"
11633        );
11634        assert_eq!(
11635            core.patch_identity_reads_for_test(),
11636            0,
11637            "the bypass must settle without ever running the shared scan"
11638        );
11639    }
11640
11641    // --- Phase B's comparison: the `sync` cell, end to end through a real `Core`:
11642    // the six named cases, plus the two ways "every entity, every Generation" is
11643    // most easily lost. ---
11644
11645    fn add_origin_remote(path: &Path) {
11646        git(
11647            path,
11648            &[
11649                "remote",
11650                "add",
11651                "origin",
11652                "https://example.invalid/repo.git",
11653            ],
11654        );
11655    }
11656
11657    /// Wires `branch` up to track `refs/remotes/origin/<branch>` at `upstream_sha`,
11658    /// mirroring `patch_equivalence_is_memoised_once_per_common_dir_per_generation`'s
11659    /// own fixture shape against a real disposable repo.
11660    fn set_upstream(path: &Path, branch: &str, upstream_sha: &str) {
11661        git(
11662            path,
11663            &["config", &format!("branch.{branch}.remote"), "origin"],
11664        );
11665        git(
11666            path,
11667            &[
11668                "config",
11669                &format!("branch.{branch}.merge"),
11670                &format!("refs/heads/{branch}"),
11671            ],
11672        );
11673        git(
11674            path,
11675            &[
11676                "update-ref",
11677                &format!("refs/remotes/origin/{branch}"),
11678                upstream_sha,
11679            ],
11680        );
11681    }
11682
11683    fn refresh_and_settle(core: &Core) -> crate::snapshot::Snapshot {
11684        let keys: Vec<EntityKey> = core
11685            .snapshot()
11686            .entities
11687            .iter()
11688            .map(|entity| entity.key.clone())
11689            .collect();
11690        core.refresh(&keys);
11691        core.settle()
11692    }
11693
11694    fn sync_of<'a>(
11695        snapshot: &'a crate::snapshot::Snapshot,
11696        path: &Path,
11697    ) -> Option<&'a Settled<SyncState>> {
11698        snapshot
11699            .entities
11700            .iter()
11701            .find(|entity| entity.key.path() == path)
11702            .unwrap_or_else(|| panic!("no entity for {}", path.display()))
11703            .sync
11704            .settled()
11705    }
11706
11707    /// Named case 1 of 6: an attached branch ahead of its upstream.
11708    #[test]
11709    fn an_attached_branch_ahead_of_its_upstream_reads_the_ahead_count() {
11710        let dir = tempfile::tempdir().expect("temp dir");
11711        let root = root_of(&dir);
11712        let repo = root.join("repo");
11713        init_repo_with_a_commit(&repo);
11714        let fork_sha = head_sha(&repo);
11715        add_origin_remote(&repo);
11716        set_upstream(&repo, "main", &fork_sha);
11717        git(&repo, &["commit", "--allow-empty", "-m", "local work"]);
11718
11719        let core = Core::start_discovered(spec(vec![root]));
11720        let settled = refresh_and_settle(&core);
11721
11722        match sync_of(&settled, &repo) {
11723            Some(Settled::Known {
11724                value: SyncState::Tracking(AheadBehind { ahead, behind }),
11725                at: _,
11726                stale: _,
11727            }) => {
11728                assert_eq!(*ahead, 1);
11729                assert_eq!(*behind, 0);
11730            }
11731            other => panic!("expected 1 ahead, 0 behind, got {other:?}"),
11732        }
11733    }
11734
11735    /// Named case 2 of 6: an attached branch behind its upstream.
11736    #[test]
11737    fn an_attached_branch_behind_its_upstream_reads_the_behind_count() {
11738        let dir = tempfile::tempdir().expect("temp dir");
11739        let root = root_of(&dir);
11740        let repo = root.join("repo");
11741        init_repo_with_a_commit(&repo);
11742        git(&repo, &["checkout", "-b", "temp"]);
11743        git(&repo, &["commit", "--allow-empty", "-m", "upstream work"]);
11744        let upstream_sha = head_sha(&repo);
11745        git(&repo, &["checkout", "main"]);
11746        git(&repo, &["branch", "-D", "temp"]);
11747        add_origin_remote(&repo);
11748        set_upstream(&repo, "main", &upstream_sha);
11749
11750        let core = Core::start_discovered(spec(vec![root]));
11751        let settled = refresh_and_settle(&core);
11752
11753        match sync_of(&settled, &repo) {
11754            Some(Settled::Known {
11755                value: SyncState::Tracking(AheadBehind { ahead, behind }),
11756                at: _,
11757                stale: _,
11758            }) => {
11759                assert_eq!(*ahead, 0);
11760                assert_eq!(*behind, 1);
11761            }
11762            other => panic!("expected 0 ahead, 1 behind, got {other:?}"),
11763        }
11764    }
11765
11766    /// Named case 3 of 6: an attached branch level with its upstream.
11767    #[test]
11768    fn an_attached_branch_level_with_its_upstream_reads_in_sync() {
11769        let dir = tempfile::tempdir().expect("temp dir");
11770        let root = root_of(&dir);
11771        let repo = root.join("repo");
11772        init_repo_with_a_commit(&repo);
11773        let sha = head_sha(&repo);
11774        add_origin_remote(&repo);
11775        set_upstream(&repo, "main", &sha);
11776
11777        let core = Core::start_discovered(spec(vec![root]));
11778        let settled = refresh_and_settle(&core);
11779
11780        match sync_of(&settled, &repo) {
11781            Some(Settled::Known {
11782                value:
11783                    SyncState::Tracking(AheadBehind {
11784                        ahead: 0,
11785                        behind: 0,
11786                    }),
11787                at: _,
11788                stale: _,
11789            }) => {}
11790            other => panic!("expected level with its upstream, got {other:?}"),
11791        }
11792    }
11793
11794    /// Named case 4 of 6: an attached branch tracking nothing, on a Repo that does
11795    /// have a remote. Distinguishes this from case 6 below: the absence here is the
11796    /// branch's own tracking configuration, not the Repo's remote.
11797    #[test]
11798    fn an_attached_branch_tracking_nothing_reads_no_upstream() {
11799        let dir = tempfile::tempdir().expect("temp dir");
11800        let root = root_of(&dir);
11801        let repo = root.join("repo");
11802        init_repo_with_a_commit(&repo);
11803        add_origin_remote(&repo);
11804
11805        let core = Core::start_discovered(spec(vec![root]));
11806        let settled = refresh_and_settle(&core);
11807
11808        match sync_of(&settled, &repo) {
11809            Some(Settled::Known {
11810                value: SyncState::NoUpstream,
11811                at: _,
11812                stale: _,
11813            }) => {}
11814            other => panic!("expected no upstream configured, got {other:?}"),
11815        }
11816    }
11817
11818    /// Named case 5 of 6: a detached row, on a Repo that does have a remote.
11819    /// Distinguishes this from case 6 below the same way case 4 does.
11820    #[test]
11821    fn a_detached_row_reads_no_upstream() {
11822        let dir = tempfile::tempdir().expect("temp dir");
11823        let root = root_of(&dir);
11824        let repo = root.join("repo");
11825        init_repo_with_a_commit(&repo);
11826        let first_sha = head_sha(&repo);
11827        git(&repo, &["commit", "--allow-empty", "-m", "second"]);
11828        git(&repo, &["checkout", "--detach", &first_sha]);
11829        add_origin_remote(&repo);
11830
11831        let core = Core::start_discovered(spec(vec![root]));
11832        let settled = refresh_and_settle(&core);
11833
11834        match sync_of(&settled, &repo) {
11835            Some(Settled::Known {
11836                value: SyncState::NoUpstream,
11837                at: _,
11838                stale: _,
11839            }) => {}
11840            other => panic!("expected a detached row to read no upstream, got {other:?}"),
11841        }
11842    }
11843
11844    /// Named case 6 of 6: a Repo with no remote at all. The propagation half of
11845    /// criterion 3 is the substance here, not the Repo row alone: a linked Worktree
11846    /// shares the parent's config and has no upstream of its own to speak of either,
11847    /// so it must read the exact same `NoRemote` value, not `NoUpstream`.
11848    #[test]
11849    fn a_repo_with_no_remote_reads_no_remote_on_itself_and_every_worktree() {
11850        let dir = tempfile::tempdir().expect("temp dir");
11851        let root = root_of(&dir);
11852        let parent = root.join("parent");
11853        init_repo_with_a_commit(&parent);
11854        let worktree = root.join("feature");
11855        git(
11856            &parent,
11857            &[
11858                "worktree",
11859                "add",
11860                "-b",
11861                "feature",
11862                worktree.to_str().expect("utf8 path"),
11863            ],
11864        );
11865
11866        let core = Core::start_discovered(spec(vec![root]));
11867        let settled = refresh_and_settle(&core);
11868
11869        assert_eq!(
11870            settled.entities.len(),
11871            2,
11872            "expected the parent Repo and its one linked Worktree"
11873        );
11874        for path in [&parent, &worktree] {
11875            match sync_of(&settled, path) {
11876                Some(Settled::Known {
11877                    value: SyncState::NoRemote,
11878                    at: _,
11879                    stale: _,
11880                }) => {}
11881                other => panic!(
11882                    "expected {} to read no remote at all, got {other:?}",
11883                    path.display()
11884                ),
11885            }
11886        }
11887    }
11888
11889    /// Criterion 1's "every entity" half: two sibling Worktrees under one Repo, each
11890    /// with a different sync outcome, computed together in one Generation. A test
11891    /// driving only one of them could not see an implementation that dispatches the
11892    /// comparison for a single hand-picked entity rather than every one whose HEAD
11893    /// carries a branch.
11894    #[test]
11895    fn sync_is_computed_for_every_entity_dispatched_this_generation_not_only_one() {
11896        let dir = tempfile::tempdir().expect("temp dir");
11897        let root = root_of(&dir);
11898        let parent = root.join("parent");
11899        init_repo_with_a_commit(&parent);
11900        let fork_sha = head_sha(&parent);
11901        add_origin_remote(&parent);
11902
11903        let ahead_worktree = root.join("feature-ahead");
11904        git(
11905            &parent,
11906            &[
11907                "worktree",
11908                "add",
11909                "-b",
11910                "feature-ahead",
11911                ahead_worktree.to_str().expect("utf8 path"),
11912            ],
11913        );
11914        set_upstream(&parent, "feature-ahead", &fork_sha);
11915        git(
11916            &ahead_worktree,
11917            &["commit", "--allow-empty", "-m", "unpushed"],
11918        );
11919
11920        let behind_worktree = root.join("feature-behind");
11921        git(
11922            &parent,
11923            &[
11924                "worktree",
11925                "add",
11926                "-b",
11927                "feature-behind",
11928                behind_worktree.to_str().expect("utf8 path"),
11929            ],
11930        );
11931        git(
11932            &behind_worktree,
11933            &["commit", "--allow-empty", "-m", "on the remote only"],
11934        );
11935        let ahead_of_behind_sha = head_sha(&behind_worktree);
11936        git(&behind_worktree, &["reset", "--hard", "HEAD~1"]);
11937        set_upstream(&parent, "feature-behind", &ahead_of_behind_sha);
11938
11939        let core = Core::start_discovered(spec(vec![root]));
11940        let settled = refresh_and_settle(&core);
11941
11942        match sync_of(&settled, &ahead_worktree) {
11943            Some(Settled::Known {
11944                value:
11945                    SyncState::Tracking(AheadBehind {
11946                        ahead: 1,
11947                        behind: 0,
11948                    }),
11949                at: _,
11950                stale: _,
11951            }) => {}
11952            other => panic!("expected feature-ahead to read 1 ahead, got {other:?}"),
11953        }
11954        match sync_of(&settled, &behind_worktree) {
11955            Some(Settled::Known {
11956                value:
11957                    SyncState::Tracking(AheadBehind {
11958                        ahead: 0,
11959                        behind: 1,
11960                    }),
11961                at: _,
11962                stale: _,
11963            }) => {}
11964            other => panic!("expected feature-behind to read 1 behind, got {other:?}"),
11965        }
11966    }
11967
11968    /// Criterion 1's "every Generation" half: a second, later refresh recomputes
11969    /// `sync` rather than a first Generation's answer sticking around unrefreshed.
11970    /// A test that only ever drives one Generation cannot see an implementation
11971    /// that dispatches the comparison once, at `Core::start`'s own discovery, and
11972    /// never again on an explicit `refresh`.
11973    #[test]
11974    fn sync_recomputes_on_a_second_generation_not_only_the_first() {
11975        let dir = tempfile::tempdir().expect("temp dir");
11976        let root = root_of(&dir);
11977        let repo = root.join("repo");
11978        init_repo_with_a_commit(&repo);
11979        let fork_sha = head_sha(&repo);
11980        add_origin_remote(&repo);
11981        set_upstream(&repo, "main", &fork_sha);
11982
11983        let core = Core::start_discovered(spec(vec![root]));
11984        let first = refresh_and_settle(&core);
11985        match sync_of(&first, &repo) {
11986            Some(Settled::Known {
11987                value:
11988                    SyncState::Tracking(AheadBehind {
11989                        ahead: 0,
11990                        behind: 0,
11991                    }),
11992                at: _,
11993                stale: _,
11994            }) => {}
11995            other => panic!("expected the first Generation level with its upstream, got {other:?}"),
11996        }
11997
11998        git(
11999            &repo,
12000            &[
12001                "commit",
12002                "--allow-empty",
12003                "-m",
12004                "second Generation's own work",
12005            ],
12006        );
12007        let second = refresh_and_settle(&core);
12008        match sync_of(&second, &repo) {
12009            Some(Settled::Known {
12010                value:
12011                    SyncState::Tracking(AheadBehind {
12012                        ahead: 1,
12013                        behind: 0,
12014                    }),
12015                at: _,
12016                stale: _,
12017            }) => {}
12018            other => panic!(
12019                "expected the second Generation to recompute and read 1 ahead, got {other:?}"
12020            ),
12021        }
12022    }
12023
12024    /// The Worktree-reporting criterion: after a default branch moves, the Worktrees
12025    /// now behind it are reported by name. `base` (the same "behind the default branch"
12026    /// count [`base.rs`] computes and every row's own `name` already carries) is what
12027    /// "reported by name" means in practice: a snapshot reader finds each Worktree by
12028    /// the name on its row, not by position, so this test does the same, matching each
12029    /// assertion to its own fixture's name rather than to "the first" or "the last"
12030    /// entity.
12031    ///
12032    /// `wt-behind` is branched from the default branch's tip before it moves and is left
12033    /// untouched, the same shape a fetch leaves an existing linked Worktree in; `wt-
12034    /// caught-up` is branched from the tip *after* it moves, so it is unaffected. Two
12035    /// Worktrees are required, not one: a test with only `wt-behind` would still pass
12036    /// against an implementation that reports every Worktree as behind regardless of
12037    /// whether it actually is, and a test that asserted only "something is reported"
12038    /// would pass even if the names or the counts were swapped.
12039    #[test]
12040    fn worktrees_now_behind_a_moved_default_branch_are_reported_by_name() {
12041        let dir = tempfile::tempdir().expect("temp dir");
12042        let root = root_of(&dir);
12043        let repo = root.join("repo");
12044        init_repo_with_a_commit(&repo);
12045        let sha_a = head_sha(&repo);
12046        add_origin_remote(&repo);
12047        set_upstream(&repo, "main", &sha_a);
12048
12049        let behind_path = root.join("wt-behind");
12050        git(
12051            &repo,
12052            &[
12053                "worktree",
12054                "add",
12055                "-b",
12056                "topic-behind",
12057                behind_path.to_str().expect("utf8 path"),
12058                "main",
12059            ],
12060        );
12061
12062        // Moves only the default branch's own remote-tracking ref, the same shape a
12063        // fetch leaves behind: `repo`'s own checked-out `main` does not move, so this
12064        // is deliberately not exercising the auto-update itself, only what a moved
12065        // default branch does to every Worktree's own `base` count.
12066        git(&repo, &["checkout", "-b", "scratch"]);
12067        git(&repo, &["commit", "--allow-empty", "-m", "second"]);
12068        let sha_b = head_sha(&repo);
12069        git(&repo, &["checkout", "main"]);
12070        git(&repo, &["update-ref", "refs/remotes/origin/main", &sha_b]);
12071        git(&repo, &["branch", "-D", "scratch"]);
12072
12073        // Branched from the plain commit sha, not `refs/remotes/origin/main` itself:
12074        // starting a new branch from a remote-tracking ref makes git auto-configure it
12075        // to track that same ref, which would make this row the default branch's own
12076        // row (`base.rs`'s `branch_is_default_branchs_own_row`) and settle `base` as
12077        // `NotApplicable` rather than the `0` this fixture means to prove.
12078        let caught_up_path = root.join("wt-caught-up");
12079        git(
12080            &repo,
12081            &[
12082                "worktree",
12083                "add",
12084                "-b",
12085                "topic-caught-up",
12086                caught_up_path.to_str().expect("utf8 path"),
12087                &sha_b,
12088            ],
12089        );
12090
12091        let core = Core::start_discovered(spec(vec![root]));
12092        let snapshot = refresh_and_settle(&core);
12093
12094        let base_of = |name: &str| -> u32 {
12095            let entity = snapshot
12096                .entities
12097                .iter()
12098                .find(|entity| &*entity.name == name)
12099                .unwrap_or_else(|| panic!("no entity named {name} in {snapshot:?}"));
12100            match entity.base.settled() {
12101                Some(Settled::Known {
12102                    value,
12103                    at: _,
12104                    stale: _,
12105                }) => *value,
12106                other => panic!("expected a known base count for {name}, got {other:?}"),
12107            }
12108        };
12109
12110        assert!(
12111            base_of("wt-behind") > 0,
12112            "a Worktree branched before the default branch moved must be reported behind"
12113        );
12114        assert_eq!(
12115            base_of("wt-caught-up"),
12116            0,
12117            "a Worktree branched from the new tip must not be reported behind"
12118        );
12119    }
12120
12121    /// The periodic fetch's own scheduler: criterion 3's five rules
12122    /// ([refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
12123    /// "The periodic fetch"). Every fixture here is a bare repo this test creates plus a
12124    /// real `git clone` of it, per the standing constraint that a fetch test never
12125    /// touches a real remote or the network.
12126    mod fetch_scheduler {
12127        use super::*;
12128        use crate::liveness::wait_for_or;
12129
12130        fn fetch_spec(enabled: bool, root: PathBuf) -> CoreSpec {
12131            let mut spec = spec(vec![root]);
12132            spec.fetch = FetchSpec {
12133                enabled,
12134                interval: Duration::from_secs(3600),
12135                concurrency: 4,
12136            };
12137            spec
12138        }
12139
12140        /// A bare "remote" this call creates and seeds with one commit, never a real
12141        /// remote and never touched over the network.
12142        fn seeded_remote() -> tempfile::TempDir {
12143            let remote = tempfile::tempdir().expect("temp dir");
12144            crate::test_support::init_bare(remote.path());
12145            crate::test_support::push_new_commit(remote.path(), "README.md", "seed\n");
12146            remote
12147        }
12148
12149        fn clone_into(remote: &Path, dest: &Path) {
12150            let status = Command::new("git")
12151                .arg("clone")
12152                .arg(remote)
12153                .arg(dest)
12154                .status()
12155                .expect("run git clone");
12156            assert!(status.success());
12157            crate::test_support::set_identity(dest);
12158        }
12159
12160        /// The scheduler's first rule: enabling the periodic fetch runs one cycle
12161        /// immediately rather than waiting for `fetch.interval` to elapse. `fetch_ticks`
12162        /// is `crossbeam_channel::never()`, so the only way `fetch_cycle_count_for_test`
12163        /// can ever move is the immediate cycle `start_internal` dispatches on its own
12164        /// plain thread; a scheduler that only reacted to a tick would leave this at
12165        /// zero forever.
12166        #[test]
12167        fn enabling_the_periodic_fetch_runs_one_cycle_before_any_tick_arrives() {
12168            let remote = seeded_remote();
12169            let root = tempfile::tempdir().expect("temp dir");
12170            let root_path = root_of(&root);
12171            clone_into(remote.path(), &root_path.join("parent"));
12172
12173            let fetch_ticks: Receiver<Instant> = crossbeam_channel::never();
12174            let started = Core::start_for_test_with_fetch(
12175                fetch_spec(true, root_path),
12176                Duration::from_secs(3600),
12177                crossbeam_channel::never(),
12178                fetch_ticks,
12179            )
12180            .discovered();
12181            let core = started.core;
12182
12183            wait_for(
12184                "the periodic fetch to run its first cycle without waiting for a tick",
12185                || core.fetch_cycle_count_for_test() >= 1,
12186            );
12187        }
12188
12189        /// A tick on the periodic fetch's own channel runs a second cycle, proving the
12190        /// recurring cadence is wired to the same dedicated thread the immediate cycle
12191        /// used, not merely a one-shot dispatched at start.
12192        #[test]
12193        fn a_tick_on_the_fetch_channel_runs_another_cycle() {
12194            let remote = seeded_remote();
12195            let root = tempfile::tempdir().expect("temp dir");
12196            let root_path = root_of(&root);
12197            clone_into(remote.path(), &root_path.join("parent"));
12198
12199            let (fetch_tick_tx, fetch_tick_rx) = crossbeam_channel::unbounded();
12200            let started = Core::start_for_test_with_fetch(
12201                fetch_spec(true, root_path),
12202                Duration::from_secs(3600),
12203                crossbeam_channel::never(),
12204                fetch_tick_rx,
12205            )
12206            .discovered();
12207            let core = started.core;
12208
12209            wait_for("the immediate cycle to have run first", || {
12210                core.fetch_cycle_count_for_test() >= 1
12211            });
12212
12213            fetch_tick_tx
12214                .send(Instant::now())
12215                .expect("send a fetch tick");
12216
12217            wait_for("a tick on the fetch channel to run a second cycle", || {
12218                core.fetch_cycle_count_for_test() >= 2
12219            });
12220        }
12221
12222        /// Points `repo`'s `origin` at a path nothing lives at, breaking `fetch_and_prune`
12223        /// alone: discovery has already found `repo` as a real Repo before this runs, so
12224        /// only the fetch itself fails, never the walk. A local path rather than a loopback
12225        /// address, so this never touches even the machine's own network stack, the same
12226        /// standing constraint every fixture in this module already holds to.
12227        fn break_remote(repo: &Path) {
12228            let status = Command::new("git")
12229                .arg("-C")
12230                .arg(repo)
12231                .args(["remote", "set-url", "origin", "/nonexistent-remote-282"])
12232                .status()
12233                .expect("run git remote set-url");
12234            assert!(status.success());
12235        }
12236
12237        /// Criterion: a cycle where every fetch succeeds reports no failures.
12238        #[test]
12239        fn a_cycle_in_which_every_fetch_succeeds_reports_no_failures() {
12240            let remote = seeded_remote();
12241            let root = tempfile::tempdir().expect("temp dir");
12242            let root_path = root_of(&root);
12243            clone_into(remote.path(), &root_path.join("parent"));
12244
12245            let fetch_ticks: Receiver<Instant> = crossbeam_channel::never();
12246            let started = Core::start_for_test_with_fetch(
12247                fetch_spec(true, root_path),
12248                Duration::from_secs(3600),
12249                crossbeam_channel::never(),
12250                fetch_ticks,
12251            )
12252            .discovered();
12253            let core = started.core;
12254
12255            wait_for("the periodic fetch to run its first cycle", || {
12256                core.fetch_cycle_count_for_test() >= 1
12257            });
12258
12259            assert!(
12260                core.fetch_failures().failed.is_empty(),
12261                "a cycle where every fetch succeeds must report no failures, got: {:?}",
12262                core.fetch_failures().failed
12263            );
12264        }
12265
12266        /// A cycle in which one repository cannot be fetched counts that one failure, and
12267        /// the per-repository independence at the fetch loop's own swallow is unchanged,
12268        /// proven here by the sibling repository still fetching.
12269        #[test]
12270        fn a_repository_that_cannot_be_fetched_is_counted_while_its_sibling_still_fetches() {
12271            let good_remote = seeded_remote();
12272            let bad_remote = seeded_remote();
12273            let root = tempfile::tempdir().expect("temp dir");
12274            let root_path = root_of(&root);
12275            let good = root_path.join("good");
12276            let bad = root_path.join("bad");
12277            clone_into(good_remote.path(), &good);
12278            clone_into(bad_remote.path(), &bad);
12279            break_remote(&bad);
12280
12281            crate::test_support::push_new_commit(good_remote.path(), "second.txt", "second\n");
12282            let good_remote_tip = rev_parse(good_remote.path(), "refs/heads/main");
12283
12284            let fetch_ticks: Receiver<Instant> = crossbeam_channel::never();
12285            let started = Core::start_for_test_with_fetch(
12286                fetch_spec(true, root_path),
12287                Duration::from_secs(3600),
12288                crossbeam_channel::never(),
12289                fetch_ticks,
12290            )
12291            .discovered();
12292            let core = started.core;
12293
12294            wait_for(
12295                "the cycle to run and count the one repository it could not fetch",
12296                || core.fetch_failures().failed.len() == 1,
12297            );
12298
12299            let failures = core.fetch_failures();
12300            assert_eq!(
12301                failures.failed.len(),
12302                1,
12303                "exactly one repository failed, so exactly one failure must be counted, \
12304                 got: {:?}",
12305                failures.failed
12306            );
12307            assert!(
12308                failures.failed[0].0.to_string_lossy().contains("bad"),
12309                "the counted failure must name the repository that actually failed, \
12310                 got: {:?}",
12311                failures.failed
12312            );
12313
12314            wait_for(
12315                "the sibling repository to still fetch despite the other one failing",
12316                || rev_parse(&good, "refs/remotes/origin/main") == good_remote_tip,
12317            );
12318        }
12319
12320        /// [`crate::test_support::push_new_commit`], but onto `branch` rather than
12321        /// always `main`: this scheduler test needs a second commit on `topic`
12322        /// specifically, so ancestry alone cannot call it merged into `main`.
12323        fn push_new_commit_on_branch(remote: &Path, branch: &str, name: &str, contents: &str) {
12324            let contributor = tempfile::tempdir().expect("temp dir");
12325            let status = Command::new("git")
12326                .arg("clone")
12327                .arg("--branch")
12328                .arg(branch)
12329                .arg(remote)
12330                .arg(contributor.path())
12331                .status()
12332                .expect("run git clone");
12333            assert!(status.success());
12334            std::fs::write(contributor.path().join(name), contents).expect("write fixture file");
12335            git(contributor.path(), &["add", name]);
12336            git(contributor.path(), &["commit", "-m", "extra work on topic"]);
12337            git(contributor.path(), &["push", "origin", branch]);
12338        }
12339
12340        /// Criteria 3 and 4 together, end to end: the periodic fetch always prunes, so
12341        /// `Gone` can appear at all, and a finished fetch starts one normal Generation
12342        /// on its own, so the pruned state actually lands on the table without the test
12343        /// calling `refresh` itself. `topic` carries a commit `main` never gets, so
12344        /// ancestry alone cannot call it `Merged`; deleting it upstream before the
12345        /// scheduler's own fetch is what a plain, non-pruning fetch could never turn
12346        /// into `Gone`.
12347        #[test]
12348        fn a_finished_fetch_prunes_and_starts_its_own_generation_that_lands_gone() {
12349            let remote = seeded_remote();
12350            let root = tempfile::tempdir().expect("temp dir");
12351            let root_path = root_of(&root);
12352            let parent = root_path.join("parent");
12353            clone_into(remote.path(), &parent);
12354
12355            git(remote.path(), &["branch", "topic"]);
12356            push_new_commit_on_branch(remote.path(), "topic", "topic.txt", "extra work\n");
12357
12358            // A deliberate, ordinary fetch by the test's own setup, distinct from the
12359            // Core's own periodic fetch under test: `parent` was cloned before `topic`
12360            // existed, so this is what teaches it about `origin/topic` at all, the same
12361            // way any real clone would only learn of a branch created after it cloned
12362            // on its own next fetch.
12363            git(&parent, &["fetch", "origin"]);
12364
12365            let worktree_path = root_path.join("topic-worktree");
12366            git(
12367                &parent,
12368                &[
12369                    "worktree",
12370                    "add",
12371                    "-b",
12372                    "topic",
12373                    worktree_path.to_str().expect("utf8 path"),
12374                    "origin/topic",
12375                ],
12376            );
12377
12378            // Deleted only now, after the worktree already tracks it: this is the
12379            // upstream disappearance a plain fetch can see but never prune away, and
12380            // exactly what the scheduler's own fetch (not this setup) must prune.
12381            git(remote.path(), &["branch", "-D", "topic"]);
12382
12383            let fetch_ticks: Receiver<Instant> = crossbeam_channel::never();
12384            let started = Core::start_for_test_with_fetch(
12385                fetch_spec(true, root_path),
12386                Duration::from_secs(3600),
12387                crossbeam_channel::never(),
12388                fetch_ticks,
12389            )
12390            .discovered();
12391            let core = started.core;
12392
12393            wait_for_or(
12394                "a finished fetch's own Generation to land the pruned Worktree as Gone \
12395                 without the test ever calling refresh",
12396                || {
12397                    core.snapshot()
12398                        .entities
12399                        .iter()
12400                        .filter(|entity| matches!(entity.kind, Kind::Worktree))
12401                        .any(|entity| {
12402                            matches!(
12403                                entity.state.settled(),
12404                                Some(Settled::Known {
12405                                    value: WorktreeState::Gone,
12406                                    at: _,
12407                                    stale: _,
12408                                })
12409                            )
12410                        })
12411                },
12412                || {
12413                    format!(
12414                        "snapshot: {:?}",
12415                        core.snapshot()
12416                            .entities
12417                            .iter()
12418                            .map(|entity| (entity.kind, entity.state.settled().cloned()))
12419                            .collect::<Vec<_>>()
12420                    )
12421                },
12422            );
12423        }
12424
12425        fn spec_with_auto_update(
12426            fetch_enabled: bool,
12427            auto_update_enabled: bool,
12428            root: PathBuf,
12429        ) -> CoreSpec {
12430            let mut spec = fetch_spec(fetch_enabled, root);
12431            spec.auto_update = AutoUpdateSpec {
12432                enabled: auto_update_enabled,
12433            };
12434            spec
12435        }
12436
12437        fn rev_parse(path: &Path, rev: &str) -> String {
12438            let output = Command::new("git")
12439                .arg("-C")
12440                .arg(path)
12441                .args(["rev-parse", rev])
12442                .output()
12443                .expect("run git rev-parse");
12444            assert!(output.status.success(), "git rev-parse {rev} failed");
12445            String::from_utf8(output.stdout)
12446                .expect("utf8 sha")
12447                .trim()
12448                .to_string()
12449        }
12450
12451        /// Criterion 1's "off by default" half: `fetch.enabled` alone is not enough to
12452        /// move a branch. `fetch_ticks` never fires, so the only cycle that can possibly
12453        /// run is the immediate one `start_internal` dispatches on being enabled; that
12454        /// cycle fetches (`fetch_cycle_count_for_test` proves it ran) and must still
12455        /// leave the eligible local branch exactly where it was, since `auto_update`
12456        /// carries its own, separate `enabled` flag this spec never turns on.
12457        #[test]
12458        fn auto_update_is_off_by_default_even_with_fetch_enabled() {
12459            let remote = seeded_remote();
12460            let root = tempfile::tempdir().expect("temp dir");
12461            let root_path = root_of(&root);
12462            let parent = root_path.join("parent");
12463            clone_into(remote.path(), &parent);
12464            let before = rev_parse(&parent, "refs/heads/main");
12465
12466            crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
12467
12468            let fetch_ticks: Receiver<Instant> = crossbeam_channel::never();
12469            let started = Core::start_for_test_with_fetch(
12470                spec_with_auto_update(true, false, root_path),
12471                Duration::from_secs(3600),
12472                crossbeam_channel::never(),
12473                fetch_ticks,
12474            )
12475            .discovered();
12476            let core = started.core;
12477
12478            wait_for(
12479                "the periodic fetch to still run its immediate cycle",
12480                || core.fetch_cycle_count_for_test() >= 1,
12481            );
12482            assert_eq!(
12483                rev_parse(&parent, "refs/heads/main"),
12484                before,
12485                "an eligible branch must not move while auto_update.enabled is false, \
12486                 even though fetch.enabled is true"
12487            );
12488        }
12489
12490        /// Criterion 1's "rides the fetch cycle with no timer of its own" half: the
12491        /// remote is already ahead *before* `Core::start`, `fetch_ticks` is
12492        /// `crossbeam_channel::never()` so no recurring tick ever fires, and yet the
12493        /// eligible branch still moves, proving the auto-update ran on the same
12494        /// immediate first cycle the periodic fetch itself uses rather than waiting on
12495        /// any tick of its own.
12496        #[test]
12497        fn auto_update_enabled_rides_the_immediate_fetch_cycle_with_no_timer_of_its_own() {
12498            let remote = seeded_remote();
12499            let root = tempfile::tempdir().expect("temp dir");
12500            let root_path = root_of(&root);
12501            let parent = root_path.join("parent");
12502            clone_into(remote.path(), &parent);
12503
12504            crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
12505            let remote_tip = rev_parse(remote.path(), "refs/heads/main");
12506
12507            let fetch_ticks: Receiver<Instant> = crossbeam_channel::never();
12508            let started = Core::start_for_test_with_fetch(
12509                spec_with_auto_update(true, true, root_path),
12510                Duration::from_secs(3600),
12511                crossbeam_channel::never(),
12512                fetch_ticks,
12513            )
12514            .discovered();
12515            // Kept alive, unused otherwise: dropping `Core` joins its dedicated thread,
12516            // which would stop the immediate cycle this test is waiting on.
12517            let _core = started.core;
12518
12519            wait_for(
12520                "the eligible branch to fast-forward on the immediate cycle alone, with no \
12521                 fetch tick and no auto-update tick of its own",
12522                || rev_parse(&parent, "refs/heads/main") == remote_tip,
12523            );
12524        }
12525    }
12526
12527    /// [`Core::attempt_auto_update`] must answer exactly what
12528    /// [`crate::auto_update::attempt`] would for the same Repo, since it delegates to that
12529    /// function rather than reimplementing its own copy of the eligibility rules: the
12530    /// built-in `sync` action's own "reuses `auto_update`'s existing rules rather than a
12531    /// second implementation"
12532    /// ([repo-management.md](https://github.com/paulchiu/repon/blob/main/docs/spec/repo-management.md))
12533    /// is proven here, at the one seam a reimplementation could actually diverge from the
12534    /// rules it is supposed to reuse. Every fixture is a bare repo this test creates plus a
12535    /// real `git clone` of it, the same standing constraint `fetch_scheduler` above follows.
12536    mod attempt_auto_update {
12537        use super::*;
12538
12539        fn seeded_remote() -> tempfile::TempDir {
12540            let remote = tempfile::tempdir().expect("temp dir");
12541            crate::test_support::init_bare(remote.path());
12542            crate::test_support::push_new_commit(remote.path(), "README.md", "seed\n");
12543            remote
12544        }
12545
12546        fn clone_into(remote: &Path, dest: &Path) {
12547            let status = Command::new("git")
12548                .arg("clone")
12549                .arg(remote)
12550                .arg(dest)
12551                .status()
12552                .expect("run git clone");
12553            assert!(status.success());
12554            crate::test_support::set_identity(dest);
12555        }
12556
12557        /// Discovers `root`'s one Repo and hands back the live `Core` alongside its key,
12558        /// the same `Core::start_discovered` plus `settle` shape [`delete_risk`]'s own tests
12559        /// already use: this method reads the repository fresh, not a Cell, so discovery's
12560        /// own read-only probes running first are never a race with it.
12561        fn discover_repo(root: &Path) -> (Core, EntityKey) {
12562            let core = Core::start_discovered(spec(vec![root.to_path_buf()]));
12563            let key = core
12564                .settle()
12565                .entities
12566                .into_iter()
12567                .find(|entity| entity.kind == Kind::Repo)
12568                .expect("the Repo row is discovered")
12569                .key;
12570            (core, key)
12571        }
12572
12573        /// The eligible condition: clean, behind, not ahead, tracking an upstream. Proves
12574        /// the wrapper both classifies and actually moves the branch, not only the former.
12575        #[test]
12576        fn an_eligible_repo_fast_forwards_through_the_wrapper_too() {
12577            let remote = seeded_remote();
12578            let root = tempfile::tempdir().expect("temp dir");
12579            let root_path = root_of(&root);
12580            let repo = root_path.join("repo");
12581            clone_into(remote.path(), &repo);
12582            crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
12583            crate::test_support::git(&repo, &["fetch", "origin"]);
12584
12585            let (core, key) = discover_repo(&root_path);
12586
12587            assert_eq!(core.attempt_auto_update(&key), AutoUpdateAttempt::Updated);
12588            assert!(
12589                repo.join("second.txt").exists(),
12590                "the fast-forward must reach the working tree through the wrapper too"
12591            );
12592        }
12593
12594        /// Condition 1: a dirty working tree.
12595        #[test]
12596        fn a_dirty_repo_is_reported_not_clean_through_the_wrapper_too() {
12597            let remote = seeded_remote();
12598            let root = tempfile::tempdir().expect("temp dir");
12599            let root_path = root_of(&root);
12600            let repo = root_path.join("repo");
12601            clone_into(remote.path(), &repo);
12602            crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
12603            crate::test_support::git(&repo, &["fetch", "origin"]);
12604            fs::write(repo.join("stray.txt"), "uncommitted\n").expect("write a stray file");
12605
12606            let (core, key) = discover_repo(&root_path);
12607
12608            assert_eq!(core.attempt_auto_update(&key), AutoUpdateAttempt::NotClean);
12609        }
12610
12611        /// Condition 2: already level with the upstream.
12612        #[test]
12613        fn an_up_to_date_repo_is_reported_not_behind_through_the_wrapper_too() {
12614            let remote = seeded_remote();
12615            let root = tempfile::tempdir().expect("temp dir");
12616            let root_path = root_of(&root);
12617            let repo = root_path.join("repo");
12618            clone_into(remote.path(), &repo);
12619            crate::test_support::git(&repo, &["fetch", "origin"]);
12620
12621            let (core, key) = discover_repo(&root_path);
12622
12623            assert_eq!(core.attempt_auto_update(&key), AutoUpdateAttempt::NotBehind);
12624        }
12625
12626        /// Condition 3: a local commit the upstream does not have.
12627        #[test]
12628        fn an_unpublished_local_commit_is_reported_not_fast_forward_through_the_wrapper_too() {
12629            let remote = seeded_remote();
12630            let root = tempfile::tempdir().expect("temp dir");
12631            let root_path = root_of(&root);
12632            let repo = root_path.join("repo");
12633            clone_into(remote.path(), &repo);
12634            crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
12635            crate::test_support::git(&repo, &["fetch", "origin"]);
12636            crate::test_support::commit_file(&repo, "local-only.txt", "never pushed\n");
12637
12638            let (core, key) = discover_repo(&root_path);
12639
12640            assert_eq!(
12641                core.attempt_auto_update(&key),
12642                AutoUpdateAttempt::NotFastForward
12643            );
12644        }
12645
12646        /// Condition 4: no upstream configured at all.
12647        #[test]
12648        fn a_branch_with_no_upstream_is_reported_through_the_wrapper_too() {
12649            let remote = seeded_remote();
12650            let root = tempfile::tempdir().expect("temp dir");
12651            let root_path = root_of(&root);
12652            let repo = root_path.join("repo");
12653            clone_into(remote.path(), &repo);
12654            crate::test_support::git(&repo, &["checkout", "-b", "untracked-branch"]);
12655
12656            let (core, key) = discover_repo(&root_path);
12657
12658            assert_eq!(
12659                core.attempt_auto_update(&key),
12660                AutoUpdateAttempt::NoUpstream
12661            );
12662        }
12663    }
12664
12665    /// [default-branch.md](https://github.com/paulchiu/repon/blob/main/docs/spec/default-branch.md)'s
12666    /// "The network": criterion 3 (the local chain answers first, and only a later network
12667    /// round trip supersedes it) and criterion 4 (`Core::rederive_default_branches` runs the
12668    /// same lookup on demand, over exactly the given keys, without fetching). Every fixture
12669    /// here is a bare repo this test creates plus a real `git clone` of it, the same standing
12670    /// constraint `fetch_scheduler` above already follows.
12671    mod network_default_branch {
12672        use super::*;
12673
12674        fn seeded_remote() -> tempfile::TempDir {
12675            let remote = tempfile::tempdir().expect("temp dir");
12676            crate::test_support::init_bare(remote.path());
12677            crate::test_support::push_new_commit(remote.path(), "README.md", "seed\n");
12678            remote
12679        }
12680
12681        fn clone_into(remote: &Path, dest: &Path) {
12682            let status = Command::new("git")
12683                .arg("clone")
12684                .arg(remote)
12685                .arg(dest)
12686                .status()
12687                .expect("run git clone");
12688            assert!(status.success());
12689            crate::test_support::set_identity(dest);
12690        }
12691
12692        /// Sets `path`'s own `HEAD` (a bare repo, so this is the "remote"'s advertised
12693        /// answer) to point at `branch`, without checking anything out.
12694        fn set_remote_head(path: &Path, branch: &str) {
12695            git(
12696                path,
12697                &["symbolic-ref", "HEAD", &format!("refs/heads/{branch}")],
12698            );
12699        }
12700
12701        fn rev_parse(path: &Path, rev: &str) -> String {
12702            let output = Command::new("git")
12703                .arg("-C")
12704                .arg(path)
12705                .args(["rev-parse", rev])
12706                .output()
12707                .expect("run git rev-parse");
12708            assert!(output.status.success());
12709            String::from_utf8(output.stdout)
12710                .expect("utf8 sha")
12711                .trim()
12712                .to_string()
12713        }
12714
12715        fn default_branch_name(entity: &EntityState) -> Option<String> {
12716            match entity.default_branch.settled() {
12717                Some(Settled::Known {
12718                    value,
12719                    at: _,
12720                    stale: _,
12721                }) => Some(value.name().to_string()),
12722                _ => None,
12723            }
12724        }
12725
12726        /// Criterion 3: with a reachable remote whose advertised HEAD differs from the
12727        /// clone's own cached `origin/HEAD`, a plain refresh still answers from the local
12728        /// chain alone (the network is never consulted just to render a Generation), and
12729        /// only [`Core::rederive_default_branches`] actually reaching the remote supersedes
12730        /// it, for the rest of this `Core`'s own session (default-branch.md's "The network":
12731        /// "supersedes the local one for that session"). The mutation this is chosen to
12732        /// catch: were `supersede_with_network` never applied (or applied unconditionally
12733        /// before the local chain even ran), either the first assertion would already read
12734        /// `origin/trunk`, or the second would still read `origin/main`.
12735        #[test]
12736        fn the_local_chain_answers_first_and_only_a_later_network_round_trip_supersedes_it() {
12737            let remote = seeded_remote();
12738            let root = tempfile::tempdir().expect("temp dir");
12739            let root_path = root_of(&root);
12740            let repo_path = root_path.join("repo");
12741            clone_into(remote.path(), &repo_path);
12742
12743            // The clone's own cached `origin/HEAD` still names `main`; the remote's own
12744            // current answer is changed to a different, real branch only after cloning.
12745            git(remote.path(), &["branch", "trunk"]);
12746            set_remote_head(remote.path(), "trunk");
12747
12748            let core = Core::start_discovered(spec(vec![root_path]));
12749            let key = core.snapshot().entities[0].key.clone();
12750
12751            core.refresh(std::slice::from_ref(&key));
12752            let settled = core.settle();
12753            assert_eq!(
12754                default_branch_name(&settled.entities[0]),
12755                Some("origin/main".to_string()),
12756                "a plain refresh must answer from the local chain alone, unaffected by the \
12757                 remote's own current (but not yet asked) truth"
12758            );
12759
12760            core.rederive_default_branches(std::slice::from_ref(&key));
12761            let settled = core.settle();
12762            assert_eq!(
12763                default_branch_name(&settled.entities[0]),
12764                Some("origin/trunk".to_string()),
12765                "once the network round trip actually ran, its own differing answer must \
12766                 supersede the local chain's"
12767            );
12768        }
12769
12770        /// Criterion 4: [`Core::rederive_default_branches`] runs the same lookup on demand,
12771        /// over exactly the given keys, without fetching. "Without fetching" is shown the
12772        /// way `fetch.rs`'s own `a_fetch_transfers_new_commits_so_a_behind_count_can_move`
12773        /// shows a real fetch moving one, the mirror image: the remote gains a new commit
12774        /// after the clone, and this call must leave the clone's own remote-tracking ref
12775        /// exactly where it was, because `probe_remote_head`'s handshake-only lookup
12776        /// transfers no pack. "Over the Selection" is exercised as "over exactly the given
12777        /// keys": a second, unrelated repo stands in for a row outside it, and its whole
12778        /// entity state (every cell, not only `default_branch`) is asserted unchanged.
12779        #[test]
12780        fn rederive_default_branches_never_fetches_and_leaves_a_row_outside_it_untouched() {
12781            let remote = seeded_remote();
12782            let root = tempfile::tempdir().expect("temp dir");
12783            let root_path = root_of(&root);
12784            let selected_path = root_path.join("selected");
12785            let outside_path = root_path.join("outside");
12786            clone_into(remote.path(), &selected_path);
12787            init_repo_with_a_commit(&outside_path);
12788
12789            git(remote.path(), &["branch", "trunk"]);
12790            crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
12791            set_remote_head(remote.path(), "trunk");
12792            let before_tracking = rev_parse(&selected_path, "refs/remotes/origin/main");
12793
12794            let core = Core::start_discovered(spec(vec![root_path]));
12795            let snapshot = core.snapshot();
12796            let selected_key = snapshot
12797                .entities
12798                .iter()
12799                .find(|entity| entity.key.path() == selected_path)
12800                .expect("discovered the selected repo")
12801                .key
12802                .clone();
12803            let outside_key = snapshot
12804                .entities
12805                .iter()
12806                .find(|entity| entity.key.path() == outside_path)
12807                .expect("discovered the outside repo")
12808                .key
12809                .clone();
12810
12811            core.refresh(&[selected_key.clone(), outside_key.clone()]);
12812            let settled = core.settle();
12813            let outside_before = format!(
12814                "{:?}",
12815                settled
12816                    .entities
12817                    .iter()
12818                    .find(|entity| entity.key == outside_key)
12819                    .expect("outside entity present")
12820            );
12821
12822            core.rederive_default_branches(std::slice::from_ref(&selected_key));
12823            let settled = core.settle();
12824
12825            let selected_after = settled
12826                .entities
12827                .iter()
12828                .find(|entity| entity.key == selected_key)
12829                .expect("selected entity present");
12830            assert_eq!(
12831                default_branch_name(selected_after),
12832                Some("origin/trunk".to_string()),
12833                "the rederive must have reached the remote's own current, differing answer"
12834            );
12835
12836            let after_tracking = rev_parse(&selected_path, "refs/remotes/origin/main");
12837            assert_eq!(
12838                before_tracking, after_tracking,
12839                "a rederive must never fetch: the remote-tracking ref must not have moved \
12840                 even though the remote gained a new commit"
12841            );
12842
12843            let outside_after = format!(
12844                "{:?}",
12845                settled
12846                    .entities
12847                    .iter()
12848                    .find(|entity| entity.key == outside_key)
12849                    .expect("outside entity present")
12850            );
12851            assert_eq!(
12852                outside_before, outside_after,
12853                "a row outside the rederive's own keys must be left exactly as it was, not \
12854                 only on its default_branch cell"
12855            );
12856        }
12857    }
12858
12859    // =====================================================================================
12860    // `set_exclusions`: `[[repo]]`'s `exclude` re-applied live, with no rebuild and no
12861    // rediscovery, per repo-management.md's "Writing config".
12862    // =====================================================================================
12863
12864    /// The live half: a row already in the table becomes excluded, and is subtracted from
12865    /// `operable_count`, without a rebuilt `Core` and without a Generation of any kind.
12866    #[test]
12867    fn set_exclusions_excludes_a_row_already_in_the_table_with_no_rebuild() {
12868        let dir = tempfile::tempdir().expect("temp dir");
12869        let root = root_of(&dir);
12870        let repo = root.join("repo");
12871        init_repo_with_a_commit(&repo);
12872
12873        let core = Core::start_discovered(spec(vec![root]));
12874        let snapshot = core.settle();
12875        let key = snapshot.entities[0].key.clone();
12876        let generation_before = snapshot.generation;
12877        assert!(
12878            !snapshot.entities[0].excluded,
12879            "nothing excludes it to start with"
12880        );
12881        assert_eq!(core.operable_count(std::slice::from_ref(&key)), 1);
12882
12883        core.set_exclusions(&[RepoOverride {
12884            path: repo.clone(),
12885            default_branch: None,
12886            excluded: true,
12887        }]);
12888
12889        let after = core.snapshot();
12890        assert!(
12891            after.entities[0].excluded,
12892            "the row the write named is excluded in the very next snapshot"
12893        );
12894        assert_eq!(
12895            core.operable_count(&[key]),
12896            0,
12897            "an excluded row is subtracted from what an operation may reach"
12898        );
12899        assert_eq!(
12900            after.generation, generation_before,
12901            "re-applying an operate-time filter must start no Generation of its own"
12902        );
12903    }
12904
12905    /// The other direction: dropping the entry clears the flag, so a row ignored and shown
12906    /// again in one session ends where it started.
12907    #[test]
12908    fn set_exclusions_clears_the_flag_when_the_entry_is_gone() {
12909        let dir = tempfile::tempdir().expect("temp dir");
12910        let root = root_of(&dir);
12911        let repo = root.join("repo");
12912        init_repo_with_a_commit(&repo);
12913
12914        let core = Core::start_discovered(spec_with_overrides(
12915            vec![root],
12916            vec![RepoOverride {
12917                path: repo.clone(),
12918                default_branch: None,
12919                excluded: true,
12920            }],
12921        ));
12922        assert!(
12923            core.settle().entities[0].excluded,
12924            "the starting override excludes it"
12925        );
12926
12927        core.set_exclusions(&[]);
12928
12929        assert!(
12930            !core.snapshot().entities[0].excluded,
12931            "removing the entry unexcludes the row in the very next snapshot"
12932        );
12933    }
12934
12935    /// The boundary the specification draws around the live half: `exclude` re-applies and
12936    /// `default_branch` does not, because one is an operate-time filter and the other is a
12937    /// probe input. A `set_exclusions` that swapped the whole `[[repo]]` reading in would
12938    /// move both, which is what this refuses.
12939    #[test]
12940    fn set_exclusions_moves_exclude_alone_and_never_the_default_branch_override() {
12941        let dir = tempfile::tempdir().expect("temp dir");
12942        let root = root_of(&dir);
12943        let repo = root.join("repo");
12944        init_repo_with_a_commit(&repo);
12945        crate::test_support::git(&repo, &["branch", "trunk"]);
12946
12947        let core = Core::start_discovered(spec(vec![root]));
12948        let key = core.settle().entities[0].key.clone();
12949        core.refresh(std::slice::from_ref(&key));
12950        let before = format!("{:?}", core.settle().entities[0].default_branch.settled());
12951
12952        core.set_exclusions(&[RepoOverride {
12953            path: repo.clone(),
12954            default_branch: Some("trunk".to_string()),
12955            excluded: true,
12956        }]);
12957        core.refresh(&[key]);
12958        core.settle();
12959
12960        let after = core.snapshot();
12961        assert!(after.entities[0].excluded, "exclude took effect");
12962        assert_eq!(
12963            format!("{:?}", after.entities[0].default_branch.settled()),
12964            before,
12965            "a default_branch override reaches a session only through a rebuilt Core"
12966        );
12967    }
12968
12969    // =====================================================================================
12970    // `record_own_work`: the receipt a Management operation leaves, docs/spec/repo-management.md
12971    // =====================================================================================
12972
12973    /// One receipt per named row, and the shape the caller never gets to choose: `running` is
12974    /// `None`, `skip` is `None` (a refusal is not an excluded row), and there is
12975    /// exactly one step, because such an operation is one act rather than an ordered list.
12976    #[test]
12977    fn record_own_work_leaves_one_receipt_per_row_it_names_and_none_elsewhere() {
12978        let dir = tempfile::tempdir().expect("temp dir");
12979        let root = root_of(&dir);
12980        init_repo_with_a_commit(&root.join("repo-a"));
12981        init_repo_with_a_commit(&root.join("repo-b"));
12982
12983        let core = Core::start_discovered(spec(vec![root]));
12984        let entities = core.settle().entities;
12985        let named = entities
12986            .iter()
12987            .find(|entity| &*entity.name == "repo-a")
12988            .expect("repo-a is discovered")
12989            .key
12990            .clone();
12991
12992        core.record_own_work(
12993            "ignore",
12994            &[(
12995                named.clone(),
12996                OwnWork::Refused(Arc::from("refused, already ignored")),
12997                Duration::from_millis(7),
12998            )],
12999        );
13000
13001        let after = core.snapshot().entities;
13002        let receipt = after
13003            .iter()
13004            .find(|entity| entity.key == named)
13005            .and_then(|entity| entity.last_action.clone())
13006            .expect("the row it named carries a receipt");
13007        assert_eq!(&*receipt.label, "ignore");
13008        assert!(
13009            !receipt.not_applicable(),
13010            "a refusal is not an excluded row"
13011        );
13012        assert!(receipt.running.is_none(), "the work is already done");
13013        assert_eq!(receipt.steps.len(), 1, "one act, not an ordered list");
13014        assert_eq!(&*receipt.steps[0].label, "ignore");
13015        assert_eq!(receipt.steps[0].elapsed, Duration::from_millis(7));
13016        assert!(receipt.steps[0].output.is_empty(), "nothing to quote");
13017        assert!(receipt.steps[0].elision.is_none());
13018        assert_eq!(
13019            receipt.steps[0].outcome,
13020            StepOutcome::OwnWork(OwnWork::Refused(Arc::from("refused, already ignored"))),
13021        );
13022        assert!(
13023            after
13024                .iter()
13025                .filter(|entity| entity.key != named)
13026                .all(|entity| entity.last_action.is_none()),
13027            "no row this did not name takes a receipt"
13028        );
13029    }
13030
13031    /// A key the table no longer holds is skipped rather than panicking or landing on the
13032    /// wrong row, the same fallback every key-addressed entry point here gives one: a `delete`
13033    /// whose Repo is already gone is exactly this case.
13034    #[test]
13035    fn record_own_work_skips_a_key_the_table_no_longer_holds() {
13036        let dir = tempfile::tempdir().expect("temp dir");
13037        let root = root_of(&dir);
13038        init_repo_with_a_commit(&root.join("repo-a"));
13039
13040        let core = Core::start_discovered(spec(vec![root]));
13041        let entities = core.settle().entities;
13042        let stranger = EntityKey::new(Arc::from(std::path::Path::new("/nowhere/at/all")));
13043
13044        core.record_own_work(
13045            "delete",
13046            &[(stranger, OwnWork::Did(Arc::from("gone")), Duration::ZERO)],
13047        );
13048
13049        assert!(
13050            core.snapshot()
13051                .entities
13052                .iter()
13053                .all(|entity| entity.last_action.is_none()),
13054            "an unknown key writes nothing anywhere"
13055        );
13056        assert_eq!(core.snapshot().entities.len(), entities.len());
13057    }
13058
13059    // =====================================================================================
13060    // `delete_risk`: the three facts repo-management.md's confirm gate names per Repo, read
13061    // rather than stubbed. Every repository here is built in a temp directory this test owns,
13062    // and no path comes from config, an environment variable or the working directory.
13063    // =====================================================================================
13064
13065    /// A Repo with all three: an uncommitted change, a commit no remote-tracking ref carries,
13066    /// and a linked Worktree pointing into it.
13067    #[test]
13068    fn delete_risk_reads_all_three_facts_the_confirm_gate_names() {
13069        let dir = tempfile::tempdir().expect("temp dir");
13070        let root = root_of(&dir);
13071        let repo = root.join("repo");
13072        init_repo_with_a_commit(&repo);
13073        fs::write(repo.join("uncommitted.txt"), "not staged\n").expect("write a stray file");
13074        crate::test_support::git(
13075            &repo,
13076            &["worktree", "add", "-b", "sidecar", "../sidecar-worktree"],
13077        );
13078
13079        let core = Core::start_discovered(spec(vec![root]));
13080        // Settled first, so the startup Generation's own phase C is no longer reading this
13081        // same repository while the line below reads it: two concurrent gix statuses over one
13082        // working tree is a race in the harness, not in `delete_risk`.
13083        let key = core
13084            .settle()
13085            .entities
13086            .into_iter()
13087            .find(|entity| entity.kind == Kind::Repo)
13088            .expect("the Repo row is discovered")
13089            .key;
13090
13091        let risk = core.delete_risk(&key).expect("read the risk");
13092
13093        assert!(risk.uncommitted, "the stray file makes the tree dirty");
13094        assert!(
13095            risk.unpushed_commits > 0 && risk.unpushed_branches > 0,
13096            "no remote-tracking ref carries any of this Repo's commits, got {risk:?}"
13097        );
13098        assert_eq!(
13099            risk.linked_worktrees, 1,
13100            "the one linked Worktree pointing into this Repo is counted, got {risk:?}"
13101        );
13102    }
13103
13104    /// The `uncommitted` field's own range, one position at a time, because the composition
13105    /// behind it folds four separate reads: a modified tracked file, a deleted tracked file,
13106    /// an untracked file, and a staged change. Each gets a repository of its own with nothing
13107    /// else wrong with it, so narrowing the composition to any one of the four fails here
13108    /// rather than passing on whichever position a single fixture happened to sample.
13109    #[test]
13110    fn every_kind_of_work_that_is_not_in_a_commit_makes_the_gate_say_uncommitted() {
13111        for kind in ["modified", "deleted", "untracked", "staged"] {
13112            let dir = tempfile::tempdir().expect("temp dir");
13113            let root = root_of(&dir);
13114            let repo = root.join("repo");
13115            init_repo_with_a_commit(&repo);
13116            fs::write(repo.join("tracked.txt"), "first\n").expect("write a tracked file");
13117            crate::test_support::git(&repo, &["add", "tracked.txt"]);
13118            crate::test_support::git(&repo, &["commit", "-m", "add tracked"]);
13119            let sha = crate::test_support::head_sha(&repo);
13120            crate::test_support::git(&repo, &["update-ref", "refs/remotes/origin/main", &sha]);
13121
13122            match kind {
13123                "modified" => fs::write(repo.join("tracked.txt"), "second\n").expect("modify it"),
13124                "deleted" => fs::remove_file(repo.join("tracked.txt")).expect("delete it"),
13125                "untracked" => fs::write(repo.join("stray.txt"), "new\n").expect("write a stray"),
13126                "staged" => {
13127                    fs::write(repo.join("staged.txt"), "new\n").expect("write a new file");
13128                    crate::test_support::git(&repo, &["add", "staged.txt"]);
13129                }
13130                other => unreachable!("unhandled kind {other}"),
13131            }
13132
13133            let core = Core::start_discovered(spec(vec![root]));
13134            let key = core.settle().entities[0].key.clone();
13135
13136            let risk = core.delete_risk(&key).expect("read the risk");
13137
13138            assert!(
13139                risk.uncommitted,
13140                "a {kind} change is work that is not in a commit, got {risk:?}"
13141            );
13142        }
13143    }
13144
13145    /// The staged case, stated on its own as well as in the range above, because it is the
13146    /// one the dirty column deliberately answers `clean` to: `dirty_counts` compares the index
13147    /// against the working tree and never against `HEAD`, so a `git add` with no commit is
13148    /// invisible to it. Both readings are asserted here together, so a fix that widened
13149    /// `dirty_counts` instead of giving the gate its own read would fail this rather than
13150    /// silently change what the dirty column means.
13151    #[test]
13152    fn staged_work_reads_clean_to_the_dirty_column_and_uncommitted_to_the_delete_gate() {
13153        let dir = tempfile::tempdir().expect("temp dir");
13154        let root = root_of(&dir);
13155        let repo = root.join("repo");
13156        init_repo_with_a_commit(&repo);
13157        let sha = crate::test_support::head_sha(&repo);
13158        crate::test_support::git(&repo, &["update-ref", "refs/remotes/origin/main", &sha]);
13159        fs::write(repo.join("staged.txt"), "staged\n").expect("write a new file");
13160        crate::test_support::git(&repo, &["add", "staged.txt"]);
13161
13162        let core = Core::start_discovered(spec(vec![root]));
13163        let key = core.settle().entities[0].key.clone();
13164
13165        let opened = git::open_thread_safe(repo.as_path())
13166            .expect("open the repo")
13167            .to_thread_local();
13168        let dirty = git::dirty_counts(&opened, Arc::new(AtomicBool::new(false)))
13169            .expect("read the dirty counts");
13170        assert_eq!(
13171            dirty.total(),
13172            0,
13173            "the dirty column stays an index-to-worktree comparison, got {dirty:?}"
13174        );
13175
13176        let risk = core.delete_risk(&key).expect("read the risk");
13177        assert!(
13178            risk.uncommitted,
13179            "a Repo whose only work is staged must never be listed plainly, got {risk:?}"
13180        );
13181    }
13182
13183    /// The two unpushed quantities are two quantities: a fixture whose commit count and
13184    /// branch count differ, so transposing the pair in the composition changes both numbers
13185    /// rather than satisfying an inequality either way round.
13186    #[test]
13187    fn unpushed_commits_and_unpushed_branches_are_counted_into_their_own_fields() {
13188        let dir = tempfile::tempdir().expect("temp dir");
13189        let root = root_of(&dir);
13190        let repo = root.join("repo");
13191        init_repo_with_a_commit(&repo);
13192        let sha = crate::test_support::head_sha(&repo);
13193        crate::test_support::git(&repo, &["update-ref", "refs/remotes/origin/main", &sha]);
13194        for nth in 0..3 {
13195            fs::write(repo.join(format!("file-{nth}.txt")), "x\n").expect("write a file");
13196            crate::test_support::git(&repo, &["add", "."]);
13197            crate::test_support::git(&repo, &["commit", "-m", "unpushed"]);
13198        }
13199        crate::test_support::git(&repo, &["checkout", "."]);
13200
13201        let core = Core::start_discovered(spec(vec![root]));
13202        let key = core.settle().entities[0].key.clone();
13203
13204        let risk = core.delete_risk(&key).expect("read the risk");
13205
13206        assert_eq!(
13207            (risk.unpushed_commits, risk.unpushed_branches),
13208            (3, 1),
13209            "three commits on one branch, each in its own field, got {risk:?}"
13210        );
13211    }
13212
13213    /// The linked-Worktree count is git's own register, not the table's: a Worktree living
13214    /// outside the active Set's roots is never discovered, and deleting the Repo it is linked
13215    /// from orphans it just the same.
13216    #[test]
13217    fn a_linked_worktree_outside_the_sets_roots_is_still_counted_by_the_gate() {
13218        let dir = tempfile::tempdir().expect("temp dir");
13219        let base = root_of(&dir);
13220        let inside = base.join("inside");
13221        let outside = base.join("outside");
13222        fs::create_dir_all(&outside).expect("create the outside dir");
13223        let repo = inside.join("repo");
13224        init_repo_with_a_commit(&repo);
13225        crate::test_support::git(
13226            &repo,
13227            &["worktree", "add", "-b", "sidecar", "../../outside/sidecar"],
13228        );
13229        assert!(
13230            outside.join("sidecar").exists(),
13231            "the harness really created a linked Worktree outside the Set's roots"
13232        );
13233
13234        // Bounded by `inside` alone, so the Worktree is not a row in this Core's own table.
13235        let core = Core::start_discovered(spec(vec![inside]));
13236        let snapshot = core.settle();
13237        assert!(
13238            snapshot
13239                .entities
13240                .iter()
13241                .all(|entity| entity.kind != Kind::Worktree),
13242            "the Worktree is outside the roots and so is not discovered, got {:?}",
13243            snapshot.entities.iter().map(|e| e.kind).collect::<Vec<_>>()
13244        );
13245        let key = snapshot
13246            .entities
13247            .into_iter()
13248            .find(|entity| entity.kind == Kind::Repo)
13249            .expect("the Repo row is discovered")
13250            .key;
13251
13252        let risk = core.delete_risk(&key).expect("read the risk");
13253
13254        assert_eq!(
13255            risk.linked_worktrees, 1,
13256            "the gate must name the linked Worktree deleting this Repo would orphan, got {risk:?}"
13257        );
13258    }
13259
13260    /// The "listed plainly" case: nothing uncommitted, every commit already on a
13261    /// remote-tracking ref, and no linked Worktree at all. Asserted as its own test rather
13262    /// than left implied, since a gate that reports risk on every Repo is as wrong as one
13263    /// that reports it on none.
13264    #[test]
13265    fn delete_risk_on_a_clean_fully_pushed_repo_with_no_worktrees_reports_nothing() {
13266        let dir = tempfile::tempdir().expect("temp dir");
13267        let root = root_of(&dir);
13268        let repo = root.join("repo");
13269        init_repo_with_a_commit(&repo);
13270        let sha = crate::test_support::head_sha(&repo);
13271        crate::test_support::git(&repo, &["update-ref", "refs/remotes/origin/main", &sha]);
13272
13273        let core = Core::start_discovered(spec(vec![root]));
13274        let key = core.settle().entities[0].key.clone();
13275
13276        let risk = core.delete_risk(&key).expect("read the risk");
13277
13278        assert_eq!(
13279            risk,
13280            DeleteRisk {
13281                uncommitted: false,
13282                unpushed_commits: 0,
13283                unpushed_branches: 0,
13284                linked_worktrees: 0,
13285            }
13286        );
13287    }
13288
13289    // =====================================================================================
13290    // `worktree_admin_dir` and `linked_worktree_paths`: what `delete` needs to remove a
13291    // linked Worktree the way `git worktree remove` does, and to take a Repo's own linked
13292    // Worktrees with it. Every repository here is built in a temp directory this test owns.
13293    // =====================================================================================
13294
13295    /// The administrative directory named for a Worktree row is the one `git worktree list`
13296    /// stops naming once it is gone, proven by removing exactly that directory by hand.
13297    #[test]
13298    fn worktree_admin_dir_names_the_entry_git_worktree_list_forgets_once_it_is_removed() {
13299        let dir = tempfile::tempdir().expect("temp dir");
13300        let root = root_of(&dir);
13301        let repo = root.join("repo");
13302        init_repo_with_a_commit(&repo);
13303        let worktree = root.join("sidecar");
13304        crate::test_support::git(
13305            &repo,
13306            &[
13307                "worktree",
13308                "add",
13309                "-b",
13310                "sidecar",
13311                worktree.to_str().expect("utf8 path"),
13312            ],
13313        );
13314
13315        let core = Core::start_discovered(spec(vec![root]));
13316        let key = core
13317            .settle()
13318            .entities
13319            .into_iter()
13320            .find(|entity| entity.kind == Kind::Worktree)
13321            .expect("the Worktree row is discovered")
13322            .key;
13323
13324        let admin_dir = core.worktree_admin_dir(&key).expect("read the admin dir");
13325        fs::remove_dir_all(&admin_dir).expect("remove the admin dir by hand");
13326
13327        let reopened = git::open_thread_safe(&repo)
13328            .expect("reopen the repo")
13329            .to_thread_local();
13330        assert_eq!(
13331            git::linked_worktrees(&reopened).expect("count"),
13332            0,
13333            "removing the admin dir alone must be what git's own register stops naming"
13334        );
13335    }
13336
13337    /// A Worktree whose own path is not a git repository at all (the fixture for "the parent
13338    /// Repo is gone or unreadable"): the read errors rather than naming a directory that was
13339    /// never a Worktree's own administrative entry.
13340    #[test]
13341    fn worktree_admin_dir_errors_when_the_path_cannot_be_opened_as_a_repository() {
13342        let dir = tempfile::tempdir().expect("temp dir");
13343        let root = root_of(&dir);
13344        let not_a_repo = root.join("plain-directory");
13345        fs::create_dir_all(&not_a_repo).expect("create it");
13346
13347        let core = Core::start_discovered(spec(vec![root]));
13348        core.settle();
13349        let key = EntityKey::new(Arc::from(not_a_repo.as_path()));
13350
13351        assert!(core.worktree_admin_dir(&key).is_err());
13352    }
13353
13354    /// Every linked Worktree's own working directory, named by path rather than merely
13355    /// counted, for the Repo deletion cascade to remove.
13356    #[test]
13357    fn linked_worktree_paths_names_every_linked_worktrees_own_directory() {
13358        let dir = tempfile::tempdir().expect("temp dir");
13359        let root = root_of(&dir);
13360        let repo = root.join("repo");
13361        init_repo_with_a_commit(&repo);
13362        let first = root.join("first-worktree");
13363        let second = root.join("second-worktree");
13364        crate::test_support::git(
13365            &repo,
13366            &[
13367                "worktree",
13368                "add",
13369                "-b",
13370                "one",
13371                first.to_str().expect("utf8 path"),
13372            ],
13373        );
13374        crate::test_support::git(
13375            &repo,
13376            &[
13377                "worktree",
13378                "add",
13379                "-b",
13380                "two",
13381                second.to_str().expect("utf8 path"),
13382            ],
13383        );
13384
13385        let core = Core::start_discovered(spec(vec![root]));
13386        let key = core
13387            .settle()
13388            .entities
13389            .into_iter()
13390            .find(|entity| entity.kind == Kind::Repo)
13391            .expect("the Repo row is discovered")
13392            .key;
13393
13394        let mut paths = core
13395            .linked_worktree_paths(&key)
13396            .expect("read the linked worktree paths");
13397        paths.sort();
13398        let mut expected = vec![
13399            first.canonicalize().expect("canonicalize first"),
13400            second.canonicalize().expect("canonicalize second"),
13401        ];
13402        expected.sort();
13403
13404        assert_eq!(paths, expected);
13405    }
13406}