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    /// Start a periodic-fetch cycle now rather than on the next `fetch.interval` tick, which
393    /// is how the first discovery asks for the immediate cycle enabling the fetch owes
394    /// ([refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s "The
395    /// periodic fetch"). Sent rather than run there, so every cycle has the one owner. Asked
396    /// for once and never again, so the clock holds it until it can start it rather than
397    /// dropping it.
398    FetchOwed,
399    /// [`Core::fetch_now`]: a cycle the caller asked for. Refused rather than held while one
400    /// is already in flight, which is the whole difference from [`ClockControl::FetchOwed`]:
401    /// an obligation owed once must survive until it can be met, where a repeated gesture
402    /// against a fetch that is already running has nothing left to ask for.
403    FetchNow,
404}
405
406/// A running core: its own table, its own dedicated thread, and the rayon pool it
407/// shares with the rest of the process for probes.
408///
409/// Construction is `start`, never a plain constructor, because it spawns; `Drop`
410/// joins every thread it spawned. The public entry points are exactly `start`,
411/// `refresh`, `probe_now`, `snapshot`, `try_settle`, `dismiss`, `pause`, `resume`,
412/// `discovery_warning` and `run_action` (see its own doc comment).
413pub struct Core {
414    table: Arc<RwLock<Table>>,
415    /// Resolved once at `start` and never mutated afterwards: `default_branch` is a probe
416    /// input, so moving it needs the rediscovery a rebuilt `Core` does
417    /// ([config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md#reload)).
418    overrides: Arc<Vec<ResolvedOverride>>,
419    /// The live `exclude` half of the same `[[repo]]` entries, replaced wholesale by
420    /// [`Core::set_exclusions`] with no rebuild and no rediscovery, the same shape
421    /// `show_submodules` already has: `exclude` decides only whether an operation may reach
422    /// a row that is discovered and listed either way, so it is an operate-time filter over
423    /// a table that is already correct
424    /// ([repo-management.md](https://github.com/paulchiu/repon/blob/main/docs/spec/repo-management.md)'s
425    /// "Writing config").
426    exclusions: Arc<RwLock<Vec<ResolvedExclusion>>>,
427    /// The Set `start` was given, retained so `refresh` can re-run discovery over
428    /// the same bounding specification at the head of every Generation
429    /// ([discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md),
430    /// [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)).
431    /// Immutable for the same reason `overrides` is: a Set's `roots` or globs
432    /// changing is a config reload, which re-derives a whole new `Core` rather
433    /// than mutating this one in place.
434    set: SetSpec,
435    /// Set once discovery abandons a walk, and never cleared for the life of this
436    /// `Core`: it takes the Set out of the automatic refresh path, since
437    /// re-running a thirty-second walk at the head of every Generation is not a
438    /// degraded mode worth paying for.
439    discovery_manual: Arc<AtomicBool>,
440    /// How long a re-run discovery walk may run before the still-walking warning
441    /// fires; real value is one second outside a test.
442    discovery_warn_after: Duration,
443    /// How long a re-run discovery walk may run before it is abandoned, in nanoseconds;
444    /// real value is [`discovery::ABANDON_AFTER`] outside a test. Shared and atomic so a
445    /// test can tighten it after `start`, rather than racing one deadline against both a
446    /// walk that must survive and a walk that must not.
447    discovery_abandon_after: Arc<AtomicU64>,
448    /// The live show-submodules preference a dispatched Generation reads: `true` once
449    /// [`Core::set_show_submodules`] last set it that way, `CoreSpec::show_submodules` until
450    /// then. Atomic and shared with every `RefreshHandles` clone so toggling it needs no
451    /// rebuild and dispatches nothing of its own
452    /// ([discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md)'s
453    /// "Showing Submodules": "toggling is instant, because nothing needs discovering").
454    show_submodules: Arc<AtomicBool>,
455    settle_gate: Arc<SettleGate>,
456    control: Sender<ClockControl>,
457    clock_thread: Option<JoinHandle<()>>,
458    /// Set by the dedicated thread's discovery-slow watcher if `start`'s one walk
459    /// ran a full second without finishing, and by a later re-run's own abandon
460    /// path. Read through [`Core::discovery_warning`], the UI's shared warning
461    /// slot's one entry point onto discovery, per
462    /// [discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md).
463    discovery_warning: Arc<Mutex<Option<String>>>,
464    /// Reset to zero at the start of every `refresh`, then incremented once per
465    /// distinct common dir among that Generation's dispatched entities whose
466    /// default-branch chain facts are actually computed, as opposed to reused from
467    /// another entity sharing the same common dir. Never persisted across
468    /// Generations, per [ADR 0006](https://github.com/paulchiu/repon/blob/main/docs/adr/0006-no-git-state-cache-session-state-by-name.md):
469    /// the memo cache itself lives only for the lifetime of one `refresh` call.
470    /// Read only by `default_branch_chain_reads_for_test`, which is what proves
471    /// [default-branch.md](https://github.com/paulchiu/repon/blob/main/docs/spec/default-branch.md)'s
472    /// per-common-dir memoisation actually ran rather than merely agreeing by
473    /// coincidence.
474    #[allow(dead_code)] // read only by default_branch_chain_reads_for_test
475    default_branch_chain_reads: Arc<AtomicUsize>,
476    /// The same counter as `default_branch_chain_reads`, for patch equivalence's
477    /// own expensive half ([`patch_equivalence::scan_default_branch`]) instead of
478    /// the default-branch chain's: reset to zero at the start of every `refresh`,
479    /// incremented once per distinct common dir whose default-branch commit
480    /// history is actually scanned, as opposed to reused from another entity
481    /// sharing the same common dir this Generation. Never persisted across
482    /// Generations, per [ADR 0006](https://github.com/paulchiu/repon/blob/main/docs/adr/0006-no-git-state-cache-session-state-by-name.md).
483    /// Read only by `patch_identity_reads_for_test`.
484    #[allow(dead_code)] // read only by patch_identity_reads_for_test
485    patch_identity_reads: Arc<AtomicUsize>,
486    /// The bound each actually-run [`patch_equivalence::scan_default_branch`] call
487    /// this Generation was passed, one entry per common dir it ran for, in the
488    /// order those scans ran; cleared at the start of every `refresh`. Recorded
489    /// from inside `patch_identities_for`'s `compute` closure, so this is the value
490    /// the production call site used, not a value a test recomputes independently.
491    /// Read only by `patch_scan_bounds_for_test`.
492    #[allow(dead_code)] // read only by patch_scan_bounds_for_test
493    patch_scan_bounds: Arc<Mutex<Vec<Option<gix::ObjectId>>>>,
494    /// Which Action run is admitted and what reaches its steps' children: see
495    /// [`ActionLifecycle`]. What [`Core::run_action`]'s own entry is guarded by, what
496    /// [`Core::action_running`] reads, and where [`Core::hold_action`],
497    /// [`Core::continue_action`] and [`Core::stop_action`] each find the control they
498    /// signal.
499    action_lifecycle: Arc<Mutex<ActionLifecycle>>,
500    /// Every key `refresh`'s own sequential dispatch loop iterated, in the order it iterated
501    /// them, cleared at the start of every call: this is dispatch order, not completion
502    /// order, recorded synchronously in the loop that decides it, before any `rayon::spawn`
503    /// closure ever runs. [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
504    /// "Scope and order" fixes dispatch order as the one dial phase C has; completion order
505    /// on a concurrent pool is a different, non-deterministic fact this field does not claim
506    /// to answer. Read only by `dispatch_log_for_test`.
507    #[allow(dead_code)] // read only by dispatch_log_for_test
508    dispatch_log: Arc<Mutex<Vec<EntityKey>>>,
509    /// Test-only synchronisation points, keyed by entity, letting a test hold the
510    /// dispatch loop's state and dirty probes open after that same entity's cheap
511    /// outcomes (branch, sync, default branch) have already landed on the table,
512    /// so [`refresh`]'s two applies can be proven independent with a blocking wait
513    /// rather than a sleep. Always present and normally empty: a Generation reads it
514    /// once per entity as it dispatches that entity, and one never registered here
515    /// resolves to nothing and proceeds exactly as if this field did not exist.
516    /// Registered and read only by the `_for_test` methods below.
517    #[allow(dead_code)] // populated and read only by tests
518    phase_c_gates: Arc<Mutex<HashMap<EntityKey, PhaseCGateHandle>>>,
519    /// The age past which a `Known` `dirty` or `state` cell reads Stale even though
520    /// nothing probed it again: `CoreSpec::status_stale_after`'s own copy, applied
521    /// inside [`Core::snapshot`] rather than by a background sweep, since
522    /// [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
523    /// "Staleness" rules out a global clock-driven one.
524    status_stale_after: Duration,
525    /// Every key the metadata poll's most recent sweep actually re-ran phases A
526    /// and B for, in the order it found them moved, cleared at the start of every
527    /// sweep. Read only by `poll_reprobed_for_test`, which is what proves a
528    /// sweep re-probes the moved entity alone rather than the whole population.
529    #[allow(dead_code)] // read only by poll_reprobed_for_test
530    poll_reprobed: Arc<Mutex<Vec<EntityKey>>>,
531    /// How many metadata-poll sweeps have run in total, whether or not any entity
532    /// had moved. Read only by `poll_sweep_count_for_test`, which is what proves a
533    /// real tick sent through the dedicated thread's own channel reaches the
534    /// sweep at all, distinct from `poll_reprobed` proving what a sweep that found
535    /// movement then did.
536    #[allow(dead_code)] // read only by poll_sweep_count_for_test
537    poll_sweep_count: Arc<AtomicUsize>,
538    /// How many periodic-fetch cycles have run in total, whether or not any
539    /// repository had a remote to fetch: the immediate first cycle plus one per
540    /// `fetch.interval` tick since. Read only by `fetch_cycle_count_for_test`,
541    /// which is what proves the immediate cycle ran without waiting on the
542    /// recurring cadence at all.
543    #[allow(dead_code)] // read only by fetch_cycle_count_for_test
544    fetch_cycle_count: Arc<AtomicUsize>,
545    /// The network's advertised default branch, per common dir, read from a fetch
546    /// handshake's own advertised HEAD alone
547    /// ([default-branch.md](https://github.com/paulchiu/repon/blob/main/docs/spec/default-branch.md)'s
548    /// "The network"): present only once the periodic fetch or
549    /// [`Core::rederive_default_branches`] has actually reached that remote.
550    /// Superseded there, never here on read; consulted by every default-branch
551    /// probe this crate runs, so an answer landed by one persists across every
552    /// later Generation for the life of this `Core`, which is what "supersedes
553    /// the local one for that session" means: never written back to any
554    /// reference, and gone the moment this `Core` is dropped, per ADR 0012.
555    network_default_branch: Arc<Mutex<HashMap<PathBuf, Arc<str>>>>,
556    /// The most recently completed periodic-fetch cycle's own failures, replaced
557    /// wholesale by [`run_fetch_cycle`] every time it runs. Read through
558    /// [`Core::fetch_failures`].
559    fetch_failures: Arc<Mutex<FetchFailures>>,
560    /// Whether a fetch cycle is in flight right now: set as the clock starts one and
561    /// cleared as it takes it back, so the one thread that owns a cycle is the only writer.
562    /// Read through [`Core::fetch_running`] the same way `settle_gate` is read through
563    /// [`Core::refresh_running`], for the status row's own rank 3.
564    fetch_running: Arc<AtomicBool>,
565    /// Orders every spawned dispatch body this `Core` starts; see
566    /// [`DispatchTurnstile`].
567    turnstile: Arc<DispatchTurnstile>,
568    /// See [`DiscoveryGate`]. `None` on every production path.
569    discovery_gate: Option<DiscoveryGate>,
570    /// See [`ActionCompletionBoundary`]. Disarmed unless a test arms it, and off the
571    /// default build entirely.
572    #[cfg(test)]
573    action_completion_boundary: Arc<ActionCompletionBoundary>,
574    /// See [`FetchBoundary`]. Disarmed unless a test arms it, and off the default build
575    /// entirely.
576    #[cfg(test)]
577    fetch_boundary: Arc<FetchBoundary>,
578}
579
580/// One entity's phase C test gate state, guarded by the paired [`Condvar`] stored
581/// alongside it in [`Core::phase_c_gates`].
582#[derive(Default)]
583struct PhaseCGate {
584    /// Set once this entity's cheap outcomes have been applied to the table.
585    cheap_landed: bool,
586    /// Set by a test once it has observed `cheap_landed` and wants phase C (and
587    /// D) to proceed.
588    may_proceed: bool,
589    /// Set once this entity's phase C/D outcomes have been applied to the table
590    /// and the settle gate decremented for it.
591    finished: bool,
592}
593
594/// A [`PhaseCGate`] shared between the dispatch loop and the `_for_test` methods
595/// that register, wait on and release it.
596type PhaseCGateHandle = Arc<(Mutex<PhaseCGate>, Condvar)>;
597
598/// The Action lifecycle's one owned value: the run admitted right now, if any, together
599/// with the reach into that run's steps' children.
600///
601/// Admission and completion are each one transition on this one value, so a run's control
602/// arrives and leaves with its admission rather than through a second write a later run can
603/// land between. Every critical section here is a read or a single field write, so the lock
604/// is never held across a wait on a child process, across git, or across anything that can
605/// panic and poison it.
606#[derive(Default)]
607struct ActionLifecycle {
608    /// The admitted run's own reach into its steps' children, `None` between runs: what
609    /// [`Core::hold_action`], [`Core::continue_action`] and [`Core::stop_action`] each look
610    /// up before doing anything, so all three are no-ops with no fan-out live. Deliberately
611    /// its own value rather than folded into `pause`/`resume`'s machinery, per
612    /// [ADR 0018](https://github.com/paulchiu/repon/blob/main/docs/adr/0018-an-action-is-a-fanout-of-pty-backed-steps.md)'s
613    /// own hold and stop verbs: the core is contractually not told why background work
614    /// stopped, and a step's child needs SIGSTOP/SIGTERM/SIGKILL, information `pause` must
615    /// never carry.
616    live: Option<Arc<executor::RunControl>>,
617}
618
619impl ActionLifecycle {
620    /// Admits a run and registers its control together, or refuses because one is already
621    /// live: only one fan-out runs at a time, per
622    /// [ADR 0018](https://github.com/paulchiu/repon/blob/main/docs/adr/0018-an-action-is-a-fanout-of-pty-backed-steps.md)'s
623    /// "One Action runs at a time".
624    fn admit(&mut self, control: Arc<executor::RunControl>) -> bool {
625        if self.live.is_some() {
626            return false;
627        }
628        self.live = Some(control);
629        true
630    }
631
632    /// Releases the admitted run, the last thing [`RunCompletion`] does.
633    fn complete(&mut self) {
634        self.live = None;
635    }
636}
637
638/// Releases a finished run's admission however its fan-out thread ends, so a panic past the
639/// fan-out can never leave a `Core` reading that run as live for the rest of its life.
640///
641/// Dropped after the completion Generation has been dispatched, which is what orders the
642/// two: the next run is refused until this one has started the Generation it owes, so what
643/// that run cancels on the way in can never be a Generation the run it replaced has yet to
644/// dispatch.
645struct RunCompletion {
646    lifecycle: Arc<Mutex<ActionLifecycle>>,
647    /// See [`ActionCompletionBoundary`].
648    #[cfg(test)]
649    boundary: Arc<ActionCompletionBoundary>,
650}
651
652impl Drop for RunCompletion {
653    fn drop(&mut self) {
654        // Nothing parks here unless a test armed this boundary.
655        #[cfg(test)]
656        self.boundary.hold();
657        self.lifecycle.lock().unwrap().complete();
658    }
659}
660
661/// A park in the one statement between a completion dispatching its Generation and
662/// [`RunCompletion`] releasing the run, for a test.
663///
664/// Neither half of that ordering is observable from outside without holding the completion
665/// there: the two are adjacent statements, and a test racing them reads whichever it
666/// happened to catch. One per `Core` and disarmed until a test arms it, so a run nobody is
667/// watching reads one bool and carries on, and the whole affordance is gated off the
668/// default build.
669#[cfg(test)]
670#[derive(Default)]
671pub(crate) struct ActionCompletionBoundary {
672    state: Mutex<BoundaryState>,
673    changed: Condvar,
674}
675
676/// [`ActionCompletionBoundary`]'s own state, guarded by its `Condvar`.
677#[cfg(test)]
678#[derive(Default)]
679struct BoundaryState {
680    /// Set by a test before the run whose completion it wants held.
681    armed: bool,
682    /// Set by the completion that parked at an armed boundary.
683    reached: bool,
684    /// Set when the [`ArmedBoundary`] drops.
685    released: bool,
686}
687
688#[cfg(test)]
689impl ActionCompletionBoundary {
690    /// Holds the next completion to reach this boundary until the returned value drops. For
691    /// a test, before the run whose completion it wants held.
692    pub(crate) fn arm(self: &Arc<Self>) -> ArmedBoundary {
693        self.state.lock().unwrap().armed = true;
694        ArmedBoundary(Arc::clone(self))
695    }
696
697    /// Parks a completion here while an armed boundary holds it.
698    fn hold(&self) {
699        let mut state = self.state.lock().unwrap();
700        if !state.armed {
701            return;
702        }
703        state.reached = true;
704        self.changed.notify_all();
705        let (state, expiry) = self
706            .changed
707            .wait_timeout_while(state, liveness::BACKSTOP, |state| !state.released)
708            .unwrap();
709        drop(state);
710        if expiry.timed_out() {
711            liveness::expired(
712                liveness::BACKSTOP,
713                "a test to release the Action completion boundary",
714                "",
715            );
716        }
717    }
718}
719
720/// One armed [`ActionCompletionBoundary`], released when this drops so an assertion failing
721/// inside the window reports itself rather than leaving a completion parked for
722/// [`liveness::BACKSTOP`].
723#[cfg(test)]
724pub(crate) struct ArmedBoundary(Arc<ActionCompletionBoundary>);
725
726#[cfg(test)]
727impl ArmedBoundary {
728    /// Blocks until a completion has parked at this boundary. For a test.
729    pub(crate) fn wait_until_reached(&self) {
730        let (state, expiry) = self
731            .0
732            .changed
733            .wait_timeout_while(self.0.state.lock().unwrap(), liveness::BACKSTOP, |state| {
734                !state.reached
735            })
736            .unwrap();
737        drop(state);
738        if expiry.timed_out() {
739            liveness::expired(
740                liveness::BACKSTOP,
741                "a completion to reach the Action completion boundary",
742                "",
743            );
744        }
745    }
746}
747
748#[cfg(test)]
749impl Drop for ArmedBoundary {
750    fn drop(&mut self) {
751        let mut state = self.0.state.lock().unwrap();
752        state.released = true;
753        self.0.changed.notify_all();
754    }
755}
756
757/// A park inside one periodic-fetch cycle's own per-repository work, for a test.
758///
759/// A fetch against a real remote finishes when the remote says so, which is no moment a test
760/// can hold anything at. This is that moment: the cycle signals it has entered a fetch and
761/// stays there until the test that armed this lets it go, so the clock's own tick, pause and
762/// shutdown handling can be observed against a fetch that provably has not finished. A
763/// cancellation is recorded here rather than acted on, which is the bound
764/// [`crate::fetch::fetch_and_prune`] documents for a real fetch before its receive stage.
765/// One per `Core` and disarmed until a test arms it, so a cycle nobody is watching reads one
766/// bool and carries on, and the whole affordance is gated off the default build.
767#[cfg(test)]
768#[derive(Default)]
769pub(crate) struct FetchBoundary {
770    state: Mutex<FetchBoundaryState>,
771    changed: Condvar,
772}
773
774/// [`FetchBoundary`]'s own state, guarded by its `Condvar`.
775#[cfg(test)]
776#[derive(Default)]
777struct FetchBoundaryState {
778    /// Set by a test before the cycle it wants held.
779    armed: bool,
780    /// Set by the first fetch that parked at an armed boundary.
781    reached: bool,
782    /// Set when the [`ArmedFetchBoundary`] drops.
783    released: bool,
784    /// Set when the cycle holding a fetch here is cancelled.
785    cancelled: bool,
786}
787
788#[cfg(test)]
789impl FetchBoundary {
790    /// Holds every fetch that reaches this boundary until the returned value drops. For a
791    /// test, before the cycle it wants held. Every flag resets here, so a second armed cycle
792    /// on the same `Core` parks rather than walking through what the first one left set.
793    pub(crate) fn arm(self: &Arc<Self>) -> ArmedFetchBoundary {
794        *self.state.lock().unwrap() = FetchBoundaryState {
795            armed: true,
796            ..FetchBoundaryState::default()
797        };
798        ArmedFetchBoundary(Arc::clone(self))
799    }
800
801    /// Parks a fetch here until the test that armed this lets it go.
802    ///
803    /// No deadline of its own, deliberately: a clock too wedged to reach the release would
804    /// otherwise be let through by a timeout here, and the test that was watching it would
805    /// pass a couple of minutes late rather than fail.
806    fn hold(&self) {
807        let mut state = self.state.lock().unwrap();
808        if !state.armed {
809            return;
810        }
811        state.reached = true;
812        self.changed.notify_all();
813        drop(
814            self.changed
815                .wait_while(state, |state| !state.released)
816                .unwrap(),
817        );
818    }
819
820    /// Records that the cycle holding a fetch here was cancelled. Evidence for the test
821    /// rather than a release, since a real fetch before its receive stage reads no flag.
822    fn cancelled(&self) {
823        self.state.lock().unwrap().cancelled = true;
824        self.changed.notify_all();
825    }
826}
827
828/// One armed [`FetchBoundary`], released when this drops so an assertion failing inside the
829/// window reports itself rather than leaving a fetch parked for the rest of the run.
830#[cfg(test)]
831pub(crate) struct ArmedFetchBoundary(Arc<FetchBoundary>);
832
833#[cfg(test)]
834impl ArmedFetchBoundary {
835    /// Blocks until a fetch has parked at this boundary. For a test.
836    pub(crate) fn wait_until_reached(&self) {
837        self.wait_until("a fetch to reach the fetch boundary", |state| state.reached);
838    }
839
840    /// Blocks until the cycle whose fetch is parked here has been cancelled. For a test.
841    pub(crate) fn wait_until_cancelled(&self) {
842        self.wait_until("the held cycle's own cancellation", |state| state.cancelled);
843    }
844
845    fn wait_until(&self, property: &str, held: impl Fn(&FetchBoundaryState) -> bool) {
846        let (state, expiry) = self
847            .0
848            .changed
849            .wait_timeout_while(self.0.state.lock().unwrap(), liveness::BACKSTOP, |state| {
850                !held(state)
851            })
852            .unwrap();
853        drop(state);
854        if expiry.timed_out() {
855            liveness::expired(liveness::BACKSTOP, property, "");
856        }
857    }
858}
859
860#[cfg(test)]
861impl Drop for ArmedFetchBoundary {
862    fn drop(&mut self) {
863        let mut state = self.0.state.lock().unwrap();
864        state.released = true;
865        self.0.changed.notify_all();
866    }
867}
868
869impl Core {
870    /// Spawns the dedicated thread, starts the first discovery walk on a thread of
871    /// its own, and returns a running core at once.
872    ///
873    /// The table it returns is empty: discovery lands its rows afterwards, which is what
874    /// lets a consumer claim the terminal and draw a first frame without waiting out a
875    /// walk (refresh.md's "The first frame"). That walk is refresh.md's "Startup"
876    /// Generation as well, dispatched over what it found, so a consumer probes its rows
877    /// by starting a `Core` and never by asking for a second walk of the same tree.
878    /// [`Self::try_settle`] waits for it the way it waits for any other Generation.
879    pub fn start(spec: CoreSpec) -> Core {
880        Self::start_watched(spec).core
881    }
882
883    /// [`Self::start`], keeping the handles `start_internal` hands back.
884    fn start_watched(spec: CoreSpec) -> StartForTest {
885        let interval = spec.poll_interval.max(Duration::from_nanos(1));
886        let ticks = crossbeam_channel::tick(interval);
887        let alive = Arc::new(AtomicBool::new(true));
888        let fetch_start = FetchStart {
889            enabled: spec.fetch.enabled,
890            concurrency: spec.fetch.concurrency.max(1),
891            ticks: if spec.fetch.enabled {
892                crossbeam_channel::tick(spec.fetch.interval.max(Duration::from_nanos(1)))
893            } else {
894                crossbeam_channel::never()
895            },
896        };
897        start_internal(
898            spec,
899            Duration::from_secs(1),
900            discovery::ABANDON_AFTER,
901            ticks,
902            fetch_start,
903            alive,
904            None,
905        )
906    }
907
908    /// [`Self::start`], blocked until the first discovery has landed on the table.
909    ///
910    /// For a test, and for nothing else: `start` returns against an empty table
911    /// now, so a test that reads the table straight afterwards needs this
912    /// rendezvous. It is a join on the discovery thread rather than a poll or a
913    /// sleep, so it carries no deadline of its own.
914    ///
915    /// Gated behind `test-util` (on by default under `cfg(test)` for this crate's own
916    /// tests) so a test-only affordance never ships on the default published surface,
917    /// per [ADR 0021](https://github.com/paulchiu/repon/blob/main/docs/adr/0021-a-release-is-what-the-tag-pipeline-publishes.md).
918    #[cfg(any(test, feature = "test-util"))]
919    pub fn start_discovered(spec: CoreSpec) -> Core {
920        let mut started = Self::start_watched(spec);
921        if let Some(handle) = started.initial_discovery.take() {
922            handle
923                .join()
924                .expect("the first discovery thread should not panic");
925        }
926        started.core
927    }
928
929    /// Starts a new Generation, dispatching a probe for every key in `order` that
930    /// the table already knows, in that order. An empty or unknown-only `order`
931    /// dispatches nothing and carries no other meaning. Returns immediately: the
932    /// probes run on rayon's global pool.
933    pub fn refresh(&self, order: &[EntityKey]) -> Generation {
934        self.refresh_handles().dispatch(order)
935    }
936
937    /// Starts a new Generation over every entity this Generation's own discovery
938    /// leaves in the table, in discovery order.
939    ///
940    /// A Set switch's Generation, per
941    /// [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
942    /// "Switching Set": the caller has just discarded the old Set's rows, so it has no
943    /// order to compute and no keys to name. Unlike [`Self::refresh`], which resolves
944    /// the order the caller handed it, this resolves the order after discovery has run,
945    /// which is what lets it cover rows the caller could not have named. Startup needs
946    /// none of this: [`Self::start`]'s own walk is that Generation. Returns
947    /// immediately, the same way `refresh` does.
948    pub fn refresh_all(&self) -> Generation {
949        self.refresh_handles().dispatch_over_everything()
950    }
951
952    /// Re-derives `default_branch` alone for every key in `keys` already known to
953    /// the table, in a fresh Generation, per
954    /// [default-branch.md](https://github.com/paulchiu/repon/blob/main/docs/spec/default-branch.md)'s
955    /// "A user-triggered re-derive over the Selection ... on demand" and
956    /// [keybindings.md](https://github.com/paulchiu/repon/blob/main/docs/spec/keybindings.md)'s
957    /// `b`. Unlike [`Self::refresh`], this never re-runs discovery and never
958    /// touches any other cell on any entity, known or not: a key outside `keys`
959    /// is left exactly as it was, and so is every cell but `default_branch` on a
960    /// key inside it.
961    ///
962    /// Runs the local chain exactly as any other refresh would, then a
963    /// handshake-only network probe per distinct common dir among `keys`
964    /// (`fetch::probe_remote_head`): no pack requested and no ref updated, which is
965    /// "without fetching". Its answer, once landed on `network_default_branch`, is
966    /// what `supersede_with_network` applies here and on every later probe of that
967    /// common dir for the life of this `Core`.
968    ///
969    /// Returns immediately, which is also why a stalled remote has nothing to end
970    /// it here: the deadline sweep is per entity, not per cell, so this is on the
971    /// open-questions register rather than closed. The probes run on a plain thread, never rayon's
972    /// global pool, for the reason `fetch::run_bounded`'s own doc comment gives
973    /// the periodic fetch's identical choice: a remote blocked on the network
974    /// for seconds must never take a worker away from the pool every other
975    /// probe shares.
976    pub fn rederive_default_branches(&self, keys: &[EntityKey]) -> Generation {
977        let generation = {
978            let mut table = self.table.write().unwrap();
979            table.generation += 1;
980            Generation::new(table.generation)
981        };
982
983        let dispatched: Vec<RederiveCandidate> = {
984            let mut table = self.table.write().unwrap();
985            let mut dispatched = Vec::new();
986            for key in keys {
987                let Some(&idx) = table.index.get(key) else {
988                    continue;
989                };
990                table.entities[idx].default_branch.begin_probe();
991                let common_dir = Arc::clone(&table.entities[idx].common_dir);
992                let override_branch = find_entry(&self.overrides, key.path(), &common_dir)
993                    .and_then(|entry| entry.default_branch.clone());
994                let repo = table.repos.get(key).cloned();
995                let kind = table.entities[idx].kind;
996                dispatched.push(RederiveCandidate {
997                    key: key.clone(),
998                    path: key.path().to_path_buf(),
999                    common_dir,
1000                    repo,
1001                    override_branch,
1002                    kind,
1003                });
1004            }
1005            dispatched
1006        };
1007
1008        if dispatched.is_empty() {
1009            return generation;
1010        }
1011
1012        begin_probes_owed(&self.settle_gate, dispatched.len());
1013
1014        let table = Arc::clone(&self.table);
1015        let settle_gate = Arc::clone(&self.settle_gate);
1016        let network_default_branch = Arc::clone(&self.network_default_branch);
1017        thread::spawn(move || {
1018            let common_dirs: HashSet<Arc<Path>> = dispatched
1019                .iter()
1020                .map(|candidate| Arc::clone(&candidate.common_dir))
1021                .collect();
1022            probe_network_default_branches(&common_dirs, &network_default_branch);
1023
1024            // Scoped to this one call, never shared with a concurrent `refresh`'s own
1025            // memo: the local chain's own per-common-dir facts are cheap enough
1026            // (`default-branch.md`'s "about 20ms") that a fresh cache here costs this
1027            // call nothing a shared one would have saved.
1028            let chain_cache: ChainFactsCache = Mutex::new(HashMap::new());
1029            let chain_reads = AtomicUsize::new(0);
1030            let never_cancelled = AtomicBool::new(false);
1031
1032            for candidate in dispatched {
1033                let RederiveCandidate {
1034                    key,
1035                    path,
1036                    common_dir,
1037                    repo,
1038                    override_branch,
1039                    kind,
1040                } = candidate;
1041                let network_branch = network_branch_for(&network_default_branch, &common_dir);
1042                let resolution = probe_default_branch_memoised(
1043                    &path,
1044                    repo.as_deref(),
1045                    &common_dir,
1046                    DefaultBranchHints {
1047                        override_branch: override_branch.as_deref(),
1048                        network_branch: network_branch.as_deref(),
1049                    },
1050                    kind,
1051                    &never_cancelled,
1052                    &ChainFactsMemo {
1053                        cache: &chain_cache,
1054                        reads: &chain_reads,
1055                    },
1056                );
1057                {
1058                    let mut table = table.write().unwrap();
1059                    if let (Some(&idx), Some(resolution)) = (table.index.get(&key), resolution) {
1060                        table.entities[idx].apply_default_branch_resolution(generation, resolution);
1061                    }
1062                }
1063                complete_one(&settle_gate);
1064            }
1065        });
1066
1067        generation
1068    }
1069
1070    /// Clones out every `Arc` a Generation's dispatch reads, plus the plain data
1071    /// ([`SetSpec`], the two durations) it cannot share by reference: a handful of
1072    /// refcount bumps, never a copy of the table itself. This is what lets
1073    /// [`run_action`](Core::run_action)'s completion, which runs on a plain thread
1074    /// this `Core` does not own and outlives the `&self` borrow that started it,
1075    /// start the one normal Generation `docs/spec/actions.md`'s "Refreshing around a
1076    /// run" promises through the exact same [`RefreshHandles::dispatch`] `refresh`
1077    /// itself calls, rather than a second, drifting copy of its body.
1078    fn refresh_handles(&self) -> RefreshHandles {
1079        RefreshHandles {
1080            table: Arc::clone(&self.table),
1081            overrides: Arc::clone(&self.overrides),
1082            exclusions: Arc::clone(&self.exclusions),
1083            set: self.set.clone(),
1084            discovery_manual: Arc::clone(&self.discovery_manual),
1085            discovery_warn_after: self.discovery_warn_after,
1086            discovery_abandon_after: Arc::clone(&self.discovery_abandon_after),
1087            discovery_warning: Arc::clone(&self.discovery_warning),
1088            show_submodules: Arc::clone(&self.show_submodules),
1089            settle_gate: Arc::clone(&self.settle_gate),
1090            default_branch_chain_reads: Arc::clone(&self.default_branch_chain_reads),
1091            patch_identity_reads: Arc::clone(&self.patch_identity_reads),
1092            patch_scan_bounds: Arc::clone(&self.patch_scan_bounds),
1093            dispatch_log: Arc::clone(&self.dispatch_log),
1094            phase_c_gates: Arc::clone(&self.phase_c_gates),
1095            network_default_branch: Arc::clone(&self.network_default_branch),
1096            turnstile: Arc::clone(&self.turnstile),
1097            discovery_gate: self.discovery_gate.clone(),
1098        }
1099    }
1100
1101    /// Re-probes one entity synchronously against the table's current Generation,
1102    /// which is what a Launcher return needs before a normal Generation starts.
1103    /// Inserts a fresh entity for an unknown key rather than panicking, since a
1104    /// caller can otherwise only reach this with a key `snapshot` just handed it.
1105    pub fn probe_now(&self, key: &EntityKey) -> EntityState {
1106        // An `Arc` rather than a bare flag: [`probe_status`] hands gix an owned clone of
1107        // its cancel token the way `refresh`'s own dispatch does, and every other probe
1108        // below still takes it as `&AtomicBool` through the same deref coercion.
1109        let never_cancelled = Arc::new(AtomicBool::new(false));
1110        let (cached_repo, common_dir_hint, probes_state, probes_base, kind) = {
1111            let table = self.table.read().unwrap();
1112            let repo = table.repos.get(key).cloned();
1113            let common_dir = table
1114                .index
1115                .get(key)
1116                .map(|&idx| Arc::clone(&table.entities[idx].common_dir));
1117            // An unknown key has no entity yet to ask, and falls back to `false`,
1118            // matching the fallback insert below: a freshly inserted `Kind::Repo`
1119            // entity's `state` is `NotApplicable` from construction too, and its
1120            // `base` is not (only a Submodule's is).
1121            let probes_state = table
1122                .index
1123                .get(key)
1124                .map(|&idx| table.entities[idx].probes_state())
1125                .unwrap_or(false);
1126            let probes_base = table
1127                .index
1128                .get(key)
1129                .map(|&idx| table.entities[idx].probes_base())
1130                .unwrap_or(true);
1131            // Same fallback as `probes_state`/`probes_base`: an unknown key falls back to
1132            // the `Kind::Repo` the insert below actually gives it.
1133            let kind = table
1134                .index
1135                .get(key)
1136                .map(|&idx| table.entities[idx].kind)
1137                .unwrap_or(Kind::Repo);
1138            (repo, common_dir, probes_state, probes_base, kind)
1139        };
1140        let common_dir_hint = common_dir_hint.unwrap_or_else(|| Arc::from(key.path().join(".git")));
1141        let override_branch = find_entry(&self.overrides, key.path(), &common_dir_hint)
1142            .and_then(|entry| entry.default_branch.clone());
1143        let excluded = excluded_by(
1144            &self.exclusions.read().unwrap(),
1145            key.path(),
1146            &common_dir_hint,
1147        );
1148
1149        let branch_outcome =
1150            probe_branch(key.path(), cached_repo.as_deref(), kind, &never_cancelled);
1151        let sync_outcome = probe_sync(
1152            key.path(),
1153            cached_repo.as_deref(),
1154            branch_outcome.as_ref().map(|(settled, ..)| settled),
1155            kind,
1156            &never_cancelled,
1157        );
1158        let default_branch_outcome = probe_default_branch(
1159            key.path(),
1160            cached_repo.as_deref(),
1161            DefaultBranchHints {
1162                override_branch: override_branch.as_deref(),
1163                network_branch: network_branch_for(&self.network_default_branch, &common_dir_hint)
1164                    .as_deref(),
1165            },
1166            kind,
1167            &never_cancelled,
1168        );
1169        let base_outcome = if probes_base {
1170            probe_base(
1171                key.path(),
1172                cached_repo.as_deref(),
1173                branch_outcome.as_ref().map(|(settled, ..)| settled),
1174                default_branch_outcome.as_ref().map(|r| &r.settled),
1175                &never_cancelled,
1176            )
1177        } else {
1178            None
1179        };
1180        let state_outcome = if probes_state {
1181            // A single synchronous re-probe shares nothing with any Generation's
1182            // dispatch, so a throwaway cache is exactly as much sharing as this
1183            // one call needs. Its bound gate has exactly one entity to hear
1184            // from: itself, so it never actually waits.
1185            let patch_cache: PatchIdentityCache = Mutex::new(HashMap::new());
1186            let patch_reads = AtomicUsize::new(0);
1187            let patch_scan_bounds = Mutex::new(Vec::new());
1188            let gate = BoundGate::new(1);
1189            let mut report = GateReport::new(&gate);
1190            let memo = PatchEquivalenceMemo {
1191                cache: &patch_cache,
1192                reads: &patch_reads,
1193                scan_bounds: &patch_scan_bounds,
1194            };
1195            probe_worktree_state(
1196                key.path(),
1197                cached_repo.as_deref(),
1198                default_branch_outcome.as_ref().map(|r| &r.settled),
1199                &common_dir_hint,
1200                &never_cancelled,
1201                &memo,
1202                &mut report,
1203            )
1204        } else {
1205            None
1206        };
1207        let dirty_outcome =
1208            probe_status(key.path(), cached_repo.as_deref(), kind, &never_cancelled);
1209
1210        let mut table = self.table.write().unwrap();
1211        let generation = Generation::new(table.generation);
1212        let idx = match table.index.get(key).copied() {
1213            Some(idx) => idx,
1214            None => {
1215                let name = display_name(key.path());
1216                table.entities.push(EntityState::new(
1217                    key.clone(),
1218                    name,
1219                    common_dir_hint,
1220                    Kind::Repo,
1221                ));
1222                let idx = table.entities.len() - 1;
1223                table.index.insert(key.clone(), idx);
1224                idx
1225            }
1226        };
1227        table.entities[idx].excluded = excluded;
1228        if let Some((settled, in_progress, recent)) = branch_outcome {
1229            table.entities[idx].apply_branch_probe(generation, settled, in_progress, recent);
1230        }
1231        if let Some(settled) = sync_outcome {
1232            table.entities[idx].sync.settle(generation, settled);
1233        }
1234        if let Some(settled) = base_outcome {
1235            table.entities[idx].base.settle(generation, settled);
1236        }
1237        if let Some(resolution) = default_branch_outcome {
1238            table.entities[idx].apply_default_branch_resolution(generation, resolution);
1239        }
1240        if let Some(settled) = state_outcome {
1241            table.entities[idx].state.settle(generation, settled);
1242        }
1243        if let Some(settled) = dirty_outcome {
1244            table.entities[idx].dirty.settle(generation, settled);
1245        }
1246        table.entities[idx].clone()
1247    }
1248
1249    /// Clones the whole table now, without waiting for anything in flight. Ages
1250    /// every entity's `dirty` and `state` cells into Stale here, on the clone
1251    /// rather than the stored table, so a snapshot stays a pure read: the other
1252    /// staleness writer, poll evidence, does mutate the stored table, because a
1253    /// detected move is itself a fact worth keeping, but elapsed time is not.
1254    pub fn snapshot(&self) -> Snapshot {
1255        let table = self.table.read().unwrap();
1256        let mut entities = table.entities.clone();
1257        for entity in &mut entities {
1258            entity.age_status_cells(self.status_stale_after);
1259        }
1260        Snapshot {
1261            generation: Generation::new(table.generation),
1262            discovered_at: table.discovered_at,
1263            entities,
1264        }
1265    }
1266
1267    /// Blocks until nothing is in flight or `within` elapses, then returns a snapshot.
1268    /// The machine-readable consumer's whole loop.
1269    ///
1270    /// `Ok` is a table that actually settled. `Err` is the wait giving up, carrying the
1271    /// snapshot as it stood at that moment so a caller that means to degrade still has
1272    /// something to degrade with. The two are separate arms rather than one return value
1273    /// because they are separate facts: a half-populated table read as a settled one is a
1274    /// wrong answer, not a late one, and it reads as a defect several steps downstream with
1275    /// nothing left naming the wait.
1276    pub fn try_settle(&self, within: Duration) -> Result<Snapshot, Snapshot> {
1277        let (lock, cvar) = &*self.settle_gate;
1278        let guard = lock.lock().unwrap();
1279        let (guard, timeout) = cvar
1280            .wait_timeout_while(guard, within, |counts| !counts.is_settled())
1281            .unwrap();
1282        // Released before the snapshot below, which takes the table lock: holding both at
1283        // once is a lock order nothing else in this file takes.
1284        drop(guard);
1285        let snapshot = self.snapshot();
1286        if timeout.timed_out() {
1287            Err(snapshot)
1288        } else {
1289            Ok(snapshot)
1290        }
1291    }
1292
1293    /// Blocks until nothing is in flight, panicking once [`liveness::BACKSTOP`] expires.
1294    /// For a test.
1295    ///
1296    /// Takes no deadline, unlike [`Self::try_settle`], because every deadline this ever
1297    /// took was a number guessed against the machine its author had: the wait is on a
1298    /// liveness property ("the Generation I just dispatched lands"), which carries no
1299    /// wall-clock bound of its own, so the only honest bound is the shared backstop.
1300    /// A wait whose *number* is the claim ("nothing arrives within 200ms") is a different
1301    /// wait and belongs on [`Self::try_settle`], which reports an expiry rather than
1302    /// panicking on one.
1303    #[cfg(any(test, feature = "test-util"))]
1304    pub fn settle(&self) -> Snapshot {
1305        self.settle_within(liveness::BACKSTOP)
1306    }
1307
1308    /// [`Self::settle`] against an explicit deadline, so this crate's own tests can
1309    /// exercise the expiry path without waiting out a real backstop. The same seam
1310    /// `liveness::wait_within` gives its module.
1311    #[cfg(any(test, feature = "test-util"))]
1312    fn settle_within(&self, deadline: Duration) -> Snapshot {
1313        self.try_settle(deadline).unwrap_or_else(|_| {
1314            // Read out and released before the panic below: unwinding out of a held guard
1315            // poisons the gate, and every later `lock().unwrap()` on it, `Drop`'s included,
1316            // then panics on the way out and turns a named report into an abort.
1317            let (probes, dispatches) = {
1318                let counts = self.settle_gate.0.lock().unwrap();
1319                (counts.probes, counts.dispatches)
1320            };
1321            liveness::expired(
1322                deadline,
1323                "everything this Core has in flight to land",
1324                &format!("{probes} probe(s) and {dispatches} dispatch(es) still outstanding"),
1325            )
1326        })
1327    }
1328
1329    /// What deleting `key`'s working tree destroys, read fresh right now
1330    /// ([repo-management.md](https://github.com/paulchiu/repon/blob/main/docs/spec/repo-management.md)'s
1331    /// "The confirm gate"). Every one of the three is a git read rather than a fold over this
1332    /// entity's Cells or over the table: the gate is answering "what will accepting this
1333    /// destroy", a Cell carries whatever the last Generation left there, and the table is
1334    /// bounded by the active Set's roots, so a linked Worktree outside them would go
1335    /// unnamed. Both are the wrong tense, or the wrong scope, for a question with no undo.
1336    ///
1337    /// `uncommitted` is both halves of "not in a commit": the index against the working tree
1338    /// (`git::dirty_counts`) and `HEAD` against the index (`git::staged_changes`). The
1339    /// second is the one a `git add` with no commit lands in, and the one the dirty column
1340    /// deliberately never asks about.
1341    ///
1342    /// Errors rather than reporting zero when any read fails, so a gate never says "nothing
1343    /// to lose" because it could not look.
1344    pub fn delete_risk(&self, key: &EntityKey) -> Result<DeleteRisk, git::ProbeError> {
1345        let repo = git::open_thread_safe(key.path())?.to_thread_local();
1346        let dirty = git::dirty_counts(&repo, Arc::new(AtomicBool::new(false)))?;
1347        let staged = git::staged_changes(&repo)?;
1348        let (unpushed_commits, unpushed_branches) = git::unpushed(&repo)?;
1349        let linked_worktrees = git::linked_worktrees(&repo)?;
1350        Ok(DeleteRisk {
1351            uncommitted: dirty.total() > 0 || staged,
1352            unpushed_commits,
1353            unpushed_branches,
1354            linked_worktrees,
1355        })
1356    }
1357
1358    /// The administrative directory `git worktree remove` deletes for `key`'s own linked
1359    /// Worktree, read fresh right now. `Err` when `key`'s own path cannot even be opened as
1360    /// a git repository, which is what "the parent Repo is gone or unreadable" means for a
1361    /// `delete` on a Worktree row
1362    /// ([repo-management.md](https://github.com/paulchiu/repon/blob/main/docs/spec/repo-management.md)'s
1363    /// "What `delete` does to a Worktree"): the caller falls back to removing the working
1364    /// directory alone.
1365    pub fn worktree_admin_dir(&self, key: &EntityKey) -> Result<PathBuf, git::ProbeError> {
1366        let repo = git::open_thread_safe(key.path())?.to_thread_local();
1367        Ok(git::worktree_admin_dir(&repo))
1368    }
1369
1370    /// Every linked Worktree's own working directory pointing into `key`'s Repo, read
1371    /// fresh right now: what deleting a Repo needs to also remove, since each linked
1372    /// Worktree's directory sits outside the Repo's own and is untouched by removing that
1373    /// alone
1374    /// ([repo-management.md](https://github.com/paulchiu/repon/blob/main/docs/spec/repo-management.md)'s
1375    /// "Deleting a Repo also takes its linked Worktrees with it").
1376    pub fn linked_worktree_paths(&self, key: &EntityKey) -> Result<Vec<PathBuf>, git::ProbeError> {
1377        let repo = git::open_thread_safe(key.path())?.to_thread_local();
1378        git::linked_worktree_paths(&repo)
1379    }
1380
1381    /// `delete`'s phase 1: the ignored directories inside the working tree at `path`, read
1382    /// fresh right now
1383    /// ([repo-management.md](https://github.com/paulchiu/repon/blob/main/docs/spec/repo-management.md)'s
1384    /// "Deleting a working tree"). `path` rather than an [`EntityKey`] because a Repo
1385    /// `delete` runs this once for its own working tree and once more for each linked
1386    /// Worktree [`Self::linked_worktree_paths`] names, and only the first of those has a Set
1387    /// row of its own.
1388    pub fn ignored_directories_for_deletion(
1389        &self,
1390        path: &Path,
1391    ) -> Result<Vec<PathBuf>, git::ProbeError> {
1392        let repo = git::open_thread_safe(path)?.to_thread_local();
1393        git::ignored_directories_for_deletion(&repo)
1394    }
1395
1396    /// Attempts the fast-forward-only auto-update on `key`'s own Repo, on demand: exactly
1397    /// `crate::auto_update::attempt`'s own five rules and its own fast-forward, reused
1398    /// rather than a second implementation for the built-in `sync` action to call by hand
1399    /// ([repo-management.md](https://github.com/paulchiu/repon/blob/main/docs/spec/repo-management.md)).
1400    /// Read fresh right now, the same tense [`Self::delete_risk`] reads in: eligibility can
1401    /// change between the gate and the run, so this is never answered from a Cell.
1402    pub fn attempt_auto_update(&self, key: &EntityKey) -> AutoUpdateAttempt {
1403        match crate::auto_update::attempt(key.path()) {
1404            crate::auto_update::Outcome::Ineligible(crate::auto_update::Ineligible::NotClean) => {
1405                AutoUpdateAttempt::NotClean
1406            }
1407            crate::auto_update::Outcome::Ineligible(crate::auto_update::Ineligible::NoUpstream) => {
1408                AutoUpdateAttempt::NoUpstream
1409            }
1410            crate::auto_update::Outcome::Ineligible(crate::auto_update::Ineligible::NotBehind) => {
1411                AutoUpdateAttempt::NotBehind
1412            }
1413            crate::auto_update::Outcome::Ineligible(
1414                crate::auto_update::Ineligible::NotFastForward,
1415            ) => AutoUpdateAttempt::NotFastForward,
1416            crate::auto_update::Outcome::Updated { .. } => AutoUpdateAttempt::Updated,
1417            crate::auto_update::Outcome::Failed(error) => AutoUpdateAttempt::Failed(error),
1418        }
1419    }
1420
1421    /// Runs `action`'s own steps against one Entity, on the calling thread, blocking until
1422    /// they finish rather than handing the run off the way [`Core::run_action`]'s async
1423    /// 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))
1424    /// needs the outcome before the built-in can proceed or report, which nothing running
1425    /// off this thread can give in time. Reuses `run_action_for_entity`, the identical
1426    /// per-step execution `run_action`'s fan-out gives every entity, so a hook and a
1427    /// configured `[[action]]` never diverge in what a step means; writes nothing to the
1428    /// table and touches none of `run_action`'s own state (the one admitted run and its
1429    /// controls), since a hook is a distinct concern from the one fan-out the palette
1430    /// tracks.
1431    ///
1432    /// `None` when `key` names no Entity this table currently knows.
1433    pub fn run_action_for_entity_blocking(
1434        &self,
1435        action: &ActionSpec,
1436        key: &EntityKey,
1437    ) -> Option<ActionReceipt> {
1438        let entity = {
1439            let table = self.table.read().unwrap();
1440            let idx = *table.index.get(key)?;
1441            table.entities[idx].clone()
1442        };
1443        let control = executor::RunControl::new();
1444        Some(run_action_for_entity(&entity, action, &control, &|_| {}))
1445    }
1446
1447    /// Vends a [`ManagementHandle`]: the `Send + 'static` seam a management run's own
1448    /// per-row work moves onto a background thread through, so it stops blocking the caller
1449    /// the way [`Self::run_action`]'s own fan-out already moves an `Arc<RwLock<Table>>`
1450    /// clone onto its own thread
1451    /// ([0033](https://github.com/paulchiu/repon/blob/main/docs/adr/0033-a-management-run-moves-off-the-calling-thread-and-cancels-between-rows.md)).
1452    pub fn management_handle(&self) -> ManagementHandle {
1453        ManagementHandle {
1454            table: Arc::clone(&self.table),
1455        }
1456    }
1457
1458    /// Drops one entity from the table, cancelling any probe in flight against it.
1459    pub fn dismiss(&self, key: &EntityKey) {
1460        let mut table = self.table.write().unwrap();
1461        if let Some(idx) = table.index.remove(key) {
1462            table.entities.remove(idx);
1463            for position in table.index.values_mut() {
1464                if *position > idx {
1465                    *position -= 1;
1466                }
1467            }
1468        }
1469        table.poll_fingerprints.remove(key);
1470        if let Some(in_flight) = table.in_flight.remove(key) {
1471            in_flight.cancel.store(true, Ordering::Release);
1472            drop(table);
1473            complete_one(&self.settle_gate);
1474        }
1475    }
1476
1477    /// Resolves `order` against the table this instant and splits it into the entities
1478    /// that will actually run and the ones a matching `[[repo]]` `exclude = true`
1479    /// override sweeps in and skips
1480    /// ([config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md#per-repo-entries)).
1481    /// [`Self::run_action`] and [`Self::operable_count`] both call this rather than
1482    /// each keeping its own copy of the `!entity.excluded` test, so a consumer's confirm
1483    /// gate or palette border can never show a count a real run then contradicts
1484    /// ([`docs/spec/actions.md`](https://github.com/paulchiu/repon/blob/main/docs/spec/actions.md)'s
1485    /// "The Selection and the gate": "a wrong count would lie twice"). A key `order`
1486    /// names that no longer resolves (already dismissed, or never discovered) is
1487    /// silently dropped from both halves.
1488    fn partition_operable(&self, order: &[EntityKey]) -> (Vec<EntityState>, Vec<EntityState>) {
1489        let table = self.table.read().unwrap();
1490        order
1491            .iter()
1492            .filter_map(|key| table.index.get(key).map(|&idx| table.entities[idx].clone()))
1493            .partition(|entity| !entity.excluded)
1494    }
1495
1496    /// How many of `order` are operable, i.e. not excluded: [`Self::run_action`]'s own
1497    /// first move is the identical partition this method itself calls, so this is the one
1498    /// number a confirm gate and a palette border can both read without either ever
1499    /// drifting from what that first move keeps. Not the final count a run acts on once
1500    /// `action.when` is `Some`: [`Self::applicability`] narrows this same set further, and
1501    /// [`Self::run_action`] itself only ever runs the rows that narrowing proves.
1502    pub fn operable_count(&self, order: &[EntityKey]) -> usize {
1503        self.partition_operable(order).0.len()
1504    }
1505
1506    /// How many Entities in the live table are Vanished. Reads the table in place rather
1507    /// than through [`Self::snapshot`], so a caller needing only the count does not pay for
1508    /// a clone of the whole table and its staleness pass on every frame.
1509    pub fn vanished_count(&self) -> usize {
1510        self.table
1511            .read()
1512            .unwrap()
1513            .entities
1514            .iter()
1515            .filter(|entity| entity.presence == Presence::Vanished)
1516            .count()
1517    }
1518
1519    /// How an Action's `when` predicate divides the very rows [`Self::operable_count`]
1520    /// counts: the identical partition runs first, so an excluded row is subtracted before
1521    /// the predicate ever sees it and `when` narrows what is left rather than replacing that
1522    /// subtraction
1523    /// ([`docs/spec/actions.md`](https://github.com/paulchiu/repon/blob/main/docs/spec/actions.md)'s
1524    /// "The Selection and the gate"). A palette calls this ahead of time, to report a count
1525    /// before a choice is even made; [`Self::run_action`] runs the identical classification
1526    /// against the identical rows once a choice is confirmed, over `ActionSpec::when` rather
1527    /// than an argument of its own, so a preview and a real run can never disagree.
1528    ///
1529    /// The tally lives here rather than in the consumer for that reason alone:
1530    /// `partition_operable` is this type's own, so a caller cannot count applicability over
1531    /// a set the run would not act on.
1532    pub fn applicability(&self, order: &[EntityKey], when: &Filter) -> Applicability {
1533        when.applicability(self.partition_operable(order).0.iter())
1534    }
1535
1536    /// `true` from an Action run's admission until its completion has dispatched the
1537    /// Generation it owes, the consumer-facing read of the one admitted run
1538    /// ([ADR 0018](https://github.com/paulchiu/repon/blob/main/docs/adr/0018-an-action-is-a-fanout-of-pty-backed-steps.md)'s
1539    /// "One Action runs at a time"): what a TUI gates `;`, `s`, `1` to `9` and `Ctrl+R`
1540    /// against while a run is in flight
1541    /// ([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)).
1542    pub fn action_running(&self) -> bool {
1543        self.action_lifecycle.lock().unwrap().live.is_some()
1544    }
1545
1546    /// `true` while any refresh-shaped dispatch this `Core` started still owes the table
1547    /// work: a Generation reserved and not yet raised the probes it dispatches, or probes
1548    /// raised and not yet landed, cancelled or timed out. The same gate [`Core::try_settle`]
1549    /// blocks on, read here without blocking, so a consumer can report a Refresh's own
1550    /// progress on screen while it runs rather than waiting for it to finish
1551    /// ([refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)).
1552    /// Covers `refresh`, `refresh_all`, `rederive_default_branches`, `probe_now` and the
1553    /// startup walk alike; an Action's own fan-out never touches this gate, which is what
1554    /// `action_running` reads instead.
1555    pub fn refresh_running(&self) -> bool {
1556        let (lock, _cvar) = &*self.settle_gate;
1557        !lock.lock().unwrap().is_settled()
1558    }
1559
1560    /// Whether a fetch cycle is in flight, whichever asked for it: the periodic one, the
1561    /// immediate one enabling the fetch owes, or [`Core::fetch_now`]. Read fresh every frame
1562    /// by the status row, the same way [`Self::refresh_running`] is.
1563    pub fn fetch_running(&self) -> bool {
1564        self.fetch_running.load(Ordering::Acquire)
1565    }
1566
1567    /// Runs `action` across every key in `order` that the table currently knows: each
1568    /// entity's own steps run in order and stop at that entity's first failure, exactly
1569    /// as [config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md)'s
1570    /// "Actions" fixes, with later steps recorded `NotRun` rather than silently skipped.
1571    /// Cross-entity concurrency is bounded by `action.concurrency`, on a
1572    /// `rayon::ThreadPool` this call builds and owns for the run alone, never rayon's
1573    /// global pool the probe fan-out shares: a step blocked in `wait()` removes a
1574    /// worker from whichever pool holds it, and the global pool has none to spare
1575    /// without starving a refresh in flight
1576    /// ([`docs/spec/actions.md`](https://github.com/paulchiu/repon/blob/main/docs/spec/actions.md)'s
1577    /// "The fan-out"). Returns immediately; every step's own child, and this run's
1578    /// completion, run off the calling thread.
1579    ///
1580    /// Returns `false` and touches nothing if a fan-out is already running: only one
1581    /// runs at a time
1582    /// ([ADR 0018](https://github.com/paulchiu/repon/blob/main/docs/adr/0018-an-action-is-a-fanout-of-pty-backed-steps.md)'s
1583    /// "One Action runs at a time"), but the spec settles only that the *palette* goes
1584    /// inert while one is live, never what a second, concurrent call to this seam itself
1585    /// should do. Rejecting outright, rather than queuing, is this call's own choice: a
1586    /// queue needs its own ordering and cancellation story that no acceptance criterion
1587    /// here asks for.
1588    ///
1589    /// An entity in `order` carrying a matching `[[repo]]` `exclude = true`
1590    /// ([config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md#per-repo-entries))
1591    /// never runs a step: it receives a [`Skip::Excluded`] receipt with an empty step list
1592    /// immediately, the one legitimate producer of `Not applicable`
1593    /// ([`docs/spec/actions.md`](https://github.com/paulchiu/repon/blob/main/docs/spec/actions.md)'s
1594    /// "The Selection and the gate"). An unknown key in `order` (already dismissed, or
1595    /// never discovered) is silently skipped, the same fallback `refresh` gives one.
1596    ///
1597    /// `action.when`, once every excluded row is already subtracted, decides what runs
1598    /// rather than only what a palette reported about it: a row it proves is handed a
1599    /// step, a row it disproves gets a [`Skip::Inapplicable`] receipt instead, and a row it
1600    /// cannot settle (a Cell it reads has not settled) gets [`Skip::Unresolved`], since an
1601    /// unprovable row is not a provable one and a run has no basis to touch it either
1602    /// (`docs/spec/actions.md`'s "The Selection and the gate", reversing what that
1603    /// paragraph originally decided). `None` runs every operable row, exactly as before
1604    /// `when` reached this call.
1605    ///
1606    /// Starting a run cancels any in-flight Generation outright rather than sharing
1607    /// execution with it, and completion starts exactly one normal Generation over
1608    /// every entity the table currently knows, not only the ones this run touched.
1609    /// Explicitly not done, for the same reason: re-probing each affected entity
1610    /// synchronously first, the way a Launcher return does with [`Core::probe_now`].
1611    /// Both choices, and their measured cost, are
1612    /// [ADR 0018](https://github.com/paulchiu/repon/blob/main/docs/adr/0018-an-action-is-a-fanout-of-pty-backed-steps.md)'s
1613    /// ("Refreshing around a run").
1614    pub fn run_action(&self, action: ActionSpec, order: &[EntityKey]) -> bool {
1615        // Built before admission rather than after it, so the run a caller is told started
1616        // is admitted with its own reach into its children already attached: a
1617        // `stop_action` the instant this returns `true` can never find no control, and a
1618        // completion racing in can never find someone else's.
1619        let control = executor::RunControl::new();
1620        if !self
1621            .action_lifecycle
1622            .lock()
1623            .unwrap()
1624            .admit(Arc::clone(&control))
1625        {
1626            return false;
1627        }
1628
1629        // Criterion 3's first half: starting a run cancels any in-flight Generation
1630        // outright, never sharing the machine with it.
1631        cancel_in_flight(&self.table, &self.settle_gate);
1632
1633        let (operable, excluded) = self.partition_operable(order);
1634
1635        let write_skip_receipts = |entities: &[EntityState], skip: Skip| {
1636            if entities.is_empty() {
1637                return;
1638            }
1639            let finished_at = Timestamp::now();
1640            let mut table = self.table.write().unwrap();
1641            for entity in entities {
1642                if let Some(&idx) = table.index.get(&entity.key) {
1643                    table.entities[idx].last_action = Some(ActionReceipt {
1644                        label: Arc::clone(&action.label),
1645                        steps: Arc::from(Vec::new()),
1646                        skip: Some(skip),
1647                        finished_at,
1648                        running: None,
1649                    });
1650                }
1651            }
1652        };
1653
1654        write_skip_receipts(&excluded, Skip::Excluded);
1655
1656        let included = match &action.when {
1657            Some(when) => {
1658                let Partition {
1659                    applicable,
1660                    inapplicable,
1661                    unresolved,
1662                } = when.partition(operable);
1663                write_skip_receipts(&inapplicable, Skip::Inapplicable);
1664                write_skip_receipts(&unresolved, Skip::Unresolved);
1665                applicable
1666            }
1667            None => operable,
1668        };
1669
1670        let table_handle = Arc::clone(&self.table);
1671        let refresh_handles = self.refresh_handles();
1672        let action_lifecycle = Arc::clone(&self.action_lifecycle);
1673        #[cfg(test)]
1674        let completion_boundary = Arc::clone(&self.action_completion_boundary);
1675        // At least one worker regardless of what `action.concurrency` says: 0 has no
1676        // sensible reading as "run nothing" here (the schema has no floor, only an
1677        // explicit absence of a *ceiling*, `docs/spec/actions.md`'s "The fan-out"), and
1678        // `rayon::ThreadPoolBuilder::num_threads(0)` means "let rayon choose" rather
1679        // than zero workers, which would silently hand this run back to a pool sized by
1680        // something other than `concurrency`.
1681        let concurrency = action.concurrency.max(1) as usize;
1682
1683        // A plain OS thread, never a job on either rayon pool: `RefreshHandles::dispatch`
1684        // below calls `rayon::spawn`, which targets whichever pool the *calling* thread
1685        // already belongs to, so running this orchestration from inside the dedicated
1686        // pool built below would misroute the completion Generation's own probes onto
1687        // it instead of the global pool every other probe uses.
1688        thread::spawn(move || {
1689            let pool = rayon::ThreadPoolBuilder::new()
1690                .num_threads(concurrency)
1691                .build()
1692                .expect("build the Action fan-out's own dedicated pool");
1693
1694            // Caught rather than left to unwind straight out of this thread: a poisoned
1695            // `RwLock` from an unrelated earlier panic is enough to panic the
1696            // `table_handle.write().unwrap()` below, and without `catch_unwind` that
1697            // would unwind past the `RunCompletion` just beyond it before that guard
1698            // exists, leaving this `Core` reading its run as live for the rest of its life.
1699            let fan_out = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1700                pool.install(|| {
1701                    included.into_par_iter().for_each(|entity| {
1702                        let write_receipt = |receipt: ActionReceipt| {
1703                            let mut table = table_handle.write().unwrap();
1704                            if let Some(&idx) = table.index.get(&entity.key) {
1705                                table.entities[idx].last_action = Some(receipt);
1706                            }
1707                        };
1708                        let receipt =
1709                            run_action_for_entity(&entity, &action, &control, &write_receipt);
1710                        write_receipt(receipt);
1711                    });
1712                });
1713            }));
1714
1715            // scan: action-completion-path begin -- criterion 4: nothing from here to the
1716            // matching end marker below may re-probe an affected entity synchronously the
1717            // way a Launcher return does with `probe_now`; scoped this narrowly (rather
1718            // than a whole-crate scan) because a legitimate Launcher-return caller lives
1719            // in an unrelated call site the same absence claim must not forbid.
1720            // Criterion 6: the fan-out's own steps are over here, panic or not, and this
1721            // run stays admitted only until `completion` drops one statement past the
1722            // Generation below. `hold_action`, `continue_action` and `stop_action` are
1723            // no-ops again from that point, and a second `run_action` before it is refused
1724            // rather than left to race the Generation this run still owes.
1725            let completion = RunCompletion {
1726                lifecycle: action_lifecycle,
1727                #[cfg(test)]
1728                boundary: completion_boundary,
1729            };
1730
1731            // A panicked fan-out never finished cleanly, so it earns no completion
1732            // Generation. Swallowed rather than resumed: the default panic hook already
1733            // printed it to stderr before `catch_unwind` returned, and this crate carries
1734            // no logger to hand it to instead.
1735            let Ok(()) = fan_out else {
1736                return;
1737            };
1738
1739            // Criterion 3's second half: completion starts one normal Generation over
1740            // every entity currently known, not only the ones this run acted on.
1741            let all_keys: Vec<EntityKey> = table_handle
1742                .read()
1743                .unwrap()
1744                .entities
1745                .iter()
1746                .map(|entity| entity.key.clone())
1747                .collect();
1748            refresh_handles.dispatch(&all_keys);
1749            drop(completion);
1750            // scan: action-completion-path end
1751        });
1752
1753        true
1754    }
1755
1756    /// SIGSTOPs every currently live step's process group in the fan-out `run_action`
1757    /// started, reversible with [`Self::continue_action`]: suspending a run is reversible,
1758    /// where cancelling one is not
1759    /// ([`docs/spec/actions.md`](https://github.com/paulchiu/repon/blob/main/docs/spec/actions.md)'s
1760    /// "Cancellation and quit"). A no-op while no fan-out is running. Its own verb,
1761    /// kept apart from [`Self::pause`], which stays ignorant of why background work stopped.
1762    pub fn hold_action(&self) {
1763        if let Some(control) = self.live_action_control() {
1764            control.hold();
1765        }
1766    }
1767
1768    /// SIGCONTs every currently live step's process group, undoing [`Self::hold_action`]. A
1769    /// no-op while no fan-out is running.
1770    pub fn continue_action(&self) {
1771        if let Some(control) = self.live_action_control() {
1772            control.continue_run();
1773        }
1774    }
1775
1776    /// Cancels the fan-out `run_action` started: SIGTERM now to every step's process group
1777    /// still live, SIGKILL after a grace to whichever of those have not exited by then,
1778    /// because SIGTERM is trappable and SIGKILL is not. A step already running when this is
1779    /// called becomes `Cancelled`; so does a step, or a whole entity's run, that had not
1780    /// started, which stays distinct from `NotRun`
1781    /// ([`docs/spec/actions.md`](https://github.com/paulchiu/repon/blob/main/docs/spec/actions.md)'s
1782    /// "Cancellation and quit"). A no-op while no fan-out is running. Its own verb,
1783    /// kept apart from [`Self::pause`] for the same reason [`Self::hold_action`] is.
1784    pub fn stop_action(&self) {
1785        if let Some(control) = self.live_action_control() {
1786            control.cancel();
1787        }
1788    }
1789
1790    /// The live run's reach into its steps' children, cloned out so the three verbs above
1791    /// signal a process group with [`Core::action_lifecycle`]'s lock already released.
1792    /// `None` with no fan-out live, which is what makes each of them a no-op then.
1793    fn live_action_control(&self) -> Option<Arc<executor::RunControl>> {
1794        self.action_lifecycle.lock().unwrap().live.clone()
1795    }
1796
1797    /// Stops all background work: the dedicated thread stops ticking and every
1798    /// probe currently in flight is cancelled. The core is never told why.
1799    pub fn pause(&self) {
1800        let _ = self.control.send(ClockControl::Pause);
1801    }
1802
1803    /// Restarts the dedicated thread's ticking. No Generation is queued to fire on resume;
1804    /// one is the consumer's decision, not this call's. The single cycle enabling the
1805    /// periodic fetch owes does fire here, if a pause landed before it.
1806    pub fn resume(&self) {
1807        let _ = self.control.send(ClockControl::Resume);
1808    }
1809
1810    /// Starts a fetch-and-prune cycle now rather than at the next `fetch.interval` tick:
1811    /// the identical cycle the timer runs, so it always prunes, honours
1812    /// `fetch.concurrency`, counts its own failures into [`Core::fetch_failures`] and
1813    /// dispatches one normal Generation on completion, and the auto-update rides it exactly
1814    /// as it rides a tick's.
1815    ///
1816    /// Ungated by `FetchSpec::enabled`, which governs only the cycle this `Core` runs
1817    /// unbidden on a timer; this one is asked for. Refused rather than queued while a cycle
1818    /// is already in flight, the same choice a tick arriving mid-cycle already makes.
1819    pub fn fetch_now(&self) {
1820        let _ = self.control.send(ClockControl::FetchNow);
1821    }
1822
1823    /// The persistent warning a re-run discovery walk leaves behind once it abandons, or
1824    /// `None` while none has. Never cleared once set, the same as `discovery_manual`: the
1825    /// Set stays out of the automatic refresh path for the life of this `Core`. The UI's
1826    /// shared warning slot polls this every frame, since it can turn from `None` to `Some`
1827    /// at any point in the run with no reload involved.
1828    pub fn discovery_warning(&self) -> Option<String> {
1829        self.discovery_warning.lock().unwrap().clone()
1830    }
1831
1832    /// The most recently completed periodic-fetch cycle's own failures, or an
1833    /// empty [`FetchFailures`] once every fetch in that cycle succeeded, or the
1834    /// cycle has never run. The UI's shared warning slot polls this every frame,
1835    /// the same way it polls [`Self::discovery_warning`] and
1836    /// [`Self::vanished_count`], since a later cycle can replace this at any point
1837    /// in the run with no reload involved.
1838    pub fn fetch_failures(&self) -> FetchFailures {
1839        self.fetch_failures.lock().unwrap().clone()
1840    }
1841
1842    /// Sets the live show-submodules preference a Generation's dispatch reads from this
1843    /// point on: whether a Kind::Submodule entity is probed at all
1844    /// ([discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md)'s
1845    /// "Showing Submodules"). Takes effect on the next `refresh`, dispatches nothing of its
1846    /// own and starts no Generation, which is what makes toggling this instant rather than a
1847    /// rebuild: `CoreSpec`'s own `show_submodules` is only this flag's starting value.
1848    pub fn set_show_submodules(&self, show_submodules: bool) {
1849        self.show_submodules
1850            .store(show_submodules, Ordering::Release);
1851    }
1852
1853    /// Writes one receipt per row for work Repon did itself, with no child process anywhere
1854    /// in it: what a Management operation leaves behind
1855    /// ([repo-management.md](https://github.com/paulchiu/repon/blob/main/docs/spec/repo-management.md)'s
1856    /// "Receipts", [`docs/spec/actions.md`](https://github.com/paulchiu/repon/blob/main/docs/spec/actions.md)'s
1857    /// `OwnWork`).
1858    ///
1859    /// The receipt is built here rather than handed in whole, so a consumer supplies only
1860    /// what Repon did and the words for it: `skip` stays `None`, since a refusal is a row
1861    /// that was operated on rather than one of the three ways a row is skipped, `running`
1862    /// stays `None`, since the work is already done, and the step count stays one, since the
1863    /// operation is one act rather than an ordered list.
1864    /// `label` is the operation's own name and doubles as the single step's label; the step's
1865    /// captured output is empty, there being no other program's screen to quote.
1866    ///
1867    /// Starts no Generation and dispatches nothing, for the same reason
1868    /// [`Core::set_exclusions`] does not: a receipt is something Repon did rather than a
1869    /// reading of the world, so nothing here can make a cell any more or less true. A key the
1870    /// table no longer holds is skipped, the same fallback every key-addressed entry point
1871    /// here gives one.
1872    pub fn record_own_work(&self, label: &str, results: &[(EntityKey, OwnWork, Duration)]) {
1873        let label: Arc<str> = Arc::from(label);
1874        let finished_at = Timestamp::now();
1875        let mut table = self.table.write().unwrap();
1876        for (key, work, elapsed) in results {
1877            let Some(&idx) = table.index.get(key) else {
1878                continue;
1879            };
1880            table.entities[idx].last_action = Some(ActionReceipt {
1881                label: Arc::clone(&label),
1882                steps: Arc::from(vec![StepResult {
1883                    label: Arc::clone(&label),
1884                    outcome: StepOutcome::OwnWork(work.clone()),
1885                    output: Arc::from(&b""[..]),
1886                    elapsed: *elapsed,
1887                    elision: None,
1888                    shell: false,
1889                    interactive: false,
1890                }]),
1891                skip: None,
1892                finished_at,
1893                running: None,
1894            });
1895        }
1896    }
1897
1898    /// Replaces the live `exclude` half of `[[repo]]` and re-applies it over every row the
1899    /// table already holds, so the next [`Core::snapshot`] answers with the new reading
1900    /// ([repo-management.md](https://github.com/paulchiu/repon/blob/main/docs/spec/repo-management.md)'s
1901    /// "Writing config": an `ignore` takes effect as soon as this call returns).
1902    ///
1903    /// Starts no Generation, dispatches nothing and rediscovers nothing, for the same reason
1904    /// [`Core::set_show_submodules`] does not: `exclude` decides only whether an operation
1905    /// may reach a row, never what discovery finds or what a probe reads. `default_branch`,
1906    /// the other key a `[[repo]]` entry may carry, is a probe input and is deliberately not
1907    /// moved here; it still needs a rebuilt `Core`.
1908    pub fn set_exclusions(&self, overrides: &[RepoOverride]) {
1909        let (_, resolved) = resolve_entries(overrides);
1910        // Written and released before the table lock is taken, never held across it:
1911        // `rerun_discovery` reads these two in the opposite order.
1912        {
1913            let mut exclusions = self.exclusions.write().unwrap();
1914            *exclusions = resolved.clone();
1915        }
1916        let mut table = self.table.write().unwrap();
1917        for entity in &mut table.entities {
1918            entity.excluded = excluded_by(&resolved, entity.key.path(), &entity.common_dir);
1919        }
1920    }
1921}
1922
1923/// The read-only, path-driven operations a management run's per-row work needs
1924/// (`crates/repon/src/management.rs`'s own `run_one_record`), cloned out of
1925/// [`Core::management_handle`] rather than borrowed from a live `Core`: `Send + 'static`, so
1926/// a caller can move it onto a background thread the way [`Core::run_action`]'s own fan-out
1927/// thread already moves its `Arc<RwLock<Table>>` clone there. Grants none of `Core`'s other
1928/// state (the one admitted Action run and its controls, the clock thread): a management run
1929/// is a distinct concern from the one fan-out those track, and this handle's own methods touch
1930/// only the table, exactly as [`Core::run_action_for_entity_blocking`] already does.
1931#[derive(Clone)]
1932pub struct ManagementHandle {
1933    table: Arc<RwLock<Table>>,
1934}
1935
1936impl ManagementHandle {
1937    /// Identical to [`Core::worktree_admin_dir`], against this handle's own table clone.
1938    pub fn worktree_admin_dir(&self, key: &EntityKey) -> Result<PathBuf, git::ProbeError> {
1939        let repo = git::open_thread_safe(key.path())?.to_thread_local();
1940        Ok(git::worktree_admin_dir(&repo))
1941    }
1942
1943    /// Identical to [`Core::linked_worktree_paths`], against this handle's own table clone.
1944    pub fn linked_worktree_paths(&self, key: &EntityKey) -> Result<Vec<PathBuf>, git::ProbeError> {
1945        let repo = git::open_thread_safe(key.path())?.to_thread_local();
1946        git::linked_worktree_paths(&repo)
1947    }
1948
1949    /// Identical to [`Core::ignored_directories_for_deletion`], against this handle's own
1950    /// table clone.
1951    pub fn ignored_directories_for_deletion(
1952        &self,
1953        path: &Path,
1954    ) -> Result<Vec<PathBuf>, git::ProbeError> {
1955        let repo = git::open_thread_safe(path)?.to_thread_local();
1956        git::ignored_directories_for_deletion(&repo)
1957    }
1958
1959    /// Identical to [`Core::attempt_auto_update`].
1960    pub fn attempt_auto_update(&self, key: &EntityKey) -> AutoUpdateAttempt {
1961        match crate::auto_update::attempt(key.path()) {
1962            crate::auto_update::Outcome::Ineligible(crate::auto_update::Ineligible::NotClean) => {
1963                AutoUpdateAttempt::NotClean
1964            }
1965            crate::auto_update::Outcome::Ineligible(crate::auto_update::Ineligible::NoUpstream) => {
1966                AutoUpdateAttempt::NoUpstream
1967            }
1968            crate::auto_update::Outcome::Ineligible(crate::auto_update::Ineligible::NotBehind) => {
1969                AutoUpdateAttempt::NotBehind
1970            }
1971            crate::auto_update::Outcome::Ineligible(
1972                crate::auto_update::Ineligible::NotFastForward,
1973            ) => AutoUpdateAttempt::NotFastForward,
1974            crate::auto_update::Outcome::Updated { .. } => AutoUpdateAttempt::Updated,
1975            crate::auto_update::Outcome::Failed(error) => AutoUpdateAttempt::Failed(error),
1976        }
1977    }
1978
1979    /// Identical to [`Core::run_action_for_entity_blocking`], against this handle's own
1980    /// table clone rather than a live `Core`.
1981    pub fn run_action_for_entity_blocking(
1982        &self,
1983        action: &ActionSpec,
1984        key: &EntityKey,
1985    ) -> Option<ActionReceipt> {
1986        let entity = {
1987            let table = self.table.read().unwrap();
1988            let idx = *table.index.get(key)?;
1989            table.entities[idx].clone()
1990        };
1991        let control = executor::RunControl::new();
1992        Some(run_action_for_entity(&entity, action, &control, &|_| {}))
1993    }
1994}
1995
1996/// Every `Arc` and plain-data field a Generation's dispatch reads, owned rather than
1997/// borrowed: [`Core::refresh_handles`] is the only constructor once a `Core` exists,
1998/// and its own doc comment carries the reason this exists at all. `start_internal`
1999/// builds one directly, since the periodic fetch's own completion Generation needs
2000/// this before there is a `Core` to ask; `Clone` is what lets that one value serve
2001/// both the recurring cadence and the immediate first cycle without a second,
2002/// drifting construction. Field names and types mirror `Core`'s own exactly, so
2003/// [`Self::dispatch`] and [`Self::rerun_discovery`] are `refresh` and
2004/// `rerun_discovery`'s bodies moved verbatim, `self.field` unchanged.
2005#[derive(Clone)]
2006struct RefreshHandles {
2007    table: Arc<RwLock<Table>>,
2008    overrides: Arc<Vec<ResolvedOverride>>,
2009    /// [`Core::exclusions`]'s own clone, so a re-run discovery's newly found rows take
2010    /// whatever `exclude` says right now rather than whatever it said at `start`.
2011    exclusions: Arc<RwLock<Vec<ResolvedExclusion>>>,
2012    set: SetSpec,
2013    discovery_manual: Arc<AtomicBool>,
2014    discovery_warn_after: Duration,
2015    discovery_abandon_after: Arc<AtomicU64>,
2016    discovery_warning: Arc<Mutex<Option<String>>>,
2017    show_submodules: Arc<AtomicBool>,
2018    settle_gate: Arc<SettleGate>,
2019    default_branch_chain_reads: Arc<AtomicUsize>,
2020    patch_identity_reads: Arc<AtomicUsize>,
2021    patch_scan_bounds: Arc<Mutex<Vec<Option<gix::ObjectId>>>>,
2022    dispatch_log: Arc<Mutex<Vec<EntityKey>>>,
2023    phase_c_gates: Arc<Mutex<HashMap<EntityKey, PhaseCGateHandle>>>,
2024    /// [`Core::network_default_branch`]'s own clone: [`run_fetch_cycle`] writes
2025    /// into it once a fetch's own handshake advertises a HEAD, and this
2026    /// dispatch's own default-branch probes read it back the same Generation.
2027    network_default_branch: Arc<Mutex<HashMap<PathBuf, Arc<str>>>>,
2028    /// [`Core::turnstile`]'s own clone, so every dispatch this `Core` starts,
2029    /// wherever it is called from, queues in the one order.
2030    turnstile: Arc<DispatchTurnstile>,
2031    /// [`Core::discovery_gate`]'s own clone; `None` on every production path.
2032    discovery_gate: Option<DiscoveryGate>,
2033}
2034
2035/// Runs the spawned dispatch bodies in the order their Generations were reserved.
2036///
2037/// Reserving the number is what a caller waits for; everything after it happens on
2038/// a thread of its own, and two of those threads reaching the table out of order
2039/// would let an older Generation cancel a newer one's in-flight entries and then
2040/// record itself as the live one, which is refresh.md's supersession rule read
2041/// backwards. A ticket taken under the same lock that mints the Generation, and
2042/// served in ticket order, is what stops that.
2043#[derive(Default)]
2044struct DispatchTurnstile {
2045    /// The ticket whose body may run, and the [`Condvar`] every waiting body sleeps on.
2046    serving: Mutex<u64>,
2047    ready: Condvar,
2048    /// The next ticket to hand out. Only ever read under the table write lock
2049    /// [`RefreshHandles::reserve_generation`] holds, so tickets and Generations
2050    /// are issued in the one order.
2051    next: AtomicU64,
2052}
2053
2054impl DispatchTurnstile {
2055    fn reserve(&self) -> u64 {
2056        self.next.fetch_add(1, Ordering::AcqRel)
2057    }
2058
2059    /// Blocks until `ticket` is the one being served. The returned guard releases
2060    /// the next ticket when it drops, panic included, so one body that unwinds
2061    /// cannot wedge every dispatch after it.
2062    fn take(&self, ticket: u64) -> DispatchTurn<'_> {
2063        let serving = self.serving.lock().unwrap();
2064        drop(
2065            self.ready
2066                .wait_while(serving, |serving| *serving != ticket)
2067                .unwrap(),
2068        );
2069        DispatchTurn {
2070            turnstile: self,
2071            ticket,
2072        }
2073    }
2074}
2075
2076/// One body's turn at the [`DispatchTurnstile`], held for as long as that body runs.
2077struct DispatchTurn<'a> {
2078    turnstile: &'a DispatchTurnstile,
2079    ticket: u64,
2080}
2081
2082impl Drop for DispatchTurn<'_> {
2083    fn drop(&mut self) {
2084        let mut serving = self.turnstile.serving.lock().unwrap();
2085        *serving = self.ticket + 1;
2086        self.turnstile.ready.notify_all();
2087    }
2088}
2089
2090impl RefreshHandles {
2091    /// `Core::refresh`'s whole body, moved here so `run_action`'s completion can call
2092    /// the identical dispatch from a thread that owns no reference to `Core` itself.
2093    ///
2094    /// Reserves this Generation's number and its turnstile place on the calling
2095    /// thread and does everything else, discovery's own walk included, on a thread
2096    /// of its own, the shape [`Core::rederive_default_branches`] already takes: no
2097    /// caller waits out a walk, and every one of them is fire and forget past the
2098    /// number this returns.
2099    fn dispatch(&self, order: &[EntityKey]) -> Generation {
2100        let (generation, ticket) = self.reserve_generation();
2101        begin_dispatch(&self.settle_gate);
2102        let handles = self.clone();
2103        let order = order.to_vec();
2104        thread::spawn(move || {
2105            let _turn = handles.turnstile.take(ticket);
2106            handles.run_generation(&order, generation);
2107            finish_dispatch(&handles.settle_gate);
2108        });
2109        generation
2110    }
2111
2112    /// [`Core::refresh_all`]'s whole body: the same reservation and the same spawned
2113    /// shape as [`Self::dispatch`], with the order read off the table this
2114    /// Generation's own discovery just reconciled rather than taken from a caller.
2115    fn dispatch_over_everything(&self) -> Generation {
2116        let (generation, ticket) = self.reserve_generation();
2117        begin_dispatch(&self.settle_gate);
2118        let handles = self.clone();
2119        thread::spawn(move || {
2120            let _turn = handles.turnstile.take(ticket);
2121            handles.rediscover();
2122            let order: Vec<EntityKey> = handles
2123                .table
2124                .read()
2125                .unwrap()
2126                .entities
2127                .iter()
2128                .map(|entity| entity.key.clone())
2129                .collect();
2130            handles.dispatch_probes(&order, generation);
2131            finish_dispatch(&handles.settle_gate);
2132        });
2133        generation
2134    }
2135
2136    /// Takes this Generation's number and its turnstile ticket under one hold of
2137    /// the table lock, so the two orders can never disagree.
2138    fn reserve_generation(&self) -> (Generation, u64) {
2139        let mut table = self.table.write().unwrap();
2140        table.generation += 1;
2141        (Generation::new(table.generation), self.turnstile.reserve())
2142    }
2143
2144    /// [`Self::dispatch`]'s spawned body: both halves of discovery, then the probe
2145    /// fan-out for `order`.
2146    fn run_generation(&self, order: &[EntityKey], generation: Generation) {
2147        self.rediscover();
2148        self.dispatch_probes(order, generation);
2149    }
2150
2151    /// Both halves of discovery at the head of one Generation, per refresh.md and
2152    /// discovery.md: an entity no longer found becomes Vanished, and one found again
2153    /// (new, or previously Vanished) is Present. Skipped once an earlier walk has
2154    /// abandoned, which takes the Set out of this automatic path until a fresh `Core`
2155    /// starts over different roots.
2156    fn rediscover(&self) {
2157        if !self.discovery_manual.load(Ordering::Acquire) {
2158            self.rerun_discovery();
2159        }
2160    }
2161
2162    /// The probe fan-out alone, against the table as it stands: one rayon task per
2163    /// dispatched entity, exactly as before this Generation's discovery moved off
2164    /// the calling thread. Split out from [`Self::run_generation`] so
2165    /// [`Self::dispatch_over_everything`], which has to resolve its order between the walk
2166    /// and the fan-out, shares this body rather than keeping a second copy of it.
2167    fn dispatch_probes(&self, order: &[EntityKey], generation: Generation) {
2168        // Scoped to this one Generation, per default-branch.md's "memoised per
2169        // common dir within a single refresh generation": a fresh cache every
2170        // call, never carried over, never touched by the previous Generation's
2171        // still-finishing tasks holding their own clone of the old one.
2172        self.default_branch_chain_reads.store(0, Ordering::Release);
2173        self.patch_identity_reads.store(0, Ordering::Release);
2174        self.patch_scan_bounds.lock().unwrap().clear();
2175        self.dispatch_log.lock().unwrap().clear();
2176
2177        let generation_number = generation.value();
2178        let mut table = self.table.write().unwrap();
2179        table
2180            .generation_started_at
2181            .insert(generation_number, Instant::now());
2182
2183        let show_submodules = self.show_submodules.load(Ordering::Acquire);
2184        let mut dispatched = Vec::new();
2185        for key in order {
2186            let Some(&idx) = table.index.get(key) else {
2187                continue;
2188            };
2189            if !dispatches_kind(table.entities[idx].kind, show_submodules) {
2190                // Narrows the work, not merely the view: a hidden Submodule's Cells are
2191                // left exactly as this Generation found them, so a normal Generation pays
2192                // nothing for it (`docs/spec/discovery.md`'s "Showing Submodules").
2193                continue;
2194            }
2195            if let Some(previous) = table.in_flight.remove(key) {
2196                previous.cancel.store(true, Ordering::Release);
2197            }
2198            let cancel = Arc::new(AtomicBool::new(false));
2199            table.in_flight.insert(
2200                key.clone(),
2201                InFlight {
2202                    generation: generation_number,
2203                    cancel: Arc::clone(&cancel),
2204                },
2205            );
2206            begin_probes(&mut table.entities[idx]);
2207            dispatched.push((key.clone(), cancel));
2208        }
2209
2210        if dispatched.is_empty() {
2211            return;
2212        }
2213
2214        begin_probes_owed(&self.settle_gate, dispatched.len());
2215        let repos: Vec<Option<Arc<gix::ThreadSafeRepository>>> = dispatched
2216            .iter()
2217            .map(|(key, _)| table.repos.get(key).cloned())
2218            .collect();
2219        let override_branches: Vec<Option<String>> = dispatched
2220            .iter()
2221            .map(|(key, _)| {
2222                let idx = table.index[key];
2223                let common_dir = &table.entities[idx].common_dir;
2224                find_entry(&self.overrides, key.path(), common_dir)
2225                    .and_then(|entry| entry.default_branch.clone())
2226            })
2227            .collect();
2228        let network_branches: Vec<Option<Arc<str>>> = dispatched
2229            .iter()
2230            .map(|(key, _)| {
2231                let idx = table.index[key];
2232                let common_dir = &table.entities[idx].common_dir;
2233                network_branch_for(&self.network_default_branch, common_dir)
2234            })
2235            .collect();
2236        let common_dirs: Vec<Arc<Path>> = dispatched
2237            .iter()
2238            .map(|(key, _)| Arc::clone(&table.entities[table.index[key]].common_dir))
2239            .collect();
2240        let probes_state: Vec<bool> = dispatched
2241            .iter()
2242            .map(|(key, _)| table.entities[table.index[key]].probes_state())
2243            .collect();
2244        let probes_base: Vec<bool> = dispatched
2245            .iter()
2246            .map(|(key, _)| table.entities[table.index[key]].probes_base())
2247            .collect();
2248        let kinds: Vec<Kind> = dispatched
2249            .iter()
2250            .map(|(key, _)| table.entities[table.index[key]].kind)
2251            .collect();
2252        drop(table);
2253
2254        // Scoped to this dispatch alone: every task below gets its own clone of
2255        // this `Arc`, and once they all finish and drop it, the cache and every
2256        // `ChainFacts` it holds are freed. Nothing here outlives one Generation.
2257        let chain_cache: Arc<ChainFactsCache> = Arc::new(Mutex::new(HashMap::new()));
2258        // Same lifetime as `chain_cache`, one dispatch's worth: patch
2259        // equivalence's own per-common-dir memo, per default-branch.md's "Two
2260        // passes on screen".
2261        let patch_cache: Arc<PatchIdentityCache> = Arc::new(Mutex::new(HashMap::new()));
2262        // One gate per common dir with at least one entity that will run
2263        // `landing::probe` this Generation, sized up front so it is known
2264        // exactly how many entities owe it a report before any of them run;
2265        // see `BoundGate`.
2266        let bound_gates: Arc<HashMap<Arc<Path>, BoundGate>> = Arc::new({
2267            let mut counts: HashMap<Arc<Path>, usize> = HashMap::new();
2268            for (common_dir, probes_state) in common_dirs.iter().zip(&probes_state) {
2269                if *probes_state {
2270                    *counts.entry(Arc::clone(common_dir)).or_insert(0) += 1;
2271                }
2272            }
2273            counts
2274                .into_iter()
2275                .map(|(dir, count)| (dir, BoundGate::new(count)))
2276                .collect()
2277        });
2278
2279        for (
2280            (
2281                (
2282                    (((((key, cancel), repo), override_branch), network_branch), common_dir),
2283                    probes_state,
2284                ),
2285                probes_base,
2286            ),
2287            kind,
2288        ) in dispatched
2289            .into_iter()
2290            .zip(repos)
2291            .zip(override_branches)
2292            .zip(network_branches)
2293            .zip(common_dirs)
2294            .zip(probes_state)
2295            .zip(probes_base)
2296            .zip(kinds)
2297        {
2298            // Recorded here, in this loop's own sequential iteration, rather than in the
2299            // one above: this is the loop whose order a future change (a sort by predicted
2300            // cost, say) would actually be tempted to touch, since it is the one that decides
2301            // each entity's `rayon::spawn` call, not merely which entities were dispatched.
2302            self.dispatch_log.lock().unwrap().push(key.clone());
2303            let path = key.path().to_path_buf();
2304            let table_handle = Arc::clone(&self.table);
2305            let settle_gate = Arc::clone(&self.settle_gate);
2306            let chain_cache = Arc::clone(&chain_cache);
2307            let chain_reads = Arc::clone(&self.default_branch_chain_reads);
2308            let patch_cache = Arc::clone(&patch_cache);
2309            let patch_reads = Arc::clone(&self.patch_identity_reads);
2310            let patch_scan_bounds = Arc::clone(&self.patch_scan_bounds);
2311            let bound_gates = Arc::clone(&bound_gates);
2312            // Resolved once here and moved into the task, which holds no handle on the
2313            // map itself: a probe signals the gate its own Generation was dispatched
2314            // against, so one still running from an earlier Generation can never signal a
2315            // gate registered after that Generation dispatched.
2316            let held_gate = self.phase_c_gates.lock().unwrap().get(&key).cloned();
2317            // scan: probe-fanout-pool begin -- rayon's global pool, not a dedicated one:
2318            // docs/adr/0013's sweep found the width a dedicated pool would need to pick is
2319            // a broad plateau that the global pool's own free default already sits inside
2320            // at every corpus size tried, and is the only width that stayed competitive
2321            // across idle, fetch-sized and Action-sized concurrent load. A dedicated pool
2322            // would cost a second idle thread pool's worth of memory and startup time to
2323            // land somewhere this measurement found no better than free.
2324            rayon::spawn(move || {
2325                let branch_outcome = probe_branch(&path, repo.as_deref(), kind, &cancel);
2326                let sync_outcome = probe_sync(
2327                    &path,
2328                    repo.as_deref(),
2329                    branch_outcome.as_ref().map(|(settled, ..)| settled),
2330                    kind,
2331                    &cancel,
2332                );
2333                let default_branch_outcome = probe_default_branch_memoised(
2334                    &path,
2335                    repo.as_deref(),
2336                    &common_dir,
2337                    DefaultBranchHints {
2338                        override_branch: override_branch.as_deref(),
2339                        network_branch: network_branch.as_deref(),
2340                    },
2341                    kind,
2342                    &cancel,
2343                    &ChainFactsMemo {
2344                        cache: &chain_cache,
2345                        reads: &chain_reads,
2346                    },
2347                );
2348                let base_outcome = if probes_base {
2349                    probe_base(
2350                        &path,
2351                        repo.as_deref(),
2352                        branch_outcome.as_ref().map(|(settled, ..)| settled),
2353                        default_branch_outcome.as_ref().map(|r| &r.settled),
2354                        &cancel,
2355                    )
2356                } else {
2357                    None
2358                };
2359
2360                // Phases A and B land the moment they answer, per refresh.md's "The
2361                // first frame": every cheap column filled within 200ms, never gated
2362                // on phase C or D's much slower answers below. `default_branch_outcome`
2363                // is cloned here rather than moved, since phase D's landing probe
2364                // below still needs to read it.
2365                apply_cheap_probe_outcomes(
2366                    &table_handle,
2367                    &key,
2368                    generation,
2369                    CheapProbeOutcomes {
2370                        branch: branch_outcome,
2371                        sync: sync_outcome,
2372                        base: base_outcome,
2373                        default_branch: default_branch_outcome.clone(),
2374                    },
2375                );
2376
2377                // Test-only: let a test hold phase C and D open here, after the cheap
2378                // outcomes above are already visible on the table, so the two applies'
2379                // independence can be proven by blocking on a Condvar rather than by racing
2380                // a sleep against a probe.
2381                if let Some(gate) = &held_gate {
2382                    let (lock, cvar) = &**gate;
2383                    let mut state = lock.lock().unwrap();
2384                    state.cheap_landed = true;
2385                    cvar.notify_all();
2386                    state = cvar.wait_while(state, |state| !state.may_proceed).unwrap();
2387                    drop(state);
2388                }
2389
2390                let state_outcome = if probes_state {
2391                    let gate = bound_gates
2392                        .get(&common_dir)
2393                        .expect("every probes_state entity's common dir has a gate sized for it");
2394                    let mut report = GateReport::new(gate);
2395                    let memo = PatchEquivalenceMemo {
2396                        cache: &patch_cache,
2397                        reads: &patch_reads,
2398                        scan_bounds: &patch_scan_bounds,
2399                    };
2400                    probe_worktree_state(
2401                        &path,
2402                        repo.as_deref(),
2403                        default_branch_outcome.as_ref().map(|r| &r.settled),
2404                        &common_dir,
2405                        &cancel,
2406                        &memo,
2407                        &mut report,
2408                    )
2409                } else {
2410                    None
2411                };
2412                let dirty_outcome = probe_status(&path, repo.as_deref(), kind, &cancel);
2413                apply_probe_outcome(
2414                    &table_handle,
2415                    &settle_gate,
2416                    &key,
2417                    generation,
2418                    ProbeOutcomes {
2419                        state: state_outcome,
2420                        dirty: dirty_outcome,
2421                    },
2422                );
2423
2424                // The same handle the cheap gate above blocked on, never a second lookup:
2425                // see where it is resolved.
2426                if let Some(gate) = &held_gate {
2427                    let (lock, cvar) = &**gate;
2428                    let mut state = lock.lock().unwrap();
2429                    state.finished = true;
2430                    cvar.notify_all();
2431                }
2432            });
2433            // scan: probe-fanout-pool end
2434        }
2435    }
2436
2437    /// Re-runs both halves of discovery over `self.set` and reconciles the
2438    /// result into the live table, per [discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md)
2439    /// and [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md).
2440    /// The walk and resolve run outside the table lock, since an abandoned walk
2441    /// can take up to thirty seconds; only reconciling the result briefly holds
2442    /// the write lock. Already-known boundaries reuse their cached repository
2443    /// handle rather than reopening it, which is what keeps re-running discovery
2444    /// every Generation from paying every entity's open cost again.
2445    fn rerun_discovery(&self) {
2446        let repos_cache: HashMap<EntityKey, Arc<gix::ThreadSafeRepository>> =
2447            self.table.read().unwrap().repos.clone();
2448
2449        wait_for_discovery_gate(self.discovery_gate.as_ref());
2450        // The watcher is left detached, as it always has been here: nothing on this
2451        // path reads its handle.
2452        let (watch, _watcher) = spawn_discovery_watcher(
2453            self.set.roots.clone(),
2454            &self.discovery_warning,
2455            self.discovery_warn_after,
2456        );
2457        let discovery = run_watched_discovery(
2458            &watch,
2459            &self.set,
2460            &self.discovery_warning,
2461            Duration::from_nanos(self.discovery_abandon_after.load(Ordering::Acquire)),
2462        );
2463        if discovery.abandoned {
2464            self.discovery_manual.store(true, Ordering::Release);
2465        }
2466
2467        let (discovered, gitmodules_failures) =
2468            discovery::resolve_with_cache(&self.set, &discovery.entities, &repos_cache);
2469
2470        // Copied out before the table lock is taken, never read through it: `set_exclusions`
2471        // takes these two locks in the opposite order, and holding one while asking for the
2472        // other is what would let the two deadlock.
2473        let exclusions = self.exclusions.read().unwrap().clone();
2474        let mut table = self.table.write().unwrap();
2475        table.discovered_at = Timestamp::now();
2476        let cancelled = merge_discovery(&mut table, &exclusions, discovered, gitmodules_failures);
2477        drop(table);
2478        if cancelled > 0 {
2479            complete_many(&self.settle_gate, cancelled);
2480        }
2481    }
2482}
2483
2484impl Drop for Core {
2485    /// Cancels whatever this `Core` still has in flight, then joins the dedicated thread.
2486    ///
2487    /// The cancel is what [`Core::pause`] already does, for the same reason
2488    /// [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
2489    /// "Cancellation" gives: an abandoned Generation is cancelled rather than left to
2490    /// finish, since a Set switch rebuilds the `Core` and the outgoing one's fan-out
2491    /// would otherwise contend for the same cores as the incoming one's. A probe already
2492    /// past its own cancel check still runs to completion on rayon's global pool, which
2493    /// is shared process-wide infrastructure rather than a thread this core spawned, so
2494    /// it is not joined here.
2495    fn drop(&mut self) {
2496        cancel_in_flight(&self.table, &self.settle_gate);
2497        let _ = self.control.send(ClockControl::Shutdown);
2498        if let Some(handle) = self.clock_thread.take() {
2499            let _ = handle.join();
2500        }
2501    }
2502}
2503
2504/// `start_internal`'s result: the running core, plus the three handles a test needs
2505/// to make its threading deterministic instead of sleeping. `Core::start` only
2506/// ever reads `core` out of it; the other three fields exist for
2507/// `Core::start_for_test`.
2508pub(crate) struct StartForTest {
2509    pub core: Core,
2510    #[allow(dead_code)] // read only by tests; the plain lib target never builds them
2511    pub clock_alive: Arc<AtomicBool>,
2512    #[allow(dead_code)] // read only by tests; the plain lib target never builds them
2513    pub discovery_watcher: JoinHandle<()>,
2514    /// The thread the first discovery runs on. Joining it is the rendezvous that
2515    /// says the walk finished and its rows reached the table, with no sleep and no
2516    /// poll anywhere in the wait.
2517    #[allow(dead_code)] // read only by tests; the plain lib target never builds them
2518    pub initial_discovery: Option<JoinHandle<()>>,
2519    /// How many periodic-fetch cycles the clock has taken back and joined, cancelled ones
2520    /// included, which a test keeps a handle on across `Core::drop` the same way it keeps
2521    /// `clock_alive`: a cycle shutdown joined is only observable once the `Core` that owned
2522    /// it is gone.
2523    #[allow(dead_code)] // read only by tests; the plain lib target never builds them
2524    pub fetch_cycles_taken_back: Arc<AtomicUsize>,
2525}
2526
2527#[cfg(test)]
2528impl StartForTest {
2529    /// Blocks until the first discovery has landed on the table, then hands this
2530    /// back so a test reads a populated table rather than the empty one `start`
2531    /// itself returns.
2532    fn discovered(mut self) -> Self {
2533        if let Some(handle) = self.initial_discovery.take() {
2534            handle
2535                .join()
2536                .expect("the first discovery thread should not panic");
2537        }
2538        self
2539    }
2540}
2541
2542impl Core {
2543    /// Puts one already-known entity into the in-flight state a real `refresh`
2544    /// dispatch would, without spawning anything to complete it, so a test can
2545    /// drive the deadline sweep through the tick channel alone and prove the sweep
2546    /// runs on a tick rather than on a clock of its own, or prove that `pause`
2547    /// cancels a real in-flight entry from outside this crate.
2548    ///
2549    /// Gated behind `test-util` (on by default under `cfg(test)` for this crate's own
2550    /// tests) so a test-only affordance never ships on the default published surface,
2551    /// per [ADR 0021](https://github.com/paulchiu/repon/blob/main/docs/adr/0021-a-release-is-what-the-tag-pipeline-publishes.md),
2552    /// the same reason `Timestamp::at` is gated.
2553    #[cfg(any(test, feature = "test-util"))]
2554    pub fn begin_untracked_probe_for_test(&self, key: &EntityKey) -> Arc<AtomicBool> {
2555        let mut table = self.table.write().unwrap();
2556        table.generation += 1;
2557        let generation_number = table.generation;
2558        table
2559            .generation_started_at
2560            .insert(generation_number, Instant::now());
2561        if let Some(&idx) = table.index.get(key) {
2562            begin_probes(&mut table.entities[idx]);
2563        }
2564        let cancel = Arc::new(AtomicBool::new(false));
2565        table.in_flight.insert(
2566            key.clone(),
2567            InFlight {
2568                generation: generation_number,
2569                cancel: Arc::clone(&cancel),
2570            },
2571        );
2572        begin_probes_owed(&self.settle_gate, 1);
2573        cancel
2574    }
2575}
2576
2577/// One simulated in-flight Generation, as [`Core::begin_shared_generation_for_test`]
2578/// left it: the Generation itself, and one interrupt flag per key it covers.
2579#[cfg(test)]
2580pub(crate) struct SharedGeneration {
2581    /// The Generation this simulation minted, so a test can name it and its successor
2582    /// rather than the counter values they happen to hold.
2583    pub generation: Generation,
2584    /// One `cancel` flag per covered key, the same handle a real dispatch would hold.
2585    pub cancels: HashMap<EntityKey, Arc<AtomicBool>>,
2586}
2587
2588#[cfg(test)]
2589impl Core {
2590    /// The cached thread-safe repository handle discovery left for `key`, if any,
2591    /// so a test can prove the cache was actually populated and, by comparing
2592    /// `Arc::ptr_eq` across two reads, that a probe reused it rather than
2593    /// replacing it with a freshly opened one.
2594    pub(crate) fn cached_repo_handle_for_test(
2595        &self,
2596        key: &EntityKey,
2597    ) -> Option<Arc<gix::ThreadSafeRepository>> {
2598        self.table.read().unwrap().repos.get(key).cloned()
2599    }
2600
2601    /// How many times the most recent `refresh` actually computed the
2602    /// default-branch chain's per-common-dir facts, as opposed to reusing an
2603    /// already-computed answer for a common dir another dispatched entity already
2604    /// paid for. What proves the per-common-dir memoisation ran at all: two
2605    /// entities agreeing on their resolved default branch proves nothing on its
2606    /// own, since two distinct common dirs can legitimately agree too.
2607    pub(crate) fn default_branch_chain_reads_for_test(&self) -> usize {
2608        self.default_branch_chain_reads.load(Ordering::Acquire)
2609    }
2610
2611    /// How many times the most recent `refresh` actually scanned a common dir's
2612    /// default-branch commit history for patch equivalence, as opposed to
2613    /// reusing an already-computed scan for a common dir another dispatched
2614    /// entity already paid for. The same proof `default_branch_chain_reads_for_test`
2615    /// gives the default-branch chain, for patch equivalence's own memo.
2616    pub(crate) fn patch_identity_reads_for_test(&self) -> usize {
2617        self.patch_identity_reads.load(Ordering::Acquire)
2618    }
2619
2620    /// The bound each actually-run `scan_default_branch` call this Generation
2621    /// used, one entry per common dir it ran for, in run order. Unlike
2622    /// `patch_identity_reads_for_test`, which only proves a scan ran once per
2623    /// common dir, this proves *what* it was bounded by: the deepest merge base
2624    /// among the dispatched siblings, per `BoundGate::deepest`, rather than
2625    /// whichever entity's own merge base happened to reach the scan first.
2626    pub(crate) fn patch_scan_bounds_for_test(&self) -> Vec<Option<gix::ObjectId>> {
2627        self.patch_scan_bounds.lock().unwrap().clone()
2628    }
2629
2630    /// Every key the most recent `refresh` call's own sequential dispatch loop iterated,
2631    /// in that order: dispatch order, proven directly rather than inferred from completion,
2632    /// which a concurrent pool never guarantees (criterion 5's honest half).
2633    pub(crate) fn dispatch_log_for_test(&self) -> Vec<EntityKey> {
2634        self.dispatch_log.lock().unwrap().clone()
2635    }
2636
2637    /// Runs one metadata-poll sweep synchronously on the calling thread: the exact
2638    /// work the dedicated thread's tick arm performs, called directly so a test can
2639    /// prove the sweep's own effects without racing the injected tick channel's
2640    /// delivery to that other thread.
2641    pub(crate) fn poll_once_for_test(&self) {
2642        run_poll_sweep(
2643            &self.table,
2644            &self.overrides,
2645            &self.show_submodules,
2646            &self.poll_reprobed,
2647            &self.poll_sweep_count,
2648            &self.network_default_branch,
2649        );
2650    }
2651
2652    /// Every key the most recent `poll_once_for_test` call actually re-ran phases A
2653    /// and B for, in the order it found them moved: proves "for that entity only"
2654    /// by naming exactly which entities were touched, not merely that one of them
2655    /// was.
2656    pub(crate) fn poll_reprobed_for_test(&self) -> Vec<EntityKey> {
2657        self.poll_reprobed.lock().unwrap().clone()
2658    }
2659
2660    /// How many metadata-poll sweeps have run in total, so a test driving the real
2661    /// dedicated thread through its injected tick channel can prove a tick reached
2662    /// the sweep at all, not only what the sweep did once it ran.
2663    pub(crate) fn poll_sweep_count_for_test(&self) -> usize {
2664        self.poll_sweep_count.load(Ordering::Acquire)
2665    }
2666
2667    /// This `Core`'s own [`ActionCompletionBoundary`], to arm before the run whose
2668    /// completion a test wants held open. For a test.
2669    #[cfg(test)]
2670    pub(crate) fn action_completion_boundary(&self) -> Arc<ActionCompletionBoundary> {
2671        Arc::clone(&self.action_completion_boundary)
2672    }
2673
2674    /// This `Core`'s own [`FetchBoundary`], to arm before the fetch cycle a test wants held.
2675    #[cfg(test)]
2676    pub(crate) fn fetch_boundary(&self) -> Arc<FetchBoundary> {
2677        Arc::clone(&self.fetch_boundary)
2678    }
2679
2680    /// Registers a closed phase C/D gate for `key`, so the next `refresh` that
2681    /// dispatches it will land its cheap outcomes, then block before touching
2682    /// phase C or D until [`Core::release_phase_c_for_test`] opens the gate.
2683    /// Must be called before the dispatching `refresh`, since a Generation resolves
2684    /// each entity's gate as it dispatches it and its probes signal that one alone.
2685    pub(crate) fn hold_phase_c_for_test(&self, key: &EntityKey) {
2686        self.phase_c_gates.lock().unwrap().insert(
2687            key.clone(),
2688            Arc::new((Mutex::new(PhaseCGate::default()), Condvar::new())),
2689        );
2690    }
2691
2692    /// Blocks the calling thread, with no sleep or poll, until `key`'s cheap
2693    /// outcomes have landed on the table. Panics if `key` has no gate
2694    /// registered, since that means the test forgot [`Core::hold_phase_c_for_test`].
2695    pub(crate) fn wait_phase_c_landed_for_test(&self, key: &EntityKey) {
2696        let gate = self
2697            .phase_c_gates
2698            .lock()
2699            .unwrap()
2700            .get(key)
2701            .cloned()
2702            .expect("hold_phase_c_for_test must be called before waiting on its gate");
2703        let (lock, cvar) = &*gate;
2704        let guard = lock.lock().unwrap();
2705        drop(cvar.wait_while(guard, |state| !state.cheap_landed).unwrap());
2706    }
2707
2708    /// Lets `key`'s held phase C and D proceed. Does not itself wait for them to
2709    /// finish; pair with [`Core::wait_phase_c_finished_for_test`].
2710    pub(crate) fn release_phase_c_for_test(&self, key: &EntityKey) {
2711        let gate = self
2712            .phase_c_gates
2713            .lock()
2714            .unwrap()
2715            .get(key)
2716            .cloned()
2717            .expect("hold_phase_c_for_test must be called before releasing its gate");
2718        let (lock, cvar) = &*gate;
2719        let mut state = lock.lock().unwrap();
2720        state.may_proceed = true;
2721        cvar.notify_all();
2722    }
2723
2724    /// Blocks the calling thread, with no sleep or poll, until `key`'s phase C/D
2725    /// outcome has been applied and the settle gate decremented for it.
2726    pub(crate) fn wait_phase_c_finished_for_test(&self, key: &EntityKey) {
2727        let gate = self
2728            .phase_c_gates
2729            .lock()
2730            .unwrap()
2731            .get(key)
2732            .cloned()
2733            .expect("hold_phase_c_for_test must be called before waiting on its gate");
2734        let (lock, cvar) = &*gate;
2735        let guard = lock.lock().unwrap();
2736        drop(cvar.wait_while(guard, |state| !state.finished).unwrap());
2737    }
2738
2739    /// Blocks the calling thread, with no sleep and no poll, until every Generation
2740    /// reserved so far has finished dispatching: what a test waits on before reading
2741    /// a count a dispatch raises, now that a Generation reserves its number on the
2742    /// calling thread and raises that count on one of its own.
2743    pub(crate) fn wait_dispatched_for_test(&self) {
2744        let (lock, cvar) = &*self.settle_gate;
2745        let guard = lock.lock().unwrap();
2746        drop(
2747            cvar.wait_while(guard, |counts| counts.dispatches > 0)
2748                .unwrap(),
2749        );
2750    }
2751
2752    /// The settle gate's raw outstanding count, so a test can prove a single
2753    /// dispatched entity's split write decrements it exactly once overall,
2754    /// neither twice (an early `settle`) nor zero times (a `settle` that hangs).
2755    pub(crate) fn settle_gate_count_for_test(&self) -> usize {
2756        self.settle_gate.0.lock().unwrap().probes
2757    }
2758
2759    /// `start`, with the tick source and the discovery-slow warning's threshold
2760    /// injected rather than real, so a test drives the dedicated thread's cadence
2761    /// through a channel it controls and never waits out a real second.
2762    pub(crate) fn start_for_test(
2763        spec: CoreSpec,
2764        warn_after: Duration,
2765        ticks: Receiver<Instant>,
2766    ) -> StartForTest {
2767        Self::start_for_test_with_discovery_abandon(
2768            spec,
2769            warn_after,
2770            discovery::ABANDON_AFTER,
2771            ticks,
2772        )
2773    }
2774
2775    /// `start_for_test`, with the discovery abandon deadline also injected, so a
2776    /// test can force a walk to abandon deterministically instead of running one
2777    /// for the real thirty seconds. The periodic fetch is always off here: a test
2778    /// that wants it runs [`Core::start_for_test_with_fetch`] instead, which is
2779    /// what keeps this constructor's own signature free of a feature-gated
2780    /// parameter.
2781    pub(crate) fn start_for_test_with_discovery_abandon(
2782        spec: CoreSpec,
2783        warn_after: Duration,
2784        discovery_abandon_after: Duration,
2785        ticks: Receiver<Instant>,
2786    ) -> StartForTest {
2787        Self::start_for_test_gated(spec, warn_after, discovery_abandon_after, ticks, None)
2788    }
2789
2790    /// `start_for_test_with_discovery_abandon`, with the discovery gate injected: with a
2791    /// closed one, every walk this `Core` starts blocks before it begins, so a caller's own
2792    /// return is observed against a walk that provably has not run.
2793    pub(crate) fn start_for_test_gated(
2794        spec: CoreSpec,
2795        warn_after: Duration,
2796        discovery_abandon_after: Duration,
2797        ticks: Receiver<Instant>,
2798        discovery_gate: Option<DiscoveryGate>,
2799    ) -> StartForTest {
2800        let alive = Arc::new(AtomicBool::new(true));
2801        start_internal(
2802            spec,
2803            warn_after,
2804            discovery_abandon_after,
2805            ticks,
2806            FetchStart {
2807                enabled: false,
2808                concurrency: 1,
2809                ticks: crossbeam_channel::never(),
2810            },
2811            alive,
2812            discovery_gate,
2813        )
2814    }
2815
2816    /// `start_for_test_with_discovery_abandon`, with the periodic fetch's own tick
2817    /// channel injected too, so a test can prove the recurring cadence without
2818    /// waiting out a real `fetch.interval`. `spec.fetch.enabled` still governs
2819    /// whether the immediate first cycle fires; `fetch_ticks` governs every cycle
2820    /// after that.
2821    pub(crate) fn start_for_test_with_fetch(
2822        spec: CoreSpec,
2823        warn_after: Duration,
2824        ticks: Receiver<Instant>,
2825        fetch_ticks: Receiver<Instant>,
2826    ) -> StartForTest {
2827        Self::start_for_test_with_fetch_gated(spec, warn_after, ticks, fetch_ticks, None)
2828    }
2829
2830    /// [`Self::start_for_test_with_fetch`], with the discovery gate injected too: a closed
2831    /// gate holds the first walk, which is what puts a call made on this `Core` provably
2832    /// before the immediate cycle that walk asks for.
2833    pub(crate) fn start_for_test_with_fetch_gated(
2834        spec: CoreSpec,
2835        warn_after: Duration,
2836        ticks: Receiver<Instant>,
2837        fetch_ticks: Receiver<Instant>,
2838        discovery_gate: Option<DiscoveryGate>,
2839    ) -> StartForTest {
2840        let alive = Arc::new(AtomicBool::new(true));
2841        let fetch_start = FetchStart {
2842            enabled: spec.fetch.enabled,
2843            concurrency: spec.fetch.concurrency.max(1),
2844            ticks: fetch_ticks,
2845        };
2846        start_internal(
2847            spec,
2848            warn_after,
2849            discovery::ABANDON_AFTER,
2850            ticks,
2851            fetch_start,
2852            alive,
2853            discovery_gate,
2854        )
2855    }
2856
2857    /// How many periodic-fetch cycles have run in total: the immediate first one
2858    /// plus one per `fetch.interval` tick since, whether or not any repository had
2859    /// a remote to fetch.
2860    pub(crate) fn fetch_cycle_count_for_test(&self) -> usize {
2861        self.fetch_cycle_count.load(Ordering::Acquire)
2862    }
2863
2864    /// Whether an abandoned discovery has already taken this `Core` out of the
2865    /// automatic refresh path, so a test can assert the precondition explicitly
2866    /// rather than infer it from a later refresh's behaviour alone.
2867    /// Tightens the abandon deadline after `start`, so a test can let the first walk
2868    /// finish under a deadline it cannot lose against and still force a later walk to
2869    /// abandon.
2870    #[cfg(test)]
2871    pub(crate) fn set_discovery_abandon_after_for_test(&self, after: Duration) {
2872        self.discovery_abandon_after
2873            .store(after.as_nanos() as u64, Ordering::Release);
2874    }
2875
2876    pub(crate) fn discovery_manual_for_test(&self) -> bool {
2877        self.discovery_manual.load(Ordering::Acquire)
2878    }
2879
2880    /// Puts several already-known entities into the in-flight state of one shared
2881    /// Generation, without spawning anything to complete them and without
2882    /// touching the settle gate, so a test can drive per-entity supersession
2883    /// directly: which keys a later real `refresh` does and does not cover, and
2884    /// what happens to each one's own cancel flag and eventual result.
2885    ///
2886    /// Hands back the Generation it minted rather than only the flags, so the test
2887    /// names that Generation and its successor instead of the counter values they
2888    /// happen to hold.
2889    pub(crate) fn begin_shared_generation_for_test(&self, keys: &[EntityKey]) -> SharedGeneration {
2890        let mut table = self.table.write().unwrap();
2891        table.generation += 1;
2892        let generation_number = table.generation;
2893        table
2894            .generation_started_at
2895            .insert(generation_number, Instant::now());
2896        let mut cancels = HashMap::new();
2897        for key in keys {
2898            if let Some(&idx) = table.index.get(key) {
2899                table.entities[idx].branch.begin_probe();
2900            }
2901            let cancel = Arc::new(AtomicBool::new(false));
2902            table.in_flight.insert(
2903                key.clone(),
2904                InFlight {
2905                    generation: generation_number,
2906                    cancel: Arc::clone(&cancel),
2907                },
2908            );
2909            cancels.insert(key.clone(), cancel);
2910        }
2911        SharedGeneration {
2912            generation: Generation::new(generation_number),
2913            cancels,
2914        }
2915    }
2916
2917    /// Lands one branch probe result for `key` at `generation` through the exact
2918    /// same path a real dispatched probe's cheap outcomes take
2919    /// ([`apply_cheap_probe_outcomes`]), so a test can simulate a result arriving
2920    /// late, out of Generation order, without a second, weaker implementation of
2921    /// the write-time supersession check.
2922    pub(crate) fn apply_probe_result_for_test(
2923        &self,
2924        key: &EntityKey,
2925        generation: Generation,
2926        settled: Settled<Head>,
2927    ) {
2928        apply_cheap_probe_outcomes(
2929            &self.table,
2930            key,
2931            generation,
2932            CheapProbeOutcomes {
2933                branch: Some((settled, None, Vec::new())),
2934                sync: None,
2935                base: None,
2936                default_branch: None,
2937            },
2938        );
2939    }
2940
2941    /// Writes `receipt` directly onto `key`'s `last_action`, bypassing `run_action`
2942    /// entirely: lets a test put an exact, hand-built receipt on a live `Core`'s table
2943    /// without spawning any real child process.
2944    pub(crate) fn set_last_action_for_test(
2945        &self,
2946        key: &EntityKey,
2947        receipt: crate::entity::ActionReceipt,
2948    ) {
2949        let mut table = self.table.write().unwrap();
2950        if let Some(&idx) = table.index.get(key) {
2951            table.entities[idx].last_action = Some(receipt);
2952        }
2953    }
2954}
2955
2956/// One entity's whole Action run: every step in `action.steps`, in order, stopping at
2957/// the first failure, with every step after it recorded `NotRun` rather than silently
2958/// skipped ([config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md)'s
2959/// "Actions", `docs/spec/actions.md`'s "Step outcomes"). Never called for an excluded,
2960/// inapplicable or unresolved entity: [`Core::run_action`] gives those their own `Skip`
2961/// receipt itself and never reaches this function for them.
2962///
2963/// `control` is the same `RunControl` every other entity's run in this fan-out shares:
2964/// checked before every step starts, so a step not yet reached when `control.cancel` fires
2965/// becomes `Cancelled` rather than ever spawning, and again the instant a spawned step's
2966/// `run_step` call returns, so a step that was actually running when cancellation fired
2967/// becomes `Cancelled` regardless of the exit `run_step` itself observed (a signalled child
2968/// has no clean outcome of its own to report). `Cancelled` and `NotRun` are deliberately
2969/// kept apart here: once cancellation is seen, every remaining step (including a step
2970/// already past the "before it starts" check but not yet run) is `Cancelled`, never
2971/// `NotRun`, which stays reserved for being blocked by an earlier failure
2972/// (`docs/spec/actions.md`'s "Step outcomes").
2973///
2974/// `report` is called once per step, immediately before that step starts, with a receipt
2975/// whose `running` names it: the caller writes this straight onto the table, which is what
2976/// lets a still-running step's own label and elapsed time reach a reader before the whole
2977/// entity's run has finished (`docs/spec/actions.md`'s "The run on screen"). The final
2978/// return value is the same shape with `running: None`, the caller's job to write once more.
2979fn run_action_for_entity(
2980    entity: &EntityState,
2981    action: &ActionSpec,
2982    control: &Arc<executor::RunControl>,
2983    report: &dyn Fn(ActionReceipt),
2984) -> ActionReceipt {
2985    let base_env = environment::environment(entity, action.name.as_deref());
2986    let mut failed = false;
2987    let mut cancelled = false;
2988    let mut results: Vec<StepResult> = Vec::with_capacity(action.steps.len());
2989    for step in &action.steps {
2990        if failed || cancelled || control.is_cancelled() {
2991            cancelled = cancelled || control.is_cancelled();
2992            results.push(StepResult {
2993                label: Arc::from(step.argv.join(" ")),
2994                outcome: if cancelled {
2995                    StepOutcome::Cancelled
2996                } else {
2997                    StepOutcome::NotRun
2998                },
2999                output: Arc::from(&b""[..]),
3000                elapsed: Duration::ZERO,
3001                elision: None,
3002                shell: step.shell,
3003                interactive: step.interactive,
3004            });
3005            continue;
3006        }
3007        let label: Arc<str> = Arc::from(step.argv.join(" "));
3008        report(ActionReceipt {
3009            label: Arc::clone(&action.label),
3010            steps: Arc::from(results.clone()),
3011            skip: None,
3012            finished_at: Timestamp::now(),
3013            running: Some(RunningStep {
3014                label: Arc::clone(&label),
3015                started_at: Timestamp::now(),
3016                shell: step.shell,
3017                interactive: step.interactive,
3018            }),
3019        });
3020        // The step's own `env` table is applied after the environment contract's
3021        // set-or-unset pairs, so it overrides the guaranteed set exactly as a
3022        // Launcher's own `env` field already does (`docs/spec/config.md`'s
3023        // "Launchers").
3024        let mut env = base_env.clone();
3025        env.extend(
3026            step.env
3027                .iter()
3028                .map(|(name, value)| (name.clone(), Some(value.clone()))),
3029        );
3030        let mut result = executor::run_step(
3031            &step.argv,
3032            step.shell,
3033            step.interactive,
3034            entity.key.path(),
3035            &env,
3036            control,
3037        );
3038        if control.is_cancelled() {
3039            result.outcome = StepOutcome::Cancelled;
3040            cancelled = true;
3041        } else {
3042            failed = result.outcome.is_failure();
3043        }
3044        results.push(result);
3045    }
3046    ActionReceipt {
3047        label: Arc::clone(&action.label),
3048        steps: Arc::from(results),
3049        skip: None,
3050        finished_at: Timestamp::now(),
3051        running: None,
3052    }
3053}
3054
3055/// A gate a test closes to hold every discovery walk this `Core` starts, at the
3056/// point before the walk begins, so a caller's own return can be observed against a
3057/// walk that provably has not run. `None` on every production path, the same way
3058/// `Core::phase_c_gates` is empty on one.
3059type DiscoveryGate = Arc<(Mutex<bool>, Condvar)>;
3060
3061/// Blocks while `gate` is closed, and returns at once when there is none, which is
3062/// every production path.
3063fn wait_for_discovery_gate(gate: Option<&DiscoveryGate>) {
3064    let Some(gate) = gate else {
3065        return;
3066    };
3067    let (lock, cvar) = &**gate;
3068    let open = lock.lock().unwrap();
3069    drop(cvar.wait_while(open, |open| !*open).unwrap());
3070}
3071
3072/// Opens or closes a [`DiscoveryGate`], waking whatever walk is held on it.
3073#[cfg(test)]
3074fn set_discovery_gate(gate: &DiscoveryGate, open: bool) {
3075    let (lock, cvar) = &**gate;
3076    *lock.lock().unwrap() = open;
3077    cvar.notify_all();
3078}
3079
3080/// What one watched discovery walk and the thread watching it share: the counter
3081/// the walk bumps as it goes, and the flag it sets on finishing.
3082struct DiscoveryWatch {
3083    progress: Arc<AtomicUsize>,
3084    finished: Arc<AtomicBool>,
3085}
3086
3087/// Arms the still-walking watcher for a walk that has not started yet, leaving the
3088/// still-walking warning behind in `discovery_warning` if that walk outruns
3089/// `warn_after`. Separate from [`run_watched_discovery`] so `start_internal` can arm
3090/// it on the calling thread, and hand a test its handle, while the walk it watches
3091/// runs on a thread of its own.
3092fn spawn_discovery_watcher(
3093    roots: Vec<PathBuf>,
3094    discovery_warning: &Arc<Mutex<Option<String>>>,
3095    warn_after: Duration,
3096) -> (DiscoveryWatch, JoinHandle<()>) {
3097    let progress = Arc::new(AtomicUsize::new(0));
3098    let finished = Arc::new(AtomicBool::new(false));
3099    let watcher = thread::spawn({
3100        let progress = Arc::clone(&progress);
3101        let finished = Arc::clone(&finished);
3102        let warning_slot = Arc::clone(discovery_warning);
3103        move || {
3104            if let Some(message) = watch_for_slow_discovery(progress, finished, roots, warn_after) {
3105                *warning_slot.lock().unwrap() = Some(message);
3106            }
3107        }
3108    });
3109    (DiscoveryWatch { progress, finished }, watcher)
3110}
3111
3112/// Runs one discovery boundary walk against `set` under an already-armed `watch`,
3113/// leaving the abandoned-discovery warning in `discovery_warning` if the walk
3114/// abandons past `abandon_after`. Shared by `start_internal`'s first walk and
3115/// `rerun_discovery`'s later ones, so a refresh-triggered abandon runs the same
3116/// wiring `start`'s own walk does, never a parallel copy of it.
3117fn run_watched_discovery(
3118    watch: &DiscoveryWatch,
3119    set: &SetSpec,
3120    discovery_warning: &Arc<Mutex<Option<String>>>,
3121    abandon_after: Duration,
3122) -> discovery::Discovery {
3123    let discovery =
3124        discovery::discover_watched_with_deadline(set, Arc::clone(&watch.progress), abandon_after);
3125    watch.finished.store(true, Ordering::Release);
3126
3127    if discovery.abandoned {
3128        *discovery_warning.lock().unwrap() =
3129            Some(abandoned_discovery_message(discovery.directories_visited));
3130    }
3131
3132    discovery
3133}
3134
3135/// Shared body of `start` and `start_for_test`: builds the empty table, spawns the
3136/// dedicated thread, and starts the first discovery on a thread of its own.
3137fn start_internal(
3138    spec: CoreSpec,
3139    warn_after: Duration,
3140    discovery_abandon_after: Duration,
3141    ticks: Receiver<Instant>,
3142    fetch_start: FetchStart,
3143    alive: Arc<AtomicBool>,
3144    discovery_gate: Option<DiscoveryGate>,
3145) -> StartForTest {
3146    let FetchStart {
3147        enabled: fetch_enabled,
3148        concurrency: fetch_concurrency,
3149        ticks: fetch_ticks,
3150    } = fetch_start;
3151    let discovery_warning = Arc::new(Mutex::new(None));
3152    let discovery_manual = Arc::new(AtomicBool::new(false));
3153
3154    let (overrides, resolved_exclusions) = resolve_entries(&spec.overrides);
3155    let overrides = Arc::new(overrides);
3156    let exclusions = Arc::new(RwLock::new(resolved_exclusions));
3157    let show_submodules = Arc::new(AtomicBool::new(spec.show_submodules));
3158
3159    let table = Arc::new(RwLock::new(Table {
3160        generation: 0,
3161        discovered_at: Timestamp::now(),
3162        entities: Vec::new(),
3163        index: HashMap::new(),
3164        in_flight: HashMap::new(),
3165        generation_started_at: HashMap::new(),
3166        repos: HashMap::new(),
3167        poll_fingerprints: HashMap::new(),
3168    }));
3169
3170    let settle_gate: Arc<SettleGate> =
3171        Arc::new((Mutex::new(SettleCounts::default()), Condvar::new()));
3172    let poll_reprobed = Arc::new(Mutex::new(Vec::new()));
3173    let poll_sweep_count = Arc::new(AtomicUsize::new(0));
3174    let network_default_branch = Arc::new(Mutex::new(HashMap::new()));
3175    let (control, control_rx) = crossbeam_channel::unbounded();
3176    let poll_handles = PollHandles {
3177        overrides: Arc::clone(&overrides),
3178        show_submodules: Arc::clone(&show_submodules),
3179        poll_reprobed: Arc::clone(&poll_reprobed),
3180        poll_sweep_count: Arc::clone(&poll_sweep_count),
3181        network_default_branch: Arc::clone(&network_default_branch),
3182    };
3183
3184    // Hoisted out of the `Core` struct literal below, rather than built inline
3185    // there as before this field existed: `RefreshHandles` needs its own clone of
3186    // each of these, constructed before `Core` takes ownership of the originals.
3187    let discovery_abandon_after_atomic =
3188        Arc::new(AtomicU64::new(discovery_abandon_after.as_nanos() as u64));
3189    let default_branch_chain_reads = Arc::new(AtomicUsize::new(0));
3190    let patch_identity_reads = Arc::new(AtomicUsize::new(0));
3191    let patch_scan_bounds = Arc::new(Mutex::new(Vec::new()));
3192    let dispatch_log = Arc::new(Mutex::new(Vec::new()));
3193    let phase_c_gates = Arc::new(Mutex::new(HashMap::new()));
3194    let fetch_cycle_count = Arc::new(AtomicUsize::new(0));
3195    let fetch_failures = Arc::new(Mutex::new(FetchFailures::default()));
3196    let fetch_cycles_taken_back = Arc::new(AtomicUsize::new(0));
3197    let fetch_running = Arc::new(AtomicBool::new(false));
3198    let (fetch_finished_tx, fetch_finished_rx) = crossbeam_channel::unbounded();
3199    #[cfg(test)]
3200    let fetch_boundary = Arc::new(FetchBoundary::default());
3201    let turnstile = Arc::new(DispatchTurnstile::default());
3202
3203    let fetch_refresh_handles = RefreshHandles {
3204        table: Arc::clone(&table),
3205        overrides: Arc::clone(&overrides),
3206        exclusions: Arc::clone(&exclusions),
3207        set: spec.set.clone(),
3208        discovery_manual: Arc::clone(&discovery_manual),
3209        discovery_warn_after: warn_after,
3210        discovery_abandon_after: Arc::clone(&discovery_abandon_after_atomic),
3211        discovery_warning: Arc::clone(&discovery_warning),
3212        show_submodules: Arc::clone(&show_submodules),
3213        settle_gate: Arc::clone(&settle_gate),
3214        default_branch_chain_reads: Arc::clone(&default_branch_chain_reads),
3215        patch_identity_reads: Arc::clone(&patch_identity_reads),
3216        patch_scan_bounds: Arc::clone(&patch_scan_bounds),
3217        dispatch_log: Arc::clone(&dispatch_log),
3218        phase_c_gates: Arc::clone(&phase_c_gates),
3219        network_default_branch: Arc::clone(&network_default_branch),
3220        turnstile: Arc::clone(&turnstile),
3221        discovery_gate: discovery_gate.clone(),
3222    };
3223    let auto_update_enabled = spec.auto_update.enabled;
3224    let fetch_schedule = FetchSchedule {
3225        concurrency: fetch_concurrency,
3226        ticks: fetch_ticks,
3227        refresh: fetch_refresh_handles.clone(),
3228        cycle_count: Arc::clone(&fetch_cycle_count),
3229        failures: Arc::clone(&fetch_failures),
3230        auto_update_enabled,
3231        finished: fetch_finished_rx,
3232        finished_tx: fetch_finished_tx,
3233        taken_back_count: Arc::clone(&fetch_cycles_taken_back),
3234        running: Arc::clone(&fetch_running),
3235        #[cfg(test)]
3236        boundary: Arc::clone(&fetch_boundary),
3237    };
3238
3239    let clock_thread = spawn_clock_thread(
3240        Arc::clone(&table),
3241        poll_handles,
3242        fetch_schedule,
3243        Arc::clone(&settle_gate),
3244        spec.generation_deadline,
3245        ClockChannels {
3246            control: control_rx,
3247            ticks,
3248            alive: Arc::clone(&alive),
3249        },
3250    );
3251
3252    // Discovery runs here rather than on the calling thread, so `Core::start`
3253    // returns against the empty table above and the consumer can claim the terminal
3254    // and draw before the walk has finished (ADR 0015's "a constructor that spawns
3255    // threads is not a surprise"). This walk is also refresh.md's "Startup"
3256    // Generation, so a launch walks the tree once: the number and the turnstile place
3257    // are reserved here on the calling thread, exactly as every later Generation
3258    // reserves its own, and the walk and the fan-out it orders both run on the
3259    // spawned thread. The debt is recorded before the spawn, so a `settle` called in
3260    // between waits for this Generation rather than returning on an empty table.
3261    let (startup_generation, startup_ticket) = fetch_refresh_handles.reserve_generation();
3262    begin_dispatch(&settle_gate);
3263    let (watch, discovery_watcher) =
3264        spawn_discovery_watcher(spec.set.roots.clone(), &discovery_warning, warn_after);
3265    let initial_discovery = thread::spawn({
3266        let set = spec.set.clone();
3267        let discovery_warning = Arc::clone(&discovery_warning);
3268        let discovery_manual = Arc::clone(&discovery_manual);
3269        let exclusions = Arc::clone(&exclusions);
3270        let table = Arc::clone(&table);
3271        let settle_gate = Arc::clone(&settle_gate);
3272        let fetch_refresh_handles = fetch_refresh_handles.clone();
3273        let control = control.clone();
3274        let discovery_gate = discovery_gate.clone();
3275        move || {
3276            let turn = fetch_refresh_handles.turnstile.take(startup_ticket);
3277            wait_for_discovery_gate(discovery_gate.as_ref());
3278            let discovery =
3279                run_watched_discovery(&watch, &set, &discovery_warning, discovery_abandon_after);
3280            if discovery.abandoned {
3281                discovery_manual.store(true, Ordering::Release);
3282            }
3283
3284            // Discovery's second half: every boundary the walk just found becomes a
3285            // Repo or a Worktree, and each one's own `.gitmodules` (never recursed
3286            // into) names its Submodules. One combined list, with nothing recording
3287            // which half produced a given entry.
3288            let (discovered, gitmodules_failures) = discovery::resolve(&set, &discovery.entities);
3289            let resolved_exclusions = exclusions.read().unwrap().clone();
3290            let order: Vec<EntityKey> = {
3291                let mut table = table.write().unwrap();
3292                // A fresh table has nothing in flight yet, so nothing here is ever
3293                // cancelled: the same reconciliation `refresh` uses later, run once
3294                // against an empty starting point.
3295                merge_discovery(
3296                    &mut table,
3297                    &resolved_exclusions,
3298                    discovered,
3299                    gitmodules_failures,
3300                );
3301                table.discovered_at = Timestamp::now();
3302                table
3303                    .entities
3304                    .iter()
3305                    .map(|entity| entity.key.clone())
3306                    .collect()
3307            };
3308            // Read off the table this walk just reconciled, the same way
3309            // `dispatch_over_everything` resolves its own order: nobody holding the
3310            // empty table `start` returned has a key to name yet.
3311            fetch_refresh_handles.dispatch_probes(&order, startup_generation);
3312            finish_dispatch(&settle_gate);
3313            // Released here rather than at thread exit: the first fetch cycle spawned
3314            // below is not part of this Generation's body.
3315            drop(turn);
3316
3317            // "Fires immediately on being enabled rather than waiting for the first
3318            // tick" ([refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
3319            // "The periodic fetch"): the recurring cadence only ever fires after a full
3320            // `fetch.interval` has elapsed, so the first cycle is asked for here, once.
3321            // Asked for rather than run, so the clock owns this cycle exactly as it owns
3322            // every later one. From inside this thread rather than beside it, because a
3323            // cycle reads the table to know what to fetch and the walk above is what puts
3324            // anything in it.
3325            if fetch_enabled {
3326                let _ = control.send(ClockControl::FetchOwed);
3327            }
3328        }
3329    });
3330
3331    StartForTest {
3332        core: Core {
3333            table,
3334            overrides,
3335            exclusions,
3336            set: spec.set,
3337            discovery_manual,
3338            discovery_warn_after: warn_after,
3339            discovery_abandon_after: discovery_abandon_after_atomic,
3340            show_submodules,
3341            settle_gate,
3342            control,
3343            clock_thread: Some(clock_thread),
3344            discovery_warning,
3345            default_branch_chain_reads,
3346            patch_identity_reads,
3347            patch_scan_bounds,
3348            action_lifecycle: Arc::new(Mutex::new(ActionLifecycle::default())),
3349            dispatch_log,
3350            phase_c_gates,
3351            status_stale_after: spec.status_stale_after,
3352            poll_reprobed,
3353            poll_sweep_count,
3354            fetch_cycle_count,
3355            network_default_branch,
3356            fetch_failures,
3357            fetch_running,
3358            turnstile,
3359            discovery_gate,
3360            #[cfg(test)]
3361            action_completion_boundary: Arc::new(ActionCompletionBoundary::default()),
3362            #[cfg(test)]
3363            fetch_boundary: Arc::clone(&fetch_boundary),
3364        },
3365        clock_alive: alive,
3366        discovery_watcher,
3367        initial_discovery: Some(initial_discovery),
3368        fetch_cycles_taken_back,
3369    }
3370}
3371
3372/// Everything the dedicated thread's tick arm needs for [`run_poll_sweep`] beyond
3373/// the table it already takes, bundled so `spawn_clock_thread` stays within
3374/// clippy's argument limit.
3375struct PollHandles {
3376    overrides: Arc<Vec<ResolvedOverride>>,
3377    show_submodules: Arc<AtomicBool>,
3378    poll_reprobed: Arc<Mutex<Vec<EntityKey>>>,
3379    poll_sweep_count: Arc<AtomicUsize>,
3380    /// [`Core::network_default_branch`]'s own clone, so a poll-triggered re-probe
3381    /// still reflects an already-superseded default branch rather than reverting
3382    /// to the local chain's own answer until the next full refresh.
3383    network_default_branch: Arc<Mutex<HashMap<PathBuf, Arc<str>>>>,
3384}
3385
3386/// What [`start_internal`] needs from `CoreSpec::fetch` to schedule the periodic fetch,
3387/// bundled into one argument rather than three so this crate's own `clippy::too_many_arguments`
3388/// budget has room for it: extracted once at each of `Core::start`'s two callers.
3389struct FetchStart {
3390    enabled: bool,
3391    concurrency: usize,
3392    ticks: Receiver<Instant>,
3393}
3394
3395/// The periodic fetch's own scheduling inputs, threaded through [`start_internal`]
3396/// and [`spawn_clock_thread`] as plain values rather than reading `CoreSpec::fetch`
3397/// directly: extracted once at each of the two callers. Carries no `enabled` flag
3398/// of its own: `ticks` is [`crossbeam_channel::never`] whenever the periodic fetch
3399/// is off, so the arm that reads it simply never fires, the same way the poll's
3400/// own `ticks` does when a test has no interest in it.
3401struct FetchSchedule {
3402    concurrency: usize,
3403    ticks: Receiver<Instant>,
3404    refresh: RefreshHandles,
3405    cycle_count: Arc<AtomicUsize>,
3406    failures: Arc<Mutex<FetchFailures>>,
3407    /// `CoreSpec::auto_update`'s own `enabled` flag, read once at `start` like every
3408    /// other field on [`FetchSchedule`]: the fast-forward-only update carries no
3409    /// interval of its own, so there is no separate tick to gate it on, only this.
3410    auto_update_enabled: bool,
3411    /// A cycle's own worker sends `()` here as its last act; the clock's arm on `finished` is
3412    /// what takes that cycle back, joins its worker and dispatches the Generation it owes.
3413    /// The clock holds `finished_tx` as well, so this arm only ever fires on a real
3414    /// completion.
3415    finished: Receiver<()>,
3416    finished_tx: Sender<()>,
3417    /// How many cycles the clock has taken back and joined, which is what lets a test
3418    /// observe a cycle's own end rather than infer it.
3419    taken_back_count: Arc<AtomicUsize>,
3420    /// See [`Core::fetch_running`].
3421    running: Arc<AtomicBool>,
3422    /// See [`FetchBoundary`]. Disarmed unless a test arms it, and off the default build
3423    /// entirely.
3424    #[cfg(test)]
3425    boundary: Arc<FetchBoundary>,
3426}
3427
3428/// The dedicated thread's own control-plane wiring, bundled into one argument so
3429/// [`spawn_clock_thread`] stays within clippy's argument limit: `control` is the
3430/// pause/resume/shutdown channel every `Core` method sends into, `ticks` drives the
3431/// poll and deadline sweep, and `alive` is the flag the thread clears on its way out
3432/// (both for a test to observe and for nothing else, since `Drop` joins the handle
3433/// directly rather than polling this).
3434struct ClockChannels {
3435    control: Receiver<ClockControl>,
3436    ticks: Receiver<Instant>,
3437    alive: Arc<AtomicBool>,
3438}
3439
3440/// The dedicated thread: the metadata poll tick, the Generation deadline sweep and
3441/// the periodic fetch's own tick share this one interval loop, separate from the
3442/// probe pool and from any render loop, so suspending the terminal reschedules
3443/// none of it. Driven by `ticks` and `fetch.ticks` rather than a bare
3444/// `thread::sleep`, which is what a test replaces to make the cadence
3445/// deterministic. The poll and deadline sweep run first on every `ticks` tick,
3446/// both while `!paused`; a fetch cycle starts on every `fetch.ticks` tick, also only
3447/// while `!paused`, so a suspended Repon neither sweeps nor fetches while the user
3448/// is in a Launcher.
3449///
3450/// A cycle runs on a worker of its own rather than here, so a fetch waiting on a remote
3451/// stalls none of the above. This loop is the cycle's owner for as long as it runs: it starts
3452/// at most one at a time, holds the immediate cycle enabling the fetch owes until it can
3453/// start it, cancels the live one on pause and on the way out, and takes it back on the
3454/// completion message the worker sends. Everything a cycle owes the table beyond its
3455/// own fetches, the Generation above all, is dispatched from here rather than from the
3456/// worker, so a cancelled cycle cannot land anything the lifecycle has already moved past.
3457fn spawn_clock_thread(
3458    table: Arc<RwLock<Table>>,
3459    poll: PollHandles,
3460    fetch: FetchSchedule,
3461    settle_gate: Arc<SettleGate>,
3462    generation_deadline: Duration,
3463    channels: ClockChannels,
3464) -> JoinHandle<()> {
3465    let ClockChannels {
3466        control,
3467        ticks,
3468        alive,
3469    } = channels;
3470    thread::spawn(move || {
3471        let mut paused = false;
3472        let mut cycle: Option<FetchCycle> = None;
3473        let mut immediate_cycle_owed = false;
3474        loop {
3475            select! {
3476                recv(control) -> message => match message {
3477                    Ok(ClockControl::Pause) => {
3478                        paused = true;
3479                        cancel_in_flight(&table, &settle_gate);
3480                        if let Some(cycle) = &cycle {
3481                            cycle.cancel();
3482                        }
3483                    }
3484                    Ok(ClockControl::Resume) => paused = false,
3485                    Ok(ClockControl::FetchOwed) => immediate_cycle_owed = true,
3486                    Ok(ClockControl::FetchNow) => {
3487                        if !paused && cycle.is_none() {
3488                            cycle = Some(start_fetch_cycle(&table, &fetch));
3489                        }
3490                    }
3491                    Ok(ClockControl::Shutdown) | Err(_) => break,
3492                },
3493                recv(ticks) -> tick => {
3494                    if tick.is_err() {
3495                        break;
3496                    }
3497                    if !paused {
3498                        run_poll_sweep(
3499                            &table,
3500                            &poll.overrides,
3501                            &poll.show_submodules,
3502                            &poll.poll_reprobed,
3503                            &poll.poll_sweep_count,
3504                            &poll.network_default_branch,
3505                        );
3506                        sweep_deadline(&table, &settle_gate, generation_deadline);
3507                    }
3508                }
3509                recv(fetch.ticks) -> tick => {
3510                    if tick.is_err() {
3511                        break;
3512                    }
3513                    // Refused rather than queued while one is live, the same choice
3514                    // `Core::run_action` already makes for a second fan-out: two cycles over
3515                    // the same population would fetch and auto-update the same repositories
3516                    // at once.
3517                    if !paused && cycle.is_none() {
3518                        cycle = Some(start_fetch_cycle(&table, &fetch));
3519                    }
3520                }
3521                recv(fetch.finished) -> _ => {
3522                    if let Some(finished) = cycle.take() {
3523                        let cancelled = finished.cancelled();
3524                        finished.join();
3525                        if !cancelled {
3526                            dispatch_fetch_completion(&table, &fetch.refresh);
3527                        }
3528                        fetch.taken_back_count.fetch_add(1, Ordering::Release);
3529                        fetch.running.store(false, Ordering::Release);
3530                    }
3531                }
3532            }
3533            // Started here rather than in the arm that asked for it, so a pause or a live
3534            // cycle delays the immediate cycle rather than losing it.
3535            if immediate_cycle_owed && !paused && cycle.is_none() {
3536                immediate_cycle_owed = false;
3537                cycle = Some(start_fetch_cycle(&table, &fetch));
3538            }
3539        }
3540        // Shutdown waits the cycle out rather than detaching it, so no worker is still
3541        // fetching or fast-forwarding once `Core::drop` returns; the wait is only as short as
3542        // [`FetchCycle::cancel`] can make it. The Generation it would have owed is not
3543        // dispatched, since the table it would write to is going away with this `Core`.
3544        if let Some(cycle) = cycle.take() {
3545            cycle.cancel();
3546            cycle.join();
3547            fetch.taken_back_count.fetch_add(1, Ordering::Release);
3548            fetch.running.store(false, Ordering::Release);
3549        }
3550        alive.store(false, Ordering::Release);
3551    })
3552}
3553
3554/// The periodic-fetch cycle running right now, owned by the clock for as long as it runs:
3555/// the worker doing the fetching, and the one flag every fetch in that cycle was handed.
3556///
3557/// Owned rather than detached so the clock can end a cycle it has moved past and know that it
3558/// has: [`Self::cancel`] is what pause and shutdown reach for, and [`Self::join`] is what
3559/// makes shutdown's own answer honest.
3560struct FetchCycle {
3561    cancel: Arc<AtomicBool>,
3562    worker: JoinHandle<()>,
3563    /// See [`FetchBoundary`].
3564    #[cfg(test)]
3565    boundary: Arc<FetchBoundary>,
3566}
3567
3568impl FetchCycle {
3569    /// Ends this cycle: no further repository is fetched, one already in its receive stage
3570    /// unwinds, and neither the auto-update nor the completion Generation runs.
3571    ///
3572    /// It is not a bound on a fetch already connecting or preparing: gix takes a cancellation
3573    /// flag at [`gix::remote::fetch::Prepare::receive`] and nowhere earlier, which
3574    /// [`crate::fetch::fetch_and_prune`]'s own doc comment records in full.
3575    fn cancel(&self) {
3576        self.cancel.store(true, Ordering::Release);
3577        #[cfg(test)]
3578        self.boundary.cancelled();
3579    }
3580
3581    fn cancelled(&self) -> bool {
3582        self.cancel.load(Ordering::Acquire)
3583    }
3584
3585    /// Waits for this cycle's own worker to stop.
3586    fn join(self) {
3587        let _ = self.worker.join();
3588    }
3589}
3590
3591/// Starts one cycle on a worker of its own, which sends `()` on `fetch.finished` as its last
3592/// act however the cycle itself ended.
3593///
3594/// The send is what the clock waits for before joining that worker, so it happens past a
3595/// panicked cycle too, caught here for the reason `Core::run_action`'s own fan-out catches
3596/// one: without it a poisoned lock from an unrelated earlier panic would leave this `Core`
3597/// unable to ever start another cycle.
3598fn start_fetch_cycle(table: &Arc<RwLock<Table>>, fetch: &FetchSchedule) -> FetchCycle {
3599    fetch.running.store(true, Ordering::Release);
3600    let cancel = Arc::new(AtomicBool::new(false));
3601    let work = FetchCycleWork {
3602        table: Arc::clone(table),
3603        concurrency: fetch.concurrency,
3604        cancel: Arc::clone(&cancel),
3605        network_default_branch: Arc::clone(&fetch.refresh.network_default_branch),
3606        cycle_count: Arc::clone(&fetch.cycle_count),
3607        failures: Arc::clone(&fetch.failures),
3608        auto_update_enabled: fetch.auto_update_enabled,
3609        #[cfg(test)]
3610        boundary: Arc::clone(&fetch.boundary),
3611    };
3612    #[cfg(test)]
3613    let boundary = Arc::clone(&work.boundary);
3614    let finished = fetch.finished_tx.clone();
3615    let worker = thread::spawn(move || {
3616        let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
3617            run_fetch_cycle(&work);
3618        }));
3619        let _ = finished.send(());
3620    });
3621    FetchCycle {
3622        cancel,
3623        worker,
3624        #[cfg(test)]
3625        boundary,
3626    }
3627}
3628
3629/// The one normal Generation a finished cycle owes
3630/// ([refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s "The
3631/// periodic fetch": "a finished fetch starts a normal generation"), over every entity the
3632/// table now knows rather than only the ones that cycle fetched.
3633fn dispatch_fetch_completion(table: &Arc<RwLock<Table>>, refresh: &RefreshHandles) {
3634    let all_keys: Vec<EntityKey> = table
3635        .read()
3636        .unwrap()
3637        .entities
3638        .iter()
3639        .map(|entity| entity.key.clone())
3640        .collect();
3641    refresh.dispatch(&all_keys);
3642}
3643
3644/// One periodic-fetch cycle's own inputs, cloned out of [`FetchSchedule`] when a cycle
3645/// starts: `cancel` is the one flag every fetch in this cycle is handed, so whoever owns the
3646/// cycle can end all of them at once. Carries the network default branch map alone and never
3647/// the whole [`RefreshHandles`], since dispatching the Generation is the clock's to fence.
3648struct FetchCycleWork {
3649    table: Arc<RwLock<Table>>,
3650    concurrency: usize,
3651    cancel: Arc<AtomicBool>,
3652    network_default_branch: Arc<Mutex<HashMap<PathBuf, Arc<str>>>>,
3653    cycle_count: Arc<AtomicUsize>,
3654    failures: Arc<Mutex<FetchFailures>>,
3655    auto_update_enabled: bool,
3656    /// See [`FetchBoundary`]. Disarmed unless a test arms it, and off the default build
3657    /// entirely.
3658    #[cfg(test)]
3659    boundary: Arc<FetchBoundary>,
3660}
3661
3662/// One periodic-fetch cycle: every distinct git common dir this table currently
3663/// knows, not excluded, fetched with pruning, bounded to `concurrency` at once, then the
3664/// fast-forward-only auto-update over what that fetch just learned. The Generation a
3665/// finished cycle owes is [`dispatch_fetch_completion`]'s, back on the clock, so a cancelled
3666/// cycle cannot land one. `cycle_count` counts every
3667/// call, whether or not any repository had a remote to fetch, so a test driving
3668/// the dedicated thread's own tick channel can prove a tick reached this function
3669/// at all, the same proof [`Core::poll_sweep_count_for_test`] gives the poll.
3670///
3671/// Two things worth recording beside this scheduler rather than only in
3672/// [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md):
3673/// `Gone` is systematically under-reported without this cycle running, because a
3674/// remote-tracking ref only disappears once a prune removes it
3675/// ([`crate::landing`]'s `classify_unmerged_branch` doc comment), so a Repo with
3676/// `fetch.enabled = false` can carry a stale upstream indefinitely and never show
3677/// it. And the cadence itself is unresolved: `fetch.interval`'s default of five
3678/// minutes is [config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md)'s
3679/// stated number, not one this crate has measured against a real population the
3680/// way the poll interval and the generation deadline were.
3681fn run_fetch_cycle(work: &FetchCycleWork) {
3682    let FetchCycleWork {
3683        table,
3684        concurrency,
3685        cancel,
3686        network_default_branch,
3687        cycle_count,
3688        failures,
3689        auto_update_enabled,
3690        #[cfg(test)]
3691        boundary,
3692    } = work;
3693    let auto_update_enabled = *auto_update_enabled;
3694    cycle_count.fetch_add(1, Ordering::Release);
3695
3696    let common_dirs = distinct_fetchable_common_dirs(table);
3697    let failed: Mutex<Vec<(PathBuf, String)>> = Mutex::new(Vec::new());
3698    crate::fetch::run_bounded(common_dirs, (*concurrency).max(1), |common_dir| {
3699        // Nothing parks here unless a test armed this boundary.
3700        #[cfg(test)]
3701        boundary.hold();
3702        // A cancelled cycle starts no more work: the repositories this pool has not reached
3703        // yet are simply not fetched.
3704        if cancel.load(Ordering::Acquire) {
3705            return;
3706        }
3707        // Every repository's own fetch result is independent: one credential
3708        // failure or one unreachable remote must never stop the rest of the
3709        // cycle from running, so a per-repository error is swallowed here
3710        // rather than aborting the whole cycle. It is still counted below,
3711        // which is the count this cycle's own [`FetchFailures`] carries.
3712        match crate::fetch::fetch_and_prune(&common_dir, cancel) {
3713            Ok(outcome) => {
3714                // The handshake this fetch already paid for is what
3715                // [default-branch.md](https://github.com/paulchiu/repon/blob/main/docs/spec/default-branch.md)'s
3716                // "The network" means by "arrives inside a round trip already being
3717                // paid for": landed here, before `refresh.dispatch` below re-runs
3718                // the local chain, so the local answer always computes first and
3719                // this only ever supersedes it. `Unborn` and a missing answer both
3720                // leave any earlier session answer for this common dir untouched,
3721                // since neither is itself a fact worth overwriting one with.
3722                if let Some(crate::fetch::AdvertisedDefaultBranch::Branch(name)) =
3723                    outcome.advertised_default_branch
3724                {
3725                    network_default_branch
3726                        .lock()
3727                        .unwrap()
3728                        .insert(common_dir.clone(), Arc::from(name));
3729                }
3730            }
3731            Err(error) => {
3732                failed
3733                    .lock()
3734                    .unwrap()
3735                    .push((common_dir.clone(), error.to_string()));
3736            }
3737        }
3738    });
3739    // A cancelled cycle never completed, so what it reached is not
3740    // [`FetchFailures`]'s "most recently completed cycle": the previous cycle's own
3741    // count stands rather than being replaced by a partial one.
3742    if !cancel.load(Ordering::Acquire) {
3743        *failures.lock().unwrap() = FetchFailures {
3744            failed: failed.into_inner().unwrap(),
3745        };
3746    }
3747
3748    // The fast-forward-only auto-update rides this cycle rather than a timer of its
3749    // own, per `docs/spec/config.md`'s "Refresh, fetch and auto-update": it can only
3750    // ever act on what the fetch just above learned, so it runs here, after every
3751    // fetch has settled and before the Generation the clock dispatches reports the
3752    // result. Sequential rather than `fetch::run_bounded`'s own concurrency, since this
3753    // is a mutating pass over a Repo's own working tree and index, not a read against a
3754    // remote: ADR 0002's narrowest-safe-operation rule favours a simple, serial pass
3755    // over throughput a mutation has no need of.
3756    if auto_update_enabled {
3757        for repo_path in repos_eligible_for_auto_update_attempt(table) {
3758            // Re-read per Repo, not once: this is the mutating half of the cycle, so a
3759            // cancellation arriving partway through it stops the next Repo from being
3760            // written to at all.
3761            if cancel.load(Ordering::Acquire) {
3762                break;
3763            }
3764            // One Repo's ineligibility or failure never stops another's: the same
3765            // independence the fetch loop above already gives each repository.
3766            let _ = crate::auto_update::attempt(&repo_path);
3767        }
3768    }
3769}
3770
3771/// Every non-excluded Repo's own working directory, one per distinct common dir the
3772/// table currently knows: the auto-update acts on a Repo's own row, per
3773/// `docs/spec/config.md`'s "acts only on a Repo", so a Worktree sharing that common
3774/// dir is never a candidate here even though it is `distinct_fetchable_common_dirs`'s
3775/// own definition of "fetchable" for the read-only fetch above. Listed, never
3776/// operated on, mirrors the same `excluded` rule the fetch loop's own common-dir
3777/// filter applies, checked here against the Repo entity's own flag rather than any
3778/// Worktree that happens to share its common dir.
3779fn repos_eligible_for_auto_update_attempt(table: &Arc<RwLock<Table>>) -> Vec<PathBuf> {
3780    table
3781        .read()
3782        .unwrap()
3783        .entities
3784        .iter()
3785        .filter(|entity| entity.kind == Kind::Repo && !entity.excluded)
3786        .map(|entity| entity.key.path().to_path_buf())
3787        .collect()
3788}
3789
3790/// Every distinct git common dir a fetch cycle should fetch: deduplicated across
3791/// every entity sharing one (a Repo and its linked Worktrees), and skipped only
3792/// when every entity sharing that common dir is excluded
3793/// ([config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md#per-repo-entries)'s
3794/// "listed, never operated on"), since a Worktree named directly by its own path
3795/// can carry a different `excluded` than an entry it would otherwise inherit.
3796fn distinct_fetchable_common_dirs(table: &Arc<RwLock<Table>>) -> Vec<PathBuf> {
3797    let table = table.read().unwrap();
3798    let mut seen: HashMap<PathBuf, bool> = HashMap::new();
3799    for entity in &table.entities {
3800        let common_dir = entity.common_dir.to_path_buf();
3801        let operable = seen.entry(common_dir).or_insert(false);
3802        *operable = *operable || !entity.excluded;
3803    }
3804    seen.into_iter()
3805        .filter(|(_, operable)| *operable)
3806        .map(|(common_dir, _)| common_dir)
3807        .collect()
3808}
3809
3810/// [`Core::rederive_default_branches`]'s own network half: a handshake-only probe
3811/// per `common_dir`, landing a `Branch` answer on `network_default_branch` for
3812/// [`supersede_with_network`] to read back. `Unborn` and a probe failure both
3813/// leave any earlier session answer for that common dir untouched, the same
3814/// convention [`run_fetch_cycle`] already follows.
3815fn probe_network_default_branches(
3816    common_dirs: &HashSet<Arc<Path>>,
3817    network_default_branch: &Mutex<HashMap<PathBuf, Arc<str>>>,
3818) {
3819    for common_dir in common_dirs {
3820        if let Ok(Some(crate::fetch::AdvertisedDefaultBranch::Branch(name))) =
3821            crate::fetch::probe_remote_head(common_dir)
3822        {
3823            network_default_branch
3824                .lock()
3825                .unwrap()
3826                .insert(common_dir.to_path_buf(), Arc::from(name));
3827        }
3828    }
3829}
3830
3831/// One entity [`Core::rederive_default_branches`] gathered under the table lock,
3832/// everything its own spawned thread needs to re-run the default-branch chain
3833/// without holding that lock while it does: a plain struct rather than a tuple,
3834/// per this crate's own `clippy::type_complexity` budget.
3835struct RederiveCandidate {
3836    key: EntityKey,
3837    path: PathBuf,
3838    common_dir: Arc<Path>,
3839    repo: Option<Arc<gix::ThreadSafeRepository>>,
3840    override_branch: Option<String>,
3841    kind: Kind,
3842}
3843
3844/// One entity as the metadata poll sweep found it, everything gathered under one
3845/// read lock so the filesystem stats and any re-probe below run outside it.
3846struct PollCandidate {
3847    key: EntityKey,
3848    path: PathBuf,
3849    common_dir: Arc<Path>,
3850    kind: Kind,
3851    cached_repo: Option<Arc<gix::ThreadSafeRepository>>,
3852    probes_base: bool,
3853}
3854
3855/// One metadata-poll sweep ([refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
3856/// "The poll"): for every entity a Generation's dispatch would also cover (a
3857/// hidden Submodule is skipped by the same [`dispatches_kind`] rule), stats
3858/// [`poll::POLLED_GITDIR_ENTRIES`] in its own gitdir. That gitdir is the cached
3859/// [`gix::ThreadSafeRepository`] handle's own `git_dir()` where discovery cached
3860/// one (the per-worktree location a linked Worktree's `HEAD` and `index` actually
3861/// live at), or else a fresh open's `git_dir()`, the same fallback every other
3862/// probe in this module already takes for a Submodule, which discovery never
3863/// opens. A first sweep for a newly discovered entity has nothing to compare
3864/// against yet, so it only records a baseline and reports no movement.
3865///
3866/// On movement it force-stales `dirty` and `state`, the two cells with no cheap
3867/// detector, then re-runs phases A and B for that entity alone and lets their own
3868/// supersession land the fresh values; it never starts a status probe of its own.
3869/// `poll_reprobed` is cleared and refilled with exactly the keys this call
3870/// actually re-ran, in the order it found them moved. `poll_sweep_count` counts
3871/// every call, whether or not anything moved, so a test can prove a real tick
3872/// reached this function at all.
3873fn run_poll_sweep(
3874    table: &Arc<RwLock<Table>>,
3875    overrides: &Arc<Vec<ResolvedOverride>>,
3876    show_submodules: &Arc<AtomicBool>,
3877    poll_reprobed: &Arc<Mutex<Vec<EntityKey>>>,
3878    poll_sweep_count: &Arc<AtomicUsize>,
3879    network_default_branch: &Mutex<HashMap<PathBuf, Arc<str>>>,
3880) {
3881    poll_sweep_count.fetch_add(1, Ordering::Release);
3882    poll_reprobed.lock().unwrap().clear();
3883    let show_submodules = show_submodules.load(Ordering::Acquire);
3884
3885    let candidates: Vec<PollCandidate> = {
3886        let table = table.read().unwrap();
3887        table
3888            .entities
3889            .iter()
3890            .filter(|entity| dispatches_kind(entity.kind, show_submodules))
3891            .map(|entity| PollCandidate {
3892                key: entity.key.clone(),
3893                path: entity.key.path().to_path_buf(),
3894                common_dir: Arc::clone(&entity.common_dir),
3895                kind: entity.kind,
3896                cached_repo: table.repos.get(&entity.key).cloned(),
3897                probes_base: entity.probes_base(),
3898            })
3899            .collect()
3900    };
3901
3902    for candidate in candidates {
3903        // A fresh open, never cached across sweeps: this is the same cost every
3904        // other probe in this module already pays for an entity discovery left
3905        // no handle for (always true of a Submodule), and reusing the handle it
3906        // returns for the re-probe below saves a second open on the one path
3907        // that actually detected movement.
3908        let opened;
3909        let repo = match candidate.cached_repo.as_deref() {
3910            Some(repo) => Some(repo),
3911            None => match git::open_thread_safe(&candidate.path) {
3912                Ok(repo) => {
3913                    opened = repo;
3914                    Some(&opened)
3915                }
3916                Err(_) => None,
3917            },
3918        };
3919        let gitdir = repo
3920            .map(|repo| repo.git_dir().to_path_buf())
3921            .unwrap_or_else(|| candidate.common_dir.to_path_buf());
3922
3923        let current = poll::fingerprint(&gitdir);
3924        let moved = {
3925            let mut table = table.write().unwrap();
3926            let previous = table
3927                .poll_fingerprints
3928                .insert(candidate.key.clone(), current);
3929            previous.is_some_and(|previous| poll::moved(&previous, &current))
3930        };
3931        if !moved {
3932            continue;
3933        }
3934
3935        {
3936            let mut table = table.write().unwrap();
3937            if let Some(&idx) = table.index.get(&candidate.key) {
3938                table.entities[idx].force_stale_status_cells();
3939            }
3940        }
3941
3942        let override_branch = find_entry(overrides, &candidate.path, &candidate.common_dir)
3943            .and_then(|entry| entry.default_branch.clone());
3944        let never_cancelled = AtomicBool::new(false);
3945        let chain_cache: ChainFactsCache = Mutex::new(HashMap::new());
3946        let chain_reads = AtomicUsize::new(0);
3947
3948        let branch_outcome = probe_branch(&candidate.path, repo, candidate.kind, &never_cancelled);
3949        let sync_outcome = probe_sync(
3950            &candidate.path,
3951            repo,
3952            branch_outcome.as_ref().map(|(settled, ..)| settled),
3953            candidate.kind,
3954            &never_cancelled,
3955        );
3956        let default_branch_outcome = probe_default_branch_memoised(
3957            &candidate.path,
3958            repo,
3959            &candidate.common_dir,
3960            DefaultBranchHints {
3961                override_branch: override_branch.as_deref(),
3962                network_branch: network_branch_for(network_default_branch, &candidate.common_dir)
3963                    .as_deref(),
3964            },
3965            candidate.kind,
3966            &never_cancelled,
3967            &ChainFactsMemo {
3968                cache: &chain_cache,
3969                reads: &chain_reads,
3970            },
3971        );
3972        let base_outcome = if candidate.probes_base {
3973            probe_base(
3974                &candidate.path,
3975                repo,
3976                branch_outcome.as_ref().map(|(settled, ..)| settled),
3977                default_branch_outcome.as_ref().map(|r| &r.settled),
3978                &never_cancelled,
3979            )
3980        } else {
3981            None
3982        };
3983
3984        let generation = {
3985            let mut table = table.write().unwrap();
3986            table.generation += 1;
3987            Generation::new(table.generation)
3988        };
3989        apply_cheap_probe_outcomes(
3990            table,
3991            &candidate.key,
3992            generation,
3993            CheapProbeOutcomes {
3994                branch: branch_outcome,
3995                sync: sync_outcome,
3996                base: base_outcome,
3997                default_branch: default_branch_outcome,
3998            },
3999        );
4000        poll_reprobed.lock().unwrap().push(candidate.key);
4001    }
4002}
4003
4004/// Cancels every probe currently in flight and drops the table's record of them,
4005/// which is what suspension does: the in-flight Generation is cancelled outright
4006/// rather than left to finish. Releases a pending `settle` too, since nothing is
4007/// now going to finish it.
4008fn cancel_in_flight(table: &Arc<RwLock<Table>>, settle_gate: &Arc<SettleGate>) {
4009    let mut table = table.write().unwrap();
4010    let cancelled = table.in_flight.len();
4011    for in_flight in table.in_flight.values() {
4012        in_flight.cancel.store(true, Ordering::Release);
4013    }
4014    table.in_flight.clear();
4015    table.generation_started_at.clear();
4016    drop(table);
4017    if cancelled > 0 {
4018        complete_many(settle_gate, cancelled);
4019    }
4020}
4021
4022/// A `Cell<T>`'s in-flight and timeout behaviour, uniform across every payload
4023/// type `EntityState` carries, so [`sweep_deadline`] can sweep every cell
4024/// through one array rather than one hand-written branch per cell: a cell only
4025/// ever times out if it was actually marked in flight, which is what lets the
4026/// sweep apply to all of them without asking what `Kind` owns them.
4027trait TimeoutableCell {
4028    fn is_in_flight(&self) -> bool;
4029    /// Settles this cell `Unknown(TimedOut)` for `generation`, subject to the
4030    /// same supersession `Cell::settle` already enforces.
4031    fn time_out(&mut self, generation: Generation);
4032}
4033
4034impl<T> TimeoutableCell for Cell<T> {
4035    fn is_in_flight(&self) -> bool {
4036        Cell::is_in_flight(self)
4037    }
4038
4039    fn time_out(&mut self, generation: Generation) {
4040        self.settle(generation, Settled::Unknown(Unknown::TimedOut));
4041    }
4042}
4043
4044/// Marks every cell still in flight past its own Generation's deadline `Unknown`,
4045/// per [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md):
4046/// there is no per-cell timeout, only this sweep, and it never interrupts the
4047/// underlying probe, which keeps running; the sweep only stops waiting on it.
4048fn sweep_deadline(table: &Arc<RwLock<Table>>, settle_gate: &Arc<SettleGate>, deadline: Duration) {
4049    let mut table = table.write().unwrap();
4050    let now = Instant::now();
4051    let mut timed_out = Vec::new();
4052    for (key, in_flight) in table.in_flight.iter() {
4053        let started = table
4054            .generation_started_at
4055            .get(&in_flight.generation)
4056            .copied()
4057            .unwrap_or(now);
4058        if now.duration_since(started) >= deadline {
4059            timed_out.push((key.clone(), Generation::new(in_flight.generation)));
4060        }
4061    }
4062    for (key, generation) in &timed_out {
4063        if let Some(&idx) = table.index.get(key) {
4064            // Exhaustive: a Cell added to `EntityState` later must be named here
4065            // or this fails to compile, so it cannot silently time out never.
4066            let EntityState {
4067                key: _,
4068                name: _,
4069                common_dir: _,
4070                kind: _,
4071                branch,
4072                sync,
4073                base,
4074                dirty,
4075                state,
4076                default_branch,
4077                diagnostics: _,
4078                last_action: _,
4079                presence: _,
4080                excluded: _,
4081                in_progress_operation: _,
4082                recent_commits: _,
4083            } = &mut table.entities[idx];
4084            let cells: [&mut dyn TimeoutableCell; 6] =
4085                [branch, sync, base, dirty, state, default_branch];
4086            for cell in cells {
4087                // Only a cell actually marked in flight times out: a Repo's or a
4088                // Submodule's `state` (never probed, by `EntityState::probes_state`)
4089                // and any cell no probe yet reaches (`sync`, `base`) are never in
4090                // flight, so this never overwrites them with a lie.
4091                if cell.is_in_flight() {
4092                    cell.time_out(*generation);
4093                }
4094            }
4095        }
4096        table.in_flight.remove(key);
4097    }
4098    let live_generations: std::collections::HashSet<u64> =
4099        table.in_flight.values().map(|f| f.generation).collect();
4100    table
4101        .generation_started_at
4102        .retain(|generation, _| live_generations.contains(generation));
4103    drop(table);
4104    if !timed_out.is_empty() {
4105        complete_many(settle_gate, timed_out.len());
4106    }
4107}
4108
4109/// Marks the cells this Generation's dispatch is about to probe as in flight,
4110/// via an exhaustive destructure of `EntityState`'s cells: a cell added later
4111/// must be named here (`_` if it is not yet probed) or this fails to compile,
4112/// which is what stops a cell [`apply_probe_outcome`] settles from going
4113/// in-flight silently forgotten, and reading wrong on `is_in_flight` for the
4114/// whole dispatch.
4115fn begin_probes(entity: &mut EntityState) {
4116    let probes_state = entity.probes_state();
4117    let EntityState {
4118        key: _,
4119        name: _,
4120        common_dir: _,
4121        kind: _,
4122        branch,
4123        sync: _,
4124        base: _,
4125        dirty,
4126        state,
4127        default_branch,
4128        diagnostics: _,
4129        last_action: _,
4130        presence: _,
4131        excluded: _,
4132        in_progress_operation: _,
4133        recent_commits: _,
4134    } = entity;
4135    branch.begin_probe();
4136    default_branch.begin_probe();
4137    // Phase C runs against every dispatched entity, Repo, Worktree or Submodule alike:
4138    // refresh.md's "Scope and order" makes scope never a partial dial, so `dirty` carries
4139    // no `probes_state`-style condition of its own.
4140    dirty.begin_probe();
4141    // Only a Worktree's `state` is ever (re)probed: a Repo's is `NotApplicable`
4142    // and a Submodule's is `Unknown` from construction, neither ever revisited
4143    // (`EntityState::probes_state`), and marking either in flight here would
4144    // leave it in-flight forever, since nothing would ever call `settle` on it.
4145    if probes_state {
4146        state.begin_probe();
4147    }
4148}
4149
4150/// What [`Core::try_settle`] waits on, and the one lock every count it waits on lives
4151/// under, so a settle can never observe one of them without the other.
4152type SettleGate = (Mutex<SettleCounts>, Condvar);
4153
4154/// The two outstanding counts [`Core::try_settle`] blocks on.
4155///
4156/// `dispatches` exists because a Generation reserves its number on the calling
4157/// thread and does everything else on one of its own: between those two moments
4158/// `probes` has not been raised yet, so a settle reading `probes` alone would
4159/// return on a table nothing has started writing to.
4160#[derive(Default)]
4161struct SettleCounts {
4162    /// Dispatched entities that have yet to land a phase C/D outcome, be cancelled
4163    /// or time out.
4164    probes: usize,
4165    /// Generations whose number is reserved and whose own dispatch body has not
4166    /// finished raising `probes` for what it dispatches.
4167    dispatches: usize,
4168}
4169
4170impl SettleCounts {
4171    /// Whether nothing this `Core` has started is still owed to the table.
4172    ///
4173    /// An exhaustive destructure: a third count added to this struct must be named here
4174    /// or this fails to compile, rather than being silently left out of what a settle
4175    /// waits for.
4176    fn is_settled(&self) -> bool {
4177        let SettleCounts { probes, dispatches } = self;
4178        *probes == 0 && *dispatches == 0
4179    }
4180}
4181
4182/// Records one reserved Generation as owed, before the thread that will dispatch
4183/// it has started. Paired with exactly one [`finish_dispatch`].
4184fn begin_dispatch(settle_gate: &SettleGate) {
4185    let (lock, _cvar) = settle_gate;
4186    lock.lock().unwrap().dispatches += 1;
4187}
4188
4189/// Releases the debt [`begin_dispatch`] recorded, once that Generation's own
4190/// dispatch has raised `probes` for everything it dispatched.
4191fn finish_dispatch(settle_gate: &SettleGate) {
4192    let (lock, cvar) = settle_gate;
4193    let mut counts = lock.lock().unwrap();
4194    counts.dispatches = counts.dispatches.saturating_sub(1);
4195    drop(counts);
4196    // Unconditionally, unlike `complete_many`: a waiter watching `dispatches` alone
4197    // would never be woken by a change that leaves `probes` outstanding.
4198    cvar.notify_all();
4199}
4200
4201fn begin_probes_owed(settle_gate: &SettleGate, owed: usize) {
4202    let (lock, _cvar) = settle_gate;
4203    lock.lock().unwrap().probes += owed;
4204}
4205
4206fn complete_one(settle_gate: &SettleGate) {
4207    complete_many(settle_gate, 1);
4208}
4209
4210fn complete_many(settle_gate: &SettleGate, finished: usize) {
4211    let (lock, cvar) = settle_gate;
4212    let mut counts = lock.lock().unwrap();
4213    counts.probes = counts.probes.saturating_sub(finished);
4214    if counts.is_settled() {
4215        cvar.notify_all();
4216    }
4217}
4218
4219/// Reads one entity's HEAD shape, or `None` if `cancel` was already set before the
4220/// read started. The one check this crate makes today: `git::head_shape` itself has
4221/// no interruption point to check `cancel` against mid-read, unlike the later
4222/// phases [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)
4223/// describes gix taking it through directly.
4224///
4225/// `repo` is the entity's cached thread-safe handle when discovery already opened
4226/// one; this task derives its own `Repository` from it via `to_thread_local`
4227/// rather than sharing that derived handle with any other task. `None` (a
4228/// Submodule, or a boundary discovery could not open) falls back to opening fresh,
4229/// which is where an unreadable repository's `ProbeError::Open` still surfaces.
4230///
4231/// Also reads the entity's in-progress git operation and recent commits off the
4232/// same open handle, since both ride along at negligible extra cost
4233/// ([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)).
4234/// Neither is a Cell in its own right, so both travel with the branch read they
4235/// were taken alongside rather than getting independent supersession of their
4236/// own; [`EntityState::apply_branch_probe`] is where that pairing lands.
4237const RECENT_COMMITS_LIMIT: usize = 5;
4238
4239/// What an open-repository failure means for `kind`: a genuine Probe error for a Repo or a
4240/// Worktree, but for a Submodule the far more common, expected shape of "never `git
4241/// submodule update --init`-ed" ([discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md)'s
4242/// "The Submodule row": "An uninitialised Submodule is a row with every cell blank and `?`
4243/// in the gutter"). Exhaustive over `Kind` rather than a wildcard, so a fourth variant added
4244/// later must decide which grade it gets rather than silently inheriting one.
4245fn submodule_open_failure<T>(kind: Kind, error: git::ProbeError) -> Settled<T> {
4246    match kind {
4247        Kind::Repo | Kind::Worktree => Settled::Failed(error),
4248        Kind::Submodule => Settled::Unknown(Unknown::SubmoduleUninitialized),
4249    }
4250}
4251
4252fn probe_branch(
4253    path: &Path,
4254    repo: Option<&gix::ThreadSafeRepository>,
4255    kind: Kind,
4256    cancel: &AtomicBool,
4257) -> Option<(
4258    Settled<Head>,
4259    Option<git::InProgressOperation>,
4260    Vec<git::RecentCommit>,
4261)> {
4262    if cancel.load(Ordering::Acquire) {
4263        return None;
4264    }
4265    let opened;
4266    let repo = match repo {
4267        Some(repo) => repo,
4268        None => match git::open_thread_safe(path) {
4269            Ok(repo) => {
4270                opened = repo;
4271                &opened
4272            }
4273            Err(error) => return Some((submodule_open_failure(kind, error), None, Vec::new())),
4274        },
4275    };
4276    let local = repo.to_thread_local();
4277    let settled = match git::head_shape(&local) {
4278        Ok(head) => Settled::Known {
4279            value: head,
4280            at: Timestamp::now(),
4281            stale: false,
4282        },
4283        Err(error) => Settled::Failed(error),
4284    };
4285    let in_progress = git::in_progress_operation(&local);
4286    let recent = git::recent_commits(&local, RECENT_COMMITS_LIMIT);
4287    Some((settled, in_progress, recent))
4288}
4289
4290/// Phase B's comparison: the `sync` cell's ahead/behind counts against the
4291/// branch's upstream, for every entity whose HEAD carries a branch, every
4292/// Generation ([refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)).
4293/// `None` if `cancel` was already set, or if `branch_settled` is itself `None`
4294/// because the branch probe it depends on was cancelled first. A `Failed` branch
4295/// read fails `sync` the same way, rather than guessing at a HEAD shape the
4296/// branch probe itself could not read; every other shape (a live branch, a
4297/// detached or unborn HEAD) is handed to [`git::resolve_sync`], which is where
4298/// "no branch" and "no remote at all" settle to their own values. `repo` follows
4299/// the same cached-handle convention as [`probe_branch`].
4300fn probe_sync(
4301    path: &Path,
4302    repo: Option<&gix::ThreadSafeRepository>,
4303    branch_settled: Option<&Settled<Head>>,
4304    kind: Kind,
4305    cancel: &AtomicBool,
4306) -> Option<Settled<SyncState>> {
4307    if cancel.load(Ordering::Acquire) {
4308        return None;
4309    }
4310    let head = match branch_settled? {
4311        Settled::Known {
4312            value,
4313            at: _,
4314            stale: _,
4315        } => Some(value),
4316        Settled::Failed(error) => return Some(Settled::Failed(error.clone())),
4317        Settled::Unknown(_) | Settled::NotApplicable => None,
4318    };
4319    let opened;
4320    let repo = match repo {
4321        Some(repo) => repo,
4322        None => match git::open_thread_safe(path) {
4323            Ok(repo) => {
4324                opened = repo;
4325                &opened
4326            }
4327            Err(error) => return Some(submodule_open_failure(kind, error)),
4328        },
4329    };
4330    let local = repo.to_thread_local();
4331    let settled = match git::resolve_sync(&local, head) {
4332        Ok(value) => Settled::Known {
4333            value,
4334            at: Timestamp::now(),
4335            stale: false,
4336        },
4337        Err(error) => Settled::Failed(error),
4338    };
4339    Some(settled)
4340}
4341
4342/// Phase B's second rev-walk: the `base` cell's count behind the resolved default
4343/// branch, for every entity [`crate::base::probe`] does not exempt
4344/// ([default-branch.md](https://github.com/paulchiu/repon/blob/main/docs/spec/default-branch.md)'s
4345/// "The two behind counts"). `None` if `cancel` was already set, or if either
4346/// `branch_settled` or `default_branch_settled` is itself `None` because the probe
4347/// it depends on was cancelled first; [`crate::base::probe`] itself always settles
4348/// once reached. A `Failed` or not-yet-`Known` `branch_settled` carries no commit to
4349/// compare, so it is treated the same "nothing to settle yet" way, except a genuine
4350/// `Failed` branch read, which propagates onto `base` too: a row whose HEAD could
4351/// not be read has nothing to compute behind anything. `repo` follows the same
4352/// cached-handle convention as [`probe_branch`].
4353fn probe_base(
4354    path: &Path,
4355    repo: Option<&gix::ThreadSafeRepository>,
4356    branch_settled: Option<&Settled<Head>>,
4357    default_branch_settled: Option<&Settled<DefaultBranch>>,
4358    cancel: &AtomicBool,
4359) -> Option<Settled<u32>> {
4360    if cancel.load(Ordering::Acquire) {
4361        return None;
4362    }
4363    let head = match branch_settled? {
4364        Settled::Known {
4365            value,
4366            at: _,
4367            stale: _,
4368        } => value,
4369        Settled::Failed(error) => return Some(Settled::Failed(error.clone())),
4370        Settled::Unknown(_) | Settled::NotApplicable => return None,
4371    };
4372    let default_branch_settled = default_branch_settled?;
4373    let opened;
4374    let repo = match repo {
4375        Some(repo) => repo,
4376        None => match git::open_thread_safe(path) {
4377            Ok(repo) => {
4378                opened = repo;
4379                &opened
4380            }
4381            Err(error) => return Some(Settled::Failed(error)),
4382        },
4383    };
4384    let local = repo.to_thread_local();
4385    Some(base::probe(&local, head, default_branch_settled))
4386}
4387
4388/// Phase C's typed counts, dispatched over every entity in a Generation with no
4389/// scoping of its own: [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
4390/// "Scope and order" makes scope never a partial dial, only order, so this carries
4391/// no visibility filter and no cost heuristic; the caller's dispatch order is the
4392/// only dial, expressed entirely by the position `path` already holds in
4393/// `Core::refresh`'s `order`. `None` if `cancel` was already set before the read
4394/// started; unlike the cheaper phases above, `cancel` is also handed straight
4395/// into gix, which checks it while the read is under way rather than only before
4396/// it starts, since this is the one phase long enough for that to matter.
4397fn probe_status(
4398    path: &Path,
4399    repo: Option<&gix::ThreadSafeRepository>,
4400    kind: Kind,
4401    cancel: &Arc<AtomicBool>,
4402) -> Option<Settled<DirtyCounts>> {
4403    if cancel.load(Ordering::Acquire) {
4404        return None;
4405    }
4406    let opened;
4407    let repo = match repo {
4408        Some(repo) => repo,
4409        None => match git::open_thread_safe(path) {
4410            Ok(repo) => {
4411                opened = repo;
4412                &opened
4413            }
4414            Err(error) => return Some(submodule_open_failure(kind, error)),
4415        },
4416    };
4417    let local = repo.to_thread_local();
4418    classify_status_result(git::dirty_counts(&local, Arc::clone(cancel)), cancel)
4419}
4420
4421/// Folds [`git::dirty_counts`]'s result into [`probe_status`]'s outcome. Split out as its own
4422/// function so the one case a live probe cannot reproduce deterministically, cancellation
4423/// observed genuinely mid-read, is directly testable: gix's own error carries no typed "this
4424/// was cancelled" case (its interrupt point reports through a bare `io::Error`, same as any
4425/// other I/O failure), so `cancel` itself, which this task alone owns for the duration of its
4426/// probe, is the answer. An error alongside a cancel flag now set is what an interruption
4427/// 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
4428/// precedent is that interrupted work is dropped rather than settled `Failed`, the same as
4429/// every cheaper phase's pre-check already does.
4430///
4431/// gix checks `should_interrupt` per index entry rather than before every read, so a walk
4432/// short enough to run out of entries to check between the flag flipping and the walk
4433/// finishing can still return `Ok`. `cancel` is re-checked on that arm too, and an `Ok` that
4434/// raced ahead of it is dropped the same way an `Err` alongside it already is, so a cancelled
4435/// generation never lands a value regardless of which side of that race gix landed on.
4436fn classify_status_result(
4437    result: Result<DirtyCounts, git::ProbeError>,
4438    cancel: &AtomicBool,
4439) -> Option<Settled<DirtyCounts>> {
4440    match result {
4441        Ok(_) if cancel.load(Ordering::Acquire) => None,
4442        Ok(value) => Some(Settled::Known {
4443            value,
4444            at: Timestamp::now(),
4445            stale: false,
4446        }),
4447        Err(_) if cancel.load(Ordering::Acquire) => None,
4448        Err(error) => Some(Settled::Failed(error)),
4449    }
4450}
4451
4452/// Rung 1's config override and the network's session-held answer, bundled into
4453/// one argument the way [`ChainFactsMemo`] bundles its own two: both
4454/// [`probe_default_branch`] and [`probe_default_branch_memoised`] already sit at
4455/// clippy's argument limit, and the two hints always travel together, one per
4456/// dispatched entity.
4457struct DefaultBranchHints<'a> {
4458    /// Matched by common dir before this is called; `None` when no `[[repo]]`
4459    /// entry names this entity's own default branch.
4460    override_branch: Option<&'a str>,
4461    /// [`network_branch_for`]'s own answer for this entity's common dir; `None`
4462    /// until a fetch handshake or [`Core::rederive_default_branches`] has
4463    /// actually reached that remote this session.
4464    network_branch: Option<&'a str>,
4465}
4466
4467/// [`Core::network_default_branch`]'s own lookup, by common dir: a small helper
4468/// so every probe site reads it the same way rather than repeating the lock and
4469/// clone.
4470fn network_branch_for(
4471    network_default_branch: &Mutex<HashMap<PathBuf, Arc<str>>>,
4472    common_dir: &Path,
4473) -> Option<Arc<str>> {
4474    network_default_branch
4475        .lock()
4476        .unwrap()
4477        .get(common_dir)
4478        .cloned()
4479}
4480
4481/// Supersedes `resolution`'s own settled value with `network_branch`, if given,
4482/// per [default-branch.md](https://github.com/paulchiu/repon/blob/main/docs/spec/default-branch.md)'s
4483/// "The network": never the primary source, so `resolution` is always the local
4484/// chain's own complete answer, computed unconditionally by the caller before
4485/// this ever runs. This is the one place ADR 0012's stated ceiling is actually
4486/// closed: on a Repo where rung 2 and rung 3 agree and are both wrong (the
4487/// hidden-Submodule case the ADR measures), no local rung can ever correct
4488/// itself, and only a reachable remote's own answer, landed here, can.
4489fn supersede_with_network(
4490    mut resolution: default_branch::Resolution,
4491    network_branch: Option<&str>,
4492) -> default_branch::Resolution {
4493    if let Some(name) = network_branch {
4494        resolution.settled = Settled::Known {
4495            value: DefaultBranch::new(name.into()),
4496            at: Timestamp::now(),
4497            stale: false,
4498        };
4499    }
4500    resolution
4501}
4502
4503/// Runs the four-rung default branch chain against `path`, or `None` if `cancel`
4504/// was already set before the read started, then [`supersede_with_network`]s the
4505/// result with `hints.network_branch`.
4506///
4507/// `repo` follows the same cached-handle convention as [`probe_branch`]: `None`
4508/// falls back to opening fresh, which is where an unreadable repository surfaces
4509/// as [`default_branch::Resolution::failed`] rather than a settled Unknown.
4510fn probe_default_branch(
4511    path: &Path,
4512    repo: Option<&gix::ThreadSafeRepository>,
4513    hints: DefaultBranchHints<'_>,
4514    kind: Kind,
4515    cancel: &AtomicBool,
4516) -> Option<default_branch::Resolution> {
4517    if cancel.load(Ordering::Acquire) {
4518        return None;
4519    }
4520    let opened;
4521    let repo = match repo {
4522        Some(repo) => repo,
4523        None => match git::open_thread_safe(path) {
4524            Ok(repo) => {
4525                opened = repo;
4526                &opened
4527            }
4528            Err(error) => {
4529                return Some(match kind {
4530                    Kind::Repo | Kind::Worktree => default_branch::Resolution::failed(error),
4531                    Kind::Submodule => default_branch::Resolution::submodule_uninitialized(),
4532                });
4533            }
4534        },
4535    };
4536    Some(supersede_with_network(
4537        default_branch::resolve(&repo.to_thread_local(), hints.override_branch),
4538        hints.network_branch,
4539    ))
4540}
4541
4542/// Coordinates one common dir's Outstanding entities so every one of their own
4543/// merge bases against the default branch is known before the shared scan
4544/// runs, per [default-branch.md](https://github.com/paulchiu/repon/blob/main/docs/spec/default-branch.md)'s
4545/// requirement that the bound be *collected*, not computed lazily on whichever
4546/// entity happens to arrive first. `remaining` starts at the number of
4547/// dispatched entities in this common dir that will call [`GateReport::report`]
4548/// this Generation (every entity `landing::probe` runs for, whether it settles
4549/// immediately or reaches patch equivalence); `deepest` blocks until all of
4550/// them have, then folds their contributed merge bases pairwise via
4551/// [`git::checked_merge_base`] so the result is an ancestor of (at least as
4552/// deep as) every one of them, and memoises that answer for every later caller
4553/// sharing this dir.
4554struct BoundGate {
4555    state: Mutex<BoundGateState>,
4556    condvar: Condvar,
4557    bound: OnceLock<Option<gix::ObjectId>>,
4558}
4559
4560struct BoundGateState {
4561    remaining: usize,
4562    candidates: Vec<gix::ObjectId>,
4563}
4564
4565impl BoundGate {
4566    fn new(remaining: usize) -> Self {
4567        Self {
4568            state: Mutex::new(BoundGateState {
4569                remaining,
4570                candidates: Vec::new(),
4571            }),
4572            condvar: Condvar::new(),
4573            bound: OnceLock::new(),
4574        }
4575    }
4576
4577    /// One entity's contribution: `Some(base)` when it reached patch
4578    /// equivalence and had a merge base to offer, `None` otherwise (it settled
4579    /// by ancestry, was cancelled, failed to read, or shared no history with
4580    /// the default branch at all). Wakes every task blocked in [`Self::deepest`]
4581    /// once every entity counted in `remaining` has reported.
4582    fn report(&self, candidate: Option<gix::ObjectId>) {
4583        let mut state = self.state.lock().unwrap();
4584        if let Some(candidate) = candidate {
4585            state.candidates.push(candidate);
4586        }
4587        state.remaining -= 1;
4588        if state.remaining == 0 {
4589            self.condvar.notify_all();
4590        }
4591    }
4592
4593    /// Blocks until every entity sharing this common dir has reported, then
4594    /// returns the deepest merge base among their contributions (`None` if
4595    /// none contributed one, so the scan is left unbounded). The candidates are
4596    /// taken and folded into `bound` inside the same critical section, so
4597    /// whichever call is first to finish waiting is guaranteed to be the one
4598    /// that computes the memoised answer from them; computing outside the lock
4599    /// would let a later call, left holding an empty list by
4600    /// [`std::mem::take`], win the race into [`OnceLock::get_or_init`] and
4601    /// memoise `None` regardless of what the first call actually contributed.
4602    fn deepest(&self, repo: &gix::Repository) -> Option<gix::ObjectId> {
4603        let mut state = self.state.lock().unwrap();
4604        while state.remaining != 0 {
4605            state = self.condvar.wait(state).unwrap();
4606        }
4607        let candidates = std::mem::take(&mut state.candidates);
4608        *self
4609            .bound
4610            .get_or_init(|| deepest_merge_base(repo, &candidates))
4611    }
4612}
4613
4614/// Folds `candidates` pairwise via [`git::checked_merge_base`] into the one
4615/// deepest among them: when two candidates are ancestor and descendant, their
4616/// own merge base is exactly the ancestor, so the fold converges on whichever
4617/// candidate is deepest; two on unrelated lines of history fold to their own
4618/// common ancestor instead, which is still a safe (if not the tightest
4619/// possible) lower bound for the scan.
4620fn deepest_merge_base(
4621    repo: &gix::Repository,
4622    candidates: &[gix::ObjectId],
4623) -> Option<gix::ObjectId> {
4624    let mut candidates = candidates.iter().copied();
4625    let mut deepest = candidates.next()?;
4626    for candidate in candidates {
4627        deepest = git::checked_merge_base(repo, deepest, candidate)
4628            .ok()
4629            .flatten()
4630            .unwrap_or(deepest);
4631    }
4632    Some(deepest)
4633}
4634
4635/// Reports exactly once to a [`BoundGate`], on drop if [`Self::report_now`] was
4636/// never called explicitly: every exit path out of [`probe_worktree_state`]
4637/// and [`probe_patch_equivalence`] must release its common dir's gate, since a
4638/// path that forgot to would deadlock every sibling still waiting in
4639/// [`BoundGate::deepest`].
4640struct GateReport<'a> {
4641    gate: &'a BoundGate,
4642    reported: bool,
4643}
4644
4645impl<'a> GateReport<'a> {
4646    fn new(gate: &'a BoundGate) -> Self {
4647        Self {
4648            gate,
4649            reported: false,
4650        }
4651    }
4652
4653    /// Reports `candidate` immediately rather than waiting for drop: the one
4654    /// path that goes on to call [`BoundGate::deepest`] must report its own
4655    /// contribution first, or it would wait on a count that can never reach
4656    /// zero without its own report.
4657    fn report_now(&mut self, candidate: Option<gix::ObjectId>) {
4658        self.gate.report(candidate);
4659        self.reported = true;
4660    }
4661}
4662
4663impl Drop for GateReport<'_> {
4664    fn drop(&mut self) {
4665        if !self.reported {
4666            self.gate.report(None);
4667        }
4668    }
4669}
4670
4671/// The per-common-dir patch-equivalence memo plumbing, bundled into one
4672/// argument so [`probe_worktree_state`] and [`probe_patch_equivalence`] each
4673/// take it as a single parameter rather than three loose ones.
4674struct PatchEquivalenceMemo<'a> {
4675    cache: &'a PatchIdentityCache,
4676    reads: &'a AtomicUsize,
4677    /// Where [`probe_patch_equivalence`] records the bound it actually passed to
4678    /// [`patch_equivalence::scan_default_branch`], for `Core::patch_scan_bounds_for_test`.
4679    scan_bounds: &'a Mutex<Vec<Option<gix::ObjectId>>>,
4680}
4681
4682/// Runs both of Phase D's passes for one Worktree entity: `landing::probe`'s
4683/// ancestry check, then, only when it answers `Outstanding`,
4684/// [`probe_patch_equivalence`]'s content check. `None` if `cancel` was already
4685/// set, or if `default_branch_settled` is itself `None` because the
4686/// default-branch probe it depends on was cancelled first. `repo` follows the
4687/// same cached-handle convention as [`probe_branch`]. `report` always reports
4688/// exactly once to this entity's common dir's `BoundGate`, on every path
4689/// through this function, via its own `Drop`.
4690fn probe_worktree_state(
4691    path: &Path,
4692    repo: Option<&gix::ThreadSafeRepository>,
4693    default_branch_settled: Option<&Settled<DefaultBranch>>,
4694    common_dir: &Arc<Path>,
4695    cancel: &AtomicBool,
4696    memo: &PatchEquivalenceMemo<'_>,
4697    report: &mut GateReport<'_>,
4698) -> Option<Settled<WorktreeState>> {
4699    if cancel.load(Ordering::Acquire) {
4700        return None;
4701    }
4702    let default_branch_settled = default_branch_settled?;
4703    let opened;
4704    let repo = match repo {
4705        Some(repo) => repo,
4706        None => match git::open_thread_safe(path) {
4707            Ok(repo) => {
4708                opened = repo;
4709                &opened
4710            }
4711            Err(error) => return Some(Settled::Failed(error)),
4712        },
4713    };
4714    let local = repo.to_thread_local();
4715    match landing::probe(&local, default_branch_settled) {
4716        landing::Outcome::Settle(settled) => Some(settled),
4717        landing::Outcome::Outstanding(outstanding) => {
4718            probe_patch_equivalence(&local, &outstanding, common_dir, cancel, memo, report)
4719        }
4720    }
4721}
4722
4723/// Phase D's expensive half, reached only when `landing::probe` answered
4724/// `Outstanding`: this is the seam that keeps patch equivalence off every
4725/// entity ancestry already settled. Reports the merge base the first pass
4726/// already walked to `report` *before* asking for the shared scan, then checks
4727/// patch equivalence against `memo`'s per-common-dir cache, per
4728/// [default-branch.md](https://github.com/paulchiu/repon/blob/main/docs/spec/default-branch.md)'s
4729/// "Two passes on screen" and its bound on the scan's own depth.
4730fn probe_patch_equivalence(
4731    repo: &gix::Repository,
4732    outstanding: &landing::Outstanding,
4733    common_dir: &Arc<Path>,
4734    cancel: &AtomicBool,
4735    memo: &PatchEquivalenceMemo<'_>,
4736    report: &mut GateReport<'_>,
4737) -> Option<Settled<WorktreeState>> {
4738    if cancel.load(Ordering::Acquire) {
4739        return None;
4740    }
4741    let landing::Outstanding {
4742        entity_tip,
4743        default_tip,
4744        merge_base,
4745    } = *outstanding;
4746    let Some(merge_base) = merge_base else {
4747        // No shared history at all: a real negative the first pass already
4748        // established. This entity needs no bound and no shared scan, so it
4749        // reports and settles without waiting on either; the empty set is never
4750        // actually consulted, since `probe` returns `Active` for a `None` merge
4751        // base before it would look.
4752        report.report_now(None);
4753        return Some(patch_equivalence::probe(
4754            repo,
4755            entity_tip,
4756            None,
4757            &patch_equivalence::PatchIdentitySet::new(),
4758        ));
4759    };
4760    // Reported now, not left to `report`'s `Drop`: the wait just below blocks
4761    // on every entity sharing this common dir having reported, this entity
4762    // included, so reporting late here would deadlock on its own wait.
4763    report.report_now(Some(merge_base));
4764    let bound = report.gate.deepest(repo);
4765    let shared = match patch_identities_for(memo.cache, common_dir, memo.reads, || {
4766        // Recorded here, inside the closure that only ever runs for whichever
4767        // entity's task is first to reach `patch_identities_for` for this common
4768        // dir, so this is the bound the one real `scan_default_branch` call for
4769        // it actually used, not a value a test recomputes independently.
4770        memo.scan_bounds.lock().unwrap().push(bound);
4771        patch_equivalence::scan_default_branch(repo, default_tip, bound)
4772    }) {
4773        Ok(shared) => shared,
4774        Err(error) => return Some(Settled::Failed(error)),
4775    };
4776    Some(patch_equivalence::probe(
4777        repo,
4778        entity_tip,
4779        Some(merge_base),
4780        &shared,
4781    ))
4782}
4783
4784/// One Generation's patch-equivalence memo: at most one
4785/// [`patch_equivalence::PatchIdentitySet`] per common dir, shared by every
4786/// dispatched entity `landing::probe` answered `Outstanding` for. Built fresh
4787/// in [`Core::refresh`] and dropped once every task from that dispatch has
4788/// finished, the same lifetime `ChainFactsCache` has. The computed `Result` is
4789/// itself cached, since a common dir a scan fails against fails identically
4790/// for every entity sharing it this Generation.
4791type PatchIdentityCache = Mutex<
4792    HashMap<Arc<Path>, Arc<OnceLock<Result<patch_equivalence::PatchIdentitySet, git::ProbeError>>>>,
4793>;
4794
4795/// The per-common-dir half of [`probe_patch_equivalence`]: returns the
4796/// already-computed scan for `common_dir` if another entity in this
4797/// Generation's dispatch already ran it, blocking until that computation
4798/// finishes if it is still running; otherwise runs `compute` itself, caches the
4799/// result, and increments `reads` exactly once for the common dir this call is
4800/// the first to reach. Structurally identical to [`chain_facts_for`]; kept
4801/// separate rather than made generic over it, since the two caches are keyed by
4802/// different Generations' worth of dispatch and sharing one would blur which
4803/// pass a given read counted for.
4804fn patch_identities_for(
4805    cache: &PatchIdentityCache,
4806    common_dir: &Arc<Path>,
4807    reads: &AtomicUsize,
4808    compute: impl FnOnce() -> Result<patch_equivalence::PatchIdentitySet, git::ProbeError>,
4809) -> Result<patch_equivalence::PatchIdentitySet, git::ProbeError> {
4810    let cell = {
4811        let mut cache = cache.lock().unwrap();
4812        Arc::clone(
4813            cache
4814                .entry(Arc::clone(common_dir))
4815                .or_insert_with(|| Arc::new(OnceLock::new())),
4816        )
4817    };
4818    cell.get_or_init(|| {
4819        reads.fetch_add(1, Ordering::Relaxed);
4820        compute()
4821    })
4822    .clone()
4823}
4824
4825/// One Generation's default-branch chain memo: at most one [`default_branch::ChainFacts`]
4826/// per common dir, shared by every dispatched entity that names it. Built fresh in
4827/// [`Core::refresh`] and dropped once every task from that dispatch has finished.
4828type ChainFactsCache = Mutex<HashMap<Arc<Path>, Arc<OnceLock<default_branch::ChainFacts>>>>;
4829
4830/// The per-common-dir half of [`probe_default_branch_memoised`]: returns the
4831/// already-cached facts for `common_dir` if another entity in this Generation's
4832/// dispatch already computed them, blocking until that computation finishes if it
4833/// is still running; otherwise runs `compute` itself, caches the result, and
4834/// increments `reads` exactly once for the common dir this call is the first to
4835/// reach.
4836fn chain_facts_for(
4837    cache: &ChainFactsCache,
4838    common_dir: &Arc<Path>,
4839    reads: &AtomicUsize,
4840    compute: impl FnOnce() -> default_branch::ChainFacts,
4841) -> default_branch::ChainFacts {
4842    let cell = {
4843        let mut cache = cache.lock().unwrap();
4844        Arc::clone(
4845            cache
4846                .entry(Arc::clone(common_dir))
4847                .or_insert_with(|| Arc::new(OnceLock::new())),
4848        )
4849    };
4850    cell.get_or_init(|| {
4851        reads.fetch_add(1, Ordering::Relaxed);
4852        compute()
4853    })
4854    .clone()
4855}
4856
4857/// Runs the four-rung default branch chain against `path`, memoising rungs 2 and
4858/// 3's own per-common-dir facts in `cache` so every entity sharing `common_dir`
4859/// within the same dispatch reads the loose file and its reference lookups once
4860/// rather than once per entity, per [default-branch.md](https://github.com/paulchiu/repon/blob/main/docs/spec/default-branch.md)'s
4861/// "Memoised per common dir within a single refresh generation". `None` if
4862/// `cancel` was already set before the read started; `override_branch` is rung 1's
4863/// own entity-specific value, never memoised because it is not a common-dir fact.
4864/// [`chain_facts_for`]'s own two collaborators, bundled so
4865/// [`probe_default_branch_memoised`] stays within clippy's argument limit: the two always
4866/// travel together, one dispatch's worth of both, per [`Core::refresh_handles`].
4867struct ChainFactsMemo<'a> {
4868    cache: &'a ChainFactsCache,
4869    reads: &'a AtomicUsize,
4870}
4871
4872fn probe_default_branch_memoised(
4873    path: &Path,
4874    repo: Option<&gix::ThreadSafeRepository>,
4875    common_dir: &Arc<Path>,
4876    hints: DefaultBranchHints<'_>,
4877    kind: Kind,
4878    cancel: &AtomicBool,
4879    memo: &ChainFactsMemo<'_>,
4880) -> Option<default_branch::Resolution> {
4881    if cancel.load(Ordering::Acquire) {
4882        return None;
4883    }
4884    let opened;
4885    let repo = match repo {
4886        Some(repo) => repo,
4887        None => match git::open_thread_safe(path) {
4888            Ok(repo) => {
4889                opened = repo;
4890                &opened
4891            }
4892            Err(error) => {
4893                return Some(match kind {
4894                    Kind::Repo | Kind::Worktree => default_branch::Resolution::failed(error),
4895                    Kind::Submodule => default_branch::Resolution::submodule_uninitialized(),
4896                });
4897            }
4898        },
4899    };
4900    let local = repo.to_thread_local();
4901    let facts = chain_facts_for(memo.cache, common_dir, memo.reads, || {
4902        default_branch::ChainFacts::resolve(&local)
4903    });
4904    Some(supersede_with_network(
4905        default_branch::resolve_with_facts(&facts, hints.override_branch),
4906        hints.network_branch,
4907    ))
4908}
4909
4910/// Phase A and B's per-cell outcomes, landed as soon as they are computed via
4911/// [`apply_cheap_probe_outcomes`], well before phase C or D answer. Named rather
4912/// than positional so a transposed pair of trailing `None`s cannot compile
4913/// silently into the wrong cell.
4914struct CheapProbeOutcomes {
4915    branch: Option<(
4916        Settled<Head>,
4917        Option<git::InProgressOperation>,
4918        Vec<git::RecentCommit>,
4919    )>,
4920    sync: Option<Settled<SyncState>>,
4921    base: Option<Settled<u32>>,
4922    default_branch: Option<default_branch::Resolution>,
4923}
4924
4925/// Writes phase A and B's cells for `key` at `generation`, subject to the
4926/// per-cell supersession `Cell::settle` already enforces, and records the
4927/// default-branch diagnostics only on the write that actually won. Deliberately
4928/// does not touch `in_flight` or `settle_gate`: those belong to whichever apply
4929/// closes out the entity's dispatch, which per
4930/// [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
4931/// "The first frame" is this call's whole point, since a slow phase C or D must
4932/// never hold these cells off the table.
4933fn apply_cheap_probe_outcomes(
4934    table: &Arc<RwLock<Table>>,
4935    key: &EntityKey,
4936    generation: Generation,
4937    outcomes: CheapProbeOutcomes,
4938) {
4939    let CheapProbeOutcomes {
4940        branch: branch_outcome,
4941        sync: sync_outcome,
4942        base: base_outcome,
4943        default_branch: default_branch_outcome,
4944    } = outcomes;
4945    let mut table = table.write().unwrap();
4946    if let Some(&idx) = table.index.get(key) {
4947        if let Some((settled, in_progress, recent)) = branch_outcome {
4948            table.entities[idx].apply_branch_probe(generation, settled, in_progress, recent);
4949        }
4950        if let Some(settled) = sync_outcome {
4951            table.entities[idx].sync.settle(generation, settled);
4952        }
4953        if let Some(settled) = base_outcome {
4954            table.entities[idx].base.settle(generation, settled);
4955        }
4956        if let Some(resolution) = default_branch_outcome {
4957            table.entities[idx].apply_default_branch_resolution(generation, resolution);
4958        }
4959    }
4960}
4961
4962/// Phase C and D's per-cell outcomes, landed once they answer, via
4963/// [`apply_probe_outcome`]: named rather than positional for the same reason as
4964/// [`CheapProbeOutcomes`].
4965struct ProbeOutcomes {
4966    state: Option<Settled<WorktreeState>>,
4967    dirty: Option<Settled<DirtyCounts>>,
4968}
4969
4970/// Lands one probe's phase C/D outcome for `key` at `generation`: writes the
4971/// `state` and `dirty` cells subject to the per-cell supersession `Cell::settle`
4972/// already enforces, then clears `key`'s in-flight entry if `generation` still
4973/// owns it and signals `settle_gate` once for the whole entity. This is the one
4974/// write that closes out a dispatched entity, whether or not
4975/// [`apply_cheap_probe_outcomes`] already landed that same entity's cheap cells;
4976/// a test's simulated late result goes through the same path so it does not
4977/// duplicate this bookkeeping.
4978///
4979/// `outcomes.state` being `None` writes nothing at all: the `state` cell is left
4980/// exactly as unsettled as `begin_probe` alone leaves it, which is what an
4981/// attached branch with a live upstream ancestry could not clear, and that
4982/// `probe_patch_equivalence` was itself cancelled before answering, still shows.
4983fn apply_probe_outcome(
4984    table: &Arc<RwLock<Table>>,
4985    settle_gate: &Arc<SettleGate>,
4986    key: &EntityKey,
4987    generation: Generation,
4988    outcomes: ProbeOutcomes,
4989) {
4990    let ProbeOutcomes {
4991        state: state_outcome,
4992        dirty: dirty_outcome,
4993    } = outcomes;
4994    let mut table = table.write().unwrap();
4995    if let Some(&idx) = table.index.get(key) {
4996        if let Some(settled) = state_outcome {
4997            table.entities[idx].state.settle(generation, settled);
4998        }
4999        if let Some(settled) = dirty_outcome {
5000            table.entities[idx].dirty.settle(generation, settled);
5001        }
5002    }
5003    // By Generation as well as by key. Cancellation is cooperative, so a superseded
5004    // probe still runs to completion and arrives here after the Generation that
5005    // superseded it has already put its own entry under this key; clearing by key
5006    // alone would delete that live entry, leaving the entity with nothing for the
5007    // next Generation to interrupt and nothing for the deadline sweep to time out.
5008    // The settle gate is signalled either way, since the debt belongs to the probe
5009    // rather than to the entry.
5010    if table
5011        .in_flight
5012        .get(key)
5013        .is_some_and(|in_flight| in_flight.generation == generation.value())
5014    {
5015        table.in_flight.remove(key);
5016    }
5017    drop(table);
5018    complete_one(settle_gate);
5019}
5020
5021/// Reconciles one discovery result into `table`: a found entity is inserted or
5022/// marked Present again, even if it was Vanished, and one no longer found is
5023/// marked Vanished via [`EntityState::mark_vanished`]. Returns how many
5024/// in-flight probes were cancelled by a newly Vanished entity, for the caller
5025/// to signal `settle_gate`.
5026fn merge_discovery(
5027    table: &mut Table,
5028    exclusions: &[ResolvedExclusion],
5029    discovered: Vec<discovery::DiscoveredEntity>,
5030    gitmodules_failures: Vec<(EntityKey, String)>,
5031) -> usize {
5032    let mut found: HashSet<EntityKey> = HashSet::with_capacity(discovered.len());
5033
5034    for discovered in discovered {
5035        found.insert(discovered.key.clone());
5036        match table.index.get(&discovered.key).copied() {
5037            Some(idx) => {
5038                table.entities[idx].presence = Presence::Present;
5039                if let Some(repo) = discovered.repo {
5040                    table.repos.insert(discovered.key.clone(), repo);
5041                }
5042            }
5043            None => {
5044                let name = discovered
5045                    .display_name_override
5046                    .clone()
5047                    .unwrap_or_else(|| display_name(discovered.key.path()));
5048                let mut entity = EntityState::new(
5049                    discovered.key.clone(),
5050                    name,
5051                    Arc::clone(&discovered.common_dir),
5052                    discovered.kind,
5053                );
5054                entity.excluded =
5055                    excluded_by(exclusions, discovered.key.path(), &discovered.common_dir);
5056                if let Some(repo) = discovered.repo {
5057                    table.repos.insert(discovered.key.clone(), repo);
5058                }
5059                let idx = table.entities.len();
5060                table.index.insert(discovered.key, idx);
5061                table.entities.push(entity);
5062            }
5063        }
5064    }
5065
5066    // A boundary's `.gitmodules` failure is re-derived from this pass alone,
5067    // never carried over from a previous one: a failure that was fixed since the
5068    // last Generation must clear, not stay stuck forever.
5069    let now_failing: HashMap<EntityKey, String> = gitmodules_failures.into_iter().collect();
5070    for key in &found {
5071        if let Some(&idx) = table.index.get(key) {
5072            table.entities[idx].diagnostics.gitmodules_failed = now_failing
5073                .get(key)
5074                .map(|message| Arc::from(message.as_str()));
5075        }
5076    }
5077
5078    let missing: Vec<EntityKey> = table
5079        .index
5080        .keys()
5081        .filter(|key| !found.contains(*key))
5082        .cloned()
5083        .collect();
5084    let mut cancelled = 0usize;
5085    for key in missing {
5086        if let Some(&idx) = table.index.get(&key) {
5087            table.entities[idx].mark_vanished();
5088        }
5089        if let Some(in_flight) = table.in_flight.remove(&key) {
5090            in_flight.cancel.store(true, Ordering::Release);
5091            cancelled += 1;
5092        }
5093    }
5094
5095    cancelled
5096}
5097
5098/// A basename read from the entity's own resolved path. A real display name has
5099/// collision handling that belongs to [config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md);
5100/// this is a placeholder good enough to populate the table.
5101///
5102/// This is the one function that computes it: `start_internal`'s discovery loop
5103/// and `probe_now`'s fallback insert for an unknown key both call it rather than
5104/// formatting a name of their own, which is what keeps the name shown on screen
5105/// and the name a future state file would key by byte-identical.
5106fn display_name(path: &Path) -> Arc<str> {
5107    Arc::from(
5108        path.file_name()
5109            .and_then(|name| name.to_str())
5110            .unwrap_or("?"),
5111    )
5112}
5113
5114/// Sleeps for `warn_after`, then reports `progress`'s count and `roots` if the walk
5115/// still has not finished, per [discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md):
5116/// the one-second still-walking warning needs a timer watching an in-flight walk
5117/// from outside it, since discovery itself has no callback and no notion of "still
5118/// running". `None` once the walk has already finished.
5119fn watch_for_slow_discovery(
5120    progress: Arc<AtomicUsize>,
5121    finished: Arc<AtomicBool>,
5122    roots: Vec<PathBuf>,
5123    warn_after: Duration,
5124) -> Option<String> {
5125    thread::sleep(warn_after);
5126    if finished.load(Ordering::Acquire) {
5127        return None;
5128    }
5129    Some(still_walking_message(
5130        progress.load(Ordering::Acquire),
5131        &roots,
5132    ))
5133}
5134
5135fn still_walking_message(directories_visited: usize, roots: &[PathBuf]) -> String {
5136    let roots = roots
5137        .iter()
5138        .map(|root| root.display().to_string())
5139        .collect::<Vec<_>>()
5140        .join(", ");
5141    format!("discovery: still walking, {directories_visited} directories reached under {roots}")
5142}
5143
5144/// The persistent warning left once a walk abandons, per
5145/// [config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md#discovery-bounds):
5146/// unlike the still-walking warning, this one never clears itself, since the Set
5147/// stays out of the automatic refresh path for the life of this `Core`.
5148fn abandoned_discovery_message(directories_visited: usize) -> String {
5149    format!("discovery: stopped at {directories_visited} directories")
5150}
5151
5152/// Runs `step` until it says it is done or `cancel` is observed set, checked before
5153/// every call. Returns how many times `step` actually ran, which is what lets a
5154/// test prove a cancelled loop stopped mid-flight rather than merely having a flag
5155/// set on it somewhere. Not yet called from a real probe: `git::head_shape` has no
5156/// loop to interrupt, so this is the shape a later, genuinely interruptible phase
5157/// (gix `status`, taking `should_interrupt` directly) will use.
5158#[allow(dead_code)] // exercised by its own test; no interruptible probe calls it yet
5159pub(crate) fn run_while_not_cancelled(
5160    cancel: &AtomicBool,
5161    mut step: impl FnMut() -> bool,
5162) -> usize {
5163    let mut ran = 0;
5164    while !cancel.load(Ordering::Acquire) {
5165        if !step() {
5166            break;
5167        }
5168        ran += 1;
5169    }
5170    ran
5171}
5172
5173#[cfg(test)]
5174mod tests {
5175    use std::fs;
5176    use std::process::Command;
5177    use std::sync::mpsc;
5178
5179    use super::*;
5180    use crate::entity::{AheadBehind, DefaultBranchStopped, WorktreeState};
5181    use crate::liveness::{BACKSTOP, FIXTURE_LIFETIME, wait_for};
5182    use crate::snapshot::{RowSummary, summary};
5183    use crate::test_support::{git, head_sha, loose_object_count};
5184
5185    fn init_repo_with_a_commit(path: &Path) {
5186        fs::create_dir_all(path).expect("create repo dir");
5187        gix::init(path).expect("init repo");
5188        let status = Command::new("git")
5189            .arg("-C")
5190            .arg(path)
5191            .args(["-c", "user.email=test@example.com", "-c", "user.name=Test"])
5192            .args(["commit", "--allow-empty", "-m", "first"])
5193            .status()
5194            .expect("run git commit");
5195        assert!(status.success());
5196    }
5197
5198    /// A second (or later) commit against an already-initialised repo at `path`,
5199    /// with the same explicit identity `init_repo_with_a_commit` supplies: never
5200    /// relying on a global git identity, which a machine running CI has none of.
5201    /// Commits a real change, which is what the poll's own user story is about and what an
5202    /// empty commit is not: `git add` rewrites `.git/index` unconditionally, while whether a
5203    /// commit with nothing staged rewrites it is left to git's racy-entry heuristic and
5204    /// differs between platforms. `index` is the only one of the polled paths a commit on an
5205    /// attached HEAD moves, so a test that depends on an empty commit moving it is testing
5206    /// that heuristic rather than the poll.
5207    fn commit_a_change(path: &Path, message: &str) {
5208        let gitdir = gitdir_of(path);
5209        let before = poll::fingerprint(&gitdir);
5210
5211        std::fs::write(path.join(format!("{message}.txt")), message.as_bytes())
5212            .expect("write a file to commit");
5213        let added = Command::new("git")
5214            .arg("-C")
5215            .arg(path)
5216            .args(["add", "-A"])
5217            .status()
5218            .expect("run git add");
5219        assert!(added.success());
5220        commit(path, message, &["-m", message]);
5221
5222        // The fixture's own premise, asserted rather than assumed: a commit on an attached
5223        // HEAD moves none of the polled paths except `index` (`HEAD` is untouched, and
5224        // rewriting `refs/heads/<branch>` does not move `refs/` itself), so if git leaves
5225        // `index` alone here there is nothing for the poll to see and the failure belongs to
5226        // this fixture, not to the sweep it is setting up.
5227        assert!(
5228            poll::moved(&before, &poll::fingerprint(&gitdir)),
5229            "committing in {} moved none of the polled paths under {}, so this fixture cannot \
5230             show the poll anything",
5231            path.display(),
5232            gitdir.display()
5233        );
5234    }
5235
5236    /// The absolute gitdir git itself reports, which for a linked Worktree is its own
5237    /// `.git/worktrees/<name>` rather than the `.git` file beside the checkout.
5238    fn gitdir_of(work_dir: &Path) -> PathBuf {
5239        let output = Command::new("git")
5240            .arg("-C")
5241            .arg(work_dir)
5242            .args(["rev-parse", "--absolute-git-dir"])
5243            .output()
5244            .expect("run git rev-parse");
5245        assert!(
5246            output.status.success(),
5247            "resolve the gitdir of {}",
5248            work_dir.display()
5249        );
5250        PathBuf::from(
5251            std::str::from_utf8(&output.stdout)
5252                .expect("a utf-8 gitdir path")
5253                .trim(),
5254        )
5255    }
5256
5257    /// The shared tail of the commit helpers.
5258    fn commit(path: &Path, message: &str, args: &[&str]) {
5259        let status = Command::new("git")
5260            .arg("-C")
5261            .arg(path)
5262            .args(["-c", "user.email=test@example.com", "-c", "user.name=Test"])
5263            .arg("commit")
5264            .args(args)
5265            .status()
5266            .unwrap_or_else(|error| panic!("run git commit {message}: {error}"));
5267        assert!(status.success());
5268    }
5269
5270    /// A `FetchSpec` that never fires on its own: `enabled: false`, so every
5271    /// existing test that does not care about the periodic fetch keeps behaving
5272    /// exactly as it did before this field existed.
5273    fn fetch_spec_for_test() -> FetchSpec {
5274        FetchSpec {
5275            enabled: false,
5276            interval: Duration::from_secs(3600),
5277            concurrency: 4,
5278        }
5279    }
5280
5281    /// An `AutoUpdateSpec` that never fires on its own, the same reason
5282    /// [`fetch_spec_for_test`] never does: every existing test that does not care
5283    /// about the auto-update keeps behaving exactly as it did before this field
5284    /// existed.
5285    fn auto_update_spec_for_test() -> AutoUpdateSpec {
5286        AutoUpdateSpec { enabled: false }
5287    }
5288
5289    fn spec(roots: Vec<PathBuf>) -> CoreSpec {
5290        CoreSpec {
5291            set: SetSpec {
5292                name: "test".to_string(),
5293                roots,
5294                include: Vec::new(),
5295                exclude: Vec::new(),
5296            },
5297            overrides: Vec::new(),
5298            poll_interval: Duration::from_secs(3600),
5299            status_stale_after: Duration::from_secs(3600),
5300            generation_deadline: Duration::from_secs(3600),
5301            show_submodules: false,
5302            fetch: fetch_spec_for_test(),
5303            auto_update: auto_update_spec_for_test(),
5304        }
5305    }
5306
5307    /// Criterion 2's "no field" half: scope is never a partial dial, not even as a field
5308    /// on the plain-data struct crossing into the core. An exhaustive destructure names
5309    /// every field `CoreSpec` has; a scoping field added under any name fails to compile
5310    /// this test rather than landing unacknowledged. `show_submodules` is named here too,
5311    /// deliberately: it narrows probing and rendering, never what discovery bounds, so it
5312    /// is not the scoping field this test guards against
5313    /// ([discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md)'s
5314    /// "narrows the view rather than bounding the work"). `fetch` and `auto_update` are
5315    /// excluded from that same guard for the same reason: they narrow what the periodic
5316    /// fetch and the fast-forward-only update touch, never what discovery bounds.
5317    #[test]
5318    fn core_spec_carries_no_scoping_field_scope_is_never_a_dial() {
5319        let CoreSpec {
5320            set: _,
5321            overrides: _,
5322            poll_interval: _,
5323            status_stale_after: _,
5324            generation_deadline: _,
5325            show_submodules: _,
5326            fetch: _,
5327            auto_update: _,
5328        } = spec(Vec::new());
5329    }
5330
5331    fn root_of(dir: &tempfile::TempDir) -> PathBuf {
5332        dir.path().canonicalize().expect("canonicalize temp dir")
5333    }
5334
5335    /// Blocks until `core`'s launch Generation has settled, and hands back what it settled
5336    /// to.
5337    ///
5338    /// `Core::start`'s own first walk is that `Core`'s Generation 1 and probes every row it
5339    /// finds, so a test that counts what a later Generation did, or that watches a cell
5340    /// only its own Generation may write, has to begin from a table launch has already
5341    /// finished with. [`BACKSTOP`] rather than a budget, and the gate is read afterwards so
5342    /// an expired wait fails here by name instead of downstream as a wrong value.
5343    fn settle_launch(core: &Core) -> Snapshot {
5344        let launched = core.settle();
5345        assert_eq!(
5346            core.settle_gate_count_for_test(),
5347            0,
5348            "launch's own Generation never settled, so nothing after this is starting from \
5349             the point it claims to"
5350        );
5351        launched
5352    }
5353
5354    /// [`settle_launch`] over a `Core` built the ordinary way, for the many tests that want
5355    /// nothing else from the constructor.
5356    fn started_and_settled(spec: CoreSpec) -> (Core, Snapshot) {
5357        let core = Core::start_discovered(spec);
5358        let launched = settle_launch(&core);
5359        (core, launched)
5360    }
5361
5362    /// Sets every polled gitdir entry's modification time ten seconds into the past, so any
5363    /// write that follows reads as newer than the baseline by more than a filesystem's
5364    /// timestamp granularity. Without it a commit made microseconds after the baseline sweep
5365    /// lands in the same coarse tick on Linux and reads as no movement at all, which is a race
5366    /// in the harness rather than in the poll: real sweeps are a configured interval apart.
5367    /// Reads the polled names from [`poll::POLLED_GITDIR_ENTRIES`] rather than restating them.
5368    fn backdate_polled_entries(work_dir: &Path) {
5369        let gitdir = gitdir_of(work_dir);
5370
5371        let past = std::time::SystemTime::now() - Duration::from_secs(10);
5372        let mut touched = 0;
5373        for name in poll::POLLED_GITDIR_ENTRIES {
5374            let path = gitdir.join(name);
5375            if path.exists() {
5376                set_mtime_to(&path, past);
5377                touched += 1;
5378            }
5379        }
5380        assert!(
5381            touched > 0,
5382            "backdated nothing under {}; the gitdir holds none of the polled entries and the \
5383             baseline this sets up would not be older than what follows",
5384            gitdir.display()
5385        );
5386    }
5387
5388    /// `utimensat`, since a plain file handle cannot set a directory's time and `refs` is one.
5389    fn set_mtime_to(path: &Path, at: std::time::SystemTime) {
5390        use std::os::unix::ffi::OsStrExt;
5391
5392        let secs = at
5393            .duration_since(std::time::SystemTime::UNIX_EPOCH)
5394            .expect("a time after the epoch")
5395            .as_secs() as libc::time_t;
5396        let times = [
5397            libc::timespec {
5398                tv_sec: secs,
5399                tv_nsec: 0,
5400            },
5401            libc::timespec {
5402                tv_sec: secs,
5403                tv_nsec: 0,
5404            },
5405        ];
5406        let c_path =
5407            std::ffi::CString::new(path.as_os_str().as_bytes()).expect("a path with no NUL");
5408        let rc = unsafe { libc::utimensat(libc::AT_FDCWD, c_path.as_ptr(), times.as_ptr(), 0) };
5409        assert_eq!(
5410            rc,
5411            0,
5412            "set mtime on {}: {}",
5413            path.display(),
5414            std::io::Error::last_os_error()
5415        );
5416    }
5417
5418    fn step(argv: &[&str]) -> Step {
5419        Step {
5420            argv: argv.iter().map(|s| s.to_string()).collect(),
5421            shell: false,
5422            interactive: false,
5423            env: Vec::new(),
5424        }
5425    }
5426
5427    /// `shell = true`'s own convention: one argv element, the whole command string.
5428    fn shell_step(command: &str) -> Step {
5429        Step {
5430            argv: vec![command.to_string()],
5431            shell: true,
5432            interactive: false,
5433            env: Vec::new(),
5434        }
5435    }
5436
5437    /// `shell = true` plus `interactive = true`: the same convention, run through
5438    /// `$SHELL -ic` instead of `$SHELL -c`.
5439    fn interactive_shell_step(command: &str) -> Step {
5440        Step {
5441            argv: vec![command.to_string()],
5442            shell: true,
5443            interactive: true,
5444            env: Vec::new(),
5445        }
5446    }
5447
5448    /// The one entity's Action receipt, if the run that wrote it is the one `label` names:
5449    /// a run that replaced an earlier run's receipt on the same row is what these reads are
5450    /// distinguishing.
5451    fn receipt_labelled(core: &Core, key: &EntityKey, label: &str) -> Option<ActionReceipt> {
5452        core.snapshot()
5453            .entities
5454            .iter()
5455            .find(|entity| entity.key == *key)
5456            .and_then(|entity| entity.last_action.clone())
5457            .filter(|receipt| &*receipt.label == label)
5458    }
5459
5460    fn action(label: &str, steps: Vec<Step>) -> ActionSpec {
5461        ActionSpec {
5462            label: Arc::from(label),
5463            name: Some(Arc::from(label)),
5464            steps,
5465            concurrency: 4,
5466            when: None,
5467        }
5468    }
5469
5470    /// [`action`], narrowed by `when`, a Filter grammar predicate
5471    /// (`docs/spec/actions.md`'s "The Selection and the gate").
5472    fn action_with_when(label: &str, steps: Vec<Step>, when: &str) -> ActionSpec {
5473        ActionSpec {
5474            when: Some(Filter::parse(when)),
5475            ..action(label, steps)
5476        }
5477    }
5478
5479    /// End-to-end: the test thread never spawns anything itself, only calls
5480    /// `Core`'s public methods, and real branch data still lands in the snapshot.
5481    /// That is the proof that the core owns the threads doing the work, not the
5482    /// consumer.
5483    #[test]
5484    fn refresh_and_settle_populate_real_cells_without_the_caller_spawning_a_thread() {
5485        let dir = tempfile::tempdir().expect("temp dir");
5486        let root = root_of(&dir);
5487        let repo = root.join("repo");
5488        init_repo_with_a_commit(&repo);
5489
5490        let core = Core::start_discovered(spec(vec![root]));
5491        let keys: Vec<EntityKey> = core
5492            .snapshot()
5493            .entities
5494            .iter()
5495            .map(|entity| entity.key.clone())
5496            .collect();
5497        assert_eq!(keys.len(), 1);
5498
5499        core.refresh(&keys);
5500        let settled = core.settle();
5501
5502        let entity = &settled.entities[0];
5503        match entity.branch.settled() {
5504            Some(Settled::Known {
5505                value: Head::Branch { .. },
5506                at: _,
5507                stale: _,
5508            }) => {}
5509            other => panic!("expected an attached branch, got {other:?}"),
5510        }
5511    }
5512
5513    // --- Single source of truth: read the first-frame budgets from the spec itself,
5514    // the same pattern `executor.rs` already uses for its PTY width and capture bounds
5515    // against `docs/spec/actions.md`. ---
5516
5517    fn spec_refresh_md() -> String {
5518        let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
5519        std::fs::read_to_string(manifest_dir.join("../../docs/spec/refresh.md"))
5520            .expect("read docs/spec/refresh.md")
5521    }
5522
5523    fn spec_first_frame_budgets_ms(spec: &str) -> (u64, u64) {
5524        let anchor = "rows with names on screen within ";
5525        let after = spec
5526            .split(anchor)
5527            .nth(1)
5528            .expect("the first-frame budget sentence is present");
5529        let mut parts = after.splitn(2, "ms, every cheap column filled within ");
5530        let names: u64 = parts
5531            .next()
5532            .expect("a names-on-screen budget")
5533            .parse()
5534            .expect("the names-on-screen budget is an integer");
5535        let after_cheap = parts.next().expect("a cheap-column budget and beyond");
5536        let cheap_columns: u64 = after_cheap
5537            .split("ms,")
5538            .next()
5539            .expect("a cheap-column budget")
5540            .parse()
5541            .expect("the cheap-column budget is an integer");
5542        (names, cheap_columns)
5543    }
5544
5545    /// Criterion 1: the two budgets `refresh.md`'s "The first frame" states are declared
5546    /// once as named constants and cross-checked against the spec sentence here, so the
5547    /// spec and the code cannot drift apart silently.
5548    #[test]
5549    fn first_frame_budget_constants_match_the_spec_of_record() {
5550        let spec = spec_refresh_md();
5551        let (names_ms, cheap_columns_ms) = spec_first_frame_budgets_ms(&spec);
5552        assert_eq!(names_ms, FIRST_FRAME_NAMES_BUDGET_MS);
5553        assert_eq!(cheap_columns_ms, FIRST_FRAME_CHEAP_COLUMNS_BUDGET_MS);
5554    }
5555
5556    /// Criterion 2: every entity a Generation is dispatched over gets its phase C read,
5557    /// never a subset. `refresh.md`'s "Scope and order" makes scope never a partial dial,
5558    /// so this proves it against a population wide enough that a mistaken "first K" or
5559    /// "last K" scoping mistake would leave a visible gap: sixteen real repos, dispatched in
5560    /// one Generation, every one of them still `dirty: Known` once settled, position sixteen
5561    /// exactly as covered as position one. A mutation that scoped phase C to, say, the first
5562    /// ten dispatched entities fails this directly.
5563    #[test]
5564    fn every_dispatched_entity_gets_its_dirty_cell_settled_not_a_subset() {
5565        let dir = tempfile::tempdir().expect("temp dir");
5566        let root = root_of(&dir);
5567        const ENTITY_COUNT: usize = 16;
5568        for index in 0..ENTITY_COUNT {
5569            init_repo_with_a_commit(&root.join(format!("repo-{index}")));
5570        }
5571
5572        let core = Core::start_discovered(spec(vec![root]));
5573        let keys: Vec<EntityKey> = core
5574            .snapshot()
5575            .entities
5576            .iter()
5577            .map(|entity| entity.key.clone())
5578            .collect();
5579        assert_eq!(keys.len(), ENTITY_COUNT, "expected every repo discovered");
5580
5581        core.refresh(&keys);
5582        let settled = core.settle();
5583
5584        for entity in &settled.entities {
5585            assert!(
5586                matches!(
5587                    entity.dirty.settled(),
5588                    Some(Settled::Known {
5589                        value: _,
5590                        at: _,
5591                        stale: _
5592                    })
5593                ),
5594                "entity {:?} was left without a settled dirty cell, which is exactly what a \
5595                 visibility-scoped dispatch would leave behind on the entities it skipped: \
5596                 got {:?}",
5597                entity.name,
5598                entity.dirty.settled()
5599            );
5600        }
5601    }
5602
5603    /// refresh.md's "The first frame" budget (cheap columns filled within 200ms) is
5604    /// unreachable if the cheap outcomes wait behind phase C, so this proves the two
5605    /// applies are independent with a blocking seam rather than a sleep or a wall-clock
5606    /// deadline: `Core::hold_phase_c_for_test` holds phase C (and D) open after the cheap
5607    /// outcomes have already landed, and the test observes `branch` carrying this
5608    /// Generation's answer while `dirty` still carries the previous one. Run this against
5609    /// a version that bundles every outcome into one apply placed after phase C computes
5610    /// (this ticket's regression) and it fails, since nothing writes `branch` until that
5611    /// single bundled apply lands alongside `dirty`.
5612    ///
5613    /// Launch's own Generation is drained first and both cells are then moved, so each is
5614    /// read on the value it holds rather than on being blank: a table that has already
5615    /// been probed once is the only starting point available now that `Core::start` runs
5616    /// a Generation of its own, and reading values is the stronger claim anyway.
5617    #[test]
5618    fn cheap_outcomes_land_before_a_held_phase_c_settles() {
5619        let dir = tempfile::tempdir().expect("temp dir");
5620        let root = root_of(&dir);
5621        let repo = root.join("repo");
5622        init_repo_with_a_commit(&repo);
5623
5624        let (core, launched) = started_and_settled(spec(vec![root]));
5625        let key = launched.entities[0].key.clone();
5626        assert_eq!(
5627            dirty_total(&launched.entities[0]),
5628            0,
5629            "the fixture starts clean, which is the value the held phase C must still be \
5630             reading once the working tree below has moved"
5631        );
5632
5633        // One move per phase, so neither cell can be read on absence: `branch` is phase A
5634        // and must carry the new name while phase C is held, `dirty` is phase C and must
5635        // still carry launch's own clean count until it is released.
5636        git(&repo, &["checkout", "-b", "held"]);
5637        fs::write(repo.join("untracked.txt"), b"uncommitted")
5638            .expect("write an untracked file into the fixture");
5639
5640        core.hold_phase_c_for_test(&key);
5641        core.refresh(std::slice::from_ref(&key));
5642        core.wait_phase_c_landed_for_test(&key);
5643
5644        let mid_flight = core.snapshot();
5645        let entity = mid_flight
5646            .entities
5647            .iter()
5648            .find(|entity| entity.key == key)
5649            .expect("entity present");
5650        assert!(
5651            matches!(
5652                entity.branch.settled(),
5653                Some(Settled::Known {
5654                    value: Head::Branch { name, .. },
5655                    at: _,
5656                    stale: _
5657                }) if &**name == "held"
5658            ),
5659            "the cheap branch cell must carry this Generation's own answer while phase C is \
5660             still held open, got {:?}",
5661            entity.branch.settled()
5662        );
5663        assert!(
5664            entity.dirty.is_in_flight() && dirty_total(entity) == 0,
5665            "phase C is deliberately held open here; a bundled apply would already have \
5666             written this cell's new count alongside branch, got {:?}",
5667            entity.dirty.settled()
5668        );
5669
5670        core.release_phase_c_for_test(&key);
5671        core.wait_phase_c_finished_for_test(&key);
5672
5673        let settled = core.snapshot();
5674        let entity = settled
5675            .entities
5676            .iter()
5677            .find(|entity| entity.key == key)
5678            .expect("entity present");
5679        assert_eq!(
5680            dirty_total(entity),
5681            1,
5682            "phase C must settle its own count once released, got {:?}",
5683            entity.dirty.settled()
5684        );
5685    }
5686
5687    /// One entity's settled dirty count, or a panic naming what it read instead. Lets a
5688    /// test that has to distinguish two Generations by value say "still zero" and "now
5689    /// one" without repeating the match on every read.
5690    fn dirty_total(entity: &EntityState) -> u32 {
5691        match entity.dirty.settled() {
5692            Some(Settled::Known {
5693                value,
5694                at: _,
5695                stale: _,
5696            }) => value.total(),
5697            other => panic!("expected a settled dirty count, got {other:?}"),
5698        }
5699    }
5700
5701    /// Splitting one dispatched entity's write into a cheap apply and a phase C/D apply
5702    /// must still signal `settle_gate` exactly once per entity, or `settle` hangs (never
5703    /// decremented enough) or returns early (decremented twice). Two entities held open
5704    /// together prove the exact count at each step: a mutation that also decrements the
5705    /// gate from the cheap apply leaves it at 0 instead of 2 after both entities' cheap
5706    /// outcomes land, and a mutation that drops the decrement from the phase C/D apply
5707    /// leaves it at 2, never 1, once only the first entity finishes.
5708    #[test]
5709    fn splitting_the_probe_write_signals_settle_gate_exactly_once_per_entity() {
5710        let dir = tempfile::tempdir().expect("temp dir");
5711        let root = root_of(&dir);
5712        init_repo_with_a_commit(&root.join("a"));
5713        init_repo_with_a_commit(&root.join("b"));
5714
5715        let (core, snapshot) = started_and_settled(spec(vec![root]));
5716        let key_a = snapshot
5717            .entities
5718            .iter()
5719            .find(|entity| &*entity.name == "a")
5720            .expect("entity a present")
5721            .key
5722            .clone();
5723        let key_b = snapshot
5724            .entities
5725            .iter()
5726            .find(|entity| &*entity.name == "b")
5727            .expect("entity b present")
5728            .key
5729            .clone();
5730
5731        core.hold_phase_c_for_test(&key_a);
5732        core.hold_phase_c_for_test(&key_b);
5733        core.refresh(&[key_a.clone(), key_b.clone()]);
5734        // A Generation reserves its number on this thread and raises the gate on one of
5735        // its own, so this is the rendezvous that says the raise has happened. A join,
5736        // never a sleep.
5737        core.wait_dispatched_for_test();
5738        assert_eq!(
5739            core.settle_gate_count_for_test(),
5740            2,
5741            "dispatching two entities must add exactly two to the settle gate"
5742        );
5743
5744        core.wait_phase_c_landed_for_test(&key_a);
5745        core.wait_phase_c_landed_for_test(&key_b);
5746        assert_eq!(
5747            core.settle_gate_count_for_test(),
5748            2,
5749            "the cheap apply must never touch the settle gate: both entities' cheap \
5750             outcomes have landed and neither has finished phase C yet"
5751        );
5752
5753        core.release_phase_c_for_test(&key_a);
5754        core.wait_phase_c_finished_for_test(&key_a);
5755        assert_eq!(
5756            core.settle_gate_count_for_test(),
5757            1,
5758            "exactly one entity finished, so the gate must fall by exactly one, not two \
5759             (double-counted) and not zero (left short)"
5760        );
5761
5762        core.release_phase_c_for_test(&key_b);
5763        core.wait_phase_c_finished_for_test(&key_b);
5764        assert_eq!(
5765            core.settle_gate_count_for_test(),
5766            0,
5767            "both entities finished, so the gate must be fully drained"
5768        );
5769    }
5770
5771    /// The gate [`Core::hold_phase_c_for_test`] last registered for `key`, so a test can
5772    /// still name one a later registration for the same entity has replaced in the map.
5773    fn registered_gate(core: &Core, key: &EntityKey) -> PhaseCGateHandle {
5774        core.phase_c_gates
5775            .lock()
5776            .unwrap()
5777            .get(key)
5778            .cloned()
5779            .expect("hold_phase_c_for_test must be called before reading its gate")
5780    }
5781
5782    /// Opens `gate` directly rather than through [`Core::release_phase_c_for_test`], which
5783    /// resolves by key and so cannot name a gate a later registration has replaced.
5784    fn release_gate(gate: &PhaseCGateHandle) {
5785        let (lock, cvar) = &**gate;
5786        lock.lock().unwrap().may_proceed = true;
5787        cvar.notify_all();
5788    }
5789
5790    fn gate_is_finished(gate: &PhaseCGateHandle) -> bool {
5791        gate.0.lock().unwrap().finished
5792    }
5793
5794    /// A probe signals the phase C gate its own Generation was dispatched against, never
5795    /// whatever gate the map holds by the time that probe finishes.
5796    ///
5797    /// Reading the map twice per probe, once before phase C and once after, made the gate
5798    /// a probe signalled a function of when it got there: a probe from an already-settled
5799    /// Generation, past its own first read but not yet past its second, would find a gate
5800    /// registered in between and mark it finished, so the wait a later Generation was
5801    /// making returned before that Generation had applied anything or touched the settle
5802    /// gate. Registering a second gate for the same entity while the first is still held
5803    /// open is that interleaving with the timing taken out of it: the parked probe took
5804    /// the first gate, and the map holds the second by the time it finishes.
5805    #[test]
5806    fn a_probe_signals_the_phase_c_gate_its_own_generation_was_dispatched_against() {
5807        let dir = tempfile::tempdir().expect("temp dir");
5808        let root = root_of(&dir);
5809        init_repo_with_a_commit(&root.join("repo"));
5810
5811        let (core, launched) = started_and_settled(spec(vec![root]));
5812        let key = launched.entities[0].key.clone();
5813
5814        core.hold_phase_c_for_test(&key);
5815        let dispatched_against = registered_gate(&core, &key);
5816        core.refresh(std::slice::from_ref(&key));
5817        core.wait_phase_c_landed_for_test(&key);
5818
5819        core.hold_phase_c_for_test(&key);
5820        let registered_later = registered_gate(&core, &key);
5821        release_gate(&dispatched_against);
5822
5823        wait_for(
5824            "the held probe to signal the gate its own Generation was dispatched against",
5825            || gate_is_finished(&dispatched_against),
5826        );
5827        assert!(
5828            !gate_is_finished(&registered_later),
5829            "a gate registered after this Generation dispatched must never be marked \
5830             finished by it: a test waiting on that gate would return before this \
5831             Generation had applied its outcome or decremented the settle gate"
5832        );
5833    }
5834
5835    /// A probe finishing clears its own Generation's in-flight entry, never whatever the
5836    /// table holds under that key by the time it gets there.
5837    ///
5838    /// Cancellation is cooperative (refresh.md's "Cancellation"), so a superseded probe
5839    /// runs to completion and reaches `apply_probe_outcome` after the Generation that
5840    /// superseded it has already put its own entry under the same key. Clearing by key
5841    /// alone deleted that live entry, and refresh.md's "Supersession" then had nothing to
5842    /// set: the Generation after it found no previous entry, so the entity's interrupt
5843    /// flag stayed false and its probe ran on uncancelled, which is the 1.79x ADR 0013
5844    /// measured. Parking a probe at its phase C gate and superseding it while it is held
5845    /// is that interleaving with the timing taken out of it.
5846    #[test]
5847    fn a_probe_finishing_clears_only_its_own_generations_in_flight_entry() {
5848        let dir = tempfile::tempdir().expect("temp dir");
5849        let root = root_of(&dir);
5850        init_repo_with_a_commit(&root.join("repo"));
5851
5852        let (core, launched) = started_and_settled(spec(vec![root]));
5853        let key = launched.entities[0].key.clone();
5854
5855        core.hold_phase_c_for_test(&key);
5856        core.refresh(std::slice::from_ref(&key));
5857        core.wait_phase_c_landed_for_test(&key);
5858
5859        // The Generation that supersedes the parked probe, holding the interrupt flag the
5860        // `refresh` below has to be able to find and set.
5861        let superseding = core.begin_shared_generation_for_test(std::slice::from_ref(&key));
5862
5863        core.release_phase_c_for_test(&key);
5864        core.wait_phase_c_finished_for_test(&key);
5865
5866        core.refresh(std::slice::from_ref(&key));
5867        core.wait_dispatched_for_test();
5868
5869        assert!(
5870            superseding.cancels[&key].load(Ordering::Acquire),
5871            "a probe from a Generation that has already been superseded must leave the \
5872             live Generation's in-flight entry alone, or the Generation after it has \
5873             nothing to interrupt"
5874        );
5875    }
5876
5877    /// Criterion 5, the honest half: a concurrent pool's *completion* order is not
5878    /// dispatch order and asserting it would make this test flaky in exact proportion to
5879    /// how well rayon's scheduler works, so this asserts *dispatch* order instead, which is
5880    /// deterministic because `refresh`'s own dispatch loop is a single sequential pass over
5881    /// `order` that spawns work without ever waiting on it. `dispatch_order` itself, the
5882    /// function that actually builds the cursor-then-visible-then-rest sequence
5883    /// `refresh.md`'s "Scope and order" names, lives in the `repon` crate and is tested
5884    /// there: `core-api.md`'s ownership table gives that computation to the consumer, never
5885    /// to this crate. What this test proves on the core side is the half core-api.md commits
5886    /// to: `refresh` dispatches in exactly the order it is handed, position for position,
5887    /// never reordered by any heuristic of its own (never, per `refresh.md`, by predicted
5888    /// cost). A hand-built three-tier order stands in for what `dispatch_order` would
5889    /// produce, six entities discovered, one named cursor, two named visible, three left
5890    /// over in discovery order.
5891    #[test]
5892    fn refresh_dispatches_phase_c_in_exactly_the_order_it_is_given() {
5893        let dir = tempfile::tempdir().expect("temp dir");
5894        let root = root_of(&dir);
5895        const ENTITY_COUNT: usize = 6;
5896        for index in 0..ENTITY_COUNT {
5897            init_repo_with_a_commit(&root.join(format!("repo-{index}")));
5898        }
5899
5900        let (core, launched) = started_and_settled(spec(vec![root]));
5901        let discovery_order: Vec<EntityKey> = launched
5902            .entities
5903            .iter()
5904            .map(|entity| entity.key.clone())
5905            .collect();
5906        assert_eq!(
5907            discovery_order.len(),
5908            ENTITY_COUNT,
5909            "expected every repo discovered"
5910        );
5911
5912        // The cursor row, then the visible rows (never the cursor's own row twice), then
5913        // everything else in discovery order: refresh.md's own three tiers, hand-assembled
5914        // the way `dispatch_order` would.
5915        let cursor = discovery_order[3].clone();
5916        let visible = [discovery_order[1].clone(), discovery_order[4].clone()];
5917        let mut three_tier_order = vec![cursor.clone()];
5918        three_tier_order.extend(visible.iter().cloned());
5919        for key in &discovery_order {
5920            if *key != cursor && !visible.contains(key) {
5921                three_tier_order.push(key.clone());
5922            }
5923        }
5924        assert_eq!(
5925            three_tier_order.len(),
5926            ENTITY_COUNT,
5927            "sanity check: the hand-built order must cover every discovered entity exactly \
5928             once"
5929        );
5930
5931        core.refresh(&three_tier_order);
5932        core.settle();
5933
5934        assert_eq!(
5935            core.dispatch_log_for_test(),
5936            three_tier_order,
5937            "refresh must dispatch phase C in exactly the order it was given: the cursor \
5938             row, then the visible rows, then the rest in discovery order"
5939        );
5940    }
5941
5942    /// The defining behaviour for the shared-handle probe path: discovery leaves
5943    /// one thread-safe handle per entity, and a `refresh` reuses that same `Arc`
5944    /// rather than opening the repository again, proven by pointer identity
5945    /// surviving a probe rather than by inference from timing.
5946    #[test]
5947    fn refresh_reuses_the_cached_repository_handle_rather_than_reopening_it() {
5948        let dir = tempfile::tempdir().expect("temp dir");
5949        let root = root_of(&dir);
5950        let repo = root.join("repo");
5951        init_repo_with_a_commit(&repo);
5952
5953        let core = Core::start_discovered(spec(vec![root]));
5954        let key = core.snapshot().entities[0].key.clone();
5955        let before = core
5956            .cached_repo_handle_for_test(&key)
5957            .expect("discovery should have cached a handle");
5958
5959        core.refresh(std::slice::from_ref(&key));
5960        core.settle();
5961
5962        let after = core
5963            .cached_repo_handle_for_test(&key)
5964            .expect("the cached handle should still be there after a refresh");
5965        assert!(
5966            Arc::ptr_eq(&before, &after),
5967            "a refresh must reuse the cached handle, not replace it with a new one"
5968        );
5969    }
5970
5971    /// `refresh_running` reads true from the instant `refresh` returns, before its spawned
5972    /// dispatch has raised a single probe: `refresh` reserves the Generation and records the
5973    /// dispatch debt on the calling thread, so a caller reading this the same frame it
5974    /// dispatched must never see a false "nothing outstanding". It reads false again once
5975    /// the Generation has fully landed.
5976    #[test]
5977    fn refresh_running_reads_true_the_instant_refresh_returns_and_false_once_it_settles() {
5978        let dir = tempfile::tempdir().expect("temp dir");
5979        let root = root_of(&dir);
5980        init_repo_with_a_commit(&root.join("repo"));
5981
5982        let core = Core::start_discovered(spec(vec![root]));
5983        core.settle();
5984        assert!(
5985            !core.refresh_running(),
5986            "sanity: nothing outstanding once startup has settled"
5987        );
5988
5989        let keys: Vec<EntityKey> = core
5990            .snapshot()
5991            .entities
5992            .iter()
5993            .map(|entity| entity.key.clone())
5994            .collect();
5995        core.refresh(&keys);
5996        assert!(
5997            core.refresh_running(),
5998            "refresh reserves its Generation and records the dispatch debt before it \
5999             returns, so this must already read true"
6000        );
6001
6002        core.settle();
6003        assert!(
6004            !core.refresh_running(),
6005            "settle blocks until nothing is outstanding, so this must read false once it \
6006             returns"
6007        );
6008    }
6009
6010    /// A key with no cached handle, either because it was never discovered or
6011    /// because discovery could not open it, still gets a real answer: the probe
6012    /// falls back to opening the repository itself rather than failing outright.
6013    #[test]
6014    fn probing_a_key_with_no_cached_handle_still_opens_the_repository_itself() {
6015        let dir = tempfile::tempdir().expect("temp dir");
6016        let root = root_of(&dir);
6017        let repo = root.join("repo");
6018        init_repo_with_a_commit(&repo);
6019
6020        // A core discovering an unrelated, empty root, so `repo` is never cached.
6021        let empty_root = root_of(&tempfile::tempdir().expect("temp dir"));
6022        let core = Core::start_discovered(spec(vec![empty_root]));
6023        let key = EntityKey::new(Arc::from(repo.as_path()));
6024        assert!(core.cached_repo_handle_for_test(&key).is_none());
6025
6026        let entity = core.probe_now(&key);
6027
6028        assert!(matches!(
6029            entity.branch.settled(),
6030            Some(Settled::Known {
6031                value: Head::Branch { .. },
6032                at: _,
6033                stale: _
6034            })
6035        ));
6036    }
6037
6038    /// An empty order names no key, so the Generation it starts must reach no entity at
6039    /// all. Read off the dispatch log and the in-flight flag rather than off an unprobed
6040    /// cell, since launch's own Generation has already filled every cell by here.
6041    #[test]
6042    fn an_empty_order_dispatches_nothing_and_settle_returns_immediately() {
6043        let dir = tempfile::tempdir().expect("temp dir");
6044        let root = root_of(&dir);
6045        let repo = root.join("repo");
6046        init_repo_with_a_commit(&repo);
6047
6048        let (core, _launched) = started_and_settled(spec(vec![root]));
6049        assert!(
6050            !core.dispatch_log_for_test().is_empty(),
6051            "launch dispatched nothing, so an empty log below would say nothing about the \
6052             empty order"
6053        );
6054
6055        core.refresh(&[]);
6056        core.wait_dispatched_for_test();
6057
6058        assert_eq!(
6059            core.dispatch_log_for_test(),
6060            Vec::new(),
6061            "an empty order must dispatch no probe"
6062        );
6063        // The number is the claim here, not a backstop: an order naming nobody raises no
6064        // probe, so the gate is already at zero and this must come back settled at once
6065        // rather than eventually.
6066        let settled = core
6067            .try_settle(Duration::from_millis(50))
6068            .expect("an empty order raises no probe, so the settle gate is already at zero");
6069        assert!(!settled.entities[0].branch.is_in_flight());
6070    }
6071
6072    /// One entity left owing a probe that nothing will ever complete: no tick is sent, so
6073    /// the deadline sweep that would otherwise time the cell out never runs, and the settle
6074    /// gate stays above zero for as long as anyone waits on it.
6075    ///
6076    /// Returns the live `Core` and the tick sender, which the caller must hold: dropping it
6077    /// stops the dedicated thread's own select arm, and a `Core` whose thread has gone is a
6078    /// different fixture from the one these waits mean to test.
6079    fn one_probe_owed_that_never_lands(
6080        dir: &tempfile::TempDir,
6081    ) -> (Core, crossbeam_channel::Sender<Instant>) {
6082        let root = root_of(dir);
6083        init_repo_with_a_commit(&root.join("repo"));
6084        let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
6085        let core = Core::start_for_test(spec(vec![root]), Duration::from_secs(3600), tick_rx)
6086            .discovered()
6087            .core;
6088        let key = settle_launch(&core).entities[0].key.clone();
6089        core.begin_untracked_probe_for_test(&key);
6090        (core, tick_tx)
6091    }
6092
6093    /// The defect this pair exists for: a settle that gives up used to be indistinguishable
6094    /// from one that succeeded, so the table it handed back was read as an answer and the
6095    /// run failed several steps downstream with nothing left naming the wait.
6096    ///
6097    /// [`Core::settle`]'s half is to report at the wait, the way `liveness::wait_for` does.
6098    /// Driven through `settle_within` rather than `settle` so the expiry path is exercised
6099    /// without waiting out a real backstop.
6100    #[test]
6101    #[should_panic(expected = "waiting for everything this Core has in flight to land")]
6102    fn a_settle_that_expires_reports_at_the_wait_rather_than_returning_the_table() {
6103        let dir = tempfile::tempdir().expect("temp dir");
6104        let (core, _tick_tx) = one_probe_owed_that_never_lands(&dir);
6105
6106        core.settle_within(Duration::from_millis(20));
6107    }
6108
6109    /// [`Core::try_settle`]'s half of the same claim, for the callers that mean to degrade
6110    /// rather than fail: the expiry comes back as `Err`, so the unsettled table can only be
6111    /// reached by a caller that has already acknowledged the wait gave up.
6112    #[test]
6113    fn try_settle_hands_an_expiry_back_as_an_error_carrying_the_table_it_gave_up_on() {
6114        let dir = tempfile::tempdir().expect("temp dir");
6115        let (core, _tick_tx) = one_probe_owed_that_never_lands(&dir);
6116
6117        let unsettled = core
6118            .try_settle(Duration::from_millis(20))
6119            .expect_err("a probe nothing will ever complete cannot settle");
6120
6121        assert!(
6122            unsettled.entities[0].branch.is_in_flight(),
6123            "the Err arm must still carry the table as it stood, so a caller that degrades \
6124             deliberately has something to degrade with"
6125        );
6126    }
6127
6128    /// The other arm, so the two are told apart by what actually happened rather than by
6129    /// `Err` being the only reachable answer.
6130    #[test]
6131    fn try_settle_hands_a_generation_that_really_landed_back_as_ok() {
6132        let dir = tempfile::tempdir().expect("temp dir");
6133        let root = root_of(&dir);
6134        init_repo_with_a_commit(&root.join("repo"));
6135
6136        let (core, launched) = started_and_settled(spec(vec![root]));
6137        let key = launched.entities[0].key.clone();
6138        core.refresh(std::slice::from_ref(&key));
6139
6140        let settled = core
6141            .try_settle(BACKSTOP)
6142            .expect("a dispatched Generation must land inside the backstop");
6143
6144        assert!(!settled.entities[0].branch.is_in_flight());
6145    }
6146
6147    /// A Launcher return re-probes one entity through `probe_now`, so every cell a
6148    /// Generation settles must settle here too. `sync` is the one most recently added and
6149    /// the one a merge is most likely to drop, since no other test reads it off this path.
6150    #[test]
6151    fn probe_now_settles_the_sync_cell_as_well_as_the_branch_it_depends_on() {
6152        let dir = tempfile::tempdir().expect("temp dir");
6153        let root = root_of(&dir);
6154        let repo = root.join("repo");
6155        init_repo_with_a_commit(&repo);
6156
6157        let core = Core::start_discovered(spec(vec![root]));
6158        let key = core.snapshot().entities[0].key.clone();
6159
6160        let entity = core.probe_now(&key);
6161
6162        assert!(
6163            matches!(
6164                entity.sync.settled(),
6165                Some(Settled::Known {
6166                    value: SyncState::NoRemote,
6167                    at: _,
6168                    stale: _
6169                })
6170            ),
6171            "expected probe_now to settle sync, got {:?}",
6172            entity.sync.settled()
6173        );
6174    }
6175
6176    /// The same guard as the `sync` one above, for `base`: `probe_now` must settle it
6177    /// too, not only the dispatch loop `refresh` drives.
6178    #[test]
6179    fn probe_now_settles_the_base_cell_as_well_as_the_branch_it_depends_on() {
6180        let dir = tempfile::tempdir().expect("temp dir");
6181        let root = root_of(&dir);
6182        let repo = root.join("repo");
6183        init_repo_with_a_commit(&repo);
6184
6185        let core = Core::start_discovered(spec(vec![root]));
6186        let key = core.snapshot().entities[0].key.clone();
6187
6188        let entity = core.probe_now(&key);
6189
6190        assert!(
6191            matches!(entity.base.settled(), Some(Settled::NotApplicable)),
6192            "expected probe_now to settle base Not applicable for a Repo with no remote, \
6193             got {:?}",
6194            entity.base.settled()
6195        );
6196    }
6197
6198    /// The end-to-end wiring `probe_now`'s own guard above cannot prove: a real
6199    /// `refresh` dispatch, through `CheapProbeOutcomes`, must land a genuine
6200    /// computed `base` count on the table, not just a Not-applicable fallback.
6201    #[test]
6202    fn refresh_settles_a_real_base_count_against_the_resolved_default_branch() {
6203        let dir = tempfile::tempdir().expect("temp dir");
6204        let root = root_of(&dir);
6205        let repo = root.join("repo");
6206        init_repo_with_a_commit(&repo);
6207        git(
6208            &repo,
6209            &[
6210                "remote",
6211                "add",
6212                "origin",
6213                "https://example.invalid/repo.git",
6214            ],
6215        );
6216        let root_sha = head_sha(&repo);
6217        // The default branch (`origin/main`, resolved through rung 3's name list
6218        // since no `origin/HEAD` exists) moves one commit ahead of this Repo's own
6219        // checked-out branch, which never gets its own upstream configured, so
6220        // `sync` reads `-` while `base` still has a resolved default branch to
6221        // count behind.
6222        git(&repo, &["commit", "--allow-empty", "-m", "second"]);
6223        let tip_sha = head_sha(&repo);
6224        git(&repo, &["reset", "--hard", &root_sha]);
6225        git(&repo, &["update-ref", "refs/remotes/origin/main", &tip_sha]);
6226
6227        let core = Core::start_discovered(spec(vec![root]));
6228        let key = core.snapshot().entities[0].key.clone();
6229
6230        core.refresh(std::slice::from_ref(&key));
6231        let settled = core.settle();
6232
6233        assert!(
6234            matches!(
6235                settled.entities[0].base.settled(),
6236                Some(Settled::Known {
6237                    value: 1,
6238                    at: _,
6239                    stale: _
6240                })
6241            ),
6242            "expected a real refresh to settle base's live count against the resolved \
6243             default branch, got {:?}",
6244            settled.entities[0].base.settled()
6245        );
6246    }
6247
6248    /// The same guard as the `sync` one above, for `dirty`: it is the cell most recently
6249    /// added to this path, and dropping its settle here leaves every other test green.
6250    /// The repo carries one untracked file so a settled cell has to hold the counted
6251    /// value, not a zeroed placeholder that a default-constructed `DirtyCounts` would
6252    /// also satisfy.
6253    #[test]
6254    fn probe_now_settles_the_dirty_cell_with_the_counts_it_probed() {
6255        let dir = tempfile::tempdir().expect("temp dir");
6256        let root = root_of(&dir);
6257        let repo = root.join("repo");
6258        init_repo_with_a_commit(&repo);
6259        fs::write(repo.join("untracked.txt"), "x").expect("write untracked file");
6260
6261        let core = Core::start_discovered(spec(vec![root]));
6262        let key = core.snapshot().entities[0].key.clone();
6263
6264        let entity = core.probe_now(&key);
6265
6266        assert!(
6267            matches!(
6268                entity.dirty.settled(),
6269                Some(Settled::Known {
6270                    value: DirtyCounts {
6271                        modified: 0,
6272                        untracked: 1,
6273                        deleted: 0,
6274                    },
6275                    at: _,
6276                    stale: _
6277                })
6278            ),
6279            "expected probe_now to settle dirty with the one untracked path, got {:?}",
6280            entity.dirty.settled()
6281        );
6282    }
6283
6284    #[test]
6285    fn probe_now_updates_the_entity_synchronously_with_no_refresh_call() {
6286        let dir = tempfile::tempdir().expect("temp dir");
6287        let root = root_of(&dir);
6288        let repo = root.join("repo");
6289        init_repo_with_a_commit(&repo);
6290
6291        let core = Core::start_discovered(spec(vec![root]));
6292        let key = core.snapshot().entities[0].key.clone();
6293
6294        let entity = core.probe_now(&key);
6295
6296        assert!(matches!(
6297            entity.branch.settled(),
6298            Some(Settled::Known {
6299                value: Head::Branch { .. },
6300                at: _,
6301                stale: _
6302            })
6303        ));
6304    }
6305
6306    /// The one-function guarantee: whether an entity's name is set by discovery at
6307    /// `Core::start` or by `probe_now`'s fallback insert for a key the table did
6308    /// not already know, both routes must produce the same string for the same
6309    /// path, since a future state file keys the Selection by this name and a
6310    /// second formatting of it would silently break restoring by name.
6311    #[test]
6312    fn the_display_name_agrees_between_discovery_and_probe_nows_fallback_insert() {
6313        let dir = tempfile::tempdir().expect("temp dir");
6314        let root = root_of(&dir);
6315        let repo = root.join("named-repo");
6316        init_repo_with_a_commit(&repo);
6317
6318        let core = Core::start_discovered(spec(vec![root]));
6319        let discovered = core.snapshot().entities[0].clone();
6320        assert_eq!(&*discovered.name, "named-repo");
6321
6322        core.dismiss(&discovered.key);
6323        assert!(core.snapshot().entities.is_empty());
6324
6325        let reinserted = core.probe_now(&discovered.key);
6326
6327        assert_eq!(
6328            reinserted.name, discovered.name,
6329            "the name discovery assigned and the name probe_now's fallback insert \
6330             assigns for the same path must be byte-identical"
6331        );
6332    }
6333
6334    #[test]
6335    fn dismiss_removes_the_entity_from_the_snapshot() {
6336        let dir = tempfile::tempdir().expect("temp dir");
6337        let root = root_of(&dir);
6338        let repo = root.join("repo");
6339        init_repo_with_a_commit(&repo);
6340
6341        let core = Core::start_discovered(spec(vec![root]));
6342        let key = core.snapshot().entities[0].key.clone();
6343
6344        core.dismiss(&key);
6345
6346        assert!(core.snapshot().entities.is_empty());
6347    }
6348
6349    /// Foundation for every criterion below: one entity's own steps run in order and a
6350    /// failure marks every later step `NotRun` rather than silently skipping it or
6351    /// running it anyway, exactly the closed set of four outcomes
6352    /// [config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md)'s
6353    /// "Actions" and `docs/spec/actions.md`'s "Step outcomes" both fix.
6354    ///
6355    /// The third step would succeed if it ran (`true` always exits zero), so its being
6356    /// stopped is what this test observes, not an accident of a step that would have
6357    /// failed anyway. It also writes a marker file rather than only exiting zero: a
6358    /// receipt correctly labelled `NotRun` is not, by itself, proof the step never ran
6359    /// (an implementation could execute a step and then paper over its result), so the
6360    /// missing file is evidence the receipt cannot fake.
6361    #[test]
6362    fn an_entitys_steps_run_in_order_and_a_failure_marks_every_later_step_not_run() {
6363        let dir = tempfile::tempdir().expect("temp dir");
6364        let root = root_of(&dir);
6365        let repo = root.join("repo");
6366        init_repo_with_a_commit(&repo);
6367        let marker = repo.join("step-three-ran");
6368
6369        let core = Core::start_discovered(spec(vec![root]));
6370        let key = core.snapshot().entities[0].key.clone();
6371        let steps = vec![
6372            step(&["true"]),
6373            step(&["sh", "-c", "exit 7"]),
6374            step(&["touch", "step-three-ran"]),
6375        ];
6376
6377        let started = core.run_action(action("reinstall", steps), std::slice::from_ref(&key));
6378
6379        assert!(started);
6380        wait_for("the fan-out to finish and write a receipt", || {
6381            !core.action_running()
6382        });
6383        let receipt = core.snapshot().entities[0]
6384            .last_action
6385            .clone()
6386            .expect("receipt written");
6387        assert_eq!(receipt.steps.len(), 3);
6388        assert_eq!(receipt.steps[0].outcome, StepOutcome::Ok);
6389        assert_eq!(receipt.steps[1].outcome, StepOutcome::Failed(7));
6390        assert_eq!(
6391            receipt.steps[2].outcome,
6392            StepOutcome::NotRun,
6393            "a step after a failure must be recorded NotRun, not silently dropped or run anyway"
6394        );
6395        assert!(
6396            !marker.exists(),
6397            "the third step's own `touch` must never have run: its marker file exists, so \
6398             the step ran despite being recorded NotRun"
6399        );
6400    }
6401
6402    /// Independent of stopping at a failure: three always-succeeding steps each append
6403    /// their own digit to the same file, so the file's final content pins the actual
6404    /// execution order rather than trusting that a linear scan of `action.steps` runs
6405    /// them in the sequence they were declared in.
6406    #[test]
6407    fn steps_run_in_the_order_theyre_declared_not_some_other_order() {
6408        let dir = tempfile::tempdir().expect("temp dir");
6409        let root = root_of(&dir);
6410        let repo = root.join("repo");
6411        init_repo_with_a_commit(&repo);
6412        let order_log = repo.join("order.log");
6413
6414        let core = Core::start_discovered(spec(vec![root]));
6415        let key = core.snapshot().entities[0].key.clone();
6416        let steps = vec![
6417            step(&["sh", "-c", "printf 1 >> order.log"]),
6418            step(&["sh", "-c", "printf 2 >> order.log"]),
6419            step(&["sh", "-c", "printf 3 >> order.log"]),
6420        ];
6421
6422        let started = core.run_action(action("ordering", steps), std::slice::from_ref(&key));
6423
6424        assert!(started);
6425        wait_for("the fan-out to finish and write a receipt", || {
6426            !core.action_running()
6427        });
6428        let receipt = core.snapshot().entities[0]
6429            .last_action
6430            .clone()
6431            .expect("receipt written");
6432        assert_eq!(receipt.steps.len(), 3);
6433        assert!(
6434            receipt
6435                .steps
6436                .iter()
6437                .all(|result| result.outcome == StepOutcome::Ok),
6438            "every step here always exits zero; this test isolates ordering from gating"
6439        );
6440        let content = fs::read_to_string(&order_log).expect("order.log written by the steps");
6441        assert_eq!(
6442            content, "123",
6443            "the file's content pins actual execution order; running the steps out of \
6444             declaration order would produce a different digit sequence here even though \
6445             every step still succeeds"
6446        );
6447    }
6448
6449    /// `docs/spec/actions.md`'s "The run on screen": a reader must see a step's own
6450    /// finished output "as it arrives", not only once the whole entity's run has ended.
6451    /// The second step sleeps long enough to give a poll a real window to observe the
6452    /// receipt mid-run; a version of `run_action_for_entity` that only wrote once, at the
6453    /// end, would never let this test observe `running: Some(_)` at all; it would either
6454    /// see no receipt (before) or the whole finished one (after), never the state in
6455    /// between where the first step is done and the second is still going.
6456    #[test]
6457    fn a_still_running_actions_finished_step_and_its_currently_executing_one_are_both_visible_before_the_whole_run_ends()
6458     {
6459        let dir = tempfile::tempdir().expect("temp dir");
6460        let root = root_of(&dir);
6461        let repo = root.join("repo");
6462        init_repo_with_a_commit(&repo);
6463
6464        let core = Core::start_discovered(spec(vec![root]));
6465        let key = core.snapshot().entities[0].key.clone();
6466        let steps = vec![step(&["true"]), step(&["sh", "-c", "sleep 0.5"])];
6467
6468        let started = core.run_action(action("reinstall", steps), std::slice::from_ref(&key));
6469        assert!(started);
6470
6471        // Waits specifically for the *second* step's own running receipt, not merely any
6472        // one: under a slow or busy machine the first step (`true`) can still be the one
6473        // reported running the first time this poll checks, which would assert the wrong
6474        // step's own shape below rather than a flaky pass.
6475        wait_for(
6476            "a receipt naming the second step running before the run finished",
6477            || {
6478                core.snapshot().entities[0]
6479                    .last_action
6480                    .as_ref()
6481                    .and_then(|receipt| receipt.running.as_ref())
6482                    .is_some_and(|running| running.label.contains("sleep"))
6483            },
6484        );
6485        let mid_run = core.snapshot().entities[0]
6486            .last_action
6487            .clone()
6488            .expect("receipt written");
6489        assert_eq!(
6490            mid_run.steps.len(),
6491            1,
6492            "the first, already-finished step must already be in `steps`"
6493        );
6494        assert_eq!(mid_run.steps[0].outcome, StepOutcome::Ok);
6495        let running = mid_run.running.expect("a step must be recorded running");
6496        assert!(
6497            running.label.contains("sleep"),
6498            "expected the running step's own label, got {:?}",
6499            running.label
6500        );
6501
6502        wait_for("the fan-out to finish", || !core.action_running());
6503        let finished = core.snapshot().entities[0]
6504            .last_action
6505            .clone()
6506            .expect("receipt written");
6507        assert!(
6508            finished.running.is_none(),
6509            "a finished receipt must carry no running step"
6510        );
6511        assert_eq!(finished.steps.len(), 2);
6512    }
6513
6514    /// `Step::shell` must actually reach the child, end to end through `run_action`,
6515    /// not merely be a field that parses. Prints `$0` inside the step's own
6516    /// command string: `sh -c <string>` with no third argument would leave `$0` reading
6517    /// whatever the shell defaults it to, never the literal `repon`
6518    /// [config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md)'s
6519    /// `shell = true` sentence requires. `executor.rs`'s own unit tests cover `run_step`
6520    /// directly; this proves `core.rs` actually sets `shell` on the `Step` it builds and
6521    /// passes it through.
6522    #[test]
6523    fn a_shell_true_step_runs_through_shell_c_with_repon_as_its_own_dollar_zero() {
6524        let dir = tempfile::tempdir().expect("temp dir");
6525        let root = root_of(&dir);
6526        let repo = root.join("repo");
6527        init_repo_with_a_commit(&repo);
6528
6529        let core = Core::start_discovered(spec(vec![root]));
6530        let key = core.snapshot().entities[0].key.clone();
6531        let steps = vec![shell_step("echo \"[$0]\"")];
6532
6533        let started = core.run_action(action("shell-step", steps), std::slice::from_ref(&key));
6534
6535        assert!(started);
6536        wait_for("the fan-out to finish and write a receipt", || {
6537            !core.action_running()
6538        });
6539        let receipt = core.snapshot().entities[0]
6540            .last_action
6541            .clone()
6542            .expect("receipt written");
6543        assert_eq!(receipt.steps.len(), 1);
6544        assert_eq!(receipt.steps[0].outcome, StepOutcome::Ok);
6545        assert_eq!(&*receipt.steps[0].output, b"[repon]\n");
6546        assert!(
6547            receipt.steps[0].shell,
6548            "the receipt's own StepResult::shell must carry the mode the step ran under"
6549        );
6550    }
6551
6552    /// `Step::interactive` must actually reach `run_step` end to end through `run_action`,
6553    /// the same proof `a_shell_true_step_runs_through_shell_c_with_repon_as_its_own_dollar_zero`
6554    /// already gives `shell`: this asserts `core.rs` sets `interactive` on the `Step` it
6555    /// builds and that the receipt carries it back, not the shell's own rc-sourcing
6556    /// behaviour, which `executor.rs`'s own `shell_argv` unit test already covers on the
6557    /// constructed argv.
6558    #[test]
6559    fn an_interactive_shell_true_step_runs_through_run_action_with_interactive_on_its_receipt() {
6560        let dir = tempfile::tempdir().expect("temp dir");
6561        let root = root_of(&dir);
6562        let repo = root.join("repo");
6563        init_repo_with_a_commit(&repo);
6564
6565        let core = Core::start_discovered(spec(vec![root]));
6566        let key = core.snapshot().entities[0].key.clone();
6567        let steps = vec![interactive_shell_step("true")];
6568
6569        let started = core.run_action(
6570            action("interactive-step", steps),
6571            std::slice::from_ref(&key),
6572        );
6573
6574        assert!(started);
6575        wait_for("the fan-out to finish and write a receipt", || {
6576            !core.action_running()
6577        });
6578        let receipt = core.snapshot().entities[0]
6579            .last_action
6580            .clone()
6581            .expect("receipt written");
6582        assert_eq!(receipt.steps.len(), 1);
6583        assert_eq!(receipt.steps[0].outcome, StepOutcome::Ok);
6584        assert!(
6585            receipt.steps[0].shell,
6586            "an interactive step is still a shell step"
6587        );
6588        assert!(
6589            receipt.steps[0].interactive,
6590            "the receipt's own StepResult::interactive must carry the mode the step ran under"
6591        );
6592    }
6593
6594    /// [`StepResult::shell`]'s own claim on the plain argv side, so the two modes are
6595    /// proven end to end through `run_action` rather than only `shell = true`: an ordinary
6596    /// step's receipt must read `false`, not merely default to it by construction.
6597    #[test]
6598    fn an_argv_step_runs_through_run_action_with_shell_false_on_its_receipt() {
6599        let dir = tempfile::tempdir().expect("temp dir");
6600        let root = root_of(&dir);
6601        let repo = root.join("repo");
6602        init_repo_with_a_commit(&repo);
6603
6604        let core = Core::start_discovered(spec(vec![root]));
6605        let key = core.snapshot().entities[0].key.clone();
6606        let steps = vec![Step {
6607            argv: vec!["true".to_string()],
6608            shell: false,
6609            interactive: false,
6610            env: Vec::new(),
6611        }];
6612
6613        let started = core.run_action(action("argv-step", steps), std::slice::from_ref(&key));
6614
6615        assert!(started);
6616        wait_for("the fan-out to finish and write a receipt", || {
6617            !core.action_running()
6618        });
6619        let receipt = core.snapshot().entities[0]
6620            .last_action
6621            .clone()
6622            .expect("receipt written");
6623        assert!(!receipt.steps[0].shell);
6624    }
6625
6626    /// Criterion 3's first half. `begin_shared_generation_for_test` puts the entity
6627    /// in flight against a Generation of its own, exactly as a real `refresh` would;
6628    /// this proves `run_action` cancels that Generation's own flag rather than merely
6629    /// starting alongside it, which is the difference between the 0.85s and 3.14s
6630    /// measurements `docs/spec/actions.md`'s "Refreshing around a run" reports.
6631    #[test]
6632    fn starting_an_action_cancels_any_generation_already_in_flight() {
6633        let dir = tempfile::tempdir().expect("temp dir");
6634        let root = root_of(&dir);
6635        let repo = root.join("repo");
6636        init_repo_with_a_commit(&repo);
6637
6638        let core = Core::start_discovered(spec(vec![root]));
6639        let key = core.snapshot().entities[0].key.clone();
6640        let in_flight = core.begin_shared_generation_for_test(std::slice::from_ref(&key));
6641        let cancel = in_flight
6642            .cancels
6643            .get(&key)
6644            .expect("the in-flight entity has a cancel flag")
6645            .clone();
6646        assert!(!cancel.load(Ordering::Acquire));
6647
6648        let started = core.run_action(
6649            action("reinstall", vec![step(&["true"])]),
6650            std::slice::from_ref(&key),
6651        );
6652
6653        assert!(started);
6654        assert!(
6655            cancel.load(Ordering::Acquire),
6656            "starting an Action must cancel a Generation already in flight, not share \
6657             execution with it"
6658        );
6659        // Drain the fan-out and its completion refresh so this test's background
6660        // thread does not outlive it.
6661        wait_for("the fan-out and its completion refresh to drain", || {
6662            !core.action_running()
6663        });
6664    }
6665
6666    /// Criterion 3's second half, and the double-refresh mutation this test is written
6667    /// to catch: a completed Action starting its own Generation *and* a second one
6668    /// left over from a naive implementation that also called `refresh` directly would
6669    /// both leave every entity settled, so counting settled entities alone cannot tell
6670    /// zero, one and two apart. Reading the table's own `generation` number after
6671    /// completion can: it must be the Generation immediately after the settled table
6672    /// this Action ran against, covering both entities although the Action only ever
6673    /// named one of them. Named by its order rather than by a number, so what launch
6674    /// itself mints cannot renumber the claim.
6675    #[test]
6676    fn a_finished_action_starts_exactly_one_generation_over_every_known_entity() {
6677        let dir = tempfile::tempdir().expect("temp dir");
6678        let root = root_of(&dir);
6679        let acted_on = root.join("acted-on");
6680        let untouched = root.join("untouched");
6681        init_repo_with_a_commit(&acted_on);
6682        init_repo_with_a_commit(&untouched);
6683
6684        let (core, before) = started_and_settled(spec(vec![root]));
6685        let acted_key = before
6686            .entities
6687            .iter()
6688            .find(|entity| entity.key.path() == acted_on)
6689            .expect("the acted-on entity is discovered")
6690            .key
6691            .clone();
6692
6693        let started = core.run_action(
6694            action("reinstall", vec![step(&["true"])]),
6695            std::slice::from_ref(&acted_key),
6696        );
6697
6698        assert!(started);
6699        wait_for(
6700            "the completion Generation to probe every known entity, including the one the \
6701             Action never touched",
6702            || {
6703                let snapshot = core.snapshot();
6704                snapshot.generation != before.generation
6705                    && snapshot.entities.iter().all(|entity| {
6706                        matches!(
6707                            entity.branch.settled(),
6708                            Some(Settled::Known {
6709                                value: _,
6710                                at: _,
6711                                stale: _
6712                            })
6713                        )
6714                    })
6715            },
6716        );
6717        assert_eq!(
6718            core.settle().generation,
6719            before.generation.successor(),
6720            "completion must start exactly one Generation: not zero (no refresh at all) and \
6721             not two (a double refresh)"
6722        );
6723    }
6724
6725    /// A completion dispatches its Generation while its own run is still admitted, and a
6726    /// submission arriving before that release is refused. Together those are what keeps a
6727    /// completion from dispatching over a run that replaced it: the next run's admission,
6728    /// and the cancellation it performs on the way in, can only ever follow a Generation
6729    /// this one has already started.
6730    ///
6731    /// [`Core::action_completion_boundary`] holds the completion between the two, the one
6732    /// place either half is observable: they are adjacent statements, so a test racing them
6733    /// reads whichever it happened to catch.
6734    #[test]
6735    fn a_completion_dispatches_its_generation_before_releasing_its_run() {
6736        let dir = tempfile::tempdir().expect("temp dir");
6737        let root = root_of(&dir);
6738        let repo = root.join("repo");
6739        init_repo_with_a_commit(&repo);
6740
6741        let (core, before) = started_and_settled(spec(vec![root]));
6742        let key = before.entities[0].key.clone();
6743        let armed = core.action_completion_boundary().arm();
6744
6745        assert!(core.run_action(
6746            action("finishing", vec![step(&["true"])]),
6747            std::slice::from_ref(&key)
6748        ));
6749        armed.wait_until_reached();
6750
6751        assert_eq!(
6752            core.snapshot().generation,
6753            before.generation.successor(),
6754            "the completion Generation must be dispatched before the run releases its \
6755             admission"
6756        );
6757        assert!(
6758            !core.run_action(
6759                action("racing", vec![step(&["true"])]),
6760                std::slice::from_ref(&key)
6761            ),
6762            "a submission before that release must be refused, so what a run cancels on the \
6763             way in is never a Generation the run it replaced has yet to dispatch"
6764        );
6765
6766        drop(armed);
6767        wait_for("the finished run to release its admission", || {
6768            !core.action_running()
6769        });
6770    }
6771
6772    /// Criterion 5. The excluded row gets the one legitimate `not_applicable` receipt
6773    /// with no steps; the acted-on row's own step is made to fail, which is the strong
6774    /// half of the claim: a receipt with steps that failed is still not the
6775    /// `not_applicable` shape, so nothing but an excluded row can ever produce it.
6776    #[test]
6777    fn an_excluded_row_swept_into_an_action_gets_a_not_applicable_receipt_and_no_other_path_does() {
6778        let dir = tempfile::tempdir().expect("temp dir");
6779        let root = root_of(&dir);
6780        let excluded_repo = root.join("excluded");
6781        let normal_repo = root.join("normal");
6782        init_repo_with_a_commit(&excluded_repo);
6783        init_repo_with_a_commit(&normal_repo);
6784
6785        let core = Core::start_discovered(spec_with_overrides(
6786            vec![root],
6787            vec![RepoOverride {
6788                path: excluded_repo.clone(),
6789                default_branch: None,
6790                excluded: true,
6791            }],
6792        ));
6793        let snapshot = core.snapshot();
6794        let find = |path: &Path| {
6795            snapshot
6796                .entities
6797                .iter()
6798                .find(|entity| entity.key.path() == path)
6799                .unwrap_or_else(|| panic!("entity at {path:?} present"))
6800                .key
6801                .clone()
6802        };
6803        let excluded_key = find(&excluded_repo);
6804        let normal_key = find(&normal_repo);
6805        assert!(
6806            snapshot
6807                .entities
6808                .iter()
6809                .find(|entity| entity.key == excluded_key)
6810                .unwrap()
6811                .excluded
6812        );
6813
6814        let started = core.run_action(
6815            action("reinstall", vec![step(&["sh", "-c", "exit 3"])]),
6816            &[excluded_key.clone(), normal_key.clone()],
6817        );
6818
6819        assert!(started);
6820        // `!core.action_running()`, not merely "both entities have some receipt": a
6821        // still-running entity now writes an intermediate receipt naming its currently
6822        // executing step before it finishes (`docs/spec/actions.md`'s "The run on screen"),
6823        // so `last_action.is_some()` alone can be true well before `normal_key`'s own step
6824        // has actually run.
6825        wait_for("the fan-out to finish", || !core.action_running());
6826
6827        let after = core.snapshot();
6828        let receipt_of = |key: &EntityKey| {
6829            after
6830                .entities
6831                .iter()
6832                .find(|entity| entity.key == *key)
6833                .unwrap()
6834                .last_action
6835                .clone()
6836                .unwrap()
6837        };
6838        let excluded_receipt = receipt_of(&excluded_key);
6839        assert!(excluded_receipt.not_applicable());
6840        assert!(excluded_receipt.steps.is_empty());
6841
6842        let normal_receipt = receipt_of(&normal_key);
6843        assert!(
6844            !normal_receipt.not_applicable(),
6845            "a row that actually ran a step, even a failing one, must never read as \
6846             not_applicable: an excluded row is the one legitimate producer of that outcome"
6847        );
6848        assert!(!normal_receipt.steps.is_empty());
6849        assert!(normal_receipt.failed());
6850    }
6851
6852    /// Criterion 4: `operable_count` and `run_action`'s own partition must be one
6853    /// computation, not two that happen to agree today. Proven against independent
6854    /// evidence, the same way the test above does: run an Action over one excluded and
6855    /// one normal entity, then check `operable_count`'s answer against how many of the
6856    /// two actually got a real (not `not_applicable`) receipt, rather than against a
6857    /// second hand-written copy of the exclusion rule.
6858    #[test]
6859    fn operable_count_matches_how_many_entities_run_action_actually_runs_a_step_against() {
6860        let dir = tempfile::tempdir().expect("temp dir");
6861        let root = root_of(&dir);
6862        let excluded_repo = root.join("excluded");
6863        let normal_repo = root.join("normal");
6864        init_repo_with_a_commit(&excluded_repo);
6865        init_repo_with_a_commit(&normal_repo);
6866
6867        let core = Core::start_discovered(spec_with_overrides(
6868            vec![root],
6869            vec![RepoOverride {
6870                path: excluded_repo.clone(),
6871                default_branch: None,
6872                excluded: true,
6873            }],
6874        ));
6875        let snapshot = core.snapshot();
6876        let find = |path: &Path| {
6877            snapshot
6878                .entities
6879                .iter()
6880                .find(|entity| entity.key.path() == path)
6881                .unwrap_or_else(|| panic!("entity at {path:?} present"))
6882                .key
6883                .clone()
6884        };
6885        let order = [find(&excluded_repo), find(&normal_repo)];
6886
6887        assert_eq!(
6888            core.operable_count(&order),
6889            1,
6890            "one of the two rows is excluded, so exactly one is operable"
6891        );
6892
6893        let started = core.run_action(action("reinstall", vec![step(&["true"])]), &order);
6894        assert!(started);
6895
6896        wait_for("every entity in the order to carry a receipt", || {
6897            let snapshot = core.snapshot();
6898            order.iter().all(|key| {
6899                snapshot
6900                    .entities
6901                    .iter()
6902                    .find(|entity| entity.key == *key)
6903                    .and_then(|entity| entity.last_action.as_ref())
6904                    .is_some()
6905            })
6906        });
6907
6908        let after = core.snapshot();
6909        let actually_ran = after
6910            .entities
6911            .iter()
6912            .filter(|entity| order.contains(&entity.key))
6913            .filter(|entity| {
6914                entity
6915                    .last_action
6916                    .as_ref()
6917                    .is_some_and(|receipt| !receipt.not_applicable())
6918            })
6919            .count();
6920
6921        assert_eq!(
6922            core.operable_count(&order),
6923            actually_ran,
6924            "operable_count must report exactly how many rows run_action actually ran a \
6925             step against, not merely how many keys resolved"
6926        );
6927    }
6928
6929    /// [`Core::run_action_for_entity_blocking`]'s own reason to exist: it returns the
6930    /// finished receipt on the calling thread rather than handing the run off, so a caller
6931    /// needs no `wait_for` at all to see the step's own effect, unlike every `run_action`
6932    /// test above.
6933    #[test]
6934    fn run_action_for_entity_blocking_returns_the_finished_receipt_on_the_calling_thread() {
6935        let dir = tempfile::tempdir().expect("temp dir");
6936        let root = root_of(&dir);
6937        let repo = root.join("repo");
6938        init_repo_with_a_commit(&repo);
6939        let marker = repo.join("hook-ran");
6940
6941        let core = Core::start_discovered(spec_with_overrides(vec![root], Vec::new()));
6942        let key = core
6943            .snapshot()
6944            .entities
6945            .iter()
6946            .find(|entity| entity.key.path() == repo)
6947            .expect("the repo is discovered")
6948            .key
6949            .clone();
6950
6951        let receipt = core
6952            .run_action_for_entity_blocking(
6953                &action("hook", vec![step(&["touch", "hook-ran"])]),
6954                &key,
6955            )
6956            .expect("the entity is known");
6957
6958        assert!(
6959            marker.exists(),
6960            "the step must have already run by the time this call returns"
6961        );
6962        assert_eq!(receipt.steps.len(), 1);
6963        assert_eq!(receipt.steps[0].outcome, StepOutcome::Ok);
6964    }
6965
6966    /// [`step`], plus the one environment entry a `test-util` build reads an injected PTY
6967    /// setup failure from: the step reports that resource's own failure instead of ever
6968    /// spawning a child.
6969    fn step_that_cannot_prepare(argv: &[&str], resource: &str) -> Step {
6970        Step {
6971            env: vec![(
6972                executor::SETUP_FAILURE_VARIABLE.to_string(),
6973                resource.to_string(),
6974            )],
6975            ..step(argv)
6976        }
6977    }
6978
6979    /// A step whose own PTY setup fails is a failed receipt the run hands back, not a step
6980    /// that never returns: the failure names the resource, the rest of the run reports
6981    /// `NotRun`, and a later Action against the same row still succeeds. Run off this
6982    /// thread and collected through the liveness backstop, since the claim under test is
6983    /// that these calls return at all.
6984    #[test]
6985    fn a_step_whose_pty_setup_fails_finishes_the_run_and_leaves_a_later_action_working() {
6986        let dir = tempfile::tempdir().expect("temp dir");
6987        let root = root_of(&dir);
6988        let repo = root.join("repo");
6989        init_repo_with_a_commit(&repo);
6990
6991        let core = Core::start_discovered(spec_with_overrides(vec![root], Vec::new()));
6992        let key = core
6993            .snapshot()
6994            .entities
6995            .iter()
6996            .find(|entity| entity.key.path() == repo)
6997            .expect("the repo is discovered")
6998            .key
6999            .clone();
7000
7001        let (tx, rx) = mpsc::channel();
7002        thread::spawn(move || {
7003            let faulted = core.run_action_for_entity_blocking(
7004                &action(
7005                    "hook",
7006                    vec![
7007                        step_that_cannot_prepare(&["touch", "first-ran"], "notify-pipe"),
7008                        step(&["touch", "second-ran"]),
7009                    ],
7010                ),
7011                &key,
7012            );
7013            let later = core.run_action_for_entity_blocking(
7014                &action("hook", vec![step(&["touch", "later-ran"])]),
7015                &key,
7016            );
7017            let _ = tx.send((faulted, later));
7018        });
7019        let (faulted, later) = rx
7020            .recv_timeout(BACKSTOP)
7021            .expect("a run whose first step cannot prepare its pty must still hand back receipts");
7022
7023        let faulted = faulted.expect("the entity is known");
7024        assert!(
7025            matches!(faulted.steps[0].outcome, StepOutcome::Failed(code) if code != 0),
7026            "expected the first step to fail, got {:?}",
7027            faulted.steps[0].outcome
7028        );
7029        let detail = String::from_utf8_lossy(&faulted.steps[0].output).to_string();
7030        assert!(
7031            detail.contains("pipe that notices"),
7032            "expected the receipt to name the resource that failed, got {detail:?}"
7033        );
7034        assert_eq!(faulted.steps[1].outcome, StepOutcome::NotRun);
7035        assert!(
7036            !repo.join("first-ran").exists() && !repo.join("second-ran").exists(),
7037            "a step that never prepared its pty must never have run its command"
7038        );
7039
7040        let later = later.expect("the entity is known");
7041        assert_eq!(later.steps[0].outcome, StepOutcome::Ok);
7042        assert!(
7043            repo.join("later-ran").exists(),
7044            "a later Action must still run its own command"
7045        );
7046    }
7047
7048    /// `None` rather than a receipt for a key the table does not know: the same fallback
7049    /// every other key-addressed `Core` entry point gives one, and the caller's own signal
7050    /// for "no hook to consult" when a hook names a row `sync`'s own eligibility has already
7051    /// dropped.
7052    #[test]
7053    fn run_action_for_entity_blocking_answers_none_for_an_unknown_key() {
7054        let dir = tempfile::tempdir().expect("temp dir");
7055        let root = root_of(&dir);
7056        let core = Core::start_discovered(spec_with_overrides(vec![root.clone()], Vec::new()));
7057
7058        let unknown = EntityKey::new(Arc::from(root.join("never-discovered").as_path()));
7059
7060        assert!(
7061            core.run_action_for_entity_blocking(&action("hook", vec![step(&["true"])]), &unknown)
7062                .is_none()
7063        );
7064    }
7065
7066    /// The reversed decision itself: `when` now decides what runs, not only what a palette
7067    /// reports about it. A row the predicate proves runs a real step; a row it disproves
7068    /// gets a `Skip::Inapplicable` receipt with no steps and never spawns a child process at
7069    /// all, which the failing command below would have surfaced as a `Failed` step had it
7070    /// run (`docs/spec/actions.md`'s "The Selection and the gate", reversing what that
7071    /// section originally decided).
7072    #[test]
7073    fn run_action_skips_a_row_its_when_predicate_disproves_rather_than_running_it_anyway() {
7074        let dir = tempfile::tempdir().expect("temp dir");
7075        let root = root_of(&dir);
7076        let proved_repo = root.join("alpha");
7077        let disproved_repo = root.join("beta");
7078        init_repo_with_a_commit(&proved_repo);
7079        init_repo_with_a_commit(&disproved_repo);
7080
7081        let core = Core::start_discovered(spec(vec![root]));
7082        let snapshot = core.snapshot();
7083        let find = |path: &Path| {
7084            snapshot
7085                .entities
7086                .iter()
7087                .find(|entity| entity.key.path() == path)
7088                .unwrap_or_else(|| panic!("entity at {path:?} present"))
7089                .key
7090                .clone()
7091        };
7092        let proved_key = find(&proved_repo);
7093        let disproved_key = find(&disproved_repo);
7094        let order = [proved_key.clone(), disproved_key.clone()];
7095
7096        // A command that would mark a real run `Failed` if it ever ran, so a disproved row
7097        // that wrongly ran a step is caught by its own outcome rather than only by `skip`.
7098        let started = core.run_action(
7099            action_with_when(
7100                "reinstall",
7101                vec![step(&["sh", "-c", "exit 3"])],
7102                "name:alpha",
7103            ),
7104            &order,
7105        );
7106        assert!(started);
7107        wait_for("the fan-out to finish", || !core.action_running());
7108
7109        let after = core.snapshot();
7110        let receipt_of = |key: &EntityKey| {
7111            after
7112                .entities
7113                .iter()
7114                .find(|entity| entity.key == *key)
7115                .unwrap()
7116                .last_action
7117                .clone()
7118                .unwrap()
7119        };
7120
7121        let proved_receipt = receipt_of(&proved_key);
7122        assert_eq!(
7123            proved_receipt.skip, None,
7124            "the row the predicate proved must actually run"
7125        );
7126        assert!(proved_receipt.failed(), "its own step still ran and failed");
7127
7128        let disproved_receipt = receipt_of(&disproved_key);
7129        assert!(
7130            disproved_receipt.inapplicable(),
7131            "the row the predicate disproved must be skipped rather than run"
7132        );
7133        assert!(disproved_receipt.steps.is_empty());
7134        assert!(
7135            !disproved_receipt.failed(),
7136            "a skipped row never ran a step, so it cannot have failed one"
7137        );
7138    }
7139
7140    /// An excluded row is subtracted before an Action's `when` ever sees it, so the
7141    /// predicate narrows what is left rather than replacing that subtraction
7142    /// (`docs/spec/actions.md`'s "The Selection and the gate").
7143    ///
7144    /// Proven against `operable_count` itself rather than against a hand-written expectation:
7145    /// a predicate every remaining row satisfies must leave a total identical to that count,
7146    /// which it cannot do if the excluded row reached the tally under any of the three
7147    /// headings.
7148    #[test]
7149    fn applicability_subtracts_an_excluded_row_before_the_predicate_reads_it() {
7150        let dir = tempfile::tempdir().expect("temp dir");
7151        let root = root_of(&dir);
7152        let excluded_repo = root.join("excluded");
7153        let normal_repo = root.join("normal");
7154        init_repo_with_a_commit(&excluded_repo);
7155        init_repo_with_a_commit(&normal_repo);
7156
7157        let core = Core::start_discovered(spec_with_overrides(
7158            vec![root],
7159            vec![RepoOverride {
7160                path: excluded_repo.clone(),
7161                default_branch: None,
7162                excluded: true,
7163            }],
7164        ));
7165        let order: Vec<EntityKey> = core
7166            .snapshot()
7167            .entities
7168            .iter()
7169            .map(|entity| entity.key.clone())
7170            .collect();
7171        assert_eq!(order.len(), 2, "the fixture must discover both repos");
7172
7173        let counts = core.applicability(&order, &Filter::parse("kind:repo"));
7174
7175        assert_eq!(
7176            counts.total(),
7177            core.operable_count(&order),
7178            "the predicate must be counted over exactly the rows `operable_count` keeps"
7179        );
7180        assert_eq!(
7181            counts,
7182            Applicability {
7183                applicable: 1,
7184                inapplicable: 0,
7185                unresolved: 0,
7186            }
7187        );
7188    }
7189
7190    /// An unknown key (already dismissed, or never discovered) is silently dropped from
7191    /// the count, the same fallback `run_action` gives one: this is the half of
7192    /// `partition_operable` no fixture above exercises, since every key there resolves.
7193    #[test]
7194    fn operable_count_silently_drops_a_key_that_no_longer_resolves() {
7195        let dir = tempfile::tempdir().expect("temp dir");
7196        let root = root_of(&dir);
7197        let repo = root.join("repo");
7198        init_repo_with_a_commit(&repo);
7199
7200        let core = Core::start_discovered(spec(vec![root]));
7201        let real_key = core.snapshot().entities[0].key.clone();
7202        let unknown_key = EntityKey::new(Arc::from(dir.path().join("never-discovered")));
7203
7204        assert_eq!(core.operable_count(&[real_key, unknown_key]), 1);
7205    }
7206
7207    /// Criterion 6. The second call is rejected synchronously (admission refuses it before
7208    /// anything else runs), so this needs no waiting to observe; only the cleanup wait at
7209    /// the end needs [`wait_for`].
7210    #[test]
7211    fn only_one_action_fan_out_runs_at_a_time_a_second_call_is_rejected_while_one_is_live() {
7212        let dir = tempfile::tempdir().expect("temp dir");
7213        let root = root_of(&dir);
7214        let repo = root.join("repo");
7215        init_repo_with_a_commit(&repo);
7216
7217        let core = Core::start_discovered(spec(vec![root]));
7218        let key = core.snapshot().entities[0].key.clone();
7219        let slow = action("first", vec![step(&["sh", "-c", "sleep 0.3"])]);
7220        let fast = action("second", vec![step(&["true"])]);
7221
7222        let first_started = core.run_action(slow, std::slice::from_ref(&key));
7223        let second_started = core.run_action(fast, std::slice::from_ref(&key));
7224
7225        assert!(first_started);
7226        assert!(
7227            !second_started,
7228            "a second run_action call must be rejected while the first is still in flight"
7229        );
7230        wait_for("the accepted first fan-out to finish", || {
7231            !core.action_running()
7232        });
7233        let receipt = core.snapshot().entities[0].last_action.clone().unwrap();
7234        assert_eq!(
7235            &*receipt.label, "first",
7236            "the surviving receipt must be the accepted first run's, never the rejected second"
7237        );
7238    }
7239
7240    /// Refusing a submission must leave the live run exactly as it was: the refused call
7241    /// registers no control of its own, so the run already in flight is still the one
7242    /// `stop_action` reaches.
7243    ///
7244    /// A guard on the refusal path rather than a reproduction of anything: refusing has
7245    /// always returned before touching a control, and this pins that it still does. Both
7246    /// steps sleep [`FIXTURE_LIFETIME`], since the outcomes below cannot tell a cancelled
7247    /// step from one that reached its own end inside the wait watching it.
7248    #[test]
7249    fn a_refused_second_submission_leaves_the_first_action_still_stoppable() {
7250        let dir = tempfile::tempdir().expect("temp dir");
7251        let root = root_of(&dir);
7252        let repo = root.join("repo");
7253        init_repo_with_a_commit(&repo);
7254
7255        let core = Core::start_discovered(spec(vec![root]));
7256        let key = core.snapshot().entities[0].key.clone();
7257        let sleep_past_the_backstop = format!("sleep {}", FIXTURE_LIFETIME.as_secs());
7258        let live = action(
7259            "live",
7260            vec![
7261                step(&["sh", "-c", &sleep_past_the_backstop]),
7262                step(&["sh", "-c", &sleep_past_the_backstop]),
7263            ],
7264        );
7265
7266        assert!(core.run_action(live, std::slice::from_ref(&key)));
7267        wait_for("the live run's own first step to start", || {
7268            core.snapshot().entities[0]
7269                .last_action
7270                .as_ref()
7271                .is_some_and(|receipt| receipt.running.is_some())
7272        });
7273
7274        assert!(
7275            !core.run_action(
7276                action("refused", vec![step(&["true"])]),
7277                std::slice::from_ref(&key)
7278            ),
7279            "a second submission must be refused while one run is still live"
7280        );
7281
7282        core.stop_action();
7283
7284        wait_for("the still-controllable run to come down", || {
7285            !core.action_running()
7286        });
7287        let receipt = core.snapshot().entities[0].last_action.clone().unwrap();
7288        assert_eq!(&*receipt.label, "live");
7289        assert_eq!(
7290            receipt.steps[0].outcome,
7291            StepOutcome::Cancelled,
7292            "the refused submission must leave the live run's own control in place, so \
7293             stop_action still reaches the step it was running"
7294        );
7295        assert_eq!(
7296            receipt.steps[1].outcome,
7297            StepOutcome::Cancelled,
7298            "a step that had not started when the run was cancelled must read Cancelled too"
7299        );
7300    }
7301
7302    /// A run accepted the moment a completion releases its admission owns the controls for
7303    /// the rest of its life: that completion has nothing left to register by then, so
7304    /// `stop_action` still reaches this run's own steps.
7305    ///
7306    /// [`Core::action_completion_boundary`] pins "the moment" rather than approximating it:
7307    /// the submission made while the completion is parked must be refused, and the one made
7308    /// once it is released must be accepted, so what is stopped below is a run accepted at
7309    /// the earliest point one can be. Both of its steps sleep [`FIXTURE_LIFETIME`], since
7310    /// the outcomes asserted cannot tell a cancelled step from one that reached its own end
7311    /// inside the wait watching it.
7312    #[test]
7313    fn a_run_accepted_once_a_completion_releases_its_admission_is_still_stoppable() {
7314        let dir = tempfile::tempdir().expect("temp dir");
7315        let root = root_of(&dir);
7316        let repo = root.join("repo");
7317        init_repo_with_a_commit(&repo);
7318
7319        let core = Core::start_discovered(spec(vec![root]));
7320        let key = core.snapshot().entities[0].key.clone();
7321        let armed = core.action_completion_boundary().arm();
7322
7323        assert!(core.run_action(
7324            action("finishing", vec![step(&["true"])]),
7325            std::slice::from_ref(&key)
7326        ));
7327        armed.wait_until_reached();
7328        assert!(
7329            !core.run_action(
7330                action("early", vec![step(&["true"])]),
7331                std::slice::from_ref(&key)
7332            ),
7333            "a submission made before the completion releases its admission must be refused"
7334        );
7335        drop(armed);
7336        wait_for("the finished run to release its admission", || {
7337            !core.action_running()
7338        });
7339
7340        let sleep_past_the_backstop = format!("sleep {}", FIXTURE_LIFETIME.as_secs());
7341        let following = action(
7342            "following",
7343            vec![
7344                step(&["sh", "-c", &sleep_past_the_backstop]),
7345                step(&["sh", "-c", &sleep_past_the_backstop]),
7346            ],
7347        );
7348        assert!(
7349            core.run_action(following, std::slice::from_ref(&key)),
7350            "a submission made once that release has happened must be accepted"
7351        );
7352        wait_for("the following run's own first step to start", || {
7353            receipt_labelled(&core, &key, "following")
7354                .is_some_and(|receipt| receipt.running.is_some())
7355        });
7356
7357        core.stop_action();
7358
7359        wait_for("the cancelled run to come down", || !core.action_running());
7360        let receipt =
7361            receipt_labelled(&core, &key, "following").expect("the following run's receipt");
7362        assert_eq!(
7363            receipt.steps[0].outcome,
7364            StepOutcome::Cancelled,
7365            "the completion this run followed must leave stop_action still reaching it"
7366        );
7367        assert_eq!(
7368            receipt.steps[1].outcome,
7369            StepOutcome::Cancelled,
7370            "a cancelled run's remaining step must never start, so it reads Cancelled"
7371        );
7372    }
7373
7374    // =====================================================================================
7375    // Criteria 3 and 4: `Core::hold_action`/`Core::continue_action` are their own verbs on
7376    // the core, kept apart from the generic `pause`/`resume` the probes use, and suspending
7377    // a fan-out is reversible: a held step's own progress genuinely pauses, and resumes
7378    // exactly where it left off, rather than the run merely finishing on its own regardless.
7379    // =====================================================================================
7380
7381    /// A black-box proof through the public API alone, with no reach into the step's own
7382    /// pid: a one-second step, held for 1.5s (comfortably longer than the step would ever
7383    /// take unheld) and then continued. If `hold_action` were a no-op, the step would
7384    /// already have finished on its own well before this test ever calls
7385    /// `continue_action`, and `action_running` would already read `false` at the
7386    /// mid-hold checkpoint below; that is the exact mutation this test is written to catch.
7387    #[test]
7388    fn hold_action_genuinely_pauses_a_running_steps_progress_and_continue_action_resumes_it() {
7389        let dir = tempfile::tempdir().expect("temp dir");
7390        let root = root_of(&dir);
7391        let repo = root.join("repo");
7392        init_repo_with_a_commit(&repo);
7393
7394        let core = Core::start_discovered(spec(vec![root]));
7395        let key = core.snapshot().entities[0].key.clone();
7396        let two_seconds = action("brief", vec![step(&["sh", "-c", "sleep 2"])]);
7397
7398        assert!(core.run_action(two_seconds, std::slice::from_ref(&key)));
7399        wait_for("the two-second step to actually start running", || {
7400            core.snapshot().entities[0]
7401                .last_action
7402                .as_ref()
7403                .is_some_and(|receipt| receipt.running.is_some())
7404        });
7405
7406        // The receipt's own `running: Some(_)` is written just before `run_step` is even
7407        // called, so it can race that call's own spawn, which is when the step's process
7408        // group is actually registered. SIGSTOP is idempotent, so pulsing `hold_action`
7409        // over a short bounded window (well inside the step's own 2s) is what makes that
7410        // race resolve deterministically rather than flakily, without ever risking a hang:
7411        // a stuck `hold_action` here fails this loop's own fixed iteration count, not this
7412        // test's wall clock.
7413        for _ in 0..20 {
7414            core.hold_action();
7415            thread::sleep(Duration::from_millis(20));
7416        }
7417
7418        thread::sleep(Duration::from_millis(1_800));
7419        assert!(
7420            core.action_running(),
7421            "a genuinely held step must not have finished on its own well past its own 2s \
7422             sleep; a no-op hold_action would already show this false here"
7423        );
7424
7425        core.continue_action();
7426        wait_for("continue_action to let the held step finish", || {
7427            !core.action_running()
7428        });
7429        let receipt = core.snapshot().entities[0].last_action.clone().unwrap();
7430        assert_eq!(receipt.steps[0].outcome, StepOutcome::Ok);
7431    }
7432
7433    /// `hold_action`, `continue_action` and `stop_action` must all be safe to call with no
7434    /// fan-out live: nothing to signal, so each is a plain no-op rather than a panic or a
7435    /// stray signal to nothing.
7436    #[test]
7437    fn hold_continue_and_stop_action_are_no_ops_with_no_fan_out_running() {
7438        let dir = tempfile::tempdir().expect("temp dir");
7439        let root = root_of(&dir);
7440        let repo = root.join("repo");
7441        init_repo_with_a_commit(&repo);
7442
7443        let core = Core::start_discovered(spec(vec![root]));
7444
7445        core.hold_action();
7446        core.continue_action();
7447        core.stop_action();
7448
7449        assert!(!core.action_running());
7450    }
7451
7452    // =====================================================================================
7453    // Criterion 1: Escape (`Core::stop_action`) cancels the fan-out with two signals, the
7454    // terminating one and then the uncatchable one after a grace, because the first is
7455    // trappable. Exercised through the real public seam, never by calling `RunControl`
7456    // directly, so this is `stop_action` end to end rather than only its own primitive.
7457    // =====================================================================================
7458
7459    /// A child that traps and ignores SIGTERM is the only fixture that actually
7460    /// discriminates the two-signal design from a one-signal one: a child that dies on
7461    /// SIGTERM alone would pass this test even if `stop_action` were mutated to drop its
7462    /// own SIGKILL follow-up entirely, which is exactly the regression this criterion
7463    /// exists to catch.
7464    ///
7465    /// The step sleeps [`FIXTURE_LIFETIME`], ten times the backstop every wait below
7466    /// carries, so a `stop_action` that stops working reads back as a named wait giving up
7467    /// rather than as the step ending on its own inside the wait watching it. That margin is
7468    /// the whole discrimination here, because the outcome assertion cannot supply it:
7469    /// `run_action_for_entity` stamps `Cancelled` on whatever was running the moment the run
7470    /// was cancelled, however the step actually ended. A run that does fail here leaves the
7471    /// trapping child alive until its own sleep ends, which is the price of a fixture the
7472    /// wait cannot outlast.
7473    #[test]
7474    fn stop_action_escalates_from_sigterm_to_sigkill_against_a_trapping_step() {
7475        let dir = tempfile::tempdir().expect("temp dir");
7476        let root = root_of(&dir);
7477        let repo = root.join("repo");
7478        init_repo_with_a_commit(&repo);
7479
7480        let core = Core::start_discovered(spec(vec![root]));
7481        let key = core.snapshot().entities[0].key.clone();
7482        let sleep_past_the_backstop = format!("trap '' TERM; sleep {}", FIXTURE_LIFETIME.as_secs());
7483        let trapping = action(
7484            "trapping",
7485            vec![step(&["sh", "-c", &sleep_past_the_backstop])],
7486        );
7487
7488        assert!(core.run_action(trapping, std::slice::from_ref(&key)));
7489        wait_for("the trapping step to actually start running", || {
7490            core.snapshot().entities[0]
7491                .last_action
7492                .as_ref()
7493                .is_some_and(|receipt| receipt.running.is_some())
7494        });
7495        // Gives the shell time to install its own trap before any signal can arrive; the
7496        // outcome asserted below is the actual proof, not this fixed delay.
7497        thread::sleep(Duration::from_millis(100));
7498
7499        core.stop_action();
7500
7501        wait_for(
7502            "a SIGTERM-trapping step to come down from the follow-up SIGKILL",
7503            || !core.action_running(),
7504        );
7505        let receipt = core.snapshot().entities[0].last_action.clone().unwrap();
7506        assert_eq!(receipt.steps.len(), 1);
7507        assert_eq!(
7508            receipt.steps[0].outcome,
7509            StepOutcome::Cancelled,
7510            "a step running when the run was cancelled must read Cancelled, never Failed"
7511        );
7512    }
7513
7514    // =====================================================================================
7515    // Criterion 2: cancellation produces `Cancelled`, never `NotRun`, which stays reserved
7516    // for being blocked by an earlier failure. Both outcomes are shown live in the same
7517    // run, on different entities, so they can be told apart rather than merely observed
7518    // one at a time.
7519    // =====================================================================================
7520
7521    /// One Action, two entities, dispatched together at `concurrency: 2`: `fail`'s own
7522    /// first step exits nonzero well before the run is ever cancelled, so its second step
7523    /// is a genuine `NotRun`; `slow`'s own first step is still sleeping when
7524    /// `stop_action` fires, so both of its steps read `Cancelled`. A test that only ever
7525    /// produced one of the two outcomes could not prove they are told apart; this fixture
7526    /// has both live in the same receipt set, so a mutation that collapsed one into the
7527    /// other would be caught by whichever entity it broke.
7528    #[test]
7529    fn cancelled_and_not_run_are_distinct_outcomes_shown_together_in_one_run() {
7530        let dir = tempfile::tempdir().expect("temp dir");
7531        let root = root_of(&dir);
7532        init_repo_with_a_commit(&root.join("fail"));
7533        init_repo_with_a_commit(&root.join("slow"));
7534
7535        let core = Core::start_discovered(spec(vec![root]));
7536        let snapshot = core.snapshot();
7537        let fail_key = snapshot
7538            .entities
7539            .iter()
7540            .find(|entity| &*entity.name == "fail")
7541            .expect("the fail entity is present")
7542            .key
7543            .clone();
7544        let slow_key = snapshot
7545            .entities
7546            .iter()
7547            .find(|entity| &*entity.name == "slow")
7548            .expect("the slow entity is present")
7549            .key
7550            .clone();
7551
7552        // One step list run against both entities: behaviour branches on the entity's own
7553        // directory name, which is `$PWD`'s basename in each entity's own working
7554        // directory, so `fail` fails immediately and `slow` is still running when this
7555        // test cancels the whole run.
7556        // `slow`'s branch sleeps `FIXTURE_LIFETIME` rather than a number of its own: the
7557        // wait below is on cancellation bringing the fan-out down, which a step that ends by
7558        // itself inside the backstop would satisfy without cancellation working at all.
7559        let branch_on_the_entity_name = format!(
7560            "case \"$(basename \"$PWD\")\" in fail) exit 1 ;; *) sleep {} ;; esac",
7561            FIXTURE_LIFETIME.as_secs()
7562        );
7563        let steps = vec![
7564            step(&["sh", "-c", &branch_on_the_entity_name]),
7565            step(&["true"]),
7566        ];
7567        let mut action_spec = action("mixed", steps);
7568        action_spec.concurrency = 2;
7569
7570        assert!(core.run_action(action_spec, &[fail_key.clone(), slow_key.clone()]));
7571
7572        // `fail` must have already finished (both its steps recorded) while `slow` is
7573        // still running its own first step: the two entities' own outcomes are captured
7574        // at the same moment, which is what makes them "shown together".
7575        wait_for(
7576            "`fail` finished and `slow` still running before cancelling",
7577            || {
7578                let snapshot = core.snapshot();
7579                let fail_done = snapshot
7580                    .entities
7581                    .iter()
7582                    .find(|entity| entity.key == fail_key)
7583                    .and_then(|entity| entity.last_action.as_ref())
7584                    .is_some_and(|receipt| receipt.steps.len() == 2);
7585                let slow_running = snapshot
7586                    .entities
7587                    .iter()
7588                    .find(|entity| entity.key == slow_key)
7589                    .and_then(|entity| entity.last_action.as_ref())
7590                    .is_some_and(|receipt| receipt.running.is_some());
7591                fail_done && slow_running
7592            },
7593        );
7594
7595        core.stop_action();
7596        wait_for("the fan-out to finish once cancelled", || {
7597            !core.action_running()
7598        });
7599
7600        let snapshot = core.snapshot();
7601        let fail_receipt = snapshot
7602            .entities
7603            .iter()
7604            .find(|entity| entity.key == fail_key)
7605            .and_then(|entity| entity.last_action.clone())
7606            .expect("fail's own receipt");
7607        assert_eq!(fail_receipt.steps[0].outcome, StepOutcome::Failed(1));
7608        assert_eq!(
7609            fail_receipt.steps[1].outcome,
7610            StepOutcome::NotRun,
7611            "blocked by fail's own earlier failure, not by the later cancellation"
7612        );
7613
7614        let slow_receipt = snapshot
7615            .entities
7616            .iter()
7617            .find(|entity| entity.key == slow_key)
7618            .and_then(|entity| entity.last_action.clone())
7619            .expect("slow's own receipt");
7620        assert_eq!(
7621            slow_receipt.steps[0].outcome,
7622            StepOutcome::Cancelled,
7623            "a step running when the run was cancelled must read Cancelled"
7624        );
7625        assert_eq!(
7626            slow_receipt.steps[1].outcome,
7627            StepOutcome::Cancelled,
7628            "a step that had not started when the run was cancelled must also read \
7629             Cancelled, never NotRun, which stays reserved for an earlier failure"
7630        );
7631    }
7632
7633    /// A panic anywhere inside the fan-out, a poisoned `RwLock` from an unrelated
7634    /// earlier panic is enough, must not leave this `Core` reading a run as live for the
7635    /// rest of its life. Poisons the table lock directly rather than
7636    /// injecting a fault into `run_action_for_entity`, which runs a real child process
7637    /// and has no seam for one: the fan-out's own `table_handle.write().unwrap()` then
7638    /// panics on the poisoned lock exactly the way an unrelated earlier panic would in
7639    /// production.
7640    #[test]
7641    fn a_panicking_fan_out_still_resets_action_running_so_a_later_action_can_start() {
7642        let dir = tempfile::tempdir().expect("temp dir");
7643        let root = root_of(&dir);
7644        let repo = root.join("repo");
7645        init_repo_with_a_commit(&repo);
7646
7647        // Drained before the table lock is poisoned below: a probe still in flight would
7648        // take the poison too, and a panic in one of rayon's global workers aborts the
7649        // process rather than unwinding.
7650        let (core, launched) = started_and_settled(spec(vec![root]));
7651        let key = launched.entities[0].key.clone();
7652
7653        // A step slow enough that the fan-out's own write of `last_action` cannot have
7654        // happened yet by the time the poisoning below completes: `run_action`'s own
7655        // synchronous prefix (admission, `cancel_in_flight`, the read that builds
7656        // `included`) is already finished by the time this call returns,
7657        // so poisoning the lock afterwards can only reach the fan-out's own write,
7658        // inside its own spawned thread.
7659        let started = core.run_action(
7660            action("boom", vec![step(&["sh", "-c", "sleep 0.3"])]),
7661            std::slice::from_ref(&key),
7662        );
7663        assert!(started);
7664
7665        let table = Arc::clone(&core.table);
7666        thread::spawn(move || {
7667            let _guard = table.write().unwrap();
7668            panic!("deliberately poison the table lock for this test");
7669        })
7670        .join()
7671        .expect_err("the poisoning thread must itself panic to poison the lock");
7672
7673        // Without `catch_unwind` around the fan-out this never becomes false: its own write
7674        // panics on the now-poisoned lock, unwinds out of `pool.install` and skips the
7675        // completion transition just past it, leaving this `Core` reading its run as live
7676        // for ever.
7677        wait_for(
7678            "a panicking fan-out to end its run rather than leave it reading as live",
7679            || !core.action_running(),
7680        );
7681
7682        // Clears the poison this test itself introduced to force the panic, an
7683        // artifact of the test rather than anything production code ever does, so a
7684        // real, full `run_action` call below proves the ended run actually lets another
7685        // Action run to completion, not merely that one private read flipped.
7686        core.table.clear_poison();
7687
7688        let second_started = core.run_action(
7689            action("second", vec![step(&["true"])]),
7690            std::slice::from_ref(&key),
7691        );
7692        assert!(
7693            second_started,
7694            "a later Action must be able to start once the panicking one has finished"
7695        );
7696        wait_for("the second Action to run to completion", || {
7697            core.snapshot()
7698                .entities
7699                .iter()
7700                .find(|entity| entity.key == key)
7701                .and_then(|entity| entity.last_action.as_ref())
7702                .is_some_and(|receipt| &*receipt.label == "second")
7703        });
7704    }
7705
7706    /// Asserts `entity` reads exactly as a Vanished row must: still in the table,
7707    /// its last known branch value untouched, and that same cell's staleness
7708    /// forced on. Shared by the Repo and the Submodule vanish tests so both
7709    /// exercise the identical assertion rather than a Repo-shaped one and a
7710    /// Submodule-shaped one that only look alike.
7711    fn assert_vanished_with_stale_branch(entity: &EntityState, expected_branch: &str) {
7712        assert_eq!(entity.presence, crate::entity::Presence::Vanished);
7713        match entity.branch.settled() {
7714            Some(Settled::Known {
7715                value: Head::Branch { name, .. },
7716                stale: true,
7717                at: _,
7718            }) => assert_eq!(
7719                &**name, expected_branch,
7720                "a Vanished entity must keep its last known branch value"
7721            ),
7722            other => panic!(
7723                "expected the branch cell to keep its Known value and go stale, got {other:?}"
7724            ),
7725        }
7726    }
7727
7728    /// The central behaviour this ticket adds: an entity discovery no longer
7729    /// finds stays in the table with its last known values, every cell forced
7730    /// stale, rather than disappearing. Proven end to end through `refresh` and
7731    /// `settle`, which is what proves discovery itself re-ran rather than the
7732    /// entity merely being left alone.
7733    #[test]
7734    fn a_repo_removed_from_disk_stays_in_the_table_vanished_with_its_last_values() {
7735        let dir = tempfile::tempdir().expect("temp dir");
7736        let root = root_of(&dir);
7737        let repo = root.join("repo");
7738        init_repo_with_a_commit(&repo);
7739
7740        let core = Core::start_discovered(spec(vec![root]));
7741        let key = core.snapshot().entities[0].key.clone();
7742        core.refresh(std::slice::from_ref(&key));
7743        let before = core.settle();
7744        let branch_name = match before.entities[0].branch.settled() {
7745            Some(Settled::Known {
7746                value: Head::Branch { name, .. },
7747                at: _,
7748                stale: _,
7749            }) => name.to_string(),
7750            other => panic!("expected the first refresh to settle a branch, got {other:?}"),
7751        };
7752
7753        fs::remove_dir_all(&repo).expect("remove the repo from disk");
7754
7755        core.refresh(&[]);
7756        let after = core.settle();
7757
7758        assert_eq!(
7759            after.entities.len(),
7760            1,
7761            "a vanished entity must stay in the snapshot, not disappear from it"
7762        );
7763        assert_vanished_with_stale_branch(&after.entities[0], &branch_name);
7764    }
7765
7766    /// Criterion 2's "untouched by the vanished-staleness path" made behavioural, through a
7767    /// real `Core::refresh` rather than calling `mark_vanished` directly: the same pass that
7768    /// forces every settled Cell stale on this entity must leave its receipt exactly as it was.
7769    #[test]
7770    fn a_vanished_entitys_action_receipt_survives_the_vanished_staleness_pass_untouched() {
7771        let dir = tempfile::tempdir().expect("temp dir");
7772        let root = root_of(&dir);
7773        let repo = root.join("repo");
7774        init_repo_with_a_commit(&repo);
7775
7776        let core = Core::start_discovered(spec(vec![root]));
7777        let key = core.snapshot().entities[0].key.clone();
7778        let receipt = crate::entity::ActionReceipt {
7779            label: Arc::from("reinstall"),
7780            steps: Arc::from(vec![crate::entity::StepResult {
7781                label: Arc::from("pnpm install"),
7782                outcome: crate::entity::StepOutcome::Ok,
7783                output: Arc::from(&b""[..]),
7784                elapsed: Duration::from_millis(1),
7785                elision: None,
7786                shell: false,
7787                interactive: false,
7788            }]),
7789            skip: None,
7790            finished_at: Timestamp::now(),
7791            running: None,
7792        };
7793        core.set_last_action_for_test(&key, receipt.clone());
7794
7795        fs::remove_dir_all(&repo).expect("remove the repo from disk");
7796        core.refresh(&[]);
7797        let after = core.settle();
7798
7799        let entity = &after.entities[0];
7800        assert_eq!(entity.presence, crate::entity::Presence::Vanished);
7801        assert_eq!(entity.last_action, Some(receipt));
7802    }
7803
7804    /// Criterion 6's reason for `ActionReceipt` sharing rather than copying is "the snapshot
7805    /// is cloned every frame"; a bare `ActionReceipt::clone()` only proves `Arc::clone` shares,
7806    /// which holds by definition and says nothing about this design. Proven instead through
7807    /// `Core::snapshot` itself: put a receipt on a live `Core`'s table, take two snapshots, and
7808    /// assert the label and steps are the same allocation across them, not merely equal. This
7809    /// passes as written, since the sharing does hold end to end; it exists to fail if some
7810    /// intermediate step ever re-materialised the receipt's bytes.
7811    #[test]
7812    fn two_snapshots_of_an_entity_share_its_last_actions_label_and_steps_by_pointer() {
7813        let dir = tempfile::tempdir().expect("temp dir");
7814        let root = root_of(&dir);
7815        let repo = root.join("repo");
7816        init_repo_with_a_commit(&repo);
7817
7818        let core = Core::start_discovered(spec(vec![root]));
7819        let key = core.snapshot().entities[0].key.clone();
7820        let receipt = crate::entity::ActionReceipt {
7821            label: Arc::from("reinstall"),
7822            steps: Arc::from(vec![crate::entity::StepResult {
7823                label: Arc::from("pnpm install"),
7824                outcome: crate::entity::StepOutcome::Failed(1),
7825                output: Arc::from(&b""[..]),
7826                elapsed: Duration::from_millis(1),
7827                elision: None,
7828                shell: false,
7829                interactive: false,
7830            }]),
7831            skip: None,
7832            finished_at: Timestamp::now(),
7833            running: None,
7834        };
7835        core.set_last_action_for_test(&key, receipt);
7836
7837        let first = core.snapshot();
7838        let second = core.snapshot();
7839        let first_receipt = first.entities[0]
7840            .last_action
7841            .as_ref()
7842            .expect("receipt was set");
7843        let second_receipt = second.entities[0]
7844            .last_action
7845            .as_ref()
7846            .expect("receipt was set");
7847
7848        assert!(
7849            Arc::ptr_eq(&first_receipt.label, &second_receipt.label),
7850            "two snapshots of the same receipt must share the label's allocation, not \
7851             re-copy it"
7852        );
7853        assert!(
7854            Arc::ptr_eq(&first_receipt.steps, &second_receipt.steps),
7855            "two snapshots of the same receipt must share the steps slice's allocation, not \
7856             re-copy it, which is also what shares every step's own captured output"
7857        );
7858    }
7859
7860    /// A Submodule vanishes by exactly the same rule as a Repo: no code path here
7861    /// is specific to which half of discovery produced the entry. Driven through
7862    /// the Submodule half (removing its declaration from `.gitmodules`, never
7863    /// touched by the boundary walk) and asserted with the very same helper the
7864    /// Repo test above uses.
7865    #[test]
7866    fn a_submodule_removed_from_gitmodules_vanishes_by_the_same_rule_as_a_repo() {
7867        let dir = tempfile::tempdir().expect("temp dir");
7868        let root = root_of(&dir);
7869        let parent = root.join("parent");
7870        init_repo_with_a_commit(&parent);
7871        fs::write(
7872            parent.join(".gitmodules"),
7873            "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
7874        )
7875        .expect("write .gitmodules");
7876        let submodule_path = parent.join("vendor").join("lib");
7877        init_repo_with_a_commit(&submodule_path);
7878
7879        // Shown, so the explicit `refresh` just below actually dispatches a probe against
7880        // it: this test is about the Vanished rule, not about `show_submodules` gating.
7881        let mut core_spec = spec(vec![root]);
7882        core_spec.show_submodules = true;
7883        let core = Core::start_discovered(core_spec);
7884        let snapshot = core.snapshot();
7885        let submodule_key = snapshot
7886            .entities
7887            .iter()
7888            .find(|entity| matches!(entity.kind, Kind::Submodule))
7889            .expect("submodule discovered")
7890            .key
7891            .clone();
7892        core.refresh(std::slice::from_ref(&submodule_key));
7893        let before = core.settle();
7894        let submodule_before = before
7895            .entities
7896            .iter()
7897            .find(|entity| entity.key == submodule_key)
7898            .expect("submodule present");
7899        let branch_name = match submodule_before.branch.settled() {
7900            Some(Settled::Known {
7901                value: Head::Branch { name, .. },
7902                at: _,
7903                stale: _,
7904            }) => name.to_string(),
7905            other => {
7906                panic!("expected the submodule's first refresh to settle a branch, got {other:?}")
7907            }
7908        };
7909
7910        // The submodule is no longer declared: discovery's second half will no
7911        // longer produce this entry, exactly as removing the parent's own `.git`
7912        // boundary would remove a Repo's entry.
7913        fs::write(parent.join(".gitmodules"), "").expect("clear .gitmodules");
7914
7915        core.refresh(&[]);
7916        let after = core.settle();
7917
7918        let submodule_after = after
7919            .entities
7920            .iter()
7921            .find(|entity| entity.key == submodule_key)
7922            .expect("the vanished submodule must stay in the snapshot");
7923        assert_vanished_with_stale_branch(submodule_after, &branch_name);
7924    }
7925
7926    /// Dismissal writes nothing to disk, so a Repo dismissed from one `Core`
7927    /// reads as an ordinary, freshly discovered Present entity on a brand new
7928    /// `Core` over the same roots, never as a restored Vanished row: startup is
7929    /// always a Generation with an empty prior state.
7930    #[test]
7931    fn dismissal_persists_nothing_across_a_fresh_core() {
7932        let dir = tempfile::tempdir().expect("temp dir");
7933        let root = root_of(&dir);
7934        let repo = root.join("repo");
7935        init_repo_with_a_commit(&repo);
7936
7937        let first_core = Core::start_discovered(spec(vec![root.clone()]));
7938        let key = first_core.snapshot().entities[0].key.clone();
7939        first_core.dismiss(&key);
7940        assert!(first_core.snapshot().entities.is_empty());
7941        drop(first_core);
7942
7943        let second_core = Core::start_discovered(spec(vec![root]));
7944        let snapshot = second_core.snapshot();
7945
7946        assert_eq!(
7947            snapshot.entities.len(),
7948            1,
7949            "a fresh Core must discover the repo again"
7950        );
7951        assert_eq!(
7952            snapshot.entities[0].presence,
7953            crate::entity::Presence::Present,
7954            "nothing from the dismissing Core's lifetime may be persisted, so the \
7955             repo must come back Present, never restored as Vanished"
7956        );
7957    }
7958
7959    /// An entity that moves reads as vanished plus new: its old key stays in the
7960    /// table Vanished with its last values, and a brand new entity appears at the
7961    /// new path, rather than the move being recognised as a rename.
7962    #[test]
7963    fn a_repo_that_moves_reads_as_vanished_plus_new() {
7964        let dir = tempfile::tempdir().expect("temp dir");
7965        let root = root_of(&dir);
7966        let original_path = root.join("original-name");
7967        init_repo_with_a_commit(&original_path);
7968
7969        let core = Core::start_discovered(spec(vec![root.clone()]));
7970        let original_key = core.snapshot().entities[0].key.clone();
7971        core.refresh(std::slice::from_ref(&original_key));
7972        let before = core.settle();
7973        let branch_name = match before.entities[0].branch.settled() {
7974            Some(Settled::Known {
7975                value: Head::Branch { name, .. },
7976                at: _,
7977                stale: _,
7978            }) => name.to_string(),
7979            other => panic!("expected the first refresh to settle a branch, got {other:?}"),
7980        };
7981
7982        let moved_path = root.join("new-name");
7983        fs::rename(&original_path, &moved_path).expect("move the repo on disk");
7984
7985        core.refresh(&[]);
7986        let after = core.settle();
7987
7988        assert_eq!(
7989            after.entities.len(),
7990            2,
7991            "a moved entity must read as the old key vanished plus a new one present, \
7992             never as one renamed entity"
7993        );
7994        let old_entity = after
7995            .entities
7996            .iter()
7997            .find(|entity| entity.key == original_key)
7998            .expect("the old key must stay in the table");
7999        assert_vanished_with_stale_branch(old_entity, &branch_name);
8000        let new_entity = after
8001            .entities
8002            .iter()
8003            .find(|entity| entity.key != original_key)
8004            .expect("a new entity at the moved path must be present");
8005        assert_eq!(new_entity.presence, crate::entity::Presence::Present);
8006        assert_eq!(new_entity.key.path(), moved_path);
8007    }
8008
8009    /// Reappearance is vanishing's mirror: an entity discovery stops finding, and
8010    /// then finds again, must come back Present rather than staying stuck
8011    /// Vanished forever.
8012    #[test]
8013    fn a_vanished_repo_recreated_on_disk_reads_present_on_the_next_refresh() {
8014        let dir = tempfile::tempdir().expect("temp dir");
8015        let root = root_of(&dir);
8016        let repo = root.join("repo");
8017        init_repo_with_a_commit(&repo);
8018
8019        let core = Core::start_discovered(spec(vec![root]));
8020        let key = core.snapshot().entities[0].key.clone();
8021
8022        fs::remove_dir_all(&repo).expect("remove the repo from disk");
8023        core.refresh(&[]);
8024        let vanished = core.settle();
8025        assert_eq!(
8026            vanished.entities[0].presence,
8027            crate::entity::Presence::Vanished,
8028            "the repo must read Vanished once removed from disk"
8029        );
8030
8031        init_repo_with_a_commit(&repo);
8032        core.refresh(&[]);
8033        let recreated = core.settle();
8034
8035        let entity = recreated
8036            .entities
8037            .iter()
8038            .find(|entity| entity.key == key)
8039            .expect("the recreated repo must still resolve to the same entity key");
8040        assert_eq!(
8041            entity.presence,
8042            crate::entity::Presence::Present,
8043            "an entity discovery finds again after it vanished must read Present, \
8044             not stay stuck Vanished forever"
8045        );
8046    }
8047
8048    /// Discovery riding the refresh is what lets a brand new entity appear
8049    /// without a fresh `Core::start`: a repo created after `start` is picked up
8050    /// by the very next `refresh`, even though the caller's `order` cannot yet
8051    /// name a key it never saw.
8052    #[test]
8053    fn a_new_repo_created_after_start_is_discovered_by_the_next_refresh() {
8054        let dir = tempfile::tempdir().expect("temp dir");
8055        let root = root_of(&dir);
8056        init_repo_with_a_commit(&root.join("first"));
8057
8058        let core = Core::start_discovered(spec(vec![root.clone()]));
8059        assert_eq!(core.snapshot().entities.len(), 1);
8060
8061        init_repo_with_a_commit(&root.join("second"));
8062        core.refresh(&[]);
8063        let after = core.settle();
8064
8065        assert_eq!(
8066            after.entities.len(),
8067            2,
8068            "a new repo created after start must be found by the next refresh's own discovery"
8069        );
8070
8071        // The entity is usable, not merely counted: a refresh that names its key
8072        // actually probes it and settles a real cell.
8073        let new_key = after
8074            .entities
8075            .iter()
8076            .find(|entity| &*entity.name == "second")
8077            .expect("the newly discovered repo must be named by the walk")
8078            .key
8079            .clone();
8080        core.refresh(std::slice::from_ref(&new_key));
8081        let probed = core.settle();
8082        let new_entity = probed
8083            .entities
8084            .iter()
8085            .find(|entity| entity.key == new_key)
8086            .expect("the newly discovered repo must still be present");
8087        assert!(
8088            matches!(
8089                new_entity.branch.settled(),
8090                Some(Settled::Known {
8091                    value: _,
8092                    at: _,
8093                    stale: _
8094                })
8095            ),
8096            "a refresh naming the newly discovered repo's key must actually probe \
8097             it and settle its branch cell, got {:?}",
8098            new_entity.branch.settled()
8099        );
8100    }
8101
8102    /// The abandon path takes the Set out of the automatic refresh path: once one
8103    /// discovery invocation abandons, a later `refresh` does not re-run discovery
8104    /// at all, proven by a repo created afterward never appearing, not merely by
8105    /// reading an internal flag.
8106    #[test]
8107    fn an_abandoned_discovery_stops_riding_later_refreshes() {
8108        let dir = tempfile::tempdir().expect("temp dir");
8109        let root = root_of(&dir);
8110        // A wide fan of plain directories, real enough for the walk to measurably
8111        // outrun a millisecond-scale deadline, so `start`'s own discovery
8112        // abandons rather than merely being told to (`Duration::ZERO` would trip
8113        // on the very first directory regardless of what is actually here, which
8114        // could never distinguish a guarded `refresh` from an unguarded one that
8115        // simply keeps re-abandoning against the same still-huge tree).
8116        let decoys = root.join("decoys");
8117        for i in 0..4_000 {
8118            fs::create_dir(decoys.join(format!("decoy-{i}")))
8119                .or_else(|_| fs::create_dir_all(decoys.join(format!("decoy-{i}"))))
8120                .expect("create decoy dir");
8121        }
8122        let (_tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
8123
8124        let started = Core::start_for_test_with_discovery_abandon(
8125            spec(vec![root.clone()]),
8126            Duration::from_secs(3600),
8127            Duration::from_micros(500),
8128            tick_rx,
8129        )
8130        .discovered();
8131        let core = started.core;
8132        assert!(
8133            core.discovery_manual_for_test(),
8134            "walking 4,000 decoy directories against a 500 microsecond deadline \
8135             must have abandoned and taken the Set manual"
8136        );
8137
8138        // The tree shrinks back to nothing slow: if `refresh` were still (wrongly)
8139        // re-running discovery, this walk would finish comfortably inside the
8140        // same deadline and find the new repo below. Only the manual guard can
8141        // account for it staying undiscovered.
8142        fs::remove_dir_all(&decoys).expect("remove decoy directories");
8143        init_repo_with_a_commit(&root.join("second"));
8144
8145        core.refresh(&[]);
8146        let after = core.settle();
8147
8148        assert!(
8149            !after
8150                .entities
8151                .iter()
8152                .any(|entity| &*entity.name == "second"),
8153            "once discovery has abandoned, a later refresh must not re-run it, so a \
8154             repo created afterward, on a tree that would now resolve quickly, \
8155             must still never appear"
8156        );
8157    }
8158
8159    /// `rerun_discovery`'s own abandon handling, exercised by a walk that only
8160    /// abandons on a later `refresh`, never on `start`'s: the first walk, over a
8161    /// tree small enough to finish comfortably inside the deadline, must leave
8162    /// the Set automatic, and only the second walk, once the same tree has grown
8163    /// a wide fan of decoys, may flip the manual flag and leave the abandoned
8164    /// warning. Both existing abandon tests force the abandon inside `start`'s
8165    /// own walk, which can never reach this block: `refresh` gates
8166    /// `rerun_discovery` behind the manual flag `start` already set.
8167    #[test]
8168    fn a_refresh_triggered_discovery_abandon_sets_manual_and_warns() {
8169        let dir = tempfile::tempdir().expect("temp dir");
8170        let root = root_of(&dir);
8171        init_repo_with_a_commit(&root.join("first"));
8172        let (_tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
8173
8174        // The first walk runs under a deadline it cannot lose against, so this
8175        // precondition is not a race. Tightening the deadline afterwards is what
8176        // separates the walk that must survive from the walk that must abandon:
8177        // one deadline serving both is a knife edge, and scheduling latency on a
8178        // loaded machine erases any margin a wall-clock figure can buy.
8179        let started = Core::start_for_test_with_discovery_abandon(
8180            spec(vec![root.clone()]),
8181            Duration::from_secs(3600),
8182            Duration::from_secs(3600),
8183            tick_rx,
8184        )
8185        .discovered();
8186        let core = started.core;
8187        assert!(
8188            !core.discovery_manual_for_test(),
8189            "an hour-long deadline must leave the first walk automatic"
8190        );
8191
8192        // Grown only after the first walk has finished (`discovered` above joined it),
8193        // so this fan of decoys is invisible to that walk and can only be reached by a
8194        // walk `refresh` triggers itself.
8195        let decoys = root.join("decoys");
8196        for i in 0..4_000 {
8197            fs::create_dir(decoys.join(format!("decoy-{i}")))
8198                .or_else(|_| fs::create_dir_all(decoys.join(format!("decoy-{i}"))))
8199                .expect("create decoy dir");
8200        }
8201        core.set_discovery_abandon_after_for_test(Duration::from_micros(500));
8202
8203        core.refresh(&[]);
8204        // `refresh` returns the moment it has reserved its Generation; this is the
8205        // rendezvous that says its own walk has run.
8206        core.wait_dispatched_for_test();
8207
8208        assert!(
8209            core.discovery_manual_for_test(),
8210            "refresh's own rerun_discovery must abandon against the newly-grown \
8211             tree and take the Set manual, the same as an abandon at start does"
8212        );
8213        let warning = core.discovery_warning();
8214        assert!(
8215            warning
8216                .as_deref()
8217                .is_some_and(|message| message.starts_with("discovery: stopped at")),
8218            "refresh's rerun_discovery must leave the abandoned-discovery warning \
8219             behind, not merely flip the manual flag: got {warning:?}"
8220        );
8221    }
8222
8223    /// The other half: an abandoned Set going manual must not leak into a
8224    /// different `Core`. The only way this crate can express "the Set's roots or
8225    /// globs changed" today is a fresh `Core::start` (a live in-place reload has
8226    /// no entry point in `Core` yet), so this proves the manual flag lives on one
8227    /// `Core` instance rather than anywhere global.
8228    #[test]
8229    fn a_fresh_core_over_different_roots_is_unaffected_by_another_cores_abandoned_discovery() {
8230        let abandoned_dir = tempfile::tempdir().expect("temp dir");
8231        let abandoned_root = root_of(&abandoned_dir);
8232        init_repo_with_a_commit(&abandoned_root.join("first"));
8233        let (_tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
8234        let started = Core::start_for_test_with_discovery_abandon(
8235            spec(vec![abandoned_root]),
8236            Duration::from_secs(3600),
8237            Duration::ZERO,
8238            tick_rx,
8239        )
8240        .discovered();
8241        started.core.refresh(&[]);
8242        started.core.settle();
8243        assert!(
8244            started.core.discovery_manual_for_test(),
8245            "the zero-length abandon deadline must have already taken this Core manual"
8246        );
8247        drop(started.core);
8248
8249        let fresh_dir = tempfile::tempdir().expect("temp dir");
8250        let fresh_root = root_of(&fresh_dir);
8251        init_repo_with_a_commit(&fresh_root.join("first"));
8252        let fresh_core = Core::start_discovered(spec(vec![fresh_root.clone()]));
8253        assert_eq!(fresh_core.snapshot().entities.len(), 1);
8254
8255        init_repo_with_a_commit(&fresh_root.join("second"));
8256        fresh_core.refresh(&[]);
8257        let after = fresh_core.settle();
8258
8259        assert_eq!(
8260            after.entities.len(),
8261            2,
8262            "a fresh Core, standing in for the Set's roots changing, must discover \
8263             normally regardless of an earlier, unrelated Core having gone manual"
8264        );
8265    }
8266
8267    /// Proves shutdown is clean: dropping the core blocks until the dedicated
8268    /// thread has actually returned, not merely until a message was sent to it.
8269    /// The tick sender is kept alive for the whole test, so the only way the
8270    /// thread can have stopped is the shutdown message `Drop` sends.
8271    #[test]
8272    fn dropping_the_core_joins_the_dedicated_thread_before_returning() {
8273        let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
8274        let dir = tempfile::tempdir().expect("temp dir");
8275        let root = root_of(&dir);
8276
8277        let started =
8278            Core::start_for_test(spec(vec![root]), Duration::from_secs(3600), tick_rx).discovered();
8279        assert!(started.clock_alive.load(Ordering::Acquire));
8280
8281        drop(started.core);
8282
8283        assert!(
8284            !started.clock_alive.load(Ordering::Acquire),
8285            "the dedicated thread should have exited, and cleared this flag, before drop returned"
8286        );
8287        drop(tick_tx);
8288    }
8289
8290    /// Cadence is driven entirely by the injected tick channel, never by a clock of
8291    /// the loop's own: with a zero deadline, the sweep is provably ready to fire
8292    /// the instant it runs, so whether it has run is exactly whether a tick has
8293    /// been sent, proven with no sleep on either side.
8294    #[test]
8295    fn the_deadline_sweep_runs_only_when_a_tick_arrives() {
8296        let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
8297        let dir = tempfile::tempdir().expect("temp dir");
8298        let root = root_of(&dir);
8299        let repo = root.join("repo");
8300        init_repo_with_a_commit(&repo);
8301
8302        let mut spec = spec(vec![root]);
8303        spec.generation_deadline = Duration::ZERO;
8304        let started = Core::start_for_test(spec, Duration::from_secs(3600), tick_rx).discovered();
8305        let core = started.core;
8306        // Drained, so the only entry in flight below is this test's own and only the sweep
8307        // can settle it again.
8308        let key = settle_launch(&core).entities[0].key.clone();
8309
8310        core.begin_untracked_probe_for_test(&key);
8311
8312        // No tick has been sent: the sweep has not run even though the (zero)
8313        // deadline has already elapsed in real time.
8314        let before = core.snapshot();
8315        assert!(
8316            matches!(
8317                before.entities[0].branch.settled(),
8318                Some(Settled::Known {
8319                    value: _,
8320                    at: _,
8321                    stale: _
8322                })
8323            ),
8324            "the cell still holds launch's own answer here, so the Unknown below is the \
8325             sweep's write rather than a cell that was already empty"
8326        );
8327        assert!(before.entities[0].branch.is_in_flight());
8328
8329        tick_tx.send(Instant::now()).expect("send one tick");
8330        let after = core.settle();
8331
8332        assert!(matches!(
8333            after.entities[0].branch.settled(),
8334            Some(Settled::Unknown(Unknown::TimedOut))
8335        ));
8336    }
8337
8338    /// Proves the real dedicated thread's tick arm actually reaches
8339    /// [`run_poll_sweep`], not merely that [`Core::poll_once_for_test`]'s direct
8340    /// call does the right thing: a mutation deleting the call inside
8341    /// `spawn_clock_thread` would leave every other poll test in this file green
8342    /// while failing only this one. [`wait_for`] backstops the wait rather than
8343    /// asserting any particular latency: the two ticks are sent from this thread
8344    /// and merely need to be picked up by the idle dedicated thread, not to land
8345    /// within a stated budget.
8346    #[test]
8347    fn a_real_tick_through_the_dedicated_thread_reaches_the_poll_sweep_and_reprobes_a_moved_entity()
8348    {
8349        let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
8350        let dir = tempfile::tempdir().expect("temp dir");
8351        let root = root_of(&dir);
8352        let repo = root.join("repo");
8353        init_repo_with_a_commit(&repo);
8354
8355        let started =
8356            Core::start_for_test(spec(vec![root]), Duration::from_secs(3600), tick_rx).discovered();
8357        let core = started.core;
8358        let key = core.snapshot().entities[0].key.clone();
8359
8360        backdate_polled_entries(&repo);
8361
8362        // The first tick only records a baseline: nothing has moved yet against a
8363        // fingerprint that did not exist before this tick.
8364        tick_tx
8365            .send(Instant::now())
8366            .expect("send the baseline tick");
8367        wait_for(
8368            "a tick sent on the real channel to reach the poll sweep",
8369            || core.poll_sweep_count_for_test() >= 1,
8370        );
8371        assert!(core.poll_reprobed_for_test().is_empty());
8372
8373        commit_a_change(&repo, "second");
8374
8375        tick_tx
8376            .send(Instant::now())
8377            .expect("send the movement tick");
8378        wait_for(
8379            "the real tick channel to reach the poll sweep and reprobe the moved entity",
8380            || core.poll_reprobed_for_test() == vec![key.clone()],
8381        );
8382        drop(tick_tx);
8383    }
8384
8385    /// Criterion 2's whole claim, over two entities so "for that entity only" has
8386    /// something to discriminate against: committing into one of two Repos and
8387    /// running one poll sweep re-probes branch/sync/base for the moved Repo alone
8388    /// (`poll_reprobed_for_test` names exactly it, never the other), force-stales
8389    /// its `dirty` and `state` without changing their value or timestamp (the
8390    /// absence claim that no status probe ran), and leaves the untouched Repo's
8391    /// cells byte-for-byte as the prior real `refresh` left them.
8392    #[test]
8393    fn poll_reprobe_touches_only_the_moved_entity_and_never_runs_a_status_probe() {
8394        let dir = tempfile::tempdir().expect("temp dir");
8395        let root = root_of(&dir);
8396        let repo_a = root.join("repo-a");
8397        let repo_b = root.join("repo-b");
8398        init_repo_with_a_commit(&repo_a);
8399        init_repo_with_a_commit(&repo_b);
8400
8401        let core = Core::start_discovered(spec(vec![root]));
8402        let snapshot = core.snapshot();
8403        let key_a = snapshot
8404            .entities
8405            .iter()
8406            .find(|entity| entity.key.path() == repo_a)
8407            .expect("repo-a discovered")
8408            .key
8409            .clone();
8410        let key_b = snapshot
8411            .entities
8412            .iter()
8413            .find(|entity| entity.key.path() == repo_b)
8414            .expect("repo-b discovered")
8415            .key
8416            .clone();
8417
8418        core.refresh(&[key_a.clone(), key_b.clone()]);
8419        let landed = core.settle();
8420        let entity_of = |snapshot: &Snapshot, key: &EntityKey| {
8421            snapshot
8422                .entities
8423                .iter()
8424                .find(|entity| &entity.key == key)
8425                .expect("entity present")
8426                .clone()
8427        };
8428        let a_before = entity_of(&landed, &key_a);
8429        let b_before = entity_of(&landed, &key_b);
8430        let branch_at = |entity: &EntityState| match entity.branch.settled() {
8431            Some(Settled::Known {
8432                at,
8433                value: _,
8434                stale: _,
8435            }) => *at,
8436            other => panic!("expected a landed branch, got {other:?}"),
8437        };
8438        let dirty_state = |entity: &EntityState| match entity.dirty.settled() {
8439            Some(Settled::Known { value, at, stale }) => (*value, *at, *stale),
8440            other => panic!("expected a landed dirty count, got {other:?}"),
8441        };
8442        let (a_dirty_value_before, a_dirty_at_before, a_dirty_stale_before) =
8443            dirty_state(&a_before);
8444        assert!(
8445            !a_dirty_stale_before,
8446            "the fresh refresh must land dirty as not stale"
8447        );
8448
8449        backdate_polled_entries(&repo_a);
8450
8451        backdate_polled_entries(&repo_b);
8452
8453        core.poll_once_for_test();
8454        assert!(
8455            core.poll_reprobed_for_test().is_empty(),
8456            "a first sweep has nothing to compare against, so it must report no movement"
8457        );
8458
8459        commit_a_change(&repo_a, "second");
8460        core.poll_once_for_test();
8461
8462        assert_eq!(
8463            core.poll_reprobed_for_test(),
8464            vec![key_a.clone()],
8465            "only the entity whose gitdir actually moved must be re-probed"
8466        );
8467
8468        let after = core.snapshot();
8469        let a_after = entity_of(&after, &key_a);
8470        let b_after = entity_of(&after, &key_b);
8471
8472        assert_ne!(
8473            branch_at(&a_after),
8474            branch_at(&a_before),
8475            "the moved entity's branch must carry a fresh timestamp from the re-probe"
8476        );
8477        let (a_dirty_value_after, a_dirty_at_after, a_dirty_stale_after) = dirty_state(&a_after);
8478        assert_eq!(
8479            a_dirty_value_after, a_dirty_value_before,
8480            "no status probe ran, so dirty's value must be exactly what the last real refresh \
8481             landed"
8482        );
8483        assert_eq!(
8484            a_dirty_at_after, a_dirty_at_before,
8485            "no status probe ran, so dirty's timestamp must be untouched, only its stale flag \
8486             set"
8487        );
8488        assert!(
8489            a_dirty_stale_after,
8490            "the moved entity's dirty cell must go stale on poll evidence"
8491        );
8492
8493        assert_eq!(
8494            branch_at(&b_after),
8495            branch_at(&b_before),
8496            "the untouched entity's branch must be exactly as the prior refresh left it"
8497        );
8498        let (b_dirty_value_after, b_dirty_at_after, b_dirty_stale_after) = dirty_state(&b_after);
8499        let (b_dirty_value_before, b_dirty_at_before, b_dirty_stale_before) =
8500            dirty_state(&b_before);
8501        assert_eq!(b_dirty_value_after, b_dirty_value_before);
8502        assert_eq!(b_dirty_at_after, b_dirty_at_before);
8503        assert_eq!(
8504            b_dirty_stale_after, b_dirty_stale_before,
8505            "an entity the sweep found unmoved must never go stale"
8506        );
8507    }
8508
8509    /// Criterion 3's attached half, and one of `refresh.md`'s two named traps: a
8510    /// commit on an attached HEAD never touches `.git/HEAD` at all, only
8511    /// `.git/logs/HEAD`. The poll must still see the commit, through `index`
8512    /// rather than through `HEAD`.
8513    #[test]
8514    fn poll_detects_an_attached_commit_through_index_while_head_itself_never_moves() {
8515        let dir = tempfile::tempdir().expect("temp dir");
8516        let root = root_of(&dir);
8517        let repo = root.join("repo");
8518        init_repo_with_a_commit(&repo);
8519
8520        let core = Core::start_discovered(spec(vec![root]));
8521        let key = core.snapshot().entities[0].key.clone();
8522        backdate_polled_entries(&repo);
8523        core.poll_once_for_test();
8524        assert!(core.poll_reprobed_for_test().is_empty());
8525
8526        let head_path = repo.join(".git").join("HEAD");
8527        let head_mtime_before = fs::metadata(&head_path)
8528            .expect("stat HEAD")
8529            .modified()
8530            .expect("HEAD mtime");
8531
8532        commit_a_change(&repo, "second");
8533
8534        let head_mtime_after = fs::metadata(&head_path)
8535            .expect("stat HEAD")
8536            .modified()
8537            .expect("HEAD mtime");
8538        assert_eq!(
8539            head_mtime_before, head_mtime_after,
8540            "a commit on an attached HEAD must never touch HEAD itself"
8541        );
8542
8543        core.poll_once_for_test();
8544        assert_eq!(
8545            core.poll_reprobed_for_test(),
8546            vec![key],
8547            "the poll must still detect the attached commit, through index rather than HEAD"
8548        );
8549    }
8550
8551    /// Criterion 3's detached half: [head.md](https://github.com/paulchiu/repon/blob/main/docs/spec/head.md)'s
8552    /// claim that a detached row's evidence is better than an attached row's,
8553    /// because a commit on a detached HEAD writes the new object id straight into
8554    /// the per-worktree `HEAD` file itself. Run against a real linked Worktree,
8555    /// never the main working tree, since that per-worktree file is exactly what
8556    /// distinguishes this case from the attached one above.
8557    #[test]
8558    fn poll_detects_a_detached_commit_through_the_per_worktree_head_file() {
8559        let dir = tempfile::tempdir().expect("temp dir");
8560        let root = root_of(&dir);
8561        let parent = root.join("parent");
8562        init_repo_with_a_commit(&parent);
8563        let worktree_path = root.join("detached-worktree");
8564        let status = Command::new("git")
8565            .arg("-C")
8566            .arg(&parent)
8567            .args([
8568                "worktree",
8569                "add",
8570                "--detach",
8571                worktree_path.to_str().expect("utf8 path"),
8572            ])
8573            .status()
8574            .expect("run git worktree add");
8575        assert!(status.success());
8576
8577        let core = Core::start_discovered(spec(vec![root]));
8578        let snapshot = core.snapshot();
8579        let worktree_key = snapshot
8580            .entities
8581            .iter()
8582            .find(|entity| matches!(entity.kind, Kind::Worktree))
8583            .expect("worktree discovered")
8584            .key
8585            .clone();
8586
8587        backdate_polled_entries(&parent);
8588        backdate_polled_entries(&worktree_path);
8589
8590        core.poll_once_for_test();
8591        assert!(core.poll_reprobed_for_test().is_empty());
8592
8593        let worktree_head_path = parent
8594            .join(".git")
8595            .join("worktrees")
8596            .join("detached-worktree")
8597            .join("HEAD");
8598        let head_mtime_before = fs::metadata(&worktree_head_path)
8599            .expect("stat the per-worktree HEAD")
8600            .modified()
8601            .expect("HEAD mtime");
8602
8603        commit_a_change(&worktree_path, "on the detached worktree");
8604
8605        let head_mtime_after = fs::metadata(&worktree_head_path)
8606            .expect("stat the per-worktree HEAD")
8607            .modified()
8608            .expect("HEAD mtime");
8609        assert_ne!(
8610            head_mtime_before, head_mtime_after,
8611            "a commit on a detached HEAD must write the new object id straight into its own \
8612             HEAD file"
8613        );
8614
8615        core.poll_once_for_test();
8616        assert_eq!(
8617            core.poll_reprobed_for_test(),
8618            vec![worktree_key],
8619            "the poll must detect the detached commit via the per-worktree HEAD file"
8620        );
8621    }
8622
8623    /// Criterion 4's elapsed-age writer, wired through `Core::snapshot` end to end:
8624    /// `status_stale_after` from `CoreSpec` is what decides whether a freshly
8625    /// landed `dirty` cell already reads Stale. A `Duration::from_nanos(1)`
8626    /// threshold has necessarily already elapsed by the time `snapshot` runs
8627    /// afterwards, so this needs no sleep and depends on no stated latency budget,
8628    /// only on real wall-clock time having advanced at all between two calls.
8629    #[test]
8630    fn snapshot_ages_a_freshly_landed_dirty_cell_stale_once_status_stale_after_has_elapsed() {
8631        let dir = tempfile::tempdir().expect("temp dir");
8632        let root = root_of(&dir);
8633        let repo = root.join("repo");
8634        init_repo_with_a_commit(&repo);
8635
8636        let mut short_lived = spec(vec![root]);
8637        short_lived.status_stale_after = Duration::from_nanos(1);
8638        let core = Core::start_discovered(short_lived);
8639        let key = core.snapshot().entities[0].key.clone();
8640        core.refresh(std::slice::from_ref(&key));
8641        core.settle();
8642
8643        let aged = core.snapshot();
8644        match aged.entities[0].dirty.settled() {
8645            Some(Settled::Known {
8646                stale: true,
8647                value: _,
8648                at: _,
8649            }) => {}
8650            other => panic!(
8651                "expected a landed dirty cell to have already aged past a one-nanosecond \
8652                 threshold, got {other:?}"
8653            ),
8654        }
8655    }
8656
8657    /// The same wiring's other side: a landed `dirty` cell stays fresh under a
8658    /// large `status_stale_after`, so the wiring is genuinely reading the
8659    /// threshold rather than always staling.
8660    #[test]
8661    fn snapshot_leaves_a_freshly_landed_dirty_cell_fresh_under_a_large_status_stale_after() {
8662        let dir = tempfile::tempdir().expect("temp dir");
8663        let root = root_of(&dir);
8664        let repo = root.join("repo");
8665        init_repo_with_a_commit(&repo);
8666
8667        let core = Core::start_discovered(spec(vec![root]));
8668        let key = core.snapshot().entities[0].key.clone();
8669        core.refresh(std::slice::from_ref(&key));
8670        core.settle();
8671
8672        let fresh = core.snapshot();
8673        match fresh.entities[0].dirty.settled() {
8674            Some(Settled::Known {
8675                stale: false,
8676                value: _,
8677                at: _,
8678            }) => {}
8679            other => panic!("expected a freshly landed dirty cell to stay fresh, got {other:?}"),
8680        }
8681    }
8682
8683    /// Criterion 5's absence claim: a hidden Submodule (`show_submodules` off) is
8684    /// never in the poll's own candidate set, so a commit into it is never
8685    /// detected, while the identical commit against the same Submodule shown is.
8686    /// Run as one test over the same fixture with the flag flipped, rather than
8687    /// two, so the only variable between the two sweeps is the flag itself.
8688    #[test]
8689    fn hidden_submodules_are_never_polled_but_shown_ones_are() {
8690        let dir = tempfile::tempdir().expect("temp dir");
8691        let root = root_of(&dir);
8692        let parent = root.join("parent");
8693        init_repo_with_a_commit(&parent);
8694        fs::write(
8695            parent.join(".gitmodules"),
8696            "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
8697        )
8698        .expect("write .gitmodules");
8699        let submodule_path = parent.join("vendor").join("lib");
8700        init_repo_with_a_commit(&submodule_path);
8701
8702        let mut hidden_spec = spec(vec![root.clone()]);
8703        hidden_spec.show_submodules = false;
8704        let hidden_core = Core::start_discovered(hidden_spec);
8705        // Discovery's own pass always runs regardless of the flag
8706        // (discovery.md's "Showing Submodules": "the pass always runs, so
8707        // Submodules are always known"), so the row exists; only probing and the
8708        // poll are gated on it.
8709        let hidden_submodule_key = hidden_core
8710            .snapshot()
8711            .entities
8712            .iter()
8713            .find(|entity| matches!(entity.kind, Kind::Submodule))
8714            .expect("the submodule is discovered regardless of show_submodules")
8715            .key
8716            .clone();
8717        backdate_polled_entries(&submodule_path);
8718        hidden_core.poll_once_for_test();
8719        commit_a_change(&submodule_path, "into the hidden submodule");
8720        hidden_core.poll_once_for_test();
8721        assert!(
8722            !hidden_core
8723                .poll_reprobed_for_test()
8724                .contains(&hidden_submodule_key),
8725            "a hidden Submodule must never be re-probed by the poll, since it was never \
8726             polled at all"
8727        );
8728        drop(hidden_core);
8729
8730        let mut shown_spec = spec(vec![root]);
8731        shown_spec.show_submodules = true;
8732        let shown_core = Core::start_discovered(shown_spec);
8733        let submodule_key = shown_core
8734            .snapshot()
8735            .entities
8736            .iter()
8737            .find(|entity| matches!(entity.kind, Kind::Submodule))
8738            .expect("the submodule is discovered regardless of show_submodules")
8739            .key
8740            .clone();
8741        backdate_polled_entries(&submodule_path);
8742        shown_core.poll_once_for_test();
8743        commit_a_change(&submodule_path, "into the shown submodule");
8744        shown_core.poll_once_for_test();
8745        assert_eq!(
8746            shown_core.poll_reprobed_for_test(),
8747            vec![submodule_key],
8748            "a shown Submodule must be polled and re-probed exactly like any other row"
8749        );
8750    }
8751
8752    /// Pause cancels a real in-flight entry (not merely stores a flag nobody
8753    /// reads): the cancel flag `begin_untracked_probe_for_test` returns is
8754    /// observed `true` afterward, and `settle` unblocks because pause released it,
8755    /// which is only possible if pause's handler on the dedicated thread actually
8756    /// ran.
8757    #[test]
8758    fn pause_cancels_every_in_flight_entity_and_releases_a_pending_settle() {
8759        let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
8760        let dir = tempfile::tempdir().expect("temp dir");
8761        let root = root_of(&dir);
8762        let repo = root.join("repo");
8763        init_repo_with_a_commit(&repo);
8764
8765        let started =
8766            Core::start_for_test(spec(vec![root]), Duration::from_secs(3600), tick_rx).discovered();
8767        let core = started.core;
8768        // Drained, so the only entry in flight below is the one this test puts there.
8769        let key = settle_launch(&core).entities[0].key.clone();
8770        let cancel = core.begin_untracked_probe_for_test(&key);
8771        assert!(!cancel.load(Ordering::Acquire));
8772
8773        core.pause();
8774        let settled = core.settle();
8775
8776        assert!(
8777            cancel.load(Ordering::Acquire),
8778            "pause should cancel the entity that was in flight"
8779        );
8780        assert!(settled.entities[0].branch.is_in_flight());
8781        drop(tick_tx);
8782    }
8783
8784    /// A launch walks the tree once.
8785    ///
8786    /// Discovery rides on every Generation
8787    /// ([refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
8788    /// "Discovery is never on the calling thread"), so counting a launch's walks is
8789    /// counting its Generations: one walk means the very first Generation a fresh `Core`
8790    /// mints is the only one a settled launch has, and that it already covers every row
8791    /// the walk found. A second walk would be a second Generation and would read here.
8792    #[test]
8793    fn a_launch_is_one_generation_over_every_row_its_own_walk_found() {
8794        let dir = tempfile::tempdir().expect("temp dir");
8795        let root = root_of(&dir);
8796        init_repo_with_a_commit(&root.join("first"));
8797        init_repo_with_a_commit(&root.join("second"));
8798
8799        let (_core, launched) = started_and_settled(spec(vec![root]));
8800
8801        assert_eq!(
8802            launched.generation,
8803            Generation::default().successor(),
8804            "a launch must settle on the first Generation a fresh `Core` mints; a second \
8805             walk of the same tree would be a second Generation"
8806        );
8807        let mut named: Vec<String> = launched
8808            .entities
8809            .iter()
8810            .filter(|entity| entity.branch.settled().is_some())
8811            .map(|entity| entity.name.to_string())
8812            .collect();
8813        named.sort();
8814        assert_eq!(
8815            named,
8816            vec!["first".to_string(), "second".to_string()],
8817            "that one Generation must cover every row its own walk found, or the walk it \
8818             saved would have to be paid by a second one"
8819        );
8820    }
8821
8822    /// A `Core` going away cancels what it still has in flight, the same way `pause` does.
8823    ///
8824    /// [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
8825    /// "Cancellation": an abandoned Generation is cancelled rather than left to finish,
8826    /// because both would contend for the same cores. A Set switch is where that bites,
8827    /// rebuilding the `Core` while the outgoing one's fan-out is still running.
8828    #[test]
8829    fn dropping_a_core_cancels_every_entity_it_still_has_in_flight() {
8830        let dir = tempfile::tempdir().expect("temp dir");
8831        let root = root_of(&dir);
8832        init_repo_with_a_commit(&root.join("repo"));
8833
8834        let (core, launched) = started_and_settled(spec(vec![root]));
8835        let key = launched.entities[0].key.clone();
8836        let cancel = core.begin_untracked_probe_for_test(&key);
8837        assert!(!cancel.load(Ordering::Acquire));
8838
8839        drop(core);
8840
8841        assert!(
8842            cancel.load(Ordering::Acquire),
8843            "a dropped Core must cancel the Generation it still has in flight rather than \
8844             leave it running against a Set nothing will read again"
8845        );
8846    }
8847
8848    /// Per-entity supersession, not global. An older Generation covers two entities,
8849    /// A and B, both simulated as still in flight. A Selection-scoped newer
8850    /// Generation covers only A: A's own older interrupt flag must be set, and B's
8851    /// must not, since the newer one never mentions B. Once the newer Generation has
8852    /// written A's cell, A's slow older result finally arrives and must be dropped
8853    /// there; B's own older result, arriving after everything else, must still be
8854    /// accepted, because the newer Generation never superseded it.
8855    ///
8856    /// The two are named by their order, never by their counter values, so a
8857    /// Generation minted earlier in the crate cannot renumber this test out from
8858    /// under itself.
8859    ///
8860    /// This is exactly the distinction a global-current-Generation comparison
8861    /// would get wrong: such a check compares every write against the table's one
8862    /// counter, which the Selection-scoped refresh has already advanced, so B's
8863    /// older result would be wrongly dropped even though nothing ever superseded B
8864    /// specifically. Before `Cell::settle`'s comparison was wired
8865    /// against the cell's own recorded Generation this test failed exactly there:
8866    /// B's late result was rejected, which is precisely the "cannot strand the
8867    /// rows it never spoke for" defect the ticket names.
8868    ///
8869    /// This test read A's interrupt flag intermittently false under load. The cause was
8870    /// `apply_probe_outcome` clearing the in-flight entry by key alone: launch's own
8871    /// Generation was left undrained here, so one of its probes could finish after the
8872    /// simulated older Generation had put its flags under the same keys and delete the
8873    /// entry holding them, leaving the Selection-scoped refresh nothing to supersede.
8874    /// Launch is drained first now, and the entry is cleared by Generation as well as by
8875    /// key, which `a_probe_finishing_clears_only_its_own_generations_in_flight_entry`
8876    /// pins directly.
8877    #[test]
8878    fn a_selection_scoped_refresh_supersedes_only_the_entity_it_covers() {
8879        let dir = tempfile::tempdir().expect("temp dir");
8880        let root = root_of(&dir);
8881        init_repo_with_a_commit(&root.join("a"));
8882        init_repo_with_a_commit(&root.join("b"));
8883
8884        let (core, snapshot) = started_and_settled(spec(vec![root]));
8885        let key_a = snapshot
8886            .entities
8887            .iter()
8888            .find(|entity| &*entity.name == "a")
8889            .expect("entity a discovered")
8890            .key
8891            .clone();
8892        let key_b = snapshot
8893            .entities
8894            .iter()
8895            .find(|entity| &*entity.name == "b")
8896            .expect("entity b discovered")
8897            .key
8898            .clone();
8899
8900        // The older Generation, simulated: both A and B are mid-flight, with nothing
8901        // spawned to complete either one, so the test controls exactly when each
8902        // one's result lands.
8903        let older = core.begin_shared_generation_for_test(&[key_a.clone(), key_b.clone()]);
8904
8905        // A Selection-scoped refresh over A alone, the very next Generation after the
8906        // one still in flight.
8907        let newer = core.refresh(std::slice::from_ref(&key_a));
8908        assert_eq!(
8909            newer,
8910            older.generation.successor(),
8911            "the Selection-scoped refresh must be the Generation immediately after the one \
8912             still in flight, with nothing minted in between"
8913        );
8914
8915        // Supersession happens on the new Generation's own thread, behind its walk, so
8916        // this is the rendezvous that says it has happened. A join, never a deadline: no
8917        // production rule bounds how long that walk takes short of the thirty seconds at
8918        // which discovery is abandoned.
8919        core.wait_dispatched_for_test();
8920        assert!(
8921            older.cancels[&key_a].load(Ordering::Acquire),
8922            "the entity the new Generation covers must have its old interrupt flag set"
8923        );
8924        assert!(
8925            !older.cancels[&key_b].load(Ordering::Acquire),
8926            "an entity the new Generation does not cover must be left running, untouched"
8927        );
8928
8929        // [`BACKSTOP`] rather than a budget: what follows reads the cell the new
8930        // Generation's own probe writes, which is a liveness property with no wall-clock
8931        // bound of its own.
8932        let after_refresh = core.settle();
8933
8934        let a_after_gen2 = after_refresh
8935            .entities
8936            .iter()
8937            .find(|entity| entity.key == key_a)
8938            .expect("entity a present");
8939        assert!(
8940            matches!(
8941                a_after_gen2.branch.settled(),
8942                Some(Settled::Known {
8943                    value: Head::Branch { .. },
8944                    at: _,
8945                    stale: _
8946                })
8947            ),
8948            "the newer Generation's real probe should have written A's cell by now"
8949        );
8950
8951        // A's slow older result finally arrives, after the newer Generation has
8952        // already written the cell: dropped, since it is lower than the Generation
8953        // already recorded there.
8954        core.apply_probe_result_for_test(
8955            &key_a,
8956            older.generation,
8957            Settled::Known {
8958                value: Head::Branch {
8959                    name: Arc::from("stale-from-generation-one"),
8960                    commit: gix::hash::Kind::Sha1.null(),
8961                },
8962                at: Timestamp::now(),
8963                stale: false,
8964            },
8965        );
8966        let after_stale_write = core.snapshot();
8967        let a_final = after_stale_write
8968            .entities
8969            .iter()
8970            .find(|entity| entity.key == key_a)
8971            .expect("entity a present");
8972        match a_final.branch.settled() {
8973            Some(Settled::Known {
8974                value: Head::Branch { name, .. },
8975                at: _,
8976                stale: _,
8977            }) => assert_ne!(
8978                &**name, "stale-from-generation-one",
8979                "a lower-Generation result must be dropped at the cell it would write"
8980            ),
8981            other => panic!("expected A to still hold the newer Generation's value, got {other:?}"),
8982        }
8983
8984        // B's own older result, landing last of all, is still accepted: the newer
8985        // Generation never covered B, so nothing superseded it.
8986        core.apply_probe_result_for_test(
8987            &key_b,
8988            older.generation,
8989            Settled::Known {
8990                value: Head::Branch {
8991                    name: Arc::from("b-generation-one-result"),
8992                    commit: gix::hash::Kind::Sha1.null(),
8993                },
8994                at: Timestamp::now(),
8995                stale: false,
8996            },
8997        );
8998        let final_snapshot = core.snapshot();
8999        let b_final = final_snapshot
9000            .entities
9001            .iter()
9002            .find(|entity| entity.key == key_b)
9003            .expect("entity b present");
9004        match b_final.branch.settled() {
9005            Some(Settled::Known {
9006                value: Head::Branch { name, .. },
9007                at: _,
9008                stale: _,
9009            }) => assert_eq!(
9010                &**name, "b-generation-one-result",
9011                "an entity the new Generation never covered must still accept its own result"
9012            ),
9013            other => {
9014                panic!("expected B's un-superseded older result to be accepted, got {other:?}")
9015            }
9016        }
9017    }
9018
9019    /// The deadline sweep abandons only what is still Loading when it fires. An
9020    /// entity already settled by the time the deadline sweep runs keeps its value
9021    /// untouched, blanking nothing, while a different entity still mid-flight in
9022    /// the same sweep becomes Unknown with the timed-out reason.
9023    #[test]
9024    fn the_deadline_sweep_keeps_already_settled_cells_and_only_times_out_what_is_still_loading() {
9025        let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
9026        let dir = tempfile::tempdir().expect("temp dir");
9027        let root = root_of(&dir);
9028        init_repo_with_a_commit(&root.join("a"));
9029        init_repo_with_a_commit(&root.join("b"));
9030
9031        let mut spec = spec(vec![root]);
9032        spec.generation_deadline = Duration::ZERO;
9033        let started = Core::start_for_test(spec, Duration::from_secs(3600), tick_rx).discovered();
9034        let core = started.core;
9035        // Drained, so the only cell still loading when the sweep fires is the one this
9036        // test puts in flight.
9037        let snapshot = settle_launch(&core);
9038        let key_a = snapshot
9039            .entities
9040            .iter()
9041            .find(|entity| &*entity.name == "a")
9042            .expect("entity a discovered")
9043            .key
9044            .clone();
9045        let key_b = snapshot
9046            .entities
9047            .iter()
9048            .find(|entity| &*entity.name == "b")
9049            .expect("entity b discovered")
9050            .key
9051            .clone();
9052
9053        // A is already settled, synchronously, before the deadline ever has a
9054        // chance to fire.
9055        let a_settled = core.probe_now(&key_a);
9056        let a_value_before = match a_settled.branch.settled() {
9057            Some(Settled::Known {
9058                value: Head::Branch { name, .. },
9059                at: _,
9060                stale: _,
9061            }) => Arc::clone(name),
9062            other => panic!("expected A's synchronous probe to settle a branch, got {other:?}"),
9063        };
9064
9065        // B is left mid-flight, in a Generation whose (zero) deadline has already
9066        // elapsed in real time, but the sweep has not run yet: no tick has been
9067        // sent.
9068        let cancel_b = core.begin_untracked_probe_for_test(&key_b);
9069        let before_tick = core.snapshot();
9070        let b_before = before_tick
9071            .entities
9072            .iter()
9073            .find(|entity| entity.key == key_b)
9074            .expect("entity b present");
9075        assert!(
9076            b_before.branch.is_in_flight(),
9077            "B must be mid-flight when the sweep fires; that is the only shape the sweep \
9078             may touch"
9079        );
9080        assert!(
9081            matches!(
9082                b_before.branch.settled(),
9083                Some(Settled::Known {
9084                    value: _,
9085                    at: _,
9086                    stale: _
9087                })
9088            ),
9089            "B still carries launch's own answer here, so the Unknown below is a write the \
9090             sweep made rather than a cell that was already empty, got {:?}",
9091            b_before.branch.settled()
9092        );
9093
9094        tick_tx.send(Instant::now()).expect("send one tick");
9095        let after_sweep = core.settle();
9096
9097        let a_after = after_sweep
9098            .entities
9099            .iter()
9100            .find(|entity| entity.key == key_a)
9101            .expect("entity a present");
9102        match a_after.branch.settled() {
9103            Some(Settled::Known {
9104                value: Head::Branch { name, .. },
9105                at: _,
9106                stale: _,
9107            }) => assert_eq!(
9108                name, &a_value_before,
9109                "an already-settled cell must keep its value when the deadline sweep runs, not be blanked"
9110            ),
9111            other => panic!("expected A's settled value to survive the sweep, got {other:?}"),
9112        }
9113
9114        let b_after = after_sweep
9115            .entities
9116            .iter()
9117            .find(|entity| entity.key == key_b)
9118            .expect("entity b present");
9119        assert!(matches!(
9120            b_after.branch.settled(),
9121            Some(Settled::Unknown(Unknown::TimedOut))
9122        ));
9123        assert!(
9124            !cancel_b.load(Ordering::Acquire),
9125            "the deadline sweep marks a cell Unknown; it never sets the entity's own \
9126             cancel flag, since the underlying probe (nonexistent here) is left to keep running"
9127        );
9128    }
9129
9130    /// The deadline sweep must reach a Worktree's outstanding `state` cell the
9131    /// same way it already reaches `branch` and `default_branch`: asking and
9132    /// getting nothing back is Unknown, not a cell stuck in-flight forever once
9133    /// the Generation that would have answered it is gone. A Repo's `state`,
9134    /// `NotApplicable` from construction and never in flight, must survive the
9135    /// same sweep untouched, proving the sweep only times out a cell actually
9136    /// marked in flight rather than blanket-settling every entity's `state` cell.
9137    #[test]
9138    fn the_deadline_sweep_times_out_a_worktrees_outstanding_state_but_leaves_a_repos_not_applicable_one_alone()
9139     {
9140        let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
9141        let dir = tempfile::tempdir().expect("temp dir");
9142        let root = root_of(&dir);
9143        let parent = root.join("parent");
9144        init_repo_with_a_commit(&parent);
9145        let worktree_path = root.join("feature-worktree");
9146        git(
9147            &parent,
9148            &[
9149                "worktree",
9150                "add",
9151                "-b",
9152                "feature",
9153                worktree_path.to_str().expect("utf8 path"),
9154            ],
9155        );
9156
9157        let mut spec = spec(vec![root]);
9158        spec.generation_deadline = Duration::ZERO;
9159        let started = Core::start_for_test(spec, Duration::from_secs(3600), tick_rx).discovered();
9160        let core = started.core;
9161        // Drained, so launch's own real refresh has already landed on every cell before
9162        // either `begin_untracked_probe_for_test` call below puts one artificially back in
9163        // flight: skipping this left a real probe for the same cell free to settle it
9164        // between that call and the sweep, which turns the sweep's own `is_in_flight`
9165        // guard (working exactly as designed, since a cell no longer loading is not the
9166        // sweep's to touch) into a race the assertion below loses however rarely.
9167        let snapshot = settle_launch(&core);
9168        let repo_key = snapshot
9169            .entities
9170            .iter()
9171            .find(|entity| matches!(entity.kind, Kind::Repo))
9172            .expect("repo entity present")
9173            .key
9174            .clone();
9175        let worktree_key = snapshot
9176            .entities
9177            .iter()
9178            .find(|entity| matches!(entity.kind, Kind::Worktree))
9179            .expect("worktree entity present")
9180            .key
9181            .clone();
9182
9183        // Both left mid-flight in a Generation whose (zero) deadline has already
9184        // elapsed, with no tick sent yet, mirroring how `Core::refresh` begins a
9185        // Worktree's `state` probe alongside `branch`. The Repo is in flight too
9186        // (on `branch` only, per the same gate), so the sweep actually reaches
9187        // it and the guard has something real to prove.
9188        core.begin_untracked_probe_for_test(&repo_key);
9189        core.begin_untracked_probe_for_test(&worktree_key);
9190
9191        tick_tx.send(Instant::now()).expect("send one tick");
9192        let after_sweep = core.settle();
9193
9194        let worktree_after = after_sweep
9195            .entities
9196            .iter()
9197            .find(|entity| entity.key == worktree_key)
9198            .expect("worktree entity present");
9199        assert!(
9200            matches!(
9201                worktree_after.state.settled(),
9202                Some(Settled::Unknown(Unknown::TimedOut))
9203            ),
9204            "expected the outstanding state cell to time out, got {:?}",
9205            worktree_after.state.settled()
9206        );
9207
9208        let repo_after = after_sweep
9209            .entities
9210            .iter()
9211            .find(|entity| entity.key == repo_key)
9212            .expect("repo entity present");
9213        assert!(
9214            matches!(repo_after.state.settled(), Some(Settled::NotApplicable)),
9215            "a Repo's Not applicable state must survive the sweep untouched, got {:?}",
9216            repo_after.state.settled()
9217        );
9218    }
9219
9220    /// Criterion 2's "never goes stale on a poll" made behavioural: the dedicated thread's
9221    /// tick-driven sweep is what a poll is in this codebase today (`spawn_clock_thread` calls
9222    /// [`sweep_deadline`] on every tick), and it must leave a receipt exactly as it was even
9223    /// while it is busy timing out a genuinely outstanding Cell on the very same entity.
9224    #[test]
9225    fn the_deadline_sweeps_poll_never_touches_an_entitys_action_receipt() {
9226        let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
9227        let dir = tempfile::tempdir().expect("temp dir");
9228        let root = root_of(&dir);
9229        let repo = root.join("repo");
9230        init_repo_with_a_commit(&repo);
9231
9232        let mut spec = spec(vec![root]);
9233        spec.generation_deadline = Duration::ZERO;
9234        let started = Core::start_for_test(spec, Duration::from_secs(3600), tick_rx).discovered();
9235        let core = started.core;
9236        // Drained, so the only entry the sweep below finds in flight is this test's own.
9237        let key = settle_launch(&core).entities[0].key.clone();
9238
9239        let receipt = crate::entity::ActionReceipt {
9240            label: Arc::from("reinstall"),
9241            steps: Arc::from(vec![crate::entity::StepResult {
9242                label: Arc::from("pnpm install"),
9243                outcome: crate::entity::StepOutcome::Ok,
9244                output: Arc::from(&b""[..]),
9245                elapsed: Duration::from_millis(1),
9246                elision: None,
9247                shell: false,
9248                interactive: false,
9249            }]),
9250            skip: None,
9251            finished_at: Timestamp::now(),
9252            running: None,
9253        };
9254        core.set_last_action_for_test(&key, receipt.clone());
9255
9256        // Left mid-flight in a Generation whose (zero) deadline has already elapsed, so the
9257        // sweep this tick triggers has a real Cell to time out on this very entity.
9258        core.begin_untracked_probe_for_test(&key);
9259        tick_tx.send(Instant::now()).expect("send one tick");
9260        let after = core.settle();
9261
9262        let entity = after
9263            .entities
9264            .iter()
9265            .find(|entity| entity.key == key)
9266            .expect("entity present");
9267        assert!(
9268            matches!(
9269                entity.branch.settled(),
9270                Some(Settled::Unknown(Unknown::TimedOut))
9271            ),
9272            "sanity check: the sweep must have actually timed out the in-flight cell, got {:?}",
9273            entity.branch.settled()
9274        );
9275        assert_eq!(entity.last_action, Some(receipt));
9276    }
9277
9278    /// Cancellation observed before a probe's very first read stops it from ever
9279    /// opening the repository at all, proven behaviourally rather than by
9280    /// re-reading the flag: a path that does not exist would settle as
9281    /// `Failed(Open(_))` if the open call actually ran, so getting `None` back
9282    /// instead is only possible if the read never started. This is the honest
9283    /// limit of what phase A can prove: `git::head_shape` is one syscall with no
9284    /// interruption point mid-read, so cancellation here stops work that has not
9285    /// started rather than work already running. [`classify_status_result_drops_an_error_once_cancel_reads_true`]
9286    /// covers the genuinely interruptible phase this crate now has.
9287    #[test]
9288    fn a_cancelled_probe_never_opens_the_repository_at_all() {
9289        let cancel = AtomicBool::new(true);
9290
9291        let outcome = probe_branch(
9292            Path::new("/nonexistent/nowhere-at-all"),
9293            None,
9294            Kind::Repo,
9295            &cancel,
9296        );
9297
9298        assert!(
9299            outcome.is_none(),
9300            "a probe observing cancellation before its first read must do no work \
9301             at all, not attempt the read and fail having tried it"
9302        );
9303    }
9304
9305    /// Phase C's own cancellation shape, distinct from phase A and B's "before the read
9306    /// starts" check: gix can report a genuinely mid-read cancellation as an `Err`
9307    /// (`dirty_counts_threads_the_cancel_flag_into_gix` in `git.rs` proves the flag actually
9308    /// reaches gix, which is what makes that `Err` possible at all), and this test covers the
9309    /// half that lives here, that `classify_status_result` folds that error back to `None`
9310    /// rather than `Settled::Failed` once `cancel` reads `true`, per ADR 0013's "interrupted
9311    /// work becomes Unknown rather than Failed". A mutation that dropped the `cancel`-aware
9312    /// arm (always settling `Failed` on any error, the way the cheaper phases' own errors do)
9313    /// fails this directly.
9314    #[test]
9315    fn classify_status_result_drops_an_error_once_cancel_reads_true() {
9316        let cancel = AtomicBool::new(true);
9317
9318        let outcome = classify_status_result(
9319            Err(crate::git::ProbeError::Status(Arc::from("boom"))),
9320            &cancel,
9321        );
9322
9323        assert!(
9324            outcome.is_none(),
9325            "an error alongside a cancel flag already set must read as cancelled, not \
9326             Failed, got {outcome:?}"
9327        );
9328    }
9329
9330    /// The other side of the same fold: an error with `cancel` still `false` is a genuine
9331    /// failure and must settle `Failed`, not be silently dropped the way a cancelled read is.
9332    #[test]
9333    fn classify_status_result_settles_failed_when_cancel_never_fired() {
9334        let cancel = AtomicBool::new(false);
9335
9336        let outcome = classify_status_result(
9337            Err(crate::git::ProbeError::Status(Arc::from("boom"))),
9338            &cancel,
9339        );
9340
9341        assert!(
9342            matches!(outcome, Some(Settled::Failed(git::ProbeError::Status(_)))),
9343            "a genuine error with no cancellation must settle Failed, got {outcome:?}"
9344        );
9345    }
9346
9347    /// gix polls `should_interrupt` per index entry rather than before every read, so a walk
9348    /// short enough to finish between checks (or with nothing left to check against) can
9349    /// complete and return `Ok` even though `cancel` was set part way through it. Settling
9350    /// that `Ok` anyway would let a cancelled generation write a value, exactly the outcome
9351    /// [refresh.md's "Cancellation"](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)
9352    /// says cancellation prevents. `classify_status_result` must re-check the same flag it
9353    /// owns on the `Ok` arm too, not only on `Err`, and drop the value the same way a
9354    /// cancelled `Err` is already dropped.
9355    #[test]
9356    fn classify_status_result_drops_an_ok_once_cancel_reads_true() {
9357        let cancel = AtomicBool::new(true);
9358
9359        let outcome = classify_status_result(Ok(DirtyCounts::default()), &cancel);
9360
9361        assert!(
9362            outcome.is_none(),
9363            "an Ok value that raced ahead of a cancel flag now set must read as cancelled, \
9364             not be settled Known, got {outcome:?}"
9365        );
9366    }
9367
9368    /// The other side of the same fold: an `Ok` with `cancel` still `false` is a genuine
9369    /// completed read and must settle `Known`, not be silently dropped.
9370    #[test]
9371    fn classify_status_result_settles_known_when_cancel_never_fired() {
9372        let cancel = AtomicBool::new(false);
9373        let counts = DirtyCounts {
9374            modified: 1,
9375            untracked: 2,
9376            deleted: 3,
9377        };
9378
9379        let outcome = classify_status_result(Ok(counts), &cancel);
9380
9381        assert!(
9382            matches!(
9383                outcome,
9384                Some(Settled::Known {
9385                    value,
9386                    at: _,
9387                    stale: _
9388                }) if value == counts
9389            ),
9390            "a genuine completed read with no cancellation must settle Known, got {outcome:?}"
9391        );
9392    }
9393
9394    /// The defining behaviour: a linked Worktree shares its parent's object store
9395    /// and remotes, but `Core` must still surface it as its own row rather than
9396    /// folding it into the Repo it is attached to. A real `git worktree add` is run
9397    /// against a genuine parent so the proof covers git's actual on-disk shape, not
9398    /// a hand-built stand-in for it.
9399    #[test]
9400    fn a_linked_worktree_is_its_own_entity_and_never_doubles_as_a_repo() {
9401        let dir = tempfile::tempdir().expect("temp dir");
9402        let root = root_of(&dir);
9403        let parent = root.join("parent");
9404        init_repo_with_a_commit(&parent);
9405        let worktree_path = root.join("feature-worktree");
9406        let status = Command::new("git")
9407            .arg("-C")
9408            .arg(&parent)
9409            .args([
9410                "worktree",
9411                "add",
9412                "-b",
9413                "feature",
9414                worktree_path.to_str().expect("utf8 path"),
9415            ])
9416            .status()
9417            .expect("run git worktree add");
9418        assert!(status.success());
9419
9420        let core = Core::start_discovered(spec(vec![root]));
9421        let snapshot = core.snapshot();
9422
9423        assert_eq!(
9424            snapshot.entities.len(),
9425            2,
9426            "expected the parent plus one Worktree, not two Repos"
9427        );
9428        let repo_count = snapshot
9429            .entities
9430            .iter()
9431            .filter(|entity| matches!(entity.kind, Kind::Repo))
9432            .count();
9433        let worktree_count = snapshot
9434            .entities
9435            .iter()
9436            .filter(|entity| matches!(entity.kind, Kind::Worktree))
9437            .count();
9438        assert_eq!(
9439            repo_count, 1,
9440            "the parent must be counted as exactly one Repo"
9441        );
9442        assert_eq!(
9443            worktree_count, 1,
9444            "the linked worktree must be counted as exactly one Worktree"
9445        );
9446
9447        let worktree_entity = snapshot
9448            .entities
9449            .iter()
9450            .find(|entity| matches!(entity.kind, Kind::Worktree))
9451            .expect("worktree entity present");
9452        let repo_entity = snapshot
9453            .entities
9454            .iter()
9455            .find(|entity| matches!(entity.kind, Kind::Repo))
9456            .expect("repo entity present");
9457        assert_eq!(worktree_entity.common_dir, repo_entity.common_dir);
9458
9459        // Each carries its own branch: the parent stayed on its default branch and
9460        // the worktree checked out `feature`.
9461        let repo_branch = core.probe_now(&repo_entity.key);
9462        let worktree_branch = core.probe_now(&worktree_entity.key);
9463        match (
9464            repo_branch.branch.settled(),
9465            worktree_branch.branch.settled(),
9466        ) {
9467            (
9468                Some(Settled::Known {
9469                    value:
9470                        Head::Branch {
9471                            name: repo_name, ..
9472                        },
9473                    at: _,
9474                    stale: _,
9475                }),
9476                Some(Settled::Known {
9477                    value:
9478                        Head::Branch {
9479                            name: worktree_name,
9480                            ..
9481                        },
9482                    at: _,
9483                    stale: _,
9484                }),
9485            ) => {
9486                assert_ne!(repo_name, worktree_name);
9487                assert_eq!(&**worktree_name, "feature");
9488            }
9489            other => panic!("expected both entities to read an attached branch, got {other:?}"),
9490        }
9491    }
9492
9493    /// End-to-end proof that `state` is actually wired into a real Generation:
9494    /// a linked Worktree whose branch is an ancestor of the default branch reads
9495    /// `Merged` after a real `refresh`, not merely in `landing`'s own unit tests.
9496    #[test]
9497    fn a_worktrees_branch_that_is_an_ancestor_of_the_default_branch_reads_merged_after_a_refresh() {
9498        let dir = tempfile::tempdir().expect("temp dir");
9499        let root = root_of(&dir);
9500        let parent = root.join("parent");
9501        init_repo_with_a_commit(&parent);
9502        git(
9503            &parent,
9504            &[
9505                "remote",
9506                "add",
9507                "origin",
9508                "https://example.invalid/repo.git",
9509            ],
9510        );
9511        let sha = head_sha(&parent);
9512        git(&parent, &["update-ref", "refs/remotes/origin/main", &sha]);
9513        let worktree_path = root.join("feature-worktree");
9514        git(
9515            &parent,
9516            &[
9517                "worktree",
9518                "add",
9519                "-b",
9520                "feature",
9521                worktree_path.to_str().expect("utf8 path"),
9522            ],
9523        );
9524
9525        let core = Core::start_discovered(spec(vec![root]));
9526        let keys: Vec<EntityKey> = core
9527            .snapshot()
9528            .entities
9529            .iter()
9530            .map(|entity| entity.key.clone())
9531            .collect();
9532
9533        core.refresh(&keys);
9534        let settled = core.settle();
9535
9536        let worktree_entity = settled
9537            .entities
9538            .iter()
9539            .find(|entity| matches!(entity.kind, Kind::Worktree))
9540            .expect("worktree entity present");
9541        assert!(
9542            matches!(
9543                worktree_entity.state.settled(),
9544                Some(Settled::Known {
9545                    value: WorktreeState::Merged,
9546                    at: _,
9547                    stale: _
9548                })
9549            ),
9550            "expected the worktree, at the same commit as the default branch, to read Merged, got {:?}",
9551            worktree_entity.state.settled()
9552        );
9553    }
9554
9555    /// The squash merge this whole ticket is named for, proven end to end
9556    /// through a real `refresh`: `feature`'s two commits are squashed into one
9557    /// commit on the default branch, so ancestry cannot see it (`feature`'s tip
9558    /// never becomes an ancestor), and only patch equivalence can. Its upstream
9559    /// tracking ref still resolves, matching the moment right after a squash
9560    /// merge and before the next prune removes it, which is what routes this
9561    /// entity through `Outstanding` into the second pass rather than settling
9562    /// `Gone` at the first.
9563    #[test]
9564    fn a_squash_merged_worktree_branch_reads_merged_after_a_refresh() {
9565        let dir = tempfile::tempdir().expect("temp dir");
9566        let root = root_of(&dir);
9567        let parent = root.join("parent");
9568        init_repo_with_a_commit(&parent);
9569        git(
9570            &parent,
9571            &[
9572                "remote",
9573                "add",
9574                "origin",
9575                "https://example.invalid/repo.git",
9576            ],
9577        );
9578        let worktree_path = root.join("feature-worktree");
9579        git(
9580            &parent,
9581            &[
9582                "worktree",
9583                "add",
9584                "-b",
9585                "feature",
9586                worktree_path.to_str().expect("utf8 path"),
9587            ],
9588        );
9589        fs::write(worktree_path.join("a.txt"), "one\n").expect("write a.txt");
9590        git(&worktree_path, &["add", "a.txt"]);
9591        git(&worktree_path, &["commit", "-m", "add a"]);
9592        fs::write(worktree_path.join("b.txt"), "two\n").expect("write b.txt");
9593        git(&worktree_path, &["add", "b.txt"]);
9594        git(&worktree_path, &["commit", "-m", "add b"]);
9595        let feature_sha = head_sha(&worktree_path);
9596
9597        // Squashed into the parent's own checkout, which is what the default
9598        // branch resolves against.
9599        git(&parent, &["merge", "--squash", "feature"]);
9600        git(&parent, &["commit", "-m", "squashed feature"]);
9601        let main_sha = head_sha(&parent);
9602        git(
9603            &parent,
9604            &["update-ref", "refs/remotes/origin/main", &main_sha],
9605        );
9606
9607        // `feature`'s own upstream, still resolving: the moment before a prune
9608        // removes it.
9609        git(&parent, &["config", "branch.feature.remote", "origin"]);
9610        git(
9611            &parent,
9612            &["config", "branch.feature.merge", "refs/heads/feature"],
9613        );
9614        git(
9615            &parent,
9616            &["update-ref", "refs/remotes/origin/feature", &feature_sha],
9617        );
9618
9619        let core = Core::start_discovered(spec(vec![root]));
9620        let keys: Vec<EntityKey> = core
9621            .snapshot()
9622            .entities
9623            .iter()
9624            .map(|entity| entity.key.clone())
9625            .collect();
9626
9627        core.refresh(&keys);
9628        let settled = core.settle();
9629
9630        let worktree_entity = settled
9631            .entities
9632            .iter()
9633            .find(|entity| matches!(entity.kind, Kind::Worktree))
9634            .expect("worktree entity present");
9635        assert!(
9636            matches!(
9637                worktree_entity.state.settled(),
9638                Some(Settled::Known {
9639                    value: WorktreeState::Merged,
9640                    at: _,
9641                    stale: _
9642                })
9643            ),
9644            "expected a squash-merged worktree branch to read Merged, got {:?}",
9645            worktree_entity.state.settled()
9646        );
9647    }
9648
9649    /// Proves the negative the state cell alone cannot: patch equivalence's
9650    /// expensive scan must never even start for an entity ancestry already
9651    /// settled. A Worktree whose branch is an ancestor of the default branch
9652    /// settles `Merged` at the first pass, so the only common dir in this test
9653    /// must show zero scans; a `state`-only assertion would still pass an
9654    /// implementation that ran the second pass over every entity and discarded
9655    /// whichever answer ancestry had already provided.
9656    #[test]
9657    fn patch_equivalence_never_runs_for_an_entity_ancestry_already_settled() {
9658        let dir = tempfile::tempdir().expect("temp dir");
9659        let root = root_of(&dir);
9660        let parent = root.join("parent");
9661        init_repo_with_a_commit(&parent);
9662        git(
9663            &parent,
9664            &[
9665                "remote",
9666                "add",
9667                "origin",
9668                "https://example.invalid/repo.git",
9669            ],
9670        );
9671        let sha = head_sha(&parent);
9672        git(&parent, &["update-ref", "refs/remotes/origin/main", &sha]);
9673        let worktree_path = root.join("feature-worktree");
9674        git(
9675            &parent,
9676            &[
9677                "worktree",
9678                "add",
9679                "-b",
9680                "feature",
9681                worktree_path.to_str().expect("utf8 path"),
9682            ],
9683        );
9684
9685        let (core, launched) = started_and_settled(spec(vec![root]));
9686        let keys: Vec<EntityKey> = launched
9687            .entities
9688            .iter()
9689            .map(|entity| entity.key.clone())
9690            .collect();
9691
9692        core.refresh(&keys);
9693        let settled = core.settle();
9694
9695        let worktree_entity = settled
9696            .entities
9697            .iter()
9698            .find(|entity| matches!(entity.kind, Kind::Worktree))
9699            .expect("worktree entity present");
9700        assert!(
9701            matches!(
9702                worktree_entity.state.settled(),
9703                Some(Settled::Known {
9704                    value: WorktreeState::Merged,
9705                    at: _,
9706                    stale: _
9707                })
9708            ),
9709            "expected ancestry alone to settle Merged here, got {:?}",
9710            worktree_entity.state.settled()
9711        );
9712        assert_eq!(
9713            core.patch_identity_reads_for_test(),
9714            0,
9715            "ancestry already settled this entity, so patch equivalence's shared \
9716             scan must never run for its common dir at all"
9717        );
9718    }
9719
9720    /// [`patch_equivalence`]'s own unit test proves the module itself writes no
9721    /// loose object; this proves the same through the real dispatch path a
9722    /// user's refresh actually runs, so a write introduced in `core.rs`'s glue
9723    /// rather than in the module would be caught too. Reuses the squash-merge
9724    /// fixture that routes a real `Core::refresh` into patch equivalence's
9725    /// second pass, and counts loose objects in the parent repository, since a
9726    /// linked Worktree shares its object database with its common dir.
9727    #[test]
9728    fn a_full_refresh_reaching_patch_equivalence_writes_no_loose_objects() {
9729        let dir = tempfile::tempdir().expect("temp dir");
9730        let root = root_of(&dir);
9731        let parent = root.join("parent");
9732        init_repo_with_a_commit(&parent);
9733        git(
9734            &parent,
9735            &[
9736                "remote",
9737                "add",
9738                "origin",
9739                "https://example.invalid/repo.git",
9740            ],
9741        );
9742        let worktree_path = root.join("feature-worktree");
9743        git(
9744            &parent,
9745            &[
9746                "worktree",
9747                "add",
9748                "-b",
9749                "feature",
9750                worktree_path.to_str().expect("utf8 path"),
9751            ],
9752        );
9753        fs::write(worktree_path.join("a.txt"), "one\n").expect("write a.txt");
9754        git(&worktree_path, &["add", "a.txt"]);
9755        git(&worktree_path, &["commit", "-m", "add a"]);
9756        fs::write(worktree_path.join("b.txt"), "two\n").expect("write b.txt");
9757        git(&worktree_path, &["add", "b.txt"]);
9758        git(&worktree_path, &["commit", "-m", "add b"]);
9759        let feature_sha = head_sha(&worktree_path);
9760
9761        git(&parent, &["merge", "--squash", "feature"]);
9762        git(&parent, &["commit", "-m", "squashed feature"]);
9763        let main_sha = head_sha(&parent);
9764        git(
9765            &parent,
9766            &["update-ref", "refs/remotes/origin/main", &main_sha],
9767        );
9768        git(&parent, &["config", "branch.feature.remote", "origin"]);
9769        git(
9770            &parent,
9771            &["config", "branch.feature.merge", "refs/heads/feature"],
9772        );
9773        git(
9774            &parent,
9775            &["update-ref", "refs/remotes/origin/feature", &feature_sha],
9776        );
9777
9778        let core = Core::start_discovered(spec(vec![root]));
9779        let keys: Vec<EntityKey> = core
9780            .snapshot()
9781            .entities
9782            .iter()
9783            .map(|entity| entity.key.clone())
9784            .collect();
9785
9786        let before = loose_object_count(&parent);
9787        core.refresh(&keys);
9788        let settled = core.settle();
9789        let after = loose_object_count(&parent);
9790
9791        let worktree_entity = settled
9792            .entities
9793            .iter()
9794            .find(|entity| matches!(entity.kind, Kind::Worktree))
9795            .expect("worktree entity present");
9796        assert!(
9797            matches!(
9798                worktree_entity.state.settled(),
9799                Some(Settled::Known {
9800                    value: WorktreeState::Merged,
9801                    at: _,
9802                    stale: _
9803                })
9804            ),
9805            "expected this refresh to actually reach patch equivalence and settle \
9806             Merged, got {:?}",
9807            worktree_entity.state.settled()
9808        );
9809        assert_eq!(
9810            before, after,
9811            "a full refresh reaching patch equivalence must never write a loose \
9812             object to the repository"
9813        );
9814    }
9815
9816    /// With patch equivalence now built, a diverged attached branch with a live
9817    /// upstream no longer stays outstanding forever: once ancestry says no,
9818    /// the second pass gets a real answer, and genuinely unmerged work (a real
9819    /// file change with no counterpart on the default branch, not merely an
9820    /// empty marker commit) settles `Active` rather than `Gone` or `Merged`,
9821    /// proven through the real dispatch path rather than either pass in
9822    /// isolation.
9823    #[test]
9824    fn a_diverged_worktree_with_a_live_upstream_and_genuinely_unmerged_work_settles_active_after_a_refresh()
9825     {
9826        let dir = tempfile::tempdir().expect("temp dir");
9827        let root = root_of(&dir);
9828        let parent = root.join("parent");
9829        init_repo_with_a_commit(&parent);
9830        let base_sha = head_sha(&parent);
9831        git(
9832            &parent,
9833            &[
9834                "remote",
9835                "add",
9836                "origin",
9837                "https://example.invalid/repo.git",
9838            ],
9839        );
9840        git(
9841            &parent,
9842            &["update-ref", "refs/remotes/origin/main", &base_sha],
9843        );
9844        let worktree_path = root.join("feature-worktree");
9845        git(
9846            &parent,
9847            &[
9848                "worktree",
9849                "add",
9850                "-b",
9851                "feature",
9852                worktree_path.to_str().expect("utf8 path"),
9853            ],
9854        );
9855        // Unmerged work: a real file change feature has that main (and
9856        // origin/main) do not, and that main never gains by any other means.
9857        fs::write(worktree_path.join("feature.txt"), "unmerged work\n").expect("write feature.txt");
9858        git(&worktree_path, &["add", "feature.txt"]);
9859        git(&worktree_path, &["commit", "-m", "unmerged"]);
9860        let feature_sha = head_sha(&worktree_path);
9861        // `feature`'s own upstream, live: the common dir's shared config and refs
9862        // make this visible from the worktree's own probe too.
9863        git(&parent, &["config", "branch.feature.remote", "origin"]);
9864        git(
9865            &parent,
9866            &["config", "branch.feature.merge", "refs/heads/feature"],
9867        );
9868        git(
9869            &parent,
9870            &["update-ref", "refs/remotes/origin/feature", &feature_sha],
9871        );
9872
9873        let core = Core::start_discovered(spec(vec![root]));
9874        let keys: Vec<EntityKey> = core
9875            .snapshot()
9876            .entities
9877            .iter()
9878            .map(|entity| entity.key.clone())
9879            .collect();
9880
9881        core.refresh(&keys);
9882        let settled = core.settle();
9883
9884        let worktree_entity = settled
9885            .entities
9886            .iter()
9887            .find(|entity| matches!(entity.kind, Kind::Worktree))
9888            .expect("worktree entity present");
9889        assert!(
9890            matches!(
9891                worktree_entity.state.settled(),
9892                Some(Settled::Known {
9893                    value: WorktreeState::Active,
9894                    at: _,
9895                    stale: _
9896                })
9897            ),
9898            "expected genuinely unmerged work with a live upstream to settle Active, got {:?}",
9899            worktree_entity.state.settled()
9900        );
9901    }
9902
9903    /// `CoreSpec::show_submodules` gates probing and dispatch, never Snapshot membership:
9904    /// a discovered Submodule is always part of the snapshot `Core::start` builds, shown or
9905    /// not, because the module pass that finds it always runs
9906    /// ([discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md)'s
9907    /// "the pass always runs, so Submodules are always known"). Built with the default,
9908    /// hidden reading precisely to prove that.
9909    #[test]
9910    fn a_submodule_is_in_the_snapshot_even_though_hidden_by_the_default_preference() {
9911        let dir = tempfile::tempdir().expect("temp dir");
9912        let root = root_of(&dir);
9913        let parent = root.join("parent");
9914        init_repo_with_a_commit(&parent);
9915        fs::write(
9916            parent.join(".gitmodules"),
9917            "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
9918        )
9919        .expect("write .gitmodules");
9920        fs::create_dir_all(parent.join("vendor").join("lib")).expect("create submodule dir");
9921
9922        let core = Core::start_discovered(spec(vec![root]));
9923        let snapshot = core.snapshot();
9924
9925        assert!(
9926            snapshot
9927                .entities
9928                .iter()
9929                .any(|entity| matches!(entity.kind, Kind::Submodule)),
9930            "a discovered Submodule must be in the snapshot even while show_submodules is off"
9931        );
9932    }
9933
9934    /// A Submodule's `state` and `base` cells must stay `Unknown` through a real
9935    /// refresh cycle, not only at construction:
9936    /// [`EntityState::probes_state`] and [`EntityState::probes_base`] are what
9937    /// stop `refresh`'s dispatch from ever calling `landing::probe` or
9938    /// `probe_base` for it again. The Submodule here is a real, valid repository
9939    /// with a real remote and a resolvable default branch ahead of its own tip
9940    /// (in fact an ancestor of it, so ancestry alone would prove `Merged`), so if
9941    /// either gate were missing this would settle a genuine live answer rather
9942    /// than merely fail to open.
9943    #[test]
9944    fn a_submodules_state_and_base_cells_stay_unknown_through_a_real_refresh() {
9945        let dir = tempfile::tempdir().expect("temp dir");
9946        let root = root_of(&dir);
9947        let parent = root.join("parent");
9948        init_repo_with_a_commit(&parent);
9949        fs::write(
9950            parent.join(".gitmodules"),
9951            "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
9952        )
9953        .expect("write .gitmodules");
9954        let submodule = parent.join("vendor").join("lib");
9955        init_repo_with_a_commit(&submodule);
9956        git(
9957            &submodule,
9958            &["remote", "add", "origin", "https://example.invalid/lib.git"],
9959        );
9960        let root_sha = head_sha(&submodule);
9961        git(&submodule, &["commit", "--allow-empty", "-m", "second"]);
9962        let tip_sha = head_sha(&submodule);
9963        git(&submodule, &["reset", "--hard", &root_sha]);
9964        git(
9965            &submodule,
9966            &["update-ref", "refs/remotes/origin/main", &tip_sha],
9967        );
9968
9969        // Shown, so the explicit `refresh` below actually dispatches a probe against it:
9970        // this test is about `probes_base`'s own gate, not about `show_submodules`'s.
9971        let mut core_spec = spec(vec![root]);
9972        core_spec.show_submodules = true;
9973        let core = Core::start_discovered(core_spec);
9974        let key = core
9975            .snapshot()
9976            .entities
9977            .iter()
9978            .find(|entity| matches!(entity.kind, Kind::Submodule))
9979            .expect("a discovered Submodule")
9980            .key
9981            .clone();
9982
9983        core.refresh(std::slice::from_ref(&key));
9984        let settled = core.settle();
9985        let submodule_entity = settled
9986            .entities
9987            .iter()
9988            .find(|entity| entity.key == key)
9989            .expect("the Submodule entity");
9990
9991        assert!(
9992            matches!(
9993                submodule_entity.base.settled(),
9994                Some(Settled::Unknown(Unknown::NoDefaultBranch))
9995            ),
9996            "expected a Submodule's base to stay Unknown through a real refresh, \
9997             got {:?}",
9998            submodule_entity.base.settled()
9999        );
10000        assert!(
10001            matches!(
10002                submodule_entity.state.settled(),
10003                Some(Settled::Unknown(Unknown::NoDefaultBranch))
10004            ),
10005            "expected a Submodule's state to stay Unknown through a real refresh, \
10006             rather than settling Merged off an untrusted default branch, got {:?}",
10007            submodule_entity.state.settled()
10008        );
10009    }
10010
10011    /// [discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md)'s
10012    /// "The Submodule row" fixes `name` as "the submodule path"; this proves the fact lands
10013    /// on the real `EntityState` `Core::start` builds, not only on the intermediate
10014    /// `DiscoveredEntity` `discovery::tests` already covers.
10015    #[test]
10016    fn a_submodules_entity_name_is_its_relative_path_not_its_basename() {
10017        let dir = tempfile::tempdir().expect("temp dir");
10018        let root = root_of(&dir);
10019        let parent = root.join("parent");
10020        init_repo_with_a_commit(&parent);
10021        fs::write(
10022            parent.join(".gitmodules"),
10023            "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
10024        )
10025        .expect("write .gitmodules");
10026        fs::create_dir_all(parent.join("vendor").join("lib")).expect("create submodule dir");
10027
10028        let core = Core::start_discovered(spec(vec![root]));
10029        let submodule = core
10030            .snapshot()
10031            .entities
10032            .into_iter()
10033            .find(|entity| matches!(entity.kind, Kind::Submodule))
10034            .expect("a discovered Submodule");
10035
10036        assert_eq!(
10037            submodule.name.as_ref(),
10038            "vendor/lib",
10039            "expected the declared relative path, not the basename `lib`"
10040        );
10041    }
10042
10043    /// AC3's negative case: an uninitialised Submodule (never `git submodule update
10044    /// --init`-ed, so its own path holds no `.git` at all) settles every cell a probe would
10045    /// otherwise open a repository for `Unknown(SubmoduleUninitialized)`, never `Failed`,
10046    /// because not being there yet is the normal, expected shape
10047    /// ([discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md)'s
10048    /// "An uninitialised Submodule is a row with every cell blank and `?` in the gutter").
10049    /// The row still exists (the assertion below finds it), so the row itself is not the
10050    /// mutation this covers; `probe_branch`/`probe_sync`/`probe_status`'s classification is.
10051    #[test]
10052    fn an_uninitialised_submodules_probed_cells_settle_unknown_not_failed() {
10053        let dir = tempfile::tempdir().expect("temp dir");
10054        let root = root_of(&dir);
10055        let parent = root.join("parent");
10056        init_repo_with_a_commit(&parent);
10057        fs::write(
10058            parent.join(".gitmodules"),
10059            "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
10060        )
10061        .expect("write .gitmodules");
10062        // Deliberately never initialised: no directory at all at the declared path, the
10063        // shape a plain `git clone` (no `--recurse-submodules`) leaves behind.
10064
10065        let mut core_spec = spec(vec![root]);
10066        core_spec.show_submodules = true;
10067        let core = Core::start_discovered(core_spec);
10068        let key = core
10069            .snapshot()
10070            .entities
10071            .iter()
10072            .find(|entity| matches!(entity.kind, Kind::Submodule))
10073            .expect("a discovered Submodule")
10074            .key
10075            .clone();
10076
10077        core.refresh(std::slice::from_ref(&key));
10078        let settled = core.settle();
10079        let submodule = settled
10080            .entities
10081            .iter()
10082            .find(|entity| entity.key == key)
10083            .expect("the Submodule entity");
10084
10085        assert!(
10086            matches!(
10087                submodule.branch.settled(),
10088                Some(Settled::Unknown(Unknown::SubmoduleUninitialized))
10089            ),
10090            "expected branch to settle Unknown(SubmoduleUninitialized), got {:?}",
10091            submodule.branch.settled()
10092        );
10093        assert!(
10094            matches!(
10095                submodule.sync.settled(),
10096                Some(Settled::Unknown(Unknown::SubmoduleUninitialized))
10097            ),
10098            "expected sync to settle Unknown(SubmoduleUninitialized), got {:?}",
10099            submodule.sync.settled()
10100        );
10101        assert!(
10102            matches!(
10103                submodule.dirty.settled(),
10104                Some(Settled::Unknown(Unknown::SubmoduleUninitialized))
10105            ),
10106            "expected dirty to settle Unknown(SubmoduleUninitialized), got {:?}",
10107            submodule.dirty.settled()
10108        );
10109        assert_eq!(
10110            summary(submodule),
10111            RowSummary::Unknown,
10112            "expected the row's own gutter fold to read Unknown, not Failed"
10113        );
10114    }
10115
10116    /// AC4's cost half: `show_submodules` off means a dispatched Generation never even
10117    /// opens a shown Submodule's own repository, while a shown one right beside it is
10118    /// probed normally in the very same Generation. Both submodules are real, valid
10119    /// repositories, so a probed-but-ignored implementation and a never-dispatched one are
10120    /// distinguishable only by whether the hidden one's cells ever leave "never settled".
10121    #[test]
10122    fn dispatch_skips_probing_a_hidden_submodule_while_probing_the_same_one_shown() {
10123        let dir = tempfile::tempdir().expect("temp dir");
10124        let root = root_of(&dir);
10125        let parent = root.join("parent");
10126        init_repo_with_a_commit(&parent);
10127        fs::write(
10128            parent.join(".gitmodules"),
10129            "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
10130        )
10131        .expect("write .gitmodules");
10132        init_repo_with_a_commit(&parent.join("vendor").join("lib"));
10133
10134        // `spec`'s own default: `show_submodules: false`.
10135        let core = Core::start_discovered(spec(vec![root]));
10136        let key = core
10137            .snapshot()
10138            .entities
10139            .iter()
10140            .find(|entity| matches!(entity.kind, Kind::Submodule))
10141            .expect("a discovered Submodule")
10142            .key
10143            .clone();
10144
10145        // First Generation, dispatched while hidden: `dispatch` must skip it outright.
10146        core.refresh(std::slice::from_ref(&key));
10147        let while_hidden = core.settle();
10148        let hidden_entity = while_hidden
10149            .entities
10150            .iter()
10151            .find(|entity| entity.key == key)
10152            .expect("submodule entity");
10153        assert!(
10154            hidden_entity.branch.settled().is_none(),
10155            "a Submodule dispatched while hidden must never even reach probe_branch, \
10156             so its cell stays never-settled rather than holding any value at all, got {:?}",
10157            hidden_entity.branch.settled()
10158        );
10159
10160        // Toggled live, no rebuild, then the very same key is handed to `refresh` again:
10161        // the second Generation is what proves the flag narrows the work rather than the
10162        // key, since nothing about the key or the `Core` itself changed in between.
10163        core.set_show_submodules(true);
10164        core.refresh(std::slice::from_ref(&key));
10165        let while_shown = core.settle();
10166        let shown_entity = while_shown
10167            .entities
10168            .iter()
10169            .find(|entity| entity.key == key)
10170            .expect("submodule entity");
10171        assert!(
10172            matches!(
10173                shown_entity.branch.settled(),
10174                Some(Settled::Known {
10175                    value: _,
10176                    at: _,
10177                    stale: _
10178                })
10179            ),
10180            "expected the same Submodule's branch to settle a real value once shown, got {:?}",
10181            shown_entity.branch.settled()
10182        );
10183    }
10184
10185    /// AC4's other half: toggling the live preference is free. Proven the same way
10186    /// `reload_with_the_same_active_set_leaves_discovery_and_its_generation_untouched`
10187    /// proves a same-Set reload never rebuilds `Core`: a Generation counter a rediscovery
10188    /// or a dispatch would have to move, checked before and after the toggle with nothing
10189    /// else run in between.
10190    #[test]
10191    fn toggling_show_submodules_starts_no_new_generation_and_dispatches_nothing() {
10192        let dir = tempfile::tempdir().expect("temp dir");
10193        let root = root_of(&dir);
10194        init_repo_with_a_commit(&root.join("repo-a"));
10195
10196        // Drained, so the two readings below differ only by whatever the toggles did.
10197        let (core, launched) = started_and_settled(spec(vec![root]));
10198        let before = launched.generation;
10199        let dispatched_before = core.dispatch_log_for_test();
10200        assert!(
10201            !dispatched_before.is_empty(),
10202            "launch dispatched nothing, so the comparison below would hold however much a \
10203             toggle dispatched"
10204        );
10205
10206        core.set_show_submodules(true);
10207        core.set_show_submodules(false);
10208
10209        assert_eq!(
10210            core.snapshot().generation,
10211            before,
10212            "toggling show_submodules must start no Generation of its own"
10213        );
10214        assert_eq!(
10215            core.dispatch_log_for_test(),
10216            dispatched_before,
10217            "toggling show_submodules must dispatch no probe of its own, leaving the last \
10218             Generation's own log exactly as it found it"
10219        );
10220    }
10221
10222    /// AC5: a `.gitmodules` parse failure marks the parent Repo's row Failed whether or not
10223    /// Submodules are shown, because the module pass that finds the failure runs either way
10224    /// ([discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md)'s
10225    /// "Failure": "The mark appears whether or not `show_submodules` is on, because the pass
10226    /// ran either way"). `spec`'s own default is already `show_submodules: false`, which is
10227    /// what makes this a real proof rather than a coincidence of some other default.
10228    #[test]
10229    fn a_malformed_gitmodules_file_still_fails_the_parent_while_submodules_are_hidden() {
10230        let dir = tempfile::tempdir().expect("temp dir");
10231        let root = root_of(&dir);
10232        let parent = root.join("parent");
10233        init_repo_with_a_commit(&parent);
10234        fs::write(
10235            parent.join(".gitmodules"),
10236            "[submodule \"lib\"\n\tpath = lib\n",
10237        )
10238        .expect("write malformed .gitmodules");
10239
10240        let core = Core::start_discovered(spec(vec![root]));
10241        let key = core
10242            .snapshot()
10243            .entities
10244            .iter()
10245            .find(|entity| entity.key.path() == parent)
10246            .expect("the parent entity")
10247            .key
10248            .clone();
10249        // The fold reads Failed only once the row holds some probed value at all: a
10250        // Generation's own dispatch is what proves the mark survives real probing, not
10251        // merely discovery's own construction-time diagnostics write.
10252        core.refresh(std::slice::from_ref(&key));
10253        let settled = core.settle();
10254        let parent_entity = settled
10255            .entities
10256            .iter()
10257            .find(|entity| entity.key == key)
10258            .expect("the parent entity");
10259
10260        assert_eq!(
10261            summary(parent_entity),
10262            RowSummary::Failed,
10263            "expected the parent to fold Failed even with Submodules hidden"
10264        );
10265        assert!(
10266            parent_entity.diagnostics.gitmodules_failed.is_some(),
10267            "expected the failure recorded in Diagnostics for the detail pane"
10268        );
10269        assert!(
10270            !settled
10271                .entities
10272                .iter()
10273                .any(|entity| matches!(entity.kind, Kind::Submodule)),
10274            "an unparseable .gitmodules yields no Submodule rows for that parent"
10275        );
10276    }
10277
10278    #[test]
10279    fn count_matches_a_plain_discoverys_entity_count() {
10280        let dir = tempfile::tempdir().expect("temp dir");
10281        let root = root_of(&dir);
10282        init_repo_with_a_commit(&root.join("one"));
10283        init_repo_with_a_commit(&root.join("two"));
10284
10285        let set = SetSpec {
10286            name: "test".to_string(),
10287            roots: vec![root],
10288            include: Vec::new(),
10289            exclude: Vec::new(),
10290        };
10291
10292        assert_eq!(discovery::count(&set), 2);
10293    }
10294
10295    #[test]
10296    fn the_slow_discovery_watcher_warns_with_the_count_reached_and_the_roots() {
10297        let progress = Arc::new(AtomicUsize::new(42));
10298        let finished = Arc::new(AtomicBool::new(false));
10299        let roots = vec![PathBuf::from("/repos/a"), PathBuf::from("/repos/b")];
10300
10301        let warning = watch_for_slow_discovery(progress, finished, roots, Duration::from_millis(1));
10302
10303        let message = warning.expect("a walk that has not finished should warn");
10304        assert!(message.contains("42"));
10305        assert!(message.contains("/repos/a"));
10306        assert!(message.contains("/repos/b"));
10307    }
10308
10309    #[test]
10310    fn the_slow_discovery_watcher_is_silent_once_the_walk_has_already_finished() {
10311        let progress = Arc::new(AtomicUsize::new(7));
10312        let finished = Arc::new(AtomicBool::new(true));
10313
10314        let warning =
10315            watch_for_slow_discovery(progress, finished, Vec::new(), Duration::from_millis(1));
10316
10317        assert!(warning.is_none());
10318    }
10319
10320    /// The same watcher `start_internal` wires in: on a fast, already-finished
10321    /// walk (the common case), joining its handle proves it ran and recorded no
10322    /// warning, exercised through `Core::start` itself rather than in isolation.
10323    /// `warn_after` is one second, the real production threshold, rather than a
10324    /// margin picked for speed: a one-repository walk finishes orders of
10325    /// magnitude faster than that even on a loaded machine, so this proves the
10326    /// fast path without racing a real walk the way a millisecond threshold did.
10327    #[test]
10328    fn a_fast_discovery_leaves_no_warning_once_the_watcher_has_run() {
10329        let dir = tempfile::tempdir().expect("temp dir");
10330        let root = root_of(&dir);
10331        init_repo_with_a_commit(&root.join("repo"));
10332        let (_tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
10333
10334        let started =
10335            Core::start_for_test(spec(vec![root]), Duration::from_secs(1), tick_rx).discovered();
10336        started
10337            .discovery_watcher
10338            .join()
10339            .expect("watcher thread should not panic");
10340
10341        assert!(started.core.discovery_warning().is_none());
10342    }
10343
10344    /// A [`DiscoveryGate`] starting `open`, and the channel that opens it once the call
10345    /// under test has returned.
10346    ///
10347    /// The gate is what makes "before its walk has run" a rendezvous rather than a
10348    /// margin. The channel is what makes an implementation that walks inline fail its
10349    /// assertion instead of wedging the run: nothing else would ever open the gate for
10350    /// it, so the backstop below is its only release, and the assertion then reports.
10351    fn gate_opened_on_signal(open: bool) -> (DiscoveryGate, Sender<()>, JoinHandle<()>) {
10352        let gate: DiscoveryGate = Arc::new((Mutex::new(open), Condvar::new()));
10353        let (returned_tx, returned_rx) = crossbeam_channel::bounded::<()>(1);
10354        let opener = thread::spawn({
10355            let gate = Arc::clone(&gate);
10356            move || {
10357                let _ = returned_rx.recv_timeout(crate::liveness::BACKSTOP);
10358                set_discovery_gate(&gate, true);
10359            }
10360        });
10361        (gate, returned_tx, opener)
10362    }
10363
10364    /// Criterion 1: `Core::start` returns before discovery has finished, and the rows
10365    /// land when discovery does.
10366    ///
10367    /// The walk is held closed before the `Core` is built, so the empty table below is
10368    /// the table `start` actually returned rather than one this test raced it to. Joining
10369    /// the harness's own `initial_discovery` handle afterwards is the rendezvous that says
10370    /// the walk landed: no sleep and no poll on either side.
10371    ///
10372    /// The row's phase C is held from before the walk is let go, so the cell read below
10373    /// is read at a point this test fixes rather than at whatever point launch's own
10374    /// Generation happened to have reached.
10375    #[test]
10376    fn start_returns_against_an_empty_table_and_the_rows_land_when_discovery_does() {
10377        let dir = tempfile::tempdir().expect("temp dir");
10378        let root = root_of(&dir);
10379        let repo = root.join("repo");
10380        init_repo_with_a_commit(&repo);
10381        let (_tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
10382        let (gate, start_returned, opener) = gate_opened_on_signal(false);
10383
10384        let started = Core::start_for_test_gated(
10385            spec(vec![root]),
10386            Duration::from_secs(3600),
10387            discovery::ABANDON_AFTER,
10388            tick_rx,
10389            Some(Arc::clone(&gate)),
10390        );
10391        let at_start = started.core.snapshot();
10392        let key = EntityKey::new(Arc::from(repo.as_path()));
10393        started.core.hold_phase_c_for_test(&key);
10394        start_returned.send(()).expect("the opener is listening");
10395        opener.join().expect("the opener thread should not panic");
10396        let started = started.discovered();
10397
10398        assert!(
10399            at_start.entities.is_empty(),
10400            "`Core::start` must return before discovery has finished, against the empty \
10401             table a consumer draws its first frame from, got {:?}",
10402            at_start
10403                .entities
10404                .iter()
10405                .map(|entity| entity.name.to_string())
10406                .collect::<Vec<_>>()
10407        );
10408
10409        let landed = started.core.snapshot();
10410        assert_eq!(
10411            landed
10412                .entities
10413                .iter()
10414                .map(|entity| entity.name.to_string())
10415                .collect::<Vec<_>>(),
10416            vec!["repo".to_string()],
10417            "the row must land on the table as soon as discovery does"
10418        );
10419        assert!(
10420            landed.entities[0].dirty.settled().is_none() && landed.entities[0].dirty.is_in_flight(),
10421            "discovery lands the row alone: launch's own Generation is already covering it \
10422             and its Cells stay unsettled until that Generation answers, which is what the \
10423             spinner sits behind"
10424        );
10425
10426        started.core.release_phase_c_for_test(&key);
10427        started.core.wait_phase_c_finished_for_test(&key);
10428    }
10429
10430    /// Criterion 2: a Generation that resolves its own order after its own discovery
10431    /// covers every row that walk found, including the ones the caller could not have
10432    /// named, and fills their Cells.
10433    ///
10434    /// `refresh_all` rather than `refresh`, because a caller that has just discarded the
10435    /// old Set's rows has no key to order by; the row below is discovered by this
10436    /// Generation's own walk and probed by the same Generation. Named by its order after
10437    /// launch's own Generation rather than by a number.
10438    #[test]
10439    fn refresh_all_covers_every_row_its_own_discovery_found() {
10440        let dir = tempfile::tempdir().expect("temp dir");
10441        let root = root_of(&dir);
10442        init_repo_with_a_commit(&root.join("repo"));
10443
10444        let (core, launched) = started_and_settled(spec(vec![root.clone()]));
10445        assert_eq!(
10446            launched
10447                .entities
10448                .iter()
10449                .map(|entity| entity.name.to_string())
10450                .collect::<Vec<_>>(),
10451            vec!["repo".to_string()],
10452            "launch's own walk must have landed and covered exactly the one row that \
10453             existed when it ran"
10454        );
10455        // Created after that walk finished, so this row exists in no snapshot the caller
10456        // could have read: only a Generation that resolves its own order after its own
10457        // discovery reaches it.
10458        init_repo_with_a_commit(&root.join("late"));
10459
10460        assert_eq!(
10461            core.refresh_all(),
10462            launched.generation.successor(),
10463            "`refresh_all` must be the Generation immediately after the one already on the \
10464             table"
10465        );
10466        let settled = core.settle();
10467
10468        let mut named: Vec<String> = settled
10469            .entities
10470            .iter()
10471            .filter(|entity| entity.branch.settled().is_some())
10472            .map(|entity| entity.name.to_string())
10473            .collect();
10474        named.sort();
10475        assert_eq!(
10476            named,
10477            vec!["late".to_string(), "repo".to_string()],
10478            "the Generation must cover every row its own discovery found, including one the \
10479             caller had no key for"
10480        );
10481    }
10482
10483    /// Criterion 3: `r`, focus gained and resume all reach `Core::refresh`, and it
10484    /// returns before its own Generation's discovery has run, so none of them holds the
10485    /// event loop for the length of a walk.
10486    ///
10487    /// `late` is created after the first walk has already finished, so only this
10488    /// `refresh`'s own walk could ever find it: its absence from the table `refresh`
10489    /// returned against is what says that walk had not run. Opening the gate afterwards
10490    /// lets the same Generation finish, which is what proves the work was deferred rather
10491    /// than dropped.
10492    #[test]
10493    fn refresh_returns_before_its_own_generations_discovery_has_run() {
10494        let dir = tempfile::tempdir().expect("temp dir");
10495        let root = root_of(&dir);
10496        init_repo_with_a_commit(&root.join("repo"));
10497        let (_tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
10498        let (gate, walk_may_run, opener) = gate_opened_on_signal(true);
10499
10500        let started = Core::start_for_test_gated(
10501            spec(vec![root.clone()]),
10502            Duration::from_secs(3600),
10503            discovery::ABANDON_AFTER,
10504            tick_rx,
10505            Some(Arc::clone(&gate)),
10506        )
10507        .discovered();
10508        let core = started.core;
10509        // Drained, so the settle-gate reading below is this `refresh`'s alone.
10510        let launched = settle_launch(&core);
10511        let keys: Vec<EntityKey> = launched
10512            .entities
10513            .iter()
10514            .map(|entity| entity.key.clone())
10515            .collect();
10516        init_repo_with_a_commit(&root.join("late"));
10517
10518        set_discovery_gate(&gate, false);
10519        let generation = core.refresh(&keys);
10520        let while_held = core.snapshot();
10521        let dispatched_while_held = core.settle_gate_count_for_test();
10522        walk_may_run.send(()).expect("the opener is listening");
10523        opener.join().expect("the opener thread should not panic");
10524
10525        assert_eq!(
10526            generation,
10527            launched.generation.successor(),
10528            "`refresh` must return its own Generation's number, the one immediately after \
10529             the table's, before that Generation has done any of its work"
10530        );
10531        assert!(
10532            !while_held
10533                .entities
10534                .iter()
10535                .any(|entity| &*entity.name == "late"),
10536            "`refresh` must return before its own Generation's walk has run, so a Repo \
10537             created after the previous walk is not on the table it returned against"
10538        );
10539        assert_eq!(
10540            dispatched_while_held, 0,
10541            "`refresh` returned before its Generation reached the table at all, so nothing \
10542             is dispatched yet"
10543        );
10544
10545        core.wait_dispatched_for_test();
10546        let settled = core.settle();
10547
10548        assert!(
10549            settled
10550                .entities
10551                .iter()
10552                .any(|entity| &*entity.name == "late"),
10553            "the deferred Generation must still run its own walk once it is let through: \
10554             deferred, never dropped"
10555        );
10556    }
10557
10558    /// The turnstile's whole claim: a Generation reserved second cannot reach the table
10559    /// before the one reserved first, whatever the two threads' own scheduling does.
10560    ///
10561    /// Without it a `refresh` whose walk finished quickly could insert its in-flight
10562    /// entries ahead of an older Generation's, leaving the older one to cancel the newer
10563    /// one and record itself as the live one, which is
10564    /// [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
10565    /// "Supersession" read backwards. The later ticket is taken on this thread, so it can
10566    /// only ever record itself after the earlier body has recorded and released; an
10567    /// implementation that did not wait would record the later one first.
10568    #[test]
10569    fn a_dispatch_body_waits_for_every_earlier_reserved_generation() {
10570        let turnstile = Arc::new(DispatchTurnstile::default());
10571        let earlier = turnstile.reserve();
10572        let later = turnstile.reserve();
10573        let order = Arc::new(Mutex::new(Vec::new()));
10574
10575        let earlier_body = thread::spawn({
10576            let turnstile = Arc::clone(&turnstile);
10577            let order = Arc::clone(&order);
10578            move || {
10579                let _turn = turnstile.take(earlier);
10580                order.lock().unwrap().push(earlier);
10581            }
10582        });
10583
10584        {
10585            let _turn = turnstile.take(later);
10586            order.lock().unwrap().push(later);
10587        }
10588        earlier_body
10589            .join()
10590            .expect("the earlier body should not panic");
10591
10592        assert_eq!(
10593            *order.lock().unwrap(),
10594            vec![earlier, later],
10595            "a dispatch body must run in the order its Generation was reserved"
10596        );
10597    }
10598
10599    /// The generic cancellation primitive stops a loop the instant `cancel` is
10600    /// observed, proven with a channel rendezvous rather than a sleep: `cancel` is
10601    /// set only after the worker's third step has genuinely completed, so a fourth
10602    /// step running at all would mean the flag was set but never actually checked.
10603    #[test]
10604    fn run_while_not_cancelled_stops_at_the_next_check_rather_than_running_forever() {
10605        let cancel = Arc::new(AtomicBool::new(false));
10606        let worker_cancel = Arc::clone(&cancel);
10607        let (step_started_tx, step_started_rx) = crossbeam_channel::bounded::<()>(0);
10608        let (proceed_tx, proceed_rx) = crossbeam_channel::bounded::<()>(0);
10609
10610        let worker = thread::spawn(move || {
10611            run_while_not_cancelled(&worker_cancel, || {
10612                step_started_tx.send(()).expect("test should be listening");
10613                proceed_rx.recv().is_ok()
10614            })
10615        });
10616
10617        for _ in 0..2 {
10618            step_started_rx
10619                .recv()
10620                .expect("worker should announce each step");
10621            proceed_tx.send(()).expect("let the step finish");
10622        }
10623        step_started_rx
10624            .recv()
10625            .expect("worker should announce its third step");
10626        cancel.store(true, Ordering::Release);
10627        proceed_tx.send(()).expect("let the third step finish");
10628
10629        let ran = worker.join().expect("worker thread should not panic");
10630
10631        assert_eq!(
10632            ran, 3,
10633            "expected cancellation to stop the loop after its third step"
10634        );
10635    }
10636
10637    /// Phase A's own per-entity timing distribution: opens (or reuses a cached
10638    /// handle for) every entity in `population` and reads `HEAD` from it, exactly
10639    /// the work `probe_branch` does, one rayon task per entity via `fanout::scatter`
10640    /// rather than `Core::refresh`, so the timing is not entangled with the
10641    /// settle-gate bookkeeping a full `Core` also pays for. Returns one
10642    /// [`Duration`] per entity actually probed, so a caller reports a real
10643    /// distribution rather than a total divided by a count.
10644    fn benchmark_identity_phase(
10645        population: Vec<crate::discovery::DiscoveredEntity>,
10646    ) -> (Duration, Vec<Duration>) {
10647        let (tx, rx) = crossbeam_channel::unbounded();
10648        let started = Instant::now();
10649        crate::fanout::scatter(population, tx, |entity| {
10650            let task_started = Instant::now();
10651            let repo = match &entity.repo {
10652                Some(repo) => repo.to_thread_local(),
10653                None => match git::open_thread_safe(entity.key.path()) {
10654                    Ok(repo) => repo.to_thread_local(),
10655                    Err(_) => return None,
10656                },
10657            };
10658            let _ = git::head_shape(&repo);
10659            Some(task_started.elapsed())
10660        });
10661        let wall = started.elapsed();
10662        let durations: Vec<Duration> = rx.into_iter().flatten().collect();
10663        (wall, durations)
10664    }
10665
10666    /// Every root this machine actually has of the two the owner's real corpus
10667    /// lives under. Read from `$HOME` at run time rather than a literal in this
10668    /// file, so no personal path is ever recorded in committed source.
10669    fn real_corpus_roots() -> Vec<PathBuf> {
10670        let Some(home) = std::env::var_os("HOME") else {
10671            return Vec::new();
10672        };
10673        let home = PathBuf::from(home);
10674        ["dev", "dev-misc"]
10675            .into_iter()
10676            .map(|leaf| home.join(leaf))
10677            .filter(|root| root.is_dir())
10678            .collect()
10679    }
10680
10681    /// A `.git`-committed disposable repository per index, standing in for the
10682    /// real corpus when it is absent or too small to be meaningful. Each one gets
10683    /// a distinct commit so opening it is not a single cached filesystem page for
10684    /// every entity.
10685    fn generated_fixture_corpus(size: usize) -> tempfile::TempDir {
10686        let root = tempfile::tempdir().expect("temp dir for generated fixture corpus");
10687        for i in 0..size {
10688            let repo = root.path().join(format!("fixture-repo-{i}"));
10689            fs::create_dir_all(&repo).expect("create fixture repo dir");
10690            gix::init(&repo).expect("init fixture repo");
10691            let status = Command::new("git")
10692                .arg("-C")
10693                .arg(&repo)
10694                .args(["-c", "user.email=test@example.com", "-c", "user.name=Test"])
10695                .args(["commit", "--allow-empty", "-m", &format!("commit {i}")])
10696                .status()
10697                .expect("run git commit");
10698            assert!(status.success());
10699        }
10700        root
10701    }
10702
10703    /// Percentile `p` (0 to 100) of an already-sorted, non-empty slice.
10704    fn percentile(sorted: &[Duration], p: usize) -> Duration {
10705        let index = (sorted.len() - 1) * p / 100;
10706        sorted[index]
10707    }
10708
10709    /// Path-component names to keep out of the benchmark's population entirely,
10710    /// read from an environment variable rather than a literal in this file: a
10711    /// standing project rule keeps certain names out of committed source, so a
10712    /// real run supplies them at invocation time
10713    /// (`REPON_BENCHMARK_EXCLUDE_NAMES=name-one,name-two`) instead of this file
10714    /// ever spelling one out. Empty, and therefore excluding nothing, when unset.
10715    fn extra_excluded_names() -> Vec<String> {
10716        parse_excluded_names(&std::env::var("REPON_BENCHMARK_EXCLUDE_NAMES").unwrap_or_default())
10717    }
10718
10719    /// The comma-separated parsing `extra_excluded_names` applies to whatever the
10720    /// environment variable holds, split out so it can be proven against a literal
10721    /// string rather than by mutating process environment state a parallel test
10722    /// run could race on.
10723    fn parse_excluded_names(raw: &str) -> Vec<String> {
10724        raw.split(',')
10725            .map(str::trim)
10726            .filter(|name| !name.is_empty())
10727            .map(str::to_string)
10728            .collect()
10729    }
10730
10731    /// Discovers, resolves and excluded-name-filters one root list into a
10732    /// population, without opening anything `excluded_names` names at any depth.
10733    /// Returns the wall time of discovery and resolution alongside the
10734    /// population, since resolution is where every entity's repository is
10735    /// actually opened the first time ([`git::resolve_boundary`]); the identity
10736    /// phase timed afterwards only re-reads `HEAD` from the handle that step
10737    /// already cached.
10738    fn discover_population(
10739        roots: Vec<PathBuf>,
10740        excluded_names: &[String],
10741    ) -> (Vec<crate::discovery::DiscoveredEntity>, Duration) {
10742        let set = SetSpec {
10743            name: "identity-probe-benchmark".to_string(),
10744            roots,
10745            include: Vec::new(),
10746            exclude: Vec::new(),
10747        };
10748        let started = Instant::now();
10749        let discovery = discovery::discover(&set);
10750        let (discovered, _) = discovery::resolve(&set, &discovery.entities);
10751        let elapsed = started.elapsed();
10752        let population = discovered
10753            .into_iter()
10754            .filter(|entity| {
10755                !entity.key.path().components().any(|component| {
10756                    excluded_names
10757                        .iter()
10758                        .any(|name| component.as_os_str() == name.as_str())
10759                })
10760            })
10761            .collect();
10762        (population, elapsed)
10763    }
10764
10765    /// The exclusion mechanism proven against a fixture: a name present nowhere
10766    /// but this test's own excluded-names list still keeps a matching boundary
10767    /// out of the discovered population, and its two siblings still get through.
10768    #[test]
10769    fn a_boundary_whose_path_matches_an_excluded_name_is_left_out_of_the_population() {
10770        let fixture = generated_fixture_corpus(3);
10771        let excluded = vec!["fixture-repo-1".to_string()];
10772
10773        let (population, _) = discover_population(vec![fixture.path().to_path_buf()], &excluded);
10774
10775        assert_eq!(population.len(), 2);
10776        assert!(
10777            population
10778                .iter()
10779                .all(|entity| entity.key.path().file_name().unwrap() != "fixture-repo-1"),
10780            "the excluded name must never appear in the population discovery returns"
10781        );
10782    }
10783
10784    #[test]
10785    fn excluded_names_parses_a_comma_separated_list_and_ignores_blanks() {
10786        assert_eq!(
10787            parse_excluded_names("foo, bar ,,baz"),
10788            vec!["foo".to_string(), "bar".to_string(), "baz".to_string()]
10789        );
10790        assert!(parse_excluded_names("").is_empty());
10791        assert!(parse_excluded_names("   ").is_empty());
10792    }
10793
10794    /// Benchmarks the identity probe (phase A: open the repository, read `HEAD`)
10795    /// against the owner's real corpus under `$HOME/dev` and `$HOME/dev-misc`,
10796    /// falling back to a generated fixture when the real corpus is absent or too
10797    /// small to be meaningful (fewer than 20 entities). Never run by `just ci`:
10798    /// this is a hand-run measurement, per this project's convention of recording
10799    /// hand-run figures with the date, machine and toolchain rather than asserting
10800    /// a timing budget in a committed test. Run it with:
10801    /// `cargo test -p repon-core --release -- --ignored --nocapture identity_probe_benchmark`
10802    ///
10803    /// Read-only throughout: discovery only stats for a `.git` entry and phase A
10804    /// only reads `HEAD`. Any boundary whose path has a component named by
10805    /// `REPON_BENCHMARK_EXCLUDE_NAMES` is dropped before discovery's second half
10806    /// would ever open it, which is how a standing exclusion is honoured without
10807    /// this file naming what it excludes.
10808    #[test]
10809    #[ignore = "hand-run against the owner's real corpus; see docs/spec/refresh.md for the recorded figures"]
10810    fn identity_probe_benchmark() {
10811        let excluded_names = extra_excluded_names();
10812
10813        // `_fixture` is held for the rest of the test whenever a fixture is used,
10814        // so its directories still exist when the identity phase opens them; it is
10815        // simply never populated on the real-corpus path.
10816        let mut _fixture: Option<tempfile::TempDir> = None;
10817
10818        let (real_population, real_discovery_wall) =
10819            discover_population(real_corpus_roots(), &excluded_names);
10820        let (population, using_fixture, discovery_wall) = if real_population.len() >= 20 {
10821            (real_population, false, real_discovery_wall)
10822        } else {
10823            println!(
10824                "real corpus absent or too small to be meaningful ({} entities); \
10825                 using a generated fixture instead",
10826                real_population.len()
10827            );
10828            let fixture = generated_fixture_corpus(300);
10829            let (population, fixture_discovery_wall) =
10830                discover_population(vec![fixture.path().to_path_buf()], &excluded_names);
10831            _fixture = Some(fixture);
10832            (population, true, fixture_discovery_wall)
10833        };
10834
10835        let population_size = population.len();
10836        assert!(
10837            population_size > 0,
10838            "neither a real corpus root nor the generated fixture produced any entities"
10839        );
10840
10841        let (wall, mut durations) = benchmark_identity_phase(population);
10842        durations.sort();
10843
10844        println!(
10845            "identity probe benchmark: corpus = {}, population = {population_size}",
10846            if using_fixture {
10847                "generated fixture"
10848            } else {
10849                "real corpus"
10850            }
10851        );
10852        println!(
10853            "discovery + first open (serial, every entity's own gix::open): {discovery_wall:?}"
10854        );
10855        println!("identity phase, warm, parallel (HEAD re-read from the cached handle): {wall:?}");
10856        println!(
10857            "identity phase per entity: p50 {:?}, p90 {:?}, max {:?}",
10858            percentile(&durations, 50),
10859            percentile(&durations, 90),
10860            durations.last().copied().unwrap_or_default(),
10861        );
10862    }
10863
10864    fn spec_with_overrides(roots: Vec<PathBuf>, overrides: Vec<RepoOverride>) -> CoreSpec {
10865        let mut spec = spec(roots);
10866        spec.overrides = overrides;
10867        spec
10868    }
10869
10870    /// The seam this proves: an explicit per-Repo override reaches all the way
10871    /// through `Core::refresh` and `settle` into the `default_branch` cell as
10872    /// rung 1, recorded in diagnostics, even though `origin/HEAD` and the name
10873    /// list would both answer differently if asked.
10874    #[test]
10875    fn a_per_repo_override_resolves_the_default_branch_at_rung_one_through_a_real_refresh() {
10876        let dir = tempfile::tempdir().expect("temp dir");
10877        let root = root_of(&dir);
10878        let repo = root.join("repo");
10879        init_repo_with_a_commit(&repo);
10880        git(
10881            &repo,
10882            &[
10883                "remote",
10884                "add",
10885                "origin",
10886                "https://example.invalid/repo.git",
10887            ],
10888        );
10889        let sha = head_sha(&repo);
10890        git(&repo, &["update-ref", "refs/remotes/origin/main", &sha]);
10891        let remote_refs_dir = repo
10892            .join(".git")
10893            .join("refs")
10894            .join("remotes")
10895            .join("origin");
10896        fs::create_dir_all(&remote_refs_dir).expect("create refs/remotes/origin dir");
10897        fs::write(
10898            remote_refs_dir.join("HEAD"),
10899            "ref: refs/remotes/origin/main\n",
10900        )
10901        .expect("write HEAD");
10902
10903        let core = Core::start_discovered(spec_with_overrides(
10904            vec![root],
10905            vec![RepoOverride {
10906                path: repo.clone(),
10907                default_branch: Some("develop".to_string()),
10908                excluded: false,
10909            }],
10910        ));
10911        let key = core.snapshot().entities[0].key.clone();
10912
10913        core.refresh(std::slice::from_ref(&key));
10914        let settled = core.settle();
10915        let entity = &settled.entities[0];
10916
10917        match entity.default_branch.settled() {
10918            Some(Settled::Known {
10919                value,
10920                at: _,
10921                stale: _,
10922            }) => assert_eq!(
10923                value.name(),
10924                "origin/develop",
10925                "the override must win even though origin/HEAD names a different branch"
10926            ),
10927            other => panic!("expected the override's own answer, got {other:?}"),
10928        }
10929        assert_eq!(
10930            entity.diagnostics.default_branch_rung,
10931            Some(1),
10932            "an override must be recorded as rung 1"
10933        );
10934    }
10935
10936    /// `probe_now`'s synchronous path carries the same override wiring as
10937    /// `refresh`, proven directly since a Launcher return uses it without ever
10938    /// calling `refresh` first.
10939    #[test]
10940    fn a_per_repo_override_also_resolves_through_probe_now() {
10941        let dir = tempfile::tempdir().expect("temp dir");
10942        let root = root_of(&dir);
10943        let repo = root.join("repo");
10944        init_repo_with_a_commit(&repo);
10945
10946        let core = Core::start_discovered(spec_with_overrides(
10947            vec![root],
10948            vec![RepoOverride {
10949                path: repo.clone(),
10950                default_branch: Some("release".to_string()),
10951                excluded: false,
10952            }],
10953        ));
10954        let key = core.snapshot().entities[0].key.clone();
10955
10956        let entity = core.probe_now(&key);
10957
10958        match entity.default_branch.settled() {
10959            // No remote at all: the override still answers, using the bare name.
10960            Some(Settled::Known {
10961                value,
10962                at: _,
10963                stale: _,
10964            }) => assert_eq!(value.name(), "release"),
10965            other => panic!("expected the override's own answer, got {other:?}"),
10966        }
10967        assert_eq!(entity.diagnostics.default_branch_rung, Some(1));
10968    }
10969
10970    /// The three named ways rung 4 is reached are recorded distinctly, not merged
10971    /// into one opaque "gave up" fact: no remote at all, two or more remotes with
10972    /// none named `origin`, and a chosen remote whose tracking refs matched
10973    /// nothing in the name list.
10974    #[test]
10975    fn reaching_rung_four_with_no_remote_at_all_records_why() {
10976        let dir = tempfile::tempdir().expect("temp dir");
10977        let root = root_of(&dir);
10978        let repo = root.join("repo");
10979        init_repo_with_a_commit(&repo);
10980
10981        let core = Core::start_discovered(spec(vec![root]));
10982        let key = core.snapshot().entities[0].key.clone();
10983
10984        core.refresh(std::slice::from_ref(&key));
10985        let settled = core.settle();
10986        let entity = &settled.entities[0];
10987
10988        assert_eq!(entity.diagnostics.default_branch_rung, Some(4));
10989        assert_eq!(
10990            entity.diagnostics.default_branch_stopped,
10991            Some(DefaultBranchStopped::NoRemote)
10992        );
10993    }
10994
10995    #[test]
10996    fn reaching_rung_four_with_two_unnamed_remotes_records_why() {
10997        let dir = tempfile::tempdir().expect("temp dir");
10998        let root = root_of(&dir);
10999        let repo = root.join("repo");
11000        init_repo_with_a_commit(&repo);
11001        git(
11002            &repo,
11003            &[
11004                "remote",
11005                "add",
11006                "fork-one",
11007                "https://example.invalid/one.git",
11008            ],
11009        );
11010        git(
11011            &repo,
11012            &[
11013                "remote",
11014                "add",
11015                "fork-two",
11016                "https://example.invalid/two.git",
11017            ],
11018        );
11019
11020        let core = Core::start_discovered(spec(vec![root]));
11021        let key = core.snapshot().entities[0].key.clone();
11022
11023        core.refresh(std::slice::from_ref(&key));
11024        let settled = core.settle();
11025        let entity = &settled.entities[0];
11026
11027        assert_eq!(entity.diagnostics.default_branch_rung, Some(4));
11028        assert_eq!(
11029            entity.diagnostics.default_branch_stopped,
11030            Some(DefaultBranchStopped::AmbiguousRemote)
11031        );
11032    }
11033
11034    #[test]
11035    fn reaching_rung_four_with_a_chosen_remote_and_no_matching_ref_records_why() {
11036        let dir = tempfile::tempdir().expect("temp dir");
11037        let root = root_of(&dir);
11038        let repo = root.join("repo");
11039        init_repo_with_a_commit(&repo);
11040        git(
11041            &repo,
11042            &[
11043                "remote",
11044                "add",
11045                "origin",
11046                "https://example.invalid/repo.git",
11047            ],
11048        );
11049        // A remote-tracking ref exists, but under a name outside rung 3's list, and
11050        // there is no origin/HEAD at all.
11051        let sha = head_sha(&repo);
11052        git(&repo, &["update-ref", "refs/remotes/origin/feature", &sha]);
11053
11054        let core = Core::start_discovered(spec(vec![root]));
11055        let key = core.snapshot().entities[0].key.clone();
11056
11057        core.refresh(std::slice::from_ref(&key));
11058        let settled = core.settle();
11059        let entity = &settled.entities[0];
11060
11061        assert_eq!(entity.diagnostics.default_branch_rung, Some(4));
11062        assert_eq!(
11063            entity.diagnostics.default_branch_stopped,
11064            Some(DefaultBranchStopped::NameListExhausted)
11065        );
11066    }
11067
11068    /// A Repo with no override and no resolvable remote reaches rung 4: Unknown,
11069    /// never Failed, which stays reserved for a git error.
11070    #[test]
11071    fn a_repo_with_nothing_to_resolve_settles_unknown_never_failed() {
11072        let dir = tempfile::tempdir().expect("temp dir");
11073        let root = root_of(&dir);
11074        let repo = root.join("repo");
11075        init_repo_with_a_commit(&repo);
11076
11077        let core = Core::start_discovered(spec(vec![root]));
11078        let key = core.snapshot().entities[0].key.clone();
11079
11080        core.refresh(std::slice::from_ref(&key));
11081        let settled = core.settle();
11082        let entity = &settled.entities[0];
11083
11084        assert!(matches!(
11085            entity.default_branch.settled(),
11086            Some(Settled::Unknown(Unknown::NoDefaultBranch))
11087        ));
11088        assert_eq!(entity.diagnostics.default_branch_rung, Some(4));
11089    }
11090
11091    /// The seam this proves: a stale symbolic `origin/HEAD` reaches all the way
11092    /// through `Core::refresh` and `settle` into `Diagnostics`, not just the
11093    /// fallen-through rung 3 answer, since the spec requires recording that the
11094    /// stale case is what happened rather than leaving the same trail a merely
11095    /// absent `origin/HEAD` would.
11096    #[test]
11097    fn a_stale_remote_head_is_recorded_in_diagnostics_through_a_real_refresh() {
11098        let dir = tempfile::tempdir().expect("temp dir");
11099        let root = root_of(&dir);
11100        let repo = root.join("repo");
11101        init_repo_with_a_commit(&repo);
11102        git(
11103            &repo,
11104            &[
11105                "remote",
11106                "add",
11107                "origin",
11108                "https://example.invalid/repo.git",
11109            ],
11110        );
11111        let sha = head_sha(&repo);
11112        git(&repo, &["update-ref", "refs/remotes/origin/trunk", &sha]);
11113        let remote_refs_dir = repo
11114            .join(".git")
11115            .join("refs")
11116            .join("remotes")
11117            .join("origin");
11118        fs::create_dir_all(&remote_refs_dir).expect("create refs/remotes/origin dir");
11119        // Points at a name never created as a ref: the stale case, not merely absent.
11120        fs::write(
11121            remote_refs_dir.join("HEAD"),
11122            "ref: refs/remotes/origin/main\n",
11123        )
11124        .expect("write HEAD");
11125
11126        let core = Core::start_discovered(spec(vec![root]));
11127        let key = core.snapshot().entities[0].key.clone();
11128
11129        core.refresh(std::slice::from_ref(&key));
11130        let settled = core.settle();
11131        let entity = &settled.entities[0];
11132
11133        match entity.default_branch.settled() {
11134            Some(Settled::Known {
11135                value,
11136                at: _,
11137                stale: _,
11138            }) => {
11139                assert_eq!(value.name(), "origin/trunk")
11140            }
11141            other => panic!("expected the name list's answer, got {other:?}"),
11142        }
11143        assert!(
11144            entity.diagnostics.default_branch_rung_two_stale,
11145            "a stale origin/HEAD target must be recorded on the entity's diagnostics"
11146        );
11147    }
11148
11149    /// A resolvable `origin/HEAD` must never be marked stale, so the flag actually
11150    /// distinguishes the two cases rather than always being set once rung 2 runs.
11151    #[test]
11152    fn a_resolvable_remote_head_is_not_recorded_as_stale() {
11153        let dir = tempfile::tempdir().expect("temp dir");
11154        let root = root_of(&dir);
11155        let repo = root.join("repo");
11156        init_repo_with_a_commit(&repo);
11157        git(
11158            &repo,
11159            &[
11160                "remote",
11161                "add",
11162                "origin",
11163                "https://example.invalid/repo.git",
11164            ],
11165        );
11166        let sha = head_sha(&repo);
11167        git(&repo, &["update-ref", "refs/remotes/origin/main", &sha]);
11168        let remote_refs_dir = repo
11169            .join(".git")
11170            .join("refs")
11171            .join("remotes")
11172            .join("origin");
11173        fs::create_dir_all(&remote_refs_dir).expect("create refs/remotes/origin dir");
11174        fs::write(
11175            remote_refs_dir.join("HEAD"),
11176            "ref: refs/remotes/origin/main\n",
11177        )
11178        .expect("write HEAD");
11179
11180        let core = Core::start_discovered(spec(vec![root]));
11181        let key = core.snapshot().entities[0].key.clone();
11182
11183        core.refresh(std::slice::from_ref(&key));
11184        let settled = core.settle();
11185        let entity = &settled.entities[0];
11186
11187        assert!(!entity.diagnostics.default_branch_rung_two_stale);
11188    }
11189
11190    /// The defining behaviour for per-Repo matching: one `[[repo]]` entry naming
11191    /// only the parent Repo's own path still applies to a linked Worktree sharing
11192    /// its common dir, proven against a real `git worktree add` rather than a
11193    /// hand-built stand-in for the on-disk relationship.
11194    #[test]
11195    fn one_override_on_a_repos_path_covers_a_worktree_sharing_its_common_dir() {
11196        let dir = tempfile::tempdir().expect("temp dir");
11197        let root = root_of(&dir);
11198        let parent = root.join("parent");
11199        init_repo_with_a_commit(&parent);
11200        let worktree = root.join("worktree");
11201        git(
11202            &parent,
11203            &[
11204                "worktree",
11205                "add",
11206                "-b",
11207                "feature",
11208                worktree.to_str().expect("utf8 path"),
11209            ],
11210        );
11211
11212        let core = Core::start_discovered(spec_with_overrides(
11213            vec![root],
11214            vec![RepoOverride {
11215                path: parent.clone(),
11216                default_branch: None,
11217                excluded: true,
11218            }],
11219        ));
11220        let snapshot = core.snapshot();
11221
11222        for entity in &snapshot.entities {
11223            assert!(
11224                entity.excluded,
11225                "both the Repo and its Worktree must inherit the entry declared on the Repo's own path, entity: {:?}",
11226                entity.key
11227            );
11228        }
11229        assert_eq!(
11230            snapshot.entities.len(),
11231            2,
11232            "expected the parent plus its worktree"
11233        );
11234    }
11235
11236    /// The other direction: an entry naming a Worktree's own path beats the entry
11237    /// it would otherwise inherit from the Repo it shares a common dir with, while
11238    /// a second Worktree with no entry of its own still inherits the Repo's entry.
11239    #[test]
11240    fn an_entry_naming_a_worktrees_own_path_beats_the_inherited_one() {
11241        let dir = tempfile::tempdir().expect("temp dir");
11242        let root = root_of(&dir);
11243        let parent = root.join("parent");
11244        init_repo_with_a_commit(&parent);
11245        let worktree_own = root.join("worktree-own");
11246        let worktree_inherits = root.join("worktree-inherits");
11247        git(
11248            &parent,
11249            &[
11250                "worktree",
11251                "add",
11252                "-b",
11253                "feature-own",
11254                worktree_own.to_str().expect("utf8 path"),
11255            ],
11256        );
11257        git(
11258            &parent,
11259            &[
11260                "worktree",
11261                "add",
11262                "-b",
11263                "feature-inherits",
11264                worktree_inherits.to_str().expect("utf8 path"),
11265            ],
11266        );
11267
11268        let core = Core::start_discovered(spec_with_overrides(
11269            vec![root],
11270            vec![
11271                RepoOverride {
11272                    path: parent.clone(),
11273                    default_branch: None,
11274                    excluded: true,
11275                },
11276                RepoOverride {
11277                    path: worktree_own.clone(),
11278                    default_branch: None,
11279                    excluded: false,
11280                },
11281            ],
11282        ));
11283        let snapshot = core.snapshot();
11284
11285        let find = |path: &Path| {
11286            snapshot
11287                .entities
11288                .iter()
11289                .find(|entity| entity.key.path() == path)
11290                .unwrap_or_else(|| panic!("entity at {path:?} present"))
11291        };
11292
11293        assert!(
11294            find(&parent).excluded,
11295            "the parent Repo has no entry of its own and inherits the excluding one"
11296        );
11297        assert!(
11298            !find(&worktree_own).excluded,
11299            "the Worktree named directly by its own path must use its own entry, not the inherited one"
11300        );
11301        assert!(
11302            find(&worktree_inherits).excluded,
11303            "a sibling Worktree with no entry of its own still inherits the Repo's entry"
11304        );
11305    }
11306
11307    /// A Submodule's own common dir differs from its parent's
11308    /// (`<parent common dir>/modules/<name>`), so an entry naming only the
11309    /// parent's path can never also exclude the parent's Submodule: the entry
11310    /// covers the parent and its Worktrees, never a Submodule reached through it.
11311    #[test]
11312    fn an_override_on_the_parents_path_never_excludes_its_submodule() {
11313        let dir = tempfile::tempdir().expect("temp dir");
11314        let root = root_of(&dir);
11315        let parent = root.join("parent");
11316        init_repo_with_a_commit(&parent);
11317        fs::write(
11318            parent.join(".gitmodules"),
11319            "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
11320        )
11321        .expect("write .gitmodules");
11322        fs::create_dir_all(parent.join("vendor").join("lib")).expect("create submodule dir");
11323
11324        let core = Core::start_discovered(spec_with_overrides(
11325            vec![root],
11326            vec![RepoOverride {
11327                path: parent.clone(),
11328                default_branch: None,
11329                excluded: true,
11330            }],
11331        ));
11332        let snapshot = core.snapshot();
11333
11334        let submodule = snapshot
11335            .entities
11336            .iter()
11337            .find(|entity| matches!(entity.kind, Kind::Submodule))
11338            .expect("the submodule is still discovered and listed");
11339        assert!(
11340            !submodule.excluded,
11341            "an entry naming only the parent's path must never reach a Submodule, \
11342             whose own common dir differs from its parent's"
11343        );
11344    }
11345
11346    /// The seam this proves: `Core::default_branch_chain_reads_for_test` counts
11347    /// how many times a `refresh` actually computed the default-branch chain's
11348    /// per-common-dir facts (`default_branch::ChainFacts::resolve`, the loose-file
11349    /// read plus the reference lookups), rather than reusing an already-computed
11350    /// answer for a common dir another entity in the same Generation already paid
11351    /// for. Reading the count off `Core` this way is the seam, not an internal:
11352    /// it is a named, stable test-only entry point in the same
11353    /// `#[cfg(test)] impl Core` family as `cached_repo_handle_for_test`, which
11354    /// already proves a different sharing question the same way. There is no
11355    /// black-box way to observe "how many times an internal read ran" through
11356    /// `Snapshot` alone, since two different common dirs can legitimately answer
11357    /// with the same branch name.
11358    ///
11359    /// Three Worktrees share one common dir with their Repo (four entities); a
11360    /// second, unrelated Repo has its own. Memoised, the count is 2, the number of
11361    /// distinct common dirs; unmemoised, it is 4, the number of entities.
11362    #[test]
11363    fn the_default_branch_chain_is_memoised_once_per_common_dir_per_generation() {
11364        let dir = tempfile::tempdir().expect("temp dir");
11365        let root = root_of(&dir);
11366        let parent = root.join("parent");
11367        init_repo_with_a_commit(&parent);
11368        for name in ["wt-a", "wt-b", "wt-c"] {
11369            let worktree = root.join(name);
11370            git(
11371                &parent,
11372                &[
11373                    "worktree",
11374                    "add",
11375                    "-b",
11376                    name,
11377                    worktree.to_str().expect("utf8 path"),
11378                ],
11379            );
11380        }
11381        let other_repo = root.join("other");
11382        init_repo_with_a_commit(&other_repo);
11383
11384        let (core, launched) = started_and_settled(spec(vec![root]));
11385        let keys: Vec<EntityKey> = launched
11386            .entities
11387            .iter()
11388            .map(|entity| entity.key.clone())
11389            .collect();
11390        assert_eq!(
11391            keys.len(),
11392            5,
11393            "expected the parent, its three worktrees and the unrelated repo"
11394        );
11395
11396        core.refresh(&keys);
11397        core.settle();
11398
11399        assert_eq!(
11400            core.default_branch_chain_reads_for_test(),
11401            2,
11402            "four entities span exactly two common dirs; a memoised chain reads \
11403             each common dir once, not once per entity"
11404        );
11405
11406        // A second Generation pays the same two reads again. A cache hoisted onto
11407        // `Core` would answer this refresh for free and read 0, which is the
11408        // persistence ADR 0006 refuses.
11409        core.refresh(&keys);
11410        core.settle();
11411        assert_eq!(
11412            core.default_branch_chain_reads_for_test(),
11413            2,
11414            "the memo lives inside one Generation's dispatch; the next Generation \
11415             recomputes rather than inheriting it"
11416        );
11417    }
11418
11419    /// The same proof as `the_default_branch_chain_is_memoised_once_per_common_dir_per_generation`,
11420    /// for patch equivalence's own expensive half: two sibling Worktrees, each
11421    /// with a live upstream and unmerged work of its own, share one common dir
11422    /// and must scan its default-branch history once between them, not twice;
11423    /// an unrelated Repo's own Worktree, in its own common dir, pays for a
11424    /// second scan. Both entities settling (`Active`, since neither's work
11425    /// actually landed) is what proves the second pass ran for both rather than
11426    /// one being cancelled or skipped, which would otherwise let a
11427    /// once-per-entity implementation coincidentally also read 2.
11428    #[test]
11429    fn patch_equivalence_is_memoised_once_per_common_dir_per_generation() {
11430        let dir = tempfile::tempdir().expect("temp dir");
11431        let root = root_of(&dir);
11432        let parent = root.join("parent");
11433        init_repo_with_a_commit(&parent);
11434        git(
11435            &parent,
11436            &[
11437                "remote",
11438                "add",
11439                "origin",
11440                "https://example.invalid/repo.git",
11441            ],
11442        );
11443        let base_sha = head_sha(&parent);
11444        git(
11445            &parent,
11446            &["update-ref", "refs/remotes/origin/main", &base_sha],
11447        );
11448        for name in ["feature-x", "feature-y"] {
11449            let worktree = root.join(name);
11450            git(
11451                &parent,
11452                &[
11453                    "worktree",
11454                    "add",
11455                    "-b",
11456                    name,
11457                    worktree.to_str().expect("utf8 path"),
11458                ],
11459            );
11460            fs::write(worktree.join(format!("{name}.txt")), "unmerged\n")
11461                .expect("write worktree file");
11462            git(&worktree, &["add", "."]);
11463            git(&worktree, &["commit", "-m", "unmerged work"]);
11464            let tip_sha = head_sha(&worktree);
11465            git(
11466                &parent,
11467                &["config", &format!("branch.{name}.remote"), "origin"],
11468            );
11469            git(
11470                &parent,
11471                &[
11472                    "config",
11473                    &format!("branch.{name}.merge"),
11474                    &format!("refs/heads/{name}"),
11475                ],
11476            );
11477            git(
11478                &parent,
11479                &[
11480                    "update-ref",
11481                    &format!("refs/remotes/origin/{name}"),
11482                    &tip_sha,
11483                ],
11484            );
11485        }
11486
11487        let other_parent = root.join("other");
11488        init_repo_with_a_commit(&other_parent);
11489        git(
11490            &other_parent,
11491            &[
11492                "remote",
11493                "add",
11494                "origin",
11495                "https://example.invalid/other.git",
11496            ],
11497        );
11498        let other_base_sha = head_sha(&other_parent);
11499        git(
11500            &other_parent,
11501            &["update-ref", "refs/remotes/origin/main", &other_base_sha],
11502        );
11503        let other_worktree = root.join("other-feature");
11504        git(
11505            &other_parent,
11506            &[
11507                "worktree",
11508                "add",
11509                "-b",
11510                "other-feature",
11511                other_worktree.to_str().expect("utf8 path"),
11512            ],
11513        );
11514        fs::write(other_worktree.join("other.txt"), "unmerged\n").expect("write worktree file");
11515        git(&other_worktree, &["add", "."]);
11516        git(&other_worktree, &["commit", "-m", "unmerged work"]);
11517        let other_tip_sha = head_sha(&other_worktree);
11518        git(
11519            &other_parent,
11520            &["config", "branch.other-feature.remote", "origin"],
11521        );
11522        git(
11523            &other_parent,
11524            &[
11525                "config",
11526                "branch.other-feature.merge",
11527                "refs/heads/other-feature",
11528            ],
11529        );
11530        git(
11531            &other_parent,
11532            &[
11533                "update-ref",
11534                "refs/remotes/origin/other-feature",
11535                &other_tip_sha,
11536            ],
11537        );
11538
11539        let (core, launched) = started_and_settled(spec(vec![root]));
11540        let keys: Vec<EntityKey> = launched
11541            .entities
11542            .iter()
11543            .map(|entity| entity.key.clone())
11544            .collect();
11545        assert_eq!(
11546            keys.len(),
11547            5,
11548            "expected two parents plus their three worktrees"
11549        );
11550
11551        core.refresh(&keys);
11552        let settled = core.settle();
11553
11554        let worktree_states: Vec<_> = settled
11555            .entities
11556            .iter()
11557            .filter(|entity| matches!(entity.kind, Kind::Worktree))
11558            .map(|entity| entity.state.settled())
11559            .collect();
11560        assert_eq!(worktree_states.len(), 3, "expected three worktree rows");
11561        for settled_state in &worktree_states {
11562            assert!(
11563                matches!(
11564                    settled_state,
11565                    Some(Settled::Known {
11566                        value: WorktreeState::Active,
11567                        at: _,
11568                        stale: _
11569                    })
11570                ),
11571                "expected every worktree's genuinely unmerged work to settle Active, got {settled_state:?}"
11572            );
11573        }
11574
11575        assert_eq!(
11576            core.patch_identity_reads_for_test(),
11577            2,
11578            "two worktrees share one common dir and must scan its default-branch \
11579             history once between them, not once per entity; the unrelated repo's \
11580             own worktree pays for a second scan"
11581        );
11582
11583        // A second Generation pays for the same two scans again: a cache hoisted
11584        // onto `Core` would answer this refresh for free and read 0.
11585        core.refresh(&keys);
11586        core.settle();
11587        assert_eq!(
11588            core.patch_identity_reads_for_test(),
11589            2,
11590            "the memo lives inside one Generation's dispatch; the next Generation \
11591             recomputes rather than inheriting it"
11592        );
11593    }
11594
11595    /// Criterion 3's widen direction, end to end: `feature-deep` forks at the
11596    /// parent commit `deep_fork_sha` and is squashed into main immediately
11597    /// afterwards; `feature-shallow` forks at that squash commit (strictly more
11598    /// recent, so its own merge base is shallower) and is squashed in turn to
11599    /// produce `main`'s tip. The deepest merge base among the two siblings is
11600    /// `feature-deep`'s own, `deep_fork_sha`, not `feature-shallow`'s.
11601    ///
11602    /// A scan bounded by the *shallowest* sibling's merge base instead of the
11603    /// deepest would stop before reaching the commit that squashed
11604    /// `feature-deep` in, since that commit sits strictly between the two
11605    /// bounds: `feature-deep` would then settle `Active` instead of `Merged`.
11606    /// This is a smoke test for that outcome through the real dispatch
11607    /// pipeline, not a proof: rayon's work stealing gives dispatch `order` no
11608    /// ordering guarantee, so `feature-deep` landing last here is a nudge
11609    /// towards, never proof of, exercising a lazy first-arrival bound.
11610    /// `bound_gate_deepest_folds_every_candidate_regardless_of_report_order`
11611    /// below is what deterministically proves the bound is collected from
11612    /// every sibling rather than computed lazily from whichever arrives first.
11613    #[test]
11614    fn an_entity_whose_merge_base_is_deeper_than_its_siblings_widens_the_shared_scan() {
11615        let dir = tempfile::tempdir().expect("temp dir");
11616        let root = root_of(&dir);
11617        let parent = root.join("parent");
11618        init_repo_with_a_commit(&parent);
11619        git(
11620            &parent,
11621            &[
11622                "remote",
11623                "add",
11624                "origin",
11625                "https://example.invalid/repo.git",
11626            ],
11627        );
11628        let deep_fork_sha = head_sha(&parent);
11629
11630        git(&parent, &["branch", "feature-deep"]);
11631        let deep_worktree = root.join("feature-deep");
11632        git(
11633            &parent,
11634            &[
11635                "worktree",
11636                "add",
11637                deep_worktree.to_str().expect("utf8 path"),
11638                "feature-deep",
11639            ],
11640        );
11641        fs::write(deep_worktree.join("deep.txt"), "deep work\n").expect("write deep.txt");
11642        git(&deep_worktree, &["add", "."]);
11643        git(&deep_worktree, &["commit", "-m", "deep work"]);
11644        let deep_tip_sha = head_sha(&deep_worktree);
11645
11646        git(&parent, &["merge", "--squash", "feature-deep"]);
11647        git(&parent, &["commit", "-m", "squashed deep"]);
11648        let shallow_fork_sha = head_sha(&parent);
11649
11650        git(&parent, &["branch", "feature-shallow"]);
11651        let shallow_worktree = root.join("feature-shallow");
11652        git(
11653            &parent,
11654            &[
11655                "worktree",
11656                "add",
11657                shallow_worktree.to_str().expect("utf8 path"),
11658                "feature-shallow",
11659            ],
11660        );
11661        fs::write(shallow_worktree.join("shallow.txt"), "shallow work\n")
11662            .expect("write shallow.txt");
11663        git(&shallow_worktree, &["add", "."]);
11664        git(&shallow_worktree, &["commit", "-m", "shallow work"]);
11665        let shallow_tip_sha = head_sha(&shallow_worktree);
11666
11667        git(&parent, &["merge", "--squash", "feature-shallow"]);
11668        git(&parent, &["commit", "-m", "squashed shallow"]);
11669        let main_tip_sha = head_sha(&parent);
11670        assert_ne!(
11671            deep_fork_sha, shallow_fork_sha,
11672            "the two siblings must fork at genuinely different commits"
11673        );
11674
11675        git(
11676            &parent,
11677            &["update-ref", "refs/remotes/origin/main", &main_tip_sha],
11678        );
11679        for (name, tip_sha) in [
11680            ("feature-deep", &deep_tip_sha),
11681            ("feature-shallow", &shallow_tip_sha),
11682        ] {
11683            git(
11684                &parent,
11685                &["config", &format!("branch.{name}.remote"), "origin"],
11686            );
11687            git(
11688                &parent,
11689                &[
11690                    "config",
11691                    &format!("branch.{name}.merge"),
11692                    &format!("refs/heads/{name}"),
11693                ],
11694            );
11695            git(
11696                &parent,
11697                &[
11698                    "update-ref",
11699                    &format!("refs/remotes/origin/{name}"),
11700                    tip_sha,
11701                ],
11702            );
11703        }
11704
11705        let (core, snapshot) = started_and_settled(spec(vec![root]));
11706        let deep_key = snapshot
11707            .entities
11708            .iter()
11709            .find(|entity| entity.key.path() == deep_worktree)
11710            .expect("feature-deep worktree discovered")
11711            .key
11712            .clone();
11713        let shallow_key = snapshot
11714            .entities
11715            .iter()
11716            .find(|entity| entity.key.path() == shallow_worktree)
11717            .expect("feature-shallow worktree discovered")
11718            .key
11719            .clone();
11720        let parent_key = snapshot
11721            .entities
11722            .iter()
11723            .find(|entity| entity.key.path() == parent)
11724            .expect("parent repo discovered")
11725            .key
11726            .clone();
11727        // The deepest sibling dispatched last, so a lazy bound computed from
11728        // whichever entity arrives first would reach for the shallow sibling's
11729        // own narrower merge base instead.
11730        let order = vec![parent_key, shallow_key.clone(), deep_key.clone()];
11731
11732        core.refresh(&order);
11733        let settled = core.settle();
11734
11735        let state_of = |key: &EntityKey| {
11736            settled
11737                .entities
11738                .iter()
11739                .find(|entity| &entity.key == key)
11740                .and_then(|entity| entity.state.settled())
11741                .cloned()
11742        };
11743        assert!(
11744            matches!(
11745                state_of(&deep_key),
11746                Some(Settled::Known {
11747                    value: WorktreeState::Merged,
11748                    at: _,
11749                    stale: _
11750                })
11751            ),
11752            "expected the deepest sibling's own squash commit to be found once the scan is \
11753             bounded by the deepest merge base, got {:?}",
11754            state_of(&deep_key)
11755        );
11756        assert!(
11757            matches!(
11758                state_of(&shallow_key),
11759                Some(Settled::Known {
11760                    value: WorktreeState::Merged,
11761                    at: _,
11762                    stale: _
11763                })
11764            ),
11765            "expected the shallow sibling to settle Merged too, got {:?}",
11766            state_of(&shallow_key)
11767        );
11768        assert_eq!(
11769            core.patch_identity_reads_for_test(),
11770            1,
11771            "both worktrees share one common dir and must still scan its default-branch \
11772             history once between them, not once per entity"
11773        );
11774        assert_eq!(
11775            core.patch_scan_bounds_for_test(),
11776            vec![Some(id(&deep_fork_sha))],
11777            "the one shared scan that ran must have been bounded by the deepest sibling's own \
11778             merge base, not the shallower one's"
11779        );
11780    }
11781
11782    fn id(sha: &str) -> gix::ObjectId {
11783        gix::ObjectId::from_hex(sha.as_bytes()).expect("parse sha")
11784    }
11785
11786    /// Criterion 1, proved at the barrier itself rather than through rayon's
11787    /// unordered dispatch: `shallow` is reported before `deep` on purpose, so a
11788    /// lazy first-arrival implementation (answer with whichever candidate
11789    /// showed up first, rather than collecting every sibling's own merge base)
11790    /// would settle on `shallow` and fail this assertion. `deep` is an ancestor
11791    /// of `shallow`, so the correct fold finds it regardless of report order.
11792    #[test]
11793    fn bound_gate_deepest_folds_every_candidate_regardless_of_report_order() {
11794        let dir = tempfile::tempdir().expect("temp dir");
11795        let repo_path = root_of(&dir).join("repo");
11796        init_repo_with_a_commit(&repo_path);
11797        let deep_sha = id(&head_sha(&repo_path));
11798        fs::write(repo_path.join("child.txt"), "child\n").expect("write child.txt");
11799        git(&repo_path, &["add", "."]);
11800        git(&repo_path, &["commit", "-m", "child of deep"]);
11801        let shallow_sha = id(&head_sha(&repo_path));
11802
11803        let repo = gix::open(&repo_path).expect("open repo");
11804        let gate = BoundGate::new(2);
11805        gate.report(Some(shallow_sha));
11806        gate.report(Some(deep_sha));
11807
11808        assert_eq!(
11809            gate.deepest(&repo),
11810            Some(deep_sha),
11811            "the deepest candidate must win even though the shallower one reported first"
11812        );
11813    }
11814
11815    /// Deterministic proof that [`probe_patch_equivalence`] itself consults
11816    /// [`BoundGate::deepest`] for the bound it hands to
11817    /// [`patch_equivalence::scan_default_branch`], rather than reaching for its
11818    /// own entity's merge base. Unlike
11819    /// `bound_gate_deepest_folds_every_candidate_regardless_of_report_order`,
11820    /// which proves `BoundGate` and `deepest_merge_base` correct in isolation,
11821    /// this drives `probe_patch_equivalence` itself and inspects what it
11822    /// actually recorded into `memo.scan_bounds`. `deep_sha`'s contribution is
11823    /// pre-reported by hand, standing in for a sibling entity that already ran
11824    /// this Generation; the one entity this test drives through the real
11825    /// function arrives at `shallow_sha`, so its own merge base against
11826    /// `default_tip` is `shallow_sha`, strictly shallower than `deep_sha`. A
11827    /// regression that bounds the scan by the arriving entity's own merge base
11828    /// instead of the gate's answer would record `shallow_sha` here, and would
11829    /// do so every single run: unlike the integration smoke test below, there
11830    /// is no rayon dispatch order here to sometimes get it right by accident.
11831    #[test]
11832    fn probe_patch_equivalence_bounds_the_scan_by_the_gates_deepest_not_its_own_merge_base() {
11833        let dir = tempfile::tempdir().expect("temp dir");
11834        let repo_path = root_of(&dir).join("repo");
11835        init_repo_with_a_commit(&repo_path);
11836        let deep_sha = id(&head_sha(&repo_path));
11837        fs::write(repo_path.join("child.txt"), "child\n").expect("write child.txt");
11838        git(&repo_path, &["add", "."]);
11839        git(&repo_path, &["commit", "-m", "child of deep"]);
11840        let shallow_sha_hex = head_sha(&repo_path);
11841        let shallow_sha = id(&shallow_sha_hex);
11842        fs::write(repo_path.join("tip.txt"), "tip\n").expect("write tip.txt");
11843        git(&repo_path, &["add", "."]);
11844        git(&repo_path, &["commit", "-m", "default tip"]);
11845        let default_tip_hex = head_sha(&repo_path);
11846
11847        let repo = gix::open(&repo_path).expect("open repo");
11848        // What `landing::probe` hands over for a Worktree entity sitting at
11849        // `shallow`, whose own tip is not main's actual tip.
11850        let outstanding = landing::Outstanding {
11851            entity_tip: shallow_sha,
11852            default_tip: id(&default_tip_hex),
11853            merge_base: Some(shallow_sha),
11854        };
11855        let common_dir: Arc<Path> = Arc::from(repo_path.join(".git"));
11856        let cancel = AtomicBool::new(false);
11857        let patch_cache: PatchIdentityCache = Mutex::new(HashMap::new());
11858        let patch_reads = AtomicUsize::new(0);
11859        let patch_scan_bounds: Mutex<Vec<Option<gix::ObjectId>>> = Mutex::new(Vec::new());
11860        let memo = PatchEquivalenceMemo {
11861            cache: &patch_cache,
11862            reads: &patch_reads,
11863            scan_bounds: &patch_scan_bounds,
11864        };
11865        // Two entities share this common dir this Generation: `deep_sha` stands
11866        // in for a sibling that already reported its own, deeper merge base;
11867        // `shallow` is the one entity driven through the real function below.
11868        let gate = BoundGate::new(2);
11869        gate.report(Some(deep_sha));
11870        let mut report = GateReport::new(&gate);
11871
11872        probe_patch_equivalence(
11873            &repo,
11874            &outstanding,
11875            &common_dir,
11876            &cancel,
11877            &memo,
11878            &mut report,
11879        );
11880
11881        assert_eq!(
11882            patch_scan_bounds.lock().unwrap().as_slice(),
11883            [Some(deep_sha)],
11884            "the scan must be bounded by the deepest sibling's merge base, not shallow's own \
11885             ({shallow_sha:?})"
11886        );
11887    }
11888
11889    /// The carry itself: [`probe_patch_equivalence`] diffs the entity's own
11890    /// range from the merge base `landing::probe` handed over, rather than
11891    /// walking the same commit pair a second time. `mid_sha` is a real commit
11892    /// on `feature` but not its fork point, so the two answers differ: from the
11893    /// fork point the range is the whole squashed change and settles `Merged`,
11894    /// from `mid_sha` it is only `b.txt` and settles `Active`. A regression that
11895    /// recomputed the base here would answer `Merged` and fail this test.
11896    #[test]
11897    fn probe_patch_equivalence_diffs_from_the_merge_base_it_was_handed() {
11898        let dir = tempfile::tempdir().expect("temp dir");
11899        let repo_path = root_of(&dir).join("repo");
11900        init_repo_with_a_commit(&repo_path);
11901        let fork_point_hex = head_sha(&repo_path);
11902        git(&repo_path, &["checkout", "-b", "feature"]);
11903        fs::write(repo_path.join("a.txt"), "one\n").expect("write a.txt");
11904        git(&repo_path, &["add", "a.txt"]);
11905        git(&repo_path, &["commit", "-m", "add a"]);
11906        let mid_sha = id(&head_sha(&repo_path));
11907        fs::write(repo_path.join("b.txt"), "two\n").expect("write b.txt");
11908        git(&repo_path, &["add", "b.txt"]);
11909        git(&repo_path, &["commit", "-m", "add b"]);
11910        let feature_sha = id(&head_sha(&repo_path));
11911        git(&repo_path, &["checkout", "-B", "main", &fork_point_hex]);
11912        git(&repo_path, &["merge", "--squash", "feature"]);
11913        git(&repo_path, &["commit", "-m", "squashed feature"]);
11914        let main_sha = id(&head_sha(&repo_path));
11915
11916        let repo = gix::open(&repo_path).expect("open repo");
11917        // What `landing::probe` hands over, with a base halfway along the
11918        // branch standing in for one only this pass could know.
11919        let outstanding = landing::Outstanding {
11920            entity_tip: feature_sha,
11921            default_tip: main_sha,
11922            merge_base: Some(mid_sha),
11923        };
11924        let common_dir: Arc<Path> = Arc::from(repo_path.join(".git"));
11925        let cancel = AtomicBool::new(false);
11926        let patch_cache: PatchIdentityCache = Mutex::new(HashMap::new());
11927        let patch_reads = AtomicUsize::new(0);
11928        let patch_scan_bounds: Mutex<Vec<Option<gix::ObjectId>>> = Mutex::new(Vec::new());
11929        let memo = PatchEquivalenceMemo {
11930            cache: &patch_cache,
11931            reads: &patch_reads,
11932            scan_bounds: &patch_scan_bounds,
11933        };
11934        let gate = BoundGate::new(1);
11935        let mut report = GateReport::new(&gate);
11936
11937        let settled = probe_patch_equivalence(
11938            &repo,
11939            &outstanding,
11940            &common_dir,
11941            &cancel,
11942            &memo,
11943            &mut report,
11944        );
11945
11946        assert!(
11947            matches!(
11948                settled,
11949                Some(Settled::Known {
11950                    value: WorktreeState::Active,
11951                    at: _,
11952                    stale: _
11953                })
11954            ),
11955            "the range must be measured from the handed-in base ({mid_sha:?}), whose only \
11956             change the squash commit does not match, got {settled:?}"
11957        );
11958    }
11959
11960    /// The edge [`deepest_merge_base`] exists for: no entity sharing a common
11961    /// dir ever had a merge base to offer (every one settled by ancestry, was
11962    /// cancelled, or shared no history with the default branch at all), so the
11963    /// scan is left unbounded. `deepest_merge_base` returns before its first
11964    /// candidate lookup here, which is what lets this fixture skip building any
11965    /// commit history at all.
11966    #[test]
11967    fn bound_gate_deepest_with_no_candidates_leaves_the_scan_unbounded() {
11968        let dir = tempfile::tempdir().expect("temp dir");
11969        let repo_path = root_of(&dir).join("repo");
11970        gix::init(&repo_path).expect("init repo");
11971        let repo = gix::open(&repo_path).expect("open repo");
11972
11973        let gate = BoundGate::new(2);
11974        gate.report(None);
11975        gate.report(None);
11976
11977        assert_eq!(
11978            gate.deepest(&repo),
11979            None,
11980            "no contributed candidate must leave the scan unbounded"
11981        );
11982    }
11983
11984    /// `probe_patch_equivalence`'s `Ok(None)` arm bypasses the shared scan for
11985    /// an Outstanding entity with no shared history at all. `unrelated` is a
11986    /// real branch, with a live upstream so `landing::probe`
11987    /// leaves it `Outstanding`, whose own root commit shares no history with
11988    /// `main`'s, driven through `Core` end to end rather than by calling
11989    /// `probe_patch_equivalence` or `patch_equivalence::probe` directly, so a
11990    /// removed bypass (the shared scan run unconditionally instead) is
11991    /// exercised for real: `BoundGate::deepest` would then block forever on a
11992    /// scan this entity never asked for.
11993    #[test]
11994    fn an_outstanding_entity_with_no_shared_history_settles_active_without_the_shared_scan() {
11995        let dir = tempfile::tempdir().expect("temp dir");
11996        let root = root_of(&dir);
11997        let parent = root.join("parent");
11998        init_repo_with_a_commit(&parent);
11999        git(&parent, &["branch", "-M", "main"]);
12000        git(
12001            &parent,
12002            &[
12003                "remote",
12004                "add",
12005                "origin",
12006                "https://example.invalid/repo.git",
12007            ],
12008        );
12009        let main_sha = head_sha(&parent);
12010        git(
12011            &parent,
12012            &["update-ref", "refs/remotes/origin/main", &main_sha],
12013        );
12014
12015        git(&parent, &["checkout", "--orphan", "unrelated"]);
12016        git(
12017            &parent,
12018            &["commit", "--allow-empty", "-m", "unrelated root"],
12019        );
12020        let unrelated_sha = head_sha(&parent);
12021        git(&parent, &["checkout", "main"]);
12022
12023        let worktree = root.join("unrelated");
12024        git(
12025            &parent,
12026            &[
12027                "worktree",
12028                "add",
12029                worktree.to_str().expect("utf8 path"),
12030                "unrelated",
12031            ],
12032        );
12033        git(&parent, &["config", "branch.unrelated.remote", "origin"]);
12034        git(
12035            &parent,
12036            &["config", "branch.unrelated.merge", "refs/heads/unrelated"],
12037        );
12038        git(
12039            &parent,
12040            &[
12041                "update-ref",
12042                "refs/remotes/origin/unrelated",
12043                &unrelated_sha,
12044            ],
12045        );
12046
12047        let (core, snapshot) = started_and_settled(spec(vec![root]));
12048        let worktree_key = snapshot
12049            .entities
12050            .iter()
12051            .find(|entity| entity.key.path() == worktree)
12052            .expect("unrelated worktree discovered")
12053            .key
12054            .clone();
12055
12056        core.refresh(std::slice::from_ref(&worktree_key));
12057        let settled = core.settle();
12058
12059        let state = settled
12060            .entities
12061            .iter()
12062            .find(|entity| entity.key == worktree_key)
12063            .and_then(|entity| entity.state.settled())
12064            .cloned();
12065        assert!(
12066            matches!(
12067                state,
12068                Some(Settled::Known {
12069                    value: WorktreeState::Active,
12070                    at: _,
12071                    stale: _
12072                })
12073            ),
12074            "expected an Outstanding entity with no shared history to settle Active via the \
12075             bypass, got {state:?}"
12076        );
12077        assert_eq!(
12078            core.patch_identity_reads_for_test(),
12079            0,
12080            "the bypass must settle without ever running the shared scan"
12081        );
12082    }
12083
12084    // --- Phase B's comparison: the `sync` cell, end to end through a real `Core`:
12085    // the six named cases, plus the two ways "every entity, every Generation" is
12086    // most easily lost. ---
12087
12088    fn add_origin_remote(path: &Path) {
12089        git(
12090            path,
12091            &[
12092                "remote",
12093                "add",
12094                "origin",
12095                "https://example.invalid/repo.git",
12096            ],
12097        );
12098    }
12099
12100    /// Wires `branch` up to track `refs/remotes/origin/<branch>` at `upstream_sha`,
12101    /// mirroring `patch_equivalence_is_memoised_once_per_common_dir_per_generation`'s
12102    /// own fixture shape against a real disposable repo.
12103    fn set_upstream(path: &Path, branch: &str, upstream_sha: &str) {
12104        git(
12105            path,
12106            &["config", &format!("branch.{branch}.remote"), "origin"],
12107        );
12108        git(
12109            path,
12110            &[
12111                "config",
12112                &format!("branch.{branch}.merge"),
12113                &format!("refs/heads/{branch}"),
12114            ],
12115        );
12116        git(
12117            path,
12118            &[
12119                "update-ref",
12120                &format!("refs/remotes/origin/{branch}"),
12121                upstream_sha,
12122            ],
12123        );
12124    }
12125
12126    fn refresh_and_settle(core: &Core) -> crate::snapshot::Snapshot {
12127        let keys: Vec<EntityKey> = core
12128            .snapshot()
12129            .entities
12130            .iter()
12131            .map(|entity| entity.key.clone())
12132            .collect();
12133        core.refresh(&keys);
12134        core.settle()
12135    }
12136
12137    fn sync_of<'a>(
12138        snapshot: &'a crate::snapshot::Snapshot,
12139        path: &Path,
12140    ) -> Option<&'a Settled<SyncState>> {
12141        snapshot
12142            .entities
12143            .iter()
12144            .find(|entity| entity.key.path() == path)
12145            .unwrap_or_else(|| panic!("no entity for {}", path.display()))
12146            .sync
12147            .settled()
12148    }
12149
12150    /// Named case 1 of 6: an attached branch ahead of its upstream.
12151    #[test]
12152    fn an_attached_branch_ahead_of_its_upstream_reads_the_ahead_count() {
12153        let dir = tempfile::tempdir().expect("temp dir");
12154        let root = root_of(&dir);
12155        let repo = root.join("repo");
12156        init_repo_with_a_commit(&repo);
12157        let fork_sha = head_sha(&repo);
12158        add_origin_remote(&repo);
12159        set_upstream(&repo, "main", &fork_sha);
12160        git(&repo, &["commit", "--allow-empty", "-m", "local work"]);
12161
12162        let core = Core::start_discovered(spec(vec![root]));
12163        let settled = refresh_and_settle(&core);
12164
12165        match sync_of(&settled, &repo) {
12166            Some(Settled::Known {
12167                value: SyncState::Tracking(AheadBehind { ahead, behind }),
12168                at: _,
12169                stale: _,
12170            }) => {
12171                assert_eq!(*ahead, 1);
12172                assert_eq!(*behind, 0);
12173            }
12174            other => panic!("expected 1 ahead, 0 behind, got {other:?}"),
12175        }
12176    }
12177
12178    /// Named case 2 of 6: an attached branch behind its upstream.
12179    #[test]
12180    fn an_attached_branch_behind_its_upstream_reads_the_behind_count() {
12181        let dir = tempfile::tempdir().expect("temp dir");
12182        let root = root_of(&dir);
12183        let repo = root.join("repo");
12184        init_repo_with_a_commit(&repo);
12185        git(&repo, &["checkout", "-b", "temp"]);
12186        git(&repo, &["commit", "--allow-empty", "-m", "upstream work"]);
12187        let upstream_sha = head_sha(&repo);
12188        git(&repo, &["checkout", "main"]);
12189        git(&repo, &["branch", "-D", "temp"]);
12190        add_origin_remote(&repo);
12191        set_upstream(&repo, "main", &upstream_sha);
12192
12193        let core = Core::start_discovered(spec(vec![root]));
12194        let settled = refresh_and_settle(&core);
12195
12196        match sync_of(&settled, &repo) {
12197            Some(Settled::Known {
12198                value: SyncState::Tracking(AheadBehind { ahead, behind }),
12199                at: _,
12200                stale: _,
12201            }) => {
12202                assert_eq!(*ahead, 0);
12203                assert_eq!(*behind, 1);
12204            }
12205            other => panic!("expected 0 ahead, 1 behind, got {other:?}"),
12206        }
12207    }
12208
12209    /// Named case 3 of 6: an attached branch level with its upstream.
12210    #[test]
12211    fn an_attached_branch_level_with_its_upstream_reads_in_sync() {
12212        let dir = tempfile::tempdir().expect("temp dir");
12213        let root = root_of(&dir);
12214        let repo = root.join("repo");
12215        init_repo_with_a_commit(&repo);
12216        let sha = head_sha(&repo);
12217        add_origin_remote(&repo);
12218        set_upstream(&repo, "main", &sha);
12219
12220        let core = Core::start_discovered(spec(vec![root]));
12221        let settled = refresh_and_settle(&core);
12222
12223        match sync_of(&settled, &repo) {
12224            Some(Settled::Known {
12225                value:
12226                    SyncState::Tracking(AheadBehind {
12227                        ahead: 0,
12228                        behind: 0,
12229                    }),
12230                at: _,
12231                stale: _,
12232            }) => {}
12233            other => panic!("expected level with its upstream, got {other:?}"),
12234        }
12235    }
12236
12237    /// Named case 4 of 6: an attached branch tracking nothing, on a Repo that does
12238    /// have a remote. Distinguishes this from case 6 below: the absence here is the
12239    /// branch's own tracking configuration, not the Repo's remote.
12240    #[test]
12241    fn an_attached_branch_tracking_nothing_reads_no_upstream() {
12242        let dir = tempfile::tempdir().expect("temp dir");
12243        let root = root_of(&dir);
12244        let repo = root.join("repo");
12245        init_repo_with_a_commit(&repo);
12246        add_origin_remote(&repo);
12247
12248        let core = Core::start_discovered(spec(vec![root]));
12249        let settled = refresh_and_settle(&core);
12250
12251        match sync_of(&settled, &repo) {
12252            Some(Settled::Known {
12253                value: SyncState::NoUpstream,
12254                at: _,
12255                stale: _,
12256            }) => {}
12257            other => panic!("expected no upstream configured, got {other:?}"),
12258        }
12259    }
12260
12261    /// Named case 5 of 6: a detached row, on a Repo that does have a remote.
12262    /// Distinguishes this from case 6 below the same way case 4 does.
12263    #[test]
12264    fn a_detached_row_reads_no_upstream() {
12265        let dir = tempfile::tempdir().expect("temp dir");
12266        let root = root_of(&dir);
12267        let repo = root.join("repo");
12268        init_repo_with_a_commit(&repo);
12269        let first_sha = head_sha(&repo);
12270        git(&repo, &["commit", "--allow-empty", "-m", "second"]);
12271        git(&repo, &["checkout", "--detach", &first_sha]);
12272        add_origin_remote(&repo);
12273
12274        let core = Core::start_discovered(spec(vec![root]));
12275        let settled = refresh_and_settle(&core);
12276
12277        match sync_of(&settled, &repo) {
12278            Some(Settled::Known {
12279                value: SyncState::NoUpstream,
12280                at: _,
12281                stale: _,
12282            }) => {}
12283            other => panic!("expected a detached row to read no upstream, got {other:?}"),
12284        }
12285    }
12286
12287    /// Named case 6 of 6: a Repo with no remote at all. The propagation half of
12288    /// criterion 3 is the substance here, not the Repo row alone: a linked Worktree
12289    /// shares the parent's config and has no upstream of its own to speak of either,
12290    /// so it must read the exact same `NoRemote` value, not `NoUpstream`.
12291    #[test]
12292    fn a_repo_with_no_remote_reads_no_remote_on_itself_and_every_worktree() {
12293        let dir = tempfile::tempdir().expect("temp dir");
12294        let root = root_of(&dir);
12295        let parent = root.join("parent");
12296        init_repo_with_a_commit(&parent);
12297        let worktree = root.join("feature");
12298        git(
12299            &parent,
12300            &[
12301                "worktree",
12302                "add",
12303                "-b",
12304                "feature",
12305                worktree.to_str().expect("utf8 path"),
12306            ],
12307        );
12308
12309        let core = Core::start_discovered(spec(vec![root]));
12310        let settled = refresh_and_settle(&core);
12311
12312        assert_eq!(
12313            settled.entities.len(),
12314            2,
12315            "expected the parent Repo and its one linked Worktree"
12316        );
12317        for path in [&parent, &worktree] {
12318            match sync_of(&settled, path) {
12319                Some(Settled::Known {
12320                    value: SyncState::NoRemote,
12321                    at: _,
12322                    stale: _,
12323                }) => {}
12324                other => panic!(
12325                    "expected {} to read no remote at all, got {other:?}",
12326                    path.display()
12327                ),
12328            }
12329        }
12330    }
12331
12332    /// Criterion 1's "every entity" half: two sibling Worktrees under one Repo, each
12333    /// with a different sync outcome, computed together in one Generation. A test
12334    /// driving only one of them could not see an implementation that dispatches the
12335    /// comparison for a single hand-picked entity rather than every one whose HEAD
12336    /// carries a branch.
12337    #[test]
12338    fn sync_is_computed_for_every_entity_dispatched_this_generation_not_only_one() {
12339        let dir = tempfile::tempdir().expect("temp dir");
12340        let root = root_of(&dir);
12341        let parent = root.join("parent");
12342        init_repo_with_a_commit(&parent);
12343        let fork_sha = head_sha(&parent);
12344        add_origin_remote(&parent);
12345
12346        let ahead_worktree = root.join("feature-ahead");
12347        git(
12348            &parent,
12349            &[
12350                "worktree",
12351                "add",
12352                "-b",
12353                "feature-ahead",
12354                ahead_worktree.to_str().expect("utf8 path"),
12355            ],
12356        );
12357        set_upstream(&parent, "feature-ahead", &fork_sha);
12358        git(
12359            &ahead_worktree,
12360            &["commit", "--allow-empty", "-m", "unpushed"],
12361        );
12362
12363        let behind_worktree = root.join("feature-behind");
12364        git(
12365            &parent,
12366            &[
12367                "worktree",
12368                "add",
12369                "-b",
12370                "feature-behind",
12371                behind_worktree.to_str().expect("utf8 path"),
12372            ],
12373        );
12374        git(
12375            &behind_worktree,
12376            &["commit", "--allow-empty", "-m", "on the remote only"],
12377        );
12378        let ahead_of_behind_sha = head_sha(&behind_worktree);
12379        git(&behind_worktree, &["reset", "--hard", "HEAD~1"]);
12380        set_upstream(&parent, "feature-behind", &ahead_of_behind_sha);
12381
12382        let core = Core::start_discovered(spec(vec![root]));
12383        let settled = refresh_and_settle(&core);
12384
12385        match sync_of(&settled, &ahead_worktree) {
12386            Some(Settled::Known {
12387                value:
12388                    SyncState::Tracking(AheadBehind {
12389                        ahead: 1,
12390                        behind: 0,
12391                    }),
12392                at: _,
12393                stale: _,
12394            }) => {}
12395            other => panic!("expected feature-ahead to read 1 ahead, got {other:?}"),
12396        }
12397        match sync_of(&settled, &behind_worktree) {
12398            Some(Settled::Known {
12399                value:
12400                    SyncState::Tracking(AheadBehind {
12401                        ahead: 0,
12402                        behind: 1,
12403                    }),
12404                at: _,
12405                stale: _,
12406            }) => {}
12407            other => panic!("expected feature-behind to read 1 behind, got {other:?}"),
12408        }
12409    }
12410
12411    /// Criterion 1's "every Generation" half: a second, later refresh recomputes
12412    /// `sync` rather than a first Generation's answer sticking around unrefreshed.
12413    /// A test that only ever drives one Generation cannot see an implementation
12414    /// that dispatches the comparison once, at `Core::start`'s own discovery, and
12415    /// never again on an explicit `refresh`.
12416    #[test]
12417    fn sync_recomputes_on_a_second_generation_not_only_the_first() {
12418        let dir = tempfile::tempdir().expect("temp dir");
12419        let root = root_of(&dir);
12420        let repo = root.join("repo");
12421        init_repo_with_a_commit(&repo);
12422        let fork_sha = head_sha(&repo);
12423        add_origin_remote(&repo);
12424        set_upstream(&repo, "main", &fork_sha);
12425
12426        let core = Core::start_discovered(spec(vec![root]));
12427        let first = refresh_and_settle(&core);
12428        match sync_of(&first, &repo) {
12429            Some(Settled::Known {
12430                value:
12431                    SyncState::Tracking(AheadBehind {
12432                        ahead: 0,
12433                        behind: 0,
12434                    }),
12435                at: _,
12436                stale: _,
12437            }) => {}
12438            other => panic!("expected the first Generation level with its upstream, got {other:?}"),
12439        }
12440
12441        git(
12442            &repo,
12443            &[
12444                "commit",
12445                "--allow-empty",
12446                "-m",
12447                "second Generation's own work",
12448            ],
12449        );
12450        let second = refresh_and_settle(&core);
12451        match sync_of(&second, &repo) {
12452            Some(Settled::Known {
12453                value:
12454                    SyncState::Tracking(AheadBehind {
12455                        ahead: 1,
12456                        behind: 0,
12457                    }),
12458                at: _,
12459                stale: _,
12460            }) => {}
12461            other => panic!(
12462                "expected the second Generation to recompute and read 1 ahead, got {other:?}"
12463            ),
12464        }
12465    }
12466
12467    /// The Worktree-reporting criterion: after a default branch moves, the Worktrees
12468    /// now behind it are reported by name. `base` (the same "behind the default branch"
12469    /// count [`base.rs`] computes and every row's own `name` already carries) is what
12470    /// "reported by name" means in practice: a snapshot reader finds each Worktree by
12471    /// the name on its row, not by position, so this test does the same, matching each
12472    /// assertion to its own fixture's name rather than to "the first" or "the last"
12473    /// entity.
12474    ///
12475    /// `wt-behind` is branched from the default branch's tip before it moves and is left
12476    /// untouched, the same shape a fetch leaves an existing linked Worktree in; `wt-
12477    /// caught-up` is branched from the tip *after* it moves, so it is unaffected. Two
12478    /// Worktrees are required, not one: a test with only `wt-behind` would still pass
12479    /// against an implementation that reports every Worktree as behind regardless of
12480    /// whether it actually is, and a test that asserted only "something is reported"
12481    /// would pass even if the names or the counts were swapped.
12482    #[test]
12483    fn worktrees_now_behind_a_moved_default_branch_are_reported_by_name() {
12484        let dir = tempfile::tempdir().expect("temp dir");
12485        let root = root_of(&dir);
12486        let repo = root.join("repo");
12487        init_repo_with_a_commit(&repo);
12488        let sha_a = head_sha(&repo);
12489        add_origin_remote(&repo);
12490        set_upstream(&repo, "main", &sha_a);
12491
12492        let behind_path = root.join("wt-behind");
12493        git(
12494            &repo,
12495            &[
12496                "worktree",
12497                "add",
12498                "-b",
12499                "topic-behind",
12500                behind_path.to_str().expect("utf8 path"),
12501                "main",
12502            ],
12503        );
12504
12505        // Moves only the default branch's own remote-tracking ref, the same shape a
12506        // fetch leaves behind: `repo`'s own checked-out `main` does not move, so this
12507        // is deliberately not exercising the auto-update itself, only what a moved
12508        // default branch does to every Worktree's own `base` count.
12509        git(&repo, &["checkout", "-b", "scratch"]);
12510        git(&repo, &["commit", "--allow-empty", "-m", "second"]);
12511        let sha_b = head_sha(&repo);
12512        git(&repo, &["checkout", "main"]);
12513        git(&repo, &["update-ref", "refs/remotes/origin/main", &sha_b]);
12514        git(&repo, &["branch", "-D", "scratch"]);
12515
12516        // Branched from the plain commit sha, not `refs/remotes/origin/main` itself:
12517        // starting a new branch from a remote-tracking ref makes git auto-configure it
12518        // to track that same ref, which would make this row the default branch's own
12519        // row (`base.rs`'s `branch_is_default_branchs_own_row`) and settle `base` as
12520        // `NotApplicable` rather than the `0` this fixture means to prove.
12521        let caught_up_path = root.join("wt-caught-up");
12522        git(
12523            &repo,
12524            &[
12525                "worktree",
12526                "add",
12527                "-b",
12528                "topic-caught-up",
12529                caught_up_path.to_str().expect("utf8 path"),
12530                &sha_b,
12531            ],
12532        );
12533
12534        let core = Core::start_discovered(spec(vec![root]));
12535        let snapshot = refresh_and_settle(&core);
12536
12537        let base_of = |name: &str| -> u32 {
12538            let entity = snapshot
12539                .entities
12540                .iter()
12541                .find(|entity| &*entity.name == name)
12542                .unwrap_or_else(|| panic!("no entity named {name} in {snapshot:?}"));
12543            match entity.base.settled() {
12544                Some(Settled::Known {
12545                    value,
12546                    at: _,
12547                    stale: _,
12548                }) => *value,
12549                other => panic!("expected a known base count for {name}, got {other:?}"),
12550            }
12551        };
12552
12553        assert!(
12554            base_of("wt-behind") > 0,
12555            "a Worktree branched before the default branch moved must be reported behind"
12556        );
12557        assert_eq!(
12558            base_of("wt-caught-up"),
12559            0,
12560            "a Worktree branched from the new tip must not be reported behind"
12561        );
12562    }
12563
12564    /// The periodic fetch's own scheduler: criterion 3's five rules
12565    /// ([refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
12566    /// "The periodic fetch"). Every fixture here is a bare repo this test creates plus a
12567    /// real `git clone` of it, per the standing constraint that a fetch test never
12568    /// touches a real remote or the network.
12569    mod fetch_scheduler {
12570        use super::*;
12571        use crate::liveness::wait_for_or;
12572
12573        fn fetch_spec(enabled: bool, root: PathBuf) -> CoreSpec {
12574            let mut spec = spec(vec![root]);
12575            spec.fetch = FetchSpec {
12576                enabled,
12577                interval: Duration::from_secs(3600),
12578                concurrency: 4,
12579            };
12580            spec
12581        }
12582
12583        /// A bare "remote" this call creates and seeds with one commit, never a real
12584        /// remote and never touched over the network.
12585        fn seeded_remote() -> tempfile::TempDir {
12586            let remote = tempfile::tempdir().expect("temp dir");
12587            crate::test_support::init_bare(remote.path());
12588            crate::test_support::push_new_commit(remote.path(), "README.md", "seed\n");
12589            remote
12590        }
12591
12592        fn clone_into(remote: &Path, dest: &Path) {
12593            let status = Command::new("git")
12594                .arg("clone")
12595                .arg(remote)
12596                .arg(dest)
12597                .status()
12598                .expect("run git clone");
12599            assert!(status.success());
12600            crate::test_support::set_identity(dest);
12601        }
12602
12603        /// The scheduler's first rule: enabling the periodic fetch runs one cycle
12604        /// immediately rather than waiting for `fetch.interval` to elapse. `fetch_ticks`
12605        /// is `crossbeam_channel::never()`, so the only way `fetch_cycle_count_for_test`
12606        /// can ever move is the immediate cycle `start_internal` dispatches on its own
12607        /// plain thread; a scheduler that only reacted to a tick would leave this at
12608        /// zero forever.
12609        #[test]
12610        fn enabling_the_periodic_fetch_runs_one_cycle_before_any_tick_arrives() {
12611            let remote = seeded_remote();
12612            let root = tempfile::tempdir().expect("temp dir");
12613            let root_path = root_of(&root);
12614            clone_into(remote.path(), &root_path.join("parent"));
12615
12616            let fetch_ticks: Receiver<Instant> = crossbeam_channel::never();
12617            let started = Core::start_for_test_with_fetch(
12618                fetch_spec(true, root_path),
12619                Duration::from_secs(3600),
12620                crossbeam_channel::never(),
12621                fetch_ticks,
12622            )
12623            .discovered();
12624            let core = started.core;
12625
12626            wait_for(
12627                "the periodic fetch to run its first cycle without waiting for a tick",
12628                || core.fetch_cycle_count_for_test() >= 1,
12629            );
12630        }
12631
12632        /// `Core::fetch_now` runs a cycle on demand, and does so with `fetch.enabled`
12633        /// false: that flag governs the cycle this `Core` runs unbidden on a timer, never a
12634        /// cycle the caller asked for. `fetch_ticks` is `crossbeam_channel::never()` and the
12635        /// periodic fetch is off, so nothing but this call can move the cycle count.
12636        #[test]
12637        fn fetch_now_runs_a_cycle_even_though_the_periodic_fetch_is_disabled() {
12638            let remote = seeded_remote();
12639            let root = tempfile::tempdir().expect("temp dir");
12640            let root_path = root_of(&root);
12641            clone_into(remote.path(), &root_path.join("parent"));
12642
12643            let started = Core::start_for_test_with_fetch(
12644                fetch_spec(false, root_path),
12645                Duration::from_secs(3600),
12646                crossbeam_channel::never(),
12647                crossbeam_channel::never(),
12648            )
12649            .discovered();
12650            let core = started.core;
12651
12652            core.fetch_now();
12653
12654            wait_for("the on-demand fetch to run a cycle", || {
12655                core.fetch_cycle_count_for_test() >= 1
12656            });
12657        }
12658
12659        /// An on-demand fetch asked for while one is already in flight is refused rather
12660        /// than queued, the same choice a tick arriving mid-cycle already makes. The first
12661        /// cycle is provably still running when the second call lands, since it is parked at
12662        /// [`FetchBoundary`]. `fetch_cycles_taken_back` is read after the `Core` is dropped,
12663        /// which joins the clock thread and takes back whatever cycle it still held: a
12664        /// queued second cycle would have started at the foot of the same loop iteration
12665        /// that took the first one back, so it would be counted here.
12666        #[test]
12667        fn a_second_fetch_now_while_one_is_in_flight_is_refused_rather_than_queued() {
12668            let remote = seeded_remote();
12669            let root = tempfile::tempdir().expect("temp dir");
12670            let root_path = root_of(&root);
12671            clone_into(remote.path(), &root_path.join("parent"));
12672
12673            let started = Core::start_for_test_with_fetch(
12674                fetch_spec(false, root_path),
12675                Duration::from_secs(3600),
12676                crossbeam_channel::never(),
12677                crossbeam_channel::never(),
12678            )
12679            .discovered();
12680            let core = started.core;
12681            let taken_back = Arc::clone(&started.fetch_cycles_taken_back);
12682
12683            let held = core.fetch_boundary().arm();
12684            core.fetch_now();
12685            held.wait_until_reached();
12686            core.fetch_now();
12687            drop(held);
12688
12689            wait_for("the first cycle to be taken back", || {
12690                taken_back.load(Ordering::Acquire) >= 1
12691            });
12692            drop(core);
12693
12694            assert_eq!(
12695                taken_back.load(Ordering::Acquire),
12696                1,
12697                "the second press must have started no cycle of its own"
12698            );
12699        }
12700
12701        /// [`Core::fetch_running`] is what the status row reads every frame, so it must be
12702        /// true for exactly as long as a cycle is in flight. Holding the cycle at
12703        /// [`FetchBoundary`] makes "in flight" a moment to assert at rather than a race to
12704        /// catch; the clearing half is a wait, since it lands on the clock thread a moment
12705        /// after the fetch itself returns.
12706        #[test]
12707        fn fetch_running_holds_while_a_cycle_is_in_flight_and_clears_once_it_is_taken_back() {
12708            let remote = seeded_remote();
12709            let root = tempfile::tempdir().expect("temp dir");
12710            let root_path = root_of(&root);
12711            clone_into(remote.path(), &root_path.join("parent"));
12712
12713            let started = Core::start_for_test_with_fetch(
12714                fetch_spec(false, root_path),
12715                Duration::from_secs(3600),
12716                crossbeam_channel::never(),
12717                crossbeam_channel::never(),
12718            )
12719            .discovered();
12720            let core = started.core;
12721            assert!(
12722                !core.fetch_running(),
12723                "sanity: no cycle has been asked for yet"
12724            );
12725
12726            let held = core.fetch_boundary().arm();
12727            core.fetch_now();
12728            held.wait_until_reached();
12729            assert!(
12730                core.fetch_running(),
12731                "a cycle parked mid-fetch is still in flight"
12732            );
12733
12734            drop(held);
12735            wait_for(
12736                "the cycle to be taken back and fetch_running to clear",
12737                || !core.fetch_running(),
12738            );
12739        }
12740
12741        /// The failure counting an on-demand cycle owes is the periodic one's, unchanged: one
12742        /// unreachable repository is counted and named, and its sibling still fetches. The
12743        /// periodic fetch is off and no tick ever fires, so the cycle under test is the one
12744        /// `fetch_now` asked for.
12745        #[test]
12746        fn an_on_demand_cycle_counts_a_repository_it_could_not_reach_and_fetches_the_rest() {
12747            let good_remote = seeded_remote();
12748            let bad_remote = seeded_remote();
12749            let root = tempfile::tempdir().expect("temp dir");
12750            let root_path = root_of(&root);
12751            let good = root_path.join("good");
12752            let bad = root_path.join("bad");
12753            clone_into(good_remote.path(), &good);
12754            clone_into(bad_remote.path(), &bad);
12755            break_remote(&bad);
12756
12757            crate::test_support::push_new_commit(good_remote.path(), "second.txt", "second\n");
12758            let good_remote_tip = rev_parse(good_remote.path(), "refs/heads/main");
12759
12760            let started = Core::start_for_test_with_fetch(
12761                fetch_spec(false, root_path),
12762                Duration::from_secs(3600),
12763                crossbeam_channel::never(),
12764                crossbeam_channel::never(),
12765            )
12766            .discovered();
12767            let core = started.core;
12768
12769            core.fetch_now();
12770
12771            wait_for(
12772                "the on-demand cycle to count the one repository it could not fetch",
12773                || core.fetch_failures().failed.len() == 1,
12774            );
12775            let failures = core.fetch_failures();
12776            assert!(
12777                failures.failed[0].0.to_string_lossy().contains("bad"),
12778                "the counted failure must name the repository that actually failed, got: {:?}",
12779                failures.failed
12780            );
12781
12782            wait_for(
12783                "the sibling repository to still fetch despite the other one failing",
12784                || rev_parse(&good, "refs/remotes/origin/main") == good_remote_tip,
12785            );
12786        }
12787
12788        /// A tick on the periodic fetch's own channel runs a second cycle, proving the
12789        /// recurring cadence is wired to the same dedicated thread the immediate cycle
12790        /// used, not merely a one-shot dispatched at start.
12791        ///
12792        /// The tick is sent only once the immediate cycle has been taken back, since a tick
12793        /// arriving while a cycle is live is refused rather than queued.
12794        #[test]
12795        fn a_tick_on_the_fetch_channel_runs_another_cycle() {
12796            let remote = seeded_remote();
12797            let root = tempfile::tempdir().expect("temp dir");
12798            let root_path = root_of(&root);
12799            clone_into(remote.path(), &root_path.join("parent"));
12800
12801            let (fetch_tick_tx, fetch_tick_rx) = crossbeam_channel::unbounded();
12802            let started = Core::start_for_test_with_fetch(
12803                fetch_spec(true, root_path),
12804                Duration::from_secs(3600),
12805                crossbeam_channel::never(),
12806                fetch_tick_rx,
12807            )
12808            .discovered();
12809            let core = started.core;
12810
12811            wait_for(
12812                "the immediate cycle to have run and been taken back first",
12813                || started.fetch_cycles_taken_back.load(Ordering::Acquire) >= 1,
12814            );
12815
12816            fetch_tick_tx
12817                .send(Instant::now())
12818                .expect("send a fetch tick");
12819
12820            wait_for("a tick on the fetch channel to run a second cycle", || {
12821                core.fetch_cycle_count_for_test() >= 2
12822            });
12823        }
12824
12825        /// The clock is a coordinator, never a fetch's own caller: a cycle held at
12826        /// [`FetchBoundary`] must leave the Generation deadline sweep on the same thread free
12827        /// to settle a probe that has run out of time. `fetch.enabled` is false and the cycle
12828        /// under test comes from a tick alone, so the only fetch in flight is the one this
12829        /// test is holding.
12830        #[test]
12831        fn a_deadline_tick_still_times_out_a_pending_probe_while_a_fetch_is_held() {
12832            let remote = seeded_remote();
12833            let root = tempfile::tempdir().expect("temp dir");
12834            let root_path = root_of(&root);
12835            clone_into(remote.path(), &root_path.join("parent"));
12836
12837            let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
12838            let (fetch_tick_tx, fetch_tick_rx) = crossbeam_channel::unbounded::<Instant>();
12839            let mut spec = fetch_spec(false, root_path);
12840            spec.generation_deadline = Duration::ZERO;
12841            let started = Core::start_for_test_with_fetch(
12842                spec,
12843                Duration::from_secs(3600),
12844                tick_rx,
12845                fetch_tick_rx,
12846            )
12847            .discovered();
12848            let core = started.core;
12849            let key = core.settle().entities[0].key.clone();
12850
12851            let held = core.fetch_boundary().arm();
12852            fetch_tick_tx
12853                .send(Instant::now())
12854                .expect("send a fetch tick");
12855            held.wait_until_reached();
12856
12857            core.begin_untracked_probe_for_test(&key);
12858            tick_tx.send(Instant::now()).expect("send one tick");
12859            let after = core.settle();
12860
12861            assert!(
12862                matches!(
12863                    after.entities[0].branch.settled(),
12864                    Some(Settled::Unknown(Unknown::TimedOut))
12865                ),
12866                "the deadline sweep must still run while a fetch is held, got: {:?}",
12867                after.entities[0].branch.settled()
12868            );
12869        }
12870
12871        /// Pause is the lifecycle owner ending the live cycle where it stands, not only
12872        /// stopping the next one: the cancellation reaches a fetch that is provably still
12873        /// running, no further repository is fetched, the mutating half of that cycle never
12874        /// runs, and the Generation a finished cycle owes is never dispatched once the held
12875        /// fetch is let go. Both fences have something to hold: `parent` is left genuinely
12876        /// eligible (clean, behind, tracking an upstream) by a fetch this test performs
12877        /// itself, and `stale` is left a commit behind its remote, so a cycle that carried on
12878        /// would move each of them.
12879        #[test]
12880        fn pause_cancels_a_held_cycle_so_it_neither_auto_updates_nor_dispatches_its_generation() {
12881            let remote = seeded_remote();
12882            let root = tempfile::tempdir().expect("temp dir");
12883            let root_path = root_of(&root);
12884            let parent = root_path.join("parent");
12885            let stale = root_path.join("stale");
12886            clone_into(remote.path(), &parent);
12887            clone_into(remote.path(), &stale);
12888            crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
12889            git(&parent, &["fetch", "origin"]);
12890            let before_tip = rev_parse(&parent, "refs/heads/main");
12891            let stale_before = rev_parse(&stale, "refs/remotes/origin/main");
12892
12893            let (fetch_tick_tx, fetch_tick_rx) = crossbeam_channel::unbounded();
12894            let started = Core::start_for_test_with_fetch(
12895                spec_with_auto_update(false, true, root_path),
12896                Duration::from_secs(3600),
12897                crossbeam_channel::never(),
12898                fetch_tick_rx,
12899            )
12900            .discovered();
12901            let core = started.core;
12902            let before = core.settle().generation;
12903
12904            let held = core.fetch_boundary().arm();
12905            fetch_tick_tx
12906                .send(Instant::now())
12907                .expect("send a fetch tick");
12908            held.wait_until_reached();
12909
12910            core.pause();
12911            held.wait_until_cancelled();
12912            drop(held);
12913            wait_for("the cancelled cycle to be taken back by the clock", || {
12914                started.fetch_cycles_taken_back.load(Ordering::Acquire) >= 1
12915            });
12916
12917            assert_eq!(
12918                rev_parse(&parent, "refs/heads/main"),
12919                before_tip,
12920                "a cancelled cycle must not fast-forward a Repo its auto-update would \
12921                 otherwise have moved"
12922            );
12923            assert_eq!(
12924                rev_parse(&stale, "refs/remotes/origin/main"),
12925                stale_before,
12926                "a cancelled cycle must land no fetch beyond the one it was holding"
12927            );
12928            assert_eq!(
12929                core.snapshot().generation,
12930                before,
12931                "releasing a cancelled fetch must not dispatch the completion Generation \
12932                 its cycle would otherwise have owed"
12933            );
12934        }
12935
12936        /// A tick arriving while a cycle is live is refused, not queued and not run beside
12937        /// it: two cycles over the same population would fetch and fast-forward the same
12938        /// repositories at once. The clock takes both further ticks off the channel while the
12939        /// first cycle is provably still held, which is what makes the refusal the reading
12940        /// here rather than a scheduling delay.
12941        #[test]
12942        fn a_fetch_tick_taken_while_a_cycle_is_live_starts_no_second_cycle() {
12943            let remote = seeded_remote();
12944            let root = tempfile::tempdir().expect("temp dir");
12945            let root_path = root_of(&root);
12946            clone_into(remote.path(), &root_path.join("parent"));
12947
12948            let (fetch_tick_tx, fetch_tick_rx) = crossbeam_channel::unbounded();
12949            // A second handle on the same queue, read but never received from: the clock
12950            // emptying it is what says both further ticks have been taken.
12951            let pending_ticks = fetch_tick_rx.clone();
12952            let started = Core::start_for_test_with_fetch(
12953                fetch_spec(false, root_path),
12954                Duration::from_secs(3600),
12955                crossbeam_channel::never(),
12956                fetch_tick_rx,
12957            )
12958            .discovered();
12959            let core = started.core;
12960
12961            let held = core.fetch_boundary().arm();
12962            fetch_tick_tx
12963                .send(Instant::now())
12964                .expect("send the tick that starts the cycle");
12965            held.wait_until_reached();
12966
12967            for _ in 0..2 {
12968                fetch_tick_tx
12969                    .send(Instant::now())
12970                    .expect("send a tick while the cycle is live");
12971            }
12972            wait_for("the clock to take both further ticks", || {
12973                pending_ticks.is_empty()
12974            });
12975
12976            drop(held);
12977            wait_for("the released cycle to be taken back by the clock", || {
12978                started.fetch_cycles_taken_back.load(Ordering::Acquire) >= 1
12979            });
12980
12981            assert_eq!(
12982                core.fetch_cycle_count_for_test(),
12983                1,
12984                "two ticks taken while a cycle was held must have started no cycle of their \
12985                 own"
12986            );
12987        }
12988
12989        /// [`FetchFailures`] is the most recently *completed* cycle's own count
12990        /// (GLOSSARY.md), so a cancelled one never replaces it: what that cycle reached
12991        /// before it was ended is not a count of what could not be fetched. The immediate
12992        /// cycle here completes and counts its one broken remote; the second is cancelled
12993        /// while its fetch is held, and the count standing afterwards is still the first
12994        /// cycle's.
12995        #[test]
12996        fn a_cancelled_cycle_leaves_the_completed_cycles_failures_standing() {
12997            let remote = seeded_remote();
12998            let root = tempfile::tempdir().expect("temp dir");
12999            let root_path = root_of(&root);
13000            let broken = root_path.join("broken");
13001            clone_into(remote.path(), &broken);
13002            break_remote(&broken);
13003
13004            let (fetch_tick_tx, fetch_tick_rx) = crossbeam_channel::unbounded();
13005            let started = Core::start_for_test_with_fetch(
13006                fetch_spec(true, root_path),
13007                Duration::from_secs(3600),
13008                crossbeam_channel::never(),
13009                fetch_tick_rx,
13010            )
13011            .discovered();
13012            let core = started.core;
13013
13014            wait_for("the immediate cycle to complete and be taken back", || {
13015                started.fetch_cycles_taken_back.load(Ordering::Acquire) >= 1
13016            });
13017            assert_eq!(
13018                core.fetch_failures().failed.len(),
13019                1,
13020                "the completed cycle must have counted its one broken remote, got: {:?}",
13021                core.fetch_failures().failed
13022            );
13023
13024            let held = core.fetch_boundary().arm();
13025            fetch_tick_tx
13026                .send(Instant::now())
13027                .expect("send a fetch tick");
13028            held.wait_until_reached();
13029            core.pause();
13030            held.wait_until_cancelled();
13031            drop(held);
13032            wait_for("the cancelled cycle to be taken back by the clock", || {
13033                started.fetch_cycles_taken_back.load(Ordering::Acquire) >= 2
13034            });
13035
13036            assert_eq!(
13037                core.fetch_failures().failed.len(),
13038                1,
13039                "a cancelled cycle must leave the completed cycle's own count standing, \
13040                 got: {:?}",
13041                core.fetch_failures().failed
13042            );
13043        }
13044
13045        /// The one cycle enabling the periodic fetch owes
13046        /// ([refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
13047        /// "fires immediately on being enabled") is held by a pause rather than lost to it:
13048        /// the first walk asks for it once and nothing asks again, so a Launcher handoff
13049        /// landing during that walk would otherwise cost the user a whole `fetch.interval`.
13050        /// The walk is held closed until the pause has been sent, which is what orders the
13051        /// two rather than racing them.
13052        #[test]
13053        fn a_pause_landing_before_the_immediate_cycle_holds_it_until_resume() {
13054            let remote = seeded_remote();
13055            let root = tempfile::tempdir().expect("temp dir");
13056            let root_path = root_of(&root);
13057            clone_into(remote.path(), &root_path.join("parent"));
13058
13059            let (gate, walk_may_run, opener) = gate_opened_on_signal(false);
13060            let started = Core::start_for_test_with_fetch_gated(
13061                fetch_spec(true, root_path),
13062                Duration::from_secs(3600),
13063                crossbeam_channel::never(),
13064                crossbeam_channel::never(),
13065                Some(gate),
13066            );
13067            started.core.pause();
13068            walk_may_run.send(()).expect("the opener is listening");
13069            opener.join().expect("the opener thread should not panic");
13070            let core = started.discovered().core;
13071
13072            core.resume();
13073
13074            wait_for(
13075                "the held immediate cycle to run once the clock resumes",
13076                || core.fetch_cycle_count_for_test() >= 1,
13077            );
13078        }
13079
13080        /// Teardown signals the cycle's own cancellation and waits for the worker to stop,
13081        /// rather than abandoning a thread that is still fetching and fast-forwarding
13082        /// repositories. Both halves are read against a fetch this test is still holding:
13083        /// the cancellation is observed at the boundary, and teardown is still waiting while
13084        /// that fetch has not returned, which a teardown that merely signalled and detached
13085        /// could not be. It runs on a thread of its own, so a teardown that never returns
13086        /// fails this test rather than wedging the run. The Repo is left eligible for the
13087        /// auto-update by a fetch this test performs itself, so the branch standing still
13088        /// afterwards is a worker that stopped rather than one with nothing to do.
13089        #[test]
13090        fn dropping_the_core_cancels_and_joins_a_held_fetch_cycle_before_returning() {
13091            let remote = seeded_remote();
13092            let root = tempfile::tempdir().expect("temp dir");
13093            let root_path = root_of(&root);
13094            let parent = root_path.join("parent");
13095            clone_into(remote.path(), &parent);
13096            crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
13097            git(&parent, &["fetch", "origin"]);
13098            let before_tip = rev_parse(&parent, "refs/heads/main");
13099
13100            let (fetch_tick_tx, fetch_tick_rx) = crossbeam_channel::unbounded();
13101            let started = Core::start_for_test_with_fetch(
13102                spec_with_auto_update(false, true, root_path),
13103                Duration::from_secs(3600),
13104                crossbeam_channel::never(),
13105                fetch_tick_rx,
13106            )
13107            .discovered();
13108            let core = started.core;
13109
13110            let held = core.fetch_boundary().arm();
13111            fetch_tick_tx
13112                .send(Instant::now())
13113                .expect("send a fetch tick");
13114            held.wait_until_reached();
13115
13116            let (returned_tx, returned_rx) = crossbeam_channel::bounded::<()>(1);
13117            let teardown = thread::spawn(move || {
13118                drop(core);
13119                let _ = returned_tx.send(());
13120            });
13121
13122            held.wait_until_cancelled();
13123            // A safety claim rather than a liveness one, so no deadline can prove it and
13124            // load only ever weakens it: teardown is inside its own join for as long as the
13125            // fetch below has not returned.
13126            assert!(
13127                returned_rx
13128                    .recv_timeout(Duration::from_millis(200))
13129                    .is_err(),
13130                "teardown must still be waiting on the worker it cancelled, not have \
13131                 detached it"
13132            );
13133
13134            drop(held);
13135            returned_rx
13136                .recv_timeout(liveness::BACKSTOP)
13137                .expect("teardown returns once the worker it joined has stopped");
13138            teardown
13139                .join()
13140                .expect("the teardown thread should not panic");
13141
13142            assert_eq!(
13143                started.fetch_cycles_taken_back.load(Ordering::Acquire),
13144                1,
13145                "teardown must have taken its own cycle back rather than left it running"
13146            );
13147            assert_eq!(
13148                rev_parse(&parent, "refs/heads/main"),
13149                before_tip,
13150                "no worker may still be fast-forwarding a repository once teardown has \
13151                 returned"
13152            );
13153        }
13154
13155        /// Points `repo`'s `origin` at a path nothing lives at, breaking `fetch_and_prune`
13156        /// alone: discovery has already found `repo` as a real Repo before this runs, so
13157        /// only the fetch itself fails, never the walk. A local path rather than a loopback
13158        /// address, so this never touches even the machine's own network stack, the same
13159        /// standing constraint every fixture in this module already holds to.
13160        fn break_remote(repo: &Path) {
13161            let status = Command::new("git")
13162                .arg("-C")
13163                .arg(repo)
13164                .args(["remote", "set-url", "origin", "/nonexistent-remote-282"])
13165                .status()
13166                .expect("run git remote set-url");
13167            assert!(status.success());
13168        }
13169
13170        /// Criterion: a cycle where every fetch succeeds reports no failures.
13171        #[test]
13172        fn a_cycle_in_which_every_fetch_succeeds_reports_no_failures() {
13173            let remote = seeded_remote();
13174            let root = tempfile::tempdir().expect("temp dir");
13175            let root_path = root_of(&root);
13176            clone_into(remote.path(), &root_path.join("parent"));
13177
13178            let fetch_ticks: Receiver<Instant> = crossbeam_channel::never();
13179            let started = Core::start_for_test_with_fetch(
13180                fetch_spec(true, root_path),
13181                Duration::from_secs(3600),
13182                crossbeam_channel::never(),
13183                fetch_ticks,
13184            )
13185            .discovered();
13186            let core = started.core;
13187
13188            wait_for("the periodic fetch to run its first cycle", || {
13189                core.fetch_cycle_count_for_test() >= 1
13190            });
13191
13192            assert!(
13193                core.fetch_failures().failed.is_empty(),
13194                "a cycle where every fetch succeeds must report no failures, got: {:?}",
13195                core.fetch_failures().failed
13196            );
13197        }
13198
13199        /// A cycle in which one repository cannot be fetched counts that one failure, and
13200        /// the per-repository independence at the fetch loop's own swallow is unchanged,
13201        /// proven here by the sibling repository still fetching.
13202        #[test]
13203        fn a_repository_that_cannot_be_fetched_is_counted_while_its_sibling_still_fetches() {
13204            let good_remote = seeded_remote();
13205            let bad_remote = seeded_remote();
13206            let root = tempfile::tempdir().expect("temp dir");
13207            let root_path = root_of(&root);
13208            let good = root_path.join("good");
13209            let bad = root_path.join("bad");
13210            clone_into(good_remote.path(), &good);
13211            clone_into(bad_remote.path(), &bad);
13212            break_remote(&bad);
13213
13214            crate::test_support::push_new_commit(good_remote.path(), "second.txt", "second\n");
13215            let good_remote_tip = rev_parse(good_remote.path(), "refs/heads/main");
13216
13217            let fetch_ticks: Receiver<Instant> = crossbeam_channel::never();
13218            let started = Core::start_for_test_with_fetch(
13219                fetch_spec(true, root_path),
13220                Duration::from_secs(3600),
13221                crossbeam_channel::never(),
13222                fetch_ticks,
13223            )
13224            .discovered();
13225            let core = started.core;
13226
13227            wait_for(
13228                "the cycle to run and count the one repository it could not fetch",
13229                || core.fetch_failures().failed.len() == 1,
13230            );
13231
13232            let failures = core.fetch_failures();
13233            assert_eq!(
13234                failures.failed.len(),
13235                1,
13236                "exactly one repository failed, so exactly one failure must be counted, \
13237                 got: {:?}",
13238                failures.failed
13239            );
13240            assert!(
13241                failures.failed[0].0.to_string_lossy().contains("bad"),
13242                "the counted failure must name the repository that actually failed, \
13243                 got: {:?}",
13244                failures.failed
13245            );
13246
13247            wait_for(
13248                "the sibling repository to still fetch despite the other one failing",
13249                || rev_parse(&good, "refs/remotes/origin/main") == good_remote_tip,
13250            );
13251        }
13252
13253        /// [`crate::test_support::push_new_commit`], but onto `branch` rather than
13254        /// always `main`: this scheduler test needs a second commit on `topic`
13255        /// specifically, so ancestry alone cannot call it merged into `main`.
13256        fn push_new_commit_on_branch(remote: &Path, branch: &str, name: &str, contents: &str) {
13257            let contributor = tempfile::tempdir().expect("temp dir");
13258            let status = Command::new("git")
13259                .arg("clone")
13260                .arg("--branch")
13261                .arg(branch)
13262                .arg(remote)
13263                .arg(contributor.path())
13264                .status()
13265                .expect("run git clone");
13266            assert!(status.success());
13267            std::fs::write(contributor.path().join(name), contents).expect("write fixture file");
13268            git(contributor.path(), &["add", name]);
13269            git(contributor.path(), &["commit", "-m", "extra work on topic"]);
13270            git(contributor.path(), &["push", "origin", branch]);
13271        }
13272
13273        /// Criteria 3 and 4 together, end to end: the periodic fetch always prunes, so
13274        /// `Gone` can appear at all, and a finished fetch starts one normal Generation
13275        /// on its own, so the pruned state actually lands on the table without the test
13276        /// calling `refresh` itself. `topic` carries a commit `main` never gets, so
13277        /// ancestry alone cannot call it `Merged`; deleting it upstream before the
13278        /// scheduler's own fetch is what a plain, non-pruning fetch could never turn
13279        /// into `Gone`.
13280        #[test]
13281        fn a_finished_fetch_prunes_and_starts_its_own_generation_that_lands_gone() {
13282            let remote = seeded_remote();
13283            let root = tempfile::tempdir().expect("temp dir");
13284            let root_path = root_of(&root);
13285            let parent = root_path.join("parent");
13286            clone_into(remote.path(), &parent);
13287
13288            git(remote.path(), &["branch", "topic"]);
13289            push_new_commit_on_branch(remote.path(), "topic", "topic.txt", "extra work\n");
13290
13291            // A deliberate, ordinary fetch by the test's own setup, distinct from the
13292            // Core's own periodic fetch under test: `parent` was cloned before `topic`
13293            // existed, so this is what teaches it about `origin/topic` at all, the same
13294            // way any real clone would only learn of a branch created after it cloned
13295            // on its own next fetch.
13296            git(&parent, &["fetch", "origin"]);
13297
13298            let worktree_path = root_path.join("topic-worktree");
13299            git(
13300                &parent,
13301                &[
13302                    "worktree",
13303                    "add",
13304                    "-b",
13305                    "topic",
13306                    worktree_path.to_str().expect("utf8 path"),
13307                    "origin/topic",
13308                ],
13309            );
13310
13311            // Deleted only now, after the worktree already tracks it: this is the
13312            // upstream disappearance a plain fetch can see but never prune away, and
13313            // exactly what the scheduler's own fetch (not this setup) must prune.
13314            git(remote.path(), &["branch", "-D", "topic"]);
13315
13316            let fetch_ticks: Receiver<Instant> = crossbeam_channel::never();
13317            let started = Core::start_for_test_with_fetch(
13318                fetch_spec(true, root_path),
13319                Duration::from_secs(3600),
13320                crossbeam_channel::never(),
13321                fetch_ticks,
13322            )
13323            .discovered();
13324            let core = started.core;
13325
13326            wait_for_or(
13327                "a finished fetch's own Generation to land the pruned Worktree as Gone \
13328                 without the test ever calling refresh",
13329                || {
13330                    core.snapshot()
13331                        .entities
13332                        .iter()
13333                        .filter(|entity| matches!(entity.kind, Kind::Worktree))
13334                        .any(|entity| {
13335                            matches!(
13336                                entity.state.settled(),
13337                                Some(Settled::Known {
13338                                    value: WorktreeState::Gone,
13339                                    at: _,
13340                                    stale: _,
13341                                })
13342                            )
13343                        })
13344                },
13345                || {
13346                    format!(
13347                        "snapshot: {:?}",
13348                        core.snapshot()
13349                            .entities
13350                            .iter()
13351                            .map(|entity| (entity.kind, entity.state.settled().cloned()))
13352                            .collect::<Vec<_>>()
13353                    )
13354                },
13355            );
13356        }
13357
13358        /// The whole chain an on-demand fetch owes the screen, at the one place a user
13359        /// reads it: `parent` is a commit behind a remote it has never fetched from, so its
13360        /// `sync` cell says level. One `fetch_now` must fetch, then dispatch the completion
13361        /// Generation that re-probes, and leave the cell reading one behind. `fetch_ticks`
13362        /// never fires and the periodic fetch is off, so nothing else could have moved it.
13363        #[test]
13364        fn an_on_demand_fetch_lands_a_new_behind_count_through_the_generation_it_dispatches() {
13365            let remote = seeded_remote();
13366            let root = tempfile::tempdir().expect("temp dir");
13367            let root_path = root_of(&root);
13368            let parent = root_path.join("parent");
13369            clone_into(remote.path(), &parent);
13370
13371            crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
13372
13373            let started = Core::start_for_test_with_fetch(
13374                spec_with_auto_update(false, false, root_path),
13375                Duration::from_secs(3600),
13376                crossbeam_channel::never(),
13377                crossbeam_channel::never(),
13378            )
13379            .discovered();
13380            let core = started.core;
13381
13382            core.fetch_now();
13383
13384            wait_for("the on-demand fetch to land a behind count of 1", || {
13385                matches!(
13386                    sync_of(&core.snapshot(), &parent),
13387                    Some(Settled::Known {
13388                        value: SyncState::Tracking(AheadBehind {
13389                            ahead: 0,
13390                            behind: 1
13391                        }),
13392                        at: _,
13393                        stale: _,
13394                    })
13395                )
13396            });
13397        }
13398
13399        /// The auto-update rides an on-demand cycle exactly as it rides a tick's, because it
13400        /// is the same cycle started early rather than a narrower one of its own. The remote
13401        /// is ahead before `Core::start`, no tick ever fires and the periodic fetch is off,
13402        /// so `fetch_now` is the only thing that could have moved the branch.
13403        #[test]
13404        fn auto_update_rides_an_on_demand_cycle_the_same_way_it_rides_a_tick() {
13405            let remote = seeded_remote();
13406            let root = tempfile::tempdir().expect("temp dir");
13407            let root_path = root_of(&root);
13408            let parent = root_path.join("parent");
13409            clone_into(remote.path(), &parent);
13410
13411            crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
13412            let remote_tip = rev_parse(remote.path(), "refs/heads/main");
13413
13414            let started = Core::start_for_test_with_fetch(
13415                spec_with_auto_update(false, true, root_path),
13416                Duration::from_secs(3600),
13417                crossbeam_channel::never(),
13418                crossbeam_channel::never(),
13419            )
13420            .discovered();
13421            let core = started.core;
13422
13423            core.fetch_now();
13424
13425            wait_for(
13426                "the eligible branch to fast-forward on the on-demand cycle",
13427                || rev_parse(&parent, "refs/heads/main") == remote_tip,
13428            );
13429        }
13430
13431        /// The other half: `auto_update.enabled` still decides. An on-demand fetch with it
13432        /// off fetches and leaves the eligible branch exactly where it was, so the key is a
13433        /// request for a fetch and never a fast-forward in its own right.
13434        #[test]
13435        fn an_on_demand_fetch_moves_no_branch_while_auto_update_is_disabled() {
13436            let remote = seeded_remote();
13437            let root = tempfile::tempdir().expect("temp dir");
13438            let root_path = root_of(&root);
13439            let parent = root_path.join("parent");
13440            clone_into(remote.path(), &parent);
13441            let before = rev_parse(&parent, "refs/heads/main");
13442
13443            crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
13444
13445            let started = Core::start_for_test_with_fetch(
13446                spec_with_auto_update(false, false, root_path),
13447                Duration::from_secs(3600),
13448                crossbeam_channel::never(),
13449                crossbeam_channel::never(),
13450            )
13451            .discovered();
13452            let core = started.core;
13453
13454            core.fetch_now();
13455
13456            wait_for("the on-demand cycle to have run", || {
13457                core.fetch_cycle_count_for_test() >= 1
13458            });
13459            assert_eq!(
13460                rev_parse(&parent, "refs/heads/main"),
13461                before,
13462                "an on-demand fetch must move no branch while auto_update.enabled is false"
13463            );
13464        }
13465
13466        fn spec_with_auto_update(
13467            fetch_enabled: bool,
13468            auto_update_enabled: bool,
13469            root: PathBuf,
13470        ) -> CoreSpec {
13471            let mut spec = fetch_spec(fetch_enabled, root);
13472            spec.auto_update = AutoUpdateSpec {
13473                enabled: auto_update_enabled,
13474            };
13475            spec
13476        }
13477
13478        fn rev_parse(path: &Path, rev: &str) -> String {
13479            let output = Command::new("git")
13480                .arg("-C")
13481                .arg(path)
13482                .args(["rev-parse", rev])
13483                .output()
13484                .expect("run git rev-parse");
13485            assert!(output.status.success(), "git rev-parse {rev} failed");
13486            String::from_utf8(output.stdout)
13487                .expect("utf8 sha")
13488                .trim()
13489                .to_string()
13490        }
13491
13492        /// Criterion 1's "off by default" half: `fetch.enabled` alone is not enough to
13493        /// move a branch. `fetch_ticks` never fires, so the only cycle that can possibly
13494        /// run is the immediate one `start_internal` dispatches on being enabled; that
13495        /// cycle fetches (`fetch_cycle_count_for_test` proves it ran) and must still
13496        /// leave the eligible local branch exactly where it was, since `auto_update`
13497        /// carries its own, separate `enabled` flag this spec never turns on.
13498        #[test]
13499        fn auto_update_is_off_by_default_even_with_fetch_enabled() {
13500            let remote = seeded_remote();
13501            let root = tempfile::tempdir().expect("temp dir");
13502            let root_path = root_of(&root);
13503            let parent = root_path.join("parent");
13504            clone_into(remote.path(), &parent);
13505            let before = rev_parse(&parent, "refs/heads/main");
13506
13507            crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
13508
13509            let fetch_ticks: Receiver<Instant> = crossbeam_channel::never();
13510            let started = Core::start_for_test_with_fetch(
13511                spec_with_auto_update(true, false, root_path),
13512                Duration::from_secs(3600),
13513                crossbeam_channel::never(),
13514                fetch_ticks,
13515            )
13516            .discovered();
13517            let core = started.core;
13518
13519            wait_for(
13520                "the periodic fetch to still run its immediate cycle",
13521                || core.fetch_cycle_count_for_test() >= 1,
13522            );
13523            assert_eq!(
13524                rev_parse(&parent, "refs/heads/main"),
13525                before,
13526                "an eligible branch must not move while auto_update.enabled is false, \
13527                 even though fetch.enabled is true"
13528            );
13529        }
13530
13531        /// Criterion 1's "rides the fetch cycle with no timer of its own" half: the
13532        /// remote is already ahead *before* `Core::start`, `fetch_ticks` is
13533        /// `crossbeam_channel::never()` so no recurring tick ever fires, and yet the
13534        /// eligible branch still moves, proving the auto-update ran on the same
13535        /// immediate first cycle the periodic fetch itself uses rather than waiting on
13536        /// any tick of its own.
13537        #[test]
13538        fn auto_update_enabled_rides_the_immediate_fetch_cycle_with_no_timer_of_its_own() {
13539            let remote = seeded_remote();
13540            let root = tempfile::tempdir().expect("temp dir");
13541            let root_path = root_of(&root);
13542            let parent = root_path.join("parent");
13543            clone_into(remote.path(), &parent);
13544
13545            crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
13546            let remote_tip = rev_parse(remote.path(), "refs/heads/main");
13547
13548            let fetch_ticks: Receiver<Instant> = crossbeam_channel::never();
13549            let started = Core::start_for_test_with_fetch(
13550                spec_with_auto_update(true, true, root_path),
13551                Duration::from_secs(3600),
13552                crossbeam_channel::never(),
13553                fetch_ticks,
13554            )
13555            .discovered();
13556            // Kept alive, unused otherwise: dropping `Core` joins its dedicated thread,
13557            // which would stop the immediate cycle this test is waiting on.
13558            let _core = started.core;
13559
13560            wait_for(
13561                "the eligible branch to fast-forward on the immediate cycle alone, with no \
13562                 fetch tick and no auto-update tick of its own",
13563                || rev_parse(&parent, "refs/heads/main") == remote_tip,
13564            );
13565        }
13566    }
13567
13568    /// [`Core::attempt_auto_update`] must answer exactly what
13569    /// [`crate::auto_update::attempt`] would for the same Repo, since it delegates to that
13570    /// function rather than reimplementing its own copy of the eligibility rules: the
13571    /// built-in `sync` action's own "reuses `auto_update`'s existing rules rather than a
13572    /// second implementation"
13573    /// ([repo-management.md](https://github.com/paulchiu/repon/blob/main/docs/spec/repo-management.md))
13574    /// is proven here, at the one seam a reimplementation could actually diverge from the
13575    /// rules it is supposed to reuse. Every fixture is a bare repo this test creates plus a
13576    /// real `git clone` of it, the same standing constraint `fetch_scheduler` above follows.
13577    mod attempt_auto_update {
13578        use super::*;
13579
13580        fn seeded_remote() -> tempfile::TempDir {
13581            let remote = tempfile::tempdir().expect("temp dir");
13582            crate::test_support::init_bare(remote.path());
13583            crate::test_support::push_new_commit(remote.path(), "README.md", "seed\n");
13584            remote
13585        }
13586
13587        fn clone_into(remote: &Path, dest: &Path) {
13588            let status = Command::new("git")
13589                .arg("clone")
13590                .arg(remote)
13591                .arg(dest)
13592                .status()
13593                .expect("run git clone");
13594            assert!(status.success());
13595            crate::test_support::set_identity(dest);
13596        }
13597
13598        /// Discovers `root`'s one Repo and hands back the live `Core` alongside its key,
13599        /// the same `Core::start_discovered` plus `settle` shape [`delete_risk`]'s own tests
13600        /// already use: this method reads the repository fresh, not a Cell, so discovery's
13601        /// own read-only probes running first are never a race with it.
13602        fn discover_repo(root: &Path) -> (Core, EntityKey) {
13603            let core = Core::start_discovered(spec(vec![root.to_path_buf()]));
13604            let key = core
13605                .settle()
13606                .entities
13607                .into_iter()
13608                .find(|entity| entity.kind == Kind::Repo)
13609                .expect("the Repo row is discovered")
13610                .key;
13611            (core, key)
13612        }
13613
13614        /// The eligible condition: clean, behind, not ahead, tracking an upstream. Proves
13615        /// the wrapper both classifies and actually moves the branch, not only the former.
13616        #[test]
13617        fn an_eligible_repo_fast_forwards_through_the_wrapper_too() {
13618            let remote = seeded_remote();
13619            let root = tempfile::tempdir().expect("temp dir");
13620            let root_path = root_of(&root);
13621            let repo = root_path.join("repo");
13622            clone_into(remote.path(), &repo);
13623            crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
13624            crate::test_support::git(&repo, &["fetch", "origin"]);
13625
13626            let (core, key) = discover_repo(&root_path);
13627
13628            assert_eq!(core.attempt_auto_update(&key), AutoUpdateAttempt::Updated);
13629            assert!(
13630                repo.join("second.txt").exists(),
13631                "the fast-forward must reach the working tree through the wrapper too"
13632            );
13633        }
13634
13635        /// Condition 1: a dirty working tree.
13636        #[test]
13637        fn a_dirty_repo_is_reported_not_clean_through_the_wrapper_too() {
13638            let remote = seeded_remote();
13639            let root = tempfile::tempdir().expect("temp dir");
13640            let root_path = root_of(&root);
13641            let repo = root_path.join("repo");
13642            clone_into(remote.path(), &repo);
13643            crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
13644            crate::test_support::git(&repo, &["fetch", "origin"]);
13645            fs::write(repo.join("stray.txt"), "uncommitted\n").expect("write a stray file");
13646
13647            let (core, key) = discover_repo(&root_path);
13648
13649            assert_eq!(core.attempt_auto_update(&key), AutoUpdateAttempt::NotClean);
13650        }
13651
13652        /// Condition 2: already level with the upstream.
13653        #[test]
13654        fn an_up_to_date_repo_is_reported_not_behind_through_the_wrapper_too() {
13655            let remote = seeded_remote();
13656            let root = tempfile::tempdir().expect("temp dir");
13657            let root_path = root_of(&root);
13658            let repo = root_path.join("repo");
13659            clone_into(remote.path(), &repo);
13660            crate::test_support::git(&repo, &["fetch", "origin"]);
13661
13662            let (core, key) = discover_repo(&root_path);
13663
13664            assert_eq!(core.attempt_auto_update(&key), AutoUpdateAttempt::NotBehind);
13665        }
13666
13667        /// Condition 3: a local commit the upstream does not have.
13668        #[test]
13669        fn an_unpublished_local_commit_is_reported_not_fast_forward_through_the_wrapper_too() {
13670            let remote = seeded_remote();
13671            let root = tempfile::tempdir().expect("temp dir");
13672            let root_path = root_of(&root);
13673            let repo = root_path.join("repo");
13674            clone_into(remote.path(), &repo);
13675            crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
13676            crate::test_support::git(&repo, &["fetch", "origin"]);
13677            crate::test_support::commit_file(&repo, "local-only.txt", "never pushed\n");
13678
13679            let (core, key) = discover_repo(&root_path);
13680
13681            assert_eq!(
13682                core.attempt_auto_update(&key),
13683                AutoUpdateAttempt::NotFastForward
13684            );
13685        }
13686
13687        /// Condition 4: no upstream configured at all.
13688        #[test]
13689        fn a_branch_with_no_upstream_is_reported_through_the_wrapper_too() {
13690            let remote = seeded_remote();
13691            let root = tempfile::tempdir().expect("temp dir");
13692            let root_path = root_of(&root);
13693            let repo = root_path.join("repo");
13694            clone_into(remote.path(), &repo);
13695            crate::test_support::git(&repo, &["checkout", "-b", "untracked-branch"]);
13696
13697            let (core, key) = discover_repo(&root_path);
13698
13699            assert_eq!(
13700                core.attempt_auto_update(&key),
13701                AutoUpdateAttempt::NoUpstream
13702            );
13703        }
13704    }
13705
13706    /// [default-branch.md](https://github.com/paulchiu/repon/blob/main/docs/spec/default-branch.md)'s
13707    /// "The network": criterion 3 (the local chain answers first, and only a later network
13708    /// round trip supersedes it) and criterion 4 (`Core::rederive_default_branches` runs the
13709    /// same lookup on demand, over exactly the given keys, without fetching). Every fixture
13710    /// here is a bare repo this test creates plus a real `git clone` of it, the same standing
13711    /// constraint `fetch_scheduler` above already follows.
13712    mod network_default_branch {
13713        use super::*;
13714
13715        fn seeded_remote() -> tempfile::TempDir {
13716            let remote = tempfile::tempdir().expect("temp dir");
13717            crate::test_support::init_bare(remote.path());
13718            crate::test_support::push_new_commit(remote.path(), "README.md", "seed\n");
13719            remote
13720        }
13721
13722        fn clone_into(remote: &Path, dest: &Path) {
13723            let status = Command::new("git")
13724                .arg("clone")
13725                .arg(remote)
13726                .arg(dest)
13727                .status()
13728                .expect("run git clone");
13729            assert!(status.success());
13730            crate::test_support::set_identity(dest);
13731        }
13732
13733        /// Sets `path`'s own `HEAD` (a bare repo, so this is the "remote"'s advertised
13734        /// answer) to point at `branch`, without checking anything out.
13735        fn set_remote_head(path: &Path, branch: &str) {
13736            git(
13737                path,
13738                &["symbolic-ref", "HEAD", &format!("refs/heads/{branch}")],
13739            );
13740        }
13741
13742        fn rev_parse(path: &Path, rev: &str) -> String {
13743            let output = Command::new("git")
13744                .arg("-C")
13745                .arg(path)
13746                .args(["rev-parse", rev])
13747                .output()
13748                .expect("run git rev-parse");
13749            assert!(output.status.success());
13750            String::from_utf8(output.stdout)
13751                .expect("utf8 sha")
13752                .trim()
13753                .to_string()
13754        }
13755
13756        fn default_branch_name(entity: &EntityState) -> Option<String> {
13757            match entity.default_branch.settled() {
13758                Some(Settled::Known {
13759                    value,
13760                    at: _,
13761                    stale: _,
13762                }) => Some(value.name().to_string()),
13763                _ => None,
13764            }
13765        }
13766
13767        /// Criterion 3: with a reachable remote whose advertised HEAD differs from the
13768        /// clone's own cached `origin/HEAD`, a plain refresh still answers from the local
13769        /// chain alone (the network is never consulted just to render a Generation), and
13770        /// only [`Core::rederive_default_branches`] actually reaching the remote supersedes
13771        /// it, for the rest of this `Core`'s own session (default-branch.md's "The network":
13772        /// "supersedes the local one for that session"). The mutation this is chosen to
13773        /// catch: were `supersede_with_network` never applied (or applied unconditionally
13774        /// before the local chain even ran), either the first assertion would already read
13775        /// `origin/trunk`, or the second would still read `origin/main`.
13776        #[test]
13777        fn the_local_chain_answers_first_and_only_a_later_network_round_trip_supersedes_it() {
13778            let remote = seeded_remote();
13779            let root = tempfile::tempdir().expect("temp dir");
13780            let root_path = root_of(&root);
13781            let repo_path = root_path.join("repo");
13782            clone_into(remote.path(), &repo_path);
13783
13784            // The clone's own cached `origin/HEAD` still names `main`; the remote's own
13785            // current answer is changed to a different, real branch only after cloning.
13786            git(remote.path(), &["branch", "trunk"]);
13787            set_remote_head(remote.path(), "trunk");
13788
13789            let core = Core::start_discovered(spec(vec![root_path]));
13790            let key = core.snapshot().entities[0].key.clone();
13791
13792            core.refresh(std::slice::from_ref(&key));
13793            let settled = core.settle();
13794            assert_eq!(
13795                default_branch_name(&settled.entities[0]),
13796                Some("origin/main".to_string()),
13797                "a plain refresh must answer from the local chain alone, unaffected by the \
13798                 remote's own current (but not yet asked) truth"
13799            );
13800
13801            core.rederive_default_branches(std::slice::from_ref(&key));
13802            let settled = core.settle();
13803            assert_eq!(
13804                default_branch_name(&settled.entities[0]),
13805                Some("origin/trunk".to_string()),
13806                "once the network round trip actually ran, its own differing answer must \
13807                 supersede the local chain's"
13808            );
13809        }
13810
13811        /// Criterion 4: [`Core::rederive_default_branches`] runs the same lookup on demand,
13812        /// over exactly the given keys, without fetching. "Without fetching" is shown the
13813        /// way `fetch.rs`'s own `a_fetch_transfers_new_commits_so_a_behind_count_can_move`
13814        /// shows a real fetch moving one, the mirror image: the remote gains a new commit
13815        /// after the clone, and this call must leave the clone's own remote-tracking ref
13816        /// exactly where it was, because `probe_remote_head`'s handshake-only lookup
13817        /// transfers no pack. "Over the Selection" is exercised as "over exactly the given
13818        /// keys": a second, unrelated repo stands in for a row outside it, and its whole
13819        /// entity state (every cell, not only `default_branch`) is asserted unchanged.
13820        #[test]
13821        fn rederive_default_branches_never_fetches_and_leaves_a_row_outside_it_untouched() {
13822            let remote = seeded_remote();
13823            let root = tempfile::tempdir().expect("temp dir");
13824            let root_path = root_of(&root);
13825            let selected_path = root_path.join("selected");
13826            let outside_path = root_path.join("outside");
13827            clone_into(remote.path(), &selected_path);
13828            init_repo_with_a_commit(&outside_path);
13829
13830            git(remote.path(), &["branch", "trunk"]);
13831            crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
13832            set_remote_head(remote.path(), "trunk");
13833            let before_tracking = rev_parse(&selected_path, "refs/remotes/origin/main");
13834
13835            let core = Core::start_discovered(spec(vec![root_path]));
13836            let snapshot = core.snapshot();
13837            let selected_key = snapshot
13838                .entities
13839                .iter()
13840                .find(|entity| entity.key.path() == selected_path)
13841                .expect("discovered the selected repo")
13842                .key
13843                .clone();
13844            let outside_key = snapshot
13845                .entities
13846                .iter()
13847                .find(|entity| entity.key.path() == outside_path)
13848                .expect("discovered the outside repo")
13849                .key
13850                .clone();
13851
13852            core.refresh(&[selected_key.clone(), outside_key.clone()]);
13853            let settled = core.settle();
13854            let outside_before = format!(
13855                "{:?}",
13856                settled
13857                    .entities
13858                    .iter()
13859                    .find(|entity| entity.key == outside_key)
13860                    .expect("outside entity present")
13861            );
13862
13863            core.rederive_default_branches(std::slice::from_ref(&selected_key));
13864            let settled = core.settle();
13865
13866            let selected_after = settled
13867                .entities
13868                .iter()
13869                .find(|entity| entity.key == selected_key)
13870                .expect("selected entity present");
13871            assert_eq!(
13872                default_branch_name(selected_after),
13873                Some("origin/trunk".to_string()),
13874                "the rederive must have reached the remote's own current, differing answer"
13875            );
13876
13877            let after_tracking = rev_parse(&selected_path, "refs/remotes/origin/main");
13878            assert_eq!(
13879                before_tracking, after_tracking,
13880                "a rederive must never fetch: the remote-tracking ref must not have moved \
13881                 even though the remote gained a new commit"
13882            );
13883
13884            let outside_after = format!(
13885                "{:?}",
13886                settled
13887                    .entities
13888                    .iter()
13889                    .find(|entity| entity.key == outside_key)
13890                    .expect("outside entity present")
13891            );
13892            assert_eq!(
13893                outside_before, outside_after,
13894                "a row outside the rederive's own keys must be left exactly as it was, not \
13895                 only on its default_branch cell"
13896            );
13897        }
13898    }
13899
13900    // =====================================================================================
13901    // `set_exclusions`: `[[repo]]`'s `exclude` re-applied live, with no rebuild and no
13902    // rediscovery, per repo-management.md's "Writing config".
13903    // =====================================================================================
13904
13905    /// The live half: a row already in the table becomes excluded, and is subtracted from
13906    /// `operable_count`, without a rebuilt `Core` and without a Generation of any kind.
13907    #[test]
13908    fn set_exclusions_excludes_a_row_already_in_the_table_with_no_rebuild() {
13909        let dir = tempfile::tempdir().expect("temp dir");
13910        let root = root_of(&dir);
13911        let repo = root.join("repo");
13912        init_repo_with_a_commit(&repo);
13913
13914        let core = Core::start_discovered(spec(vec![root]));
13915        let snapshot = core.settle();
13916        let key = snapshot.entities[0].key.clone();
13917        let generation_before = snapshot.generation;
13918        assert!(
13919            !snapshot.entities[0].excluded,
13920            "nothing excludes it to start with"
13921        );
13922        assert_eq!(core.operable_count(std::slice::from_ref(&key)), 1);
13923
13924        core.set_exclusions(&[RepoOverride {
13925            path: repo.clone(),
13926            default_branch: None,
13927            excluded: true,
13928        }]);
13929
13930        let after = core.snapshot();
13931        assert!(
13932            after.entities[0].excluded,
13933            "the row the write named is excluded in the very next snapshot"
13934        );
13935        assert_eq!(
13936            core.operable_count(&[key]),
13937            0,
13938            "an excluded row is subtracted from what an operation may reach"
13939        );
13940        assert_eq!(
13941            after.generation, generation_before,
13942            "re-applying an operate-time filter must start no Generation of its own"
13943        );
13944    }
13945
13946    /// The other direction: dropping the entry clears the flag, so a row ignored and shown
13947    /// again in one session ends where it started.
13948    #[test]
13949    fn set_exclusions_clears_the_flag_when_the_entry_is_gone() {
13950        let dir = tempfile::tempdir().expect("temp dir");
13951        let root = root_of(&dir);
13952        let repo = root.join("repo");
13953        init_repo_with_a_commit(&repo);
13954
13955        let core = Core::start_discovered(spec_with_overrides(
13956            vec![root],
13957            vec![RepoOverride {
13958                path: repo.clone(),
13959                default_branch: None,
13960                excluded: true,
13961            }],
13962        ));
13963        assert!(
13964            core.settle().entities[0].excluded,
13965            "the starting override excludes it"
13966        );
13967
13968        core.set_exclusions(&[]);
13969
13970        assert!(
13971            !core.snapshot().entities[0].excluded,
13972            "removing the entry unexcludes the row in the very next snapshot"
13973        );
13974    }
13975
13976    /// The boundary the specification draws around the live half: `exclude` re-applies and
13977    /// `default_branch` does not, because one is an operate-time filter and the other is a
13978    /// probe input. A `set_exclusions` that swapped the whole `[[repo]]` reading in would
13979    /// move both, which is what this refuses.
13980    #[test]
13981    fn set_exclusions_moves_exclude_alone_and_never_the_default_branch_override() {
13982        let dir = tempfile::tempdir().expect("temp dir");
13983        let root = root_of(&dir);
13984        let repo = root.join("repo");
13985        init_repo_with_a_commit(&repo);
13986        crate::test_support::git(&repo, &["branch", "trunk"]);
13987
13988        let core = Core::start_discovered(spec(vec![root]));
13989        let key = core.settle().entities[0].key.clone();
13990        core.refresh(std::slice::from_ref(&key));
13991        let before = format!("{:?}", core.settle().entities[0].default_branch.settled());
13992
13993        core.set_exclusions(&[RepoOverride {
13994            path: repo.clone(),
13995            default_branch: Some("trunk".to_string()),
13996            excluded: true,
13997        }]);
13998        core.refresh(&[key]);
13999        core.settle();
14000
14001        let after = core.snapshot();
14002        assert!(after.entities[0].excluded, "exclude took effect");
14003        assert_eq!(
14004            format!("{:?}", after.entities[0].default_branch.settled()),
14005            before,
14006            "a default_branch override reaches a session only through a rebuilt Core"
14007        );
14008    }
14009
14010    // =====================================================================================
14011    // `record_own_work`: the receipt a Management operation leaves, docs/spec/repo-management.md
14012    // =====================================================================================
14013
14014    /// One receipt per named row, and the shape the caller never gets to choose: `running` is
14015    /// `None`, `skip` is `None` (a refusal is not an excluded row), and there is
14016    /// exactly one step, because such an operation is one act rather than an ordered list.
14017    #[test]
14018    fn record_own_work_leaves_one_receipt_per_row_it_names_and_none_elsewhere() {
14019        let dir = tempfile::tempdir().expect("temp dir");
14020        let root = root_of(&dir);
14021        init_repo_with_a_commit(&root.join("repo-a"));
14022        init_repo_with_a_commit(&root.join("repo-b"));
14023
14024        let core = Core::start_discovered(spec(vec![root]));
14025        let entities = core.settle().entities;
14026        let named = entities
14027            .iter()
14028            .find(|entity| &*entity.name == "repo-a")
14029            .expect("repo-a is discovered")
14030            .key
14031            .clone();
14032
14033        core.record_own_work(
14034            "ignore",
14035            &[(
14036                named.clone(),
14037                OwnWork::Refused(Arc::from("refused, already ignored")),
14038                Duration::from_millis(7),
14039            )],
14040        );
14041
14042        let after = core.snapshot().entities;
14043        let receipt = after
14044            .iter()
14045            .find(|entity| entity.key == named)
14046            .and_then(|entity| entity.last_action.clone())
14047            .expect("the row it named carries a receipt");
14048        assert_eq!(&*receipt.label, "ignore");
14049        assert!(
14050            !receipt.not_applicable(),
14051            "a refusal is not an excluded row"
14052        );
14053        assert!(receipt.running.is_none(), "the work is already done");
14054        assert_eq!(receipt.steps.len(), 1, "one act, not an ordered list");
14055        assert_eq!(&*receipt.steps[0].label, "ignore");
14056        assert_eq!(receipt.steps[0].elapsed, Duration::from_millis(7));
14057        assert!(receipt.steps[0].output.is_empty(), "nothing to quote");
14058        assert!(receipt.steps[0].elision.is_none());
14059        assert_eq!(
14060            receipt.steps[0].outcome,
14061            StepOutcome::OwnWork(OwnWork::Refused(Arc::from("refused, already ignored"))),
14062        );
14063        assert!(
14064            after
14065                .iter()
14066                .filter(|entity| entity.key != named)
14067                .all(|entity| entity.last_action.is_none()),
14068            "no row this did not name takes a receipt"
14069        );
14070    }
14071
14072    /// A key the table no longer holds is skipped rather than panicking or landing on the
14073    /// wrong row, the same fallback every key-addressed entry point here gives one: a `delete`
14074    /// whose Repo is already gone is exactly this case.
14075    #[test]
14076    fn record_own_work_skips_a_key_the_table_no_longer_holds() {
14077        let dir = tempfile::tempdir().expect("temp dir");
14078        let root = root_of(&dir);
14079        init_repo_with_a_commit(&root.join("repo-a"));
14080
14081        let core = Core::start_discovered(spec(vec![root]));
14082        let entities = core.settle().entities;
14083        let stranger = EntityKey::new(Arc::from(std::path::Path::new("/nowhere/at/all")));
14084
14085        core.record_own_work(
14086            "delete",
14087            &[(stranger, OwnWork::Did(Arc::from("gone")), Duration::ZERO)],
14088        );
14089
14090        assert!(
14091            core.snapshot()
14092                .entities
14093                .iter()
14094                .all(|entity| entity.last_action.is_none()),
14095            "an unknown key writes nothing anywhere"
14096        );
14097        assert_eq!(core.snapshot().entities.len(), entities.len());
14098    }
14099
14100    // =====================================================================================
14101    // `delete_risk`: the three facts repo-management.md's confirm gate names per Repo, read
14102    // rather than stubbed. Every repository here is built in a temp directory this test owns,
14103    // and no path comes from config, an environment variable or the working directory.
14104    // =====================================================================================
14105
14106    /// A Repo with all three: an uncommitted change, a commit no remote-tracking ref carries,
14107    /// and a linked Worktree pointing into it.
14108    #[test]
14109    fn delete_risk_reads_all_three_facts_the_confirm_gate_names() {
14110        let dir = tempfile::tempdir().expect("temp dir");
14111        let root = root_of(&dir);
14112        let repo = root.join("repo");
14113        init_repo_with_a_commit(&repo);
14114        fs::write(repo.join("uncommitted.txt"), "not staged\n").expect("write a stray file");
14115        crate::test_support::git(
14116            &repo,
14117            &["worktree", "add", "-b", "sidecar", "../sidecar-worktree"],
14118        );
14119
14120        let core = Core::start_discovered(spec(vec![root]));
14121        // Settled first, so the startup Generation's own phase C is no longer reading this
14122        // same repository while the line below reads it: two concurrent gix statuses over one
14123        // working tree is a race in the harness, not in `delete_risk`.
14124        let key = core
14125            .settle()
14126            .entities
14127            .into_iter()
14128            .find(|entity| entity.kind == Kind::Repo)
14129            .expect("the Repo row is discovered")
14130            .key;
14131
14132        let risk = core.delete_risk(&key).expect("read the risk");
14133
14134        assert!(risk.uncommitted, "the stray file makes the tree dirty");
14135        assert!(
14136            risk.unpushed_commits > 0 && risk.unpushed_branches > 0,
14137            "no remote-tracking ref carries any of this Repo's commits, got {risk:?}"
14138        );
14139        assert_eq!(
14140            risk.linked_worktrees, 1,
14141            "the one linked Worktree pointing into this Repo is counted, got {risk:?}"
14142        );
14143    }
14144
14145    /// The `uncommitted` field's own range, one position at a time, because the composition
14146    /// behind it folds four separate reads: a modified tracked file, a deleted tracked file,
14147    /// an untracked file, and a staged change. Each gets a repository of its own with nothing
14148    /// else wrong with it, so narrowing the composition to any one of the four fails here
14149    /// rather than passing on whichever position a single fixture happened to sample.
14150    #[test]
14151    fn every_kind_of_work_that_is_not_in_a_commit_makes_the_gate_say_uncommitted() {
14152        for kind in ["modified", "deleted", "untracked", "staged"] {
14153            let dir = tempfile::tempdir().expect("temp dir");
14154            let root = root_of(&dir);
14155            let repo = root.join("repo");
14156            init_repo_with_a_commit(&repo);
14157            fs::write(repo.join("tracked.txt"), "first\n").expect("write a tracked file");
14158            crate::test_support::git(&repo, &["add", "tracked.txt"]);
14159            crate::test_support::git(&repo, &["commit", "-m", "add tracked"]);
14160            let sha = crate::test_support::head_sha(&repo);
14161            crate::test_support::git(&repo, &["update-ref", "refs/remotes/origin/main", &sha]);
14162
14163            match kind {
14164                "modified" => fs::write(repo.join("tracked.txt"), "second\n").expect("modify it"),
14165                "deleted" => fs::remove_file(repo.join("tracked.txt")).expect("delete it"),
14166                "untracked" => fs::write(repo.join("stray.txt"), "new\n").expect("write a stray"),
14167                "staged" => {
14168                    fs::write(repo.join("staged.txt"), "new\n").expect("write a new file");
14169                    crate::test_support::git(&repo, &["add", "staged.txt"]);
14170                }
14171                other => unreachable!("unhandled kind {other}"),
14172            }
14173
14174            let core = Core::start_discovered(spec(vec![root]));
14175            let key = core.settle().entities[0].key.clone();
14176
14177            let risk = core.delete_risk(&key).expect("read the risk");
14178
14179            assert!(
14180                risk.uncommitted,
14181                "a {kind} change is work that is not in a commit, got {risk:?}"
14182            );
14183        }
14184    }
14185
14186    /// The staged case, stated on its own as well as in the range above, because it is the
14187    /// one the dirty column deliberately answers `clean` to: `dirty_counts` compares the index
14188    /// against the working tree and never against `HEAD`, so a `git add` with no commit is
14189    /// invisible to it. Both readings are asserted here together, so a fix that widened
14190    /// `dirty_counts` instead of giving the gate its own read would fail this rather than
14191    /// silently change what the dirty column means.
14192    #[test]
14193    fn staged_work_reads_clean_to_the_dirty_column_and_uncommitted_to_the_delete_gate() {
14194        let dir = tempfile::tempdir().expect("temp dir");
14195        let root = root_of(&dir);
14196        let repo = root.join("repo");
14197        init_repo_with_a_commit(&repo);
14198        let sha = crate::test_support::head_sha(&repo);
14199        crate::test_support::git(&repo, &["update-ref", "refs/remotes/origin/main", &sha]);
14200        fs::write(repo.join("staged.txt"), "staged\n").expect("write a new file");
14201        crate::test_support::git(&repo, &["add", "staged.txt"]);
14202
14203        let core = Core::start_discovered(spec(vec![root]));
14204        let key = core.settle().entities[0].key.clone();
14205
14206        let opened = git::open_thread_safe(repo.as_path())
14207            .expect("open the repo")
14208            .to_thread_local();
14209        let dirty = git::dirty_counts(&opened, Arc::new(AtomicBool::new(false)))
14210            .expect("read the dirty counts");
14211        assert_eq!(
14212            dirty.total(),
14213            0,
14214            "the dirty column stays an index-to-worktree comparison, got {dirty:?}"
14215        );
14216
14217        let risk = core.delete_risk(&key).expect("read the risk");
14218        assert!(
14219            risk.uncommitted,
14220            "a Repo whose only work is staged must never be listed plainly, got {risk:?}"
14221        );
14222    }
14223
14224    /// The two unpushed quantities are two quantities: a fixture whose commit count and
14225    /// branch count differ, so transposing the pair in the composition changes both numbers
14226    /// rather than satisfying an inequality either way round.
14227    #[test]
14228    fn unpushed_commits_and_unpushed_branches_are_counted_into_their_own_fields() {
14229        let dir = tempfile::tempdir().expect("temp dir");
14230        let root = root_of(&dir);
14231        let repo = root.join("repo");
14232        init_repo_with_a_commit(&repo);
14233        let sha = crate::test_support::head_sha(&repo);
14234        crate::test_support::git(&repo, &["update-ref", "refs/remotes/origin/main", &sha]);
14235        for nth in 0..3 {
14236            fs::write(repo.join(format!("file-{nth}.txt")), "x\n").expect("write a file");
14237            crate::test_support::git(&repo, &["add", "."]);
14238            crate::test_support::git(&repo, &["commit", "-m", "unpushed"]);
14239        }
14240        crate::test_support::git(&repo, &["checkout", "."]);
14241
14242        let core = Core::start_discovered(spec(vec![root]));
14243        let key = core.settle().entities[0].key.clone();
14244
14245        let risk = core.delete_risk(&key).expect("read the risk");
14246
14247        assert_eq!(
14248            (risk.unpushed_commits, risk.unpushed_branches),
14249            (3, 1),
14250            "three commits on one branch, each in its own field, got {risk:?}"
14251        );
14252    }
14253
14254    /// The linked-Worktree count is git's own register, not the table's: a Worktree living
14255    /// outside the active Set's roots is never discovered, and deleting the Repo it is linked
14256    /// from orphans it just the same.
14257    #[test]
14258    fn a_linked_worktree_outside_the_sets_roots_is_still_counted_by_the_gate() {
14259        let dir = tempfile::tempdir().expect("temp dir");
14260        let base = root_of(&dir);
14261        let inside = base.join("inside");
14262        let outside = base.join("outside");
14263        fs::create_dir_all(&outside).expect("create the outside dir");
14264        let repo = inside.join("repo");
14265        init_repo_with_a_commit(&repo);
14266        crate::test_support::git(
14267            &repo,
14268            &["worktree", "add", "-b", "sidecar", "../../outside/sidecar"],
14269        );
14270        assert!(
14271            outside.join("sidecar").exists(),
14272            "the harness really created a linked Worktree outside the Set's roots"
14273        );
14274
14275        // Bounded by `inside` alone, so the Worktree is not a row in this Core's own table.
14276        let core = Core::start_discovered(spec(vec![inside]));
14277        let snapshot = core.settle();
14278        assert!(
14279            snapshot
14280                .entities
14281                .iter()
14282                .all(|entity| entity.kind != Kind::Worktree),
14283            "the Worktree is outside the roots and so is not discovered, got {:?}",
14284            snapshot.entities.iter().map(|e| e.kind).collect::<Vec<_>>()
14285        );
14286        let key = snapshot
14287            .entities
14288            .into_iter()
14289            .find(|entity| entity.kind == Kind::Repo)
14290            .expect("the Repo row is discovered")
14291            .key;
14292
14293        let risk = core.delete_risk(&key).expect("read the risk");
14294
14295        assert_eq!(
14296            risk.linked_worktrees, 1,
14297            "the gate must name the linked Worktree deleting this Repo would orphan, got {risk:?}"
14298        );
14299    }
14300
14301    /// The "listed plainly" case: nothing uncommitted, every commit already on a
14302    /// remote-tracking ref, and no linked Worktree at all. Asserted as its own test rather
14303    /// than left implied, since a gate that reports risk on every Repo is as wrong as one
14304    /// that reports it on none.
14305    #[test]
14306    fn delete_risk_on_a_clean_fully_pushed_repo_with_no_worktrees_reports_nothing() {
14307        let dir = tempfile::tempdir().expect("temp dir");
14308        let root = root_of(&dir);
14309        let repo = root.join("repo");
14310        init_repo_with_a_commit(&repo);
14311        let sha = crate::test_support::head_sha(&repo);
14312        crate::test_support::git(&repo, &["update-ref", "refs/remotes/origin/main", &sha]);
14313
14314        let core = Core::start_discovered(spec(vec![root]));
14315        let key = core.settle().entities[0].key.clone();
14316
14317        let risk = core.delete_risk(&key).expect("read the risk");
14318
14319        assert_eq!(
14320            risk,
14321            DeleteRisk {
14322                uncommitted: false,
14323                unpushed_commits: 0,
14324                unpushed_branches: 0,
14325                linked_worktrees: 0,
14326            }
14327        );
14328    }
14329
14330    // =====================================================================================
14331    // `worktree_admin_dir` and `linked_worktree_paths`: what `delete` needs to remove a
14332    // linked Worktree the way `git worktree remove` does, and to take a Repo's own linked
14333    // Worktrees with it. Every repository here is built in a temp directory this test owns.
14334    // =====================================================================================
14335
14336    /// The administrative directory named for a Worktree row is the one `git worktree list`
14337    /// stops naming once it is gone, proven by removing exactly that directory by hand.
14338    #[test]
14339    fn worktree_admin_dir_names_the_entry_git_worktree_list_forgets_once_it_is_removed() {
14340        let dir = tempfile::tempdir().expect("temp dir");
14341        let root = root_of(&dir);
14342        let repo = root.join("repo");
14343        init_repo_with_a_commit(&repo);
14344        let worktree = root.join("sidecar");
14345        crate::test_support::git(
14346            &repo,
14347            &[
14348                "worktree",
14349                "add",
14350                "-b",
14351                "sidecar",
14352                worktree.to_str().expect("utf8 path"),
14353            ],
14354        );
14355
14356        let core = Core::start_discovered(spec(vec![root]));
14357        let key = core
14358            .settle()
14359            .entities
14360            .into_iter()
14361            .find(|entity| entity.kind == Kind::Worktree)
14362            .expect("the Worktree row is discovered")
14363            .key;
14364
14365        let admin_dir = core.worktree_admin_dir(&key).expect("read the admin dir");
14366        fs::remove_dir_all(&admin_dir).expect("remove the admin dir by hand");
14367
14368        let reopened = git::open_thread_safe(&repo)
14369            .expect("reopen the repo")
14370            .to_thread_local();
14371        assert_eq!(
14372            git::linked_worktrees(&reopened).expect("count"),
14373            0,
14374            "removing the admin dir alone must be what git's own register stops naming"
14375        );
14376    }
14377
14378    /// A Worktree whose own path is not a git repository at all (the fixture for "the parent
14379    /// Repo is gone or unreadable"): the read errors rather than naming a directory that was
14380    /// never a Worktree's own administrative entry.
14381    #[test]
14382    fn worktree_admin_dir_errors_when_the_path_cannot_be_opened_as_a_repository() {
14383        let dir = tempfile::tempdir().expect("temp dir");
14384        let root = root_of(&dir);
14385        let not_a_repo = root.join("plain-directory");
14386        fs::create_dir_all(&not_a_repo).expect("create it");
14387
14388        let core = Core::start_discovered(spec(vec![root]));
14389        core.settle();
14390        let key = EntityKey::new(Arc::from(not_a_repo.as_path()));
14391
14392        assert!(core.worktree_admin_dir(&key).is_err());
14393    }
14394
14395    /// Every linked Worktree's own working directory, named by path rather than merely
14396    /// counted, for the Repo deletion cascade to remove.
14397    #[test]
14398    fn linked_worktree_paths_names_every_linked_worktrees_own_directory() {
14399        let dir = tempfile::tempdir().expect("temp dir");
14400        let root = root_of(&dir);
14401        let repo = root.join("repo");
14402        init_repo_with_a_commit(&repo);
14403        let first = root.join("first-worktree");
14404        let second = root.join("second-worktree");
14405        crate::test_support::git(
14406            &repo,
14407            &[
14408                "worktree",
14409                "add",
14410                "-b",
14411                "one",
14412                first.to_str().expect("utf8 path"),
14413            ],
14414        );
14415        crate::test_support::git(
14416            &repo,
14417            &[
14418                "worktree",
14419                "add",
14420                "-b",
14421                "two",
14422                second.to_str().expect("utf8 path"),
14423            ],
14424        );
14425
14426        let core = Core::start_discovered(spec(vec![root]));
14427        let key = core
14428            .settle()
14429            .entities
14430            .into_iter()
14431            .find(|entity| entity.kind == Kind::Repo)
14432            .expect("the Repo row is discovered")
14433            .key;
14434
14435        let mut paths = core
14436            .linked_worktree_paths(&key)
14437            .expect("read the linked worktree paths");
14438        paths.sort();
14439        let mut expected = vec![
14440            first.canonicalize().expect("canonicalize first"),
14441            second.canonicalize().expect("canonicalize second"),
14442        ];
14443        expected.sort();
14444
14445        assert_eq!(paths, expected);
14446    }
14447}