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              amont.conventions is `declared`; the safety net still runs",
133            warning_sign(),
134            held.len(),
135        );
136    }
137    kept
138}
139
140/// Say out loud which checks did not run.
141///
142/// A skip is otherwise invisible at exactly the moment it matters. With
143/// `hook.skip = merge-conflict` set, a commit printed six green ticks and no
144/// hint that a seventh check had been disabled — the developer sees a clean run
145/// and concludes they are covered.
146///
147/// It is worse than it sounds, because one value can silence a whole stage:
148/// `hook.skip = pre-commit` suppresses all fifteen. That is now something
149/// somebody meant rather than the accident it once was — `e` used to cost
150/// twenty by substring reach — but a commit under it still looks exactly like a
151/// commit that had nothing to report.
152///
153/// One line, only when something was actually skipped, so a normal commit is
154/// unchanged. This reaches every skip however it was created — hand-edited
155/// config included — which no dashboard can claim.
156fn announce_skips(dropped: &[&str]) {
157    if dropped.is_empty() {
158        return;
159    }
160    let plural = if dropped.len() == 1 {
161        "check"
162    } else {
163        "checks"
164    };
165    println!(
166        "{} {} {plural} skipped by {}: {}",
167        warning_sign(),
168        dropped.len(),
169        highlight("hook.skip"),
170        dropped.join(", ")
171    );
172}
173
174/// Run every item concurrently and collect `(name, code)` in the INPUT order.
175///
176/// Extracted so the concurrency itself can be tested with a rendezvous instead
177/// of a stopwatch — an earlier wall-clock test was flaky the moment the machine
178/// was busy, and a threshold that trips under load teaches you to ignore it.
179fn run_concurrently<T, R, F>(items: &[T], run: F, if_thread_died: R) -> Vec<R>
180where
181    T: Sync,
182    R: Send + Sync + Clone,
183    F: Fn(&T) -> R + Sync,
184{
185    let slots: Vec<Mutex<Option<R>>> = items.iter().map(|_| Mutex::new(None)).collect();
186    std::thread::scope(|scope| {
187        for (item, slot) in items.iter().zip(&slots) {
188            let run = &run;
189            let died = &if_thread_died;
190            scope.spawn(move || {
191                // CAUGHT, not propagated. `thread::scope` re-raises a child
192                // panic in the parent, which would abort the whole hook with a
193                // backtrace and throw away the other nineteen checks' results —
194                // and would make `if_thread_died` unreachable, which is what it
195                // was until this test existed to notice.
196                let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(item)))
197                    .unwrap_or_else(|_| died.clone());
198                *slot.lock().expect("poisoned") = Some(outcome);
199            });
200        }
201    });
202    slots
203        .into_iter()
204        .map(|s| {
205            s.into_inner()
206                .expect("poisoned")
207                .unwrap_or_else(|| if_thread_died.clone())
208        })
209        .collect()
210}
211
212/// Take the index-fidelity hold, or say why the caller must stop.
213///
214/// Extracted from `pre_commit` so that `amont run` — which its own doc
215/// comment calls "a rehearsal of the hook" — can take exactly the same hold
216/// rather than judging the working tree while a real commit judges the index.
217///
218/// Around the WHOLE fan-out, not per check: twenty checks run concurrently and
219/// would fight over one working tree.
220fn hold_unstaged() -> Result<crate::staged_only::StagedOnly, Verdict> {
221    // BEFORE `enter()`, not after: `enter()` is what checks out the tree and
222    // parks the unstaged half, and a signal landing in the gap between that
223    // and the handler being armed would hit the default disposition — dead
224    // process, tree left checked out, nothing restored. The handler no-ops
225    // harmlessly on a signal that arrives before there is anything held.
226    crate::staged_only::install_signal_handler();
227    match crate::staged_only::StagedOnly::enter() {
228        Ok(guard) => Ok(guard),
229        Err(e) => {
230            // Refusing to check the wrong content is the safe direction; a
231            // check that read the tree would be answering about a commit
232            // nobody is making.
233            eprintln!("{e}");
234            Err(Verdict::Block)
235        }
236    }
237}
238
239pub fn pre_commit(ctx: &Ctx) -> Verdict {
240    // Before anything runs: a pinned tool at the wrong version makes every
241    // verdict below it suspect, and the warning costs one --version per pin.
242    crate::manifest::verify_tool_pins(&ctx.manifest.pins);
243    let in_progress = crate::git_states_in_progress();
244    let checks = selected_during(Stage::PreCommit, &in_progress, ctx.manifest);
245
246    let held = match hold_unstaged() {
247        Ok(guard) => guard,
248        Err(verdict) => return verdict,
249    };
250
251    let (verdict, outcomes) = run_stage_traced(&checks, ctx, &Overrides::read());
252
253    // What post-commit will bind to the commit: the gate-declared checks
254    // that RAN clean, recorded while the index still is the commit's tree.
255    // Called on every verdict — an empty record clears any leftover marker,
256    // so a blocked attempt (or a repo with nothing declared) cannot leave an
257    // earlier attempt's marker to vouch for the next commit. `Unavailable`
258    // deliberately does not qualify: a check whose tool is missing judged
259    // nothing, and stamping it would be the paper promise this exists to
260    // replace.
261    // EVERY blocking declaration, not only the npm GATE names: a custom
262    // `pre-commit check … block …` earns its stamp the same way, and a
263    // same-named pre-push declaration defers to it (see `pair_verdict`).
264    let ran: Vec<String> = if matches!(verdict, Verdict::Block) {
265        Vec::new()
266    } else {
267        crate::hooks::run_tests::blocking_commit_decls(&ctx.manifest.externals)
268            .into_iter()
269            .filter(|d| {
270                checks
271                    .iter()
272                    .zip(&outcomes)
273                    .any(|(c, o)| c.name() == d.id && matches!(o, Outcome::Passed | Outcome::Fixed))
274            })
275            .map(|d| d.script)
276            .collect()
277    };
278    let ran: Vec<&str> = ran.iter().map(String::as_str).collect();
279    crate::gate_stamp::record(&ran);
280
281    drop(held);
282    verdict
283}
284
285/// The pre-commit body, over the checks it is GIVEN.
286///
287/// A seam, so a test can hand it a check that panics. Without it the value
288/// standing in for a dead check was a literal at one call site that no test
289/// could reach — the rule was asserted on the runner and merely hoped for here.
290fn run_stage(checks: &[&dyn Check], ctx: &Ctx, severities: &Overrides) -> Verdict {
291    run_stage_traced(checks, ctx, severities).0
292}
293
294/// [`run_stage`], keeping the per-check outcomes — index-aligned with
295/// `checks` — alive past the verdict. `pre_commit` needs them to know which
296/// gate-declared checks actually ran (`gate_stamp`); `Report` cannot answer
297/// that, because `classify` deliberately drops the names of `Passed`.
298fn run_stage_traced(
299    checks: &[&dyn Check],
300    ctx: &Ctx,
301    severities: &Overrides,
302) -> (Verdict, Vec<Outcome>) {
303    if checks.is_empty() {
304        return (Verdict::Proceed, Vec::new());
305    }
306    // One slot per check: everything a check says lands in its own buffer
307    // and reaches stdout as ONE block when it finishes — see `live`. Off
308    // (`amont.progress false`), no sink is ever installed and every print
309    // streams exactly as it always did.
310    let stage = crate::live::enabled().then(|| {
311        let names: Vec<&str> = checks.iter().map(|c| c.name()).collect();
312        crate::live::Stage::begin(&names)
313    });
314    let items: Vec<(usize, &&dyn Check)> = checks.iter().enumerate().collect();
315    let outcomes = run_concurrently(
316        &items,
317        |(idx, check)| {
318            let _sink = stage.as_ref().map(|s| s.enter(*idx));
319            // The block is emitted however the check leaves — a panicking
320            // check's partial output still reaches the reader, above the
321            // dead-check verdict `run_concurrently` fills in.
322            let _flush = stage
323                .as_ref()
324                .map(|s| crate::live::FinishOnDrop::new(s, *idx));
325            let sub = Ctx {
326                name: check.name(),
327                args: ctx.args,
328                hooks_dir: ctx.hooks_dir,
329                push: ctx.push,
330                manifest: ctx.manifest,
331            };
332            check.run(&sub)
333        },
334        // A check whose thread died has not passed. Stated here, where the slot
335        // is filled, rather than hidden in a `Default` impl that every future
336        // `#[derive(Default)]` would silently inherit.
337        Outcome::Failed,
338    );
339
340    let report = classify(checks, &outcomes, severities);
341    announce(&report);
342    (report.verdict(), outcomes)
343}
344
345/// What a stage concluded, before anything is printed or exited.
346///
347/// A VALUE, so the classification can be asserted directly. While this was one
348/// function that classified, printed and returned an exit code, its tests could
349/// only check the code — whether the right thing was SAID went untested.
350#[derive(Debug, Default, PartialEq, Eq)]
351struct Report<'a> {
352    /// Repaired. The commit proceeds, but the author's files changed under
353    /// them and that must be said out loud.
354    fixed: Vec<&'a str>,
355    /// Failed, and the severity that applies blocks.
356    blocked: Vec<&'a str>,
357    /// Failed, but configured to warn. The check printed an error and meant it,
358    /// so somebody has to say it did not block.
359    downgraded: Vec<&'a str>,
360    /// Could not run. Distinct from "passed", which is the whole point.
361    unavailable: Vec<&'a str>,
362}
363
364impl Report<'_> {
365    fn verdict(&self) -> Verdict {
366        Verdict::blocking(!self.blocked.is_empty())
367    }
368}
369
370/// Pure: outcomes and severities in, a verdict out. No IO.
371fn classify<'a>(
372    checks: &[&'a dyn Check],
373    outcomes: &[Outcome],
374    severities: &Overrides,
375) -> Report<'a> {
376    let mut report = Report::default();
377    for (check, outcome) in checks.iter().zip(outcomes) {
378        match outcome {
379            // `Warned` needs nothing: a check that chose to warn has already
380            // said what it wanted to, and a roll-up would only repeat it.
381            Outcome::Passed | Outcome::Warned => {}
382            Outcome::Fixed => report.fixed.push(check.name()),
383            Outcome::Unavailable => report.unavailable.push(check.name()),
384            Outcome::Failed => match severities.of(*check) {
385                Severity::Block => report.blocked.push(check.name()),
386                Severity::Warn => report.downgraded.push(check.name()),
387            },
388        }
389    }
390    report
391}
392
393/// Says what happened. Prints; decides nothing.
394fn announce(report: &Report) {
395    if !report.fixed.is_empty() {
396        // Louder than a pass, because files on disk are not what the author
397        // left them: they asked for the repair, but they did not watch it.
398        println!(
399            "{} {} check(s) fixed and re-staged: {}",
400            valid_sign(),
401            report.fixed.len(),
402            report.fixed.join(", ")
403        );
404    }
405    if !report.unavailable.is_empty() {
406        // Distinct from "passed". Silence here is how a repo looks verified
407        // when nothing actually ran — the trailing count is the one line
408        // guaranteed to be read, whatever the twenty blocks above said.
409        println!(
410            "{} {} check(s) could not run: {}",
411            warning_sign(),
412            report.unavailable.len(),
413            report.unavailable.join(", ")
414        );
415    }
416    if !report.downgraded.is_empty() {
417        println!(
418            "{} {} check(s) reported a problem but are set to warn: {}",
419            warning_sign(),
420            report.downgraded.len(),
421            report.downgraded.join(", ")
422        );
423    }
424    if report.blocked.is_empty() {
425        return;
426    }
427    println!("\n🚨  Error raised by:");
428    for name in &report.blocked {
429        println!("    - {}", highlight(name));
430    }
431}
432
433/// Point every check at `git ls-files` instead of the index.
434///
435/// THE definition, called from both entry points. There used to be two: this
436/// one, and a copy in `main.rs` built from a RAW `ls-files` — no `-z` — whose
437/// output git QUOTES for any unusual byte, so `é.json` arrived as the nine-byte
438/// literal `"\303\251.json"` and was handed to prettier and eslint as a path
439/// that does not exist. And because `override_file_set` writes a `OnceLock`,
440/// main's quoted list WON: whichever ran first was the one that counted, and
441/// main's ran first. `git.rs` documents this exact failure.
442pub fn enter_all_files_mode() {
443    crate::hooks::common::override_file_set(
444        crate::git::stdout_paths(&["ls-files"]).unwrap_or_default(),
445    );
446}
447
448/// `amont run` — every applicable check, on demand.
449///
450/// Two questions, and the mode says which it answers:
451///
452/// - **staged** (default) is "would my commit pass" — the same set a commit
453///   would check, so it is a rehearsal of the hook, and it takes the same
454///   index-fidelity hold the hook takes.
455/// - **`--all-files`** is "does my working tree pass". Deliberately NOT the same
456///   question: on a dirty tree it reports on content that is not committed and
457///   may never be. That is right for adopting a check into an existing
458///   repository, where `git add .` is not an acceptable way to measure the mess,
459///   and it is why `--all-files` takes no stash — there is no staged/unstaged
460///   distinction to protect when the answer is "all of it".
461pub fn run_all(ctx: &Ctx, all_files: bool) -> Verdict {
462    // ORDER: the override goes in FIRST. It is what tells `fixing_enabled` and
463    // `restage` that the file set is not the index, and both are consulted
464    // from inside the checks below.
465    if all_files {
466        enter_all_files_mode();
467        if crate::hooks::common::fixing_requested() {
468            println!(
469                "{} {} is set, but fixing is off for {}: the input set is the \
470                 working tree, not the index",
471                warning_sign(),
472                highlight("amont.fix"),
473                highlight("--all-files")
474            );
475        }
476        // Stash-free, per decision 1 of docs/index-fidelity-and-run-modes.md:
477        // there is no staged/unstaged distinction to protect when the input
478        // set is `git ls-files`, so a hold would be surprising extra mutation
479        // with no correctness upside.
480        return run_stage(
481            &selected(Stage::PreCommit, ctx.manifest),
482            ctx,
483            &Overrides::read(),
484        );
485    }
486
487    // Staged mode IS a rehearsal of the commit, so it takes the same hold the
488    // commit does. Without it, `amont run` failed on garbage in the tree
489    // that `git commit` — which holds the unstaged half aside — passed, and
490    // vice versa: the two modes disagreed about the same repository, which is
491    // exactly what this mode exists not to do.
492    let held = match hold_unstaged() {
493        Ok(guard) => guard,
494        Err(verdict) => return verdict,
495    };
496    let verdict = run_stage(
497        &selected(Stage::PreCommit, ctx.manifest),
498        ctx,
499        &Overrides::read(),
500    );
501    // AFTER the report has been printed: dropping earlier would put the
502    // unstaged content back under a check that is still reading files.
503    drop(held);
504    verdict
505}
506
507/// `amont run <check>` — one check by name. `None` when there is no such
508/// check, which the caller turns into a usage error.
509///
510/// Lives here rather than in `main.rs` so `registry::lookup` stays inside the
511/// runtime, and so the hold decision is made once: a named check takes the
512/// index-fidelity hold only when it is a `Stage::PreCommit` check running in
513/// staged mode. A pre-push or commit-msg check invoked by name must never
514/// touch the working tree — nothing about a push is a staging operation.
515pub fn run_named(ctx: &Ctx, name: &str, all_files: bool) -> Option<Verdict> {
516    let run_check = crate::registry::lookup(name, ctx.manifest)?;
517    if all_files {
518        enter_all_files_mode();
519        return Some(run_check(ctx));
520    }
521    let is_pre_commit_check = crate::registry::one_named(name, ctx.manifest)
522        .is_some_and(|c| c.stage() == Stage::PreCommit);
523    if !is_pre_commit_check {
524        return Some(run_check(ctx));
525    }
526    let held = match hold_unstaged() {
527        Ok(guard) => guard,
528        Err(verdict) => return Some(verdict),
529    };
530    let verdict = run_check(ctx);
531    drop(held);
532    Some(verdict)
533}
534
535pub fn pre_push(ctx: &Ctx) -> Verdict {
536    crate::manifest::verify_tool_pins(&ctx.manifest.pins);
537    // NB: no CHERRY_PICK_HEAD check here — the zsh pre-push had none either.
538    let severities = Overrides::read();
539    // pre-push had NO state guard at all, with a comment admitting it existed
540    // only because the zsh version had none. Now it asks the same question
541    // pre-commit does and each check answers for itself.
542    let in_progress = crate::git_states_in_progress();
543    let pre_push_checks = selected_during(Stage::PrePush, &in_progress, ctx.manifest);
544    let stage = crate::live::enabled().then(|| {
545        let names: Vec<&str> = pre_push_checks.iter().map(|c| c.name()).collect();
546        crate::live::Stage::begin(&names)
547    });
548    for (idx, check) in pre_push_checks.iter().enumerate() {
549        let _sink = stage.as_ref().map(|s| s.enter(idx));
550        let _flush = stage
551            .as_ref()
552            .map(|s| crate::live::FinishOnDrop::new(s, idx));
553        // A declared pre-push external whose NAME is also declared at
554        // pre-commit (blocking) is a gate pair: the commit-time side earned
555        // per-commit stamps, and this side runs only for pushes carrying
556        // commits with no record of it — the same contract the npm gate has
557        // always had, for vocabularies npm never heard of (`cargo test`,
558        // `pytest`, anything). Messages mirror the npm gate's exactly;
559        // docs/checks.md quotes them.
560        if let Some(ext) = ctx
561            .manifest
562            .externals
563            .iter()
564            .find(|e| e.stage == Stage::PrePush && e.id == check.name())
565        {
566            match crate::hooks::run_tests::pair_verdict(ext, ctx.manifest, ctx.push) {
567                crate::hooks::run_tests::PairVerdict::Gated => {
568                    crate::say!(
569                        "{} {} gated at commit instead — not repeating it here",
570                        valid_sign(),
571                        highlight(&ext.short_name),
572                    );
573                    continue;
574                }
575                crate::hooks::run_tests::PairVerdict::Unstamped(n) => {
576                    crate::say!(
577                        "{} {} is declared at commit time, but {n} pushed                          commit{} carr{} no record of it — running it here",
578                        warning_sign(),
579                        ext.short_name,
580                        if n == 1 { "" } else { "s" },
581                        if n == 1 { "ies" } else { "y" },
582                    );
583                }
584                crate::hooks::run_tests::PairVerdict::NotPaired => {}
585            }
586        }
587        let sub = Ctx {
588            name: check.name(),
589            args: ctx.args,
590            hooks_dir: ctx.hooks_dir,
591            push: ctx.push,
592            manifest: ctx.manifest,
593        };
594        match check.run(&sub) {
595            Outcome::Passed => {}
596            // Announced, never fatal: a check that could not run has not
597            // invalidated anything, and neither has a warning.
598            Outcome::Unavailable => {
599                println!(
600                    "{} {} could not run",
601                    warning_sign(),
602                    highlight(check.name())
603                )
604            }
605            Outcome::Warned => {}
606            // Cannot occur: `Fix::Rewrite` is refused on a pre-push
607            // declaration, so nothing here can repair anything.
608            Outcome::Fixed => {}
609            Outcome::Failed => match severities.of(*check) {
610                Severity::Warn => println!(
611                    "{} {} reported a problem (severity warn)",
612                    warning_sign(),
613                    highlight(check.name())
614                ),
615                // Fail-fast applies ONLY to Block: the later steps are
616                // expensive and their preconditions are gone.
617                Severity::Block => {
618                    println!("\n🚨  Error raised by hook {}", highlight(check.name()));
619                    return Verdict::Block;
620                }
621            },
622        }
623    }
624    Verdict::Proceed
625}
626
627#[cfg(test)]
628mod tests {
629    use super::*;
630    use crate::check::{Builtin, Scope};
631    use std::sync::atomic::{AtomicUsize, Ordering};
632
633    /// A check whose only job is to carry a name and a severity into `report`.
634    /// Its `run` is never called — `report` is fed outcomes directly, which is
635    /// what makes `Unavailable` testable at all: the real thing needs a missing
636    /// binary, and a test that uninstalls the developer's toolchain is worse
637    /// than no test.
638    const fn stub(name: &'static str, severity: Severity) -> Builtin {
639        Builtin {
640            name,
641            stage: Stage::PreCommit,
642            scope: Scope::ALWAYS,
643            severity,
644            run: |_| Outcome::Passed,
645            fix: crate::check::Fix::None,
646            reach: crate::check::Reach::Convention,
647        }
648    }
649
650    /// No overrides configured. `report` takes them as a VALUE now, so its
651    /// tests need no repository and no git at all.
652    fn none() -> Overrides {
653        Overrides::default()
654    }
655
656    static BLOCKER: Builtin = stub("stub-blocker", Severity::Block);
657    static WARNER: Builtin = stub("stub-warner", Severity::Warn);
658
659    /// The unit tests hold `&dyn Check` for the same reason the dispatcher
660    /// does: `report` must not be able to tell a built-in from an external.
661    const fn as_checks(cs: [&'static Builtin; 3]) -> [&'static dyn Check; 3] {
662        [cs[0], cs[1], cs[2]]
663    }
664
665    /// The classification itself, which used to be unreachable: while one
666    /// function classified AND printed AND returned a code, a test could assert
667    /// the code and nothing else.
668    #[test]
669    fn every_outcome_lands_in_the_right_bucket() {
670        let checks: [&dyn Check; 4] = [&BLOCKER, &BLOCKER, &WARNER, &BLOCKER];
671        let got = classify(
672            &checks,
673            &[
674                Outcome::Passed,
675                Outcome::Unavailable,
676                Outcome::Failed,
677                Outcome::Failed,
678            ],
679            &none(),
680        );
681        assert_eq!(got.blocked, ["stub-blocker"], "{got:?}");
682        assert_eq!(got.downgraded, ["stub-warner"], "{got:?}");
683        assert_eq!(got.unavailable, ["stub-blocker"], "{got:?}");
684    }
685
686    /// A clean stage concludes nothing at all — not an empty message, no
687    /// message. Twenty checks that passed should print no roll-ups.
688    #[test]
689    fn a_clean_stage_has_nothing_to_report() {
690        let checks: [&dyn Check; 2] = [&BLOCKER, &WARNER];
691        let got = classify(&checks, &[Outcome::Passed, Outcome::Warned], &none());
692        assert_eq!(got, Report::default());
693        assert_eq!(got.verdict(), Verdict::Proceed);
694    }
695
696    #[test]
697    fn a_blocking_failure_is_the_only_thing_that_fails_the_commit() {
698        let b: &dyn Check = &BLOCKER;
699        let w: &dyn Check = &WARNER;
700        assert_eq!(
701            classify(&[b], &[Outcome::Failed], &none()).verdict(),
702            Verdict::Block
703        );
704        assert_eq!(
705            classify(&[b], &[Outcome::Passed], &none()).verdict(),
706            Verdict::Proceed
707        );
708        // Every non-blocking shape, one at a time, so a regression cannot hide
709        // behind a passing sibling.
710        assert_eq!(
711            classify(&[b], &[Outcome::Warned], &none()).verdict(),
712            Verdict::Proceed
713        );
714        assert_eq!(
715            classify(&[b], &[Outcome::Unavailable], &none()).verdict(),
716            Verdict::Proceed
717        );
718        assert_eq!(
719            classify(&[w], &[Outcome::Failed], &none()).verdict(),
720            Verdict::Proceed
721        );
722    }
723
724    #[test]
725    fn one_blocking_failure_among_many_still_fails() {
726        let checks = as_checks([&BLOCKER, &WARNER, &BLOCKER]);
727        assert_eq!(
728            classify(
729                &checks,
730                &[Outcome::Unavailable, Outcome::Failed, Outcome::Failed],
731                &none()
732            )
733            .verdict(),
734            Verdict::Block
735        );
736        // Same shape, with the only *blocking* failure removed.
737        assert_eq!(
738            classify(
739                &checks,
740                &[Outcome::Unavailable, Outcome::Failed, Outcome::Passed],
741                &none()
742            )
743            .verdict(),
744            Verdict::Proceed
745        );
746    }
747
748    /// The slot of a check whose thread died. Reading that as a pass is how a
749    /// crash becomes a green commit.
750    ///
751    /// Asserted through the RUNNER, not through a `Default` impl: the rule
752    /// belongs to this call site, and a test on `Outcome::default()` proved
753    /// only that a trait impl existed, not that the runner used it.
754    /// A check that PANICS must fail the commit, not pass it — and must not
755    /// take the other checks down with it.
756    ///
757    /// Driven through the stage body rather than the runner, because the value
758    /// that stands in for a dead check is chosen at the call site and the
759    /// runner's own test cannot see that choice.
760    #[test]
761    fn a_panicking_check_blocks_the_commit() {
762        static DIES: Builtin = Builtin {
763            name: "stub-dies",
764            stage: Stage::PreCommit,
765            scope: Scope::ALWAYS,
766            severity: Severity::Block,
767            run: |_| panic!("this check died"),
768            fix: crate::check::Fix::None,
769            reach: crate::check::Reach::Convention,
770        };
771        let hook = std::panic::take_hook();
772        std::panic::set_hook(Box::new(|_| {}));
773        let push = crate::pushrefs::PushRefs::default();
774        let manifest = crate::manifest::Manifest::default();
775        let ctx = Ctx {
776            name: "pre-commit",
777            args: &[],
778            hooks_dir: std::path::Path::new("."),
779            push: &push,
780            manifest: &manifest,
781        };
782        let verdict = run_stage(&[&DIES], &ctx, &none());
783        std::panic::set_hook(hook);
784        assert_eq!(
785            verdict,
786            Verdict::Block,
787            "a check that died must not let the commit through"
788        );
789    }
790
791    #[test]
792    fn a_thread_that_dies_leaves_a_failure_behind() {
793        // The default hook would print a backtrace for the deliberate panic and
794        // make a passing run look broken.
795        let hook = std::panic::take_hook();
796        std::panic::set_hook(Box::new(|_| {}));
797        let items = ["a", "b", "c"];
798        let out = run_concurrently(
799            &items,
800            |n: &&str| {
801                if *n == "b" {
802                    panic!("this check died");
803                }
804                Outcome::Passed
805            },
806            Outcome::Failed,
807        );
808        std::panic::set_hook(hook);
809        assert_eq!(
810            out,
811            vec![Outcome::Passed, Outcome::Failed, Outcome::Passed],
812            "a dead check must not read as one that passed, \
813             and must not take the other checks down with it"
814        );
815    }
816    use std::time::{Duration, Instant};
817
818    /// Concurrency proved by RENDEZVOUS, not by a stopwatch: every task must
819    /// observe all the others arrive. Were the runner serial, the first task
820    /// would wait alone, time out, and return non-zero — a failure, not a hang.
821    #[test]
822    fn run_concurrently_actually_overlaps() {
823        static ARRIVED: AtomicUsize = AtomicUsize::new(0);
824        ARRIVED.store(0, Ordering::SeqCst);
825        let names: Vec<&'static str> = vec!["a", "b", "c", "d"];
826        let n = names.len();
827
828        let out = run_concurrently(
829            &names,
830            move |_: &&str| {
831                ARRIVED.fetch_add(1, Ordering::SeqCst);
832                let deadline = Instant::now() + Duration::from_secs(10);
833                while ARRIVED.load(Ordering::SeqCst) < n {
834                    if Instant::now() > deadline {
835                        return 1; // never met the others — execution was serial
836                    }
837                    std::thread::yield_now();
838                }
839                0
840            },
841            1,
842        );
843        assert!(
844            out.iter().all(|c| *c == 0),
845            "tasks did not overlap: {out:?}"
846        );
847    }
848
849    #[test]
850    fn results_come_back_in_input_order() {
851        let names: Vec<&'static str> = vec!["first", "second", "third"];
852        let out = run_concurrently(&names, |n| if *n == "second" { 7 } else { 0 }, -1);
853        assert_eq!(out, vec![0, 7, 0], "results keep the input order");
854    }
855
856    /// The filter calls the shared resolver rather than restating it. This test
857    /// used to inline `n.contains(s)` — its own copy of the rule — and so went
858    /// on passing after the rule changed underneath it.
859    #[test]
860    fn skips_are_filtered_by_the_shared_resolver() {
861        let all = ["pre-commit-ruff", "pre-commit-prettier"];
862        let skips = ["ruff".to_string()];
863        let kept: Vec<_> = all
864            .iter()
865            .copied()
866            .filter(|n| !skips.iter().any(|s| crate::skip_suppresses(n, s)))
867            .collect();
868        assert_eq!(kept, vec!["pre-commit-prettier"]);
869    }
870}