amont_runtime/lib.rs
1//! The git-templates hook logic: registry, dispatchers and every check.
2//!
3//! This is a library so that more than one binary can hold the same truth about
4//! what a hook IS. `amont` (the commit path) executes the checks;
5//! `amont-fleet` reports on how they are installed across the fleet. Before
6//! the split there was no lib target at all, which is why `cargo test --lib`
7//! failed outright.
8//!
9//! **This crate gains no third-party dependency.** The hook binary depends on
10//! it, so anything added here reaches every commit transitively. The one
11//! crate it links is `git-attest`, attest's std-only implementation of the
12//! attestation format (decisions ADR-0037), argued in Cargo.toml and checked
13//! by `scripts/check-no-deps.sh`. ratatui and friends belong in `amont-fleet`.
14//!
15//! Hooks are invoked through a thin `sh` shim at each hook path, which passes
16//! the hooks directory it lives in:
17//!
18//! ```text
19//! amont --hooks-dir <dir> pre-commit [args…]
20//! ```
21
22/// Serialises every test that moves the process cwd **or depends on it**.
23///
24/// The second half of that sentence was missing, and it cost a day. The
25/// lock started as "movers only" — `gate_stamp` and `attest`, which both
26/// talk to "the repository at cwd" and each carried its own mutex, so each
27/// was serialised against itself and raced against the other. But a mover
28/// is only half the hazard: a test that READS the ambient cwd is just as
29/// exposed, because production code spawns git without `-C`. While
30/// `gate_stamp` held cwd inside its fixture, `restage_distinguishes_
31/// nothing_from_failure` ran `git add` — landing in that fixture and
32/// taking its `.git/index.lock`, so the fixture's own `git commit` failed
33/// 128 and the panic surfaced three lines later as a missing gate stamp.
34/// Roughly one run in forty, and unreadable until the fixtures started
35/// reporting git's exit status.
36///
37/// So: if a test moves the cwd, or calls anything that spawns git without
38/// naming a directory, it takes this lock.
39#[cfg(test)]
40pub(crate) static TEST_CWD: std::sync::Mutex<()> = std::sync::Mutex::new(());
41
42pub mod active;
43pub mod agents_md;
44pub mod attest;
45pub mod attest_inputs;
46pub mod bypass;
47pub mod capture;
48pub mod check;
49pub mod commit_style;
50pub mod config;
51pub mod content;
52pub mod dispatch;
53pub mod downgrade;
54pub mod excerpt;
55pub mod finding;
56pub mod gate_evidence;
57pub mod gate_identity;
58pub mod gate_stamp;
59pub mod git;
60pub mod hookfile;
61pub mod hooks;
62pub mod host_slots;
63pub mod install;
64pub mod json;
65pub mod json_read;
66pub mod land;
67pub mod live;
68pub mod load;
69pub mod manifest;
70pub mod pack;
71pub mod policy;
72pub mod proctree;
73pub mod pushed_tree;
74pub mod pushrefs;
75pub mod registry;
76pub mod rehearsal;
77pub mod setup;
78pub mod skew;
79pub mod snapshot_prep;
80pub mod staged_only;
81pub mod tree_cache;
82pub mod tree_lint;
83pub mod tree_run;
84pub mod tree_skew;
85pub mod trust;
86pub mod ui;
87pub mod vocabulary;
88
89use std::path::Path;
90use std::process::{Command, Stdio};
91
92/// `git config --get-all hook.skip`, or empty when unset/unavailable.
93/// The two triggers a check can be attached to, as they are spelled in config.
94///
95/// Deliberately the same strings as `Stage::as_str`, and
96/// `every_id_agrees_with_its_declared_stage` keeps them that way.
97pub const TRIGGERS: [&str; 2] = ["pre-commit", "pre-push"];
98
99/// How specifically a configured value names a check.
100///
101/// Ordered, so that when several keys match one check the most specific wins —
102/// which only matters for `amont.severity`, since a skip is a boolean.
103#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
104pub enum Match {
105 /// `pre-commit` — every check on that trigger.
106 Trigger,
107 /// `clippy` — that check, whichever trigger it is on.
108 ShortName,
109 /// `pre-commit-clippy` — this check and no other.
110 FullId,
111}
112
113/// A check's id without its trigger — `pre-commit-clippy` → `clippy`.
114///
115/// For DISPLAY only, wherever the trigger is already established by a heading
116/// or a neighbouring column. Under a `pre-commit` heading, printing
117/// `pre-commit-clippy` on every row spends eleven columns restating what the
118/// heading said. Never write this to config or compare against it: two checks
119/// can share a short name, and telling them apart is what the id is for.
120pub fn short_name(check: &str) -> &str {
121 for trigger in TRIGGERS {
122 if let Some(short) = check
123 .strip_prefix(trigger)
124 .and_then(|rest| rest.strip_prefix('-'))
125 {
126 return short;
127 }
128 }
129 check
130}
131
132/// Does `pattern`, as written in `hook.skip` or `amont.severity.<pattern>`,
133/// name `check`?
134///
135/// A check's id is `<trigger>-<name>`, and exactly three things name it:
136///
137/// | written | means |
138/// |---|---|
139/// | `pre-commit-clippy` | that one check |
140/// | `pre-commit` | every check on that trigger |
141/// | `clippy` | that check, on any trigger |
142///
143/// Three exact comparisons. **No substring.** The previous rule was
144/// `check.contains(skip)`, which made `hook.skip = clippy` work by accident of
145/// reach — and `hook.skip = e` disable all twenty checks by the same accident,
146/// and `lint-js` silently also suppress `lint-json-yaml`. Naming the three
147/// things a user actually means keeps every useful case and removes every
148/// sharp edge, including the one the old doc comment called "not a bug".
149///
150/// This reads the trigger out of the ID, which is not the same as deriving a
151/// check's stage: `Stage` remains a declared field and is what the dispatcher
152/// obeys. Here we are parsing an identifier a human typed.
153///
154/// Defined ONCE because four callers need it — the dispatcher decides what
155/// runs, the severity resolver decides what blocks, the fleet view reports
156/// where a check applies, and the skip resolver computes reach. A
157/// reimplementation that disagreed would have the dashboard claim a check is
158/// active while the dispatcher skips it.
159pub fn names_check(check: &str, pattern: &str) -> Option<Match> {
160 if check == pattern {
161 return Some(Match::FullId);
162 }
163 for trigger in TRIGGERS {
164 let Some(short) = check
165 .strip_prefix(trigger)
166 .and_then(|rest| rest.strip_prefix('-'))
167 else {
168 continue;
169 };
170 // An id carries one trigger, so the first that matches is the answer.
171 if pattern == trigger {
172 return Some(Match::Trigger);
173 }
174 if pattern == short {
175 return Some(Match::ShortName);
176 }
177 return None;
178 }
179 None
180}
181
182/// Does `skip`, as configured in `hook.skip`, suppress `check`?
183pub fn skip_suppresses(check: &str, skip: &str) -> bool {
184 names_check(check, skip).is_some()
185}
186
187/// One check, as reported by `amont list`.
188///
189/// Deliberately flat — this is what gets rendered as text or serialised to
190/// JSON, and a reader (human or agent) parsing the latter should not have to
191/// chase nested objects for a yes/no question.
192#[derive(Debug, Clone)]
193pub struct CheckListing {
194 /// `<trigger>-<name>` — what `hook.skip` and `amont.severity.<key>`
195 /// resolve against.
196 pub id: String,
197 pub short_name: String,
198 pub stage: check::Stage,
199 pub source: Source,
200 /// What the check (or manifest line) declared.
201 pub declared_severity: check::Severity,
202 /// For a declared check, what its `amont.conf` line says, trusted or
203 /// not: `declared_severity` reads `warn` for a check that cannot run,
204 /// an untrusted one included, and a reader asking what the repository
205 /// declares needs the line's own word. `None` for a builtin and for a
206 /// line that did not parse.
207 pub manifest_severity: Option<check::Severity>,
208 /// What `registry::Overrides::of` would actually apply — NOT the same as
209 /// `declared_severity` once `amont.severity.*` is configured. This is
210 /// the one thing the old text-only `list_checks` never reported, and the
211 /// exact "declared vs. effective" gap that caused a real bug in the
212 /// fleet's own severity column (see `amont-fleet/src/severities.rs`).
213 pub effective_severity: check::Severity,
214 pub severity_overridden: bool,
215 /// Where the winning override came from, only when one applies —
216 /// strictly additive next to `severity_overridden`, whose meaning is
217 /// unchanged.
218 pub severity_source: Option<registry::Source>,
219 pub fix: check::Fix,
220 pub status: Status,
221 /// Empty when `status == Status::Runs`; the same prose the text output
222 /// always showed for the other three states.
223 pub reason: String,
224 pub scope_files: Vec<String>,
225 pub scope_opt_in: Vec<String>,
226 /// `Some` only for a declared, `Runnable` external — a builtin has no
227 /// command to show, and an `Unusable` external never got far enough to
228 /// have one.
229 pub command: Option<String>,
230}
231
232#[derive(Debug, Clone, Copy, PartialEq, Eq)]
233pub enum Source {
234 Builtin,
235 Declared,
236}
237
238/// The four states the text output's glyphs already named. Not called
239/// `Outcome` — that type means what a check concluded when it RAN; this means
240/// whether it would run at all, the same question `Scope::matches` answers.
241#[derive(Debug, Clone, Copy, PartialEq, Eq)]
242pub enum Status {
243 Runs,
244 Inert,
245 Skipped,
246 Unusable,
247}
248
249pub struct ListOptions {
250 pub json: bool,
251 pub stage: Option<check::Stage>,
252 pub pushed: bool,
253 /// Print the inert checks too, one row each, as this always did.
254 pub all: bool,
255}
256
257/// Every check that would be considered for `stage_filter` (or both stages,
258/// when `None`), evaluated against `paths`.
259///
260/// Reads `hook.skip` and `amont.severity.*` from the current repo's git
261/// config, same as the original `list_checks` did — this is why it is not
262/// unit-tested in isolation; see `hooks/pull_rebase.rs`'s own split between
263/// pure helpers (unit-tested) and config-dependent behaviour (integration
264/// tested) for the precedent.
265pub fn gather_checks(
266 settings: &crate::config::Settings,
267 stage_filter: Option<check::Stage>,
268 paths: &[String],
269 manifest: &manifest::Manifest,
270) -> Vec<CheckListing> {
271 use crate::check::Stage;
272 let stages: Vec<Stage> = match stage_filter {
273 Some(s) => vec![s],
274 None => vec![Stage::PreCommit, Stage::PrePush],
275 };
276 let skips = configured_skips(settings);
277 let overrides = registry::Overrides::read(settings);
278 let externals_by_id: std::collections::BTreeMap<&str, &manifest::External> = manifest
279 .externals
280 .iter()
281 .map(|e| (e.id.as_str(), e))
282 .collect();
283
284 let mut out = Vec::new();
285 for stage in stages {
286 // Externals are listed here too, and marked, because the question
287 // this command answers — "would this run here?" — is asked most
288 // often about the check somebody just added to `amont.conf`.
289 for check in registry::all_stage_checks(stage, manifest) {
290 let name = check.name();
291 let external = externals_by_id.get(name).copied();
292 let skipped = skips.iter().any(|s| skip_suppresses(name, s));
293 let applies = check.scope().matches(paths);
294
295 let unusable_why = external.and_then(|e| match &e.kind {
296 manifest::Kind::Unusable { why, .. } => Some(why.as_str()),
297 manifest::Kind::Runnable { .. } => None,
298 });
299 // Four states: a check that is correctly silent must never look
300 // like one that is disabled, and neither must look like one
301 // whose declaration could not be read.
302 let (status, reason) = if let Some(w) = unusable_why {
303 (Status::Unusable, format!("{} {w}", manifest::MANIFEST))
304 } else if skipped {
305 // Which source? Policy skips read differently from machine
306 // skips — the legend and the fleet detail pane echo this
307 // wording, so the three move together.
308 let via = if settings
309 .policy()
310 .skips
311 .iter()
312 .any(|s| skip_suppresses(name, s))
313 {
314 "skipped via amont.conf"
315 } else {
316 "skipped via hook.skip"
317 };
318 (Status::Skipped, via.to_string())
319 } else if applies {
320 (Status::Runs, String::new())
321 } else {
322 (
323 Status::Inert,
324 format!("inert here — needs {}", describe(check.scope())),
325 )
326 };
327
328 let command = external.and_then(|e| match &e.kind {
329 manifest::Kind::Runnable { program, args, .. } => Some(
330 std::iter::once(program.as_str())
331 .chain(args.iter().map(String::as_str))
332 .collect::<Vec<_>>()
333 .join(" "),
334 ),
335 manifest::Kind::Unusable { .. } => None,
336 });
337
338 let declared_severity = check.severity();
339 let effective_severity = overrides.of(check);
340 let severity_source = overrides
341 .applied_with_source(name)
342 .map(|(_, _, src)| src)
343 .filter(|_| declared_severity != effective_severity);
344 out.push(CheckListing {
345 id: name.to_string(),
346 short_name: short_name(name).to_string(),
347 stage,
348 source: if external.is_some() {
349 Source::Declared
350 } else {
351 Source::Builtin
352 },
353 declared_severity,
354 manifest_severity: external.and_then(manifest::External::manifest_severity),
355 effective_severity,
356 severity_overridden: declared_severity != effective_severity,
357 severity_source,
358 fix: check.fix(),
359 status,
360 reason,
361 scope_files: {
362 let scope = check.scope();
363 scope
364 .files
365 .iter()
366 .chain(scope.names.iter())
367 .map(|s| s.to_string())
368 .collect()
369 },
370 scope_opt_in: check.scope().opt_in.iter().map(|s| s.to_string()).collect(),
371 command,
372 });
373 }
374 }
375 out
376}
377
378/// BYTE-IDENTICAL to what `list_checks` printed before it grew `--json`.
379/// `listings` is already stage-grouped (`gather_checks` iterates stage by
380/// stage), so a heading prints exactly once per stage encountered, in the
381/// same order.
382/// The ecosystem a scope token belongs to, for the inert summary.
383///
384/// Presentational and deliberately incomplete: a token nobody has mapped
385/// simply does not get named, and its check is still counted. The alternative
386/// — a `family` field on every `Builtin` — is thirty-seven places for a label
387/// to drift out of step with the scope that actually decides anything.
388///
389/// Generic tokens (`.yaml`, `.json`) are absent on purpose. Four checks want
390/// `.yaml` for four different reasons, so naming YAML here would tell a reader
391/// their repository is "missing YAML checks" when what it is missing is
392/// Kubernetes.
393fn ecosystem(token: &str) -> Option<&'static str> {
394 Some(match token {
395 ".rs" | "Cargo.toml" | "Cargo.lock" => "Rust",
396 ".go" | "go.mod" | "go.sum" => "Go",
397 ".py"
398 | ".pyi"
399 | "requirements.txt"
400 | "pyproject.toml"
401 | "ruff.toml"
402 | ".ruff.toml"
403 | "pytest.ini"
404 | "conftest.py"
405 | "pyrightconfig.json"
406 | "pyrightconfig.jsonc" => "Python",
407 ".js" | ".jsx" | ".ts" | ".tsx" | ".vue" | "package.json" | "package-lock.json" => {
408 "JavaScript"
409 }
410 "kustomization.yaml" | "kustomization.yml" => "Kubernetes",
411 t if t.starts_with(".kube-linter") => "Kubernetes",
412 t if t.starts_with(".prettierrc") || t == "prettier.config.js" => "JavaScript",
413 _ => return None,
414 })
415}
416
417/// Every check that would run here, and one line for the ones that would not.
418///
419/// The default used to print all thirty-odd rows interleaved alphabetically.
420/// In a repository amont serves well that is about half inert; in one built on
421/// a stack it does not cover yet it is two thirds — and every one of those rows
422/// names somebody else's language. Read top to bottom it says "this tool is for
423/// other people", which is the opposite of true: the checks that DO run are
424/// `secrets`, `large-files` and `merge-conflict`, the ones that prevent
425/// incidents in any repository at all.
426///
427/// So the inert rows collapse to a count and the ecosystems they belong to.
428/// `--all` brings them back, because "why is clippy not running" is a real
429/// question with a real answer already written.
430///
431/// **Skipped and unusable rows are never collapsed.** `⊘` means somebody
432/// silenced a check and `✗` means a declaration is broken; both are things the
433/// reader has to act on, and both would be lost in a count.
434pub fn print_text(listings: &[CheckListing], all: bool) {
435 let shown: Vec<&CheckListing> = listings
436 .iter()
437 .filter(|l| all || l.status != Status::Inert)
438 .collect();
439
440 let mut current: Option<check::Stage> = None;
441 for l in &shown {
442 if current != Some(l.stage) {
443 println!("{}", ui::highlight(l.stage.as_str()));
444 current = Some(l.stage);
445 }
446 let glyph = match l.status {
447 Status::Unusable => '✗',
448 Status::Skipped => '⊘',
449 Status::Runs => '●',
450 Status::Inert => '○',
451 };
452 // The SHORT name: this loop is already inside a `pre-commit` /
453 // `pre-push` heading, so printing the trigger on all twenty rows
454 // restates the heading twenty times and pushes the reason — the part
455 // that differs per row — eleven columns to the right.
456 //
457 // Where a check CAME FROM belongs next to its name, not appended
458 // after a reason that is often empty. A reader scanning this list
459 // wants to know which of these their repository added.
460 // A declared check's name and its reason both come from the
461 // repository's manifest, and `amont list` is read at least as often
462 // as the trust prompt. Sanitised before padding, so the column width is
463 // computed on what is printed — see `ui::sanitize`.
464 let short_name = ui::sanitize(&l.short_name);
465 let label = match l.source {
466 Source::Declared => format!("{short_name} (declared)"),
467 Source::Builtin => short_name,
468 };
469 println!(" {glyph} {label:<26} {}", ui::sanitize(&l.reason));
470 }
471
472 let runs = listings.iter().filter(|l| l.status == Status::Runs).count();
473 let inert: Vec<&CheckListing> = listings
474 .iter()
475 .filter(|l| l.status == Status::Inert)
476 .collect();
477
478 println!();
479 if inert.is_empty() {
480 println!(" {runs} active here.");
481 } else if all {
482 println!(" {runs} active here, {} inert.", inert.len());
483 } else {
484 let mut families: Vec<&str> = inert
485 .iter()
486 .flat_map(|l| l.scope_files.iter().chain(l.scope_opt_in.iter()))
487 .filter_map(|t| ecosystem(t))
488 .collect();
489 families.sort_unstable();
490 families.dedup();
491 let named = if families.is_empty() {
492 String::new()
493 } else {
494 format!(" ({})", families.join(", "))
495 };
496 println!(
497 " {runs} active here. {} inert{named} — amont list --all",
498 inert.len()
499 );
500 }
501 println!(" ● runs here ○ inert ⊘ skipped via hook.skip ✗ declaration unusable");
502}
503
504/// What `commit-msg` will enforce here, and where each answer came from.
505///
506/// Printed **always**, not only when something has been configured. The
507/// defaults are the divisive part — a gitmoji in every subject, a 50-character
508/// description — and somebody who wants them changed has no reason to guess
509/// that four keys exist. `amont list` is where they are already looking, so
510/// it is where the dial belongs.
511///
512/// The source column appears only for a value somebody set: repeating
513/// "default" on four rows spends a column restating the line underneath.
514pub fn print_commit_style(style: &commit_style::Style, rows: &[commit_style::Setting]) {
515 println!();
516 println!("{}", ui::highlight("commit style"));
517 for r in rows {
518 let origin = if r.set_here {
519 format!("{} ({})", r.key, r.scope.as_str())
520 } else {
521 String::new()
522 };
523 println!(" {:<18} {:<10} {origin}", r.label, r.value);
524 }
525 println!();
526 for w in style.warnings() {
527 println!(" {} {w}", ui::warning_sign().trim());
528 }
529 println!(" `amont setup` to change any of these");
530}
531
532/// The commit-style block as JSON: the effective value, the shipped default,
533/// whether they differ and where the answer came from — the same
534/// declared-vs-effective shape `CheckListing` uses for severity.
535fn commit_style_json(style: &commit_style::Style, rows: &[commit_style::Setting]) -> String {
536 let setting = |r: &commit_style::Setting, value: String, default: String| {
537 json::object(&[
538 format!("\"value\":{value}"),
539 format!("\"default\":{default}"),
540 json::bool_field("overridden", r.overridden),
541 json::bool_field("set_here", r.set_here),
542 json::string_field("source", r.scope.as_str()),
543 json::string_field("key", r.key),
544 ])
545 };
546 let d = commit_style::Style::default();
547 // `rows` is built in this order by `commit_style::describe`, and the
548 // numbers are emitted as numbers so a reader can compare them.
549 let quoted = |s: &str| format!("\"{}\"", json::escape(s));
550 let fields: Vec<String> = rows
551 .iter()
552 .map(|r| {
553 let (value, default) = match r.key {
554 commit_style::KEY_GITMOJI => {
555 (quoted(style.gitmoji.as_str()), quoted(d.gitmoji.as_str()))
556 }
557 commit_style::KEY_SUBJECT_MAX => {
558 (style.subject_max.to_string(), d.subject_max.to_string())
559 }
560 commit_style::KEY_DESCRIPTION_MAX => (
561 style.description_max.to_string(),
562 d.description_max.to_string(),
563 ),
564 _ => (style.body_wrap.to_string(), d.body_wrap.to_string()),
565 };
566 let name = r.key.rsplit('.').next().unwrap_or(r.key);
567 format!("\"{}\":{}", json::escape(name), setting(r, value, default))
568 })
569 .collect();
570
571 let warnings: Vec<String> = style.warnings();
572 let mut all = fields;
573 all.push(json::string_array_field("warnings", &warnings));
574 json::object(&all)
575}
576
577/// The format id this document declares, as its first field.
578///
579/// Every other machine-readable thing this tool writes carries one and
580/// REFUSES what it does not recognise — `amont-gate-v1`, `amont-held-v1`,
581/// `amont-skew-v1`, `amont-bypasses-v1`, and `amont-attest-v2`, whose bump
582/// exists precisely so a v1 verifier reads a v2 note as no note rather than
583/// misreading it. This document, the most public machine surface of the
584/// three, carried none: a reader had no way to state which contract it was
585/// written against, so a rename here would land as a silently different
586/// answer rather than a failure. Bump the version when a field's MEANING
587/// changes or one is removed; adding a field keeps it, which is what the
588/// object shape below was already for.
589pub const LIST_FORMAT: &str = "amont-list-v1";
590
591/// `{"format": "amont-list-v1", "stage_filter": ..., "checks": [...]}` — an
592/// object, not a bare array, so a field can be added later without changing
593/// the top-level shape.
594pub fn print_json(
595 settings: &crate::config::Settings,
596 stage_filter: Option<check::Stage>,
597 pushed: bool,
598 listings: &[CheckListing],
599 bypasses: &bypass::Ledger,
600 downgrades: &downgrade::Ledger,
601 conventions_apply: bool,
602) {
603 let checks: Vec<String> = listings
604 .iter()
605 .map(|l| {
606 json::object(&[
607 json::string_field("id", &l.id),
608 json::string_field("short_name", &l.short_name),
609 json::string_field("stage", l.stage.as_str()),
610 json::string_field(
611 "source",
612 match l.source {
613 Source::Builtin => "builtin",
614 Source::Declared => "declared",
615 },
616 ),
617 json::string_field("declared_severity", l.declared_severity.as_str()),
618 json::opt_string_field(
619 "manifest_severity",
620 l.manifest_severity.map(check::Severity::as_str),
621 ),
622 json::string_field("effective_severity", l.effective_severity.as_str()),
623 json::bool_field("severity_overridden", l.severity_overridden),
624 json::opt_string_field(
625 "severity_source",
626 l.severity_source.map(registry::Source::as_str),
627 ),
628 json::string_field("fix", l.fix.as_str()),
629 json::string_field(
630 "status",
631 match l.status {
632 Status::Runs => "runs",
633 Status::Inert => "inert",
634 Status::Skipped => "skipped",
635 Status::Unusable => "unusable",
636 },
637 ),
638 json::string_field("reason", &l.reason),
639 json::string_array_field("scope_files", &l.scope_files),
640 json::string_array_field("scope_opt_in", &l.scope_opt_in),
641 json::opt_string_field("command", l.command.as_deref()),
642 ])
643 })
644 .collect();
645
646 let (style, rows) = commit_style::describe(settings);
647 println!(
648 "{}",
649 json::object(&[
650 json::string_field("format", LIST_FORMAT),
651 json::opt_string_field("stage_filter", stage_filter.map(check::Stage::as_str)),
652 json::bool_field("pushed", pushed),
653 format!("\"checks\":{}", json::array(&checks)),
654 format!("\"commit_style\":{}", commit_style_json(&style, &rows)),
655 format!("\"branch_style\":{}", branch_style_json()),
656 format!("\"bypasses\":{}", bypasses_json(bypasses)),
657 format!("\"downgrades\":{}", downgrades_json(downgrades)),
658 json::bool_field("conventions_apply", conventions_apply),
659 ])
660 );
661}
662
663/// `{"total": N, "last": <epoch|null>, "by_script": [...]}` — the ledger of
664/// unverified commits, so a parsing reader (the fleet, an agent) sees the
665/// same numbers `amont list` prints.
666fn downgrades_json(l: &downgrade::Ledger) -> String {
667 let by_check: Vec<String> = l
668 .by_check
669 .iter()
670 .map(|c| {
671 json::object(&[
672 json::string_field("check", &c.check),
673 json::int_field("count", c.count as i64),
674 json::int_field("would_block", c.would_block as i64),
675 json::int_field("last", c.last as i64),
676 ])
677 })
678 .collect();
679 json::object(&[
680 json::int_field("total", l.total as i64),
681 json::int_field("would_block", l.would_block as i64),
682 json::int_field("commits", l.commits as i64),
683 json::opt_int_field("first", l.first.map(|v| v as i64)),
684 json::opt_int_field("last", l.last.map(|v| v as i64)),
685 format!("\"by_check\":{}", json::array(&by_check)),
686 ])
687}
688
689fn bypasses_json(l: &bypass::Ledger) -> String {
690 let by_script: Vec<String> = l
691 .by_script
692 .iter()
693 .map(|s| {
694 json::object(&[
695 json::string_field("script", &s.script),
696 json::int_field("count", s.count as i64),
697 json::int_field("last", s.last as i64),
698 ])
699 })
700 .collect();
701 json::object(&[
702 json::int_field("total", l.total as i64),
703 json::opt_int_field("last", l.last.map(|v| v as i64)),
704 format!("\"by_script\":{}", json::array(&by_script)),
705 ])
706}
707
708/// The branch contract, in the same document agents are told to consult — so
709/// the pattern is knowable BEFORE a branch is created rather than discovered
710/// at push time. Rendered from `vocabulary::BRANCH_PREFIXES`, the same table
711/// `pre-push-branch-pattern` enforces: there is no second copy to drift.
712fn branch_style_json() -> String {
713 let prefixes: Vec<String> = vocabulary::BRANCH_PREFIXES
714 .iter()
715 .map(|p| p.name.to_string())
716 .collect();
717 json::object(&[
718 json::string_field("shape", "<prefix>/<name>"),
719 json::string_field("pattern", &vocabulary::branch_contract()),
720 json::string_array_field("prefixes", &prefixes),
721 ])
722}
723
724/// `git ls-files` — every check's default scope evaluation, unchanged from
725/// what `list_checks` always did.
726///
727/// Through `git::stdout_paths`, i.e. with `-z`. A raw `ls-files` QUOTES any
728/// path holding an unusual byte: `é.json` comes back as the nine-byte literal
729/// `"\303\251.json"`, which ends with a quote rather than an extension, so
730/// `Scope::matches` reports a check as irrelevant to a repository it plainly
731/// covers. Cosmetic here (this only decides what `list` prints) and not
732/// cosmetic in `dispatch::enter_all_files_mode`, which is the same bug — so
733/// both ask the same way.
734pub(crate) fn tracked_paths() -> Vec<String> {
735 git::stdout_paths(&["ls-files"]).unwrap_or_default()
736}
737
738/// The pushed-range file list, computed standalone rather than from a real
739/// pre-push invocation's stdin.
740///
741/// Reuses `pushrefs::changed_files`, which already handles zero-oid deletes,
742/// merge commits and the `--stdin` trailing-newline edge case — this only
743/// SYNTHESISES the one `PushRef` a standalone invocation has no other way to
744/// obtain.
745fn pushed_paths() -> Result<Vec<String>, String> {
746 let synthetic = pushrefs::synthetic_from_upstream()?;
747 Ok(pushrefs::changed_files(&[synthetic]))
748}
749
750/// `amont list`: what would run here, and why — as prose, or as
751/// `--json` for a reader that wants to parse it.
752pub fn list_checks(opts: ListOptions) -> i32 {
753 let paths = if opts.pushed {
754 match pushed_paths() {
755 Ok(p) => p,
756 Err(msg) => {
757 if opts.json {
758 println!("{}", json::object(&[json::string_field("error", &msg)]));
759 } else {
760 eprintln!("amont: {msg}");
761 }
762 return 2;
763 }
764 }
765 } else {
766 tracked_paths()
767 };
768 // Loaded HERE, with the repository this command is standing in — the
769 // owned-manifest shape every entrypoint now follows. See manifest::load.
770 let manifest = manifest::load(std::path::Path::new(&hooks::common::repo_root()));
771 // The policy is OWNED here and borrowed downstream. The invariant that
772 // used to be a comment — install immediately after load, before any
773 // config read — is now the borrow checker's: nothing downstream can read
774 // configuration without being handed this.
775 let settings = crate::config::Settings::new(manifest.policy.clone());
776 let listings = gather_checks(&settings, opts.stage, &paths, &manifest);
777 let bypasses = bypass::read();
778 let downgrades = downgrade::read();
779 let conventions_apply = dispatch::conventions_apply(&settings, &manifest);
780 if opts.json {
781 print_json(
782 &settings,
783 opts.stage,
784 opts.pushed,
785 &listings,
786 &bypasses,
787 &downgrades,
788 conventions_apply,
789 );
790 } else {
791 print_text(&listings, opts.all);
792 // Not filtered by `--stage`: commit style belongs to no stage, and
793 // suppressing it for `--stage pre-push` would only hide it from the
794 // reader who narrowed their question.
795 let (style, rows) = commit_style::describe(&settings);
796 print_commit_style(&style, &rows);
797 print_bypasses(&bypasses);
798 print_downgrades(&downgrades);
799 if !conventions_apply {
800 println!(
801 "\n ! conventions held back — no amont.conf here and amont.conventions \
802 is `declared`; only the safety net runs"
803 );
804 }
805 }
806 0
807}
808
809/// The shadow-mode worksheet, only when there is one — a repository that has
810/// never warned about anything keeps its old output byte-for-byte.
811///
812/// This is what a fortnight of `amont.severity.pre-commit warn` is FOR: the
813/// counts are the argument, and the footer carries the one action a reader
814/// takes afterwards. Repeating a config line per row would bury it.
815fn print_downgrades(l: &downgrade::Ledger) {
816 if l.total == 0 {
817 return;
818 }
819 let now = std::time::SystemTime::now()
820 .duration_since(std::time::UNIX_EPOCH)
821 .map(|d| d.as_secs())
822 .unwrap_or_default();
823 println!("\nproblems that did not block");
824 let pad = l
825 .by_check
826 .iter()
827 .map(|c| ui::sanitize(&c.check).chars().count())
828 .max()
829 .unwrap_or(0);
830 for c in &l.by_check {
831 // A check that declares `warn` itself was never going to block, and
832 // saying so inline stops it being read as rollout evidence.
833 let advisory = if c.would_block == 0 {
834 " (advisory)"
835 } else {
836 ""
837 };
838 println!(
839 " {:<pad$} {:>3} last {}{}",
840 ui::sanitize(&c.check),
841 c.count,
842 bypass::age(now, c.last),
843 advisory
844 );
845 }
846 // Events and commits are different facts: forty commits each tripping a
847 // check once is a check the team disagrees with, while one commit tripping
848 // it forty times is one person losing an afternoon.
849 let since = l
850 .first
851 .map(|f| format!(", since {}", bypass::age(now, f)))
852 .unwrap_or_default();
853 println!(
854 " {} event{} over {} commit{}{since}",
855 l.total,
856 if l.total == 1 { "" } else { "s" },
857 l.commits,
858 if l.commits == 1 { "" } else { "s" },
859 );
860 if l.would_block > 0 {
861 println!(
862 " {} of them would have blocked — set amont.severity.<check> to keep one \
863 advisory when you go back to block",
864 l.would_block
865 );
866 }
867}
868
869/// The unverified-commit tally, only when there is one — a clean repository's
870/// `amont list` output stays byte-identical to what it always was.
871fn print_bypasses(l: &bypass::Ledger) {
872 if l.total == 0 {
873 return;
874 }
875 let now = std::time::SystemTime::now()
876 .duration_since(std::time::UNIX_EPOCH)
877 .map(|d| d.as_secs())
878 .unwrap_or_default();
879 println!("\nunverified commits");
880 let pad = l
881 .by_script
882 .iter()
883 .map(|s| ui::sanitize(&s.script).chars().count())
884 .max()
885 .unwrap_or(0);
886 for s in &l.by_script {
887 println!(
888 " {:<pad$} {:>3} last {}",
889 ui::sanitize(&s.script),
890 s.count,
891 bypass::age(now, s.last)
892 );
893 }
894 println!(
895 " these commits carry no record that their commit-time gate ran — the push gate ran it instead"
896 );
897}
898
899fn describe(s: crate::check::Scope) -> String {
900 let files = if s.is_unscoped() {
901 String::new()
902 } else {
903 s.files
904 .iter()
905 .chain(s.names.iter())
906 .chain(s.dirs.iter())
907 .copied()
908 .collect::<Vec<_>>()
909 .join(" ")
910 };
911 let opt = s.opt_in.join(" | ");
912 match (files.is_empty(), opt.is_empty()) {
913 (false, false) => format!("{files} + {opt}"),
914 (false, true) => files,
915 (true, false) => opt,
916 (true, true) => "nothing".into(),
917 }
918}
919
920/// The machine's `hook.skip` entries PLUS the trusted policy's `skip`
921/// lines — the union every resolution site sees. Callers that must tell the
922/// two apart (the dispatcher announces them separately) use
923/// [`skips_by_source`].
924pub fn configured_skips(settings: &config::Settings) -> Vec<String> {
925 policy::union_skips(machine_skips(), settings.policy())
926}
927
928/// `(machine, policy)` — the split the announcements need: "you decided
929/// this" and "your team decided this" are different things to be told.
930pub fn skips_by_source(settings: &config::Settings) -> (Vec<String>, Vec<String>) {
931 (machine_skips(), settings.policy().skips.clone())
932}
933
934/// The check names one `hook.skip` value carries. A value may join several
935/// with commas (`-c hook.skip=clippy,prettier`), which is the same as one
936/// `hook.skip` per name. A check id is letters, digits, `.`, `_` and `-`, so a
937/// comma can never be part of a name and the split is never ambiguous. Names
938/// are matched exactly afterwards, as ever: joining never turns one into a glob.
939pub fn skip_names(value: &str) -> impl Iterator<Item = &str> {
940 value
941 .split(',')
942 .map(str::trim)
943 .filter(|name| !name.is_empty())
944}
945
946fn machine_skips() -> Vec<String> {
947 let Ok(out) = Command::new("git")
948 .args(["config", "--get-all", "hook.skip"])
949 .stderr(Stdio::null())
950 .output()
951 else {
952 return Vec::new();
953 };
954 String::from_utf8_lossy(&out.stdout)
955 .lines()
956 .flat_map(skip_names)
957 .map(str::to_owned)
958 .collect()
959}
960
961/// Which git operations are part-way through, from the markers in `$GIT_DIR`.
962///
963/// Asks git directly rather than deriving a path from `hooks_dir`. That used
964/// to be `hooks_dir.parent()` — correct for the main worktree, where hooks
965/// live in `.git/hooks` and `.git` IS `$GIT_DIR`, but wrong for a LINKED
966/// worktree: hooks dispatch from the COMMON directory's `hooks/`, shared
967/// across every worktree, while `MERGE_HEAD`/`CHERRY_PICK_HEAD`/etc. live in
968/// each worktree's own PRIVATE gitdir under `.git/worktrees/<name>`.
969/// Conflating the two silently disabled this guard for every linked
970/// worktree — the same mistake `staged_only`'s store path made, which lost
971/// unstaged work outright; see its module doc.
972pub fn git_states_in_progress() -> Vec<crate::check::GitState> {
973 let Some(git_dir) = crate::git::stdout(&["rev-parse", "--git-dir"]) else {
974 return Vec::new();
975 };
976 let git_dir = Path::new(&git_dir);
977 crate::check::GitState::ALL
978 .into_iter()
979 .filter(|state| {
980 state
981 .markers()
982 .iter()
983 .any(|marker| git_dir.join(marker).exists())
984 })
985 .collect()
986}
987
988/// True during a cherry-pick, where the zsh `pre-commit` exited 0 immediately.
989/// Superseded internally by [`git_states_in_progress`]; kept for whatever
990/// still calls it directly. See that function for why this asks git rather
991/// than deriving a path from `hooks_dir`.
992pub fn cherry_pick_in_progress() -> bool {
993 crate::git::stdout(&["rev-parse", "--git-dir"])
994 .map(|d| Path::new(&d).join("CHERRY_PICK_HEAD").exists())
995 .unwrap_or(false)
996}
997
998#[cfg(test)]
999mod naming {
1000 use super::*;
1001
1002 /// A joined value is the same as one skip per name; spaces, empty pieces
1003 /// and a trailing comma change nothing, and a lone name is untouched.
1004 #[test]
1005 fn a_comma_joined_skip_is_one_skip_per_name() {
1006 fn names(v: &str) -> Vec<&str> {
1007 skip_names(v).collect()
1008 }
1009 assert_eq!(names("clippy"), ["clippy"]);
1010 assert_eq!(
1011 names("pre-commit-cargo-test,pre-commit-plugin-test"),
1012 ["pre-commit-cargo-test", "pre-commit-plugin-test"]
1013 );
1014 assert_eq!(names(" a , b ,,c, "), ["a", "b", "c"]);
1015 assert!(names(" , ").is_empty());
1016 }
1017
1018 /// Joining never widens a name: each piece is matched exactly, as a
1019 /// separate `hook.skip` would be, so `e,clippy` still reaches nothing by `e`.
1020 #[test]
1021 fn joining_names_does_not_turn_them_into_globs() {
1022 assert!(!skip_suppresses("pre-commit-clippy", "e"));
1023 assert!(skip_names("e,clippy").any(|n| skip_suppresses("pre-commit-clippy", n)));
1024 assert!(!skip_names("e,lint").any(|n| skip_suppresses("pre-commit-clippy", n)));
1025 }
1026
1027 /// The three things a user can write, and what each reaches.
1028 #[test]
1029 fn three_ways_to_name_a_check() {
1030 assert_eq!(
1031 names_check("pre-commit-clippy", "pre-commit-clippy"),
1032 Some(Match::FullId)
1033 );
1034 assert_eq!(
1035 names_check("pre-commit-clippy", "pre-commit"),
1036 Some(Match::Trigger)
1037 );
1038 assert_eq!(
1039 names_check("pre-commit-clippy", "clippy"),
1040 Some(Match::ShortName)
1041 );
1042 }
1043
1044 /// The hazards the old substring rule created, all gone by construction.
1045 #[test]
1046 fn nothing_matches_by_accident() {
1047 // `hook.skip = e` disabled all twenty checks. It now reaches nothing.
1048 for pattern in ["e", "t", "i", ""] {
1049 assert_eq!(
1050 names_check("pre-commit-clippy", pattern),
1051 None,
1052 "{pattern:?}"
1053 );
1054 }
1055 // A partial word is not a name.
1056 assert_eq!(names_check("pre-commit-clippy", "clip"), None);
1057 assert_eq!(names_check("pre-commit-clippy", "lint"), None);
1058 // The wrong trigger reaches nothing.
1059 assert_eq!(names_check("pre-commit-clippy", "pre-push"), None);
1060 // And the empty string names nothing, rather than everything — git
1061 // stores `hook.skip` with no value as exactly this.
1062 assert_eq!(names_check("pre-commit-clippy", ""), None);
1063 }
1064
1065 /// The coupling `docs/hook-skip-management.md` warned about: `lint-js` is a
1066 /// substring of `lint-json-yaml`, so skipping one used to skip both.
1067 #[test]
1068 fn a_short_name_does_not_reach_a_longer_one() {
1069 assert!(names_check("pre-commit-lint-json-yaml", "lint-js").is_none());
1070 assert_eq!(
1071 names_check("pre-commit-lint-js", "lint-js"),
1072 Some(Match::ShortName)
1073 );
1074 assert_eq!(
1075 names_check("pre-commit-lint-json-yaml", "lint-json-yaml"),
1076 Some(Match::ShortName)
1077 );
1078 }
1079
1080 /// The one value that exists in the real fleet.
1081 #[test]
1082 fn the_fleets_only_skip_still_resolves() {
1083 assert_eq!(
1084 names_check("pre-push-run-tests-js", "run-tests-js"),
1085 Some(Match::ShortName)
1086 );
1087 }
1088
1089 /// A trigger reaches every check on it and none on the other.
1090 #[test]
1091 fn a_trigger_reaches_its_own_stage_only() {
1092 let pre_commit = registry::CHECKS
1093 .iter()
1094 .filter(|c| names_check(c.name, "pre-commit").is_some())
1095 .count();
1096 let pre_push = registry::CHECKS
1097 .iter()
1098 .filter(|c| names_check(c.name, "pre-push").is_some())
1099 .count();
1100 assert_eq!(pre_commit + pre_push, registry::CHECKS.len());
1101 assert!(pre_commit > 0 && pre_push > 0);
1102 }
1103
1104 /// Specificity ordering, which decides severity when several keys apply.
1105 #[test]
1106 fn a_full_id_outranks_a_short_name_outranks_a_trigger() {
1107 assert!(Match::FullId > Match::ShortName);
1108 assert!(Match::ShortName > Match::Trigger);
1109 }
1110
1111 /// The resolver reads the trigger out of the ID. That is only sound while
1112 /// every ID agrees with the stage its check actually declares — so it is
1113 /// checked rather than assumed.
1114 #[test]
1115 fn every_id_agrees_with_its_declared_stage() {
1116 for check in registry::CHECKS {
1117 assert_eq!(
1118 names_check(check.name, check.stage.as_str()),
1119 Some(Match::Trigger),
1120 "{} declares {:?} but its id says otherwise",
1121 check.name,
1122 check.stage
1123 );
1124 }
1125 }
1126}
1127
1128#[cfg(test)]
1129mod listing {
1130 use super::*;
1131
1132 /// The summary names ecosystems so the inert count reads as "other
1133 /// people's stacks" rather than "twenty-two things are broken".
1134 #[test]
1135 fn scope_tokens_map_to_the_ecosystem_a_reader_would_name() {
1136 assert_eq!(ecosystem(".rs"), Some("Rust"));
1137 assert_eq!(ecosystem("Cargo.lock"), Some("Rust"));
1138 assert_eq!(ecosystem("go.sum"), Some("Go"));
1139 assert_eq!(ecosystem("pyproject.toml"), Some("Python"));
1140 assert_eq!(ecosystem(".tsx"), Some("JavaScript"));
1141 assert_eq!(ecosystem(".prettierrc.json"), Some("JavaScript"));
1142 assert_eq!(ecosystem("kustomization.yaml"), Some("Kubernetes"));
1143 assert_eq!(ecosystem(".kube-linter.yaml"), Some("Kubernetes"));
1144 }
1145
1146 /// The generic tokens are unmapped ON PURPOSE. Four checks want `.yaml`
1147 /// for four unrelated reasons, so naming YAML would tell a reader their
1148 /// repository is missing "YAML checks" when what it is missing is
1149 /// Kubernetes. An unmapped token is still COUNTED — it just goes unnamed,
1150 /// which is the honest failure mode for a presentational map.
1151 #[test]
1152 fn generic_tokens_are_deliberately_unnamed() {
1153 for t in [".yaml", ".yml", ".json", ".md", ".sh", "unknown-thing"] {
1154 assert_eq!(ecosystem(t), None, "{t} should not name an ecosystem");
1155 }
1156 }
1157}