Skip to main content

repon_core/
core.rs

1//! `Core::start` and the threads and clocks it owns.
2//!
3//! See `docs/spec/core-api.md`'s "Threads and lifecycle" and "The entry points",
4//! [ADR 0006](https://github.com/paulchiu/repon/blob/main/docs/adr/0006-no-git-state-cache-session-state-by-name.md)
5//! and [ADR 0015](https://github.com/paulchiu/repon/blob/main/docs/adr/0015-the-core-owns-the-table.md).
6//!
7//! One dedicated thread runs the two second metadata poll and the thirty second
8//! Generation deadline sweep on a shared interval loop; probes go on rayon's global
9//! pool, one task per entity, which is infrastructure this crate already shares
10//! rather than a thread `Core` itself owns. `Core::start` is the only thing that
11//! spawns the dedicated thread, and `Drop` joins it, so a consumer never spawns one
12//! of its own. The dedicated thread's ticking source is an injected channel rather
13//! than a bare `thread::sleep`, which is what lets a test drive the poll and
14//! deadline cadence deterministically instead of sleeping and hoping.
15//!
16//! Discovery, both halves, re-runs at the head of every Generation
17//! ([refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md),
18//! [discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md)):
19//! an Entity a later walk does not find goes [`crate::entity::Presence::Vanished`]
20//! rather than disappearing, and one an abandoned walk cannot finish in time takes
21//! its Set out of this automatic path until a fresh `Core` starts over different
22//! roots. Phase C, status, is later work; this module already dispatches identity
23//! (`branch`), `default_branch`, Phase B's own comparison (`sync`) and Phase D's
24//! landing pass (`state`), the reads [`crate::git::head_shape`],
25//! [`crate::default_branch::resolve`], [`crate::git::resolve_sync`] and
26//! [`crate::landing::probe`] already do correctly, so the threading and
27//! supersession machinery has a real payload to move rather than a stub. Nothing
28//! here is written to or read from disk, so
29//! every `start` recomputes from scratch; the consequence is that first-frame
30//! speed has to come from progressive loading rather than a cache, which is
31//! future work this crate does not yet do (`start` blocks on its one discovery
32//! walk, at 20ms for the measured population).
33
34use std::collections::{HashMap, HashSet};
35use std::path::{Path, PathBuf};
36use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
37use std::sync::{Arc, Condvar, Mutex, OnceLock, RwLock};
38use std::thread::{self, JoinHandle};
39use std::time::{Duration, Instant};
40
41use crossbeam_channel::{Receiver, Sender, select};
42use rayon::iter::{IntoParallelIterator, ParallelIterator};
43
44use crate::base;
45use crate::cell::{Cell, Generation, Settled, Timestamp, Unknown};
46use crate::default_branch;
47use crate::discovery::{self, SetSpec};
48use crate::entity::{
49    ActionReceipt, DefaultBranch, DeleteRisk, DirtyCounts, EntityKey, EntityState, Head, Kind,
50    OwnWork, Presence, RunningStep, Skip, StepOutcome, StepResult, SyncState, WorktreeState,
51};
52use crate::environment;
53use crate::executor;
54use crate::filter::{Applicability, Filter, Partition};
55use crate::git;
56use crate::landing;
57#[cfg(any(test, feature = "test-util"))]
58use crate::liveness;
59use crate::patch_equivalence;
60use crate::poll;
61use crate::snapshot::Snapshot;
62
63/// Budget within which rows with names must be on screen at first frame
64/// (`docs/spec/refresh.md`'s "The first frame"). Nothing reads this yet; discovery's own
65/// wall time is the thing that would have to stay under it once first-frame timing is
66/// enforced rather than merely stated. Read only by
67/// `first_frame_budget_constants_match_the_spec_of_record`.
68#[allow(dead_code)] // read only by first_frame_budget_constants_match_the_spec_of_record
69const FIRST_FRAME_NAMES_BUDGET_MS: u64 = 50;
70
71/// Budget within which every cheap column (phase A/B) must be filled at first frame
72/// (`docs/spec/refresh.md`'s "The first frame"). Nothing reads this yet; the identity and
73/// comparison probes' combined wall time is what would have to stay under it. Read only by
74/// `first_frame_budget_constants_match_the_spec_of_record`.
75#[allow(dead_code)] // read only by first_frame_budget_constants_match_the_spec_of_record
76const FIRST_FRAME_CHEAP_COLUMNS_BUDGET_MS: u64 = 200;
77
78/// One Repo's config-level override, crossing from the consumer as plain data: no
79/// TOML type, no `~` expansion left undone. [config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md)
80/// owns parsing it; the core only ever receives the result, and resolves `path` to
81/// the git common dir itself, since opening a repository is the core's own work.
82///
83/// Keyed on `path` rather than the common dir `docs/spec/core-api.md` first named,
84/// which this amends: a Worktree and its parent Repo share one common dir, so only
85/// the entry's own path can outrank an entry reached by inheritance.
86#[derive(Debug, Clone)]
87pub struct RepoOverride {
88    pub path: PathBuf,
89    pub default_branch: Option<String>,
90    pub excluded: bool,
91}
92
93/// One command in an Action's ordered list, crossing from the consumer as plain data:
94/// `from_env` already resolved, so this crate never learns what that means
95/// ([config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md)'s
96/// "Actions", "Launchers" carries the same split for a Launcher's own argv). `shell`
97/// crosses over unresolved, because resolving it is `executor::run_step`'s own job,
98/// the same convention the `repon` crate's Launcher `shell = true` uses: with
99/// `shell` set, `argv` holds exactly one element, the whole command string, per
100/// [config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md)'s
101/// "Launchers". `env`'s pairs are applied after
102/// [`environment::environment`]'s own set-or-unset pairs, so a step's own `env` table
103/// overrides the guaranteed set exactly as [config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md)'s
104/// Launcher `env` field already does.
105#[derive(Debug, Clone)]
106pub struct Step {
107    pub argv: Vec<String>,
108    pub shell: bool,
109    /// Runs `argv` through `$SHELL -ic` rather than `$SHELL -c`, sourcing the user's own rc
110    /// file first ([config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md)'s
111    /// `interactive` key). Meaningless, and always `false`, unless `shell` is also set; the
112    /// consumer rejects `interactive` without `shell` at config load rather than this crate
113    /// silently ignoring it.
114    pub interactive: bool,
115    pub env: Vec<(String, String)>,
116}
117
118/// One Action fan-out, crossing from the consumer as plain data: no TOML type, no
119/// confirm gate, no palette. `label` is what a receipt's own label carries: the
120/// Action's name, or the ad hoc command string typed into the palette. `name` is
121/// `REPON_ACTION`'s own value and is `None` for an ad hoc run, exactly as
122/// [config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md)'s
123/// environment contract already treats a Launcher's absent `REPON_ACTION`. `when` is
124/// `None` for a built-in, a typed command or a configured entry that declares none, in
125/// which case every operable row runs; `Some` is what [`Core::run_action`] itself now
126/// decides the fan-out by, not only what a palette reports about it
127/// ([`docs/spec/actions.md`](https://github.com/paulchiu/repon/blob/main/docs/spec/actions.md)'s
128/// "The Selection and the gate").
129#[derive(Debug, Clone)]
130pub struct ActionSpec {
131    pub label: Arc<str>,
132    pub name: Option<Arc<str>>,
133    pub steps: Vec<Step>,
134    pub concurrency: u32,
135    pub when: Option<Filter>,
136}
137
138/// A [`RepoOverride`]'s probe-input half with its common dir already resolved, built
139/// once at `Core::start` and frozen for this `Core`'s life: `default_branch` is read
140/// while probing, so moving it needs the rediscovery a rebuild does
141/// ([repo-management.md](https://github.com/paulchiu/repon/blob/main/docs/spec/repo-management.md)'s
142/// "Writing config").
143#[derive(Debug, Clone)]
144struct ResolvedOverride {
145    path: PathBuf,
146    common_dir: PathBuf,
147    default_branch: Option<String>,
148}
149
150/// A [`RepoOverride`]'s `exclude` with its common dir already resolved, held apart from
151/// [`ResolvedOverride`] because it re-applies live: `exclude` decides only whether an
152/// operation may reach a row that is discovered, probed and listed either way
153/// ([config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md)'s "listed,
154/// never operated on"), so [`Core::set_exclusions`] replaces it with no rebuild at all.
155#[derive(Debug, Clone)]
156struct ResolvedExclusion {
157    path: PathBuf,
158    common_dir: PathBuf,
159    excluded: bool,
160}
161
162/// The two path fields [`find_entry`]'s match rule reads, so the rule itself is written
163/// once for both halves a `[[repo]]` entry resolves into.
164trait ResolvedEntry {
165    fn path(&self) -> &Path;
166    fn common_dir(&self) -> &Path;
167}
168
169impl ResolvedEntry for ResolvedOverride {
170    fn path(&self) -> &Path {
171        &self.path
172    }
173
174    fn common_dir(&self) -> &Path {
175        &self.common_dir
176    }
177}
178
179impl ResolvedEntry for ResolvedExclusion {
180    fn path(&self) -> &Path {
181        &self.path
182    }
183
184    fn common_dir(&self) -> &Path {
185        &self.common_dir
186    }
187}
188
189/// Opens every override's own `path` to learn its common dir, once, and splits the result
190/// into the half frozen for this `Core`'s life and the half [`Core::set_exclusions`] can
191/// replace live. Silently drops an entry that will not even open: a path that matches no
192/// discovered entity already gets its own warning on the consumer's side
193/// ([config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md#cross-key-validity)),
194/// and the core raises no second one for the same fact.
195fn resolve_entries(overrides: &[RepoOverride]) -> (Vec<ResolvedOverride>, Vec<ResolvedExclusion>) {
196    overrides
197        .iter()
198        .filter_map(|entry| {
199            let common_dir = git::common_dir_of(&entry.path).ok()?;
200            Some((
201                ResolvedOverride {
202                    path: entry.path.clone(),
203                    common_dir: common_dir.to_path_buf(),
204                    default_branch: entry.default_branch.clone(),
205                },
206                ResolvedExclusion {
207                    path: entry.path.clone(),
208                    common_dir: common_dir.to_path_buf(),
209                    excluded: entry.excluded,
210                },
211            ))
212        })
213        .unzip()
214}
215
216/// The entry that applies to an entity at `path` sharing `common_dir`: one naming
217/// `path` itself, or else the first declared entry sharing `common_dir`, which is
218/// what lets one entry cover a Repo and every Worktree attached to it while a
219/// Worktree named directly by its own path still beats the entry it would
220/// otherwise inherit ([config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md#per-repo-entries)).
221///
222/// A consequence worth stating rather than working around here: a Submodule's own
223/// common dir (`<parent common dir>/modules/<name>`, per `discovery.rs`'s
224/// `resolve`) is never equal to its parent's, so one entry naming the parent's path
225/// can never also exclude the parent's Submodules. The documented workaround is a
226/// Set's own `exclude` glob over the subtree, which keeps the Submodule out of
227/// discovery entirely rather than merely marking it excluded here.
228fn find_entry<'a, T: ResolvedEntry>(
229    entries: &'a [T],
230    path: &Path,
231    common_dir: &Path,
232) -> Option<&'a T> {
233    entries
234        .iter()
235        .find(|entry| entry.path() == path)
236        .or_else(|| {
237            entries
238                .iter()
239                .find(|entry| entry.common_dir() == common_dir)
240        })
241}
242
243/// Whether an entity at `path` sharing `common_dir` is excluded by `exclusions`: the one
244/// place the table's own [`EntityState::excluded`] is derived, read at entity creation, at
245/// [`Core::probe_now`] and again whenever [`Core::set_exclusions`] replaces the list.
246fn excluded_by(exclusions: &[ResolvedExclusion], path: &Path, common_dir: &Path) -> bool {
247    find_entry(exclusions, path, common_dir).is_some_and(|entry| entry.excluded)
248}
249
250/// Whether a Generation's dispatch probes `kind` at all: always for a Repo or a
251/// Worktree, only while `show_submodules` is on for a Submodule
252/// ([discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md)'s
253/// "Showing Submodules": "the flag decides... whether they are probed"). Exhaustive over
254/// `Kind`, so a fourth variant added later must be named here rather than silently
255/// falling into either branch.
256fn dispatches_kind(kind: Kind, show_submodules: bool) -> bool {
257    match kind {
258        Kind::Repo | Kind::Worktree => true,
259        Kind::Submodule => show_submodules,
260    }
261}
262
263/// The periodic fetch's own crossing data
264/// ([config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md)'s
265/// `[fetch]` table): whether it runs at all, its cadence, and how many run at once.
266/// Plain bounding data, not the mutating mechanism itself: `enabled: false` here is
267/// what keeps the cycle from running, not the absence of any machinery to run it.
268#[derive(Debug, Clone)]
269pub struct FetchSpec {
270    pub enabled: bool,
271    pub interval: Duration,
272    pub concurrency: usize,
273}
274
275/// The fast-forward-only auto-update's own crossing data
276/// ([config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md)'s
277/// `[auto_update]` table): whether it runs at all.
278///
279/// Plain bounding data, not the mutating mechanism, for the same reason [`FetchSpec`]
280/// is. Carries no interval or concurrency of its own, because it never ticks on its
281/// own clock; it rides the periodic fetch cycle [`FetchSpec`] already schedules, so
282/// `enabled: true` here is inert while `FetchSpec::enabled` is false.
283#[derive(Debug, Clone, Copy)]
284pub struct AutoUpdateSpec {
285    pub enabled: bool,
286}
287
288/// The most recent periodic-fetch cycle's own failures, read fresh through
289/// [`Core::fetch_failures`] the same way [`Core::discovery_warning`] and
290/// [`Core::vanished_count`] are: never latched, so a cycle where every fetch
291/// succeeds leaves this empty rather than carrying a stale failure forward from an
292/// earlier one. Per-repository independence is unaffected: one entry here is one
293/// repository `run_fetch_cycle` could not reach, never a reason another repository's
294/// own fetch was skipped.
295///
296/// Carries the path and the underlying `FetchError`'s own text, for
297/// a consumer's log; a consumer's own screen-facing warning is expected to surface
298/// only `failed.len()`, the precedent `warnings.rs`'s `OnRefreshFailed` already sets
299/// for never putting remote-supplied text on screen.
300#[derive(Debug, Clone, Default, PartialEq, Eq)]
301pub struct FetchFailures {
302    pub failed: Vec<(PathBuf, String)>,
303}
304
305/// One [`Core::attempt_auto_update`] result on a single Repo: the fast-forward-only
306/// auto-update's own five eligibility rules
307/// ([config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md)'s
308/// eligibility rule), flattened into one return type so the built-in `sync` action
309/// ([repo-management.md](https://github.com/paulchiu/repon/blob/main/docs/spec/repo-management.md))
310/// can report every ineligible reason to the user rather than a bare "did nothing".
311#[derive(Debug, Clone, PartialEq, Eq)]
312pub enum AutoUpdateAttempt {
313    /// The working tree or index carried a change of its own.
314    NotClean,
315    /// No branch, no remote, or a branch with no upstream configured.
316    NoUpstream,
317    /// Already level with its upstream: nothing to move.
318    NotBehind,
319    /// The local branch has a commit its upstream does not, so no fast-forward exists.
320    NotFastForward,
321    /// The branch fast-forwarded to its upstream.
322    Updated,
323    /// A git read or write failed partway through.
324    Failed(String),
325}
326
327/// Everything `Core::start` needs, handed as plain data. The core reads no file, no
328/// path and no environment variable: this is the whole crossing, per
329/// `docs/spec/core-api.md`'s "What crosses from config".
330#[derive(Debug, Clone)]
331pub struct CoreSpec {
332    pub set: SetSpec,
333    pub overrides: Vec<RepoOverride>,
334    pub poll_interval: Duration,
335    pub status_stale_after: Duration,
336    pub generation_deadline: Duration,
337    /// The initial reading of the show-submodules preference
338    /// ([discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md)'s
339    /// "Showing Submodules"): whether a dispatched Generation probes a Submodule at all.
340    /// Live-updatable afterwards through [`Core::set_show_submodules`], which is what lets
341    /// toggling it skip a Core rebuild and the rediscovery that would come with one.
342    pub show_submodules: bool,
343    /// The periodic fetch's own bounding data. See [`FetchSpec`].
344    pub fetch: FetchSpec,
345    /// The fast-forward-only auto-update's own bounding data. See [`AutoUpdateSpec`]:
346    /// inert while `fetch.enabled` is itself `false`
347    /// (`Warning::AutoUpdateWithoutFetch` is what tells a config author that
348    /// combination can never fire).
349    pub auto_update: AutoUpdateSpec,
350}
351
352/// One entity's in-flight probe: which Generation dispatched it, and the flag that
353/// generation owns to cancel it. [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)
354/// fixes one `Arc<AtomicBool>` per in-flight entity, passed as `should_interrupt`;
355/// `gix::interrupt::IS_INTERRUPTED` is a process-global static and is never used.
356struct InFlight {
357    generation: u64,
358    cancel: Arc<AtomicBool>,
359}
360
361/// The assembled entity table `Core` owns: the only place a Generation's per-cell
362/// supersession check happens, per ADR 0015.
363struct Table {
364    generation: u64,
365    discovered_at: Timestamp,
366    entities: Vec<EntityState>,
367    index: HashMap<EntityKey, usize>,
368    in_flight: HashMap<EntityKey, InFlight>,
369    /// When each still-live Generation's dispatch started, for the deadline sweep.
370    /// Pruned once nothing is left in flight for a Generation.
371    generation_started_at: HashMap<u64, Instant>,
372    /// Each entity's thread-safe repository handle, opened once by discovery.
373    /// A probe task clones the `Arc` (cheap, a refcount bump) and derives its own
374    /// `Repository` from it via `to_thread_local`, so no task ever shares a
375    /// `Repository` with another one; a missing entry (a Submodule, or a boundary
376    /// that would not open) falls back to opening fresh at probe time.
377    repos: HashMap<EntityKey, Arc<gix::ThreadSafeRepository>>,
378    /// Each entity's gitdir reading as of the previous metadata poll sweep, so the
379    /// next sweep can tell whether any of [`poll::POLLED_GITDIR_ENTRIES`] moved
380    /// ([refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
381    /// "The poll"). Absent for an entity the sweep has never yet reached, which is
382    /// what lets a first sweep record a baseline rather than reporting the whole
383    /// population as having just moved.
384    poll_fingerprints: HashMap<EntityKey, poll::GitdirFingerprint>,
385}
386
387/// A message the dedicated thread's control channel carries; distinct from a tick.
388enum ClockControl {
389    Pause,
390    Resume,
391    Shutdown,
392}
393
394/// A running core: its own table, its own dedicated thread, and the rayon pool it
395/// shares with the rest of the process for probes.
396///
397/// Construction is `start`, never a plain constructor, because it spawns; `Drop`
398/// joins every thread it spawned. The public entry points are exactly `start`,
399/// `refresh`, `probe_now`, `snapshot`, `try_settle`, `dismiss`, `pause`, `resume`,
400/// `discovery_warning` and `run_action` (see its own doc comment).
401pub struct Core {
402    table: Arc<RwLock<Table>>,
403    /// Resolved once at `start` and never mutated afterwards: `default_branch` is a probe
404    /// input, so moving it needs the rediscovery a rebuilt `Core` does
405    /// ([config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md#reload)).
406    overrides: Arc<Vec<ResolvedOverride>>,
407    /// The live `exclude` half of the same `[[repo]]` entries, replaced wholesale by
408    /// [`Core::set_exclusions`] with no rebuild and no rediscovery, the same shape
409    /// `show_submodules` already has: `exclude` decides only whether an operation may reach
410    /// a row that is discovered and listed either way, so it is an operate-time filter over
411    /// a table that is already correct
412    /// ([repo-management.md](https://github.com/paulchiu/repon/blob/main/docs/spec/repo-management.md)'s
413    /// "Writing config").
414    exclusions: Arc<RwLock<Vec<ResolvedExclusion>>>,
415    /// The Set `start` was given, retained so `refresh` can re-run discovery over
416    /// the same bounding specification at the head of every Generation
417    /// ([discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md),
418    /// [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)).
419    /// Immutable for the same reason `overrides` is: a Set's `roots` or globs
420    /// changing is a config reload, which re-derives a whole new `Core` rather
421    /// than mutating this one in place.
422    set: SetSpec,
423    /// Set once discovery abandons a walk, and never cleared for the life of this
424    /// `Core`: it takes the Set out of the automatic refresh path, since
425    /// re-running a thirty-second walk at the head of every Generation is not a
426    /// degraded mode worth paying for.
427    discovery_manual: Arc<AtomicBool>,
428    /// How long a re-run discovery walk may run before the still-walking warning
429    /// fires; real value is one second outside a test.
430    discovery_warn_after: Duration,
431    /// How long a re-run discovery walk may run before it is abandoned, in nanoseconds;
432    /// real value is [`discovery::ABANDON_AFTER`] outside a test. Shared and atomic so a
433    /// test can tighten it after `start`, rather than racing one deadline against both a
434    /// walk that must survive and a walk that must not.
435    discovery_abandon_after: Arc<AtomicU64>,
436    /// The live show-submodules preference a dispatched Generation reads: `true` once
437    /// [`Core::set_show_submodules`] last set it that way, `CoreSpec::show_submodules` until
438    /// then. Atomic and shared with every `RefreshHandles` clone so toggling it needs no
439    /// rebuild and dispatches nothing of its own
440    /// ([discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md)'s
441    /// "Showing Submodules": "toggling is instant, because nothing needs discovering").
442    show_submodules: Arc<AtomicBool>,
443    settle_gate: Arc<SettleGate>,
444    control: Sender<ClockControl>,
445    clock_thread: Option<JoinHandle<()>>,
446    /// Set by the dedicated thread's discovery-slow watcher if `start`'s one walk
447    /// ran a full second without finishing, and by a later re-run's own abandon
448    /// path. Read through [`Core::discovery_warning`], the UI's shared warning
449    /// slot's one entry point onto discovery, per
450    /// [discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md).
451    discovery_warning: Arc<Mutex<Option<String>>>,
452    /// Reset to zero at the start of every `refresh`, then incremented once per
453    /// distinct common dir among that Generation's dispatched entities whose
454    /// default-branch chain facts are actually computed, as opposed to reused from
455    /// another entity sharing the same common dir. Never persisted across
456    /// Generations, per [ADR 0006](https://github.com/paulchiu/repon/blob/main/docs/adr/0006-no-git-state-cache-session-state-by-name.md):
457    /// the memo cache itself lives only for the lifetime of one `refresh` call.
458    /// Read only by `default_branch_chain_reads_for_test`, which is what proves
459    /// [default-branch.md](https://github.com/paulchiu/repon/blob/main/docs/spec/default-branch.md)'s
460    /// per-common-dir memoisation actually ran rather than merely agreeing by
461    /// coincidence.
462    #[allow(dead_code)] // read only by default_branch_chain_reads_for_test
463    default_branch_chain_reads: Arc<AtomicUsize>,
464    /// The same counter as `default_branch_chain_reads`, for patch equivalence's
465    /// own expensive half ([`patch_equivalence::scan_default_branch`]) instead of
466    /// the default-branch chain's: reset to zero at the start of every `refresh`,
467    /// incremented once per distinct common dir whose default-branch commit
468    /// history is actually scanned, as opposed to reused from another entity
469    /// sharing the same common dir this Generation. Never persisted across
470    /// Generations, per [ADR 0006](https://github.com/paulchiu/repon/blob/main/docs/adr/0006-no-git-state-cache-session-state-by-name.md).
471    /// Read only by `patch_identity_reads_for_test`.
472    #[allow(dead_code)] // read only by patch_identity_reads_for_test
473    patch_identity_reads: Arc<AtomicUsize>,
474    /// The bound each actually-run [`patch_equivalence::scan_default_branch`] call
475    /// this Generation was passed, one entry per common dir it ran for, in the
476    /// order those scans ran; cleared at the start of every `refresh`. Recorded
477    /// from inside `patch_identities_for`'s `compute` closure, so this is the value
478    /// the production call site used, not a value a test recomputes independently.
479    /// Read only by `patch_scan_bounds_for_test`.
480    #[allow(dead_code)] // read only by patch_scan_bounds_for_test
481    patch_scan_bounds: Arc<Mutex<Vec<Option<gix::ObjectId>>>>,
482    /// Which Action run is admitted and what reaches its steps' children: see
483    /// [`ActionLifecycle`]. What [`Core::run_action`]'s own entry is guarded by, what
484    /// [`Core::action_running`] reads, and where [`Core::hold_action`],
485    /// [`Core::continue_action`] and [`Core::stop_action`] each find the control they
486    /// signal.
487    action_lifecycle: Arc<Mutex<ActionLifecycle>>,
488    /// Every key `refresh`'s own sequential dispatch loop iterated, in the order it iterated
489    /// them, cleared at the start of every call: this is dispatch order, not completion
490    /// order, recorded synchronously in the loop that decides it, before any `rayon::spawn`
491    /// closure ever runs. [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
492    /// "Scope and order" fixes dispatch order as the one dial phase C has; completion order
493    /// on a concurrent pool is a different, non-deterministic fact this field does not claim
494    /// to answer. Read only by `dispatch_log_for_test`.
495    #[allow(dead_code)] // read only by dispatch_log_for_test
496    dispatch_log: Arc<Mutex<Vec<EntityKey>>>,
497    /// Test-only synchronisation points, keyed by entity, letting a test hold the
498    /// dispatch loop's state and dirty probes open after that same entity's cheap
499    /// outcomes (branch, sync, default branch) have already landed on the table,
500    /// so [`refresh`]'s two applies can be proven independent with a blocking wait
501    /// rather than a sleep. Always present and normally empty: a Generation reads it
502    /// once per entity as it dispatches that entity, and one never registered here
503    /// resolves to nothing and proceeds exactly as if this field did not exist.
504    /// Registered and read only by the `_for_test` methods below.
505    #[allow(dead_code)] // populated and read only by tests
506    phase_c_gates: Arc<Mutex<HashMap<EntityKey, PhaseCGateHandle>>>,
507    /// The age past which a `Known` `dirty` or `state` cell reads Stale even though
508    /// nothing probed it again: `CoreSpec::status_stale_after`'s own copy, applied
509    /// inside [`Core::snapshot`] rather than by a background sweep, since
510    /// [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
511    /// "Staleness" rules out a global clock-driven one.
512    status_stale_after: Duration,
513    /// Every key the metadata poll's most recent sweep actually re-ran phases A
514    /// and B for, in the order it found them moved, cleared at the start of every
515    /// sweep. Read only by `poll_reprobed_for_test`, which is what proves a
516    /// sweep re-probes the moved entity alone rather than the whole population.
517    #[allow(dead_code)] // read only by poll_reprobed_for_test
518    poll_reprobed: Arc<Mutex<Vec<EntityKey>>>,
519    /// How many metadata-poll sweeps have run in total, whether or not any entity
520    /// had moved. Read only by `poll_sweep_count_for_test`, which is what proves a
521    /// real tick sent through the dedicated thread's own channel reaches the
522    /// sweep at all, distinct from `poll_reprobed` proving what a sweep that found
523    /// movement then did.
524    #[allow(dead_code)] // read only by poll_sweep_count_for_test
525    poll_sweep_count: Arc<AtomicUsize>,
526    /// How many periodic-fetch cycles have run in total, whether or not any
527    /// repository had a remote to fetch: the immediate first cycle plus one per
528    /// `fetch.interval` tick since. Read only by `fetch_cycle_count_for_test`,
529    /// which is what proves the immediate cycle ran without waiting on the
530    /// recurring cadence at all.
531    #[allow(dead_code)] // read only by fetch_cycle_count_for_test
532    fetch_cycle_count: Arc<AtomicUsize>,
533    /// The network's advertised default branch, per common dir, read from a fetch
534    /// handshake's own advertised HEAD alone
535    /// ([default-branch.md](https://github.com/paulchiu/repon/blob/main/docs/spec/default-branch.md)'s
536    /// "The network"): present only once the periodic fetch or
537    /// [`Core::rederive_default_branches`] has actually reached that remote.
538    /// Superseded there, never here on read; consulted by every default-branch
539    /// probe this crate runs, so an answer landed by one persists across every
540    /// later Generation for the life of this `Core`, which is what "supersedes
541    /// the local one for that session" means: never written back to any
542    /// reference, and gone the moment this `Core` is dropped, per ADR 0012.
543    network_default_branch: Arc<Mutex<HashMap<PathBuf, Arc<str>>>>,
544    /// The most recently completed periodic-fetch cycle's own failures, replaced
545    /// wholesale by [`run_fetch_cycle`] every time it runs. Read through
546    /// [`Core::fetch_failures`].
547    fetch_failures: Arc<Mutex<FetchFailures>>,
548    /// Orders every spawned dispatch body this `Core` starts; see
549    /// [`DispatchTurnstile`].
550    turnstile: Arc<DispatchTurnstile>,
551    /// See [`DiscoveryGate`]. `None` on every production path.
552    discovery_gate: Option<DiscoveryGate>,
553    /// See [`ActionCompletionBoundary`]. Disarmed unless a test arms it, and off the
554    /// default build entirely.
555    #[cfg(test)]
556    action_completion_boundary: Arc<ActionCompletionBoundary>,
557}
558
559/// One entity's phase C test gate state, guarded by the paired [`Condvar`] stored
560/// alongside it in [`Core::phase_c_gates`].
561#[derive(Default)]
562struct PhaseCGate {
563    /// Set once this entity's cheap outcomes have been applied to the table.
564    cheap_landed: bool,
565    /// Set by a test once it has observed `cheap_landed` and wants phase C (and
566    /// D) to proceed.
567    may_proceed: bool,
568    /// Set once this entity's phase C/D outcomes have been applied to the table
569    /// and the settle gate decremented for it.
570    finished: bool,
571}
572
573/// A [`PhaseCGate`] shared between the dispatch loop and the `_for_test` methods
574/// that register, wait on and release it.
575type PhaseCGateHandle = Arc<(Mutex<PhaseCGate>, Condvar)>;
576
577/// The Action lifecycle's one owned value: the run admitted right now, if any, together
578/// with the reach into that run's steps' children.
579///
580/// Admission and completion are each one transition on this one value, so a run's control
581/// arrives and leaves with its admission rather than through a second write a later run can
582/// land between. Every critical section here is a read or a single field write, so the lock
583/// is never held across a wait on a child process, across git, or across anything that can
584/// panic and poison it.
585#[derive(Default)]
586struct ActionLifecycle {
587    /// The admitted run's own reach into its steps' children, `None` between runs: what
588    /// [`Core::hold_action`], [`Core::continue_action`] and [`Core::stop_action`] each look
589    /// up before doing anything, so all three are no-ops with no fan-out live. Deliberately
590    /// its own value rather than folded into `pause`/`resume`'s machinery, per
591    /// [ADR 0018](https://github.com/paulchiu/repon/blob/main/docs/adr/0018-an-action-is-a-fanout-of-pty-backed-steps.md)'s
592    /// own hold and stop verbs: the core is contractually not told why background work
593    /// stopped, and a step's child needs SIGSTOP/SIGTERM/SIGKILL, information `pause` must
594    /// never carry.
595    live: Option<Arc<executor::RunControl>>,
596}
597
598impl ActionLifecycle {
599    /// Admits a run and registers its control together, or refuses because one is already
600    /// live: only one fan-out runs at a time, per
601    /// [ADR 0018](https://github.com/paulchiu/repon/blob/main/docs/adr/0018-an-action-is-a-fanout-of-pty-backed-steps.md)'s
602    /// "One Action runs at a time".
603    fn admit(&mut self, control: Arc<executor::RunControl>) -> bool {
604        if self.live.is_some() {
605            return false;
606        }
607        self.live = Some(control);
608        true
609    }
610
611    /// Releases the admitted run, the last thing [`RunCompletion`] does.
612    fn complete(&mut self) {
613        self.live = None;
614    }
615}
616
617/// Releases a finished run's admission however its fan-out thread ends, so a panic past the
618/// fan-out can never leave a `Core` reading that run as live for the rest of its life.
619///
620/// Dropped after the completion Generation has been dispatched, which is what orders the
621/// two: the next run is refused until this one has started the Generation it owes, so what
622/// that run cancels on the way in can never be a Generation the run it replaced has yet to
623/// dispatch.
624struct RunCompletion {
625    lifecycle: Arc<Mutex<ActionLifecycle>>,
626    /// See [`ActionCompletionBoundary`].
627    #[cfg(test)]
628    boundary: Arc<ActionCompletionBoundary>,
629}
630
631impl Drop for RunCompletion {
632    fn drop(&mut self) {
633        // Nothing parks here unless a test armed this boundary.
634        #[cfg(test)]
635        self.boundary.hold();
636        self.lifecycle.lock().unwrap().complete();
637    }
638}
639
640/// A park in the one statement between a completion dispatching its Generation and
641/// [`RunCompletion`] releasing the run, for a test.
642///
643/// Neither half of that ordering is observable from outside without holding the completion
644/// there: the two are adjacent statements, and a test racing them reads whichever it
645/// happened to catch. One per `Core` and disarmed until a test arms it, so a run nobody is
646/// watching reads one bool and carries on, and the whole affordance is gated off the
647/// default build.
648#[cfg(test)]
649#[derive(Default)]
650pub(crate) struct ActionCompletionBoundary {
651    state: Mutex<BoundaryState>,
652    changed: Condvar,
653}
654
655/// [`ActionCompletionBoundary`]'s own state, guarded by its `Condvar`.
656#[cfg(test)]
657#[derive(Default)]
658struct BoundaryState {
659    /// Set by a test before the run whose completion it wants held.
660    armed: bool,
661    /// Set by the completion that parked at an armed boundary.
662    reached: bool,
663    /// Set when the [`ArmedBoundary`] drops.
664    released: bool,
665}
666
667#[cfg(test)]
668impl ActionCompletionBoundary {
669    /// Holds the next completion to reach this boundary until the returned value drops. For
670    /// a test, before the run whose completion it wants held.
671    pub(crate) fn arm(self: &Arc<Self>) -> ArmedBoundary {
672        self.state.lock().unwrap().armed = true;
673        ArmedBoundary(Arc::clone(self))
674    }
675
676    /// Parks a completion here while an armed boundary holds it.
677    fn hold(&self) {
678        let mut state = self.state.lock().unwrap();
679        if !state.armed {
680            return;
681        }
682        state.reached = true;
683        self.changed.notify_all();
684        let (state, expiry) = self
685            .changed
686            .wait_timeout_while(state, liveness::BACKSTOP, |state| !state.released)
687            .unwrap();
688        drop(state);
689        if expiry.timed_out() {
690            liveness::expired(
691                liveness::BACKSTOP,
692                "a test to release the Action completion boundary",
693                "",
694            );
695        }
696    }
697}
698
699/// One armed [`ActionCompletionBoundary`], released when this drops so an assertion failing
700/// inside the window reports itself rather than leaving a completion parked for
701/// [`liveness::BACKSTOP`].
702#[cfg(test)]
703pub(crate) struct ArmedBoundary(Arc<ActionCompletionBoundary>);
704
705#[cfg(test)]
706impl ArmedBoundary {
707    /// Blocks until a completion has parked at this boundary. For a test.
708    pub(crate) fn wait_until_reached(&self) {
709        let (state, expiry) = self
710            .0
711            .changed
712            .wait_timeout_while(self.0.state.lock().unwrap(), liveness::BACKSTOP, |state| {
713                !state.reached
714            })
715            .unwrap();
716        drop(state);
717        if expiry.timed_out() {
718            liveness::expired(
719                liveness::BACKSTOP,
720                "a completion to reach the Action completion boundary",
721                "",
722            );
723        }
724    }
725}
726
727#[cfg(test)]
728impl Drop for ArmedBoundary {
729    fn drop(&mut self) {
730        let mut state = self.0.state.lock().unwrap();
731        state.released = true;
732        self.0.changed.notify_all();
733    }
734}
735
736impl Core {
737    /// Spawns the dedicated thread, starts the first discovery walk on a thread of
738    /// its own, and returns a running core at once.
739    ///
740    /// The table it returns is empty: discovery lands its rows afterwards, which is what
741    /// lets a consumer claim the terminal and draw a first frame without waiting out a
742    /// walk (refresh.md's "The first frame"). That walk is refresh.md's "Startup"
743    /// Generation as well, dispatched over what it found, so a consumer probes its rows
744    /// by starting a `Core` and never by asking for a second walk of the same tree.
745    /// [`Self::try_settle`] waits for it the way it waits for any other Generation.
746    pub fn start(spec: CoreSpec) -> Core {
747        Self::start_watched(spec).core
748    }
749
750    /// [`Self::start`], keeping the handles `start_internal` hands back.
751    fn start_watched(spec: CoreSpec) -> StartForTest {
752        let interval = spec.poll_interval.max(Duration::from_nanos(1));
753        let ticks = crossbeam_channel::tick(interval);
754        let alive = Arc::new(AtomicBool::new(true));
755        let fetch_start = FetchStart {
756            enabled: spec.fetch.enabled,
757            concurrency: spec.fetch.concurrency.max(1),
758            ticks: if spec.fetch.enabled {
759                crossbeam_channel::tick(spec.fetch.interval.max(Duration::from_nanos(1)))
760            } else {
761                crossbeam_channel::never()
762            },
763        };
764        start_internal(
765            spec,
766            Duration::from_secs(1),
767            discovery::ABANDON_AFTER,
768            ticks,
769            fetch_start,
770            alive,
771            None,
772        )
773    }
774
775    /// [`Self::start`], blocked until the first discovery has landed on the table.
776    ///
777    /// For a test, and for nothing else: `start` returns against an empty table
778    /// now, so a test that reads the table straight afterwards needs this
779    /// rendezvous. It is a join on the discovery thread rather than a poll or a
780    /// sleep, so it carries no deadline of its own.
781    ///
782    /// Gated behind `test-util` (on by default under `cfg(test)` for this crate's own
783    /// tests) so a test-only affordance never ships on the default published surface,
784    /// per [ADR 0021](https://github.com/paulchiu/repon/blob/main/docs/adr/0021-a-release-is-what-the-tag-pipeline-publishes.md).
785    #[cfg(any(test, feature = "test-util"))]
786    pub fn start_discovered(spec: CoreSpec) -> Core {
787        let mut started = Self::start_watched(spec);
788        if let Some(handle) = started.initial_discovery.take() {
789            handle
790                .join()
791                .expect("the first discovery thread should not panic");
792        }
793        started.core
794    }
795
796    /// Starts a new Generation, dispatching a probe for every key in `order` that
797    /// the table already knows, in that order. An empty or unknown-only `order`
798    /// dispatches nothing and carries no other meaning. Returns immediately: the
799    /// probes run on rayon's global pool.
800    pub fn refresh(&self, order: &[EntityKey]) -> Generation {
801        self.refresh_handles().dispatch(order)
802    }
803
804    /// Starts a new Generation over every entity this Generation's own discovery
805    /// leaves in the table, in discovery order.
806    ///
807    /// A Set switch's Generation, per
808    /// [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
809    /// "Switching Set": the caller has just discarded the old Set's rows, so it has no
810    /// order to compute and no keys to name. Unlike [`Self::refresh`], which resolves
811    /// the order the caller handed it, this resolves the order after discovery has run,
812    /// which is what lets it cover rows the caller could not have named. Startup needs
813    /// none of this: [`Self::start`]'s own walk is that Generation. Returns
814    /// immediately, the same way `refresh` does.
815    pub fn refresh_all(&self) -> Generation {
816        self.refresh_handles().dispatch_over_everything()
817    }
818
819    /// Re-derives `default_branch` alone for every key in `keys` already known to
820    /// the table, in a fresh Generation, per
821    /// [default-branch.md](https://github.com/paulchiu/repon/blob/main/docs/spec/default-branch.md)'s
822    /// "A user-triggered re-derive over the Selection ... on demand" and
823    /// [keybindings.md](https://github.com/paulchiu/repon/blob/main/docs/spec/keybindings.md)'s
824    /// `b`. Unlike [`Self::refresh`], this never re-runs discovery and never
825    /// touches any other cell on any entity, known or not: a key outside `keys`
826    /// is left exactly as it was, and so is every cell but `default_branch` on a
827    /// key inside it.
828    ///
829    /// Runs the local chain exactly as any other refresh would, then a
830    /// handshake-only network probe per distinct common dir among `keys`
831    /// (`fetch::probe_remote_head`): no pack requested and no ref updated, which is
832    /// "without fetching". Its answer, once landed on `network_default_branch`, is
833    /// what `supersede_with_network` applies here and on every later probe of that
834    /// common dir for the life of this `Core`.
835    ///
836    /// Returns immediately, which is also why a stalled remote has nothing to end
837    /// it here: the deadline sweep is per entity, not per cell, so this is on the
838    /// open-questions register rather than closed. The probes run on a plain thread, never rayon's
839    /// global pool, for the reason `fetch::run_bounded`'s own doc comment gives
840    /// the periodic fetch's identical choice: a remote blocked on the network
841    /// for seconds must never take a worker away from the pool every other
842    /// probe shares.
843    pub fn rederive_default_branches(&self, keys: &[EntityKey]) -> Generation {
844        let generation = {
845            let mut table = self.table.write().unwrap();
846            table.generation += 1;
847            Generation::new(table.generation)
848        };
849
850        let dispatched: Vec<RederiveCandidate> = {
851            let mut table = self.table.write().unwrap();
852            let mut dispatched = Vec::new();
853            for key in keys {
854                let Some(&idx) = table.index.get(key) else {
855                    continue;
856                };
857                table.entities[idx].default_branch.begin_probe();
858                let common_dir = Arc::clone(&table.entities[idx].common_dir);
859                let override_branch = find_entry(&self.overrides, key.path(), &common_dir)
860                    .and_then(|entry| entry.default_branch.clone());
861                let repo = table.repos.get(key).cloned();
862                let kind = table.entities[idx].kind;
863                dispatched.push(RederiveCandidate {
864                    key: key.clone(),
865                    path: key.path().to_path_buf(),
866                    common_dir,
867                    repo,
868                    override_branch,
869                    kind,
870                });
871            }
872            dispatched
873        };
874
875        if dispatched.is_empty() {
876            return generation;
877        }
878
879        begin_probes_owed(&self.settle_gate, dispatched.len());
880
881        let table = Arc::clone(&self.table);
882        let settle_gate = Arc::clone(&self.settle_gate);
883        let network_default_branch = Arc::clone(&self.network_default_branch);
884        thread::spawn(move || {
885            let common_dirs: HashSet<Arc<Path>> = dispatched
886                .iter()
887                .map(|candidate| Arc::clone(&candidate.common_dir))
888                .collect();
889            probe_network_default_branches(&common_dirs, &network_default_branch);
890
891            // Scoped to this one call, never shared with a concurrent `refresh`'s own
892            // memo: the local chain's own per-common-dir facts are cheap enough
893            // (`default-branch.md`'s "about 20ms") that a fresh cache here costs this
894            // call nothing a shared one would have saved.
895            let chain_cache: ChainFactsCache = Mutex::new(HashMap::new());
896            let chain_reads = AtomicUsize::new(0);
897            let never_cancelled = AtomicBool::new(false);
898
899            for candidate in dispatched {
900                let RederiveCandidate {
901                    key,
902                    path,
903                    common_dir,
904                    repo,
905                    override_branch,
906                    kind,
907                } = candidate;
908                let network_branch = network_branch_for(&network_default_branch, &common_dir);
909                let resolution = probe_default_branch_memoised(
910                    &path,
911                    repo.as_deref(),
912                    &common_dir,
913                    DefaultBranchHints {
914                        override_branch: override_branch.as_deref(),
915                        network_branch: network_branch.as_deref(),
916                    },
917                    kind,
918                    &never_cancelled,
919                    &ChainFactsMemo {
920                        cache: &chain_cache,
921                        reads: &chain_reads,
922                    },
923                );
924                {
925                    let mut table = table.write().unwrap();
926                    if let (Some(&idx), Some(resolution)) = (table.index.get(&key), resolution) {
927                        table.entities[idx].apply_default_branch_resolution(generation, resolution);
928                    }
929                }
930                complete_one(&settle_gate);
931            }
932        });
933
934        generation
935    }
936
937    /// Clones out every `Arc` a Generation's dispatch reads, plus the plain data
938    /// ([`SetSpec`], the two durations) it cannot share by reference: a handful of
939    /// refcount bumps, never a copy of the table itself. This is what lets
940    /// [`run_action`](Core::run_action)'s completion, which runs on a plain thread
941    /// this `Core` does not own and outlives the `&self` borrow that started it,
942    /// start the one normal Generation `docs/spec/actions.md`'s "Refreshing around a
943    /// run" promises through the exact same [`RefreshHandles::dispatch`] `refresh`
944    /// itself calls, rather than a second, drifting copy of its body.
945    fn refresh_handles(&self) -> RefreshHandles {
946        RefreshHandles {
947            table: Arc::clone(&self.table),
948            overrides: Arc::clone(&self.overrides),
949            exclusions: Arc::clone(&self.exclusions),
950            set: self.set.clone(),
951            discovery_manual: Arc::clone(&self.discovery_manual),
952            discovery_warn_after: self.discovery_warn_after,
953            discovery_abandon_after: Arc::clone(&self.discovery_abandon_after),
954            discovery_warning: Arc::clone(&self.discovery_warning),
955            show_submodules: Arc::clone(&self.show_submodules),
956            settle_gate: Arc::clone(&self.settle_gate),
957            default_branch_chain_reads: Arc::clone(&self.default_branch_chain_reads),
958            patch_identity_reads: Arc::clone(&self.patch_identity_reads),
959            patch_scan_bounds: Arc::clone(&self.patch_scan_bounds),
960            dispatch_log: Arc::clone(&self.dispatch_log),
961            phase_c_gates: Arc::clone(&self.phase_c_gates),
962            network_default_branch: Arc::clone(&self.network_default_branch),
963            turnstile: Arc::clone(&self.turnstile),
964            discovery_gate: self.discovery_gate.clone(),
965        }
966    }
967
968    /// Re-probes one entity synchronously against the table's current Generation,
969    /// which is what a Launcher return needs before a normal Generation starts.
970    /// Inserts a fresh entity for an unknown key rather than panicking, since a
971    /// caller can otherwise only reach this with a key `snapshot` just handed it.
972    pub fn probe_now(&self, key: &EntityKey) -> EntityState {
973        // An `Arc` rather than a bare flag: [`probe_status`] hands gix an owned clone of
974        // its cancel token the way `refresh`'s own dispatch does, and every other probe
975        // below still takes it as `&AtomicBool` through the same deref coercion.
976        let never_cancelled = Arc::new(AtomicBool::new(false));
977        let (cached_repo, common_dir_hint, probes_state, probes_base, kind) = {
978            let table = self.table.read().unwrap();
979            let repo = table.repos.get(key).cloned();
980            let common_dir = table
981                .index
982                .get(key)
983                .map(|&idx| Arc::clone(&table.entities[idx].common_dir));
984            // An unknown key has no entity yet to ask, and falls back to `false`,
985            // matching the fallback insert below: a freshly inserted `Kind::Repo`
986            // entity's `state` is `NotApplicable` from construction too, and its
987            // `base` is not (only a Submodule's is).
988            let probes_state = table
989                .index
990                .get(key)
991                .map(|&idx| table.entities[idx].probes_state())
992                .unwrap_or(false);
993            let probes_base = table
994                .index
995                .get(key)
996                .map(|&idx| table.entities[idx].probes_base())
997                .unwrap_or(true);
998            // Same fallback as `probes_state`/`probes_base`: an unknown key falls back to
999            // the `Kind::Repo` the insert below actually gives it.
1000            let kind = table
1001                .index
1002                .get(key)
1003                .map(|&idx| table.entities[idx].kind)
1004                .unwrap_or(Kind::Repo);
1005            (repo, common_dir, probes_state, probes_base, kind)
1006        };
1007        let common_dir_hint = common_dir_hint.unwrap_or_else(|| Arc::from(key.path().join(".git")));
1008        let override_branch = find_entry(&self.overrides, key.path(), &common_dir_hint)
1009            .and_then(|entry| entry.default_branch.clone());
1010        let excluded = excluded_by(
1011            &self.exclusions.read().unwrap(),
1012            key.path(),
1013            &common_dir_hint,
1014        );
1015
1016        let branch_outcome =
1017            probe_branch(key.path(), cached_repo.as_deref(), kind, &never_cancelled);
1018        let sync_outcome = probe_sync(
1019            key.path(),
1020            cached_repo.as_deref(),
1021            branch_outcome.as_ref().map(|(settled, ..)| settled),
1022            kind,
1023            &never_cancelled,
1024        );
1025        let default_branch_outcome = probe_default_branch(
1026            key.path(),
1027            cached_repo.as_deref(),
1028            DefaultBranchHints {
1029                override_branch: override_branch.as_deref(),
1030                network_branch: network_branch_for(&self.network_default_branch, &common_dir_hint)
1031                    .as_deref(),
1032            },
1033            kind,
1034            &never_cancelled,
1035        );
1036        let base_outcome = if probes_base {
1037            probe_base(
1038                key.path(),
1039                cached_repo.as_deref(),
1040                branch_outcome.as_ref().map(|(settled, ..)| settled),
1041                default_branch_outcome.as_ref().map(|r| &r.settled),
1042                &never_cancelled,
1043            )
1044        } else {
1045            None
1046        };
1047        let state_outcome = if probes_state {
1048            // A single synchronous re-probe shares nothing with any Generation's
1049            // dispatch, so a throwaway cache is exactly as much sharing as this
1050            // one call needs. Its bound gate has exactly one entity to hear
1051            // from: itself, so it never actually waits.
1052            let patch_cache: PatchIdentityCache = Mutex::new(HashMap::new());
1053            let patch_reads = AtomicUsize::new(0);
1054            let patch_scan_bounds = Mutex::new(Vec::new());
1055            let gate = BoundGate::new(1);
1056            let mut report = GateReport::new(&gate);
1057            let memo = PatchEquivalenceMemo {
1058                cache: &patch_cache,
1059                reads: &patch_reads,
1060                scan_bounds: &patch_scan_bounds,
1061            };
1062            probe_worktree_state(
1063                key.path(),
1064                cached_repo.as_deref(),
1065                default_branch_outcome.as_ref().map(|r| &r.settled),
1066                &common_dir_hint,
1067                &never_cancelled,
1068                &memo,
1069                &mut report,
1070            )
1071        } else {
1072            None
1073        };
1074        let dirty_outcome =
1075            probe_status(key.path(), cached_repo.as_deref(), kind, &never_cancelled);
1076
1077        let mut table = self.table.write().unwrap();
1078        let generation = Generation::new(table.generation);
1079        let idx = match table.index.get(key).copied() {
1080            Some(idx) => idx,
1081            None => {
1082                let name = display_name(key.path());
1083                table.entities.push(EntityState::new(
1084                    key.clone(),
1085                    name,
1086                    common_dir_hint,
1087                    Kind::Repo,
1088                ));
1089                let idx = table.entities.len() - 1;
1090                table.index.insert(key.clone(), idx);
1091                idx
1092            }
1093        };
1094        table.entities[idx].excluded = excluded;
1095        if let Some((settled, in_progress, recent)) = branch_outcome {
1096            table.entities[idx].apply_branch_probe(generation, settled, in_progress, recent);
1097        }
1098        if let Some(settled) = sync_outcome {
1099            table.entities[idx].sync.settle(generation, settled);
1100        }
1101        if let Some(settled) = base_outcome {
1102            table.entities[idx].base.settle(generation, settled);
1103        }
1104        if let Some(resolution) = default_branch_outcome {
1105            table.entities[idx].apply_default_branch_resolution(generation, resolution);
1106        }
1107        if let Some(settled) = state_outcome {
1108            table.entities[idx].state.settle(generation, settled);
1109        }
1110        if let Some(settled) = dirty_outcome {
1111            table.entities[idx].dirty.settle(generation, settled);
1112        }
1113        table.entities[idx].clone()
1114    }
1115
1116    /// Clones the whole table now, without waiting for anything in flight. Ages
1117    /// every entity's `dirty` and `state` cells into Stale here, on the clone
1118    /// rather than the stored table, so a snapshot stays a pure read: the other
1119    /// staleness writer, poll evidence, does mutate the stored table, because a
1120    /// detected move is itself a fact worth keeping, but elapsed time is not.
1121    pub fn snapshot(&self) -> Snapshot {
1122        let table = self.table.read().unwrap();
1123        let mut entities = table.entities.clone();
1124        for entity in &mut entities {
1125            entity.age_status_cells(self.status_stale_after);
1126        }
1127        Snapshot {
1128            generation: Generation::new(table.generation),
1129            discovered_at: table.discovered_at,
1130            entities,
1131        }
1132    }
1133
1134    /// Blocks until nothing is in flight or `within` elapses, then returns a snapshot.
1135    /// The machine-readable consumer's whole loop.
1136    ///
1137    /// `Ok` is a table that actually settled. `Err` is the wait giving up, carrying the
1138    /// snapshot as it stood at that moment so a caller that means to degrade still has
1139    /// something to degrade with. The two are separate arms rather than one return value
1140    /// because they are separate facts: a half-populated table read as a settled one is a
1141    /// wrong answer, not a late one, and it reads as a defect several steps downstream with
1142    /// nothing left naming the wait.
1143    pub fn try_settle(&self, within: Duration) -> Result<Snapshot, Snapshot> {
1144        let (lock, cvar) = &*self.settle_gate;
1145        let guard = lock.lock().unwrap();
1146        let (guard, timeout) = cvar
1147            .wait_timeout_while(guard, within, |counts| !counts.is_settled())
1148            .unwrap();
1149        // Released before the snapshot below, which takes the table lock: holding both at
1150        // once is a lock order nothing else in this file takes.
1151        drop(guard);
1152        let snapshot = self.snapshot();
1153        if timeout.timed_out() {
1154            Err(snapshot)
1155        } else {
1156            Ok(snapshot)
1157        }
1158    }
1159
1160    /// Blocks until nothing is in flight, panicking once [`liveness::BACKSTOP`] expires.
1161    /// For a test.
1162    ///
1163    /// Takes no deadline, unlike [`Self::try_settle`], because every deadline this ever
1164    /// took was a number guessed against the machine its author had: the wait is on a
1165    /// liveness property ("the Generation I just dispatched lands"), which carries no
1166    /// wall-clock bound of its own, so the only honest bound is the shared backstop.
1167    /// A wait whose *number* is the claim ("nothing arrives within 200ms") is a different
1168    /// wait and belongs on [`Self::try_settle`], which reports an expiry rather than
1169    /// panicking on one.
1170    #[cfg(any(test, feature = "test-util"))]
1171    pub fn settle(&self) -> Snapshot {
1172        self.settle_within(liveness::BACKSTOP)
1173    }
1174
1175    /// [`Self::settle`] against an explicit deadline, so this crate's own tests can
1176    /// exercise the expiry path without waiting out a real backstop. The same seam
1177    /// `liveness::wait_within` gives its module.
1178    #[cfg(any(test, feature = "test-util"))]
1179    fn settle_within(&self, deadline: Duration) -> Snapshot {
1180        self.try_settle(deadline).unwrap_or_else(|_| {
1181            // Read out and released before the panic below: unwinding out of a held guard
1182            // poisons the gate, and every later `lock().unwrap()` on it, `Drop`'s included,
1183            // then panics on the way out and turns a named report into an abort.
1184            let (probes, dispatches) = {
1185                let counts = self.settle_gate.0.lock().unwrap();
1186                (counts.probes, counts.dispatches)
1187            };
1188            liveness::expired(
1189                deadline,
1190                "everything this Core has in flight to land",
1191                &format!("{probes} probe(s) and {dispatches} dispatch(es) still outstanding"),
1192            )
1193        })
1194    }
1195
1196    /// What deleting `key`'s working tree destroys, read fresh right now
1197    /// ([repo-management.md](https://github.com/paulchiu/repon/blob/main/docs/spec/repo-management.md)'s
1198    /// "The confirm gate"). Every one of the three is a git read rather than a fold over this
1199    /// entity's Cells or over the table: the gate is answering "what will accepting this
1200    /// destroy", a Cell carries whatever the last Generation left there, and the table is
1201    /// bounded by the active Set's roots, so a linked Worktree outside them would go
1202    /// unnamed. Both are the wrong tense, or the wrong scope, for a question with no undo.
1203    ///
1204    /// `uncommitted` is both halves of "not in a commit": the index against the working tree
1205    /// (`git::dirty_counts`) and `HEAD` against the index (`git::staged_changes`). The
1206    /// second is the one a `git add` with no commit lands in, and the one the dirty column
1207    /// deliberately never asks about.
1208    ///
1209    /// Errors rather than reporting zero when any read fails, so a gate never says "nothing
1210    /// to lose" because it could not look.
1211    pub fn delete_risk(&self, key: &EntityKey) -> Result<DeleteRisk, git::ProbeError> {
1212        let repo = git::open_thread_safe(key.path())?.to_thread_local();
1213        let dirty = git::dirty_counts(&repo, Arc::new(AtomicBool::new(false)))?;
1214        let staged = git::staged_changes(&repo)?;
1215        let (unpushed_commits, unpushed_branches) = git::unpushed(&repo)?;
1216        let linked_worktrees = git::linked_worktrees(&repo)?;
1217        Ok(DeleteRisk {
1218            uncommitted: dirty.total() > 0 || staged,
1219            unpushed_commits,
1220            unpushed_branches,
1221            linked_worktrees,
1222        })
1223    }
1224
1225    /// The administrative directory `git worktree remove` deletes for `key`'s own linked
1226    /// Worktree, read fresh right now. `Err` when `key`'s own path cannot even be opened as
1227    /// a git repository, which is what "the parent Repo is gone or unreadable" means for a
1228    /// `delete` on a Worktree row
1229    /// ([repo-management.md](https://github.com/paulchiu/repon/blob/main/docs/spec/repo-management.md)'s
1230    /// "What `delete` does to a Worktree"): the caller falls back to removing the working
1231    /// directory alone.
1232    pub fn worktree_admin_dir(&self, key: &EntityKey) -> Result<PathBuf, git::ProbeError> {
1233        let repo = git::open_thread_safe(key.path())?.to_thread_local();
1234        Ok(git::worktree_admin_dir(&repo))
1235    }
1236
1237    /// Every linked Worktree's own working directory pointing into `key`'s Repo, read
1238    /// fresh right now: what deleting a Repo needs to also remove, since each linked
1239    /// Worktree's directory sits outside the Repo's own and is untouched by removing that
1240    /// alone
1241    /// ([repo-management.md](https://github.com/paulchiu/repon/blob/main/docs/spec/repo-management.md)'s
1242    /// "Deleting a Repo also takes its linked Worktrees with it").
1243    pub fn linked_worktree_paths(&self, key: &EntityKey) -> Result<Vec<PathBuf>, git::ProbeError> {
1244        let repo = git::open_thread_safe(key.path())?.to_thread_local();
1245        git::linked_worktree_paths(&repo)
1246    }
1247
1248    /// `delete`'s phase 1: the ignored directories inside the working tree at `path`, read
1249    /// fresh right now
1250    /// ([repo-management.md](https://github.com/paulchiu/repon/blob/main/docs/spec/repo-management.md)'s
1251    /// "Deleting a working tree"). `path` rather than an [`EntityKey`] because a Repo
1252    /// `delete` runs this once for its own working tree and once more for each linked
1253    /// Worktree [`Self::linked_worktree_paths`] names, and only the first of those has a Set
1254    /// row of its own.
1255    pub fn ignored_directories_for_deletion(
1256        &self,
1257        path: &Path,
1258    ) -> Result<Vec<PathBuf>, git::ProbeError> {
1259        let repo = git::open_thread_safe(path)?.to_thread_local();
1260        git::ignored_directories_for_deletion(&repo)
1261    }
1262
1263    /// Attempts the fast-forward-only auto-update on `key`'s own Repo, on demand: exactly
1264    /// `crate::auto_update::attempt`'s own five rules and its own fast-forward, reused
1265    /// rather than a second implementation for the built-in `sync` action to call by hand
1266    /// ([repo-management.md](https://github.com/paulchiu/repon/blob/main/docs/spec/repo-management.md)).
1267    /// Read fresh right now, the same tense [`Self::delete_risk`] reads in: eligibility can
1268    /// change between the gate and the run, so this is never answered from a Cell.
1269    pub fn attempt_auto_update(&self, key: &EntityKey) -> AutoUpdateAttempt {
1270        match crate::auto_update::attempt(key.path()) {
1271            crate::auto_update::Outcome::Ineligible(crate::auto_update::Ineligible::NotClean) => {
1272                AutoUpdateAttempt::NotClean
1273            }
1274            crate::auto_update::Outcome::Ineligible(crate::auto_update::Ineligible::NoUpstream) => {
1275                AutoUpdateAttempt::NoUpstream
1276            }
1277            crate::auto_update::Outcome::Ineligible(crate::auto_update::Ineligible::NotBehind) => {
1278                AutoUpdateAttempt::NotBehind
1279            }
1280            crate::auto_update::Outcome::Ineligible(
1281                crate::auto_update::Ineligible::NotFastForward,
1282            ) => AutoUpdateAttempt::NotFastForward,
1283            crate::auto_update::Outcome::Updated { .. } => AutoUpdateAttempt::Updated,
1284            crate::auto_update::Outcome::Failed(error) => AutoUpdateAttempt::Failed(error),
1285        }
1286    }
1287
1288    /// Runs `action`'s own steps against one Entity, on the calling thread, blocking until
1289    /// they finish rather than handing the run off the way [`Core::run_action`]'s async
1290    /// fan-out does. A pre or post hook wrapping a built-in ([0032](https://github.com/paulchiu/repon/blob/main/docs/adr/0032-hooks-around-a-built-in-fire-on-its-own-confirm-gate-never-its-completion.md))
1291    /// needs the outcome before the built-in can proceed or report, which nothing running
1292    /// off this thread can give in time. Reuses `run_action_for_entity`, the identical
1293    /// per-step execution `run_action`'s fan-out gives every entity, so a hook and a
1294    /// configured `[[action]]` never diverge in what a step means; writes nothing to the
1295    /// table and touches none of `run_action`'s own state (the one admitted run and its
1296    /// controls), since a hook is a distinct concern from the one fan-out the palette
1297    /// tracks.
1298    ///
1299    /// `None` when `key` names no Entity this table currently knows.
1300    pub fn run_action_for_entity_blocking(
1301        &self,
1302        action: &ActionSpec,
1303        key: &EntityKey,
1304    ) -> Option<ActionReceipt> {
1305        let entity = {
1306            let table = self.table.read().unwrap();
1307            let idx = *table.index.get(key)?;
1308            table.entities[idx].clone()
1309        };
1310        let control = executor::RunControl::new();
1311        Some(run_action_for_entity(&entity, action, &control, &|_| {}))
1312    }
1313
1314    /// Vends a [`ManagementHandle`]: the `Send + 'static` seam a management run's own
1315    /// per-row work moves onto a background thread through, so it stops blocking the caller
1316    /// the way [`Self::run_action`]'s own fan-out already moves an `Arc<RwLock<Table>>`
1317    /// clone onto its own thread
1318    /// ([0033](https://github.com/paulchiu/repon/blob/main/docs/adr/0033-a-management-run-moves-off-the-calling-thread-and-cancels-between-rows.md)).
1319    pub fn management_handle(&self) -> ManagementHandle {
1320        ManagementHandle {
1321            table: Arc::clone(&self.table),
1322        }
1323    }
1324
1325    /// Drops one entity from the table, cancelling any probe in flight against it.
1326    pub fn dismiss(&self, key: &EntityKey) {
1327        let mut table = self.table.write().unwrap();
1328        if let Some(idx) = table.index.remove(key) {
1329            table.entities.remove(idx);
1330            for position in table.index.values_mut() {
1331                if *position > idx {
1332                    *position -= 1;
1333                }
1334            }
1335        }
1336        table.poll_fingerprints.remove(key);
1337        if let Some(in_flight) = table.in_flight.remove(key) {
1338            in_flight.cancel.store(true, Ordering::Release);
1339            drop(table);
1340            complete_one(&self.settle_gate);
1341        }
1342    }
1343
1344    /// Resolves `order` against the table this instant and splits it into the entities
1345    /// that will actually run and the ones a matching `[[repo]]` `exclude = true`
1346    /// override sweeps in and skips
1347    /// ([config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md#per-repo-entries)).
1348    /// [`Self::run_action`] and [`Self::operable_count`] both call this rather than
1349    /// each keeping its own copy of the `!entity.excluded` test, so a consumer's confirm
1350    /// gate or palette border can never show a count a real run then contradicts
1351    /// ([`docs/spec/actions.md`](https://github.com/paulchiu/repon/blob/main/docs/spec/actions.md)'s
1352    /// "The Selection and the gate": "a wrong count would lie twice"). A key `order`
1353    /// names that no longer resolves (already dismissed, or never discovered) is
1354    /// silently dropped from both halves.
1355    fn partition_operable(&self, order: &[EntityKey]) -> (Vec<EntityState>, Vec<EntityState>) {
1356        let table = self.table.read().unwrap();
1357        order
1358            .iter()
1359            .filter_map(|key| table.index.get(key).map(|&idx| table.entities[idx].clone()))
1360            .partition(|entity| !entity.excluded)
1361    }
1362
1363    /// How many of `order` are operable, i.e. not excluded: [`Self::run_action`]'s own
1364    /// first move is the identical partition this method itself calls, so this is the one
1365    /// number a confirm gate and a palette border can both read without either ever
1366    /// drifting from what that first move keeps. Not the final count a run acts on once
1367    /// `action.when` is `Some`: [`Self::applicability`] narrows this same set further, and
1368    /// [`Self::run_action`] itself only ever runs the rows that narrowing proves.
1369    pub fn operable_count(&self, order: &[EntityKey]) -> usize {
1370        self.partition_operable(order).0.len()
1371    }
1372
1373    /// How many Entities in the live table are Vanished. Reads the table in place rather
1374    /// than through [`Self::snapshot`], so a caller needing only the count does not pay for
1375    /// a clone of the whole table and its staleness pass on every frame.
1376    pub fn vanished_count(&self) -> usize {
1377        self.table
1378            .read()
1379            .unwrap()
1380            .entities
1381            .iter()
1382            .filter(|entity| entity.presence == Presence::Vanished)
1383            .count()
1384    }
1385
1386    /// How an Action's `when` predicate divides the very rows [`Self::operable_count`]
1387    /// counts: the identical partition runs first, so an excluded row is subtracted before
1388    /// the predicate ever sees it and `when` narrows what is left rather than replacing that
1389    /// subtraction
1390    /// ([`docs/spec/actions.md`](https://github.com/paulchiu/repon/blob/main/docs/spec/actions.md)'s
1391    /// "The Selection and the gate"). A palette calls this ahead of time, to report a count
1392    /// before a choice is even made; [`Self::run_action`] runs the identical classification
1393    /// against the identical rows once a choice is confirmed, over `ActionSpec::when` rather
1394    /// than an argument of its own, so a preview and a real run can never disagree.
1395    ///
1396    /// The tally lives here rather than in the consumer for that reason alone:
1397    /// `partition_operable` is this type's own, so a caller cannot count applicability over
1398    /// a set the run would not act on.
1399    pub fn applicability(&self, order: &[EntityKey], when: &Filter) -> Applicability {
1400        when.applicability(self.partition_operable(order).0.iter())
1401    }
1402
1403    /// `true` from an Action run's admission until its completion has dispatched the
1404    /// Generation it owes, the consumer-facing read of the one admitted run
1405    /// ([ADR 0018](https://github.com/paulchiu/repon/blob/main/docs/adr/0018-an-action-is-a-fanout-of-pty-backed-steps.md)'s
1406    /// "One Action runs at a time"): what a TUI gates `;`, `s`, `1` to `9` and `Ctrl+R`
1407    /// against while a run is in flight
1408    /// ([ADR 0023](https://github.com/paulchiu/repon/blob/main/docs/adr/0023-an-unbuilt-binding-is-not-advertised-and-an-unavailable-one-answers-on-press.md)).
1409    pub fn action_running(&self) -> bool {
1410        self.action_lifecycle.lock().unwrap().live.is_some()
1411    }
1412
1413    /// `true` while any refresh-shaped dispatch this `Core` started still owes the table
1414    /// work: a Generation reserved and not yet raised the probes it dispatches, or probes
1415    /// raised and not yet landed, cancelled or timed out. The same gate [`Core::try_settle`]
1416    /// blocks on, read here without blocking, so a consumer can report a Refresh's own
1417    /// progress on screen while it runs rather than waiting for it to finish
1418    /// ([refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)).
1419    /// Covers `refresh`, `refresh_all`, `rederive_default_branches`, `probe_now` and the
1420    /// startup walk alike; an Action's own fan-out never touches this gate, which is what
1421    /// `action_running` reads instead.
1422    pub fn refresh_running(&self) -> bool {
1423        let (lock, _cvar) = &*self.settle_gate;
1424        !lock.lock().unwrap().is_settled()
1425    }
1426
1427    /// Runs `action` across every key in `order` that the table currently knows: each
1428    /// entity's own steps run in order and stop at that entity's first failure, exactly
1429    /// as [config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md)'s
1430    /// "Actions" fixes, with later steps recorded `NotRun` rather than silently skipped.
1431    /// Cross-entity concurrency is bounded by `action.concurrency`, on a
1432    /// `rayon::ThreadPool` this call builds and owns for the run alone, never rayon's
1433    /// global pool the probe fan-out shares: a step blocked in `wait()` removes a
1434    /// worker from whichever pool holds it, and the global pool has none to spare
1435    /// without starving a refresh in flight
1436    /// ([`docs/spec/actions.md`](https://github.com/paulchiu/repon/blob/main/docs/spec/actions.md)'s
1437    /// "The fan-out"). Returns immediately; every step's own child, and this run's
1438    /// completion, run off the calling thread.
1439    ///
1440    /// Returns `false` and touches nothing if a fan-out is already running: only one
1441    /// runs at a time
1442    /// ([ADR 0018](https://github.com/paulchiu/repon/blob/main/docs/adr/0018-an-action-is-a-fanout-of-pty-backed-steps.md)'s
1443    /// "One Action runs at a time"), but the spec settles only that the *palette* goes
1444    /// inert while one is live, never what a second, concurrent call to this seam itself
1445    /// should do. Rejecting outright, rather than queuing, is this call's own choice: a
1446    /// queue needs its own ordering and cancellation story that no acceptance criterion
1447    /// here asks for.
1448    ///
1449    /// An entity in `order` carrying a matching `[[repo]]` `exclude = true`
1450    /// ([config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md#per-repo-entries))
1451    /// never runs a step: it receives a [`Skip::Excluded`] receipt with an empty step list
1452    /// immediately, the one legitimate producer of `Not applicable`
1453    /// ([`docs/spec/actions.md`](https://github.com/paulchiu/repon/blob/main/docs/spec/actions.md)'s
1454    /// "The Selection and the gate"). An unknown key in `order` (already dismissed, or
1455    /// never discovered) is silently skipped, the same fallback `refresh` gives one.
1456    ///
1457    /// `action.when`, once every excluded row is already subtracted, decides what runs
1458    /// rather than only what a palette reported about it: a row it proves is handed a
1459    /// step, a row it disproves gets a [`Skip::Inapplicable`] receipt instead, and a row it
1460    /// cannot settle (a Cell it reads has not settled) gets [`Skip::Unresolved`], since an
1461    /// unprovable row is not a provable one and a run has no basis to touch it either
1462    /// (`docs/spec/actions.md`'s "The Selection and the gate", reversing what that
1463    /// paragraph originally decided). `None` runs every operable row, exactly as before
1464    /// `when` reached this call.
1465    ///
1466    /// Starting a run cancels any in-flight Generation outright rather than sharing
1467    /// execution with it, and completion starts exactly one normal Generation over
1468    /// every entity the table currently knows, not only the ones this run touched.
1469    /// Explicitly not done, for the same reason: re-probing each affected entity
1470    /// synchronously first, the way a Launcher return does with [`Core::probe_now`].
1471    /// Both choices, and their measured cost, are
1472    /// [ADR 0018](https://github.com/paulchiu/repon/blob/main/docs/adr/0018-an-action-is-a-fanout-of-pty-backed-steps.md)'s
1473    /// ("Refreshing around a run").
1474    pub fn run_action(&self, action: ActionSpec, order: &[EntityKey]) -> bool {
1475        // Built before admission rather than after it, so the run a caller is told started
1476        // is admitted with its own reach into its children already attached: a
1477        // `stop_action` the instant this returns `true` can never find no control, and a
1478        // completion racing in can never find someone else's.
1479        let control = executor::RunControl::new();
1480        if !self
1481            .action_lifecycle
1482            .lock()
1483            .unwrap()
1484            .admit(Arc::clone(&control))
1485        {
1486            return false;
1487        }
1488
1489        // Criterion 3's first half: starting a run cancels any in-flight Generation
1490        // outright, never sharing the machine with it.
1491        cancel_in_flight(&self.table, &self.settle_gate);
1492
1493        let (operable, excluded) = self.partition_operable(order);
1494
1495        let write_skip_receipts = |entities: &[EntityState], skip: Skip| {
1496            if entities.is_empty() {
1497                return;
1498            }
1499            let finished_at = Timestamp::now();
1500            let mut table = self.table.write().unwrap();
1501            for entity in entities {
1502                if let Some(&idx) = table.index.get(&entity.key) {
1503                    table.entities[idx].last_action = Some(ActionReceipt {
1504                        label: Arc::clone(&action.label),
1505                        steps: Arc::from(Vec::new()),
1506                        skip: Some(skip),
1507                        finished_at,
1508                        running: None,
1509                    });
1510                }
1511            }
1512        };
1513
1514        write_skip_receipts(&excluded, Skip::Excluded);
1515
1516        let included = match &action.when {
1517            Some(when) => {
1518                let Partition {
1519                    applicable,
1520                    inapplicable,
1521                    unresolved,
1522                } = when.partition(operable);
1523                write_skip_receipts(&inapplicable, Skip::Inapplicable);
1524                write_skip_receipts(&unresolved, Skip::Unresolved);
1525                applicable
1526            }
1527            None => operable,
1528        };
1529
1530        let table_handle = Arc::clone(&self.table);
1531        let refresh_handles = self.refresh_handles();
1532        let action_lifecycle = Arc::clone(&self.action_lifecycle);
1533        #[cfg(test)]
1534        let completion_boundary = Arc::clone(&self.action_completion_boundary);
1535        // At least one worker regardless of what `action.concurrency` says: 0 has no
1536        // sensible reading as "run nothing" here (the schema has no floor, only an
1537        // explicit absence of a *ceiling*, `docs/spec/actions.md`'s "The fan-out"), and
1538        // `rayon::ThreadPoolBuilder::num_threads(0)` means "let rayon choose" rather
1539        // than zero workers, which would silently hand this run back to a pool sized by
1540        // something other than `concurrency`.
1541        let concurrency = action.concurrency.max(1) as usize;
1542
1543        // A plain OS thread, never a job on either rayon pool: `RefreshHandles::dispatch`
1544        // below calls `rayon::spawn`, which targets whichever pool the *calling* thread
1545        // already belongs to, so running this orchestration from inside the dedicated
1546        // pool built below would misroute the completion Generation's own probes onto
1547        // it instead of the global pool every other probe uses.
1548        thread::spawn(move || {
1549            let pool = rayon::ThreadPoolBuilder::new()
1550                .num_threads(concurrency)
1551                .build()
1552                .expect("build the Action fan-out's own dedicated pool");
1553
1554            // Caught rather than left to unwind straight out of this thread: a poisoned
1555            // `RwLock` from an unrelated earlier panic is enough to panic the
1556            // `table_handle.write().unwrap()` below, and without `catch_unwind` that
1557            // would unwind past the `RunCompletion` just beyond it before that guard
1558            // exists, leaving this `Core` reading its run as live for the rest of its life.
1559            let fan_out = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1560                pool.install(|| {
1561                    included.into_par_iter().for_each(|entity| {
1562                        let write_receipt = |receipt: ActionReceipt| {
1563                            let mut table = table_handle.write().unwrap();
1564                            if let Some(&idx) = table.index.get(&entity.key) {
1565                                table.entities[idx].last_action = Some(receipt);
1566                            }
1567                        };
1568                        let receipt =
1569                            run_action_for_entity(&entity, &action, &control, &write_receipt);
1570                        write_receipt(receipt);
1571                    });
1572                });
1573            }));
1574
1575            // scan: action-completion-path begin -- criterion 4: nothing from here to the
1576            // matching end marker below may re-probe an affected entity synchronously the
1577            // way a Launcher return does with `probe_now`; scoped this narrowly (rather
1578            // than a whole-crate scan) because a legitimate Launcher-return caller lives
1579            // in an unrelated call site the same absence claim must not forbid.
1580            // Criterion 6: the fan-out's own steps are over here, panic or not, and this
1581            // run stays admitted only until `completion` drops one statement past the
1582            // Generation below. `hold_action`, `continue_action` and `stop_action` are
1583            // no-ops again from that point, and a second `run_action` before it is refused
1584            // rather than left to race the Generation this run still owes.
1585            let completion = RunCompletion {
1586                lifecycle: action_lifecycle,
1587                #[cfg(test)]
1588                boundary: completion_boundary,
1589            };
1590
1591            // A panicked fan-out never finished cleanly, so it earns no completion
1592            // Generation. Swallowed rather than resumed: the default panic hook already
1593            // printed it to stderr before `catch_unwind` returned, and this crate carries
1594            // no logger to hand it to instead.
1595            let Ok(()) = fan_out else {
1596                return;
1597            };
1598
1599            // Criterion 3's second half: completion starts one normal Generation over
1600            // every entity currently known, not only the ones this run acted on.
1601            let all_keys: Vec<EntityKey> = table_handle
1602                .read()
1603                .unwrap()
1604                .entities
1605                .iter()
1606                .map(|entity| entity.key.clone())
1607                .collect();
1608            refresh_handles.dispatch(&all_keys);
1609            drop(completion);
1610            // scan: action-completion-path end
1611        });
1612
1613        true
1614    }
1615
1616    /// SIGSTOPs every currently live step's process group in the fan-out `run_action`
1617    /// started, reversible with [`Self::continue_action`]: suspending a run is reversible,
1618    /// where cancelling one is not
1619    /// ([`docs/spec/actions.md`](https://github.com/paulchiu/repon/blob/main/docs/spec/actions.md)'s
1620    /// "Cancellation and quit"). A no-op while no fan-out is running. Its own verb,
1621    /// kept apart from [`Self::pause`], which stays ignorant of why background work stopped.
1622    pub fn hold_action(&self) {
1623        if let Some(control) = self.live_action_control() {
1624            control.hold();
1625        }
1626    }
1627
1628    /// SIGCONTs every currently live step's process group, undoing [`Self::hold_action`]. A
1629    /// no-op while no fan-out is running.
1630    pub fn continue_action(&self) {
1631        if let Some(control) = self.live_action_control() {
1632            control.continue_run();
1633        }
1634    }
1635
1636    /// Cancels the fan-out `run_action` started: SIGTERM now to every step's process group
1637    /// still live, SIGKILL after a grace to whichever of those have not exited by then,
1638    /// because SIGTERM is trappable and SIGKILL is not. A step already running when this is
1639    /// called becomes `Cancelled`; so does a step, or a whole entity's run, that had not
1640    /// started, which stays distinct from `NotRun`
1641    /// ([`docs/spec/actions.md`](https://github.com/paulchiu/repon/blob/main/docs/spec/actions.md)'s
1642    /// "Cancellation and quit"). A no-op while no fan-out is running. Its own verb,
1643    /// kept apart from [`Self::pause`] for the same reason [`Self::hold_action`] is.
1644    pub fn stop_action(&self) {
1645        if let Some(control) = self.live_action_control() {
1646            control.cancel();
1647        }
1648    }
1649
1650    /// The live run's reach into its steps' children, cloned out so the three verbs above
1651    /// signal a process group with [`Core::action_lifecycle`]'s lock already released.
1652    /// `None` with no fan-out live, which is what makes each of them a no-op then.
1653    fn live_action_control(&self) -> Option<Arc<executor::RunControl>> {
1654        self.action_lifecycle.lock().unwrap().live.clone()
1655    }
1656
1657    /// Stops all background work: the dedicated thread stops ticking and every
1658    /// probe currently in flight is cancelled. The core is never told why.
1659    pub fn pause(&self) {
1660        let _ = self.control.send(ClockControl::Pause);
1661    }
1662
1663    /// Restarts the dedicated thread's ticking. Nothing is queued to fire on
1664    /// resume; a normal Generation is the consumer's decision, not this call's.
1665    pub fn resume(&self) {
1666        let _ = self.control.send(ClockControl::Resume);
1667    }
1668
1669    /// The persistent warning a re-run discovery walk leaves behind once it abandons, or
1670    /// `None` while none has. Never cleared once set, the same as `discovery_manual`: the
1671    /// Set stays out of the automatic refresh path for the life of this `Core`. The UI's
1672    /// shared warning slot polls this every frame, since it can turn from `None` to `Some`
1673    /// at any point in the run with no reload involved.
1674    pub fn discovery_warning(&self) -> Option<String> {
1675        self.discovery_warning.lock().unwrap().clone()
1676    }
1677
1678    /// The most recently completed periodic-fetch cycle's own failures, or an
1679    /// empty [`FetchFailures`] once every fetch in that cycle succeeded, or the
1680    /// cycle has never run. The UI's shared warning slot polls this every frame,
1681    /// the same way it polls [`Self::discovery_warning`] and
1682    /// [`Self::vanished_count`], since a later cycle can replace this at any point
1683    /// in the run with no reload involved.
1684    pub fn fetch_failures(&self) -> FetchFailures {
1685        self.fetch_failures.lock().unwrap().clone()
1686    }
1687
1688    /// Sets the live show-submodules preference a Generation's dispatch reads from this
1689    /// point on: whether a Kind::Submodule entity is probed at all
1690    /// ([discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md)'s
1691    /// "Showing Submodules"). Takes effect on the next `refresh`, dispatches nothing of its
1692    /// own and starts no Generation, which is what makes toggling this instant rather than a
1693    /// rebuild: `CoreSpec`'s own `show_submodules` is only this flag's starting value.
1694    pub fn set_show_submodules(&self, show_submodules: bool) {
1695        self.show_submodules
1696            .store(show_submodules, Ordering::Release);
1697    }
1698
1699    /// Writes one receipt per row for work Repon did itself, with no child process anywhere
1700    /// in it: what a Management operation leaves behind
1701    /// ([repo-management.md](https://github.com/paulchiu/repon/blob/main/docs/spec/repo-management.md)'s
1702    /// "Receipts", [`docs/spec/actions.md`](https://github.com/paulchiu/repon/blob/main/docs/spec/actions.md)'s
1703    /// `OwnWork`).
1704    ///
1705    /// The receipt is built here rather than handed in whole, so a consumer supplies only
1706    /// what Repon did and the words for it: `skip` stays `None`, since a refusal is a row
1707    /// that was operated on rather than one of the three ways a row is skipped, `running`
1708    /// stays `None`, since the work is already done, and the step count stays one, since the
1709    /// operation is one act rather than an ordered list.
1710    /// `label` is the operation's own name and doubles as the single step's label; the step's
1711    /// captured output is empty, there being no other program's screen to quote.
1712    ///
1713    /// Starts no Generation and dispatches nothing, for the same reason
1714    /// [`Core::set_exclusions`] does not: a receipt is something Repon did rather than a
1715    /// reading of the world, so nothing here can make a cell any more or less true. A key the
1716    /// table no longer holds is skipped, the same fallback every key-addressed entry point
1717    /// here gives one.
1718    pub fn record_own_work(&self, label: &str, results: &[(EntityKey, OwnWork, Duration)]) {
1719        let label: Arc<str> = Arc::from(label);
1720        let finished_at = Timestamp::now();
1721        let mut table = self.table.write().unwrap();
1722        for (key, work, elapsed) in results {
1723            let Some(&idx) = table.index.get(key) else {
1724                continue;
1725            };
1726            table.entities[idx].last_action = Some(ActionReceipt {
1727                label: Arc::clone(&label),
1728                steps: Arc::from(vec![StepResult {
1729                    label: Arc::clone(&label),
1730                    outcome: StepOutcome::OwnWork(work.clone()),
1731                    output: Arc::from(&b""[..]),
1732                    elapsed: *elapsed,
1733                    elision: None,
1734                    shell: false,
1735                    interactive: false,
1736                }]),
1737                skip: None,
1738                finished_at,
1739                running: None,
1740            });
1741        }
1742    }
1743
1744    /// Replaces the live `exclude` half of `[[repo]]` and re-applies it over every row the
1745    /// table already holds, so the next [`Core::snapshot`] answers with the new reading
1746    /// ([repo-management.md](https://github.com/paulchiu/repon/blob/main/docs/spec/repo-management.md)'s
1747    /// "Writing config": an `ignore` takes effect as soon as this call returns).
1748    ///
1749    /// Starts no Generation, dispatches nothing and rediscovers nothing, for the same reason
1750    /// [`Core::set_show_submodules`] does not: `exclude` decides only whether an operation
1751    /// may reach a row, never what discovery finds or what a probe reads. `default_branch`,
1752    /// the other key a `[[repo]]` entry may carry, is a probe input and is deliberately not
1753    /// moved here; it still needs a rebuilt `Core`.
1754    pub fn set_exclusions(&self, overrides: &[RepoOverride]) {
1755        let (_, resolved) = resolve_entries(overrides);
1756        // Written and released before the table lock is taken, never held across it:
1757        // `rerun_discovery` reads these two in the opposite order.
1758        {
1759            let mut exclusions = self.exclusions.write().unwrap();
1760            *exclusions = resolved.clone();
1761        }
1762        let mut table = self.table.write().unwrap();
1763        for entity in &mut table.entities {
1764            entity.excluded = excluded_by(&resolved, entity.key.path(), &entity.common_dir);
1765        }
1766    }
1767}
1768
1769/// The read-only, path-driven operations a management run's per-row work needs
1770/// (`crates/repon/src/management.rs`'s own `run_one_record`), cloned out of
1771/// [`Core::management_handle`] rather than borrowed from a live `Core`: `Send + 'static`, so
1772/// a caller can move it onto a background thread the way [`Core::run_action`]'s own fan-out
1773/// thread already moves its `Arc<RwLock<Table>>` clone there. Grants none of `Core`'s other
1774/// state (the one admitted Action run and its controls, the clock thread): a management run
1775/// is a distinct concern from the one fan-out those track, and this handle's own methods touch
1776/// only the table, exactly as [`Core::run_action_for_entity_blocking`] already does.
1777#[derive(Clone)]
1778pub struct ManagementHandle {
1779    table: Arc<RwLock<Table>>,
1780}
1781
1782impl ManagementHandle {
1783    /// Identical to [`Core::worktree_admin_dir`], against this handle's own table clone.
1784    pub fn worktree_admin_dir(&self, key: &EntityKey) -> Result<PathBuf, git::ProbeError> {
1785        let repo = git::open_thread_safe(key.path())?.to_thread_local();
1786        Ok(git::worktree_admin_dir(&repo))
1787    }
1788
1789    /// Identical to [`Core::linked_worktree_paths`], against this handle's own table clone.
1790    pub fn linked_worktree_paths(&self, key: &EntityKey) -> Result<Vec<PathBuf>, git::ProbeError> {
1791        let repo = git::open_thread_safe(key.path())?.to_thread_local();
1792        git::linked_worktree_paths(&repo)
1793    }
1794
1795    /// Identical to [`Core::ignored_directories_for_deletion`], against this handle's own
1796    /// table clone.
1797    pub fn ignored_directories_for_deletion(
1798        &self,
1799        path: &Path,
1800    ) -> Result<Vec<PathBuf>, git::ProbeError> {
1801        let repo = git::open_thread_safe(path)?.to_thread_local();
1802        git::ignored_directories_for_deletion(&repo)
1803    }
1804
1805    /// Identical to [`Core::attempt_auto_update`].
1806    pub fn attempt_auto_update(&self, key: &EntityKey) -> AutoUpdateAttempt {
1807        match crate::auto_update::attempt(key.path()) {
1808            crate::auto_update::Outcome::Ineligible(crate::auto_update::Ineligible::NotClean) => {
1809                AutoUpdateAttempt::NotClean
1810            }
1811            crate::auto_update::Outcome::Ineligible(crate::auto_update::Ineligible::NoUpstream) => {
1812                AutoUpdateAttempt::NoUpstream
1813            }
1814            crate::auto_update::Outcome::Ineligible(crate::auto_update::Ineligible::NotBehind) => {
1815                AutoUpdateAttempt::NotBehind
1816            }
1817            crate::auto_update::Outcome::Ineligible(
1818                crate::auto_update::Ineligible::NotFastForward,
1819            ) => AutoUpdateAttempt::NotFastForward,
1820            crate::auto_update::Outcome::Updated { .. } => AutoUpdateAttempt::Updated,
1821            crate::auto_update::Outcome::Failed(error) => AutoUpdateAttempt::Failed(error),
1822        }
1823    }
1824
1825    /// Identical to [`Core::run_action_for_entity_blocking`], against this handle's own
1826    /// table clone rather than a live `Core`.
1827    pub fn run_action_for_entity_blocking(
1828        &self,
1829        action: &ActionSpec,
1830        key: &EntityKey,
1831    ) -> Option<ActionReceipt> {
1832        let entity = {
1833            let table = self.table.read().unwrap();
1834            let idx = *table.index.get(key)?;
1835            table.entities[idx].clone()
1836        };
1837        let control = executor::RunControl::new();
1838        Some(run_action_for_entity(&entity, action, &control, &|_| {}))
1839    }
1840}
1841
1842/// Every `Arc` and plain-data field a Generation's dispatch reads, owned rather than
1843/// borrowed: [`Core::refresh_handles`] is the only constructor once a `Core` exists,
1844/// and its own doc comment carries the reason this exists at all. `start_internal`
1845/// builds one directly, since the periodic fetch's own completion Generation needs
1846/// this before there is a `Core` to ask; `Clone` is what lets that one value serve
1847/// both the recurring cadence and the immediate first cycle without a second,
1848/// drifting construction. Field names and types mirror `Core`'s own exactly, so
1849/// [`Self::dispatch`] and [`Self::rerun_discovery`] are `refresh` and
1850/// `rerun_discovery`'s bodies moved verbatim, `self.field` unchanged.
1851#[derive(Clone)]
1852struct RefreshHandles {
1853    table: Arc<RwLock<Table>>,
1854    overrides: Arc<Vec<ResolvedOverride>>,
1855    /// [`Core::exclusions`]'s own clone, so a re-run discovery's newly found rows take
1856    /// whatever `exclude` says right now rather than whatever it said at `start`.
1857    exclusions: Arc<RwLock<Vec<ResolvedExclusion>>>,
1858    set: SetSpec,
1859    discovery_manual: Arc<AtomicBool>,
1860    discovery_warn_after: Duration,
1861    discovery_abandon_after: Arc<AtomicU64>,
1862    discovery_warning: Arc<Mutex<Option<String>>>,
1863    show_submodules: Arc<AtomicBool>,
1864    settle_gate: Arc<SettleGate>,
1865    default_branch_chain_reads: Arc<AtomicUsize>,
1866    patch_identity_reads: Arc<AtomicUsize>,
1867    patch_scan_bounds: Arc<Mutex<Vec<Option<gix::ObjectId>>>>,
1868    dispatch_log: Arc<Mutex<Vec<EntityKey>>>,
1869    phase_c_gates: Arc<Mutex<HashMap<EntityKey, PhaseCGateHandle>>>,
1870    /// [`Core::network_default_branch`]'s own clone: [`run_fetch_cycle`] writes
1871    /// into it once a fetch's own handshake advertises a HEAD, and this
1872    /// dispatch's own default-branch probes read it back the same Generation.
1873    network_default_branch: Arc<Mutex<HashMap<PathBuf, Arc<str>>>>,
1874    /// [`Core::turnstile`]'s own clone, so every dispatch this `Core` starts,
1875    /// wherever it is called from, queues in the one order.
1876    turnstile: Arc<DispatchTurnstile>,
1877    /// [`Core::discovery_gate`]'s own clone; `None` on every production path.
1878    discovery_gate: Option<DiscoveryGate>,
1879}
1880
1881/// Runs the spawned dispatch bodies in the order their Generations were reserved.
1882///
1883/// Reserving the number is what a caller waits for; everything after it happens on
1884/// a thread of its own, and two of those threads reaching the table out of order
1885/// would let an older Generation cancel a newer one's in-flight entries and then
1886/// record itself as the live one, which is refresh.md's supersession rule read
1887/// backwards. A ticket taken under the same lock that mints the Generation, and
1888/// served in ticket order, is what stops that.
1889#[derive(Default)]
1890struct DispatchTurnstile {
1891    /// The ticket whose body may run, and the [`Condvar`] every waiting body sleeps on.
1892    serving: Mutex<u64>,
1893    ready: Condvar,
1894    /// The next ticket to hand out. Only ever read under the table write lock
1895    /// [`RefreshHandles::reserve_generation`] holds, so tickets and Generations
1896    /// are issued in the one order.
1897    next: AtomicU64,
1898}
1899
1900impl DispatchTurnstile {
1901    fn reserve(&self) -> u64 {
1902        self.next.fetch_add(1, Ordering::AcqRel)
1903    }
1904
1905    /// Blocks until `ticket` is the one being served. The returned guard releases
1906    /// the next ticket when it drops, panic included, so one body that unwinds
1907    /// cannot wedge every dispatch after it.
1908    fn take(&self, ticket: u64) -> DispatchTurn<'_> {
1909        let serving = self.serving.lock().unwrap();
1910        drop(
1911            self.ready
1912                .wait_while(serving, |serving| *serving != ticket)
1913                .unwrap(),
1914        );
1915        DispatchTurn {
1916            turnstile: self,
1917            ticket,
1918        }
1919    }
1920}
1921
1922/// One body's turn at the [`DispatchTurnstile`], held for as long as that body runs.
1923struct DispatchTurn<'a> {
1924    turnstile: &'a DispatchTurnstile,
1925    ticket: u64,
1926}
1927
1928impl Drop for DispatchTurn<'_> {
1929    fn drop(&mut self) {
1930        let mut serving = self.turnstile.serving.lock().unwrap();
1931        *serving = self.ticket + 1;
1932        self.turnstile.ready.notify_all();
1933    }
1934}
1935
1936impl RefreshHandles {
1937    /// `Core::refresh`'s whole body, moved here so `run_action`'s completion can call
1938    /// the identical dispatch from a thread that owns no reference to `Core` itself.
1939    ///
1940    /// Reserves this Generation's number and its turnstile place on the calling
1941    /// thread and does everything else, discovery's own walk included, on a thread
1942    /// of its own, the shape [`Core::rederive_default_branches`] already takes: no
1943    /// caller waits out a walk, and every one of them is fire and forget past the
1944    /// number this returns.
1945    fn dispatch(&self, order: &[EntityKey]) -> Generation {
1946        let (generation, ticket) = self.reserve_generation();
1947        begin_dispatch(&self.settle_gate);
1948        let handles = self.clone();
1949        let order = order.to_vec();
1950        thread::spawn(move || {
1951            let _turn = handles.turnstile.take(ticket);
1952            handles.run_generation(&order, generation);
1953            finish_dispatch(&handles.settle_gate);
1954        });
1955        generation
1956    }
1957
1958    /// [`Core::refresh_all`]'s whole body: the same reservation and the same spawned
1959    /// shape as [`Self::dispatch`], with the order read off the table this
1960    /// Generation's own discovery just reconciled rather than taken from a caller.
1961    fn dispatch_over_everything(&self) -> Generation {
1962        let (generation, ticket) = self.reserve_generation();
1963        begin_dispatch(&self.settle_gate);
1964        let handles = self.clone();
1965        thread::spawn(move || {
1966            let _turn = handles.turnstile.take(ticket);
1967            handles.rediscover();
1968            let order: Vec<EntityKey> = handles
1969                .table
1970                .read()
1971                .unwrap()
1972                .entities
1973                .iter()
1974                .map(|entity| entity.key.clone())
1975                .collect();
1976            handles.dispatch_probes(&order, generation);
1977            finish_dispatch(&handles.settle_gate);
1978        });
1979        generation
1980    }
1981
1982    /// Takes this Generation's number and its turnstile ticket under one hold of
1983    /// the table lock, so the two orders can never disagree.
1984    fn reserve_generation(&self) -> (Generation, u64) {
1985        let mut table = self.table.write().unwrap();
1986        table.generation += 1;
1987        (Generation::new(table.generation), self.turnstile.reserve())
1988    }
1989
1990    /// [`Self::dispatch`]'s spawned body: both halves of discovery, then the probe
1991    /// fan-out for `order`.
1992    fn run_generation(&self, order: &[EntityKey], generation: Generation) {
1993        self.rediscover();
1994        self.dispatch_probes(order, generation);
1995    }
1996
1997    /// Both halves of discovery at the head of one Generation, per refresh.md and
1998    /// discovery.md: an entity no longer found becomes Vanished, and one found again
1999    /// (new, or previously Vanished) is Present. Skipped once an earlier walk has
2000    /// abandoned, which takes the Set out of this automatic path until a fresh `Core`
2001    /// starts over different roots.
2002    fn rediscover(&self) {
2003        if !self.discovery_manual.load(Ordering::Acquire) {
2004            self.rerun_discovery();
2005        }
2006    }
2007
2008    /// The probe fan-out alone, against the table as it stands: one rayon task per
2009    /// dispatched entity, exactly as before this Generation's discovery moved off
2010    /// the calling thread. Split out from [`Self::run_generation`] so
2011    /// [`Self::dispatch_over_everything`], which has to resolve its order between the walk
2012    /// and the fan-out, shares this body rather than keeping a second copy of it.
2013    fn dispatch_probes(&self, order: &[EntityKey], generation: Generation) {
2014        // Scoped to this one Generation, per default-branch.md's "memoised per
2015        // common dir within a single refresh generation": a fresh cache every
2016        // call, never carried over, never touched by the previous Generation's
2017        // still-finishing tasks holding their own clone of the old one.
2018        self.default_branch_chain_reads.store(0, Ordering::Release);
2019        self.patch_identity_reads.store(0, Ordering::Release);
2020        self.patch_scan_bounds.lock().unwrap().clear();
2021        self.dispatch_log.lock().unwrap().clear();
2022
2023        let generation_number = generation.value();
2024        let mut table = self.table.write().unwrap();
2025        table
2026            .generation_started_at
2027            .insert(generation_number, Instant::now());
2028
2029        let show_submodules = self.show_submodules.load(Ordering::Acquire);
2030        let mut dispatched = Vec::new();
2031        for key in order {
2032            let Some(&idx) = table.index.get(key) else {
2033                continue;
2034            };
2035            if !dispatches_kind(table.entities[idx].kind, show_submodules) {
2036                // Narrows the work, not merely the view: a hidden Submodule's Cells are
2037                // left exactly as this Generation found them, so a normal Generation pays
2038                // nothing for it (`docs/spec/discovery.md`'s "Showing Submodules").
2039                continue;
2040            }
2041            if let Some(previous) = table.in_flight.remove(key) {
2042                previous.cancel.store(true, Ordering::Release);
2043            }
2044            let cancel = Arc::new(AtomicBool::new(false));
2045            table.in_flight.insert(
2046                key.clone(),
2047                InFlight {
2048                    generation: generation_number,
2049                    cancel: Arc::clone(&cancel),
2050                },
2051            );
2052            begin_probes(&mut table.entities[idx]);
2053            dispatched.push((key.clone(), cancel));
2054        }
2055
2056        if dispatched.is_empty() {
2057            return;
2058        }
2059
2060        begin_probes_owed(&self.settle_gate, dispatched.len());
2061        let repos: Vec<Option<Arc<gix::ThreadSafeRepository>>> = dispatched
2062            .iter()
2063            .map(|(key, _)| table.repos.get(key).cloned())
2064            .collect();
2065        let override_branches: Vec<Option<String>> = dispatched
2066            .iter()
2067            .map(|(key, _)| {
2068                let idx = table.index[key];
2069                let common_dir = &table.entities[idx].common_dir;
2070                find_entry(&self.overrides, key.path(), common_dir)
2071                    .and_then(|entry| entry.default_branch.clone())
2072            })
2073            .collect();
2074        let network_branches: Vec<Option<Arc<str>>> = dispatched
2075            .iter()
2076            .map(|(key, _)| {
2077                let idx = table.index[key];
2078                let common_dir = &table.entities[idx].common_dir;
2079                network_branch_for(&self.network_default_branch, common_dir)
2080            })
2081            .collect();
2082        let common_dirs: Vec<Arc<Path>> = dispatched
2083            .iter()
2084            .map(|(key, _)| Arc::clone(&table.entities[table.index[key]].common_dir))
2085            .collect();
2086        let probes_state: Vec<bool> = dispatched
2087            .iter()
2088            .map(|(key, _)| table.entities[table.index[key]].probes_state())
2089            .collect();
2090        let probes_base: Vec<bool> = dispatched
2091            .iter()
2092            .map(|(key, _)| table.entities[table.index[key]].probes_base())
2093            .collect();
2094        let kinds: Vec<Kind> = dispatched
2095            .iter()
2096            .map(|(key, _)| table.entities[table.index[key]].kind)
2097            .collect();
2098        drop(table);
2099
2100        // Scoped to this dispatch alone: every task below gets its own clone of
2101        // this `Arc`, and once they all finish and drop it, the cache and every
2102        // `ChainFacts` it holds are freed. Nothing here outlives one Generation.
2103        let chain_cache: Arc<ChainFactsCache> = Arc::new(Mutex::new(HashMap::new()));
2104        // Same lifetime as `chain_cache`, one dispatch's worth: patch
2105        // equivalence's own per-common-dir memo, per default-branch.md's "Two
2106        // passes on screen".
2107        let patch_cache: Arc<PatchIdentityCache> = Arc::new(Mutex::new(HashMap::new()));
2108        // One gate per common dir with at least one entity that will run
2109        // `landing::probe` this Generation, sized up front so it is known
2110        // exactly how many entities owe it a report before any of them run;
2111        // see `BoundGate`.
2112        let bound_gates: Arc<HashMap<Arc<Path>, BoundGate>> = Arc::new({
2113            let mut counts: HashMap<Arc<Path>, usize> = HashMap::new();
2114            for (common_dir, probes_state) in common_dirs.iter().zip(&probes_state) {
2115                if *probes_state {
2116                    *counts.entry(Arc::clone(common_dir)).or_insert(0) += 1;
2117                }
2118            }
2119            counts
2120                .into_iter()
2121                .map(|(dir, count)| (dir, BoundGate::new(count)))
2122                .collect()
2123        });
2124
2125        for (
2126            (
2127                (
2128                    (((((key, cancel), repo), override_branch), network_branch), common_dir),
2129                    probes_state,
2130                ),
2131                probes_base,
2132            ),
2133            kind,
2134        ) in dispatched
2135            .into_iter()
2136            .zip(repos)
2137            .zip(override_branches)
2138            .zip(network_branches)
2139            .zip(common_dirs)
2140            .zip(probes_state)
2141            .zip(probes_base)
2142            .zip(kinds)
2143        {
2144            // Recorded here, in this loop's own sequential iteration, rather than in the
2145            // one above: this is the loop whose order a future change (a sort by predicted
2146            // cost, say) would actually be tempted to touch, since it is the one that decides
2147            // each entity's `rayon::spawn` call, not merely which entities were dispatched.
2148            self.dispatch_log.lock().unwrap().push(key.clone());
2149            let path = key.path().to_path_buf();
2150            let table_handle = Arc::clone(&self.table);
2151            let settle_gate = Arc::clone(&self.settle_gate);
2152            let chain_cache = Arc::clone(&chain_cache);
2153            let chain_reads = Arc::clone(&self.default_branch_chain_reads);
2154            let patch_cache = Arc::clone(&patch_cache);
2155            let patch_reads = Arc::clone(&self.patch_identity_reads);
2156            let patch_scan_bounds = Arc::clone(&self.patch_scan_bounds);
2157            let bound_gates = Arc::clone(&bound_gates);
2158            // Resolved once here and moved into the task, which holds no handle on the
2159            // map itself: a probe signals the gate its own Generation was dispatched
2160            // against, so one still running from an earlier Generation can never signal a
2161            // gate registered after that Generation dispatched.
2162            let held_gate = self.phase_c_gates.lock().unwrap().get(&key).cloned();
2163            // scan: probe-fanout-pool begin -- rayon's global pool, not a dedicated one:
2164            // docs/adr/0013's sweep found the width a dedicated pool would need to pick is
2165            // a broad plateau that the global pool's own free default already sits inside
2166            // at every corpus size tried, and is the only width that stayed competitive
2167            // across idle, fetch-sized and Action-sized concurrent load. A dedicated pool
2168            // would cost a second idle thread pool's worth of memory and startup time to
2169            // land somewhere this measurement found no better than free.
2170            rayon::spawn(move || {
2171                let branch_outcome = probe_branch(&path, repo.as_deref(), kind, &cancel);
2172                let sync_outcome = probe_sync(
2173                    &path,
2174                    repo.as_deref(),
2175                    branch_outcome.as_ref().map(|(settled, ..)| settled),
2176                    kind,
2177                    &cancel,
2178                );
2179                let default_branch_outcome = probe_default_branch_memoised(
2180                    &path,
2181                    repo.as_deref(),
2182                    &common_dir,
2183                    DefaultBranchHints {
2184                        override_branch: override_branch.as_deref(),
2185                        network_branch: network_branch.as_deref(),
2186                    },
2187                    kind,
2188                    &cancel,
2189                    &ChainFactsMemo {
2190                        cache: &chain_cache,
2191                        reads: &chain_reads,
2192                    },
2193                );
2194                let base_outcome = if probes_base {
2195                    probe_base(
2196                        &path,
2197                        repo.as_deref(),
2198                        branch_outcome.as_ref().map(|(settled, ..)| settled),
2199                        default_branch_outcome.as_ref().map(|r| &r.settled),
2200                        &cancel,
2201                    )
2202                } else {
2203                    None
2204                };
2205
2206                // Phases A and B land the moment they answer, per refresh.md's "The
2207                // first frame": every cheap column filled within 200ms, never gated
2208                // on phase C or D's much slower answers below. `default_branch_outcome`
2209                // is cloned here rather than moved, since phase D's landing probe
2210                // below still needs to read it.
2211                apply_cheap_probe_outcomes(
2212                    &table_handle,
2213                    &key,
2214                    generation,
2215                    CheapProbeOutcomes {
2216                        branch: branch_outcome,
2217                        sync: sync_outcome,
2218                        base: base_outcome,
2219                        default_branch: default_branch_outcome.clone(),
2220                    },
2221                );
2222
2223                // Test-only: let a test hold phase C and D open here, after the cheap
2224                // outcomes above are already visible on the table, so the two applies'
2225                // independence can be proven by blocking on a Condvar rather than by racing
2226                // a sleep against a probe.
2227                if let Some(gate) = &held_gate {
2228                    let (lock, cvar) = &**gate;
2229                    let mut state = lock.lock().unwrap();
2230                    state.cheap_landed = true;
2231                    cvar.notify_all();
2232                    state = cvar.wait_while(state, |state| !state.may_proceed).unwrap();
2233                    drop(state);
2234                }
2235
2236                let state_outcome = if probes_state {
2237                    let gate = bound_gates
2238                        .get(&common_dir)
2239                        .expect("every probes_state entity's common dir has a gate sized for it");
2240                    let mut report = GateReport::new(gate);
2241                    let memo = PatchEquivalenceMemo {
2242                        cache: &patch_cache,
2243                        reads: &patch_reads,
2244                        scan_bounds: &patch_scan_bounds,
2245                    };
2246                    probe_worktree_state(
2247                        &path,
2248                        repo.as_deref(),
2249                        default_branch_outcome.as_ref().map(|r| &r.settled),
2250                        &common_dir,
2251                        &cancel,
2252                        &memo,
2253                        &mut report,
2254                    )
2255                } else {
2256                    None
2257                };
2258                let dirty_outcome = probe_status(&path, repo.as_deref(), kind, &cancel);
2259                apply_probe_outcome(
2260                    &table_handle,
2261                    &settle_gate,
2262                    &key,
2263                    generation,
2264                    ProbeOutcomes {
2265                        state: state_outcome,
2266                        dirty: dirty_outcome,
2267                    },
2268                );
2269
2270                // The same handle the cheap gate above blocked on, never a second lookup:
2271                // see where it is resolved.
2272                if let Some(gate) = &held_gate {
2273                    let (lock, cvar) = &**gate;
2274                    let mut state = lock.lock().unwrap();
2275                    state.finished = true;
2276                    cvar.notify_all();
2277                }
2278            });
2279            // scan: probe-fanout-pool end
2280        }
2281    }
2282
2283    /// Re-runs both halves of discovery over `self.set` and reconciles the
2284    /// result into the live table, per [discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md)
2285    /// and [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md).
2286    /// The walk and resolve run outside the table lock, since an abandoned walk
2287    /// can take up to thirty seconds; only reconciling the result briefly holds
2288    /// the write lock. Already-known boundaries reuse their cached repository
2289    /// handle rather than reopening it, which is what keeps re-running discovery
2290    /// every Generation from paying every entity's open cost again.
2291    fn rerun_discovery(&self) {
2292        let repos_cache: HashMap<EntityKey, Arc<gix::ThreadSafeRepository>> =
2293            self.table.read().unwrap().repos.clone();
2294
2295        wait_for_discovery_gate(self.discovery_gate.as_ref());
2296        // The watcher is left detached, as it always has been here: nothing on this
2297        // path reads its handle.
2298        let (watch, _watcher) = spawn_discovery_watcher(
2299            self.set.roots.clone(),
2300            &self.discovery_warning,
2301            self.discovery_warn_after,
2302        );
2303        let discovery = run_watched_discovery(
2304            &watch,
2305            &self.set,
2306            &self.discovery_warning,
2307            Duration::from_nanos(self.discovery_abandon_after.load(Ordering::Acquire)),
2308        );
2309        if discovery.abandoned {
2310            self.discovery_manual.store(true, Ordering::Release);
2311        }
2312
2313        let (discovered, gitmodules_failures) =
2314            discovery::resolve_with_cache(&self.set, &discovery.entities, &repos_cache);
2315
2316        // Copied out before the table lock is taken, never read through it: `set_exclusions`
2317        // takes these two locks in the opposite order, and holding one while asking for the
2318        // other is what would let the two deadlock.
2319        let exclusions = self.exclusions.read().unwrap().clone();
2320        let mut table = self.table.write().unwrap();
2321        table.discovered_at = Timestamp::now();
2322        let cancelled = merge_discovery(&mut table, &exclusions, discovered, gitmodules_failures);
2323        drop(table);
2324        if cancelled > 0 {
2325            complete_many(&self.settle_gate, cancelled);
2326        }
2327    }
2328}
2329
2330impl Drop for Core {
2331    /// Cancels whatever this `Core` still has in flight, then joins the dedicated thread.
2332    ///
2333    /// The cancel is what [`Core::pause`] already does, for the same reason
2334    /// [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
2335    /// "Cancellation" gives: an abandoned Generation is cancelled rather than left to
2336    /// finish, since a Set switch rebuilds the `Core` and the outgoing one's fan-out
2337    /// would otherwise contend for the same cores as the incoming one's. A probe already
2338    /// past its own cancel check still runs to completion on rayon's global pool, which
2339    /// is shared process-wide infrastructure rather than a thread this core spawned, so
2340    /// it is not joined here.
2341    fn drop(&mut self) {
2342        cancel_in_flight(&self.table, &self.settle_gate);
2343        let _ = self.control.send(ClockControl::Shutdown);
2344        if let Some(handle) = self.clock_thread.take() {
2345            let _ = handle.join();
2346        }
2347    }
2348}
2349
2350/// `start_internal`'s result: the running core, plus the three handles a test needs
2351/// to make its threading deterministic instead of sleeping. `Core::start` only
2352/// ever reads `core` out of it; the other three fields exist for
2353/// `Core::start_for_test`.
2354pub(crate) struct StartForTest {
2355    pub core: Core,
2356    #[allow(dead_code)] // read only by tests; the plain lib target never builds them
2357    pub clock_alive: Arc<AtomicBool>,
2358    #[allow(dead_code)] // read only by tests; the plain lib target never builds them
2359    pub discovery_watcher: JoinHandle<()>,
2360    /// The thread the first discovery runs on. Joining it is the rendezvous that
2361    /// says the walk finished and its rows reached the table, with no sleep and no
2362    /// poll anywhere in the wait.
2363    #[allow(dead_code)] // read only by tests; the plain lib target never builds them
2364    pub initial_discovery: Option<JoinHandle<()>>,
2365}
2366
2367#[cfg(test)]
2368impl StartForTest {
2369    /// Blocks until the first discovery has landed on the table, then hands this
2370    /// back so a test reads a populated table rather than the empty one `start`
2371    /// itself returns.
2372    fn discovered(mut self) -> Self {
2373        if let Some(handle) = self.initial_discovery.take() {
2374            handle
2375                .join()
2376                .expect("the first discovery thread should not panic");
2377        }
2378        self
2379    }
2380}
2381
2382impl Core {
2383    /// Puts one already-known entity into the in-flight state a real `refresh`
2384    /// dispatch would, without spawning anything to complete it, so a test can
2385    /// drive the deadline sweep through the tick channel alone and prove the sweep
2386    /// runs on a tick rather than on a clock of its own, or prove that `pause`
2387    /// cancels a real in-flight entry from outside this crate.
2388    ///
2389    /// Gated behind `test-util` (on by default under `cfg(test)` for this crate's own
2390    /// tests) so a test-only affordance never ships on the default published surface,
2391    /// per [ADR 0021](https://github.com/paulchiu/repon/blob/main/docs/adr/0021-a-release-is-what-the-tag-pipeline-publishes.md),
2392    /// the same reason `Timestamp::at` is gated.
2393    #[cfg(any(test, feature = "test-util"))]
2394    pub fn begin_untracked_probe_for_test(&self, key: &EntityKey) -> Arc<AtomicBool> {
2395        let mut table = self.table.write().unwrap();
2396        table.generation += 1;
2397        let generation_number = table.generation;
2398        table
2399            .generation_started_at
2400            .insert(generation_number, Instant::now());
2401        if let Some(&idx) = table.index.get(key) {
2402            begin_probes(&mut table.entities[idx]);
2403        }
2404        let cancel = Arc::new(AtomicBool::new(false));
2405        table.in_flight.insert(
2406            key.clone(),
2407            InFlight {
2408                generation: generation_number,
2409                cancel: Arc::clone(&cancel),
2410            },
2411        );
2412        begin_probes_owed(&self.settle_gate, 1);
2413        cancel
2414    }
2415}
2416
2417/// One simulated in-flight Generation, as [`Core::begin_shared_generation_for_test`]
2418/// left it: the Generation itself, and one interrupt flag per key it covers.
2419#[cfg(test)]
2420pub(crate) struct SharedGeneration {
2421    /// The Generation this simulation minted, so a test can name it and its successor
2422    /// rather than the counter values they happen to hold.
2423    pub generation: Generation,
2424    /// One `cancel` flag per covered key, the same handle a real dispatch would hold.
2425    pub cancels: HashMap<EntityKey, Arc<AtomicBool>>,
2426}
2427
2428#[cfg(test)]
2429impl Core {
2430    /// The cached thread-safe repository handle discovery left for `key`, if any,
2431    /// so a test can prove the cache was actually populated and, by comparing
2432    /// `Arc::ptr_eq` across two reads, that a probe reused it rather than
2433    /// replacing it with a freshly opened one.
2434    pub(crate) fn cached_repo_handle_for_test(
2435        &self,
2436        key: &EntityKey,
2437    ) -> Option<Arc<gix::ThreadSafeRepository>> {
2438        self.table.read().unwrap().repos.get(key).cloned()
2439    }
2440
2441    /// How many times the most recent `refresh` actually computed the
2442    /// default-branch chain's per-common-dir facts, as opposed to reusing an
2443    /// already-computed answer for a common dir another dispatched entity already
2444    /// paid for. What proves the per-common-dir memoisation ran at all: two
2445    /// entities agreeing on their resolved default branch proves nothing on its
2446    /// own, since two distinct common dirs can legitimately agree too.
2447    pub(crate) fn default_branch_chain_reads_for_test(&self) -> usize {
2448        self.default_branch_chain_reads.load(Ordering::Acquire)
2449    }
2450
2451    /// How many times the most recent `refresh` actually scanned a common dir's
2452    /// default-branch commit history for patch equivalence, as opposed to
2453    /// reusing an already-computed scan for a common dir another dispatched
2454    /// entity already paid for. The same proof `default_branch_chain_reads_for_test`
2455    /// gives the default-branch chain, for patch equivalence's own memo.
2456    pub(crate) fn patch_identity_reads_for_test(&self) -> usize {
2457        self.patch_identity_reads.load(Ordering::Acquire)
2458    }
2459
2460    /// The bound each actually-run `scan_default_branch` call this Generation
2461    /// used, one entry per common dir it ran for, in run order. Unlike
2462    /// `patch_identity_reads_for_test`, which only proves a scan ran once per
2463    /// common dir, this proves *what* it was bounded by: the deepest merge base
2464    /// among the dispatched siblings, per `BoundGate::deepest`, rather than
2465    /// whichever entity's own merge base happened to reach the scan first.
2466    pub(crate) fn patch_scan_bounds_for_test(&self) -> Vec<Option<gix::ObjectId>> {
2467        self.patch_scan_bounds.lock().unwrap().clone()
2468    }
2469
2470    /// Every key the most recent `refresh` call's own sequential dispatch loop iterated,
2471    /// in that order: dispatch order, proven directly rather than inferred from completion,
2472    /// which a concurrent pool never guarantees (criterion 5's honest half).
2473    pub(crate) fn dispatch_log_for_test(&self) -> Vec<EntityKey> {
2474        self.dispatch_log.lock().unwrap().clone()
2475    }
2476
2477    /// Runs one metadata-poll sweep synchronously on the calling thread: the exact
2478    /// work the dedicated thread's tick arm performs, called directly so a test can
2479    /// prove the sweep's own effects without racing the injected tick channel's
2480    /// delivery to that other thread.
2481    pub(crate) fn poll_once_for_test(&self) {
2482        run_poll_sweep(
2483            &self.table,
2484            &self.overrides,
2485            &self.show_submodules,
2486            &self.poll_reprobed,
2487            &self.poll_sweep_count,
2488            &self.network_default_branch,
2489        );
2490    }
2491
2492    /// Every key the most recent `poll_once_for_test` call actually re-ran phases A
2493    /// and B for, in the order it found them moved: proves "for that entity only"
2494    /// by naming exactly which entities were touched, not merely that one of them
2495    /// was.
2496    pub(crate) fn poll_reprobed_for_test(&self) -> Vec<EntityKey> {
2497        self.poll_reprobed.lock().unwrap().clone()
2498    }
2499
2500    /// How many metadata-poll sweeps have run in total, so a test driving the real
2501    /// dedicated thread through its injected tick channel can prove a tick reached
2502    /// the sweep at all, not only what the sweep did once it ran.
2503    pub(crate) fn poll_sweep_count_for_test(&self) -> usize {
2504        self.poll_sweep_count.load(Ordering::Acquire)
2505    }
2506
2507    /// This `Core`'s own [`ActionCompletionBoundary`], to arm before the run whose
2508    /// completion a test wants held open. For a test.
2509    #[cfg(test)]
2510    pub(crate) fn action_completion_boundary(&self) -> Arc<ActionCompletionBoundary> {
2511        Arc::clone(&self.action_completion_boundary)
2512    }
2513
2514    /// Registers a closed phase C/D gate for `key`, so the next `refresh` that
2515    /// dispatches it will land its cheap outcomes, then block before touching
2516    /// phase C or D until [`Core::release_phase_c_for_test`] opens the gate.
2517    /// Must be called before the dispatching `refresh`, since a Generation resolves
2518    /// each entity's gate as it dispatches it and its probes signal that one alone.
2519    pub(crate) fn hold_phase_c_for_test(&self, key: &EntityKey) {
2520        self.phase_c_gates.lock().unwrap().insert(
2521            key.clone(),
2522            Arc::new((Mutex::new(PhaseCGate::default()), Condvar::new())),
2523        );
2524    }
2525
2526    /// Blocks the calling thread, with no sleep or poll, until `key`'s cheap
2527    /// outcomes have landed on the table. Panics if `key` has no gate
2528    /// registered, since that means the test forgot [`Core::hold_phase_c_for_test`].
2529    pub(crate) fn wait_phase_c_landed_for_test(&self, key: &EntityKey) {
2530        let gate = self
2531            .phase_c_gates
2532            .lock()
2533            .unwrap()
2534            .get(key)
2535            .cloned()
2536            .expect("hold_phase_c_for_test must be called before waiting on its gate");
2537        let (lock, cvar) = &*gate;
2538        let guard = lock.lock().unwrap();
2539        drop(cvar.wait_while(guard, |state| !state.cheap_landed).unwrap());
2540    }
2541
2542    /// Lets `key`'s held phase C and D proceed. Does not itself wait for them to
2543    /// finish; pair with [`Core::wait_phase_c_finished_for_test`].
2544    pub(crate) fn release_phase_c_for_test(&self, key: &EntityKey) {
2545        let gate = self
2546            .phase_c_gates
2547            .lock()
2548            .unwrap()
2549            .get(key)
2550            .cloned()
2551            .expect("hold_phase_c_for_test must be called before releasing its gate");
2552        let (lock, cvar) = &*gate;
2553        let mut state = lock.lock().unwrap();
2554        state.may_proceed = true;
2555        cvar.notify_all();
2556    }
2557
2558    /// Blocks the calling thread, with no sleep or poll, until `key`'s phase C/D
2559    /// outcome has been applied and the settle gate decremented for it.
2560    pub(crate) fn wait_phase_c_finished_for_test(&self, key: &EntityKey) {
2561        let gate = self
2562            .phase_c_gates
2563            .lock()
2564            .unwrap()
2565            .get(key)
2566            .cloned()
2567            .expect("hold_phase_c_for_test must be called before waiting on its gate");
2568        let (lock, cvar) = &*gate;
2569        let guard = lock.lock().unwrap();
2570        drop(cvar.wait_while(guard, |state| !state.finished).unwrap());
2571    }
2572
2573    /// Blocks the calling thread, with no sleep and no poll, until every Generation
2574    /// reserved so far has finished dispatching: what a test waits on before reading
2575    /// a count a dispatch raises, now that a Generation reserves its number on the
2576    /// calling thread and raises that count on one of its own.
2577    pub(crate) fn wait_dispatched_for_test(&self) {
2578        let (lock, cvar) = &*self.settle_gate;
2579        let guard = lock.lock().unwrap();
2580        drop(
2581            cvar.wait_while(guard, |counts| counts.dispatches > 0)
2582                .unwrap(),
2583        );
2584    }
2585
2586    /// The settle gate's raw outstanding count, so a test can prove a single
2587    /// dispatched entity's split write decrements it exactly once overall,
2588    /// neither twice (an early `settle`) nor zero times (a `settle` that hangs).
2589    pub(crate) fn settle_gate_count_for_test(&self) -> usize {
2590        self.settle_gate.0.lock().unwrap().probes
2591    }
2592
2593    /// `start`, with the tick source and the discovery-slow warning's threshold
2594    /// injected rather than real, so a test drives the dedicated thread's cadence
2595    /// through a channel it controls and never waits out a real second.
2596    pub(crate) fn start_for_test(
2597        spec: CoreSpec,
2598        warn_after: Duration,
2599        ticks: Receiver<Instant>,
2600    ) -> StartForTest {
2601        Self::start_for_test_with_discovery_abandon(
2602            spec,
2603            warn_after,
2604            discovery::ABANDON_AFTER,
2605            ticks,
2606        )
2607    }
2608
2609    /// `start_for_test`, with the discovery abandon deadline also injected, so a
2610    /// test can force a walk to abandon deterministically instead of running one
2611    /// for the real thirty seconds. The periodic fetch is always off here: a test
2612    /// that wants it runs [`Core::start_for_test_with_fetch`] instead, which is
2613    /// what keeps this constructor's own signature free of a feature-gated
2614    /// parameter.
2615    pub(crate) fn start_for_test_with_discovery_abandon(
2616        spec: CoreSpec,
2617        warn_after: Duration,
2618        discovery_abandon_after: Duration,
2619        ticks: Receiver<Instant>,
2620    ) -> StartForTest {
2621        Self::start_for_test_gated(spec, warn_after, discovery_abandon_after, ticks, None)
2622    }
2623
2624    /// `start_for_test_with_discovery_abandon`, with the discovery gate injected: with a
2625    /// closed one, every walk this `Core` starts blocks before it begins, so a caller's own
2626    /// return is observed against a walk that provably has not run.
2627    pub(crate) fn start_for_test_gated(
2628        spec: CoreSpec,
2629        warn_after: Duration,
2630        discovery_abandon_after: Duration,
2631        ticks: Receiver<Instant>,
2632        discovery_gate: Option<DiscoveryGate>,
2633    ) -> StartForTest {
2634        let alive = Arc::new(AtomicBool::new(true));
2635        start_internal(
2636            spec,
2637            warn_after,
2638            discovery_abandon_after,
2639            ticks,
2640            FetchStart {
2641                enabled: false,
2642                concurrency: 1,
2643                ticks: crossbeam_channel::never(),
2644            },
2645            alive,
2646            discovery_gate,
2647        )
2648    }
2649
2650    /// `start_for_test_with_discovery_abandon`, with the periodic fetch's own tick
2651    /// channel injected too, so a test can prove the recurring cadence without
2652    /// waiting out a real `fetch.interval`. `spec.fetch.enabled` still governs
2653    /// whether the immediate first cycle fires; `fetch_ticks` governs every cycle
2654    /// after that.
2655    pub(crate) fn start_for_test_with_fetch(
2656        spec: CoreSpec,
2657        warn_after: Duration,
2658        ticks: Receiver<Instant>,
2659        fetch_ticks: Receiver<Instant>,
2660    ) -> StartForTest {
2661        let alive = Arc::new(AtomicBool::new(true));
2662        let fetch_start = FetchStart {
2663            enabled: spec.fetch.enabled,
2664            concurrency: spec.fetch.concurrency.max(1),
2665            ticks: fetch_ticks,
2666        };
2667        start_internal(
2668            spec,
2669            warn_after,
2670            discovery::ABANDON_AFTER,
2671            ticks,
2672            fetch_start,
2673            alive,
2674            None,
2675        )
2676    }
2677
2678    /// How many periodic-fetch cycles have run in total: the immediate first one
2679    /// plus one per `fetch.interval` tick since, whether or not any repository had
2680    /// a remote to fetch.
2681    pub(crate) fn fetch_cycle_count_for_test(&self) -> usize {
2682        self.fetch_cycle_count.load(Ordering::Acquire)
2683    }
2684
2685    /// Whether an abandoned discovery has already taken this `Core` out of the
2686    /// automatic refresh path, so a test can assert the precondition explicitly
2687    /// rather than infer it from a later refresh's behaviour alone.
2688    /// Tightens the abandon deadline after `start`, so a test can let the first walk
2689    /// finish under a deadline it cannot lose against and still force a later walk to
2690    /// abandon.
2691    #[cfg(test)]
2692    pub(crate) fn set_discovery_abandon_after_for_test(&self, after: Duration) {
2693        self.discovery_abandon_after
2694            .store(after.as_nanos() as u64, Ordering::Release);
2695    }
2696
2697    pub(crate) fn discovery_manual_for_test(&self) -> bool {
2698        self.discovery_manual.load(Ordering::Acquire)
2699    }
2700
2701    /// Puts several already-known entities into the in-flight state of one shared
2702    /// Generation, without spawning anything to complete them and without
2703    /// touching the settle gate, so a test can drive per-entity supersession
2704    /// directly: which keys a later real `refresh` does and does not cover, and
2705    /// what happens to each one's own cancel flag and eventual result.
2706    ///
2707    /// Hands back the Generation it minted rather than only the flags, so the test
2708    /// names that Generation and its successor instead of the counter values they
2709    /// happen to hold.
2710    pub(crate) fn begin_shared_generation_for_test(&self, keys: &[EntityKey]) -> SharedGeneration {
2711        let mut table = self.table.write().unwrap();
2712        table.generation += 1;
2713        let generation_number = table.generation;
2714        table
2715            .generation_started_at
2716            .insert(generation_number, Instant::now());
2717        let mut cancels = HashMap::new();
2718        for key in keys {
2719            if let Some(&idx) = table.index.get(key) {
2720                table.entities[idx].branch.begin_probe();
2721            }
2722            let cancel = Arc::new(AtomicBool::new(false));
2723            table.in_flight.insert(
2724                key.clone(),
2725                InFlight {
2726                    generation: generation_number,
2727                    cancel: Arc::clone(&cancel),
2728                },
2729            );
2730            cancels.insert(key.clone(), cancel);
2731        }
2732        SharedGeneration {
2733            generation: Generation::new(generation_number),
2734            cancels,
2735        }
2736    }
2737
2738    /// Lands one branch probe result for `key` at `generation` through the exact
2739    /// same path a real dispatched probe's cheap outcomes take
2740    /// ([`apply_cheap_probe_outcomes`]), so a test can simulate a result arriving
2741    /// late, out of Generation order, without a second, weaker implementation of
2742    /// the write-time supersession check.
2743    pub(crate) fn apply_probe_result_for_test(
2744        &self,
2745        key: &EntityKey,
2746        generation: Generation,
2747        settled: Settled<Head>,
2748    ) {
2749        apply_cheap_probe_outcomes(
2750            &self.table,
2751            key,
2752            generation,
2753            CheapProbeOutcomes {
2754                branch: Some((settled, None, Vec::new())),
2755                sync: None,
2756                base: None,
2757                default_branch: None,
2758            },
2759        );
2760    }
2761
2762    /// Writes `receipt` directly onto `key`'s `last_action`, bypassing `run_action`
2763    /// entirely: lets a test put an exact, hand-built receipt on a live `Core`'s table
2764    /// without spawning any real child process.
2765    pub(crate) fn set_last_action_for_test(
2766        &self,
2767        key: &EntityKey,
2768        receipt: crate::entity::ActionReceipt,
2769    ) {
2770        let mut table = self.table.write().unwrap();
2771        if let Some(&idx) = table.index.get(key) {
2772            table.entities[idx].last_action = Some(receipt);
2773        }
2774    }
2775}
2776
2777/// One entity's whole Action run: every step in `action.steps`, in order, stopping at
2778/// the first failure, with every step after it recorded `NotRun` rather than silently
2779/// skipped ([config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md)'s
2780/// "Actions", `docs/spec/actions.md`'s "Step outcomes"). Never called for an excluded,
2781/// inapplicable or unresolved entity: [`Core::run_action`] gives those their own `Skip`
2782/// receipt itself and never reaches this function for them.
2783///
2784/// `control` is the same `RunControl` every other entity's run in this fan-out shares:
2785/// checked before every step starts, so a step not yet reached when `control.cancel` fires
2786/// becomes `Cancelled` rather than ever spawning, and again the instant a spawned step's
2787/// `run_step` call returns, so a step that was actually running when cancellation fired
2788/// becomes `Cancelled` regardless of the exit `run_step` itself observed (a signalled child
2789/// has no clean outcome of its own to report). `Cancelled` and `NotRun` are deliberately
2790/// kept apart here: once cancellation is seen, every remaining step (including a step
2791/// already past the "before it starts" check but not yet run) is `Cancelled`, never
2792/// `NotRun`, which stays reserved for being blocked by an earlier failure
2793/// (`docs/spec/actions.md`'s "Step outcomes").
2794///
2795/// `report` is called once per step, immediately before that step starts, with a receipt
2796/// whose `running` names it: the caller writes this straight onto the table, which is what
2797/// lets a still-running step's own label and elapsed time reach a reader before the whole
2798/// entity's run has finished (`docs/spec/actions.md`'s "The run on screen"). The final
2799/// return value is the same shape with `running: None`, the caller's job to write once more.
2800fn run_action_for_entity(
2801    entity: &EntityState,
2802    action: &ActionSpec,
2803    control: &Arc<executor::RunControl>,
2804    report: &dyn Fn(ActionReceipt),
2805) -> ActionReceipt {
2806    let base_env = environment::environment(entity, action.name.as_deref());
2807    let mut failed = false;
2808    let mut cancelled = false;
2809    let mut results: Vec<StepResult> = Vec::with_capacity(action.steps.len());
2810    for step in &action.steps {
2811        if failed || cancelled || control.is_cancelled() {
2812            cancelled = cancelled || control.is_cancelled();
2813            results.push(StepResult {
2814                label: Arc::from(step.argv.join(" ")),
2815                outcome: if cancelled {
2816                    StepOutcome::Cancelled
2817                } else {
2818                    StepOutcome::NotRun
2819                },
2820                output: Arc::from(&b""[..]),
2821                elapsed: Duration::ZERO,
2822                elision: None,
2823                shell: step.shell,
2824                interactive: step.interactive,
2825            });
2826            continue;
2827        }
2828        let label: Arc<str> = Arc::from(step.argv.join(" "));
2829        report(ActionReceipt {
2830            label: Arc::clone(&action.label),
2831            steps: Arc::from(results.clone()),
2832            skip: None,
2833            finished_at: Timestamp::now(),
2834            running: Some(RunningStep {
2835                label: Arc::clone(&label),
2836                started_at: Timestamp::now(),
2837                shell: step.shell,
2838                interactive: step.interactive,
2839            }),
2840        });
2841        // The step's own `env` table is applied after the environment contract's
2842        // set-or-unset pairs, so it overrides the guaranteed set exactly as a
2843        // Launcher's own `env` field already does (`docs/spec/config.md`'s
2844        // "Launchers").
2845        let mut env = base_env.clone();
2846        env.extend(
2847            step.env
2848                .iter()
2849                .map(|(name, value)| (name.clone(), Some(value.clone()))),
2850        );
2851        let mut result = executor::run_step(
2852            &step.argv,
2853            step.shell,
2854            step.interactive,
2855            entity.key.path(),
2856            &env,
2857            control,
2858        );
2859        if control.is_cancelled() {
2860            result.outcome = StepOutcome::Cancelled;
2861            cancelled = true;
2862        } else {
2863            failed = result.outcome.is_failure();
2864        }
2865        results.push(result);
2866    }
2867    ActionReceipt {
2868        label: Arc::clone(&action.label),
2869        steps: Arc::from(results),
2870        skip: None,
2871        finished_at: Timestamp::now(),
2872        running: None,
2873    }
2874}
2875
2876/// A gate a test closes to hold every discovery walk this `Core` starts, at the
2877/// point before the walk begins, so a caller's own return can be observed against a
2878/// walk that provably has not run. `None` on every production path, the same way
2879/// `Core::phase_c_gates` is empty on one.
2880type DiscoveryGate = Arc<(Mutex<bool>, Condvar)>;
2881
2882/// Blocks while `gate` is closed, and returns at once when there is none, which is
2883/// every production path.
2884fn wait_for_discovery_gate(gate: Option<&DiscoveryGate>) {
2885    let Some(gate) = gate else {
2886        return;
2887    };
2888    let (lock, cvar) = &**gate;
2889    let open = lock.lock().unwrap();
2890    drop(cvar.wait_while(open, |open| !*open).unwrap());
2891}
2892
2893/// Opens or closes a [`DiscoveryGate`], waking whatever walk is held on it.
2894#[cfg(test)]
2895fn set_discovery_gate(gate: &DiscoveryGate, open: bool) {
2896    let (lock, cvar) = &**gate;
2897    *lock.lock().unwrap() = open;
2898    cvar.notify_all();
2899}
2900
2901/// What one watched discovery walk and the thread watching it share: the counter
2902/// the walk bumps as it goes, and the flag it sets on finishing.
2903struct DiscoveryWatch {
2904    progress: Arc<AtomicUsize>,
2905    finished: Arc<AtomicBool>,
2906}
2907
2908/// Arms the still-walking watcher for a walk that has not started yet, leaving the
2909/// still-walking warning behind in `discovery_warning` if that walk outruns
2910/// `warn_after`. Separate from [`run_watched_discovery`] so `start_internal` can arm
2911/// it on the calling thread, and hand a test its handle, while the walk it watches
2912/// runs on a thread of its own.
2913fn spawn_discovery_watcher(
2914    roots: Vec<PathBuf>,
2915    discovery_warning: &Arc<Mutex<Option<String>>>,
2916    warn_after: Duration,
2917) -> (DiscoveryWatch, JoinHandle<()>) {
2918    let progress = Arc::new(AtomicUsize::new(0));
2919    let finished = Arc::new(AtomicBool::new(false));
2920    let watcher = thread::spawn({
2921        let progress = Arc::clone(&progress);
2922        let finished = Arc::clone(&finished);
2923        let warning_slot = Arc::clone(discovery_warning);
2924        move || {
2925            if let Some(message) = watch_for_slow_discovery(progress, finished, roots, warn_after) {
2926                *warning_slot.lock().unwrap() = Some(message);
2927            }
2928        }
2929    });
2930    (DiscoveryWatch { progress, finished }, watcher)
2931}
2932
2933/// Runs one discovery boundary walk against `set` under an already-armed `watch`,
2934/// leaving the abandoned-discovery warning in `discovery_warning` if the walk
2935/// abandons past `abandon_after`. Shared by `start_internal`'s first walk and
2936/// `rerun_discovery`'s later ones, so a refresh-triggered abandon runs the same
2937/// wiring `start`'s own walk does, never a parallel copy of it.
2938fn run_watched_discovery(
2939    watch: &DiscoveryWatch,
2940    set: &SetSpec,
2941    discovery_warning: &Arc<Mutex<Option<String>>>,
2942    abandon_after: Duration,
2943) -> discovery::Discovery {
2944    let discovery =
2945        discovery::discover_watched_with_deadline(set, Arc::clone(&watch.progress), abandon_after);
2946    watch.finished.store(true, Ordering::Release);
2947
2948    if discovery.abandoned {
2949        *discovery_warning.lock().unwrap() =
2950            Some(abandoned_discovery_message(discovery.directories_visited));
2951    }
2952
2953    discovery
2954}
2955
2956/// Shared body of `start` and `start_for_test`: builds the empty table, spawns the
2957/// dedicated thread, and starts the first discovery on a thread of its own.
2958fn start_internal(
2959    spec: CoreSpec,
2960    warn_after: Duration,
2961    discovery_abandon_after: Duration,
2962    ticks: Receiver<Instant>,
2963    fetch_start: FetchStart,
2964    alive: Arc<AtomicBool>,
2965    discovery_gate: Option<DiscoveryGate>,
2966) -> StartForTest {
2967    let FetchStart {
2968        enabled: fetch_enabled,
2969        concurrency: fetch_concurrency,
2970        ticks: fetch_ticks,
2971    } = fetch_start;
2972    let discovery_warning = Arc::new(Mutex::new(None));
2973    let discovery_manual = Arc::new(AtomicBool::new(false));
2974
2975    let (overrides, resolved_exclusions) = resolve_entries(&spec.overrides);
2976    let overrides = Arc::new(overrides);
2977    let exclusions = Arc::new(RwLock::new(resolved_exclusions));
2978    let show_submodules = Arc::new(AtomicBool::new(spec.show_submodules));
2979
2980    let table = Arc::new(RwLock::new(Table {
2981        generation: 0,
2982        discovered_at: Timestamp::now(),
2983        entities: Vec::new(),
2984        index: HashMap::new(),
2985        in_flight: HashMap::new(),
2986        generation_started_at: HashMap::new(),
2987        repos: HashMap::new(),
2988        poll_fingerprints: HashMap::new(),
2989    }));
2990
2991    let settle_gate: Arc<SettleGate> =
2992        Arc::new((Mutex::new(SettleCounts::default()), Condvar::new()));
2993    let poll_reprobed = Arc::new(Mutex::new(Vec::new()));
2994    let poll_sweep_count = Arc::new(AtomicUsize::new(0));
2995    let network_default_branch = Arc::new(Mutex::new(HashMap::new()));
2996    let (control, control_rx) = crossbeam_channel::unbounded();
2997    let poll_handles = PollHandles {
2998        overrides: Arc::clone(&overrides),
2999        show_submodules: Arc::clone(&show_submodules),
3000        poll_reprobed: Arc::clone(&poll_reprobed),
3001        poll_sweep_count: Arc::clone(&poll_sweep_count),
3002        network_default_branch: Arc::clone(&network_default_branch),
3003    };
3004
3005    // Hoisted out of the `Core` struct literal below, rather than built inline
3006    // there as before this field existed: `RefreshHandles` needs its own clone of
3007    // each of these, constructed before `Core` takes ownership of the originals.
3008    let discovery_abandon_after_atomic =
3009        Arc::new(AtomicU64::new(discovery_abandon_after.as_nanos() as u64));
3010    let default_branch_chain_reads = Arc::new(AtomicUsize::new(0));
3011    let patch_identity_reads = Arc::new(AtomicUsize::new(0));
3012    let patch_scan_bounds = Arc::new(Mutex::new(Vec::new()));
3013    let dispatch_log = Arc::new(Mutex::new(Vec::new()));
3014    let phase_c_gates = Arc::new(Mutex::new(HashMap::new()));
3015    let fetch_cycle_count = Arc::new(AtomicUsize::new(0));
3016    let fetch_failures = Arc::new(Mutex::new(FetchFailures::default()));
3017    let turnstile = Arc::new(DispatchTurnstile::default());
3018
3019    let fetch_refresh_handles = RefreshHandles {
3020        table: Arc::clone(&table),
3021        overrides: Arc::clone(&overrides),
3022        exclusions: Arc::clone(&exclusions),
3023        set: spec.set.clone(),
3024        discovery_manual: Arc::clone(&discovery_manual),
3025        discovery_warn_after: warn_after,
3026        discovery_abandon_after: Arc::clone(&discovery_abandon_after_atomic),
3027        discovery_warning: Arc::clone(&discovery_warning),
3028        show_submodules: Arc::clone(&show_submodules),
3029        settle_gate: Arc::clone(&settle_gate),
3030        default_branch_chain_reads: Arc::clone(&default_branch_chain_reads),
3031        patch_identity_reads: Arc::clone(&patch_identity_reads),
3032        patch_scan_bounds: Arc::clone(&patch_scan_bounds),
3033        dispatch_log: Arc::clone(&dispatch_log),
3034        phase_c_gates: Arc::clone(&phase_c_gates),
3035        network_default_branch: Arc::clone(&network_default_branch),
3036        turnstile: Arc::clone(&turnstile),
3037        discovery_gate: discovery_gate.clone(),
3038    };
3039    let auto_update_enabled = spec.auto_update.enabled;
3040    let fetch_schedule = FetchSchedule {
3041        concurrency: fetch_concurrency,
3042        ticks: fetch_ticks,
3043        refresh: fetch_refresh_handles.clone(),
3044        cycle_count: Arc::clone(&fetch_cycle_count),
3045        failures: Arc::clone(&fetch_failures),
3046        auto_update_enabled,
3047    };
3048
3049    let clock_thread = spawn_clock_thread(
3050        Arc::clone(&table),
3051        poll_handles,
3052        fetch_schedule,
3053        Arc::clone(&settle_gate),
3054        spec.generation_deadline,
3055        ClockChannels {
3056            control: control_rx,
3057            ticks,
3058            alive: Arc::clone(&alive),
3059        },
3060    );
3061
3062    // Discovery runs here rather than on the calling thread, so `Core::start`
3063    // returns against the empty table above and the consumer can claim the terminal
3064    // and draw before the walk has finished (ADR 0015's "a constructor that spawns
3065    // threads is not a surprise"). This walk is also refresh.md's "Startup"
3066    // Generation, so a launch walks the tree once: the number and the turnstile place
3067    // are reserved here on the calling thread, exactly as every later Generation
3068    // reserves its own, and the walk and the fan-out it orders both run on the
3069    // spawned thread. The debt is recorded before the spawn, so a `settle` called in
3070    // between waits for this Generation rather than returning on an empty table.
3071    let (startup_generation, startup_ticket) = fetch_refresh_handles.reserve_generation();
3072    begin_dispatch(&settle_gate);
3073    let (watch, discovery_watcher) =
3074        spawn_discovery_watcher(spec.set.roots.clone(), &discovery_warning, warn_after);
3075    let initial_discovery = thread::spawn({
3076        let set = spec.set.clone();
3077        let discovery_warning = Arc::clone(&discovery_warning);
3078        let discovery_manual = Arc::clone(&discovery_manual);
3079        let exclusions = Arc::clone(&exclusions);
3080        let table = Arc::clone(&table);
3081        let settle_gate = Arc::clone(&settle_gate);
3082        let fetch_refresh_handles = fetch_refresh_handles.clone();
3083        let fetch_cycle_count = Arc::clone(&fetch_cycle_count);
3084        let fetch_failures = Arc::clone(&fetch_failures);
3085        let discovery_gate = discovery_gate.clone();
3086        move || {
3087            let turn = fetch_refresh_handles.turnstile.take(startup_ticket);
3088            wait_for_discovery_gate(discovery_gate.as_ref());
3089            let discovery =
3090                run_watched_discovery(&watch, &set, &discovery_warning, discovery_abandon_after);
3091            if discovery.abandoned {
3092                discovery_manual.store(true, Ordering::Release);
3093            }
3094
3095            // Discovery's second half: every boundary the walk just found becomes a
3096            // Repo or a Worktree, and each one's own `.gitmodules` (never recursed
3097            // into) names its Submodules. One combined list, with nothing recording
3098            // which half produced a given entry.
3099            let (discovered, gitmodules_failures) = discovery::resolve(&set, &discovery.entities);
3100            let resolved_exclusions = exclusions.read().unwrap().clone();
3101            let order: Vec<EntityKey> = {
3102                let mut table = table.write().unwrap();
3103                // A fresh table has nothing in flight yet, so nothing here is ever
3104                // cancelled: the same reconciliation `refresh` uses later, run once
3105                // against an empty starting point.
3106                merge_discovery(
3107                    &mut table,
3108                    &resolved_exclusions,
3109                    discovered,
3110                    gitmodules_failures,
3111                );
3112                table.discovered_at = Timestamp::now();
3113                table
3114                    .entities
3115                    .iter()
3116                    .map(|entity| entity.key.clone())
3117                    .collect()
3118            };
3119            // Read off the table this walk just reconciled, the same way
3120            // `dispatch_over_everything` resolves its own order: nobody holding the
3121            // empty table `start` returned has a key to name yet.
3122            fetch_refresh_handles.dispatch_probes(&order, startup_generation);
3123            finish_dispatch(&settle_gate);
3124            // Released here rather than at thread exit: the first fetch cycle spawned
3125            // below is not part of this Generation's body.
3126            drop(turn);
3127
3128            // "Fires immediately on being enabled rather than waiting for the first
3129            // tick" ([refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
3130            // "The periodic fetch"): the recurring cadence only ever fires after a full
3131            // `fetch.interval` has elapsed, so the first cycle is dispatched here, once,
3132            // on its own plain thread rather than on the dedicated clock thread, which
3133            // must stay free to keep polling and sweeping deadlines while this cycle
3134            // runs. From inside this thread rather than beside it, because a cycle reads
3135            // the table to know what to fetch and the walk above is what puts anything
3136            // in it.
3137            if fetch_enabled {
3138                let table = Arc::clone(&table);
3139                thread::spawn(move || {
3140                    run_fetch_cycle(
3141                        &table,
3142                        fetch_concurrency,
3143                        &fetch_refresh_handles,
3144                        &fetch_cycle_count,
3145                        &fetch_failures,
3146                        auto_update_enabled,
3147                    );
3148                });
3149            }
3150        }
3151    });
3152
3153    StartForTest {
3154        core: Core {
3155            table,
3156            overrides,
3157            exclusions,
3158            set: spec.set,
3159            discovery_manual,
3160            discovery_warn_after: warn_after,
3161            discovery_abandon_after: discovery_abandon_after_atomic,
3162            show_submodules,
3163            settle_gate,
3164            control,
3165            clock_thread: Some(clock_thread),
3166            discovery_warning,
3167            default_branch_chain_reads,
3168            patch_identity_reads,
3169            patch_scan_bounds,
3170            action_lifecycle: Arc::new(Mutex::new(ActionLifecycle::default())),
3171            dispatch_log,
3172            phase_c_gates,
3173            status_stale_after: spec.status_stale_after,
3174            poll_reprobed,
3175            poll_sweep_count,
3176            fetch_cycle_count,
3177            network_default_branch,
3178            fetch_failures,
3179            turnstile,
3180            discovery_gate,
3181            #[cfg(test)]
3182            action_completion_boundary: Arc::new(ActionCompletionBoundary::default()),
3183        },
3184        clock_alive: alive,
3185        discovery_watcher,
3186        initial_discovery: Some(initial_discovery),
3187    }
3188}
3189
3190/// Everything the dedicated thread's tick arm needs for [`run_poll_sweep`] beyond
3191/// the table it already takes, bundled so `spawn_clock_thread` stays within
3192/// clippy's argument limit.
3193struct PollHandles {
3194    overrides: Arc<Vec<ResolvedOverride>>,
3195    show_submodules: Arc<AtomicBool>,
3196    poll_reprobed: Arc<Mutex<Vec<EntityKey>>>,
3197    poll_sweep_count: Arc<AtomicUsize>,
3198    /// [`Core::network_default_branch`]'s own clone, so a poll-triggered re-probe
3199    /// still reflects an already-superseded default branch rather than reverting
3200    /// to the local chain's own answer until the next full refresh.
3201    network_default_branch: Arc<Mutex<HashMap<PathBuf, Arc<str>>>>,
3202}
3203
3204/// What [`start_internal`] needs from `CoreSpec::fetch` to schedule the periodic fetch,
3205/// bundled into one argument rather than three so this crate's own `clippy::too_many_arguments`
3206/// budget has room for it: extracted once at each of `Core::start`'s two callers.
3207struct FetchStart {
3208    enabled: bool,
3209    concurrency: usize,
3210    ticks: Receiver<Instant>,
3211}
3212
3213/// The periodic fetch's own scheduling inputs, threaded through [`start_internal`]
3214/// and [`spawn_clock_thread`] as plain values rather than reading `CoreSpec::fetch`
3215/// directly: extracted once at each of the two callers. Carries no `enabled` flag
3216/// of its own: `ticks` is [`crossbeam_channel::never`] whenever the periodic fetch
3217/// is off, so the arm that reads it simply never fires, the same way the poll's
3218/// own `ticks` does when a test has no interest in it.
3219struct FetchSchedule {
3220    concurrency: usize,
3221    ticks: Receiver<Instant>,
3222    refresh: RefreshHandles,
3223    cycle_count: Arc<AtomicUsize>,
3224    failures: Arc<Mutex<FetchFailures>>,
3225    /// `CoreSpec::auto_update`'s own `enabled` flag, read once at `start` like every
3226    /// other field on [`FetchSchedule`]: the fast-forward-only update carries no
3227    /// interval of its own, so there is no separate tick to gate it on, only this.
3228    auto_update_enabled: bool,
3229}
3230
3231/// The dedicated thread's own control-plane wiring, bundled into one argument so
3232/// [`spawn_clock_thread`] stays within clippy's argument limit: `control` is the
3233/// pause/resume/shutdown channel every `Core` method sends into, `ticks` drives the
3234/// poll and deadline sweep, and `alive` is the flag the thread clears on its way out
3235/// (both for a test to observe and for nothing else, since `Drop` joins the handle
3236/// directly rather than polling this).
3237struct ClockChannels {
3238    control: Receiver<ClockControl>,
3239    ticks: Receiver<Instant>,
3240    alive: Arc<AtomicBool>,
3241}
3242
3243/// The dedicated thread: the metadata poll tick, the Generation deadline sweep and
3244/// the periodic fetch's own tick share this one interval loop, separate from the
3245/// probe pool and from any render loop, so suspending the terminal reschedules
3246/// none of it. Driven by `ticks` and `fetch.ticks` rather than a bare
3247/// `thread::sleep`, which is what a test replaces to make the cadence
3248/// deterministic. The poll and deadline sweep run first on every `ticks` tick,
3249/// both while `!paused`; a fetch cycle runs on every `fetch.ticks` tick, also only
3250/// while `!paused`, so a suspended Repon neither sweeps nor fetches while the user
3251/// is in a Launcher.
3252fn spawn_clock_thread(
3253    table: Arc<RwLock<Table>>,
3254    poll: PollHandles,
3255    fetch: FetchSchedule,
3256    settle_gate: Arc<SettleGate>,
3257    generation_deadline: Duration,
3258    channels: ClockChannels,
3259) -> JoinHandle<()> {
3260    let ClockChannels {
3261        control,
3262        ticks,
3263        alive,
3264    } = channels;
3265    thread::spawn(move || {
3266        let mut paused = false;
3267        loop {
3268            select! {
3269                recv(control) -> message => match message {
3270                    Ok(ClockControl::Pause) => {
3271                        paused = true;
3272                        cancel_in_flight(&table, &settle_gate);
3273                    }
3274                    Ok(ClockControl::Resume) => paused = false,
3275                    Ok(ClockControl::Shutdown) | Err(_) => break,
3276                },
3277                recv(ticks) -> tick => {
3278                    if tick.is_err() {
3279                        break;
3280                    }
3281                    if !paused {
3282                        run_poll_sweep(
3283                            &table,
3284                            &poll.overrides,
3285                            &poll.show_submodules,
3286                            &poll.poll_reprobed,
3287                            &poll.poll_sweep_count,
3288                            &poll.network_default_branch,
3289                        );
3290                        sweep_deadline(&table, &settle_gate, generation_deadline);
3291                    }
3292                }
3293                recv(fetch.ticks) -> tick => {
3294                    if tick.is_err() {
3295                        break;
3296                    }
3297                    if !paused {
3298                        run_fetch_cycle(
3299                            &table,
3300                            fetch.concurrency,
3301                            &fetch.refresh,
3302                            &fetch.cycle_count,
3303                            &fetch.failures,
3304                            fetch.auto_update_enabled,
3305                        );
3306                    }
3307                }
3308            }
3309        }
3310        alive.store(false, Ordering::Release);
3311    })
3312}
3313
3314/// One periodic-fetch cycle: every distinct git common dir this table currently
3315/// knows, not excluded, fetched with pruning, bounded to `concurrency` at once,
3316/// then one normal Generation over every entity the table now knows
3317/// ([refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
3318/// "The periodic fetch": "a finished fetch starts a normal generation"), the exact
3319/// completion path [`Core::run_action`] already uses. `cycle_count` counts every
3320/// call, whether or not any repository had a remote to fetch, so a test driving
3321/// the dedicated thread's own tick channel can prove a tick reached this function
3322/// at all, the same proof [`Core::poll_sweep_count_for_test`] gives the poll.
3323///
3324/// Two things worth recording beside this scheduler rather than only in
3325/// [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md):
3326/// `Gone` is systematically under-reported without this cycle running, because a
3327/// remote-tracking ref only disappears once a prune removes it
3328/// ([`crate::landing`]'s `classify_unmerged_branch` doc comment), so a Repo with
3329/// `fetch.enabled = false` can carry a stale upstream indefinitely and never show
3330/// it. And the cadence itself is unresolved: `fetch.interval`'s default of five
3331/// minutes is [config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md)'s
3332/// stated number, not one this crate has measured against a real population the
3333/// way the poll interval and the generation deadline were.
3334fn run_fetch_cycle(
3335    table: &Arc<RwLock<Table>>,
3336    concurrency: usize,
3337    refresh: &RefreshHandles,
3338    cycle_count: &Arc<AtomicUsize>,
3339    failures: &Arc<Mutex<FetchFailures>>,
3340    auto_update_enabled: bool,
3341) {
3342    cycle_count.fetch_add(1, Ordering::Release);
3343
3344    let common_dirs = distinct_fetchable_common_dirs(table);
3345    let failed: Mutex<Vec<(PathBuf, String)>> = Mutex::new(Vec::new());
3346    crate::fetch::run_bounded(common_dirs, concurrency.max(1), |common_dir| {
3347        let cancel = AtomicBool::new(false);
3348        // Every repository's own fetch result is independent: one credential
3349        // failure or one unreachable remote must never stop the rest of the
3350        // cycle from running, so a per-repository error is swallowed here
3351        // rather than aborting the whole cycle. It is still counted below,
3352        // which is the count this cycle's own [`FetchFailures`] carries.
3353        match crate::fetch::fetch_and_prune(&common_dir, &cancel) {
3354            Ok(outcome) => {
3355                // The handshake this fetch already paid for is what
3356                // [default-branch.md](https://github.com/paulchiu/repon/blob/main/docs/spec/default-branch.md)'s
3357                // "The network" means by "arrives inside a round trip already being
3358                // paid for": landed here, before `refresh.dispatch` below re-runs
3359                // the local chain, so the local answer always computes first and
3360                // this only ever supersedes it. `Unborn` and a missing answer both
3361                // leave any earlier session answer for this common dir untouched,
3362                // since neither is itself a fact worth overwriting one with.
3363                if let Some(crate::fetch::AdvertisedDefaultBranch::Branch(name)) =
3364                    outcome.advertised_default_branch
3365                {
3366                    refresh
3367                        .network_default_branch
3368                        .lock()
3369                        .unwrap()
3370                        .insert(common_dir.clone(), Arc::from(name));
3371                }
3372            }
3373            Err(error) => {
3374                failed
3375                    .lock()
3376                    .unwrap()
3377                    .push((common_dir.clone(), error.to_string()));
3378            }
3379        }
3380    });
3381    *failures.lock().unwrap() = FetchFailures {
3382        failed: failed.into_inner().unwrap(),
3383    };
3384
3385    // The fast-forward-only auto-update rides this cycle rather than a timer of its
3386    // own, per `docs/spec/config.md`'s "Refresh, fetch and auto-update": it can only
3387    // ever act on what the fetch just above learned, so it runs here, after every
3388    // fetch has settled and before the one Generation below reports the result.
3389    // Sequential rather than `fetch::run_bounded`'s own concurrency, since this is a
3390    // mutating pass over a Repo's own working tree and index, not a read against a
3391    // remote: ADR 0002's narrowest-safe-operation rule favours a simple, serial pass
3392    // over throughput a mutation has no need of.
3393    if auto_update_enabled {
3394        for repo_path in repos_eligible_for_auto_update_attempt(table) {
3395            // One Repo's ineligibility or failure never stops another's: the same
3396            // independence the fetch loop above already gives each repository.
3397            let _ = crate::auto_update::attempt(&repo_path);
3398        }
3399    }
3400
3401    let all_keys: Vec<EntityKey> = table
3402        .read()
3403        .unwrap()
3404        .entities
3405        .iter()
3406        .map(|entity| entity.key.clone())
3407        .collect();
3408    refresh.dispatch(&all_keys);
3409}
3410
3411/// Every non-excluded Repo's own working directory, one per distinct common dir the
3412/// table currently knows: the auto-update acts on a Repo's own row, per
3413/// `docs/spec/config.md`'s "acts only on a Repo", so a Worktree sharing that common
3414/// dir is never a candidate here even though it is `distinct_fetchable_common_dirs`'s
3415/// own definition of "fetchable" for the read-only fetch above. Listed, never
3416/// operated on, mirrors the same `excluded` rule the fetch loop's own common-dir
3417/// filter applies, checked here against the Repo entity's own flag rather than any
3418/// Worktree that happens to share its common dir.
3419fn repos_eligible_for_auto_update_attempt(table: &Arc<RwLock<Table>>) -> Vec<PathBuf> {
3420    table
3421        .read()
3422        .unwrap()
3423        .entities
3424        .iter()
3425        .filter(|entity| entity.kind == Kind::Repo && !entity.excluded)
3426        .map(|entity| entity.key.path().to_path_buf())
3427        .collect()
3428}
3429
3430/// Every distinct git common dir a fetch cycle should fetch: deduplicated across
3431/// every entity sharing one (a Repo and its linked Worktrees), and skipped only
3432/// when every entity sharing that common dir is excluded
3433/// ([config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md#per-repo-entries)'s
3434/// "listed, never operated on"), since a Worktree named directly by its own path
3435/// can carry a different `excluded` than an entry it would otherwise inherit.
3436fn distinct_fetchable_common_dirs(table: &Arc<RwLock<Table>>) -> Vec<PathBuf> {
3437    let table = table.read().unwrap();
3438    let mut seen: HashMap<PathBuf, bool> = HashMap::new();
3439    for entity in &table.entities {
3440        let common_dir = entity.common_dir.to_path_buf();
3441        let operable = seen.entry(common_dir).or_insert(false);
3442        *operable = *operable || !entity.excluded;
3443    }
3444    seen.into_iter()
3445        .filter(|(_, operable)| *operable)
3446        .map(|(common_dir, _)| common_dir)
3447        .collect()
3448}
3449
3450/// [`Core::rederive_default_branches`]'s own network half: a handshake-only probe
3451/// per `common_dir`, landing a `Branch` answer on `network_default_branch` for
3452/// [`supersede_with_network`] to read back. `Unborn` and a probe failure both
3453/// leave any earlier session answer for that common dir untouched, the same
3454/// convention [`run_fetch_cycle`] already follows.
3455fn probe_network_default_branches(
3456    common_dirs: &HashSet<Arc<Path>>,
3457    network_default_branch: &Mutex<HashMap<PathBuf, Arc<str>>>,
3458) {
3459    for common_dir in common_dirs {
3460        if let Ok(Some(crate::fetch::AdvertisedDefaultBranch::Branch(name))) =
3461            crate::fetch::probe_remote_head(common_dir)
3462        {
3463            network_default_branch
3464                .lock()
3465                .unwrap()
3466                .insert(common_dir.to_path_buf(), Arc::from(name));
3467        }
3468    }
3469}
3470
3471/// One entity [`Core::rederive_default_branches`] gathered under the table lock,
3472/// everything its own spawned thread needs to re-run the default-branch chain
3473/// without holding that lock while it does: a plain struct rather than a tuple,
3474/// per this crate's own `clippy::type_complexity` budget.
3475struct RederiveCandidate {
3476    key: EntityKey,
3477    path: PathBuf,
3478    common_dir: Arc<Path>,
3479    repo: Option<Arc<gix::ThreadSafeRepository>>,
3480    override_branch: Option<String>,
3481    kind: Kind,
3482}
3483
3484/// One entity as the metadata poll sweep found it, everything gathered under one
3485/// read lock so the filesystem stats and any re-probe below run outside it.
3486struct PollCandidate {
3487    key: EntityKey,
3488    path: PathBuf,
3489    common_dir: Arc<Path>,
3490    kind: Kind,
3491    cached_repo: Option<Arc<gix::ThreadSafeRepository>>,
3492    probes_base: bool,
3493}
3494
3495/// One metadata-poll sweep ([refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
3496/// "The poll"): for every entity a Generation's dispatch would also cover (a
3497/// hidden Submodule is skipped by the same [`dispatches_kind`] rule), stats
3498/// [`poll::POLLED_GITDIR_ENTRIES`] in its own gitdir. That gitdir is the cached
3499/// [`gix::ThreadSafeRepository`] handle's own `git_dir()` where discovery cached
3500/// one (the per-worktree location a linked Worktree's `HEAD` and `index` actually
3501/// live at), or else a fresh open's `git_dir()`, the same fallback every other
3502/// probe in this module already takes for a Submodule, which discovery never
3503/// opens. A first sweep for a newly discovered entity has nothing to compare
3504/// against yet, so it only records a baseline and reports no movement.
3505///
3506/// On movement it force-stales `dirty` and `state`, the two cells with no cheap
3507/// detector, then re-runs phases A and B for that entity alone and lets their own
3508/// supersession land the fresh values; it never starts a status probe of its own.
3509/// `poll_reprobed` is cleared and refilled with exactly the keys this call
3510/// actually re-ran, in the order it found them moved. `poll_sweep_count` counts
3511/// every call, whether or not anything moved, so a test can prove a real tick
3512/// reached this function at all.
3513fn run_poll_sweep(
3514    table: &Arc<RwLock<Table>>,
3515    overrides: &Arc<Vec<ResolvedOverride>>,
3516    show_submodules: &Arc<AtomicBool>,
3517    poll_reprobed: &Arc<Mutex<Vec<EntityKey>>>,
3518    poll_sweep_count: &Arc<AtomicUsize>,
3519    network_default_branch: &Mutex<HashMap<PathBuf, Arc<str>>>,
3520) {
3521    poll_sweep_count.fetch_add(1, Ordering::Release);
3522    poll_reprobed.lock().unwrap().clear();
3523    let show_submodules = show_submodules.load(Ordering::Acquire);
3524
3525    let candidates: Vec<PollCandidate> = {
3526        let table = table.read().unwrap();
3527        table
3528            .entities
3529            .iter()
3530            .filter(|entity| dispatches_kind(entity.kind, show_submodules))
3531            .map(|entity| PollCandidate {
3532                key: entity.key.clone(),
3533                path: entity.key.path().to_path_buf(),
3534                common_dir: Arc::clone(&entity.common_dir),
3535                kind: entity.kind,
3536                cached_repo: table.repos.get(&entity.key).cloned(),
3537                probes_base: entity.probes_base(),
3538            })
3539            .collect()
3540    };
3541
3542    for candidate in candidates {
3543        // A fresh open, never cached across sweeps: this is the same cost every
3544        // other probe in this module already pays for an entity discovery left
3545        // no handle for (always true of a Submodule), and reusing the handle it
3546        // returns for the re-probe below saves a second open on the one path
3547        // that actually detected movement.
3548        let opened;
3549        let repo = match candidate.cached_repo.as_deref() {
3550            Some(repo) => Some(repo),
3551            None => match git::open_thread_safe(&candidate.path) {
3552                Ok(repo) => {
3553                    opened = repo;
3554                    Some(&opened)
3555                }
3556                Err(_) => None,
3557            },
3558        };
3559        let gitdir = repo
3560            .map(|repo| repo.git_dir().to_path_buf())
3561            .unwrap_or_else(|| candidate.common_dir.to_path_buf());
3562
3563        let current = poll::fingerprint(&gitdir);
3564        let moved = {
3565            let mut table = table.write().unwrap();
3566            let previous = table
3567                .poll_fingerprints
3568                .insert(candidate.key.clone(), current);
3569            previous.is_some_and(|previous| poll::moved(&previous, &current))
3570        };
3571        if !moved {
3572            continue;
3573        }
3574
3575        {
3576            let mut table = table.write().unwrap();
3577            if let Some(&idx) = table.index.get(&candidate.key) {
3578                table.entities[idx].force_stale_status_cells();
3579            }
3580        }
3581
3582        let override_branch = find_entry(overrides, &candidate.path, &candidate.common_dir)
3583            .and_then(|entry| entry.default_branch.clone());
3584        let never_cancelled = AtomicBool::new(false);
3585        let chain_cache: ChainFactsCache = Mutex::new(HashMap::new());
3586        let chain_reads = AtomicUsize::new(0);
3587
3588        let branch_outcome = probe_branch(&candidate.path, repo, candidate.kind, &never_cancelled);
3589        let sync_outcome = probe_sync(
3590            &candidate.path,
3591            repo,
3592            branch_outcome.as_ref().map(|(settled, ..)| settled),
3593            candidate.kind,
3594            &never_cancelled,
3595        );
3596        let default_branch_outcome = probe_default_branch_memoised(
3597            &candidate.path,
3598            repo,
3599            &candidate.common_dir,
3600            DefaultBranchHints {
3601                override_branch: override_branch.as_deref(),
3602                network_branch: network_branch_for(network_default_branch, &candidate.common_dir)
3603                    .as_deref(),
3604            },
3605            candidate.kind,
3606            &never_cancelled,
3607            &ChainFactsMemo {
3608                cache: &chain_cache,
3609                reads: &chain_reads,
3610            },
3611        );
3612        let base_outcome = if candidate.probes_base {
3613            probe_base(
3614                &candidate.path,
3615                repo,
3616                branch_outcome.as_ref().map(|(settled, ..)| settled),
3617                default_branch_outcome.as_ref().map(|r| &r.settled),
3618                &never_cancelled,
3619            )
3620        } else {
3621            None
3622        };
3623
3624        let generation = {
3625            let mut table = table.write().unwrap();
3626            table.generation += 1;
3627            Generation::new(table.generation)
3628        };
3629        apply_cheap_probe_outcomes(
3630            table,
3631            &candidate.key,
3632            generation,
3633            CheapProbeOutcomes {
3634                branch: branch_outcome,
3635                sync: sync_outcome,
3636                base: base_outcome,
3637                default_branch: default_branch_outcome,
3638            },
3639        );
3640        poll_reprobed.lock().unwrap().push(candidate.key);
3641    }
3642}
3643
3644/// Cancels every probe currently in flight and drops the table's record of them,
3645/// which is what suspension does: the in-flight Generation is cancelled outright
3646/// rather than left to finish. Releases a pending `settle` too, since nothing is
3647/// now going to finish it.
3648fn cancel_in_flight(table: &Arc<RwLock<Table>>, settle_gate: &Arc<SettleGate>) {
3649    let mut table = table.write().unwrap();
3650    let cancelled = table.in_flight.len();
3651    for in_flight in table.in_flight.values() {
3652        in_flight.cancel.store(true, Ordering::Release);
3653    }
3654    table.in_flight.clear();
3655    table.generation_started_at.clear();
3656    drop(table);
3657    if cancelled > 0 {
3658        complete_many(settle_gate, cancelled);
3659    }
3660}
3661
3662/// A `Cell<T>`'s in-flight and timeout behaviour, uniform across every payload
3663/// type `EntityState` carries, so [`sweep_deadline`] can sweep every cell
3664/// through one array rather than one hand-written branch per cell: a cell only
3665/// ever times out if it was actually marked in flight, which is what lets the
3666/// sweep apply to all of them without asking what `Kind` owns them.
3667trait TimeoutableCell {
3668    fn is_in_flight(&self) -> bool;
3669    /// Settles this cell `Unknown(TimedOut)` for `generation`, subject to the
3670    /// same supersession `Cell::settle` already enforces.
3671    fn time_out(&mut self, generation: Generation);
3672}
3673
3674impl<T> TimeoutableCell for Cell<T> {
3675    fn is_in_flight(&self) -> bool {
3676        Cell::is_in_flight(self)
3677    }
3678
3679    fn time_out(&mut self, generation: Generation) {
3680        self.settle(generation, Settled::Unknown(Unknown::TimedOut));
3681    }
3682}
3683
3684/// Marks every cell still in flight past its own Generation's deadline `Unknown`,
3685/// per [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md):
3686/// there is no per-cell timeout, only this sweep, and it never interrupts the
3687/// underlying probe, which keeps running; the sweep only stops waiting on it.
3688fn sweep_deadline(table: &Arc<RwLock<Table>>, settle_gate: &Arc<SettleGate>, deadline: Duration) {
3689    let mut table = table.write().unwrap();
3690    let now = Instant::now();
3691    let mut timed_out = Vec::new();
3692    for (key, in_flight) in table.in_flight.iter() {
3693        let started = table
3694            .generation_started_at
3695            .get(&in_flight.generation)
3696            .copied()
3697            .unwrap_or(now);
3698        if now.duration_since(started) >= deadline {
3699            timed_out.push((key.clone(), Generation::new(in_flight.generation)));
3700        }
3701    }
3702    for (key, generation) in &timed_out {
3703        if let Some(&idx) = table.index.get(key) {
3704            // Exhaustive: a Cell added to `EntityState` later must be named here
3705            // or this fails to compile, so it cannot silently time out never.
3706            let EntityState {
3707                key: _,
3708                name: _,
3709                common_dir: _,
3710                kind: _,
3711                branch,
3712                sync,
3713                base,
3714                dirty,
3715                state,
3716                default_branch,
3717                diagnostics: _,
3718                last_action: _,
3719                presence: _,
3720                excluded: _,
3721                in_progress_operation: _,
3722                recent_commits: _,
3723            } = &mut table.entities[idx];
3724            let cells: [&mut dyn TimeoutableCell; 6] =
3725                [branch, sync, base, dirty, state, default_branch];
3726            for cell in cells {
3727                // Only a cell actually marked in flight times out: a Repo's or a
3728                // Submodule's `state` (never probed, by `EntityState::probes_state`)
3729                // and any cell no probe yet reaches (`sync`, `base`) are never in
3730                // flight, so this never overwrites them with a lie.
3731                if cell.is_in_flight() {
3732                    cell.time_out(*generation);
3733                }
3734            }
3735        }
3736        table.in_flight.remove(key);
3737    }
3738    let live_generations: std::collections::HashSet<u64> =
3739        table.in_flight.values().map(|f| f.generation).collect();
3740    table
3741        .generation_started_at
3742        .retain(|generation, _| live_generations.contains(generation));
3743    drop(table);
3744    if !timed_out.is_empty() {
3745        complete_many(settle_gate, timed_out.len());
3746    }
3747}
3748
3749/// Marks the cells this Generation's dispatch is about to probe as in flight,
3750/// via an exhaustive destructure of `EntityState`'s cells: a cell added later
3751/// must be named here (`_` if it is not yet probed) or this fails to compile,
3752/// which is what stops a cell [`apply_probe_outcome`] settles from going
3753/// in-flight silently forgotten, and reading wrong on `is_in_flight` for the
3754/// whole dispatch.
3755fn begin_probes(entity: &mut EntityState) {
3756    let probes_state = entity.probes_state();
3757    let EntityState {
3758        key: _,
3759        name: _,
3760        common_dir: _,
3761        kind: _,
3762        branch,
3763        sync: _,
3764        base: _,
3765        dirty,
3766        state,
3767        default_branch,
3768        diagnostics: _,
3769        last_action: _,
3770        presence: _,
3771        excluded: _,
3772        in_progress_operation: _,
3773        recent_commits: _,
3774    } = entity;
3775    branch.begin_probe();
3776    default_branch.begin_probe();
3777    // Phase C runs against every dispatched entity, Repo, Worktree or Submodule alike:
3778    // refresh.md's "Scope and order" makes scope never a partial dial, so `dirty` carries
3779    // no `probes_state`-style condition of its own.
3780    dirty.begin_probe();
3781    // Only a Worktree's `state` is ever (re)probed: a Repo's is `NotApplicable`
3782    // and a Submodule's is `Unknown` from construction, neither ever revisited
3783    // (`EntityState::probes_state`), and marking either in flight here would
3784    // leave it in-flight forever, since nothing would ever call `settle` on it.
3785    if probes_state {
3786        state.begin_probe();
3787    }
3788}
3789
3790/// What [`Core::try_settle`] waits on, and the one lock every count it waits on lives
3791/// under, so a settle can never observe one of them without the other.
3792type SettleGate = (Mutex<SettleCounts>, Condvar);
3793
3794/// The two outstanding counts [`Core::try_settle`] blocks on.
3795///
3796/// `dispatches` exists because a Generation reserves its number on the calling
3797/// thread and does everything else on one of its own: between those two moments
3798/// `probes` has not been raised yet, so a settle reading `probes` alone would
3799/// return on a table nothing has started writing to.
3800#[derive(Default)]
3801struct SettleCounts {
3802    /// Dispatched entities that have yet to land a phase C/D outcome, be cancelled
3803    /// or time out.
3804    probes: usize,
3805    /// Generations whose number is reserved and whose own dispatch body has not
3806    /// finished raising `probes` for what it dispatches.
3807    dispatches: usize,
3808}
3809
3810impl SettleCounts {
3811    /// Whether nothing this `Core` has started is still owed to the table.
3812    ///
3813    /// An exhaustive destructure: a third count added to this struct must be named here
3814    /// or this fails to compile, rather than being silently left out of what a settle
3815    /// waits for.
3816    fn is_settled(&self) -> bool {
3817        let SettleCounts { probes, dispatches } = self;
3818        *probes == 0 && *dispatches == 0
3819    }
3820}
3821
3822/// Records one reserved Generation as owed, before the thread that will dispatch
3823/// it has started. Paired with exactly one [`finish_dispatch`].
3824fn begin_dispatch(settle_gate: &SettleGate) {
3825    let (lock, _cvar) = settle_gate;
3826    lock.lock().unwrap().dispatches += 1;
3827}
3828
3829/// Releases the debt [`begin_dispatch`] recorded, once that Generation's own
3830/// dispatch has raised `probes` for everything it dispatched.
3831fn finish_dispatch(settle_gate: &SettleGate) {
3832    let (lock, cvar) = settle_gate;
3833    let mut counts = lock.lock().unwrap();
3834    counts.dispatches = counts.dispatches.saturating_sub(1);
3835    drop(counts);
3836    // Unconditionally, unlike `complete_many`: a waiter watching `dispatches` alone
3837    // would never be woken by a change that leaves `probes` outstanding.
3838    cvar.notify_all();
3839}
3840
3841fn begin_probes_owed(settle_gate: &SettleGate, owed: usize) {
3842    let (lock, _cvar) = settle_gate;
3843    lock.lock().unwrap().probes += owed;
3844}
3845
3846fn complete_one(settle_gate: &SettleGate) {
3847    complete_many(settle_gate, 1);
3848}
3849
3850fn complete_many(settle_gate: &SettleGate, finished: usize) {
3851    let (lock, cvar) = settle_gate;
3852    let mut counts = lock.lock().unwrap();
3853    counts.probes = counts.probes.saturating_sub(finished);
3854    if counts.is_settled() {
3855        cvar.notify_all();
3856    }
3857}
3858
3859/// Reads one entity's HEAD shape, or `None` if `cancel` was already set before the
3860/// read started. The one check this crate makes today: `git::head_shape` itself has
3861/// no interruption point to check `cancel` against mid-read, unlike the later
3862/// phases [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)
3863/// describes gix taking it through directly.
3864///
3865/// `repo` is the entity's cached thread-safe handle when discovery already opened
3866/// one; this task derives its own `Repository` from it via `to_thread_local`
3867/// rather than sharing that derived handle with any other task. `None` (a
3868/// Submodule, or a boundary discovery could not open) falls back to opening fresh,
3869/// which is where an unreadable repository's `ProbeError::Open` still surfaces.
3870///
3871/// Also reads the entity's in-progress git operation and recent commits off the
3872/// same open handle, since both ride along at negligible extra cost
3873/// ([ADR 0019](https://github.com/paulchiu/repon/blob/main/docs/adr/0019-a-detached-head-is-a-shape-of-head-not-a-worktree-state.md)).
3874/// Neither is a Cell in its own right, so both travel with the branch read they
3875/// were taken alongside rather than getting independent supersession of their
3876/// own; [`EntityState::apply_branch_probe`] is where that pairing lands.
3877const RECENT_COMMITS_LIMIT: usize = 5;
3878
3879/// What an open-repository failure means for `kind`: a genuine Probe error for a Repo or a
3880/// Worktree, but for a Submodule the far more common, expected shape of "never `git
3881/// submodule update --init`-ed" ([discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md)'s
3882/// "The Submodule row": "An uninitialised Submodule is a row with every cell blank and `?`
3883/// in the gutter"). Exhaustive over `Kind` rather than a wildcard, so a fourth variant added
3884/// later must decide which grade it gets rather than silently inheriting one.
3885fn submodule_open_failure<T>(kind: Kind, error: git::ProbeError) -> Settled<T> {
3886    match kind {
3887        Kind::Repo | Kind::Worktree => Settled::Failed(error),
3888        Kind::Submodule => Settled::Unknown(Unknown::SubmoduleUninitialized),
3889    }
3890}
3891
3892fn probe_branch(
3893    path: &Path,
3894    repo: Option<&gix::ThreadSafeRepository>,
3895    kind: Kind,
3896    cancel: &AtomicBool,
3897) -> Option<(
3898    Settled<Head>,
3899    Option<git::InProgressOperation>,
3900    Vec<git::RecentCommit>,
3901)> {
3902    if cancel.load(Ordering::Acquire) {
3903        return None;
3904    }
3905    let opened;
3906    let repo = match repo {
3907        Some(repo) => repo,
3908        None => match git::open_thread_safe(path) {
3909            Ok(repo) => {
3910                opened = repo;
3911                &opened
3912            }
3913            Err(error) => return Some((submodule_open_failure(kind, error), None, Vec::new())),
3914        },
3915    };
3916    let local = repo.to_thread_local();
3917    let settled = match git::head_shape(&local) {
3918        Ok(head) => Settled::Known {
3919            value: head,
3920            at: Timestamp::now(),
3921            stale: false,
3922        },
3923        Err(error) => Settled::Failed(error),
3924    };
3925    let in_progress = git::in_progress_operation(&local);
3926    let recent = git::recent_commits(&local, RECENT_COMMITS_LIMIT);
3927    Some((settled, in_progress, recent))
3928}
3929
3930/// Phase B's comparison: the `sync` cell's ahead/behind counts against the
3931/// branch's upstream, for every entity whose HEAD carries a branch, every
3932/// Generation ([refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)).
3933/// `None` if `cancel` was already set, or if `branch_settled` is itself `None`
3934/// because the branch probe it depends on was cancelled first. A `Failed` branch
3935/// read fails `sync` the same way, rather than guessing at a HEAD shape the
3936/// branch probe itself could not read; every other shape (a live branch, a
3937/// detached or unborn HEAD) is handed to [`git::resolve_sync`], which is where
3938/// "no branch" and "no remote at all" settle to their own values. `repo` follows
3939/// the same cached-handle convention as [`probe_branch`].
3940fn probe_sync(
3941    path: &Path,
3942    repo: Option<&gix::ThreadSafeRepository>,
3943    branch_settled: Option<&Settled<Head>>,
3944    kind: Kind,
3945    cancel: &AtomicBool,
3946) -> Option<Settled<SyncState>> {
3947    if cancel.load(Ordering::Acquire) {
3948        return None;
3949    }
3950    let head = match branch_settled? {
3951        Settled::Known {
3952            value,
3953            at: _,
3954            stale: _,
3955        } => Some(value),
3956        Settled::Failed(error) => return Some(Settled::Failed(error.clone())),
3957        Settled::Unknown(_) | Settled::NotApplicable => None,
3958    };
3959    let opened;
3960    let repo = match repo {
3961        Some(repo) => repo,
3962        None => match git::open_thread_safe(path) {
3963            Ok(repo) => {
3964                opened = repo;
3965                &opened
3966            }
3967            Err(error) => return Some(submodule_open_failure(kind, error)),
3968        },
3969    };
3970    let local = repo.to_thread_local();
3971    let settled = match git::resolve_sync(&local, head) {
3972        Ok(value) => Settled::Known {
3973            value,
3974            at: Timestamp::now(),
3975            stale: false,
3976        },
3977        Err(error) => Settled::Failed(error),
3978    };
3979    Some(settled)
3980}
3981
3982/// Phase B's second rev-walk: the `base` cell's count behind the resolved default
3983/// branch, for every entity [`crate::base::probe`] does not exempt
3984/// ([default-branch.md](https://github.com/paulchiu/repon/blob/main/docs/spec/default-branch.md)'s
3985/// "The two behind counts"). `None` if `cancel` was already set, or if either
3986/// `branch_settled` or `default_branch_settled` is itself `None` because the probe
3987/// it depends on was cancelled first; [`crate::base::probe`] itself always settles
3988/// once reached. A `Failed` or not-yet-`Known` `branch_settled` carries no commit to
3989/// compare, so it is treated the same "nothing to settle yet" way, except a genuine
3990/// `Failed` branch read, which propagates onto `base` too: a row whose HEAD could
3991/// not be read has nothing to compute behind anything. `repo` follows the same
3992/// cached-handle convention as [`probe_branch`].
3993fn probe_base(
3994    path: &Path,
3995    repo: Option<&gix::ThreadSafeRepository>,
3996    branch_settled: Option<&Settled<Head>>,
3997    default_branch_settled: Option<&Settled<DefaultBranch>>,
3998    cancel: &AtomicBool,
3999) -> Option<Settled<u32>> {
4000    if cancel.load(Ordering::Acquire) {
4001        return None;
4002    }
4003    let head = match branch_settled? {
4004        Settled::Known {
4005            value,
4006            at: _,
4007            stale: _,
4008        } => value,
4009        Settled::Failed(error) => return Some(Settled::Failed(error.clone())),
4010        Settled::Unknown(_) | Settled::NotApplicable => return None,
4011    };
4012    let default_branch_settled = default_branch_settled?;
4013    let opened;
4014    let repo = match repo {
4015        Some(repo) => repo,
4016        None => match git::open_thread_safe(path) {
4017            Ok(repo) => {
4018                opened = repo;
4019                &opened
4020            }
4021            Err(error) => return Some(Settled::Failed(error)),
4022        },
4023    };
4024    let local = repo.to_thread_local();
4025    Some(base::probe(&local, head, default_branch_settled))
4026}
4027
4028/// Phase C's typed counts, dispatched over every entity in a Generation with no
4029/// scoping of its own: [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
4030/// "Scope and order" makes scope never a partial dial, only order, so this carries
4031/// no visibility filter and no cost heuristic; the caller's dispatch order is the
4032/// only dial, expressed entirely by the position `path` already holds in
4033/// `Core::refresh`'s `order`. `None` if `cancel` was already set before the read
4034/// started; unlike the cheaper phases above, `cancel` is also handed straight
4035/// into gix, which checks it while the read is under way rather than only before
4036/// it starts, since this is the one phase long enough for that to matter.
4037fn probe_status(
4038    path: &Path,
4039    repo: Option<&gix::ThreadSafeRepository>,
4040    kind: Kind,
4041    cancel: &Arc<AtomicBool>,
4042) -> Option<Settled<DirtyCounts>> {
4043    if cancel.load(Ordering::Acquire) {
4044        return None;
4045    }
4046    let opened;
4047    let repo = match repo {
4048        Some(repo) => repo,
4049        None => match git::open_thread_safe(path) {
4050            Ok(repo) => {
4051                opened = repo;
4052                &opened
4053            }
4054            Err(error) => return Some(submodule_open_failure(kind, error)),
4055        },
4056    };
4057    let local = repo.to_thread_local();
4058    classify_status_result(git::dirty_counts(&local, Arc::clone(cancel)), cancel)
4059}
4060
4061/// Folds [`git::dirty_counts`]'s result into [`probe_status`]'s outcome. Split out as its own
4062/// function so the one case a live probe cannot reproduce deterministically, cancellation
4063/// observed genuinely mid-read, is directly testable: gix's own error carries no typed "this
4064/// was cancelled" case (its interrupt point reports through a bare `io::Error`, same as any
4065/// other I/O failure), so `cancel` itself, which this task alone owns for the duration of its
4066/// probe, is the answer. An error alongside a cancel flag now set is what an interruption
4067/// mid-read looks like, and [ADR 0013](https://github.com/paulchiu/repon/blob/main/docs/adr/0013-no-filesystem-watching-a-refresh-is-a-cancellable-generation.md)'s
4068/// precedent is that interrupted work is dropped rather than settled `Failed`, the same as
4069/// every cheaper phase's pre-check already does.
4070///
4071/// gix checks `should_interrupt` per index entry rather than before every read, so a walk
4072/// short enough to run out of entries to check between the flag flipping and the walk
4073/// finishing can still return `Ok`. `cancel` is re-checked on that arm too, and an `Ok` that
4074/// raced ahead of it is dropped the same way an `Err` alongside it already is, so a cancelled
4075/// generation never lands a value regardless of which side of that race gix landed on.
4076fn classify_status_result(
4077    result: Result<DirtyCounts, git::ProbeError>,
4078    cancel: &AtomicBool,
4079) -> Option<Settled<DirtyCounts>> {
4080    match result {
4081        Ok(_) if cancel.load(Ordering::Acquire) => None,
4082        Ok(value) => Some(Settled::Known {
4083            value,
4084            at: Timestamp::now(),
4085            stale: false,
4086        }),
4087        Err(_) if cancel.load(Ordering::Acquire) => None,
4088        Err(error) => Some(Settled::Failed(error)),
4089    }
4090}
4091
4092/// Rung 1's config override and the network's session-held answer, bundled into
4093/// one argument the way [`ChainFactsMemo`] bundles its own two: both
4094/// [`probe_default_branch`] and [`probe_default_branch_memoised`] already sit at
4095/// clippy's argument limit, and the two hints always travel together, one per
4096/// dispatched entity.
4097struct DefaultBranchHints<'a> {
4098    /// Matched by common dir before this is called; `None` when no `[[repo]]`
4099    /// entry names this entity's own default branch.
4100    override_branch: Option<&'a str>,
4101    /// [`network_branch_for`]'s own answer for this entity's common dir; `None`
4102    /// until a fetch handshake or [`Core::rederive_default_branches`] has
4103    /// actually reached that remote this session.
4104    network_branch: Option<&'a str>,
4105}
4106
4107/// [`Core::network_default_branch`]'s own lookup, by common dir: a small helper
4108/// so every probe site reads it the same way rather than repeating the lock and
4109/// clone.
4110fn network_branch_for(
4111    network_default_branch: &Mutex<HashMap<PathBuf, Arc<str>>>,
4112    common_dir: &Path,
4113) -> Option<Arc<str>> {
4114    network_default_branch
4115        .lock()
4116        .unwrap()
4117        .get(common_dir)
4118        .cloned()
4119}
4120
4121/// Supersedes `resolution`'s own settled value with `network_branch`, if given,
4122/// per [default-branch.md](https://github.com/paulchiu/repon/blob/main/docs/spec/default-branch.md)'s
4123/// "The network": never the primary source, so `resolution` is always the local
4124/// chain's own complete answer, computed unconditionally by the caller before
4125/// this ever runs. This is the one place ADR 0012's stated ceiling is actually
4126/// closed: on a Repo where rung 2 and rung 3 agree and are both wrong (the
4127/// hidden-Submodule case the ADR measures), no local rung can ever correct
4128/// itself, and only a reachable remote's own answer, landed here, can.
4129fn supersede_with_network(
4130    mut resolution: default_branch::Resolution,
4131    network_branch: Option<&str>,
4132) -> default_branch::Resolution {
4133    if let Some(name) = network_branch {
4134        resolution.settled = Settled::Known {
4135            value: DefaultBranch::new(name.into()),
4136            at: Timestamp::now(),
4137            stale: false,
4138        };
4139    }
4140    resolution
4141}
4142
4143/// Runs the four-rung default branch chain against `path`, or `None` if `cancel`
4144/// was already set before the read started, then [`supersede_with_network`]s the
4145/// result with `hints.network_branch`.
4146///
4147/// `repo` follows the same cached-handle convention as [`probe_branch`]: `None`
4148/// falls back to opening fresh, which is where an unreadable repository surfaces
4149/// as [`default_branch::Resolution::failed`] rather than a settled Unknown.
4150fn probe_default_branch(
4151    path: &Path,
4152    repo: Option<&gix::ThreadSafeRepository>,
4153    hints: DefaultBranchHints<'_>,
4154    kind: Kind,
4155    cancel: &AtomicBool,
4156) -> Option<default_branch::Resolution> {
4157    if cancel.load(Ordering::Acquire) {
4158        return None;
4159    }
4160    let opened;
4161    let repo = match repo {
4162        Some(repo) => repo,
4163        None => match git::open_thread_safe(path) {
4164            Ok(repo) => {
4165                opened = repo;
4166                &opened
4167            }
4168            Err(error) => {
4169                return Some(match kind {
4170                    Kind::Repo | Kind::Worktree => default_branch::Resolution::failed(error),
4171                    Kind::Submodule => default_branch::Resolution::submodule_uninitialized(),
4172                });
4173            }
4174        },
4175    };
4176    Some(supersede_with_network(
4177        default_branch::resolve(&repo.to_thread_local(), hints.override_branch),
4178        hints.network_branch,
4179    ))
4180}
4181
4182/// Coordinates one common dir's Outstanding entities so every one of their own
4183/// merge bases against the default branch is known before the shared scan
4184/// runs, per [default-branch.md](https://github.com/paulchiu/repon/blob/main/docs/spec/default-branch.md)'s
4185/// requirement that the bound be *collected*, not computed lazily on whichever
4186/// entity happens to arrive first. `remaining` starts at the number of
4187/// dispatched entities in this common dir that will call [`GateReport::report`]
4188/// this Generation (every entity `landing::probe` runs for, whether it settles
4189/// immediately or reaches patch equivalence); `deepest` blocks until all of
4190/// them have, then folds their contributed merge bases pairwise via
4191/// [`git::checked_merge_base`] so the result is an ancestor of (at least as
4192/// deep as) every one of them, and memoises that answer for every later caller
4193/// sharing this dir.
4194struct BoundGate {
4195    state: Mutex<BoundGateState>,
4196    condvar: Condvar,
4197    bound: OnceLock<Option<gix::ObjectId>>,
4198}
4199
4200struct BoundGateState {
4201    remaining: usize,
4202    candidates: Vec<gix::ObjectId>,
4203}
4204
4205impl BoundGate {
4206    fn new(remaining: usize) -> Self {
4207        Self {
4208            state: Mutex::new(BoundGateState {
4209                remaining,
4210                candidates: Vec::new(),
4211            }),
4212            condvar: Condvar::new(),
4213            bound: OnceLock::new(),
4214        }
4215    }
4216
4217    /// One entity's contribution: `Some(base)` when it reached patch
4218    /// equivalence and had a merge base to offer, `None` otherwise (it settled
4219    /// by ancestry, was cancelled, failed to read, or shared no history with
4220    /// the default branch at all). Wakes every task blocked in [`Self::deepest`]
4221    /// once every entity counted in `remaining` has reported.
4222    fn report(&self, candidate: Option<gix::ObjectId>) {
4223        let mut state = self.state.lock().unwrap();
4224        if let Some(candidate) = candidate {
4225            state.candidates.push(candidate);
4226        }
4227        state.remaining -= 1;
4228        if state.remaining == 0 {
4229            self.condvar.notify_all();
4230        }
4231    }
4232
4233    /// Blocks until every entity sharing this common dir has reported, then
4234    /// returns the deepest merge base among their contributions (`None` if
4235    /// none contributed one, so the scan is left unbounded). The candidates are
4236    /// taken and folded into `bound` inside the same critical section, so
4237    /// whichever call is first to finish waiting is guaranteed to be the one
4238    /// that computes the memoised answer from them; computing outside the lock
4239    /// would let a later call, left holding an empty list by
4240    /// [`std::mem::take`], win the race into [`OnceLock::get_or_init`] and
4241    /// memoise `None` regardless of what the first call actually contributed.
4242    fn deepest(&self, repo: &gix::Repository) -> Option<gix::ObjectId> {
4243        let mut state = self.state.lock().unwrap();
4244        while state.remaining != 0 {
4245            state = self.condvar.wait(state).unwrap();
4246        }
4247        let candidates = std::mem::take(&mut state.candidates);
4248        *self
4249            .bound
4250            .get_or_init(|| deepest_merge_base(repo, &candidates))
4251    }
4252}
4253
4254/// Folds `candidates` pairwise via [`git::checked_merge_base`] into the one
4255/// deepest among them: when two candidates are ancestor and descendant, their
4256/// own merge base is exactly the ancestor, so the fold converges on whichever
4257/// candidate is deepest; two on unrelated lines of history fold to their own
4258/// common ancestor instead, which is still a safe (if not the tightest
4259/// possible) lower bound for the scan.
4260fn deepest_merge_base(
4261    repo: &gix::Repository,
4262    candidates: &[gix::ObjectId],
4263) -> Option<gix::ObjectId> {
4264    let mut candidates = candidates.iter().copied();
4265    let mut deepest = candidates.next()?;
4266    for candidate in candidates {
4267        deepest = git::checked_merge_base(repo, deepest, candidate)
4268            .ok()
4269            .flatten()
4270            .unwrap_or(deepest);
4271    }
4272    Some(deepest)
4273}
4274
4275/// Reports exactly once to a [`BoundGate`], on drop if [`Self::report_now`] was
4276/// never called explicitly: every exit path out of [`probe_worktree_state`]
4277/// and [`probe_patch_equivalence`] must release its common dir's gate, since a
4278/// path that forgot to would deadlock every sibling still waiting in
4279/// [`BoundGate::deepest`].
4280struct GateReport<'a> {
4281    gate: &'a BoundGate,
4282    reported: bool,
4283}
4284
4285impl<'a> GateReport<'a> {
4286    fn new(gate: &'a BoundGate) -> Self {
4287        Self {
4288            gate,
4289            reported: false,
4290        }
4291    }
4292
4293    /// Reports `candidate` immediately rather than waiting for drop: the one
4294    /// path that goes on to call [`BoundGate::deepest`] must report its own
4295    /// contribution first, or it would wait on a count that can never reach
4296    /// zero without its own report.
4297    fn report_now(&mut self, candidate: Option<gix::ObjectId>) {
4298        self.gate.report(candidate);
4299        self.reported = true;
4300    }
4301}
4302
4303impl Drop for GateReport<'_> {
4304    fn drop(&mut self) {
4305        if !self.reported {
4306            self.gate.report(None);
4307        }
4308    }
4309}
4310
4311/// The per-common-dir patch-equivalence memo plumbing, bundled into one
4312/// argument so [`probe_worktree_state`] and [`probe_patch_equivalence`] each
4313/// take it as a single parameter rather than three loose ones.
4314struct PatchEquivalenceMemo<'a> {
4315    cache: &'a PatchIdentityCache,
4316    reads: &'a AtomicUsize,
4317    /// Where [`probe_patch_equivalence`] records the bound it actually passed to
4318    /// [`patch_equivalence::scan_default_branch`], for `Core::patch_scan_bounds_for_test`.
4319    scan_bounds: &'a Mutex<Vec<Option<gix::ObjectId>>>,
4320}
4321
4322/// Runs both of Phase D's passes for one Worktree entity: `landing::probe`'s
4323/// ancestry check, then, only when it answers `Outstanding`,
4324/// [`probe_patch_equivalence`]'s content check. `None` if `cancel` was already
4325/// set, or if `default_branch_settled` is itself `None` because the
4326/// default-branch probe it depends on was cancelled first. `repo` follows the
4327/// same cached-handle convention as [`probe_branch`]. `report` always reports
4328/// exactly once to this entity's common dir's `BoundGate`, on every path
4329/// through this function, via its own `Drop`.
4330fn probe_worktree_state(
4331    path: &Path,
4332    repo: Option<&gix::ThreadSafeRepository>,
4333    default_branch_settled: Option<&Settled<DefaultBranch>>,
4334    common_dir: &Arc<Path>,
4335    cancel: &AtomicBool,
4336    memo: &PatchEquivalenceMemo<'_>,
4337    report: &mut GateReport<'_>,
4338) -> Option<Settled<WorktreeState>> {
4339    if cancel.load(Ordering::Acquire) {
4340        return None;
4341    }
4342    let default_branch_settled = default_branch_settled?;
4343    let opened;
4344    let repo = match repo {
4345        Some(repo) => repo,
4346        None => match git::open_thread_safe(path) {
4347            Ok(repo) => {
4348                opened = repo;
4349                &opened
4350            }
4351            Err(error) => return Some(Settled::Failed(error)),
4352        },
4353    };
4354    let local = repo.to_thread_local();
4355    match landing::probe(&local, default_branch_settled) {
4356        landing::Outcome::Settle(settled) => Some(settled),
4357        landing::Outcome::Outstanding(outstanding) => {
4358            probe_patch_equivalence(&local, &outstanding, common_dir, cancel, memo, report)
4359        }
4360    }
4361}
4362
4363/// Phase D's expensive half, reached only when `landing::probe` answered
4364/// `Outstanding`: this is the seam that keeps patch equivalence off every
4365/// entity ancestry already settled. Reports the merge base the first pass
4366/// already walked to `report` *before* asking for the shared scan, then checks
4367/// patch equivalence against `memo`'s per-common-dir cache, per
4368/// [default-branch.md](https://github.com/paulchiu/repon/blob/main/docs/spec/default-branch.md)'s
4369/// "Two passes on screen" and its bound on the scan's own depth.
4370fn probe_patch_equivalence(
4371    repo: &gix::Repository,
4372    outstanding: &landing::Outstanding,
4373    common_dir: &Arc<Path>,
4374    cancel: &AtomicBool,
4375    memo: &PatchEquivalenceMemo<'_>,
4376    report: &mut GateReport<'_>,
4377) -> Option<Settled<WorktreeState>> {
4378    if cancel.load(Ordering::Acquire) {
4379        return None;
4380    }
4381    let landing::Outstanding {
4382        entity_tip,
4383        default_tip,
4384        merge_base,
4385    } = *outstanding;
4386    let Some(merge_base) = merge_base else {
4387        // No shared history at all: a real negative the first pass already
4388        // established. This entity needs no bound and no shared scan, so it
4389        // reports and settles without waiting on either; the empty set is never
4390        // actually consulted, since `probe` returns `Active` for a `None` merge
4391        // base before it would look.
4392        report.report_now(None);
4393        return Some(patch_equivalence::probe(
4394            repo,
4395            entity_tip,
4396            None,
4397            &patch_equivalence::PatchIdentitySet::new(),
4398        ));
4399    };
4400    // Reported now, not left to `report`'s `Drop`: the wait just below blocks
4401    // on every entity sharing this common dir having reported, this entity
4402    // included, so reporting late here would deadlock on its own wait.
4403    report.report_now(Some(merge_base));
4404    let bound = report.gate.deepest(repo);
4405    let shared = match patch_identities_for(memo.cache, common_dir, memo.reads, || {
4406        // Recorded here, inside the closure that only ever runs for whichever
4407        // entity's task is first to reach `patch_identities_for` for this common
4408        // dir, so this is the bound the one real `scan_default_branch` call for
4409        // it actually used, not a value a test recomputes independently.
4410        memo.scan_bounds.lock().unwrap().push(bound);
4411        patch_equivalence::scan_default_branch(repo, default_tip, bound)
4412    }) {
4413        Ok(shared) => shared,
4414        Err(error) => return Some(Settled::Failed(error)),
4415    };
4416    Some(patch_equivalence::probe(
4417        repo,
4418        entity_tip,
4419        Some(merge_base),
4420        &shared,
4421    ))
4422}
4423
4424/// One Generation's patch-equivalence memo: at most one
4425/// [`patch_equivalence::PatchIdentitySet`] per common dir, shared by every
4426/// dispatched entity `landing::probe` answered `Outstanding` for. Built fresh
4427/// in [`Core::refresh`] and dropped once every task from that dispatch has
4428/// finished, the same lifetime `ChainFactsCache` has. The computed `Result` is
4429/// itself cached, since a common dir a scan fails against fails identically
4430/// for every entity sharing it this Generation.
4431type PatchIdentityCache = Mutex<
4432    HashMap<Arc<Path>, Arc<OnceLock<Result<patch_equivalence::PatchIdentitySet, git::ProbeError>>>>,
4433>;
4434
4435/// The per-common-dir half of [`probe_patch_equivalence`]: returns the
4436/// already-computed scan for `common_dir` if another entity in this
4437/// Generation's dispatch already ran it, blocking until that computation
4438/// finishes if it is still running; otherwise runs `compute` itself, caches the
4439/// result, and increments `reads` exactly once for the common dir this call is
4440/// the first to reach. Structurally identical to [`chain_facts_for`]; kept
4441/// separate rather than made generic over it, since the two caches are keyed by
4442/// different Generations' worth of dispatch and sharing one would blur which
4443/// pass a given read counted for.
4444fn patch_identities_for(
4445    cache: &PatchIdentityCache,
4446    common_dir: &Arc<Path>,
4447    reads: &AtomicUsize,
4448    compute: impl FnOnce() -> Result<patch_equivalence::PatchIdentitySet, git::ProbeError>,
4449) -> Result<patch_equivalence::PatchIdentitySet, git::ProbeError> {
4450    let cell = {
4451        let mut cache = cache.lock().unwrap();
4452        Arc::clone(
4453            cache
4454                .entry(Arc::clone(common_dir))
4455                .or_insert_with(|| Arc::new(OnceLock::new())),
4456        )
4457    };
4458    cell.get_or_init(|| {
4459        reads.fetch_add(1, Ordering::Relaxed);
4460        compute()
4461    })
4462    .clone()
4463}
4464
4465/// One Generation's default-branch chain memo: at most one [`default_branch::ChainFacts`]
4466/// per common dir, shared by every dispatched entity that names it. Built fresh in
4467/// [`Core::refresh`] and dropped once every task from that dispatch has finished.
4468type ChainFactsCache = Mutex<HashMap<Arc<Path>, Arc<OnceLock<default_branch::ChainFacts>>>>;
4469
4470/// The per-common-dir half of [`probe_default_branch_memoised`]: returns the
4471/// already-cached facts for `common_dir` if another entity in this Generation's
4472/// dispatch already computed them, blocking until that computation finishes if it
4473/// is still running; otherwise runs `compute` itself, caches the result, and
4474/// increments `reads` exactly once for the common dir this call is the first to
4475/// reach.
4476fn chain_facts_for(
4477    cache: &ChainFactsCache,
4478    common_dir: &Arc<Path>,
4479    reads: &AtomicUsize,
4480    compute: impl FnOnce() -> default_branch::ChainFacts,
4481) -> default_branch::ChainFacts {
4482    let cell = {
4483        let mut cache = cache.lock().unwrap();
4484        Arc::clone(
4485            cache
4486                .entry(Arc::clone(common_dir))
4487                .or_insert_with(|| Arc::new(OnceLock::new())),
4488        )
4489    };
4490    cell.get_or_init(|| {
4491        reads.fetch_add(1, Ordering::Relaxed);
4492        compute()
4493    })
4494    .clone()
4495}
4496
4497/// Runs the four-rung default branch chain against `path`, memoising rungs 2 and
4498/// 3's own per-common-dir facts in `cache` so every entity sharing `common_dir`
4499/// within the same dispatch reads the loose file and its reference lookups once
4500/// rather than once per entity, per [default-branch.md](https://github.com/paulchiu/repon/blob/main/docs/spec/default-branch.md)'s
4501/// "Memoised per common dir within a single refresh generation". `None` if
4502/// `cancel` was already set before the read started; `override_branch` is rung 1's
4503/// own entity-specific value, never memoised because it is not a common-dir fact.
4504/// [`chain_facts_for`]'s own two collaborators, bundled so
4505/// [`probe_default_branch_memoised`] stays within clippy's argument limit: the two always
4506/// travel together, one dispatch's worth of both, per [`Core::refresh_handles`].
4507struct ChainFactsMemo<'a> {
4508    cache: &'a ChainFactsCache,
4509    reads: &'a AtomicUsize,
4510}
4511
4512fn probe_default_branch_memoised(
4513    path: &Path,
4514    repo: Option<&gix::ThreadSafeRepository>,
4515    common_dir: &Arc<Path>,
4516    hints: DefaultBranchHints<'_>,
4517    kind: Kind,
4518    cancel: &AtomicBool,
4519    memo: &ChainFactsMemo<'_>,
4520) -> Option<default_branch::Resolution> {
4521    if cancel.load(Ordering::Acquire) {
4522        return None;
4523    }
4524    let opened;
4525    let repo = match repo {
4526        Some(repo) => repo,
4527        None => match git::open_thread_safe(path) {
4528            Ok(repo) => {
4529                opened = repo;
4530                &opened
4531            }
4532            Err(error) => {
4533                return Some(match kind {
4534                    Kind::Repo | Kind::Worktree => default_branch::Resolution::failed(error),
4535                    Kind::Submodule => default_branch::Resolution::submodule_uninitialized(),
4536                });
4537            }
4538        },
4539    };
4540    let local = repo.to_thread_local();
4541    let facts = chain_facts_for(memo.cache, common_dir, memo.reads, || {
4542        default_branch::ChainFacts::resolve(&local)
4543    });
4544    Some(supersede_with_network(
4545        default_branch::resolve_with_facts(&facts, hints.override_branch),
4546        hints.network_branch,
4547    ))
4548}
4549
4550/// Phase A and B's per-cell outcomes, landed as soon as they are computed via
4551/// [`apply_cheap_probe_outcomes`], well before phase C or D answer. Named rather
4552/// than positional so a transposed pair of trailing `None`s cannot compile
4553/// silently into the wrong cell.
4554struct CheapProbeOutcomes {
4555    branch: Option<(
4556        Settled<Head>,
4557        Option<git::InProgressOperation>,
4558        Vec<git::RecentCommit>,
4559    )>,
4560    sync: Option<Settled<SyncState>>,
4561    base: Option<Settled<u32>>,
4562    default_branch: Option<default_branch::Resolution>,
4563}
4564
4565/// Writes phase A and B's cells for `key` at `generation`, subject to the
4566/// per-cell supersession `Cell::settle` already enforces, and records the
4567/// default-branch diagnostics only on the write that actually won. Deliberately
4568/// does not touch `in_flight` or `settle_gate`: those belong to whichever apply
4569/// closes out the entity's dispatch, which per
4570/// [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
4571/// "The first frame" is this call's whole point, since a slow phase C or D must
4572/// never hold these cells off the table.
4573fn apply_cheap_probe_outcomes(
4574    table: &Arc<RwLock<Table>>,
4575    key: &EntityKey,
4576    generation: Generation,
4577    outcomes: CheapProbeOutcomes,
4578) {
4579    let CheapProbeOutcomes {
4580        branch: branch_outcome,
4581        sync: sync_outcome,
4582        base: base_outcome,
4583        default_branch: default_branch_outcome,
4584    } = outcomes;
4585    let mut table = table.write().unwrap();
4586    if let Some(&idx) = table.index.get(key) {
4587        if let Some((settled, in_progress, recent)) = branch_outcome {
4588            table.entities[idx].apply_branch_probe(generation, settled, in_progress, recent);
4589        }
4590        if let Some(settled) = sync_outcome {
4591            table.entities[idx].sync.settle(generation, settled);
4592        }
4593        if let Some(settled) = base_outcome {
4594            table.entities[idx].base.settle(generation, settled);
4595        }
4596        if let Some(resolution) = default_branch_outcome {
4597            table.entities[idx].apply_default_branch_resolution(generation, resolution);
4598        }
4599    }
4600}
4601
4602/// Phase C and D's per-cell outcomes, landed once they answer, via
4603/// [`apply_probe_outcome`]: named rather than positional for the same reason as
4604/// [`CheapProbeOutcomes`].
4605struct ProbeOutcomes {
4606    state: Option<Settled<WorktreeState>>,
4607    dirty: Option<Settled<DirtyCounts>>,
4608}
4609
4610/// Lands one probe's phase C/D outcome for `key` at `generation`: writes the
4611/// `state` and `dirty` cells subject to the per-cell supersession `Cell::settle`
4612/// already enforces, then clears `key`'s in-flight entry if `generation` still
4613/// owns it and signals `settle_gate` once for the whole entity. This is the one
4614/// write that closes out a dispatched entity, whether or not
4615/// [`apply_cheap_probe_outcomes`] already landed that same entity's cheap cells;
4616/// a test's simulated late result goes through the same path so it does not
4617/// duplicate this bookkeeping.
4618///
4619/// `outcomes.state` being `None` writes nothing at all: the `state` cell is left
4620/// exactly as unsettled as `begin_probe` alone leaves it, which is what an
4621/// attached branch with a live upstream ancestry could not clear, and that
4622/// `probe_patch_equivalence` was itself cancelled before answering, still shows.
4623fn apply_probe_outcome(
4624    table: &Arc<RwLock<Table>>,
4625    settle_gate: &Arc<SettleGate>,
4626    key: &EntityKey,
4627    generation: Generation,
4628    outcomes: ProbeOutcomes,
4629) {
4630    let ProbeOutcomes {
4631        state: state_outcome,
4632        dirty: dirty_outcome,
4633    } = outcomes;
4634    let mut table = table.write().unwrap();
4635    if let Some(&idx) = table.index.get(key) {
4636        if let Some(settled) = state_outcome {
4637            table.entities[idx].state.settle(generation, settled);
4638        }
4639        if let Some(settled) = dirty_outcome {
4640            table.entities[idx].dirty.settle(generation, settled);
4641        }
4642    }
4643    // By Generation as well as by key. Cancellation is cooperative, so a superseded
4644    // probe still runs to completion and arrives here after the Generation that
4645    // superseded it has already put its own entry under this key; clearing by key
4646    // alone would delete that live entry, leaving the entity with nothing for the
4647    // next Generation to interrupt and nothing for the deadline sweep to time out.
4648    // The settle gate is signalled either way, since the debt belongs to the probe
4649    // rather than to the entry.
4650    if table
4651        .in_flight
4652        .get(key)
4653        .is_some_and(|in_flight| in_flight.generation == generation.value())
4654    {
4655        table.in_flight.remove(key);
4656    }
4657    drop(table);
4658    complete_one(settle_gate);
4659}
4660
4661/// Reconciles one discovery result into `table`: a found entity is inserted or
4662/// marked Present again, even if it was Vanished, and one no longer found is
4663/// marked Vanished via [`EntityState::mark_vanished`]. Returns how many
4664/// in-flight probes were cancelled by a newly Vanished entity, for the caller
4665/// to signal `settle_gate`.
4666fn merge_discovery(
4667    table: &mut Table,
4668    exclusions: &[ResolvedExclusion],
4669    discovered: Vec<discovery::DiscoveredEntity>,
4670    gitmodules_failures: Vec<(EntityKey, String)>,
4671) -> usize {
4672    let mut found: HashSet<EntityKey> = HashSet::with_capacity(discovered.len());
4673
4674    for discovered in discovered {
4675        found.insert(discovered.key.clone());
4676        match table.index.get(&discovered.key).copied() {
4677            Some(idx) => {
4678                table.entities[idx].presence = Presence::Present;
4679                if let Some(repo) = discovered.repo {
4680                    table.repos.insert(discovered.key.clone(), repo);
4681                }
4682            }
4683            None => {
4684                let name = discovered
4685                    .display_name_override
4686                    .clone()
4687                    .unwrap_or_else(|| display_name(discovered.key.path()));
4688                let mut entity = EntityState::new(
4689                    discovered.key.clone(),
4690                    name,
4691                    Arc::clone(&discovered.common_dir),
4692                    discovered.kind,
4693                );
4694                entity.excluded =
4695                    excluded_by(exclusions, discovered.key.path(), &discovered.common_dir);
4696                if let Some(repo) = discovered.repo {
4697                    table.repos.insert(discovered.key.clone(), repo);
4698                }
4699                let idx = table.entities.len();
4700                table.index.insert(discovered.key, idx);
4701                table.entities.push(entity);
4702            }
4703        }
4704    }
4705
4706    // A boundary's `.gitmodules` failure is re-derived from this pass alone,
4707    // never carried over from a previous one: a failure that was fixed since the
4708    // last Generation must clear, not stay stuck forever.
4709    let now_failing: HashMap<EntityKey, String> = gitmodules_failures.into_iter().collect();
4710    for key in &found {
4711        if let Some(&idx) = table.index.get(key) {
4712            table.entities[idx].diagnostics.gitmodules_failed = now_failing
4713                .get(key)
4714                .map(|message| Arc::from(message.as_str()));
4715        }
4716    }
4717
4718    let missing: Vec<EntityKey> = table
4719        .index
4720        .keys()
4721        .filter(|key| !found.contains(*key))
4722        .cloned()
4723        .collect();
4724    let mut cancelled = 0usize;
4725    for key in missing {
4726        if let Some(&idx) = table.index.get(&key) {
4727            table.entities[idx].mark_vanished();
4728        }
4729        if let Some(in_flight) = table.in_flight.remove(&key) {
4730            in_flight.cancel.store(true, Ordering::Release);
4731            cancelled += 1;
4732        }
4733    }
4734
4735    cancelled
4736}
4737
4738/// A basename read from the entity's own resolved path. A real display name has
4739/// collision handling that belongs to [config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md);
4740/// this is a placeholder good enough to populate the table.
4741///
4742/// This is the one function that computes it: `start_internal`'s discovery loop
4743/// and `probe_now`'s fallback insert for an unknown key both call it rather than
4744/// formatting a name of their own, which is what keeps the name shown on screen
4745/// and the name a future state file would key by byte-identical.
4746fn display_name(path: &Path) -> Arc<str> {
4747    Arc::from(
4748        path.file_name()
4749            .and_then(|name| name.to_str())
4750            .unwrap_or("?"),
4751    )
4752}
4753
4754/// Sleeps for `warn_after`, then reports `progress`'s count and `roots` if the walk
4755/// still has not finished, per [discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md):
4756/// the one-second still-walking warning needs a timer watching an in-flight walk
4757/// from outside it, since discovery itself has no callback and no notion of "still
4758/// running". `None` once the walk has already finished.
4759fn watch_for_slow_discovery(
4760    progress: Arc<AtomicUsize>,
4761    finished: Arc<AtomicBool>,
4762    roots: Vec<PathBuf>,
4763    warn_after: Duration,
4764) -> Option<String> {
4765    thread::sleep(warn_after);
4766    if finished.load(Ordering::Acquire) {
4767        return None;
4768    }
4769    Some(still_walking_message(
4770        progress.load(Ordering::Acquire),
4771        &roots,
4772    ))
4773}
4774
4775fn still_walking_message(directories_visited: usize, roots: &[PathBuf]) -> String {
4776    let roots = roots
4777        .iter()
4778        .map(|root| root.display().to_string())
4779        .collect::<Vec<_>>()
4780        .join(", ");
4781    format!("discovery: still walking, {directories_visited} directories reached under {roots}")
4782}
4783
4784/// The persistent warning left once a walk abandons, per
4785/// [config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md#discovery-bounds):
4786/// unlike the still-walking warning, this one never clears itself, since the Set
4787/// stays out of the automatic refresh path for the life of this `Core`.
4788fn abandoned_discovery_message(directories_visited: usize) -> String {
4789    format!("discovery: stopped at {directories_visited} directories")
4790}
4791
4792/// Runs `step` until it says it is done or `cancel` is observed set, checked before
4793/// every call. Returns how many times `step` actually ran, which is what lets a
4794/// test prove a cancelled loop stopped mid-flight rather than merely having a flag
4795/// set on it somewhere. Not yet called from a real probe: `git::head_shape` has no
4796/// loop to interrupt, so this is the shape a later, genuinely interruptible phase
4797/// (gix `status`, taking `should_interrupt` directly) will use.
4798#[allow(dead_code)] // exercised by its own test; no interruptible probe calls it yet
4799pub(crate) fn run_while_not_cancelled(
4800    cancel: &AtomicBool,
4801    mut step: impl FnMut() -> bool,
4802) -> usize {
4803    let mut ran = 0;
4804    while !cancel.load(Ordering::Acquire) {
4805        if !step() {
4806            break;
4807        }
4808        ran += 1;
4809    }
4810    ran
4811}
4812
4813#[cfg(test)]
4814mod tests {
4815    use std::fs;
4816    use std::process::Command;
4817    use std::sync::mpsc;
4818
4819    use super::*;
4820    use crate::entity::{AheadBehind, DefaultBranchStopped, WorktreeState};
4821    use crate::liveness::{BACKSTOP, FIXTURE_LIFETIME, wait_for};
4822    use crate::snapshot::{RowSummary, summary};
4823    use crate::test_support::{git, head_sha, loose_object_count};
4824
4825    fn init_repo_with_a_commit(path: &Path) {
4826        fs::create_dir_all(path).expect("create repo dir");
4827        gix::init(path).expect("init repo");
4828        let status = Command::new("git")
4829            .arg("-C")
4830            .arg(path)
4831            .args(["-c", "user.email=test@example.com", "-c", "user.name=Test"])
4832            .args(["commit", "--allow-empty", "-m", "first"])
4833            .status()
4834            .expect("run git commit");
4835        assert!(status.success());
4836    }
4837
4838    /// A second (or later) commit against an already-initialised repo at `path`,
4839    /// with the same explicit identity `init_repo_with_a_commit` supplies: never
4840    /// relying on a global git identity, which a machine running CI has none of.
4841    /// Commits a real change, which is what the poll's own user story is about and what an
4842    /// empty commit is not: `git add` rewrites `.git/index` unconditionally, while whether a
4843    /// commit with nothing staged rewrites it is left to git's racy-entry heuristic and
4844    /// differs between platforms. `index` is the only one of the polled paths a commit on an
4845    /// attached HEAD moves, so a test that depends on an empty commit moving it is testing
4846    /// that heuristic rather than the poll.
4847    fn commit_a_change(path: &Path, message: &str) {
4848        let gitdir = gitdir_of(path);
4849        let before = poll::fingerprint(&gitdir);
4850
4851        std::fs::write(path.join(format!("{message}.txt")), message.as_bytes())
4852            .expect("write a file to commit");
4853        let added = Command::new("git")
4854            .arg("-C")
4855            .arg(path)
4856            .args(["add", "-A"])
4857            .status()
4858            .expect("run git add");
4859        assert!(added.success());
4860        commit(path, message, &["-m", message]);
4861
4862        // The fixture's own premise, asserted rather than assumed: a commit on an attached
4863        // HEAD moves none of the polled paths except `index` (`HEAD` is untouched, and
4864        // rewriting `refs/heads/<branch>` does not move `refs/` itself), so if git leaves
4865        // `index` alone here there is nothing for the poll to see and the failure belongs to
4866        // this fixture, not to the sweep it is setting up.
4867        assert!(
4868            poll::moved(&before, &poll::fingerprint(&gitdir)),
4869            "committing in {} moved none of the polled paths under {}, so this fixture cannot \
4870             show the poll anything",
4871            path.display(),
4872            gitdir.display()
4873        );
4874    }
4875
4876    /// The absolute gitdir git itself reports, which for a linked Worktree is its own
4877    /// `.git/worktrees/<name>` rather than the `.git` file beside the checkout.
4878    fn gitdir_of(work_dir: &Path) -> PathBuf {
4879        let output = Command::new("git")
4880            .arg("-C")
4881            .arg(work_dir)
4882            .args(["rev-parse", "--absolute-git-dir"])
4883            .output()
4884            .expect("run git rev-parse");
4885        assert!(
4886            output.status.success(),
4887            "resolve the gitdir of {}",
4888            work_dir.display()
4889        );
4890        PathBuf::from(
4891            std::str::from_utf8(&output.stdout)
4892                .expect("a utf-8 gitdir path")
4893                .trim(),
4894        )
4895    }
4896
4897    /// The shared tail of the commit helpers.
4898    fn commit(path: &Path, message: &str, args: &[&str]) {
4899        let status = Command::new("git")
4900            .arg("-C")
4901            .arg(path)
4902            .args(["-c", "user.email=test@example.com", "-c", "user.name=Test"])
4903            .arg("commit")
4904            .args(args)
4905            .status()
4906            .unwrap_or_else(|error| panic!("run git commit {message}: {error}"));
4907        assert!(status.success());
4908    }
4909
4910    /// A `FetchSpec` that never fires on its own: `enabled: false`, so every
4911    /// existing test that does not care about the periodic fetch keeps behaving
4912    /// exactly as it did before this field existed.
4913    fn fetch_spec_for_test() -> FetchSpec {
4914        FetchSpec {
4915            enabled: false,
4916            interval: Duration::from_secs(3600),
4917            concurrency: 4,
4918        }
4919    }
4920
4921    /// An `AutoUpdateSpec` that never fires on its own, the same reason
4922    /// [`fetch_spec_for_test`] never does: every existing test that does not care
4923    /// about the auto-update keeps behaving exactly as it did before this field
4924    /// existed.
4925    fn auto_update_spec_for_test() -> AutoUpdateSpec {
4926        AutoUpdateSpec { enabled: false }
4927    }
4928
4929    fn spec(roots: Vec<PathBuf>) -> CoreSpec {
4930        CoreSpec {
4931            set: SetSpec {
4932                name: "test".to_string(),
4933                roots,
4934                include: Vec::new(),
4935                exclude: Vec::new(),
4936            },
4937            overrides: Vec::new(),
4938            poll_interval: Duration::from_secs(3600),
4939            status_stale_after: Duration::from_secs(3600),
4940            generation_deadline: Duration::from_secs(3600),
4941            show_submodules: false,
4942            fetch: fetch_spec_for_test(),
4943            auto_update: auto_update_spec_for_test(),
4944        }
4945    }
4946
4947    /// Criterion 2's "no field" half: scope is never a partial dial, not even as a field
4948    /// on the plain-data struct crossing into the core. An exhaustive destructure names
4949    /// every field `CoreSpec` has; a scoping field added under any name fails to compile
4950    /// this test rather than landing unacknowledged. `show_submodules` is named here too,
4951    /// deliberately: it narrows probing and rendering, never what discovery bounds, so it
4952    /// is not the scoping field this test guards against
4953    /// ([discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md)'s
4954    /// "narrows the view rather than bounding the work"). `fetch` and `auto_update` are
4955    /// excluded from that same guard for the same reason: they narrow what the periodic
4956    /// fetch and the fast-forward-only update touch, never what discovery bounds.
4957    #[test]
4958    fn core_spec_carries_no_scoping_field_scope_is_never_a_dial() {
4959        let CoreSpec {
4960            set: _,
4961            overrides: _,
4962            poll_interval: _,
4963            status_stale_after: _,
4964            generation_deadline: _,
4965            show_submodules: _,
4966            fetch: _,
4967            auto_update: _,
4968        } = spec(Vec::new());
4969    }
4970
4971    fn root_of(dir: &tempfile::TempDir) -> PathBuf {
4972        dir.path().canonicalize().expect("canonicalize temp dir")
4973    }
4974
4975    /// Blocks until `core`'s launch Generation has settled, and hands back what it settled
4976    /// to.
4977    ///
4978    /// `Core::start`'s own first walk is that `Core`'s Generation 1 and probes every row it
4979    /// finds, so a test that counts what a later Generation did, or that watches a cell
4980    /// only its own Generation may write, has to begin from a table launch has already
4981    /// finished with. [`BACKSTOP`] rather than a budget, and the gate is read afterwards so
4982    /// an expired wait fails here by name instead of downstream as a wrong value.
4983    fn settle_launch(core: &Core) -> Snapshot {
4984        let launched = core.settle();
4985        assert_eq!(
4986            core.settle_gate_count_for_test(),
4987            0,
4988            "launch's own Generation never settled, so nothing after this is starting from \
4989             the point it claims to"
4990        );
4991        launched
4992    }
4993
4994    /// [`settle_launch`] over a `Core` built the ordinary way, for the many tests that want
4995    /// nothing else from the constructor.
4996    fn started_and_settled(spec: CoreSpec) -> (Core, Snapshot) {
4997        let core = Core::start_discovered(spec);
4998        let launched = settle_launch(&core);
4999        (core, launched)
5000    }
5001
5002    /// Sets every polled gitdir entry's modification time ten seconds into the past, so any
5003    /// write that follows reads as newer than the baseline by more than a filesystem's
5004    /// timestamp granularity. Without it a commit made microseconds after the baseline sweep
5005    /// lands in the same coarse tick on Linux and reads as no movement at all, which is a race
5006    /// in the harness rather than in the poll: real sweeps are a configured interval apart.
5007    /// Reads the polled names from [`poll::POLLED_GITDIR_ENTRIES`] rather than restating them.
5008    fn backdate_polled_entries(work_dir: &Path) {
5009        let gitdir = gitdir_of(work_dir);
5010
5011        let past = std::time::SystemTime::now() - Duration::from_secs(10);
5012        let mut touched = 0;
5013        for name in poll::POLLED_GITDIR_ENTRIES {
5014            let path = gitdir.join(name);
5015            if path.exists() {
5016                set_mtime_to(&path, past);
5017                touched += 1;
5018            }
5019        }
5020        assert!(
5021            touched > 0,
5022            "backdated nothing under {}; the gitdir holds none of the polled entries and the \
5023             baseline this sets up would not be older than what follows",
5024            gitdir.display()
5025        );
5026    }
5027
5028    /// `utimensat`, since a plain file handle cannot set a directory's time and `refs` is one.
5029    fn set_mtime_to(path: &Path, at: std::time::SystemTime) {
5030        use std::os::unix::ffi::OsStrExt;
5031
5032        let secs = at
5033            .duration_since(std::time::SystemTime::UNIX_EPOCH)
5034            .expect("a time after the epoch")
5035            .as_secs() as libc::time_t;
5036        let times = [
5037            libc::timespec {
5038                tv_sec: secs,
5039                tv_nsec: 0,
5040            },
5041            libc::timespec {
5042                tv_sec: secs,
5043                tv_nsec: 0,
5044            },
5045        ];
5046        let c_path =
5047            std::ffi::CString::new(path.as_os_str().as_bytes()).expect("a path with no NUL");
5048        let rc = unsafe { libc::utimensat(libc::AT_FDCWD, c_path.as_ptr(), times.as_ptr(), 0) };
5049        assert_eq!(
5050            rc,
5051            0,
5052            "set mtime on {}: {}",
5053            path.display(),
5054            std::io::Error::last_os_error()
5055        );
5056    }
5057
5058    fn step(argv: &[&str]) -> Step {
5059        Step {
5060            argv: argv.iter().map(|s| s.to_string()).collect(),
5061            shell: false,
5062            interactive: false,
5063            env: Vec::new(),
5064        }
5065    }
5066
5067    /// `shell = true`'s own convention: one argv element, the whole command string.
5068    fn shell_step(command: &str) -> Step {
5069        Step {
5070            argv: vec![command.to_string()],
5071            shell: true,
5072            interactive: false,
5073            env: Vec::new(),
5074        }
5075    }
5076
5077    /// `shell = true` plus `interactive = true`: the same convention, run through
5078    /// `$SHELL -ic` instead of `$SHELL -c`.
5079    fn interactive_shell_step(command: &str) -> Step {
5080        Step {
5081            argv: vec![command.to_string()],
5082            shell: true,
5083            interactive: true,
5084            env: Vec::new(),
5085        }
5086    }
5087
5088    /// The one entity's Action receipt, if the run that wrote it is the one `label` names:
5089    /// a run that replaced an earlier run's receipt on the same row is what these reads are
5090    /// distinguishing.
5091    fn receipt_labelled(core: &Core, key: &EntityKey, label: &str) -> Option<ActionReceipt> {
5092        core.snapshot()
5093            .entities
5094            .iter()
5095            .find(|entity| entity.key == *key)
5096            .and_then(|entity| entity.last_action.clone())
5097            .filter(|receipt| &*receipt.label == label)
5098    }
5099
5100    fn action(label: &str, steps: Vec<Step>) -> ActionSpec {
5101        ActionSpec {
5102            label: Arc::from(label),
5103            name: Some(Arc::from(label)),
5104            steps,
5105            concurrency: 4,
5106            when: None,
5107        }
5108    }
5109
5110    /// [`action`], narrowed by `when`, a Filter grammar predicate
5111    /// (`docs/spec/actions.md`'s "The Selection and the gate").
5112    fn action_with_when(label: &str, steps: Vec<Step>, when: &str) -> ActionSpec {
5113        ActionSpec {
5114            when: Some(Filter::parse(when)),
5115            ..action(label, steps)
5116        }
5117    }
5118
5119    /// End-to-end: the test thread never spawns anything itself, only calls
5120    /// `Core`'s public methods, and real branch data still lands in the snapshot.
5121    /// That is the proof that the core owns the threads doing the work, not the
5122    /// consumer.
5123    #[test]
5124    fn refresh_and_settle_populate_real_cells_without_the_caller_spawning_a_thread() {
5125        let dir = tempfile::tempdir().expect("temp dir");
5126        let root = root_of(&dir);
5127        let repo = root.join("repo");
5128        init_repo_with_a_commit(&repo);
5129
5130        let core = Core::start_discovered(spec(vec![root]));
5131        let keys: Vec<EntityKey> = core
5132            .snapshot()
5133            .entities
5134            .iter()
5135            .map(|entity| entity.key.clone())
5136            .collect();
5137        assert_eq!(keys.len(), 1);
5138
5139        core.refresh(&keys);
5140        let settled = core.settle();
5141
5142        let entity = &settled.entities[0];
5143        match entity.branch.settled() {
5144            Some(Settled::Known {
5145                value: Head::Branch { .. },
5146                at: _,
5147                stale: _,
5148            }) => {}
5149            other => panic!("expected an attached branch, got {other:?}"),
5150        }
5151    }
5152
5153    // --- Single source of truth: read the first-frame budgets from the spec itself,
5154    // the same pattern `executor.rs` already uses for its PTY width and capture bounds
5155    // against `docs/spec/actions.md`. ---
5156
5157    fn spec_refresh_md() -> String {
5158        let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
5159        std::fs::read_to_string(manifest_dir.join("../../docs/spec/refresh.md"))
5160            .expect("read docs/spec/refresh.md")
5161    }
5162
5163    fn spec_first_frame_budgets_ms(spec: &str) -> (u64, u64) {
5164        let anchor = "rows with names on screen within ";
5165        let after = spec
5166            .split(anchor)
5167            .nth(1)
5168            .expect("the first-frame budget sentence is present");
5169        let mut parts = after.splitn(2, "ms, every cheap column filled within ");
5170        let names: u64 = parts
5171            .next()
5172            .expect("a names-on-screen budget")
5173            .parse()
5174            .expect("the names-on-screen budget is an integer");
5175        let after_cheap = parts.next().expect("a cheap-column budget and beyond");
5176        let cheap_columns: u64 = after_cheap
5177            .split("ms,")
5178            .next()
5179            .expect("a cheap-column budget")
5180            .parse()
5181            .expect("the cheap-column budget is an integer");
5182        (names, cheap_columns)
5183    }
5184
5185    /// Criterion 1: the two budgets `refresh.md`'s "The first frame" states are declared
5186    /// once as named constants and cross-checked against the spec sentence here, so the
5187    /// spec and the code cannot drift apart silently.
5188    #[test]
5189    fn first_frame_budget_constants_match_the_spec_of_record() {
5190        let spec = spec_refresh_md();
5191        let (names_ms, cheap_columns_ms) = spec_first_frame_budgets_ms(&spec);
5192        assert_eq!(names_ms, FIRST_FRAME_NAMES_BUDGET_MS);
5193        assert_eq!(cheap_columns_ms, FIRST_FRAME_CHEAP_COLUMNS_BUDGET_MS);
5194    }
5195
5196    /// Criterion 2: every entity a Generation is dispatched over gets its phase C read,
5197    /// never a subset. `refresh.md`'s "Scope and order" makes scope never a partial dial,
5198    /// so this proves it against a population wide enough that a mistaken "first K" or
5199    /// "last K" scoping mistake would leave a visible gap: sixteen real repos, dispatched in
5200    /// one Generation, every one of them still `dirty: Known` once settled, position sixteen
5201    /// exactly as covered as position one. A mutation that scoped phase C to, say, the first
5202    /// ten dispatched entities fails this directly.
5203    #[test]
5204    fn every_dispatched_entity_gets_its_dirty_cell_settled_not_a_subset() {
5205        let dir = tempfile::tempdir().expect("temp dir");
5206        let root = root_of(&dir);
5207        const ENTITY_COUNT: usize = 16;
5208        for index in 0..ENTITY_COUNT {
5209            init_repo_with_a_commit(&root.join(format!("repo-{index}")));
5210        }
5211
5212        let core = Core::start_discovered(spec(vec![root]));
5213        let keys: Vec<EntityKey> = core
5214            .snapshot()
5215            .entities
5216            .iter()
5217            .map(|entity| entity.key.clone())
5218            .collect();
5219        assert_eq!(keys.len(), ENTITY_COUNT, "expected every repo discovered");
5220
5221        core.refresh(&keys);
5222        let settled = core.settle();
5223
5224        for entity in &settled.entities {
5225            assert!(
5226                matches!(
5227                    entity.dirty.settled(),
5228                    Some(Settled::Known {
5229                        value: _,
5230                        at: _,
5231                        stale: _
5232                    })
5233                ),
5234                "entity {:?} was left without a settled dirty cell, which is exactly what a \
5235                 visibility-scoped dispatch would leave behind on the entities it skipped: \
5236                 got {:?}",
5237                entity.name,
5238                entity.dirty.settled()
5239            );
5240        }
5241    }
5242
5243    /// refresh.md's "The first frame" budget (cheap columns filled within 200ms) is
5244    /// unreachable if the cheap outcomes wait behind phase C, so this proves the two
5245    /// applies are independent with a blocking seam rather than a sleep or a wall-clock
5246    /// deadline: `Core::hold_phase_c_for_test` holds phase C (and D) open after the cheap
5247    /// outcomes have already landed, and the test observes `branch` carrying this
5248    /// Generation's answer while `dirty` still carries the previous one. Run this against
5249    /// a version that bundles every outcome into one apply placed after phase C computes
5250    /// (this ticket's regression) and it fails, since nothing writes `branch` until that
5251    /// single bundled apply lands alongside `dirty`.
5252    ///
5253    /// Launch's own Generation is drained first and both cells are then moved, so each is
5254    /// read on the value it holds rather than on being blank: a table that has already
5255    /// been probed once is the only starting point available now that `Core::start` runs
5256    /// a Generation of its own, and reading values is the stronger claim anyway.
5257    #[test]
5258    fn cheap_outcomes_land_before_a_held_phase_c_settles() {
5259        let dir = tempfile::tempdir().expect("temp dir");
5260        let root = root_of(&dir);
5261        let repo = root.join("repo");
5262        init_repo_with_a_commit(&repo);
5263
5264        let (core, launched) = started_and_settled(spec(vec![root]));
5265        let key = launched.entities[0].key.clone();
5266        assert_eq!(
5267            dirty_total(&launched.entities[0]),
5268            0,
5269            "the fixture starts clean, which is the value the held phase C must still be \
5270             reading once the working tree below has moved"
5271        );
5272
5273        // One move per phase, so neither cell can be read on absence: `branch` is phase A
5274        // and must carry the new name while phase C is held, `dirty` is phase C and must
5275        // still carry launch's own clean count until it is released.
5276        git(&repo, &["checkout", "-b", "held"]);
5277        fs::write(repo.join("untracked.txt"), b"uncommitted")
5278            .expect("write an untracked file into the fixture");
5279
5280        core.hold_phase_c_for_test(&key);
5281        core.refresh(std::slice::from_ref(&key));
5282        core.wait_phase_c_landed_for_test(&key);
5283
5284        let mid_flight = core.snapshot();
5285        let entity = mid_flight
5286            .entities
5287            .iter()
5288            .find(|entity| entity.key == key)
5289            .expect("entity present");
5290        assert!(
5291            matches!(
5292                entity.branch.settled(),
5293                Some(Settled::Known {
5294                    value: Head::Branch { name, .. },
5295                    at: _,
5296                    stale: _
5297                }) if &**name == "held"
5298            ),
5299            "the cheap branch cell must carry this Generation's own answer while phase C is \
5300             still held open, got {:?}",
5301            entity.branch.settled()
5302        );
5303        assert!(
5304            entity.dirty.is_in_flight() && dirty_total(entity) == 0,
5305            "phase C is deliberately held open here; a bundled apply would already have \
5306             written this cell's new count alongside branch, got {:?}",
5307            entity.dirty.settled()
5308        );
5309
5310        core.release_phase_c_for_test(&key);
5311        core.wait_phase_c_finished_for_test(&key);
5312
5313        let settled = core.snapshot();
5314        let entity = settled
5315            .entities
5316            .iter()
5317            .find(|entity| entity.key == key)
5318            .expect("entity present");
5319        assert_eq!(
5320            dirty_total(entity),
5321            1,
5322            "phase C must settle its own count once released, got {:?}",
5323            entity.dirty.settled()
5324        );
5325    }
5326
5327    /// One entity's settled dirty count, or a panic naming what it read instead. Lets a
5328    /// test that has to distinguish two Generations by value say "still zero" and "now
5329    /// one" without repeating the match on every read.
5330    fn dirty_total(entity: &EntityState) -> u32 {
5331        match entity.dirty.settled() {
5332            Some(Settled::Known {
5333                value,
5334                at: _,
5335                stale: _,
5336            }) => value.total(),
5337            other => panic!("expected a settled dirty count, got {other:?}"),
5338        }
5339    }
5340
5341    /// Splitting one dispatched entity's write into a cheap apply and a phase C/D apply
5342    /// must still signal `settle_gate` exactly once per entity, or `settle` hangs (never
5343    /// decremented enough) or returns early (decremented twice). Two entities held open
5344    /// together prove the exact count at each step: a mutation that also decrements the
5345    /// gate from the cheap apply leaves it at 0 instead of 2 after both entities' cheap
5346    /// outcomes land, and a mutation that drops the decrement from the phase C/D apply
5347    /// leaves it at 2, never 1, once only the first entity finishes.
5348    #[test]
5349    fn splitting_the_probe_write_signals_settle_gate_exactly_once_per_entity() {
5350        let dir = tempfile::tempdir().expect("temp dir");
5351        let root = root_of(&dir);
5352        init_repo_with_a_commit(&root.join("a"));
5353        init_repo_with_a_commit(&root.join("b"));
5354
5355        let (core, snapshot) = started_and_settled(spec(vec![root]));
5356        let key_a = snapshot
5357            .entities
5358            .iter()
5359            .find(|entity| &*entity.name == "a")
5360            .expect("entity a present")
5361            .key
5362            .clone();
5363        let key_b = snapshot
5364            .entities
5365            .iter()
5366            .find(|entity| &*entity.name == "b")
5367            .expect("entity b present")
5368            .key
5369            .clone();
5370
5371        core.hold_phase_c_for_test(&key_a);
5372        core.hold_phase_c_for_test(&key_b);
5373        core.refresh(&[key_a.clone(), key_b.clone()]);
5374        // A Generation reserves its number on this thread and raises the gate on one of
5375        // its own, so this is the rendezvous that says the raise has happened. A join,
5376        // never a sleep.
5377        core.wait_dispatched_for_test();
5378        assert_eq!(
5379            core.settle_gate_count_for_test(),
5380            2,
5381            "dispatching two entities must add exactly two to the settle gate"
5382        );
5383
5384        core.wait_phase_c_landed_for_test(&key_a);
5385        core.wait_phase_c_landed_for_test(&key_b);
5386        assert_eq!(
5387            core.settle_gate_count_for_test(),
5388            2,
5389            "the cheap apply must never touch the settle gate: both entities' cheap \
5390             outcomes have landed and neither has finished phase C yet"
5391        );
5392
5393        core.release_phase_c_for_test(&key_a);
5394        core.wait_phase_c_finished_for_test(&key_a);
5395        assert_eq!(
5396            core.settle_gate_count_for_test(),
5397            1,
5398            "exactly one entity finished, so the gate must fall by exactly one, not two \
5399             (double-counted) and not zero (left short)"
5400        );
5401
5402        core.release_phase_c_for_test(&key_b);
5403        core.wait_phase_c_finished_for_test(&key_b);
5404        assert_eq!(
5405            core.settle_gate_count_for_test(),
5406            0,
5407            "both entities finished, so the gate must be fully drained"
5408        );
5409    }
5410
5411    /// The gate [`Core::hold_phase_c_for_test`] last registered for `key`, so a test can
5412    /// still name one a later registration for the same entity has replaced in the map.
5413    fn registered_gate(core: &Core, key: &EntityKey) -> PhaseCGateHandle {
5414        core.phase_c_gates
5415            .lock()
5416            .unwrap()
5417            .get(key)
5418            .cloned()
5419            .expect("hold_phase_c_for_test must be called before reading its gate")
5420    }
5421
5422    /// Opens `gate` directly rather than through [`Core::release_phase_c_for_test`], which
5423    /// resolves by key and so cannot name a gate a later registration has replaced.
5424    fn release_gate(gate: &PhaseCGateHandle) {
5425        let (lock, cvar) = &**gate;
5426        lock.lock().unwrap().may_proceed = true;
5427        cvar.notify_all();
5428    }
5429
5430    fn gate_is_finished(gate: &PhaseCGateHandle) -> bool {
5431        gate.0.lock().unwrap().finished
5432    }
5433
5434    /// A probe signals the phase C gate its own Generation was dispatched against, never
5435    /// whatever gate the map holds by the time that probe finishes.
5436    ///
5437    /// Reading the map twice per probe, once before phase C and once after, made the gate
5438    /// a probe signalled a function of when it got there: a probe from an already-settled
5439    /// Generation, past its own first read but not yet past its second, would find a gate
5440    /// registered in between and mark it finished, so the wait a later Generation was
5441    /// making returned before that Generation had applied anything or touched the settle
5442    /// gate. Registering a second gate for the same entity while the first is still held
5443    /// open is that interleaving with the timing taken out of it: the parked probe took
5444    /// the first gate, and the map holds the second by the time it finishes.
5445    #[test]
5446    fn a_probe_signals_the_phase_c_gate_its_own_generation_was_dispatched_against() {
5447        let dir = tempfile::tempdir().expect("temp dir");
5448        let root = root_of(&dir);
5449        init_repo_with_a_commit(&root.join("repo"));
5450
5451        let (core, launched) = started_and_settled(spec(vec![root]));
5452        let key = launched.entities[0].key.clone();
5453
5454        core.hold_phase_c_for_test(&key);
5455        let dispatched_against = registered_gate(&core, &key);
5456        core.refresh(std::slice::from_ref(&key));
5457        core.wait_phase_c_landed_for_test(&key);
5458
5459        core.hold_phase_c_for_test(&key);
5460        let registered_later = registered_gate(&core, &key);
5461        release_gate(&dispatched_against);
5462
5463        wait_for(
5464            "the held probe to signal the gate its own Generation was dispatched against",
5465            || gate_is_finished(&dispatched_against),
5466        );
5467        assert!(
5468            !gate_is_finished(&registered_later),
5469            "a gate registered after this Generation dispatched must never be marked \
5470             finished by it: a test waiting on that gate would return before this \
5471             Generation had applied its outcome or decremented the settle gate"
5472        );
5473    }
5474
5475    /// A probe finishing clears its own Generation's in-flight entry, never whatever the
5476    /// table holds under that key by the time it gets there.
5477    ///
5478    /// Cancellation is cooperative (refresh.md's "Cancellation"), so a superseded probe
5479    /// runs to completion and reaches `apply_probe_outcome` after the Generation that
5480    /// superseded it has already put its own entry under the same key. Clearing by key
5481    /// alone deleted that live entry, and refresh.md's "Supersession" then had nothing to
5482    /// set: the Generation after it found no previous entry, so the entity's interrupt
5483    /// flag stayed false and its probe ran on uncancelled, which is the 1.79x ADR 0013
5484    /// measured. Parking a probe at its phase C gate and superseding it while it is held
5485    /// is that interleaving with the timing taken out of it.
5486    #[test]
5487    fn a_probe_finishing_clears_only_its_own_generations_in_flight_entry() {
5488        let dir = tempfile::tempdir().expect("temp dir");
5489        let root = root_of(&dir);
5490        init_repo_with_a_commit(&root.join("repo"));
5491
5492        let (core, launched) = started_and_settled(spec(vec![root]));
5493        let key = launched.entities[0].key.clone();
5494
5495        core.hold_phase_c_for_test(&key);
5496        core.refresh(std::slice::from_ref(&key));
5497        core.wait_phase_c_landed_for_test(&key);
5498
5499        // The Generation that supersedes the parked probe, holding the interrupt flag the
5500        // `refresh` below has to be able to find and set.
5501        let superseding = core.begin_shared_generation_for_test(std::slice::from_ref(&key));
5502
5503        core.release_phase_c_for_test(&key);
5504        core.wait_phase_c_finished_for_test(&key);
5505
5506        core.refresh(std::slice::from_ref(&key));
5507        core.wait_dispatched_for_test();
5508
5509        assert!(
5510            superseding.cancels[&key].load(Ordering::Acquire),
5511            "a probe from a Generation that has already been superseded must leave the \
5512             live Generation's in-flight entry alone, or the Generation after it has \
5513             nothing to interrupt"
5514        );
5515    }
5516
5517    /// Criterion 5, the honest half: a concurrent pool's *completion* order is not
5518    /// dispatch order and asserting it would make this test flaky in exact proportion to
5519    /// how well rayon's scheduler works, so this asserts *dispatch* order instead, which is
5520    /// deterministic because `refresh`'s own dispatch loop is a single sequential pass over
5521    /// `order` that spawns work without ever waiting on it. `dispatch_order` itself, the
5522    /// function that actually builds the cursor-then-visible-then-rest sequence
5523    /// `refresh.md`'s "Scope and order" names, lives in the `repon` crate and is tested
5524    /// there: `core-api.md`'s ownership table gives that computation to the consumer, never
5525    /// to this crate. What this test proves on the core side is the half core-api.md commits
5526    /// to: `refresh` dispatches in exactly the order it is handed, position for position,
5527    /// never reordered by any heuristic of its own (never, per `refresh.md`, by predicted
5528    /// cost). A hand-built three-tier order stands in for what `dispatch_order` would
5529    /// produce, six entities discovered, one named cursor, two named visible, three left
5530    /// over in discovery order.
5531    #[test]
5532    fn refresh_dispatches_phase_c_in_exactly_the_order_it_is_given() {
5533        let dir = tempfile::tempdir().expect("temp dir");
5534        let root = root_of(&dir);
5535        const ENTITY_COUNT: usize = 6;
5536        for index in 0..ENTITY_COUNT {
5537            init_repo_with_a_commit(&root.join(format!("repo-{index}")));
5538        }
5539
5540        let (core, launched) = started_and_settled(spec(vec![root]));
5541        let discovery_order: Vec<EntityKey> = launched
5542            .entities
5543            .iter()
5544            .map(|entity| entity.key.clone())
5545            .collect();
5546        assert_eq!(
5547            discovery_order.len(),
5548            ENTITY_COUNT,
5549            "expected every repo discovered"
5550        );
5551
5552        // The cursor row, then the visible rows (never the cursor's own row twice), then
5553        // everything else in discovery order: refresh.md's own three tiers, hand-assembled
5554        // the way `dispatch_order` would.
5555        let cursor = discovery_order[3].clone();
5556        let visible = [discovery_order[1].clone(), discovery_order[4].clone()];
5557        let mut three_tier_order = vec![cursor.clone()];
5558        three_tier_order.extend(visible.iter().cloned());
5559        for key in &discovery_order {
5560            if *key != cursor && !visible.contains(key) {
5561                three_tier_order.push(key.clone());
5562            }
5563        }
5564        assert_eq!(
5565            three_tier_order.len(),
5566            ENTITY_COUNT,
5567            "sanity check: the hand-built order must cover every discovered entity exactly \
5568             once"
5569        );
5570
5571        core.refresh(&three_tier_order);
5572        core.settle();
5573
5574        assert_eq!(
5575            core.dispatch_log_for_test(),
5576            three_tier_order,
5577            "refresh must dispatch phase C in exactly the order it was given: the cursor \
5578             row, then the visible rows, then the rest in discovery order"
5579        );
5580    }
5581
5582    /// The defining behaviour for the shared-handle probe path: discovery leaves
5583    /// one thread-safe handle per entity, and a `refresh` reuses that same `Arc`
5584    /// rather than opening the repository again, proven by pointer identity
5585    /// surviving a probe rather than by inference from timing.
5586    #[test]
5587    fn refresh_reuses_the_cached_repository_handle_rather_than_reopening_it() {
5588        let dir = tempfile::tempdir().expect("temp dir");
5589        let root = root_of(&dir);
5590        let repo = root.join("repo");
5591        init_repo_with_a_commit(&repo);
5592
5593        let core = Core::start_discovered(spec(vec![root]));
5594        let key = core.snapshot().entities[0].key.clone();
5595        let before = core
5596            .cached_repo_handle_for_test(&key)
5597            .expect("discovery should have cached a handle");
5598
5599        core.refresh(std::slice::from_ref(&key));
5600        core.settle();
5601
5602        let after = core
5603            .cached_repo_handle_for_test(&key)
5604            .expect("the cached handle should still be there after a refresh");
5605        assert!(
5606            Arc::ptr_eq(&before, &after),
5607            "a refresh must reuse the cached handle, not replace it with a new one"
5608        );
5609    }
5610
5611    /// `refresh_running` reads true from the instant `refresh` returns, before its spawned
5612    /// dispatch has raised a single probe: `refresh` reserves the Generation and records the
5613    /// dispatch debt on the calling thread, so a caller reading this the same frame it
5614    /// dispatched must never see a false "nothing outstanding". It reads false again once
5615    /// the Generation has fully landed.
5616    #[test]
5617    fn refresh_running_reads_true_the_instant_refresh_returns_and_false_once_it_settles() {
5618        let dir = tempfile::tempdir().expect("temp dir");
5619        let root = root_of(&dir);
5620        init_repo_with_a_commit(&root.join("repo"));
5621
5622        let core = Core::start_discovered(spec(vec![root]));
5623        core.settle();
5624        assert!(
5625            !core.refresh_running(),
5626            "sanity: nothing outstanding once startup has settled"
5627        );
5628
5629        let keys: Vec<EntityKey> = core
5630            .snapshot()
5631            .entities
5632            .iter()
5633            .map(|entity| entity.key.clone())
5634            .collect();
5635        core.refresh(&keys);
5636        assert!(
5637            core.refresh_running(),
5638            "refresh reserves its Generation and records the dispatch debt before it \
5639             returns, so this must already read true"
5640        );
5641
5642        core.settle();
5643        assert!(
5644            !core.refresh_running(),
5645            "settle blocks until nothing is outstanding, so this must read false once it \
5646             returns"
5647        );
5648    }
5649
5650    /// A key with no cached handle, either because it was never discovered or
5651    /// because discovery could not open it, still gets a real answer: the probe
5652    /// falls back to opening the repository itself rather than failing outright.
5653    #[test]
5654    fn probing_a_key_with_no_cached_handle_still_opens_the_repository_itself() {
5655        let dir = tempfile::tempdir().expect("temp dir");
5656        let root = root_of(&dir);
5657        let repo = root.join("repo");
5658        init_repo_with_a_commit(&repo);
5659
5660        // A core discovering an unrelated, empty root, so `repo` is never cached.
5661        let empty_root = root_of(&tempfile::tempdir().expect("temp dir"));
5662        let core = Core::start_discovered(spec(vec![empty_root]));
5663        let key = EntityKey::new(Arc::from(repo.as_path()));
5664        assert!(core.cached_repo_handle_for_test(&key).is_none());
5665
5666        let entity = core.probe_now(&key);
5667
5668        assert!(matches!(
5669            entity.branch.settled(),
5670            Some(Settled::Known {
5671                value: Head::Branch { .. },
5672                at: _,
5673                stale: _
5674            })
5675        ));
5676    }
5677
5678    /// An empty order names no key, so the Generation it starts must reach no entity at
5679    /// all. Read off the dispatch log and the in-flight flag rather than off an unprobed
5680    /// cell, since launch's own Generation has already filled every cell by here.
5681    #[test]
5682    fn an_empty_order_dispatches_nothing_and_settle_returns_immediately() {
5683        let dir = tempfile::tempdir().expect("temp dir");
5684        let root = root_of(&dir);
5685        let repo = root.join("repo");
5686        init_repo_with_a_commit(&repo);
5687
5688        let (core, _launched) = started_and_settled(spec(vec![root]));
5689        assert!(
5690            !core.dispatch_log_for_test().is_empty(),
5691            "launch dispatched nothing, so an empty log below would say nothing about the \
5692             empty order"
5693        );
5694
5695        core.refresh(&[]);
5696        core.wait_dispatched_for_test();
5697
5698        assert_eq!(
5699            core.dispatch_log_for_test(),
5700            Vec::new(),
5701            "an empty order must dispatch no probe"
5702        );
5703        // The number is the claim here, not a backstop: an order naming nobody raises no
5704        // probe, so the gate is already at zero and this must come back settled at once
5705        // rather than eventually.
5706        let settled = core
5707            .try_settle(Duration::from_millis(50))
5708            .expect("an empty order raises no probe, so the settle gate is already at zero");
5709        assert!(!settled.entities[0].branch.is_in_flight());
5710    }
5711
5712    /// One entity left owing a probe that nothing will ever complete: no tick is sent, so
5713    /// the deadline sweep that would otherwise time the cell out never runs, and the settle
5714    /// gate stays above zero for as long as anyone waits on it.
5715    ///
5716    /// Returns the live `Core` and the tick sender, which the caller must hold: dropping it
5717    /// stops the dedicated thread's own select arm, and a `Core` whose thread has gone is a
5718    /// different fixture from the one these waits mean to test.
5719    fn one_probe_owed_that_never_lands(
5720        dir: &tempfile::TempDir,
5721    ) -> (Core, crossbeam_channel::Sender<Instant>) {
5722        let root = root_of(dir);
5723        init_repo_with_a_commit(&root.join("repo"));
5724        let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
5725        let core = Core::start_for_test(spec(vec![root]), Duration::from_secs(3600), tick_rx)
5726            .discovered()
5727            .core;
5728        let key = settle_launch(&core).entities[0].key.clone();
5729        core.begin_untracked_probe_for_test(&key);
5730        (core, tick_tx)
5731    }
5732
5733    /// The defect this pair exists for: a settle that gives up used to be indistinguishable
5734    /// from one that succeeded, so the table it handed back was read as an answer and the
5735    /// run failed several steps downstream with nothing left naming the wait.
5736    ///
5737    /// [`Core::settle`]'s half is to report at the wait, the way `liveness::wait_for` does.
5738    /// Driven through `settle_within` rather than `settle` so the expiry path is exercised
5739    /// without waiting out a real backstop.
5740    #[test]
5741    #[should_panic(expected = "waiting for everything this Core has in flight to land")]
5742    fn a_settle_that_expires_reports_at_the_wait_rather_than_returning_the_table() {
5743        let dir = tempfile::tempdir().expect("temp dir");
5744        let (core, _tick_tx) = one_probe_owed_that_never_lands(&dir);
5745
5746        core.settle_within(Duration::from_millis(20));
5747    }
5748
5749    /// [`Core::try_settle`]'s half of the same claim, for the callers that mean to degrade
5750    /// rather than fail: the expiry comes back as `Err`, so the unsettled table can only be
5751    /// reached by a caller that has already acknowledged the wait gave up.
5752    #[test]
5753    fn try_settle_hands_an_expiry_back_as_an_error_carrying_the_table_it_gave_up_on() {
5754        let dir = tempfile::tempdir().expect("temp dir");
5755        let (core, _tick_tx) = one_probe_owed_that_never_lands(&dir);
5756
5757        let unsettled = core
5758            .try_settle(Duration::from_millis(20))
5759            .expect_err("a probe nothing will ever complete cannot settle");
5760
5761        assert!(
5762            unsettled.entities[0].branch.is_in_flight(),
5763            "the Err arm must still carry the table as it stood, so a caller that degrades \
5764             deliberately has something to degrade with"
5765        );
5766    }
5767
5768    /// The other arm, so the two are told apart by what actually happened rather than by
5769    /// `Err` being the only reachable answer.
5770    #[test]
5771    fn try_settle_hands_a_generation_that_really_landed_back_as_ok() {
5772        let dir = tempfile::tempdir().expect("temp dir");
5773        let root = root_of(&dir);
5774        init_repo_with_a_commit(&root.join("repo"));
5775
5776        let (core, launched) = started_and_settled(spec(vec![root]));
5777        let key = launched.entities[0].key.clone();
5778        core.refresh(std::slice::from_ref(&key));
5779
5780        let settled = core
5781            .try_settle(BACKSTOP)
5782            .expect("a dispatched Generation must land inside the backstop");
5783
5784        assert!(!settled.entities[0].branch.is_in_flight());
5785    }
5786
5787    /// A Launcher return re-probes one entity through `probe_now`, so every cell a
5788    /// Generation settles must settle here too. `sync` is the one most recently added and
5789    /// the one a merge is most likely to drop, since no other test reads it off this path.
5790    #[test]
5791    fn probe_now_settles_the_sync_cell_as_well_as_the_branch_it_depends_on() {
5792        let dir = tempfile::tempdir().expect("temp dir");
5793        let root = root_of(&dir);
5794        let repo = root.join("repo");
5795        init_repo_with_a_commit(&repo);
5796
5797        let core = Core::start_discovered(spec(vec![root]));
5798        let key = core.snapshot().entities[0].key.clone();
5799
5800        let entity = core.probe_now(&key);
5801
5802        assert!(
5803            matches!(
5804                entity.sync.settled(),
5805                Some(Settled::Known {
5806                    value: SyncState::NoRemote,
5807                    at: _,
5808                    stale: _
5809                })
5810            ),
5811            "expected probe_now to settle sync, got {:?}",
5812            entity.sync.settled()
5813        );
5814    }
5815
5816    /// The same guard as the `sync` one above, for `base`: `probe_now` must settle it
5817    /// too, not only the dispatch loop `refresh` drives.
5818    #[test]
5819    fn probe_now_settles_the_base_cell_as_well_as_the_branch_it_depends_on() {
5820        let dir = tempfile::tempdir().expect("temp dir");
5821        let root = root_of(&dir);
5822        let repo = root.join("repo");
5823        init_repo_with_a_commit(&repo);
5824
5825        let core = Core::start_discovered(spec(vec![root]));
5826        let key = core.snapshot().entities[0].key.clone();
5827
5828        let entity = core.probe_now(&key);
5829
5830        assert!(
5831            matches!(entity.base.settled(), Some(Settled::NotApplicable)),
5832            "expected probe_now to settle base Not applicable for a Repo with no remote, \
5833             got {:?}",
5834            entity.base.settled()
5835        );
5836    }
5837
5838    /// The end-to-end wiring `probe_now`'s own guard above cannot prove: a real
5839    /// `refresh` dispatch, through `CheapProbeOutcomes`, must land a genuine
5840    /// computed `base` count on the table, not just a Not-applicable fallback.
5841    #[test]
5842    fn refresh_settles_a_real_base_count_against_the_resolved_default_branch() {
5843        let dir = tempfile::tempdir().expect("temp dir");
5844        let root = root_of(&dir);
5845        let repo = root.join("repo");
5846        init_repo_with_a_commit(&repo);
5847        git(
5848            &repo,
5849            &[
5850                "remote",
5851                "add",
5852                "origin",
5853                "https://example.invalid/repo.git",
5854            ],
5855        );
5856        let root_sha = head_sha(&repo);
5857        // The default branch (`origin/main`, resolved through rung 3's name list
5858        // since no `origin/HEAD` exists) moves one commit ahead of this Repo's own
5859        // checked-out branch, which never gets its own upstream configured, so
5860        // `sync` reads `-` while `base` still has a resolved default branch to
5861        // count behind.
5862        git(&repo, &["commit", "--allow-empty", "-m", "second"]);
5863        let tip_sha = head_sha(&repo);
5864        git(&repo, &["reset", "--hard", &root_sha]);
5865        git(&repo, &["update-ref", "refs/remotes/origin/main", &tip_sha]);
5866
5867        let core = Core::start_discovered(spec(vec![root]));
5868        let key = core.snapshot().entities[0].key.clone();
5869
5870        core.refresh(std::slice::from_ref(&key));
5871        let settled = core.settle();
5872
5873        assert!(
5874            matches!(
5875                settled.entities[0].base.settled(),
5876                Some(Settled::Known {
5877                    value: 1,
5878                    at: _,
5879                    stale: _
5880                })
5881            ),
5882            "expected a real refresh to settle base's live count against the resolved \
5883             default branch, got {:?}",
5884            settled.entities[0].base.settled()
5885        );
5886    }
5887
5888    /// The same guard as the `sync` one above, for `dirty`: it is the cell most recently
5889    /// added to this path, and dropping its settle here leaves every other test green.
5890    /// The repo carries one untracked file so a settled cell has to hold the counted
5891    /// value, not a zeroed placeholder that a default-constructed `DirtyCounts` would
5892    /// also satisfy.
5893    #[test]
5894    fn probe_now_settles_the_dirty_cell_with_the_counts_it_probed() {
5895        let dir = tempfile::tempdir().expect("temp dir");
5896        let root = root_of(&dir);
5897        let repo = root.join("repo");
5898        init_repo_with_a_commit(&repo);
5899        fs::write(repo.join("untracked.txt"), "x").expect("write untracked file");
5900
5901        let core = Core::start_discovered(spec(vec![root]));
5902        let key = core.snapshot().entities[0].key.clone();
5903
5904        let entity = core.probe_now(&key);
5905
5906        assert!(
5907            matches!(
5908                entity.dirty.settled(),
5909                Some(Settled::Known {
5910                    value: DirtyCounts {
5911                        modified: 0,
5912                        untracked: 1,
5913                        deleted: 0,
5914                    },
5915                    at: _,
5916                    stale: _
5917                })
5918            ),
5919            "expected probe_now to settle dirty with the one untracked path, got {:?}",
5920            entity.dirty.settled()
5921        );
5922    }
5923
5924    #[test]
5925    fn probe_now_updates_the_entity_synchronously_with_no_refresh_call() {
5926        let dir = tempfile::tempdir().expect("temp dir");
5927        let root = root_of(&dir);
5928        let repo = root.join("repo");
5929        init_repo_with_a_commit(&repo);
5930
5931        let core = Core::start_discovered(spec(vec![root]));
5932        let key = core.snapshot().entities[0].key.clone();
5933
5934        let entity = core.probe_now(&key);
5935
5936        assert!(matches!(
5937            entity.branch.settled(),
5938            Some(Settled::Known {
5939                value: Head::Branch { .. },
5940                at: _,
5941                stale: _
5942            })
5943        ));
5944    }
5945
5946    /// The one-function guarantee: whether an entity's name is set by discovery at
5947    /// `Core::start` or by `probe_now`'s fallback insert for a key the table did
5948    /// not already know, both routes must produce the same string for the same
5949    /// path, since a future state file keys the Selection by this name and a
5950    /// second formatting of it would silently break restoring by name.
5951    #[test]
5952    fn the_display_name_agrees_between_discovery_and_probe_nows_fallback_insert() {
5953        let dir = tempfile::tempdir().expect("temp dir");
5954        let root = root_of(&dir);
5955        let repo = root.join("named-repo");
5956        init_repo_with_a_commit(&repo);
5957
5958        let core = Core::start_discovered(spec(vec![root]));
5959        let discovered = core.snapshot().entities[0].clone();
5960        assert_eq!(&*discovered.name, "named-repo");
5961
5962        core.dismiss(&discovered.key);
5963        assert!(core.snapshot().entities.is_empty());
5964
5965        let reinserted = core.probe_now(&discovered.key);
5966
5967        assert_eq!(
5968            reinserted.name, discovered.name,
5969            "the name discovery assigned and the name probe_now's fallback insert \
5970             assigns for the same path must be byte-identical"
5971        );
5972    }
5973
5974    #[test]
5975    fn dismiss_removes_the_entity_from_the_snapshot() {
5976        let dir = tempfile::tempdir().expect("temp dir");
5977        let root = root_of(&dir);
5978        let repo = root.join("repo");
5979        init_repo_with_a_commit(&repo);
5980
5981        let core = Core::start_discovered(spec(vec![root]));
5982        let key = core.snapshot().entities[0].key.clone();
5983
5984        core.dismiss(&key);
5985
5986        assert!(core.snapshot().entities.is_empty());
5987    }
5988
5989    /// Foundation for every criterion below: one entity's own steps run in order and a
5990    /// failure marks every later step `NotRun` rather than silently skipping it or
5991    /// running it anyway, exactly the closed set of four outcomes
5992    /// [config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md)'s
5993    /// "Actions" and `docs/spec/actions.md`'s "Step outcomes" both fix.
5994    ///
5995    /// The third step would succeed if it ran (`true` always exits zero), so its being
5996    /// stopped is what this test observes, not an accident of a step that would have
5997    /// failed anyway. It also writes a marker file rather than only exiting zero: a
5998    /// receipt correctly labelled `NotRun` is not, by itself, proof the step never ran
5999    /// (an implementation could execute a step and then paper over its result), so the
6000    /// missing file is evidence the receipt cannot fake.
6001    #[test]
6002    fn an_entitys_steps_run_in_order_and_a_failure_marks_every_later_step_not_run() {
6003        let dir = tempfile::tempdir().expect("temp dir");
6004        let root = root_of(&dir);
6005        let repo = root.join("repo");
6006        init_repo_with_a_commit(&repo);
6007        let marker = repo.join("step-three-ran");
6008
6009        let core = Core::start_discovered(spec(vec![root]));
6010        let key = core.snapshot().entities[0].key.clone();
6011        let steps = vec![
6012            step(&["true"]),
6013            step(&["sh", "-c", "exit 7"]),
6014            step(&["touch", "step-three-ran"]),
6015        ];
6016
6017        let started = core.run_action(action("reinstall", steps), std::slice::from_ref(&key));
6018
6019        assert!(started);
6020        wait_for("the fan-out to finish and write a receipt", || {
6021            !core.action_running()
6022        });
6023        let receipt = core.snapshot().entities[0]
6024            .last_action
6025            .clone()
6026            .expect("receipt written");
6027        assert_eq!(receipt.steps.len(), 3);
6028        assert_eq!(receipt.steps[0].outcome, StepOutcome::Ok);
6029        assert_eq!(receipt.steps[1].outcome, StepOutcome::Failed(7));
6030        assert_eq!(
6031            receipt.steps[2].outcome,
6032            StepOutcome::NotRun,
6033            "a step after a failure must be recorded NotRun, not silently dropped or run anyway"
6034        );
6035        assert!(
6036            !marker.exists(),
6037            "the third step's own `touch` must never have run: its marker file exists, so \
6038             the step ran despite being recorded NotRun"
6039        );
6040    }
6041
6042    /// Independent of stopping at a failure: three always-succeeding steps each append
6043    /// their own digit to the same file, so the file's final content pins the actual
6044    /// execution order rather than trusting that a linear scan of `action.steps` runs
6045    /// them in the sequence they were declared in.
6046    #[test]
6047    fn steps_run_in_the_order_theyre_declared_not_some_other_order() {
6048        let dir = tempfile::tempdir().expect("temp dir");
6049        let root = root_of(&dir);
6050        let repo = root.join("repo");
6051        init_repo_with_a_commit(&repo);
6052        let order_log = repo.join("order.log");
6053
6054        let core = Core::start_discovered(spec(vec![root]));
6055        let key = core.snapshot().entities[0].key.clone();
6056        let steps = vec![
6057            step(&["sh", "-c", "printf 1 >> order.log"]),
6058            step(&["sh", "-c", "printf 2 >> order.log"]),
6059            step(&["sh", "-c", "printf 3 >> order.log"]),
6060        ];
6061
6062        let started = core.run_action(action("ordering", steps), std::slice::from_ref(&key));
6063
6064        assert!(started);
6065        wait_for("the fan-out to finish and write a receipt", || {
6066            !core.action_running()
6067        });
6068        let receipt = core.snapshot().entities[0]
6069            .last_action
6070            .clone()
6071            .expect("receipt written");
6072        assert_eq!(receipt.steps.len(), 3);
6073        assert!(
6074            receipt
6075                .steps
6076                .iter()
6077                .all(|result| result.outcome == StepOutcome::Ok),
6078            "every step here always exits zero; this test isolates ordering from gating"
6079        );
6080        let content = fs::read_to_string(&order_log).expect("order.log written by the steps");
6081        assert_eq!(
6082            content, "123",
6083            "the file's content pins actual execution order; running the steps out of \
6084             declaration order would produce a different digit sequence here even though \
6085             every step still succeeds"
6086        );
6087    }
6088
6089    /// `docs/spec/actions.md`'s "The run on screen": a reader must see a step's own
6090    /// finished output "as it arrives", not only once the whole entity's run has ended.
6091    /// The second step sleeps long enough to give a poll a real window to observe the
6092    /// receipt mid-run; a version of `run_action_for_entity` that only wrote once, at the
6093    /// end, would never let this test observe `running: Some(_)` at all; it would either
6094    /// see no receipt (before) or the whole finished one (after), never the state in
6095    /// between where the first step is done and the second is still going.
6096    #[test]
6097    fn a_still_running_actions_finished_step_and_its_currently_executing_one_are_both_visible_before_the_whole_run_ends()
6098     {
6099        let dir = tempfile::tempdir().expect("temp dir");
6100        let root = root_of(&dir);
6101        let repo = root.join("repo");
6102        init_repo_with_a_commit(&repo);
6103
6104        let core = Core::start_discovered(spec(vec![root]));
6105        let key = core.snapshot().entities[0].key.clone();
6106        let steps = vec![step(&["true"]), step(&["sh", "-c", "sleep 0.5"])];
6107
6108        let started = core.run_action(action("reinstall", steps), std::slice::from_ref(&key));
6109        assert!(started);
6110
6111        // Waits specifically for the *second* step's own running receipt, not merely any
6112        // one: under a slow or busy machine the first step (`true`) can still be the one
6113        // reported running the first time this poll checks, which would assert the wrong
6114        // step's own shape below rather than a flaky pass.
6115        wait_for(
6116            "a receipt naming the second step running before the run finished",
6117            || {
6118                core.snapshot().entities[0]
6119                    .last_action
6120                    .as_ref()
6121                    .and_then(|receipt| receipt.running.as_ref())
6122                    .is_some_and(|running| running.label.contains("sleep"))
6123            },
6124        );
6125        let mid_run = core.snapshot().entities[0]
6126            .last_action
6127            .clone()
6128            .expect("receipt written");
6129        assert_eq!(
6130            mid_run.steps.len(),
6131            1,
6132            "the first, already-finished step must already be in `steps`"
6133        );
6134        assert_eq!(mid_run.steps[0].outcome, StepOutcome::Ok);
6135        let running = mid_run.running.expect("a step must be recorded running");
6136        assert!(
6137            running.label.contains("sleep"),
6138            "expected the running step's own label, got {:?}",
6139            running.label
6140        );
6141
6142        wait_for("the fan-out to finish", || !core.action_running());
6143        let finished = core.snapshot().entities[0]
6144            .last_action
6145            .clone()
6146            .expect("receipt written");
6147        assert!(
6148            finished.running.is_none(),
6149            "a finished receipt must carry no running step"
6150        );
6151        assert_eq!(finished.steps.len(), 2);
6152    }
6153
6154    /// `Step::shell` must actually reach the child, end to end through `run_action`,
6155    /// not merely be a field that parses. Prints `$0` inside the step's own
6156    /// command string: `sh -c <string>` with no third argument would leave `$0` reading
6157    /// whatever the shell defaults it to, never the literal `repon`
6158    /// [config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md)'s
6159    /// `shell = true` sentence requires. `executor.rs`'s own unit tests cover `run_step`
6160    /// directly; this proves `core.rs` actually sets `shell` on the `Step` it builds and
6161    /// passes it through.
6162    #[test]
6163    fn a_shell_true_step_runs_through_shell_c_with_repon_as_its_own_dollar_zero() {
6164        let dir = tempfile::tempdir().expect("temp dir");
6165        let root = root_of(&dir);
6166        let repo = root.join("repo");
6167        init_repo_with_a_commit(&repo);
6168
6169        let core = Core::start_discovered(spec(vec![root]));
6170        let key = core.snapshot().entities[0].key.clone();
6171        let steps = vec![shell_step("echo \"[$0]\"")];
6172
6173        let started = core.run_action(action("shell-step", steps), std::slice::from_ref(&key));
6174
6175        assert!(started);
6176        wait_for("the fan-out to finish and write a receipt", || {
6177            !core.action_running()
6178        });
6179        let receipt = core.snapshot().entities[0]
6180            .last_action
6181            .clone()
6182            .expect("receipt written");
6183        assert_eq!(receipt.steps.len(), 1);
6184        assert_eq!(receipt.steps[0].outcome, StepOutcome::Ok);
6185        assert_eq!(&*receipt.steps[0].output, b"[repon]\n");
6186        assert!(
6187            receipt.steps[0].shell,
6188            "the receipt's own StepResult::shell must carry the mode the step ran under"
6189        );
6190    }
6191
6192    /// `Step::interactive` must actually reach `run_step` end to end through `run_action`,
6193    /// the same proof `a_shell_true_step_runs_through_shell_c_with_repon_as_its_own_dollar_zero`
6194    /// already gives `shell`: this asserts `core.rs` sets `interactive` on the `Step` it
6195    /// builds and that the receipt carries it back, not the shell's own rc-sourcing
6196    /// behaviour, which `executor.rs`'s own `shell_argv` unit test already covers on the
6197    /// constructed argv.
6198    #[test]
6199    fn an_interactive_shell_true_step_runs_through_run_action_with_interactive_on_its_receipt() {
6200        let dir = tempfile::tempdir().expect("temp dir");
6201        let root = root_of(&dir);
6202        let repo = root.join("repo");
6203        init_repo_with_a_commit(&repo);
6204
6205        let core = Core::start_discovered(spec(vec![root]));
6206        let key = core.snapshot().entities[0].key.clone();
6207        let steps = vec![interactive_shell_step("true")];
6208
6209        let started = core.run_action(
6210            action("interactive-step", steps),
6211            std::slice::from_ref(&key),
6212        );
6213
6214        assert!(started);
6215        wait_for("the fan-out to finish and write a receipt", || {
6216            !core.action_running()
6217        });
6218        let receipt = core.snapshot().entities[0]
6219            .last_action
6220            .clone()
6221            .expect("receipt written");
6222        assert_eq!(receipt.steps.len(), 1);
6223        assert_eq!(receipt.steps[0].outcome, StepOutcome::Ok);
6224        assert!(
6225            receipt.steps[0].shell,
6226            "an interactive step is still a shell step"
6227        );
6228        assert!(
6229            receipt.steps[0].interactive,
6230            "the receipt's own StepResult::interactive must carry the mode the step ran under"
6231        );
6232    }
6233
6234    /// [`StepResult::shell`]'s own claim on the plain argv side, so the two modes are
6235    /// proven end to end through `run_action` rather than only `shell = true`: an ordinary
6236    /// step's receipt must read `false`, not merely default to it by construction.
6237    #[test]
6238    fn an_argv_step_runs_through_run_action_with_shell_false_on_its_receipt() {
6239        let dir = tempfile::tempdir().expect("temp dir");
6240        let root = root_of(&dir);
6241        let repo = root.join("repo");
6242        init_repo_with_a_commit(&repo);
6243
6244        let core = Core::start_discovered(spec(vec![root]));
6245        let key = core.snapshot().entities[0].key.clone();
6246        let steps = vec![Step {
6247            argv: vec!["true".to_string()],
6248            shell: false,
6249            interactive: false,
6250            env: Vec::new(),
6251        }];
6252
6253        let started = core.run_action(action("argv-step", steps), std::slice::from_ref(&key));
6254
6255        assert!(started);
6256        wait_for("the fan-out to finish and write a receipt", || {
6257            !core.action_running()
6258        });
6259        let receipt = core.snapshot().entities[0]
6260            .last_action
6261            .clone()
6262            .expect("receipt written");
6263        assert!(!receipt.steps[0].shell);
6264    }
6265
6266    /// Criterion 3's first half. `begin_shared_generation_for_test` puts the entity
6267    /// in flight against a Generation of its own, exactly as a real `refresh` would;
6268    /// this proves `run_action` cancels that Generation's own flag rather than merely
6269    /// starting alongside it, which is the difference between the 0.85s and 3.14s
6270    /// measurements `docs/spec/actions.md`'s "Refreshing around a run" reports.
6271    #[test]
6272    fn starting_an_action_cancels_any_generation_already_in_flight() {
6273        let dir = tempfile::tempdir().expect("temp dir");
6274        let root = root_of(&dir);
6275        let repo = root.join("repo");
6276        init_repo_with_a_commit(&repo);
6277
6278        let core = Core::start_discovered(spec(vec![root]));
6279        let key = core.snapshot().entities[0].key.clone();
6280        let in_flight = core.begin_shared_generation_for_test(std::slice::from_ref(&key));
6281        let cancel = in_flight
6282            .cancels
6283            .get(&key)
6284            .expect("the in-flight entity has a cancel flag")
6285            .clone();
6286        assert!(!cancel.load(Ordering::Acquire));
6287
6288        let started = core.run_action(
6289            action("reinstall", vec![step(&["true"])]),
6290            std::slice::from_ref(&key),
6291        );
6292
6293        assert!(started);
6294        assert!(
6295            cancel.load(Ordering::Acquire),
6296            "starting an Action must cancel a Generation already in flight, not share \
6297             execution with it"
6298        );
6299        // Drain the fan-out and its completion refresh so this test's background
6300        // thread does not outlive it.
6301        wait_for("the fan-out and its completion refresh to drain", || {
6302            !core.action_running()
6303        });
6304    }
6305
6306    /// Criterion 3's second half, and the double-refresh mutation this test is written
6307    /// to catch: a completed Action starting its own Generation *and* a second one
6308    /// left over from a naive implementation that also called `refresh` directly would
6309    /// both leave every entity settled, so counting settled entities alone cannot tell
6310    /// zero, one and two apart. Reading the table's own `generation` number after
6311    /// completion can: it must be the Generation immediately after the settled table
6312    /// this Action ran against, covering both entities although the Action only ever
6313    /// named one of them. Named by its order rather than by a number, so what launch
6314    /// itself mints cannot renumber the claim.
6315    #[test]
6316    fn a_finished_action_starts_exactly_one_generation_over_every_known_entity() {
6317        let dir = tempfile::tempdir().expect("temp dir");
6318        let root = root_of(&dir);
6319        let acted_on = root.join("acted-on");
6320        let untouched = root.join("untouched");
6321        init_repo_with_a_commit(&acted_on);
6322        init_repo_with_a_commit(&untouched);
6323
6324        let (core, before) = started_and_settled(spec(vec![root]));
6325        let acted_key = before
6326            .entities
6327            .iter()
6328            .find(|entity| entity.key.path() == acted_on)
6329            .expect("the acted-on entity is discovered")
6330            .key
6331            .clone();
6332
6333        let started = core.run_action(
6334            action("reinstall", vec![step(&["true"])]),
6335            std::slice::from_ref(&acted_key),
6336        );
6337
6338        assert!(started);
6339        wait_for(
6340            "the completion Generation to probe every known entity, including the one the \
6341             Action never touched",
6342            || {
6343                let snapshot = core.snapshot();
6344                snapshot.generation != before.generation
6345                    && snapshot.entities.iter().all(|entity| {
6346                        matches!(
6347                            entity.branch.settled(),
6348                            Some(Settled::Known {
6349                                value: _,
6350                                at: _,
6351                                stale: _
6352                            })
6353                        )
6354                    })
6355            },
6356        );
6357        assert_eq!(
6358            core.settle().generation,
6359            before.generation.successor(),
6360            "completion must start exactly one Generation: not zero (no refresh at all) and \
6361             not two (a double refresh)"
6362        );
6363    }
6364
6365    /// A completion dispatches its Generation while its own run is still admitted, and a
6366    /// submission arriving before that release is refused. Together those are what keeps a
6367    /// completion from dispatching over a run that replaced it: the next run's admission,
6368    /// and the cancellation it performs on the way in, can only ever follow a Generation
6369    /// this one has already started.
6370    ///
6371    /// [`Core::action_completion_boundary`] holds the completion between the two, the one
6372    /// place either half is observable: they are adjacent statements, so a test racing them
6373    /// reads whichever it happened to catch.
6374    #[test]
6375    fn a_completion_dispatches_its_generation_before_releasing_its_run() {
6376        let dir = tempfile::tempdir().expect("temp dir");
6377        let root = root_of(&dir);
6378        let repo = root.join("repo");
6379        init_repo_with_a_commit(&repo);
6380
6381        let (core, before) = started_and_settled(spec(vec![root]));
6382        let key = before.entities[0].key.clone();
6383        let armed = core.action_completion_boundary().arm();
6384
6385        assert!(core.run_action(
6386            action("finishing", vec![step(&["true"])]),
6387            std::slice::from_ref(&key)
6388        ));
6389        armed.wait_until_reached();
6390
6391        assert_eq!(
6392            core.snapshot().generation,
6393            before.generation.successor(),
6394            "the completion Generation must be dispatched before the run releases its \
6395             admission"
6396        );
6397        assert!(
6398            !core.run_action(
6399                action("racing", vec![step(&["true"])]),
6400                std::slice::from_ref(&key)
6401            ),
6402            "a submission before that release must be refused, so what a run cancels on the \
6403             way in is never a Generation the run it replaced has yet to dispatch"
6404        );
6405
6406        drop(armed);
6407        wait_for("the finished run to release its admission", || {
6408            !core.action_running()
6409        });
6410    }
6411
6412    /// Criterion 5. The excluded row gets the one legitimate `not_applicable` receipt
6413    /// with no steps; the acted-on row's own step is made to fail, which is the strong
6414    /// half of the claim: a receipt with steps that failed is still not the
6415    /// `not_applicable` shape, so nothing but an excluded row can ever produce it.
6416    #[test]
6417    fn an_excluded_row_swept_into_an_action_gets_a_not_applicable_receipt_and_no_other_path_does() {
6418        let dir = tempfile::tempdir().expect("temp dir");
6419        let root = root_of(&dir);
6420        let excluded_repo = root.join("excluded");
6421        let normal_repo = root.join("normal");
6422        init_repo_with_a_commit(&excluded_repo);
6423        init_repo_with_a_commit(&normal_repo);
6424
6425        let core = Core::start_discovered(spec_with_overrides(
6426            vec![root],
6427            vec![RepoOverride {
6428                path: excluded_repo.clone(),
6429                default_branch: None,
6430                excluded: true,
6431            }],
6432        ));
6433        let snapshot = core.snapshot();
6434        let find = |path: &Path| {
6435            snapshot
6436                .entities
6437                .iter()
6438                .find(|entity| entity.key.path() == path)
6439                .unwrap_or_else(|| panic!("entity at {path:?} present"))
6440                .key
6441                .clone()
6442        };
6443        let excluded_key = find(&excluded_repo);
6444        let normal_key = find(&normal_repo);
6445        assert!(
6446            snapshot
6447                .entities
6448                .iter()
6449                .find(|entity| entity.key == excluded_key)
6450                .unwrap()
6451                .excluded
6452        );
6453
6454        let started = core.run_action(
6455            action("reinstall", vec![step(&["sh", "-c", "exit 3"])]),
6456            &[excluded_key.clone(), normal_key.clone()],
6457        );
6458
6459        assert!(started);
6460        // `!core.action_running()`, not merely "both entities have some receipt": a
6461        // still-running entity now writes an intermediate receipt naming its currently
6462        // executing step before it finishes (`docs/spec/actions.md`'s "The run on screen"),
6463        // so `last_action.is_some()` alone can be true well before `normal_key`'s own step
6464        // has actually run.
6465        wait_for("the fan-out to finish", || !core.action_running());
6466
6467        let after = core.snapshot();
6468        let receipt_of = |key: &EntityKey| {
6469            after
6470                .entities
6471                .iter()
6472                .find(|entity| entity.key == *key)
6473                .unwrap()
6474                .last_action
6475                .clone()
6476                .unwrap()
6477        };
6478        let excluded_receipt = receipt_of(&excluded_key);
6479        assert!(excluded_receipt.not_applicable());
6480        assert!(excluded_receipt.steps.is_empty());
6481
6482        let normal_receipt = receipt_of(&normal_key);
6483        assert!(
6484            !normal_receipt.not_applicable(),
6485            "a row that actually ran a step, even a failing one, must never read as \
6486             not_applicable: an excluded row is the one legitimate producer of that outcome"
6487        );
6488        assert!(!normal_receipt.steps.is_empty());
6489        assert!(normal_receipt.failed());
6490    }
6491
6492    /// Criterion 4: `operable_count` and `run_action`'s own partition must be one
6493    /// computation, not two that happen to agree today. Proven against independent
6494    /// evidence, the same way the test above does: run an Action over one excluded and
6495    /// one normal entity, then check `operable_count`'s answer against how many of the
6496    /// two actually got a real (not `not_applicable`) receipt, rather than against a
6497    /// second hand-written copy of the exclusion rule.
6498    #[test]
6499    fn operable_count_matches_how_many_entities_run_action_actually_runs_a_step_against() {
6500        let dir = tempfile::tempdir().expect("temp dir");
6501        let root = root_of(&dir);
6502        let excluded_repo = root.join("excluded");
6503        let normal_repo = root.join("normal");
6504        init_repo_with_a_commit(&excluded_repo);
6505        init_repo_with_a_commit(&normal_repo);
6506
6507        let core = Core::start_discovered(spec_with_overrides(
6508            vec![root],
6509            vec![RepoOverride {
6510                path: excluded_repo.clone(),
6511                default_branch: None,
6512                excluded: true,
6513            }],
6514        ));
6515        let snapshot = core.snapshot();
6516        let find = |path: &Path| {
6517            snapshot
6518                .entities
6519                .iter()
6520                .find(|entity| entity.key.path() == path)
6521                .unwrap_or_else(|| panic!("entity at {path:?} present"))
6522                .key
6523                .clone()
6524        };
6525        let order = [find(&excluded_repo), find(&normal_repo)];
6526
6527        assert_eq!(
6528            core.operable_count(&order),
6529            1,
6530            "one of the two rows is excluded, so exactly one is operable"
6531        );
6532
6533        let started = core.run_action(action("reinstall", vec![step(&["true"])]), &order);
6534        assert!(started);
6535
6536        wait_for("every entity in the order to carry a receipt", || {
6537            let snapshot = core.snapshot();
6538            order.iter().all(|key| {
6539                snapshot
6540                    .entities
6541                    .iter()
6542                    .find(|entity| entity.key == *key)
6543                    .and_then(|entity| entity.last_action.as_ref())
6544                    .is_some()
6545            })
6546        });
6547
6548        let after = core.snapshot();
6549        let actually_ran = after
6550            .entities
6551            .iter()
6552            .filter(|entity| order.contains(&entity.key))
6553            .filter(|entity| {
6554                entity
6555                    .last_action
6556                    .as_ref()
6557                    .is_some_and(|receipt| !receipt.not_applicable())
6558            })
6559            .count();
6560
6561        assert_eq!(
6562            core.operable_count(&order),
6563            actually_ran,
6564            "operable_count must report exactly how many rows run_action actually ran a \
6565             step against, not merely how many keys resolved"
6566        );
6567    }
6568
6569    /// [`Core::run_action_for_entity_blocking`]'s own reason to exist: it returns the
6570    /// finished receipt on the calling thread rather than handing the run off, so a caller
6571    /// needs no `wait_for` at all to see the step's own effect, unlike every `run_action`
6572    /// test above.
6573    #[test]
6574    fn run_action_for_entity_blocking_returns_the_finished_receipt_on_the_calling_thread() {
6575        let dir = tempfile::tempdir().expect("temp dir");
6576        let root = root_of(&dir);
6577        let repo = root.join("repo");
6578        init_repo_with_a_commit(&repo);
6579        let marker = repo.join("hook-ran");
6580
6581        let core = Core::start_discovered(spec_with_overrides(vec![root], Vec::new()));
6582        let key = core
6583            .snapshot()
6584            .entities
6585            .iter()
6586            .find(|entity| entity.key.path() == repo)
6587            .expect("the repo is discovered")
6588            .key
6589            .clone();
6590
6591        let receipt = core
6592            .run_action_for_entity_blocking(
6593                &action("hook", vec![step(&["touch", "hook-ran"])]),
6594                &key,
6595            )
6596            .expect("the entity is known");
6597
6598        assert!(
6599            marker.exists(),
6600            "the step must have already run by the time this call returns"
6601        );
6602        assert_eq!(receipt.steps.len(), 1);
6603        assert_eq!(receipt.steps[0].outcome, StepOutcome::Ok);
6604    }
6605
6606    /// [`step`], plus the one environment entry a `test-util` build reads an injected PTY
6607    /// setup failure from: the step reports that resource's own failure instead of ever
6608    /// spawning a child.
6609    fn step_that_cannot_prepare(argv: &[&str], resource: &str) -> Step {
6610        Step {
6611            env: vec![(
6612                executor::SETUP_FAILURE_VARIABLE.to_string(),
6613                resource.to_string(),
6614            )],
6615            ..step(argv)
6616        }
6617    }
6618
6619    /// A step whose own PTY setup fails is a failed receipt the run hands back, not a step
6620    /// that never returns: the failure names the resource, the rest of the run reports
6621    /// `NotRun`, and a later Action against the same row still succeeds. Run off this
6622    /// thread and collected through the liveness backstop, since the claim under test is
6623    /// that these calls return at all.
6624    #[test]
6625    fn a_step_whose_pty_setup_fails_finishes_the_run_and_leaves_a_later_action_working() {
6626        let dir = tempfile::tempdir().expect("temp dir");
6627        let root = root_of(&dir);
6628        let repo = root.join("repo");
6629        init_repo_with_a_commit(&repo);
6630
6631        let core = Core::start_discovered(spec_with_overrides(vec![root], Vec::new()));
6632        let key = core
6633            .snapshot()
6634            .entities
6635            .iter()
6636            .find(|entity| entity.key.path() == repo)
6637            .expect("the repo is discovered")
6638            .key
6639            .clone();
6640
6641        let (tx, rx) = mpsc::channel();
6642        thread::spawn(move || {
6643            let faulted = core.run_action_for_entity_blocking(
6644                &action(
6645                    "hook",
6646                    vec![
6647                        step_that_cannot_prepare(&["touch", "first-ran"], "notify-pipe"),
6648                        step(&["touch", "second-ran"]),
6649                    ],
6650                ),
6651                &key,
6652            );
6653            let later = core.run_action_for_entity_blocking(
6654                &action("hook", vec![step(&["touch", "later-ran"])]),
6655                &key,
6656            );
6657            let _ = tx.send((faulted, later));
6658        });
6659        let (faulted, later) = rx
6660            .recv_timeout(BACKSTOP)
6661            .expect("a run whose first step cannot prepare its pty must still hand back receipts");
6662
6663        let faulted = faulted.expect("the entity is known");
6664        assert!(
6665            matches!(faulted.steps[0].outcome, StepOutcome::Failed(code) if code != 0),
6666            "expected the first step to fail, got {:?}",
6667            faulted.steps[0].outcome
6668        );
6669        let detail = String::from_utf8_lossy(&faulted.steps[0].output).to_string();
6670        assert!(
6671            detail.contains("pipe that notices"),
6672            "expected the receipt to name the resource that failed, got {detail:?}"
6673        );
6674        assert_eq!(faulted.steps[1].outcome, StepOutcome::NotRun);
6675        assert!(
6676            !repo.join("first-ran").exists() && !repo.join("second-ran").exists(),
6677            "a step that never prepared its pty must never have run its command"
6678        );
6679
6680        let later = later.expect("the entity is known");
6681        assert_eq!(later.steps[0].outcome, StepOutcome::Ok);
6682        assert!(
6683            repo.join("later-ran").exists(),
6684            "a later Action must still run its own command"
6685        );
6686    }
6687
6688    /// `None` rather than a receipt for a key the table does not know: the same fallback
6689    /// every other key-addressed `Core` entry point gives one, and the caller's own signal
6690    /// for "no hook to consult" when a hook names a row `sync`'s own eligibility has already
6691    /// dropped.
6692    #[test]
6693    fn run_action_for_entity_blocking_answers_none_for_an_unknown_key() {
6694        let dir = tempfile::tempdir().expect("temp dir");
6695        let root = root_of(&dir);
6696        let core = Core::start_discovered(spec_with_overrides(vec![root.clone()], Vec::new()));
6697
6698        let unknown = EntityKey::new(Arc::from(root.join("never-discovered").as_path()));
6699
6700        assert!(
6701            core.run_action_for_entity_blocking(&action("hook", vec![step(&["true"])]), &unknown)
6702                .is_none()
6703        );
6704    }
6705
6706    /// The reversed decision itself: `when` now decides what runs, not only what a palette
6707    /// reports about it. A row the predicate proves runs a real step; a row it disproves
6708    /// gets a `Skip::Inapplicable` receipt with no steps and never spawns a child process at
6709    /// all, which the failing command below would have surfaced as a `Failed` step had it
6710    /// run (`docs/spec/actions.md`'s "The Selection and the gate", reversing what that
6711    /// section originally decided).
6712    #[test]
6713    fn run_action_skips_a_row_its_when_predicate_disproves_rather_than_running_it_anyway() {
6714        let dir = tempfile::tempdir().expect("temp dir");
6715        let root = root_of(&dir);
6716        let proved_repo = root.join("alpha");
6717        let disproved_repo = root.join("beta");
6718        init_repo_with_a_commit(&proved_repo);
6719        init_repo_with_a_commit(&disproved_repo);
6720
6721        let core = Core::start_discovered(spec(vec![root]));
6722        let snapshot = core.snapshot();
6723        let find = |path: &Path| {
6724            snapshot
6725                .entities
6726                .iter()
6727                .find(|entity| entity.key.path() == path)
6728                .unwrap_or_else(|| panic!("entity at {path:?} present"))
6729                .key
6730                .clone()
6731        };
6732        let proved_key = find(&proved_repo);
6733        let disproved_key = find(&disproved_repo);
6734        let order = [proved_key.clone(), disproved_key.clone()];
6735
6736        // A command that would mark a real run `Failed` if it ever ran, so a disproved row
6737        // that wrongly ran a step is caught by its own outcome rather than only by `skip`.
6738        let started = core.run_action(
6739            action_with_when(
6740                "reinstall",
6741                vec![step(&["sh", "-c", "exit 3"])],
6742                "name:alpha",
6743            ),
6744            &order,
6745        );
6746        assert!(started);
6747        wait_for("the fan-out to finish", || !core.action_running());
6748
6749        let after = core.snapshot();
6750        let receipt_of = |key: &EntityKey| {
6751            after
6752                .entities
6753                .iter()
6754                .find(|entity| entity.key == *key)
6755                .unwrap()
6756                .last_action
6757                .clone()
6758                .unwrap()
6759        };
6760
6761        let proved_receipt = receipt_of(&proved_key);
6762        assert_eq!(
6763            proved_receipt.skip, None,
6764            "the row the predicate proved must actually run"
6765        );
6766        assert!(proved_receipt.failed(), "its own step still ran and failed");
6767
6768        let disproved_receipt = receipt_of(&disproved_key);
6769        assert!(
6770            disproved_receipt.inapplicable(),
6771            "the row the predicate disproved must be skipped rather than run"
6772        );
6773        assert!(disproved_receipt.steps.is_empty());
6774        assert!(
6775            !disproved_receipt.failed(),
6776            "a skipped row never ran a step, so it cannot have failed one"
6777        );
6778    }
6779
6780    /// An excluded row is subtracted before an Action's `when` ever sees it, so the
6781    /// predicate narrows what is left rather than replacing that subtraction
6782    /// (`docs/spec/actions.md`'s "The Selection and the gate").
6783    ///
6784    /// Proven against `operable_count` itself rather than against a hand-written expectation:
6785    /// a predicate every remaining row satisfies must leave a total identical to that count,
6786    /// which it cannot do if the excluded row reached the tally under any of the three
6787    /// headings.
6788    #[test]
6789    fn applicability_subtracts_an_excluded_row_before_the_predicate_reads_it() {
6790        let dir = tempfile::tempdir().expect("temp dir");
6791        let root = root_of(&dir);
6792        let excluded_repo = root.join("excluded");
6793        let normal_repo = root.join("normal");
6794        init_repo_with_a_commit(&excluded_repo);
6795        init_repo_with_a_commit(&normal_repo);
6796
6797        let core = Core::start_discovered(spec_with_overrides(
6798            vec![root],
6799            vec![RepoOverride {
6800                path: excluded_repo.clone(),
6801                default_branch: None,
6802                excluded: true,
6803            }],
6804        ));
6805        let order: Vec<EntityKey> = core
6806            .snapshot()
6807            .entities
6808            .iter()
6809            .map(|entity| entity.key.clone())
6810            .collect();
6811        assert_eq!(order.len(), 2, "the fixture must discover both repos");
6812
6813        let counts = core.applicability(&order, &Filter::parse("kind:repo"));
6814
6815        assert_eq!(
6816            counts.total(),
6817            core.operable_count(&order),
6818            "the predicate must be counted over exactly the rows `operable_count` keeps"
6819        );
6820        assert_eq!(
6821            counts,
6822            Applicability {
6823                applicable: 1,
6824                inapplicable: 0,
6825                unresolved: 0,
6826            }
6827        );
6828    }
6829
6830    /// An unknown key (already dismissed, or never discovered) is silently dropped from
6831    /// the count, the same fallback `run_action` gives one: this is the half of
6832    /// `partition_operable` no fixture above exercises, since every key there resolves.
6833    #[test]
6834    fn operable_count_silently_drops_a_key_that_no_longer_resolves() {
6835        let dir = tempfile::tempdir().expect("temp dir");
6836        let root = root_of(&dir);
6837        let repo = root.join("repo");
6838        init_repo_with_a_commit(&repo);
6839
6840        let core = Core::start_discovered(spec(vec![root]));
6841        let real_key = core.snapshot().entities[0].key.clone();
6842        let unknown_key = EntityKey::new(Arc::from(dir.path().join("never-discovered")));
6843
6844        assert_eq!(core.operable_count(&[real_key, unknown_key]), 1);
6845    }
6846
6847    /// Criterion 6. The second call is rejected synchronously (admission refuses it before
6848    /// anything else runs), so this needs no waiting to observe; only the cleanup wait at
6849    /// the end needs [`wait_for`].
6850    #[test]
6851    fn only_one_action_fan_out_runs_at_a_time_a_second_call_is_rejected_while_one_is_live() {
6852        let dir = tempfile::tempdir().expect("temp dir");
6853        let root = root_of(&dir);
6854        let repo = root.join("repo");
6855        init_repo_with_a_commit(&repo);
6856
6857        let core = Core::start_discovered(spec(vec![root]));
6858        let key = core.snapshot().entities[0].key.clone();
6859        let slow = action("first", vec![step(&["sh", "-c", "sleep 0.3"])]);
6860        let fast = action("second", vec![step(&["true"])]);
6861
6862        let first_started = core.run_action(slow, std::slice::from_ref(&key));
6863        let second_started = core.run_action(fast, std::slice::from_ref(&key));
6864
6865        assert!(first_started);
6866        assert!(
6867            !second_started,
6868            "a second run_action call must be rejected while the first is still in flight"
6869        );
6870        wait_for("the accepted first fan-out to finish", || {
6871            !core.action_running()
6872        });
6873        let receipt = core.snapshot().entities[0].last_action.clone().unwrap();
6874        assert_eq!(
6875            &*receipt.label, "first",
6876            "the surviving receipt must be the accepted first run's, never the rejected second"
6877        );
6878    }
6879
6880    /// Refusing a submission must leave the live run exactly as it was: the refused call
6881    /// registers no control of its own, so the run already in flight is still the one
6882    /// `stop_action` reaches.
6883    ///
6884    /// A guard on the refusal path rather than a reproduction of anything: refusing has
6885    /// always returned before touching a control, and this pins that it still does. Both
6886    /// steps sleep [`FIXTURE_LIFETIME`], since the outcomes below cannot tell a cancelled
6887    /// step from one that reached its own end inside the wait watching it.
6888    #[test]
6889    fn a_refused_second_submission_leaves_the_first_action_still_stoppable() {
6890        let dir = tempfile::tempdir().expect("temp dir");
6891        let root = root_of(&dir);
6892        let repo = root.join("repo");
6893        init_repo_with_a_commit(&repo);
6894
6895        let core = Core::start_discovered(spec(vec![root]));
6896        let key = core.snapshot().entities[0].key.clone();
6897        let sleep_past_the_backstop = format!("sleep {}", FIXTURE_LIFETIME.as_secs());
6898        let live = action(
6899            "live",
6900            vec![
6901                step(&["sh", "-c", &sleep_past_the_backstop]),
6902                step(&["sh", "-c", &sleep_past_the_backstop]),
6903            ],
6904        );
6905
6906        assert!(core.run_action(live, std::slice::from_ref(&key)));
6907        wait_for("the live run's own first step to start", || {
6908            core.snapshot().entities[0]
6909                .last_action
6910                .as_ref()
6911                .is_some_and(|receipt| receipt.running.is_some())
6912        });
6913
6914        assert!(
6915            !core.run_action(
6916                action("refused", vec![step(&["true"])]),
6917                std::slice::from_ref(&key)
6918            ),
6919            "a second submission must be refused while one run is still live"
6920        );
6921
6922        core.stop_action();
6923
6924        wait_for("the still-controllable run to come down", || {
6925            !core.action_running()
6926        });
6927        let receipt = core.snapshot().entities[0].last_action.clone().unwrap();
6928        assert_eq!(&*receipt.label, "live");
6929        assert_eq!(
6930            receipt.steps[0].outcome,
6931            StepOutcome::Cancelled,
6932            "the refused submission must leave the live run's own control in place, so \
6933             stop_action still reaches the step it was running"
6934        );
6935        assert_eq!(
6936            receipt.steps[1].outcome,
6937            StepOutcome::Cancelled,
6938            "a step that had not started when the run was cancelled must read Cancelled too"
6939        );
6940    }
6941
6942    /// A run accepted the moment a completion releases its admission owns the controls for
6943    /// the rest of its life: that completion has nothing left to register by then, so
6944    /// `stop_action` still reaches this run's own steps.
6945    ///
6946    /// [`Core::action_completion_boundary`] pins "the moment" rather than approximating it:
6947    /// the submission made while the completion is parked must be refused, and the one made
6948    /// once it is released must be accepted, so what is stopped below is a run accepted at
6949    /// the earliest point one can be. Both of its steps sleep [`FIXTURE_LIFETIME`], since
6950    /// the outcomes asserted cannot tell a cancelled step from one that reached its own end
6951    /// inside the wait watching it.
6952    #[test]
6953    fn a_run_accepted_once_a_completion_releases_its_admission_is_still_stoppable() {
6954        let dir = tempfile::tempdir().expect("temp dir");
6955        let root = root_of(&dir);
6956        let repo = root.join("repo");
6957        init_repo_with_a_commit(&repo);
6958
6959        let core = Core::start_discovered(spec(vec![root]));
6960        let key = core.snapshot().entities[0].key.clone();
6961        let armed = core.action_completion_boundary().arm();
6962
6963        assert!(core.run_action(
6964            action("finishing", vec![step(&["true"])]),
6965            std::slice::from_ref(&key)
6966        ));
6967        armed.wait_until_reached();
6968        assert!(
6969            !core.run_action(
6970                action("early", vec![step(&["true"])]),
6971                std::slice::from_ref(&key)
6972            ),
6973            "a submission made before the completion releases its admission must be refused"
6974        );
6975        drop(armed);
6976        wait_for("the finished run to release its admission", || {
6977            !core.action_running()
6978        });
6979
6980        let sleep_past_the_backstop = format!("sleep {}", FIXTURE_LIFETIME.as_secs());
6981        let following = action(
6982            "following",
6983            vec![
6984                step(&["sh", "-c", &sleep_past_the_backstop]),
6985                step(&["sh", "-c", &sleep_past_the_backstop]),
6986            ],
6987        );
6988        assert!(
6989            core.run_action(following, std::slice::from_ref(&key)),
6990            "a submission made once that release has happened must be accepted"
6991        );
6992        wait_for("the following run's own first step to start", || {
6993            receipt_labelled(&core, &key, "following")
6994                .is_some_and(|receipt| receipt.running.is_some())
6995        });
6996
6997        core.stop_action();
6998
6999        wait_for("the cancelled run to come down", || !core.action_running());
7000        let receipt =
7001            receipt_labelled(&core, &key, "following").expect("the following run's receipt");
7002        assert_eq!(
7003            receipt.steps[0].outcome,
7004            StepOutcome::Cancelled,
7005            "the completion this run followed must leave stop_action still reaching it"
7006        );
7007        assert_eq!(
7008            receipt.steps[1].outcome,
7009            StepOutcome::Cancelled,
7010            "a cancelled run's remaining step must never start, so it reads Cancelled"
7011        );
7012    }
7013
7014    // =====================================================================================
7015    // Criteria 3 and 4: `Core::hold_action`/`Core::continue_action` are their own verbs on
7016    // the core, kept apart from the generic `pause`/`resume` the probes use, and suspending
7017    // a fan-out is reversible: a held step's own progress genuinely pauses, and resumes
7018    // exactly where it left off, rather than the run merely finishing on its own regardless.
7019    // =====================================================================================
7020
7021    /// A black-box proof through the public API alone, with no reach into the step's own
7022    /// pid: a one-second step, held for 1.5s (comfortably longer than the step would ever
7023    /// take unheld) and then continued. If `hold_action` were a no-op, the step would
7024    /// already have finished on its own well before this test ever calls
7025    /// `continue_action`, and `action_running` would already read `false` at the
7026    /// mid-hold checkpoint below; that is the exact mutation this test is written to catch.
7027    #[test]
7028    fn hold_action_genuinely_pauses_a_running_steps_progress_and_continue_action_resumes_it() {
7029        let dir = tempfile::tempdir().expect("temp dir");
7030        let root = root_of(&dir);
7031        let repo = root.join("repo");
7032        init_repo_with_a_commit(&repo);
7033
7034        let core = Core::start_discovered(spec(vec![root]));
7035        let key = core.snapshot().entities[0].key.clone();
7036        let two_seconds = action("brief", vec![step(&["sh", "-c", "sleep 2"])]);
7037
7038        assert!(core.run_action(two_seconds, std::slice::from_ref(&key)));
7039        wait_for("the two-second step to actually start running", || {
7040            core.snapshot().entities[0]
7041                .last_action
7042                .as_ref()
7043                .is_some_and(|receipt| receipt.running.is_some())
7044        });
7045
7046        // The receipt's own `running: Some(_)` is written just before `run_step` is even
7047        // called, so it can race that call's own spawn, which is when the step's process
7048        // group is actually registered. SIGSTOP is idempotent, so pulsing `hold_action`
7049        // over a short bounded window (well inside the step's own 2s) is what makes that
7050        // race resolve deterministically rather than flakily, without ever risking a hang:
7051        // a stuck `hold_action` here fails this loop's own fixed iteration count, not this
7052        // test's wall clock.
7053        for _ in 0..20 {
7054            core.hold_action();
7055            thread::sleep(Duration::from_millis(20));
7056        }
7057
7058        thread::sleep(Duration::from_millis(1_800));
7059        assert!(
7060            core.action_running(),
7061            "a genuinely held step must not have finished on its own well past its own 2s \
7062             sleep; a no-op hold_action would already show this false here"
7063        );
7064
7065        core.continue_action();
7066        wait_for("continue_action to let the held step finish", || {
7067            !core.action_running()
7068        });
7069        let receipt = core.snapshot().entities[0].last_action.clone().unwrap();
7070        assert_eq!(receipt.steps[0].outcome, StepOutcome::Ok);
7071    }
7072
7073    /// `hold_action`, `continue_action` and `stop_action` must all be safe to call with no
7074    /// fan-out live: nothing to signal, so each is a plain no-op rather than a panic or a
7075    /// stray signal to nothing.
7076    #[test]
7077    fn hold_continue_and_stop_action_are_no_ops_with_no_fan_out_running() {
7078        let dir = tempfile::tempdir().expect("temp dir");
7079        let root = root_of(&dir);
7080        let repo = root.join("repo");
7081        init_repo_with_a_commit(&repo);
7082
7083        let core = Core::start_discovered(spec(vec![root]));
7084
7085        core.hold_action();
7086        core.continue_action();
7087        core.stop_action();
7088
7089        assert!(!core.action_running());
7090    }
7091
7092    // =====================================================================================
7093    // Criterion 1: Escape (`Core::stop_action`) cancels the fan-out with two signals, the
7094    // terminating one and then the uncatchable one after a grace, because the first is
7095    // trappable. Exercised through the real public seam, never by calling `RunControl`
7096    // directly, so this is `stop_action` end to end rather than only its own primitive.
7097    // =====================================================================================
7098
7099    /// A child that traps and ignores SIGTERM is the only fixture that actually
7100    /// discriminates the two-signal design from a one-signal one: a child that dies on
7101    /// SIGTERM alone would pass this test even if `stop_action` were mutated to drop its
7102    /// own SIGKILL follow-up entirely, which is exactly the regression this criterion
7103    /// exists to catch.
7104    ///
7105    /// The step sleeps [`FIXTURE_LIFETIME`], ten times the backstop every wait below
7106    /// carries, so a `stop_action` that stops working reads back as a named wait giving up
7107    /// rather than as the step ending on its own inside the wait watching it. That margin is
7108    /// the whole discrimination here, because the outcome assertion cannot supply it:
7109    /// `run_action_for_entity` stamps `Cancelled` on whatever was running the moment the run
7110    /// was cancelled, however the step actually ended. A run that does fail here leaves the
7111    /// trapping child alive until its own sleep ends, which is the price of a fixture the
7112    /// wait cannot outlast.
7113    #[test]
7114    fn stop_action_escalates_from_sigterm_to_sigkill_against_a_trapping_step() {
7115        let dir = tempfile::tempdir().expect("temp dir");
7116        let root = root_of(&dir);
7117        let repo = root.join("repo");
7118        init_repo_with_a_commit(&repo);
7119
7120        let core = Core::start_discovered(spec(vec![root]));
7121        let key = core.snapshot().entities[0].key.clone();
7122        let sleep_past_the_backstop = format!("trap '' TERM; sleep {}", FIXTURE_LIFETIME.as_secs());
7123        let trapping = action(
7124            "trapping",
7125            vec![step(&["sh", "-c", &sleep_past_the_backstop])],
7126        );
7127
7128        assert!(core.run_action(trapping, std::slice::from_ref(&key)));
7129        wait_for("the trapping step to actually start running", || {
7130            core.snapshot().entities[0]
7131                .last_action
7132                .as_ref()
7133                .is_some_and(|receipt| receipt.running.is_some())
7134        });
7135        // Gives the shell time to install its own trap before any signal can arrive; the
7136        // outcome asserted below is the actual proof, not this fixed delay.
7137        thread::sleep(Duration::from_millis(100));
7138
7139        core.stop_action();
7140
7141        wait_for(
7142            "a SIGTERM-trapping step to come down from the follow-up SIGKILL",
7143            || !core.action_running(),
7144        );
7145        let receipt = core.snapshot().entities[0].last_action.clone().unwrap();
7146        assert_eq!(receipt.steps.len(), 1);
7147        assert_eq!(
7148            receipt.steps[0].outcome,
7149            StepOutcome::Cancelled,
7150            "a step running when the run was cancelled must read Cancelled, never Failed"
7151        );
7152    }
7153
7154    // =====================================================================================
7155    // Criterion 2: cancellation produces `Cancelled`, never `NotRun`, which stays reserved
7156    // for being blocked by an earlier failure. Both outcomes are shown live in the same
7157    // run, on different entities, so they can be told apart rather than merely observed
7158    // one at a time.
7159    // =====================================================================================
7160
7161    /// One Action, two entities, dispatched together at `concurrency: 2`: `fail`'s own
7162    /// first step exits nonzero well before the run is ever cancelled, so its second step
7163    /// is a genuine `NotRun`; `slow`'s own first step is still sleeping when
7164    /// `stop_action` fires, so both of its steps read `Cancelled`. A test that only ever
7165    /// produced one of the two outcomes could not prove they are told apart; this fixture
7166    /// has both live in the same receipt set, so a mutation that collapsed one into the
7167    /// other would be caught by whichever entity it broke.
7168    #[test]
7169    fn cancelled_and_not_run_are_distinct_outcomes_shown_together_in_one_run() {
7170        let dir = tempfile::tempdir().expect("temp dir");
7171        let root = root_of(&dir);
7172        init_repo_with_a_commit(&root.join("fail"));
7173        init_repo_with_a_commit(&root.join("slow"));
7174
7175        let core = Core::start_discovered(spec(vec![root]));
7176        let snapshot = core.snapshot();
7177        let fail_key = snapshot
7178            .entities
7179            .iter()
7180            .find(|entity| &*entity.name == "fail")
7181            .expect("the fail entity is present")
7182            .key
7183            .clone();
7184        let slow_key = snapshot
7185            .entities
7186            .iter()
7187            .find(|entity| &*entity.name == "slow")
7188            .expect("the slow entity is present")
7189            .key
7190            .clone();
7191
7192        // One step list run against both entities: behaviour branches on the entity's own
7193        // directory name, which is `$PWD`'s basename in each entity's own working
7194        // directory, so `fail` fails immediately and `slow` is still running when this
7195        // test cancels the whole run.
7196        // `slow`'s branch sleeps `FIXTURE_LIFETIME` rather than a number of its own: the
7197        // wait below is on cancellation bringing the fan-out down, which a step that ends by
7198        // itself inside the backstop would satisfy without cancellation working at all.
7199        let branch_on_the_entity_name = format!(
7200            "case \"$(basename \"$PWD\")\" in fail) exit 1 ;; *) sleep {} ;; esac",
7201            FIXTURE_LIFETIME.as_secs()
7202        );
7203        let steps = vec![
7204            step(&["sh", "-c", &branch_on_the_entity_name]),
7205            step(&["true"]),
7206        ];
7207        let mut action_spec = action("mixed", steps);
7208        action_spec.concurrency = 2;
7209
7210        assert!(core.run_action(action_spec, &[fail_key.clone(), slow_key.clone()]));
7211
7212        // `fail` must have already finished (both its steps recorded) while `slow` is
7213        // still running its own first step: the two entities' own outcomes are captured
7214        // at the same moment, which is what makes them "shown together".
7215        wait_for(
7216            "`fail` finished and `slow` still running before cancelling",
7217            || {
7218                let snapshot = core.snapshot();
7219                let fail_done = snapshot
7220                    .entities
7221                    .iter()
7222                    .find(|entity| entity.key == fail_key)
7223                    .and_then(|entity| entity.last_action.as_ref())
7224                    .is_some_and(|receipt| receipt.steps.len() == 2);
7225                let slow_running = snapshot
7226                    .entities
7227                    .iter()
7228                    .find(|entity| entity.key == slow_key)
7229                    .and_then(|entity| entity.last_action.as_ref())
7230                    .is_some_and(|receipt| receipt.running.is_some());
7231                fail_done && slow_running
7232            },
7233        );
7234
7235        core.stop_action();
7236        wait_for("the fan-out to finish once cancelled", || {
7237            !core.action_running()
7238        });
7239
7240        let snapshot = core.snapshot();
7241        let fail_receipt = snapshot
7242            .entities
7243            .iter()
7244            .find(|entity| entity.key == fail_key)
7245            .and_then(|entity| entity.last_action.clone())
7246            .expect("fail's own receipt");
7247        assert_eq!(fail_receipt.steps[0].outcome, StepOutcome::Failed(1));
7248        assert_eq!(
7249            fail_receipt.steps[1].outcome,
7250            StepOutcome::NotRun,
7251            "blocked by fail's own earlier failure, not by the later cancellation"
7252        );
7253
7254        let slow_receipt = snapshot
7255            .entities
7256            .iter()
7257            .find(|entity| entity.key == slow_key)
7258            .and_then(|entity| entity.last_action.clone())
7259            .expect("slow's own receipt");
7260        assert_eq!(
7261            slow_receipt.steps[0].outcome,
7262            StepOutcome::Cancelled,
7263            "a step running when the run was cancelled must read Cancelled"
7264        );
7265        assert_eq!(
7266            slow_receipt.steps[1].outcome,
7267            StepOutcome::Cancelled,
7268            "a step that had not started when the run was cancelled must also read \
7269             Cancelled, never NotRun, which stays reserved for an earlier failure"
7270        );
7271    }
7272
7273    /// A panic anywhere inside the fan-out, a poisoned `RwLock` from an unrelated
7274    /// earlier panic is enough, must not leave this `Core` reading a run as live for the
7275    /// rest of its life. Poisons the table lock directly rather than
7276    /// injecting a fault into `run_action_for_entity`, which runs a real child process
7277    /// and has no seam for one: the fan-out's own `table_handle.write().unwrap()` then
7278    /// panics on the poisoned lock exactly the way an unrelated earlier panic would in
7279    /// production.
7280    #[test]
7281    fn a_panicking_fan_out_still_resets_action_running_so_a_later_action_can_start() {
7282        let dir = tempfile::tempdir().expect("temp dir");
7283        let root = root_of(&dir);
7284        let repo = root.join("repo");
7285        init_repo_with_a_commit(&repo);
7286
7287        // Drained before the table lock is poisoned below: a probe still in flight would
7288        // take the poison too, and a panic in one of rayon's global workers aborts the
7289        // process rather than unwinding.
7290        let (core, launched) = started_and_settled(spec(vec![root]));
7291        let key = launched.entities[0].key.clone();
7292
7293        // A step slow enough that the fan-out's own write of `last_action` cannot have
7294        // happened yet by the time the poisoning below completes: `run_action`'s own
7295        // synchronous prefix (admission, `cancel_in_flight`, the read that builds
7296        // `included`) is already finished by the time this call returns,
7297        // so poisoning the lock afterwards can only reach the fan-out's own write,
7298        // inside its own spawned thread.
7299        let started = core.run_action(
7300            action("boom", vec![step(&["sh", "-c", "sleep 0.3"])]),
7301            std::slice::from_ref(&key),
7302        );
7303        assert!(started);
7304
7305        let table = Arc::clone(&core.table);
7306        thread::spawn(move || {
7307            let _guard = table.write().unwrap();
7308            panic!("deliberately poison the table lock for this test");
7309        })
7310        .join()
7311        .expect_err("the poisoning thread must itself panic to poison the lock");
7312
7313        // Without `catch_unwind` around the fan-out this never becomes false: its own write
7314        // panics on the now-poisoned lock, unwinds out of `pool.install` and skips the
7315        // completion transition just past it, leaving this `Core` reading its run as live
7316        // for ever.
7317        wait_for(
7318            "a panicking fan-out to end its run rather than leave it reading as live",
7319            || !core.action_running(),
7320        );
7321
7322        // Clears the poison this test itself introduced to force the panic, an
7323        // artifact of the test rather than anything production code ever does, so a
7324        // real, full `run_action` call below proves the ended run actually lets another
7325        // Action run to completion, not merely that one private read flipped.
7326        core.table.clear_poison();
7327
7328        let second_started = core.run_action(
7329            action("second", vec![step(&["true"])]),
7330            std::slice::from_ref(&key),
7331        );
7332        assert!(
7333            second_started,
7334            "a later Action must be able to start once the panicking one has finished"
7335        );
7336        wait_for("the second Action to run to completion", || {
7337            core.snapshot()
7338                .entities
7339                .iter()
7340                .find(|entity| entity.key == key)
7341                .and_then(|entity| entity.last_action.as_ref())
7342                .is_some_and(|receipt| &*receipt.label == "second")
7343        });
7344    }
7345
7346    /// Asserts `entity` reads exactly as a Vanished row must: still in the table,
7347    /// its last known branch value untouched, and that same cell's staleness
7348    /// forced on. Shared by the Repo and the Submodule vanish tests so both
7349    /// exercise the identical assertion rather than a Repo-shaped one and a
7350    /// Submodule-shaped one that only look alike.
7351    fn assert_vanished_with_stale_branch(entity: &EntityState, expected_branch: &str) {
7352        assert_eq!(entity.presence, crate::entity::Presence::Vanished);
7353        match entity.branch.settled() {
7354            Some(Settled::Known {
7355                value: Head::Branch { name, .. },
7356                stale: true,
7357                at: _,
7358            }) => assert_eq!(
7359                &**name, expected_branch,
7360                "a Vanished entity must keep its last known branch value"
7361            ),
7362            other => panic!(
7363                "expected the branch cell to keep its Known value and go stale, got {other:?}"
7364            ),
7365        }
7366    }
7367
7368    /// The central behaviour this ticket adds: an entity discovery no longer
7369    /// finds stays in the table with its last known values, every cell forced
7370    /// stale, rather than disappearing. Proven end to end through `refresh` and
7371    /// `settle`, which is what proves discovery itself re-ran rather than the
7372    /// entity merely being left alone.
7373    #[test]
7374    fn a_repo_removed_from_disk_stays_in_the_table_vanished_with_its_last_values() {
7375        let dir = tempfile::tempdir().expect("temp dir");
7376        let root = root_of(&dir);
7377        let repo = root.join("repo");
7378        init_repo_with_a_commit(&repo);
7379
7380        let core = Core::start_discovered(spec(vec![root]));
7381        let key = core.snapshot().entities[0].key.clone();
7382        core.refresh(std::slice::from_ref(&key));
7383        let before = core.settle();
7384        let branch_name = match before.entities[0].branch.settled() {
7385            Some(Settled::Known {
7386                value: Head::Branch { name, .. },
7387                at: _,
7388                stale: _,
7389            }) => name.to_string(),
7390            other => panic!("expected the first refresh to settle a branch, got {other:?}"),
7391        };
7392
7393        fs::remove_dir_all(&repo).expect("remove the repo from disk");
7394
7395        core.refresh(&[]);
7396        let after = core.settle();
7397
7398        assert_eq!(
7399            after.entities.len(),
7400            1,
7401            "a vanished entity must stay in the snapshot, not disappear from it"
7402        );
7403        assert_vanished_with_stale_branch(&after.entities[0], &branch_name);
7404    }
7405
7406    /// Criterion 2's "untouched by the vanished-staleness path" made behavioural, through a
7407    /// real `Core::refresh` rather than calling `mark_vanished` directly: the same pass that
7408    /// forces every settled Cell stale on this entity must leave its receipt exactly as it was.
7409    #[test]
7410    fn a_vanished_entitys_action_receipt_survives_the_vanished_staleness_pass_untouched() {
7411        let dir = tempfile::tempdir().expect("temp dir");
7412        let root = root_of(&dir);
7413        let repo = root.join("repo");
7414        init_repo_with_a_commit(&repo);
7415
7416        let core = Core::start_discovered(spec(vec![root]));
7417        let key = core.snapshot().entities[0].key.clone();
7418        let receipt = crate::entity::ActionReceipt {
7419            label: Arc::from("reinstall"),
7420            steps: Arc::from(vec![crate::entity::StepResult {
7421                label: Arc::from("pnpm install"),
7422                outcome: crate::entity::StepOutcome::Ok,
7423                output: Arc::from(&b""[..]),
7424                elapsed: Duration::from_millis(1),
7425                elision: None,
7426                shell: false,
7427                interactive: false,
7428            }]),
7429            skip: None,
7430            finished_at: Timestamp::now(),
7431            running: None,
7432        };
7433        core.set_last_action_for_test(&key, receipt.clone());
7434
7435        fs::remove_dir_all(&repo).expect("remove the repo from disk");
7436        core.refresh(&[]);
7437        let after = core.settle();
7438
7439        let entity = &after.entities[0];
7440        assert_eq!(entity.presence, crate::entity::Presence::Vanished);
7441        assert_eq!(entity.last_action, Some(receipt));
7442    }
7443
7444    /// Criterion 6's reason for `ActionReceipt` sharing rather than copying is "the snapshot
7445    /// is cloned every frame"; a bare `ActionReceipt::clone()` only proves `Arc::clone` shares,
7446    /// which holds by definition and says nothing about this design. Proven instead through
7447    /// `Core::snapshot` itself: put a receipt on a live `Core`'s table, take two snapshots, and
7448    /// assert the label and steps are the same allocation across them, not merely equal. This
7449    /// passes as written, since the sharing does hold end to end; it exists to fail if some
7450    /// intermediate step ever re-materialised the receipt's bytes.
7451    #[test]
7452    fn two_snapshots_of_an_entity_share_its_last_actions_label_and_steps_by_pointer() {
7453        let dir = tempfile::tempdir().expect("temp dir");
7454        let root = root_of(&dir);
7455        let repo = root.join("repo");
7456        init_repo_with_a_commit(&repo);
7457
7458        let core = Core::start_discovered(spec(vec![root]));
7459        let key = core.snapshot().entities[0].key.clone();
7460        let receipt = crate::entity::ActionReceipt {
7461            label: Arc::from("reinstall"),
7462            steps: Arc::from(vec![crate::entity::StepResult {
7463                label: Arc::from("pnpm install"),
7464                outcome: crate::entity::StepOutcome::Failed(1),
7465                output: Arc::from(&b""[..]),
7466                elapsed: Duration::from_millis(1),
7467                elision: None,
7468                shell: false,
7469                interactive: false,
7470            }]),
7471            skip: None,
7472            finished_at: Timestamp::now(),
7473            running: None,
7474        };
7475        core.set_last_action_for_test(&key, receipt);
7476
7477        let first = core.snapshot();
7478        let second = core.snapshot();
7479        let first_receipt = first.entities[0]
7480            .last_action
7481            .as_ref()
7482            .expect("receipt was set");
7483        let second_receipt = second.entities[0]
7484            .last_action
7485            .as_ref()
7486            .expect("receipt was set");
7487
7488        assert!(
7489            Arc::ptr_eq(&first_receipt.label, &second_receipt.label),
7490            "two snapshots of the same receipt must share the label's allocation, not \
7491             re-copy it"
7492        );
7493        assert!(
7494            Arc::ptr_eq(&first_receipt.steps, &second_receipt.steps),
7495            "two snapshots of the same receipt must share the steps slice's allocation, not \
7496             re-copy it, which is also what shares every step's own captured output"
7497        );
7498    }
7499
7500    /// A Submodule vanishes by exactly the same rule as a Repo: no code path here
7501    /// is specific to which half of discovery produced the entry. Driven through
7502    /// the Submodule half (removing its declaration from `.gitmodules`, never
7503    /// touched by the boundary walk) and asserted with the very same helper the
7504    /// Repo test above uses.
7505    #[test]
7506    fn a_submodule_removed_from_gitmodules_vanishes_by_the_same_rule_as_a_repo() {
7507        let dir = tempfile::tempdir().expect("temp dir");
7508        let root = root_of(&dir);
7509        let parent = root.join("parent");
7510        init_repo_with_a_commit(&parent);
7511        fs::write(
7512            parent.join(".gitmodules"),
7513            "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
7514        )
7515        .expect("write .gitmodules");
7516        let submodule_path = parent.join("vendor").join("lib");
7517        init_repo_with_a_commit(&submodule_path);
7518
7519        // Shown, so the explicit `refresh` just below actually dispatches a probe against
7520        // it: this test is about the Vanished rule, not about `show_submodules` gating.
7521        let mut core_spec = spec(vec![root]);
7522        core_spec.show_submodules = true;
7523        let core = Core::start_discovered(core_spec);
7524        let snapshot = core.snapshot();
7525        let submodule_key = snapshot
7526            .entities
7527            .iter()
7528            .find(|entity| matches!(entity.kind, Kind::Submodule))
7529            .expect("submodule discovered")
7530            .key
7531            .clone();
7532        core.refresh(std::slice::from_ref(&submodule_key));
7533        let before = core.settle();
7534        let submodule_before = before
7535            .entities
7536            .iter()
7537            .find(|entity| entity.key == submodule_key)
7538            .expect("submodule present");
7539        let branch_name = match submodule_before.branch.settled() {
7540            Some(Settled::Known {
7541                value: Head::Branch { name, .. },
7542                at: _,
7543                stale: _,
7544            }) => name.to_string(),
7545            other => {
7546                panic!("expected the submodule's first refresh to settle a branch, got {other:?}")
7547            }
7548        };
7549
7550        // The submodule is no longer declared: discovery's second half will no
7551        // longer produce this entry, exactly as removing the parent's own `.git`
7552        // boundary would remove a Repo's entry.
7553        fs::write(parent.join(".gitmodules"), "").expect("clear .gitmodules");
7554
7555        core.refresh(&[]);
7556        let after = core.settle();
7557
7558        let submodule_after = after
7559            .entities
7560            .iter()
7561            .find(|entity| entity.key == submodule_key)
7562            .expect("the vanished submodule must stay in the snapshot");
7563        assert_vanished_with_stale_branch(submodule_after, &branch_name);
7564    }
7565
7566    /// Dismissal writes nothing to disk, so a Repo dismissed from one `Core`
7567    /// reads as an ordinary, freshly discovered Present entity on a brand new
7568    /// `Core` over the same roots, never as a restored Vanished row: startup is
7569    /// always a Generation with an empty prior state.
7570    #[test]
7571    fn dismissal_persists_nothing_across_a_fresh_core() {
7572        let dir = tempfile::tempdir().expect("temp dir");
7573        let root = root_of(&dir);
7574        let repo = root.join("repo");
7575        init_repo_with_a_commit(&repo);
7576
7577        let first_core = Core::start_discovered(spec(vec![root.clone()]));
7578        let key = first_core.snapshot().entities[0].key.clone();
7579        first_core.dismiss(&key);
7580        assert!(first_core.snapshot().entities.is_empty());
7581        drop(first_core);
7582
7583        let second_core = Core::start_discovered(spec(vec![root]));
7584        let snapshot = second_core.snapshot();
7585
7586        assert_eq!(
7587            snapshot.entities.len(),
7588            1,
7589            "a fresh Core must discover the repo again"
7590        );
7591        assert_eq!(
7592            snapshot.entities[0].presence,
7593            crate::entity::Presence::Present,
7594            "nothing from the dismissing Core's lifetime may be persisted, so the \
7595             repo must come back Present, never restored as Vanished"
7596        );
7597    }
7598
7599    /// An entity that moves reads as vanished plus new: its old key stays in the
7600    /// table Vanished with its last values, and a brand new entity appears at the
7601    /// new path, rather than the move being recognised as a rename.
7602    #[test]
7603    fn a_repo_that_moves_reads_as_vanished_plus_new() {
7604        let dir = tempfile::tempdir().expect("temp dir");
7605        let root = root_of(&dir);
7606        let original_path = root.join("original-name");
7607        init_repo_with_a_commit(&original_path);
7608
7609        let core = Core::start_discovered(spec(vec![root.clone()]));
7610        let original_key = core.snapshot().entities[0].key.clone();
7611        core.refresh(std::slice::from_ref(&original_key));
7612        let before = core.settle();
7613        let branch_name = match before.entities[0].branch.settled() {
7614            Some(Settled::Known {
7615                value: Head::Branch { name, .. },
7616                at: _,
7617                stale: _,
7618            }) => name.to_string(),
7619            other => panic!("expected the first refresh to settle a branch, got {other:?}"),
7620        };
7621
7622        let moved_path = root.join("new-name");
7623        fs::rename(&original_path, &moved_path).expect("move the repo on disk");
7624
7625        core.refresh(&[]);
7626        let after = core.settle();
7627
7628        assert_eq!(
7629            after.entities.len(),
7630            2,
7631            "a moved entity must read as the old key vanished plus a new one present, \
7632             never as one renamed entity"
7633        );
7634        let old_entity = after
7635            .entities
7636            .iter()
7637            .find(|entity| entity.key == original_key)
7638            .expect("the old key must stay in the table");
7639        assert_vanished_with_stale_branch(old_entity, &branch_name);
7640        let new_entity = after
7641            .entities
7642            .iter()
7643            .find(|entity| entity.key != original_key)
7644            .expect("a new entity at the moved path must be present");
7645        assert_eq!(new_entity.presence, crate::entity::Presence::Present);
7646        assert_eq!(new_entity.key.path(), moved_path);
7647    }
7648
7649    /// Reappearance is vanishing's mirror: an entity discovery stops finding, and
7650    /// then finds again, must come back Present rather than staying stuck
7651    /// Vanished forever.
7652    #[test]
7653    fn a_vanished_repo_recreated_on_disk_reads_present_on_the_next_refresh() {
7654        let dir = tempfile::tempdir().expect("temp dir");
7655        let root = root_of(&dir);
7656        let repo = root.join("repo");
7657        init_repo_with_a_commit(&repo);
7658
7659        let core = Core::start_discovered(spec(vec![root]));
7660        let key = core.snapshot().entities[0].key.clone();
7661
7662        fs::remove_dir_all(&repo).expect("remove the repo from disk");
7663        core.refresh(&[]);
7664        let vanished = core.settle();
7665        assert_eq!(
7666            vanished.entities[0].presence,
7667            crate::entity::Presence::Vanished,
7668            "the repo must read Vanished once removed from disk"
7669        );
7670
7671        init_repo_with_a_commit(&repo);
7672        core.refresh(&[]);
7673        let recreated = core.settle();
7674
7675        let entity = recreated
7676            .entities
7677            .iter()
7678            .find(|entity| entity.key == key)
7679            .expect("the recreated repo must still resolve to the same entity key");
7680        assert_eq!(
7681            entity.presence,
7682            crate::entity::Presence::Present,
7683            "an entity discovery finds again after it vanished must read Present, \
7684             not stay stuck Vanished forever"
7685        );
7686    }
7687
7688    /// Discovery riding the refresh is what lets a brand new entity appear
7689    /// without a fresh `Core::start`: a repo created after `start` is picked up
7690    /// by the very next `refresh`, even though the caller's `order` cannot yet
7691    /// name a key it never saw.
7692    #[test]
7693    fn a_new_repo_created_after_start_is_discovered_by_the_next_refresh() {
7694        let dir = tempfile::tempdir().expect("temp dir");
7695        let root = root_of(&dir);
7696        init_repo_with_a_commit(&root.join("first"));
7697
7698        let core = Core::start_discovered(spec(vec![root.clone()]));
7699        assert_eq!(core.snapshot().entities.len(), 1);
7700
7701        init_repo_with_a_commit(&root.join("second"));
7702        core.refresh(&[]);
7703        let after = core.settle();
7704
7705        assert_eq!(
7706            after.entities.len(),
7707            2,
7708            "a new repo created after start must be found by the next refresh's own discovery"
7709        );
7710
7711        // The entity is usable, not merely counted: a refresh that names its key
7712        // actually probes it and settles a real cell.
7713        let new_key = after
7714            .entities
7715            .iter()
7716            .find(|entity| &*entity.name == "second")
7717            .expect("the newly discovered repo must be named by the walk")
7718            .key
7719            .clone();
7720        core.refresh(std::slice::from_ref(&new_key));
7721        let probed = core.settle();
7722        let new_entity = probed
7723            .entities
7724            .iter()
7725            .find(|entity| entity.key == new_key)
7726            .expect("the newly discovered repo must still be present");
7727        assert!(
7728            matches!(
7729                new_entity.branch.settled(),
7730                Some(Settled::Known {
7731                    value: _,
7732                    at: _,
7733                    stale: _
7734                })
7735            ),
7736            "a refresh naming the newly discovered repo's key must actually probe \
7737             it and settle its branch cell, got {:?}",
7738            new_entity.branch.settled()
7739        );
7740    }
7741
7742    /// The abandon path takes the Set out of the automatic refresh path: once one
7743    /// discovery invocation abandons, a later `refresh` does not re-run discovery
7744    /// at all, proven by a repo created afterward never appearing, not merely by
7745    /// reading an internal flag.
7746    #[test]
7747    fn an_abandoned_discovery_stops_riding_later_refreshes() {
7748        let dir = tempfile::tempdir().expect("temp dir");
7749        let root = root_of(&dir);
7750        // A wide fan of plain directories, real enough for the walk to measurably
7751        // outrun a millisecond-scale deadline, so `start`'s own discovery
7752        // abandons rather than merely being told to (`Duration::ZERO` would trip
7753        // on the very first directory regardless of what is actually here, which
7754        // could never distinguish a guarded `refresh` from an unguarded one that
7755        // simply keeps re-abandoning against the same still-huge tree).
7756        let decoys = root.join("decoys");
7757        for i in 0..4_000 {
7758            fs::create_dir(decoys.join(format!("decoy-{i}")))
7759                .or_else(|_| fs::create_dir_all(decoys.join(format!("decoy-{i}"))))
7760                .expect("create decoy dir");
7761        }
7762        let (_tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
7763
7764        let started = Core::start_for_test_with_discovery_abandon(
7765            spec(vec![root.clone()]),
7766            Duration::from_secs(3600),
7767            Duration::from_micros(500),
7768            tick_rx,
7769        )
7770        .discovered();
7771        let core = started.core;
7772        assert!(
7773            core.discovery_manual_for_test(),
7774            "walking 4,000 decoy directories against a 500 microsecond deadline \
7775             must have abandoned and taken the Set manual"
7776        );
7777
7778        // The tree shrinks back to nothing slow: if `refresh` were still (wrongly)
7779        // re-running discovery, this walk would finish comfortably inside the
7780        // same deadline and find the new repo below. Only the manual guard can
7781        // account for it staying undiscovered.
7782        fs::remove_dir_all(&decoys).expect("remove decoy directories");
7783        init_repo_with_a_commit(&root.join("second"));
7784
7785        core.refresh(&[]);
7786        let after = core.settle();
7787
7788        assert!(
7789            !after
7790                .entities
7791                .iter()
7792                .any(|entity| &*entity.name == "second"),
7793            "once discovery has abandoned, a later refresh must not re-run it, so a \
7794             repo created afterward, on a tree that would now resolve quickly, \
7795             must still never appear"
7796        );
7797    }
7798
7799    /// `rerun_discovery`'s own abandon handling, exercised by a walk that only
7800    /// abandons on a later `refresh`, never on `start`'s: the first walk, over a
7801    /// tree small enough to finish comfortably inside the deadline, must leave
7802    /// the Set automatic, and only the second walk, once the same tree has grown
7803    /// a wide fan of decoys, may flip the manual flag and leave the abandoned
7804    /// warning. Both existing abandon tests force the abandon inside `start`'s
7805    /// own walk, which can never reach this block: `refresh` gates
7806    /// `rerun_discovery` behind the manual flag `start` already set.
7807    #[test]
7808    fn a_refresh_triggered_discovery_abandon_sets_manual_and_warns() {
7809        let dir = tempfile::tempdir().expect("temp dir");
7810        let root = root_of(&dir);
7811        init_repo_with_a_commit(&root.join("first"));
7812        let (_tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
7813
7814        // The first walk runs under a deadline it cannot lose against, so this
7815        // precondition is not a race. Tightening the deadline afterwards is what
7816        // separates the walk that must survive from the walk that must abandon:
7817        // one deadline serving both is a knife edge, and scheduling latency on a
7818        // loaded machine erases any margin a wall-clock figure can buy.
7819        let started = Core::start_for_test_with_discovery_abandon(
7820            spec(vec![root.clone()]),
7821            Duration::from_secs(3600),
7822            Duration::from_secs(3600),
7823            tick_rx,
7824        )
7825        .discovered();
7826        let core = started.core;
7827        assert!(
7828            !core.discovery_manual_for_test(),
7829            "an hour-long deadline must leave the first walk automatic"
7830        );
7831
7832        // Grown only after the first walk has finished (`discovered` above joined it),
7833        // so this fan of decoys is invisible to that walk and can only be reached by a
7834        // walk `refresh` triggers itself.
7835        let decoys = root.join("decoys");
7836        for i in 0..4_000 {
7837            fs::create_dir(decoys.join(format!("decoy-{i}")))
7838                .or_else(|_| fs::create_dir_all(decoys.join(format!("decoy-{i}"))))
7839                .expect("create decoy dir");
7840        }
7841        core.set_discovery_abandon_after_for_test(Duration::from_micros(500));
7842
7843        core.refresh(&[]);
7844        // `refresh` returns the moment it has reserved its Generation; this is the
7845        // rendezvous that says its own walk has run.
7846        core.wait_dispatched_for_test();
7847
7848        assert!(
7849            core.discovery_manual_for_test(),
7850            "refresh's own rerun_discovery must abandon against the newly-grown \
7851             tree and take the Set manual, the same as an abandon at start does"
7852        );
7853        let warning = core.discovery_warning();
7854        assert!(
7855            warning
7856                .as_deref()
7857                .is_some_and(|message| message.starts_with("discovery: stopped at")),
7858            "refresh's rerun_discovery must leave the abandoned-discovery warning \
7859             behind, not merely flip the manual flag: got {warning:?}"
7860        );
7861    }
7862
7863    /// The other half: an abandoned Set going manual must not leak into a
7864    /// different `Core`. The only way this crate can express "the Set's roots or
7865    /// globs changed" today is a fresh `Core::start` (a live in-place reload has
7866    /// no entry point in `Core` yet), so this proves the manual flag lives on one
7867    /// `Core` instance rather than anywhere global.
7868    #[test]
7869    fn a_fresh_core_over_different_roots_is_unaffected_by_another_cores_abandoned_discovery() {
7870        let abandoned_dir = tempfile::tempdir().expect("temp dir");
7871        let abandoned_root = root_of(&abandoned_dir);
7872        init_repo_with_a_commit(&abandoned_root.join("first"));
7873        let (_tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
7874        let started = Core::start_for_test_with_discovery_abandon(
7875            spec(vec![abandoned_root]),
7876            Duration::from_secs(3600),
7877            Duration::ZERO,
7878            tick_rx,
7879        )
7880        .discovered();
7881        started.core.refresh(&[]);
7882        started.core.settle();
7883        assert!(
7884            started.core.discovery_manual_for_test(),
7885            "the zero-length abandon deadline must have already taken this Core manual"
7886        );
7887        drop(started.core);
7888
7889        let fresh_dir = tempfile::tempdir().expect("temp dir");
7890        let fresh_root = root_of(&fresh_dir);
7891        init_repo_with_a_commit(&fresh_root.join("first"));
7892        let fresh_core = Core::start_discovered(spec(vec![fresh_root.clone()]));
7893        assert_eq!(fresh_core.snapshot().entities.len(), 1);
7894
7895        init_repo_with_a_commit(&fresh_root.join("second"));
7896        fresh_core.refresh(&[]);
7897        let after = fresh_core.settle();
7898
7899        assert_eq!(
7900            after.entities.len(),
7901            2,
7902            "a fresh Core, standing in for the Set's roots changing, must discover \
7903             normally regardless of an earlier, unrelated Core having gone manual"
7904        );
7905    }
7906
7907    /// Proves shutdown is clean: dropping the core blocks until the dedicated
7908    /// thread has actually returned, not merely until a message was sent to it.
7909    /// The tick sender is kept alive for the whole test, so the only way the
7910    /// thread can have stopped is the shutdown message `Drop` sends.
7911    #[test]
7912    fn dropping_the_core_joins_the_dedicated_thread_before_returning() {
7913        let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
7914        let dir = tempfile::tempdir().expect("temp dir");
7915        let root = root_of(&dir);
7916
7917        let started =
7918            Core::start_for_test(spec(vec![root]), Duration::from_secs(3600), tick_rx).discovered();
7919        assert!(started.clock_alive.load(Ordering::Acquire));
7920
7921        drop(started.core);
7922
7923        assert!(
7924            !started.clock_alive.load(Ordering::Acquire),
7925            "the dedicated thread should have exited, and cleared this flag, before drop returned"
7926        );
7927        drop(tick_tx);
7928    }
7929
7930    /// Cadence is driven entirely by the injected tick channel, never by a clock of
7931    /// the loop's own: with a zero deadline, the sweep is provably ready to fire
7932    /// the instant it runs, so whether it has run is exactly whether a tick has
7933    /// been sent, proven with no sleep on either side.
7934    #[test]
7935    fn the_deadline_sweep_runs_only_when_a_tick_arrives() {
7936        let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
7937        let dir = tempfile::tempdir().expect("temp dir");
7938        let root = root_of(&dir);
7939        let repo = root.join("repo");
7940        init_repo_with_a_commit(&repo);
7941
7942        let mut spec = spec(vec![root]);
7943        spec.generation_deadline = Duration::ZERO;
7944        let started = Core::start_for_test(spec, Duration::from_secs(3600), tick_rx).discovered();
7945        let core = started.core;
7946        // Drained, so the only entry in flight below is this test's own and only the sweep
7947        // can settle it again.
7948        let key = settle_launch(&core).entities[0].key.clone();
7949
7950        core.begin_untracked_probe_for_test(&key);
7951
7952        // No tick has been sent: the sweep has not run even though the (zero)
7953        // deadline has already elapsed in real time.
7954        let before = core.snapshot();
7955        assert!(
7956            matches!(
7957                before.entities[0].branch.settled(),
7958                Some(Settled::Known {
7959                    value: _,
7960                    at: _,
7961                    stale: _
7962                })
7963            ),
7964            "the cell still holds launch's own answer here, so the Unknown below is the \
7965             sweep's write rather than a cell that was already empty"
7966        );
7967        assert!(before.entities[0].branch.is_in_flight());
7968
7969        tick_tx.send(Instant::now()).expect("send one tick");
7970        let after = core.settle();
7971
7972        assert!(matches!(
7973            after.entities[0].branch.settled(),
7974            Some(Settled::Unknown(Unknown::TimedOut))
7975        ));
7976    }
7977
7978    /// Proves the real dedicated thread's tick arm actually reaches
7979    /// [`run_poll_sweep`], not merely that [`Core::poll_once_for_test`]'s direct
7980    /// call does the right thing: a mutation deleting the call inside
7981    /// `spawn_clock_thread` would leave every other poll test in this file green
7982    /// while failing only this one. [`wait_for`] backstops the wait rather than
7983    /// asserting any particular latency: the two ticks are sent from this thread
7984    /// and merely need to be picked up by the idle dedicated thread, not to land
7985    /// within a stated budget.
7986    #[test]
7987    fn a_real_tick_through_the_dedicated_thread_reaches_the_poll_sweep_and_reprobes_a_moved_entity()
7988    {
7989        let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
7990        let dir = tempfile::tempdir().expect("temp dir");
7991        let root = root_of(&dir);
7992        let repo = root.join("repo");
7993        init_repo_with_a_commit(&repo);
7994
7995        let started =
7996            Core::start_for_test(spec(vec![root]), Duration::from_secs(3600), tick_rx).discovered();
7997        let core = started.core;
7998        let key = core.snapshot().entities[0].key.clone();
7999
8000        backdate_polled_entries(&repo);
8001
8002        // The first tick only records a baseline: nothing has moved yet against a
8003        // fingerprint that did not exist before this tick.
8004        tick_tx
8005            .send(Instant::now())
8006            .expect("send the baseline tick");
8007        wait_for(
8008            "a tick sent on the real channel to reach the poll sweep",
8009            || core.poll_sweep_count_for_test() >= 1,
8010        );
8011        assert!(core.poll_reprobed_for_test().is_empty());
8012
8013        commit_a_change(&repo, "second");
8014
8015        tick_tx
8016            .send(Instant::now())
8017            .expect("send the movement tick");
8018        wait_for(
8019            "the real tick channel to reach the poll sweep and reprobe the moved entity",
8020            || core.poll_reprobed_for_test() == vec![key.clone()],
8021        );
8022        drop(tick_tx);
8023    }
8024
8025    /// Criterion 2's whole claim, over two entities so "for that entity only" has
8026    /// something to discriminate against: committing into one of two Repos and
8027    /// running one poll sweep re-probes branch/sync/base for the moved Repo alone
8028    /// (`poll_reprobed_for_test` names exactly it, never the other), force-stales
8029    /// its `dirty` and `state` without changing their value or timestamp (the
8030    /// absence claim that no status probe ran), and leaves the untouched Repo's
8031    /// cells byte-for-byte as the prior real `refresh` left them.
8032    #[test]
8033    fn poll_reprobe_touches_only_the_moved_entity_and_never_runs_a_status_probe() {
8034        let dir = tempfile::tempdir().expect("temp dir");
8035        let root = root_of(&dir);
8036        let repo_a = root.join("repo-a");
8037        let repo_b = root.join("repo-b");
8038        init_repo_with_a_commit(&repo_a);
8039        init_repo_with_a_commit(&repo_b);
8040
8041        let core = Core::start_discovered(spec(vec![root]));
8042        let snapshot = core.snapshot();
8043        let key_a = snapshot
8044            .entities
8045            .iter()
8046            .find(|entity| entity.key.path() == repo_a)
8047            .expect("repo-a discovered")
8048            .key
8049            .clone();
8050        let key_b = snapshot
8051            .entities
8052            .iter()
8053            .find(|entity| entity.key.path() == repo_b)
8054            .expect("repo-b discovered")
8055            .key
8056            .clone();
8057
8058        core.refresh(&[key_a.clone(), key_b.clone()]);
8059        let landed = core.settle();
8060        let entity_of = |snapshot: &Snapshot, key: &EntityKey| {
8061            snapshot
8062                .entities
8063                .iter()
8064                .find(|entity| &entity.key == key)
8065                .expect("entity present")
8066                .clone()
8067        };
8068        let a_before = entity_of(&landed, &key_a);
8069        let b_before = entity_of(&landed, &key_b);
8070        let branch_at = |entity: &EntityState| match entity.branch.settled() {
8071            Some(Settled::Known {
8072                at,
8073                value: _,
8074                stale: _,
8075            }) => *at,
8076            other => panic!("expected a landed branch, got {other:?}"),
8077        };
8078        let dirty_state = |entity: &EntityState| match entity.dirty.settled() {
8079            Some(Settled::Known { value, at, stale }) => (*value, *at, *stale),
8080            other => panic!("expected a landed dirty count, got {other:?}"),
8081        };
8082        let (a_dirty_value_before, a_dirty_at_before, a_dirty_stale_before) =
8083            dirty_state(&a_before);
8084        assert!(
8085            !a_dirty_stale_before,
8086            "the fresh refresh must land dirty as not stale"
8087        );
8088
8089        backdate_polled_entries(&repo_a);
8090
8091        backdate_polled_entries(&repo_b);
8092
8093        core.poll_once_for_test();
8094        assert!(
8095            core.poll_reprobed_for_test().is_empty(),
8096            "a first sweep has nothing to compare against, so it must report no movement"
8097        );
8098
8099        commit_a_change(&repo_a, "second");
8100        core.poll_once_for_test();
8101
8102        assert_eq!(
8103            core.poll_reprobed_for_test(),
8104            vec![key_a.clone()],
8105            "only the entity whose gitdir actually moved must be re-probed"
8106        );
8107
8108        let after = core.snapshot();
8109        let a_after = entity_of(&after, &key_a);
8110        let b_after = entity_of(&after, &key_b);
8111
8112        assert_ne!(
8113            branch_at(&a_after),
8114            branch_at(&a_before),
8115            "the moved entity's branch must carry a fresh timestamp from the re-probe"
8116        );
8117        let (a_dirty_value_after, a_dirty_at_after, a_dirty_stale_after) = dirty_state(&a_after);
8118        assert_eq!(
8119            a_dirty_value_after, a_dirty_value_before,
8120            "no status probe ran, so dirty's value must be exactly what the last real refresh \
8121             landed"
8122        );
8123        assert_eq!(
8124            a_dirty_at_after, a_dirty_at_before,
8125            "no status probe ran, so dirty's timestamp must be untouched, only its stale flag \
8126             set"
8127        );
8128        assert!(
8129            a_dirty_stale_after,
8130            "the moved entity's dirty cell must go stale on poll evidence"
8131        );
8132
8133        assert_eq!(
8134            branch_at(&b_after),
8135            branch_at(&b_before),
8136            "the untouched entity's branch must be exactly as the prior refresh left it"
8137        );
8138        let (b_dirty_value_after, b_dirty_at_after, b_dirty_stale_after) = dirty_state(&b_after);
8139        let (b_dirty_value_before, b_dirty_at_before, b_dirty_stale_before) =
8140            dirty_state(&b_before);
8141        assert_eq!(b_dirty_value_after, b_dirty_value_before);
8142        assert_eq!(b_dirty_at_after, b_dirty_at_before);
8143        assert_eq!(
8144            b_dirty_stale_after, b_dirty_stale_before,
8145            "an entity the sweep found unmoved must never go stale"
8146        );
8147    }
8148
8149    /// Criterion 3's attached half, and one of `refresh.md`'s two named traps: a
8150    /// commit on an attached HEAD never touches `.git/HEAD` at all, only
8151    /// `.git/logs/HEAD`. The poll must still see the commit, through `index`
8152    /// rather than through `HEAD`.
8153    #[test]
8154    fn poll_detects_an_attached_commit_through_index_while_head_itself_never_moves() {
8155        let dir = tempfile::tempdir().expect("temp dir");
8156        let root = root_of(&dir);
8157        let repo = root.join("repo");
8158        init_repo_with_a_commit(&repo);
8159
8160        let core = Core::start_discovered(spec(vec![root]));
8161        let key = core.snapshot().entities[0].key.clone();
8162        backdate_polled_entries(&repo);
8163        core.poll_once_for_test();
8164        assert!(core.poll_reprobed_for_test().is_empty());
8165
8166        let head_path = repo.join(".git").join("HEAD");
8167        let head_mtime_before = fs::metadata(&head_path)
8168            .expect("stat HEAD")
8169            .modified()
8170            .expect("HEAD mtime");
8171
8172        commit_a_change(&repo, "second");
8173
8174        let head_mtime_after = fs::metadata(&head_path)
8175            .expect("stat HEAD")
8176            .modified()
8177            .expect("HEAD mtime");
8178        assert_eq!(
8179            head_mtime_before, head_mtime_after,
8180            "a commit on an attached HEAD must never touch HEAD itself"
8181        );
8182
8183        core.poll_once_for_test();
8184        assert_eq!(
8185            core.poll_reprobed_for_test(),
8186            vec![key],
8187            "the poll must still detect the attached commit, through index rather than HEAD"
8188        );
8189    }
8190
8191    /// Criterion 3's detached half: [head.md](https://github.com/paulchiu/repon/blob/main/docs/spec/head.md)'s
8192    /// claim that a detached row's evidence is better than an attached row's,
8193    /// because a commit on a detached HEAD writes the new object id straight into
8194    /// the per-worktree `HEAD` file itself. Run against a real linked Worktree,
8195    /// never the main working tree, since that per-worktree file is exactly what
8196    /// distinguishes this case from the attached one above.
8197    #[test]
8198    fn poll_detects_a_detached_commit_through_the_per_worktree_head_file() {
8199        let dir = tempfile::tempdir().expect("temp dir");
8200        let root = root_of(&dir);
8201        let parent = root.join("parent");
8202        init_repo_with_a_commit(&parent);
8203        let worktree_path = root.join("detached-worktree");
8204        let status = Command::new("git")
8205            .arg("-C")
8206            .arg(&parent)
8207            .args([
8208                "worktree",
8209                "add",
8210                "--detach",
8211                worktree_path.to_str().expect("utf8 path"),
8212            ])
8213            .status()
8214            .expect("run git worktree add");
8215        assert!(status.success());
8216
8217        let core = Core::start_discovered(spec(vec![root]));
8218        let snapshot = core.snapshot();
8219        let worktree_key = snapshot
8220            .entities
8221            .iter()
8222            .find(|entity| matches!(entity.kind, Kind::Worktree))
8223            .expect("worktree discovered")
8224            .key
8225            .clone();
8226
8227        backdate_polled_entries(&parent);
8228        backdate_polled_entries(&worktree_path);
8229
8230        core.poll_once_for_test();
8231        assert!(core.poll_reprobed_for_test().is_empty());
8232
8233        let worktree_head_path = parent
8234            .join(".git")
8235            .join("worktrees")
8236            .join("detached-worktree")
8237            .join("HEAD");
8238        let head_mtime_before = fs::metadata(&worktree_head_path)
8239            .expect("stat the per-worktree HEAD")
8240            .modified()
8241            .expect("HEAD mtime");
8242
8243        commit_a_change(&worktree_path, "on the detached worktree");
8244
8245        let head_mtime_after = fs::metadata(&worktree_head_path)
8246            .expect("stat the per-worktree HEAD")
8247            .modified()
8248            .expect("HEAD mtime");
8249        assert_ne!(
8250            head_mtime_before, head_mtime_after,
8251            "a commit on a detached HEAD must write the new object id straight into its own \
8252             HEAD file"
8253        );
8254
8255        core.poll_once_for_test();
8256        assert_eq!(
8257            core.poll_reprobed_for_test(),
8258            vec![worktree_key],
8259            "the poll must detect the detached commit via the per-worktree HEAD file"
8260        );
8261    }
8262
8263    /// Criterion 4's elapsed-age writer, wired through `Core::snapshot` end to end:
8264    /// `status_stale_after` from `CoreSpec` is what decides whether a freshly
8265    /// landed `dirty` cell already reads Stale. A `Duration::from_nanos(1)`
8266    /// threshold has necessarily already elapsed by the time `snapshot` runs
8267    /// afterwards, so this needs no sleep and depends on no stated latency budget,
8268    /// only on real wall-clock time having advanced at all between two calls.
8269    #[test]
8270    fn snapshot_ages_a_freshly_landed_dirty_cell_stale_once_status_stale_after_has_elapsed() {
8271        let dir = tempfile::tempdir().expect("temp dir");
8272        let root = root_of(&dir);
8273        let repo = root.join("repo");
8274        init_repo_with_a_commit(&repo);
8275
8276        let mut short_lived = spec(vec![root]);
8277        short_lived.status_stale_after = Duration::from_nanos(1);
8278        let core = Core::start_discovered(short_lived);
8279        let key = core.snapshot().entities[0].key.clone();
8280        core.refresh(std::slice::from_ref(&key));
8281        core.settle();
8282
8283        let aged = core.snapshot();
8284        match aged.entities[0].dirty.settled() {
8285            Some(Settled::Known {
8286                stale: true,
8287                value: _,
8288                at: _,
8289            }) => {}
8290            other => panic!(
8291                "expected a landed dirty cell to have already aged past a one-nanosecond \
8292                 threshold, got {other:?}"
8293            ),
8294        }
8295    }
8296
8297    /// The same wiring's other side: a landed `dirty` cell stays fresh under a
8298    /// large `status_stale_after`, so the wiring is genuinely reading the
8299    /// threshold rather than always staling.
8300    #[test]
8301    fn snapshot_leaves_a_freshly_landed_dirty_cell_fresh_under_a_large_status_stale_after() {
8302        let dir = tempfile::tempdir().expect("temp dir");
8303        let root = root_of(&dir);
8304        let repo = root.join("repo");
8305        init_repo_with_a_commit(&repo);
8306
8307        let core = Core::start_discovered(spec(vec![root]));
8308        let key = core.snapshot().entities[0].key.clone();
8309        core.refresh(std::slice::from_ref(&key));
8310        core.settle();
8311
8312        let fresh = core.snapshot();
8313        match fresh.entities[0].dirty.settled() {
8314            Some(Settled::Known {
8315                stale: false,
8316                value: _,
8317                at: _,
8318            }) => {}
8319            other => panic!("expected a freshly landed dirty cell to stay fresh, got {other:?}"),
8320        }
8321    }
8322
8323    /// Criterion 5's absence claim: a hidden Submodule (`show_submodules` off) is
8324    /// never in the poll's own candidate set, so a commit into it is never
8325    /// detected, while the identical commit against the same Submodule shown is.
8326    /// Run as one test over the same fixture with the flag flipped, rather than
8327    /// two, so the only variable between the two sweeps is the flag itself.
8328    #[test]
8329    fn hidden_submodules_are_never_polled_but_shown_ones_are() {
8330        let dir = tempfile::tempdir().expect("temp dir");
8331        let root = root_of(&dir);
8332        let parent = root.join("parent");
8333        init_repo_with_a_commit(&parent);
8334        fs::write(
8335            parent.join(".gitmodules"),
8336            "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
8337        )
8338        .expect("write .gitmodules");
8339        let submodule_path = parent.join("vendor").join("lib");
8340        init_repo_with_a_commit(&submodule_path);
8341
8342        let mut hidden_spec = spec(vec![root.clone()]);
8343        hidden_spec.show_submodules = false;
8344        let hidden_core = Core::start_discovered(hidden_spec);
8345        // Discovery's own pass always runs regardless of the flag
8346        // (discovery.md's "Showing Submodules": "the pass always runs, so
8347        // Submodules are always known"), so the row exists; only probing and the
8348        // poll are gated on it.
8349        let hidden_submodule_key = hidden_core
8350            .snapshot()
8351            .entities
8352            .iter()
8353            .find(|entity| matches!(entity.kind, Kind::Submodule))
8354            .expect("the submodule is discovered regardless of show_submodules")
8355            .key
8356            .clone();
8357        backdate_polled_entries(&submodule_path);
8358        hidden_core.poll_once_for_test();
8359        commit_a_change(&submodule_path, "into the hidden submodule");
8360        hidden_core.poll_once_for_test();
8361        assert!(
8362            !hidden_core
8363                .poll_reprobed_for_test()
8364                .contains(&hidden_submodule_key),
8365            "a hidden Submodule must never be re-probed by the poll, since it was never \
8366             polled at all"
8367        );
8368        drop(hidden_core);
8369
8370        let mut shown_spec = spec(vec![root]);
8371        shown_spec.show_submodules = true;
8372        let shown_core = Core::start_discovered(shown_spec);
8373        let submodule_key = shown_core
8374            .snapshot()
8375            .entities
8376            .iter()
8377            .find(|entity| matches!(entity.kind, Kind::Submodule))
8378            .expect("the submodule is discovered regardless of show_submodules")
8379            .key
8380            .clone();
8381        backdate_polled_entries(&submodule_path);
8382        shown_core.poll_once_for_test();
8383        commit_a_change(&submodule_path, "into the shown submodule");
8384        shown_core.poll_once_for_test();
8385        assert_eq!(
8386            shown_core.poll_reprobed_for_test(),
8387            vec![submodule_key],
8388            "a shown Submodule must be polled and re-probed exactly like any other row"
8389        );
8390    }
8391
8392    /// Pause cancels a real in-flight entry (not merely stores a flag nobody
8393    /// reads): the cancel flag `begin_untracked_probe_for_test` returns is
8394    /// observed `true` afterward, and `settle` unblocks because pause released it,
8395    /// which is only possible if pause's handler on the dedicated thread actually
8396    /// ran.
8397    #[test]
8398    fn pause_cancels_every_in_flight_entity_and_releases_a_pending_settle() {
8399        let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
8400        let dir = tempfile::tempdir().expect("temp dir");
8401        let root = root_of(&dir);
8402        let repo = root.join("repo");
8403        init_repo_with_a_commit(&repo);
8404
8405        let started =
8406            Core::start_for_test(spec(vec![root]), Duration::from_secs(3600), tick_rx).discovered();
8407        let core = started.core;
8408        // Drained, so the only entry in flight below is the one this test puts there.
8409        let key = settle_launch(&core).entities[0].key.clone();
8410        let cancel = core.begin_untracked_probe_for_test(&key);
8411        assert!(!cancel.load(Ordering::Acquire));
8412
8413        core.pause();
8414        let settled = core.settle();
8415
8416        assert!(
8417            cancel.load(Ordering::Acquire),
8418            "pause should cancel the entity that was in flight"
8419        );
8420        assert!(settled.entities[0].branch.is_in_flight());
8421        drop(tick_tx);
8422    }
8423
8424    /// A launch walks the tree once.
8425    ///
8426    /// Discovery rides on every Generation
8427    /// ([refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
8428    /// "Discovery is never on the calling thread"), so counting a launch's walks is
8429    /// counting its Generations: one walk means the very first Generation a fresh `Core`
8430    /// mints is the only one a settled launch has, and that it already covers every row
8431    /// the walk found. A second walk would be a second Generation and would read here.
8432    #[test]
8433    fn a_launch_is_one_generation_over_every_row_its_own_walk_found() {
8434        let dir = tempfile::tempdir().expect("temp dir");
8435        let root = root_of(&dir);
8436        init_repo_with_a_commit(&root.join("first"));
8437        init_repo_with_a_commit(&root.join("second"));
8438
8439        let (_core, launched) = started_and_settled(spec(vec![root]));
8440
8441        assert_eq!(
8442            launched.generation,
8443            Generation::default().successor(),
8444            "a launch must settle on the first Generation a fresh `Core` mints; a second \
8445             walk of the same tree would be a second Generation"
8446        );
8447        let mut named: Vec<String> = launched
8448            .entities
8449            .iter()
8450            .filter(|entity| entity.branch.settled().is_some())
8451            .map(|entity| entity.name.to_string())
8452            .collect();
8453        named.sort();
8454        assert_eq!(
8455            named,
8456            vec!["first".to_string(), "second".to_string()],
8457            "that one Generation must cover every row its own walk found, or the walk it \
8458             saved would have to be paid by a second one"
8459        );
8460    }
8461
8462    /// A `Core` going away cancels what it still has in flight, the same way `pause` does.
8463    ///
8464    /// [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
8465    /// "Cancellation": an abandoned Generation is cancelled rather than left to finish,
8466    /// because both would contend for the same cores. A Set switch is where that bites,
8467    /// rebuilding the `Core` while the outgoing one's fan-out is still running.
8468    #[test]
8469    fn dropping_a_core_cancels_every_entity_it_still_has_in_flight() {
8470        let dir = tempfile::tempdir().expect("temp dir");
8471        let root = root_of(&dir);
8472        init_repo_with_a_commit(&root.join("repo"));
8473
8474        let (core, launched) = started_and_settled(spec(vec![root]));
8475        let key = launched.entities[0].key.clone();
8476        let cancel = core.begin_untracked_probe_for_test(&key);
8477        assert!(!cancel.load(Ordering::Acquire));
8478
8479        drop(core);
8480
8481        assert!(
8482            cancel.load(Ordering::Acquire),
8483            "a dropped Core must cancel the Generation it still has in flight rather than \
8484             leave it running against a Set nothing will read again"
8485        );
8486    }
8487
8488    /// Per-entity supersession, not global. An older Generation covers two entities,
8489    /// A and B, both simulated as still in flight. A Selection-scoped newer
8490    /// Generation covers only A: A's own older interrupt flag must be set, and B's
8491    /// must not, since the newer one never mentions B. Once the newer Generation has
8492    /// written A's cell, A's slow older result finally arrives and must be dropped
8493    /// there; B's own older result, arriving after everything else, must still be
8494    /// accepted, because the newer Generation never superseded it.
8495    ///
8496    /// The two are named by their order, never by their counter values, so a
8497    /// Generation minted earlier in the crate cannot renumber this test out from
8498    /// under itself.
8499    ///
8500    /// This is exactly the distinction a global-current-Generation comparison
8501    /// would get wrong: such a check compares every write against the table's one
8502    /// counter, which the Selection-scoped refresh has already advanced, so B's
8503    /// older result would be wrongly dropped even though nothing ever superseded B
8504    /// specifically. Before `Cell::settle`'s comparison was wired
8505    /// against the cell's own recorded Generation this test failed exactly there:
8506    /// B's late result was rejected, which is precisely the "cannot strand the
8507    /// rows it never spoke for" defect the ticket names.
8508    ///
8509    /// This test read A's interrupt flag intermittently false under load. The cause was
8510    /// `apply_probe_outcome` clearing the in-flight entry by key alone: launch's own
8511    /// Generation was left undrained here, so one of its probes could finish after the
8512    /// simulated older Generation had put its flags under the same keys and delete the
8513    /// entry holding them, leaving the Selection-scoped refresh nothing to supersede.
8514    /// Launch is drained first now, and the entry is cleared by Generation as well as by
8515    /// key, which `a_probe_finishing_clears_only_its_own_generations_in_flight_entry`
8516    /// pins directly.
8517    #[test]
8518    fn a_selection_scoped_refresh_supersedes_only_the_entity_it_covers() {
8519        let dir = tempfile::tempdir().expect("temp dir");
8520        let root = root_of(&dir);
8521        init_repo_with_a_commit(&root.join("a"));
8522        init_repo_with_a_commit(&root.join("b"));
8523
8524        let (core, snapshot) = started_and_settled(spec(vec![root]));
8525        let key_a = snapshot
8526            .entities
8527            .iter()
8528            .find(|entity| &*entity.name == "a")
8529            .expect("entity a discovered")
8530            .key
8531            .clone();
8532        let key_b = snapshot
8533            .entities
8534            .iter()
8535            .find(|entity| &*entity.name == "b")
8536            .expect("entity b discovered")
8537            .key
8538            .clone();
8539
8540        // The older Generation, simulated: both A and B are mid-flight, with nothing
8541        // spawned to complete either one, so the test controls exactly when each
8542        // one's result lands.
8543        let older = core.begin_shared_generation_for_test(&[key_a.clone(), key_b.clone()]);
8544
8545        // A Selection-scoped refresh over A alone, the very next Generation after the
8546        // one still in flight.
8547        let newer = core.refresh(std::slice::from_ref(&key_a));
8548        assert_eq!(
8549            newer,
8550            older.generation.successor(),
8551            "the Selection-scoped refresh must be the Generation immediately after the one \
8552             still in flight, with nothing minted in between"
8553        );
8554
8555        // Supersession happens on the new Generation's own thread, behind its walk, so
8556        // this is the rendezvous that says it has happened. A join, never a deadline: no
8557        // production rule bounds how long that walk takes short of the thirty seconds at
8558        // which discovery is abandoned.
8559        core.wait_dispatched_for_test();
8560        assert!(
8561            older.cancels[&key_a].load(Ordering::Acquire),
8562            "the entity the new Generation covers must have its old interrupt flag set"
8563        );
8564        assert!(
8565            !older.cancels[&key_b].load(Ordering::Acquire),
8566            "an entity the new Generation does not cover must be left running, untouched"
8567        );
8568
8569        // [`BACKSTOP`] rather than a budget: what follows reads the cell the new
8570        // Generation's own probe writes, which is a liveness property with no wall-clock
8571        // bound of its own.
8572        let after_refresh = core.settle();
8573
8574        let a_after_gen2 = after_refresh
8575            .entities
8576            .iter()
8577            .find(|entity| entity.key == key_a)
8578            .expect("entity a present");
8579        assert!(
8580            matches!(
8581                a_after_gen2.branch.settled(),
8582                Some(Settled::Known {
8583                    value: Head::Branch { .. },
8584                    at: _,
8585                    stale: _
8586                })
8587            ),
8588            "the newer Generation's real probe should have written A's cell by now"
8589        );
8590
8591        // A's slow older result finally arrives, after the newer Generation has
8592        // already written the cell: dropped, since it is lower than the Generation
8593        // already recorded there.
8594        core.apply_probe_result_for_test(
8595            &key_a,
8596            older.generation,
8597            Settled::Known {
8598                value: Head::Branch {
8599                    name: Arc::from("stale-from-generation-one"),
8600                    commit: gix::hash::Kind::Sha1.null(),
8601                },
8602                at: Timestamp::now(),
8603                stale: false,
8604            },
8605        );
8606        let after_stale_write = core.snapshot();
8607        let a_final = after_stale_write
8608            .entities
8609            .iter()
8610            .find(|entity| entity.key == key_a)
8611            .expect("entity a present");
8612        match a_final.branch.settled() {
8613            Some(Settled::Known {
8614                value: Head::Branch { name, .. },
8615                at: _,
8616                stale: _,
8617            }) => assert_ne!(
8618                &**name, "stale-from-generation-one",
8619                "a lower-Generation result must be dropped at the cell it would write"
8620            ),
8621            other => panic!("expected A to still hold the newer Generation's value, got {other:?}"),
8622        }
8623
8624        // B's own older result, landing last of all, is still accepted: the newer
8625        // Generation never covered B, so nothing superseded it.
8626        core.apply_probe_result_for_test(
8627            &key_b,
8628            older.generation,
8629            Settled::Known {
8630                value: Head::Branch {
8631                    name: Arc::from("b-generation-one-result"),
8632                    commit: gix::hash::Kind::Sha1.null(),
8633                },
8634                at: Timestamp::now(),
8635                stale: false,
8636            },
8637        );
8638        let final_snapshot = core.snapshot();
8639        let b_final = final_snapshot
8640            .entities
8641            .iter()
8642            .find(|entity| entity.key == key_b)
8643            .expect("entity b present");
8644        match b_final.branch.settled() {
8645            Some(Settled::Known {
8646                value: Head::Branch { name, .. },
8647                at: _,
8648                stale: _,
8649            }) => assert_eq!(
8650                &**name, "b-generation-one-result",
8651                "an entity the new Generation never covered must still accept its own result"
8652            ),
8653            other => {
8654                panic!("expected B's un-superseded older result to be accepted, got {other:?}")
8655            }
8656        }
8657    }
8658
8659    /// The deadline sweep abandons only what is still Loading when it fires. An
8660    /// entity already settled by the time the deadline sweep runs keeps its value
8661    /// untouched, blanking nothing, while a different entity still mid-flight in
8662    /// the same sweep becomes Unknown with the timed-out reason.
8663    #[test]
8664    fn the_deadline_sweep_keeps_already_settled_cells_and_only_times_out_what_is_still_loading() {
8665        let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
8666        let dir = tempfile::tempdir().expect("temp dir");
8667        let root = root_of(&dir);
8668        init_repo_with_a_commit(&root.join("a"));
8669        init_repo_with_a_commit(&root.join("b"));
8670
8671        let mut spec = spec(vec![root]);
8672        spec.generation_deadline = Duration::ZERO;
8673        let started = Core::start_for_test(spec, Duration::from_secs(3600), tick_rx).discovered();
8674        let core = started.core;
8675        // Drained, so the only cell still loading when the sweep fires is the one this
8676        // test puts in flight.
8677        let snapshot = settle_launch(&core);
8678        let key_a = snapshot
8679            .entities
8680            .iter()
8681            .find(|entity| &*entity.name == "a")
8682            .expect("entity a discovered")
8683            .key
8684            .clone();
8685        let key_b = snapshot
8686            .entities
8687            .iter()
8688            .find(|entity| &*entity.name == "b")
8689            .expect("entity b discovered")
8690            .key
8691            .clone();
8692
8693        // A is already settled, synchronously, before the deadline ever has a
8694        // chance to fire.
8695        let a_settled = core.probe_now(&key_a);
8696        let a_value_before = match a_settled.branch.settled() {
8697            Some(Settled::Known {
8698                value: Head::Branch { name, .. },
8699                at: _,
8700                stale: _,
8701            }) => Arc::clone(name),
8702            other => panic!("expected A's synchronous probe to settle a branch, got {other:?}"),
8703        };
8704
8705        // B is left mid-flight, in a Generation whose (zero) deadline has already
8706        // elapsed in real time, but the sweep has not run yet: no tick has been
8707        // sent.
8708        let cancel_b = core.begin_untracked_probe_for_test(&key_b);
8709        let before_tick = core.snapshot();
8710        let b_before = before_tick
8711            .entities
8712            .iter()
8713            .find(|entity| entity.key == key_b)
8714            .expect("entity b present");
8715        assert!(
8716            b_before.branch.is_in_flight(),
8717            "B must be mid-flight when the sweep fires; that is the only shape the sweep \
8718             may touch"
8719        );
8720        assert!(
8721            matches!(
8722                b_before.branch.settled(),
8723                Some(Settled::Known {
8724                    value: _,
8725                    at: _,
8726                    stale: _
8727                })
8728            ),
8729            "B still carries launch's own answer here, so the Unknown below is a write the \
8730             sweep made rather than a cell that was already empty, got {:?}",
8731            b_before.branch.settled()
8732        );
8733
8734        tick_tx.send(Instant::now()).expect("send one tick");
8735        let after_sweep = core.settle();
8736
8737        let a_after = after_sweep
8738            .entities
8739            .iter()
8740            .find(|entity| entity.key == key_a)
8741            .expect("entity a present");
8742        match a_after.branch.settled() {
8743            Some(Settled::Known {
8744                value: Head::Branch { name, .. },
8745                at: _,
8746                stale: _,
8747            }) => assert_eq!(
8748                name, &a_value_before,
8749                "an already-settled cell must keep its value when the deadline sweep runs, not be blanked"
8750            ),
8751            other => panic!("expected A's settled value to survive the sweep, got {other:?}"),
8752        }
8753
8754        let b_after = after_sweep
8755            .entities
8756            .iter()
8757            .find(|entity| entity.key == key_b)
8758            .expect("entity b present");
8759        assert!(matches!(
8760            b_after.branch.settled(),
8761            Some(Settled::Unknown(Unknown::TimedOut))
8762        ));
8763        assert!(
8764            !cancel_b.load(Ordering::Acquire),
8765            "the deadline sweep marks a cell Unknown; it never sets the entity's own \
8766             cancel flag, since the underlying probe (nonexistent here) is left to keep running"
8767        );
8768    }
8769
8770    /// The deadline sweep must reach a Worktree's outstanding `state` cell the
8771    /// same way it already reaches `branch` and `default_branch`: asking and
8772    /// getting nothing back is Unknown, not a cell stuck in-flight forever once
8773    /// the Generation that would have answered it is gone. A Repo's `state`,
8774    /// `NotApplicable` from construction and never in flight, must survive the
8775    /// same sweep untouched, proving the sweep only times out a cell actually
8776    /// marked in flight rather than blanket-settling every entity's `state` cell.
8777    #[test]
8778    fn the_deadline_sweep_times_out_a_worktrees_outstanding_state_but_leaves_a_repos_not_applicable_one_alone()
8779     {
8780        let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
8781        let dir = tempfile::tempdir().expect("temp dir");
8782        let root = root_of(&dir);
8783        let parent = root.join("parent");
8784        init_repo_with_a_commit(&parent);
8785        let worktree_path = root.join("feature-worktree");
8786        git(
8787            &parent,
8788            &[
8789                "worktree",
8790                "add",
8791                "-b",
8792                "feature",
8793                worktree_path.to_str().expect("utf8 path"),
8794            ],
8795        );
8796
8797        let mut spec = spec(vec![root]);
8798        spec.generation_deadline = Duration::ZERO;
8799        let started = Core::start_for_test(spec, Duration::from_secs(3600), tick_rx).discovered();
8800        let core = started.core;
8801        // Drained, so launch's own real refresh has already landed on every cell before
8802        // either `begin_untracked_probe_for_test` call below puts one artificially back in
8803        // flight: skipping this left a real probe for the same cell free to settle it
8804        // between that call and the sweep, which turns the sweep's own `is_in_flight`
8805        // guard (working exactly as designed, since a cell no longer loading is not the
8806        // sweep's to touch) into a race the assertion below loses however rarely.
8807        let snapshot = settle_launch(&core);
8808        let repo_key = snapshot
8809            .entities
8810            .iter()
8811            .find(|entity| matches!(entity.kind, Kind::Repo))
8812            .expect("repo entity present")
8813            .key
8814            .clone();
8815        let worktree_key = snapshot
8816            .entities
8817            .iter()
8818            .find(|entity| matches!(entity.kind, Kind::Worktree))
8819            .expect("worktree entity present")
8820            .key
8821            .clone();
8822
8823        // Both left mid-flight in a Generation whose (zero) deadline has already
8824        // elapsed, with no tick sent yet, mirroring how `Core::refresh` begins a
8825        // Worktree's `state` probe alongside `branch`. The Repo is in flight too
8826        // (on `branch` only, per the same gate), so the sweep actually reaches
8827        // it and the guard has something real to prove.
8828        core.begin_untracked_probe_for_test(&repo_key);
8829        core.begin_untracked_probe_for_test(&worktree_key);
8830
8831        tick_tx.send(Instant::now()).expect("send one tick");
8832        let after_sweep = core.settle();
8833
8834        let worktree_after = after_sweep
8835            .entities
8836            .iter()
8837            .find(|entity| entity.key == worktree_key)
8838            .expect("worktree entity present");
8839        assert!(
8840            matches!(
8841                worktree_after.state.settled(),
8842                Some(Settled::Unknown(Unknown::TimedOut))
8843            ),
8844            "expected the outstanding state cell to time out, got {:?}",
8845            worktree_after.state.settled()
8846        );
8847
8848        let repo_after = after_sweep
8849            .entities
8850            .iter()
8851            .find(|entity| entity.key == repo_key)
8852            .expect("repo entity present");
8853        assert!(
8854            matches!(repo_after.state.settled(), Some(Settled::NotApplicable)),
8855            "a Repo's Not applicable state must survive the sweep untouched, got {:?}",
8856            repo_after.state.settled()
8857        );
8858    }
8859
8860    /// Criterion 2's "never goes stale on a poll" made behavioural: the dedicated thread's
8861    /// tick-driven sweep is what a poll is in this codebase today (`spawn_clock_thread` calls
8862    /// [`sweep_deadline`] on every tick), and it must leave a receipt exactly as it was even
8863    /// while it is busy timing out a genuinely outstanding Cell on the very same entity.
8864    #[test]
8865    fn the_deadline_sweeps_poll_never_touches_an_entitys_action_receipt() {
8866        let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
8867        let dir = tempfile::tempdir().expect("temp dir");
8868        let root = root_of(&dir);
8869        let repo = root.join("repo");
8870        init_repo_with_a_commit(&repo);
8871
8872        let mut spec = spec(vec![root]);
8873        spec.generation_deadline = Duration::ZERO;
8874        let started = Core::start_for_test(spec, Duration::from_secs(3600), tick_rx).discovered();
8875        let core = started.core;
8876        // Drained, so the only entry the sweep below finds in flight is this test's own.
8877        let key = settle_launch(&core).entities[0].key.clone();
8878
8879        let receipt = crate::entity::ActionReceipt {
8880            label: Arc::from("reinstall"),
8881            steps: Arc::from(vec![crate::entity::StepResult {
8882                label: Arc::from("pnpm install"),
8883                outcome: crate::entity::StepOutcome::Ok,
8884                output: Arc::from(&b""[..]),
8885                elapsed: Duration::from_millis(1),
8886                elision: None,
8887                shell: false,
8888                interactive: false,
8889            }]),
8890            skip: None,
8891            finished_at: Timestamp::now(),
8892            running: None,
8893        };
8894        core.set_last_action_for_test(&key, receipt.clone());
8895
8896        // Left mid-flight in a Generation whose (zero) deadline has already elapsed, so the
8897        // sweep this tick triggers has a real Cell to time out on this very entity.
8898        core.begin_untracked_probe_for_test(&key);
8899        tick_tx.send(Instant::now()).expect("send one tick");
8900        let after = core.settle();
8901
8902        let entity = after
8903            .entities
8904            .iter()
8905            .find(|entity| entity.key == key)
8906            .expect("entity present");
8907        assert!(
8908            matches!(
8909                entity.branch.settled(),
8910                Some(Settled::Unknown(Unknown::TimedOut))
8911            ),
8912            "sanity check: the sweep must have actually timed out the in-flight cell, got {:?}",
8913            entity.branch.settled()
8914        );
8915        assert_eq!(entity.last_action, Some(receipt));
8916    }
8917
8918    /// Cancellation observed before a probe's very first read stops it from ever
8919    /// opening the repository at all, proven behaviourally rather than by
8920    /// re-reading the flag: a path that does not exist would settle as
8921    /// `Failed(Open(_))` if the open call actually ran, so getting `None` back
8922    /// instead is only possible if the read never started. This is the honest
8923    /// limit of what phase A can prove: `git::head_shape` is one syscall with no
8924    /// interruption point mid-read, so cancellation here stops work that has not
8925    /// started rather than work already running. [`classify_status_result_drops_an_error_once_cancel_reads_true`]
8926    /// covers the genuinely interruptible phase this crate now has.
8927    #[test]
8928    fn a_cancelled_probe_never_opens_the_repository_at_all() {
8929        let cancel = AtomicBool::new(true);
8930
8931        let outcome = probe_branch(
8932            Path::new("/nonexistent/nowhere-at-all"),
8933            None,
8934            Kind::Repo,
8935            &cancel,
8936        );
8937
8938        assert!(
8939            outcome.is_none(),
8940            "a probe observing cancellation before its first read must do no work \
8941             at all, not attempt the read and fail having tried it"
8942        );
8943    }
8944
8945    /// Phase C's own cancellation shape, distinct from phase A and B's "before the read
8946    /// starts" check: gix can report a genuinely mid-read cancellation as an `Err`
8947    /// (`dirty_counts_threads_the_cancel_flag_into_gix` in `git.rs` proves the flag actually
8948    /// reaches gix, which is what makes that `Err` possible at all), and this test covers the
8949    /// half that lives here, that `classify_status_result` folds that error back to `None`
8950    /// rather than `Settled::Failed` once `cancel` reads `true`, per ADR 0013's "interrupted
8951    /// work becomes Unknown rather than Failed". A mutation that dropped the `cancel`-aware
8952    /// arm (always settling `Failed` on any error, the way the cheaper phases' own errors do)
8953    /// fails this directly.
8954    #[test]
8955    fn classify_status_result_drops_an_error_once_cancel_reads_true() {
8956        let cancel = AtomicBool::new(true);
8957
8958        let outcome = classify_status_result(
8959            Err(crate::git::ProbeError::Status(Arc::from("boom"))),
8960            &cancel,
8961        );
8962
8963        assert!(
8964            outcome.is_none(),
8965            "an error alongside a cancel flag already set must read as cancelled, not \
8966             Failed, got {outcome:?}"
8967        );
8968    }
8969
8970    /// The other side of the same fold: an error with `cancel` still `false` is a genuine
8971    /// failure and must settle `Failed`, not be silently dropped the way a cancelled read is.
8972    #[test]
8973    fn classify_status_result_settles_failed_when_cancel_never_fired() {
8974        let cancel = AtomicBool::new(false);
8975
8976        let outcome = classify_status_result(
8977            Err(crate::git::ProbeError::Status(Arc::from("boom"))),
8978            &cancel,
8979        );
8980
8981        assert!(
8982            matches!(outcome, Some(Settled::Failed(git::ProbeError::Status(_)))),
8983            "a genuine error with no cancellation must settle Failed, got {outcome:?}"
8984        );
8985    }
8986
8987    /// gix polls `should_interrupt` per index entry rather than before every read, so a walk
8988    /// short enough to finish between checks (or with nothing left to check against) can
8989    /// complete and return `Ok` even though `cancel` was set part way through it. Settling
8990    /// that `Ok` anyway would let a cancelled generation write a value, exactly the outcome
8991    /// [refresh.md's "Cancellation"](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)
8992    /// says cancellation prevents. `classify_status_result` must re-check the same flag it
8993    /// owns on the `Ok` arm too, not only on `Err`, and drop the value the same way a
8994    /// cancelled `Err` is already dropped.
8995    #[test]
8996    fn classify_status_result_drops_an_ok_once_cancel_reads_true() {
8997        let cancel = AtomicBool::new(true);
8998
8999        let outcome = classify_status_result(Ok(DirtyCounts::default()), &cancel);
9000
9001        assert!(
9002            outcome.is_none(),
9003            "an Ok value that raced ahead of a cancel flag now set must read as cancelled, \
9004             not be settled Known, got {outcome:?}"
9005        );
9006    }
9007
9008    /// The other side of the same fold: an `Ok` with `cancel` still `false` is a genuine
9009    /// completed read and must settle `Known`, not be silently dropped.
9010    #[test]
9011    fn classify_status_result_settles_known_when_cancel_never_fired() {
9012        let cancel = AtomicBool::new(false);
9013        let counts = DirtyCounts {
9014            modified: 1,
9015            untracked: 2,
9016            deleted: 3,
9017        };
9018
9019        let outcome = classify_status_result(Ok(counts), &cancel);
9020
9021        assert!(
9022            matches!(
9023                outcome,
9024                Some(Settled::Known {
9025                    value,
9026                    at: _,
9027                    stale: _
9028                }) if value == counts
9029            ),
9030            "a genuine completed read with no cancellation must settle Known, got {outcome:?}"
9031        );
9032    }
9033
9034    /// The defining behaviour: a linked Worktree shares its parent's object store
9035    /// and remotes, but `Core` must still surface it as its own row rather than
9036    /// folding it into the Repo it is attached to. A real `git worktree add` is run
9037    /// against a genuine parent so the proof covers git's actual on-disk shape, not
9038    /// a hand-built stand-in for it.
9039    #[test]
9040    fn a_linked_worktree_is_its_own_entity_and_never_doubles_as_a_repo() {
9041        let dir = tempfile::tempdir().expect("temp dir");
9042        let root = root_of(&dir);
9043        let parent = root.join("parent");
9044        init_repo_with_a_commit(&parent);
9045        let worktree_path = root.join("feature-worktree");
9046        let status = Command::new("git")
9047            .arg("-C")
9048            .arg(&parent)
9049            .args([
9050                "worktree",
9051                "add",
9052                "-b",
9053                "feature",
9054                worktree_path.to_str().expect("utf8 path"),
9055            ])
9056            .status()
9057            .expect("run git worktree add");
9058        assert!(status.success());
9059
9060        let core = Core::start_discovered(spec(vec![root]));
9061        let snapshot = core.snapshot();
9062
9063        assert_eq!(
9064            snapshot.entities.len(),
9065            2,
9066            "expected the parent plus one Worktree, not two Repos"
9067        );
9068        let repo_count = snapshot
9069            .entities
9070            .iter()
9071            .filter(|entity| matches!(entity.kind, Kind::Repo))
9072            .count();
9073        let worktree_count = snapshot
9074            .entities
9075            .iter()
9076            .filter(|entity| matches!(entity.kind, Kind::Worktree))
9077            .count();
9078        assert_eq!(
9079            repo_count, 1,
9080            "the parent must be counted as exactly one Repo"
9081        );
9082        assert_eq!(
9083            worktree_count, 1,
9084            "the linked worktree must be counted as exactly one Worktree"
9085        );
9086
9087        let worktree_entity = snapshot
9088            .entities
9089            .iter()
9090            .find(|entity| matches!(entity.kind, Kind::Worktree))
9091            .expect("worktree entity present");
9092        let repo_entity = snapshot
9093            .entities
9094            .iter()
9095            .find(|entity| matches!(entity.kind, Kind::Repo))
9096            .expect("repo entity present");
9097        assert_eq!(worktree_entity.common_dir, repo_entity.common_dir);
9098
9099        // Each carries its own branch: the parent stayed on its default branch and
9100        // the worktree checked out `feature`.
9101        let repo_branch = core.probe_now(&repo_entity.key);
9102        let worktree_branch = core.probe_now(&worktree_entity.key);
9103        match (
9104            repo_branch.branch.settled(),
9105            worktree_branch.branch.settled(),
9106        ) {
9107            (
9108                Some(Settled::Known {
9109                    value:
9110                        Head::Branch {
9111                            name: repo_name, ..
9112                        },
9113                    at: _,
9114                    stale: _,
9115                }),
9116                Some(Settled::Known {
9117                    value:
9118                        Head::Branch {
9119                            name: worktree_name,
9120                            ..
9121                        },
9122                    at: _,
9123                    stale: _,
9124                }),
9125            ) => {
9126                assert_ne!(repo_name, worktree_name);
9127                assert_eq!(&**worktree_name, "feature");
9128            }
9129            other => panic!("expected both entities to read an attached branch, got {other:?}"),
9130        }
9131    }
9132
9133    /// End-to-end proof that `state` is actually wired into a real Generation:
9134    /// a linked Worktree whose branch is an ancestor of the default branch reads
9135    /// `Merged` after a real `refresh`, not merely in `landing`'s own unit tests.
9136    #[test]
9137    fn a_worktrees_branch_that_is_an_ancestor_of_the_default_branch_reads_merged_after_a_refresh() {
9138        let dir = tempfile::tempdir().expect("temp dir");
9139        let root = root_of(&dir);
9140        let parent = root.join("parent");
9141        init_repo_with_a_commit(&parent);
9142        git(
9143            &parent,
9144            &[
9145                "remote",
9146                "add",
9147                "origin",
9148                "https://example.invalid/repo.git",
9149            ],
9150        );
9151        let sha = head_sha(&parent);
9152        git(&parent, &["update-ref", "refs/remotes/origin/main", &sha]);
9153        let worktree_path = root.join("feature-worktree");
9154        git(
9155            &parent,
9156            &[
9157                "worktree",
9158                "add",
9159                "-b",
9160                "feature",
9161                worktree_path.to_str().expect("utf8 path"),
9162            ],
9163        );
9164
9165        let core = Core::start_discovered(spec(vec![root]));
9166        let keys: Vec<EntityKey> = core
9167            .snapshot()
9168            .entities
9169            .iter()
9170            .map(|entity| entity.key.clone())
9171            .collect();
9172
9173        core.refresh(&keys);
9174        let settled = core.settle();
9175
9176        let worktree_entity = settled
9177            .entities
9178            .iter()
9179            .find(|entity| matches!(entity.kind, Kind::Worktree))
9180            .expect("worktree entity present");
9181        assert!(
9182            matches!(
9183                worktree_entity.state.settled(),
9184                Some(Settled::Known {
9185                    value: WorktreeState::Merged,
9186                    at: _,
9187                    stale: _
9188                })
9189            ),
9190            "expected the worktree, at the same commit as the default branch, to read Merged, got {:?}",
9191            worktree_entity.state.settled()
9192        );
9193    }
9194
9195    /// The squash merge this whole ticket is named for, proven end to end
9196    /// through a real `refresh`: `feature`'s two commits are squashed into one
9197    /// commit on the default branch, so ancestry cannot see it (`feature`'s tip
9198    /// never becomes an ancestor), and only patch equivalence can. Its upstream
9199    /// tracking ref still resolves, matching the moment right after a squash
9200    /// merge and before the next prune removes it, which is what routes this
9201    /// entity through `Outstanding` into the second pass rather than settling
9202    /// `Gone` at the first.
9203    #[test]
9204    fn a_squash_merged_worktree_branch_reads_merged_after_a_refresh() {
9205        let dir = tempfile::tempdir().expect("temp dir");
9206        let root = root_of(&dir);
9207        let parent = root.join("parent");
9208        init_repo_with_a_commit(&parent);
9209        git(
9210            &parent,
9211            &[
9212                "remote",
9213                "add",
9214                "origin",
9215                "https://example.invalid/repo.git",
9216            ],
9217        );
9218        let worktree_path = root.join("feature-worktree");
9219        git(
9220            &parent,
9221            &[
9222                "worktree",
9223                "add",
9224                "-b",
9225                "feature",
9226                worktree_path.to_str().expect("utf8 path"),
9227            ],
9228        );
9229        fs::write(worktree_path.join("a.txt"), "one\n").expect("write a.txt");
9230        git(&worktree_path, &["add", "a.txt"]);
9231        git(&worktree_path, &["commit", "-m", "add a"]);
9232        fs::write(worktree_path.join("b.txt"), "two\n").expect("write b.txt");
9233        git(&worktree_path, &["add", "b.txt"]);
9234        git(&worktree_path, &["commit", "-m", "add b"]);
9235        let feature_sha = head_sha(&worktree_path);
9236
9237        // Squashed into the parent's own checkout, which is what the default
9238        // branch resolves against.
9239        git(&parent, &["merge", "--squash", "feature"]);
9240        git(&parent, &["commit", "-m", "squashed feature"]);
9241        let main_sha = head_sha(&parent);
9242        git(
9243            &parent,
9244            &["update-ref", "refs/remotes/origin/main", &main_sha],
9245        );
9246
9247        // `feature`'s own upstream, still resolving: the moment before a prune
9248        // removes it.
9249        git(&parent, &["config", "branch.feature.remote", "origin"]);
9250        git(
9251            &parent,
9252            &["config", "branch.feature.merge", "refs/heads/feature"],
9253        );
9254        git(
9255            &parent,
9256            &["update-ref", "refs/remotes/origin/feature", &feature_sha],
9257        );
9258
9259        let core = Core::start_discovered(spec(vec![root]));
9260        let keys: Vec<EntityKey> = core
9261            .snapshot()
9262            .entities
9263            .iter()
9264            .map(|entity| entity.key.clone())
9265            .collect();
9266
9267        core.refresh(&keys);
9268        let settled = core.settle();
9269
9270        let worktree_entity = settled
9271            .entities
9272            .iter()
9273            .find(|entity| matches!(entity.kind, Kind::Worktree))
9274            .expect("worktree entity present");
9275        assert!(
9276            matches!(
9277                worktree_entity.state.settled(),
9278                Some(Settled::Known {
9279                    value: WorktreeState::Merged,
9280                    at: _,
9281                    stale: _
9282                })
9283            ),
9284            "expected a squash-merged worktree branch to read Merged, got {:?}",
9285            worktree_entity.state.settled()
9286        );
9287    }
9288
9289    /// Proves the negative the state cell alone cannot: patch equivalence's
9290    /// expensive scan must never even start for an entity ancestry already
9291    /// settled. A Worktree whose branch is an ancestor of the default branch
9292    /// settles `Merged` at the first pass, so the only common dir in this test
9293    /// must show zero scans; a `state`-only assertion would still pass an
9294    /// implementation that ran the second pass over every entity and discarded
9295    /// whichever answer ancestry had already provided.
9296    #[test]
9297    fn patch_equivalence_never_runs_for_an_entity_ancestry_already_settled() {
9298        let dir = tempfile::tempdir().expect("temp dir");
9299        let root = root_of(&dir);
9300        let parent = root.join("parent");
9301        init_repo_with_a_commit(&parent);
9302        git(
9303            &parent,
9304            &[
9305                "remote",
9306                "add",
9307                "origin",
9308                "https://example.invalid/repo.git",
9309            ],
9310        );
9311        let sha = head_sha(&parent);
9312        git(&parent, &["update-ref", "refs/remotes/origin/main", &sha]);
9313        let worktree_path = root.join("feature-worktree");
9314        git(
9315            &parent,
9316            &[
9317                "worktree",
9318                "add",
9319                "-b",
9320                "feature",
9321                worktree_path.to_str().expect("utf8 path"),
9322            ],
9323        );
9324
9325        let (core, launched) = started_and_settled(spec(vec![root]));
9326        let keys: Vec<EntityKey> = launched
9327            .entities
9328            .iter()
9329            .map(|entity| entity.key.clone())
9330            .collect();
9331
9332        core.refresh(&keys);
9333        let settled = core.settle();
9334
9335        let worktree_entity = settled
9336            .entities
9337            .iter()
9338            .find(|entity| matches!(entity.kind, Kind::Worktree))
9339            .expect("worktree entity present");
9340        assert!(
9341            matches!(
9342                worktree_entity.state.settled(),
9343                Some(Settled::Known {
9344                    value: WorktreeState::Merged,
9345                    at: _,
9346                    stale: _
9347                })
9348            ),
9349            "expected ancestry alone to settle Merged here, got {:?}",
9350            worktree_entity.state.settled()
9351        );
9352        assert_eq!(
9353            core.patch_identity_reads_for_test(),
9354            0,
9355            "ancestry already settled this entity, so patch equivalence's shared \
9356             scan must never run for its common dir at all"
9357        );
9358    }
9359
9360    /// [`patch_equivalence`]'s own unit test proves the module itself writes no
9361    /// loose object; this proves the same through the real dispatch path a
9362    /// user's refresh actually runs, so a write introduced in `core.rs`'s glue
9363    /// rather than in the module would be caught too. Reuses the squash-merge
9364    /// fixture that routes a real `Core::refresh` into patch equivalence's
9365    /// second pass, and counts loose objects in the parent repository, since a
9366    /// linked Worktree shares its object database with its common dir.
9367    #[test]
9368    fn a_full_refresh_reaching_patch_equivalence_writes_no_loose_objects() {
9369        let dir = tempfile::tempdir().expect("temp dir");
9370        let root = root_of(&dir);
9371        let parent = root.join("parent");
9372        init_repo_with_a_commit(&parent);
9373        git(
9374            &parent,
9375            &[
9376                "remote",
9377                "add",
9378                "origin",
9379                "https://example.invalid/repo.git",
9380            ],
9381        );
9382        let worktree_path = root.join("feature-worktree");
9383        git(
9384            &parent,
9385            &[
9386                "worktree",
9387                "add",
9388                "-b",
9389                "feature",
9390                worktree_path.to_str().expect("utf8 path"),
9391            ],
9392        );
9393        fs::write(worktree_path.join("a.txt"), "one\n").expect("write a.txt");
9394        git(&worktree_path, &["add", "a.txt"]);
9395        git(&worktree_path, &["commit", "-m", "add a"]);
9396        fs::write(worktree_path.join("b.txt"), "two\n").expect("write b.txt");
9397        git(&worktree_path, &["add", "b.txt"]);
9398        git(&worktree_path, &["commit", "-m", "add b"]);
9399        let feature_sha = head_sha(&worktree_path);
9400
9401        git(&parent, &["merge", "--squash", "feature"]);
9402        git(&parent, &["commit", "-m", "squashed feature"]);
9403        let main_sha = head_sha(&parent);
9404        git(
9405            &parent,
9406            &["update-ref", "refs/remotes/origin/main", &main_sha],
9407        );
9408        git(&parent, &["config", "branch.feature.remote", "origin"]);
9409        git(
9410            &parent,
9411            &["config", "branch.feature.merge", "refs/heads/feature"],
9412        );
9413        git(
9414            &parent,
9415            &["update-ref", "refs/remotes/origin/feature", &feature_sha],
9416        );
9417
9418        let core = Core::start_discovered(spec(vec![root]));
9419        let keys: Vec<EntityKey> = core
9420            .snapshot()
9421            .entities
9422            .iter()
9423            .map(|entity| entity.key.clone())
9424            .collect();
9425
9426        let before = loose_object_count(&parent);
9427        core.refresh(&keys);
9428        let settled = core.settle();
9429        let after = loose_object_count(&parent);
9430
9431        let worktree_entity = settled
9432            .entities
9433            .iter()
9434            .find(|entity| matches!(entity.kind, Kind::Worktree))
9435            .expect("worktree entity present");
9436        assert!(
9437            matches!(
9438                worktree_entity.state.settled(),
9439                Some(Settled::Known {
9440                    value: WorktreeState::Merged,
9441                    at: _,
9442                    stale: _
9443                })
9444            ),
9445            "expected this refresh to actually reach patch equivalence and settle \
9446             Merged, got {:?}",
9447            worktree_entity.state.settled()
9448        );
9449        assert_eq!(
9450            before, after,
9451            "a full refresh reaching patch equivalence must never write a loose \
9452             object to the repository"
9453        );
9454    }
9455
9456    /// With patch equivalence now built, a diverged attached branch with a live
9457    /// upstream no longer stays outstanding forever: once ancestry says no,
9458    /// the second pass gets a real answer, and genuinely unmerged work (a real
9459    /// file change with no counterpart on the default branch, not merely an
9460    /// empty marker commit) settles `Active` rather than `Gone` or `Merged`,
9461    /// proven through the real dispatch path rather than either pass in
9462    /// isolation.
9463    #[test]
9464    fn a_diverged_worktree_with_a_live_upstream_and_genuinely_unmerged_work_settles_active_after_a_refresh()
9465     {
9466        let dir = tempfile::tempdir().expect("temp dir");
9467        let root = root_of(&dir);
9468        let parent = root.join("parent");
9469        init_repo_with_a_commit(&parent);
9470        let base_sha = head_sha(&parent);
9471        git(
9472            &parent,
9473            &[
9474                "remote",
9475                "add",
9476                "origin",
9477                "https://example.invalid/repo.git",
9478            ],
9479        );
9480        git(
9481            &parent,
9482            &["update-ref", "refs/remotes/origin/main", &base_sha],
9483        );
9484        let worktree_path = root.join("feature-worktree");
9485        git(
9486            &parent,
9487            &[
9488                "worktree",
9489                "add",
9490                "-b",
9491                "feature",
9492                worktree_path.to_str().expect("utf8 path"),
9493            ],
9494        );
9495        // Unmerged work: a real file change feature has that main (and
9496        // origin/main) do not, and that main never gains by any other means.
9497        fs::write(worktree_path.join("feature.txt"), "unmerged work\n").expect("write feature.txt");
9498        git(&worktree_path, &["add", "feature.txt"]);
9499        git(&worktree_path, &["commit", "-m", "unmerged"]);
9500        let feature_sha = head_sha(&worktree_path);
9501        // `feature`'s own upstream, live: the common dir's shared config and refs
9502        // make this visible from the worktree's own probe too.
9503        git(&parent, &["config", "branch.feature.remote", "origin"]);
9504        git(
9505            &parent,
9506            &["config", "branch.feature.merge", "refs/heads/feature"],
9507        );
9508        git(
9509            &parent,
9510            &["update-ref", "refs/remotes/origin/feature", &feature_sha],
9511        );
9512
9513        let core = Core::start_discovered(spec(vec![root]));
9514        let keys: Vec<EntityKey> = core
9515            .snapshot()
9516            .entities
9517            .iter()
9518            .map(|entity| entity.key.clone())
9519            .collect();
9520
9521        core.refresh(&keys);
9522        let settled = core.settle();
9523
9524        let worktree_entity = settled
9525            .entities
9526            .iter()
9527            .find(|entity| matches!(entity.kind, Kind::Worktree))
9528            .expect("worktree entity present");
9529        assert!(
9530            matches!(
9531                worktree_entity.state.settled(),
9532                Some(Settled::Known {
9533                    value: WorktreeState::Active,
9534                    at: _,
9535                    stale: _
9536                })
9537            ),
9538            "expected genuinely unmerged work with a live upstream to settle Active, got {:?}",
9539            worktree_entity.state.settled()
9540        );
9541    }
9542
9543    /// `CoreSpec::show_submodules` gates probing and dispatch, never Snapshot membership:
9544    /// a discovered Submodule is always part of the snapshot `Core::start` builds, shown or
9545    /// not, because the module pass that finds it always runs
9546    /// ([discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md)'s
9547    /// "the pass always runs, so Submodules are always known"). Built with the default,
9548    /// hidden reading precisely to prove that.
9549    #[test]
9550    fn a_submodule_is_in_the_snapshot_even_though_hidden_by_the_default_preference() {
9551        let dir = tempfile::tempdir().expect("temp dir");
9552        let root = root_of(&dir);
9553        let parent = root.join("parent");
9554        init_repo_with_a_commit(&parent);
9555        fs::write(
9556            parent.join(".gitmodules"),
9557            "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
9558        )
9559        .expect("write .gitmodules");
9560        fs::create_dir_all(parent.join("vendor").join("lib")).expect("create submodule dir");
9561
9562        let core = Core::start_discovered(spec(vec![root]));
9563        let snapshot = core.snapshot();
9564
9565        assert!(
9566            snapshot
9567                .entities
9568                .iter()
9569                .any(|entity| matches!(entity.kind, Kind::Submodule)),
9570            "a discovered Submodule must be in the snapshot even while show_submodules is off"
9571        );
9572    }
9573
9574    /// A Submodule's `state` and `base` cells must stay `Unknown` through a real
9575    /// refresh cycle, not only at construction:
9576    /// [`EntityState::probes_state`] and [`EntityState::probes_base`] are what
9577    /// stop `refresh`'s dispatch from ever calling `landing::probe` or
9578    /// `probe_base` for it again. The Submodule here is a real, valid repository
9579    /// with a real remote and a resolvable default branch ahead of its own tip
9580    /// (in fact an ancestor of it, so ancestry alone would prove `Merged`), so if
9581    /// either gate were missing this would settle a genuine live answer rather
9582    /// than merely fail to open.
9583    #[test]
9584    fn a_submodules_state_and_base_cells_stay_unknown_through_a_real_refresh() {
9585        let dir = tempfile::tempdir().expect("temp dir");
9586        let root = root_of(&dir);
9587        let parent = root.join("parent");
9588        init_repo_with_a_commit(&parent);
9589        fs::write(
9590            parent.join(".gitmodules"),
9591            "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
9592        )
9593        .expect("write .gitmodules");
9594        let submodule = parent.join("vendor").join("lib");
9595        init_repo_with_a_commit(&submodule);
9596        git(
9597            &submodule,
9598            &["remote", "add", "origin", "https://example.invalid/lib.git"],
9599        );
9600        let root_sha = head_sha(&submodule);
9601        git(&submodule, &["commit", "--allow-empty", "-m", "second"]);
9602        let tip_sha = head_sha(&submodule);
9603        git(&submodule, &["reset", "--hard", &root_sha]);
9604        git(
9605            &submodule,
9606            &["update-ref", "refs/remotes/origin/main", &tip_sha],
9607        );
9608
9609        // Shown, so the explicit `refresh` below actually dispatches a probe against it:
9610        // this test is about `probes_base`'s own gate, not about `show_submodules`'s.
9611        let mut core_spec = spec(vec![root]);
9612        core_spec.show_submodules = true;
9613        let core = Core::start_discovered(core_spec);
9614        let key = core
9615            .snapshot()
9616            .entities
9617            .iter()
9618            .find(|entity| matches!(entity.kind, Kind::Submodule))
9619            .expect("a discovered Submodule")
9620            .key
9621            .clone();
9622
9623        core.refresh(std::slice::from_ref(&key));
9624        let settled = core.settle();
9625        let submodule_entity = settled
9626            .entities
9627            .iter()
9628            .find(|entity| entity.key == key)
9629            .expect("the Submodule entity");
9630
9631        assert!(
9632            matches!(
9633                submodule_entity.base.settled(),
9634                Some(Settled::Unknown(Unknown::NoDefaultBranch))
9635            ),
9636            "expected a Submodule's base to stay Unknown through a real refresh, \
9637             got {:?}",
9638            submodule_entity.base.settled()
9639        );
9640        assert!(
9641            matches!(
9642                submodule_entity.state.settled(),
9643                Some(Settled::Unknown(Unknown::NoDefaultBranch))
9644            ),
9645            "expected a Submodule's state to stay Unknown through a real refresh, \
9646             rather than settling Merged off an untrusted default branch, got {:?}",
9647            submodule_entity.state.settled()
9648        );
9649    }
9650
9651    /// [discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md)'s
9652    /// "The Submodule row" fixes `name` as "the submodule path"; this proves the fact lands
9653    /// on the real `EntityState` `Core::start` builds, not only on the intermediate
9654    /// `DiscoveredEntity` `discovery::tests` already covers.
9655    #[test]
9656    fn a_submodules_entity_name_is_its_relative_path_not_its_basename() {
9657        let dir = tempfile::tempdir().expect("temp dir");
9658        let root = root_of(&dir);
9659        let parent = root.join("parent");
9660        init_repo_with_a_commit(&parent);
9661        fs::write(
9662            parent.join(".gitmodules"),
9663            "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
9664        )
9665        .expect("write .gitmodules");
9666        fs::create_dir_all(parent.join("vendor").join("lib")).expect("create submodule dir");
9667
9668        let core = Core::start_discovered(spec(vec![root]));
9669        let submodule = core
9670            .snapshot()
9671            .entities
9672            .into_iter()
9673            .find(|entity| matches!(entity.kind, Kind::Submodule))
9674            .expect("a discovered Submodule");
9675
9676        assert_eq!(
9677            submodule.name.as_ref(),
9678            "vendor/lib",
9679            "expected the declared relative path, not the basename `lib`"
9680        );
9681    }
9682
9683    /// AC3's negative case: an uninitialised Submodule (never `git submodule update
9684    /// --init`-ed, so its own path holds no `.git` at all) settles every cell a probe would
9685    /// otherwise open a repository for `Unknown(SubmoduleUninitialized)`, never `Failed`,
9686    /// because not being there yet is the normal, expected shape
9687    /// ([discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md)'s
9688    /// "An uninitialised Submodule is a row with every cell blank and `?` in the gutter").
9689    /// The row still exists (the assertion below finds it), so the row itself is not the
9690    /// mutation this covers; `probe_branch`/`probe_sync`/`probe_status`'s classification is.
9691    #[test]
9692    fn an_uninitialised_submodules_probed_cells_settle_unknown_not_failed() {
9693        let dir = tempfile::tempdir().expect("temp dir");
9694        let root = root_of(&dir);
9695        let parent = root.join("parent");
9696        init_repo_with_a_commit(&parent);
9697        fs::write(
9698            parent.join(".gitmodules"),
9699            "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
9700        )
9701        .expect("write .gitmodules");
9702        // Deliberately never initialised: no directory at all at the declared path, the
9703        // shape a plain `git clone` (no `--recurse-submodules`) leaves behind.
9704
9705        let mut core_spec = spec(vec![root]);
9706        core_spec.show_submodules = true;
9707        let core = Core::start_discovered(core_spec);
9708        let key = core
9709            .snapshot()
9710            .entities
9711            .iter()
9712            .find(|entity| matches!(entity.kind, Kind::Submodule))
9713            .expect("a discovered Submodule")
9714            .key
9715            .clone();
9716
9717        core.refresh(std::slice::from_ref(&key));
9718        let settled = core.settle();
9719        let submodule = settled
9720            .entities
9721            .iter()
9722            .find(|entity| entity.key == key)
9723            .expect("the Submodule entity");
9724
9725        assert!(
9726            matches!(
9727                submodule.branch.settled(),
9728                Some(Settled::Unknown(Unknown::SubmoduleUninitialized))
9729            ),
9730            "expected branch to settle Unknown(SubmoduleUninitialized), got {:?}",
9731            submodule.branch.settled()
9732        );
9733        assert!(
9734            matches!(
9735                submodule.sync.settled(),
9736                Some(Settled::Unknown(Unknown::SubmoduleUninitialized))
9737            ),
9738            "expected sync to settle Unknown(SubmoduleUninitialized), got {:?}",
9739            submodule.sync.settled()
9740        );
9741        assert!(
9742            matches!(
9743                submodule.dirty.settled(),
9744                Some(Settled::Unknown(Unknown::SubmoduleUninitialized))
9745            ),
9746            "expected dirty to settle Unknown(SubmoduleUninitialized), got {:?}",
9747            submodule.dirty.settled()
9748        );
9749        assert_eq!(
9750            summary(submodule),
9751            RowSummary::Unknown,
9752            "expected the row's own gutter fold to read Unknown, not Failed"
9753        );
9754    }
9755
9756    /// AC4's cost half: `show_submodules` off means a dispatched Generation never even
9757    /// opens a shown Submodule's own repository, while a shown one right beside it is
9758    /// probed normally in the very same Generation. Both submodules are real, valid
9759    /// repositories, so a probed-but-ignored implementation and a never-dispatched one are
9760    /// distinguishable only by whether the hidden one's cells ever leave "never settled".
9761    #[test]
9762    fn dispatch_skips_probing_a_hidden_submodule_while_probing_the_same_one_shown() {
9763        let dir = tempfile::tempdir().expect("temp dir");
9764        let root = root_of(&dir);
9765        let parent = root.join("parent");
9766        init_repo_with_a_commit(&parent);
9767        fs::write(
9768            parent.join(".gitmodules"),
9769            "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
9770        )
9771        .expect("write .gitmodules");
9772        init_repo_with_a_commit(&parent.join("vendor").join("lib"));
9773
9774        // `spec`'s own default: `show_submodules: false`.
9775        let core = Core::start_discovered(spec(vec![root]));
9776        let key = core
9777            .snapshot()
9778            .entities
9779            .iter()
9780            .find(|entity| matches!(entity.kind, Kind::Submodule))
9781            .expect("a discovered Submodule")
9782            .key
9783            .clone();
9784
9785        // First Generation, dispatched while hidden: `dispatch` must skip it outright.
9786        core.refresh(std::slice::from_ref(&key));
9787        let while_hidden = core.settle();
9788        let hidden_entity = while_hidden
9789            .entities
9790            .iter()
9791            .find(|entity| entity.key == key)
9792            .expect("submodule entity");
9793        assert!(
9794            hidden_entity.branch.settled().is_none(),
9795            "a Submodule dispatched while hidden must never even reach probe_branch, \
9796             so its cell stays never-settled rather than holding any value at all, got {:?}",
9797            hidden_entity.branch.settled()
9798        );
9799
9800        // Toggled live, no rebuild, then the very same key is handed to `refresh` again:
9801        // the second Generation is what proves the flag narrows the work rather than the
9802        // key, since nothing about the key or the `Core` itself changed in between.
9803        core.set_show_submodules(true);
9804        core.refresh(std::slice::from_ref(&key));
9805        let while_shown = core.settle();
9806        let shown_entity = while_shown
9807            .entities
9808            .iter()
9809            .find(|entity| entity.key == key)
9810            .expect("submodule entity");
9811        assert!(
9812            matches!(
9813                shown_entity.branch.settled(),
9814                Some(Settled::Known {
9815                    value: _,
9816                    at: _,
9817                    stale: _
9818                })
9819            ),
9820            "expected the same Submodule's branch to settle a real value once shown, got {:?}",
9821            shown_entity.branch.settled()
9822        );
9823    }
9824
9825    /// AC4's other half: toggling the live preference is free. Proven the same way
9826    /// `reload_with_the_same_active_set_leaves_discovery_and_its_generation_untouched`
9827    /// proves a same-Set reload never rebuilds `Core`: a Generation counter a rediscovery
9828    /// or a dispatch would have to move, checked before and after the toggle with nothing
9829    /// else run in between.
9830    #[test]
9831    fn toggling_show_submodules_starts_no_new_generation_and_dispatches_nothing() {
9832        let dir = tempfile::tempdir().expect("temp dir");
9833        let root = root_of(&dir);
9834        init_repo_with_a_commit(&root.join("repo-a"));
9835
9836        // Drained, so the two readings below differ only by whatever the toggles did.
9837        let (core, launched) = started_and_settled(spec(vec![root]));
9838        let before = launched.generation;
9839        let dispatched_before = core.dispatch_log_for_test();
9840        assert!(
9841            !dispatched_before.is_empty(),
9842            "launch dispatched nothing, so the comparison below would hold however much a \
9843             toggle dispatched"
9844        );
9845
9846        core.set_show_submodules(true);
9847        core.set_show_submodules(false);
9848
9849        assert_eq!(
9850            core.snapshot().generation,
9851            before,
9852            "toggling show_submodules must start no Generation of its own"
9853        );
9854        assert_eq!(
9855            core.dispatch_log_for_test(),
9856            dispatched_before,
9857            "toggling show_submodules must dispatch no probe of its own, leaving the last \
9858             Generation's own log exactly as it found it"
9859        );
9860    }
9861
9862    /// AC5: a `.gitmodules` parse failure marks the parent Repo's row Failed whether or not
9863    /// Submodules are shown, because the module pass that finds the failure runs either way
9864    /// ([discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md)'s
9865    /// "Failure": "The mark appears whether or not `show_submodules` is on, because the pass
9866    /// ran either way"). `spec`'s own default is already `show_submodules: false`, which is
9867    /// what makes this a real proof rather than a coincidence of some other default.
9868    #[test]
9869    fn a_malformed_gitmodules_file_still_fails_the_parent_while_submodules_are_hidden() {
9870        let dir = tempfile::tempdir().expect("temp dir");
9871        let root = root_of(&dir);
9872        let parent = root.join("parent");
9873        init_repo_with_a_commit(&parent);
9874        fs::write(
9875            parent.join(".gitmodules"),
9876            "[submodule \"lib\"\n\tpath = lib\n",
9877        )
9878        .expect("write malformed .gitmodules");
9879
9880        let core = Core::start_discovered(spec(vec![root]));
9881        let key = core
9882            .snapshot()
9883            .entities
9884            .iter()
9885            .find(|entity| entity.key.path() == parent)
9886            .expect("the parent entity")
9887            .key
9888            .clone();
9889        // The fold reads Failed only once the row holds some probed value at all: a
9890        // Generation's own dispatch is what proves the mark survives real probing, not
9891        // merely discovery's own construction-time diagnostics write.
9892        core.refresh(std::slice::from_ref(&key));
9893        let settled = core.settle();
9894        let parent_entity = settled
9895            .entities
9896            .iter()
9897            .find(|entity| entity.key == key)
9898            .expect("the parent entity");
9899
9900        assert_eq!(
9901            summary(parent_entity),
9902            RowSummary::Failed,
9903            "expected the parent to fold Failed even with Submodules hidden"
9904        );
9905        assert!(
9906            parent_entity.diagnostics.gitmodules_failed.is_some(),
9907            "expected the failure recorded in Diagnostics for the detail pane"
9908        );
9909        assert!(
9910            !settled
9911                .entities
9912                .iter()
9913                .any(|entity| matches!(entity.kind, Kind::Submodule)),
9914            "an unparseable .gitmodules yields no Submodule rows for that parent"
9915        );
9916    }
9917
9918    #[test]
9919    fn count_matches_a_plain_discoverys_entity_count() {
9920        let dir = tempfile::tempdir().expect("temp dir");
9921        let root = root_of(&dir);
9922        init_repo_with_a_commit(&root.join("one"));
9923        init_repo_with_a_commit(&root.join("two"));
9924
9925        let set = SetSpec {
9926            name: "test".to_string(),
9927            roots: vec![root],
9928            include: Vec::new(),
9929            exclude: Vec::new(),
9930        };
9931
9932        assert_eq!(discovery::count(&set), 2);
9933    }
9934
9935    #[test]
9936    fn the_slow_discovery_watcher_warns_with_the_count_reached_and_the_roots() {
9937        let progress = Arc::new(AtomicUsize::new(42));
9938        let finished = Arc::new(AtomicBool::new(false));
9939        let roots = vec![PathBuf::from("/repos/a"), PathBuf::from("/repos/b")];
9940
9941        let warning = watch_for_slow_discovery(progress, finished, roots, Duration::from_millis(1));
9942
9943        let message = warning.expect("a walk that has not finished should warn");
9944        assert!(message.contains("42"));
9945        assert!(message.contains("/repos/a"));
9946        assert!(message.contains("/repos/b"));
9947    }
9948
9949    #[test]
9950    fn the_slow_discovery_watcher_is_silent_once_the_walk_has_already_finished() {
9951        let progress = Arc::new(AtomicUsize::new(7));
9952        let finished = Arc::new(AtomicBool::new(true));
9953
9954        let warning =
9955            watch_for_slow_discovery(progress, finished, Vec::new(), Duration::from_millis(1));
9956
9957        assert!(warning.is_none());
9958    }
9959
9960    /// The same watcher `start_internal` wires in: on a fast, already-finished
9961    /// walk (the common case), joining its handle proves it ran and recorded no
9962    /// warning, exercised through `Core::start` itself rather than in isolation.
9963    /// `warn_after` is one second, the real production threshold, rather than a
9964    /// margin picked for speed: a one-repository walk finishes orders of
9965    /// magnitude faster than that even on a loaded machine, so this proves the
9966    /// fast path without racing a real walk the way a millisecond threshold did.
9967    #[test]
9968    fn a_fast_discovery_leaves_no_warning_once_the_watcher_has_run() {
9969        let dir = tempfile::tempdir().expect("temp dir");
9970        let root = root_of(&dir);
9971        init_repo_with_a_commit(&root.join("repo"));
9972        let (_tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
9973
9974        let started =
9975            Core::start_for_test(spec(vec![root]), Duration::from_secs(1), tick_rx).discovered();
9976        started
9977            .discovery_watcher
9978            .join()
9979            .expect("watcher thread should not panic");
9980
9981        assert!(started.core.discovery_warning().is_none());
9982    }
9983
9984    /// A [`DiscoveryGate`] starting `open`, and the channel that opens it once the call
9985    /// under test has returned.
9986    ///
9987    /// The gate is what makes "before its walk has run" a rendezvous rather than a
9988    /// margin. The channel is what makes an implementation that walks inline fail its
9989    /// assertion instead of wedging the run: nothing else would ever open the gate for
9990    /// it, so the backstop below is its only release, and the assertion then reports.
9991    fn gate_opened_on_signal(open: bool) -> (DiscoveryGate, Sender<()>, JoinHandle<()>) {
9992        let gate: DiscoveryGate = Arc::new((Mutex::new(open), Condvar::new()));
9993        let (returned_tx, returned_rx) = crossbeam_channel::bounded::<()>(1);
9994        let opener = thread::spawn({
9995            let gate = Arc::clone(&gate);
9996            move || {
9997                let _ = returned_rx.recv_timeout(crate::liveness::BACKSTOP);
9998                set_discovery_gate(&gate, true);
9999            }
10000        });
10001        (gate, returned_tx, opener)
10002    }
10003
10004    /// Criterion 1: `Core::start` returns before discovery has finished, and the rows
10005    /// land when discovery does.
10006    ///
10007    /// The walk is held closed before the `Core` is built, so the empty table below is
10008    /// the table `start` actually returned rather than one this test raced it to. Joining
10009    /// the harness's own `initial_discovery` handle afterwards is the rendezvous that says
10010    /// the walk landed: no sleep and no poll on either side.
10011    ///
10012    /// The row's phase C is held from before the walk is let go, so the cell read below
10013    /// is read at a point this test fixes rather than at whatever point launch's own
10014    /// Generation happened to have reached.
10015    #[test]
10016    fn start_returns_against_an_empty_table_and_the_rows_land_when_discovery_does() {
10017        let dir = tempfile::tempdir().expect("temp dir");
10018        let root = root_of(&dir);
10019        let repo = root.join("repo");
10020        init_repo_with_a_commit(&repo);
10021        let (_tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
10022        let (gate, start_returned, opener) = gate_opened_on_signal(false);
10023
10024        let started = Core::start_for_test_gated(
10025            spec(vec![root]),
10026            Duration::from_secs(3600),
10027            discovery::ABANDON_AFTER,
10028            tick_rx,
10029            Some(Arc::clone(&gate)),
10030        );
10031        let at_start = started.core.snapshot();
10032        let key = EntityKey::new(Arc::from(repo.as_path()));
10033        started.core.hold_phase_c_for_test(&key);
10034        start_returned.send(()).expect("the opener is listening");
10035        opener.join().expect("the opener thread should not panic");
10036        let started = started.discovered();
10037
10038        assert!(
10039            at_start.entities.is_empty(),
10040            "`Core::start` must return before discovery has finished, against the empty \
10041             table a consumer draws its first frame from, got {:?}",
10042            at_start
10043                .entities
10044                .iter()
10045                .map(|entity| entity.name.to_string())
10046                .collect::<Vec<_>>()
10047        );
10048
10049        let landed = started.core.snapshot();
10050        assert_eq!(
10051            landed
10052                .entities
10053                .iter()
10054                .map(|entity| entity.name.to_string())
10055                .collect::<Vec<_>>(),
10056            vec!["repo".to_string()],
10057            "the row must land on the table as soon as discovery does"
10058        );
10059        assert!(
10060            landed.entities[0].dirty.settled().is_none() && landed.entities[0].dirty.is_in_flight(),
10061            "discovery lands the row alone: launch's own Generation is already covering it \
10062             and its Cells stay unsettled until that Generation answers, which is what the \
10063             spinner sits behind"
10064        );
10065
10066        started.core.release_phase_c_for_test(&key);
10067        started.core.wait_phase_c_finished_for_test(&key);
10068    }
10069
10070    /// Criterion 2: a Generation that resolves its own order after its own discovery
10071    /// covers every row that walk found, including the ones the caller could not have
10072    /// named, and fills their Cells.
10073    ///
10074    /// `refresh_all` rather than `refresh`, because a caller that has just discarded the
10075    /// old Set's rows has no key to order by; the row below is discovered by this
10076    /// Generation's own walk and probed by the same Generation. Named by its order after
10077    /// launch's own Generation rather than by a number.
10078    #[test]
10079    fn refresh_all_covers_every_row_its_own_discovery_found() {
10080        let dir = tempfile::tempdir().expect("temp dir");
10081        let root = root_of(&dir);
10082        init_repo_with_a_commit(&root.join("repo"));
10083
10084        let (core, launched) = started_and_settled(spec(vec![root.clone()]));
10085        assert_eq!(
10086            launched
10087                .entities
10088                .iter()
10089                .map(|entity| entity.name.to_string())
10090                .collect::<Vec<_>>(),
10091            vec!["repo".to_string()],
10092            "launch's own walk must have landed and covered exactly the one row that \
10093             existed when it ran"
10094        );
10095        // Created after that walk finished, so this row exists in no snapshot the caller
10096        // could have read: only a Generation that resolves its own order after its own
10097        // discovery reaches it.
10098        init_repo_with_a_commit(&root.join("late"));
10099
10100        assert_eq!(
10101            core.refresh_all(),
10102            launched.generation.successor(),
10103            "`refresh_all` must be the Generation immediately after the one already on the \
10104             table"
10105        );
10106        let settled = core.settle();
10107
10108        let mut named: Vec<String> = settled
10109            .entities
10110            .iter()
10111            .filter(|entity| entity.branch.settled().is_some())
10112            .map(|entity| entity.name.to_string())
10113            .collect();
10114        named.sort();
10115        assert_eq!(
10116            named,
10117            vec!["late".to_string(), "repo".to_string()],
10118            "the Generation must cover every row its own discovery found, including one the \
10119             caller had no key for"
10120        );
10121    }
10122
10123    /// Criterion 3: `r`, focus gained and resume all reach `Core::refresh`, and it
10124    /// returns before its own Generation's discovery has run, so none of them holds the
10125    /// event loop for the length of a walk.
10126    ///
10127    /// `late` is created after the first walk has already finished, so only this
10128    /// `refresh`'s own walk could ever find it: its absence from the table `refresh`
10129    /// returned against is what says that walk had not run. Opening the gate afterwards
10130    /// lets the same Generation finish, which is what proves the work was deferred rather
10131    /// than dropped.
10132    #[test]
10133    fn refresh_returns_before_its_own_generations_discovery_has_run() {
10134        let dir = tempfile::tempdir().expect("temp dir");
10135        let root = root_of(&dir);
10136        init_repo_with_a_commit(&root.join("repo"));
10137        let (_tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
10138        let (gate, walk_may_run, opener) = gate_opened_on_signal(true);
10139
10140        let started = Core::start_for_test_gated(
10141            spec(vec![root.clone()]),
10142            Duration::from_secs(3600),
10143            discovery::ABANDON_AFTER,
10144            tick_rx,
10145            Some(Arc::clone(&gate)),
10146        )
10147        .discovered();
10148        let core = started.core;
10149        // Drained, so the settle-gate reading below is this `refresh`'s alone.
10150        let launched = settle_launch(&core);
10151        let keys: Vec<EntityKey> = launched
10152            .entities
10153            .iter()
10154            .map(|entity| entity.key.clone())
10155            .collect();
10156        init_repo_with_a_commit(&root.join("late"));
10157
10158        set_discovery_gate(&gate, false);
10159        let generation = core.refresh(&keys);
10160        let while_held = core.snapshot();
10161        let dispatched_while_held = core.settle_gate_count_for_test();
10162        walk_may_run.send(()).expect("the opener is listening");
10163        opener.join().expect("the opener thread should not panic");
10164
10165        assert_eq!(
10166            generation,
10167            launched.generation.successor(),
10168            "`refresh` must return its own Generation's number, the one immediately after \
10169             the table's, before that Generation has done any of its work"
10170        );
10171        assert!(
10172            !while_held
10173                .entities
10174                .iter()
10175                .any(|entity| &*entity.name == "late"),
10176            "`refresh` must return before its own Generation's walk has run, so a Repo \
10177             created after the previous walk is not on the table it returned against"
10178        );
10179        assert_eq!(
10180            dispatched_while_held, 0,
10181            "`refresh` returned before its Generation reached the table at all, so nothing \
10182             is dispatched yet"
10183        );
10184
10185        core.wait_dispatched_for_test();
10186        let settled = core.settle();
10187
10188        assert!(
10189            settled
10190                .entities
10191                .iter()
10192                .any(|entity| &*entity.name == "late"),
10193            "the deferred Generation must still run its own walk once it is let through: \
10194             deferred, never dropped"
10195        );
10196    }
10197
10198    /// The turnstile's whole claim: a Generation reserved second cannot reach the table
10199    /// before the one reserved first, whatever the two threads' own scheduling does.
10200    ///
10201    /// Without it a `refresh` whose walk finished quickly could insert its in-flight
10202    /// entries ahead of an older Generation's, leaving the older one to cancel the newer
10203    /// one and record itself as the live one, which is
10204    /// [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
10205    /// "Supersession" read backwards. The later ticket is taken on this thread, so it can
10206    /// only ever record itself after the earlier body has recorded and released; an
10207    /// implementation that did not wait would record the later one first.
10208    #[test]
10209    fn a_dispatch_body_waits_for_every_earlier_reserved_generation() {
10210        let turnstile = Arc::new(DispatchTurnstile::default());
10211        let earlier = turnstile.reserve();
10212        let later = turnstile.reserve();
10213        let order = Arc::new(Mutex::new(Vec::new()));
10214
10215        let earlier_body = thread::spawn({
10216            let turnstile = Arc::clone(&turnstile);
10217            let order = Arc::clone(&order);
10218            move || {
10219                let _turn = turnstile.take(earlier);
10220                order.lock().unwrap().push(earlier);
10221            }
10222        });
10223
10224        {
10225            let _turn = turnstile.take(later);
10226            order.lock().unwrap().push(later);
10227        }
10228        earlier_body
10229            .join()
10230            .expect("the earlier body should not panic");
10231
10232        assert_eq!(
10233            *order.lock().unwrap(),
10234            vec![earlier, later],
10235            "a dispatch body must run in the order its Generation was reserved"
10236        );
10237    }
10238
10239    /// The generic cancellation primitive stops a loop the instant `cancel` is
10240    /// observed, proven with a channel rendezvous rather than a sleep: `cancel` is
10241    /// set only after the worker's third step has genuinely completed, so a fourth
10242    /// step running at all would mean the flag was set but never actually checked.
10243    #[test]
10244    fn run_while_not_cancelled_stops_at_the_next_check_rather_than_running_forever() {
10245        let cancel = Arc::new(AtomicBool::new(false));
10246        let worker_cancel = Arc::clone(&cancel);
10247        let (step_started_tx, step_started_rx) = crossbeam_channel::bounded::<()>(0);
10248        let (proceed_tx, proceed_rx) = crossbeam_channel::bounded::<()>(0);
10249
10250        let worker = thread::spawn(move || {
10251            run_while_not_cancelled(&worker_cancel, || {
10252                step_started_tx.send(()).expect("test should be listening");
10253                proceed_rx.recv().is_ok()
10254            })
10255        });
10256
10257        for _ in 0..2 {
10258            step_started_rx
10259                .recv()
10260                .expect("worker should announce each step");
10261            proceed_tx.send(()).expect("let the step finish");
10262        }
10263        step_started_rx
10264            .recv()
10265            .expect("worker should announce its third step");
10266        cancel.store(true, Ordering::Release);
10267        proceed_tx.send(()).expect("let the third step finish");
10268
10269        let ran = worker.join().expect("worker thread should not panic");
10270
10271        assert_eq!(
10272            ran, 3,
10273            "expected cancellation to stop the loop after its third step"
10274        );
10275    }
10276
10277    /// Phase A's own per-entity timing distribution: opens (or reuses a cached
10278    /// handle for) every entity in `population` and reads `HEAD` from it, exactly
10279    /// the work `probe_branch` does, one rayon task per entity via `fanout::scatter`
10280    /// rather than `Core::refresh`, so the timing is not entangled with the
10281    /// settle-gate bookkeeping a full `Core` also pays for. Returns one
10282    /// [`Duration`] per entity actually probed, so a caller reports a real
10283    /// distribution rather than a total divided by a count.
10284    fn benchmark_identity_phase(
10285        population: Vec<crate::discovery::DiscoveredEntity>,
10286    ) -> (Duration, Vec<Duration>) {
10287        let (tx, rx) = crossbeam_channel::unbounded();
10288        let started = Instant::now();
10289        crate::fanout::scatter(population, tx, |entity| {
10290            let task_started = Instant::now();
10291            let repo = match &entity.repo {
10292                Some(repo) => repo.to_thread_local(),
10293                None => match git::open_thread_safe(entity.key.path()) {
10294                    Ok(repo) => repo.to_thread_local(),
10295                    Err(_) => return None,
10296                },
10297            };
10298            let _ = git::head_shape(&repo);
10299            Some(task_started.elapsed())
10300        });
10301        let wall = started.elapsed();
10302        let durations: Vec<Duration> = rx.into_iter().flatten().collect();
10303        (wall, durations)
10304    }
10305
10306    /// Every root this machine actually has of the two the owner's real corpus
10307    /// lives under. Read from `$HOME` at run time rather than a literal in this
10308    /// file, so no personal path is ever recorded in committed source.
10309    fn real_corpus_roots() -> Vec<PathBuf> {
10310        let Some(home) = std::env::var_os("HOME") else {
10311            return Vec::new();
10312        };
10313        let home = PathBuf::from(home);
10314        ["dev", "dev-misc"]
10315            .into_iter()
10316            .map(|leaf| home.join(leaf))
10317            .filter(|root| root.is_dir())
10318            .collect()
10319    }
10320
10321    /// A `.git`-committed disposable repository per index, standing in for the
10322    /// real corpus when it is absent or too small to be meaningful. Each one gets
10323    /// a distinct commit so opening it is not a single cached filesystem page for
10324    /// every entity.
10325    fn generated_fixture_corpus(size: usize) -> tempfile::TempDir {
10326        let root = tempfile::tempdir().expect("temp dir for generated fixture corpus");
10327        for i in 0..size {
10328            let repo = root.path().join(format!("fixture-repo-{i}"));
10329            fs::create_dir_all(&repo).expect("create fixture repo dir");
10330            gix::init(&repo).expect("init fixture repo");
10331            let status = Command::new("git")
10332                .arg("-C")
10333                .arg(&repo)
10334                .args(["-c", "user.email=test@example.com", "-c", "user.name=Test"])
10335                .args(["commit", "--allow-empty", "-m", &format!("commit {i}")])
10336                .status()
10337                .expect("run git commit");
10338            assert!(status.success());
10339        }
10340        root
10341    }
10342
10343    /// Percentile `p` (0 to 100) of an already-sorted, non-empty slice.
10344    fn percentile(sorted: &[Duration], p: usize) -> Duration {
10345        let index = (sorted.len() - 1) * p / 100;
10346        sorted[index]
10347    }
10348
10349    /// Path-component names to keep out of the benchmark's population entirely,
10350    /// read from an environment variable rather than a literal in this file: a
10351    /// standing project rule keeps certain names out of committed source, so a
10352    /// real run supplies them at invocation time
10353    /// (`REPON_BENCHMARK_EXCLUDE_NAMES=name-one,name-two`) instead of this file
10354    /// ever spelling one out. Empty, and therefore excluding nothing, when unset.
10355    fn extra_excluded_names() -> Vec<String> {
10356        parse_excluded_names(&std::env::var("REPON_BENCHMARK_EXCLUDE_NAMES").unwrap_or_default())
10357    }
10358
10359    /// The comma-separated parsing `extra_excluded_names` applies to whatever the
10360    /// environment variable holds, split out so it can be proven against a literal
10361    /// string rather than by mutating process environment state a parallel test
10362    /// run could race on.
10363    fn parse_excluded_names(raw: &str) -> Vec<String> {
10364        raw.split(',')
10365            .map(str::trim)
10366            .filter(|name| !name.is_empty())
10367            .map(str::to_string)
10368            .collect()
10369    }
10370
10371    /// Discovers, resolves and excluded-name-filters one root list into a
10372    /// population, without opening anything `excluded_names` names at any depth.
10373    /// Returns the wall time of discovery and resolution alongside the
10374    /// population, since resolution is where every entity's repository is
10375    /// actually opened the first time ([`git::resolve_boundary`]); the identity
10376    /// phase timed afterwards only re-reads `HEAD` from the handle that step
10377    /// already cached.
10378    fn discover_population(
10379        roots: Vec<PathBuf>,
10380        excluded_names: &[String],
10381    ) -> (Vec<crate::discovery::DiscoveredEntity>, Duration) {
10382        let set = SetSpec {
10383            name: "identity-probe-benchmark".to_string(),
10384            roots,
10385            include: Vec::new(),
10386            exclude: Vec::new(),
10387        };
10388        let started = Instant::now();
10389        let discovery = discovery::discover(&set);
10390        let (discovered, _) = discovery::resolve(&set, &discovery.entities);
10391        let elapsed = started.elapsed();
10392        let population = discovered
10393            .into_iter()
10394            .filter(|entity| {
10395                !entity.key.path().components().any(|component| {
10396                    excluded_names
10397                        .iter()
10398                        .any(|name| component.as_os_str() == name.as_str())
10399                })
10400            })
10401            .collect();
10402        (population, elapsed)
10403    }
10404
10405    /// The exclusion mechanism proven against a fixture: a name present nowhere
10406    /// but this test's own excluded-names list still keeps a matching boundary
10407    /// out of the discovered population, and its two siblings still get through.
10408    #[test]
10409    fn a_boundary_whose_path_matches_an_excluded_name_is_left_out_of_the_population() {
10410        let fixture = generated_fixture_corpus(3);
10411        let excluded = vec!["fixture-repo-1".to_string()];
10412
10413        let (population, _) = discover_population(vec![fixture.path().to_path_buf()], &excluded);
10414
10415        assert_eq!(population.len(), 2);
10416        assert!(
10417            population
10418                .iter()
10419                .all(|entity| entity.key.path().file_name().unwrap() != "fixture-repo-1"),
10420            "the excluded name must never appear in the population discovery returns"
10421        );
10422    }
10423
10424    #[test]
10425    fn excluded_names_parses_a_comma_separated_list_and_ignores_blanks() {
10426        assert_eq!(
10427            parse_excluded_names("foo, bar ,,baz"),
10428            vec!["foo".to_string(), "bar".to_string(), "baz".to_string()]
10429        );
10430        assert!(parse_excluded_names("").is_empty());
10431        assert!(parse_excluded_names("   ").is_empty());
10432    }
10433
10434    /// Benchmarks the identity probe (phase A: open the repository, read `HEAD`)
10435    /// against the owner's real corpus under `$HOME/dev` and `$HOME/dev-misc`,
10436    /// falling back to a generated fixture when the real corpus is absent or too
10437    /// small to be meaningful (fewer than 20 entities). Never run by `just ci`:
10438    /// this is a hand-run measurement, per this project's convention of recording
10439    /// hand-run figures with the date, machine and toolchain rather than asserting
10440    /// a timing budget in a committed test. Run it with:
10441    /// `cargo test -p repon-core --release -- --ignored --nocapture identity_probe_benchmark`
10442    ///
10443    /// Read-only throughout: discovery only stats for a `.git` entry and phase A
10444    /// only reads `HEAD`. Any boundary whose path has a component named by
10445    /// `REPON_BENCHMARK_EXCLUDE_NAMES` is dropped before discovery's second half
10446    /// would ever open it, which is how a standing exclusion is honoured without
10447    /// this file naming what it excludes.
10448    #[test]
10449    #[ignore = "hand-run against the owner's real corpus; see docs/spec/refresh.md for the recorded figures"]
10450    fn identity_probe_benchmark() {
10451        let excluded_names = extra_excluded_names();
10452
10453        // `_fixture` is held for the rest of the test whenever a fixture is used,
10454        // so its directories still exist when the identity phase opens them; it is
10455        // simply never populated on the real-corpus path.
10456        let mut _fixture: Option<tempfile::TempDir> = None;
10457
10458        let (real_population, real_discovery_wall) =
10459            discover_population(real_corpus_roots(), &excluded_names);
10460        let (population, using_fixture, discovery_wall) = if real_population.len() >= 20 {
10461            (real_population, false, real_discovery_wall)
10462        } else {
10463            println!(
10464                "real corpus absent or too small to be meaningful ({} entities); \
10465                 using a generated fixture instead",
10466                real_population.len()
10467            );
10468            let fixture = generated_fixture_corpus(300);
10469            let (population, fixture_discovery_wall) =
10470                discover_population(vec![fixture.path().to_path_buf()], &excluded_names);
10471            _fixture = Some(fixture);
10472            (population, true, fixture_discovery_wall)
10473        };
10474
10475        let population_size = population.len();
10476        assert!(
10477            population_size > 0,
10478            "neither a real corpus root nor the generated fixture produced any entities"
10479        );
10480
10481        let (wall, mut durations) = benchmark_identity_phase(population);
10482        durations.sort();
10483
10484        println!(
10485            "identity probe benchmark: corpus = {}, population = {population_size}",
10486            if using_fixture {
10487                "generated fixture"
10488            } else {
10489                "real corpus"
10490            }
10491        );
10492        println!(
10493            "discovery + first open (serial, every entity's own gix::open): {discovery_wall:?}"
10494        );
10495        println!("identity phase, warm, parallel (HEAD re-read from the cached handle): {wall:?}");
10496        println!(
10497            "identity phase per entity: p50 {:?}, p90 {:?}, max {:?}",
10498            percentile(&durations, 50),
10499            percentile(&durations, 90),
10500            durations.last().copied().unwrap_or_default(),
10501        );
10502    }
10503
10504    fn spec_with_overrides(roots: Vec<PathBuf>, overrides: Vec<RepoOverride>) -> CoreSpec {
10505        let mut spec = spec(roots);
10506        spec.overrides = overrides;
10507        spec
10508    }
10509
10510    /// The seam this proves: an explicit per-Repo override reaches all the way
10511    /// through `Core::refresh` and `settle` into the `default_branch` cell as
10512    /// rung 1, recorded in diagnostics, even though `origin/HEAD` and the name
10513    /// list would both answer differently if asked.
10514    #[test]
10515    fn a_per_repo_override_resolves_the_default_branch_at_rung_one_through_a_real_refresh() {
10516        let dir = tempfile::tempdir().expect("temp dir");
10517        let root = root_of(&dir);
10518        let repo = root.join("repo");
10519        init_repo_with_a_commit(&repo);
10520        git(
10521            &repo,
10522            &[
10523                "remote",
10524                "add",
10525                "origin",
10526                "https://example.invalid/repo.git",
10527            ],
10528        );
10529        let sha = head_sha(&repo);
10530        git(&repo, &["update-ref", "refs/remotes/origin/main", &sha]);
10531        let remote_refs_dir = repo
10532            .join(".git")
10533            .join("refs")
10534            .join("remotes")
10535            .join("origin");
10536        fs::create_dir_all(&remote_refs_dir).expect("create refs/remotes/origin dir");
10537        fs::write(
10538            remote_refs_dir.join("HEAD"),
10539            "ref: refs/remotes/origin/main\n",
10540        )
10541        .expect("write HEAD");
10542
10543        let core = Core::start_discovered(spec_with_overrides(
10544            vec![root],
10545            vec![RepoOverride {
10546                path: repo.clone(),
10547                default_branch: Some("develop".to_string()),
10548                excluded: false,
10549            }],
10550        ));
10551        let key = core.snapshot().entities[0].key.clone();
10552
10553        core.refresh(std::slice::from_ref(&key));
10554        let settled = core.settle();
10555        let entity = &settled.entities[0];
10556
10557        match entity.default_branch.settled() {
10558            Some(Settled::Known {
10559                value,
10560                at: _,
10561                stale: _,
10562            }) => assert_eq!(
10563                value.name(),
10564                "origin/develop",
10565                "the override must win even though origin/HEAD names a different branch"
10566            ),
10567            other => panic!("expected the override's own answer, got {other:?}"),
10568        }
10569        assert_eq!(
10570            entity.diagnostics.default_branch_rung,
10571            Some(1),
10572            "an override must be recorded as rung 1"
10573        );
10574    }
10575
10576    /// `probe_now`'s synchronous path carries the same override wiring as
10577    /// `refresh`, proven directly since a Launcher return uses it without ever
10578    /// calling `refresh` first.
10579    #[test]
10580    fn a_per_repo_override_also_resolves_through_probe_now() {
10581        let dir = tempfile::tempdir().expect("temp dir");
10582        let root = root_of(&dir);
10583        let repo = root.join("repo");
10584        init_repo_with_a_commit(&repo);
10585
10586        let core = Core::start_discovered(spec_with_overrides(
10587            vec![root],
10588            vec![RepoOverride {
10589                path: repo.clone(),
10590                default_branch: Some("release".to_string()),
10591                excluded: false,
10592            }],
10593        ));
10594        let key = core.snapshot().entities[0].key.clone();
10595
10596        let entity = core.probe_now(&key);
10597
10598        match entity.default_branch.settled() {
10599            // No remote at all: the override still answers, using the bare name.
10600            Some(Settled::Known {
10601                value,
10602                at: _,
10603                stale: _,
10604            }) => assert_eq!(value.name(), "release"),
10605            other => panic!("expected the override's own answer, got {other:?}"),
10606        }
10607        assert_eq!(entity.diagnostics.default_branch_rung, Some(1));
10608    }
10609
10610    /// The three named ways rung 4 is reached are recorded distinctly, not merged
10611    /// into one opaque "gave up" fact: no remote at all, two or more remotes with
10612    /// none named `origin`, and a chosen remote whose tracking refs matched
10613    /// nothing in the name list.
10614    #[test]
10615    fn reaching_rung_four_with_no_remote_at_all_records_why() {
10616        let dir = tempfile::tempdir().expect("temp dir");
10617        let root = root_of(&dir);
10618        let repo = root.join("repo");
10619        init_repo_with_a_commit(&repo);
10620
10621        let core = Core::start_discovered(spec(vec![root]));
10622        let key = core.snapshot().entities[0].key.clone();
10623
10624        core.refresh(std::slice::from_ref(&key));
10625        let settled = core.settle();
10626        let entity = &settled.entities[0];
10627
10628        assert_eq!(entity.diagnostics.default_branch_rung, Some(4));
10629        assert_eq!(
10630            entity.diagnostics.default_branch_stopped,
10631            Some(DefaultBranchStopped::NoRemote)
10632        );
10633    }
10634
10635    #[test]
10636    fn reaching_rung_four_with_two_unnamed_remotes_records_why() {
10637        let dir = tempfile::tempdir().expect("temp dir");
10638        let root = root_of(&dir);
10639        let repo = root.join("repo");
10640        init_repo_with_a_commit(&repo);
10641        git(
10642            &repo,
10643            &[
10644                "remote",
10645                "add",
10646                "fork-one",
10647                "https://example.invalid/one.git",
10648            ],
10649        );
10650        git(
10651            &repo,
10652            &[
10653                "remote",
10654                "add",
10655                "fork-two",
10656                "https://example.invalid/two.git",
10657            ],
10658        );
10659
10660        let core = Core::start_discovered(spec(vec![root]));
10661        let key = core.snapshot().entities[0].key.clone();
10662
10663        core.refresh(std::slice::from_ref(&key));
10664        let settled = core.settle();
10665        let entity = &settled.entities[0];
10666
10667        assert_eq!(entity.diagnostics.default_branch_rung, Some(4));
10668        assert_eq!(
10669            entity.diagnostics.default_branch_stopped,
10670            Some(DefaultBranchStopped::AmbiguousRemote)
10671        );
10672    }
10673
10674    #[test]
10675    fn reaching_rung_four_with_a_chosen_remote_and_no_matching_ref_records_why() {
10676        let dir = tempfile::tempdir().expect("temp dir");
10677        let root = root_of(&dir);
10678        let repo = root.join("repo");
10679        init_repo_with_a_commit(&repo);
10680        git(
10681            &repo,
10682            &[
10683                "remote",
10684                "add",
10685                "origin",
10686                "https://example.invalid/repo.git",
10687            ],
10688        );
10689        // A remote-tracking ref exists, but under a name outside rung 3's list, and
10690        // there is no origin/HEAD at all.
10691        let sha = head_sha(&repo);
10692        git(&repo, &["update-ref", "refs/remotes/origin/feature", &sha]);
10693
10694        let core = Core::start_discovered(spec(vec![root]));
10695        let key = core.snapshot().entities[0].key.clone();
10696
10697        core.refresh(std::slice::from_ref(&key));
10698        let settled = core.settle();
10699        let entity = &settled.entities[0];
10700
10701        assert_eq!(entity.diagnostics.default_branch_rung, Some(4));
10702        assert_eq!(
10703            entity.diagnostics.default_branch_stopped,
10704            Some(DefaultBranchStopped::NameListExhausted)
10705        );
10706    }
10707
10708    /// A Repo with no override and no resolvable remote reaches rung 4: Unknown,
10709    /// never Failed, which stays reserved for a git error.
10710    #[test]
10711    fn a_repo_with_nothing_to_resolve_settles_unknown_never_failed() {
10712        let dir = tempfile::tempdir().expect("temp dir");
10713        let root = root_of(&dir);
10714        let repo = root.join("repo");
10715        init_repo_with_a_commit(&repo);
10716
10717        let core = Core::start_discovered(spec(vec![root]));
10718        let key = core.snapshot().entities[0].key.clone();
10719
10720        core.refresh(std::slice::from_ref(&key));
10721        let settled = core.settle();
10722        let entity = &settled.entities[0];
10723
10724        assert!(matches!(
10725            entity.default_branch.settled(),
10726            Some(Settled::Unknown(Unknown::NoDefaultBranch))
10727        ));
10728        assert_eq!(entity.diagnostics.default_branch_rung, Some(4));
10729    }
10730
10731    /// The seam this proves: a stale symbolic `origin/HEAD` reaches all the way
10732    /// through `Core::refresh` and `settle` into `Diagnostics`, not just the
10733    /// fallen-through rung 3 answer, since the spec requires recording that the
10734    /// stale case is what happened rather than leaving the same trail a merely
10735    /// absent `origin/HEAD` would.
10736    #[test]
10737    fn a_stale_remote_head_is_recorded_in_diagnostics_through_a_real_refresh() {
10738        let dir = tempfile::tempdir().expect("temp dir");
10739        let root = root_of(&dir);
10740        let repo = root.join("repo");
10741        init_repo_with_a_commit(&repo);
10742        git(
10743            &repo,
10744            &[
10745                "remote",
10746                "add",
10747                "origin",
10748                "https://example.invalid/repo.git",
10749            ],
10750        );
10751        let sha = head_sha(&repo);
10752        git(&repo, &["update-ref", "refs/remotes/origin/trunk", &sha]);
10753        let remote_refs_dir = repo
10754            .join(".git")
10755            .join("refs")
10756            .join("remotes")
10757            .join("origin");
10758        fs::create_dir_all(&remote_refs_dir).expect("create refs/remotes/origin dir");
10759        // Points at a name never created as a ref: the stale case, not merely absent.
10760        fs::write(
10761            remote_refs_dir.join("HEAD"),
10762            "ref: refs/remotes/origin/main\n",
10763        )
10764        .expect("write HEAD");
10765
10766        let core = Core::start_discovered(spec(vec![root]));
10767        let key = core.snapshot().entities[0].key.clone();
10768
10769        core.refresh(std::slice::from_ref(&key));
10770        let settled = core.settle();
10771        let entity = &settled.entities[0];
10772
10773        match entity.default_branch.settled() {
10774            Some(Settled::Known {
10775                value,
10776                at: _,
10777                stale: _,
10778            }) => {
10779                assert_eq!(value.name(), "origin/trunk")
10780            }
10781            other => panic!("expected the name list's answer, got {other:?}"),
10782        }
10783        assert!(
10784            entity.diagnostics.default_branch_rung_two_stale,
10785            "a stale origin/HEAD target must be recorded on the entity's diagnostics"
10786        );
10787    }
10788
10789    /// A resolvable `origin/HEAD` must never be marked stale, so the flag actually
10790    /// distinguishes the two cases rather than always being set once rung 2 runs.
10791    #[test]
10792    fn a_resolvable_remote_head_is_not_recorded_as_stale() {
10793        let dir = tempfile::tempdir().expect("temp dir");
10794        let root = root_of(&dir);
10795        let repo = root.join("repo");
10796        init_repo_with_a_commit(&repo);
10797        git(
10798            &repo,
10799            &[
10800                "remote",
10801                "add",
10802                "origin",
10803                "https://example.invalid/repo.git",
10804            ],
10805        );
10806        let sha = head_sha(&repo);
10807        git(&repo, &["update-ref", "refs/remotes/origin/main", &sha]);
10808        let remote_refs_dir = repo
10809            .join(".git")
10810            .join("refs")
10811            .join("remotes")
10812            .join("origin");
10813        fs::create_dir_all(&remote_refs_dir).expect("create refs/remotes/origin dir");
10814        fs::write(
10815            remote_refs_dir.join("HEAD"),
10816            "ref: refs/remotes/origin/main\n",
10817        )
10818        .expect("write HEAD");
10819
10820        let core = Core::start_discovered(spec(vec![root]));
10821        let key = core.snapshot().entities[0].key.clone();
10822
10823        core.refresh(std::slice::from_ref(&key));
10824        let settled = core.settle();
10825        let entity = &settled.entities[0];
10826
10827        assert!(!entity.diagnostics.default_branch_rung_two_stale);
10828    }
10829
10830    /// The defining behaviour for per-Repo matching: one `[[repo]]` entry naming
10831    /// only the parent Repo's own path still applies to a linked Worktree sharing
10832    /// its common dir, proven against a real `git worktree add` rather than a
10833    /// hand-built stand-in for the on-disk relationship.
10834    #[test]
10835    fn one_override_on_a_repos_path_covers_a_worktree_sharing_its_common_dir() {
10836        let dir = tempfile::tempdir().expect("temp dir");
10837        let root = root_of(&dir);
10838        let parent = root.join("parent");
10839        init_repo_with_a_commit(&parent);
10840        let worktree = root.join("worktree");
10841        git(
10842            &parent,
10843            &[
10844                "worktree",
10845                "add",
10846                "-b",
10847                "feature",
10848                worktree.to_str().expect("utf8 path"),
10849            ],
10850        );
10851
10852        let core = Core::start_discovered(spec_with_overrides(
10853            vec![root],
10854            vec![RepoOverride {
10855                path: parent.clone(),
10856                default_branch: None,
10857                excluded: true,
10858            }],
10859        ));
10860        let snapshot = core.snapshot();
10861
10862        for entity in &snapshot.entities {
10863            assert!(
10864                entity.excluded,
10865                "both the Repo and its Worktree must inherit the entry declared on the Repo's own path, entity: {:?}",
10866                entity.key
10867            );
10868        }
10869        assert_eq!(
10870            snapshot.entities.len(),
10871            2,
10872            "expected the parent plus its worktree"
10873        );
10874    }
10875
10876    /// The other direction: an entry naming a Worktree's own path beats the entry
10877    /// it would otherwise inherit from the Repo it shares a common dir with, while
10878    /// a second Worktree with no entry of its own still inherits the Repo's entry.
10879    #[test]
10880    fn an_entry_naming_a_worktrees_own_path_beats_the_inherited_one() {
10881        let dir = tempfile::tempdir().expect("temp dir");
10882        let root = root_of(&dir);
10883        let parent = root.join("parent");
10884        init_repo_with_a_commit(&parent);
10885        let worktree_own = root.join("worktree-own");
10886        let worktree_inherits = root.join("worktree-inherits");
10887        git(
10888            &parent,
10889            &[
10890                "worktree",
10891                "add",
10892                "-b",
10893                "feature-own",
10894                worktree_own.to_str().expect("utf8 path"),
10895            ],
10896        );
10897        git(
10898            &parent,
10899            &[
10900                "worktree",
10901                "add",
10902                "-b",
10903                "feature-inherits",
10904                worktree_inherits.to_str().expect("utf8 path"),
10905            ],
10906        );
10907
10908        let core = Core::start_discovered(spec_with_overrides(
10909            vec![root],
10910            vec![
10911                RepoOverride {
10912                    path: parent.clone(),
10913                    default_branch: None,
10914                    excluded: true,
10915                },
10916                RepoOverride {
10917                    path: worktree_own.clone(),
10918                    default_branch: None,
10919                    excluded: false,
10920                },
10921            ],
10922        ));
10923        let snapshot = core.snapshot();
10924
10925        let find = |path: &Path| {
10926            snapshot
10927                .entities
10928                .iter()
10929                .find(|entity| entity.key.path() == path)
10930                .unwrap_or_else(|| panic!("entity at {path:?} present"))
10931        };
10932
10933        assert!(
10934            find(&parent).excluded,
10935            "the parent Repo has no entry of its own and inherits the excluding one"
10936        );
10937        assert!(
10938            !find(&worktree_own).excluded,
10939            "the Worktree named directly by its own path must use its own entry, not the inherited one"
10940        );
10941        assert!(
10942            find(&worktree_inherits).excluded,
10943            "a sibling Worktree with no entry of its own still inherits the Repo's entry"
10944        );
10945    }
10946
10947    /// A Submodule's own common dir differs from its parent's
10948    /// (`<parent common dir>/modules/<name>`), so an entry naming only the
10949    /// parent's path can never also exclude the parent's Submodule: the entry
10950    /// covers the parent and its Worktrees, never a Submodule reached through it.
10951    #[test]
10952    fn an_override_on_the_parents_path_never_excludes_its_submodule() {
10953        let dir = tempfile::tempdir().expect("temp dir");
10954        let root = root_of(&dir);
10955        let parent = root.join("parent");
10956        init_repo_with_a_commit(&parent);
10957        fs::write(
10958            parent.join(".gitmodules"),
10959            "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
10960        )
10961        .expect("write .gitmodules");
10962        fs::create_dir_all(parent.join("vendor").join("lib")).expect("create submodule dir");
10963
10964        let core = Core::start_discovered(spec_with_overrides(
10965            vec![root],
10966            vec![RepoOverride {
10967                path: parent.clone(),
10968                default_branch: None,
10969                excluded: true,
10970            }],
10971        ));
10972        let snapshot = core.snapshot();
10973
10974        let submodule = snapshot
10975            .entities
10976            .iter()
10977            .find(|entity| matches!(entity.kind, Kind::Submodule))
10978            .expect("the submodule is still discovered and listed");
10979        assert!(
10980            !submodule.excluded,
10981            "an entry naming only the parent's path must never reach a Submodule, \
10982             whose own common dir differs from its parent's"
10983        );
10984    }
10985
10986    /// The seam this proves: `Core::default_branch_chain_reads_for_test` counts
10987    /// how many times a `refresh` actually computed the default-branch chain's
10988    /// per-common-dir facts (`default_branch::ChainFacts::resolve`, the loose-file
10989    /// read plus the reference lookups), rather than reusing an already-computed
10990    /// answer for a common dir another entity in the same Generation already paid
10991    /// for. Reading the count off `Core` this way is the seam, not an internal:
10992    /// it is a named, stable test-only entry point in the same
10993    /// `#[cfg(test)] impl Core` family as `cached_repo_handle_for_test`, which
10994    /// already proves a different sharing question the same way. There is no
10995    /// black-box way to observe "how many times an internal read ran" through
10996    /// `Snapshot` alone, since two different common dirs can legitimately answer
10997    /// with the same branch name.
10998    ///
10999    /// Three Worktrees share one common dir with their Repo (four entities); a
11000    /// second, unrelated Repo has its own. Memoised, the count is 2, the number of
11001    /// distinct common dirs; unmemoised, it is 4, the number of entities.
11002    #[test]
11003    fn the_default_branch_chain_is_memoised_once_per_common_dir_per_generation() {
11004        let dir = tempfile::tempdir().expect("temp dir");
11005        let root = root_of(&dir);
11006        let parent = root.join("parent");
11007        init_repo_with_a_commit(&parent);
11008        for name in ["wt-a", "wt-b", "wt-c"] {
11009            let worktree = root.join(name);
11010            git(
11011                &parent,
11012                &[
11013                    "worktree",
11014                    "add",
11015                    "-b",
11016                    name,
11017                    worktree.to_str().expect("utf8 path"),
11018                ],
11019            );
11020        }
11021        let other_repo = root.join("other");
11022        init_repo_with_a_commit(&other_repo);
11023
11024        let (core, launched) = started_and_settled(spec(vec![root]));
11025        let keys: Vec<EntityKey> = launched
11026            .entities
11027            .iter()
11028            .map(|entity| entity.key.clone())
11029            .collect();
11030        assert_eq!(
11031            keys.len(),
11032            5,
11033            "expected the parent, its three worktrees and the unrelated repo"
11034        );
11035
11036        core.refresh(&keys);
11037        core.settle();
11038
11039        assert_eq!(
11040            core.default_branch_chain_reads_for_test(),
11041            2,
11042            "four entities span exactly two common dirs; a memoised chain reads \
11043             each common dir once, not once per entity"
11044        );
11045
11046        // A second Generation pays the same two reads again. A cache hoisted onto
11047        // `Core` would answer this refresh for free and read 0, which is the
11048        // persistence ADR 0006 refuses.
11049        core.refresh(&keys);
11050        core.settle();
11051        assert_eq!(
11052            core.default_branch_chain_reads_for_test(),
11053            2,
11054            "the memo lives inside one Generation's dispatch; the next Generation \
11055             recomputes rather than inheriting it"
11056        );
11057    }
11058
11059    /// The same proof as `the_default_branch_chain_is_memoised_once_per_common_dir_per_generation`,
11060    /// for patch equivalence's own expensive half: two sibling Worktrees, each
11061    /// with a live upstream and unmerged work of its own, share one common dir
11062    /// and must scan its default-branch history once between them, not twice;
11063    /// an unrelated Repo's own Worktree, in its own common dir, pays for a
11064    /// second scan. Both entities settling (`Active`, since neither's work
11065    /// actually landed) is what proves the second pass ran for both rather than
11066    /// one being cancelled or skipped, which would otherwise let a
11067    /// once-per-entity implementation coincidentally also read 2.
11068    #[test]
11069    fn patch_equivalence_is_memoised_once_per_common_dir_per_generation() {
11070        let dir = tempfile::tempdir().expect("temp dir");
11071        let root = root_of(&dir);
11072        let parent = root.join("parent");
11073        init_repo_with_a_commit(&parent);
11074        git(
11075            &parent,
11076            &[
11077                "remote",
11078                "add",
11079                "origin",
11080                "https://example.invalid/repo.git",
11081            ],
11082        );
11083        let base_sha = head_sha(&parent);
11084        git(
11085            &parent,
11086            &["update-ref", "refs/remotes/origin/main", &base_sha],
11087        );
11088        for name in ["feature-x", "feature-y"] {
11089            let worktree = root.join(name);
11090            git(
11091                &parent,
11092                &[
11093                    "worktree",
11094                    "add",
11095                    "-b",
11096                    name,
11097                    worktree.to_str().expect("utf8 path"),
11098                ],
11099            );
11100            fs::write(worktree.join(format!("{name}.txt")), "unmerged\n")
11101                .expect("write worktree file");
11102            git(&worktree, &["add", "."]);
11103            git(&worktree, &["commit", "-m", "unmerged work"]);
11104            let tip_sha = head_sha(&worktree);
11105            git(
11106                &parent,
11107                &["config", &format!("branch.{name}.remote"), "origin"],
11108            );
11109            git(
11110                &parent,
11111                &[
11112                    "config",
11113                    &format!("branch.{name}.merge"),
11114                    &format!("refs/heads/{name}"),
11115                ],
11116            );
11117            git(
11118                &parent,
11119                &[
11120                    "update-ref",
11121                    &format!("refs/remotes/origin/{name}"),
11122                    &tip_sha,
11123                ],
11124            );
11125        }
11126
11127        let other_parent = root.join("other");
11128        init_repo_with_a_commit(&other_parent);
11129        git(
11130            &other_parent,
11131            &[
11132                "remote",
11133                "add",
11134                "origin",
11135                "https://example.invalid/other.git",
11136            ],
11137        );
11138        let other_base_sha = head_sha(&other_parent);
11139        git(
11140            &other_parent,
11141            &["update-ref", "refs/remotes/origin/main", &other_base_sha],
11142        );
11143        let other_worktree = root.join("other-feature");
11144        git(
11145            &other_parent,
11146            &[
11147                "worktree",
11148                "add",
11149                "-b",
11150                "other-feature",
11151                other_worktree.to_str().expect("utf8 path"),
11152            ],
11153        );
11154        fs::write(other_worktree.join("other.txt"), "unmerged\n").expect("write worktree file");
11155        git(&other_worktree, &["add", "."]);
11156        git(&other_worktree, &["commit", "-m", "unmerged work"]);
11157        let other_tip_sha = head_sha(&other_worktree);
11158        git(
11159            &other_parent,
11160            &["config", "branch.other-feature.remote", "origin"],
11161        );
11162        git(
11163            &other_parent,
11164            &[
11165                "config",
11166                "branch.other-feature.merge",
11167                "refs/heads/other-feature",
11168            ],
11169        );
11170        git(
11171            &other_parent,
11172            &[
11173                "update-ref",
11174                "refs/remotes/origin/other-feature",
11175                &other_tip_sha,
11176            ],
11177        );
11178
11179        let (core, launched) = started_and_settled(spec(vec![root]));
11180        let keys: Vec<EntityKey> = launched
11181            .entities
11182            .iter()
11183            .map(|entity| entity.key.clone())
11184            .collect();
11185        assert_eq!(
11186            keys.len(),
11187            5,
11188            "expected two parents plus their three worktrees"
11189        );
11190
11191        core.refresh(&keys);
11192        let settled = core.settle();
11193
11194        let worktree_states: Vec<_> = settled
11195            .entities
11196            .iter()
11197            .filter(|entity| matches!(entity.kind, Kind::Worktree))
11198            .map(|entity| entity.state.settled())
11199            .collect();
11200        assert_eq!(worktree_states.len(), 3, "expected three worktree rows");
11201        for settled_state in &worktree_states {
11202            assert!(
11203                matches!(
11204                    settled_state,
11205                    Some(Settled::Known {
11206                        value: WorktreeState::Active,
11207                        at: _,
11208                        stale: _
11209                    })
11210                ),
11211                "expected every worktree's genuinely unmerged work to settle Active, got {settled_state:?}"
11212            );
11213        }
11214
11215        assert_eq!(
11216            core.patch_identity_reads_for_test(),
11217            2,
11218            "two worktrees share one common dir and must scan its default-branch \
11219             history once between them, not once per entity; the unrelated repo's \
11220             own worktree pays for a second scan"
11221        );
11222
11223        // A second Generation pays for the same two scans again: a cache hoisted
11224        // onto `Core` would answer this refresh for free and read 0.
11225        core.refresh(&keys);
11226        core.settle();
11227        assert_eq!(
11228            core.patch_identity_reads_for_test(),
11229            2,
11230            "the memo lives inside one Generation's dispatch; the next Generation \
11231             recomputes rather than inheriting it"
11232        );
11233    }
11234
11235    /// Criterion 3's widen direction, end to end: `feature-deep` forks at the
11236    /// parent commit `deep_fork_sha` and is squashed into main immediately
11237    /// afterwards; `feature-shallow` forks at that squash commit (strictly more
11238    /// recent, so its own merge base is shallower) and is squashed in turn to
11239    /// produce `main`'s tip. The deepest merge base among the two siblings is
11240    /// `feature-deep`'s own, `deep_fork_sha`, not `feature-shallow`'s.
11241    ///
11242    /// A scan bounded by the *shallowest* sibling's merge base instead of the
11243    /// deepest would stop before reaching the commit that squashed
11244    /// `feature-deep` in, since that commit sits strictly between the two
11245    /// bounds: `feature-deep` would then settle `Active` instead of `Merged`.
11246    /// This is a smoke test for that outcome through the real dispatch
11247    /// pipeline, not a proof: rayon's work stealing gives dispatch `order` no
11248    /// ordering guarantee, so `feature-deep` landing last here is a nudge
11249    /// towards, never proof of, exercising a lazy first-arrival bound.
11250    /// `bound_gate_deepest_folds_every_candidate_regardless_of_report_order`
11251    /// below is what deterministically proves the bound is collected from
11252    /// every sibling rather than computed lazily from whichever arrives first.
11253    #[test]
11254    fn an_entity_whose_merge_base_is_deeper_than_its_siblings_widens_the_shared_scan() {
11255        let dir = tempfile::tempdir().expect("temp dir");
11256        let root = root_of(&dir);
11257        let parent = root.join("parent");
11258        init_repo_with_a_commit(&parent);
11259        git(
11260            &parent,
11261            &[
11262                "remote",
11263                "add",
11264                "origin",
11265                "https://example.invalid/repo.git",
11266            ],
11267        );
11268        let deep_fork_sha = head_sha(&parent);
11269
11270        git(&parent, &["branch", "feature-deep"]);
11271        let deep_worktree = root.join("feature-deep");
11272        git(
11273            &parent,
11274            &[
11275                "worktree",
11276                "add",
11277                deep_worktree.to_str().expect("utf8 path"),
11278                "feature-deep",
11279            ],
11280        );
11281        fs::write(deep_worktree.join("deep.txt"), "deep work\n").expect("write deep.txt");
11282        git(&deep_worktree, &["add", "."]);
11283        git(&deep_worktree, &["commit", "-m", "deep work"]);
11284        let deep_tip_sha = head_sha(&deep_worktree);
11285
11286        git(&parent, &["merge", "--squash", "feature-deep"]);
11287        git(&parent, &["commit", "-m", "squashed deep"]);
11288        let shallow_fork_sha = head_sha(&parent);
11289
11290        git(&parent, &["branch", "feature-shallow"]);
11291        let shallow_worktree = root.join("feature-shallow");
11292        git(
11293            &parent,
11294            &[
11295                "worktree",
11296                "add",
11297                shallow_worktree.to_str().expect("utf8 path"),
11298                "feature-shallow",
11299            ],
11300        );
11301        fs::write(shallow_worktree.join("shallow.txt"), "shallow work\n")
11302            .expect("write shallow.txt");
11303        git(&shallow_worktree, &["add", "."]);
11304        git(&shallow_worktree, &["commit", "-m", "shallow work"]);
11305        let shallow_tip_sha = head_sha(&shallow_worktree);
11306
11307        git(&parent, &["merge", "--squash", "feature-shallow"]);
11308        git(&parent, &["commit", "-m", "squashed shallow"]);
11309        let main_tip_sha = head_sha(&parent);
11310        assert_ne!(
11311            deep_fork_sha, shallow_fork_sha,
11312            "the two siblings must fork at genuinely different commits"
11313        );
11314
11315        git(
11316            &parent,
11317            &["update-ref", "refs/remotes/origin/main", &main_tip_sha],
11318        );
11319        for (name, tip_sha) in [
11320            ("feature-deep", &deep_tip_sha),
11321            ("feature-shallow", &shallow_tip_sha),
11322        ] {
11323            git(
11324                &parent,
11325                &["config", &format!("branch.{name}.remote"), "origin"],
11326            );
11327            git(
11328                &parent,
11329                &[
11330                    "config",
11331                    &format!("branch.{name}.merge"),
11332                    &format!("refs/heads/{name}"),
11333                ],
11334            );
11335            git(
11336                &parent,
11337                &[
11338                    "update-ref",
11339                    &format!("refs/remotes/origin/{name}"),
11340                    tip_sha,
11341                ],
11342            );
11343        }
11344
11345        let (core, snapshot) = started_and_settled(spec(vec![root]));
11346        let deep_key = snapshot
11347            .entities
11348            .iter()
11349            .find(|entity| entity.key.path() == deep_worktree)
11350            .expect("feature-deep worktree discovered")
11351            .key
11352            .clone();
11353        let shallow_key = snapshot
11354            .entities
11355            .iter()
11356            .find(|entity| entity.key.path() == shallow_worktree)
11357            .expect("feature-shallow worktree discovered")
11358            .key
11359            .clone();
11360        let parent_key = snapshot
11361            .entities
11362            .iter()
11363            .find(|entity| entity.key.path() == parent)
11364            .expect("parent repo discovered")
11365            .key
11366            .clone();
11367        // The deepest sibling dispatched last, so a lazy bound computed from
11368        // whichever entity arrives first would reach for the shallow sibling's
11369        // own narrower merge base instead.
11370        let order = vec![parent_key, shallow_key.clone(), deep_key.clone()];
11371
11372        core.refresh(&order);
11373        let settled = core.settle();
11374
11375        let state_of = |key: &EntityKey| {
11376            settled
11377                .entities
11378                .iter()
11379                .find(|entity| &entity.key == key)
11380                .and_then(|entity| entity.state.settled())
11381                .cloned()
11382        };
11383        assert!(
11384            matches!(
11385                state_of(&deep_key),
11386                Some(Settled::Known {
11387                    value: WorktreeState::Merged,
11388                    at: _,
11389                    stale: _
11390                })
11391            ),
11392            "expected the deepest sibling's own squash commit to be found once the scan is \
11393             bounded by the deepest merge base, got {:?}",
11394            state_of(&deep_key)
11395        );
11396        assert!(
11397            matches!(
11398                state_of(&shallow_key),
11399                Some(Settled::Known {
11400                    value: WorktreeState::Merged,
11401                    at: _,
11402                    stale: _
11403                })
11404            ),
11405            "expected the shallow sibling to settle Merged too, got {:?}",
11406            state_of(&shallow_key)
11407        );
11408        assert_eq!(
11409            core.patch_identity_reads_for_test(),
11410            1,
11411            "both worktrees share one common dir and must still scan its default-branch \
11412             history once between them, not once per entity"
11413        );
11414        assert_eq!(
11415            core.patch_scan_bounds_for_test(),
11416            vec![Some(id(&deep_fork_sha))],
11417            "the one shared scan that ran must have been bounded by the deepest sibling's own \
11418             merge base, not the shallower one's"
11419        );
11420    }
11421
11422    fn id(sha: &str) -> gix::ObjectId {
11423        gix::ObjectId::from_hex(sha.as_bytes()).expect("parse sha")
11424    }
11425
11426    /// Criterion 1, proved at the barrier itself rather than through rayon's
11427    /// unordered dispatch: `shallow` is reported before `deep` on purpose, so a
11428    /// lazy first-arrival implementation (answer with whichever candidate
11429    /// showed up first, rather than collecting every sibling's own merge base)
11430    /// would settle on `shallow` and fail this assertion. `deep` is an ancestor
11431    /// of `shallow`, so the correct fold finds it regardless of report order.
11432    #[test]
11433    fn bound_gate_deepest_folds_every_candidate_regardless_of_report_order() {
11434        let dir = tempfile::tempdir().expect("temp dir");
11435        let repo_path = root_of(&dir).join("repo");
11436        init_repo_with_a_commit(&repo_path);
11437        let deep_sha = id(&head_sha(&repo_path));
11438        fs::write(repo_path.join("child.txt"), "child\n").expect("write child.txt");
11439        git(&repo_path, &["add", "."]);
11440        git(&repo_path, &["commit", "-m", "child of deep"]);
11441        let shallow_sha = id(&head_sha(&repo_path));
11442
11443        let repo = gix::open(&repo_path).expect("open repo");
11444        let gate = BoundGate::new(2);
11445        gate.report(Some(shallow_sha));
11446        gate.report(Some(deep_sha));
11447
11448        assert_eq!(
11449            gate.deepest(&repo),
11450            Some(deep_sha),
11451            "the deepest candidate must win even though the shallower one reported first"
11452        );
11453    }
11454
11455    /// Deterministic proof that [`probe_patch_equivalence`] itself consults
11456    /// [`BoundGate::deepest`] for the bound it hands to
11457    /// [`patch_equivalence::scan_default_branch`], rather than reaching for its
11458    /// own entity's merge base. Unlike
11459    /// `bound_gate_deepest_folds_every_candidate_regardless_of_report_order`,
11460    /// which proves `BoundGate` and `deepest_merge_base` correct in isolation,
11461    /// this drives `probe_patch_equivalence` itself and inspects what it
11462    /// actually recorded into `memo.scan_bounds`. `deep_sha`'s contribution is
11463    /// pre-reported by hand, standing in for a sibling entity that already ran
11464    /// this Generation; the one entity this test drives through the real
11465    /// function arrives at `shallow_sha`, so its own merge base against
11466    /// `default_tip` is `shallow_sha`, strictly shallower than `deep_sha`. A
11467    /// regression that bounds the scan by the arriving entity's own merge base
11468    /// instead of the gate's answer would record `shallow_sha` here, and would
11469    /// do so every single run: unlike the integration smoke test below, there
11470    /// is no rayon dispatch order here to sometimes get it right by accident.
11471    #[test]
11472    fn probe_patch_equivalence_bounds_the_scan_by_the_gates_deepest_not_its_own_merge_base() {
11473        let dir = tempfile::tempdir().expect("temp dir");
11474        let repo_path = root_of(&dir).join("repo");
11475        init_repo_with_a_commit(&repo_path);
11476        let deep_sha = id(&head_sha(&repo_path));
11477        fs::write(repo_path.join("child.txt"), "child\n").expect("write child.txt");
11478        git(&repo_path, &["add", "."]);
11479        git(&repo_path, &["commit", "-m", "child of deep"]);
11480        let shallow_sha_hex = head_sha(&repo_path);
11481        let shallow_sha = id(&shallow_sha_hex);
11482        fs::write(repo_path.join("tip.txt"), "tip\n").expect("write tip.txt");
11483        git(&repo_path, &["add", "."]);
11484        git(&repo_path, &["commit", "-m", "default tip"]);
11485        let default_tip_hex = head_sha(&repo_path);
11486
11487        let repo = gix::open(&repo_path).expect("open repo");
11488        // What `landing::probe` hands over for a Worktree entity sitting at
11489        // `shallow`, whose own tip is not main's actual tip.
11490        let outstanding = landing::Outstanding {
11491            entity_tip: shallow_sha,
11492            default_tip: id(&default_tip_hex),
11493            merge_base: Some(shallow_sha),
11494        };
11495        let common_dir: Arc<Path> = Arc::from(repo_path.join(".git"));
11496        let cancel = AtomicBool::new(false);
11497        let patch_cache: PatchIdentityCache = Mutex::new(HashMap::new());
11498        let patch_reads = AtomicUsize::new(0);
11499        let patch_scan_bounds: Mutex<Vec<Option<gix::ObjectId>>> = Mutex::new(Vec::new());
11500        let memo = PatchEquivalenceMemo {
11501            cache: &patch_cache,
11502            reads: &patch_reads,
11503            scan_bounds: &patch_scan_bounds,
11504        };
11505        // Two entities share this common dir this Generation: `deep_sha` stands
11506        // in for a sibling that already reported its own, deeper merge base;
11507        // `shallow` is the one entity driven through the real function below.
11508        let gate = BoundGate::new(2);
11509        gate.report(Some(deep_sha));
11510        let mut report = GateReport::new(&gate);
11511
11512        probe_patch_equivalence(
11513            &repo,
11514            &outstanding,
11515            &common_dir,
11516            &cancel,
11517            &memo,
11518            &mut report,
11519        );
11520
11521        assert_eq!(
11522            patch_scan_bounds.lock().unwrap().as_slice(),
11523            [Some(deep_sha)],
11524            "the scan must be bounded by the deepest sibling's merge base, not shallow's own \
11525             ({shallow_sha:?})"
11526        );
11527    }
11528
11529    /// The carry itself: [`probe_patch_equivalence`] diffs the entity's own
11530    /// range from the merge base `landing::probe` handed over, rather than
11531    /// walking the same commit pair a second time. `mid_sha` is a real commit
11532    /// on `feature` but not its fork point, so the two answers differ: from the
11533    /// fork point the range is the whole squashed change and settles `Merged`,
11534    /// from `mid_sha` it is only `b.txt` and settles `Active`. A regression that
11535    /// recomputed the base here would answer `Merged` and fail this test.
11536    #[test]
11537    fn probe_patch_equivalence_diffs_from_the_merge_base_it_was_handed() {
11538        let dir = tempfile::tempdir().expect("temp dir");
11539        let repo_path = root_of(&dir).join("repo");
11540        init_repo_with_a_commit(&repo_path);
11541        let fork_point_hex = head_sha(&repo_path);
11542        git(&repo_path, &["checkout", "-b", "feature"]);
11543        fs::write(repo_path.join("a.txt"), "one\n").expect("write a.txt");
11544        git(&repo_path, &["add", "a.txt"]);
11545        git(&repo_path, &["commit", "-m", "add a"]);
11546        let mid_sha = id(&head_sha(&repo_path));
11547        fs::write(repo_path.join("b.txt"), "two\n").expect("write b.txt");
11548        git(&repo_path, &["add", "b.txt"]);
11549        git(&repo_path, &["commit", "-m", "add b"]);
11550        let feature_sha = id(&head_sha(&repo_path));
11551        git(&repo_path, &["checkout", "-B", "main", &fork_point_hex]);
11552        git(&repo_path, &["merge", "--squash", "feature"]);
11553        git(&repo_path, &["commit", "-m", "squashed feature"]);
11554        let main_sha = id(&head_sha(&repo_path));
11555
11556        let repo = gix::open(&repo_path).expect("open repo");
11557        // What `landing::probe` hands over, with a base halfway along the
11558        // branch standing in for one only this pass could know.
11559        let outstanding = landing::Outstanding {
11560            entity_tip: feature_sha,
11561            default_tip: main_sha,
11562            merge_base: Some(mid_sha),
11563        };
11564        let common_dir: Arc<Path> = Arc::from(repo_path.join(".git"));
11565        let cancel = AtomicBool::new(false);
11566        let patch_cache: PatchIdentityCache = Mutex::new(HashMap::new());
11567        let patch_reads = AtomicUsize::new(0);
11568        let patch_scan_bounds: Mutex<Vec<Option<gix::ObjectId>>> = Mutex::new(Vec::new());
11569        let memo = PatchEquivalenceMemo {
11570            cache: &patch_cache,
11571            reads: &patch_reads,
11572            scan_bounds: &patch_scan_bounds,
11573        };
11574        let gate = BoundGate::new(1);
11575        let mut report = GateReport::new(&gate);
11576
11577        let settled = probe_patch_equivalence(
11578            &repo,
11579            &outstanding,
11580            &common_dir,
11581            &cancel,
11582            &memo,
11583            &mut report,
11584        );
11585
11586        assert!(
11587            matches!(
11588                settled,
11589                Some(Settled::Known {
11590                    value: WorktreeState::Active,
11591                    at: _,
11592                    stale: _
11593                })
11594            ),
11595            "the range must be measured from the handed-in base ({mid_sha:?}), whose only \
11596             change the squash commit does not match, got {settled:?}"
11597        );
11598    }
11599
11600    /// The edge [`deepest_merge_base`] exists for: no entity sharing a common
11601    /// dir ever had a merge base to offer (every one settled by ancestry, was
11602    /// cancelled, or shared no history with the default branch at all), so the
11603    /// scan is left unbounded. `deepest_merge_base` returns before its first
11604    /// candidate lookup here, which is what lets this fixture skip building any
11605    /// commit history at all.
11606    #[test]
11607    fn bound_gate_deepest_with_no_candidates_leaves_the_scan_unbounded() {
11608        let dir = tempfile::tempdir().expect("temp dir");
11609        let repo_path = root_of(&dir).join("repo");
11610        gix::init(&repo_path).expect("init repo");
11611        let repo = gix::open(&repo_path).expect("open repo");
11612
11613        let gate = BoundGate::new(2);
11614        gate.report(None);
11615        gate.report(None);
11616
11617        assert_eq!(
11618            gate.deepest(&repo),
11619            None,
11620            "no contributed candidate must leave the scan unbounded"
11621        );
11622    }
11623
11624    /// `probe_patch_equivalence`'s `Ok(None)` arm bypasses the shared scan for
11625    /// an Outstanding entity with no shared history at all. `unrelated` is a
11626    /// real branch, with a live upstream so `landing::probe`
11627    /// leaves it `Outstanding`, whose own root commit shares no history with
11628    /// `main`'s, driven through `Core` end to end rather than by calling
11629    /// `probe_patch_equivalence` or `patch_equivalence::probe` directly, so a
11630    /// removed bypass (the shared scan run unconditionally instead) is
11631    /// exercised for real: `BoundGate::deepest` would then block forever on a
11632    /// scan this entity never asked for.
11633    #[test]
11634    fn an_outstanding_entity_with_no_shared_history_settles_active_without_the_shared_scan() {
11635        let dir = tempfile::tempdir().expect("temp dir");
11636        let root = root_of(&dir);
11637        let parent = root.join("parent");
11638        init_repo_with_a_commit(&parent);
11639        git(&parent, &["branch", "-M", "main"]);
11640        git(
11641            &parent,
11642            &[
11643                "remote",
11644                "add",
11645                "origin",
11646                "https://example.invalid/repo.git",
11647            ],
11648        );
11649        let main_sha = head_sha(&parent);
11650        git(
11651            &parent,
11652            &["update-ref", "refs/remotes/origin/main", &main_sha],
11653        );
11654
11655        git(&parent, &["checkout", "--orphan", "unrelated"]);
11656        git(
11657            &parent,
11658            &["commit", "--allow-empty", "-m", "unrelated root"],
11659        );
11660        let unrelated_sha = head_sha(&parent);
11661        git(&parent, &["checkout", "main"]);
11662
11663        let worktree = root.join("unrelated");
11664        git(
11665            &parent,
11666            &[
11667                "worktree",
11668                "add",
11669                worktree.to_str().expect("utf8 path"),
11670                "unrelated",
11671            ],
11672        );
11673        git(&parent, &["config", "branch.unrelated.remote", "origin"]);
11674        git(
11675            &parent,
11676            &["config", "branch.unrelated.merge", "refs/heads/unrelated"],
11677        );
11678        git(
11679            &parent,
11680            &[
11681                "update-ref",
11682                "refs/remotes/origin/unrelated",
11683                &unrelated_sha,
11684            ],
11685        );
11686
11687        let (core, snapshot) = started_and_settled(spec(vec![root]));
11688        let worktree_key = snapshot
11689            .entities
11690            .iter()
11691            .find(|entity| entity.key.path() == worktree)
11692            .expect("unrelated worktree discovered")
11693            .key
11694            .clone();
11695
11696        core.refresh(std::slice::from_ref(&worktree_key));
11697        let settled = core.settle();
11698
11699        let state = settled
11700            .entities
11701            .iter()
11702            .find(|entity| entity.key == worktree_key)
11703            .and_then(|entity| entity.state.settled())
11704            .cloned();
11705        assert!(
11706            matches!(
11707                state,
11708                Some(Settled::Known {
11709                    value: WorktreeState::Active,
11710                    at: _,
11711                    stale: _
11712                })
11713            ),
11714            "expected an Outstanding entity with no shared history to settle Active via the \
11715             bypass, got {state:?}"
11716        );
11717        assert_eq!(
11718            core.patch_identity_reads_for_test(),
11719            0,
11720            "the bypass must settle without ever running the shared scan"
11721        );
11722    }
11723
11724    // --- Phase B's comparison: the `sync` cell, end to end through a real `Core`:
11725    // the six named cases, plus the two ways "every entity, every Generation" is
11726    // most easily lost. ---
11727
11728    fn add_origin_remote(path: &Path) {
11729        git(
11730            path,
11731            &[
11732                "remote",
11733                "add",
11734                "origin",
11735                "https://example.invalid/repo.git",
11736            ],
11737        );
11738    }
11739
11740    /// Wires `branch` up to track `refs/remotes/origin/<branch>` at `upstream_sha`,
11741    /// mirroring `patch_equivalence_is_memoised_once_per_common_dir_per_generation`'s
11742    /// own fixture shape against a real disposable repo.
11743    fn set_upstream(path: &Path, branch: &str, upstream_sha: &str) {
11744        git(
11745            path,
11746            &["config", &format!("branch.{branch}.remote"), "origin"],
11747        );
11748        git(
11749            path,
11750            &[
11751                "config",
11752                &format!("branch.{branch}.merge"),
11753                &format!("refs/heads/{branch}"),
11754            ],
11755        );
11756        git(
11757            path,
11758            &[
11759                "update-ref",
11760                &format!("refs/remotes/origin/{branch}"),
11761                upstream_sha,
11762            ],
11763        );
11764    }
11765
11766    fn refresh_and_settle(core: &Core) -> crate::snapshot::Snapshot {
11767        let keys: Vec<EntityKey> = core
11768            .snapshot()
11769            .entities
11770            .iter()
11771            .map(|entity| entity.key.clone())
11772            .collect();
11773        core.refresh(&keys);
11774        core.settle()
11775    }
11776
11777    fn sync_of<'a>(
11778        snapshot: &'a crate::snapshot::Snapshot,
11779        path: &Path,
11780    ) -> Option<&'a Settled<SyncState>> {
11781        snapshot
11782            .entities
11783            .iter()
11784            .find(|entity| entity.key.path() == path)
11785            .unwrap_or_else(|| panic!("no entity for {}", path.display()))
11786            .sync
11787            .settled()
11788    }
11789
11790    /// Named case 1 of 6: an attached branch ahead of its upstream.
11791    #[test]
11792    fn an_attached_branch_ahead_of_its_upstream_reads_the_ahead_count() {
11793        let dir = tempfile::tempdir().expect("temp dir");
11794        let root = root_of(&dir);
11795        let repo = root.join("repo");
11796        init_repo_with_a_commit(&repo);
11797        let fork_sha = head_sha(&repo);
11798        add_origin_remote(&repo);
11799        set_upstream(&repo, "main", &fork_sha);
11800        git(&repo, &["commit", "--allow-empty", "-m", "local work"]);
11801
11802        let core = Core::start_discovered(spec(vec![root]));
11803        let settled = refresh_and_settle(&core);
11804
11805        match sync_of(&settled, &repo) {
11806            Some(Settled::Known {
11807                value: SyncState::Tracking(AheadBehind { ahead, behind }),
11808                at: _,
11809                stale: _,
11810            }) => {
11811                assert_eq!(*ahead, 1);
11812                assert_eq!(*behind, 0);
11813            }
11814            other => panic!("expected 1 ahead, 0 behind, got {other:?}"),
11815        }
11816    }
11817
11818    /// Named case 2 of 6: an attached branch behind its upstream.
11819    #[test]
11820    fn an_attached_branch_behind_its_upstream_reads_the_behind_count() {
11821        let dir = tempfile::tempdir().expect("temp dir");
11822        let root = root_of(&dir);
11823        let repo = root.join("repo");
11824        init_repo_with_a_commit(&repo);
11825        git(&repo, &["checkout", "-b", "temp"]);
11826        git(&repo, &["commit", "--allow-empty", "-m", "upstream work"]);
11827        let upstream_sha = head_sha(&repo);
11828        git(&repo, &["checkout", "main"]);
11829        git(&repo, &["branch", "-D", "temp"]);
11830        add_origin_remote(&repo);
11831        set_upstream(&repo, "main", &upstream_sha);
11832
11833        let core = Core::start_discovered(spec(vec![root]));
11834        let settled = refresh_and_settle(&core);
11835
11836        match sync_of(&settled, &repo) {
11837            Some(Settled::Known {
11838                value: SyncState::Tracking(AheadBehind { ahead, behind }),
11839                at: _,
11840                stale: _,
11841            }) => {
11842                assert_eq!(*ahead, 0);
11843                assert_eq!(*behind, 1);
11844            }
11845            other => panic!("expected 0 ahead, 1 behind, got {other:?}"),
11846        }
11847    }
11848
11849    /// Named case 3 of 6: an attached branch level with its upstream.
11850    #[test]
11851    fn an_attached_branch_level_with_its_upstream_reads_in_sync() {
11852        let dir = tempfile::tempdir().expect("temp dir");
11853        let root = root_of(&dir);
11854        let repo = root.join("repo");
11855        init_repo_with_a_commit(&repo);
11856        let sha = head_sha(&repo);
11857        add_origin_remote(&repo);
11858        set_upstream(&repo, "main", &sha);
11859
11860        let core = Core::start_discovered(spec(vec![root]));
11861        let settled = refresh_and_settle(&core);
11862
11863        match sync_of(&settled, &repo) {
11864            Some(Settled::Known {
11865                value:
11866                    SyncState::Tracking(AheadBehind {
11867                        ahead: 0,
11868                        behind: 0,
11869                    }),
11870                at: _,
11871                stale: _,
11872            }) => {}
11873            other => panic!("expected level with its upstream, got {other:?}"),
11874        }
11875    }
11876
11877    /// Named case 4 of 6: an attached branch tracking nothing, on a Repo that does
11878    /// have a remote. Distinguishes this from case 6 below: the absence here is the
11879    /// branch's own tracking configuration, not the Repo's remote.
11880    #[test]
11881    fn an_attached_branch_tracking_nothing_reads_no_upstream() {
11882        let dir = tempfile::tempdir().expect("temp dir");
11883        let root = root_of(&dir);
11884        let repo = root.join("repo");
11885        init_repo_with_a_commit(&repo);
11886        add_origin_remote(&repo);
11887
11888        let core = Core::start_discovered(spec(vec![root]));
11889        let settled = refresh_and_settle(&core);
11890
11891        match sync_of(&settled, &repo) {
11892            Some(Settled::Known {
11893                value: SyncState::NoUpstream,
11894                at: _,
11895                stale: _,
11896            }) => {}
11897            other => panic!("expected no upstream configured, got {other:?}"),
11898        }
11899    }
11900
11901    /// Named case 5 of 6: a detached row, on a Repo that does have a remote.
11902    /// Distinguishes this from case 6 below the same way case 4 does.
11903    #[test]
11904    fn a_detached_row_reads_no_upstream() {
11905        let dir = tempfile::tempdir().expect("temp dir");
11906        let root = root_of(&dir);
11907        let repo = root.join("repo");
11908        init_repo_with_a_commit(&repo);
11909        let first_sha = head_sha(&repo);
11910        git(&repo, &["commit", "--allow-empty", "-m", "second"]);
11911        git(&repo, &["checkout", "--detach", &first_sha]);
11912        add_origin_remote(&repo);
11913
11914        let core = Core::start_discovered(spec(vec![root]));
11915        let settled = refresh_and_settle(&core);
11916
11917        match sync_of(&settled, &repo) {
11918            Some(Settled::Known {
11919                value: SyncState::NoUpstream,
11920                at: _,
11921                stale: _,
11922            }) => {}
11923            other => panic!("expected a detached row to read no upstream, got {other:?}"),
11924        }
11925    }
11926
11927    /// Named case 6 of 6: a Repo with no remote at all. The propagation half of
11928    /// criterion 3 is the substance here, not the Repo row alone: a linked Worktree
11929    /// shares the parent's config and has no upstream of its own to speak of either,
11930    /// so it must read the exact same `NoRemote` value, not `NoUpstream`.
11931    #[test]
11932    fn a_repo_with_no_remote_reads_no_remote_on_itself_and_every_worktree() {
11933        let dir = tempfile::tempdir().expect("temp dir");
11934        let root = root_of(&dir);
11935        let parent = root.join("parent");
11936        init_repo_with_a_commit(&parent);
11937        let worktree = root.join("feature");
11938        git(
11939            &parent,
11940            &[
11941                "worktree",
11942                "add",
11943                "-b",
11944                "feature",
11945                worktree.to_str().expect("utf8 path"),
11946            ],
11947        );
11948
11949        let core = Core::start_discovered(spec(vec![root]));
11950        let settled = refresh_and_settle(&core);
11951
11952        assert_eq!(
11953            settled.entities.len(),
11954            2,
11955            "expected the parent Repo and its one linked Worktree"
11956        );
11957        for path in [&parent, &worktree] {
11958            match sync_of(&settled, path) {
11959                Some(Settled::Known {
11960                    value: SyncState::NoRemote,
11961                    at: _,
11962                    stale: _,
11963                }) => {}
11964                other => panic!(
11965                    "expected {} to read no remote at all, got {other:?}",
11966                    path.display()
11967                ),
11968            }
11969        }
11970    }
11971
11972    /// Criterion 1's "every entity" half: two sibling Worktrees under one Repo, each
11973    /// with a different sync outcome, computed together in one Generation. A test
11974    /// driving only one of them could not see an implementation that dispatches the
11975    /// comparison for a single hand-picked entity rather than every one whose HEAD
11976    /// carries a branch.
11977    #[test]
11978    fn sync_is_computed_for_every_entity_dispatched_this_generation_not_only_one() {
11979        let dir = tempfile::tempdir().expect("temp dir");
11980        let root = root_of(&dir);
11981        let parent = root.join("parent");
11982        init_repo_with_a_commit(&parent);
11983        let fork_sha = head_sha(&parent);
11984        add_origin_remote(&parent);
11985
11986        let ahead_worktree = root.join("feature-ahead");
11987        git(
11988            &parent,
11989            &[
11990                "worktree",
11991                "add",
11992                "-b",
11993                "feature-ahead",
11994                ahead_worktree.to_str().expect("utf8 path"),
11995            ],
11996        );
11997        set_upstream(&parent, "feature-ahead", &fork_sha);
11998        git(
11999            &ahead_worktree,
12000            &["commit", "--allow-empty", "-m", "unpushed"],
12001        );
12002
12003        let behind_worktree = root.join("feature-behind");
12004        git(
12005            &parent,
12006            &[
12007                "worktree",
12008                "add",
12009                "-b",
12010                "feature-behind",
12011                behind_worktree.to_str().expect("utf8 path"),
12012            ],
12013        );
12014        git(
12015            &behind_worktree,
12016            &["commit", "--allow-empty", "-m", "on the remote only"],
12017        );
12018        let ahead_of_behind_sha = head_sha(&behind_worktree);
12019        git(&behind_worktree, &["reset", "--hard", "HEAD~1"]);
12020        set_upstream(&parent, "feature-behind", &ahead_of_behind_sha);
12021
12022        let core = Core::start_discovered(spec(vec![root]));
12023        let settled = refresh_and_settle(&core);
12024
12025        match sync_of(&settled, &ahead_worktree) {
12026            Some(Settled::Known {
12027                value:
12028                    SyncState::Tracking(AheadBehind {
12029                        ahead: 1,
12030                        behind: 0,
12031                    }),
12032                at: _,
12033                stale: _,
12034            }) => {}
12035            other => panic!("expected feature-ahead to read 1 ahead, got {other:?}"),
12036        }
12037        match sync_of(&settled, &behind_worktree) {
12038            Some(Settled::Known {
12039                value:
12040                    SyncState::Tracking(AheadBehind {
12041                        ahead: 0,
12042                        behind: 1,
12043                    }),
12044                at: _,
12045                stale: _,
12046            }) => {}
12047            other => panic!("expected feature-behind to read 1 behind, got {other:?}"),
12048        }
12049    }
12050
12051    /// Criterion 1's "every Generation" half: a second, later refresh recomputes
12052    /// `sync` rather than a first Generation's answer sticking around unrefreshed.
12053    /// A test that only ever drives one Generation cannot see an implementation
12054    /// that dispatches the comparison once, at `Core::start`'s own discovery, and
12055    /// never again on an explicit `refresh`.
12056    #[test]
12057    fn sync_recomputes_on_a_second_generation_not_only_the_first() {
12058        let dir = tempfile::tempdir().expect("temp dir");
12059        let root = root_of(&dir);
12060        let repo = root.join("repo");
12061        init_repo_with_a_commit(&repo);
12062        let fork_sha = head_sha(&repo);
12063        add_origin_remote(&repo);
12064        set_upstream(&repo, "main", &fork_sha);
12065
12066        let core = Core::start_discovered(spec(vec![root]));
12067        let first = refresh_and_settle(&core);
12068        match sync_of(&first, &repo) {
12069            Some(Settled::Known {
12070                value:
12071                    SyncState::Tracking(AheadBehind {
12072                        ahead: 0,
12073                        behind: 0,
12074                    }),
12075                at: _,
12076                stale: _,
12077            }) => {}
12078            other => panic!("expected the first Generation level with its upstream, got {other:?}"),
12079        }
12080
12081        git(
12082            &repo,
12083            &[
12084                "commit",
12085                "--allow-empty",
12086                "-m",
12087                "second Generation's own work",
12088            ],
12089        );
12090        let second = refresh_and_settle(&core);
12091        match sync_of(&second, &repo) {
12092            Some(Settled::Known {
12093                value:
12094                    SyncState::Tracking(AheadBehind {
12095                        ahead: 1,
12096                        behind: 0,
12097                    }),
12098                at: _,
12099                stale: _,
12100            }) => {}
12101            other => panic!(
12102                "expected the second Generation to recompute and read 1 ahead, got {other:?}"
12103            ),
12104        }
12105    }
12106
12107    /// The Worktree-reporting criterion: after a default branch moves, the Worktrees
12108    /// now behind it are reported by name. `base` (the same "behind the default branch"
12109    /// count [`base.rs`] computes and every row's own `name` already carries) is what
12110    /// "reported by name" means in practice: a snapshot reader finds each Worktree by
12111    /// the name on its row, not by position, so this test does the same, matching each
12112    /// assertion to its own fixture's name rather than to "the first" or "the last"
12113    /// entity.
12114    ///
12115    /// `wt-behind` is branched from the default branch's tip before it moves and is left
12116    /// untouched, the same shape a fetch leaves an existing linked Worktree in; `wt-
12117    /// caught-up` is branched from the tip *after* it moves, so it is unaffected. Two
12118    /// Worktrees are required, not one: a test with only `wt-behind` would still pass
12119    /// against an implementation that reports every Worktree as behind regardless of
12120    /// whether it actually is, and a test that asserted only "something is reported"
12121    /// would pass even if the names or the counts were swapped.
12122    #[test]
12123    fn worktrees_now_behind_a_moved_default_branch_are_reported_by_name() {
12124        let dir = tempfile::tempdir().expect("temp dir");
12125        let root = root_of(&dir);
12126        let repo = root.join("repo");
12127        init_repo_with_a_commit(&repo);
12128        let sha_a = head_sha(&repo);
12129        add_origin_remote(&repo);
12130        set_upstream(&repo, "main", &sha_a);
12131
12132        let behind_path = root.join("wt-behind");
12133        git(
12134            &repo,
12135            &[
12136                "worktree",
12137                "add",
12138                "-b",
12139                "topic-behind",
12140                behind_path.to_str().expect("utf8 path"),
12141                "main",
12142            ],
12143        );
12144
12145        // Moves only the default branch's own remote-tracking ref, the same shape a
12146        // fetch leaves behind: `repo`'s own checked-out `main` does not move, so this
12147        // is deliberately not exercising the auto-update itself, only what a moved
12148        // default branch does to every Worktree's own `base` count.
12149        git(&repo, &["checkout", "-b", "scratch"]);
12150        git(&repo, &["commit", "--allow-empty", "-m", "second"]);
12151        let sha_b = head_sha(&repo);
12152        git(&repo, &["checkout", "main"]);
12153        git(&repo, &["update-ref", "refs/remotes/origin/main", &sha_b]);
12154        git(&repo, &["branch", "-D", "scratch"]);
12155
12156        // Branched from the plain commit sha, not `refs/remotes/origin/main` itself:
12157        // starting a new branch from a remote-tracking ref makes git auto-configure it
12158        // to track that same ref, which would make this row the default branch's own
12159        // row (`base.rs`'s `branch_is_default_branchs_own_row`) and settle `base` as
12160        // `NotApplicable` rather than the `0` this fixture means to prove.
12161        let caught_up_path = root.join("wt-caught-up");
12162        git(
12163            &repo,
12164            &[
12165                "worktree",
12166                "add",
12167                "-b",
12168                "topic-caught-up",
12169                caught_up_path.to_str().expect("utf8 path"),
12170                &sha_b,
12171            ],
12172        );
12173
12174        let core = Core::start_discovered(spec(vec![root]));
12175        let snapshot = refresh_and_settle(&core);
12176
12177        let base_of = |name: &str| -> u32 {
12178            let entity = snapshot
12179                .entities
12180                .iter()
12181                .find(|entity| &*entity.name == name)
12182                .unwrap_or_else(|| panic!("no entity named {name} in {snapshot:?}"));
12183            match entity.base.settled() {
12184                Some(Settled::Known {
12185                    value,
12186                    at: _,
12187                    stale: _,
12188                }) => *value,
12189                other => panic!("expected a known base count for {name}, got {other:?}"),
12190            }
12191        };
12192
12193        assert!(
12194            base_of("wt-behind") > 0,
12195            "a Worktree branched before the default branch moved must be reported behind"
12196        );
12197        assert_eq!(
12198            base_of("wt-caught-up"),
12199            0,
12200            "a Worktree branched from the new tip must not be reported behind"
12201        );
12202    }
12203
12204    /// The periodic fetch's own scheduler: criterion 3's five rules
12205    /// ([refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
12206    /// "The periodic fetch"). Every fixture here is a bare repo this test creates plus a
12207    /// real `git clone` of it, per the standing constraint that a fetch test never
12208    /// touches a real remote or the network.
12209    mod fetch_scheduler {
12210        use super::*;
12211        use crate::liveness::wait_for_or;
12212
12213        fn fetch_spec(enabled: bool, root: PathBuf) -> CoreSpec {
12214            let mut spec = spec(vec![root]);
12215            spec.fetch = FetchSpec {
12216                enabled,
12217                interval: Duration::from_secs(3600),
12218                concurrency: 4,
12219            };
12220            spec
12221        }
12222
12223        /// A bare "remote" this call creates and seeds with one commit, never a real
12224        /// remote and never touched over the network.
12225        fn seeded_remote() -> tempfile::TempDir {
12226            let remote = tempfile::tempdir().expect("temp dir");
12227            crate::test_support::init_bare(remote.path());
12228            crate::test_support::push_new_commit(remote.path(), "README.md", "seed\n");
12229            remote
12230        }
12231
12232        fn clone_into(remote: &Path, dest: &Path) {
12233            let status = Command::new("git")
12234                .arg("clone")
12235                .arg(remote)
12236                .arg(dest)
12237                .status()
12238                .expect("run git clone");
12239            assert!(status.success());
12240            crate::test_support::set_identity(dest);
12241        }
12242
12243        /// The scheduler's first rule: enabling the periodic fetch runs one cycle
12244        /// immediately rather than waiting for `fetch.interval` to elapse. `fetch_ticks`
12245        /// is `crossbeam_channel::never()`, so the only way `fetch_cycle_count_for_test`
12246        /// can ever move is the immediate cycle `start_internal` dispatches on its own
12247        /// plain thread; a scheduler that only reacted to a tick would leave this at
12248        /// zero forever.
12249        #[test]
12250        fn enabling_the_periodic_fetch_runs_one_cycle_before_any_tick_arrives() {
12251            let remote = seeded_remote();
12252            let root = tempfile::tempdir().expect("temp dir");
12253            let root_path = root_of(&root);
12254            clone_into(remote.path(), &root_path.join("parent"));
12255
12256            let fetch_ticks: Receiver<Instant> = crossbeam_channel::never();
12257            let started = Core::start_for_test_with_fetch(
12258                fetch_spec(true, root_path),
12259                Duration::from_secs(3600),
12260                crossbeam_channel::never(),
12261                fetch_ticks,
12262            )
12263            .discovered();
12264            let core = started.core;
12265
12266            wait_for(
12267                "the periodic fetch to run its first cycle without waiting for a tick",
12268                || core.fetch_cycle_count_for_test() >= 1,
12269            );
12270        }
12271
12272        /// A tick on the periodic fetch's own channel runs a second cycle, proving the
12273        /// recurring cadence is wired to the same dedicated thread the immediate cycle
12274        /// used, not merely a one-shot dispatched at start.
12275        #[test]
12276        fn a_tick_on_the_fetch_channel_runs_another_cycle() {
12277            let remote = seeded_remote();
12278            let root = tempfile::tempdir().expect("temp dir");
12279            let root_path = root_of(&root);
12280            clone_into(remote.path(), &root_path.join("parent"));
12281
12282            let (fetch_tick_tx, fetch_tick_rx) = crossbeam_channel::unbounded();
12283            let started = Core::start_for_test_with_fetch(
12284                fetch_spec(true, root_path),
12285                Duration::from_secs(3600),
12286                crossbeam_channel::never(),
12287                fetch_tick_rx,
12288            )
12289            .discovered();
12290            let core = started.core;
12291
12292            wait_for("the immediate cycle to have run first", || {
12293                core.fetch_cycle_count_for_test() >= 1
12294            });
12295
12296            fetch_tick_tx
12297                .send(Instant::now())
12298                .expect("send a fetch tick");
12299
12300            wait_for("a tick on the fetch channel to run a second cycle", || {
12301                core.fetch_cycle_count_for_test() >= 2
12302            });
12303        }
12304
12305        /// Points `repo`'s `origin` at a path nothing lives at, breaking `fetch_and_prune`
12306        /// alone: discovery has already found `repo` as a real Repo before this runs, so
12307        /// only the fetch itself fails, never the walk. A local path rather than a loopback
12308        /// address, so this never touches even the machine's own network stack, the same
12309        /// standing constraint every fixture in this module already holds to.
12310        fn break_remote(repo: &Path) {
12311            let status = Command::new("git")
12312                .arg("-C")
12313                .arg(repo)
12314                .args(["remote", "set-url", "origin", "/nonexistent-remote-282"])
12315                .status()
12316                .expect("run git remote set-url");
12317            assert!(status.success());
12318        }
12319
12320        /// Criterion: a cycle where every fetch succeeds reports no failures.
12321        #[test]
12322        fn a_cycle_in_which_every_fetch_succeeds_reports_no_failures() {
12323            let remote = seeded_remote();
12324            let root = tempfile::tempdir().expect("temp dir");
12325            let root_path = root_of(&root);
12326            clone_into(remote.path(), &root_path.join("parent"));
12327
12328            let fetch_ticks: Receiver<Instant> = crossbeam_channel::never();
12329            let started = Core::start_for_test_with_fetch(
12330                fetch_spec(true, root_path),
12331                Duration::from_secs(3600),
12332                crossbeam_channel::never(),
12333                fetch_ticks,
12334            )
12335            .discovered();
12336            let core = started.core;
12337
12338            wait_for("the periodic fetch to run its first cycle", || {
12339                core.fetch_cycle_count_for_test() >= 1
12340            });
12341
12342            assert!(
12343                core.fetch_failures().failed.is_empty(),
12344                "a cycle where every fetch succeeds must report no failures, got: {:?}",
12345                core.fetch_failures().failed
12346            );
12347        }
12348
12349        /// A cycle in which one repository cannot be fetched counts that one failure, and
12350        /// the per-repository independence at the fetch loop's own swallow is unchanged,
12351        /// proven here by the sibling repository still fetching.
12352        #[test]
12353        fn a_repository_that_cannot_be_fetched_is_counted_while_its_sibling_still_fetches() {
12354            let good_remote = seeded_remote();
12355            let bad_remote = seeded_remote();
12356            let root = tempfile::tempdir().expect("temp dir");
12357            let root_path = root_of(&root);
12358            let good = root_path.join("good");
12359            let bad = root_path.join("bad");
12360            clone_into(good_remote.path(), &good);
12361            clone_into(bad_remote.path(), &bad);
12362            break_remote(&bad);
12363
12364            crate::test_support::push_new_commit(good_remote.path(), "second.txt", "second\n");
12365            let good_remote_tip = rev_parse(good_remote.path(), "refs/heads/main");
12366
12367            let fetch_ticks: Receiver<Instant> = crossbeam_channel::never();
12368            let started = Core::start_for_test_with_fetch(
12369                fetch_spec(true, root_path),
12370                Duration::from_secs(3600),
12371                crossbeam_channel::never(),
12372                fetch_ticks,
12373            )
12374            .discovered();
12375            let core = started.core;
12376
12377            wait_for(
12378                "the cycle to run and count the one repository it could not fetch",
12379                || core.fetch_failures().failed.len() == 1,
12380            );
12381
12382            let failures = core.fetch_failures();
12383            assert_eq!(
12384                failures.failed.len(),
12385                1,
12386                "exactly one repository failed, so exactly one failure must be counted, \
12387                 got: {:?}",
12388                failures.failed
12389            );
12390            assert!(
12391                failures.failed[0].0.to_string_lossy().contains("bad"),
12392                "the counted failure must name the repository that actually failed, \
12393                 got: {:?}",
12394                failures.failed
12395            );
12396
12397            wait_for(
12398                "the sibling repository to still fetch despite the other one failing",
12399                || rev_parse(&good, "refs/remotes/origin/main") == good_remote_tip,
12400            );
12401        }
12402
12403        /// [`crate::test_support::push_new_commit`], but onto `branch` rather than
12404        /// always `main`: this scheduler test needs a second commit on `topic`
12405        /// specifically, so ancestry alone cannot call it merged into `main`.
12406        fn push_new_commit_on_branch(remote: &Path, branch: &str, name: &str, contents: &str) {
12407            let contributor = tempfile::tempdir().expect("temp dir");
12408            let status = Command::new("git")
12409                .arg("clone")
12410                .arg("--branch")
12411                .arg(branch)
12412                .arg(remote)
12413                .arg(contributor.path())
12414                .status()
12415                .expect("run git clone");
12416            assert!(status.success());
12417            std::fs::write(contributor.path().join(name), contents).expect("write fixture file");
12418            git(contributor.path(), &["add", name]);
12419            git(contributor.path(), &["commit", "-m", "extra work on topic"]);
12420            git(contributor.path(), &["push", "origin", branch]);
12421        }
12422
12423        /// Criteria 3 and 4 together, end to end: the periodic fetch always prunes, so
12424        /// `Gone` can appear at all, and a finished fetch starts one normal Generation
12425        /// on its own, so the pruned state actually lands on the table without the test
12426        /// calling `refresh` itself. `topic` carries a commit `main` never gets, so
12427        /// ancestry alone cannot call it `Merged`; deleting it upstream before the
12428        /// scheduler's own fetch is what a plain, non-pruning fetch could never turn
12429        /// into `Gone`.
12430        #[test]
12431        fn a_finished_fetch_prunes_and_starts_its_own_generation_that_lands_gone() {
12432            let remote = seeded_remote();
12433            let root = tempfile::tempdir().expect("temp dir");
12434            let root_path = root_of(&root);
12435            let parent = root_path.join("parent");
12436            clone_into(remote.path(), &parent);
12437
12438            git(remote.path(), &["branch", "topic"]);
12439            push_new_commit_on_branch(remote.path(), "topic", "topic.txt", "extra work\n");
12440
12441            // A deliberate, ordinary fetch by the test's own setup, distinct from the
12442            // Core's own periodic fetch under test: `parent` was cloned before `topic`
12443            // existed, so this is what teaches it about `origin/topic` at all, the same
12444            // way any real clone would only learn of a branch created after it cloned
12445            // on its own next fetch.
12446            git(&parent, &["fetch", "origin"]);
12447
12448            let worktree_path = root_path.join("topic-worktree");
12449            git(
12450                &parent,
12451                &[
12452                    "worktree",
12453                    "add",
12454                    "-b",
12455                    "topic",
12456                    worktree_path.to_str().expect("utf8 path"),
12457                    "origin/topic",
12458                ],
12459            );
12460
12461            // Deleted only now, after the worktree already tracks it: this is the
12462            // upstream disappearance a plain fetch can see but never prune away, and
12463            // exactly what the scheduler's own fetch (not this setup) must prune.
12464            git(remote.path(), &["branch", "-D", "topic"]);
12465
12466            let fetch_ticks: Receiver<Instant> = crossbeam_channel::never();
12467            let started = Core::start_for_test_with_fetch(
12468                fetch_spec(true, root_path),
12469                Duration::from_secs(3600),
12470                crossbeam_channel::never(),
12471                fetch_ticks,
12472            )
12473            .discovered();
12474            let core = started.core;
12475
12476            wait_for_or(
12477                "a finished fetch's own Generation to land the pruned Worktree as Gone \
12478                 without the test ever calling refresh",
12479                || {
12480                    core.snapshot()
12481                        .entities
12482                        .iter()
12483                        .filter(|entity| matches!(entity.kind, Kind::Worktree))
12484                        .any(|entity| {
12485                            matches!(
12486                                entity.state.settled(),
12487                                Some(Settled::Known {
12488                                    value: WorktreeState::Gone,
12489                                    at: _,
12490                                    stale: _,
12491                                })
12492                            )
12493                        })
12494                },
12495                || {
12496                    format!(
12497                        "snapshot: {:?}",
12498                        core.snapshot()
12499                            .entities
12500                            .iter()
12501                            .map(|entity| (entity.kind, entity.state.settled().cloned()))
12502                            .collect::<Vec<_>>()
12503                    )
12504                },
12505            );
12506        }
12507
12508        fn spec_with_auto_update(
12509            fetch_enabled: bool,
12510            auto_update_enabled: bool,
12511            root: PathBuf,
12512        ) -> CoreSpec {
12513            let mut spec = fetch_spec(fetch_enabled, root);
12514            spec.auto_update = AutoUpdateSpec {
12515                enabled: auto_update_enabled,
12516            };
12517            spec
12518        }
12519
12520        fn rev_parse(path: &Path, rev: &str) -> String {
12521            let output = Command::new("git")
12522                .arg("-C")
12523                .arg(path)
12524                .args(["rev-parse", rev])
12525                .output()
12526                .expect("run git rev-parse");
12527            assert!(output.status.success(), "git rev-parse {rev} failed");
12528            String::from_utf8(output.stdout)
12529                .expect("utf8 sha")
12530                .trim()
12531                .to_string()
12532        }
12533
12534        /// Criterion 1's "off by default" half: `fetch.enabled` alone is not enough to
12535        /// move a branch. `fetch_ticks` never fires, so the only cycle that can possibly
12536        /// run is the immediate one `start_internal` dispatches on being enabled; that
12537        /// cycle fetches (`fetch_cycle_count_for_test` proves it ran) and must still
12538        /// leave the eligible local branch exactly where it was, since `auto_update`
12539        /// carries its own, separate `enabled` flag this spec never turns on.
12540        #[test]
12541        fn auto_update_is_off_by_default_even_with_fetch_enabled() {
12542            let remote = seeded_remote();
12543            let root = tempfile::tempdir().expect("temp dir");
12544            let root_path = root_of(&root);
12545            let parent = root_path.join("parent");
12546            clone_into(remote.path(), &parent);
12547            let before = rev_parse(&parent, "refs/heads/main");
12548
12549            crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
12550
12551            let fetch_ticks: Receiver<Instant> = crossbeam_channel::never();
12552            let started = Core::start_for_test_with_fetch(
12553                spec_with_auto_update(true, false, root_path),
12554                Duration::from_secs(3600),
12555                crossbeam_channel::never(),
12556                fetch_ticks,
12557            )
12558            .discovered();
12559            let core = started.core;
12560
12561            wait_for(
12562                "the periodic fetch to still run its immediate cycle",
12563                || core.fetch_cycle_count_for_test() >= 1,
12564            );
12565            assert_eq!(
12566                rev_parse(&parent, "refs/heads/main"),
12567                before,
12568                "an eligible branch must not move while auto_update.enabled is false, \
12569                 even though fetch.enabled is true"
12570            );
12571        }
12572
12573        /// Criterion 1's "rides the fetch cycle with no timer of its own" half: the
12574        /// remote is already ahead *before* `Core::start`, `fetch_ticks` is
12575        /// `crossbeam_channel::never()` so no recurring tick ever fires, and yet the
12576        /// eligible branch still moves, proving the auto-update ran on the same
12577        /// immediate first cycle the periodic fetch itself uses rather than waiting on
12578        /// any tick of its own.
12579        #[test]
12580        fn auto_update_enabled_rides_the_immediate_fetch_cycle_with_no_timer_of_its_own() {
12581            let remote = seeded_remote();
12582            let root = tempfile::tempdir().expect("temp dir");
12583            let root_path = root_of(&root);
12584            let parent = root_path.join("parent");
12585            clone_into(remote.path(), &parent);
12586
12587            crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
12588            let remote_tip = rev_parse(remote.path(), "refs/heads/main");
12589
12590            let fetch_ticks: Receiver<Instant> = crossbeam_channel::never();
12591            let started = Core::start_for_test_with_fetch(
12592                spec_with_auto_update(true, true, root_path),
12593                Duration::from_secs(3600),
12594                crossbeam_channel::never(),
12595                fetch_ticks,
12596            )
12597            .discovered();
12598            // Kept alive, unused otherwise: dropping `Core` joins its dedicated thread,
12599            // which would stop the immediate cycle this test is waiting on.
12600            let _core = started.core;
12601
12602            wait_for(
12603                "the eligible branch to fast-forward on the immediate cycle alone, with no \
12604                 fetch tick and no auto-update tick of its own",
12605                || rev_parse(&parent, "refs/heads/main") == remote_tip,
12606            );
12607        }
12608    }
12609
12610    /// [`Core::attempt_auto_update`] must answer exactly what
12611    /// [`crate::auto_update::attempt`] would for the same Repo, since it delegates to that
12612    /// function rather than reimplementing its own copy of the eligibility rules: the
12613    /// built-in `sync` action's own "reuses `auto_update`'s existing rules rather than a
12614    /// second implementation"
12615    /// ([repo-management.md](https://github.com/paulchiu/repon/blob/main/docs/spec/repo-management.md))
12616    /// is proven here, at the one seam a reimplementation could actually diverge from the
12617    /// rules it is supposed to reuse. Every fixture is a bare repo this test creates plus a
12618    /// real `git clone` of it, the same standing constraint `fetch_scheduler` above follows.
12619    mod attempt_auto_update {
12620        use super::*;
12621
12622        fn seeded_remote() -> tempfile::TempDir {
12623            let remote = tempfile::tempdir().expect("temp dir");
12624            crate::test_support::init_bare(remote.path());
12625            crate::test_support::push_new_commit(remote.path(), "README.md", "seed\n");
12626            remote
12627        }
12628
12629        fn clone_into(remote: &Path, dest: &Path) {
12630            let status = Command::new("git")
12631                .arg("clone")
12632                .arg(remote)
12633                .arg(dest)
12634                .status()
12635                .expect("run git clone");
12636            assert!(status.success());
12637            crate::test_support::set_identity(dest);
12638        }
12639
12640        /// Discovers `root`'s one Repo and hands back the live `Core` alongside its key,
12641        /// the same `Core::start_discovered` plus `settle` shape [`delete_risk`]'s own tests
12642        /// already use: this method reads the repository fresh, not a Cell, so discovery's
12643        /// own read-only probes running first are never a race with it.
12644        fn discover_repo(root: &Path) -> (Core, EntityKey) {
12645            let core = Core::start_discovered(spec(vec![root.to_path_buf()]));
12646            let key = core
12647                .settle()
12648                .entities
12649                .into_iter()
12650                .find(|entity| entity.kind == Kind::Repo)
12651                .expect("the Repo row is discovered")
12652                .key;
12653            (core, key)
12654        }
12655
12656        /// The eligible condition: clean, behind, not ahead, tracking an upstream. Proves
12657        /// the wrapper both classifies and actually moves the branch, not only the former.
12658        #[test]
12659        fn an_eligible_repo_fast_forwards_through_the_wrapper_too() {
12660            let remote = seeded_remote();
12661            let root = tempfile::tempdir().expect("temp dir");
12662            let root_path = root_of(&root);
12663            let repo = root_path.join("repo");
12664            clone_into(remote.path(), &repo);
12665            crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
12666            crate::test_support::git(&repo, &["fetch", "origin"]);
12667
12668            let (core, key) = discover_repo(&root_path);
12669
12670            assert_eq!(core.attempt_auto_update(&key), AutoUpdateAttempt::Updated);
12671            assert!(
12672                repo.join("second.txt").exists(),
12673                "the fast-forward must reach the working tree through the wrapper too"
12674            );
12675        }
12676
12677        /// Condition 1: a dirty working tree.
12678        #[test]
12679        fn a_dirty_repo_is_reported_not_clean_through_the_wrapper_too() {
12680            let remote = seeded_remote();
12681            let root = tempfile::tempdir().expect("temp dir");
12682            let root_path = root_of(&root);
12683            let repo = root_path.join("repo");
12684            clone_into(remote.path(), &repo);
12685            crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
12686            crate::test_support::git(&repo, &["fetch", "origin"]);
12687            fs::write(repo.join("stray.txt"), "uncommitted\n").expect("write a stray file");
12688
12689            let (core, key) = discover_repo(&root_path);
12690
12691            assert_eq!(core.attempt_auto_update(&key), AutoUpdateAttempt::NotClean);
12692        }
12693
12694        /// Condition 2: already level with the upstream.
12695        #[test]
12696        fn an_up_to_date_repo_is_reported_not_behind_through_the_wrapper_too() {
12697            let remote = seeded_remote();
12698            let root = tempfile::tempdir().expect("temp dir");
12699            let root_path = root_of(&root);
12700            let repo = root_path.join("repo");
12701            clone_into(remote.path(), &repo);
12702            crate::test_support::git(&repo, &["fetch", "origin"]);
12703
12704            let (core, key) = discover_repo(&root_path);
12705
12706            assert_eq!(core.attempt_auto_update(&key), AutoUpdateAttempt::NotBehind);
12707        }
12708
12709        /// Condition 3: a local commit the upstream does not have.
12710        #[test]
12711        fn an_unpublished_local_commit_is_reported_not_fast_forward_through_the_wrapper_too() {
12712            let remote = seeded_remote();
12713            let root = tempfile::tempdir().expect("temp dir");
12714            let root_path = root_of(&root);
12715            let repo = root_path.join("repo");
12716            clone_into(remote.path(), &repo);
12717            crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
12718            crate::test_support::git(&repo, &["fetch", "origin"]);
12719            crate::test_support::commit_file(&repo, "local-only.txt", "never pushed\n");
12720
12721            let (core, key) = discover_repo(&root_path);
12722
12723            assert_eq!(
12724                core.attempt_auto_update(&key),
12725                AutoUpdateAttempt::NotFastForward
12726            );
12727        }
12728
12729        /// Condition 4: no upstream configured at all.
12730        #[test]
12731        fn a_branch_with_no_upstream_is_reported_through_the_wrapper_too() {
12732            let remote = seeded_remote();
12733            let root = tempfile::tempdir().expect("temp dir");
12734            let root_path = root_of(&root);
12735            let repo = root_path.join("repo");
12736            clone_into(remote.path(), &repo);
12737            crate::test_support::git(&repo, &["checkout", "-b", "untracked-branch"]);
12738
12739            let (core, key) = discover_repo(&root_path);
12740
12741            assert_eq!(
12742                core.attempt_auto_update(&key),
12743                AutoUpdateAttempt::NoUpstream
12744            );
12745        }
12746    }
12747
12748    /// [default-branch.md](https://github.com/paulchiu/repon/blob/main/docs/spec/default-branch.md)'s
12749    /// "The network": criterion 3 (the local chain answers first, and only a later network
12750    /// round trip supersedes it) and criterion 4 (`Core::rederive_default_branches` runs the
12751    /// same lookup on demand, over exactly the given keys, without fetching). Every fixture
12752    /// here is a bare repo this test creates plus a real `git clone` of it, the same standing
12753    /// constraint `fetch_scheduler` above already follows.
12754    mod network_default_branch {
12755        use super::*;
12756
12757        fn seeded_remote() -> tempfile::TempDir {
12758            let remote = tempfile::tempdir().expect("temp dir");
12759            crate::test_support::init_bare(remote.path());
12760            crate::test_support::push_new_commit(remote.path(), "README.md", "seed\n");
12761            remote
12762        }
12763
12764        fn clone_into(remote: &Path, dest: &Path) {
12765            let status = Command::new("git")
12766                .arg("clone")
12767                .arg(remote)
12768                .arg(dest)
12769                .status()
12770                .expect("run git clone");
12771            assert!(status.success());
12772            crate::test_support::set_identity(dest);
12773        }
12774
12775        /// Sets `path`'s own `HEAD` (a bare repo, so this is the "remote"'s advertised
12776        /// answer) to point at `branch`, without checking anything out.
12777        fn set_remote_head(path: &Path, branch: &str) {
12778            git(
12779                path,
12780                &["symbolic-ref", "HEAD", &format!("refs/heads/{branch}")],
12781            );
12782        }
12783
12784        fn rev_parse(path: &Path, rev: &str) -> String {
12785            let output = Command::new("git")
12786                .arg("-C")
12787                .arg(path)
12788                .args(["rev-parse", rev])
12789                .output()
12790                .expect("run git rev-parse");
12791            assert!(output.status.success());
12792            String::from_utf8(output.stdout)
12793                .expect("utf8 sha")
12794                .trim()
12795                .to_string()
12796        }
12797
12798        fn default_branch_name(entity: &EntityState) -> Option<String> {
12799            match entity.default_branch.settled() {
12800                Some(Settled::Known {
12801                    value,
12802                    at: _,
12803                    stale: _,
12804                }) => Some(value.name().to_string()),
12805                _ => None,
12806            }
12807        }
12808
12809        /// Criterion 3: with a reachable remote whose advertised HEAD differs from the
12810        /// clone's own cached `origin/HEAD`, a plain refresh still answers from the local
12811        /// chain alone (the network is never consulted just to render a Generation), and
12812        /// only [`Core::rederive_default_branches`] actually reaching the remote supersedes
12813        /// it, for the rest of this `Core`'s own session (default-branch.md's "The network":
12814        /// "supersedes the local one for that session"). The mutation this is chosen to
12815        /// catch: were `supersede_with_network` never applied (or applied unconditionally
12816        /// before the local chain even ran), either the first assertion would already read
12817        /// `origin/trunk`, or the second would still read `origin/main`.
12818        #[test]
12819        fn the_local_chain_answers_first_and_only_a_later_network_round_trip_supersedes_it() {
12820            let remote = seeded_remote();
12821            let root = tempfile::tempdir().expect("temp dir");
12822            let root_path = root_of(&root);
12823            let repo_path = root_path.join("repo");
12824            clone_into(remote.path(), &repo_path);
12825
12826            // The clone's own cached `origin/HEAD` still names `main`; the remote's own
12827            // current answer is changed to a different, real branch only after cloning.
12828            git(remote.path(), &["branch", "trunk"]);
12829            set_remote_head(remote.path(), "trunk");
12830
12831            let core = Core::start_discovered(spec(vec![root_path]));
12832            let key = core.snapshot().entities[0].key.clone();
12833
12834            core.refresh(std::slice::from_ref(&key));
12835            let settled = core.settle();
12836            assert_eq!(
12837                default_branch_name(&settled.entities[0]),
12838                Some("origin/main".to_string()),
12839                "a plain refresh must answer from the local chain alone, unaffected by the \
12840                 remote's own current (but not yet asked) truth"
12841            );
12842
12843            core.rederive_default_branches(std::slice::from_ref(&key));
12844            let settled = core.settle();
12845            assert_eq!(
12846                default_branch_name(&settled.entities[0]),
12847                Some("origin/trunk".to_string()),
12848                "once the network round trip actually ran, its own differing answer must \
12849                 supersede the local chain's"
12850            );
12851        }
12852
12853        /// Criterion 4: [`Core::rederive_default_branches`] runs the same lookup on demand,
12854        /// over exactly the given keys, without fetching. "Without fetching" is shown the
12855        /// way `fetch.rs`'s own `a_fetch_transfers_new_commits_so_a_behind_count_can_move`
12856        /// shows a real fetch moving one, the mirror image: the remote gains a new commit
12857        /// after the clone, and this call must leave the clone's own remote-tracking ref
12858        /// exactly where it was, because `probe_remote_head`'s handshake-only lookup
12859        /// transfers no pack. "Over the Selection" is exercised as "over exactly the given
12860        /// keys": a second, unrelated repo stands in for a row outside it, and its whole
12861        /// entity state (every cell, not only `default_branch`) is asserted unchanged.
12862        #[test]
12863        fn rederive_default_branches_never_fetches_and_leaves_a_row_outside_it_untouched() {
12864            let remote = seeded_remote();
12865            let root = tempfile::tempdir().expect("temp dir");
12866            let root_path = root_of(&root);
12867            let selected_path = root_path.join("selected");
12868            let outside_path = root_path.join("outside");
12869            clone_into(remote.path(), &selected_path);
12870            init_repo_with_a_commit(&outside_path);
12871
12872            git(remote.path(), &["branch", "trunk"]);
12873            crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
12874            set_remote_head(remote.path(), "trunk");
12875            let before_tracking = rev_parse(&selected_path, "refs/remotes/origin/main");
12876
12877            let core = Core::start_discovered(spec(vec![root_path]));
12878            let snapshot = core.snapshot();
12879            let selected_key = snapshot
12880                .entities
12881                .iter()
12882                .find(|entity| entity.key.path() == selected_path)
12883                .expect("discovered the selected repo")
12884                .key
12885                .clone();
12886            let outside_key = snapshot
12887                .entities
12888                .iter()
12889                .find(|entity| entity.key.path() == outside_path)
12890                .expect("discovered the outside repo")
12891                .key
12892                .clone();
12893
12894            core.refresh(&[selected_key.clone(), outside_key.clone()]);
12895            let settled = core.settle();
12896            let outside_before = format!(
12897                "{:?}",
12898                settled
12899                    .entities
12900                    .iter()
12901                    .find(|entity| entity.key == outside_key)
12902                    .expect("outside entity present")
12903            );
12904
12905            core.rederive_default_branches(std::slice::from_ref(&selected_key));
12906            let settled = core.settle();
12907
12908            let selected_after = settled
12909                .entities
12910                .iter()
12911                .find(|entity| entity.key == selected_key)
12912                .expect("selected entity present");
12913            assert_eq!(
12914                default_branch_name(selected_after),
12915                Some("origin/trunk".to_string()),
12916                "the rederive must have reached the remote's own current, differing answer"
12917            );
12918
12919            let after_tracking = rev_parse(&selected_path, "refs/remotes/origin/main");
12920            assert_eq!(
12921                before_tracking, after_tracking,
12922                "a rederive must never fetch: the remote-tracking ref must not have moved \
12923                 even though the remote gained a new commit"
12924            );
12925
12926            let outside_after = format!(
12927                "{:?}",
12928                settled
12929                    .entities
12930                    .iter()
12931                    .find(|entity| entity.key == outside_key)
12932                    .expect("outside entity present")
12933            );
12934            assert_eq!(
12935                outside_before, outside_after,
12936                "a row outside the rederive's own keys must be left exactly as it was, not \
12937                 only on its default_branch cell"
12938            );
12939        }
12940    }
12941
12942    // =====================================================================================
12943    // `set_exclusions`: `[[repo]]`'s `exclude` re-applied live, with no rebuild and no
12944    // rediscovery, per repo-management.md's "Writing config".
12945    // =====================================================================================
12946
12947    /// The live half: a row already in the table becomes excluded, and is subtracted from
12948    /// `operable_count`, without a rebuilt `Core` and without a Generation of any kind.
12949    #[test]
12950    fn set_exclusions_excludes_a_row_already_in_the_table_with_no_rebuild() {
12951        let dir = tempfile::tempdir().expect("temp dir");
12952        let root = root_of(&dir);
12953        let repo = root.join("repo");
12954        init_repo_with_a_commit(&repo);
12955
12956        let core = Core::start_discovered(spec(vec![root]));
12957        let snapshot = core.settle();
12958        let key = snapshot.entities[0].key.clone();
12959        let generation_before = snapshot.generation;
12960        assert!(
12961            !snapshot.entities[0].excluded,
12962            "nothing excludes it to start with"
12963        );
12964        assert_eq!(core.operable_count(std::slice::from_ref(&key)), 1);
12965
12966        core.set_exclusions(&[RepoOverride {
12967            path: repo.clone(),
12968            default_branch: None,
12969            excluded: true,
12970        }]);
12971
12972        let after = core.snapshot();
12973        assert!(
12974            after.entities[0].excluded,
12975            "the row the write named is excluded in the very next snapshot"
12976        );
12977        assert_eq!(
12978            core.operable_count(&[key]),
12979            0,
12980            "an excluded row is subtracted from what an operation may reach"
12981        );
12982        assert_eq!(
12983            after.generation, generation_before,
12984            "re-applying an operate-time filter must start no Generation of its own"
12985        );
12986    }
12987
12988    /// The other direction: dropping the entry clears the flag, so a row ignored and shown
12989    /// again in one session ends where it started.
12990    #[test]
12991    fn set_exclusions_clears_the_flag_when_the_entry_is_gone() {
12992        let dir = tempfile::tempdir().expect("temp dir");
12993        let root = root_of(&dir);
12994        let repo = root.join("repo");
12995        init_repo_with_a_commit(&repo);
12996
12997        let core = Core::start_discovered(spec_with_overrides(
12998            vec![root],
12999            vec![RepoOverride {
13000                path: repo.clone(),
13001                default_branch: None,
13002                excluded: true,
13003            }],
13004        ));
13005        assert!(
13006            core.settle().entities[0].excluded,
13007            "the starting override excludes it"
13008        );
13009
13010        core.set_exclusions(&[]);
13011
13012        assert!(
13013            !core.snapshot().entities[0].excluded,
13014            "removing the entry unexcludes the row in the very next snapshot"
13015        );
13016    }
13017
13018    /// The boundary the specification draws around the live half: `exclude` re-applies and
13019    /// `default_branch` does not, because one is an operate-time filter and the other is a
13020    /// probe input. A `set_exclusions` that swapped the whole `[[repo]]` reading in would
13021    /// move both, which is what this refuses.
13022    #[test]
13023    fn set_exclusions_moves_exclude_alone_and_never_the_default_branch_override() {
13024        let dir = tempfile::tempdir().expect("temp dir");
13025        let root = root_of(&dir);
13026        let repo = root.join("repo");
13027        init_repo_with_a_commit(&repo);
13028        crate::test_support::git(&repo, &["branch", "trunk"]);
13029
13030        let core = Core::start_discovered(spec(vec![root]));
13031        let key = core.settle().entities[0].key.clone();
13032        core.refresh(std::slice::from_ref(&key));
13033        let before = format!("{:?}", core.settle().entities[0].default_branch.settled());
13034
13035        core.set_exclusions(&[RepoOverride {
13036            path: repo.clone(),
13037            default_branch: Some("trunk".to_string()),
13038            excluded: true,
13039        }]);
13040        core.refresh(&[key]);
13041        core.settle();
13042
13043        let after = core.snapshot();
13044        assert!(after.entities[0].excluded, "exclude took effect");
13045        assert_eq!(
13046            format!("{:?}", after.entities[0].default_branch.settled()),
13047            before,
13048            "a default_branch override reaches a session only through a rebuilt Core"
13049        );
13050    }
13051
13052    // =====================================================================================
13053    // `record_own_work`: the receipt a Management operation leaves, docs/spec/repo-management.md
13054    // =====================================================================================
13055
13056    /// One receipt per named row, and the shape the caller never gets to choose: `running` is
13057    /// `None`, `skip` is `None` (a refusal is not an excluded row), and there is
13058    /// exactly one step, because such an operation is one act rather than an ordered list.
13059    #[test]
13060    fn record_own_work_leaves_one_receipt_per_row_it_names_and_none_elsewhere() {
13061        let dir = tempfile::tempdir().expect("temp dir");
13062        let root = root_of(&dir);
13063        init_repo_with_a_commit(&root.join("repo-a"));
13064        init_repo_with_a_commit(&root.join("repo-b"));
13065
13066        let core = Core::start_discovered(spec(vec![root]));
13067        let entities = core.settle().entities;
13068        let named = entities
13069            .iter()
13070            .find(|entity| &*entity.name == "repo-a")
13071            .expect("repo-a is discovered")
13072            .key
13073            .clone();
13074
13075        core.record_own_work(
13076            "ignore",
13077            &[(
13078                named.clone(),
13079                OwnWork::Refused(Arc::from("refused, already ignored")),
13080                Duration::from_millis(7),
13081            )],
13082        );
13083
13084        let after = core.snapshot().entities;
13085        let receipt = after
13086            .iter()
13087            .find(|entity| entity.key == named)
13088            .and_then(|entity| entity.last_action.clone())
13089            .expect("the row it named carries a receipt");
13090        assert_eq!(&*receipt.label, "ignore");
13091        assert!(
13092            !receipt.not_applicable(),
13093            "a refusal is not an excluded row"
13094        );
13095        assert!(receipt.running.is_none(), "the work is already done");
13096        assert_eq!(receipt.steps.len(), 1, "one act, not an ordered list");
13097        assert_eq!(&*receipt.steps[0].label, "ignore");
13098        assert_eq!(receipt.steps[0].elapsed, Duration::from_millis(7));
13099        assert!(receipt.steps[0].output.is_empty(), "nothing to quote");
13100        assert!(receipt.steps[0].elision.is_none());
13101        assert_eq!(
13102            receipt.steps[0].outcome,
13103            StepOutcome::OwnWork(OwnWork::Refused(Arc::from("refused, already ignored"))),
13104        );
13105        assert!(
13106            after
13107                .iter()
13108                .filter(|entity| entity.key != named)
13109                .all(|entity| entity.last_action.is_none()),
13110            "no row this did not name takes a receipt"
13111        );
13112    }
13113
13114    /// A key the table no longer holds is skipped rather than panicking or landing on the
13115    /// wrong row, the same fallback every key-addressed entry point here gives one: a `delete`
13116    /// whose Repo is already gone is exactly this case.
13117    #[test]
13118    fn record_own_work_skips_a_key_the_table_no_longer_holds() {
13119        let dir = tempfile::tempdir().expect("temp dir");
13120        let root = root_of(&dir);
13121        init_repo_with_a_commit(&root.join("repo-a"));
13122
13123        let core = Core::start_discovered(spec(vec![root]));
13124        let entities = core.settle().entities;
13125        let stranger = EntityKey::new(Arc::from(std::path::Path::new("/nowhere/at/all")));
13126
13127        core.record_own_work(
13128            "delete",
13129            &[(stranger, OwnWork::Did(Arc::from("gone")), Duration::ZERO)],
13130        );
13131
13132        assert!(
13133            core.snapshot()
13134                .entities
13135                .iter()
13136                .all(|entity| entity.last_action.is_none()),
13137            "an unknown key writes nothing anywhere"
13138        );
13139        assert_eq!(core.snapshot().entities.len(), entities.len());
13140    }
13141
13142    // =====================================================================================
13143    // `delete_risk`: the three facts repo-management.md's confirm gate names per Repo, read
13144    // rather than stubbed. Every repository here is built in a temp directory this test owns,
13145    // and no path comes from config, an environment variable or the working directory.
13146    // =====================================================================================
13147
13148    /// A Repo with all three: an uncommitted change, a commit no remote-tracking ref carries,
13149    /// and a linked Worktree pointing into it.
13150    #[test]
13151    fn delete_risk_reads_all_three_facts_the_confirm_gate_names() {
13152        let dir = tempfile::tempdir().expect("temp dir");
13153        let root = root_of(&dir);
13154        let repo = root.join("repo");
13155        init_repo_with_a_commit(&repo);
13156        fs::write(repo.join("uncommitted.txt"), "not staged\n").expect("write a stray file");
13157        crate::test_support::git(
13158            &repo,
13159            &["worktree", "add", "-b", "sidecar", "../sidecar-worktree"],
13160        );
13161
13162        let core = Core::start_discovered(spec(vec![root]));
13163        // Settled first, so the startup Generation's own phase C is no longer reading this
13164        // same repository while the line below reads it: two concurrent gix statuses over one
13165        // working tree is a race in the harness, not in `delete_risk`.
13166        let key = core
13167            .settle()
13168            .entities
13169            .into_iter()
13170            .find(|entity| entity.kind == Kind::Repo)
13171            .expect("the Repo row is discovered")
13172            .key;
13173
13174        let risk = core.delete_risk(&key).expect("read the risk");
13175
13176        assert!(risk.uncommitted, "the stray file makes the tree dirty");
13177        assert!(
13178            risk.unpushed_commits > 0 && risk.unpushed_branches > 0,
13179            "no remote-tracking ref carries any of this Repo's commits, got {risk:?}"
13180        );
13181        assert_eq!(
13182            risk.linked_worktrees, 1,
13183            "the one linked Worktree pointing into this Repo is counted, got {risk:?}"
13184        );
13185    }
13186
13187    /// The `uncommitted` field's own range, one position at a time, because the composition
13188    /// behind it folds four separate reads: a modified tracked file, a deleted tracked file,
13189    /// an untracked file, and a staged change. Each gets a repository of its own with nothing
13190    /// else wrong with it, so narrowing the composition to any one of the four fails here
13191    /// rather than passing on whichever position a single fixture happened to sample.
13192    #[test]
13193    fn every_kind_of_work_that_is_not_in_a_commit_makes_the_gate_say_uncommitted() {
13194        for kind in ["modified", "deleted", "untracked", "staged"] {
13195            let dir = tempfile::tempdir().expect("temp dir");
13196            let root = root_of(&dir);
13197            let repo = root.join("repo");
13198            init_repo_with_a_commit(&repo);
13199            fs::write(repo.join("tracked.txt"), "first\n").expect("write a tracked file");
13200            crate::test_support::git(&repo, &["add", "tracked.txt"]);
13201            crate::test_support::git(&repo, &["commit", "-m", "add tracked"]);
13202            let sha = crate::test_support::head_sha(&repo);
13203            crate::test_support::git(&repo, &["update-ref", "refs/remotes/origin/main", &sha]);
13204
13205            match kind {
13206                "modified" => fs::write(repo.join("tracked.txt"), "second\n").expect("modify it"),
13207                "deleted" => fs::remove_file(repo.join("tracked.txt")).expect("delete it"),
13208                "untracked" => fs::write(repo.join("stray.txt"), "new\n").expect("write a stray"),
13209                "staged" => {
13210                    fs::write(repo.join("staged.txt"), "new\n").expect("write a new file");
13211                    crate::test_support::git(&repo, &["add", "staged.txt"]);
13212                }
13213                other => unreachable!("unhandled kind {other}"),
13214            }
13215
13216            let core = Core::start_discovered(spec(vec![root]));
13217            let key = core.settle().entities[0].key.clone();
13218
13219            let risk = core.delete_risk(&key).expect("read the risk");
13220
13221            assert!(
13222                risk.uncommitted,
13223                "a {kind} change is work that is not in a commit, got {risk:?}"
13224            );
13225        }
13226    }
13227
13228    /// The staged case, stated on its own as well as in the range above, because it is the
13229    /// one the dirty column deliberately answers `clean` to: `dirty_counts` compares the index
13230    /// against the working tree and never against `HEAD`, so a `git add` with no commit is
13231    /// invisible to it. Both readings are asserted here together, so a fix that widened
13232    /// `dirty_counts` instead of giving the gate its own read would fail this rather than
13233    /// silently change what the dirty column means.
13234    #[test]
13235    fn staged_work_reads_clean_to_the_dirty_column_and_uncommitted_to_the_delete_gate() {
13236        let dir = tempfile::tempdir().expect("temp dir");
13237        let root = root_of(&dir);
13238        let repo = root.join("repo");
13239        init_repo_with_a_commit(&repo);
13240        let sha = crate::test_support::head_sha(&repo);
13241        crate::test_support::git(&repo, &["update-ref", "refs/remotes/origin/main", &sha]);
13242        fs::write(repo.join("staged.txt"), "staged\n").expect("write a new file");
13243        crate::test_support::git(&repo, &["add", "staged.txt"]);
13244
13245        let core = Core::start_discovered(spec(vec![root]));
13246        let key = core.settle().entities[0].key.clone();
13247
13248        let opened = git::open_thread_safe(repo.as_path())
13249            .expect("open the repo")
13250            .to_thread_local();
13251        let dirty = git::dirty_counts(&opened, Arc::new(AtomicBool::new(false)))
13252            .expect("read the dirty counts");
13253        assert_eq!(
13254            dirty.total(),
13255            0,
13256            "the dirty column stays an index-to-worktree comparison, got {dirty:?}"
13257        );
13258
13259        let risk = core.delete_risk(&key).expect("read the risk");
13260        assert!(
13261            risk.uncommitted,
13262            "a Repo whose only work is staged must never be listed plainly, got {risk:?}"
13263        );
13264    }
13265
13266    /// The two unpushed quantities are two quantities: a fixture whose commit count and
13267    /// branch count differ, so transposing the pair in the composition changes both numbers
13268    /// rather than satisfying an inequality either way round.
13269    #[test]
13270    fn unpushed_commits_and_unpushed_branches_are_counted_into_their_own_fields() {
13271        let dir = tempfile::tempdir().expect("temp dir");
13272        let root = root_of(&dir);
13273        let repo = root.join("repo");
13274        init_repo_with_a_commit(&repo);
13275        let sha = crate::test_support::head_sha(&repo);
13276        crate::test_support::git(&repo, &["update-ref", "refs/remotes/origin/main", &sha]);
13277        for nth in 0..3 {
13278            fs::write(repo.join(format!("file-{nth}.txt")), "x\n").expect("write a file");
13279            crate::test_support::git(&repo, &["add", "."]);
13280            crate::test_support::git(&repo, &["commit", "-m", "unpushed"]);
13281        }
13282        crate::test_support::git(&repo, &["checkout", "."]);
13283
13284        let core = Core::start_discovered(spec(vec![root]));
13285        let key = core.settle().entities[0].key.clone();
13286
13287        let risk = core.delete_risk(&key).expect("read the risk");
13288
13289        assert_eq!(
13290            (risk.unpushed_commits, risk.unpushed_branches),
13291            (3, 1),
13292            "three commits on one branch, each in its own field, got {risk:?}"
13293        );
13294    }
13295
13296    /// The linked-Worktree count is git's own register, not the table's: a Worktree living
13297    /// outside the active Set's roots is never discovered, and deleting the Repo it is linked
13298    /// from orphans it just the same.
13299    #[test]
13300    fn a_linked_worktree_outside_the_sets_roots_is_still_counted_by_the_gate() {
13301        let dir = tempfile::tempdir().expect("temp dir");
13302        let base = root_of(&dir);
13303        let inside = base.join("inside");
13304        let outside = base.join("outside");
13305        fs::create_dir_all(&outside).expect("create the outside dir");
13306        let repo = inside.join("repo");
13307        init_repo_with_a_commit(&repo);
13308        crate::test_support::git(
13309            &repo,
13310            &["worktree", "add", "-b", "sidecar", "../../outside/sidecar"],
13311        );
13312        assert!(
13313            outside.join("sidecar").exists(),
13314            "the harness really created a linked Worktree outside the Set's roots"
13315        );
13316
13317        // Bounded by `inside` alone, so the Worktree is not a row in this Core's own table.
13318        let core = Core::start_discovered(spec(vec![inside]));
13319        let snapshot = core.settle();
13320        assert!(
13321            snapshot
13322                .entities
13323                .iter()
13324                .all(|entity| entity.kind != Kind::Worktree),
13325            "the Worktree is outside the roots and so is not discovered, got {:?}",
13326            snapshot.entities.iter().map(|e| e.kind).collect::<Vec<_>>()
13327        );
13328        let key = snapshot
13329            .entities
13330            .into_iter()
13331            .find(|entity| entity.kind == Kind::Repo)
13332            .expect("the Repo row is discovered")
13333            .key;
13334
13335        let risk = core.delete_risk(&key).expect("read the risk");
13336
13337        assert_eq!(
13338            risk.linked_worktrees, 1,
13339            "the gate must name the linked Worktree deleting this Repo would orphan, got {risk:?}"
13340        );
13341    }
13342
13343    /// The "listed plainly" case: nothing uncommitted, every commit already on a
13344    /// remote-tracking ref, and no linked Worktree at all. Asserted as its own test rather
13345    /// than left implied, since a gate that reports risk on every Repo is as wrong as one
13346    /// that reports it on none.
13347    #[test]
13348    fn delete_risk_on_a_clean_fully_pushed_repo_with_no_worktrees_reports_nothing() {
13349        let dir = tempfile::tempdir().expect("temp dir");
13350        let root = root_of(&dir);
13351        let repo = root.join("repo");
13352        init_repo_with_a_commit(&repo);
13353        let sha = crate::test_support::head_sha(&repo);
13354        crate::test_support::git(&repo, &["update-ref", "refs/remotes/origin/main", &sha]);
13355
13356        let core = Core::start_discovered(spec(vec![root]));
13357        let key = core.settle().entities[0].key.clone();
13358
13359        let risk = core.delete_risk(&key).expect("read the risk");
13360
13361        assert_eq!(
13362            risk,
13363            DeleteRisk {
13364                uncommitted: false,
13365                unpushed_commits: 0,
13366                unpushed_branches: 0,
13367                linked_worktrees: 0,
13368            }
13369        );
13370    }
13371
13372    // =====================================================================================
13373    // `worktree_admin_dir` and `linked_worktree_paths`: what `delete` needs to remove a
13374    // linked Worktree the way `git worktree remove` does, and to take a Repo's own linked
13375    // Worktrees with it. Every repository here is built in a temp directory this test owns.
13376    // =====================================================================================
13377
13378    /// The administrative directory named for a Worktree row is the one `git worktree list`
13379    /// stops naming once it is gone, proven by removing exactly that directory by hand.
13380    #[test]
13381    fn worktree_admin_dir_names_the_entry_git_worktree_list_forgets_once_it_is_removed() {
13382        let dir = tempfile::tempdir().expect("temp dir");
13383        let root = root_of(&dir);
13384        let repo = root.join("repo");
13385        init_repo_with_a_commit(&repo);
13386        let worktree = root.join("sidecar");
13387        crate::test_support::git(
13388            &repo,
13389            &[
13390                "worktree",
13391                "add",
13392                "-b",
13393                "sidecar",
13394                worktree.to_str().expect("utf8 path"),
13395            ],
13396        );
13397
13398        let core = Core::start_discovered(spec(vec![root]));
13399        let key = core
13400            .settle()
13401            .entities
13402            .into_iter()
13403            .find(|entity| entity.kind == Kind::Worktree)
13404            .expect("the Worktree row is discovered")
13405            .key;
13406
13407        let admin_dir = core.worktree_admin_dir(&key).expect("read the admin dir");
13408        fs::remove_dir_all(&admin_dir).expect("remove the admin dir by hand");
13409
13410        let reopened = git::open_thread_safe(&repo)
13411            .expect("reopen the repo")
13412            .to_thread_local();
13413        assert_eq!(
13414            git::linked_worktrees(&reopened).expect("count"),
13415            0,
13416            "removing the admin dir alone must be what git's own register stops naming"
13417        );
13418    }
13419
13420    /// A Worktree whose own path is not a git repository at all (the fixture for "the parent
13421    /// Repo is gone or unreadable"): the read errors rather than naming a directory that was
13422    /// never a Worktree's own administrative entry.
13423    #[test]
13424    fn worktree_admin_dir_errors_when_the_path_cannot_be_opened_as_a_repository() {
13425        let dir = tempfile::tempdir().expect("temp dir");
13426        let root = root_of(&dir);
13427        let not_a_repo = root.join("plain-directory");
13428        fs::create_dir_all(&not_a_repo).expect("create it");
13429
13430        let core = Core::start_discovered(spec(vec![root]));
13431        core.settle();
13432        let key = EntityKey::new(Arc::from(not_a_repo.as_path()));
13433
13434        assert!(core.worktree_admin_dir(&key).is_err());
13435    }
13436
13437    /// Every linked Worktree's own working directory, named by path rather than merely
13438    /// counted, for the Repo deletion cascade to remove.
13439    #[test]
13440    fn linked_worktree_paths_names_every_linked_worktrees_own_directory() {
13441        let dir = tempfile::tempdir().expect("temp dir");
13442        let root = root_of(&dir);
13443        let repo = root.join("repo");
13444        init_repo_with_a_commit(&repo);
13445        let first = root.join("first-worktree");
13446        let second = root.join("second-worktree");
13447        crate::test_support::git(
13448            &repo,
13449            &[
13450                "worktree",
13451                "add",
13452                "-b",
13453                "one",
13454                first.to_str().expect("utf8 path"),
13455            ],
13456        );
13457        crate::test_support::git(
13458            &repo,
13459            &[
13460                "worktree",
13461                "add",
13462                "-b",
13463                "two",
13464                second.to_str().expect("utf8 path"),
13465            ],
13466        );
13467
13468        let core = Core::start_discovered(spec(vec![root]));
13469        let key = core
13470            .settle()
13471            .entities
13472            .into_iter()
13473            .find(|entity| entity.kind == Kind::Repo)
13474            .expect("the Repo row is discovered")
13475            .key;
13476
13477        let mut paths = core
13478            .linked_worktree_paths(&key)
13479            .expect("read the linked worktree paths");
13480        paths.sort();
13481        let mut expected = vec![
13482            first.canonicalize().expect("canonicalize first"),
13483            second.canonicalize().expect("canonicalize second"),
13484        ];
13485        expected.sort();
13486
13487        assert_eq!(paths, expected);
13488    }
13489}