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