Skip to main content

repon_core/
core.rs

1//! `Core::start` and the threads and clocks it owns.
2//!
3//! See `docs/spec/core-api.md`'s "Threads and lifecycle" and "The entry points",
4//! [ADR 0006](https://github.com/paulchiu/repon/blob/main/docs/adr/0006-no-git-state-cache-session-state-by-name.md)
5//! and [ADR 0015](https://github.com/paulchiu/repon/blob/main/docs/adr/0015-the-core-owns-the-table.md).
6//!
7//! One dedicated thread runs the two second metadata poll and the thirty second
8//! Generation deadline sweep on a shared interval loop; probes go on rayon's global
9//! pool, one task per entity, which is infrastructure this crate already shares
10//! rather than a thread `Core` itself owns. `Core::start` is the only thing that
11//! spawns the dedicated thread, and `Drop` joins it, so a consumer never spawns one
12//! of its own. The dedicated thread's ticking source is an injected channel rather
13//! than a bare `thread::sleep`, which is what lets a test drive the poll and
14//! deadline cadence deterministically instead of sleeping and hoping.
15//!
16//! Discovery, both halves, re-runs at the head of every Generation
17//! ([refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md),
18//! [discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md)):
19//! an Entity a later walk does not find goes [`crate::entity::Presence::Vanished`]
20//! rather than disappearing, and one an abandoned walk cannot finish in time takes
21//! its Set out of this automatic path until a fresh `Core` starts over different
22//! roots. Phase C, status, is later work; this module already dispatches identity
23//! (`branch`), `default_branch`, Phase B's own comparison (`sync`) and Phase D's
24//! landing pass (`state`), the reads [`crate::git::head_shape`],
25//! [`crate::default_branch::resolve`], [`crate::git::resolve_sync`] and
26//! [`crate::landing::probe`] already do correctly, so the threading and
27//! supersession machinery has a real payload to move rather than a stub. Nothing
28//! here is written to or read from disk, so
29//! every `start` recomputes from scratch; the consequence is that first-frame
30//! speed has to come from progressive loading rather than a cache, which is
31//! future work this crate does not yet do (`start` blocks on its one discovery
32//! walk, at 20ms for the measured population).
33
34use std::collections::{HashMap, HashSet};
35use std::path::{Path, PathBuf};
36use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
37use std::sync::{Arc, Condvar, Mutex, OnceLock, RwLock};
38use std::thread::{self, JoinHandle};
39use std::time::{Duration, Instant};
40
41use crossbeam_channel::{Receiver, Sender, select};
42use rayon::iter::{IntoParallelIterator, ParallelIterator};
43
44use crate::base;
45use crate::cell::{Cell, Generation, Settled, Timestamp, Unknown};
46use crate::default_branch;
47use crate::discovery::{self, SetSpec};
48use crate::entity::{
49    ActionReceipt, DefaultBranch, DeleteRisk, DirtyCounts, EntityKey, EntityState, Head, Kind,
50    OwnWork, Presence, RunningStep, Skip, StepOutcome, StepResult, SyncState, WorktreeState,
51};
52use crate::environment;
53use crate::executor;
54use crate::filter::{Applicability, Filter, Partition};
55use crate::git;
56use crate::landing;
57#[cfg(any(test, feature = "test-util"))]
58use crate::liveness;
59use crate::patch_equivalence;
60use crate::poll;
61use crate::snapshot::Snapshot;
62
63/// Budget within which rows with names must be on screen at first frame
64/// (`docs/spec/refresh.md`'s "The first frame"). Nothing reads this yet; discovery's own
65/// wall time is the thing that would have to stay under it once first-frame timing is
66/// enforced rather than merely stated. Read only by
67/// `first_frame_budget_constants_match_the_spec_of_record`.
68#[allow(dead_code)] // read only by first_frame_budget_constants_match_the_spec_of_record
69const FIRST_FRAME_NAMES_BUDGET_MS: u64 = 50;
70
71/// Budget within which every cheap column (phase A/B) must be filled at first frame
72/// (`docs/spec/refresh.md`'s "The first frame"). Nothing reads this yet; the identity and
73/// comparison probes' combined wall time is what would have to stay under it. Read only by
74/// `first_frame_budget_constants_match_the_spec_of_record`.
75#[allow(dead_code)] // read only by first_frame_budget_constants_match_the_spec_of_record
76const FIRST_FRAME_CHEAP_COLUMNS_BUDGET_MS: u64 = 200;
77
78/// One Repo's config-level override, crossing from the consumer as plain data: no
79/// TOML type, no `~` expansion left undone. [config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md)
80/// owns parsing it; the core only ever receives the result, and resolves `path` to
81/// the git common dir itself, since opening a repository is the core's own work.
82///
83/// Keyed on `path` rather than the common dir `docs/spec/core-api.md` first named,
84/// which this amends: a Worktree and its parent Repo share one common dir, so only
85/// the entry's own path can outrank an entry reached by inheritance.
86#[derive(Debug, Clone)]
87pub struct RepoOverride {
88    pub path: PathBuf,
89    pub default_branch: Option<String>,
90    pub excluded: bool,
91}
92
93/// One command in an Action's ordered list, crossing from the consumer as plain data:
94/// `from_env` already resolved, so this crate never learns what that means
95/// ([config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md)'s
96/// "Actions", "Launchers" carries the same split for a Launcher's own argv). `shell`
97/// crosses over unresolved, because resolving it is `executor::run_step`'s own job,
98/// the same convention the `repon` crate's Launcher `shell = true` uses: with
99/// `shell` set, `argv` holds exactly one element, the whole command string, per
100/// [config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md)'s
101/// "Launchers". `env`'s pairs are applied after
102/// [`environment::environment`]'s own set-or-unset pairs, so a step's own `env` table
103/// overrides the guaranteed set exactly as [config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md)'s
104/// Launcher `env` field already does.
105#[derive(Debug, Clone)]
106pub struct Step {
107    pub argv: Vec<String>,
108    pub shell: bool,
109    /// Runs `argv` through `$SHELL -ic` rather than `$SHELL -c`, sourcing the user's own rc
110    /// file first ([config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md)'s
111    /// `interactive` key). Meaningless, and always `false`, unless `shell` is also set; the
112    /// consumer rejects `interactive` without `shell` at config load rather than this crate
113    /// silently ignoring it.
114    pub interactive: bool,
115    pub env: Vec<(String, String)>,
116}
117
118/// One Action fan-out, crossing from the consumer as plain data: no TOML type, no
119/// confirm gate, no palette. `label` is what a receipt's own label carries: the
120/// Action's name, or the ad hoc command string typed into the palette. `name` is
121/// `REPON_ACTION`'s own value and is `None` for an ad hoc run, exactly as
122/// [config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md)'s
123/// environment contract already treats a Launcher's absent `REPON_ACTION`. `when` is
124/// `None` for a built-in, a typed command or a configured entry that declares none, in
125/// which case every operable row runs; `Some` is what [`Core::run_action`] itself now
126/// decides the fan-out by, not only what a palette reports about it
127/// ([`docs/spec/actions.md`](https://github.com/paulchiu/repon/blob/main/docs/spec/actions.md)'s
128/// "The Selection and the gate").
129#[derive(Debug, Clone)]
130pub struct ActionSpec {
131    pub label: Arc<str>,
132    pub name: Option<Arc<str>>,
133    pub steps: Vec<Step>,
134    pub concurrency: u32,
135    pub when: Option<Filter>,
136}
137
138/// A [`RepoOverride`]'s probe-input half with its common dir already resolved, built
139/// once at `Core::start` and frozen for this `Core`'s life: `default_branch` is read
140/// while probing, so moving it needs the rediscovery a rebuild does
141/// ([repo-management.md](https://github.com/paulchiu/repon/blob/main/docs/spec/repo-management.md)'s
142/// "Writing config").
143#[derive(Debug, Clone)]
144struct ResolvedOverride {
145    path: PathBuf,
146    common_dir: PathBuf,
147    default_branch: Option<String>,
148}
149
150/// A [`RepoOverride`]'s `exclude` with its common dir already resolved, held apart from
151/// [`ResolvedOverride`] because it re-applies live: `exclude` decides only whether an
152/// operation may reach a row that is discovered, probed and listed either way
153/// ([config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md)'s "listed,
154/// never operated on"), so [`Core::set_exclusions`] replaces it with no rebuild at all.
155#[derive(Debug, Clone)]
156struct ResolvedExclusion {
157    path: PathBuf,
158    common_dir: PathBuf,
159    excluded: bool,
160}
161
162/// The two path fields [`find_entry`]'s match rule reads, so the rule itself is written
163/// once for both halves a `[[repo]]` entry resolves into.
164trait ResolvedEntry {
165    fn path(&self) -> &Path;
166    fn common_dir(&self) -> &Path;
167}
168
169impl ResolvedEntry for ResolvedOverride {
170    fn path(&self) -> &Path {
171        &self.path
172    }
173
174    fn common_dir(&self) -> &Path {
175        &self.common_dir
176    }
177}
178
179impl ResolvedEntry for ResolvedExclusion {
180    fn path(&self) -> &Path {
181        &self.path
182    }
183
184    fn common_dir(&self) -> &Path {
185        &self.common_dir
186    }
187}
188
189/// Opens every override's own `path` to learn its common dir, once, and splits the result
190/// into the half frozen for this `Core`'s life and the half [`Core::set_exclusions`] can
191/// replace live. Silently drops an entry that will not even open: a path that matches no
192/// discovered entity already gets its own warning on the consumer's side
193/// ([config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md#cross-key-validity)),
194/// and the core raises no second one for the same fact.
195fn resolve_entries(overrides: &[RepoOverride]) -> (Vec<ResolvedOverride>, Vec<ResolvedExclusion>) {
196    overrides
197        .iter()
198        .filter_map(|entry| {
199            let common_dir = git::common_dir_of(&entry.path).ok()?;
200            Some((
201                ResolvedOverride {
202                    path: entry.path.clone(),
203                    common_dir: common_dir.to_path_buf(),
204                    default_branch: entry.default_branch.clone(),
205                },
206                ResolvedExclusion {
207                    path: entry.path.clone(),
208                    common_dir: common_dir.to_path_buf(),
209                    excluded: entry.excluded,
210                },
211            ))
212        })
213        .unzip()
214}
215
216/// The entry that applies to an entity at `path` sharing `common_dir`: one naming
217/// `path` itself, or else the first declared entry sharing `common_dir`, which is
218/// what lets one entry cover a Repo and every Worktree attached to it while a
219/// Worktree named directly by its own path still beats the entry it would
220/// otherwise inherit ([config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md#per-repo-entries)).
221///
222/// A consequence worth stating rather than working around here: a Submodule's own
223/// common dir (`<parent common dir>/modules/<name>`, per `discovery.rs`'s
224/// `resolve`) is never equal to its parent's, so one entry naming the parent's path
225/// can never also exclude the parent's Submodules. The documented workaround is a
226/// Set's own `exclude` glob over the subtree, which keeps the Submodule out of
227/// discovery entirely rather than merely marking it excluded here.
228fn find_entry<'a, T: ResolvedEntry>(
229    entries: &'a [T],
230    path: &Path,
231    common_dir: &Path,
232) -> Option<&'a T> {
233    entries
234        .iter()
235        .find(|entry| entry.path() == path)
236        .or_else(|| {
237            entries
238                .iter()
239                .find(|entry| entry.common_dir() == common_dir)
240        })
241}
242
243/// Whether an entity at `path` sharing `common_dir` is excluded by `exclusions`: the one
244/// place the table's own [`EntityState::excluded`] is derived, read at entity creation, at
245/// [`Core::probe_now`] and again whenever [`Core::set_exclusions`] replaces the list.
246fn excluded_by(exclusions: &[ResolvedExclusion], path: &Path, common_dir: &Path) -> bool {
247    find_entry(exclusions, path, common_dir).is_some_and(|entry| entry.excluded)
248}
249
250/// Whether a Generation's dispatch probes `kind` at all: always for a Repo or a
251/// Worktree, only while `show_submodules` is on for a Submodule
252/// ([discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md)'s
253/// "Showing Submodules": "the flag decides... whether they are probed"). Exhaustive over
254/// `Kind`, so a fourth variant added later must be named here rather than silently
255/// falling into either branch.
256fn dispatches_kind(kind: Kind, show_submodules: bool) -> bool {
257    match kind {
258        Kind::Repo | Kind::Worktree => true,
259        Kind::Submodule => show_submodules,
260    }
261}
262
263/// The periodic fetch's own crossing data
264/// ([config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md)'s
265/// `[fetch]` table): whether it runs at all, its cadence, and how many run at once.
266/// Plain bounding data, not the mutating mechanism itself: `enabled: false` here is
267/// what keeps the cycle from running, not the absence of any machinery to run it.
268#[derive(Debug, Clone)]
269pub struct FetchSpec {
270    pub enabled: bool,
271    pub interval: Duration,
272    pub concurrency: usize,
273}
274
275/// The fast-forward-only auto-update's own crossing data
276/// ([config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md)'s
277/// `[auto_update]` table): whether it runs at all.
278///
279/// Plain bounding data, not the mutating mechanism, for the same reason [`FetchSpec`]
280/// is. Carries no interval or concurrency of its own, because it never ticks on its
281/// own clock; it rides the periodic fetch cycle [`FetchSpec`] already schedules, so
282/// `enabled: true` here is inert while `FetchSpec::enabled` is false.
283#[derive(Debug, Clone, Copy)]
284pub struct AutoUpdateSpec {
285    pub enabled: bool,
286}
287
288/// The most recent periodic-fetch cycle's own failures, read fresh through
289/// [`Core::fetch_failures`] the same way [`Core::discovery_warning`] and
290/// [`Core::vanished_count`] are: never latched, so a cycle where every fetch
291/// succeeds leaves this empty rather than carrying a stale failure forward from an
292/// earlier one. Per-repository independence is unaffected: one entry here is one
293/// repository `run_fetch_cycle` could not reach, never a reason another repository's
294/// own fetch was skipped.
295///
296/// Carries the path and the underlying `FetchError`'s own text, for
297/// a consumer's log; a consumer's own screen-facing warning is expected to surface
298/// only `failed.len()`, the precedent `warnings.rs`'s `OnRefreshFailed` already sets
299/// for never putting remote-supplied text on screen.
300#[derive(Debug, Clone, Default, PartialEq, Eq)]
301pub struct FetchFailures {
302    pub failed: Vec<(PathBuf, String)>,
303}
304
305/// One [`Core::attempt_auto_update`] result on a single Repo: the fast-forward-only
306/// auto-update's own five eligibility rules
307/// ([config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md)'s
308/// eligibility rule), flattened into one return type so the built-in `sync` action
309/// ([repo-management.md](https://github.com/paulchiu/repon/blob/main/docs/spec/repo-management.md))
310/// can report every ineligible reason to the user rather than a bare "did nothing".
311#[derive(Debug, Clone, PartialEq, Eq)]
312pub enum AutoUpdateAttempt {
313    /// The working tree or index carried a change of its own.
314    NotClean,
315    /// No branch, no remote, or a branch with no upstream configured.
316    NoUpstream,
317    /// Already level with its upstream: nothing to move.
318    NotBehind,
319    /// The local branch has a commit its upstream does not, so no fast-forward exists.
320    NotFastForward,
321    /// The branch fast-forwarded to its upstream.
322    Updated,
323    /// A git read or write failed partway through.
324    Failed(String),
325}
326
327/// Everything `Core::start` needs, handed as plain data. The core reads no file, no
328/// path and no environment variable: this is the whole crossing, per
329/// `docs/spec/core-api.md`'s "What crosses from config".
330#[derive(Debug, Clone)]
331pub struct CoreSpec {
332    pub set: SetSpec,
333    pub overrides: Vec<RepoOverride>,
334    pub poll_interval: Duration,
335    pub status_stale_after: Duration,
336    pub generation_deadline: Duration,
337    /// The initial reading of the show-submodules preference
338    /// ([discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md)'s
339    /// "Showing Submodules"): whether a dispatched Generation probes a Submodule at all.
340    /// Live-updatable afterwards through [`Core::set_show_submodules`], which is what lets
341    /// toggling it skip a Core rebuild and the rediscovery that would come with one.
342    pub show_submodules: bool,
343    /// The periodic fetch's own bounding data. See [`FetchSpec`].
344    pub fetch: FetchSpec,
345    /// The fast-forward-only auto-update's own bounding data. See [`AutoUpdateSpec`]:
346    /// inert while `fetch.enabled` is itself `false`
347    /// (`Warning::AutoUpdateWithoutFetch` is what tells a config author that
348    /// combination can never fire).
349    pub auto_update: AutoUpdateSpec,
350}
351
352/// One entity's in-flight probe: which Generation dispatched it, and the flag that
353/// generation owns to cancel it. [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)
354/// fixes one `Arc<AtomicBool>` per in-flight entity, passed as `should_interrupt`;
355/// `gix::interrupt::IS_INTERRUPTED` is a process-global static and is never used.
356struct InFlight {
357    generation: u64,
358    cancel: Arc<AtomicBool>,
359}
360
361/// The assembled entity table `Core` owns: the only place a Generation's per-cell
362/// supersession check happens, per ADR 0015.
363struct Table {
364    generation: u64,
365    discovered_at: Timestamp,
366    entities: Vec<EntityState>,
367    index: HashMap<EntityKey, usize>,
368    in_flight: HashMap<EntityKey, InFlight>,
369    /// When each still-live Generation's dispatch started, for the deadline sweep.
370    /// Pruned once nothing is left in flight for a Generation.
371    generation_started_at: HashMap<u64, Instant>,
372    /// Each entity's thread-safe repository handle, opened once by discovery.
373    /// A probe task clones the `Arc` (cheap, a refcount bump) and derives its own
374    /// `Repository` from it via `to_thread_local`, so no task ever shares a
375    /// `Repository` with another one; a missing entry (a Submodule, or a boundary
376    /// that would not open) falls back to opening fresh at probe time.
377    repos: HashMap<EntityKey, Arc<gix::ThreadSafeRepository>>,
378    /// Each entity's gitdir reading as of the previous metadata poll sweep, so the
379    /// next sweep can tell whether any of [`poll::POLLED_GITDIR_ENTRIES`] moved
380    /// ([refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
381    /// "The poll"). Absent for an entity the sweep has never yet reached, which is
382    /// what lets a first sweep record a baseline rather than reporting the whole
383    /// population as having just moved.
384    poll_fingerprints: HashMap<EntityKey, poll::GitdirFingerprint>,
385}
386
387/// A message the dedicated thread's control channel carries; distinct from a tick.
388enum ClockControl {
389    Pause,
390    Resume,
391    Shutdown,
392    /// Start a periodic-fetch cycle now rather than on the next `fetch.interval` tick, which
393    /// is how the first discovery asks for the immediate cycle enabling the fetch owes
394    /// ([refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s "The
395    /// periodic fetch"). Sent rather than run there, so every cycle has the one owner. Asked
396    /// for once and never again, so the clock holds it until it can start it rather than
397    /// dropping it.
398    FetchNow,
399}
400
401/// A running core: its own table, its own dedicated thread, and the rayon pool it
402/// shares with the rest of the process for probes.
403///
404/// Construction is `start`, never a plain constructor, because it spawns; `Drop`
405/// joins every thread it spawned. The public entry points are exactly `start`,
406/// `refresh`, `probe_now`, `snapshot`, `try_settle`, `dismiss`, `pause`, `resume`,
407/// `discovery_warning` and `run_action` (see its own doc comment).
408pub struct Core {
409    table: Arc<RwLock<Table>>,
410    /// Resolved once at `start` and never mutated afterwards: `default_branch` is a probe
411    /// input, so moving it needs the rediscovery a rebuilt `Core` does
412    /// ([config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md#reload)).
413    overrides: Arc<Vec<ResolvedOverride>>,
414    /// The live `exclude` half of the same `[[repo]]` entries, replaced wholesale by
415    /// [`Core::set_exclusions`] with no rebuild and no rediscovery, the same shape
416    /// `show_submodules` already has: `exclude` decides only whether an operation may reach
417    /// a row that is discovered and listed either way, so it is an operate-time filter over
418    /// a table that is already correct
419    /// ([repo-management.md](https://github.com/paulchiu/repon/blob/main/docs/spec/repo-management.md)'s
420    /// "Writing config").
421    exclusions: Arc<RwLock<Vec<ResolvedExclusion>>>,
422    /// The Set `start` was given, retained so `refresh` can re-run discovery over
423    /// the same bounding specification at the head of every Generation
424    /// ([discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md),
425    /// [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)).
426    /// Immutable for the same reason `overrides` is: a Set's `roots` or globs
427    /// changing is a config reload, which re-derives a whole new `Core` rather
428    /// than mutating this one in place.
429    set: SetSpec,
430    /// Set once discovery abandons a walk, and never cleared for the life of this
431    /// `Core`: it takes the Set out of the automatic refresh path, since
432    /// re-running a thirty-second walk at the head of every Generation is not a
433    /// degraded mode worth paying for.
434    discovery_manual: Arc<AtomicBool>,
435    /// How long a re-run discovery walk may run before the still-walking warning
436    /// fires; real value is one second outside a test.
437    discovery_warn_after: Duration,
438    /// How long a re-run discovery walk may run before it is abandoned, in nanoseconds;
439    /// real value is [`discovery::ABANDON_AFTER`] outside a test. Shared and atomic so a
440    /// test can tighten it after `start`, rather than racing one deadline against both a
441    /// walk that must survive and a walk that must not.
442    discovery_abandon_after: Arc<AtomicU64>,
443    /// The live show-submodules preference a dispatched Generation reads: `true` once
444    /// [`Core::set_show_submodules`] last set it that way, `CoreSpec::show_submodules` until
445    /// then. Atomic and shared with every `RefreshHandles` clone so toggling it needs no
446    /// rebuild and dispatches nothing of its own
447    /// ([discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md)'s
448    /// "Showing Submodules": "toggling is instant, because nothing needs discovering").
449    show_submodules: Arc<AtomicBool>,
450    settle_gate: Arc<SettleGate>,
451    control: Sender<ClockControl>,
452    clock_thread: Option<JoinHandle<()>>,
453    /// Set by the dedicated thread's discovery-slow watcher if `start`'s one walk
454    /// ran a full second without finishing, and by a later re-run's own abandon
455    /// path. Read through [`Core::discovery_warning`], the UI's shared warning
456    /// slot's one entry point onto discovery, per
457    /// [discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md).
458    discovery_warning: Arc<Mutex<Option<String>>>,
459    /// Reset to zero at the start of every `refresh`, then incremented once per
460    /// distinct common dir among that Generation's dispatched entities whose
461    /// default-branch chain facts are actually computed, as opposed to reused from
462    /// another entity sharing the same common dir. Never persisted across
463    /// Generations, per [ADR 0006](https://github.com/paulchiu/repon/blob/main/docs/adr/0006-no-git-state-cache-session-state-by-name.md):
464    /// the memo cache itself lives only for the lifetime of one `refresh` call.
465    /// Read only by `default_branch_chain_reads_for_test`, which is what proves
466    /// [default-branch.md](https://github.com/paulchiu/repon/blob/main/docs/spec/default-branch.md)'s
467    /// per-common-dir memoisation actually ran rather than merely agreeing by
468    /// coincidence.
469    #[allow(dead_code)] // read only by default_branch_chain_reads_for_test
470    default_branch_chain_reads: Arc<AtomicUsize>,
471    /// The same counter as `default_branch_chain_reads`, for patch equivalence's
472    /// own expensive half ([`patch_equivalence::scan_default_branch`]) instead of
473    /// the default-branch chain's: reset to zero at the start of every `refresh`,
474    /// incremented once per distinct common dir whose default-branch commit
475    /// history is actually scanned, as opposed to reused from another entity
476    /// sharing the same common dir this Generation. Never persisted across
477    /// Generations, per [ADR 0006](https://github.com/paulchiu/repon/blob/main/docs/adr/0006-no-git-state-cache-session-state-by-name.md).
478    /// Read only by `patch_identity_reads_for_test`.
479    #[allow(dead_code)] // read only by patch_identity_reads_for_test
480    patch_identity_reads: Arc<AtomicUsize>,
481    /// The bound each actually-run [`patch_equivalence::scan_default_branch`] call
482    /// this Generation was passed, one entry per common dir it ran for, in the
483    /// order those scans ran; cleared at the start of every `refresh`. Recorded
484    /// from inside `patch_identities_for`'s `compute` closure, so this is the value
485    /// the production call site used, not a value a test recomputes independently.
486    /// Read only by `patch_scan_bounds_for_test`.
487    #[allow(dead_code)] // read only by patch_scan_bounds_for_test
488    patch_scan_bounds: Arc<Mutex<Vec<Option<gix::ObjectId>>>>,
489    /// Which Action run is admitted and what reaches its steps' children: see
490    /// [`ActionLifecycle`]. What [`Core::run_action`]'s own entry is guarded by, what
491    /// [`Core::action_running`] reads, and where [`Core::hold_action`],
492    /// [`Core::continue_action`] and [`Core::stop_action`] each find the control they
493    /// signal.
494    action_lifecycle: Arc<Mutex<ActionLifecycle>>,
495    /// Every key `refresh`'s own sequential dispatch loop iterated, in the order it iterated
496    /// them, cleared at the start of every call: this is dispatch order, not completion
497    /// order, recorded synchronously in the loop that decides it, before any `rayon::spawn`
498    /// closure ever runs. [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
499    /// "Scope and order" fixes dispatch order as the one dial phase C has; completion order
500    /// on a concurrent pool is a different, non-deterministic fact this field does not claim
501    /// to answer. Read only by `dispatch_log_for_test`.
502    #[allow(dead_code)] // read only by dispatch_log_for_test
503    dispatch_log: Arc<Mutex<Vec<EntityKey>>>,
504    /// Test-only synchronisation points, keyed by entity, letting a test hold the
505    /// dispatch loop's state and dirty probes open after that same entity's cheap
506    /// outcomes (branch, sync, default branch) have already landed on the table,
507    /// so [`refresh`]'s two applies can be proven independent with a blocking wait
508    /// rather than a sleep. Always present and normally empty: a Generation reads it
509    /// once per entity as it dispatches that entity, and one never registered here
510    /// resolves to nothing and proceeds exactly as if this field did not exist.
511    /// Registered and read only by the `_for_test` methods below.
512    #[allow(dead_code)] // populated and read only by tests
513    phase_c_gates: Arc<Mutex<HashMap<EntityKey, PhaseCGateHandle>>>,
514    /// The age past which a `Known` `dirty` or `state` cell reads Stale even though
515    /// nothing probed it again: `CoreSpec::status_stale_after`'s own copy, applied
516    /// inside [`Core::snapshot`] rather than by a background sweep, since
517    /// [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
518    /// "Staleness" rules out a global clock-driven one.
519    status_stale_after: Duration,
520    /// Every key the metadata poll's most recent sweep actually re-ran phases A
521    /// and B for, in the order it found them moved, cleared at the start of every
522    /// sweep. Read only by `poll_reprobed_for_test`, which is what proves a
523    /// sweep re-probes the moved entity alone rather than the whole population.
524    #[allow(dead_code)] // read only by poll_reprobed_for_test
525    poll_reprobed: Arc<Mutex<Vec<EntityKey>>>,
526    /// How many metadata-poll sweeps have run in total, whether or not any entity
527    /// had moved. Read only by `poll_sweep_count_for_test`, which is what proves a
528    /// real tick sent through the dedicated thread's own channel reaches the
529    /// sweep at all, distinct from `poll_reprobed` proving what a sweep that found
530    /// movement then did.
531    #[allow(dead_code)] // read only by poll_sweep_count_for_test
532    poll_sweep_count: Arc<AtomicUsize>,
533    /// How many periodic-fetch cycles have run in total, whether or not any
534    /// repository had a remote to fetch: the immediate first cycle plus one per
535    /// `fetch.interval` tick since. Read only by `fetch_cycle_count_for_test`,
536    /// which is what proves the immediate cycle ran without waiting on the
537    /// recurring cadence at all.
538    #[allow(dead_code)] // read only by fetch_cycle_count_for_test
539    fetch_cycle_count: Arc<AtomicUsize>,
540    /// The network's advertised default branch, per common dir, read from a fetch
541    /// handshake's own advertised HEAD alone
542    /// ([default-branch.md](https://github.com/paulchiu/repon/blob/main/docs/spec/default-branch.md)'s
543    /// "The network"): present only once the periodic fetch or
544    /// [`Core::rederive_default_branches`] has actually reached that remote.
545    /// Superseded there, never here on read; consulted by every default-branch
546    /// probe this crate runs, so an answer landed by one persists across every
547    /// later Generation for the life of this `Core`, which is what "supersedes
548    /// the local one for that session" means: never written back to any
549    /// reference, and gone the moment this `Core` is dropped, per ADR 0012.
550    network_default_branch: Arc<Mutex<HashMap<PathBuf, Arc<str>>>>,
551    /// The most recently completed periodic-fetch cycle's own failures, replaced
552    /// wholesale by [`run_fetch_cycle`] every time it runs. Read through
553    /// [`Core::fetch_failures`].
554    fetch_failures: Arc<Mutex<FetchFailures>>,
555    /// Orders every spawned dispatch body this `Core` starts; see
556    /// [`DispatchTurnstile`].
557    turnstile: Arc<DispatchTurnstile>,
558    /// See [`DiscoveryGate`]. `None` on every production path.
559    discovery_gate: Option<DiscoveryGate>,
560    /// See [`ActionCompletionBoundary`]. Disarmed unless a test arms it, and off the
561    /// default build entirely.
562    #[cfg(test)]
563    action_completion_boundary: Arc<ActionCompletionBoundary>,
564    /// See [`FetchBoundary`]. Disarmed unless a test arms it, and off the default build
565    /// entirely.
566    #[cfg(test)]
567    fetch_boundary: Arc<FetchBoundary>,
568}
569
570/// One entity's phase C test gate state, guarded by the paired [`Condvar`] stored
571/// alongside it in [`Core::phase_c_gates`].
572#[derive(Default)]
573struct PhaseCGate {
574    /// Set once this entity's cheap outcomes have been applied to the table.
575    cheap_landed: bool,
576    /// Set by a test once it has observed `cheap_landed` and wants phase C (and
577    /// D) to proceed.
578    may_proceed: bool,
579    /// Set once this entity's phase C/D outcomes have been applied to the table
580    /// and the settle gate decremented for it.
581    finished: bool,
582}
583
584/// A [`PhaseCGate`] shared between the dispatch loop and the `_for_test` methods
585/// that register, wait on and release it.
586type PhaseCGateHandle = Arc<(Mutex<PhaseCGate>, Condvar)>;
587
588/// The Action lifecycle's one owned value: the run admitted right now, if any, together
589/// with the reach into that run's steps' children.
590///
591/// Admission and completion are each one transition on this one value, so a run's control
592/// arrives and leaves with its admission rather than through a second write a later run can
593/// land between. Every critical section here is a read or a single field write, so the lock
594/// is never held across a wait on a child process, across git, or across anything that can
595/// panic and poison it.
596#[derive(Default)]
597struct ActionLifecycle {
598    /// The admitted run's own reach into its steps' children, `None` between runs: what
599    /// [`Core::hold_action`], [`Core::continue_action`] and [`Core::stop_action`] each look
600    /// up before doing anything, so all three are no-ops with no fan-out live. Deliberately
601    /// its own value rather than folded into `pause`/`resume`'s machinery, per
602    /// [ADR 0018](https://github.com/paulchiu/repon/blob/main/docs/adr/0018-an-action-is-a-fanout-of-pty-backed-steps.md)'s
603    /// own hold and stop verbs: the core is contractually not told why background work
604    /// stopped, and a step's child needs SIGSTOP/SIGTERM/SIGKILL, information `pause` must
605    /// never carry.
606    live: Option<Arc<executor::RunControl>>,
607}
608
609impl ActionLifecycle {
610    /// Admits a run and registers its control together, or refuses because one is already
611    /// live: only one fan-out runs at a time, per
612    /// [ADR 0018](https://github.com/paulchiu/repon/blob/main/docs/adr/0018-an-action-is-a-fanout-of-pty-backed-steps.md)'s
613    /// "One Action runs at a time".
614    fn admit(&mut self, control: Arc<executor::RunControl>) -> bool {
615        if self.live.is_some() {
616            return false;
617        }
618        self.live = Some(control);
619        true
620    }
621
622    /// Releases the admitted run, the last thing [`RunCompletion`] does.
623    fn complete(&mut self) {
624        self.live = None;
625    }
626}
627
628/// Releases a finished run's admission however its fan-out thread ends, so a panic past the
629/// fan-out can never leave a `Core` reading that run as live for the rest of its life.
630///
631/// Dropped after the completion Generation has been dispatched, which is what orders the
632/// two: the next run is refused until this one has started the Generation it owes, so what
633/// that run cancels on the way in can never be a Generation the run it replaced has yet to
634/// dispatch.
635struct RunCompletion {
636    lifecycle: Arc<Mutex<ActionLifecycle>>,
637    /// See [`ActionCompletionBoundary`].
638    #[cfg(test)]
639    boundary: Arc<ActionCompletionBoundary>,
640}
641
642impl Drop for RunCompletion {
643    fn drop(&mut self) {
644        // Nothing parks here unless a test armed this boundary.
645        #[cfg(test)]
646        self.boundary.hold();
647        self.lifecycle.lock().unwrap().complete();
648    }
649}
650
651/// A park in the one statement between a completion dispatching its Generation and
652/// [`RunCompletion`] releasing the run, for a test.
653///
654/// Neither half of that ordering is observable from outside without holding the completion
655/// there: the two are adjacent statements, and a test racing them reads whichever it
656/// happened to catch. One per `Core` and disarmed until a test arms it, so a run nobody is
657/// watching reads one bool and carries on, and the whole affordance is gated off the
658/// default build.
659#[cfg(test)]
660#[derive(Default)]
661pub(crate) struct ActionCompletionBoundary {
662    state: Mutex<BoundaryState>,
663    changed: Condvar,
664}
665
666/// [`ActionCompletionBoundary`]'s own state, guarded by its `Condvar`.
667#[cfg(test)]
668#[derive(Default)]
669struct BoundaryState {
670    /// Set by a test before the run whose completion it wants held.
671    armed: bool,
672    /// Set by the completion that parked at an armed boundary.
673    reached: bool,
674    /// Set when the [`ArmedBoundary`] drops.
675    released: bool,
676}
677
678#[cfg(test)]
679impl ActionCompletionBoundary {
680    /// Holds the next completion to reach this boundary until the returned value drops. For
681    /// a test, before the run whose completion it wants held.
682    pub(crate) fn arm(self: &Arc<Self>) -> ArmedBoundary {
683        self.state.lock().unwrap().armed = true;
684        ArmedBoundary(Arc::clone(self))
685    }
686
687    /// Parks a completion here while an armed boundary holds it.
688    fn hold(&self) {
689        let mut state = self.state.lock().unwrap();
690        if !state.armed {
691            return;
692        }
693        state.reached = true;
694        self.changed.notify_all();
695        let (state, expiry) = self
696            .changed
697            .wait_timeout_while(state, liveness::BACKSTOP, |state| !state.released)
698            .unwrap();
699        drop(state);
700        if expiry.timed_out() {
701            liveness::expired(
702                liveness::BACKSTOP,
703                "a test to release the Action completion boundary",
704                "",
705            );
706        }
707    }
708}
709
710/// One armed [`ActionCompletionBoundary`], released when this drops so an assertion failing
711/// inside the window reports itself rather than leaving a completion parked for
712/// [`liveness::BACKSTOP`].
713#[cfg(test)]
714pub(crate) struct ArmedBoundary(Arc<ActionCompletionBoundary>);
715
716#[cfg(test)]
717impl ArmedBoundary {
718    /// Blocks until a completion has parked at this boundary. For a test.
719    pub(crate) fn wait_until_reached(&self) {
720        let (state, expiry) = self
721            .0
722            .changed
723            .wait_timeout_while(self.0.state.lock().unwrap(), liveness::BACKSTOP, |state| {
724                !state.reached
725            })
726            .unwrap();
727        drop(state);
728        if expiry.timed_out() {
729            liveness::expired(
730                liveness::BACKSTOP,
731                "a completion to reach the Action completion boundary",
732                "",
733            );
734        }
735    }
736}
737
738#[cfg(test)]
739impl Drop for ArmedBoundary {
740    fn drop(&mut self) {
741        let mut state = self.0.state.lock().unwrap();
742        state.released = true;
743        self.0.changed.notify_all();
744    }
745}
746
747/// A park inside one periodic-fetch cycle's own per-repository work, for a test.
748///
749/// A fetch against a real remote finishes when the remote says so, which is no moment a test
750/// can hold anything at. This is that moment: the cycle signals it has entered a fetch and
751/// stays there until the test that armed this lets it go, so the clock's own tick, pause and
752/// shutdown handling can be observed against a fetch that provably has not finished. A
753/// cancellation is recorded here rather than acted on, which is the bound
754/// [`crate::fetch::fetch_and_prune`] documents for a real fetch before its receive stage.
755/// One per `Core` and disarmed until a test arms it, so a cycle nobody is watching reads one
756/// bool and carries on, and the whole affordance is gated off the default build.
757#[cfg(test)]
758#[derive(Default)]
759pub(crate) struct FetchBoundary {
760    state: Mutex<FetchBoundaryState>,
761    changed: Condvar,
762}
763
764/// [`FetchBoundary`]'s own state, guarded by its `Condvar`.
765#[cfg(test)]
766#[derive(Default)]
767struct FetchBoundaryState {
768    /// Set by a test before the cycle it wants held.
769    armed: bool,
770    /// Set by the first fetch that parked at an armed boundary.
771    reached: bool,
772    /// Set when the [`ArmedFetchBoundary`] drops.
773    released: bool,
774    /// Set when the cycle holding a fetch here is cancelled.
775    cancelled: bool,
776}
777
778#[cfg(test)]
779impl FetchBoundary {
780    /// Holds every fetch that reaches this boundary until the returned value drops. For a
781    /// test, before the cycle it wants held. Every flag resets here, so a second armed cycle
782    /// on the same `Core` parks rather than walking through what the first one left set.
783    pub(crate) fn arm(self: &Arc<Self>) -> ArmedFetchBoundary {
784        *self.state.lock().unwrap() = FetchBoundaryState {
785            armed: true,
786            ..FetchBoundaryState::default()
787        };
788        ArmedFetchBoundary(Arc::clone(self))
789    }
790
791    /// Parks a fetch here until the test that armed this lets it go.
792    ///
793    /// No deadline of its own, deliberately: a clock too wedged to reach the release would
794    /// otherwise be let through by a timeout here, and the test that was watching it would
795    /// pass a couple of minutes late rather than fail.
796    fn hold(&self) {
797        let mut state = self.state.lock().unwrap();
798        if !state.armed {
799            return;
800        }
801        state.reached = true;
802        self.changed.notify_all();
803        drop(
804            self.changed
805                .wait_while(state, |state| !state.released)
806                .unwrap(),
807        );
808    }
809
810    /// Records that the cycle holding a fetch here was cancelled. Evidence for the test
811    /// rather than a release, since a real fetch before its receive stage reads no flag.
812    fn cancelled(&self) {
813        self.state.lock().unwrap().cancelled = true;
814        self.changed.notify_all();
815    }
816}
817
818/// One armed [`FetchBoundary`], released when this drops so an assertion failing inside the
819/// window reports itself rather than leaving a fetch parked for the rest of the run.
820#[cfg(test)]
821pub(crate) struct ArmedFetchBoundary(Arc<FetchBoundary>);
822
823#[cfg(test)]
824impl ArmedFetchBoundary {
825    /// Blocks until a fetch has parked at this boundary. For a test.
826    pub(crate) fn wait_until_reached(&self) {
827        self.wait_until("a fetch to reach the fetch boundary", |state| state.reached);
828    }
829
830    /// Blocks until the cycle whose fetch is parked here has been cancelled. For a test.
831    pub(crate) fn wait_until_cancelled(&self) {
832        self.wait_until("the held cycle's own cancellation", |state| state.cancelled);
833    }
834
835    fn wait_until(&self, property: &str, held: impl Fn(&FetchBoundaryState) -> bool) {
836        let (state, expiry) = self
837            .0
838            .changed
839            .wait_timeout_while(self.0.state.lock().unwrap(), liveness::BACKSTOP, |state| {
840                !held(state)
841            })
842            .unwrap();
843        drop(state);
844        if expiry.timed_out() {
845            liveness::expired(liveness::BACKSTOP, property, "");
846        }
847    }
848}
849
850#[cfg(test)]
851impl Drop for ArmedFetchBoundary {
852    fn drop(&mut self) {
853        let mut state = self.0.state.lock().unwrap();
854        state.released = true;
855        self.0.changed.notify_all();
856    }
857}
858
859impl Core {
860    /// Spawns the dedicated thread, starts the first discovery walk on a thread of
861    /// its own, and returns a running core at once.
862    ///
863    /// The table it returns is empty: discovery lands its rows afterwards, which is what
864    /// lets a consumer claim the terminal and draw a first frame without waiting out a
865    /// walk (refresh.md's "The first frame"). That walk is refresh.md's "Startup"
866    /// Generation as well, dispatched over what it found, so a consumer probes its rows
867    /// by starting a `Core` and never by asking for a second walk of the same tree.
868    /// [`Self::try_settle`] waits for it the way it waits for any other Generation.
869    pub fn start(spec: CoreSpec) -> Core {
870        Self::start_watched(spec).core
871    }
872
873    /// [`Self::start`], keeping the handles `start_internal` hands back.
874    fn start_watched(spec: CoreSpec) -> StartForTest {
875        let interval = spec.poll_interval.max(Duration::from_nanos(1));
876        let ticks = crossbeam_channel::tick(interval);
877        let alive = Arc::new(AtomicBool::new(true));
878        let fetch_start = FetchStart {
879            enabled: spec.fetch.enabled,
880            concurrency: spec.fetch.concurrency.max(1),
881            ticks: if spec.fetch.enabled {
882                crossbeam_channel::tick(spec.fetch.interval.max(Duration::from_nanos(1)))
883            } else {
884                crossbeam_channel::never()
885            },
886        };
887        start_internal(
888            spec,
889            Duration::from_secs(1),
890            discovery::ABANDON_AFTER,
891            ticks,
892            fetch_start,
893            alive,
894            None,
895        )
896    }
897
898    /// [`Self::start`], blocked until the first discovery has landed on the table.
899    ///
900    /// For a test, and for nothing else: `start` returns against an empty table
901    /// now, so a test that reads the table straight afterwards needs this
902    /// rendezvous. It is a join on the discovery thread rather than a poll or a
903    /// sleep, so it carries no deadline of its own.
904    ///
905    /// Gated behind `test-util` (on by default under `cfg(test)` for this crate's own
906    /// tests) so a test-only affordance never ships on the default published surface,
907    /// per [ADR 0021](https://github.com/paulchiu/repon/blob/main/docs/adr/0021-a-release-is-what-the-tag-pipeline-publishes.md).
908    #[cfg(any(test, feature = "test-util"))]
909    pub fn start_discovered(spec: CoreSpec) -> Core {
910        let mut started = Self::start_watched(spec);
911        if let Some(handle) = started.initial_discovery.take() {
912            handle
913                .join()
914                .expect("the first discovery thread should not panic");
915        }
916        started.core
917    }
918
919    /// Starts a new Generation, dispatching a probe for every key in `order` that
920    /// the table already knows, in that order. An empty or unknown-only `order`
921    /// dispatches nothing and carries no other meaning. Returns immediately: the
922    /// probes run on rayon's global pool.
923    pub fn refresh(&self, order: &[EntityKey]) -> Generation {
924        self.refresh_handles().dispatch(order)
925    }
926
927    /// Starts a new Generation over every entity this Generation's own discovery
928    /// leaves in the table, in discovery order.
929    ///
930    /// A Set switch's Generation, per
931    /// [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
932    /// "Switching Set": the caller has just discarded the old Set's rows, so it has no
933    /// order to compute and no keys to name. Unlike [`Self::refresh`], which resolves
934    /// the order the caller handed it, this resolves the order after discovery has run,
935    /// which is what lets it cover rows the caller could not have named. Startup needs
936    /// none of this: [`Self::start`]'s own walk is that Generation. Returns
937    /// immediately, the same way `refresh` does.
938    pub fn refresh_all(&self) -> Generation {
939        self.refresh_handles().dispatch_over_everything()
940    }
941
942    /// Re-derives `default_branch` alone for every key in `keys` already known to
943    /// the table, in a fresh Generation, per
944    /// [default-branch.md](https://github.com/paulchiu/repon/blob/main/docs/spec/default-branch.md)'s
945    /// "A user-triggered re-derive over the Selection ... on demand" and
946    /// [keybindings.md](https://github.com/paulchiu/repon/blob/main/docs/spec/keybindings.md)'s
947    /// `b`. Unlike [`Self::refresh`], this never re-runs discovery and never
948    /// touches any other cell on any entity, known or not: a key outside `keys`
949    /// is left exactly as it was, and so is every cell but `default_branch` on a
950    /// key inside it.
951    ///
952    /// Runs the local chain exactly as any other refresh would, then a
953    /// handshake-only network probe per distinct common dir among `keys`
954    /// (`fetch::probe_remote_head`): no pack requested and no ref updated, which is
955    /// "without fetching". Its answer, once landed on `network_default_branch`, is
956    /// what `supersede_with_network` applies here and on every later probe of that
957    /// common dir for the life of this `Core`.
958    ///
959    /// Returns immediately, which is also why a stalled remote has nothing to end
960    /// it here: the deadline sweep is per entity, not per cell, so this is on the
961    /// open-questions register rather than closed. The probes run on a plain thread, never rayon's
962    /// global pool, for the reason `fetch::run_bounded`'s own doc comment gives
963    /// the periodic fetch's identical choice: a remote blocked on the network
964    /// for seconds must never take a worker away from the pool every other
965    /// probe shares.
966    pub fn rederive_default_branches(&self, keys: &[EntityKey]) -> Generation {
967        let generation = {
968            let mut table = self.table.write().unwrap();
969            table.generation += 1;
970            Generation::new(table.generation)
971        };
972
973        let dispatched: Vec<RederiveCandidate> = {
974            let mut table = self.table.write().unwrap();
975            let mut dispatched = Vec::new();
976            for key in keys {
977                let Some(&idx) = table.index.get(key) else {
978                    continue;
979                };
980                table.entities[idx].default_branch.begin_probe();
981                let common_dir = Arc::clone(&table.entities[idx].common_dir);
982                let override_branch = find_entry(&self.overrides, key.path(), &common_dir)
983                    .and_then(|entry| entry.default_branch.clone());
984                let repo = table.repos.get(key).cloned();
985                let kind = table.entities[idx].kind;
986                dispatched.push(RederiveCandidate {
987                    key: key.clone(),
988                    path: key.path().to_path_buf(),
989                    common_dir,
990                    repo,
991                    override_branch,
992                    kind,
993                });
994            }
995            dispatched
996        };
997
998        if dispatched.is_empty() {
999            return generation;
1000        }
1001
1002        begin_probes_owed(&self.settle_gate, dispatched.len());
1003
1004        let table = Arc::clone(&self.table);
1005        let settle_gate = Arc::clone(&self.settle_gate);
1006        let network_default_branch = Arc::clone(&self.network_default_branch);
1007        thread::spawn(move || {
1008            let common_dirs: HashSet<Arc<Path>> = dispatched
1009                .iter()
1010                .map(|candidate| Arc::clone(&candidate.common_dir))
1011                .collect();
1012            probe_network_default_branches(&common_dirs, &network_default_branch);
1013
1014            // Scoped to this one call, never shared with a concurrent `refresh`'s own
1015            // memo: the local chain's own per-common-dir facts are cheap enough
1016            // (`default-branch.md`'s "about 20ms") that a fresh cache here costs this
1017            // call nothing a shared one would have saved.
1018            let chain_cache: ChainFactsCache = Mutex::new(HashMap::new());
1019            let chain_reads = AtomicUsize::new(0);
1020            let never_cancelled = AtomicBool::new(false);
1021
1022            for candidate in dispatched {
1023                let RederiveCandidate {
1024                    key,
1025                    path,
1026                    common_dir,
1027                    repo,
1028                    override_branch,
1029                    kind,
1030                } = candidate;
1031                let network_branch = network_branch_for(&network_default_branch, &common_dir);
1032                let resolution = probe_default_branch_memoised(
1033                    &path,
1034                    repo.as_deref(),
1035                    &common_dir,
1036                    DefaultBranchHints {
1037                        override_branch: override_branch.as_deref(),
1038                        network_branch: network_branch.as_deref(),
1039                    },
1040                    kind,
1041                    &never_cancelled,
1042                    &ChainFactsMemo {
1043                        cache: &chain_cache,
1044                        reads: &chain_reads,
1045                    },
1046                );
1047                {
1048                    let mut table = table.write().unwrap();
1049                    if let (Some(&idx), Some(resolution)) = (table.index.get(&key), resolution) {
1050                        table.entities[idx].apply_default_branch_resolution(generation, resolution);
1051                    }
1052                }
1053                complete_one(&settle_gate);
1054            }
1055        });
1056
1057        generation
1058    }
1059
1060    /// Clones out every `Arc` a Generation's dispatch reads, plus the plain data
1061    /// ([`SetSpec`], the two durations) it cannot share by reference: a handful of
1062    /// refcount bumps, never a copy of the table itself. This is what lets
1063    /// [`run_action`](Core::run_action)'s completion, which runs on a plain thread
1064    /// this `Core` does not own and outlives the `&self` borrow that started it,
1065    /// start the one normal Generation `docs/spec/actions.md`'s "Refreshing around a
1066    /// run" promises through the exact same [`RefreshHandles::dispatch`] `refresh`
1067    /// itself calls, rather than a second, drifting copy of its body.
1068    fn refresh_handles(&self) -> RefreshHandles {
1069        RefreshHandles {
1070            table: Arc::clone(&self.table),
1071            overrides: Arc::clone(&self.overrides),
1072            exclusions: Arc::clone(&self.exclusions),
1073            set: self.set.clone(),
1074            discovery_manual: Arc::clone(&self.discovery_manual),
1075            discovery_warn_after: self.discovery_warn_after,
1076            discovery_abandon_after: Arc::clone(&self.discovery_abandon_after),
1077            discovery_warning: Arc::clone(&self.discovery_warning),
1078            show_submodules: Arc::clone(&self.show_submodules),
1079            settle_gate: Arc::clone(&self.settle_gate),
1080            default_branch_chain_reads: Arc::clone(&self.default_branch_chain_reads),
1081            patch_identity_reads: Arc::clone(&self.patch_identity_reads),
1082            patch_scan_bounds: Arc::clone(&self.patch_scan_bounds),
1083            dispatch_log: Arc::clone(&self.dispatch_log),
1084            phase_c_gates: Arc::clone(&self.phase_c_gates),
1085            network_default_branch: Arc::clone(&self.network_default_branch),
1086            turnstile: Arc::clone(&self.turnstile),
1087            discovery_gate: self.discovery_gate.clone(),
1088        }
1089    }
1090
1091    /// Re-probes one entity synchronously against the table's current Generation,
1092    /// which is what a Launcher return needs before a normal Generation starts.
1093    /// Inserts a fresh entity for an unknown key rather than panicking, since a
1094    /// caller can otherwise only reach this with a key `snapshot` just handed it.
1095    pub fn probe_now(&self, key: &EntityKey) -> EntityState {
1096        // An `Arc` rather than a bare flag: [`probe_status`] hands gix an owned clone of
1097        // its cancel token the way `refresh`'s own dispatch does, and every other probe
1098        // below still takes it as `&AtomicBool` through the same deref coercion.
1099        let never_cancelled = Arc::new(AtomicBool::new(false));
1100        let (cached_repo, common_dir_hint, probes_state, probes_base, kind) = {
1101            let table = self.table.read().unwrap();
1102            let repo = table.repos.get(key).cloned();
1103            let common_dir = table
1104                .index
1105                .get(key)
1106                .map(|&idx| Arc::clone(&table.entities[idx].common_dir));
1107            // An unknown key has no entity yet to ask, and falls back to `false`,
1108            // matching the fallback insert below: a freshly inserted `Kind::Repo`
1109            // entity's `state` is `NotApplicable` from construction too, and its
1110            // `base` is not (only a Submodule's is).
1111            let probes_state = table
1112                .index
1113                .get(key)
1114                .map(|&idx| table.entities[idx].probes_state())
1115                .unwrap_or(false);
1116            let probes_base = table
1117                .index
1118                .get(key)
1119                .map(|&idx| table.entities[idx].probes_base())
1120                .unwrap_or(true);
1121            // Same fallback as `probes_state`/`probes_base`: an unknown key falls back to
1122            // the `Kind::Repo` the insert below actually gives it.
1123            let kind = table
1124                .index
1125                .get(key)
1126                .map(|&idx| table.entities[idx].kind)
1127                .unwrap_or(Kind::Repo);
1128            (repo, common_dir, probes_state, probes_base, kind)
1129        };
1130        let common_dir_hint = common_dir_hint.unwrap_or_else(|| Arc::from(key.path().join(".git")));
1131        let override_branch = find_entry(&self.overrides, key.path(), &common_dir_hint)
1132            .and_then(|entry| entry.default_branch.clone());
1133        let excluded = excluded_by(
1134            &self.exclusions.read().unwrap(),
1135            key.path(),
1136            &common_dir_hint,
1137        );
1138
1139        let branch_outcome =
1140            probe_branch(key.path(), cached_repo.as_deref(), kind, &never_cancelled);
1141        let sync_outcome = probe_sync(
1142            key.path(),
1143            cached_repo.as_deref(),
1144            branch_outcome.as_ref().map(|(settled, ..)| settled),
1145            kind,
1146            &never_cancelled,
1147        );
1148        let default_branch_outcome = probe_default_branch(
1149            key.path(),
1150            cached_repo.as_deref(),
1151            DefaultBranchHints {
1152                override_branch: override_branch.as_deref(),
1153                network_branch: network_branch_for(&self.network_default_branch, &common_dir_hint)
1154                    .as_deref(),
1155            },
1156            kind,
1157            &never_cancelled,
1158        );
1159        let base_outcome = if probes_base {
1160            probe_base(
1161                key.path(),
1162                cached_repo.as_deref(),
1163                branch_outcome.as_ref().map(|(settled, ..)| settled),
1164                default_branch_outcome.as_ref().map(|r| &r.settled),
1165                &never_cancelled,
1166            )
1167        } else {
1168            None
1169        };
1170        let state_outcome = if probes_state {
1171            // A single synchronous re-probe shares nothing with any Generation's
1172            // dispatch, so a throwaway cache is exactly as much sharing as this
1173            // one call needs. Its bound gate has exactly one entity to hear
1174            // from: itself, so it never actually waits.
1175            let patch_cache: PatchIdentityCache = Mutex::new(HashMap::new());
1176            let patch_reads = AtomicUsize::new(0);
1177            let patch_scan_bounds = Mutex::new(Vec::new());
1178            let gate = BoundGate::new(1);
1179            let mut report = GateReport::new(&gate);
1180            let memo = PatchEquivalenceMemo {
1181                cache: &patch_cache,
1182                reads: &patch_reads,
1183                scan_bounds: &patch_scan_bounds,
1184            };
1185            probe_worktree_state(
1186                key.path(),
1187                cached_repo.as_deref(),
1188                default_branch_outcome.as_ref().map(|r| &r.settled),
1189                &common_dir_hint,
1190                &never_cancelled,
1191                &memo,
1192                &mut report,
1193            )
1194        } else {
1195            None
1196        };
1197        let dirty_outcome =
1198            probe_status(key.path(), cached_repo.as_deref(), kind, &never_cancelled);
1199
1200        let mut table = self.table.write().unwrap();
1201        let generation = Generation::new(table.generation);
1202        let idx = match table.index.get(key).copied() {
1203            Some(idx) => idx,
1204            None => {
1205                let name = display_name(key.path());
1206                table.entities.push(EntityState::new(
1207                    key.clone(),
1208                    name,
1209                    common_dir_hint,
1210                    Kind::Repo,
1211                ));
1212                let idx = table.entities.len() - 1;
1213                table.index.insert(key.clone(), idx);
1214                idx
1215            }
1216        };
1217        table.entities[idx].excluded = excluded;
1218        if let Some((settled, in_progress, recent)) = branch_outcome {
1219            table.entities[idx].apply_branch_probe(generation, settled, in_progress, recent);
1220        }
1221        if let Some(settled) = sync_outcome {
1222            table.entities[idx].sync.settle(generation, settled);
1223        }
1224        if let Some(settled) = base_outcome {
1225            table.entities[idx].base.settle(generation, settled);
1226        }
1227        if let Some(resolution) = default_branch_outcome {
1228            table.entities[idx].apply_default_branch_resolution(generation, resolution);
1229        }
1230        if let Some(settled) = state_outcome {
1231            table.entities[idx].state.settle(generation, settled);
1232        }
1233        if let Some(settled) = dirty_outcome {
1234            table.entities[idx].dirty.settle(generation, settled);
1235        }
1236        table.entities[idx].clone()
1237    }
1238
1239    /// Clones the whole table now, without waiting for anything in flight. Ages
1240    /// every entity's `dirty` and `state` cells into Stale here, on the clone
1241    /// rather than the stored table, so a snapshot stays a pure read: the other
1242    /// staleness writer, poll evidence, does mutate the stored table, because a
1243    /// detected move is itself a fact worth keeping, but elapsed time is not.
1244    pub fn snapshot(&self) -> Snapshot {
1245        let table = self.table.read().unwrap();
1246        let mut entities = table.entities.clone();
1247        for entity in &mut entities {
1248            entity.age_status_cells(self.status_stale_after);
1249        }
1250        Snapshot {
1251            generation: Generation::new(table.generation),
1252            discovered_at: table.discovered_at,
1253            entities,
1254        }
1255    }
1256
1257    /// Blocks until nothing is in flight or `within` elapses, then returns a snapshot.
1258    /// The machine-readable consumer's whole loop.
1259    ///
1260    /// `Ok` is a table that actually settled. `Err` is the wait giving up, carrying the
1261    /// snapshot as it stood at that moment so a caller that means to degrade still has
1262    /// something to degrade with. The two are separate arms rather than one return value
1263    /// because they are separate facts: a half-populated table read as a settled one is a
1264    /// wrong answer, not a late one, and it reads as a defect several steps downstream with
1265    /// nothing left naming the wait.
1266    pub fn try_settle(&self, within: Duration) -> Result<Snapshot, Snapshot> {
1267        let (lock, cvar) = &*self.settle_gate;
1268        let guard = lock.lock().unwrap();
1269        let (guard, timeout) = cvar
1270            .wait_timeout_while(guard, within, |counts| !counts.is_settled())
1271            .unwrap();
1272        // Released before the snapshot below, which takes the table lock: holding both at
1273        // once is a lock order nothing else in this file takes.
1274        drop(guard);
1275        let snapshot = self.snapshot();
1276        if timeout.timed_out() {
1277            Err(snapshot)
1278        } else {
1279            Ok(snapshot)
1280        }
1281    }
1282
1283    /// Blocks until nothing is in flight, panicking once [`liveness::BACKSTOP`] expires.
1284    /// For a test.
1285    ///
1286    /// Takes no deadline, unlike [`Self::try_settle`], because every deadline this ever
1287    /// took was a number guessed against the machine its author had: the wait is on a
1288    /// liveness property ("the Generation I just dispatched lands"), which carries no
1289    /// wall-clock bound of its own, so the only honest bound is the shared backstop.
1290    /// A wait whose *number* is the claim ("nothing arrives within 200ms") is a different
1291    /// wait and belongs on [`Self::try_settle`], which reports an expiry rather than
1292    /// panicking on one.
1293    #[cfg(any(test, feature = "test-util"))]
1294    pub fn settle(&self) -> Snapshot {
1295        self.settle_within(liveness::BACKSTOP)
1296    }
1297
1298    /// [`Self::settle`] against an explicit deadline, so this crate's own tests can
1299    /// exercise the expiry path without waiting out a real backstop. The same seam
1300    /// `liveness::wait_within` gives its module.
1301    #[cfg(any(test, feature = "test-util"))]
1302    fn settle_within(&self, deadline: Duration) -> Snapshot {
1303        self.try_settle(deadline).unwrap_or_else(|_| {
1304            // Read out and released before the panic below: unwinding out of a held guard
1305            // poisons the gate, and every later `lock().unwrap()` on it, `Drop`'s included,
1306            // then panics on the way out and turns a named report into an abort.
1307            let (probes, dispatches) = {
1308                let counts = self.settle_gate.0.lock().unwrap();
1309                (counts.probes, counts.dispatches)
1310            };
1311            liveness::expired(
1312                deadline,
1313                "everything this Core has in flight to land",
1314                &format!("{probes} probe(s) and {dispatches} dispatch(es) still outstanding"),
1315            )
1316        })
1317    }
1318
1319    /// What deleting `key`'s working tree destroys, read fresh right now
1320    /// ([repo-management.md](https://github.com/paulchiu/repon/blob/main/docs/spec/repo-management.md)'s
1321    /// "The confirm gate"). Every one of the three is a git read rather than a fold over this
1322    /// entity's Cells or over the table: the gate is answering "what will accepting this
1323    /// destroy", a Cell carries whatever the last Generation left there, and the table is
1324    /// bounded by the active Set's roots, so a linked Worktree outside them would go
1325    /// unnamed. Both are the wrong tense, or the wrong scope, for a question with no undo.
1326    ///
1327    /// `uncommitted` is both halves of "not in a commit": the index against the working tree
1328    /// (`git::dirty_counts`) and `HEAD` against the index (`git::staged_changes`). The
1329    /// second is the one a `git add` with no commit lands in, and the one the dirty column
1330    /// deliberately never asks about.
1331    ///
1332    /// Errors rather than reporting zero when any read fails, so a gate never says "nothing
1333    /// to lose" because it could not look.
1334    pub fn delete_risk(&self, key: &EntityKey) -> Result<DeleteRisk, git::ProbeError> {
1335        let repo = git::open_thread_safe(key.path())?.to_thread_local();
1336        let dirty = git::dirty_counts(&repo, Arc::new(AtomicBool::new(false)))?;
1337        let staged = git::staged_changes(&repo)?;
1338        let (unpushed_commits, unpushed_branches) = git::unpushed(&repo)?;
1339        let linked_worktrees = git::linked_worktrees(&repo)?;
1340        Ok(DeleteRisk {
1341            uncommitted: dirty.total() > 0 || staged,
1342            unpushed_commits,
1343            unpushed_branches,
1344            linked_worktrees,
1345        })
1346    }
1347
1348    /// The administrative directory `git worktree remove` deletes for `key`'s own linked
1349    /// Worktree, read fresh right now. `Err` when `key`'s own path cannot even be opened as
1350    /// a git repository, which is what "the parent Repo is gone or unreadable" means for a
1351    /// `delete` on a Worktree row
1352    /// ([repo-management.md](https://github.com/paulchiu/repon/blob/main/docs/spec/repo-management.md)'s
1353    /// "What `delete` does to a Worktree"): the caller falls back to removing the working
1354    /// directory alone.
1355    pub fn worktree_admin_dir(&self, key: &EntityKey) -> Result<PathBuf, git::ProbeError> {
1356        let repo = git::open_thread_safe(key.path())?.to_thread_local();
1357        Ok(git::worktree_admin_dir(&repo))
1358    }
1359
1360    /// Every linked Worktree's own working directory pointing into `key`'s Repo, read
1361    /// fresh right now: what deleting a Repo needs to also remove, since each linked
1362    /// Worktree's directory sits outside the Repo's own and is untouched by removing that
1363    /// alone
1364    /// ([repo-management.md](https://github.com/paulchiu/repon/blob/main/docs/spec/repo-management.md)'s
1365    /// "Deleting a Repo also takes its linked Worktrees with it").
1366    pub fn linked_worktree_paths(&self, key: &EntityKey) -> Result<Vec<PathBuf>, git::ProbeError> {
1367        let repo = git::open_thread_safe(key.path())?.to_thread_local();
1368        git::linked_worktree_paths(&repo)
1369    }
1370
1371    /// `delete`'s phase 1: the ignored directories inside the working tree at `path`, read
1372    /// fresh right now
1373    /// ([repo-management.md](https://github.com/paulchiu/repon/blob/main/docs/spec/repo-management.md)'s
1374    /// "Deleting a working tree"). `path` rather than an [`EntityKey`] because a Repo
1375    /// `delete` runs this once for its own working tree and once more for each linked
1376    /// Worktree [`Self::linked_worktree_paths`] names, and only the first of those has a Set
1377    /// row of its own.
1378    pub fn ignored_directories_for_deletion(
1379        &self,
1380        path: &Path,
1381    ) -> Result<Vec<PathBuf>, git::ProbeError> {
1382        let repo = git::open_thread_safe(path)?.to_thread_local();
1383        git::ignored_directories_for_deletion(&repo)
1384    }
1385
1386    /// Attempts the fast-forward-only auto-update on `key`'s own Repo, on demand: exactly
1387    /// `crate::auto_update::attempt`'s own five rules and its own fast-forward, reused
1388    /// rather than a second implementation for the built-in `sync` action to call by hand
1389    /// ([repo-management.md](https://github.com/paulchiu/repon/blob/main/docs/spec/repo-management.md)).
1390    /// Read fresh right now, the same tense [`Self::delete_risk`] reads in: eligibility can
1391    /// change between the gate and the run, so this is never answered from a Cell.
1392    pub fn attempt_auto_update(&self, key: &EntityKey) -> AutoUpdateAttempt {
1393        match crate::auto_update::attempt(key.path()) {
1394            crate::auto_update::Outcome::Ineligible(crate::auto_update::Ineligible::NotClean) => {
1395                AutoUpdateAttempt::NotClean
1396            }
1397            crate::auto_update::Outcome::Ineligible(crate::auto_update::Ineligible::NoUpstream) => {
1398                AutoUpdateAttempt::NoUpstream
1399            }
1400            crate::auto_update::Outcome::Ineligible(crate::auto_update::Ineligible::NotBehind) => {
1401                AutoUpdateAttempt::NotBehind
1402            }
1403            crate::auto_update::Outcome::Ineligible(
1404                crate::auto_update::Ineligible::NotFastForward,
1405            ) => AutoUpdateAttempt::NotFastForward,
1406            crate::auto_update::Outcome::Updated { .. } => AutoUpdateAttempt::Updated,
1407            crate::auto_update::Outcome::Failed(error) => AutoUpdateAttempt::Failed(error),
1408        }
1409    }
1410
1411    /// Runs `action`'s own steps against one Entity, on the calling thread, blocking until
1412    /// they finish rather than handing the run off the way [`Core::run_action`]'s async
1413    /// 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))
1414    /// needs the outcome before the built-in can proceed or report, which nothing running
1415    /// off this thread can give in time. Reuses `run_action_for_entity`, the identical
1416    /// per-step execution `run_action`'s fan-out gives every entity, so a hook and a
1417    /// configured `[[action]]` never diverge in what a step means; writes nothing to the
1418    /// table and touches none of `run_action`'s own state (the one admitted run and its
1419    /// controls), since a hook is a distinct concern from the one fan-out the palette
1420    /// tracks.
1421    ///
1422    /// `None` when `key` names no Entity this table currently knows.
1423    pub fn run_action_for_entity_blocking(
1424        &self,
1425        action: &ActionSpec,
1426        key: &EntityKey,
1427    ) -> Option<ActionReceipt> {
1428        let entity = {
1429            let table = self.table.read().unwrap();
1430            let idx = *table.index.get(key)?;
1431            table.entities[idx].clone()
1432        };
1433        let control = executor::RunControl::new();
1434        Some(run_action_for_entity(&entity, action, &control, &|_| {}))
1435    }
1436
1437    /// Vends a [`ManagementHandle`]: the `Send + 'static` seam a management run's own
1438    /// per-row work moves onto a background thread through, so it stops blocking the caller
1439    /// the way [`Self::run_action`]'s own fan-out already moves an `Arc<RwLock<Table>>`
1440    /// clone onto its own thread
1441    /// ([0033](https://github.com/paulchiu/repon/blob/main/docs/adr/0033-a-management-run-moves-off-the-calling-thread-and-cancels-between-rows.md)).
1442    pub fn management_handle(&self) -> ManagementHandle {
1443        ManagementHandle {
1444            table: Arc::clone(&self.table),
1445        }
1446    }
1447
1448    /// Drops one entity from the table, cancelling any probe in flight against it.
1449    pub fn dismiss(&self, key: &EntityKey) {
1450        let mut table = self.table.write().unwrap();
1451        if let Some(idx) = table.index.remove(key) {
1452            table.entities.remove(idx);
1453            for position in table.index.values_mut() {
1454                if *position > idx {
1455                    *position -= 1;
1456                }
1457            }
1458        }
1459        table.poll_fingerprints.remove(key);
1460        if let Some(in_flight) = table.in_flight.remove(key) {
1461            in_flight.cancel.store(true, Ordering::Release);
1462            drop(table);
1463            complete_one(&self.settle_gate);
1464        }
1465    }
1466
1467    /// Resolves `order` against the table this instant and splits it into the entities
1468    /// that will actually run and the ones a matching `[[repo]]` `exclude = true`
1469    /// override sweeps in and skips
1470    /// ([config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md#per-repo-entries)).
1471    /// [`Self::run_action`] and [`Self::operable_count`] both call this rather than
1472    /// each keeping its own copy of the `!entity.excluded` test, so a consumer's confirm
1473    /// gate or palette border can never show a count a real run then contradicts
1474    /// ([`docs/spec/actions.md`](https://github.com/paulchiu/repon/blob/main/docs/spec/actions.md)'s
1475    /// "The Selection and the gate": "a wrong count would lie twice"). A key `order`
1476    /// names that no longer resolves (already dismissed, or never discovered) is
1477    /// silently dropped from both halves.
1478    fn partition_operable(&self, order: &[EntityKey]) -> (Vec<EntityState>, Vec<EntityState>) {
1479        let table = self.table.read().unwrap();
1480        order
1481            .iter()
1482            .filter_map(|key| table.index.get(key).map(|&idx| table.entities[idx].clone()))
1483            .partition(|entity| !entity.excluded)
1484    }
1485
1486    /// How many of `order` are operable, i.e. not excluded: [`Self::run_action`]'s own
1487    /// first move is the identical partition this method itself calls, so this is the one
1488    /// number a confirm gate and a palette border can both read without either ever
1489    /// drifting from what that first move keeps. Not the final count a run acts on once
1490    /// `action.when` is `Some`: [`Self::applicability`] narrows this same set further, and
1491    /// [`Self::run_action`] itself only ever runs the rows that narrowing proves.
1492    pub fn operable_count(&self, order: &[EntityKey]) -> usize {
1493        self.partition_operable(order).0.len()
1494    }
1495
1496    /// How many Entities in the live table are Vanished. Reads the table in place rather
1497    /// than through [`Self::snapshot`], so a caller needing only the count does not pay for
1498    /// a clone of the whole table and its staleness pass on every frame.
1499    pub fn vanished_count(&self) -> usize {
1500        self.table
1501            .read()
1502            .unwrap()
1503            .entities
1504            .iter()
1505            .filter(|entity| entity.presence == Presence::Vanished)
1506            .count()
1507    }
1508
1509    /// How an Action's `when` predicate divides the very rows [`Self::operable_count`]
1510    /// counts: the identical partition runs first, so an excluded row is subtracted before
1511    /// the predicate ever sees it and `when` narrows what is left rather than replacing that
1512    /// subtraction
1513    /// ([`docs/spec/actions.md`](https://github.com/paulchiu/repon/blob/main/docs/spec/actions.md)'s
1514    /// "The Selection and the gate"). A palette calls this ahead of time, to report a count
1515    /// before a choice is even made; [`Self::run_action`] runs the identical classification
1516    /// against the identical rows once a choice is confirmed, over `ActionSpec::when` rather
1517    /// than an argument of its own, so a preview and a real run can never disagree.
1518    ///
1519    /// The tally lives here rather than in the consumer for that reason alone:
1520    /// `partition_operable` is this type's own, so a caller cannot count applicability over
1521    /// a set the run would not act on.
1522    pub fn applicability(&self, order: &[EntityKey], when: &Filter) -> Applicability {
1523        when.applicability(self.partition_operable(order).0.iter())
1524    }
1525
1526    /// `true` from an Action run's admission until its completion has dispatched the
1527    /// Generation it owes, the consumer-facing read of the one admitted run
1528    /// ([ADR 0018](https://github.com/paulchiu/repon/blob/main/docs/adr/0018-an-action-is-a-fanout-of-pty-backed-steps.md)'s
1529    /// "One Action runs at a time"): what a TUI gates `;`, `s`, `1` to `9` and `Ctrl+R`
1530    /// against while a run is in flight
1531    /// ([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)).
1532    pub fn action_running(&self) -> bool {
1533        self.action_lifecycle.lock().unwrap().live.is_some()
1534    }
1535
1536    /// `true` while any refresh-shaped dispatch this `Core` started still owes the table
1537    /// work: a Generation reserved and not yet raised the probes it dispatches, or probes
1538    /// raised and not yet landed, cancelled or timed out. The same gate [`Core::try_settle`]
1539    /// blocks on, read here without blocking, so a consumer can report a Refresh's own
1540    /// progress on screen while it runs rather than waiting for it to finish
1541    /// ([refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)).
1542    /// Covers `refresh`, `refresh_all`, `rederive_default_branches`, `probe_now` and the
1543    /// startup walk alike; an Action's own fan-out never touches this gate, which is what
1544    /// `action_running` reads instead.
1545    pub fn refresh_running(&self) -> bool {
1546        let (lock, _cvar) = &*self.settle_gate;
1547        !lock.lock().unwrap().is_settled()
1548    }
1549
1550    /// Runs `action` across every key in `order` that the table currently knows: each
1551    /// entity's own steps run in order and stop at that entity's first failure, exactly
1552    /// as [config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md)'s
1553    /// "Actions" fixes, with later steps recorded `NotRun` rather than silently skipped.
1554    /// Cross-entity concurrency is bounded by `action.concurrency`, on a
1555    /// `rayon::ThreadPool` this call builds and owns for the run alone, never rayon's
1556    /// global pool the probe fan-out shares: a step blocked in `wait()` removes a
1557    /// worker from whichever pool holds it, and the global pool has none to spare
1558    /// without starving a refresh in flight
1559    /// ([`docs/spec/actions.md`](https://github.com/paulchiu/repon/blob/main/docs/spec/actions.md)'s
1560    /// "The fan-out"). Returns immediately; every step's own child, and this run's
1561    /// completion, run off the calling thread.
1562    ///
1563    /// Returns `false` and touches nothing if a fan-out is already running: only one
1564    /// runs at a time
1565    /// ([ADR 0018](https://github.com/paulchiu/repon/blob/main/docs/adr/0018-an-action-is-a-fanout-of-pty-backed-steps.md)'s
1566    /// "One Action runs at a time"), but the spec settles only that the *palette* goes
1567    /// inert while one is live, never what a second, concurrent call to this seam itself
1568    /// should do. Rejecting outright, rather than queuing, is this call's own choice: a
1569    /// queue needs its own ordering and cancellation story that no acceptance criterion
1570    /// here asks for.
1571    ///
1572    /// An entity in `order` carrying a matching `[[repo]]` `exclude = true`
1573    /// ([config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md#per-repo-entries))
1574    /// never runs a step: it receives a [`Skip::Excluded`] receipt with an empty step list
1575    /// immediately, the one legitimate producer of `Not applicable`
1576    /// ([`docs/spec/actions.md`](https://github.com/paulchiu/repon/blob/main/docs/spec/actions.md)'s
1577    /// "The Selection and the gate"). An unknown key in `order` (already dismissed, or
1578    /// never discovered) is silently skipped, the same fallback `refresh` gives one.
1579    ///
1580    /// `action.when`, once every excluded row is already subtracted, decides what runs
1581    /// rather than only what a palette reported about it: a row it proves is handed a
1582    /// step, a row it disproves gets a [`Skip::Inapplicable`] receipt instead, and a row it
1583    /// cannot settle (a Cell it reads has not settled) gets [`Skip::Unresolved`], since an
1584    /// unprovable row is not a provable one and a run has no basis to touch it either
1585    /// (`docs/spec/actions.md`'s "The Selection and the gate", reversing what that
1586    /// paragraph originally decided). `None` runs every operable row, exactly as before
1587    /// `when` reached this call.
1588    ///
1589    /// Starting a run cancels any in-flight Generation outright rather than sharing
1590    /// execution with it, and completion starts exactly one normal Generation over
1591    /// every entity the table currently knows, not only the ones this run touched.
1592    /// Explicitly not done, for the same reason: re-probing each affected entity
1593    /// synchronously first, the way a Launcher return does with [`Core::probe_now`].
1594    /// Both choices, and their measured cost, are
1595    /// [ADR 0018](https://github.com/paulchiu/repon/blob/main/docs/adr/0018-an-action-is-a-fanout-of-pty-backed-steps.md)'s
1596    /// ("Refreshing around a run").
1597    pub fn run_action(&self, action: ActionSpec, order: &[EntityKey]) -> bool {
1598        // Built before admission rather than after it, so the run a caller is told started
1599        // is admitted with its own reach into its children already attached: a
1600        // `stop_action` the instant this returns `true` can never find no control, and a
1601        // completion racing in can never find someone else's.
1602        let control = executor::RunControl::new();
1603        if !self
1604            .action_lifecycle
1605            .lock()
1606            .unwrap()
1607            .admit(Arc::clone(&control))
1608        {
1609            return false;
1610        }
1611
1612        // Criterion 3's first half: starting a run cancels any in-flight Generation
1613        // outright, never sharing the machine with it.
1614        cancel_in_flight(&self.table, &self.settle_gate);
1615
1616        let (operable, excluded) = self.partition_operable(order);
1617
1618        let write_skip_receipts = |entities: &[EntityState], skip: Skip| {
1619            if entities.is_empty() {
1620                return;
1621            }
1622            let finished_at = Timestamp::now();
1623            let mut table = self.table.write().unwrap();
1624            for entity in entities {
1625                if let Some(&idx) = table.index.get(&entity.key) {
1626                    table.entities[idx].last_action = Some(ActionReceipt {
1627                        label: Arc::clone(&action.label),
1628                        steps: Arc::from(Vec::new()),
1629                        skip: Some(skip),
1630                        finished_at,
1631                        running: None,
1632                    });
1633                }
1634            }
1635        };
1636
1637        write_skip_receipts(&excluded, Skip::Excluded);
1638
1639        let included = match &action.when {
1640            Some(when) => {
1641                let Partition {
1642                    applicable,
1643                    inapplicable,
1644                    unresolved,
1645                } = when.partition(operable);
1646                write_skip_receipts(&inapplicable, Skip::Inapplicable);
1647                write_skip_receipts(&unresolved, Skip::Unresolved);
1648                applicable
1649            }
1650            None => operable,
1651        };
1652
1653        let table_handle = Arc::clone(&self.table);
1654        let refresh_handles = self.refresh_handles();
1655        let action_lifecycle = Arc::clone(&self.action_lifecycle);
1656        #[cfg(test)]
1657        let completion_boundary = Arc::clone(&self.action_completion_boundary);
1658        // At least one worker regardless of what `action.concurrency` says: 0 has no
1659        // sensible reading as "run nothing" here (the schema has no floor, only an
1660        // explicit absence of a *ceiling*, `docs/spec/actions.md`'s "The fan-out"), and
1661        // `rayon::ThreadPoolBuilder::num_threads(0)` means "let rayon choose" rather
1662        // than zero workers, which would silently hand this run back to a pool sized by
1663        // something other than `concurrency`.
1664        let concurrency = action.concurrency.max(1) as usize;
1665
1666        // A plain OS thread, never a job on either rayon pool: `RefreshHandles::dispatch`
1667        // below calls `rayon::spawn`, which targets whichever pool the *calling* thread
1668        // already belongs to, so running this orchestration from inside the dedicated
1669        // pool built below would misroute the completion Generation's own probes onto
1670        // it instead of the global pool every other probe uses.
1671        thread::spawn(move || {
1672            let pool = rayon::ThreadPoolBuilder::new()
1673                .num_threads(concurrency)
1674                .build()
1675                .expect("build the Action fan-out's own dedicated pool");
1676
1677            // Caught rather than left to unwind straight out of this thread: a poisoned
1678            // `RwLock` from an unrelated earlier panic is enough to panic the
1679            // `table_handle.write().unwrap()` below, and without `catch_unwind` that
1680            // would unwind past the `RunCompletion` just beyond it before that guard
1681            // exists, leaving this `Core` reading its run as live for the rest of its life.
1682            let fan_out = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1683                pool.install(|| {
1684                    included.into_par_iter().for_each(|entity| {
1685                        let write_receipt = |receipt: ActionReceipt| {
1686                            let mut table = table_handle.write().unwrap();
1687                            if let Some(&idx) = table.index.get(&entity.key) {
1688                                table.entities[idx].last_action = Some(receipt);
1689                            }
1690                        };
1691                        let receipt =
1692                            run_action_for_entity(&entity, &action, &control, &write_receipt);
1693                        write_receipt(receipt);
1694                    });
1695                });
1696            }));
1697
1698            // scan: action-completion-path begin -- criterion 4: nothing from here to the
1699            // matching end marker below may re-probe an affected entity synchronously the
1700            // way a Launcher return does with `probe_now`; scoped this narrowly (rather
1701            // than a whole-crate scan) because a legitimate Launcher-return caller lives
1702            // in an unrelated call site the same absence claim must not forbid.
1703            // Criterion 6: the fan-out's own steps are over here, panic or not, and this
1704            // run stays admitted only until `completion` drops one statement past the
1705            // Generation below. `hold_action`, `continue_action` and `stop_action` are
1706            // no-ops again from that point, and a second `run_action` before it is refused
1707            // rather than left to race the Generation this run still owes.
1708            let completion = RunCompletion {
1709                lifecycle: action_lifecycle,
1710                #[cfg(test)]
1711                boundary: completion_boundary,
1712            };
1713
1714            // A panicked fan-out never finished cleanly, so it earns no completion
1715            // Generation. Swallowed rather than resumed: the default panic hook already
1716            // printed it to stderr before `catch_unwind` returned, and this crate carries
1717            // no logger to hand it to instead.
1718            let Ok(()) = fan_out else {
1719                return;
1720            };
1721
1722            // Criterion 3's second half: completion starts one normal Generation over
1723            // every entity currently known, not only the ones this run acted on.
1724            let all_keys: Vec<EntityKey> = table_handle
1725                .read()
1726                .unwrap()
1727                .entities
1728                .iter()
1729                .map(|entity| entity.key.clone())
1730                .collect();
1731            refresh_handles.dispatch(&all_keys);
1732            drop(completion);
1733            // scan: action-completion-path end
1734        });
1735
1736        true
1737    }
1738
1739    /// SIGSTOPs every currently live step's process group in the fan-out `run_action`
1740    /// started, reversible with [`Self::continue_action`]: suspending a run is reversible,
1741    /// where cancelling one is not
1742    /// ([`docs/spec/actions.md`](https://github.com/paulchiu/repon/blob/main/docs/spec/actions.md)'s
1743    /// "Cancellation and quit"). A no-op while no fan-out is running. Its own verb,
1744    /// kept apart from [`Self::pause`], which stays ignorant of why background work stopped.
1745    pub fn hold_action(&self) {
1746        if let Some(control) = self.live_action_control() {
1747            control.hold();
1748        }
1749    }
1750
1751    /// SIGCONTs every currently live step's process group, undoing [`Self::hold_action`]. A
1752    /// no-op while no fan-out is running.
1753    pub fn continue_action(&self) {
1754        if let Some(control) = self.live_action_control() {
1755            control.continue_run();
1756        }
1757    }
1758
1759    /// Cancels the fan-out `run_action` started: SIGTERM now to every step's process group
1760    /// still live, SIGKILL after a grace to whichever of those have not exited by then,
1761    /// because SIGTERM is trappable and SIGKILL is not. A step already running when this is
1762    /// called becomes `Cancelled`; so does a step, or a whole entity's run, that had not
1763    /// started, which stays distinct from `NotRun`
1764    /// ([`docs/spec/actions.md`](https://github.com/paulchiu/repon/blob/main/docs/spec/actions.md)'s
1765    /// "Cancellation and quit"). A no-op while no fan-out is running. Its own verb,
1766    /// kept apart from [`Self::pause`] for the same reason [`Self::hold_action`] is.
1767    pub fn stop_action(&self) {
1768        if let Some(control) = self.live_action_control() {
1769            control.cancel();
1770        }
1771    }
1772
1773    /// The live run's reach into its steps' children, cloned out so the three verbs above
1774    /// signal a process group with [`Core::action_lifecycle`]'s lock already released.
1775    /// `None` with no fan-out live, which is what makes each of them a no-op then.
1776    fn live_action_control(&self) -> Option<Arc<executor::RunControl>> {
1777        self.action_lifecycle.lock().unwrap().live.clone()
1778    }
1779
1780    /// Stops all background work: the dedicated thread stops ticking and every
1781    /// probe currently in flight is cancelled. The core is never told why.
1782    pub fn pause(&self) {
1783        let _ = self.control.send(ClockControl::Pause);
1784    }
1785
1786    /// Restarts the dedicated thread's ticking. No Generation is queued to fire on resume;
1787    /// one is the consumer's decision, not this call's. The single cycle enabling the
1788    /// periodic fetch owes does fire here, if a pause landed before it.
1789    pub fn resume(&self) {
1790        let _ = self.control.send(ClockControl::Resume);
1791    }
1792
1793    /// The persistent warning a re-run discovery walk leaves behind once it abandons, or
1794    /// `None` while none has. Never cleared once set, the same as `discovery_manual`: the
1795    /// Set stays out of the automatic refresh path for the life of this `Core`. The UI's
1796    /// shared warning slot polls this every frame, since it can turn from `None` to `Some`
1797    /// at any point in the run with no reload involved.
1798    pub fn discovery_warning(&self) -> Option<String> {
1799        self.discovery_warning.lock().unwrap().clone()
1800    }
1801
1802    /// The most recently completed periodic-fetch cycle's own failures, or an
1803    /// empty [`FetchFailures`] once every fetch in that cycle succeeded, or the
1804    /// cycle has never run. The UI's shared warning slot polls this every frame,
1805    /// the same way it polls [`Self::discovery_warning`] and
1806    /// [`Self::vanished_count`], since a later cycle can replace this at any point
1807    /// in the run with no reload involved.
1808    pub fn fetch_failures(&self) -> FetchFailures {
1809        self.fetch_failures.lock().unwrap().clone()
1810    }
1811
1812    /// Sets the live show-submodules preference a Generation's dispatch reads from this
1813    /// point on: whether a Kind::Submodule entity is probed at all
1814    /// ([discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md)'s
1815    /// "Showing Submodules"). Takes effect on the next `refresh`, dispatches nothing of its
1816    /// own and starts no Generation, which is what makes toggling this instant rather than a
1817    /// rebuild: `CoreSpec`'s own `show_submodules` is only this flag's starting value.
1818    pub fn set_show_submodules(&self, show_submodules: bool) {
1819        self.show_submodules
1820            .store(show_submodules, Ordering::Release);
1821    }
1822
1823    /// Writes one receipt per row for work Repon did itself, with no child process anywhere
1824    /// in it: what a Management operation leaves behind
1825    /// ([repo-management.md](https://github.com/paulchiu/repon/blob/main/docs/spec/repo-management.md)'s
1826    /// "Receipts", [`docs/spec/actions.md`](https://github.com/paulchiu/repon/blob/main/docs/spec/actions.md)'s
1827    /// `OwnWork`).
1828    ///
1829    /// The receipt is built here rather than handed in whole, so a consumer supplies only
1830    /// what Repon did and the words for it: `skip` stays `None`, since a refusal is a row
1831    /// that was operated on rather than one of the three ways a row is skipped, `running`
1832    /// stays `None`, since the work is already done, and the step count stays one, since the
1833    /// operation is one act rather than an ordered list.
1834    /// `label` is the operation's own name and doubles as the single step's label; the step's
1835    /// captured output is empty, there being no other program's screen to quote.
1836    ///
1837    /// Starts no Generation and dispatches nothing, for the same reason
1838    /// [`Core::set_exclusions`] does not: a receipt is something Repon did rather than a
1839    /// reading of the world, so nothing here can make a cell any more or less true. A key the
1840    /// table no longer holds is skipped, the same fallback every key-addressed entry point
1841    /// here gives one.
1842    pub fn record_own_work(&self, label: &str, results: &[(EntityKey, OwnWork, Duration)]) {
1843        let label: Arc<str> = Arc::from(label);
1844        let finished_at = Timestamp::now();
1845        let mut table = self.table.write().unwrap();
1846        for (key, work, elapsed) in results {
1847            let Some(&idx) = table.index.get(key) else {
1848                continue;
1849            };
1850            table.entities[idx].last_action = Some(ActionReceipt {
1851                label: Arc::clone(&label),
1852                steps: Arc::from(vec![StepResult {
1853                    label: Arc::clone(&label),
1854                    outcome: StepOutcome::OwnWork(work.clone()),
1855                    output: Arc::from(&b""[..]),
1856                    elapsed: *elapsed,
1857                    elision: None,
1858                    shell: false,
1859                    interactive: false,
1860                }]),
1861                skip: None,
1862                finished_at,
1863                running: None,
1864            });
1865        }
1866    }
1867
1868    /// Replaces the live `exclude` half of `[[repo]]` and re-applies it over every row the
1869    /// table already holds, so the next [`Core::snapshot`] answers with the new reading
1870    /// ([repo-management.md](https://github.com/paulchiu/repon/blob/main/docs/spec/repo-management.md)'s
1871    /// "Writing config": an `ignore` takes effect as soon as this call returns).
1872    ///
1873    /// Starts no Generation, dispatches nothing and rediscovers nothing, for the same reason
1874    /// [`Core::set_show_submodules`] does not: `exclude` decides only whether an operation
1875    /// may reach a row, never what discovery finds or what a probe reads. `default_branch`,
1876    /// the other key a `[[repo]]` entry may carry, is a probe input and is deliberately not
1877    /// moved here; it still needs a rebuilt `Core`.
1878    pub fn set_exclusions(&self, overrides: &[RepoOverride]) {
1879        let (_, resolved) = resolve_entries(overrides);
1880        // Written and released before the table lock is taken, never held across it:
1881        // `rerun_discovery` reads these two in the opposite order.
1882        {
1883            let mut exclusions = self.exclusions.write().unwrap();
1884            *exclusions = resolved.clone();
1885        }
1886        let mut table = self.table.write().unwrap();
1887        for entity in &mut table.entities {
1888            entity.excluded = excluded_by(&resolved, entity.key.path(), &entity.common_dir);
1889        }
1890    }
1891}
1892
1893/// The read-only, path-driven operations a management run's per-row work needs
1894/// (`crates/repon/src/management.rs`'s own `run_one_record`), cloned out of
1895/// [`Core::management_handle`] rather than borrowed from a live `Core`: `Send + 'static`, so
1896/// a caller can move it onto a background thread the way [`Core::run_action`]'s own fan-out
1897/// thread already moves its `Arc<RwLock<Table>>` clone there. Grants none of `Core`'s other
1898/// state (the one admitted Action run and its controls, the clock thread): a management run
1899/// is a distinct concern from the one fan-out those track, and this handle's own methods touch
1900/// only the table, exactly as [`Core::run_action_for_entity_blocking`] already does.
1901#[derive(Clone)]
1902pub struct ManagementHandle {
1903    table: Arc<RwLock<Table>>,
1904}
1905
1906impl ManagementHandle {
1907    /// Identical to [`Core::worktree_admin_dir`], against this handle's own table clone.
1908    pub fn worktree_admin_dir(&self, key: &EntityKey) -> Result<PathBuf, git::ProbeError> {
1909        let repo = git::open_thread_safe(key.path())?.to_thread_local();
1910        Ok(git::worktree_admin_dir(&repo))
1911    }
1912
1913    /// Identical to [`Core::linked_worktree_paths`], against this handle's own table clone.
1914    pub fn linked_worktree_paths(&self, key: &EntityKey) -> Result<Vec<PathBuf>, git::ProbeError> {
1915        let repo = git::open_thread_safe(key.path())?.to_thread_local();
1916        git::linked_worktree_paths(&repo)
1917    }
1918
1919    /// Identical to [`Core::ignored_directories_for_deletion`], against this handle's own
1920    /// table clone.
1921    pub fn ignored_directories_for_deletion(
1922        &self,
1923        path: &Path,
1924    ) -> Result<Vec<PathBuf>, git::ProbeError> {
1925        let repo = git::open_thread_safe(path)?.to_thread_local();
1926        git::ignored_directories_for_deletion(&repo)
1927    }
1928
1929    /// Identical to [`Core::attempt_auto_update`].
1930    pub fn attempt_auto_update(&self, key: &EntityKey) -> AutoUpdateAttempt {
1931        match crate::auto_update::attempt(key.path()) {
1932            crate::auto_update::Outcome::Ineligible(crate::auto_update::Ineligible::NotClean) => {
1933                AutoUpdateAttempt::NotClean
1934            }
1935            crate::auto_update::Outcome::Ineligible(crate::auto_update::Ineligible::NoUpstream) => {
1936                AutoUpdateAttempt::NoUpstream
1937            }
1938            crate::auto_update::Outcome::Ineligible(crate::auto_update::Ineligible::NotBehind) => {
1939                AutoUpdateAttempt::NotBehind
1940            }
1941            crate::auto_update::Outcome::Ineligible(
1942                crate::auto_update::Ineligible::NotFastForward,
1943            ) => AutoUpdateAttempt::NotFastForward,
1944            crate::auto_update::Outcome::Updated { .. } => AutoUpdateAttempt::Updated,
1945            crate::auto_update::Outcome::Failed(error) => AutoUpdateAttempt::Failed(error),
1946        }
1947    }
1948
1949    /// Identical to [`Core::run_action_for_entity_blocking`], against this handle's own
1950    /// table clone rather than a live `Core`.
1951    pub fn run_action_for_entity_blocking(
1952        &self,
1953        action: &ActionSpec,
1954        key: &EntityKey,
1955    ) -> Option<ActionReceipt> {
1956        let entity = {
1957            let table = self.table.read().unwrap();
1958            let idx = *table.index.get(key)?;
1959            table.entities[idx].clone()
1960        };
1961        let control = executor::RunControl::new();
1962        Some(run_action_for_entity(&entity, action, &control, &|_| {}))
1963    }
1964}
1965
1966/// Every `Arc` and plain-data field a Generation's dispatch reads, owned rather than
1967/// borrowed: [`Core::refresh_handles`] is the only constructor once a `Core` exists,
1968/// and its own doc comment carries the reason this exists at all. `start_internal`
1969/// builds one directly, since the periodic fetch's own completion Generation needs
1970/// this before there is a `Core` to ask; `Clone` is what lets that one value serve
1971/// both the recurring cadence and the immediate first cycle without a second,
1972/// drifting construction. Field names and types mirror `Core`'s own exactly, so
1973/// [`Self::dispatch`] and [`Self::rerun_discovery`] are `refresh` and
1974/// `rerun_discovery`'s bodies moved verbatim, `self.field` unchanged.
1975#[derive(Clone)]
1976struct RefreshHandles {
1977    table: Arc<RwLock<Table>>,
1978    overrides: Arc<Vec<ResolvedOverride>>,
1979    /// [`Core::exclusions`]'s own clone, so a re-run discovery's newly found rows take
1980    /// whatever `exclude` says right now rather than whatever it said at `start`.
1981    exclusions: Arc<RwLock<Vec<ResolvedExclusion>>>,
1982    set: SetSpec,
1983    discovery_manual: Arc<AtomicBool>,
1984    discovery_warn_after: Duration,
1985    discovery_abandon_after: Arc<AtomicU64>,
1986    discovery_warning: Arc<Mutex<Option<String>>>,
1987    show_submodules: Arc<AtomicBool>,
1988    settle_gate: Arc<SettleGate>,
1989    default_branch_chain_reads: Arc<AtomicUsize>,
1990    patch_identity_reads: Arc<AtomicUsize>,
1991    patch_scan_bounds: Arc<Mutex<Vec<Option<gix::ObjectId>>>>,
1992    dispatch_log: Arc<Mutex<Vec<EntityKey>>>,
1993    phase_c_gates: Arc<Mutex<HashMap<EntityKey, PhaseCGateHandle>>>,
1994    /// [`Core::network_default_branch`]'s own clone: [`run_fetch_cycle`] writes
1995    /// into it once a fetch's own handshake advertises a HEAD, and this
1996    /// dispatch's own default-branch probes read it back the same Generation.
1997    network_default_branch: Arc<Mutex<HashMap<PathBuf, Arc<str>>>>,
1998    /// [`Core::turnstile`]'s own clone, so every dispatch this `Core` starts,
1999    /// wherever it is called from, queues in the one order.
2000    turnstile: Arc<DispatchTurnstile>,
2001    /// [`Core::discovery_gate`]'s own clone; `None` on every production path.
2002    discovery_gate: Option<DiscoveryGate>,
2003}
2004
2005/// Runs the spawned dispatch bodies in the order their Generations were reserved.
2006///
2007/// Reserving the number is what a caller waits for; everything after it happens on
2008/// a thread of its own, and two of those threads reaching the table out of order
2009/// would let an older Generation cancel a newer one's in-flight entries and then
2010/// record itself as the live one, which is refresh.md's supersession rule read
2011/// backwards. A ticket taken under the same lock that mints the Generation, and
2012/// served in ticket order, is what stops that.
2013#[derive(Default)]
2014struct DispatchTurnstile {
2015    /// The ticket whose body may run, and the [`Condvar`] every waiting body sleeps on.
2016    serving: Mutex<u64>,
2017    ready: Condvar,
2018    /// The next ticket to hand out. Only ever read under the table write lock
2019    /// [`RefreshHandles::reserve_generation`] holds, so tickets and Generations
2020    /// are issued in the one order.
2021    next: AtomicU64,
2022}
2023
2024impl DispatchTurnstile {
2025    fn reserve(&self) -> u64 {
2026        self.next.fetch_add(1, Ordering::AcqRel)
2027    }
2028
2029    /// Blocks until `ticket` is the one being served. The returned guard releases
2030    /// the next ticket when it drops, panic included, so one body that unwinds
2031    /// cannot wedge every dispatch after it.
2032    fn take(&self, ticket: u64) -> DispatchTurn<'_> {
2033        let serving = self.serving.lock().unwrap();
2034        drop(
2035            self.ready
2036                .wait_while(serving, |serving| *serving != ticket)
2037                .unwrap(),
2038        );
2039        DispatchTurn {
2040            turnstile: self,
2041            ticket,
2042        }
2043    }
2044}
2045
2046/// One body's turn at the [`DispatchTurnstile`], held for as long as that body runs.
2047struct DispatchTurn<'a> {
2048    turnstile: &'a DispatchTurnstile,
2049    ticket: u64,
2050}
2051
2052impl Drop for DispatchTurn<'_> {
2053    fn drop(&mut self) {
2054        let mut serving = self.turnstile.serving.lock().unwrap();
2055        *serving = self.ticket + 1;
2056        self.turnstile.ready.notify_all();
2057    }
2058}
2059
2060impl RefreshHandles {
2061    /// `Core::refresh`'s whole body, moved here so `run_action`'s completion can call
2062    /// the identical dispatch from a thread that owns no reference to `Core` itself.
2063    ///
2064    /// Reserves this Generation's number and its turnstile place on the calling
2065    /// thread and does everything else, discovery's own walk included, on a thread
2066    /// of its own, the shape [`Core::rederive_default_branches`] already takes: no
2067    /// caller waits out a walk, and every one of them is fire and forget past the
2068    /// number this returns.
2069    fn dispatch(&self, order: &[EntityKey]) -> Generation {
2070        let (generation, ticket) = self.reserve_generation();
2071        begin_dispatch(&self.settle_gate);
2072        let handles = self.clone();
2073        let order = order.to_vec();
2074        thread::spawn(move || {
2075            let _turn = handles.turnstile.take(ticket);
2076            handles.run_generation(&order, generation);
2077            finish_dispatch(&handles.settle_gate);
2078        });
2079        generation
2080    }
2081
2082    /// [`Core::refresh_all`]'s whole body: the same reservation and the same spawned
2083    /// shape as [`Self::dispatch`], with the order read off the table this
2084    /// Generation's own discovery just reconciled rather than taken from a caller.
2085    fn dispatch_over_everything(&self) -> Generation {
2086        let (generation, ticket) = self.reserve_generation();
2087        begin_dispatch(&self.settle_gate);
2088        let handles = self.clone();
2089        thread::spawn(move || {
2090            let _turn = handles.turnstile.take(ticket);
2091            handles.rediscover();
2092            let order: Vec<EntityKey> = handles
2093                .table
2094                .read()
2095                .unwrap()
2096                .entities
2097                .iter()
2098                .map(|entity| entity.key.clone())
2099                .collect();
2100            handles.dispatch_probes(&order, generation);
2101            finish_dispatch(&handles.settle_gate);
2102        });
2103        generation
2104    }
2105
2106    /// Takes this Generation's number and its turnstile ticket under one hold of
2107    /// the table lock, so the two orders can never disagree.
2108    fn reserve_generation(&self) -> (Generation, u64) {
2109        let mut table = self.table.write().unwrap();
2110        table.generation += 1;
2111        (Generation::new(table.generation), self.turnstile.reserve())
2112    }
2113
2114    /// [`Self::dispatch`]'s spawned body: both halves of discovery, then the probe
2115    /// fan-out for `order`.
2116    fn run_generation(&self, order: &[EntityKey], generation: Generation) {
2117        self.rediscover();
2118        self.dispatch_probes(order, generation);
2119    }
2120
2121    /// Both halves of discovery at the head of one Generation, per refresh.md and
2122    /// discovery.md: an entity no longer found becomes Vanished, and one found again
2123    /// (new, or previously Vanished) is Present. Skipped once an earlier walk has
2124    /// abandoned, which takes the Set out of this automatic path until a fresh `Core`
2125    /// starts over different roots.
2126    fn rediscover(&self) {
2127        if !self.discovery_manual.load(Ordering::Acquire) {
2128            self.rerun_discovery();
2129        }
2130    }
2131
2132    /// The probe fan-out alone, against the table as it stands: one rayon task per
2133    /// dispatched entity, exactly as before this Generation's discovery moved off
2134    /// the calling thread. Split out from [`Self::run_generation`] so
2135    /// [`Self::dispatch_over_everything`], which has to resolve its order between the walk
2136    /// and the fan-out, shares this body rather than keeping a second copy of it.
2137    fn dispatch_probes(&self, order: &[EntityKey], generation: Generation) {
2138        // Scoped to this one Generation, per default-branch.md's "memoised per
2139        // common dir within a single refresh generation": a fresh cache every
2140        // call, never carried over, never touched by the previous Generation's
2141        // still-finishing tasks holding their own clone of the old one.
2142        self.default_branch_chain_reads.store(0, Ordering::Release);
2143        self.patch_identity_reads.store(0, Ordering::Release);
2144        self.patch_scan_bounds.lock().unwrap().clear();
2145        self.dispatch_log.lock().unwrap().clear();
2146
2147        let generation_number = generation.value();
2148        let mut table = self.table.write().unwrap();
2149        table
2150            .generation_started_at
2151            .insert(generation_number, Instant::now());
2152
2153        let show_submodules = self.show_submodules.load(Ordering::Acquire);
2154        let mut dispatched = Vec::new();
2155        for key in order {
2156            let Some(&idx) = table.index.get(key) else {
2157                continue;
2158            };
2159            if !dispatches_kind(table.entities[idx].kind, show_submodules) {
2160                // Narrows the work, not merely the view: a hidden Submodule's Cells are
2161                // left exactly as this Generation found them, so a normal Generation pays
2162                // nothing for it (`docs/spec/discovery.md`'s "Showing Submodules").
2163                continue;
2164            }
2165            if let Some(previous) = table.in_flight.remove(key) {
2166                previous.cancel.store(true, Ordering::Release);
2167            }
2168            let cancel = Arc::new(AtomicBool::new(false));
2169            table.in_flight.insert(
2170                key.clone(),
2171                InFlight {
2172                    generation: generation_number,
2173                    cancel: Arc::clone(&cancel),
2174                },
2175            );
2176            begin_probes(&mut table.entities[idx]);
2177            dispatched.push((key.clone(), cancel));
2178        }
2179
2180        if dispatched.is_empty() {
2181            return;
2182        }
2183
2184        begin_probes_owed(&self.settle_gate, dispatched.len());
2185        let repos: Vec<Option<Arc<gix::ThreadSafeRepository>>> = dispatched
2186            .iter()
2187            .map(|(key, _)| table.repos.get(key).cloned())
2188            .collect();
2189        let override_branches: Vec<Option<String>> = dispatched
2190            .iter()
2191            .map(|(key, _)| {
2192                let idx = table.index[key];
2193                let common_dir = &table.entities[idx].common_dir;
2194                find_entry(&self.overrides, key.path(), common_dir)
2195                    .and_then(|entry| entry.default_branch.clone())
2196            })
2197            .collect();
2198        let network_branches: Vec<Option<Arc<str>>> = dispatched
2199            .iter()
2200            .map(|(key, _)| {
2201                let idx = table.index[key];
2202                let common_dir = &table.entities[idx].common_dir;
2203                network_branch_for(&self.network_default_branch, common_dir)
2204            })
2205            .collect();
2206        let common_dirs: Vec<Arc<Path>> = dispatched
2207            .iter()
2208            .map(|(key, _)| Arc::clone(&table.entities[table.index[key]].common_dir))
2209            .collect();
2210        let probes_state: Vec<bool> = dispatched
2211            .iter()
2212            .map(|(key, _)| table.entities[table.index[key]].probes_state())
2213            .collect();
2214        let probes_base: Vec<bool> = dispatched
2215            .iter()
2216            .map(|(key, _)| table.entities[table.index[key]].probes_base())
2217            .collect();
2218        let kinds: Vec<Kind> = dispatched
2219            .iter()
2220            .map(|(key, _)| table.entities[table.index[key]].kind)
2221            .collect();
2222        drop(table);
2223
2224        // Scoped to this dispatch alone: every task below gets its own clone of
2225        // this `Arc`, and once they all finish and drop it, the cache and every
2226        // `ChainFacts` it holds are freed. Nothing here outlives one Generation.
2227        let chain_cache: Arc<ChainFactsCache> = Arc::new(Mutex::new(HashMap::new()));
2228        // Same lifetime as `chain_cache`, one dispatch's worth: patch
2229        // equivalence's own per-common-dir memo, per default-branch.md's "Two
2230        // passes on screen".
2231        let patch_cache: Arc<PatchIdentityCache> = Arc::new(Mutex::new(HashMap::new()));
2232        // One gate per common dir with at least one entity that will run
2233        // `landing::probe` this Generation, sized up front so it is known
2234        // exactly how many entities owe it a report before any of them run;
2235        // see `BoundGate`.
2236        let bound_gates: Arc<HashMap<Arc<Path>, BoundGate>> = Arc::new({
2237            let mut counts: HashMap<Arc<Path>, usize> = HashMap::new();
2238            for (common_dir, probes_state) in common_dirs.iter().zip(&probes_state) {
2239                if *probes_state {
2240                    *counts.entry(Arc::clone(common_dir)).or_insert(0) += 1;
2241                }
2242            }
2243            counts
2244                .into_iter()
2245                .map(|(dir, count)| (dir, BoundGate::new(count)))
2246                .collect()
2247        });
2248
2249        for (
2250            (
2251                (
2252                    (((((key, cancel), repo), override_branch), network_branch), common_dir),
2253                    probes_state,
2254                ),
2255                probes_base,
2256            ),
2257            kind,
2258        ) in dispatched
2259            .into_iter()
2260            .zip(repos)
2261            .zip(override_branches)
2262            .zip(network_branches)
2263            .zip(common_dirs)
2264            .zip(probes_state)
2265            .zip(probes_base)
2266            .zip(kinds)
2267        {
2268            // Recorded here, in this loop's own sequential iteration, rather than in the
2269            // one above: this is the loop whose order a future change (a sort by predicted
2270            // cost, say) would actually be tempted to touch, since it is the one that decides
2271            // each entity's `rayon::spawn` call, not merely which entities were dispatched.
2272            self.dispatch_log.lock().unwrap().push(key.clone());
2273            let path = key.path().to_path_buf();
2274            let table_handle = Arc::clone(&self.table);
2275            let settle_gate = Arc::clone(&self.settle_gate);
2276            let chain_cache = Arc::clone(&chain_cache);
2277            let chain_reads = Arc::clone(&self.default_branch_chain_reads);
2278            let patch_cache = Arc::clone(&patch_cache);
2279            let patch_reads = Arc::clone(&self.patch_identity_reads);
2280            let patch_scan_bounds = Arc::clone(&self.patch_scan_bounds);
2281            let bound_gates = Arc::clone(&bound_gates);
2282            // Resolved once here and moved into the task, which holds no handle on the
2283            // map itself: a probe signals the gate its own Generation was dispatched
2284            // against, so one still running from an earlier Generation can never signal a
2285            // gate registered after that Generation dispatched.
2286            let held_gate = self.phase_c_gates.lock().unwrap().get(&key).cloned();
2287            // scan: probe-fanout-pool begin -- rayon's global pool, not a dedicated one:
2288            // docs/adr/0013's sweep found the width a dedicated pool would need to pick is
2289            // a broad plateau that the global pool's own free default already sits inside
2290            // at every corpus size tried, and is the only width that stayed competitive
2291            // across idle, fetch-sized and Action-sized concurrent load. A dedicated pool
2292            // would cost a second idle thread pool's worth of memory and startup time to
2293            // land somewhere this measurement found no better than free.
2294            rayon::spawn(move || {
2295                let branch_outcome = probe_branch(&path, repo.as_deref(), kind, &cancel);
2296                let sync_outcome = probe_sync(
2297                    &path,
2298                    repo.as_deref(),
2299                    branch_outcome.as_ref().map(|(settled, ..)| settled),
2300                    kind,
2301                    &cancel,
2302                );
2303                let default_branch_outcome = probe_default_branch_memoised(
2304                    &path,
2305                    repo.as_deref(),
2306                    &common_dir,
2307                    DefaultBranchHints {
2308                        override_branch: override_branch.as_deref(),
2309                        network_branch: network_branch.as_deref(),
2310                    },
2311                    kind,
2312                    &cancel,
2313                    &ChainFactsMemo {
2314                        cache: &chain_cache,
2315                        reads: &chain_reads,
2316                    },
2317                );
2318                let base_outcome = if probes_base {
2319                    probe_base(
2320                        &path,
2321                        repo.as_deref(),
2322                        branch_outcome.as_ref().map(|(settled, ..)| settled),
2323                        default_branch_outcome.as_ref().map(|r| &r.settled),
2324                        &cancel,
2325                    )
2326                } else {
2327                    None
2328                };
2329
2330                // Phases A and B land the moment they answer, per refresh.md's "The
2331                // first frame": every cheap column filled within 200ms, never gated
2332                // on phase C or D's much slower answers below. `default_branch_outcome`
2333                // is cloned here rather than moved, since phase D's landing probe
2334                // below still needs to read it.
2335                apply_cheap_probe_outcomes(
2336                    &table_handle,
2337                    &key,
2338                    generation,
2339                    CheapProbeOutcomes {
2340                        branch: branch_outcome,
2341                        sync: sync_outcome,
2342                        base: base_outcome,
2343                        default_branch: default_branch_outcome.clone(),
2344                    },
2345                );
2346
2347                // Test-only: let a test hold phase C and D open here, after the cheap
2348                // outcomes above are already visible on the table, so the two applies'
2349                // independence can be proven by blocking on a Condvar rather than by racing
2350                // a sleep against a probe.
2351                if let Some(gate) = &held_gate {
2352                    let (lock, cvar) = &**gate;
2353                    let mut state = lock.lock().unwrap();
2354                    state.cheap_landed = true;
2355                    cvar.notify_all();
2356                    state = cvar.wait_while(state, |state| !state.may_proceed).unwrap();
2357                    drop(state);
2358                }
2359
2360                let state_outcome = if probes_state {
2361                    let gate = bound_gates
2362                        .get(&common_dir)
2363                        .expect("every probes_state entity's common dir has a gate sized for it");
2364                    let mut report = GateReport::new(gate);
2365                    let memo = PatchEquivalenceMemo {
2366                        cache: &patch_cache,
2367                        reads: &patch_reads,
2368                        scan_bounds: &patch_scan_bounds,
2369                    };
2370                    probe_worktree_state(
2371                        &path,
2372                        repo.as_deref(),
2373                        default_branch_outcome.as_ref().map(|r| &r.settled),
2374                        &common_dir,
2375                        &cancel,
2376                        &memo,
2377                        &mut report,
2378                    )
2379                } else {
2380                    None
2381                };
2382                let dirty_outcome = probe_status(&path, repo.as_deref(), kind, &cancel);
2383                apply_probe_outcome(
2384                    &table_handle,
2385                    &settle_gate,
2386                    &key,
2387                    generation,
2388                    ProbeOutcomes {
2389                        state: state_outcome,
2390                        dirty: dirty_outcome,
2391                    },
2392                );
2393
2394                // The same handle the cheap gate above blocked on, never a second lookup:
2395                // see where it is resolved.
2396                if let Some(gate) = &held_gate {
2397                    let (lock, cvar) = &**gate;
2398                    let mut state = lock.lock().unwrap();
2399                    state.finished = true;
2400                    cvar.notify_all();
2401                }
2402            });
2403            // scan: probe-fanout-pool end
2404        }
2405    }
2406
2407    /// Re-runs both halves of discovery over `self.set` and reconciles the
2408    /// result into the live table, per [discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md)
2409    /// and [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md).
2410    /// The walk and resolve run outside the table lock, since an abandoned walk
2411    /// can take up to thirty seconds; only reconciling the result briefly holds
2412    /// the write lock. Already-known boundaries reuse their cached repository
2413    /// handle rather than reopening it, which is what keeps re-running discovery
2414    /// every Generation from paying every entity's open cost again.
2415    fn rerun_discovery(&self) {
2416        let repos_cache: HashMap<EntityKey, Arc<gix::ThreadSafeRepository>> =
2417            self.table.read().unwrap().repos.clone();
2418
2419        wait_for_discovery_gate(self.discovery_gate.as_ref());
2420        // The watcher is left detached, as it always has been here: nothing on this
2421        // path reads its handle.
2422        let (watch, _watcher) = spawn_discovery_watcher(
2423            self.set.roots.clone(),
2424            &self.discovery_warning,
2425            self.discovery_warn_after,
2426        );
2427        let discovery = run_watched_discovery(
2428            &watch,
2429            &self.set,
2430            &self.discovery_warning,
2431            Duration::from_nanos(self.discovery_abandon_after.load(Ordering::Acquire)),
2432        );
2433        if discovery.abandoned {
2434            self.discovery_manual.store(true, Ordering::Release);
2435        }
2436
2437        let (discovered, gitmodules_failures) =
2438            discovery::resolve_with_cache(&self.set, &discovery.entities, &repos_cache);
2439
2440        // Copied out before the table lock is taken, never read through it: `set_exclusions`
2441        // takes these two locks in the opposite order, and holding one while asking for the
2442        // other is what would let the two deadlock.
2443        let exclusions = self.exclusions.read().unwrap().clone();
2444        let mut table = self.table.write().unwrap();
2445        table.discovered_at = Timestamp::now();
2446        let cancelled = merge_discovery(&mut table, &exclusions, discovered, gitmodules_failures);
2447        drop(table);
2448        if cancelled > 0 {
2449            complete_many(&self.settle_gate, cancelled);
2450        }
2451    }
2452}
2453
2454impl Drop for Core {
2455    /// Cancels whatever this `Core` still has in flight, then joins the dedicated thread.
2456    ///
2457    /// The cancel is what [`Core::pause`] already does, for the same reason
2458    /// [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
2459    /// "Cancellation" gives: an abandoned Generation is cancelled rather than left to
2460    /// finish, since a Set switch rebuilds the `Core` and the outgoing one's fan-out
2461    /// would otherwise contend for the same cores as the incoming one's. A probe already
2462    /// past its own cancel check still runs to completion on rayon's global pool, which
2463    /// is shared process-wide infrastructure rather than a thread this core spawned, so
2464    /// it is not joined here.
2465    fn drop(&mut self) {
2466        cancel_in_flight(&self.table, &self.settle_gate);
2467        let _ = self.control.send(ClockControl::Shutdown);
2468        if let Some(handle) = self.clock_thread.take() {
2469            let _ = handle.join();
2470        }
2471    }
2472}
2473
2474/// `start_internal`'s result: the running core, plus the three handles a test needs
2475/// to make its threading deterministic instead of sleeping. `Core::start` only
2476/// ever reads `core` out of it; the other three fields exist for
2477/// `Core::start_for_test`.
2478pub(crate) struct StartForTest {
2479    pub core: Core,
2480    #[allow(dead_code)] // read only by tests; the plain lib target never builds them
2481    pub clock_alive: Arc<AtomicBool>,
2482    #[allow(dead_code)] // read only by tests; the plain lib target never builds them
2483    pub discovery_watcher: JoinHandle<()>,
2484    /// The thread the first discovery runs on. Joining it is the rendezvous that
2485    /// says the walk finished and its rows reached the table, with no sleep and no
2486    /// poll anywhere in the wait.
2487    #[allow(dead_code)] // read only by tests; the plain lib target never builds them
2488    pub initial_discovery: Option<JoinHandle<()>>,
2489    /// How many periodic-fetch cycles the clock has taken back and joined, cancelled ones
2490    /// included, which a test keeps a handle on across `Core::drop` the same way it keeps
2491    /// `clock_alive`: a cycle shutdown joined is only observable once the `Core` that owned
2492    /// it is gone.
2493    #[allow(dead_code)] // read only by tests; the plain lib target never builds them
2494    pub fetch_cycles_taken_back: Arc<AtomicUsize>,
2495}
2496
2497#[cfg(test)]
2498impl StartForTest {
2499    /// Blocks until the first discovery has landed on the table, then hands this
2500    /// back so a test reads a populated table rather than the empty one `start`
2501    /// itself returns.
2502    fn discovered(mut self) -> Self {
2503        if let Some(handle) = self.initial_discovery.take() {
2504            handle
2505                .join()
2506                .expect("the first discovery thread should not panic");
2507        }
2508        self
2509    }
2510}
2511
2512impl Core {
2513    /// Puts one already-known entity into the in-flight state a real `refresh`
2514    /// dispatch would, without spawning anything to complete it, so a test can
2515    /// drive the deadline sweep through the tick channel alone and prove the sweep
2516    /// runs on a tick rather than on a clock of its own, or prove that `pause`
2517    /// cancels a real in-flight entry from outside this crate.
2518    ///
2519    /// Gated behind `test-util` (on by default under `cfg(test)` for this crate's own
2520    /// tests) so a test-only affordance never ships on the default published surface,
2521    /// per [ADR 0021](https://github.com/paulchiu/repon/blob/main/docs/adr/0021-a-release-is-what-the-tag-pipeline-publishes.md),
2522    /// the same reason `Timestamp::at` is gated.
2523    #[cfg(any(test, feature = "test-util"))]
2524    pub fn begin_untracked_probe_for_test(&self, key: &EntityKey) -> Arc<AtomicBool> {
2525        let mut table = self.table.write().unwrap();
2526        table.generation += 1;
2527        let generation_number = table.generation;
2528        table
2529            .generation_started_at
2530            .insert(generation_number, Instant::now());
2531        if let Some(&idx) = table.index.get(key) {
2532            begin_probes(&mut table.entities[idx]);
2533        }
2534        let cancel = Arc::new(AtomicBool::new(false));
2535        table.in_flight.insert(
2536            key.clone(),
2537            InFlight {
2538                generation: generation_number,
2539                cancel: Arc::clone(&cancel),
2540            },
2541        );
2542        begin_probes_owed(&self.settle_gate, 1);
2543        cancel
2544    }
2545}
2546
2547/// One simulated in-flight Generation, as [`Core::begin_shared_generation_for_test`]
2548/// left it: the Generation itself, and one interrupt flag per key it covers.
2549#[cfg(test)]
2550pub(crate) struct SharedGeneration {
2551    /// The Generation this simulation minted, so a test can name it and its successor
2552    /// rather than the counter values they happen to hold.
2553    pub generation: Generation,
2554    /// One `cancel` flag per covered key, the same handle a real dispatch would hold.
2555    pub cancels: HashMap<EntityKey, Arc<AtomicBool>>,
2556}
2557
2558#[cfg(test)]
2559impl Core {
2560    /// The cached thread-safe repository handle discovery left for `key`, if any,
2561    /// so a test can prove the cache was actually populated and, by comparing
2562    /// `Arc::ptr_eq` across two reads, that a probe reused it rather than
2563    /// replacing it with a freshly opened one.
2564    pub(crate) fn cached_repo_handle_for_test(
2565        &self,
2566        key: &EntityKey,
2567    ) -> Option<Arc<gix::ThreadSafeRepository>> {
2568        self.table.read().unwrap().repos.get(key).cloned()
2569    }
2570
2571    /// How many times the most recent `refresh` actually computed the
2572    /// default-branch chain's per-common-dir facts, as opposed to reusing an
2573    /// already-computed answer for a common dir another dispatched entity already
2574    /// paid for. What proves the per-common-dir memoisation ran at all: two
2575    /// entities agreeing on their resolved default branch proves nothing on its
2576    /// own, since two distinct common dirs can legitimately agree too.
2577    pub(crate) fn default_branch_chain_reads_for_test(&self) -> usize {
2578        self.default_branch_chain_reads.load(Ordering::Acquire)
2579    }
2580
2581    /// How many times the most recent `refresh` actually scanned a common dir's
2582    /// default-branch commit history for patch equivalence, as opposed to
2583    /// reusing an already-computed scan for a common dir another dispatched
2584    /// entity already paid for. The same proof `default_branch_chain_reads_for_test`
2585    /// gives the default-branch chain, for patch equivalence's own memo.
2586    pub(crate) fn patch_identity_reads_for_test(&self) -> usize {
2587        self.patch_identity_reads.load(Ordering::Acquire)
2588    }
2589
2590    /// The bound each actually-run `scan_default_branch` call this Generation
2591    /// used, one entry per common dir it ran for, in run order. Unlike
2592    /// `patch_identity_reads_for_test`, which only proves a scan ran once per
2593    /// common dir, this proves *what* it was bounded by: the deepest merge base
2594    /// among the dispatched siblings, per `BoundGate::deepest`, rather than
2595    /// whichever entity's own merge base happened to reach the scan first.
2596    pub(crate) fn patch_scan_bounds_for_test(&self) -> Vec<Option<gix::ObjectId>> {
2597        self.patch_scan_bounds.lock().unwrap().clone()
2598    }
2599
2600    /// Every key the most recent `refresh` call's own sequential dispatch loop iterated,
2601    /// in that order: dispatch order, proven directly rather than inferred from completion,
2602    /// which a concurrent pool never guarantees (criterion 5's honest half).
2603    pub(crate) fn dispatch_log_for_test(&self) -> Vec<EntityKey> {
2604        self.dispatch_log.lock().unwrap().clone()
2605    }
2606
2607    /// Runs one metadata-poll sweep synchronously on the calling thread: the exact
2608    /// work the dedicated thread's tick arm performs, called directly so a test can
2609    /// prove the sweep's own effects without racing the injected tick channel's
2610    /// delivery to that other thread.
2611    pub(crate) fn poll_once_for_test(&self) {
2612        run_poll_sweep(
2613            &self.table,
2614            &self.overrides,
2615            &self.show_submodules,
2616            &self.poll_reprobed,
2617            &self.poll_sweep_count,
2618            &self.network_default_branch,
2619        );
2620    }
2621
2622    /// Every key the most recent `poll_once_for_test` call actually re-ran phases A
2623    /// and B for, in the order it found them moved: proves "for that entity only"
2624    /// by naming exactly which entities were touched, not merely that one of them
2625    /// was.
2626    pub(crate) fn poll_reprobed_for_test(&self) -> Vec<EntityKey> {
2627        self.poll_reprobed.lock().unwrap().clone()
2628    }
2629
2630    /// How many metadata-poll sweeps have run in total, so a test driving the real
2631    /// dedicated thread through its injected tick channel can prove a tick reached
2632    /// the sweep at all, not only what the sweep did once it ran.
2633    pub(crate) fn poll_sweep_count_for_test(&self) -> usize {
2634        self.poll_sweep_count.load(Ordering::Acquire)
2635    }
2636
2637    /// This `Core`'s own [`ActionCompletionBoundary`], to arm before the run whose
2638    /// completion a test wants held open. For a test.
2639    #[cfg(test)]
2640    pub(crate) fn action_completion_boundary(&self) -> Arc<ActionCompletionBoundary> {
2641        Arc::clone(&self.action_completion_boundary)
2642    }
2643
2644    /// This `Core`'s own [`FetchBoundary`], to arm before the fetch cycle a test wants held.
2645    #[cfg(test)]
2646    pub(crate) fn fetch_boundary(&self) -> Arc<FetchBoundary> {
2647        Arc::clone(&self.fetch_boundary)
2648    }
2649
2650    /// Registers a closed phase C/D gate for `key`, so the next `refresh` that
2651    /// dispatches it will land its cheap outcomes, then block before touching
2652    /// phase C or D until [`Core::release_phase_c_for_test`] opens the gate.
2653    /// Must be called before the dispatching `refresh`, since a Generation resolves
2654    /// each entity's gate as it dispatches it and its probes signal that one alone.
2655    pub(crate) fn hold_phase_c_for_test(&self, key: &EntityKey) {
2656        self.phase_c_gates.lock().unwrap().insert(
2657            key.clone(),
2658            Arc::new((Mutex::new(PhaseCGate::default()), Condvar::new())),
2659        );
2660    }
2661
2662    /// Blocks the calling thread, with no sleep or poll, until `key`'s cheap
2663    /// outcomes have landed on the table. Panics if `key` has no gate
2664    /// registered, since that means the test forgot [`Core::hold_phase_c_for_test`].
2665    pub(crate) fn wait_phase_c_landed_for_test(&self, key: &EntityKey) {
2666        let gate = self
2667            .phase_c_gates
2668            .lock()
2669            .unwrap()
2670            .get(key)
2671            .cloned()
2672            .expect("hold_phase_c_for_test must be called before waiting on its gate");
2673        let (lock, cvar) = &*gate;
2674        let guard = lock.lock().unwrap();
2675        drop(cvar.wait_while(guard, |state| !state.cheap_landed).unwrap());
2676    }
2677
2678    /// Lets `key`'s held phase C and D proceed. Does not itself wait for them to
2679    /// finish; pair with [`Core::wait_phase_c_finished_for_test`].
2680    pub(crate) fn release_phase_c_for_test(&self, key: &EntityKey) {
2681        let gate = self
2682            .phase_c_gates
2683            .lock()
2684            .unwrap()
2685            .get(key)
2686            .cloned()
2687            .expect("hold_phase_c_for_test must be called before releasing its gate");
2688        let (lock, cvar) = &*gate;
2689        let mut state = lock.lock().unwrap();
2690        state.may_proceed = true;
2691        cvar.notify_all();
2692    }
2693
2694    /// Blocks the calling thread, with no sleep or poll, until `key`'s phase C/D
2695    /// outcome has been applied and the settle gate decremented for it.
2696    pub(crate) fn wait_phase_c_finished_for_test(&self, key: &EntityKey) {
2697        let gate = self
2698            .phase_c_gates
2699            .lock()
2700            .unwrap()
2701            .get(key)
2702            .cloned()
2703            .expect("hold_phase_c_for_test must be called before waiting on its gate");
2704        let (lock, cvar) = &*gate;
2705        let guard = lock.lock().unwrap();
2706        drop(cvar.wait_while(guard, |state| !state.finished).unwrap());
2707    }
2708
2709    /// Blocks the calling thread, with no sleep and no poll, until every Generation
2710    /// reserved so far has finished dispatching: what a test waits on before reading
2711    /// a count a dispatch raises, now that a Generation reserves its number on the
2712    /// calling thread and raises that count on one of its own.
2713    pub(crate) fn wait_dispatched_for_test(&self) {
2714        let (lock, cvar) = &*self.settle_gate;
2715        let guard = lock.lock().unwrap();
2716        drop(
2717            cvar.wait_while(guard, |counts| counts.dispatches > 0)
2718                .unwrap(),
2719        );
2720    }
2721
2722    /// The settle gate's raw outstanding count, so a test can prove a single
2723    /// dispatched entity's split write decrements it exactly once overall,
2724    /// neither twice (an early `settle`) nor zero times (a `settle` that hangs).
2725    pub(crate) fn settle_gate_count_for_test(&self) -> usize {
2726        self.settle_gate.0.lock().unwrap().probes
2727    }
2728
2729    /// `start`, with the tick source and the discovery-slow warning's threshold
2730    /// injected rather than real, so a test drives the dedicated thread's cadence
2731    /// through a channel it controls and never waits out a real second.
2732    pub(crate) fn start_for_test(
2733        spec: CoreSpec,
2734        warn_after: Duration,
2735        ticks: Receiver<Instant>,
2736    ) -> StartForTest {
2737        Self::start_for_test_with_discovery_abandon(
2738            spec,
2739            warn_after,
2740            discovery::ABANDON_AFTER,
2741            ticks,
2742        )
2743    }
2744
2745    /// `start_for_test`, with the discovery abandon deadline also injected, so a
2746    /// test can force a walk to abandon deterministically instead of running one
2747    /// for the real thirty seconds. The periodic fetch is always off here: a test
2748    /// that wants it runs [`Core::start_for_test_with_fetch`] instead, which is
2749    /// what keeps this constructor's own signature free of a feature-gated
2750    /// parameter.
2751    pub(crate) fn start_for_test_with_discovery_abandon(
2752        spec: CoreSpec,
2753        warn_after: Duration,
2754        discovery_abandon_after: Duration,
2755        ticks: Receiver<Instant>,
2756    ) -> StartForTest {
2757        Self::start_for_test_gated(spec, warn_after, discovery_abandon_after, ticks, None)
2758    }
2759
2760    /// `start_for_test_with_discovery_abandon`, with the discovery gate injected: with a
2761    /// closed one, every walk this `Core` starts blocks before it begins, so a caller's own
2762    /// return is observed against a walk that provably has not run.
2763    pub(crate) fn start_for_test_gated(
2764        spec: CoreSpec,
2765        warn_after: Duration,
2766        discovery_abandon_after: Duration,
2767        ticks: Receiver<Instant>,
2768        discovery_gate: Option<DiscoveryGate>,
2769    ) -> StartForTest {
2770        let alive = Arc::new(AtomicBool::new(true));
2771        start_internal(
2772            spec,
2773            warn_after,
2774            discovery_abandon_after,
2775            ticks,
2776            FetchStart {
2777                enabled: false,
2778                concurrency: 1,
2779                ticks: crossbeam_channel::never(),
2780            },
2781            alive,
2782            discovery_gate,
2783        )
2784    }
2785
2786    /// `start_for_test_with_discovery_abandon`, with the periodic fetch's own tick
2787    /// channel injected too, so a test can prove the recurring cadence without
2788    /// waiting out a real `fetch.interval`. `spec.fetch.enabled` still governs
2789    /// whether the immediate first cycle fires; `fetch_ticks` governs every cycle
2790    /// after that.
2791    pub(crate) fn start_for_test_with_fetch(
2792        spec: CoreSpec,
2793        warn_after: Duration,
2794        ticks: Receiver<Instant>,
2795        fetch_ticks: Receiver<Instant>,
2796    ) -> StartForTest {
2797        Self::start_for_test_with_fetch_gated(spec, warn_after, ticks, fetch_ticks, None)
2798    }
2799
2800    /// [`Self::start_for_test_with_fetch`], with the discovery gate injected too: a closed
2801    /// gate holds the first walk, which is what puts a call made on this `Core` provably
2802    /// before the immediate cycle that walk asks for.
2803    pub(crate) fn start_for_test_with_fetch_gated(
2804        spec: CoreSpec,
2805        warn_after: Duration,
2806        ticks: Receiver<Instant>,
2807        fetch_ticks: Receiver<Instant>,
2808        discovery_gate: Option<DiscoveryGate>,
2809    ) -> StartForTest {
2810        let alive = Arc::new(AtomicBool::new(true));
2811        let fetch_start = FetchStart {
2812            enabled: spec.fetch.enabled,
2813            concurrency: spec.fetch.concurrency.max(1),
2814            ticks: fetch_ticks,
2815        };
2816        start_internal(
2817            spec,
2818            warn_after,
2819            discovery::ABANDON_AFTER,
2820            ticks,
2821            fetch_start,
2822            alive,
2823            discovery_gate,
2824        )
2825    }
2826
2827    /// How many periodic-fetch cycles have run in total: the immediate first one
2828    /// plus one per `fetch.interval` tick since, whether or not any repository had
2829    /// a remote to fetch.
2830    pub(crate) fn fetch_cycle_count_for_test(&self) -> usize {
2831        self.fetch_cycle_count.load(Ordering::Acquire)
2832    }
2833
2834    /// Whether an abandoned discovery has already taken this `Core` out of the
2835    /// automatic refresh path, so a test can assert the precondition explicitly
2836    /// rather than infer it from a later refresh's behaviour alone.
2837    /// Tightens the abandon deadline after `start`, so a test can let the first walk
2838    /// finish under a deadline it cannot lose against and still force a later walk to
2839    /// abandon.
2840    #[cfg(test)]
2841    pub(crate) fn set_discovery_abandon_after_for_test(&self, after: Duration) {
2842        self.discovery_abandon_after
2843            .store(after.as_nanos() as u64, Ordering::Release);
2844    }
2845
2846    pub(crate) fn discovery_manual_for_test(&self) -> bool {
2847        self.discovery_manual.load(Ordering::Acquire)
2848    }
2849
2850    /// Puts several already-known entities into the in-flight state of one shared
2851    /// Generation, without spawning anything to complete them and without
2852    /// touching the settle gate, so a test can drive per-entity supersession
2853    /// directly: which keys a later real `refresh` does and does not cover, and
2854    /// what happens to each one's own cancel flag and eventual result.
2855    ///
2856    /// Hands back the Generation it minted rather than only the flags, so the test
2857    /// names that Generation and its successor instead of the counter values they
2858    /// happen to hold.
2859    pub(crate) fn begin_shared_generation_for_test(&self, keys: &[EntityKey]) -> SharedGeneration {
2860        let mut table = self.table.write().unwrap();
2861        table.generation += 1;
2862        let generation_number = table.generation;
2863        table
2864            .generation_started_at
2865            .insert(generation_number, Instant::now());
2866        let mut cancels = HashMap::new();
2867        for key in keys {
2868            if let Some(&idx) = table.index.get(key) {
2869                table.entities[idx].branch.begin_probe();
2870            }
2871            let cancel = Arc::new(AtomicBool::new(false));
2872            table.in_flight.insert(
2873                key.clone(),
2874                InFlight {
2875                    generation: generation_number,
2876                    cancel: Arc::clone(&cancel),
2877                },
2878            );
2879            cancels.insert(key.clone(), cancel);
2880        }
2881        SharedGeneration {
2882            generation: Generation::new(generation_number),
2883            cancels,
2884        }
2885    }
2886
2887    /// Lands one branch probe result for `key` at `generation` through the exact
2888    /// same path a real dispatched probe's cheap outcomes take
2889    /// ([`apply_cheap_probe_outcomes`]), so a test can simulate a result arriving
2890    /// late, out of Generation order, without a second, weaker implementation of
2891    /// the write-time supersession check.
2892    pub(crate) fn apply_probe_result_for_test(
2893        &self,
2894        key: &EntityKey,
2895        generation: Generation,
2896        settled: Settled<Head>,
2897    ) {
2898        apply_cheap_probe_outcomes(
2899            &self.table,
2900            key,
2901            generation,
2902            CheapProbeOutcomes {
2903                branch: Some((settled, None, Vec::new())),
2904                sync: None,
2905                base: None,
2906                default_branch: None,
2907            },
2908        );
2909    }
2910
2911    /// Writes `receipt` directly onto `key`'s `last_action`, bypassing `run_action`
2912    /// entirely: lets a test put an exact, hand-built receipt on a live `Core`'s table
2913    /// without spawning any real child process.
2914    pub(crate) fn set_last_action_for_test(
2915        &self,
2916        key: &EntityKey,
2917        receipt: crate::entity::ActionReceipt,
2918    ) {
2919        let mut table = self.table.write().unwrap();
2920        if let Some(&idx) = table.index.get(key) {
2921            table.entities[idx].last_action = Some(receipt);
2922        }
2923    }
2924}
2925
2926/// One entity's whole Action run: every step in `action.steps`, in order, stopping at
2927/// the first failure, with every step after it recorded `NotRun` rather than silently
2928/// skipped ([config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md)'s
2929/// "Actions", `docs/spec/actions.md`'s "Step outcomes"). Never called for an excluded,
2930/// inapplicable or unresolved entity: [`Core::run_action`] gives those their own `Skip`
2931/// receipt itself and never reaches this function for them.
2932///
2933/// `control` is the same `RunControl` every other entity's run in this fan-out shares:
2934/// checked before every step starts, so a step not yet reached when `control.cancel` fires
2935/// becomes `Cancelled` rather than ever spawning, and again the instant a spawned step's
2936/// `run_step` call returns, so a step that was actually running when cancellation fired
2937/// becomes `Cancelled` regardless of the exit `run_step` itself observed (a signalled child
2938/// has no clean outcome of its own to report). `Cancelled` and `NotRun` are deliberately
2939/// kept apart here: once cancellation is seen, every remaining step (including a step
2940/// already past the "before it starts" check but not yet run) is `Cancelled`, never
2941/// `NotRun`, which stays reserved for being blocked by an earlier failure
2942/// (`docs/spec/actions.md`'s "Step outcomes").
2943///
2944/// `report` is called once per step, immediately before that step starts, with a receipt
2945/// whose `running` names it: the caller writes this straight onto the table, which is what
2946/// lets a still-running step's own label and elapsed time reach a reader before the whole
2947/// entity's run has finished (`docs/spec/actions.md`'s "The run on screen"). The final
2948/// return value is the same shape with `running: None`, the caller's job to write once more.
2949fn run_action_for_entity(
2950    entity: &EntityState,
2951    action: &ActionSpec,
2952    control: &Arc<executor::RunControl>,
2953    report: &dyn Fn(ActionReceipt),
2954) -> ActionReceipt {
2955    let base_env = environment::environment(entity, action.name.as_deref());
2956    let mut failed = false;
2957    let mut cancelled = false;
2958    let mut results: Vec<StepResult> = Vec::with_capacity(action.steps.len());
2959    for step in &action.steps {
2960        if failed || cancelled || control.is_cancelled() {
2961            cancelled = cancelled || control.is_cancelled();
2962            results.push(StepResult {
2963                label: Arc::from(step.argv.join(" ")),
2964                outcome: if cancelled {
2965                    StepOutcome::Cancelled
2966                } else {
2967                    StepOutcome::NotRun
2968                },
2969                output: Arc::from(&b""[..]),
2970                elapsed: Duration::ZERO,
2971                elision: None,
2972                shell: step.shell,
2973                interactive: step.interactive,
2974            });
2975            continue;
2976        }
2977        let label: Arc<str> = Arc::from(step.argv.join(" "));
2978        report(ActionReceipt {
2979            label: Arc::clone(&action.label),
2980            steps: Arc::from(results.clone()),
2981            skip: None,
2982            finished_at: Timestamp::now(),
2983            running: Some(RunningStep {
2984                label: Arc::clone(&label),
2985                started_at: Timestamp::now(),
2986                shell: step.shell,
2987                interactive: step.interactive,
2988            }),
2989        });
2990        // The step's own `env` table is applied after the environment contract's
2991        // set-or-unset pairs, so it overrides the guaranteed set exactly as a
2992        // Launcher's own `env` field already does (`docs/spec/config.md`'s
2993        // "Launchers").
2994        let mut env = base_env.clone();
2995        env.extend(
2996            step.env
2997                .iter()
2998                .map(|(name, value)| (name.clone(), Some(value.clone()))),
2999        );
3000        let mut result = executor::run_step(
3001            &step.argv,
3002            step.shell,
3003            step.interactive,
3004            entity.key.path(),
3005            &env,
3006            control,
3007        );
3008        if control.is_cancelled() {
3009            result.outcome = StepOutcome::Cancelled;
3010            cancelled = true;
3011        } else {
3012            failed = result.outcome.is_failure();
3013        }
3014        results.push(result);
3015    }
3016    ActionReceipt {
3017        label: Arc::clone(&action.label),
3018        steps: Arc::from(results),
3019        skip: None,
3020        finished_at: Timestamp::now(),
3021        running: None,
3022    }
3023}
3024
3025/// A gate a test closes to hold every discovery walk this `Core` starts, at the
3026/// point before the walk begins, so a caller's own return can be observed against a
3027/// walk that provably has not run. `None` on every production path, the same way
3028/// `Core::phase_c_gates` is empty on one.
3029type DiscoveryGate = Arc<(Mutex<bool>, Condvar)>;
3030
3031/// Blocks while `gate` is closed, and returns at once when there is none, which is
3032/// every production path.
3033fn wait_for_discovery_gate(gate: Option<&DiscoveryGate>) {
3034    let Some(gate) = gate else {
3035        return;
3036    };
3037    let (lock, cvar) = &**gate;
3038    let open = lock.lock().unwrap();
3039    drop(cvar.wait_while(open, |open| !*open).unwrap());
3040}
3041
3042/// Opens or closes a [`DiscoveryGate`], waking whatever walk is held on it.
3043#[cfg(test)]
3044fn set_discovery_gate(gate: &DiscoveryGate, open: bool) {
3045    let (lock, cvar) = &**gate;
3046    *lock.lock().unwrap() = open;
3047    cvar.notify_all();
3048}
3049
3050/// What one watched discovery walk and the thread watching it share: the counter
3051/// the walk bumps as it goes, and the flag it sets on finishing.
3052struct DiscoveryWatch {
3053    progress: Arc<AtomicUsize>,
3054    finished: Arc<AtomicBool>,
3055}
3056
3057/// Arms the still-walking watcher for a walk that has not started yet, leaving the
3058/// still-walking warning behind in `discovery_warning` if that walk outruns
3059/// `warn_after`. Separate from [`run_watched_discovery`] so `start_internal` can arm
3060/// it on the calling thread, and hand a test its handle, while the walk it watches
3061/// runs on a thread of its own.
3062fn spawn_discovery_watcher(
3063    roots: Vec<PathBuf>,
3064    discovery_warning: &Arc<Mutex<Option<String>>>,
3065    warn_after: Duration,
3066) -> (DiscoveryWatch, JoinHandle<()>) {
3067    let progress = Arc::new(AtomicUsize::new(0));
3068    let finished = Arc::new(AtomicBool::new(false));
3069    let watcher = thread::spawn({
3070        let progress = Arc::clone(&progress);
3071        let finished = Arc::clone(&finished);
3072        let warning_slot = Arc::clone(discovery_warning);
3073        move || {
3074            if let Some(message) = watch_for_slow_discovery(progress, finished, roots, warn_after) {
3075                *warning_slot.lock().unwrap() = Some(message);
3076            }
3077        }
3078    });
3079    (DiscoveryWatch { progress, finished }, watcher)
3080}
3081
3082/// Runs one discovery boundary walk against `set` under an already-armed `watch`,
3083/// leaving the abandoned-discovery warning in `discovery_warning` if the walk
3084/// abandons past `abandon_after`. Shared by `start_internal`'s first walk and
3085/// `rerun_discovery`'s later ones, so a refresh-triggered abandon runs the same
3086/// wiring `start`'s own walk does, never a parallel copy of it.
3087fn run_watched_discovery(
3088    watch: &DiscoveryWatch,
3089    set: &SetSpec,
3090    discovery_warning: &Arc<Mutex<Option<String>>>,
3091    abandon_after: Duration,
3092) -> discovery::Discovery {
3093    let discovery =
3094        discovery::discover_watched_with_deadline(set, Arc::clone(&watch.progress), abandon_after);
3095    watch.finished.store(true, Ordering::Release);
3096
3097    if discovery.abandoned {
3098        *discovery_warning.lock().unwrap() =
3099            Some(abandoned_discovery_message(discovery.directories_visited));
3100    }
3101
3102    discovery
3103}
3104
3105/// Shared body of `start` and `start_for_test`: builds the empty table, spawns the
3106/// dedicated thread, and starts the first discovery on a thread of its own.
3107fn start_internal(
3108    spec: CoreSpec,
3109    warn_after: Duration,
3110    discovery_abandon_after: Duration,
3111    ticks: Receiver<Instant>,
3112    fetch_start: FetchStart,
3113    alive: Arc<AtomicBool>,
3114    discovery_gate: Option<DiscoveryGate>,
3115) -> StartForTest {
3116    let FetchStart {
3117        enabled: fetch_enabled,
3118        concurrency: fetch_concurrency,
3119        ticks: fetch_ticks,
3120    } = fetch_start;
3121    let discovery_warning = Arc::new(Mutex::new(None));
3122    let discovery_manual = Arc::new(AtomicBool::new(false));
3123
3124    let (overrides, resolved_exclusions) = resolve_entries(&spec.overrides);
3125    let overrides = Arc::new(overrides);
3126    let exclusions = Arc::new(RwLock::new(resolved_exclusions));
3127    let show_submodules = Arc::new(AtomicBool::new(spec.show_submodules));
3128
3129    let table = Arc::new(RwLock::new(Table {
3130        generation: 0,
3131        discovered_at: Timestamp::now(),
3132        entities: Vec::new(),
3133        index: HashMap::new(),
3134        in_flight: HashMap::new(),
3135        generation_started_at: HashMap::new(),
3136        repos: HashMap::new(),
3137        poll_fingerprints: HashMap::new(),
3138    }));
3139
3140    let settle_gate: Arc<SettleGate> =
3141        Arc::new((Mutex::new(SettleCounts::default()), Condvar::new()));
3142    let poll_reprobed = Arc::new(Mutex::new(Vec::new()));
3143    let poll_sweep_count = Arc::new(AtomicUsize::new(0));
3144    let network_default_branch = Arc::new(Mutex::new(HashMap::new()));
3145    let (control, control_rx) = crossbeam_channel::unbounded();
3146    let poll_handles = PollHandles {
3147        overrides: Arc::clone(&overrides),
3148        show_submodules: Arc::clone(&show_submodules),
3149        poll_reprobed: Arc::clone(&poll_reprobed),
3150        poll_sweep_count: Arc::clone(&poll_sweep_count),
3151        network_default_branch: Arc::clone(&network_default_branch),
3152    };
3153
3154    // Hoisted out of the `Core` struct literal below, rather than built inline
3155    // there as before this field existed: `RefreshHandles` needs its own clone of
3156    // each of these, constructed before `Core` takes ownership of the originals.
3157    let discovery_abandon_after_atomic =
3158        Arc::new(AtomicU64::new(discovery_abandon_after.as_nanos() as u64));
3159    let default_branch_chain_reads = Arc::new(AtomicUsize::new(0));
3160    let patch_identity_reads = Arc::new(AtomicUsize::new(0));
3161    let patch_scan_bounds = Arc::new(Mutex::new(Vec::new()));
3162    let dispatch_log = Arc::new(Mutex::new(Vec::new()));
3163    let phase_c_gates = Arc::new(Mutex::new(HashMap::new()));
3164    let fetch_cycle_count = Arc::new(AtomicUsize::new(0));
3165    let fetch_failures = Arc::new(Mutex::new(FetchFailures::default()));
3166    let fetch_cycles_taken_back = Arc::new(AtomicUsize::new(0));
3167    let (fetch_finished_tx, fetch_finished_rx) = crossbeam_channel::unbounded();
3168    #[cfg(test)]
3169    let fetch_boundary = Arc::new(FetchBoundary::default());
3170    let turnstile = Arc::new(DispatchTurnstile::default());
3171
3172    let fetch_refresh_handles = RefreshHandles {
3173        table: Arc::clone(&table),
3174        overrides: Arc::clone(&overrides),
3175        exclusions: Arc::clone(&exclusions),
3176        set: spec.set.clone(),
3177        discovery_manual: Arc::clone(&discovery_manual),
3178        discovery_warn_after: warn_after,
3179        discovery_abandon_after: Arc::clone(&discovery_abandon_after_atomic),
3180        discovery_warning: Arc::clone(&discovery_warning),
3181        show_submodules: Arc::clone(&show_submodules),
3182        settle_gate: Arc::clone(&settle_gate),
3183        default_branch_chain_reads: Arc::clone(&default_branch_chain_reads),
3184        patch_identity_reads: Arc::clone(&patch_identity_reads),
3185        patch_scan_bounds: Arc::clone(&patch_scan_bounds),
3186        dispatch_log: Arc::clone(&dispatch_log),
3187        phase_c_gates: Arc::clone(&phase_c_gates),
3188        network_default_branch: Arc::clone(&network_default_branch),
3189        turnstile: Arc::clone(&turnstile),
3190        discovery_gate: discovery_gate.clone(),
3191    };
3192    let auto_update_enabled = spec.auto_update.enabled;
3193    let fetch_schedule = FetchSchedule {
3194        concurrency: fetch_concurrency,
3195        ticks: fetch_ticks,
3196        refresh: fetch_refresh_handles.clone(),
3197        cycle_count: Arc::clone(&fetch_cycle_count),
3198        failures: Arc::clone(&fetch_failures),
3199        auto_update_enabled,
3200        finished: fetch_finished_rx,
3201        finished_tx: fetch_finished_tx,
3202        taken_back_count: Arc::clone(&fetch_cycles_taken_back),
3203        #[cfg(test)]
3204        boundary: Arc::clone(&fetch_boundary),
3205    };
3206
3207    let clock_thread = spawn_clock_thread(
3208        Arc::clone(&table),
3209        poll_handles,
3210        fetch_schedule,
3211        Arc::clone(&settle_gate),
3212        spec.generation_deadline,
3213        ClockChannels {
3214            control: control_rx,
3215            ticks,
3216            alive: Arc::clone(&alive),
3217        },
3218    );
3219
3220    // Discovery runs here rather than on the calling thread, so `Core::start`
3221    // returns against the empty table above and the consumer can claim the terminal
3222    // and draw before the walk has finished (ADR 0015's "a constructor that spawns
3223    // threads is not a surprise"). This walk is also refresh.md's "Startup"
3224    // Generation, so a launch walks the tree once: the number and the turnstile place
3225    // are reserved here on the calling thread, exactly as every later Generation
3226    // reserves its own, and the walk and the fan-out it orders both run on the
3227    // spawned thread. The debt is recorded before the spawn, so a `settle` called in
3228    // between waits for this Generation rather than returning on an empty table.
3229    let (startup_generation, startup_ticket) = fetch_refresh_handles.reserve_generation();
3230    begin_dispatch(&settle_gate);
3231    let (watch, discovery_watcher) =
3232        spawn_discovery_watcher(spec.set.roots.clone(), &discovery_warning, warn_after);
3233    let initial_discovery = thread::spawn({
3234        let set = spec.set.clone();
3235        let discovery_warning = Arc::clone(&discovery_warning);
3236        let discovery_manual = Arc::clone(&discovery_manual);
3237        let exclusions = Arc::clone(&exclusions);
3238        let table = Arc::clone(&table);
3239        let settle_gate = Arc::clone(&settle_gate);
3240        let fetch_refresh_handles = fetch_refresh_handles.clone();
3241        let control = control.clone();
3242        let discovery_gate = discovery_gate.clone();
3243        move || {
3244            let turn = fetch_refresh_handles.turnstile.take(startup_ticket);
3245            wait_for_discovery_gate(discovery_gate.as_ref());
3246            let discovery =
3247                run_watched_discovery(&watch, &set, &discovery_warning, discovery_abandon_after);
3248            if discovery.abandoned {
3249                discovery_manual.store(true, Ordering::Release);
3250            }
3251
3252            // Discovery's second half: every boundary the walk just found becomes a
3253            // Repo or a Worktree, and each one's own `.gitmodules` (never recursed
3254            // into) names its Submodules. One combined list, with nothing recording
3255            // which half produced a given entry.
3256            let (discovered, gitmodules_failures) = discovery::resolve(&set, &discovery.entities);
3257            let resolved_exclusions = exclusions.read().unwrap().clone();
3258            let order: Vec<EntityKey> = {
3259                let mut table = table.write().unwrap();
3260                // A fresh table has nothing in flight yet, so nothing here is ever
3261                // cancelled: the same reconciliation `refresh` uses later, run once
3262                // against an empty starting point.
3263                merge_discovery(
3264                    &mut table,
3265                    &resolved_exclusions,
3266                    discovered,
3267                    gitmodules_failures,
3268                );
3269                table.discovered_at = Timestamp::now();
3270                table
3271                    .entities
3272                    .iter()
3273                    .map(|entity| entity.key.clone())
3274                    .collect()
3275            };
3276            // Read off the table this walk just reconciled, the same way
3277            // `dispatch_over_everything` resolves its own order: nobody holding the
3278            // empty table `start` returned has a key to name yet.
3279            fetch_refresh_handles.dispatch_probes(&order, startup_generation);
3280            finish_dispatch(&settle_gate);
3281            // Released here rather than at thread exit: the first fetch cycle spawned
3282            // below is not part of this Generation's body.
3283            drop(turn);
3284
3285            // "Fires immediately on being enabled rather than waiting for the first
3286            // tick" ([refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
3287            // "The periodic fetch"): the recurring cadence only ever fires after a full
3288            // `fetch.interval` has elapsed, so the first cycle is asked for here, once.
3289            // Asked for rather than run, so the clock owns this cycle exactly as it owns
3290            // every later one. From inside this thread rather than beside it, because a
3291            // cycle reads the table to know what to fetch and the walk above is what puts
3292            // anything in it.
3293            if fetch_enabled {
3294                let _ = control.send(ClockControl::FetchNow);
3295            }
3296        }
3297    });
3298
3299    StartForTest {
3300        core: Core {
3301            table,
3302            overrides,
3303            exclusions,
3304            set: spec.set,
3305            discovery_manual,
3306            discovery_warn_after: warn_after,
3307            discovery_abandon_after: discovery_abandon_after_atomic,
3308            show_submodules,
3309            settle_gate,
3310            control,
3311            clock_thread: Some(clock_thread),
3312            discovery_warning,
3313            default_branch_chain_reads,
3314            patch_identity_reads,
3315            patch_scan_bounds,
3316            action_lifecycle: Arc::new(Mutex::new(ActionLifecycle::default())),
3317            dispatch_log,
3318            phase_c_gates,
3319            status_stale_after: spec.status_stale_after,
3320            poll_reprobed,
3321            poll_sweep_count,
3322            fetch_cycle_count,
3323            network_default_branch,
3324            fetch_failures,
3325            turnstile,
3326            discovery_gate,
3327            #[cfg(test)]
3328            action_completion_boundary: Arc::new(ActionCompletionBoundary::default()),
3329            #[cfg(test)]
3330            fetch_boundary: Arc::clone(&fetch_boundary),
3331        },
3332        clock_alive: alive,
3333        discovery_watcher,
3334        initial_discovery: Some(initial_discovery),
3335        fetch_cycles_taken_back,
3336    }
3337}
3338
3339/// Everything the dedicated thread's tick arm needs for [`run_poll_sweep`] beyond
3340/// the table it already takes, bundled so `spawn_clock_thread` stays within
3341/// clippy's argument limit.
3342struct PollHandles {
3343    overrides: Arc<Vec<ResolvedOverride>>,
3344    show_submodules: Arc<AtomicBool>,
3345    poll_reprobed: Arc<Mutex<Vec<EntityKey>>>,
3346    poll_sweep_count: Arc<AtomicUsize>,
3347    /// [`Core::network_default_branch`]'s own clone, so a poll-triggered re-probe
3348    /// still reflects an already-superseded default branch rather than reverting
3349    /// to the local chain's own answer until the next full refresh.
3350    network_default_branch: Arc<Mutex<HashMap<PathBuf, Arc<str>>>>,
3351}
3352
3353/// What [`start_internal`] needs from `CoreSpec::fetch` to schedule the periodic fetch,
3354/// bundled into one argument rather than three so this crate's own `clippy::too_many_arguments`
3355/// budget has room for it: extracted once at each of `Core::start`'s two callers.
3356struct FetchStart {
3357    enabled: bool,
3358    concurrency: usize,
3359    ticks: Receiver<Instant>,
3360}
3361
3362/// The periodic fetch's own scheduling inputs, threaded through [`start_internal`]
3363/// and [`spawn_clock_thread`] as plain values rather than reading `CoreSpec::fetch`
3364/// directly: extracted once at each of the two callers. Carries no `enabled` flag
3365/// of its own: `ticks` is [`crossbeam_channel::never`] whenever the periodic fetch
3366/// is off, so the arm that reads it simply never fires, the same way the poll's
3367/// own `ticks` does when a test has no interest in it.
3368struct FetchSchedule {
3369    concurrency: usize,
3370    ticks: Receiver<Instant>,
3371    refresh: RefreshHandles,
3372    cycle_count: Arc<AtomicUsize>,
3373    failures: Arc<Mutex<FetchFailures>>,
3374    /// `CoreSpec::auto_update`'s own `enabled` flag, read once at `start` like every
3375    /// other field on [`FetchSchedule`]: the fast-forward-only update carries no
3376    /// interval of its own, so there is no separate tick to gate it on, only this.
3377    auto_update_enabled: bool,
3378    /// A cycle's own worker sends `()` here as its last act; the clock's arm on `finished` is
3379    /// what takes that cycle back, joins its worker and dispatches the Generation it owes.
3380    /// The clock holds `finished_tx` as well, so this arm only ever fires on a real
3381    /// completion.
3382    finished: Receiver<()>,
3383    finished_tx: Sender<()>,
3384    /// How many cycles the clock has taken back and joined, which is what lets a test
3385    /// observe a cycle's own end rather than infer it.
3386    taken_back_count: Arc<AtomicUsize>,
3387    /// See [`FetchBoundary`]. Disarmed unless a test arms it, and off the default build
3388    /// entirely.
3389    #[cfg(test)]
3390    boundary: Arc<FetchBoundary>,
3391}
3392
3393/// The dedicated thread's own control-plane wiring, bundled into one argument so
3394/// [`spawn_clock_thread`] stays within clippy's argument limit: `control` is the
3395/// pause/resume/shutdown channel every `Core` method sends into, `ticks` drives the
3396/// poll and deadline sweep, and `alive` is the flag the thread clears on its way out
3397/// (both for a test to observe and for nothing else, since `Drop` joins the handle
3398/// directly rather than polling this).
3399struct ClockChannels {
3400    control: Receiver<ClockControl>,
3401    ticks: Receiver<Instant>,
3402    alive: Arc<AtomicBool>,
3403}
3404
3405/// The dedicated thread: the metadata poll tick, the Generation deadline sweep and
3406/// the periodic fetch's own tick share this one interval loop, separate from the
3407/// probe pool and from any render loop, so suspending the terminal reschedules
3408/// none of it. Driven by `ticks` and `fetch.ticks` rather than a bare
3409/// `thread::sleep`, which is what a test replaces to make the cadence
3410/// deterministic. The poll and deadline sweep run first on every `ticks` tick,
3411/// both while `!paused`; a fetch cycle starts on every `fetch.ticks` tick, also only
3412/// while `!paused`, so a suspended Repon neither sweeps nor fetches while the user
3413/// is in a Launcher.
3414///
3415/// A cycle runs on a worker of its own rather than here, so a fetch waiting on a remote
3416/// stalls none of the above. This loop is the cycle's owner for as long as it runs: it starts
3417/// at most one at a time, holds the immediate cycle enabling the fetch owes until it can
3418/// start it, cancels the live one on pause and on the way out, and takes it back on the
3419/// completion message the worker sends. Everything a cycle owes the table beyond its
3420/// own fetches, the Generation above all, is dispatched from here rather than from the
3421/// worker, so a cancelled cycle cannot land anything the lifecycle has already moved past.
3422fn spawn_clock_thread(
3423    table: Arc<RwLock<Table>>,
3424    poll: PollHandles,
3425    fetch: FetchSchedule,
3426    settle_gate: Arc<SettleGate>,
3427    generation_deadline: Duration,
3428    channels: ClockChannels,
3429) -> JoinHandle<()> {
3430    let ClockChannels {
3431        control,
3432        ticks,
3433        alive,
3434    } = channels;
3435    thread::spawn(move || {
3436        let mut paused = false;
3437        let mut cycle: Option<FetchCycle> = None;
3438        let mut immediate_cycle_owed = false;
3439        loop {
3440            select! {
3441                recv(control) -> message => match message {
3442                    Ok(ClockControl::Pause) => {
3443                        paused = true;
3444                        cancel_in_flight(&table, &settle_gate);
3445                        if let Some(cycle) = &cycle {
3446                            cycle.cancel();
3447                        }
3448                    }
3449                    Ok(ClockControl::Resume) => paused = false,
3450                    Ok(ClockControl::FetchNow) => immediate_cycle_owed = true,
3451                    Ok(ClockControl::Shutdown) | Err(_) => break,
3452                },
3453                recv(ticks) -> tick => {
3454                    if tick.is_err() {
3455                        break;
3456                    }
3457                    if !paused {
3458                        run_poll_sweep(
3459                            &table,
3460                            &poll.overrides,
3461                            &poll.show_submodules,
3462                            &poll.poll_reprobed,
3463                            &poll.poll_sweep_count,
3464                            &poll.network_default_branch,
3465                        );
3466                        sweep_deadline(&table, &settle_gate, generation_deadline);
3467                    }
3468                }
3469                recv(fetch.ticks) -> tick => {
3470                    if tick.is_err() {
3471                        break;
3472                    }
3473                    // Refused rather than queued while one is live, the same choice
3474                    // `Core::run_action` already makes for a second fan-out: two cycles over
3475                    // the same population would fetch and auto-update the same repositories
3476                    // at once.
3477                    if !paused && cycle.is_none() {
3478                        cycle = Some(start_fetch_cycle(&table, &fetch));
3479                    }
3480                }
3481                recv(fetch.finished) -> _ => {
3482                    if let Some(finished) = cycle.take() {
3483                        let cancelled = finished.cancelled();
3484                        finished.join();
3485                        if !cancelled {
3486                            dispatch_fetch_completion(&table, &fetch.refresh);
3487                        }
3488                        fetch.taken_back_count.fetch_add(1, Ordering::Release);
3489                    }
3490                }
3491            }
3492            // Started here rather than in the arm that asked for it, so a pause or a live
3493            // cycle delays the immediate cycle rather than losing it.
3494            if immediate_cycle_owed && !paused && cycle.is_none() {
3495                immediate_cycle_owed = false;
3496                cycle = Some(start_fetch_cycle(&table, &fetch));
3497            }
3498        }
3499        // Shutdown waits the cycle out rather than detaching it, so no worker is still
3500        // fetching or fast-forwarding once `Core::drop` returns; the wait is only as short as
3501        // [`FetchCycle::cancel`] can make it. The Generation it would have owed is not
3502        // dispatched, since the table it would write to is going away with this `Core`.
3503        if let Some(cycle) = cycle.take() {
3504            cycle.cancel();
3505            cycle.join();
3506            fetch.taken_back_count.fetch_add(1, Ordering::Release);
3507        }
3508        alive.store(false, Ordering::Release);
3509    })
3510}
3511
3512/// The periodic-fetch cycle running right now, owned by the clock for as long as it runs:
3513/// the worker doing the fetching, and the one flag every fetch in that cycle was handed.
3514///
3515/// Owned rather than detached so the clock can end a cycle it has moved past and know that it
3516/// has: [`Self::cancel`] is what pause and shutdown reach for, and [`Self::join`] is what
3517/// makes shutdown's own answer honest.
3518struct FetchCycle {
3519    cancel: Arc<AtomicBool>,
3520    worker: JoinHandle<()>,
3521    /// See [`FetchBoundary`].
3522    #[cfg(test)]
3523    boundary: Arc<FetchBoundary>,
3524}
3525
3526impl FetchCycle {
3527    /// Ends this cycle: no further repository is fetched, one already in its receive stage
3528    /// unwinds, and neither the auto-update nor the completion Generation runs.
3529    ///
3530    /// It is not a bound on a fetch already connecting or preparing: gix takes a cancellation
3531    /// flag at [`gix::remote::fetch::Prepare::receive`] and nowhere earlier, which
3532    /// [`crate::fetch::fetch_and_prune`]'s own doc comment records in full.
3533    fn cancel(&self) {
3534        self.cancel.store(true, Ordering::Release);
3535        #[cfg(test)]
3536        self.boundary.cancelled();
3537    }
3538
3539    fn cancelled(&self) -> bool {
3540        self.cancel.load(Ordering::Acquire)
3541    }
3542
3543    /// Waits for this cycle's own worker to stop.
3544    fn join(self) {
3545        let _ = self.worker.join();
3546    }
3547}
3548
3549/// Starts one cycle on a worker of its own, which sends `()` on `fetch.finished` as its last
3550/// act however the cycle itself ended.
3551///
3552/// The send is what the clock waits for before joining that worker, so it happens past a
3553/// panicked cycle too, caught here for the reason `Core::run_action`'s own fan-out catches
3554/// one: without it a poisoned lock from an unrelated earlier panic would leave this `Core`
3555/// unable to ever start another cycle.
3556fn start_fetch_cycle(table: &Arc<RwLock<Table>>, fetch: &FetchSchedule) -> FetchCycle {
3557    let cancel = Arc::new(AtomicBool::new(false));
3558    let work = FetchCycleWork {
3559        table: Arc::clone(table),
3560        concurrency: fetch.concurrency,
3561        cancel: Arc::clone(&cancel),
3562        network_default_branch: Arc::clone(&fetch.refresh.network_default_branch),
3563        cycle_count: Arc::clone(&fetch.cycle_count),
3564        failures: Arc::clone(&fetch.failures),
3565        auto_update_enabled: fetch.auto_update_enabled,
3566        #[cfg(test)]
3567        boundary: Arc::clone(&fetch.boundary),
3568    };
3569    #[cfg(test)]
3570    let boundary = Arc::clone(&work.boundary);
3571    let finished = fetch.finished_tx.clone();
3572    let worker = thread::spawn(move || {
3573        let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
3574            run_fetch_cycle(&work);
3575        }));
3576        let _ = finished.send(());
3577    });
3578    FetchCycle {
3579        cancel,
3580        worker,
3581        #[cfg(test)]
3582        boundary,
3583    }
3584}
3585
3586/// The one normal Generation a finished cycle owes
3587/// ([refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s "The
3588/// periodic fetch": "a finished fetch starts a normal generation"), over every entity the
3589/// table now knows rather than only the ones that cycle fetched.
3590fn dispatch_fetch_completion(table: &Arc<RwLock<Table>>, refresh: &RefreshHandles) {
3591    let all_keys: Vec<EntityKey> = table
3592        .read()
3593        .unwrap()
3594        .entities
3595        .iter()
3596        .map(|entity| entity.key.clone())
3597        .collect();
3598    refresh.dispatch(&all_keys);
3599}
3600
3601/// One periodic-fetch cycle's own inputs, cloned out of [`FetchSchedule`] when a cycle
3602/// starts: `cancel` is the one flag every fetch in this cycle is handed, so whoever owns the
3603/// cycle can end all of them at once. Carries the network default branch map alone and never
3604/// the whole [`RefreshHandles`], since dispatching the Generation is the clock's to fence.
3605struct FetchCycleWork {
3606    table: Arc<RwLock<Table>>,
3607    concurrency: usize,
3608    cancel: Arc<AtomicBool>,
3609    network_default_branch: Arc<Mutex<HashMap<PathBuf, Arc<str>>>>,
3610    cycle_count: Arc<AtomicUsize>,
3611    failures: Arc<Mutex<FetchFailures>>,
3612    auto_update_enabled: bool,
3613    /// See [`FetchBoundary`]. Disarmed unless a test arms it, and off the default build
3614    /// entirely.
3615    #[cfg(test)]
3616    boundary: Arc<FetchBoundary>,
3617}
3618
3619/// One periodic-fetch cycle: every distinct git common dir this table currently
3620/// knows, not excluded, fetched with pruning, bounded to `concurrency` at once, then the
3621/// fast-forward-only auto-update over what that fetch just learned. The Generation a
3622/// finished cycle owes is [`dispatch_fetch_completion`]'s, back on the clock, so a cancelled
3623/// cycle cannot land one. `cycle_count` counts every
3624/// call, whether or not any repository had a remote to fetch, so a test driving
3625/// the dedicated thread's own tick channel can prove a tick reached this function
3626/// at all, the same proof [`Core::poll_sweep_count_for_test`] gives the poll.
3627///
3628/// Two things worth recording beside this scheduler rather than only in
3629/// [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md):
3630/// `Gone` is systematically under-reported without this cycle running, because a
3631/// remote-tracking ref only disappears once a prune removes it
3632/// ([`crate::landing`]'s `classify_unmerged_branch` doc comment), so a Repo with
3633/// `fetch.enabled = false` can carry a stale upstream indefinitely and never show
3634/// it. And the cadence itself is unresolved: `fetch.interval`'s default of five
3635/// minutes is [config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md)'s
3636/// stated number, not one this crate has measured against a real population the
3637/// way the poll interval and the generation deadline were.
3638fn run_fetch_cycle(work: &FetchCycleWork) {
3639    let FetchCycleWork {
3640        table,
3641        concurrency,
3642        cancel,
3643        network_default_branch,
3644        cycle_count,
3645        failures,
3646        auto_update_enabled,
3647        #[cfg(test)]
3648        boundary,
3649    } = work;
3650    let auto_update_enabled = *auto_update_enabled;
3651    cycle_count.fetch_add(1, Ordering::Release);
3652
3653    let common_dirs = distinct_fetchable_common_dirs(table);
3654    let failed: Mutex<Vec<(PathBuf, String)>> = Mutex::new(Vec::new());
3655    crate::fetch::run_bounded(common_dirs, (*concurrency).max(1), |common_dir| {
3656        // Nothing parks here unless a test armed this boundary.
3657        #[cfg(test)]
3658        boundary.hold();
3659        // A cancelled cycle starts no more work: the repositories this pool has not reached
3660        // yet are simply not fetched.
3661        if cancel.load(Ordering::Acquire) {
3662            return;
3663        }
3664        // Every repository's own fetch result is independent: one credential
3665        // failure or one unreachable remote must never stop the rest of the
3666        // cycle from running, so a per-repository error is swallowed here
3667        // rather than aborting the whole cycle. It is still counted below,
3668        // which is the count this cycle's own [`FetchFailures`] carries.
3669        match crate::fetch::fetch_and_prune(&common_dir, cancel) {
3670            Ok(outcome) => {
3671                // The handshake this fetch already paid for is what
3672                // [default-branch.md](https://github.com/paulchiu/repon/blob/main/docs/spec/default-branch.md)'s
3673                // "The network" means by "arrives inside a round trip already being
3674                // paid for": landed here, before `refresh.dispatch` below re-runs
3675                // the local chain, so the local answer always computes first and
3676                // this only ever supersedes it. `Unborn` and a missing answer both
3677                // leave any earlier session answer for this common dir untouched,
3678                // since neither is itself a fact worth overwriting one with.
3679                if let Some(crate::fetch::AdvertisedDefaultBranch::Branch(name)) =
3680                    outcome.advertised_default_branch
3681                {
3682                    network_default_branch
3683                        .lock()
3684                        .unwrap()
3685                        .insert(common_dir.clone(), Arc::from(name));
3686                }
3687            }
3688            Err(error) => {
3689                failed
3690                    .lock()
3691                    .unwrap()
3692                    .push((common_dir.clone(), error.to_string()));
3693            }
3694        }
3695    });
3696    // A cancelled cycle never completed, so what it reached is not
3697    // [`FetchFailures`]'s "most recently completed cycle": the previous cycle's own
3698    // count stands rather than being replaced by a partial one.
3699    if !cancel.load(Ordering::Acquire) {
3700        *failures.lock().unwrap() = FetchFailures {
3701            failed: failed.into_inner().unwrap(),
3702        };
3703    }
3704
3705    // The fast-forward-only auto-update rides this cycle rather than a timer of its
3706    // own, per `docs/spec/config.md`'s "Refresh, fetch and auto-update": it can only
3707    // ever act on what the fetch just above learned, so it runs here, after every
3708    // fetch has settled and before the Generation the clock dispatches reports the
3709    // result. Sequential rather than `fetch::run_bounded`'s own concurrency, since this
3710    // is a mutating pass over a Repo's own working tree and index, not a read against a
3711    // remote: ADR 0002's narrowest-safe-operation rule favours a simple, serial pass
3712    // over throughput a mutation has no need of.
3713    if auto_update_enabled {
3714        for repo_path in repos_eligible_for_auto_update_attempt(table) {
3715            // Re-read per Repo, not once: this is the mutating half of the cycle, so a
3716            // cancellation arriving partway through it stops the next Repo from being
3717            // written to at all.
3718            if cancel.load(Ordering::Acquire) {
3719                break;
3720            }
3721            // One Repo's ineligibility or failure never stops another's: the same
3722            // independence the fetch loop above already gives each repository.
3723            let _ = crate::auto_update::attempt(&repo_path);
3724        }
3725    }
3726}
3727
3728/// Every non-excluded Repo's own working directory, one per distinct common dir the
3729/// table currently knows: the auto-update acts on a Repo's own row, per
3730/// `docs/spec/config.md`'s "acts only on a Repo", so a Worktree sharing that common
3731/// dir is never a candidate here even though it is `distinct_fetchable_common_dirs`'s
3732/// own definition of "fetchable" for the read-only fetch above. Listed, never
3733/// operated on, mirrors the same `excluded` rule the fetch loop's own common-dir
3734/// filter applies, checked here against the Repo entity's own flag rather than any
3735/// Worktree that happens to share its common dir.
3736fn repos_eligible_for_auto_update_attempt(table: &Arc<RwLock<Table>>) -> Vec<PathBuf> {
3737    table
3738        .read()
3739        .unwrap()
3740        .entities
3741        .iter()
3742        .filter(|entity| entity.kind == Kind::Repo && !entity.excluded)
3743        .map(|entity| entity.key.path().to_path_buf())
3744        .collect()
3745}
3746
3747/// Every distinct git common dir a fetch cycle should fetch: deduplicated across
3748/// every entity sharing one (a Repo and its linked Worktrees), and skipped only
3749/// when every entity sharing that common dir is excluded
3750/// ([config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md#per-repo-entries)'s
3751/// "listed, never operated on"), since a Worktree named directly by its own path
3752/// can carry a different `excluded` than an entry it would otherwise inherit.
3753fn distinct_fetchable_common_dirs(table: &Arc<RwLock<Table>>) -> Vec<PathBuf> {
3754    let table = table.read().unwrap();
3755    let mut seen: HashMap<PathBuf, bool> = HashMap::new();
3756    for entity in &table.entities {
3757        let common_dir = entity.common_dir.to_path_buf();
3758        let operable = seen.entry(common_dir).or_insert(false);
3759        *operable = *operable || !entity.excluded;
3760    }
3761    seen.into_iter()
3762        .filter(|(_, operable)| *operable)
3763        .map(|(common_dir, _)| common_dir)
3764        .collect()
3765}
3766
3767/// [`Core::rederive_default_branches`]'s own network half: a handshake-only probe
3768/// per `common_dir`, landing a `Branch` answer on `network_default_branch` for
3769/// [`supersede_with_network`] to read back. `Unborn` and a probe failure both
3770/// leave any earlier session answer for that common dir untouched, the same
3771/// convention [`run_fetch_cycle`] already follows.
3772fn probe_network_default_branches(
3773    common_dirs: &HashSet<Arc<Path>>,
3774    network_default_branch: &Mutex<HashMap<PathBuf, Arc<str>>>,
3775) {
3776    for common_dir in common_dirs {
3777        if let Ok(Some(crate::fetch::AdvertisedDefaultBranch::Branch(name))) =
3778            crate::fetch::probe_remote_head(common_dir)
3779        {
3780            network_default_branch
3781                .lock()
3782                .unwrap()
3783                .insert(common_dir.to_path_buf(), Arc::from(name));
3784        }
3785    }
3786}
3787
3788/// One entity [`Core::rederive_default_branches`] gathered under the table lock,
3789/// everything its own spawned thread needs to re-run the default-branch chain
3790/// without holding that lock while it does: a plain struct rather than a tuple,
3791/// per this crate's own `clippy::type_complexity` budget.
3792struct RederiveCandidate {
3793    key: EntityKey,
3794    path: PathBuf,
3795    common_dir: Arc<Path>,
3796    repo: Option<Arc<gix::ThreadSafeRepository>>,
3797    override_branch: Option<String>,
3798    kind: Kind,
3799}
3800
3801/// One entity as the metadata poll sweep found it, everything gathered under one
3802/// read lock so the filesystem stats and any re-probe below run outside it.
3803struct PollCandidate {
3804    key: EntityKey,
3805    path: PathBuf,
3806    common_dir: Arc<Path>,
3807    kind: Kind,
3808    cached_repo: Option<Arc<gix::ThreadSafeRepository>>,
3809    probes_base: bool,
3810}
3811
3812/// One metadata-poll sweep ([refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
3813/// "The poll"): for every entity a Generation's dispatch would also cover (a
3814/// hidden Submodule is skipped by the same [`dispatches_kind`] rule), stats
3815/// [`poll::POLLED_GITDIR_ENTRIES`] in its own gitdir. That gitdir is the cached
3816/// [`gix::ThreadSafeRepository`] handle's own `git_dir()` where discovery cached
3817/// one (the per-worktree location a linked Worktree's `HEAD` and `index` actually
3818/// live at), or else a fresh open's `git_dir()`, the same fallback every other
3819/// probe in this module already takes for a Submodule, which discovery never
3820/// opens. A first sweep for a newly discovered entity has nothing to compare
3821/// against yet, so it only records a baseline and reports no movement.
3822///
3823/// On movement it force-stales `dirty` and `state`, the two cells with no cheap
3824/// detector, then re-runs phases A and B for that entity alone and lets their own
3825/// supersession land the fresh values; it never starts a status probe of its own.
3826/// `poll_reprobed` is cleared and refilled with exactly the keys this call
3827/// actually re-ran, in the order it found them moved. `poll_sweep_count` counts
3828/// every call, whether or not anything moved, so a test can prove a real tick
3829/// reached this function at all.
3830fn run_poll_sweep(
3831    table: &Arc<RwLock<Table>>,
3832    overrides: &Arc<Vec<ResolvedOverride>>,
3833    show_submodules: &Arc<AtomicBool>,
3834    poll_reprobed: &Arc<Mutex<Vec<EntityKey>>>,
3835    poll_sweep_count: &Arc<AtomicUsize>,
3836    network_default_branch: &Mutex<HashMap<PathBuf, Arc<str>>>,
3837) {
3838    poll_sweep_count.fetch_add(1, Ordering::Release);
3839    poll_reprobed.lock().unwrap().clear();
3840    let show_submodules = show_submodules.load(Ordering::Acquire);
3841
3842    let candidates: Vec<PollCandidate> = {
3843        let table = table.read().unwrap();
3844        table
3845            .entities
3846            .iter()
3847            .filter(|entity| dispatches_kind(entity.kind, show_submodules))
3848            .map(|entity| PollCandidate {
3849                key: entity.key.clone(),
3850                path: entity.key.path().to_path_buf(),
3851                common_dir: Arc::clone(&entity.common_dir),
3852                kind: entity.kind,
3853                cached_repo: table.repos.get(&entity.key).cloned(),
3854                probes_base: entity.probes_base(),
3855            })
3856            .collect()
3857    };
3858
3859    for candidate in candidates {
3860        // A fresh open, never cached across sweeps: this is the same cost every
3861        // other probe in this module already pays for an entity discovery left
3862        // no handle for (always true of a Submodule), and reusing the handle it
3863        // returns for the re-probe below saves a second open on the one path
3864        // that actually detected movement.
3865        let opened;
3866        let repo = match candidate.cached_repo.as_deref() {
3867            Some(repo) => Some(repo),
3868            None => match git::open_thread_safe(&candidate.path) {
3869                Ok(repo) => {
3870                    opened = repo;
3871                    Some(&opened)
3872                }
3873                Err(_) => None,
3874            },
3875        };
3876        let gitdir = repo
3877            .map(|repo| repo.git_dir().to_path_buf())
3878            .unwrap_or_else(|| candidate.common_dir.to_path_buf());
3879
3880        let current = poll::fingerprint(&gitdir);
3881        let moved = {
3882            let mut table = table.write().unwrap();
3883            let previous = table
3884                .poll_fingerprints
3885                .insert(candidate.key.clone(), current);
3886            previous.is_some_and(|previous| poll::moved(&previous, &current))
3887        };
3888        if !moved {
3889            continue;
3890        }
3891
3892        {
3893            let mut table = table.write().unwrap();
3894            if let Some(&idx) = table.index.get(&candidate.key) {
3895                table.entities[idx].force_stale_status_cells();
3896            }
3897        }
3898
3899        let override_branch = find_entry(overrides, &candidate.path, &candidate.common_dir)
3900            .and_then(|entry| entry.default_branch.clone());
3901        let never_cancelled = AtomicBool::new(false);
3902        let chain_cache: ChainFactsCache = Mutex::new(HashMap::new());
3903        let chain_reads = AtomicUsize::new(0);
3904
3905        let branch_outcome = probe_branch(&candidate.path, repo, candidate.kind, &never_cancelled);
3906        let sync_outcome = probe_sync(
3907            &candidate.path,
3908            repo,
3909            branch_outcome.as_ref().map(|(settled, ..)| settled),
3910            candidate.kind,
3911            &never_cancelled,
3912        );
3913        let default_branch_outcome = probe_default_branch_memoised(
3914            &candidate.path,
3915            repo,
3916            &candidate.common_dir,
3917            DefaultBranchHints {
3918                override_branch: override_branch.as_deref(),
3919                network_branch: network_branch_for(network_default_branch, &candidate.common_dir)
3920                    .as_deref(),
3921            },
3922            candidate.kind,
3923            &never_cancelled,
3924            &ChainFactsMemo {
3925                cache: &chain_cache,
3926                reads: &chain_reads,
3927            },
3928        );
3929        let base_outcome = if candidate.probes_base {
3930            probe_base(
3931                &candidate.path,
3932                repo,
3933                branch_outcome.as_ref().map(|(settled, ..)| settled),
3934                default_branch_outcome.as_ref().map(|r| &r.settled),
3935                &never_cancelled,
3936            )
3937        } else {
3938            None
3939        };
3940
3941        let generation = {
3942            let mut table = table.write().unwrap();
3943            table.generation += 1;
3944            Generation::new(table.generation)
3945        };
3946        apply_cheap_probe_outcomes(
3947            table,
3948            &candidate.key,
3949            generation,
3950            CheapProbeOutcomes {
3951                branch: branch_outcome,
3952                sync: sync_outcome,
3953                base: base_outcome,
3954                default_branch: default_branch_outcome,
3955            },
3956        );
3957        poll_reprobed.lock().unwrap().push(candidate.key);
3958    }
3959}
3960
3961/// Cancels every probe currently in flight and drops the table's record of them,
3962/// which is what suspension does: the in-flight Generation is cancelled outright
3963/// rather than left to finish. Releases a pending `settle` too, since nothing is
3964/// now going to finish it.
3965fn cancel_in_flight(table: &Arc<RwLock<Table>>, settle_gate: &Arc<SettleGate>) {
3966    let mut table = table.write().unwrap();
3967    let cancelled = table.in_flight.len();
3968    for in_flight in table.in_flight.values() {
3969        in_flight.cancel.store(true, Ordering::Release);
3970    }
3971    table.in_flight.clear();
3972    table.generation_started_at.clear();
3973    drop(table);
3974    if cancelled > 0 {
3975        complete_many(settle_gate, cancelled);
3976    }
3977}
3978
3979/// A `Cell<T>`'s in-flight and timeout behaviour, uniform across every payload
3980/// type `EntityState` carries, so [`sweep_deadline`] can sweep every cell
3981/// through one array rather than one hand-written branch per cell: a cell only
3982/// ever times out if it was actually marked in flight, which is what lets the
3983/// sweep apply to all of them without asking what `Kind` owns them.
3984trait TimeoutableCell {
3985    fn is_in_flight(&self) -> bool;
3986    /// Settles this cell `Unknown(TimedOut)` for `generation`, subject to the
3987    /// same supersession `Cell::settle` already enforces.
3988    fn time_out(&mut self, generation: Generation);
3989}
3990
3991impl<T> TimeoutableCell for Cell<T> {
3992    fn is_in_flight(&self) -> bool {
3993        Cell::is_in_flight(self)
3994    }
3995
3996    fn time_out(&mut self, generation: Generation) {
3997        self.settle(generation, Settled::Unknown(Unknown::TimedOut));
3998    }
3999}
4000
4001/// Marks every cell still in flight past its own Generation's deadline `Unknown`,
4002/// per [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md):
4003/// there is no per-cell timeout, only this sweep, and it never interrupts the
4004/// underlying probe, which keeps running; the sweep only stops waiting on it.
4005fn sweep_deadline(table: &Arc<RwLock<Table>>, settle_gate: &Arc<SettleGate>, deadline: Duration) {
4006    let mut table = table.write().unwrap();
4007    let now = Instant::now();
4008    let mut timed_out = Vec::new();
4009    for (key, in_flight) in table.in_flight.iter() {
4010        let started = table
4011            .generation_started_at
4012            .get(&in_flight.generation)
4013            .copied()
4014            .unwrap_or(now);
4015        if now.duration_since(started) >= deadline {
4016            timed_out.push((key.clone(), Generation::new(in_flight.generation)));
4017        }
4018    }
4019    for (key, generation) in &timed_out {
4020        if let Some(&idx) = table.index.get(key) {
4021            // Exhaustive: a Cell added to `EntityState` later must be named here
4022            // or this fails to compile, so it cannot silently time out never.
4023            let EntityState {
4024                key: _,
4025                name: _,
4026                common_dir: _,
4027                kind: _,
4028                branch,
4029                sync,
4030                base,
4031                dirty,
4032                state,
4033                default_branch,
4034                diagnostics: _,
4035                last_action: _,
4036                presence: _,
4037                excluded: _,
4038                in_progress_operation: _,
4039                recent_commits: _,
4040            } = &mut table.entities[idx];
4041            let cells: [&mut dyn TimeoutableCell; 6] =
4042                [branch, sync, base, dirty, state, default_branch];
4043            for cell in cells {
4044                // Only a cell actually marked in flight times out: a Repo's or a
4045                // Submodule's `state` (never probed, by `EntityState::probes_state`)
4046                // and any cell no probe yet reaches (`sync`, `base`) are never in
4047                // flight, so this never overwrites them with a lie.
4048                if cell.is_in_flight() {
4049                    cell.time_out(*generation);
4050                }
4051            }
4052        }
4053        table.in_flight.remove(key);
4054    }
4055    let live_generations: std::collections::HashSet<u64> =
4056        table.in_flight.values().map(|f| f.generation).collect();
4057    table
4058        .generation_started_at
4059        .retain(|generation, _| live_generations.contains(generation));
4060    drop(table);
4061    if !timed_out.is_empty() {
4062        complete_many(settle_gate, timed_out.len());
4063    }
4064}
4065
4066/// Marks the cells this Generation's dispatch is about to probe as in flight,
4067/// via an exhaustive destructure of `EntityState`'s cells: a cell added later
4068/// must be named here (`_` if it is not yet probed) or this fails to compile,
4069/// which is what stops a cell [`apply_probe_outcome`] settles from going
4070/// in-flight silently forgotten, and reading wrong on `is_in_flight` for the
4071/// whole dispatch.
4072fn begin_probes(entity: &mut EntityState) {
4073    let probes_state = entity.probes_state();
4074    let EntityState {
4075        key: _,
4076        name: _,
4077        common_dir: _,
4078        kind: _,
4079        branch,
4080        sync: _,
4081        base: _,
4082        dirty,
4083        state,
4084        default_branch,
4085        diagnostics: _,
4086        last_action: _,
4087        presence: _,
4088        excluded: _,
4089        in_progress_operation: _,
4090        recent_commits: _,
4091    } = entity;
4092    branch.begin_probe();
4093    default_branch.begin_probe();
4094    // Phase C runs against every dispatched entity, Repo, Worktree or Submodule alike:
4095    // refresh.md's "Scope and order" makes scope never a partial dial, so `dirty` carries
4096    // no `probes_state`-style condition of its own.
4097    dirty.begin_probe();
4098    // Only a Worktree's `state` is ever (re)probed: a Repo's is `NotApplicable`
4099    // and a Submodule's is `Unknown` from construction, neither ever revisited
4100    // (`EntityState::probes_state`), and marking either in flight here would
4101    // leave it in-flight forever, since nothing would ever call `settle` on it.
4102    if probes_state {
4103        state.begin_probe();
4104    }
4105}
4106
4107/// What [`Core::try_settle`] waits on, and the one lock every count it waits on lives
4108/// under, so a settle can never observe one of them without the other.
4109type SettleGate = (Mutex<SettleCounts>, Condvar);
4110
4111/// The two outstanding counts [`Core::try_settle`] blocks on.
4112///
4113/// `dispatches` exists because a Generation reserves its number on the calling
4114/// thread and does everything else on one of its own: between those two moments
4115/// `probes` has not been raised yet, so a settle reading `probes` alone would
4116/// return on a table nothing has started writing to.
4117#[derive(Default)]
4118struct SettleCounts {
4119    /// Dispatched entities that have yet to land a phase C/D outcome, be cancelled
4120    /// or time out.
4121    probes: usize,
4122    /// Generations whose number is reserved and whose own dispatch body has not
4123    /// finished raising `probes` for what it dispatches.
4124    dispatches: usize,
4125}
4126
4127impl SettleCounts {
4128    /// Whether nothing this `Core` has started is still owed to the table.
4129    ///
4130    /// An exhaustive destructure: a third count added to this struct must be named here
4131    /// or this fails to compile, rather than being silently left out of what a settle
4132    /// waits for.
4133    fn is_settled(&self) -> bool {
4134        let SettleCounts { probes, dispatches } = self;
4135        *probes == 0 && *dispatches == 0
4136    }
4137}
4138
4139/// Records one reserved Generation as owed, before the thread that will dispatch
4140/// it has started. Paired with exactly one [`finish_dispatch`].
4141fn begin_dispatch(settle_gate: &SettleGate) {
4142    let (lock, _cvar) = settle_gate;
4143    lock.lock().unwrap().dispatches += 1;
4144}
4145
4146/// Releases the debt [`begin_dispatch`] recorded, once that Generation's own
4147/// dispatch has raised `probes` for everything it dispatched.
4148fn finish_dispatch(settle_gate: &SettleGate) {
4149    let (lock, cvar) = settle_gate;
4150    let mut counts = lock.lock().unwrap();
4151    counts.dispatches = counts.dispatches.saturating_sub(1);
4152    drop(counts);
4153    // Unconditionally, unlike `complete_many`: a waiter watching `dispatches` alone
4154    // would never be woken by a change that leaves `probes` outstanding.
4155    cvar.notify_all();
4156}
4157
4158fn begin_probes_owed(settle_gate: &SettleGate, owed: usize) {
4159    let (lock, _cvar) = settle_gate;
4160    lock.lock().unwrap().probes += owed;
4161}
4162
4163fn complete_one(settle_gate: &SettleGate) {
4164    complete_many(settle_gate, 1);
4165}
4166
4167fn complete_many(settle_gate: &SettleGate, finished: usize) {
4168    let (lock, cvar) = settle_gate;
4169    let mut counts = lock.lock().unwrap();
4170    counts.probes = counts.probes.saturating_sub(finished);
4171    if counts.is_settled() {
4172        cvar.notify_all();
4173    }
4174}
4175
4176/// Reads one entity's HEAD shape, or `None` if `cancel` was already set before the
4177/// read started. The one check this crate makes today: `git::head_shape` itself has
4178/// no interruption point to check `cancel` against mid-read, unlike the later
4179/// phases [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)
4180/// describes gix taking it through directly.
4181///
4182/// `repo` is the entity's cached thread-safe handle when discovery already opened
4183/// one; this task derives its own `Repository` from it via `to_thread_local`
4184/// rather than sharing that derived handle with any other task. `None` (a
4185/// Submodule, or a boundary discovery could not open) falls back to opening fresh,
4186/// which is where an unreadable repository's `ProbeError::Open` still surfaces.
4187///
4188/// Also reads the entity's in-progress git operation and recent commits off the
4189/// same open handle, since both ride along at negligible extra cost
4190/// ([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)).
4191/// Neither is a Cell in its own right, so both travel with the branch read they
4192/// were taken alongside rather than getting independent supersession of their
4193/// own; [`EntityState::apply_branch_probe`] is where that pairing lands.
4194const RECENT_COMMITS_LIMIT: usize = 5;
4195
4196/// What an open-repository failure means for `kind`: a genuine Probe error for a Repo or a
4197/// Worktree, but for a Submodule the far more common, expected shape of "never `git
4198/// submodule update --init`-ed" ([discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md)'s
4199/// "The Submodule row": "An uninitialised Submodule is a row with every cell blank and `?`
4200/// in the gutter"). Exhaustive over `Kind` rather than a wildcard, so a fourth variant added
4201/// later must decide which grade it gets rather than silently inheriting one.
4202fn submodule_open_failure<T>(kind: Kind, error: git::ProbeError) -> Settled<T> {
4203    match kind {
4204        Kind::Repo | Kind::Worktree => Settled::Failed(error),
4205        Kind::Submodule => Settled::Unknown(Unknown::SubmoduleUninitialized),
4206    }
4207}
4208
4209fn probe_branch(
4210    path: &Path,
4211    repo: Option<&gix::ThreadSafeRepository>,
4212    kind: Kind,
4213    cancel: &AtomicBool,
4214) -> Option<(
4215    Settled<Head>,
4216    Option<git::InProgressOperation>,
4217    Vec<git::RecentCommit>,
4218)> {
4219    if cancel.load(Ordering::Acquire) {
4220        return None;
4221    }
4222    let opened;
4223    let repo = match repo {
4224        Some(repo) => repo,
4225        None => match git::open_thread_safe(path) {
4226            Ok(repo) => {
4227                opened = repo;
4228                &opened
4229            }
4230            Err(error) => return Some((submodule_open_failure(kind, error), None, Vec::new())),
4231        },
4232    };
4233    let local = repo.to_thread_local();
4234    let settled = match git::head_shape(&local) {
4235        Ok(head) => Settled::Known {
4236            value: head,
4237            at: Timestamp::now(),
4238            stale: false,
4239        },
4240        Err(error) => Settled::Failed(error),
4241    };
4242    let in_progress = git::in_progress_operation(&local);
4243    let recent = git::recent_commits(&local, RECENT_COMMITS_LIMIT);
4244    Some((settled, in_progress, recent))
4245}
4246
4247/// Phase B's comparison: the `sync` cell's ahead/behind counts against the
4248/// branch's upstream, for every entity whose HEAD carries a branch, every
4249/// Generation ([refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)).
4250/// `None` if `cancel` was already set, or if `branch_settled` is itself `None`
4251/// because the branch probe it depends on was cancelled first. A `Failed` branch
4252/// read fails `sync` the same way, rather than guessing at a HEAD shape the
4253/// branch probe itself could not read; every other shape (a live branch, a
4254/// detached or unborn HEAD) is handed to [`git::resolve_sync`], which is where
4255/// "no branch" and "no remote at all" settle to their own values. `repo` follows
4256/// the same cached-handle convention as [`probe_branch`].
4257fn probe_sync(
4258    path: &Path,
4259    repo: Option<&gix::ThreadSafeRepository>,
4260    branch_settled: Option<&Settled<Head>>,
4261    kind: Kind,
4262    cancel: &AtomicBool,
4263) -> Option<Settled<SyncState>> {
4264    if cancel.load(Ordering::Acquire) {
4265        return None;
4266    }
4267    let head = match branch_settled? {
4268        Settled::Known {
4269            value,
4270            at: _,
4271            stale: _,
4272        } => Some(value),
4273        Settled::Failed(error) => return Some(Settled::Failed(error.clone())),
4274        Settled::Unknown(_) | Settled::NotApplicable => None,
4275    };
4276    let opened;
4277    let repo = match repo {
4278        Some(repo) => repo,
4279        None => match git::open_thread_safe(path) {
4280            Ok(repo) => {
4281                opened = repo;
4282                &opened
4283            }
4284            Err(error) => return Some(submodule_open_failure(kind, error)),
4285        },
4286    };
4287    let local = repo.to_thread_local();
4288    let settled = match git::resolve_sync(&local, head) {
4289        Ok(value) => Settled::Known {
4290            value,
4291            at: Timestamp::now(),
4292            stale: false,
4293        },
4294        Err(error) => Settled::Failed(error),
4295    };
4296    Some(settled)
4297}
4298
4299/// Phase B's second rev-walk: the `base` cell's count behind the resolved default
4300/// branch, for every entity [`crate::base::probe`] does not exempt
4301/// ([default-branch.md](https://github.com/paulchiu/repon/blob/main/docs/spec/default-branch.md)'s
4302/// "The two behind counts"). `None` if `cancel` was already set, or if either
4303/// `branch_settled` or `default_branch_settled` is itself `None` because the probe
4304/// it depends on was cancelled first; [`crate::base::probe`] itself always settles
4305/// once reached. A `Failed` or not-yet-`Known` `branch_settled` carries no commit to
4306/// compare, so it is treated the same "nothing to settle yet" way, except a genuine
4307/// `Failed` branch read, which propagates onto `base` too: a row whose HEAD could
4308/// not be read has nothing to compute behind anything. `repo` follows the same
4309/// cached-handle convention as [`probe_branch`].
4310fn probe_base(
4311    path: &Path,
4312    repo: Option<&gix::ThreadSafeRepository>,
4313    branch_settled: Option<&Settled<Head>>,
4314    default_branch_settled: Option<&Settled<DefaultBranch>>,
4315    cancel: &AtomicBool,
4316) -> Option<Settled<u32>> {
4317    if cancel.load(Ordering::Acquire) {
4318        return None;
4319    }
4320    let head = match branch_settled? {
4321        Settled::Known {
4322            value,
4323            at: _,
4324            stale: _,
4325        } => value,
4326        Settled::Failed(error) => return Some(Settled::Failed(error.clone())),
4327        Settled::Unknown(_) | Settled::NotApplicable => return None,
4328    };
4329    let default_branch_settled = default_branch_settled?;
4330    let opened;
4331    let repo = match repo {
4332        Some(repo) => repo,
4333        None => match git::open_thread_safe(path) {
4334            Ok(repo) => {
4335                opened = repo;
4336                &opened
4337            }
4338            Err(error) => return Some(Settled::Failed(error)),
4339        },
4340    };
4341    let local = repo.to_thread_local();
4342    Some(base::probe(&local, head, default_branch_settled))
4343}
4344
4345/// Phase C's typed counts, dispatched over every entity in a Generation with no
4346/// scoping of its own: [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
4347/// "Scope and order" makes scope never a partial dial, only order, so this carries
4348/// no visibility filter and no cost heuristic; the caller's dispatch order is the
4349/// only dial, expressed entirely by the position `path` already holds in
4350/// `Core::refresh`'s `order`. `None` if `cancel` was already set before the read
4351/// started; unlike the cheaper phases above, `cancel` is also handed straight
4352/// into gix, which checks it while the read is under way rather than only before
4353/// it starts, since this is the one phase long enough for that to matter.
4354fn probe_status(
4355    path: &Path,
4356    repo: Option<&gix::ThreadSafeRepository>,
4357    kind: Kind,
4358    cancel: &Arc<AtomicBool>,
4359) -> Option<Settled<DirtyCounts>> {
4360    if cancel.load(Ordering::Acquire) {
4361        return None;
4362    }
4363    let opened;
4364    let repo = match repo {
4365        Some(repo) => repo,
4366        None => match git::open_thread_safe(path) {
4367            Ok(repo) => {
4368                opened = repo;
4369                &opened
4370            }
4371            Err(error) => return Some(submodule_open_failure(kind, error)),
4372        },
4373    };
4374    let local = repo.to_thread_local();
4375    classify_status_result(git::dirty_counts(&local, Arc::clone(cancel)), cancel)
4376}
4377
4378/// Folds [`git::dirty_counts`]'s result into [`probe_status`]'s outcome. Split out as its own
4379/// function so the one case a live probe cannot reproduce deterministically, cancellation
4380/// observed genuinely mid-read, is directly testable: gix's own error carries no typed "this
4381/// was cancelled" case (its interrupt point reports through a bare `io::Error`, same as any
4382/// other I/O failure), so `cancel` itself, which this task alone owns for the duration of its
4383/// probe, is the answer. An error alongside a cancel flag now set is what an interruption
4384/// 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
4385/// precedent is that interrupted work is dropped rather than settled `Failed`, the same as
4386/// every cheaper phase's pre-check already does.
4387///
4388/// gix checks `should_interrupt` per index entry rather than before every read, so a walk
4389/// short enough to run out of entries to check between the flag flipping and the walk
4390/// finishing can still return `Ok`. `cancel` is re-checked on that arm too, and an `Ok` that
4391/// raced ahead of it is dropped the same way an `Err` alongside it already is, so a cancelled
4392/// generation never lands a value regardless of which side of that race gix landed on.
4393fn classify_status_result(
4394    result: Result<DirtyCounts, git::ProbeError>,
4395    cancel: &AtomicBool,
4396) -> Option<Settled<DirtyCounts>> {
4397    match result {
4398        Ok(_) if cancel.load(Ordering::Acquire) => None,
4399        Ok(value) => Some(Settled::Known {
4400            value,
4401            at: Timestamp::now(),
4402            stale: false,
4403        }),
4404        Err(_) if cancel.load(Ordering::Acquire) => None,
4405        Err(error) => Some(Settled::Failed(error)),
4406    }
4407}
4408
4409/// Rung 1's config override and the network's session-held answer, bundled into
4410/// one argument the way [`ChainFactsMemo`] bundles its own two: both
4411/// [`probe_default_branch`] and [`probe_default_branch_memoised`] already sit at
4412/// clippy's argument limit, and the two hints always travel together, one per
4413/// dispatched entity.
4414struct DefaultBranchHints<'a> {
4415    /// Matched by common dir before this is called; `None` when no `[[repo]]`
4416    /// entry names this entity's own default branch.
4417    override_branch: Option<&'a str>,
4418    /// [`network_branch_for`]'s own answer for this entity's common dir; `None`
4419    /// until a fetch handshake or [`Core::rederive_default_branches`] has
4420    /// actually reached that remote this session.
4421    network_branch: Option<&'a str>,
4422}
4423
4424/// [`Core::network_default_branch`]'s own lookup, by common dir: a small helper
4425/// so every probe site reads it the same way rather than repeating the lock and
4426/// clone.
4427fn network_branch_for(
4428    network_default_branch: &Mutex<HashMap<PathBuf, Arc<str>>>,
4429    common_dir: &Path,
4430) -> Option<Arc<str>> {
4431    network_default_branch
4432        .lock()
4433        .unwrap()
4434        .get(common_dir)
4435        .cloned()
4436}
4437
4438/// Supersedes `resolution`'s own settled value with `network_branch`, if given,
4439/// per [default-branch.md](https://github.com/paulchiu/repon/blob/main/docs/spec/default-branch.md)'s
4440/// "The network": never the primary source, so `resolution` is always the local
4441/// chain's own complete answer, computed unconditionally by the caller before
4442/// this ever runs. This is the one place ADR 0012's stated ceiling is actually
4443/// closed: on a Repo where rung 2 and rung 3 agree and are both wrong (the
4444/// hidden-Submodule case the ADR measures), no local rung can ever correct
4445/// itself, and only a reachable remote's own answer, landed here, can.
4446fn supersede_with_network(
4447    mut resolution: default_branch::Resolution,
4448    network_branch: Option<&str>,
4449) -> default_branch::Resolution {
4450    if let Some(name) = network_branch {
4451        resolution.settled = Settled::Known {
4452            value: DefaultBranch::new(name.into()),
4453            at: Timestamp::now(),
4454            stale: false,
4455        };
4456    }
4457    resolution
4458}
4459
4460/// Runs the four-rung default branch chain against `path`, or `None` if `cancel`
4461/// was already set before the read started, then [`supersede_with_network`]s the
4462/// result with `hints.network_branch`.
4463///
4464/// `repo` follows the same cached-handle convention as [`probe_branch`]: `None`
4465/// falls back to opening fresh, which is where an unreadable repository surfaces
4466/// as [`default_branch::Resolution::failed`] rather than a settled Unknown.
4467fn probe_default_branch(
4468    path: &Path,
4469    repo: Option<&gix::ThreadSafeRepository>,
4470    hints: DefaultBranchHints<'_>,
4471    kind: Kind,
4472    cancel: &AtomicBool,
4473) -> Option<default_branch::Resolution> {
4474    if cancel.load(Ordering::Acquire) {
4475        return None;
4476    }
4477    let opened;
4478    let repo = match repo {
4479        Some(repo) => repo,
4480        None => match git::open_thread_safe(path) {
4481            Ok(repo) => {
4482                opened = repo;
4483                &opened
4484            }
4485            Err(error) => {
4486                return Some(match kind {
4487                    Kind::Repo | Kind::Worktree => default_branch::Resolution::failed(error),
4488                    Kind::Submodule => default_branch::Resolution::submodule_uninitialized(),
4489                });
4490            }
4491        },
4492    };
4493    Some(supersede_with_network(
4494        default_branch::resolve(&repo.to_thread_local(), hints.override_branch),
4495        hints.network_branch,
4496    ))
4497}
4498
4499/// Coordinates one common dir's Outstanding entities so every one of their own
4500/// merge bases against the default branch is known before the shared scan
4501/// runs, per [default-branch.md](https://github.com/paulchiu/repon/blob/main/docs/spec/default-branch.md)'s
4502/// requirement that the bound be *collected*, not computed lazily on whichever
4503/// entity happens to arrive first. `remaining` starts at the number of
4504/// dispatched entities in this common dir that will call [`GateReport::report`]
4505/// this Generation (every entity `landing::probe` runs for, whether it settles
4506/// immediately or reaches patch equivalence); `deepest` blocks until all of
4507/// them have, then folds their contributed merge bases pairwise via
4508/// [`git::checked_merge_base`] so the result is an ancestor of (at least as
4509/// deep as) every one of them, and memoises that answer for every later caller
4510/// sharing this dir.
4511struct BoundGate {
4512    state: Mutex<BoundGateState>,
4513    condvar: Condvar,
4514    bound: OnceLock<Option<gix::ObjectId>>,
4515}
4516
4517struct BoundGateState {
4518    remaining: usize,
4519    candidates: Vec<gix::ObjectId>,
4520}
4521
4522impl BoundGate {
4523    fn new(remaining: usize) -> Self {
4524        Self {
4525            state: Mutex::new(BoundGateState {
4526                remaining,
4527                candidates: Vec::new(),
4528            }),
4529            condvar: Condvar::new(),
4530            bound: OnceLock::new(),
4531        }
4532    }
4533
4534    /// One entity's contribution: `Some(base)` when it reached patch
4535    /// equivalence and had a merge base to offer, `None` otherwise (it settled
4536    /// by ancestry, was cancelled, failed to read, or shared no history with
4537    /// the default branch at all). Wakes every task blocked in [`Self::deepest`]
4538    /// once every entity counted in `remaining` has reported.
4539    fn report(&self, candidate: Option<gix::ObjectId>) {
4540        let mut state = self.state.lock().unwrap();
4541        if let Some(candidate) = candidate {
4542            state.candidates.push(candidate);
4543        }
4544        state.remaining -= 1;
4545        if state.remaining == 0 {
4546            self.condvar.notify_all();
4547        }
4548    }
4549
4550    /// Blocks until every entity sharing this common dir has reported, then
4551    /// returns the deepest merge base among their contributions (`None` if
4552    /// none contributed one, so the scan is left unbounded). The candidates are
4553    /// taken and folded into `bound` inside the same critical section, so
4554    /// whichever call is first to finish waiting is guaranteed to be the one
4555    /// that computes the memoised answer from them; computing outside the lock
4556    /// would let a later call, left holding an empty list by
4557    /// [`std::mem::take`], win the race into [`OnceLock::get_or_init`] and
4558    /// memoise `None` regardless of what the first call actually contributed.
4559    fn deepest(&self, repo: &gix::Repository) -> Option<gix::ObjectId> {
4560        let mut state = self.state.lock().unwrap();
4561        while state.remaining != 0 {
4562            state = self.condvar.wait(state).unwrap();
4563        }
4564        let candidates = std::mem::take(&mut state.candidates);
4565        *self
4566            .bound
4567            .get_or_init(|| deepest_merge_base(repo, &candidates))
4568    }
4569}
4570
4571/// Folds `candidates` pairwise via [`git::checked_merge_base`] into the one
4572/// deepest among them: when two candidates are ancestor and descendant, their
4573/// own merge base is exactly the ancestor, so the fold converges on whichever
4574/// candidate is deepest; two on unrelated lines of history fold to their own
4575/// common ancestor instead, which is still a safe (if not the tightest
4576/// possible) lower bound for the scan.
4577fn deepest_merge_base(
4578    repo: &gix::Repository,
4579    candidates: &[gix::ObjectId],
4580) -> Option<gix::ObjectId> {
4581    let mut candidates = candidates.iter().copied();
4582    let mut deepest = candidates.next()?;
4583    for candidate in candidates {
4584        deepest = git::checked_merge_base(repo, deepest, candidate)
4585            .ok()
4586            .flatten()
4587            .unwrap_or(deepest);
4588    }
4589    Some(deepest)
4590}
4591
4592/// Reports exactly once to a [`BoundGate`], on drop if [`Self::report_now`] was
4593/// never called explicitly: every exit path out of [`probe_worktree_state`]
4594/// and [`probe_patch_equivalence`] must release its common dir's gate, since a
4595/// path that forgot to would deadlock every sibling still waiting in
4596/// [`BoundGate::deepest`].
4597struct GateReport<'a> {
4598    gate: &'a BoundGate,
4599    reported: bool,
4600}
4601
4602impl<'a> GateReport<'a> {
4603    fn new(gate: &'a BoundGate) -> Self {
4604        Self {
4605            gate,
4606            reported: false,
4607        }
4608    }
4609
4610    /// Reports `candidate` immediately rather than waiting for drop: the one
4611    /// path that goes on to call [`BoundGate::deepest`] must report its own
4612    /// contribution first, or it would wait on a count that can never reach
4613    /// zero without its own report.
4614    fn report_now(&mut self, candidate: Option<gix::ObjectId>) {
4615        self.gate.report(candidate);
4616        self.reported = true;
4617    }
4618}
4619
4620impl Drop for GateReport<'_> {
4621    fn drop(&mut self) {
4622        if !self.reported {
4623            self.gate.report(None);
4624        }
4625    }
4626}
4627
4628/// The per-common-dir patch-equivalence memo plumbing, bundled into one
4629/// argument so [`probe_worktree_state`] and [`probe_patch_equivalence`] each
4630/// take it as a single parameter rather than three loose ones.
4631struct PatchEquivalenceMemo<'a> {
4632    cache: &'a PatchIdentityCache,
4633    reads: &'a AtomicUsize,
4634    /// Where [`probe_patch_equivalence`] records the bound it actually passed to
4635    /// [`patch_equivalence::scan_default_branch`], for `Core::patch_scan_bounds_for_test`.
4636    scan_bounds: &'a Mutex<Vec<Option<gix::ObjectId>>>,
4637}
4638
4639/// Runs both of Phase D's passes for one Worktree entity: `landing::probe`'s
4640/// ancestry check, then, only when it answers `Outstanding`,
4641/// [`probe_patch_equivalence`]'s content check. `None` if `cancel` was already
4642/// set, or if `default_branch_settled` is itself `None` because the
4643/// default-branch probe it depends on was cancelled first. `repo` follows the
4644/// same cached-handle convention as [`probe_branch`]. `report` always reports
4645/// exactly once to this entity's common dir's `BoundGate`, on every path
4646/// through this function, via its own `Drop`.
4647fn probe_worktree_state(
4648    path: &Path,
4649    repo: Option<&gix::ThreadSafeRepository>,
4650    default_branch_settled: Option<&Settled<DefaultBranch>>,
4651    common_dir: &Arc<Path>,
4652    cancel: &AtomicBool,
4653    memo: &PatchEquivalenceMemo<'_>,
4654    report: &mut GateReport<'_>,
4655) -> Option<Settled<WorktreeState>> {
4656    if cancel.load(Ordering::Acquire) {
4657        return None;
4658    }
4659    let default_branch_settled = default_branch_settled?;
4660    let opened;
4661    let repo = match repo {
4662        Some(repo) => repo,
4663        None => match git::open_thread_safe(path) {
4664            Ok(repo) => {
4665                opened = repo;
4666                &opened
4667            }
4668            Err(error) => return Some(Settled::Failed(error)),
4669        },
4670    };
4671    let local = repo.to_thread_local();
4672    match landing::probe(&local, default_branch_settled) {
4673        landing::Outcome::Settle(settled) => Some(settled),
4674        landing::Outcome::Outstanding(outstanding) => {
4675            probe_patch_equivalence(&local, &outstanding, common_dir, cancel, memo, report)
4676        }
4677    }
4678}
4679
4680/// Phase D's expensive half, reached only when `landing::probe` answered
4681/// `Outstanding`: this is the seam that keeps patch equivalence off every
4682/// entity ancestry already settled. Reports the merge base the first pass
4683/// already walked to `report` *before* asking for the shared scan, then checks
4684/// patch equivalence against `memo`'s per-common-dir cache, per
4685/// [default-branch.md](https://github.com/paulchiu/repon/blob/main/docs/spec/default-branch.md)'s
4686/// "Two passes on screen" and its bound on the scan's own depth.
4687fn probe_patch_equivalence(
4688    repo: &gix::Repository,
4689    outstanding: &landing::Outstanding,
4690    common_dir: &Arc<Path>,
4691    cancel: &AtomicBool,
4692    memo: &PatchEquivalenceMemo<'_>,
4693    report: &mut GateReport<'_>,
4694) -> Option<Settled<WorktreeState>> {
4695    if cancel.load(Ordering::Acquire) {
4696        return None;
4697    }
4698    let landing::Outstanding {
4699        entity_tip,
4700        default_tip,
4701        merge_base,
4702    } = *outstanding;
4703    let Some(merge_base) = merge_base else {
4704        // No shared history at all: a real negative the first pass already
4705        // established. This entity needs no bound and no shared scan, so it
4706        // reports and settles without waiting on either; the empty set is never
4707        // actually consulted, since `probe` returns `Active` for a `None` merge
4708        // base before it would look.
4709        report.report_now(None);
4710        return Some(patch_equivalence::probe(
4711            repo,
4712            entity_tip,
4713            None,
4714            &patch_equivalence::PatchIdentitySet::new(),
4715        ));
4716    };
4717    // Reported now, not left to `report`'s `Drop`: the wait just below blocks
4718    // on every entity sharing this common dir having reported, this entity
4719    // included, so reporting late here would deadlock on its own wait.
4720    report.report_now(Some(merge_base));
4721    let bound = report.gate.deepest(repo);
4722    let shared = match patch_identities_for(memo.cache, common_dir, memo.reads, || {
4723        // Recorded here, inside the closure that only ever runs for whichever
4724        // entity's task is first to reach `patch_identities_for` for this common
4725        // dir, so this is the bound the one real `scan_default_branch` call for
4726        // it actually used, not a value a test recomputes independently.
4727        memo.scan_bounds.lock().unwrap().push(bound);
4728        patch_equivalence::scan_default_branch(repo, default_tip, bound)
4729    }) {
4730        Ok(shared) => shared,
4731        Err(error) => return Some(Settled::Failed(error)),
4732    };
4733    Some(patch_equivalence::probe(
4734        repo,
4735        entity_tip,
4736        Some(merge_base),
4737        &shared,
4738    ))
4739}
4740
4741/// One Generation's patch-equivalence memo: at most one
4742/// [`patch_equivalence::PatchIdentitySet`] per common dir, shared by every
4743/// dispatched entity `landing::probe` answered `Outstanding` for. Built fresh
4744/// in [`Core::refresh`] and dropped once every task from that dispatch has
4745/// finished, the same lifetime `ChainFactsCache` has. The computed `Result` is
4746/// itself cached, since a common dir a scan fails against fails identically
4747/// for every entity sharing it this Generation.
4748type PatchIdentityCache = Mutex<
4749    HashMap<Arc<Path>, Arc<OnceLock<Result<patch_equivalence::PatchIdentitySet, git::ProbeError>>>>,
4750>;
4751
4752/// The per-common-dir half of [`probe_patch_equivalence`]: returns the
4753/// already-computed scan for `common_dir` if another entity in this
4754/// Generation's dispatch already ran it, blocking until that computation
4755/// finishes if it is still running; otherwise runs `compute` itself, caches the
4756/// result, and increments `reads` exactly once for the common dir this call is
4757/// the first to reach. Structurally identical to [`chain_facts_for`]; kept
4758/// separate rather than made generic over it, since the two caches are keyed by
4759/// different Generations' worth of dispatch and sharing one would blur which
4760/// pass a given read counted for.
4761fn patch_identities_for(
4762    cache: &PatchIdentityCache,
4763    common_dir: &Arc<Path>,
4764    reads: &AtomicUsize,
4765    compute: impl FnOnce() -> Result<patch_equivalence::PatchIdentitySet, git::ProbeError>,
4766) -> Result<patch_equivalence::PatchIdentitySet, git::ProbeError> {
4767    let cell = {
4768        let mut cache = cache.lock().unwrap();
4769        Arc::clone(
4770            cache
4771                .entry(Arc::clone(common_dir))
4772                .or_insert_with(|| Arc::new(OnceLock::new())),
4773        )
4774    };
4775    cell.get_or_init(|| {
4776        reads.fetch_add(1, Ordering::Relaxed);
4777        compute()
4778    })
4779    .clone()
4780}
4781
4782/// One Generation's default-branch chain memo: at most one [`default_branch::ChainFacts`]
4783/// per common dir, shared by every dispatched entity that names it. Built fresh in
4784/// [`Core::refresh`] and dropped once every task from that dispatch has finished.
4785type ChainFactsCache = Mutex<HashMap<Arc<Path>, Arc<OnceLock<default_branch::ChainFacts>>>>;
4786
4787/// The per-common-dir half of [`probe_default_branch_memoised`]: returns the
4788/// already-cached facts for `common_dir` if another entity in this Generation's
4789/// dispatch already computed them, blocking until that computation finishes if it
4790/// is still running; otherwise runs `compute` itself, caches the result, and
4791/// increments `reads` exactly once for the common dir this call is the first to
4792/// reach.
4793fn chain_facts_for(
4794    cache: &ChainFactsCache,
4795    common_dir: &Arc<Path>,
4796    reads: &AtomicUsize,
4797    compute: impl FnOnce() -> default_branch::ChainFacts,
4798) -> default_branch::ChainFacts {
4799    let cell = {
4800        let mut cache = cache.lock().unwrap();
4801        Arc::clone(
4802            cache
4803                .entry(Arc::clone(common_dir))
4804                .or_insert_with(|| Arc::new(OnceLock::new())),
4805        )
4806    };
4807    cell.get_or_init(|| {
4808        reads.fetch_add(1, Ordering::Relaxed);
4809        compute()
4810    })
4811    .clone()
4812}
4813
4814/// Runs the four-rung default branch chain against `path`, memoising rungs 2 and
4815/// 3's own per-common-dir facts in `cache` so every entity sharing `common_dir`
4816/// within the same dispatch reads the loose file and its reference lookups once
4817/// rather than once per entity, per [default-branch.md](https://github.com/paulchiu/repon/blob/main/docs/spec/default-branch.md)'s
4818/// "Memoised per common dir within a single refresh generation". `None` if
4819/// `cancel` was already set before the read started; `override_branch` is rung 1's
4820/// own entity-specific value, never memoised because it is not a common-dir fact.
4821/// [`chain_facts_for`]'s own two collaborators, bundled so
4822/// [`probe_default_branch_memoised`] stays within clippy's argument limit: the two always
4823/// travel together, one dispatch's worth of both, per [`Core::refresh_handles`].
4824struct ChainFactsMemo<'a> {
4825    cache: &'a ChainFactsCache,
4826    reads: &'a AtomicUsize,
4827}
4828
4829fn probe_default_branch_memoised(
4830    path: &Path,
4831    repo: Option<&gix::ThreadSafeRepository>,
4832    common_dir: &Arc<Path>,
4833    hints: DefaultBranchHints<'_>,
4834    kind: Kind,
4835    cancel: &AtomicBool,
4836    memo: &ChainFactsMemo<'_>,
4837) -> Option<default_branch::Resolution> {
4838    if cancel.load(Ordering::Acquire) {
4839        return None;
4840    }
4841    let opened;
4842    let repo = match repo {
4843        Some(repo) => repo,
4844        None => match git::open_thread_safe(path) {
4845            Ok(repo) => {
4846                opened = repo;
4847                &opened
4848            }
4849            Err(error) => {
4850                return Some(match kind {
4851                    Kind::Repo | Kind::Worktree => default_branch::Resolution::failed(error),
4852                    Kind::Submodule => default_branch::Resolution::submodule_uninitialized(),
4853                });
4854            }
4855        },
4856    };
4857    let local = repo.to_thread_local();
4858    let facts = chain_facts_for(memo.cache, common_dir, memo.reads, || {
4859        default_branch::ChainFacts::resolve(&local)
4860    });
4861    Some(supersede_with_network(
4862        default_branch::resolve_with_facts(&facts, hints.override_branch),
4863        hints.network_branch,
4864    ))
4865}
4866
4867/// Phase A and B's per-cell outcomes, landed as soon as they are computed via
4868/// [`apply_cheap_probe_outcomes`], well before phase C or D answer. Named rather
4869/// than positional so a transposed pair of trailing `None`s cannot compile
4870/// silently into the wrong cell.
4871struct CheapProbeOutcomes {
4872    branch: Option<(
4873        Settled<Head>,
4874        Option<git::InProgressOperation>,
4875        Vec<git::RecentCommit>,
4876    )>,
4877    sync: Option<Settled<SyncState>>,
4878    base: Option<Settled<u32>>,
4879    default_branch: Option<default_branch::Resolution>,
4880}
4881
4882/// Writes phase A and B's cells for `key` at `generation`, subject to the
4883/// per-cell supersession `Cell::settle` already enforces, and records the
4884/// default-branch diagnostics only on the write that actually won. Deliberately
4885/// does not touch `in_flight` or `settle_gate`: those belong to whichever apply
4886/// closes out the entity's dispatch, which per
4887/// [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
4888/// "The first frame" is this call's whole point, since a slow phase C or D must
4889/// never hold these cells off the table.
4890fn apply_cheap_probe_outcomes(
4891    table: &Arc<RwLock<Table>>,
4892    key: &EntityKey,
4893    generation: Generation,
4894    outcomes: CheapProbeOutcomes,
4895) {
4896    let CheapProbeOutcomes {
4897        branch: branch_outcome,
4898        sync: sync_outcome,
4899        base: base_outcome,
4900        default_branch: default_branch_outcome,
4901    } = outcomes;
4902    let mut table = table.write().unwrap();
4903    if let Some(&idx) = table.index.get(key) {
4904        if let Some((settled, in_progress, recent)) = branch_outcome {
4905            table.entities[idx].apply_branch_probe(generation, settled, in_progress, recent);
4906        }
4907        if let Some(settled) = sync_outcome {
4908            table.entities[idx].sync.settle(generation, settled);
4909        }
4910        if let Some(settled) = base_outcome {
4911            table.entities[idx].base.settle(generation, settled);
4912        }
4913        if let Some(resolution) = default_branch_outcome {
4914            table.entities[idx].apply_default_branch_resolution(generation, resolution);
4915        }
4916    }
4917}
4918
4919/// Phase C and D's per-cell outcomes, landed once they answer, via
4920/// [`apply_probe_outcome`]: named rather than positional for the same reason as
4921/// [`CheapProbeOutcomes`].
4922struct ProbeOutcomes {
4923    state: Option<Settled<WorktreeState>>,
4924    dirty: Option<Settled<DirtyCounts>>,
4925}
4926
4927/// Lands one probe's phase C/D outcome for `key` at `generation`: writes the
4928/// `state` and `dirty` cells subject to the per-cell supersession `Cell::settle`
4929/// already enforces, then clears `key`'s in-flight entry if `generation` still
4930/// owns it and signals `settle_gate` once for the whole entity. This is the one
4931/// write that closes out a dispatched entity, whether or not
4932/// [`apply_cheap_probe_outcomes`] already landed that same entity's cheap cells;
4933/// a test's simulated late result goes through the same path so it does not
4934/// duplicate this bookkeeping.
4935///
4936/// `outcomes.state` being `None` writes nothing at all: the `state` cell is left
4937/// exactly as unsettled as `begin_probe` alone leaves it, which is what an
4938/// attached branch with a live upstream ancestry could not clear, and that
4939/// `probe_patch_equivalence` was itself cancelled before answering, still shows.
4940fn apply_probe_outcome(
4941    table: &Arc<RwLock<Table>>,
4942    settle_gate: &Arc<SettleGate>,
4943    key: &EntityKey,
4944    generation: Generation,
4945    outcomes: ProbeOutcomes,
4946) {
4947    let ProbeOutcomes {
4948        state: state_outcome,
4949        dirty: dirty_outcome,
4950    } = outcomes;
4951    let mut table = table.write().unwrap();
4952    if let Some(&idx) = table.index.get(key) {
4953        if let Some(settled) = state_outcome {
4954            table.entities[idx].state.settle(generation, settled);
4955        }
4956        if let Some(settled) = dirty_outcome {
4957            table.entities[idx].dirty.settle(generation, settled);
4958        }
4959    }
4960    // By Generation as well as by key. Cancellation is cooperative, so a superseded
4961    // probe still runs to completion and arrives here after the Generation that
4962    // superseded it has already put its own entry under this key; clearing by key
4963    // alone would delete that live entry, leaving the entity with nothing for the
4964    // next Generation to interrupt and nothing for the deadline sweep to time out.
4965    // The settle gate is signalled either way, since the debt belongs to the probe
4966    // rather than to the entry.
4967    if table
4968        .in_flight
4969        .get(key)
4970        .is_some_and(|in_flight| in_flight.generation == generation.value())
4971    {
4972        table.in_flight.remove(key);
4973    }
4974    drop(table);
4975    complete_one(settle_gate);
4976}
4977
4978/// Reconciles one discovery result into `table`: a found entity is inserted or
4979/// marked Present again, even if it was Vanished, and one no longer found is
4980/// marked Vanished via [`EntityState::mark_vanished`]. Returns how many
4981/// in-flight probes were cancelled by a newly Vanished entity, for the caller
4982/// to signal `settle_gate`.
4983fn merge_discovery(
4984    table: &mut Table,
4985    exclusions: &[ResolvedExclusion],
4986    discovered: Vec<discovery::DiscoveredEntity>,
4987    gitmodules_failures: Vec<(EntityKey, String)>,
4988) -> usize {
4989    let mut found: HashSet<EntityKey> = HashSet::with_capacity(discovered.len());
4990
4991    for discovered in discovered {
4992        found.insert(discovered.key.clone());
4993        match table.index.get(&discovered.key).copied() {
4994            Some(idx) => {
4995                table.entities[idx].presence = Presence::Present;
4996                if let Some(repo) = discovered.repo {
4997                    table.repos.insert(discovered.key.clone(), repo);
4998                }
4999            }
5000            None => {
5001                let name = discovered
5002                    .display_name_override
5003                    .clone()
5004                    .unwrap_or_else(|| display_name(discovered.key.path()));
5005                let mut entity = EntityState::new(
5006                    discovered.key.clone(),
5007                    name,
5008                    Arc::clone(&discovered.common_dir),
5009                    discovered.kind,
5010                );
5011                entity.excluded =
5012                    excluded_by(exclusions, discovered.key.path(), &discovered.common_dir);
5013                if let Some(repo) = discovered.repo {
5014                    table.repos.insert(discovered.key.clone(), repo);
5015                }
5016                let idx = table.entities.len();
5017                table.index.insert(discovered.key, idx);
5018                table.entities.push(entity);
5019            }
5020        }
5021    }
5022
5023    // A boundary's `.gitmodules` failure is re-derived from this pass alone,
5024    // never carried over from a previous one: a failure that was fixed since the
5025    // last Generation must clear, not stay stuck forever.
5026    let now_failing: HashMap<EntityKey, String> = gitmodules_failures.into_iter().collect();
5027    for key in &found {
5028        if let Some(&idx) = table.index.get(key) {
5029            table.entities[idx].diagnostics.gitmodules_failed = now_failing
5030                .get(key)
5031                .map(|message| Arc::from(message.as_str()));
5032        }
5033    }
5034
5035    let missing: Vec<EntityKey> = table
5036        .index
5037        .keys()
5038        .filter(|key| !found.contains(*key))
5039        .cloned()
5040        .collect();
5041    let mut cancelled = 0usize;
5042    for key in missing {
5043        if let Some(&idx) = table.index.get(&key) {
5044            table.entities[idx].mark_vanished();
5045        }
5046        if let Some(in_flight) = table.in_flight.remove(&key) {
5047            in_flight.cancel.store(true, Ordering::Release);
5048            cancelled += 1;
5049        }
5050    }
5051
5052    cancelled
5053}
5054
5055/// A basename read from the entity's own resolved path. A real display name has
5056/// collision handling that belongs to [config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md);
5057/// this is a placeholder good enough to populate the table.
5058///
5059/// This is the one function that computes it: `start_internal`'s discovery loop
5060/// and `probe_now`'s fallback insert for an unknown key both call it rather than
5061/// formatting a name of their own, which is what keeps the name shown on screen
5062/// and the name a future state file would key by byte-identical.
5063fn display_name(path: &Path) -> Arc<str> {
5064    Arc::from(
5065        path.file_name()
5066            .and_then(|name| name.to_str())
5067            .unwrap_or("?"),
5068    )
5069}
5070
5071/// Sleeps for `warn_after`, then reports `progress`'s count and `roots` if the walk
5072/// still has not finished, per [discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md):
5073/// the one-second still-walking warning needs a timer watching an in-flight walk
5074/// from outside it, since discovery itself has no callback and no notion of "still
5075/// running". `None` once the walk has already finished.
5076fn watch_for_slow_discovery(
5077    progress: Arc<AtomicUsize>,
5078    finished: Arc<AtomicBool>,
5079    roots: Vec<PathBuf>,
5080    warn_after: Duration,
5081) -> Option<String> {
5082    thread::sleep(warn_after);
5083    if finished.load(Ordering::Acquire) {
5084        return None;
5085    }
5086    Some(still_walking_message(
5087        progress.load(Ordering::Acquire),
5088        &roots,
5089    ))
5090}
5091
5092fn still_walking_message(directories_visited: usize, roots: &[PathBuf]) -> String {
5093    let roots = roots
5094        .iter()
5095        .map(|root| root.display().to_string())
5096        .collect::<Vec<_>>()
5097        .join(", ");
5098    format!("discovery: still walking, {directories_visited} directories reached under {roots}")
5099}
5100
5101/// The persistent warning left once a walk abandons, per
5102/// [config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md#discovery-bounds):
5103/// unlike the still-walking warning, this one never clears itself, since the Set
5104/// stays out of the automatic refresh path for the life of this `Core`.
5105fn abandoned_discovery_message(directories_visited: usize) -> String {
5106    format!("discovery: stopped at {directories_visited} directories")
5107}
5108
5109/// Runs `step` until it says it is done or `cancel` is observed set, checked before
5110/// every call. Returns how many times `step` actually ran, which is what lets a
5111/// test prove a cancelled loop stopped mid-flight rather than merely having a flag
5112/// set on it somewhere. Not yet called from a real probe: `git::head_shape` has no
5113/// loop to interrupt, so this is the shape a later, genuinely interruptible phase
5114/// (gix `status`, taking `should_interrupt` directly) will use.
5115#[allow(dead_code)] // exercised by its own test; no interruptible probe calls it yet
5116pub(crate) fn run_while_not_cancelled(
5117    cancel: &AtomicBool,
5118    mut step: impl FnMut() -> bool,
5119) -> usize {
5120    let mut ran = 0;
5121    while !cancel.load(Ordering::Acquire) {
5122        if !step() {
5123            break;
5124        }
5125        ran += 1;
5126    }
5127    ran
5128}
5129
5130#[cfg(test)]
5131mod tests {
5132    use std::fs;
5133    use std::process::Command;
5134    use std::sync::mpsc;
5135
5136    use super::*;
5137    use crate::entity::{AheadBehind, DefaultBranchStopped, WorktreeState};
5138    use crate::liveness::{BACKSTOP, FIXTURE_LIFETIME, wait_for};
5139    use crate::snapshot::{RowSummary, summary};
5140    use crate::test_support::{git, head_sha, loose_object_count};
5141
5142    fn init_repo_with_a_commit(path: &Path) {
5143        fs::create_dir_all(path).expect("create repo dir");
5144        gix::init(path).expect("init repo");
5145        let status = Command::new("git")
5146            .arg("-C")
5147            .arg(path)
5148            .args(["-c", "user.email=test@example.com", "-c", "user.name=Test"])
5149            .args(["commit", "--allow-empty", "-m", "first"])
5150            .status()
5151            .expect("run git commit");
5152        assert!(status.success());
5153    }
5154
5155    /// A second (or later) commit against an already-initialised repo at `path`,
5156    /// with the same explicit identity `init_repo_with_a_commit` supplies: never
5157    /// relying on a global git identity, which a machine running CI has none of.
5158    /// Commits a real change, which is what the poll's own user story is about and what an
5159    /// empty commit is not: `git add` rewrites `.git/index` unconditionally, while whether a
5160    /// commit with nothing staged rewrites it is left to git's racy-entry heuristic and
5161    /// differs between platforms. `index` is the only one of the polled paths a commit on an
5162    /// attached HEAD moves, so a test that depends on an empty commit moving it is testing
5163    /// that heuristic rather than the poll.
5164    fn commit_a_change(path: &Path, message: &str) {
5165        let gitdir = gitdir_of(path);
5166        let before = poll::fingerprint(&gitdir);
5167
5168        std::fs::write(path.join(format!("{message}.txt")), message.as_bytes())
5169            .expect("write a file to commit");
5170        let added = Command::new("git")
5171            .arg("-C")
5172            .arg(path)
5173            .args(["add", "-A"])
5174            .status()
5175            .expect("run git add");
5176        assert!(added.success());
5177        commit(path, message, &["-m", message]);
5178
5179        // The fixture's own premise, asserted rather than assumed: a commit on an attached
5180        // HEAD moves none of the polled paths except `index` (`HEAD` is untouched, and
5181        // rewriting `refs/heads/<branch>` does not move `refs/` itself), so if git leaves
5182        // `index` alone here there is nothing for the poll to see and the failure belongs to
5183        // this fixture, not to the sweep it is setting up.
5184        assert!(
5185            poll::moved(&before, &poll::fingerprint(&gitdir)),
5186            "committing in {} moved none of the polled paths under {}, so this fixture cannot \
5187             show the poll anything",
5188            path.display(),
5189            gitdir.display()
5190        );
5191    }
5192
5193    /// The absolute gitdir git itself reports, which for a linked Worktree is its own
5194    /// `.git/worktrees/<name>` rather than the `.git` file beside the checkout.
5195    fn gitdir_of(work_dir: &Path) -> PathBuf {
5196        let output = Command::new("git")
5197            .arg("-C")
5198            .arg(work_dir)
5199            .args(["rev-parse", "--absolute-git-dir"])
5200            .output()
5201            .expect("run git rev-parse");
5202        assert!(
5203            output.status.success(),
5204            "resolve the gitdir of {}",
5205            work_dir.display()
5206        );
5207        PathBuf::from(
5208            std::str::from_utf8(&output.stdout)
5209                .expect("a utf-8 gitdir path")
5210                .trim(),
5211        )
5212    }
5213
5214    /// The shared tail of the commit helpers.
5215    fn commit(path: &Path, message: &str, args: &[&str]) {
5216        let status = Command::new("git")
5217            .arg("-C")
5218            .arg(path)
5219            .args(["-c", "user.email=test@example.com", "-c", "user.name=Test"])
5220            .arg("commit")
5221            .args(args)
5222            .status()
5223            .unwrap_or_else(|error| panic!("run git commit {message}: {error}"));
5224        assert!(status.success());
5225    }
5226
5227    /// A `FetchSpec` that never fires on its own: `enabled: false`, so every
5228    /// existing test that does not care about the periodic fetch keeps behaving
5229    /// exactly as it did before this field existed.
5230    fn fetch_spec_for_test() -> FetchSpec {
5231        FetchSpec {
5232            enabled: false,
5233            interval: Duration::from_secs(3600),
5234            concurrency: 4,
5235        }
5236    }
5237
5238    /// An `AutoUpdateSpec` that never fires on its own, the same reason
5239    /// [`fetch_spec_for_test`] never does: every existing test that does not care
5240    /// about the auto-update keeps behaving exactly as it did before this field
5241    /// existed.
5242    fn auto_update_spec_for_test() -> AutoUpdateSpec {
5243        AutoUpdateSpec { enabled: false }
5244    }
5245
5246    fn spec(roots: Vec<PathBuf>) -> CoreSpec {
5247        CoreSpec {
5248            set: SetSpec {
5249                name: "test".to_string(),
5250                roots,
5251                include: Vec::new(),
5252                exclude: Vec::new(),
5253            },
5254            overrides: Vec::new(),
5255            poll_interval: Duration::from_secs(3600),
5256            status_stale_after: Duration::from_secs(3600),
5257            generation_deadline: Duration::from_secs(3600),
5258            show_submodules: false,
5259            fetch: fetch_spec_for_test(),
5260            auto_update: auto_update_spec_for_test(),
5261        }
5262    }
5263
5264    /// Criterion 2's "no field" half: scope is never a partial dial, not even as a field
5265    /// on the plain-data struct crossing into the core. An exhaustive destructure names
5266    /// every field `CoreSpec` has; a scoping field added under any name fails to compile
5267    /// this test rather than landing unacknowledged. `show_submodules` is named here too,
5268    /// deliberately: it narrows probing and rendering, never what discovery bounds, so it
5269    /// is not the scoping field this test guards against
5270    /// ([discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md)'s
5271    /// "narrows the view rather than bounding the work"). `fetch` and `auto_update` are
5272    /// excluded from that same guard for the same reason: they narrow what the periodic
5273    /// fetch and the fast-forward-only update touch, never what discovery bounds.
5274    #[test]
5275    fn core_spec_carries_no_scoping_field_scope_is_never_a_dial() {
5276        let CoreSpec {
5277            set: _,
5278            overrides: _,
5279            poll_interval: _,
5280            status_stale_after: _,
5281            generation_deadline: _,
5282            show_submodules: _,
5283            fetch: _,
5284            auto_update: _,
5285        } = spec(Vec::new());
5286    }
5287
5288    fn root_of(dir: &tempfile::TempDir) -> PathBuf {
5289        dir.path().canonicalize().expect("canonicalize temp dir")
5290    }
5291
5292    /// Blocks until `core`'s launch Generation has settled, and hands back what it settled
5293    /// to.
5294    ///
5295    /// `Core::start`'s own first walk is that `Core`'s Generation 1 and probes every row it
5296    /// finds, so a test that counts what a later Generation did, or that watches a cell
5297    /// only its own Generation may write, has to begin from a table launch has already
5298    /// finished with. [`BACKSTOP`] rather than a budget, and the gate is read afterwards so
5299    /// an expired wait fails here by name instead of downstream as a wrong value.
5300    fn settle_launch(core: &Core) -> Snapshot {
5301        let launched = core.settle();
5302        assert_eq!(
5303            core.settle_gate_count_for_test(),
5304            0,
5305            "launch's own Generation never settled, so nothing after this is starting from \
5306             the point it claims to"
5307        );
5308        launched
5309    }
5310
5311    /// [`settle_launch`] over a `Core` built the ordinary way, for the many tests that want
5312    /// nothing else from the constructor.
5313    fn started_and_settled(spec: CoreSpec) -> (Core, Snapshot) {
5314        let core = Core::start_discovered(spec);
5315        let launched = settle_launch(&core);
5316        (core, launched)
5317    }
5318
5319    /// Sets every polled gitdir entry's modification time ten seconds into the past, so any
5320    /// write that follows reads as newer than the baseline by more than a filesystem's
5321    /// timestamp granularity. Without it a commit made microseconds after the baseline sweep
5322    /// lands in the same coarse tick on Linux and reads as no movement at all, which is a race
5323    /// in the harness rather than in the poll: real sweeps are a configured interval apart.
5324    /// Reads the polled names from [`poll::POLLED_GITDIR_ENTRIES`] rather than restating them.
5325    fn backdate_polled_entries(work_dir: &Path) {
5326        let gitdir = gitdir_of(work_dir);
5327
5328        let past = std::time::SystemTime::now() - Duration::from_secs(10);
5329        let mut touched = 0;
5330        for name in poll::POLLED_GITDIR_ENTRIES {
5331            let path = gitdir.join(name);
5332            if path.exists() {
5333                set_mtime_to(&path, past);
5334                touched += 1;
5335            }
5336        }
5337        assert!(
5338            touched > 0,
5339            "backdated nothing under {}; the gitdir holds none of the polled entries and the \
5340             baseline this sets up would not be older than what follows",
5341            gitdir.display()
5342        );
5343    }
5344
5345    /// `utimensat`, since a plain file handle cannot set a directory's time and `refs` is one.
5346    fn set_mtime_to(path: &Path, at: std::time::SystemTime) {
5347        use std::os::unix::ffi::OsStrExt;
5348
5349        let secs = at
5350            .duration_since(std::time::SystemTime::UNIX_EPOCH)
5351            .expect("a time after the epoch")
5352            .as_secs() as libc::time_t;
5353        let times = [
5354            libc::timespec {
5355                tv_sec: secs,
5356                tv_nsec: 0,
5357            },
5358            libc::timespec {
5359                tv_sec: secs,
5360                tv_nsec: 0,
5361            },
5362        ];
5363        let c_path =
5364            std::ffi::CString::new(path.as_os_str().as_bytes()).expect("a path with no NUL");
5365        let rc = unsafe { libc::utimensat(libc::AT_FDCWD, c_path.as_ptr(), times.as_ptr(), 0) };
5366        assert_eq!(
5367            rc,
5368            0,
5369            "set mtime on {}: {}",
5370            path.display(),
5371            std::io::Error::last_os_error()
5372        );
5373    }
5374
5375    fn step(argv: &[&str]) -> Step {
5376        Step {
5377            argv: argv.iter().map(|s| s.to_string()).collect(),
5378            shell: false,
5379            interactive: false,
5380            env: Vec::new(),
5381        }
5382    }
5383
5384    /// `shell = true`'s own convention: one argv element, the whole command string.
5385    fn shell_step(command: &str) -> Step {
5386        Step {
5387            argv: vec![command.to_string()],
5388            shell: true,
5389            interactive: false,
5390            env: Vec::new(),
5391        }
5392    }
5393
5394    /// `shell = true` plus `interactive = true`: the same convention, run through
5395    /// `$SHELL -ic` instead of `$SHELL -c`.
5396    fn interactive_shell_step(command: &str) -> Step {
5397        Step {
5398            argv: vec![command.to_string()],
5399            shell: true,
5400            interactive: true,
5401            env: Vec::new(),
5402        }
5403    }
5404
5405    /// The one entity's Action receipt, if the run that wrote it is the one `label` names:
5406    /// a run that replaced an earlier run's receipt on the same row is what these reads are
5407    /// distinguishing.
5408    fn receipt_labelled(core: &Core, key: &EntityKey, label: &str) -> Option<ActionReceipt> {
5409        core.snapshot()
5410            .entities
5411            .iter()
5412            .find(|entity| entity.key == *key)
5413            .and_then(|entity| entity.last_action.clone())
5414            .filter(|receipt| &*receipt.label == label)
5415    }
5416
5417    fn action(label: &str, steps: Vec<Step>) -> ActionSpec {
5418        ActionSpec {
5419            label: Arc::from(label),
5420            name: Some(Arc::from(label)),
5421            steps,
5422            concurrency: 4,
5423            when: None,
5424        }
5425    }
5426
5427    /// [`action`], narrowed by `when`, a Filter grammar predicate
5428    /// (`docs/spec/actions.md`'s "The Selection and the gate").
5429    fn action_with_when(label: &str, steps: Vec<Step>, when: &str) -> ActionSpec {
5430        ActionSpec {
5431            when: Some(Filter::parse(when)),
5432            ..action(label, steps)
5433        }
5434    }
5435
5436    /// End-to-end: the test thread never spawns anything itself, only calls
5437    /// `Core`'s public methods, and real branch data still lands in the snapshot.
5438    /// That is the proof that the core owns the threads doing the work, not the
5439    /// consumer.
5440    #[test]
5441    fn refresh_and_settle_populate_real_cells_without_the_caller_spawning_a_thread() {
5442        let dir = tempfile::tempdir().expect("temp dir");
5443        let root = root_of(&dir);
5444        let repo = root.join("repo");
5445        init_repo_with_a_commit(&repo);
5446
5447        let core = Core::start_discovered(spec(vec![root]));
5448        let keys: Vec<EntityKey> = core
5449            .snapshot()
5450            .entities
5451            .iter()
5452            .map(|entity| entity.key.clone())
5453            .collect();
5454        assert_eq!(keys.len(), 1);
5455
5456        core.refresh(&keys);
5457        let settled = core.settle();
5458
5459        let entity = &settled.entities[0];
5460        match entity.branch.settled() {
5461            Some(Settled::Known {
5462                value: Head::Branch { .. },
5463                at: _,
5464                stale: _,
5465            }) => {}
5466            other => panic!("expected an attached branch, got {other:?}"),
5467        }
5468    }
5469
5470    // --- Single source of truth: read the first-frame budgets from the spec itself,
5471    // the same pattern `executor.rs` already uses for its PTY width and capture bounds
5472    // against `docs/spec/actions.md`. ---
5473
5474    fn spec_refresh_md() -> String {
5475        let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
5476        std::fs::read_to_string(manifest_dir.join("../../docs/spec/refresh.md"))
5477            .expect("read docs/spec/refresh.md")
5478    }
5479
5480    fn spec_first_frame_budgets_ms(spec: &str) -> (u64, u64) {
5481        let anchor = "rows with names on screen within ";
5482        let after = spec
5483            .split(anchor)
5484            .nth(1)
5485            .expect("the first-frame budget sentence is present");
5486        let mut parts = after.splitn(2, "ms, every cheap column filled within ");
5487        let names: u64 = parts
5488            .next()
5489            .expect("a names-on-screen budget")
5490            .parse()
5491            .expect("the names-on-screen budget is an integer");
5492        let after_cheap = parts.next().expect("a cheap-column budget and beyond");
5493        let cheap_columns: u64 = after_cheap
5494            .split("ms,")
5495            .next()
5496            .expect("a cheap-column budget")
5497            .parse()
5498            .expect("the cheap-column budget is an integer");
5499        (names, cheap_columns)
5500    }
5501
5502    /// Criterion 1: the two budgets `refresh.md`'s "The first frame" states are declared
5503    /// once as named constants and cross-checked against the spec sentence here, so the
5504    /// spec and the code cannot drift apart silently.
5505    #[test]
5506    fn first_frame_budget_constants_match_the_spec_of_record() {
5507        let spec = spec_refresh_md();
5508        let (names_ms, cheap_columns_ms) = spec_first_frame_budgets_ms(&spec);
5509        assert_eq!(names_ms, FIRST_FRAME_NAMES_BUDGET_MS);
5510        assert_eq!(cheap_columns_ms, FIRST_FRAME_CHEAP_COLUMNS_BUDGET_MS);
5511    }
5512
5513    /// Criterion 2: every entity a Generation is dispatched over gets its phase C read,
5514    /// never a subset. `refresh.md`'s "Scope and order" makes scope never a partial dial,
5515    /// so this proves it against a population wide enough that a mistaken "first K" or
5516    /// "last K" scoping mistake would leave a visible gap: sixteen real repos, dispatched in
5517    /// one Generation, every one of them still `dirty: Known` once settled, position sixteen
5518    /// exactly as covered as position one. A mutation that scoped phase C to, say, the first
5519    /// ten dispatched entities fails this directly.
5520    #[test]
5521    fn every_dispatched_entity_gets_its_dirty_cell_settled_not_a_subset() {
5522        let dir = tempfile::tempdir().expect("temp dir");
5523        let root = root_of(&dir);
5524        const ENTITY_COUNT: usize = 16;
5525        for index in 0..ENTITY_COUNT {
5526            init_repo_with_a_commit(&root.join(format!("repo-{index}")));
5527        }
5528
5529        let core = Core::start_discovered(spec(vec![root]));
5530        let keys: Vec<EntityKey> = core
5531            .snapshot()
5532            .entities
5533            .iter()
5534            .map(|entity| entity.key.clone())
5535            .collect();
5536        assert_eq!(keys.len(), ENTITY_COUNT, "expected every repo discovered");
5537
5538        core.refresh(&keys);
5539        let settled = core.settle();
5540
5541        for entity in &settled.entities {
5542            assert!(
5543                matches!(
5544                    entity.dirty.settled(),
5545                    Some(Settled::Known {
5546                        value: _,
5547                        at: _,
5548                        stale: _
5549                    })
5550                ),
5551                "entity {:?} was left without a settled dirty cell, which is exactly what a \
5552                 visibility-scoped dispatch would leave behind on the entities it skipped: \
5553                 got {:?}",
5554                entity.name,
5555                entity.dirty.settled()
5556            );
5557        }
5558    }
5559
5560    /// refresh.md's "The first frame" budget (cheap columns filled within 200ms) is
5561    /// unreachable if the cheap outcomes wait behind phase C, so this proves the two
5562    /// applies are independent with a blocking seam rather than a sleep or a wall-clock
5563    /// deadline: `Core::hold_phase_c_for_test` holds phase C (and D) open after the cheap
5564    /// outcomes have already landed, and the test observes `branch` carrying this
5565    /// Generation's answer while `dirty` still carries the previous one. Run this against
5566    /// a version that bundles every outcome into one apply placed after phase C computes
5567    /// (this ticket's regression) and it fails, since nothing writes `branch` until that
5568    /// single bundled apply lands alongside `dirty`.
5569    ///
5570    /// Launch's own Generation is drained first and both cells are then moved, so each is
5571    /// read on the value it holds rather than on being blank: a table that has already
5572    /// been probed once is the only starting point available now that `Core::start` runs
5573    /// a Generation of its own, and reading values is the stronger claim anyway.
5574    #[test]
5575    fn cheap_outcomes_land_before_a_held_phase_c_settles() {
5576        let dir = tempfile::tempdir().expect("temp dir");
5577        let root = root_of(&dir);
5578        let repo = root.join("repo");
5579        init_repo_with_a_commit(&repo);
5580
5581        let (core, launched) = started_and_settled(spec(vec![root]));
5582        let key = launched.entities[0].key.clone();
5583        assert_eq!(
5584            dirty_total(&launched.entities[0]),
5585            0,
5586            "the fixture starts clean, which is the value the held phase C must still be \
5587             reading once the working tree below has moved"
5588        );
5589
5590        // One move per phase, so neither cell can be read on absence: `branch` is phase A
5591        // and must carry the new name while phase C is held, `dirty` is phase C and must
5592        // still carry launch's own clean count until it is released.
5593        git(&repo, &["checkout", "-b", "held"]);
5594        fs::write(repo.join("untracked.txt"), b"uncommitted")
5595            .expect("write an untracked file into the fixture");
5596
5597        core.hold_phase_c_for_test(&key);
5598        core.refresh(std::slice::from_ref(&key));
5599        core.wait_phase_c_landed_for_test(&key);
5600
5601        let mid_flight = core.snapshot();
5602        let entity = mid_flight
5603            .entities
5604            .iter()
5605            .find(|entity| entity.key == key)
5606            .expect("entity present");
5607        assert!(
5608            matches!(
5609                entity.branch.settled(),
5610                Some(Settled::Known {
5611                    value: Head::Branch { name, .. },
5612                    at: _,
5613                    stale: _
5614                }) if &**name == "held"
5615            ),
5616            "the cheap branch cell must carry this Generation's own answer while phase C is \
5617             still held open, got {:?}",
5618            entity.branch.settled()
5619        );
5620        assert!(
5621            entity.dirty.is_in_flight() && dirty_total(entity) == 0,
5622            "phase C is deliberately held open here; a bundled apply would already have \
5623             written this cell's new count alongside branch, got {:?}",
5624            entity.dirty.settled()
5625        );
5626
5627        core.release_phase_c_for_test(&key);
5628        core.wait_phase_c_finished_for_test(&key);
5629
5630        let settled = core.snapshot();
5631        let entity = settled
5632            .entities
5633            .iter()
5634            .find(|entity| entity.key == key)
5635            .expect("entity present");
5636        assert_eq!(
5637            dirty_total(entity),
5638            1,
5639            "phase C must settle its own count once released, got {:?}",
5640            entity.dirty.settled()
5641        );
5642    }
5643
5644    /// One entity's settled dirty count, or a panic naming what it read instead. Lets a
5645    /// test that has to distinguish two Generations by value say "still zero" and "now
5646    /// one" without repeating the match on every read.
5647    fn dirty_total(entity: &EntityState) -> u32 {
5648        match entity.dirty.settled() {
5649            Some(Settled::Known {
5650                value,
5651                at: _,
5652                stale: _,
5653            }) => value.total(),
5654            other => panic!("expected a settled dirty count, got {other:?}"),
5655        }
5656    }
5657
5658    /// Splitting one dispatched entity's write into a cheap apply and a phase C/D apply
5659    /// must still signal `settle_gate` exactly once per entity, or `settle` hangs (never
5660    /// decremented enough) or returns early (decremented twice). Two entities held open
5661    /// together prove the exact count at each step: a mutation that also decrements the
5662    /// gate from the cheap apply leaves it at 0 instead of 2 after both entities' cheap
5663    /// outcomes land, and a mutation that drops the decrement from the phase C/D apply
5664    /// leaves it at 2, never 1, once only the first entity finishes.
5665    #[test]
5666    fn splitting_the_probe_write_signals_settle_gate_exactly_once_per_entity() {
5667        let dir = tempfile::tempdir().expect("temp dir");
5668        let root = root_of(&dir);
5669        init_repo_with_a_commit(&root.join("a"));
5670        init_repo_with_a_commit(&root.join("b"));
5671
5672        let (core, snapshot) = started_and_settled(spec(vec![root]));
5673        let key_a = snapshot
5674            .entities
5675            .iter()
5676            .find(|entity| &*entity.name == "a")
5677            .expect("entity a present")
5678            .key
5679            .clone();
5680        let key_b = snapshot
5681            .entities
5682            .iter()
5683            .find(|entity| &*entity.name == "b")
5684            .expect("entity b present")
5685            .key
5686            .clone();
5687
5688        core.hold_phase_c_for_test(&key_a);
5689        core.hold_phase_c_for_test(&key_b);
5690        core.refresh(&[key_a.clone(), key_b.clone()]);
5691        // A Generation reserves its number on this thread and raises the gate on one of
5692        // its own, so this is the rendezvous that says the raise has happened. A join,
5693        // never a sleep.
5694        core.wait_dispatched_for_test();
5695        assert_eq!(
5696            core.settle_gate_count_for_test(),
5697            2,
5698            "dispatching two entities must add exactly two to the settle gate"
5699        );
5700
5701        core.wait_phase_c_landed_for_test(&key_a);
5702        core.wait_phase_c_landed_for_test(&key_b);
5703        assert_eq!(
5704            core.settle_gate_count_for_test(),
5705            2,
5706            "the cheap apply must never touch the settle gate: both entities' cheap \
5707             outcomes have landed and neither has finished phase C yet"
5708        );
5709
5710        core.release_phase_c_for_test(&key_a);
5711        core.wait_phase_c_finished_for_test(&key_a);
5712        assert_eq!(
5713            core.settle_gate_count_for_test(),
5714            1,
5715            "exactly one entity finished, so the gate must fall by exactly one, not two \
5716             (double-counted) and not zero (left short)"
5717        );
5718
5719        core.release_phase_c_for_test(&key_b);
5720        core.wait_phase_c_finished_for_test(&key_b);
5721        assert_eq!(
5722            core.settle_gate_count_for_test(),
5723            0,
5724            "both entities finished, so the gate must be fully drained"
5725        );
5726    }
5727
5728    /// The gate [`Core::hold_phase_c_for_test`] last registered for `key`, so a test can
5729    /// still name one a later registration for the same entity has replaced in the map.
5730    fn registered_gate(core: &Core, key: &EntityKey) -> PhaseCGateHandle {
5731        core.phase_c_gates
5732            .lock()
5733            .unwrap()
5734            .get(key)
5735            .cloned()
5736            .expect("hold_phase_c_for_test must be called before reading its gate")
5737    }
5738
5739    /// Opens `gate` directly rather than through [`Core::release_phase_c_for_test`], which
5740    /// resolves by key and so cannot name a gate a later registration has replaced.
5741    fn release_gate(gate: &PhaseCGateHandle) {
5742        let (lock, cvar) = &**gate;
5743        lock.lock().unwrap().may_proceed = true;
5744        cvar.notify_all();
5745    }
5746
5747    fn gate_is_finished(gate: &PhaseCGateHandle) -> bool {
5748        gate.0.lock().unwrap().finished
5749    }
5750
5751    /// A probe signals the phase C gate its own Generation was dispatched against, never
5752    /// whatever gate the map holds by the time that probe finishes.
5753    ///
5754    /// Reading the map twice per probe, once before phase C and once after, made the gate
5755    /// a probe signalled a function of when it got there: a probe from an already-settled
5756    /// Generation, past its own first read but not yet past its second, would find a gate
5757    /// registered in between and mark it finished, so the wait a later Generation was
5758    /// making returned before that Generation had applied anything or touched the settle
5759    /// gate. Registering a second gate for the same entity while the first is still held
5760    /// open is that interleaving with the timing taken out of it: the parked probe took
5761    /// the first gate, and the map holds the second by the time it finishes.
5762    #[test]
5763    fn a_probe_signals_the_phase_c_gate_its_own_generation_was_dispatched_against() {
5764        let dir = tempfile::tempdir().expect("temp dir");
5765        let root = root_of(&dir);
5766        init_repo_with_a_commit(&root.join("repo"));
5767
5768        let (core, launched) = started_and_settled(spec(vec![root]));
5769        let key = launched.entities[0].key.clone();
5770
5771        core.hold_phase_c_for_test(&key);
5772        let dispatched_against = registered_gate(&core, &key);
5773        core.refresh(std::slice::from_ref(&key));
5774        core.wait_phase_c_landed_for_test(&key);
5775
5776        core.hold_phase_c_for_test(&key);
5777        let registered_later = registered_gate(&core, &key);
5778        release_gate(&dispatched_against);
5779
5780        wait_for(
5781            "the held probe to signal the gate its own Generation was dispatched against",
5782            || gate_is_finished(&dispatched_against),
5783        );
5784        assert!(
5785            !gate_is_finished(&registered_later),
5786            "a gate registered after this Generation dispatched must never be marked \
5787             finished by it: a test waiting on that gate would return before this \
5788             Generation had applied its outcome or decremented the settle gate"
5789        );
5790    }
5791
5792    /// A probe finishing clears its own Generation's in-flight entry, never whatever the
5793    /// table holds under that key by the time it gets there.
5794    ///
5795    /// Cancellation is cooperative (refresh.md's "Cancellation"), so a superseded probe
5796    /// runs to completion and reaches `apply_probe_outcome` after the Generation that
5797    /// superseded it has already put its own entry under the same key. Clearing by key
5798    /// alone deleted that live entry, and refresh.md's "Supersession" then had nothing to
5799    /// set: the Generation after it found no previous entry, so the entity's interrupt
5800    /// flag stayed false and its probe ran on uncancelled, which is the 1.79x ADR 0013
5801    /// measured. Parking a probe at its phase C gate and superseding it while it is held
5802    /// is that interleaving with the timing taken out of it.
5803    #[test]
5804    fn a_probe_finishing_clears_only_its_own_generations_in_flight_entry() {
5805        let dir = tempfile::tempdir().expect("temp dir");
5806        let root = root_of(&dir);
5807        init_repo_with_a_commit(&root.join("repo"));
5808
5809        let (core, launched) = started_and_settled(spec(vec![root]));
5810        let key = launched.entities[0].key.clone();
5811
5812        core.hold_phase_c_for_test(&key);
5813        core.refresh(std::slice::from_ref(&key));
5814        core.wait_phase_c_landed_for_test(&key);
5815
5816        // The Generation that supersedes the parked probe, holding the interrupt flag the
5817        // `refresh` below has to be able to find and set.
5818        let superseding = core.begin_shared_generation_for_test(std::slice::from_ref(&key));
5819
5820        core.release_phase_c_for_test(&key);
5821        core.wait_phase_c_finished_for_test(&key);
5822
5823        core.refresh(std::slice::from_ref(&key));
5824        core.wait_dispatched_for_test();
5825
5826        assert!(
5827            superseding.cancels[&key].load(Ordering::Acquire),
5828            "a probe from a Generation that has already been superseded must leave the \
5829             live Generation's in-flight entry alone, or the Generation after it has \
5830             nothing to interrupt"
5831        );
5832    }
5833
5834    /// Criterion 5, the honest half: a concurrent pool's *completion* order is not
5835    /// dispatch order and asserting it would make this test flaky in exact proportion to
5836    /// how well rayon's scheduler works, so this asserts *dispatch* order instead, which is
5837    /// deterministic because `refresh`'s own dispatch loop is a single sequential pass over
5838    /// `order` that spawns work without ever waiting on it. `dispatch_order` itself, the
5839    /// function that actually builds the cursor-then-visible-then-rest sequence
5840    /// `refresh.md`'s "Scope and order" names, lives in the `repon` crate and is tested
5841    /// there: `core-api.md`'s ownership table gives that computation to the consumer, never
5842    /// to this crate. What this test proves on the core side is the half core-api.md commits
5843    /// to: `refresh` dispatches in exactly the order it is handed, position for position,
5844    /// never reordered by any heuristic of its own (never, per `refresh.md`, by predicted
5845    /// cost). A hand-built three-tier order stands in for what `dispatch_order` would
5846    /// produce, six entities discovered, one named cursor, two named visible, three left
5847    /// over in discovery order.
5848    #[test]
5849    fn refresh_dispatches_phase_c_in_exactly_the_order_it_is_given() {
5850        let dir = tempfile::tempdir().expect("temp dir");
5851        let root = root_of(&dir);
5852        const ENTITY_COUNT: usize = 6;
5853        for index in 0..ENTITY_COUNT {
5854            init_repo_with_a_commit(&root.join(format!("repo-{index}")));
5855        }
5856
5857        let (core, launched) = started_and_settled(spec(vec![root]));
5858        let discovery_order: Vec<EntityKey> = launched
5859            .entities
5860            .iter()
5861            .map(|entity| entity.key.clone())
5862            .collect();
5863        assert_eq!(
5864            discovery_order.len(),
5865            ENTITY_COUNT,
5866            "expected every repo discovered"
5867        );
5868
5869        // The cursor row, then the visible rows (never the cursor's own row twice), then
5870        // everything else in discovery order: refresh.md's own three tiers, hand-assembled
5871        // the way `dispatch_order` would.
5872        let cursor = discovery_order[3].clone();
5873        let visible = [discovery_order[1].clone(), discovery_order[4].clone()];
5874        let mut three_tier_order = vec![cursor.clone()];
5875        three_tier_order.extend(visible.iter().cloned());
5876        for key in &discovery_order {
5877            if *key != cursor && !visible.contains(key) {
5878                three_tier_order.push(key.clone());
5879            }
5880        }
5881        assert_eq!(
5882            three_tier_order.len(),
5883            ENTITY_COUNT,
5884            "sanity check: the hand-built order must cover every discovered entity exactly \
5885             once"
5886        );
5887
5888        core.refresh(&three_tier_order);
5889        core.settle();
5890
5891        assert_eq!(
5892            core.dispatch_log_for_test(),
5893            three_tier_order,
5894            "refresh must dispatch phase C in exactly the order it was given: the cursor \
5895             row, then the visible rows, then the rest in discovery order"
5896        );
5897    }
5898
5899    /// The defining behaviour for the shared-handle probe path: discovery leaves
5900    /// one thread-safe handle per entity, and a `refresh` reuses that same `Arc`
5901    /// rather than opening the repository again, proven by pointer identity
5902    /// surviving a probe rather than by inference from timing.
5903    #[test]
5904    fn refresh_reuses_the_cached_repository_handle_rather_than_reopening_it() {
5905        let dir = tempfile::tempdir().expect("temp dir");
5906        let root = root_of(&dir);
5907        let repo = root.join("repo");
5908        init_repo_with_a_commit(&repo);
5909
5910        let core = Core::start_discovered(spec(vec![root]));
5911        let key = core.snapshot().entities[0].key.clone();
5912        let before = core
5913            .cached_repo_handle_for_test(&key)
5914            .expect("discovery should have cached a handle");
5915
5916        core.refresh(std::slice::from_ref(&key));
5917        core.settle();
5918
5919        let after = core
5920            .cached_repo_handle_for_test(&key)
5921            .expect("the cached handle should still be there after a refresh");
5922        assert!(
5923            Arc::ptr_eq(&before, &after),
5924            "a refresh must reuse the cached handle, not replace it with a new one"
5925        );
5926    }
5927
5928    /// `refresh_running` reads true from the instant `refresh` returns, before its spawned
5929    /// dispatch has raised a single probe: `refresh` reserves the Generation and records the
5930    /// dispatch debt on the calling thread, so a caller reading this the same frame it
5931    /// dispatched must never see a false "nothing outstanding". It reads false again once
5932    /// the Generation has fully landed.
5933    #[test]
5934    fn refresh_running_reads_true_the_instant_refresh_returns_and_false_once_it_settles() {
5935        let dir = tempfile::tempdir().expect("temp dir");
5936        let root = root_of(&dir);
5937        init_repo_with_a_commit(&root.join("repo"));
5938
5939        let core = Core::start_discovered(spec(vec![root]));
5940        core.settle();
5941        assert!(
5942            !core.refresh_running(),
5943            "sanity: nothing outstanding once startup has settled"
5944        );
5945
5946        let keys: Vec<EntityKey> = core
5947            .snapshot()
5948            .entities
5949            .iter()
5950            .map(|entity| entity.key.clone())
5951            .collect();
5952        core.refresh(&keys);
5953        assert!(
5954            core.refresh_running(),
5955            "refresh reserves its Generation and records the dispatch debt before it \
5956             returns, so this must already read true"
5957        );
5958
5959        core.settle();
5960        assert!(
5961            !core.refresh_running(),
5962            "settle blocks until nothing is outstanding, so this must read false once it \
5963             returns"
5964        );
5965    }
5966
5967    /// A key with no cached handle, either because it was never discovered or
5968    /// because discovery could not open it, still gets a real answer: the probe
5969    /// falls back to opening the repository itself rather than failing outright.
5970    #[test]
5971    fn probing_a_key_with_no_cached_handle_still_opens_the_repository_itself() {
5972        let dir = tempfile::tempdir().expect("temp dir");
5973        let root = root_of(&dir);
5974        let repo = root.join("repo");
5975        init_repo_with_a_commit(&repo);
5976
5977        // A core discovering an unrelated, empty root, so `repo` is never cached.
5978        let empty_root = root_of(&tempfile::tempdir().expect("temp dir"));
5979        let core = Core::start_discovered(spec(vec![empty_root]));
5980        let key = EntityKey::new(Arc::from(repo.as_path()));
5981        assert!(core.cached_repo_handle_for_test(&key).is_none());
5982
5983        let entity = core.probe_now(&key);
5984
5985        assert!(matches!(
5986            entity.branch.settled(),
5987            Some(Settled::Known {
5988                value: Head::Branch { .. },
5989                at: _,
5990                stale: _
5991            })
5992        ));
5993    }
5994
5995    /// An empty order names no key, so the Generation it starts must reach no entity at
5996    /// all. Read off the dispatch log and the in-flight flag rather than off an unprobed
5997    /// cell, since launch's own Generation has already filled every cell by here.
5998    #[test]
5999    fn an_empty_order_dispatches_nothing_and_settle_returns_immediately() {
6000        let dir = tempfile::tempdir().expect("temp dir");
6001        let root = root_of(&dir);
6002        let repo = root.join("repo");
6003        init_repo_with_a_commit(&repo);
6004
6005        let (core, _launched) = started_and_settled(spec(vec![root]));
6006        assert!(
6007            !core.dispatch_log_for_test().is_empty(),
6008            "launch dispatched nothing, so an empty log below would say nothing about the \
6009             empty order"
6010        );
6011
6012        core.refresh(&[]);
6013        core.wait_dispatched_for_test();
6014
6015        assert_eq!(
6016            core.dispatch_log_for_test(),
6017            Vec::new(),
6018            "an empty order must dispatch no probe"
6019        );
6020        // The number is the claim here, not a backstop: an order naming nobody raises no
6021        // probe, so the gate is already at zero and this must come back settled at once
6022        // rather than eventually.
6023        let settled = core
6024            .try_settle(Duration::from_millis(50))
6025            .expect("an empty order raises no probe, so the settle gate is already at zero");
6026        assert!(!settled.entities[0].branch.is_in_flight());
6027    }
6028
6029    /// One entity left owing a probe that nothing will ever complete: no tick is sent, so
6030    /// the deadline sweep that would otherwise time the cell out never runs, and the settle
6031    /// gate stays above zero for as long as anyone waits on it.
6032    ///
6033    /// Returns the live `Core` and the tick sender, which the caller must hold: dropping it
6034    /// stops the dedicated thread's own select arm, and a `Core` whose thread has gone is a
6035    /// different fixture from the one these waits mean to test.
6036    fn one_probe_owed_that_never_lands(
6037        dir: &tempfile::TempDir,
6038    ) -> (Core, crossbeam_channel::Sender<Instant>) {
6039        let root = root_of(dir);
6040        init_repo_with_a_commit(&root.join("repo"));
6041        let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
6042        let core = Core::start_for_test(spec(vec![root]), Duration::from_secs(3600), tick_rx)
6043            .discovered()
6044            .core;
6045        let key = settle_launch(&core).entities[0].key.clone();
6046        core.begin_untracked_probe_for_test(&key);
6047        (core, tick_tx)
6048    }
6049
6050    /// The defect this pair exists for: a settle that gives up used to be indistinguishable
6051    /// from one that succeeded, so the table it handed back was read as an answer and the
6052    /// run failed several steps downstream with nothing left naming the wait.
6053    ///
6054    /// [`Core::settle`]'s half is to report at the wait, the way `liveness::wait_for` does.
6055    /// Driven through `settle_within` rather than `settle` so the expiry path is exercised
6056    /// without waiting out a real backstop.
6057    #[test]
6058    #[should_panic(expected = "waiting for everything this Core has in flight to land")]
6059    fn a_settle_that_expires_reports_at_the_wait_rather_than_returning_the_table() {
6060        let dir = tempfile::tempdir().expect("temp dir");
6061        let (core, _tick_tx) = one_probe_owed_that_never_lands(&dir);
6062
6063        core.settle_within(Duration::from_millis(20));
6064    }
6065
6066    /// [`Core::try_settle`]'s half of the same claim, for the callers that mean to degrade
6067    /// rather than fail: the expiry comes back as `Err`, so the unsettled table can only be
6068    /// reached by a caller that has already acknowledged the wait gave up.
6069    #[test]
6070    fn try_settle_hands_an_expiry_back_as_an_error_carrying_the_table_it_gave_up_on() {
6071        let dir = tempfile::tempdir().expect("temp dir");
6072        let (core, _tick_tx) = one_probe_owed_that_never_lands(&dir);
6073
6074        let unsettled = core
6075            .try_settle(Duration::from_millis(20))
6076            .expect_err("a probe nothing will ever complete cannot settle");
6077
6078        assert!(
6079            unsettled.entities[0].branch.is_in_flight(),
6080            "the Err arm must still carry the table as it stood, so a caller that degrades \
6081             deliberately has something to degrade with"
6082        );
6083    }
6084
6085    /// The other arm, so the two are told apart by what actually happened rather than by
6086    /// `Err` being the only reachable answer.
6087    #[test]
6088    fn try_settle_hands_a_generation_that_really_landed_back_as_ok() {
6089        let dir = tempfile::tempdir().expect("temp dir");
6090        let root = root_of(&dir);
6091        init_repo_with_a_commit(&root.join("repo"));
6092
6093        let (core, launched) = started_and_settled(spec(vec![root]));
6094        let key = launched.entities[0].key.clone();
6095        core.refresh(std::slice::from_ref(&key));
6096
6097        let settled = core
6098            .try_settle(BACKSTOP)
6099            .expect("a dispatched Generation must land inside the backstop");
6100
6101        assert!(!settled.entities[0].branch.is_in_flight());
6102    }
6103
6104    /// A Launcher return re-probes one entity through `probe_now`, so every cell a
6105    /// Generation settles must settle here too. `sync` is the one most recently added and
6106    /// the one a merge is most likely to drop, since no other test reads it off this path.
6107    #[test]
6108    fn probe_now_settles_the_sync_cell_as_well_as_the_branch_it_depends_on() {
6109        let dir = tempfile::tempdir().expect("temp dir");
6110        let root = root_of(&dir);
6111        let repo = root.join("repo");
6112        init_repo_with_a_commit(&repo);
6113
6114        let core = Core::start_discovered(spec(vec![root]));
6115        let key = core.snapshot().entities[0].key.clone();
6116
6117        let entity = core.probe_now(&key);
6118
6119        assert!(
6120            matches!(
6121                entity.sync.settled(),
6122                Some(Settled::Known {
6123                    value: SyncState::NoRemote,
6124                    at: _,
6125                    stale: _
6126                })
6127            ),
6128            "expected probe_now to settle sync, got {:?}",
6129            entity.sync.settled()
6130        );
6131    }
6132
6133    /// The same guard as the `sync` one above, for `base`: `probe_now` must settle it
6134    /// too, not only the dispatch loop `refresh` drives.
6135    #[test]
6136    fn probe_now_settles_the_base_cell_as_well_as_the_branch_it_depends_on() {
6137        let dir = tempfile::tempdir().expect("temp dir");
6138        let root = root_of(&dir);
6139        let repo = root.join("repo");
6140        init_repo_with_a_commit(&repo);
6141
6142        let core = Core::start_discovered(spec(vec![root]));
6143        let key = core.snapshot().entities[0].key.clone();
6144
6145        let entity = core.probe_now(&key);
6146
6147        assert!(
6148            matches!(entity.base.settled(), Some(Settled::NotApplicable)),
6149            "expected probe_now to settle base Not applicable for a Repo with no remote, \
6150             got {:?}",
6151            entity.base.settled()
6152        );
6153    }
6154
6155    /// The end-to-end wiring `probe_now`'s own guard above cannot prove: a real
6156    /// `refresh` dispatch, through `CheapProbeOutcomes`, must land a genuine
6157    /// computed `base` count on the table, not just a Not-applicable fallback.
6158    #[test]
6159    fn refresh_settles_a_real_base_count_against_the_resolved_default_branch() {
6160        let dir = tempfile::tempdir().expect("temp dir");
6161        let root = root_of(&dir);
6162        let repo = root.join("repo");
6163        init_repo_with_a_commit(&repo);
6164        git(
6165            &repo,
6166            &[
6167                "remote",
6168                "add",
6169                "origin",
6170                "https://example.invalid/repo.git",
6171            ],
6172        );
6173        let root_sha = head_sha(&repo);
6174        // The default branch (`origin/main`, resolved through rung 3's name list
6175        // since no `origin/HEAD` exists) moves one commit ahead of this Repo's own
6176        // checked-out branch, which never gets its own upstream configured, so
6177        // `sync` reads `-` while `base` still has a resolved default branch to
6178        // count behind.
6179        git(&repo, &["commit", "--allow-empty", "-m", "second"]);
6180        let tip_sha = head_sha(&repo);
6181        git(&repo, &["reset", "--hard", &root_sha]);
6182        git(&repo, &["update-ref", "refs/remotes/origin/main", &tip_sha]);
6183
6184        let core = Core::start_discovered(spec(vec![root]));
6185        let key = core.snapshot().entities[0].key.clone();
6186
6187        core.refresh(std::slice::from_ref(&key));
6188        let settled = core.settle();
6189
6190        assert!(
6191            matches!(
6192                settled.entities[0].base.settled(),
6193                Some(Settled::Known {
6194                    value: 1,
6195                    at: _,
6196                    stale: _
6197                })
6198            ),
6199            "expected a real refresh to settle base's live count against the resolved \
6200             default branch, got {:?}",
6201            settled.entities[0].base.settled()
6202        );
6203    }
6204
6205    /// The same guard as the `sync` one above, for `dirty`: it is the cell most recently
6206    /// added to this path, and dropping its settle here leaves every other test green.
6207    /// The repo carries one untracked file so a settled cell has to hold the counted
6208    /// value, not a zeroed placeholder that a default-constructed `DirtyCounts` would
6209    /// also satisfy.
6210    #[test]
6211    fn probe_now_settles_the_dirty_cell_with_the_counts_it_probed() {
6212        let dir = tempfile::tempdir().expect("temp dir");
6213        let root = root_of(&dir);
6214        let repo = root.join("repo");
6215        init_repo_with_a_commit(&repo);
6216        fs::write(repo.join("untracked.txt"), "x").expect("write untracked file");
6217
6218        let core = Core::start_discovered(spec(vec![root]));
6219        let key = core.snapshot().entities[0].key.clone();
6220
6221        let entity = core.probe_now(&key);
6222
6223        assert!(
6224            matches!(
6225                entity.dirty.settled(),
6226                Some(Settled::Known {
6227                    value: DirtyCounts {
6228                        modified: 0,
6229                        untracked: 1,
6230                        deleted: 0,
6231                    },
6232                    at: _,
6233                    stale: _
6234                })
6235            ),
6236            "expected probe_now to settle dirty with the one untracked path, got {:?}",
6237            entity.dirty.settled()
6238        );
6239    }
6240
6241    #[test]
6242    fn probe_now_updates_the_entity_synchronously_with_no_refresh_call() {
6243        let dir = tempfile::tempdir().expect("temp dir");
6244        let root = root_of(&dir);
6245        let repo = root.join("repo");
6246        init_repo_with_a_commit(&repo);
6247
6248        let core = Core::start_discovered(spec(vec![root]));
6249        let key = core.snapshot().entities[0].key.clone();
6250
6251        let entity = core.probe_now(&key);
6252
6253        assert!(matches!(
6254            entity.branch.settled(),
6255            Some(Settled::Known {
6256                value: Head::Branch { .. },
6257                at: _,
6258                stale: _
6259            })
6260        ));
6261    }
6262
6263    /// The one-function guarantee: whether an entity's name is set by discovery at
6264    /// `Core::start` or by `probe_now`'s fallback insert for a key the table did
6265    /// not already know, both routes must produce the same string for the same
6266    /// path, since a future state file keys the Selection by this name and a
6267    /// second formatting of it would silently break restoring by name.
6268    #[test]
6269    fn the_display_name_agrees_between_discovery_and_probe_nows_fallback_insert() {
6270        let dir = tempfile::tempdir().expect("temp dir");
6271        let root = root_of(&dir);
6272        let repo = root.join("named-repo");
6273        init_repo_with_a_commit(&repo);
6274
6275        let core = Core::start_discovered(spec(vec![root]));
6276        let discovered = core.snapshot().entities[0].clone();
6277        assert_eq!(&*discovered.name, "named-repo");
6278
6279        core.dismiss(&discovered.key);
6280        assert!(core.snapshot().entities.is_empty());
6281
6282        let reinserted = core.probe_now(&discovered.key);
6283
6284        assert_eq!(
6285            reinserted.name, discovered.name,
6286            "the name discovery assigned and the name probe_now's fallback insert \
6287             assigns for the same path must be byte-identical"
6288        );
6289    }
6290
6291    #[test]
6292    fn dismiss_removes_the_entity_from_the_snapshot() {
6293        let dir = tempfile::tempdir().expect("temp dir");
6294        let root = root_of(&dir);
6295        let repo = root.join("repo");
6296        init_repo_with_a_commit(&repo);
6297
6298        let core = Core::start_discovered(spec(vec![root]));
6299        let key = core.snapshot().entities[0].key.clone();
6300
6301        core.dismiss(&key);
6302
6303        assert!(core.snapshot().entities.is_empty());
6304    }
6305
6306    /// Foundation for every criterion below: one entity's own steps run in order and a
6307    /// failure marks every later step `NotRun` rather than silently skipping it or
6308    /// running it anyway, exactly the closed set of four outcomes
6309    /// [config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md)'s
6310    /// "Actions" and `docs/spec/actions.md`'s "Step outcomes" both fix.
6311    ///
6312    /// The third step would succeed if it ran (`true` always exits zero), so its being
6313    /// stopped is what this test observes, not an accident of a step that would have
6314    /// failed anyway. It also writes a marker file rather than only exiting zero: a
6315    /// receipt correctly labelled `NotRun` is not, by itself, proof the step never ran
6316    /// (an implementation could execute a step and then paper over its result), so the
6317    /// missing file is evidence the receipt cannot fake.
6318    #[test]
6319    fn an_entitys_steps_run_in_order_and_a_failure_marks_every_later_step_not_run() {
6320        let dir = tempfile::tempdir().expect("temp dir");
6321        let root = root_of(&dir);
6322        let repo = root.join("repo");
6323        init_repo_with_a_commit(&repo);
6324        let marker = repo.join("step-three-ran");
6325
6326        let core = Core::start_discovered(spec(vec![root]));
6327        let key = core.snapshot().entities[0].key.clone();
6328        let steps = vec![
6329            step(&["true"]),
6330            step(&["sh", "-c", "exit 7"]),
6331            step(&["touch", "step-three-ran"]),
6332        ];
6333
6334        let started = core.run_action(action("reinstall", steps), std::slice::from_ref(&key));
6335
6336        assert!(started);
6337        wait_for("the fan-out to finish and write a receipt", || {
6338            !core.action_running()
6339        });
6340        let receipt = core.snapshot().entities[0]
6341            .last_action
6342            .clone()
6343            .expect("receipt written");
6344        assert_eq!(receipt.steps.len(), 3);
6345        assert_eq!(receipt.steps[0].outcome, StepOutcome::Ok);
6346        assert_eq!(receipt.steps[1].outcome, StepOutcome::Failed(7));
6347        assert_eq!(
6348            receipt.steps[2].outcome,
6349            StepOutcome::NotRun,
6350            "a step after a failure must be recorded NotRun, not silently dropped or run anyway"
6351        );
6352        assert!(
6353            !marker.exists(),
6354            "the third step's own `touch` must never have run: its marker file exists, so \
6355             the step ran despite being recorded NotRun"
6356        );
6357    }
6358
6359    /// Independent of stopping at a failure: three always-succeeding steps each append
6360    /// their own digit to the same file, so the file's final content pins the actual
6361    /// execution order rather than trusting that a linear scan of `action.steps` runs
6362    /// them in the sequence they were declared in.
6363    #[test]
6364    fn steps_run_in_the_order_theyre_declared_not_some_other_order() {
6365        let dir = tempfile::tempdir().expect("temp dir");
6366        let root = root_of(&dir);
6367        let repo = root.join("repo");
6368        init_repo_with_a_commit(&repo);
6369        let order_log = repo.join("order.log");
6370
6371        let core = Core::start_discovered(spec(vec![root]));
6372        let key = core.snapshot().entities[0].key.clone();
6373        let steps = vec![
6374            step(&["sh", "-c", "printf 1 >> order.log"]),
6375            step(&["sh", "-c", "printf 2 >> order.log"]),
6376            step(&["sh", "-c", "printf 3 >> order.log"]),
6377        ];
6378
6379        let started = core.run_action(action("ordering", steps), std::slice::from_ref(&key));
6380
6381        assert!(started);
6382        wait_for("the fan-out to finish and write a receipt", || {
6383            !core.action_running()
6384        });
6385        let receipt = core.snapshot().entities[0]
6386            .last_action
6387            .clone()
6388            .expect("receipt written");
6389        assert_eq!(receipt.steps.len(), 3);
6390        assert!(
6391            receipt
6392                .steps
6393                .iter()
6394                .all(|result| result.outcome == StepOutcome::Ok),
6395            "every step here always exits zero; this test isolates ordering from gating"
6396        );
6397        let content = fs::read_to_string(&order_log).expect("order.log written by the steps");
6398        assert_eq!(
6399            content, "123",
6400            "the file's content pins actual execution order; running the steps out of \
6401             declaration order would produce a different digit sequence here even though \
6402             every step still succeeds"
6403        );
6404    }
6405
6406    /// `docs/spec/actions.md`'s "The run on screen": a reader must see a step's own
6407    /// finished output "as it arrives", not only once the whole entity's run has ended.
6408    /// The second step sleeps long enough to give a poll a real window to observe the
6409    /// receipt mid-run; a version of `run_action_for_entity` that only wrote once, at the
6410    /// end, would never let this test observe `running: Some(_)` at all; it would either
6411    /// see no receipt (before) or the whole finished one (after), never the state in
6412    /// between where the first step is done and the second is still going.
6413    #[test]
6414    fn a_still_running_actions_finished_step_and_its_currently_executing_one_are_both_visible_before_the_whole_run_ends()
6415     {
6416        let dir = tempfile::tempdir().expect("temp dir");
6417        let root = root_of(&dir);
6418        let repo = root.join("repo");
6419        init_repo_with_a_commit(&repo);
6420
6421        let core = Core::start_discovered(spec(vec![root]));
6422        let key = core.snapshot().entities[0].key.clone();
6423        let steps = vec![step(&["true"]), step(&["sh", "-c", "sleep 0.5"])];
6424
6425        let started = core.run_action(action("reinstall", steps), std::slice::from_ref(&key));
6426        assert!(started);
6427
6428        // Waits specifically for the *second* step's own running receipt, not merely any
6429        // one: under a slow or busy machine the first step (`true`) can still be the one
6430        // reported running the first time this poll checks, which would assert the wrong
6431        // step's own shape below rather than a flaky pass.
6432        wait_for(
6433            "a receipt naming the second step running before the run finished",
6434            || {
6435                core.snapshot().entities[0]
6436                    .last_action
6437                    .as_ref()
6438                    .and_then(|receipt| receipt.running.as_ref())
6439                    .is_some_and(|running| running.label.contains("sleep"))
6440            },
6441        );
6442        let mid_run = core.snapshot().entities[0]
6443            .last_action
6444            .clone()
6445            .expect("receipt written");
6446        assert_eq!(
6447            mid_run.steps.len(),
6448            1,
6449            "the first, already-finished step must already be in `steps`"
6450        );
6451        assert_eq!(mid_run.steps[0].outcome, StepOutcome::Ok);
6452        let running = mid_run.running.expect("a step must be recorded running");
6453        assert!(
6454            running.label.contains("sleep"),
6455            "expected the running step's own label, got {:?}",
6456            running.label
6457        );
6458
6459        wait_for("the fan-out to finish", || !core.action_running());
6460        let finished = core.snapshot().entities[0]
6461            .last_action
6462            .clone()
6463            .expect("receipt written");
6464        assert!(
6465            finished.running.is_none(),
6466            "a finished receipt must carry no running step"
6467        );
6468        assert_eq!(finished.steps.len(), 2);
6469    }
6470
6471    /// `Step::shell` must actually reach the child, end to end through `run_action`,
6472    /// not merely be a field that parses. Prints `$0` inside the step's own
6473    /// command string: `sh -c <string>` with no third argument would leave `$0` reading
6474    /// whatever the shell defaults it to, never the literal `repon`
6475    /// [config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md)'s
6476    /// `shell = true` sentence requires. `executor.rs`'s own unit tests cover `run_step`
6477    /// directly; this proves `core.rs` actually sets `shell` on the `Step` it builds and
6478    /// passes it through.
6479    #[test]
6480    fn a_shell_true_step_runs_through_shell_c_with_repon_as_its_own_dollar_zero() {
6481        let dir = tempfile::tempdir().expect("temp dir");
6482        let root = root_of(&dir);
6483        let repo = root.join("repo");
6484        init_repo_with_a_commit(&repo);
6485
6486        let core = Core::start_discovered(spec(vec![root]));
6487        let key = core.snapshot().entities[0].key.clone();
6488        let steps = vec![shell_step("echo \"[$0]\"")];
6489
6490        let started = core.run_action(action("shell-step", steps), std::slice::from_ref(&key));
6491
6492        assert!(started);
6493        wait_for("the fan-out to finish and write a receipt", || {
6494            !core.action_running()
6495        });
6496        let receipt = core.snapshot().entities[0]
6497            .last_action
6498            .clone()
6499            .expect("receipt written");
6500        assert_eq!(receipt.steps.len(), 1);
6501        assert_eq!(receipt.steps[0].outcome, StepOutcome::Ok);
6502        assert_eq!(&*receipt.steps[0].output, b"[repon]\n");
6503        assert!(
6504            receipt.steps[0].shell,
6505            "the receipt's own StepResult::shell must carry the mode the step ran under"
6506        );
6507    }
6508
6509    /// `Step::interactive` must actually reach `run_step` end to end through `run_action`,
6510    /// the same proof `a_shell_true_step_runs_through_shell_c_with_repon_as_its_own_dollar_zero`
6511    /// already gives `shell`: this asserts `core.rs` sets `interactive` on the `Step` it
6512    /// builds and that the receipt carries it back, not the shell's own rc-sourcing
6513    /// behaviour, which `executor.rs`'s own `shell_argv` unit test already covers on the
6514    /// constructed argv.
6515    #[test]
6516    fn an_interactive_shell_true_step_runs_through_run_action_with_interactive_on_its_receipt() {
6517        let dir = tempfile::tempdir().expect("temp dir");
6518        let root = root_of(&dir);
6519        let repo = root.join("repo");
6520        init_repo_with_a_commit(&repo);
6521
6522        let core = Core::start_discovered(spec(vec![root]));
6523        let key = core.snapshot().entities[0].key.clone();
6524        let steps = vec![interactive_shell_step("true")];
6525
6526        let started = core.run_action(
6527            action("interactive-step", steps),
6528            std::slice::from_ref(&key),
6529        );
6530
6531        assert!(started);
6532        wait_for("the fan-out to finish and write a receipt", || {
6533            !core.action_running()
6534        });
6535        let receipt = core.snapshot().entities[0]
6536            .last_action
6537            .clone()
6538            .expect("receipt written");
6539        assert_eq!(receipt.steps.len(), 1);
6540        assert_eq!(receipt.steps[0].outcome, StepOutcome::Ok);
6541        assert!(
6542            receipt.steps[0].shell,
6543            "an interactive step is still a shell step"
6544        );
6545        assert!(
6546            receipt.steps[0].interactive,
6547            "the receipt's own StepResult::interactive must carry the mode the step ran under"
6548        );
6549    }
6550
6551    /// [`StepResult::shell`]'s own claim on the plain argv side, so the two modes are
6552    /// proven end to end through `run_action` rather than only `shell = true`: an ordinary
6553    /// step's receipt must read `false`, not merely default to it by construction.
6554    #[test]
6555    fn an_argv_step_runs_through_run_action_with_shell_false_on_its_receipt() {
6556        let dir = tempfile::tempdir().expect("temp dir");
6557        let root = root_of(&dir);
6558        let repo = root.join("repo");
6559        init_repo_with_a_commit(&repo);
6560
6561        let core = Core::start_discovered(spec(vec![root]));
6562        let key = core.snapshot().entities[0].key.clone();
6563        let steps = vec![Step {
6564            argv: vec!["true".to_string()],
6565            shell: false,
6566            interactive: false,
6567            env: Vec::new(),
6568        }];
6569
6570        let started = core.run_action(action("argv-step", steps), std::slice::from_ref(&key));
6571
6572        assert!(started);
6573        wait_for("the fan-out to finish and write a receipt", || {
6574            !core.action_running()
6575        });
6576        let receipt = core.snapshot().entities[0]
6577            .last_action
6578            .clone()
6579            .expect("receipt written");
6580        assert!(!receipt.steps[0].shell);
6581    }
6582
6583    /// Criterion 3's first half. `begin_shared_generation_for_test` puts the entity
6584    /// in flight against a Generation of its own, exactly as a real `refresh` would;
6585    /// this proves `run_action` cancels that Generation's own flag rather than merely
6586    /// starting alongside it, which is the difference between the 0.85s and 3.14s
6587    /// measurements `docs/spec/actions.md`'s "Refreshing around a run" reports.
6588    #[test]
6589    fn starting_an_action_cancels_any_generation_already_in_flight() {
6590        let dir = tempfile::tempdir().expect("temp dir");
6591        let root = root_of(&dir);
6592        let repo = root.join("repo");
6593        init_repo_with_a_commit(&repo);
6594
6595        let core = Core::start_discovered(spec(vec![root]));
6596        let key = core.snapshot().entities[0].key.clone();
6597        let in_flight = core.begin_shared_generation_for_test(std::slice::from_ref(&key));
6598        let cancel = in_flight
6599            .cancels
6600            .get(&key)
6601            .expect("the in-flight entity has a cancel flag")
6602            .clone();
6603        assert!(!cancel.load(Ordering::Acquire));
6604
6605        let started = core.run_action(
6606            action("reinstall", vec![step(&["true"])]),
6607            std::slice::from_ref(&key),
6608        );
6609
6610        assert!(started);
6611        assert!(
6612            cancel.load(Ordering::Acquire),
6613            "starting an Action must cancel a Generation already in flight, not share \
6614             execution with it"
6615        );
6616        // Drain the fan-out and its completion refresh so this test's background
6617        // thread does not outlive it.
6618        wait_for("the fan-out and its completion refresh to drain", || {
6619            !core.action_running()
6620        });
6621    }
6622
6623    /// Criterion 3's second half, and the double-refresh mutation this test is written
6624    /// to catch: a completed Action starting its own Generation *and* a second one
6625    /// left over from a naive implementation that also called `refresh` directly would
6626    /// both leave every entity settled, so counting settled entities alone cannot tell
6627    /// zero, one and two apart. Reading the table's own `generation` number after
6628    /// completion can: it must be the Generation immediately after the settled table
6629    /// this Action ran against, covering both entities although the Action only ever
6630    /// named one of them. Named by its order rather than by a number, so what launch
6631    /// itself mints cannot renumber the claim.
6632    #[test]
6633    fn a_finished_action_starts_exactly_one_generation_over_every_known_entity() {
6634        let dir = tempfile::tempdir().expect("temp dir");
6635        let root = root_of(&dir);
6636        let acted_on = root.join("acted-on");
6637        let untouched = root.join("untouched");
6638        init_repo_with_a_commit(&acted_on);
6639        init_repo_with_a_commit(&untouched);
6640
6641        let (core, before) = started_and_settled(spec(vec![root]));
6642        let acted_key = before
6643            .entities
6644            .iter()
6645            .find(|entity| entity.key.path() == acted_on)
6646            .expect("the acted-on entity is discovered")
6647            .key
6648            .clone();
6649
6650        let started = core.run_action(
6651            action("reinstall", vec![step(&["true"])]),
6652            std::slice::from_ref(&acted_key),
6653        );
6654
6655        assert!(started);
6656        wait_for(
6657            "the completion Generation to probe every known entity, including the one the \
6658             Action never touched",
6659            || {
6660                let snapshot = core.snapshot();
6661                snapshot.generation != before.generation
6662                    && snapshot.entities.iter().all(|entity| {
6663                        matches!(
6664                            entity.branch.settled(),
6665                            Some(Settled::Known {
6666                                value: _,
6667                                at: _,
6668                                stale: _
6669                            })
6670                        )
6671                    })
6672            },
6673        );
6674        assert_eq!(
6675            core.settle().generation,
6676            before.generation.successor(),
6677            "completion must start exactly one Generation: not zero (no refresh at all) and \
6678             not two (a double refresh)"
6679        );
6680    }
6681
6682    /// A completion dispatches its Generation while its own run is still admitted, and a
6683    /// submission arriving before that release is refused. Together those are what keeps a
6684    /// completion from dispatching over a run that replaced it: the next run's admission,
6685    /// and the cancellation it performs on the way in, can only ever follow a Generation
6686    /// this one has already started.
6687    ///
6688    /// [`Core::action_completion_boundary`] holds the completion between the two, the one
6689    /// place either half is observable: they are adjacent statements, so a test racing them
6690    /// reads whichever it happened to catch.
6691    #[test]
6692    fn a_completion_dispatches_its_generation_before_releasing_its_run() {
6693        let dir = tempfile::tempdir().expect("temp dir");
6694        let root = root_of(&dir);
6695        let repo = root.join("repo");
6696        init_repo_with_a_commit(&repo);
6697
6698        let (core, before) = started_and_settled(spec(vec![root]));
6699        let key = before.entities[0].key.clone();
6700        let armed = core.action_completion_boundary().arm();
6701
6702        assert!(core.run_action(
6703            action("finishing", vec![step(&["true"])]),
6704            std::slice::from_ref(&key)
6705        ));
6706        armed.wait_until_reached();
6707
6708        assert_eq!(
6709            core.snapshot().generation,
6710            before.generation.successor(),
6711            "the completion Generation must be dispatched before the run releases its \
6712             admission"
6713        );
6714        assert!(
6715            !core.run_action(
6716                action("racing", vec![step(&["true"])]),
6717                std::slice::from_ref(&key)
6718            ),
6719            "a submission before that release must be refused, so what a run cancels on the \
6720             way in is never a Generation the run it replaced has yet to dispatch"
6721        );
6722
6723        drop(armed);
6724        wait_for("the finished run to release its admission", || {
6725            !core.action_running()
6726        });
6727    }
6728
6729    /// Criterion 5. The excluded row gets the one legitimate `not_applicable` receipt
6730    /// with no steps; the acted-on row's own step is made to fail, which is the strong
6731    /// half of the claim: a receipt with steps that failed is still not the
6732    /// `not_applicable` shape, so nothing but an excluded row can ever produce it.
6733    #[test]
6734    fn an_excluded_row_swept_into_an_action_gets_a_not_applicable_receipt_and_no_other_path_does() {
6735        let dir = tempfile::tempdir().expect("temp dir");
6736        let root = root_of(&dir);
6737        let excluded_repo = root.join("excluded");
6738        let normal_repo = root.join("normal");
6739        init_repo_with_a_commit(&excluded_repo);
6740        init_repo_with_a_commit(&normal_repo);
6741
6742        let core = Core::start_discovered(spec_with_overrides(
6743            vec![root],
6744            vec![RepoOverride {
6745                path: excluded_repo.clone(),
6746                default_branch: None,
6747                excluded: true,
6748            }],
6749        ));
6750        let snapshot = core.snapshot();
6751        let find = |path: &Path| {
6752            snapshot
6753                .entities
6754                .iter()
6755                .find(|entity| entity.key.path() == path)
6756                .unwrap_or_else(|| panic!("entity at {path:?} present"))
6757                .key
6758                .clone()
6759        };
6760        let excluded_key = find(&excluded_repo);
6761        let normal_key = find(&normal_repo);
6762        assert!(
6763            snapshot
6764                .entities
6765                .iter()
6766                .find(|entity| entity.key == excluded_key)
6767                .unwrap()
6768                .excluded
6769        );
6770
6771        let started = core.run_action(
6772            action("reinstall", vec![step(&["sh", "-c", "exit 3"])]),
6773            &[excluded_key.clone(), normal_key.clone()],
6774        );
6775
6776        assert!(started);
6777        // `!core.action_running()`, not merely "both entities have some receipt": a
6778        // still-running entity now writes an intermediate receipt naming its currently
6779        // executing step before it finishes (`docs/spec/actions.md`'s "The run on screen"),
6780        // so `last_action.is_some()` alone can be true well before `normal_key`'s own step
6781        // has actually run.
6782        wait_for("the fan-out to finish", || !core.action_running());
6783
6784        let after = core.snapshot();
6785        let receipt_of = |key: &EntityKey| {
6786            after
6787                .entities
6788                .iter()
6789                .find(|entity| entity.key == *key)
6790                .unwrap()
6791                .last_action
6792                .clone()
6793                .unwrap()
6794        };
6795        let excluded_receipt = receipt_of(&excluded_key);
6796        assert!(excluded_receipt.not_applicable());
6797        assert!(excluded_receipt.steps.is_empty());
6798
6799        let normal_receipt = receipt_of(&normal_key);
6800        assert!(
6801            !normal_receipt.not_applicable(),
6802            "a row that actually ran a step, even a failing one, must never read as \
6803             not_applicable: an excluded row is the one legitimate producer of that outcome"
6804        );
6805        assert!(!normal_receipt.steps.is_empty());
6806        assert!(normal_receipt.failed());
6807    }
6808
6809    /// Criterion 4: `operable_count` and `run_action`'s own partition must be one
6810    /// computation, not two that happen to agree today. Proven against independent
6811    /// evidence, the same way the test above does: run an Action over one excluded and
6812    /// one normal entity, then check `operable_count`'s answer against how many of the
6813    /// two actually got a real (not `not_applicable`) receipt, rather than against a
6814    /// second hand-written copy of the exclusion rule.
6815    #[test]
6816    fn operable_count_matches_how_many_entities_run_action_actually_runs_a_step_against() {
6817        let dir = tempfile::tempdir().expect("temp dir");
6818        let root = root_of(&dir);
6819        let excluded_repo = root.join("excluded");
6820        let normal_repo = root.join("normal");
6821        init_repo_with_a_commit(&excluded_repo);
6822        init_repo_with_a_commit(&normal_repo);
6823
6824        let core = Core::start_discovered(spec_with_overrides(
6825            vec![root],
6826            vec![RepoOverride {
6827                path: excluded_repo.clone(),
6828                default_branch: None,
6829                excluded: true,
6830            }],
6831        ));
6832        let snapshot = core.snapshot();
6833        let find = |path: &Path| {
6834            snapshot
6835                .entities
6836                .iter()
6837                .find(|entity| entity.key.path() == path)
6838                .unwrap_or_else(|| panic!("entity at {path:?} present"))
6839                .key
6840                .clone()
6841        };
6842        let order = [find(&excluded_repo), find(&normal_repo)];
6843
6844        assert_eq!(
6845            core.operable_count(&order),
6846            1,
6847            "one of the two rows is excluded, so exactly one is operable"
6848        );
6849
6850        let started = core.run_action(action("reinstall", vec![step(&["true"])]), &order);
6851        assert!(started);
6852
6853        wait_for("every entity in the order to carry a receipt", || {
6854            let snapshot = core.snapshot();
6855            order.iter().all(|key| {
6856                snapshot
6857                    .entities
6858                    .iter()
6859                    .find(|entity| entity.key == *key)
6860                    .and_then(|entity| entity.last_action.as_ref())
6861                    .is_some()
6862            })
6863        });
6864
6865        let after = core.snapshot();
6866        let actually_ran = after
6867            .entities
6868            .iter()
6869            .filter(|entity| order.contains(&entity.key))
6870            .filter(|entity| {
6871                entity
6872                    .last_action
6873                    .as_ref()
6874                    .is_some_and(|receipt| !receipt.not_applicable())
6875            })
6876            .count();
6877
6878        assert_eq!(
6879            core.operable_count(&order),
6880            actually_ran,
6881            "operable_count must report exactly how many rows run_action actually ran a \
6882             step against, not merely how many keys resolved"
6883        );
6884    }
6885
6886    /// [`Core::run_action_for_entity_blocking`]'s own reason to exist: it returns the
6887    /// finished receipt on the calling thread rather than handing the run off, so a caller
6888    /// needs no `wait_for` at all to see the step's own effect, unlike every `run_action`
6889    /// test above.
6890    #[test]
6891    fn run_action_for_entity_blocking_returns_the_finished_receipt_on_the_calling_thread() {
6892        let dir = tempfile::tempdir().expect("temp dir");
6893        let root = root_of(&dir);
6894        let repo = root.join("repo");
6895        init_repo_with_a_commit(&repo);
6896        let marker = repo.join("hook-ran");
6897
6898        let core = Core::start_discovered(spec_with_overrides(vec![root], Vec::new()));
6899        let key = core
6900            .snapshot()
6901            .entities
6902            .iter()
6903            .find(|entity| entity.key.path() == repo)
6904            .expect("the repo is discovered")
6905            .key
6906            .clone();
6907
6908        let receipt = core
6909            .run_action_for_entity_blocking(
6910                &action("hook", vec![step(&["touch", "hook-ran"])]),
6911                &key,
6912            )
6913            .expect("the entity is known");
6914
6915        assert!(
6916            marker.exists(),
6917            "the step must have already run by the time this call returns"
6918        );
6919        assert_eq!(receipt.steps.len(), 1);
6920        assert_eq!(receipt.steps[0].outcome, StepOutcome::Ok);
6921    }
6922
6923    /// [`step`], plus the one environment entry a `test-util` build reads an injected PTY
6924    /// setup failure from: the step reports that resource's own failure instead of ever
6925    /// spawning a child.
6926    fn step_that_cannot_prepare(argv: &[&str], resource: &str) -> Step {
6927        Step {
6928            env: vec![(
6929                executor::SETUP_FAILURE_VARIABLE.to_string(),
6930                resource.to_string(),
6931            )],
6932            ..step(argv)
6933        }
6934    }
6935
6936    /// A step whose own PTY setup fails is a failed receipt the run hands back, not a step
6937    /// that never returns: the failure names the resource, the rest of the run reports
6938    /// `NotRun`, and a later Action against the same row still succeeds. Run off this
6939    /// thread and collected through the liveness backstop, since the claim under test is
6940    /// that these calls return at all.
6941    #[test]
6942    fn a_step_whose_pty_setup_fails_finishes_the_run_and_leaves_a_later_action_working() {
6943        let dir = tempfile::tempdir().expect("temp dir");
6944        let root = root_of(&dir);
6945        let repo = root.join("repo");
6946        init_repo_with_a_commit(&repo);
6947
6948        let core = Core::start_discovered(spec_with_overrides(vec![root], Vec::new()));
6949        let key = core
6950            .snapshot()
6951            .entities
6952            .iter()
6953            .find(|entity| entity.key.path() == repo)
6954            .expect("the repo is discovered")
6955            .key
6956            .clone();
6957
6958        let (tx, rx) = mpsc::channel();
6959        thread::spawn(move || {
6960            let faulted = core.run_action_for_entity_blocking(
6961                &action(
6962                    "hook",
6963                    vec![
6964                        step_that_cannot_prepare(&["touch", "first-ran"], "notify-pipe"),
6965                        step(&["touch", "second-ran"]),
6966                    ],
6967                ),
6968                &key,
6969            );
6970            let later = core.run_action_for_entity_blocking(
6971                &action("hook", vec![step(&["touch", "later-ran"])]),
6972                &key,
6973            );
6974            let _ = tx.send((faulted, later));
6975        });
6976        let (faulted, later) = rx
6977            .recv_timeout(BACKSTOP)
6978            .expect("a run whose first step cannot prepare its pty must still hand back receipts");
6979
6980        let faulted = faulted.expect("the entity is known");
6981        assert!(
6982            matches!(faulted.steps[0].outcome, StepOutcome::Failed(code) if code != 0),
6983            "expected the first step to fail, got {:?}",
6984            faulted.steps[0].outcome
6985        );
6986        let detail = String::from_utf8_lossy(&faulted.steps[0].output).to_string();
6987        assert!(
6988            detail.contains("pipe that notices"),
6989            "expected the receipt to name the resource that failed, got {detail:?}"
6990        );
6991        assert_eq!(faulted.steps[1].outcome, StepOutcome::NotRun);
6992        assert!(
6993            !repo.join("first-ran").exists() && !repo.join("second-ran").exists(),
6994            "a step that never prepared its pty must never have run its command"
6995        );
6996
6997        let later = later.expect("the entity is known");
6998        assert_eq!(later.steps[0].outcome, StepOutcome::Ok);
6999        assert!(
7000            repo.join("later-ran").exists(),
7001            "a later Action must still run its own command"
7002        );
7003    }
7004
7005    /// `None` rather than a receipt for a key the table does not know: the same fallback
7006    /// every other key-addressed `Core` entry point gives one, and the caller's own signal
7007    /// for "no hook to consult" when a hook names a row `sync`'s own eligibility has already
7008    /// dropped.
7009    #[test]
7010    fn run_action_for_entity_blocking_answers_none_for_an_unknown_key() {
7011        let dir = tempfile::tempdir().expect("temp dir");
7012        let root = root_of(&dir);
7013        let core = Core::start_discovered(spec_with_overrides(vec![root.clone()], Vec::new()));
7014
7015        let unknown = EntityKey::new(Arc::from(root.join("never-discovered").as_path()));
7016
7017        assert!(
7018            core.run_action_for_entity_blocking(&action("hook", vec![step(&["true"])]), &unknown)
7019                .is_none()
7020        );
7021    }
7022
7023    /// The reversed decision itself: `when` now decides what runs, not only what a palette
7024    /// reports about it. A row the predicate proves runs a real step; a row it disproves
7025    /// gets a `Skip::Inapplicable` receipt with no steps and never spawns a child process at
7026    /// all, which the failing command below would have surfaced as a `Failed` step had it
7027    /// run (`docs/spec/actions.md`'s "The Selection and the gate", reversing what that
7028    /// section originally decided).
7029    #[test]
7030    fn run_action_skips_a_row_its_when_predicate_disproves_rather_than_running_it_anyway() {
7031        let dir = tempfile::tempdir().expect("temp dir");
7032        let root = root_of(&dir);
7033        let proved_repo = root.join("alpha");
7034        let disproved_repo = root.join("beta");
7035        init_repo_with_a_commit(&proved_repo);
7036        init_repo_with_a_commit(&disproved_repo);
7037
7038        let core = Core::start_discovered(spec(vec![root]));
7039        let snapshot = core.snapshot();
7040        let find = |path: &Path| {
7041            snapshot
7042                .entities
7043                .iter()
7044                .find(|entity| entity.key.path() == path)
7045                .unwrap_or_else(|| panic!("entity at {path:?} present"))
7046                .key
7047                .clone()
7048        };
7049        let proved_key = find(&proved_repo);
7050        let disproved_key = find(&disproved_repo);
7051        let order = [proved_key.clone(), disproved_key.clone()];
7052
7053        // A command that would mark a real run `Failed` if it ever ran, so a disproved row
7054        // that wrongly ran a step is caught by its own outcome rather than only by `skip`.
7055        let started = core.run_action(
7056            action_with_when(
7057                "reinstall",
7058                vec![step(&["sh", "-c", "exit 3"])],
7059                "name:alpha",
7060            ),
7061            &order,
7062        );
7063        assert!(started);
7064        wait_for("the fan-out to finish", || !core.action_running());
7065
7066        let after = core.snapshot();
7067        let receipt_of = |key: &EntityKey| {
7068            after
7069                .entities
7070                .iter()
7071                .find(|entity| entity.key == *key)
7072                .unwrap()
7073                .last_action
7074                .clone()
7075                .unwrap()
7076        };
7077
7078        let proved_receipt = receipt_of(&proved_key);
7079        assert_eq!(
7080            proved_receipt.skip, None,
7081            "the row the predicate proved must actually run"
7082        );
7083        assert!(proved_receipt.failed(), "its own step still ran and failed");
7084
7085        let disproved_receipt = receipt_of(&disproved_key);
7086        assert!(
7087            disproved_receipt.inapplicable(),
7088            "the row the predicate disproved must be skipped rather than run"
7089        );
7090        assert!(disproved_receipt.steps.is_empty());
7091        assert!(
7092            !disproved_receipt.failed(),
7093            "a skipped row never ran a step, so it cannot have failed one"
7094        );
7095    }
7096
7097    /// An excluded row is subtracted before an Action's `when` ever sees it, so the
7098    /// predicate narrows what is left rather than replacing that subtraction
7099    /// (`docs/spec/actions.md`'s "The Selection and the gate").
7100    ///
7101    /// Proven against `operable_count` itself rather than against a hand-written expectation:
7102    /// a predicate every remaining row satisfies must leave a total identical to that count,
7103    /// which it cannot do if the excluded row reached the tally under any of the three
7104    /// headings.
7105    #[test]
7106    fn applicability_subtracts_an_excluded_row_before_the_predicate_reads_it() {
7107        let dir = tempfile::tempdir().expect("temp dir");
7108        let root = root_of(&dir);
7109        let excluded_repo = root.join("excluded");
7110        let normal_repo = root.join("normal");
7111        init_repo_with_a_commit(&excluded_repo);
7112        init_repo_with_a_commit(&normal_repo);
7113
7114        let core = Core::start_discovered(spec_with_overrides(
7115            vec![root],
7116            vec![RepoOverride {
7117                path: excluded_repo.clone(),
7118                default_branch: None,
7119                excluded: true,
7120            }],
7121        ));
7122        let order: Vec<EntityKey> = core
7123            .snapshot()
7124            .entities
7125            .iter()
7126            .map(|entity| entity.key.clone())
7127            .collect();
7128        assert_eq!(order.len(), 2, "the fixture must discover both repos");
7129
7130        let counts = core.applicability(&order, &Filter::parse("kind:repo"));
7131
7132        assert_eq!(
7133            counts.total(),
7134            core.operable_count(&order),
7135            "the predicate must be counted over exactly the rows `operable_count` keeps"
7136        );
7137        assert_eq!(
7138            counts,
7139            Applicability {
7140                applicable: 1,
7141                inapplicable: 0,
7142                unresolved: 0,
7143            }
7144        );
7145    }
7146
7147    /// An unknown key (already dismissed, or never discovered) is silently dropped from
7148    /// the count, the same fallback `run_action` gives one: this is the half of
7149    /// `partition_operable` no fixture above exercises, since every key there resolves.
7150    #[test]
7151    fn operable_count_silently_drops_a_key_that_no_longer_resolves() {
7152        let dir = tempfile::tempdir().expect("temp dir");
7153        let root = root_of(&dir);
7154        let repo = root.join("repo");
7155        init_repo_with_a_commit(&repo);
7156
7157        let core = Core::start_discovered(spec(vec![root]));
7158        let real_key = core.snapshot().entities[0].key.clone();
7159        let unknown_key = EntityKey::new(Arc::from(dir.path().join("never-discovered")));
7160
7161        assert_eq!(core.operable_count(&[real_key, unknown_key]), 1);
7162    }
7163
7164    /// Criterion 6. The second call is rejected synchronously (admission refuses it before
7165    /// anything else runs), so this needs no waiting to observe; only the cleanup wait at
7166    /// the end needs [`wait_for`].
7167    #[test]
7168    fn only_one_action_fan_out_runs_at_a_time_a_second_call_is_rejected_while_one_is_live() {
7169        let dir = tempfile::tempdir().expect("temp dir");
7170        let root = root_of(&dir);
7171        let repo = root.join("repo");
7172        init_repo_with_a_commit(&repo);
7173
7174        let core = Core::start_discovered(spec(vec![root]));
7175        let key = core.snapshot().entities[0].key.clone();
7176        let slow = action("first", vec![step(&["sh", "-c", "sleep 0.3"])]);
7177        let fast = action("second", vec![step(&["true"])]);
7178
7179        let first_started = core.run_action(slow, std::slice::from_ref(&key));
7180        let second_started = core.run_action(fast, std::slice::from_ref(&key));
7181
7182        assert!(first_started);
7183        assert!(
7184            !second_started,
7185            "a second run_action call must be rejected while the first is still in flight"
7186        );
7187        wait_for("the accepted first fan-out to finish", || {
7188            !core.action_running()
7189        });
7190        let receipt = core.snapshot().entities[0].last_action.clone().unwrap();
7191        assert_eq!(
7192            &*receipt.label, "first",
7193            "the surviving receipt must be the accepted first run's, never the rejected second"
7194        );
7195    }
7196
7197    /// Refusing a submission must leave the live run exactly as it was: the refused call
7198    /// registers no control of its own, so the run already in flight is still the one
7199    /// `stop_action` reaches.
7200    ///
7201    /// A guard on the refusal path rather than a reproduction of anything: refusing has
7202    /// always returned before touching a control, and this pins that it still does. Both
7203    /// steps sleep [`FIXTURE_LIFETIME`], since the outcomes below cannot tell a cancelled
7204    /// step from one that reached its own end inside the wait watching it.
7205    #[test]
7206    fn a_refused_second_submission_leaves_the_first_action_still_stoppable() {
7207        let dir = tempfile::tempdir().expect("temp dir");
7208        let root = root_of(&dir);
7209        let repo = root.join("repo");
7210        init_repo_with_a_commit(&repo);
7211
7212        let core = Core::start_discovered(spec(vec![root]));
7213        let key = core.snapshot().entities[0].key.clone();
7214        let sleep_past_the_backstop = format!("sleep {}", FIXTURE_LIFETIME.as_secs());
7215        let live = action(
7216            "live",
7217            vec![
7218                step(&["sh", "-c", &sleep_past_the_backstop]),
7219                step(&["sh", "-c", &sleep_past_the_backstop]),
7220            ],
7221        );
7222
7223        assert!(core.run_action(live, std::slice::from_ref(&key)));
7224        wait_for("the live run's own first step to start", || {
7225            core.snapshot().entities[0]
7226                .last_action
7227                .as_ref()
7228                .is_some_and(|receipt| receipt.running.is_some())
7229        });
7230
7231        assert!(
7232            !core.run_action(
7233                action("refused", vec![step(&["true"])]),
7234                std::slice::from_ref(&key)
7235            ),
7236            "a second submission must be refused while one run is still live"
7237        );
7238
7239        core.stop_action();
7240
7241        wait_for("the still-controllable run to come down", || {
7242            !core.action_running()
7243        });
7244        let receipt = core.snapshot().entities[0].last_action.clone().unwrap();
7245        assert_eq!(&*receipt.label, "live");
7246        assert_eq!(
7247            receipt.steps[0].outcome,
7248            StepOutcome::Cancelled,
7249            "the refused submission must leave the live run's own control in place, so \
7250             stop_action still reaches the step it was running"
7251        );
7252        assert_eq!(
7253            receipt.steps[1].outcome,
7254            StepOutcome::Cancelled,
7255            "a step that had not started when the run was cancelled must read Cancelled too"
7256        );
7257    }
7258
7259    /// A run accepted the moment a completion releases its admission owns the controls for
7260    /// the rest of its life: that completion has nothing left to register by then, so
7261    /// `stop_action` still reaches this run's own steps.
7262    ///
7263    /// [`Core::action_completion_boundary`] pins "the moment" rather than approximating it:
7264    /// the submission made while the completion is parked must be refused, and the one made
7265    /// once it is released must be accepted, so what is stopped below is a run accepted at
7266    /// the earliest point one can be. Both of its steps sleep [`FIXTURE_LIFETIME`], since
7267    /// the outcomes asserted cannot tell a cancelled step from one that reached its own end
7268    /// inside the wait watching it.
7269    #[test]
7270    fn a_run_accepted_once_a_completion_releases_its_admission_is_still_stoppable() {
7271        let dir = tempfile::tempdir().expect("temp dir");
7272        let root = root_of(&dir);
7273        let repo = root.join("repo");
7274        init_repo_with_a_commit(&repo);
7275
7276        let core = Core::start_discovered(spec(vec![root]));
7277        let key = core.snapshot().entities[0].key.clone();
7278        let armed = core.action_completion_boundary().arm();
7279
7280        assert!(core.run_action(
7281            action("finishing", vec![step(&["true"])]),
7282            std::slice::from_ref(&key)
7283        ));
7284        armed.wait_until_reached();
7285        assert!(
7286            !core.run_action(
7287                action("early", vec![step(&["true"])]),
7288                std::slice::from_ref(&key)
7289            ),
7290            "a submission made before the completion releases its admission must be refused"
7291        );
7292        drop(armed);
7293        wait_for("the finished run to release its admission", || {
7294            !core.action_running()
7295        });
7296
7297        let sleep_past_the_backstop = format!("sleep {}", FIXTURE_LIFETIME.as_secs());
7298        let following = action(
7299            "following",
7300            vec![
7301                step(&["sh", "-c", &sleep_past_the_backstop]),
7302                step(&["sh", "-c", &sleep_past_the_backstop]),
7303            ],
7304        );
7305        assert!(
7306            core.run_action(following, std::slice::from_ref(&key)),
7307            "a submission made once that release has happened must be accepted"
7308        );
7309        wait_for("the following run's own first step to start", || {
7310            receipt_labelled(&core, &key, "following")
7311                .is_some_and(|receipt| receipt.running.is_some())
7312        });
7313
7314        core.stop_action();
7315
7316        wait_for("the cancelled run to come down", || !core.action_running());
7317        let receipt =
7318            receipt_labelled(&core, &key, "following").expect("the following run's receipt");
7319        assert_eq!(
7320            receipt.steps[0].outcome,
7321            StepOutcome::Cancelled,
7322            "the completion this run followed must leave stop_action still reaching it"
7323        );
7324        assert_eq!(
7325            receipt.steps[1].outcome,
7326            StepOutcome::Cancelled,
7327            "a cancelled run's remaining step must never start, so it reads Cancelled"
7328        );
7329    }
7330
7331    // =====================================================================================
7332    // Criteria 3 and 4: `Core::hold_action`/`Core::continue_action` are their own verbs on
7333    // the core, kept apart from the generic `pause`/`resume` the probes use, and suspending
7334    // a fan-out is reversible: a held step's own progress genuinely pauses, and resumes
7335    // exactly where it left off, rather than the run merely finishing on its own regardless.
7336    // =====================================================================================
7337
7338    /// A black-box proof through the public API alone, with no reach into the step's own
7339    /// pid: a one-second step, held for 1.5s (comfortably longer than the step would ever
7340    /// take unheld) and then continued. If `hold_action` were a no-op, the step would
7341    /// already have finished on its own well before this test ever calls
7342    /// `continue_action`, and `action_running` would already read `false` at the
7343    /// mid-hold checkpoint below; that is the exact mutation this test is written to catch.
7344    #[test]
7345    fn hold_action_genuinely_pauses_a_running_steps_progress_and_continue_action_resumes_it() {
7346        let dir = tempfile::tempdir().expect("temp dir");
7347        let root = root_of(&dir);
7348        let repo = root.join("repo");
7349        init_repo_with_a_commit(&repo);
7350
7351        let core = Core::start_discovered(spec(vec![root]));
7352        let key = core.snapshot().entities[0].key.clone();
7353        let two_seconds = action("brief", vec![step(&["sh", "-c", "sleep 2"])]);
7354
7355        assert!(core.run_action(two_seconds, std::slice::from_ref(&key)));
7356        wait_for("the two-second step to actually start running", || {
7357            core.snapshot().entities[0]
7358                .last_action
7359                .as_ref()
7360                .is_some_and(|receipt| receipt.running.is_some())
7361        });
7362
7363        // The receipt's own `running: Some(_)` is written just before `run_step` is even
7364        // called, so it can race that call's own spawn, which is when the step's process
7365        // group is actually registered. SIGSTOP is idempotent, so pulsing `hold_action`
7366        // over a short bounded window (well inside the step's own 2s) is what makes that
7367        // race resolve deterministically rather than flakily, without ever risking a hang:
7368        // a stuck `hold_action` here fails this loop's own fixed iteration count, not this
7369        // test's wall clock.
7370        for _ in 0..20 {
7371            core.hold_action();
7372            thread::sleep(Duration::from_millis(20));
7373        }
7374
7375        thread::sleep(Duration::from_millis(1_800));
7376        assert!(
7377            core.action_running(),
7378            "a genuinely held step must not have finished on its own well past its own 2s \
7379             sleep; a no-op hold_action would already show this false here"
7380        );
7381
7382        core.continue_action();
7383        wait_for("continue_action to let the held step finish", || {
7384            !core.action_running()
7385        });
7386        let receipt = core.snapshot().entities[0].last_action.clone().unwrap();
7387        assert_eq!(receipt.steps[0].outcome, StepOutcome::Ok);
7388    }
7389
7390    /// `hold_action`, `continue_action` and `stop_action` must all be safe to call with no
7391    /// fan-out live: nothing to signal, so each is a plain no-op rather than a panic or a
7392    /// stray signal to nothing.
7393    #[test]
7394    fn hold_continue_and_stop_action_are_no_ops_with_no_fan_out_running() {
7395        let dir = tempfile::tempdir().expect("temp dir");
7396        let root = root_of(&dir);
7397        let repo = root.join("repo");
7398        init_repo_with_a_commit(&repo);
7399
7400        let core = Core::start_discovered(spec(vec![root]));
7401
7402        core.hold_action();
7403        core.continue_action();
7404        core.stop_action();
7405
7406        assert!(!core.action_running());
7407    }
7408
7409    // =====================================================================================
7410    // Criterion 1: Escape (`Core::stop_action`) cancels the fan-out with two signals, the
7411    // terminating one and then the uncatchable one after a grace, because the first is
7412    // trappable. Exercised through the real public seam, never by calling `RunControl`
7413    // directly, so this is `stop_action` end to end rather than only its own primitive.
7414    // =====================================================================================
7415
7416    /// A child that traps and ignores SIGTERM is the only fixture that actually
7417    /// discriminates the two-signal design from a one-signal one: a child that dies on
7418    /// SIGTERM alone would pass this test even if `stop_action` were mutated to drop its
7419    /// own SIGKILL follow-up entirely, which is exactly the regression this criterion
7420    /// exists to catch.
7421    ///
7422    /// The step sleeps [`FIXTURE_LIFETIME`], ten times the backstop every wait below
7423    /// carries, so a `stop_action` that stops working reads back as a named wait giving up
7424    /// rather than as the step ending on its own inside the wait watching it. That margin is
7425    /// the whole discrimination here, because the outcome assertion cannot supply it:
7426    /// `run_action_for_entity` stamps `Cancelled` on whatever was running the moment the run
7427    /// was cancelled, however the step actually ended. A run that does fail here leaves the
7428    /// trapping child alive until its own sleep ends, which is the price of a fixture the
7429    /// wait cannot outlast.
7430    #[test]
7431    fn stop_action_escalates_from_sigterm_to_sigkill_against_a_trapping_step() {
7432        let dir = tempfile::tempdir().expect("temp dir");
7433        let root = root_of(&dir);
7434        let repo = root.join("repo");
7435        init_repo_with_a_commit(&repo);
7436
7437        let core = Core::start_discovered(spec(vec![root]));
7438        let key = core.snapshot().entities[0].key.clone();
7439        let sleep_past_the_backstop = format!("trap '' TERM; sleep {}", FIXTURE_LIFETIME.as_secs());
7440        let trapping = action(
7441            "trapping",
7442            vec![step(&["sh", "-c", &sleep_past_the_backstop])],
7443        );
7444
7445        assert!(core.run_action(trapping, std::slice::from_ref(&key)));
7446        wait_for("the trapping step to actually start running", || {
7447            core.snapshot().entities[0]
7448                .last_action
7449                .as_ref()
7450                .is_some_and(|receipt| receipt.running.is_some())
7451        });
7452        // Gives the shell time to install its own trap before any signal can arrive; the
7453        // outcome asserted below is the actual proof, not this fixed delay.
7454        thread::sleep(Duration::from_millis(100));
7455
7456        core.stop_action();
7457
7458        wait_for(
7459            "a SIGTERM-trapping step to come down from the follow-up SIGKILL",
7460            || !core.action_running(),
7461        );
7462        let receipt = core.snapshot().entities[0].last_action.clone().unwrap();
7463        assert_eq!(receipt.steps.len(), 1);
7464        assert_eq!(
7465            receipt.steps[0].outcome,
7466            StepOutcome::Cancelled,
7467            "a step running when the run was cancelled must read Cancelled, never Failed"
7468        );
7469    }
7470
7471    // =====================================================================================
7472    // Criterion 2: cancellation produces `Cancelled`, never `NotRun`, which stays reserved
7473    // for being blocked by an earlier failure. Both outcomes are shown live in the same
7474    // run, on different entities, so they can be told apart rather than merely observed
7475    // one at a time.
7476    // =====================================================================================
7477
7478    /// One Action, two entities, dispatched together at `concurrency: 2`: `fail`'s own
7479    /// first step exits nonzero well before the run is ever cancelled, so its second step
7480    /// is a genuine `NotRun`; `slow`'s own first step is still sleeping when
7481    /// `stop_action` fires, so both of its steps read `Cancelled`. A test that only ever
7482    /// produced one of the two outcomes could not prove they are told apart; this fixture
7483    /// has both live in the same receipt set, so a mutation that collapsed one into the
7484    /// other would be caught by whichever entity it broke.
7485    #[test]
7486    fn cancelled_and_not_run_are_distinct_outcomes_shown_together_in_one_run() {
7487        let dir = tempfile::tempdir().expect("temp dir");
7488        let root = root_of(&dir);
7489        init_repo_with_a_commit(&root.join("fail"));
7490        init_repo_with_a_commit(&root.join("slow"));
7491
7492        let core = Core::start_discovered(spec(vec![root]));
7493        let snapshot = core.snapshot();
7494        let fail_key = snapshot
7495            .entities
7496            .iter()
7497            .find(|entity| &*entity.name == "fail")
7498            .expect("the fail entity is present")
7499            .key
7500            .clone();
7501        let slow_key = snapshot
7502            .entities
7503            .iter()
7504            .find(|entity| &*entity.name == "slow")
7505            .expect("the slow entity is present")
7506            .key
7507            .clone();
7508
7509        // One step list run against both entities: behaviour branches on the entity's own
7510        // directory name, which is `$PWD`'s basename in each entity's own working
7511        // directory, so `fail` fails immediately and `slow` is still running when this
7512        // test cancels the whole run.
7513        // `slow`'s branch sleeps `FIXTURE_LIFETIME` rather than a number of its own: the
7514        // wait below is on cancellation bringing the fan-out down, which a step that ends by
7515        // itself inside the backstop would satisfy without cancellation working at all.
7516        let branch_on_the_entity_name = format!(
7517            "case \"$(basename \"$PWD\")\" in fail) exit 1 ;; *) sleep {} ;; esac",
7518            FIXTURE_LIFETIME.as_secs()
7519        );
7520        let steps = vec![
7521            step(&["sh", "-c", &branch_on_the_entity_name]),
7522            step(&["true"]),
7523        ];
7524        let mut action_spec = action("mixed", steps);
7525        action_spec.concurrency = 2;
7526
7527        assert!(core.run_action(action_spec, &[fail_key.clone(), slow_key.clone()]));
7528
7529        // `fail` must have already finished (both its steps recorded) while `slow` is
7530        // still running its own first step: the two entities' own outcomes are captured
7531        // at the same moment, which is what makes them "shown together".
7532        wait_for(
7533            "`fail` finished and `slow` still running before cancelling",
7534            || {
7535                let snapshot = core.snapshot();
7536                let fail_done = snapshot
7537                    .entities
7538                    .iter()
7539                    .find(|entity| entity.key == fail_key)
7540                    .and_then(|entity| entity.last_action.as_ref())
7541                    .is_some_and(|receipt| receipt.steps.len() == 2);
7542                let slow_running = snapshot
7543                    .entities
7544                    .iter()
7545                    .find(|entity| entity.key == slow_key)
7546                    .and_then(|entity| entity.last_action.as_ref())
7547                    .is_some_and(|receipt| receipt.running.is_some());
7548                fail_done && slow_running
7549            },
7550        );
7551
7552        core.stop_action();
7553        wait_for("the fan-out to finish once cancelled", || {
7554            !core.action_running()
7555        });
7556
7557        let snapshot = core.snapshot();
7558        let fail_receipt = snapshot
7559            .entities
7560            .iter()
7561            .find(|entity| entity.key == fail_key)
7562            .and_then(|entity| entity.last_action.clone())
7563            .expect("fail's own receipt");
7564        assert_eq!(fail_receipt.steps[0].outcome, StepOutcome::Failed(1));
7565        assert_eq!(
7566            fail_receipt.steps[1].outcome,
7567            StepOutcome::NotRun,
7568            "blocked by fail's own earlier failure, not by the later cancellation"
7569        );
7570
7571        let slow_receipt = snapshot
7572            .entities
7573            .iter()
7574            .find(|entity| entity.key == slow_key)
7575            .and_then(|entity| entity.last_action.clone())
7576            .expect("slow's own receipt");
7577        assert_eq!(
7578            slow_receipt.steps[0].outcome,
7579            StepOutcome::Cancelled,
7580            "a step running when the run was cancelled must read Cancelled"
7581        );
7582        assert_eq!(
7583            slow_receipt.steps[1].outcome,
7584            StepOutcome::Cancelled,
7585            "a step that had not started when the run was cancelled must also read \
7586             Cancelled, never NotRun, which stays reserved for an earlier failure"
7587        );
7588    }
7589
7590    /// A panic anywhere inside the fan-out, a poisoned `RwLock` from an unrelated
7591    /// earlier panic is enough, must not leave this `Core` reading a run as live for the
7592    /// rest of its life. Poisons the table lock directly rather than
7593    /// injecting a fault into `run_action_for_entity`, which runs a real child process
7594    /// and has no seam for one: the fan-out's own `table_handle.write().unwrap()` then
7595    /// panics on the poisoned lock exactly the way an unrelated earlier panic would in
7596    /// production.
7597    #[test]
7598    fn a_panicking_fan_out_still_resets_action_running_so_a_later_action_can_start() {
7599        let dir = tempfile::tempdir().expect("temp dir");
7600        let root = root_of(&dir);
7601        let repo = root.join("repo");
7602        init_repo_with_a_commit(&repo);
7603
7604        // Drained before the table lock is poisoned below: a probe still in flight would
7605        // take the poison too, and a panic in one of rayon's global workers aborts the
7606        // process rather than unwinding.
7607        let (core, launched) = started_and_settled(spec(vec![root]));
7608        let key = launched.entities[0].key.clone();
7609
7610        // A step slow enough that the fan-out's own write of `last_action` cannot have
7611        // happened yet by the time the poisoning below completes: `run_action`'s own
7612        // synchronous prefix (admission, `cancel_in_flight`, the read that builds
7613        // `included`) is already finished by the time this call returns,
7614        // so poisoning the lock afterwards can only reach the fan-out's own write,
7615        // inside its own spawned thread.
7616        let started = core.run_action(
7617            action("boom", vec![step(&["sh", "-c", "sleep 0.3"])]),
7618            std::slice::from_ref(&key),
7619        );
7620        assert!(started);
7621
7622        let table = Arc::clone(&core.table);
7623        thread::spawn(move || {
7624            let _guard = table.write().unwrap();
7625            panic!("deliberately poison the table lock for this test");
7626        })
7627        .join()
7628        .expect_err("the poisoning thread must itself panic to poison the lock");
7629
7630        // Without `catch_unwind` around the fan-out this never becomes false: its own write
7631        // panics on the now-poisoned lock, unwinds out of `pool.install` and skips the
7632        // completion transition just past it, leaving this `Core` reading its run as live
7633        // for ever.
7634        wait_for(
7635            "a panicking fan-out to end its run rather than leave it reading as live",
7636            || !core.action_running(),
7637        );
7638
7639        // Clears the poison this test itself introduced to force the panic, an
7640        // artifact of the test rather than anything production code ever does, so a
7641        // real, full `run_action` call below proves the ended run actually lets another
7642        // Action run to completion, not merely that one private read flipped.
7643        core.table.clear_poison();
7644
7645        let second_started = core.run_action(
7646            action("second", vec![step(&["true"])]),
7647            std::slice::from_ref(&key),
7648        );
7649        assert!(
7650            second_started,
7651            "a later Action must be able to start once the panicking one has finished"
7652        );
7653        wait_for("the second Action to run to completion", || {
7654            core.snapshot()
7655                .entities
7656                .iter()
7657                .find(|entity| entity.key == key)
7658                .and_then(|entity| entity.last_action.as_ref())
7659                .is_some_and(|receipt| &*receipt.label == "second")
7660        });
7661    }
7662
7663    /// Asserts `entity` reads exactly as a Vanished row must: still in the table,
7664    /// its last known branch value untouched, and that same cell's staleness
7665    /// forced on. Shared by the Repo and the Submodule vanish tests so both
7666    /// exercise the identical assertion rather than a Repo-shaped one and a
7667    /// Submodule-shaped one that only look alike.
7668    fn assert_vanished_with_stale_branch(entity: &EntityState, expected_branch: &str) {
7669        assert_eq!(entity.presence, crate::entity::Presence::Vanished);
7670        match entity.branch.settled() {
7671            Some(Settled::Known {
7672                value: Head::Branch { name, .. },
7673                stale: true,
7674                at: _,
7675            }) => assert_eq!(
7676                &**name, expected_branch,
7677                "a Vanished entity must keep its last known branch value"
7678            ),
7679            other => panic!(
7680                "expected the branch cell to keep its Known value and go stale, got {other:?}"
7681            ),
7682        }
7683    }
7684
7685    /// The central behaviour this ticket adds: an entity discovery no longer
7686    /// finds stays in the table with its last known values, every cell forced
7687    /// stale, rather than disappearing. Proven end to end through `refresh` and
7688    /// `settle`, which is what proves discovery itself re-ran rather than the
7689    /// entity merely being left alone.
7690    #[test]
7691    fn a_repo_removed_from_disk_stays_in_the_table_vanished_with_its_last_values() {
7692        let dir = tempfile::tempdir().expect("temp dir");
7693        let root = root_of(&dir);
7694        let repo = root.join("repo");
7695        init_repo_with_a_commit(&repo);
7696
7697        let core = Core::start_discovered(spec(vec![root]));
7698        let key = core.snapshot().entities[0].key.clone();
7699        core.refresh(std::slice::from_ref(&key));
7700        let before = core.settle();
7701        let branch_name = match before.entities[0].branch.settled() {
7702            Some(Settled::Known {
7703                value: Head::Branch { name, .. },
7704                at: _,
7705                stale: _,
7706            }) => name.to_string(),
7707            other => panic!("expected the first refresh to settle a branch, got {other:?}"),
7708        };
7709
7710        fs::remove_dir_all(&repo).expect("remove the repo from disk");
7711
7712        core.refresh(&[]);
7713        let after = core.settle();
7714
7715        assert_eq!(
7716            after.entities.len(),
7717            1,
7718            "a vanished entity must stay in the snapshot, not disappear from it"
7719        );
7720        assert_vanished_with_stale_branch(&after.entities[0], &branch_name);
7721    }
7722
7723    /// Criterion 2's "untouched by the vanished-staleness path" made behavioural, through a
7724    /// real `Core::refresh` rather than calling `mark_vanished` directly: the same pass that
7725    /// forces every settled Cell stale on this entity must leave its receipt exactly as it was.
7726    #[test]
7727    fn a_vanished_entitys_action_receipt_survives_the_vanished_staleness_pass_untouched() {
7728        let dir = tempfile::tempdir().expect("temp dir");
7729        let root = root_of(&dir);
7730        let repo = root.join("repo");
7731        init_repo_with_a_commit(&repo);
7732
7733        let core = Core::start_discovered(spec(vec![root]));
7734        let key = core.snapshot().entities[0].key.clone();
7735        let receipt = crate::entity::ActionReceipt {
7736            label: Arc::from("reinstall"),
7737            steps: Arc::from(vec![crate::entity::StepResult {
7738                label: Arc::from("pnpm install"),
7739                outcome: crate::entity::StepOutcome::Ok,
7740                output: Arc::from(&b""[..]),
7741                elapsed: Duration::from_millis(1),
7742                elision: None,
7743                shell: false,
7744                interactive: false,
7745            }]),
7746            skip: None,
7747            finished_at: Timestamp::now(),
7748            running: None,
7749        };
7750        core.set_last_action_for_test(&key, receipt.clone());
7751
7752        fs::remove_dir_all(&repo).expect("remove the repo from disk");
7753        core.refresh(&[]);
7754        let after = core.settle();
7755
7756        let entity = &after.entities[0];
7757        assert_eq!(entity.presence, crate::entity::Presence::Vanished);
7758        assert_eq!(entity.last_action, Some(receipt));
7759    }
7760
7761    /// Criterion 6's reason for `ActionReceipt` sharing rather than copying is "the snapshot
7762    /// is cloned every frame"; a bare `ActionReceipt::clone()` only proves `Arc::clone` shares,
7763    /// which holds by definition and says nothing about this design. Proven instead through
7764    /// `Core::snapshot` itself: put a receipt on a live `Core`'s table, take two snapshots, and
7765    /// assert the label and steps are the same allocation across them, not merely equal. This
7766    /// passes as written, since the sharing does hold end to end; it exists to fail if some
7767    /// intermediate step ever re-materialised the receipt's bytes.
7768    #[test]
7769    fn two_snapshots_of_an_entity_share_its_last_actions_label_and_steps_by_pointer() {
7770        let dir = tempfile::tempdir().expect("temp dir");
7771        let root = root_of(&dir);
7772        let repo = root.join("repo");
7773        init_repo_with_a_commit(&repo);
7774
7775        let core = Core::start_discovered(spec(vec![root]));
7776        let key = core.snapshot().entities[0].key.clone();
7777        let receipt = crate::entity::ActionReceipt {
7778            label: Arc::from("reinstall"),
7779            steps: Arc::from(vec![crate::entity::StepResult {
7780                label: Arc::from("pnpm install"),
7781                outcome: crate::entity::StepOutcome::Failed(1),
7782                output: Arc::from(&b""[..]),
7783                elapsed: Duration::from_millis(1),
7784                elision: None,
7785                shell: false,
7786                interactive: false,
7787            }]),
7788            skip: None,
7789            finished_at: Timestamp::now(),
7790            running: None,
7791        };
7792        core.set_last_action_for_test(&key, receipt);
7793
7794        let first = core.snapshot();
7795        let second = core.snapshot();
7796        let first_receipt = first.entities[0]
7797            .last_action
7798            .as_ref()
7799            .expect("receipt was set");
7800        let second_receipt = second.entities[0]
7801            .last_action
7802            .as_ref()
7803            .expect("receipt was set");
7804
7805        assert!(
7806            Arc::ptr_eq(&first_receipt.label, &second_receipt.label),
7807            "two snapshots of the same receipt must share the label's allocation, not \
7808             re-copy it"
7809        );
7810        assert!(
7811            Arc::ptr_eq(&first_receipt.steps, &second_receipt.steps),
7812            "two snapshots of the same receipt must share the steps slice's allocation, not \
7813             re-copy it, which is also what shares every step's own captured output"
7814        );
7815    }
7816
7817    /// A Submodule vanishes by exactly the same rule as a Repo: no code path here
7818    /// is specific to which half of discovery produced the entry. Driven through
7819    /// the Submodule half (removing its declaration from `.gitmodules`, never
7820    /// touched by the boundary walk) and asserted with the very same helper the
7821    /// Repo test above uses.
7822    #[test]
7823    fn a_submodule_removed_from_gitmodules_vanishes_by_the_same_rule_as_a_repo() {
7824        let dir = tempfile::tempdir().expect("temp dir");
7825        let root = root_of(&dir);
7826        let parent = root.join("parent");
7827        init_repo_with_a_commit(&parent);
7828        fs::write(
7829            parent.join(".gitmodules"),
7830            "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
7831        )
7832        .expect("write .gitmodules");
7833        let submodule_path = parent.join("vendor").join("lib");
7834        init_repo_with_a_commit(&submodule_path);
7835
7836        // Shown, so the explicit `refresh` just below actually dispatches a probe against
7837        // it: this test is about the Vanished rule, not about `show_submodules` gating.
7838        let mut core_spec = spec(vec![root]);
7839        core_spec.show_submodules = true;
7840        let core = Core::start_discovered(core_spec);
7841        let snapshot = core.snapshot();
7842        let submodule_key = snapshot
7843            .entities
7844            .iter()
7845            .find(|entity| matches!(entity.kind, Kind::Submodule))
7846            .expect("submodule discovered")
7847            .key
7848            .clone();
7849        core.refresh(std::slice::from_ref(&submodule_key));
7850        let before = core.settle();
7851        let submodule_before = before
7852            .entities
7853            .iter()
7854            .find(|entity| entity.key == submodule_key)
7855            .expect("submodule present");
7856        let branch_name = match submodule_before.branch.settled() {
7857            Some(Settled::Known {
7858                value: Head::Branch { name, .. },
7859                at: _,
7860                stale: _,
7861            }) => name.to_string(),
7862            other => {
7863                panic!("expected the submodule's first refresh to settle a branch, got {other:?}")
7864            }
7865        };
7866
7867        // The submodule is no longer declared: discovery's second half will no
7868        // longer produce this entry, exactly as removing the parent's own `.git`
7869        // boundary would remove a Repo's entry.
7870        fs::write(parent.join(".gitmodules"), "").expect("clear .gitmodules");
7871
7872        core.refresh(&[]);
7873        let after = core.settle();
7874
7875        let submodule_after = after
7876            .entities
7877            .iter()
7878            .find(|entity| entity.key == submodule_key)
7879            .expect("the vanished submodule must stay in the snapshot");
7880        assert_vanished_with_stale_branch(submodule_after, &branch_name);
7881    }
7882
7883    /// Dismissal writes nothing to disk, so a Repo dismissed from one `Core`
7884    /// reads as an ordinary, freshly discovered Present entity on a brand new
7885    /// `Core` over the same roots, never as a restored Vanished row: startup is
7886    /// always a Generation with an empty prior state.
7887    #[test]
7888    fn dismissal_persists_nothing_across_a_fresh_core() {
7889        let dir = tempfile::tempdir().expect("temp dir");
7890        let root = root_of(&dir);
7891        let repo = root.join("repo");
7892        init_repo_with_a_commit(&repo);
7893
7894        let first_core = Core::start_discovered(spec(vec![root.clone()]));
7895        let key = first_core.snapshot().entities[0].key.clone();
7896        first_core.dismiss(&key);
7897        assert!(first_core.snapshot().entities.is_empty());
7898        drop(first_core);
7899
7900        let second_core = Core::start_discovered(spec(vec![root]));
7901        let snapshot = second_core.snapshot();
7902
7903        assert_eq!(
7904            snapshot.entities.len(),
7905            1,
7906            "a fresh Core must discover the repo again"
7907        );
7908        assert_eq!(
7909            snapshot.entities[0].presence,
7910            crate::entity::Presence::Present,
7911            "nothing from the dismissing Core's lifetime may be persisted, so the \
7912             repo must come back Present, never restored as Vanished"
7913        );
7914    }
7915
7916    /// An entity that moves reads as vanished plus new: its old key stays in the
7917    /// table Vanished with its last values, and a brand new entity appears at the
7918    /// new path, rather than the move being recognised as a rename.
7919    #[test]
7920    fn a_repo_that_moves_reads_as_vanished_plus_new() {
7921        let dir = tempfile::tempdir().expect("temp dir");
7922        let root = root_of(&dir);
7923        let original_path = root.join("original-name");
7924        init_repo_with_a_commit(&original_path);
7925
7926        let core = Core::start_discovered(spec(vec![root.clone()]));
7927        let original_key = core.snapshot().entities[0].key.clone();
7928        core.refresh(std::slice::from_ref(&original_key));
7929        let before = core.settle();
7930        let branch_name = match before.entities[0].branch.settled() {
7931            Some(Settled::Known {
7932                value: Head::Branch { name, .. },
7933                at: _,
7934                stale: _,
7935            }) => name.to_string(),
7936            other => panic!("expected the first refresh to settle a branch, got {other:?}"),
7937        };
7938
7939        let moved_path = root.join("new-name");
7940        fs::rename(&original_path, &moved_path).expect("move the repo on disk");
7941
7942        core.refresh(&[]);
7943        let after = core.settle();
7944
7945        assert_eq!(
7946            after.entities.len(),
7947            2,
7948            "a moved entity must read as the old key vanished plus a new one present, \
7949             never as one renamed entity"
7950        );
7951        let old_entity = after
7952            .entities
7953            .iter()
7954            .find(|entity| entity.key == original_key)
7955            .expect("the old key must stay in the table");
7956        assert_vanished_with_stale_branch(old_entity, &branch_name);
7957        let new_entity = after
7958            .entities
7959            .iter()
7960            .find(|entity| entity.key != original_key)
7961            .expect("a new entity at the moved path must be present");
7962        assert_eq!(new_entity.presence, crate::entity::Presence::Present);
7963        assert_eq!(new_entity.key.path(), moved_path);
7964    }
7965
7966    /// Reappearance is vanishing's mirror: an entity discovery stops finding, and
7967    /// then finds again, must come back Present rather than staying stuck
7968    /// Vanished forever.
7969    #[test]
7970    fn a_vanished_repo_recreated_on_disk_reads_present_on_the_next_refresh() {
7971        let dir = tempfile::tempdir().expect("temp dir");
7972        let root = root_of(&dir);
7973        let repo = root.join("repo");
7974        init_repo_with_a_commit(&repo);
7975
7976        let core = Core::start_discovered(spec(vec![root]));
7977        let key = core.snapshot().entities[0].key.clone();
7978
7979        fs::remove_dir_all(&repo).expect("remove the repo from disk");
7980        core.refresh(&[]);
7981        let vanished = core.settle();
7982        assert_eq!(
7983            vanished.entities[0].presence,
7984            crate::entity::Presence::Vanished,
7985            "the repo must read Vanished once removed from disk"
7986        );
7987
7988        init_repo_with_a_commit(&repo);
7989        core.refresh(&[]);
7990        let recreated = core.settle();
7991
7992        let entity = recreated
7993            .entities
7994            .iter()
7995            .find(|entity| entity.key == key)
7996            .expect("the recreated repo must still resolve to the same entity key");
7997        assert_eq!(
7998            entity.presence,
7999            crate::entity::Presence::Present,
8000            "an entity discovery finds again after it vanished must read Present, \
8001             not stay stuck Vanished forever"
8002        );
8003    }
8004
8005    /// Discovery riding the refresh is what lets a brand new entity appear
8006    /// without a fresh `Core::start`: a repo created after `start` is picked up
8007    /// by the very next `refresh`, even though the caller's `order` cannot yet
8008    /// name a key it never saw.
8009    #[test]
8010    fn a_new_repo_created_after_start_is_discovered_by_the_next_refresh() {
8011        let dir = tempfile::tempdir().expect("temp dir");
8012        let root = root_of(&dir);
8013        init_repo_with_a_commit(&root.join("first"));
8014
8015        let core = Core::start_discovered(spec(vec![root.clone()]));
8016        assert_eq!(core.snapshot().entities.len(), 1);
8017
8018        init_repo_with_a_commit(&root.join("second"));
8019        core.refresh(&[]);
8020        let after = core.settle();
8021
8022        assert_eq!(
8023            after.entities.len(),
8024            2,
8025            "a new repo created after start must be found by the next refresh's own discovery"
8026        );
8027
8028        // The entity is usable, not merely counted: a refresh that names its key
8029        // actually probes it and settles a real cell.
8030        let new_key = after
8031            .entities
8032            .iter()
8033            .find(|entity| &*entity.name == "second")
8034            .expect("the newly discovered repo must be named by the walk")
8035            .key
8036            .clone();
8037        core.refresh(std::slice::from_ref(&new_key));
8038        let probed = core.settle();
8039        let new_entity = probed
8040            .entities
8041            .iter()
8042            .find(|entity| entity.key == new_key)
8043            .expect("the newly discovered repo must still be present");
8044        assert!(
8045            matches!(
8046                new_entity.branch.settled(),
8047                Some(Settled::Known {
8048                    value: _,
8049                    at: _,
8050                    stale: _
8051                })
8052            ),
8053            "a refresh naming the newly discovered repo's key must actually probe \
8054             it and settle its branch cell, got {:?}",
8055            new_entity.branch.settled()
8056        );
8057    }
8058
8059    /// The abandon path takes the Set out of the automatic refresh path: once one
8060    /// discovery invocation abandons, a later `refresh` does not re-run discovery
8061    /// at all, proven by a repo created afterward never appearing, not merely by
8062    /// reading an internal flag.
8063    #[test]
8064    fn an_abandoned_discovery_stops_riding_later_refreshes() {
8065        let dir = tempfile::tempdir().expect("temp dir");
8066        let root = root_of(&dir);
8067        // A wide fan of plain directories, real enough for the walk to measurably
8068        // outrun a millisecond-scale deadline, so `start`'s own discovery
8069        // abandons rather than merely being told to (`Duration::ZERO` would trip
8070        // on the very first directory regardless of what is actually here, which
8071        // could never distinguish a guarded `refresh` from an unguarded one that
8072        // simply keeps re-abandoning against the same still-huge tree).
8073        let decoys = root.join("decoys");
8074        for i in 0..4_000 {
8075            fs::create_dir(decoys.join(format!("decoy-{i}")))
8076                .or_else(|_| fs::create_dir_all(decoys.join(format!("decoy-{i}"))))
8077                .expect("create decoy dir");
8078        }
8079        let (_tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
8080
8081        let started = Core::start_for_test_with_discovery_abandon(
8082            spec(vec![root.clone()]),
8083            Duration::from_secs(3600),
8084            Duration::from_micros(500),
8085            tick_rx,
8086        )
8087        .discovered();
8088        let core = started.core;
8089        assert!(
8090            core.discovery_manual_for_test(),
8091            "walking 4,000 decoy directories against a 500 microsecond deadline \
8092             must have abandoned and taken the Set manual"
8093        );
8094
8095        // The tree shrinks back to nothing slow: if `refresh` were still (wrongly)
8096        // re-running discovery, this walk would finish comfortably inside the
8097        // same deadline and find the new repo below. Only the manual guard can
8098        // account for it staying undiscovered.
8099        fs::remove_dir_all(&decoys).expect("remove decoy directories");
8100        init_repo_with_a_commit(&root.join("second"));
8101
8102        core.refresh(&[]);
8103        let after = core.settle();
8104
8105        assert!(
8106            !after
8107                .entities
8108                .iter()
8109                .any(|entity| &*entity.name == "second"),
8110            "once discovery has abandoned, a later refresh must not re-run it, so a \
8111             repo created afterward, on a tree that would now resolve quickly, \
8112             must still never appear"
8113        );
8114    }
8115
8116    /// `rerun_discovery`'s own abandon handling, exercised by a walk that only
8117    /// abandons on a later `refresh`, never on `start`'s: the first walk, over a
8118    /// tree small enough to finish comfortably inside the deadline, must leave
8119    /// the Set automatic, and only the second walk, once the same tree has grown
8120    /// a wide fan of decoys, may flip the manual flag and leave the abandoned
8121    /// warning. Both existing abandon tests force the abandon inside `start`'s
8122    /// own walk, which can never reach this block: `refresh` gates
8123    /// `rerun_discovery` behind the manual flag `start` already set.
8124    #[test]
8125    fn a_refresh_triggered_discovery_abandon_sets_manual_and_warns() {
8126        let dir = tempfile::tempdir().expect("temp dir");
8127        let root = root_of(&dir);
8128        init_repo_with_a_commit(&root.join("first"));
8129        let (_tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
8130
8131        // The first walk runs under a deadline it cannot lose against, so this
8132        // precondition is not a race. Tightening the deadline afterwards is what
8133        // separates the walk that must survive from the walk that must abandon:
8134        // one deadline serving both is a knife edge, and scheduling latency on a
8135        // loaded machine erases any margin a wall-clock figure can buy.
8136        let started = Core::start_for_test_with_discovery_abandon(
8137            spec(vec![root.clone()]),
8138            Duration::from_secs(3600),
8139            Duration::from_secs(3600),
8140            tick_rx,
8141        )
8142        .discovered();
8143        let core = started.core;
8144        assert!(
8145            !core.discovery_manual_for_test(),
8146            "an hour-long deadline must leave the first walk automatic"
8147        );
8148
8149        // Grown only after the first walk has finished (`discovered` above joined it),
8150        // so this fan of decoys is invisible to that walk and can only be reached by a
8151        // walk `refresh` triggers itself.
8152        let decoys = root.join("decoys");
8153        for i in 0..4_000 {
8154            fs::create_dir(decoys.join(format!("decoy-{i}")))
8155                .or_else(|_| fs::create_dir_all(decoys.join(format!("decoy-{i}"))))
8156                .expect("create decoy dir");
8157        }
8158        core.set_discovery_abandon_after_for_test(Duration::from_micros(500));
8159
8160        core.refresh(&[]);
8161        // `refresh` returns the moment it has reserved its Generation; this is the
8162        // rendezvous that says its own walk has run.
8163        core.wait_dispatched_for_test();
8164
8165        assert!(
8166            core.discovery_manual_for_test(),
8167            "refresh's own rerun_discovery must abandon against the newly-grown \
8168             tree and take the Set manual, the same as an abandon at start does"
8169        );
8170        let warning = core.discovery_warning();
8171        assert!(
8172            warning
8173                .as_deref()
8174                .is_some_and(|message| message.starts_with("discovery: stopped at")),
8175            "refresh's rerun_discovery must leave the abandoned-discovery warning \
8176             behind, not merely flip the manual flag: got {warning:?}"
8177        );
8178    }
8179
8180    /// The other half: an abandoned Set going manual must not leak into a
8181    /// different `Core`. The only way this crate can express "the Set's roots or
8182    /// globs changed" today is a fresh `Core::start` (a live in-place reload has
8183    /// no entry point in `Core` yet), so this proves the manual flag lives on one
8184    /// `Core` instance rather than anywhere global.
8185    #[test]
8186    fn a_fresh_core_over_different_roots_is_unaffected_by_another_cores_abandoned_discovery() {
8187        let abandoned_dir = tempfile::tempdir().expect("temp dir");
8188        let abandoned_root = root_of(&abandoned_dir);
8189        init_repo_with_a_commit(&abandoned_root.join("first"));
8190        let (_tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
8191        let started = Core::start_for_test_with_discovery_abandon(
8192            spec(vec![abandoned_root]),
8193            Duration::from_secs(3600),
8194            Duration::ZERO,
8195            tick_rx,
8196        )
8197        .discovered();
8198        started.core.refresh(&[]);
8199        started.core.settle();
8200        assert!(
8201            started.core.discovery_manual_for_test(),
8202            "the zero-length abandon deadline must have already taken this Core manual"
8203        );
8204        drop(started.core);
8205
8206        let fresh_dir = tempfile::tempdir().expect("temp dir");
8207        let fresh_root = root_of(&fresh_dir);
8208        init_repo_with_a_commit(&fresh_root.join("first"));
8209        let fresh_core = Core::start_discovered(spec(vec![fresh_root.clone()]));
8210        assert_eq!(fresh_core.snapshot().entities.len(), 1);
8211
8212        init_repo_with_a_commit(&fresh_root.join("second"));
8213        fresh_core.refresh(&[]);
8214        let after = fresh_core.settle();
8215
8216        assert_eq!(
8217            after.entities.len(),
8218            2,
8219            "a fresh Core, standing in for the Set's roots changing, must discover \
8220             normally regardless of an earlier, unrelated Core having gone manual"
8221        );
8222    }
8223
8224    /// Proves shutdown is clean: dropping the core blocks until the dedicated
8225    /// thread has actually returned, not merely until a message was sent to it.
8226    /// The tick sender is kept alive for the whole test, so the only way the
8227    /// thread can have stopped is the shutdown message `Drop` sends.
8228    #[test]
8229    fn dropping_the_core_joins_the_dedicated_thread_before_returning() {
8230        let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
8231        let dir = tempfile::tempdir().expect("temp dir");
8232        let root = root_of(&dir);
8233
8234        let started =
8235            Core::start_for_test(spec(vec![root]), Duration::from_secs(3600), tick_rx).discovered();
8236        assert!(started.clock_alive.load(Ordering::Acquire));
8237
8238        drop(started.core);
8239
8240        assert!(
8241            !started.clock_alive.load(Ordering::Acquire),
8242            "the dedicated thread should have exited, and cleared this flag, before drop returned"
8243        );
8244        drop(tick_tx);
8245    }
8246
8247    /// Cadence is driven entirely by the injected tick channel, never by a clock of
8248    /// the loop's own: with a zero deadline, the sweep is provably ready to fire
8249    /// the instant it runs, so whether it has run is exactly whether a tick has
8250    /// been sent, proven with no sleep on either side.
8251    #[test]
8252    fn the_deadline_sweep_runs_only_when_a_tick_arrives() {
8253        let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
8254        let dir = tempfile::tempdir().expect("temp dir");
8255        let root = root_of(&dir);
8256        let repo = root.join("repo");
8257        init_repo_with_a_commit(&repo);
8258
8259        let mut spec = spec(vec![root]);
8260        spec.generation_deadline = Duration::ZERO;
8261        let started = Core::start_for_test(spec, Duration::from_secs(3600), tick_rx).discovered();
8262        let core = started.core;
8263        // Drained, so the only entry in flight below is this test's own and only the sweep
8264        // can settle it again.
8265        let key = settle_launch(&core).entities[0].key.clone();
8266
8267        core.begin_untracked_probe_for_test(&key);
8268
8269        // No tick has been sent: the sweep has not run even though the (zero)
8270        // deadline has already elapsed in real time.
8271        let before = core.snapshot();
8272        assert!(
8273            matches!(
8274                before.entities[0].branch.settled(),
8275                Some(Settled::Known {
8276                    value: _,
8277                    at: _,
8278                    stale: _
8279                })
8280            ),
8281            "the cell still holds launch's own answer here, so the Unknown below is the \
8282             sweep's write rather than a cell that was already empty"
8283        );
8284        assert!(before.entities[0].branch.is_in_flight());
8285
8286        tick_tx.send(Instant::now()).expect("send one tick");
8287        let after = core.settle();
8288
8289        assert!(matches!(
8290            after.entities[0].branch.settled(),
8291            Some(Settled::Unknown(Unknown::TimedOut))
8292        ));
8293    }
8294
8295    /// Proves the real dedicated thread's tick arm actually reaches
8296    /// [`run_poll_sweep`], not merely that [`Core::poll_once_for_test`]'s direct
8297    /// call does the right thing: a mutation deleting the call inside
8298    /// `spawn_clock_thread` would leave every other poll test in this file green
8299    /// while failing only this one. [`wait_for`] backstops the wait rather than
8300    /// asserting any particular latency: the two ticks are sent from this thread
8301    /// and merely need to be picked up by the idle dedicated thread, not to land
8302    /// within a stated budget.
8303    #[test]
8304    fn a_real_tick_through_the_dedicated_thread_reaches_the_poll_sweep_and_reprobes_a_moved_entity()
8305    {
8306        let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
8307        let dir = tempfile::tempdir().expect("temp dir");
8308        let root = root_of(&dir);
8309        let repo = root.join("repo");
8310        init_repo_with_a_commit(&repo);
8311
8312        let started =
8313            Core::start_for_test(spec(vec![root]), Duration::from_secs(3600), tick_rx).discovered();
8314        let core = started.core;
8315        let key = core.snapshot().entities[0].key.clone();
8316
8317        backdate_polled_entries(&repo);
8318
8319        // The first tick only records a baseline: nothing has moved yet against a
8320        // fingerprint that did not exist before this tick.
8321        tick_tx
8322            .send(Instant::now())
8323            .expect("send the baseline tick");
8324        wait_for(
8325            "a tick sent on the real channel to reach the poll sweep",
8326            || core.poll_sweep_count_for_test() >= 1,
8327        );
8328        assert!(core.poll_reprobed_for_test().is_empty());
8329
8330        commit_a_change(&repo, "second");
8331
8332        tick_tx
8333            .send(Instant::now())
8334            .expect("send the movement tick");
8335        wait_for(
8336            "the real tick channel to reach the poll sweep and reprobe the moved entity",
8337            || core.poll_reprobed_for_test() == vec![key.clone()],
8338        );
8339        drop(tick_tx);
8340    }
8341
8342    /// Criterion 2's whole claim, over two entities so "for that entity only" has
8343    /// something to discriminate against: committing into one of two Repos and
8344    /// running one poll sweep re-probes branch/sync/base for the moved Repo alone
8345    /// (`poll_reprobed_for_test` names exactly it, never the other), force-stales
8346    /// its `dirty` and `state` without changing their value or timestamp (the
8347    /// absence claim that no status probe ran), and leaves the untouched Repo's
8348    /// cells byte-for-byte as the prior real `refresh` left them.
8349    #[test]
8350    fn poll_reprobe_touches_only_the_moved_entity_and_never_runs_a_status_probe() {
8351        let dir = tempfile::tempdir().expect("temp dir");
8352        let root = root_of(&dir);
8353        let repo_a = root.join("repo-a");
8354        let repo_b = root.join("repo-b");
8355        init_repo_with_a_commit(&repo_a);
8356        init_repo_with_a_commit(&repo_b);
8357
8358        let core = Core::start_discovered(spec(vec![root]));
8359        let snapshot = core.snapshot();
8360        let key_a = snapshot
8361            .entities
8362            .iter()
8363            .find(|entity| entity.key.path() == repo_a)
8364            .expect("repo-a discovered")
8365            .key
8366            .clone();
8367        let key_b = snapshot
8368            .entities
8369            .iter()
8370            .find(|entity| entity.key.path() == repo_b)
8371            .expect("repo-b discovered")
8372            .key
8373            .clone();
8374
8375        core.refresh(&[key_a.clone(), key_b.clone()]);
8376        let landed = core.settle();
8377        let entity_of = |snapshot: &Snapshot, key: &EntityKey| {
8378            snapshot
8379                .entities
8380                .iter()
8381                .find(|entity| &entity.key == key)
8382                .expect("entity present")
8383                .clone()
8384        };
8385        let a_before = entity_of(&landed, &key_a);
8386        let b_before = entity_of(&landed, &key_b);
8387        let branch_at = |entity: &EntityState| match entity.branch.settled() {
8388            Some(Settled::Known {
8389                at,
8390                value: _,
8391                stale: _,
8392            }) => *at,
8393            other => panic!("expected a landed branch, got {other:?}"),
8394        };
8395        let dirty_state = |entity: &EntityState| match entity.dirty.settled() {
8396            Some(Settled::Known { value, at, stale }) => (*value, *at, *stale),
8397            other => panic!("expected a landed dirty count, got {other:?}"),
8398        };
8399        let (a_dirty_value_before, a_dirty_at_before, a_dirty_stale_before) =
8400            dirty_state(&a_before);
8401        assert!(
8402            !a_dirty_stale_before,
8403            "the fresh refresh must land dirty as not stale"
8404        );
8405
8406        backdate_polled_entries(&repo_a);
8407
8408        backdate_polled_entries(&repo_b);
8409
8410        core.poll_once_for_test();
8411        assert!(
8412            core.poll_reprobed_for_test().is_empty(),
8413            "a first sweep has nothing to compare against, so it must report no movement"
8414        );
8415
8416        commit_a_change(&repo_a, "second");
8417        core.poll_once_for_test();
8418
8419        assert_eq!(
8420            core.poll_reprobed_for_test(),
8421            vec![key_a.clone()],
8422            "only the entity whose gitdir actually moved must be re-probed"
8423        );
8424
8425        let after = core.snapshot();
8426        let a_after = entity_of(&after, &key_a);
8427        let b_after = entity_of(&after, &key_b);
8428
8429        assert_ne!(
8430            branch_at(&a_after),
8431            branch_at(&a_before),
8432            "the moved entity's branch must carry a fresh timestamp from the re-probe"
8433        );
8434        let (a_dirty_value_after, a_dirty_at_after, a_dirty_stale_after) = dirty_state(&a_after);
8435        assert_eq!(
8436            a_dirty_value_after, a_dirty_value_before,
8437            "no status probe ran, so dirty's value must be exactly what the last real refresh \
8438             landed"
8439        );
8440        assert_eq!(
8441            a_dirty_at_after, a_dirty_at_before,
8442            "no status probe ran, so dirty's timestamp must be untouched, only its stale flag \
8443             set"
8444        );
8445        assert!(
8446            a_dirty_stale_after,
8447            "the moved entity's dirty cell must go stale on poll evidence"
8448        );
8449
8450        assert_eq!(
8451            branch_at(&b_after),
8452            branch_at(&b_before),
8453            "the untouched entity's branch must be exactly as the prior refresh left it"
8454        );
8455        let (b_dirty_value_after, b_dirty_at_after, b_dirty_stale_after) = dirty_state(&b_after);
8456        let (b_dirty_value_before, b_dirty_at_before, b_dirty_stale_before) =
8457            dirty_state(&b_before);
8458        assert_eq!(b_dirty_value_after, b_dirty_value_before);
8459        assert_eq!(b_dirty_at_after, b_dirty_at_before);
8460        assert_eq!(
8461            b_dirty_stale_after, b_dirty_stale_before,
8462            "an entity the sweep found unmoved must never go stale"
8463        );
8464    }
8465
8466    /// Criterion 3's attached half, and one of `refresh.md`'s two named traps: a
8467    /// commit on an attached HEAD never touches `.git/HEAD` at all, only
8468    /// `.git/logs/HEAD`. The poll must still see the commit, through `index`
8469    /// rather than through `HEAD`.
8470    #[test]
8471    fn poll_detects_an_attached_commit_through_index_while_head_itself_never_moves() {
8472        let dir = tempfile::tempdir().expect("temp dir");
8473        let root = root_of(&dir);
8474        let repo = root.join("repo");
8475        init_repo_with_a_commit(&repo);
8476
8477        let core = Core::start_discovered(spec(vec![root]));
8478        let key = core.snapshot().entities[0].key.clone();
8479        backdate_polled_entries(&repo);
8480        core.poll_once_for_test();
8481        assert!(core.poll_reprobed_for_test().is_empty());
8482
8483        let head_path = repo.join(".git").join("HEAD");
8484        let head_mtime_before = fs::metadata(&head_path)
8485            .expect("stat HEAD")
8486            .modified()
8487            .expect("HEAD mtime");
8488
8489        commit_a_change(&repo, "second");
8490
8491        let head_mtime_after = fs::metadata(&head_path)
8492            .expect("stat HEAD")
8493            .modified()
8494            .expect("HEAD mtime");
8495        assert_eq!(
8496            head_mtime_before, head_mtime_after,
8497            "a commit on an attached HEAD must never touch HEAD itself"
8498        );
8499
8500        core.poll_once_for_test();
8501        assert_eq!(
8502            core.poll_reprobed_for_test(),
8503            vec![key],
8504            "the poll must still detect the attached commit, through index rather than HEAD"
8505        );
8506    }
8507
8508    /// Criterion 3's detached half: [head.md](https://github.com/paulchiu/repon/blob/main/docs/spec/head.md)'s
8509    /// claim that a detached row's evidence is better than an attached row's,
8510    /// because a commit on a detached HEAD writes the new object id straight into
8511    /// the per-worktree `HEAD` file itself. Run against a real linked Worktree,
8512    /// never the main working tree, since that per-worktree file is exactly what
8513    /// distinguishes this case from the attached one above.
8514    #[test]
8515    fn poll_detects_a_detached_commit_through_the_per_worktree_head_file() {
8516        let dir = tempfile::tempdir().expect("temp dir");
8517        let root = root_of(&dir);
8518        let parent = root.join("parent");
8519        init_repo_with_a_commit(&parent);
8520        let worktree_path = root.join("detached-worktree");
8521        let status = Command::new("git")
8522            .arg("-C")
8523            .arg(&parent)
8524            .args([
8525                "worktree",
8526                "add",
8527                "--detach",
8528                worktree_path.to_str().expect("utf8 path"),
8529            ])
8530            .status()
8531            .expect("run git worktree add");
8532        assert!(status.success());
8533
8534        let core = Core::start_discovered(spec(vec![root]));
8535        let snapshot = core.snapshot();
8536        let worktree_key = snapshot
8537            .entities
8538            .iter()
8539            .find(|entity| matches!(entity.kind, Kind::Worktree))
8540            .expect("worktree discovered")
8541            .key
8542            .clone();
8543
8544        backdate_polled_entries(&parent);
8545        backdate_polled_entries(&worktree_path);
8546
8547        core.poll_once_for_test();
8548        assert!(core.poll_reprobed_for_test().is_empty());
8549
8550        let worktree_head_path = parent
8551            .join(".git")
8552            .join("worktrees")
8553            .join("detached-worktree")
8554            .join("HEAD");
8555        let head_mtime_before = fs::metadata(&worktree_head_path)
8556            .expect("stat the per-worktree HEAD")
8557            .modified()
8558            .expect("HEAD mtime");
8559
8560        commit_a_change(&worktree_path, "on the detached worktree");
8561
8562        let head_mtime_after = fs::metadata(&worktree_head_path)
8563            .expect("stat the per-worktree HEAD")
8564            .modified()
8565            .expect("HEAD mtime");
8566        assert_ne!(
8567            head_mtime_before, head_mtime_after,
8568            "a commit on a detached HEAD must write the new object id straight into its own \
8569             HEAD file"
8570        );
8571
8572        core.poll_once_for_test();
8573        assert_eq!(
8574            core.poll_reprobed_for_test(),
8575            vec![worktree_key],
8576            "the poll must detect the detached commit via the per-worktree HEAD file"
8577        );
8578    }
8579
8580    /// Criterion 4's elapsed-age writer, wired through `Core::snapshot` end to end:
8581    /// `status_stale_after` from `CoreSpec` is what decides whether a freshly
8582    /// landed `dirty` cell already reads Stale. A `Duration::from_nanos(1)`
8583    /// threshold has necessarily already elapsed by the time `snapshot` runs
8584    /// afterwards, so this needs no sleep and depends on no stated latency budget,
8585    /// only on real wall-clock time having advanced at all between two calls.
8586    #[test]
8587    fn snapshot_ages_a_freshly_landed_dirty_cell_stale_once_status_stale_after_has_elapsed() {
8588        let dir = tempfile::tempdir().expect("temp dir");
8589        let root = root_of(&dir);
8590        let repo = root.join("repo");
8591        init_repo_with_a_commit(&repo);
8592
8593        let mut short_lived = spec(vec![root]);
8594        short_lived.status_stale_after = Duration::from_nanos(1);
8595        let core = Core::start_discovered(short_lived);
8596        let key = core.snapshot().entities[0].key.clone();
8597        core.refresh(std::slice::from_ref(&key));
8598        core.settle();
8599
8600        let aged = core.snapshot();
8601        match aged.entities[0].dirty.settled() {
8602            Some(Settled::Known {
8603                stale: true,
8604                value: _,
8605                at: _,
8606            }) => {}
8607            other => panic!(
8608                "expected a landed dirty cell to have already aged past a one-nanosecond \
8609                 threshold, got {other:?}"
8610            ),
8611        }
8612    }
8613
8614    /// The same wiring's other side: a landed `dirty` cell stays fresh under a
8615    /// large `status_stale_after`, so the wiring is genuinely reading the
8616    /// threshold rather than always staling.
8617    #[test]
8618    fn snapshot_leaves_a_freshly_landed_dirty_cell_fresh_under_a_large_status_stale_after() {
8619        let dir = tempfile::tempdir().expect("temp dir");
8620        let root = root_of(&dir);
8621        let repo = root.join("repo");
8622        init_repo_with_a_commit(&repo);
8623
8624        let core = Core::start_discovered(spec(vec![root]));
8625        let key = core.snapshot().entities[0].key.clone();
8626        core.refresh(std::slice::from_ref(&key));
8627        core.settle();
8628
8629        let fresh = core.snapshot();
8630        match fresh.entities[0].dirty.settled() {
8631            Some(Settled::Known {
8632                stale: false,
8633                value: _,
8634                at: _,
8635            }) => {}
8636            other => panic!("expected a freshly landed dirty cell to stay fresh, got {other:?}"),
8637        }
8638    }
8639
8640    /// Criterion 5's absence claim: a hidden Submodule (`show_submodules` off) is
8641    /// never in the poll's own candidate set, so a commit into it is never
8642    /// detected, while the identical commit against the same Submodule shown is.
8643    /// Run as one test over the same fixture with the flag flipped, rather than
8644    /// two, so the only variable between the two sweeps is the flag itself.
8645    #[test]
8646    fn hidden_submodules_are_never_polled_but_shown_ones_are() {
8647        let dir = tempfile::tempdir().expect("temp dir");
8648        let root = root_of(&dir);
8649        let parent = root.join("parent");
8650        init_repo_with_a_commit(&parent);
8651        fs::write(
8652            parent.join(".gitmodules"),
8653            "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
8654        )
8655        .expect("write .gitmodules");
8656        let submodule_path = parent.join("vendor").join("lib");
8657        init_repo_with_a_commit(&submodule_path);
8658
8659        let mut hidden_spec = spec(vec![root.clone()]);
8660        hidden_spec.show_submodules = false;
8661        let hidden_core = Core::start_discovered(hidden_spec);
8662        // Discovery's own pass always runs regardless of the flag
8663        // (discovery.md's "Showing Submodules": "the pass always runs, so
8664        // Submodules are always known"), so the row exists; only probing and the
8665        // poll are gated on it.
8666        let hidden_submodule_key = hidden_core
8667            .snapshot()
8668            .entities
8669            .iter()
8670            .find(|entity| matches!(entity.kind, Kind::Submodule))
8671            .expect("the submodule is discovered regardless of show_submodules")
8672            .key
8673            .clone();
8674        backdate_polled_entries(&submodule_path);
8675        hidden_core.poll_once_for_test();
8676        commit_a_change(&submodule_path, "into the hidden submodule");
8677        hidden_core.poll_once_for_test();
8678        assert!(
8679            !hidden_core
8680                .poll_reprobed_for_test()
8681                .contains(&hidden_submodule_key),
8682            "a hidden Submodule must never be re-probed by the poll, since it was never \
8683             polled at all"
8684        );
8685        drop(hidden_core);
8686
8687        let mut shown_spec = spec(vec![root]);
8688        shown_spec.show_submodules = true;
8689        let shown_core = Core::start_discovered(shown_spec);
8690        let submodule_key = shown_core
8691            .snapshot()
8692            .entities
8693            .iter()
8694            .find(|entity| matches!(entity.kind, Kind::Submodule))
8695            .expect("the submodule is discovered regardless of show_submodules")
8696            .key
8697            .clone();
8698        backdate_polled_entries(&submodule_path);
8699        shown_core.poll_once_for_test();
8700        commit_a_change(&submodule_path, "into the shown submodule");
8701        shown_core.poll_once_for_test();
8702        assert_eq!(
8703            shown_core.poll_reprobed_for_test(),
8704            vec![submodule_key],
8705            "a shown Submodule must be polled and re-probed exactly like any other row"
8706        );
8707    }
8708
8709    /// Pause cancels a real in-flight entry (not merely stores a flag nobody
8710    /// reads): the cancel flag `begin_untracked_probe_for_test` returns is
8711    /// observed `true` afterward, and `settle` unblocks because pause released it,
8712    /// which is only possible if pause's handler on the dedicated thread actually
8713    /// ran.
8714    #[test]
8715    fn pause_cancels_every_in_flight_entity_and_releases_a_pending_settle() {
8716        let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
8717        let dir = tempfile::tempdir().expect("temp dir");
8718        let root = root_of(&dir);
8719        let repo = root.join("repo");
8720        init_repo_with_a_commit(&repo);
8721
8722        let started =
8723            Core::start_for_test(spec(vec![root]), Duration::from_secs(3600), tick_rx).discovered();
8724        let core = started.core;
8725        // Drained, so the only entry in flight below is the one this test puts there.
8726        let key = settle_launch(&core).entities[0].key.clone();
8727        let cancel = core.begin_untracked_probe_for_test(&key);
8728        assert!(!cancel.load(Ordering::Acquire));
8729
8730        core.pause();
8731        let settled = core.settle();
8732
8733        assert!(
8734            cancel.load(Ordering::Acquire),
8735            "pause should cancel the entity that was in flight"
8736        );
8737        assert!(settled.entities[0].branch.is_in_flight());
8738        drop(tick_tx);
8739    }
8740
8741    /// A launch walks the tree once.
8742    ///
8743    /// Discovery rides on every Generation
8744    /// ([refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
8745    /// "Discovery is never on the calling thread"), so counting a launch's walks is
8746    /// counting its Generations: one walk means the very first Generation a fresh `Core`
8747    /// mints is the only one a settled launch has, and that it already covers every row
8748    /// the walk found. A second walk would be a second Generation and would read here.
8749    #[test]
8750    fn a_launch_is_one_generation_over_every_row_its_own_walk_found() {
8751        let dir = tempfile::tempdir().expect("temp dir");
8752        let root = root_of(&dir);
8753        init_repo_with_a_commit(&root.join("first"));
8754        init_repo_with_a_commit(&root.join("second"));
8755
8756        let (_core, launched) = started_and_settled(spec(vec![root]));
8757
8758        assert_eq!(
8759            launched.generation,
8760            Generation::default().successor(),
8761            "a launch must settle on the first Generation a fresh `Core` mints; a second \
8762             walk of the same tree would be a second Generation"
8763        );
8764        let mut named: Vec<String> = launched
8765            .entities
8766            .iter()
8767            .filter(|entity| entity.branch.settled().is_some())
8768            .map(|entity| entity.name.to_string())
8769            .collect();
8770        named.sort();
8771        assert_eq!(
8772            named,
8773            vec!["first".to_string(), "second".to_string()],
8774            "that one Generation must cover every row its own walk found, or the walk it \
8775             saved would have to be paid by a second one"
8776        );
8777    }
8778
8779    /// A `Core` going away cancels what it still has in flight, the same way `pause` does.
8780    ///
8781    /// [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
8782    /// "Cancellation": an abandoned Generation is cancelled rather than left to finish,
8783    /// because both would contend for the same cores. A Set switch is where that bites,
8784    /// rebuilding the `Core` while the outgoing one's fan-out is still running.
8785    #[test]
8786    fn dropping_a_core_cancels_every_entity_it_still_has_in_flight() {
8787        let dir = tempfile::tempdir().expect("temp dir");
8788        let root = root_of(&dir);
8789        init_repo_with_a_commit(&root.join("repo"));
8790
8791        let (core, launched) = started_and_settled(spec(vec![root]));
8792        let key = launched.entities[0].key.clone();
8793        let cancel = core.begin_untracked_probe_for_test(&key);
8794        assert!(!cancel.load(Ordering::Acquire));
8795
8796        drop(core);
8797
8798        assert!(
8799            cancel.load(Ordering::Acquire),
8800            "a dropped Core must cancel the Generation it still has in flight rather than \
8801             leave it running against a Set nothing will read again"
8802        );
8803    }
8804
8805    /// Per-entity supersession, not global. An older Generation covers two entities,
8806    /// A and B, both simulated as still in flight. A Selection-scoped newer
8807    /// Generation covers only A: A's own older interrupt flag must be set, and B's
8808    /// must not, since the newer one never mentions B. Once the newer Generation has
8809    /// written A's cell, A's slow older result finally arrives and must be dropped
8810    /// there; B's own older result, arriving after everything else, must still be
8811    /// accepted, because the newer Generation never superseded it.
8812    ///
8813    /// The two are named by their order, never by their counter values, so a
8814    /// Generation minted earlier in the crate cannot renumber this test out from
8815    /// under itself.
8816    ///
8817    /// This is exactly the distinction a global-current-Generation comparison
8818    /// would get wrong: such a check compares every write against the table's one
8819    /// counter, which the Selection-scoped refresh has already advanced, so B's
8820    /// older result would be wrongly dropped even though nothing ever superseded B
8821    /// specifically. Before `Cell::settle`'s comparison was wired
8822    /// against the cell's own recorded Generation this test failed exactly there:
8823    /// B's late result was rejected, which is precisely the "cannot strand the
8824    /// rows it never spoke for" defect the ticket names.
8825    ///
8826    /// This test read A's interrupt flag intermittently false under load. The cause was
8827    /// `apply_probe_outcome` clearing the in-flight entry by key alone: launch's own
8828    /// Generation was left undrained here, so one of its probes could finish after the
8829    /// simulated older Generation had put its flags under the same keys and delete the
8830    /// entry holding them, leaving the Selection-scoped refresh nothing to supersede.
8831    /// Launch is drained first now, and the entry is cleared by Generation as well as by
8832    /// key, which `a_probe_finishing_clears_only_its_own_generations_in_flight_entry`
8833    /// pins directly.
8834    #[test]
8835    fn a_selection_scoped_refresh_supersedes_only_the_entity_it_covers() {
8836        let dir = tempfile::tempdir().expect("temp dir");
8837        let root = root_of(&dir);
8838        init_repo_with_a_commit(&root.join("a"));
8839        init_repo_with_a_commit(&root.join("b"));
8840
8841        let (core, snapshot) = started_and_settled(spec(vec![root]));
8842        let key_a = snapshot
8843            .entities
8844            .iter()
8845            .find(|entity| &*entity.name == "a")
8846            .expect("entity a discovered")
8847            .key
8848            .clone();
8849        let key_b = snapshot
8850            .entities
8851            .iter()
8852            .find(|entity| &*entity.name == "b")
8853            .expect("entity b discovered")
8854            .key
8855            .clone();
8856
8857        // The older Generation, simulated: both A and B are mid-flight, with nothing
8858        // spawned to complete either one, so the test controls exactly when each
8859        // one's result lands.
8860        let older = core.begin_shared_generation_for_test(&[key_a.clone(), key_b.clone()]);
8861
8862        // A Selection-scoped refresh over A alone, the very next Generation after the
8863        // one still in flight.
8864        let newer = core.refresh(std::slice::from_ref(&key_a));
8865        assert_eq!(
8866            newer,
8867            older.generation.successor(),
8868            "the Selection-scoped refresh must be the Generation immediately after the one \
8869             still in flight, with nothing minted in between"
8870        );
8871
8872        // Supersession happens on the new Generation's own thread, behind its walk, so
8873        // this is the rendezvous that says it has happened. A join, never a deadline: no
8874        // production rule bounds how long that walk takes short of the thirty seconds at
8875        // which discovery is abandoned.
8876        core.wait_dispatched_for_test();
8877        assert!(
8878            older.cancels[&key_a].load(Ordering::Acquire),
8879            "the entity the new Generation covers must have its old interrupt flag set"
8880        );
8881        assert!(
8882            !older.cancels[&key_b].load(Ordering::Acquire),
8883            "an entity the new Generation does not cover must be left running, untouched"
8884        );
8885
8886        // [`BACKSTOP`] rather than a budget: what follows reads the cell the new
8887        // Generation's own probe writes, which is a liveness property with no wall-clock
8888        // bound of its own.
8889        let after_refresh = core.settle();
8890
8891        let a_after_gen2 = after_refresh
8892            .entities
8893            .iter()
8894            .find(|entity| entity.key == key_a)
8895            .expect("entity a present");
8896        assert!(
8897            matches!(
8898                a_after_gen2.branch.settled(),
8899                Some(Settled::Known {
8900                    value: Head::Branch { .. },
8901                    at: _,
8902                    stale: _
8903                })
8904            ),
8905            "the newer Generation's real probe should have written A's cell by now"
8906        );
8907
8908        // A's slow older result finally arrives, after the newer Generation has
8909        // already written the cell: dropped, since it is lower than the Generation
8910        // already recorded there.
8911        core.apply_probe_result_for_test(
8912            &key_a,
8913            older.generation,
8914            Settled::Known {
8915                value: Head::Branch {
8916                    name: Arc::from("stale-from-generation-one"),
8917                    commit: gix::hash::Kind::Sha1.null(),
8918                },
8919                at: Timestamp::now(),
8920                stale: false,
8921            },
8922        );
8923        let after_stale_write = core.snapshot();
8924        let a_final = after_stale_write
8925            .entities
8926            .iter()
8927            .find(|entity| entity.key == key_a)
8928            .expect("entity a present");
8929        match a_final.branch.settled() {
8930            Some(Settled::Known {
8931                value: Head::Branch { name, .. },
8932                at: _,
8933                stale: _,
8934            }) => assert_ne!(
8935                &**name, "stale-from-generation-one",
8936                "a lower-Generation result must be dropped at the cell it would write"
8937            ),
8938            other => panic!("expected A to still hold the newer Generation's value, got {other:?}"),
8939        }
8940
8941        // B's own older result, landing last of all, is still accepted: the newer
8942        // Generation never covered B, so nothing superseded it.
8943        core.apply_probe_result_for_test(
8944            &key_b,
8945            older.generation,
8946            Settled::Known {
8947                value: Head::Branch {
8948                    name: Arc::from("b-generation-one-result"),
8949                    commit: gix::hash::Kind::Sha1.null(),
8950                },
8951                at: Timestamp::now(),
8952                stale: false,
8953            },
8954        );
8955        let final_snapshot = core.snapshot();
8956        let b_final = final_snapshot
8957            .entities
8958            .iter()
8959            .find(|entity| entity.key == key_b)
8960            .expect("entity b present");
8961        match b_final.branch.settled() {
8962            Some(Settled::Known {
8963                value: Head::Branch { name, .. },
8964                at: _,
8965                stale: _,
8966            }) => assert_eq!(
8967                &**name, "b-generation-one-result",
8968                "an entity the new Generation never covered must still accept its own result"
8969            ),
8970            other => {
8971                panic!("expected B's un-superseded older result to be accepted, got {other:?}")
8972            }
8973        }
8974    }
8975
8976    /// The deadline sweep abandons only what is still Loading when it fires. An
8977    /// entity already settled by the time the deadline sweep runs keeps its value
8978    /// untouched, blanking nothing, while a different entity still mid-flight in
8979    /// the same sweep becomes Unknown with the timed-out reason.
8980    #[test]
8981    fn the_deadline_sweep_keeps_already_settled_cells_and_only_times_out_what_is_still_loading() {
8982        let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
8983        let dir = tempfile::tempdir().expect("temp dir");
8984        let root = root_of(&dir);
8985        init_repo_with_a_commit(&root.join("a"));
8986        init_repo_with_a_commit(&root.join("b"));
8987
8988        let mut spec = spec(vec![root]);
8989        spec.generation_deadline = Duration::ZERO;
8990        let started = Core::start_for_test(spec, Duration::from_secs(3600), tick_rx).discovered();
8991        let core = started.core;
8992        // Drained, so the only cell still loading when the sweep fires is the one this
8993        // test puts in flight.
8994        let snapshot = settle_launch(&core);
8995        let key_a = snapshot
8996            .entities
8997            .iter()
8998            .find(|entity| &*entity.name == "a")
8999            .expect("entity a discovered")
9000            .key
9001            .clone();
9002        let key_b = snapshot
9003            .entities
9004            .iter()
9005            .find(|entity| &*entity.name == "b")
9006            .expect("entity b discovered")
9007            .key
9008            .clone();
9009
9010        // A is already settled, synchronously, before the deadline ever has a
9011        // chance to fire.
9012        let a_settled = core.probe_now(&key_a);
9013        let a_value_before = match a_settled.branch.settled() {
9014            Some(Settled::Known {
9015                value: Head::Branch { name, .. },
9016                at: _,
9017                stale: _,
9018            }) => Arc::clone(name),
9019            other => panic!("expected A's synchronous probe to settle a branch, got {other:?}"),
9020        };
9021
9022        // B is left mid-flight, in a Generation whose (zero) deadline has already
9023        // elapsed in real time, but the sweep has not run yet: no tick has been
9024        // sent.
9025        let cancel_b = core.begin_untracked_probe_for_test(&key_b);
9026        let before_tick = core.snapshot();
9027        let b_before = before_tick
9028            .entities
9029            .iter()
9030            .find(|entity| entity.key == key_b)
9031            .expect("entity b present");
9032        assert!(
9033            b_before.branch.is_in_flight(),
9034            "B must be mid-flight when the sweep fires; that is the only shape the sweep \
9035             may touch"
9036        );
9037        assert!(
9038            matches!(
9039                b_before.branch.settled(),
9040                Some(Settled::Known {
9041                    value: _,
9042                    at: _,
9043                    stale: _
9044                })
9045            ),
9046            "B still carries launch's own answer here, so the Unknown below is a write the \
9047             sweep made rather than a cell that was already empty, got {:?}",
9048            b_before.branch.settled()
9049        );
9050
9051        tick_tx.send(Instant::now()).expect("send one tick");
9052        let after_sweep = core.settle();
9053
9054        let a_after = after_sweep
9055            .entities
9056            .iter()
9057            .find(|entity| entity.key == key_a)
9058            .expect("entity a present");
9059        match a_after.branch.settled() {
9060            Some(Settled::Known {
9061                value: Head::Branch { name, .. },
9062                at: _,
9063                stale: _,
9064            }) => assert_eq!(
9065                name, &a_value_before,
9066                "an already-settled cell must keep its value when the deadline sweep runs, not be blanked"
9067            ),
9068            other => panic!("expected A's settled value to survive the sweep, got {other:?}"),
9069        }
9070
9071        let b_after = after_sweep
9072            .entities
9073            .iter()
9074            .find(|entity| entity.key == key_b)
9075            .expect("entity b present");
9076        assert!(matches!(
9077            b_after.branch.settled(),
9078            Some(Settled::Unknown(Unknown::TimedOut))
9079        ));
9080        assert!(
9081            !cancel_b.load(Ordering::Acquire),
9082            "the deadline sweep marks a cell Unknown; it never sets the entity's own \
9083             cancel flag, since the underlying probe (nonexistent here) is left to keep running"
9084        );
9085    }
9086
9087    /// The deadline sweep must reach a Worktree's outstanding `state` cell the
9088    /// same way it already reaches `branch` and `default_branch`: asking and
9089    /// getting nothing back is Unknown, not a cell stuck in-flight forever once
9090    /// the Generation that would have answered it is gone. A Repo's `state`,
9091    /// `NotApplicable` from construction and never in flight, must survive the
9092    /// same sweep untouched, proving the sweep only times out a cell actually
9093    /// marked in flight rather than blanket-settling every entity's `state` cell.
9094    #[test]
9095    fn the_deadline_sweep_times_out_a_worktrees_outstanding_state_but_leaves_a_repos_not_applicable_one_alone()
9096     {
9097        let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
9098        let dir = tempfile::tempdir().expect("temp dir");
9099        let root = root_of(&dir);
9100        let parent = root.join("parent");
9101        init_repo_with_a_commit(&parent);
9102        let worktree_path = root.join("feature-worktree");
9103        git(
9104            &parent,
9105            &[
9106                "worktree",
9107                "add",
9108                "-b",
9109                "feature",
9110                worktree_path.to_str().expect("utf8 path"),
9111            ],
9112        );
9113
9114        let mut spec = spec(vec![root]);
9115        spec.generation_deadline = Duration::ZERO;
9116        let started = Core::start_for_test(spec, Duration::from_secs(3600), tick_rx).discovered();
9117        let core = started.core;
9118        // Drained, so launch's own real refresh has already landed on every cell before
9119        // either `begin_untracked_probe_for_test` call below puts one artificially back in
9120        // flight: skipping this left a real probe for the same cell free to settle it
9121        // between that call and the sweep, which turns the sweep's own `is_in_flight`
9122        // guard (working exactly as designed, since a cell no longer loading is not the
9123        // sweep's to touch) into a race the assertion below loses however rarely.
9124        let snapshot = settle_launch(&core);
9125        let repo_key = snapshot
9126            .entities
9127            .iter()
9128            .find(|entity| matches!(entity.kind, Kind::Repo))
9129            .expect("repo entity present")
9130            .key
9131            .clone();
9132        let worktree_key = snapshot
9133            .entities
9134            .iter()
9135            .find(|entity| matches!(entity.kind, Kind::Worktree))
9136            .expect("worktree entity present")
9137            .key
9138            .clone();
9139
9140        // Both left mid-flight in a Generation whose (zero) deadline has already
9141        // elapsed, with no tick sent yet, mirroring how `Core::refresh` begins a
9142        // Worktree's `state` probe alongside `branch`. The Repo is in flight too
9143        // (on `branch` only, per the same gate), so the sweep actually reaches
9144        // it and the guard has something real to prove.
9145        core.begin_untracked_probe_for_test(&repo_key);
9146        core.begin_untracked_probe_for_test(&worktree_key);
9147
9148        tick_tx.send(Instant::now()).expect("send one tick");
9149        let after_sweep = core.settle();
9150
9151        let worktree_after = after_sweep
9152            .entities
9153            .iter()
9154            .find(|entity| entity.key == worktree_key)
9155            .expect("worktree entity present");
9156        assert!(
9157            matches!(
9158                worktree_after.state.settled(),
9159                Some(Settled::Unknown(Unknown::TimedOut))
9160            ),
9161            "expected the outstanding state cell to time out, got {:?}",
9162            worktree_after.state.settled()
9163        );
9164
9165        let repo_after = after_sweep
9166            .entities
9167            .iter()
9168            .find(|entity| entity.key == repo_key)
9169            .expect("repo entity present");
9170        assert!(
9171            matches!(repo_after.state.settled(), Some(Settled::NotApplicable)),
9172            "a Repo's Not applicable state must survive the sweep untouched, got {:?}",
9173            repo_after.state.settled()
9174        );
9175    }
9176
9177    /// Criterion 2's "never goes stale on a poll" made behavioural: the dedicated thread's
9178    /// tick-driven sweep is what a poll is in this codebase today (`spawn_clock_thread` calls
9179    /// [`sweep_deadline`] on every tick), and it must leave a receipt exactly as it was even
9180    /// while it is busy timing out a genuinely outstanding Cell on the very same entity.
9181    #[test]
9182    fn the_deadline_sweeps_poll_never_touches_an_entitys_action_receipt() {
9183        let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
9184        let dir = tempfile::tempdir().expect("temp dir");
9185        let root = root_of(&dir);
9186        let repo = root.join("repo");
9187        init_repo_with_a_commit(&repo);
9188
9189        let mut spec = spec(vec![root]);
9190        spec.generation_deadline = Duration::ZERO;
9191        let started = Core::start_for_test(spec, Duration::from_secs(3600), tick_rx).discovered();
9192        let core = started.core;
9193        // Drained, so the only entry the sweep below finds in flight is this test's own.
9194        let key = settle_launch(&core).entities[0].key.clone();
9195
9196        let receipt = crate::entity::ActionReceipt {
9197            label: Arc::from("reinstall"),
9198            steps: Arc::from(vec![crate::entity::StepResult {
9199                label: Arc::from("pnpm install"),
9200                outcome: crate::entity::StepOutcome::Ok,
9201                output: Arc::from(&b""[..]),
9202                elapsed: Duration::from_millis(1),
9203                elision: None,
9204                shell: false,
9205                interactive: false,
9206            }]),
9207            skip: None,
9208            finished_at: Timestamp::now(),
9209            running: None,
9210        };
9211        core.set_last_action_for_test(&key, receipt.clone());
9212
9213        // Left mid-flight in a Generation whose (zero) deadline has already elapsed, so the
9214        // sweep this tick triggers has a real Cell to time out on this very entity.
9215        core.begin_untracked_probe_for_test(&key);
9216        tick_tx.send(Instant::now()).expect("send one tick");
9217        let after = core.settle();
9218
9219        let entity = after
9220            .entities
9221            .iter()
9222            .find(|entity| entity.key == key)
9223            .expect("entity present");
9224        assert!(
9225            matches!(
9226                entity.branch.settled(),
9227                Some(Settled::Unknown(Unknown::TimedOut))
9228            ),
9229            "sanity check: the sweep must have actually timed out the in-flight cell, got {:?}",
9230            entity.branch.settled()
9231        );
9232        assert_eq!(entity.last_action, Some(receipt));
9233    }
9234
9235    /// Cancellation observed before a probe's very first read stops it from ever
9236    /// opening the repository at all, proven behaviourally rather than by
9237    /// re-reading the flag: a path that does not exist would settle as
9238    /// `Failed(Open(_))` if the open call actually ran, so getting `None` back
9239    /// instead is only possible if the read never started. This is the honest
9240    /// limit of what phase A can prove: `git::head_shape` is one syscall with no
9241    /// interruption point mid-read, so cancellation here stops work that has not
9242    /// started rather than work already running. [`classify_status_result_drops_an_error_once_cancel_reads_true`]
9243    /// covers the genuinely interruptible phase this crate now has.
9244    #[test]
9245    fn a_cancelled_probe_never_opens_the_repository_at_all() {
9246        let cancel = AtomicBool::new(true);
9247
9248        let outcome = probe_branch(
9249            Path::new("/nonexistent/nowhere-at-all"),
9250            None,
9251            Kind::Repo,
9252            &cancel,
9253        );
9254
9255        assert!(
9256            outcome.is_none(),
9257            "a probe observing cancellation before its first read must do no work \
9258             at all, not attempt the read and fail having tried it"
9259        );
9260    }
9261
9262    /// Phase C's own cancellation shape, distinct from phase A and B's "before the read
9263    /// starts" check: gix can report a genuinely mid-read cancellation as an `Err`
9264    /// (`dirty_counts_threads_the_cancel_flag_into_gix` in `git.rs` proves the flag actually
9265    /// reaches gix, which is what makes that `Err` possible at all), and this test covers the
9266    /// half that lives here, that `classify_status_result` folds that error back to `None`
9267    /// rather than `Settled::Failed` once `cancel` reads `true`, per ADR 0013's "interrupted
9268    /// work becomes Unknown rather than Failed". A mutation that dropped the `cancel`-aware
9269    /// arm (always settling `Failed` on any error, the way the cheaper phases' own errors do)
9270    /// fails this directly.
9271    #[test]
9272    fn classify_status_result_drops_an_error_once_cancel_reads_true() {
9273        let cancel = AtomicBool::new(true);
9274
9275        let outcome = classify_status_result(
9276            Err(crate::git::ProbeError::Status(Arc::from("boom"))),
9277            &cancel,
9278        );
9279
9280        assert!(
9281            outcome.is_none(),
9282            "an error alongside a cancel flag already set must read as cancelled, not \
9283             Failed, got {outcome:?}"
9284        );
9285    }
9286
9287    /// The other side of the same fold: an error with `cancel` still `false` is a genuine
9288    /// failure and must settle `Failed`, not be silently dropped the way a cancelled read is.
9289    #[test]
9290    fn classify_status_result_settles_failed_when_cancel_never_fired() {
9291        let cancel = AtomicBool::new(false);
9292
9293        let outcome = classify_status_result(
9294            Err(crate::git::ProbeError::Status(Arc::from("boom"))),
9295            &cancel,
9296        );
9297
9298        assert!(
9299            matches!(outcome, Some(Settled::Failed(git::ProbeError::Status(_)))),
9300            "a genuine error with no cancellation must settle Failed, got {outcome:?}"
9301        );
9302    }
9303
9304    /// gix polls `should_interrupt` per index entry rather than before every read, so a walk
9305    /// short enough to finish between checks (or with nothing left to check against) can
9306    /// complete and return `Ok` even though `cancel` was set part way through it. Settling
9307    /// that `Ok` anyway would let a cancelled generation write a value, exactly the outcome
9308    /// [refresh.md's "Cancellation"](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)
9309    /// says cancellation prevents. `classify_status_result` must re-check the same flag it
9310    /// owns on the `Ok` arm too, not only on `Err`, and drop the value the same way a
9311    /// cancelled `Err` is already dropped.
9312    #[test]
9313    fn classify_status_result_drops_an_ok_once_cancel_reads_true() {
9314        let cancel = AtomicBool::new(true);
9315
9316        let outcome = classify_status_result(Ok(DirtyCounts::default()), &cancel);
9317
9318        assert!(
9319            outcome.is_none(),
9320            "an Ok value that raced ahead of a cancel flag now set must read as cancelled, \
9321             not be settled Known, got {outcome:?}"
9322        );
9323    }
9324
9325    /// The other side of the same fold: an `Ok` with `cancel` still `false` is a genuine
9326    /// completed read and must settle `Known`, not be silently dropped.
9327    #[test]
9328    fn classify_status_result_settles_known_when_cancel_never_fired() {
9329        let cancel = AtomicBool::new(false);
9330        let counts = DirtyCounts {
9331            modified: 1,
9332            untracked: 2,
9333            deleted: 3,
9334        };
9335
9336        let outcome = classify_status_result(Ok(counts), &cancel);
9337
9338        assert!(
9339            matches!(
9340                outcome,
9341                Some(Settled::Known {
9342                    value,
9343                    at: _,
9344                    stale: _
9345                }) if value == counts
9346            ),
9347            "a genuine completed read with no cancellation must settle Known, got {outcome:?}"
9348        );
9349    }
9350
9351    /// The defining behaviour: a linked Worktree shares its parent's object store
9352    /// and remotes, but `Core` must still surface it as its own row rather than
9353    /// folding it into the Repo it is attached to. A real `git worktree add` is run
9354    /// against a genuine parent so the proof covers git's actual on-disk shape, not
9355    /// a hand-built stand-in for it.
9356    #[test]
9357    fn a_linked_worktree_is_its_own_entity_and_never_doubles_as_a_repo() {
9358        let dir = tempfile::tempdir().expect("temp dir");
9359        let root = root_of(&dir);
9360        let parent = root.join("parent");
9361        init_repo_with_a_commit(&parent);
9362        let worktree_path = root.join("feature-worktree");
9363        let status = Command::new("git")
9364            .arg("-C")
9365            .arg(&parent)
9366            .args([
9367                "worktree",
9368                "add",
9369                "-b",
9370                "feature",
9371                worktree_path.to_str().expect("utf8 path"),
9372            ])
9373            .status()
9374            .expect("run git worktree add");
9375        assert!(status.success());
9376
9377        let core = Core::start_discovered(spec(vec![root]));
9378        let snapshot = core.snapshot();
9379
9380        assert_eq!(
9381            snapshot.entities.len(),
9382            2,
9383            "expected the parent plus one Worktree, not two Repos"
9384        );
9385        let repo_count = snapshot
9386            .entities
9387            .iter()
9388            .filter(|entity| matches!(entity.kind, Kind::Repo))
9389            .count();
9390        let worktree_count = snapshot
9391            .entities
9392            .iter()
9393            .filter(|entity| matches!(entity.kind, Kind::Worktree))
9394            .count();
9395        assert_eq!(
9396            repo_count, 1,
9397            "the parent must be counted as exactly one Repo"
9398        );
9399        assert_eq!(
9400            worktree_count, 1,
9401            "the linked worktree must be counted as exactly one Worktree"
9402        );
9403
9404        let worktree_entity = snapshot
9405            .entities
9406            .iter()
9407            .find(|entity| matches!(entity.kind, Kind::Worktree))
9408            .expect("worktree entity present");
9409        let repo_entity = snapshot
9410            .entities
9411            .iter()
9412            .find(|entity| matches!(entity.kind, Kind::Repo))
9413            .expect("repo entity present");
9414        assert_eq!(worktree_entity.common_dir, repo_entity.common_dir);
9415
9416        // Each carries its own branch: the parent stayed on its default branch and
9417        // the worktree checked out `feature`.
9418        let repo_branch = core.probe_now(&repo_entity.key);
9419        let worktree_branch = core.probe_now(&worktree_entity.key);
9420        match (
9421            repo_branch.branch.settled(),
9422            worktree_branch.branch.settled(),
9423        ) {
9424            (
9425                Some(Settled::Known {
9426                    value:
9427                        Head::Branch {
9428                            name: repo_name, ..
9429                        },
9430                    at: _,
9431                    stale: _,
9432                }),
9433                Some(Settled::Known {
9434                    value:
9435                        Head::Branch {
9436                            name: worktree_name,
9437                            ..
9438                        },
9439                    at: _,
9440                    stale: _,
9441                }),
9442            ) => {
9443                assert_ne!(repo_name, worktree_name);
9444                assert_eq!(&**worktree_name, "feature");
9445            }
9446            other => panic!("expected both entities to read an attached branch, got {other:?}"),
9447        }
9448    }
9449
9450    /// End-to-end proof that `state` is actually wired into a real Generation:
9451    /// a linked Worktree whose branch is an ancestor of the default branch reads
9452    /// `Merged` after a real `refresh`, not merely in `landing`'s own unit tests.
9453    #[test]
9454    fn a_worktrees_branch_that_is_an_ancestor_of_the_default_branch_reads_merged_after_a_refresh() {
9455        let dir = tempfile::tempdir().expect("temp dir");
9456        let root = root_of(&dir);
9457        let parent = root.join("parent");
9458        init_repo_with_a_commit(&parent);
9459        git(
9460            &parent,
9461            &[
9462                "remote",
9463                "add",
9464                "origin",
9465                "https://example.invalid/repo.git",
9466            ],
9467        );
9468        let sha = head_sha(&parent);
9469        git(&parent, &["update-ref", "refs/remotes/origin/main", &sha]);
9470        let worktree_path = root.join("feature-worktree");
9471        git(
9472            &parent,
9473            &[
9474                "worktree",
9475                "add",
9476                "-b",
9477                "feature",
9478                worktree_path.to_str().expect("utf8 path"),
9479            ],
9480        );
9481
9482        let core = Core::start_discovered(spec(vec![root]));
9483        let keys: Vec<EntityKey> = core
9484            .snapshot()
9485            .entities
9486            .iter()
9487            .map(|entity| entity.key.clone())
9488            .collect();
9489
9490        core.refresh(&keys);
9491        let settled = core.settle();
9492
9493        let worktree_entity = settled
9494            .entities
9495            .iter()
9496            .find(|entity| matches!(entity.kind, Kind::Worktree))
9497            .expect("worktree entity present");
9498        assert!(
9499            matches!(
9500                worktree_entity.state.settled(),
9501                Some(Settled::Known {
9502                    value: WorktreeState::Merged,
9503                    at: _,
9504                    stale: _
9505                })
9506            ),
9507            "expected the worktree, at the same commit as the default branch, to read Merged, got {:?}",
9508            worktree_entity.state.settled()
9509        );
9510    }
9511
9512    /// The squash merge this whole ticket is named for, proven end to end
9513    /// through a real `refresh`: `feature`'s two commits are squashed into one
9514    /// commit on the default branch, so ancestry cannot see it (`feature`'s tip
9515    /// never becomes an ancestor), and only patch equivalence can. Its upstream
9516    /// tracking ref still resolves, matching the moment right after a squash
9517    /// merge and before the next prune removes it, which is what routes this
9518    /// entity through `Outstanding` into the second pass rather than settling
9519    /// `Gone` at the first.
9520    #[test]
9521    fn a_squash_merged_worktree_branch_reads_merged_after_a_refresh() {
9522        let dir = tempfile::tempdir().expect("temp dir");
9523        let root = root_of(&dir);
9524        let parent = root.join("parent");
9525        init_repo_with_a_commit(&parent);
9526        git(
9527            &parent,
9528            &[
9529                "remote",
9530                "add",
9531                "origin",
9532                "https://example.invalid/repo.git",
9533            ],
9534        );
9535        let worktree_path = root.join("feature-worktree");
9536        git(
9537            &parent,
9538            &[
9539                "worktree",
9540                "add",
9541                "-b",
9542                "feature",
9543                worktree_path.to_str().expect("utf8 path"),
9544            ],
9545        );
9546        fs::write(worktree_path.join("a.txt"), "one\n").expect("write a.txt");
9547        git(&worktree_path, &["add", "a.txt"]);
9548        git(&worktree_path, &["commit", "-m", "add a"]);
9549        fs::write(worktree_path.join("b.txt"), "two\n").expect("write b.txt");
9550        git(&worktree_path, &["add", "b.txt"]);
9551        git(&worktree_path, &["commit", "-m", "add b"]);
9552        let feature_sha = head_sha(&worktree_path);
9553
9554        // Squashed into the parent's own checkout, which is what the default
9555        // branch resolves against.
9556        git(&parent, &["merge", "--squash", "feature"]);
9557        git(&parent, &["commit", "-m", "squashed feature"]);
9558        let main_sha = head_sha(&parent);
9559        git(
9560            &parent,
9561            &["update-ref", "refs/remotes/origin/main", &main_sha],
9562        );
9563
9564        // `feature`'s own upstream, still resolving: the moment before a prune
9565        // removes it.
9566        git(&parent, &["config", "branch.feature.remote", "origin"]);
9567        git(
9568            &parent,
9569            &["config", "branch.feature.merge", "refs/heads/feature"],
9570        );
9571        git(
9572            &parent,
9573            &["update-ref", "refs/remotes/origin/feature", &feature_sha],
9574        );
9575
9576        let core = Core::start_discovered(spec(vec![root]));
9577        let keys: Vec<EntityKey> = core
9578            .snapshot()
9579            .entities
9580            .iter()
9581            .map(|entity| entity.key.clone())
9582            .collect();
9583
9584        core.refresh(&keys);
9585        let settled = core.settle();
9586
9587        let worktree_entity = settled
9588            .entities
9589            .iter()
9590            .find(|entity| matches!(entity.kind, Kind::Worktree))
9591            .expect("worktree entity present");
9592        assert!(
9593            matches!(
9594                worktree_entity.state.settled(),
9595                Some(Settled::Known {
9596                    value: WorktreeState::Merged,
9597                    at: _,
9598                    stale: _
9599                })
9600            ),
9601            "expected a squash-merged worktree branch to read Merged, got {:?}",
9602            worktree_entity.state.settled()
9603        );
9604    }
9605
9606    /// Proves the negative the state cell alone cannot: patch equivalence's
9607    /// expensive scan must never even start for an entity ancestry already
9608    /// settled. A Worktree whose branch is an ancestor of the default branch
9609    /// settles `Merged` at the first pass, so the only common dir in this test
9610    /// must show zero scans; a `state`-only assertion would still pass an
9611    /// implementation that ran the second pass over every entity and discarded
9612    /// whichever answer ancestry had already provided.
9613    #[test]
9614    fn patch_equivalence_never_runs_for_an_entity_ancestry_already_settled() {
9615        let dir = tempfile::tempdir().expect("temp dir");
9616        let root = root_of(&dir);
9617        let parent = root.join("parent");
9618        init_repo_with_a_commit(&parent);
9619        git(
9620            &parent,
9621            &[
9622                "remote",
9623                "add",
9624                "origin",
9625                "https://example.invalid/repo.git",
9626            ],
9627        );
9628        let sha = head_sha(&parent);
9629        git(&parent, &["update-ref", "refs/remotes/origin/main", &sha]);
9630        let worktree_path = root.join("feature-worktree");
9631        git(
9632            &parent,
9633            &[
9634                "worktree",
9635                "add",
9636                "-b",
9637                "feature",
9638                worktree_path.to_str().expect("utf8 path"),
9639            ],
9640        );
9641
9642        let (core, launched) = started_and_settled(spec(vec![root]));
9643        let keys: Vec<EntityKey> = launched
9644            .entities
9645            .iter()
9646            .map(|entity| entity.key.clone())
9647            .collect();
9648
9649        core.refresh(&keys);
9650        let settled = core.settle();
9651
9652        let worktree_entity = settled
9653            .entities
9654            .iter()
9655            .find(|entity| matches!(entity.kind, Kind::Worktree))
9656            .expect("worktree entity present");
9657        assert!(
9658            matches!(
9659                worktree_entity.state.settled(),
9660                Some(Settled::Known {
9661                    value: WorktreeState::Merged,
9662                    at: _,
9663                    stale: _
9664                })
9665            ),
9666            "expected ancestry alone to settle Merged here, got {:?}",
9667            worktree_entity.state.settled()
9668        );
9669        assert_eq!(
9670            core.patch_identity_reads_for_test(),
9671            0,
9672            "ancestry already settled this entity, so patch equivalence's shared \
9673             scan must never run for its common dir at all"
9674        );
9675    }
9676
9677    /// [`patch_equivalence`]'s own unit test proves the module itself writes no
9678    /// loose object; this proves the same through the real dispatch path a
9679    /// user's refresh actually runs, so a write introduced in `core.rs`'s glue
9680    /// rather than in the module would be caught too. Reuses the squash-merge
9681    /// fixture that routes a real `Core::refresh` into patch equivalence's
9682    /// second pass, and counts loose objects in the parent repository, since a
9683    /// linked Worktree shares its object database with its common dir.
9684    #[test]
9685    fn a_full_refresh_reaching_patch_equivalence_writes_no_loose_objects() {
9686        let dir = tempfile::tempdir().expect("temp dir");
9687        let root = root_of(&dir);
9688        let parent = root.join("parent");
9689        init_repo_with_a_commit(&parent);
9690        git(
9691            &parent,
9692            &[
9693                "remote",
9694                "add",
9695                "origin",
9696                "https://example.invalid/repo.git",
9697            ],
9698        );
9699        let worktree_path = root.join("feature-worktree");
9700        git(
9701            &parent,
9702            &[
9703                "worktree",
9704                "add",
9705                "-b",
9706                "feature",
9707                worktree_path.to_str().expect("utf8 path"),
9708            ],
9709        );
9710        fs::write(worktree_path.join("a.txt"), "one\n").expect("write a.txt");
9711        git(&worktree_path, &["add", "a.txt"]);
9712        git(&worktree_path, &["commit", "-m", "add a"]);
9713        fs::write(worktree_path.join("b.txt"), "two\n").expect("write b.txt");
9714        git(&worktree_path, &["add", "b.txt"]);
9715        git(&worktree_path, &["commit", "-m", "add b"]);
9716        let feature_sha = head_sha(&worktree_path);
9717
9718        git(&parent, &["merge", "--squash", "feature"]);
9719        git(&parent, &["commit", "-m", "squashed feature"]);
9720        let main_sha = head_sha(&parent);
9721        git(
9722            &parent,
9723            &["update-ref", "refs/remotes/origin/main", &main_sha],
9724        );
9725        git(&parent, &["config", "branch.feature.remote", "origin"]);
9726        git(
9727            &parent,
9728            &["config", "branch.feature.merge", "refs/heads/feature"],
9729        );
9730        git(
9731            &parent,
9732            &["update-ref", "refs/remotes/origin/feature", &feature_sha],
9733        );
9734
9735        let core = Core::start_discovered(spec(vec![root]));
9736        let keys: Vec<EntityKey> = core
9737            .snapshot()
9738            .entities
9739            .iter()
9740            .map(|entity| entity.key.clone())
9741            .collect();
9742
9743        let before = loose_object_count(&parent);
9744        core.refresh(&keys);
9745        let settled = core.settle();
9746        let after = loose_object_count(&parent);
9747
9748        let worktree_entity = settled
9749            .entities
9750            .iter()
9751            .find(|entity| matches!(entity.kind, Kind::Worktree))
9752            .expect("worktree entity present");
9753        assert!(
9754            matches!(
9755                worktree_entity.state.settled(),
9756                Some(Settled::Known {
9757                    value: WorktreeState::Merged,
9758                    at: _,
9759                    stale: _
9760                })
9761            ),
9762            "expected this refresh to actually reach patch equivalence and settle \
9763             Merged, got {:?}",
9764            worktree_entity.state.settled()
9765        );
9766        assert_eq!(
9767            before, after,
9768            "a full refresh reaching patch equivalence must never write a loose \
9769             object to the repository"
9770        );
9771    }
9772
9773    /// With patch equivalence now built, a diverged attached branch with a live
9774    /// upstream no longer stays outstanding forever: once ancestry says no,
9775    /// the second pass gets a real answer, and genuinely unmerged work (a real
9776    /// file change with no counterpart on the default branch, not merely an
9777    /// empty marker commit) settles `Active` rather than `Gone` or `Merged`,
9778    /// proven through the real dispatch path rather than either pass in
9779    /// isolation.
9780    #[test]
9781    fn a_diverged_worktree_with_a_live_upstream_and_genuinely_unmerged_work_settles_active_after_a_refresh()
9782     {
9783        let dir = tempfile::tempdir().expect("temp dir");
9784        let root = root_of(&dir);
9785        let parent = root.join("parent");
9786        init_repo_with_a_commit(&parent);
9787        let base_sha = head_sha(&parent);
9788        git(
9789            &parent,
9790            &[
9791                "remote",
9792                "add",
9793                "origin",
9794                "https://example.invalid/repo.git",
9795            ],
9796        );
9797        git(
9798            &parent,
9799            &["update-ref", "refs/remotes/origin/main", &base_sha],
9800        );
9801        let worktree_path = root.join("feature-worktree");
9802        git(
9803            &parent,
9804            &[
9805                "worktree",
9806                "add",
9807                "-b",
9808                "feature",
9809                worktree_path.to_str().expect("utf8 path"),
9810            ],
9811        );
9812        // Unmerged work: a real file change feature has that main (and
9813        // origin/main) do not, and that main never gains by any other means.
9814        fs::write(worktree_path.join("feature.txt"), "unmerged work\n").expect("write feature.txt");
9815        git(&worktree_path, &["add", "feature.txt"]);
9816        git(&worktree_path, &["commit", "-m", "unmerged"]);
9817        let feature_sha = head_sha(&worktree_path);
9818        // `feature`'s own upstream, live: the common dir's shared config and refs
9819        // make this visible from the worktree's own probe too.
9820        git(&parent, &["config", "branch.feature.remote", "origin"]);
9821        git(
9822            &parent,
9823            &["config", "branch.feature.merge", "refs/heads/feature"],
9824        );
9825        git(
9826            &parent,
9827            &["update-ref", "refs/remotes/origin/feature", &feature_sha],
9828        );
9829
9830        let core = Core::start_discovered(spec(vec![root]));
9831        let keys: Vec<EntityKey> = core
9832            .snapshot()
9833            .entities
9834            .iter()
9835            .map(|entity| entity.key.clone())
9836            .collect();
9837
9838        core.refresh(&keys);
9839        let settled = core.settle();
9840
9841        let worktree_entity = settled
9842            .entities
9843            .iter()
9844            .find(|entity| matches!(entity.kind, Kind::Worktree))
9845            .expect("worktree entity present");
9846        assert!(
9847            matches!(
9848                worktree_entity.state.settled(),
9849                Some(Settled::Known {
9850                    value: WorktreeState::Active,
9851                    at: _,
9852                    stale: _
9853                })
9854            ),
9855            "expected genuinely unmerged work with a live upstream to settle Active, got {:?}",
9856            worktree_entity.state.settled()
9857        );
9858    }
9859
9860    /// `CoreSpec::show_submodules` gates probing and dispatch, never Snapshot membership:
9861    /// a discovered Submodule is always part of the snapshot `Core::start` builds, shown or
9862    /// not, because the module pass that finds it always runs
9863    /// ([discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md)'s
9864    /// "the pass always runs, so Submodules are always known"). Built with the default,
9865    /// hidden reading precisely to prove that.
9866    #[test]
9867    fn a_submodule_is_in_the_snapshot_even_though_hidden_by_the_default_preference() {
9868        let dir = tempfile::tempdir().expect("temp dir");
9869        let root = root_of(&dir);
9870        let parent = root.join("parent");
9871        init_repo_with_a_commit(&parent);
9872        fs::write(
9873            parent.join(".gitmodules"),
9874            "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
9875        )
9876        .expect("write .gitmodules");
9877        fs::create_dir_all(parent.join("vendor").join("lib")).expect("create submodule dir");
9878
9879        let core = Core::start_discovered(spec(vec![root]));
9880        let snapshot = core.snapshot();
9881
9882        assert!(
9883            snapshot
9884                .entities
9885                .iter()
9886                .any(|entity| matches!(entity.kind, Kind::Submodule)),
9887            "a discovered Submodule must be in the snapshot even while show_submodules is off"
9888        );
9889    }
9890
9891    /// A Submodule's `state` and `base` cells must stay `Unknown` through a real
9892    /// refresh cycle, not only at construction:
9893    /// [`EntityState::probes_state`] and [`EntityState::probes_base`] are what
9894    /// stop `refresh`'s dispatch from ever calling `landing::probe` or
9895    /// `probe_base` for it again. The Submodule here is a real, valid repository
9896    /// with a real remote and a resolvable default branch ahead of its own tip
9897    /// (in fact an ancestor of it, so ancestry alone would prove `Merged`), so if
9898    /// either gate were missing this would settle a genuine live answer rather
9899    /// than merely fail to open.
9900    #[test]
9901    fn a_submodules_state_and_base_cells_stay_unknown_through_a_real_refresh() {
9902        let dir = tempfile::tempdir().expect("temp dir");
9903        let root = root_of(&dir);
9904        let parent = root.join("parent");
9905        init_repo_with_a_commit(&parent);
9906        fs::write(
9907            parent.join(".gitmodules"),
9908            "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
9909        )
9910        .expect("write .gitmodules");
9911        let submodule = parent.join("vendor").join("lib");
9912        init_repo_with_a_commit(&submodule);
9913        git(
9914            &submodule,
9915            &["remote", "add", "origin", "https://example.invalid/lib.git"],
9916        );
9917        let root_sha = head_sha(&submodule);
9918        git(&submodule, &["commit", "--allow-empty", "-m", "second"]);
9919        let tip_sha = head_sha(&submodule);
9920        git(&submodule, &["reset", "--hard", &root_sha]);
9921        git(
9922            &submodule,
9923            &["update-ref", "refs/remotes/origin/main", &tip_sha],
9924        );
9925
9926        // Shown, so the explicit `refresh` below actually dispatches a probe against it:
9927        // this test is about `probes_base`'s own gate, not about `show_submodules`'s.
9928        let mut core_spec = spec(vec![root]);
9929        core_spec.show_submodules = true;
9930        let core = Core::start_discovered(core_spec);
9931        let key = core
9932            .snapshot()
9933            .entities
9934            .iter()
9935            .find(|entity| matches!(entity.kind, Kind::Submodule))
9936            .expect("a discovered Submodule")
9937            .key
9938            .clone();
9939
9940        core.refresh(std::slice::from_ref(&key));
9941        let settled = core.settle();
9942        let submodule_entity = settled
9943            .entities
9944            .iter()
9945            .find(|entity| entity.key == key)
9946            .expect("the Submodule entity");
9947
9948        assert!(
9949            matches!(
9950                submodule_entity.base.settled(),
9951                Some(Settled::Unknown(Unknown::NoDefaultBranch))
9952            ),
9953            "expected a Submodule's base to stay Unknown through a real refresh, \
9954             got {:?}",
9955            submodule_entity.base.settled()
9956        );
9957        assert!(
9958            matches!(
9959                submodule_entity.state.settled(),
9960                Some(Settled::Unknown(Unknown::NoDefaultBranch))
9961            ),
9962            "expected a Submodule's state to stay Unknown through a real refresh, \
9963             rather than settling Merged off an untrusted default branch, got {:?}",
9964            submodule_entity.state.settled()
9965        );
9966    }
9967
9968    /// [discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md)'s
9969    /// "The Submodule row" fixes `name` as "the submodule path"; this proves the fact lands
9970    /// on the real `EntityState` `Core::start` builds, not only on the intermediate
9971    /// `DiscoveredEntity` `discovery::tests` already covers.
9972    #[test]
9973    fn a_submodules_entity_name_is_its_relative_path_not_its_basename() {
9974        let dir = tempfile::tempdir().expect("temp dir");
9975        let root = root_of(&dir);
9976        let parent = root.join("parent");
9977        init_repo_with_a_commit(&parent);
9978        fs::write(
9979            parent.join(".gitmodules"),
9980            "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
9981        )
9982        .expect("write .gitmodules");
9983        fs::create_dir_all(parent.join("vendor").join("lib")).expect("create submodule dir");
9984
9985        let core = Core::start_discovered(spec(vec![root]));
9986        let submodule = core
9987            .snapshot()
9988            .entities
9989            .into_iter()
9990            .find(|entity| matches!(entity.kind, Kind::Submodule))
9991            .expect("a discovered Submodule");
9992
9993        assert_eq!(
9994            submodule.name.as_ref(),
9995            "vendor/lib",
9996            "expected the declared relative path, not the basename `lib`"
9997        );
9998    }
9999
10000    /// AC3's negative case: an uninitialised Submodule (never `git submodule update
10001    /// --init`-ed, so its own path holds no `.git` at all) settles every cell a probe would
10002    /// otherwise open a repository for `Unknown(SubmoduleUninitialized)`, never `Failed`,
10003    /// because not being there yet is the normal, expected shape
10004    /// ([discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md)'s
10005    /// "An uninitialised Submodule is a row with every cell blank and `?` in the gutter").
10006    /// The row still exists (the assertion below finds it), so the row itself is not the
10007    /// mutation this covers; `probe_branch`/`probe_sync`/`probe_status`'s classification is.
10008    #[test]
10009    fn an_uninitialised_submodules_probed_cells_settle_unknown_not_failed() {
10010        let dir = tempfile::tempdir().expect("temp dir");
10011        let root = root_of(&dir);
10012        let parent = root.join("parent");
10013        init_repo_with_a_commit(&parent);
10014        fs::write(
10015            parent.join(".gitmodules"),
10016            "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
10017        )
10018        .expect("write .gitmodules");
10019        // Deliberately never initialised: no directory at all at the declared path, the
10020        // shape a plain `git clone` (no `--recurse-submodules`) leaves behind.
10021
10022        let mut core_spec = spec(vec![root]);
10023        core_spec.show_submodules = true;
10024        let core = Core::start_discovered(core_spec);
10025        let key = core
10026            .snapshot()
10027            .entities
10028            .iter()
10029            .find(|entity| matches!(entity.kind, Kind::Submodule))
10030            .expect("a discovered Submodule")
10031            .key
10032            .clone();
10033
10034        core.refresh(std::slice::from_ref(&key));
10035        let settled = core.settle();
10036        let submodule = settled
10037            .entities
10038            .iter()
10039            .find(|entity| entity.key == key)
10040            .expect("the Submodule entity");
10041
10042        assert!(
10043            matches!(
10044                submodule.branch.settled(),
10045                Some(Settled::Unknown(Unknown::SubmoduleUninitialized))
10046            ),
10047            "expected branch to settle Unknown(SubmoduleUninitialized), got {:?}",
10048            submodule.branch.settled()
10049        );
10050        assert!(
10051            matches!(
10052                submodule.sync.settled(),
10053                Some(Settled::Unknown(Unknown::SubmoduleUninitialized))
10054            ),
10055            "expected sync to settle Unknown(SubmoduleUninitialized), got {:?}",
10056            submodule.sync.settled()
10057        );
10058        assert!(
10059            matches!(
10060                submodule.dirty.settled(),
10061                Some(Settled::Unknown(Unknown::SubmoduleUninitialized))
10062            ),
10063            "expected dirty to settle Unknown(SubmoduleUninitialized), got {:?}",
10064            submodule.dirty.settled()
10065        );
10066        assert_eq!(
10067            summary(submodule),
10068            RowSummary::Unknown,
10069            "expected the row's own gutter fold to read Unknown, not Failed"
10070        );
10071    }
10072
10073    /// AC4's cost half: `show_submodules` off means a dispatched Generation never even
10074    /// opens a shown Submodule's own repository, while a shown one right beside it is
10075    /// probed normally in the very same Generation. Both submodules are real, valid
10076    /// repositories, so a probed-but-ignored implementation and a never-dispatched one are
10077    /// distinguishable only by whether the hidden one's cells ever leave "never settled".
10078    #[test]
10079    fn dispatch_skips_probing_a_hidden_submodule_while_probing_the_same_one_shown() {
10080        let dir = tempfile::tempdir().expect("temp dir");
10081        let root = root_of(&dir);
10082        let parent = root.join("parent");
10083        init_repo_with_a_commit(&parent);
10084        fs::write(
10085            parent.join(".gitmodules"),
10086            "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
10087        )
10088        .expect("write .gitmodules");
10089        init_repo_with_a_commit(&parent.join("vendor").join("lib"));
10090
10091        // `spec`'s own default: `show_submodules: false`.
10092        let core = Core::start_discovered(spec(vec![root]));
10093        let key = core
10094            .snapshot()
10095            .entities
10096            .iter()
10097            .find(|entity| matches!(entity.kind, Kind::Submodule))
10098            .expect("a discovered Submodule")
10099            .key
10100            .clone();
10101
10102        // First Generation, dispatched while hidden: `dispatch` must skip it outright.
10103        core.refresh(std::slice::from_ref(&key));
10104        let while_hidden = core.settle();
10105        let hidden_entity = while_hidden
10106            .entities
10107            .iter()
10108            .find(|entity| entity.key == key)
10109            .expect("submodule entity");
10110        assert!(
10111            hidden_entity.branch.settled().is_none(),
10112            "a Submodule dispatched while hidden must never even reach probe_branch, \
10113             so its cell stays never-settled rather than holding any value at all, got {:?}",
10114            hidden_entity.branch.settled()
10115        );
10116
10117        // Toggled live, no rebuild, then the very same key is handed to `refresh` again:
10118        // the second Generation is what proves the flag narrows the work rather than the
10119        // key, since nothing about the key or the `Core` itself changed in between.
10120        core.set_show_submodules(true);
10121        core.refresh(std::slice::from_ref(&key));
10122        let while_shown = core.settle();
10123        let shown_entity = while_shown
10124            .entities
10125            .iter()
10126            .find(|entity| entity.key == key)
10127            .expect("submodule entity");
10128        assert!(
10129            matches!(
10130                shown_entity.branch.settled(),
10131                Some(Settled::Known {
10132                    value: _,
10133                    at: _,
10134                    stale: _
10135                })
10136            ),
10137            "expected the same Submodule's branch to settle a real value once shown, got {:?}",
10138            shown_entity.branch.settled()
10139        );
10140    }
10141
10142    /// AC4's other half: toggling the live preference is free. Proven the same way
10143    /// `reload_with_the_same_active_set_leaves_discovery_and_its_generation_untouched`
10144    /// proves a same-Set reload never rebuilds `Core`: a Generation counter a rediscovery
10145    /// or a dispatch would have to move, checked before and after the toggle with nothing
10146    /// else run in between.
10147    #[test]
10148    fn toggling_show_submodules_starts_no_new_generation_and_dispatches_nothing() {
10149        let dir = tempfile::tempdir().expect("temp dir");
10150        let root = root_of(&dir);
10151        init_repo_with_a_commit(&root.join("repo-a"));
10152
10153        // Drained, so the two readings below differ only by whatever the toggles did.
10154        let (core, launched) = started_and_settled(spec(vec![root]));
10155        let before = launched.generation;
10156        let dispatched_before = core.dispatch_log_for_test();
10157        assert!(
10158            !dispatched_before.is_empty(),
10159            "launch dispatched nothing, so the comparison below would hold however much a \
10160             toggle dispatched"
10161        );
10162
10163        core.set_show_submodules(true);
10164        core.set_show_submodules(false);
10165
10166        assert_eq!(
10167            core.snapshot().generation,
10168            before,
10169            "toggling show_submodules must start no Generation of its own"
10170        );
10171        assert_eq!(
10172            core.dispatch_log_for_test(),
10173            dispatched_before,
10174            "toggling show_submodules must dispatch no probe of its own, leaving the last \
10175             Generation's own log exactly as it found it"
10176        );
10177    }
10178
10179    /// AC5: a `.gitmodules` parse failure marks the parent Repo's row Failed whether or not
10180    /// Submodules are shown, because the module pass that finds the failure runs either way
10181    /// ([discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md)'s
10182    /// "Failure": "The mark appears whether or not `show_submodules` is on, because the pass
10183    /// ran either way"). `spec`'s own default is already `show_submodules: false`, which is
10184    /// what makes this a real proof rather than a coincidence of some other default.
10185    #[test]
10186    fn a_malformed_gitmodules_file_still_fails_the_parent_while_submodules_are_hidden() {
10187        let dir = tempfile::tempdir().expect("temp dir");
10188        let root = root_of(&dir);
10189        let parent = root.join("parent");
10190        init_repo_with_a_commit(&parent);
10191        fs::write(
10192            parent.join(".gitmodules"),
10193            "[submodule \"lib\"\n\tpath = lib\n",
10194        )
10195        .expect("write malformed .gitmodules");
10196
10197        let core = Core::start_discovered(spec(vec![root]));
10198        let key = core
10199            .snapshot()
10200            .entities
10201            .iter()
10202            .find(|entity| entity.key.path() == parent)
10203            .expect("the parent entity")
10204            .key
10205            .clone();
10206        // The fold reads Failed only once the row holds some probed value at all: a
10207        // Generation's own dispatch is what proves the mark survives real probing, not
10208        // merely discovery's own construction-time diagnostics write.
10209        core.refresh(std::slice::from_ref(&key));
10210        let settled = core.settle();
10211        let parent_entity = settled
10212            .entities
10213            .iter()
10214            .find(|entity| entity.key == key)
10215            .expect("the parent entity");
10216
10217        assert_eq!(
10218            summary(parent_entity),
10219            RowSummary::Failed,
10220            "expected the parent to fold Failed even with Submodules hidden"
10221        );
10222        assert!(
10223            parent_entity.diagnostics.gitmodules_failed.is_some(),
10224            "expected the failure recorded in Diagnostics for the detail pane"
10225        );
10226        assert!(
10227            !settled
10228                .entities
10229                .iter()
10230                .any(|entity| matches!(entity.kind, Kind::Submodule)),
10231            "an unparseable .gitmodules yields no Submodule rows for that parent"
10232        );
10233    }
10234
10235    #[test]
10236    fn count_matches_a_plain_discoverys_entity_count() {
10237        let dir = tempfile::tempdir().expect("temp dir");
10238        let root = root_of(&dir);
10239        init_repo_with_a_commit(&root.join("one"));
10240        init_repo_with_a_commit(&root.join("two"));
10241
10242        let set = SetSpec {
10243            name: "test".to_string(),
10244            roots: vec![root],
10245            include: Vec::new(),
10246            exclude: Vec::new(),
10247        };
10248
10249        assert_eq!(discovery::count(&set), 2);
10250    }
10251
10252    #[test]
10253    fn the_slow_discovery_watcher_warns_with_the_count_reached_and_the_roots() {
10254        let progress = Arc::new(AtomicUsize::new(42));
10255        let finished = Arc::new(AtomicBool::new(false));
10256        let roots = vec![PathBuf::from("/repos/a"), PathBuf::from("/repos/b")];
10257
10258        let warning = watch_for_slow_discovery(progress, finished, roots, Duration::from_millis(1));
10259
10260        let message = warning.expect("a walk that has not finished should warn");
10261        assert!(message.contains("42"));
10262        assert!(message.contains("/repos/a"));
10263        assert!(message.contains("/repos/b"));
10264    }
10265
10266    #[test]
10267    fn the_slow_discovery_watcher_is_silent_once_the_walk_has_already_finished() {
10268        let progress = Arc::new(AtomicUsize::new(7));
10269        let finished = Arc::new(AtomicBool::new(true));
10270
10271        let warning =
10272            watch_for_slow_discovery(progress, finished, Vec::new(), Duration::from_millis(1));
10273
10274        assert!(warning.is_none());
10275    }
10276
10277    /// The same watcher `start_internal` wires in: on a fast, already-finished
10278    /// walk (the common case), joining its handle proves it ran and recorded no
10279    /// warning, exercised through `Core::start` itself rather than in isolation.
10280    /// `warn_after` is one second, the real production threshold, rather than a
10281    /// margin picked for speed: a one-repository walk finishes orders of
10282    /// magnitude faster than that even on a loaded machine, so this proves the
10283    /// fast path without racing a real walk the way a millisecond threshold did.
10284    #[test]
10285    fn a_fast_discovery_leaves_no_warning_once_the_watcher_has_run() {
10286        let dir = tempfile::tempdir().expect("temp dir");
10287        let root = root_of(&dir);
10288        init_repo_with_a_commit(&root.join("repo"));
10289        let (_tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
10290
10291        let started =
10292            Core::start_for_test(spec(vec![root]), Duration::from_secs(1), tick_rx).discovered();
10293        started
10294            .discovery_watcher
10295            .join()
10296            .expect("watcher thread should not panic");
10297
10298        assert!(started.core.discovery_warning().is_none());
10299    }
10300
10301    /// A [`DiscoveryGate`] starting `open`, and the channel that opens it once the call
10302    /// under test has returned.
10303    ///
10304    /// The gate is what makes "before its walk has run" a rendezvous rather than a
10305    /// margin. The channel is what makes an implementation that walks inline fail its
10306    /// assertion instead of wedging the run: nothing else would ever open the gate for
10307    /// it, so the backstop below is its only release, and the assertion then reports.
10308    fn gate_opened_on_signal(open: bool) -> (DiscoveryGate, Sender<()>, JoinHandle<()>) {
10309        let gate: DiscoveryGate = Arc::new((Mutex::new(open), Condvar::new()));
10310        let (returned_tx, returned_rx) = crossbeam_channel::bounded::<()>(1);
10311        let opener = thread::spawn({
10312            let gate = Arc::clone(&gate);
10313            move || {
10314                let _ = returned_rx.recv_timeout(crate::liveness::BACKSTOP);
10315                set_discovery_gate(&gate, true);
10316            }
10317        });
10318        (gate, returned_tx, opener)
10319    }
10320
10321    /// Criterion 1: `Core::start` returns before discovery has finished, and the rows
10322    /// land when discovery does.
10323    ///
10324    /// The walk is held closed before the `Core` is built, so the empty table below is
10325    /// the table `start` actually returned rather than one this test raced it to. Joining
10326    /// the harness's own `initial_discovery` handle afterwards is the rendezvous that says
10327    /// the walk landed: no sleep and no poll on either side.
10328    ///
10329    /// The row's phase C is held from before the walk is let go, so the cell read below
10330    /// is read at a point this test fixes rather than at whatever point launch's own
10331    /// Generation happened to have reached.
10332    #[test]
10333    fn start_returns_against_an_empty_table_and_the_rows_land_when_discovery_does() {
10334        let dir = tempfile::tempdir().expect("temp dir");
10335        let root = root_of(&dir);
10336        let repo = root.join("repo");
10337        init_repo_with_a_commit(&repo);
10338        let (_tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
10339        let (gate, start_returned, opener) = gate_opened_on_signal(false);
10340
10341        let started = Core::start_for_test_gated(
10342            spec(vec![root]),
10343            Duration::from_secs(3600),
10344            discovery::ABANDON_AFTER,
10345            tick_rx,
10346            Some(Arc::clone(&gate)),
10347        );
10348        let at_start = started.core.snapshot();
10349        let key = EntityKey::new(Arc::from(repo.as_path()));
10350        started.core.hold_phase_c_for_test(&key);
10351        start_returned.send(()).expect("the opener is listening");
10352        opener.join().expect("the opener thread should not panic");
10353        let started = started.discovered();
10354
10355        assert!(
10356            at_start.entities.is_empty(),
10357            "`Core::start` must return before discovery has finished, against the empty \
10358             table a consumer draws its first frame from, got {:?}",
10359            at_start
10360                .entities
10361                .iter()
10362                .map(|entity| entity.name.to_string())
10363                .collect::<Vec<_>>()
10364        );
10365
10366        let landed = started.core.snapshot();
10367        assert_eq!(
10368            landed
10369                .entities
10370                .iter()
10371                .map(|entity| entity.name.to_string())
10372                .collect::<Vec<_>>(),
10373            vec!["repo".to_string()],
10374            "the row must land on the table as soon as discovery does"
10375        );
10376        assert!(
10377            landed.entities[0].dirty.settled().is_none() && landed.entities[0].dirty.is_in_flight(),
10378            "discovery lands the row alone: launch's own Generation is already covering it \
10379             and its Cells stay unsettled until that Generation answers, which is what the \
10380             spinner sits behind"
10381        );
10382
10383        started.core.release_phase_c_for_test(&key);
10384        started.core.wait_phase_c_finished_for_test(&key);
10385    }
10386
10387    /// Criterion 2: a Generation that resolves its own order after its own discovery
10388    /// covers every row that walk found, including the ones the caller could not have
10389    /// named, and fills their Cells.
10390    ///
10391    /// `refresh_all` rather than `refresh`, because a caller that has just discarded the
10392    /// old Set's rows has no key to order by; the row below is discovered by this
10393    /// Generation's own walk and probed by the same Generation. Named by its order after
10394    /// launch's own Generation rather than by a number.
10395    #[test]
10396    fn refresh_all_covers_every_row_its_own_discovery_found() {
10397        let dir = tempfile::tempdir().expect("temp dir");
10398        let root = root_of(&dir);
10399        init_repo_with_a_commit(&root.join("repo"));
10400
10401        let (core, launched) = started_and_settled(spec(vec![root.clone()]));
10402        assert_eq!(
10403            launched
10404                .entities
10405                .iter()
10406                .map(|entity| entity.name.to_string())
10407                .collect::<Vec<_>>(),
10408            vec!["repo".to_string()],
10409            "launch's own walk must have landed and covered exactly the one row that \
10410             existed when it ran"
10411        );
10412        // Created after that walk finished, so this row exists in no snapshot the caller
10413        // could have read: only a Generation that resolves its own order after its own
10414        // discovery reaches it.
10415        init_repo_with_a_commit(&root.join("late"));
10416
10417        assert_eq!(
10418            core.refresh_all(),
10419            launched.generation.successor(),
10420            "`refresh_all` must be the Generation immediately after the one already on the \
10421             table"
10422        );
10423        let settled = core.settle();
10424
10425        let mut named: Vec<String> = settled
10426            .entities
10427            .iter()
10428            .filter(|entity| entity.branch.settled().is_some())
10429            .map(|entity| entity.name.to_string())
10430            .collect();
10431        named.sort();
10432        assert_eq!(
10433            named,
10434            vec!["late".to_string(), "repo".to_string()],
10435            "the Generation must cover every row its own discovery found, including one the \
10436             caller had no key for"
10437        );
10438    }
10439
10440    /// Criterion 3: `r`, focus gained and resume all reach `Core::refresh`, and it
10441    /// returns before its own Generation's discovery has run, so none of them holds the
10442    /// event loop for the length of a walk.
10443    ///
10444    /// `late` is created after the first walk has already finished, so only this
10445    /// `refresh`'s own walk could ever find it: its absence from the table `refresh`
10446    /// returned against is what says that walk had not run. Opening the gate afterwards
10447    /// lets the same Generation finish, which is what proves the work was deferred rather
10448    /// than dropped.
10449    #[test]
10450    fn refresh_returns_before_its_own_generations_discovery_has_run() {
10451        let dir = tempfile::tempdir().expect("temp dir");
10452        let root = root_of(&dir);
10453        init_repo_with_a_commit(&root.join("repo"));
10454        let (_tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
10455        let (gate, walk_may_run, opener) = gate_opened_on_signal(true);
10456
10457        let started = Core::start_for_test_gated(
10458            spec(vec![root.clone()]),
10459            Duration::from_secs(3600),
10460            discovery::ABANDON_AFTER,
10461            tick_rx,
10462            Some(Arc::clone(&gate)),
10463        )
10464        .discovered();
10465        let core = started.core;
10466        // Drained, so the settle-gate reading below is this `refresh`'s alone.
10467        let launched = settle_launch(&core);
10468        let keys: Vec<EntityKey> = launched
10469            .entities
10470            .iter()
10471            .map(|entity| entity.key.clone())
10472            .collect();
10473        init_repo_with_a_commit(&root.join("late"));
10474
10475        set_discovery_gate(&gate, false);
10476        let generation = core.refresh(&keys);
10477        let while_held = core.snapshot();
10478        let dispatched_while_held = core.settle_gate_count_for_test();
10479        walk_may_run.send(()).expect("the opener is listening");
10480        opener.join().expect("the opener thread should not panic");
10481
10482        assert_eq!(
10483            generation,
10484            launched.generation.successor(),
10485            "`refresh` must return its own Generation's number, the one immediately after \
10486             the table's, before that Generation has done any of its work"
10487        );
10488        assert!(
10489            !while_held
10490                .entities
10491                .iter()
10492                .any(|entity| &*entity.name == "late"),
10493            "`refresh` must return before its own Generation's walk has run, so a Repo \
10494             created after the previous walk is not on the table it returned against"
10495        );
10496        assert_eq!(
10497            dispatched_while_held, 0,
10498            "`refresh` returned before its Generation reached the table at all, so nothing \
10499             is dispatched yet"
10500        );
10501
10502        core.wait_dispatched_for_test();
10503        let settled = core.settle();
10504
10505        assert!(
10506            settled
10507                .entities
10508                .iter()
10509                .any(|entity| &*entity.name == "late"),
10510            "the deferred Generation must still run its own walk once it is let through: \
10511             deferred, never dropped"
10512        );
10513    }
10514
10515    /// The turnstile's whole claim: a Generation reserved second cannot reach the table
10516    /// before the one reserved first, whatever the two threads' own scheduling does.
10517    ///
10518    /// Without it a `refresh` whose walk finished quickly could insert its in-flight
10519    /// entries ahead of an older Generation's, leaving the older one to cancel the newer
10520    /// one and record itself as the live one, which is
10521    /// [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
10522    /// "Supersession" read backwards. The later ticket is taken on this thread, so it can
10523    /// only ever record itself after the earlier body has recorded and released; an
10524    /// implementation that did not wait would record the later one first.
10525    #[test]
10526    fn a_dispatch_body_waits_for_every_earlier_reserved_generation() {
10527        let turnstile = Arc::new(DispatchTurnstile::default());
10528        let earlier = turnstile.reserve();
10529        let later = turnstile.reserve();
10530        let order = Arc::new(Mutex::new(Vec::new()));
10531
10532        let earlier_body = thread::spawn({
10533            let turnstile = Arc::clone(&turnstile);
10534            let order = Arc::clone(&order);
10535            move || {
10536                let _turn = turnstile.take(earlier);
10537                order.lock().unwrap().push(earlier);
10538            }
10539        });
10540
10541        {
10542            let _turn = turnstile.take(later);
10543            order.lock().unwrap().push(later);
10544        }
10545        earlier_body
10546            .join()
10547            .expect("the earlier body should not panic");
10548
10549        assert_eq!(
10550            *order.lock().unwrap(),
10551            vec![earlier, later],
10552            "a dispatch body must run in the order its Generation was reserved"
10553        );
10554    }
10555
10556    /// The generic cancellation primitive stops a loop the instant `cancel` is
10557    /// observed, proven with a channel rendezvous rather than a sleep: `cancel` is
10558    /// set only after the worker's third step has genuinely completed, so a fourth
10559    /// step running at all would mean the flag was set but never actually checked.
10560    #[test]
10561    fn run_while_not_cancelled_stops_at_the_next_check_rather_than_running_forever() {
10562        let cancel = Arc::new(AtomicBool::new(false));
10563        let worker_cancel = Arc::clone(&cancel);
10564        let (step_started_tx, step_started_rx) = crossbeam_channel::bounded::<()>(0);
10565        let (proceed_tx, proceed_rx) = crossbeam_channel::bounded::<()>(0);
10566
10567        let worker = thread::spawn(move || {
10568            run_while_not_cancelled(&worker_cancel, || {
10569                step_started_tx.send(()).expect("test should be listening");
10570                proceed_rx.recv().is_ok()
10571            })
10572        });
10573
10574        for _ in 0..2 {
10575            step_started_rx
10576                .recv()
10577                .expect("worker should announce each step");
10578            proceed_tx.send(()).expect("let the step finish");
10579        }
10580        step_started_rx
10581            .recv()
10582            .expect("worker should announce its third step");
10583        cancel.store(true, Ordering::Release);
10584        proceed_tx.send(()).expect("let the third step finish");
10585
10586        let ran = worker.join().expect("worker thread should not panic");
10587
10588        assert_eq!(
10589            ran, 3,
10590            "expected cancellation to stop the loop after its third step"
10591        );
10592    }
10593
10594    /// Phase A's own per-entity timing distribution: opens (or reuses a cached
10595    /// handle for) every entity in `population` and reads `HEAD` from it, exactly
10596    /// the work `probe_branch` does, one rayon task per entity via `fanout::scatter`
10597    /// rather than `Core::refresh`, so the timing is not entangled with the
10598    /// settle-gate bookkeeping a full `Core` also pays for. Returns one
10599    /// [`Duration`] per entity actually probed, so a caller reports a real
10600    /// distribution rather than a total divided by a count.
10601    fn benchmark_identity_phase(
10602        population: Vec<crate::discovery::DiscoveredEntity>,
10603    ) -> (Duration, Vec<Duration>) {
10604        let (tx, rx) = crossbeam_channel::unbounded();
10605        let started = Instant::now();
10606        crate::fanout::scatter(population, tx, |entity| {
10607            let task_started = Instant::now();
10608            let repo = match &entity.repo {
10609                Some(repo) => repo.to_thread_local(),
10610                None => match git::open_thread_safe(entity.key.path()) {
10611                    Ok(repo) => repo.to_thread_local(),
10612                    Err(_) => return None,
10613                },
10614            };
10615            let _ = git::head_shape(&repo);
10616            Some(task_started.elapsed())
10617        });
10618        let wall = started.elapsed();
10619        let durations: Vec<Duration> = rx.into_iter().flatten().collect();
10620        (wall, durations)
10621    }
10622
10623    /// Every root this machine actually has of the two the owner's real corpus
10624    /// lives under. Read from `$HOME` at run time rather than a literal in this
10625    /// file, so no personal path is ever recorded in committed source.
10626    fn real_corpus_roots() -> Vec<PathBuf> {
10627        let Some(home) = std::env::var_os("HOME") else {
10628            return Vec::new();
10629        };
10630        let home = PathBuf::from(home);
10631        ["dev", "dev-misc"]
10632            .into_iter()
10633            .map(|leaf| home.join(leaf))
10634            .filter(|root| root.is_dir())
10635            .collect()
10636    }
10637
10638    /// A `.git`-committed disposable repository per index, standing in for the
10639    /// real corpus when it is absent or too small to be meaningful. Each one gets
10640    /// a distinct commit so opening it is not a single cached filesystem page for
10641    /// every entity.
10642    fn generated_fixture_corpus(size: usize) -> tempfile::TempDir {
10643        let root = tempfile::tempdir().expect("temp dir for generated fixture corpus");
10644        for i in 0..size {
10645            let repo = root.path().join(format!("fixture-repo-{i}"));
10646            fs::create_dir_all(&repo).expect("create fixture repo dir");
10647            gix::init(&repo).expect("init fixture repo");
10648            let status = Command::new("git")
10649                .arg("-C")
10650                .arg(&repo)
10651                .args(["-c", "user.email=test@example.com", "-c", "user.name=Test"])
10652                .args(["commit", "--allow-empty", "-m", &format!("commit {i}")])
10653                .status()
10654                .expect("run git commit");
10655            assert!(status.success());
10656        }
10657        root
10658    }
10659
10660    /// Percentile `p` (0 to 100) of an already-sorted, non-empty slice.
10661    fn percentile(sorted: &[Duration], p: usize) -> Duration {
10662        let index = (sorted.len() - 1) * p / 100;
10663        sorted[index]
10664    }
10665
10666    /// Path-component names to keep out of the benchmark's population entirely,
10667    /// read from an environment variable rather than a literal in this file: a
10668    /// standing project rule keeps certain names out of committed source, so a
10669    /// real run supplies them at invocation time
10670    /// (`REPON_BENCHMARK_EXCLUDE_NAMES=name-one,name-two`) instead of this file
10671    /// ever spelling one out. Empty, and therefore excluding nothing, when unset.
10672    fn extra_excluded_names() -> Vec<String> {
10673        parse_excluded_names(&std::env::var("REPON_BENCHMARK_EXCLUDE_NAMES").unwrap_or_default())
10674    }
10675
10676    /// The comma-separated parsing `extra_excluded_names` applies to whatever the
10677    /// environment variable holds, split out so it can be proven against a literal
10678    /// string rather than by mutating process environment state a parallel test
10679    /// run could race on.
10680    fn parse_excluded_names(raw: &str) -> Vec<String> {
10681        raw.split(',')
10682            .map(str::trim)
10683            .filter(|name| !name.is_empty())
10684            .map(str::to_string)
10685            .collect()
10686    }
10687
10688    /// Discovers, resolves and excluded-name-filters one root list into a
10689    /// population, without opening anything `excluded_names` names at any depth.
10690    /// Returns the wall time of discovery and resolution alongside the
10691    /// population, since resolution is where every entity's repository is
10692    /// actually opened the first time ([`git::resolve_boundary`]); the identity
10693    /// phase timed afterwards only re-reads `HEAD` from the handle that step
10694    /// already cached.
10695    fn discover_population(
10696        roots: Vec<PathBuf>,
10697        excluded_names: &[String],
10698    ) -> (Vec<crate::discovery::DiscoveredEntity>, Duration) {
10699        let set = SetSpec {
10700            name: "identity-probe-benchmark".to_string(),
10701            roots,
10702            include: Vec::new(),
10703            exclude: Vec::new(),
10704        };
10705        let started = Instant::now();
10706        let discovery = discovery::discover(&set);
10707        let (discovered, _) = discovery::resolve(&set, &discovery.entities);
10708        let elapsed = started.elapsed();
10709        let population = discovered
10710            .into_iter()
10711            .filter(|entity| {
10712                !entity.key.path().components().any(|component| {
10713                    excluded_names
10714                        .iter()
10715                        .any(|name| component.as_os_str() == name.as_str())
10716                })
10717            })
10718            .collect();
10719        (population, elapsed)
10720    }
10721
10722    /// The exclusion mechanism proven against a fixture: a name present nowhere
10723    /// but this test's own excluded-names list still keeps a matching boundary
10724    /// out of the discovered population, and its two siblings still get through.
10725    #[test]
10726    fn a_boundary_whose_path_matches_an_excluded_name_is_left_out_of_the_population() {
10727        let fixture = generated_fixture_corpus(3);
10728        let excluded = vec!["fixture-repo-1".to_string()];
10729
10730        let (population, _) = discover_population(vec![fixture.path().to_path_buf()], &excluded);
10731
10732        assert_eq!(population.len(), 2);
10733        assert!(
10734            population
10735                .iter()
10736                .all(|entity| entity.key.path().file_name().unwrap() != "fixture-repo-1"),
10737            "the excluded name must never appear in the population discovery returns"
10738        );
10739    }
10740
10741    #[test]
10742    fn excluded_names_parses_a_comma_separated_list_and_ignores_blanks() {
10743        assert_eq!(
10744            parse_excluded_names("foo, bar ,,baz"),
10745            vec!["foo".to_string(), "bar".to_string(), "baz".to_string()]
10746        );
10747        assert!(parse_excluded_names("").is_empty());
10748        assert!(parse_excluded_names("   ").is_empty());
10749    }
10750
10751    /// Benchmarks the identity probe (phase A: open the repository, read `HEAD`)
10752    /// against the owner's real corpus under `$HOME/dev` and `$HOME/dev-misc`,
10753    /// falling back to a generated fixture when the real corpus is absent or too
10754    /// small to be meaningful (fewer than 20 entities). Never run by `just ci`:
10755    /// this is a hand-run measurement, per this project's convention of recording
10756    /// hand-run figures with the date, machine and toolchain rather than asserting
10757    /// a timing budget in a committed test. Run it with:
10758    /// `cargo test -p repon-core --release -- --ignored --nocapture identity_probe_benchmark`
10759    ///
10760    /// Read-only throughout: discovery only stats for a `.git` entry and phase A
10761    /// only reads `HEAD`. Any boundary whose path has a component named by
10762    /// `REPON_BENCHMARK_EXCLUDE_NAMES` is dropped before discovery's second half
10763    /// would ever open it, which is how a standing exclusion is honoured without
10764    /// this file naming what it excludes.
10765    #[test]
10766    #[ignore = "hand-run against the owner's real corpus; see docs/spec/refresh.md for the recorded figures"]
10767    fn identity_probe_benchmark() {
10768        let excluded_names = extra_excluded_names();
10769
10770        // `_fixture` is held for the rest of the test whenever a fixture is used,
10771        // so its directories still exist when the identity phase opens them; it is
10772        // simply never populated on the real-corpus path.
10773        let mut _fixture: Option<tempfile::TempDir> = None;
10774
10775        let (real_population, real_discovery_wall) =
10776            discover_population(real_corpus_roots(), &excluded_names);
10777        let (population, using_fixture, discovery_wall) = if real_population.len() >= 20 {
10778            (real_population, false, real_discovery_wall)
10779        } else {
10780            println!(
10781                "real corpus absent or too small to be meaningful ({} entities); \
10782                 using a generated fixture instead",
10783                real_population.len()
10784            );
10785            let fixture = generated_fixture_corpus(300);
10786            let (population, fixture_discovery_wall) =
10787                discover_population(vec![fixture.path().to_path_buf()], &excluded_names);
10788            _fixture = Some(fixture);
10789            (population, true, fixture_discovery_wall)
10790        };
10791
10792        let population_size = population.len();
10793        assert!(
10794            population_size > 0,
10795            "neither a real corpus root nor the generated fixture produced any entities"
10796        );
10797
10798        let (wall, mut durations) = benchmark_identity_phase(population);
10799        durations.sort();
10800
10801        println!(
10802            "identity probe benchmark: corpus = {}, population = {population_size}",
10803            if using_fixture {
10804                "generated fixture"
10805            } else {
10806                "real corpus"
10807            }
10808        );
10809        println!(
10810            "discovery + first open (serial, every entity's own gix::open): {discovery_wall:?}"
10811        );
10812        println!("identity phase, warm, parallel (HEAD re-read from the cached handle): {wall:?}");
10813        println!(
10814            "identity phase per entity: p50 {:?}, p90 {:?}, max {:?}",
10815            percentile(&durations, 50),
10816            percentile(&durations, 90),
10817            durations.last().copied().unwrap_or_default(),
10818        );
10819    }
10820
10821    fn spec_with_overrides(roots: Vec<PathBuf>, overrides: Vec<RepoOverride>) -> CoreSpec {
10822        let mut spec = spec(roots);
10823        spec.overrides = overrides;
10824        spec
10825    }
10826
10827    /// The seam this proves: an explicit per-Repo override reaches all the way
10828    /// through `Core::refresh` and `settle` into the `default_branch` cell as
10829    /// rung 1, recorded in diagnostics, even though `origin/HEAD` and the name
10830    /// list would both answer differently if asked.
10831    #[test]
10832    fn a_per_repo_override_resolves_the_default_branch_at_rung_one_through_a_real_refresh() {
10833        let dir = tempfile::tempdir().expect("temp dir");
10834        let root = root_of(&dir);
10835        let repo = root.join("repo");
10836        init_repo_with_a_commit(&repo);
10837        git(
10838            &repo,
10839            &[
10840                "remote",
10841                "add",
10842                "origin",
10843                "https://example.invalid/repo.git",
10844            ],
10845        );
10846        let sha = head_sha(&repo);
10847        git(&repo, &["update-ref", "refs/remotes/origin/main", &sha]);
10848        let remote_refs_dir = repo
10849            .join(".git")
10850            .join("refs")
10851            .join("remotes")
10852            .join("origin");
10853        fs::create_dir_all(&remote_refs_dir).expect("create refs/remotes/origin dir");
10854        fs::write(
10855            remote_refs_dir.join("HEAD"),
10856            "ref: refs/remotes/origin/main\n",
10857        )
10858        .expect("write HEAD");
10859
10860        let core = Core::start_discovered(spec_with_overrides(
10861            vec![root],
10862            vec![RepoOverride {
10863                path: repo.clone(),
10864                default_branch: Some("develop".to_string()),
10865                excluded: false,
10866            }],
10867        ));
10868        let key = core.snapshot().entities[0].key.clone();
10869
10870        core.refresh(std::slice::from_ref(&key));
10871        let settled = core.settle();
10872        let entity = &settled.entities[0];
10873
10874        match entity.default_branch.settled() {
10875            Some(Settled::Known {
10876                value,
10877                at: _,
10878                stale: _,
10879            }) => assert_eq!(
10880                value.name(),
10881                "origin/develop",
10882                "the override must win even though origin/HEAD names a different branch"
10883            ),
10884            other => panic!("expected the override's own answer, got {other:?}"),
10885        }
10886        assert_eq!(
10887            entity.diagnostics.default_branch_rung,
10888            Some(1),
10889            "an override must be recorded as rung 1"
10890        );
10891    }
10892
10893    /// `probe_now`'s synchronous path carries the same override wiring as
10894    /// `refresh`, proven directly since a Launcher return uses it without ever
10895    /// calling `refresh` first.
10896    #[test]
10897    fn a_per_repo_override_also_resolves_through_probe_now() {
10898        let dir = tempfile::tempdir().expect("temp dir");
10899        let root = root_of(&dir);
10900        let repo = root.join("repo");
10901        init_repo_with_a_commit(&repo);
10902
10903        let core = Core::start_discovered(spec_with_overrides(
10904            vec![root],
10905            vec![RepoOverride {
10906                path: repo.clone(),
10907                default_branch: Some("release".to_string()),
10908                excluded: false,
10909            }],
10910        ));
10911        let key = core.snapshot().entities[0].key.clone();
10912
10913        let entity = core.probe_now(&key);
10914
10915        match entity.default_branch.settled() {
10916            // No remote at all: the override still answers, using the bare name.
10917            Some(Settled::Known {
10918                value,
10919                at: _,
10920                stale: _,
10921            }) => assert_eq!(value.name(), "release"),
10922            other => panic!("expected the override's own answer, got {other:?}"),
10923        }
10924        assert_eq!(entity.diagnostics.default_branch_rung, Some(1));
10925    }
10926
10927    /// The three named ways rung 4 is reached are recorded distinctly, not merged
10928    /// into one opaque "gave up" fact: no remote at all, two or more remotes with
10929    /// none named `origin`, and a chosen remote whose tracking refs matched
10930    /// nothing in the name list.
10931    #[test]
10932    fn reaching_rung_four_with_no_remote_at_all_records_why() {
10933        let dir = tempfile::tempdir().expect("temp dir");
10934        let root = root_of(&dir);
10935        let repo = root.join("repo");
10936        init_repo_with_a_commit(&repo);
10937
10938        let core = Core::start_discovered(spec(vec![root]));
10939        let key = core.snapshot().entities[0].key.clone();
10940
10941        core.refresh(std::slice::from_ref(&key));
10942        let settled = core.settle();
10943        let entity = &settled.entities[0];
10944
10945        assert_eq!(entity.diagnostics.default_branch_rung, Some(4));
10946        assert_eq!(
10947            entity.diagnostics.default_branch_stopped,
10948            Some(DefaultBranchStopped::NoRemote)
10949        );
10950    }
10951
10952    #[test]
10953    fn reaching_rung_four_with_two_unnamed_remotes_records_why() {
10954        let dir = tempfile::tempdir().expect("temp dir");
10955        let root = root_of(&dir);
10956        let repo = root.join("repo");
10957        init_repo_with_a_commit(&repo);
10958        git(
10959            &repo,
10960            &[
10961                "remote",
10962                "add",
10963                "fork-one",
10964                "https://example.invalid/one.git",
10965            ],
10966        );
10967        git(
10968            &repo,
10969            &[
10970                "remote",
10971                "add",
10972                "fork-two",
10973                "https://example.invalid/two.git",
10974            ],
10975        );
10976
10977        let core = Core::start_discovered(spec(vec![root]));
10978        let key = core.snapshot().entities[0].key.clone();
10979
10980        core.refresh(std::slice::from_ref(&key));
10981        let settled = core.settle();
10982        let entity = &settled.entities[0];
10983
10984        assert_eq!(entity.diagnostics.default_branch_rung, Some(4));
10985        assert_eq!(
10986            entity.diagnostics.default_branch_stopped,
10987            Some(DefaultBranchStopped::AmbiguousRemote)
10988        );
10989    }
10990
10991    #[test]
10992    fn reaching_rung_four_with_a_chosen_remote_and_no_matching_ref_records_why() {
10993        let dir = tempfile::tempdir().expect("temp dir");
10994        let root = root_of(&dir);
10995        let repo = root.join("repo");
10996        init_repo_with_a_commit(&repo);
10997        git(
10998            &repo,
10999            &[
11000                "remote",
11001                "add",
11002                "origin",
11003                "https://example.invalid/repo.git",
11004            ],
11005        );
11006        // A remote-tracking ref exists, but under a name outside rung 3's list, and
11007        // there is no origin/HEAD at all.
11008        let sha = head_sha(&repo);
11009        git(&repo, &["update-ref", "refs/remotes/origin/feature", &sha]);
11010
11011        let core = Core::start_discovered(spec(vec![root]));
11012        let key = core.snapshot().entities[0].key.clone();
11013
11014        core.refresh(std::slice::from_ref(&key));
11015        let settled = core.settle();
11016        let entity = &settled.entities[0];
11017
11018        assert_eq!(entity.diagnostics.default_branch_rung, Some(4));
11019        assert_eq!(
11020            entity.diagnostics.default_branch_stopped,
11021            Some(DefaultBranchStopped::NameListExhausted)
11022        );
11023    }
11024
11025    /// A Repo with no override and no resolvable remote reaches rung 4: Unknown,
11026    /// never Failed, which stays reserved for a git error.
11027    #[test]
11028    fn a_repo_with_nothing_to_resolve_settles_unknown_never_failed() {
11029        let dir = tempfile::tempdir().expect("temp dir");
11030        let root = root_of(&dir);
11031        let repo = root.join("repo");
11032        init_repo_with_a_commit(&repo);
11033
11034        let core = Core::start_discovered(spec(vec![root]));
11035        let key = core.snapshot().entities[0].key.clone();
11036
11037        core.refresh(std::slice::from_ref(&key));
11038        let settled = core.settle();
11039        let entity = &settled.entities[0];
11040
11041        assert!(matches!(
11042            entity.default_branch.settled(),
11043            Some(Settled::Unknown(Unknown::NoDefaultBranch))
11044        ));
11045        assert_eq!(entity.diagnostics.default_branch_rung, Some(4));
11046    }
11047
11048    /// The seam this proves: a stale symbolic `origin/HEAD` reaches all the way
11049    /// through `Core::refresh` and `settle` into `Diagnostics`, not just the
11050    /// fallen-through rung 3 answer, since the spec requires recording that the
11051    /// stale case is what happened rather than leaving the same trail a merely
11052    /// absent `origin/HEAD` would.
11053    #[test]
11054    fn a_stale_remote_head_is_recorded_in_diagnostics_through_a_real_refresh() {
11055        let dir = tempfile::tempdir().expect("temp dir");
11056        let root = root_of(&dir);
11057        let repo = root.join("repo");
11058        init_repo_with_a_commit(&repo);
11059        git(
11060            &repo,
11061            &[
11062                "remote",
11063                "add",
11064                "origin",
11065                "https://example.invalid/repo.git",
11066            ],
11067        );
11068        let sha = head_sha(&repo);
11069        git(&repo, &["update-ref", "refs/remotes/origin/trunk", &sha]);
11070        let remote_refs_dir = repo
11071            .join(".git")
11072            .join("refs")
11073            .join("remotes")
11074            .join("origin");
11075        fs::create_dir_all(&remote_refs_dir).expect("create refs/remotes/origin dir");
11076        // Points at a name never created as a ref: the stale case, not merely absent.
11077        fs::write(
11078            remote_refs_dir.join("HEAD"),
11079            "ref: refs/remotes/origin/main\n",
11080        )
11081        .expect("write HEAD");
11082
11083        let core = Core::start_discovered(spec(vec![root]));
11084        let key = core.snapshot().entities[0].key.clone();
11085
11086        core.refresh(std::slice::from_ref(&key));
11087        let settled = core.settle();
11088        let entity = &settled.entities[0];
11089
11090        match entity.default_branch.settled() {
11091            Some(Settled::Known {
11092                value,
11093                at: _,
11094                stale: _,
11095            }) => {
11096                assert_eq!(value.name(), "origin/trunk")
11097            }
11098            other => panic!("expected the name list's answer, got {other:?}"),
11099        }
11100        assert!(
11101            entity.diagnostics.default_branch_rung_two_stale,
11102            "a stale origin/HEAD target must be recorded on the entity's diagnostics"
11103        );
11104    }
11105
11106    /// A resolvable `origin/HEAD` must never be marked stale, so the flag actually
11107    /// distinguishes the two cases rather than always being set once rung 2 runs.
11108    #[test]
11109    fn a_resolvable_remote_head_is_not_recorded_as_stale() {
11110        let dir = tempfile::tempdir().expect("temp dir");
11111        let root = root_of(&dir);
11112        let repo = root.join("repo");
11113        init_repo_with_a_commit(&repo);
11114        git(
11115            &repo,
11116            &[
11117                "remote",
11118                "add",
11119                "origin",
11120                "https://example.invalid/repo.git",
11121            ],
11122        );
11123        let sha = head_sha(&repo);
11124        git(&repo, &["update-ref", "refs/remotes/origin/main", &sha]);
11125        let remote_refs_dir = repo
11126            .join(".git")
11127            .join("refs")
11128            .join("remotes")
11129            .join("origin");
11130        fs::create_dir_all(&remote_refs_dir).expect("create refs/remotes/origin dir");
11131        fs::write(
11132            remote_refs_dir.join("HEAD"),
11133            "ref: refs/remotes/origin/main\n",
11134        )
11135        .expect("write HEAD");
11136
11137        let core = Core::start_discovered(spec(vec![root]));
11138        let key = core.snapshot().entities[0].key.clone();
11139
11140        core.refresh(std::slice::from_ref(&key));
11141        let settled = core.settle();
11142        let entity = &settled.entities[0];
11143
11144        assert!(!entity.diagnostics.default_branch_rung_two_stale);
11145    }
11146
11147    /// The defining behaviour for per-Repo matching: one `[[repo]]` entry naming
11148    /// only the parent Repo's own path still applies to a linked Worktree sharing
11149    /// its common dir, proven against a real `git worktree add` rather than a
11150    /// hand-built stand-in for the on-disk relationship.
11151    #[test]
11152    fn one_override_on_a_repos_path_covers_a_worktree_sharing_its_common_dir() {
11153        let dir = tempfile::tempdir().expect("temp dir");
11154        let root = root_of(&dir);
11155        let parent = root.join("parent");
11156        init_repo_with_a_commit(&parent);
11157        let worktree = root.join("worktree");
11158        git(
11159            &parent,
11160            &[
11161                "worktree",
11162                "add",
11163                "-b",
11164                "feature",
11165                worktree.to_str().expect("utf8 path"),
11166            ],
11167        );
11168
11169        let core = Core::start_discovered(spec_with_overrides(
11170            vec![root],
11171            vec![RepoOverride {
11172                path: parent.clone(),
11173                default_branch: None,
11174                excluded: true,
11175            }],
11176        ));
11177        let snapshot = core.snapshot();
11178
11179        for entity in &snapshot.entities {
11180            assert!(
11181                entity.excluded,
11182                "both the Repo and its Worktree must inherit the entry declared on the Repo's own path, entity: {:?}",
11183                entity.key
11184            );
11185        }
11186        assert_eq!(
11187            snapshot.entities.len(),
11188            2,
11189            "expected the parent plus its worktree"
11190        );
11191    }
11192
11193    /// The other direction: an entry naming a Worktree's own path beats the entry
11194    /// it would otherwise inherit from the Repo it shares a common dir with, while
11195    /// a second Worktree with no entry of its own still inherits the Repo's entry.
11196    #[test]
11197    fn an_entry_naming_a_worktrees_own_path_beats_the_inherited_one() {
11198        let dir = tempfile::tempdir().expect("temp dir");
11199        let root = root_of(&dir);
11200        let parent = root.join("parent");
11201        init_repo_with_a_commit(&parent);
11202        let worktree_own = root.join("worktree-own");
11203        let worktree_inherits = root.join("worktree-inherits");
11204        git(
11205            &parent,
11206            &[
11207                "worktree",
11208                "add",
11209                "-b",
11210                "feature-own",
11211                worktree_own.to_str().expect("utf8 path"),
11212            ],
11213        );
11214        git(
11215            &parent,
11216            &[
11217                "worktree",
11218                "add",
11219                "-b",
11220                "feature-inherits",
11221                worktree_inherits.to_str().expect("utf8 path"),
11222            ],
11223        );
11224
11225        let core = Core::start_discovered(spec_with_overrides(
11226            vec![root],
11227            vec![
11228                RepoOverride {
11229                    path: parent.clone(),
11230                    default_branch: None,
11231                    excluded: true,
11232                },
11233                RepoOverride {
11234                    path: worktree_own.clone(),
11235                    default_branch: None,
11236                    excluded: false,
11237                },
11238            ],
11239        ));
11240        let snapshot = core.snapshot();
11241
11242        let find = |path: &Path| {
11243            snapshot
11244                .entities
11245                .iter()
11246                .find(|entity| entity.key.path() == path)
11247                .unwrap_or_else(|| panic!("entity at {path:?} present"))
11248        };
11249
11250        assert!(
11251            find(&parent).excluded,
11252            "the parent Repo has no entry of its own and inherits the excluding one"
11253        );
11254        assert!(
11255            !find(&worktree_own).excluded,
11256            "the Worktree named directly by its own path must use its own entry, not the inherited one"
11257        );
11258        assert!(
11259            find(&worktree_inherits).excluded,
11260            "a sibling Worktree with no entry of its own still inherits the Repo's entry"
11261        );
11262    }
11263
11264    /// A Submodule's own common dir differs from its parent's
11265    /// (`<parent common dir>/modules/<name>`), so an entry naming only the
11266    /// parent's path can never also exclude the parent's Submodule: the entry
11267    /// covers the parent and its Worktrees, never a Submodule reached through it.
11268    #[test]
11269    fn an_override_on_the_parents_path_never_excludes_its_submodule() {
11270        let dir = tempfile::tempdir().expect("temp dir");
11271        let root = root_of(&dir);
11272        let parent = root.join("parent");
11273        init_repo_with_a_commit(&parent);
11274        fs::write(
11275            parent.join(".gitmodules"),
11276            "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
11277        )
11278        .expect("write .gitmodules");
11279        fs::create_dir_all(parent.join("vendor").join("lib")).expect("create submodule dir");
11280
11281        let core = Core::start_discovered(spec_with_overrides(
11282            vec![root],
11283            vec![RepoOverride {
11284                path: parent.clone(),
11285                default_branch: None,
11286                excluded: true,
11287            }],
11288        ));
11289        let snapshot = core.snapshot();
11290
11291        let submodule = snapshot
11292            .entities
11293            .iter()
11294            .find(|entity| matches!(entity.kind, Kind::Submodule))
11295            .expect("the submodule is still discovered and listed");
11296        assert!(
11297            !submodule.excluded,
11298            "an entry naming only the parent's path must never reach a Submodule, \
11299             whose own common dir differs from its parent's"
11300        );
11301    }
11302
11303    /// The seam this proves: `Core::default_branch_chain_reads_for_test` counts
11304    /// how many times a `refresh` actually computed the default-branch chain's
11305    /// per-common-dir facts (`default_branch::ChainFacts::resolve`, the loose-file
11306    /// read plus the reference lookups), rather than reusing an already-computed
11307    /// answer for a common dir another entity in the same Generation already paid
11308    /// for. Reading the count off `Core` this way is the seam, not an internal:
11309    /// it is a named, stable test-only entry point in the same
11310    /// `#[cfg(test)] impl Core` family as `cached_repo_handle_for_test`, which
11311    /// already proves a different sharing question the same way. There is no
11312    /// black-box way to observe "how many times an internal read ran" through
11313    /// `Snapshot` alone, since two different common dirs can legitimately answer
11314    /// with the same branch name.
11315    ///
11316    /// Three Worktrees share one common dir with their Repo (four entities); a
11317    /// second, unrelated Repo has its own. Memoised, the count is 2, the number of
11318    /// distinct common dirs; unmemoised, it is 4, the number of entities.
11319    #[test]
11320    fn the_default_branch_chain_is_memoised_once_per_common_dir_per_generation() {
11321        let dir = tempfile::tempdir().expect("temp dir");
11322        let root = root_of(&dir);
11323        let parent = root.join("parent");
11324        init_repo_with_a_commit(&parent);
11325        for name in ["wt-a", "wt-b", "wt-c"] {
11326            let worktree = root.join(name);
11327            git(
11328                &parent,
11329                &[
11330                    "worktree",
11331                    "add",
11332                    "-b",
11333                    name,
11334                    worktree.to_str().expect("utf8 path"),
11335                ],
11336            );
11337        }
11338        let other_repo = root.join("other");
11339        init_repo_with_a_commit(&other_repo);
11340
11341        let (core, launched) = started_and_settled(spec(vec![root]));
11342        let keys: Vec<EntityKey> = launched
11343            .entities
11344            .iter()
11345            .map(|entity| entity.key.clone())
11346            .collect();
11347        assert_eq!(
11348            keys.len(),
11349            5,
11350            "expected the parent, its three worktrees and the unrelated repo"
11351        );
11352
11353        core.refresh(&keys);
11354        core.settle();
11355
11356        assert_eq!(
11357            core.default_branch_chain_reads_for_test(),
11358            2,
11359            "four entities span exactly two common dirs; a memoised chain reads \
11360             each common dir once, not once per entity"
11361        );
11362
11363        // A second Generation pays the same two reads again. A cache hoisted onto
11364        // `Core` would answer this refresh for free and read 0, which is the
11365        // persistence ADR 0006 refuses.
11366        core.refresh(&keys);
11367        core.settle();
11368        assert_eq!(
11369            core.default_branch_chain_reads_for_test(),
11370            2,
11371            "the memo lives inside one Generation's dispatch; the next Generation \
11372             recomputes rather than inheriting it"
11373        );
11374    }
11375
11376    /// The same proof as `the_default_branch_chain_is_memoised_once_per_common_dir_per_generation`,
11377    /// for patch equivalence's own expensive half: two sibling Worktrees, each
11378    /// with a live upstream and unmerged work of its own, share one common dir
11379    /// and must scan its default-branch history once between them, not twice;
11380    /// an unrelated Repo's own Worktree, in its own common dir, pays for a
11381    /// second scan. Both entities settling (`Active`, since neither's work
11382    /// actually landed) is what proves the second pass ran for both rather than
11383    /// one being cancelled or skipped, which would otherwise let a
11384    /// once-per-entity implementation coincidentally also read 2.
11385    #[test]
11386    fn patch_equivalence_is_memoised_once_per_common_dir_per_generation() {
11387        let dir = tempfile::tempdir().expect("temp dir");
11388        let root = root_of(&dir);
11389        let parent = root.join("parent");
11390        init_repo_with_a_commit(&parent);
11391        git(
11392            &parent,
11393            &[
11394                "remote",
11395                "add",
11396                "origin",
11397                "https://example.invalid/repo.git",
11398            ],
11399        );
11400        let base_sha = head_sha(&parent);
11401        git(
11402            &parent,
11403            &["update-ref", "refs/remotes/origin/main", &base_sha],
11404        );
11405        for name in ["feature-x", "feature-y"] {
11406            let worktree = root.join(name);
11407            git(
11408                &parent,
11409                &[
11410                    "worktree",
11411                    "add",
11412                    "-b",
11413                    name,
11414                    worktree.to_str().expect("utf8 path"),
11415                ],
11416            );
11417            fs::write(worktree.join(format!("{name}.txt")), "unmerged\n")
11418                .expect("write worktree file");
11419            git(&worktree, &["add", "."]);
11420            git(&worktree, &["commit", "-m", "unmerged work"]);
11421            let tip_sha = head_sha(&worktree);
11422            git(
11423                &parent,
11424                &["config", &format!("branch.{name}.remote"), "origin"],
11425            );
11426            git(
11427                &parent,
11428                &[
11429                    "config",
11430                    &format!("branch.{name}.merge"),
11431                    &format!("refs/heads/{name}"),
11432                ],
11433            );
11434            git(
11435                &parent,
11436                &[
11437                    "update-ref",
11438                    &format!("refs/remotes/origin/{name}"),
11439                    &tip_sha,
11440                ],
11441            );
11442        }
11443
11444        let other_parent = root.join("other");
11445        init_repo_with_a_commit(&other_parent);
11446        git(
11447            &other_parent,
11448            &[
11449                "remote",
11450                "add",
11451                "origin",
11452                "https://example.invalid/other.git",
11453            ],
11454        );
11455        let other_base_sha = head_sha(&other_parent);
11456        git(
11457            &other_parent,
11458            &["update-ref", "refs/remotes/origin/main", &other_base_sha],
11459        );
11460        let other_worktree = root.join("other-feature");
11461        git(
11462            &other_parent,
11463            &[
11464                "worktree",
11465                "add",
11466                "-b",
11467                "other-feature",
11468                other_worktree.to_str().expect("utf8 path"),
11469            ],
11470        );
11471        fs::write(other_worktree.join("other.txt"), "unmerged\n").expect("write worktree file");
11472        git(&other_worktree, &["add", "."]);
11473        git(&other_worktree, &["commit", "-m", "unmerged work"]);
11474        let other_tip_sha = head_sha(&other_worktree);
11475        git(
11476            &other_parent,
11477            &["config", "branch.other-feature.remote", "origin"],
11478        );
11479        git(
11480            &other_parent,
11481            &[
11482                "config",
11483                "branch.other-feature.merge",
11484                "refs/heads/other-feature",
11485            ],
11486        );
11487        git(
11488            &other_parent,
11489            &[
11490                "update-ref",
11491                "refs/remotes/origin/other-feature",
11492                &other_tip_sha,
11493            ],
11494        );
11495
11496        let (core, launched) = started_and_settled(spec(vec![root]));
11497        let keys: Vec<EntityKey> = launched
11498            .entities
11499            .iter()
11500            .map(|entity| entity.key.clone())
11501            .collect();
11502        assert_eq!(
11503            keys.len(),
11504            5,
11505            "expected two parents plus their three worktrees"
11506        );
11507
11508        core.refresh(&keys);
11509        let settled = core.settle();
11510
11511        let worktree_states: Vec<_> = settled
11512            .entities
11513            .iter()
11514            .filter(|entity| matches!(entity.kind, Kind::Worktree))
11515            .map(|entity| entity.state.settled())
11516            .collect();
11517        assert_eq!(worktree_states.len(), 3, "expected three worktree rows");
11518        for settled_state in &worktree_states {
11519            assert!(
11520                matches!(
11521                    settled_state,
11522                    Some(Settled::Known {
11523                        value: WorktreeState::Active,
11524                        at: _,
11525                        stale: _
11526                    })
11527                ),
11528                "expected every worktree's genuinely unmerged work to settle Active, got {settled_state:?}"
11529            );
11530        }
11531
11532        assert_eq!(
11533            core.patch_identity_reads_for_test(),
11534            2,
11535            "two worktrees share one common dir and must scan its default-branch \
11536             history once between them, not once per entity; the unrelated repo's \
11537             own worktree pays for a second scan"
11538        );
11539
11540        // A second Generation pays for the same two scans again: a cache hoisted
11541        // onto `Core` would answer this refresh for free and read 0.
11542        core.refresh(&keys);
11543        core.settle();
11544        assert_eq!(
11545            core.patch_identity_reads_for_test(),
11546            2,
11547            "the memo lives inside one Generation's dispatch; the next Generation \
11548             recomputes rather than inheriting it"
11549        );
11550    }
11551
11552    /// Criterion 3's widen direction, end to end: `feature-deep` forks at the
11553    /// parent commit `deep_fork_sha` and is squashed into main immediately
11554    /// afterwards; `feature-shallow` forks at that squash commit (strictly more
11555    /// recent, so its own merge base is shallower) and is squashed in turn to
11556    /// produce `main`'s tip. The deepest merge base among the two siblings is
11557    /// `feature-deep`'s own, `deep_fork_sha`, not `feature-shallow`'s.
11558    ///
11559    /// A scan bounded by the *shallowest* sibling's merge base instead of the
11560    /// deepest would stop before reaching the commit that squashed
11561    /// `feature-deep` in, since that commit sits strictly between the two
11562    /// bounds: `feature-deep` would then settle `Active` instead of `Merged`.
11563    /// This is a smoke test for that outcome through the real dispatch
11564    /// pipeline, not a proof: rayon's work stealing gives dispatch `order` no
11565    /// ordering guarantee, so `feature-deep` landing last here is a nudge
11566    /// towards, never proof of, exercising a lazy first-arrival bound.
11567    /// `bound_gate_deepest_folds_every_candidate_regardless_of_report_order`
11568    /// below is what deterministically proves the bound is collected from
11569    /// every sibling rather than computed lazily from whichever arrives first.
11570    #[test]
11571    fn an_entity_whose_merge_base_is_deeper_than_its_siblings_widens_the_shared_scan() {
11572        let dir = tempfile::tempdir().expect("temp dir");
11573        let root = root_of(&dir);
11574        let parent = root.join("parent");
11575        init_repo_with_a_commit(&parent);
11576        git(
11577            &parent,
11578            &[
11579                "remote",
11580                "add",
11581                "origin",
11582                "https://example.invalid/repo.git",
11583            ],
11584        );
11585        let deep_fork_sha = head_sha(&parent);
11586
11587        git(&parent, &["branch", "feature-deep"]);
11588        let deep_worktree = root.join("feature-deep");
11589        git(
11590            &parent,
11591            &[
11592                "worktree",
11593                "add",
11594                deep_worktree.to_str().expect("utf8 path"),
11595                "feature-deep",
11596            ],
11597        );
11598        fs::write(deep_worktree.join("deep.txt"), "deep work\n").expect("write deep.txt");
11599        git(&deep_worktree, &["add", "."]);
11600        git(&deep_worktree, &["commit", "-m", "deep work"]);
11601        let deep_tip_sha = head_sha(&deep_worktree);
11602
11603        git(&parent, &["merge", "--squash", "feature-deep"]);
11604        git(&parent, &["commit", "-m", "squashed deep"]);
11605        let shallow_fork_sha = head_sha(&parent);
11606
11607        git(&parent, &["branch", "feature-shallow"]);
11608        let shallow_worktree = root.join("feature-shallow");
11609        git(
11610            &parent,
11611            &[
11612                "worktree",
11613                "add",
11614                shallow_worktree.to_str().expect("utf8 path"),
11615                "feature-shallow",
11616            ],
11617        );
11618        fs::write(shallow_worktree.join("shallow.txt"), "shallow work\n")
11619            .expect("write shallow.txt");
11620        git(&shallow_worktree, &["add", "."]);
11621        git(&shallow_worktree, &["commit", "-m", "shallow work"]);
11622        let shallow_tip_sha = head_sha(&shallow_worktree);
11623
11624        git(&parent, &["merge", "--squash", "feature-shallow"]);
11625        git(&parent, &["commit", "-m", "squashed shallow"]);
11626        let main_tip_sha = head_sha(&parent);
11627        assert_ne!(
11628            deep_fork_sha, shallow_fork_sha,
11629            "the two siblings must fork at genuinely different commits"
11630        );
11631
11632        git(
11633            &parent,
11634            &["update-ref", "refs/remotes/origin/main", &main_tip_sha],
11635        );
11636        for (name, tip_sha) in [
11637            ("feature-deep", &deep_tip_sha),
11638            ("feature-shallow", &shallow_tip_sha),
11639        ] {
11640            git(
11641                &parent,
11642                &["config", &format!("branch.{name}.remote"), "origin"],
11643            );
11644            git(
11645                &parent,
11646                &[
11647                    "config",
11648                    &format!("branch.{name}.merge"),
11649                    &format!("refs/heads/{name}"),
11650                ],
11651            );
11652            git(
11653                &parent,
11654                &[
11655                    "update-ref",
11656                    &format!("refs/remotes/origin/{name}"),
11657                    tip_sha,
11658                ],
11659            );
11660        }
11661
11662        let (core, snapshot) = started_and_settled(spec(vec![root]));
11663        let deep_key = snapshot
11664            .entities
11665            .iter()
11666            .find(|entity| entity.key.path() == deep_worktree)
11667            .expect("feature-deep worktree discovered")
11668            .key
11669            .clone();
11670        let shallow_key = snapshot
11671            .entities
11672            .iter()
11673            .find(|entity| entity.key.path() == shallow_worktree)
11674            .expect("feature-shallow worktree discovered")
11675            .key
11676            .clone();
11677        let parent_key = snapshot
11678            .entities
11679            .iter()
11680            .find(|entity| entity.key.path() == parent)
11681            .expect("parent repo discovered")
11682            .key
11683            .clone();
11684        // The deepest sibling dispatched last, so a lazy bound computed from
11685        // whichever entity arrives first would reach for the shallow sibling's
11686        // own narrower merge base instead.
11687        let order = vec![parent_key, shallow_key.clone(), deep_key.clone()];
11688
11689        core.refresh(&order);
11690        let settled = core.settle();
11691
11692        let state_of = |key: &EntityKey| {
11693            settled
11694                .entities
11695                .iter()
11696                .find(|entity| &entity.key == key)
11697                .and_then(|entity| entity.state.settled())
11698                .cloned()
11699        };
11700        assert!(
11701            matches!(
11702                state_of(&deep_key),
11703                Some(Settled::Known {
11704                    value: WorktreeState::Merged,
11705                    at: _,
11706                    stale: _
11707                })
11708            ),
11709            "expected the deepest sibling's own squash commit to be found once the scan is \
11710             bounded by the deepest merge base, got {:?}",
11711            state_of(&deep_key)
11712        );
11713        assert!(
11714            matches!(
11715                state_of(&shallow_key),
11716                Some(Settled::Known {
11717                    value: WorktreeState::Merged,
11718                    at: _,
11719                    stale: _
11720                })
11721            ),
11722            "expected the shallow sibling to settle Merged too, got {:?}",
11723            state_of(&shallow_key)
11724        );
11725        assert_eq!(
11726            core.patch_identity_reads_for_test(),
11727            1,
11728            "both worktrees share one common dir and must still scan its default-branch \
11729             history once between them, not once per entity"
11730        );
11731        assert_eq!(
11732            core.patch_scan_bounds_for_test(),
11733            vec![Some(id(&deep_fork_sha))],
11734            "the one shared scan that ran must have been bounded by the deepest sibling's own \
11735             merge base, not the shallower one's"
11736        );
11737    }
11738
11739    fn id(sha: &str) -> gix::ObjectId {
11740        gix::ObjectId::from_hex(sha.as_bytes()).expect("parse sha")
11741    }
11742
11743    /// Criterion 1, proved at the barrier itself rather than through rayon's
11744    /// unordered dispatch: `shallow` is reported before `deep` on purpose, so a
11745    /// lazy first-arrival implementation (answer with whichever candidate
11746    /// showed up first, rather than collecting every sibling's own merge base)
11747    /// would settle on `shallow` and fail this assertion. `deep` is an ancestor
11748    /// of `shallow`, so the correct fold finds it regardless of report order.
11749    #[test]
11750    fn bound_gate_deepest_folds_every_candidate_regardless_of_report_order() {
11751        let dir = tempfile::tempdir().expect("temp dir");
11752        let repo_path = root_of(&dir).join("repo");
11753        init_repo_with_a_commit(&repo_path);
11754        let deep_sha = id(&head_sha(&repo_path));
11755        fs::write(repo_path.join("child.txt"), "child\n").expect("write child.txt");
11756        git(&repo_path, &["add", "."]);
11757        git(&repo_path, &["commit", "-m", "child of deep"]);
11758        let shallow_sha = id(&head_sha(&repo_path));
11759
11760        let repo = gix::open(&repo_path).expect("open repo");
11761        let gate = BoundGate::new(2);
11762        gate.report(Some(shallow_sha));
11763        gate.report(Some(deep_sha));
11764
11765        assert_eq!(
11766            gate.deepest(&repo),
11767            Some(deep_sha),
11768            "the deepest candidate must win even though the shallower one reported first"
11769        );
11770    }
11771
11772    /// Deterministic proof that [`probe_patch_equivalence`] itself consults
11773    /// [`BoundGate::deepest`] for the bound it hands to
11774    /// [`patch_equivalence::scan_default_branch`], rather than reaching for its
11775    /// own entity's merge base. Unlike
11776    /// `bound_gate_deepest_folds_every_candidate_regardless_of_report_order`,
11777    /// which proves `BoundGate` and `deepest_merge_base` correct in isolation,
11778    /// this drives `probe_patch_equivalence` itself and inspects what it
11779    /// actually recorded into `memo.scan_bounds`. `deep_sha`'s contribution is
11780    /// pre-reported by hand, standing in for a sibling entity that already ran
11781    /// this Generation; the one entity this test drives through the real
11782    /// function arrives at `shallow_sha`, so its own merge base against
11783    /// `default_tip` is `shallow_sha`, strictly shallower than `deep_sha`. A
11784    /// regression that bounds the scan by the arriving entity's own merge base
11785    /// instead of the gate's answer would record `shallow_sha` here, and would
11786    /// do so every single run: unlike the integration smoke test below, there
11787    /// is no rayon dispatch order here to sometimes get it right by accident.
11788    #[test]
11789    fn probe_patch_equivalence_bounds_the_scan_by_the_gates_deepest_not_its_own_merge_base() {
11790        let dir = tempfile::tempdir().expect("temp dir");
11791        let repo_path = root_of(&dir).join("repo");
11792        init_repo_with_a_commit(&repo_path);
11793        let deep_sha = id(&head_sha(&repo_path));
11794        fs::write(repo_path.join("child.txt"), "child\n").expect("write child.txt");
11795        git(&repo_path, &["add", "."]);
11796        git(&repo_path, &["commit", "-m", "child of deep"]);
11797        let shallow_sha_hex = head_sha(&repo_path);
11798        let shallow_sha = id(&shallow_sha_hex);
11799        fs::write(repo_path.join("tip.txt"), "tip\n").expect("write tip.txt");
11800        git(&repo_path, &["add", "."]);
11801        git(&repo_path, &["commit", "-m", "default tip"]);
11802        let default_tip_hex = head_sha(&repo_path);
11803
11804        let repo = gix::open(&repo_path).expect("open repo");
11805        // What `landing::probe` hands over for a Worktree entity sitting at
11806        // `shallow`, whose own tip is not main's actual tip.
11807        let outstanding = landing::Outstanding {
11808            entity_tip: shallow_sha,
11809            default_tip: id(&default_tip_hex),
11810            merge_base: Some(shallow_sha),
11811        };
11812        let common_dir: Arc<Path> = Arc::from(repo_path.join(".git"));
11813        let cancel = AtomicBool::new(false);
11814        let patch_cache: PatchIdentityCache = Mutex::new(HashMap::new());
11815        let patch_reads = AtomicUsize::new(0);
11816        let patch_scan_bounds: Mutex<Vec<Option<gix::ObjectId>>> = Mutex::new(Vec::new());
11817        let memo = PatchEquivalenceMemo {
11818            cache: &patch_cache,
11819            reads: &patch_reads,
11820            scan_bounds: &patch_scan_bounds,
11821        };
11822        // Two entities share this common dir this Generation: `deep_sha` stands
11823        // in for a sibling that already reported its own, deeper merge base;
11824        // `shallow` is the one entity driven through the real function below.
11825        let gate = BoundGate::new(2);
11826        gate.report(Some(deep_sha));
11827        let mut report = GateReport::new(&gate);
11828
11829        probe_patch_equivalence(
11830            &repo,
11831            &outstanding,
11832            &common_dir,
11833            &cancel,
11834            &memo,
11835            &mut report,
11836        );
11837
11838        assert_eq!(
11839            patch_scan_bounds.lock().unwrap().as_slice(),
11840            [Some(deep_sha)],
11841            "the scan must be bounded by the deepest sibling's merge base, not shallow's own \
11842             ({shallow_sha:?})"
11843        );
11844    }
11845
11846    /// The carry itself: [`probe_patch_equivalence`] diffs the entity's own
11847    /// range from the merge base `landing::probe` handed over, rather than
11848    /// walking the same commit pair a second time. `mid_sha` is a real commit
11849    /// on `feature` but not its fork point, so the two answers differ: from the
11850    /// fork point the range is the whole squashed change and settles `Merged`,
11851    /// from `mid_sha` it is only `b.txt` and settles `Active`. A regression that
11852    /// recomputed the base here would answer `Merged` and fail this test.
11853    #[test]
11854    fn probe_patch_equivalence_diffs_from_the_merge_base_it_was_handed() {
11855        let dir = tempfile::tempdir().expect("temp dir");
11856        let repo_path = root_of(&dir).join("repo");
11857        init_repo_with_a_commit(&repo_path);
11858        let fork_point_hex = head_sha(&repo_path);
11859        git(&repo_path, &["checkout", "-b", "feature"]);
11860        fs::write(repo_path.join("a.txt"), "one\n").expect("write a.txt");
11861        git(&repo_path, &["add", "a.txt"]);
11862        git(&repo_path, &["commit", "-m", "add a"]);
11863        let mid_sha = id(&head_sha(&repo_path));
11864        fs::write(repo_path.join("b.txt"), "two\n").expect("write b.txt");
11865        git(&repo_path, &["add", "b.txt"]);
11866        git(&repo_path, &["commit", "-m", "add b"]);
11867        let feature_sha = id(&head_sha(&repo_path));
11868        git(&repo_path, &["checkout", "-B", "main", &fork_point_hex]);
11869        git(&repo_path, &["merge", "--squash", "feature"]);
11870        git(&repo_path, &["commit", "-m", "squashed feature"]);
11871        let main_sha = id(&head_sha(&repo_path));
11872
11873        let repo = gix::open(&repo_path).expect("open repo");
11874        // What `landing::probe` hands over, with a base halfway along the
11875        // branch standing in for one only this pass could know.
11876        let outstanding = landing::Outstanding {
11877            entity_tip: feature_sha,
11878            default_tip: main_sha,
11879            merge_base: Some(mid_sha),
11880        };
11881        let common_dir: Arc<Path> = Arc::from(repo_path.join(".git"));
11882        let cancel = AtomicBool::new(false);
11883        let patch_cache: PatchIdentityCache = Mutex::new(HashMap::new());
11884        let patch_reads = AtomicUsize::new(0);
11885        let patch_scan_bounds: Mutex<Vec<Option<gix::ObjectId>>> = Mutex::new(Vec::new());
11886        let memo = PatchEquivalenceMemo {
11887            cache: &patch_cache,
11888            reads: &patch_reads,
11889            scan_bounds: &patch_scan_bounds,
11890        };
11891        let gate = BoundGate::new(1);
11892        let mut report = GateReport::new(&gate);
11893
11894        let settled = probe_patch_equivalence(
11895            &repo,
11896            &outstanding,
11897            &common_dir,
11898            &cancel,
11899            &memo,
11900            &mut report,
11901        );
11902
11903        assert!(
11904            matches!(
11905                settled,
11906                Some(Settled::Known {
11907                    value: WorktreeState::Active,
11908                    at: _,
11909                    stale: _
11910                })
11911            ),
11912            "the range must be measured from the handed-in base ({mid_sha:?}), whose only \
11913             change the squash commit does not match, got {settled:?}"
11914        );
11915    }
11916
11917    /// The edge [`deepest_merge_base`] exists for: no entity sharing a common
11918    /// dir ever had a merge base to offer (every one settled by ancestry, was
11919    /// cancelled, or shared no history with the default branch at all), so the
11920    /// scan is left unbounded. `deepest_merge_base` returns before its first
11921    /// candidate lookup here, which is what lets this fixture skip building any
11922    /// commit history at all.
11923    #[test]
11924    fn bound_gate_deepest_with_no_candidates_leaves_the_scan_unbounded() {
11925        let dir = tempfile::tempdir().expect("temp dir");
11926        let repo_path = root_of(&dir).join("repo");
11927        gix::init(&repo_path).expect("init repo");
11928        let repo = gix::open(&repo_path).expect("open repo");
11929
11930        let gate = BoundGate::new(2);
11931        gate.report(None);
11932        gate.report(None);
11933
11934        assert_eq!(
11935            gate.deepest(&repo),
11936            None,
11937            "no contributed candidate must leave the scan unbounded"
11938        );
11939    }
11940
11941    /// `probe_patch_equivalence`'s `Ok(None)` arm bypasses the shared scan for
11942    /// an Outstanding entity with no shared history at all. `unrelated` is a
11943    /// real branch, with a live upstream so `landing::probe`
11944    /// leaves it `Outstanding`, whose own root commit shares no history with
11945    /// `main`'s, driven through `Core` end to end rather than by calling
11946    /// `probe_patch_equivalence` or `patch_equivalence::probe` directly, so a
11947    /// removed bypass (the shared scan run unconditionally instead) is
11948    /// exercised for real: `BoundGate::deepest` would then block forever on a
11949    /// scan this entity never asked for.
11950    #[test]
11951    fn an_outstanding_entity_with_no_shared_history_settles_active_without_the_shared_scan() {
11952        let dir = tempfile::tempdir().expect("temp dir");
11953        let root = root_of(&dir);
11954        let parent = root.join("parent");
11955        init_repo_with_a_commit(&parent);
11956        git(&parent, &["branch", "-M", "main"]);
11957        git(
11958            &parent,
11959            &[
11960                "remote",
11961                "add",
11962                "origin",
11963                "https://example.invalid/repo.git",
11964            ],
11965        );
11966        let main_sha = head_sha(&parent);
11967        git(
11968            &parent,
11969            &["update-ref", "refs/remotes/origin/main", &main_sha],
11970        );
11971
11972        git(&parent, &["checkout", "--orphan", "unrelated"]);
11973        git(
11974            &parent,
11975            &["commit", "--allow-empty", "-m", "unrelated root"],
11976        );
11977        let unrelated_sha = head_sha(&parent);
11978        git(&parent, &["checkout", "main"]);
11979
11980        let worktree = root.join("unrelated");
11981        git(
11982            &parent,
11983            &[
11984                "worktree",
11985                "add",
11986                worktree.to_str().expect("utf8 path"),
11987                "unrelated",
11988            ],
11989        );
11990        git(&parent, &["config", "branch.unrelated.remote", "origin"]);
11991        git(
11992            &parent,
11993            &["config", "branch.unrelated.merge", "refs/heads/unrelated"],
11994        );
11995        git(
11996            &parent,
11997            &[
11998                "update-ref",
11999                "refs/remotes/origin/unrelated",
12000                &unrelated_sha,
12001            ],
12002        );
12003
12004        let (core, snapshot) = started_and_settled(spec(vec![root]));
12005        let worktree_key = snapshot
12006            .entities
12007            .iter()
12008            .find(|entity| entity.key.path() == worktree)
12009            .expect("unrelated worktree discovered")
12010            .key
12011            .clone();
12012
12013        core.refresh(std::slice::from_ref(&worktree_key));
12014        let settled = core.settle();
12015
12016        let state = settled
12017            .entities
12018            .iter()
12019            .find(|entity| entity.key == worktree_key)
12020            .and_then(|entity| entity.state.settled())
12021            .cloned();
12022        assert!(
12023            matches!(
12024                state,
12025                Some(Settled::Known {
12026                    value: WorktreeState::Active,
12027                    at: _,
12028                    stale: _
12029                })
12030            ),
12031            "expected an Outstanding entity with no shared history to settle Active via the \
12032             bypass, got {state:?}"
12033        );
12034        assert_eq!(
12035            core.patch_identity_reads_for_test(),
12036            0,
12037            "the bypass must settle without ever running the shared scan"
12038        );
12039    }
12040
12041    // --- Phase B's comparison: the `sync` cell, end to end through a real `Core`:
12042    // the six named cases, plus the two ways "every entity, every Generation" is
12043    // most easily lost. ---
12044
12045    fn add_origin_remote(path: &Path) {
12046        git(
12047            path,
12048            &[
12049                "remote",
12050                "add",
12051                "origin",
12052                "https://example.invalid/repo.git",
12053            ],
12054        );
12055    }
12056
12057    /// Wires `branch` up to track `refs/remotes/origin/<branch>` at `upstream_sha`,
12058    /// mirroring `patch_equivalence_is_memoised_once_per_common_dir_per_generation`'s
12059    /// own fixture shape against a real disposable repo.
12060    fn set_upstream(path: &Path, branch: &str, upstream_sha: &str) {
12061        git(
12062            path,
12063            &["config", &format!("branch.{branch}.remote"), "origin"],
12064        );
12065        git(
12066            path,
12067            &[
12068                "config",
12069                &format!("branch.{branch}.merge"),
12070                &format!("refs/heads/{branch}"),
12071            ],
12072        );
12073        git(
12074            path,
12075            &[
12076                "update-ref",
12077                &format!("refs/remotes/origin/{branch}"),
12078                upstream_sha,
12079            ],
12080        );
12081    }
12082
12083    fn refresh_and_settle(core: &Core) -> crate::snapshot::Snapshot {
12084        let keys: Vec<EntityKey> = core
12085            .snapshot()
12086            .entities
12087            .iter()
12088            .map(|entity| entity.key.clone())
12089            .collect();
12090        core.refresh(&keys);
12091        core.settle()
12092    }
12093
12094    fn sync_of<'a>(
12095        snapshot: &'a crate::snapshot::Snapshot,
12096        path: &Path,
12097    ) -> Option<&'a Settled<SyncState>> {
12098        snapshot
12099            .entities
12100            .iter()
12101            .find(|entity| entity.key.path() == path)
12102            .unwrap_or_else(|| panic!("no entity for {}", path.display()))
12103            .sync
12104            .settled()
12105    }
12106
12107    /// Named case 1 of 6: an attached branch ahead of its upstream.
12108    #[test]
12109    fn an_attached_branch_ahead_of_its_upstream_reads_the_ahead_count() {
12110        let dir = tempfile::tempdir().expect("temp dir");
12111        let root = root_of(&dir);
12112        let repo = root.join("repo");
12113        init_repo_with_a_commit(&repo);
12114        let fork_sha = head_sha(&repo);
12115        add_origin_remote(&repo);
12116        set_upstream(&repo, "main", &fork_sha);
12117        git(&repo, &["commit", "--allow-empty", "-m", "local work"]);
12118
12119        let core = Core::start_discovered(spec(vec![root]));
12120        let settled = refresh_and_settle(&core);
12121
12122        match sync_of(&settled, &repo) {
12123            Some(Settled::Known {
12124                value: SyncState::Tracking(AheadBehind { ahead, behind }),
12125                at: _,
12126                stale: _,
12127            }) => {
12128                assert_eq!(*ahead, 1);
12129                assert_eq!(*behind, 0);
12130            }
12131            other => panic!("expected 1 ahead, 0 behind, got {other:?}"),
12132        }
12133    }
12134
12135    /// Named case 2 of 6: an attached branch behind its upstream.
12136    #[test]
12137    fn an_attached_branch_behind_its_upstream_reads_the_behind_count() {
12138        let dir = tempfile::tempdir().expect("temp dir");
12139        let root = root_of(&dir);
12140        let repo = root.join("repo");
12141        init_repo_with_a_commit(&repo);
12142        git(&repo, &["checkout", "-b", "temp"]);
12143        git(&repo, &["commit", "--allow-empty", "-m", "upstream work"]);
12144        let upstream_sha = head_sha(&repo);
12145        git(&repo, &["checkout", "main"]);
12146        git(&repo, &["branch", "-D", "temp"]);
12147        add_origin_remote(&repo);
12148        set_upstream(&repo, "main", &upstream_sha);
12149
12150        let core = Core::start_discovered(spec(vec![root]));
12151        let settled = refresh_and_settle(&core);
12152
12153        match sync_of(&settled, &repo) {
12154            Some(Settled::Known {
12155                value: SyncState::Tracking(AheadBehind { ahead, behind }),
12156                at: _,
12157                stale: _,
12158            }) => {
12159                assert_eq!(*ahead, 0);
12160                assert_eq!(*behind, 1);
12161            }
12162            other => panic!("expected 0 ahead, 1 behind, got {other:?}"),
12163        }
12164    }
12165
12166    /// Named case 3 of 6: an attached branch level with its upstream.
12167    #[test]
12168    fn an_attached_branch_level_with_its_upstream_reads_in_sync() {
12169        let dir = tempfile::tempdir().expect("temp dir");
12170        let root = root_of(&dir);
12171        let repo = root.join("repo");
12172        init_repo_with_a_commit(&repo);
12173        let sha = head_sha(&repo);
12174        add_origin_remote(&repo);
12175        set_upstream(&repo, "main", &sha);
12176
12177        let core = Core::start_discovered(spec(vec![root]));
12178        let settled = refresh_and_settle(&core);
12179
12180        match sync_of(&settled, &repo) {
12181            Some(Settled::Known {
12182                value:
12183                    SyncState::Tracking(AheadBehind {
12184                        ahead: 0,
12185                        behind: 0,
12186                    }),
12187                at: _,
12188                stale: _,
12189            }) => {}
12190            other => panic!("expected level with its upstream, got {other:?}"),
12191        }
12192    }
12193
12194    /// Named case 4 of 6: an attached branch tracking nothing, on a Repo that does
12195    /// have a remote. Distinguishes this from case 6 below: the absence here is the
12196    /// branch's own tracking configuration, not the Repo's remote.
12197    #[test]
12198    fn an_attached_branch_tracking_nothing_reads_no_upstream() {
12199        let dir = tempfile::tempdir().expect("temp dir");
12200        let root = root_of(&dir);
12201        let repo = root.join("repo");
12202        init_repo_with_a_commit(&repo);
12203        add_origin_remote(&repo);
12204
12205        let core = Core::start_discovered(spec(vec![root]));
12206        let settled = refresh_and_settle(&core);
12207
12208        match sync_of(&settled, &repo) {
12209            Some(Settled::Known {
12210                value: SyncState::NoUpstream,
12211                at: _,
12212                stale: _,
12213            }) => {}
12214            other => panic!("expected no upstream configured, got {other:?}"),
12215        }
12216    }
12217
12218    /// Named case 5 of 6: a detached row, on a Repo that does have a remote.
12219    /// Distinguishes this from case 6 below the same way case 4 does.
12220    #[test]
12221    fn a_detached_row_reads_no_upstream() {
12222        let dir = tempfile::tempdir().expect("temp dir");
12223        let root = root_of(&dir);
12224        let repo = root.join("repo");
12225        init_repo_with_a_commit(&repo);
12226        let first_sha = head_sha(&repo);
12227        git(&repo, &["commit", "--allow-empty", "-m", "second"]);
12228        git(&repo, &["checkout", "--detach", &first_sha]);
12229        add_origin_remote(&repo);
12230
12231        let core = Core::start_discovered(spec(vec![root]));
12232        let settled = refresh_and_settle(&core);
12233
12234        match sync_of(&settled, &repo) {
12235            Some(Settled::Known {
12236                value: SyncState::NoUpstream,
12237                at: _,
12238                stale: _,
12239            }) => {}
12240            other => panic!("expected a detached row to read no upstream, got {other:?}"),
12241        }
12242    }
12243
12244    /// Named case 6 of 6: a Repo with no remote at all. The propagation half of
12245    /// criterion 3 is the substance here, not the Repo row alone: a linked Worktree
12246    /// shares the parent's config and has no upstream of its own to speak of either,
12247    /// so it must read the exact same `NoRemote` value, not `NoUpstream`.
12248    #[test]
12249    fn a_repo_with_no_remote_reads_no_remote_on_itself_and_every_worktree() {
12250        let dir = tempfile::tempdir().expect("temp dir");
12251        let root = root_of(&dir);
12252        let parent = root.join("parent");
12253        init_repo_with_a_commit(&parent);
12254        let worktree = root.join("feature");
12255        git(
12256            &parent,
12257            &[
12258                "worktree",
12259                "add",
12260                "-b",
12261                "feature",
12262                worktree.to_str().expect("utf8 path"),
12263            ],
12264        );
12265
12266        let core = Core::start_discovered(spec(vec![root]));
12267        let settled = refresh_and_settle(&core);
12268
12269        assert_eq!(
12270            settled.entities.len(),
12271            2,
12272            "expected the parent Repo and its one linked Worktree"
12273        );
12274        for path in [&parent, &worktree] {
12275            match sync_of(&settled, path) {
12276                Some(Settled::Known {
12277                    value: SyncState::NoRemote,
12278                    at: _,
12279                    stale: _,
12280                }) => {}
12281                other => panic!(
12282                    "expected {} to read no remote at all, got {other:?}",
12283                    path.display()
12284                ),
12285            }
12286        }
12287    }
12288
12289    /// Criterion 1's "every entity" half: two sibling Worktrees under one Repo, each
12290    /// with a different sync outcome, computed together in one Generation. A test
12291    /// driving only one of them could not see an implementation that dispatches the
12292    /// comparison for a single hand-picked entity rather than every one whose HEAD
12293    /// carries a branch.
12294    #[test]
12295    fn sync_is_computed_for_every_entity_dispatched_this_generation_not_only_one() {
12296        let dir = tempfile::tempdir().expect("temp dir");
12297        let root = root_of(&dir);
12298        let parent = root.join("parent");
12299        init_repo_with_a_commit(&parent);
12300        let fork_sha = head_sha(&parent);
12301        add_origin_remote(&parent);
12302
12303        let ahead_worktree = root.join("feature-ahead");
12304        git(
12305            &parent,
12306            &[
12307                "worktree",
12308                "add",
12309                "-b",
12310                "feature-ahead",
12311                ahead_worktree.to_str().expect("utf8 path"),
12312            ],
12313        );
12314        set_upstream(&parent, "feature-ahead", &fork_sha);
12315        git(
12316            &ahead_worktree,
12317            &["commit", "--allow-empty", "-m", "unpushed"],
12318        );
12319
12320        let behind_worktree = root.join("feature-behind");
12321        git(
12322            &parent,
12323            &[
12324                "worktree",
12325                "add",
12326                "-b",
12327                "feature-behind",
12328                behind_worktree.to_str().expect("utf8 path"),
12329            ],
12330        );
12331        git(
12332            &behind_worktree,
12333            &["commit", "--allow-empty", "-m", "on the remote only"],
12334        );
12335        let ahead_of_behind_sha = head_sha(&behind_worktree);
12336        git(&behind_worktree, &["reset", "--hard", "HEAD~1"]);
12337        set_upstream(&parent, "feature-behind", &ahead_of_behind_sha);
12338
12339        let core = Core::start_discovered(spec(vec![root]));
12340        let settled = refresh_and_settle(&core);
12341
12342        match sync_of(&settled, &ahead_worktree) {
12343            Some(Settled::Known {
12344                value:
12345                    SyncState::Tracking(AheadBehind {
12346                        ahead: 1,
12347                        behind: 0,
12348                    }),
12349                at: _,
12350                stale: _,
12351            }) => {}
12352            other => panic!("expected feature-ahead to read 1 ahead, got {other:?}"),
12353        }
12354        match sync_of(&settled, &behind_worktree) {
12355            Some(Settled::Known {
12356                value:
12357                    SyncState::Tracking(AheadBehind {
12358                        ahead: 0,
12359                        behind: 1,
12360                    }),
12361                at: _,
12362                stale: _,
12363            }) => {}
12364            other => panic!("expected feature-behind to read 1 behind, got {other:?}"),
12365        }
12366    }
12367
12368    /// Criterion 1's "every Generation" half: a second, later refresh recomputes
12369    /// `sync` rather than a first Generation's answer sticking around unrefreshed.
12370    /// A test that only ever drives one Generation cannot see an implementation
12371    /// that dispatches the comparison once, at `Core::start`'s own discovery, and
12372    /// never again on an explicit `refresh`.
12373    #[test]
12374    fn sync_recomputes_on_a_second_generation_not_only_the_first() {
12375        let dir = tempfile::tempdir().expect("temp dir");
12376        let root = root_of(&dir);
12377        let repo = root.join("repo");
12378        init_repo_with_a_commit(&repo);
12379        let fork_sha = head_sha(&repo);
12380        add_origin_remote(&repo);
12381        set_upstream(&repo, "main", &fork_sha);
12382
12383        let core = Core::start_discovered(spec(vec![root]));
12384        let first = refresh_and_settle(&core);
12385        match sync_of(&first, &repo) {
12386            Some(Settled::Known {
12387                value:
12388                    SyncState::Tracking(AheadBehind {
12389                        ahead: 0,
12390                        behind: 0,
12391                    }),
12392                at: _,
12393                stale: _,
12394            }) => {}
12395            other => panic!("expected the first Generation level with its upstream, got {other:?}"),
12396        }
12397
12398        git(
12399            &repo,
12400            &[
12401                "commit",
12402                "--allow-empty",
12403                "-m",
12404                "second Generation's own work",
12405            ],
12406        );
12407        let second = refresh_and_settle(&core);
12408        match sync_of(&second, &repo) {
12409            Some(Settled::Known {
12410                value:
12411                    SyncState::Tracking(AheadBehind {
12412                        ahead: 1,
12413                        behind: 0,
12414                    }),
12415                at: _,
12416                stale: _,
12417            }) => {}
12418            other => panic!(
12419                "expected the second Generation to recompute and read 1 ahead, got {other:?}"
12420            ),
12421        }
12422    }
12423
12424    /// The Worktree-reporting criterion: after a default branch moves, the Worktrees
12425    /// now behind it are reported by name. `base` (the same "behind the default branch"
12426    /// count [`base.rs`] computes and every row's own `name` already carries) is what
12427    /// "reported by name" means in practice: a snapshot reader finds each Worktree by
12428    /// the name on its row, not by position, so this test does the same, matching each
12429    /// assertion to its own fixture's name rather than to "the first" or "the last"
12430    /// entity.
12431    ///
12432    /// `wt-behind` is branched from the default branch's tip before it moves and is left
12433    /// untouched, the same shape a fetch leaves an existing linked Worktree in; `wt-
12434    /// caught-up` is branched from the tip *after* it moves, so it is unaffected. Two
12435    /// Worktrees are required, not one: a test with only `wt-behind` would still pass
12436    /// against an implementation that reports every Worktree as behind regardless of
12437    /// whether it actually is, and a test that asserted only "something is reported"
12438    /// would pass even if the names or the counts were swapped.
12439    #[test]
12440    fn worktrees_now_behind_a_moved_default_branch_are_reported_by_name() {
12441        let dir = tempfile::tempdir().expect("temp dir");
12442        let root = root_of(&dir);
12443        let repo = root.join("repo");
12444        init_repo_with_a_commit(&repo);
12445        let sha_a = head_sha(&repo);
12446        add_origin_remote(&repo);
12447        set_upstream(&repo, "main", &sha_a);
12448
12449        let behind_path = root.join("wt-behind");
12450        git(
12451            &repo,
12452            &[
12453                "worktree",
12454                "add",
12455                "-b",
12456                "topic-behind",
12457                behind_path.to_str().expect("utf8 path"),
12458                "main",
12459            ],
12460        );
12461
12462        // Moves only the default branch's own remote-tracking ref, the same shape a
12463        // fetch leaves behind: `repo`'s own checked-out `main` does not move, so this
12464        // is deliberately not exercising the auto-update itself, only what a moved
12465        // default branch does to every Worktree's own `base` count.
12466        git(&repo, &["checkout", "-b", "scratch"]);
12467        git(&repo, &["commit", "--allow-empty", "-m", "second"]);
12468        let sha_b = head_sha(&repo);
12469        git(&repo, &["checkout", "main"]);
12470        git(&repo, &["update-ref", "refs/remotes/origin/main", &sha_b]);
12471        git(&repo, &["branch", "-D", "scratch"]);
12472
12473        // Branched from the plain commit sha, not `refs/remotes/origin/main` itself:
12474        // starting a new branch from a remote-tracking ref makes git auto-configure it
12475        // to track that same ref, which would make this row the default branch's own
12476        // row (`base.rs`'s `branch_is_default_branchs_own_row`) and settle `base` as
12477        // `NotApplicable` rather than the `0` this fixture means to prove.
12478        let caught_up_path = root.join("wt-caught-up");
12479        git(
12480            &repo,
12481            &[
12482                "worktree",
12483                "add",
12484                "-b",
12485                "topic-caught-up",
12486                caught_up_path.to_str().expect("utf8 path"),
12487                &sha_b,
12488            ],
12489        );
12490
12491        let core = Core::start_discovered(spec(vec![root]));
12492        let snapshot = refresh_and_settle(&core);
12493
12494        let base_of = |name: &str| -> u32 {
12495            let entity = snapshot
12496                .entities
12497                .iter()
12498                .find(|entity| &*entity.name == name)
12499                .unwrap_or_else(|| panic!("no entity named {name} in {snapshot:?}"));
12500            match entity.base.settled() {
12501                Some(Settled::Known {
12502                    value,
12503                    at: _,
12504                    stale: _,
12505                }) => *value,
12506                other => panic!("expected a known base count for {name}, got {other:?}"),
12507            }
12508        };
12509
12510        assert!(
12511            base_of("wt-behind") > 0,
12512            "a Worktree branched before the default branch moved must be reported behind"
12513        );
12514        assert_eq!(
12515            base_of("wt-caught-up"),
12516            0,
12517            "a Worktree branched from the new tip must not be reported behind"
12518        );
12519    }
12520
12521    /// The periodic fetch's own scheduler: criterion 3's five rules
12522    /// ([refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
12523    /// "The periodic fetch"). Every fixture here is a bare repo this test creates plus a
12524    /// real `git clone` of it, per the standing constraint that a fetch test never
12525    /// touches a real remote or the network.
12526    mod fetch_scheduler {
12527        use super::*;
12528        use crate::liveness::wait_for_or;
12529
12530        fn fetch_spec(enabled: bool, root: PathBuf) -> CoreSpec {
12531            let mut spec = spec(vec![root]);
12532            spec.fetch = FetchSpec {
12533                enabled,
12534                interval: Duration::from_secs(3600),
12535                concurrency: 4,
12536            };
12537            spec
12538        }
12539
12540        /// A bare "remote" this call creates and seeds with one commit, never a real
12541        /// remote and never touched over the network.
12542        fn seeded_remote() -> tempfile::TempDir {
12543            let remote = tempfile::tempdir().expect("temp dir");
12544            crate::test_support::init_bare(remote.path());
12545            crate::test_support::push_new_commit(remote.path(), "README.md", "seed\n");
12546            remote
12547        }
12548
12549        fn clone_into(remote: &Path, dest: &Path) {
12550            let status = Command::new("git")
12551                .arg("clone")
12552                .arg(remote)
12553                .arg(dest)
12554                .status()
12555                .expect("run git clone");
12556            assert!(status.success());
12557            crate::test_support::set_identity(dest);
12558        }
12559
12560        /// The scheduler's first rule: enabling the periodic fetch runs one cycle
12561        /// immediately rather than waiting for `fetch.interval` to elapse. `fetch_ticks`
12562        /// is `crossbeam_channel::never()`, so the only way `fetch_cycle_count_for_test`
12563        /// can ever move is the immediate cycle `start_internal` dispatches on its own
12564        /// plain thread; a scheduler that only reacted to a tick would leave this at
12565        /// zero forever.
12566        #[test]
12567        fn enabling_the_periodic_fetch_runs_one_cycle_before_any_tick_arrives() {
12568            let remote = seeded_remote();
12569            let root = tempfile::tempdir().expect("temp dir");
12570            let root_path = root_of(&root);
12571            clone_into(remote.path(), &root_path.join("parent"));
12572
12573            let fetch_ticks: Receiver<Instant> = crossbeam_channel::never();
12574            let started = Core::start_for_test_with_fetch(
12575                fetch_spec(true, root_path),
12576                Duration::from_secs(3600),
12577                crossbeam_channel::never(),
12578                fetch_ticks,
12579            )
12580            .discovered();
12581            let core = started.core;
12582
12583            wait_for(
12584                "the periodic fetch to run its first cycle without waiting for a tick",
12585                || core.fetch_cycle_count_for_test() >= 1,
12586            );
12587        }
12588
12589        /// A tick on the periodic fetch's own channel runs a second cycle, proving the
12590        /// recurring cadence is wired to the same dedicated thread the immediate cycle
12591        /// used, not merely a one-shot dispatched at start.
12592        ///
12593        /// The tick is sent only once the immediate cycle has been taken back, since a tick
12594        /// arriving while a cycle is live is refused rather than queued.
12595        #[test]
12596        fn a_tick_on_the_fetch_channel_runs_another_cycle() {
12597            let remote = seeded_remote();
12598            let root = tempfile::tempdir().expect("temp dir");
12599            let root_path = root_of(&root);
12600            clone_into(remote.path(), &root_path.join("parent"));
12601
12602            let (fetch_tick_tx, fetch_tick_rx) = crossbeam_channel::unbounded();
12603            let started = Core::start_for_test_with_fetch(
12604                fetch_spec(true, root_path),
12605                Duration::from_secs(3600),
12606                crossbeam_channel::never(),
12607                fetch_tick_rx,
12608            )
12609            .discovered();
12610            let core = started.core;
12611
12612            wait_for(
12613                "the immediate cycle to have run and been taken back first",
12614                || started.fetch_cycles_taken_back.load(Ordering::Acquire) >= 1,
12615            );
12616
12617            fetch_tick_tx
12618                .send(Instant::now())
12619                .expect("send a fetch tick");
12620
12621            wait_for("a tick on the fetch channel to run a second cycle", || {
12622                core.fetch_cycle_count_for_test() >= 2
12623            });
12624        }
12625
12626        /// The clock is a coordinator, never a fetch's own caller: a cycle held at
12627        /// [`FetchBoundary`] must leave the Generation deadline sweep on the same thread free
12628        /// to settle a probe that has run out of time. `fetch.enabled` is false and the cycle
12629        /// under test comes from a tick alone, so the only fetch in flight is the one this
12630        /// test is holding.
12631        #[test]
12632        fn a_deadline_tick_still_times_out_a_pending_probe_while_a_fetch_is_held() {
12633            let remote = seeded_remote();
12634            let root = tempfile::tempdir().expect("temp dir");
12635            let root_path = root_of(&root);
12636            clone_into(remote.path(), &root_path.join("parent"));
12637
12638            let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
12639            let (fetch_tick_tx, fetch_tick_rx) = crossbeam_channel::unbounded::<Instant>();
12640            let mut spec = fetch_spec(false, root_path);
12641            spec.generation_deadline = Duration::ZERO;
12642            let started = Core::start_for_test_with_fetch(
12643                spec,
12644                Duration::from_secs(3600),
12645                tick_rx,
12646                fetch_tick_rx,
12647            )
12648            .discovered();
12649            let core = started.core;
12650            let key = core.settle().entities[0].key.clone();
12651
12652            let held = core.fetch_boundary().arm();
12653            fetch_tick_tx
12654                .send(Instant::now())
12655                .expect("send a fetch tick");
12656            held.wait_until_reached();
12657
12658            core.begin_untracked_probe_for_test(&key);
12659            tick_tx.send(Instant::now()).expect("send one tick");
12660            let after = core.settle();
12661
12662            assert!(
12663                matches!(
12664                    after.entities[0].branch.settled(),
12665                    Some(Settled::Unknown(Unknown::TimedOut))
12666                ),
12667                "the deadline sweep must still run while a fetch is held, got: {:?}",
12668                after.entities[0].branch.settled()
12669            );
12670        }
12671
12672        /// Pause is the lifecycle owner ending the live cycle where it stands, not only
12673        /// stopping the next one: the cancellation reaches a fetch that is provably still
12674        /// running, no further repository is fetched, the mutating half of that cycle never
12675        /// runs, and the Generation a finished cycle owes is never dispatched once the held
12676        /// fetch is let go. Both fences have something to hold: `parent` is left genuinely
12677        /// eligible (clean, behind, tracking an upstream) by a fetch this test performs
12678        /// itself, and `stale` is left a commit behind its remote, so a cycle that carried on
12679        /// would move each of them.
12680        #[test]
12681        fn pause_cancels_a_held_cycle_so_it_neither_auto_updates_nor_dispatches_its_generation() {
12682            let remote = seeded_remote();
12683            let root = tempfile::tempdir().expect("temp dir");
12684            let root_path = root_of(&root);
12685            let parent = root_path.join("parent");
12686            let stale = root_path.join("stale");
12687            clone_into(remote.path(), &parent);
12688            clone_into(remote.path(), &stale);
12689            crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
12690            git(&parent, &["fetch", "origin"]);
12691            let before_tip = rev_parse(&parent, "refs/heads/main");
12692            let stale_before = rev_parse(&stale, "refs/remotes/origin/main");
12693
12694            let (fetch_tick_tx, fetch_tick_rx) = crossbeam_channel::unbounded();
12695            let started = Core::start_for_test_with_fetch(
12696                spec_with_auto_update(false, true, root_path),
12697                Duration::from_secs(3600),
12698                crossbeam_channel::never(),
12699                fetch_tick_rx,
12700            )
12701            .discovered();
12702            let core = started.core;
12703            let before = core.settle().generation;
12704
12705            let held = core.fetch_boundary().arm();
12706            fetch_tick_tx
12707                .send(Instant::now())
12708                .expect("send a fetch tick");
12709            held.wait_until_reached();
12710
12711            core.pause();
12712            held.wait_until_cancelled();
12713            drop(held);
12714            wait_for("the cancelled cycle to be taken back by the clock", || {
12715                started.fetch_cycles_taken_back.load(Ordering::Acquire) >= 1
12716            });
12717
12718            assert_eq!(
12719                rev_parse(&parent, "refs/heads/main"),
12720                before_tip,
12721                "a cancelled cycle must not fast-forward a Repo its auto-update would \
12722                 otherwise have moved"
12723            );
12724            assert_eq!(
12725                rev_parse(&stale, "refs/remotes/origin/main"),
12726                stale_before,
12727                "a cancelled cycle must land no fetch beyond the one it was holding"
12728            );
12729            assert_eq!(
12730                core.snapshot().generation,
12731                before,
12732                "releasing a cancelled fetch must not dispatch the completion Generation \
12733                 its cycle would otherwise have owed"
12734            );
12735        }
12736
12737        /// A tick arriving while a cycle is live is refused, not queued and not run beside
12738        /// it: two cycles over the same population would fetch and fast-forward the same
12739        /// repositories at once. The clock takes both further ticks off the channel while the
12740        /// first cycle is provably still held, which is what makes the refusal the reading
12741        /// here rather than a scheduling delay.
12742        #[test]
12743        fn a_fetch_tick_taken_while_a_cycle_is_live_starts_no_second_cycle() {
12744            let remote = seeded_remote();
12745            let root = tempfile::tempdir().expect("temp dir");
12746            let root_path = root_of(&root);
12747            clone_into(remote.path(), &root_path.join("parent"));
12748
12749            let (fetch_tick_tx, fetch_tick_rx) = crossbeam_channel::unbounded();
12750            // A second handle on the same queue, read but never received from: the clock
12751            // emptying it is what says both further ticks have been taken.
12752            let pending_ticks = fetch_tick_rx.clone();
12753            let started = Core::start_for_test_with_fetch(
12754                fetch_spec(false, root_path),
12755                Duration::from_secs(3600),
12756                crossbeam_channel::never(),
12757                fetch_tick_rx,
12758            )
12759            .discovered();
12760            let core = started.core;
12761
12762            let held = core.fetch_boundary().arm();
12763            fetch_tick_tx
12764                .send(Instant::now())
12765                .expect("send the tick that starts the cycle");
12766            held.wait_until_reached();
12767
12768            for _ in 0..2 {
12769                fetch_tick_tx
12770                    .send(Instant::now())
12771                    .expect("send a tick while the cycle is live");
12772            }
12773            wait_for("the clock to take both further ticks", || {
12774                pending_ticks.is_empty()
12775            });
12776
12777            drop(held);
12778            wait_for("the released cycle to be taken back by the clock", || {
12779                started.fetch_cycles_taken_back.load(Ordering::Acquire) >= 1
12780            });
12781
12782            assert_eq!(
12783                core.fetch_cycle_count_for_test(),
12784                1,
12785                "two ticks taken while a cycle was held must have started no cycle of their \
12786                 own"
12787            );
12788        }
12789
12790        /// [`FetchFailures`] is the most recently *completed* cycle's own count
12791        /// (GLOSSARY.md), so a cancelled one never replaces it: what that cycle reached
12792        /// before it was ended is not a count of what could not be fetched. The immediate
12793        /// cycle here completes and counts its one broken remote; the second is cancelled
12794        /// while its fetch is held, and the count standing afterwards is still the first
12795        /// cycle's.
12796        #[test]
12797        fn a_cancelled_cycle_leaves_the_completed_cycles_failures_standing() {
12798            let remote = seeded_remote();
12799            let root = tempfile::tempdir().expect("temp dir");
12800            let root_path = root_of(&root);
12801            let broken = root_path.join("broken");
12802            clone_into(remote.path(), &broken);
12803            break_remote(&broken);
12804
12805            let (fetch_tick_tx, fetch_tick_rx) = crossbeam_channel::unbounded();
12806            let started = Core::start_for_test_with_fetch(
12807                fetch_spec(true, root_path),
12808                Duration::from_secs(3600),
12809                crossbeam_channel::never(),
12810                fetch_tick_rx,
12811            )
12812            .discovered();
12813            let core = started.core;
12814
12815            wait_for("the immediate cycle to complete and be taken back", || {
12816                started.fetch_cycles_taken_back.load(Ordering::Acquire) >= 1
12817            });
12818            assert_eq!(
12819                core.fetch_failures().failed.len(),
12820                1,
12821                "the completed cycle must have counted its one broken remote, got: {:?}",
12822                core.fetch_failures().failed
12823            );
12824
12825            let held = core.fetch_boundary().arm();
12826            fetch_tick_tx
12827                .send(Instant::now())
12828                .expect("send a fetch tick");
12829            held.wait_until_reached();
12830            core.pause();
12831            held.wait_until_cancelled();
12832            drop(held);
12833            wait_for("the cancelled cycle to be taken back by the clock", || {
12834                started.fetch_cycles_taken_back.load(Ordering::Acquire) >= 2
12835            });
12836
12837            assert_eq!(
12838                core.fetch_failures().failed.len(),
12839                1,
12840                "a cancelled cycle must leave the completed cycle's own count standing, \
12841                 got: {:?}",
12842                core.fetch_failures().failed
12843            );
12844        }
12845
12846        /// The one cycle enabling the periodic fetch owes
12847        /// ([refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
12848        /// "fires immediately on being enabled") is held by a pause rather than lost to it:
12849        /// the first walk asks for it once and nothing asks again, so a Launcher handoff
12850        /// landing during that walk would otherwise cost the user a whole `fetch.interval`.
12851        /// The walk is held closed until the pause has been sent, which is what orders the
12852        /// two rather than racing them.
12853        #[test]
12854        fn a_pause_landing_before_the_immediate_cycle_holds_it_until_resume() {
12855            let remote = seeded_remote();
12856            let root = tempfile::tempdir().expect("temp dir");
12857            let root_path = root_of(&root);
12858            clone_into(remote.path(), &root_path.join("parent"));
12859
12860            let (gate, walk_may_run, opener) = gate_opened_on_signal(false);
12861            let started = Core::start_for_test_with_fetch_gated(
12862                fetch_spec(true, root_path),
12863                Duration::from_secs(3600),
12864                crossbeam_channel::never(),
12865                crossbeam_channel::never(),
12866                Some(gate),
12867            );
12868            started.core.pause();
12869            walk_may_run.send(()).expect("the opener is listening");
12870            opener.join().expect("the opener thread should not panic");
12871            let core = started.discovered().core;
12872
12873            core.resume();
12874
12875            wait_for(
12876                "the held immediate cycle to run once the clock resumes",
12877                || core.fetch_cycle_count_for_test() >= 1,
12878            );
12879        }
12880
12881        /// Teardown signals the cycle's own cancellation and waits for the worker to stop,
12882        /// rather than abandoning a thread that is still fetching and fast-forwarding
12883        /// repositories. Both halves are read against a fetch this test is still holding:
12884        /// the cancellation is observed at the boundary, and teardown is still waiting while
12885        /// that fetch has not returned, which a teardown that merely signalled and detached
12886        /// could not be. It runs on a thread of its own, so a teardown that never returns
12887        /// fails this test rather than wedging the run. The Repo is left eligible for the
12888        /// auto-update by a fetch this test performs itself, so the branch standing still
12889        /// afterwards is a worker that stopped rather than one with nothing to do.
12890        #[test]
12891        fn dropping_the_core_cancels_and_joins_a_held_fetch_cycle_before_returning() {
12892            let remote = seeded_remote();
12893            let root = tempfile::tempdir().expect("temp dir");
12894            let root_path = root_of(&root);
12895            let parent = root_path.join("parent");
12896            clone_into(remote.path(), &parent);
12897            crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
12898            git(&parent, &["fetch", "origin"]);
12899            let before_tip = rev_parse(&parent, "refs/heads/main");
12900
12901            let (fetch_tick_tx, fetch_tick_rx) = crossbeam_channel::unbounded();
12902            let started = Core::start_for_test_with_fetch(
12903                spec_with_auto_update(false, true, root_path),
12904                Duration::from_secs(3600),
12905                crossbeam_channel::never(),
12906                fetch_tick_rx,
12907            )
12908            .discovered();
12909            let core = started.core;
12910
12911            let held = core.fetch_boundary().arm();
12912            fetch_tick_tx
12913                .send(Instant::now())
12914                .expect("send a fetch tick");
12915            held.wait_until_reached();
12916
12917            let (returned_tx, returned_rx) = crossbeam_channel::bounded::<()>(1);
12918            let teardown = thread::spawn(move || {
12919                drop(core);
12920                let _ = returned_tx.send(());
12921            });
12922
12923            held.wait_until_cancelled();
12924            // A safety claim rather than a liveness one, so no deadline can prove it and
12925            // load only ever weakens it: teardown is inside its own join for as long as the
12926            // fetch below has not returned.
12927            assert!(
12928                returned_rx
12929                    .recv_timeout(Duration::from_millis(200))
12930                    .is_err(),
12931                "teardown must still be waiting on the worker it cancelled, not have \
12932                 detached it"
12933            );
12934
12935            drop(held);
12936            returned_rx
12937                .recv_timeout(liveness::BACKSTOP)
12938                .expect("teardown returns once the worker it joined has stopped");
12939            teardown
12940                .join()
12941                .expect("the teardown thread should not panic");
12942
12943            assert_eq!(
12944                started.fetch_cycles_taken_back.load(Ordering::Acquire),
12945                1,
12946                "teardown must have taken its own cycle back rather than left it running"
12947            );
12948            assert_eq!(
12949                rev_parse(&parent, "refs/heads/main"),
12950                before_tip,
12951                "no worker may still be fast-forwarding a repository once teardown has \
12952                 returned"
12953            );
12954        }
12955
12956        /// Points `repo`'s `origin` at a path nothing lives at, breaking `fetch_and_prune`
12957        /// alone: discovery has already found `repo` as a real Repo before this runs, so
12958        /// only the fetch itself fails, never the walk. A local path rather than a loopback
12959        /// address, so this never touches even the machine's own network stack, the same
12960        /// standing constraint every fixture in this module already holds to.
12961        fn break_remote(repo: &Path) {
12962            let status = Command::new("git")
12963                .arg("-C")
12964                .arg(repo)
12965                .args(["remote", "set-url", "origin", "/nonexistent-remote-282"])
12966                .status()
12967                .expect("run git remote set-url");
12968            assert!(status.success());
12969        }
12970
12971        /// Criterion: a cycle where every fetch succeeds reports no failures.
12972        #[test]
12973        fn a_cycle_in_which_every_fetch_succeeds_reports_no_failures() {
12974            let remote = seeded_remote();
12975            let root = tempfile::tempdir().expect("temp dir");
12976            let root_path = root_of(&root);
12977            clone_into(remote.path(), &root_path.join("parent"));
12978
12979            let fetch_ticks: Receiver<Instant> = crossbeam_channel::never();
12980            let started = Core::start_for_test_with_fetch(
12981                fetch_spec(true, root_path),
12982                Duration::from_secs(3600),
12983                crossbeam_channel::never(),
12984                fetch_ticks,
12985            )
12986            .discovered();
12987            let core = started.core;
12988
12989            wait_for("the periodic fetch to run its first cycle", || {
12990                core.fetch_cycle_count_for_test() >= 1
12991            });
12992
12993            assert!(
12994                core.fetch_failures().failed.is_empty(),
12995                "a cycle where every fetch succeeds must report no failures, got: {:?}",
12996                core.fetch_failures().failed
12997            );
12998        }
12999
13000        /// A cycle in which one repository cannot be fetched counts that one failure, and
13001        /// the per-repository independence at the fetch loop's own swallow is unchanged,
13002        /// proven here by the sibling repository still fetching.
13003        #[test]
13004        fn a_repository_that_cannot_be_fetched_is_counted_while_its_sibling_still_fetches() {
13005            let good_remote = seeded_remote();
13006            let bad_remote = seeded_remote();
13007            let root = tempfile::tempdir().expect("temp dir");
13008            let root_path = root_of(&root);
13009            let good = root_path.join("good");
13010            let bad = root_path.join("bad");
13011            clone_into(good_remote.path(), &good);
13012            clone_into(bad_remote.path(), &bad);
13013            break_remote(&bad);
13014
13015            crate::test_support::push_new_commit(good_remote.path(), "second.txt", "second\n");
13016            let good_remote_tip = rev_parse(good_remote.path(), "refs/heads/main");
13017
13018            let fetch_ticks: Receiver<Instant> = crossbeam_channel::never();
13019            let started = Core::start_for_test_with_fetch(
13020                fetch_spec(true, root_path),
13021                Duration::from_secs(3600),
13022                crossbeam_channel::never(),
13023                fetch_ticks,
13024            )
13025            .discovered();
13026            let core = started.core;
13027
13028            wait_for(
13029                "the cycle to run and count the one repository it could not fetch",
13030                || core.fetch_failures().failed.len() == 1,
13031            );
13032
13033            let failures = core.fetch_failures();
13034            assert_eq!(
13035                failures.failed.len(),
13036                1,
13037                "exactly one repository failed, so exactly one failure must be counted, \
13038                 got: {:?}",
13039                failures.failed
13040            );
13041            assert!(
13042                failures.failed[0].0.to_string_lossy().contains("bad"),
13043                "the counted failure must name the repository that actually failed, \
13044                 got: {:?}",
13045                failures.failed
13046            );
13047
13048            wait_for(
13049                "the sibling repository to still fetch despite the other one failing",
13050                || rev_parse(&good, "refs/remotes/origin/main") == good_remote_tip,
13051            );
13052        }
13053
13054        /// [`crate::test_support::push_new_commit`], but onto `branch` rather than
13055        /// always `main`: this scheduler test needs a second commit on `topic`
13056        /// specifically, so ancestry alone cannot call it merged into `main`.
13057        fn push_new_commit_on_branch(remote: &Path, branch: &str, name: &str, contents: &str) {
13058            let contributor = tempfile::tempdir().expect("temp dir");
13059            let status = Command::new("git")
13060                .arg("clone")
13061                .arg("--branch")
13062                .arg(branch)
13063                .arg(remote)
13064                .arg(contributor.path())
13065                .status()
13066                .expect("run git clone");
13067            assert!(status.success());
13068            std::fs::write(contributor.path().join(name), contents).expect("write fixture file");
13069            git(contributor.path(), &["add", name]);
13070            git(contributor.path(), &["commit", "-m", "extra work on topic"]);
13071            git(contributor.path(), &["push", "origin", branch]);
13072        }
13073
13074        /// Criteria 3 and 4 together, end to end: the periodic fetch always prunes, so
13075        /// `Gone` can appear at all, and a finished fetch starts one normal Generation
13076        /// on its own, so the pruned state actually lands on the table without the test
13077        /// calling `refresh` itself. `topic` carries a commit `main` never gets, so
13078        /// ancestry alone cannot call it `Merged`; deleting it upstream before the
13079        /// scheduler's own fetch is what a plain, non-pruning fetch could never turn
13080        /// into `Gone`.
13081        #[test]
13082        fn a_finished_fetch_prunes_and_starts_its_own_generation_that_lands_gone() {
13083            let remote = seeded_remote();
13084            let root = tempfile::tempdir().expect("temp dir");
13085            let root_path = root_of(&root);
13086            let parent = root_path.join("parent");
13087            clone_into(remote.path(), &parent);
13088
13089            git(remote.path(), &["branch", "topic"]);
13090            push_new_commit_on_branch(remote.path(), "topic", "topic.txt", "extra work\n");
13091
13092            // A deliberate, ordinary fetch by the test's own setup, distinct from the
13093            // Core's own periodic fetch under test: `parent` was cloned before `topic`
13094            // existed, so this is what teaches it about `origin/topic` at all, the same
13095            // way any real clone would only learn of a branch created after it cloned
13096            // on its own next fetch.
13097            git(&parent, &["fetch", "origin"]);
13098
13099            let worktree_path = root_path.join("topic-worktree");
13100            git(
13101                &parent,
13102                &[
13103                    "worktree",
13104                    "add",
13105                    "-b",
13106                    "topic",
13107                    worktree_path.to_str().expect("utf8 path"),
13108                    "origin/topic",
13109                ],
13110            );
13111
13112            // Deleted only now, after the worktree already tracks it: this is the
13113            // upstream disappearance a plain fetch can see but never prune away, and
13114            // exactly what the scheduler's own fetch (not this setup) must prune.
13115            git(remote.path(), &["branch", "-D", "topic"]);
13116
13117            let fetch_ticks: Receiver<Instant> = crossbeam_channel::never();
13118            let started = Core::start_for_test_with_fetch(
13119                fetch_spec(true, root_path),
13120                Duration::from_secs(3600),
13121                crossbeam_channel::never(),
13122                fetch_ticks,
13123            )
13124            .discovered();
13125            let core = started.core;
13126
13127            wait_for_or(
13128                "a finished fetch's own Generation to land the pruned Worktree as Gone \
13129                 without the test ever calling refresh",
13130                || {
13131                    core.snapshot()
13132                        .entities
13133                        .iter()
13134                        .filter(|entity| matches!(entity.kind, Kind::Worktree))
13135                        .any(|entity| {
13136                            matches!(
13137                                entity.state.settled(),
13138                                Some(Settled::Known {
13139                                    value: WorktreeState::Gone,
13140                                    at: _,
13141                                    stale: _,
13142                                })
13143                            )
13144                        })
13145                },
13146                || {
13147                    format!(
13148                        "snapshot: {:?}",
13149                        core.snapshot()
13150                            .entities
13151                            .iter()
13152                            .map(|entity| (entity.kind, entity.state.settled().cloned()))
13153                            .collect::<Vec<_>>()
13154                    )
13155                },
13156            );
13157        }
13158
13159        fn spec_with_auto_update(
13160            fetch_enabled: bool,
13161            auto_update_enabled: bool,
13162            root: PathBuf,
13163        ) -> CoreSpec {
13164            let mut spec = fetch_spec(fetch_enabled, root);
13165            spec.auto_update = AutoUpdateSpec {
13166                enabled: auto_update_enabled,
13167            };
13168            spec
13169        }
13170
13171        fn rev_parse(path: &Path, rev: &str) -> String {
13172            let output = Command::new("git")
13173                .arg("-C")
13174                .arg(path)
13175                .args(["rev-parse", rev])
13176                .output()
13177                .expect("run git rev-parse");
13178            assert!(output.status.success(), "git rev-parse {rev} failed");
13179            String::from_utf8(output.stdout)
13180                .expect("utf8 sha")
13181                .trim()
13182                .to_string()
13183        }
13184
13185        /// Criterion 1's "off by default" half: `fetch.enabled` alone is not enough to
13186        /// move a branch. `fetch_ticks` never fires, so the only cycle that can possibly
13187        /// run is the immediate one `start_internal` dispatches on being enabled; that
13188        /// cycle fetches (`fetch_cycle_count_for_test` proves it ran) and must still
13189        /// leave the eligible local branch exactly where it was, since `auto_update`
13190        /// carries its own, separate `enabled` flag this spec never turns on.
13191        #[test]
13192        fn auto_update_is_off_by_default_even_with_fetch_enabled() {
13193            let remote = seeded_remote();
13194            let root = tempfile::tempdir().expect("temp dir");
13195            let root_path = root_of(&root);
13196            let parent = root_path.join("parent");
13197            clone_into(remote.path(), &parent);
13198            let before = rev_parse(&parent, "refs/heads/main");
13199
13200            crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
13201
13202            let fetch_ticks: Receiver<Instant> = crossbeam_channel::never();
13203            let started = Core::start_for_test_with_fetch(
13204                spec_with_auto_update(true, false, root_path),
13205                Duration::from_secs(3600),
13206                crossbeam_channel::never(),
13207                fetch_ticks,
13208            )
13209            .discovered();
13210            let core = started.core;
13211
13212            wait_for(
13213                "the periodic fetch to still run its immediate cycle",
13214                || core.fetch_cycle_count_for_test() >= 1,
13215            );
13216            assert_eq!(
13217                rev_parse(&parent, "refs/heads/main"),
13218                before,
13219                "an eligible branch must not move while auto_update.enabled is false, \
13220                 even though fetch.enabled is true"
13221            );
13222        }
13223
13224        /// Criterion 1's "rides the fetch cycle with no timer of its own" half: the
13225        /// remote is already ahead *before* `Core::start`, `fetch_ticks` is
13226        /// `crossbeam_channel::never()` so no recurring tick ever fires, and yet the
13227        /// eligible branch still moves, proving the auto-update ran on the same
13228        /// immediate first cycle the periodic fetch itself uses rather than waiting on
13229        /// any tick of its own.
13230        #[test]
13231        fn auto_update_enabled_rides_the_immediate_fetch_cycle_with_no_timer_of_its_own() {
13232            let remote = seeded_remote();
13233            let root = tempfile::tempdir().expect("temp dir");
13234            let root_path = root_of(&root);
13235            let parent = root_path.join("parent");
13236            clone_into(remote.path(), &parent);
13237
13238            crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
13239            let remote_tip = rev_parse(remote.path(), "refs/heads/main");
13240
13241            let fetch_ticks: Receiver<Instant> = crossbeam_channel::never();
13242            let started = Core::start_for_test_with_fetch(
13243                spec_with_auto_update(true, true, root_path),
13244                Duration::from_secs(3600),
13245                crossbeam_channel::never(),
13246                fetch_ticks,
13247            )
13248            .discovered();
13249            // Kept alive, unused otherwise: dropping `Core` joins its dedicated thread,
13250            // which would stop the immediate cycle this test is waiting on.
13251            let _core = started.core;
13252
13253            wait_for(
13254                "the eligible branch to fast-forward on the immediate cycle alone, with no \
13255                 fetch tick and no auto-update tick of its own",
13256                || rev_parse(&parent, "refs/heads/main") == remote_tip,
13257            );
13258        }
13259    }
13260
13261    /// [`Core::attempt_auto_update`] must answer exactly what
13262    /// [`crate::auto_update::attempt`] would for the same Repo, since it delegates to that
13263    /// function rather than reimplementing its own copy of the eligibility rules: the
13264    /// built-in `sync` action's own "reuses `auto_update`'s existing rules rather than a
13265    /// second implementation"
13266    /// ([repo-management.md](https://github.com/paulchiu/repon/blob/main/docs/spec/repo-management.md))
13267    /// is proven here, at the one seam a reimplementation could actually diverge from the
13268    /// rules it is supposed to reuse. Every fixture is a bare repo this test creates plus a
13269    /// real `git clone` of it, the same standing constraint `fetch_scheduler` above follows.
13270    mod attempt_auto_update {
13271        use super::*;
13272
13273        fn seeded_remote() -> tempfile::TempDir {
13274            let remote = tempfile::tempdir().expect("temp dir");
13275            crate::test_support::init_bare(remote.path());
13276            crate::test_support::push_new_commit(remote.path(), "README.md", "seed\n");
13277            remote
13278        }
13279
13280        fn clone_into(remote: &Path, dest: &Path) {
13281            let status = Command::new("git")
13282                .arg("clone")
13283                .arg(remote)
13284                .arg(dest)
13285                .status()
13286                .expect("run git clone");
13287            assert!(status.success());
13288            crate::test_support::set_identity(dest);
13289        }
13290
13291        /// Discovers `root`'s one Repo and hands back the live `Core` alongside its key,
13292        /// the same `Core::start_discovered` plus `settle` shape [`delete_risk`]'s own tests
13293        /// already use: this method reads the repository fresh, not a Cell, so discovery's
13294        /// own read-only probes running first are never a race with it.
13295        fn discover_repo(root: &Path) -> (Core, EntityKey) {
13296            let core = Core::start_discovered(spec(vec![root.to_path_buf()]));
13297            let key = core
13298                .settle()
13299                .entities
13300                .into_iter()
13301                .find(|entity| entity.kind == Kind::Repo)
13302                .expect("the Repo row is discovered")
13303                .key;
13304            (core, key)
13305        }
13306
13307        /// The eligible condition: clean, behind, not ahead, tracking an upstream. Proves
13308        /// the wrapper both classifies and actually moves the branch, not only the former.
13309        #[test]
13310        fn an_eligible_repo_fast_forwards_through_the_wrapper_too() {
13311            let remote = seeded_remote();
13312            let root = tempfile::tempdir().expect("temp dir");
13313            let root_path = root_of(&root);
13314            let repo = root_path.join("repo");
13315            clone_into(remote.path(), &repo);
13316            crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
13317            crate::test_support::git(&repo, &["fetch", "origin"]);
13318
13319            let (core, key) = discover_repo(&root_path);
13320
13321            assert_eq!(core.attempt_auto_update(&key), AutoUpdateAttempt::Updated);
13322            assert!(
13323                repo.join("second.txt").exists(),
13324                "the fast-forward must reach the working tree through the wrapper too"
13325            );
13326        }
13327
13328        /// Condition 1: a dirty working tree.
13329        #[test]
13330        fn a_dirty_repo_is_reported_not_clean_through_the_wrapper_too() {
13331            let remote = seeded_remote();
13332            let root = tempfile::tempdir().expect("temp dir");
13333            let root_path = root_of(&root);
13334            let repo = root_path.join("repo");
13335            clone_into(remote.path(), &repo);
13336            crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
13337            crate::test_support::git(&repo, &["fetch", "origin"]);
13338            fs::write(repo.join("stray.txt"), "uncommitted\n").expect("write a stray file");
13339
13340            let (core, key) = discover_repo(&root_path);
13341
13342            assert_eq!(core.attempt_auto_update(&key), AutoUpdateAttempt::NotClean);
13343        }
13344
13345        /// Condition 2: already level with the upstream.
13346        #[test]
13347        fn an_up_to_date_repo_is_reported_not_behind_through_the_wrapper_too() {
13348            let remote = seeded_remote();
13349            let root = tempfile::tempdir().expect("temp dir");
13350            let root_path = root_of(&root);
13351            let repo = root_path.join("repo");
13352            clone_into(remote.path(), &repo);
13353            crate::test_support::git(&repo, &["fetch", "origin"]);
13354
13355            let (core, key) = discover_repo(&root_path);
13356
13357            assert_eq!(core.attempt_auto_update(&key), AutoUpdateAttempt::NotBehind);
13358        }
13359
13360        /// Condition 3: a local commit the upstream does not have.
13361        #[test]
13362        fn an_unpublished_local_commit_is_reported_not_fast_forward_through_the_wrapper_too() {
13363            let remote = seeded_remote();
13364            let root = tempfile::tempdir().expect("temp dir");
13365            let root_path = root_of(&root);
13366            let repo = root_path.join("repo");
13367            clone_into(remote.path(), &repo);
13368            crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
13369            crate::test_support::git(&repo, &["fetch", "origin"]);
13370            crate::test_support::commit_file(&repo, "local-only.txt", "never pushed\n");
13371
13372            let (core, key) = discover_repo(&root_path);
13373
13374            assert_eq!(
13375                core.attempt_auto_update(&key),
13376                AutoUpdateAttempt::NotFastForward
13377            );
13378        }
13379
13380        /// Condition 4: no upstream configured at all.
13381        #[test]
13382        fn a_branch_with_no_upstream_is_reported_through_the_wrapper_too() {
13383            let remote = seeded_remote();
13384            let root = tempfile::tempdir().expect("temp dir");
13385            let root_path = root_of(&root);
13386            let repo = root_path.join("repo");
13387            clone_into(remote.path(), &repo);
13388            crate::test_support::git(&repo, &["checkout", "-b", "untracked-branch"]);
13389
13390            let (core, key) = discover_repo(&root_path);
13391
13392            assert_eq!(
13393                core.attempt_auto_update(&key),
13394                AutoUpdateAttempt::NoUpstream
13395            );
13396        }
13397    }
13398
13399    /// [default-branch.md](https://github.com/paulchiu/repon/blob/main/docs/spec/default-branch.md)'s
13400    /// "The network": criterion 3 (the local chain answers first, and only a later network
13401    /// round trip supersedes it) and criterion 4 (`Core::rederive_default_branches` runs the
13402    /// same lookup on demand, over exactly the given keys, without fetching). Every fixture
13403    /// here is a bare repo this test creates plus a real `git clone` of it, the same standing
13404    /// constraint `fetch_scheduler` above already follows.
13405    mod network_default_branch {
13406        use super::*;
13407
13408        fn seeded_remote() -> tempfile::TempDir {
13409            let remote = tempfile::tempdir().expect("temp dir");
13410            crate::test_support::init_bare(remote.path());
13411            crate::test_support::push_new_commit(remote.path(), "README.md", "seed\n");
13412            remote
13413        }
13414
13415        fn clone_into(remote: &Path, dest: &Path) {
13416            let status = Command::new("git")
13417                .arg("clone")
13418                .arg(remote)
13419                .arg(dest)
13420                .status()
13421                .expect("run git clone");
13422            assert!(status.success());
13423            crate::test_support::set_identity(dest);
13424        }
13425
13426        /// Sets `path`'s own `HEAD` (a bare repo, so this is the "remote"'s advertised
13427        /// answer) to point at `branch`, without checking anything out.
13428        fn set_remote_head(path: &Path, branch: &str) {
13429            git(
13430                path,
13431                &["symbolic-ref", "HEAD", &format!("refs/heads/{branch}")],
13432            );
13433        }
13434
13435        fn rev_parse(path: &Path, rev: &str) -> String {
13436            let output = Command::new("git")
13437                .arg("-C")
13438                .arg(path)
13439                .args(["rev-parse", rev])
13440                .output()
13441                .expect("run git rev-parse");
13442            assert!(output.status.success());
13443            String::from_utf8(output.stdout)
13444                .expect("utf8 sha")
13445                .trim()
13446                .to_string()
13447        }
13448
13449        fn default_branch_name(entity: &EntityState) -> Option<String> {
13450            match entity.default_branch.settled() {
13451                Some(Settled::Known {
13452                    value,
13453                    at: _,
13454                    stale: _,
13455                }) => Some(value.name().to_string()),
13456                _ => None,
13457            }
13458        }
13459
13460        /// Criterion 3: with a reachable remote whose advertised HEAD differs from the
13461        /// clone's own cached `origin/HEAD`, a plain refresh still answers from the local
13462        /// chain alone (the network is never consulted just to render a Generation), and
13463        /// only [`Core::rederive_default_branches`] actually reaching the remote supersedes
13464        /// it, for the rest of this `Core`'s own session (default-branch.md's "The network":
13465        /// "supersedes the local one for that session"). The mutation this is chosen to
13466        /// catch: were `supersede_with_network` never applied (or applied unconditionally
13467        /// before the local chain even ran), either the first assertion would already read
13468        /// `origin/trunk`, or the second would still read `origin/main`.
13469        #[test]
13470        fn the_local_chain_answers_first_and_only_a_later_network_round_trip_supersedes_it() {
13471            let remote = seeded_remote();
13472            let root = tempfile::tempdir().expect("temp dir");
13473            let root_path = root_of(&root);
13474            let repo_path = root_path.join("repo");
13475            clone_into(remote.path(), &repo_path);
13476
13477            // The clone's own cached `origin/HEAD` still names `main`; the remote's own
13478            // current answer is changed to a different, real branch only after cloning.
13479            git(remote.path(), &["branch", "trunk"]);
13480            set_remote_head(remote.path(), "trunk");
13481
13482            let core = Core::start_discovered(spec(vec![root_path]));
13483            let key = core.snapshot().entities[0].key.clone();
13484
13485            core.refresh(std::slice::from_ref(&key));
13486            let settled = core.settle();
13487            assert_eq!(
13488                default_branch_name(&settled.entities[0]),
13489                Some("origin/main".to_string()),
13490                "a plain refresh must answer from the local chain alone, unaffected by the \
13491                 remote's own current (but not yet asked) truth"
13492            );
13493
13494            core.rederive_default_branches(std::slice::from_ref(&key));
13495            let settled = core.settle();
13496            assert_eq!(
13497                default_branch_name(&settled.entities[0]),
13498                Some("origin/trunk".to_string()),
13499                "once the network round trip actually ran, its own differing answer must \
13500                 supersede the local chain's"
13501            );
13502        }
13503
13504        /// Criterion 4: [`Core::rederive_default_branches`] runs the same lookup on demand,
13505        /// over exactly the given keys, without fetching. "Without fetching" is shown the
13506        /// way `fetch.rs`'s own `a_fetch_transfers_new_commits_so_a_behind_count_can_move`
13507        /// shows a real fetch moving one, the mirror image: the remote gains a new commit
13508        /// after the clone, and this call must leave the clone's own remote-tracking ref
13509        /// exactly where it was, because `probe_remote_head`'s handshake-only lookup
13510        /// transfers no pack. "Over the Selection" is exercised as "over exactly the given
13511        /// keys": a second, unrelated repo stands in for a row outside it, and its whole
13512        /// entity state (every cell, not only `default_branch`) is asserted unchanged.
13513        #[test]
13514        fn rederive_default_branches_never_fetches_and_leaves_a_row_outside_it_untouched() {
13515            let remote = seeded_remote();
13516            let root = tempfile::tempdir().expect("temp dir");
13517            let root_path = root_of(&root);
13518            let selected_path = root_path.join("selected");
13519            let outside_path = root_path.join("outside");
13520            clone_into(remote.path(), &selected_path);
13521            init_repo_with_a_commit(&outside_path);
13522
13523            git(remote.path(), &["branch", "trunk"]);
13524            crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
13525            set_remote_head(remote.path(), "trunk");
13526            let before_tracking = rev_parse(&selected_path, "refs/remotes/origin/main");
13527
13528            let core = Core::start_discovered(spec(vec![root_path]));
13529            let snapshot = core.snapshot();
13530            let selected_key = snapshot
13531                .entities
13532                .iter()
13533                .find(|entity| entity.key.path() == selected_path)
13534                .expect("discovered the selected repo")
13535                .key
13536                .clone();
13537            let outside_key = snapshot
13538                .entities
13539                .iter()
13540                .find(|entity| entity.key.path() == outside_path)
13541                .expect("discovered the outside repo")
13542                .key
13543                .clone();
13544
13545            core.refresh(&[selected_key.clone(), outside_key.clone()]);
13546            let settled = core.settle();
13547            let outside_before = format!(
13548                "{:?}",
13549                settled
13550                    .entities
13551                    .iter()
13552                    .find(|entity| entity.key == outside_key)
13553                    .expect("outside entity present")
13554            );
13555
13556            core.rederive_default_branches(std::slice::from_ref(&selected_key));
13557            let settled = core.settle();
13558
13559            let selected_after = settled
13560                .entities
13561                .iter()
13562                .find(|entity| entity.key == selected_key)
13563                .expect("selected entity present");
13564            assert_eq!(
13565                default_branch_name(selected_after),
13566                Some("origin/trunk".to_string()),
13567                "the rederive must have reached the remote's own current, differing answer"
13568            );
13569
13570            let after_tracking = rev_parse(&selected_path, "refs/remotes/origin/main");
13571            assert_eq!(
13572                before_tracking, after_tracking,
13573                "a rederive must never fetch: the remote-tracking ref must not have moved \
13574                 even though the remote gained a new commit"
13575            );
13576
13577            let outside_after = format!(
13578                "{:?}",
13579                settled
13580                    .entities
13581                    .iter()
13582                    .find(|entity| entity.key == outside_key)
13583                    .expect("outside entity present")
13584            );
13585            assert_eq!(
13586                outside_before, outside_after,
13587                "a row outside the rederive's own keys must be left exactly as it was, not \
13588                 only on its default_branch cell"
13589            );
13590        }
13591    }
13592
13593    // =====================================================================================
13594    // `set_exclusions`: `[[repo]]`'s `exclude` re-applied live, with no rebuild and no
13595    // rediscovery, per repo-management.md's "Writing config".
13596    // =====================================================================================
13597
13598    /// The live half: a row already in the table becomes excluded, and is subtracted from
13599    /// `operable_count`, without a rebuilt `Core` and without a Generation of any kind.
13600    #[test]
13601    fn set_exclusions_excludes_a_row_already_in_the_table_with_no_rebuild() {
13602        let dir = tempfile::tempdir().expect("temp dir");
13603        let root = root_of(&dir);
13604        let repo = root.join("repo");
13605        init_repo_with_a_commit(&repo);
13606
13607        let core = Core::start_discovered(spec(vec![root]));
13608        let snapshot = core.settle();
13609        let key = snapshot.entities[0].key.clone();
13610        let generation_before = snapshot.generation;
13611        assert!(
13612            !snapshot.entities[0].excluded,
13613            "nothing excludes it to start with"
13614        );
13615        assert_eq!(core.operable_count(std::slice::from_ref(&key)), 1);
13616
13617        core.set_exclusions(&[RepoOverride {
13618            path: repo.clone(),
13619            default_branch: None,
13620            excluded: true,
13621        }]);
13622
13623        let after = core.snapshot();
13624        assert!(
13625            after.entities[0].excluded,
13626            "the row the write named is excluded in the very next snapshot"
13627        );
13628        assert_eq!(
13629            core.operable_count(&[key]),
13630            0,
13631            "an excluded row is subtracted from what an operation may reach"
13632        );
13633        assert_eq!(
13634            after.generation, generation_before,
13635            "re-applying an operate-time filter must start no Generation of its own"
13636        );
13637    }
13638
13639    /// The other direction: dropping the entry clears the flag, so a row ignored and shown
13640    /// again in one session ends where it started.
13641    #[test]
13642    fn set_exclusions_clears_the_flag_when_the_entry_is_gone() {
13643        let dir = tempfile::tempdir().expect("temp dir");
13644        let root = root_of(&dir);
13645        let repo = root.join("repo");
13646        init_repo_with_a_commit(&repo);
13647
13648        let core = Core::start_discovered(spec_with_overrides(
13649            vec![root],
13650            vec![RepoOverride {
13651                path: repo.clone(),
13652                default_branch: None,
13653                excluded: true,
13654            }],
13655        ));
13656        assert!(
13657            core.settle().entities[0].excluded,
13658            "the starting override excludes it"
13659        );
13660
13661        core.set_exclusions(&[]);
13662
13663        assert!(
13664            !core.snapshot().entities[0].excluded,
13665            "removing the entry unexcludes the row in the very next snapshot"
13666        );
13667    }
13668
13669    /// The boundary the specification draws around the live half: `exclude` re-applies and
13670    /// `default_branch` does not, because one is an operate-time filter and the other is a
13671    /// probe input. A `set_exclusions` that swapped the whole `[[repo]]` reading in would
13672    /// move both, which is what this refuses.
13673    #[test]
13674    fn set_exclusions_moves_exclude_alone_and_never_the_default_branch_override() {
13675        let dir = tempfile::tempdir().expect("temp dir");
13676        let root = root_of(&dir);
13677        let repo = root.join("repo");
13678        init_repo_with_a_commit(&repo);
13679        crate::test_support::git(&repo, &["branch", "trunk"]);
13680
13681        let core = Core::start_discovered(spec(vec![root]));
13682        let key = core.settle().entities[0].key.clone();
13683        core.refresh(std::slice::from_ref(&key));
13684        let before = format!("{:?}", core.settle().entities[0].default_branch.settled());
13685
13686        core.set_exclusions(&[RepoOverride {
13687            path: repo.clone(),
13688            default_branch: Some("trunk".to_string()),
13689            excluded: true,
13690        }]);
13691        core.refresh(&[key]);
13692        core.settle();
13693
13694        let after = core.snapshot();
13695        assert!(after.entities[0].excluded, "exclude took effect");
13696        assert_eq!(
13697            format!("{:?}", after.entities[0].default_branch.settled()),
13698            before,
13699            "a default_branch override reaches a session only through a rebuilt Core"
13700        );
13701    }
13702
13703    // =====================================================================================
13704    // `record_own_work`: the receipt a Management operation leaves, docs/spec/repo-management.md
13705    // =====================================================================================
13706
13707    /// One receipt per named row, and the shape the caller never gets to choose: `running` is
13708    /// `None`, `skip` is `None` (a refusal is not an excluded row), and there is
13709    /// exactly one step, because such an operation is one act rather than an ordered list.
13710    #[test]
13711    fn record_own_work_leaves_one_receipt_per_row_it_names_and_none_elsewhere() {
13712        let dir = tempfile::tempdir().expect("temp dir");
13713        let root = root_of(&dir);
13714        init_repo_with_a_commit(&root.join("repo-a"));
13715        init_repo_with_a_commit(&root.join("repo-b"));
13716
13717        let core = Core::start_discovered(spec(vec![root]));
13718        let entities = core.settle().entities;
13719        let named = entities
13720            .iter()
13721            .find(|entity| &*entity.name == "repo-a")
13722            .expect("repo-a is discovered")
13723            .key
13724            .clone();
13725
13726        core.record_own_work(
13727            "ignore",
13728            &[(
13729                named.clone(),
13730                OwnWork::Refused(Arc::from("refused, already ignored")),
13731                Duration::from_millis(7),
13732            )],
13733        );
13734
13735        let after = core.snapshot().entities;
13736        let receipt = after
13737            .iter()
13738            .find(|entity| entity.key == named)
13739            .and_then(|entity| entity.last_action.clone())
13740            .expect("the row it named carries a receipt");
13741        assert_eq!(&*receipt.label, "ignore");
13742        assert!(
13743            !receipt.not_applicable(),
13744            "a refusal is not an excluded row"
13745        );
13746        assert!(receipt.running.is_none(), "the work is already done");
13747        assert_eq!(receipt.steps.len(), 1, "one act, not an ordered list");
13748        assert_eq!(&*receipt.steps[0].label, "ignore");
13749        assert_eq!(receipt.steps[0].elapsed, Duration::from_millis(7));
13750        assert!(receipt.steps[0].output.is_empty(), "nothing to quote");
13751        assert!(receipt.steps[0].elision.is_none());
13752        assert_eq!(
13753            receipt.steps[0].outcome,
13754            StepOutcome::OwnWork(OwnWork::Refused(Arc::from("refused, already ignored"))),
13755        );
13756        assert!(
13757            after
13758                .iter()
13759                .filter(|entity| entity.key != named)
13760                .all(|entity| entity.last_action.is_none()),
13761            "no row this did not name takes a receipt"
13762        );
13763    }
13764
13765    /// A key the table no longer holds is skipped rather than panicking or landing on the
13766    /// wrong row, the same fallback every key-addressed entry point here gives one: a `delete`
13767    /// whose Repo is already gone is exactly this case.
13768    #[test]
13769    fn record_own_work_skips_a_key_the_table_no_longer_holds() {
13770        let dir = tempfile::tempdir().expect("temp dir");
13771        let root = root_of(&dir);
13772        init_repo_with_a_commit(&root.join("repo-a"));
13773
13774        let core = Core::start_discovered(spec(vec![root]));
13775        let entities = core.settle().entities;
13776        let stranger = EntityKey::new(Arc::from(std::path::Path::new("/nowhere/at/all")));
13777
13778        core.record_own_work(
13779            "delete",
13780            &[(stranger, OwnWork::Did(Arc::from("gone")), Duration::ZERO)],
13781        );
13782
13783        assert!(
13784            core.snapshot()
13785                .entities
13786                .iter()
13787                .all(|entity| entity.last_action.is_none()),
13788            "an unknown key writes nothing anywhere"
13789        );
13790        assert_eq!(core.snapshot().entities.len(), entities.len());
13791    }
13792
13793    // =====================================================================================
13794    // `delete_risk`: the three facts repo-management.md's confirm gate names per Repo, read
13795    // rather than stubbed. Every repository here is built in a temp directory this test owns,
13796    // and no path comes from config, an environment variable or the working directory.
13797    // =====================================================================================
13798
13799    /// A Repo with all three: an uncommitted change, a commit no remote-tracking ref carries,
13800    /// and a linked Worktree pointing into it.
13801    #[test]
13802    fn delete_risk_reads_all_three_facts_the_confirm_gate_names() {
13803        let dir = tempfile::tempdir().expect("temp dir");
13804        let root = root_of(&dir);
13805        let repo = root.join("repo");
13806        init_repo_with_a_commit(&repo);
13807        fs::write(repo.join("uncommitted.txt"), "not staged\n").expect("write a stray file");
13808        crate::test_support::git(
13809            &repo,
13810            &["worktree", "add", "-b", "sidecar", "../sidecar-worktree"],
13811        );
13812
13813        let core = Core::start_discovered(spec(vec![root]));
13814        // Settled first, so the startup Generation's own phase C is no longer reading this
13815        // same repository while the line below reads it: two concurrent gix statuses over one
13816        // working tree is a race in the harness, not in `delete_risk`.
13817        let key = core
13818            .settle()
13819            .entities
13820            .into_iter()
13821            .find(|entity| entity.kind == Kind::Repo)
13822            .expect("the Repo row is discovered")
13823            .key;
13824
13825        let risk = core.delete_risk(&key).expect("read the risk");
13826
13827        assert!(risk.uncommitted, "the stray file makes the tree dirty");
13828        assert!(
13829            risk.unpushed_commits > 0 && risk.unpushed_branches > 0,
13830            "no remote-tracking ref carries any of this Repo's commits, got {risk:?}"
13831        );
13832        assert_eq!(
13833            risk.linked_worktrees, 1,
13834            "the one linked Worktree pointing into this Repo is counted, got {risk:?}"
13835        );
13836    }
13837
13838    /// The `uncommitted` field's own range, one position at a time, because the composition
13839    /// behind it folds four separate reads: a modified tracked file, a deleted tracked file,
13840    /// an untracked file, and a staged change. Each gets a repository of its own with nothing
13841    /// else wrong with it, so narrowing the composition to any one of the four fails here
13842    /// rather than passing on whichever position a single fixture happened to sample.
13843    #[test]
13844    fn every_kind_of_work_that_is_not_in_a_commit_makes_the_gate_say_uncommitted() {
13845        for kind in ["modified", "deleted", "untracked", "staged"] {
13846            let dir = tempfile::tempdir().expect("temp dir");
13847            let root = root_of(&dir);
13848            let repo = root.join("repo");
13849            init_repo_with_a_commit(&repo);
13850            fs::write(repo.join("tracked.txt"), "first\n").expect("write a tracked file");
13851            crate::test_support::git(&repo, &["add", "tracked.txt"]);
13852            crate::test_support::git(&repo, &["commit", "-m", "add tracked"]);
13853            let sha = crate::test_support::head_sha(&repo);
13854            crate::test_support::git(&repo, &["update-ref", "refs/remotes/origin/main", &sha]);
13855
13856            match kind {
13857                "modified" => fs::write(repo.join("tracked.txt"), "second\n").expect("modify it"),
13858                "deleted" => fs::remove_file(repo.join("tracked.txt")).expect("delete it"),
13859                "untracked" => fs::write(repo.join("stray.txt"), "new\n").expect("write a stray"),
13860                "staged" => {
13861                    fs::write(repo.join("staged.txt"), "new\n").expect("write a new file");
13862                    crate::test_support::git(&repo, &["add", "staged.txt"]);
13863                }
13864                other => unreachable!("unhandled kind {other}"),
13865            }
13866
13867            let core = Core::start_discovered(spec(vec![root]));
13868            let key = core.settle().entities[0].key.clone();
13869
13870            let risk = core.delete_risk(&key).expect("read the risk");
13871
13872            assert!(
13873                risk.uncommitted,
13874                "a {kind} change is work that is not in a commit, got {risk:?}"
13875            );
13876        }
13877    }
13878
13879    /// The staged case, stated on its own as well as in the range above, because it is the
13880    /// one the dirty column deliberately answers `clean` to: `dirty_counts` compares the index
13881    /// against the working tree and never against `HEAD`, so a `git add` with no commit is
13882    /// invisible to it. Both readings are asserted here together, so a fix that widened
13883    /// `dirty_counts` instead of giving the gate its own read would fail this rather than
13884    /// silently change what the dirty column means.
13885    #[test]
13886    fn staged_work_reads_clean_to_the_dirty_column_and_uncommitted_to_the_delete_gate() {
13887        let dir = tempfile::tempdir().expect("temp dir");
13888        let root = root_of(&dir);
13889        let repo = root.join("repo");
13890        init_repo_with_a_commit(&repo);
13891        let sha = crate::test_support::head_sha(&repo);
13892        crate::test_support::git(&repo, &["update-ref", "refs/remotes/origin/main", &sha]);
13893        fs::write(repo.join("staged.txt"), "staged\n").expect("write a new file");
13894        crate::test_support::git(&repo, &["add", "staged.txt"]);
13895
13896        let core = Core::start_discovered(spec(vec![root]));
13897        let key = core.settle().entities[0].key.clone();
13898
13899        let opened = git::open_thread_safe(repo.as_path())
13900            .expect("open the repo")
13901            .to_thread_local();
13902        let dirty = git::dirty_counts(&opened, Arc::new(AtomicBool::new(false)))
13903            .expect("read the dirty counts");
13904        assert_eq!(
13905            dirty.total(),
13906            0,
13907            "the dirty column stays an index-to-worktree comparison, got {dirty:?}"
13908        );
13909
13910        let risk = core.delete_risk(&key).expect("read the risk");
13911        assert!(
13912            risk.uncommitted,
13913            "a Repo whose only work is staged must never be listed plainly, got {risk:?}"
13914        );
13915    }
13916
13917    /// The two unpushed quantities are two quantities: a fixture whose commit count and
13918    /// branch count differ, so transposing the pair in the composition changes both numbers
13919    /// rather than satisfying an inequality either way round.
13920    #[test]
13921    fn unpushed_commits_and_unpushed_branches_are_counted_into_their_own_fields() {
13922        let dir = tempfile::tempdir().expect("temp dir");
13923        let root = root_of(&dir);
13924        let repo = root.join("repo");
13925        init_repo_with_a_commit(&repo);
13926        let sha = crate::test_support::head_sha(&repo);
13927        crate::test_support::git(&repo, &["update-ref", "refs/remotes/origin/main", &sha]);
13928        for nth in 0..3 {
13929            fs::write(repo.join(format!("file-{nth}.txt")), "x\n").expect("write a file");
13930            crate::test_support::git(&repo, &["add", "."]);
13931            crate::test_support::git(&repo, &["commit", "-m", "unpushed"]);
13932        }
13933        crate::test_support::git(&repo, &["checkout", "."]);
13934
13935        let core = Core::start_discovered(spec(vec![root]));
13936        let key = core.settle().entities[0].key.clone();
13937
13938        let risk = core.delete_risk(&key).expect("read the risk");
13939
13940        assert_eq!(
13941            (risk.unpushed_commits, risk.unpushed_branches),
13942            (3, 1),
13943            "three commits on one branch, each in its own field, got {risk:?}"
13944        );
13945    }
13946
13947    /// The linked-Worktree count is git's own register, not the table's: a Worktree living
13948    /// outside the active Set's roots is never discovered, and deleting the Repo it is linked
13949    /// from orphans it just the same.
13950    #[test]
13951    fn a_linked_worktree_outside_the_sets_roots_is_still_counted_by_the_gate() {
13952        let dir = tempfile::tempdir().expect("temp dir");
13953        let base = root_of(&dir);
13954        let inside = base.join("inside");
13955        let outside = base.join("outside");
13956        fs::create_dir_all(&outside).expect("create the outside dir");
13957        let repo = inside.join("repo");
13958        init_repo_with_a_commit(&repo);
13959        crate::test_support::git(
13960            &repo,
13961            &["worktree", "add", "-b", "sidecar", "../../outside/sidecar"],
13962        );
13963        assert!(
13964            outside.join("sidecar").exists(),
13965            "the harness really created a linked Worktree outside the Set's roots"
13966        );
13967
13968        // Bounded by `inside` alone, so the Worktree is not a row in this Core's own table.
13969        let core = Core::start_discovered(spec(vec![inside]));
13970        let snapshot = core.settle();
13971        assert!(
13972            snapshot
13973                .entities
13974                .iter()
13975                .all(|entity| entity.kind != Kind::Worktree),
13976            "the Worktree is outside the roots and so is not discovered, got {:?}",
13977            snapshot.entities.iter().map(|e| e.kind).collect::<Vec<_>>()
13978        );
13979        let key = snapshot
13980            .entities
13981            .into_iter()
13982            .find(|entity| entity.kind == Kind::Repo)
13983            .expect("the Repo row is discovered")
13984            .key;
13985
13986        let risk = core.delete_risk(&key).expect("read the risk");
13987
13988        assert_eq!(
13989            risk.linked_worktrees, 1,
13990            "the gate must name the linked Worktree deleting this Repo would orphan, got {risk:?}"
13991        );
13992    }
13993
13994    /// The "listed plainly" case: nothing uncommitted, every commit already on a
13995    /// remote-tracking ref, and no linked Worktree at all. Asserted as its own test rather
13996    /// than left implied, since a gate that reports risk on every Repo is as wrong as one
13997    /// that reports it on none.
13998    #[test]
13999    fn delete_risk_on_a_clean_fully_pushed_repo_with_no_worktrees_reports_nothing() {
14000        let dir = tempfile::tempdir().expect("temp dir");
14001        let root = root_of(&dir);
14002        let repo = root.join("repo");
14003        init_repo_with_a_commit(&repo);
14004        let sha = crate::test_support::head_sha(&repo);
14005        crate::test_support::git(&repo, &["update-ref", "refs/remotes/origin/main", &sha]);
14006
14007        let core = Core::start_discovered(spec(vec![root]));
14008        let key = core.settle().entities[0].key.clone();
14009
14010        let risk = core.delete_risk(&key).expect("read the risk");
14011
14012        assert_eq!(
14013            risk,
14014            DeleteRisk {
14015                uncommitted: false,
14016                unpushed_commits: 0,
14017                unpushed_branches: 0,
14018                linked_worktrees: 0,
14019            }
14020        );
14021    }
14022
14023    // =====================================================================================
14024    // `worktree_admin_dir` and `linked_worktree_paths`: what `delete` needs to remove a
14025    // linked Worktree the way `git worktree remove` does, and to take a Repo's own linked
14026    // Worktrees with it. Every repository here is built in a temp directory this test owns.
14027    // =====================================================================================
14028
14029    /// The administrative directory named for a Worktree row is the one `git worktree list`
14030    /// stops naming once it is gone, proven by removing exactly that directory by hand.
14031    #[test]
14032    fn worktree_admin_dir_names_the_entry_git_worktree_list_forgets_once_it_is_removed() {
14033        let dir = tempfile::tempdir().expect("temp dir");
14034        let root = root_of(&dir);
14035        let repo = root.join("repo");
14036        init_repo_with_a_commit(&repo);
14037        let worktree = root.join("sidecar");
14038        crate::test_support::git(
14039            &repo,
14040            &[
14041                "worktree",
14042                "add",
14043                "-b",
14044                "sidecar",
14045                worktree.to_str().expect("utf8 path"),
14046            ],
14047        );
14048
14049        let core = Core::start_discovered(spec(vec![root]));
14050        let key = core
14051            .settle()
14052            .entities
14053            .into_iter()
14054            .find(|entity| entity.kind == Kind::Worktree)
14055            .expect("the Worktree row is discovered")
14056            .key;
14057
14058        let admin_dir = core.worktree_admin_dir(&key).expect("read the admin dir");
14059        fs::remove_dir_all(&admin_dir).expect("remove the admin dir by hand");
14060
14061        let reopened = git::open_thread_safe(&repo)
14062            .expect("reopen the repo")
14063            .to_thread_local();
14064        assert_eq!(
14065            git::linked_worktrees(&reopened).expect("count"),
14066            0,
14067            "removing the admin dir alone must be what git's own register stops naming"
14068        );
14069    }
14070
14071    /// A Worktree whose own path is not a git repository at all (the fixture for "the parent
14072    /// Repo is gone or unreadable"): the read errors rather than naming a directory that was
14073    /// never a Worktree's own administrative entry.
14074    #[test]
14075    fn worktree_admin_dir_errors_when_the_path_cannot_be_opened_as_a_repository() {
14076        let dir = tempfile::tempdir().expect("temp dir");
14077        let root = root_of(&dir);
14078        let not_a_repo = root.join("plain-directory");
14079        fs::create_dir_all(&not_a_repo).expect("create it");
14080
14081        let core = Core::start_discovered(spec(vec![root]));
14082        core.settle();
14083        let key = EntityKey::new(Arc::from(not_a_repo.as_path()));
14084
14085        assert!(core.worktree_admin_dir(&key).is_err());
14086    }
14087
14088    /// Every linked Worktree's own working directory, named by path rather than merely
14089    /// counted, for the Repo deletion cascade to remove.
14090    #[test]
14091    fn linked_worktree_paths_names_every_linked_worktrees_own_directory() {
14092        let dir = tempfile::tempdir().expect("temp dir");
14093        let root = root_of(&dir);
14094        let repo = root.join("repo");
14095        init_repo_with_a_commit(&repo);
14096        let first = root.join("first-worktree");
14097        let second = root.join("second-worktree");
14098        crate::test_support::git(
14099            &repo,
14100            &[
14101                "worktree",
14102                "add",
14103                "-b",
14104                "one",
14105                first.to_str().expect("utf8 path"),
14106            ],
14107        );
14108        crate::test_support::git(
14109            &repo,
14110            &[
14111                "worktree",
14112                "add",
14113                "-b",
14114                "two",
14115                second.to_str().expect("utf8 path"),
14116            ],
14117        );
14118
14119        let core = Core::start_discovered(spec(vec![root]));
14120        let key = core
14121            .settle()
14122            .entities
14123            .into_iter()
14124            .find(|entity| entity.kind == Kind::Repo)
14125            .expect("the Repo row is discovered")
14126            .key;
14127
14128        let mut paths = core
14129            .linked_worktree_paths(&key)
14130            .expect("read the linked worktree paths");
14131        paths.sort();
14132        let mut expected = vec![
14133            first.canonicalize().expect("canonicalize first"),
14134            second.canonicalize().expect("canonicalize second"),
14135        ];
14136        expected.sort();
14137
14138        assert_eq!(paths, expected);
14139    }
14140}