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