Skip to main content

amont_runtime/
tree_lint.rs

1//! Tree gates at commit time: run beside the commit's checks, never decide it,
2//! stamp the tree when they prove it (ADR-0024).
3//!
4//! The commit's verdict is untouched: it comes from the checks it always came
5//! from. A tree gate adds at most `amont.treeLintSlack` after those finish, and
6//! a pass is recorded as the token `tree:<name>` in the commit's gate stamp
7//! (`gate_stamp::record`), which post-commit binds to both the commit and its
8//! tree. The push reads the tree's note, so a reword keeps the proof.
9//!
10//! No stamp, and the gates do not even start, when the tree they would lint is
11//! not exactly the commit's tree:
12//!
13//! - tracked files had unstaged edits (captured BEFORE the staged-only hold
14//!   parks them — after, the working tree always looks clean);
15//! - untracked, non-ignored files exist;
16//! - ignored files exist outside the allow-list (`snapshotPrepareOutputs`,
17//!   plus tool caches by default) — a gitignored generated module can make a
18//!   linter pass here and fail in CI;
19//! - a merge, rebase, cherry-pick or revert is in progress.
20
21use std::path::{Path, PathBuf};
22use std::sync::atomic::{AtomicBool, Ordering};
23use std::sync::Arc;
24use std::thread::JoinHandle;
25use std::time::{Duration, Instant};
26
27use crate::hooks::common::{hl, ok, say};
28use crate::manifest::{TreeGate, CACHE_PLACEHOLDER};
29use crate::tree_run::TreeRun;
30
31pub const TOGGLE: &str = "amont.treeLint";
32pub const SLACK: &str = "amont.treeLintSlack";
33pub const OUTPUTS: &str = "amont.snapshotPrepareOutputs";
34
35/// Seconds tree gates may run after the commit's own checks have finished.
36const DEFAULT_SLACK: i64 = 2;
37
38/// The ceiling for one tree gate at commit, whatever the slack: a gate is
39/// cancelled at `slack` after the checks anyway, this only bounds a commit
40/// whose checks themselves run long.
41const CEILING: Duration = Duration::from_secs(600);
42
43/// Ignored paths that are tool caches or dependency trees CI recreates, and
44/// never a module a linter could import in place of a committed one.
45pub const DEFAULT_OUTPUTS: &[&str] = &[
46    "node_modules/",
47    ".venv/",
48    "venv/",
49    "__pycache__/",
50    ".ruff_cache/",
51    ".pytest_cache/",
52    ".mypy_cache/",
53    ".hypothesis/",
54    ".eslintcache",
55    ".prettiercache",
56    ".DS_Store",
57];
58
59/// Whether tree gates run here. On by default where attestation is on:
60/// a stamp nobody signs is paid for and never used.
61pub fn enabled(settings: &crate::config::Settings) -> bool {
62    crate::config::boolean_or(settings, TOGGLE, crate::attest::enabled(settings))
63}
64
65fn slack_ms(settings: &crate::config::Settings) -> u64 {
66    u64::try_from(slack(settings).as_millis()).unwrap_or(u64::MAX)
67}
68
69fn slack(settings: &crate::config::Settings) -> Duration {
70    let secs = crate::config::integer_or(settings, SLACK, DEFAULT_SLACK, 0..=600);
71    Duration::from_secs(u64::try_from(secs).unwrap_or(0))
72}
73
74/// The allow-list: the defaults plus `snapshotPrepareOutputs`.
75pub fn outputs(settings: &crate::config::Settings) -> Vec<String> {
76    let mut out: Vec<String> = DEFAULT_OUTPUTS.iter().map(|s| s.to_string()).collect();
77    if let Some(v) = crate::config::string_value(settings, OUTPUTS) {
78        out.extend(v.split_whitespace().map(str::to_string));
79    }
80    out
81}
82
83/// Whether an ignored entry (as `git ls-files -o -i --directory` prints it:
84/// directories end in `/`) is covered by an allow-list pattern.
85///
86/// A pattern ending in `/` names a directory, anywhere in the tree
87/// (`__pycache__/` matches `pkg/__pycache__/`). Any other pattern names a
88/// file by basename, with `*` as the only wildcard (`*.log`).
89pub fn allowed(entry: &str, patterns: &[String]) -> bool {
90    patterns.iter().any(|p| {
91        if let Some(dir) = p.strip_suffix('/') {
92            let dir = dir.trim_start_matches("./");
93            entry == format!("{dir}/")
94                || entry.starts_with(&format!("{dir}/"))
95                || entry.contains(&format!("/{dir}/"))
96        } else {
97            let base = entry
98                .trim_end_matches('/')
99                .rsplit('/')
100                .next()
101                .unwrap_or(entry);
102            glob_basename(p, base)
103        }
104    })
105}
106
107/// `*` matches any run of characters; nothing else is special.
108fn glob_basename(pattern: &str, name: &str) -> bool {
109    let parts: Vec<&str> = pattern.split('*').collect();
110    if parts.len() == 1 {
111        return pattern == name;
112    }
113    let mut rest = name;
114    for (i, part) in parts.iter().enumerate() {
115        if i == 0 {
116            let Some(r) = rest.strip_prefix(part) else {
117                return false;
118            };
119            rest = r;
120        } else if i == parts.len() - 1 {
121            return rest.ends_with(part);
122        } else {
123            let Some(at) = rest.find(part) else {
124                return false;
125            };
126            rest = &rest[at + part.len()..];
127        }
128    }
129    true
130}
131
132/// Why this commit's tree cannot be proven, or `None` when it can. Runs
133/// BEFORE the staged-only hold: only then can unstaged edits be seen.
134pub fn guard(settings: &crate::config::Settings) -> Option<String> {
135    if !crate::git_states_in_progress().is_empty() {
136        return Some("a merge, rebase, cherry-pick or revert is in progress".into());
137    }
138    if !crate::git::succeeds(&["diff", "--quiet"]) {
139        return Some("tracked files have unstaged edits (the tree is not the commit's)".into());
140    }
141    content_guard(Path::new(&crate::hooks::common::repo_root()), settings)
142}
143
144/// Untracked files, and ignored ones outside the allow-list, under `dir`.
145fn content_guard(dir: &Path, settings: &crate::config::Settings) -> Option<String> {
146    // A git that cannot answer proves nothing: withhold, never read "none".
147    let Some(untracked) = crate::git::stdout_in(
148        dir,
149        &["ls-files", "--others", "--exclude-standard", "--directory"],
150    ) else {
151        return Some("git could not list untracked files".into());
152    };
153    if let Some(first) = untracked.lines().find(|l| !l.trim().is_empty()) {
154        return Some(format!("an untracked file is present ({first})"));
155    }
156    let Some(ignored) = crate::git::stdout_in(
157        dir,
158        &[
159            "ls-files",
160            "--others",
161            "--ignored",
162            "--exclude-standard",
163            "--directory",
164        ],
165    ) else {
166        return Some("git could not list ignored files".into());
167    };
168    let allow = outputs(settings);
169    if let Some(first) = ignored
170        .lines()
171        .map(str::trim)
172        .find(|l| !l.is_empty() && !allowed(l, &allow))
173    {
174        return Some(format!(
175            "an ignored file outside {} is present ({first})",
176            hl("snapshotPrepareOutputs")
177        ));
178    }
179    None
180}
181
182/// The argv a gate runs: its command through `sh -c`, as a CI `run:` line
183/// runs through a shell, with `{cache}` expanded to `cache_flags`.
184pub fn argv(gate: &TreeGate, cache_flags: &str) -> Vec<String> {
185    if cache_flags.trim().is_empty() {
186        // Exactly what CI runs: no dangling `--`.
187        return vec!["sh".into(), "-c".into(), gate.normalized()];
188    }
189    let command: Vec<&str> = gate
190        .command
191        .split_whitespace()
192        .flat_map(|w| {
193            if w == CACHE_PLACEHOLDER {
194                cache_flags.split_whitespace().collect::<Vec<_>>()
195            } else {
196                vec![w]
197            }
198        })
199        .collect();
200    vec!["sh".into(), "-c".into(), command.join(" ")]
201}
202
203/// What one gate's thread decided, and did.
204enum Decision {
205    /// It ran: how it ended, and how long it took.
206    Ran(TreeRun, u64),
207    /// The tool it would run is not the one CI resolves.
208    Skew(String),
209    /// Its version could not be read before the deadline or the cancel.
210    NoVersion,
211    /// No completed run in its current namespace.
212    Cold,
213    /// Another run held its cache lock.
214    Busy,
215    /// Its last run (ms) would not fit this commit's cover plus the slack.
216    Slow(u64),
217}
218
219type InFlight = (String, JoinHandle<Decision>);
220
221/// Tree gates in flight.
222pub struct SideCar {
223    cancel: Arc<AtomicBool>,
224    running: Vec<InFlight>,
225}
226
227/// This hook run's tree-gate outcomes, for `gate_evidence`: proven, failed,
228/// cold, busy, skew, withheld, cancelled — the hit rate, and why it missed.
229static EVIDENCE: std::sync::Mutex<Vec<crate::gate_stamp::Run>> = std::sync::Mutex::new(Vec::new());
230
231fn note(name: &str, outcome: crate::gate_stamp::RunOutcome, ms: u64) {
232    let at = std::time::SystemTime::now()
233        .duration_since(std::time::UNIX_EPOCH)
234        .map(|d| d.as_secs())
235        .unwrap_or(0);
236    if let Ok(mut e) = EVIDENCE.lock() {
237        e.push(crate::gate_stamp::Run {
238            at,
239            gate: format!("tree-{name}"),
240            outcome,
241            ms,
242        });
243    }
244}
245
246/// Every gate withheld for the same reason (the guard fired).
247pub fn note_withheld(gates: &[TreeGate]) {
248    for g in gates {
249        note(&g.name, crate::gate_stamp::RunOutcome::Withheld, 0);
250    }
251}
252
253/// Write this run's tree-gate evidence against `tree`'s gate note, and
254/// clear it. Best-effort, like every evidence writer.
255pub fn flush_evidence(tree: &str) {
256    let runs = EVIDENCE
257        .lock()
258        .map(|mut e| std::mem::take(&mut *e))
259        .unwrap_or_default();
260    if !runs.is_empty() {
261        crate::gate_stamp::record_runs(tree, &runs);
262    }
263}
264
265fn cwd_of(root: &Path, gate: &TreeGate) -> PathBuf {
266    match &gate.cwd {
267        Some(dir) => root.join(dir),
268        None => root.to_path_buf(),
269    }
270}
271
272/// Start one thread per gate against the working tree, which the staged-only
273/// hold has made the commit's tree. EVERYTHING a gate needs to decide runs in
274/// its own thread, so none of it delays the commit's checks: the version
275/// probe (bounded and cancellable — a wrapper may reach the network), the
276/// skew check, the namespace, the warm and fit tests, the lock, the run.
277/// `cover_ms` is how long this commit's own declared checks are expected to
278/// run. Returns `None` when nothing starts.
279pub fn start(
280    settings: &crate::config::Settings,
281    root: &Path,
282    gates: &[TreeGate],
283    pins: &[crate::manifest::ToolPin],
284    cover_ms: u64,
285) -> Option<SideCar> {
286    if gates.is_empty() {
287        return None;
288    }
289    if cfg!(not(unix)) {
290        say("  tree gates need a Unix shell and do not run here — CI will lint");
291        return None;
292    }
293    let cancel = Arc::new(AtomicBool::new(false));
294    let deadline = Instant::now() + CEILING;
295    let budget_ms = cover_ms.saturating_add(slack_ms(settings));
296    let running = gates
297        .iter()
298        .map(|g| {
299            let gate = g.clone();
300            let pins = pins.to_vec();
301            let cwd = cwd_of(root, g);
302            let flag = Arc::clone(&cancel);
303            let handle = std::thread::spawn(move || {
304                decide_and_run(&gate, &cwd, &pins, budget_ms, deadline, &flag)
305            });
306            (g.name.clone(), handle)
307        })
308        .collect();
309    Some(SideCar { cancel, running })
310}
311
312fn decide_and_run(
313    g: &TreeGate,
314    cwd: &Path,
315    pins: &[crate::manifest::ToolPin],
316    budget_ms: u64,
317    deadline: Instant,
318    cancel: &AtomicBool,
319) -> Decision {
320    // Would it fit? Its last run against this commit's cover plus the slack,
321    // asked FIRST so a gate that cannot fit costs nothing. Unknown runs once,
322    // to learn.
323    if let Some(ms) = crate::tree_cache::gate_last_ms(g).filter(|ms| *ms > budget_ms) {
324        return Decision::Slow(ms);
325    }
326    let Some(version) = crate::tree_cache::tool_version(cwd, g, deadline, cancel) else {
327        return Decision::NoVersion;
328    };
329    if let Some(why) = crate::tree_skew::skew(cwd, g, pins, &version) {
330        return Decision::Skew(why);
331    }
332    let Some(ns) =
333        crate::tree_cache::namespace(g, &version).filter(|ns| crate::tree_cache::is_warm(g, ns))
334    else {
335        return Decision::Cold;
336    };
337    let Some(ns_dir) = crate::tree_cache::gate_dir(g).map(|d| d.join(&ns)) else {
338        return Decision::Cold;
339    };
340    let Some(lock) = crate::tree_cache::try_lock(g) else {
341        return Decision::Busy;
342    };
343    let argv = argv(
344        g,
345        &crate::tree_cache::cache_flags(cwd, g, &ns_dir, deadline, cancel),
346    );
347    let began = Instant::now();
348    let run = crate::tree_run::run(&argv, cwd, deadline, cancel);
349    let ms = u64::try_from(began.elapsed().as_millis()).unwrap_or(u64::MAX);
350    // Only now: the runner killed the whole group before returning, so
351    // nothing that could still write the cache is alive.
352    drop(lock);
353    Decision::Ran(run, ms)
354}
355
356/// Which declared tree gates every pushed tip's TREE proves, as note gate
357/// ids (`tree-<name>`), and which it does not, by name. A tip whose tree git
358/// cannot name proves nothing.
359pub fn tree_verdict(gates: &[TreeGate], tips: &[String]) -> (Vec<String>, Vec<String>) {
360    let trees: Vec<Option<String>> = tips
361        .iter()
362        .map(|t| crate::git::stdout(&["rev-parse", &format!("{t}^{{tree}}")]))
363        .collect();
364    let tokens: Vec<Vec<String>> = trees
365        .iter()
366        .map(|t| {
367            t.as_deref()
368                .map(crate::gate_stamp::tree_tokens)
369                .unwrap_or_default()
370        })
371        .collect();
372    let mut proven = Vec::new();
373    let mut unproven = Vec::new();
374    for g in gates {
375        let key = g.stamp_key();
376        if !tips.is_empty() && tokens.iter().all(|toks| toks.iter().any(|t| t == &key)) {
377            proven.push(g.id());
378        } else {
379            unproven.push(g.name.clone());
380        }
381    }
382    (proven, unproven)
383}
384
385pub const REHEARSAL_TIMEOUT: &str = "amont.treeLintRehearsalTimeout";
386pub const WAIT: &str = "amont.treeLintWait";
387
388/// Whether a snapshot is still exactly its tree once `snapshotPrepare` (and
389/// `snapshotCarry`) ran: tracked content unchanged, nothing untracked, and
390/// nothing ignored outside the allow-list. Prepare runs arbitrary commands,
391/// so this is what keeps it from forging the tree a stamp names.
392pub fn prepare_guard(snapshot: &Path, settings: &crate::config::Settings) -> Option<String> {
393    if !crate::git::succeeds_in(snapshot, &["diff", "--quiet", "HEAD"]) {
394        return Some("snapshotPrepare changed tracked content".into());
395    }
396    content_guard(snapshot, settings)
397}
398
399/// The rehearsal's half (ADR-0024): prove `gates` on the snapshot of `tree`,
400/// uncached (every snapshot sits at a new path) and bounded by
401/// `amont.treeLintRehearsalTimeout`, and stamp the TREE. Runs before the
402/// rehearsal's test gates, so a push waiting on lint does not wait on tests.
403pub fn rehearse(
404    settings: &crate::config::Settings,
405    snapshot: &Path,
406    tree: &str,
407    gates: &[TreeGate],
408) -> Vec<String> {
409    if gates.is_empty() || cfg!(not(unix)) {
410        return Vec::new();
411    }
412    if let Some(why) = prepare_guard(snapshot, settings) {
413        println!("tree lint not proven in the snapshot: {why}");
414        note_withheld(gates);
415        flush_evidence(tree);
416        return Vec::new();
417    }
418    let budget = crate::config::integer_or(settings, REHEARSAL_TIMEOUT, 120, 1..=3600);
419    let budget = Duration::from_secs(u64::try_from(budget).unwrap_or(120));
420    let mut tokens = Vec::new();
421    let pins = crate::manifest::load(snapshot).pins;
422    for g in gates {
423        let cwd = cwd_of(snapshot, g);
424        let Some(version) = crate::tree_cache::tool_version(
425            &cwd,
426            g,
427            Instant::now() + budget,
428            &AtomicBool::new(false),
429        ) else {
430            note(&g.name, crate::gate_stamp::RunOutcome::Skew, 0);
431            println!(
432                "tree lint not proven: {} — its tool's version could not be read",
433                g.name
434            );
435            continue;
436        };
437        if let Some(why) = crate::tree_skew::skew(&cwd, g, &pins, &version) {
438            note(&g.name, crate::gate_stamp::RunOutcome::Skew, 0);
439            println!("tree lint not proven: {} — {why}", g.name);
440            continue;
441        }
442        let started = Instant::now();
443        let run = crate::tree_run::run(
444            &argv(g, ""),
445            &cwd,
446            started + budget,
447            &AtomicBool::new(false),
448        );
449        let ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX);
450        match run {
451            TreeRun::Passed => {
452                note(&g.name, crate::gate_stamp::RunOutcome::Passed, ms);
453                println!(
454                    "tree lint proven: {} ({:.1}s)",
455                    g.name,
456                    started.elapsed().as_secs_f32()
457                );
458                tokens.push(g.stamp_key());
459            }
460            other => {
461                let outcome = match other {
462                    TreeRun::Failed(_) => crate::gate_stamp::RunOutcome::Failed,
463                    TreeRun::TimedOut | TreeRun::Cancelled => {
464                        crate::gate_stamp::RunOutcome::Cancelled
465                    }
466                    TreeRun::Spawn(_) => crate::gate_stamp::RunOutcome::Unavailable,
467                    TreeRun::Passed => unreachable!("matched above"),
468                };
469                note(&g.name, outcome, ms);
470                println!("tree lint not proven: {} — {other:?}", g.name);
471            }
472        }
473    }
474    flush_evidence(tree);
475    if !tokens.is_empty() && !crate::gate_stamp::stamp_tree(tree, &tokens) {
476        println!("git refused the tree note — nothing stamped");
477        return Vec::new();
478    }
479    tokens
480}
481
482/// [`tree_verdict`], waiting — at most `amont.treeLintWait` counted from
483/// `started` (the start of pre-push), as ONE deadline for every gate — for a
484/// rehearsal running on the tree of one of `tips` to stamp its tree gates.
485/// Independent of `amont.rehearsalWait`: it neither uses nor extends it.
486pub fn await_verdict(
487    settings: &crate::config::Settings,
488    gates: &[TreeGate],
489    tips: &[String],
490    started: Instant,
491) -> (Vec<String>, Vec<String>) {
492    let secs = crate::config::integer_or(settings, WAIT, 30, 0..=3600);
493    let until = started + Duration::from_secs(u64::try_from(secs).unwrap_or(30));
494    let trees: Vec<String> = tips
495        .iter()
496        .filter_map(|t| crate::git::stdout(&["rev-parse", &format!("{t}^{{tree}}")]))
497        .collect();
498    loop {
499        let verdict = tree_verdict(gates, tips);
500        let rehearsing =
501            crate::rehearsal::read().is_some_and(|s| s.alive() && trees.contains(&s.tree));
502        if verdict.1.is_empty() || !rehearsing || Instant::now() >= until {
503            return verdict;
504        }
505        std::thread::sleep(Duration::from_millis(250));
506    }
507}
508
509/// `prefix + body + suffix`, with `body` cut (and an ellipsis added) so the
510/// whole line fits 80 columns. The body carries a tool's own summary line,
511/// whose length nothing bounds.
512pub fn fit_line(prefix: &str, body: &str, suffix: &str) -> String {
513    const COLUMNS: usize = 80;
514    let room = COLUMNS.saturating_sub(prefix.chars().count() + suffix.chars().count());
515    let body = if body.chars().count() > room && room > 1 {
516        let cut: String = body.chars().take(room - 1).collect();
517        format!("{cut}…")
518    } else {
519        body.to_string()
520    };
521    format!("{prefix}{body}{suffix}")
522}
523
524/// Where the background warm-up writes: its own log, never the rehearsal's.
525#[cfg(unix)]
526fn warm_log() -> Option<PathBuf> {
527    crate::git::stdout(&["rev-parse", "--absolute-git-dir"])
528        .map(|d| PathBuf::from(d).join("amont-warm.log"))
529}
530
531/// Start the background warm-up for the cold gates, and say so once — under
532/// every `amont.quiet` setting: a cold gate is why this commit carries no
533/// `tree:` stamp, and quiet only swallows success lines.
534fn warm_later(cold: &[String]) {
535    #[cfg(unix)]
536    {
537        let Some(log) = warm_log() else { return };
538        match crate::rehearsal::spawn_amont(&["warm", "--worker"], &log) {
539            Ok(_) => say(&fit_line(
540                "  tree lint cold: ",
541                &cold.join(" "),
542                " — warming ($GIT_DIR/amont-warm.log)",
543            )),
544            Err(e) => say(&format!(
545                "  tree lint cold: {} — could not start the warm-up ({e}); run {}",
546                cold.join(" "),
547                hl("amont warm")
548            )),
549        }
550    }
551    #[cfg(not(unix))]
552    {
553        let _ = cold;
554    }
555}
556
557/// `amont warm` — fill every cold tree gate's cache in its current
558/// namespace: a full run with `{cache}`, then the completion marker. Old
559/// namespaces are deleted first, under the gate's lock. A gate whose lock is
560/// held is left to whoever holds it. Exit 0 whatever the lint found: a
561/// warm-up proves nothing, it only makes the next commit's run cheap.
562pub fn warm(root: &Path) -> i32 {
563    let manifest = crate::manifest::load(root);
564    if manifest.tree.is_empty() {
565        println!("amont warm: no trusted tree gate here");
566        return 0;
567    }
568    for g in &manifest.tree {
569        let cwd = cwd_of(root, g);
570        let Some(version) = crate::tree_cache::tool_version(
571            &cwd,
572            g,
573            Instant::now() + Duration::from_secs(60),
574            &AtomicBool::new(false),
575        ) else {
576            println!("{}: its tool's version could not be read — skipped", g.name);
577            continue;
578        };
579        let Some(ns) = crate::tree_cache::namespace(g, &version) else {
580            println!("{}: git could not name the namespace — skipped", g.name);
581            continue;
582        };
583        if crate::tree_cache::is_warm(g, &ns) {
584            println!("{}: already warm", g.name);
585            continue;
586        }
587        let Some(_lock) = crate::tree_cache::try_lock(g) else {
588            println!("{}: another run holds the cache — skipped", g.name);
589            continue;
590        };
591        let Some(ns_dir) = crate::tree_cache::enter_namespace(g, &ns) else {
592            println!("{}: cannot create the cache directory — skipped", g.name);
593            continue;
594        };
595        let argv = argv(
596            g,
597            &crate::tree_cache::cache_flags(
598                &cwd,
599                g,
600                &ns_dir,
601                Instant::now() + CEILING,
602                &AtomicBool::new(false),
603            ),
604        );
605        let started = Instant::now();
606        let run = crate::tree_run::run(&argv, &cwd, started + CEILING, &AtomicBool::new(false));
607        let secs = started.elapsed().as_secs_f32();
608        match run {
609            TreeRun::Passed | TreeRun::Failed(_) => {
610                crate::tree_cache::mark_complete(&ns_dir);
611                println!("{}: warm ({secs:.1}s)", g.name);
612            }
613            other => println!("{}: not warmed — {other:?}", g.name),
614        }
615    }
616    0
617}
618
619/// Wait at most `amont.treeLintSlack` for the gates still running, cancel the
620/// rest, and return the stamp tokens of the gates that proved the tree.
621/// `stampable` is false when the commit is blocked or a check rewrote files:
622/// then nothing is waited for and nothing is stamped. Cold gates go to the
623/// background warm-up.
624pub fn finish(settings: &crate::config::Settings, car: SideCar, stampable: bool) -> Vec<String> {
625    use crate::gate_stamp::RunOutcome;
626    let until = Instant::now()
627        + if stampable {
628            slack(settings)
629        } else {
630            Duration::ZERO
631        };
632    while Instant::now() < until && car.running.iter().any(|(_, h)| !h.is_finished()) {
633        std::thread::sleep(Duration::from_millis(20));
634    }
635    car.cancel.store(true, Ordering::SeqCst);
636    let mut proven = Vec::new();
637    // (gate, why): every gate this commit did not prove, one reason each.
638    let mut unproven: Vec<(String, String, bool)> = Vec::new();
639    let mut cold = Vec::new();
640    for (name, handle) in car.running {
641        let decision = handle
642            .join()
643            .unwrap_or(Decision::Ran(TreeRun::Spawn("thread died".into()), 0));
644        match decision {
645            Decision::Ran(run, ms) => {
646                // A completed run's time, or a cancelled one's lower bound:
647                // what the next commit's fit test reads.
648                if !matches!(run, TreeRun::Spawn(_)) {
649                    crate::tree_cache::record_gate_last_ms(&name, ms);
650                }
651                match run {
652                    TreeRun::Passed if stampable => {
653                        note(&name, RunOutcome::Passed, ms);
654                        proven.push(name);
655                    }
656                    TreeRun::Passed => note(&name, RunOutcome::Withheld, ms),
657                    TreeRun::Failed(summary) => {
658                        note(&name, RunOutcome::Failed, ms);
659                        unproven.push((name, summary, false));
660                    }
661                    TreeRun::TimedOut | TreeRun::Cancelled => {
662                        note(&name, RunOutcome::Cancelled, ms);
663                        unproven.push((
664                            name,
665                            "still running when the commit was ready".into(),
666                            false,
667                        ));
668                    }
669                    TreeRun::Spawn(e) => {
670                        note(&name, RunOutcome::Unavailable, ms);
671                        unproven.push((name, format!("could not start ({e})"), false));
672                    }
673                }
674            }
675            Decision::Skew(why) => {
676                note(&name, RunOutcome::Skew, 0);
677                unproven.push((name, why, false));
678            }
679            Decision::NoVersion => {
680                note(&name, RunOutcome::Skew, 0);
681                unproven.push((
682                    name,
683                    "its tool's version could not be read in time".into(),
684                    false,
685                ));
686            }
687            Decision::Cold => {
688                note(&name, RunOutcome::Cold, 0);
689                cold.push(name);
690            }
691            Decision::Busy => {
692                note(&name, RunOutcome::Busy, 0);
693                unproven.push((name, "a warm-up holds its cache".into(), false));
694            }
695            Decision::Slow(ms) => {
696                note(&name, RunOutcome::Slow, 0);
697                unproven.push((
698                    name,
699                    format!(
700                        "skipped: needs ~{:.1}s, more than this commit leaves",
701                        ms as f64 / 1000.0
702                    ),
703                    true,
704                ));
705            }
706        }
707    }
708    // The gates linted the working tree the staged-only hold made the index.
709    // If it moved while they ran (an editor autosave, a gate that writes), the
710    // proof is about another tree: withhold it.
711    if stampable && !proven.is_empty() {
712        let moved = if crate::git::succeeds(&["diff", "--quiet"]) {
713            content_guard(Path::new(&crate::hooks::common::repo_root()), settings)
714        } else {
715            Some("tracked files changed while it ran".into())
716        };
717        if let Some(why) = moved {
718            for name in proven.drain(..) {
719                note(&name, RunOutcome::Withheld, 0);
720                unproven.push((name, format!("the tree moved while it ran ({why})"), false));
721            }
722        }
723    }
724    if !proven.is_empty() {
725        ok(settings, &format!("tree lint proven: {}", proven.join(" ")));
726    }
727    // One header naming the consequence once, then one line per gate, each
728    // fitted to 80 columns (a tool's own summary is unbounded).
729    let quiet = crate::live::quiet(settings);
730    let unproven: Vec<(String, String, bool)> = unproven
731        .into_iter()
732        .filter(|(_, _, skipped)| !(quiet && *skipped))
733        .collect();
734    if stampable && !unproven.is_empty() {
735        say("  tree lint not proven — CI will lint:");
736        for (name, why, _) in &unproven {
737            say(&fit_line(
738                &format!("    {} — ", crate::ui::sanitize(name)),
739                &crate::ui::sanitize(why),
740                "",
741            ));
742        }
743    }
744    if !cold.is_empty() {
745        warm_later(&cold);
746    }
747    proven.iter().map(|n| format!("tree:{n}")).collect()
748}
749
750#[cfg(test)]
751mod tests {
752    use super::*;
753
754    fn pats(extra: &[&str]) -> Vec<String> {
755        DEFAULT_OUTPUTS
756            .iter()
757            .chain(extra)
758            .map(|s| s.to_string())
759            .collect()
760    }
761
762    #[test]
763    fn tree_lint_allows_tool_caches_anywhere() {
764        let p = pats(&[]);
765        assert!(allowed("node_modules/", &p));
766        assert!(allowed("packages/api/__pycache__/", &p));
767        assert!(allowed(".venv/", &p));
768        assert!(allowed("web/.DS_Store", &p));
769    }
770
771    #[test]
772    fn tree_lint_refuses_an_ignored_module() {
773        let p = pats(&[]);
774        assert!(!allowed("src/generated/types.ts", &p));
775        assert!(!allowed(".react-router/", &p));
776        assert!(!allowed("build/", &p));
777    }
778
779    #[test]
780    fn tree_lint_repo_patterns_extend_the_list() {
781        let p = pats(&["build/", ".react-router/", "*.db", "*.db-wal"]);
782        assert!(allowed("build/", &p));
783        assert!(allowed(".react-router/", &p));
784        assert!(allowed("landscape.db", &p));
785        assert!(allowed("landscape.db-wal", &p));
786        assert!(!allowed("landscape.sqlite", &p));
787    }
788
789    #[test]
790    fn tree_lint_fit_line_cuts_the_body_to_eighty_columns() {
791        let l = fit_line(
792            "  tree lint not proven: ",
793            "eslint — 12 problems (12 errors, 0 warnings) and some more words",
794            " — CI will lint",
795        );
796        assert_eq!(l.chars().count(), 80, "{l}");
797        assert!(l.ends_with("… — CI will lint"), "{l}");
798        let body = fit_line("    eslint — ", &"x".repeat(200), "");
799        assert_eq!(body.chars().count(), 80);
800        let short = fit_line("a", "b", "c");
801        assert_eq!(short, "abc");
802    }
803
804    #[test]
805    fn tree_lint_argv_runs_through_sh_with_cache_expanded() {
806        let g = crate::manifest::tree_gates("tree eslint eslint * attest npm run lint -- {cache}")
807            .remove(0);
808        assert_eq!(
809            argv(&g, "--cache --cache-location /x"),
810            vec!["sh", "-c", "npm run lint -- --cache --cache-location /x"]
811        );
812        assert_eq!(argv(&g, ""), vec!["sh", "-c", "npm run lint"]);
813    }
814}