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