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