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