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 pub manifest: &'a crate::manifest::Manifest,
39}
40
41pub type HookFn = fn(&Ctx) -> Verdict;
42
43pub const ENTRYPOINTS: &[(&str, HookFn)] = &[
46 ("pre-commit", dispatch::pre_commit),
47 ("pre-push", dispatch::pre_push),
48 ("commit-msg", |ctx| hooks::commit_msg::run(ctx.args)),
49 ("prepare-commit-msg", |ctx| {
50 hooks::prepare_commit_msg::run(ctx.args)
51 }),
52 ("post-commit", |ctx| hooks::post_commit::run(ctx)),
53];
54
55const MID_OPERATION: &[GitState] = &[
74 GitState::Merge,
75 GitState::Rebase,
76 GitState::CherryPick,
77 GitState::Revert,
78];
79
80pub const CHECKS: &[Builtin] = &[
81 Builtin {
83 name: "pre-commit-argo-lint",
84 stage: Stage::PreCommit,
85 scope: Scope::new(
86 hooks::k8s::EXTS,
87 &["kustomization.yaml", "kustomization.yml"],
88 )
89 .not_during(MID_OPERATION),
90 severity: Severity::Block,
91 fix: Fix::None,
92 run: |ctx| hooks::k8s::argo_lint(ctx.args),
93 },
94 Builtin {
95 name: "pre-commit-ban-terms",
96 stage: Stage::PreCommit,
97 scope: Scope::files(&[".js", ".jsx", ".ts", ".tsx", ".vue"]),
98 severity: Severity::Block,
99 fix: Fix::None,
100 run: |ctx| hooks::ban_terms::run(ctx.name, ctx.args),
101 },
102 Builtin {
107 name: "pre-commit-branch-pattern",
108 stage: Stage::PreCommit,
109 scope: Scope::ALWAYS,
110 severity: Severity::Warn,
111 fix: Fix::None,
112 run: |_ctx| hooks::branch_pattern::early(),
113 },
114 Builtin {
115 name: "pre-commit-cargo-fmt",
116 stage: Stage::PreCommit,
117 scope: Scope::new(hooks::rust_tools::EXTS, &["Cargo.toml"]).not_during(MID_OPERATION),
118 severity: Severity::Block,
119 fix: Fix::Rewrite,
120 run: |ctx| hooks::rust_tools::fmt(ctx.args),
121 },
122 Builtin {
123 name: "pre-commit-clippy",
124 stage: Stage::PreCommit,
125 scope: Scope::new(hooks::rust_tools::EXTS, &["Cargo.toml"]).not_during(MID_OPERATION),
126 severity: Severity::Block,
127 fix: Fix::None,
128 run: |ctx| hooks::rust_tools::clippy(ctx.args),
129 },
130 Builtin {
131 name: "pre-commit-kube-linter",
132 stage: Stage::PreCommit,
133 scope: Scope::new(
134 hooks::k8s::EXTS,
135 &[".kube-linter*.yaml", ".kube-linter*.yml"],
136 )
137 .not_during(MID_OPERATION),
138 severity: Severity::Block,
139 fix: Fix::None,
140 run: |ctx| hooks::k8s::kube_linter(ctx.args),
141 },
142 Builtin {
143 name: "pre-commit-kubeconform",
144 stage: Stage::PreCommit,
145 scope: Scope::new(
146 hooks::k8s::EXTS,
147 &["kustomization.yaml", "kustomization.yml"],
148 )
149 .not_during(MID_OPERATION),
150 severity: Severity::Block,
151 fix: Fix::None,
152 run: |ctx| hooks::k8s::kubeconform(ctx.args),
153 },
154 Builtin {
155 name: "pre-commit-lint-js",
156 stage: Stage::PreCommit,
157 scope: Scope::new(hooks::lint_js::EXTS, &["package.json"]).not_during(MID_OPERATION),
158 severity: Severity::Block,
159 fix: Fix::None,
160 run: |ctx| hooks::lint_js::run(ctx.args),
161 },
162 Builtin {
163 name: "pre-commit-lint-json-yaml",
164 stage: Stage::PreCommit,
165 scope: Scope::files(hooks::lint_json_yaml::EXTS).not_during(MID_OPERATION),
166 severity: Severity::Block,
167 fix: Fix::None,
168 run: |ctx| hooks::lint_json_yaml::run(ctx.args),
169 },
170 Builtin {
171 name: "pre-commit-merge-conflict",
172 stage: Stage::PreCommit,
173 scope: Scope::ALWAYS,
174 severity: Severity::Block,
175 fix: Fix::None,
176 run: |ctx| hooks::merge_conflict::run(ctx.name, ctx.args),
177 },
178 Builtin {
179 name: "pre-commit-package-lock",
180 stage: Stage::PreCommit,
181 scope: Scope::new(&[], &["package.json"]),
182 severity: Severity::Block,
183 fix: Fix::None,
184 run: |ctx| hooks::package_lock::run(ctx.args),
185 },
186 Builtin {
187 name: "pre-commit-prettier",
188 stage: Stage::PreCommit,
189 scope: Scope::new(
190 &[],
191 &[
192 ".prettierrc",
193 ".prettierrc.json",
194 ".prettierrc.yml",
195 ".prettierrc.yaml",
196 ".prettierrc.js",
197 "prettier.config.js",
198 ],
199 )
200 .not_during(MID_OPERATION),
201 severity: Severity::Block,
202 fix: Fix::Rewrite,
203 run: |ctx| hooks::prettier::run(ctx.args),
204 },
205 Builtin {
206 name: "pre-commit-pyright",
207 stage: Stage::PreCommit,
208 scope: Scope::new(
209 hooks::python_tools::EXTS,
210 &[
211 "pyrightconfig.json",
212 "pyrightconfig.jsonc",
213 "pyproject.toml",
214 ],
215 )
216 .not_during(MID_OPERATION),
217 severity: Severity::Block,
218 fix: Fix::None,
219 run: |ctx| hooks::python_tools::pyright(ctx.args),
220 },
221 Builtin {
222 name: "pre-commit-ruff",
223 stage: Stage::PreCommit,
224 scope: Scope::new(
225 hooks::python_tools::EXTS,
226 &["ruff.toml", ".ruff.toml", "pyproject.toml"],
227 )
228 .not_during(MID_OPERATION),
229 severity: Severity::Block,
230 fix: Fix::Rewrite,
231 run: |ctx| hooks::python_tools::ruff(ctx.args),
232 },
233 Builtin {
234 name: "pre-commit-usual-name",
235 stage: Stage::PreCommit,
236 scope: Scope::ALWAYS,
237 severity: Severity::Block,
238 fix: Fix::None,
239 run: |ctx| hooks::usual_name::run(ctx.args),
240 },
241 Builtin {
242 name: "pre-commit-yamllint",
243 stage: Stage::PreCommit,
244 scope: Scope::new(
245 hooks::yamllint::EXTS,
246 &[".yamllint.yaml", ".yamllint.yml", ".yamllint"],
247 )
248 .not_during(MID_OPERATION),
249 severity: Severity::Block,
250 fix: Fix::None,
251 run: |ctx| hooks::yamllint::run(ctx.args),
252 },
253 Builtin {
255 name: "pre-push-branch-protect",
256 stage: Stage::PrePush,
257 scope: Scope::ALWAYS,
258 severity: Severity::Block,
259 fix: Fix::None,
260 run: |ctx| hooks::branch_protect::run(ctx.push.get()),
261 },
262 Builtin {
263 name: "pre-push-branch-pattern",
264 stage: Stage::PrePush,
265 scope: Scope::ALWAYS,
266 severity: Severity::Block,
267 fix: Fix::None,
268 run: |ctx| hooks::branch_pattern::run(ctx.push.get(), ctx.args),
269 },
270 Builtin {
271 name: "pre-push-pull-rebase",
272 stage: Stage::PrePush,
273 scope: Scope::ALWAYS.not_during(&[GitState::Rebase, GitState::Merge]),
274 severity: Severity::Block,
275 fix: Fix::None,
276 run: |ctx| hooks::pull_rebase::run(ctx.args),
277 },
278 Builtin {
279 name: "pre-push-run-tests-js",
280 stage: Stage::PrePush,
281 scope: Scope::new(hooks::run_tests::JS_EXTS, &["package.json"])
282 .not_during(&[GitState::Bisect, GitState::Rebase]),
283 severity: Severity::Block,
284 fix: Fix::None,
285 run: |ctx| hooks::run_tests::run(ctx.push.get(), &ctx.manifest.externals),
286 },
287 Builtin {
288 name: "pre-push-cargo-test",
289 stage: Stage::PrePush,
290 scope: Scope::new(hooks::rust_tools::EXTS, &["Cargo.toml"])
291 .not_during(&[GitState::Bisect, GitState::Rebase]),
292 severity: Severity::Block,
293 fix: Fix::None,
294 run: |ctx| hooks::rust_tools::test(ctx.push.get()),
295 },
296];
297
298pub fn severity_of(check: &dyn Check) -> Severity {
304 effective_override(None, check.name()).unwrap_or_else(|| check.severity())
305}
306
307pub fn severity_key(check: &str) -> String {
309 format!("amont.severity.{check}")
310}
311
312#[derive(Debug, Default, Clone)]
323pub struct Overrides(std::collections::BTreeMap<String, Severity>);
324
325impl Overrides {
326 pub fn read() -> Overrides {
327 Overrides::from_config(crate::git::stdout(&[
328 "config",
329 "--get-regexp",
330 r"^amont\.severity\.",
331 ]))
332 }
333
334 pub fn from_config(out: Option<String>) -> Overrides {
342 let mut map = std::collections::BTreeMap::new();
343 for line in out.as_deref().unwrap_or_default().lines() {
344 let Some((key, value)) = line.split_once(' ') else {
345 continue;
346 };
347 let Some(check) = key.strip_prefix("amont.severity.") else {
348 continue;
349 };
350 match Severity::parse(value.trim()) {
353 Some(s) => {
354 map.insert(check.to_string(), s);
355 }
356 None => {
359 map.remove(check);
360 }
361 }
362 }
363 Overrides(map)
364 }
365
366 pub fn applied_to(&self, check: &str) -> Option<(&str, Severity)> {
373 self.0
374 .iter()
375 .filter_map(|(pattern, severity)| {
376 crate::names_check(check, pattern).map(|m| (m, pattern.as_str(), *severity))
377 })
378 .max_by_key(|(m, _, _)| *m)
379 .map(|(_, pattern, severity)| (pattern, severity))
380 }
381
382 pub fn of(&self, check: &dyn Check) -> Severity {
384 self.applied_to(check.name())
385 .map(|(_, severity)| severity)
386 .unwrap_or_else(|| check.severity())
387 }
388}
389
390#[cfg(test)]
391mod precedence {
392 use super::{Overrides, Severity};
393
394 fn overrides(lines: &[&str]) -> Overrides {
395 let text = lines
396 .iter()
397 .map(|l| format!("amont.severity.{l}\n"))
398 .collect::<String>();
399 Overrides::from_config(Some(text))
400 }
401
402 #[test]
406 fn the_more_specific_key_wins() {
407 let both = overrides(&["pre-commit warn", "pre-commit-clippy block"]);
408 assert_eq!(
409 both.applied_to("pre-commit-clippy"),
410 Some(("pre-commit-clippy", Severity::Block)),
411 "a full id beats its trigger"
412 );
413 assert_eq!(
414 both.applied_to("pre-commit-shellcheck"),
415 Some(("pre-commit", Severity::Warn)),
416 "and the trigger still governs every check it did not exempt"
417 );
418 }
419
420 #[test]
423 fn the_three_ways_to_name_a_check_are_ranked() {
424 let all = overrides(&["pre-commit warn", "clippy block", "pre-commit-clippy warn"]);
425 assert_eq!(
426 all.applied_to("pre-commit-clippy"),
427 Some(("pre-commit-clippy", Severity::Warn))
428 );
429
430 let no_full = overrides(&["pre-commit warn", "clippy block"]);
431 assert_eq!(
432 no_full.applied_to("pre-commit-clippy"),
433 Some(("clippy", Severity::Block)),
434 "a short name beats a trigger"
435 );
436 }
437
438 #[test]
441 fn a_key_that_names_no_check_applies_to_nothing() {
442 let typo = overrides(&["clipy warn", "e warn", " warn"]);
443 assert_eq!(typo.applied_to("pre-commit-clippy"), None);
444 }
445}
446
447pub fn effective_override(repo: Option<&Path>, check: &str) -> Option<Severity> {
457 overrides_in(repo).applied_to(check).map(|(_, s)| s)
458}
459
460pub fn effective_key(repo: Option<&Path>, check: &str) -> Option<String> {
466 overrides_in(repo)
467 .applied_to(check)
468 .map(|(pattern, _)| pattern.to_string())
469}
470
471fn overrides_in(repo: Option<&Path>) -> Overrides {
472 let args = ["config", "--get-regexp", r"^amont\.severity\."];
473 Overrides::from_config(match repo {
474 None => crate::git::stdout(&args),
475 Some(dir) => crate::git::stdout_in(dir, &args),
476 })
477}
478
479pub fn stage_checks(stage: Stage) -> impl Iterator<Item = &'static Builtin> {
481 CHECKS.iter().filter(move |check| check.stage == stage)
482}
483
484pub fn all_stage_checks<'a>(
491 stage: Stage,
492 manifest: &'a crate::manifest::Manifest,
493) -> Vec<&'a dyn Check> {
494 let mut out: Vec<&'a dyn Check> = stage_checks(stage)
495 .map(|check| check as &dyn Check)
496 .collect();
497 out.extend(
498 manifest
499 .externals
500 .iter()
501 .filter(|external| external.stage == stage)
502 .map(|external| external as &dyn Check),
503 );
504 out
505}
506
507pub fn lookup(name: &str, manifest: &crate::manifest::Manifest) -> Option<HookFn> {
508 if let Some((_, f)) = ENTRYPOINTS.iter().find(|(n, _)| *n == name) {
509 return Some(*f);
510 }
511 if CHECKS.iter().any(|check| check.name == name)
518 || manifest
519 .externals
520 .iter()
521 .any(|external| external.id == name)
522 {
523 return Some(|ctx: &Ctx| {
524 let check = one_named(ctx.name, ctx.manifest).expect("checked above");
525 Verdict::blocking(matches!(
526 (check.run(ctx), severity_of(check)),
527 (Outcome::Failed, Severity::Block)
528 ))
529 });
530 }
531 None
532}
533
534pub fn one_named<'a>(name: &str, manifest: &'a crate::manifest::Manifest) -> Option<&'a dyn Check> {
538 if let Some(builtin) = CHECKS.iter().find(|check| check.name == name) {
539 return Some(builtin);
540 }
541 manifest
542 .externals
543 .iter()
544 .find(|external| external.id == name)
545 .map(|external| external as &dyn Check)
546}
547
548#[cfg(test)]
549mod tests {
550 use super::{lookup, Overrides, Severity, Stage, CHECKS, ENTRYPOINTS};
551 use std::collections::BTreeSet;
552
553 #[test]
560 fn the_batch_agrees_with_the_authority() {
561 let d = std::env::temp_dir().join(format!("ov-{}", std::process::id()));
562 let _ = std::fs::remove_dir_all(&d);
563 std::fs::create_dir_all(&d).unwrap();
564 let git = |args: &[&str]| {
565 std::process::Command::new("git")
566 .args(args)
567 .current_dir(&d)
568 .output()
569 .expect("git");
570 };
571 git(&["init", "-q", "--template=", "."]);
572 let key = "amont.severity.pre-commit-merge-conflict";
573 git(&["config", "--add", key, "warn"]);
574 git(&["config", "--add", key, "block"]);
575
576 let raw = std::process::Command::new("git")
577 .args(["config", "--get-regexp", r"^amont\.severity\."])
578 .current_dir(&d)
579 .output()
580 .expect("git");
581 let batch = Overrides::from_config(Some(
582 String::from_utf8_lossy(&raw.stdout).trim().to_string(),
583 ));
584 let authority =
585 crate::git::stdout_in(&d, &["config", "--get", key]).and_then(|v| Severity::parse(&v));
586 let _ = std::fs::remove_dir_all(&d);
587
588 assert_eq!(
589 authority,
590 Some(Severity::Block),
591 "git applies the last entry"
592 );
593 assert_eq!(
594 batch.0.get("pre-commit-merge-conflict").copied(),
595 authority,
596 "the batch reader disagreed with `--get`"
597 );
598 }
599
600 #[test]
604 fn an_unrecognised_value_clears_rather_than_overrides() {
605 let o = Overrides::from_config(Some(
606 "amont.severity.a warn\namont.severity.a advisory\namont.severity.b warn".to_string(),
607 ));
608 assert_eq!(o.0.get("a"), None, "a typo must not leave `warn` standing");
609 assert_eq!(o.0.get("b").copied(), Some(Severity::Warn));
610 }
611
612 #[test]
613 fn names_are_unique_across_entrypoints_and_checks() {
614 let mut seen = BTreeSet::new();
615 for n in ENTRYPOINTS
616 .iter()
617 .map(|(n, _)| *n)
618 .chain(CHECKS.iter().map(|check| check.name))
619 {
620 assert!(seen.insert(n), "duplicate registration: {n}");
621 }
622 }
623
624 #[test]
626 fn the_shipped_shims_are_exactly_the_git_invoked_hooks() {
627 let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/../../templates/hooks");
628 let mut shipped: Vec<String> = std::fs::read_dir(dir)
629 .expect("templates/hooks")
630 .flatten()
631 .map(|entry| entry.file_name().to_string_lossy().into_owned())
632 .collect();
633 shipped.sort();
634 assert_eq!(
635 shipped,
636 vec![
637 "commit-msg",
638 "post-commit",
639 "pre-commit",
640 "pre-push",
641 "prepare-commit-msg"
642 ]
643 );
644 let none = crate::manifest::Manifest::default();
645 for name in &shipped {
646 assert!(
647 lookup(name, &none).is_some(),
648 "shipped shim {name:?} has no handler"
649 );
650 }
651 }
652
653 #[test]
656 fn every_check_is_reachable_by_name() {
657 let none = crate::manifest::Manifest::default();
658 for check in CHECKS {
659 assert!(
660 lookup(check.name, &none).is_some(),
661 "{} not reachable",
662 check.name
663 );
664 }
665 assert!(lookup("pre-commit-not-a-check", &none).is_none());
666 }
667
668 #[test]
670 fn pre_push_runs_cheapest_first() {
671 let order: Vec<&str> = super::stage_checks(Stage::PrePush)
672 .map(|check| check.name)
673 .collect();
674 assert_eq!(
675 order,
676 vec![
677 "pre-push-branch-protect",
678 "pre-push-branch-pattern",
679 "pre-push-pull-rebase",
680 "pre-push-run-tests-js",
681 "pre-push-cargo-test",
682 ]
683 );
684 }
685
686 enum Consumes {
692 All,
693 Exts(&'static [&'static str]),
694 }
695
696 const CONSUMED: &[(&str, Consumes)] = &[
710 (
711 "pre-commit-argo-lint",
712 Consumes::Exts(crate::hooks::k8s::EXTS),
713 ),
714 ("pre-commit-ban-terms", Consumes::All),
718 (
719 "pre-commit-cargo-fmt",
720 Consumes::Exts(crate::hooks::rust_tools::EXTS),
721 ),
722 (
723 "pre-commit-clippy",
724 Consumes::Exts(crate::hooks::rust_tools::RUST_PATHS),
725 ),
726 (
727 "pre-commit-kube-linter",
728 Consumes::Exts(crate::hooks::k8s::EXTS),
729 ),
730 (
731 "pre-commit-kubeconform",
732 Consumes::Exts(crate::hooks::k8s::EXTS),
733 ),
734 (
735 "pre-commit-lint-js",
736 Consumes::Exts(crate::hooks::lint_js::EXTS),
737 ),
738 (
739 "pre-commit-lint-json-yaml",
740 Consumes::Exts(crate::hooks::lint_json_yaml::EXTS),
741 ),
742 ("pre-commit-merge-conflict", Consumes::All),
743 ("pre-commit-package-lock", Consumes::All),
744 (
748 "pre-commit-prettier",
749 Consumes::Exts(crate::hooks::prettier::EXTS),
750 ),
751 (
752 "pre-commit-pyright",
753 Consumes::Exts(crate::hooks::python_tools::EXTS),
754 ),
755 (
756 "pre-commit-ruff",
757 Consumes::Exts(crate::hooks::python_tools::EXTS),
758 ),
759 ("pre-commit-usual-name", Consumes::All),
760 (
761 "pre-commit-yamllint",
762 Consumes::Exts(crate::hooks::yamllint::EXTS),
763 ),
764 ("pre-push-branch-protect", Consumes::All),
765 ("pre-push-branch-pattern", Consumes::All),
766 ("pre-push-pull-rebase", Consumes::All),
767 (
768 "pre-push-run-tests-js",
769 Consumes::Exts(crate::hooks::run_tests::JS_EXTS),
770 ),
771 (
772 "pre-push-cargo-test",
773 Consumes::Exts(crate::hooks::rust_tools::RUST_PATHS),
774 ),
775 ];
776
777 #[test]
788 fn no_check_declares_a_file_type_it_does_not_consume() {
789 for (name, _) in CONSUMED {
790 assert!(
791 CHECKS.iter().any(|check| check.name == *name),
792 "CONSUMED names {name:?}, which is not a check"
793 );
794 }
795 for check in CHECKS {
796 let entry = CONSUMED.iter().find(|(name, _)| *name == check.name);
797 if check.scope.files.is_empty() && entry.is_none() {
798 continue;
799 }
800 let Some((_, consumes)) = entry else {
801 panic!(
802 "{} declares scope.files {:?} but is missing from CONSUMED — \
803 say what it actually reads",
804 check.name, check.scope.files
805 );
806 };
807 let Consumes::Exts(consumed) = consumes else {
808 continue; };
810 for ext in check.scope.files {
811 assert!(
812 consumed.contains(ext),
813 "{} declares {ext:?} in its scope but never asks for it — \
814 `amont list` would report a coverage the check does not have",
815 check.name
816 );
817 }
818 }
819 }
820
821 const HAS_FIXING_CODE: &[(&str, bool)] = &[
830 ("pre-commit-argo-lint", false),
831 ("pre-commit-ban-terms", false),
832 ("pre-commit-branch-pattern", false),
833 ("pre-commit-cargo-fmt", true),
834 ("pre-commit-clippy", false),
835 ("pre-commit-kube-linter", false),
836 ("pre-commit-kubeconform", false),
837 ("pre-commit-lint-js", false),
838 ("pre-commit-lint-json-yaml", false),
839 ("pre-commit-merge-conflict", false),
840 ("pre-commit-package-lock", false),
841 ("pre-commit-prettier", true),
842 ("pre-commit-pyright", false),
843 ("pre-commit-ruff", true),
844 ("pre-commit-usual-name", false),
845 ("pre-commit-yamllint", false),
846 ("pre-push-branch-protect", false),
847 ("pre-push-branch-pattern", false),
848 ("pre-push-pull-rebase", false),
849 ("pre-push-run-tests-js", false),
850 ("pre-push-cargo-test", false),
851 ];
852
853 #[test]
856 fn every_rewrite_declaration_has_a_fixer() {
857 let declared: BTreeSet<&str> = CHECKS
858 .iter()
859 .filter(|check| check.fix == super::Fix::Rewrite)
860 .map(|check| check.name)
861 .collect();
862 let implemented: BTreeSet<&str> = HAS_FIXING_CODE
863 .iter()
864 .filter(|(_, has)| *has)
865 .map(|(name, _)| *name)
866 .collect();
867 assert_eq!(
868 declared, implemented,
869 "a check declaring Fix::Rewrite with no fixer lies to `amont list --json`, \
870 and a check with a fixer that does not declare it can never be reached"
871 );
872
873 let listed: BTreeSet<&str> = HAS_FIXING_CODE.iter().map(|(name, _)| *name).collect();
876 let all: BTreeSet<&str> = CHECKS.iter().map(|check| check.name).collect();
877 assert_eq!(listed, all, "HAS_FIXING_CODE does not cover CHECKS");
878 }
879
880 #[test]
883 fn every_check_declares_a_stage_and_a_scope() {
884 assert_eq!(CHECKS.len(), 21);
885 let pre_commit = super::stage_checks(Stage::PreCommit).count();
886 let pre_push = super::stage_checks(Stage::PrePush).count();
887 assert_eq!(
888 pre_commit + pre_push,
889 CHECKS.len(),
890 "every check has a stage"
891 );
892 }
893}