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