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