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