Skip to main content

amont_runtime/
dispatch.rs

1//! The two dispatchers.
2//!
3//! They are NOT the same shape, and both shapes are load-bearing:
4//!
5//! - `pre-commit` runs its checks CONCURRENTLY and reports EVERY failure.
6//!   Serial would be a visible slowdown on each commit; stopping at the first
7//!   failure would hide the rest, so you'd fix one lint error, commit, and
8//!   immediately meet the next.
9//! - `pre-push` runs them SERIALLY and stops at the FIRST failure, naming just
10//!   that check. The steps are ordered and expensive (protected branch, then
11//!   branch name, then rebase, then the whole test suite) and there is no point
12//!   running tests after a rebase conflict.
13//!
14//! Resist the tempting shared `run_all` helper — collapsing these is the
15//! obvious way to silently lose the distinction. `tests/dispatchers.rs` pins
16//! both.
17//!
18//! Checks are FUNCTIONS in this binary, called directly. They used to be files:
19//! `.git/hooks/pre-commit-*`, each an identical `sh` shim whose only job was to
20//! re-exec this same binary and tell it its own name. One commit therefore cost
21//! 27 processes — a shim, the binary, then 13 more shims and 13 more binaries —
22//! to do work the binary already had in a table.
23//!
24//! Deleting that removed the filename glob (order was lexicographic, so a
25//! rename could silently reorder a gate), the shebang emulation Windows needed
26//! because it cannot execute a `#!` script, and the spawn plumbing under both.
27//! Order is now a declared list in `registry`.
28
29use std::sync::Mutex;
30
31use crate::check::{Check, Outcome, Severity, Stage, Verdict};
32use crate::configured_skips;
33use crate::registry::{all_stage_checks, Ctx, Overrides};
34use crate::ui::{highlight, valid_sign, warning_sign};
35
36/// The checks for a stage, minus anything `hook.skip` filters out. Resolution
37/// goes through `names_check`, the one rule this and the severity lookup share:
38/// `git config hook.skip ruff` skips `pre-commit-ruff` by short name.
39fn selected<'a>(
40    settings: &crate::config::Settings,
41    stage: Stage,
42    manifest: &'a crate::manifest::Manifest,
43) -> Vec<&'a dyn Check> {
44    selected_during(settings, stage, &[], manifest)
45}
46
47/// Do this repository's hooks apply the CONVENTIONS, or only the safety net?
48///
49/// `git config amont.conventions declared` (usually `--global`, set by
50/// `amont enroll`) scopes the house rules to repositories that commit an
51/// `amont.conf` — the standing grant of `init.templateDir` then becomes safe
52/// to hand a whole team: a clone of somebody else's project gets conflict,
53/// secret, size and debug-leftover protection, and none of this team's
54/// opinions about commit subjects or branch names. The default,
55/// `everywhere`, keeps today's behaviour exactly.
56///
57/// Presence of the manifest is the declaration; its CONTENT stays
58/// trust-gated. Reading presence executes nothing, so no consent is needed.
59pub fn conventions_apply(
60    settings: &crate::config::Settings,
61    manifest: &crate::manifest::Manifest,
62) -> bool {
63    manifest.declared || !declared_mode(settings)
64}
65
66/// One config read per process — this sits on the hook path of every commit.
67fn declared_mode(settings: &crate::config::Settings) -> bool {
68    *settings.declared_mode.get_or_init(|| {
69        crate::config::enumerated_or(
70            settings,
71            "amont.conventions",
72            &["everywhere", "declared"],
73            "everywhere",
74        ) == "declared"
75    })
76}
77
78/// The checks for a stage, minus `hook.skip` and minus anything that declares
79/// it does not run during an operation currently in progress.
80fn selected_during<'a>(
81    settings: &crate::config::Settings,
82    stage: Stage,
83    in_progress: &[crate::check::GitState],
84    manifest: &'a crate::manifest::Manifest,
85) -> Vec<&'a dyn Check> {
86    let skips = configured_skips(settings);
87    // Externals are included here, so `hook.skip` and the severity override
88    // govern a declared command exactly as they govern a built-in. A repository
89    // that can add a check it cannot disable would be a worse deal than not
90    // being able to add one.
91    let (kept, dropped): (Vec<_>, Vec<_>) = all_stage_checks(stage, manifest)
92        .into_iter()
93        .partition(|c| !skips.iter().any(|s| crate::skip_suppresses(c.name(), s)));
94    let names: Vec<&str> = dropped.iter().map(|c| c.name()).collect();
95    announce_skips(settings, &names);
96
97    // Announced separately from `hook.skip`, and with the operation named: "not
98    // during a rebase" is a property of the moment and will be true again in a
99    // minute, which is a different thing to tell a reader than "you disabled
100    // this".
101    let (kept, paused): (Vec<_>, Vec<_>) = kept.into_iter().partition(|check| {
102        !check
103            .scope()
104            .not_during
105            .iter()
106            .any(|state| in_progress.contains(state))
107    });
108    if !paused.is_empty() {
109        let what = in_progress
110            .iter()
111            .map(|s| s.as_str())
112            .collect::<Vec<_>>()
113            .join(" and ");
114        println!(
115            "{} {} check(s) paused during {what}: {}",
116            warning_sign(),
117            paused.len(),
118            paused
119                .iter()
120                .map(|c| c.name())
121                .collect::<Vec<_>>()
122                .join(", ")
123        );
124    }
125
126    // The conventions split, last: a held-back check was neither skipped (a
127    // choice about THIS repository) nor paused (a property of the moment) —
128    // this repository simply never subscribed. One line, count not names:
129    // in the clone-of-somebody-else's-project case this prints on every
130    // commit, and fifteen names every time is how a safety message becomes
131    // scroll-past noise.
132    if conventions_apply(settings, manifest) {
133        return kept;
134    }
135    let (kept, held): (Vec<_>, Vec<_>) = kept
136        .into_iter()
137        .partition(|check| check.reach() == crate::check::Reach::Safety);
138    if !held.is_empty() {
139        println!(
140            "{} {} convention check(s) held back — no amont.conf here and \
141             amont.conventions is `declared`; the safety net still runs",
142            warning_sign(),
143            held.len(),
144        );
145    }
146    kept
147}
148
149/// The key that turns evidence ordering on. `declared` — the registry's
150/// order, the one this file's header argues for — is the default and nothing
151/// about it changes.
152const ORDER: &str = "amont.order";
153
154/// How far back [`crate::gate_evidence::order_by_evidence`] looks. Not a
155/// config key: it is an input to an ordering that changes nothing about what
156/// runs, and a knob nobody can predict the effect of is a knob that invites
157/// cargo-culting. Ninety days is the same window the report defaults to, so
158/// the ordering a push takes is the ordering `amont-fleet gates` explains.
159const ORDER_WINDOW_DAYS: u64 = 90;
160
161/// Does this repository want its push gates ordered by what the record says?
162fn evidence_ordering(settings: &crate::config::Settings) -> bool {
163    crate::config::enumerated_or(settings, ORDER, &["declared", "evidence"], "declared")
164        == "evidence"
165}
166
167/// Reorder the SCOPED push gates — the suites and audits — by the local
168/// record, leaving everything else exactly where the registry put it.
169///
170/// Two halves, and the split is the safety property. The unscoped pre-push
171/// checks ask about the PUSH — is this branch protected, is the name legal,
172/// is the branch behind, is there a secret in it — and the registry orders
173/// them "cheapest and most decisive first" for a reason: discovering a
174/// protected branch after twenty minutes of tests is precisely the waste this
175/// feature exists to remove, and promoting a suite above them would
176/// reintroduce it. So they keep their positions absolutely.
177///
178/// The scoped gates are permuted among the positions they already occupy.
179/// Nothing is added, removed or skipped — [`crate::gate_evidence`] argues
180/// that at length — and with no history the permutation is the identity.
181fn ordered_by_evidence<'a>(
182    settings: &crate::config::Settings,
183    checks: Vec<&'a dyn Check>,
184) -> Vec<&'a dyn Check> {
185    if !evidence_ordering(settings) || checks.len() < 2 {
186        return checks;
187    }
188    let slots: Vec<usize> = checks
189        .iter()
190        .enumerate()
191        .filter(|(_, c)| !c.scope().is_unscoped())
192        .map(|(i, _)| i)
193        .collect();
194    if slots.len() < 2 {
195        return checks;
196    }
197    let names: Vec<String> = slots
198        .iter()
199        .map(|&i| checks[i].name().to_string())
200        .collect();
201    let history = crate::gate_evidence::history_in(std::path::Path::new("."));
202    if history.is_empty() {
203        return checks;
204    }
205    let order = crate::gate_evidence::order_by_evidence(
206        &names,
207        &history,
208        crate::gate_evidence::now(),
209        ORDER_WINDOW_DAYS,
210    );
211    let mut out = checks.clone();
212    for (slot, &pick) in slots.iter().zip(&order) {
213        out[*slot] = checks[slots[pick]];
214    }
215    if out
216        .iter()
217        .map(|c| c.name())
218        .ne(checks.iter().map(|c| c.name()))
219    {
220        crate::say!(
221            "{} gate order from this repository's own record: {}",
222            valid_sign(),
223            slots
224                .iter()
225                .map(|&i| out[i].name())
226                .collect::<Vec<_>>()
227                .join(", ")
228        );
229    }
230    out
231}
232
233/// Say out loud which checks did not run.
234///
235/// A skip is otherwise invisible at exactly the moment it matters. With
236/// `hook.skip = merge-conflict` set, a commit printed six green ticks and no
237/// hint that a seventh check had been disabled — the developer sees a clean run
238/// and concludes they are covered.
239///
240/// It is worse than it sounds, because one value can silence a whole stage:
241/// `hook.skip = pre-commit` suppresses all fifteen. That is now something
242/// somebody meant rather than the accident it once was — `e` used to cost
243/// twenty by substring reach — but a commit under it still looks exactly like a
244/// commit that had nothing to report.
245///
246/// One line, only when something was actually skipped, so a normal commit is
247/// unchanged. This reaches every skip however it was created — hand-edited
248/// config included — which no dashboard can claim.
249fn announce_skips(settings: &crate::config::Settings, dropped: &[&str]) {
250    if dropped.is_empty() {
251        return;
252    }
253    // Two lines, not one: "you decided this" (hook.skip on this machine)
254    // and "your team decided this" (a skip line in the committed
255    // amont.conf) are different things to be told — the same reason paused
256    // and held-back get their own sentences. A name both sources suppress
257    // is announced as the machine's: the local decision is the nearer one.
258    let (machine, _policy) = crate::skips_by_source(settings);
259    let (yours, theirs): (Vec<&&str>, Vec<&&str>) = dropped
260        .iter()
261        .partition(|name| machine.iter().any(|s| crate::skip_suppresses(name, s)));
262    let say = |names: &[&&str], via: &str| {
263        if names.is_empty() {
264            return;
265        }
266        let plural = if names.len() == 1 { "check" } else { "checks" };
267        println!(
268            "{} {} {plural} skipped by {}: {}",
269            warning_sign(),
270            names.len(),
271            highlight(via),
272            names.iter().map(|n| **n).collect::<Vec<_>>().join(", ")
273        );
274    };
275    say(&yours, "hook.skip");
276    say(&theirs, "amont.conf");
277}
278
279/// Say, once per stage, what the manifest's policy could not do — withheld
280/// behind trust, or aiming at names that exist nowhere. Policy that silently
281/// does not apply is a silent behaviour change, which is the one kind this
282/// codebase does not allow itself.
283fn announce_policy_state(settings: &crate::config::Settings, manifest: &crate::manifest::Manifest) {
284    if let Some(why) = manifest.policy_withheld {
285        println!(
286            "{} {} policy not applied: {why}",
287            warning_sign(),
288            highlight(crate::manifest::MANIFEST),
289        );
290    }
291    for note in &manifest.policy_notes {
292        println!("{} {}", warning_sign(), note);
293    }
294    // The version floor rides the same two call sites: once per stage,
295    // beside the other "this repository expects something you lack" lines.
296    crate::skew::announce_minimum(settings);
297}
298
299/// Run every item concurrently and collect `(name, code)` in the INPUT order.
300///
301/// Extracted so the concurrency itself can be tested with a rendezvous instead
302/// of a stopwatch — an earlier wall-clock test was flaky the moment the machine
303/// was busy, and a threshold that trips under load teaches you to ignore it.
304fn run_concurrently<T, R, F>(items: &[T], run: F, if_thread_died: R) -> Vec<R>
305where
306    T: Sync,
307    R: Send + Sync + Clone,
308    F: Fn(&T) -> R + Sync,
309{
310    let slots: Vec<Mutex<Option<R>>> = items.iter().map(|_| Mutex::new(None)).collect();
311    std::thread::scope(|scope| {
312        for (item, slot) in items.iter().zip(&slots) {
313            let run = &run;
314            let died = &if_thread_died;
315            scope.spawn(move || {
316                // CAUGHT, not propagated. `thread::scope` re-raises a child
317                // panic in the parent, which would abort the whole hook with a
318                // backtrace and throw away the other nineteen checks' results —
319                // and would make `if_thread_died` unreachable, which is what it
320                // was until this test existed to notice.
321                let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(item)))
322                    .unwrap_or_else(|_| died.clone());
323                *slot.lock().expect("poisoned") = Some(outcome);
324            });
325        }
326    });
327    slots
328        .into_iter()
329        .map(|s| {
330            s.into_inner()
331                .expect("poisoned")
332                .unwrap_or_else(|| if_thread_died.clone())
333        })
334        .collect()
335}
336
337/// Take the index-fidelity hold, or say why the caller must stop.
338///
339/// Extracted from `pre_commit` so that `amont run` — which its own doc
340/// comment calls "a rehearsal of the hook" — can take exactly the same hold
341/// rather than judging the working tree while a real commit judges the index.
342///
343/// Around the WHOLE fan-out, not per check: twenty checks run concurrently and
344/// would fight over one working tree.
345fn hold_unstaged() -> Result<crate::staged_only::StagedOnly, Verdict> {
346    // BEFORE `enter()`, not after: `enter()` is what checks out the tree and
347    // parks the unstaged half, and a signal landing in the gap between that
348    // and the handler being armed would hit the default disposition — dead
349    // process, tree left checked out, nothing restored. The handler no-ops
350    // harmlessly on a signal that arrives before there is anything held.
351    crate::staged_only::install_signal_handler();
352    match crate::staged_only::StagedOnly::enter() {
353        Ok(guard) => Ok(guard),
354        Err(e) => {
355            // Refusing to check the wrong content is the safe direction; a
356            // check that read the tree would be answering about a commit
357            // nobody is making.
358            eprintln!("{e}");
359            Err(Verdict::Block)
360        }
361    }
362}
363
364pub fn pre_commit(ctx: &Ctx) -> Verdict {
365    let settings = ctx.settings;
366    // Before anything runs: a pinned tool at the wrong version makes every
367    // verdict below it suspect, and the warning costs one --version per pin.
368    crate::manifest::verify_tool_pins(&ctx.manifest.pins);
369    announce_policy_state(ctx.settings, ctx.manifest);
370    let in_progress = crate::git_states_in_progress();
371    let checks = selected_during(ctx.settings, Stage::PreCommit, &in_progress, ctx.manifest);
372
373    // Tree gates (ADR-0024): judged BEFORE the hold, which is the only moment
374    // unstaged edits are visible; started AFTER it, so they lint the commit's
375    // tree. They never decide the commit.
376    let tree_gates = if ctx.manifest.tree.is_empty() || !crate::tree_lint::enabled(settings) {
377        None
378    } else {
379        match crate::tree_lint::guard(settings) {
380            Some(why) => {
381                crate::hooks::common::say("  tree lint not proven — CI will lint:");
382                crate::hooks::common::say(&crate::tree_lint::fit_line("    ", &why, ""));
383                crate::tree_lint::note_withheld(&ctx.manifest.tree);
384                None
385            }
386            None => Some(&ctx.manifest.tree),
387        }
388    };
389
390    let held = match hold_unstaged() {
391        Ok(guard) => guard,
392        Err(verdict) => return verdict,
393    };
394
395    let root = crate::hooks::common::repo_root();
396    // How long this commit's own declared checks are expected to run — the
397    // cover a tree gate can hide behind — from their last measured runs.
398    let decls =
399        crate::hooks::run_tests::blocking_commit_decls(ctx.settings, &ctx.manifest.externals);
400    // Only declarations whose scope this commit touches RUN; the others
401    // return at once, and their duration says nothing about cover.
402    let staged = if ctx.manifest.tree.is_empty() {
403        Vec::new()
404    } else {
405        crate::hooks::common::staged_files(&[])
406    };
407    // A declaration in scope that was never measured is UNKNOWN cover, and
408    // unknown means run: the run teaches both numbers, exactly as an unknown
409    // gate time does. Guessing 0 would skip the one commit that could learn.
410    let cover_ms = if tree_gates.is_some() {
411        decls
412            .iter()
413            .filter(|d| d.scope.touches(&staged))
414            .map(|d| crate::tree_cache::duration_of(&d.id).unwrap_or(u64::MAX))
415            .max()
416            .unwrap_or(0)
417    } else {
418        0
419    };
420    let side_car = tree_gates.and_then(|g| {
421        crate::tree_lint::start(
422            settings,
423            std::path::Path::new(&root),
424            g,
425            &ctx.manifest.pins,
426            cover_ms,
427        )
428    });
429
430    let severities = Overrides::read(settings);
431    let (verdict, outcomes, durations) = run_stage_traced(settings, &checks, ctx, &severities);
432    // Remember how long the declared checks that judged something took, for
433    // the next commit's cover estimate.
434    if !ctx.manifest.tree.is_empty() {
435        let measured: Vec<(String, u64)> = checks
436            .iter()
437            .zip(outcomes.iter().zip(&durations))
438            .filter(|(c, (o, _))| {
439                matches!(o, Outcome::Passed | Outcome::Failed)
440                    && decls
441                        .iter()
442                        .any(|d| d.id == c.name() && d.scope.touches(&staged))
443            })
444            .map(|(c, (_, ms))| (c.name().to_string(), *ms))
445            .collect();
446        crate::tree_cache::record_durations(&measured);
447    }
448
449    // The shadow-mode ledger. Silent, best-effort, and never consulted by any
450    // verdict — see `crate::downgrade`.
451    crate::downgrade::note(
452        settings,
453        &downgraded_events(&checks, &outcomes, &severities),
454    );
455
456    // What post-commit will bind to the commit: the gate-declared checks
457    // that RAN clean, recorded while the index still is the commit's tree.
458    // Called on every verdict — an empty record clears any leftover marker,
459    // so a blocked attempt (or a repo with nothing declared) cannot leave an
460    // earlier attempt's marker to vouch for the next commit. `Unavailable`
461    // deliberately does not qualify: a check whose tool is missing judged
462    // nothing, and stamping it would be the paper promise this exists to
463    // replace.
464    // EVERY blocking declaration, not only the npm GATE names: a custom
465    // `pre-commit check … block …` earns its stamp the same way, and a
466    // same-named pre-push declaration defers to it (see `pair_verdict`).
467    let ran: Vec<String> = if matches!(verdict, Verdict::Block) {
468        Vec::new()
469    } else {
470        crate::hooks::run_tests::blocking_commit_decls(ctx.settings, &ctx.manifest.externals)
471            .into_iter()
472            .filter(|d| {
473                checks
474                    .iter()
475                    .zip(&outcomes)
476                    .any(|(c, o)| c.name() == d.id && matches!(o, Outcome::Passed | Outcome::Fixed))
477            })
478            .map(|d| d.script)
479            .collect()
480    };
481    // A rewrite means the tree the gates linted is not the one committed.
482    let stampable =
483        !matches!(verdict, Verdict::Block) && !outcomes.iter().any(|o| matches!(o, Outcome::Fixed));
484    let mut ran = ran;
485    if let Some(car) = side_car {
486        ran.extend(crate::tree_lint::finish(settings, car, stampable));
487    }
488    let ran: Vec<&str> = ran.iter().map(String::as_str).collect();
489    crate::gate_stamp::record(&ran);
490    // Under the hold, the index IS the commit's tree: its evidence goes there.
491    if !ctx.manifest.tree.is_empty() {
492        if let Some(tree) = crate::git::stdout(&["write-tree"]) {
493            crate::tree_lint::flush_evidence(&tree);
494        }
495    }
496
497    drop(held);
498    verdict
499}
500
501/// The checks that FAILED without blocking, each with why — the shadow-mode
502/// signal [`crate::downgrade`] keeps.
503///
504/// Derived at the hook entry points and deliberately NOT inside
505/// [`run_stage_traced`], which is where `classify` already computes the same
506/// set. `run_all` reaches that function too, so recording there would let
507/// every `amont run` rehearsal inflate a ledger a lead is going to read as a
508/// count of real commits — and `run --all-files` over a dirty tree would add
509/// dozens of events for content nobody is committing.
510///
511/// A check that DECLARES `warn` is counted but is not evidence about a
512/// rollout: it was never going to block, so calling it "would have blocked"
513/// would inflate the one number the whole feature exists to produce. Only an
514/// override of a blocking check earns that.
515fn downgraded_events(
516    checks: &[&dyn Check],
517    outcomes: &[Outcome],
518    severities: &Overrides,
519) -> Vec<(String, crate::downgrade::Origin)> {
520    checks
521        .iter()
522        .zip(outcomes)
523        .filter(|(_, o)| matches!(o, Outcome::Failed))
524        .filter(|(c, _)| matches!(severities.of(**c), Severity::Warn))
525        .map(|(c, _)| (c.name().to_string(), downgrade_origin(*c, severities)))
526        .collect()
527}
528
529/// Why this check did not block. Shared, because `pre_push` fail-fasts and
530/// never builds an outcomes vector to hand to [`downgraded_events`].
531fn downgrade_origin(check: &dyn Check, severities: &Overrides) -> crate::downgrade::Origin {
532    use crate::downgrade::Origin;
533    use crate::registry::Source;
534    if matches!(check.severity(), Severity::Warn) {
535        return Origin::Declared;
536    }
537    match severities.applied_with_source(check.name()) {
538        Some((_, _, Source::Config)) => Origin::Config,
539        Some((_, _, Source::Policy)) => Origin::Policy,
540        // Declared `block`, resolved `warn`, and nothing claims to have
541        // overridden it: a contradiction. Count it, but not as evidence — the
542        // conservative direction for a number whose only failure mode is
543        // being too alarming.
544        None => Origin::Declared,
545    }
546}
547
548/// The pre-commit body, over the checks it is GIVEN.
549///
550/// A seam, so a test can hand it a check that panics. Without it the value
551/// standing in for a dead check was a literal at one call site that no test
552/// could reach — the rule was asserted on the runner and merely hoped for here.
553fn run_stage(checks: &[&dyn Check], ctx: &Ctx, severities: &Overrides) -> Verdict {
554    let settings = ctx.settings;
555    run_stage_traced(settings, checks, ctx, severities).0
556}
557
558/// [`run_stage`], keeping the per-check outcomes — index-aligned with
559/// `checks` — alive past the verdict. `pre_commit` needs them to know which
560/// gate-declared checks actually ran (`gate_stamp`); `Report` cannot answer
561/// that, because `classify` deliberately drops the names of `Passed`.
562fn run_stage_traced(
563    settings: &crate::config::Settings,
564    checks: &[&dyn Check],
565    ctx: &Ctx,
566    severities: &Overrides,
567) -> (Verdict, Vec<Outcome>, Vec<u64>) {
568    if checks.is_empty() {
569        return (Verdict::Proceed, Vec::new(), Vec::new());
570    }
571    // One slot per check: everything a check says lands in its own buffer
572    // and reaches stdout as ONE block when it finishes — see `live`. Off
573    // (`amont.progress false`), no sink is ever installed and every print
574    // streams exactly as it always did.
575    let stage = crate::live::enabled(settings).then(|| {
576        let names: Vec<&str> = checks.iter().map(|c| c.name()).collect();
577        crate::live::Stage::begin(settings, &names)
578    });
579    let items: Vec<(usize, &&dyn Check)> = checks.iter().enumerate().collect();
580    let timed = run_concurrently(
581        &items,
582        |(idx, check)| {
583            let started = std::time::Instant::now();
584            let _sink = stage.as_ref().map(|s| s.enter(*idx));
585            // The block is emitted however the check leaves — a panicking
586            // check's partial output still reaches the reader, above the
587            // dead-check verdict `run_concurrently` fills in.
588            let flush = stage
589                .as_ref()
590                .map(|s| crate::live::FinishOnDrop::new(ctx.settings, s, *idx));
591            let sub = Ctx {
592                name: check.name(),
593                args: ctx.args,
594                hooks_dir: ctx.hooks_dir,
595                push: ctx.push,
596                manifest: ctx.manifest,
597                settings: ctx.settings,
598            };
599            let slot = crate::host_slots::enter_check(check.name());
600            let outcome = check.run(&sub);
601            if let Some(f) = &flush {
602                f.outcome(outcome);
603            }
604            // The record measures the check, not its wait for a host slot.
605            let ran = started.elapsed().saturating_sub(slot.queued());
606            let ms = u64::try_from(ran.as_millis()).unwrap_or(u64::MAX);
607            (outcome, ms)
608        },
609        // A check whose thread died has not passed. Stated here, where the slot
610        // is filled, rather than hidden in a `Default` impl that every future
611        // `#[derive(Default)]` would silently inherit.
612        (Outcome::Failed, 0),
613    );
614    let (outcomes, durations): (Vec<Outcome>, Vec<u64>) = timed.into_iter().unzip();
615
616    let report = classify(checks, &outcomes, severities);
617    announce(settings, &report);
618    (report.verdict(), outcomes, durations)
619}
620
621/// What a stage concluded, before anything is printed or exited.
622///
623/// A VALUE, so the classification can be asserted directly. While this was one
624/// function that classified, printed and returned an exit code, its tests could
625/// only check the code — whether the right thing was SAID went untested.
626#[derive(Debug, Default, PartialEq, Eq)]
627struct Report<'a> {
628    /// Repaired. The commit proceeds, but the author's files changed under
629    /// them and that must be said out loud.
630    fixed: Vec<&'a str>,
631    /// Failed, and the severity that applies blocks.
632    blocked: Vec<&'a str>,
633    /// Failed, but configured to warn. The check printed an error and meant it,
634    /// so somebody has to say it did not block.
635    downgraded: Vec<&'a str>,
636    /// Could not run. Distinct from "passed", which is the whole point.
637    unavailable: Vec<&'a str>,
638    /// How many passed outright. Only read when `amont.quiet` swallowed their
639    /// individual lines — a run that says nothing at all is indistinguishable
640    /// from a gate that never ran, and that is the one thing this crate will
641    /// not let a reader believe.
642    passed: usize,
643}
644
645impl Report<'_> {
646    fn verdict(&self) -> Verdict {
647        Verdict::blocking(!self.blocked.is_empty())
648    }
649}
650
651/// Pure: outcomes and severities in, a verdict out. No IO.
652fn classify<'a>(
653    checks: &[&'a dyn Check],
654    outcomes: &[Outcome],
655    severities: &Overrides,
656) -> Report<'a> {
657    let mut report = Report::default();
658    for (check, outcome) in checks.iter().zip(outcomes) {
659        match outcome {
660            Outcome::Passed => report.passed += 1,
661            // `Warned` needs nothing: a check that chose to warn has already
662            // said what it wanted to, and a roll-up would only repeat it.
663            Outcome::Warned => {}
664            Outcome::Fixed => report.fixed.push(check.name()),
665            Outcome::Unavailable => report.unavailable.push(check.name()),
666            // Judged nothing, said nothing: not a pass to count, not a gap
667            // to announce.
668            Outcome::Inert => {}
669            Outcome::Failed => match severities.of(*check) {
670                Severity::Block => report.blocked.push(check.name()),
671                Severity::Warn => report.downgraded.push(check.name()),
672            },
673        }
674    }
675    report
676}
677
678/// Says what happened. Prints; decides nothing.
679fn announce(settings: &crate::config::Settings, report: &Report) {
680    if crate::live::quiet(settings) && report.passed > 0 {
681        println!("{} {} check(s) passed", valid_sign(), report.passed);
682    }
683    if !report.fixed.is_empty() {
684        // Louder than a pass, because files on disk are not what the author
685        // left them: they asked for the repair, but they did not watch it.
686        println!(
687            "{} {} check(s) fixed and re-staged: {}",
688            valid_sign(),
689            report.fixed.len(),
690            report.fixed.join(", ")
691        );
692    }
693    if !report.unavailable.is_empty() {
694        // Distinct from "passed". Silence here is how a repo looks verified
695        // when nothing actually ran — the trailing count is the one line
696        // guaranteed to be read, whatever the twenty blocks above said.
697        println!(
698            "{} {} check(s) could not run: {}",
699            warning_sign(),
700            report.unavailable.len(),
701            report.unavailable.join(", ")
702        );
703    }
704    if !report.downgraded.is_empty() {
705        println!(
706            "{} {} check(s) reported a problem but are set to warn: {}",
707            warning_sign(),
708            report.downgraded.len(),
709            report.downgraded.join(", ")
710        );
711    }
712    if report.blocked.is_empty() {
713        return;
714    }
715    println!("\n🚨  Error raised by:");
716    for name in &report.blocked {
717        println!("    - {}", highlight(name));
718    }
719}
720
721/// Point every check at `git ls-files` instead of the index.
722///
723/// THE definition, called from both entry points. There used to be two: this
724/// one, and a copy in `main.rs` built from a RAW `ls-files` — no `-z` — whose
725/// output git QUOTES for any unusual byte, so `é.json` arrived as the nine-byte
726/// literal `"\303\251.json"` and was handed to prettier and eslint as a path
727/// that does not exist. And because `override_file_set` writes a `OnceLock`,
728/// main's quoted list WON: whichever ran first was the one that counted, and
729/// main's ran first. `git.rs` documents this exact failure.
730pub fn enter_all_files_mode() {
731    crate::hooks::common::override_file_set(
732        crate::git::stdout_paths(&["ls-files"]).unwrap_or_default(),
733    );
734}
735
736/// `amont run` — every applicable check, on demand.
737///
738/// Two questions, and the mode says which it answers:
739///
740/// - **staged** (default) is "would my commit pass" — the same set a commit
741///   would check, so it is a rehearsal of the hook, and it takes the same
742///   index-fidelity hold the hook takes.
743/// - **`--all-files`** is "does my working tree pass". Deliberately NOT the same
744///   question: on a dirty tree it reports on content that is not committed and
745///   may never be. That is right for adopting a check into an existing
746///   repository, where `git add .` is not an acceptable way to measure the mess,
747///   and it is why `--all-files` takes no stash — there is no staged/unstaged
748///   distinction to protect when the answer is "all of it".
749pub fn run_all(ctx: &Ctx, all_files: bool) -> Verdict {
750    let settings = ctx.settings;
751    // ORDER: the override goes in FIRST. It is what tells `fixing_enabled` and
752    // `restage` that the file set is not the index, and both are consulted
753    // from inside the checks below.
754    if all_files {
755        enter_all_files_mode();
756        if crate::hooks::common::fixing_requested(settings) {
757            println!(
758                "{} {} is set, but fixing is off for {}: the input set is the \
759                 working tree, not the index",
760                warning_sign(),
761                highlight("amont.fix"),
762                highlight("--all-files")
763            );
764        }
765        // Stash-free, per decision 1 of docs/index-fidelity-and-run-modes.md:
766        // there is no staged/unstaged distinction to protect when the input
767        // set is `git ls-files`, so a hold would be surprising extra mutation
768        // with no correctness upside.
769        return run_stage(
770            &selected(ctx.settings, Stage::PreCommit, ctx.manifest),
771            ctx,
772            &Overrides::read(settings),
773        );
774    }
775
776    // Staged mode IS a rehearsal of the commit, so it takes the same hold the
777    // commit does. Without it, `amont run` failed on garbage in the tree
778    // that `git commit` — which holds the unstaged half aside — passed, and
779    // vice versa: the two modes disagreed about the same repository, which is
780    // exactly what this mode exists not to do.
781    let held = match hold_unstaged() {
782        Ok(guard) => guard,
783        Err(verdict) => return verdict,
784    };
785    let verdict = run_stage(
786        &selected(ctx.settings, Stage::PreCommit, ctx.manifest),
787        ctx,
788        &Overrides::read(settings),
789    );
790    // AFTER the report has been printed: dropping earlier would put the
791    // unstaged content back under a check that is still reading files.
792    drop(held);
793    verdict
794}
795
796/// `amont run <check>` — one check by name. `None` when there is no such
797/// check, which the caller turns into a usage error.
798///
799/// Lives here rather than in `main.rs` so `registry::lookup` stays inside the
800/// runtime, and so the hold decision is made once: a named check takes the
801/// index-fidelity hold only when it is a `Stage::PreCommit` check running in
802/// staged mode. A pre-push or commit-msg check invoked by name must never
803/// touch the working tree — nothing about a push is a staging operation.
804/// Resolve what `amont run <name>` means, exactly as `hook.skip` resolves a
805/// name — the rest of the tool taught `ban-terms`; making `run` demand the
806/// full id was a pointless second vocabulary. Ambiguity is an answer, not a
807/// guess: the two `branch-pattern` checks are different code at different
808/// stages.
809///
810/// Public because the CALLER needs the answer before anything else happens:
811/// main decides whether to synthesize push refs from the resolved name, and
812/// an ambiguous name must say so rather than fail on a missing upstream it
813/// was never going to use.
814pub fn resolve_check_name(name: &str, manifest: &crate::manifest::Manifest) -> Named2 {
815    if crate::registry::lookup(name, manifest).is_some() {
816        return Named2::Resolved(name.to_string());
817    }
818    let mut matches: Vec<String> = crate::registry::CHECKS
819        .iter()
820        .map(|c| c.name.to_string())
821        .chain(manifest.externals.iter().map(|e| e.id.clone()))
822        .filter(|id| crate::skip_suppresses(id, name))
823        .collect();
824    matches.dedup();
825    match matches.len() {
826        0 => Named2::Unknown,
827        1 => Named2::Resolved(matches.remove(0)),
828        _ => Named2::Ambiguous(matches),
829    }
830}
831
832/// How a run name resolved.
833pub enum Named2 {
834    Resolved(String),
835    Unknown,
836    Ambiguous(Vec<String>),
837}
838
839pub fn run_named(ctx: &Ctx, name: &str, all_files: bool) -> Named {
840    let full: String = match resolve_check_name(name, ctx.manifest) {
841        Named2::Resolved(id) => id,
842        Named2::Unknown => return Named::Unknown,
843        Named2::Ambiguous(ids) => return Named::Ambiguous(ids),
844    };
845    let name = full.as_str();
846    let Some(run_check) = crate::registry::lookup(name, ctx.manifest) else {
847        return Named::Unknown;
848    };
849    // The Ctx must carry the RESOLVED id: lookup's closure re-resolves
850    // through `ctx.name`, and handing it the short name back would panic on
851    // the very ambiguity this function just settled.
852    let ctx = &Ctx {
853        name,
854        args: ctx.args,
855        hooks_dir: ctx.hooks_dir,
856        push: ctx.push,
857        manifest: ctx.manifest,
858        settings: ctx.settings,
859    };
860    // Under quiet, a check run by name is captured like one inside its stage
861    // — one slot, its tool's output to a log, one line on a pass — whatever
862    // `amont.progress` says: the person who asked for quiet asked for this.
863    let check = crate::registry::one_named(name, ctx.manifest);
864    let run = |ctx: &Ctx| match check.filter(|_| crate::live::quiet(ctx.settings)) {
865        Some(check) => run_quiet(ctx, check),
866        None => run_check(ctx),
867    };
868    if all_files {
869        enter_all_files_mode();
870        return Named::Ran(run(ctx));
871    }
872    let is_pre_commit_check = check.is_some_and(|c| c.stage() == Stage::PreCommit);
873    if !is_pre_commit_check {
874        return Named::Ran(run(ctx));
875    }
876    let held = match hold_unstaged() {
877        Ok(guard) => guard,
878        Err(verdict) => return Named::Ran(verdict),
879    };
880    let verdict = run(ctx);
881    drop(held);
882    Named::Ran(verdict)
883}
884
885/// One check by name, in a one-slot stage. The verdict is the one
886/// `registry::lookup` gives a check: blocking only a failed `block` check.
887fn run_quiet(ctx: &Ctx, check: &dyn Check) -> Verdict {
888    let stage = crate::live::Stage::begin(ctx.settings, &[check.name()]);
889    stage.named();
890    let _sink = stage.enter(0);
891    let flush = crate::live::FinishOnDrop::new(ctx.settings, &stage, 0);
892    let outcome = {
893        let _slot = crate::host_slots::enter_check(check.name());
894        check.run(ctx)
895    };
896    flush.outcome(outcome);
897    // The block prints here, before anything the caller says after it.
898    drop(flush);
899    // The same rule `registry::lookup` applies, with every variant named.
900    let blocks = match outcome {
901        Outcome::Failed => match crate::registry::severity_of(ctx.settings, check) {
902            Severity::Block => true,
903            Severity::Warn => false,
904        },
905        Outcome::Passed
906        | Outcome::Warned
907        | Outcome::Fixed
908        | Outcome::Unavailable
909        | Outcome::Inert => false,
910    };
911    Verdict::blocking(blocks)
912}
913
914/// What `run_named` resolved a name to.
915pub enum Named {
916    Ran(Verdict),
917    /// Nothing matches — full id, short name, or entrypoint.
918    Unknown,
919    /// A short name that reaches more than one check; the caller lists them
920    /// so the user can pick a full id.
921    Ambiguous(Vec<String>),
922}
923
924pub fn pre_push(ctx: &Ctx) -> Verdict {
925    let settings = ctx.settings;
926    // `amont.treeLintWait` counts from HERE, so no earlier wait (a test
927    // rehearsal's `amont.rehearsalWait`) can extend it.
928    let push_started = std::time::Instant::now();
929    // The notes push `attest` makes re-enters this hook; its ref list is only
930    // ever the attest ref, so there is nothing to prove — and proving it
931    // would recurse.
932    if crate::attest::push_guard_active() {
933        return Verdict::Proceed;
934    }
935    crate::manifest::verify_tool_pins(&ctx.manifest.pins);
936    announce_policy_state(ctx.settings, ctx.manifest);
937    // NB: no CHERRY_PICK_HEAD check here — the zsh pre-push had none either.
938    let severities = Overrides::read(settings);
939    // pre-push had NO state guard at all, with a comment admitting it existed
940    // only because the zsh version had none. Now it asks the same question
941    // pre-commit does and each check answers for itself.
942    let in_progress = crate::git_states_in_progress();
943    // Declared order unless this repository asked for the other one. The
944    // permutation is decided BEFORE the live stage is begun, because the
945    // stage draws the list in the order it is given.
946    let pre_push_checks = ordered_by_evidence(
947        settings,
948        selected_during(ctx.settings, Stage::PrePush, &in_progress, ctx.manifest),
949    );
950    let stage = crate::live::enabled(settings).then(|| {
951        let names: Vec<&str> = pre_push_checks.iter().map(|c| c.name()).collect();
952        crate::live::Stage::begin(settings, &names)
953    });
954    // What actually PASSED, for the attestation at the bottom. `Warned` and
955    // `Unavailable` stay out — "could not run" is not "passed" — and a
956    // commit-time-gated pair counts, because its stamps say the check ran on
957    // every pushed tree.
958    let mut passed: Vec<String> = Vec::new();
959    // Accumulated rather than written per check: one append at the end costs a
960    // single file open, and the loop below can leave early.
961    let mut downgraded: Vec<(String, crate::downgrade::Origin)> = Vec::new();
962    // PUSH STAMPS. The tips being pushed, and what earlier runs of THIS gate
963    // recorded against their trees — an `amont run pre-push` rehearsal, or a
964    // push whose gate passed and whose transport then died. A scoped gate
965    // (a test suite: its verdict is a function of the tree alone) whose
966    // stamp sits on every tip is not run again; the unscoped ones
967    // (branch-protect, secrets — questions about the push, not the content)
968    // always run. `amont.pushStamps false` turns the reuse off.
969    //
970    // Reading `ctx.push` here may consume stdin, exactly as `attest` does
971    // below; every pre-push check reads it anyway.
972    let tips: Vec<String> = {
973        let mut t: Vec<String> = ctx
974            .push
975            .get()
976            .iter()
977            .filter(|r| !r.local_oid.chars().all(|c| c == '0'))
978            .map(|r| r.local_oid.clone())
979            .collect();
980        t.dedup();
981        t
982    };
983    let reuse_stamps = crate::gate_stamp::push_stamps_enabled(settings);
984    // The working tree AS THE SUITE WILL BE HANDED IT, captured before any
985    // gate has run.
986    //
987    // Timing is the point. Asking afterwards means a gate that modifies a
988    // tracked file — a formatter, a suite that updates a snapshot fixture —
989    // disqualifies its OWN stamp. What a stamp needs to know is what the
990    // suite could READ, which is the state it was handed; a change the suite
991    // itself made was never an input to it.
992    //
993    // Tracked modifications only. `stamp_tips` argues that gap, which is a
994    // deliberate one.
995    let tree_at_start = if reuse_stamps {
996        crate::git::stdout(&["status", "--porcelain", "--untracked-files=no"]).unwrap_or_default()
997    } else {
998        String::new()
999    };
1000    // A background rehearsal of one of these tips may be mid-suite right
1001    // now. Waiting for it is strictly less work than starting over, and
1002    // its stamp — read AFTER the wait, below — is the hand-off.
1003    // Only when this push has test gates for the rehearsal to vouch for: a
1004    // rehearsal that is only proving tree lint is waited for by the tree
1005    // verdict alone, under its own `amont.treeLintWait` (ADR-0024), never
1006    // under `amont.rehearsalWait`.
1007    if reuse_stamps && !tips.is_empty() {
1008        let changed = crate::pushrefs::changed_files(ctx.push.get());
1009        if !scoped_push_gates(settings, ctx.manifest, &changed).is_empty() {
1010            crate::rehearsal::await_for(settings, &tips);
1011        }
1012    }
1013    let push_stamps = if reuse_stamps && !tips.is_empty() {
1014        crate::gate_stamp::stamps_for(&tips)
1015    } else {
1016        Default::default()
1017    };
1018    // Inside a rehearsal snapshot only the content gates make sense: the
1019    // unscoped checks ask about a PUSH — its branch name, its target, its
1020    // secrets — and no push is happening. Said once, not per check.
1021    let rehearsing = crate::rehearsal::in_snapshot();
1022    if rehearsing {
1023        crate::say!(
1024            "rehearsal: running the test gates only — the push-shaped checks run at push time"
1025        );
1026    }
1027    let stamped_on_every_tip = |name: &str| -> bool {
1028        !tips.is_empty()
1029            && tips.iter().all(|t| {
1030                push_stamps
1031                    .get(t)
1032                    .is_some_and(|s| s.iter().any(|g| g == name))
1033            })
1034    };
1035    // What actually RAN and passed here (as opposed to being vouched for by
1036    // a stamp) — the set this run may stamp in turn.
1037    let mut ran_and_passed: Vec<String> = Vec::new();
1038    // What each gate COST and how it ENDED, for the record `gate_evidence`
1039    // reads. Every outcome, failures included: the stamp deliberately has no
1040    // opinion about a gate that failed, and a dataset that kept only the
1041    // passes could never say a gate is flaky.
1042    let mut evidence: Vec<crate::gate_stamp::Run> = Vec::new();
1043    // The question `amont list` answers with "inert here — needs go.sum":
1044    // does this repository carry the marker that turns the check on? Asked
1045    // of the index, once, and answered the same way here — a check the
1046    // registry calls inert used to run anyway, find its tool or lockfile
1047    // missing, and warn that it "could not run" on every push of a
1048    // repository it was never meant to touch.
1049    let tracked = crate::tracked_paths();
1050    for (idx, check) in pre_push_checks.iter().enumerate() {
1051        if !check.scope().opted_in(&tracked) {
1052            continue;
1053        }
1054        let _sink = stage.as_ref().map(|s| s.enter(idx));
1055        let flush = stage
1056            .as_ref()
1057            .map(|s| crate::live::FinishOnDrop::new(ctx.settings, s, idx));
1058        // A declared pre-push external whose NAME is also declared at
1059        // pre-commit (blocking) is a gate pair: the commit-time side earned
1060        // per-commit stamps, and this side runs only for pushes carrying
1061        // commits with no record of it — the same contract the npm gate has
1062        // always had, for vocabularies npm never heard of (`cargo test`,
1063        // `pytest`, anything). Messages mirror the npm gate's exactly;
1064        // docs/checks.md quotes them.
1065        // The push side of a pair is `(name, scope)`, from whichever of the
1066        // two kinds of check this is. A declared pre-push external supplies
1067        // its own; anything else is a BUILT-IN, and `pairing_name` plus the
1068        // registry's scope say the same two things about it.
1069        //
1070        // The external is tried FIRST and its inputs are unchanged, so the
1071        // declared path behaves exactly as before — that is what the
1072        // untouched declared-pair tests prove.
1073        if rehearsing && check.scope().is_unscoped() {
1074            continue;
1075        }
1076        if !check.scope().is_unscoped() && stamped_on_every_tip(check.name()) {
1077            crate::say!(
1078                "{} {} passed on this exact tree earlier — not repeating it here",
1079                valid_sign(),
1080                highlight(check.name()),
1081            );
1082            passed.push(check.name().to_string());
1083            continue;
1084        }
1085        let declared = ctx
1086            .manifest
1087            .externals
1088            .iter()
1089            .find(|e| e.stage == Stage::PrePush && e.id == check.name());
1090        let builtin_scope = check.scope();
1091        let pairing: Option<(&str, &crate::check::Scope)> = match declared {
1092            Some(ext) => match &ext.kind {
1093                crate::manifest::Kind::Runnable { scope, .. } => {
1094                    Some((ext.short_name.as_str(), scope))
1095                }
1096                // A declared pre-push entry that runs nothing has no scope to
1097                // judge with, and is not a built-in either. Nothing to pair.
1098                _ => None,
1099            },
1100            None => Some((check.pairing_name(), &builtin_scope)),
1101        };
1102        if let Some((name, push_scope)) = pairing {
1103            match crate::hooks::run_tests::pair_verdict(
1104                ctx.settings,
1105                name,
1106                push_scope,
1107                ctx.manifest,
1108                ctx.push,
1109            ) {
1110                crate::hooks::run_tests::PairVerdict::Gated => {
1111                    crate::say!(
1112                        "{} {} gated at commit instead — not repeating it here",
1113                        valid_sign(),
1114                        highlight(name),
1115                    );
1116                    passed.push(check.name().to_string());
1117                    continue;
1118                }
1119                crate::hooks::run_tests::PairVerdict::Unstamped(n) => {
1120                    // `amont.unstampedPush refuse`: never run a suite with
1121                    // the remote's connection held open — say how to earn
1122                    // the stamp instead. Not inside a rehearsal snapshot:
1123                    // running there is how the stamp is earned.
1124                    if !rehearsing && crate::rehearsal::refuse_unstamped(ctx.settings) {
1125                        crate::say!(
1126                            "{} {} is declared at commit time, but {n} pushed \
1127                             commit{} carr{} no record of it (rewritten by a \
1128                             rebase or amend, or made without the hooks) — \
1129                             refusing the push",
1130                            warning_sign(),
1131                            name,
1132                            if n == 1 { "" } else { "s" },
1133                            if n == 1 { "ies" } else { "y" },
1134                        );
1135                        crate::say!(
1136                            "    Test this tree first, with no push waiting on it: {} — then push again",
1137                            highlight("amont rehearse --wait"),
1138                        );
1139                        crate::downgrade::note(settings, &downgraded);
1140                        record_evidence(&tips, &evidence);
1141                        println!("\n🚨  Error raised by hook {}", highlight(check.name()));
1142                        return Verdict::Block;
1143                    }
1144                    crate::say!(
1145                        "{} {} is declared at commit time, but {n} pushed \
1146                         commit{} carr{} no record of it — running it here",
1147                        warning_sign(),
1148                        name,
1149                        if n == 1 { "" } else { "s" },
1150                        if n == 1 { "ies" } else { "y" },
1151                    );
1152                }
1153                crate::hooks::run_tests::PairVerdict::NotPaired => {}
1154            }
1155        }
1156        let sub = Ctx {
1157            name: check.name(),
1158            args: ctx.args,
1159            hooks_dir: ctx.hooks_dir,
1160            push: ctx.push,
1161            manifest: ctx.manifest,
1162            settings: ctx.settings,
1163        };
1164        // Wall clock around the check itself, not around the hook: the number
1165        // that catches a suite which stopped finding tests is how long THAT
1166        // gate took, and everything outside this call is bookkeeping.
1167        let started = std::time::Instant::now();
1168        let slot = crate::host_slots::enter_check(check.name());
1169        let outcome = check.run(&sub);
1170        if let Some(f) = &flush {
1171            f.outcome(outcome);
1172        }
1173        let ran = started.elapsed().saturating_sub(slot.queued());
1174        drop(slot);
1175        evidence.push(crate::gate_stamp::Run {
1176            at: crate::gate_evidence::now(),
1177            gate: check.name().to_string(),
1178            outcome: run_outcome(outcome),
1179            ms: ran.as_millis().min(u128::from(u64::MAX)) as u64,
1180        });
1181        match outcome {
1182            Outcome::Passed => {
1183                passed.push(check.name().to_string());
1184                if !check.scope().is_unscoped() {
1185                    ran_and_passed.push(check.name().to_string());
1186                }
1187            }
1188            // Announced, never fatal: a check that could not run has not
1189            // invalidated anything, and neither has a warning.
1190            Outcome::Unavailable => {
1191                println!(
1192                    "{} {} could not run",
1193                    warning_sign(),
1194                    highlight(check.name())
1195                )
1196            }
1197            Outcome::Warned => {}
1198            // Nothing to judge: not passed (nothing to stamp or attest), not
1199            // a gap (nothing is missing). The dispatcher asked the registry's
1200            // opt-in question above; this is a check answering it for itself.
1201            Outcome::Inert => {}
1202            // Cannot occur: `Fix::Rewrite` is refused on a pre-push
1203            // declaration, so nothing here can repair anything.
1204            Outcome::Fixed => {}
1205            Outcome::Failed => match severities.of(*check) {
1206                Severity::Warn => {
1207                    downgraded.push((
1208                        check.name().to_string(),
1209                        downgrade_origin(*check, &severities),
1210                    ));
1211                    println!(
1212                        "{} {} reported a problem (severity warn)",
1213                        warning_sign(),
1214                        highlight(check.name())
1215                    );
1216                }
1217                // Fail-fast applies ONLY to Block: the later steps are
1218                // expensive and their preconditions are gone.
1219                Severity::Block => {
1220                    // Record what already warned before leaving. Those checks
1221                    // ran and reported; a later check blocking does not unmake
1222                    // them, and dropping them here would make the ledger quietly
1223                    // under-count every push that ended badly.
1224                    crate::downgrade::note(settings, &downgraded);
1225                    // The same argument for the evidence, and it matters more
1226                    // here: this is the path a FAILURE leaves by, and a record
1227                    // written only on the way out of a successful push would
1228                    // be a dataset of passes calling itself a dataset of runs.
1229                    record_evidence(&tips, &evidence);
1230                    println!("\n🚨  Error raised by hook {}", highlight(check.name()));
1231                    return Verdict::Block;
1232                }
1233            },
1234        }
1235    }
1236    // Every block gate passed — stamp the tips with the scoped gates that
1237    // RAN here, so the next push of this content (a retry after a dropped
1238    // connection, or the real push after an `amont run pre-push` rehearsal)
1239    // skips them. Only when what ran is what is being pushed: with
1240    // `amont.testPushedTree` the suite ran on the tip itself — unless the
1241    // snapshot could not be made, which `stamp_tips` asks about rather than
1242    // assuming; otherwise it ran on the working tree, which vouches for the
1243    // tip only when the two are the same content — HEAD, with nothing
1244    // modified and nothing untracked.
1245    // The same proxy `attestable` uses, for the same reason: a scoped gate
1246    // whose files the push never touched returns `Passed` having run
1247    // nothing, and a stamp for THAT would let a later push that does touch
1248    // them skip a suite nobody ran.
1249    if reuse_stamps && !ran_and_passed.is_empty() {
1250        let changed = crate::pushrefs::changed_files(ctx.push.get());
1251        let really_ran: Vec<String> = pre_push_checks
1252            .iter()
1253            .filter(|c| ran_and_passed.iter().any(|p| p == c.name()))
1254            .filter(|c| c.scope().touches(&changed))
1255            .map(|c| c.name().to_string())
1256            .collect();
1257        stamp_tips(&tips, &really_ran, &tree_at_start);
1258    }
1259    // …and say so to CI, if this repository opted in.
1260    // Gated behind `enabled()` HERE, not just inside `attest_push`: reading
1261    // `ctx.push` may consume stdin, and a disabled repo should leave stdin
1262    // exactly as it found it.
1263    // Tree gates (ADR-0024) are not push checks: nothing runs here. Each is
1264    // attested only when the TREE of every pushed tip carries its commit-time
1265    // proof; a whole-tree proof needs no changed-files scope. Fail-closed:
1266    // anything else is simply not attested, and CI lints.
1267    let (tree_proven, tree_unproven) =
1268        if !ctx.manifest.tree.is_empty() && crate::attest::enabled(settings) {
1269            crate::tree_lint::await_verdict(settings, &ctx.manifest.tree, &tips, push_started)
1270        } else {
1271            (Vec::new(), Vec::new())
1272        };
1273    if !tree_unproven.is_empty() {
1274        crate::hooks::common::say(&crate::tree_lint::fit_line(
1275            "  lint not attested (cold or changed tree): ",
1276            &tree_unproven.join(" "),
1277            " — CI will lint",
1278        ));
1279    }
1280    if (!passed.is_empty() || !tree_proven.is_empty()) && crate::attest::enabled(settings) {
1281        let remote = ctx
1282            .args
1283            .first()
1284            .map(|a| a.to_string_lossy().into_owned())
1285            .unwrap_or_default();
1286        let changed = crate::pushrefs::changed_files(ctx.push.get());
1287        let mut vouched = attestable(&pre_push_checks, &passed, &changed);
1288        vouched.extend(tree_proven);
1289        crate::attest::attest_push(ctx.settings, &remote, ctx.push.get(), &vouched);
1290    }
1291    crate::downgrade::note(settings, &downgraded);
1292    record_evidence(&tips, &evidence);
1293    Verdict::Proceed
1294}
1295
1296/// What one check's outcome is called in the record.
1297fn run_outcome(outcome: Outcome) -> crate::gate_stamp::RunOutcome {
1298    use crate::gate_stamp::RunOutcome as R;
1299    match outcome {
1300        Outcome::Passed => R::Passed,
1301        Outcome::Failed => R::Failed,
1302        Outcome::Warned => R::Warned,
1303        Outcome::Fixed => R::Fixed,
1304        Outcome::Unavailable => R::Unavailable,
1305        Outcome::Inert => R::Inert,
1306    }
1307}
1308
1309/// File this push's runs under the content they judged.
1310///
1311/// ONE key, not one per tip: a push of two branches ran each gate once, and
1312/// writing the same run onto both trees would make a report count it twice.
1313/// The first tip's tree is that key, falling back to `HEAD` — and to nothing
1314/// at all when git will not name either, which costs a row in a report and
1315/// never a verdict.
1316fn record_evidence(tips: &[String], runs: &[crate::gate_stamp::Run]) {
1317    if runs.is_empty() {
1318        return;
1319    }
1320    let tree = tips
1321        .first()
1322        .and_then(|tip| crate::git::stdout(&["rev-parse", &format!("{tip}^{{tree}}")]))
1323        .or_else(|| crate::git::stdout(&["rev-parse", "HEAD^{tree}"]));
1324    if let Some(tree) = tree {
1325        crate::gate_stamp::record_runs(&tree, runs);
1326    }
1327}
1328
1329/// The scoped pre-push gates — test suites — that have work to do for a
1330/// push that changed `changed`: what a rehearsal would run, and therefore
1331/// what its stamp would have to name before a push may skip anything.
1332///
1333/// The same two filters `pre_push` applies before stamping (selected here,
1334/// and `scope().touches` the change), so the rehearsal's idea of "nothing to
1335/// do" is the push's idea of "nothing to stamp".
1336pub fn scoped_push_gates(
1337    settings: &crate::config::Settings,
1338    manifest: &crate::manifest::Manifest,
1339    changed: &[String],
1340) -> Vec<String> {
1341    let in_progress = crate::git_states_in_progress();
1342    let tracked = crate::tracked_paths();
1343    selected_during(settings, Stage::PrePush, &in_progress, manifest)
1344        .into_iter()
1345        .filter(|c| !c.scope().is_unscoped() && c.scope().touches(changed))
1346        // The same opt-in gate `pre_push` applies: a suite the repository
1347        // never turned on is not work a rehearsal owes a stamp for.
1348        .filter(|c| c.scope().opted_in(&tracked))
1349        .map(|c| c.name().to_string())
1350        .collect()
1351}
1352
1353/// Push-stamp every tip whose content is what the gates actually tested.
1354///
1355/// See the comment at the call site for the two cases. Silent when nothing
1356/// qualifies — a dirty working tree is the ordinary state of a machine
1357/// mid-work, and a note on every push would teach people to ignore it.
1358fn stamp_tips(tips: &[String], gates: &[String], tree_at_start: &str) {
1359    let head = crate::git::stdout(&["rev-parse", "HEAD"]);
1360    // TRACKED modifications only — a KNOWN gap, kept deliberately, and
1361    // spelled out because the comment that used to sit here argued it
1362    // backwards ("untracked files are not in any tree the suite could have
1363    // been asked about"). That is not the reason. The danger is not that the
1364    // tree lacks them, it is that the RUN had them: a new test file, a
1365    // fixture, a local `.env`, present while the suite ran and absent from
1366    // the tree the stamp vouches for.
1367    //
1368    // Counting them was tried, and is worse than the gap. Gates leave
1369    // artefacts — a log, a coverage directory, whatever a declared
1370    // `amont.conf` command writes — and nothing cleans them up, so a
1371    // repository using declared gates would stop earning stamps permanently
1372    // after its first commit. That does not merely lose an optimisation: it
1373    // puts the suite back INSIDE the push, which is the failure the whole
1374    // stamping mechanism exists to prevent. amont's own
1375    // `a_push_stamp_merges_with_a_commit_time_stamp` fixture is exactly that
1376    // shape, and CI is where it surfaced — a `*.log` line in one developer's
1377    // global gitignore had hidden it on the machine that wrote the change.
1378    //
1379    // `amont.testPushedTree true` closes the gap properly for anyone who
1380    // wants it closed: it runs the suite in a checkout of the commit, where
1381    // no untracked file exists to be read.
1382    //
1383    // `tree_at_start` — not a fresh `git status`. The gates have run by now
1384    // and may have written into the tree; what a stamp needs to know is what
1385    // the suite could READ, which is the state it was handed. See the
1386    // capture in `pre_push`.
1387    let worktree_clean = tree_at_start.trim().is_empty();
1388    let in_snapshot = crate::rehearsal::in_snapshot();
1389    let mut stamped: Vec<String> = Vec::new();
1390    for tip in tips {
1391        // Inside a rehearsal the working tree IS a checkout git made of this
1392        // commit, so it is the tip's content by construction — and whatever
1393        // `amont.snapshotPrepare` had to add to make it runnable (a
1394        // `node_modules`, a virtualenv) is not a reason to distrust it. That
1395        // is the same argument a pushed-tree snapshot makes.
1396        let is_head = head.as_deref() == Some(tip.as_str());
1397        let tree_is_tip = is_head && (in_snapshot || worktree_clean);
1398        // PER GATE, from what each one actually did — not from the config.
1399        // `amont.testPushedTree` is a request; `pushed_tree::ran_on_tip` is
1400        // the record of which gates were handed a checkout of this tip. A
1401        // gate that ran somewhere else (a snapshot that could not be made,
1402        // or a check that ran in the working tree) may vouch for the tip
1403        // only when the working tree WAS the tip. Deciding from the flag
1404        // stamped every passing gate onto a tip that one of them had never
1405        // seen.
1406        let vouched: Vec<String> = gates
1407            .iter()
1408            .filter(|g| tree_is_tip || crate::pushed_tree::ran_on_tip(g, tip))
1409            .cloned()
1410            .collect();
1411        if vouched.is_empty() {
1412            continue;
1413        }
1414        let spec = format!("{tip}^{{tree}}");
1415        let Some(tree) = crate::git::stdout(&["rev-parse", &spec]) else {
1416            continue;
1417        };
1418        if crate::gate_stamp::stamp_push(tip, &tree, &vouched) {
1419            for g in vouched {
1420                if !stamped.contains(&g) {
1421                    stamped.push(g);
1422                }
1423            }
1424        }
1425    }
1426    if !stamped.is_empty() {
1427        crate::say!(
1428            "{} stamped {} for this tree — the next push of it skips them ({})",
1429            valid_sign(),
1430            highlight(&stamped.join(" ")),
1431            crate::gate_stamp::NOTES_REF,
1432        );
1433    }
1434}
1435
1436/// Of the checks that passed, the ones an attestation may actually VOUCH for.
1437///
1438/// A language gate whose scope the push never touched returns `Passed` having
1439/// run nothing — `cargo_test` walks its refs, finds no crate root, and falls
1440/// out of the loop green. That is right for a push gate (there was nothing to
1441/// object to) and wrong for an attestation: a JS-only push was minting
1442/// `gates … pre-push-cargo-test pre-push-go-test pre-push-pytest`, and in a
1443/// MIXED repository CI would then skip a suite that nobody ran on that tree.
1444///
1445/// The declared `scope` is the honest filter, and the same data `amont list`
1446/// already reports. Unscoped checks (`Scope::ALWAYS` — branch-protect,
1447/// secrets) match everything and are vouched for, which is accurate: they
1448/// really did run. An empty `changed` vouches for nothing scoped, which is
1449/// the safe direction — CI runs the suite.
1450///
1451/// [`Scope::touches`], NOT `Scope::matches`. `matches` also asks whether the
1452/// repository has opted in — whether a `Cargo.toml` exists — and asking that
1453/// of a push DIFF can only answer no unless the push happened to touch the
1454/// marker. So `pre-push-cargo-test` was vouched for by a push that edited
1455/// `Cargo.toml` and never by one that edited only `.rs` files, which is the
1456/// ordinary case and the one worth skipping CI for. The feature attested
1457/// almost nothing, silently, and looked like it worked.
1458///
1459/// WHAT THIS IS STILL A PROXY FOR, stated plainly because the trust path
1460/// deserves it: the honest question is "did this gate actually run", and no
1461/// gate reports that. `rust_tools::test` returns `Passed` whether it ran a
1462/// suite or found no crate root and fell out of its loop — the very thing
1463/// the first paragraph describes. Scope is the closest available stand-in.
1464/// It is now a good one: a gate is vouched for only if the push changed a
1465/// file its extensions cover.
1466///
1467/// The gap that remains is narrow and one-directional: a `.rs` file outside
1468/// every crate would be covered here while `cargo test` had nothing to say
1469/// about it. Closing it properly means an outcome that distinguishes "ran
1470/// and passed" from "found nothing to do", which is a change to every gate
1471/// and to `Outcome` itself — worth doing, not worth smuggling into this.
1472fn attestable(checks: &[&dyn Check], passed: &[String], changed: &[String]) -> Vec<String> {
1473    checks
1474        .iter()
1475        .filter(|c| passed.iter().any(|p| p == c.name()))
1476        .filter(|c| c.scope().touches(changed))
1477        .map(|c| c.name().to_string())
1478        .collect()
1479}
1480
1481#[cfg(test)]
1482mod tests {
1483    use super::*;
1484    use crate::check::{Builtin, Scope};
1485    use std::sync::atomic::{AtomicUsize, Ordering};
1486
1487    /// A check whose only job is to carry a name and a severity into `report`.
1488    /// Its `run` is never called — `report` is fed outcomes directly, which is
1489    /// what makes `Unavailable` testable at all: the real thing needs a missing
1490    /// binary, and a test that uninstalls the developer's toolchain is worse
1491    /// than no test.
1492    const fn stub(name: &'static str, severity: Severity) -> Builtin {
1493        Builtin {
1494            name,
1495            stage: Stage::PreCommit,
1496            scope: Scope::ALWAYS,
1497            severity,
1498            run: |_| Outcome::Passed,
1499            fix: crate::check::Fix::None,
1500            reach: crate::check::Reach::Convention,
1501        }
1502    }
1503
1504    /// A pre-push gate with an OPT-IN file, as the real Rust and Python
1505    /// gates have: `.rs` files, but only where a `Cargo.toml` exists.
1506    const fn opt_in(
1507        name: &'static str,
1508        exts: &'static [&'static str],
1509        names: &'static [&'static str],
1510    ) -> Builtin {
1511        Builtin {
1512            name,
1513            stage: Stage::PrePush,
1514            scope: Scope::new(exts, names),
1515            severity: Severity::Block,
1516            run: |_| Outcome::Passed,
1517            fix: crate::check::Fix::None,
1518            reach: crate::check::Reach::Convention,
1519        }
1520    }
1521
1522    /// A pre-push gate scoped to one language's files.
1523    const fn scoped(name: &'static str, exts: &'static [&'static str]) -> Builtin {
1524        Builtin {
1525            name,
1526            stage: Stage::PrePush,
1527            scope: Scope::new(exts, &[]),
1528            severity: Severity::Block,
1529            run: |_| Outcome::Passed,
1530            fix: crate::check::Fix::None,
1531            reach: crate::check::Reach::Convention,
1532        }
1533    }
1534
1535    /// The over-claim this filter exists to stop, caught in the wild: a
1536    /// JS-only push minted `gates … pre-push-cargo-test pre-push-go-test
1537    /// pre-push-pytest`, because each of those gates finds nothing of its
1538    /// language to do and returns `Passed` having run NOTHING. Harmless in a
1539    /// single-language repo, unsound in a mixed one — CI would skip a suite
1540    /// nobody ran on that tree.
1541    #[test]
1542    fn a_gate_whose_language_the_push_never_touched_is_not_vouched_for() {
1543        let js = scoped("pre-push-run-tests-js", &[".ts", ".js"]);
1544        let rust = scoped("pre-push-cargo-test", &[".rs"]);
1545        let py = scoped("pre-push-pytest", &[".py"]);
1546        let always = stub("pre-push-secrets", Severity::Block);
1547        let checks: Vec<&dyn Check> = vec![&js, &rust, &py, &always];
1548        let passed: Vec<String> = checks.iter().map(|c| c.name().to_string()).collect();
1549
1550        let changed = vec!["app/routes/home.ts".to_string()];
1551        let vouched = attestable(&checks, &passed, &changed);
1552        assert_eq!(
1553            vouched,
1554            vec![
1555                "pre-push-run-tests-js".to_string(),
1556                "pre-push-secrets".to_string()
1557            ],
1558            "only the gate that had work, plus the unscoped one that always runs"
1559        );
1560
1561        // Nothing computed about the push vouches for nothing scoped — the
1562        // safe direction, since CI then runs the suite.
1563        assert_eq!(
1564            attestable(&checks, &passed, &[]),
1565            vec!["pre-push-secrets".to_string()]
1566        );
1567
1568        // A check that did NOT pass is never vouched for, whatever its scope.
1569        let only_rust_passed = vec!["pre-push-cargo-test".to_string()];
1570        assert!(attestable(&checks, &only_rust_passed, &changed).is_empty());
1571    }
1572
1573    /// No overrides configured. `report` takes them as a VALUE now, so its
1574    /// tests need no repository and no git at all.
1575    fn none() -> Overrides {
1576        Overrides::default()
1577    }
1578
1579    static BLOCKER: Builtin = stub("stub-blocker", Severity::Block);
1580    static WARNER: Builtin = stub("stub-warner", Severity::Warn);
1581
1582    /// The unit tests hold `&dyn Check` for the same reason the dispatcher
1583    /// does: `report` must not be able to tell a built-in from an external.
1584    const fn as_checks(cs: [&'static Builtin; 3]) -> [&'static dyn Check; 3] {
1585        [cs[0], cs[1], cs[2]]
1586    }
1587
1588    /// The classification itself, which used to be unreachable: while one
1589    /// function classified AND printed AND returned a code, a test could assert
1590    /// the code and nothing else.
1591    #[test]
1592    fn every_outcome_lands_in_the_right_bucket() {
1593        let checks: [&dyn Check; 5] = [&BLOCKER, &BLOCKER, &WARNER, &BLOCKER, &BLOCKER];
1594        let got = classify(
1595            &checks,
1596            &[
1597                Outcome::Passed,
1598                Outcome::Unavailable,
1599                Outcome::Failed,
1600                Outcome::Failed,
1601                Outcome::Inert,
1602            ],
1603            &none(),
1604        );
1605        assert_eq!(got.blocked, ["stub-blocker"], "{got:?}");
1606        assert_eq!(got.downgraded, ["stub-warner"], "{got:?}");
1607        assert_eq!(got.unavailable, ["stub-blocker"], "{got:?}");
1608        assert_eq!(got.passed, 1, "inert is not a pass: {got:?}");
1609    }
1610
1611    /// A clean stage concludes nothing at all — not an empty message, no
1612    /// message. Twenty checks that passed should print no roll-ups.
1613    ///
1614    /// `passed` is a tally, not a roll-up. It exists so that `amont.quiet` can
1615    /// say how many lines it swallowed, and it must never be the reason this
1616    /// report looks like it has something to announce.
1617    #[test]
1618    fn a_clean_stage_has_nothing_to_report() {
1619        let checks: [&dyn Check; 2] = [&BLOCKER, &WARNER];
1620        let got = classify(&checks, &[Outcome::Passed, Outcome::Warned], &none());
1621        assert!(
1622            got.fixed.is_empty()
1623                && got.blocked.is_empty()
1624                && got.downgraded.is_empty()
1625                && got.unavailable.is_empty(),
1626            "a clean stage found something to announce: {got:?}"
1627        );
1628        assert_eq!(got.passed, 1, "the pass was not tallied: {got:?}");
1629        assert_eq!(got.verdict(), Verdict::Proceed);
1630    }
1631
1632    #[test]
1633    fn a_blocking_failure_is_the_only_thing_that_fails_the_commit() {
1634        let b: &dyn Check = &BLOCKER;
1635        let w: &dyn Check = &WARNER;
1636        assert_eq!(
1637            classify(&[b], &[Outcome::Failed], &none()).verdict(),
1638            Verdict::Block
1639        );
1640        assert_eq!(
1641            classify(&[b], &[Outcome::Passed], &none()).verdict(),
1642            Verdict::Proceed
1643        );
1644        // Every non-blocking shape, one at a time, so a regression cannot hide
1645        // behind a passing sibling.
1646        assert_eq!(
1647            classify(&[b], &[Outcome::Warned], &none()).verdict(),
1648            Verdict::Proceed
1649        );
1650        assert_eq!(
1651            classify(&[b], &[Outcome::Unavailable], &none()).verdict(),
1652            Verdict::Proceed
1653        );
1654        assert_eq!(
1655            classify(&[w], &[Outcome::Failed], &none()).verdict(),
1656            Verdict::Proceed
1657        );
1658    }
1659
1660    #[test]
1661    fn one_blocking_failure_among_many_still_fails() {
1662        let checks = as_checks([&BLOCKER, &WARNER, &BLOCKER]);
1663        assert_eq!(
1664            classify(
1665                &checks,
1666                &[Outcome::Unavailable, Outcome::Failed, Outcome::Failed],
1667                &none()
1668            )
1669            .verdict(),
1670            Verdict::Block
1671        );
1672        // Same shape, with the only *blocking* failure removed.
1673        assert_eq!(
1674            classify(
1675                &checks,
1676                &[Outcome::Unavailable, Outcome::Failed, Outcome::Passed],
1677                &none()
1678            )
1679            .verdict(),
1680            Verdict::Proceed
1681        );
1682    }
1683
1684    /// The slot of a check whose thread died. Reading that as a pass is how a
1685    /// crash becomes a green commit.
1686    ///
1687    /// Asserted through the RUNNER, not through a `Default` impl: the rule
1688    /// belongs to this call site, and a test on `Outcome::default()` proved
1689    /// only that a trait impl existed, not that the runner used it.
1690    /// A check that PANICS must fail the commit, not pass it — and must not
1691    /// take the other checks down with it.
1692    ///
1693    /// Driven through the stage body rather than the runner, because the value
1694    /// that stands in for a dead check is chosen at the call site and the
1695    /// runner's own test cannot see that choice.
1696    #[test]
1697    fn a_panicking_check_blocks_the_commit() {
1698        static DIES: Builtin = Builtin {
1699            name: "stub-dies",
1700            stage: Stage::PreCommit,
1701            scope: Scope::ALWAYS,
1702            severity: Severity::Block,
1703            run: |_| panic!("this check died"),
1704            fix: crate::check::Fix::None,
1705            reach: crate::check::Reach::Convention,
1706        };
1707        let hook = std::panic::take_hook();
1708        std::panic::set_hook(Box::new(|_| {}));
1709        let push = crate::pushrefs::PushRefs::default();
1710        let manifest = crate::manifest::Manifest::default();
1711        let settings = crate::config::Settings::default();
1712        let ctx = Ctx {
1713            name: "pre-commit",
1714            args: &[],
1715            hooks_dir: std::path::Path::new("."),
1716            push: &push,
1717            manifest: &manifest,
1718            settings: &settings,
1719        };
1720        let verdict = run_stage(&[&DIES], &ctx, &none());
1721        std::panic::set_hook(hook);
1722        assert_eq!(
1723            verdict,
1724            Verdict::Block,
1725            "a check that died must not let the commit through"
1726        );
1727    }
1728
1729    #[test]
1730    fn a_thread_that_dies_leaves_a_failure_behind() {
1731        // The default hook would print a backtrace for the deliberate panic and
1732        // make a passing run look broken.
1733        let hook = std::panic::take_hook();
1734        std::panic::set_hook(Box::new(|_| {}));
1735        let items = ["a", "b", "c"];
1736        let out = run_concurrently(
1737            &items,
1738            |n: &&str| {
1739                if *n == "b" {
1740                    panic!("this check died");
1741                }
1742                Outcome::Passed
1743            },
1744            Outcome::Failed,
1745        );
1746        std::panic::set_hook(hook);
1747        assert_eq!(
1748            out,
1749            vec![Outcome::Passed, Outcome::Failed, Outcome::Passed],
1750            "a dead check must not read as one that passed, \
1751             and must not take the other checks down with it"
1752        );
1753    }
1754    use std::time::{Duration, Instant};
1755
1756    /// Concurrency proved by RENDEZVOUS, not by a stopwatch: every task must
1757    /// observe all the others arrive. Were the runner serial, the first task
1758    /// would wait alone, time out, and return non-zero — a failure, not a hang.
1759    #[test]
1760    fn run_concurrently_actually_overlaps() {
1761        static ARRIVED: AtomicUsize = AtomicUsize::new(0);
1762        ARRIVED.store(0, Ordering::SeqCst);
1763        let names: Vec<&'static str> = vec!["a", "b", "c", "d"];
1764        let n = names.len();
1765
1766        let out = run_concurrently(
1767            &names,
1768            move |_: &&str| {
1769                ARRIVED.fetch_add(1, Ordering::SeqCst);
1770                let deadline = Instant::now() + Duration::from_secs(10);
1771                while ARRIVED.load(Ordering::SeqCst) < n {
1772                    if Instant::now() > deadline {
1773                        return 1; // never met the others — execution was serial
1774                    }
1775                    std::thread::yield_now();
1776                }
1777                0
1778            },
1779            1,
1780        );
1781        assert!(
1782            out.iter().all(|c| *c == 0),
1783            "tasks did not overlap: {out:?}"
1784        );
1785    }
1786
1787    #[test]
1788    fn results_come_back_in_input_order() {
1789        let names: Vec<&'static str> = vec!["first", "second", "third"];
1790        let out = run_concurrently(&names, |n| if *n == "second" { 7 } else { 0 }, -1);
1791        assert_eq!(out, vec![0, 7, 0], "results keep the input order");
1792    }
1793
1794    /// The filter calls the shared resolver rather than restating it. This test
1795    /// used to inline `n.contains(s)` — its own copy of the rule — and so went
1796    /// on passing after the rule changed underneath it.
1797    #[test]
1798    fn skips_are_filtered_by_the_shared_resolver() {
1799        let all = ["pre-commit-ruff", "pre-commit-prettier"];
1800        let skips = ["ruff".to_string()];
1801        let kept: Vec<_> = all
1802            .iter()
1803            .copied()
1804            .filter(|n| !skips.iter().any(|s| crate::skip_suppresses(n, s)))
1805            .collect();
1806        assert_eq!(kept, vec!["pre-commit-prettier"]);
1807    }
1808
1809    /// The ordinary Rust push: source changed, `Cargo.toml` untouched.
1810    ///
1811    /// This test used to assert the OPPOSITE, and said so — it pinned
1812    /// `attestable` filtering on `Scope::matches`, which also asks whether
1813    /// the repository has opted in. Asking that of a push diff meant a
1814    /// `.rs`-only push, in a repository with a `Cargo.toml` sitting right
1815    /// there, attested nothing. Since that is what nearly every Rust push
1816    /// looks like, the feature was attesting almost nothing at all — quietly,
1817    /// while appearing to work.
1818    #[test]
1819    fn an_ordinary_source_push_is_vouched_for() {
1820        let rust = opt_in("pre-push-cargo-test", &[".rs"], &["Cargo.toml"]);
1821        let checks: Vec<&dyn Check> = vec![&rust];
1822        let passed = vec!["pre-push-cargo-test".to_string()];
1823
1824        let changed = vec!["crates/amont/src/main.rs".to_string()];
1825        assert_eq!(
1826            attestable(&checks, &passed, &changed),
1827            vec!["pre-push-cargo-test".to_string()],
1828            "a push that changed Rust must vouch for the Rust gate"
1829        );
1830
1831        // And still when the marker IS in the diff — the opt-in file is not
1832        // required, but it is not disqualifying either.
1833        let changed = vec![
1834            "crates/amont/src/main.rs".to_string(),
1835            "Cargo.toml".to_string(),
1836        ];
1837        assert_eq!(
1838            attestable(&checks, &passed, &changed),
1839            vec!["pre-push-cargo-test".to_string()]
1840        );
1841    }
1842
1843    /// The over-claim the filter exists to stop, with an opt-in gate: a
1844    /// JS-only push must not vouch for the Rust suite, whatever files the
1845    /// repository contains.
1846    ///
1847    /// This is the half of the contract the fix above must not have broken,
1848    /// and it is the reason `touches` asks about EXTENSIONS rather than
1849    /// dropping the scope test altogether.
1850    #[test]
1851    fn an_opt_in_gate_is_not_vouched_for_by_a_push_in_another_language() {
1852        let rust = opt_in("pre-push-cargo-test", &[".rs"], &["Cargo.toml"]);
1853        let checks: Vec<&dyn Check> = vec![&rust];
1854        let passed = vec!["pre-push-cargo-test".to_string()];
1855
1856        for changed in [
1857            vec!["app/routes/home.ts".to_string()],
1858            // Even the marker alone: editing Cargo.toml changes no Rust
1859            // source, so the suite has nothing new to have verified.
1860            vec!["Cargo.toml".to_string()],
1861            vec![],
1862        ] {
1863            assert!(
1864                attestable(&checks, &passed, &changed).is_empty(),
1865                "must not vouch for the Rust gate on {changed:?}"
1866            );
1867        }
1868    }
1869}