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