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