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 {
102 name: "pre-commit-branch-pattern",
103 stage: Stage::PreCommit,
104 scope: Scope::ALWAYS,
105 severity: Severity::Warn,
106 fix: Fix::None,
107 run: |_ctx| hooks::branch_pattern::early(),
108 },
109 Builtin {
110 name: "pre-commit-cargo-fmt",
111 stage: Stage::PreCommit,
112 scope: Scope::new(hooks::rust_tools::EXTS, &["Cargo.toml"]).not_during(MID_OPERATION),
113 severity: Severity::Block,
114 fix: Fix::Rewrite,
115 run: |ctx| hooks::rust_tools::fmt(ctx.args),
116 },
117 Builtin {
118 name: "pre-commit-clippy",
119 stage: Stage::PreCommit,
120 scope: Scope::new(hooks::rust_tools::EXTS, &["Cargo.toml"]).not_during(MID_OPERATION),
121 severity: Severity::Block,
122 fix: Fix::None,
123 run: |ctx| hooks::rust_tools::clippy(ctx.args),
124 },
125 Builtin {
126 name: "pre-commit-kube-linter",
127 stage: Stage::PreCommit,
128 scope: Scope::new(
129 hooks::k8s::EXTS,
130 &[".kube-linter*.yaml", ".kube-linter*.yml"],
131 )
132 .not_during(MID_OPERATION),
133 severity: Severity::Block,
134 fix: Fix::None,
135 run: |ctx| hooks::k8s::kube_linter(ctx.args),
136 },
137 Builtin {
138 name: "pre-commit-kubeconform",
139 stage: Stage::PreCommit,
140 scope: Scope::new(
141 hooks::k8s::EXTS,
142 &["kustomization.yaml", "kustomization.yml"],
143 )
144 .not_during(MID_OPERATION),
145 severity: Severity::Block,
146 fix: Fix::None,
147 run: |ctx| hooks::k8s::kubeconform(ctx.args),
148 },
149 Builtin {
150 name: "pre-commit-lint-js",
151 stage: Stage::PreCommit,
152 scope: Scope::new(hooks::lint_js::EXTS, &["package.json"]).not_during(MID_OPERATION),
153 severity: Severity::Block,
154 fix: Fix::None,
155 run: |ctx| hooks::lint_js::run(ctx.args),
156 },
157 Builtin {
158 name: "pre-commit-lint-json-yaml",
159 stage: Stage::PreCommit,
160 scope: Scope::files(hooks::lint_json_yaml::EXTS).not_during(MID_OPERATION),
161 severity: Severity::Block,
162 fix: Fix::None,
163 run: |ctx| hooks::lint_json_yaml::run(ctx.args),
164 },
165 Builtin {
166 name: "pre-commit-merge-conflict",
167 stage: Stage::PreCommit,
168 scope: Scope::ALWAYS,
169 severity: Severity::Block,
170 fix: Fix::None,
171 run: |ctx| hooks::merge_conflict::run(ctx.name, ctx.args),
172 },
173 Builtin {
174 name: "pre-commit-package-lock",
175 stage: Stage::PreCommit,
176 scope: Scope::new(&[], &["package.json"]),
177 severity: Severity::Block,
178 fix: Fix::None,
179 run: |ctx| hooks::package_lock::run(ctx.args),
180 },
181 Builtin {
182 name: "pre-commit-prettier",
183 stage: Stage::PreCommit,
184 scope: Scope::new(
185 &[],
186 &[
187 ".prettierrc",
188 ".prettierrc.json",
189 ".prettierrc.yml",
190 ".prettierrc.yaml",
191 ".prettierrc.js",
192 "prettier.config.js",
193 ],
194 )
195 .not_during(MID_OPERATION),
196 severity: Severity::Block,
197 fix: Fix::Rewrite,
198 run: |ctx| hooks::prettier::run(ctx.args),
199 },
200 Builtin {
201 name: "pre-commit-pyright",
202 stage: Stage::PreCommit,
203 scope: Scope::new(
204 hooks::python_tools::EXTS,
205 &[
206 "pyrightconfig.json",
207 "pyrightconfig.jsonc",
208 "pyproject.toml",
209 ],
210 )
211 .not_during(MID_OPERATION),
212 severity: Severity::Block,
213 fix: Fix::None,
214 run: |ctx| hooks::python_tools::pyright(ctx.args),
215 },
216 Builtin {
217 name: "pre-commit-ruff",
218 stage: Stage::PreCommit,
219 scope: Scope::new(
220 hooks::python_tools::EXTS,
221 &["ruff.toml", ".ruff.toml", "pyproject.toml"],
222 )
223 .not_during(MID_OPERATION),
224 severity: Severity::Block,
225 fix: Fix::Rewrite,
226 run: |ctx| hooks::python_tools::ruff(ctx.args),
227 },
228 Builtin {
229 name: "pre-commit-usual-name",
230 stage: Stage::PreCommit,
231 scope: Scope::ALWAYS,
232 severity: Severity::Block,
233 fix: Fix::None,
234 run: |ctx| hooks::usual_name::run(ctx.args),
235 },
236 Builtin {
237 name: "pre-commit-yamllint",
238 stage: Stage::PreCommit,
239 scope: Scope::new(
240 hooks::yamllint::EXTS,
241 &[".yamllint.yaml", ".yamllint.yml", ".yamllint"],
242 )
243 .not_during(MID_OPERATION),
244 severity: Severity::Block,
245 fix: Fix::None,
246 run: |ctx| hooks::yamllint::run(ctx.args),
247 },
248 Builtin {
250 name: "pre-push-branch-protect",
251 stage: Stage::PrePush,
252 scope: Scope::ALWAYS,
253 severity: Severity::Block,
254 fix: Fix::None,
255 run: |ctx| hooks::branch_protect::run(ctx.push.get()),
256 },
257 Builtin {
258 name: "pre-push-branch-pattern",
259 stage: Stage::PrePush,
260 scope: Scope::ALWAYS,
261 severity: Severity::Block,
262 fix: Fix::None,
263 run: |ctx| hooks::branch_pattern::run(ctx.push.get(), ctx.args),
264 },
265 Builtin {
266 name: "pre-push-pull-rebase",
267 stage: Stage::PrePush,
268 scope: Scope::ALWAYS.not_during(&[GitState::Rebase, GitState::Merge]),
269 severity: Severity::Block,
270 fix: Fix::None,
271 run: |ctx| hooks::pull_rebase::run(ctx.args),
272 },
273 Builtin {
274 name: "pre-push-run-tests-js",
275 stage: Stage::PrePush,
276 scope: Scope::new(hooks::run_tests::JS_EXTS, &["package.json"])
277 .not_during(&[GitState::Bisect, GitState::Rebase]),
278 severity: Severity::Block,
279 fix: Fix::None,
280 run: |ctx| hooks::run_tests::run(ctx.push.get()),
281 },
282 Builtin {
283 name: "pre-push-cargo-test",
284 stage: Stage::PrePush,
285 scope: Scope::new(hooks::rust_tools::EXTS, &["Cargo.toml"])
286 .not_during(&[GitState::Bisect, GitState::Rebase]),
287 severity: Severity::Block,
288 fix: Fix::None,
289 run: |ctx| hooks::rust_tools::test(ctx.push.get()),
290 },
291];
292
293pub fn severity_of(check: &dyn Check) -> Severity {
299 effective_override(None, check.name()).unwrap_or_else(|| check.severity())
300}
301
302pub fn severity_key(check: &str) -> String {
304 format!("amont.severity.{check}")
305}
306
307#[derive(Debug, Default, Clone)]
318pub struct Overrides(std::collections::BTreeMap<String, Severity>);
319
320impl Overrides {
321 pub fn read() -> Overrides {
322 Overrides::from_config(crate::git::stdout(&[
323 "config",
324 "--get-regexp",
325 r"^amont\.severity\.",
326 ]))
327 }
328
329 pub fn from_config(out: Option<String>) -> Overrides {
337 let mut map = std::collections::BTreeMap::new();
338 for line in out.as_deref().unwrap_or_default().lines() {
339 let Some((key, value)) = line.split_once(' ') else {
340 continue;
341 };
342 let Some(check) = key.strip_prefix("amont.severity.") else {
343 continue;
344 };
345 match Severity::parse(value.trim()) {
348 Some(s) => {
349 map.insert(check.to_string(), s);
350 }
351 None => {
354 map.remove(check);
355 }
356 }
357 }
358 Overrides(map)
359 }
360
361 pub fn applied_to(&self, check: &str) -> Option<(&str, Severity)> {
368 self.0
369 .iter()
370 .filter_map(|(pattern, severity)| {
371 crate::names_check(check, pattern).map(|m| (m, pattern.as_str(), *severity))
372 })
373 .max_by_key(|(m, _, _)| *m)
374 .map(|(_, pattern, severity)| (pattern, severity))
375 }
376
377 pub fn of(&self, check: &dyn Check) -> Severity {
379 self.applied_to(check.name())
380 .map(|(_, severity)| severity)
381 .unwrap_or_else(|| check.severity())
382 }
383}
384
385#[cfg(test)]
386mod precedence {
387 use super::{Overrides, Severity};
388
389 fn overrides(lines: &[&str]) -> Overrides {
390 let text = lines
391 .iter()
392 .map(|l| format!("amont.severity.{l}\n"))
393 .collect::<String>();
394 Overrides::from_config(Some(text))
395 }
396
397 #[test]
401 fn the_more_specific_key_wins() {
402 let both = overrides(&["pre-commit warn", "pre-commit-clippy block"]);
403 assert_eq!(
404 both.applied_to("pre-commit-clippy"),
405 Some(("pre-commit-clippy", Severity::Block)),
406 "a full id beats its trigger"
407 );
408 assert_eq!(
409 both.applied_to("pre-commit-shellcheck"),
410 Some(("pre-commit", Severity::Warn)),
411 "and the trigger still governs every check it did not exempt"
412 );
413 }
414
415 #[test]
418 fn the_three_ways_to_name_a_check_are_ranked() {
419 let all = overrides(&["pre-commit warn", "clippy block", "pre-commit-clippy warn"]);
420 assert_eq!(
421 all.applied_to("pre-commit-clippy"),
422 Some(("pre-commit-clippy", Severity::Warn))
423 );
424
425 let no_full = overrides(&["pre-commit warn", "clippy block"]);
426 assert_eq!(
427 no_full.applied_to("pre-commit-clippy"),
428 Some(("clippy", Severity::Block)),
429 "a short name beats a trigger"
430 );
431 }
432
433 #[test]
436 fn a_key_that_names_no_check_applies_to_nothing() {
437 let typo = overrides(&["clipy warn", "e warn", " warn"]);
438 assert_eq!(typo.applied_to("pre-commit-clippy"), None);
439 }
440}
441
442pub fn effective_override(repo: Option<&Path>, check: &str) -> Option<Severity> {
452 overrides_in(repo).applied_to(check).map(|(_, s)| s)
453}
454
455pub fn effective_key(repo: Option<&Path>, check: &str) -> Option<String> {
461 overrides_in(repo)
462 .applied_to(check)
463 .map(|(pattern, _)| pattern.to_string())
464}
465
466fn overrides_in(repo: Option<&Path>) -> Overrides {
467 let args = ["config", "--get-regexp", r"^amont\.severity\."];
468 Overrides::from_config(match repo {
469 None => crate::git::stdout(&args),
470 Some(dir) => crate::git::stdout_in(dir, &args),
471 })
472}
473
474pub fn stage_checks(stage: Stage) -> impl Iterator<Item = &'static Builtin> {
476 CHECKS.iter().filter(move |check| check.stage == stage)
477}
478
479pub fn all_stage_checks(stage: Stage) -> Vec<&'static dyn Check> {
486 let mut out: Vec<&'static dyn Check> = stage_checks(stage)
487 .map(|check| check as &dyn Check)
488 .collect();
489 out.extend(
490 crate::manifest::externals()
491 .iter()
492 .filter(|external| external.stage == stage)
493 .map(|external| external as &dyn Check),
494 );
495 out
496}
497
498pub fn lookup(name: &str) -> Option<HookFn> {
499 if let Some((_, f)) = ENTRYPOINTS.iter().find(|(n, _)| *n == name) {
500 return Some(*f);
501 }
502 if CHECKS.iter().any(|check| check.name == name)
507 || crate::manifest::externals()
508 .iter()
509 .any(|external| external.id == name)
510 {
511 return Some(|ctx: &Ctx| {
512 let check = one_named(ctx.name).expect("checked above");
513 Verdict::blocking(matches!(
514 (check.run(ctx), severity_of(check)),
515 (Outcome::Failed, Severity::Block)
516 ))
517 });
518 }
519 None
520}
521
522pub fn one_named(name: &str) -> Option<&'static dyn Check> {
526 if let Some(builtin) = CHECKS.iter().find(|check| check.name == name) {
527 return Some(builtin);
528 }
529 crate::manifest::externals()
530 .iter()
531 .find(|external| external.id == name)
532 .map(|external| external as &dyn Check)
533}
534
535#[cfg(test)]
536mod tests {
537 use super::{lookup, Overrides, Severity, Stage, CHECKS, ENTRYPOINTS};
538 use std::collections::BTreeSet;
539
540 #[test]
547 fn the_batch_agrees_with_the_authority() {
548 let d = std::env::temp_dir().join(format!("ov-{}", std::process::id()));
549 let _ = std::fs::remove_dir_all(&d);
550 std::fs::create_dir_all(&d).unwrap();
551 let git = |args: &[&str]| {
552 std::process::Command::new("git")
553 .args(args)
554 .current_dir(&d)
555 .output()
556 .expect("git");
557 };
558 git(&["init", "-q", "--template=", "."]);
559 let key = "amont.severity.pre-commit-merge-conflict";
560 git(&["config", "--add", key, "warn"]);
561 git(&["config", "--add", key, "block"]);
562
563 let raw = std::process::Command::new("git")
564 .args(["config", "--get-regexp", r"^amont\.severity\."])
565 .current_dir(&d)
566 .output()
567 .expect("git");
568 let batch = Overrides::from_config(Some(
569 String::from_utf8_lossy(&raw.stdout).trim().to_string(),
570 ));
571 let authority =
572 crate::git::stdout_in(&d, &["config", "--get", key]).and_then(|v| Severity::parse(&v));
573 let _ = std::fs::remove_dir_all(&d);
574
575 assert_eq!(
576 authority,
577 Some(Severity::Block),
578 "git applies the last entry"
579 );
580 assert_eq!(
581 batch.0.get("pre-commit-merge-conflict").copied(),
582 authority,
583 "the batch reader disagreed with `--get`"
584 );
585 }
586
587 #[test]
591 fn an_unrecognised_value_clears_rather_than_overrides() {
592 let o = Overrides::from_config(Some(
593 "amont.severity.a warn\namont.severity.a advisory\namont.severity.b warn".to_string(),
594 ));
595 assert_eq!(o.0.get("a"), None, "a typo must not leave `warn` standing");
596 assert_eq!(o.0.get("b").copied(), Some(Severity::Warn));
597 }
598
599 #[test]
600 fn names_are_unique_across_entrypoints_and_checks() {
601 let mut seen = BTreeSet::new();
602 for n in ENTRYPOINTS
603 .iter()
604 .map(|(n, _)| *n)
605 .chain(CHECKS.iter().map(|check| check.name))
606 {
607 assert!(seen.insert(n), "duplicate registration: {n}");
608 }
609 }
610
611 #[test]
613 fn the_shipped_shims_are_exactly_the_git_invoked_hooks() {
614 let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/../../templates/hooks");
615 let mut shipped: Vec<String> = std::fs::read_dir(dir)
616 .expect("templates/hooks")
617 .flatten()
618 .map(|entry| entry.file_name().to_string_lossy().into_owned())
619 .collect();
620 shipped.sort();
621 assert_eq!(
622 shipped,
623 vec!["commit-msg", "pre-commit", "pre-push", "prepare-commit-msg"]
624 );
625 for name in &shipped {
626 assert!(
627 lookup(name).is_some(),
628 "shipped shim {name:?} has no handler"
629 );
630 }
631 }
632
633 #[test]
636 fn every_check_is_reachable_by_name() {
637 for check in CHECKS {
638 assert!(lookup(check.name).is_some(), "{} not reachable", check.name);
639 }
640 assert!(lookup("pre-commit-not-a-check").is_none());
641 }
642
643 #[test]
645 fn pre_push_runs_cheapest_first() {
646 let order: Vec<&str> = super::stage_checks(Stage::PrePush)
647 .map(|check| check.name)
648 .collect();
649 assert_eq!(
650 order,
651 vec![
652 "pre-push-branch-protect",
653 "pre-push-branch-pattern",
654 "pre-push-pull-rebase",
655 "pre-push-run-tests-js",
656 "pre-push-cargo-test",
657 ]
658 );
659 }
660
661 enum Consumes {
667 All,
668 Exts(&'static [&'static str]),
669 }
670
671 const CONSUMED: &[(&str, Consumes)] = &[
685 (
686 "pre-commit-argo-lint",
687 Consumes::Exts(crate::hooks::k8s::EXTS),
688 ),
689 ("pre-commit-ban-terms", Consumes::All),
693 (
694 "pre-commit-cargo-fmt",
695 Consumes::Exts(crate::hooks::rust_tools::EXTS),
696 ),
697 (
698 "pre-commit-clippy",
699 Consumes::Exts(crate::hooks::rust_tools::RUST_PATHS),
700 ),
701 (
702 "pre-commit-kube-linter",
703 Consumes::Exts(crate::hooks::k8s::EXTS),
704 ),
705 (
706 "pre-commit-kubeconform",
707 Consumes::Exts(crate::hooks::k8s::EXTS),
708 ),
709 (
710 "pre-commit-lint-js",
711 Consumes::Exts(crate::hooks::lint_js::EXTS),
712 ),
713 (
714 "pre-commit-lint-json-yaml",
715 Consumes::Exts(crate::hooks::lint_json_yaml::EXTS),
716 ),
717 ("pre-commit-merge-conflict", Consumes::All),
718 ("pre-commit-package-lock", Consumes::All),
719 (
723 "pre-commit-prettier",
724 Consumes::Exts(crate::hooks::prettier::EXTS),
725 ),
726 (
727 "pre-commit-pyright",
728 Consumes::Exts(crate::hooks::python_tools::EXTS),
729 ),
730 (
731 "pre-commit-ruff",
732 Consumes::Exts(crate::hooks::python_tools::EXTS),
733 ),
734 ("pre-commit-usual-name", Consumes::All),
735 (
736 "pre-commit-yamllint",
737 Consumes::Exts(crate::hooks::yamllint::EXTS),
738 ),
739 ("pre-push-branch-protect", Consumes::All),
740 ("pre-push-branch-pattern", Consumes::All),
741 ("pre-push-pull-rebase", Consumes::All),
742 (
743 "pre-push-run-tests-js",
744 Consumes::Exts(crate::hooks::run_tests::JS_EXTS),
745 ),
746 (
747 "pre-push-cargo-test",
748 Consumes::Exts(crate::hooks::rust_tools::RUST_PATHS),
749 ),
750 ];
751
752 #[test]
763 fn no_check_declares_a_file_type_it_does_not_consume() {
764 for (name, _) in CONSUMED {
765 assert!(
766 CHECKS.iter().any(|check| check.name == *name),
767 "CONSUMED names {name:?}, which is not a check"
768 );
769 }
770 for check in CHECKS {
771 let entry = CONSUMED.iter().find(|(name, _)| *name == check.name);
772 if check.scope.files.is_empty() && entry.is_none() {
773 continue;
774 }
775 let Some((_, consumes)) = entry else {
776 panic!(
777 "{} declares scope.files {:?} but is missing from CONSUMED — \
778 say what it actually reads",
779 check.name, check.scope.files
780 );
781 };
782 let Consumes::Exts(consumed) = consumes else {
783 continue; };
785 for ext in check.scope.files {
786 assert!(
787 consumed.contains(ext),
788 "{} declares {ext:?} in its scope but never asks for it — \
789 `amont list` would report a coverage the check does not have",
790 check.name
791 );
792 }
793 }
794 }
795
796 const HAS_FIXING_CODE: &[(&str, bool)] = &[
805 ("pre-commit-argo-lint", false),
806 ("pre-commit-ban-terms", false),
807 ("pre-commit-branch-pattern", false),
808 ("pre-commit-cargo-fmt", true),
809 ("pre-commit-clippy", false),
810 ("pre-commit-kube-linter", false),
811 ("pre-commit-kubeconform", false),
812 ("pre-commit-lint-js", false),
813 ("pre-commit-lint-json-yaml", false),
814 ("pre-commit-merge-conflict", false),
815 ("pre-commit-package-lock", false),
816 ("pre-commit-prettier", true),
817 ("pre-commit-pyright", false),
818 ("pre-commit-ruff", true),
819 ("pre-commit-usual-name", false),
820 ("pre-commit-yamllint", false),
821 ("pre-push-branch-protect", false),
822 ("pre-push-branch-pattern", false),
823 ("pre-push-pull-rebase", false),
824 ("pre-push-run-tests-js", false),
825 ("pre-push-cargo-test", false),
826 ];
827
828 #[test]
831 fn every_rewrite_declaration_has_a_fixer() {
832 let declared: BTreeSet<&str> = CHECKS
833 .iter()
834 .filter(|check| check.fix == super::Fix::Rewrite)
835 .map(|check| check.name)
836 .collect();
837 let implemented: BTreeSet<&str> = HAS_FIXING_CODE
838 .iter()
839 .filter(|(_, has)| *has)
840 .map(|(name, _)| *name)
841 .collect();
842 assert_eq!(
843 declared, implemented,
844 "a check declaring Fix::Rewrite with no fixer lies to `amont list --json`, \
845 and a check with a fixer that does not declare it can never be reached"
846 );
847
848 let listed: BTreeSet<&str> = HAS_FIXING_CODE.iter().map(|(name, _)| *name).collect();
851 let all: BTreeSet<&str> = CHECKS.iter().map(|check| check.name).collect();
852 assert_eq!(listed, all, "HAS_FIXING_CODE does not cover CHECKS");
853 }
854
855 #[test]
858 fn every_check_declares_a_stage_and_a_scope() {
859 assert_eq!(CHECKS.len(), 21);
860 let pre_commit = super::stage_checks(Stage::PreCommit).count();
861 let pre_push = super::stage_checks(Stage::PrePush).count();
862 assert_eq!(
863 pre_commit + pre_push,
864 CHECKS.len(),
865 "every check has a stage"
866 );
867 }
868}