1use std::ffi::OsString;
15use std::path::Path;
16
17use crate::check::{Builtin, Check, Fix, GitState, Outcome, Scope, Severity, Stage, Verdict};
18use crate::pushrefs::PushRefs;
19use crate::{dispatch, hooks};
20
21pub struct Ctx<'a> {
25 pub name: &'a str,
27 pub args: &'a [OsString],
29 pub hooks_dir: &'a Path,
32 pub push: &'a PushRefs,
35}
36
37pub type HookFn = fn(&Ctx) -> Verdict;
38
39pub const ENTRYPOINTS: &[(&str, HookFn)] = &[
42 ("pre-commit", dispatch::pre_commit),
43 ("pre-push", dispatch::pre_push),
44 ("commit-msg", |ctx| hooks::commit_msg::run(ctx.args)),
45 ("prepare-commit-msg", |ctx| {
46 hooks::prepare_commit_msg::run(ctx.args)
47 }),
48];
49
50const MID_OPERATION: &[GitState] = &[
69 GitState::Merge,
70 GitState::Rebase,
71 GitState::CherryPick,
72 GitState::Revert,
73];
74
75pub const CHECKS: &[Builtin] = &[
76 Builtin {
78 name: "pre-commit-argo-lint",
79 stage: Stage::PreCommit,
80 scope: Scope::new(
81 hooks::k8s::EXTS,
82 &["kustomization.yaml", "kustomization.yml"],
83 )
84 .not_during(MID_OPERATION),
85 severity: Severity::Block,
86 fix: Fix::None,
87 run: |ctx| hooks::k8s::argo_lint(ctx.args),
88 },
89 Builtin {
90 name: "pre-commit-ban-terms",
91 stage: Stage::PreCommit,
92 scope: Scope::files(&[".js", ".jsx", ".ts", ".tsx", ".vue"]),
93 severity: Severity::Block,
94 fix: Fix::None,
95 run: |ctx| hooks::ban_terms::run(ctx.name, ctx.args),
96 },
97 Builtin {
98 name: "pre-commit-cargo-fmt",
99 stage: Stage::PreCommit,
100 scope: Scope::new(hooks::rust_tools::EXTS, &["Cargo.toml"]).not_during(MID_OPERATION),
101 severity: Severity::Block,
102 fix: Fix::Rewrite,
103 run: |ctx| hooks::rust_tools::fmt(ctx.args),
104 },
105 Builtin {
106 name: "pre-commit-clippy",
107 stage: Stage::PreCommit,
108 scope: Scope::new(hooks::rust_tools::EXTS, &["Cargo.toml"]).not_during(MID_OPERATION),
109 severity: Severity::Block,
110 fix: Fix::None,
111 run: |ctx| hooks::rust_tools::clippy(ctx.args),
112 },
113 Builtin {
114 name: "pre-commit-kube-linter",
115 stage: Stage::PreCommit,
116 scope: Scope::new(
117 hooks::k8s::EXTS,
118 &[".kube-linter*.yaml", ".kube-linter*.yml"],
119 )
120 .not_during(MID_OPERATION),
121 severity: Severity::Block,
122 fix: Fix::None,
123 run: |ctx| hooks::k8s::kube_linter(ctx.args),
124 },
125 Builtin {
126 name: "pre-commit-kubeconform",
127 stage: Stage::PreCommit,
128 scope: Scope::new(
129 hooks::k8s::EXTS,
130 &["kustomization.yaml", "kustomization.yml"],
131 )
132 .not_during(MID_OPERATION),
133 severity: Severity::Block,
134 fix: Fix::None,
135 run: |ctx| hooks::k8s::kubeconform(ctx.args),
136 },
137 Builtin {
138 name: "pre-commit-lint-js",
139 stage: Stage::PreCommit,
140 scope: Scope::new(hooks::lint_js::EXTS, &["package.json"]).not_during(MID_OPERATION),
141 severity: Severity::Block,
142 fix: Fix::None,
143 run: |ctx| hooks::lint_js::run(ctx.args),
144 },
145 Builtin {
146 name: "pre-commit-lint-json-yaml",
147 stage: Stage::PreCommit,
148 scope: Scope::files(hooks::lint_json_yaml::EXTS).not_during(MID_OPERATION),
149 severity: Severity::Block,
150 fix: Fix::None,
151 run: |ctx| hooks::lint_json_yaml::run(ctx.args),
152 },
153 Builtin {
154 name: "pre-commit-merge-conflict",
155 stage: Stage::PreCommit,
156 scope: Scope::ALWAYS,
157 severity: Severity::Block,
158 fix: Fix::None,
159 run: |ctx| hooks::merge_conflict::run(ctx.name, ctx.args),
160 },
161 Builtin {
162 name: "pre-commit-package-lock",
163 stage: Stage::PreCommit,
164 scope: Scope::new(&[], &["package.json"]),
165 severity: Severity::Block,
166 fix: Fix::None,
167 run: |ctx| hooks::package_lock::run(ctx.args),
168 },
169 Builtin {
170 name: "pre-commit-prettier",
171 stage: Stage::PreCommit,
172 scope: Scope::new(
173 &[],
174 &[
175 ".prettierrc",
176 ".prettierrc.json",
177 ".prettierrc.yml",
178 ".prettierrc.yaml",
179 ".prettierrc.js",
180 "prettier.config.js",
181 ],
182 )
183 .not_during(MID_OPERATION),
184 severity: Severity::Block,
185 fix: Fix::Rewrite,
186 run: |ctx| hooks::prettier::run(ctx.args),
187 },
188 Builtin {
189 name: "pre-commit-pyright",
190 stage: Stage::PreCommit,
191 scope: Scope::new(
192 hooks::python_tools::EXTS,
193 &[
194 "pyrightconfig.json",
195 "pyrightconfig.jsonc",
196 "pyproject.toml",
197 ],
198 )
199 .not_during(MID_OPERATION),
200 severity: Severity::Block,
201 fix: Fix::None,
202 run: |ctx| hooks::python_tools::pyright(ctx.args),
203 },
204 Builtin {
205 name: "pre-commit-ruff",
206 stage: Stage::PreCommit,
207 scope: Scope::new(
208 hooks::python_tools::EXTS,
209 &["ruff.toml", ".ruff.toml", "pyproject.toml"],
210 )
211 .not_during(MID_OPERATION),
212 severity: Severity::Block,
213 fix: Fix::Rewrite,
214 run: |ctx| hooks::python_tools::ruff(ctx.args),
215 },
216 Builtin {
217 name: "pre-commit-usual-name",
218 stage: Stage::PreCommit,
219 scope: Scope::ALWAYS,
220 severity: Severity::Block,
221 fix: Fix::None,
222 run: |ctx| hooks::usual_name::run(ctx.args),
223 },
224 Builtin {
225 name: "pre-commit-yamllint",
226 stage: Stage::PreCommit,
227 scope: Scope::new(
228 hooks::yamllint::EXTS,
229 &[".yamllint.yaml", ".yamllint.yml", ".yamllint"],
230 )
231 .not_during(MID_OPERATION),
232 severity: Severity::Block,
233 fix: Fix::None,
234 run: |ctx| hooks::yamllint::run(ctx.args),
235 },
236 Builtin {
238 name: "pre-push-branch-protect",
239 stage: Stage::PrePush,
240 scope: Scope::ALWAYS,
241 severity: Severity::Block,
242 fix: Fix::None,
243 run: |ctx| hooks::branch_protect::run(ctx.push.get()),
244 },
245 Builtin {
246 name: "pre-push-branch-pattern",
247 stage: Stage::PrePush,
248 scope: Scope::ALWAYS,
249 severity: Severity::Block,
250 fix: Fix::None,
251 run: |ctx| hooks::branch_pattern::run(ctx.push.get(), ctx.args),
252 },
253 Builtin {
254 name: "pre-push-pull-rebase",
255 stage: Stage::PrePush,
256 scope: Scope::ALWAYS.not_during(&[GitState::Rebase, GitState::Merge]),
257 severity: Severity::Block,
258 fix: Fix::None,
259 run: |ctx| hooks::pull_rebase::run(ctx.args),
260 },
261 Builtin {
262 name: "pre-push-run-tests-js",
263 stage: Stage::PrePush,
264 scope: Scope::new(hooks::run_tests::JS_EXTS, &["package.json"])
265 .not_during(&[GitState::Bisect, GitState::Rebase]),
266 severity: Severity::Block,
267 fix: Fix::None,
268 run: |ctx| hooks::run_tests::run(ctx.push.get()),
269 },
270 Builtin {
271 name: "pre-push-cargo-test",
272 stage: Stage::PrePush,
273 scope: Scope::new(hooks::rust_tools::EXTS, &["Cargo.toml"])
274 .not_during(&[GitState::Bisect, GitState::Rebase]),
275 severity: Severity::Block,
276 fix: Fix::None,
277 run: |ctx| hooks::rust_tools::test(ctx.push.get()),
278 },
279];
280
281pub fn severity_of(check: &dyn Check) -> Severity {
287 effective_override(None, check.name()).unwrap_or_else(|| check.severity())
288}
289
290pub fn severity_key(check: &str) -> String {
292 format!("amont.severity.{check}")
293}
294
295#[derive(Debug, Default, Clone)]
306pub struct Overrides(std::collections::BTreeMap<String, Severity>);
307
308impl Overrides {
309 pub fn read() -> Overrides {
310 Overrides::from_config(crate::git::stdout(&[
311 "config",
312 "--get-regexp",
313 r"^amont\.severity\.",
314 ]))
315 }
316
317 pub fn from_config(out: Option<String>) -> Overrides {
325 let mut map = std::collections::BTreeMap::new();
326 for line in out.as_deref().unwrap_or_default().lines() {
327 let Some((key, value)) = line.split_once(' ') else {
328 continue;
329 };
330 let Some(check) = key.strip_prefix("amont.severity.") else {
331 continue;
332 };
333 match Severity::parse(value.trim()) {
336 Some(s) => {
337 map.insert(check.to_string(), s);
338 }
339 None => {
342 map.remove(check);
343 }
344 }
345 }
346 Overrides(map)
347 }
348
349 pub fn applied_to(&self, check: &str) -> Option<(&str, Severity)> {
356 self.0
357 .iter()
358 .filter_map(|(pattern, severity)| {
359 crate::names_check(check, pattern).map(|m| (m, pattern.as_str(), *severity))
360 })
361 .max_by_key(|(m, _, _)| *m)
362 .map(|(_, pattern, severity)| (pattern, severity))
363 }
364
365 pub fn of(&self, check: &dyn Check) -> Severity {
367 self.applied_to(check.name())
368 .map(|(_, severity)| severity)
369 .unwrap_or_else(|| check.severity())
370 }
371}
372
373#[cfg(test)]
374mod precedence {
375 use super::{Overrides, Severity};
376
377 fn overrides(lines: &[&str]) -> Overrides {
378 let text = lines
379 .iter()
380 .map(|l| format!("amont.severity.{l}\n"))
381 .collect::<String>();
382 Overrides::from_config(Some(text))
383 }
384
385 #[test]
389 fn the_more_specific_key_wins() {
390 let both = overrides(&["pre-commit warn", "pre-commit-clippy block"]);
391 assert_eq!(
392 both.applied_to("pre-commit-clippy"),
393 Some(("pre-commit-clippy", Severity::Block)),
394 "a full id beats its trigger"
395 );
396 assert_eq!(
397 both.applied_to("pre-commit-shellcheck"),
398 Some(("pre-commit", Severity::Warn)),
399 "and the trigger still governs every check it did not exempt"
400 );
401 }
402
403 #[test]
406 fn the_three_ways_to_name_a_check_are_ranked() {
407 let all = overrides(&["pre-commit warn", "clippy block", "pre-commit-clippy warn"]);
408 assert_eq!(
409 all.applied_to("pre-commit-clippy"),
410 Some(("pre-commit-clippy", Severity::Warn))
411 );
412
413 let no_full = overrides(&["pre-commit warn", "clippy block"]);
414 assert_eq!(
415 no_full.applied_to("pre-commit-clippy"),
416 Some(("clippy", Severity::Block)),
417 "a short name beats a trigger"
418 );
419 }
420
421 #[test]
424 fn a_key_that_names_no_check_applies_to_nothing() {
425 let typo = overrides(&["clipy warn", "e warn", " warn"]);
426 assert_eq!(typo.applied_to("pre-commit-clippy"), None);
427 }
428}
429
430pub fn effective_override(repo: Option<&Path>, check: &str) -> Option<Severity> {
440 overrides_in(repo).applied_to(check).map(|(_, s)| s)
441}
442
443pub fn effective_key(repo: Option<&Path>, check: &str) -> Option<String> {
449 overrides_in(repo)
450 .applied_to(check)
451 .map(|(pattern, _)| pattern.to_string())
452}
453
454fn overrides_in(repo: Option<&Path>) -> Overrides {
455 let args = ["config", "--get-regexp", r"^amont\.severity\."];
456 Overrides::from_config(match repo {
457 None => crate::git::stdout(&args),
458 Some(dir) => crate::git::stdout_in(dir, &args),
459 })
460}
461
462pub fn stage_checks(stage: Stage) -> impl Iterator<Item = &'static Builtin> {
464 CHECKS.iter().filter(move |check| check.stage == stage)
465}
466
467pub fn all_stage_checks(stage: Stage) -> Vec<&'static dyn Check> {
474 let mut out: Vec<&'static dyn Check> = stage_checks(stage)
475 .map(|check| check as &dyn Check)
476 .collect();
477 out.extend(
478 crate::manifest::externals()
479 .iter()
480 .filter(|external| external.stage == stage)
481 .map(|external| external as &dyn Check),
482 );
483 out
484}
485
486pub fn lookup(name: &str) -> Option<HookFn> {
487 if let Some((_, f)) = ENTRYPOINTS.iter().find(|(n, _)| *n == name) {
488 return Some(*f);
489 }
490 if CHECKS.iter().any(|check| check.name == name)
495 || crate::manifest::externals()
496 .iter()
497 .any(|external| external.id == name)
498 {
499 return Some(|ctx: &Ctx| {
500 let check = one_named(ctx.name).expect("checked above");
501 Verdict::blocking(matches!(
502 (check.run(ctx), severity_of(check)),
503 (Outcome::Failed, Severity::Block)
504 ))
505 });
506 }
507 None
508}
509
510pub fn one_named(name: &str) -> Option<&'static dyn Check> {
514 if let Some(builtin) = CHECKS.iter().find(|check| check.name == name) {
515 return Some(builtin);
516 }
517 crate::manifest::externals()
518 .iter()
519 .find(|external| external.id == name)
520 .map(|external| external as &dyn Check)
521}
522
523#[cfg(test)]
524mod tests {
525 use super::{lookup, Overrides, Severity, Stage, CHECKS, ENTRYPOINTS};
526 use std::collections::BTreeSet;
527
528 #[test]
535 fn the_batch_agrees_with_the_authority() {
536 let d = std::env::temp_dir().join(format!("ov-{}", std::process::id()));
537 let _ = std::fs::remove_dir_all(&d);
538 std::fs::create_dir_all(&d).unwrap();
539 let git = |args: &[&str]| {
540 std::process::Command::new("git")
541 .args(args)
542 .current_dir(&d)
543 .output()
544 .expect("git");
545 };
546 git(&["init", "-q", "--template=", "."]);
547 let key = "amont.severity.pre-commit-merge-conflict";
548 git(&["config", "--add", key, "warn"]);
549 git(&["config", "--add", key, "block"]);
550
551 let raw = std::process::Command::new("git")
552 .args(["config", "--get-regexp", r"^amont\.severity\."])
553 .current_dir(&d)
554 .output()
555 .expect("git");
556 let batch = Overrides::from_config(Some(
557 String::from_utf8_lossy(&raw.stdout).trim().to_string(),
558 ));
559 let authority =
560 crate::git::stdout_in(&d, &["config", "--get", key]).and_then(|v| Severity::parse(&v));
561 let _ = std::fs::remove_dir_all(&d);
562
563 assert_eq!(
564 authority,
565 Some(Severity::Block),
566 "git applies the last entry"
567 );
568 assert_eq!(
569 batch.0.get("pre-commit-merge-conflict").copied(),
570 authority,
571 "the batch reader disagreed with `--get`"
572 );
573 }
574
575 #[test]
579 fn an_unrecognised_value_clears_rather_than_overrides() {
580 let o = Overrides::from_config(Some(
581 "amont.severity.a warn\namont.severity.a advisory\namont.severity.b warn".to_string(),
582 ));
583 assert_eq!(o.0.get("a"), None, "a typo must not leave `warn` standing");
584 assert_eq!(o.0.get("b").copied(), Some(Severity::Warn));
585 }
586
587 #[test]
588 fn names_are_unique_across_entrypoints_and_checks() {
589 let mut seen = BTreeSet::new();
590 for n in ENTRYPOINTS
591 .iter()
592 .map(|(n, _)| *n)
593 .chain(CHECKS.iter().map(|check| check.name))
594 {
595 assert!(seen.insert(n), "duplicate registration: {n}");
596 }
597 }
598
599 #[test]
601 fn the_shipped_shims_are_exactly_the_git_invoked_hooks() {
602 let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/../../templates/hooks");
603 let mut shipped: Vec<String> = std::fs::read_dir(dir)
604 .expect("templates/hooks")
605 .flatten()
606 .map(|entry| entry.file_name().to_string_lossy().into_owned())
607 .collect();
608 shipped.sort();
609 assert_eq!(
610 shipped,
611 vec!["commit-msg", "pre-commit", "pre-push", "prepare-commit-msg"]
612 );
613 for name in &shipped {
614 assert!(
615 lookup(name).is_some(),
616 "shipped shim {name:?} has no handler"
617 );
618 }
619 }
620
621 #[test]
624 fn every_check_is_reachable_by_name() {
625 for check in CHECKS {
626 assert!(lookup(check.name).is_some(), "{} not reachable", check.name);
627 }
628 assert!(lookup("pre-commit-not-a-check").is_none());
629 }
630
631 #[test]
633 fn pre_push_runs_cheapest_first() {
634 let order: Vec<&str> = super::stage_checks(Stage::PrePush)
635 .map(|check| check.name)
636 .collect();
637 assert_eq!(
638 order,
639 vec![
640 "pre-push-branch-protect",
641 "pre-push-branch-pattern",
642 "pre-push-pull-rebase",
643 "pre-push-run-tests-js",
644 "pre-push-cargo-test",
645 ]
646 );
647 }
648
649 enum Consumes {
655 All,
656 Exts(&'static [&'static str]),
657 }
658
659 const CONSUMED: &[(&str, Consumes)] = &[
673 (
674 "pre-commit-argo-lint",
675 Consumes::Exts(crate::hooks::k8s::EXTS),
676 ),
677 ("pre-commit-ban-terms", Consumes::All),
681 (
682 "pre-commit-cargo-fmt",
683 Consumes::Exts(crate::hooks::rust_tools::EXTS),
684 ),
685 (
686 "pre-commit-clippy",
687 Consumes::Exts(crate::hooks::rust_tools::RUST_PATHS),
688 ),
689 (
690 "pre-commit-kube-linter",
691 Consumes::Exts(crate::hooks::k8s::EXTS),
692 ),
693 (
694 "pre-commit-kubeconform",
695 Consumes::Exts(crate::hooks::k8s::EXTS),
696 ),
697 (
698 "pre-commit-lint-js",
699 Consumes::Exts(crate::hooks::lint_js::EXTS),
700 ),
701 (
702 "pre-commit-lint-json-yaml",
703 Consumes::Exts(crate::hooks::lint_json_yaml::EXTS),
704 ),
705 ("pre-commit-merge-conflict", Consumes::All),
706 ("pre-commit-package-lock", Consumes::All),
707 (
711 "pre-commit-prettier",
712 Consumes::Exts(crate::hooks::prettier::EXTS),
713 ),
714 (
715 "pre-commit-pyright",
716 Consumes::Exts(crate::hooks::python_tools::EXTS),
717 ),
718 (
719 "pre-commit-ruff",
720 Consumes::Exts(crate::hooks::python_tools::EXTS),
721 ),
722 ("pre-commit-usual-name", Consumes::All),
723 (
724 "pre-commit-yamllint",
725 Consumes::Exts(crate::hooks::yamllint::EXTS),
726 ),
727 ("pre-push-branch-protect", Consumes::All),
728 ("pre-push-branch-pattern", Consumes::All),
729 ("pre-push-pull-rebase", Consumes::All),
730 (
731 "pre-push-run-tests-js",
732 Consumes::Exts(crate::hooks::run_tests::JS_EXTS),
733 ),
734 (
735 "pre-push-cargo-test",
736 Consumes::Exts(crate::hooks::rust_tools::RUST_PATHS),
737 ),
738 ];
739
740 #[test]
751 fn no_check_declares_a_file_type_it_does_not_consume() {
752 for (name, _) in CONSUMED {
753 assert!(
754 CHECKS.iter().any(|check| check.name == *name),
755 "CONSUMED names {name:?}, which is not a check"
756 );
757 }
758 for check in CHECKS {
759 let entry = CONSUMED.iter().find(|(name, _)| *name == check.name);
760 if check.scope.files.is_empty() && entry.is_none() {
761 continue;
762 }
763 let Some((_, consumes)) = entry else {
764 panic!(
765 "{} declares scope.files {:?} but is missing from CONSUMED — \
766 say what it actually reads",
767 check.name, check.scope.files
768 );
769 };
770 let Consumes::Exts(consumed) = consumes else {
771 continue; };
773 for ext in check.scope.files {
774 assert!(
775 consumed.contains(ext),
776 "{} declares {ext:?} in its scope but never asks for it — \
777 `amont list` would report a coverage the check does not have",
778 check.name
779 );
780 }
781 }
782 }
783
784 const HAS_FIXING_CODE: &[(&str, bool)] = &[
793 ("pre-commit-argo-lint", false),
794 ("pre-commit-ban-terms", false),
795 ("pre-commit-cargo-fmt", true),
796 ("pre-commit-clippy", false),
797 ("pre-commit-kube-linter", false),
798 ("pre-commit-kubeconform", false),
799 ("pre-commit-lint-js", false),
800 ("pre-commit-lint-json-yaml", false),
801 ("pre-commit-merge-conflict", false),
802 ("pre-commit-package-lock", false),
803 ("pre-commit-prettier", true),
804 ("pre-commit-pyright", false),
805 ("pre-commit-ruff", true),
806 ("pre-commit-usual-name", false),
807 ("pre-commit-yamllint", false),
808 ("pre-push-branch-protect", false),
809 ("pre-push-branch-pattern", false),
810 ("pre-push-pull-rebase", false),
811 ("pre-push-run-tests-js", false),
812 ("pre-push-cargo-test", false),
813 ];
814
815 #[test]
818 fn every_rewrite_declaration_has_a_fixer() {
819 let declared: BTreeSet<&str> = CHECKS
820 .iter()
821 .filter(|check| check.fix == super::Fix::Rewrite)
822 .map(|check| check.name)
823 .collect();
824 let implemented: BTreeSet<&str> = HAS_FIXING_CODE
825 .iter()
826 .filter(|(_, has)| *has)
827 .map(|(name, _)| *name)
828 .collect();
829 assert_eq!(
830 declared, implemented,
831 "a check declaring Fix::Rewrite with no fixer lies to `amont list --json`, \
832 and a check with a fixer that does not declare it can never be reached"
833 );
834
835 let listed: BTreeSet<&str> = HAS_FIXING_CODE.iter().map(|(name, _)| *name).collect();
838 let all: BTreeSet<&str> = CHECKS.iter().map(|check| check.name).collect();
839 assert_eq!(listed, all, "HAS_FIXING_CODE does not cover CHECKS");
840 }
841
842 #[test]
845 fn every_check_declares_a_stage_and_a_scope() {
846 assert_eq!(CHECKS.len(), 20);
847 let pre_commit = super::stage_checks(Stage::PreCommit).count();
848 let pre_push = super::stage_checks(Stage::PrePush).count();
849 assert_eq!(
850 pre_commit + pre_push,
851 CHECKS.len(),
852 "every check has a stage"
853 );
854 }
855}