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}