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