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(stage: Stage, manifest: &crate::manifest::Manifest) -> Vec<&dyn Check> {
40    selected_during(stage, &[], manifest)
41}
42
43/// Do this repository's hooks apply the CONVENTIONS, or only the safety net?
44///
45/// `git config amont.conventions declared` (usually `--global`, set by
46/// `amont enroll`) scopes the house rules to repositories that commit an
47/// `amont.conf` — the standing grant of `init.templateDir` then becomes safe
48/// to hand a whole team: a clone of somebody else's project gets conflict,
49/// secret, size and debug-leftover protection, and none of this team's
50/// opinions about commit subjects or branch names. The default,
51/// `everywhere`, keeps today's behaviour exactly.
52///
53/// Presence of the manifest is the declaration; its CONTENT stays
54/// trust-gated. Reading presence executes nothing, so no consent is needed.
55pub fn conventions_apply(manifest: &crate::manifest::Manifest) -> bool {
56    manifest.declared || !declared_mode()
57}
58
59/// One config read per process — this sits on the hook path of every commit.
60fn declared_mode() -> bool {
61    static MODE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
62    *MODE.get_or_init(|| {
63        crate::config::enumerated_or(
64            "amont.conventions",
65            &["everywhere", "declared"],
66            "everywhere",
67        ) == "declared"
68    })
69}
70
71/// The checks for a stage, minus `hook.skip` and minus anything that declares
72/// it does not run during an operation currently in progress.
73fn selected_during<'a>(
74    stage: Stage,
75    in_progress: &[crate::check::GitState],
76    manifest: &'a crate::manifest::Manifest,
77) -> Vec<&'a dyn Check> {
78    let skips = configured_skips();
79    // Externals are included here, so `hook.skip` and the severity override
80    // govern a declared command exactly as they govern a built-in. A repository
81    // that can add a check it cannot disable would be a worse deal than not
82    // being able to add one.
83    let (kept, dropped): (Vec<_>, Vec<_>) = all_stage_checks(stage, manifest)
84        .into_iter()
85        .partition(|c| !skips.iter().any(|s| crate::skip_suppresses(c.name(), s)));
86    let names: Vec<&str> = dropped.iter().map(|c| c.name()).collect();
87    announce_skips(&names);
88
89    // Announced separately from `hook.skip`, and with the operation named: "not
90    // during a rebase" is a property of the moment and will be true again in a
91    // minute, which is a different thing to tell a reader than "you disabled
92    // this".
93    let (kept, paused): (Vec<_>, Vec<_>) = kept.into_iter().partition(|check| {
94        !check
95            .scope()
96            .not_during
97            .iter()
98            .any(|state| in_progress.contains(state))
99    });
100    if !paused.is_empty() {
101        let what = in_progress
102            .iter()
103            .map(|s| s.as_str())
104            .collect::<Vec<_>>()
105            .join(" and ");
106        println!(
107            "{} {} check(s) paused during {what}: {}",
108            warning_sign(),
109            paused.len(),
110            paused
111                .iter()
112                .map(|c| c.name())
113                .collect::<Vec<_>>()
114                .join(", ")
115        );
116    }
117
118    // The conventions split, last: a held-back check was neither skipped (a
119    // choice about THIS repository) nor paused (a property of the moment) —
120    // this repository simply never subscribed. One line, count not names:
121    // in the clone-of-somebody-else's-project case this prints on every
122    // commit, and fifteen names every time is how a safety message becomes
123    // scroll-past noise.
124    if conventions_apply(manifest) {
125        return kept;
126    }
127    let (kept, held): (Vec<_>, Vec<_>) = kept
128        .into_iter()
129        .partition(|check| check.reach() == crate::check::Reach::Safety);
130    if !held.is_empty() {
131        println!(
132            "{} {} convention check(s) held back — no amont.conf here and \
133             amont.conventions is `declared`; the safety net still runs",
134            warning_sign(),
135            held.len(),
136        );
137    }
138    kept
139}
140
141/// Say out loud which checks did not run.
142///
143/// A skip is otherwise invisible at exactly the moment it matters. With
144/// `hook.skip = merge-conflict` set, a commit printed six green ticks and no
145/// hint that a seventh check had been disabled — the developer sees a clean run
146/// and concludes they are covered.
147///
148/// It is worse than it sounds, because one value can silence a whole stage:
149/// `hook.skip = pre-commit` suppresses all fifteen. That is now something
150/// somebody meant rather than the accident it once was — `e` used to cost
151/// twenty by substring reach — but a commit under it still looks exactly like a
152/// commit that had nothing to report.
153///
154/// One line, only when something was actually skipped, so a normal commit is
155/// unchanged. This reaches every skip however it was created — hand-edited
156/// config included — which no dashboard can claim.
157fn announce_skips(dropped: &[&str]) {
158    if dropped.is_empty() {
159        return;
160    }
161    // Two lines, not one: "you decided this" (hook.skip on this machine)
162    // and "your team decided this" (a skip line in the committed
163    // amont.conf) are different things to be told — the same reason paused
164    // and held-back get their own sentences. A name both sources suppress
165    // is announced as the machine's: the local decision is the nearer one.
166    let (machine, _policy) = crate::skips_by_source();
167    let (yours, theirs): (Vec<&&str>, Vec<&&str>) = dropped
168        .iter()
169        .partition(|name| machine.iter().any(|s| crate::skip_suppresses(name, s)));
170    let say = |names: &[&&str], via: &str| {
171        if names.is_empty() {
172            return;
173        }
174        let plural = if names.len() == 1 { "check" } else { "checks" };
175        println!(
176            "{} {} {plural} skipped by {}: {}",
177            warning_sign(),
178            names.len(),
179            highlight(via),
180            names.iter().map(|n| **n).collect::<Vec<_>>().join(", ")
181        );
182    };
183    say(&yours, "hook.skip");
184    say(&theirs, "amont.conf");
185}
186
187/// Say, once per stage, what the manifest's policy could not do — withheld
188/// behind trust, or aiming at names that exist nowhere. Policy that silently
189/// does not apply is a silent behaviour change, which is the one kind this
190/// codebase does not allow itself.
191fn announce_policy_state(manifest: &crate::manifest::Manifest) {
192    if let Some(why) = manifest.policy_withheld {
193        println!(
194            "{} {} policy not applied: {why}",
195            warning_sign(),
196            highlight(crate::manifest::MANIFEST),
197        );
198    }
199    for note in &manifest.policy_notes {
200        println!("{} {}", warning_sign(), note);
201    }
202    // The version floor rides the same two call sites: once per stage,
203    // beside the other "this repository expects something you lack" lines.
204    crate::skew::announce_minimum();
205}
206
207/// Run every item concurrently and collect `(name, code)` in the INPUT order.
208///
209/// Extracted so the concurrency itself can be tested with a rendezvous instead
210/// of a stopwatch — an earlier wall-clock test was flaky the moment the machine
211/// was busy, and a threshold that trips under load teaches you to ignore it.
212fn run_concurrently<T, R, F>(items: &[T], run: F, if_thread_died: R) -> Vec<R>
213where
214    T: Sync,
215    R: Send + Sync + Clone,
216    F: Fn(&T) -> R + Sync,
217{
218    let slots: Vec<Mutex<Option<R>>> = items.iter().map(|_| Mutex::new(None)).collect();
219    std::thread::scope(|scope| {
220        for (item, slot) in items.iter().zip(&slots) {
221            let run = &run;
222            let died = &if_thread_died;
223            scope.spawn(move || {
224                // CAUGHT, not propagated. `thread::scope` re-raises a child
225                // panic in the parent, which would abort the whole hook with a
226                // backtrace and throw away the other nineteen checks' results —
227                // and would make `if_thread_died` unreachable, which is what it
228                // was until this test existed to notice.
229                let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(item)))
230                    .unwrap_or_else(|_| died.clone());
231                *slot.lock().expect("poisoned") = Some(outcome);
232            });
233        }
234    });
235    slots
236        .into_iter()
237        .map(|s| {
238            s.into_inner()
239                .expect("poisoned")
240                .unwrap_or_else(|| if_thread_died.clone())
241        })
242        .collect()
243}
244
245/// Take the index-fidelity hold, or say why the caller must stop.
246///
247/// Extracted from `pre_commit` so that `amont run` — which its own doc
248/// comment calls "a rehearsal of the hook" — can take exactly the same hold
249/// rather than judging the working tree while a real commit judges the index.
250///
251/// Around the WHOLE fan-out, not per check: twenty checks run concurrently and
252/// would fight over one working tree.
253fn hold_unstaged() -> Result<crate::staged_only::StagedOnly, Verdict> {
254    // BEFORE `enter()`, not after: `enter()` is what checks out the tree and
255    // parks the unstaged half, and a signal landing in the gap between that
256    // and the handler being armed would hit the default disposition — dead
257    // process, tree left checked out, nothing restored. The handler no-ops
258    // harmlessly on a signal that arrives before there is anything held.
259    crate::staged_only::install_signal_handler();
260    match crate::staged_only::StagedOnly::enter() {
261        Ok(guard) => Ok(guard),
262        Err(e) => {
263            // Refusing to check the wrong content is the safe direction; a
264            // check that read the tree would be answering about a commit
265            // nobody is making.
266            eprintln!("{e}");
267            Err(Verdict::Block)
268        }
269    }
270}
271
272pub fn pre_commit(ctx: &Ctx) -> Verdict {
273    // Before anything runs: a pinned tool at the wrong version makes every
274    // verdict below it suspect, and the warning costs one --version per pin.
275    crate::manifest::verify_tool_pins(&ctx.manifest.pins);
276    announce_policy_state(ctx.manifest);
277    let in_progress = crate::git_states_in_progress();
278    let checks = selected_during(Stage::PreCommit, &in_progress, ctx.manifest);
279
280    let held = match hold_unstaged() {
281        Ok(guard) => guard,
282        Err(verdict) => return verdict,
283    };
284
285    let severities = Overrides::read();
286    let (verdict, outcomes) = run_stage_traced(&checks, ctx, &severities);
287
288    // The shadow-mode ledger. Silent, best-effort, and never consulted by any
289    // verdict — see `crate::downgrade`.
290    crate::downgrade::note(&downgraded_events(&checks, &outcomes, &severities));
291
292    // What post-commit will bind to the commit: the gate-declared checks
293    // that RAN clean, recorded while the index still is the commit's tree.
294    // Called on every verdict — an empty record clears any leftover marker,
295    // so a blocked attempt (or a repo with nothing declared) cannot leave an
296    // earlier attempt's marker to vouch for the next commit. `Unavailable`
297    // deliberately does not qualify: a check whose tool is missing judged
298    // nothing, and stamping it would be the paper promise this exists to
299    // replace.
300    // EVERY blocking declaration, not only the npm GATE names: a custom
301    // `pre-commit check … block …` earns its stamp the same way, and a
302    // same-named pre-push declaration defers to it (see `pair_verdict`).
303    let ran: Vec<String> = if matches!(verdict, Verdict::Block) {
304        Vec::new()
305    } else {
306        crate::hooks::run_tests::blocking_commit_decls(&ctx.manifest.externals)
307            .into_iter()
308            .filter(|d| {
309                checks
310                    .iter()
311                    .zip(&outcomes)
312                    .any(|(c, o)| c.name() == d.id && matches!(o, Outcome::Passed | Outcome::Fixed))
313            })
314            .map(|d| d.script)
315            .collect()
316    };
317    let ran: Vec<&str> = ran.iter().map(String::as_str).collect();
318    crate::gate_stamp::record(&ran);
319
320    drop(held);
321    verdict
322}
323
324/// The checks that FAILED without blocking, each with why — the shadow-mode
325/// signal [`crate::downgrade`] keeps.
326///
327/// Derived at the hook entry points and deliberately NOT inside
328/// [`run_stage_traced`], which is where `classify` already computes the same
329/// set. `run_all` reaches that function too, so recording there would let
330/// every `amont run` rehearsal inflate a ledger a lead is going to read as a
331/// count of real commits — and `run --all-files` over a dirty tree would add
332/// dozens of events for content nobody is committing.
333///
334/// A check that DECLARES `warn` is counted but is not evidence about a
335/// rollout: it was never going to block, so calling it "would have blocked"
336/// would inflate the one number the whole feature exists to produce. Only an
337/// override of a blocking check earns that.
338fn downgraded_events(
339    checks: &[&dyn Check],
340    outcomes: &[Outcome],
341    severities: &Overrides,
342) -> Vec<(String, crate::downgrade::Origin)> {
343    checks
344        .iter()
345        .zip(outcomes)
346        .filter(|(_, o)| matches!(o, Outcome::Failed))
347        .filter(|(c, _)| matches!(severities.of(**c), Severity::Warn))
348        .map(|(c, _)| (c.name().to_string(), downgrade_origin(*c, severities)))
349        .collect()
350}
351
352/// Why this check did not block. Shared, because `pre_push` fail-fasts and
353/// never builds an outcomes vector to hand to [`downgraded_events`].
354fn downgrade_origin(check: &dyn Check, severities: &Overrides) -> crate::downgrade::Origin {
355    use crate::downgrade::Origin;
356    use crate::registry::Source;
357    if matches!(check.severity(), Severity::Warn) {
358        return Origin::Declared;
359    }
360    match severities.applied_with_source(check.name()) {
361        Some((_, _, Source::Config)) => Origin::Config,
362        Some((_, _, Source::Policy)) => Origin::Policy,
363        // Declared `block`, resolved `warn`, and nothing claims to have
364        // overridden it: a contradiction. Count it, but not as evidence — the
365        // conservative direction for a number whose only failure mode is
366        // being too alarming.
367        None => Origin::Declared,
368    }
369}
370
371/// The pre-commit body, over the checks it is GIVEN.
372///
373/// A seam, so a test can hand it a check that panics. Without it the value
374/// standing in for a dead check was a literal at one call site that no test
375/// could reach — the rule was asserted on the runner and merely hoped for here.
376fn run_stage(checks: &[&dyn Check], ctx: &Ctx, severities: &Overrides) -> Verdict {
377    run_stage_traced(checks, ctx, severities).0
378}
379
380/// [`run_stage`], keeping the per-check outcomes — index-aligned with
381/// `checks` — alive past the verdict. `pre_commit` needs them to know which
382/// gate-declared checks actually ran (`gate_stamp`); `Report` cannot answer
383/// that, because `classify` deliberately drops the names of `Passed`.
384fn run_stage_traced(
385    checks: &[&dyn Check],
386    ctx: &Ctx,
387    severities: &Overrides,
388) -> (Verdict, Vec<Outcome>) {
389    if checks.is_empty() {
390        return (Verdict::Proceed, Vec::new());
391    }
392    // One slot per check: everything a check says lands in its own buffer
393    // and reaches stdout as ONE block when it finishes — see `live`. Off
394    // (`amont.progress false`), no sink is ever installed and every print
395    // streams exactly as it always did.
396    let stage = crate::live::enabled().then(|| {
397        let names: Vec<&str> = checks.iter().map(|c| c.name()).collect();
398        crate::live::Stage::begin(&names)
399    });
400    let items: Vec<(usize, &&dyn Check)> = checks.iter().enumerate().collect();
401    let outcomes = run_concurrently(
402        &items,
403        |(idx, check)| {
404            let _sink = stage.as_ref().map(|s| s.enter(*idx));
405            // The block is emitted however the check leaves — a panicking
406            // check's partial output still reaches the reader, above the
407            // dead-check verdict `run_concurrently` fills in.
408            let _flush = stage
409                .as_ref()
410                .map(|s| crate::live::FinishOnDrop::new(s, *idx));
411            let sub = Ctx {
412                name: check.name(),
413                args: ctx.args,
414                hooks_dir: ctx.hooks_dir,
415                push: ctx.push,
416                manifest: ctx.manifest,
417            };
418            check.run(&sub)
419        },
420        // A check whose thread died has not passed. Stated here, where the slot
421        // is filled, rather than hidden in a `Default` impl that every future
422        // `#[derive(Default)]` would silently inherit.
423        Outcome::Failed,
424    );
425
426    let report = classify(checks, &outcomes, severities);
427    announce(&report);
428    (report.verdict(), outcomes)
429}
430
431/// What a stage concluded, before anything is printed or exited.
432///
433/// A VALUE, so the classification can be asserted directly. While this was one
434/// function that classified, printed and returned an exit code, its tests could
435/// only check the code — whether the right thing was SAID went untested.
436#[derive(Debug, Default, PartialEq, Eq)]
437struct Report<'a> {
438    /// Repaired. The commit proceeds, but the author's files changed under
439    /// them and that must be said out loud.
440    fixed: Vec<&'a str>,
441    /// Failed, and the severity that applies blocks.
442    blocked: Vec<&'a str>,
443    /// Failed, but configured to warn. The check printed an error and meant it,
444    /// so somebody has to say it did not block.
445    downgraded: Vec<&'a str>,
446    /// Could not run. Distinct from "passed", which is the whole point.
447    unavailable: Vec<&'a str>,
448}
449
450impl Report<'_> {
451    fn verdict(&self) -> Verdict {
452        Verdict::blocking(!self.blocked.is_empty())
453    }
454}
455
456/// Pure: outcomes and severities in, a verdict out. No IO.
457fn classify<'a>(
458    checks: &[&'a dyn Check],
459    outcomes: &[Outcome],
460    severities: &Overrides,
461) -> Report<'a> {
462    let mut report = Report::default();
463    for (check, outcome) in checks.iter().zip(outcomes) {
464        match outcome {
465            // `Warned` needs nothing: a check that chose to warn has already
466            // said what it wanted to, and a roll-up would only repeat it.
467            Outcome::Passed | Outcome::Warned => {}
468            Outcome::Fixed => report.fixed.push(check.name()),
469            Outcome::Unavailable => report.unavailable.push(check.name()),
470            Outcome::Failed => match severities.of(*check) {
471                Severity::Block => report.blocked.push(check.name()),
472                Severity::Warn => report.downgraded.push(check.name()),
473            },
474        }
475    }
476    report
477}
478
479/// Says what happened. Prints; decides nothing.
480fn announce(report: &Report) {
481    if !report.fixed.is_empty() {
482        // Louder than a pass, because files on disk are not what the author
483        // left them: they asked for the repair, but they did not watch it.
484        println!(
485            "{} {} check(s) fixed and re-staged: {}",
486            valid_sign(),
487            report.fixed.len(),
488            report.fixed.join(", ")
489        );
490    }
491    if !report.unavailable.is_empty() {
492        // Distinct from "passed". Silence here is how a repo looks verified
493        // when nothing actually ran — the trailing count is the one line
494        // guaranteed to be read, whatever the twenty blocks above said.
495        println!(
496            "{} {} check(s) could not run: {}",
497            warning_sign(),
498            report.unavailable.len(),
499            report.unavailable.join(", ")
500        );
501    }
502    if !report.downgraded.is_empty() {
503        println!(
504            "{} {} check(s) reported a problem but are set to warn: {}",
505            warning_sign(),
506            report.downgraded.len(),
507            report.downgraded.join(", ")
508        );
509    }
510    if report.blocked.is_empty() {
511        return;
512    }
513    println!("\n🚨  Error raised by:");
514    for name in &report.blocked {
515        println!("    - {}", highlight(name));
516    }
517}
518
519/// Point every check at `git ls-files` instead of the index.
520///
521/// THE definition, called from both entry points. There used to be two: this
522/// one, and a copy in `main.rs` built from a RAW `ls-files` — no `-z` — whose
523/// output git QUOTES for any unusual byte, so `é.json` arrived as the nine-byte
524/// literal `"\303\251.json"` and was handed to prettier and eslint as a path
525/// that does not exist. And because `override_file_set` writes a `OnceLock`,
526/// main's quoted list WON: whichever ran first was the one that counted, and
527/// main's ran first. `git.rs` documents this exact failure.
528pub fn enter_all_files_mode() {
529    crate::hooks::common::override_file_set(
530        crate::git::stdout_paths(&["ls-files"]).unwrap_or_default(),
531    );
532}
533
534/// `amont run` — every applicable check, on demand.
535///
536/// Two questions, and the mode says which it answers:
537///
538/// - **staged** (default) is "would my commit pass" — the same set a commit
539///   would check, so it is a rehearsal of the hook, and it takes the same
540///   index-fidelity hold the hook takes.
541/// - **`--all-files`** is "does my working tree pass". Deliberately NOT the same
542///   question: on a dirty tree it reports on content that is not committed and
543///   may never be. That is right for adopting a check into an existing
544///   repository, where `git add .` is not an acceptable way to measure the mess,
545///   and it is why `--all-files` takes no stash — there is no staged/unstaged
546///   distinction to protect when the answer is "all of it".
547pub fn run_all(ctx: &Ctx, all_files: bool) -> Verdict {
548    // ORDER: the override goes in FIRST. It is what tells `fixing_enabled` and
549    // `restage` that the file set is not the index, and both are consulted
550    // from inside the checks below.
551    if all_files {
552        enter_all_files_mode();
553        if crate::hooks::common::fixing_requested() {
554            println!(
555                "{} {} is set, but fixing is off for {}: the input set is the \
556                 working tree, not the index",
557                warning_sign(),
558                highlight("amont.fix"),
559                highlight("--all-files")
560            );
561        }
562        // Stash-free, per decision 1 of docs/index-fidelity-and-run-modes.md:
563        // there is no staged/unstaged distinction to protect when the input
564        // set is `git ls-files`, so a hold would be surprising extra mutation
565        // with no correctness upside.
566        return run_stage(
567            &selected(Stage::PreCommit, ctx.manifest),
568            ctx,
569            &Overrides::read(),
570        );
571    }
572
573    // Staged mode IS a rehearsal of the commit, so it takes the same hold the
574    // commit does. Without it, `amont run` failed on garbage in the tree
575    // that `git commit` — which holds the unstaged half aside — passed, and
576    // vice versa: the two modes disagreed about the same repository, which is
577    // exactly what this mode exists not to do.
578    let held = match hold_unstaged() {
579        Ok(guard) => guard,
580        Err(verdict) => return verdict,
581    };
582    let verdict = run_stage(
583        &selected(Stage::PreCommit, ctx.manifest),
584        ctx,
585        &Overrides::read(),
586    );
587    // AFTER the report has been printed: dropping earlier would put the
588    // unstaged content back under a check that is still reading files.
589    drop(held);
590    verdict
591}
592
593/// `amont run <check>` — one check by name. `None` when there is no such
594/// check, which the caller turns into a usage error.
595///
596/// Lives here rather than in `main.rs` so `registry::lookup` stays inside the
597/// runtime, and so the hold decision is made once: a named check takes the
598/// index-fidelity hold only when it is a `Stage::PreCommit` check running in
599/// staged mode. A pre-push or commit-msg check invoked by name must never
600/// touch the working tree — nothing about a push is a staging operation.
601/// Resolve what `amont run <name>` means, exactly as `hook.skip` resolves a
602/// name — the rest of the tool taught `ban-terms`; making `run` demand the
603/// full id was a pointless second vocabulary. Ambiguity is an answer, not a
604/// guess: the two `branch-pattern` checks are different code at different
605/// stages.
606///
607/// Public because the CALLER needs the answer before anything else happens:
608/// main decides whether to synthesize push refs from the resolved name, and
609/// an ambiguous name must say so rather than fail on a missing upstream it
610/// was never going to use.
611pub fn resolve_check_name(name: &str, manifest: &crate::manifest::Manifest) -> Named2 {
612    if crate::registry::lookup(name, manifest).is_some() {
613        return Named2::Resolved(name.to_string());
614    }
615    let mut matches: Vec<String> = crate::registry::CHECKS
616        .iter()
617        .map(|c| c.name.to_string())
618        .chain(manifest.externals.iter().map(|e| e.id.clone()))
619        .filter(|id| crate::skip_suppresses(id, name))
620        .collect();
621    matches.dedup();
622    match matches.len() {
623        0 => Named2::Unknown,
624        1 => Named2::Resolved(matches.remove(0)),
625        _ => Named2::Ambiguous(matches),
626    }
627}
628
629/// How a run name resolved.
630pub enum Named2 {
631    Resolved(String),
632    Unknown,
633    Ambiguous(Vec<String>),
634}
635
636pub fn run_named(ctx: &Ctx, name: &str, all_files: bool) -> Named {
637    let full: String = match resolve_check_name(name, ctx.manifest) {
638        Named2::Resolved(id) => id,
639        Named2::Unknown => return Named::Unknown,
640        Named2::Ambiguous(ids) => return Named::Ambiguous(ids),
641    };
642    let name = full.as_str();
643    let Some(run_check) = crate::registry::lookup(name, ctx.manifest) else {
644        return Named::Unknown;
645    };
646    // The Ctx must carry the RESOLVED id: lookup's closure re-resolves
647    // through `ctx.name`, and handing it the short name back would panic on
648    // the very ambiguity this function just settled.
649    let ctx = &Ctx {
650        name,
651        args: ctx.args,
652        hooks_dir: ctx.hooks_dir,
653        push: ctx.push,
654        manifest: ctx.manifest,
655    };
656    if all_files {
657        enter_all_files_mode();
658        return Named::Ran(run_check(ctx));
659    }
660    let is_pre_commit_check = crate::registry::one_named(name, ctx.manifest)
661        .is_some_and(|c| c.stage() == Stage::PreCommit);
662    if !is_pre_commit_check {
663        return Named::Ran(run_check(ctx));
664    }
665    let held = match hold_unstaged() {
666        Ok(guard) => guard,
667        Err(verdict) => return Named::Ran(verdict),
668    };
669    let verdict = run_check(ctx);
670    drop(held);
671    Named::Ran(verdict)
672}
673
674/// What `run_named` resolved a name to.
675pub enum Named {
676    Ran(Verdict),
677    /// Nothing matches — full id, short name, or entrypoint.
678    Unknown,
679    /// A short name that reaches more than one check; the caller lists them
680    /// so the user can pick a full id.
681    Ambiguous(Vec<String>),
682}
683
684pub fn pre_push(ctx: &Ctx) -> Verdict {
685    // The notes push `attest` makes re-enters this hook; its ref list is only
686    // ever the attest ref, so there is nothing to prove — and proving it
687    // would recurse.
688    if crate::attest::push_guard_active() {
689        return Verdict::Proceed;
690    }
691    crate::manifest::verify_tool_pins(&ctx.manifest.pins);
692    announce_policy_state(ctx.manifest);
693    // NB: no CHERRY_PICK_HEAD check here — the zsh pre-push had none either.
694    let severities = Overrides::read();
695    // pre-push had NO state guard at all, with a comment admitting it existed
696    // only because the zsh version had none. Now it asks the same question
697    // pre-commit does and each check answers for itself.
698    let in_progress = crate::git_states_in_progress();
699    let pre_push_checks = selected_during(Stage::PrePush, &in_progress, ctx.manifest);
700    let stage = crate::live::enabled().then(|| {
701        let names: Vec<&str> = pre_push_checks.iter().map(|c| c.name()).collect();
702        crate::live::Stage::begin(&names)
703    });
704    // What actually PASSED, for the attestation at the bottom. `Warned` and
705    // `Unavailable` stay out — "could not run" is not "passed" — and a
706    // commit-time-gated pair counts, because its stamps say the check ran on
707    // every pushed tree.
708    let mut passed: Vec<String> = Vec::new();
709    // Accumulated rather than written per check: one append at the end costs a
710    // single file open, and the loop below can leave early.
711    let mut downgraded: Vec<(String, crate::downgrade::Origin)> = Vec::new();
712    // PUSH STAMPS. The tips being pushed, and what earlier runs of THIS gate
713    // recorded against their trees — an `amont run pre-push` rehearsal, or a
714    // push whose gate passed and whose transport then died. A scoped gate
715    // (a test suite: its verdict is a function of the tree alone) whose
716    // stamp sits on every tip is not run again; the unscoped ones
717    // (branch-protect, secrets — questions about the push, not the content)
718    // always run. `amont.pushStamps false` turns the reuse off.
719    //
720    // Reading `ctx.push` here may consume stdin, exactly as `attest` does
721    // below; every pre-push check reads it anyway.
722    let tips: Vec<String> = {
723        let mut t: Vec<String> = ctx
724            .push
725            .get()
726            .iter()
727            .filter(|r| !r.local_oid.chars().all(|c| c == '0'))
728            .map(|r| r.local_oid.clone())
729            .collect();
730        t.dedup();
731        t
732    };
733    let reuse_stamps = crate::gate_stamp::push_stamps_enabled();
734    // A background rehearsal of one of these tips may be mid-suite right
735    // now. Waiting for it is strictly less work than starting over, and
736    // its stamp — read AFTER the wait, below — is the hand-off.
737    if reuse_stamps && !tips.is_empty() {
738        crate::rehearsal::await_for(&tips);
739    }
740    let push_stamps = if reuse_stamps && !tips.is_empty() {
741        crate::gate_stamp::stamps_for(&tips)
742    } else {
743        Default::default()
744    };
745    // Inside a rehearsal snapshot only the content gates make sense: the
746    // unscoped checks ask about a PUSH — its branch name, its target, its
747    // secrets — and no push is happening. Said once, not per check.
748    let rehearsing = crate::rehearsal::in_snapshot();
749    if rehearsing {
750        crate::say!(
751            "rehearsal: running the test gates only — the push-shaped checks run at push time"
752        );
753    }
754    let stamped_on_every_tip = |name: &str| -> bool {
755        !tips.is_empty()
756            && tips.iter().all(|t| {
757                push_stamps
758                    .get(t)
759                    .is_some_and(|s| s.iter().any(|g| g == name))
760            })
761    };
762    // What actually RAN and passed here (as opposed to being vouched for by
763    // a stamp) — the set this run may stamp in turn.
764    let mut ran_and_passed: Vec<String> = Vec::new();
765    for (idx, check) in pre_push_checks.iter().enumerate() {
766        let _sink = stage.as_ref().map(|s| s.enter(idx));
767        let _flush = stage
768            .as_ref()
769            .map(|s| crate::live::FinishOnDrop::new(s, idx));
770        // A declared pre-push external whose NAME is also declared at
771        // pre-commit (blocking) is a gate pair: the commit-time side earned
772        // per-commit stamps, and this side runs only for pushes carrying
773        // commits with no record of it — the same contract the npm gate has
774        // always had, for vocabularies npm never heard of (`cargo test`,
775        // `pytest`, anything). Messages mirror the npm gate's exactly;
776        // docs/checks.md quotes them.
777        // The push side of a pair is `(name, scope)`, from whichever of the
778        // two kinds of check this is. A declared pre-push external supplies
779        // its own; anything else is a BUILT-IN, and `pairing_name` plus the
780        // registry's scope say the same two things about it.
781        //
782        // The external is tried FIRST and its inputs are unchanged, so the
783        // declared path behaves exactly as before — that is what the
784        // untouched declared-pair tests prove.
785        if rehearsing && check.scope().is_unscoped() {
786            continue;
787        }
788        if !check.scope().is_unscoped() && stamped_on_every_tip(check.name()) {
789            crate::say!(
790                "{} {} passed on this exact tree earlier — not repeating it here",
791                valid_sign(),
792                highlight(check.name()),
793            );
794            passed.push(check.name().to_string());
795            continue;
796        }
797        let declared = ctx
798            .manifest
799            .externals
800            .iter()
801            .find(|e| e.stage == Stage::PrePush && e.id == check.name());
802        let builtin_scope = check.scope();
803        let pairing: Option<(&str, &crate::check::Scope)> = match declared {
804            Some(ext) => match &ext.kind {
805                crate::manifest::Kind::Runnable { scope, .. } => {
806                    Some((ext.short_name.as_str(), scope))
807                }
808                // A declared pre-push entry that runs nothing has no scope to
809                // judge with, and is not a built-in either. Nothing to pair.
810                _ => None,
811            },
812            None => Some((check.pairing_name(), &builtin_scope)),
813        };
814        if let Some((name, push_scope)) = pairing {
815            match crate::hooks::run_tests::pair_verdict(name, push_scope, ctx.manifest, ctx.push) {
816                crate::hooks::run_tests::PairVerdict::Gated => {
817                    crate::say!(
818                        "{} {} gated at commit instead — not repeating it here",
819                        valid_sign(),
820                        highlight(name),
821                    );
822                    passed.push(check.name().to_string());
823                    continue;
824                }
825                crate::hooks::run_tests::PairVerdict::Unstamped(n) => {
826                    crate::say!(
827                        "{} {} is declared at commit time, but {n} pushed \
828                         commit{} carr{} no record of it — running it here",
829                        warning_sign(),
830                        name,
831                        if n == 1 { "" } else { "s" },
832                        if n == 1 { "ies" } else { "y" },
833                    );
834                }
835                crate::hooks::run_tests::PairVerdict::NotPaired => {}
836            }
837        }
838        let sub = Ctx {
839            name: check.name(),
840            args: ctx.args,
841            hooks_dir: ctx.hooks_dir,
842            push: ctx.push,
843            manifest: ctx.manifest,
844        };
845        match check.run(&sub) {
846            Outcome::Passed => {
847                passed.push(check.name().to_string());
848                if !check.scope().is_unscoped() {
849                    ran_and_passed.push(check.name().to_string());
850                }
851            }
852            // Announced, never fatal: a check that could not run has not
853            // invalidated anything, and neither has a warning.
854            Outcome::Unavailable => {
855                println!(
856                    "{} {} could not run",
857                    warning_sign(),
858                    highlight(check.name())
859                )
860            }
861            Outcome::Warned => {}
862            // Cannot occur: `Fix::Rewrite` is refused on a pre-push
863            // declaration, so nothing here can repair anything.
864            Outcome::Fixed => {}
865            Outcome::Failed => match severities.of(*check) {
866                Severity::Warn => {
867                    downgraded.push((
868                        check.name().to_string(),
869                        downgrade_origin(*check, &severities),
870                    ));
871                    println!(
872                        "{} {} reported a problem (severity warn)",
873                        warning_sign(),
874                        highlight(check.name())
875                    );
876                }
877                // Fail-fast applies ONLY to Block: the later steps are
878                // expensive and their preconditions are gone.
879                Severity::Block => {
880                    // Record what already warned before leaving. Those checks
881                    // ran and reported; a later check blocking does not unmake
882                    // them, and dropping them here would make the ledger quietly
883                    // under-count every push that ended badly.
884                    crate::downgrade::note(&downgraded);
885                    println!("\n🚨  Error raised by hook {}", highlight(check.name()));
886                    return Verdict::Block;
887                }
888            },
889        }
890    }
891    // Every block gate passed — stamp the tips with the scoped gates that
892    // RAN here, so the next push of this content (a retry after a dropped
893    // connection, or the real push after an `amont run pre-push` rehearsal)
894    // skips them. Only when what ran is what is being pushed: with
895    // `amont.testPushedTree` the suite ran on the tip itself; otherwise it
896    // ran on the working tree, which vouches for the tip only when the two
897    // are the same content — HEAD, with nothing tracked modified.
898    // The same proxy `attestable` uses, for the same reason: a scoped gate
899    // whose files the push never touched returns `Passed` having run
900    // nothing, and a stamp for THAT would let a later push that does touch
901    // them skip a suite nobody ran.
902    if reuse_stamps && !ran_and_passed.is_empty() {
903        let changed = crate::pushrefs::changed_files(ctx.push.get());
904        let really_ran: Vec<String> = pre_push_checks
905            .iter()
906            .filter(|c| ran_and_passed.iter().any(|p| p == c.name()))
907            .filter(|c| c.scope().touches(&changed))
908            .map(|c| c.name().to_string())
909            .collect();
910        stamp_tips(&tips, &really_ran);
911    }
912    // …and say so to CI, if this repository opted in.
913    // Gated behind `enabled()` HERE, not just inside `attest_push`: reading
914    // `ctx.push` may consume stdin, and a disabled repo should leave stdin
915    // exactly as it found it.
916    if !passed.is_empty() && crate::attest::enabled() {
917        let remote = ctx
918            .args
919            .first()
920            .map(|a| a.to_string_lossy().into_owned())
921            .unwrap_or_default();
922        let changed = crate::pushrefs::changed_files(ctx.push.get());
923        let vouched = attestable(&pre_push_checks, &passed, &changed);
924        crate::attest::attest_push(&remote, ctx.push.get(), &vouched);
925    }
926    crate::downgrade::note(&downgraded);
927    Verdict::Proceed
928}
929
930/// The scoped pre-push gates — test suites — that have work to do for a
931/// push that changed `changed`: what a rehearsal would run, and therefore
932/// what its stamp would have to name before a push may skip anything.
933///
934/// The same two filters `pre_push` applies before stamping (selected here,
935/// and `scope().touches` the change), so the rehearsal's idea of "nothing to
936/// do" is the push's idea of "nothing to stamp".
937pub fn scoped_push_gates(manifest: &crate::manifest::Manifest, changed: &[String]) -> Vec<String> {
938    let in_progress = crate::git_states_in_progress();
939    selected_during(Stage::PrePush, &in_progress, manifest)
940        .into_iter()
941        .filter(|c| !c.scope().is_unscoped() && c.scope().touches(changed))
942        .map(|c| c.name().to_string())
943        .collect()
944}
945
946/// Push-stamp every tip whose content is what the gates actually tested.
947///
948/// See the comment at the call site for the two cases. Silent when nothing
949/// qualifies — a dirty working tree is the ordinary state of a machine
950/// mid-work, and a note on every push would teach people to ignore it.
951fn stamp_tips(tips: &[String], gates: &[String]) {
952    let pushed_tree_mode = crate::pushed_tree::enabled();
953    let head = crate::git::stdout(&["rev-parse", "HEAD"]);
954    // Tracked modifications only: untracked files are not in any tree the
955    // suite could have been asked about, and `status --porcelain` with
956    // `--untracked-files=no` is the cheapest honest answer.
957    let worktree_clean = || {
958        crate::git::stdout(&["status", "--porcelain", "--untracked-files=no"])
959            .is_some_and(|s| s.trim().is_empty())
960    };
961    let mut stamped: Vec<&str> = Vec::new();
962    for tip in tips {
963        let vouchable =
964            pushed_tree_mode || (head.as_deref() == Some(tip.as_str()) && worktree_clean());
965        if !vouchable {
966            continue;
967        }
968        let spec = format!("{tip}^{{tree}}");
969        let Some(tree) = crate::git::stdout(&["rev-parse", &spec]) else {
970            continue;
971        };
972        if crate::gate_stamp::stamp_push(tip, &tree, gates) {
973            stamped.push(tip);
974        }
975    }
976    if !stamped.is_empty() {
977        crate::say!(
978            "{} stamped {} for this tree — the next push of it skips them ({})",
979            valid_sign(),
980            highlight(&gates.join(" ")),
981            crate::gate_stamp::NOTES_REF,
982        );
983    }
984}
985
986/// Of the checks that passed, the ones an attestation may actually VOUCH for.
987///
988/// A language gate whose scope the push never touched returns `Passed` having
989/// run nothing — `cargo_test` walks its refs, finds no crate root, and falls
990/// out of the loop green. That is right for a push gate (there was nothing to
991/// object to) and wrong for an attestation: a JS-only push was minting
992/// `gates … pre-push-cargo-test pre-push-go-test pre-push-pytest`, and in a
993/// MIXED repository CI would then skip a suite that nobody ran on that tree.
994///
995/// The declared `scope` is the honest filter, and the same data `amont list`
996/// already reports. Unscoped checks (`Scope::ALWAYS` — branch-protect,
997/// secrets) match everything and are vouched for, which is accurate: they
998/// really did run. An empty `changed` vouches for nothing scoped, which is
999/// the safe direction — CI runs the suite.
1000///
1001/// [`Scope::touches`], NOT `Scope::matches`. `matches` also asks whether the
1002/// repository has opted in — whether a `Cargo.toml` exists — and asking that
1003/// of a push DIFF can only answer no unless the push happened to touch the
1004/// marker. So `pre-push-cargo-test` was vouched for by a push that edited
1005/// `Cargo.toml` and never by one that edited only `.rs` files, which is the
1006/// ordinary case and the one worth skipping CI for. The feature attested
1007/// almost nothing, silently, and looked like it worked.
1008///
1009/// WHAT THIS IS STILL A PROXY FOR, stated plainly because the trust path
1010/// deserves it: the honest question is "did this gate actually run", and no
1011/// gate reports that. `rust_tools::test` returns `Passed` whether it ran a
1012/// suite or found no crate root and fell out of its loop — the very thing
1013/// the first paragraph describes. Scope is the closest available stand-in.
1014/// It is now a good one: a gate is vouched for only if the push changed a
1015/// file its extensions cover.
1016///
1017/// The gap that remains is narrow and one-directional: a `.rs` file outside
1018/// every crate would be covered here while `cargo test` had nothing to say
1019/// about it. Closing it properly means an outcome that distinguishes "ran
1020/// and passed" from "found nothing to do", which is a change to every gate
1021/// and to `Outcome` itself — worth doing, not worth smuggling into this.
1022fn attestable(checks: &[&dyn Check], passed: &[String], changed: &[String]) -> Vec<String> {
1023    checks
1024        .iter()
1025        .filter(|c| passed.iter().any(|p| p == c.name()))
1026        .filter(|c| c.scope().touches(changed))
1027        .map(|c| c.name().to_string())
1028        .collect()
1029}
1030
1031#[cfg(test)]
1032mod tests {
1033    use super::*;
1034    use crate::check::{Builtin, Scope};
1035    use std::sync::atomic::{AtomicUsize, Ordering};
1036
1037    /// A check whose only job is to carry a name and a severity into `report`.
1038    /// Its `run` is never called — `report` is fed outcomes directly, which is
1039    /// what makes `Unavailable` testable at all: the real thing needs a missing
1040    /// binary, and a test that uninstalls the developer's toolchain is worse
1041    /// than no test.
1042    const fn stub(name: &'static str, severity: Severity) -> Builtin {
1043        Builtin {
1044            name,
1045            stage: Stage::PreCommit,
1046            scope: Scope::ALWAYS,
1047            severity,
1048            run: |_| Outcome::Passed,
1049            fix: crate::check::Fix::None,
1050            reach: crate::check::Reach::Convention,
1051        }
1052    }
1053
1054    /// A pre-push gate with an OPT-IN file, as the real Rust and Python
1055    /// gates have: `.rs` files, but only where a `Cargo.toml` exists.
1056    const fn opt_in(
1057        name: &'static str,
1058        exts: &'static [&'static str],
1059        names: &'static [&'static str],
1060    ) -> Builtin {
1061        Builtin {
1062            name,
1063            stage: Stage::PrePush,
1064            scope: Scope::new(exts, names),
1065            severity: Severity::Block,
1066            run: |_| Outcome::Passed,
1067            fix: crate::check::Fix::None,
1068            reach: crate::check::Reach::Convention,
1069        }
1070    }
1071
1072    /// A pre-push gate scoped to one language's files.
1073    const fn scoped(name: &'static str, exts: &'static [&'static str]) -> Builtin {
1074        Builtin {
1075            name,
1076            stage: Stage::PrePush,
1077            scope: Scope::new(exts, &[]),
1078            severity: Severity::Block,
1079            run: |_| Outcome::Passed,
1080            fix: crate::check::Fix::None,
1081            reach: crate::check::Reach::Convention,
1082        }
1083    }
1084
1085    /// The over-claim this filter exists to stop, caught in the wild: a
1086    /// JS-only push minted `gates … pre-push-cargo-test pre-push-go-test
1087    /// pre-push-pytest`, because each of those gates finds nothing of its
1088    /// language to do and returns `Passed` having run NOTHING. Harmless in a
1089    /// single-language repo, unsound in a mixed one — CI would skip a suite
1090    /// nobody ran on that tree.
1091    #[test]
1092    fn a_gate_whose_language_the_push_never_touched_is_not_vouched_for() {
1093        let js = scoped("pre-push-run-tests-js", &[".ts", ".js"]);
1094        let rust = scoped("pre-push-cargo-test", &[".rs"]);
1095        let py = scoped("pre-push-pytest", &[".py"]);
1096        let always = stub("pre-push-secrets", Severity::Block);
1097        let checks: Vec<&dyn Check> = vec![&js, &rust, &py, &always];
1098        let passed: Vec<String> = checks.iter().map(|c| c.name().to_string()).collect();
1099
1100        let changed = vec!["app/routes/home.ts".to_string()];
1101        let vouched = attestable(&checks, &passed, &changed);
1102        assert_eq!(
1103            vouched,
1104            vec![
1105                "pre-push-run-tests-js".to_string(),
1106                "pre-push-secrets".to_string()
1107            ],
1108            "only the gate that had work, plus the unscoped one that always runs"
1109        );
1110
1111        // Nothing computed about the push vouches for nothing scoped — the
1112        // safe direction, since CI then runs the suite.
1113        assert_eq!(
1114            attestable(&checks, &passed, &[]),
1115            vec!["pre-push-secrets".to_string()]
1116        );
1117
1118        // A check that did NOT pass is never vouched for, whatever its scope.
1119        let only_rust_passed = vec!["pre-push-cargo-test".to_string()];
1120        assert!(attestable(&checks, &only_rust_passed, &changed).is_empty());
1121    }
1122
1123    /// No overrides configured. `report` takes them as a VALUE now, so its
1124    /// tests need no repository and no git at all.
1125    fn none() -> Overrides {
1126        Overrides::default()
1127    }
1128
1129    static BLOCKER: Builtin = stub("stub-blocker", Severity::Block);
1130    static WARNER: Builtin = stub("stub-warner", Severity::Warn);
1131
1132    /// The unit tests hold `&dyn Check` for the same reason the dispatcher
1133    /// does: `report` must not be able to tell a built-in from an external.
1134    const fn as_checks(cs: [&'static Builtin; 3]) -> [&'static dyn Check; 3] {
1135        [cs[0], cs[1], cs[2]]
1136    }
1137
1138    /// The classification itself, which used to be unreachable: while one
1139    /// function classified AND printed AND returned a code, a test could assert
1140    /// the code and nothing else.
1141    #[test]
1142    fn every_outcome_lands_in_the_right_bucket() {
1143        let checks: [&dyn Check; 4] = [&BLOCKER, &BLOCKER, &WARNER, &BLOCKER];
1144        let got = classify(
1145            &checks,
1146            &[
1147                Outcome::Passed,
1148                Outcome::Unavailable,
1149                Outcome::Failed,
1150                Outcome::Failed,
1151            ],
1152            &none(),
1153        );
1154        assert_eq!(got.blocked, ["stub-blocker"], "{got:?}");
1155        assert_eq!(got.downgraded, ["stub-warner"], "{got:?}");
1156        assert_eq!(got.unavailable, ["stub-blocker"], "{got:?}");
1157    }
1158
1159    /// A clean stage concludes nothing at all — not an empty message, no
1160    /// message. Twenty checks that passed should print no roll-ups.
1161    #[test]
1162    fn a_clean_stage_has_nothing_to_report() {
1163        let checks: [&dyn Check; 2] = [&BLOCKER, &WARNER];
1164        let got = classify(&checks, &[Outcome::Passed, Outcome::Warned], &none());
1165        assert_eq!(got, Report::default());
1166        assert_eq!(got.verdict(), Verdict::Proceed);
1167    }
1168
1169    #[test]
1170    fn a_blocking_failure_is_the_only_thing_that_fails_the_commit() {
1171        let b: &dyn Check = &BLOCKER;
1172        let w: &dyn Check = &WARNER;
1173        assert_eq!(
1174            classify(&[b], &[Outcome::Failed], &none()).verdict(),
1175            Verdict::Block
1176        );
1177        assert_eq!(
1178            classify(&[b], &[Outcome::Passed], &none()).verdict(),
1179            Verdict::Proceed
1180        );
1181        // Every non-blocking shape, one at a time, so a regression cannot hide
1182        // behind a passing sibling.
1183        assert_eq!(
1184            classify(&[b], &[Outcome::Warned], &none()).verdict(),
1185            Verdict::Proceed
1186        );
1187        assert_eq!(
1188            classify(&[b], &[Outcome::Unavailable], &none()).verdict(),
1189            Verdict::Proceed
1190        );
1191        assert_eq!(
1192            classify(&[w], &[Outcome::Failed], &none()).verdict(),
1193            Verdict::Proceed
1194        );
1195    }
1196
1197    #[test]
1198    fn one_blocking_failure_among_many_still_fails() {
1199        let checks = as_checks([&BLOCKER, &WARNER, &BLOCKER]);
1200        assert_eq!(
1201            classify(
1202                &checks,
1203                &[Outcome::Unavailable, Outcome::Failed, Outcome::Failed],
1204                &none()
1205            )
1206            .verdict(),
1207            Verdict::Block
1208        );
1209        // Same shape, with the only *blocking* failure removed.
1210        assert_eq!(
1211            classify(
1212                &checks,
1213                &[Outcome::Unavailable, Outcome::Failed, Outcome::Passed],
1214                &none()
1215            )
1216            .verdict(),
1217            Verdict::Proceed
1218        );
1219    }
1220
1221    /// The slot of a check whose thread died. Reading that as a pass is how a
1222    /// crash becomes a green commit.
1223    ///
1224    /// Asserted through the RUNNER, not through a `Default` impl: the rule
1225    /// belongs to this call site, and a test on `Outcome::default()` proved
1226    /// only that a trait impl existed, not that the runner used it.
1227    /// A check that PANICS must fail the commit, not pass it — and must not
1228    /// take the other checks down with it.
1229    ///
1230    /// Driven through the stage body rather than the runner, because the value
1231    /// that stands in for a dead check is chosen at the call site and the
1232    /// runner's own test cannot see that choice.
1233    #[test]
1234    fn a_panicking_check_blocks_the_commit() {
1235        static DIES: Builtin = Builtin {
1236            name: "stub-dies",
1237            stage: Stage::PreCommit,
1238            scope: Scope::ALWAYS,
1239            severity: Severity::Block,
1240            run: |_| panic!("this check died"),
1241            fix: crate::check::Fix::None,
1242            reach: crate::check::Reach::Convention,
1243        };
1244        let hook = std::panic::take_hook();
1245        std::panic::set_hook(Box::new(|_| {}));
1246        let push = crate::pushrefs::PushRefs::default();
1247        let manifest = crate::manifest::Manifest::default();
1248        let ctx = Ctx {
1249            name: "pre-commit",
1250            args: &[],
1251            hooks_dir: std::path::Path::new("."),
1252            push: &push,
1253            manifest: &manifest,
1254        };
1255        let verdict = run_stage(&[&DIES], &ctx, &none());
1256        std::panic::set_hook(hook);
1257        assert_eq!(
1258            verdict,
1259            Verdict::Block,
1260            "a check that died must not let the commit through"
1261        );
1262    }
1263
1264    #[test]
1265    fn a_thread_that_dies_leaves_a_failure_behind() {
1266        // The default hook would print a backtrace for the deliberate panic and
1267        // make a passing run look broken.
1268        let hook = std::panic::take_hook();
1269        std::panic::set_hook(Box::new(|_| {}));
1270        let items = ["a", "b", "c"];
1271        let out = run_concurrently(
1272            &items,
1273            |n: &&str| {
1274                if *n == "b" {
1275                    panic!("this check died");
1276                }
1277                Outcome::Passed
1278            },
1279            Outcome::Failed,
1280        );
1281        std::panic::set_hook(hook);
1282        assert_eq!(
1283            out,
1284            vec![Outcome::Passed, Outcome::Failed, Outcome::Passed],
1285            "a dead check must not read as one that passed, \
1286             and must not take the other checks down with it"
1287        );
1288    }
1289    use std::time::{Duration, Instant};
1290
1291    /// Concurrency proved by RENDEZVOUS, not by a stopwatch: every task must
1292    /// observe all the others arrive. Were the runner serial, the first task
1293    /// would wait alone, time out, and return non-zero — a failure, not a hang.
1294    #[test]
1295    fn run_concurrently_actually_overlaps() {
1296        static ARRIVED: AtomicUsize = AtomicUsize::new(0);
1297        ARRIVED.store(0, Ordering::SeqCst);
1298        let names: Vec<&'static str> = vec!["a", "b", "c", "d"];
1299        let n = names.len();
1300
1301        let out = run_concurrently(
1302            &names,
1303            move |_: &&str| {
1304                ARRIVED.fetch_add(1, Ordering::SeqCst);
1305                let deadline = Instant::now() + Duration::from_secs(10);
1306                while ARRIVED.load(Ordering::SeqCst) < n {
1307                    if Instant::now() > deadline {
1308                        return 1; // never met the others — execution was serial
1309                    }
1310                    std::thread::yield_now();
1311                }
1312                0
1313            },
1314            1,
1315        );
1316        assert!(
1317            out.iter().all(|c| *c == 0),
1318            "tasks did not overlap: {out:?}"
1319        );
1320    }
1321
1322    #[test]
1323    fn results_come_back_in_input_order() {
1324        let names: Vec<&'static str> = vec!["first", "second", "third"];
1325        let out = run_concurrently(&names, |n| if *n == "second" { 7 } else { 0 }, -1);
1326        assert_eq!(out, vec![0, 7, 0], "results keep the input order");
1327    }
1328
1329    /// The filter calls the shared resolver rather than restating it. This test
1330    /// used to inline `n.contains(s)` — its own copy of the rule — and so went
1331    /// on passing after the rule changed underneath it.
1332    #[test]
1333    fn skips_are_filtered_by_the_shared_resolver() {
1334        let all = ["pre-commit-ruff", "pre-commit-prettier"];
1335        let skips = ["ruff".to_string()];
1336        let kept: Vec<_> = all
1337            .iter()
1338            .copied()
1339            .filter(|n| !skips.iter().any(|s| crate::skip_suppresses(n, s)))
1340            .collect();
1341        assert_eq!(kept, vec!["pre-commit-prettier"]);
1342    }
1343
1344    /// The ordinary Rust push: source changed, `Cargo.toml` untouched.
1345    ///
1346    /// This test used to assert the OPPOSITE, and said so — it pinned
1347    /// `attestable` filtering on `Scope::matches`, which also asks whether
1348    /// the repository has opted in. Asking that of a push diff meant a
1349    /// `.rs`-only push, in a repository with a `Cargo.toml` sitting right
1350    /// there, attested nothing. Since that is what nearly every Rust push
1351    /// looks like, the feature was attesting almost nothing at all — quietly,
1352    /// while appearing to work.
1353    #[test]
1354    fn an_ordinary_source_push_is_vouched_for() {
1355        let rust = opt_in("pre-push-cargo-test", &[".rs"], &["Cargo.toml"]);
1356        let checks: Vec<&dyn Check> = vec![&rust];
1357        let passed = vec!["pre-push-cargo-test".to_string()];
1358
1359        let changed = vec!["crates/amont/src/main.rs".to_string()];
1360        assert_eq!(
1361            attestable(&checks, &passed, &changed),
1362            vec!["pre-push-cargo-test".to_string()],
1363            "a push that changed Rust must vouch for the Rust gate"
1364        );
1365
1366        // And still when the marker IS in the diff — the opt-in file is not
1367        // required, but it is not disqualifying either.
1368        let changed = vec![
1369            "crates/amont/src/main.rs".to_string(),
1370            "Cargo.toml".to_string(),
1371        ];
1372        assert_eq!(
1373            attestable(&checks, &passed, &changed),
1374            vec!["pre-push-cargo-test".to_string()]
1375        );
1376    }
1377
1378    /// The over-claim the filter exists to stop, with an opt-in gate: a
1379    /// JS-only push must not vouch for the Rust suite, whatever files the
1380    /// repository contains.
1381    ///
1382    /// This is the half of the contract the fix above must not have broken,
1383    /// and it is the reason `touches` asks about EXTENSIONS rather than
1384    /// dropping the scope test altogether.
1385    #[test]
1386    fn an_opt_in_gate_is_not_vouched_for_by_a_push_in_another_language() {
1387        let rust = opt_in("pre-push-cargo-test", &[".rs"], &["Cargo.toml"]);
1388        let checks: Vec<&dyn Check> = vec![&rust];
1389        let passed = vec!["pre-push-cargo-test".to_string()];
1390
1391        for changed in [
1392            vec!["app/routes/home.ts".to_string()],
1393            // Even the marker alone: editing Cargo.toml changes no Rust
1394            // source, so the suite has nothing new to have verified.
1395            vec!["Cargo.toml".to_string()],
1396            vec![],
1397        ] {
1398            assert!(
1399                attestable(&checks, &passed, &changed).is_empty(),
1400                "must not vouch for the Rust gate on {changed:?}"
1401            );
1402        }
1403    }
1404}