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