Skip to main content

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