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 // The version floor rides the same two call sites: once per stage,
203 // beside the other "this repository expects something you lack" lines.
204 crate::skew::announce_minimum();
205}
206
207/// Run every item concurrently and collect `(name, code)` in the INPUT order.
208///
209/// Extracted so the concurrency itself can be tested with a rendezvous instead
210/// of a stopwatch — an earlier wall-clock test was flaky the moment the machine
211/// was busy, and a threshold that trips under load teaches you to ignore it.
212fn run_concurrently<T, R, F>(items: &[T], run: F, if_thread_died: R) -> Vec<R>
213where
214 T: Sync,
215 R: Send + Sync + Clone,
216 F: Fn(&T) -> R + Sync,
217{
218 let slots: Vec<Mutex<Option<R>>> = items.iter().map(|_| Mutex::new(None)).collect();
219 std::thread::scope(|scope| {
220 for (item, slot) in items.iter().zip(&slots) {
221 let run = &run;
222 let died = &if_thread_died;
223 scope.spawn(move || {
224 // CAUGHT, not propagated. `thread::scope` re-raises a child
225 // panic in the parent, which would abort the whole hook with a
226 // backtrace and throw away the other nineteen checks' results —
227 // and would make `if_thread_died` unreachable, which is what it
228 // was until this test existed to notice.
229 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(item)))
230 .unwrap_or_else(|_| died.clone());
231 *slot.lock().expect("poisoned") = Some(outcome);
232 });
233 }
234 });
235 slots
236 .into_iter()
237 .map(|s| {
238 s.into_inner()
239 .expect("poisoned")
240 .unwrap_or_else(|| if_thread_died.clone())
241 })
242 .collect()
243}
244
245/// Take the index-fidelity hold, or say why the caller must stop.
246///
247/// Extracted from `pre_commit` so that `amont run` — which its own doc
248/// comment calls "a rehearsal of the hook" — can take exactly the same hold
249/// rather than judging the working tree while a real commit judges the index.
250///
251/// Around the WHOLE fan-out, not per check: twenty checks run concurrently and
252/// would fight over one working tree.
253fn hold_unstaged() -> Result<crate::staged_only::StagedOnly, Verdict> {
254 // BEFORE `enter()`, not after: `enter()` is what checks out the tree and
255 // parks the unstaged half, and a signal landing in the gap between that
256 // and the handler being armed would hit the default disposition — dead
257 // process, tree left checked out, nothing restored. The handler no-ops
258 // harmlessly on a signal that arrives before there is anything held.
259 crate::staged_only::install_signal_handler();
260 match crate::staged_only::StagedOnly::enter() {
261 Ok(guard) => Ok(guard),
262 Err(e) => {
263 // Refusing to check the wrong content is the safe direction; a
264 // check that read the tree would be answering about a commit
265 // nobody is making.
266 eprintln!("{e}");
267 Err(Verdict::Block)
268 }
269 }
270}
271
272pub fn pre_commit(ctx: &Ctx) -> Verdict {
273 // Before anything runs: a pinned tool at the wrong version makes every
274 // verdict below it suspect, and the warning costs one --version per pin.
275 crate::manifest::verify_tool_pins(&ctx.manifest.pins);
276 announce_policy_state(ctx.manifest);
277 let in_progress = crate::git_states_in_progress();
278 let checks = selected_during(Stage::PreCommit, &in_progress, ctx.manifest);
279
280 let held = match hold_unstaged() {
281 Ok(guard) => guard,
282 Err(verdict) => return verdict,
283 };
284
285 let severities = Overrides::read();
286 let (verdict, outcomes) = run_stage_traced(&checks, ctx, &severities);
287
288 // The shadow-mode ledger. Silent, best-effort, and never consulted by any
289 // verdict — see `crate::downgrade`.
290 crate::downgrade::note(&downgraded_events(&checks, &outcomes, &severities));
291
292 // What post-commit will bind to the commit: the gate-declared checks
293 // that RAN clean, recorded while the index still is the commit's tree.
294 // Called on every verdict — an empty record clears any leftover marker,
295 // so a blocked attempt (or a repo with nothing declared) cannot leave an
296 // earlier attempt's marker to vouch for the next commit. `Unavailable`
297 // deliberately does not qualify: a check whose tool is missing judged
298 // nothing, and stamping it would be the paper promise this exists to
299 // replace.
300 // EVERY blocking declaration, not only the npm GATE names: a custom
301 // `pre-commit check … block …` earns its stamp the same way, and a
302 // same-named pre-push declaration defers to it (see `pair_verdict`).
303 let ran: Vec<String> = if matches!(verdict, Verdict::Block) {
304 Vec::new()
305 } else {
306 crate::hooks::run_tests::blocking_commit_decls(&ctx.manifest.externals)
307 .into_iter()
308 .filter(|d| {
309 checks
310 .iter()
311 .zip(&outcomes)
312 .any(|(c, o)| c.name() == d.id && matches!(o, Outcome::Passed | Outcome::Fixed))
313 })
314 .map(|d| d.script)
315 .collect()
316 };
317 let ran: Vec<&str> = ran.iter().map(String::as_str).collect();
318 crate::gate_stamp::record(&ran);
319
320 drop(held);
321 verdict
322}
323
324/// The checks that FAILED without blocking, each with why — the shadow-mode
325/// signal [`crate::downgrade`] keeps.
326///
327/// Derived at the hook entry points and deliberately NOT inside
328/// [`run_stage_traced`], which is where `classify` already computes the same
329/// set. `run_all` reaches that function too, so recording there would let
330/// every `amont run` rehearsal inflate a ledger a lead is going to read as a
331/// count of real commits — and `run --all-files` over a dirty tree would add
332/// dozens of events for content nobody is committing.
333///
334/// A check that DECLARES `warn` is counted but is not evidence about a
335/// rollout: it was never going to block, so calling it "would have blocked"
336/// would inflate the one number the whole feature exists to produce. Only an
337/// override of a blocking check earns that.
338fn downgraded_events(
339 checks: &[&dyn Check],
340 outcomes: &[Outcome],
341 severities: &Overrides,
342) -> Vec<(String, crate::downgrade::Origin)> {
343 checks
344 .iter()
345 .zip(outcomes)
346 .filter(|(_, o)| matches!(o, Outcome::Failed))
347 .filter(|(c, _)| matches!(severities.of(**c), Severity::Warn))
348 .map(|(c, _)| (c.name().to_string(), downgrade_origin(*c, severities)))
349 .collect()
350}
351
352/// Why this check did not block. Shared, because `pre_push` fail-fasts and
353/// never builds an outcomes vector to hand to [`downgraded_events`].
354fn downgrade_origin(check: &dyn Check, severities: &Overrides) -> crate::downgrade::Origin {
355 use crate::downgrade::Origin;
356 use crate::registry::Source;
357 if matches!(check.severity(), Severity::Warn) {
358 return Origin::Declared;
359 }
360 match severities.applied_with_source(check.name()) {
361 Some((_, _, Source::Config)) => Origin::Config,
362 Some((_, _, Source::Policy)) => Origin::Policy,
363 // Declared `block`, resolved `warn`, and nothing claims to have
364 // overridden it: a contradiction. Count it, but not as evidence — the
365 // conservative direction for a number whose only failure mode is
366 // being too alarming.
367 None => Origin::Declared,
368 }
369}
370
371/// The pre-commit body, over the checks it is GIVEN.
372///
373/// A seam, so a test can hand it a check that panics. Without it the value
374/// standing in for a dead check was a literal at one call site that no test
375/// could reach — the rule was asserted on the runner and merely hoped for here.
376fn run_stage(checks: &[&dyn Check], ctx: &Ctx, severities: &Overrides) -> Verdict {
377 run_stage_traced(checks, ctx, severities).0
378}
379
380/// [`run_stage`], keeping the per-check outcomes — index-aligned with
381/// `checks` — alive past the verdict. `pre_commit` needs them to know which
382/// gate-declared checks actually ran (`gate_stamp`); `Report` cannot answer
383/// that, because `classify` deliberately drops the names of `Passed`.
384fn run_stage_traced(
385 checks: &[&dyn Check],
386 ctx: &Ctx,
387 severities: &Overrides,
388) -> (Verdict, Vec<Outcome>) {
389 if checks.is_empty() {
390 return (Verdict::Proceed, Vec::new());
391 }
392 // One slot per check: everything a check says lands in its own buffer
393 // and reaches stdout as ONE block when it finishes — see `live`. Off
394 // (`amont.progress false`), no sink is ever installed and every print
395 // streams exactly as it always did.
396 let stage = crate::live::enabled().then(|| {
397 let names: Vec<&str> = checks.iter().map(|c| c.name()).collect();
398 crate::live::Stage::begin(&names)
399 });
400 let items: Vec<(usize, &&dyn Check)> = checks.iter().enumerate().collect();
401 let outcomes = run_concurrently(
402 &items,
403 |(idx, check)| {
404 let _sink = stage.as_ref().map(|s| s.enter(*idx));
405 // The block is emitted however the check leaves — a panicking
406 // check's partial output still reaches the reader, above the
407 // dead-check verdict `run_concurrently` fills in.
408 let _flush = stage
409 .as_ref()
410 .map(|s| crate::live::FinishOnDrop::new(s, *idx));
411 let sub = Ctx {
412 name: check.name(),
413 args: ctx.args,
414 hooks_dir: ctx.hooks_dir,
415 push: ctx.push,
416 manifest: ctx.manifest,
417 };
418 check.run(&sub)
419 },
420 // A check whose thread died has not passed. Stated here, where the slot
421 // is filled, rather than hidden in a `Default` impl that every future
422 // `#[derive(Default)]` would silently inherit.
423 Outcome::Failed,
424 );
425
426 let report = classify(checks, &outcomes, severities);
427 announce(&report);
428 (report.verdict(), outcomes)
429}
430
431/// What a stage concluded, before anything is printed or exited.
432///
433/// A VALUE, so the classification can be asserted directly. While this was one
434/// function that classified, printed and returned an exit code, its tests could
435/// only check the code — whether the right thing was SAID went untested.
436#[derive(Debug, Default, PartialEq, Eq)]
437struct Report<'a> {
438 /// Repaired. The commit proceeds, but the author's files changed under
439 /// them and that must be said out loud.
440 fixed: Vec<&'a str>,
441 /// Failed, and the severity that applies blocks.
442 blocked: Vec<&'a str>,
443 /// Failed, but configured to warn. The check printed an error and meant it,
444 /// so somebody has to say it did not block.
445 downgraded: Vec<&'a str>,
446 /// Could not run. Distinct from "passed", which is the whole point.
447 unavailable: Vec<&'a str>,
448 /// How many passed outright. Only read when `amont.quiet` swallowed their
449 /// individual lines — a run that says nothing at all is indistinguishable
450 /// from a gate that never ran, and that is the one thing this crate will
451 /// not let a reader believe.
452 passed: usize,
453}
454
455impl Report<'_> {
456 fn verdict(&self) -> Verdict {
457 Verdict::blocking(!self.blocked.is_empty())
458 }
459}
460
461/// Pure: outcomes and severities in, a verdict out. No IO.
462fn classify<'a>(
463 checks: &[&'a dyn Check],
464 outcomes: &[Outcome],
465 severities: &Overrides,
466) -> Report<'a> {
467 let mut report = Report::default();
468 for (check, outcome) in checks.iter().zip(outcomes) {
469 match outcome {
470 Outcome::Passed => report.passed += 1,
471 // `Warned` needs nothing: a check that chose to warn has already
472 // said what it wanted to, and a roll-up would only repeat it.
473 Outcome::Warned => {}
474 Outcome::Fixed => report.fixed.push(check.name()),
475 Outcome::Unavailable => report.unavailable.push(check.name()),
476 Outcome::Failed => match severities.of(*check) {
477 Severity::Block => report.blocked.push(check.name()),
478 Severity::Warn => report.downgraded.push(check.name()),
479 },
480 }
481 }
482 report
483}
484
485/// Says what happened. Prints; decides nothing.
486fn announce(report: &Report) {
487 if crate::live::quiet() && report.passed > 0 {
488 println!("{} {} check(s) passed", valid_sign(), report.passed);
489 }
490 if !report.fixed.is_empty() {
491 // Louder than a pass, because files on disk are not what the author
492 // left them: they asked for the repair, but they did not watch it.
493 println!(
494 "{} {} check(s) fixed and re-staged: {}",
495 valid_sign(),
496 report.fixed.len(),
497 report.fixed.join(", ")
498 );
499 }
500 if !report.unavailable.is_empty() {
501 // Distinct from "passed". Silence here is how a repo looks verified
502 // when nothing actually ran — the trailing count is the one line
503 // guaranteed to be read, whatever the twenty blocks above said.
504 println!(
505 "{} {} check(s) could not run: {}",
506 warning_sign(),
507 report.unavailable.len(),
508 report.unavailable.join(", ")
509 );
510 }
511 if !report.downgraded.is_empty() {
512 println!(
513 "{} {} check(s) reported a problem but are set to warn: {}",
514 warning_sign(),
515 report.downgraded.len(),
516 report.downgraded.join(", ")
517 );
518 }
519 if report.blocked.is_empty() {
520 return;
521 }
522 println!("\n🚨 Error raised by:");
523 for name in &report.blocked {
524 println!(" - {}", highlight(name));
525 }
526}
527
528/// Point every check at `git ls-files` instead of the index.
529///
530/// THE definition, called from both entry points. There used to be two: this
531/// one, and a copy in `main.rs` built from a RAW `ls-files` — no `-z` — whose
532/// output git QUOTES for any unusual byte, so `é.json` arrived as the nine-byte
533/// literal `"\303\251.json"` and was handed to prettier and eslint as a path
534/// that does not exist. And because `override_file_set` writes a `OnceLock`,
535/// main's quoted list WON: whichever ran first was the one that counted, and
536/// main's ran first. `git.rs` documents this exact failure.
537pub fn enter_all_files_mode() {
538 crate::hooks::common::override_file_set(
539 crate::git::stdout_paths(&["ls-files"]).unwrap_or_default(),
540 );
541}
542
543/// `amont run` — every applicable check, on demand.
544///
545/// Two questions, and the mode says which it answers:
546///
547/// - **staged** (default) is "would my commit pass" — the same set a commit
548/// would check, so it is a rehearsal of the hook, and it takes the same
549/// index-fidelity hold the hook takes.
550/// - **`--all-files`** is "does my working tree pass". Deliberately NOT the same
551/// question: on a dirty tree it reports on content that is not committed and
552/// may never be. That is right for adopting a check into an existing
553/// repository, where `git add .` is not an acceptable way to measure the mess,
554/// and it is why `--all-files` takes no stash — there is no staged/unstaged
555/// distinction to protect when the answer is "all of it".
556pub fn run_all(ctx: &Ctx, all_files: bool) -> Verdict {
557 // ORDER: the override goes in FIRST. It is what tells `fixing_enabled` and
558 // `restage` that the file set is not the index, and both are consulted
559 // from inside the checks below.
560 if all_files {
561 enter_all_files_mode();
562 if crate::hooks::common::fixing_requested() {
563 println!(
564 "{} {} is set, but fixing is off for {}: the input set is the \
565 working tree, not the index",
566 warning_sign(),
567 highlight("amont.fix"),
568 highlight("--all-files")
569 );
570 }
571 // Stash-free, per decision 1 of docs/index-fidelity-and-run-modes.md:
572 // there is no staged/unstaged distinction to protect when the input
573 // set is `git ls-files`, so a hold would be surprising extra mutation
574 // with no correctness upside.
575 return run_stage(
576 &selected(Stage::PreCommit, ctx.manifest),
577 ctx,
578 &Overrides::read(),
579 );
580 }
581
582 // Staged mode IS a rehearsal of the commit, so it takes the same hold the
583 // commit does. Without it, `amont run` failed on garbage in the tree
584 // that `git commit` — which holds the unstaged half aside — passed, and
585 // vice versa: the two modes disagreed about the same repository, which is
586 // exactly what this mode exists not to do.
587 let held = match hold_unstaged() {
588 Ok(guard) => guard,
589 Err(verdict) => return verdict,
590 };
591 let verdict = run_stage(
592 &selected(Stage::PreCommit, ctx.manifest),
593 ctx,
594 &Overrides::read(),
595 );
596 // AFTER the report has been printed: dropping earlier would put the
597 // unstaged content back under a check that is still reading files.
598 drop(held);
599 verdict
600}
601
602/// `amont run <check>` — one check by name. `None` when there is no such
603/// check, which the caller turns into a usage error.
604///
605/// Lives here rather than in `main.rs` so `registry::lookup` stays inside the
606/// runtime, and so the hold decision is made once: a named check takes the
607/// index-fidelity hold only when it is a `Stage::PreCommit` check running in
608/// staged mode. A pre-push or commit-msg check invoked by name must never
609/// touch the working tree — nothing about a push is a staging operation.
610/// Resolve what `amont run <name>` means, exactly as `hook.skip` resolves a
611/// name — the rest of the tool taught `ban-terms`; making `run` demand the
612/// full id was a pointless second vocabulary. Ambiguity is an answer, not a
613/// guess: the two `branch-pattern` checks are different code at different
614/// stages.
615///
616/// Public because the CALLER needs the answer before anything else happens:
617/// main decides whether to synthesize push refs from the resolved name, and
618/// an ambiguous name must say so rather than fail on a missing upstream it
619/// was never going to use.
620pub fn resolve_check_name(name: &str, manifest: &crate::manifest::Manifest) -> Named2 {
621 if crate::registry::lookup(name, manifest).is_some() {
622 return Named2::Resolved(name.to_string());
623 }
624 let mut matches: Vec<String> = crate::registry::CHECKS
625 .iter()
626 .map(|c| c.name.to_string())
627 .chain(manifest.externals.iter().map(|e| e.id.clone()))
628 .filter(|id| crate::skip_suppresses(id, name))
629 .collect();
630 matches.dedup();
631 match matches.len() {
632 0 => Named2::Unknown,
633 1 => Named2::Resolved(matches.remove(0)),
634 _ => Named2::Ambiguous(matches),
635 }
636}
637
638/// How a run name resolved.
639pub enum Named2 {
640 Resolved(String),
641 Unknown,
642 Ambiguous(Vec<String>),
643}
644
645pub fn run_named(ctx: &Ctx, name: &str, all_files: bool) -> Named {
646 let full: String = match resolve_check_name(name, ctx.manifest) {
647 Named2::Resolved(id) => id,
648 Named2::Unknown => return Named::Unknown,
649 Named2::Ambiguous(ids) => return Named::Ambiguous(ids),
650 };
651 let name = full.as_str();
652 let Some(run_check) = crate::registry::lookup(name, ctx.manifest) else {
653 return Named::Unknown;
654 };
655 // The Ctx must carry the RESOLVED id: lookup's closure re-resolves
656 // through `ctx.name`, and handing it the short name back would panic on
657 // the very ambiguity this function just settled.
658 let ctx = &Ctx {
659 name,
660 args: ctx.args,
661 hooks_dir: ctx.hooks_dir,
662 push: ctx.push,
663 manifest: ctx.manifest,
664 };
665 if all_files {
666 enter_all_files_mode();
667 return Named::Ran(run_check(ctx));
668 }
669 let is_pre_commit_check = crate::registry::one_named(name, ctx.manifest)
670 .is_some_and(|c| c.stage() == Stage::PreCommit);
671 if !is_pre_commit_check {
672 return Named::Ran(run_check(ctx));
673 }
674 let held = match hold_unstaged() {
675 Ok(guard) => guard,
676 Err(verdict) => return Named::Ran(verdict),
677 };
678 let verdict = run_check(ctx);
679 drop(held);
680 Named::Ran(verdict)
681}
682
683/// What `run_named` resolved a name to.
684pub enum Named {
685 Ran(Verdict),
686 /// Nothing matches — full id, short name, or entrypoint.
687 Unknown,
688 /// A short name that reaches more than one check; the caller lists them
689 /// so the user can pick a full id.
690 Ambiguous(Vec<String>),
691}
692
693pub fn pre_push(ctx: &Ctx) -> Verdict {
694 // The notes push `attest` makes re-enters this hook; its ref list is only
695 // ever the attest ref, so there is nothing to prove — and proving it
696 // would recurse.
697 if crate::attest::push_guard_active() {
698 return Verdict::Proceed;
699 }
700 crate::manifest::verify_tool_pins(&ctx.manifest.pins);
701 announce_policy_state(ctx.manifest);
702 // NB: no CHERRY_PICK_HEAD check here — the zsh pre-push had none either.
703 let severities = Overrides::read();
704 // pre-push had NO state guard at all, with a comment admitting it existed
705 // only because the zsh version had none. Now it asks the same question
706 // pre-commit does and each check answers for itself.
707 let in_progress = crate::git_states_in_progress();
708 let pre_push_checks = selected_during(Stage::PrePush, &in_progress, ctx.manifest);
709 let stage = crate::live::enabled().then(|| {
710 let names: Vec<&str> = pre_push_checks.iter().map(|c| c.name()).collect();
711 crate::live::Stage::begin(&names)
712 });
713 // What actually PASSED, for the attestation at the bottom. `Warned` and
714 // `Unavailable` stay out — "could not run" is not "passed" — and a
715 // commit-time-gated pair counts, because its stamps say the check ran on
716 // every pushed tree.
717 let mut passed: Vec<String> = Vec::new();
718 // Accumulated rather than written per check: one append at the end costs a
719 // single file open, and the loop below can leave early.
720 let mut downgraded: Vec<(String, crate::downgrade::Origin)> = Vec::new();
721 // PUSH STAMPS. The tips being pushed, and what earlier runs of THIS gate
722 // recorded against their trees — an `amont run pre-push` rehearsal, or a
723 // push whose gate passed and whose transport then died. A scoped gate
724 // (a test suite: its verdict is a function of the tree alone) whose
725 // stamp sits on every tip is not run again; the unscoped ones
726 // (branch-protect, secrets — questions about the push, not the content)
727 // always run. `amont.pushStamps false` turns the reuse off.
728 //
729 // Reading `ctx.push` here may consume stdin, exactly as `attest` does
730 // below; every pre-push check reads it anyway.
731 let tips: Vec<String> = {
732 let mut t: Vec<String> = ctx
733 .push
734 .get()
735 .iter()
736 .filter(|r| !r.local_oid.chars().all(|c| c == '0'))
737 .map(|r| r.local_oid.clone())
738 .collect();
739 t.dedup();
740 t
741 };
742 let reuse_stamps = crate::gate_stamp::push_stamps_enabled();
743 // The working tree AS THE SUITE WILL BE HANDED IT, captured before any
744 // gate has run.
745 //
746 // Timing is the point. Asking afterwards means a gate that modifies a
747 // tracked file — a formatter, a suite that updates a snapshot fixture —
748 // disqualifies its OWN stamp. What a stamp needs to know is what the
749 // suite could READ, which is the state it was handed; a change the suite
750 // itself made was never an input to it.
751 //
752 // Tracked modifications only. `stamp_tips` argues that gap, which is a
753 // deliberate one.
754 let tree_at_start = if reuse_stamps {
755 crate::git::stdout(&["status", "--porcelain", "--untracked-files=no"]).unwrap_or_default()
756 } else {
757 String::new()
758 };
759 // A background rehearsal of one of these tips may be mid-suite right
760 // now. Waiting for it is strictly less work than starting over, and
761 // its stamp — read AFTER the wait, below — is the hand-off.
762 if reuse_stamps && !tips.is_empty() {
763 crate::rehearsal::await_for(&tips);
764 }
765 let push_stamps = if reuse_stamps && !tips.is_empty() {
766 crate::gate_stamp::stamps_for(&tips)
767 } else {
768 Default::default()
769 };
770 // Inside a rehearsal snapshot only the content gates make sense: the
771 // unscoped checks ask about a PUSH — its branch name, its target, its
772 // secrets — and no push is happening. Said once, not per check.
773 let rehearsing = crate::rehearsal::in_snapshot();
774 if rehearsing {
775 crate::say!(
776 "rehearsal: running the test gates only — the push-shaped checks run at push time"
777 );
778 }
779 let stamped_on_every_tip = |name: &str| -> bool {
780 !tips.is_empty()
781 && tips.iter().all(|t| {
782 push_stamps
783 .get(t)
784 .is_some_and(|s| s.iter().any(|g| g == name))
785 })
786 };
787 // What actually RAN and passed here (as opposed to being vouched for by
788 // a stamp) — the set this run may stamp in turn.
789 let mut ran_and_passed: Vec<String> = Vec::new();
790 for (idx, check) in pre_push_checks.iter().enumerate() {
791 let _sink = stage.as_ref().map(|s| s.enter(idx));
792 let _flush = stage
793 .as_ref()
794 .map(|s| crate::live::FinishOnDrop::new(s, idx));
795 // A declared pre-push external whose NAME is also declared at
796 // pre-commit (blocking) is a gate pair: the commit-time side earned
797 // per-commit stamps, and this side runs only for pushes carrying
798 // commits with no record of it — the same contract the npm gate has
799 // always had, for vocabularies npm never heard of (`cargo test`,
800 // `pytest`, anything). Messages mirror the npm gate's exactly;
801 // docs/checks.md quotes them.
802 // The push side of a pair is `(name, scope)`, from whichever of the
803 // two kinds of check this is. A declared pre-push external supplies
804 // its own; anything else is a BUILT-IN, and `pairing_name` plus the
805 // registry's scope say the same two things about it.
806 //
807 // The external is tried FIRST and its inputs are unchanged, so the
808 // declared path behaves exactly as before — that is what the
809 // untouched declared-pair tests prove.
810 if rehearsing && check.scope().is_unscoped() {
811 continue;
812 }
813 if !check.scope().is_unscoped() && stamped_on_every_tip(check.name()) {
814 crate::say!(
815 "{} {} passed on this exact tree earlier — not repeating it here",
816 valid_sign(),
817 highlight(check.name()),
818 );
819 passed.push(check.name().to_string());
820 continue;
821 }
822 let declared = ctx
823 .manifest
824 .externals
825 .iter()
826 .find(|e| e.stage == Stage::PrePush && e.id == check.name());
827 let builtin_scope = check.scope();
828 let pairing: Option<(&str, &crate::check::Scope)> = match declared {
829 Some(ext) => match &ext.kind {
830 crate::manifest::Kind::Runnable { scope, .. } => {
831 Some((ext.short_name.as_str(), scope))
832 }
833 // A declared pre-push entry that runs nothing has no scope to
834 // judge with, and is not a built-in either. Nothing to pair.
835 _ => None,
836 },
837 None => Some((check.pairing_name(), &builtin_scope)),
838 };
839 if let Some((name, push_scope)) = pairing {
840 match crate::hooks::run_tests::pair_verdict(name, push_scope, ctx.manifest, ctx.push) {
841 crate::hooks::run_tests::PairVerdict::Gated => {
842 crate::say!(
843 "{} {} gated at commit instead — not repeating it here",
844 valid_sign(),
845 highlight(name),
846 );
847 passed.push(check.name().to_string());
848 continue;
849 }
850 crate::hooks::run_tests::PairVerdict::Unstamped(n) => {
851 crate::say!(
852 "{} {} is declared at commit time, but {n} pushed \
853 commit{} carr{} no record of it — running it here",
854 warning_sign(),
855 name,
856 if n == 1 { "" } else { "s" },
857 if n == 1 { "ies" } else { "y" },
858 );
859 }
860 crate::hooks::run_tests::PairVerdict::NotPaired => {}
861 }
862 }
863 let sub = Ctx {
864 name: check.name(),
865 args: ctx.args,
866 hooks_dir: ctx.hooks_dir,
867 push: ctx.push,
868 manifest: ctx.manifest,
869 };
870 match check.run(&sub) {
871 Outcome::Passed => {
872 passed.push(check.name().to_string());
873 if !check.scope().is_unscoped() {
874 ran_and_passed.push(check.name().to_string());
875 }
876 }
877 // Announced, never fatal: a check that could not run has not
878 // invalidated anything, and neither has a warning.
879 Outcome::Unavailable => {
880 println!(
881 "{} {} could not run",
882 warning_sign(),
883 highlight(check.name())
884 )
885 }
886 Outcome::Warned => {}
887 // Cannot occur: `Fix::Rewrite` is refused on a pre-push
888 // declaration, so nothing here can repair anything.
889 Outcome::Fixed => {}
890 Outcome::Failed => match severities.of(*check) {
891 Severity::Warn => {
892 downgraded.push((
893 check.name().to_string(),
894 downgrade_origin(*check, &severities),
895 ));
896 println!(
897 "{} {} reported a problem (severity warn)",
898 warning_sign(),
899 highlight(check.name())
900 );
901 }
902 // Fail-fast applies ONLY to Block: the later steps are
903 // expensive and their preconditions are gone.
904 Severity::Block => {
905 // Record what already warned before leaving. Those checks
906 // ran and reported; a later check blocking does not unmake
907 // them, and dropping them here would make the ledger quietly
908 // under-count every push that ended badly.
909 crate::downgrade::note(&downgraded);
910 println!("\n🚨 Error raised by hook {}", highlight(check.name()));
911 return Verdict::Block;
912 }
913 },
914 }
915 }
916 // Every block gate passed — stamp the tips with the scoped gates that
917 // RAN here, so the next push of this content (a retry after a dropped
918 // connection, or the real push after an `amont run pre-push` rehearsal)
919 // skips them. Only when what ran is what is being pushed: with
920 // `amont.testPushedTree` the suite ran on the tip itself — unless the
921 // snapshot could not be made, which `stamp_tips` asks about rather than
922 // assuming; otherwise it ran on the working tree, which vouches for the
923 // tip only when the two are the same content — HEAD, with nothing
924 // modified and nothing untracked.
925 // The same proxy `attestable` uses, for the same reason: a scoped gate
926 // whose files the push never touched returns `Passed` having run
927 // nothing, and a stamp for THAT would let a later push that does touch
928 // them skip a suite nobody ran.
929 if reuse_stamps && !ran_and_passed.is_empty() {
930 let changed = crate::pushrefs::changed_files(ctx.push.get());
931 let really_ran: Vec<String> = pre_push_checks
932 .iter()
933 .filter(|c| ran_and_passed.iter().any(|p| p == c.name()))
934 .filter(|c| c.scope().touches(&changed))
935 .map(|c| c.name().to_string())
936 .collect();
937 stamp_tips(&tips, &really_ran, &tree_at_start);
938 }
939 // …and say so to CI, if this repository opted in.
940 // Gated behind `enabled()` HERE, not just inside `attest_push`: reading
941 // `ctx.push` may consume stdin, and a disabled repo should leave stdin
942 // exactly as it found it.
943 if !passed.is_empty() && crate::attest::enabled() {
944 let remote = ctx
945 .args
946 .first()
947 .map(|a| a.to_string_lossy().into_owned())
948 .unwrap_or_default();
949 let changed = crate::pushrefs::changed_files(ctx.push.get());
950 let vouched = attestable(&pre_push_checks, &passed, &changed);
951 crate::attest::attest_push(&remote, ctx.push.get(), &vouched);
952 }
953 crate::downgrade::note(&downgraded);
954 Verdict::Proceed
955}
956
957/// The scoped pre-push gates — test suites — that have work to do for a
958/// push that changed `changed`: what a rehearsal would run, and therefore
959/// what its stamp would have to name before a push may skip anything.
960///
961/// The same two filters `pre_push` applies before stamping (selected here,
962/// and `scope().touches` the change), so the rehearsal's idea of "nothing to
963/// do" is the push's idea of "nothing to stamp".
964pub fn scoped_push_gates(manifest: &crate::manifest::Manifest, changed: &[String]) -> Vec<String> {
965 let in_progress = crate::git_states_in_progress();
966 selected_during(Stage::PrePush, &in_progress, manifest)
967 .into_iter()
968 .filter(|c| !c.scope().is_unscoped() && c.scope().touches(changed))
969 .map(|c| c.name().to_string())
970 .collect()
971}
972
973/// Push-stamp every tip whose content is what the gates actually tested.
974///
975/// See the comment at the call site for the two cases. Silent when nothing
976/// qualifies — a dirty working tree is the ordinary state of a machine
977/// mid-work, and a note on every push would teach people to ignore it.
978fn stamp_tips(tips: &[String], gates: &[String], tree_at_start: &str) {
979 let pushed_tree_mode = crate::pushed_tree::enabled();
980 let head = crate::git::stdout(&["rev-parse", "HEAD"]);
981 // TRACKED modifications only — a KNOWN gap, kept deliberately, and
982 // spelled out because the comment that used to sit here argued it
983 // backwards ("untracked files are not in any tree the suite could have
984 // been asked about"). That is not the reason. The danger is not that the
985 // tree lacks them, it is that the RUN had them: a new test file, a
986 // fixture, a local `.env`, present while the suite ran and absent from
987 // the tree the stamp vouches for.
988 //
989 // Counting them was tried, and is worse than the gap. Gates leave
990 // artefacts — a log, a coverage directory, whatever a declared
991 // `amont.conf` command writes — and nothing cleans them up, so a
992 // repository using declared gates would stop earning stamps permanently
993 // after its first commit. That does not merely lose an optimisation: it
994 // puts the suite back INSIDE the push, which is the failure the whole
995 // stamping mechanism exists to prevent. amont's own
996 // `a_push_stamp_merges_with_a_commit_time_stamp` fixture is exactly that
997 // shape, and CI is where it surfaced — a `*.log` line in one developer's
998 // global gitignore had hidden it on the machine that wrote the change.
999 //
1000 // `amont.testPushedTree true` closes the gap properly for anyone who
1001 // wants it closed: it runs the suite in a checkout of the commit, where
1002 // no untracked file exists to be read.
1003 //
1004 // `tree_at_start` — not a fresh `git status`. The gates have run by now
1005 // and may have written into the tree; what a stamp needs to know is what
1006 // the suite could READ, which is the state it was handed. See the
1007 // capture in `pre_push`.
1008 let worktree_clean = tree_at_start.trim().is_empty();
1009 let in_snapshot = crate::rehearsal::in_snapshot();
1010 let mut stamped: Vec<&str> = Vec::new();
1011 for tip in tips {
1012 // `pushed_tree_mode` is the CONFIG, not what happened. A snapshot
1013 // that could not be created falls back to the working tree and says
1014 // so, and stamping on the strength of the flag vouched for content
1015 // the suite never saw. `pushed_tree::fell_back` is what actually
1016 // happened.
1017 let snapshot_ran = pushed_tree_mode && !crate::pushed_tree::fell_back(tip);
1018 // Inside a rehearsal the working tree IS a checkout git made of this
1019 // commit, so it is the tip's content by construction — and whatever
1020 // `amont.snapshotPrepare` had to add to make it runnable (a
1021 // `node_modules`, a virtualenv) is not a reason to distrust it. That
1022 // is the same argument `snapshot_ran` makes for the pushed-tree mode.
1023 let is_head = head.as_deref() == Some(tip.as_str());
1024 let vouchable = snapshot_ran || (is_head && (in_snapshot || worktree_clean));
1025 if !vouchable {
1026 continue;
1027 }
1028 let spec = format!("{tip}^{{tree}}");
1029 let Some(tree) = crate::git::stdout(&["rev-parse", &spec]) else {
1030 continue;
1031 };
1032 if crate::gate_stamp::stamp_push(tip, &tree, gates) {
1033 stamped.push(tip);
1034 }
1035 }
1036 if !stamped.is_empty() {
1037 crate::say!(
1038 "{} stamped {} for this tree — the next push of it skips them ({})",
1039 valid_sign(),
1040 highlight(&gates.join(" ")),
1041 crate::gate_stamp::NOTES_REF,
1042 );
1043 }
1044}
1045
1046/// Of the checks that passed, the ones an attestation may actually VOUCH for.
1047///
1048/// A language gate whose scope the push never touched returns `Passed` having
1049/// run nothing — `cargo_test` walks its refs, finds no crate root, and falls
1050/// out of the loop green. That is right for a push gate (there was nothing to
1051/// object to) and wrong for an attestation: a JS-only push was minting
1052/// `gates … pre-push-cargo-test pre-push-go-test pre-push-pytest`, and in a
1053/// MIXED repository CI would then skip a suite that nobody ran on that tree.
1054///
1055/// The declared `scope` is the honest filter, and the same data `amont list`
1056/// already reports. Unscoped checks (`Scope::ALWAYS` — branch-protect,
1057/// secrets) match everything and are vouched for, which is accurate: they
1058/// really did run. An empty `changed` vouches for nothing scoped, which is
1059/// the safe direction — CI runs the suite.
1060///
1061/// [`Scope::touches`], NOT `Scope::matches`. `matches` also asks whether the
1062/// repository has opted in — whether a `Cargo.toml` exists — and asking that
1063/// of a push DIFF can only answer no unless the push happened to touch the
1064/// marker. So `pre-push-cargo-test` was vouched for by a push that edited
1065/// `Cargo.toml` and never by one that edited only `.rs` files, which is the
1066/// ordinary case and the one worth skipping CI for. The feature attested
1067/// almost nothing, silently, and looked like it worked.
1068///
1069/// WHAT THIS IS STILL A PROXY FOR, stated plainly because the trust path
1070/// deserves it: the honest question is "did this gate actually run", and no
1071/// gate reports that. `rust_tools::test` returns `Passed` whether it ran a
1072/// suite or found no crate root and fell out of its loop — the very thing
1073/// the first paragraph describes. Scope is the closest available stand-in.
1074/// It is now a good one: a gate is vouched for only if the push changed a
1075/// file its extensions cover.
1076///
1077/// The gap that remains is narrow and one-directional: a `.rs` file outside
1078/// every crate would be covered here while `cargo test` had nothing to say
1079/// about it. Closing it properly means an outcome that distinguishes "ran
1080/// and passed" from "found nothing to do", which is a change to every gate
1081/// and to `Outcome` itself — worth doing, not worth smuggling into this.
1082fn attestable(checks: &[&dyn Check], passed: &[String], changed: &[String]) -> Vec<String> {
1083 checks
1084 .iter()
1085 .filter(|c| passed.iter().any(|p| p == c.name()))
1086 .filter(|c| c.scope().touches(changed))
1087 .map(|c| c.name().to_string())
1088 .collect()
1089}
1090
1091#[cfg(test)]
1092mod tests {
1093 use super::*;
1094 use crate::check::{Builtin, Scope};
1095 use std::sync::atomic::{AtomicUsize, Ordering};
1096
1097 /// A check whose only job is to carry a name and a severity into `report`.
1098 /// Its `run` is never called — `report` is fed outcomes directly, which is
1099 /// what makes `Unavailable` testable at all: the real thing needs a missing
1100 /// binary, and a test that uninstalls the developer's toolchain is worse
1101 /// than no test.
1102 const fn stub(name: &'static str, severity: Severity) -> Builtin {
1103 Builtin {
1104 name,
1105 stage: Stage::PreCommit,
1106 scope: Scope::ALWAYS,
1107 severity,
1108 run: |_| Outcome::Passed,
1109 fix: crate::check::Fix::None,
1110 reach: crate::check::Reach::Convention,
1111 }
1112 }
1113
1114 /// A pre-push gate with an OPT-IN file, as the real Rust and Python
1115 /// gates have: `.rs` files, but only where a `Cargo.toml` exists.
1116 const fn opt_in(
1117 name: &'static str,
1118 exts: &'static [&'static str],
1119 names: &'static [&'static str],
1120 ) -> Builtin {
1121 Builtin {
1122 name,
1123 stage: Stage::PrePush,
1124 scope: Scope::new(exts, names),
1125 severity: Severity::Block,
1126 run: |_| Outcome::Passed,
1127 fix: crate::check::Fix::None,
1128 reach: crate::check::Reach::Convention,
1129 }
1130 }
1131
1132 /// A pre-push gate scoped to one language's files.
1133 const fn scoped(name: &'static str, exts: &'static [&'static str]) -> Builtin {
1134 Builtin {
1135 name,
1136 stage: Stage::PrePush,
1137 scope: Scope::new(exts, &[]),
1138 severity: Severity::Block,
1139 run: |_| Outcome::Passed,
1140 fix: crate::check::Fix::None,
1141 reach: crate::check::Reach::Convention,
1142 }
1143 }
1144
1145 /// The over-claim this filter exists to stop, caught in the wild: a
1146 /// JS-only push minted `gates … pre-push-cargo-test pre-push-go-test
1147 /// pre-push-pytest`, because each of those gates finds nothing of its
1148 /// language to do and returns `Passed` having run NOTHING. Harmless in a
1149 /// single-language repo, unsound in a mixed one — CI would skip a suite
1150 /// nobody ran on that tree.
1151 #[test]
1152 fn a_gate_whose_language_the_push_never_touched_is_not_vouched_for() {
1153 let js = scoped("pre-push-run-tests-js", &[".ts", ".js"]);
1154 let rust = scoped("pre-push-cargo-test", &[".rs"]);
1155 let py = scoped("pre-push-pytest", &[".py"]);
1156 let always = stub("pre-push-secrets", Severity::Block);
1157 let checks: Vec<&dyn Check> = vec![&js, &rust, &py, &always];
1158 let passed: Vec<String> = checks.iter().map(|c| c.name().to_string()).collect();
1159
1160 let changed = vec!["app/routes/home.ts".to_string()];
1161 let vouched = attestable(&checks, &passed, &changed);
1162 assert_eq!(
1163 vouched,
1164 vec![
1165 "pre-push-run-tests-js".to_string(),
1166 "pre-push-secrets".to_string()
1167 ],
1168 "only the gate that had work, plus the unscoped one that always runs"
1169 );
1170
1171 // Nothing computed about the push vouches for nothing scoped — the
1172 // safe direction, since CI then runs the suite.
1173 assert_eq!(
1174 attestable(&checks, &passed, &[]),
1175 vec!["pre-push-secrets".to_string()]
1176 );
1177
1178 // A check that did NOT pass is never vouched for, whatever its scope.
1179 let only_rust_passed = vec!["pre-push-cargo-test".to_string()];
1180 assert!(attestable(&checks, &only_rust_passed, &changed).is_empty());
1181 }
1182
1183 /// No overrides configured. `report` takes them as a VALUE now, so its
1184 /// tests need no repository and no git at all.
1185 fn none() -> Overrides {
1186 Overrides::default()
1187 }
1188
1189 static BLOCKER: Builtin = stub("stub-blocker", Severity::Block);
1190 static WARNER: Builtin = stub("stub-warner", Severity::Warn);
1191
1192 /// The unit tests hold `&dyn Check` for the same reason the dispatcher
1193 /// does: `report` must not be able to tell a built-in from an external.
1194 const fn as_checks(cs: [&'static Builtin; 3]) -> [&'static dyn Check; 3] {
1195 [cs[0], cs[1], cs[2]]
1196 }
1197
1198 /// The classification itself, which used to be unreachable: while one
1199 /// function classified AND printed AND returned a code, a test could assert
1200 /// the code and nothing else.
1201 #[test]
1202 fn every_outcome_lands_in_the_right_bucket() {
1203 let checks: [&dyn Check; 4] = [&BLOCKER, &BLOCKER, &WARNER, &BLOCKER];
1204 let got = classify(
1205 &checks,
1206 &[
1207 Outcome::Passed,
1208 Outcome::Unavailable,
1209 Outcome::Failed,
1210 Outcome::Failed,
1211 ],
1212 &none(),
1213 );
1214 assert_eq!(got.blocked, ["stub-blocker"], "{got:?}");
1215 assert_eq!(got.downgraded, ["stub-warner"], "{got:?}");
1216 assert_eq!(got.unavailable, ["stub-blocker"], "{got:?}");
1217 }
1218
1219 /// A clean stage concludes nothing at all — not an empty message, no
1220 /// message. Twenty checks that passed should print no roll-ups.
1221 ///
1222 /// `passed` is a tally, not a roll-up. It exists so that `amont.quiet` can
1223 /// say how many lines it swallowed, and it must never be the reason this
1224 /// report looks like it has something to announce.
1225 #[test]
1226 fn a_clean_stage_has_nothing_to_report() {
1227 let checks: [&dyn Check; 2] = [&BLOCKER, &WARNER];
1228 let got = classify(&checks, &[Outcome::Passed, Outcome::Warned], &none());
1229 assert!(
1230 got.fixed.is_empty()
1231 && got.blocked.is_empty()
1232 && got.downgraded.is_empty()
1233 && got.unavailable.is_empty(),
1234 "a clean stage found something to announce: {got:?}"
1235 );
1236 assert_eq!(got.passed, 1, "the pass was not tallied: {got:?}");
1237 assert_eq!(got.verdict(), Verdict::Proceed);
1238 }
1239
1240 #[test]
1241 fn a_blocking_failure_is_the_only_thing_that_fails_the_commit() {
1242 let b: &dyn Check = &BLOCKER;
1243 let w: &dyn Check = &WARNER;
1244 assert_eq!(
1245 classify(&[b], &[Outcome::Failed], &none()).verdict(),
1246 Verdict::Block
1247 );
1248 assert_eq!(
1249 classify(&[b], &[Outcome::Passed], &none()).verdict(),
1250 Verdict::Proceed
1251 );
1252 // Every non-blocking shape, one at a time, so a regression cannot hide
1253 // behind a passing sibling.
1254 assert_eq!(
1255 classify(&[b], &[Outcome::Warned], &none()).verdict(),
1256 Verdict::Proceed
1257 );
1258 assert_eq!(
1259 classify(&[b], &[Outcome::Unavailable], &none()).verdict(),
1260 Verdict::Proceed
1261 );
1262 assert_eq!(
1263 classify(&[w], &[Outcome::Failed], &none()).verdict(),
1264 Verdict::Proceed
1265 );
1266 }
1267
1268 #[test]
1269 fn one_blocking_failure_among_many_still_fails() {
1270 let checks = as_checks([&BLOCKER, &WARNER, &BLOCKER]);
1271 assert_eq!(
1272 classify(
1273 &checks,
1274 &[Outcome::Unavailable, Outcome::Failed, Outcome::Failed],
1275 &none()
1276 )
1277 .verdict(),
1278 Verdict::Block
1279 );
1280 // Same shape, with the only *blocking* failure removed.
1281 assert_eq!(
1282 classify(
1283 &checks,
1284 &[Outcome::Unavailable, Outcome::Failed, Outcome::Passed],
1285 &none()
1286 )
1287 .verdict(),
1288 Verdict::Proceed
1289 );
1290 }
1291
1292 /// The slot of a check whose thread died. Reading that as a pass is how a
1293 /// crash becomes a green commit.
1294 ///
1295 /// Asserted through the RUNNER, not through a `Default` impl: the rule
1296 /// belongs to this call site, and a test on `Outcome::default()` proved
1297 /// only that a trait impl existed, not that the runner used it.
1298 /// A check that PANICS must fail the commit, not pass it — and must not
1299 /// take the other checks down with it.
1300 ///
1301 /// Driven through the stage body rather than the runner, because the value
1302 /// that stands in for a dead check is chosen at the call site and the
1303 /// runner's own test cannot see that choice.
1304 #[test]
1305 fn a_panicking_check_blocks_the_commit() {
1306 static DIES: Builtin = Builtin {
1307 name: "stub-dies",
1308 stage: Stage::PreCommit,
1309 scope: Scope::ALWAYS,
1310 severity: Severity::Block,
1311 run: |_| panic!("this check died"),
1312 fix: crate::check::Fix::None,
1313 reach: crate::check::Reach::Convention,
1314 };
1315 let hook = std::panic::take_hook();
1316 std::panic::set_hook(Box::new(|_| {}));
1317 let push = crate::pushrefs::PushRefs::default();
1318 let manifest = crate::manifest::Manifest::default();
1319 let ctx = Ctx {
1320 name: "pre-commit",
1321 args: &[],
1322 hooks_dir: std::path::Path::new("."),
1323 push: &push,
1324 manifest: &manifest,
1325 };
1326 let verdict = run_stage(&[&DIES], &ctx, &none());
1327 std::panic::set_hook(hook);
1328 assert_eq!(
1329 verdict,
1330 Verdict::Block,
1331 "a check that died must not let the commit through"
1332 );
1333 }
1334
1335 #[test]
1336 fn a_thread_that_dies_leaves_a_failure_behind() {
1337 // The default hook would print a backtrace for the deliberate panic and
1338 // make a passing run look broken.
1339 let hook = std::panic::take_hook();
1340 std::panic::set_hook(Box::new(|_| {}));
1341 let items = ["a", "b", "c"];
1342 let out = run_concurrently(
1343 &items,
1344 |n: &&str| {
1345 if *n == "b" {
1346 panic!("this check died");
1347 }
1348 Outcome::Passed
1349 },
1350 Outcome::Failed,
1351 );
1352 std::panic::set_hook(hook);
1353 assert_eq!(
1354 out,
1355 vec![Outcome::Passed, Outcome::Failed, Outcome::Passed],
1356 "a dead check must not read as one that passed, \
1357 and must not take the other checks down with it"
1358 );
1359 }
1360 use std::time::{Duration, Instant};
1361
1362 /// Concurrency proved by RENDEZVOUS, not by a stopwatch: every task must
1363 /// observe all the others arrive. Were the runner serial, the first task
1364 /// would wait alone, time out, and return non-zero — a failure, not a hang.
1365 #[test]
1366 fn run_concurrently_actually_overlaps() {
1367 static ARRIVED: AtomicUsize = AtomicUsize::new(0);
1368 ARRIVED.store(0, Ordering::SeqCst);
1369 let names: Vec<&'static str> = vec!["a", "b", "c", "d"];
1370 let n = names.len();
1371
1372 let out = run_concurrently(
1373 &names,
1374 move |_: &&str| {
1375 ARRIVED.fetch_add(1, Ordering::SeqCst);
1376 let deadline = Instant::now() + Duration::from_secs(10);
1377 while ARRIVED.load(Ordering::SeqCst) < n {
1378 if Instant::now() > deadline {
1379 return 1; // never met the others — execution was serial
1380 }
1381 std::thread::yield_now();
1382 }
1383 0
1384 },
1385 1,
1386 );
1387 assert!(
1388 out.iter().all(|c| *c == 0),
1389 "tasks did not overlap: {out:?}"
1390 );
1391 }
1392
1393 #[test]
1394 fn results_come_back_in_input_order() {
1395 let names: Vec<&'static str> = vec!["first", "second", "third"];
1396 let out = run_concurrently(&names, |n| if *n == "second" { 7 } else { 0 }, -1);
1397 assert_eq!(out, vec![0, 7, 0], "results keep the input order");
1398 }
1399
1400 /// The filter calls the shared resolver rather than restating it. This test
1401 /// used to inline `n.contains(s)` — its own copy of the rule — and so went
1402 /// on passing after the rule changed underneath it.
1403 #[test]
1404 fn skips_are_filtered_by_the_shared_resolver() {
1405 let all = ["pre-commit-ruff", "pre-commit-prettier"];
1406 let skips = ["ruff".to_string()];
1407 let kept: Vec<_> = all
1408 .iter()
1409 .copied()
1410 .filter(|n| !skips.iter().any(|s| crate::skip_suppresses(n, s)))
1411 .collect();
1412 assert_eq!(kept, vec!["pre-commit-prettier"]);
1413 }
1414
1415 /// The ordinary Rust push: source changed, `Cargo.toml` untouched.
1416 ///
1417 /// This test used to assert the OPPOSITE, and said so — it pinned
1418 /// `attestable` filtering on `Scope::matches`, which also asks whether
1419 /// the repository has opted in. Asking that of a push diff meant a
1420 /// `.rs`-only push, in a repository with a `Cargo.toml` sitting right
1421 /// there, attested nothing. Since that is what nearly every Rust push
1422 /// looks like, the feature was attesting almost nothing at all — quietly,
1423 /// while appearing to work.
1424 #[test]
1425 fn an_ordinary_source_push_is_vouched_for() {
1426 let rust = opt_in("pre-push-cargo-test", &[".rs"], &["Cargo.toml"]);
1427 let checks: Vec<&dyn Check> = vec![&rust];
1428 let passed = vec!["pre-push-cargo-test".to_string()];
1429
1430 let changed = vec!["crates/amont/src/main.rs".to_string()];
1431 assert_eq!(
1432 attestable(&checks, &passed, &changed),
1433 vec!["pre-push-cargo-test".to_string()],
1434 "a push that changed Rust must vouch for the Rust gate"
1435 );
1436
1437 // And still when the marker IS in the diff — the opt-in file is not
1438 // required, but it is not disqualifying either.
1439 let changed = vec![
1440 "crates/amont/src/main.rs".to_string(),
1441 "Cargo.toml".to_string(),
1442 ];
1443 assert_eq!(
1444 attestable(&checks, &passed, &changed),
1445 vec!["pre-push-cargo-test".to_string()]
1446 );
1447 }
1448
1449 /// The over-claim the filter exists to stop, with an opt-in gate: a
1450 /// JS-only push must not vouch for the Rust suite, whatever files the
1451 /// repository contains.
1452 ///
1453 /// This is the half of the contract the fix above must not have broken,
1454 /// and it is the reason `touches` asks about EXTENSIONS rather than
1455 /// dropping the scope test altogether.
1456 #[test]
1457 fn an_opt_in_gate_is_not_vouched_for_by_a_push_in_another_language() {
1458 let rust = opt_in("pre-push-cargo-test", &[".rs"], &["Cargo.toml"]);
1459 let checks: Vec<&dyn Check> = vec![&rust];
1460 let passed = vec!["pre-push-cargo-test".to_string()];
1461
1462 for changed in [
1463 vec!["app/routes/home.ts".to_string()],
1464 // Even the marker alone: editing Cargo.toml changes no Rust
1465 // source, so the suite has nothing new to have verified.
1466 vec!["Cargo.toml".to_string()],
1467 vec![],
1468 ] {
1469 assert!(
1470 attestable(&checks, &passed, &changed).is_empty(),
1471 "must not vouch for the Rust gate on {changed:?}"
1472 );
1473 }
1474 }
1475}