Skip to main content

forge_agent/env/
git_diff.rs

1//! Git diff scanner - on-demand `git` subprocess invocations that
2//! produce a [`GitDiffSnapshot`] describing the project's current
3//! changeset.
4//!
5//! Polled (not watched): the workspace reaches [`scan`] as
6//! `forge_workspace::env::git_diff::scan`, a wildcard re-export of this
7//! module through the workspace facade, and runs it on the
8//! "snapshot is `None` OR age ≥ 10 s → fetch" rule while a seat is
9//! held. The snapshot carries branch info alongside diff stats, so this
10//! module replaces both the previous file-watcher `GitContextWatcher`
11//! and the per-turn refresh hooks that fed it.
12//!
13//! The snapshot exposes two independent diff layers so callers can
14//! render both simultaneously when both apply (worker on a topic
15//! branch with uncommitted edits, for instance). Each layer is a
16//! [`LayerState`] with three variants:
17//! - `worktree`: uncommitted edits vs HEAD (the dirty tree).
18//!   `Clean` when the tree is clean.
19//! - `branch_ahead`: the branch's commits ahead of the default
20//!   branch. `Clean` on the default branch, on detached HEAD, or
21//!   when there's no resolvable default. `ScanFailed` carries the
22//!   per-layer subprocess-error signal.
23//!
24//! `scan` always returns a value. Subprocess failures, missing
25//! repos, oversize output, and timeouts all collapse to a non-InRepo
26//! `repo_gate` (`NotARepo` / `ScannerFailed`); the failure surfaces in
27//! the trace log so a real issue can be diagnosed without breaking the
28//! rendering path.
29
30use std::collections::HashMap;
31use std::path::{Path, PathBuf};
32use std::sync::OnceLock;
33use std::time::{Duration, Instant};
34
35use parking_lot::Mutex;
36
37use super::git_command;
38use forge_primitives::git::{FileStatus, GitBranch, GitIssueRef, GitPrInfo};
39use forge_primitives::git_diff::{
40    BaseState, GitBranchAhead, GitDiffFile, GitDiffSnapshot, GitDiffStats, LayerState, PrRefresh,
41    RepoGate,
42};
43use serde::Deserialize;
44use tokio::time::timeout;
45
46pub mod content;
47pub mod file;
48pub mod hunks;
49pub mod resolver;
50
51/// Max bytes accepted from any single `git` subprocess invocation.
52/// `--numstat` is small in practice; the per-file `git diff <target>
53/// --no-ext-diff -- <path>` calls produced by [`hunks::scan`] are
54/// each bounded by one file's worth of changes. 8 MiB is generous
55/// headroom for genuinely huge single-file diffs (lockfiles,
56/// generated source, vendored snapshots) without exposing the scan
57/// to unbounded memory growth on a corrupted-git-output edge case.
58const STDOUT_SIZE_CAP: usize = 8 * 1024 * 1024;
59
60/// Per-subprocess timeout. A scan's worst case is one budget per child, and a
61/// repository with no remote and no local default branch issues ten of them
62/// (`SCAN_CHILDREN`). In practice every command returns in <100 ms on a local
63/// repo, and timeouts only fire on hung network mounts or genuinely huge
64/// repositories.
65const COMMAND_TIMEOUT: Duration = Duration::from_secs(10);
66
67/// The children one scan issues for a repository with no remote and no local
68/// default branch: `rev-parse`, the `origin/HEAD` probe plus its four
69/// candidates (all missed), `status`, the worktree layer's `numstat` and
70/// `name-status`, and `for-each-ref` for the pushed sha. The PR lookup adds a
71/// `gh` child only once a pushed sha exists, which such a repository has none
72/// of.
73///
74/// **That shape is the count.** One whose default does resolve leaves the
75/// probe loop early and runs the ahead layer instead, so it needs a count of
76/// its own; this constant is not it.
77#[cfg(any(test, feature = "testing"))]
78const SCAN_CHILDREN: u32 = 10;
79
80/// One scan's worst case for the shape `SCAN_CHILDREN` counts, which is what a
81/// test waiting on a scan derives its own bound from. Test-only: production
82/// reads the per-child budget, never the count.
83#[cfg(any(test, feature = "testing"))]
84pub const SCAN_WORST_CASE: Duration =
85    Duration::from_secs(COMMAND_TIMEOUT.as_secs() * SCAN_CHILDREN as u64);
86
87/// The budget the tests give a scan's `git` children: **not the shell's
88/// own.** A scan's commands are bounded by it while the machine starts each
89/// child, and a loaded machine can hold a child's first output past the
90/// shell's 10 s - the same stall the PR lookup hit (measured 2026-10-14, the
91/// family's reds at 10.5 s and 11.9 s). A stalled child must be a slow case
92/// in the tests, not a red.
93#[cfg(test)]
94pub(super) const TEST_GIT_BUDGET: Duration = Duration::from_secs(60);
95
96/// Point the repo at `dir` at a `git` of our own that sleeps before it
97/// answers: a child that cannot finish inside a zero budget's first poll,
98/// where the machine's warm `git` can - `timeout` polls its value before
99/// its delay, so an instant child answers inside a zero budget, which is
100/// how the witness's refusal arms redded CI (#2249). With this the
101/// refusals are a property of the seam, not a race.
102///
103/// The wrapper lives in `bin`, **outside the repo**: a file inside would
104/// read back as an untracked change and skew a scan's own file list.
105#[cfg(test)]
106pub(super) fn stall_git_at(dir: &std::path::Path, bin: &std::path::Path) {
107    stall_git_for(dir, bin, 5);
108}
109
110/// [`stall_git_at`] with the stall spelled, for a case that must show its own
111/// budget cutting a child short of a stall longer than it.
112#[cfg(test)]
113pub(super) fn stall_git_for(dir: &std::path::Path, bin: &std::path::Path, secs: u64) {
114    use std::os::unix::fs::PermissionsExt as _;
115
116    let wrapper = bin.join("stalled-git");
117    std::fs::write(&wrapper, format!("#!/bin/sh\n/bin/sleep {secs}\nexec git \"$@\"\n"))
118        .expect("the wrapper");
119    std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).expect("+x");
120    crate::env::git_command::point_git_at(dir, &wrapper);
121}
122
123/// Point the repo at `dir` at a `git` whose `fetch` refuses and whose every
124/// other subcommand is the machine's own: the shape a refresh that cannot run
125/// leaves behind - the base stays where it was, and the answer is unchecked
126/// with git's own words.
127#[cfg(test)]
128pub(super) fn fail_fetch_at(dir: &std::path::Path, bin: &std::path::Path) {
129    use std::os::unix::fs::PermissionsExt as _;
130
131    let wrapper = bin.join("fetchless-git");
132    std::fs::write(
133        &wrapper,
134        "#!/bin/sh\ncase \"$*\" in\n  *\" fetch \"*) echo 'fatal: forced fetch failure' >&2; \
135         exit 128 ;;\nesac\nexec git \"$@\"\n",
136    )
137    .expect("the wrapper");
138    std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).expect("+x");
139    crate::env::git_command::point_git_at(dir, &wrapper);
140}
141
142/// Point the repo at `dir` at a `git` whose `fetch` refuses the way git does
143/// when another process holds a ref lock, so the refresh's lock
144/// classification can be driven end to end.
145#[cfg(test)]
146pub(super) fn lock_fetch_at(dir: &std::path::Path, bin: &std::path::Path) {
147    use std::os::unix::fs::PermissionsExt as _;
148
149    let wrapper = bin.join("locked-git");
150    std::fs::write(
151        &wrapper,
152        "#!/bin/sh\ncase \"$*\" in\n  *\" fetch \"*) echo \"fatal: cannot lock ref \
153         'refs/remotes/origin/main': File exists.\" >&2; exit 1 ;;\nesac\nexec git \"$@\"\n",
154    )
155    .expect("the wrapper");
156    std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).expect("+x");
157    crate::env::git_command::point_git_at(dir, &wrapper);
158}
159
160/// Point the repo at `dir` at a `git` that cannot answer the `origin`
161/// question at all: both `remote get-url` and the repo-presence probe refuse,
162/// while every other subcommand is the machine's own.
163#[cfg(test)]
164pub(super) fn unanswerable_origin_at(dir: &std::path::Path, bin: &std::path::Path) {
165    use std::os::unix::fs::PermissionsExt as _;
166
167    let wrapper = bin.join("unanswerable-origin-git");
168    std::fs::write(
169        &wrapper,
170        "#!/bin/sh\ncase \"$*\" in\n  *\" remote\"*) echo 'fatal: forced' >&2; exit 128 \
171         ;;\n  *\" rev-parse --git-dir\"*) echo 'fatal: forced' >&2; exit 128 ;;\n\
172         esac\nexec git \"$@\"\n",
173    )
174    .expect("the wrapper");
175    std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).expect("+x");
176    crate::env::git_command::point_git_at(dir, &wrapper);
177}
178
179/// The environment a test's `git` wrapper gives its child, so a case about a
180/// checkout that names no ssh command is not answered by the developer's own
181/// global config.
182#[cfg(test)]
183const ISOLATED_GIT_CONFIG: &str = "GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null";
184
185/// Write a `git` wrapper with one refusal on `fetch` and an optional stall on
186/// another subcommand, delegating everything else to the machine's own git,
187/// with the machine's own config kept out of reach.
188#[cfg(test)]
189fn write_wrapper(
190    dir: &std::path::Path,
191    bin: &std::path::Path,
192    name: &str,
193    stalls: &[(&str, &str)],
194    fetch_body: &str,
195) {
196    use std::fmt::Write as _;
197    use std::os::unix::fs::PermissionsExt as _;
198
199    let mut script = String::from("#!/bin/sh\ncase \"$*\" in\n");
200    writeln!(script, "  *\" fetch \"*) {fetch_body} ;;").expect("the wrapper's fetch arm");
201    for (subcommand, body) in stalls {
202        writeln!(script, "  *\" {subcommand} \"*) {body} ;;").expect("the wrapper's stall arm");
203    }
204    writeln!(script, "esac\n{ISOLATED_GIT_CONFIG} exec git \"$@\"")
205        .expect("the wrapper's fallback");
206    let wrapper = bin.join(name);
207    std::fs::write(&wrapper, script).expect("the wrapper");
208    std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).expect("+x");
209    crate::env::git_command::point_git_at(dir, &wrapper);
210}
211
212/// Point `dir`'s `git` at a wrapper that cannot see the machine's own config:
213/// the ssh classifier reads `core.sshCommand` from it, and a developer's global
214/// value would answer for the checkout under test.
215#[cfg(test)]
216fn without_machine_config_at(dir: &std::path::Path, bin: &std::path::Path) {
217    use std::os::unix::fs::PermissionsExt as _;
218
219    let script = format!("#!/bin/sh\n{ISOLATED_GIT_CONFIG} exec git \"$@\"\n");
220    let wrapper = bin.join("isolated-git");
221    std::fs::write(&wrapper, script).expect("the wrapper");
222    std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).expect("+x");
223    crate::env::git_command::point_git_at(dir, &wrapper);
224}
225
226/// A fetch a test can hold open, and the marks that prove what happened while
227/// it was held.
228#[cfg(test)]
229struct HeldFetch {
230    /// Exists while a fetch is blocked in the wrapper.
231    open: std::path::PathBuf,
232    /// Dropped by the test to let the blocked fetch run.
233    release: std::path::PathBuf,
234    /// One line per fetch started.
235    starts: std::path::PathBuf,
236    /// One line per `rev-parse --git-common-dir`, the registry key's own
237    /// probe: a second line is a second caller reaching the registry.
238    probes: std::path::PathBuf,
239}
240
241#[cfg(test)]
242impl HeldFetch {
243    fn starts(&self) -> usize {
244        std::fs::read_to_string(&self.starts).map(|text| text.lines().count()).unwrap_or_default()
245    }
246
247    fn probes(&self) -> usize {
248        std::fs::read_to_string(&self.probes).map(|text| text.lines().count()).unwrap_or_default()
249    }
250
251    fn open(&self) -> bool {
252        self.open.exists()
253    }
254
255    fn release(&self) {
256        std::fs::write(&self.release, b"go").expect("release the fetch");
257    }
258}
259
260/// Point `dir` at a `git` whose `fetch` marks itself, blocks until
261/// [`HeldFetch::release`], and then runs the real one; every
262/// `rev-parse --git-common-dir` marks itself too.
263#[cfg(test)]
264fn held_fetch_at(dir: &std::path::Path, bin: &std::path::Path) -> HeldFetch {
265    let open = bin.join("fetch-open");
266    let release = bin.join("fetch-release");
267    let starts = bin.join("fetch-starts");
268    let probes = bin.join("common-dir-probes");
269    let body = format!(
270        "echo start >> '{starts}'; : > '{open}'; \
271         while [ ! -e '{release}' ]; do /bin/sleep 0.02; done; exec git \"$@\"",
272        starts = starts.display(),
273        open = open.display(),
274        release = release.display(),
275    );
276    let probe = format!("echo probe >> '{}'", probes.display());
277    write_wrapper(dir, bin, "held-git", &[("rev-parse", &probe)], &body);
278    HeldFetch { open, release, starts, probes }
279}
280
281/// Poll `ready` until it answers, with a bound that fails the test rather than
282/// hanging it: every wait here is for a child to reach a step, and a loaded
283/// machine schedules it late.
284#[cfg(test)]
285async fn wait_for(ready: impl FnMut() -> bool, what: &str) {
286    wait_for_within(ready, what, Duration::from_secs(30)).await;
287}
288
289/// [`wait_for`] with its bound spelled: a wait that spans a kick's own children
290/// has to allow each the machine's budget, which a loaded machine can spend in
291/// full before the fetch it is waiting on even starts.
292#[cfg(test)]
293async fn wait_for_within(mut ready: impl FnMut() -> bool, what: &str, bound: Duration) {
294    let mut waited = Duration::ZERO;
295    while !ready() {
296        assert!(waited < bound, "waited for {what}");
297        tokio::time::sleep(Duration::from_millis(10)).await;
298        waited += Duration::from_millis(10);
299    }
300}
301
302/// Number of file rows surfaced in the rendered diff list. The
303/// renderer collapses the rest into a "+N more" line; the count above
304/// the list tells the truth either way.
305const TOP_FILE_COUNT: usize = 7;
306
307/// Whether a git repository is reachable from a scan cwd, and whether
308/// git could answer the question at all. The third variant matters:
309/// "git told me there is no repo" and "git would not run" must not
310/// collapse together, or a missing git binary reads as a non-repo and
311/// silently suppresses the GIT section instead of reporting itself.
312#[derive(Debug, Clone, Copy, PartialEq, Eq)]
313enum RepoPresence {
314    /// A repo exists here, even if it has no commits yet.
315    Present,
316    /// git ran, reported no repository, and there is no `.git` on disk
317    /// to contradict it.
318    Absent,
319    /// git could not be run, or it refused a checkout that does have a
320    /// `.git`. Either way the question went unanswered, so the caller
321    /// must report rather than suppress.
322    Unusable,
323}
324
325/// Ask whether `cwd` sits inside a git repo with `rev-parse --git-dir`,
326/// whose exit status answers repo-existence alone: zero inside any work
327/// tree including one with no commits, non-zero outside one and for a
328/// path that does not exist. Matching git's `fatal:` prose would not
329/// work here even setting version drift aside - a deleted `.git/HEAD`
330/// and a plain non-repo emit the same line.
331///
332/// A non-zero exit is then refined by whether `.git` is on disk: a
333/// pruned worktree entry, a deleted `HEAD` and a corrupt gitfile all
334/// exit 128 with `.git` still present, and each is a fault to report
335/// rather than an absent repo. Undeterminable biases to `Unusable`, so
336/// a broken checkout is never silently suppressed.
337///
338/// Not routed through [`run_git`]: a non-zero exit is the answer here
339/// rather than a fault, so it must neither be logged as a failure nor
340/// collapsed with "git would not run", which the caller has already
341/// logged with git's stderr.
342async fn repo_presence(cwd: &Path) -> RepoPresence {
343    repo_presence_within(cwd, COMMAND_TIMEOUT).await
344}
345
346/// [`repo_presence`] with its command budget spelled; see [`scan_within`].
347async fn repo_presence_within(cwd: &Path, budget: Duration) -> RepoPresence {
348    let mut command = git_command::tokio_git_command(cwd);
349    command.arg("-C").arg(cwd).args(["rev-parse", "--git-dir"]).kill_on_drop(true);
350    match timeout(budget, command.output()).await {
351        Ok(Ok(output)) if output.status.success() => RepoPresence::Present,
352        Ok(Ok(_)) if dot_git_present(cwd) => RepoPresence::Unusable,
353        Ok(Ok(_)) => RepoPresence::Absent,
354        Ok(Err(_)) | Err(_) => RepoPresence::Unusable,
355    }
356}
357
358/// Whether `.git` is on disk, without following symlinks - a dangling
359/// `.git` symlink is a broken checkout rather than an absent repo, and
360/// `Path::try_exists` reports it as missing because it stats the target.
361/// Anything but a definite "not found" counts as present, so an
362/// unreadable parent biases to [`RepoPresence::Unusable`] as well.
363fn dot_git_present(cwd: &Path) -> bool {
364    !matches!(
365        cwd.join(".git").symlink_metadata(),
366        Err(err) if err.kind() == std::io::ErrorKind::NotFound
367    )
368}
369
370/// Classify `git rev-parse --abbrev-ref HEAD` output into either the
371/// branch string to proceed with, or the terminal `repo_gate`. Empty
372/// stdout is a clean "not a repo" signal (git ran and reported it);
373/// Oversize means the scan subprocess itself misbehaved.
374///
375/// `presence` resolves the non-zero-exit case, which is ambiguous on
376/// its own: git exits 128 both outside a repo and when it fails inside
377/// one, so only the narrower repo-existence question separates a
378/// legitimate non-repo from a sick scanner.
379fn classify_rev_parse(output: GitOutput, presence: RepoPresence) -> Result<String, RepoGate> {
380    match output {
381        GitOutput::Ok(s) => Ok(s.trim().to_owned()),
382        GitOutput::Empty => Err(RepoGate::NotARepo),
383        GitOutput::Failed if presence == RepoPresence::Absent => Err(RepoGate::NotARepo),
384        GitOutput::Failed | GitOutput::Oversize => Err(RepoGate::ScannerFailed),
385    }
386}
387
388/// Resolve the rev-parse gate for `cwd`: the raw branch string, or the
389/// terminal `repo_gate`. The repo-existence probe runs only on a
390/// non-zero exit, so the healthy path still costs a single subprocess.
391async fn rev_parse_gate(cwd: &Path, budget: Duration) -> Result<String, RepoGate> {
392    let output = run_git_within(cwd, &["rev-parse", "--abbrev-ref", "HEAD"], budget).await;
393    let presence = if matches!(output, GitOutput::Failed) {
394        repo_presence_within(cwd, budget).await
395    } else {
396        RepoPresence::Present
397    };
398    classify_rev_parse(output, presence)
399}
400
401/// Minimum gap between PR-lookup attempts for one branch+sha, even
402/// when the attempt found nothing or failed - without this a cached
403/// `None` would stick for the branch's whole life (a PR opened after
404/// the session's first scan would never show) and a persistent `gh`
405/// failure would hammer every 10 s scan.
406const PR_REFETCH_INTERVAL: Duration = Duration::from_secs(300);
407
408/// Max commits the pushed-sha walk examines. Bounds the pathological
409/// case (a deep unpushed history) so one scan can't walk the whole
410/// repo; 500 first-parent commits is generous headroom over any real
411/// worktree's unpushed depth.
412const PUSHED_SHA_WALK_CAP: usize = 500;
413
414/// The kick's own cadence. The chain stays honest while no review page is
415/// open because a scan freshens the ref for the next one; throttled so a burst
416/// of scans fetches at most once a window.
417const WATCH_REFRESH_INTERVAL: Duration = Duration::from_secs(240);
418
419/// The budget a drawing read's background refresh gets.
420///
421/// **Longer than a scan's child budget on purpose**: forge's own ssh command
422/// gives up at `ConnectTimeout=15`, so a 10 s bound would cut the fetch before
423/// git could name its failure and the page would draw "could not be checked"
424/// with no reason. A fetch that outlives even this measures nothing, and a
425/// cut run is not stored as a check.
426const DISPLAY_FETCH_BUDGET: Duration = Duration::from_secs(30);
427
428/// Per-checkout last refresh, keyed by the scan cwd.
429///
430/// **The cadence is per checkout rather than per repository by choice**: a
431/// worktree's own scan is its own read, and two checkouts of one repo
432/// refreshing their shared ref twice a window is cheap. The run registry below
433/// is the one that must be per repository.
434static LAST_WATCH_REFRESH: OnceLock<Mutex<HashMap<PathBuf, Instant>>> = OnceLock::new();
435
436/// One base refresh in flight per repository and base branch, shared by every
437/// kick that arrives while it runs: two checkouts of one repository fetch the
438/// same ref once. Weak, so a repository nobody is refreshing for leaves
439/// nothing behind; dead entries are pruned on the next look.
440static BASE_RUNS: OnceLock<Runs> = OnceLock::new();
441
442/// A run's address: the repository's common dir and the base branch.
443type RunKey = (PathBuf, String);
444
445/// Every run in flight, weak so a repository nobody is refreshing for leaves
446/// nothing behind.
447type Runs = Mutex<HashMap<RunKey, std::sync::Weak<BaseRun>>>;
448
449/// One base refresh in flight, and the answer it will publish.
450struct BaseRun {
451    answer: tokio::sync::watch::Sender<Option<BaseRefresh>>,
452}
453
454impl BaseRun {
455    fn new() -> std::sync::Arc<Self> {
456        let (answer, _) = tokio::sync::watch::channel(None);
457        std::sync::Arc::new(Self { answer })
458    }
459
460    fn publish(&self, refresh: BaseRefresh) {
461        // **`send_replace`, not `send`.** tokio's `send` throws the value away
462        // when no receiver is subscribed, which is the ordinary case for a
463        // leader nobody joined; a caller that upgrades this run after that
464        // would then wait on a channel whose value is still `None` and never
465        // wake, wedging every later refresh for the repository.
466        self.answer.send_replace(Some(refresh));
467    }
468
469    async fn wait(&self) -> BaseRefresh {
470        let mut answer = self.answer.subscribe();
471        if let Some(held) = answer.borrow().clone() {
472            return held;
473        }
474        // The value is stored rather than only handed to a waiter, so a
475        // publish between the borrow above and this await is still read here.
476        let _ = answer.changed().await;
477        answer.borrow().clone().unwrap_or_else(|| BaseRefresh {
478            state: BaseState::Unverified,
479            base_ref: None,
480            error: Some("the base refresh ended without an answer".to_owned()),
481            cut: false,
482            unanswered: true,
483            at: Instant::now(),
484        })
485    }
486}
487
488/// The run for `key`: the one already in flight (or its answer, when it has
489/// finished and a caller still holds it), or a new one the caller leads.
490fn join_base_run(key: RunKey) -> (std::sync::Arc<BaseRun>, bool) {
491    let mut runs = BASE_RUNS.get_or_init(|| Mutex::new(HashMap::new())).lock();
492    runs.retain(|_, weak| weak.strong_count() > 0);
493    if let Some(run) = runs.get(&key).and_then(std::sync::Weak::upgrade) {
494        return (run, false);
495    }
496    lead_base_run(&mut runs, key)
497}
498
499fn lead_base_run(
500    runs: &mut HashMap<RunKey, std::sync::Weak<BaseRun>>,
501    key: RunKey,
502) -> (std::sync::Arc<BaseRun>, bool) {
503    let run = BaseRun::new();
504    runs.insert(key, std::sync::Arc::downgrade(&run));
505    (run, true)
506}
507
508/// Publish the leader's answer, or a failure if the leader ended without one:
509/// a caller waiting on a run whose leader panicked or was cancelled must not
510/// wait for the process's life.
511struct LeaderGuard {
512    run: std::sync::Arc<BaseRun>,
513    published: bool,
514}
515
516impl LeaderGuard {
517    fn new(run: std::sync::Arc<BaseRun>) -> Self {
518        Self { run, published: false }
519    }
520
521    fn publish(&mut self, refresh: BaseRefresh) {
522        self.run.publish(refresh);
523        self.published = true;
524    }
525}
526
527impl Drop for LeaderGuard {
528    fn drop(&mut self) {
529        if self.published {
530            return;
531        }
532        self.run.publish(BaseRefresh {
533            state: BaseState::Unverified,
534            base_ref: None,
535            error: Some("the base refresh ended without an answer".to_owned()),
536            cut: false,
537            unanswered: true,
538            at: Instant::now(),
539        });
540    }
541}
542
543/// One completed base refresh: when it measured, what it found, and git's own
544/// words when it could not run.
545///
546/// **The words ride the measurement** because a page that drew the base
547/// unchecked is owed the reason the background fetch gave, and the only path
548/// to it is the measurement this store holds.
549#[derive(Debug, Clone, PartialEq, Eq)]
550pub struct BaseMeasurement {
551    pub at: Instant,
552    pub state: BaseState,
553    pub error: Option<String>,
554}
555
556/// What each checkout's last completed refresh measured, so a scan can carry
557/// it onto the seat's row without waiting for the fetch itself. Pruned on the
558/// same window as the kick, and the row keeps its stored base when an entry
559/// goes.
560///
561/// **Keyed by the base branch as well as the checkout**: one worktree can draw
562/// two bases (a release branch against `origin/main`, then a hotfix against
563/// `origin/develop`), and a measurement of one must never answer the other.
564static LAST_MEASURED: OnceLock<Mutex<HashMap<(PathBuf, String), BaseMeasurement>>> =
565    OnceLock::new();
566
567fn remember_measurement(cwd: &Path, default_branch: &str, refresh: &BaseRefresh) {
568    // **A run that ended without answering measured nothing**, and neither did
569    // one a bound cut: the first is a placeholder for a fetch that never
570    // reported, the second an unchecked marker for one that outlived its
571    // budget. Storing either would draw "could not be checked" on the next
572    // scan's row and answer a later reading with a check nobody made.
573    if refresh.unanswered || refresh.cut {
574        return;
575    }
576    let mut held = LAST_MEASURED.get_or_init(|| Mutex::new(HashMap::new())).lock();
577    let now = Instant::now();
578    held.retain(|_, measured| measurement_in_window(measured, now));
579    held.insert(
580        (cwd.to_path_buf(), default_branch.to_owned()),
581        BaseMeasurement {
582            // **The instant the answer's state was established**, not the
583            // instant it was recorded: a read that joined a run stores the
584            // same instant its own row carries, so the watch does not rescan
585            // for a measurement it has already adopted.
586            at: refresh.at,
587            state: refresh.state,
588            error: refresh.error.clone(),
589        },
590    );
591}
592
593/// This checkout's last completed refresh of `default_branch`. `None` until
594/// one has completed; a reader applies `measurement_in_window` itself, since
595/// the store only prunes on write.
596pub fn last_base_measurement(cwd: &Path, default_branch: &str) -> Option<BaseMeasurement> {
597    LAST_MEASURED
598        .get_or_init(|| Mutex::new(HashMap::new()))
599        .lock()
600        .get(&(cwd.to_path_buf(), default_branch.to_owned()))
601        .cloned()
602}
603
604/// Point `cwd`'s `git` spawns at `program`: the `testing` feature's seam, so a
605/// crate whose fixtures need a held child drives the same wrapper this crate's
606/// own tests do. Pair with [`clear_git_point_for_tests`].
607#[cfg(any(test, feature = "testing"))]
608pub fn point_git_at_for_tests(cwd: &Path, program: &Path) {
609    crate::env::git_command::point_git_at(cwd, program);
610}
611
612/// Drop `cwd`'s pointer, back to the machine's `git`.
613#[cfg(any(test, feature = "testing"))]
614pub fn clear_git_point_for_tests(cwd: &Path) {
615    crate::env::git_command::clear_git_point(cwd);
616}
617
618/// The repository a checkout belongs to, as the fetch gate's key: git's
619/// common dir, canonicalised.
620///
621/// **Keyed on the common dir rather than the scan cwd because linked
622/// worktrees share `refs/remotes/*`.** Two seats in `<repo>` and
623/// `<repo>/.claude/worktrees/x` - this project's normal layout - contend for
624/// one ref lock, so a gate keyed per cwd would let both fetch at once and
625/// misattribute the loser's refusal. A path git cannot answer for falls back
626/// to the cwd: a separate gate cannot deadlock, it only fails to serialise.
627async fn repo_key(cwd: &Path, budget: Duration) -> PathBuf {
628    let common = match run_git_within(cwd, &["rev-parse", "--git-common-dir"], budget).await {
629        GitOutput::Ok(path) => path.trim().to_owned(),
630        GitOutput::Empty | GitOutput::Failed | GitOutput::Oversize => return cwd.to_path_buf(),
631    };
632    let joined = cwd.join(common);
633    joined.canonicalize().unwrap_or(joined)
634}
635
636/// Whether a watch refresh is due: none yet, or the last one is `window` old.
637fn refresh_due(last: Option<Instant>, now: Instant, window: Duration) -> bool {
638    match last {
639        None => true,
640        Some(last) => now.saturating_duration_since(last) >= window,
641    }
642}
643
644/// Claim a kick's slot for `key`, stamping `now` when it is due, so two scans
645/// cannot both fetch.
646fn claim_refresh_slot(key: &Path, now: Instant, window: Duration) -> bool {
647    let mut map = LAST_WATCH_REFRESH.get_or_init(|| Mutex::new(HashMap::new())).lock();
648    // A checkout nobody has scanned for a window is not a slot to keep: the
649    // entry would outlive the project it names for the life of the process.
650    map.retain(|_, last| now.saturating_duration_since(*last) < window);
651    let due = refresh_due(map.get(key).copied(), now, window);
652    if due {
653        map.insert(key.to_path_buf(), now);
654    }
655    due
656}
657
658/// Stamp `key`'s slot whatever it held: a refresh that just ran left the ref
659/// fresh, so a kick inside the window has nothing to fetch.
660fn stamp_refresh_slot(key: &Path, now: Instant) {
661    LAST_WATCH_REFRESH
662        .get_or_init(|| Mutex::new(HashMap::new()))
663        .lock()
664        .insert(key.to_path_buf(), now);
665}
666
667/// Handles for the background fetches kicked so far, so a test can settle an
668/// in-flight fetch before its fixture directory is removed. A test that drops
669/// its guard while `git fetch` is still writing into the tree aborts the
670/// removal part-way and leaves the rest of the directory behind.
671///
672/// Per process and never pruned, so under libtest's threaded runner one test's
673/// settle drains another's handle. nextest, which this repo runs, gives each
674/// test a process of its own.
675#[cfg(test)]
676static FETCH_TASKS: OnceLock<Mutex<Vec<tokio::task::JoinHandle<()>>>> = OnceLock::new();
677
678/// Hand a kicked fetch's handle to the test-only registry above. A production
679/// build discards it: the fetch is fire-and-forget, which is what the scan
680/// promises its caller.
681fn track_background_fetch(fetch: tokio::task::JoinHandle<()>) {
682    #[cfg(test)]
683    FETCH_TASKS.get_or_init(|| Mutex::new(Vec::new())).lock().push(fetch);
684    #[cfg(not(test))]
685    drop(fetch);
686}
687
688/// Await every background fetch kicked so far, and report how many that was.
689/// The count is the caller's guard against a silent no-op: a fixture that
690/// stops resolving a remote-tracking default, or a window that swallows the
691/// kick, would leave nothing to settle and the teardown racing an unseen
692/// child again.
693#[cfg(test)]
694async fn settle_background_fetches() -> usize {
695    let tasks = std::mem::take(&mut *FETCH_TASKS.get_or_init(|| Mutex::new(Vec::new())).lock());
696    let settled = tasks.len();
697    for task in tasks {
698        let _ = task.await;
699    }
700    settled
701}
702
703/// Kick a throttled, non-blocking base refresh so the remote-tracking ref the
704/// branch-ahead layer compares against is fresher for the NEXT scan. Only fires
705/// for a remote-tracking default (`origin/...`); a purely-local default has no
706/// remote to fetch. Failures log from [`fetch_base`], which owns the one fetch
707/// path with its flags and lock line.
708///
709/// **Nobody waits on this refresh**, and it may not spend a reader's wait: a
710/// drawing read that arrives while it runs answers what it already holds and
711/// lets this run's answer serve the next read (see
712/// [`refresh_base_for_display`]) rather than joining it. A second kick for the
713/// same repository joins the run rather than fetching again, and `budget`
714/// bounds the run, so a hung remote cannot leave a child behind.
715///
716/// **`scan_within` is the one scan that can serve a seat with no loop**: its
717/// record read would otherwise re-read the tree for ever against a ref
718/// nothing ever refreshes.
719fn kick_background_fetch(cwd: &Path, default_branch: &str, budget: Duration) {
720    if !default_branch.starts_with("origin/") {
721        return;
722    }
723    if !claim_refresh_slot(cwd, Instant::now(), WATCH_REFRESH_INTERVAL) {
724        return;
725    }
726    let cwd = cwd.to_path_buf();
727    let branch = default_branch.to_owned();
728    track_background_fetch(tokio::spawn(async move {
729        let _ = refresh_base(&cwd, &branch, budget).await;
730    }));
731}
732
733/// What one base refresh answered.
734#[derive(Debug, Clone, PartialEq, Eq)]
735pub struct BaseRefresh {
736    /// How current the base is.
737    pub state: BaseState,
738    /// The sha the base resolved to after the refresh. Both the stats scan and
739    /// the content read are taken against this one sha, so a count and a file
740    /// list can never describe two different refs.
741    pub base_ref: Option<String>,
742    /// Why the fetch did not run, in git's own first line (or forge's words
743    /// for a child it could not start or had to bound). `None` when it ran.
744    /// The record carries this so a page can say what failed rather than only
745    /// that the base could not be checked.
746    pub error: Option<String>,
747    /// The run's own bound ended the fetch. A cut run measured nothing, so its
748    /// answer is never stored as the checkout's check.
749    pub cut: bool,
750    /// The run ended without answering - its leader panicked or was cancelled.
751    /// The state and error are a placeholder rather than a measurement, and no
752    /// scan's row carries them.
753    pub unanswered: bool,
754    /// **When this answer's state was established**: the instant a completed
755    /// refresh measured it, the instant a drawing read decided to answer the
756    /// base unchecked, or the instant the stored measurement being reused was
757    /// taken. A scan stamps the row's `base_at` from this rather than from its
758    /// own write, so a measurement that lands while the row is being stored is
759    /// still newer than the place it must land in.
760    pub at: Instant,
761}
762
763/// Answer the base a drawn branch diff is measured against, and keep it fresh
764/// off the read.
765///
766/// **Called by the read that draws the diff, never by a record read.** A state
767/// carried on a stored scan would be as old as that scan and could call a base
768/// current after the remote had moved on, which is the stale-diff defect
769/// itself.
770///
771/// **A remote-tracking base is never waited on.** The read answers the last
772/// completed check the store holds, or the base unchecked when it holds none,
773/// pinned to the local tip the diff is read at, and kicks a throttled
774/// background refresh either way; that run's measurement answers the next read
775/// (#2818). A local base has no fetch to run: the probes that say whether it
776/// can lag behind anything answer here, as they did before the refresh became
777/// a kick.
778pub async fn refresh_base_for_display(cwd: &Path, default_branch: &str) -> BaseRefresh {
779    let decided_at = Instant::now();
780    if !default_branch.starts_with("origin/") {
781        let deadline = Some(Instant::now() + COMMAND_TIMEOUT);
782        let (state, base_ref) = local_base(cwd, default_branch, deadline).await;
783        return BaseRefresh {
784            state,
785            base_ref,
786            error: None,
787            cut: false,
788            unanswered: false,
789            at: decided_at,
790        };
791    }
792    // **Kicked before the local reads below**, so the fetch runs beside them
793    // rather than after. The slot is claimed synchronously: a run another
794    // checkout of this repository already kicked is joined, not repeated, and
795    // one this checkout fetched for inside the window adds nothing.
796    kick_background_fetch(cwd, default_branch, DISPLAY_FETCH_BUDGET);
797    stored_base(cwd, default_branch, decided_at).await
798}
799
800/// Answer the last completed check the store holds, or the base unchecked,
801/// pinned to the local tip read beside it.
802///
803/// **A run that published while the tip was being read still counts**, as long
804/// as it completed before that tip was read: its fetch wrote the ref the tip
805/// names, so the pair is one base. The store is re-read after the tip for that
806/// reason, and a measurement stamped after the tip is left for the next read
807/// rather than paired with a ref it does not describe.
808async fn stored_base(cwd: &Path, default_branch: &str, decided_at: Instant) -> BaseRefresh {
809    let measured = last_base_measurement(cwd, default_branch)
810        .filter(|measured| measurement_in_window(measured, decided_at));
811    let base_ref = local_tip(cwd, default_branch, COMMAND_TIMEOUT).await;
812    let tip_at = Instant::now();
813    let measured = measured.or_else(|| {
814        last_base_measurement(cwd, default_branch)
815            .filter(|measured| measured.at <= tip_at && measurement_in_window(measured, tip_at))
816    });
817    match measured {
818        // **The instant the check was made**, not this read's: a run
819        // publishing while the read is answered must still land newer than
820        // the place this answer is stored.
821        Some(measured) => answer_measurement(measured, base_ref),
822        None => BaseRefresh {
823            state: BaseState::Unverified,
824            base_ref,
825            error: None,
826            cut: false,
827            unanswered: false,
828            at: decided_at,
829        },
830    }
831}
832
833/// Answer the base by running the refresh on this call: **the one path that
834/// waits**, for a seat with no session.
835///
836/// A sessionless seat has no watch, so nothing would deliver a kicked check to
837/// its page: the read is the only thing that can carry it. The slot is claimed
838/// first, so a second open inside the window answers the stored check rather
839/// than fetching again (#2818).
840pub async fn refresh_base_now(cwd: &Path, default_branch: &str) -> BaseRefresh {
841    let decided_at = Instant::now();
842    if !default_branch.starts_with("origin/") {
843        let deadline = Some(Instant::now() + COMMAND_TIMEOUT);
844        let (state, base_ref) = local_base(cwd, default_branch, deadline).await;
845        return BaseRefresh {
846            state,
847            base_ref,
848            error: None,
849            cut: false,
850            unanswered: false,
851            at: decided_at,
852        };
853    }
854    if claim_refresh_slot(cwd, decided_at, WATCH_REFRESH_INTERVAL) {
855        // **The display budget, not the scan's**: forge's own ssh gives up at
856        // `ConnectTimeout=15` and http at `lowSpeedTime=20`, so a ten-second
857        // bound would cut the fetch before git could name its failure and the
858        // page would draw "could not be checked" with no reason.
859        let refresh = refresh_base(cwd, default_branch, DISPLAY_FETCH_BUDGET).await;
860        give_back_slot(cwd, &refresh);
861        return refresh;
862    }
863    // **The slot is another run's.** Answer the check it left when there is
864    // one; otherwise wait on a run - a sessionless page has no watch to heal
865    // an answer that names no check.
866    if let Some(measured) = last_base_measurement(cwd, default_branch)
867        .filter(|measured| measurement_in_window(measured, decided_at))
868    {
869        let base_ref = local_tip(cwd, default_branch, COMMAND_TIMEOUT).await;
870        answer_measurement(measured, base_ref)
871    } else {
872        let refresh = refresh_base(cwd, default_branch, DISPLAY_FETCH_BUDGET).await;
873        give_back_slot(cwd, &refresh);
874        refresh
875    }
876}
877
878/// The answer a stored check gives a drawing read: its state, words and
879/// instant, pinned to the local tip the caller read beside it.
880fn answer_measurement(measured: BaseMeasurement, base_ref: Option<String>) -> BaseRefresh {
881    BaseRefresh {
882        state: if base_ref.is_some() { measured.state } else { BaseState::Unverified },
883        base_ref,
884        error: measured.error,
885        cut: false,
886        unanswered: false,
887        at: measured.at,
888    }
889}
890
891/// **A run that answered no check gives the slot back.** A cut or aborted run
892/// measured nothing; leaving its claim stamped would answer silence for the
893/// whole window on the one seat whose read is what heals it.
894fn give_back_slot(cwd: &Path, refresh: &BaseRefresh) {
895    if !refresh.cut && !refresh.unanswered {
896        return;
897    }
898    if let Some(slots) = LAST_WATCH_REFRESH.get() {
899        slots.lock().remove(cwd);
900    }
901}
902
903/// Whether a measurement still answers a drawing read: the store's own window,
904/// which is how long the store keeps one.
905fn measurement_in_window(measured: &BaseMeasurement, now: Instant) -> bool {
906    now.saturating_duration_since(measured.at) < WATCH_REFRESH_INTERVAL
907}
908
909/// [`refresh_base_for_display`] with a bound: a test gives the refresh one the
910/// machine's load cannot eat, since a budget spent starting a child is the
911/// machine's bound rather than the test's subject. Production's bounds are the
912/// kick's (the scan's child budget) and a sessionless read's (the display
913/// budget), both reaching [`refresh_base`].
914#[cfg(test)]
915async fn refresh_base_within(cwd: &Path, default_branch: &str, budget: Duration) -> BaseRefresh {
916    refresh_base(cwd, default_branch, budget).await
917}
918
919/// The one refresh path: one run per repository and base branch, joined by a
920/// kick that arrives while it works, and its answer stored as the checkout's
921/// measurement for the reads that follow.
922async fn refresh_base(cwd: &Path, default_branch: &str, budget: Duration) -> BaseRefresh {
923    let key = (repo_key(cwd, COMMAND_TIMEOUT).await, default_branch.to_owned());
924    let refresh = refresh_once(cwd, default_branch, budget, key).await;
925    remember_measurement(cwd, default_branch, &refresh);
926    refresh
927}
928
929/// Join (or lead) one run for `key` and answer it. A kick that arrives while
930/// another works joins it rather than fetching the same base twice.
931async fn refresh_once(
932    cwd: &Path,
933    default_branch: &str,
934    budget: Duration,
935    key: RunKey,
936) -> BaseRefresh {
937    let (run, leader) = join_base_run(key);
938    if leader {
939        let mut guard = LeaderGuard::new(std::sync::Arc::clone(&run));
940        let refresh = refresh_base_owned(cwd, default_branch, budget).await;
941        guard.publish(refresh.clone());
942        refresh
943    } else {
944        run.wait().await
945    }
946}
947
948/// One refresh's own work: the fetch, then the state read off the ref that
949/// fetch wrote, every step sharing `budget`.
950async fn refresh_base_owned(cwd: &Path, default_branch: &str, budget: Duration) -> BaseRefresh {
951    let deadline = Some(Instant::now() + budget);
952    // **The user's own ssh command wins**, whichever spelling holds it: forge
953    // sets `GIT_SSH_COMMAND` only when nothing else names one.
954    let ssh = if ssh_is_forges(cwd).await {
955        git_command::SshCommand::Forge
956    } else {
957        git_command::SshCommand::User
958    };
959    // **A local base never reaches here in production**: both callers branch
960    // on a remote-tracking default first and answer a local base from
961    // [`local_base`]. This crate's bounded tests drive the refresh directly
962    // with one, and they get the same probes rather than a fetch.
963    let Some(remote_branch) = default_branch.strip_prefix("origin/") else {
964        let (state, base_ref) = local_base(cwd, default_branch, deadline).await;
965        return BaseRefresh {
966            state,
967            base_ref,
968            error: None,
969            cut: false,
970            unanswered: false,
971            at: Instant::now(),
972        };
973    };
974    let fetched = fetch_base(cwd, remote_branch, deadline, budget, ssh).await;
975    if fetched.error.is_none() {
976        // **The ref is fresh now.** Stamping the kick's window here is what
977        // keeps a scan seconds later from fetching the same answer again.
978        stamp_refresh_slot(cwd, Instant::now());
979    }
980    // **A successful fetch is the answer, so the remote is not asked
981    // again.** `fetch_base` carries an explicit
982    // `+refs/heads/<b>:refs/remotes/origin/<b>` refspec, which writes that
983    // destination whatever the repository's own `remote.origin.fetch`
984    // says, and a fetch that cannot find the ref exits non-zero. So exit 0
985    // leaves the ref at the remote's tip and the base is current by
986    // construction. This replaces the post-fetch `ls-remote` that guarded
987    // against a fetch writing nothing: the explicit refspec is what makes
988    // that guard unnecessary. A fetch that failed wrote nothing, and no
989    // remote tip is read to count a lag against, so its base is answered
990    // unchecked with git's own words rather than with a count.
991    let base_ref = local_tip(cwd, default_branch, remaining(deadline)).await;
992    let state = if fetched.error.is_none() && base_ref.is_some() {
993        BaseState::Current
994    } else {
995        BaseState::Unverified
996    };
997    BaseRefresh {
998        state,
999        base_ref,
1000        error: fetched.error,
1001        cut: fetched.cut,
1002        unanswered: false,
1003        at: Instant::now(),
1004    }
1005}
1006
1007/// The state of a base that names no remote-tracking ref, and the sha it
1008/// resolves to: the probes a local base owes.
1009///
1010/// A local base has no ref to fetch. With an `origin` remote it can still be
1011/// arbitrarily behind one nobody ever fetched, and there is no
1012/// remote-tracking ref to name that lag against, so the base is not claimed
1013/// rather than silently called local. **A base that tracks a remote of its
1014/// own can lag too**: a local `main` whose upstream is `upstream/main` is
1015/// stale exactly as an `origin/main` would be, and calling it local would be
1016/// the same silence.
1017async fn local_base(
1018    cwd: &Path,
1019    default_branch: &str,
1020    deadline: Option<Instant>,
1021) -> (BaseState, Option<String>) {
1022    let local = local_tip(cwd, default_branch, remaining(deadline)).await;
1023    let state = match origin_remote(cwd, deadline).await {
1024        OriginRemote::Present => BaseState::Unverified,
1025        OriginRemote::Absent => {
1026            match local_base_tracks_upstream(cwd, default_branch, deadline).await {
1027                Some(false) => BaseState::Local,
1028                Some(true) | None => BaseState::Unverified,
1029            }
1030        }
1031        // **A probe that never answered must not answer "no remote".** That is
1032        // the silent stale claim by another name: the base is drawn as purely
1033        // local while an `origin` it may lag behind sits right there.
1034        OriginRemote::Unanswered(reason) => {
1035            tracing::debug!(
1036                target: crate::logging::targets::ENV_GIT,
1037                cwd = %cwd.display(),
1038                event_name = "git_base_origin_probe_unanswered",
1039                message = "the origin probe could not answer, so a local base is not claimed",
1040                outcome = "unverified",
1041                reason = %reason,
1042                base = %default_branch,
1043            );
1044            BaseState::Unverified
1045        }
1046    };
1047    (state, local)
1048}
1049
1050/// Whether forge builds the ssh command for this checkout's network children:
1051/// true when the environment names none and the repository's own
1052/// `core.sshCommand` is unset, because `GIT_SSH_COMMAND` outranks both.
1053async fn ssh_is_forges(cwd: &Path) -> bool {
1054    if std::env::var_os("GIT_SSH_COMMAND").is_some() || std::env::var_os("GIT_SSH").is_some() {
1055        return false;
1056    }
1057    // **`git config --get` on a missing key EXITS 1**, which the classified
1058    // reader reports as a failure, not as an empty answer: the exit code is
1059    // what tells "this checkout names no command" from "git could not
1060    // answer", and only the first may install forge's.
1061    let mut command = git_command::tokio_git_command(cwd);
1062    command.arg("-C").arg(cwd).args(["config", "--get", "core.sshCommand"]).kill_on_drop(true);
1063    match child_output(command.output(), COMMAND_TIMEOUT).await {
1064        // The key is set. An empty value is git's own "unset", so it is not.
1065        Some(Ok(out)) if out.status.success() => out.stdout.iter().all(u8::is_ascii_whitespace),
1066        // Exit 1 with nothing on stdout: no such key.
1067        Some(Ok(out)) if out.status.code() == Some(1) && out.stdout.is_empty() => true,
1068        // A probe that did not answer leaves the question open, and forge does
1069        // not overwrite a command it could not read.
1070        _ => false,
1071    }
1072}
1073
1074/// Fetch one remote-tracking branch without touching the rest of the user's
1075/// checkout: an explicit refspec with its destination, so a remote whose own
1076/// fetch refspec differs still updates the ref this layer reads;
1077/// `--no-auto-gc` so a killed child leaves no maintenance daemon behind;
1078/// `--no-write-fetch-head` so their `FETCH_HEAD` is not clobbered on every
1079/// open; and `--quiet` so progress never rides a captured stream.
1080///
1081/// Answers what the child did: `error` is `None` when git fetched and git's own
1082/// words when it did not, and `cut` says the run's bound ended it.
1083async fn fetch_base(
1084    cwd: &Path,
1085    remote_branch: &str,
1086    deadline: Option<Instant>,
1087    budget: Duration,
1088    ssh: git_command::SshCommand,
1089) -> Fetched {
1090    let refspec = format!("+refs/heads/{remote_branch}:refs/remotes/origin/{remote_branch}");
1091    let mut command = git_command::network_tokio_git_command(cwd, ssh);
1092    command
1093        .arg("-C")
1094        .arg(cwd)
1095        .args(["fetch", "--no-auto-gc", "--no-write-fetch-head", "--quiet", "origin", &refspec])
1096        .kill_on_drop(true);
1097    // **A bounded run kills the child, and a fetch killed while it holds a ref
1098    // lock leaves `<ref>.lock` behind.** The window is between the lock being
1099    // taken and the ref renamed - microseconds - and the alternative is a hung
1100    // remote keeping a child per open. A stranded lock is not cleaned up here:
1101    // removing a lock another git may hold is worse than naming it, which the
1102    // lock WARN below does.
1103    let left = remaining(deadline);
1104    match child_output(command.output(), left).await {
1105        Some(Ok(out)) if out.status.success() => Fetched { error: None, cut: false },
1106        Some(Ok(out)) => {
1107            let stderr = String::from_utf8_lossy(&out.stderr);
1108            let first = stderr.lines().next().unwrap_or_default();
1109            if is_lock_failure(&stderr) {
1110                // **A ref lock is the environment's mark in the user's tree**,
1111                // and nothing here can say whose: the user's own git, another
1112                // forge instance on the same repo, and a killed child's
1113                // stranded lock all read the same. DEBUG for that reason
1114                // (rule 20): the line names the repo, the branch and git's own
1115                // words, which carry the lock file's path - everything acting
1116                // on it needs, without claiming it is forge's health.
1117                tracing::debug!(
1118                    target: crate::logging::targets::ENV_GIT,
1119                    cwd = %cwd.display(),
1120                    event_name = "git_base_fetch_lock_refused",
1121                    message = "the base fetch could not take a ref lock in the checkout",
1122                    outcome = "failure",
1123                    branch = %remote_branch,
1124                    stderr = %first,
1125                );
1126            } else {
1127                tracing::debug!(
1128                    target: crate::logging::targets::ENV_GIT,
1129                    cwd = %cwd.display(),
1130                    event_name = "git_base_fetch_nonzero_exit",
1131                    message = "the base fetch exited non-zero",
1132                    outcome = "failure",
1133                    exit_code = out.status.code().unwrap_or(-1),
1134                    branch = %remote_branch,
1135                    stderr = %first,
1136                );
1137            }
1138            // **Redact first, then the cap.** A cap that lands inside a
1139            // credential's userinfo removes the `@` the redactor needs, and
1140            // the credential would reach the record whole.
1141            Fetched {
1142                error: Some(clipped(&redact(&first_line(&out.stderr)), FAILURE_WORDS_CAP)),
1143                cut: false,
1144            }
1145        }
1146        Some(Err(err)) => {
1147            tracing::debug!(
1148                target: crate::logging::targets::ENV_GIT,
1149                cwd = %cwd.display(),
1150                event_name = "git_base_fetch_failed",
1151                message = "the base fetch spawn / wait failed",
1152                outcome = "failure",
1153                error = %err,
1154                branch = %remote_branch,
1155            );
1156            Fetched { error: Some(format!("git could not be run: {err}")), cut: false }
1157        }
1158        None => {
1159            // **The run's budget, not what was left of it**: a run that spent
1160            // its bound before the child started would otherwise say "0s".
1161            let given = budget.as_secs_f64();
1162            tracing::debug!(
1163                target: crate::logging::targets::ENV_GIT,
1164                cwd = %cwd.display(),
1165                event_name = "git_base_fetch_timeout",
1166                message = "the base fetch did not finish inside the budget it was given",
1167                outcome = "timeout",
1168                branch = %remote_branch,
1169                budget_s = given,
1170            );
1171            let words = format!("the fetch did not finish inside the {given}s it was given");
1172            Fetched { error: Some(words), cut: true }
1173        }
1174    }
1175}
1176
1177/// What one fetch's child answered.
1178struct Fetched {
1179    /// Why it did not run, in git's own words; `None` when it ran.
1180    error: Option<String>,
1181    /// The run's own bound ended it.
1182    cut: bool,
1183}
1184
1185/// Git's own words for a failed child: the first non-empty stderr line,
1186/// uncapped so the redactor can see the whole URL before anything is cut.
1187fn first_line(stderr: &[u8]) -> String {
1188    let text = String::from_utf8_lossy(stderr);
1189    text.lines()
1190        .map(str::trim)
1191        .find(|line| !line.is_empty())
1192        .unwrap_or("git exited non-zero without saying why")
1193        .to_owned()
1194}
1195
1196/// Carried characters of git's failure line. A page draws it in one sentence
1197/// beside the base note; git's own `fatal:` lines are far shorter than this,
1198/// and the cap applies to the redacted line.
1199const FAILURE_WORDS_CAP: usize = 200;
1200
1201/// Git's words with any credential a remote URL carries taken out.
1202///
1203/// git prints the URL it could not reach, userinfo included, and the record
1204/// this feeds is re-sent on every read and drawn on a page. Everything before
1205/// the `@` is the part a URL keeps its credential in, so it goes whole.
1206fn redact(words: &str) -> String {
1207    let mut out = String::with_capacity(words.len());
1208    let mut rest = words;
1209    while let Some(at) = rest.find("://") {
1210        let (before, after) = rest.split_at(at + 3);
1211        out.push_str(before);
1212        // The authority runs to the next path separator, quote or space.
1213        let end = after
1214            .find(|c: char| c == '/' || c == '\'' || c == '"' || c.is_whitespace())
1215            .unwrap_or(after.len());
1216        let (authority, tail) = after.split_at(end);
1217        match authority.rfind('@') {
1218            Some(userinfo) => {
1219                out.push_str("<redacted>");
1220                out.push_str(&authority[userinfo..]);
1221            }
1222            None => out.push_str(authority),
1223        }
1224        rest = tail;
1225    }
1226    out.push_str(rest);
1227    out
1228}
1229
1230/// Whether git refused the fetch because a ref lock it needed is held - by
1231/// another git in the same checkout, or by a killed child that left one.
1232///
1233/// **Forge runs one fetch per repository and base branch** (a caller joins the
1234/// run already in flight), so no second fetch of this ref is this process's:
1235/// two branches of one repository take different ref locks and may run
1236/// together. What reaches this check is the user's git, another forge
1237/// instance on the same repo, or a stranded lock.
1238fn is_lock_failure(stderr: &str) -> bool {
1239    stderr.contains("cannot lock ref")
1240        || (stderr.contains("Unable to create") && stderr.contains(".lock"))
1241}
1242
1243/// The budget a command handed no deadline reads as: no cap at all. Every
1244/// refresh runs under a budget of its own (a kick's, or a sessionless read's),
1245/// so this is what "no deadline" means to the helpers rather than a mode any
1246/// read takes.
1247const UNBOUNDED: Duration = Duration::MAX;
1248
1249/// The budget left before `deadline`, zero once spent and [`UNBOUNDED`] when
1250/// there is no deadline at all.
1251fn remaining(deadline: Option<Instant>) -> Duration {
1252    deadline.map_or(UNBOUNDED, |deadline| deadline.saturating_duration_since(Instant::now()))
1253}
1254
1255/// One child's output under `budget`; `None` when the budget ran out first and
1256/// the child was killed.
1257async fn child_output(
1258    child: impl std::future::Future<Output = std::io::Result<std::process::Output>>,
1259    budget: Duration,
1260) -> Option<std::io::Result<std::process::Output>> {
1261    if budget == UNBOUNDED { Some(child.await) } else { timeout(budget, child).await.ok() }
1262}
1263
1264/// The sha `reference` resolves to, or `None` when git cannot read it.
1265async fn local_tip(cwd: &Path, reference: &str, budget: Duration) -> Option<String> {
1266    if budget.is_zero() {
1267        return None;
1268    }
1269    match run_git_within(cwd, &["rev-parse", reference], budget).await {
1270        GitOutput::Ok(sha) => Some(sha.trim().to_owned()),
1271        GitOutput::Empty | GitOutput::Failed | GitOutput::Oversize => None,
1272    }
1273}
1274
1275/// Whether the checkout has an `origin` remote: yes, no, or a probe that
1276/// could not answer.
1277enum OriginRemote {
1278    Present,
1279    Absent,
1280    /// git could not be run, or a sick checkout refused it. The question is
1281    /// open, and an open question must not answer "no remote". The reason is
1282    /// what the line about it carries: a spent budget, a git that refused,
1283    /// and an oversized answer are three different things to look at.
1284    Unanswered(&'static str),
1285}
1286
1287/// Ask whether `cwd` has an `origin` remote at all: a local base beside one is
1288/// a base nobody fetched, which is not the same as a repository without a
1289/// remote to lag behind.
1290///
1291/// **The question is asked with `git remote`, not `get-url`.** A non-zero
1292/// `get-url` is two answers in one shape - "no such remote" and a git that
1293/// would not run - and reading both as "no remote" is how a base nobody
1294/// fetched got drawn as purely local. `git remote` answers a list on success;
1295/// a repository with none is an empty list, and anything else is a probe that
1296/// did not answer.
1297///
1298/// The match is the literal `origin`: it is the one remote a base refresh
1299/// knows how to fetch, so a repository whose only remote is named otherwise
1300/// still reads as having nothing this read could refresh.
1301async fn origin_remote(cwd: &Path, deadline: Option<Instant>) -> OriginRemote {
1302    if remaining(deadline).is_zero() {
1303        return OriginRemote::Unanswered("budget_spent");
1304    }
1305    match run_git_within(cwd, &["remote"], remaining(deadline)).await {
1306        GitOutput::Ok(listing) => {
1307            if listing.lines().any(|name| name.trim() == "origin") {
1308                OriginRemote::Present
1309            } else {
1310                OriginRemote::Absent
1311            }
1312        }
1313        // Ran in a repository with no remotes at all.
1314        GitOutput::Empty => OriginRemote::Absent,
1315        GitOutput::Failed => match repo_presence_within(cwd, remaining(deadline)).await {
1316            // Not a repository: there is no origin to lag behind either.
1317            RepoPresence::Absent => OriginRemote::Absent,
1318            // A repository git refused to list: the question is open.
1319            RepoPresence::Present | RepoPresence::Unusable => {
1320                OriginRemote::Unanswered("git_refused")
1321            }
1322        },
1323        GitOutput::Oversize => OriginRemote::Unanswered("oversize"),
1324    }
1325}
1326
1327/// Whether the local base itself tracks a remote (`main@{upstream}`): a base
1328/// that does can be behind that remote exactly as an `origin/...` can, and
1329/// nothing was fetched for it here. `None` when git could not answer, which
1330/// the caller treats as unverified rather than as "nothing to lag behind".
1331async fn local_base_tracks_upstream(
1332    cwd: &Path,
1333    base: &str,
1334    deadline: Option<Instant>,
1335) -> Option<bool> {
1336    if remaining(deadline).is_zero() {
1337        return None;
1338    }
1339    let spec = format!("{base}@{{upstream}}");
1340    match run_git_within(cwd, &["rev-parse", "--abbrev-ref", &spec], remaining(deadline)).await {
1341        GitOutput::Ok(_) => Some(true),
1342        GitOutput::Empty | GitOutput::Failed => Some(false),
1343        GitOutput::Oversize => None,
1344    }
1345}
1346
1347/// Run the full scan sequence against `cwd` and return a snapshot.
1348/// Always succeeds - every failure path collapses to a non-InRepo
1349/// `repo_gate` and a log line naming the step that failed. Callers
1350/// should treat the snapshot as authoritative for rendering
1351/// regardless of which variant came back.
1352///
1353/// `prev` is the most recent snapshot for this `cwd` (if any). It's
1354/// used to reuse cached PR info while the branch name, pushed sha
1355/// and fetch age (5 minutes) all say the cached answer still holds
1356/// (`pr_cache_fresh`). Pass `None` for cold starts.
1357/// The caller's current branch name via `git rev-parse --abbrev-ref HEAD`,
1358/// keeping a detached HEAD apart from a failed read: `Ok(Some(name))` on
1359/// a named branch, `Ok(None)` on a detached HEAD, `Err(gate)` when git
1360/// reported nothing usable. Mirrors the `GitBranch::Named` string
1361/// [`scan`] derives, so the review MCP can resolve a caller's `(project,
1362/// branch)` review scope to the same key the `/diff` overlay persists
1363/// under - and name the step that failed when it can't.
1364pub async fn current_branch(cwd: &Path) -> Result<Option<String>, RepoGate> {
1365    let name = rev_parse_gate(cwd, COMMAND_TIMEOUT).await?;
1366    Ok((name != "HEAD" && !name.is_empty()).then_some(name))
1367}
1368
1369/// How many files `cwd`'s worktree has changed against HEAD: tracked
1370/// modifications plus untracked files, one `--porcelain` line each. `Err`
1371/// carries the repo gate, so a caller can tell "not a repo" from a git
1372/// that would not run rather than reading either as a clean tree.
1373pub async fn changed_file_count(cwd: &Path) -> Result<usize, RepoGate> {
1374    match run_git(cwd, &["status", "--porcelain"]).await {
1375        GitOutput::Ok(text) => Ok(text.lines().filter(|line| !line.trim().is_empty()).count()),
1376        GitOutput::Empty => Ok(0),
1377        GitOutput::Oversize => Err(RepoGate::ScannerFailed),
1378        // Same split `rev_parse_gate` draws: a non-zero exit with `.git`
1379        // still on disk is a broken checkout to report, not an absent repo.
1380        GitOutput::Failed => match repo_presence(cwd).await {
1381            RepoPresence::Absent => Err(RepoGate::NotARepo),
1382            RepoPresence::Present | RepoPresence::Unusable => Err(RepoGate::ScannerFailed),
1383        },
1384    }
1385}
1386
1387/// `env` is `cwd`'s project's own declared environment, carried as the
1388/// identity of every `gh` call the scan makes, so the PR row reads as
1389/// the project rather than as the server's ambient login.
1390pub async fn scan<S: std::hash::BuildHasher>(
1391    cwd: &Path,
1392    env: &HashMap<String, String, S>,
1393    prev: Option<&GitDiffSnapshot>,
1394) -> GitDiffSnapshot {
1395    scan_within(cwd, env, prev, None, PrRefresh::Fetch, true, COMMAND_TIMEOUT).await
1396}
1397
1398/// [`scan`] for a read that will fetch the base itself a moment later: the
1399/// same local scan with none of the background kick, so one read of a
1400/// sessionless seat pays one fetch - its own - and never queues that fetch
1401/// behind forge's on the shared per-repository gate.
1402pub async fn scan_without_base_fetch<S: std::hash::BuildHasher>(
1403    cwd: &Path,
1404    env: &HashMap<String, String, S>,
1405    prev: Option<&GitDiffSnapshot>,
1406) -> GitDiffSnapshot {
1407    scan_within(cwd, env, prev, None, PrRefresh::Fetch, false, COMMAND_TIMEOUT).await
1408}
1409
1410/// [`scan`] for a read a client is waiting on: a PR cache past its window is
1411/// served for the branch and sha it belongs to rather than looked up, so the
1412/// read never waits on the network; a cache from another branch or push
1413/// answers nothing. The seat's own loop scans with [`scan`] and pushes the
1414/// refreshed row.
1415pub async fn scan_serving_cached_pr<S: std::hash::BuildHasher>(
1416    cwd: &Path,
1417    env: &HashMap<String, String, S>,
1418    prev: Option<&GitDiffSnapshot>,
1419) -> GitDiffSnapshot {
1420    scan_within(cwd, env, prev, None, PrRefresh::ServeCached, false, COMMAND_TIMEOUT).await
1421}
1422
1423/// A read pinned to one base revision: the branch-ahead layer is computed
1424/// against `base` (a sha a refresh just resolved) rather than against the
1425/// ref's name, so this read and a content read taken beside it describe one
1426/// commit even if the ref moves between them. Never fetches and never looks
1427/// the PR up: the caller has already refreshed the base and is drawing it.
1428pub async fn scan_pinned<S: std::hash::BuildHasher>(
1429    cwd: &Path,
1430    env: &HashMap<String, String, S>,
1431    prev: Option<&GitDiffSnapshot>,
1432    base: &str,
1433) -> GitDiffSnapshot {
1434    scan_within(cwd, env, prev, Some(base), PrRefresh::ServeCached, false, COMMAND_TIMEOUT).await
1435}
1436
1437/// [`scan`] with the scan's command budget spelled: the shell gives every
1438/// child a scan spawns the 10 s `COMMAND_TIMEOUT`, and a test gives them one
1439/// of its own.
1440///
1441/// **The budget is a bound on the machine, not on the scan's work**: it is
1442/// spent starting each child, so a machine can hold a child's first output
1443/// past the shell's 10 s and fail or blank a layer that is merely slow -
1444/// measured 2026-10-14: the PR lookup's `gh` child was killed at 10.5 s and
1445/// 10.9 s while a gate ran beside the suite, and a `gh` held back 11 s
1446/// reproduces it on demand, the same mechanism that reds a scan whose `git`
1447/// child stalls. A test that inherits the shell's budget goes red over a
1448/// stall that is no part of what it pins.
1449async fn scan_within<S: std::hash::BuildHasher>(
1450    cwd: &Path,
1451    env: &HashMap<String, String, S>,
1452    prev: Option<&GitDiffSnapshot>,
1453    base: Option<&str>,
1454    pr_refresh: PrRefresh,
1455    // Whether this scan kicks the throttled background fetch. A read that
1456    // will fetch the base itself passes `false`.
1457    kick: bool,
1458    budget: Duration,
1459) -> GitDiffSnapshot {
1460    let raw_branch = match rev_parse_gate(cwd, budget).await {
1461        Ok(branch) => branch,
1462        // Both the not-a-repo and scanner-crash cases collapse to the
1463        // same all-Clean snapshot, differing only by `repo_gate` so the
1464        // renderer can tell "not a git repository" from "scanner
1465        // unhealthy."
1466        Err(repo_gate) => {
1467            return GitDiffSnapshot {
1468                branch: GitBranch::NoRepo,
1469                default_branch: None,
1470                repo_gate,
1471                pushed_sha: None,
1472                worktree: LayerState::Clean,
1473                branch_ahead: LayerState::Clean,
1474                pr: None,
1475                closes: Vec::new(),
1476                pr_fetched_at: None,
1477            };
1478        }
1479    };
1480    let detached = raw_branch == "HEAD";
1481    let branch = if detached {
1482        GitBranch::Detached
1483    } else if raw_branch.is_empty() {
1484        GitBranch::Unknown
1485    } else {
1486        GitBranch::Named(raw_branch.clone())
1487    };
1488
1489    let default_branch = resolve_default_branch(cwd, budget).await;
1490    let on_default = checked_out_on_default(&raw_branch, default_branch.as_deref());
1491    // **An ordinary scan keeps `origin/<default>` fresh for the NEXT read.**
1492    // It is kicked, never awaited: this scan reads the ref as it stands and
1493    // nobody's read waits on the fetch. The watch's own pass also refreshes
1494    // inline and claims the same window, so one repository fetches at most
1495    // once per window whichever path gets there first - and a sessionless
1496    // seat, which has no loop to refresh for it, is covered here.
1497    if kick
1498        && pr_refresh == PrRefresh::Fetch
1499        && !detached
1500        && !on_default
1501        && let Some(default) = default_branch.as_deref()
1502        && default.starts_with("origin/")
1503    {
1504        kick_background_fetch(cwd, default, budget);
1505    }
1506    let dirty_probe = is_worktree_dirty(cwd, budget).await;
1507
1508    // Layer 1: uncommitted edits vs HEAD. The three legal states
1509    // (clean tree, populated diff, scan failed) are unrepresentable
1510    // as illegal combinations because `LayerState` encodes them
1511    // directly. Either upstream failure (dirty probe OR numstat)
1512    // collapses to `ScanFailed` so the renderer surfaces "(scan
1513    // failed)" instead of silently dropping to clean-tree.
1514    let worktree: LayerState<GitDiffStats> = match dirty_probe {
1515        None => LayerState::ScanFailed,
1516        Some(false) => LayerState::Clean,
1517        Some(true) => match numstat(cwd, &["diff", "--numstat", "HEAD"], budget).await {
1518            Ok(mut stats) => {
1519                // One more read of the same tree state for the glyphs the
1520                // counts cannot carry; a failed pass leaves every file
1521                // `Modified` rather than failing the layer.
1522                if let Some(marks) = name_statuses(cwd, "HEAD", budget).await {
1523                    for file in &mut stats.files {
1524                        if let Some(status) = marks.get(&file.path) {
1525                            file.status = *status;
1526                        }
1527                    }
1528                }
1529                LayerState::Populated(stats)
1530            }
1531            Err(_) => LayerState::ScanFailed,
1532        },
1533    };
1534
1535    // Layer 2: commits the branch has ahead of the default branch.
1536    // Skipped on detached HEAD (no meaningful "branch name" to be
1537    // ahead from), on the default branch itself (the diff against
1538    // itself is empty by construction), and when default_branch is
1539    // unknown (no sensible base). A clean branch sitting at the
1540    // merge-base with no commits ahead collapses to `Clean` so the
1541    // renderer doesn't show an empty layer-2 row. `ScanFailed`
1542    // carries the subprocess-error signal mirroring layer 1.
1543    let branch_ahead: LayerState<GitBranchAhead> = if !detached && !on_default {
1544        // A pinned read uses the sha it was given; a free one uses the ref's
1545        // name. The snapshot still carries the NAME for display.
1546        if let Some(default) = base.or(default_branch.as_deref()) {
1547            let range = format!("{default}...HEAD");
1548            match numstat(cwd, &["diff", "--numstat", &range], budget).await {
1549                Ok(stats) => match commit_count_in_range(cwd, default, "HEAD", budget).await {
1550                    None => LayerState::ScanFailed,
1551                    Some(0) => LayerState::Clean,
1552                    Some(commit_count) => {
1553                        // `commit_count == 0` is the cleanest signal
1554                        // that the branch has no commits ahead of
1555                        // default; gate on that alone. The earlier
1556                        // `&& stats.total_files == 0` clause masked
1557                        // the racy merge-base / force-push corner
1558                        // case where commit_count == 0 but stats
1559                        // showed files, producing a "0 commits vs
1560                        // main" subtitle that read oddly to the user.
1561                        // One more read of the same range for the glyphs
1562                        // the counts cannot carry, exactly as the worktree
1563                        // layer corrects its own; a failed pass leaves
1564                        // every file `Modified` rather than failing the
1565                        // layer.
1566                        let mut stats = stats;
1567                        if let Some(marks) = name_statuses(cwd, &range, budget).await {
1568                            for file in &mut stats.files {
1569                                if let Some(status) = marks.get(&file.path) {
1570                                    file.status = *status;
1571                                }
1572                            }
1573                        }
1574                        // The chain itself, for the view that draws what
1575                        // the branch is working on rather than only how
1576                        // far it runs.
1577                        let commits = commits_in_range(cwd, default, "HEAD", budget)
1578                            .await
1579                            .unwrap_or_default();
1580                        LayerState::Populated(GitBranchAhead { commit_count, stats, commits })
1581                    }
1582                },
1583                Err(_) => LayerState::ScanFailed,
1584            }
1585        } else {
1586            LayerState::Clean
1587        }
1588    } else {
1589        LayerState::Clean
1590    };
1591
1592    // PR / closes only make sense for named non-default branches -
1593    // default branch never has a PR open against itself, detached /
1594    // unknown branches can't be queried by name. For eligible
1595    // branches, resolve HEAD's newest pushed ancestor (the lookup
1596    // key - never the branch name, which diverges from the PR head
1597    // ref for `HEAD:<name>` pushes) and reuse the prior snapshot's
1598    // data while the sha and fetch age say the cache still holds.
1599    let (pr, closes, pr_fetched_at, pushed_sha) = match &branch {
1600        GitBranch::Named(name) if !on_default => {
1601            let pushed_sha = resolve_pushed_sha(cwd, PUSHED_SHA_WALK_CAP, budget).await;
1602            let (pr, closes, pr_fetched_at) =
1603                pr_for_head_within(cwd, env, name, pushed_sha.as_deref(), prev, pr_refresh, budget)
1604                    .await;
1605            (pr, closes, pr_fetched_at, pushed_sha)
1606        }
1607        _ => (None, Vec::new(), None, None),
1608    };
1609
1610    // repo_gate=InRepo here - we've passed the rev-parse gate and
1611    // every downstream subprocess (default-branch resolution,
1612    // numstat, gh pr) is best-effort; failures collapse to safe
1613    // defaults plus per-layer `LayerState::ScanFailed` so the
1614    // renderer can surface them at the layer level rather than
1615    // poisoning the overall snapshot. The ScannerFailed gate is
1616    // only the rev-parse-failed return at the top of this function.
1617    GitDiffSnapshot {
1618        branch,
1619        default_branch,
1620        repo_gate: RepoGate::InRepo,
1621        pushed_sha,
1622        worktree,
1623        branch_ahead,
1624        pr,
1625        closes,
1626        pr_fetched_at,
1627    }
1628}
1629
1630/// Count commits in `<base>..HEAD` via `git rev-list --count`.
1631/// Returns `None` on any failure path (subprocess error, parse
1632/// failure, anomalous empty output) so callers can route the
1633/// signal into `LayerState::ScanFailed` instead of masking the
1634/// failure as 0. Each non-Ok branch emits a WARN log mirroring the
1635/// `gh_pr_lookup_*` pattern.
1636async fn commit_count_in_range(
1637    cwd: &Path,
1638    base: &str,
1639    head: &str,
1640    budget: Duration,
1641) -> Option<u32> {
1642    let range = format!("{base}..{head}");
1643    match run_git_within(cwd, &["rev-list", "--count", &range], budget).await {
1644        GitOutput::Ok(s) => match s.trim().parse::<u32>() {
1645            Ok(count) => Some(count),
1646            Err(err) => {
1647                tracing::warn!(
1648                    target: crate::logging::targets::ENV_GIT,
1649                    cwd = %cwd.display(),
1650                    event_name = "git_commit_count_parse_failed",
1651                    message = "rev-list --count stdout did not parse as u32",
1652                    outcome = "failure",
1653                    error = %err,
1654                    range = %range,
1655                    raw = %s.trim(),
1656                );
1657                None
1658            }
1659        },
1660        GitOutput::Empty => {
1661            // rev-list --count always emits `0\n` on success even for
1662            // an empty range; Empty here is anomalous (binary stdout,
1663            // truncation, etc.). Log separately so operators can tell
1664            // it apart from Failed/Oversize when triaging a vanished
1665            // layer-2 row.
1666            tracing::warn!(
1667                target: crate::logging::targets::ENV_GIT,
1668                cwd = %cwd.display(),
1669                event_name = "git_commit_count_empty",
1670                message = "rev-list --count returned empty stdout (expected at minimum '0\\n')",
1671                outcome = "anomalous",
1672                range = %range,
1673            );
1674            None
1675        }
1676        GitOutput::Failed | GitOutput::Oversize => None,
1677    }
1678}
1679
1680/// Resolve the ref a feature branch's "ahead" diff compares against.
1681/// Prefers the remote-tracking `origin/<default>` (via `origin/HEAD`,
1682/// else `origin/main` / `origin/master`) so a locally-stale `main`
1683/// can't fold already-merged commits into the diff; falls back to a
1684/// purely-local `main` / `master` for a repo with no `origin` remote.
1685/// The returned value IS the compare-ref - the `origin/` prefix carries
1686/// the remote-vs-local decision, so callers wanting the plain branch
1687/// name strip it. `None` when nothing resolves.
1688async fn resolve_default_branch(cwd: &Path, budget: Duration) -> Option<String> {
1689    // `origin/HEAD` advertises the remote's default; `--short` yields
1690    // e.g. `origin/main`. Keep the prefix - it's the remote-tracking
1691    // ref we want to compare against (GitOutput::Ok is non-empty).
1692    if let GitOutput::Ok(s) =
1693        run_git_within(cwd, &["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], budget).await
1694    {
1695        return Some(s.trim().to_owned());
1696    }
1697    // No `origin/HEAD`: try the conventional remote-tracking refs, then
1698    // a purely-local `main` / `master` (a repo with no origin remote).
1699    for candidate in ["origin/main", "origin/master", "main", "master"] {
1700        if let GitOutput::Ok(_) =
1701            run_git_within(cwd, &["rev-parse", "--verify", candidate], budget).await
1702        {
1703            return Some(candidate.to_owned());
1704        }
1705    }
1706    // Nothing resolved. A feature-branch diff has no meaningful base,
1707    // so layer 2 collapses to `LayerState::Clean`. WARN so it's grep-
1708    // able when the user reports "the GIT section never shows my
1709    // branch's diff".
1710    tracing::warn!(
1711        target: crate::logging::targets::ENV_GIT,
1712        cwd = %cwd.display(),
1713        event_name = "git_default_branch_unknown",
1714        message = "default branch fallback exhausted (no origin/HEAD, origin/main, origin/master, main, or master)",
1715        outcome = "skipped",
1716    );
1717    None
1718}
1719
1720/// Returns `Some(dirty)` when `git status` ran cleanly (`Ok` /
1721/// `Empty`); `None` when the subprocess hit `Failed` / `Oversize`.
1722/// Callers thread the `None` signal into `LayerState::ScanFailed`
1723/// so the renderer can surface "(scan failed)" for layer 1 instead
1724/// of silently rendering a clean tree when the probe upstream of
1725/// numstat collapsed.
1726async fn is_worktree_dirty(cwd: &Path, budget: Duration) -> Option<bool> {
1727    match run_git_within(cwd, &["status", "--porcelain=v1", "--untracked-files=no"], budget).await {
1728        GitOutput::Ok(s) => Some(!s.trim().is_empty()),
1729        GitOutput::Empty => Some(false),
1730        GitOutput::Failed | GitOutput::Oversize => None,
1731    }
1732}
1733
1734/// Underlying `git diff --numstat` subprocess didn't return a usable
1735/// result. Callers map this to `LayerState::ScanFailed` so the
1736/// renderer can surface the failure. The variants carry enough
1737/// classification to triage from the log emitted by `run_git`;
1738/// downstream callers don't differentiate them today but the type
1739/// keeps the option open.
1740#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1741pub enum NumstatError {
1742    /// Subprocess crashed, timed out, or exited non-zero. See the
1743    /// `git_subprocess_failed` / `git_subprocess_nonzero_exit` /
1744    /// `git_subprocess_timeout` events.
1745    Subprocess,
1746    /// Stdout exceeded the per-command size cap. See
1747    /// `git_subprocess_oversize` WARN event.
1748    Oversize,
1749}
1750
1751/// Run `git <args>` and parse the `--numstat` output. Returns the
1752/// top-`TOP_FILE_COUNT` files (sorted by total changes desc, alpha
1753/// tie-break) plus the full file count and overall add/remove
1754/// totals.
1755/// Returns `Err(NumstatError)` when the underlying `git` subprocess
1756/// hit `Failed` / `Oversize`; callers map that signal to
1757/// `LayerState::ScanFailed` so the renderer can distinguish "scan
1758/// failed" from "clean tree, nothing to show". `Empty` (exit 0, no
1759/// stdout) collapses to a zero-row stats block since that's the
1760/// legitimate "no diff to report" outcome.
1761async fn numstat(
1762    cwd: &Path,
1763    args: &[&str],
1764    budget: Duration,
1765) -> Result<GitDiffStats, NumstatError> {
1766    let raw = match run_git_within(cwd, args, budget).await {
1767        GitOutput::Ok(s) => s,
1768        GitOutput::Empty => String::new(),
1769        GitOutput::Failed => return Err(NumstatError::Subprocess),
1770        GitOutput::Oversize => return Err(NumstatError::Oversize),
1771    };
1772    Ok(stats_of(parse_numstat(&raw)))
1773}
1774
1775/// One file list as a stats block: the full totals, then the
1776/// top-`TOP_FILE_COUNT` files by churn (alpha tie-break) - the count above
1777/// the list tells the truth either way. The shape a layer's stats and each
1778/// commit's own stats both take.
1779fn stats_of(mut files: Vec<GitDiffFile>) -> GitDiffStats {
1780    let total_files = files.len();
1781    let total_added: u32 = files.iter().fold(0u32, |acc, f| acc.saturating_add(f.added));
1782    let total_removed: u32 = files.iter().fold(0u32, |acc, f| acc.saturating_add(f.removed));
1783    files.sort_by(|a, b| {
1784        let a_total = a.added.saturating_add(a.removed);
1785        let b_total = b.added.saturating_add(b.removed);
1786        b_total.cmp(&a_total).then_with(|| a.path.cmp(&b.path))
1787    });
1788    files.truncate(TOP_FILE_COUNT);
1789    GitDiffStats { files, total_files, total_added, total_removed }
1790}
1791
1792/// Parse `<added>\t<removed>\t<path>` lines. Skips binary entries
1793/// (`added` or `removed` reported as `-`). Uses `splitn(3, '\t')`
1794/// so paths containing tabs survive intact.
1795///
1796/// `--numstat` carries no status code, so every file lands `Modified`
1797/// and [`name_statuses`] corrects what it can afterwards: a file the
1798/// merge never reaches keeps the least-alarming class.
1799fn parse_numstat(raw: &str) -> Vec<GitDiffFile> {
1800    raw.lines().filter_map(parse_numstat_line).collect()
1801}
1802
1803/// One `<added>\t<removed>\t<path>` line, or `None` for anything else - a
1804/// binary entry (counts as `-`) and a format line among them.
1805fn parse_numstat_line(line: &str) -> Option<GitDiffFile> {
1806    let mut parts = line.splitn(3, '\t');
1807    let added = parts.next()?;
1808    let removed = parts.next()?;
1809    let path = parts.next()?;
1810    if added == "-" || removed == "-" {
1811        return None;
1812    }
1813    let added = added.parse::<u32>().ok()?;
1814    let removed = removed.parse::<u32>().ok()?;
1815    Some(GitDiffFile {
1816        path: rename_target(path),
1817        added,
1818        removed,
1819        status: forge_primitives::git::FileStatus::Modified,
1820    })
1821}
1822
1823/// A `--numstat` path resolved to the file's new name: a rename crosses as
1824/// `dir/{old => new}.rs`, or `old => new` when the two share no affixes, while
1825/// `--name-status` and the diff body both speak the new path alone - so the
1826/// merge in [`scan`] needs the same key from every read.
1827fn rename_target(path: &str) -> String {
1828    let Some((before, after)) = path.split_once(" => ") else {
1829        return path.to_owned();
1830    };
1831    match (before.rfind('{'), after.find('}')) {
1832        (Some(open), Some(close)) => {
1833            format!("{}{}{}", &before[..open], &after[..close], &after[close + 1..])
1834        }
1835        _ => after.to_owned(),
1836    }
1837}
1838
1839/// One changed path's status against `target`, from
1840/// `git diff --name-status` - the same classifier the hunks pass
1841/// reads, so the glyph beside a file matches the one its diff body
1842/// would draw. Best-effort: a failed read leaves the counts alone.
1843async fn name_statuses(
1844    cwd: &Path,
1845    target: &str,
1846    budget: Duration,
1847) -> Option<HashMap<String, FileStatus>> {
1848    match run_git_within(cwd, &["diff", "--name-status", target], budget).await {
1849        GitOutput::Ok(raw) => Some(
1850            self::hunks::parse_name_status_entries(&raw)
1851                .into_iter()
1852                .map(|entry| (entry.path, entry.status))
1853                .collect(),
1854        ),
1855        GitOutput::Empty => Some(HashMap::new()),
1856        GitOutput::Failed | GitOutput::Oversize => None,
1857    }
1858}
1859
1860/// The branch's commit chain ahead of `base`, newest first, capped at
1861/// [`COMMITS_WALK_CAP`]: the count says how many there are, these say
1862/// what they are, and each carries the files it changed. Two walks of the
1863/// same range - `--numstat` for the counts, `--name-status` for the marks,
1864/// which git will not emit together. `%h` is the short sha, `%s` the
1865/// subject; the unit separator cannot occur in either, so one line splits
1866/// cleanly.
1867async fn commits_in_range(
1868    cwd: &Path,
1869    base: &str,
1870    head: &str,
1871    budget: Duration,
1872) -> Option<Vec<forge_primitives::git::GitCommit>> {
1873    let range = format!("{base}..{head}");
1874    let cap = format!("--max-count={COMMITS_WALK_CAP}");
1875    let listed = match run_git_within(
1876        cwd,
1877        &["log", &cap, "--numstat", "--format=%h%x1f%s%x1f%ct", &range],
1878        budget,
1879    )
1880    .await
1881    {
1882        GitOutput::Ok(raw) => raw,
1883        GitOutput::Empty => return Some(Vec::new()),
1884        GitOutput::Failed | GitOutput::Oversize => return None,
1885    };
1886    // Best-effort: a failed marks walk leaves every file `Modified`
1887    // rather than failing the chain.
1888    let marks = match run_git_within(
1889        cwd,
1890        &["log", &cap, "--name-status", "--format=%h%x1f", &range],
1891        budget,
1892    )
1893    .await
1894    {
1895        GitOutput::Ok(raw) => parse_commit_marks(&raw),
1896        GitOutput::Empty | GitOutput::Failed | GitOutput::Oversize => HashMap::new(),
1897    };
1898
1899    let mut commits = Vec::new();
1900    let mut open: Option<(String, String, u64)> = None;
1901    let mut files: Vec<GitDiffFile> = Vec::new();
1902    for line in listed.lines() {
1903        let fields: Vec<&str> = line.split('\u{1f}').collect();
1904        if let [sha, subject, secs] = fields[..] {
1905            // The format line opens a commit; the numstat lines under it
1906            // are that commit's own files.
1907            if let Some((sha, subject, time)) = open.take() {
1908                commits.push(forge_primitives::git::GitCommit {
1909                    sha,
1910                    subject,
1911                    stats: stats_of(std::mem::take(&mut files)),
1912                    time,
1913                });
1914            }
1915            open = Some((sha.to_owned(), subject.to_owned(), secs.parse().unwrap_or(0)));
1916        } else if let Some(file) = parse_numstat_line(line) {
1917            files.push(file);
1918        }
1919    }
1920    if let Some((sha, subject, time)) = open.take() {
1921        commits.push(forge_primitives::git::GitCommit {
1922            sha,
1923            subject,
1924            stats: stats_of(files),
1925            time,
1926        });
1927    }
1928    for commit in &mut commits {
1929        if let Some(by_path) = marks.get(&commit.sha) {
1930            for file in &mut commit.stats.files {
1931                if let Some(status) = by_path.get(&file.path) {
1932                    file.status = *status;
1933                }
1934            }
1935        }
1936    }
1937    Some(commits)
1938}
1939
1940/// Marks per commit from a `--name-status` walk of the same chain: the
1941/// status lines carry no sha of their own, so each block belongs to the
1942/// format line that opened it.
1943fn parse_commit_marks(raw: &str) -> HashMap<String, HashMap<String, FileStatus>> {
1944    let mut marks: HashMap<String, HashMap<String, FileStatus>> = HashMap::new();
1945    let mut open: Option<String> = None;
1946    for line in raw.lines() {
1947        if let Some((sha, _)) = line.split_once('\u{1f}') {
1948            open = Some(sha.to_owned());
1949            continue;
1950        }
1951        let Some(sha) = open.as_deref() else { continue };
1952        // "M\tpath" and "R100\told\tnew" - the new path is the last field.
1953        let mut parts = line.split('\t');
1954        let (Some(code), Some(path)) = (parts.next(), parts.next_back()) else {
1955            continue;
1956        };
1957        let Some(status) = code.chars().next().and_then(hunks::status_of) else {
1958            continue;
1959        };
1960        marks.entry(sha.to_owned()).or_default().insert(path.to_owned(), status);
1961    }
1962    marks
1963}
1964
1965/// How many commits the chain walk reads. Past it the count above the
1966/// list still tells the truth; the list is what a reader scans, not
1967/// what they count.
1968const COMMITS_WALK_CAP: u32 = 20;
1969
1970/// Whether a scan's PR block is past its refetch window, so a scan that may
1971/// reach the network would look it up again.
1972///
1973/// The seat's watch is the scan that refreshes it: the read path serves what
1974/// the store holds, and this is the leg that makes the watch's next pass scan
1975/// even when the tree itself is still - otherwise the correction would wait
1976/// for the tree's own staleness window (10 s) rather than its own (~1 s).
1977pub fn pr_refresh_due(diff: &GitDiffSnapshot, now: std::time::SystemTime) -> bool {
1978    let GitBranch::Named(name) = &diff.branch else {
1979        return false;
1980    };
1981    // The scan's own eligibility: a branch checked out ON the default has no
1982    // PR against itself, and its block is never looked up - so its absent
1983    // attempt time is "nothing to fetch", not "due".
1984    !checked_out_on_default(name, diff.default_branch.as_deref())
1985        && diff.pr_fetched_at.is_none_or(|at| {
1986            !now.duration_since(at).is_ok_and(|elapsed| elapsed < PR_REFETCH_INTERVAL)
1987        })
1988}
1989
1990/// Whether `branch` is the default a scan measures against, compared as plain
1991/// names: `default_branch` may be a remote-tracking ref (`origin/main`), and a
1992/// checked-out `main` is the default rather than a feature branch ahead of it.
1993fn checked_out_on_default(branch: &str, default: Option<&str>) -> bool {
1994    default
1995        .map(|default| default.strip_prefix("origin/").unwrap_or(default))
1996        .is_some_and(|default| default == branch)
1997}
1998
1999/// Whether the previous snapshot's PR block is still reusable: the
2000/// branch name and pushed sha both match the current scan AND the
2001/// last lookup attempt is less than [`PR_REFETCH_INTERVAL`] old.
2002/// The sha leg - not the branch name - is what makes a PR opened or
2003/// pushed after an earlier scan show up; the timer leg unsticks a
2004/// cached `None` (a scan that ran before the PR existed would
2005/// otherwise hold `pr: None` for the branch's whole life).
2006fn pr_cache_fresh(
2007    prev: Option<&GitDiffSnapshot>,
2008    branch: &str,
2009    pushed_sha: Option<&str>,
2010    now: std::time::SystemTime,
2011) -> bool {
2012    let Some(prev) = prev else {
2013        return false;
2014    };
2015    let GitBranch::Named(prev_name) = &prev.branch else {
2016        return false;
2017    };
2018    prev_name == branch
2019        && prev.pushed_sha.as_deref() == pushed_sha
2020        && prev.pr_fetched_at.is_some_and(|at| {
2021            now.duration_since(at).is_ok_and(|elapsed| elapsed < PR_REFETCH_INTERVAL)
2022        })
2023}
2024
2025/// What one PR lookup produced. `Failed` is kept apart from `None`
2026/// so a transient `gh` blip cannot evict a known-good cached PR.
2027enum PrLookup {
2028    Found(GitPrInfo, Vec<GitIssueRef>),
2029    /// The query ran and answered "no open PR containing this sha".
2030    None,
2031    /// The query did not complete (gh missing / auth / rate limit /
2032    /// unparseable response).
2033    Failed,
2034}
2035
2036/// Resolve PR info for the branch from `prev`'s cache when fresh,
2037/// else query fresh by the pushed sha. Stamps `pr_fetched_at` on the
2038/// returned attempt time so failed / empty lookups rate-limit too.
2039/// A `Failed` lookup keeps `prev`'s PR when branch and sha still
2040/// match - a gh blip must not flash the row off.
2041///
2042/// The `gh` children it spawns are bounded by `budget`; see
2043/// [`scan_within`] for why a test spells its own.
2044async fn pr_for_head_within<S: std::hash::BuildHasher>(
2045    cwd: &Path,
2046    env: &HashMap<String, String, S>,
2047    branch: &str,
2048    pushed_sha: Option<&str>,
2049    prev: Option<&GitDiffSnapshot>,
2050    pr_refresh: PrRefresh,
2051    budget: Duration,
2052) -> (Option<GitPrInfo>, Vec<GitIssueRef>, Option<std::time::SystemTime>) {
2053    let now = std::time::SystemTime::now();
2054    let cached = match prev {
2055        Some(prev) if pr_cache_fresh(Some(prev), branch, pushed_sha, now) => Some(prev),
2056        _ => None,
2057    };
2058    if let Some(prev) = cached {
2059        return (prev.pr.clone(), prev.closes.clone(), prev.pr_fetched_at);
2060    }
2061    // **A read somebody waits on does not go to the network.** The
2062    // PR is the one field a scan cannot answer locally, and its lookup is a
2063    // TLS + API round trip - measured at 0.9 s, which was the whole warm
2064    // open's remainder. The stored attempt time stands, so the seat's loop
2065    // (which nobody waits on) finds the cache stale and refreshes it on its
2066    // next pass.
2067    if pr_refresh == PrRefresh::ServeCached {
2068        return match prev {
2069            Some(prev) if same_branch_and_sha(prev, branch, pushed_sha) => {
2070                (prev.pr.clone(), prev.closes.clone(), prev.pr_fetched_at)
2071            }
2072            _ => (None, Vec::new(), None),
2073        };
2074    }
2075    match fetch_pr_for_pushed_sha(cwd, env, pushed_sha, budget).await {
2076        PrLookup::Found(pr, closes) => (Some(pr), closes, Some(now)),
2077        PrLookup::None => (None, Vec::new(), Some(now)),
2078        PrLookup::Failed => match prev {
2079            Some(prev) if same_branch_and_sha(prev, branch, pushed_sha) => {
2080                (prev.pr.clone(), prev.closes.clone(), Some(now))
2081            }
2082            _ => (None, Vec::new(), Some(now)),
2083        },
2084    }
2085}
2086
2087/// Whether `prev` describes the same branch and pushed sha the
2088/// current scan resolved - the condition under which a failed
2089/// lookup may keep `prev`'s cached PR.
2090fn same_branch_and_sha(prev: &GitDiffSnapshot, branch: &str, pushed_sha: Option<&str>) -> bool {
2091    matches!(&prev.branch, GitBranch::Named(prev_name) if prev_name == branch)
2092        && prev.pushed_sha.as_deref() == pushed_sha
2093}
2094
2095/// Resolve HEAD's newest pushed ancestor: walk HEAD's first-parent
2096/// history newest-first (bounded by `cap`) and stop at the first sha
2097/// the remote-tracking refs already contain. `None` when nothing of
2098/// HEAD's ancestry is pushed or no remote refs exist (a truthful
2099/// negative); a git failure also yields `None` but is an
2100/// UNRESOLVABLE lookup, not a negative - the row may flicker off for
2101/// one scan while git is broken and recovers on the next scan. The
2102/// walk happens inside git so a worktree ahead of the
2103/// PR tip by unpushed commits still resolves the commit the PR was
2104/// pushed from.
2105async fn resolve_pushed_sha(cwd: &Path, cap: usize, budget: Duration) -> Option<String> {
2106    let remote_shas = match run_git_within(
2107        cwd,
2108        &["for-each-ref", "refs/remotes", "--format=%(objectname)"],
2109        budget,
2110    )
2111    .await
2112    {
2113        GitOutput::Ok(raw) => raw
2114            .lines()
2115            .map(str::trim)
2116            .filter(|line| !line.is_empty())
2117            .map(str::to_owned)
2118            .collect::<std::collections::HashSet<_>>(),
2119        GitOutput::Empty | GitOutput::Failed | GitOutput::Oversize => {
2120            std::collections::HashSet::default()
2121        }
2122    };
2123    if remote_shas.is_empty() {
2124        return None;
2125    }
2126    let max_count = format!("--max-count={cap}");
2127    let walk =
2128        run_git_within(cwd, &["rev-list", "--first-parent", &max_count, "HEAD"], budget).await;
2129    let GitOutput::Ok(raw) = walk else {
2130        return None;
2131    };
2132    raw.lines().map(str::trim).find(|sha| remote_shas.contains(*sha)).map(str::to_owned)
2133}
2134
2135/// Look up the open PR containing `pushed_sha`. Returns
2136/// [`PrLookup::None`] when the query ran and answered "no open PR"
2137/// (nothing pushed, or no open PR contains the sha) and
2138/// [`PrLookup::Failed`] on every failure path - `gh` missing,
2139/// unauthenticated, not a github repo, JSON parse error. Failures
2140/// carry a structured event so operators can grep for
2141/// `gh_pr_lookup_*` when triaging "PR row never shows"; the
2142/// subprocess shapes are at DEBUG and the parse failure at WARN. The
2143/// commit-not-on-remote shape logs at DEBUG instead because an
2144/// unpushed HEAD is a normal mid-work state, not an operator signal.
2145async fn fetch_pr_for_pushed_sha<S: std::hash::BuildHasher>(
2146    cwd: &Path,
2147    env: &HashMap<String, String, S>,
2148    pushed_sha: Option<&str>,
2149    budget: Duration,
2150) -> PrLookup {
2151    let Some(sha) = pushed_sha else {
2152        return PrLookup::None;
2153    };
2154    let endpoint = format!("repos/{{owner}}/{{repo}}/commits/{sha}/pulls");
2155    let raw = match run_gh(cwd, env, &["api", &endpoint], GhNotFound::Tolerate, budget).await {
2156        GitOutput::Ok(s) => s,
2157        GitOutput::Empty => {
2158            // A completed query with a definitive negative: exit 0
2159            // with `[]`, or the commit-not-on-remote shape run_gh
2160            // folds into Empty. No open PR, no cache-keep.
2161            return PrLookup::None;
2162        }
2163        GitOutput::Failed | GitOutput::Oversize => return PrLookup::Failed,
2164    };
2165    match pick_open_pr(&raw) {
2166        Err(err) => {
2167            tracing::warn!(
2168                target: crate::logging::targets::ENV_GIT,
2169                cwd = %cwd.display(),
2170                event_name = "gh_pr_lookup_parse_failed",
2171                message = "gh api commits pulls returned unparseable json",
2172                outcome = "failure",
2173                error = %err,
2174                sha = %sha,
2175            );
2176            PrLookup::Failed
2177        }
2178        Ok(None) => PrLookup::None,
2179        Ok(Some((number, url, draft))) => {
2180            let closes = fetch_closing_issues(cwd, env, number, budget).await;
2181            PrLookup::Found(GitPrInfo { number, url, draft }, closes)
2182        }
2183    }
2184}
2185
2186/// Select the open PR from the `commits/<sha>/pulls` REST response.
2187/// Multiple open PRs can contain the same commit (stacked PRs,
2188/// cherry-picks); the most recently `updated_at` one wins - the
2189/// actively-evolving PR is the right answer in stacked workflows -
2190/// with the higher number breaking ties. One row renders, never a
2191/// list.
2192///
2193/// `updated_at` compares lexicographically, which is chronological
2194/// for GitHub's uniform RFC3339 `Z`-suffixed format.
2195fn pick_open_pr(raw: &str) -> Result<Option<(u64, String, bool)>, serde_json::Error> {
2196    let entries: Vec<GhApiPull> = serde_json::from_str(raw)?;
2197    Ok(entries
2198        .into_iter()
2199        .filter(|pr| pr.state == "open")
2200        .max_by(|a, b| (&a.updated_at, a.number).cmp(&(&b.updated_at, b.number)))
2201        .map(|pr| (pr.number, pr.html_url, pr.draft)))
2202}
2203
2204/// `commits/<sha>/pulls` REST entry shape. Only the fields the
2205/// selection rule reads are deserialised; `gh api` adds others
2206/// (`title`, `body`, …) that serde silently drops.
2207#[derive(Deserialize)]
2208struct GhApiPull {
2209    number: u64,
2210    state: String,
2211    html_url: String,
2212    updated_at: String,
2213    /// The row says draft or open; the two read differently to a
2214    /// reviewer, and GitHub's own word for it is here.
2215    #[serde(default)]
2216    draft: bool,
2217}
2218
2219/// Fetch the PR's closing-issue list via
2220/// `gh pr view <n> --json closingIssuesReferences`. The REST
2221/// `commits/<sha>/pulls` response doesn't carry closing issues (a
2222/// GraphQL-only field), so this second call keeps the
2223/// `→ closes #M` tail rendering. Degrades to an empty list on any
2224/// failure - the PR row is the headline, the closes are the tail.
2225async fn fetch_closing_issues<S: std::hash::BuildHasher>(
2226    cwd: &Path,
2227    env: &HashMap<String, String, S>,
2228    number: u64,
2229    budget: Duration,
2230) -> Vec<GitIssueRef> {
2231    let number = number.to_string();
2232    let raw = match run_gh(
2233        cwd,
2234        env,
2235        &["pr", "view", &number, "--json", "closingIssuesReferences"],
2236        GhNotFound::Fail,
2237        budget,
2238    )
2239    .await
2240    {
2241        GitOutput::Ok(raw) => raw,
2242        GitOutput::Empty | GitOutput::Failed | GitOutput::Oversize => return Vec::new(),
2243    };
2244    let parsed: GhPrView = match serde_json::from_str(&raw) {
2245        Ok(parsed) => parsed,
2246        Err(err) => {
2247            tracing::warn!(
2248                target: crate::logging::targets::ENV_GIT,
2249                cwd = %cwd.display(),
2250                event_name = "gh_pr_lookup_parse_failed",
2251                message = "gh pr view returned unparseable json",
2252                outcome = "failure",
2253                error = %err,
2254                pr = %number,
2255            );
2256            return Vec::new();
2257        }
2258    };
2259    parsed
2260        .closing_issues
2261        .into_iter()
2262        .map(|issue| GitIssueRef { number: issue.number, url: issue.url })
2263        .collect()
2264}
2265
2266/// `gh pr view --json` wrapper shape.
2267#[derive(Deserialize)]
2268struct GhPrView {
2269    #[serde(default, rename = "closingIssuesReferences")]
2270    closing_issues: Vec<GhIssueEntry>,
2271}
2272
2273/// `closingIssuesReferences` element shape. Same selective-field
2274/// pattern as `GhPrEntry`.
2275#[derive(Deserialize)]
2276struct GhIssueEntry {
2277    number: u64,
2278    url: String,
2279}
2280
2281/// Result of one `git` subprocess invocation. Callers treat all
2282/// failure variants the same way (collapse to `NoRepo` / zero
2283/// stats), but the variants are split so the log captures the right
2284/// context - `Failed` means non-zero exit with stderr that an
2285/// operator might need; `Empty` is a legitimate "ran fine, no
2286/// output" signal (the common case for `status --porcelain` on a
2287/// clean tree).
2288pub(super) enum GitOutput {
2289    Ok(String),
2290    /// Exit 0, empty stdout (legitimate "no output to report" case).
2291    Empty,
2292    /// Timeout, spawn error, interrupted, or non-zero exit. Logged
2293    /// at DEBUG with stderr (truncated) inside `run_git`: a repo or a
2294    /// network that will not answer is the environment's state, every
2295    /// caller degrades to a rendered "(scan failed)", and the GIT
2296    /// section is re-probed on the next tick.
2297    Failed,
2298    /// Stdout exceeded [`STDOUT_SIZE_CAP`].
2299    Oversize,
2300}
2301
2302/// Cap on captured stderr surfaced into the failure log. Far below the
2303/// stdout cap because stderr is conversational - a couple of
2304/// `fatal:` lines is more than enough context.
2305const STDERR_LOG_CAP: usize = 1024;
2306
2307/// `text` cut to `cap` bytes without splitting a character: git's stderr is
2308/// arbitrary bytes, and a slice at a byte offset inside one panics.
2309fn clipped(text: &str, cap: usize) -> String {
2310    if text.len() <= cap {
2311        return text.to_owned();
2312    }
2313    let mut end = cap;
2314    while end > 0 && !text.is_char_boundary(end) {
2315        end -= 1;
2316    }
2317    format!("{}…", &text[..end])
2318}
2319
2320/// Spawn `git <args>` against `cwd`, await with a per-command
2321/// timeout, return classified output. Failures log DEBUG with the
2322/// captured stderr, so triage of "the GIT section shows nothing"
2323/// can still tell "clean tree" from "corrupt index / fatal: …"
2324/// without re-running, at a level that does not report a miss the
2325/// probe makes on every scan of a perfectly ordinary repo.
2326pub(super) async fn run_git(cwd: &Path, args: &[&str]) -> GitOutput {
2327    run_git_within(cwd, args, COMMAND_TIMEOUT).await
2328}
2329
2330/// [`run_git`] with its command budget spelled: the shell gives a `git`
2331/// child [`COMMAND_TIMEOUT`], and a test gives it one of its own.
2332///
2333/// **The budget is a bound on the machine, not on git**: it is spent
2334/// starting the child, so a loaded machine can hold a `git`'s first
2335/// output past the shell's 10 s and fail a scan that is merely slow -
2336/// see `scan_within` for the measurement and the reproduction.
2337pub(super) async fn run_git_within(cwd: &Path, args: &[&str], budget: Duration) -> GitOutput {
2338    let mut command = git_command::tokio_git_command(cwd);
2339    command.arg("-C").arg(cwd).args(args).kill_on_drop(true);
2340    classify_git(cwd, args, command, budget).await
2341}
2342
2343/// One git child's output, classified, with its failures logged.
2344async fn classify_git(
2345    cwd: &Path,
2346    args: &[&str],
2347    mut command: tokio::process::Command,
2348    budget: Duration,
2349) -> GitOutput {
2350    let fut = command.output();
2351    let output = match child_output(fut, budget).await {
2352        Some(Ok(out)) => out,
2353        Some(Err(err)) => {
2354            tracing::debug!(
2355                target: crate::logging::targets::ENV_GIT,
2356                cwd = %cwd.display(),
2357                event_name = "git_subprocess_failed",
2358                message = "git subprocess spawn / wait failed",
2359                outcome = "failure",
2360                error = %err,
2361                args = ?args,
2362            );
2363            return GitOutput::Failed;
2364        }
2365        None => {
2366            tracing::debug!(
2367                target: crate::logging::targets::ENV_GIT,
2368                cwd = %cwd.display(),
2369                event_name = "git_subprocess_timeout",
2370                message = "git subprocess timed out",
2371                outcome = "timeout",
2372                args = ?args,
2373            );
2374            return GitOutput::Failed;
2375        }
2376    };
2377    if !output.status.success() {
2378        // Non-zero exit. The renderer still collapses to NoRepo /
2379        // zero stats - keep the surface failure-tolerant - but record
2380        // the exit code + truncated stderr so triage can tell
2381        // "git: command not found" / "fatal: not a git repository" /
2382        // "fatal: index file corrupt" apart without reproducing.
2383        // Cwd-not-in-a-repo is one of the legitimate hits here, and
2384        // `origin/HEAD` is absent by design in a repo whose remote was
2385        // added rather than cloned, which is why the level is DEBUG.
2386        let stderr = String::from_utf8_lossy(&output.stderr);
2387        let stderr_truncated = clipped(&stderr, STDERR_LOG_CAP);
2388        tracing::debug!(
2389            target: crate::logging::targets::ENV_GIT,
2390            cwd = %cwd.display(),
2391            event_name = "git_subprocess_nonzero_exit",
2392            message = "git subprocess exited non-zero",
2393            outcome = "failure",
2394            exit_code = output.status.code().unwrap_or(-1),
2395            stderr = %stderr_truncated,
2396            args = ?args,
2397        );
2398        return GitOutput::Failed;
2399    }
2400    if output.stdout.len() > STDOUT_SIZE_CAP {
2401        tracing::warn!(
2402            target: crate::logging::targets::ENV_GIT,
2403            cwd = %cwd.display(),
2404            event_name = "git_subprocess_oversize",
2405            message = "git subprocess stdout exceeded size cap",
2406            outcome = "oversize",
2407            bytes = output.stdout.len(),
2408            args = ?args,
2409        );
2410        return GitOutput::Oversize;
2411    }
2412    let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
2413    if stdout.trim().is_empty() { GitOutput::Empty } else { GitOutput::Ok(stdout) }
2414}
2415
2416/// How a `gh` call's "not found" failure should be classified.
2417/// `Tolerate` covers the shape the endpoint legitimately produces for a
2418/// mid-work state (an unpushed HEAD), which is a completed query with a
2419/// definitive negative rather than a failure.
2420///
2421/// `Fail` is a failure, not a level: every arm here records at DEBUG,
2422/// so the two differ in what the caller may conclude from the result
2423/// and not in what the log says.
2424enum GhNotFound {
2425    Fail,
2426    Tolerate,
2427}
2428
2429/// Spawn `gh <args>` from `cwd` (gh derives the github repo from
2430/// the current working directory - there's no `-C` equivalent),
2431/// carrying `env` as the project's own identity.
2432/// Mirrors [`run_git`]'s timeout / classification / DEBUG logging so
2433/// failures distinguish "gh: command not found" (binary missing)
2434/// from "gh: To use GitHub CLI in a Git repository, please run …"
2435/// (not a github remote) from "no pull requests found" (legitimate
2436/// empty result) when triaging "PR row never shows".
2437async fn run_gh<S: std::hash::BuildHasher>(
2438    cwd: &Path,
2439    env: &HashMap<String, String, S>,
2440    args: &[&str],
2441    not_found: GhNotFound,
2442    budget: Duration,
2443) -> GitOutput {
2444    let mut command = git_command::gh_tokio_command(env);
2445    command.current_dir(cwd).args(args).kill_on_drop(true);
2446    let fut = command.output();
2447    let output = match child_output(fut, budget).await {
2448        Some(Ok(out)) => out,
2449        Some(Err(err)) => {
2450            tracing::debug!(
2451                target: crate::logging::targets::ENV_GIT,
2452                cwd = %cwd.display(),
2453                event_name = "gh_subprocess_failed",
2454                message = "gh subprocess spawn / wait failed",
2455                outcome = "failure",
2456                error = %err,
2457                args = ?args,
2458            );
2459            return GitOutput::Failed;
2460        }
2461        None => {
2462            tracing::debug!(
2463                target: crate::logging::targets::ENV_GIT,
2464                cwd = %cwd.display(),
2465                event_name = "gh_subprocess_timeout",
2466                message = "gh subprocess timed out",
2467                outcome = "timeout",
2468                args = ?args,
2469            );
2470            return GitOutput::Failed;
2471        }
2472    };
2473    if !output.status.success() {
2474        let stderr = String::from_utf8_lossy(&output.stderr);
2475        // The commits/<sha>/pulls endpoint answers HTTP 422 with
2476        // this prose when the sha isn't on the remote - an unpushed
2477        // HEAD mid-work, not an operator fault. That is a completed
2478        // query with a definitive negative, not a failure, so it
2479        // returns Empty (PrLookup::None downstream) rather than
2480        // inheriting the keep-cached-PR semantics of Failed.
2481        if matches!(not_found, GhNotFound::Tolerate) && stderr.contains("No commit found for SHA") {
2482            tracing::debug!(
2483                target: crate::logging::targets::ENV_GIT,
2484                cwd = %cwd.display(),
2485                event_name = "gh_pr_lookup_unpushed_sha",
2486                message = "commit not on the remote; PR lookup skipped",
2487                outcome = "no_data",
2488                args = ?args,
2489            );
2490            return GitOutput::Empty;
2491        }
2492        // gh exits non-zero on: missing auth (4), not a github
2493        // remote (1), API error (1). All collapse to "no PR" for
2494        // the renderer, and the log records stderr at DEBUG so triage
2495        // can still tell which case fired.
2496        let stderr_truncated = clipped(&stderr, STDERR_LOG_CAP);
2497        tracing::debug!(
2498            target: crate::logging::targets::ENV_GIT,
2499            cwd = %cwd.display(),
2500            event_name = "gh_subprocess_nonzero_exit",
2501            message = "gh subprocess exited non-zero",
2502            outcome = "failure",
2503            exit_code = output.status.code().unwrap_or(-1),
2504            stderr = %stderr_truncated,
2505            args = ?args,
2506        );
2507        return GitOutput::Failed;
2508    }
2509    if output.stdout.len() > STDOUT_SIZE_CAP {
2510        tracing::warn!(
2511            target: crate::logging::targets::ENV_GIT,
2512            cwd = %cwd.display(),
2513            event_name = "gh_subprocess_oversize",
2514            message = "gh subprocess stdout exceeded size cap",
2515            outcome = "oversize",
2516            bytes = output.stdout.len(),
2517            args = ?args,
2518        );
2519        return GitOutput::Oversize;
2520    }
2521    let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
2522    if stdout.trim().is_empty() { GitOutput::Empty } else { GitOutput::Ok(stdout) }
2523}
2524
2525#[cfg(test)]
2526mod tests {
2527    use super::*;
2528    use std::path::PathBuf;
2529    use std::process::Command as StdCommand;
2530    use tempfile::TempDir;
2531
2532    /// Buffer tracing output so an emitted record can be read back.
2533    #[derive(Clone, Default)]
2534    struct LogCapture(std::sync::Arc<parking_lot::Mutex<Vec<u8>>>);
2535
2536    impl std::io::Write for LogCapture {
2537        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
2538            self.0.lock().extend_from_slice(buf);
2539            Ok(buf.len())
2540        }
2541        fn flush(&mut self) -> std::io::Result<()> {
2542            Ok(())
2543        }
2544    }
2545
2546    impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for LogCapture {
2547        type Writer = Self;
2548        fn make_writer(&'a self) -> Self::Writer {
2549            self.clone()
2550        }
2551    }
2552
2553    /// The budget the PR-lookup cases give their `gh` child: **not the
2554    /// shell's own.** The wait is spent starting that child, and a loaded
2555    /// machine can hold a child's first write past the shell's 10 s -
2556    /// measured 2026-10-14, `the_pr_lookup_spawn_carries_the_projects_env`
2557    /// failed twice at 10.5 s and 10.9 s while a gate ran beside the suite,
2558    /// and a `gh` held back 11 s reproduces it on demand. The stall is the
2559    /// machine's, and no case here pins how long a `gh` may take, so they
2560    /// give the child a budget load cannot eat.
2561    const TEST_LOOKUP_BUDGET: Duration = Duration::from_secs(60);
2562
2563    /// The scan these cases drive: the production entry with the budget the
2564    /// tests give it ([`TEST_GIT_BUDGET`]), so a machine's stall of a `git`
2565    /// child cannot red a case that is not about timing.
2566    async fn scan_in_test(
2567        cwd: &std::path::Path,
2568        env: &HashMap<String, String>,
2569        prev: Option<&GitDiffSnapshot>,
2570    ) -> GitDiffSnapshot {
2571        scan_within(cwd, env, prev, None, PrRefresh::Fetch, true, TEST_GIT_BUDGET).await
2572    }
2573
2574    fn init_repo(dir: &TempDir, initial_branch: &str) {
2575        let path = dir.path();
2576        // The `git init -b <branch>` flag was added in git 2.28; CI
2577        // runners can still be on 2.25 (Ubuntu 20.04's default). To
2578        // stay compatible we `init` without `-b` and then point HEAD
2579        // at the desired branch via `symbolic-ref` - works on every
2580        // git version, including ones where `init.defaultBranch`
2581        // isn't recognised. Each call asserts `status.success()` so
2582        // a silent setup failure surfaces here rather than confusing
2583        // the assertion downstream.
2584        let run = |args: &[&str]| {
2585            let out =
2586                StdCommand::new("git").arg("-C").arg(path).args(args).output().expect("git ok");
2587            assert!(
2588                out.status.success(),
2589                "git {:?} failed in {}: stderr={}",
2590                args,
2591                path.display(),
2592                String::from_utf8_lossy(&out.stderr),
2593            );
2594        };
2595        run(&["init", "-q"]);
2596        run(&["symbolic-ref", "HEAD", &format!("refs/heads/{initial_branch}")]);
2597        run(&["config", "user.email", "test@example.com"]);
2598        run(&["config", "user.name", "Test"]);
2599        run(&["config", "commit.gpgsign", "false"]);
2600    }
2601
2602    fn write_file(dir: &TempDir, name: &str, contents: &str) {
2603        let path = dir.path().join(name);
2604        if let Some(parent) = path.parent() {
2605            std::fs::create_dir_all(parent).expect("mkdir");
2606        }
2607        std::fs::write(&path, contents).expect("write");
2608    }
2609
2610    fn commit_all(dir: &TempDir, message: &str) {
2611        let path = dir.path();
2612        let run = |args: &[&str]| {
2613            StdCommand::new("git").arg("-C").arg(path).args(args).output().expect("git ok");
2614        };
2615        run(&["add", "-A"]);
2616        run(&["commit", "-q", "-m", message]);
2617    }
2618
2619    #[test]
2620    fn parse_numstat_handles_added_removed() {
2621        let raw = "12\t3\tsrc/foo.rs\n5\t0\tsrc/bar.rs\n";
2622        let parsed = parse_numstat(raw);
2623        assert_eq!(parsed.len(), 2);
2624        assert_eq!(parsed[0].path, "src/foo.rs");
2625        assert_eq!(parsed[0].added, 12);
2626        assert_eq!(parsed[0].removed, 3);
2627        assert_eq!(parsed[1].added, 5);
2628        assert_eq!(parsed[1].removed, 0);
2629    }
2630
2631    #[test]
2632    fn parse_numstat_resolves_rename_paths_to_the_new_name() {
2633        // The forms measured from git: braces when the sides share affixes,
2634        // plain `old => new` when they do not. The name-status pass and the
2635        // diff body both speak the new path, so the merge needs it here.
2636        let raw = "0\t0\tdir/{a.txt => b.txt}\n1\t1\tplain.txt => renamed.txt\n3\t0\tkept.txt\n";
2637        let parsed = parse_numstat(raw);
2638        assert_eq!(parsed[0].path, "dir/b.txt", "the brace form resolves to the new path");
2639        assert_eq!(parsed[1].path, "renamed.txt", "the bare form takes the right side");
2640        assert_eq!(parsed[2].path, "kept.txt", "a path without a rename is its own");
2641    }
2642
2643    #[test]
2644    fn parse_numstat_skips_binary_files() {
2645        let raw = "-\t-\tbin/blob.png\n4\t1\tsrc/foo.rs\n";
2646        let parsed = parse_numstat(raw);
2647        assert_eq!(parsed.len(), 1);
2648        assert_eq!(parsed[0].path, "src/foo.rs");
2649    }
2650
2651    #[test]
2652    fn parse_numstat_handles_malformed_lines() {
2653        let raw = "12 missing tabs\n\n5\tnope\n4\t1\tsrc/foo.rs\n";
2654        let parsed = parse_numstat(raw);
2655        assert_eq!(parsed.len(), 1);
2656        assert_eq!(parsed[0].path, "src/foo.rs");
2657    }
2658
2659    #[test]
2660    fn parse_numstat_keeps_tabs_inside_path_via_splitn() {
2661        // `splitn(3, '\t')` should leave the rest-of-line intact as
2662        // the path, including any tabs inside it.
2663        let raw = "4\t1\tsrc/weird\tpath.rs\n";
2664        let parsed = parse_numstat(raw);
2665        assert_eq!(parsed.len(), 1);
2666        assert_eq!(parsed[0].path, "src/weird\tpath.rs");
2667    }
2668
2669    /// `tempfile::tempdir()` produces a dir with no `.git/`, so the
2670    /// repo-existence probe finds nothing and `repo_gate` is NotARepo.
2671    /// The distinction matters for the renderer: a healthy non-repo
2672    /// gets a clean hidden GIT section, while a sick scanner surfaces
2673    /// the unhealthy banner.
2674    #[tokio::test(flavor = "current_thread")]
2675    async fn scan_no_repo_collapses_to_not_in_repo() {
2676        let dir = tempfile::tempdir().expect("tempdir");
2677        let snap = scan_in_test(dir.path(), &HashMap::new(), None).await;
2678        assert_eq!(snap.repo_gate, RepoGate::NotARepo);
2679        assert!(matches!(snap.worktree, LayerState::Clean));
2680        assert!(matches!(snap.branch_ahead, LayerState::Clean));
2681        assert!(snap.default_branch.is_none());
2682    }
2683
2684    /// **The scan's `git` children take the budget the caller gives.** A
2685    /// scan whose budget runs out before `git` answers reads as a failed
2686    /// scan, and the same repo scans cleanly under the budget these cases
2687    /// use - the seam `repo_presence`, the background fetch and every
2688    /// `run_git` spawn now carry, production keeping `COMMAND_TIMEOUT`.
2689    ///
2690    /// **The refusing budget is ZERO, not a small millisecond count**: a
2691    /// child cannot answer on the first poll, so the refusal is a property
2692    /// of the seam rather than a race with a fast machine (a 1 ms budget
2693    /// was - CI's runner answered a `git` command inside it).
2694    #[tokio::test(flavor = "current_thread")]
2695    async fn the_scans_git_commands_take_the_budget_they_are_given() {
2696        let dir = tempfile::tempdir().expect("tempdir");
2697        init_repo(&dir, "feature");
2698        // A commit, so the branch is born: an unborn HEAD fails rev-parse on
2699        // its own, which would let the refusal arm pass for the wrong reason.
2700        write_file(&dir, "README.md", "one\n");
2701        commit_all(&dir, "seed");
2702
2703        // Both refusals run against a `git` that cannot answer inside the
2704        // zero budget: the machine's own can, and did, on CI (#2249).
2705        let bin = tempfile::tempdir().expect("tempdir");
2706        stall_git_at(dir.path(), bin.path());
2707
2708        let refused = scan_within(
2709            dir.path(),
2710            &HashMap::new(),
2711            None,
2712            None,
2713            PrRefresh::Fetch,
2714            true,
2715            Duration::ZERO,
2716        )
2717        .await;
2718        assert_eq!(
2719            refused.repo_gate,
2720            RepoGate::ScannerFailed,
2721            "a budget that runs out before git answers reads as a failed scan",
2722        );
2723
2724        // The seam is not only the scan's first spawn: a helper called
2725        // deeper in refuses a zero budget the same way, so the claim has a
2726        // witness beyond `rev_parse_gate`'s collapse.
2727        let later = numstat(dir.path(), &["diff", "--numstat", "HEAD"], Duration::ZERO).await;
2728        assert!(
2729            matches!(later, Err(NumstatError::Subprocess)),
2730            "a later helper refuses a zero budget too: {later:?}",
2731        );
2732
2733        crate::env::git_command::clear_git_point(dir.path());
2734        let scanned = scan_within(
2735            dir.path(),
2736            &HashMap::new(),
2737            None,
2738            None,
2739            PrRefresh::Fetch,
2740            true,
2741            TEST_GIT_BUDGET,
2742        )
2743        .await;
2744        assert_eq!(scanned.repo_gate, RepoGate::InRepo, "and the tests' budget scans the repo");
2745        assert!(
2746            matches!(scanned.branch, GitBranch::Named(ref name) if name == "feature"),
2747            "with the branch git reported: {:?}",
2748            scanned.branch,
2749        );
2750        assert!(
2751            TEST_GIT_BUDGET >= Duration::from_secs(30),
2752            "the tests' budget must stay far past the shell's own 10 s: a loaded machine held a \
2753             lookup child back for 10.5 s (measured 2026-10-14), the stall this seam exists to \
2754             keep out of the suite",
2755        );
2756    }
2757
2758    #[tokio::test(flavor = "current_thread")]
2759    async fn current_branch_reports_named_branch() {
2760        let dir = tempfile::tempdir().expect("tempdir");
2761        init_repo(&dir, "feat/x");
2762        write_file(&dir, "README.md", "hi\n");
2763        commit_all(&dir, "init");
2764        assert_eq!(current_branch(dir.path()).await, Ok(Some("feat/x".to_owned())));
2765    }
2766
2767    #[tokio::test(flavor = "current_thread")]
2768    async fn current_branch_is_ok_none_on_detached_head() {
2769        let dir = tempfile::tempdir().expect("tempdir");
2770        init_repo(&dir, "main");
2771        write_file(&dir, "README.md", "hi\n");
2772        commit_all(&dir, "init");
2773        let out = StdCommand::new("git")
2774            .arg("-C")
2775            .arg(dir.path())
2776            .args(["checkout", "--detach"])
2777            .output()
2778            .expect("git ok");
2779        assert!(out.status.success(), "detach: {}", String::from_utf8_lossy(&out.stderr));
2780        assert_eq!(
2781            current_branch(dir.path()).await,
2782            Ok(None),
2783            "a detached HEAD is a repo with no branch name, not a failed read",
2784        );
2785    }
2786
2787    /// `git rev-parse --abbrev-ref HEAD` exits 128 both outside a work
2788    /// tree and when it fails inside one, so the exit code alone can't
2789    /// separate them. The repo-existence probe does: a plain directory
2790    /// has no repo, so it is a clean NotARepo rather than a scanner
2791    /// failure, and the renderer suppresses the GIT section.
2792    #[tokio::test(flavor = "current_thread")]
2793    async fn current_branch_reports_not_a_repo_outside_a_repo() {
2794        let dir = tempfile::tempdir().expect("tempdir");
2795        assert_eq!(current_branch(dir.path()).await, Err(RepoGate::NotARepo));
2796    }
2797
2798    /// A path that does not exist has no repo either - git can't even
2799    /// chdir to it - so it lands on the same gate rather than claiming
2800    /// the scanner is sick. `run_git`'s record still carries git's
2801    /// stderr for triage.
2802    #[tokio::test(flavor = "current_thread")]
2803    async fn current_branch_reports_not_a_repo_for_a_missing_directory() {
2804        let dir = tempfile::tempdir().expect("tempdir");
2805        assert_eq!(current_branch(&dir.path().join("gone")).await, Err(RepoGate::NotARepo));
2806    }
2807
2808    /// An unborn HEAD (`git init` with no commits) is the case the exit
2809    /// code cannot be trusted for: `rev-parse --abbrev-ref HEAD` fails
2810    /// because HEAD resolves to nothing, while `--git-dir` succeeds.
2811    #[tokio::test(flavor = "current_thread")]
2812    async fn current_branch_on_an_unborn_head_is_not_reported_as_a_non_repo() {
2813        let dir = tempfile::tempdir().expect("tempdir");
2814        init_repo(&dir, "main");
2815        assert_eq!(
2816            current_branch(dir.path()).await,
2817            Err(RepoGate::ScannerFailed),
2818            "a repo with no commits is still a repo, so it must not be suppressed as NotARepo",
2819        );
2820    }
2821
2822    /// The foreign-repo hazard, end to end: with `GIT_DIR` /
2823    /// `GIT_WORK_TREE` / `GIT_COMMON_DIR` exported at a foreign repo -
2824    /// the shape a git hook or a `rebase --exec` shell hands down -
2825    /// every routed spawn must answer from its own cwd, not the
2826    /// exported repo. The layer cannot be injected in-process (the
2827    /// env is process-global and `unsafe` to set under this edition),
2828    /// so the probe re-execs this test binary as a child with the
2829    /// three variables in its ambient environment and asserts the
2830    /// child's answers. Unscrubbed, `git -C <plain dir> rev-parse
2831    /// --git-dir` exits 0 answering from the foreign repo, so every
2832    /// assertion below fails against the unscrubbed shape.
2833    #[tokio::test(flavor = "current_thread")]
2834    async fn git_layer_answers_from_cwd_not_an_exported_git_dir() {
2835        const PROBE: &str = "FORGE_SCRUB_PROBE";
2836        const PROBE_OK: &str = "FORGE_SCRUB_PROBE_OK";
2837        const PROBE_TEST: &str =
2838            "env::git_diff::tests::git_layer_answers_from_cwd_not_an_exported_git_dir";
2839
2840        let foreign = tempfile::tempdir().expect("foreign tempdir");
2841        let host = tempfile::tempdir().expect("host tempdir");
2842        let plain = tempfile::tempdir().expect("plain tempdir");
2843        init_repo(&foreign, "exported-only-branch");
2844        write_file(&foreign, "README.md", "foreign\n");
2845        commit_all(&foreign, "foreign init");
2846        init_repo(&host, "cwd-repo-branch");
2847        write_file(&host, "README.md", "host\n");
2848        commit_all(&host, "host init");
2849
2850        if std::env::var(PROBE).is_err() {
2851            // Parent mode: export the three repo-location variables at
2852            // the foreign repo and run this test again as a child.
2853            let out = StdCommand::new(std::env::current_exe().expect("current_exe"))
2854                .args(["--exact", PROBE_TEST, "--test-threads=1", "--nocapture"])
2855                .env(PROBE, "1")
2856                .env("GIT_DIR", foreign.path().join(".git"))
2857                .env("GIT_WORK_TREE", foreign.path())
2858                .env("GIT_COMMON_DIR", foreign.path().join(".git"))
2859                .env("FORGE_SCRUB_HOST", host.path())
2860                .env("FORGE_SCRUB_PLAIN", plain.path())
2861                .output()
2862                .expect("spawn probe");
2863            assert!(
2864                out.status.success(),
2865                "probe failed under exported GIT_DIR: {}",
2866                String::from_utf8_lossy(&out.stdout),
2867            );
2868            assert!(
2869                String::from_utf8_lossy(&out.stdout).contains(PROBE_OK),
2870                "the probe never ran (filter matched nothing?)",
2871            );
2872            return;
2873        }
2874
2875        // Child mode: the three variables are ambient here. Fixtures
2876        // already exist (git fixture calls would otherwise init the
2877        // foreign repo); only production spawns run.
2878        let host = PathBuf::from(std::env::var("FORGE_SCRUB_HOST").expect("host path"));
2879        let plain = PathBuf::from(std::env::var("FORGE_SCRUB_PLAIN").expect("plain path"));
2880
2881        assert_eq!(
2882            repo_presence(&plain).await,
2883            RepoPresence::Absent,
2884            "a plain dir with GIT_DIR exported must not answer as a repo",
2885        );
2886        assert!(
2887            !crate::env::worktree::is_git_repo(&plain),
2888            "is_git_repo must not inherit the exported repo",
2889        );
2890        let GitOutput::Ok(branch) = run_git(&host, &["rev-parse", "--abbrev-ref", "HEAD"]).await
2891        else {
2892            panic!("run_git failed against a live repo");
2893        };
2894        assert_eq!(
2895            branch.trim(),
2896            "cwd-repo-branch",
2897            "run_git must answer from -C, not the exported GIT_DIR",
2898        );
2899        println!("{PROBE_OK}");
2900    }
2901
2902    #[tokio::test(flavor = "current_thread")]
2903    async fn scan_clean_default_branch_has_no_layers() {
2904        let dir = tempfile::tempdir().expect("tempdir");
2905        init_repo(&dir, "main");
2906        write_file(&dir, "README.md", "hello\n");
2907        commit_all(&dir, "init");
2908        let snap = scan_in_test(dir.path(), &HashMap::new(), None).await;
2909        assert_eq!(snap.repo_gate, RepoGate::InRepo);
2910        assert!(matches!(snap.worktree, LayerState::Clean), "clean tree → no layer 1");
2911        assert!(matches!(snap.branch_ahead, LayerState::Clean), "on default → no layer 2");
2912        assert_eq!(snap.default_branch.as_deref(), Some("main"));
2913    }
2914
2915    /// **A scan that answers "no PR" still stamps its attempt (#2536).** The
2916    /// watch refreshes on `pr_refresh_due`, which reads that stamp: a lookup
2917    /// that answered nothing (here, an unpushed branch) must not leave the
2918    /// cache looking never-attempted, or every held seat on such a branch
2919    /// would rescan and spawn `gh` on every poke.
2920    #[tokio::test(flavor = "current_thread")]
2921    async fn a_fetch_scan_stamps_an_attempt_when_there_is_no_pr() {
2922        let dir = tempfile::tempdir().expect("tempdir");
2923        init_repo(&dir, "feature");
2924        write_file(&dir, "README.md", "hello\n");
2925        commit_all(&dir, "init");
2926        let snap = scan_in_test(dir.path(), &HashMap::new(), None).await;
2927        assert!(
2928            matches!(snap.branch, GitBranch::Named(ref name) if name == "feature"),
2929            "the scan reads the feature branch",
2930        );
2931        assert_eq!(snap.pr, None, "an unpushed branch has no PR to show");
2932        assert!(snap.pr_fetched_at.is_some(), "but the attempt is stamped");
2933        assert!(
2934            !pr_refresh_due(&snap, std::time::SystemTime::now()),
2935            "so the cache is not due on every poke",
2936        );
2937    }
2938
2939    #[tokio::test(flavor = "current_thread")]
2940    async fn scan_dirty_default_branch_populates_worktree_only() {
2941        let dir = tempfile::tempdir().expect("tempdir");
2942        init_repo(&dir, "main");
2943        write_file(&dir, "README.md", "first\n");
2944        commit_all(&dir, "init");
2945        // Dirty: modify the tracked file without committing.
2946        write_file(&dir, "README.md", "second\nthird\n");
2947        let snap = scan_in_test(dir.path(), &HashMap::new(), None).await;
2948        let LayerState::Populated(stats) = &snap.worktree else {
2949            panic!("layer 1 populated on dirty tree");
2950        };
2951        assert!(
2952            matches!(snap.branch_ahead, LayerState::Clean),
2953            "on default branch → layer 2 stays empty"
2954        );
2955        assert_eq!(stats.total_files, 1);
2956        assert_eq!(stats.files[0].path, "README.md");
2957        assert!(stats.files[0].added >= 1);
2958        assert_eq!(stats.total_added, stats.files[0].added);
2959        assert_eq!(stats.total_removed, stats.files[0].removed);
2960    }
2961
2962    #[tokio::test(flavor = "current_thread")]
2963    async fn scan_clean_feature_branch_populates_branch_ahead_only() {
2964        let dir = tempfile::tempdir().expect("tempdir");
2965        init_repo(&dir, "main");
2966        write_file(&dir, "README.md", "first\n");
2967        commit_all(&dir, "init");
2968        let run = |args: &[&str]| {
2969            StdCommand::new("git").arg("-C").arg(dir.path()).args(args).output().expect("git ok");
2970        };
2971        run(&["checkout", "-q", "-b", "feat/x"]);
2972        write_file(&dir, "feat.rs", "fn x() {}\n");
2973        commit_all(&dir, "feat commit");
2974
2975        let snap = scan_in_test(dir.path(), &HashMap::new(), None).await;
2976        assert!(matches!(snap.worktree, LayerState::Clean), "clean tree → no layer 1");
2977        let LayerState::Populated(ahead) = &snap.branch_ahead else {
2978            panic!("layer 2 populated on feature branch");
2979        };
2980        assert_eq!(ahead.commit_count, 1, "one commit on feature branch beyond main");
2981        assert_eq!(ahead.stats.total_files, 1);
2982        assert_eq!(ahead.stats.files[0].path, "feat.rs");
2983        assert_eq!(snap.default_branch.as_deref(), Some("main"));
2984        assert_eq!(ahead.stats.total_added, ahead.stats.files[0].added);
2985        assert_eq!(ahead.stats.total_removed, ahead.stats.files[0].removed);
2986    }
2987
2988    /// Feature branch with uncommitted edits: both layers must
2989    /// populate independently. Layer 1 carries the dirty tree;
2990    /// layer 2 carries the commit(s) on the feature branch.
2991    #[tokio::test(flavor = "current_thread")]
2992    async fn scan_dirty_feature_branch_populates_both_layers() {
2993        let dir = tempfile::tempdir().expect("tempdir");
2994        init_repo(&dir, "main");
2995        write_file(&dir, "README.md", "first\n");
2996        commit_all(&dir, "init");
2997        let run = |args: &[&str]| {
2998            StdCommand::new("git").arg("-C").arg(dir.path()).args(args).output().expect("git ok");
2999        };
3000        run(&["checkout", "-q", "-b", "feat/x"]);
3001        write_file(&dir, "feat.rs", "fn x() {}\n");
3002        commit_all(&dir, "feat commit");
3003        // Dirty the worktree AFTER the feature commit.
3004        write_file(&dir, "feat.rs", "fn x() {}\nfn y() {}\n");
3005
3006        let snap = scan_in_test(dir.path(), &HashMap::new(), None).await;
3007        let LayerState::Populated(worktree) = &snap.worktree else {
3008            panic!("layer 1 populated on dirty tree");
3009        };
3010        let LayerState::Populated(ahead) = &snap.branch_ahead else {
3011            panic!("layer 2 populated on feature branch");
3012        };
3013        // Layer 1: in-progress edits to feat.rs.
3014        assert_eq!(worktree.total_files, 1);
3015        assert_eq!(worktree.files[0].path, "feat.rs");
3016        // Layer 2: the committed-and-unmerged work (the original
3017        // `feat commit` against main).
3018        assert_eq!(ahead.commit_count, 1);
3019        assert_eq!(ahead.stats.total_files, 1);
3020        assert_eq!(ahead.stats.files[0].path, "feat.rs");
3021        assert_eq!(
3022            ahead.stats.files[0].status,
3023            forge_primitives::git::FileStatus::Added,
3024            "the range's added file wears Added, not the numstat default"
3025        );
3026        // And the chain carries each commit's own files, marked the same way.
3027        assert_eq!(ahead.commits.len(), 1, "the chain is the one feature commit");
3028        assert_eq!(ahead.commits[0].stats.files[0].path, "feat.rs", "the commit's own file");
3029        assert_eq!(
3030            ahead.commits[0].stats.files[0].status,
3031            forge_primitives::git::FileStatus::Added,
3032            "with the commit's own mark"
3033        );
3034        assert_eq!(ahead.commits[0].stats.files[0].added, 1, "and the commit's own count");
3035        assert!(ahead.commits[0].time > 0, "the commit carries its instant");
3036    }
3037
3038    #[tokio::test(flavor = "current_thread")]
3039    async fn scan_detached_dirty_populates_worktree_no_branch_ahead() {
3040        let dir = tempfile::tempdir().expect("tempdir");
3041        init_repo(&dir, "main");
3042        write_file(&dir, "README.md", "first\n");
3043        commit_all(&dir, "init");
3044        write_file(&dir, "README.md", "second\n");
3045        commit_all(&dir, "second");
3046        let run = |args: &[&str]| {
3047            StdCommand::new("git").arg("-C").arg(dir.path()).args(args).output().expect("git ok");
3048        };
3049        run(&["checkout", "-q", "HEAD~1"]);
3050        // Dirty in detached state.
3051        write_file(&dir, "README.md", "third\n");
3052
3053        let snap = scan_in_test(dir.path(), &HashMap::new(), None).await;
3054        assert!(snap.worktree.is_populated(), "detached + dirty → layer 1 populated");
3055        assert!(
3056            matches!(snap.branch_ahead, LayerState::Clean),
3057            "detached HEAD has no branch name, layer 2 stays empty"
3058        );
3059    }
3060
3061    #[tokio::test(flavor = "current_thread")]
3062    async fn scan_detached_clean_has_no_layers() {
3063        let dir = tempfile::tempdir().expect("tempdir");
3064        init_repo(&dir, "main");
3065        write_file(&dir, "README.md", "first\n");
3066        commit_all(&dir, "init");
3067        write_file(&dir, "README.md", "second\n");
3068        commit_all(&dir, "second");
3069        let run = |args: &[&str]| {
3070            StdCommand::new("git").arg("-C").arg(dir.path()).args(args).output().expect("git ok");
3071        };
3072        run(&["checkout", "-q", "HEAD~1"]);
3073        let snap = scan_in_test(dir.path(), &HashMap::new(), None).await;
3074        assert_eq!(snap.repo_gate, RepoGate::InRepo);
3075        assert!(matches!(snap.worktree, LayerState::Clean));
3076        assert!(matches!(snap.branch_ahead, LayerState::Clean));
3077        assert_eq!(snap.pr, None, "detached HEAD resolves no PR row");
3078        assert_eq!(snap.pushed_sha, None, "the pushed-sha walk is skipped when detached");
3079    }
3080
3081    /// The pushed-sha walk resolves a DIVERGED upstream: the remote
3082    /// has commits HEAD lacks (a sibling commit the remote gained),
3083    /// and the walk still stops at HEAD's newest pushed ancestor -
3084    /// never the sibling, which is outside HEAD's ancestry.
3085    #[tokio::test(flavor = "current_thread")]
3086    async fn pushed_sha_walk_resolves_pushed_ancestor_when_upstream_diverged() {
3087        let dir = tempfile::tempdir().expect("tempdir");
3088        init_repo(&dir, "audit/x");
3089        write_file(&dir, "README.md", "one\n");
3090        commit_all(&dir, "pushed commit");
3091        let pushed = rev_parse_head(&dir);
3092        let run = |args: &[&str]| {
3093            StdCommand::new("git").arg("-C").arg(dir.path()).args(args).output().expect("git ok");
3094        };
3095        run(&["update-ref", "refs/remotes/origin/audit/x", &pushed]);
3096        // A remote-only sibling commit: same parent as the pushed
3097        // tip, so the remote history diverged - HEAD lacks it.
3098        let out = StdCommand::new("git")
3099            .arg("-C")
3100            .arg(dir.path())
3101            .args(["commit-tree", "HEAD^{tree}", "-p", &pushed, "-m", "remote-only"])
3102            .output()
3103            .expect("git ok");
3104        assert!(out.status.success(), "commit-tree failed");
3105        let remote_only = String::from_utf8_lossy(&out.stdout).trim().to_owned();
3106        run(&["update-ref", "refs/remotes/origin/main", &remote_only]);
3107        write_file(&dir, "more.rs", "y\n");
3108        commit_all(&dir, "unpushed commit");
3109
3110        assert_eq!(
3111            resolve_pushed_sha(dir.path(), 500, TEST_GIT_BUDGET).await.as_deref(),
3112            Some(pushed.as_str()),
3113            "the sibling remote-only commit is outside HEAD's ancestry and must not resolve"
3114        );
3115    }
3116
3117    /// The unstick trap pinned at the cache-decision level: a cached
3118    /// `None` (scan ran before the PR was opened) goes stale on the
3119    /// same timer and refetches even with branch and sha unchanged.
3120    #[test]
3121    fn pr_cache_fresh_refetches_a_cached_none_when_stale() {
3122        let old = std::time::SystemTime::now()
3123            .checked_sub(PR_REFETCH_INTERVAL + std::time::Duration::from_secs(1))
3124            .expect("wall clock is at least PR_REFETCH_INTERVAL + 1s past boot");
3125        let prev = GitDiffSnapshot {
3126            branch: GitBranch::Named("feat/x".into()),
3127            default_branch: Some("main".into()),
3128            repo_gate: RepoGate::InRepo,
3129            pushed_sha: Some("aaaa".into()),
3130            worktree: LayerState::Clean,
3131            branch_ahead: LayerState::Clean,
3132            pr: None,
3133            closes: Vec::new(),
3134            pr_fetched_at: Some(old),
3135        };
3136        assert!(
3137            !pr_cache_fresh(Some(&prev), "feat/x", Some("aaaa"), std::time::SystemTime::now()),
3138            "stale None must refetch so a PR opened after the first scan shows up"
3139        );
3140    }
3141
3142    /// An unborn HEAD (fresh init, no commits): the scan collapses to
3143    /// the scanner-failed gate and the PR side stays dark - no row,
3144    /// no panic.
3145    #[tokio::test(flavor = "current_thread")]
3146    async fn scan_on_an_unborn_head_has_no_pr_row() {
3147        let dir = tempfile::tempdir().expect("tempdir");
3148        init_repo(&dir, "main");
3149        let snap = scan_in_test(dir.path(), &HashMap::new(), None).await;
3150        assert_eq!(snap.repo_gate, RepoGate::ScannerFailed);
3151        assert_eq!(snap.pr, None);
3152        assert_eq!(snap.pushed_sha, None);
3153        assert_eq!(snap.pr_fetched_at, None);
3154    }
3155
3156    #[tokio::test(flavor = "current_thread")]
3157    async fn scan_dirty_default_branch_aggregates_totals_across_files() {
3158        let dir = tempfile::tempdir().expect("tempdir");
3159        init_repo(&dir, "main");
3160        write_file(&dir, "a.txt", "1\n2\n3\n");
3161        write_file(&dir, "b.txt", "1\n");
3162        commit_all(&dir, "init");
3163        // Add a line to a.txt, replace b.txt with a longer body, add
3164        // a new tracked file. Total adds should sum across all
3165        // changed files.
3166        write_file(&dir, "a.txt", "1\n2\n3\n4\n");
3167        write_file(&dir, "b.txt", "x\ny\nz\n");
3168        write_file(&dir, "c.txt", "new\n");
3169        // Stage c.txt so it shows up in `git diff HEAD`.
3170        StdCommand::new("git")
3171            .arg("-C")
3172            .arg(dir.path())
3173            .args(["add", "c.txt"])
3174            .output()
3175            .expect("git ok");
3176
3177        let snap = scan_in_test(dir.path(), &HashMap::new(), None).await;
3178        let LayerState::Populated(stats) = &snap.worktree else {
3179            panic!("dirty tree → layer 1 populated");
3180        };
3181        assert_eq!(stats.total_files, 3);
3182        // Sum of per-file added/removed equals the overall total.
3183        let per_file_added: u32 = stats.files.iter().map(|f| f.added).sum();
3184        let per_file_removed: u32 = stats.files.iter().map(|f| f.removed).sum();
3185        assert_eq!(stats.total_added, per_file_added);
3186        assert_eq!(stats.total_removed, per_file_removed);
3187        // At least one row in each direction (we added 1+3+1 lines
3188        // and removed 1).
3189        assert!(stats.total_added >= 5);
3190        assert!(stats.total_removed >= 1);
3191    }
3192
3193    #[test]
3194    fn top_files_sort_by_total_changes_then_alpha() {
3195        let mut files = [
3196            GitDiffFile {
3197                path: "small.rs".into(),
3198                added: 1,
3199                removed: 0,
3200                status: FileStatus::Modified,
3201            },
3202            GitDiffFile {
3203                path: "big-b.rs".into(),
3204                added: 50,
3205                removed: 10,
3206                status: FileStatus::Modified,
3207            },
3208            GitDiffFile {
3209                path: "big-a.rs".into(),
3210                added: 50,
3211                removed: 10,
3212                status: FileStatus::Modified,
3213            },
3214            GitDiffFile {
3215                path: "medium.rs".into(),
3216                added: 10,
3217                removed: 5,
3218                status: FileStatus::Modified,
3219            },
3220        ];
3221        files.sort_by(|a, b| {
3222            let a_total = a.added.saturating_add(a.removed);
3223            let b_total = b.added.saturating_add(b.removed);
3224            b_total.cmp(&a_total).then_with(|| a.path.cmp(&b.path))
3225        });
3226        assert_eq!(files[0].path, "big-a.rs");
3227        assert_eq!(files[1].path, "big-b.rs");
3228        assert_eq!(files[2].path, "medium.rs");
3229        assert_eq!(files[3].path, "small.rs");
3230    }
3231
3232    /// Cache hit: same branch, same pushed sha, fetched inside the
3233    /// 5-minute window. The reuse that keeps steady-state 10 s scans
3234    /// gh-free.
3235    #[test]
3236    fn pr_cache_fresh_reuses_when_branch_sha_and_age_hold() {
3237        let prev = snapshot_with_pr(42);
3238        assert!(pr_cache_fresh(Some(&prev), "feat/x", Some("aaaa"), std::time::SystemTime::now()));
3239    }
3240
3241    /// Refetch trigger: the pushed sha moved (new push / rebase
3242    /// landed on the remote), so the cached PR may no longer be the
3243    /// one containing the branch's pushed tip.
3244    #[test]
3245    fn pr_cache_fresh_refetches_when_pushed_sha_changes() {
3246        let prev = snapshot_with_pr(42);
3247        assert!(
3248            !pr_cache_fresh(Some(&prev), "feat/x", Some("bbbb"), std::time::SystemTime::now()),
3249            "sha change must invalidate the cached PR"
3250        );
3251    }
3252
3253    /// Refetch trigger: the fetch is older than the interval, even
3254    /// with branch and sha unchanged - unsticks a cached `None` from
3255    /// before the PR was opened.
3256    #[test]
3257    fn pr_cache_fresh_refetches_when_fetch_is_stale() {
3258        let prev = snapshot_with_pr(42);
3259        let old = std::time::SystemTime::now()
3260            .checked_sub(PR_REFETCH_INTERVAL + std::time::Duration::from_secs(1))
3261            .expect("monotonic wall clock is at least PR_REFETCH_INTERVAL + 1s past boot");
3262        assert!(
3263            !pr_cache_fresh(Some(&prev), "feat/x", Some("aaaa"), old),
3264            "stale fetch must refetch so a PR opened after the first scan shows up"
3265        );
3266    }
3267
3268    /// **A serving lookup answers only what belongs to this branch and sha.**
3269    /// The store's PR is served when it is this row's; another branch's or
3270    /// another push's is not - the shape a branch switch or a push makes, and
3271    /// serving it would put one seat's PR on another's row.
3272    #[tokio::test(flavor = "current_thread")]
3273    async fn serve_cached_answers_nothing_for_another_branch_or_sha() {
3274        let dir = tempfile::tempdir().expect("tempdir");
3275        let prev = snapshot_with_pr_at(42, Some(600));
3276        let budget = TEST_GIT_BUDGET;
3277
3278        let (pr, closes, at) = pr_for_head_within(
3279            dir.path(),
3280            &HashMap::new(),
3281            "feat/y",
3282            Some("aaaa"),
3283            Some(&prev),
3284            PrRefresh::ServeCached,
3285            budget,
3286        )
3287        .await;
3288        assert_eq!(pr, None, "another branch's PR is not this row's");
3289        assert!(closes.is_empty(), "nor are its closing issues");
3290        assert_eq!(at, None, "and no attempt time is claimed for it");
3291
3292        let (pr, _, at) = pr_for_head_within(
3293            dir.path(),
3294            &HashMap::new(),
3295            "feat/x",
3296            Some("bbbb"),
3297            Some(&prev),
3298            PrRefresh::ServeCached,
3299            budget,
3300        )
3301        .await;
3302        assert_eq!(pr, None, "another push's PR is not this row's");
3303        assert_eq!(at, None, "and no attempt time is claimed for it");
3304
3305        let (pr, _, at) = pr_for_head_within(
3306            dir.path(),
3307            &HashMap::new(),
3308            "feat/x",
3309            Some("aaaa"),
3310            Some(&prev),
3311            PrRefresh::ServeCached,
3312            budget,
3313        )
3314        .await;
3315        assert_eq!(pr.map(|found| found.number), Some(42), "its own PR is served");
3316        assert_eq!(at, prev.pr_fetched_at, "with the attempt time the store holds");
3317    }
3318
3319    /// **The watch's PR-due leg follows the scan's own eligibility.**
3320    /// A feature branch past the window is due; a fresh attempt is not; and a
3321    /// checkout ON the default has no PR against itself, so its absent attempt
3322    /// time reads "nothing to fetch" rather than "due" - the shape that had
3323    /// every on-default fetch scan re-read the tree.
3324    #[test]
3325    fn pr_refresh_due_follows_the_scans_own_eligibility() {
3326        // The attempts are stamped before `now`, so the fresh one is inside
3327        // the window rather than a clock tick past it.
3328        let fresh = snapshot_with_pr(42);
3329        let stale = snapshot_with_pr_at(42, Some(600));
3330        let mut on_default = snapshot_with_pr_at(42, None);
3331        on_default.branch = GitBranch::Named("main".into());
3332        let now = std::time::SystemTime::now();
3333
3334        assert!(pr_refresh_due(&stale, now), "a stale attempt on a feature branch is due");
3335        assert!(!pr_refresh_due(&fresh, now), "an attempt inside the window is not due");
3336        assert!(
3337            !pr_refresh_due(&on_default, now),
3338            "a branch checked out on the default has nothing to look up",
3339        );
3340        // A feature branch with no attempt on record is what a first open or a
3341        // branch switch writes; it is due, or the cache would never fill.
3342        assert!(
3343            pr_refresh_due(&snapshot_with_pr_at(42, None), now),
3344            "a feature branch never attempted is due",
3345        );
3346    }
3347
3348    /// `pr_for_head_within` short-circuits the `gh` calls when the cache
3349    /// is fresh - even when the `cwd` would make a real `gh` invocation
3350    /// fail (tempdir has no github remote). The returned `pr` /
3351    /// `closes` MUST be clones of `prev`'s, proving the cache hit.
3352    #[tokio::test(flavor = "current_thread")]
3353    async fn pr_for_head_reuses_prev_when_cache_fresh() {
3354        let dir = tempfile::tempdir().expect("tempdir");
3355        let pr = GitPrInfo { number: 42, url: "https://example/pull/42".into(), draft: false };
3356        let closes = vec![GitIssueRef { number: 7, url: "https://example/issues/7".into() }];
3357        let prev = GitDiffSnapshot {
3358            branch: GitBranch::Named("feat/x".into()),
3359            default_branch: Some("main".into()),
3360            repo_gate: RepoGate::InRepo,
3361            pushed_sha: Some("aaaa".into()),
3362            worktree: LayerState::Clean,
3363            branch_ahead: LayerState::Clean,
3364            pr: Some(pr.clone()),
3365            closes: closes.clone(),
3366            pr_fetched_at: Some(std::time::SystemTime::now()),
3367        };
3368
3369        let (got_pr, got_closes, got_at) = pr_for_head_within(
3370            dir.path(),
3371            &HashMap::new(),
3372            "feat/x",
3373            Some("aaaa"),
3374            Some(&prev),
3375            PrRefresh::Fetch,
3376            COMMAND_TIMEOUT,
3377        )
3378        .await;
3379        assert_eq!(got_pr, Some(pr));
3380        assert_eq!(got_closes, closes);
3381        assert_eq!(got_at, prev.pr_fetched_at);
3382    }
3383
3384    /// A stale cache refetches - and when the refetch FAILS (tempdir
3385    /// makes `gh` fail whether or not it is installed), the known-good
3386    /// cached PR survives instead of flashing off: a gh blip must not
3387    /// evict the row.
3388    #[tokio::test(flavor = "current_thread")]
3389    async fn pr_for_head_keeps_cached_pr_when_refetch_fails() {
3390        let dir = tempfile::tempdir().expect("tempdir");
3391        let stale_at = std::time::SystemTime::now()
3392            .checked_sub(PR_REFETCH_INTERVAL + std::time::Duration::from_secs(1))
3393            .expect("wall clock is at least PR_REFETCH_INTERVAL + 1s past boot");
3394        let prev = GitDiffSnapshot {
3395            branch: GitBranch::Named("feat/x".into()),
3396            default_branch: Some("main".into()),
3397            repo_gate: RepoGate::InRepo,
3398            pushed_sha: Some("aaaa".into()),
3399            worktree: LayerState::Clean,
3400            branch_ahead: LayerState::Clean,
3401            pr: Some(GitPrInfo { number: 42, url: "https://example/pull/42".into(), draft: false }),
3402            closes: Vec::new(),
3403            pr_fetched_at: Some(stale_at),
3404        };
3405
3406        let (got_pr, _got_closes, got_at) = pr_for_head_within(
3407            dir.path(),
3408            &HashMap::new(),
3409            "feat/x",
3410            Some("aaaa"),
3411            Some(&prev),
3412            PrRefresh::Fetch,
3413            COMMAND_TIMEOUT,
3414        )
3415        .await;
3416        assert_eq!(got_pr.as_ref().map(|p| p.number), Some(42), "failed refetch keeps the row");
3417        let now = std::time::SystemTime::now();
3418        assert!(
3419            got_at.is_some_and(|at| now.duration_since(at).is_ok_and(|d| d.as_secs() < 60)),
3420            "the refetch attempt must be stamped so the next retry waits for the interval"
3421        );
3422    }
3423
3424    /// A failed refetch after a BRANCH change has no matching cached
3425    /// PR to keep - the row clears rather than showing the previous
3426    /// branch's PR.
3427    #[tokio::test(flavor = "current_thread")]
3428    async fn pr_for_head_clears_failed_refetch_when_branch_changed() {
3429        let dir = tempfile::tempdir().expect("tempdir");
3430        let stale_at = std::time::SystemTime::now()
3431            .checked_sub(PR_REFETCH_INTERVAL + std::time::Duration::from_secs(1))
3432            .expect("wall clock is at least PR_REFETCH_INTERVAL + 1s past boot");
3433        let prev = GitDiffSnapshot {
3434            branch: GitBranch::Named("feat/x".into()),
3435            default_branch: Some("main".into()),
3436            repo_gate: RepoGate::InRepo,
3437            pushed_sha: Some("aaaa".into()),
3438            worktree: LayerState::Clean,
3439            branch_ahead: LayerState::Clean,
3440            pr: Some(GitPrInfo { number: 42, url: "https://example/pull/42".into(), draft: false }),
3441            closes: Vec::new(),
3442            pr_fetched_at: Some(stale_at),
3443        };
3444
3445        let (got_pr, got_closes, got_at) = pr_for_head_within(
3446            dir.path(),
3447            &HashMap::new(),
3448            "feat/y",
3449            Some("aaaa"),
3450            Some(&prev),
3451            PrRefresh::Fetch,
3452            COMMAND_TIMEOUT,
3453        )
3454        .await;
3455        assert_eq!(got_pr, None, "a failed lookup must not show another branch's PR");
3456        assert!(got_closes.is_empty());
3457        assert!(got_at.is_some());
3458    }
3459
3460    /// The spawn a project's PR read makes carries that project's own
3461    /// identity: the env handed to `scan` reaches the `gh` child. Which
3462    /// ambient variables come off is pinned in `git_command`; this pins
3463    /// that the env is threaded rather than dropped on the way down.
3464    /// Real git for the repo, and a `gh` of our own the child finds
3465    /// through the PATH the project env carries, so the identity it was
3466    /// handed is readable afterwards.
3467    #[tokio::test(flavor = "current_thread")]
3468    async fn the_pr_lookup_spawn_carries_the_projects_env() {
3469        let recorded = scan_with_own_gh(Duration::ZERO, TEST_LOOKUP_BUDGET)
3470            .await
3471            .expect("the scan's gh child ran and recorded the identity it was handed");
3472        assert_eq!(recorded, "project-token", "the PR lookup spawns gh as the project");
3473    }
3474
3475    /// **The PR lookup's wait is the budget it is given.** A `gh` child held
3476    /// back past a small budget is refused - the lookup never lands - and
3477    /// the same child is waited out by the budget these cases use, which is
3478    /// the seam `the_pr_lookup_spawn_carries_the_projects_env` rests on: the
3479    /// wait belongs to the machine, not to the lookup.
3480    #[tokio::test(flavor = "current_thread")]
3481    async fn the_pr_lookup_is_bounded_by_the_budget_it_is_given() {
3482        let refused = scan_with_own_gh(Duration::from_secs(3), Duration::from_secs(1)).await;
3483        assert!(
3484            refused.is_none(),
3485            "a budget that runs out before the child answers leaves the lookup unrun: {refused:?}",
3486        );
3487
3488        let waited = scan_with_own_gh(Duration::from_secs(3), TEST_LOOKUP_BUDGET).await;
3489        assert_eq!(
3490            waited.as_deref(),
3491            Some("project-token"),
3492            "and the tests' budget waits that child out",
3493        );
3494        assert!(
3495            TEST_LOOKUP_BUDGET >= Duration::from_secs(30),
3496            "the tests' budget must stay far past the shell's own 10 s: a loaded machine held a \
3497             lookup child back for 10.5 s (measured 2026-10-14), which is the stall this seam \
3498             exists to keep out of the suite",
3499        );
3500    }
3501
3502    /// The gh-identity fixture: a repo whose tip reads as pushed, and a `gh`
3503    /// of our own on the project's PATH that records the identity it was
3504    /// handed after holding its answer back `stall`. Returns what the scan
3505    /// read from that record (`None` when the child never wrote it).
3506    ///
3507    /// **The stall spells `/bin/sleep`**: the child's PATH is the fixture's
3508    /// own bin directory alone, so a bare `sleep` is not found and the stall
3509    /// silently never happens.
3510    async fn scan_with_own_gh(stall: Duration, budget: Duration) -> Option<String> {
3511        use std::os::unix::fs::PermissionsExt;
3512
3513        let dir = tempfile::tempdir().expect("tempdir");
3514        init_repo(&dir, "feature");
3515        let run = |args: &[&str]| {
3516            let out =
3517                StdCommand::new("git").arg("-C").arg(dir.path()).args(args).output().expect("git");
3518            assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
3519        };
3520        write_file(&dir, "README.md", "one\n");
3521        commit_all(&dir, "pushed commit");
3522        // Simulate the push: the tip is on a remote-tracking ref and the
3523        // checkout is not the default branch, so the scan runs its PR
3524        // lookup against it.
3525        run(&["update-ref", "refs/remotes/origin/main", "HEAD"]);
3526        run(&["symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/main"]);
3527
3528        let bin = dir.path().join("bin");
3529        std::fs::create_dir_all(&bin).expect("bin dir");
3530        let seen = dir.path().join("gh-seen-identity");
3531        let gh = bin.join("gh");
3532        let hold = if stall.is_zero() {
3533            String::new()
3534        } else {
3535            format!("/bin/sleep {}\n", stall.as_secs_f64())
3536        };
3537        std::fs::write(
3538            &gh,
3539            format!(
3540                "#!/bin/sh\n{hold}printf '%s' \"$GH_TOKEN\" > {}\nprintf '[]'\n",
3541                seen.display()
3542            ),
3543        )
3544        .expect("write gh");
3545        std::fs::set_permissions(&gh, std::fs::Permissions::from_mode(0o755)).expect("chmod gh");
3546        let env = HashMap::from([
3547            ("PATH".to_owned(), bin.display().to_string()),
3548            ("GH_TOKEN".to_owned(), "project-token".to_owned()),
3549        ]);
3550
3551        scan_within(dir.path(), &env, None, None, PrRefresh::Fetch, true, budget).await;
3552        std::fs::read_to_string(&seen).ok()
3553    }
3554
3555    /// The incident shape: a worktree whose local branch name differs
3556    /// from the PR's head ref still resolves its PR, because the
3557    /// lookup key is the pushed ancestor sha, not the branch name.
3558    /// Real git, no network: `origin`'s tracking ref is simulated
3559    /// with `update-ref`, exactly what a `HEAD:<name>` push + fetch
3560    /// leaves behind.
3561    #[tokio::test(flavor = "current_thread")]
3562    async fn pushed_sha_walk_resolves_pushed_ancestor_when_worktree_is_ahead() {
3563        let dir = tempfile::tempdir().expect("tempdir");
3564        init_repo(&dir, "worktree-inspector-pr-row");
3565        write_file(&dir, "README.md", "one\n");
3566        commit_all(&dir, "pushed commit");
3567        let pushed = rev_parse_head(&dir);
3568        let run = |args: &[&str]| {
3569            StdCommand::new("git").arg("-C").arg(dir.path()).args(args).output().expect("git ok");
3570        };
3571        // Simulate the push as `HEAD:audit/2026-09-03` + fetch: the
3572        // local branch stays `worktree-inspector-pr-row` while the
3573        // remote tracking ref names the pushed target.
3574        run(&["update-ref", "refs/remotes/origin/audit/2026-09-03", &pushed]);
3575        write_file(&dir, "more.rs", "fn y() {}\n");
3576        commit_all(&dir, "unpushed commit");
3577        write_file(&dir, "more2.rs", "fn z() {}\n");
3578        commit_all(&dir, "unpushed commit 2");
3579
3580        assert_eq!(
3581            resolve_pushed_sha(dir.path(), 500, TEST_GIT_BUDGET).await.as_deref(),
3582            Some(pushed.as_str()),
3583            "the walk must stop at the pushed ancestor, not the unpushed tip"
3584        );
3585    }
3586
3587    /// A worktree sitting exactly on its pushed tip resolves the tip
3588    /// itself (the names-match happy path).
3589    #[tokio::test(flavor = "current_thread")]
3590    async fn pushed_sha_walk_resolves_tip_when_pushed() {
3591        let dir = tempfile::tempdir().expect("tempdir");
3592        init_repo(&dir, "main");
3593        write_file(&dir, "README.md", "one\n");
3594        commit_all(&dir, "pushed commit");
3595        let pushed = rev_parse_head(&dir);
3596        StdCommand::new("git")
3597            .arg("-C")
3598            .arg(dir.path())
3599            .args(["update-ref", "refs/remotes/origin/main", &pushed])
3600            .output()
3601            .expect("git ok");
3602
3603        assert_eq!(
3604            resolve_pushed_sha(dir.path(), 500, TEST_GIT_BUDGET).await.as_deref(),
3605            Some(pushed.as_str())
3606        );
3607    }
3608
3609    /// Nothing pushed - no remote refs share any of HEAD's history.
3610    /// No row, and the walk terminates rather than scanning the repo.
3611    #[tokio::test(flavor = "current_thread")]
3612    async fn pushed_sha_walk_returns_none_when_nothing_pushed() {
3613        let dir = tempfile::tempdir().expect("tempdir");
3614        init_repo(&dir, "main");
3615        write_file(&dir, "README.md", "one\n");
3616        commit_all(&dir, "local commit");
3617
3618        assert_eq!(resolve_pushed_sha(dir.path(), 500, TEST_GIT_BUDGET).await, None);
3619    }
3620
3621    /// A pathological unpushed history terminates at the cap: shas
3622    /// beyond `cap` first-parent commits are never examined, so a
3623    /// pushed ancestor outside the cap does not resolve (truthful
3624    /// "no row" rather than an unbounded walk).
3625    #[tokio::test(flavor = "current_thread")]
3626    async fn pushed_sha_walk_caps_at_max_count() {
3627        let dir = tempfile::tempdir().expect("tempdir");
3628        init_repo(&dir, "main");
3629        write_file(&dir, "README.md", "one\n");
3630        commit_all(&dir, "pushed commit");
3631        let pushed = rev_parse_head(&dir);
3632        StdCommand::new("git")
3633            .arg("-C")
3634            .arg(dir.path())
3635            .args(["update-ref", "refs/remotes/origin/main", &pushed])
3636            .output()
3637            .expect("git ok");
3638        for i in 0..4 {
3639            write_file(&dir, &format!("f{i}.rs"), "x\n");
3640            commit_all(&dir, &format!("unpushed {i}"));
3641        }
3642
3643        // Cap 2 reaches only the newest 2 unpushed commits; the
3644        // pushed ancestor at depth 5 stays out of reach.
3645        assert_eq!(resolve_pushed_sha(dir.path(), 2, TEST_GIT_BUDGET).await, None);
3646        // Full cap reaches it.
3647        assert_eq!(
3648            resolve_pushed_sha(dir.path(), 500, TEST_GIT_BUDGET).await.as_deref(),
3649            Some(pushed.as_str())
3650        );
3651    }
3652
3653    /// The open-filter + newest-updatedAt selection against the
3654    /// `commits/<sha>/pulls` REST shape: a closed PR with a NEWER
3655    /// updated_at must lose to an open PR, and two open PRs pick the
3656    /// most recently updated one (the actively-evolving stacked PR).
3657    #[test]
3658    fn pick_open_pr_filters_to_open_and_picks_newest_updated_at() {
3659        let raw = concat!(
3660            r#"[{"number":900,"state":"closed","html_url":"https://example/pull/900","updated_at":"2026-09-03T15:00:00Z"},"#,
3661            r#"{"number":858,"state":"open","html_url":"https://example/pull/858","updated_at":"2026-09-03T10:00:00Z"},"#,
3662            r#"{"number":859,"state":"open","html_url":"https://example/pull/859","updated_at":"2026-09-03T12:00:00Z"}]"#
3663        );
3664        let picked = pick_open_pr(raw).expect("parses");
3665        assert_eq!(
3666            picked,
3667            Some((859, "https://example/pull/859".to_owned(), false)),
3668            "open filter first, then most recently updated wins"
3669        );
3670    }
3671
3672    /// Equal `updated_at` breaks deterministically on the higher
3673    /// number, so the pick never depends on response ordering.
3674    #[test]
3675    fn pick_open_pr_ties_break_on_higher_number() {
3676        let raw = concat!(
3677            r#"[{"number":401,"state":"open","html_url":"https://example/pull/401","updated_at":"2026-09-03T12:00:00Z"},"#,
3678            r#"{"number":402,"state":"open","html_url":"https://example/pull/402","updated_at":"2026-09-03T12:00:00Z"}]"#
3679        );
3680        assert_eq!(
3681            pick_open_pr(raw).expect("parses"),
3682            Some((402, "https://example/pull/402".to_owned(), false))
3683        );
3684    }
3685
3686    /// Only merged/closed PRs contain the sha: no row. The
3687    /// open-only invariant means a merged PR row can never render.
3688    #[test]
3689    fn pick_open_pr_returns_none_when_only_closed_prs() {
3690        let raw = r#"[{"number":857,"state":"closed","html_url":"https://example/pull/857","updated_at":"2026-09-03T13:16:33Z"}]"#;
3691        assert_eq!(pick_open_pr(raw).expect("parses"), None);
3692    }
3693
3694    /// Garbage JSON is an Err (the caller logs
3695    /// `gh_pr_lookup_parse_failed`), never a silent None.
3696    #[test]
3697    fn pick_open_pr_reports_parse_error() {
3698        assert!(pick_open_pr("not json").is_err());
3699    }
3700
3701    fn snapshot_with_pr(number: u64) -> GitDiffSnapshot {
3702        snapshot_with_pr_at(number, Some(0))
3703    }
3704
3705    fn snapshot_with_pr_at(number: u64, fetched_secs_ago: Option<u64>) -> GitDiffSnapshot {
3706        let pr = GitPrInfo { number, url: format!("https://example/pull/{number}"), draft: false };
3707        let pr_fetched_at = fetched_secs_ago
3708            .map(|secs| std::time::SystemTime::now() - std::time::Duration::from_secs(secs));
3709        GitDiffSnapshot {
3710            branch: GitBranch::Named("feat/x".into()),
3711            default_branch: Some("main".into()),
3712            repo_gate: RepoGate::InRepo,
3713            pushed_sha: Some("aaaa".into()),
3714            worktree: LayerState::Clean,
3715            branch_ahead: LayerState::Clean,
3716            pr: Some(pr),
3717            closes: Vec::new(),
3718            pr_fetched_at,
3719        }
3720    }
3721
3722    /// Full 40-hex sha of HEAD in the test repo.
3723    fn rev_parse_head(dir: &TempDir) -> String {
3724        rev_parse(dir.path(), "HEAD")
3725    }
3726
3727    /// The full sha `rev` names in `dir`.
3728    fn rev_parse(dir: &Path, rev: &str) -> String {
3729        let out = StdCommand::new("git")
3730            .arg("-C")
3731            .arg(dir)
3732            .args(["rev-parse", rev])
3733            .output()
3734            .expect("git ok");
3735        assert!(out.status.success(), "rev-parse {rev} failed in {}", dir.display());
3736        String::from_utf8_lossy(&out.stdout).trim().to_owned()
3737    }
3738
3739    /// Run `git <args>` at `dir`, asserting it succeeded.
3740    fn git_at(dir: &Path, args: &[&str]) {
3741        let out = StdCommand::new("git").arg("-C").arg(dir).args(args).output().expect("git ok");
3742        assert!(
3743            out.status.success(),
3744            "git {args:?} failed in {}: {}",
3745            dir.display(),
3746            String::from_utf8_lossy(&out.stderr),
3747        );
3748    }
3749
3750    /// `scan` carries cached PR data through the cache-hit path. Set
3751    /// up a real git repo on a feature branch, build a `prev`
3752    /// snapshot for the same branch with a synthetic PR, and assert
3753    /// the returned snapshot mirrors prev's PR fields. Without the
3754    /// cache, `scan` would call `gh` against a non-github tempdir
3755    /// and the PR fields would come back empty.
3756    #[tokio::test(flavor = "current_thread")]
3757    async fn scan_carries_prev_pr_through_when_branch_unchanged() {
3758        let dir = tempfile::tempdir().expect("tempdir");
3759        init_repo(&dir, "main");
3760        write_file(&dir, "README.md", "first\n");
3761        commit_all(&dir, "init");
3762        let run = |args: &[&str]| {
3763            StdCommand::new("git").arg("-C").arg(dir.path()).args(args).output().expect("git ok");
3764        };
3765        run(&["checkout", "-q", "-b", "feat/cache"]);
3766        write_file(&dir, "feat.rs", "fn x() {}\n");
3767        commit_all(&dir, "feat commit");
3768
3769        let synthetic_pr =
3770            GitPrInfo { number: 99, url: "https://example/pull/99".into(), draft: false };
3771        let synthetic_closes =
3772            vec![GitIssueRef { number: 1, url: "https://example/issues/1".into() }];
3773        let prev = GitDiffSnapshot {
3774            branch: GitBranch::Named("feat/cache".into()),
3775            default_branch: Some("main".into()),
3776            repo_gate: RepoGate::InRepo,
3777            pushed_sha: None,
3778            worktree: LayerState::Clean,
3779            branch_ahead: LayerState::Clean,
3780            pr: Some(synthetic_pr.clone()),
3781            closes: synthetic_closes.clone(),
3782            pr_fetched_at: Some(std::time::SystemTime::now()),
3783        };
3784
3785        let snap = scan_in_test(dir.path(), &HashMap::new(), Some(&prev)).await;
3786        assert_eq!(snap.pr, Some(synthetic_pr), "cached PR must carry through scan");
3787        assert_eq!(snap.closes, synthetic_closes);
3788        assert_eq!(snap.pushed_sha, None, "nothing pushed in this repo");
3789    }
3790
3791    /// On the default branch, `scan` skips the PR fetch entirely -
3792    /// `pr` / `closes` stay empty. Mirrors the "no PR opens against
3793    /// main" assumption (true for personal repos; fork → upstream
3794    /// edge case is out of scope for v1).
3795    #[tokio::test(flavor = "current_thread")]
3796    async fn scan_skips_pr_fetch_on_default_branch() {
3797        let dir = tempfile::tempdir().expect("tempdir");
3798        init_repo(&dir, "main");
3799        write_file(&dir, "README.md", "hello\n");
3800        commit_all(&dir, "init");
3801
3802        let prev = GitDiffSnapshot {
3803            branch: GitBranch::Named("main".into()),
3804            default_branch: Some("main".into()),
3805            repo_gate: RepoGate::InRepo,
3806            pushed_sha: None,
3807            worktree: LayerState::Clean,
3808            branch_ahead: LayerState::Clean,
3809            pr: Some(GitPrInfo { number: 1, url: "url".into(), draft: false }),
3810            closes: Vec::new(),
3811            pr_fetched_at: Some(std::time::SystemTime::now()),
3812        };
3813        // Even with a cache-hit-shaped prev, the default-branch gate
3814        // wins and the PR field clears.
3815        let snap = scan_in_test(dir.path(), &HashMap::new(), Some(&prev)).await;
3816        assert_eq!(snap.pr, None);
3817        assert!(snap.closes.is_empty());
3818        assert_eq!(snap.pushed_sha, None, "the walk must not run on the default branch");
3819        assert_eq!(snap.pr_fetched_at, None);
3820    }
3821
3822    #[test]
3823    fn classify_rev_parse_maps_output_to_repo_gate() {
3824        // Ok(stdout) trims to the branch; Empty is a clean non-repo
3825        // signal; Oversize is always a scanner fault.
3826        assert_eq!(
3827            classify_rev_parse(GitOutput::Ok("  main\n".to_owned()), RepoPresence::Present),
3828            Ok("main".to_owned())
3829        );
3830        assert_eq!(
3831            classify_rev_parse(GitOutput::Empty, RepoPresence::Absent),
3832            Err(RepoGate::NotARepo)
3833        );
3834        assert_eq!(
3835            classify_rev_parse(GitOutput::Oversize, RepoPresence::Present),
3836            Err(RepoGate::ScannerFailed)
3837        );
3838    }
3839
3840    /// A non-zero exit is the ambiguous case, and the probe is what
3841    /// resolves it. `Unusable` must stay on the scanner gate: a git
3842    /// that would not run is a fault to report, not a licence to
3843    /// suppress the section as though there were no repo.
3844    #[test]
3845    fn a_non_zero_exit_is_resolved_by_the_repo_presence_probe() {
3846        assert_eq!(
3847            classify_rev_parse(GitOutput::Failed, RepoPresence::Absent),
3848            Err(RepoGate::NotARepo)
3849        );
3850        assert_eq!(
3851            classify_rev_parse(GitOutput::Failed, RepoPresence::Present),
3852            Err(RepoGate::ScannerFailed)
3853        );
3854        assert_eq!(
3855            classify_rev_parse(GitOutput::Failed, RepoPresence::Unusable),
3856            Err(RepoGate::ScannerFailed)
3857        );
3858    }
3859
3860    /// The probe's own contract against real git: a plain directory has
3861    /// no repo, a fresh `git init` with no commits does. That second
3862    /// case is the one the branch rev-parse gets wrong on its own.
3863    #[tokio::test(flavor = "current_thread")]
3864    async fn repo_presence_separates_a_bare_directory_from_an_unborn_repo() {
3865        let plain = tempfile::tempdir().expect("tempdir");
3866        assert_eq!(repo_presence(plain.path()).await, RepoPresence::Absent);
3867        assert_eq!(repo_presence(&plain.path().join("gone")).await, RepoPresence::Absent);
3868
3869        let repo = tempfile::tempdir().expect("tempdir");
3870        init_repo(&repo, "main");
3871        assert_eq!(repo_presence(repo.path()).await, RepoPresence::Present);
3872    }
3873
3874    /// A checkout git refuses is not an absent repo. Deleting `.git/HEAD`
3875    /// leaves `.git` on disk while `rev-parse` exits 128 with the *same*
3876    /// `fatal:` line a plain non-repo gives, so the on-disk check is the
3877    /// only thing separating them - and reading it as absent would
3878    /// suppress the GIT section for a broken checkout instead of
3879    /// reporting it. The pruned-worktree case forge produces routinely
3880    /// has this shape.
3881    #[tokio::test(flavor = "current_thread")]
3882    async fn a_checkout_git_refuses_is_unusable_not_absent() {
3883        let dir = tempfile::tempdir().expect("tempdir");
3884        init_repo(&dir, "main");
3885        std::fs::remove_file(dir.path().join(".git").join("HEAD")).expect("remove HEAD");
3886
3887        assert_eq!(repo_presence(dir.path()).await, RepoPresence::Unusable);
3888        assert_eq!(
3889            current_branch(dir.path()).await,
3890            Err(RepoGate::ScannerFailed),
3891            "a checkout git refuses must surface the scanner banner, not read as a non-repo",
3892        );
3893        assert_eq!(
3894            scan_in_test(dir.path(), &HashMap::new(), None).await.repo_gate,
3895            RepoGate::ScannerFailed,
3896        );
3897    }
3898
3899    /// The one shape where following symlinks reads a broken checkout as
3900    /// an absent one: git gives the same "not a git repository" line a
3901    /// plain non-repo gives, and `try_exists` says missing because it
3902    /// stats the vanished target rather than the link.
3903    #[cfg(unix)]
3904    #[tokio::test(flavor = "current_thread")]
3905    async fn a_dangling_dot_git_symlink_is_unusable_not_absent() {
3906        let dir = tempfile::tempdir().expect("tempdir");
3907        std::os::unix::fs::symlink(dir.path().join("gone-target"), dir.path().join(".git"))
3908            .expect("symlink");
3909
3910        assert_eq!(repo_presence(dir.path()).await, RepoPresence::Unusable);
3911        assert_eq!(
3912            scan_in_test(dir.path(), &HashMap::new(), None).await.repo_gate,
3913            RepoGate::ScannerFailed,
3914        );
3915    }
3916
3917    /// The stale-base shape: a bare remote, a checkout whose `origin/main`
3918    /// sits one commit behind the remote, and a feature branch cut from the
3919    /// remote's tip with one commit of its own. On the stale ref the range
3920    /// folds the remote's commit in; on a refreshed one it is the branch's
3921    /// own change alone.
3922    fn stale_base_fixture() -> (TempDir, TempDir) {
3923        let upstream = tempfile::tempdir().expect("bare remote");
3924        git_at(upstream.path(), &["init", "-q", "--bare"]);
3925        let dir = tempfile::tempdir().expect("tempdir");
3926        init_repo(&dir, "main");
3927        write_file(&dir, "base.txt", "base\n");
3928        commit_all(&dir, "X base");
3929        let base = rev_parse_head(&dir);
3930        git_at(dir.path(), &["remote", "add", "origin", &upstream.path().to_string_lossy()]);
3931        git_at(dir.path(), &["push", "-q", "-u", "origin", "main"]);
3932        // Upstream moves on: one commit main gained that the checkout has not
3933        // fetched.
3934        write_file(&dir, "upstream.txt", "upstream\n");
3935        commit_all(&dir, "Y upstream");
3936        git_at(dir.path(), &["push", "-q", "origin", "main"]);
3937        // The feature branch is cut from upstream's tip, with one commit of
3938        // its own.
3939        git_at(dir.path(), &["checkout", "-q", "-b", "feature"]);
3940        write_file(&dir, "feat.txt", "feat\n");
3941        commit_all(&dir, "Z feat");
3942        // The stale ref: the checkout's origin/main is still X even though the
3943        // remote's main is Y.
3944        git_at(dir.path(), &["update-ref", "refs/remotes/origin/main", &base]);
3945        git_at(
3946            dir.path(),
3947            &["symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/main"],
3948        );
3949        (upstream, dir)
3950    }
3951
3952    /// **The display read's refresh moves the base before the diff is read.**
3953    ///
3954    /// Ved's Granite case: the checkout's `origin/main` was stale while the
3955    /// remote had moved on, so the merge-base with HEAD moved back and every
3956    /// commit main had gained since the stale ref folded into the branch's
3957    /// diff - a 3-commit branch read as 19 changed files. The read that draws
3958    /// the diff refreshes the base first; the control proves the stale ref
3959    /// really does inflate the range, so this cannot pass by a wrong fixture.
3960    #[tokio::test(flavor = "current_thread")]
3961    async fn the_display_refresh_moves_a_stale_base_before_the_diff_is_read() {
3962        let (upstream, dir) = stale_base_fixture();
3963
3964        let stale = StdCommand::new("git")
3965            .arg("-C")
3966            .arg(dir.path())
3967            .args(["diff", "--name-only", "origin/main...HEAD"])
3968            .output()
3969            .expect("git ok");
3970        assert!(stale.status.success(), "the control range reads");
3971        let stale_files = String::from_utf8_lossy(&stale.stdout);
3972        assert!(
3973            stale_files.contains("upstream.txt") && stale_files.contains("feat.txt"),
3974            "control: the stale base folds upstream's commit into the branch diff: {stale_files:?}",
3975        );
3976
3977        let base = refresh_base_within(dir.path(), "origin/main", TEST_GIT_BUDGET).await;
3978        assert_eq!(base.state, BaseState::Current, "the fetch answered a current base");
3979        assert_eq!(
3980            rev_parse(dir.path(), "refs/remotes/origin/main"),
3981            rev_parse(upstream.path(), "main"),
3982            "and it moved the remote-tracking ref the layer reads",
3983        );
3984        assert_eq!(
3985            base.base_ref.as_deref(),
3986            Some(rev_parse(upstream.path(), "main").as_str()),
3987            "and the sha it pins is the ref's own after the fetch",
3988        );
3989
3990        let snap = scan_in_test(dir.path(), &HashMap::new(), None).await;
3991        // The scan kicks a throttled background fetch; settle it before the
3992        // fixture directory goes.
3993        let _ = settle_background_fetches().await;
3994        let LayerState::Populated(ahead) = &snap.branch_ahead else {
3995            panic!("the feature branch has one commit ahead of the refreshed base");
3996        };
3997        assert_eq!(ahead.commit_count, 1, "the branch's own commit alone");
3998        assert_eq!(
3999            ahead.stats.files.iter().map(|file| file.path.as_str()).collect::<Vec<_>>(),
4000            vec!["feat.txt"],
4001            "upstream's already-merged commit is not the branch's change",
4002        );
4003    }
4004
4005    /// **A base the fetch could not refresh is answered unchecked, pinned to
4006    /// the ref the checkout still holds.** No remote tip is read any more, so
4007    /// no lag is claimed; the stale ref stays where it was, and the layer read
4008    /// after this is still the stale one - the unchecked state is what keeps
4009    /// it from reading as the branch's own change.
4010    #[tokio::test(flavor = "current_thread")]
4011    async fn a_base_the_fetch_could_not_refresh_is_unchecked_and_pinned_to_the_stale_ref() {
4012        let (_upstream, dir) = stale_base_fixture();
4013        let stale = rev_parse(dir.path(), "refs/remotes/origin/main");
4014        let bin = tempfile::tempdir().expect("wrapper dir");
4015        fail_fetch_at(dir.path(), bin.path());
4016
4017        let base = refresh_base_within(dir.path(), "origin/main", TEST_GIT_BUDGET).await;
4018
4019        assert_eq!(
4020            base.state,
4021            BaseState::Unverified,
4022            "the fetch did not run, so the base is not claimed",
4023        );
4024        assert!(base.error.is_some(), "and git's own words ride the answer: {base:?}");
4025        assert_eq!(
4026            base.base_ref.as_deref(),
4027            Some(stale.as_str()),
4028            "pinned to the ref the checkout still holds",
4029        );
4030        // The scan is local, so the stale ref this case is about is exactly
4031        // what it reads; its kicked background fetch is settled before the
4032        // fixture goes.
4033        let snap = scan_in_test(dir.path(), &HashMap::new(), None).await;
4034        crate::env::git_command::clear_git_point(dir.path());
4035        let _ = settle_background_fetches().await;
4036        let LayerState::Populated(ahead) = &snap.branch_ahead else {
4037            panic!("the branch-ahead layer is read from the ref it holds");
4038        };
4039        assert_eq!(ahead.commit_count, 2, "the unfetched range still folds upstream's commit in");
4040        assert!(
4041            ahead.stats.files.iter().any(|file| file.path == "upstream.txt"),
4042            "so the file list is the stale one, which is what the label must cover",
4043        );
4044    }
4045
4046    /// A base whose remote cannot be reached is neither refreshed nor
4047    /// measured, and says exactly that rather than drawing silence.
4048    #[tokio::test(flavor = "current_thread")]
4049    async fn a_base_with_no_reachable_remote_is_unverified() {
4050        let dir = tempfile::tempdir().expect("tempdir");
4051        init_repo(&dir, "main");
4052        write_file(&dir, "base.txt", "base\n");
4053        commit_all(&dir, "base");
4054        git_at(dir.path(), &["remote", "add", "origin", "/nonexistent/forge-base.git"]);
4055        git_at(dir.path(), &["update-ref", "refs/remotes/origin/main", "HEAD"]);
4056        git_at(
4057            dir.path(),
4058            &["symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/main"],
4059        );
4060
4061        assert_eq!(
4062            refresh_base_within(dir.path(), "origin/main", TEST_GIT_BUDGET).await.state,
4063            BaseState::Unverified,
4064            "an unreachable remote leaves the base unchecked, and the refresh says so",
4065        );
4066    }
4067
4068    /// The control for the two below: a checkout whose `origin` probe really
4069    /// answers "no such remote" has nothing upstream to lag behind, and its
4070    /// local base is `local`.
4071    #[tokio::test(flavor = "current_thread")]
4072    async fn a_local_base_with_no_origin_remote_is_local() {
4073        let dir = tempfile::tempdir().expect("tempdir");
4074        init_repo(&dir, "main");
4075        write_file(&dir, "base.txt", "base\n");
4076        commit_all(&dir, "base");
4077
4078        assert_eq!(
4079            refresh_base_within(dir.path(), "main", TEST_GIT_BUDGET).await.state,
4080            BaseState::Local,
4081            "a repository with no origin has nothing this base can lag behind",
4082        );
4083    }
4084
4085    /// **A probe that never answered must not answer "no remote".** The base
4086    /// is local, and the checkout's `origin` question could not be asked at
4087    /// all: `local` would draw the stale base silently, so the answer is
4088    /// `unverified` and the line that says so names the checkout.
4089    #[tokio::test(flavor = "current_thread")]
4090    async fn a_local_base_beside_an_unanswerable_origin_probe_is_unverified() {
4091        let dir = tempfile::tempdir().expect("tempdir");
4092        init_repo(&dir, "main");
4093        write_file(&dir, "base.txt", "base\n");
4094        commit_all(&dir, "base");
4095        let bin = tempfile::tempdir().expect("wrapper dir");
4096        unanswerable_origin_at(dir.path(), bin.path());
4097        let capture = LogCapture::default();
4098        // The line is DEBUG: a git that will not answer is the environment's
4099        // weather, and the default filter has to be lowered to read it.
4100        let subscriber = tracing_subscriber::fmt()
4101            .with_writer(capture.clone())
4102            // No ANSI: the assertions read the fields, and the writer colours
4103            // the separators, so `behind=1` arrives as three styled spans.
4104            .with_ansi(false)
4105            .with_max_level(tracing::Level::DEBUG)
4106            .finish();
4107
4108        let guard = tracing::subscriber::set_default(subscriber);
4109        let refresh = refresh_base_within(dir.path(), "main", TEST_GIT_BUDGET).await;
4110        drop(guard);
4111        crate::env::git_command::clear_git_point(dir.path());
4112
4113        assert_eq!(
4114            refresh.state,
4115            BaseState::Unverified,
4116            "an unanswered origin probe leaves the base unchecked, not local",
4117        );
4118        let log = String::from_utf8_lossy(&capture.0.lock().clone()).into_owned();
4119        assert!(
4120            log.contains("git_base_origin_probe_unanswered"),
4121            "the decision is its own event: {log}",
4122        );
4123        assert!(
4124            log.contains(&dir.path().display().to_string()),
4125            "and it names the checkout a reader has to look at: {log}",
4126        );
4127        assert!(log.contains("git_refused"), "and why the probe could not answer: {log}");
4128    }
4129
4130    /// A local base whose own branch tracks a remote can lag behind it just
4131    /// as an `origin/...` can, so it is not `local`: nothing was fetched for
4132    /// that upstream here.
4133    #[tokio::test(flavor = "current_thread")]
4134    async fn a_local_base_that_tracks_an_upstream_is_unverified() {
4135        let upstream = tempfile::tempdir().expect("bare remote");
4136        git_at(upstream.path(), &["init", "-q", "--bare"]);
4137        let dir = tempfile::tempdir().expect("tempdir");
4138        init_repo(&dir, "main");
4139        write_file(&dir, "base.txt", "base\n");
4140        commit_all(&dir, "base");
4141        git_at(dir.path(), &["remote", "add", "upstream", &upstream.path().to_string_lossy()]);
4142        git_at(dir.path(), &["push", "-q", "-u", "upstream", "main"]);
4143
4144        assert_eq!(
4145            refresh_base_within(dir.path(), "main", TEST_GIT_BUDGET).await.state,
4146            BaseState::Unverified,
4147            "a base tracking a remote nobody fetched is not a local base",
4148        );
4149    }
4150
4151    /// **A pinned scan reads the sha it was given, not the ref's current
4152    /// value.** The by-name scan answers the post-fetch base; the pinned one
4153    /// the stale sha, with that base's count and file list.
4154    #[tokio::test(flavor = "current_thread")]
4155    async fn a_pinned_scan_reads_the_sha_it_was_given() {
4156        let (upstream, dir) = stale_base_fixture();
4157        let stale = rev_parse(dir.path(), "refs/remotes/origin/main");
4158        // The fetch a review read would run: the ref moves to the tip.
4159        git_at(
4160            dir.path(),
4161            &["update-ref", "refs/remotes/origin/main", &rev_parse(upstream.path(), "main")],
4162        );
4163
4164        let by_name = scan_serving_cached_pr(dir.path(), &HashMap::new(), None).await;
4165        let LayerState::Populated(named) = &by_name.branch_ahead else {
4166            panic!("the branch layer is read");
4167        };
4168        assert_eq!(named.commit_count, 1, "the by-name scan reads the moved ref");
4169
4170        let pinned = scan_pinned(dir.path(), &HashMap::new(), None, &stale).await;
4171        let LayerState::Populated(ahead) = &pinned.branch_ahead else {
4172            panic!("the pinned scan reads the stale base");
4173        };
4174        assert_eq!(ahead.commit_count, 2, "the pinned scan reads the sha it was given");
4175        let paths: Vec<&str> = ahead.stats.files.iter().map(|file| file.path.as_str()).collect();
4176        assert!(
4177            paths.contains(&"upstream.txt") && paths.contains(&"feat.txt"),
4178            "and that base's own files, not the moved ref's: {paths:?}",
4179        );
4180    }
4181
4182    /// **The content read follows the pinned base too.** The stats and the
4183    /// files must describe one commit; a content read at the ref's name would
4184    /// carry the moved base's files beside the pinned count.
4185    #[tokio::test(flavor = "current_thread")]
4186    async fn the_content_read_follows_the_pinned_base() {
4187        let (upstream, dir) = stale_base_fixture();
4188        let stale = rev_parse(dir.path(), "refs/remotes/origin/main");
4189        git_at(
4190            dir.path(),
4191            &["update-ref", "refs/remotes/origin/main", &rev_parse(upstream.path(), "main")],
4192        );
4193        let snapshot = scan_serving_cached_pr(dir.path(), &HashMap::new(), None).await;
4194
4195        let content = super::content::scan_within(
4196            dir.path(),
4197            &snapshot,
4198            Some(BaseState::Current),
4199            Some(&stale),
4200            None,
4201            TEST_GIT_BUDGET,
4202        )
4203        .await;
4204
4205        let LayerState::Populated(files) = &content.branch_ahead else {
4206            panic!("the branch layer is read");
4207        };
4208        let paths: Vec<&str> = files.iter().map(|file| file.path.as_str()).collect();
4209        assert!(
4210            paths.contains(&"upstream.txt") && paths.contains(&"feat.txt"),
4211            "the content is the pinned base's range, not the moved ref's: {paths:?}",
4212        );
4213    }
4214
4215    /// **A refresh that just ran leaves the kick nothing to do.** Stamping the
4216    /// window after a successful fetch is what keeps a scan seconds later from
4217    /// fetching the same answer again.
4218    #[tokio::test(flavor = "current_thread")]
4219    async fn a_refresh_stamps_the_kicks_window() {
4220        let (_upstream, dir) = stale_base_fixture();
4221
4222        let refresh = refresh_base_within(dir.path(), "origin/main", TEST_GIT_BUDGET).await;
4223        assert_eq!(refresh.state, BaseState::Current, "the refresh fetched");
4224        assert!(
4225            !claim_refresh_slot(dir.path(), Instant::now(), WATCH_REFRESH_INTERVAL),
4226            "and a kick inside the window it stamped does not fetch again",
4227        );
4228    }
4229
4230    #[test]
4231    fn the_refresh_window_gates_the_next_fetch() {
4232        let now = Instant::now();
4233        let window = Duration::from_secs(240);
4234        assert!(refresh_due(None, now, window), "no prior fetch is due");
4235        assert!(!refresh_due(Some(now), now, window), "a just-now fetch is throttled");
4236        let stale = now
4237            .checked_sub(window + Duration::from_secs(1))
4238            .expect("the monotonic clock is past a window");
4239        assert!(refresh_due(Some(stale), now, window), "a stale prior fetch is due again");
4240    }
4241
4242    #[test]
4243    fn a_claimed_slot_throttles_and_prunes() {
4244        let key = tempfile::tempdir().expect("a key of its own");
4245        let expired = tempfile::tempdir().expect("another key");
4246        let now = Instant::now();
4247        let window = Duration::from_secs(240);
4248
4249        assert!(claim_refresh_slot(key.path(), now, window), "the first claim is due");
4250        assert!(!claim_refresh_slot(key.path(), now, window), "an immediate re-claim is throttled");
4251        assert!(claim_refresh_slot(expired.path(), now, window), "another key has its own window");
4252        assert!(
4253            claim_refresh_slot(key.path(), now + window, window),
4254            "a claim once the window elapses is due again",
4255        );
4256        let map = LAST_WATCH_REFRESH.get().expect("the map exists").lock();
4257        assert!(
4258            !map.contains_key(expired.path()),
4259            "and the claim pruned the other key's expired slot",
4260        );
4261    }
4262
4263    /// The tri-state's `Present` arm: a local base beside an `origin` remote
4264    /// can be arbitrarily behind one nobody fetched, so it is never `local`.
4265    #[tokio::test(flavor = "current_thread")]
4266    async fn a_local_base_beside_an_origin_remote_is_unverified() {
4267        let upstream = tempfile::tempdir().expect("bare remote");
4268        git_at(upstream.path(), &["init", "-q", "--bare"]);
4269        let dir = tempfile::tempdir().expect("tempdir");
4270        init_repo(&dir, "main");
4271        write_file(&dir, "base.txt", "base\n");
4272        commit_all(&dir, "base");
4273        git_at(dir.path(), &["remote", "add", "origin", &upstream.path().to_string_lossy()]);
4274
4275        assert_eq!(
4276            refresh_base_within(dir.path(), "main", TEST_GIT_BUDGET).await.state,
4277            BaseState::Unverified,
4278            "an origin exists, so a local base is a base nobody fetched",
4279        );
4280    }
4281
4282    /// **One run per repository, not per checkout.** Two linked worktrees share
4283    /// `refs/remotes/*`, so a worktree's caller joins its checkout's refresh
4284    /// rather than fetching the same ref again.
4285    #[tokio::test(flavor = "current_thread")]
4286    async fn linked_worktrees_join_one_repository_run() {
4287        let dir = tempfile::tempdir().expect("tempdir");
4288        init_repo(&dir, "main");
4289        write_file(&dir, "base.txt", "base\n");
4290        commit_all(&dir, "base");
4291        let beside = tempfile::tempdir().expect("a second directory");
4292        let linked = beside.path().join("wt");
4293        git_at(dir.path(), &["worktree", "add", "-q", "-b", "linked", &linked.to_string_lossy()]);
4294
4295        let main_key = repo_key(dir.path(), TEST_GIT_BUDGET).await;
4296        let linked_key = repo_key(&linked, TEST_GIT_BUDGET).await;
4297        assert_eq!(main_key, linked_key, "a worktree and its checkout share one ref store");
4298        let (first, leader) = join_base_run((main_key, "origin/main".to_owned()));
4299        assert!(leader, "the first caller starts the run");
4300        let (joined, leader) = join_base_run((linked_key, "origin/main".to_owned()));
4301        assert!(!leader, "and the worktree's caller joins it rather than starting a second");
4302        assert!(std::sync::Arc::ptr_eq(&first, &joined), "the one run answers both");
4303    }
4304
4305    /// A ref lock held in the user's own checkout keeps the base stale; with no
4306    /// remote tip read any more, the answer is unchecked rather than a count.
4307    /// The operator's side is the lock classification's own: forge's mark in
4308    /// their tree is logged apart from the machine's network weather.
4309    #[tokio::test(flavor = "current_thread")]
4310    async fn a_base_the_users_git_has_locked_is_unchecked() {
4311        let (_upstream, dir) = stale_base_fixture();
4312        let bin = tempfile::tempdir().expect("wrapper dir");
4313        lock_fetch_at(dir.path(), bin.path());
4314
4315        assert_eq!(
4316            refresh_base_within(dir.path(), "origin/main", TEST_GIT_BUDGET).await.state,
4317            BaseState::Unverified,
4318            "a lock the fetch could not take leaves the base unchecked",
4319        );
4320        crate::env::git_command::clear_git_point(dir.path());
4321    }
4322
4323    /// **The lock refusal is logged as forge's mark, not as the machine's
4324    /// weather**, and the line names the repo a reader has to look at.
4325    #[tokio::test(flavor = "current_thread")]
4326    async fn a_lock_refusal_is_named_with_its_checkout_and_gits_words() {
4327        let (_upstream, dir) = stale_base_fixture();
4328        let bin = tempfile::tempdir().expect("wrapper dir");
4329        lock_fetch_at(dir.path(), bin.path());
4330        let capture = LogCapture::default();
4331        let subscriber = tracing_subscriber::fmt()
4332            .with_writer(capture.clone())
4333            .with_ansi(false)
4334            // DEBUG: nothing can say whose git holds the lock, so the level
4335            // does not claim it is forge's (rule 20).
4336            .with_max_level(tracing::Level::DEBUG)
4337            .finish();
4338        let guard = tracing::subscriber::set_default(subscriber);
4339
4340        let _ = refresh_base_within(dir.path(), "origin/main", TEST_GIT_BUDGET).await;
4341        drop(guard);
4342        crate::env::git_command::clear_git_point(dir.path());
4343
4344        let log = String::from_utf8_lossy(&capture.0.lock().clone()).into_owned();
4345        assert!(log.contains("git_base_fetch_lock_refused"), "the lock event is named: {log}");
4346        assert!(
4347            log.contains(&dir.path().display().to_string()),
4348            "and the repo it happened in: {log}",
4349        );
4350        assert!(
4351            log.contains("cannot lock ref"),
4352            "and git's own words, which carry the lock file: {log}",
4353        );
4354    }
4355
4356    /// **A successful fetch is the answer, whatever is on disk beforehand.**
4357    /// The explicit refspec writes the destination, so a checkout whose base
4358    /// ref was rewritten past the remote ends the refresh current at the
4359    /// remote's tip, not unverified.
4360    #[tokio::test(flavor = "current_thread")]
4361    async fn a_base_rewritten_past_is_current_after_its_fetch() {
4362        let (upstream, dir) = stale_base_fixture();
4363        let newer = rev_parse(upstream.path(), "main");
4364        let older = rev_parse(upstream.path(), "main~1");
4365        // The base holds the commit the remote no longer has.
4366        git_at(dir.path(), &["update-ref", "refs/remotes/origin/main", &newer]);
4367        git_at(upstream.path(), &["update-ref", "refs/heads/main", &older]);
4368
4369        let refresh = refresh_base_within(dir.path(), "origin/main", TEST_GIT_BUDGET).await;
4370        crate::env::git_command::clear_git_point(dir.path());
4371
4372        assert_eq!(
4373            refresh.state,
4374            BaseState::Current,
4375            "the fetch wrote the remote's tip over the rewritten base",
4376        );
4377        assert_eq!(
4378            refresh.base_ref.as_deref(),
4379            Some(older.as_str()),
4380            "and the sha it pins is the ref's own after the fetch",
4381        );
4382    }
4383
4384    /// The lock prose git uses, against the prose a dead network produces:
4385    /// the first is forge's to report, the second is the environment's.
4386    #[test]
4387    fn a_ref_lock_is_told_apart_from_a_dead_network() {
4388        assert!(is_lock_failure(
4389            "fatal: cannot lock ref 'refs/remotes/origin/main': is at 1a2b3c but expected 4d5e6f",
4390        ));
4391        assert!(is_lock_failure(
4392            "error: Unable to create '/repo/.git/refs/remotes/origin/main.lock': File exists.",
4393        ));
4394        assert!(!is_lock_failure(
4395            "fatal: unable to access 'https://github.com/x/y.git/': Could not resolve host: github.com",
4396        ));
4397    }
4398
4399    /// **A local base answers `local`, and the probes are what say so.** A
4400    /// repo with no `origin` and a branch tracking nothing has no remote to
4401    /// lag behind; the drawing read keeps the probes the refresh path always
4402    /// ran, so the page draws no caveat (#2818).
4403    #[tokio::test(flavor = "current_thread")]
4404    async fn a_local_base_answers_local_without_a_fetch() {
4405        let dir = tempfile::tempdir().expect("tempdir");
4406        init_repo(&dir, "main");
4407        write_file(&dir, "base.txt", "base\n");
4408        commit_all(&dir, "X base");
4409        git_at(dir.path(), &["checkout", "-q", "-b", "feature"]);
4410        write_file(&dir, "feat.txt", "feat\n");
4411        commit_all(&dir, "Z feat");
4412
4413        let answer = refresh_base_for_display(dir.path(), "main").await;
4414
4415        assert_eq!(
4416            answer.state,
4417            BaseState::Local,
4418            "a base with nothing to lag behind is local: {answer:?}",
4419        );
4420        assert!(answer.base_ref.is_some(), "resolved to the local tip");
4421        assert!(answer.error.is_none() && !answer.cut && !answer.unanswered);
4422        assert_eq!(settle_background_fetches().await, 0, "a local base fetches nothing");
4423    }
4424
4425    /// **A drawing read with no measurement answers at once, and kicks the
4426    /// refresh that serves the next one.** The fetch is held open, so a read
4427    /// that awaited it could not answer at all: the answer arriving while the
4428    /// wrapper provably blocks, with the fetch started anyway, is the whole
4429    /// property (#2818).
4430    #[tokio::test(flavor = "current_thread")]
4431    async fn a_reading_with_no_measurement_answers_at_once_and_kicks_the_refresh() {
4432        let (_upstream, dir) = stale_base_fixture();
4433        let bin = tempfile::tempdir().expect("wrapper dir");
4434        let held = held_fetch_at(dir.path(), bin.path());
4435
4436        let reading = tokio::spawn({
4437            let dir = dir.path().to_path_buf();
4438            async move { refresh_base_for_display(&dir, "origin/main").await }
4439        });
4440        let answer = timeout(TEST_GIT_BUDGET, reading)
4441            .await
4442            .expect("the reading did not wait on the fetch")
4443            .expect("the read task finished");
4444
4445        wait_for_within(|| held.starts() == 1, "the reading's refresh to start", TEST_GIT_BUDGET)
4446            .await;
4447        assert!(held.open(), "the read's refresh runs off the read: the fetch is still in flight");
4448        assert_eq!(
4449            answer.state,
4450            BaseState::Unverified,
4451            "with no check in hand, the base is unchecked"
4452        );
4453        assert!(answer.base_ref.is_some(), "and pinned to a local tip it did read");
4454        assert!(answer.error.is_none() && !answer.cut && !answer.unanswered);
4455
4456        held.release();
4457        let _ = settle_background_fetches().await;
4458        crate::env::git_command::clear_git_point(dir.path());
4459    }
4460
4461    /// **A drawing read in the 30-240 s gap answers the last base at once, and
4462    /// the refresh it kicks is what the next read draws.** The held fetch is
4463    /// the proof this read did not await it: the answer arrives while the
4464    /// wrapper blocks, carrying the check already in hand. The run's
4465    /// measurement then answers the next read, with no second fetch (#2818: a
4466    /// review open in the watch's 30-240 s gap paid one fetch per read).
4467    #[tokio::test(flavor = "current_thread")]
4468    async fn a_reading_answers_a_stored_check_past_thirty_seconds_and_the_refresh_lands() {
4469        let (upstream, dir) = stale_base_fixture();
4470        let bin = tempfile::tempdir().expect("wrapper dir");
4471        let held = held_fetch_at(dir.path(), bin.path());
4472
4473        let stale_tip = rev_parse(dir.path(), "refs/remotes/origin/main");
4474        let measured_at = Instant::now()
4475            .checked_sub(Duration::from_secs(45))
4476            .expect("the test clock is past boot");
4477        remember_measurement(
4478            dir.path(),
4479            "origin/main",
4480            &BaseRefresh {
4481                state: BaseState::Current,
4482                base_ref: Some(stale_tip.clone()),
4483                error: None,
4484                cut: false,
4485                unanswered: false,
4486                at: measured_at,
4487            },
4488        );
4489
4490        let reading = tokio::spawn({
4491            let dir = dir.path().to_path_buf();
4492            async move { refresh_base_for_display(&dir, "origin/main").await }
4493        });
4494        let answer = timeout(TEST_GIT_BUDGET, reading)
4495            .await
4496            .expect("the reading did not wait on the fetch")
4497            .expect("the read task finished");
4498
4499        assert_eq!(answer.state, BaseState::Current, "the last completed check answers the read");
4500        assert_eq!(
4501            answer.base_ref.as_deref(),
4502            Some(stale_tip.as_str()),
4503            "pinned to the base in hand"
4504        );
4505        assert_eq!(answer.at, measured_at, "at that check's own instant");
4506        wait_for_within(
4507            || held.starts() == 1,
4508            "the reading's own refresh to start",
4509            TEST_GIT_BUDGET,
4510        )
4511        .await;
4512        assert!(held.open(), "the refresh runs off the read: the fetch is still in flight");
4513
4514        held.release();
4515        let _ = settle_background_fetches().await;
4516        crate::env::git_command::clear_git_point(dir.path());
4517
4518        let next = refresh_base_for_display(dir.path(), "origin/main").await;
4519        assert_eq!(next.state, BaseState::Current, "the refreshed base answers the next read");
4520        assert_eq!(
4521            next.base_ref.as_deref(),
4522            Some(rev_parse(upstream.path(), "main").as_str()),
4523            "pinned to the tip the fetch moved the ref to",
4524        );
4525        assert_eq!(held.starts(), 1, "and the next read paid no second fetch");
4526    }
4527
4528    /// A worker branch is cut from origin/main while local `main` lags
4529    /// origin/main (the usual state when the user only pulls merged
4530    /// PRs). The branch-ahead diff must compare against the remote-
4531    /// tracking origin/main - showing only the branch's OWN commit -
4532    /// not against a stale local main that would fold in already-merged
4533    /// work. Exercised through a real linked worktree.
4534    #[tokio::test(flavor = "current_thread")]
4535    async fn scan_feature_branch_compares_against_origin_main_not_stale_local_main() {
4536        let dir = tempfile::tempdir().expect("tempdir");
4537        init_repo(&dir, "main");
4538        let run = |args: &[&str]| {
4539            let out =
4540                StdCommand::new("git").arg("-C").arg(dir.path()).args(args).output().expect("git");
4541            assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
4542        };
4543        write_file(&dir, "base.txt", "base\n");
4544        commit_all(&dir, "X base");
4545        // Y = a commit that lives on origin/main (already merged).
4546        run(&["checkout", "-q", "-b", "upstream"]);
4547        write_file(&dir, "merged.txt", "merged\n");
4548        commit_all(&dir, "Y merged");
4549        run(&["update-ref", "refs/remotes/origin/main", "upstream"]);
4550        run(&["symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/main"]);
4551        // Local main rewinds to X - it now lags origin/main by Y.
4552        run(&["checkout", "-q", "main"]);
4553        run(&["branch", "-D", "upstream"]);
4554        // Worker worktree cut from origin/main (Y), plus its own commit Z.
4555        std::fs::create_dir_all(dir.path().join(".claude/worktrees")).expect("mkdir");
4556        let wt = dir.path().join(".claude/worktrees/w");
4557        run(&["worktree", "add", "-q", wt.to_str().expect("utf8"), "-b", "feat/w", "origin/main"]);
4558        let wt_run = |args: &[&str]| {
4559            let out = StdCommand::new("git").arg("-C").arg(&wt).args(args).output().expect("git");
4560            assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
4561        };
4562        std::fs::write(wt.join("z.txt"), "z\n").expect("write z");
4563        wt_run(&["add", "-A"]);
4564        wt_run(&["commit", "-q", "-m", "Z own"]);
4565
4566        let snap = scan_in_test(&wt, &HashMap::new(), None).await;
4567        assert_eq!(
4568            snap.default_branch.as_deref(),
4569            Some("origin/main"),
4570            "compares against the remote-tracking ref, not stripped local main",
4571        );
4572        let LayerState::Populated(ahead) = &snap.branch_ahead else {
4573            panic!("branch-ahead populated for the worker branch");
4574        };
4575        assert_eq!(
4576            ahead.commit_count, 1,
4577            "only the branch's own commit vs origin/main, not the already-merged one",
4578        );
4579        assert_eq!(ahead.stats.total_files, 1);
4580        assert_eq!(
4581            ahead.stats.files[0].path, "z.txt",
4582            "merged.txt (already on origin/main) is excluded from the branch-ahead diff",
4583        );
4584    }
4585
4586    /// On `main` with the default resolved to the remote-tracking ref
4587    /// `origin/main`, the branch-ahead layer stays empty even when local
4588    /// main is AHEAD of origin/main (unpushed commits): the on-default
4589    /// check strips the remote prefix so a checked-out `main` reads as
4590    /// the default branch rather than a feature branch.
4591    #[tokio::test(flavor = "current_thread")]
4592    async fn scan_on_local_main_ahead_of_origin_skips_branch_ahead() {
4593        let dir = tempfile::tempdir().expect("tempdir");
4594        init_repo(&dir, "main");
4595        let run = |args: &[&str]| {
4596            let out =
4597                StdCommand::new("git").arg("-C").arg(dir.path()).args(args).output().expect("git");
4598            assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
4599        };
4600        write_file(&dir, "base.txt", "base\n");
4601        commit_all(&dir, "base");
4602        run(&["update-ref", "refs/remotes/origin/main", "HEAD"]);
4603        run(&["symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/main"]);
4604        // Local main advances past origin/main (unpushed commit).
4605        write_file(&dir, "local.txt", "local\n");
4606        commit_all(&dir, "unpushed");
4607
4608        let snap = scan_in_test(dir.path(), &HashMap::new(), None).await;
4609        assert_eq!(snap.default_branch.as_deref(), Some("origin/main"));
4610        assert!(
4611            matches!(snap.branch_ahead, LayerState::Clean),
4612            "on `main` the branch-ahead layer stays empty even when ahead of origin/main",
4613        );
4614    }
4615
4616    /// **A stored read leaves the base alone.** The `ServeCached` scan a held
4617    /// seat's record read takes neither fetches nor kicks: it reads the ref as
4618    /// it stands, and the read that draws the diff is what refreshes it. (The
4619    /// fetching scan is the `Fetch` one above; a sessionless seat's read takes
4620    /// that, because it has no loop to refresh for it.)
4621    #[tokio::test(flavor = "current_thread")]
4622    async fn a_stored_read_leaves_the_base_alone() {
4623        let (upstream, dir) = stale_base_fixture();
4624        let before = rev_parse(dir.path(), "refs/remotes/origin/main");
4625
4626        let snap = super::scan_serving_cached_pr(dir.path(), &HashMap::new(), None).await;
4627
4628        assert_eq!(
4629            rev_parse(dir.path(), "refs/remotes/origin/main"),
4630            before,
4631            "a record read leaves the ref alone",
4632        );
4633        assert_ne!(
4634            rev_parse(dir.path(), "refs/remotes/origin/main"),
4635            rev_parse(upstream.path(), "main"),
4636            "so the stale ref is still stale after a record read",
4637        );
4638        let LayerState::Populated(ahead) = &snap.branch_ahead else {
4639            panic!("the layer is read from the ref the scan holds");
4640        };
4641        assert_eq!(ahead.commit_count, 2, "and the stale range is what the read answers");
4642    }
4643
4644    /// **An ordinary scan kicks a throttled fetch for the NEXT read**, and
4645    /// never waits on it. This is the path a sessionless seat has: no loop
4646    /// exists to refresh for it, so without the kick its record re-reads the
4647    /// tree for ever against a ref nothing ever moves.
4648    #[tokio::test(flavor = "current_thread")]
4649    async fn an_ordinary_scan_kicks_a_throttled_fetch_for_the_next_read() {
4650        let (upstream, dir) = stale_base_fixture();
4651
4652        let _ = scan_in_test(dir.path(), &HashMap::new(), None).await;
4653        let kicked =
4654            LAST_WATCH_REFRESH.get().is_some_and(|map| map.lock().contains_key(dir.path()));
4655        assert!(kicked, "an origin-tracking scan claims (kicks) a background fetch");
4656
4657        assert!(
4658            settle_background_fetches().await >= 1,
4659            "and there is one to settle, so the kick was real",
4660        );
4661        assert_eq!(
4662            rev_parse(dir.path(), "refs/remotes/origin/main"),
4663            rev_parse(upstream.path(), "main"),
4664            "which leaves the ref fresh for the next read",
4665        );
4666    }
4667
4668    /// **A read that fetches the base itself takes no background kick.** A
4669    /// sessionless review read did both, and its own display fetch then
4670    /// queued behind forge's on the shared repository gate - so the page said
4671    /// "could not be refreshed" about a fetch forge itself was holding.
4672    #[tokio::test(flavor = "current_thread")]
4673    async fn a_scan_without_base_fetch_kicks_nothing() {
4674        let (upstream, dir) = stale_base_fixture();
4675        let before = rev_parse(dir.path(), "refs/remotes/origin/main");
4676
4677        let _ = scan_without_base_fetch(dir.path(), &HashMap::new(), None).await;
4678
4679        assert_eq!(
4680            settle_background_fetches().await,
4681            0,
4682            "the quiet scan kicks no background fetch",
4683        );
4684        assert_eq!(
4685            rev_parse(dir.path(), "refs/remotes/origin/main"),
4686            before,
4687            "and the ref is where the caller's own fetch will find it",
4688        );
4689        assert_ne!(before, rev_parse(upstream.path(), "main"), "precondition: the base is stale");
4690    }
4691
4692    /// **The scan kicks its refresh and does not wait on it.** The fetch is
4693    /// held open by the fixture, so a scan that awaited it could not answer:
4694    /// the bound below is the failure, not a performance claim.
4695    #[tokio::test(flavor = "current_thread")]
4696    async fn a_scan_kicks_its_refresh_and_the_measurement_lands_for_the_next_one() {
4697        let (upstream, dir) = stale_base_fixture();
4698        let bin = tempfile::tempdir().expect("wrapper dir");
4699        let held = held_fetch_at(dir.path(), bin.path());
4700
4701        // The bound is a failure instead of a hang, not a performance claim:
4702        // the fetch is held, so a scan that awaited it could not answer at all.
4703        // It sits above one child's own budget, because a machine that holds a
4704        // child to its bound must not read as a scan that waited.
4705        let scanned =
4706            timeout(Duration::from_secs(90), scan_in_test(dir.path(), &HashMap::new(), None))
4707                .await
4708                .expect("the scan answered while its fetch was still held");
4709        let _ = scanned;
4710        wait_for(|| held.starts() == 1, "the kicked fetch to start").await;
4711        assert!(held.open(), "the fetch the scan kicked is provably still in flight");
4712
4713        held.release();
4714        let _ = settle_background_fetches().await;
4715        crate::env::git_command::clear_git_point(dir.path());
4716        assert_eq!(
4717            rev_parse(dir.path(), "refs/remotes/origin/main"),
4718            rev_parse(upstream.path(), "main"),
4719            "and the fetch it kicked moved the base to the remote's tip",
4720        );
4721        assert_eq!(
4722            last_base_measurement(dir.path(), "origin/main").map(|measured| measured.state),
4723            Some(BaseState::Current),
4724            "and the state it measured is what the row carries next",
4725        );
4726    }
4727
4728    /// **A failed fetch says what git said, on the reading after it.** The read
4729    /// that kicks it answers unchecked without words it has not seen; the
4730    /// measurement the failed run leaves carries git's first line to the next
4731    /// read, which is what the page draws beside the base line.
4732    ///
4733    /// **The fetch is held, not failed on arrival**: a failure that lands while
4734    /// the first read is still finishing would be re-read from the store and
4735    /// answered with words that read did not wait for.
4736    #[tokio::test(flavor = "current_thread")]
4737    async fn a_failed_fetch_carries_gits_own_words() {
4738        let (_upstream, dir) = stale_base_fixture();
4739        let bin = tempfile::tempdir().expect("wrapper dir");
4740        let release = bin.path().join("fetch-release");
4741        let body = format!(
4742            "while [ ! -e '{release}' ]; do /bin/sleep 0.02; done; \
4743             echo 'fatal: forced fetch failure' >&2; exit 128",
4744            release = release.display(),
4745        );
4746        write_wrapper(dir.path(), bin.path(), "held-failing-git", &[], &body);
4747
4748        let unchecked = refresh_base_for_display(dir.path(), "origin/main").await;
4749        assert_eq!(unchecked.state, BaseState::Unverified);
4750        assert!(
4751            unchecked.error.is_none(),
4752            "the read that kicked the fetch names no failure it has not seen: {unchecked:?}",
4753        );
4754        std::fs::write(&release, b"go").expect("release the fetch");
4755        let _ = settle_background_fetches().await;
4756
4757        let refresh = refresh_base_for_display(dir.path(), "origin/main").await;
4758        crate::env::git_command::clear_git_point(dir.path());
4759        assert_eq!(
4760            refresh.error.as_deref(),
4761            Some("fatal: forced fetch failure"),
4762            "the record carries git's first line",
4763        );
4764        assert_eq!(
4765            refresh.state,
4766            BaseState::Unverified,
4767            "the fetch did not run and no tip is read, so the base is not claimed",
4768        );
4769        assert!(!refresh.cut, "a fetch that failed for its own reason was not cut by a bound");
4770    }
4771
4772    /// **A run's answer is kept for a caller that arrives after it**, even
4773    /// with no receiver subscribed when it was published: `send` would throw
4774    /// it away and leave that caller waiting for the process's life.
4775    #[tokio::test(flavor = "current_thread")]
4776    async fn a_published_answer_waits_for_a_caller_that_arrives_after_it() {
4777        let key = (PathBuf::from("/an-answer-kept"), "origin/main".to_owned());
4778        let (run, leader) = join_base_run(key.clone());
4779        assert!(leader, "the test leads");
4780        run.publish(BaseRefresh {
4781            state: BaseState::Current,
4782            base_ref: Some("abc123".to_owned()),
4783            error: None,
4784            cut: false,
4785            unanswered: false,
4786            at: Instant::now(),
4787        });
4788        // The leader's own `Arc` is still here, exactly as a caller mid-flight
4789        // would hold it, so the registry entry outlives the publish.
4790        let (joined, leader) = join_base_run(key);
4791        assert!(!leader, "the late caller joins the run");
4792        let answer = timeout(Duration::from_secs(2), joined.wait())
4793            .await
4794            .expect("the published answer is not thrown away");
4795        assert_eq!(answer.base_ref.as_deref(), Some("abc123"));
4796    }
4797
4798    /// **A leader that ends without answering wakes its waiters.** A dropped
4799    /// or panicked leader must not leave a joiner waiting for ever. The joiners
4800    /// are kicks: a drawing read answers at once and never waits on a run.
4801    #[tokio::test(flavor = "current_thread")]
4802    async fn a_cancelled_leader_answers_its_waiters() {
4803        let (upstream, dir) = stale_base_fixture();
4804        let bin = tempfile::tempdir().expect("wrapper dir");
4805        let held = held_fetch_at(dir.path(), bin.path());
4806
4807        let leader = tokio::spawn({
4808            let dir = dir.path().to_path_buf();
4809            async move { refresh_base_within(&dir, "origin/main", TEST_GIT_BUDGET).await }
4810        });
4811        wait_for(|| held.starts() == 1, "the leader's fetch to start").await;
4812        let follower = tokio::spawn({
4813            let dir = dir.path().to_path_buf();
4814            async move { refresh_base_within(&dir, "origin/main", TEST_GIT_BUDGET).await }
4815        });
4816        wait_for_within(
4817            || held.probes() >= 2,
4818            "the follower to reach the registry",
4819            TEST_GIT_BUDGET,
4820        )
4821        .await;
4822        // Its key probe is followed by the join, a few microseconds later; this
4823        // settle only puts the abort after it, and the verdict never depends on
4824        // the length of the wait.
4825        tokio::time::sleep(Duration::from_millis(200)).await;
4826        leader.abort();
4827
4828        let answer = timeout(Duration::from_secs(5), follower)
4829            .await
4830            .expect("the follower was answered")
4831            .expect("the follower's task finished");
4832        held.release();
4833        let _ = settle_background_fetches().await;
4834        crate::env::git_command::clear_git_point(dir.path());
4835        let _ = upstream;
4836        assert!(
4837            answer.error.as_deref().is_some_and(|words| words.contains("without an answer")),
4838            "a leader that ended unanswered is named: {answer:?}",
4839        );
4840        assert!(
4841            last_base_measurement(dir.path(), "origin/main").is_none(),
4842            "and a placeholder state is not stored as the checkout's measurement",
4843        );
4844    }
4845
4846    /// **A reading does not wait on the fetch a scan's kick started.** The
4847    /// kick leads a run with the scan's own bound; a drawing read answers the
4848    /// base it has as unchecked, pinned to a local tip, and the kicked run's
4849    /// answer serves the next read (#2685).
4850    ///
4851    /// The bound is a failure rather than a performance claim: the held fetch
4852    /// cannot finish, so a read that joined it could not answer at all - and
4853    /// the answer arriving while the fetch is still open is the proof it did
4854    /// not. It sits far above what a loaded machine adds to the one local
4855    /// `git` child the answer reads.
4856    #[tokio::test(flavor = "current_thread")]
4857    async fn a_reading_answers_unchecked_rather_than_joining_a_kicked_fetch() {
4858        let (_upstream, dir) = stale_base_fixture();
4859        let bin = tempfile::tempdir().expect("wrapper dir");
4860        let held = held_fetch_at(dir.path(), bin.path());
4861
4862        kick_background_fetch(dir.path(), "origin/main", TEST_GIT_BUDGET);
4863        wait_for_within(|| held.starts() == 1, "the kicked fetch to start", TEST_GIT_BUDGET).await;
4864
4865        let reading = tokio::spawn({
4866            let dir = dir.path().to_path_buf();
4867            async move { refresh_base_for_display(&dir, "origin/main").await }
4868        });
4869        let answer = timeout(TEST_GIT_BUDGET, reading)
4870            .await
4871            .expect("the reading did not wait on the kicked fetch")
4872            .expect("the read task finished");
4873
4874        assert_eq!(held.starts(), 1, "the reading started no fetch of its own");
4875        assert!(held.open(), "the kicked fetch is provably still in flight");
4876        assert_eq!(
4877            answer.state,
4878            BaseState::Unverified,
4879            "the reading says the base was not checked"
4880        );
4881        assert!(answer.base_ref.is_some(), "and pins to a local tip it did read");
4882        assert!(answer.error.is_none() && !answer.cut && !answer.unanswered);
4883
4884        held.release();
4885        let _ = settle_background_fetches().await;
4886        crate::env::git_command::clear_git_point(dir.path());
4887    }
4888
4889    /// **A run that has just finished answers the next read without a fetch.**
4890    /// The page is told the base moved and reads again; the answer is the
4891    /// measurement the run took, so the completion costs no second wait on the
4892    /// same remote.
4893    #[tokio::test(flavor = "current_thread")]
4894    async fn a_reading_after_a_finished_refresh_answers_without_fetching_again() {
4895        let (_upstream, dir) = stale_base_fixture();
4896        let bin = tempfile::tempdir().expect("wrapper dir");
4897        let held = held_fetch_at(dir.path(), bin.path());
4898
4899        kick_background_fetch(dir.path(), "origin/main", TEST_GIT_BUDGET);
4900        wait_for_within(|| held.starts() == 1, "the kicked fetch to start", TEST_GIT_BUDGET).await;
4901        held.release();
4902        let _ = settle_background_fetches().await;
4903        wait_for(
4904            || last_base_measurement(dir.path(), "origin/main").is_some(),
4905            "the measurement to land",
4906        )
4907        .await;
4908        let measured = last_base_measurement(dir.path(), "origin/main");
4909
4910        let answer = timeout(TEST_GIT_BUDGET, refresh_base_for_display(dir.path(), "origin/main"))
4911            .await
4912            .expect("the reading answered from the measurement it holds");
4913
4914        assert_eq!(held.starts(), 1, "the finished run answered the read; no second fetch");
4915        assert_eq!(
4916            Some(answer.state),
4917            measured.map(|measured| measured.state),
4918            "the state is the measurement the run took"
4919        );
4920        assert_eq!(answer.state, BaseState::Current, "the fixture's fetch makes the base current");
4921        assert!(answer.base_ref.is_some(), "pinned to the tip the fetch left");
4922
4923        assert!(answer.error.is_none() && !answer.cut && !answer.unanswered);
4924        crate::env::git_command::clear_git_point(dir.path());
4925    }
4926
4927    /// **A sessionless read waits on the kick that claimed its slot.** An
4928    /// ordinary seat read's scan kicks a refresh and claims the cwd slot; a
4929    /// review read inside that window must wait on the in-flight run rather
4930    /// than answer unchecked - no watch exists to heal a miss (#2818).
4931    #[tokio::test(flavor = "current_thread")]
4932    async fn a_sessionless_read_waits_on_the_kick_that_claimed_its_slot() {
4933        let (upstream, dir) = stale_base_fixture();
4934        let bin = tempfile::tempdir().expect("wrapper dir");
4935        let held = held_fetch_at(dir.path(), bin.path());
4936
4937        kick_background_fetch(dir.path(), "origin/main", TEST_GIT_BUDGET);
4938        wait_for_within(|| held.starts() == 1, "the kick's fetch to start", TEST_GIT_BUDGET).await;
4939        let probes_before = held.probes();
4940
4941        let reader = tokio::spawn({
4942            let dir = dir.path().to_path_buf();
4943            async move { refresh_base_now(&dir, "origin/main").await }
4944        });
4945        // It reaches the registry, and the held fetch is what it waits on.
4946        wait_for_within(
4947            || held.probes() > probes_before,
4948            "the read to reach the registry",
4949            TEST_GIT_BUDGET,
4950        )
4951        .await;
4952        tokio::time::sleep(Duration::from_millis(100)).await;
4953        assert!(!reader.is_finished(), "the read waits on the run its slot names");
4954
4955        held.release();
4956        let answer = timeout(TEST_GIT_BUDGET, reader)
4957            .await
4958            .expect("the read answered")
4959            .expect("the read task finished");
4960        let _ = settle_background_fetches().await;
4961        crate::env::git_command::clear_git_point(dir.path());
4962
4963        assert_eq!(answer.state, BaseState::Current, "the joined run's check answers");
4964        assert_eq!(
4965            answer.base_ref.as_deref(),
4966            Some(rev_parse(upstream.path(), "main").as_str()),
4967            "with the base at the remote's tip",
4968        );
4969        assert_eq!(held.starts(), 1, "the read joined the kick rather than fetching again");
4970    }
4971
4972    /// **A run that answered no check gives its slot back.** A cut or aborted
4973    /// run measured nothing; leaving its claim stamped would answer silence
4974    /// for the whole window on the one seat whose read is what heals it.
4975    #[test]
4976    fn a_run_without_a_check_gives_the_slot_back() {
4977        let dir = tempfile::tempdir().expect("tempdir");
4978        let now = Instant::now();
4979        assert!(
4980            claim_refresh_slot(dir.path(), now, WATCH_REFRESH_INTERVAL),
4981            "the fixture claims the slot",
4982        );
4983        let answered = BaseRefresh {
4984            state: BaseState::Current,
4985            base_ref: Some("abc123".to_owned()),
4986            error: None,
4987            cut: false,
4988            unanswered: false,
4989            at: Instant::now(),
4990        };
4991        give_back_slot(dir.path(), &answered);
4992        assert!(
4993            !claim_refresh_slot(dir.path(), Instant::now(), WATCH_REFRESH_INTERVAL),
4994            "a run that answered keeps its slot",
4995        );
4996
4997        let cut = BaseRefresh {
4998            state: BaseState::Unverified,
4999            base_ref: None,
5000            error: Some("the fetch did not finish inside the 30s it was given".to_owned()),
5001            cut: true,
5002            unanswered: false,
5003            at: Instant::now(),
5004        };
5005        give_back_slot(dir.path(), &cut);
5006        assert!(
5007            claim_refresh_slot(dir.path(), Instant::now(), WATCH_REFRESH_INTERVAL),
5008            "a run without a check gives the slot back",
5009        );
5010    }
5011
5012    /// **A fetch its budget cut is not a measurement, and the reading after it
5013    /// answers unchecked while its own kick runs.** The cut left nothing to
5014    /// reuse, so the reading kicks a refresh and answers at once; that run's
5015    /// measurement is what the next reading draws.
5016    #[tokio::test(flavor = "current_thread")]
5017    async fn a_cut_run_is_not_a_measurement_a_later_reading_reuses() {
5018        let (upstream, dir) = stale_base_fixture();
5019        let bin = tempfile::tempdir().expect("wrapper dir");
5020        let held = held_fetch_at(dir.path(), bin.path());
5021
5022        let cut = refresh_base_within(dir.path(), "origin/main", Duration::from_millis(300)).await;
5023        assert!(cut.cut, "the held fetch outlived the bound");
5024        assert!(
5025            last_base_measurement(dir.path(), "origin/main").is_none(),
5026            "a cut answer is not stored as the checkout's measurement",
5027        );
5028
5029        let reader = tokio::spawn({
5030            let dir = dir.path().to_path_buf();
5031            async move { refresh_base_for_display(&dir, "origin/main").await }
5032        });
5033        let answer = timeout(TEST_GIT_BUDGET, reader)
5034            .await
5035            .expect("the reading did not wait on its own fetch")
5036            .expect("the read task finished");
5037        assert_eq!(
5038            answer.state,
5039            BaseState::Unverified,
5040            "the cut answer is not a check the reading draws",
5041        );
5042        assert!(answer.error.is_none(), "and the cut's own words are not carried");
5043        wait_for_within(
5044            || held.starts() >= 2,
5045            "the reading kicks a refresh of its own",
5046            TEST_GIT_BUDGET,
5047        )
5048        .await;
5049
5050        held.release();
5051        let _ = settle_background_fetches().await;
5052        crate::env::git_command::clear_git_point(dir.path());
5053
5054        let next = refresh_base_for_display(dir.path(), "origin/main").await;
5055        assert_eq!(next.state, BaseState::Current, "the reading's own run measured the base");
5056        assert_eq!(
5057            next.base_ref.as_deref(),
5058            Some(rev_parse(upstream.path(), "main").as_str()),
5059            "with the base at the remote's tip",
5060        );
5061        assert_eq!(held.starts(), 2, "the cut run and the reading's own");
5062    }
5063
5064    /// **The explicit refspec writes the base ref, not the repository's own.**
5065    /// A repository whose `remote.origin.fetch` points somewhere else entirely
5066    /// still gets `refs/remotes/origin/<b>` moved by the refspec `fetch_base`
5067    /// passes, which is the reason the post-fetch `ls-remote` could go.
5068    #[tokio::test(flavor = "current_thread")]
5069    async fn the_explicit_refspec_writes_the_base_ref_whatever_the_remotes_own_says() {
5070        let upstream = tempfile::tempdir().expect("a bare remote");
5071        git_at(upstream.path(), &["init", "-q", "--bare"]);
5072        let dir = tempfile::tempdir().expect("tempdir");
5073        init_repo(&dir, "main");
5074        write_file(&dir, "base.txt", "base\n");
5075        commit_all(&dir, "X base");
5076        git_at(dir.path(), &["remote", "add", "origin", &upstream.path().to_string_lossy()]);
5077        git_at(dir.path(), &["push", "-q", "-u", "origin", "main"]);
5078        let stale = rev_parse_head(&dir);
5079        // The repository's own fetch refspec writes a different namespace, so
5080        // only the explicit refspec can move the ref this diff reads.
5081        git_at(
5082            dir.path(),
5083            &["config", "remote.origin.fetch", "+refs/heads/*:refs/remotes/upstream/*"],
5084        );
5085        // Upstream moves on, and the base ref is still where it was.
5086        write_file(&dir, "upstream.txt", "upstream\n");
5087        commit_all(&dir, "Y upstream");
5088        git_at(dir.path(), &["push", "-q", "origin", "main"]);
5089        git_at(dir.path(), &["update-ref", "refs/remotes/origin/main", &stale]);
5090
5091        let refresh = refresh_base_within(dir.path(), "origin/main", TEST_GIT_BUDGET).await;
5092
5093        assert_eq!(
5094            rev_parse(dir.path(), "refs/remotes/origin/main"),
5095            rev_parse(upstream.path(), "main"),
5096            "the explicit refspec moved the base ref the diff reads",
5097        );
5098        assert_eq!(refresh.state, BaseState::Current, "and the refreshed base is current");
5099    }
5100
5101    /// **A measurement answers a read only inside the store's window.** The
5102    /// reuse is a freshness claim, so its expiry is a property with its own
5103    /// test: widening the window past the store's own lifetime has to fail
5104    /// here.
5105    #[test]
5106    fn a_measurement_answers_a_reading_only_inside_the_store_window() {
5107        let now = Instant::now();
5108        // **The figures are literal on purpose.** This read used to accept a
5109        // check thirty seconds old and now accepts the store's four minutes,
5110        // so the expiry is pinned at 240 s rather than derived from the
5111        // constant it guards: moving the window has to red here.
5112        let fresh = BaseMeasurement {
5113            at: now.checked_sub(Duration::from_secs(239)).expect("the test clock is past boot"),
5114            state: BaseState::Current,
5115            error: None,
5116        };
5117        let stale = BaseMeasurement {
5118            at: now.checked_sub(Duration::from_secs(241)).expect("the test clock is past boot"),
5119            state: BaseState::Current,
5120            error: None,
5121        };
5122
5123        assert!(measurement_in_window(&fresh, now), "a check 239 s old answers the read");
5124        assert!(!measurement_in_window(&stale, now), "one 241 s old does not");
5125    }
5126
5127    /// **An expired check is not drawn as current.** The store prunes only on
5128    /// write, so an aged entry survives between writes; the read applies the
5129    /// window itself, or a check hours old would draw as `current`. The held
5130    /// fetch keeps the kick from landing a new check under the assertions,
5131    /// and a fresh check still answers once it is in the window.
5132    #[tokio::test(flavor = "current_thread")]
5133    async fn a_reading_ignores_an_expired_measurement_and_answers_unchecked() {
5134        let (_upstream, dir) = stale_base_fixture();
5135        let bin = tempfile::tempdir().expect("wrapper dir");
5136        let held = held_fetch_at(dir.path(), bin.path());
5137        remember_measurement(
5138            dir.path(),
5139            "origin/main",
5140            &BaseRefresh {
5141                state: BaseState::Current,
5142                base_ref: None,
5143                error: Some("a check from hours ago".to_owned()),
5144                cut: false,
5145                unanswered: false,
5146                at: Instant::now()
5147                    .checked_sub(WATCH_REFRESH_INTERVAL + Duration::from_secs(60))
5148                    .expect("the test clock is past boot"),
5149            },
5150        );
5151
5152        let answer = refresh_base_for_display(dir.path(), "origin/main").await;
5153        held.release();
5154        let _ = settle_background_fetches().await;
5155
5156        assert_eq!(answer.state, BaseState::Unverified, "an expired check is not drawn");
5157        assert!(answer.error.is_none(), "nor are its words carried: {answer:?}");
5158        assert!(answer.base_ref.is_some(), "the base is pinned to the local tip");
5159
5160        // The same store answers once the check is inside the window.
5161        remember_measurement(
5162            dir.path(),
5163            "origin/main",
5164            &BaseRefresh {
5165                state: BaseState::Current,
5166                base_ref: None,
5167                error: None,
5168                cut: false,
5169                unanswered: false,
5170                at: Instant::now(),
5171            },
5172        );
5173        let fresh = refresh_base_for_display(dir.path(), "origin/main").await;
5174        crate::env::git_command::clear_git_point(dir.path());
5175        assert_eq!(fresh.state, BaseState::Current, "a check inside the window answers");
5176    }
5177
5178    /// **A measurement answers only the base it measured.** One worktree can
5179    /// draw two bases, so a measurement of `origin/main` must not answer a read
5180    /// for `origin/develop`.
5181    #[tokio::test(flavor = "current_thread")]
5182    async fn a_measurement_answers_only_the_branch_it_measured() {
5183        let (_upstream, dir) = stale_base_fixture();
5184
5185        let _ = refresh_base_within(dir.path(), "origin/main", TEST_GIT_BUDGET).await;
5186        crate::env::git_command::clear_git_point(dir.path());
5187
5188        assert!(
5189            last_base_measurement(dir.path(), "origin/main").is_some(),
5190            "the refresh recorded the base it measured",
5191        );
5192        assert!(
5193            last_base_measurement(dir.path(), "origin/develop").is_none(),
5194            "and it must not answer a read for a different base",
5195        );
5196    }
5197
5198    /// **A burst of kicks costs one fetch, not one each.** The first leads a
5199    /// run the wrapper holds; the rest join it and share its answer.
5200    #[tokio::test(flavor = "current_thread")]
5201    async fn readers_share_the_refresh_the_first_leads() {
5202        let (upstream, dir) = stale_base_fixture();
5203        let bin = tempfile::tempdir().expect("wrapper dir");
5204        let held = held_fetch_at(dir.path(), bin.path());
5205
5206        let mut readers = Vec::new();
5207        for _ in 0..3 {
5208            let dir = dir.path().to_path_buf();
5209            readers.push(tokio::spawn(async move {
5210                refresh_base_within(&dir, "origin/main", TEST_GIT_BUDGET).await
5211            }));
5212        }
5213        // The first of them leads the fetch, which the wrapper holds; the rest
5214        // can only join it.
5215        wait_for(|| held.starts() == 1, "the first reader's fetch to start").await;
5216        tokio::time::sleep(Duration::from_millis(100)).await;
5217        assert_eq!(held.starts(), 1, "one fetch for the burst, not one per reader");
5218
5219        held.release();
5220        for reader in readers {
5221            // A failure bound, not a performance claim: the fetch is released,
5222            // and a loaded gate must not read as a broken join.
5223            let read = timeout(TEST_GIT_BUDGET, reader)
5224                .await
5225                .expect("the read answered")
5226                .expect("the read task finished");
5227            assert_eq!(read.state, BaseState::Current, "every reader drew the refreshed base");
5228            assert!(!read.cut && !read.unanswered);
5229        }
5230        crate::env::git_command::clear_git_point(dir.path());
5231        assert_eq!(
5232            rev_parse(dir.path(), "refs/remotes/origin/main"),
5233            rev_parse(upstream.path(), "main"),
5234            "with the base at the remote's tip",
5235        );
5236    }
5237
5238    /// **The timeout words carry the run's budget**, not what was left of it:
5239    /// a run that spent its bound before the child started would say "0s".
5240    #[tokio::test(flavor = "current_thread")]
5241    async fn a_cut_fetch_names_the_budget_it_was_given() {
5242        let (_upstream, dir) = stale_base_fixture();
5243        let bin = tempfile::tempdir().expect("wrapper dir");
5244        let held = held_fetch_at(dir.path(), bin.path());
5245        let bound = Duration::from_millis(300);
5246
5247        let refresh = refresh_base_within(dir.path(), "origin/main", bound).await;
5248        held.release();
5249        let _ = settle_background_fetches().await;
5250        crate::env::git_command::clear_git_point(dir.path());
5251
5252        assert!(refresh.cut, "the run's bound ended the fetch");
5253        assert_eq!(
5254            refresh.error.as_deref(),
5255            Some("the fetch did not finish inside the 0.3s it was given"),
5256            "the run's own budget, not the nothing left of it",
5257        );
5258    }
5259
5260    /// **Two scans inside one window cost one fetch.** The throttle the kick
5261    /// carries is what keeps a tree's every pass from reaching the network.
5262    #[tokio::test(flavor = "current_thread")]
5263    async fn two_scans_in_one_window_cost_one_fetch() {
5264        let (_upstream, dir) = stale_base_fixture();
5265        let bin = tempfile::tempdir().expect("wrapper dir");
5266        let counted = bin.path().join("fetches");
5267        let at = counted.display();
5268        let counting = format!("echo start >> '{at}'; exec git \"$@\"");
5269        write_wrapper(dir.path(), bin.path(), "counting-git", &[], &counting);
5270        let fetches = || {
5271            std::fs::read_to_string(&counted).map(|text| text.lines().count()).unwrap_or_default()
5272        };
5273
5274        let _ = scan_in_test(dir.path(), &HashMap::new(), None).await;
5275        let _ = settle_background_fetches().await;
5276        let _ = scan_in_test(dir.path(), &HashMap::new(), None).await;
5277        let _ = settle_background_fetches().await;
5278        crate::env::git_command::clear_git_point(dir.path());
5279
5280        assert_eq!(fetches(), 1, "the second scan's kick is throttled by the first");
5281    }
5282
5283    /// **A checkout that names no ssh command is forge's to bound.** The
5284    /// missing key exits 1, and the classifier reads that as "unset" rather
5285    /// than as a probe that failed.
5286    #[tokio::test(flavor = "current_thread")]
5287    async fn a_checkout_that_names_no_ssh_command_gets_forges() {
5288        if std::env::var_os("GIT_SSH_COMMAND").is_some() || std::env::var_os("GIT_SSH").is_some() {
5289            return;
5290        }
5291        let dir = tempfile::tempdir().expect("tempdir");
5292        let bin = tempfile::tempdir().expect("wrapper dir");
5293        init_repo(&dir, "main");
5294        without_machine_config_at(dir.path(), bin.path());
5295
5296        assert!(ssh_is_forges(dir.path()).await, "an ordinary checkout is forge's to bound");
5297        crate::env::git_command::clear_git_point(dir.path());
5298    }
5299
5300    /// **And one that names its own is kept**, whichever spelling: forge sets
5301    /// `GIT_SSH_COMMAND` only when nothing else names one.
5302    #[tokio::test(flavor = "current_thread")]
5303    async fn a_checkouts_own_ssh_command_is_kept() {
5304        if std::env::var_os("GIT_SSH_COMMAND").is_some() || std::env::var_os("GIT_SSH").is_some() {
5305            return;
5306        }
5307        let dir = tempfile::tempdir().expect("tempdir");
5308        let bin = tempfile::tempdir().expect("wrapper dir");
5309        init_repo(&dir, "main");
5310        git_at(dir.path(), &["config", "core.sshCommand", "/bin/true"]);
5311        without_machine_config_at(dir.path(), bin.path());
5312
5313        assert!(!ssh_is_forges(dir.path()).await, "the checkout's command governs");
5314        crate::env::git_command::clear_git_point(dir.path());
5315    }
5316
5317    /// **The classifier's answer reaches the child**: the fetch itself carries
5318    /// forge's bounded command on a checkout that names none, and nothing at
5319    /// all over one that names its own. The wrapper records the environment it
5320    /// was given, so this is the wiring and not the constructor alone.
5321    #[tokio::test(flavor = "current_thread")]
5322    async fn a_fetch_carries_forges_ssh_command_only_when_the_checkout_names_none() {
5323        if std::env::var_os("GIT_SSH_COMMAND").is_some() || std::env::var_os("GIT_SSH").is_some() {
5324            return;
5325        }
5326        let (_upstream, dir) = stale_base_fixture();
5327        let bin = tempfile::tempdir().expect("wrapper dir");
5328        let marker = bin.path().join("ssh-env");
5329        let at = marker.display();
5330        let capturing = format!(
5331            "echo \"ssh=${{GIT_SSH_COMMAND:-none}}\" >> '{at}'; \
5332             {ISOLATED_GIT_CONFIG} exec git \"$@\""
5333        );
5334        write_wrapper(dir.path(), bin.path(), "env-git", &[], &capturing);
5335
5336        let first = refresh_base_within(dir.path(), "origin/main", TEST_GIT_BUDGET).await;
5337        git_at(dir.path(), &["config", "core.sshCommand", "/bin/true"]);
5338        let second = refresh_base_within(dir.path(), "origin/main", TEST_GIT_BUDGET).await;
5339        crate::env::git_command::clear_git_point(dir.path());
5340
5341        assert_eq!(first.state, BaseState::Current);
5342        assert_eq!(second.state, BaseState::Current);
5343        let seen = std::fs::read_to_string(&marker).expect("the wrapper's record");
5344        let lines: Vec<&str> = seen.lines().collect();
5345        assert!(
5346            lines.first().is_some_and(|line| line.contains("ConnectTimeout=15")),
5347            "forge's bound is on the fetch of a checkout that names none: {seen:?}",
5348        );
5349        assert_eq!(
5350            lines.get(1).copied(),
5351            Some("ssh=none"),
5352            "and nothing is installed over the checkout's own command: {seen:?}",
5353        );
5354    }
5355
5356    /// **Git's words lose any credential a remote URL carries**: the record is
5357    /// re-sent on every read and drawn on a page.
5358    #[test]
5359    fn a_remote_url_loses_its_credential_in_the_words_a_page_draws() {
5360        assert_eq!(
5361            redact(
5362                "fatal: unable to access 'https://user:s3cret@host.example/x.git/': Could not \
5363                 resolve host"
5364            ),
5365            "fatal: unable to access 'https://<redacted>@host.example/x.git/': Could not resolve \
5366             host",
5367        );
5368        assert_eq!(
5369            redact("fatal: could not read from 'https://ghp_token@host.example/x'"),
5370            "fatal: could not read from 'https://<redacted>@host.example/x'",
5371        );
5372        assert_eq!(redact("fatal: no url here"), "fatal: no url here");
5373        assert_eq!(
5374            redact("fatal: 'https://host.example:8443/x' refused"),
5375            "fatal: 'https://host.example:8443/x' refused",
5376            "a port is not a credential",
5377        );
5378    }
5379
5380    /// **The credential goes before the cap does.** Applied first, the cap
5381    /// lands inside the userinfo and takes the `@` the redactor needs with it,
5382    /// so the head of the password reaches the record - and every page that
5383    /// draws it. A redaction case under 200 characters cannot tell the two
5384    /// orders apart; this one puts the cap inside the password.
5385    #[tokio::test(flavor = "current_thread")]
5386    async fn a_failed_fetchs_credential_goes_before_the_words_are_capped() {
5387        let (_upstream, dir) = stale_base_fixture();
5388        let bin = tempfile::tempdir().expect("wrapper dir");
5389        let refused = format!(
5390            "printf '%s\\n' \"fatal: {}https://user:SECRETSECRET@host.example/x: {}\" >&2; exit 1",
5391            "x".repeat(170),
5392            "y".repeat(300),
5393        );
5394        write_wrapper(dir.path(), bin.path(), "credential-git", &[], &refused);
5395
5396        let refresh = refresh_base_within(dir.path(), "origin/main", TEST_GIT_BUDGET).await;
5397        crate::env::git_command::clear_git_point(dir.path());
5398
5399        let words = refresh.error.expect("the fetch failed");
5400        assert!(
5401            !words.contains("SECRET"),
5402            "the credential reached the words a page draws: {words:?}",
5403        );
5404        assert!(
5405            words.contains("<redacted>"),
5406            "and the words still say the URL carried one: {words:?}",
5407        );
5408    }
5409
5410    /// **The stderr clip cuts on a character boundary**: git's stderr is
5411    /// arbitrary bytes and a byte slice inside one panics mid-refresh.
5412    #[test]
5413    fn the_stderr_clip_cuts_on_a_character_boundary() {
5414        let text = format!("{}é", "a".repeat(STDERR_LOG_CAP - 1));
5415        let out = clipped(&text, STDERR_LOG_CAP);
5416        assert!(out.ends_with('…'), "the clip marks itself: {out:?}");
5417        assert!(!out.contains('é'), "the character the cap landed inside is dropped, not split");
5418    }
5419}