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