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