Skip to main content

repon_core/
core.rs

1//! `Core::start` and the threads and clocks it owns.
2//!
3//! See `docs/spec/core-api.md`'s "Threads and lifecycle" and "The entry points",
4//! [ADR 0006](https://github.com/paulchiu/repon/blob/main/docs/adr/0006-no-git-state-cache-session-state-by-name.md)
5//! and [ADR 0015](https://github.com/paulchiu/repon/blob/main/docs/adr/0015-the-core-owns-the-table.md).
6//!
7//! One dedicated thread runs the two second metadata poll and the thirty second
8//! Generation deadline sweep on a shared interval loop; probes go on rayon's global
9//! pool, one task per entity, which is infrastructure this crate already shares
10//! rather than a thread `Core` itself owns. `Core::start` is the only thing that
11//! spawns the dedicated thread, and `Drop` joins it, so a consumer never spawns one
12//! of its own. The dedicated thread's ticking source is an injected channel rather
13//! than a bare `thread::sleep`, which is what lets a test drive the poll and
14//! deadline cadence deterministically instead of sleeping and hoping.
15//!
16//! Discovery, both halves, re-runs at the head of every Generation
17//! ([refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md),
18//! [discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md)):
19//! an Entity a later walk does not find goes [`crate::entity::Presence::Vanished`]
20//! rather than disappearing, and one an abandoned walk cannot finish in time takes
21//! its Set out of this automatic path until a fresh `Core` starts over different
22//! roots. Phase C, status, is later work; this module already dispatches identity
23//! (`branch`), `default_branch`, Phase B's own comparison (`sync`) and Phase D's
24//! landing pass (`state`), the reads [`crate::git::head_shape`],
25//! [`crate::default_branch::resolve`], [`crate::git::resolve_sync`] and
26//! [`crate::landing::probe`] already do correctly, so the threading and
27//! supersession machinery has a real payload to move rather than a stub. Nothing
28//! here is written to or read from disk, so
29//! every `start` recomputes from scratch; the consequence is that first-frame
30//! speed has to come from progressive loading rather than a cache, which is
31//! future work this crate does not yet do (`start` blocks on its one discovery
32//! walk, at 20ms for the measured population).
33
34use std::collections::{HashMap, HashSet};
35use std::path::{Path, PathBuf};
36use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
37use std::sync::{Arc, Condvar, Mutex, OnceLock, RwLock};
38use std::thread::{self, JoinHandle};
39use std::time::{Duration, Instant};
40
41use crossbeam_channel::{Receiver, Sender, select};
42use rayon::iter::{IntoParallelIterator, ParallelIterator};
43
44use crate::base;
45use crate::cell::{Cell, Generation, Settled, Timestamp, Unknown};
46use crate::default_branch;
47use crate::discovery::{self, SetSpec};
48use crate::entity::{
49    ActionReceipt, DefaultBranch, DeleteRisk, DirtyCounts, EntityKey, EntityState, Head, Kind,
50    OwnWork, Presence, RunningStep, Skip, StepOutcome, StepResult, SyncState, WorktreeState,
51};
52use crate::environment;
53use crate::executor;
54use crate::filter::{Applicability, Filter, Partition};
55use crate::git;
56use crate::landing;
57#[cfg(any(test, feature = "test-util"))]
58use crate::liveness;
59use crate::patch_equivalence;
60use crate::poll;
61use crate::snapshot::Snapshot;
62
63/// Budget within which rows with names must be on screen at first frame
64/// (`docs/spec/refresh.md`'s "The first frame"). Nothing reads this yet; discovery's own
65/// wall time is the thing that would have to stay under it once first-frame timing is
66/// enforced rather than merely stated. Read only by
67/// `first_frame_budget_constants_match_the_spec_of_record`.
68#[allow(dead_code)] // read only by first_frame_budget_constants_match_the_spec_of_record
69const FIRST_FRAME_NAMES_BUDGET_MS: u64 = 50;
70
71/// Budget within which every cheap column (phase A/B) must be filled at first frame
72/// (`docs/spec/refresh.md`'s "The first frame"). Nothing reads this yet; the identity and
73/// comparison probes' combined wall time is what would have to stay under it. Read only by
74/// `first_frame_budget_constants_match_the_spec_of_record`.
75#[allow(dead_code)] // read only by first_frame_budget_constants_match_the_spec_of_record
76const FIRST_FRAME_CHEAP_COLUMNS_BUDGET_MS: u64 = 200;
77
78/// One Repo's config-level override, crossing from the consumer as plain data: no
79/// TOML type, no `~` expansion left undone. [config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md)
80/// owns parsing it; the core only ever receives the result, and resolves `path` to
81/// the git common dir itself, since opening a repository is the core's own work.
82///
83/// Keyed on `path` rather than the common dir `docs/spec/core-api.md` first named,
84/// which this amends: a Worktree and its parent Repo share one common dir, so only
85/// the entry's own path can outrank an entry reached by inheritance.
86#[derive(Debug, Clone)]
87pub struct RepoOverride {
88    pub path: PathBuf,
89    pub default_branch: Option<String>,
90    pub excluded: bool,
91}
92
93/// One command in an Action's ordered list, crossing from the consumer as plain data:
94/// `from_env` already resolved, so this crate never learns what that means
95/// ([config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md)'s
96/// "Actions", "Launchers" carries the same split for a Launcher's own argv). `shell`
97/// crosses over unresolved, because resolving it is `executor::run_step`'s own job,
98/// the same convention the `repon` crate's Launcher `shell = true` uses: with
99/// `shell` set, `argv` holds exactly one element, the whole command string, per
100/// [config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md)'s
101/// "Launchers". `env`'s pairs are applied after
102/// [`environment::environment`]'s own set-or-unset pairs, so a step's own `env` table
103/// overrides the guaranteed set exactly as [config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md)'s
104/// Launcher `env` field already does.
105#[derive(Debug, Clone)]
106pub struct Step {
107    pub argv: Vec<String>,
108    pub shell: bool,
109    /// Runs `argv` through `$SHELL -ic` rather than `$SHELL -c`, sourcing the user's own rc
110    /// file first ([config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md)'s
111    /// `interactive` key). Meaningless, and always `false`, unless `shell` is also set; the
112    /// consumer rejects `interactive` without `shell` at config load rather than this crate
113    /// silently ignoring it.
114    pub interactive: bool,
115    pub env: Vec<(String, String)>,
116}
117
118/// One Action fan-out, crossing from the consumer as plain data: no TOML type, no
119/// confirm gate, no palette. `label` is what a receipt's own label carries: the
120/// Action's name, or the ad hoc command string typed into the palette. `name` is
121/// `REPON_ACTION`'s own value and is `None` for an ad hoc run, exactly as
122/// [config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md)'s
123/// environment contract already treats a Launcher's absent `REPON_ACTION`. `when` is
124/// `None` for a built-in, a typed command or a configured entry that declares none, in
125/// which case every operable row runs; `Some` is what [`Core::run_action`] itself now
126/// decides the fan-out by, not only what a palette reports about it
127/// ([`docs/spec/actions.md`](https://github.com/paulchiu/repon/blob/main/docs/spec/actions.md)'s
128/// "The Selection and the gate").
129#[derive(Debug, Clone)]
130pub struct ActionSpec {
131    pub label: Arc<str>,
132    pub name: Option<Arc<str>>,
133    pub steps: Vec<Step>,
134    pub concurrency: u32,
135    pub when: Option<Filter>,
136}
137
138/// A [`RepoOverride`]'s probe-input half with its common dir already resolved, built
139/// once at `Core::start` and frozen for this `Core`'s life: `default_branch` is read
140/// while probing, so moving it needs the rediscovery a rebuild does
141/// ([repo-management.md](https://github.com/paulchiu/repon/blob/main/docs/spec/repo-management.md)'s
142/// "Writing config").
143#[derive(Debug, Clone)]
144struct ResolvedOverride {
145    path: PathBuf,
146    common_dir: PathBuf,
147    default_branch: Option<String>,
148}
149
150/// A [`RepoOverride`]'s `exclude` with its common dir already resolved, held apart from
151/// [`ResolvedOverride`] because it re-applies live: `exclude` decides only whether an
152/// operation may reach a row that is discovered, probed and listed either way
153/// ([config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md)'s "listed,
154/// never operated on"), so [`Core::set_exclusions`] replaces it with no rebuild at all.
155#[derive(Debug, Clone)]
156struct ResolvedExclusion {
157    path: PathBuf,
158    common_dir: PathBuf,
159    excluded: bool,
160}
161
162/// The two path fields [`find_entry`]'s match rule reads, so the rule itself is written
163/// once for both halves a `[[repo]]` entry resolves into.
164trait ResolvedEntry {
165    fn path(&self) -> &Path;
166    fn common_dir(&self) -> &Path;
167}
168
169impl ResolvedEntry for ResolvedOverride {
170    fn path(&self) -> &Path {
171        &self.path
172    }
173
174    fn common_dir(&self) -> &Path {
175        &self.common_dir
176    }
177}
178
179impl ResolvedEntry for ResolvedExclusion {
180    fn path(&self) -> &Path {
181        &self.path
182    }
183
184    fn common_dir(&self) -> &Path {
185        &self.common_dir
186    }
187}
188
189/// Opens every override's own `path` to learn its common dir, once, and splits the result
190/// into the half frozen for this `Core`'s life and the half [`Core::set_exclusions`] can
191/// replace live. Silently drops an entry that will not even open: a path that matches no
192/// discovered entity already gets its own warning on the consumer's side
193/// ([config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md#cross-key-validity)),
194/// and the core raises no second one for the same fact.
195fn resolve_entries(overrides: &[RepoOverride]) -> (Vec<ResolvedOverride>, Vec<ResolvedExclusion>) {
196    overrides
197        .iter()
198        .filter_map(|entry| {
199            let common_dir = git::common_dir_of(&entry.path).ok()?;
200            Some((
201                ResolvedOverride {
202                    path: entry.path.clone(),
203                    common_dir: common_dir.to_path_buf(),
204                    default_branch: entry.default_branch.clone(),
205                },
206                ResolvedExclusion {
207                    path: entry.path.clone(),
208                    common_dir: common_dir.to_path_buf(),
209                    excluded: entry.excluded,
210                },
211            ))
212        })
213        .unzip()
214}
215
216/// The entry that applies to an entity at `path` sharing `common_dir`: one naming
217/// `path` itself, or else the first declared entry sharing `common_dir`, which is
218/// what lets one entry cover a Repo and every Worktree attached to it while a
219/// Worktree named directly by its own path still beats the entry it would
220/// otherwise inherit ([config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md#per-repo-entries)).
221///
222/// A consequence worth stating rather than working around here: a Submodule's own
223/// common dir (`<parent common dir>/modules/<name>`, per `discovery.rs`'s
224/// `resolve`) is never equal to its parent's, so one entry naming the parent's path
225/// can never also exclude the parent's Submodules. The documented workaround is a
226/// Set's own `exclude` glob over the subtree, which keeps the Submodule out of
227/// discovery entirely rather than merely marking it excluded here.
228fn find_entry<'a, T: ResolvedEntry>(
229    entries: &'a [T],
230    path: &Path,
231    common_dir: &Path,
232) -> Option<&'a T> {
233    entries
234        .iter()
235        .find(|entry| entry.path() == path)
236        .or_else(|| {
237            entries
238                .iter()
239                .find(|entry| entry.common_dir() == common_dir)
240        })
241}
242
243/// Whether an entity at `path` sharing `common_dir` is excluded by `exclusions`: the one
244/// place the table's own [`EntityState::excluded`] is derived, read at entity creation, at
245/// [`Core::probe_now`] and again whenever [`Core::set_exclusions`] replaces the list.
246fn excluded_by(exclusions: &[ResolvedExclusion], path: &Path, common_dir: &Path) -> bool {
247    find_entry(exclusions, path, common_dir).is_some_and(|entry| entry.excluded)
248}
249
250/// Whether a Generation's dispatch probes `kind` at all: always for a Repo or a
251/// Worktree, only while `show_submodules` is on for a Submodule
252/// ([discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md)'s
253/// "Showing Submodules": "the flag decides... whether they are probed"). Exhaustive over
254/// `Kind`, so a fourth variant added later must be named here rather than silently
255/// falling into either branch.
256fn dispatches_kind(kind: Kind, show_submodules: bool) -> bool {
257    match kind {
258        Kind::Repo | Kind::Worktree => true,
259        Kind::Submodule => show_submodules,
260    }
261}
262
263/// The periodic fetch's own crossing data
264/// ([config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md)'s
265/// `[fetch]` table): whether it runs at all, its cadence, and how many run at once.
266/// Plain bounding data, not the mutating mechanism itself: `enabled: false` here is
267/// what keeps the cycle from running, not the absence of any machinery to run it.
268#[derive(Debug, Clone)]
269pub struct FetchSpec {
270    pub enabled: bool,
271    pub interval: Duration,
272    pub concurrency: usize,
273}
274
275/// The fast-forward-only auto-update's own crossing data
276/// ([config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md)'s
277/// `[auto_update]` table): whether it runs at all.
278///
279/// Plain bounding data, not the mutating mechanism, for the same reason [`FetchSpec`]
280/// is. Carries no interval or concurrency of its own, because it never ticks on its
281/// own clock; it rides the periodic fetch cycle [`FetchSpec`] already schedules, so
282/// `enabled: true` here is inert while `FetchSpec::enabled` is false.
283#[derive(Debug, Clone, Copy)]
284pub struct AutoUpdateSpec {
285    pub enabled: bool,
286}
287
288/// The most recent periodic-fetch cycle's own failures, read fresh through
289/// [`Core::fetch_failures`] the same way [`Core::discovery_warning`] and
290/// [`Core::vanished_count`] are: never latched, so a cycle where every fetch
291/// succeeds leaves this empty rather than carrying a stale failure forward from an
292/// earlier one. Per-repository independence is unaffected: one entry here is one
293/// repository `run_fetch_cycle` could not reach, never a reason another repository's
294/// own fetch was skipped.
295///
296/// Carries the path and the underlying `FetchError`'s own text, for
297/// a consumer's log; a consumer's own screen-facing warning is expected to surface
298/// only `failed.len()`, the precedent `warnings.rs`'s `OnRefreshFailed` already sets
299/// for never putting remote-supplied text on screen.
300#[derive(Debug, Clone, Default, PartialEq, Eq)]
301pub struct FetchFailures {
302    pub failed: Vec<(PathBuf, String)>,
303}
304
305/// One [`Core::attempt_auto_update`] result on a single Repo: the fast-forward-only
306/// auto-update's own five eligibility rules
307/// ([config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md)'s
308/// eligibility rule), flattened into one return type so the built-in `sync` action
309/// ([repo-management.md](https://github.com/paulchiu/repon/blob/main/docs/spec/repo-management.md))
310/// can report every ineligible reason to the user rather than a bare "did nothing".
311#[derive(Debug, Clone, PartialEq, Eq)]
312pub enum AutoUpdateAttempt {
313    /// The working tree or index carried a change of its own.
314    NotClean,
315    /// No branch, no remote, or a branch with no upstream configured.
316    NoUpstream,
317    /// Already level with its upstream: nothing to move.
318    NotBehind,
319    /// The local branch has a commit its upstream does not, so no fast-forward exists.
320    NotFastForward,
321    /// The branch fast-forwarded to its upstream.
322    Updated,
323    /// A git read or write failed partway through.
324    Failed(String),
325}
326
327/// Everything `Core::start` needs, handed as plain data. The core reads no file, no
328/// path and no environment variable: this is the whole crossing, per
329/// `docs/spec/core-api.md`'s "What crosses from config".
330#[derive(Debug, Clone)]
331pub struct CoreSpec {
332    pub set: SetSpec,
333    pub overrides: Vec<RepoOverride>,
334    pub poll_interval: Duration,
335    pub status_stale_after: Duration,
336    pub generation_deadline: Duration,
337    /// The initial reading of the show-submodules preference
338    /// ([discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md)'s
339    /// "Showing Submodules"): whether a dispatched Generation probes a Submodule at all.
340    /// Live-updatable afterwards through [`Core::set_show_submodules`], which is what lets
341    /// toggling it skip a Core rebuild and the rediscovery that would come with one.
342    pub show_submodules: bool,
343    /// The periodic fetch's own bounding data. See [`FetchSpec`].
344    pub fetch: FetchSpec,
345    /// The fast-forward-only auto-update's own bounding data. See [`AutoUpdateSpec`]:
346    /// inert while `fetch.enabled` is itself `false`
347    /// (`Warning::AutoUpdateWithoutFetch` is what tells a config author that
348    /// combination can never fire).
349    pub auto_update: AutoUpdateSpec,
350}
351
352/// One entity's in-flight probe: which Generation dispatched it, and the flag that
353/// generation owns to cancel it. [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)
354/// fixes one `Arc<AtomicBool>` per in-flight entity, passed as `should_interrupt`;
355/// `gix::interrupt::IS_INTERRUPTED` is a process-global static and is never used.
356struct InFlight {
357    generation: u64,
358    cancel: Arc<AtomicBool>,
359}
360
361/// The assembled entity table `Core` owns: the only place a Generation's per-cell
362/// supersession check happens, per ADR 0015.
363struct Table {
364    generation: u64,
365    discovered_at: Timestamp,
366    entities: Vec<EntityState>,
367    index: HashMap<EntityKey, usize>,
368    in_flight: HashMap<EntityKey, InFlight>,
369    /// When each still-live Generation's dispatch started, for the deadline sweep.
370    /// Pruned once nothing is left in flight for a Generation.
371    generation_started_at: HashMap<u64, Instant>,
372    /// Each entity's thread-safe repository handle, opened once by discovery.
373    /// A probe task clones the `Arc` (cheap, a refcount bump) and derives its own
374    /// `Repository` from it via `to_thread_local`, so no task ever shares a
375    /// `Repository` with another one; a missing entry (a Submodule, or a boundary
376    /// that would not open) falls back to opening fresh at probe time.
377    repos: HashMap<EntityKey, Arc<gix::ThreadSafeRepository>>,
378    /// Each entity's gitdir reading as of the previous metadata poll sweep, so the
379    /// next sweep can tell whether any of [`poll::POLLED_GITDIR_ENTRIES`] moved
380    /// ([refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
381    /// "The poll"). Absent for an entity the sweep has never yet reached, which is
382    /// what lets a first sweep record a baseline rather than reporting the whole
383    /// population as having just moved.
384    poll_fingerprints: HashMap<EntityKey, poll::GitdirFingerprint>,
385}
386
387/// A message the dedicated thread's control channel carries; distinct from a tick.
388enum ClockControl {
389    Pause,
390    Resume,
391    Shutdown,
392}
393
394/// A running core: its own table, its own dedicated thread, and the rayon pool it
395/// shares with the rest of the process for probes.
396///
397/// Construction is `start`, never a plain constructor, because it spawns; `Drop`
398/// joins every thread it spawned. The public entry points are exactly `start`,
399/// `refresh`, `probe_now`, `snapshot`, `try_settle`, `dismiss`, `pause`, `resume`,
400/// `discovery_warning` and `run_action` (see its own doc comment).
401pub struct Core {
402    table: Arc<RwLock<Table>>,
403    /// Resolved once at `start` and never mutated afterwards: `default_branch` is a probe
404    /// input, so moving it needs the rediscovery a rebuilt `Core` does
405    /// ([config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md#reload)).
406    overrides: Arc<Vec<ResolvedOverride>>,
407    /// The live `exclude` half of the same `[[repo]]` entries, replaced wholesale by
408    /// [`Core::set_exclusions`] with no rebuild and no rediscovery, the same shape
409    /// `show_submodules` already has: `exclude` decides only whether an operation may reach
410    /// a row that is discovered and listed either way, so it is an operate-time filter over
411    /// a table that is already correct
412    /// ([repo-management.md](https://github.com/paulchiu/repon/blob/main/docs/spec/repo-management.md)'s
413    /// "Writing config").
414    exclusions: Arc<RwLock<Vec<ResolvedExclusion>>>,
415    /// The Set `start` was given, retained so `refresh` can re-run discovery over
416    /// the same bounding specification at the head of every Generation
417    /// ([discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md),
418    /// [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)).
419    /// Immutable for the same reason `overrides` is: a Set's `roots` or globs
420    /// changing is a config reload, which re-derives a whole new `Core` rather
421    /// than mutating this one in place.
422    set: SetSpec,
423    /// Set once discovery abandons a walk, and never cleared for the life of this
424    /// `Core`: it takes the Set out of the automatic refresh path, since
425    /// re-running a thirty-second walk at the head of every Generation is not a
426    /// degraded mode worth paying for.
427    discovery_manual: Arc<AtomicBool>,
428    /// How long a re-run discovery walk may run before the still-walking warning
429    /// fires; real value is one second outside a test.
430    discovery_warn_after: Duration,
431    /// How long a re-run discovery walk may run before it is abandoned, in nanoseconds;
432    /// real value is [`discovery::ABANDON_AFTER`] outside a test. Shared and atomic so a
433    /// test can tighten it after `start`, rather than racing one deadline against both a
434    /// walk that must survive and a walk that must not.
435    discovery_abandon_after: Arc<AtomicU64>,
436    /// The live show-submodules preference a dispatched Generation reads: `true` once
437    /// [`Core::set_show_submodules`] last set it that way, `CoreSpec::show_submodules` until
438    /// then. Atomic and shared with every `RefreshHandles` clone so toggling it needs no
439    /// rebuild and dispatches nothing of its own
440    /// ([discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md)'s
441    /// "Showing Submodules": "toggling is instant, because nothing needs discovering").
442    show_submodules: Arc<AtomicBool>,
443    settle_gate: Arc<SettleGate>,
444    control: Sender<ClockControl>,
445    clock_thread: Option<JoinHandle<()>>,
446    /// Set by the dedicated thread's discovery-slow watcher if `start`'s one walk
447    /// ran a full second without finishing, and by a later re-run's own abandon
448    /// path. Read through [`Core::discovery_warning`], the UI's shared warning
449    /// slot's one entry point onto discovery, per
450    /// [discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md).
451    discovery_warning: Arc<Mutex<Option<String>>>,
452    /// Reset to zero at the start of every `refresh`, then incremented once per
453    /// distinct common dir among that Generation's dispatched entities whose
454    /// default-branch chain facts are actually computed, as opposed to reused from
455    /// another entity sharing the same common dir. Never persisted across
456    /// Generations, per [ADR 0006](https://github.com/paulchiu/repon/blob/main/docs/adr/0006-no-git-state-cache-session-state-by-name.md):
457    /// the memo cache itself lives only for the lifetime of one `refresh` call.
458    /// Read only by `default_branch_chain_reads_for_test`, which is what proves
459    /// [default-branch.md](https://github.com/paulchiu/repon/blob/main/docs/spec/default-branch.md)'s
460    /// per-common-dir memoisation actually ran rather than merely agreeing by
461    /// coincidence.
462    #[allow(dead_code)] // read only by default_branch_chain_reads_for_test
463    default_branch_chain_reads: Arc<AtomicUsize>,
464    /// The same counter as `default_branch_chain_reads`, for patch equivalence's
465    /// own expensive half ([`patch_equivalence::scan_default_branch`]) instead of
466    /// the default-branch chain's: reset to zero at the start of every `refresh`,
467    /// incremented once per distinct common dir whose default-branch commit
468    /// history is actually scanned, as opposed to reused from another entity
469    /// sharing the same common dir this Generation. Never persisted across
470    /// Generations, per [ADR 0006](https://github.com/paulchiu/repon/blob/main/docs/adr/0006-no-git-state-cache-session-state-by-name.md).
471    /// Read only by `patch_identity_reads_for_test`.
472    #[allow(dead_code)] // read only by patch_identity_reads_for_test
473    patch_identity_reads: Arc<AtomicUsize>,
474    /// The bound each actually-run [`patch_equivalence::scan_default_branch`] call
475    /// this Generation was passed, one entry per common dir it ran for, in the
476    /// order those scans ran; cleared at the start of every `refresh`. Recorded
477    /// from inside `patch_identities_for`'s `compute` closure, so this is the value
478    /// the production call site used, not a value a test recomputes independently.
479    /// Read only by `patch_scan_bounds_for_test`.
480    #[allow(dead_code)] // read only by patch_scan_bounds_for_test
481    patch_scan_bounds: Arc<Mutex<Vec<Option<gix::ObjectId>>>>,
482    /// `true` while one Action fan-out's steps are running, `false` otherwise. Guards
483    /// [`Core::run_action`]'s own entry rather than anything a probe touches: only one
484    /// fan-out runs at a time, per [ADR 0018](https://github.com/paulchiu/repon/blob/main/docs/adr/0018-an-action-is-a-fanout-of-pty-backed-steps.md)'s
485    /// "One Action runs at a time".
486    action_running: Arc<AtomicBool>,
487    /// The current fan-out's own reach into its steps' children while `action_running` is
488    /// true, `None` otherwise: what [`Core::hold_action`], [`Core::continue_action`] and
489    /// [`Core::stop_action`] each look up before doing anything, so all three are no-ops
490    /// with no fan-out live. Deliberately its own field rather than folded into `pause`/
491    /// `resume`'s machinery, per [ADR 0018](https://github.com/paulchiu/repon/blob/main/docs/adr/0018-an-action-is-a-fanout-of-pty-backed-steps.md)'s
492    /// own hold and stop verbs: the core is contractually not told why background
493    /// work stopped, and a step's child needs SIGSTOP/SIGTERM/SIGKILL, information `pause`
494    /// must never carry.
495    action_control: Arc<Mutex<Option<Arc<executor::RunControl>>>>,
496    /// Every key `refresh`'s own sequential dispatch loop iterated, in the order it iterated
497    /// them, cleared at the start of every call: this is dispatch order, not completion
498    /// order, recorded synchronously in the loop that decides it, before any `rayon::spawn`
499    /// closure ever runs. [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
500    /// "Scope and order" fixes dispatch order as the one dial phase C has; completion order
501    /// on a concurrent pool is a different, non-deterministic fact this field does not claim
502    /// to answer. Read only by `dispatch_log_for_test`.
503    #[allow(dead_code)] // read only by dispatch_log_for_test
504    dispatch_log: Arc<Mutex<Vec<EntityKey>>>,
505    /// Test-only synchronisation points, keyed by entity, letting a test hold the
506    /// dispatch loop's state and dirty probes open after that same entity's cheap
507    /// outcomes (branch, sync, default branch) have already landed on the table,
508    /// so [`refresh`]'s two applies can be proven independent with a blocking wait
509    /// rather than a sleep. Always present and normally empty: a Generation reads it
510    /// once per entity as it dispatches that entity, and one never registered here
511    /// resolves to nothing and proceeds exactly as if this field did not exist.
512    /// Registered and read only by the `_for_test` methods below.
513    #[allow(dead_code)] // populated and read only by tests
514    phase_c_gates: Arc<Mutex<HashMap<EntityKey, PhaseCGateHandle>>>,
515    /// The age past which a `Known` `dirty` or `state` cell reads Stale even though
516    /// nothing probed it again: `CoreSpec::status_stale_after`'s own copy, applied
517    /// inside [`Core::snapshot`] rather than by a background sweep, since
518    /// [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
519    /// "Staleness" rules out a global clock-driven one.
520    status_stale_after: Duration,
521    /// Every key the metadata poll's most recent sweep actually re-ran phases A
522    /// and B for, in the order it found them moved, cleared at the start of every
523    /// sweep. Read only by `poll_reprobed_for_test`, which is what proves a
524    /// sweep re-probes the moved entity alone rather than the whole population.
525    #[allow(dead_code)] // read only by poll_reprobed_for_test
526    poll_reprobed: Arc<Mutex<Vec<EntityKey>>>,
527    /// How many metadata-poll sweeps have run in total, whether or not any entity
528    /// had moved. Read only by `poll_sweep_count_for_test`, which is what proves a
529    /// real tick sent through the dedicated thread's own channel reaches the
530    /// sweep at all, distinct from `poll_reprobed` proving what a sweep that found
531    /// movement then did.
532    #[allow(dead_code)] // read only by poll_sweep_count_for_test
533    poll_sweep_count: Arc<AtomicUsize>,
534    /// How many periodic-fetch cycles have run in total, whether or not any
535    /// repository had a remote to fetch: the immediate first cycle plus one per
536    /// `fetch.interval` tick since. Read only by `fetch_cycle_count_for_test`,
537    /// which is what proves the immediate cycle ran without waiting on the
538    /// recurring cadence at all.
539    #[allow(dead_code)] // read only by fetch_cycle_count_for_test
540    fetch_cycle_count: Arc<AtomicUsize>,
541    /// The network's advertised default branch, per common dir, read from a fetch
542    /// handshake's own advertised HEAD alone
543    /// ([default-branch.md](https://github.com/paulchiu/repon/blob/main/docs/spec/default-branch.md)'s
544    /// "The network"): present only once the periodic fetch or
545    /// [`Core::rederive_default_branches`] has actually reached that remote.
546    /// Superseded there, never here on read; consulted by every default-branch
547    /// probe this crate runs, so an answer landed by one persists across every
548    /// later Generation for the life of this `Core`, which is what "supersedes
549    /// the local one for that session" means: never written back to any
550    /// reference, and gone the moment this `Core` is dropped, per ADR 0012.
551    network_default_branch: Arc<Mutex<HashMap<PathBuf, Arc<str>>>>,
552    /// The most recently completed periodic-fetch cycle's own failures, replaced
553    /// wholesale by [`run_fetch_cycle`] every time it runs. Read through
554    /// [`Core::fetch_failures`].
555    fetch_failures: Arc<Mutex<FetchFailures>>,
556    /// Orders every spawned dispatch body this `Core` starts; see
557    /// [`DispatchTurnstile`].
558    turnstile: Arc<DispatchTurnstile>,
559    /// See [`DiscoveryGate`]. `None` on every production path.
560    discovery_gate: Option<DiscoveryGate>,
561}
562
563/// One entity's phase C test gate state, guarded by the paired [`Condvar`] stored
564/// alongside it in [`Core::phase_c_gates`].
565#[derive(Default)]
566struct PhaseCGate {
567    /// Set once this entity's cheap outcomes have been applied to the table.
568    cheap_landed: bool,
569    /// Set by a test once it has observed `cheap_landed` and wants phase C (and
570    /// D) to proceed.
571    may_proceed: bool,
572    /// Set once this entity's phase C/D outcomes have been applied to the table
573    /// and the settle gate decremented for it.
574    finished: bool,
575}
576
577/// A [`PhaseCGate`] shared between the dispatch loop and the `_for_test` methods
578/// that register, wait on and release it.
579type PhaseCGateHandle = Arc<(Mutex<PhaseCGate>, Condvar)>;
580
581impl Core {
582    /// Spawns the dedicated thread, starts the first discovery walk on a thread of
583    /// its own, and returns a running core at once.
584    ///
585    /// The table it returns is empty: discovery lands its rows afterwards, which is what
586    /// lets a consumer claim the terminal and draw a first frame without waiting out a
587    /// walk (refresh.md's "The first frame"). That walk is refresh.md's "Startup"
588    /// Generation as well, dispatched over what it found, so a consumer probes its rows
589    /// by starting a `Core` and never by asking for a second walk of the same tree.
590    /// [`Self::try_settle`] waits for it the way it waits for any other Generation.
591    pub fn start(spec: CoreSpec) -> Core {
592        Self::start_watched(spec).core
593    }
594
595    /// [`Self::start`], keeping the handles `start_internal` hands back.
596    fn start_watched(spec: CoreSpec) -> StartForTest {
597        let interval = spec.poll_interval.max(Duration::from_nanos(1));
598        let ticks = crossbeam_channel::tick(interval);
599        let alive = Arc::new(AtomicBool::new(true));
600        let fetch_start = FetchStart {
601            enabled: spec.fetch.enabled,
602            concurrency: spec.fetch.concurrency.max(1),
603            ticks: if spec.fetch.enabled {
604                crossbeam_channel::tick(spec.fetch.interval.max(Duration::from_nanos(1)))
605            } else {
606                crossbeam_channel::never()
607            },
608        };
609        start_internal(
610            spec,
611            Duration::from_secs(1),
612            discovery::ABANDON_AFTER,
613            ticks,
614            fetch_start,
615            alive,
616            None,
617        )
618    }
619
620    /// [`Self::start`], blocked until the first discovery has landed on the table.
621    ///
622    /// For a test, and for nothing else: `start` returns against an empty table
623    /// now, so a test that reads the table straight afterwards needs this
624    /// rendezvous. It is a join on the discovery thread rather than a poll or a
625    /// sleep, so it carries no deadline of its own.
626    ///
627    /// Gated behind `test-util` (on by default under `cfg(test)` for this crate's own
628    /// tests) so a test-only affordance never ships on the default published surface,
629    /// per [ADR 0021](https://github.com/paulchiu/repon/blob/main/docs/adr/0021-a-release-is-what-the-tag-pipeline-publishes.md).
630    #[cfg(any(test, feature = "test-util"))]
631    pub fn start_discovered(spec: CoreSpec) -> Core {
632        let mut started = Self::start_watched(spec);
633        if let Some(handle) = started.initial_discovery.take() {
634            handle
635                .join()
636                .expect("the first discovery thread should not panic");
637        }
638        started.core
639    }
640
641    /// Starts a new Generation, dispatching a probe for every key in `order` that
642    /// the table already knows, in that order. An empty or unknown-only `order`
643    /// dispatches nothing and carries no other meaning. Returns immediately: the
644    /// probes run on rayon's global pool.
645    pub fn refresh(&self, order: &[EntityKey]) -> Generation {
646        self.refresh_handles().dispatch(order)
647    }
648
649    /// Starts a new Generation over every entity this Generation's own discovery
650    /// leaves in the table, in discovery order.
651    ///
652    /// A Set switch's Generation, per
653    /// [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
654    /// "Switching Set": the caller has just discarded the old Set's rows, so it has no
655    /// order to compute and no keys to name. Unlike [`Self::refresh`], which resolves
656    /// the order the caller handed it, this resolves the order after discovery has run,
657    /// which is what lets it cover rows the caller could not have named. Startup needs
658    /// none of this: [`Self::start`]'s own walk is that Generation. Returns
659    /// immediately, the same way `refresh` does.
660    pub fn refresh_all(&self) -> Generation {
661        self.refresh_handles().dispatch_over_everything()
662    }
663
664    /// Re-derives `default_branch` alone for every key in `keys` already known to
665    /// the table, in a fresh Generation, per
666    /// [default-branch.md](https://github.com/paulchiu/repon/blob/main/docs/spec/default-branch.md)'s
667    /// "A user-triggered re-derive over the Selection ... on demand" and
668    /// [keybindings.md](https://github.com/paulchiu/repon/blob/main/docs/spec/keybindings.md)'s
669    /// `b`. Unlike [`Self::refresh`], this never re-runs discovery and never
670    /// touches any other cell on any entity, known or not: a key outside `keys`
671    /// is left exactly as it was, and so is every cell but `default_branch` on a
672    /// key inside it.
673    ///
674    /// Runs the local chain exactly as any other refresh would, then a
675    /// handshake-only network probe per distinct common dir among `keys`
676    /// (`fetch::probe_remote_head`): no pack requested and no ref updated, which is
677    /// "without fetching". Its answer, once landed on `network_default_branch`, is
678    /// what `supersede_with_network` applies here and on every later probe of that
679    /// common dir for the life of this `Core`.
680    ///
681    /// Returns immediately, which is also why a stalled remote has nothing to end
682    /// it here: the deadline sweep is per entity, not per cell, so this is on the
683    /// open-questions register rather than closed. The probes run on a plain thread, never rayon's
684    /// global pool, for the reason `fetch::run_bounded`'s own doc comment gives
685    /// the periodic fetch's identical choice: a remote blocked on the network
686    /// for seconds must never take a worker away from the pool every other
687    /// probe shares.
688    pub fn rederive_default_branches(&self, keys: &[EntityKey]) -> Generation {
689        let generation = {
690            let mut table = self.table.write().unwrap();
691            table.generation += 1;
692            Generation::new(table.generation)
693        };
694
695        let dispatched: Vec<RederiveCandidate> = {
696            let mut table = self.table.write().unwrap();
697            let mut dispatched = Vec::new();
698            for key in keys {
699                let Some(&idx) = table.index.get(key) else {
700                    continue;
701                };
702                table.entities[idx].default_branch.begin_probe();
703                let common_dir = Arc::clone(&table.entities[idx].common_dir);
704                let override_branch = find_entry(&self.overrides, key.path(), &common_dir)
705                    .and_then(|entry| entry.default_branch.clone());
706                let repo = table.repos.get(key).cloned();
707                let kind = table.entities[idx].kind;
708                dispatched.push(RederiveCandidate {
709                    key: key.clone(),
710                    path: key.path().to_path_buf(),
711                    common_dir,
712                    repo,
713                    override_branch,
714                    kind,
715                });
716            }
717            dispatched
718        };
719
720        if dispatched.is_empty() {
721            return generation;
722        }
723
724        begin_probes_owed(&self.settle_gate, dispatched.len());
725
726        let table = Arc::clone(&self.table);
727        let settle_gate = Arc::clone(&self.settle_gate);
728        let network_default_branch = Arc::clone(&self.network_default_branch);
729        thread::spawn(move || {
730            let common_dirs: HashSet<Arc<Path>> = dispatched
731                .iter()
732                .map(|candidate| Arc::clone(&candidate.common_dir))
733                .collect();
734            probe_network_default_branches(&common_dirs, &network_default_branch);
735
736            // Scoped to this one call, never shared with a concurrent `refresh`'s own
737            // memo: the local chain's own per-common-dir facts are cheap enough
738            // (`default-branch.md`'s "about 20ms") that a fresh cache here costs this
739            // call nothing a shared one would have saved.
740            let chain_cache: ChainFactsCache = Mutex::new(HashMap::new());
741            let chain_reads = AtomicUsize::new(0);
742            let never_cancelled = AtomicBool::new(false);
743
744            for candidate in dispatched {
745                let RederiveCandidate {
746                    key,
747                    path,
748                    common_dir,
749                    repo,
750                    override_branch,
751                    kind,
752                } = candidate;
753                let network_branch = network_branch_for(&network_default_branch, &common_dir);
754                let resolution = probe_default_branch_memoised(
755                    &path,
756                    repo.as_deref(),
757                    &common_dir,
758                    DefaultBranchHints {
759                        override_branch: override_branch.as_deref(),
760                        network_branch: network_branch.as_deref(),
761                    },
762                    kind,
763                    &never_cancelled,
764                    &ChainFactsMemo {
765                        cache: &chain_cache,
766                        reads: &chain_reads,
767                    },
768                );
769                {
770                    let mut table = table.write().unwrap();
771                    if let (Some(&idx), Some(resolution)) = (table.index.get(&key), resolution) {
772                        table.entities[idx].apply_default_branch_resolution(generation, resolution);
773                    }
774                }
775                complete_one(&settle_gate);
776            }
777        });
778
779        generation
780    }
781
782    /// Clones out every `Arc` a Generation's dispatch reads, plus the plain data
783    /// ([`SetSpec`], the two durations) it cannot share by reference: a handful of
784    /// refcount bumps, never a copy of the table itself. This is what lets
785    /// [`run_action`](Core::run_action)'s completion, which runs on a plain thread
786    /// this `Core` does not own and outlives the `&self` borrow that started it,
787    /// start the one normal Generation `docs/spec/actions.md`'s "Refreshing around a
788    /// run" promises through the exact same [`RefreshHandles::dispatch`] `refresh`
789    /// itself calls, rather than a second, drifting copy of its body.
790    fn refresh_handles(&self) -> RefreshHandles {
791        RefreshHandles {
792            table: Arc::clone(&self.table),
793            overrides: Arc::clone(&self.overrides),
794            exclusions: Arc::clone(&self.exclusions),
795            set: self.set.clone(),
796            discovery_manual: Arc::clone(&self.discovery_manual),
797            discovery_warn_after: self.discovery_warn_after,
798            discovery_abandon_after: Arc::clone(&self.discovery_abandon_after),
799            discovery_warning: Arc::clone(&self.discovery_warning),
800            show_submodules: Arc::clone(&self.show_submodules),
801            settle_gate: Arc::clone(&self.settle_gate),
802            default_branch_chain_reads: Arc::clone(&self.default_branch_chain_reads),
803            patch_identity_reads: Arc::clone(&self.patch_identity_reads),
804            patch_scan_bounds: Arc::clone(&self.patch_scan_bounds),
805            dispatch_log: Arc::clone(&self.dispatch_log),
806            phase_c_gates: Arc::clone(&self.phase_c_gates),
807            network_default_branch: Arc::clone(&self.network_default_branch),
808            turnstile: Arc::clone(&self.turnstile),
809            discovery_gate: self.discovery_gate.clone(),
810        }
811    }
812
813    /// Re-probes one entity synchronously against the table's current Generation,
814    /// which is what a Launcher return needs before a normal Generation starts.
815    /// Inserts a fresh entity for an unknown key rather than panicking, since a
816    /// caller can otherwise only reach this with a key `snapshot` just handed it.
817    pub fn probe_now(&self, key: &EntityKey) -> EntityState {
818        // An `Arc` rather than a bare flag: [`probe_status`] hands gix an owned clone of
819        // its cancel token the way `refresh`'s own dispatch does, and every other probe
820        // below still takes it as `&AtomicBool` through the same deref coercion.
821        let never_cancelled = Arc::new(AtomicBool::new(false));
822        let (cached_repo, common_dir_hint, probes_state, probes_base, kind) = {
823            let table = self.table.read().unwrap();
824            let repo = table.repos.get(key).cloned();
825            let common_dir = table
826                .index
827                .get(key)
828                .map(|&idx| Arc::clone(&table.entities[idx].common_dir));
829            // An unknown key has no entity yet to ask, and falls back to `false`,
830            // matching the fallback insert below: a freshly inserted `Kind::Repo`
831            // entity's `state` is `NotApplicable` from construction too, and its
832            // `base` is not (only a Submodule's is).
833            let probes_state = table
834                .index
835                .get(key)
836                .map(|&idx| table.entities[idx].probes_state())
837                .unwrap_or(false);
838            let probes_base = table
839                .index
840                .get(key)
841                .map(|&idx| table.entities[idx].probes_base())
842                .unwrap_or(true);
843            // Same fallback as `probes_state`/`probes_base`: an unknown key falls back to
844            // the `Kind::Repo` the insert below actually gives it.
845            let kind = table
846                .index
847                .get(key)
848                .map(|&idx| table.entities[idx].kind)
849                .unwrap_or(Kind::Repo);
850            (repo, common_dir, probes_state, probes_base, kind)
851        };
852        let common_dir_hint = common_dir_hint.unwrap_or_else(|| Arc::from(key.path().join(".git")));
853        let override_branch = find_entry(&self.overrides, key.path(), &common_dir_hint)
854            .and_then(|entry| entry.default_branch.clone());
855        let excluded = excluded_by(
856            &self.exclusions.read().unwrap(),
857            key.path(),
858            &common_dir_hint,
859        );
860
861        let branch_outcome =
862            probe_branch(key.path(), cached_repo.as_deref(), kind, &never_cancelled);
863        let sync_outcome = probe_sync(
864            key.path(),
865            cached_repo.as_deref(),
866            branch_outcome.as_ref().map(|(settled, ..)| settled),
867            kind,
868            &never_cancelled,
869        );
870        let default_branch_outcome = probe_default_branch(
871            key.path(),
872            cached_repo.as_deref(),
873            DefaultBranchHints {
874                override_branch: override_branch.as_deref(),
875                network_branch: network_branch_for(&self.network_default_branch, &common_dir_hint)
876                    .as_deref(),
877            },
878            kind,
879            &never_cancelled,
880        );
881        let base_outcome = if probes_base {
882            probe_base(
883                key.path(),
884                cached_repo.as_deref(),
885                branch_outcome.as_ref().map(|(settled, ..)| settled),
886                default_branch_outcome.as_ref().map(|r| &r.settled),
887                &never_cancelled,
888            )
889        } else {
890            None
891        };
892        let state_outcome = if probes_state {
893            // A single synchronous re-probe shares nothing with any Generation's
894            // dispatch, so a throwaway cache is exactly as much sharing as this
895            // one call needs. Its bound gate has exactly one entity to hear
896            // from: itself, so it never actually waits.
897            let patch_cache: PatchIdentityCache = Mutex::new(HashMap::new());
898            let patch_reads = AtomicUsize::new(0);
899            let patch_scan_bounds = Mutex::new(Vec::new());
900            let gate = BoundGate::new(1);
901            let mut report = GateReport::new(&gate);
902            let memo = PatchEquivalenceMemo {
903                cache: &patch_cache,
904                reads: &patch_reads,
905                scan_bounds: &patch_scan_bounds,
906            };
907            probe_worktree_state(
908                key.path(),
909                cached_repo.as_deref(),
910                default_branch_outcome.as_ref().map(|r| &r.settled),
911                &common_dir_hint,
912                &never_cancelled,
913                &memo,
914                &mut report,
915            )
916        } else {
917            None
918        };
919        let dirty_outcome =
920            probe_status(key.path(), cached_repo.as_deref(), kind, &never_cancelled);
921
922        let mut table = self.table.write().unwrap();
923        let generation = Generation::new(table.generation);
924        let idx = match table.index.get(key).copied() {
925            Some(idx) => idx,
926            None => {
927                let name = display_name(key.path());
928                table.entities.push(EntityState::new(
929                    key.clone(),
930                    name,
931                    common_dir_hint,
932                    Kind::Repo,
933                ));
934                let idx = table.entities.len() - 1;
935                table.index.insert(key.clone(), idx);
936                idx
937            }
938        };
939        table.entities[idx].excluded = excluded;
940        if let Some((settled, in_progress, recent)) = branch_outcome {
941            table.entities[idx].apply_branch_probe(generation, settled, in_progress, recent);
942        }
943        if let Some(settled) = sync_outcome {
944            table.entities[idx].sync.settle(generation, settled);
945        }
946        if let Some(settled) = base_outcome {
947            table.entities[idx].base.settle(generation, settled);
948        }
949        if let Some(resolution) = default_branch_outcome {
950            table.entities[idx].apply_default_branch_resolution(generation, resolution);
951        }
952        if let Some(settled) = state_outcome {
953            table.entities[idx].state.settle(generation, settled);
954        }
955        if let Some(settled) = dirty_outcome {
956            table.entities[idx].dirty.settle(generation, settled);
957        }
958        table.entities[idx].clone()
959    }
960
961    /// Clones the whole table now, without waiting for anything in flight. Ages
962    /// every entity's `dirty` and `state` cells into Stale here, on the clone
963    /// rather than the stored table, so a snapshot stays a pure read: the other
964    /// staleness writer, poll evidence, does mutate the stored table, because a
965    /// detected move is itself a fact worth keeping, but elapsed time is not.
966    pub fn snapshot(&self) -> Snapshot {
967        let table = self.table.read().unwrap();
968        let mut entities = table.entities.clone();
969        for entity in &mut entities {
970            entity.age_status_cells(self.status_stale_after);
971        }
972        Snapshot {
973            generation: Generation::new(table.generation),
974            discovered_at: table.discovered_at,
975            entities,
976        }
977    }
978
979    /// Blocks until nothing is in flight or `within` elapses, then returns a snapshot.
980    /// The machine-readable consumer's whole loop.
981    ///
982    /// `Ok` is a table that actually settled. `Err` is the wait giving up, carrying the
983    /// snapshot as it stood at that moment so a caller that means to degrade still has
984    /// something to degrade with. The two are separate arms rather than one return value
985    /// because they are separate facts: a half-populated table read as a settled one is a
986    /// wrong answer, not a late one, and it reads as a defect several steps downstream with
987    /// nothing left naming the wait.
988    pub fn try_settle(&self, within: Duration) -> Result<Snapshot, Snapshot> {
989        let (lock, cvar) = &*self.settle_gate;
990        let guard = lock.lock().unwrap();
991        let (guard, timeout) = cvar
992            .wait_timeout_while(guard, within, |counts| !counts.is_settled())
993            .unwrap();
994        // Released before the snapshot below, which takes the table lock: holding both at
995        // once is a lock order nothing else in this file takes.
996        drop(guard);
997        let snapshot = self.snapshot();
998        if timeout.timed_out() {
999            Err(snapshot)
1000        } else {
1001            Ok(snapshot)
1002        }
1003    }
1004
1005    /// Blocks until nothing is in flight, panicking once [`liveness::BACKSTOP`] expires.
1006    /// For a test.
1007    ///
1008    /// Takes no deadline, unlike [`Self::try_settle`], because every deadline this ever
1009    /// took was a number guessed against the machine its author had: the wait is on a
1010    /// liveness property ("the Generation I just dispatched lands"), which carries no
1011    /// wall-clock bound of its own, so the only honest bound is the shared backstop.
1012    /// A wait whose *number* is the claim ("nothing arrives within 200ms") is a different
1013    /// wait and belongs on [`Self::try_settle`], which reports an expiry rather than
1014    /// panicking on one.
1015    #[cfg(any(test, feature = "test-util"))]
1016    pub fn settle(&self) -> Snapshot {
1017        self.settle_within(liveness::BACKSTOP)
1018    }
1019
1020    /// [`Self::settle`] against an explicit deadline, so this crate's own tests can
1021    /// exercise the expiry path without waiting out a real backstop. The same seam
1022    /// `liveness::wait_within` gives its module.
1023    #[cfg(any(test, feature = "test-util"))]
1024    fn settle_within(&self, deadline: Duration) -> Snapshot {
1025        self.try_settle(deadline).unwrap_or_else(|_| {
1026            // Read out and released before the panic below: unwinding out of a held guard
1027            // poisons the gate, and every later `lock().unwrap()` on it, `Drop`'s included,
1028            // then panics on the way out and turns a named report into an abort.
1029            let (probes, dispatches) = {
1030                let counts = self.settle_gate.0.lock().unwrap();
1031                (counts.probes, counts.dispatches)
1032            };
1033            liveness::expired(
1034                deadline,
1035                "everything this Core has in flight to land",
1036                &format!("{probes} probe(s) and {dispatches} dispatch(es) still outstanding"),
1037            )
1038        })
1039    }
1040
1041    /// What deleting `key`'s working tree destroys, read fresh right now
1042    /// ([repo-management.md](https://github.com/paulchiu/repon/blob/main/docs/spec/repo-management.md)'s
1043    /// "The confirm gate"). Every one of the three is a git read rather than a fold over this
1044    /// entity's Cells or over the table: the gate is answering "what will accepting this
1045    /// destroy", a Cell carries whatever the last Generation left there, and the table is
1046    /// bounded by the active Set's roots, so a linked Worktree outside them would go
1047    /// unnamed. Both are the wrong tense, or the wrong scope, for a question with no undo.
1048    ///
1049    /// `uncommitted` is both halves of "not in a commit": the index against the working tree
1050    /// (`git::dirty_counts`) and `HEAD` against the index (`git::staged_changes`). The
1051    /// second is the one a `git add` with no commit lands in, and the one the dirty column
1052    /// deliberately never asks about.
1053    ///
1054    /// Errors rather than reporting zero when any read fails, so a gate never says "nothing
1055    /// to lose" because it could not look.
1056    pub fn delete_risk(&self, key: &EntityKey) -> Result<DeleteRisk, git::ProbeError> {
1057        let repo = git::open_thread_safe(key.path())?.to_thread_local();
1058        let dirty = git::dirty_counts(&repo, Arc::new(AtomicBool::new(false)))?;
1059        let staged = git::staged_changes(&repo)?;
1060        let (unpushed_commits, unpushed_branches) = git::unpushed(&repo)?;
1061        let linked_worktrees = git::linked_worktrees(&repo)?;
1062        Ok(DeleteRisk {
1063            uncommitted: dirty.total() > 0 || staged,
1064            unpushed_commits,
1065            unpushed_branches,
1066            linked_worktrees,
1067        })
1068    }
1069
1070    /// The administrative directory `git worktree remove` deletes for `key`'s own linked
1071    /// Worktree, read fresh right now. `Err` when `key`'s own path cannot even be opened as
1072    /// a git repository, which is what "the parent Repo is gone or unreadable" means for a
1073    /// `delete` on a Worktree row
1074    /// ([repo-management.md](https://github.com/paulchiu/repon/blob/main/docs/spec/repo-management.md)'s
1075    /// "What `delete` does to a Worktree"): the caller falls back to removing the working
1076    /// directory alone.
1077    pub fn worktree_admin_dir(&self, key: &EntityKey) -> Result<PathBuf, git::ProbeError> {
1078        let repo = git::open_thread_safe(key.path())?.to_thread_local();
1079        Ok(git::worktree_admin_dir(&repo))
1080    }
1081
1082    /// Every linked Worktree's own working directory pointing into `key`'s Repo, read
1083    /// fresh right now: what deleting a Repo needs to also remove, since each linked
1084    /// Worktree's directory sits outside the Repo's own and is untouched by removing that
1085    /// alone
1086    /// ([repo-management.md](https://github.com/paulchiu/repon/blob/main/docs/spec/repo-management.md)'s
1087    /// "Deleting a Repo also takes its linked Worktrees with it").
1088    pub fn linked_worktree_paths(&self, key: &EntityKey) -> Result<Vec<PathBuf>, git::ProbeError> {
1089        let repo = git::open_thread_safe(key.path())?.to_thread_local();
1090        git::linked_worktree_paths(&repo)
1091    }
1092
1093    /// `delete`'s phase 1: the ignored directories inside the working tree at `path`, read
1094    /// fresh right now
1095    /// ([repo-management.md](https://github.com/paulchiu/repon/blob/main/docs/spec/repo-management.md)'s
1096    /// "Deleting a working tree"). `path` rather than an [`EntityKey`] because a Repo
1097    /// `delete` runs this once for its own working tree and once more for each linked
1098    /// Worktree [`Self::linked_worktree_paths`] names, and only the first of those has a Set
1099    /// row of its own.
1100    pub fn ignored_directories_for_deletion(
1101        &self,
1102        path: &Path,
1103    ) -> Result<Vec<PathBuf>, git::ProbeError> {
1104        let repo = git::open_thread_safe(path)?.to_thread_local();
1105        git::ignored_directories_for_deletion(&repo)
1106    }
1107
1108    /// Attempts the fast-forward-only auto-update on `key`'s own Repo, on demand: exactly
1109    /// `crate::auto_update::attempt`'s own five rules and its own fast-forward, reused
1110    /// rather than a second implementation for the built-in `sync` action to call by hand
1111    /// ([repo-management.md](https://github.com/paulchiu/repon/blob/main/docs/spec/repo-management.md)).
1112    /// Read fresh right now, the same tense [`Self::delete_risk`] reads in: eligibility can
1113    /// change between the gate and the run, so this is never answered from a Cell.
1114    pub fn attempt_auto_update(&self, key: &EntityKey) -> AutoUpdateAttempt {
1115        match crate::auto_update::attempt(key.path()) {
1116            crate::auto_update::Outcome::Ineligible(crate::auto_update::Ineligible::NotClean) => {
1117                AutoUpdateAttempt::NotClean
1118            }
1119            crate::auto_update::Outcome::Ineligible(crate::auto_update::Ineligible::NoUpstream) => {
1120                AutoUpdateAttempt::NoUpstream
1121            }
1122            crate::auto_update::Outcome::Ineligible(crate::auto_update::Ineligible::NotBehind) => {
1123                AutoUpdateAttempt::NotBehind
1124            }
1125            crate::auto_update::Outcome::Ineligible(
1126                crate::auto_update::Ineligible::NotFastForward,
1127            ) => AutoUpdateAttempt::NotFastForward,
1128            crate::auto_update::Outcome::Updated { .. } => AutoUpdateAttempt::Updated,
1129            crate::auto_update::Outcome::Failed(error) => AutoUpdateAttempt::Failed(error),
1130        }
1131    }
1132
1133    /// Runs `action`'s own steps against one Entity, on the calling thread, blocking until
1134    /// they finish rather than handing the run off the way [`Core::run_action`]'s async
1135    /// 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))
1136    /// needs the outcome before the built-in can proceed or report, which nothing running
1137    /// off this thread can give in time. Reuses `run_action_for_entity`, the identical
1138    /// per-step execution `run_action`'s fan-out gives every entity, so a hook and a
1139    /// configured `[[action]]` never diverge in what a step means; writes nothing to the
1140    /// table and touches none of `run_action`'s own state (`action_running`, `action_control`),
1141    /// since a hook is a distinct concern from the one fan-out the palette tracks.
1142    ///
1143    /// `None` when `key` names no Entity this table currently knows.
1144    pub fn run_action_for_entity_blocking(
1145        &self,
1146        action: &ActionSpec,
1147        key: &EntityKey,
1148    ) -> Option<ActionReceipt> {
1149        let entity = {
1150            let table = self.table.read().unwrap();
1151            let idx = *table.index.get(key)?;
1152            table.entities[idx].clone()
1153        };
1154        let control = executor::RunControl::new();
1155        Some(run_action_for_entity(&entity, action, &control, &|_| {}))
1156    }
1157
1158    /// Vends a [`ManagementHandle`]: the `Send + 'static` seam a management run's own
1159    /// per-row work moves onto a background thread through, so it stops blocking the caller
1160    /// the way [`Self::run_action`]'s own fan-out already moves an `Arc<RwLock<Table>>`
1161    /// clone onto its own thread
1162    /// ([0033](https://github.com/paulchiu/repon/blob/main/docs/adr/0033-a-management-run-moves-off-the-calling-thread-and-cancels-between-rows.md)).
1163    pub fn management_handle(&self) -> ManagementHandle {
1164        ManagementHandle {
1165            table: Arc::clone(&self.table),
1166        }
1167    }
1168
1169    /// Drops one entity from the table, cancelling any probe in flight against it.
1170    pub fn dismiss(&self, key: &EntityKey) {
1171        let mut table = self.table.write().unwrap();
1172        if let Some(idx) = table.index.remove(key) {
1173            table.entities.remove(idx);
1174            for position in table.index.values_mut() {
1175                if *position > idx {
1176                    *position -= 1;
1177                }
1178            }
1179        }
1180        table.poll_fingerprints.remove(key);
1181        if let Some(in_flight) = table.in_flight.remove(key) {
1182            in_flight.cancel.store(true, Ordering::Release);
1183            drop(table);
1184            complete_one(&self.settle_gate);
1185        }
1186    }
1187
1188    /// Resolves `order` against the table this instant and splits it into the entities
1189    /// that will actually run and the ones a matching `[[repo]]` `exclude = true`
1190    /// override sweeps in and skips
1191    /// ([config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md#per-repo-entries)).
1192    /// [`Self::run_action`] and [`Self::operable_count`] both call this rather than
1193    /// each keeping its own copy of the `!entity.excluded` test, so a consumer's confirm
1194    /// gate or palette border can never show a count a real run then contradicts
1195    /// ([`docs/spec/actions.md`](https://github.com/paulchiu/repon/blob/main/docs/spec/actions.md)'s
1196    /// "The Selection and the gate": "a wrong count would lie twice"). A key `order`
1197    /// names that no longer resolves (already dismissed, or never discovered) is
1198    /// silently dropped from both halves.
1199    fn partition_operable(&self, order: &[EntityKey]) -> (Vec<EntityState>, Vec<EntityState>) {
1200        let table = self.table.read().unwrap();
1201        order
1202            .iter()
1203            .filter_map(|key| table.index.get(key).map(|&idx| table.entities[idx].clone()))
1204            .partition(|entity| !entity.excluded)
1205    }
1206
1207    /// How many of `order` are operable, i.e. not excluded: [`Self::run_action`]'s own
1208    /// first move is the identical partition this method itself calls, so this is the one
1209    /// number a confirm gate and a palette border can both read without either ever
1210    /// drifting from what that first move keeps. Not the final count a run acts on once
1211    /// `action.when` is `Some`: [`Self::applicability`] narrows this same set further, and
1212    /// [`Self::run_action`] itself only ever runs the rows that narrowing proves.
1213    pub fn operable_count(&self, order: &[EntityKey]) -> usize {
1214        self.partition_operable(order).0.len()
1215    }
1216
1217    /// How many Entities in the live table are Vanished. Reads the table in place rather
1218    /// than through [`Self::snapshot`], so a caller needing only the count does not pay for
1219    /// a clone of the whole table and its staleness pass on every frame.
1220    pub fn vanished_count(&self) -> usize {
1221        self.table
1222            .read()
1223            .unwrap()
1224            .entities
1225            .iter()
1226            .filter(|entity| entity.presence == Presence::Vanished)
1227            .count()
1228    }
1229
1230    /// How an Action's `when` predicate divides the very rows [`Self::operable_count`]
1231    /// counts: the identical partition runs first, so an excluded row is subtracted before
1232    /// the predicate ever sees it and `when` narrows what is left rather than replacing that
1233    /// subtraction
1234    /// ([`docs/spec/actions.md`](https://github.com/paulchiu/repon/blob/main/docs/spec/actions.md)'s
1235    /// "The Selection and the gate"). A palette calls this ahead of time, to report a count
1236    /// before a choice is even made; [`Self::run_action`] runs the identical classification
1237    /// against the identical rows once a choice is confirmed, over `ActionSpec::when` rather
1238    /// than an argument of its own, so a preview and a real run can never disagree.
1239    ///
1240    /// The tally lives here rather than in the consumer for that reason alone:
1241    /// `partition_operable` is this type's own, so a caller cannot count applicability over
1242    /// a set the run would not act on.
1243    pub fn applicability(&self, order: &[EntityKey], when: &Filter) -> Applicability {
1244        when.applicability(self.partition_operable(order).0.iter())
1245    }
1246
1247    /// `true` while one Action fan-out's steps are still running, the consumer-facing read
1248    /// of `action_running` ([ADR 0018](https://github.com/paulchiu/repon/blob/main/docs/adr/0018-an-action-is-a-fanout-of-pty-backed-steps.md)'s
1249    /// "One Action runs at a time"): what a TUI gates `;`, `s`, `1` to `9` and `Ctrl+R`
1250    /// against while a run is in flight
1251    /// ([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)).
1252    pub fn action_running(&self) -> bool {
1253        self.action_running.load(Ordering::Acquire)
1254    }
1255
1256    /// `true` while any refresh-shaped dispatch this `Core` started still owes the table
1257    /// work: a Generation reserved and not yet raised the probes it dispatches, or probes
1258    /// raised and not yet landed, cancelled or timed out. The same gate [`Core::try_settle`]
1259    /// blocks on, read here without blocking, so a consumer can report a Refresh's own
1260    /// progress on screen while it runs rather than waiting for it to finish
1261    /// ([refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)).
1262    /// Covers `refresh`, `refresh_all`, `rederive_default_branches`, `probe_now` and the
1263    /// startup walk alike; an Action's own fan-out never touches this gate, which is what
1264    /// `action_running` reads instead.
1265    pub fn refresh_running(&self) -> bool {
1266        let (lock, _cvar) = &*self.settle_gate;
1267        !lock.lock().unwrap().is_settled()
1268    }
1269
1270    /// Runs `action` across every key in `order` that the table currently knows: each
1271    /// entity's own steps run in order and stop at that entity's first failure, exactly
1272    /// as [config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md)'s
1273    /// "Actions" fixes, with later steps recorded `NotRun` rather than silently skipped.
1274    /// Cross-entity concurrency is bounded by `action.concurrency`, on a
1275    /// `rayon::ThreadPool` this call builds and owns for the run alone, never rayon's
1276    /// global pool the probe fan-out shares: a step blocked in `wait()` removes a
1277    /// worker from whichever pool holds it, and the global pool has none to spare
1278    /// without starving a refresh in flight
1279    /// ([`docs/spec/actions.md`](https://github.com/paulchiu/repon/blob/main/docs/spec/actions.md)'s
1280    /// "The fan-out"). Returns immediately; every step's own child, and this run's
1281    /// completion, run off the calling thread.
1282    ///
1283    /// Returns `false` and touches nothing if a fan-out is already running: only one
1284    /// runs at a time
1285    /// ([ADR 0018](https://github.com/paulchiu/repon/blob/main/docs/adr/0018-an-action-is-a-fanout-of-pty-backed-steps.md)'s
1286    /// "One Action runs at a time"), but the spec settles only that the *palette* goes
1287    /// inert while one is live, never what a second, concurrent call to this seam itself
1288    /// should do. Rejecting outright, rather than queuing, is this call's own choice: a
1289    /// queue needs its own ordering and cancellation story that no acceptance criterion
1290    /// here asks for.
1291    ///
1292    /// An entity in `order` carrying a matching `[[repo]]` `exclude = true`
1293    /// ([config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md#per-repo-entries))
1294    /// never runs a step: it receives a [`Skip::Excluded`] receipt with an empty step list
1295    /// immediately, the one legitimate producer of `Not applicable`
1296    /// ([`docs/spec/actions.md`](https://github.com/paulchiu/repon/blob/main/docs/spec/actions.md)'s
1297    /// "The Selection and the gate"). An unknown key in `order` (already dismissed, or
1298    /// never discovered) is silently skipped, the same fallback `refresh` gives one.
1299    ///
1300    /// `action.when`, once every excluded row is already subtracted, decides what runs
1301    /// rather than only what a palette reported about it: a row it proves is handed a
1302    /// step, a row it disproves gets a [`Skip::Inapplicable`] receipt instead, and a row it
1303    /// cannot settle (a Cell it reads has not settled) gets [`Skip::Unresolved`], since an
1304    /// unprovable row is not a provable one and a run has no basis to touch it either
1305    /// (`docs/spec/actions.md`'s "The Selection and the gate", reversing what that
1306    /// paragraph originally decided). `None` runs every operable row, exactly as before
1307    /// `when` reached this call.
1308    ///
1309    /// Starting a run cancels any in-flight Generation outright rather than sharing
1310    /// execution with it, and completion starts exactly one normal Generation over
1311    /// every entity the table currently knows, not only the ones this run touched.
1312    /// Explicitly not done, for the same reason: re-probing each affected entity
1313    /// synchronously first, the way a Launcher return does with [`Core::probe_now`].
1314    /// Both choices, and their measured cost, are
1315    /// [ADR 0018](https://github.com/paulchiu/repon/blob/main/docs/adr/0018-an-action-is-a-fanout-of-pty-backed-steps.md)'s
1316    /// ("Refreshing around a run").
1317    pub fn run_action(&self, action: ActionSpec, order: &[EntityKey]) -> bool {
1318        if self
1319            .action_running
1320            .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
1321            .is_err()
1322        {
1323            return false;
1324        }
1325
1326        // Criterion 3's first half: starting a run cancels any in-flight Generation
1327        // outright, never sharing the machine with it.
1328        cancel_in_flight(&self.table, &self.settle_gate);
1329
1330        let (operable, excluded) = self.partition_operable(order);
1331
1332        let write_skip_receipts = |entities: &[EntityState], skip: Skip| {
1333            if entities.is_empty() {
1334                return;
1335            }
1336            let finished_at = Timestamp::now();
1337            let mut table = self.table.write().unwrap();
1338            for entity in entities {
1339                if let Some(&idx) = table.index.get(&entity.key) {
1340                    table.entities[idx].last_action = Some(ActionReceipt {
1341                        label: Arc::clone(&action.label),
1342                        steps: Arc::from(Vec::new()),
1343                        skip: Some(skip),
1344                        finished_at,
1345                        running: None,
1346                    });
1347                }
1348            }
1349        };
1350
1351        write_skip_receipts(&excluded, Skip::Excluded);
1352
1353        let included = match &action.when {
1354            Some(when) => {
1355                let Partition {
1356                    applicable,
1357                    inapplicable,
1358                    unresolved,
1359                } = when.partition(operable);
1360                write_skip_receipts(&inapplicable, Skip::Inapplicable);
1361                write_skip_receipts(&unresolved, Skip::Unresolved);
1362                applicable
1363            }
1364            None => operable,
1365        };
1366
1367        let table_handle = Arc::clone(&self.table);
1368        let action_running = Arc::clone(&self.action_running);
1369        let refresh_handles = self.refresh_handles();
1370        // Built synchronously here, before this method ever returns, so a caller that
1371        // calls `stop_action`/`hold_action` the instant `run_action` returns `true` never
1372        // races an empty `action_control` against the fan-out thread below setting it.
1373        let control = executor::RunControl::new();
1374        *self.action_control.lock().unwrap() = Some(Arc::clone(&control));
1375        let action_control = Arc::clone(&self.action_control);
1376        // At least one worker regardless of what `action.concurrency` says: 0 has no
1377        // sensible reading as "run nothing" here (the schema has no floor, only an
1378        // explicit absence of a *ceiling*, `docs/spec/actions.md`'s "The fan-out"), and
1379        // `rayon::ThreadPoolBuilder::num_threads(0)` means "let rayon choose" rather
1380        // than zero workers, which would silently hand this run back to a pool sized by
1381        // something other than `concurrency`.
1382        let concurrency = action.concurrency.max(1) as usize;
1383
1384        // A plain OS thread, never a job on either rayon pool: `RefreshHandles::dispatch`
1385        // below calls `rayon::spawn`, which targets whichever pool the *calling* thread
1386        // already belongs to, so running this orchestration from inside the dedicated
1387        // pool built below would misroute the completion Generation's own probes onto
1388        // it instead of the global pool every other probe uses.
1389        thread::spawn(move || {
1390            let pool = rayon::ThreadPoolBuilder::new()
1391                .num_threads(concurrency)
1392                .build()
1393                .expect("build the Action fan-out's own dedicated pool");
1394
1395            // Caught rather than left to unwind straight out of this thread: a poisoned
1396            // `RwLock` from an unrelated earlier panic is enough to panic the
1397            // `table_handle.write().unwrap()` below, and without `catch_unwind` that
1398            // would skip the flag reset just past it, leaving `action_running` stuck
1399            // true for the life of this `Core`.
1400            let fan_out = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1401                pool.install(|| {
1402                    included.into_par_iter().for_each(|entity| {
1403                        let write_receipt = |receipt: ActionReceipt| {
1404                            let mut table = table_handle.write().unwrap();
1405                            if let Some(&idx) = table.index.get(&entity.key) {
1406                                table.entities[idx].last_action = Some(receipt);
1407                            }
1408                        };
1409                        let receipt =
1410                            run_action_for_entity(&entity, &action, &control, &write_receipt);
1411                        write_receipt(receipt);
1412                    });
1413                });
1414            }));
1415
1416            // scan: action-completion-path begin -- criterion 4: nothing from here to the
1417            // matching end marker below may re-probe an affected entity synchronously the
1418            // way a Launcher return does with `probe_now`; scoped this narrowly (rather
1419            // than a whole-crate scan) because a legitimate Launcher-return caller lives
1420            // in an unrelated call site the same absence claim must not forbid.
1421            // Criterion 6: the fan-out itself ends the moment every entity's own steps
1422            // have finished, panic or not; a second `run_action` racing in from here on
1423            // is racing the completion Generation below, never another fan-out.
1424            action_running.store(false, Ordering::Release);
1425            // This run's own `RunControl` is done being reachable: `hold_action`,
1426            // `continue_action` and `stop_action` all become no-ops again until the next
1427            // `run_action` replaces this with a fresh one.
1428            *action_control.lock().unwrap() = None;
1429
1430            // A panicked fan-out never finished cleanly, so it earns no completion
1431            // Generation once the flag above is safely reset. Swallowed rather than
1432            // resumed: the default panic hook already printed it to stderr before
1433            // `catch_unwind` returned, and this crate carries no logger to hand it to
1434            // instead.
1435            let Ok(()) = fan_out else {
1436                return;
1437            };
1438
1439            // Criterion 3's second half: completion starts one normal Generation over
1440            // every entity currently known, not only the ones this run acted on.
1441            let all_keys: Vec<EntityKey> = table_handle
1442                .read()
1443                .unwrap()
1444                .entities
1445                .iter()
1446                .map(|entity| entity.key.clone())
1447                .collect();
1448            refresh_handles.dispatch(&all_keys);
1449            // scan: action-completion-path end
1450        });
1451
1452        true
1453    }
1454
1455    /// SIGSTOPs every currently live step's process group in the fan-out `run_action`
1456    /// started, reversible with [`Self::continue_action`]: suspending a run is reversible,
1457    /// where cancelling one is not
1458    /// ([`docs/spec/actions.md`](https://github.com/paulchiu/repon/blob/main/docs/spec/actions.md)'s
1459    /// "Cancellation and quit"). A no-op while no fan-out is running. Its own verb,
1460    /// kept apart from [`Self::pause`], which stays ignorant of why background work stopped.
1461    pub fn hold_action(&self) {
1462        if let Some(control) = self.action_control.lock().unwrap().as_ref() {
1463            control.hold();
1464        }
1465    }
1466
1467    /// SIGCONTs every currently live step's process group, undoing [`Self::hold_action`]. A
1468    /// no-op while no fan-out is running.
1469    pub fn continue_action(&self) {
1470        if let Some(control) = self.action_control.lock().unwrap().as_ref() {
1471            control.continue_run();
1472        }
1473    }
1474
1475    /// Cancels the fan-out `run_action` started: SIGTERM now to every step's process group
1476    /// still live, SIGKILL after a grace to whichever of those have not exited by then,
1477    /// because SIGTERM is trappable and SIGKILL is not. A step already running when this is
1478    /// called becomes `Cancelled`; so does a step, or a whole entity's run, that had not
1479    /// started, which stays distinct from `NotRun`
1480    /// ([`docs/spec/actions.md`](https://github.com/paulchiu/repon/blob/main/docs/spec/actions.md)'s
1481    /// "Cancellation and quit"). A no-op while no fan-out is running. Its own verb,
1482    /// kept apart from [`Self::pause`] for the same reason [`Self::hold_action`] is.
1483    pub fn stop_action(&self) {
1484        if let Some(control) = self.action_control.lock().unwrap().as_ref() {
1485            control.cancel();
1486        }
1487    }
1488
1489    /// Stops all background work: the dedicated thread stops ticking and every
1490    /// probe currently in flight is cancelled. The core is never told why.
1491    pub fn pause(&self) {
1492        let _ = self.control.send(ClockControl::Pause);
1493    }
1494
1495    /// Restarts the dedicated thread's ticking. Nothing is queued to fire on
1496    /// resume; a normal Generation is the consumer's decision, not this call's.
1497    pub fn resume(&self) {
1498        let _ = self.control.send(ClockControl::Resume);
1499    }
1500
1501    /// The persistent warning a re-run discovery walk leaves behind once it abandons, or
1502    /// `None` while none has. Never cleared once set, the same as `discovery_manual`: the
1503    /// Set stays out of the automatic refresh path for the life of this `Core`. The UI's
1504    /// shared warning slot polls this every frame, since it can turn from `None` to `Some`
1505    /// at any point in the run with no reload involved.
1506    pub fn discovery_warning(&self) -> Option<String> {
1507        self.discovery_warning.lock().unwrap().clone()
1508    }
1509
1510    /// The most recently completed periodic-fetch cycle's own failures, or an
1511    /// empty [`FetchFailures`] once every fetch in that cycle succeeded, or the
1512    /// cycle has never run. The UI's shared warning slot polls this every frame,
1513    /// the same way it polls [`Self::discovery_warning`] and
1514    /// [`Self::vanished_count`], since a later cycle can replace this at any point
1515    /// in the run with no reload involved.
1516    pub fn fetch_failures(&self) -> FetchFailures {
1517        self.fetch_failures.lock().unwrap().clone()
1518    }
1519
1520    /// Sets the live show-submodules preference a Generation's dispatch reads from this
1521    /// point on: whether a Kind::Submodule entity is probed at all
1522    /// ([discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md)'s
1523    /// "Showing Submodules"). Takes effect on the next `refresh`, dispatches nothing of its
1524    /// own and starts no Generation, which is what makes toggling this instant rather than a
1525    /// rebuild: `CoreSpec`'s own `show_submodules` is only this flag's starting value.
1526    pub fn set_show_submodules(&self, show_submodules: bool) {
1527        self.show_submodules
1528            .store(show_submodules, Ordering::Release);
1529    }
1530
1531    /// Writes one receipt per row for work Repon did itself, with no child process anywhere
1532    /// in it: what a Management operation leaves behind
1533    /// ([repo-management.md](https://github.com/paulchiu/repon/blob/main/docs/spec/repo-management.md)'s
1534    /// "Receipts", [`docs/spec/actions.md`](https://github.com/paulchiu/repon/blob/main/docs/spec/actions.md)'s
1535    /// `OwnWork`).
1536    ///
1537    /// The receipt is built here rather than handed in whole, so a consumer supplies only
1538    /// what Repon did and the words for it: `skip` stays `None`, since a refusal is a row
1539    /// that was operated on rather than one of the three ways a row is skipped, `running`
1540    /// stays `None`, since the work is already done, and the step count stays one, since the
1541    /// operation is one act rather than an ordered list.
1542    /// `label` is the operation's own name and doubles as the single step's label; the step's
1543    /// captured output is empty, there being no other program's screen to quote.
1544    ///
1545    /// Starts no Generation and dispatches nothing, for the same reason
1546    /// [`Core::set_exclusions`] does not: a receipt is something Repon did rather than a
1547    /// reading of the world, so nothing here can make a cell any more or less true. A key the
1548    /// table no longer holds is skipped, the same fallback every key-addressed entry point
1549    /// here gives one.
1550    pub fn record_own_work(&self, label: &str, results: &[(EntityKey, OwnWork, Duration)]) {
1551        let label: Arc<str> = Arc::from(label);
1552        let finished_at = Timestamp::now();
1553        let mut table = self.table.write().unwrap();
1554        for (key, work, elapsed) in results {
1555            let Some(&idx) = table.index.get(key) else {
1556                continue;
1557            };
1558            table.entities[idx].last_action = Some(ActionReceipt {
1559                label: Arc::clone(&label),
1560                steps: Arc::from(vec![StepResult {
1561                    label: Arc::clone(&label),
1562                    outcome: StepOutcome::OwnWork(work.clone()),
1563                    output: Arc::from(&b""[..]),
1564                    elapsed: *elapsed,
1565                    elision: None,
1566                    shell: false,
1567                    interactive: false,
1568                }]),
1569                skip: None,
1570                finished_at,
1571                running: None,
1572            });
1573        }
1574    }
1575
1576    /// Replaces the live `exclude` half of `[[repo]]` and re-applies it over every row the
1577    /// table already holds, so the next [`Core::snapshot`] answers with the new reading
1578    /// ([repo-management.md](https://github.com/paulchiu/repon/blob/main/docs/spec/repo-management.md)'s
1579    /// "Writing config": an `ignore` takes effect as soon as this call returns).
1580    ///
1581    /// Starts no Generation, dispatches nothing and rediscovers nothing, for the same reason
1582    /// [`Core::set_show_submodules`] does not: `exclude` decides only whether an operation
1583    /// may reach a row, never what discovery finds or what a probe reads. `default_branch`,
1584    /// the other key a `[[repo]]` entry may carry, is a probe input and is deliberately not
1585    /// moved here; it still needs a rebuilt `Core`.
1586    pub fn set_exclusions(&self, overrides: &[RepoOverride]) {
1587        let (_, resolved) = resolve_entries(overrides);
1588        // Written and released before the table lock is taken, never held across it:
1589        // `rerun_discovery` reads these two in the opposite order.
1590        {
1591            let mut exclusions = self.exclusions.write().unwrap();
1592            *exclusions = resolved.clone();
1593        }
1594        let mut table = self.table.write().unwrap();
1595        for entity in &mut table.entities {
1596            entity.excluded = excluded_by(&resolved, entity.key.path(), &entity.common_dir);
1597        }
1598    }
1599}
1600
1601/// The read-only, path-driven operations a management run's per-row work needs
1602/// (`crates/repon/src/management.rs`'s own `run_one_record`), cloned out of
1603/// [`Core::management_handle`] rather than borrowed from a live `Core`: `Send + 'static`, so
1604/// a caller can move it onto a background thread the way [`Core::run_action`]'s own fan-out
1605/// thread already moves its `Arc<RwLock<Table>>` clone there. Grants none of `Core`'s other
1606/// state (`action_running`, `action_control`, the clock thread): a management run is a
1607/// distinct concern from the one fan-out those track, and this handle's own methods touch
1608/// only the table, exactly as [`Core::run_action_for_entity_blocking`] already does.
1609#[derive(Clone)]
1610pub struct ManagementHandle {
1611    table: Arc<RwLock<Table>>,
1612}
1613
1614impl ManagementHandle {
1615    /// Identical to [`Core::worktree_admin_dir`], against this handle's own table clone.
1616    pub fn worktree_admin_dir(&self, key: &EntityKey) -> Result<PathBuf, git::ProbeError> {
1617        let repo = git::open_thread_safe(key.path())?.to_thread_local();
1618        Ok(git::worktree_admin_dir(&repo))
1619    }
1620
1621    /// Identical to [`Core::linked_worktree_paths`], against this handle's own table clone.
1622    pub fn linked_worktree_paths(&self, key: &EntityKey) -> Result<Vec<PathBuf>, git::ProbeError> {
1623        let repo = git::open_thread_safe(key.path())?.to_thread_local();
1624        git::linked_worktree_paths(&repo)
1625    }
1626
1627    /// Identical to [`Core::ignored_directories_for_deletion`], against this handle's own
1628    /// table clone.
1629    pub fn ignored_directories_for_deletion(
1630        &self,
1631        path: &Path,
1632    ) -> Result<Vec<PathBuf>, git::ProbeError> {
1633        let repo = git::open_thread_safe(path)?.to_thread_local();
1634        git::ignored_directories_for_deletion(&repo)
1635    }
1636
1637    /// Identical to [`Core::attempt_auto_update`].
1638    pub fn attempt_auto_update(&self, key: &EntityKey) -> AutoUpdateAttempt {
1639        match crate::auto_update::attempt(key.path()) {
1640            crate::auto_update::Outcome::Ineligible(crate::auto_update::Ineligible::NotClean) => {
1641                AutoUpdateAttempt::NotClean
1642            }
1643            crate::auto_update::Outcome::Ineligible(crate::auto_update::Ineligible::NoUpstream) => {
1644                AutoUpdateAttempt::NoUpstream
1645            }
1646            crate::auto_update::Outcome::Ineligible(crate::auto_update::Ineligible::NotBehind) => {
1647                AutoUpdateAttempt::NotBehind
1648            }
1649            crate::auto_update::Outcome::Ineligible(
1650                crate::auto_update::Ineligible::NotFastForward,
1651            ) => AutoUpdateAttempt::NotFastForward,
1652            crate::auto_update::Outcome::Updated { .. } => AutoUpdateAttempt::Updated,
1653            crate::auto_update::Outcome::Failed(error) => AutoUpdateAttempt::Failed(error),
1654        }
1655    }
1656
1657    /// Identical to [`Core::run_action_for_entity_blocking`], against this handle's own
1658    /// table clone rather than a live `Core`.
1659    pub fn run_action_for_entity_blocking(
1660        &self,
1661        action: &ActionSpec,
1662        key: &EntityKey,
1663    ) -> Option<ActionReceipt> {
1664        let entity = {
1665            let table = self.table.read().unwrap();
1666            let idx = *table.index.get(key)?;
1667            table.entities[idx].clone()
1668        };
1669        let control = executor::RunControl::new();
1670        Some(run_action_for_entity(&entity, action, &control, &|_| {}))
1671    }
1672}
1673
1674/// Every `Arc` and plain-data field a Generation's dispatch reads, owned rather than
1675/// borrowed: [`Core::refresh_handles`] is the only constructor once a `Core` exists,
1676/// and its own doc comment carries the reason this exists at all. `start_internal`
1677/// builds one directly, since the periodic fetch's own completion Generation needs
1678/// this before there is a `Core` to ask; `Clone` is what lets that one value serve
1679/// both the recurring cadence and the immediate first cycle without a second,
1680/// drifting construction. Field names and types mirror `Core`'s own exactly, so
1681/// [`Self::dispatch`] and [`Self::rerun_discovery`] are `refresh` and
1682/// `rerun_discovery`'s bodies moved verbatim, `self.field` unchanged.
1683#[derive(Clone)]
1684struct RefreshHandles {
1685    table: Arc<RwLock<Table>>,
1686    overrides: Arc<Vec<ResolvedOverride>>,
1687    /// [`Core::exclusions`]'s own clone, so a re-run discovery's newly found rows take
1688    /// whatever `exclude` says right now rather than whatever it said at `start`.
1689    exclusions: Arc<RwLock<Vec<ResolvedExclusion>>>,
1690    set: SetSpec,
1691    discovery_manual: Arc<AtomicBool>,
1692    discovery_warn_after: Duration,
1693    discovery_abandon_after: Arc<AtomicU64>,
1694    discovery_warning: Arc<Mutex<Option<String>>>,
1695    show_submodules: Arc<AtomicBool>,
1696    settle_gate: Arc<SettleGate>,
1697    default_branch_chain_reads: Arc<AtomicUsize>,
1698    patch_identity_reads: Arc<AtomicUsize>,
1699    patch_scan_bounds: Arc<Mutex<Vec<Option<gix::ObjectId>>>>,
1700    dispatch_log: Arc<Mutex<Vec<EntityKey>>>,
1701    phase_c_gates: Arc<Mutex<HashMap<EntityKey, PhaseCGateHandle>>>,
1702    /// [`Core::network_default_branch`]'s own clone: [`run_fetch_cycle`] writes
1703    /// into it once a fetch's own handshake advertises a HEAD, and this
1704    /// dispatch's own default-branch probes read it back the same Generation.
1705    network_default_branch: Arc<Mutex<HashMap<PathBuf, Arc<str>>>>,
1706    /// [`Core::turnstile`]'s own clone, so every dispatch this `Core` starts,
1707    /// wherever it is called from, queues in the one order.
1708    turnstile: Arc<DispatchTurnstile>,
1709    /// [`Core::discovery_gate`]'s own clone; `None` on every production path.
1710    discovery_gate: Option<DiscoveryGate>,
1711}
1712
1713/// Runs the spawned dispatch bodies in the order their Generations were reserved.
1714///
1715/// Reserving the number is what a caller waits for; everything after it happens on
1716/// a thread of its own, and two of those threads reaching the table out of order
1717/// would let an older Generation cancel a newer one's in-flight entries and then
1718/// record itself as the live one, which is refresh.md's supersession rule read
1719/// backwards. A ticket taken under the same lock that mints the Generation, and
1720/// served in ticket order, is what stops that.
1721#[derive(Default)]
1722struct DispatchTurnstile {
1723    /// The ticket whose body may run, and the [`Condvar`] every waiting body sleeps on.
1724    serving: Mutex<u64>,
1725    ready: Condvar,
1726    /// The next ticket to hand out. Only ever read under the table write lock
1727    /// [`RefreshHandles::reserve_generation`] holds, so tickets and Generations
1728    /// are issued in the one order.
1729    next: AtomicU64,
1730}
1731
1732impl DispatchTurnstile {
1733    fn reserve(&self) -> u64 {
1734        self.next.fetch_add(1, Ordering::AcqRel)
1735    }
1736
1737    /// Blocks until `ticket` is the one being served. The returned guard releases
1738    /// the next ticket when it drops, panic included, so one body that unwinds
1739    /// cannot wedge every dispatch after it.
1740    fn take(&self, ticket: u64) -> DispatchTurn<'_> {
1741        let serving = self.serving.lock().unwrap();
1742        drop(
1743            self.ready
1744                .wait_while(serving, |serving| *serving != ticket)
1745                .unwrap(),
1746        );
1747        DispatchTurn {
1748            turnstile: self,
1749            ticket,
1750        }
1751    }
1752}
1753
1754/// One body's turn at the [`DispatchTurnstile`], held for as long as that body runs.
1755struct DispatchTurn<'a> {
1756    turnstile: &'a DispatchTurnstile,
1757    ticket: u64,
1758}
1759
1760impl Drop for DispatchTurn<'_> {
1761    fn drop(&mut self) {
1762        let mut serving = self.turnstile.serving.lock().unwrap();
1763        *serving = self.ticket + 1;
1764        self.turnstile.ready.notify_all();
1765    }
1766}
1767
1768impl RefreshHandles {
1769    /// `Core::refresh`'s whole body, moved here so `run_action`'s completion can call
1770    /// the identical dispatch from a thread that owns no reference to `Core` itself.
1771    ///
1772    /// Reserves this Generation's number and its turnstile place on the calling
1773    /// thread and does everything else, discovery's own walk included, on a thread
1774    /// of its own, the shape [`Core::rederive_default_branches`] already takes: no
1775    /// caller waits out a walk, and every one of them is fire and forget past the
1776    /// number this returns.
1777    fn dispatch(&self, order: &[EntityKey]) -> Generation {
1778        let (generation, ticket) = self.reserve_generation();
1779        begin_dispatch(&self.settle_gate);
1780        let handles = self.clone();
1781        let order = order.to_vec();
1782        thread::spawn(move || {
1783            let _turn = handles.turnstile.take(ticket);
1784            handles.run_generation(&order, generation);
1785            finish_dispatch(&handles.settle_gate);
1786        });
1787        generation
1788    }
1789
1790    /// [`Core::refresh_all`]'s whole body: the same reservation and the same spawned
1791    /// shape as [`Self::dispatch`], with the order read off the table this
1792    /// Generation's own discovery just reconciled rather than taken from a caller.
1793    fn dispatch_over_everything(&self) -> Generation {
1794        let (generation, ticket) = self.reserve_generation();
1795        begin_dispatch(&self.settle_gate);
1796        let handles = self.clone();
1797        thread::spawn(move || {
1798            let _turn = handles.turnstile.take(ticket);
1799            handles.rediscover();
1800            let order: Vec<EntityKey> = handles
1801                .table
1802                .read()
1803                .unwrap()
1804                .entities
1805                .iter()
1806                .map(|entity| entity.key.clone())
1807                .collect();
1808            handles.dispatch_probes(&order, generation);
1809            finish_dispatch(&handles.settle_gate);
1810        });
1811        generation
1812    }
1813
1814    /// Takes this Generation's number and its turnstile ticket under one hold of
1815    /// the table lock, so the two orders can never disagree.
1816    fn reserve_generation(&self) -> (Generation, u64) {
1817        let mut table = self.table.write().unwrap();
1818        table.generation += 1;
1819        (Generation::new(table.generation), self.turnstile.reserve())
1820    }
1821
1822    /// [`Self::dispatch`]'s spawned body: both halves of discovery, then the probe
1823    /// fan-out for `order`.
1824    fn run_generation(&self, order: &[EntityKey], generation: Generation) {
1825        self.rediscover();
1826        self.dispatch_probes(order, generation);
1827    }
1828
1829    /// Both halves of discovery at the head of one Generation, per refresh.md and
1830    /// discovery.md: an entity no longer found becomes Vanished, and one found again
1831    /// (new, or previously Vanished) is Present. Skipped once an earlier walk has
1832    /// abandoned, which takes the Set out of this automatic path until a fresh `Core`
1833    /// starts over different roots.
1834    fn rediscover(&self) {
1835        if !self.discovery_manual.load(Ordering::Acquire) {
1836            self.rerun_discovery();
1837        }
1838    }
1839
1840    /// The probe fan-out alone, against the table as it stands: one rayon task per
1841    /// dispatched entity, exactly as before this Generation's discovery moved off
1842    /// the calling thread. Split out from [`Self::run_generation`] so
1843    /// [`Self::dispatch_over_everything`], which has to resolve its order between the walk
1844    /// and the fan-out, shares this body rather than keeping a second copy of it.
1845    fn dispatch_probes(&self, order: &[EntityKey], generation: Generation) {
1846        // Scoped to this one Generation, per default-branch.md's "memoised per
1847        // common dir within a single refresh generation": a fresh cache every
1848        // call, never carried over, never touched by the previous Generation's
1849        // still-finishing tasks holding their own clone of the old one.
1850        self.default_branch_chain_reads.store(0, Ordering::Release);
1851        self.patch_identity_reads.store(0, Ordering::Release);
1852        self.patch_scan_bounds.lock().unwrap().clear();
1853        self.dispatch_log.lock().unwrap().clear();
1854
1855        let generation_number = generation.value();
1856        let mut table = self.table.write().unwrap();
1857        table
1858            .generation_started_at
1859            .insert(generation_number, Instant::now());
1860
1861        let show_submodules = self.show_submodules.load(Ordering::Acquire);
1862        let mut dispatched = Vec::new();
1863        for key in order {
1864            let Some(&idx) = table.index.get(key) else {
1865                continue;
1866            };
1867            if !dispatches_kind(table.entities[idx].kind, show_submodules) {
1868                // Narrows the work, not merely the view: a hidden Submodule's Cells are
1869                // left exactly as this Generation found them, so a normal Generation pays
1870                // nothing for it (`docs/spec/discovery.md`'s "Showing Submodules").
1871                continue;
1872            }
1873            if let Some(previous) = table.in_flight.remove(key) {
1874                previous.cancel.store(true, Ordering::Release);
1875            }
1876            let cancel = Arc::new(AtomicBool::new(false));
1877            table.in_flight.insert(
1878                key.clone(),
1879                InFlight {
1880                    generation: generation_number,
1881                    cancel: Arc::clone(&cancel),
1882                },
1883            );
1884            begin_probes(&mut table.entities[idx]);
1885            dispatched.push((key.clone(), cancel));
1886        }
1887
1888        if dispatched.is_empty() {
1889            return;
1890        }
1891
1892        begin_probes_owed(&self.settle_gate, dispatched.len());
1893        let repos: Vec<Option<Arc<gix::ThreadSafeRepository>>> = dispatched
1894            .iter()
1895            .map(|(key, _)| table.repos.get(key).cloned())
1896            .collect();
1897        let override_branches: Vec<Option<String>> = dispatched
1898            .iter()
1899            .map(|(key, _)| {
1900                let idx = table.index[key];
1901                let common_dir = &table.entities[idx].common_dir;
1902                find_entry(&self.overrides, key.path(), common_dir)
1903                    .and_then(|entry| entry.default_branch.clone())
1904            })
1905            .collect();
1906        let network_branches: Vec<Option<Arc<str>>> = dispatched
1907            .iter()
1908            .map(|(key, _)| {
1909                let idx = table.index[key];
1910                let common_dir = &table.entities[idx].common_dir;
1911                network_branch_for(&self.network_default_branch, common_dir)
1912            })
1913            .collect();
1914        let common_dirs: Vec<Arc<Path>> = dispatched
1915            .iter()
1916            .map(|(key, _)| Arc::clone(&table.entities[table.index[key]].common_dir))
1917            .collect();
1918        let probes_state: Vec<bool> = dispatched
1919            .iter()
1920            .map(|(key, _)| table.entities[table.index[key]].probes_state())
1921            .collect();
1922        let probes_base: Vec<bool> = dispatched
1923            .iter()
1924            .map(|(key, _)| table.entities[table.index[key]].probes_base())
1925            .collect();
1926        let kinds: Vec<Kind> = dispatched
1927            .iter()
1928            .map(|(key, _)| table.entities[table.index[key]].kind)
1929            .collect();
1930        drop(table);
1931
1932        // Scoped to this dispatch alone: every task below gets its own clone of
1933        // this `Arc`, and once they all finish and drop it, the cache and every
1934        // `ChainFacts` it holds are freed. Nothing here outlives one Generation.
1935        let chain_cache: Arc<ChainFactsCache> = Arc::new(Mutex::new(HashMap::new()));
1936        // Same lifetime as `chain_cache`, one dispatch's worth: patch
1937        // equivalence's own per-common-dir memo, per default-branch.md's "Two
1938        // passes on screen".
1939        let patch_cache: Arc<PatchIdentityCache> = Arc::new(Mutex::new(HashMap::new()));
1940        // One gate per common dir with at least one entity that will run
1941        // `landing::probe` this Generation, sized up front so it is known
1942        // exactly how many entities owe it a report before any of them run;
1943        // see `BoundGate`.
1944        let bound_gates: Arc<HashMap<Arc<Path>, BoundGate>> = Arc::new({
1945            let mut counts: HashMap<Arc<Path>, usize> = HashMap::new();
1946            for (common_dir, probes_state) in common_dirs.iter().zip(&probes_state) {
1947                if *probes_state {
1948                    *counts.entry(Arc::clone(common_dir)).or_insert(0) += 1;
1949                }
1950            }
1951            counts
1952                .into_iter()
1953                .map(|(dir, count)| (dir, BoundGate::new(count)))
1954                .collect()
1955        });
1956
1957        for (
1958            (
1959                (
1960                    (((((key, cancel), repo), override_branch), network_branch), common_dir),
1961                    probes_state,
1962                ),
1963                probes_base,
1964            ),
1965            kind,
1966        ) in dispatched
1967            .into_iter()
1968            .zip(repos)
1969            .zip(override_branches)
1970            .zip(network_branches)
1971            .zip(common_dirs)
1972            .zip(probes_state)
1973            .zip(probes_base)
1974            .zip(kinds)
1975        {
1976            // Recorded here, in this loop's own sequential iteration, rather than in the
1977            // one above: this is the loop whose order a future change (a sort by predicted
1978            // cost, say) would actually be tempted to touch, since it is the one that decides
1979            // each entity's `rayon::spawn` call, not merely which entities were dispatched.
1980            self.dispatch_log.lock().unwrap().push(key.clone());
1981            let path = key.path().to_path_buf();
1982            let table_handle = Arc::clone(&self.table);
1983            let settle_gate = Arc::clone(&self.settle_gate);
1984            let chain_cache = Arc::clone(&chain_cache);
1985            let chain_reads = Arc::clone(&self.default_branch_chain_reads);
1986            let patch_cache = Arc::clone(&patch_cache);
1987            let patch_reads = Arc::clone(&self.patch_identity_reads);
1988            let patch_scan_bounds = Arc::clone(&self.patch_scan_bounds);
1989            let bound_gates = Arc::clone(&bound_gates);
1990            // Resolved once here and moved into the task, which holds no handle on the
1991            // map itself: a probe signals the gate its own Generation was dispatched
1992            // against, so one still running from an earlier Generation can never signal a
1993            // gate registered after that Generation dispatched.
1994            let held_gate = self.phase_c_gates.lock().unwrap().get(&key).cloned();
1995            // scan: probe-fanout-pool begin -- rayon's global pool, not a dedicated one:
1996            // docs/adr/0013's sweep found the width a dedicated pool would need to pick is
1997            // a broad plateau that the global pool's own free default already sits inside
1998            // at every corpus size tried, and is the only width that stayed competitive
1999            // across idle, fetch-sized and Action-sized concurrent load. A dedicated pool
2000            // would cost a second idle thread pool's worth of memory and startup time to
2001            // land somewhere this measurement found no better than free.
2002            rayon::spawn(move || {
2003                let branch_outcome = probe_branch(&path, repo.as_deref(), kind, &cancel);
2004                let sync_outcome = probe_sync(
2005                    &path,
2006                    repo.as_deref(),
2007                    branch_outcome.as_ref().map(|(settled, ..)| settled),
2008                    kind,
2009                    &cancel,
2010                );
2011                let default_branch_outcome = probe_default_branch_memoised(
2012                    &path,
2013                    repo.as_deref(),
2014                    &common_dir,
2015                    DefaultBranchHints {
2016                        override_branch: override_branch.as_deref(),
2017                        network_branch: network_branch.as_deref(),
2018                    },
2019                    kind,
2020                    &cancel,
2021                    &ChainFactsMemo {
2022                        cache: &chain_cache,
2023                        reads: &chain_reads,
2024                    },
2025                );
2026                let base_outcome = if probes_base {
2027                    probe_base(
2028                        &path,
2029                        repo.as_deref(),
2030                        branch_outcome.as_ref().map(|(settled, ..)| settled),
2031                        default_branch_outcome.as_ref().map(|r| &r.settled),
2032                        &cancel,
2033                    )
2034                } else {
2035                    None
2036                };
2037
2038                // Phases A and B land the moment they answer, per refresh.md's "The
2039                // first frame": every cheap column filled within 200ms, never gated
2040                // on phase C or D's much slower answers below. `default_branch_outcome`
2041                // is cloned here rather than moved, since phase D's landing probe
2042                // below still needs to read it.
2043                apply_cheap_probe_outcomes(
2044                    &table_handle,
2045                    &key,
2046                    generation,
2047                    CheapProbeOutcomes {
2048                        branch: branch_outcome,
2049                        sync: sync_outcome,
2050                        base: base_outcome,
2051                        default_branch: default_branch_outcome.clone(),
2052                    },
2053                );
2054
2055                // Test-only: let a test hold phase C and D open here, after the cheap
2056                // outcomes above are already visible on the table, so the two applies'
2057                // independence can be proven by blocking on a Condvar rather than by racing
2058                // a sleep against a probe.
2059                if let Some(gate) = &held_gate {
2060                    let (lock, cvar) = &**gate;
2061                    let mut state = lock.lock().unwrap();
2062                    state.cheap_landed = true;
2063                    cvar.notify_all();
2064                    state = cvar.wait_while(state, |state| !state.may_proceed).unwrap();
2065                    drop(state);
2066                }
2067
2068                let state_outcome = if probes_state {
2069                    let gate = bound_gates
2070                        .get(&common_dir)
2071                        .expect("every probes_state entity's common dir has a gate sized for it");
2072                    let mut report = GateReport::new(gate);
2073                    let memo = PatchEquivalenceMemo {
2074                        cache: &patch_cache,
2075                        reads: &patch_reads,
2076                        scan_bounds: &patch_scan_bounds,
2077                    };
2078                    probe_worktree_state(
2079                        &path,
2080                        repo.as_deref(),
2081                        default_branch_outcome.as_ref().map(|r| &r.settled),
2082                        &common_dir,
2083                        &cancel,
2084                        &memo,
2085                        &mut report,
2086                    )
2087                } else {
2088                    None
2089                };
2090                let dirty_outcome = probe_status(&path, repo.as_deref(), kind, &cancel);
2091                apply_probe_outcome(
2092                    &table_handle,
2093                    &settle_gate,
2094                    &key,
2095                    generation,
2096                    ProbeOutcomes {
2097                        state: state_outcome,
2098                        dirty: dirty_outcome,
2099                    },
2100                );
2101
2102                // The same handle the cheap gate above blocked on, never a second lookup:
2103                // see where it is resolved.
2104                if let Some(gate) = &held_gate {
2105                    let (lock, cvar) = &**gate;
2106                    let mut state = lock.lock().unwrap();
2107                    state.finished = true;
2108                    cvar.notify_all();
2109                }
2110            });
2111            // scan: probe-fanout-pool end
2112        }
2113    }
2114
2115    /// Re-runs both halves of discovery over `self.set` and reconciles the
2116    /// result into the live table, per [discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md)
2117    /// and [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md).
2118    /// The walk and resolve run outside the table lock, since an abandoned walk
2119    /// can take up to thirty seconds; only reconciling the result briefly holds
2120    /// the write lock. Already-known boundaries reuse their cached repository
2121    /// handle rather than reopening it, which is what keeps re-running discovery
2122    /// every Generation from paying every entity's open cost again.
2123    fn rerun_discovery(&self) {
2124        let repos_cache: HashMap<EntityKey, Arc<gix::ThreadSafeRepository>> =
2125            self.table.read().unwrap().repos.clone();
2126
2127        wait_for_discovery_gate(self.discovery_gate.as_ref());
2128        // The watcher is left detached, as it always has been here: nothing on this
2129        // path reads its handle.
2130        let (watch, _watcher) = spawn_discovery_watcher(
2131            self.set.roots.clone(),
2132            &self.discovery_warning,
2133            self.discovery_warn_after,
2134        );
2135        let discovery = run_watched_discovery(
2136            &watch,
2137            &self.set,
2138            &self.discovery_warning,
2139            Duration::from_nanos(self.discovery_abandon_after.load(Ordering::Acquire)),
2140        );
2141        if discovery.abandoned {
2142            self.discovery_manual.store(true, Ordering::Release);
2143        }
2144
2145        let (discovered, gitmodules_failures) =
2146            discovery::resolve_with_cache(&self.set, &discovery.entities, &repos_cache);
2147
2148        // Copied out before the table lock is taken, never read through it: `set_exclusions`
2149        // takes these two locks in the opposite order, and holding one while asking for the
2150        // other is what would let the two deadlock.
2151        let exclusions = self.exclusions.read().unwrap().clone();
2152        let mut table = self.table.write().unwrap();
2153        table.discovered_at = Timestamp::now();
2154        let cancelled = merge_discovery(&mut table, &exclusions, discovered, gitmodules_failures);
2155        drop(table);
2156        if cancelled > 0 {
2157            complete_many(&self.settle_gate, cancelled);
2158        }
2159    }
2160}
2161
2162impl Drop for Core {
2163    /// Cancels whatever this `Core` still has in flight, then joins the dedicated thread.
2164    ///
2165    /// The cancel is what [`Core::pause`] already does, for the same reason
2166    /// [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
2167    /// "Cancellation" gives: an abandoned Generation is cancelled rather than left to
2168    /// finish, since a Set switch rebuilds the `Core` and the outgoing one's fan-out
2169    /// would otherwise contend for the same cores as the incoming one's. A probe already
2170    /// past its own cancel check still runs to completion on rayon's global pool, which
2171    /// is shared process-wide infrastructure rather than a thread this core spawned, so
2172    /// it is not joined here.
2173    fn drop(&mut self) {
2174        cancel_in_flight(&self.table, &self.settle_gate);
2175        let _ = self.control.send(ClockControl::Shutdown);
2176        if let Some(handle) = self.clock_thread.take() {
2177            let _ = handle.join();
2178        }
2179    }
2180}
2181
2182/// `start_internal`'s result: the running core, plus the three handles a test needs
2183/// to make its threading deterministic instead of sleeping. `Core::start` only
2184/// ever reads `core` out of it; the other three fields exist for
2185/// `Core::start_for_test`.
2186pub(crate) struct StartForTest {
2187    pub core: Core,
2188    #[allow(dead_code)] // read only by tests; the plain lib target never builds them
2189    pub clock_alive: Arc<AtomicBool>,
2190    #[allow(dead_code)] // read only by tests; the plain lib target never builds them
2191    pub discovery_watcher: JoinHandle<()>,
2192    /// The thread the first discovery runs on. Joining it is the rendezvous that
2193    /// says the walk finished and its rows reached the table, with no sleep and no
2194    /// poll anywhere in the wait.
2195    #[allow(dead_code)] // read only by tests; the plain lib target never builds them
2196    pub initial_discovery: Option<JoinHandle<()>>,
2197}
2198
2199#[cfg(test)]
2200impl StartForTest {
2201    /// Blocks until the first discovery has landed on the table, then hands this
2202    /// back so a test reads a populated table rather than the empty one `start`
2203    /// itself returns.
2204    fn discovered(mut self) -> Self {
2205        if let Some(handle) = self.initial_discovery.take() {
2206            handle
2207                .join()
2208                .expect("the first discovery thread should not panic");
2209        }
2210        self
2211    }
2212}
2213
2214impl Core {
2215    /// Puts one already-known entity into the in-flight state a real `refresh`
2216    /// dispatch would, without spawning anything to complete it, so a test can
2217    /// drive the deadline sweep through the tick channel alone and prove the sweep
2218    /// runs on a tick rather than on a clock of its own, or prove that `pause`
2219    /// cancels a real in-flight entry from outside this crate.
2220    ///
2221    /// Gated behind `test-util` (on by default under `cfg(test)` for this crate's own
2222    /// tests) so a test-only affordance never ships on the default published surface,
2223    /// per [ADR 0021](https://github.com/paulchiu/repon/blob/main/docs/adr/0021-a-release-is-what-the-tag-pipeline-publishes.md),
2224    /// the same reason `Timestamp::at` is gated.
2225    #[cfg(any(test, feature = "test-util"))]
2226    pub fn begin_untracked_probe_for_test(&self, key: &EntityKey) -> Arc<AtomicBool> {
2227        let mut table = self.table.write().unwrap();
2228        table.generation += 1;
2229        let generation_number = table.generation;
2230        table
2231            .generation_started_at
2232            .insert(generation_number, Instant::now());
2233        if let Some(&idx) = table.index.get(key) {
2234            begin_probes(&mut table.entities[idx]);
2235        }
2236        let cancel = Arc::new(AtomicBool::new(false));
2237        table.in_flight.insert(
2238            key.clone(),
2239            InFlight {
2240                generation: generation_number,
2241                cancel: Arc::clone(&cancel),
2242            },
2243        );
2244        begin_probes_owed(&self.settle_gate, 1);
2245        cancel
2246    }
2247}
2248
2249/// One simulated in-flight Generation, as [`Core::begin_shared_generation_for_test`]
2250/// left it: the Generation itself, and one interrupt flag per key it covers.
2251#[cfg(test)]
2252pub(crate) struct SharedGeneration {
2253    /// The Generation this simulation minted, so a test can name it and its successor
2254    /// rather than the counter values they happen to hold.
2255    pub generation: Generation,
2256    /// One `cancel` flag per covered key, the same handle a real dispatch would hold.
2257    pub cancels: HashMap<EntityKey, Arc<AtomicBool>>,
2258}
2259
2260#[cfg(test)]
2261impl Core {
2262    /// The cached thread-safe repository handle discovery left for `key`, if any,
2263    /// so a test can prove the cache was actually populated and, by comparing
2264    /// `Arc::ptr_eq` across two reads, that a probe reused it rather than
2265    /// replacing it with a freshly opened one.
2266    pub(crate) fn cached_repo_handle_for_test(
2267        &self,
2268        key: &EntityKey,
2269    ) -> Option<Arc<gix::ThreadSafeRepository>> {
2270        self.table.read().unwrap().repos.get(key).cloned()
2271    }
2272
2273    /// How many times the most recent `refresh` actually computed the
2274    /// default-branch chain's per-common-dir facts, as opposed to reusing an
2275    /// already-computed answer for a common dir another dispatched entity already
2276    /// paid for. What proves the per-common-dir memoisation ran at all: two
2277    /// entities agreeing on their resolved default branch proves nothing on its
2278    /// own, since two distinct common dirs can legitimately agree too.
2279    pub(crate) fn default_branch_chain_reads_for_test(&self) -> usize {
2280        self.default_branch_chain_reads.load(Ordering::Acquire)
2281    }
2282
2283    /// How many times the most recent `refresh` actually scanned a common dir's
2284    /// default-branch commit history for patch equivalence, as opposed to
2285    /// reusing an already-computed scan for a common dir another dispatched
2286    /// entity already paid for. The same proof `default_branch_chain_reads_for_test`
2287    /// gives the default-branch chain, for patch equivalence's own memo.
2288    pub(crate) fn patch_identity_reads_for_test(&self) -> usize {
2289        self.patch_identity_reads.load(Ordering::Acquire)
2290    }
2291
2292    /// The bound each actually-run `scan_default_branch` call this Generation
2293    /// used, one entry per common dir it ran for, in run order. Unlike
2294    /// `patch_identity_reads_for_test`, which only proves a scan ran once per
2295    /// common dir, this proves *what* it was bounded by: the deepest merge base
2296    /// among the dispatched siblings, per `BoundGate::deepest`, rather than
2297    /// whichever entity's own merge base happened to reach the scan first.
2298    pub(crate) fn patch_scan_bounds_for_test(&self) -> Vec<Option<gix::ObjectId>> {
2299        self.patch_scan_bounds.lock().unwrap().clone()
2300    }
2301
2302    /// Every key the most recent `refresh` call's own sequential dispatch loop iterated,
2303    /// in that order: dispatch order, proven directly rather than inferred from completion,
2304    /// which a concurrent pool never guarantees (criterion 5's honest half).
2305    pub(crate) fn dispatch_log_for_test(&self) -> Vec<EntityKey> {
2306        self.dispatch_log.lock().unwrap().clone()
2307    }
2308
2309    /// Runs one metadata-poll sweep synchronously on the calling thread: the exact
2310    /// work the dedicated thread's tick arm performs, called directly so a test can
2311    /// prove the sweep's own effects without racing the injected tick channel's
2312    /// delivery to that other thread.
2313    pub(crate) fn poll_once_for_test(&self) {
2314        run_poll_sweep(
2315            &self.table,
2316            &self.overrides,
2317            &self.show_submodules,
2318            &self.poll_reprobed,
2319            &self.poll_sweep_count,
2320            &self.network_default_branch,
2321        );
2322    }
2323
2324    /// Every key the most recent `poll_once_for_test` call actually re-ran phases A
2325    /// and B for, in the order it found them moved: proves "for that entity only"
2326    /// by naming exactly which entities were touched, not merely that one of them
2327    /// was.
2328    pub(crate) fn poll_reprobed_for_test(&self) -> Vec<EntityKey> {
2329        self.poll_reprobed.lock().unwrap().clone()
2330    }
2331
2332    /// How many metadata-poll sweeps have run in total, so a test driving the real
2333    /// dedicated thread through its injected tick channel can prove a tick reached
2334    /// the sweep at all, not only what the sweep did once it ran.
2335    pub(crate) fn poll_sweep_count_for_test(&self) -> usize {
2336        self.poll_sweep_count.load(Ordering::Acquire)
2337    }
2338
2339    /// Registers a closed phase C/D gate for `key`, so the next `refresh` that
2340    /// dispatches it will land its cheap outcomes, then block before touching
2341    /// phase C or D until [`Core::release_phase_c_for_test`] opens the gate.
2342    /// Must be called before the dispatching `refresh`, since a Generation resolves
2343    /// each entity's gate as it dispatches it and its probes signal that one alone.
2344    pub(crate) fn hold_phase_c_for_test(&self, key: &EntityKey) {
2345        self.phase_c_gates.lock().unwrap().insert(
2346            key.clone(),
2347            Arc::new((Mutex::new(PhaseCGate::default()), Condvar::new())),
2348        );
2349    }
2350
2351    /// Blocks the calling thread, with no sleep or poll, until `key`'s cheap
2352    /// outcomes have landed on the table. Panics if `key` has no gate
2353    /// registered, since that means the test forgot [`Core::hold_phase_c_for_test`].
2354    pub(crate) fn wait_phase_c_landed_for_test(&self, key: &EntityKey) {
2355        let gate = self
2356            .phase_c_gates
2357            .lock()
2358            .unwrap()
2359            .get(key)
2360            .cloned()
2361            .expect("hold_phase_c_for_test must be called before waiting on its gate");
2362        let (lock, cvar) = &*gate;
2363        let guard = lock.lock().unwrap();
2364        drop(cvar.wait_while(guard, |state| !state.cheap_landed).unwrap());
2365    }
2366
2367    /// Lets `key`'s held phase C and D proceed. Does not itself wait for them to
2368    /// finish; pair with [`Core::wait_phase_c_finished_for_test`].
2369    pub(crate) fn release_phase_c_for_test(&self, key: &EntityKey) {
2370        let gate = self
2371            .phase_c_gates
2372            .lock()
2373            .unwrap()
2374            .get(key)
2375            .cloned()
2376            .expect("hold_phase_c_for_test must be called before releasing its gate");
2377        let (lock, cvar) = &*gate;
2378        let mut state = lock.lock().unwrap();
2379        state.may_proceed = true;
2380        cvar.notify_all();
2381    }
2382
2383    /// Blocks the calling thread, with no sleep or poll, until `key`'s phase C/D
2384    /// outcome has been applied and the settle gate decremented for it.
2385    pub(crate) fn wait_phase_c_finished_for_test(&self, key: &EntityKey) {
2386        let gate = self
2387            .phase_c_gates
2388            .lock()
2389            .unwrap()
2390            .get(key)
2391            .cloned()
2392            .expect("hold_phase_c_for_test must be called before waiting on its gate");
2393        let (lock, cvar) = &*gate;
2394        let guard = lock.lock().unwrap();
2395        drop(cvar.wait_while(guard, |state| !state.finished).unwrap());
2396    }
2397
2398    /// Blocks the calling thread, with no sleep and no poll, until every Generation
2399    /// reserved so far has finished dispatching: what a test waits on before reading
2400    /// a count a dispatch raises, now that a Generation reserves its number on the
2401    /// calling thread and raises that count on one of its own.
2402    pub(crate) fn wait_dispatched_for_test(&self) {
2403        let (lock, cvar) = &*self.settle_gate;
2404        let guard = lock.lock().unwrap();
2405        drop(
2406            cvar.wait_while(guard, |counts| counts.dispatches > 0)
2407                .unwrap(),
2408        );
2409    }
2410
2411    /// The settle gate's raw outstanding count, so a test can prove a single
2412    /// dispatched entity's split write decrements it exactly once overall,
2413    /// neither twice (an early `settle`) nor zero times (a `settle` that hangs).
2414    pub(crate) fn settle_gate_count_for_test(&self) -> usize {
2415        self.settle_gate.0.lock().unwrap().probes
2416    }
2417
2418    /// `start`, with the tick source and the discovery-slow warning's threshold
2419    /// injected rather than real, so a test drives the dedicated thread's cadence
2420    /// through a channel it controls and never waits out a real second.
2421    pub(crate) fn start_for_test(
2422        spec: CoreSpec,
2423        warn_after: Duration,
2424        ticks: Receiver<Instant>,
2425    ) -> StartForTest {
2426        Self::start_for_test_with_discovery_abandon(
2427            spec,
2428            warn_after,
2429            discovery::ABANDON_AFTER,
2430            ticks,
2431        )
2432    }
2433
2434    /// `start_for_test`, with the discovery abandon deadline also injected, so a
2435    /// test can force a walk to abandon deterministically instead of running one
2436    /// for the real thirty seconds. The periodic fetch is always off here: a test
2437    /// that wants it runs [`Core::start_for_test_with_fetch`] instead, which is
2438    /// what keeps this constructor's own signature free of a feature-gated
2439    /// parameter.
2440    pub(crate) fn start_for_test_with_discovery_abandon(
2441        spec: CoreSpec,
2442        warn_after: Duration,
2443        discovery_abandon_after: Duration,
2444        ticks: Receiver<Instant>,
2445    ) -> StartForTest {
2446        Self::start_for_test_gated(spec, warn_after, discovery_abandon_after, ticks, None)
2447    }
2448
2449    /// `start_for_test_with_discovery_abandon`, with the discovery gate injected: with a
2450    /// closed one, every walk this `Core` starts blocks before it begins, so a caller's own
2451    /// return is observed against a walk that provably has not run.
2452    pub(crate) fn start_for_test_gated(
2453        spec: CoreSpec,
2454        warn_after: Duration,
2455        discovery_abandon_after: Duration,
2456        ticks: Receiver<Instant>,
2457        discovery_gate: Option<DiscoveryGate>,
2458    ) -> StartForTest {
2459        let alive = Arc::new(AtomicBool::new(true));
2460        start_internal(
2461            spec,
2462            warn_after,
2463            discovery_abandon_after,
2464            ticks,
2465            FetchStart {
2466                enabled: false,
2467                concurrency: 1,
2468                ticks: crossbeam_channel::never(),
2469            },
2470            alive,
2471            discovery_gate,
2472        )
2473    }
2474
2475    /// `start_for_test_with_discovery_abandon`, with the periodic fetch's own tick
2476    /// channel injected too, so a test can prove the recurring cadence without
2477    /// waiting out a real `fetch.interval`. `spec.fetch.enabled` still governs
2478    /// whether the immediate first cycle fires; `fetch_ticks` governs every cycle
2479    /// after that.
2480    pub(crate) fn start_for_test_with_fetch(
2481        spec: CoreSpec,
2482        warn_after: Duration,
2483        ticks: Receiver<Instant>,
2484        fetch_ticks: Receiver<Instant>,
2485    ) -> StartForTest {
2486        let alive = Arc::new(AtomicBool::new(true));
2487        let fetch_start = FetchStart {
2488            enabled: spec.fetch.enabled,
2489            concurrency: spec.fetch.concurrency.max(1),
2490            ticks: fetch_ticks,
2491        };
2492        start_internal(
2493            spec,
2494            warn_after,
2495            discovery::ABANDON_AFTER,
2496            ticks,
2497            fetch_start,
2498            alive,
2499            None,
2500        )
2501    }
2502
2503    /// How many periodic-fetch cycles have run in total: the immediate first one
2504    /// plus one per `fetch.interval` tick since, whether or not any repository had
2505    /// a remote to fetch.
2506    pub(crate) fn fetch_cycle_count_for_test(&self) -> usize {
2507        self.fetch_cycle_count.load(Ordering::Acquire)
2508    }
2509
2510    /// Whether an abandoned discovery has already taken this `Core` out of the
2511    /// automatic refresh path, so a test can assert the precondition explicitly
2512    /// rather than infer it from a later refresh's behaviour alone.
2513    /// Tightens the abandon deadline after `start`, so a test can let the first walk
2514    /// finish under a deadline it cannot lose against and still force a later walk to
2515    /// abandon.
2516    #[cfg(test)]
2517    pub(crate) fn set_discovery_abandon_after_for_test(&self, after: Duration) {
2518        self.discovery_abandon_after
2519            .store(after.as_nanos() as u64, Ordering::Release);
2520    }
2521
2522    pub(crate) fn discovery_manual_for_test(&self) -> bool {
2523        self.discovery_manual.load(Ordering::Acquire)
2524    }
2525
2526    /// Puts several already-known entities into the in-flight state of one shared
2527    /// Generation, without spawning anything to complete them and without
2528    /// touching the settle gate, so a test can drive per-entity supersession
2529    /// directly: which keys a later real `refresh` does and does not cover, and
2530    /// what happens to each one's own cancel flag and eventual result.
2531    ///
2532    /// Hands back the Generation it minted rather than only the flags, so the test
2533    /// names that Generation and its successor instead of the counter values they
2534    /// happen to hold.
2535    pub(crate) fn begin_shared_generation_for_test(&self, keys: &[EntityKey]) -> SharedGeneration {
2536        let mut table = self.table.write().unwrap();
2537        table.generation += 1;
2538        let generation_number = table.generation;
2539        table
2540            .generation_started_at
2541            .insert(generation_number, Instant::now());
2542        let mut cancels = HashMap::new();
2543        for key in keys {
2544            if let Some(&idx) = table.index.get(key) {
2545                table.entities[idx].branch.begin_probe();
2546            }
2547            let cancel = Arc::new(AtomicBool::new(false));
2548            table.in_flight.insert(
2549                key.clone(),
2550                InFlight {
2551                    generation: generation_number,
2552                    cancel: Arc::clone(&cancel),
2553                },
2554            );
2555            cancels.insert(key.clone(), cancel);
2556        }
2557        SharedGeneration {
2558            generation: Generation::new(generation_number),
2559            cancels,
2560        }
2561    }
2562
2563    /// Lands one branch probe result for `key` at `generation` through the exact
2564    /// same path a real dispatched probe's cheap outcomes take
2565    /// ([`apply_cheap_probe_outcomes`]), so a test can simulate a result arriving
2566    /// late, out of Generation order, without a second, weaker implementation of
2567    /// the write-time supersession check.
2568    pub(crate) fn apply_probe_result_for_test(
2569        &self,
2570        key: &EntityKey,
2571        generation: Generation,
2572        settled: Settled<Head>,
2573    ) {
2574        apply_cheap_probe_outcomes(
2575            &self.table,
2576            key,
2577            generation,
2578            CheapProbeOutcomes {
2579                branch: Some((settled, None, Vec::new())),
2580                sync: None,
2581                base: None,
2582                default_branch: None,
2583            },
2584        );
2585    }
2586
2587    /// Writes `receipt` directly onto `key`'s `last_action`, bypassing `run_action`
2588    /// entirely: lets a test put an exact, hand-built receipt on a live `Core`'s table
2589    /// without spawning any real child process.
2590    pub(crate) fn set_last_action_for_test(
2591        &self,
2592        key: &EntityKey,
2593        receipt: crate::entity::ActionReceipt,
2594    ) {
2595        let mut table = self.table.write().unwrap();
2596        if let Some(&idx) = table.index.get(key) {
2597            table.entities[idx].last_action = Some(receipt);
2598        }
2599    }
2600}
2601
2602/// One entity's whole Action run: every step in `action.steps`, in order, stopping at
2603/// the first failure, with every step after it recorded `NotRun` rather than silently
2604/// skipped ([config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md)'s
2605/// "Actions", `docs/spec/actions.md`'s "Step outcomes"). Never called for an excluded,
2606/// inapplicable or unresolved entity: [`Core::run_action`] gives those their own `Skip`
2607/// receipt itself and never reaches this function for them.
2608///
2609/// `control` is the same `RunControl` every other entity's run in this fan-out shares:
2610/// checked before every step starts, so a step not yet reached when `control.cancel` fires
2611/// becomes `Cancelled` rather than ever spawning, and again the instant a spawned step's
2612/// `run_step` call returns, so a step that was actually running when cancellation fired
2613/// becomes `Cancelled` regardless of the exit `run_step` itself observed (a signalled child
2614/// has no clean outcome of its own to report). `Cancelled` and `NotRun` are deliberately
2615/// kept apart here: once cancellation is seen, every remaining step (including a step
2616/// already past the "before it starts" check but not yet run) is `Cancelled`, never
2617/// `NotRun`, which stays reserved for being blocked by an earlier failure
2618/// (`docs/spec/actions.md`'s "Step outcomes").
2619///
2620/// `report` is called once per step, immediately before that step starts, with a receipt
2621/// whose `running` names it: the caller writes this straight onto the table, which is what
2622/// lets a still-running step's own label and elapsed time reach a reader before the whole
2623/// entity's run has finished (`docs/spec/actions.md`'s "The run on screen"). The final
2624/// return value is the same shape with `running: None`, the caller's job to write once more.
2625fn run_action_for_entity(
2626    entity: &EntityState,
2627    action: &ActionSpec,
2628    control: &Arc<executor::RunControl>,
2629    report: &dyn Fn(ActionReceipt),
2630) -> ActionReceipt {
2631    let base_env = environment::environment(entity, action.name.as_deref());
2632    let mut failed = false;
2633    let mut cancelled = false;
2634    let mut results: Vec<StepResult> = Vec::with_capacity(action.steps.len());
2635    for step in &action.steps {
2636        if failed || cancelled || control.is_cancelled() {
2637            cancelled = cancelled || control.is_cancelled();
2638            results.push(StepResult {
2639                label: Arc::from(step.argv.join(" ")),
2640                outcome: if cancelled {
2641                    StepOutcome::Cancelled
2642                } else {
2643                    StepOutcome::NotRun
2644                },
2645                output: Arc::from(&b""[..]),
2646                elapsed: Duration::ZERO,
2647                elision: None,
2648                shell: step.shell,
2649                interactive: step.interactive,
2650            });
2651            continue;
2652        }
2653        let label: Arc<str> = Arc::from(step.argv.join(" "));
2654        report(ActionReceipt {
2655            label: Arc::clone(&action.label),
2656            steps: Arc::from(results.clone()),
2657            skip: None,
2658            finished_at: Timestamp::now(),
2659            running: Some(RunningStep {
2660                label: Arc::clone(&label),
2661                started_at: Timestamp::now(),
2662                shell: step.shell,
2663                interactive: step.interactive,
2664            }),
2665        });
2666        // The step's own `env` table is applied after the environment contract's
2667        // set-or-unset pairs, so it overrides the guaranteed set exactly as a
2668        // Launcher's own `env` field already does (`docs/spec/config.md`'s
2669        // "Launchers").
2670        let mut env = base_env.clone();
2671        env.extend(
2672            step.env
2673                .iter()
2674                .map(|(name, value)| (name.clone(), Some(value.clone()))),
2675        );
2676        let mut result = executor::run_step(
2677            &step.argv,
2678            step.shell,
2679            step.interactive,
2680            entity.key.path(),
2681            &env,
2682            control,
2683        );
2684        if control.is_cancelled() {
2685            result.outcome = StepOutcome::Cancelled;
2686            cancelled = true;
2687        } else {
2688            failed = result.outcome.is_failure();
2689        }
2690        results.push(result);
2691    }
2692    ActionReceipt {
2693        label: Arc::clone(&action.label),
2694        steps: Arc::from(results),
2695        skip: None,
2696        finished_at: Timestamp::now(),
2697        running: None,
2698    }
2699}
2700
2701/// A gate a test closes to hold every discovery walk this `Core` starts, at the
2702/// point before the walk begins, so a caller's own return can be observed against a
2703/// walk that provably has not run. `None` on every production path, the same way
2704/// `Core::phase_c_gates` is empty on one.
2705type DiscoveryGate = Arc<(Mutex<bool>, Condvar)>;
2706
2707/// Blocks while `gate` is closed, and returns at once when there is none, which is
2708/// every production path.
2709fn wait_for_discovery_gate(gate: Option<&DiscoveryGate>) {
2710    let Some(gate) = gate else {
2711        return;
2712    };
2713    let (lock, cvar) = &**gate;
2714    let open = lock.lock().unwrap();
2715    drop(cvar.wait_while(open, |open| !*open).unwrap());
2716}
2717
2718/// Opens or closes a [`DiscoveryGate`], waking whatever walk is held on it.
2719#[cfg(test)]
2720fn set_discovery_gate(gate: &DiscoveryGate, open: bool) {
2721    let (lock, cvar) = &**gate;
2722    *lock.lock().unwrap() = open;
2723    cvar.notify_all();
2724}
2725
2726/// What one watched discovery walk and the thread watching it share: the counter
2727/// the walk bumps as it goes, and the flag it sets on finishing.
2728struct DiscoveryWatch {
2729    progress: Arc<AtomicUsize>,
2730    finished: Arc<AtomicBool>,
2731}
2732
2733/// Arms the still-walking watcher for a walk that has not started yet, leaving the
2734/// still-walking warning behind in `discovery_warning` if that walk outruns
2735/// `warn_after`. Separate from [`run_watched_discovery`] so `start_internal` can arm
2736/// it on the calling thread, and hand a test its handle, while the walk it watches
2737/// runs on a thread of its own.
2738fn spawn_discovery_watcher(
2739    roots: Vec<PathBuf>,
2740    discovery_warning: &Arc<Mutex<Option<String>>>,
2741    warn_after: Duration,
2742) -> (DiscoveryWatch, JoinHandle<()>) {
2743    let progress = Arc::new(AtomicUsize::new(0));
2744    let finished = Arc::new(AtomicBool::new(false));
2745    let watcher = thread::spawn({
2746        let progress = Arc::clone(&progress);
2747        let finished = Arc::clone(&finished);
2748        let warning_slot = Arc::clone(discovery_warning);
2749        move || {
2750            if let Some(message) = watch_for_slow_discovery(progress, finished, roots, warn_after) {
2751                *warning_slot.lock().unwrap() = Some(message);
2752            }
2753        }
2754    });
2755    (DiscoveryWatch { progress, finished }, watcher)
2756}
2757
2758/// Runs one discovery boundary walk against `set` under an already-armed `watch`,
2759/// leaving the abandoned-discovery warning in `discovery_warning` if the walk
2760/// abandons past `abandon_after`. Shared by `start_internal`'s first walk and
2761/// `rerun_discovery`'s later ones, so a refresh-triggered abandon runs the same
2762/// wiring `start`'s own walk does, never a parallel copy of it.
2763fn run_watched_discovery(
2764    watch: &DiscoveryWatch,
2765    set: &SetSpec,
2766    discovery_warning: &Arc<Mutex<Option<String>>>,
2767    abandon_after: Duration,
2768) -> discovery::Discovery {
2769    let discovery =
2770        discovery::discover_watched_with_deadline(set, Arc::clone(&watch.progress), abandon_after);
2771    watch.finished.store(true, Ordering::Release);
2772
2773    if discovery.abandoned {
2774        *discovery_warning.lock().unwrap() =
2775            Some(abandoned_discovery_message(discovery.directories_visited));
2776    }
2777
2778    discovery
2779}
2780
2781/// Shared body of `start` and `start_for_test`: builds the empty table, spawns the
2782/// dedicated thread, and starts the first discovery on a thread of its own.
2783fn start_internal(
2784    spec: CoreSpec,
2785    warn_after: Duration,
2786    discovery_abandon_after: Duration,
2787    ticks: Receiver<Instant>,
2788    fetch_start: FetchStart,
2789    alive: Arc<AtomicBool>,
2790    discovery_gate: Option<DiscoveryGate>,
2791) -> StartForTest {
2792    let FetchStart {
2793        enabled: fetch_enabled,
2794        concurrency: fetch_concurrency,
2795        ticks: fetch_ticks,
2796    } = fetch_start;
2797    let discovery_warning = Arc::new(Mutex::new(None));
2798    let discovery_manual = Arc::new(AtomicBool::new(false));
2799
2800    let (overrides, resolved_exclusions) = resolve_entries(&spec.overrides);
2801    let overrides = Arc::new(overrides);
2802    let exclusions = Arc::new(RwLock::new(resolved_exclusions));
2803    let show_submodules = Arc::new(AtomicBool::new(spec.show_submodules));
2804
2805    let table = Arc::new(RwLock::new(Table {
2806        generation: 0,
2807        discovered_at: Timestamp::now(),
2808        entities: Vec::new(),
2809        index: HashMap::new(),
2810        in_flight: HashMap::new(),
2811        generation_started_at: HashMap::new(),
2812        repos: HashMap::new(),
2813        poll_fingerprints: HashMap::new(),
2814    }));
2815
2816    let settle_gate: Arc<SettleGate> =
2817        Arc::new((Mutex::new(SettleCounts::default()), Condvar::new()));
2818    let poll_reprobed = Arc::new(Mutex::new(Vec::new()));
2819    let poll_sweep_count = Arc::new(AtomicUsize::new(0));
2820    let network_default_branch = Arc::new(Mutex::new(HashMap::new()));
2821    let (control, control_rx) = crossbeam_channel::unbounded();
2822    let poll_handles = PollHandles {
2823        overrides: Arc::clone(&overrides),
2824        show_submodules: Arc::clone(&show_submodules),
2825        poll_reprobed: Arc::clone(&poll_reprobed),
2826        poll_sweep_count: Arc::clone(&poll_sweep_count),
2827        network_default_branch: Arc::clone(&network_default_branch),
2828    };
2829
2830    // Hoisted out of the `Core` struct literal below, rather than built inline
2831    // there as before this field existed: `RefreshHandles` needs its own clone of
2832    // each of these, constructed before `Core` takes ownership of the originals.
2833    let discovery_abandon_after_atomic =
2834        Arc::new(AtomicU64::new(discovery_abandon_after.as_nanos() as u64));
2835    let default_branch_chain_reads = Arc::new(AtomicUsize::new(0));
2836    let patch_identity_reads = Arc::new(AtomicUsize::new(0));
2837    let patch_scan_bounds = Arc::new(Mutex::new(Vec::new()));
2838    let dispatch_log = Arc::new(Mutex::new(Vec::new()));
2839    let phase_c_gates = Arc::new(Mutex::new(HashMap::new()));
2840    let fetch_cycle_count = Arc::new(AtomicUsize::new(0));
2841    let fetch_failures = Arc::new(Mutex::new(FetchFailures::default()));
2842    let turnstile = Arc::new(DispatchTurnstile::default());
2843
2844    let fetch_refresh_handles = RefreshHandles {
2845        table: Arc::clone(&table),
2846        overrides: Arc::clone(&overrides),
2847        exclusions: Arc::clone(&exclusions),
2848        set: spec.set.clone(),
2849        discovery_manual: Arc::clone(&discovery_manual),
2850        discovery_warn_after: warn_after,
2851        discovery_abandon_after: Arc::clone(&discovery_abandon_after_atomic),
2852        discovery_warning: Arc::clone(&discovery_warning),
2853        show_submodules: Arc::clone(&show_submodules),
2854        settle_gate: Arc::clone(&settle_gate),
2855        default_branch_chain_reads: Arc::clone(&default_branch_chain_reads),
2856        patch_identity_reads: Arc::clone(&patch_identity_reads),
2857        patch_scan_bounds: Arc::clone(&patch_scan_bounds),
2858        dispatch_log: Arc::clone(&dispatch_log),
2859        phase_c_gates: Arc::clone(&phase_c_gates),
2860        network_default_branch: Arc::clone(&network_default_branch),
2861        turnstile: Arc::clone(&turnstile),
2862        discovery_gate: discovery_gate.clone(),
2863    };
2864    let auto_update_enabled = spec.auto_update.enabled;
2865    let fetch_schedule = FetchSchedule {
2866        concurrency: fetch_concurrency,
2867        ticks: fetch_ticks,
2868        refresh: fetch_refresh_handles.clone(),
2869        cycle_count: Arc::clone(&fetch_cycle_count),
2870        failures: Arc::clone(&fetch_failures),
2871        auto_update_enabled,
2872    };
2873
2874    let clock_thread = spawn_clock_thread(
2875        Arc::clone(&table),
2876        poll_handles,
2877        fetch_schedule,
2878        Arc::clone(&settle_gate),
2879        spec.generation_deadline,
2880        ClockChannels {
2881            control: control_rx,
2882            ticks,
2883            alive: Arc::clone(&alive),
2884        },
2885    );
2886
2887    // Discovery runs here rather than on the calling thread, so `Core::start`
2888    // returns against the empty table above and the consumer can claim the terminal
2889    // and draw before the walk has finished (ADR 0015's "a constructor that spawns
2890    // threads is not a surprise"). This walk is also refresh.md's "Startup"
2891    // Generation, so a launch walks the tree once: the number and the turnstile place
2892    // are reserved here on the calling thread, exactly as every later Generation
2893    // reserves its own, and the walk and the fan-out it orders both run on the
2894    // spawned thread. The debt is recorded before the spawn, so a `settle` called in
2895    // between waits for this Generation rather than returning on an empty table.
2896    let (startup_generation, startup_ticket) = fetch_refresh_handles.reserve_generation();
2897    begin_dispatch(&settle_gate);
2898    let (watch, discovery_watcher) =
2899        spawn_discovery_watcher(spec.set.roots.clone(), &discovery_warning, warn_after);
2900    let initial_discovery = thread::spawn({
2901        let set = spec.set.clone();
2902        let discovery_warning = Arc::clone(&discovery_warning);
2903        let discovery_manual = Arc::clone(&discovery_manual);
2904        let exclusions = Arc::clone(&exclusions);
2905        let table = Arc::clone(&table);
2906        let settle_gate = Arc::clone(&settle_gate);
2907        let fetch_refresh_handles = fetch_refresh_handles.clone();
2908        let fetch_cycle_count = Arc::clone(&fetch_cycle_count);
2909        let fetch_failures = Arc::clone(&fetch_failures);
2910        let discovery_gate = discovery_gate.clone();
2911        move || {
2912            let turn = fetch_refresh_handles.turnstile.take(startup_ticket);
2913            wait_for_discovery_gate(discovery_gate.as_ref());
2914            let discovery =
2915                run_watched_discovery(&watch, &set, &discovery_warning, discovery_abandon_after);
2916            if discovery.abandoned {
2917                discovery_manual.store(true, Ordering::Release);
2918            }
2919
2920            // Discovery's second half: every boundary the walk just found becomes a
2921            // Repo or a Worktree, and each one's own `.gitmodules` (never recursed
2922            // into) names its Submodules. One combined list, with nothing recording
2923            // which half produced a given entry.
2924            let (discovered, gitmodules_failures) = discovery::resolve(&set, &discovery.entities);
2925            let resolved_exclusions = exclusions.read().unwrap().clone();
2926            let order: Vec<EntityKey> = {
2927                let mut table = table.write().unwrap();
2928                // A fresh table has nothing in flight yet, so nothing here is ever
2929                // cancelled: the same reconciliation `refresh` uses later, run once
2930                // against an empty starting point.
2931                merge_discovery(
2932                    &mut table,
2933                    &resolved_exclusions,
2934                    discovered,
2935                    gitmodules_failures,
2936                );
2937                table.discovered_at = Timestamp::now();
2938                table
2939                    .entities
2940                    .iter()
2941                    .map(|entity| entity.key.clone())
2942                    .collect()
2943            };
2944            // Read off the table this walk just reconciled, the same way
2945            // `dispatch_over_everything` resolves its own order: nobody holding the
2946            // empty table `start` returned has a key to name yet.
2947            fetch_refresh_handles.dispatch_probes(&order, startup_generation);
2948            finish_dispatch(&settle_gate);
2949            // Released here rather than at thread exit: the first fetch cycle spawned
2950            // below is not part of this Generation's body.
2951            drop(turn);
2952
2953            // "Fires immediately on being enabled rather than waiting for the first
2954            // tick" ([refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
2955            // "The periodic fetch"): the recurring cadence only ever fires after a full
2956            // `fetch.interval` has elapsed, so the first cycle is dispatched here, once,
2957            // on its own plain thread rather than on the dedicated clock thread, which
2958            // must stay free to keep polling and sweeping deadlines while this cycle
2959            // runs. From inside this thread rather than beside it, because a cycle reads
2960            // the table to know what to fetch and the walk above is what puts anything
2961            // in it.
2962            if fetch_enabled {
2963                let table = Arc::clone(&table);
2964                thread::spawn(move || {
2965                    run_fetch_cycle(
2966                        &table,
2967                        fetch_concurrency,
2968                        &fetch_refresh_handles,
2969                        &fetch_cycle_count,
2970                        &fetch_failures,
2971                        auto_update_enabled,
2972                    );
2973                });
2974            }
2975        }
2976    });
2977
2978    StartForTest {
2979        core: Core {
2980            table,
2981            overrides,
2982            exclusions,
2983            set: spec.set,
2984            discovery_manual,
2985            discovery_warn_after: warn_after,
2986            discovery_abandon_after: discovery_abandon_after_atomic,
2987            show_submodules,
2988            settle_gate,
2989            control,
2990            clock_thread: Some(clock_thread),
2991            discovery_warning,
2992            default_branch_chain_reads,
2993            patch_identity_reads,
2994            patch_scan_bounds,
2995            action_running: Arc::new(AtomicBool::new(false)),
2996            action_control: Arc::new(Mutex::new(None)),
2997            dispatch_log,
2998            phase_c_gates,
2999            status_stale_after: spec.status_stale_after,
3000            poll_reprobed,
3001            poll_sweep_count,
3002            fetch_cycle_count,
3003            network_default_branch,
3004            fetch_failures,
3005            turnstile,
3006            discovery_gate,
3007        },
3008        clock_alive: alive,
3009        discovery_watcher,
3010        initial_discovery: Some(initial_discovery),
3011    }
3012}
3013
3014/// Everything the dedicated thread's tick arm needs for [`run_poll_sweep`] beyond
3015/// the table it already takes, bundled so `spawn_clock_thread` stays within
3016/// clippy's argument limit.
3017struct PollHandles {
3018    overrides: Arc<Vec<ResolvedOverride>>,
3019    show_submodules: Arc<AtomicBool>,
3020    poll_reprobed: Arc<Mutex<Vec<EntityKey>>>,
3021    poll_sweep_count: Arc<AtomicUsize>,
3022    /// [`Core::network_default_branch`]'s own clone, so a poll-triggered re-probe
3023    /// still reflects an already-superseded default branch rather than reverting
3024    /// to the local chain's own answer until the next full refresh.
3025    network_default_branch: Arc<Mutex<HashMap<PathBuf, Arc<str>>>>,
3026}
3027
3028/// What [`start_internal`] needs from `CoreSpec::fetch` to schedule the periodic fetch,
3029/// bundled into one argument rather than three so this crate's own `clippy::too_many_arguments`
3030/// budget has room for it: extracted once at each of `Core::start`'s two callers.
3031struct FetchStart {
3032    enabled: bool,
3033    concurrency: usize,
3034    ticks: Receiver<Instant>,
3035}
3036
3037/// The periodic fetch's own scheduling inputs, threaded through [`start_internal`]
3038/// and [`spawn_clock_thread`] as plain values rather than reading `CoreSpec::fetch`
3039/// directly: extracted once at each of the two callers. Carries no `enabled` flag
3040/// of its own: `ticks` is [`crossbeam_channel::never`] whenever the periodic fetch
3041/// is off, so the arm that reads it simply never fires, the same way the poll's
3042/// own `ticks` does when a test has no interest in it.
3043struct FetchSchedule {
3044    concurrency: usize,
3045    ticks: Receiver<Instant>,
3046    refresh: RefreshHandles,
3047    cycle_count: Arc<AtomicUsize>,
3048    failures: Arc<Mutex<FetchFailures>>,
3049    /// `CoreSpec::auto_update`'s own `enabled` flag, read once at `start` like every
3050    /// other field on [`FetchSchedule`]: the fast-forward-only update carries no
3051    /// interval of its own, so there is no separate tick to gate it on, only this.
3052    auto_update_enabled: bool,
3053}
3054
3055/// The dedicated thread's own control-plane wiring, bundled into one argument so
3056/// [`spawn_clock_thread`] stays within clippy's argument limit: `control` is the
3057/// pause/resume/shutdown channel every `Core` method sends into, `ticks` drives the
3058/// poll and deadline sweep, and `alive` is the flag the thread clears on its way out
3059/// (both for a test to observe and for nothing else, since `Drop` joins the handle
3060/// directly rather than polling this).
3061struct ClockChannels {
3062    control: Receiver<ClockControl>,
3063    ticks: Receiver<Instant>,
3064    alive: Arc<AtomicBool>,
3065}
3066
3067/// The dedicated thread: the metadata poll tick, the Generation deadline sweep and
3068/// the periodic fetch's own tick share this one interval loop, separate from the
3069/// probe pool and from any render loop, so suspending the terminal reschedules
3070/// none of it. Driven by `ticks` and `fetch.ticks` rather than a bare
3071/// `thread::sleep`, which is what a test replaces to make the cadence
3072/// deterministic. The poll and deadline sweep run first on every `ticks` tick,
3073/// both while `!paused`; a fetch cycle runs on every `fetch.ticks` tick, also only
3074/// while `!paused`, so a suspended Repon neither sweeps nor fetches while the user
3075/// is in a Launcher.
3076fn spawn_clock_thread(
3077    table: Arc<RwLock<Table>>,
3078    poll: PollHandles,
3079    fetch: FetchSchedule,
3080    settle_gate: Arc<SettleGate>,
3081    generation_deadline: Duration,
3082    channels: ClockChannels,
3083) -> JoinHandle<()> {
3084    let ClockChannels {
3085        control,
3086        ticks,
3087        alive,
3088    } = channels;
3089    thread::spawn(move || {
3090        let mut paused = false;
3091        loop {
3092            select! {
3093                recv(control) -> message => match message {
3094                    Ok(ClockControl::Pause) => {
3095                        paused = true;
3096                        cancel_in_flight(&table, &settle_gate);
3097                    }
3098                    Ok(ClockControl::Resume) => paused = false,
3099                    Ok(ClockControl::Shutdown) | Err(_) => break,
3100                },
3101                recv(ticks) -> tick => {
3102                    if tick.is_err() {
3103                        break;
3104                    }
3105                    if !paused {
3106                        run_poll_sweep(
3107                            &table,
3108                            &poll.overrides,
3109                            &poll.show_submodules,
3110                            &poll.poll_reprobed,
3111                            &poll.poll_sweep_count,
3112                            &poll.network_default_branch,
3113                        );
3114                        sweep_deadline(&table, &settle_gate, generation_deadline);
3115                    }
3116                }
3117                recv(fetch.ticks) -> tick => {
3118                    if tick.is_err() {
3119                        break;
3120                    }
3121                    if !paused {
3122                        run_fetch_cycle(
3123                            &table,
3124                            fetch.concurrency,
3125                            &fetch.refresh,
3126                            &fetch.cycle_count,
3127                            &fetch.failures,
3128                            fetch.auto_update_enabled,
3129                        );
3130                    }
3131                }
3132            }
3133        }
3134        alive.store(false, Ordering::Release);
3135    })
3136}
3137
3138/// One periodic-fetch cycle: every distinct git common dir this table currently
3139/// knows, not excluded, fetched with pruning, bounded to `concurrency` at once,
3140/// then one normal Generation over every entity the table now knows
3141/// ([refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
3142/// "The periodic fetch": "a finished fetch starts a normal generation"), the exact
3143/// completion path [`Core::run_action`] already uses. `cycle_count` counts every
3144/// call, whether or not any repository had a remote to fetch, so a test driving
3145/// the dedicated thread's own tick channel can prove a tick reached this function
3146/// at all, the same proof [`Core::poll_sweep_count_for_test`] gives the poll.
3147///
3148/// Two things worth recording beside this scheduler rather than only in
3149/// [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md):
3150/// `Gone` is systematically under-reported without this cycle running, because a
3151/// remote-tracking ref only disappears once a prune removes it
3152/// ([`crate::landing`]'s `classify_unmerged_branch` doc comment), so a Repo with
3153/// `fetch.enabled = false` can carry a stale upstream indefinitely and never show
3154/// it. And the cadence itself is unresolved: `fetch.interval`'s default of five
3155/// minutes is [config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md)'s
3156/// stated number, not one this crate has measured against a real population the
3157/// way the poll interval and the generation deadline were.
3158fn run_fetch_cycle(
3159    table: &Arc<RwLock<Table>>,
3160    concurrency: usize,
3161    refresh: &RefreshHandles,
3162    cycle_count: &Arc<AtomicUsize>,
3163    failures: &Arc<Mutex<FetchFailures>>,
3164    auto_update_enabled: bool,
3165) {
3166    cycle_count.fetch_add(1, Ordering::Release);
3167
3168    let common_dirs = distinct_fetchable_common_dirs(table);
3169    let failed: Mutex<Vec<(PathBuf, String)>> = Mutex::new(Vec::new());
3170    crate::fetch::run_bounded(common_dirs, concurrency.max(1), |common_dir| {
3171        let cancel = AtomicBool::new(false);
3172        // Every repository's own fetch result is independent: one credential
3173        // failure or one unreachable remote must never stop the rest of the
3174        // cycle from running, so a per-repository error is swallowed here
3175        // rather than aborting the whole cycle. It is still counted below,
3176        // which is the count this cycle's own [`FetchFailures`] carries.
3177        match crate::fetch::fetch_and_prune(&common_dir, &cancel) {
3178            Ok(outcome) => {
3179                // The handshake this fetch already paid for is what
3180                // [default-branch.md](https://github.com/paulchiu/repon/blob/main/docs/spec/default-branch.md)'s
3181                // "The network" means by "arrives inside a round trip already being
3182                // paid for": landed here, before `refresh.dispatch` below re-runs
3183                // the local chain, so the local answer always computes first and
3184                // this only ever supersedes it. `Unborn` and a missing answer both
3185                // leave any earlier session answer for this common dir untouched,
3186                // since neither is itself a fact worth overwriting one with.
3187                if let Some(crate::fetch::AdvertisedDefaultBranch::Branch(name)) =
3188                    outcome.advertised_default_branch
3189                {
3190                    refresh
3191                        .network_default_branch
3192                        .lock()
3193                        .unwrap()
3194                        .insert(common_dir.clone(), Arc::from(name));
3195                }
3196            }
3197            Err(error) => {
3198                failed
3199                    .lock()
3200                    .unwrap()
3201                    .push((common_dir.clone(), error.to_string()));
3202            }
3203        }
3204    });
3205    *failures.lock().unwrap() = FetchFailures {
3206        failed: failed.into_inner().unwrap(),
3207    };
3208
3209    // The fast-forward-only auto-update rides this cycle rather than a timer of its
3210    // own, per `docs/spec/config.md`'s "Refresh, fetch and auto-update": it can only
3211    // ever act on what the fetch just above learned, so it runs here, after every
3212    // fetch has settled and before the one Generation below reports the result.
3213    // Sequential rather than `fetch::run_bounded`'s own concurrency, since this is a
3214    // mutating pass over a Repo's own working tree and index, not a read against a
3215    // remote: ADR 0002's narrowest-safe-operation rule favours a simple, serial pass
3216    // over throughput a mutation has no need of.
3217    if auto_update_enabled {
3218        for repo_path in repos_eligible_for_auto_update_attempt(table) {
3219            // One Repo's ineligibility or failure never stops another's: the same
3220            // independence the fetch loop above already gives each repository.
3221            let _ = crate::auto_update::attempt(&repo_path);
3222        }
3223    }
3224
3225    let all_keys: Vec<EntityKey> = table
3226        .read()
3227        .unwrap()
3228        .entities
3229        .iter()
3230        .map(|entity| entity.key.clone())
3231        .collect();
3232    refresh.dispatch(&all_keys);
3233}
3234
3235/// Every non-excluded Repo's own working directory, one per distinct common dir the
3236/// table currently knows: the auto-update acts on a Repo's own row, per
3237/// `docs/spec/config.md`'s "acts only on a Repo", so a Worktree sharing that common
3238/// dir is never a candidate here even though it is `distinct_fetchable_common_dirs`'s
3239/// own definition of "fetchable" for the read-only fetch above. Listed, never
3240/// operated on, mirrors the same `excluded` rule the fetch loop's own common-dir
3241/// filter applies, checked here against the Repo entity's own flag rather than any
3242/// Worktree that happens to share its common dir.
3243fn repos_eligible_for_auto_update_attempt(table: &Arc<RwLock<Table>>) -> Vec<PathBuf> {
3244    table
3245        .read()
3246        .unwrap()
3247        .entities
3248        .iter()
3249        .filter(|entity| entity.kind == Kind::Repo && !entity.excluded)
3250        .map(|entity| entity.key.path().to_path_buf())
3251        .collect()
3252}
3253
3254/// Every distinct git common dir a fetch cycle should fetch: deduplicated across
3255/// every entity sharing one (a Repo and its linked Worktrees), and skipped only
3256/// when every entity sharing that common dir is excluded
3257/// ([config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md#per-repo-entries)'s
3258/// "listed, never operated on"), since a Worktree named directly by its own path
3259/// can carry a different `excluded` than an entry it would otherwise inherit.
3260fn distinct_fetchable_common_dirs(table: &Arc<RwLock<Table>>) -> Vec<PathBuf> {
3261    let table = table.read().unwrap();
3262    let mut seen: HashMap<PathBuf, bool> = HashMap::new();
3263    for entity in &table.entities {
3264        let common_dir = entity.common_dir.to_path_buf();
3265        let operable = seen.entry(common_dir).or_insert(false);
3266        *operable = *operable || !entity.excluded;
3267    }
3268    seen.into_iter()
3269        .filter(|(_, operable)| *operable)
3270        .map(|(common_dir, _)| common_dir)
3271        .collect()
3272}
3273
3274/// [`Core::rederive_default_branches`]'s own network half: a handshake-only probe
3275/// per `common_dir`, landing a `Branch` answer on `network_default_branch` for
3276/// [`supersede_with_network`] to read back. `Unborn` and a probe failure both
3277/// leave any earlier session answer for that common dir untouched, the same
3278/// convention [`run_fetch_cycle`] already follows.
3279fn probe_network_default_branches(
3280    common_dirs: &HashSet<Arc<Path>>,
3281    network_default_branch: &Mutex<HashMap<PathBuf, Arc<str>>>,
3282) {
3283    for common_dir in common_dirs {
3284        if let Ok(Some(crate::fetch::AdvertisedDefaultBranch::Branch(name))) =
3285            crate::fetch::probe_remote_head(common_dir)
3286        {
3287            network_default_branch
3288                .lock()
3289                .unwrap()
3290                .insert(common_dir.to_path_buf(), Arc::from(name));
3291        }
3292    }
3293}
3294
3295/// One entity [`Core::rederive_default_branches`] gathered under the table lock,
3296/// everything its own spawned thread needs to re-run the default-branch chain
3297/// without holding that lock while it does: a plain struct rather than a tuple,
3298/// per this crate's own `clippy::type_complexity` budget.
3299struct RederiveCandidate {
3300    key: EntityKey,
3301    path: PathBuf,
3302    common_dir: Arc<Path>,
3303    repo: Option<Arc<gix::ThreadSafeRepository>>,
3304    override_branch: Option<String>,
3305    kind: Kind,
3306}
3307
3308/// One entity as the metadata poll sweep found it, everything gathered under one
3309/// read lock so the filesystem stats and any re-probe below run outside it.
3310struct PollCandidate {
3311    key: EntityKey,
3312    path: PathBuf,
3313    common_dir: Arc<Path>,
3314    kind: Kind,
3315    cached_repo: Option<Arc<gix::ThreadSafeRepository>>,
3316    probes_base: bool,
3317}
3318
3319/// One metadata-poll sweep ([refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
3320/// "The poll"): for every entity a Generation's dispatch would also cover (a
3321/// hidden Submodule is skipped by the same [`dispatches_kind`] rule), stats
3322/// [`poll::POLLED_GITDIR_ENTRIES`] in its own gitdir. That gitdir is the cached
3323/// [`gix::ThreadSafeRepository`] handle's own `git_dir()` where discovery cached
3324/// one (the per-worktree location a linked Worktree's `HEAD` and `index` actually
3325/// live at), or else a fresh open's `git_dir()`, the same fallback every other
3326/// probe in this module already takes for a Submodule, which discovery never
3327/// opens. A first sweep for a newly discovered entity has nothing to compare
3328/// against yet, so it only records a baseline and reports no movement.
3329///
3330/// On movement it force-stales `dirty` and `state`, the two cells with no cheap
3331/// detector, then re-runs phases A and B for that entity alone and lets their own
3332/// supersession land the fresh values; it never starts a status probe of its own.
3333/// `poll_reprobed` is cleared and refilled with exactly the keys this call
3334/// actually re-ran, in the order it found them moved. `poll_sweep_count` counts
3335/// every call, whether or not anything moved, so a test can prove a real tick
3336/// reached this function at all.
3337fn run_poll_sweep(
3338    table: &Arc<RwLock<Table>>,
3339    overrides: &Arc<Vec<ResolvedOverride>>,
3340    show_submodules: &Arc<AtomicBool>,
3341    poll_reprobed: &Arc<Mutex<Vec<EntityKey>>>,
3342    poll_sweep_count: &Arc<AtomicUsize>,
3343    network_default_branch: &Mutex<HashMap<PathBuf, Arc<str>>>,
3344) {
3345    poll_sweep_count.fetch_add(1, Ordering::Release);
3346    poll_reprobed.lock().unwrap().clear();
3347    let show_submodules = show_submodules.load(Ordering::Acquire);
3348
3349    let candidates: Vec<PollCandidate> = {
3350        let table = table.read().unwrap();
3351        table
3352            .entities
3353            .iter()
3354            .filter(|entity| dispatches_kind(entity.kind, show_submodules))
3355            .map(|entity| PollCandidate {
3356                key: entity.key.clone(),
3357                path: entity.key.path().to_path_buf(),
3358                common_dir: Arc::clone(&entity.common_dir),
3359                kind: entity.kind,
3360                cached_repo: table.repos.get(&entity.key).cloned(),
3361                probes_base: entity.probes_base(),
3362            })
3363            .collect()
3364    };
3365
3366    for candidate in candidates {
3367        // A fresh open, never cached across sweeps: this is the same cost every
3368        // other probe in this module already pays for an entity discovery left
3369        // no handle for (always true of a Submodule), and reusing the handle it
3370        // returns for the re-probe below saves a second open on the one path
3371        // that actually detected movement.
3372        let opened;
3373        let repo = match candidate.cached_repo.as_deref() {
3374            Some(repo) => Some(repo),
3375            None => match git::open_thread_safe(&candidate.path) {
3376                Ok(repo) => {
3377                    opened = repo;
3378                    Some(&opened)
3379                }
3380                Err(_) => None,
3381            },
3382        };
3383        let gitdir = repo
3384            .map(|repo| repo.git_dir().to_path_buf())
3385            .unwrap_or_else(|| candidate.common_dir.to_path_buf());
3386
3387        let current = poll::fingerprint(&gitdir);
3388        let moved = {
3389            let mut table = table.write().unwrap();
3390            let previous = table
3391                .poll_fingerprints
3392                .insert(candidate.key.clone(), current);
3393            previous.is_some_and(|previous| poll::moved(&previous, &current))
3394        };
3395        if !moved {
3396            continue;
3397        }
3398
3399        {
3400            let mut table = table.write().unwrap();
3401            if let Some(&idx) = table.index.get(&candidate.key) {
3402                table.entities[idx].force_stale_status_cells();
3403            }
3404        }
3405
3406        let override_branch = find_entry(overrides, &candidate.path, &candidate.common_dir)
3407            .and_then(|entry| entry.default_branch.clone());
3408        let never_cancelled = AtomicBool::new(false);
3409        let chain_cache: ChainFactsCache = Mutex::new(HashMap::new());
3410        let chain_reads = AtomicUsize::new(0);
3411
3412        let branch_outcome = probe_branch(&candidate.path, repo, candidate.kind, &never_cancelled);
3413        let sync_outcome = probe_sync(
3414            &candidate.path,
3415            repo,
3416            branch_outcome.as_ref().map(|(settled, ..)| settled),
3417            candidate.kind,
3418            &never_cancelled,
3419        );
3420        let default_branch_outcome = probe_default_branch_memoised(
3421            &candidate.path,
3422            repo,
3423            &candidate.common_dir,
3424            DefaultBranchHints {
3425                override_branch: override_branch.as_deref(),
3426                network_branch: network_branch_for(network_default_branch, &candidate.common_dir)
3427                    .as_deref(),
3428            },
3429            candidate.kind,
3430            &never_cancelled,
3431            &ChainFactsMemo {
3432                cache: &chain_cache,
3433                reads: &chain_reads,
3434            },
3435        );
3436        let base_outcome = if candidate.probes_base {
3437            probe_base(
3438                &candidate.path,
3439                repo,
3440                branch_outcome.as_ref().map(|(settled, ..)| settled),
3441                default_branch_outcome.as_ref().map(|r| &r.settled),
3442                &never_cancelled,
3443            )
3444        } else {
3445            None
3446        };
3447
3448        let generation = {
3449            let mut table = table.write().unwrap();
3450            table.generation += 1;
3451            Generation::new(table.generation)
3452        };
3453        apply_cheap_probe_outcomes(
3454            table,
3455            &candidate.key,
3456            generation,
3457            CheapProbeOutcomes {
3458                branch: branch_outcome,
3459                sync: sync_outcome,
3460                base: base_outcome,
3461                default_branch: default_branch_outcome,
3462            },
3463        );
3464        poll_reprobed.lock().unwrap().push(candidate.key);
3465    }
3466}
3467
3468/// Cancels every probe currently in flight and drops the table's record of them,
3469/// which is what suspension does: the in-flight Generation is cancelled outright
3470/// rather than left to finish. Releases a pending `settle` too, since nothing is
3471/// now going to finish it.
3472fn cancel_in_flight(table: &Arc<RwLock<Table>>, settle_gate: &Arc<SettleGate>) {
3473    let mut table = table.write().unwrap();
3474    let cancelled = table.in_flight.len();
3475    for in_flight in table.in_flight.values() {
3476        in_flight.cancel.store(true, Ordering::Release);
3477    }
3478    table.in_flight.clear();
3479    table.generation_started_at.clear();
3480    drop(table);
3481    if cancelled > 0 {
3482        complete_many(settle_gate, cancelled);
3483    }
3484}
3485
3486/// A `Cell<T>`'s in-flight and timeout behaviour, uniform across every payload
3487/// type `EntityState` carries, so [`sweep_deadline`] can sweep every cell
3488/// through one array rather than one hand-written branch per cell: a cell only
3489/// ever times out if it was actually marked in flight, which is what lets the
3490/// sweep apply to all of them without asking what `Kind` owns them.
3491trait TimeoutableCell {
3492    fn is_in_flight(&self) -> bool;
3493    /// Settles this cell `Unknown(TimedOut)` for `generation`, subject to the
3494    /// same supersession `Cell::settle` already enforces.
3495    fn time_out(&mut self, generation: Generation);
3496}
3497
3498impl<T> TimeoutableCell for Cell<T> {
3499    fn is_in_flight(&self) -> bool {
3500        Cell::is_in_flight(self)
3501    }
3502
3503    fn time_out(&mut self, generation: Generation) {
3504        self.settle(generation, Settled::Unknown(Unknown::TimedOut));
3505    }
3506}
3507
3508/// Marks every cell still in flight past its own Generation's deadline `Unknown`,
3509/// per [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md):
3510/// there is no per-cell timeout, only this sweep, and it never interrupts the
3511/// underlying probe, which keeps running; the sweep only stops waiting on it.
3512fn sweep_deadline(table: &Arc<RwLock<Table>>, settle_gate: &Arc<SettleGate>, deadline: Duration) {
3513    let mut table = table.write().unwrap();
3514    let now = Instant::now();
3515    let mut timed_out = Vec::new();
3516    for (key, in_flight) in table.in_flight.iter() {
3517        let started = table
3518            .generation_started_at
3519            .get(&in_flight.generation)
3520            .copied()
3521            .unwrap_or(now);
3522        if now.duration_since(started) >= deadline {
3523            timed_out.push((key.clone(), Generation::new(in_flight.generation)));
3524        }
3525    }
3526    for (key, generation) in &timed_out {
3527        if let Some(&idx) = table.index.get(key) {
3528            // Exhaustive: a Cell added to `EntityState` later must be named here
3529            // or this fails to compile, so it cannot silently time out never.
3530            let EntityState {
3531                key: _,
3532                name: _,
3533                common_dir: _,
3534                kind: _,
3535                branch,
3536                sync,
3537                base,
3538                dirty,
3539                state,
3540                default_branch,
3541                diagnostics: _,
3542                last_action: _,
3543                presence: _,
3544                excluded: _,
3545                in_progress_operation: _,
3546                recent_commits: _,
3547            } = &mut table.entities[idx];
3548            let cells: [&mut dyn TimeoutableCell; 6] =
3549                [branch, sync, base, dirty, state, default_branch];
3550            for cell in cells {
3551                // Only a cell actually marked in flight times out: a Repo's or a
3552                // Submodule's `state` (never probed, by `EntityState::probes_state`)
3553                // and any cell no probe yet reaches (`sync`, `base`) are never in
3554                // flight, so this never overwrites them with a lie.
3555                if cell.is_in_flight() {
3556                    cell.time_out(*generation);
3557                }
3558            }
3559        }
3560        table.in_flight.remove(key);
3561    }
3562    let live_generations: std::collections::HashSet<u64> =
3563        table.in_flight.values().map(|f| f.generation).collect();
3564    table
3565        .generation_started_at
3566        .retain(|generation, _| live_generations.contains(generation));
3567    drop(table);
3568    if !timed_out.is_empty() {
3569        complete_many(settle_gate, timed_out.len());
3570    }
3571}
3572
3573/// Marks the cells this Generation's dispatch is about to probe as in flight,
3574/// via an exhaustive destructure of `EntityState`'s cells: a cell added later
3575/// must be named here (`_` if it is not yet probed) or this fails to compile,
3576/// which is what stops a cell [`apply_probe_outcome`] settles from going
3577/// in-flight silently forgotten, and reading wrong on `is_in_flight` for the
3578/// whole dispatch.
3579fn begin_probes(entity: &mut EntityState) {
3580    let probes_state = entity.probes_state();
3581    let EntityState {
3582        key: _,
3583        name: _,
3584        common_dir: _,
3585        kind: _,
3586        branch,
3587        sync: _,
3588        base: _,
3589        dirty,
3590        state,
3591        default_branch,
3592        diagnostics: _,
3593        last_action: _,
3594        presence: _,
3595        excluded: _,
3596        in_progress_operation: _,
3597        recent_commits: _,
3598    } = entity;
3599    branch.begin_probe();
3600    default_branch.begin_probe();
3601    // Phase C runs against every dispatched entity, Repo, Worktree or Submodule alike:
3602    // refresh.md's "Scope and order" makes scope never a partial dial, so `dirty` carries
3603    // no `probes_state`-style condition of its own.
3604    dirty.begin_probe();
3605    // Only a Worktree's `state` is ever (re)probed: a Repo's is `NotApplicable`
3606    // and a Submodule's is `Unknown` from construction, neither ever revisited
3607    // (`EntityState::probes_state`), and marking either in flight here would
3608    // leave it in-flight forever, since nothing would ever call `settle` on it.
3609    if probes_state {
3610        state.begin_probe();
3611    }
3612}
3613
3614/// What [`Core::try_settle`] waits on, and the one lock every count it waits on lives
3615/// under, so a settle can never observe one of them without the other.
3616type SettleGate = (Mutex<SettleCounts>, Condvar);
3617
3618/// The two outstanding counts [`Core::try_settle`] blocks on.
3619///
3620/// `dispatches` exists because a Generation reserves its number on the calling
3621/// thread and does everything else on one of its own: between those two moments
3622/// `probes` has not been raised yet, so a settle reading `probes` alone would
3623/// return on a table nothing has started writing to.
3624#[derive(Default)]
3625struct SettleCounts {
3626    /// Dispatched entities that have yet to land a phase C/D outcome, be cancelled
3627    /// or time out.
3628    probes: usize,
3629    /// Generations whose number is reserved and whose own dispatch body has not
3630    /// finished raising `probes` for what it dispatches.
3631    dispatches: usize,
3632}
3633
3634impl SettleCounts {
3635    /// Whether nothing this `Core` has started is still owed to the table.
3636    ///
3637    /// An exhaustive destructure: a third count added to this struct must be named here
3638    /// or this fails to compile, rather than being silently left out of what a settle
3639    /// waits for.
3640    fn is_settled(&self) -> bool {
3641        let SettleCounts { probes, dispatches } = self;
3642        *probes == 0 && *dispatches == 0
3643    }
3644}
3645
3646/// Records one reserved Generation as owed, before the thread that will dispatch
3647/// it has started. Paired with exactly one [`finish_dispatch`].
3648fn begin_dispatch(settle_gate: &SettleGate) {
3649    let (lock, _cvar) = settle_gate;
3650    lock.lock().unwrap().dispatches += 1;
3651}
3652
3653/// Releases the debt [`begin_dispatch`] recorded, once that Generation's own
3654/// dispatch has raised `probes` for everything it dispatched.
3655fn finish_dispatch(settle_gate: &SettleGate) {
3656    let (lock, cvar) = settle_gate;
3657    let mut counts = lock.lock().unwrap();
3658    counts.dispatches = counts.dispatches.saturating_sub(1);
3659    drop(counts);
3660    // Unconditionally, unlike `complete_many`: a waiter watching `dispatches` alone
3661    // would never be woken by a change that leaves `probes` outstanding.
3662    cvar.notify_all();
3663}
3664
3665fn begin_probes_owed(settle_gate: &SettleGate, owed: usize) {
3666    let (lock, _cvar) = settle_gate;
3667    lock.lock().unwrap().probes += owed;
3668}
3669
3670fn complete_one(settle_gate: &SettleGate) {
3671    complete_many(settle_gate, 1);
3672}
3673
3674fn complete_many(settle_gate: &SettleGate, finished: usize) {
3675    let (lock, cvar) = settle_gate;
3676    let mut counts = lock.lock().unwrap();
3677    counts.probes = counts.probes.saturating_sub(finished);
3678    if counts.is_settled() {
3679        cvar.notify_all();
3680    }
3681}
3682
3683/// Reads one entity's HEAD shape, or `None` if `cancel` was already set before the
3684/// read started. The one check this crate makes today: `git::head_shape` itself has
3685/// no interruption point to check `cancel` against mid-read, unlike the later
3686/// phases [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)
3687/// describes gix taking it through directly.
3688///
3689/// `repo` is the entity's cached thread-safe handle when discovery already opened
3690/// one; this task derives its own `Repository` from it via `to_thread_local`
3691/// rather than sharing that derived handle with any other task. `None` (a
3692/// Submodule, or a boundary discovery could not open) falls back to opening fresh,
3693/// which is where an unreadable repository's `ProbeError::Open` still surfaces.
3694///
3695/// Also reads the entity's in-progress git operation and recent commits off the
3696/// same open handle, since both ride along at negligible extra cost
3697/// ([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)).
3698/// Neither is a Cell in its own right, so both travel with the branch read they
3699/// were taken alongside rather than getting independent supersession of their
3700/// own; [`EntityState::apply_branch_probe`] is where that pairing lands.
3701const RECENT_COMMITS_LIMIT: usize = 5;
3702
3703/// What an open-repository failure means for `kind`: a genuine Probe error for a Repo or a
3704/// Worktree, but for a Submodule the far more common, expected shape of "never `git
3705/// submodule update --init`-ed" ([discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md)'s
3706/// "The Submodule row": "An uninitialised Submodule is a row with every cell blank and `?`
3707/// in the gutter"). Exhaustive over `Kind` rather than a wildcard, so a fourth variant added
3708/// later must decide which grade it gets rather than silently inheriting one.
3709fn submodule_open_failure<T>(kind: Kind, error: git::ProbeError) -> Settled<T> {
3710    match kind {
3711        Kind::Repo | Kind::Worktree => Settled::Failed(error),
3712        Kind::Submodule => Settled::Unknown(Unknown::SubmoduleUninitialized),
3713    }
3714}
3715
3716fn probe_branch(
3717    path: &Path,
3718    repo: Option<&gix::ThreadSafeRepository>,
3719    kind: Kind,
3720    cancel: &AtomicBool,
3721) -> Option<(
3722    Settled<Head>,
3723    Option<git::InProgressOperation>,
3724    Vec<git::RecentCommit>,
3725)> {
3726    if cancel.load(Ordering::Acquire) {
3727        return None;
3728    }
3729    let opened;
3730    let repo = match repo {
3731        Some(repo) => repo,
3732        None => match git::open_thread_safe(path) {
3733            Ok(repo) => {
3734                opened = repo;
3735                &opened
3736            }
3737            Err(error) => return Some((submodule_open_failure(kind, error), None, Vec::new())),
3738        },
3739    };
3740    let local = repo.to_thread_local();
3741    let settled = match git::head_shape(&local) {
3742        Ok(head) => Settled::Known {
3743            value: head,
3744            at: Timestamp::now(),
3745            stale: false,
3746        },
3747        Err(error) => Settled::Failed(error),
3748    };
3749    let in_progress = git::in_progress_operation(&local);
3750    let recent = git::recent_commits(&local, RECENT_COMMITS_LIMIT);
3751    Some((settled, in_progress, recent))
3752}
3753
3754/// Phase B's comparison: the `sync` cell's ahead/behind counts against the
3755/// branch's upstream, for every entity whose HEAD carries a branch, every
3756/// Generation ([refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)).
3757/// `None` if `cancel` was already set, or if `branch_settled` is itself `None`
3758/// because the branch probe it depends on was cancelled first. A `Failed` branch
3759/// read fails `sync` the same way, rather than guessing at a HEAD shape the
3760/// branch probe itself could not read; every other shape (a live branch, a
3761/// detached or unborn HEAD) is handed to [`git::resolve_sync`], which is where
3762/// "no branch" and "no remote at all" settle to their own values. `repo` follows
3763/// the same cached-handle convention as [`probe_branch`].
3764fn probe_sync(
3765    path: &Path,
3766    repo: Option<&gix::ThreadSafeRepository>,
3767    branch_settled: Option<&Settled<Head>>,
3768    kind: Kind,
3769    cancel: &AtomicBool,
3770) -> Option<Settled<SyncState>> {
3771    if cancel.load(Ordering::Acquire) {
3772        return None;
3773    }
3774    let head = match branch_settled? {
3775        Settled::Known {
3776            value,
3777            at: _,
3778            stale: _,
3779        } => Some(value),
3780        Settled::Failed(error) => return Some(Settled::Failed(error.clone())),
3781        Settled::Unknown(_) | Settled::NotApplicable => None,
3782    };
3783    let opened;
3784    let repo = match repo {
3785        Some(repo) => repo,
3786        None => match git::open_thread_safe(path) {
3787            Ok(repo) => {
3788                opened = repo;
3789                &opened
3790            }
3791            Err(error) => return Some(submodule_open_failure(kind, error)),
3792        },
3793    };
3794    let local = repo.to_thread_local();
3795    let settled = match git::resolve_sync(&local, head) {
3796        Ok(value) => Settled::Known {
3797            value,
3798            at: Timestamp::now(),
3799            stale: false,
3800        },
3801        Err(error) => Settled::Failed(error),
3802    };
3803    Some(settled)
3804}
3805
3806/// Phase B's second rev-walk: the `base` cell's count behind the resolved default
3807/// branch, for every entity [`crate::base::probe`] does not exempt
3808/// ([default-branch.md](https://github.com/paulchiu/repon/blob/main/docs/spec/default-branch.md)'s
3809/// "The two behind counts"). `None` if `cancel` was already set, or if either
3810/// `branch_settled` or `default_branch_settled` is itself `None` because the probe
3811/// it depends on was cancelled first; [`crate::base::probe`] itself always settles
3812/// once reached. A `Failed` or not-yet-`Known` `branch_settled` carries no commit to
3813/// compare, so it is treated the same "nothing to settle yet" way, except a genuine
3814/// `Failed` branch read, which propagates onto `base` too: a row whose HEAD could
3815/// not be read has nothing to compute behind anything. `repo` follows the same
3816/// cached-handle convention as [`probe_branch`].
3817fn probe_base(
3818    path: &Path,
3819    repo: Option<&gix::ThreadSafeRepository>,
3820    branch_settled: Option<&Settled<Head>>,
3821    default_branch_settled: Option<&Settled<DefaultBranch>>,
3822    cancel: &AtomicBool,
3823) -> Option<Settled<u32>> {
3824    if cancel.load(Ordering::Acquire) {
3825        return None;
3826    }
3827    let head = match branch_settled? {
3828        Settled::Known {
3829            value,
3830            at: _,
3831            stale: _,
3832        } => value,
3833        Settled::Failed(error) => return Some(Settled::Failed(error.clone())),
3834        Settled::Unknown(_) | Settled::NotApplicable => return None,
3835    };
3836    let default_branch_settled = default_branch_settled?;
3837    let opened;
3838    let repo = match repo {
3839        Some(repo) => repo,
3840        None => match git::open_thread_safe(path) {
3841            Ok(repo) => {
3842                opened = repo;
3843                &opened
3844            }
3845            Err(error) => return Some(Settled::Failed(error)),
3846        },
3847    };
3848    let local = repo.to_thread_local();
3849    Some(base::probe(&local, head, default_branch_settled))
3850}
3851
3852/// Phase C's typed counts, dispatched over every entity in a Generation with no
3853/// scoping of its own: [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
3854/// "Scope and order" makes scope never a partial dial, only order, so this carries
3855/// no visibility filter and no cost heuristic; the caller's dispatch order is the
3856/// only dial, expressed entirely by the position `path` already holds in
3857/// `Core::refresh`'s `order`. `None` if `cancel` was already set before the read
3858/// started; unlike the cheaper phases above, `cancel` is also handed straight
3859/// into gix, which checks it while the read is under way rather than only before
3860/// it starts, since this is the one phase long enough for that to matter.
3861fn probe_status(
3862    path: &Path,
3863    repo: Option<&gix::ThreadSafeRepository>,
3864    kind: Kind,
3865    cancel: &Arc<AtomicBool>,
3866) -> Option<Settled<DirtyCounts>> {
3867    if cancel.load(Ordering::Acquire) {
3868        return None;
3869    }
3870    let opened;
3871    let repo = match repo {
3872        Some(repo) => repo,
3873        None => match git::open_thread_safe(path) {
3874            Ok(repo) => {
3875                opened = repo;
3876                &opened
3877            }
3878            Err(error) => return Some(submodule_open_failure(kind, error)),
3879        },
3880    };
3881    let local = repo.to_thread_local();
3882    classify_status_result(git::dirty_counts(&local, Arc::clone(cancel)), cancel)
3883}
3884
3885/// Folds [`git::dirty_counts`]'s result into [`probe_status`]'s outcome. Split out as its own
3886/// function so the one case a live probe cannot reproduce deterministically, cancellation
3887/// observed genuinely mid-read, is directly testable: gix's own error carries no typed "this
3888/// was cancelled" case (its interrupt point reports through a bare `io::Error`, same as any
3889/// other I/O failure), so `cancel` itself, which this task alone owns for the duration of its
3890/// probe, is the answer. An error alongside a cancel flag now set is what an interruption
3891/// 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
3892/// precedent is that interrupted work is dropped rather than settled `Failed`, the same as
3893/// every cheaper phase's pre-check already does.
3894///
3895/// gix checks `should_interrupt` per index entry rather than before every read, so a walk
3896/// short enough to run out of entries to check between the flag flipping and the walk
3897/// finishing can still return `Ok`. `cancel` is re-checked on that arm too, and an `Ok` that
3898/// raced ahead of it is dropped the same way an `Err` alongside it already is, so a cancelled
3899/// generation never lands a value regardless of which side of that race gix landed on.
3900fn classify_status_result(
3901    result: Result<DirtyCounts, git::ProbeError>,
3902    cancel: &AtomicBool,
3903) -> Option<Settled<DirtyCounts>> {
3904    match result {
3905        Ok(_) if cancel.load(Ordering::Acquire) => None,
3906        Ok(value) => Some(Settled::Known {
3907            value,
3908            at: Timestamp::now(),
3909            stale: false,
3910        }),
3911        Err(_) if cancel.load(Ordering::Acquire) => None,
3912        Err(error) => Some(Settled::Failed(error)),
3913    }
3914}
3915
3916/// Rung 1's config override and the network's session-held answer, bundled into
3917/// one argument the way [`ChainFactsMemo`] bundles its own two: both
3918/// [`probe_default_branch`] and [`probe_default_branch_memoised`] already sit at
3919/// clippy's argument limit, and the two hints always travel together, one per
3920/// dispatched entity.
3921struct DefaultBranchHints<'a> {
3922    /// Matched by common dir before this is called; `None` when no `[[repo]]`
3923    /// entry names this entity's own default branch.
3924    override_branch: Option<&'a str>,
3925    /// [`network_branch_for`]'s own answer for this entity's common dir; `None`
3926    /// until a fetch handshake or [`Core::rederive_default_branches`] has
3927    /// actually reached that remote this session.
3928    network_branch: Option<&'a str>,
3929}
3930
3931/// [`Core::network_default_branch`]'s own lookup, by common dir: a small helper
3932/// so every probe site reads it the same way rather than repeating the lock and
3933/// clone.
3934fn network_branch_for(
3935    network_default_branch: &Mutex<HashMap<PathBuf, Arc<str>>>,
3936    common_dir: &Path,
3937) -> Option<Arc<str>> {
3938    network_default_branch
3939        .lock()
3940        .unwrap()
3941        .get(common_dir)
3942        .cloned()
3943}
3944
3945/// Supersedes `resolution`'s own settled value with `network_branch`, if given,
3946/// per [default-branch.md](https://github.com/paulchiu/repon/blob/main/docs/spec/default-branch.md)'s
3947/// "The network": never the primary source, so `resolution` is always the local
3948/// chain's own complete answer, computed unconditionally by the caller before
3949/// this ever runs. This is the one place ADR 0012's stated ceiling is actually
3950/// closed: on a Repo where rung 2 and rung 3 agree and are both wrong (the
3951/// hidden-Submodule case the ADR measures), no local rung can ever correct
3952/// itself, and only a reachable remote's own answer, landed here, can.
3953fn supersede_with_network(
3954    mut resolution: default_branch::Resolution,
3955    network_branch: Option<&str>,
3956) -> default_branch::Resolution {
3957    if let Some(name) = network_branch {
3958        resolution.settled = Settled::Known {
3959            value: DefaultBranch::new(name.into()),
3960            at: Timestamp::now(),
3961            stale: false,
3962        };
3963    }
3964    resolution
3965}
3966
3967/// Runs the four-rung default branch chain against `path`, or `None` if `cancel`
3968/// was already set before the read started, then [`supersede_with_network`]s the
3969/// result with `hints.network_branch`.
3970///
3971/// `repo` follows the same cached-handle convention as [`probe_branch`]: `None`
3972/// falls back to opening fresh, which is where an unreadable repository surfaces
3973/// as [`default_branch::Resolution::failed`] rather than a settled Unknown.
3974fn probe_default_branch(
3975    path: &Path,
3976    repo: Option<&gix::ThreadSafeRepository>,
3977    hints: DefaultBranchHints<'_>,
3978    kind: Kind,
3979    cancel: &AtomicBool,
3980) -> Option<default_branch::Resolution> {
3981    if cancel.load(Ordering::Acquire) {
3982        return None;
3983    }
3984    let opened;
3985    let repo = match repo {
3986        Some(repo) => repo,
3987        None => match git::open_thread_safe(path) {
3988            Ok(repo) => {
3989                opened = repo;
3990                &opened
3991            }
3992            Err(error) => {
3993                return Some(match kind {
3994                    Kind::Repo | Kind::Worktree => default_branch::Resolution::failed(error),
3995                    Kind::Submodule => default_branch::Resolution::submodule_uninitialized(),
3996                });
3997            }
3998        },
3999    };
4000    Some(supersede_with_network(
4001        default_branch::resolve(&repo.to_thread_local(), hints.override_branch),
4002        hints.network_branch,
4003    ))
4004}
4005
4006/// Coordinates one common dir's Outstanding entities so every one of their own
4007/// merge bases against the default branch is known before the shared scan
4008/// runs, per [default-branch.md](https://github.com/paulchiu/repon/blob/main/docs/spec/default-branch.md)'s
4009/// requirement that the bound be *collected*, not computed lazily on whichever
4010/// entity happens to arrive first. `remaining` starts at the number of
4011/// dispatched entities in this common dir that will call [`GateReport::report`]
4012/// this Generation (every entity `landing::probe` runs for, whether it settles
4013/// immediately or reaches patch equivalence); `deepest` blocks until all of
4014/// them have, then folds their contributed merge bases pairwise via
4015/// [`git::checked_merge_base`] so the result is an ancestor of (at least as
4016/// deep as) every one of them, and memoises that answer for every later caller
4017/// sharing this dir.
4018struct BoundGate {
4019    state: Mutex<BoundGateState>,
4020    condvar: Condvar,
4021    bound: OnceLock<Option<gix::ObjectId>>,
4022}
4023
4024struct BoundGateState {
4025    remaining: usize,
4026    candidates: Vec<gix::ObjectId>,
4027}
4028
4029impl BoundGate {
4030    fn new(remaining: usize) -> Self {
4031        Self {
4032            state: Mutex::new(BoundGateState {
4033                remaining,
4034                candidates: Vec::new(),
4035            }),
4036            condvar: Condvar::new(),
4037            bound: OnceLock::new(),
4038        }
4039    }
4040
4041    /// One entity's contribution: `Some(base)` when it reached patch
4042    /// equivalence and had a merge base to offer, `None` otherwise (it settled
4043    /// by ancestry, was cancelled, failed to read, or shared no history with
4044    /// the default branch at all). Wakes every task blocked in [`Self::deepest`]
4045    /// once every entity counted in `remaining` has reported.
4046    fn report(&self, candidate: Option<gix::ObjectId>) {
4047        let mut state = self.state.lock().unwrap();
4048        if let Some(candidate) = candidate {
4049            state.candidates.push(candidate);
4050        }
4051        state.remaining -= 1;
4052        if state.remaining == 0 {
4053            self.condvar.notify_all();
4054        }
4055    }
4056
4057    /// Blocks until every entity sharing this common dir has reported, then
4058    /// returns the deepest merge base among their contributions (`None` if
4059    /// none contributed one, so the scan is left unbounded). The candidates are
4060    /// taken and folded into `bound` inside the same critical section, so
4061    /// whichever call is first to finish waiting is guaranteed to be the one
4062    /// that computes the memoised answer from them; computing outside the lock
4063    /// would let a later call, left holding an empty list by
4064    /// [`std::mem::take`], win the race into [`OnceLock::get_or_init`] and
4065    /// memoise `None` regardless of what the first call actually contributed.
4066    fn deepest(&self, repo: &gix::Repository) -> Option<gix::ObjectId> {
4067        let mut state = self.state.lock().unwrap();
4068        while state.remaining != 0 {
4069            state = self.condvar.wait(state).unwrap();
4070        }
4071        let candidates = std::mem::take(&mut state.candidates);
4072        *self
4073            .bound
4074            .get_or_init(|| deepest_merge_base(repo, &candidates))
4075    }
4076}
4077
4078/// Folds `candidates` pairwise via [`git::checked_merge_base`] into the one
4079/// deepest among them: when two candidates are ancestor and descendant, their
4080/// own merge base is exactly the ancestor, so the fold converges on whichever
4081/// candidate is deepest; two on unrelated lines of history fold to their own
4082/// common ancestor instead, which is still a safe (if not the tightest
4083/// possible) lower bound for the scan.
4084fn deepest_merge_base(
4085    repo: &gix::Repository,
4086    candidates: &[gix::ObjectId],
4087) -> Option<gix::ObjectId> {
4088    let mut candidates = candidates.iter().copied();
4089    let mut deepest = candidates.next()?;
4090    for candidate in candidates {
4091        deepest = git::checked_merge_base(repo, deepest, candidate)
4092            .ok()
4093            .flatten()
4094            .unwrap_or(deepest);
4095    }
4096    Some(deepest)
4097}
4098
4099/// Reports exactly once to a [`BoundGate`], on drop if [`Self::report_now`] was
4100/// never called explicitly: every exit path out of [`probe_worktree_state`]
4101/// and [`probe_patch_equivalence`] must release its common dir's gate, since a
4102/// path that forgot to would deadlock every sibling still waiting in
4103/// [`BoundGate::deepest`].
4104struct GateReport<'a> {
4105    gate: &'a BoundGate,
4106    reported: bool,
4107}
4108
4109impl<'a> GateReport<'a> {
4110    fn new(gate: &'a BoundGate) -> Self {
4111        Self {
4112            gate,
4113            reported: false,
4114        }
4115    }
4116
4117    /// Reports `candidate` immediately rather than waiting for drop: the one
4118    /// path that goes on to call [`BoundGate::deepest`] must report its own
4119    /// contribution first, or it would wait on a count that can never reach
4120    /// zero without its own report.
4121    fn report_now(&mut self, candidate: Option<gix::ObjectId>) {
4122        self.gate.report(candidate);
4123        self.reported = true;
4124    }
4125}
4126
4127impl Drop for GateReport<'_> {
4128    fn drop(&mut self) {
4129        if !self.reported {
4130            self.gate.report(None);
4131        }
4132    }
4133}
4134
4135/// The per-common-dir patch-equivalence memo plumbing, bundled into one
4136/// argument so [`probe_worktree_state`] and [`probe_patch_equivalence`] each
4137/// take it as a single parameter rather than three loose ones.
4138struct PatchEquivalenceMemo<'a> {
4139    cache: &'a PatchIdentityCache,
4140    reads: &'a AtomicUsize,
4141    /// Where [`probe_patch_equivalence`] records the bound it actually passed to
4142    /// [`patch_equivalence::scan_default_branch`], for `Core::patch_scan_bounds_for_test`.
4143    scan_bounds: &'a Mutex<Vec<Option<gix::ObjectId>>>,
4144}
4145
4146/// Runs both of Phase D's passes for one Worktree entity: `landing::probe`'s
4147/// ancestry check, then, only when it answers `Outstanding`,
4148/// [`probe_patch_equivalence`]'s content check. `None` if `cancel` was already
4149/// set, or if `default_branch_settled` is itself `None` because the
4150/// default-branch probe it depends on was cancelled first. `repo` follows the
4151/// same cached-handle convention as [`probe_branch`]. `report` always reports
4152/// exactly once to this entity's common dir's `BoundGate`, on every path
4153/// through this function, via its own `Drop`.
4154fn probe_worktree_state(
4155    path: &Path,
4156    repo: Option<&gix::ThreadSafeRepository>,
4157    default_branch_settled: Option<&Settled<DefaultBranch>>,
4158    common_dir: &Arc<Path>,
4159    cancel: &AtomicBool,
4160    memo: &PatchEquivalenceMemo<'_>,
4161    report: &mut GateReport<'_>,
4162) -> Option<Settled<WorktreeState>> {
4163    if cancel.load(Ordering::Acquire) {
4164        return None;
4165    }
4166    let default_branch_settled = default_branch_settled?;
4167    let opened;
4168    let repo = match repo {
4169        Some(repo) => repo,
4170        None => match git::open_thread_safe(path) {
4171            Ok(repo) => {
4172                opened = repo;
4173                &opened
4174            }
4175            Err(error) => return Some(Settled::Failed(error)),
4176        },
4177    };
4178    let local = repo.to_thread_local();
4179    match landing::probe(&local, default_branch_settled) {
4180        landing::Outcome::Settle(settled) => Some(settled),
4181        landing::Outcome::Outstanding(outstanding) => {
4182            probe_patch_equivalence(&local, &outstanding, common_dir, cancel, memo, report)
4183        }
4184    }
4185}
4186
4187/// Phase D's expensive half, reached only when `landing::probe` answered
4188/// `Outstanding`: this is the seam that keeps patch equivalence off every
4189/// entity ancestry already settled. Reports the merge base the first pass
4190/// already walked to `report` *before* asking for the shared scan, then checks
4191/// patch equivalence against `memo`'s per-common-dir cache, per
4192/// [default-branch.md](https://github.com/paulchiu/repon/blob/main/docs/spec/default-branch.md)'s
4193/// "Two passes on screen" and its bound on the scan's own depth.
4194fn probe_patch_equivalence(
4195    repo: &gix::Repository,
4196    outstanding: &landing::Outstanding,
4197    common_dir: &Arc<Path>,
4198    cancel: &AtomicBool,
4199    memo: &PatchEquivalenceMemo<'_>,
4200    report: &mut GateReport<'_>,
4201) -> Option<Settled<WorktreeState>> {
4202    if cancel.load(Ordering::Acquire) {
4203        return None;
4204    }
4205    let landing::Outstanding {
4206        entity_tip,
4207        default_tip,
4208        merge_base,
4209    } = *outstanding;
4210    let Some(merge_base) = merge_base else {
4211        // No shared history at all: a real negative the first pass already
4212        // established. This entity needs no bound and no shared scan, so it
4213        // reports and settles without waiting on either; the empty set is never
4214        // actually consulted, since `probe` returns `Active` for a `None` merge
4215        // base before it would look.
4216        report.report_now(None);
4217        return Some(patch_equivalence::probe(
4218            repo,
4219            entity_tip,
4220            None,
4221            &patch_equivalence::PatchIdentitySet::new(),
4222        ));
4223    };
4224    // Reported now, not left to `report`'s `Drop`: the wait just below blocks
4225    // on every entity sharing this common dir having reported, this entity
4226    // included, so reporting late here would deadlock on its own wait.
4227    report.report_now(Some(merge_base));
4228    let bound = report.gate.deepest(repo);
4229    let shared = match patch_identities_for(memo.cache, common_dir, memo.reads, || {
4230        // Recorded here, inside the closure that only ever runs for whichever
4231        // entity's task is first to reach `patch_identities_for` for this common
4232        // dir, so this is the bound the one real `scan_default_branch` call for
4233        // it actually used, not a value a test recomputes independently.
4234        memo.scan_bounds.lock().unwrap().push(bound);
4235        patch_equivalence::scan_default_branch(repo, default_tip, bound)
4236    }) {
4237        Ok(shared) => shared,
4238        Err(error) => return Some(Settled::Failed(error)),
4239    };
4240    Some(patch_equivalence::probe(
4241        repo,
4242        entity_tip,
4243        Some(merge_base),
4244        &shared,
4245    ))
4246}
4247
4248/// One Generation's patch-equivalence memo: at most one
4249/// [`patch_equivalence::PatchIdentitySet`] per common dir, shared by every
4250/// dispatched entity `landing::probe` answered `Outstanding` for. Built fresh
4251/// in [`Core::refresh`] and dropped once every task from that dispatch has
4252/// finished, the same lifetime `ChainFactsCache` has. The computed `Result` is
4253/// itself cached, since a common dir a scan fails against fails identically
4254/// for every entity sharing it this Generation.
4255type PatchIdentityCache = Mutex<
4256    HashMap<Arc<Path>, Arc<OnceLock<Result<patch_equivalence::PatchIdentitySet, git::ProbeError>>>>,
4257>;
4258
4259/// The per-common-dir half of [`probe_patch_equivalence`]: returns the
4260/// already-computed scan for `common_dir` if another entity in this
4261/// Generation's dispatch already ran it, blocking until that computation
4262/// finishes if it is still running; otherwise runs `compute` itself, caches the
4263/// result, and increments `reads` exactly once for the common dir this call is
4264/// the first to reach. Structurally identical to [`chain_facts_for`]; kept
4265/// separate rather than made generic over it, since the two caches are keyed by
4266/// different Generations' worth of dispatch and sharing one would blur which
4267/// pass a given read counted for.
4268fn patch_identities_for(
4269    cache: &PatchIdentityCache,
4270    common_dir: &Arc<Path>,
4271    reads: &AtomicUsize,
4272    compute: impl FnOnce() -> Result<patch_equivalence::PatchIdentitySet, git::ProbeError>,
4273) -> Result<patch_equivalence::PatchIdentitySet, git::ProbeError> {
4274    let cell = {
4275        let mut cache = cache.lock().unwrap();
4276        Arc::clone(
4277            cache
4278                .entry(Arc::clone(common_dir))
4279                .or_insert_with(|| Arc::new(OnceLock::new())),
4280        )
4281    };
4282    cell.get_or_init(|| {
4283        reads.fetch_add(1, Ordering::Relaxed);
4284        compute()
4285    })
4286    .clone()
4287}
4288
4289/// One Generation's default-branch chain memo: at most one [`default_branch::ChainFacts`]
4290/// per common dir, shared by every dispatched entity that names it. Built fresh in
4291/// [`Core::refresh`] and dropped once every task from that dispatch has finished.
4292type ChainFactsCache = Mutex<HashMap<Arc<Path>, Arc<OnceLock<default_branch::ChainFacts>>>>;
4293
4294/// The per-common-dir half of [`probe_default_branch_memoised`]: returns the
4295/// already-cached facts for `common_dir` if another entity in this Generation's
4296/// dispatch already computed them, blocking until that computation finishes if it
4297/// is still running; otherwise runs `compute` itself, caches the result, and
4298/// increments `reads` exactly once for the common dir this call is the first to
4299/// reach.
4300fn chain_facts_for(
4301    cache: &ChainFactsCache,
4302    common_dir: &Arc<Path>,
4303    reads: &AtomicUsize,
4304    compute: impl FnOnce() -> default_branch::ChainFacts,
4305) -> default_branch::ChainFacts {
4306    let cell = {
4307        let mut cache = cache.lock().unwrap();
4308        Arc::clone(
4309            cache
4310                .entry(Arc::clone(common_dir))
4311                .or_insert_with(|| Arc::new(OnceLock::new())),
4312        )
4313    };
4314    cell.get_or_init(|| {
4315        reads.fetch_add(1, Ordering::Relaxed);
4316        compute()
4317    })
4318    .clone()
4319}
4320
4321/// Runs the four-rung default branch chain against `path`, memoising rungs 2 and
4322/// 3's own per-common-dir facts in `cache` so every entity sharing `common_dir`
4323/// within the same dispatch reads the loose file and its reference lookups once
4324/// rather than once per entity, per [default-branch.md](https://github.com/paulchiu/repon/blob/main/docs/spec/default-branch.md)'s
4325/// "Memoised per common dir within a single refresh generation". `None` if
4326/// `cancel` was already set before the read started; `override_branch` is rung 1's
4327/// own entity-specific value, never memoised because it is not a common-dir fact.
4328/// [`chain_facts_for`]'s own two collaborators, bundled so
4329/// [`probe_default_branch_memoised`] stays within clippy's argument limit: the two always
4330/// travel together, one dispatch's worth of both, per [`Core::refresh_handles`].
4331struct ChainFactsMemo<'a> {
4332    cache: &'a ChainFactsCache,
4333    reads: &'a AtomicUsize,
4334}
4335
4336fn probe_default_branch_memoised(
4337    path: &Path,
4338    repo: Option<&gix::ThreadSafeRepository>,
4339    common_dir: &Arc<Path>,
4340    hints: DefaultBranchHints<'_>,
4341    kind: Kind,
4342    cancel: &AtomicBool,
4343    memo: &ChainFactsMemo<'_>,
4344) -> Option<default_branch::Resolution> {
4345    if cancel.load(Ordering::Acquire) {
4346        return None;
4347    }
4348    let opened;
4349    let repo = match repo {
4350        Some(repo) => repo,
4351        None => match git::open_thread_safe(path) {
4352            Ok(repo) => {
4353                opened = repo;
4354                &opened
4355            }
4356            Err(error) => {
4357                return Some(match kind {
4358                    Kind::Repo | Kind::Worktree => default_branch::Resolution::failed(error),
4359                    Kind::Submodule => default_branch::Resolution::submodule_uninitialized(),
4360                });
4361            }
4362        },
4363    };
4364    let local = repo.to_thread_local();
4365    let facts = chain_facts_for(memo.cache, common_dir, memo.reads, || {
4366        default_branch::ChainFacts::resolve(&local)
4367    });
4368    Some(supersede_with_network(
4369        default_branch::resolve_with_facts(&facts, hints.override_branch),
4370        hints.network_branch,
4371    ))
4372}
4373
4374/// Phase A and B's per-cell outcomes, landed as soon as they are computed via
4375/// [`apply_cheap_probe_outcomes`], well before phase C or D answer. Named rather
4376/// than positional so a transposed pair of trailing `None`s cannot compile
4377/// silently into the wrong cell.
4378struct CheapProbeOutcomes {
4379    branch: Option<(
4380        Settled<Head>,
4381        Option<git::InProgressOperation>,
4382        Vec<git::RecentCommit>,
4383    )>,
4384    sync: Option<Settled<SyncState>>,
4385    base: Option<Settled<u32>>,
4386    default_branch: Option<default_branch::Resolution>,
4387}
4388
4389/// Writes phase A and B's cells for `key` at `generation`, subject to the
4390/// per-cell supersession `Cell::settle` already enforces, and records the
4391/// default-branch diagnostics only on the write that actually won. Deliberately
4392/// does not touch `in_flight` or `settle_gate`: those belong to whichever apply
4393/// closes out the entity's dispatch, which per
4394/// [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
4395/// "The first frame" is this call's whole point, since a slow phase C or D must
4396/// never hold these cells off the table.
4397fn apply_cheap_probe_outcomes(
4398    table: &Arc<RwLock<Table>>,
4399    key: &EntityKey,
4400    generation: Generation,
4401    outcomes: CheapProbeOutcomes,
4402) {
4403    let CheapProbeOutcomes {
4404        branch: branch_outcome,
4405        sync: sync_outcome,
4406        base: base_outcome,
4407        default_branch: default_branch_outcome,
4408    } = outcomes;
4409    let mut table = table.write().unwrap();
4410    if let Some(&idx) = table.index.get(key) {
4411        if let Some((settled, in_progress, recent)) = branch_outcome {
4412            table.entities[idx].apply_branch_probe(generation, settled, in_progress, recent);
4413        }
4414        if let Some(settled) = sync_outcome {
4415            table.entities[idx].sync.settle(generation, settled);
4416        }
4417        if let Some(settled) = base_outcome {
4418            table.entities[idx].base.settle(generation, settled);
4419        }
4420        if let Some(resolution) = default_branch_outcome {
4421            table.entities[idx].apply_default_branch_resolution(generation, resolution);
4422        }
4423    }
4424}
4425
4426/// Phase C and D's per-cell outcomes, landed once they answer, via
4427/// [`apply_probe_outcome`]: named rather than positional for the same reason as
4428/// [`CheapProbeOutcomes`].
4429struct ProbeOutcomes {
4430    state: Option<Settled<WorktreeState>>,
4431    dirty: Option<Settled<DirtyCounts>>,
4432}
4433
4434/// Lands one probe's phase C/D outcome for `key` at `generation`: writes the
4435/// `state` and `dirty` cells subject to the per-cell supersession `Cell::settle`
4436/// already enforces, then clears `key`'s in-flight entry if `generation` still
4437/// owns it and signals `settle_gate` once for the whole entity. This is the one
4438/// write that closes out a dispatched entity, whether or not
4439/// [`apply_cheap_probe_outcomes`] already landed that same entity's cheap cells;
4440/// a test's simulated late result goes through the same path so it does not
4441/// duplicate this bookkeeping.
4442///
4443/// `outcomes.state` being `None` writes nothing at all: the `state` cell is left
4444/// exactly as unsettled as `begin_probe` alone leaves it, which is what an
4445/// attached branch with a live upstream ancestry could not clear, and that
4446/// `probe_patch_equivalence` was itself cancelled before answering, still shows.
4447fn apply_probe_outcome(
4448    table: &Arc<RwLock<Table>>,
4449    settle_gate: &Arc<SettleGate>,
4450    key: &EntityKey,
4451    generation: Generation,
4452    outcomes: ProbeOutcomes,
4453) {
4454    let ProbeOutcomes {
4455        state: state_outcome,
4456        dirty: dirty_outcome,
4457    } = outcomes;
4458    let mut table = table.write().unwrap();
4459    if let Some(&idx) = table.index.get(key) {
4460        if let Some(settled) = state_outcome {
4461            table.entities[idx].state.settle(generation, settled);
4462        }
4463        if let Some(settled) = dirty_outcome {
4464            table.entities[idx].dirty.settle(generation, settled);
4465        }
4466    }
4467    // By Generation as well as by key. Cancellation is cooperative, so a superseded
4468    // probe still runs to completion and arrives here after the Generation that
4469    // superseded it has already put its own entry under this key; clearing by key
4470    // alone would delete that live entry, leaving the entity with nothing for the
4471    // next Generation to interrupt and nothing for the deadline sweep to time out.
4472    // The settle gate is signalled either way, since the debt belongs to the probe
4473    // rather than to the entry.
4474    if table
4475        .in_flight
4476        .get(key)
4477        .is_some_and(|in_flight| in_flight.generation == generation.value())
4478    {
4479        table.in_flight.remove(key);
4480    }
4481    drop(table);
4482    complete_one(settle_gate);
4483}
4484
4485/// Reconciles one discovery result into `table`: a found entity is inserted or
4486/// marked Present again, even if it was Vanished, and one no longer found is
4487/// marked Vanished via [`EntityState::mark_vanished`]. Returns how many
4488/// in-flight probes were cancelled by a newly Vanished entity, for the caller
4489/// to signal `settle_gate`.
4490fn merge_discovery(
4491    table: &mut Table,
4492    exclusions: &[ResolvedExclusion],
4493    discovered: Vec<discovery::DiscoveredEntity>,
4494    gitmodules_failures: Vec<(EntityKey, String)>,
4495) -> usize {
4496    let mut found: HashSet<EntityKey> = HashSet::with_capacity(discovered.len());
4497
4498    for discovered in discovered {
4499        found.insert(discovered.key.clone());
4500        match table.index.get(&discovered.key).copied() {
4501            Some(idx) => {
4502                table.entities[idx].presence = Presence::Present;
4503                if let Some(repo) = discovered.repo {
4504                    table.repos.insert(discovered.key.clone(), repo);
4505                }
4506            }
4507            None => {
4508                let name = discovered
4509                    .display_name_override
4510                    .clone()
4511                    .unwrap_or_else(|| display_name(discovered.key.path()));
4512                let mut entity = EntityState::new(
4513                    discovered.key.clone(),
4514                    name,
4515                    Arc::clone(&discovered.common_dir),
4516                    discovered.kind,
4517                );
4518                entity.excluded =
4519                    excluded_by(exclusions, discovered.key.path(), &discovered.common_dir);
4520                if let Some(repo) = discovered.repo {
4521                    table.repos.insert(discovered.key.clone(), repo);
4522                }
4523                let idx = table.entities.len();
4524                table.index.insert(discovered.key, idx);
4525                table.entities.push(entity);
4526            }
4527        }
4528    }
4529
4530    // A boundary's `.gitmodules` failure is re-derived from this pass alone,
4531    // never carried over from a previous one: a failure that was fixed since the
4532    // last Generation must clear, not stay stuck forever.
4533    let now_failing: HashMap<EntityKey, String> = gitmodules_failures.into_iter().collect();
4534    for key in &found {
4535        if let Some(&idx) = table.index.get(key) {
4536            table.entities[idx].diagnostics.gitmodules_failed = now_failing
4537                .get(key)
4538                .map(|message| Arc::from(message.as_str()));
4539        }
4540    }
4541
4542    let missing: Vec<EntityKey> = table
4543        .index
4544        .keys()
4545        .filter(|key| !found.contains(*key))
4546        .cloned()
4547        .collect();
4548    let mut cancelled = 0usize;
4549    for key in missing {
4550        if let Some(&idx) = table.index.get(&key) {
4551            table.entities[idx].mark_vanished();
4552        }
4553        if let Some(in_flight) = table.in_flight.remove(&key) {
4554            in_flight.cancel.store(true, Ordering::Release);
4555            cancelled += 1;
4556        }
4557    }
4558
4559    cancelled
4560}
4561
4562/// A basename read from the entity's own resolved path. A real display name has
4563/// collision handling that belongs to [config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md);
4564/// this is a placeholder good enough to populate the table.
4565///
4566/// This is the one function that computes it: `start_internal`'s discovery loop
4567/// and `probe_now`'s fallback insert for an unknown key both call it rather than
4568/// formatting a name of their own, which is what keeps the name shown on screen
4569/// and the name a future state file would key by byte-identical.
4570fn display_name(path: &Path) -> Arc<str> {
4571    Arc::from(
4572        path.file_name()
4573            .and_then(|name| name.to_str())
4574            .unwrap_or("?"),
4575    )
4576}
4577
4578/// Sleeps for `warn_after`, then reports `progress`'s count and `roots` if the walk
4579/// still has not finished, per [discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md):
4580/// the one-second still-walking warning needs a timer watching an in-flight walk
4581/// from outside it, since discovery itself has no callback and no notion of "still
4582/// running". `None` once the walk has already finished.
4583fn watch_for_slow_discovery(
4584    progress: Arc<AtomicUsize>,
4585    finished: Arc<AtomicBool>,
4586    roots: Vec<PathBuf>,
4587    warn_after: Duration,
4588) -> Option<String> {
4589    thread::sleep(warn_after);
4590    if finished.load(Ordering::Acquire) {
4591        return None;
4592    }
4593    Some(still_walking_message(
4594        progress.load(Ordering::Acquire),
4595        &roots,
4596    ))
4597}
4598
4599fn still_walking_message(directories_visited: usize, roots: &[PathBuf]) -> String {
4600    let roots = roots
4601        .iter()
4602        .map(|root| root.display().to_string())
4603        .collect::<Vec<_>>()
4604        .join(", ");
4605    format!("discovery: still walking, {directories_visited} directories reached under {roots}")
4606}
4607
4608/// The persistent warning left once a walk abandons, per
4609/// [config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md#discovery-bounds):
4610/// unlike the still-walking warning, this one never clears itself, since the Set
4611/// stays out of the automatic refresh path for the life of this `Core`.
4612fn abandoned_discovery_message(directories_visited: usize) -> String {
4613    format!("discovery: stopped at {directories_visited} directories")
4614}
4615
4616/// Runs `step` until it says it is done or `cancel` is observed set, checked before
4617/// every call. Returns how many times `step` actually ran, which is what lets a
4618/// test prove a cancelled loop stopped mid-flight rather than merely having a flag
4619/// set on it somewhere. Not yet called from a real probe: `git::head_shape` has no
4620/// loop to interrupt, so this is the shape a later, genuinely interruptible phase
4621/// (gix `status`, taking `should_interrupt` directly) will use.
4622#[allow(dead_code)] // exercised by its own test; no interruptible probe calls it yet
4623pub(crate) fn run_while_not_cancelled(
4624    cancel: &AtomicBool,
4625    mut step: impl FnMut() -> bool,
4626) -> usize {
4627    let mut ran = 0;
4628    while !cancel.load(Ordering::Acquire) {
4629        if !step() {
4630            break;
4631        }
4632        ran += 1;
4633    }
4634    ran
4635}
4636
4637#[cfg(test)]
4638mod tests {
4639    use std::fs;
4640    use std::process::Command;
4641
4642    use super::*;
4643    use crate::entity::{AheadBehind, DefaultBranchStopped, WorktreeState};
4644    use crate::liveness::{BACKSTOP, FIXTURE_LIFETIME, wait_for};
4645    use crate::snapshot::{RowSummary, summary};
4646    use crate::test_support::{git, head_sha, loose_object_count};
4647
4648    fn init_repo_with_a_commit(path: &Path) {
4649        fs::create_dir_all(path).expect("create repo dir");
4650        gix::init(path).expect("init repo");
4651        let status = Command::new("git")
4652            .arg("-C")
4653            .arg(path)
4654            .args(["-c", "user.email=test@example.com", "-c", "user.name=Test"])
4655            .args(["commit", "--allow-empty", "-m", "first"])
4656            .status()
4657            .expect("run git commit");
4658        assert!(status.success());
4659    }
4660
4661    /// A second (or later) commit against an already-initialised repo at `path`,
4662    /// with the same explicit identity `init_repo_with_a_commit` supplies: never
4663    /// relying on a global git identity, which a machine running CI has none of.
4664    /// Commits a real change, which is what the poll's own user story is about and what an
4665    /// empty commit is not: `git add` rewrites `.git/index` unconditionally, while whether a
4666    /// commit with nothing staged rewrites it is left to git's racy-entry heuristic and
4667    /// differs between platforms. `index` is the only one of the polled paths a commit on an
4668    /// attached HEAD moves, so a test that depends on an empty commit moving it is testing
4669    /// that heuristic rather than the poll.
4670    fn commit_a_change(path: &Path, message: &str) {
4671        let gitdir = gitdir_of(path);
4672        let before = poll::fingerprint(&gitdir);
4673
4674        std::fs::write(path.join(format!("{message}.txt")), message.as_bytes())
4675            .expect("write a file to commit");
4676        let added = Command::new("git")
4677            .arg("-C")
4678            .arg(path)
4679            .args(["add", "-A"])
4680            .status()
4681            .expect("run git add");
4682        assert!(added.success());
4683        commit(path, message, &["-m", message]);
4684
4685        // The fixture's own premise, asserted rather than assumed: a commit on an attached
4686        // HEAD moves none of the polled paths except `index` (`HEAD` is untouched, and
4687        // rewriting `refs/heads/<branch>` does not move `refs/` itself), so if git leaves
4688        // `index` alone here there is nothing for the poll to see and the failure belongs to
4689        // this fixture, not to the sweep it is setting up.
4690        assert!(
4691            poll::moved(&before, &poll::fingerprint(&gitdir)),
4692            "committing in {} moved none of the polled paths under {}, so this fixture cannot \
4693             show the poll anything",
4694            path.display(),
4695            gitdir.display()
4696        );
4697    }
4698
4699    /// The absolute gitdir git itself reports, which for a linked Worktree is its own
4700    /// `.git/worktrees/<name>` rather than the `.git` file beside the checkout.
4701    fn gitdir_of(work_dir: &Path) -> PathBuf {
4702        let output = Command::new("git")
4703            .arg("-C")
4704            .arg(work_dir)
4705            .args(["rev-parse", "--absolute-git-dir"])
4706            .output()
4707            .expect("run git rev-parse");
4708        assert!(
4709            output.status.success(),
4710            "resolve the gitdir of {}",
4711            work_dir.display()
4712        );
4713        PathBuf::from(
4714            std::str::from_utf8(&output.stdout)
4715                .expect("a utf-8 gitdir path")
4716                .trim(),
4717        )
4718    }
4719
4720    /// The shared tail of the commit helpers.
4721    fn commit(path: &Path, message: &str, args: &[&str]) {
4722        let status = Command::new("git")
4723            .arg("-C")
4724            .arg(path)
4725            .args(["-c", "user.email=test@example.com", "-c", "user.name=Test"])
4726            .arg("commit")
4727            .args(args)
4728            .status()
4729            .unwrap_or_else(|error| panic!("run git commit {message}: {error}"));
4730        assert!(status.success());
4731    }
4732
4733    /// A `FetchSpec` that never fires on its own: `enabled: false`, so every
4734    /// existing test that does not care about the periodic fetch keeps behaving
4735    /// exactly as it did before this field existed.
4736    fn fetch_spec_for_test() -> FetchSpec {
4737        FetchSpec {
4738            enabled: false,
4739            interval: Duration::from_secs(3600),
4740            concurrency: 4,
4741        }
4742    }
4743
4744    /// An `AutoUpdateSpec` that never fires on its own, the same reason
4745    /// [`fetch_spec_for_test`] never does: every existing test that does not care
4746    /// about the auto-update keeps behaving exactly as it did before this field
4747    /// existed.
4748    fn auto_update_spec_for_test() -> AutoUpdateSpec {
4749        AutoUpdateSpec { enabled: false }
4750    }
4751
4752    fn spec(roots: Vec<PathBuf>) -> CoreSpec {
4753        CoreSpec {
4754            set: SetSpec {
4755                name: "test".to_string(),
4756                roots,
4757                include: Vec::new(),
4758                exclude: Vec::new(),
4759            },
4760            overrides: Vec::new(),
4761            poll_interval: Duration::from_secs(3600),
4762            status_stale_after: Duration::from_secs(3600),
4763            generation_deadline: Duration::from_secs(3600),
4764            show_submodules: false,
4765            fetch: fetch_spec_for_test(),
4766            auto_update: auto_update_spec_for_test(),
4767        }
4768    }
4769
4770    /// Criterion 2's "no field" half: scope is never a partial dial, not even as a field
4771    /// on the plain-data struct crossing into the core. An exhaustive destructure names
4772    /// every field `CoreSpec` has; a scoping field added under any name fails to compile
4773    /// this test rather than landing unacknowledged. `show_submodules` is named here too,
4774    /// deliberately: it narrows probing and rendering, never what discovery bounds, so it
4775    /// is not the scoping field this test guards against
4776    /// ([discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md)'s
4777    /// "narrows the view rather than bounding the work"). `fetch` and `auto_update` are
4778    /// excluded from that same guard for the same reason: they narrow what the periodic
4779    /// fetch and the fast-forward-only update touch, never what discovery bounds.
4780    #[test]
4781    fn core_spec_carries_no_scoping_field_scope_is_never_a_dial() {
4782        let CoreSpec {
4783            set: _,
4784            overrides: _,
4785            poll_interval: _,
4786            status_stale_after: _,
4787            generation_deadline: _,
4788            show_submodules: _,
4789            fetch: _,
4790            auto_update: _,
4791        } = spec(Vec::new());
4792    }
4793
4794    fn root_of(dir: &tempfile::TempDir) -> PathBuf {
4795        dir.path().canonicalize().expect("canonicalize temp dir")
4796    }
4797
4798    /// Blocks until `core`'s launch Generation has settled, and hands back what it settled
4799    /// to.
4800    ///
4801    /// `Core::start`'s own first walk is that `Core`'s Generation 1 and probes every row it
4802    /// finds, so a test that counts what a later Generation did, or that watches a cell
4803    /// only its own Generation may write, has to begin from a table launch has already
4804    /// finished with. [`BACKSTOP`] rather than a budget, and the gate is read afterwards so
4805    /// an expired wait fails here by name instead of downstream as a wrong value.
4806    fn settle_launch(core: &Core) -> Snapshot {
4807        let launched = core.settle();
4808        assert_eq!(
4809            core.settle_gate_count_for_test(),
4810            0,
4811            "launch's own Generation never settled, so nothing after this is starting from \
4812             the point it claims to"
4813        );
4814        launched
4815    }
4816
4817    /// [`settle_launch`] over a `Core` built the ordinary way, for the many tests that want
4818    /// nothing else from the constructor.
4819    fn started_and_settled(spec: CoreSpec) -> (Core, Snapshot) {
4820        let core = Core::start_discovered(spec);
4821        let launched = settle_launch(&core);
4822        (core, launched)
4823    }
4824
4825    /// Sets every polled gitdir entry's modification time ten seconds into the past, so any
4826    /// write that follows reads as newer than the baseline by more than a filesystem's
4827    /// timestamp granularity. Without it a commit made microseconds after the baseline sweep
4828    /// lands in the same coarse tick on Linux and reads as no movement at all, which is a race
4829    /// in the harness rather than in the poll: real sweeps are a configured interval apart.
4830    /// Reads the polled names from [`poll::POLLED_GITDIR_ENTRIES`] rather than restating them.
4831    fn backdate_polled_entries(work_dir: &Path) {
4832        let gitdir = gitdir_of(work_dir);
4833
4834        let past = std::time::SystemTime::now() - Duration::from_secs(10);
4835        let mut touched = 0;
4836        for name in poll::POLLED_GITDIR_ENTRIES {
4837            let path = gitdir.join(name);
4838            if path.exists() {
4839                set_mtime_to(&path, past);
4840                touched += 1;
4841            }
4842        }
4843        assert!(
4844            touched > 0,
4845            "backdated nothing under {}; the gitdir holds none of the polled entries and the \
4846             baseline this sets up would not be older than what follows",
4847            gitdir.display()
4848        );
4849    }
4850
4851    /// `utimensat`, since a plain file handle cannot set a directory's time and `refs` is one.
4852    fn set_mtime_to(path: &Path, at: std::time::SystemTime) {
4853        use std::os::unix::ffi::OsStrExt;
4854
4855        let secs = at
4856            .duration_since(std::time::SystemTime::UNIX_EPOCH)
4857            .expect("a time after the epoch")
4858            .as_secs() as libc::time_t;
4859        let times = [
4860            libc::timespec {
4861                tv_sec: secs,
4862                tv_nsec: 0,
4863            },
4864            libc::timespec {
4865                tv_sec: secs,
4866                tv_nsec: 0,
4867            },
4868        ];
4869        let c_path =
4870            std::ffi::CString::new(path.as_os_str().as_bytes()).expect("a path with no NUL");
4871        let rc = unsafe { libc::utimensat(libc::AT_FDCWD, c_path.as_ptr(), times.as_ptr(), 0) };
4872        assert_eq!(
4873            rc,
4874            0,
4875            "set mtime on {}: {}",
4876            path.display(),
4877            std::io::Error::last_os_error()
4878        );
4879    }
4880
4881    fn step(argv: &[&str]) -> Step {
4882        Step {
4883            argv: argv.iter().map(|s| s.to_string()).collect(),
4884            shell: false,
4885            interactive: false,
4886            env: Vec::new(),
4887        }
4888    }
4889
4890    /// `shell = true`'s own convention: one argv element, the whole command string.
4891    fn shell_step(command: &str) -> Step {
4892        Step {
4893            argv: vec![command.to_string()],
4894            shell: true,
4895            interactive: false,
4896            env: Vec::new(),
4897        }
4898    }
4899
4900    /// `shell = true` plus `interactive = true`: the same convention, run through
4901    /// `$SHELL -ic` instead of `$SHELL -c`.
4902    fn interactive_shell_step(command: &str) -> Step {
4903        Step {
4904            argv: vec![command.to_string()],
4905            shell: true,
4906            interactive: true,
4907            env: Vec::new(),
4908        }
4909    }
4910
4911    fn action(label: &str, steps: Vec<Step>) -> ActionSpec {
4912        ActionSpec {
4913            label: Arc::from(label),
4914            name: Some(Arc::from(label)),
4915            steps,
4916            concurrency: 4,
4917            when: None,
4918        }
4919    }
4920
4921    /// [`action`], narrowed by `when`, a Filter grammar predicate
4922    /// (`docs/spec/actions.md`'s "The Selection and the gate").
4923    fn action_with_when(label: &str, steps: Vec<Step>, when: &str) -> ActionSpec {
4924        ActionSpec {
4925            when: Some(Filter::parse(when)),
4926            ..action(label, steps)
4927        }
4928    }
4929
4930    /// End-to-end: the test thread never spawns anything itself, only calls
4931    /// `Core`'s public methods, and real branch data still lands in the snapshot.
4932    /// That is the proof that the core owns the threads doing the work, not the
4933    /// consumer.
4934    #[test]
4935    fn refresh_and_settle_populate_real_cells_without_the_caller_spawning_a_thread() {
4936        let dir = tempfile::tempdir().expect("temp dir");
4937        let root = root_of(&dir);
4938        let repo = root.join("repo");
4939        init_repo_with_a_commit(&repo);
4940
4941        let core = Core::start_discovered(spec(vec![root]));
4942        let keys: Vec<EntityKey> = core
4943            .snapshot()
4944            .entities
4945            .iter()
4946            .map(|entity| entity.key.clone())
4947            .collect();
4948        assert_eq!(keys.len(), 1);
4949
4950        core.refresh(&keys);
4951        let settled = core.settle();
4952
4953        let entity = &settled.entities[0];
4954        match entity.branch.settled() {
4955            Some(Settled::Known {
4956                value: Head::Branch { .. },
4957                at: _,
4958                stale: _,
4959            }) => {}
4960            other => panic!("expected an attached branch, got {other:?}"),
4961        }
4962    }
4963
4964    // --- Single source of truth: read the first-frame budgets from the spec itself,
4965    // the same pattern `executor.rs` already uses for its PTY width and capture bounds
4966    // against `docs/spec/actions.md`. ---
4967
4968    fn spec_refresh_md() -> String {
4969        let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
4970        std::fs::read_to_string(manifest_dir.join("../../docs/spec/refresh.md"))
4971            .expect("read docs/spec/refresh.md")
4972    }
4973
4974    fn spec_first_frame_budgets_ms(spec: &str) -> (u64, u64) {
4975        let anchor = "rows with names on screen within ";
4976        let after = spec
4977            .split(anchor)
4978            .nth(1)
4979            .expect("the first-frame budget sentence is present");
4980        let mut parts = after.splitn(2, "ms, every cheap column filled within ");
4981        let names: u64 = parts
4982            .next()
4983            .expect("a names-on-screen budget")
4984            .parse()
4985            .expect("the names-on-screen budget is an integer");
4986        let after_cheap = parts.next().expect("a cheap-column budget and beyond");
4987        let cheap_columns: u64 = after_cheap
4988            .split("ms,")
4989            .next()
4990            .expect("a cheap-column budget")
4991            .parse()
4992            .expect("the cheap-column budget is an integer");
4993        (names, cheap_columns)
4994    }
4995
4996    /// Criterion 1: the two budgets `refresh.md`'s "The first frame" states are declared
4997    /// once as named constants and cross-checked against the spec sentence here, so the
4998    /// spec and the code cannot drift apart silently.
4999    #[test]
5000    fn first_frame_budget_constants_match_the_spec_of_record() {
5001        let spec = spec_refresh_md();
5002        let (names_ms, cheap_columns_ms) = spec_first_frame_budgets_ms(&spec);
5003        assert_eq!(names_ms, FIRST_FRAME_NAMES_BUDGET_MS);
5004        assert_eq!(cheap_columns_ms, FIRST_FRAME_CHEAP_COLUMNS_BUDGET_MS);
5005    }
5006
5007    /// Criterion 2: every entity a Generation is dispatched over gets its phase C read,
5008    /// never a subset. `refresh.md`'s "Scope and order" makes scope never a partial dial,
5009    /// so this proves it against a population wide enough that a mistaken "first K" or
5010    /// "last K" scoping mistake would leave a visible gap: sixteen real repos, dispatched in
5011    /// one Generation, every one of them still `dirty: Known` once settled, position sixteen
5012    /// exactly as covered as position one. A mutation that scoped phase C to, say, the first
5013    /// ten dispatched entities fails this directly.
5014    #[test]
5015    fn every_dispatched_entity_gets_its_dirty_cell_settled_not_a_subset() {
5016        let dir = tempfile::tempdir().expect("temp dir");
5017        let root = root_of(&dir);
5018        const ENTITY_COUNT: usize = 16;
5019        for index in 0..ENTITY_COUNT {
5020            init_repo_with_a_commit(&root.join(format!("repo-{index}")));
5021        }
5022
5023        let core = Core::start_discovered(spec(vec![root]));
5024        let keys: Vec<EntityKey> = core
5025            .snapshot()
5026            .entities
5027            .iter()
5028            .map(|entity| entity.key.clone())
5029            .collect();
5030        assert_eq!(keys.len(), ENTITY_COUNT, "expected every repo discovered");
5031
5032        core.refresh(&keys);
5033        let settled = core.settle();
5034
5035        for entity in &settled.entities {
5036            assert!(
5037                matches!(
5038                    entity.dirty.settled(),
5039                    Some(Settled::Known {
5040                        value: _,
5041                        at: _,
5042                        stale: _
5043                    })
5044                ),
5045                "entity {:?} was left without a settled dirty cell, which is exactly what a \
5046                 visibility-scoped dispatch would leave behind on the entities it skipped: \
5047                 got {:?}",
5048                entity.name,
5049                entity.dirty.settled()
5050            );
5051        }
5052    }
5053
5054    /// refresh.md's "The first frame" budget (cheap columns filled within 200ms) is
5055    /// unreachable if the cheap outcomes wait behind phase C, so this proves the two
5056    /// applies are independent with a blocking seam rather than a sleep or a wall-clock
5057    /// deadline: `Core::hold_phase_c_for_test` holds phase C (and D) open after the cheap
5058    /// outcomes have already landed, and the test observes `branch` carrying this
5059    /// Generation's answer while `dirty` still carries the previous one. Run this against
5060    /// a version that bundles every outcome into one apply placed after phase C computes
5061    /// (this ticket's regression) and it fails, since nothing writes `branch` until that
5062    /// single bundled apply lands alongside `dirty`.
5063    ///
5064    /// Launch's own Generation is drained first and both cells are then moved, so each is
5065    /// read on the value it holds rather than on being blank: a table that has already
5066    /// been probed once is the only starting point available now that `Core::start` runs
5067    /// a Generation of its own, and reading values is the stronger claim anyway.
5068    #[test]
5069    fn cheap_outcomes_land_before_a_held_phase_c_settles() {
5070        let dir = tempfile::tempdir().expect("temp dir");
5071        let root = root_of(&dir);
5072        let repo = root.join("repo");
5073        init_repo_with_a_commit(&repo);
5074
5075        let (core, launched) = started_and_settled(spec(vec![root]));
5076        let key = launched.entities[0].key.clone();
5077        assert_eq!(
5078            dirty_total(&launched.entities[0]),
5079            0,
5080            "the fixture starts clean, which is the value the held phase C must still be \
5081             reading once the working tree below has moved"
5082        );
5083
5084        // One move per phase, so neither cell can be read on absence: `branch` is phase A
5085        // and must carry the new name while phase C is held, `dirty` is phase C and must
5086        // still carry launch's own clean count until it is released.
5087        git(&repo, &["checkout", "-b", "held"]);
5088        fs::write(repo.join("untracked.txt"), b"uncommitted")
5089            .expect("write an untracked file into the fixture");
5090
5091        core.hold_phase_c_for_test(&key);
5092        core.refresh(std::slice::from_ref(&key));
5093        core.wait_phase_c_landed_for_test(&key);
5094
5095        let mid_flight = core.snapshot();
5096        let entity = mid_flight
5097            .entities
5098            .iter()
5099            .find(|entity| entity.key == key)
5100            .expect("entity present");
5101        assert!(
5102            matches!(
5103                entity.branch.settled(),
5104                Some(Settled::Known {
5105                    value: Head::Branch { name, .. },
5106                    at: _,
5107                    stale: _
5108                }) if &**name == "held"
5109            ),
5110            "the cheap branch cell must carry this Generation's own answer while phase C is \
5111             still held open, got {:?}",
5112            entity.branch.settled()
5113        );
5114        assert!(
5115            entity.dirty.is_in_flight() && dirty_total(entity) == 0,
5116            "phase C is deliberately held open here; a bundled apply would already have \
5117             written this cell's new count alongside branch, got {:?}",
5118            entity.dirty.settled()
5119        );
5120
5121        core.release_phase_c_for_test(&key);
5122        core.wait_phase_c_finished_for_test(&key);
5123
5124        let settled = core.snapshot();
5125        let entity = settled
5126            .entities
5127            .iter()
5128            .find(|entity| entity.key == key)
5129            .expect("entity present");
5130        assert_eq!(
5131            dirty_total(entity),
5132            1,
5133            "phase C must settle its own count once released, got {:?}",
5134            entity.dirty.settled()
5135        );
5136    }
5137
5138    /// One entity's settled dirty count, or a panic naming what it read instead. Lets a
5139    /// test that has to distinguish two Generations by value say "still zero" and "now
5140    /// one" without repeating the match on every read.
5141    fn dirty_total(entity: &EntityState) -> u32 {
5142        match entity.dirty.settled() {
5143            Some(Settled::Known {
5144                value,
5145                at: _,
5146                stale: _,
5147            }) => value.total(),
5148            other => panic!("expected a settled dirty count, got {other:?}"),
5149        }
5150    }
5151
5152    /// Splitting one dispatched entity's write into a cheap apply and a phase C/D apply
5153    /// must still signal `settle_gate` exactly once per entity, or `settle` hangs (never
5154    /// decremented enough) or returns early (decremented twice). Two entities held open
5155    /// together prove the exact count at each step: a mutation that also decrements the
5156    /// gate from the cheap apply leaves it at 0 instead of 2 after both entities' cheap
5157    /// outcomes land, and a mutation that drops the decrement from the phase C/D apply
5158    /// leaves it at 2, never 1, once only the first entity finishes.
5159    #[test]
5160    fn splitting_the_probe_write_signals_settle_gate_exactly_once_per_entity() {
5161        let dir = tempfile::tempdir().expect("temp dir");
5162        let root = root_of(&dir);
5163        init_repo_with_a_commit(&root.join("a"));
5164        init_repo_with_a_commit(&root.join("b"));
5165
5166        let (core, snapshot) = started_and_settled(spec(vec![root]));
5167        let key_a = snapshot
5168            .entities
5169            .iter()
5170            .find(|entity| &*entity.name == "a")
5171            .expect("entity a present")
5172            .key
5173            .clone();
5174        let key_b = snapshot
5175            .entities
5176            .iter()
5177            .find(|entity| &*entity.name == "b")
5178            .expect("entity b present")
5179            .key
5180            .clone();
5181
5182        core.hold_phase_c_for_test(&key_a);
5183        core.hold_phase_c_for_test(&key_b);
5184        core.refresh(&[key_a.clone(), key_b.clone()]);
5185        // A Generation reserves its number on this thread and raises the gate on one of
5186        // its own, so this is the rendezvous that says the raise has happened. A join,
5187        // never a sleep.
5188        core.wait_dispatched_for_test();
5189        assert_eq!(
5190            core.settle_gate_count_for_test(),
5191            2,
5192            "dispatching two entities must add exactly two to the settle gate"
5193        );
5194
5195        core.wait_phase_c_landed_for_test(&key_a);
5196        core.wait_phase_c_landed_for_test(&key_b);
5197        assert_eq!(
5198            core.settle_gate_count_for_test(),
5199            2,
5200            "the cheap apply must never touch the settle gate: both entities' cheap \
5201             outcomes have landed and neither has finished phase C yet"
5202        );
5203
5204        core.release_phase_c_for_test(&key_a);
5205        core.wait_phase_c_finished_for_test(&key_a);
5206        assert_eq!(
5207            core.settle_gate_count_for_test(),
5208            1,
5209            "exactly one entity finished, so the gate must fall by exactly one, not two \
5210             (double-counted) and not zero (left short)"
5211        );
5212
5213        core.release_phase_c_for_test(&key_b);
5214        core.wait_phase_c_finished_for_test(&key_b);
5215        assert_eq!(
5216            core.settle_gate_count_for_test(),
5217            0,
5218            "both entities finished, so the gate must be fully drained"
5219        );
5220    }
5221
5222    /// The gate [`Core::hold_phase_c_for_test`] last registered for `key`, so a test can
5223    /// still name one a later registration for the same entity has replaced in the map.
5224    fn registered_gate(core: &Core, key: &EntityKey) -> PhaseCGateHandle {
5225        core.phase_c_gates
5226            .lock()
5227            .unwrap()
5228            .get(key)
5229            .cloned()
5230            .expect("hold_phase_c_for_test must be called before reading its gate")
5231    }
5232
5233    /// Opens `gate` directly rather than through [`Core::release_phase_c_for_test`], which
5234    /// resolves by key and so cannot name a gate a later registration has replaced.
5235    fn release_gate(gate: &PhaseCGateHandle) {
5236        let (lock, cvar) = &**gate;
5237        lock.lock().unwrap().may_proceed = true;
5238        cvar.notify_all();
5239    }
5240
5241    fn gate_is_finished(gate: &PhaseCGateHandle) -> bool {
5242        gate.0.lock().unwrap().finished
5243    }
5244
5245    /// A probe signals the phase C gate its own Generation was dispatched against, never
5246    /// whatever gate the map holds by the time that probe finishes.
5247    ///
5248    /// Reading the map twice per probe, once before phase C and once after, made the gate
5249    /// a probe signalled a function of when it got there: a probe from an already-settled
5250    /// Generation, past its own first read but not yet past its second, would find a gate
5251    /// registered in between and mark it finished, so the wait a later Generation was
5252    /// making returned before that Generation had applied anything or touched the settle
5253    /// gate. Registering a second gate for the same entity while the first is still held
5254    /// open is that interleaving with the timing taken out of it: the parked probe took
5255    /// the first gate, and the map holds the second by the time it finishes.
5256    #[test]
5257    fn a_probe_signals_the_phase_c_gate_its_own_generation_was_dispatched_against() {
5258        let dir = tempfile::tempdir().expect("temp dir");
5259        let root = root_of(&dir);
5260        init_repo_with_a_commit(&root.join("repo"));
5261
5262        let (core, launched) = started_and_settled(spec(vec![root]));
5263        let key = launched.entities[0].key.clone();
5264
5265        core.hold_phase_c_for_test(&key);
5266        let dispatched_against = registered_gate(&core, &key);
5267        core.refresh(std::slice::from_ref(&key));
5268        core.wait_phase_c_landed_for_test(&key);
5269
5270        core.hold_phase_c_for_test(&key);
5271        let registered_later = registered_gate(&core, &key);
5272        release_gate(&dispatched_against);
5273
5274        wait_for(
5275            "the held probe to signal the gate its own Generation was dispatched against",
5276            || gate_is_finished(&dispatched_against),
5277        );
5278        assert!(
5279            !gate_is_finished(&registered_later),
5280            "a gate registered after this Generation dispatched must never be marked \
5281             finished by it: a test waiting on that gate would return before this \
5282             Generation had applied its outcome or decremented the settle gate"
5283        );
5284    }
5285
5286    /// A probe finishing clears its own Generation's in-flight entry, never whatever the
5287    /// table holds under that key by the time it gets there.
5288    ///
5289    /// Cancellation is cooperative (refresh.md's "Cancellation"), so a superseded probe
5290    /// runs to completion and reaches `apply_probe_outcome` after the Generation that
5291    /// superseded it has already put its own entry under the same key. Clearing by key
5292    /// alone deleted that live entry, and refresh.md's "Supersession" then had nothing to
5293    /// set: the Generation after it found no previous entry, so the entity's interrupt
5294    /// flag stayed false and its probe ran on uncancelled, which is the 1.79x ADR 0013
5295    /// measured. Parking a probe at its phase C gate and superseding it while it is held
5296    /// is that interleaving with the timing taken out of it.
5297    #[test]
5298    fn a_probe_finishing_clears_only_its_own_generations_in_flight_entry() {
5299        let dir = tempfile::tempdir().expect("temp dir");
5300        let root = root_of(&dir);
5301        init_repo_with_a_commit(&root.join("repo"));
5302
5303        let (core, launched) = started_and_settled(spec(vec![root]));
5304        let key = launched.entities[0].key.clone();
5305
5306        core.hold_phase_c_for_test(&key);
5307        core.refresh(std::slice::from_ref(&key));
5308        core.wait_phase_c_landed_for_test(&key);
5309
5310        // The Generation that supersedes the parked probe, holding the interrupt flag the
5311        // `refresh` below has to be able to find and set.
5312        let superseding = core.begin_shared_generation_for_test(std::slice::from_ref(&key));
5313
5314        core.release_phase_c_for_test(&key);
5315        core.wait_phase_c_finished_for_test(&key);
5316
5317        core.refresh(std::slice::from_ref(&key));
5318        core.wait_dispatched_for_test();
5319
5320        assert!(
5321            superseding.cancels[&key].load(Ordering::Acquire),
5322            "a probe from a Generation that has already been superseded must leave the \
5323             live Generation's in-flight entry alone, or the Generation after it has \
5324             nothing to interrupt"
5325        );
5326    }
5327
5328    /// Criterion 5, the honest half: a concurrent pool's *completion* order is not
5329    /// dispatch order and asserting it would make this test flaky in exact proportion to
5330    /// how well rayon's scheduler works, so this asserts *dispatch* order instead, which is
5331    /// deterministic because `refresh`'s own dispatch loop is a single sequential pass over
5332    /// `order` that spawns work without ever waiting on it. `dispatch_order` itself, the
5333    /// function that actually builds the cursor-then-visible-then-rest sequence
5334    /// `refresh.md`'s "Scope and order" names, lives in the `repon` crate and is tested
5335    /// there: `core-api.md`'s ownership table gives that computation to the consumer, never
5336    /// to this crate. What this test proves on the core side is the half core-api.md commits
5337    /// to: `refresh` dispatches in exactly the order it is handed, position for position,
5338    /// never reordered by any heuristic of its own (never, per `refresh.md`, by predicted
5339    /// cost). A hand-built three-tier order stands in for what `dispatch_order` would
5340    /// produce, six entities discovered, one named cursor, two named visible, three left
5341    /// over in discovery order.
5342    #[test]
5343    fn refresh_dispatches_phase_c_in_exactly_the_order_it_is_given() {
5344        let dir = tempfile::tempdir().expect("temp dir");
5345        let root = root_of(&dir);
5346        const ENTITY_COUNT: usize = 6;
5347        for index in 0..ENTITY_COUNT {
5348            init_repo_with_a_commit(&root.join(format!("repo-{index}")));
5349        }
5350
5351        let (core, launched) = started_and_settled(spec(vec![root]));
5352        let discovery_order: Vec<EntityKey> = launched
5353            .entities
5354            .iter()
5355            .map(|entity| entity.key.clone())
5356            .collect();
5357        assert_eq!(
5358            discovery_order.len(),
5359            ENTITY_COUNT,
5360            "expected every repo discovered"
5361        );
5362
5363        // The cursor row, then the visible rows (never the cursor's own row twice), then
5364        // everything else in discovery order: refresh.md's own three tiers, hand-assembled
5365        // the way `dispatch_order` would.
5366        let cursor = discovery_order[3].clone();
5367        let visible = [discovery_order[1].clone(), discovery_order[4].clone()];
5368        let mut three_tier_order = vec![cursor.clone()];
5369        three_tier_order.extend(visible.iter().cloned());
5370        for key in &discovery_order {
5371            if *key != cursor && !visible.contains(key) {
5372                three_tier_order.push(key.clone());
5373            }
5374        }
5375        assert_eq!(
5376            three_tier_order.len(),
5377            ENTITY_COUNT,
5378            "sanity check: the hand-built order must cover every discovered entity exactly \
5379             once"
5380        );
5381
5382        core.refresh(&three_tier_order);
5383        core.settle();
5384
5385        assert_eq!(
5386            core.dispatch_log_for_test(),
5387            three_tier_order,
5388            "refresh must dispatch phase C in exactly the order it was given: the cursor \
5389             row, then the visible rows, then the rest in discovery order"
5390        );
5391    }
5392
5393    /// The defining behaviour for the shared-handle probe path: discovery leaves
5394    /// one thread-safe handle per entity, and a `refresh` reuses that same `Arc`
5395    /// rather than opening the repository again, proven by pointer identity
5396    /// surviving a probe rather than by inference from timing.
5397    #[test]
5398    fn refresh_reuses_the_cached_repository_handle_rather_than_reopening_it() {
5399        let dir = tempfile::tempdir().expect("temp dir");
5400        let root = root_of(&dir);
5401        let repo = root.join("repo");
5402        init_repo_with_a_commit(&repo);
5403
5404        let core = Core::start_discovered(spec(vec![root]));
5405        let key = core.snapshot().entities[0].key.clone();
5406        let before = core
5407            .cached_repo_handle_for_test(&key)
5408            .expect("discovery should have cached a handle");
5409
5410        core.refresh(std::slice::from_ref(&key));
5411        core.settle();
5412
5413        let after = core
5414            .cached_repo_handle_for_test(&key)
5415            .expect("the cached handle should still be there after a refresh");
5416        assert!(
5417            Arc::ptr_eq(&before, &after),
5418            "a refresh must reuse the cached handle, not replace it with a new one"
5419        );
5420    }
5421
5422    /// `refresh_running` reads true from the instant `refresh` returns, before its spawned
5423    /// dispatch has raised a single probe: `refresh` reserves the Generation and records the
5424    /// dispatch debt on the calling thread, so a caller reading this the same frame it
5425    /// dispatched must never see a false "nothing outstanding". It reads false again once
5426    /// the Generation has fully landed.
5427    #[test]
5428    fn refresh_running_reads_true_the_instant_refresh_returns_and_false_once_it_settles() {
5429        let dir = tempfile::tempdir().expect("temp dir");
5430        let root = root_of(&dir);
5431        init_repo_with_a_commit(&root.join("repo"));
5432
5433        let core = Core::start_discovered(spec(vec![root]));
5434        core.settle();
5435        assert!(
5436            !core.refresh_running(),
5437            "sanity: nothing outstanding once startup has settled"
5438        );
5439
5440        let keys: Vec<EntityKey> = core
5441            .snapshot()
5442            .entities
5443            .iter()
5444            .map(|entity| entity.key.clone())
5445            .collect();
5446        core.refresh(&keys);
5447        assert!(
5448            core.refresh_running(),
5449            "refresh reserves its Generation and records the dispatch debt before it \
5450             returns, so this must already read true"
5451        );
5452
5453        core.settle();
5454        assert!(
5455            !core.refresh_running(),
5456            "settle blocks until nothing is outstanding, so this must read false once it \
5457             returns"
5458        );
5459    }
5460
5461    /// A key with no cached handle, either because it was never discovered or
5462    /// because discovery could not open it, still gets a real answer: the probe
5463    /// falls back to opening the repository itself rather than failing outright.
5464    #[test]
5465    fn probing_a_key_with_no_cached_handle_still_opens_the_repository_itself() {
5466        let dir = tempfile::tempdir().expect("temp dir");
5467        let root = root_of(&dir);
5468        let repo = root.join("repo");
5469        init_repo_with_a_commit(&repo);
5470
5471        // A core discovering an unrelated, empty root, so `repo` is never cached.
5472        let empty_root = root_of(&tempfile::tempdir().expect("temp dir"));
5473        let core = Core::start_discovered(spec(vec![empty_root]));
5474        let key = EntityKey::new(Arc::from(repo.as_path()));
5475        assert!(core.cached_repo_handle_for_test(&key).is_none());
5476
5477        let entity = core.probe_now(&key);
5478
5479        assert!(matches!(
5480            entity.branch.settled(),
5481            Some(Settled::Known {
5482                value: Head::Branch { .. },
5483                at: _,
5484                stale: _
5485            })
5486        ));
5487    }
5488
5489    /// An empty order names no key, so the Generation it starts must reach no entity at
5490    /// all. Read off the dispatch log and the in-flight flag rather than off an unprobed
5491    /// cell, since launch's own Generation has already filled every cell by here.
5492    #[test]
5493    fn an_empty_order_dispatches_nothing_and_settle_returns_immediately() {
5494        let dir = tempfile::tempdir().expect("temp dir");
5495        let root = root_of(&dir);
5496        let repo = root.join("repo");
5497        init_repo_with_a_commit(&repo);
5498
5499        let (core, _launched) = started_and_settled(spec(vec![root]));
5500        assert!(
5501            !core.dispatch_log_for_test().is_empty(),
5502            "launch dispatched nothing, so an empty log below would say nothing about the \
5503             empty order"
5504        );
5505
5506        core.refresh(&[]);
5507        core.wait_dispatched_for_test();
5508
5509        assert_eq!(
5510            core.dispatch_log_for_test(),
5511            Vec::new(),
5512            "an empty order must dispatch no probe"
5513        );
5514        // The number is the claim here, not a backstop: an order naming nobody raises no
5515        // probe, so the gate is already at zero and this must come back settled at once
5516        // rather than eventually.
5517        let settled = core
5518            .try_settle(Duration::from_millis(50))
5519            .expect("an empty order raises no probe, so the settle gate is already at zero");
5520        assert!(!settled.entities[0].branch.is_in_flight());
5521    }
5522
5523    /// One entity left owing a probe that nothing will ever complete: no tick is sent, so
5524    /// the deadline sweep that would otherwise time the cell out never runs, and the settle
5525    /// gate stays above zero for as long as anyone waits on it.
5526    ///
5527    /// Returns the live `Core` and the tick sender, which the caller must hold: dropping it
5528    /// stops the dedicated thread's own select arm, and a `Core` whose thread has gone is a
5529    /// different fixture from the one these waits mean to test.
5530    fn one_probe_owed_that_never_lands(
5531        dir: &tempfile::TempDir,
5532    ) -> (Core, crossbeam_channel::Sender<Instant>) {
5533        let root = root_of(dir);
5534        init_repo_with_a_commit(&root.join("repo"));
5535        let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
5536        let core = Core::start_for_test(spec(vec![root]), Duration::from_secs(3600), tick_rx)
5537            .discovered()
5538            .core;
5539        let key = settle_launch(&core).entities[0].key.clone();
5540        core.begin_untracked_probe_for_test(&key);
5541        (core, tick_tx)
5542    }
5543
5544    /// The defect this pair exists for: a settle that gives up used to be indistinguishable
5545    /// from one that succeeded, so the table it handed back was read as an answer and the
5546    /// run failed several steps downstream with nothing left naming the wait.
5547    ///
5548    /// [`Core::settle`]'s half is to report at the wait, the way `liveness::wait_for` does.
5549    /// Driven through `settle_within` rather than `settle` so the expiry path is exercised
5550    /// without waiting out a real backstop.
5551    #[test]
5552    #[should_panic(expected = "waiting for everything this Core has in flight to land")]
5553    fn a_settle_that_expires_reports_at_the_wait_rather_than_returning_the_table() {
5554        let dir = tempfile::tempdir().expect("temp dir");
5555        let (core, _tick_tx) = one_probe_owed_that_never_lands(&dir);
5556
5557        core.settle_within(Duration::from_millis(20));
5558    }
5559
5560    /// [`Core::try_settle`]'s half of the same claim, for the callers that mean to degrade
5561    /// rather than fail: the expiry comes back as `Err`, so the unsettled table can only be
5562    /// reached by a caller that has already acknowledged the wait gave up.
5563    #[test]
5564    fn try_settle_hands_an_expiry_back_as_an_error_carrying_the_table_it_gave_up_on() {
5565        let dir = tempfile::tempdir().expect("temp dir");
5566        let (core, _tick_tx) = one_probe_owed_that_never_lands(&dir);
5567
5568        let unsettled = core
5569            .try_settle(Duration::from_millis(20))
5570            .expect_err("a probe nothing will ever complete cannot settle");
5571
5572        assert!(
5573            unsettled.entities[0].branch.is_in_flight(),
5574            "the Err arm must still carry the table as it stood, so a caller that degrades \
5575             deliberately has something to degrade with"
5576        );
5577    }
5578
5579    /// The other arm, so the two are told apart by what actually happened rather than by
5580    /// `Err` being the only reachable answer.
5581    #[test]
5582    fn try_settle_hands_a_generation_that_really_landed_back_as_ok() {
5583        let dir = tempfile::tempdir().expect("temp dir");
5584        let root = root_of(&dir);
5585        init_repo_with_a_commit(&root.join("repo"));
5586
5587        let (core, launched) = started_and_settled(spec(vec![root]));
5588        let key = launched.entities[0].key.clone();
5589        core.refresh(std::slice::from_ref(&key));
5590
5591        let settled = core
5592            .try_settle(BACKSTOP)
5593            .expect("a dispatched Generation must land inside the backstop");
5594
5595        assert!(!settled.entities[0].branch.is_in_flight());
5596    }
5597
5598    /// A Launcher return re-probes one entity through `probe_now`, so every cell a
5599    /// Generation settles must settle here too. `sync` is the one most recently added and
5600    /// the one a merge is most likely to drop, since no other test reads it off this path.
5601    #[test]
5602    fn probe_now_settles_the_sync_cell_as_well_as_the_branch_it_depends_on() {
5603        let dir = tempfile::tempdir().expect("temp dir");
5604        let root = root_of(&dir);
5605        let repo = root.join("repo");
5606        init_repo_with_a_commit(&repo);
5607
5608        let core = Core::start_discovered(spec(vec![root]));
5609        let key = core.snapshot().entities[0].key.clone();
5610
5611        let entity = core.probe_now(&key);
5612
5613        assert!(
5614            matches!(
5615                entity.sync.settled(),
5616                Some(Settled::Known {
5617                    value: SyncState::NoRemote,
5618                    at: _,
5619                    stale: _
5620                })
5621            ),
5622            "expected probe_now to settle sync, got {:?}",
5623            entity.sync.settled()
5624        );
5625    }
5626
5627    /// The same guard as the `sync` one above, for `base`: `probe_now` must settle it
5628    /// too, not only the dispatch loop `refresh` drives.
5629    #[test]
5630    fn probe_now_settles_the_base_cell_as_well_as_the_branch_it_depends_on() {
5631        let dir = tempfile::tempdir().expect("temp dir");
5632        let root = root_of(&dir);
5633        let repo = root.join("repo");
5634        init_repo_with_a_commit(&repo);
5635
5636        let core = Core::start_discovered(spec(vec![root]));
5637        let key = core.snapshot().entities[0].key.clone();
5638
5639        let entity = core.probe_now(&key);
5640
5641        assert!(
5642            matches!(entity.base.settled(), Some(Settled::NotApplicable)),
5643            "expected probe_now to settle base Not applicable for a Repo with no remote, \
5644             got {:?}",
5645            entity.base.settled()
5646        );
5647    }
5648
5649    /// The end-to-end wiring `probe_now`'s own guard above cannot prove: a real
5650    /// `refresh` dispatch, through `CheapProbeOutcomes`, must land a genuine
5651    /// computed `base` count on the table, not just a Not-applicable fallback.
5652    #[test]
5653    fn refresh_settles_a_real_base_count_against_the_resolved_default_branch() {
5654        let dir = tempfile::tempdir().expect("temp dir");
5655        let root = root_of(&dir);
5656        let repo = root.join("repo");
5657        init_repo_with_a_commit(&repo);
5658        git(
5659            &repo,
5660            &[
5661                "remote",
5662                "add",
5663                "origin",
5664                "https://example.invalid/repo.git",
5665            ],
5666        );
5667        let root_sha = head_sha(&repo);
5668        // The default branch (`origin/main`, resolved through rung 3's name list
5669        // since no `origin/HEAD` exists) moves one commit ahead of this Repo's own
5670        // checked-out branch, which never gets its own upstream configured, so
5671        // `sync` reads `-` while `base` still has a resolved default branch to
5672        // count behind.
5673        git(&repo, &["commit", "--allow-empty", "-m", "second"]);
5674        let tip_sha = head_sha(&repo);
5675        git(&repo, &["reset", "--hard", &root_sha]);
5676        git(&repo, &["update-ref", "refs/remotes/origin/main", &tip_sha]);
5677
5678        let core = Core::start_discovered(spec(vec![root]));
5679        let key = core.snapshot().entities[0].key.clone();
5680
5681        core.refresh(std::slice::from_ref(&key));
5682        let settled = core.settle();
5683
5684        assert!(
5685            matches!(
5686                settled.entities[0].base.settled(),
5687                Some(Settled::Known {
5688                    value: 1,
5689                    at: _,
5690                    stale: _
5691                })
5692            ),
5693            "expected a real refresh to settle base's live count against the resolved \
5694             default branch, got {:?}",
5695            settled.entities[0].base.settled()
5696        );
5697    }
5698
5699    /// The same guard as the `sync` one above, for `dirty`: it is the cell most recently
5700    /// added to this path, and dropping its settle here leaves every other test green.
5701    /// The repo carries one untracked file so a settled cell has to hold the counted
5702    /// value, not a zeroed placeholder that a default-constructed `DirtyCounts` would
5703    /// also satisfy.
5704    #[test]
5705    fn probe_now_settles_the_dirty_cell_with_the_counts_it_probed() {
5706        let dir = tempfile::tempdir().expect("temp dir");
5707        let root = root_of(&dir);
5708        let repo = root.join("repo");
5709        init_repo_with_a_commit(&repo);
5710        fs::write(repo.join("untracked.txt"), "x").expect("write untracked file");
5711
5712        let core = Core::start_discovered(spec(vec![root]));
5713        let key = core.snapshot().entities[0].key.clone();
5714
5715        let entity = core.probe_now(&key);
5716
5717        assert!(
5718            matches!(
5719                entity.dirty.settled(),
5720                Some(Settled::Known {
5721                    value: DirtyCounts {
5722                        modified: 0,
5723                        untracked: 1,
5724                        deleted: 0,
5725                    },
5726                    at: _,
5727                    stale: _
5728                })
5729            ),
5730            "expected probe_now to settle dirty with the one untracked path, got {:?}",
5731            entity.dirty.settled()
5732        );
5733    }
5734
5735    #[test]
5736    fn probe_now_updates_the_entity_synchronously_with_no_refresh_call() {
5737        let dir = tempfile::tempdir().expect("temp dir");
5738        let root = root_of(&dir);
5739        let repo = root.join("repo");
5740        init_repo_with_a_commit(&repo);
5741
5742        let core = Core::start_discovered(spec(vec![root]));
5743        let key = core.snapshot().entities[0].key.clone();
5744
5745        let entity = core.probe_now(&key);
5746
5747        assert!(matches!(
5748            entity.branch.settled(),
5749            Some(Settled::Known {
5750                value: Head::Branch { .. },
5751                at: _,
5752                stale: _
5753            })
5754        ));
5755    }
5756
5757    /// The one-function guarantee: whether an entity's name is set by discovery at
5758    /// `Core::start` or by `probe_now`'s fallback insert for a key the table did
5759    /// not already know, both routes must produce the same string for the same
5760    /// path, since a future state file keys the Selection by this name and a
5761    /// second formatting of it would silently break restoring by name.
5762    #[test]
5763    fn the_display_name_agrees_between_discovery_and_probe_nows_fallback_insert() {
5764        let dir = tempfile::tempdir().expect("temp dir");
5765        let root = root_of(&dir);
5766        let repo = root.join("named-repo");
5767        init_repo_with_a_commit(&repo);
5768
5769        let core = Core::start_discovered(spec(vec![root]));
5770        let discovered = core.snapshot().entities[0].clone();
5771        assert_eq!(&*discovered.name, "named-repo");
5772
5773        core.dismiss(&discovered.key);
5774        assert!(core.snapshot().entities.is_empty());
5775
5776        let reinserted = core.probe_now(&discovered.key);
5777
5778        assert_eq!(
5779            reinserted.name, discovered.name,
5780            "the name discovery assigned and the name probe_now's fallback insert \
5781             assigns for the same path must be byte-identical"
5782        );
5783    }
5784
5785    #[test]
5786    fn dismiss_removes_the_entity_from_the_snapshot() {
5787        let dir = tempfile::tempdir().expect("temp dir");
5788        let root = root_of(&dir);
5789        let repo = root.join("repo");
5790        init_repo_with_a_commit(&repo);
5791
5792        let core = Core::start_discovered(spec(vec![root]));
5793        let key = core.snapshot().entities[0].key.clone();
5794
5795        core.dismiss(&key);
5796
5797        assert!(core.snapshot().entities.is_empty());
5798    }
5799
5800    /// Foundation for every criterion below: one entity's own steps run in order and a
5801    /// failure marks every later step `NotRun` rather than silently skipping it or
5802    /// running it anyway, exactly the closed set of four outcomes
5803    /// [config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md)'s
5804    /// "Actions" and `docs/spec/actions.md`'s "Step outcomes" both fix.
5805    ///
5806    /// The third step would succeed if it ran (`true` always exits zero), so its being
5807    /// stopped is what this test observes, not an accident of a step that would have
5808    /// failed anyway. It also writes a marker file rather than only exiting zero: a
5809    /// receipt correctly labelled `NotRun` is not, by itself, proof the step never ran
5810    /// (an implementation could execute a step and then paper over its result), so the
5811    /// missing file is evidence the receipt cannot fake.
5812    #[test]
5813    fn an_entitys_steps_run_in_order_and_a_failure_marks_every_later_step_not_run() {
5814        let dir = tempfile::tempdir().expect("temp dir");
5815        let root = root_of(&dir);
5816        let repo = root.join("repo");
5817        init_repo_with_a_commit(&repo);
5818        let marker = repo.join("step-three-ran");
5819
5820        let core = Core::start_discovered(spec(vec![root]));
5821        let key = core.snapshot().entities[0].key.clone();
5822        let steps = vec![
5823            step(&["true"]),
5824            step(&["sh", "-c", "exit 7"]),
5825            step(&["touch", "step-three-ran"]),
5826        ];
5827
5828        let started = core.run_action(action("reinstall", steps), std::slice::from_ref(&key));
5829
5830        assert!(started);
5831        wait_for("the fan-out to finish and write a receipt", || {
5832            !core.action_running()
5833        });
5834        let receipt = core.snapshot().entities[0]
5835            .last_action
5836            .clone()
5837            .expect("receipt written");
5838        assert_eq!(receipt.steps.len(), 3);
5839        assert_eq!(receipt.steps[0].outcome, StepOutcome::Ok);
5840        assert_eq!(receipt.steps[1].outcome, StepOutcome::Failed(7));
5841        assert_eq!(
5842            receipt.steps[2].outcome,
5843            StepOutcome::NotRun,
5844            "a step after a failure must be recorded NotRun, not silently dropped or run anyway"
5845        );
5846        assert!(
5847            !marker.exists(),
5848            "the third step's own `touch` must never have run: its marker file exists, so \
5849             the step ran despite being recorded NotRun"
5850        );
5851    }
5852
5853    /// Independent of stopping at a failure: three always-succeeding steps each append
5854    /// their own digit to the same file, so the file's final content pins the actual
5855    /// execution order rather than trusting that a linear scan of `action.steps` runs
5856    /// them in the sequence they were declared in.
5857    #[test]
5858    fn steps_run_in_the_order_theyre_declared_not_some_other_order() {
5859        let dir = tempfile::tempdir().expect("temp dir");
5860        let root = root_of(&dir);
5861        let repo = root.join("repo");
5862        init_repo_with_a_commit(&repo);
5863        let order_log = repo.join("order.log");
5864
5865        let core = Core::start_discovered(spec(vec![root]));
5866        let key = core.snapshot().entities[0].key.clone();
5867        let steps = vec![
5868            step(&["sh", "-c", "printf 1 >> order.log"]),
5869            step(&["sh", "-c", "printf 2 >> order.log"]),
5870            step(&["sh", "-c", "printf 3 >> order.log"]),
5871        ];
5872
5873        let started = core.run_action(action("ordering", steps), std::slice::from_ref(&key));
5874
5875        assert!(started);
5876        wait_for("the fan-out to finish and write a receipt", || {
5877            !core.action_running()
5878        });
5879        let receipt = core.snapshot().entities[0]
5880            .last_action
5881            .clone()
5882            .expect("receipt written");
5883        assert_eq!(receipt.steps.len(), 3);
5884        assert!(
5885            receipt
5886                .steps
5887                .iter()
5888                .all(|result| result.outcome == StepOutcome::Ok),
5889            "every step here always exits zero; this test isolates ordering from gating"
5890        );
5891        let content = fs::read_to_string(&order_log).expect("order.log written by the steps");
5892        assert_eq!(
5893            content, "123",
5894            "the file's content pins actual execution order; running the steps out of \
5895             declaration order would produce a different digit sequence here even though \
5896             every step still succeeds"
5897        );
5898    }
5899
5900    /// `docs/spec/actions.md`'s "The run on screen": a reader must see a step's own
5901    /// finished output "as it arrives", not only once the whole entity's run has ended.
5902    /// The second step sleeps long enough to give a poll a real window to observe the
5903    /// receipt mid-run; a version of `run_action_for_entity` that only wrote once, at the
5904    /// end, would never let this test observe `running: Some(_)` at all; it would either
5905    /// see no receipt (before) or the whole finished one (after), never the state in
5906    /// between where the first step is done and the second is still going.
5907    #[test]
5908    fn a_still_running_actions_finished_step_and_its_currently_executing_one_are_both_visible_before_the_whole_run_ends()
5909     {
5910        let dir = tempfile::tempdir().expect("temp dir");
5911        let root = root_of(&dir);
5912        let repo = root.join("repo");
5913        init_repo_with_a_commit(&repo);
5914
5915        let core = Core::start_discovered(spec(vec![root]));
5916        let key = core.snapshot().entities[0].key.clone();
5917        let steps = vec![step(&["true"]), step(&["sh", "-c", "sleep 0.5"])];
5918
5919        let started = core.run_action(action("reinstall", steps), std::slice::from_ref(&key));
5920        assert!(started);
5921
5922        // Waits specifically for the *second* step's own running receipt, not merely any
5923        // one: under a slow or busy machine the first step (`true`) can still be the one
5924        // reported running the first time this poll checks, which would assert the wrong
5925        // step's own shape below rather than a flaky pass.
5926        wait_for(
5927            "a receipt naming the second step running before the run finished",
5928            || {
5929                core.snapshot().entities[0]
5930                    .last_action
5931                    .as_ref()
5932                    .and_then(|receipt| receipt.running.as_ref())
5933                    .is_some_and(|running| running.label.contains("sleep"))
5934            },
5935        );
5936        let mid_run = core.snapshot().entities[0]
5937            .last_action
5938            .clone()
5939            .expect("receipt written");
5940        assert_eq!(
5941            mid_run.steps.len(),
5942            1,
5943            "the first, already-finished step must already be in `steps`"
5944        );
5945        assert_eq!(mid_run.steps[0].outcome, StepOutcome::Ok);
5946        let running = mid_run.running.expect("a step must be recorded running");
5947        assert!(
5948            running.label.contains("sleep"),
5949            "expected the running step's own label, got {:?}",
5950            running.label
5951        );
5952
5953        wait_for("the fan-out to finish", || !core.action_running());
5954        let finished = core.snapshot().entities[0]
5955            .last_action
5956            .clone()
5957            .expect("receipt written");
5958        assert!(
5959            finished.running.is_none(),
5960            "a finished receipt must carry no running step"
5961        );
5962        assert_eq!(finished.steps.len(), 2);
5963    }
5964
5965    /// `Step::shell` must actually reach the child, end to end through `run_action`,
5966    /// not merely be a field that parses. Prints `$0` inside the step's own
5967    /// command string: `sh -c <string>` with no third argument would leave `$0` reading
5968    /// whatever the shell defaults it to, never the literal `repon`
5969    /// [config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md)'s
5970    /// `shell = true` sentence requires. `executor.rs`'s own unit tests cover `run_step`
5971    /// directly; this proves `core.rs` actually sets `shell` on the `Step` it builds and
5972    /// passes it through.
5973    #[test]
5974    fn a_shell_true_step_runs_through_shell_c_with_repon_as_its_own_dollar_zero() {
5975        let dir = tempfile::tempdir().expect("temp dir");
5976        let root = root_of(&dir);
5977        let repo = root.join("repo");
5978        init_repo_with_a_commit(&repo);
5979
5980        let core = Core::start_discovered(spec(vec![root]));
5981        let key = core.snapshot().entities[0].key.clone();
5982        let steps = vec![shell_step("echo \"[$0]\"")];
5983
5984        let started = core.run_action(action("shell-step", steps), std::slice::from_ref(&key));
5985
5986        assert!(started);
5987        wait_for("the fan-out to finish and write a receipt", || {
5988            !core.action_running()
5989        });
5990        let receipt = core.snapshot().entities[0]
5991            .last_action
5992            .clone()
5993            .expect("receipt written");
5994        assert_eq!(receipt.steps.len(), 1);
5995        assert_eq!(receipt.steps[0].outcome, StepOutcome::Ok);
5996        assert_eq!(&*receipt.steps[0].output, b"[repon]\n");
5997        assert!(
5998            receipt.steps[0].shell,
5999            "the receipt's own StepResult::shell must carry the mode the step ran under"
6000        );
6001    }
6002
6003    /// `Step::interactive` must actually reach `run_step` end to end through `run_action`,
6004    /// the same proof `a_shell_true_step_runs_through_shell_c_with_repon_as_its_own_dollar_zero`
6005    /// already gives `shell`: this asserts `core.rs` sets `interactive` on the `Step` it
6006    /// builds and that the receipt carries it back, not the shell's own rc-sourcing
6007    /// behaviour, which `executor.rs`'s own `shell_argv` unit test already covers on the
6008    /// constructed argv.
6009    #[test]
6010    fn an_interactive_shell_true_step_runs_through_run_action_with_interactive_on_its_receipt() {
6011        let dir = tempfile::tempdir().expect("temp dir");
6012        let root = root_of(&dir);
6013        let repo = root.join("repo");
6014        init_repo_with_a_commit(&repo);
6015
6016        let core = Core::start_discovered(spec(vec![root]));
6017        let key = core.snapshot().entities[0].key.clone();
6018        let steps = vec![interactive_shell_step("true")];
6019
6020        let started = core.run_action(
6021            action("interactive-step", steps),
6022            std::slice::from_ref(&key),
6023        );
6024
6025        assert!(started);
6026        wait_for("the fan-out to finish and write a receipt", || {
6027            !core.action_running()
6028        });
6029        let receipt = core.snapshot().entities[0]
6030            .last_action
6031            .clone()
6032            .expect("receipt written");
6033        assert_eq!(receipt.steps.len(), 1);
6034        assert_eq!(receipt.steps[0].outcome, StepOutcome::Ok);
6035        assert!(
6036            receipt.steps[0].shell,
6037            "an interactive step is still a shell step"
6038        );
6039        assert!(
6040            receipt.steps[0].interactive,
6041            "the receipt's own StepResult::interactive must carry the mode the step ran under"
6042        );
6043    }
6044
6045    /// [`StepResult::shell`]'s own claim on the plain argv side, so the two modes are
6046    /// proven end to end through `run_action` rather than only `shell = true`: an ordinary
6047    /// step's receipt must read `false`, not merely default to it by construction.
6048    #[test]
6049    fn an_argv_step_runs_through_run_action_with_shell_false_on_its_receipt() {
6050        let dir = tempfile::tempdir().expect("temp dir");
6051        let root = root_of(&dir);
6052        let repo = root.join("repo");
6053        init_repo_with_a_commit(&repo);
6054
6055        let core = Core::start_discovered(spec(vec![root]));
6056        let key = core.snapshot().entities[0].key.clone();
6057        let steps = vec![Step {
6058            argv: vec!["true".to_string()],
6059            shell: false,
6060            interactive: false,
6061            env: Vec::new(),
6062        }];
6063
6064        let started = core.run_action(action("argv-step", steps), std::slice::from_ref(&key));
6065
6066        assert!(started);
6067        wait_for("the fan-out to finish and write a receipt", || {
6068            !core.action_running()
6069        });
6070        let receipt = core.snapshot().entities[0]
6071            .last_action
6072            .clone()
6073            .expect("receipt written");
6074        assert!(!receipt.steps[0].shell);
6075    }
6076
6077    /// Criterion 3's first half. `begin_shared_generation_for_test` puts the entity
6078    /// in flight against a Generation of its own, exactly as a real `refresh` would;
6079    /// this proves `run_action` cancels that Generation's own flag rather than merely
6080    /// starting alongside it, which is the difference between the 0.85s and 3.14s
6081    /// measurements `docs/spec/actions.md`'s "Refreshing around a run" reports.
6082    #[test]
6083    fn starting_an_action_cancels_any_generation_already_in_flight() {
6084        let dir = tempfile::tempdir().expect("temp dir");
6085        let root = root_of(&dir);
6086        let repo = root.join("repo");
6087        init_repo_with_a_commit(&repo);
6088
6089        let core = Core::start_discovered(spec(vec![root]));
6090        let key = core.snapshot().entities[0].key.clone();
6091        let in_flight = core.begin_shared_generation_for_test(std::slice::from_ref(&key));
6092        let cancel = in_flight
6093            .cancels
6094            .get(&key)
6095            .expect("the in-flight entity has a cancel flag")
6096            .clone();
6097        assert!(!cancel.load(Ordering::Acquire));
6098
6099        let started = core.run_action(
6100            action("reinstall", vec![step(&["true"])]),
6101            std::slice::from_ref(&key),
6102        );
6103
6104        assert!(started);
6105        assert!(
6106            cancel.load(Ordering::Acquire),
6107            "starting an Action must cancel a Generation already in flight, not share \
6108             execution with it"
6109        );
6110        // Drain the fan-out and its completion refresh so this test's background
6111        // thread does not outlive it.
6112        wait_for("the fan-out and its completion refresh to drain", || {
6113            !core.action_running()
6114        });
6115    }
6116
6117    /// Criterion 3's second half, and the double-refresh mutation this test is written
6118    /// to catch: a completed Action starting its own Generation *and* a second one
6119    /// left over from a naive implementation that also called `refresh` directly would
6120    /// both leave every entity settled, so counting settled entities alone cannot tell
6121    /// zero, one and two apart. Reading the table's own `generation` number after
6122    /// completion can: it must be the Generation immediately after the settled table
6123    /// this Action ran against, covering both entities although the Action only ever
6124    /// named one of them. Named by its order rather than by a number, so what launch
6125    /// itself mints cannot renumber the claim.
6126    #[test]
6127    fn a_finished_action_starts_exactly_one_generation_over_every_known_entity() {
6128        let dir = tempfile::tempdir().expect("temp dir");
6129        let root = root_of(&dir);
6130        let acted_on = root.join("acted-on");
6131        let untouched = root.join("untouched");
6132        init_repo_with_a_commit(&acted_on);
6133        init_repo_with_a_commit(&untouched);
6134
6135        let (core, before) = started_and_settled(spec(vec![root]));
6136        let acted_key = before
6137            .entities
6138            .iter()
6139            .find(|entity| entity.key.path() == acted_on)
6140            .expect("the acted-on entity is discovered")
6141            .key
6142            .clone();
6143
6144        let started = core.run_action(
6145            action("reinstall", vec![step(&["true"])]),
6146            std::slice::from_ref(&acted_key),
6147        );
6148
6149        assert!(started);
6150        wait_for(
6151            "the completion Generation to probe every known entity, including the one the \
6152             Action never touched",
6153            || {
6154                let snapshot = core.snapshot();
6155                snapshot.generation != before.generation
6156                    && snapshot.entities.iter().all(|entity| {
6157                        matches!(
6158                            entity.branch.settled(),
6159                            Some(Settled::Known {
6160                                value: _,
6161                                at: _,
6162                                stale: _
6163                            })
6164                        )
6165                    })
6166            },
6167        );
6168        assert_eq!(
6169            core.settle().generation,
6170            before.generation.successor(),
6171            "completion must start exactly one Generation: not zero (no refresh at all) and \
6172             not two (a double refresh)"
6173        );
6174    }
6175
6176    /// Criterion 5. The excluded row gets the one legitimate `not_applicable` receipt
6177    /// with no steps; the acted-on row's own step is made to fail, which is the strong
6178    /// half of the claim: a receipt with steps that failed is still not the
6179    /// `not_applicable` shape, so nothing but an excluded row can ever produce it.
6180    #[test]
6181    fn an_excluded_row_swept_into_an_action_gets_a_not_applicable_receipt_and_no_other_path_does() {
6182        let dir = tempfile::tempdir().expect("temp dir");
6183        let root = root_of(&dir);
6184        let excluded_repo = root.join("excluded");
6185        let normal_repo = root.join("normal");
6186        init_repo_with_a_commit(&excluded_repo);
6187        init_repo_with_a_commit(&normal_repo);
6188
6189        let core = Core::start_discovered(spec_with_overrides(
6190            vec![root],
6191            vec![RepoOverride {
6192                path: excluded_repo.clone(),
6193                default_branch: None,
6194                excluded: true,
6195            }],
6196        ));
6197        let snapshot = core.snapshot();
6198        let find = |path: &Path| {
6199            snapshot
6200                .entities
6201                .iter()
6202                .find(|entity| entity.key.path() == path)
6203                .unwrap_or_else(|| panic!("entity at {path:?} present"))
6204                .key
6205                .clone()
6206        };
6207        let excluded_key = find(&excluded_repo);
6208        let normal_key = find(&normal_repo);
6209        assert!(
6210            snapshot
6211                .entities
6212                .iter()
6213                .find(|entity| entity.key == excluded_key)
6214                .unwrap()
6215                .excluded
6216        );
6217
6218        let started = core.run_action(
6219            action("reinstall", vec![step(&["sh", "-c", "exit 3"])]),
6220            &[excluded_key.clone(), normal_key.clone()],
6221        );
6222
6223        assert!(started);
6224        // `!core.action_running()`, not merely "both entities have some receipt": a
6225        // still-running entity now writes an intermediate receipt naming its currently
6226        // executing step before it finishes (`docs/spec/actions.md`'s "The run on screen"),
6227        // so `last_action.is_some()` alone can be true well before `normal_key`'s own step
6228        // has actually run.
6229        wait_for("the fan-out to finish", || !core.action_running());
6230
6231        let after = core.snapshot();
6232        let receipt_of = |key: &EntityKey| {
6233            after
6234                .entities
6235                .iter()
6236                .find(|entity| entity.key == *key)
6237                .unwrap()
6238                .last_action
6239                .clone()
6240                .unwrap()
6241        };
6242        let excluded_receipt = receipt_of(&excluded_key);
6243        assert!(excluded_receipt.not_applicable());
6244        assert!(excluded_receipt.steps.is_empty());
6245
6246        let normal_receipt = receipt_of(&normal_key);
6247        assert!(
6248            !normal_receipt.not_applicable(),
6249            "a row that actually ran a step, even a failing one, must never read as \
6250             not_applicable: an excluded row is the one legitimate producer of that outcome"
6251        );
6252        assert!(!normal_receipt.steps.is_empty());
6253        assert!(normal_receipt.failed());
6254    }
6255
6256    /// Criterion 4: `operable_count` and `run_action`'s own partition must be one
6257    /// computation, not two that happen to agree today. Proven against independent
6258    /// evidence, the same way the test above does: run an Action over one excluded and
6259    /// one normal entity, then check `operable_count`'s answer against how many of the
6260    /// two actually got a real (not `not_applicable`) receipt, rather than against a
6261    /// second hand-written copy of the exclusion rule.
6262    #[test]
6263    fn operable_count_matches_how_many_entities_run_action_actually_runs_a_step_against() {
6264        let dir = tempfile::tempdir().expect("temp dir");
6265        let root = root_of(&dir);
6266        let excluded_repo = root.join("excluded");
6267        let normal_repo = root.join("normal");
6268        init_repo_with_a_commit(&excluded_repo);
6269        init_repo_with_a_commit(&normal_repo);
6270
6271        let core = Core::start_discovered(spec_with_overrides(
6272            vec![root],
6273            vec![RepoOverride {
6274                path: excluded_repo.clone(),
6275                default_branch: None,
6276                excluded: true,
6277            }],
6278        ));
6279        let snapshot = core.snapshot();
6280        let find = |path: &Path| {
6281            snapshot
6282                .entities
6283                .iter()
6284                .find(|entity| entity.key.path() == path)
6285                .unwrap_or_else(|| panic!("entity at {path:?} present"))
6286                .key
6287                .clone()
6288        };
6289        let order = [find(&excluded_repo), find(&normal_repo)];
6290
6291        assert_eq!(
6292            core.operable_count(&order),
6293            1,
6294            "one of the two rows is excluded, so exactly one is operable"
6295        );
6296
6297        let started = core.run_action(action("reinstall", vec![step(&["true"])]), &order);
6298        assert!(started);
6299
6300        wait_for("every entity in the order to carry a receipt", || {
6301            let snapshot = core.snapshot();
6302            order.iter().all(|key| {
6303                snapshot
6304                    .entities
6305                    .iter()
6306                    .find(|entity| entity.key == *key)
6307                    .and_then(|entity| entity.last_action.as_ref())
6308                    .is_some()
6309            })
6310        });
6311
6312        let after = core.snapshot();
6313        let actually_ran = after
6314            .entities
6315            .iter()
6316            .filter(|entity| order.contains(&entity.key))
6317            .filter(|entity| {
6318                entity
6319                    .last_action
6320                    .as_ref()
6321                    .is_some_and(|receipt| !receipt.not_applicable())
6322            })
6323            .count();
6324
6325        assert_eq!(
6326            core.operable_count(&order),
6327            actually_ran,
6328            "operable_count must report exactly how many rows run_action actually ran a \
6329             step against, not merely how many keys resolved"
6330        );
6331    }
6332
6333    /// [`Core::run_action_for_entity_blocking`]'s own reason to exist: it returns the
6334    /// finished receipt on the calling thread rather than handing the run off, so a caller
6335    /// needs no `wait_for` at all to see the step's own effect, unlike every `run_action`
6336    /// test above.
6337    #[test]
6338    fn run_action_for_entity_blocking_returns_the_finished_receipt_on_the_calling_thread() {
6339        let dir = tempfile::tempdir().expect("temp dir");
6340        let root = root_of(&dir);
6341        let repo = root.join("repo");
6342        init_repo_with_a_commit(&repo);
6343        let marker = repo.join("hook-ran");
6344
6345        let core = Core::start_discovered(spec_with_overrides(vec![root], Vec::new()));
6346        let key = core
6347            .snapshot()
6348            .entities
6349            .iter()
6350            .find(|entity| entity.key.path() == repo)
6351            .expect("the repo is discovered")
6352            .key
6353            .clone();
6354
6355        let receipt = core
6356            .run_action_for_entity_blocking(
6357                &action("hook", vec![step(&["touch", "hook-ran"])]),
6358                &key,
6359            )
6360            .expect("the entity is known");
6361
6362        assert!(
6363            marker.exists(),
6364            "the step must have already run by the time this call returns"
6365        );
6366        assert_eq!(receipt.steps.len(), 1);
6367        assert_eq!(receipt.steps[0].outcome, StepOutcome::Ok);
6368    }
6369
6370    /// `None` rather than a receipt for a key the table does not know: the same fallback
6371    /// every other key-addressed `Core` entry point gives one, and the caller's own signal
6372    /// for "no hook to consult" when a hook names a row `sync`'s own eligibility has already
6373    /// dropped.
6374    #[test]
6375    fn run_action_for_entity_blocking_answers_none_for_an_unknown_key() {
6376        let dir = tempfile::tempdir().expect("temp dir");
6377        let root = root_of(&dir);
6378        let core = Core::start_discovered(spec_with_overrides(vec![root.clone()], Vec::new()));
6379
6380        let unknown = EntityKey::new(Arc::from(root.join("never-discovered").as_path()));
6381
6382        assert!(
6383            core.run_action_for_entity_blocking(&action("hook", vec![step(&["true"])]), &unknown)
6384                .is_none()
6385        );
6386    }
6387
6388    /// The reversed decision itself: `when` now decides what runs, not only what a palette
6389    /// reports about it. A row the predicate proves runs a real step; a row it disproves
6390    /// gets a `Skip::Inapplicable` receipt with no steps and never spawns a child process at
6391    /// all, which the failing command below would have surfaced as a `Failed` step had it
6392    /// run (`docs/spec/actions.md`'s "The Selection and the gate", reversing what that
6393    /// section originally decided).
6394    #[test]
6395    fn run_action_skips_a_row_its_when_predicate_disproves_rather_than_running_it_anyway() {
6396        let dir = tempfile::tempdir().expect("temp dir");
6397        let root = root_of(&dir);
6398        let proved_repo = root.join("alpha");
6399        let disproved_repo = root.join("beta");
6400        init_repo_with_a_commit(&proved_repo);
6401        init_repo_with_a_commit(&disproved_repo);
6402
6403        let core = Core::start_discovered(spec(vec![root]));
6404        let snapshot = core.snapshot();
6405        let find = |path: &Path| {
6406            snapshot
6407                .entities
6408                .iter()
6409                .find(|entity| entity.key.path() == path)
6410                .unwrap_or_else(|| panic!("entity at {path:?} present"))
6411                .key
6412                .clone()
6413        };
6414        let proved_key = find(&proved_repo);
6415        let disproved_key = find(&disproved_repo);
6416        let order = [proved_key.clone(), disproved_key.clone()];
6417
6418        // A command that would mark a real run `Failed` if it ever ran, so a disproved row
6419        // that wrongly ran a step is caught by its own outcome rather than only by `skip`.
6420        let started = core.run_action(
6421            action_with_when(
6422                "reinstall",
6423                vec![step(&["sh", "-c", "exit 3"])],
6424                "name:alpha",
6425            ),
6426            &order,
6427        );
6428        assert!(started);
6429        wait_for("the fan-out to finish", || !core.action_running());
6430
6431        let after = core.snapshot();
6432        let receipt_of = |key: &EntityKey| {
6433            after
6434                .entities
6435                .iter()
6436                .find(|entity| entity.key == *key)
6437                .unwrap()
6438                .last_action
6439                .clone()
6440                .unwrap()
6441        };
6442
6443        let proved_receipt = receipt_of(&proved_key);
6444        assert_eq!(
6445            proved_receipt.skip, None,
6446            "the row the predicate proved must actually run"
6447        );
6448        assert!(proved_receipt.failed(), "its own step still ran and failed");
6449
6450        let disproved_receipt = receipt_of(&disproved_key);
6451        assert!(
6452            disproved_receipt.inapplicable(),
6453            "the row the predicate disproved must be skipped rather than run"
6454        );
6455        assert!(disproved_receipt.steps.is_empty());
6456        assert!(
6457            !disproved_receipt.failed(),
6458            "a skipped row never ran a step, so it cannot have failed one"
6459        );
6460    }
6461
6462    /// An excluded row is subtracted before an Action's `when` ever sees it, so the
6463    /// predicate narrows what is left rather than replacing that subtraction
6464    /// (`docs/spec/actions.md`'s "The Selection and the gate").
6465    ///
6466    /// Proven against `operable_count` itself rather than against a hand-written expectation:
6467    /// a predicate every remaining row satisfies must leave a total identical to that count,
6468    /// which it cannot do if the excluded row reached the tally under any of the three
6469    /// headings.
6470    #[test]
6471    fn applicability_subtracts_an_excluded_row_before_the_predicate_reads_it() {
6472        let dir = tempfile::tempdir().expect("temp dir");
6473        let root = root_of(&dir);
6474        let excluded_repo = root.join("excluded");
6475        let normal_repo = root.join("normal");
6476        init_repo_with_a_commit(&excluded_repo);
6477        init_repo_with_a_commit(&normal_repo);
6478
6479        let core = Core::start_discovered(spec_with_overrides(
6480            vec![root],
6481            vec![RepoOverride {
6482                path: excluded_repo.clone(),
6483                default_branch: None,
6484                excluded: true,
6485            }],
6486        ));
6487        let order: Vec<EntityKey> = core
6488            .snapshot()
6489            .entities
6490            .iter()
6491            .map(|entity| entity.key.clone())
6492            .collect();
6493        assert_eq!(order.len(), 2, "the fixture must discover both repos");
6494
6495        let counts = core.applicability(&order, &Filter::parse("kind:repo"));
6496
6497        assert_eq!(
6498            counts.total(),
6499            core.operable_count(&order),
6500            "the predicate must be counted over exactly the rows `operable_count` keeps"
6501        );
6502        assert_eq!(
6503            counts,
6504            Applicability {
6505                applicable: 1,
6506                inapplicable: 0,
6507                unresolved: 0,
6508            }
6509        );
6510    }
6511
6512    /// An unknown key (already dismissed, or never discovered) is silently dropped from
6513    /// the count, the same fallback `run_action` gives one: this is the half of
6514    /// `partition_operable` no fixture above exercises, since every key there resolves.
6515    #[test]
6516    fn operable_count_silently_drops_a_key_that_no_longer_resolves() {
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 real_key = core.snapshot().entities[0].key.clone();
6524        let unknown_key = EntityKey::new(Arc::from(dir.path().join("never-discovered")));
6525
6526        assert_eq!(core.operable_count(&[real_key, unknown_key]), 1);
6527    }
6528
6529    /// Criterion 6. The second call is rejected synchronously (`action_running`'s
6530    /// `compare_exchange` fails before anything else runs), so this needs no waiting to
6531    /// observe; only the cleanup wait at the end needs [`wait_for`].
6532    #[test]
6533    fn only_one_action_fan_out_runs_at_a_time_a_second_call_is_rejected_while_one_is_live() {
6534        let dir = tempfile::tempdir().expect("temp dir");
6535        let root = root_of(&dir);
6536        let repo = root.join("repo");
6537        init_repo_with_a_commit(&repo);
6538
6539        let core = Core::start_discovered(spec(vec![root]));
6540        let key = core.snapshot().entities[0].key.clone();
6541        let slow = action("first", vec![step(&["sh", "-c", "sleep 0.3"])]);
6542        let fast = action("second", vec![step(&["true"])]);
6543
6544        let first_started = core.run_action(slow, std::slice::from_ref(&key));
6545        let second_started = core.run_action(fast, std::slice::from_ref(&key));
6546
6547        assert!(first_started);
6548        assert!(
6549            !second_started,
6550            "a second run_action call must be rejected while the first is still in flight"
6551        );
6552        wait_for("the accepted first fan-out to finish", || {
6553            !core.action_running()
6554        });
6555        let receipt = core.snapshot().entities[0].last_action.clone().unwrap();
6556        assert_eq!(
6557            &*receipt.label, "first",
6558            "the surviving receipt must be the accepted first run's, never the rejected second"
6559        );
6560    }
6561
6562    // =====================================================================================
6563    // Criteria 3 and 4: `Core::hold_action`/`Core::continue_action` are their own verbs on
6564    // the core, kept apart from the generic `pause`/`resume` the probes use, and suspending
6565    // a fan-out is reversible: a held step's own progress genuinely pauses, and resumes
6566    // exactly where it left off, rather than the run merely finishing on its own regardless.
6567    // =====================================================================================
6568
6569    /// A black-box proof through the public API alone, with no reach into the step's own
6570    /// pid: a one-second step, held for 1.5s (comfortably longer than the step would ever
6571    /// take unheld) and then continued. If `hold_action` were a no-op, the step would
6572    /// already have finished on its own well before this test ever calls
6573    /// `continue_action`, and `action_running` would already read `false` at the
6574    /// mid-hold checkpoint below; that is the exact mutation this test is written to catch.
6575    #[test]
6576    fn hold_action_genuinely_pauses_a_running_steps_progress_and_continue_action_resumes_it() {
6577        let dir = tempfile::tempdir().expect("temp dir");
6578        let root = root_of(&dir);
6579        let repo = root.join("repo");
6580        init_repo_with_a_commit(&repo);
6581
6582        let core = Core::start_discovered(spec(vec![root]));
6583        let key = core.snapshot().entities[0].key.clone();
6584        let two_seconds = action("brief", vec![step(&["sh", "-c", "sleep 2"])]);
6585
6586        assert!(core.run_action(two_seconds, std::slice::from_ref(&key)));
6587        wait_for("the two-second step to actually start running", || {
6588            core.snapshot().entities[0]
6589                .last_action
6590                .as_ref()
6591                .is_some_and(|receipt| receipt.running.is_some())
6592        });
6593
6594        // The receipt's own `running: Some(_)` is written just before `run_step` is even
6595        // called, so it can race that call's own spawn, which is when the step's process
6596        // group is actually registered. SIGSTOP is idempotent, so pulsing `hold_action`
6597        // over a short bounded window (well inside the step's own 2s) is what makes that
6598        // race resolve deterministically rather than flakily, without ever risking a hang:
6599        // a stuck `hold_action` here fails this loop's own fixed iteration count, not this
6600        // test's wall clock.
6601        for _ in 0..20 {
6602            core.hold_action();
6603            thread::sleep(Duration::from_millis(20));
6604        }
6605
6606        thread::sleep(Duration::from_millis(1_800));
6607        assert!(
6608            core.action_running(),
6609            "a genuinely held step must not have finished on its own well past its own 2s \
6610             sleep; a no-op hold_action would already show this false here"
6611        );
6612
6613        core.continue_action();
6614        wait_for("continue_action to let the held step finish", || {
6615            !core.action_running()
6616        });
6617        let receipt = core.snapshot().entities[0].last_action.clone().unwrap();
6618        assert_eq!(receipt.steps[0].outcome, StepOutcome::Ok);
6619    }
6620
6621    /// `hold_action`, `continue_action` and `stop_action` must all be safe to call with no
6622    /// fan-out live: nothing to signal, so each is a plain no-op rather than a panic or a
6623    /// stray signal to nothing.
6624    #[test]
6625    fn hold_continue_and_stop_action_are_no_ops_with_no_fan_out_running() {
6626        let dir = tempfile::tempdir().expect("temp dir");
6627        let root = root_of(&dir);
6628        let repo = root.join("repo");
6629        init_repo_with_a_commit(&repo);
6630
6631        let core = Core::start_discovered(spec(vec![root]));
6632
6633        core.hold_action();
6634        core.continue_action();
6635        core.stop_action();
6636
6637        assert!(!core.action_running());
6638    }
6639
6640    // =====================================================================================
6641    // Criterion 1: Escape (`Core::stop_action`) cancels the fan-out with two signals, the
6642    // terminating one and then the uncatchable one after a grace, because the first is
6643    // trappable. Exercised through the real public seam, never by calling `RunControl`
6644    // directly, so this is `stop_action` end to end rather than only its own primitive.
6645    // =====================================================================================
6646
6647    /// A child that traps and ignores SIGTERM is the only fixture that actually
6648    /// discriminates the two-signal design from a one-signal one: a child that dies on
6649    /// SIGTERM alone would pass this test even if `stop_action` were mutated to drop its
6650    /// own SIGKILL follow-up entirely, which is exactly the regression this criterion
6651    /// exists to catch.
6652    ///
6653    /// The step sleeps [`FIXTURE_LIFETIME`], ten times the backstop every wait below
6654    /// carries, so a `stop_action` that stops working reads back as a named wait giving up
6655    /// rather than as the step ending on its own inside the wait watching it. That margin is
6656    /// the whole discrimination here, because the outcome assertion cannot supply it:
6657    /// `run_action_for_entity` stamps `Cancelled` on whatever was running the moment the run
6658    /// was cancelled, however the step actually ended. A run that does fail here leaves the
6659    /// trapping child alive until its own sleep ends, which is the price of a fixture the
6660    /// wait cannot outlast.
6661    #[test]
6662    fn stop_action_escalates_from_sigterm_to_sigkill_against_a_trapping_step() {
6663        let dir = tempfile::tempdir().expect("temp dir");
6664        let root = root_of(&dir);
6665        let repo = root.join("repo");
6666        init_repo_with_a_commit(&repo);
6667
6668        let core = Core::start_discovered(spec(vec![root]));
6669        let key = core.snapshot().entities[0].key.clone();
6670        let sleep_past_the_backstop = format!("trap '' TERM; sleep {}", FIXTURE_LIFETIME.as_secs());
6671        let trapping = action(
6672            "trapping",
6673            vec![step(&["sh", "-c", &sleep_past_the_backstop])],
6674        );
6675
6676        assert!(core.run_action(trapping, std::slice::from_ref(&key)));
6677        wait_for("the trapping step to actually start running", || {
6678            core.snapshot().entities[0]
6679                .last_action
6680                .as_ref()
6681                .is_some_and(|receipt| receipt.running.is_some())
6682        });
6683        // Gives the shell time to install its own trap before any signal can arrive; the
6684        // outcome asserted below is the actual proof, not this fixed delay.
6685        thread::sleep(Duration::from_millis(100));
6686
6687        core.stop_action();
6688
6689        wait_for(
6690            "a SIGTERM-trapping step to come down from the follow-up SIGKILL",
6691            || !core.action_running(),
6692        );
6693        let receipt = core.snapshot().entities[0].last_action.clone().unwrap();
6694        assert_eq!(receipt.steps.len(), 1);
6695        assert_eq!(
6696            receipt.steps[0].outcome,
6697            StepOutcome::Cancelled,
6698            "a step running when the run was cancelled must read Cancelled, never Failed"
6699        );
6700    }
6701
6702    // =====================================================================================
6703    // Criterion 2: cancellation produces `Cancelled`, never `NotRun`, which stays reserved
6704    // for being blocked by an earlier failure. Both outcomes are shown live in the same
6705    // run, on different entities, so they can be told apart rather than merely observed
6706    // one at a time.
6707    // =====================================================================================
6708
6709    /// One Action, two entities, dispatched together at `concurrency: 2`: `fail`'s own
6710    /// first step exits nonzero well before the run is ever cancelled, so its second step
6711    /// is a genuine `NotRun`; `slow`'s own first step is still sleeping when
6712    /// `stop_action` fires, so both of its steps read `Cancelled`. A test that only ever
6713    /// produced one of the two outcomes could not prove they are told apart; this fixture
6714    /// has both live in the same receipt set, so a mutation that collapsed one into the
6715    /// other would be caught by whichever entity it broke.
6716    #[test]
6717    fn cancelled_and_not_run_are_distinct_outcomes_shown_together_in_one_run() {
6718        let dir = tempfile::tempdir().expect("temp dir");
6719        let root = root_of(&dir);
6720        init_repo_with_a_commit(&root.join("fail"));
6721        init_repo_with_a_commit(&root.join("slow"));
6722
6723        let core = Core::start_discovered(spec(vec![root]));
6724        let snapshot = core.snapshot();
6725        let fail_key = snapshot
6726            .entities
6727            .iter()
6728            .find(|entity| &*entity.name == "fail")
6729            .expect("the fail entity is present")
6730            .key
6731            .clone();
6732        let slow_key = snapshot
6733            .entities
6734            .iter()
6735            .find(|entity| &*entity.name == "slow")
6736            .expect("the slow entity is present")
6737            .key
6738            .clone();
6739
6740        // One step list run against both entities: behaviour branches on the entity's own
6741        // directory name, which is `$PWD`'s basename in each entity's own working
6742        // directory, so `fail` fails immediately and `slow` is still running when this
6743        // test cancels the whole run.
6744        // `slow`'s branch sleeps `FIXTURE_LIFETIME` rather than a number of its own: the
6745        // wait below is on cancellation bringing the fan-out down, which a step that ends by
6746        // itself inside the backstop would satisfy without cancellation working at all.
6747        let branch_on_the_entity_name = format!(
6748            "case \"$(basename \"$PWD\")\" in fail) exit 1 ;; *) sleep {} ;; esac",
6749            FIXTURE_LIFETIME.as_secs()
6750        );
6751        let steps = vec![
6752            step(&["sh", "-c", &branch_on_the_entity_name]),
6753            step(&["true"]),
6754        ];
6755        let mut action_spec = action("mixed", steps);
6756        action_spec.concurrency = 2;
6757
6758        assert!(core.run_action(action_spec, &[fail_key.clone(), slow_key.clone()]));
6759
6760        // `fail` must have already finished (both its steps recorded) while `slow` is
6761        // still running its own first step: the two entities' own outcomes are captured
6762        // at the same moment, which is what makes them "shown together".
6763        wait_for(
6764            "`fail` finished and `slow` still running before cancelling",
6765            || {
6766                let snapshot = core.snapshot();
6767                let fail_done = snapshot
6768                    .entities
6769                    .iter()
6770                    .find(|entity| entity.key == fail_key)
6771                    .and_then(|entity| entity.last_action.as_ref())
6772                    .is_some_and(|receipt| receipt.steps.len() == 2);
6773                let slow_running = snapshot
6774                    .entities
6775                    .iter()
6776                    .find(|entity| entity.key == slow_key)
6777                    .and_then(|entity| entity.last_action.as_ref())
6778                    .is_some_and(|receipt| receipt.running.is_some());
6779                fail_done && slow_running
6780            },
6781        );
6782
6783        core.stop_action();
6784        wait_for("the fan-out to finish once cancelled", || {
6785            !core.action_running()
6786        });
6787
6788        let snapshot = core.snapshot();
6789        let fail_receipt = snapshot
6790            .entities
6791            .iter()
6792            .find(|entity| entity.key == fail_key)
6793            .and_then(|entity| entity.last_action.clone())
6794            .expect("fail's own receipt");
6795        assert_eq!(fail_receipt.steps[0].outcome, StepOutcome::Failed(1));
6796        assert_eq!(
6797            fail_receipt.steps[1].outcome,
6798            StepOutcome::NotRun,
6799            "blocked by fail's own earlier failure, not by the later cancellation"
6800        );
6801
6802        let slow_receipt = snapshot
6803            .entities
6804            .iter()
6805            .find(|entity| entity.key == slow_key)
6806            .and_then(|entity| entity.last_action.clone())
6807            .expect("slow's own receipt");
6808        assert_eq!(
6809            slow_receipt.steps[0].outcome,
6810            StepOutcome::Cancelled,
6811            "a step running when the run was cancelled must read Cancelled"
6812        );
6813        assert_eq!(
6814            slow_receipt.steps[1].outcome,
6815            StepOutcome::Cancelled,
6816            "a step that had not started when the run was cancelled must also read \
6817             Cancelled, never NotRun, which stays reserved for an earlier failure"
6818        );
6819    }
6820
6821    /// A panic anywhere inside the fan-out, a poisoned `RwLock` from an unrelated
6822    /// earlier panic is enough, must not leave `action_running` stuck true for the
6823    /// life of this `Core`. Poisons the table lock directly rather than
6824    /// injecting a fault into `run_action_for_entity`, which runs a real child process
6825    /// and has no seam for one: the fan-out's own `table_handle.write().unwrap()` then
6826    /// panics on the poisoned lock exactly the way an unrelated earlier panic would in
6827    /// production.
6828    #[test]
6829    fn a_panicking_fan_out_still_resets_action_running_so_a_later_action_can_start() {
6830        let dir = tempfile::tempdir().expect("temp dir");
6831        let root = root_of(&dir);
6832        let repo = root.join("repo");
6833        init_repo_with_a_commit(&repo);
6834
6835        // Drained before the table lock is poisoned below: a probe still in flight would
6836        // take the poison too, and a panic in one of rayon's global workers aborts the
6837        // process rather than unwinding.
6838        let (core, launched) = started_and_settled(spec(vec![root]));
6839        let key = launched.entities[0].key.clone();
6840
6841        // A step slow enough that the fan-out's own write of `last_action` cannot have
6842        // happened yet by the time the poisoning below completes: `run_action`'s own
6843        // synchronous prefix (the `compare_exchange`, `cancel_in_flight`, the read
6844        // that builds `included`) is already finished by the time this call returns,
6845        // so poisoning the lock afterwards can only reach the fan-out's own write,
6846        // inside its own spawned thread.
6847        let started = core.run_action(
6848            action("boom", vec![step(&["sh", "-c", "sleep 0.3"])]),
6849            std::slice::from_ref(&key),
6850        );
6851        assert!(started);
6852
6853        let table = Arc::clone(&core.table);
6854        thread::spawn(move || {
6855            let _guard = table.write().unwrap();
6856            panic!("deliberately poison the table lock for this test");
6857        })
6858        .join()
6859        .expect_err("the poisoning thread must itself panic to poison the lock");
6860
6861        // Without `catch_unwind` around the fan-out this never becomes false: its own write
6862        // panics on the now-poisoned lock, unwinds out of `pool.install` and skips the
6863        // `action_running.store(false, ...)` line entirely, leaving the flag stuck
6864        // true for the life of this `Core`.
6865        wait_for(
6866            "a panicking fan-out to reset action_running rather than leave it stuck true",
6867            || !core.action_running.load(Ordering::Acquire),
6868        );
6869
6870        // Clears the poison this test itself introduced to force the panic, an
6871        // artifact of the test rather than anything production code ever does, so a
6872        // real, full `run_action` call below proves the reset flag actually lets
6873        // another Action run to completion, not merely that one private atomic flipped.
6874        core.table.clear_poison();
6875
6876        let second_started = core.run_action(
6877            action("second", vec![step(&["true"])]),
6878            std::slice::from_ref(&key),
6879        );
6880        assert!(
6881            second_started,
6882            "a later Action must be able to start once the panicking one has finished"
6883        );
6884        wait_for("the second Action to run to completion", || {
6885            core.snapshot()
6886                .entities
6887                .iter()
6888                .find(|entity| entity.key == key)
6889                .and_then(|entity| entity.last_action.as_ref())
6890                .is_some_and(|receipt| &*receipt.label == "second")
6891        });
6892    }
6893
6894    /// Asserts `entity` reads exactly as a Vanished row must: still in the table,
6895    /// its last known branch value untouched, and that same cell's staleness
6896    /// forced on. Shared by the Repo and the Submodule vanish tests so both
6897    /// exercise the identical assertion rather than a Repo-shaped one and a
6898    /// Submodule-shaped one that only look alike.
6899    fn assert_vanished_with_stale_branch(entity: &EntityState, expected_branch: &str) {
6900        assert_eq!(entity.presence, crate::entity::Presence::Vanished);
6901        match entity.branch.settled() {
6902            Some(Settled::Known {
6903                value: Head::Branch { name, .. },
6904                stale: true,
6905                at: _,
6906            }) => assert_eq!(
6907                &**name, expected_branch,
6908                "a Vanished entity must keep its last known branch value"
6909            ),
6910            other => panic!(
6911                "expected the branch cell to keep its Known value and go stale, got {other:?}"
6912            ),
6913        }
6914    }
6915
6916    /// The central behaviour this ticket adds: an entity discovery no longer
6917    /// finds stays in the table with its last known values, every cell forced
6918    /// stale, rather than disappearing. Proven end to end through `refresh` and
6919    /// `settle`, which is what proves discovery itself re-ran rather than the
6920    /// entity merely being left alone.
6921    #[test]
6922    fn a_repo_removed_from_disk_stays_in_the_table_vanished_with_its_last_values() {
6923        let dir = tempfile::tempdir().expect("temp dir");
6924        let root = root_of(&dir);
6925        let repo = root.join("repo");
6926        init_repo_with_a_commit(&repo);
6927
6928        let core = Core::start_discovered(spec(vec![root]));
6929        let key = core.snapshot().entities[0].key.clone();
6930        core.refresh(std::slice::from_ref(&key));
6931        let before = core.settle();
6932        let branch_name = match before.entities[0].branch.settled() {
6933            Some(Settled::Known {
6934                value: Head::Branch { name, .. },
6935                at: _,
6936                stale: _,
6937            }) => name.to_string(),
6938            other => panic!("expected the first refresh to settle a branch, got {other:?}"),
6939        };
6940
6941        fs::remove_dir_all(&repo).expect("remove the repo from disk");
6942
6943        core.refresh(&[]);
6944        let after = core.settle();
6945
6946        assert_eq!(
6947            after.entities.len(),
6948            1,
6949            "a vanished entity must stay in the snapshot, not disappear from it"
6950        );
6951        assert_vanished_with_stale_branch(&after.entities[0], &branch_name);
6952    }
6953
6954    /// Criterion 2's "untouched by the vanished-staleness path" made behavioural, through a
6955    /// real `Core::refresh` rather than calling `mark_vanished` directly: the same pass that
6956    /// forces every settled Cell stale on this entity must leave its receipt exactly as it was.
6957    #[test]
6958    fn a_vanished_entitys_action_receipt_survives_the_vanished_staleness_pass_untouched() {
6959        let dir = tempfile::tempdir().expect("temp dir");
6960        let root = root_of(&dir);
6961        let repo = root.join("repo");
6962        init_repo_with_a_commit(&repo);
6963
6964        let core = Core::start_discovered(spec(vec![root]));
6965        let key = core.snapshot().entities[0].key.clone();
6966        let receipt = crate::entity::ActionReceipt {
6967            label: Arc::from("reinstall"),
6968            steps: Arc::from(vec![crate::entity::StepResult {
6969                label: Arc::from("pnpm install"),
6970                outcome: crate::entity::StepOutcome::Ok,
6971                output: Arc::from(&b""[..]),
6972                elapsed: Duration::from_millis(1),
6973                elision: None,
6974                shell: false,
6975                interactive: false,
6976            }]),
6977            skip: None,
6978            finished_at: Timestamp::now(),
6979            running: None,
6980        };
6981        core.set_last_action_for_test(&key, receipt.clone());
6982
6983        fs::remove_dir_all(&repo).expect("remove the repo from disk");
6984        core.refresh(&[]);
6985        let after = core.settle();
6986
6987        let entity = &after.entities[0];
6988        assert_eq!(entity.presence, crate::entity::Presence::Vanished);
6989        assert_eq!(entity.last_action, Some(receipt));
6990    }
6991
6992    /// Criterion 6's reason for `ActionReceipt` sharing rather than copying is "the snapshot
6993    /// is cloned every frame"; a bare `ActionReceipt::clone()` only proves `Arc::clone` shares,
6994    /// which holds by definition and says nothing about this design. Proven instead through
6995    /// `Core::snapshot` itself: put a receipt on a live `Core`'s table, take two snapshots, and
6996    /// assert the label and steps are the same allocation across them, not merely equal. This
6997    /// passes as written, since the sharing does hold end to end; it exists to fail if some
6998    /// intermediate step ever re-materialised the receipt's bytes.
6999    #[test]
7000    fn two_snapshots_of_an_entity_share_its_last_actions_label_and_steps_by_pointer() {
7001        let dir = tempfile::tempdir().expect("temp dir");
7002        let root = root_of(&dir);
7003        let repo = root.join("repo");
7004        init_repo_with_a_commit(&repo);
7005
7006        let core = Core::start_discovered(spec(vec![root]));
7007        let key = core.snapshot().entities[0].key.clone();
7008        let receipt = crate::entity::ActionReceipt {
7009            label: Arc::from("reinstall"),
7010            steps: Arc::from(vec![crate::entity::StepResult {
7011                label: Arc::from("pnpm install"),
7012                outcome: crate::entity::StepOutcome::Failed(1),
7013                output: Arc::from(&b""[..]),
7014                elapsed: Duration::from_millis(1),
7015                elision: None,
7016                shell: false,
7017                interactive: false,
7018            }]),
7019            skip: None,
7020            finished_at: Timestamp::now(),
7021            running: None,
7022        };
7023        core.set_last_action_for_test(&key, receipt);
7024
7025        let first = core.snapshot();
7026        let second = core.snapshot();
7027        let first_receipt = first.entities[0]
7028            .last_action
7029            .as_ref()
7030            .expect("receipt was set");
7031        let second_receipt = second.entities[0]
7032            .last_action
7033            .as_ref()
7034            .expect("receipt was set");
7035
7036        assert!(
7037            Arc::ptr_eq(&first_receipt.label, &second_receipt.label),
7038            "two snapshots of the same receipt must share the label's allocation, not \
7039             re-copy it"
7040        );
7041        assert!(
7042            Arc::ptr_eq(&first_receipt.steps, &second_receipt.steps),
7043            "two snapshots of the same receipt must share the steps slice's allocation, not \
7044             re-copy it, which is also what shares every step's own captured output"
7045        );
7046    }
7047
7048    /// A Submodule vanishes by exactly the same rule as a Repo: no code path here
7049    /// is specific to which half of discovery produced the entry. Driven through
7050    /// the Submodule half (removing its declaration from `.gitmodules`, never
7051    /// touched by the boundary walk) and asserted with the very same helper the
7052    /// Repo test above uses.
7053    #[test]
7054    fn a_submodule_removed_from_gitmodules_vanishes_by_the_same_rule_as_a_repo() {
7055        let dir = tempfile::tempdir().expect("temp dir");
7056        let root = root_of(&dir);
7057        let parent = root.join("parent");
7058        init_repo_with_a_commit(&parent);
7059        fs::write(
7060            parent.join(".gitmodules"),
7061            "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
7062        )
7063        .expect("write .gitmodules");
7064        let submodule_path = parent.join("vendor").join("lib");
7065        init_repo_with_a_commit(&submodule_path);
7066
7067        // Shown, so the explicit `refresh` just below actually dispatches a probe against
7068        // it: this test is about the Vanished rule, not about `show_submodules` gating.
7069        let mut core_spec = spec(vec![root]);
7070        core_spec.show_submodules = true;
7071        let core = Core::start_discovered(core_spec);
7072        let snapshot = core.snapshot();
7073        let submodule_key = snapshot
7074            .entities
7075            .iter()
7076            .find(|entity| matches!(entity.kind, Kind::Submodule))
7077            .expect("submodule discovered")
7078            .key
7079            .clone();
7080        core.refresh(std::slice::from_ref(&submodule_key));
7081        let before = core.settle();
7082        let submodule_before = before
7083            .entities
7084            .iter()
7085            .find(|entity| entity.key == submodule_key)
7086            .expect("submodule present");
7087        let branch_name = match submodule_before.branch.settled() {
7088            Some(Settled::Known {
7089                value: Head::Branch { name, .. },
7090                at: _,
7091                stale: _,
7092            }) => name.to_string(),
7093            other => {
7094                panic!("expected the submodule's first refresh to settle a branch, got {other:?}")
7095            }
7096        };
7097
7098        // The submodule is no longer declared: discovery's second half will no
7099        // longer produce this entry, exactly as removing the parent's own `.git`
7100        // boundary would remove a Repo's entry.
7101        fs::write(parent.join(".gitmodules"), "").expect("clear .gitmodules");
7102
7103        core.refresh(&[]);
7104        let after = core.settle();
7105
7106        let submodule_after = after
7107            .entities
7108            .iter()
7109            .find(|entity| entity.key == submodule_key)
7110            .expect("the vanished submodule must stay in the snapshot");
7111        assert_vanished_with_stale_branch(submodule_after, &branch_name);
7112    }
7113
7114    /// Dismissal writes nothing to disk, so a Repo dismissed from one `Core`
7115    /// reads as an ordinary, freshly discovered Present entity on a brand new
7116    /// `Core` over the same roots, never as a restored Vanished row: startup is
7117    /// always a Generation with an empty prior state.
7118    #[test]
7119    fn dismissal_persists_nothing_across_a_fresh_core() {
7120        let dir = tempfile::tempdir().expect("temp dir");
7121        let root = root_of(&dir);
7122        let repo = root.join("repo");
7123        init_repo_with_a_commit(&repo);
7124
7125        let first_core = Core::start_discovered(spec(vec![root.clone()]));
7126        let key = first_core.snapshot().entities[0].key.clone();
7127        first_core.dismiss(&key);
7128        assert!(first_core.snapshot().entities.is_empty());
7129        drop(first_core);
7130
7131        let second_core = Core::start_discovered(spec(vec![root]));
7132        let snapshot = second_core.snapshot();
7133
7134        assert_eq!(
7135            snapshot.entities.len(),
7136            1,
7137            "a fresh Core must discover the repo again"
7138        );
7139        assert_eq!(
7140            snapshot.entities[0].presence,
7141            crate::entity::Presence::Present,
7142            "nothing from the dismissing Core's lifetime may be persisted, so the \
7143             repo must come back Present, never restored as Vanished"
7144        );
7145    }
7146
7147    /// An entity that moves reads as vanished plus new: its old key stays in the
7148    /// table Vanished with its last values, and a brand new entity appears at the
7149    /// new path, rather than the move being recognised as a rename.
7150    #[test]
7151    fn a_repo_that_moves_reads_as_vanished_plus_new() {
7152        let dir = tempfile::tempdir().expect("temp dir");
7153        let root = root_of(&dir);
7154        let original_path = root.join("original-name");
7155        init_repo_with_a_commit(&original_path);
7156
7157        let core = Core::start_discovered(spec(vec![root.clone()]));
7158        let original_key = core.snapshot().entities[0].key.clone();
7159        core.refresh(std::slice::from_ref(&original_key));
7160        let before = core.settle();
7161        let branch_name = match before.entities[0].branch.settled() {
7162            Some(Settled::Known {
7163                value: Head::Branch { name, .. },
7164                at: _,
7165                stale: _,
7166            }) => name.to_string(),
7167            other => panic!("expected the first refresh to settle a branch, got {other:?}"),
7168        };
7169
7170        let moved_path = root.join("new-name");
7171        fs::rename(&original_path, &moved_path).expect("move the repo on disk");
7172
7173        core.refresh(&[]);
7174        let after = core.settle();
7175
7176        assert_eq!(
7177            after.entities.len(),
7178            2,
7179            "a moved entity must read as the old key vanished plus a new one present, \
7180             never as one renamed entity"
7181        );
7182        let old_entity = after
7183            .entities
7184            .iter()
7185            .find(|entity| entity.key == original_key)
7186            .expect("the old key must stay in the table");
7187        assert_vanished_with_stale_branch(old_entity, &branch_name);
7188        let new_entity = after
7189            .entities
7190            .iter()
7191            .find(|entity| entity.key != original_key)
7192            .expect("a new entity at the moved path must be present");
7193        assert_eq!(new_entity.presence, crate::entity::Presence::Present);
7194        assert_eq!(new_entity.key.path(), moved_path);
7195    }
7196
7197    /// Reappearance is vanishing's mirror: an entity discovery stops finding, and
7198    /// then finds again, must come back Present rather than staying stuck
7199    /// Vanished forever.
7200    #[test]
7201    fn a_vanished_repo_recreated_on_disk_reads_present_on_the_next_refresh() {
7202        let dir = tempfile::tempdir().expect("temp dir");
7203        let root = root_of(&dir);
7204        let repo = root.join("repo");
7205        init_repo_with_a_commit(&repo);
7206
7207        let core = Core::start_discovered(spec(vec![root]));
7208        let key = core.snapshot().entities[0].key.clone();
7209
7210        fs::remove_dir_all(&repo).expect("remove the repo from disk");
7211        core.refresh(&[]);
7212        let vanished = core.settle();
7213        assert_eq!(
7214            vanished.entities[0].presence,
7215            crate::entity::Presence::Vanished,
7216            "the repo must read Vanished once removed from disk"
7217        );
7218
7219        init_repo_with_a_commit(&repo);
7220        core.refresh(&[]);
7221        let recreated = core.settle();
7222
7223        let entity = recreated
7224            .entities
7225            .iter()
7226            .find(|entity| entity.key == key)
7227            .expect("the recreated repo must still resolve to the same entity key");
7228        assert_eq!(
7229            entity.presence,
7230            crate::entity::Presence::Present,
7231            "an entity discovery finds again after it vanished must read Present, \
7232             not stay stuck Vanished forever"
7233        );
7234    }
7235
7236    /// Discovery riding the refresh is what lets a brand new entity appear
7237    /// without a fresh `Core::start`: a repo created after `start` is picked up
7238    /// by the very next `refresh`, even though the caller's `order` cannot yet
7239    /// name a key it never saw.
7240    #[test]
7241    fn a_new_repo_created_after_start_is_discovered_by_the_next_refresh() {
7242        let dir = tempfile::tempdir().expect("temp dir");
7243        let root = root_of(&dir);
7244        init_repo_with_a_commit(&root.join("first"));
7245
7246        let core = Core::start_discovered(spec(vec![root.clone()]));
7247        assert_eq!(core.snapshot().entities.len(), 1);
7248
7249        init_repo_with_a_commit(&root.join("second"));
7250        core.refresh(&[]);
7251        let after = core.settle();
7252
7253        assert_eq!(
7254            after.entities.len(),
7255            2,
7256            "a new repo created after start must be found by the next refresh's own discovery"
7257        );
7258
7259        // The entity is usable, not merely counted: a refresh that names its key
7260        // actually probes it and settles a real cell.
7261        let new_key = after
7262            .entities
7263            .iter()
7264            .find(|entity| &*entity.name == "second")
7265            .expect("the newly discovered repo must be named by the walk")
7266            .key
7267            .clone();
7268        core.refresh(std::slice::from_ref(&new_key));
7269        let probed = core.settle();
7270        let new_entity = probed
7271            .entities
7272            .iter()
7273            .find(|entity| entity.key == new_key)
7274            .expect("the newly discovered repo must still be present");
7275        assert!(
7276            matches!(
7277                new_entity.branch.settled(),
7278                Some(Settled::Known {
7279                    value: _,
7280                    at: _,
7281                    stale: _
7282                })
7283            ),
7284            "a refresh naming the newly discovered repo's key must actually probe \
7285             it and settle its branch cell, got {:?}",
7286            new_entity.branch.settled()
7287        );
7288    }
7289
7290    /// The abandon path takes the Set out of the automatic refresh path: once one
7291    /// discovery invocation abandons, a later `refresh` does not re-run discovery
7292    /// at all, proven by a repo created afterward never appearing, not merely by
7293    /// reading an internal flag.
7294    #[test]
7295    fn an_abandoned_discovery_stops_riding_later_refreshes() {
7296        let dir = tempfile::tempdir().expect("temp dir");
7297        let root = root_of(&dir);
7298        // A wide fan of plain directories, real enough for the walk to measurably
7299        // outrun a millisecond-scale deadline, so `start`'s own discovery
7300        // abandons rather than merely being told to (`Duration::ZERO` would trip
7301        // on the very first directory regardless of what is actually here, which
7302        // could never distinguish a guarded `refresh` from an unguarded one that
7303        // simply keeps re-abandoning against the same still-huge tree).
7304        let decoys = root.join("decoys");
7305        for i in 0..4_000 {
7306            fs::create_dir(decoys.join(format!("decoy-{i}")))
7307                .or_else(|_| fs::create_dir_all(decoys.join(format!("decoy-{i}"))))
7308                .expect("create decoy dir");
7309        }
7310        let (_tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
7311
7312        let started = Core::start_for_test_with_discovery_abandon(
7313            spec(vec![root.clone()]),
7314            Duration::from_secs(3600),
7315            Duration::from_micros(500),
7316            tick_rx,
7317        )
7318        .discovered();
7319        let core = started.core;
7320        assert!(
7321            core.discovery_manual_for_test(),
7322            "walking 4,000 decoy directories against a 500 microsecond deadline \
7323             must have abandoned and taken the Set manual"
7324        );
7325
7326        // The tree shrinks back to nothing slow: if `refresh` were still (wrongly)
7327        // re-running discovery, this walk would finish comfortably inside the
7328        // same deadline and find the new repo below. Only the manual guard can
7329        // account for it staying undiscovered.
7330        fs::remove_dir_all(&decoys).expect("remove decoy directories");
7331        init_repo_with_a_commit(&root.join("second"));
7332
7333        core.refresh(&[]);
7334        let after = core.settle();
7335
7336        assert!(
7337            !after
7338                .entities
7339                .iter()
7340                .any(|entity| &*entity.name == "second"),
7341            "once discovery has abandoned, a later refresh must not re-run it, so a \
7342             repo created afterward, on a tree that would now resolve quickly, \
7343             must still never appear"
7344        );
7345    }
7346
7347    /// `rerun_discovery`'s own abandon handling, exercised by a walk that only
7348    /// abandons on a later `refresh`, never on `start`'s: the first walk, over a
7349    /// tree small enough to finish comfortably inside the deadline, must leave
7350    /// the Set automatic, and only the second walk, once the same tree has grown
7351    /// a wide fan of decoys, may flip the manual flag and leave the abandoned
7352    /// warning. Both existing abandon tests force the abandon inside `start`'s
7353    /// own walk, which can never reach this block: `refresh` gates
7354    /// `rerun_discovery` behind the manual flag `start` already set.
7355    #[test]
7356    fn a_refresh_triggered_discovery_abandon_sets_manual_and_warns() {
7357        let dir = tempfile::tempdir().expect("temp dir");
7358        let root = root_of(&dir);
7359        init_repo_with_a_commit(&root.join("first"));
7360        let (_tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
7361
7362        // The first walk runs under a deadline it cannot lose against, so this
7363        // precondition is not a race. Tightening the deadline afterwards is what
7364        // separates the walk that must survive from the walk that must abandon:
7365        // one deadline serving both is a knife edge, and scheduling latency on a
7366        // loaded machine erases any margin a wall-clock figure can buy.
7367        let started = Core::start_for_test_with_discovery_abandon(
7368            spec(vec![root.clone()]),
7369            Duration::from_secs(3600),
7370            Duration::from_secs(3600),
7371            tick_rx,
7372        )
7373        .discovered();
7374        let core = started.core;
7375        assert!(
7376            !core.discovery_manual_for_test(),
7377            "an hour-long deadline must leave the first walk automatic"
7378        );
7379
7380        // Grown only after the first walk has finished (`discovered` above joined it),
7381        // so this fan of decoys is invisible to that walk and can only be reached by a
7382        // walk `refresh` triggers itself.
7383        let decoys = root.join("decoys");
7384        for i in 0..4_000 {
7385            fs::create_dir(decoys.join(format!("decoy-{i}")))
7386                .or_else(|_| fs::create_dir_all(decoys.join(format!("decoy-{i}"))))
7387                .expect("create decoy dir");
7388        }
7389        core.set_discovery_abandon_after_for_test(Duration::from_micros(500));
7390
7391        core.refresh(&[]);
7392        // `refresh` returns the moment it has reserved its Generation; this is the
7393        // rendezvous that says its own walk has run.
7394        core.wait_dispatched_for_test();
7395
7396        assert!(
7397            core.discovery_manual_for_test(),
7398            "refresh's own rerun_discovery must abandon against the newly-grown \
7399             tree and take the Set manual, the same as an abandon at start does"
7400        );
7401        let warning = core.discovery_warning();
7402        assert!(
7403            warning
7404                .as_deref()
7405                .is_some_and(|message| message.starts_with("discovery: stopped at")),
7406            "refresh's rerun_discovery must leave the abandoned-discovery warning \
7407             behind, not merely flip the manual flag: got {warning:?}"
7408        );
7409    }
7410
7411    /// The other half: an abandoned Set going manual must not leak into a
7412    /// different `Core`. The only way this crate can express "the Set's roots or
7413    /// globs changed" today is a fresh `Core::start` (a live in-place reload has
7414    /// no entry point in `Core` yet), so this proves the manual flag lives on one
7415    /// `Core` instance rather than anywhere global.
7416    #[test]
7417    fn a_fresh_core_over_different_roots_is_unaffected_by_another_cores_abandoned_discovery() {
7418        let abandoned_dir = tempfile::tempdir().expect("temp dir");
7419        let abandoned_root = root_of(&abandoned_dir);
7420        init_repo_with_a_commit(&abandoned_root.join("first"));
7421        let (_tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
7422        let started = Core::start_for_test_with_discovery_abandon(
7423            spec(vec![abandoned_root]),
7424            Duration::from_secs(3600),
7425            Duration::ZERO,
7426            tick_rx,
7427        )
7428        .discovered();
7429        started.core.refresh(&[]);
7430        started.core.settle();
7431        assert!(
7432            started.core.discovery_manual_for_test(),
7433            "the zero-length abandon deadline must have already taken this Core manual"
7434        );
7435        drop(started.core);
7436
7437        let fresh_dir = tempfile::tempdir().expect("temp dir");
7438        let fresh_root = root_of(&fresh_dir);
7439        init_repo_with_a_commit(&fresh_root.join("first"));
7440        let fresh_core = Core::start_discovered(spec(vec![fresh_root.clone()]));
7441        assert_eq!(fresh_core.snapshot().entities.len(), 1);
7442
7443        init_repo_with_a_commit(&fresh_root.join("second"));
7444        fresh_core.refresh(&[]);
7445        let after = fresh_core.settle();
7446
7447        assert_eq!(
7448            after.entities.len(),
7449            2,
7450            "a fresh Core, standing in for the Set's roots changing, must discover \
7451             normally regardless of an earlier, unrelated Core having gone manual"
7452        );
7453    }
7454
7455    /// Proves shutdown is clean: dropping the core blocks until the dedicated
7456    /// thread has actually returned, not merely until a message was sent to it.
7457    /// The tick sender is kept alive for the whole test, so the only way the
7458    /// thread can have stopped is the shutdown message `Drop` sends.
7459    #[test]
7460    fn dropping_the_core_joins_the_dedicated_thread_before_returning() {
7461        let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
7462        let dir = tempfile::tempdir().expect("temp dir");
7463        let root = root_of(&dir);
7464
7465        let started =
7466            Core::start_for_test(spec(vec![root]), Duration::from_secs(3600), tick_rx).discovered();
7467        assert!(started.clock_alive.load(Ordering::Acquire));
7468
7469        drop(started.core);
7470
7471        assert!(
7472            !started.clock_alive.load(Ordering::Acquire),
7473            "the dedicated thread should have exited, and cleared this flag, before drop returned"
7474        );
7475        drop(tick_tx);
7476    }
7477
7478    /// Cadence is driven entirely by the injected tick channel, never by a clock of
7479    /// the loop's own: with a zero deadline, the sweep is provably ready to fire
7480    /// the instant it runs, so whether it has run is exactly whether a tick has
7481    /// been sent, proven with no sleep on either side.
7482    #[test]
7483    fn the_deadline_sweep_runs_only_when_a_tick_arrives() {
7484        let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
7485        let dir = tempfile::tempdir().expect("temp dir");
7486        let root = root_of(&dir);
7487        let repo = root.join("repo");
7488        init_repo_with_a_commit(&repo);
7489
7490        let mut spec = spec(vec![root]);
7491        spec.generation_deadline = Duration::ZERO;
7492        let started = Core::start_for_test(spec, Duration::from_secs(3600), tick_rx).discovered();
7493        let core = started.core;
7494        // Drained, so the only entry in flight below is this test's own and only the sweep
7495        // can settle it again.
7496        let key = settle_launch(&core).entities[0].key.clone();
7497
7498        core.begin_untracked_probe_for_test(&key);
7499
7500        // No tick has been sent: the sweep has not run even though the (zero)
7501        // deadline has already elapsed in real time.
7502        let before = core.snapshot();
7503        assert!(
7504            matches!(
7505                before.entities[0].branch.settled(),
7506                Some(Settled::Known {
7507                    value: _,
7508                    at: _,
7509                    stale: _
7510                })
7511            ),
7512            "the cell still holds launch's own answer here, so the Unknown below is the \
7513             sweep's write rather than a cell that was already empty"
7514        );
7515        assert!(before.entities[0].branch.is_in_flight());
7516
7517        tick_tx.send(Instant::now()).expect("send one tick");
7518        let after = core.settle();
7519
7520        assert!(matches!(
7521            after.entities[0].branch.settled(),
7522            Some(Settled::Unknown(Unknown::TimedOut))
7523        ));
7524    }
7525
7526    /// Proves the real dedicated thread's tick arm actually reaches
7527    /// [`run_poll_sweep`], not merely that [`Core::poll_once_for_test`]'s direct
7528    /// call does the right thing: a mutation deleting the call inside
7529    /// `spawn_clock_thread` would leave every other poll test in this file green
7530    /// while failing only this one. [`wait_for`] backstops the wait rather than
7531    /// asserting any particular latency: the two ticks are sent from this thread
7532    /// and merely need to be picked up by the idle dedicated thread, not to land
7533    /// within a stated budget.
7534    #[test]
7535    fn a_real_tick_through_the_dedicated_thread_reaches_the_poll_sweep_and_reprobes_a_moved_entity()
7536    {
7537        let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
7538        let dir = tempfile::tempdir().expect("temp dir");
7539        let root = root_of(&dir);
7540        let repo = root.join("repo");
7541        init_repo_with_a_commit(&repo);
7542
7543        let started =
7544            Core::start_for_test(spec(vec![root]), Duration::from_secs(3600), tick_rx).discovered();
7545        let core = started.core;
7546        let key = core.snapshot().entities[0].key.clone();
7547
7548        backdate_polled_entries(&repo);
7549
7550        // The first tick only records a baseline: nothing has moved yet against a
7551        // fingerprint that did not exist before this tick.
7552        tick_tx
7553            .send(Instant::now())
7554            .expect("send the baseline tick");
7555        wait_for(
7556            "a tick sent on the real channel to reach the poll sweep",
7557            || core.poll_sweep_count_for_test() >= 1,
7558        );
7559        assert!(core.poll_reprobed_for_test().is_empty());
7560
7561        commit_a_change(&repo, "second");
7562
7563        tick_tx
7564            .send(Instant::now())
7565            .expect("send the movement tick");
7566        wait_for(
7567            "the real tick channel to reach the poll sweep and reprobe the moved entity",
7568            || core.poll_reprobed_for_test() == vec![key.clone()],
7569        );
7570        drop(tick_tx);
7571    }
7572
7573    /// Criterion 2's whole claim, over two entities so "for that entity only" has
7574    /// something to discriminate against: committing into one of two Repos and
7575    /// running one poll sweep re-probes branch/sync/base for the moved Repo alone
7576    /// (`poll_reprobed_for_test` names exactly it, never the other), force-stales
7577    /// its `dirty` and `state` without changing their value or timestamp (the
7578    /// absence claim that no status probe ran), and leaves the untouched Repo's
7579    /// cells byte-for-byte as the prior real `refresh` left them.
7580    #[test]
7581    fn poll_reprobe_touches_only_the_moved_entity_and_never_runs_a_status_probe() {
7582        let dir = tempfile::tempdir().expect("temp dir");
7583        let root = root_of(&dir);
7584        let repo_a = root.join("repo-a");
7585        let repo_b = root.join("repo-b");
7586        init_repo_with_a_commit(&repo_a);
7587        init_repo_with_a_commit(&repo_b);
7588
7589        let core = Core::start_discovered(spec(vec![root]));
7590        let snapshot = core.snapshot();
7591        let key_a = snapshot
7592            .entities
7593            .iter()
7594            .find(|entity| entity.key.path() == repo_a)
7595            .expect("repo-a discovered")
7596            .key
7597            .clone();
7598        let key_b = snapshot
7599            .entities
7600            .iter()
7601            .find(|entity| entity.key.path() == repo_b)
7602            .expect("repo-b discovered")
7603            .key
7604            .clone();
7605
7606        core.refresh(&[key_a.clone(), key_b.clone()]);
7607        let landed = core.settle();
7608        let entity_of = |snapshot: &Snapshot, key: &EntityKey| {
7609            snapshot
7610                .entities
7611                .iter()
7612                .find(|entity| &entity.key == key)
7613                .expect("entity present")
7614                .clone()
7615        };
7616        let a_before = entity_of(&landed, &key_a);
7617        let b_before = entity_of(&landed, &key_b);
7618        let branch_at = |entity: &EntityState| match entity.branch.settled() {
7619            Some(Settled::Known {
7620                at,
7621                value: _,
7622                stale: _,
7623            }) => *at,
7624            other => panic!("expected a landed branch, got {other:?}"),
7625        };
7626        let dirty_state = |entity: &EntityState| match entity.dirty.settled() {
7627            Some(Settled::Known { value, at, stale }) => (*value, *at, *stale),
7628            other => panic!("expected a landed dirty count, got {other:?}"),
7629        };
7630        let (a_dirty_value_before, a_dirty_at_before, a_dirty_stale_before) =
7631            dirty_state(&a_before);
7632        assert!(
7633            !a_dirty_stale_before,
7634            "the fresh refresh must land dirty as not stale"
7635        );
7636
7637        backdate_polled_entries(&repo_a);
7638
7639        backdate_polled_entries(&repo_b);
7640
7641        core.poll_once_for_test();
7642        assert!(
7643            core.poll_reprobed_for_test().is_empty(),
7644            "a first sweep has nothing to compare against, so it must report no movement"
7645        );
7646
7647        commit_a_change(&repo_a, "second");
7648        core.poll_once_for_test();
7649
7650        assert_eq!(
7651            core.poll_reprobed_for_test(),
7652            vec![key_a.clone()],
7653            "only the entity whose gitdir actually moved must be re-probed"
7654        );
7655
7656        let after = core.snapshot();
7657        let a_after = entity_of(&after, &key_a);
7658        let b_after = entity_of(&after, &key_b);
7659
7660        assert_ne!(
7661            branch_at(&a_after),
7662            branch_at(&a_before),
7663            "the moved entity's branch must carry a fresh timestamp from the re-probe"
7664        );
7665        let (a_dirty_value_after, a_dirty_at_after, a_dirty_stale_after) = dirty_state(&a_after);
7666        assert_eq!(
7667            a_dirty_value_after, a_dirty_value_before,
7668            "no status probe ran, so dirty's value must be exactly what the last real refresh \
7669             landed"
7670        );
7671        assert_eq!(
7672            a_dirty_at_after, a_dirty_at_before,
7673            "no status probe ran, so dirty's timestamp must be untouched, only its stale flag \
7674             set"
7675        );
7676        assert!(
7677            a_dirty_stale_after,
7678            "the moved entity's dirty cell must go stale on poll evidence"
7679        );
7680
7681        assert_eq!(
7682            branch_at(&b_after),
7683            branch_at(&b_before),
7684            "the untouched entity's branch must be exactly as the prior refresh left it"
7685        );
7686        let (b_dirty_value_after, b_dirty_at_after, b_dirty_stale_after) = dirty_state(&b_after);
7687        let (b_dirty_value_before, b_dirty_at_before, b_dirty_stale_before) =
7688            dirty_state(&b_before);
7689        assert_eq!(b_dirty_value_after, b_dirty_value_before);
7690        assert_eq!(b_dirty_at_after, b_dirty_at_before);
7691        assert_eq!(
7692            b_dirty_stale_after, b_dirty_stale_before,
7693            "an entity the sweep found unmoved must never go stale"
7694        );
7695    }
7696
7697    /// Criterion 3's attached half, and one of `refresh.md`'s two named traps: a
7698    /// commit on an attached HEAD never touches `.git/HEAD` at all, only
7699    /// `.git/logs/HEAD`. The poll must still see the commit, through `index`
7700    /// rather than through `HEAD`.
7701    #[test]
7702    fn poll_detects_an_attached_commit_through_index_while_head_itself_never_moves() {
7703        let dir = tempfile::tempdir().expect("temp dir");
7704        let root = root_of(&dir);
7705        let repo = root.join("repo");
7706        init_repo_with_a_commit(&repo);
7707
7708        let core = Core::start_discovered(spec(vec![root]));
7709        let key = core.snapshot().entities[0].key.clone();
7710        backdate_polled_entries(&repo);
7711        core.poll_once_for_test();
7712        assert!(core.poll_reprobed_for_test().is_empty());
7713
7714        let head_path = repo.join(".git").join("HEAD");
7715        let head_mtime_before = fs::metadata(&head_path)
7716            .expect("stat HEAD")
7717            .modified()
7718            .expect("HEAD mtime");
7719
7720        commit_a_change(&repo, "second");
7721
7722        let head_mtime_after = fs::metadata(&head_path)
7723            .expect("stat HEAD")
7724            .modified()
7725            .expect("HEAD mtime");
7726        assert_eq!(
7727            head_mtime_before, head_mtime_after,
7728            "a commit on an attached HEAD must never touch HEAD itself"
7729        );
7730
7731        core.poll_once_for_test();
7732        assert_eq!(
7733            core.poll_reprobed_for_test(),
7734            vec![key],
7735            "the poll must still detect the attached commit, through index rather than HEAD"
7736        );
7737    }
7738
7739    /// Criterion 3's detached half: [head.md](https://github.com/paulchiu/repon/blob/main/docs/spec/head.md)'s
7740    /// claim that a detached row's evidence is better than an attached row's,
7741    /// because a commit on a detached HEAD writes the new object id straight into
7742    /// the per-worktree `HEAD` file itself. Run against a real linked Worktree,
7743    /// never the main working tree, since that per-worktree file is exactly what
7744    /// distinguishes this case from the attached one above.
7745    #[test]
7746    fn poll_detects_a_detached_commit_through_the_per_worktree_head_file() {
7747        let dir = tempfile::tempdir().expect("temp dir");
7748        let root = root_of(&dir);
7749        let parent = root.join("parent");
7750        init_repo_with_a_commit(&parent);
7751        let worktree_path = root.join("detached-worktree");
7752        let status = Command::new("git")
7753            .arg("-C")
7754            .arg(&parent)
7755            .args([
7756                "worktree",
7757                "add",
7758                "--detach",
7759                worktree_path.to_str().expect("utf8 path"),
7760            ])
7761            .status()
7762            .expect("run git worktree add");
7763        assert!(status.success());
7764
7765        let core = Core::start_discovered(spec(vec![root]));
7766        let snapshot = core.snapshot();
7767        let worktree_key = snapshot
7768            .entities
7769            .iter()
7770            .find(|entity| matches!(entity.kind, Kind::Worktree))
7771            .expect("worktree discovered")
7772            .key
7773            .clone();
7774
7775        backdate_polled_entries(&parent);
7776        backdate_polled_entries(&worktree_path);
7777
7778        core.poll_once_for_test();
7779        assert!(core.poll_reprobed_for_test().is_empty());
7780
7781        let worktree_head_path = parent
7782            .join(".git")
7783            .join("worktrees")
7784            .join("detached-worktree")
7785            .join("HEAD");
7786        let head_mtime_before = fs::metadata(&worktree_head_path)
7787            .expect("stat the per-worktree HEAD")
7788            .modified()
7789            .expect("HEAD mtime");
7790
7791        commit_a_change(&worktree_path, "on the detached worktree");
7792
7793        let head_mtime_after = fs::metadata(&worktree_head_path)
7794            .expect("stat the per-worktree HEAD")
7795            .modified()
7796            .expect("HEAD mtime");
7797        assert_ne!(
7798            head_mtime_before, head_mtime_after,
7799            "a commit on a detached HEAD must write the new object id straight into its own \
7800             HEAD file"
7801        );
7802
7803        core.poll_once_for_test();
7804        assert_eq!(
7805            core.poll_reprobed_for_test(),
7806            vec![worktree_key],
7807            "the poll must detect the detached commit via the per-worktree HEAD file"
7808        );
7809    }
7810
7811    /// Criterion 4's elapsed-age writer, wired through `Core::snapshot` end to end:
7812    /// `status_stale_after` from `CoreSpec` is what decides whether a freshly
7813    /// landed `dirty` cell already reads Stale. A `Duration::from_nanos(1)`
7814    /// threshold has necessarily already elapsed by the time `snapshot` runs
7815    /// afterwards, so this needs no sleep and depends on no stated latency budget,
7816    /// only on real wall-clock time having advanced at all between two calls.
7817    #[test]
7818    fn snapshot_ages_a_freshly_landed_dirty_cell_stale_once_status_stale_after_has_elapsed() {
7819        let dir = tempfile::tempdir().expect("temp dir");
7820        let root = root_of(&dir);
7821        let repo = root.join("repo");
7822        init_repo_with_a_commit(&repo);
7823
7824        let mut short_lived = spec(vec![root]);
7825        short_lived.status_stale_after = Duration::from_nanos(1);
7826        let core = Core::start_discovered(short_lived);
7827        let key = core.snapshot().entities[0].key.clone();
7828        core.refresh(std::slice::from_ref(&key));
7829        core.settle();
7830
7831        let aged = core.snapshot();
7832        match aged.entities[0].dirty.settled() {
7833            Some(Settled::Known {
7834                stale: true,
7835                value: _,
7836                at: _,
7837            }) => {}
7838            other => panic!(
7839                "expected a landed dirty cell to have already aged past a one-nanosecond \
7840                 threshold, got {other:?}"
7841            ),
7842        }
7843    }
7844
7845    /// The same wiring's other side: a landed `dirty` cell stays fresh under a
7846    /// large `status_stale_after`, so the wiring is genuinely reading the
7847    /// threshold rather than always staling.
7848    #[test]
7849    fn snapshot_leaves_a_freshly_landed_dirty_cell_fresh_under_a_large_status_stale_after() {
7850        let dir = tempfile::tempdir().expect("temp dir");
7851        let root = root_of(&dir);
7852        let repo = root.join("repo");
7853        init_repo_with_a_commit(&repo);
7854
7855        let core = Core::start_discovered(spec(vec![root]));
7856        let key = core.snapshot().entities[0].key.clone();
7857        core.refresh(std::slice::from_ref(&key));
7858        core.settle();
7859
7860        let fresh = core.snapshot();
7861        match fresh.entities[0].dirty.settled() {
7862            Some(Settled::Known {
7863                stale: false,
7864                value: _,
7865                at: _,
7866            }) => {}
7867            other => panic!("expected a freshly landed dirty cell to stay fresh, got {other:?}"),
7868        }
7869    }
7870
7871    /// Criterion 5's absence claim: a hidden Submodule (`show_submodules` off) is
7872    /// never in the poll's own candidate set, so a commit into it is never
7873    /// detected, while the identical commit against the same Submodule shown is.
7874    /// Run as one test over the same fixture with the flag flipped, rather than
7875    /// two, so the only variable between the two sweeps is the flag itself.
7876    #[test]
7877    fn hidden_submodules_are_never_polled_but_shown_ones_are() {
7878        let dir = tempfile::tempdir().expect("temp dir");
7879        let root = root_of(&dir);
7880        let parent = root.join("parent");
7881        init_repo_with_a_commit(&parent);
7882        fs::write(
7883            parent.join(".gitmodules"),
7884            "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
7885        )
7886        .expect("write .gitmodules");
7887        let submodule_path = parent.join("vendor").join("lib");
7888        init_repo_with_a_commit(&submodule_path);
7889
7890        let mut hidden_spec = spec(vec![root.clone()]);
7891        hidden_spec.show_submodules = false;
7892        let hidden_core = Core::start_discovered(hidden_spec);
7893        // Discovery's own pass always runs regardless of the flag
7894        // (discovery.md's "Showing Submodules": "the pass always runs, so
7895        // Submodules are always known"), so the row exists; only probing and the
7896        // poll are gated on it.
7897        let hidden_submodule_key = hidden_core
7898            .snapshot()
7899            .entities
7900            .iter()
7901            .find(|entity| matches!(entity.kind, Kind::Submodule))
7902            .expect("the submodule is discovered regardless of show_submodules")
7903            .key
7904            .clone();
7905        backdate_polled_entries(&submodule_path);
7906        hidden_core.poll_once_for_test();
7907        commit_a_change(&submodule_path, "into the hidden submodule");
7908        hidden_core.poll_once_for_test();
7909        assert!(
7910            !hidden_core
7911                .poll_reprobed_for_test()
7912                .contains(&hidden_submodule_key),
7913            "a hidden Submodule must never be re-probed by the poll, since it was never \
7914             polled at all"
7915        );
7916        drop(hidden_core);
7917
7918        let mut shown_spec = spec(vec![root]);
7919        shown_spec.show_submodules = true;
7920        let shown_core = Core::start_discovered(shown_spec);
7921        let submodule_key = shown_core
7922            .snapshot()
7923            .entities
7924            .iter()
7925            .find(|entity| matches!(entity.kind, Kind::Submodule))
7926            .expect("the submodule is discovered regardless of show_submodules")
7927            .key
7928            .clone();
7929        backdate_polled_entries(&submodule_path);
7930        shown_core.poll_once_for_test();
7931        commit_a_change(&submodule_path, "into the shown submodule");
7932        shown_core.poll_once_for_test();
7933        assert_eq!(
7934            shown_core.poll_reprobed_for_test(),
7935            vec![submodule_key],
7936            "a shown Submodule must be polled and re-probed exactly like any other row"
7937        );
7938    }
7939
7940    /// Pause cancels a real in-flight entry (not merely stores a flag nobody
7941    /// reads): the cancel flag `begin_untracked_probe_for_test` returns is
7942    /// observed `true` afterward, and `settle` unblocks because pause released it,
7943    /// which is only possible if pause's handler on the dedicated thread actually
7944    /// ran.
7945    #[test]
7946    fn pause_cancels_every_in_flight_entity_and_releases_a_pending_settle() {
7947        let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
7948        let dir = tempfile::tempdir().expect("temp dir");
7949        let root = root_of(&dir);
7950        let repo = root.join("repo");
7951        init_repo_with_a_commit(&repo);
7952
7953        let started =
7954            Core::start_for_test(spec(vec![root]), Duration::from_secs(3600), tick_rx).discovered();
7955        let core = started.core;
7956        // Drained, so the only entry in flight below is the one this test puts there.
7957        let key = settle_launch(&core).entities[0].key.clone();
7958        let cancel = core.begin_untracked_probe_for_test(&key);
7959        assert!(!cancel.load(Ordering::Acquire));
7960
7961        core.pause();
7962        let settled = core.settle();
7963
7964        assert!(
7965            cancel.load(Ordering::Acquire),
7966            "pause should cancel the entity that was in flight"
7967        );
7968        assert!(settled.entities[0].branch.is_in_flight());
7969        drop(tick_tx);
7970    }
7971
7972    /// A launch walks the tree once.
7973    ///
7974    /// Discovery rides on every Generation
7975    /// ([refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
7976    /// "Discovery is never on the calling thread"), so counting a launch's walks is
7977    /// counting its Generations: one walk means the very first Generation a fresh `Core`
7978    /// mints is the only one a settled launch has, and that it already covers every row
7979    /// the walk found. A second walk would be a second Generation and would read here.
7980    #[test]
7981    fn a_launch_is_one_generation_over_every_row_its_own_walk_found() {
7982        let dir = tempfile::tempdir().expect("temp dir");
7983        let root = root_of(&dir);
7984        init_repo_with_a_commit(&root.join("first"));
7985        init_repo_with_a_commit(&root.join("second"));
7986
7987        let (_core, launched) = started_and_settled(spec(vec![root]));
7988
7989        assert_eq!(
7990            launched.generation,
7991            Generation::default().successor(),
7992            "a launch must settle on the first Generation a fresh `Core` mints; a second \
7993             walk of the same tree would be a second Generation"
7994        );
7995        let mut named: Vec<String> = launched
7996            .entities
7997            .iter()
7998            .filter(|entity| entity.branch.settled().is_some())
7999            .map(|entity| entity.name.to_string())
8000            .collect();
8001        named.sort();
8002        assert_eq!(
8003            named,
8004            vec!["first".to_string(), "second".to_string()],
8005            "that one Generation must cover every row its own walk found, or the walk it \
8006             saved would have to be paid by a second one"
8007        );
8008    }
8009
8010    /// A `Core` going away cancels what it still has in flight, the same way `pause` does.
8011    ///
8012    /// [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
8013    /// "Cancellation": an abandoned Generation is cancelled rather than left to finish,
8014    /// because both would contend for the same cores. A Set switch is where that bites,
8015    /// rebuilding the `Core` while the outgoing one's fan-out is still running.
8016    #[test]
8017    fn dropping_a_core_cancels_every_entity_it_still_has_in_flight() {
8018        let dir = tempfile::tempdir().expect("temp dir");
8019        let root = root_of(&dir);
8020        init_repo_with_a_commit(&root.join("repo"));
8021
8022        let (core, launched) = started_and_settled(spec(vec![root]));
8023        let key = launched.entities[0].key.clone();
8024        let cancel = core.begin_untracked_probe_for_test(&key);
8025        assert!(!cancel.load(Ordering::Acquire));
8026
8027        drop(core);
8028
8029        assert!(
8030            cancel.load(Ordering::Acquire),
8031            "a dropped Core must cancel the Generation it still has in flight rather than \
8032             leave it running against a Set nothing will read again"
8033        );
8034    }
8035
8036    /// Per-entity supersession, not global. An older Generation covers two entities,
8037    /// A and B, both simulated as still in flight. A Selection-scoped newer
8038    /// Generation covers only A: A's own older interrupt flag must be set, and B's
8039    /// must not, since the newer one never mentions B. Once the newer Generation has
8040    /// written A's cell, A's slow older result finally arrives and must be dropped
8041    /// there; B's own older result, arriving after everything else, must still be
8042    /// accepted, because the newer Generation never superseded it.
8043    ///
8044    /// The two are named by their order, never by their counter values, so a
8045    /// Generation minted earlier in the crate cannot renumber this test out from
8046    /// under itself.
8047    ///
8048    /// This is exactly the distinction a global-current-Generation comparison
8049    /// would get wrong: such a check compares every write against the table's one
8050    /// counter, which the Selection-scoped refresh has already advanced, so B's
8051    /// older result would be wrongly dropped even though nothing ever superseded B
8052    /// specifically. Before `Cell::settle`'s comparison was wired
8053    /// against the cell's own recorded Generation this test failed exactly there:
8054    /// B's late result was rejected, which is precisely the "cannot strand the
8055    /// rows it never spoke for" defect the ticket names.
8056    ///
8057    /// This test read A's interrupt flag intermittently false under load. The cause was
8058    /// `apply_probe_outcome` clearing the in-flight entry by key alone: launch's own
8059    /// Generation was left undrained here, so one of its probes could finish after the
8060    /// simulated older Generation had put its flags under the same keys and delete the
8061    /// entry holding them, leaving the Selection-scoped refresh nothing to supersede.
8062    /// Launch is drained first now, and the entry is cleared by Generation as well as by
8063    /// key, which `a_probe_finishing_clears_only_its_own_generations_in_flight_entry`
8064    /// pins directly.
8065    #[test]
8066    fn a_selection_scoped_refresh_supersedes_only_the_entity_it_covers() {
8067        let dir = tempfile::tempdir().expect("temp dir");
8068        let root = root_of(&dir);
8069        init_repo_with_a_commit(&root.join("a"));
8070        init_repo_with_a_commit(&root.join("b"));
8071
8072        let (core, snapshot) = started_and_settled(spec(vec![root]));
8073        let key_a = snapshot
8074            .entities
8075            .iter()
8076            .find(|entity| &*entity.name == "a")
8077            .expect("entity a discovered")
8078            .key
8079            .clone();
8080        let key_b = snapshot
8081            .entities
8082            .iter()
8083            .find(|entity| &*entity.name == "b")
8084            .expect("entity b discovered")
8085            .key
8086            .clone();
8087
8088        // The older Generation, simulated: both A and B are mid-flight, with nothing
8089        // spawned to complete either one, so the test controls exactly when each
8090        // one's result lands.
8091        let older = core.begin_shared_generation_for_test(&[key_a.clone(), key_b.clone()]);
8092
8093        // A Selection-scoped refresh over A alone, the very next Generation after the
8094        // one still in flight.
8095        let newer = core.refresh(std::slice::from_ref(&key_a));
8096        assert_eq!(
8097            newer,
8098            older.generation.successor(),
8099            "the Selection-scoped refresh must be the Generation immediately after the one \
8100             still in flight, with nothing minted in between"
8101        );
8102
8103        // Supersession happens on the new Generation's own thread, behind its walk, so
8104        // this is the rendezvous that says it has happened. A join, never a deadline: no
8105        // production rule bounds how long that walk takes short of the thirty seconds at
8106        // which discovery is abandoned.
8107        core.wait_dispatched_for_test();
8108        assert!(
8109            older.cancels[&key_a].load(Ordering::Acquire),
8110            "the entity the new Generation covers must have its old interrupt flag set"
8111        );
8112        assert!(
8113            !older.cancels[&key_b].load(Ordering::Acquire),
8114            "an entity the new Generation does not cover must be left running, untouched"
8115        );
8116
8117        // [`BACKSTOP`] rather than a budget: what follows reads the cell the new
8118        // Generation's own probe writes, which is a liveness property with no wall-clock
8119        // bound of its own.
8120        let after_refresh = core.settle();
8121
8122        let a_after_gen2 = after_refresh
8123            .entities
8124            .iter()
8125            .find(|entity| entity.key == key_a)
8126            .expect("entity a present");
8127        assert!(
8128            matches!(
8129                a_after_gen2.branch.settled(),
8130                Some(Settled::Known {
8131                    value: Head::Branch { .. },
8132                    at: _,
8133                    stale: _
8134                })
8135            ),
8136            "the newer Generation's real probe should have written A's cell by now"
8137        );
8138
8139        // A's slow older result finally arrives, after the newer Generation has
8140        // already written the cell: dropped, since it is lower than the Generation
8141        // already recorded there.
8142        core.apply_probe_result_for_test(
8143            &key_a,
8144            older.generation,
8145            Settled::Known {
8146                value: Head::Branch {
8147                    name: Arc::from("stale-from-generation-one"),
8148                    commit: gix::hash::Kind::Sha1.null(),
8149                },
8150                at: Timestamp::now(),
8151                stale: false,
8152            },
8153        );
8154        let after_stale_write = core.snapshot();
8155        let a_final = after_stale_write
8156            .entities
8157            .iter()
8158            .find(|entity| entity.key == key_a)
8159            .expect("entity a present");
8160        match a_final.branch.settled() {
8161            Some(Settled::Known {
8162                value: Head::Branch { name, .. },
8163                at: _,
8164                stale: _,
8165            }) => assert_ne!(
8166                &**name, "stale-from-generation-one",
8167                "a lower-Generation result must be dropped at the cell it would write"
8168            ),
8169            other => panic!("expected A to still hold the newer Generation's value, got {other:?}"),
8170        }
8171
8172        // B's own older result, landing last of all, is still accepted: the newer
8173        // Generation never covered B, so nothing superseded it.
8174        core.apply_probe_result_for_test(
8175            &key_b,
8176            older.generation,
8177            Settled::Known {
8178                value: Head::Branch {
8179                    name: Arc::from("b-generation-one-result"),
8180                    commit: gix::hash::Kind::Sha1.null(),
8181                },
8182                at: Timestamp::now(),
8183                stale: false,
8184            },
8185        );
8186        let final_snapshot = core.snapshot();
8187        let b_final = final_snapshot
8188            .entities
8189            .iter()
8190            .find(|entity| entity.key == key_b)
8191            .expect("entity b present");
8192        match b_final.branch.settled() {
8193            Some(Settled::Known {
8194                value: Head::Branch { name, .. },
8195                at: _,
8196                stale: _,
8197            }) => assert_eq!(
8198                &**name, "b-generation-one-result",
8199                "an entity the new Generation never covered must still accept its own result"
8200            ),
8201            other => {
8202                panic!("expected B's un-superseded older result to be accepted, got {other:?}")
8203            }
8204        }
8205    }
8206
8207    /// The deadline sweep abandons only what is still Loading when it fires. An
8208    /// entity already settled by the time the deadline sweep runs keeps its value
8209    /// untouched, blanking nothing, while a different entity still mid-flight in
8210    /// the same sweep becomes Unknown with the timed-out reason.
8211    #[test]
8212    fn the_deadline_sweep_keeps_already_settled_cells_and_only_times_out_what_is_still_loading() {
8213        let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
8214        let dir = tempfile::tempdir().expect("temp dir");
8215        let root = root_of(&dir);
8216        init_repo_with_a_commit(&root.join("a"));
8217        init_repo_with_a_commit(&root.join("b"));
8218
8219        let mut spec = spec(vec![root]);
8220        spec.generation_deadline = Duration::ZERO;
8221        let started = Core::start_for_test(spec, Duration::from_secs(3600), tick_rx).discovered();
8222        let core = started.core;
8223        // Drained, so the only cell still loading when the sweep fires is the one this
8224        // test puts in flight.
8225        let snapshot = settle_launch(&core);
8226        let key_a = snapshot
8227            .entities
8228            .iter()
8229            .find(|entity| &*entity.name == "a")
8230            .expect("entity a discovered")
8231            .key
8232            .clone();
8233        let key_b = snapshot
8234            .entities
8235            .iter()
8236            .find(|entity| &*entity.name == "b")
8237            .expect("entity b discovered")
8238            .key
8239            .clone();
8240
8241        // A is already settled, synchronously, before the deadline ever has a
8242        // chance to fire.
8243        let a_settled = core.probe_now(&key_a);
8244        let a_value_before = match a_settled.branch.settled() {
8245            Some(Settled::Known {
8246                value: Head::Branch { name, .. },
8247                at: _,
8248                stale: _,
8249            }) => Arc::clone(name),
8250            other => panic!("expected A's synchronous probe to settle a branch, got {other:?}"),
8251        };
8252
8253        // B is left mid-flight, in a Generation whose (zero) deadline has already
8254        // elapsed in real time, but the sweep has not run yet: no tick has been
8255        // sent.
8256        let cancel_b = core.begin_untracked_probe_for_test(&key_b);
8257        let before_tick = core.snapshot();
8258        let b_before = before_tick
8259            .entities
8260            .iter()
8261            .find(|entity| entity.key == key_b)
8262            .expect("entity b present");
8263        assert!(
8264            b_before.branch.is_in_flight(),
8265            "B must be mid-flight when the sweep fires; that is the only shape the sweep \
8266             may touch"
8267        );
8268        assert!(
8269            matches!(
8270                b_before.branch.settled(),
8271                Some(Settled::Known {
8272                    value: _,
8273                    at: _,
8274                    stale: _
8275                })
8276            ),
8277            "B still carries launch's own answer here, so the Unknown below is a write the \
8278             sweep made rather than a cell that was already empty, got {:?}",
8279            b_before.branch.settled()
8280        );
8281
8282        tick_tx.send(Instant::now()).expect("send one tick");
8283        let after_sweep = core.settle();
8284
8285        let a_after = after_sweep
8286            .entities
8287            .iter()
8288            .find(|entity| entity.key == key_a)
8289            .expect("entity a present");
8290        match a_after.branch.settled() {
8291            Some(Settled::Known {
8292                value: Head::Branch { name, .. },
8293                at: _,
8294                stale: _,
8295            }) => assert_eq!(
8296                name, &a_value_before,
8297                "an already-settled cell must keep its value when the deadline sweep runs, not be blanked"
8298            ),
8299            other => panic!("expected A's settled value to survive the sweep, got {other:?}"),
8300        }
8301
8302        let b_after = after_sweep
8303            .entities
8304            .iter()
8305            .find(|entity| entity.key == key_b)
8306            .expect("entity b present");
8307        assert!(matches!(
8308            b_after.branch.settled(),
8309            Some(Settled::Unknown(Unknown::TimedOut))
8310        ));
8311        assert!(
8312            !cancel_b.load(Ordering::Acquire),
8313            "the deadline sweep marks a cell Unknown; it never sets the entity's own \
8314             cancel flag, since the underlying probe (nonexistent here) is left to keep running"
8315        );
8316    }
8317
8318    /// The deadline sweep must reach a Worktree's outstanding `state` cell the
8319    /// same way it already reaches `branch` and `default_branch`: asking and
8320    /// getting nothing back is Unknown, not a cell stuck in-flight forever once
8321    /// the Generation that would have answered it is gone. A Repo's `state`,
8322    /// `NotApplicable` from construction and never in flight, must survive the
8323    /// same sweep untouched, proving the sweep only times out a cell actually
8324    /// marked in flight rather than blanket-settling every entity's `state` cell.
8325    #[test]
8326    fn the_deadline_sweep_times_out_a_worktrees_outstanding_state_but_leaves_a_repos_not_applicable_one_alone()
8327     {
8328        let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
8329        let dir = tempfile::tempdir().expect("temp dir");
8330        let root = root_of(&dir);
8331        let parent = root.join("parent");
8332        init_repo_with_a_commit(&parent);
8333        let worktree_path = root.join("feature-worktree");
8334        git(
8335            &parent,
8336            &[
8337                "worktree",
8338                "add",
8339                "-b",
8340                "feature",
8341                worktree_path.to_str().expect("utf8 path"),
8342            ],
8343        );
8344
8345        let mut spec = spec(vec![root]);
8346        spec.generation_deadline = Duration::ZERO;
8347        let started = Core::start_for_test(spec, Duration::from_secs(3600), tick_rx).discovered();
8348        let core = started.core;
8349        // Drained, so launch's own real refresh has already landed on every cell before
8350        // either `begin_untracked_probe_for_test` call below puts one artificially back in
8351        // flight: skipping this left a real probe for the same cell free to settle it
8352        // between that call and the sweep, which turns the sweep's own `is_in_flight`
8353        // guard (working exactly as designed, since a cell no longer loading is not the
8354        // sweep's to touch) into a race the assertion below loses however rarely.
8355        let snapshot = settle_launch(&core);
8356        let repo_key = snapshot
8357            .entities
8358            .iter()
8359            .find(|entity| matches!(entity.kind, Kind::Repo))
8360            .expect("repo entity present")
8361            .key
8362            .clone();
8363        let worktree_key = snapshot
8364            .entities
8365            .iter()
8366            .find(|entity| matches!(entity.kind, Kind::Worktree))
8367            .expect("worktree entity present")
8368            .key
8369            .clone();
8370
8371        // Both left mid-flight in a Generation whose (zero) deadline has already
8372        // elapsed, with no tick sent yet, mirroring how `Core::refresh` begins a
8373        // Worktree's `state` probe alongside `branch`. The Repo is in flight too
8374        // (on `branch` only, per the same gate), so the sweep actually reaches
8375        // it and the guard has something real to prove.
8376        core.begin_untracked_probe_for_test(&repo_key);
8377        core.begin_untracked_probe_for_test(&worktree_key);
8378
8379        tick_tx.send(Instant::now()).expect("send one tick");
8380        let after_sweep = core.settle();
8381
8382        let worktree_after = after_sweep
8383            .entities
8384            .iter()
8385            .find(|entity| entity.key == worktree_key)
8386            .expect("worktree entity present");
8387        assert!(
8388            matches!(
8389                worktree_after.state.settled(),
8390                Some(Settled::Unknown(Unknown::TimedOut))
8391            ),
8392            "expected the outstanding state cell to time out, got {:?}",
8393            worktree_after.state.settled()
8394        );
8395
8396        let repo_after = after_sweep
8397            .entities
8398            .iter()
8399            .find(|entity| entity.key == repo_key)
8400            .expect("repo entity present");
8401        assert!(
8402            matches!(repo_after.state.settled(), Some(Settled::NotApplicable)),
8403            "a Repo's Not applicable state must survive the sweep untouched, got {:?}",
8404            repo_after.state.settled()
8405        );
8406    }
8407
8408    /// Criterion 2's "never goes stale on a poll" made behavioural: the dedicated thread's
8409    /// tick-driven sweep is what a poll is in this codebase today (`spawn_clock_thread` calls
8410    /// [`sweep_deadline`] on every tick), and it must leave a receipt exactly as it was even
8411    /// while it is busy timing out a genuinely outstanding Cell on the very same entity.
8412    #[test]
8413    fn the_deadline_sweeps_poll_never_touches_an_entitys_action_receipt() {
8414        let (tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
8415        let dir = tempfile::tempdir().expect("temp dir");
8416        let root = root_of(&dir);
8417        let repo = root.join("repo");
8418        init_repo_with_a_commit(&repo);
8419
8420        let mut spec = spec(vec![root]);
8421        spec.generation_deadline = Duration::ZERO;
8422        let started = Core::start_for_test(spec, Duration::from_secs(3600), tick_rx).discovered();
8423        let core = started.core;
8424        // Drained, so the only entry the sweep below finds in flight is this test's own.
8425        let key = settle_launch(&core).entities[0].key.clone();
8426
8427        let receipt = crate::entity::ActionReceipt {
8428            label: Arc::from("reinstall"),
8429            steps: Arc::from(vec![crate::entity::StepResult {
8430                label: Arc::from("pnpm install"),
8431                outcome: crate::entity::StepOutcome::Ok,
8432                output: Arc::from(&b""[..]),
8433                elapsed: Duration::from_millis(1),
8434                elision: None,
8435                shell: false,
8436                interactive: false,
8437            }]),
8438            skip: None,
8439            finished_at: Timestamp::now(),
8440            running: None,
8441        };
8442        core.set_last_action_for_test(&key, receipt.clone());
8443
8444        // Left mid-flight in a Generation whose (zero) deadline has already elapsed, so the
8445        // sweep this tick triggers has a real Cell to time out on this very entity.
8446        core.begin_untracked_probe_for_test(&key);
8447        tick_tx.send(Instant::now()).expect("send one tick");
8448        let after = core.settle();
8449
8450        let entity = after
8451            .entities
8452            .iter()
8453            .find(|entity| entity.key == key)
8454            .expect("entity present");
8455        assert!(
8456            matches!(
8457                entity.branch.settled(),
8458                Some(Settled::Unknown(Unknown::TimedOut))
8459            ),
8460            "sanity check: the sweep must have actually timed out the in-flight cell, got {:?}",
8461            entity.branch.settled()
8462        );
8463        assert_eq!(entity.last_action, Some(receipt));
8464    }
8465
8466    /// Cancellation observed before a probe's very first read stops it from ever
8467    /// opening the repository at all, proven behaviourally rather than by
8468    /// re-reading the flag: a path that does not exist would settle as
8469    /// `Failed(Open(_))` if the open call actually ran, so getting `None` back
8470    /// instead is only possible if the read never started. This is the honest
8471    /// limit of what phase A can prove: `git::head_shape` is one syscall with no
8472    /// interruption point mid-read, so cancellation here stops work that has not
8473    /// started rather than work already running. [`classify_status_result_drops_an_error_once_cancel_reads_true`]
8474    /// covers the genuinely interruptible phase this crate now has.
8475    #[test]
8476    fn a_cancelled_probe_never_opens_the_repository_at_all() {
8477        let cancel = AtomicBool::new(true);
8478
8479        let outcome = probe_branch(
8480            Path::new("/nonexistent/nowhere-at-all"),
8481            None,
8482            Kind::Repo,
8483            &cancel,
8484        );
8485
8486        assert!(
8487            outcome.is_none(),
8488            "a probe observing cancellation before its first read must do no work \
8489             at all, not attempt the read and fail having tried it"
8490        );
8491    }
8492
8493    /// Phase C's own cancellation shape, distinct from phase A and B's "before the read
8494    /// starts" check: gix can report a genuinely mid-read cancellation as an `Err`
8495    /// (`dirty_counts_threads_the_cancel_flag_into_gix` in `git.rs` proves the flag actually
8496    /// reaches gix, which is what makes that `Err` possible at all), and this test covers the
8497    /// half that lives here, that `classify_status_result` folds that error back to `None`
8498    /// rather than `Settled::Failed` once `cancel` reads `true`, per ADR 0013's "interrupted
8499    /// work becomes Unknown rather than Failed". A mutation that dropped the `cancel`-aware
8500    /// arm (always settling `Failed` on any error, the way the cheaper phases' own errors do)
8501    /// fails this directly.
8502    #[test]
8503    fn classify_status_result_drops_an_error_once_cancel_reads_true() {
8504        let cancel = AtomicBool::new(true);
8505
8506        let outcome = classify_status_result(
8507            Err(crate::git::ProbeError::Status(Arc::from("boom"))),
8508            &cancel,
8509        );
8510
8511        assert!(
8512            outcome.is_none(),
8513            "an error alongside a cancel flag already set must read as cancelled, not \
8514             Failed, got {outcome:?}"
8515        );
8516    }
8517
8518    /// The other side of the same fold: an error with `cancel` still `false` is a genuine
8519    /// failure and must settle `Failed`, not be silently dropped the way a cancelled read is.
8520    #[test]
8521    fn classify_status_result_settles_failed_when_cancel_never_fired() {
8522        let cancel = AtomicBool::new(false);
8523
8524        let outcome = classify_status_result(
8525            Err(crate::git::ProbeError::Status(Arc::from("boom"))),
8526            &cancel,
8527        );
8528
8529        assert!(
8530            matches!(outcome, Some(Settled::Failed(git::ProbeError::Status(_)))),
8531            "a genuine error with no cancellation must settle Failed, got {outcome:?}"
8532        );
8533    }
8534
8535    /// gix polls `should_interrupt` per index entry rather than before every read, so a walk
8536    /// short enough to finish between checks (or with nothing left to check against) can
8537    /// complete and return `Ok` even though `cancel` was set part way through it. Settling
8538    /// that `Ok` anyway would let a cancelled generation write a value, exactly the outcome
8539    /// [refresh.md's "Cancellation"](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)
8540    /// says cancellation prevents. `classify_status_result` must re-check the same flag it
8541    /// owns on the `Ok` arm too, not only on `Err`, and drop the value the same way a
8542    /// cancelled `Err` is already dropped.
8543    #[test]
8544    fn classify_status_result_drops_an_ok_once_cancel_reads_true() {
8545        let cancel = AtomicBool::new(true);
8546
8547        let outcome = classify_status_result(Ok(DirtyCounts::default()), &cancel);
8548
8549        assert!(
8550            outcome.is_none(),
8551            "an Ok value that raced ahead of a cancel flag now set must read as cancelled, \
8552             not be settled Known, got {outcome:?}"
8553        );
8554    }
8555
8556    /// The other side of the same fold: an `Ok` with `cancel` still `false` is a genuine
8557    /// completed read and must settle `Known`, not be silently dropped.
8558    #[test]
8559    fn classify_status_result_settles_known_when_cancel_never_fired() {
8560        let cancel = AtomicBool::new(false);
8561        let counts = DirtyCounts {
8562            modified: 1,
8563            untracked: 2,
8564            deleted: 3,
8565        };
8566
8567        let outcome = classify_status_result(Ok(counts), &cancel);
8568
8569        assert!(
8570            matches!(
8571                outcome,
8572                Some(Settled::Known {
8573                    value,
8574                    at: _,
8575                    stale: _
8576                }) if value == counts
8577            ),
8578            "a genuine completed read with no cancellation must settle Known, got {outcome:?}"
8579        );
8580    }
8581
8582    /// The defining behaviour: a linked Worktree shares its parent's object store
8583    /// and remotes, but `Core` must still surface it as its own row rather than
8584    /// folding it into the Repo it is attached to. A real `git worktree add` is run
8585    /// against a genuine parent so the proof covers git's actual on-disk shape, not
8586    /// a hand-built stand-in for it.
8587    #[test]
8588    fn a_linked_worktree_is_its_own_entity_and_never_doubles_as_a_repo() {
8589        let dir = tempfile::tempdir().expect("temp dir");
8590        let root = root_of(&dir);
8591        let parent = root.join("parent");
8592        init_repo_with_a_commit(&parent);
8593        let worktree_path = root.join("feature-worktree");
8594        let status = Command::new("git")
8595            .arg("-C")
8596            .arg(&parent)
8597            .args([
8598                "worktree",
8599                "add",
8600                "-b",
8601                "feature",
8602                worktree_path.to_str().expect("utf8 path"),
8603            ])
8604            .status()
8605            .expect("run git worktree add");
8606        assert!(status.success());
8607
8608        let core = Core::start_discovered(spec(vec![root]));
8609        let snapshot = core.snapshot();
8610
8611        assert_eq!(
8612            snapshot.entities.len(),
8613            2,
8614            "expected the parent plus one Worktree, not two Repos"
8615        );
8616        let repo_count = snapshot
8617            .entities
8618            .iter()
8619            .filter(|entity| matches!(entity.kind, Kind::Repo))
8620            .count();
8621        let worktree_count = snapshot
8622            .entities
8623            .iter()
8624            .filter(|entity| matches!(entity.kind, Kind::Worktree))
8625            .count();
8626        assert_eq!(
8627            repo_count, 1,
8628            "the parent must be counted as exactly one Repo"
8629        );
8630        assert_eq!(
8631            worktree_count, 1,
8632            "the linked worktree must be counted as exactly one Worktree"
8633        );
8634
8635        let worktree_entity = snapshot
8636            .entities
8637            .iter()
8638            .find(|entity| matches!(entity.kind, Kind::Worktree))
8639            .expect("worktree entity present");
8640        let repo_entity = snapshot
8641            .entities
8642            .iter()
8643            .find(|entity| matches!(entity.kind, Kind::Repo))
8644            .expect("repo entity present");
8645        assert_eq!(worktree_entity.common_dir, repo_entity.common_dir);
8646
8647        // Each carries its own branch: the parent stayed on its default branch and
8648        // the worktree checked out `feature`.
8649        let repo_branch = core.probe_now(&repo_entity.key);
8650        let worktree_branch = core.probe_now(&worktree_entity.key);
8651        match (
8652            repo_branch.branch.settled(),
8653            worktree_branch.branch.settled(),
8654        ) {
8655            (
8656                Some(Settled::Known {
8657                    value:
8658                        Head::Branch {
8659                            name: repo_name, ..
8660                        },
8661                    at: _,
8662                    stale: _,
8663                }),
8664                Some(Settled::Known {
8665                    value:
8666                        Head::Branch {
8667                            name: worktree_name,
8668                            ..
8669                        },
8670                    at: _,
8671                    stale: _,
8672                }),
8673            ) => {
8674                assert_ne!(repo_name, worktree_name);
8675                assert_eq!(&**worktree_name, "feature");
8676            }
8677            other => panic!("expected both entities to read an attached branch, got {other:?}"),
8678        }
8679    }
8680
8681    /// End-to-end proof that `state` is actually wired into a real Generation:
8682    /// a linked Worktree whose branch is an ancestor of the default branch reads
8683    /// `Merged` after a real `refresh`, not merely in `landing`'s own unit tests.
8684    #[test]
8685    fn a_worktrees_branch_that_is_an_ancestor_of_the_default_branch_reads_merged_after_a_refresh() {
8686        let dir = tempfile::tempdir().expect("temp dir");
8687        let root = root_of(&dir);
8688        let parent = root.join("parent");
8689        init_repo_with_a_commit(&parent);
8690        git(
8691            &parent,
8692            &[
8693                "remote",
8694                "add",
8695                "origin",
8696                "https://example.invalid/repo.git",
8697            ],
8698        );
8699        let sha = head_sha(&parent);
8700        git(&parent, &["update-ref", "refs/remotes/origin/main", &sha]);
8701        let worktree_path = root.join("feature-worktree");
8702        git(
8703            &parent,
8704            &[
8705                "worktree",
8706                "add",
8707                "-b",
8708                "feature",
8709                worktree_path.to_str().expect("utf8 path"),
8710            ],
8711        );
8712
8713        let core = Core::start_discovered(spec(vec![root]));
8714        let keys: Vec<EntityKey> = core
8715            .snapshot()
8716            .entities
8717            .iter()
8718            .map(|entity| entity.key.clone())
8719            .collect();
8720
8721        core.refresh(&keys);
8722        let settled = core.settle();
8723
8724        let worktree_entity = settled
8725            .entities
8726            .iter()
8727            .find(|entity| matches!(entity.kind, Kind::Worktree))
8728            .expect("worktree entity present");
8729        assert!(
8730            matches!(
8731                worktree_entity.state.settled(),
8732                Some(Settled::Known {
8733                    value: WorktreeState::Merged,
8734                    at: _,
8735                    stale: _
8736                })
8737            ),
8738            "expected the worktree, at the same commit as the default branch, to read Merged, got {:?}",
8739            worktree_entity.state.settled()
8740        );
8741    }
8742
8743    /// The squash merge this whole ticket is named for, proven end to end
8744    /// through a real `refresh`: `feature`'s two commits are squashed into one
8745    /// commit on the default branch, so ancestry cannot see it (`feature`'s tip
8746    /// never becomes an ancestor), and only patch equivalence can. Its upstream
8747    /// tracking ref still resolves, matching the moment right after a squash
8748    /// merge and before the next prune removes it, which is what routes this
8749    /// entity through `Outstanding` into the second pass rather than settling
8750    /// `Gone` at the first.
8751    #[test]
8752    fn a_squash_merged_worktree_branch_reads_merged_after_a_refresh() {
8753        let dir = tempfile::tempdir().expect("temp dir");
8754        let root = root_of(&dir);
8755        let parent = root.join("parent");
8756        init_repo_with_a_commit(&parent);
8757        git(
8758            &parent,
8759            &[
8760                "remote",
8761                "add",
8762                "origin",
8763                "https://example.invalid/repo.git",
8764            ],
8765        );
8766        let worktree_path = root.join("feature-worktree");
8767        git(
8768            &parent,
8769            &[
8770                "worktree",
8771                "add",
8772                "-b",
8773                "feature",
8774                worktree_path.to_str().expect("utf8 path"),
8775            ],
8776        );
8777        fs::write(worktree_path.join("a.txt"), "one\n").expect("write a.txt");
8778        git(&worktree_path, &["add", "a.txt"]);
8779        git(&worktree_path, &["commit", "-m", "add a"]);
8780        fs::write(worktree_path.join("b.txt"), "two\n").expect("write b.txt");
8781        git(&worktree_path, &["add", "b.txt"]);
8782        git(&worktree_path, &["commit", "-m", "add b"]);
8783        let feature_sha = head_sha(&worktree_path);
8784
8785        // Squashed into the parent's own checkout, which is what the default
8786        // branch resolves against.
8787        git(&parent, &["merge", "--squash", "feature"]);
8788        git(&parent, &["commit", "-m", "squashed feature"]);
8789        let main_sha = head_sha(&parent);
8790        git(
8791            &parent,
8792            &["update-ref", "refs/remotes/origin/main", &main_sha],
8793        );
8794
8795        // `feature`'s own upstream, still resolving: the moment before a prune
8796        // removes it.
8797        git(&parent, &["config", "branch.feature.remote", "origin"]);
8798        git(
8799            &parent,
8800            &["config", "branch.feature.merge", "refs/heads/feature"],
8801        );
8802        git(
8803            &parent,
8804            &["update-ref", "refs/remotes/origin/feature", &feature_sha],
8805        );
8806
8807        let core = Core::start_discovered(spec(vec![root]));
8808        let keys: Vec<EntityKey> = core
8809            .snapshot()
8810            .entities
8811            .iter()
8812            .map(|entity| entity.key.clone())
8813            .collect();
8814
8815        core.refresh(&keys);
8816        let settled = core.settle();
8817
8818        let worktree_entity = settled
8819            .entities
8820            .iter()
8821            .find(|entity| matches!(entity.kind, Kind::Worktree))
8822            .expect("worktree entity present");
8823        assert!(
8824            matches!(
8825                worktree_entity.state.settled(),
8826                Some(Settled::Known {
8827                    value: WorktreeState::Merged,
8828                    at: _,
8829                    stale: _
8830                })
8831            ),
8832            "expected a squash-merged worktree branch to read Merged, got {:?}",
8833            worktree_entity.state.settled()
8834        );
8835    }
8836
8837    /// Proves the negative the state cell alone cannot: patch equivalence's
8838    /// expensive scan must never even start for an entity ancestry already
8839    /// settled. A Worktree whose branch is an ancestor of the default branch
8840    /// settles `Merged` at the first pass, so the only common dir in this test
8841    /// must show zero scans; a `state`-only assertion would still pass an
8842    /// implementation that ran the second pass over every entity and discarded
8843    /// whichever answer ancestry had already provided.
8844    #[test]
8845    fn patch_equivalence_never_runs_for_an_entity_ancestry_already_settled() {
8846        let dir = tempfile::tempdir().expect("temp dir");
8847        let root = root_of(&dir);
8848        let parent = root.join("parent");
8849        init_repo_with_a_commit(&parent);
8850        git(
8851            &parent,
8852            &[
8853                "remote",
8854                "add",
8855                "origin",
8856                "https://example.invalid/repo.git",
8857            ],
8858        );
8859        let sha = head_sha(&parent);
8860        git(&parent, &["update-ref", "refs/remotes/origin/main", &sha]);
8861        let worktree_path = root.join("feature-worktree");
8862        git(
8863            &parent,
8864            &[
8865                "worktree",
8866                "add",
8867                "-b",
8868                "feature",
8869                worktree_path.to_str().expect("utf8 path"),
8870            ],
8871        );
8872
8873        let (core, launched) = started_and_settled(spec(vec![root]));
8874        let keys: Vec<EntityKey> = launched
8875            .entities
8876            .iter()
8877            .map(|entity| entity.key.clone())
8878            .collect();
8879
8880        core.refresh(&keys);
8881        let settled = core.settle();
8882
8883        let worktree_entity = settled
8884            .entities
8885            .iter()
8886            .find(|entity| matches!(entity.kind, Kind::Worktree))
8887            .expect("worktree entity present");
8888        assert!(
8889            matches!(
8890                worktree_entity.state.settled(),
8891                Some(Settled::Known {
8892                    value: WorktreeState::Merged,
8893                    at: _,
8894                    stale: _
8895                })
8896            ),
8897            "expected ancestry alone to settle Merged here, got {:?}",
8898            worktree_entity.state.settled()
8899        );
8900        assert_eq!(
8901            core.patch_identity_reads_for_test(),
8902            0,
8903            "ancestry already settled this entity, so patch equivalence's shared \
8904             scan must never run for its common dir at all"
8905        );
8906    }
8907
8908    /// [`patch_equivalence`]'s own unit test proves the module itself writes no
8909    /// loose object; this proves the same through the real dispatch path a
8910    /// user's refresh actually runs, so a write introduced in `core.rs`'s glue
8911    /// rather than in the module would be caught too. Reuses the squash-merge
8912    /// fixture that routes a real `Core::refresh` into patch equivalence's
8913    /// second pass, and counts loose objects in the parent repository, since a
8914    /// linked Worktree shares its object database with its common dir.
8915    #[test]
8916    fn a_full_refresh_reaching_patch_equivalence_writes_no_loose_objects() {
8917        let dir = tempfile::tempdir().expect("temp dir");
8918        let root = root_of(&dir);
8919        let parent = root.join("parent");
8920        init_repo_with_a_commit(&parent);
8921        git(
8922            &parent,
8923            &[
8924                "remote",
8925                "add",
8926                "origin",
8927                "https://example.invalid/repo.git",
8928            ],
8929        );
8930        let worktree_path = root.join("feature-worktree");
8931        git(
8932            &parent,
8933            &[
8934                "worktree",
8935                "add",
8936                "-b",
8937                "feature",
8938                worktree_path.to_str().expect("utf8 path"),
8939            ],
8940        );
8941        fs::write(worktree_path.join("a.txt"), "one\n").expect("write a.txt");
8942        git(&worktree_path, &["add", "a.txt"]);
8943        git(&worktree_path, &["commit", "-m", "add a"]);
8944        fs::write(worktree_path.join("b.txt"), "two\n").expect("write b.txt");
8945        git(&worktree_path, &["add", "b.txt"]);
8946        git(&worktree_path, &["commit", "-m", "add b"]);
8947        let feature_sha = head_sha(&worktree_path);
8948
8949        git(&parent, &["merge", "--squash", "feature"]);
8950        git(&parent, &["commit", "-m", "squashed feature"]);
8951        let main_sha = head_sha(&parent);
8952        git(
8953            &parent,
8954            &["update-ref", "refs/remotes/origin/main", &main_sha],
8955        );
8956        git(&parent, &["config", "branch.feature.remote", "origin"]);
8957        git(
8958            &parent,
8959            &["config", "branch.feature.merge", "refs/heads/feature"],
8960        );
8961        git(
8962            &parent,
8963            &["update-ref", "refs/remotes/origin/feature", &feature_sha],
8964        );
8965
8966        let core = Core::start_discovered(spec(vec![root]));
8967        let keys: Vec<EntityKey> = core
8968            .snapshot()
8969            .entities
8970            .iter()
8971            .map(|entity| entity.key.clone())
8972            .collect();
8973
8974        let before = loose_object_count(&parent);
8975        core.refresh(&keys);
8976        let settled = core.settle();
8977        let after = loose_object_count(&parent);
8978
8979        let worktree_entity = settled
8980            .entities
8981            .iter()
8982            .find(|entity| matches!(entity.kind, Kind::Worktree))
8983            .expect("worktree entity present");
8984        assert!(
8985            matches!(
8986                worktree_entity.state.settled(),
8987                Some(Settled::Known {
8988                    value: WorktreeState::Merged,
8989                    at: _,
8990                    stale: _
8991                })
8992            ),
8993            "expected this refresh to actually reach patch equivalence and settle \
8994             Merged, got {:?}",
8995            worktree_entity.state.settled()
8996        );
8997        assert_eq!(
8998            before, after,
8999            "a full refresh reaching patch equivalence must never write a loose \
9000             object to the repository"
9001        );
9002    }
9003
9004    /// With patch equivalence now built, a diverged attached branch with a live
9005    /// upstream no longer stays outstanding forever: once ancestry says no,
9006    /// the second pass gets a real answer, and genuinely unmerged work (a real
9007    /// file change with no counterpart on the default branch, not merely an
9008    /// empty marker commit) settles `Active` rather than `Gone` or `Merged`,
9009    /// proven through the real dispatch path rather than either pass in
9010    /// isolation.
9011    #[test]
9012    fn a_diverged_worktree_with_a_live_upstream_and_genuinely_unmerged_work_settles_active_after_a_refresh()
9013     {
9014        let dir = tempfile::tempdir().expect("temp dir");
9015        let root = root_of(&dir);
9016        let parent = root.join("parent");
9017        init_repo_with_a_commit(&parent);
9018        let base_sha = head_sha(&parent);
9019        git(
9020            &parent,
9021            &[
9022                "remote",
9023                "add",
9024                "origin",
9025                "https://example.invalid/repo.git",
9026            ],
9027        );
9028        git(
9029            &parent,
9030            &["update-ref", "refs/remotes/origin/main", &base_sha],
9031        );
9032        let worktree_path = root.join("feature-worktree");
9033        git(
9034            &parent,
9035            &[
9036                "worktree",
9037                "add",
9038                "-b",
9039                "feature",
9040                worktree_path.to_str().expect("utf8 path"),
9041            ],
9042        );
9043        // Unmerged work: a real file change feature has that main (and
9044        // origin/main) do not, and that main never gains by any other means.
9045        fs::write(worktree_path.join("feature.txt"), "unmerged work\n").expect("write feature.txt");
9046        git(&worktree_path, &["add", "feature.txt"]);
9047        git(&worktree_path, &["commit", "-m", "unmerged"]);
9048        let feature_sha = head_sha(&worktree_path);
9049        // `feature`'s own upstream, live: the common dir's shared config and refs
9050        // make this visible from the worktree's own probe too.
9051        git(&parent, &["config", "branch.feature.remote", "origin"]);
9052        git(
9053            &parent,
9054            &["config", "branch.feature.merge", "refs/heads/feature"],
9055        );
9056        git(
9057            &parent,
9058            &["update-ref", "refs/remotes/origin/feature", &feature_sha],
9059        );
9060
9061        let core = Core::start_discovered(spec(vec![root]));
9062        let keys: Vec<EntityKey> = core
9063            .snapshot()
9064            .entities
9065            .iter()
9066            .map(|entity| entity.key.clone())
9067            .collect();
9068
9069        core.refresh(&keys);
9070        let settled = core.settle();
9071
9072        let worktree_entity = settled
9073            .entities
9074            .iter()
9075            .find(|entity| matches!(entity.kind, Kind::Worktree))
9076            .expect("worktree entity present");
9077        assert!(
9078            matches!(
9079                worktree_entity.state.settled(),
9080                Some(Settled::Known {
9081                    value: WorktreeState::Active,
9082                    at: _,
9083                    stale: _
9084                })
9085            ),
9086            "expected genuinely unmerged work with a live upstream to settle Active, got {:?}",
9087            worktree_entity.state.settled()
9088        );
9089    }
9090
9091    /// `CoreSpec::show_submodules` gates probing and dispatch, never Snapshot membership:
9092    /// a discovered Submodule is always part of the snapshot `Core::start` builds, shown or
9093    /// not, because the module pass that finds it always runs
9094    /// ([discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md)'s
9095    /// "the pass always runs, so Submodules are always known"). Built with the default,
9096    /// hidden reading precisely to prove that.
9097    #[test]
9098    fn a_submodule_is_in_the_snapshot_even_though_hidden_by_the_default_preference() {
9099        let dir = tempfile::tempdir().expect("temp dir");
9100        let root = root_of(&dir);
9101        let parent = root.join("parent");
9102        init_repo_with_a_commit(&parent);
9103        fs::write(
9104            parent.join(".gitmodules"),
9105            "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
9106        )
9107        .expect("write .gitmodules");
9108        fs::create_dir_all(parent.join("vendor").join("lib")).expect("create submodule dir");
9109
9110        let core = Core::start_discovered(spec(vec![root]));
9111        let snapshot = core.snapshot();
9112
9113        assert!(
9114            snapshot
9115                .entities
9116                .iter()
9117                .any(|entity| matches!(entity.kind, Kind::Submodule)),
9118            "a discovered Submodule must be in the snapshot even while show_submodules is off"
9119        );
9120    }
9121
9122    /// A Submodule's `state` and `base` cells must stay `Unknown` through a real
9123    /// refresh cycle, not only at construction:
9124    /// [`EntityState::probes_state`] and [`EntityState::probes_base`] are what
9125    /// stop `refresh`'s dispatch from ever calling `landing::probe` or
9126    /// `probe_base` for it again. The Submodule here is a real, valid repository
9127    /// with a real remote and a resolvable default branch ahead of its own tip
9128    /// (in fact an ancestor of it, so ancestry alone would prove `Merged`), so if
9129    /// either gate were missing this would settle a genuine live answer rather
9130    /// than merely fail to open.
9131    #[test]
9132    fn a_submodules_state_and_base_cells_stay_unknown_through_a_real_refresh() {
9133        let dir = tempfile::tempdir().expect("temp dir");
9134        let root = root_of(&dir);
9135        let parent = root.join("parent");
9136        init_repo_with_a_commit(&parent);
9137        fs::write(
9138            parent.join(".gitmodules"),
9139            "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
9140        )
9141        .expect("write .gitmodules");
9142        let submodule = parent.join("vendor").join("lib");
9143        init_repo_with_a_commit(&submodule);
9144        git(
9145            &submodule,
9146            &["remote", "add", "origin", "https://example.invalid/lib.git"],
9147        );
9148        let root_sha = head_sha(&submodule);
9149        git(&submodule, &["commit", "--allow-empty", "-m", "second"]);
9150        let tip_sha = head_sha(&submodule);
9151        git(&submodule, &["reset", "--hard", &root_sha]);
9152        git(
9153            &submodule,
9154            &["update-ref", "refs/remotes/origin/main", &tip_sha],
9155        );
9156
9157        // Shown, so the explicit `refresh` below actually dispatches a probe against it:
9158        // this test is about `probes_base`'s own gate, not about `show_submodules`'s.
9159        let mut core_spec = spec(vec![root]);
9160        core_spec.show_submodules = true;
9161        let core = Core::start_discovered(core_spec);
9162        let key = core
9163            .snapshot()
9164            .entities
9165            .iter()
9166            .find(|entity| matches!(entity.kind, Kind::Submodule))
9167            .expect("a discovered Submodule")
9168            .key
9169            .clone();
9170
9171        core.refresh(std::slice::from_ref(&key));
9172        let settled = core.settle();
9173        let submodule_entity = settled
9174            .entities
9175            .iter()
9176            .find(|entity| entity.key == key)
9177            .expect("the Submodule entity");
9178
9179        assert!(
9180            matches!(
9181                submodule_entity.base.settled(),
9182                Some(Settled::Unknown(Unknown::NoDefaultBranch))
9183            ),
9184            "expected a Submodule's base to stay Unknown through a real refresh, \
9185             got {:?}",
9186            submodule_entity.base.settled()
9187        );
9188        assert!(
9189            matches!(
9190                submodule_entity.state.settled(),
9191                Some(Settled::Unknown(Unknown::NoDefaultBranch))
9192            ),
9193            "expected a Submodule's state to stay Unknown through a real refresh, \
9194             rather than settling Merged off an untrusted default branch, got {:?}",
9195            submodule_entity.state.settled()
9196        );
9197    }
9198
9199    /// [discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md)'s
9200    /// "The Submodule row" fixes `name` as "the submodule path"; this proves the fact lands
9201    /// on the real `EntityState` `Core::start` builds, not only on the intermediate
9202    /// `DiscoveredEntity` `discovery::tests` already covers.
9203    #[test]
9204    fn a_submodules_entity_name_is_its_relative_path_not_its_basename() {
9205        let dir = tempfile::tempdir().expect("temp dir");
9206        let root = root_of(&dir);
9207        let parent = root.join("parent");
9208        init_repo_with_a_commit(&parent);
9209        fs::write(
9210            parent.join(".gitmodules"),
9211            "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
9212        )
9213        .expect("write .gitmodules");
9214        fs::create_dir_all(parent.join("vendor").join("lib")).expect("create submodule dir");
9215
9216        let core = Core::start_discovered(spec(vec![root]));
9217        let submodule = core
9218            .snapshot()
9219            .entities
9220            .into_iter()
9221            .find(|entity| matches!(entity.kind, Kind::Submodule))
9222            .expect("a discovered Submodule");
9223
9224        assert_eq!(
9225            submodule.name.as_ref(),
9226            "vendor/lib",
9227            "expected the declared relative path, not the basename `lib`"
9228        );
9229    }
9230
9231    /// AC3's negative case: an uninitialised Submodule (never `git submodule update
9232    /// --init`-ed, so its own path holds no `.git` at all) settles every cell a probe would
9233    /// otherwise open a repository for `Unknown(SubmoduleUninitialized)`, never `Failed`,
9234    /// because not being there yet is the normal, expected shape
9235    /// ([discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md)'s
9236    /// "An uninitialised Submodule is a row with every cell blank and `?` in the gutter").
9237    /// The row still exists (the assertion below finds it), so the row itself is not the
9238    /// mutation this covers; `probe_branch`/`probe_sync`/`probe_status`'s classification is.
9239    #[test]
9240    fn an_uninitialised_submodules_probed_cells_settle_unknown_not_failed() {
9241        let dir = tempfile::tempdir().expect("temp dir");
9242        let root = root_of(&dir);
9243        let parent = root.join("parent");
9244        init_repo_with_a_commit(&parent);
9245        fs::write(
9246            parent.join(".gitmodules"),
9247            "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
9248        )
9249        .expect("write .gitmodules");
9250        // Deliberately never initialised: no directory at all at the declared path, the
9251        // shape a plain `git clone` (no `--recurse-submodules`) leaves behind.
9252
9253        let mut core_spec = spec(vec![root]);
9254        core_spec.show_submodules = true;
9255        let core = Core::start_discovered(core_spec);
9256        let key = core
9257            .snapshot()
9258            .entities
9259            .iter()
9260            .find(|entity| matches!(entity.kind, Kind::Submodule))
9261            .expect("a discovered Submodule")
9262            .key
9263            .clone();
9264
9265        core.refresh(std::slice::from_ref(&key));
9266        let settled = core.settle();
9267        let submodule = settled
9268            .entities
9269            .iter()
9270            .find(|entity| entity.key == key)
9271            .expect("the Submodule entity");
9272
9273        assert!(
9274            matches!(
9275                submodule.branch.settled(),
9276                Some(Settled::Unknown(Unknown::SubmoduleUninitialized))
9277            ),
9278            "expected branch to settle Unknown(SubmoduleUninitialized), got {:?}",
9279            submodule.branch.settled()
9280        );
9281        assert!(
9282            matches!(
9283                submodule.sync.settled(),
9284                Some(Settled::Unknown(Unknown::SubmoduleUninitialized))
9285            ),
9286            "expected sync to settle Unknown(SubmoduleUninitialized), got {:?}",
9287            submodule.sync.settled()
9288        );
9289        assert!(
9290            matches!(
9291                submodule.dirty.settled(),
9292                Some(Settled::Unknown(Unknown::SubmoduleUninitialized))
9293            ),
9294            "expected dirty to settle Unknown(SubmoduleUninitialized), got {:?}",
9295            submodule.dirty.settled()
9296        );
9297        assert_eq!(
9298            summary(submodule),
9299            RowSummary::Unknown,
9300            "expected the row's own gutter fold to read Unknown, not Failed"
9301        );
9302    }
9303
9304    /// AC4's cost half: `show_submodules` off means a dispatched Generation never even
9305    /// opens a shown Submodule's own repository, while a shown one right beside it is
9306    /// probed normally in the very same Generation. Both submodules are real, valid
9307    /// repositories, so a probed-but-ignored implementation and a never-dispatched one are
9308    /// distinguishable only by whether the hidden one's cells ever leave "never settled".
9309    #[test]
9310    fn dispatch_skips_probing_a_hidden_submodule_while_probing_the_same_one_shown() {
9311        let dir = tempfile::tempdir().expect("temp dir");
9312        let root = root_of(&dir);
9313        let parent = root.join("parent");
9314        init_repo_with_a_commit(&parent);
9315        fs::write(
9316            parent.join(".gitmodules"),
9317            "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
9318        )
9319        .expect("write .gitmodules");
9320        init_repo_with_a_commit(&parent.join("vendor").join("lib"));
9321
9322        // `spec`'s own default: `show_submodules: false`.
9323        let core = Core::start_discovered(spec(vec![root]));
9324        let key = core
9325            .snapshot()
9326            .entities
9327            .iter()
9328            .find(|entity| matches!(entity.kind, Kind::Submodule))
9329            .expect("a discovered Submodule")
9330            .key
9331            .clone();
9332
9333        // First Generation, dispatched while hidden: `dispatch` must skip it outright.
9334        core.refresh(std::slice::from_ref(&key));
9335        let while_hidden = core.settle();
9336        let hidden_entity = while_hidden
9337            .entities
9338            .iter()
9339            .find(|entity| entity.key == key)
9340            .expect("submodule entity");
9341        assert!(
9342            hidden_entity.branch.settled().is_none(),
9343            "a Submodule dispatched while hidden must never even reach probe_branch, \
9344             so its cell stays never-settled rather than holding any value at all, got {:?}",
9345            hidden_entity.branch.settled()
9346        );
9347
9348        // Toggled live, no rebuild, then the very same key is handed to `refresh` again:
9349        // the second Generation is what proves the flag narrows the work rather than the
9350        // key, since nothing about the key or the `Core` itself changed in between.
9351        core.set_show_submodules(true);
9352        core.refresh(std::slice::from_ref(&key));
9353        let while_shown = core.settle();
9354        let shown_entity = while_shown
9355            .entities
9356            .iter()
9357            .find(|entity| entity.key == key)
9358            .expect("submodule entity");
9359        assert!(
9360            matches!(
9361                shown_entity.branch.settled(),
9362                Some(Settled::Known {
9363                    value: _,
9364                    at: _,
9365                    stale: _
9366                })
9367            ),
9368            "expected the same Submodule's branch to settle a real value once shown, got {:?}",
9369            shown_entity.branch.settled()
9370        );
9371    }
9372
9373    /// AC4's other half: toggling the live preference is free. Proven the same way
9374    /// `reload_with_the_same_active_set_leaves_discovery_and_its_generation_untouched`
9375    /// proves a same-Set reload never rebuilds `Core`: a Generation counter a rediscovery
9376    /// or a dispatch would have to move, checked before and after the toggle with nothing
9377    /// else run in between.
9378    #[test]
9379    fn toggling_show_submodules_starts_no_new_generation_and_dispatches_nothing() {
9380        let dir = tempfile::tempdir().expect("temp dir");
9381        let root = root_of(&dir);
9382        init_repo_with_a_commit(&root.join("repo-a"));
9383
9384        // Drained, so the two readings below differ only by whatever the toggles did.
9385        let (core, launched) = started_and_settled(spec(vec![root]));
9386        let before = launched.generation;
9387        let dispatched_before = core.dispatch_log_for_test();
9388        assert!(
9389            !dispatched_before.is_empty(),
9390            "launch dispatched nothing, so the comparison below would hold however much a \
9391             toggle dispatched"
9392        );
9393
9394        core.set_show_submodules(true);
9395        core.set_show_submodules(false);
9396
9397        assert_eq!(
9398            core.snapshot().generation,
9399            before,
9400            "toggling show_submodules must start no Generation of its own"
9401        );
9402        assert_eq!(
9403            core.dispatch_log_for_test(),
9404            dispatched_before,
9405            "toggling show_submodules must dispatch no probe of its own, leaving the last \
9406             Generation's own log exactly as it found it"
9407        );
9408    }
9409
9410    /// AC5: a `.gitmodules` parse failure marks the parent Repo's row Failed whether or not
9411    /// Submodules are shown, because the module pass that finds the failure runs either way
9412    /// ([discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md)'s
9413    /// "Failure": "The mark appears whether or not `show_submodules` is on, because the pass
9414    /// ran either way"). `spec`'s own default is already `show_submodules: false`, which is
9415    /// what makes this a real proof rather than a coincidence of some other default.
9416    #[test]
9417    fn a_malformed_gitmodules_file_still_fails_the_parent_while_submodules_are_hidden() {
9418        let dir = tempfile::tempdir().expect("temp dir");
9419        let root = root_of(&dir);
9420        let parent = root.join("parent");
9421        init_repo_with_a_commit(&parent);
9422        fs::write(
9423            parent.join(".gitmodules"),
9424            "[submodule \"lib\"\n\tpath = lib\n",
9425        )
9426        .expect("write malformed .gitmodules");
9427
9428        let core = Core::start_discovered(spec(vec![root]));
9429        let key = core
9430            .snapshot()
9431            .entities
9432            .iter()
9433            .find(|entity| entity.key.path() == parent)
9434            .expect("the parent entity")
9435            .key
9436            .clone();
9437        // The fold reads Failed only once the row holds some probed value at all: a
9438        // Generation's own dispatch is what proves the mark survives real probing, not
9439        // merely discovery's own construction-time diagnostics write.
9440        core.refresh(std::slice::from_ref(&key));
9441        let settled = core.settle();
9442        let parent_entity = settled
9443            .entities
9444            .iter()
9445            .find(|entity| entity.key == key)
9446            .expect("the parent entity");
9447
9448        assert_eq!(
9449            summary(parent_entity),
9450            RowSummary::Failed,
9451            "expected the parent to fold Failed even with Submodules hidden"
9452        );
9453        assert!(
9454            parent_entity.diagnostics.gitmodules_failed.is_some(),
9455            "expected the failure recorded in Diagnostics for the detail pane"
9456        );
9457        assert!(
9458            !settled
9459                .entities
9460                .iter()
9461                .any(|entity| matches!(entity.kind, Kind::Submodule)),
9462            "an unparseable .gitmodules yields no Submodule rows for that parent"
9463        );
9464    }
9465
9466    #[test]
9467    fn count_matches_a_plain_discoverys_entity_count() {
9468        let dir = tempfile::tempdir().expect("temp dir");
9469        let root = root_of(&dir);
9470        init_repo_with_a_commit(&root.join("one"));
9471        init_repo_with_a_commit(&root.join("two"));
9472
9473        let set = SetSpec {
9474            name: "test".to_string(),
9475            roots: vec![root],
9476            include: Vec::new(),
9477            exclude: Vec::new(),
9478        };
9479
9480        assert_eq!(discovery::count(&set), 2);
9481    }
9482
9483    #[test]
9484    fn the_slow_discovery_watcher_warns_with_the_count_reached_and_the_roots() {
9485        let progress = Arc::new(AtomicUsize::new(42));
9486        let finished = Arc::new(AtomicBool::new(false));
9487        let roots = vec![PathBuf::from("/repos/a"), PathBuf::from("/repos/b")];
9488
9489        let warning = watch_for_slow_discovery(progress, finished, roots, Duration::from_millis(1));
9490
9491        let message = warning.expect("a walk that has not finished should warn");
9492        assert!(message.contains("42"));
9493        assert!(message.contains("/repos/a"));
9494        assert!(message.contains("/repos/b"));
9495    }
9496
9497    #[test]
9498    fn the_slow_discovery_watcher_is_silent_once_the_walk_has_already_finished() {
9499        let progress = Arc::new(AtomicUsize::new(7));
9500        let finished = Arc::new(AtomicBool::new(true));
9501
9502        let warning =
9503            watch_for_slow_discovery(progress, finished, Vec::new(), Duration::from_millis(1));
9504
9505        assert!(warning.is_none());
9506    }
9507
9508    /// The same watcher `start_internal` wires in: on a fast, already-finished
9509    /// walk (the common case), joining its handle proves it ran and recorded no
9510    /// warning, exercised through `Core::start` itself rather than in isolation.
9511    /// `warn_after` is one second, the real production threshold, rather than a
9512    /// margin picked for speed: a one-repository walk finishes orders of
9513    /// magnitude faster than that even on a loaded machine, so this proves the
9514    /// fast path without racing a real walk the way a millisecond threshold did.
9515    #[test]
9516    fn a_fast_discovery_leaves_no_warning_once_the_watcher_has_run() {
9517        let dir = tempfile::tempdir().expect("temp dir");
9518        let root = root_of(&dir);
9519        init_repo_with_a_commit(&root.join("repo"));
9520        let (_tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
9521
9522        let started =
9523            Core::start_for_test(spec(vec![root]), Duration::from_secs(1), tick_rx).discovered();
9524        started
9525            .discovery_watcher
9526            .join()
9527            .expect("watcher thread should not panic");
9528
9529        assert!(started.core.discovery_warning().is_none());
9530    }
9531
9532    /// A [`DiscoveryGate`] starting `open`, and the channel that opens it once the call
9533    /// under test has returned.
9534    ///
9535    /// The gate is what makes "before its walk has run" a rendezvous rather than a
9536    /// margin. The channel is what makes an implementation that walks inline fail its
9537    /// assertion instead of wedging the run: nothing else would ever open the gate for
9538    /// it, so the backstop below is its only release, and the assertion then reports.
9539    fn gate_opened_on_signal(open: bool) -> (DiscoveryGate, Sender<()>, JoinHandle<()>) {
9540        let gate: DiscoveryGate = Arc::new((Mutex::new(open), Condvar::new()));
9541        let (returned_tx, returned_rx) = crossbeam_channel::bounded::<()>(1);
9542        let opener = thread::spawn({
9543            let gate = Arc::clone(&gate);
9544            move || {
9545                let _ = returned_rx.recv_timeout(crate::liveness::BACKSTOP);
9546                set_discovery_gate(&gate, true);
9547            }
9548        });
9549        (gate, returned_tx, opener)
9550    }
9551
9552    /// Criterion 1: `Core::start` returns before discovery has finished, and the rows
9553    /// land when discovery does.
9554    ///
9555    /// The walk is held closed before the `Core` is built, so the empty table below is
9556    /// the table `start` actually returned rather than one this test raced it to. Joining
9557    /// the harness's own `initial_discovery` handle afterwards is the rendezvous that says
9558    /// the walk landed: no sleep and no poll on either side.
9559    ///
9560    /// The row's phase C is held from before the walk is let go, so the cell read below
9561    /// is read at a point this test fixes rather than at whatever point launch's own
9562    /// Generation happened to have reached.
9563    #[test]
9564    fn start_returns_against_an_empty_table_and_the_rows_land_when_discovery_does() {
9565        let dir = tempfile::tempdir().expect("temp dir");
9566        let root = root_of(&dir);
9567        let repo = root.join("repo");
9568        init_repo_with_a_commit(&repo);
9569        let (_tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
9570        let (gate, start_returned, opener) = gate_opened_on_signal(false);
9571
9572        let started = Core::start_for_test_gated(
9573            spec(vec![root]),
9574            Duration::from_secs(3600),
9575            discovery::ABANDON_AFTER,
9576            tick_rx,
9577            Some(Arc::clone(&gate)),
9578        );
9579        let at_start = started.core.snapshot();
9580        let key = EntityKey::new(Arc::from(repo.as_path()));
9581        started.core.hold_phase_c_for_test(&key);
9582        start_returned.send(()).expect("the opener is listening");
9583        opener.join().expect("the opener thread should not panic");
9584        let started = started.discovered();
9585
9586        assert!(
9587            at_start.entities.is_empty(),
9588            "`Core::start` must return before discovery has finished, against the empty \
9589             table a consumer draws its first frame from, got {:?}",
9590            at_start
9591                .entities
9592                .iter()
9593                .map(|entity| entity.name.to_string())
9594                .collect::<Vec<_>>()
9595        );
9596
9597        let landed = started.core.snapshot();
9598        assert_eq!(
9599            landed
9600                .entities
9601                .iter()
9602                .map(|entity| entity.name.to_string())
9603                .collect::<Vec<_>>(),
9604            vec!["repo".to_string()],
9605            "the row must land on the table as soon as discovery does"
9606        );
9607        assert!(
9608            landed.entities[0].dirty.settled().is_none() && landed.entities[0].dirty.is_in_flight(),
9609            "discovery lands the row alone: launch's own Generation is already covering it \
9610             and its Cells stay unsettled until that Generation answers, which is what the \
9611             spinner sits behind"
9612        );
9613
9614        started.core.release_phase_c_for_test(&key);
9615        started.core.wait_phase_c_finished_for_test(&key);
9616    }
9617
9618    /// Criterion 2: a Generation that resolves its own order after its own discovery
9619    /// covers every row that walk found, including the ones the caller could not have
9620    /// named, and fills their Cells.
9621    ///
9622    /// `refresh_all` rather than `refresh`, because a caller that has just discarded the
9623    /// old Set's rows has no key to order by; the row below is discovered by this
9624    /// Generation's own walk and probed by the same Generation. Named by its order after
9625    /// launch's own Generation rather than by a number.
9626    #[test]
9627    fn refresh_all_covers_every_row_its_own_discovery_found() {
9628        let dir = tempfile::tempdir().expect("temp dir");
9629        let root = root_of(&dir);
9630        init_repo_with_a_commit(&root.join("repo"));
9631
9632        let (core, launched) = started_and_settled(spec(vec![root.clone()]));
9633        assert_eq!(
9634            launched
9635                .entities
9636                .iter()
9637                .map(|entity| entity.name.to_string())
9638                .collect::<Vec<_>>(),
9639            vec!["repo".to_string()],
9640            "launch's own walk must have landed and covered exactly the one row that \
9641             existed when it ran"
9642        );
9643        // Created after that walk finished, so this row exists in no snapshot the caller
9644        // could have read: only a Generation that resolves its own order after its own
9645        // discovery reaches it.
9646        init_repo_with_a_commit(&root.join("late"));
9647
9648        assert_eq!(
9649            core.refresh_all(),
9650            launched.generation.successor(),
9651            "`refresh_all` must be the Generation immediately after the one already on the \
9652             table"
9653        );
9654        let settled = core.settle();
9655
9656        let mut named: Vec<String> = settled
9657            .entities
9658            .iter()
9659            .filter(|entity| entity.branch.settled().is_some())
9660            .map(|entity| entity.name.to_string())
9661            .collect();
9662        named.sort();
9663        assert_eq!(
9664            named,
9665            vec!["late".to_string(), "repo".to_string()],
9666            "the Generation must cover every row its own discovery found, including one the \
9667             caller had no key for"
9668        );
9669    }
9670
9671    /// Criterion 3: `r`, focus gained and resume all reach `Core::refresh`, and it
9672    /// returns before its own Generation's discovery has run, so none of them holds the
9673    /// event loop for the length of a walk.
9674    ///
9675    /// `late` is created after the first walk has already finished, so only this
9676    /// `refresh`'s own walk could ever find it: its absence from the table `refresh`
9677    /// returned against is what says that walk had not run. Opening the gate afterwards
9678    /// lets the same Generation finish, which is what proves the work was deferred rather
9679    /// than dropped.
9680    #[test]
9681    fn refresh_returns_before_its_own_generations_discovery_has_run() {
9682        let dir = tempfile::tempdir().expect("temp dir");
9683        let root = root_of(&dir);
9684        init_repo_with_a_commit(&root.join("repo"));
9685        let (_tick_tx, tick_rx) = crossbeam_channel::unbounded::<Instant>();
9686        let (gate, walk_may_run, opener) = gate_opened_on_signal(true);
9687
9688        let started = Core::start_for_test_gated(
9689            spec(vec![root.clone()]),
9690            Duration::from_secs(3600),
9691            discovery::ABANDON_AFTER,
9692            tick_rx,
9693            Some(Arc::clone(&gate)),
9694        )
9695        .discovered();
9696        let core = started.core;
9697        // Drained, so the settle-gate reading below is this `refresh`'s alone.
9698        let launched = settle_launch(&core);
9699        let keys: Vec<EntityKey> = launched
9700            .entities
9701            .iter()
9702            .map(|entity| entity.key.clone())
9703            .collect();
9704        init_repo_with_a_commit(&root.join("late"));
9705
9706        set_discovery_gate(&gate, false);
9707        let generation = core.refresh(&keys);
9708        let while_held = core.snapshot();
9709        let dispatched_while_held = core.settle_gate_count_for_test();
9710        walk_may_run.send(()).expect("the opener is listening");
9711        opener.join().expect("the opener thread should not panic");
9712
9713        assert_eq!(
9714            generation,
9715            launched.generation.successor(),
9716            "`refresh` must return its own Generation's number, the one immediately after \
9717             the table's, before that Generation has done any of its work"
9718        );
9719        assert!(
9720            !while_held
9721                .entities
9722                .iter()
9723                .any(|entity| &*entity.name == "late"),
9724            "`refresh` must return before its own Generation's walk has run, so a Repo \
9725             created after the previous walk is not on the table it returned against"
9726        );
9727        assert_eq!(
9728            dispatched_while_held, 0,
9729            "`refresh` returned before its Generation reached the table at all, so nothing \
9730             is dispatched yet"
9731        );
9732
9733        core.wait_dispatched_for_test();
9734        let settled = core.settle();
9735
9736        assert!(
9737            settled
9738                .entities
9739                .iter()
9740                .any(|entity| &*entity.name == "late"),
9741            "the deferred Generation must still run its own walk once it is let through: \
9742             deferred, never dropped"
9743        );
9744    }
9745
9746    /// The turnstile's whole claim: a Generation reserved second cannot reach the table
9747    /// before the one reserved first, whatever the two threads' own scheduling does.
9748    ///
9749    /// Without it a `refresh` whose walk finished quickly could insert its in-flight
9750    /// entries ahead of an older Generation's, leaving the older one to cancel the newer
9751    /// one and record itself as the live one, which is
9752    /// [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
9753    /// "Supersession" read backwards. The later ticket is taken on this thread, so it can
9754    /// only ever record itself after the earlier body has recorded and released; an
9755    /// implementation that did not wait would record the later one first.
9756    #[test]
9757    fn a_dispatch_body_waits_for_every_earlier_reserved_generation() {
9758        let turnstile = Arc::new(DispatchTurnstile::default());
9759        let earlier = turnstile.reserve();
9760        let later = turnstile.reserve();
9761        let order = Arc::new(Mutex::new(Vec::new()));
9762
9763        let earlier_body = thread::spawn({
9764            let turnstile = Arc::clone(&turnstile);
9765            let order = Arc::clone(&order);
9766            move || {
9767                let _turn = turnstile.take(earlier);
9768                order.lock().unwrap().push(earlier);
9769            }
9770        });
9771
9772        {
9773            let _turn = turnstile.take(later);
9774            order.lock().unwrap().push(later);
9775        }
9776        earlier_body
9777            .join()
9778            .expect("the earlier body should not panic");
9779
9780        assert_eq!(
9781            *order.lock().unwrap(),
9782            vec![earlier, later],
9783            "a dispatch body must run in the order its Generation was reserved"
9784        );
9785    }
9786
9787    /// The generic cancellation primitive stops a loop the instant `cancel` is
9788    /// observed, proven with a channel rendezvous rather than a sleep: `cancel` is
9789    /// set only after the worker's third step has genuinely completed, so a fourth
9790    /// step running at all would mean the flag was set but never actually checked.
9791    #[test]
9792    fn run_while_not_cancelled_stops_at_the_next_check_rather_than_running_forever() {
9793        let cancel = Arc::new(AtomicBool::new(false));
9794        let worker_cancel = Arc::clone(&cancel);
9795        let (step_started_tx, step_started_rx) = crossbeam_channel::bounded::<()>(0);
9796        let (proceed_tx, proceed_rx) = crossbeam_channel::bounded::<()>(0);
9797
9798        let worker = thread::spawn(move || {
9799            run_while_not_cancelled(&worker_cancel, || {
9800                step_started_tx.send(()).expect("test should be listening");
9801                proceed_rx.recv().is_ok()
9802            })
9803        });
9804
9805        for _ in 0..2 {
9806            step_started_rx
9807                .recv()
9808                .expect("worker should announce each step");
9809            proceed_tx.send(()).expect("let the step finish");
9810        }
9811        step_started_rx
9812            .recv()
9813            .expect("worker should announce its third step");
9814        cancel.store(true, Ordering::Release);
9815        proceed_tx.send(()).expect("let the third step finish");
9816
9817        let ran = worker.join().expect("worker thread should not panic");
9818
9819        assert_eq!(
9820            ran, 3,
9821            "expected cancellation to stop the loop after its third step"
9822        );
9823    }
9824
9825    /// Phase A's own per-entity timing distribution: opens (or reuses a cached
9826    /// handle for) every entity in `population` and reads `HEAD` from it, exactly
9827    /// the work `probe_branch` does, one rayon task per entity via `fanout::scatter`
9828    /// rather than `Core::refresh`, so the timing is not entangled with the
9829    /// settle-gate bookkeeping a full `Core` also pays for. Returns one
9830    /// [`Duration`] per entity actually probed, so a caller reports a real
9831    /// distribution rather than a total divided by a count.
9832    fn benchmark_identity_phase(
9833        population: Vec<crate::discovery::DiscoveredEntity>,
9834    ) -> (Duration, Vec<Duration>) {
9835        let (tx, rx) = crossbeam_channel::unbounded();
9836        let started = Instant::now();
9837        crate::fanout::scatter(population, tx, |entity| {
9838            let task_started = Instant::now();
9839            let repo = match &entity.repo {
9840                Some(repo) => repo.to_thread_local(),
9841                None => match git::open_thread_safe(entity.key.path()) {
9842                    Ok(repo) => repo.to_thread_local(),
9843                    Err(_) => return None,
9844                },
9845            };
9846            let _ = git::head_shape(&repo);
9847            Some(task_started.elapsed())
9848        });
9849        let wall = started.elapsed();
9850        let durations: Vec<Duration> = rx.into_iter().flatten().collect();
9851        (wall, durations)
9852    }
9853
9854    /// Every root this machine actually has of the two the owner's real corpus
9855    /// lives under. Read from `$HOME` at run time rather than a literal in this
9856    /// file, so no personal path is ever recorded in committed source.
9857    fn real_corpus_roots() -> Vec<PathBuf> {
9858        let Some(home) = std::env::var_os("HOME") else {
9859            return Vec::new();
9860        };
9861        let home = PathBuf::from(home);
9862        ["dev", "dev-misc"]
9863            .into_iter()
9864            .map(|leaf| home.join(leaf))
9865            .filter(|root| root.is_dir())
9866            .collect()
9867    }
9868
9869    /// A `.git`-committed disposable repository per index, standing in for the
9870    /// real corpus when it is absent or too small to be meaningful. Each one gets
9871    /// a distinct commit so opening it is not a single cached filesystem page for
9872    /// every entity.
9873    fn generated_fixture_corpus(size: usize) -> tempfile::TempDir {
9874        let root = tempfile::tempdir().expect("temp dir for generated fixture corpus");
9875        for i in 0..size {
9876            let repo = root.path().join(format!("fixture-repo-{i}"));
9877            fs::create_dir_all(&repo).expect("create fixture repo dir");
9878            gix::init(&repo).expect("init fixture repo");
9879            let status = Command::new("git")
9880                .arg("-C")
9881                .arg(&repo)
9882                .args(["-c", "user.email=test@example.com", "-c", "user.name=Test"])
9883                .args(["commit", "--allow-empty", "-m", &format!("commit {i}")])
9884                .status()
9885                .expect("run git commit");
9886            assert!(status.success());
9887        }
9888        root
9889    }
9890
9891    /// Percentile `p` (0 to 100) of an already-sorted, non-empty slice.
9892    fn percentile(sorted: &[Duration], p: usize) -> Duration {
9893        let index = (sorted.len() - 1) * p / 100;
9894        sorted[index]
9895    }
9896
9897    /// Path-component names to keep out of the benchmark's population entirely,
9898    /// read from an environment variable rather than a literal in this file: a
9899    /// standing project rule keeps certain names out of committed source, so a
9900    /// real run supplies them at invocation time
9901    /// (`REPON_BENCHMARK_EXCLUDE_NAMES=name-one,name-two`) instead of this file
9902    /// ever spelling one out. Empty, and therefore excluding nothing, when unset.
9903    fn extra_excluded_names() -> Vec<String> {
9904        parse_excluded_names(&std::env::var("REPON_BENCHMARK_EXCLUDE_NAMES").unwrap_or_default())
9905    }
9906
9907    /// The comma-separated parsing `extra_excluded_names` applies to whatever the
9908    /// environment variable holds, split out so it can be proven against a literal
9909    /// string rather than by mutating process environment state a parallel test
9910    /// run could race on.
9911    fn parse_excluded_names(raw: &str) -> Vec<String> {
9912        raw.split(',')
9913            .map(str::trim)
9914            .filter(|name| !name.is_empty())
9915            .map(str::to_string)
9916            .collect()
9917    }
9918
9919    /// Discovers, resolves and excluded-name-filters one root list into a
9920    /// population, without opening anything `excluded_names` names at any depth.
9921    /// Returns the wall time of discovery and resolution alongside the
9922    /// population, since resolution is where every entity's repository is
9923    /// actually opened the first time ([`git::resolve_boundary`]); the identity
9924    /// phase timed afterwards only re-reads `HEAD` from the handle that step
9925    /// already cached.
9926    fn discover_population(
9927        roots: Vec<PathBuf>,
9928        excluded_names: &[String],
9929    ) -> (Vec<crate::discovery::DiscoveredEntity>, Duration) {
9930        let set = SetSpec {
9931            name: "identity-probe-benchmark".to_string(),
9932            roots,
9933            include: Vec::new(),
9934            exclude: Vec::new(),
9935        };
9936        let started = Instant::now();
9937        let discovery = discovery::discover(&set);
9938        let (discovered, _) = discovery::resolve(&set, &discovery.entities);
9939        let elapsed = started.elapsed();
9940        let population = discovered
9941            .into_iter()
9942            .filter(|entity| {
9943                !entity.key.path().components().any(|component| {
9944                    excluded_names
9945                        .iter()
9946                        .any(|name| component.as_os_str() == name.as_str())
9947                })
9948            })
9949            .collect();
9950        (population, elapsed)
9951    }
9952
9953    /// The exclusion mechanism proven against a fixture: a name present nowhere
9954    /// but this test's own excluded-names list still keeps a matching boundary
9955    /// out of the discovered population, and its two siblings still get through.
9956    #[test]
9957    fn a_boundary_whose_path_matches_an_excluded_name_is_left_out_of_the_population() {
9958        let fixture = generated_fixture_corpus(3);
9959        let excluded = vec!["fixture-repo-1".to_string()];
9960
9961        let (population, _) = discover_population(vec![fixture.path().to_path_buf()], &excluded);
9962
9963        assert_eq!(population.len(), 2);
9964        assert!(
9965            population
9966                .iter()
9967                .all(|entity| entity.key.path().file_name().unwrap() != "fixture-repo-1"),
9968            "the excluded name must never appear in the population discovery returns"
9969        );
9970    }
9971
9972    #[test]
9973    fn excluded_names_parses_a_comma_separated_list_and_ignores_blanks() {
9974        assert_eq!(
9975            parse_excluded_names("foo, bar ,,baz"),
9976            vec!["foo".to_string(), "bar".to_string(), "baz".to_string()]
9977        );
9978        assert!(parse_excluded_names("").is_empty());
9979        assert!(parse_excluded_names("   ").is_empty());
9980    }
9981
9982    /// Benchmarks the identity probe (phase A: open the repository, read `HEAD`)
9983    /// against the owner's real corpus under `$HOME/dev` and `$HOME/dev-misc`,
9984    /// falling back to a generated fixture when the real corpus is absent or too
9985    /// small to be meaningful (fewer than 20 entities). Never run by `just ci`:
9986    /// this is a hand-run measurement, per this project's convention of recording
9987    /// hand-run figures with the date, machine and toolchain rather than asserting
9988    /// a timing budget in a committed test. Run it with:
9989    /// `cargo test -p repon-core --release -- --ignored --nocapture identity_probe_benchmark`
9990    ///
9991    /// Read-only throughout: discovery only stats for a `.git` entry and phase A
9992    /// only reads `HEAD`. Any boundary whose path has a component named by
9993    /// `REPON_BENCHMARK_EXCLUDE_NAMES` is dropped before discovery's second half
9994    /// would ever open it, which is how a standing exclusion is honoured without
9995    /// this file naming what it excludes.
9996    #[test]
9997    #[ignore = "hand-run against the owner's real corpus; see docs/spec/refresh.md for the recorded figures"]
9998    fn identity_probe_benchmark() {
9999        let excluded_names = extra_excluded_names();
10000
10001        // `_fixture` is held for the rest of the test whenever a fixture is used,
10002        // so its directories still exist when the identity phase opens them; it is
10003        // simply never populated on the real-corpus path.
10004        let mut _fixture: Option<tempfile::TempDir> = None;
10005
10006        let (real_population, real_discovery_wall) =
10007            discover_population(real_corpus_roots(), &excluded_names);
10008        let (population, using_fixture, discovery_wall) = if real_population.len() >= 20 {
10009            (real_population, false, real_discovery_wall)
10010        } else {
10011            println!(
10012                "real corpus absent or too small to be meaningful ({} entities); \
10013                 using a generated fixture instead",
10014                real_population.len()
10015            );
10016            let fixture = generated_fixture_corpus(300);
10017            let (population, fixture_discovery_wall) =
10018                discover_population(vec![fixture.path().to_path_buf()], &excluded_names);
10019            _fixture = Some(fixture);
10020            (population, true, fixture_discovery_wall)
10021        };
10022
10023        let population_size = population.len();
10024        assert!(
10025            population_size > 0,
10026            "neither a real corpus root nor the generated fixture produced any entities"
10027        );
10028
10029        let (wall, mut durations) = benchmark_identity_phase(population);
10030        durations.sort();
10031
10032        println!(
10033            "identity probe benchmark: corpus = {}, population = {population_size}",
10034            if using_fixture {
10035                "generated fixture"
10036            } else {
10037                "real corpus"
10038            }
10039        );
10040        println!(
10041            "discovery + first open (serial, every entity's own gix::open): {discovery_wall:?}"
10042        );
10043        println!("identity phase, warm, parallel (HEAD re-read from the cached handle): {wall:?}");
10044        println!(
10045            "identity phase per entity: p50 {:?}, p90 {:?}, max {:?}",
10046            percentile(&durations, 50),
10047            percentile(&durations, 90),
10048            durations.last().copied().unwrap_or_default(),
10049        );
10050    }
10051
10052    fn spec_with_overrides(roots: Vec<PathBuf>, overrides: Vec<RepoOverride>) -> CoreSpec {
10053        let mut spec = spec(roots);
10054        spec.overrides = overrides;
10055        spec
10056    }
10057
10058    /// The seam this proves: an explicit per-Repo override reaches all the way
10059    /// through `Core::refresh` and `settle` into the `default_branch` cell as
10060    /// rung 1, recorded in diagnostics, even though `origin/HEAD` and the name
10061    /// list would both answer differently if asked.
10062    #[test]
10063    fn a_per_repo_override_resolves_the_default_branch_at_rung_one_through_a_real_refresh() {
10064        let dir = tempfile::tempdir().expect("temp dir");
10065        let root = root_of(&dir);
10066        let repo = root.join("repo");
10067        init_repo_with_a_commit(&repo);
10068        git(
10069            &repo,
10070            &[
10071                "remote",
10072                "add",
10073                "origin",
10074                "https://example.invalid/repo.git",
10075            ],
10076        );
10077        let sha = head_sha(&repo);
10078        git(&repo, &["update-ref", "refs/remotes/origin/main", &sha]);
10079        let remote_refs_dir = repo
10080            .join(".git")
10081            .join("refs")
10082            .join("remotes")
10083            .join("origin");
10084        fs::create_dir_all(&remote_refs_dir).expect("create refs/remotes/origin dir");
10085        fs::write(
10086            remote_refs_dir.join("HEAD"),
10087            "ref: refs/remotes/origin/main\n",
10088        )
10089        .expect("write HEAD");
10090
10091        let core = Core::start_discovered(spec_with_overrides(
10092            vec![root],
10093            vec![RepoOverride {
10094                path: repo.clone(),
10095                default_branch: Some("develop".to_string()),
10096                excluded: false,
10097            }],
10098        ));
10099        let key = core.snapshot().entities[0].key.clone();
10100
10101        core.refresh(std::slice::from_ref(&key));
10102        let settled = core.settle();
10103        let entity = &settled.entities[0];
10104
10105        match entity.default_branch.settled() {
10106            Some(Settled::Known {
10107                value,
10108                at: _,
10109                stale: _,
10110            }) => assert_eq!(
10111                value.name(),
10112                "origin/develop",
10113                "the override must win even though origin/HEAD names a different branch"
10114            ),
10115            other => panic!("expected the override's own answer, got {other:?}"),
10116        }
10117        assert_eq!(
10118            entity.diagnostics.default_branch_rung,
10119            Some(1),
10120            "an override must be recorded as rung 1"
10121        );
10122    }
10123
10124    /// `probe_now`'s synchronous path carries the same override wiring as
10125    /// `refresh`, proven directly since a Launcher return uses it without ever
10126    /// calling `refresh` first.
10127    #[test]
10128    fn a_per_repo_override_also_resolves_through_probe_now() {
10129        let dir = tempfile::tempdir().expect("temp dir");
10130        let root = root_of(&dir);
10131        let repo = root.join("repo");
10132        init_repo_with_a_commit(&repo);
10133
10134        let core = Core::start_discovered(spec_with_overrides(
10135            vec![root],
10136            vec![RepoOverride {
10137                path: repo.clone(),
10138                default_branch: Some("release".to_string()),
10139                excluded: false,
10140            }],
10141        ));
10142        let key = core.snapshot().entities[0].key.clone();
10143
10144        let entity = core.probe_now(&key);
10145
10146        match entity.default_branch.settled() {
10147            // No remote at all: the override still answers, using the bare name.
10148            Some(Settled::Known {
10149                value,
10150                at: _,
10151                stale: _,
10152            }) => assert_eq!(value.name(), "release"),
10153            other => panic!("expected the override's own answer, got {other:?}"),
10154        }
10155        assert_eq!(entity.diagnostics.default_branch_rung, Some(1));
10156    }
10157
10158    /// The three named ways rung 4 is reached are recorded distinctly, not merged
10159    /// into one opaque "gave up" fact: no remote at all, two or more remotes with
10160    /// none named `origin`, and a chosen remote whose tracking refs matched
10161    /// nothing in the name list.
10162    #[test]
10163    fn reaching_rung_four_with_no_remote_at_all_records_why() {
10164        let dir = tempfile::tempdir().expect("temp dir");
10165        let root = root_of(&dir);
10166        let repo = root.join("repo");
10167        init_repo_with_a_commit(&repo);
10168
10169        let core = Core::start_discovered(spec(vec![root]));
10170        let key = core.snapshot().entities[0].key.clone();
10171
10172        core.refresh(std::slice::from_ref(&key));
10173        let settled = core.settle();
10174        let entity = &settled.entities[0];
10175
10176        assert_eq!(entity.diagnostics.default_branch_rung, Some(4));
10177        assert_eq!(
10178            entity.diagnostics.default_branch_stopped,
10179            Some(DefaultBranchStopped::NoRemote)
10180        );
10181    }
10182
10183    #[test]
10184    fn reaching_rung_four_with_two_unnamed_remotes_records_why() {
10185        let dir = tempfile::tempdir().expect("temp dir");
10186        let root = root_of(&dir);
10187        let repo = root.join("repo");
10188        init_repo_with_a_commit(&repo);
10189        git(
10190            &repo,
10191            &[
10192                "remote",
10193                "add",
10194                "fork-one",
10195                "https://example.invalid/one.git",
10196            ],
10197        );
10198        git(
10199            &repo,
10200            &[
10201                "remote",
10202                "add",
10203                "fork-two",
10204                "https://example.invalid/two.git",
10205            ],
10206        );
10207
10208        let core = Core::start_discovered(spec(vec![root]));
10209        let key = core.snapshot().entities[0].key.clone();
10210
10211        core.refresh(std::slice::from_ref(&key));
10212        let settled = core.settle();
10213        let entity = &settled.entities[0];
10214
10215        assert_eq!(entity.diagnostics.default_branch_rung, Some(4));
10216        assert_eq!(
10217            entity.diagnostics.default_branch_stopped,
10218            Some(DefaultBranchStopped::AmbiguousRemote)
10219        );
10220    }
10221
10222    #[test]
10223    fn reaching_rung_four_with_a_chosen_remote_and_no_matching_ref_records_why() {
10224        let dir = tempfile::tempdir().expect("temp dir");
10225        let root = root_of(&dir);
10226        let repo = root.join("repo");
10227        init_repo_with_a_commit(&repo);
10228        git(
10229            &repo,
10230            &[
10231                "remote",
10232                "add",
10233                "origin",
10234                "https://example.invalid/repo.git",
10235            ],
10236        );
10237        // A remote-tracking ref exists, but under a name outside rung 3's list, and
10238        // there is no origin/HEAD at all.
10239        let sha = head_sha(&repo);
10240        git(&repo, &["update-ref", "refs/remotes/origin/feature", &sha]);
10241
10242        let core = Core::start_discovered(spec(vec![root]));
10243        let key = core.snapshot().entities[0].key.clone();
10244
10245        core.refresh(std::slice::from_ref(&key));
10246        let settled = core.settle();
10247        let entity = &settled.entities[0];
10248
10249        assert_eq!(entity.diagnostics.default_branch_rung, Some(4));
10250        assert_eq!(
10251            entity.diagnostics.default_branch_stopped,
10252            Some(DefaultBranchStopped::NameListExhausted)
10253        );
10254    }
10255
10256    /// A Repo with no override and no resolvable remote reaches rung 4: Unknown,
10257    /// never Failed, which stays reserved for a git error.
10258    #[test]
10259    fn a_repo_with_nothing_to_resolve_settles_unknown_never_failed() {
10260        let dir = tempfile::tempdir().expect("temp dir");
10261        let root = root_of(&dir);
10262        let repo = root.join("repo");
10263        init_repo_with_a_commit(&repo);
10264
10265        let core = Core::start_discovered(spec(vec![root]));
10266        let key = core.snapshot().entities[0].key.clone();
10267
10268        core.refresh(std::slice::from_ref(&key));
10269        let settled = core.settle();
10270        let entity = &settled.entities[0];
10271
10272        assert!(matches!(
10273            entity.default_branch.settled(),
10274            Some(Settled::Unknown(Unknown::NoDefaultBranch))
10275        ));
10276        assert_eq!(entity.diagnostics.default_branch_rung, Some(4));
10277    }
10278
10279    /// The seam this proves: a stale symbolic `origin/HEAD` reaches all the way
10280    /// through `Core::refresh` and `settle` into `Diagnostics`, not just the
10281    /// fallen-through rung 3 answer, since the spec requires recording that the
10282    /// stale case is what happened rather than leaving the same trail a merely
10283    /// absent `origin/HEAD` would.
10284    #[test]
10285    fn a_stale_remote_head_is_recorded_in_diagnostics_through_a_real_refresh() {
10286        let dir = tempfile::tempdir().expect("temp dir");
10287        let root = root_of(&dir);
10288        let repo = root.join("repo");
10289        init_repo_with_a_commit(&repo);
10290        git(
10291            &repo,
10292            &[
10293                "remote",
10294                "add",
10295                "origin",
10296                "https://example.invalid/repo.git",
10297            ],
10298        );
10299        let sha = head_sha(&repo);
10300        git(&repo, &["update-ref", "refs/remotes/origin/trunk", &sha]);
10301        let remote_refs_dir = repo
10302            .join(".git")
10303            .join("refs")
10304            .join("remotes")
10305            .join("origin");
10306        fs::create_dir_all(&remote_refs_dir).expect("create refs/remotes/origin dir");
10307        // Points at a name never created as a ref: the stale case, not merely absent.
10308        fs::write(
10309            remote_refs_dir.join("HEAD"),
10310            "ref: refs/remotes/origin/main\n",
10311        )
10312        .expect("write HEAD");
10313
10314        let core = Core::start_discovered(spec(vec![root]));
10315        let key = core.snapshot().entities[0].key.clone();
10316
10317        core.refresh(std::slice::from_ref(&key));
10318        let settled = core.settle();
10319        let entity = &settled.entities[0];
10320
10321        match entity.default_branch.settled() {
10322            Some(Settled::Known {
10323                value,
10324                at: _,
10325                stale: _,
10326            }) => {
10327                assert_eq!(value.name(), "origin/trunk")
10328            }
10329            other => panic!("expected the name list's answer, got {other:?}"),
10330        }
10331        assert!(
10332            entity.diagnostics.default_branch_rung_two_stale,
10333            "a stale origin/HEAD target must be recorded on the entity's diagnostics"
10334        );
10335    }
10336
10337    /// A resolvable `origin/HEAD` must never be marked stale, so the flag actually
10338    /// distinguishes the two cases rather than always being set once rung 2 runs.
10339    #[test]
10340    fn a_resolvable_remote_head_is_not_recorded_as_stale() {
10341        let dir = tempfile::tempdir().expect("temp dir");
10342        let root = root_of(&dir);
10343        let repo = root.join("repo");
10344        init_repo_with_a_commit(&repo);
10345        git(
10346            &repo,
10347            &[
10348                "remote",
10349                "add",
10350                "origin",
10351                "https://example.invalid/repo.git",
10352            ],
10353        );
10354        let sha = head_sha(&repo);
10355        git(&repo, &["update-ref", "refs/remotes/origin/main", &sha]);
10356        let remote_refs_dir = repo
10357            .join(".git")
10358            .join("refs")
10359            .join("remotes")
10360            .join("origin");
10361        fs::create_dir_all(&remote_refs_dir).expect("create refs/remotes/origin dir");
10362        fs::write(
10363            remote_refs_dir.join("HEAD"),
10364            "ref: refs/remotes/origin/main\n",
10365        )
10366        .expect("write HEAD");
10367
10368        let core = Core::start_discovered(spec(vec![root]));
10369        let key = core.snapshot().entities[0].key.clone();
10370
10371        core.refresh(std::slice::from_ref(&key));
10372        let settled = core.settle();
10373        let entity = &settled.entities[0];
10374
10375        assert!(!entity.diagnostics.default_branch_rung_two_stale);
10376    }
10377
10378    /// The defining behaviour for per-Repo matching: one `[[repo]]` entry naming
10379    /// only the parent Repo's own path still applies to a linked Worktree sharing
10380    /// its common dir, proven against a real `git worktree add` rather than a
10381    /// hand-built stand-in for the on-disk relationship.
10382    #[test]
10383    fn one_override_on_a_repos_path_covers_a_worktree_sharing_its_common_dir() {
10384        let dir = tempfile::tempdir().expect("temp dir");
10385        let root = root_of(&dir);
10386        let parent = root.join("parent");
10387        init_repo_with_a_commit(&parent);
10388        let worktree = root.join("worktree");
10389        git(
10390            &parent,
10391            &[
10392                "worktree",
10393                "add",
10394                "-b",
10395                "feature",
10396                worktree.to_str().expect("utf8 path"),
10397            ],
10398        );
10399
10400        let core = Core::start_discovered(spec_with_overrides(
10401            vec![root],
10402            vec![RepoOverride {
10403                path: parent.clone(),
10404                default_branch: None,
10405                excluded: true,
10406            }],
10407        ));
10408        let snapshot = core.snapshot();
10409
10410        for entity in &snapshot.entities {
10411            assert!(
10412                entity.excluded,
10413                "both the Repo and its Worktree must inherit the entry declared on the Repo's own path, entity: {:?}",
10414                entity.key
10415            );
10416        }
10417        assert_eq!(
10418            snapshot.entities.len(),
10419            2,
10420            "expected the parent plus its worktree"
10421        );
10422    }
10423
10424    /// The other direction: an entry naming a Worktree's own path beats the entry
10425    /// it would otherwise inherit from the Repo it shares a common dir with, while
10426    /// a second Worktree with no entry of its own still inherits the Repo's entry.
10427    #[test]
10428    fn an_entry_naming_a_worktrees_own_path_beats_the_inherited_one() {
10429        let dir = tempfile::tempdir().expect("temp dir");
10430        let root = root_of(&dir);
10431        let parent = root.join("parent");
10432        init_repo_with_a_commit(&parent);
10433        let worktree_own = root.join("worktree-own");
10434        let worktree_inherits = root.join("worktree-inherits");
10435        git(
10436            &parent,
10437            &[
10438                "worktree",
10439                "add",
10440                "-b",
10441                "feature-own",
10442                worktree_own.to_str().expect("utf8 path"),
10443            ],
10444        );
10445        git(
10446            &parent,
10447            &[
10448                "worktree",
10449                "add",
10450                "-b",
10451                "feature-inherits",
10452                worktree_inherits.to_str().expect("utf8 path"),
10453            ],
10454        );
10455
10456        let core = Core::start_discovered(spec_with_overrides(
10457            vec![root],
10458            vec![
10459                RepoOverride {
10460                    path: parent.clone(),
10461                    default_branch: None,
10462                    excluded: true,
10463                },
10464                RepoOverride {
10465                    path: worktree_own.clone(),
10466                    default_branch: None,
10467                    excluded: false,
10468                },
10469            ],
10470        ));
10471        let snapshot = core.snapshot();
10472
10473        let find = |path: &Path| {
10474            snapshot
10475                .entities
10476                .iter()
10477                .find(|entity| entity.key.path() == path)
10478                .unwrap_or_else(|| panic!("entity at {path:?} present"))
10479        };
10480
10481        assert!(
10482            find(&parent).excluded,
10483            "the parent Repo has no entry of its own and inherits the excluding one"
10484        );
10485        assert!(
10486            !find(&worktree_own).excluded,
10487            "the Worktree named directly by its own path must use its own entry, not the inherited one"
10488        );
10489        assert!(
10490            find(&worktree_inherits).excluded,
10491            "a sibling Worktree with no entry of its own still inherits the Repo's entry"
10492        );
10493    }
10494
10495    /// A Submodule's own common dir differs from its parent's
10496    /// (`<parent common dir>/modules/<name>`), so an entry naming only the
10497    /// parent's path can never also exclude the parent's Submodule: the entry
10498    /// covers the parent and its Worktrees, never a Submodule reached through it.
10499    #[test]
10500    fn an_override_on_the_parents_path_never_excludes_its_submodule() {
10501        let dir = tempfile::tempdir().expect("temp dir");
10502        let root = root_of(&dir);
10503        let parent = root.join("parent");
10504        init_repo_with_a_commit(&parent);
10505        fs::write(
10506            parent.join(".gitmodules"),
10507            "[submodule \"lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n",
10508        )
10509        .expect("write .gitmodules");
10510        fs::create_dir_all(parent.join("vendor").join("lib")).expect("create submodule dir");
10511
10512        let core = Core::start_discovered(spec_with_overrides(
10513            vec![root],
10514            vec![RepoOverride {
10515                path: parent.clone(),
10516                default_branch: None,
10517                excluded: true,
10518            }],
10519        ));
10520        let snapshot = core.snapshot();
10521
10522        let submodule = snapshot
10523            .entities
10524            .iter()
10525            .find(|entity| matches!(entity.kind, Kind::Submodule))
10526            .expect("the submodule is still discovered and listed");
10527        assert!(
10528            !submodule.excluded,
10529            "an entry naming only the parent's path must never reach a Submodule, \
10530             whose own common dir differs from its parent's"
10531        );
10532    }
10533
10534    /// The seam this proves: `Core::default_branch_chain_reads_for_test` counts
10535    /// how many times a `refresh` actually computed the default-branch chain's
10536    /// per-common-dir facts (`default_branch::ChainFacts::resolve`, the loose-file
10537    /// read plus the reference lookups), rather than reusing an already-computed
10538    /// answer for a common dir another entity in the same Generation already paid
10539    /// for. Reading the count off `Core` this way is the seam, not an internal:
10540    /// it is a named, stable test-only entry point in the same
10541    /// `#[cfg(test)] impl Core` family as `cached_repo_handle_for_test`, which
10542    /// already proves a different sharing question the same way. There is no
10543    /// black-box way to observe "how many times an internal read ran" through
10544    /// `Snapshot` alone, since two different common dirs can legitimately answer
10545    /// with the same branch name.
10546    ///
10547    /// Three Worktrees share one common dir with their Repo (four entities); a
10548    /// second, unrelated Repo has its own. Memoised, the count is 2, the number of
10549    /// distinct common dirs; unmemoised, it is 4, the number of entities.
10550    #[test]
10551    fn the_default_branch_chain_is_memoised_once_per_common_dir_per_generation() {
10552        let dir = tempfile::tempdir().expect("temp dir");
10553        let root = root_of(&dir);
10554        let parent = root.join("parent");
10555        init_repo_with_a_commit(&parent);
10556        for name in ["wt-a", "wt-b", "wt-c"] {
10557            let worktree = root.join(name);
10558            git(
10559                &parent,
10560                &[
10561                    "worktree",
10562                    "add",
10563                    "-b",
10564                    name,
10565                    worktree.to_str().expect("utf8 path"),
10566                ],
10567            );
10568        }
10569        let other_repo = root.join("other");
10570        init_repo_with_a_commit(&other_repo);
10571
10572        let (core, launched) = started_and_settled(spec(vec![root]));
10573        let keys: Vec<EntityKey> = launched
10574            .entities
10575            .iter()
10576            .map(|entity| entity.key.clone())
10577            .collect();
10578        assert_eq!(
10579            keys.len(),
10580            5,
10581            "expected the parent, its three worktrees and the unrelated repo"
10582        );
10583
10584        core.refresh(&keys);
10585        core.settle();
10586
10587        assert_eq!(
10588            core.default_branch_chain_reads_for_test(),
10589            2,
10590            "four entities span exactly two common dirs; a memoised chain reads \
10591             each common dir once, not once per entity"
10592        );
10593
10594        // A second Generation pays the same two reads again. A cache hoisted onto
10595        // `Core` would answer this refresh for free and read 0, which is the
10596        // persistence ADR 0006 refuses.
10597        core.refresh(&keys);
10598        core.settle();
10599        assert_eq!(
10600            core.default_branch_chain_reads_for_test(),
10601            2,
10602            "the memo lives inside one Generation's dispatch; the next Generation \
10603             recomputes rather than inheriting it"
10604        );
10605    }
10606
10607    /// The same proof as `the_default_branch_chain_is_memoised_once_per_common_dir_per_generation`,
10608    /// for patch equivalence's own expensive half: two sibling Worktrees, each
10609    /// with a live upstream and unmerged work of its own, share one common dir
10610    /// and must scan its default-branch history once between them, not twice;
10611    /// an unrelated Repo's own Worktree, in its own common dir, pays for a
10612    /// second scan. Both entities settling (`Active`, since neither's work
10613    /// actually landed) is what proves the second pass ran for both rather than
10614    /// one being cancelled or skipped, which would otherwise let a
10615    /// once-per-entity implementation coincidentally also read 2.
10616    #[test]
10617    fn patch_equivalence_is_memoised_once_per_common_dir_per_generation() {
10618        let dir = tempfile::tempdir().expect("temp dir");
10619        let root = root_of(&dir);
10620        let parent = root.join("parent");
10621        init_repo_with_a_commit(&parent);
10622        git(
10623            &parent,
10624            &[
10625                "remote",
10626                "add",
10627                "origin",
10628                "https://example.invalid/repo.git",
10629            ],
10630        );
10631        let base_sha = head_sha(&parent);
10632        git(
10633            &parent,
10634            &["update-ref", "refs/remotes/origin/main", &base_sha],
10635        );
10636        for name in ["feature-x", "feature-y"] {
10637            let worktree = root.join(name);
10638            git(
10639                &parent,
10640                &[
10641                    "worktree",
10642                    "add",
10643                    "-b",
10644                    name,
10645                    worktree.to_str().expect("utf8 path"),
10646                ],
10647            );
10648            fs::write(worktree.join(format!("{name}.txt")), "unmerged\n")
10649                .expect("write worktree file");
10650            git(&worktree, &["add", "."]);
10651            git(&worktree, &["commit", "-m", "unmerged work"]);
10652            let tip_sha = head_sha(&worktree);
10653            git(
10654                &parent,
10655                &["config", &format!("branch.{name}.remote"), "origin"],
10656            );
10657            git(
10658                &parent,
10659                &[
10660                    "config",
10661                    &format!("branch.{name}.merge"),
10662                    &format!("refs/heads/{name}"),
10663                ],
10664            );
10665            git(
10666                &parent,
10667                &[
10668                    "update-ref",
10669                    &format!("refs/remotes/origin/{name}"),
10670                    &tip_sha,
10671                ],
10672            );
10673        }
10674
10675        let other_parent = root.join("other");
10676        init_repo_with_a_commit(&other_parent);
10677        git(
10678            &other_parent,
10679            &[
10680                "remote",
10681                "add",
10682                "origin",
10683                "https://example.invalid/other.git",
10684            ],
10685        );
10686        let other_base_sha = head_sha(&other_parent);
10687        git(
10688            &other_parent,
10689            &["update-ref", "refs/remotes/origin/main", &other_base_sha],
10690        );
10691        let other_worktree = root.join("other-feature");
10692        git(
10693            &other_parent,
10694            &[
10695                "worktree",
10696                "add",
10697                "-b",
10698                "other-feature",
10699                other_worktree.to_str().expect("utf8 path"),
10700            ],
10701        );
10702        fs::write(other_worktree.join("other.txt"), "unmerged\n").expect("write worktree file");
10703        git(&other_worktree, &["add", "."]);
10704        git(&other_worktree, &["commit", "-m", "unmerged work"]);
10705        let other_tip_sha = head_sha(&other_worktree);
10706        git(
10707            &other_parent,
10708            &["config", "branch.other-feature.remote", "origin"],
10709        );
10710        git(
10711            &other_parent,
10712            &[
10713                "config",
10714                "branch.other-feature.merge",
10715                "refs/heads/other-feature",
10716            ],
10717        );
10718        git(
10719            &other_parent,
10720            &[
10721                "update-ref",
10722                "refs/remotes/origin/other-feature",
10723                &other_tip_sha,
10724            ],
10725        );
10726
10727        let (core, launched) = started_and_settled(spec(vec![root]));
10728        let keys: Vec<EntityKey> = launched
10729            .entities
10730            .iter()
10731            .map(|entity| entity.key.clone())
10732            .collect();
10733        assert_eq!(
10734            keys.len(),
10735            5,
10736            "expected two parents plus their three worktrees"
10737        );
10738
10739        core.refresh(&keys);
10740        let settled = core.settle();
10741
10742        let worktree_states: Vec<_> = settled
10743            .entities
10744            .iter()
10745            .filter(|entity| matches!(entity.kind, Kind::Worktree))
10746            .map(|entity| entity.state.settled())
10747            .collect();
10748        assert_eq!(worktree_states.len(), 3, "expected three worktree rows");
10749        for settled_state in &worktree_states {
10750            assert!(
10751                matches!(
10752                    settled_state,
10753                    Some(Settled::Known {
10754                        value: WorktreeState::Active,
10755                        at: _,
10756                        stale: _
10757                    })
10758                ),
10759                "expected every worktree's genuinely unmerged work to settle Active, got {settled_state:?}"
10760            );
10761        }
10762
10763        assert_eq!(
10764            core.patch_identity_reads_for_test(),
10765            2,
10766            "two worktrees share one common dir and must scan its default-branch \
10767             history once between them, not once per entity; the unrelated repo's \
10768             own worktree pays for a second scan"
10769        );
10770
10771        // A second Generation pays for the same two scans again: a cache hoisted
10772        // onto `Core` would answer this refresh for free and read 0.
10773        core.refresh(&keys);
10774        core.settle();
10775        assert_eq!(
10776            core.patch_identity_reads_for_test(),
10777            2,
10778            "the memo lives inside one Generation's dispatch; the next Generation \
10779             recomputes rather than inheriting it"
10780        );
10781    }
10782
10783    /// Criterion 3's widen direction, end to end: `feature-deep` forks at the
10784    /// parent commit `deep_fork_sha` and is squashed into main immediately
10785    /// afterwards; `feature-shallow` forks at that squash commit (strictly more
10786    /// recent, so its own merge base is shallower) and is squashed in turn to
10787    /// produce `main`'s tip. The deepest merge base among the two siblings is
10788    /// `feature-deep`'s own, `deep_fork_sha`, not `feature-shallow`'s.
10789    ///
10790    /// A scan bounded by the *shallowest* sibling's merge base instead of the
10791    /// deepest would stop before reaching the commit that squashed
10792    /// `feature-deep` in, since that commit sits strictly between the two
10793    /// bounds: `feature-deep` would then settle `Active` instead of `Merged`.
10794    /// This is a smoke test for that outcome through the real dispatch
10795    /// pipeline, not a proof: rayon's work stealing gives dispatch `order` no
10796    /// ordering guarantee, so `feature-deep` landing last here is a nudge
10797    /// towards, never proof of, exercising a lazy first-arrival bound.
10798    /// `bound_gate_deepest_folds_every_candidate_regardless_of_report_order`
10799    /// below is what deterministically proves the bound is collected from
10800    /// every sibling rather than computed lazily from whichever arrives first.
10801    #[test]
10802    fn an_entity_whose_merge_base_is_deeper_than_its_siblings_widens_the_shared_scan() {
10803        let dir = tempfile::tempdir().expect("temp dir");
10804        let root = root_of(&dir);
10805        let parent = root.join("parent");
10806        init_repo_with_a_commit(&parent);
10807        git(
10808            &parent,
10809            &[
10810                "remote",
10811                "add",
10812                "origin",
10813                "https://example.invalid/repo.git",
10814            ],
10815        );
10816        let deep_fork_sha = head_sha(&parent);
10817
10818        git(&parent, &["branch", "feature-deep"]);
10819        let deep_worktree = root.join("feature-deep");
10820        git(
10821            &parent,
10822            &[
10823                "worktree",
10824                "add",
10825                deep_worktree.to_str().expect("utf8 path"),
10826                "feature-deep",
10827            ],
10828        );
10829        fs::write(deep_worktree.join("deep.txt"), "deep work\n").expect("write deep.txt");
10830        git(&deep_worktree, &["add", "."]);
10831        git(&deep_worktree, &["commit", "-m", "deep work"]);
10832        let deep_tip_sha = head_sha(&deep_worktree);
10833
10834        git(&parent, &["merge", "--squash", "feature-deep"]);
10835        git(&parent, &["commit", "-m", "squashed deep"]);
10836        let shallow_fork_sha = head_sha(&parent);
10837
10838        git(&parent, &["branch", "feature-shallow"]);
10839        let shallow_worktree = root.join("feature-shallow");
10840        git(
10841            &parent,
10842            &[
10843                "worktree",
10844                "add",
10845                shallow_worktree.to_str().expect("utf8 path"),
10846                "feature-shallow",
10847            ],
10848        );
10849        fs::write(shallow_worktree.join("shallow.txt"), "shallow work\n")
10850            .expect("write shallow.txt");
10851        git(&shallow_worktree, &["add", "."]);
10852        git(&shallow_worktree, &["commit", "-m", "shallow work"]);
10853        let shallow_tip_sha = head_sha(&shallow_worktree);
10854
10855        git(&parent, &["merge", "--squash", "feature-shallow"]);
10856        git(&parent, &["commit", "-m", "squashed shallow"]);
10857        let main_tip_sha = head_sha(&parent);
10858        assert_ne!(
10859            deep_fork_sha, shallow_fork_sha,
10860            "the two siblings must fork at genuinely different commits"
10861        );
10862
10863        git(
10864            &parent,
10865            &["update-ref", "refs/remotes/origin/main", &main_tip_sha],
10866        );
10867        for (name, tip_sha) in [
10868            ("feature-deep", &deep_tip_sha),
10869            ("feature-shallow", &shallow_tip_sha),
10870        ] {
10871            git(
10872                &parent,
10873                &["config", &format!("branch.{name}.remote"), "origin"],
10874            );
10875            git(
10876                &parent,
10877                &[
10878                    "config",
10879                    &format!("branch.{name}.merge"),
10880                    &format!("refs/heads/{name}"),
10881                ],
10882            );
10883            git(
10884                &parent,
10885                &[
10886                    "update-ref",
10887                    &format!("refs/remotes/origin/{name}"),
10888                    tip_sha,
10889                ],
10890            );
10891        }
10892
10893        let (core, snapshot) = started_and_settled(spec(vec![root]));
10894        let deep_key = snapshot
10895            .entities
10896            .iter()
10897            .find(|entity| entity.key.path() == deep_worktree)
10898            .expect("feature-deep worktree discovered")
10899            .key
10900            .clone();
10901        let shallow_key = snapshot
10902            .entities
10903            .iter()
10904            .find(|entity| entity.key.path() == shallow_worktree)
10905            .expect("feature-shallow worktree discovered")
10906            .key
10907            .clone();
10908        let parent_key = snapshot
10909            .entities
10910            .iter()
10911            .find(|entity| entity.key.path() == parent)
10912            .expect("parent repo discovered")
10913            .key
10914            .clone();
10915        // The deepest sibling dispatched last, so a lazy bound computed from
10916        // whichever entity arrives first would reach for the shallow sibling's
10917        // own narrower merge base instead.
10918        let order = vec![parent_key, shallow_key.clone(), deep_key.clone()];
10919
10920        core.refresh(&order);
10921        let settled = core.settle();
10922
10923        let state_of = |key: &EntityKey| {
10924            settled
10925                .entities
10926                .iter()
10927                .find(|entity| &entity.key == key)
10928                .and_then(|entity| entity.state.settled())
10929                .cloned()
10930        };
10931        assert!(
10932            matches!(
10933                state_of(&deep_key),
10934                Some(Settled::Known {
10935                    value: WorktreeState::Merged,
10936                    at: _,
10937                    stale: _
10938                })
10939            ),
10940            "expected the deepest sibling's own squash commit to be found once the scan is \
10941             bounded by the deepest merge base, got {:?}",
10942            state_of(&deep_key)
10943        );
10944        assert!(
10945            matches!(
10946                state_of(&shallow_key),
10947                Some(Settled::Known {
10948                    value: WorktreeState::Merged,
10949                    at: _,
10950                    stale: _
10951                })
10952            ),
10953            "expected the shallow sibling to settle Merged too, got {:?}",
10954            state_of(&shallow_key)
10955        );
10956        assert_eq!(
10957            core.patch_identity_reads_for_test(),
10958            1,
10959            "both worktrees share one common dir and must still scan its default-branch \
10960             history once between them, not once per entity"
10961        );
10962        assert_eq!(
10963            core.patch_scan_bounds_for_test(),
10964            vec![Some(id(&deep_fork_sha))],
10965            "the one shared scan that ran must have been bounded by the deepest sibling's own \
10966             merge base, not the shallower one's"
10967        );
10968    }
10969
10970    fn id(sha: &str) -> gix::ObjectId {
10971        gix::ObjectId::from_hex(sha.as_bytes()).expect("parse sha")
10972    }
10973
10974    /// Criterion 1, proved at the barrier itself rather than through rayon's
10975    /// unordered dispatch: `shallow` is reported before `deep` on purpose, so a
10976    /// lazy first-arrival implementation (answer with whichever candidate
10977    /// showed up first, rather than collecting every sibling's own merge base)
10978    /// would settle on `shallow` and fail this assertion. `deep` is an ancestor
10979    /// of `shallow`, so the correct fold finds it regardless of report order.
10980    #[test]
10981    fn bound_gate_deepest_folds_every_candidate_regardless_of_report_order() {
10982        let dir = tempfile::tempdir().expect("temp dir");
10983        let repo_path = root_of(&dir).join("repo");
10984        init_repo_with_a_commit(&repo_path);
10985        let deep_sha = id(&head_sha(&repo_path));
10986        fs::write(repo_path.join("child.txt"), "child\n").expect("write child.txt");
10987        git(&repo_path, &["add", "."]);
10988        git(&repo_path, &["commit", "-m", "child of deep"]);
10989        let shallow_sha = id(&head_sha(&repo_path));
10990
10991        let repo = gix::open(&repo_path).expect("open repo");
10992        let gate = BoundGate::new(2);
10993        gate.report(Some(shallow_sha));
10994        gate.report(Some(deep_sha));
10995
10996        assert_eq!(
10997            gate.deepest(&repo),
10998            Some(deep_sha),
10999            "the deepest candidate must win even though the shallower one reported first"
11000        );
11001    }
11002
11003    /// Deterministic proof that [`probe_patch_equivalence`] itself consults
11004    /// [`BoundGate::deepest`] for the bound it hands to
11005    /// [`patch_equivalence::scan_default_branch`], rather than reaching for its
11006    /// own entity's merge base. Unlike
11007    /// `bound_gate_deepest_folds_every_candidate_regardless_of_report_order`,
11008    /// which proves `BoundGate` and `deepest_merge_base` correct in isolation,
11009    /// this drives `probe_patch_equivalence` itself and inspects what it
11010    /// actually recorded into `memo.scan_bounds`. `deep_sha`'s contribution is
11011    /// pre-reported by hand, standing in for a sibling entity that already ran
11012    /// this Generation; the one entity this test drives through the real
11013    /// function arrives at `shallow_sha`, so its own merge base against
11014    /// `default_tip` is `shallow_sha`, strictly shallower than `deep_sha`. A
11015    /// regression that bounds the scan by the arriving entity's own merge base
11016    /// instead of the gate's answer would record `shallow_sha` here, and would
11017    /// do so every single run: unlike the integration smoke test below, there
11018    /// is no rayon dispatch order here to sometimes get it right by accident.
11019    #[test]
11020    fn probe_patch_equivalence_bounds_the_scan_by_the_gates_deepest_not_its_own_merge_base() {
11021        let dir = tempfile::tempdir().expect("temp dir");
11022        let repo_path = root_of(&dir).join("repo");
11023        init_repo_with_a_commit(&repo_path);
11024        let deep_sha = id(&head_sha(&repo_path));
11025        fs::write(repo_path.join("child.txt"), "child\n").expect("write child.txt");
11026        git(&repo_path, &["add", "."]);
11027        git(&repo_path, &["commit", "-m", "child of deep"]);
11028        let shallow_sha_hex = head_sha(&repo_path);
11029        let shallow_sha = id(&shallow_sha_hex);
11030        fs::write(repo_path.join("tip.txt"), "tip\n").expect("write tip.txt");
11031        git(&repo_path, &["add", "."]);
11032        git(&repo_path, &["commit", "-m", "default tip"]);
11033        let default_tip_hex = head_sha(&repo_path);
11034
11035        let repo = gix::open(&repo_path).expect("open repo");
11036        // What `landing::probe` hands over for a Worktree entity sitting at
11037        // `shallow`, whose own tip is not main's actual tip.
11038        let outstanding = landing::Outstanding {
11039            entity_tip: shallow_sha,
11040            default_tip: id(&default_tip_hex),
11041            merge_base: Some(shallow_sha),
11042        };
11043        let common_dir: Arc<Path> = Arc::from(repo_path.join(".git"));
11044        let cancel = AtomicBool::new(false);
11045        let patch_cache: PatchIdentityCache = Mutex::new(HashMap::new());
11046        let patch_reads = AtomicUsize::new(0);
11047        let patch_scan_bounds: Mutex<Vec<Option<gix::ObjectId>>> = Mutex::new(Vec::new());
11048        let memo = PatchEquivalenceMemo {
11049            cache: &patch_cache,
11050            reads: &patch_reads,
11051            scan_bounds: &patch_scan_bounds,
11052        };
11053        // Two entities share this common dir this Generation: `deep_sha` stands
11054        // in for a sibling that already reported its own, deeper merge base;
11055        // `shallow` is the one entity driven through the real function below.
11056        let gate = BoundGate::new(2);
11057        gate.report(Some(deep_sha));
11058        let mut report = GateReport::new(&gate);
11059
11060        probe_patch_equivalence(
11061            &repo,
11062            &outstanding,
11063            &common_dir,
11064            &cancel,
11065            &memo,
11066            &mut report,
11067        );
11068
11069        assert_eq!(
11070            patch_scan_bounds.lock().unwrap().as_slice(),
11071            [Some(deep_sha)],
11072            "the scan must be bounded by the deepest sibling's merge base, not shallow's own \
11073             ({shallow_sha:?})"
11074        );
11075    }
11076
11077    /// The carry itself: [`probe_patch_equivalence`] diffs the entity's own
11078    /// range from the merge base `landing::probe` handed over, rather than
11079    /// walking the same commit pair a second time. `mid_sha` is a real commit
11080    /// on `feature` but not its fork point, so the two answers differ: from the
11081    /// fork point the range is the whole squashed change and settles `Merged`,
11082    /// from `mid_sha` it is only `b.txt` and settles `Active`. A regression that
11083    /// recomputed the base here would answer `Merged` and fail this test.
11084    #[test]
11085    fn probe_patch_equivalence_diffs_from_the_merge_base_it_was_handed() {
11086        let dir = tempfile::tempdir().expect("temp dir");
11087        let repo_path = root_of(&dir).join("repo");
11088        init_repo_with_a_commit(&repo_path);
11089        let fork_point_hex = head_sha(&repo_path);
11090        git(&repo_path, &["checkout", "-b", "feature"]);
11091        fs::write(repo_path.join("a.txt"), "one\n").expect("write a.txt");
11092        git(&repo_path, &["add", "a.txt"]);
11093        git(&repo_path, &["commit", "-m", "add a"]);
11094        let mid_sha = id(&head_sha(&repo_path));
11095        fs::write(repo_path.join("b.txt"), "two\n").expect("write b.txt");
11096        git(&repo_path, &["add", "b.txt"]);
11097        git(&repo_path, &["commit", "-m", "add b"]);
11098        let feature_sha = id(&head_sha(&repo_path));
11099        git(&repo_path, &["checkout", "-B", "main", &fork_point_hex]);
11100        git(&repo_path, &["merge", "--squash", "feature"]);
11101        git(&repo_path, &["commit", "-m", "squashed feature"]);
11102        let main_sha = id(&head_sha(&repo_path));
11103
11104        let repo = gix::open(&repo_path).expect("open repo");
11105        // What `landing::probe` hands over, with a base halfway along the
11106        // branch standing in for one only this pass could know.
11107        let outstanding = landing::Outstanding {
11108            entity_tip: feature_sha,
11109            default_tip: main_sha,
11110            merge_base: Some(mid_sha),
11111        };
11112        let common_dir: Arc<Path> = Arc::from(repo_path.join(".git"));
11113        let cancel = AtomicBool::new(false);
11114        let patch_cache: PatchIdentityCache = Mutex::new(HashMap::new());
11115        let patch_reads = AtomicUsize::new(0);
11116        let patch_scan_bounds: Mutex<Vec<Option<gix::ObjectId>>> = Mutex::new(Vec::new());
11117        let memo = PatchEquivalenceMemo {
11118            cache: &patch_cache,
11119            reads: &patch_reads,
11120            scan_bounds: &patch_scan_bounds,
11121        };
11122        let gate = BoundGate::new(1);
11123        let mut report = GateReport::new(&gate);
11124
11125        let settled = probe_patch_equivalence(
11126            &repo,
11127            &outstanding,
11128            &common_dir,
11129            &cancel,
11130            &memo,
11131            &mut report,
11132        );
11133
11134        assert!(
11135            matches!(
11136                settled,
11137                Some(Settled::Known {
11138                    value: WorktreeState::Active,
11139                    at: _,
11140                    stale: _
11141                })
11142            ),
11143            "the range must be measured from the handed-in base ({mid_sha:?}), whose only \
11144             change the squash commit does not match, got {settled:?}"
11145        );
11146    }
11147
11148    /// The edge [`deepest_merge_base`] exists for: no entity sharing a common
11149    /// dir ever had a merge base to offer (every one settled by ancestry, was
11150    /// cancelled, or shared no history with the default branch at all), so the
11151    /// scan is left unbounded. `deepest_merge_base` returns before its first
11152    /// candidate lookup here, which is what lets this fixture skip building any
11153    /// commit history at all.
11154    #[test]
11155    fn bound_gate_deepest_with_no_candidates_leaves_the_scan_unbounded() {
11156        let dir = tempfile::tempdir().expect("temp dir");
11157        let repo_path = root_of(&dir).join("repo");
11158        gix::init(&repo_path).expect("init repo");
11159        let repo = gix::open(&repo_path).expect("open repo");
11160
11161        let gate = BoundGate::new(2);
11162        gate.report(None);
11163        gate.report(None);
11164
11165        assert_eq!(
11166            gate.deepest(&repo),
11167            None,
11168            "no contributed candidate must leave the scan unbounded"
11169        );
11170    }
11171
11172    /// `probe_patch_equivalence`'s `Ok(None)` arm bypasses the shared scan for
11173    /// an Outstanding entity with no shared history at all. `unrelated` is a
11174    /// real branch, with a live upstream so `landing::probe`
11175    /// leaves it `Outstanding`, whose own root commit shares no history with
11176    /// `main`'s, driven through `Core` end to end rather than by calling
11177    /// `probe_patch_equivalence` or `patch_equivalence::probe` directly, so a
11178    /// removed bypass (the shared scan run unconditionally instead) is
11179    /// exercised for real: `BoundGate::deepest` would then block forever on a
11180    /// scan this entity never asked for.
11181    #[test]
11182    fn an_outstanding_entity_with_no_shared_history_settles_active_without_the_shared_scan() {
11183        let dir = tempfile::tempdir().expect("temp dir");
11184        let root = root_of(&dir);
11185        let parent = root.join("parent");
11186        init_repo_with_a_commit(&parent);
11187        git(&parent, &["branch", "-M", "main"]);
11188        git(
11189            &parent,
11190            &[
11191                "remote",
11192                "add",
11193                "origin",
11194                "https://example.invalid/repo.git",
11195            ],
11196        );
11197        let main_sha = head_sha(&parent);
11198        git(
11199            &parent,
11200            &["update-ref", "refs/remotes/origin/main", &main_sha],
11201        );
11202
11203        git(&parent, &["checkout", "--orphan", "unrelated"]);
11204        git(
11205            &parent,
11206            &["commit", "--allow-empty", "-m", "unrelated root"],
11207        );
11208        let unrelated_sha = head_sha(&parent);
11209        git(&parent, &["checkout", "main"]);
11210
11211        let worktree = root.join("unrelated");
11212        git(
11213            &parent,
11214            &[
11215                "worktree",
11216                "add",
11217                worktree.to_str().expect("utf8 path"),
11218                "unrelated",
11219            ],
11220        );
11221        git(&parent, &["config", "branch.unrelated.remote", "origin"]);
11222        git(
11223            &parent,
11224            &["config", "branch.unrelated.merge", "refs/heads/unrelated"],
11225        );
11226        git(
11227            &parent,
11228            &[
11229                "update-ref",
11230                "refs/remotes/origin/unrelated",
11231                &unrelated_sha,
11232            ],
11233        );
11234
11235        let (core, snapshot) = started_and_settled(spec(vec![root]));
11236        let worktree_key = snapshot
11237            .entities
11238            .iter()
11239            .find(|entity| entity.key.path() == worktree)
11240            .expect("unrelated worktree discovered")
11241            .key
11242            .clone();
11243
11244        core.refresh(std::slice::from_ref(&worktree_key));
11245        let settled = core.settle();
11246
11247        let state = settled
11248            .entities
11249            .iter()
11250            .find(|entity| entity.key == worktree_key)
11251            .and_then(|entity| entity.state.settled())
11252            .cloned();
11253        assert!(
11254            matches!(
11255                state,
11256                Some(Settled::Known {
11257                    value: WorktreeState::Active,
11258                    at: _,
11259                    stale: _
11260                })
11261            ),
11262            "expected an Outstanding entity with no shared history to settle Active via the \
11263             bypass, got {state:?}"
11264        );
11265        assert_eq!(
11266            core.patch_identity_reads_for_test(),
11267            0,
11268            "the bypass must settle without ever running the shared scan"
11269        );
11270    }
11271
11272    // --- Phase B's comparison: the `sync` cell, end to end through a real `Core`:
11273    // the six named cases, plus the two ways "every entity, every Generation" is
11274    // most easily lost. ---
11275
11276    fn add_origin_remote(path: &Path) {
11277        git(
11278            path,
11279            &[
11280                "remote",
11281                "add",
11282                "origin",
11283                "https://example.invalid/repo.git",
11284            ],
11285        );
11286    }
11287
11288    /// Wires `branch` up to track `refs/remotes/origin/<branch>` at `upstream_sha`,
11289    /// mirroring `patch_equivalence_is_memoised_once_per_common_dir_per_generation`'s
11290    /// own fixture shape against a real disposable repo.
11291    fn set_upstream(path: &Path, branch: &str, upstream_sha: &str) {
11292        git(
11293            path,
11294            &["config", &format!("branch.{branch}.remote"), "origin"],
11295        );
11296        git(
11297            path,
11298            &[
11299                "config",
11300                &format!("branch.{branch}.merge"),
11301                &format!("refs/heads/{branch}"),
11302            ],
11303        );
11304        git(
11305            path,
11306            &[
11307                "update-ref",
11308                &format!("refs/remotes/origin/{branch}"),
11309                upstream_sha,
11310            ],
11311        );
11312    }
11313
11314    fn refresh_and_settle(core: &Core) -> crate::snapshot::Snapshot {
11315        let keys: Vec<EntityKey> = core
11316            .snapshot()
11317            .entities
11318            .iter()
11319            .map(|entity| entity.key.clone())
11320            .collect();
11321        core.refresh(&keys);
11322        core.settle()
11323    }
11324
11325    fn sync_of<'a>(
11326        snapshot: &'a crate::snapshot::Snapshot,
11327        path: &Path,
11328    ) -> Option<&'a Settled<SyncState>> {
11329        snapshot
11330            .entities
11331            .iter()
11332            .find(|entity| entity.key.path() == path)
11333            .unwrap_or_else(|| panic!("no entity for {}", path.display()))
11334            .sync
11335            .settled()
11336    }
11337
11338    /// Named case 1 of 6: an attached branch ahead of its upstream.
11339    #[test]
11340    fn an_attached_branch_ahead_of_its_upstream_reads_the_ahead_count() {
11341        let dir = tempfile::tempdir().expect("temp dir");
11342        let root = root_of(&dir);
11343        let repo = root.join("repo");
11344        init_repo_with_a_commit(&repo);
11345        let fork_sha = head_sha(&repo);
11346        add_origin_remote(&repo);
11347        set_upstream(&repo, "main", &fork_sha);
11348        git(&repo, &["commit", "--allow-empty", "-m", "local work"]);
11349
11350        let core = Core::start_discovered(spec(vec![root]));
11351        let settled = refresh_and_settle(&core);
11352
11353        match sync_of(&settled, &repo) {
11354            Some(Settled::Known {
11355                value: SyncState::Tracking(AheadBehind { ahead, behind }),
11356                at: _,
11357                stale: _,
11358            }) => {
11359                assert_eq!(*ahead, 1);
11360                assert_eq!(*behind, 0);
11361            }
11362            other => panic!("expected 1 ahead, 0 behind, got {other:?}"),
11363        }
11364    }
11365
11366    /// Named case 2 of 6: an attached branch behind its upstream.
11367    #[test]
11368    fn an_attached_branch_behind_its_upstream_reads_the_behind_count() {
11369        let dir = tempfile::tempdir().expect("temp dir");
11370        let root = root_of(&dir);
11371        let repo = root.join("repo");
11372        init_repo_with_a_commit(&repo);
11373        git(&repo, &["checkout", "-b", "temp"]);
11374        git(&repo, &["commit", "--allow-empty", "-m", "upstream work"]);
11375        let upstream_sha = head_sha(&repo);
11376        git(&repo, &["checkout", "main"]);
11377        git(&repo, &["branch", "-D", "temp"]);
11378        add_origin_remote(&repo);
11379        set_upstream(&repo, "main", &upstream_sha);
11380
11381        let core = Core::start_discovered(spec(vec![root]));
11382        let settled = refresh_and_settle(&core);
11383
11384        match sync_of(&settled, &repo) {
11385            Some(Settled::Known {
11386                value: SyncState::Tracking(AheadBehind { ahead, behind }),
11387                at: _,
11388                stale: _,
11389            }) => {
11390                assert_eq!(*ahead, 0);
11391                assert_eq!(*behind, 1);
11392            }
11393            other => panic!("expected 0 ahead, 1 behind, got {other:?}"),
11394        }
11395    }
11396
11397    /// Named case 3 of 6: an attached branch level with its upstream.
11398    #[test]
11399    fn an_attached_branch_level_with_its_upstream_reads_in_sync() {
11400        let dir = tempfile::tempdir().expect("temp dir");
11401        let root = root_of(&dir);
11402        let repo = root.join("repo");
11403        init_repo_with_a_commit(&repo);
11404        let sha = head_sha(&repo);
11405        add_origin_remote(&repo);
11406        set_upstream(&repo, "main", &sha);
11407
11408        let core = Core::start_discovered(spec(vec![root]));
11409        let settled = refresh_and_settle(&core);
11410
11411        match sync_of(&settled, &repo) {
11412            Some(Settled::Known {
11413                value:
11414                    SyncState::Tracking(AheadBehind {
11415                        ahead: 0,
11416                        behind: 0,
11417                    }),
11418                at: _,
11419                stale: _,
11420            }) => {}
11421            other => panic!("expected level with its upstream, got {other:?}"),
11422        }
11423    }
11424
11425    /// Named case 4 of 6: an attached branch tracking nothing, on a Repo that does
11426    /// have a remote. Distinguishes this from case 6 below: the absence here is the
11427    /// branch's own tracking configuration, not the Repo's remote.
11428    #[test]
11429    fn an_attached_branch_tracking_nothing_reads_no_upstream() {
11430        let dir = tempfile::tempdir().expect("temp dir");
11431        let root = root_of(&dir);
11432        let repo = root.join("repo");
11433        init_repo_with_a_commit(&repo);
11434        add_origin_remote(&repo);
11435
11436        let core = Core::start_discovered(spec(vec![root]));
11437        let settled = refresh_and_settle(&core);
11438
11439        match sync_of(&settled, &repo) {
11440            Some(Settled::Known {
11441                value: SyncState::NoUpstream,
11442                at: _,
11443                stale: _,
11444            }) => {}
11445            other => panic!("expected no upstream configured, got {other:?}"),
11446        }
11447    }
11448
11449    /// Named case 5 of 6: a detached row, on a Repo that does have a remote.
11450    /// Distinguishes this from case 6 below the same way case 4 does.
11451    #[test]
11452    fn a_detached_row_reads_no_upstream() {
11453        let dir = tempfile::tempdir().expect("temp dir");
11454        let root = root_of(&dir);
11455        let repo = root.join("repo");
11456        init_repo_with_a_commit(&repo);
11457        let first_sha = head_sha(&repo);
11458        git(&repo, &["commit", "--allow-empty", "-m", "second"]);
11459        git(&repo, &["checkout", "--detach", &first_sha]);
11460        add_origin_remote(&repo);
11461
11462        let core = Core::start_discovered(spec(vec![root]));
11463        let settled = refresh_and_settle(&core);
11464
11465        match sync_of(&settled, &repo) {
11466            Some(Settled::Known {
11467                value: SyncState::NoUpstream,
11468                at: _,
11469                stale: _,
11470            }) => {}
11471            other => panic!("expected a detached row to read no upstream, got {other:?}"),
11472        }
11473    }
11474
11475    /// Named case 6 of 6: a Repo with no remote at all. The propagation half of
11476    /// criterion 3 is the substance here, not the Repo row alone: a linked Worktree
11477    /// shares the parent's config and has no upstream of its own to speak of either,
11478    /// so it must read the exact same `NoRemote` value, not `NoUpstream`.
11479    #[test]
11480    fn a_repo_with_no_remote_reads_no_remote_on_itself_and_every_worktree() {
11481        let dir = tempfile::tempdir().expect("temp dir");
11482        let root = root_of(&dir);
11483        let parent = root.join("parent");
11484        init_repo_with_a_commit(&parent);
11485        let worktree = root.join("feature");
11486        git(
11487            &parent,
11488            &[
11489                "worktree",
11490                "add",
11491                "-b",
11492                "feature",
11493                worktree.to_str().expect("utf8 path"),
11494            ],
11495        );
11496
11497        let core = Core::start_discovered(spec(vec![root]));
11498        let settled = refresh_and_settle(&core);
11499
11500        assert_eq!(
11501            settled.entities.len(),
11502            2,
11503            "expected the parent Repo and its one linked Worktree"
11504        );
11505        for path in [&parent, &worktree] {
11506            match sync_of(&settled, path) {
11507                Some(Settled::Known {
11508                    value: SyncState::NoRemote,
11509                    at: _,
11510                    stale: _,
11511                }) => {}
11512                other => panic!(
11513                    "expected {} to read no remote at all, got {other:?}",
11514                    path.display()
11515                ),
11516            }
11517        }
11518    }
11519
11520    /// Criterion 1's "every entity" half: two sibling Worktrees under one Repo, each
11521    /// with a different sync outcome, computed together in one Generation. A test
11522    /// driving only one of them could not see an implementation that dispatches the
11523    /// comparison for a single hand-picked entity rather than every one whose HEAD
11524    /// carries a branch.
11525    #[test]
11526    fn sync_is_computed_for_every_entity_dispatched_this_generation_not_only_one() {
11527        let dir = tempfile::tempdir().expect("temp dir");
11528        let root = root_of(&dir);
11529        let parent = root.join("parent");
11530        init_repo_with_a_commit(&parent);
11531        let fork_sha = head_sha(&parent);
11532        add_origin_remote(&parent);
11533
11534        let ahead_worktree = root.join("feature-ahead");
11535        git(
11536            &parent,
11537            &[
11538                "worktree",
11539                "add",
11540                "-b",
11541                "feature-ahead",
11542                ahead_worktree.to_str().expect("utf8 path"),
11543            ],
11544        );
11545        set_upstream(&parent, "feature-ahead", &fork_sha);
11546        git(
11547            &ahead_worktree,
11548            &["commit", "--allow-empty", "-m", "unpushed"],
11549        );
11550
11551        let behind_worktree = root.join("feature-behind");
11552        git(
11553            &parent,
11554            &[
11555                "worktree",
11556                "add",
11557                "-b",
11558                "feature-behind",
11559                behind_worktree.to_str().expect("utf8 path"),
11560            ],
11561        );
11562        git(
11563            &behind_worktree,
11564            &["commit", "--allow-empty", "-m", "on the remote only"],
11565        );
11566        let ahead_of_behind_sha = head_sha(&behind_worktree);
11567        git(&behind_worktree, &["reset", "--hard", "HEAD~1"]);
11568        set_upstream(&parent, "feature-behind", &ahead_of_behind_sha);
11569
11570        let core = Core::start_discovered(spec(vec![root]));
11571        let settled = refresh_and_settle(&core);
11572
11573        match sync_of(&settled, &ahead_worktree) {
11574            Some(Settled::Known {
11575                value:
11576                    SyncState::Tracking(AheadBehind {
11577                        ahead: 1,
11578                        behind: 0,
11579                    }),
11580                at: _,
11581                stale: _,
11582            }) => {}
11583            other => panic!("expected feature-ahead to read 1 ahead, got {other:?}"),
11584        }
11585        match sync_of(&settled, &behind_worktree) {
11586            Some(Settled::Known {
11587                value:
11588                    SyncState::Tracking(AheadBehind {
11589                        ahead: 0,
11590                        behind: 1,
11591                    }),
11592                at: _,
11593                stale: _,
11594            }) => {}
11595            other => panic!("expected feature-behind to read 1 behind, got {other:?}"),
11596        }
11597    }
11598
11599    /// Criterion 1's "every Generation" half: a second, later refresh recomputes
11600    /// `sync` rather than a first Generation's answer sticking around unrefreshed.
11601    /// A test that only ever drives one Generation cannot see an implementation
11602    /// that dispatches the comparison once, at `Core::start`'s own discovery, and
11603    /// never again on an explicit `refresh`.
11604    #[test]
11605    fn sync_recomputes_on_a_second_generation_not_only_the_first() {
11606        let dir = tempfile::tempdir().expect("temp dir");
11607        let root = root_of(&dir);
11608        let repo = root.join("repo");
11609        init_repo_with_a_commit(&repo);
11610        let fork_sha = head_sha(&repo);
11611        add_origin_remote(&repo);
11612        set_upstream(&repo, "main", &fork_sha);
11613
11614        let core = Core::start_discovered(spec(vec![root]));
11615        let first = refresh_and_settle(&core);
11616        match sync_of(&first, &repo) {
11617            Some(Settled::Known {
11618                value:
11619                    SyncState::Tracking(AheadBehind {
11620                        ahead: 0,
11621                        behind: 0,
11622                    }),
11623                at: _,
11624                stale: _,
11625            }) => {}
11626            other => panic!("expected the first Generation level with its upstream, got {other:?}"),
11627        }
11628
11629        git(
11630            &repo,
11631            &[
11632                "commit",
11633                "--allow-empty",
11634                "-m",
11635                "second Generation's own work",
11636            ],
11637        );
11638        let second = refresh_and_settle(&core);
11639        match sync_of(&second, &repo) {
11640            Some(Settled::Known {
11641                value:
11642                    SyncState::Tracking(AheadBehind {
11643                        ahead: 1,
11644                        behind: 0,
11645                    }),
11646                at: _,
11647                stale: _,
11648            }) => {}
11649            other => panic!(
11650                "expected the second Generation to recompute and read 1 ahead, got {other:?}"
11651            ),
11652        }
11653    }
11654
11655    /// The Worktree-reporting criterion: after a default branch moves, the Worktrees
11656    /// now behind it are reported by name. `base` (the same "behind the default branch"
11657    /// count [`base.rs`] computes and every row's own `name` already carries) is what
11658    /// "reported by name" means in practice: a snapshot reader finds each Worktree by
11659    /// the name on its row, not by position, so this test does the same, matching each
11660    /// assertion to its own fixture's name rather than to "the first" or "the last"
11661    /// entity.
11662    ///
11663    /// `wt-behind` is branched from the default branch's tip before it moves and is left
11664    /// untouched, the same shape a fetch leaves an existing linked Worktree in; `wt-
11665    /// caught-up` is branched from the tip *after* it moves, so it is unaffected. Two
11666    /// Worktrees are required, not one: a test with only `wt-behind` would still pass
11667    /// against an implementation that reports every Worktree as behind regardless of
11668    /// whether it actually is, and a test that asserted only "something is reported"
11669    /// would pass even if the names or the counts were swapped.
11670    #[test]
11671    fn worktrees_now_behind_a_moved_default_branch_are_reported_by_name() {
11672        let dir = tempfile::tempdir().expect("temp dir");
11673        let root = root_of(&dir);
11674        let repo = root.join("repo");
11675        init_repo_with_a_commit(&repo);
11676        let sha_a = head_sha(&repo);
11677        add_origin_remote(&repo);
11678        set_upstream(&repo, "main", &sha_a);
11679
11680        let behind_path = root.join("wt-behind");
11681        git(
11682            &repo,
11683            &[
11684                "worktree",
11685                "add",
11686                "-b",
11687                "topic-behind",
11688                behind_path.to_str().expect("utf8 path"),
11689                "main",
11690            ],
11691        );
11692
11693        // Moves only the default branch's own remote-tracking ref, the same shape a
11694        // fetch leaves behind: `repo`'s own checked-out `main` does not move, so this
11695        // is deliberately not exercising the auto-update itself, only what a moved
11696        // default branch does to every Worktree's own `base` count.
11697        git(&repo, &["checkout", "-b", "scratch"]);
11698        git(&repo, &["commit", "--allow-empty", "-m", "second"]);
11699        let sha_b = head_sha(&repo);
11700        git(&repo, &["checkout", "main"]);
11701        git(&repo, &["update-ref", "refs/remotes/origin/main", &sha_b]);
11702        git(&repo, &["branch", "-D", "scratch"]);
11703
11704        // Branched from the plain commit sha, not `refs/remotes/origin/main` itself:
11705        // starting a new branch from a remote-tracking ref makes git auto-configure it
11706        // to track that same ref, which would make this row the default branch's own
11707        // row (`base.rs`'s `branch_is_default_branchs_own_row`) and settle `base` as
11708        // `NotApplicable` rather than the `0` this fixture means to prove.
11709        let caught_up_path = root.join("wt-caught-up");
11710        git(
11711            &repo,
11712            &[
11713                "worktree",
11714                "add",
11715                "-b",
11716                "topic-caught-up",
11717                caught_up_path.to_str().expect("utf8 path"),
11718                &sha_b,
11719            ],
11720        );
11721
11722        let core = Core::start_discovered(spec(vec![root]));
11723        let snapshot = refresh_and_settle(&core);
11724
11725        let base_of = |name: &str| -> u32 {
11726            let entity = snapshot
11727                .entities
11728                .iter()
11729                .find(|entity| &*entity.name == name)
11730                .unwrap_or_else(|| panic!("no entity named {name} in {snapshot:?}"));
11731            match entity.base.settled() {
11732                Some(Settled::Known {
11733                    value,
11734                    at: _,
11735                    stale: _,
11736                }) => *value,
11737                other => panic!("expected a known base count for {name}, got {other:?}"),
11738            }
11739        };
11740
11741        assert!(
11742            base_of("wt-behind") > 0,
11743            "a Worktree branched before the default branch moved must be reported behind"
11744        );
11745        assert_eq!(
11746            base_of("wt-caught-up"),
11747            0,
11748            "a Worktree branched from the new tip must not be reported behind"
11749        );
11750    }
11751
11752    /// The periodic fetch's own scheduler: criterion 3's five rules
11753    /// ([refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
11754    /// "The periodic fetch"). Every fixture here is a bare repo this test creates plus a
11755    /// real `git clone` of it, per the standing constraint that a fetch test never
11756    /// touches a real remote or the network.
11757    mod fetch_scheduler {
11758        use super::*;
11759        use crate::liveness::wait_for_or;
11760
11761        fn fetch_spec(enabled: bool, root: PathBuf) -> CoreSpec {
11762            let mut spec = spec(vec![root]);
11763            spec.fetch = FetchSpec {
11764                enabled,
11765                interval: Duration::from_secs(3600),
11766                concurrency: 4,
11767            };
11768            spec
11769        }
11770
11771        /// A bare "remote" this call creates and seeds with one commit, never a real
11772        /// remote and never touched over the network.
11773        fn seeded_remote() -> tempfile::TempDir {
11774            let remote = tempfile::tempdir().expect("temp dir");
11775            crate::test_support::init_bare(remote.path());
11776            crate::test_support::push_new_commit(remote.path(), "README.md", "seed\n");
11777            remote
11778        }
11779
11780        fn clone_into(remote: &Path, dest: &Path) {
11781            let status = Command::new("git")
11782                .arg("clone")
11783                .arg(remote)
11784                .arg(dest)
11785                .status()
11786                .expect("run git clone");
11787            assert!(status.success());
11788            crate::test_support::set_identity(dest);
11789        }
11790
11791        /// The scheduler's first rule: enabling the periodic fetch runs one cycle
11792        /// immediately rather than waiting for `fetch.interval` to elapse. `fetch_ticks`
11793        /// is `crossbeam_channel::never()`, so the only way `fetch_cycle_count_for_test`
11794        /// can ever move is the immediate cycle `start_internal` dispatches on its own
11795        /// plain thread; a scheduler that only reacted to a tick would leave this at
11796        /// zero forever.
11797        #[test]
11798        fn enabling_the_periodic_fetch_runs_one_cycle_before_any_tick_arrives() {
11799            let remote = seeded_remote();
11800            let root = tempfile::tempdir().expect("temp dir");
11801            let root_path = root_of(&root);
11802            clone_into(remote.path(), &root_path.join("parent"));
11803
11804            let fetch_ticks: Receiver<Instant> = crossbeam_channel::never();
11805            let started = Core::start_for_test_with_fetch(
11806                fetch_spec(true, root_path),
11807                Duration::from_secs(3600),
11808                crossbeam_channel::never(),
11809                fetch_ticks,
11810            )
11811            .discovered();
11812            let core = started.core;
11813
11814            wait_for(
11815                "the periodic fetch to run its first cycle without waiting for a tick",
11816                || core.fetch_cycle_count_for_test() >= 1,
11817            );
11818        }
11819
11820        /// A tick on the periodic fetch's own channel runs a second cycle, proving the
11821        /// recurring cadence is wired to the same dedicated thread the immediate cycle
11822        /// used, not merely a one-shot dispatched at start.
11823        #[test]
11824        fn a_tick_on_the_fetch_channel_runs_another_cycle() {
11825            let remote = seeded_remote();
11826            let root = tempfile::tempdir().expect("temp dir");
11827            let root_path = root_of(&root);
11828            clone_into(remote.path(), &root_path.join("parent"));
11829
11830            let (fetch_tick_tx, fetch_tick_rx) = crossbeam_channel::unbounded();
11831            let started = Core::start_for_test_with_fetch(
11832                fetch_spec(true, root_path),
11833                Duration::from_secs(3600),
11834                crossbeam_channel::never(),
11835                fetch_tick_rx,
11836            )
11837            .discovered();
11838            let core = started.core;
11839
11840            wait_for("the immediate cycle to have run first", || {
11841                core.fetch_cycle_count_for_test() >= 1
11842            });
11843
11844            fetch_tick_tx
11845                .send(Instant::now())
11846                .expect("send a fetch tick");
11847
11848            wait_for("a tick on the fetch channel to run a second cycle", || {
11849                core.fetch_cycle_count_for_test() >= 2
11850            });
11851        }
11852
11853        /// Points `repo`'s `origin` at a path nothing lives at, breaking `fetch_and_prune`
11854        /// alone: discovery has already found `repo` as a real Repo before this runs, so
11855        /// only the fetch itself fails, never the walk. A local path rather than a loopback
11856        /// address, so this never touches even the machine's own network stack, the same
11857        /// standing constraint every fixture in this module already holds to.
11858        fn break_remote(repo: &Path) {
11859            let status = Command::new("git")
11860                .arg("-C")
11861                .arg(repo)
11862                .args(["remote", "set-url", "origin", "/nonexistent-remote-282"])
11863                .status()
11864                .expect("run git remote set-url");
11865            assert!(status.success());
11866        }
11867
11868        /// Criterion: a cycle where every fetch succeeds reports no failures.
11869        #[test]
11870        fn a_cycle_in_which_every_fetch_succeeds_reports_no_failures() {
11871            let remote = seeded_remote();
11872            let root = tempfile::tempdir().expect("temp dir");
11873            let root_path = root_of(&root);
11874            clone_into(remote.path(), &root_path.join("parent"));
11875
11876            let fetch_ticks: Receiver<Instant> = crossbeam_channel::never();
11877            let started = Core::start_for_test_with_fetch(
11878                fetch_spec(true, root_path),
11879                Duration::from_secs(3600),
11880                crossbeam_channel::never(),
11881                fetch_ticks,
11882            )
11883            .discovered();
11884            let core = started.core;
11885
11886            wait_for("the periodic fetch to run its first cycle", || {
11887                core.fetch_cycle_count_for_test() >= 1
11888            });
11889
11890            assert!(
11891                core.fetch_failures().failed.is_empty(),
11892                "a cycle where every fetch succeeds must report no failures, got: {:?}",
11893                core.fetch_failures().failed
11894            );
11895        }
11896
11897        /// A cycle in which one repository cannot be fetched counts that one failure, and
11898        /// the per-repository independence at the fetch loop's own swallow is unchanged,
11899        /// proven here by the sibling repository still fetching.
11900        #[test]
11901        fn a_repository_that_cannot_be_fetched_is_counted_while_its_sibling_still_fetches() {
11902            let good_remote = seeded_remote();
11903            let bad_remote = seeded_remote();
11904            let root = tempfile::tempdir().expect("temp dir");
11905            let root_path = root_of(&root);
11906            let good = root_path.join("good");
11907            let bad = root_path.join("bad");
11908            clone_into(good_remote.path(), &good);
11909            clone_into(bad_remote.path(), &bad);
11910            break_remote(&bad);
11911
11912            crate::test_support::push_new_commit(good_remote.path(), "second.txt", "second\n");
11913            let good_remote_tip = rev_parse(good_remote.path(), "refs/heads/main");
11914
11915            let fetch_ticks: Receiver<Instant> = crossbeam_channel::never();
11916            let started = Core::start_for_test_with_fetch(
11917                fetch_spec(true, root_path),
11918                Duration::from_secs(3600),
11919                crossbeam_channel::never(),
11920                fetch_ticks,
11921            )
11922            .discovered();
11923            let core = started.core;
11924
11925            wait_for(
11926                "the cycle to run and count the one repository it could not fetch",
11927                || core.fetch_failures().failed.len() == 1,
11928            );
11929
11930            let failures = core.fetch_failures();
11931            assert_eq!(
11932                failures.failed.len(),
11933                1,
11934                "exactly one repository failed, so exactly one failure must be counted, \
11935                 got: {:?}",
11936                failures.failed
11937            );
11938            assert!(
11939                failures.failed[0].0.to_string_lossy().contains("bad"),
11940                "the counted failure must name the repository that actually failed, \
11941                 got: {:?}",
11942                failures.failed
11943            );
11944
11945            wait_for(
11946                "the sibling repository to still fetch despite the other one failing",
11947                || rev_parse(&good, "refs/remotes/origin/main") == good_remote_tip,
11948            );
11949        }
11950
11951        /// [`crate::test_support::push_new_commit`], but onto `branch` rather than
11952        /// always `main`: this scheduler test needs a second commit on `topic`
11953        /// specifically, so ancestry alone cannot call it merged into `main`.
11954        fn push_new_commit_on_branch(remote: &Path, branch: &str, name: &str, contents: &str) {
11955            let contributor = tempfile::tempdir().expect("temp dir");
11956            let status = Command::new("git")
11957                .arg("clone")
11958                .arg("--branch")
11959                .arg(branch)
11960                .arg(remote)
11961                .arg(contributor.path())
11962                .status()
11963                .expect("run git clone");
11964            assert!(status.success());
11965            std::fs::write(contributor.path().join(name), contents).expect("write fixture file");
11966            git(contributor.path(), &["add", name]);
11967            git(contributor.path(), &["commit", "-m", "extra work on topic"]);
11968            git(contributor.path(), &["push", "origin", branch]);
11969        }
11970
11971        /// Criteria 3 and 4 together, end to end: the periodic fetch always prunes, so
11972        /// `Gone` can appear at all, and a finished fetch starts one normal Generation
11973        /// on its own, so the pruned state actually lands on the table without the test
11974        /// calling `refresh` itself. `topic` carries a commit `main` never gets, so
11975        /// ancestry alone cannot call it `Merged`; deleting it upstream before the
11976        /// scheduler's own fetch is what a plain, non-pruning fetch could never turn
11977        /// into `Gone`.
11978        #[test]
11979        fn a_finished_fetch_prunes_and_starts_its_own_generation_that_lands_gone() {
11980            let remote = seeded_remote();
11981            let root = tempfile::tempdir().expect("temp dir");
11982            let root_path = root_of(&root);
11983            let parent = root_path.join("parent");
11984            clone_into(remote.path(), &parent);
11985
11986            git(remote.path(), &["branch", "topic"]);
11987            push_new_commit_on_branch(remote.path(), "topic", "topic.txt", "extra work\n");
11988
11989            // A deliberate, ordinary fetch by the test's own setup, distinct from the
11990            // Core's own periodic fetch under test: `parent` was cloned before `topic`
11991            // existed, so this is what teaches it about `origin/topic` at all, the same
11992            // way any real clone would only learn of a branch created after it cloned
11993            // on its own next fetch.
11994            git(&parent, &["fetch", "origin"]);
11995
11996            let worktree_path = root_path.join("topic-worktree");
11997            git(
11998                &parent,
11999                &[
12000                    "worktree",
12001                    "add",
12002                    "-b",
12003                    "topic",
12004                    worktree_path.to_str().expect("utf8 path"),
12005                    "origin/topic",
12006                ],
12007            );
12008
12009            // Deleted only now, after the worktree already tracks it: this is the
12010            // upstream disappearance a plain fetch can see but never prune away, and
12011            // exactly what the scheduler's own fetch (not this setup) must prune.
12012            git(remote.path(), &["branch", "-D", "topic"]);
12013
12014            let fetch_ticks: Receiver<Instant> = crossbeam_channel::never();
12015            let started = Core::start_for_test_with_fetch(
12016                fetch_spec(true, root_path),
12017                Duration::from_secs(3600),
12018                crossbeam_channel::never(),
12019                fetch_ticks,
12020            )
12021            .discovered();
12022            let core = started.core;
12023
12024            wait_for_or(
12025                "a finished fetch's own Generation to land the pruned Worktree as Gone \
12026                 without the test ever calling refresh",
12027                || {
12028                    core.snapshot()
12029                        .entities
12030                        .iter()
12031                        .filter(|entity| matches!(entity.kind, Kind::Worktree))
12032                        .any(|entity| {
12033                            matches!(
12034                                entity.state.settled(),
12035                                Some(Settled::Known {
12036                                    value: WorktreeState::Gone,
12037                                    at: _,
12038                                    stale: _,
12039                                })
12040                            )
12041                        })
12042                },
12043                || {
12044                    format!(
12045                        "snapshot: {:?}",
12046                        core.snapshot()
12047                            .entities
12048                            .iter()
12049                            .map(|entity| (entity.kind, entity.state.settled().cloned()))
12050                            .collect::<Vec<_>>()
12051                    )
12052                },
12053            );
12054        }
12055
12056        fn spec_with_auto_update(
12057            fetch_enabled: bool,
12058            auto_update_enabled: bool,
12059            root: PathBuf,
12060        ) -> CoreSpec {
12061            let mut spec = fetch_spec(fetch_enabled, root);
12062            spec.auto_update = AutoUpdateSpec {
12063                enabled: auto_update_enabled,
12064            };
12065            spec
12066        }
12067
12068        fn rev_parse(path: &Path, rev: &str) -> String {
12069            let output = Command::new("git")
12070                .arg("-C")
12071                .arg(path)
12072                .args(["rev-parse", rev])
12073                .output()
12074                .expect("run git rev-parse");
12075            assert!(output.status.success(), "git rev-parse {rev} failed");
12076            String::from_utf8(output.stdout)
12077                .expect("utf8 sha")
12078                .trim()
12079                .to_string()
12080        }
12081
12082        /// Criterion 1's "off by default" half: `fetch.enabled` alone is not enough to
12083        /// move a branch. `fetch_ticks` never fires, so the only cycle that can possibly
12084        /// run is the immediate one `start_internal` dispatches on being enabled; that
12085        /// cycle fetches (`fetch_cycle_count_for_test` proves it ran) and must still
12086        /// leave the eligible local branch exactly where it was, since `auto_update`
12087        /// carries its own, separate `enabled` flag this spec never turns on.
12088        #[test]
12089        fn auto_update_is_off_by_default_even_with_fetch_enabled() {
12090            let remote = seeded_remote();
12091            let root = tempfile::tempdir().expect("temp dir");
12092            let root_path = root_of(&root);
12093            let parent = root_path.join("parent");
12094            clone_into(remote.path(), &parent);
12095            let before = rev_parse(&parent, "refs/heads/main");
12096
12097            crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
12098
12099            let fetch_ticks: Receiver<Instant> = crossbeam_channel::never();
12100            let started = Core::start_for_test_with_fetch(
12101                spec_with_auto_update(true, false, root_path),
12102                Duration::from_secs(3600),
12103                crossbeam_channel::never(),
12104                fetch_ticks,
12105            )
12106            .discovered();
12107            let core = started.core;
12108
12109            wait_for(
12110                "the periodic fetch to still run its immediate cycle",
12111                || core.fetch_cycle_count_for_test() >= 1,
12112            );
12113            assert_eq!(
12114                rev_parse(&parent, "refs/heads/main"),
12115                before,
12116                "an eligible branch must not move while auto_update.enabled is false, \
12117                 even though fetch.enabled is true"
12118            );
12119        }
12120
12121        /// Criterion 1's "rides the fetch cycle with no timer of its own" half: the
12122        /// remote is already ahead *before* `Core::start`, `fetch_ticks` is
12123        /// `crossbeam_channel::never()` so no recurring tick ever fires, and yet the
12124        /// eligible branch still moves, proving the auto-update ran on the same
12125        /// immediate first cycle the periodic fetch itself uses rather than waiting on
12126        /// any tick of its own.
12127        #[test]
12128        fn auto_update_enabled_rides_the_immediate_fetch_cycle_with_no_timer_of_its_own() {
12129            let remote = seeded_remote();
12130            let root = tempfile::tempdir().expect("temp dir");
12131            let root_path = root_of(&root);
12132            let parent = root_path.join("parent");
12133            clone_into(remote.path(), &parent);
12134
12135            crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
12136            let remote_tip = rev_parse(remote.path(), "refs/heads/main");
12137
12138            let fetch_ticks: Receiver<Instant> = crossbeam_channel::never();
12139            let started = Core::start_for_test_with_fetch(
12140                spec_with_auto_update(true, true, root_path),
12141                Duration::from_secs(3600),
12142                crossbeam_channel::never(),
12143                fetch_ticks,
12144            )
12145            .discovered();
12146            // Kept alive, unused otherwise: dropping `Core` joins its dedicated thread,
12147            // which would stop the immediate cycle this test is waiting on.
12148            let _core = started.core;
12149
12150            wait_for(
12151                "the eligible branch to fast-forward on the immediate cycle alone, with no \
12152                 fetch tick and no auto-update tick of its own",
12153                || rev_parse(&parent, "refs/heads/main") == remote_tip,
12154            );
12155        }
12156    }
12157
12158    /// [`Core::attempt_auto_update`] must answer exactly what
12159    /// [`crate::auto_update::attempt`] would for the same Repo, since it delegates to that
12160    /// function rather than reimplementing its own copy of the eligibility rules: the
12161    /// built-in `sync` action's own "reuses `auto_update`'s existing rules rather than a
12162    /// second implementation"
12163    /// ([repo-management.md](https://github.com/paulchiu/repon/blob/main/docs/spec/repo-management.md))
12164    /// is proven here, at the one seam a reimplementation could actually diverge from the
12165    /// rules it is supposed to reuse. Every fixture is a bare repo this test creates plus a
12166    /// real `git clone` of it, the same standing constraint `fetch_scheduler` above follows.
12167    mod attempt_auto_update {
12168        use super::*;
12169
12170        fn seeded_remote() -> tempfile::TempDir {
12171            let remote = tempfile::tempdir().expect("temp dir");
12172            crate::test_support::init_bare(remote.path());
12173            crate::test_support::push_new_commit(remote.path(), "README.md", "seed\n");
12174            remote
12175        }
12176
12177        fn clone_into(remote: &Path, dest: &Path) {
12178            let status = Command::new("git")
12179                .arg("clone")
12180                .arg(remote)
12181                .arg(dest)
12182                .status()
12183                .expect("run git clone");
12184            assert!(status.success());
12185            crate::test_support::set_identity(dest);
12186        }
12187
12188        /// Discovers `root`'s one Repo and hands back the live `Core` alongside its key,
12189        /// the same `Core::start_discovered` plus `settle` shape [`delete_risk`]'s own tests
12190        /// already use: this method reads the repository fresh, not a Cell, so discovery's
12191        /// own read-only probes running first are never a race with it.
12192        fn discover_repo(root: &Path) -> (Core, EntityKey) {
12193            let core = Core::start_discovered(spec(vec![root.to_path_buf()]));
12194            let key = core
12195                .settle()
12196                .entities
12197                .into_iter()
12198                .find(|entity| entity.kind == Kind::Repo)
12199                .expect("the Repo row is discovered")
12200                .key;
12201            (core, key)
12202        }
12203
12204        /// The eligible condition: clean, behind, not ahead, tracking an upstream. Proves
12205        /// the wrapper both classifies and actually moves the branch, not only the former.
12206        #[test]
12207        fn an_eligible_repo_fast_forwards_through_the_wrapper_too() {
12208            let remote = seeded_remote();
12209            let root = tempfile::tempdir().expect("temp dir");
12210            let root_path = root_of(&root);
12211            let repo = root_path.join("repo");
12212            clone_into(remote.path(), &repo);
12213            crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
12214            crate::test_support::git(&repo, &["fetch", "origin"]);
12215
12216            let (core, key) = discover_repo(&root_path);
12217
12218            assert_eq!(core.attempt_auto_update(&key), AutoUpdateAttempt::Updated);
12219            assert!(
12220                repo.join("second.txt").exists(),
12221                "the fast-forward must reach the working tree through the wrapper too"
12222            );
12223        }
12224
12225        /// Condition 1: a dirty working tree.
12226        #[test]
12227        fn a_dirty_repo_is_reported_not_clean_through_the_wrapper_too() {
12228            let remote = seeded_remote();
12229            let root = tempfile::tempdir().expect("temp dir");
12230            let root_path = root_of(&root);
12231            let repo = root_path.join("repo");
12232            clone_into(remote.path(), &repo);
12233            crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
12234            crate::test_support::git(&repo, &["fetch", "origin"]);
12235            fs::write(repo.join("stray.txt"), "uncommitted\n").expect("write a stray file");
12236
12237            let (core, key) = discover_repo(&root_path);
12238
12239            assert_eq!(core.attempt_auto_update(&key), AutoUpdateAttempt::NotClean);
12240        }
12241
12242        /// Condition 2: already level with the upstream.
12243        #[test]
12244        fn an_up_to_date_repo_is_reported_not_behind_through_the_wrapper_too() {
12245            let remote = seeded_remote();
12246            let root = tempfile::tempdir().expect("temp dir");
12247            let root_path = root_of(&root);
12248            let repo = root_path.join("repo");
12249            clone_into(remote.path(), &repo);
12250            crate::test_support::git(&repo, &["fetch", "origin"]);
12251
12252            let (core, key) = discover_repo(&root_path);
12253
12254            assert_eq!(core.attempt_auto_update(&key), AutoUpdateAttempt::NotBehind);
12255        }
12256
12257        /// Condition 3: a local commit the upstream does not have.
12258        #[test]
12259        fn an_unpublished_local_commit_is_reported_not_fast_forward_through_the_wrapper_too() {
12260            let remote = seeded_remote();
12261            let root = tempfile::tempdir().expect("temp dir");
12262            let root_path = root_of(&root);
12263            let repo = root_path.join("repo");
12264            clone_into(remote.path(), &repo);
12265            crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
12266            crate::test_support::git(&repo, &["fetch", "origin"]);
12267            crate::test_support::commit_file(&repo, "local-only.txt", "never pushed\n");
12268
12269            let (core, key) = discover_repo(&root_path);
12270
12271            assert_eq!(
12272                core.attempt_auto_update(&key),
12273                AutoUpdateAttempt::NotFastForward
12274            );
12275        }
12276
12277        /// Condition 4: no upstream configured at all.
12278        #[test]
12279        fn a_branch_with_no_upstream_is_reported_through_the_wrapper_too() {
12280            let remote = seeded_remote();
12281            let root = tempfile::tempdir().expect("temp dir");
12282            let root_path = root_of(&root);
12283            let repo = root_path.join("repo");
12284            clone_into(remote.path(), &repo);
12285            crate::test_support::git(&repo, &["checkout", "-b", "untracked-branch"]);
12286
12287            let (core, key) = discover_repo(&root_path);
12288
12289            assert_eq!(
12290                core.attempt_auto_update(&key),
12291                AutoUpdateAttempt::NoUpstream
12292            );
12293        }
12294    }
12295
12296    /// [default-branch.md](https://github.com/paulchiu/repon/blob/main/docs/spec/default-branch.md)'s
12297    /// "The network": criterion 3 (the local chain answers first, and only a later network
12298    /// round trip supersedes it) and criterion 4 (`Core::rederive_default_branches` runs the
12299    /// same lookup on demand, over exactly the given keys, without fetching). Every fixture
12300    /// here is a bare repo this test creates plus a real `git clone` of it, the same standing
12301    /// constraint `fetch_scheduler` above already follows.
12302    mod network_default_branch {
12303        use super::*;
12304
12305        fn seeded_remote() -> tempfile::TempDir {
12306            let remote = tempfile::tempdir().expect("temp dir");
12307            crate::test_support::init_bare(remote.path());
12308            crate::test_support::push_new_commit(remote.path(), "README.md", "seed\n");
12309            remote
12310        }
12311
12312        fn clone_into(remote: &Path, dest: &Path) {
12313            let status = Command::new("git")
12314                .arg("clone")
12315                .arg(remote)
12316                .arg(dest)
12317                .status()
12318                .expect("run git clone");
12319            assert!(status.success());
12320            crate::test_support::set_identity(dest);
12321        }
12322
12323        /// Sets `path`'s own `HEAD` (a bare repo, so this is the "remote"'s advertised
12324        /// answer) to point at `branch`, without checking anything out.
12325        fn set_remote_head(path: &Path, branch: &str) {
12326            git(
12327                path,
12328                &["symbolic-ref", "HEAD", &format!("refs/heads/{branch}")],
12329            );
12330        }
12331
12332        fn rev_parse(path: &Path, rev: &str) -> String {
12333            let output = Command::new("git")
12334                .arg("-C")
12335                .arg(path)
12336                .args(["rev-parse", rev])
12337                .output()
12338                .expect("run git rev-parse");
12339            assert!(output.status.success());
12340            String::from_utf8(output.stdout)
12341                .expect("utf8 sha")
12342                .trim()
12343                .to_string()
12344        }
12345
12346        fn default_branch_name(entity: &EntityState) -> Option<String> {
12347            match entity.default_branch.settled() {
12348                Some(Settled::Known {
12349                    value,
12350                    at: _,
12351                    stale: _,
12352                }) => Some(value.name().to_string()),
12353                _ => None,
12354            }
12355        }
12356
12357        /// Criterion 3: with a reachable remote whose advertised HEAD differs from the
12358        /// clone's own cached `origin/HEAD`, a plain refresh still answers from the local
12359        /// chain alone (the network is never consulted just to render a Generation), and
12360        /// only [`Core::rederive_default_branches`] actually reaching the remote supersedes
12361        /// it, for the rest of this `Core`'s own session (default-branch.md's "The network":
12362        /// "supersedes the local one for that session"). The mutation this is chosen to
12363        /// catch: were `supersede_with_network` never applied (or applied unconditionally
12364        /// before the local chain even ran), either the first assertion would already read
12365        /// `origin/trunk`, or the second would still read `origin/main`.
12366        #[test]
12367        fn the_local_chain_answers_first_and_only_a_later_network_round_trip_supersedes_it() {
12368            let remote = seeded_remote();
12369            let root = tempfile::tempdir().expect("temp dir");
12370            let root_path = root_of(&root);
12371            let repo_path = root_path.join("repo");
12372            clone_into(remote.path(), &repo_path);
12373
12374            // The clone's own cached `origin/HEAD` still names `main`; the remote's own
12375            // current answer is changed to a different, real branch only after cloning.
12376            git(remote.path(), &["branch", "trunk"]);
12377            set_remote_head(remote.path(), "trunk");
12378
12379            let core = Core::start_discovered(spec(vec![root_path]));
12380            let key = core.snapshot().entities[0].key.clone();
12381
12382            core.refresh(std::slice::from_ref(&key));
12383            let settled = core.settle();
12384            assert_eq!(
12385                default_branch_name(&settled.entities[0]),
12386                Some("origin/main".to_string()),
12387                "a plain refresh must answer from the local chain alone, unaffected by the \
12388                 remote's own current (but not yet asked) truth"
12389            );
12390
12391            core.rederive_default_branches(std::slice::from_ref(&key));
12392            let settled = core.settle();
12393            assert_eq!(
12394                default_branch_name(&settled.entities[0]),
12395                Some("origin/trunk".to_string()),
12396                "once the network round trip actually ran, its own differing answer must \
12397                 supersede the local chain's"
12398            );
12399        }
12400
12401        /// Criterion 4: [`Core::rederive_default_branches`] runs the same lookup on demand,
12402        /// over exactly the given keys, without fetching. "Without fetching" is shown the
12403        /// way `fetch.rs`'s own `a_fetch_transfers_new_commits_so_a_behind_count_can_move`
12404        /// shows a real fetch moving one, the mirror image: the remote gains a new commit
12405        /// after the clone, and this call must leave the clone's own remote-tracking ref
12406        /// exactly where it was, because `probe_remote_head`'s handshake-only lookup
12407        /// transfers no pack. "Over the Selection" is exercised as "over exactly the given
12408        /// keys": a second, unrelated repo stands in for a row outside it, and its whole
12409        /// entity state (every cell, not only `default_branch`) is asserted unchanged.
12410        #[test]
12411        fn rederive_default_branches_never_fetches_and_leaves_a_row_outside_it_untouched() {
12412            let remote = seeded_remote();
12413            let root = tempfile::tempdir().expect("temp dir");
12414            let root_path = root_of(&root);
12415            let selected_path = root_path.join("selected");
12416            let outside_path = root_path.join("outside");
12417            clone_into(remote.path(), &selected_path);
12418            init_repo_with_a_commit(&outside_path);
12419
12420            git(remote.path(), &["branch", "trunk"]);
12421            crate::test_support::push_new_commit(remote.path(), "second.txt", "second\n");
12422            set_remote_head(remote.path(), "trunk");
12423            let before_tracking = rev_parse(&selected_path, "refs/remotes/origin/main");
12424
12425            let core = Core::start_discovered(spec(vec![root_path]));
12426            let snapshot = core.snapshot();
12427            let selected_key = snapshot
12428                .entities
12429                .iter()
12430                .find(|entity| entity.key.path() == selected_path)
12431                .expect("discovered the selected repo")
12432                .key
12433                .clone();
12434            let outside_key = snapshot
12435                .entities
12436                .iter()
12437                .find(|entity| entity.key.path() == outside_path)
12438                .expect("discovered the outside repo")
12439                .key
12440                .clone();
12441
12442            core.refresh(&[selected_key.clone(), outside_key.clone()]);
12443            let settled = core.settle();
12444            let outside_before = format!(
12445                "{:?}",
12446                settled
12447                    .entities
12448                    .iter()
12449                    .find(|entity| entity.key == outside_key)
12450                    .expect("outside entity present")
12451            );
12452
12453            core.rederive_default_branches(std::slice::from_ref(&selected_key));
12454            let settled = core.settle();
12455
12456            let selected_after = settled
12457                .entities
12458                .iter()
12459                .find(|entity| entity.key == selected_key)
12460                .expect("selected entity present");
12461            assert_eq!(
12462                default_branch_name(selected_after),
12463                Some("origin/trunk".to_string()),
12464                "the rederive must have reached the remote's own current, differing answer"
12465            );
12466
12467            let after_tracking = rev_parse(&selected_path, "refs/remotes/origin/main");
12468            assert_eq!(
12469                before_tracking, after_tracking,
12470                "a rederive must never fetch: the remote-tracking ref must not have moved \
12471                 even though the remote gained a new commit"
12472            );
12473
12474            let outside_after = format!(
12475                "{:?}",
12476                settled
12477                    .entities
12478                    .iter()
12479                    .find(|entity| entity.key == outside_key)
12480                    .expect("outside entity present")
12481            );
12482            assert_eq!(
12483                outside_before, outside_after,
12484                "a row outside the rederive's own keys must be left exactly as it was, not \
12485                 only on its default_branch cell"
12486            );
12487        }
12488    }
12489
12490    // =====================================================================================
12491    // `set_exclusions`: `[[repo]]`'s `exclude` re-applied live, with no rebuild and no
12492    // rediscovery, per repo-management.md's "Writing config".
12493    // =====================================================================================
12494
12495    /// The live half: a row already in the table becomes excluded, and is subtracted from
12496    /// `operable_count`, without a rebuilt `Core` and without a Generation of any kind.
12497    #[test]
12498    fn set_exclusions_excludes_a_row_already_in_the_table_with_no_rebuild() {
12499        let dir = tempfile::tempdir().expect("temp dir");
12500        let root = root_of(&dir);
12501        let repo = root.join("repo");
12502        init_repo_with_a_commit(&repo);
12503
12504        let core = Core::start_discovered(spec(vec![root]));
12505        let snapshot = core.settle();
12506        let key = snapshot.entities[0].key.clone();
12507        let generation_before = snapshot.generation;
12508        assert!(
12509            !snapshot.entities[0].excluded,
12510            "nothing excludes it to start with"
12511        );
12512        assert_eq!(core.operable_count(std::slice::from_ref(&key)), 1);
12513
12514        core.set_exclusions(&[RepoOverride {
12515            path: repo.clone(),
12516            default_branch: None,
12517            excluded: true,
12518        }]);
12519
12520        let after = core.snapshot();
12521        assert!(
12522            after.entities[0].excluded,
12523            "the row the write named is excluded in the very next snapshot"
12524        );
12525        assert_eq!(
12526            core.operable_count(&[key]),
12527            0,
12528            "an excluded row is subtracted from what an operation may reach"
12529        );
12530        assert_eq!(
12531            after.generation, generation_before,
12532            "re-applying an operate-time filter must start no Generation of its own"
12533        );
12534    }
12535
12536    /// The other direction: dropping the entry clears the flag, so a row ignored and shown
12537    /// again in one session ends where it started.
12538    #[test]
12539    fn set_exclusions_clears_the_flag_when_the_entry_is_gone() {
12540        let dir = tempfile::tempdir().expect("temp dir");
12541        let root = root_of(&dir);
12542        let repo = root.join("repo");
12543        init_repo_with_a_commit(&repo);
12544
12545        let core = Core::start_discovered(spec_with_overrides(
12546            vec![root],
12547            vec![RepoOverride {
12548                path: repo.clone(),
12549                default_branch: None,
12550                excluded: true,
12551            }],
12552        ));
12553        assert!(
12554            core.settle().entities[0].excluded,
12555            "the starting override excludes it"
12556        );
12557
12558        core.set_exclusions(&[]);
12559
12560        assert!(
12561            !core.snapshot().entities[0].excluded,
12562            "removing the entry unexcludes the row in the very next snapshot"
12563        );
12564    }
12565
12566    /// The boundary the specification draws around the live half: `exclude` re-applies and
12567    /// `default_branch` does not, because one is an operate-time filter and the other is a
12568    /// probe input. A `set_exclusions` that swapped the whole `[[repo]]` reading in would
12569    /// move both, which is what this refuses.
12570    #[test]
12571    fn set_exclusions_moves_exclude_alone_and_never_the_default_branch_override() {
12572        let dir = tempfile::tempdir().expect("temp dir");
12573        let root = root_of(&dir);
12574        let repo = root.join("repo");
12575        init_repo_with_a_commit(&repo);
12576        crate::test_support::git(&repo, &["branch", "trunk"]);
12577
12578        let core = Core::start_discovered(spec(vec![root]));
12579        let key = core.settle().entities[0].key.clone();
12580        core.refresh(std::slice::from_ref(&key));
12581        let before = format!("{:?}", core.settle().entities[0].default_branch.settled());
12582
12583        core.set_exclusions(&[RepoOverride {
12584            path: repo.clone(),
12585            default_branch: Some("trunk".to_string()),
12586            excluded: true,
12587        }]);
12588        core.refresh(&[key]);
12589        core.settle();
12590
12591        let after = core.snapshot();
12592        assert!(after.entities[0].excluded, "exclude took effect");
12593        assert_eq!(
12594            format!("{:?}", after.entities[0].default_branch.settled()),
12595            before,
12596            "a default_branch override reaches a session only through a rebuilt Core"
12597        );
12598    }
12599
12600    // =====================================================================================
12601    // `record_own_work`: the receipt a Management operation leaves, docs/spec/repo-management.md
12602    // =====================================================================================
12603
12604    /// One receipt per named row, and the shape the caller never gets to choose: `running` is
12605    /// `None`, `skip` is `None` (a refusal is not an excluded row), and there is
12606    /// exactly one step, because such an operation is one act rather than an ordered list.
12607    #[test]
12608    fn record_own_work_leaves_one_receipt_per_row_it_names_and_none_elsewhere() {
12609        let dir = tempfile::tempdir().expect("temp dir");
12610        let root = root_of(&dir);
12611        init_repo_with_a_commit(&root.join("repo-a"));
12612        init_repo_with_a_commit(&root.join("repo-b"));
12613
12614        let core = Core::start_discovered(spec(vec![root]));
12615        let entities = core.settle().entities;
12616        let named = entities
12617            .iter()
12618            .find(|entity| &*entity.name == "repo-a")
12619            .expect("repo-a is discovered")
12620            .key
12621            .clone();
12622
12623        core.record_own_work(
12624            "ignore",
12625            &[(
12626                named.clone(),
12627                OwnWork::Refused(Arc::from("refused, already ignored")),
12628                Duration::from_millis(7),
12629            )],
12630        );
12631
12632        let after = core.snapshot().entities;
12633        let receipt = after
12634            .iter()
12635            .find(|entity| entity.key == named)
12636            .and_then(|entity| entity.last_action.clone())
12637            .expect("the row it named carries a receipt");
12638        assert_eq!(&*receipt.label, "ignore");
12639        assert!(
12640            !receipt.not_applicable(),
12641            "a refusal is not an excluded row"
12642        );
12643        assert!(receipt.running.is_none(), "the work is already done");
12644        assert_eq!(receipt.steps.len(), 1, "one act, not an ordered list");
12645        assert_eq!(&*receipt.steps[0].label, "ignore");
12646        assert_eq!(receipt.steps[0].elapsed, Duration::from_millis(7));
12647        assert!(receipt.steps[0].output.is_empty(), "nothing to quote");
12648        assert!(receipt.steps[0].elision.is_none());
12649        assert_eq!(
12650            receipt.steps[0].outcome,
12651            StepOutcome::OwnWork(OwnWork::Refused(Arc::from("refused, already ignored"))),
12652        );
12653        assert!(
12654            after
12655                .iter()
12656                .filter(|entity| entity.key != named)
12657                .all(|entity| entity.last_action.is_none()),
12658            "no row this did not name takes a receipt"
12659        );
12660    }
12661
12662    /// A key the table no longer holds is skipped rather than panicking or landing on the
12663    /// wrong row, the same fallback every key-addressed entry point here gives one: a `delete`
12664    /// whose Repo is already gone is exactly this case.
12665    #[test]
12666    fn record_own_work_skips_a_key_the_table_no_longer_holds() {
12667        let dir = tempfile::tempdir().expect("temp dir");
12668        let root = root_of(&dir);
12669        init_repo_with_a_commit(&root.join("repo-a"));
12670
12671        let core = Core::start_discovered(spec(vec![root]));
12672        let entities = core.settle().entities;
12673        let stranger = EntityKey::new(Arc::from(std::path::Path::new("/nowhere/at/all")));
12674
12675        core.record_own_work(
12676            "delete",
12677            &[(stranger, OwnWork::Did(Arc::from("gone")), Duration::ZERO)],
12678        );
12679
12680        assert!(
12681            core.snapshot()
12682                .entities
12683                .iter()
12684                .all(|entity| entity.last_action.is_none()),
12685            "an unknown key writes nothing anywhere"
12686        );
12687        assert_eq!(core.snapshot().entities.len(), entities.len());
12688    }
12689
12690    // =====================================================================================
12691    // `delete_risk`: the three facts repo-management.md's confirm gate names per Repo, read
12692    // rather than stubbed. Every repository here is built in a temp directory this test owns,
12693    // and no path comes from config, an environment variable or the working directory.
12694    // =====================================================================================
12695
12696    /// A Repo with all three: an uncommitted change, a commit no remote-tracking ref carries,
12697    /// and a linked Worktree pointing into it.
12698    #[test]
12699    fn delete_risk_reads_all_three_facts_the_confirm_gate_names() {
12700        let dir = tempfile::tempdir().expect("temp dir");
12701        let root = root_of(&dir);
12702        let repo = root.join("repo");
12703        init_repo_with_a_commit(&repo);
12704        fs::write(repo.join("uncommitted.txt"), "not staged\n").expect("write a stray file");
12705        crate::test_support::git(
12706            &repo,
12707            &["worktree", "add", "-b", "sidecar", "../sidecar-worktree"],
12708        );
12709
12710        let core = Core::start_discovered(spec(vec![root]));
12711        // Settled first, so the startup Generation's own phase C is no longer reading this
12712        // same repository while the line below reads it: two concurrent gix statuses over one
12713        // working tree is a race in the harness, not in `delete_risk`.
12714        let key = core
12715            .settle()
12716            .entities
12717            .into_iter()
12718            .find(|entity| entity.kind == Kind::Repo)
12719            .expect("the Repo row is discovered")
12720            .key;
12721
12722        let risk = core.delete_risk(&key).expect("read the risk");
12723
12724        assert!(risk.uncommitted, "the stray file makes the tree dirty");
12725        assert!(
12726            risk.unpushed_commits > 0 && risk.unpushed_branches > 0,
12727            "no remote-tracking ref carries any of this Repo's commits, got {risk:?}"
12728        );
12729        assert_eq!(
12730            risk.linked_worktrees, 1,
12731            "the one linked Worktree pointing into this Repo is counted, got {risk:?}"
12732        );
12733    }
12734
12735    /// The `uncommitted` field's own range, one position at a time, because the composition
12736    /// behind it folds four separate reads: a modified tracked file, a deleted tracked file,
12737    /// an untracked file, and a staged change. Each gets a repository of its own with nothing
12738    /// else wrong with it, so narrowing the composition to any one of the four fails here
12739    /// rather than passing on whichever position a single fixture happened to sample.
12740    #[test]
12741    fn every_kind_of_work_that_is_not_in_a_commit_makes_the_gate_say_uncommitted() {
12742        for kind in ["modified", "deleted", "untracked", "staged"] {
12743            let dir = tempfile::tempdir().expect("temp dir");
12744            let root = root_of(&dir);
12745            let repo = root.join("repo");
12746            init_repo_with_a_commit(&repo);
12747            fs::write(repo.join("tracked.txt"), "first\n").expect("write a tracked file");
12748            crate::test_support::git(&repo, &["add", "tracked.txt"]);
12749            crate::test_support::git(&repo, &["commit", "-m", "add tracked"]);
12750            let sha = crate::test_support::head_sha(&repo);
12751            crate::test_support::git(&repo, &["update-ref", "refs/remotes/origin/main", &sha]);
12752
12753            match kind {
12754                "modified" => fs::write(repo.join("tracked.txt"), "second\n").expect("modify it"),
12755                "deleted" => fs::remove_file(repo.join("tracked.txt")).expect("delete it"),
12756                "untracked" => fs::write(repo.join("stray.txt"), "new\n").expect("write a stray"),
12757                "staged" => {
12758                    fs::write(repo.join("staged.txt"), "new\n").expect("write a new file");
12759                    crate::test_support::git(&repo, &["add", "staged.txt"]);
12760                }
12761                other => unreachable!("unhandled kind {other}"),
12762            }
12763
12764            let core = Core::start_discovered(spec(vec![root]));
12765            let key = core.settle().entities[0].key.clone();
12766
12767            let risk = core.delete_risk(&key).expect("read the risk");
12768
12769            assert!(
12770                risk.uncommitted,
12771                "a {kind} change is work that is not in a commit, got {risk:?}"
12772            );
12773        }
12774    }
12775
12776    /// The staged case, stated on its own as well as in the range above, because it is the
12777    /// one the dirty column deliberately answers `clean` to: `dirty_counts` compares the index
12778    /// against the working tree and never against `HEAD`, so a `git add` with no commit is
12779    /// invisible to it. Both readings are asserted here together, so a fix that widened
12780    /// `dirty_counts` instead of giving the gate its own read would fail this rather than
12781    /// silently change what the dirty column means.
12782    #[test]
12783    fn staged_work_reads_clean_to_the_dirty_column_and_uncommitted_to_the_delete_gate() {
12784        let dir = tempfile::tempdir().expect("temp dir");
12785        let root = root_of(&dir);
12786        let repo = root.join("repo");
12787        init_repo_with_a_commit(&repo);
12788        let sha = crate::test_support::head_sha(&repo);
12789        crate::test_support::git(&repo, &["update-ref", "refs/remotes/origin/main", &sha]);
12790        fs::write(repo.join("staged.txt"), "staged\n").expect("write a new file");
12791        crate::test_support::git(&repo, &["add", "staged.txt"]);
12792
12793        let core = Core::start_discovered(spec(vec![root]));
12794        let key = core.settle().entities[0].key.clone();
12795
12796        let opened = git::open_thread_safe(repo.as_path())
12797            .expect("open the repo")
12798            .to_thread_local();
12799        let dirty = git::dirty_counts(&opened, Arc::new(AtomicBool::new(false)))
12800            .expect("read the dirty counts");
12801        assert_eq!(
12802            dirty.total(),
12803            0,
12804            "the dirty column stays an index-to-worktree comparison, got {dirty:?}"
12805        );
12806
12807        let risk = core.delete_risk(&key).expect("read the risk");
12808        assert!(
12809            risk.uncommitted,
12810            "a Repo whose only work is staged must never be listed plainly, got {risk:?}"
12811        );
12812    }
12813
12814    /// The two unpushed quantities are two quantities: a fixture whose commit count and
12815    /// branch count differ, so transposing the pair in the composition changes both numbers
12816    /// rather than satisfying an inequality either way round.
12817    #[test]
12818    fn unpushed_commits_and_unpushed_branches_are_counted_into_their_own_fields() {
12819        let dir = tempfile::tempdir().expect("temp dir");
12820        let root = root_of(&dir);
12821        let repo = root.join("repo");
12822        init_repo_with_a_commit(&repo);
12823        let sha = crate::test_support::head_sha(&repo);
12824        crate::test_support::git(&repo, &["update-ref", "refs/remotes/origin/main", &sha]);
12825        for nth in 0..3 {
12826            fs::write(repo.join(format!("file-{nth}.txt")), "x\n").expect("write a file");
12827            crate::test_support::git(&repo, &["add", "."]);
12828            crate::test_support::git(&repo, &["commit", "-m", "unpushed"]);
12829        }
12830        crate::test_support::git(&repo, &["checkout", "."]);
12831
12832        let core = Core::start_discovered(spec(vec![root]));
12833        let key = core.settle().entities[0].key.clone();
12834
12835        let risk = core.delete_risk(&key).expect("read the risk");
12836
12837        assert_eq!(
12838            (risk.unpushed_commits, risk.unpushed_branches),
12839            (3, 1),
12840            "three commits on one branch, each in its own field, got {risk:?}"
12841        );
12842    }
12843
12844    /// The linked-Worktree count is git's own register, not the table's: a Worktree living
12845    /// outside the active Set's roots is never discovered, and deleting the Repo it is linked
12846    /// from orphans it just the same.
12847    #[test]
12848    fn a_linked_worktree_outside_the_sets_roots_is_still_counted_by_the_gate() {
12849        let dir = tempfile::tempdir().expect("temp dir");
12850        let base = root_of(&dir);
12851        let inside = base.join("inside");
12852        let outside = base.join("outside");
12853        fs::create_dir_all(&outside).expect("create the outside dir");
12854        let repo = inside.join("repo");
12855        init_repo_with_a_commit(&repo);
12856        crate::test_support::git(
12857            &repo,
12858            &["worktree", "add", "-b", "sidecar", "../../outside/sidecar"],
12859        );
12860        assert!(
12861            outside.join("sidecar").exists(),
12862            "the harness really created a linked Worktree outside the Set's roots"
12863        );
12864
12865        // Bounded by `inside` alone, so the Worktree is not a row in this Core's own table.
12866        let core = Core::start_discovered(spec(vec![inside]));
12867        let snapshot = core.settle();
12868        assert!(
12869            snapshot
12870                .entities
12871                .iter()
12872                .all(|entity| entity.kind != Kind::Worktree),
12873            "the Worktree is outside the roots and so is not discovered, got {:?}",
12874            snapshot.entities.iter().map(|e| e.kind).collect::<Vec<_>>()
12875        );
12876        let key = snapshot
12877            .entities
12878            .into_iter()
12879            .find(|entity| entity.kind == Kind::Repo)
12880            .expect("the Repo row is discovered")
12881            .key;
12882
12883        let risk = core.delete_risk(&key).expect("read the risk");
12884
12885        assert_eq!(
12886            risk.linked_worktrees, 1,
12887            "the gate must name the linked Worktree deleting this Repo would orphan, got {risk:?}"
12888        );
12889    }
12890
12891    /// The "listed plainly" case: nothing uncommitted, every commit already on a
12892    /// remote-tracking ref, and no linked Worktree at all. Asserted as its own test rather
12893    /// than left implied, since a gate that reports risk on every Repo is as wrong as one
12894    /// that reports it on none.
12895    #[test]
12896    fn delete_risk_on_a_clean_fully_pushed_repo_with_no_worktrees_reports_nothing() {
12897        let dir = tempfile::tempdir().expect("temp dir");
12898        let root = root_of(&dir);
12899        let repo = root.join("repo");
12900        init_repo_with_a_commit(&repo);
12901        let sha = crate::test_support::head_sha(&repo);
12902        crate::test_support::git(&repo, &["update-ref", "refs/remotes/origin/main", &sha]);
12903
12904        let core = Core::start_discovered(spec(vec![root]));
12905        let key = core.settle().entities[0].key.clone();
12906
12907        let risk = core.delete_risk(&key).expect("read the risk");
12908
12909        assert_eq!(
12910            risk,
12911            DeleteRisk {
12912                uncommitted: false,
12913                unpushed_commits: 0,
12914                unpushed_branches: 0,
12915                linked_worktrees: 0,
12916            }
12917        );
12918    }
12919
12920    // =====================================================================================
12921    // `worktree_admin_dir` and `linked_worktree_paths`: what `delete` needs to remove a
12922    // linked Worktree the way `git worktree remove` does, and to take a Repo's own linked
12923    // Worktrees with it. Every repository here is built in a temp directory this test owns.
12924    // =====================================================================================
12925
12926    /// The administrative directory named for a Worktree row is the one `git worktree list`
12927    /// stops naming once it is gone, proven by removing exactly that directory by hand.
12928    #[test]
12929    fn worktree_admin_dir_names_the_entry_git_worktree_list_forgets_once_it_is_removed() {
12930        let dir = tempfile::tempdir().expect("temp dir");
12931        let root = root_of(&dir);
12932        let repo = root.join("repo");
12933        init_repo_with_a_commit(&repo);
12934        let worktree = root.join("sidecar");
12935        crate::test_support::git(
12936            &repo,
12937            &[
12938                "worktree",
12939                "add",
12940                "-b",
12941                "sidecar",
12942                worktree.to_str().expect("utf8 path"),
12943            ],
12944        );
12945
12946        let core = Core::start_discovered(spec(vec![root]));
12947        let key = core
12948            .settle()
12949            .entities
12950            .into_iter()
12951            .find(|entity| entity.kind == Kind::Worktree)
12952            .expect("the Worktree row is discovered")
12953            .key;
12954
12955        let admin_dir = core.worktree_admin_dir(&key).expect("read the admin dir");
12956        fs::remove_dir_all(&admin_dir).expect("remove the admin dir by hand");
12957
12958        let reopened = git::open_thread_safe(&repo)
12959            .expect("reopen the repo")
12960            .to_thread_local();
12961        assert_eq!(
12962            git::linked_worktrees(&reopened).expect("count"),
12963            0,
12964            "removing the admin dir alone must be what git's own register stops naming"
12965        );
12966    }
12967
12968    /// A Worktree whose own path is not a git repository at all (the fixture for "the parent
12969    /// Repo is gone or unreadable"): the read errors rather than naming a directory that was
12970    /// never a Worktree's own administrative entry.
12971    #[test]
12972    fn worktree_admin_dir_errors_when_the_path_cannot_be_opened_as_a_repository() {
12973        let dir = tempfile::tempdir().expect("temp dir");
12974        let root = root_of(&dir);
12975        let not_a_repo = root.join("plain-directory");
12976        fs::create_dir_all(&not_a_repo).expect("create it");
12977
12978        let core = Core::start_discovered(spec(vec![root]));
12979        core.settle();
12980        let key = EntityKey::new(Arc::from(not_a_repo.as_path()));
12981
12982        assert!(core.worktree_admin_dir(&key).is_err());
12983    }
12984
12985    /// Every linked Worktree's own working directory, named by path rather than merely
12986    /// counted, for the Repo deletion cascade to remove.
12987    #[test]
12988    fn linked_worktree_paths_names_every_linked_worktrees_own_directory() {
12989        let dir = tempfile::tempdir().expect("temp dir");
12990        let root = root_of(&dir);
12991        let repo = root.join("repo");
12992        init_repo_with_a_commit(&repo);
12993        let first = root.join("first-worktree");
12994        let second = root.join("second-worktree");
12995        crate::test_support::git(
12996            &repo,
12997            &[
12998                "worktree",
12999                "add",
13000                "-b",
13001                "one",
13002                first.to_str().expect("utf8 path"),
13003            ],
13004        );
13005        crate::test_support::git(
13006            &repo,
13007            &[
13008                "worktree",
13009                "add",
13010                "-b",
13011                "two",
13012                second.to_str().expect("utf8 path"),
13013            ],
13014        );
13015
13016        let core = Core::start_discovered(spec(vec![root]));
13017        let key = core
13018            .settle()
13019            .entities
13020            .into_iter()
13021            .find(|entity| entity.kind == Kind::Repo)
13022            .expect("the Repo row is discovered")
13023            .key;
13024
13025        let mut paths = core
13026            .linked_worktree_paths(&key)
13027            .expect("read the linked worktree paths");
13028        paths.sort();
13029        let mut expected = vec![
13030            first.canonicalize().expect("canonicalize first"),
13031            second.canonicalize().expect("canonicalize second"),
13032        ];
13033        expected.sort();
13034
13035        assert_eq!(paths, expected);
13036    }
13037}