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