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 let ran: Vec<&'static str> = if matches!(verdict, Verdict::Block) {
214 Vec::new()
215 } else {
216 crate::hooks::run_tests::gated_at_commit(&ctx.manifest.externals)
217 .into_iter()
218 .filter(|d| {
219 checks
220 .iter()
221 .zip(&outcomes)
222 .any(|(c, o)| c.name() == d.id && matches!(o, Outcome::Passed | Outcome::Fixed))
223 })
224 .map(|d| d.script)
225 .collect()
226 };
227 crate::gate_stamp::record(&ran);
228
229 drop(held);
230 verdict
231}
232
233/// The pre-commit body, over the checks it is GIVEN.
234///
235/// A seam, so a test can hand it a check that panics. Without it the value
236/// standing in for a dead check was a literal at one call site that no test
237/// could reach — the rule was asserted on the runner and merely hoped for here.
238fn run_stage(checks: &[&dyn Check], ctx: &Ctx, severities: &Overrides) -> Verdict {
239 run_stage_traced(checks, ctx, severities).0
240}
241
242/// [`run_stage`], keeping the per-check outcomes — index-aligned with
243/// `checks` — alive past the verdict. `pre_commit` needs them to know which
244/// gate-declared checks actually ran (`gate_stamp`); `Report` cannot answer
245/// that, because `classify` deliberately drops the names of `Passed`.
246fn run_stage_traced(
247 checks: &[&dyn Check],
248 ctx: &Ctx,
249 severities: &Overrides,
250) -> (Verdict, Vec<Outcome>) {
251 if checks.is_empty() {
252 return (Verdict::Proceed, Vec::new());
253 }
254 // One slot per check: everything a check says lands in its own buffer
255 // and reaches stdout as ONE block when it finishes — see `live`. Off
256 // (`amont.progress false`), no sink is ever installed and every print
257 // streams exactly as it always did.
258 let stage = crate::live::enabled().then(|| {
259 let names: Vec<&str> = checks.iter().map(|c| c.name()).collect();
260 crate::live::Stage::begin(&names)
261 });
262 let items: Vec<(usize, &&dyn Check)> = checks.iter().enumerate().collect();
263 let outcomes = run_concurrently(
264 &items,
265 |(idx, check)| {
266 let _sink = stage.as_ref().map(|s| s.enter(*idx));
267 // The block is emitted however the check leaves — a panicking
268 // check's partial output still reaches the reader, above the
269 // dead-check verdict `run_concurrently` fills in.
270 let _flush = stage
271 .as_ref()
272 .map(|s| crate::live::FinishOnDrop::new(s, *idx));
273 let sub = Ctx {
274 name: check.name(),
275 args: ctx.args,
276 hooks_dir: ctx.hooks_dir,
277 push: ctx.push,
278 manifest: ctx.manifest,
279 };
280 check.run(&sub)
281 },
282 // A check whose thread died has not passed. Stated here, where the slot
283 // is filled, rather than hidden in a `Default` impl that every future
284 // `#[derive(Default)]` would silently inherit.
285 Outcome::Failed,
286 );
287
288 let report = classify(checks, &outcomes, severities);
289 announce(&report);
290 (report.verdict(), outcomes)
291}
292
293/// What a stage concluded, before anything is printed or exited.
294///
295/// A VALUE, so the classification can be asserted directly. While this was one
296/// function that classified, printed and returned an exit code, its tests could
297/// only check the code — whether the right thing was SAID went untested.
298#[derive(Debug, Default, PartialEq, Eq)]
299struct Report<'a> {
300 /// Repaired. The commit proceeds, but the author's files changed under
301 /// them and that must be said out loud.
302 fixed: Vec<&'a str>,
303 /// Failed, and the severity that applies blocks.
304 blocked: Vec<&'a str>,
305 /// Failed, but configured to warn. The check printed an error and meant it,
306 /// so somebody has to say it did not block.
307 downgraded: Vec<&'a str>,
308 /// Could not run. Distinct from "passed", which is the whole point.
309 unavailable: Vec<&'a str>,
310}
311
312impl Report<'_> {
313 fn verdict(&self) -> Verdict {
314 Verdict::blocking(!self.blocked.is_empty())
315 }
316}
317
318/// Pure: outcomes and severities in, a verdict out. No IO.
319fn classify<'a>(
320 checks: &[&'a dyn Check],
321 outcomes: &[Outcome],
322 severities: &Overrides,
323) -> Report<'a> {
324 let mut report = Report::default();
325 for (check, outcome) in checks.iter().zip(outcomes) {
326 match outcome {
327 // `Warned` needs nothing: a check that chose to warn has already
328 // said what it wanted to, and a roll-up would only repeat it.
329 Outcome::Passed | Outcome::Warned => {}
330 Outcome::Fixed => report.fixed.push(check.name()),
331 Outcome::Unavailable => report.unavailable.push(check.name()),
332 Outcome::Failed => match severities.of(*check) {
333 Severity::Block => report.blocked.push(check.name()),
334 Severity::Warn => report.downgraded.push(check.name()),
335 },
336 }
337 }
338 report
339}
340
341/// Says what happened. Prints; decides nothing.
342fn announce(report: &Report) {
343 if !report.fixed.is_empty() {
344 // Louder than a pass, because files on disk are not what the author
345 // left them: they asked for the repair, but they did not watch it.
346 println!(
347 "{} {} check(s) fixed and re-staged: {}",
348 valid_sign(),
349 report.fixed.len(),
350 report.fixed.join(", ")
351 );
352 }
353 if !report.unavailable.is_empty() {
354 // Distinct from "passed". Silence here is how a repo looks verified
355 // when nothing actually ran — the trailing count is the one line
356 // guaranteed to be read, whatever the twenty blocks above said.
357 println!(
358 "{} {} check(s) could not run: {}",
359 warning_sign(),
360 report.unavailable.len(),
361 report.unavailable.join(", ")
362 );
363 }
364 if !report.downgraded.is_empty() {
365 println!(
366 "{} {} check(s) reported a problem but are set to warn: {}",
367 warning_sign(),
368 report.downgraded.len(),
369 report.downgraded.join(", ")
370 );
371 }
372 if report.blocked.is_empty() {
373 return;
374 }
375 println!("\n🚨 Error raised by:");
376 for name in &report.blocked {
377 println!(" - {}", highlight(name));
378 }
379}
380
381/// Point every check at `git ls-files` instead of the index.
382///
383/// THE definition, called from both entry points. There used to be two: this
384/// one, and a copy in `main.rs` built from a RAW `ls-files` — no `-z` — whose
385/// output git QUOTES for any unusual byte, so `é.json` arrived as the nine-byte
386/// literal `"\303\251.json"` and was handed to prettier and eslint as a path
387/// that does not exist. And because `override_file_set` writes a `OnceLock`,
388/// main's quoted list WON: whichever ran first was the one that counted, and
389/// main's ran first. `git.rs` documents this exact failure.
390pub fn enter_all_files_mode() {
391 crate::hooks::common::override_file_set(
392 crate::git::stdout_paths(&["ls-files"]).unwrap_or_default(),
393 );
394}
395
396/// `amont run` — every applicable check, on demand.
397///
398/// Two questions, and the mode says which it answers:
399///
400/// - **staged** (default) is "would my commit pass" — the same set a commit
401/// would check, so it is a rehearsal of the hook, and it takes the same
402/// index-fidelity hold the hook takes.
403/// - **`--all-files`** is "does my working tree pass". Deliberately NOT the same
404/// question: on a dirty tree it reports on content that is not committed and
405/// may never be. That is right for adopting a check into an existing
406/// repository, where `git add .` is not an acceptable way to measure the mess,
407/// and it is why `--all-files` takes no stash — there is no staged/unstaged
408/// distinction to protect when the answer is "all of it".
409pub fn run_all(ctx: &Ctx, all_files: bool) -> Verdict {
410 // ORDER: the override goes in FIRST. It is what tells `fixing_enabled` and
411 // `restage` that the file set is not the index, and both are consulted
412 // from inside the checks below.
413 if all_files {
414 enter_all_files_mode();
415 if crate::hooks::common::fixing_requested() {
416 println!(
417 "{} {} is set, but fixing is off for {}: the input set is the \
418 working tree, not the index",
419 warning_sign(),
420 highlight("amont.fix"),
421 highlight("--all-files")
422 );
423 }
424 // Stash-free, per decision 1 of docs/index-fidelity-and-run-modes.md:
425 // there is no staged/unstaged distinction to protect when the input
426 // set is `git ls-files`, so a hold would be surprising extra mutation
427 // with no correctness upside.
428 return run_stage(
429 &selected(Stage::PreCommit, ctx.manifest),
430 ctx,
431 &Overrides::read(),
432 );
433 }
434
435 // Staged mode IS a rehearsal of the commit, so it takes the same hold the
436 // commit does. Without it, `amont run` failed on garbage in the tree
437 // that `git commit` — which holds the unstaged half aside — passed, and
438 // vice versa: the two modes disagreed about the same repository, which is
439 // exactly what this mode exists not to do.
440 let held = match hold_unstaged() {
441 Ok(guard) => guard,
442 Err(verdict) => return verdict,
443 };
444 let verdict = run_stage(
445 &selected(Stage::PreCommit, ctx.manifest),
446 ctx,
447 &Overrides::read(),
448 );
449 // AFTER the report has been printed: dropping earlier would put the
450 // unstaged content back under a check that is still reading files.
451 drop(held);
452 verdict
453}
454
455/// `amont run <check>` — one check by name. `None` when there is no such
456/// check, which the caller turns into a usage error.
457///
458/// Lives here rather than in `main.rs` so `registry::lookup` stays inside the
459/// runtime, and so the hold decision is made once: a named check takes the
460/// index-fidelity hold only when it is a `Stage::PreCommit` check running in
461/// staged mode. A pre-push or commit-msg check invoked by name must never
462/// touch the working tree — nothing about a push is a staging operation.
463pub fn run_named(ctx: &Ctx, name: &str, all_files: bool) -> Option<Verdict> {
464 let run_check = crate::registry::lookup(name, ctx.manifest)?;
465 if all_files {
466 enter_all_files_mode();
467 return Some(run_check(ctx));
468 }
469 let is_pre_commit_check = crate::registry::one_named(name, ctx.manifest)
470 .is_some_and(|c| c.stage() == Stage::PreCommit);
471 if !is_pre_commit_check {
472 return Some(run_check(ctx));
473 }
474 let held = match hold_unstaged() {
475 Ok(guard) => guard,
476 Err(verdict) => return Some(verdict),
477 };
478 let verdict = run_check(ctx);
479 drop(held);
480 Some(verdict)
481}
482
483pub fn pre_push(ctx: &Ctx) -> Verdict {
484 crate::manifest::verify_tool_pins(&ctx.manifest.pins);
485 // NB: no CHERRY_PICK_HEAD check here — the zsh pre-push had none either.
486 let severities = Overrides::read();
487 // pre-push had NO state guard at all, with a comment admitting it existed
488 // only because the zsh version had none. Now it asks the same question
489 // pre-commit does and each check answers for itself.
490 let in_progress = crate::git_states_in_progress();
491 let pre_push_checks = selected_during(Stage::PrePush, &in_progress, ctx.manifest);
492 let stage = crate::live::enabled().then(|| {
493 let names: Vec<&str> = pre_push_checks.iter().map(|c| c.name()).collect();
494 crate::live::Stage::begin(&names)
495 });
496 for (idx, check) in pre_push_checks.iter().enumerate() {
497 let _sink = stage.as_ref().map(|s| s.enter(idx));
498 let _flush = stage
499 .as_ref()
500 .map(|s| crate::live::FinishOnDrop::new(s, idx));
501 let sub = Ctx {
502 name: check.name(),
503 args: ctx.args,
504 hooks_dir: ctx.hooks_dir,
505 push: ctx.push,
506 manifest: ctx.manifest,
507 };
508 match check.run(&sub) {
509 Outcome::Passed => {}
510 // Announced, never fatal: a check that could not run has not
511 // invalidated anything, and neither has a warning.
512 Outcome::Unavailable => {
513 println!(
514 "{} {} could not run",
515 warning_sign(),
516 highlight(check.name())
517 )
518 }
519 Outcome::Warned => {}
520 // Cannot occur: `Fix::Rewrite` is refused on a pre-push
521 // declaration, so nothing here can repair anything.
522 Outcome::Fixed => {}
523 Outcome::Failed => match severities.of(*check) {
524 Severity::Warn => println!(
525 "{} {} reported a problem (severity warn)",
526 warning_sign(),
527 highlight(check.name())
528 ),
529 // Fail-fast applies ONLY to Block: the later steps are
530 // expensive and their preconditions are gone.
531 Severity::Block => {
532 println!("\n🚨 Error raised by hook {}", highlight(check.name()));
533 return Verdict::Block;
534 }
535 },
536 }
537 }
538 Verdict::Proceed
539}
540
541#[cfg(test)]
542mod tests {
543 use super::*;
544 use crate::check::{Builtin, Scope};
545 use std::sync::atomic::{AtomicUsize, Ordering};
546
547 /// A check whose only job is to carry a name and a severity into `report`.
548 /// Its `run` is never called — `report` is fed outcomes directly, which is
549 /// what makes `Unavailable` testable at all: the real thing needs a missing
550 /// binary, and a test that uninstalls the developer's toolchain is worse
551 /// than no test.
552 const fn stub(name: &'static str, severity: Severity) -> Builtin {
553 Builtin {
554 name,
555 stage: Stage::PreCommit,
556 scope: Scope::ALWAYS,
557 severity,
558 run: |_| Outcome::Passed,
559 fix: crate::check::Fix::None,
560 }
561 }
562
563 /// No overrides configured. `report` takes them as a VALUE now, so its
564 /// tests need no repository and no git at all.
565 fn none() -> Overrides {
566 Overrides::default()
567 }
568
569 static BLOCKER: Builtin = stub("stub-blocker", Severity::Block);
570 static WARNER: Builtin = stub("stub-warner", Severity::Warn);
571
572 /// The unit tests hold `&dyn Check` for the same reason the dispatcher
573 /// does: `report` must not be able to tell a built-in from an external.
574 const fn as_checks(cs: [&'static Builtin; 3]) -> [&'static dyn Check; 3] {
575 [cs[0], cs[1], cs[2]]
576 }
577
578 /// The classification itself, which used to be unreachable: while one
579 /// function classified AND printed AND returned a code, a test could assert
580 /// the code and nothing else.
581 #[test]
582 fn every_outcome_lands_in_the_right_bucket() {
583 let checks: [&dyn Check; 4] = [&BLOCKER, &BLOCKER, &WARNER, &BLOCKER];
584 let got = classify(
585 &checks,
586 &[
587 Outcome::Passed,
588 Outcome::Unavailable,
589 Outcome::Failed,
590 Outcome::Failed,
591 ],
592 &none(),
593 );
594 assert_eq!(got.blocked, ["stub-blocker"], "{got:?}");
595 assert_eq!(got.downgraded, ["stub-warner"], "{got:?}");
596 assert_eq!(got.unavailable, ["stub-blocker"], "{got:?}");
597 }
598
599 /// A clean stage concludes nothing at all — not an empty message, no
600 /// message. Twenty checks that passed should print no roll-ups.
601 #[test]
602 fn a_clean_stage_has_nothing_to_report() {
603 let checks: [&dyn Check; 2] = [&BLOCKER, &WARNER];
604 let got = classify(&checks, &[Outcome::Passed, Outcome::Warned], &none());
605 assert_eq!(got, Report::default());
606 assert_eq!(got.verdict(), Verdict::Proceed);
607 }
608
609 #[test]
610 fn a_blocking_failure_is_the_only_thing_that_fails_the_commit() {
611 let b: &dyn Check = &BLOCKER;
612 let w: &dyn Check = &WARNER;
613 assert_eq!(
614 classify(&[b], &[Outcome::Failed], &none()).verdict(),
615 Verdict::Block
616 );
617 assert_eq!(
618 classify(&[b], &[Outcome::Passed], &none()).verdict(),
619 Verdict::Proceed
620 );
621 // Every non-blocking shape, one at a time, so a regression cannot hide
622 // behind a passing sibling.
623 assert_eq!(
624 classify(&[b], &[Outcome::Warned], &none()).verdict(),
625 Verdict::Proceed
626 );
627 assert_eq!(
628 classify(&[b], &[Outcome::Unavailable], &none()).verdict(),
629 Verdict::Proceed
630 );
631 assert_eq!(
632 classify(&[w], &[Outcome::Failed], &none()).verdict(),
633 Verdict::Proceed
634 );
635 }
636
637 #[test]
638 fn one_blocking_failure_among_many_still_fails() {
639 let checks = as_checks([&BLOCKER, &WARNER, &BLOCKER]);
640 assert_eq!(
641 classify(
642 &checks,
643 &[Outcome::Unavailable, Outcome::Failed, Outcome::Failed],
644 &none()
645 )
646 .verdict(),
647 Verdict::Block
648 );
649 // Same shape, with the only *blocking* failure removed.
650 assert_eq!(
651 classify(
652 &checks,
653 &[Outcome::Unavailable, Outcome::Failed, Outcome::Passed],
654 &none()
655 )
656 .verdict(),
657 Verdict::Proceed
658 );
659 }
660
661 /// The slot of a check whose thread died. Reading that as a pass is how a
662 /// crash becomes a green commit.
663 ///
664 /// Asserted through the RUNNER, not through a `Default` impl: the rule
665 /// belongs to this call site, and a test on `Outcome::default()` proved
666 /// only that a trait impl existed, not that the runner used it.
667 /// A check that PANICS must fail the commit, not pass it — and must not
668 /// take the other checks down with it.
669 ///
670 /// Driven through the stage body rather than the runner, because the value
671 /// that stands in for a dead check is chosen at the call site and the
672 /// runner's own test cannot see that choice.
673 #[test]
674 fn a_panicking_check_blocks_the_commit() {
675 static DIES: Builtin = Builtin {
676 name: "stub-dies",
677 stage: Stage::PreCommit,
678 scope: Scope::ALWAYS,
679 severity: Severity::Block,
680 run: |_| panic!("this check died"),
681 fix: crate::check::Fix::None,
682 };
683 let hook = std::panic::take_hook();
684 std::panic::set_hook(Box::new(|_| {}));
685 let push = crate::pushrefs::PushRefs::default();
686 let manifest = crate::manifest::Manifest::default();
687 let ctx = Ctx {
688 name: "pre-commit",
689 args: &[],
690 hooks_dir: std::path::Path::new("."),
691 push: &push,
692 manifest: &manifest,
693 };
694 let verdict = run_stage(&[&DIES], &ctx, &none());
695 std::panic::set_hook(hook);
696 assert_eq!(
697 verdict,
698 Verdict::Block,
699 "a check that died must not let the commit through"
700 );
701 }
702
703 #[test]
704 fn a_thread_that_dies_leaves_a_failure_behind() {
705 // The default hook would print a backtrace for the deliberate panic and
706 // make a passing run look broken.
707 let hook = std::panic::take_hook();
708 std::panic::set_hook(Box::new(|_| {}));
709 let items = ["a", "b", "c"];
710 let out = run_concurrently(
711 &items,
712 |n: &&str| {
713 if *n == "b" {
714 panic!("this check died");
715 }
716 Outcome::Passed
717 },
718 Outcome::Failed,
719 );
720 std::panic::set_hook(hook);
721 assert_eq!(
722 out,
723 vec![Outcome::Passed, Outcome::Failed, Outcome::Passed],
724 "a dead check must not read as one that passed, \
725 and must not take the other checks down with it"
726 );
727 }
728 use std::time::{Duration, Instant};
729
730 /// Concurrency proved by RENDEZVOUS, not by a stopwatch: every task must
731 /// observe all the others arrive. Were the runner serial, the first task
732 /// would wait alone, time out, and return non-zero — a failure, not a hang.
733 #[test]
734 fn run_concurrently_actually_overlaps() {
735 static ARRIVED: AtomicUsize = AtomicUsize::new(0);
736 ARRIVED.store(0, Ordering::SeqCst);
737 let names: Vec<&'static str> = vec!["a", "b", "c", "d"];
738 let n = names.len();
739
740 let out = run_concurrently(
741 &names,
742 move |_: &&str| {
743 ARRIVED.fetch_add(1, Ordering::SeqCst);
744 let deadline = Instant::now() + Duration::from_secs(10);
745 while ARRIVED.load(Ordering::SeqCst) < n {
746 if Instant::now() > deadline {
747 return 1; // never met the others — execution was serial
748 }
749 std::thread::yield_now();
750 }
751 0
752 },
753 1,
754 );
755 assert!(
756 out.iter().all(|c| *c == 0),
757 "tasks did not overlap: {out:?}"
758 );
759 }
760
761 #[test]
762 fn results_come_back_in_input_order() {
763 let names: Vec<&'static str> = vec!["first", "second", "third"];
764 let out = run_concurrently(&names, |n| if *n == "second" { 7 } else { 0 }, -1);
765 assert_eq!(out, vec![0, 7, 0], "results keep the input order");
766 }
767
768 /// The filter calls the shared resolver rather than restating it. This test
769 /// used to inline `n.contains(s)` — its own copy of the rule — and so went
770 /// on passing after the rule changed underneath it.
771 #[test]
772 fn skips_are_filtered_by_the_shared_resolver() {
773 let all = ["pre-commit-ruff", "pre-commit-prettier"];
774 let skips = ["ruff".to_string()];
775 let kept: Vec<_> = all
776 .iter()
777 .copied()
778 .filter(|n| !skips.iter().any(|s| crate::skip_suppresses(n, s)))
779 .collect();
780 assert_eq!(kept, vec!["pre-commit-prettier"]);
781 }
782}