use std::ffi::OsString;
use std::path::Path;
use crate::check::{Builtin, Check, Fix, GitState, Outcome, Scope, Severity, Stage, Verdict};
use crate::pushrefs::PushRefs;
use crate::{dispatch, hooks};
pub struct Ctx<'a> {
pub name: &'a str,
pub args: &'a [OsString],
pub hooks_dir: &'a Path,
pub push: &'a PushRefs,
}
pub type HookFn = fn(&Ctx) -> Verdict;
pub const ENTRYPOINTS: &[(&str, HookFn)] = &[
("pre-commit", dispatch::pre_commit),
("pre-push", dispatch::pre_push),
("commit-msg", |ctx| hooks::commit_msg::run(ctx.args)),
("prepare-commit-msg", |ctx| {
hooks::prepare_commit_msg::run(ctx.args)
}),
];
const MID_OPERATION: &[GitState] = &[
GitState::Merge,
GitState::Rebase,
GitState::CherryPick,
GitState::Revert,
];
pub const CHECKS: &[Builtin] = &[
Builtin {
name: "pre-commit-argo-lint",
stage: Stage::PreCommit,
scope: Scope::new(
hooks::k8s::EXTS,
&["kustomization.yaml", "kustomization.yml"],
)
.not_during(MID_OPERATION),
severity: Severity::Block,
fix: Fix::None,
run: |ctx| hooks::k8s::argo_lint(ctx.args),
},
Builtin {
name: "pre-commit-ban-terms",
stage: Stage::PreCommit,
scope: Scope::files(&[".js", ".jsx", ".ts", ".tsx", ".vue"]),
severity: Severity::Block,
fix: Fix::None,
run: |ctx| hooks::ban_terms::run(ctx.name, ctx.args),
},
Builtin {
name: "pre-commit-cargo-fmt",
stage: Stage::PreCommit,
scope: Scope::new(hooks::rust_tools::EXTS, &["Cargo.toml"]).not_during(MID_OPERATION),
severity: Severity::Block,
fix: Fix::Rewrite,
run: |ctx| hooks::rust_tools::fmt(ctx.args),
},
Builtin {
name: "pre-commit-clippy",
stage: Stage::PreCommit,
scope: Scope::new(hooks::rust_tools::EXTS, &["Cargo.toml"]).not_during(MID_OPERATION),
severity: Severity::Block,
fix: Fix::None,
run: |ctx| hooks::rust_tools::clippy(ctx.args),
},
Builtin {
name: "pre-commit-kube-linter",
stage: Stage::PreCommit,
scope: Scope::new(
hooks::k8s::EXTS,
&[".kube-linter*.yaml", ".kube-linter*.yml"],
)
.not_during(MID_OPERATION),
severity: Severity::Block,
fix: Fix::None,
run: |ctx| hooks::k8s::kube_linter(ctx.args),
},
Builtin {
name: "pre-commit-kubeconform",
stage: Stage::PreCommit,
scope: Scope::new(
hooks::k8s::EXTS,
&["kustomization.yaml", "kustomization.yml"],
)
.not_during(MID_OPERATION),
severity: Severity::Block,
fix: Fix::None,
run: |ctx| hooks::k8s::kubeconform(ctx.args),
},
Builtin {
name: "pre-commit-lint-js",
stage: Stage::PreCommit,
scope: Scope::new(hooks::lint_js::EXTS, &["package.json"]).not_during(MID_OPERATION),
severity: Severity::Block,
fix: Fix::None,
run: |ctx| hooks::lint_js::run(ctx.args),
},
Builtin {
name: "pre-commit-lint-json-yaml",
stage: Stage::PreCommit,
scope: Scope::files(hooks::lint_json_yaml::EXTS).not_during(MID_OPERATION),
severity: Severity::Block,
fix: Fix::None,
run: |ctx| hooks::lint_json_yaml::run(ctx.args),
},
Builtin {
name: "pre-commit-merge-conflict",
stage: Stage::PreCommit,
scope: Scope::ALWAYS,
severity: Severity::Block,
fix: Fix::None,
run: |ctx| hooks::merge_conflict::run(ctx.name, ctx.args),
},
Builtin {
name: "pre-commit-package-lock",
stage: Stage::PreCommit,
scope: Scope::new(&[], &["package.json"]),
severity: Severity::Block,
fix: Fix::None,
run: |ctx| hooks::package_lock::run(ctx.args),
},
Builtin {
name: "pre-commit-prettier",
stage: Stage::PreCommit,
scope: Scope::new(
&[],
&[
".prettierrc",
".prettierrc.json",
".prettierrc.yml",
".prettierrc.yaml",
".prettierrc.js",
"prettier.config.js",
],
)
.not_during(MID_OPERATION),
severity: Severity::Block,
fix: Fix::Rewrite,
run: |ctx| hooks::prettier::run(ctx.args),
},
Builtin {
name: "pre-commit-pyright",
stage: Stage::PreCommit,
scope: Scope::new(
hooks::python_tools::EXTS,
&[
"pyrightconfig.json",
"pyrightconfig.jsonc",
"pyproject.toml",
],
)
.not_during(MID_OPERATION),
severity: Severity::Block,
fix: Fix::None,
run: |ctx| hooks::python_tools::pyright(ctx.args),
},
Builtin {
name: "pre-commit-ruff",
stage: Stage::PreCommit,
scope: Scope::new(
hooks::python_tools::EXTS,
&["ruff.toml", ".ruff.toml", "pyproject.toml"],
)
.not_during(MID_OPERATION),
severity: Severity::Block,
fix: Fix::Rewrite,
run: |ctx| hooks::python_tools::ruff(ctx.args),
},
Builtin {
name: "pre-commit-usual-name",
stage: Stage::PreCommit,
scope: Scope::ALWAYS,
severity: Severity::Block,
fix: Fix::None,
run: |ctx| hooks::usual_name::run(ctx.args),
},
Builtin {
name: "pre-commit-yamllint",
stage: Stage::PreCommit,
scope: Scope::new(
hooks::yamllint::EXTS,
&[".yamllint.yaml", ".yamllint.yml", ".yamllint"],
)
.not_during(MID_OPERATION),
severity: Severity::Block,
fix: Fix::None,
run: |ctx| hooks::yamllint::run(ctx.args),
},
Builtin {
name: "pre-push-branch-protect",
stage: Stage::PrePush,
scope: Scope::ALWAYS,
severity: Severity::Block,
fix: Fix::None,
run: |ctx| hooks::branch_protect::run(ctx.push.get()),
},
Builtin {
name: "pre-push-branch-pattern",
stage: Stage::PrePush,
scope: Scope::ALWAYS,
severity: Severity::Block,
fix: Fix::None,
run: |ctx| hooks::branch_pattern::run(ctx.push.get(), ctx.args),
},
Builtin {
name: "pre-push-pull-rebase",
stage: Stage::PrePush,
scope: Scope::ALWAYS.not_during(&[GitState::Rebase, GitState::Merge]),
severity: Severity::Block,
fix: Fix::None,
run: |ctx| hooks::pull_rebase::run(ctx.args),
},
Builtin {
name: "pre-push-run-tests-js",
stage: Stage::PrePush,
scope: Scope::new(hooks::run_tests::JS_EXTS, &["package.json"])
.not_during(&[GitState::Bisect, GitState::Rebase]),
severity: Severity::Block,
fix: Fix::None,
run: |ctx| hooks::run_tests::run(ctx.push.get()),
},
Builtin {
name: "pre-push-cargo-test",
stage: Stage::PrePush,
scope: Scope::new(hooks::rust_tools::EXTS, &["Cargo.toml"])
.not_during(&[GitState::Bisect, GitState::Rebase]),
severity: Severity::Block,
fix: Fix::None,
run: |ctx| hooks::rust_tools::test(ctx.push.get()),
},
];
pub fn severity_of(check: &dyn Check) -> Severity {
effective_override(None, check.name()).unwrap_or_else(|| check.severity())
}
pub fn severity_key(check: &str) -> String {
format!("amont.severity.{check}")
}
#[derive(Debug, Default, Clone)]
pub struct Overrides(std::collections::BTreeMap<String, Severity>);
impl Overrides {
pub fn read() -> Overrides {
Overrides::from_config(crate::git::stdout(&[
"config",
"--get-regexp",
r"^amont\.severity\.",
]))
}
pub fn from_config(out: Option<String>) -> Overrides {
let mut map = std::collections::BTreeMap::new();
for line in out.as_deref().unwrap_or_default().lines() {
let Some((key, value)) = line.split_once(' ') else {
continue;
};
let Some(check) = key.strip_prefix("amont.severity.") else {
continue;
};
match Severity::parse(value.trim()) {
Some(s) => {
map.insert(check.to_string(), s);
}
None => {
map.remove(check);
}
}
}
Overrides(map)
}
pub fn applied_to(&self, check: &str) -> Option<(&str, Severity)> {
self.0
.iter()
.filter_map(|(pattern, severity)| {
crate::names_check(check, pattern).map(|m| (m, pattern.as_str(), *severity))
})
.max_by_key(|(m, _, _)| *m)
.map(|(_, pattern, severity)| (pattern, severity))
}
pub fn of(&self, check: &dyn Check) -> Severity {
self.applied_to(check.name())
.map(|(_, severity)| severity)
.unwrap_or_else(|| check.severity())
}
}
#[cfg(test)]
mod precedence {
use super::{Overrides, Severity};
fn overrides(lines: &[&str]) -> Overrides {
let text = lines
.iter()
.map(|l| format!("amont.severity.{l}\n"))
.collect::<String>();
Overrides::from_config(Some(text))
}
#[test]
fn the_more_specific_key_wins() {
let both = overrides(&["pre-commit warn", "pre-commit-clippy block"]);
assert_eq!(
both.applied_to("pre-commit-clippy"),
Some(("pre-commit-clippy", Severity::Block)),
"a full id beats its trigger"
);
assert_eq!(
both.applied_to("pre-commit-shellcheck"),
Some(("pre-commit", Severity::Warn)),
"and the trigger still governs every check it did not exempt"
);
}
#[test]
fn the_three_ways_to_name_a_check_are_ranked() {
let all = overrides(&["pre-commit warn", "clippy block", "pre-commit-clippy warn"]);
assert_eq!(
all.applied_to("pre-commit-clippy"),
Some(("pre-commit-clippy", Severity::Warn))
);
let no_full = overrides(&["pre-commit warn", "clippy block"]);
assert_eq!(
no_full.applied_to("pre-commit-clippy"),
Some(("clippy", Severity::Block)),
"a short name beats a trigger"
);
}
#[test]
fn a_key_that_names_no_check_applies_to_nothing() {
let typo = overrides(&["clipy warn", "e warn", " warn"]);
assert_eq!(typo.applied_to("pre-commit-clippy"), None);
}
}
pub fn effective_override(repo: Option<&Path>, check: &str) -> Option<Severity> {
overrides_in(repo).applied_to(check).map(|(_, s)| s)
}
pub fn effective_key(repo: Option<&Path>, check: &str) -> Option<String> {
overrides_in(repo)
.applied_to(check)
.map(|(pattern, _)| pattern.to_string())
}
fn overrides_in(repo: Option<&Path>) -> Overrides {
let args = ["config", "--get-regexp", r"^amont\.severity\."];
Overrides::from_config(match repo {
None => crate::git::stdout(&args),
Some(dir) => crate::git::stdout_in(dir, &args),
})
}
pub fn stage_checks(stage: Stage) -> impl Iterator<Item = &'static Builtin> {
CHECKS.iter().filter(move |check| check.stage == stage)
}
pub fn all_stage_checks(stage: Stage) -> Vec<&'static dyn Check> {
let mut out: Vec<&'static dyn Check> = stage_checks(stage)
.map(|check| check as &dyn Check)
.collect();
out.extend(
crate::manifest::externals()
.iter()
.filter(|external| external.stage == stage)
.map(|external| external as &dyn Check),
);
out
}
pub fn lookup(name: &str) -> Option<HookFn> {
if let Some((_, f)) = ENTRYPOINTS.iter().find(|(n, _)| *n == name) {
return Some(*f);
}
if CHECKS.iter().any(|check| check.name == name)
|| crate::manifest::externals()
.iter()
.any(|external| external.id == name)
{
return Some(|ctx: &Ctx| {
let check = one_named(ctx.name).expect("checked above");
Verdict::blocking(matches!(
(check.run(ctx), severity_of(check)),
(Outcome::Failed, Severity::Block)
))
});
}
None
}
pub fn one_named(name: &str) -> Option<&'static dyn Check> {
if let Some(builtin) = CHECKS.iter().find(|check| check.name == name) {
return Some(builtin);
}
crate::manifest::externals()
.iter()
.find(|external| external.id == name)
.map(|external| external as &dyn Check)
}
#[cfg(test)]
mod tests {
use super::{lookup, Overrides, Severity, Stage, CHECKS, ENTRYPOINTS};
use std::collections::BTreeSet;
#[test]
fn the_batch_agrees_with_the_authority() {
let d = std::env::temp_dir().join(format!("ov-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
let git = |args: &[&str]| {
std::process::Command::new("git")
.args(args)
.current_dir(&d)
.output()
.expect("git");
};
git(&["init", "-q", "--template=", "."]);
let key = "amont.severity.pre-commit-merge-conflict";
git(&["config", "--add", key, "warn"]);
git(&["config", "--add", key, "block"]);
let raw = std::process::Command::new("git")
.args(["config", "--get-regexp", r"^amont\.severity\."])
.current_dir(&d)
.output()
.expect("git");
let batch = Overrides::from_config(Some(
String::from_utf8_lossy(&raw.stdout).trim().to_string(),
));
let authority =
crate::git::stdout_in(&d, &["config", "--get", key]).and_then(|v| Severity::parse(&v));
let _ = std::fs::remove_dir_all(&d);
assert_eq!(
authority,
Some(Severity::Block),
"git applies the last entry"
);
assert_eq!(
batch.0.get("pre-commit-merge-conflict").copied(),
authority,
"the batch reader disagreed with `--get`"
);
}
#[test]
fn an_unrecognised_value_clears_rather_than_overrides() {
let o = Overrides::from_config(Some(
"amont.severity.a warn\namont.severity.a advisory\namont.severity.b warn".to_string(),
));
assert_eq!(o.0.get("a"), None, "a typo must not leave `warn` standing");
assert_eq!(o.0.get("b").copied(), Some(Severity::Warn));
}
#[test]
fn names_are_unique_across_entrypoints_and_checks() {
let mut seen = BTreeSet::new();
for n in ENTRYPOINTS
.iter()
.map(|(n, _)| *n)
.chain(CHECKS.iter().map(|check| check.name))
{
assert!(seen.insert(n), "duplicate registration: {n}");
}
}
#[test]
fn the_shipped_shims_are_exactly_the_git_invoked_hooks() {
let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/../../templates/hooks");
let mut shipped: Vec<String> = std::fs::read_dir(dir)
.expect("templates/hooks")
.flatten()
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.collect();
shipped.sort();
assert_eq!(
shipped,
vec!["commit-msg", "pre-commit", "pre-push", "prepare-commit-msg"]
);
for name in &shipped {
assert!(
lookup(name).is_some(),
"shipped shim {name:?} has no handler"
);
}
}
#[test]
fn every_check_is_reachable_by_name() {
for check in CHECKS {
assert!(lookup(check.name).is_some(), "{} not reachable", check.name);
}
assert!(lookup("pre-commit-not-a-check").is_none());
}
#[test]
fn pre_push_runs_cheapest_first() {
let order: Vec<&str> = super::stage_checks(Stage::PrePush)
.map(|check| check.name)
.collect();
assert_eq!(
order,
vec![
"pre-push-branch-protect",
"pre-push-branch-pattern",
"pre-push-pull-rebase",
"pre-push-run-tests-js",
"pre-push-cargo-test",
]
);
}
enum Consumes {
All,
Exts(&'static [&'static str]),
}
const CONSUMED: &[(&str, Consumes)] = &[
(
"pre-commit-argo-lint",
Consumes::Exts(crate::hooks::k8s::EXTS),
),
("pre-commit-ban-terms", Consumes::All),
(
"pre-commit-cargo-fmt",
Consumes::Exts(crate::hooks::rust_tools::EXTS),
),
(
"pre-commit-clippy",
Consumes::Exts(crate::hooks::rust_tools::RUST_PATHS),
),
(
"pre-commit-kube-linter",
Consumes::Exts(crate::hooks::k8s::EXTS),
),
(
"pre-commit-kubeconform",
Consumes::Exts(crate::hooks::k8s::EXTS),
),
(
"pre-commit-lint-js",
Consumes::Exts(crate::hooks::lint_js::EXTS),
),
(
"pre-commit-lint-json-yaml",
Consumes::Exts(crate::hooks::lint_json_yaml::EXTS),
),
("pre-commit-merge-conflict", Consumes::All),
("pre-commit-package-lock", Consumes::All),
(
"pre-commit-prettier",
Consumes::Exts(crate::hooks::prettier::EXTS),
),
(
"pre-commit-pyright",
Consumes::Exts(crate::hooks::python_tools::EXTS),
),
(
"pre-commit-ruff",
Consumes::Exts(crate::hooks::python_tools::EXTS),
),
("pre-commit-usual-name", Consumes::All),
(
"pre-commit-yamllint",
Consumes::Exts(crate::hooks::yamllint::EXTS),
),
("pre-push-branch-protect", Consumes::All),
("pre-push-branch-pattern", Consumes::All),
("pre-push-pull-rebase", Consumes::All),
(
"pre-push-run-tests-js",
Consumes::Exts(crate::hooks::run_tests::JS_EXTS),
),
(
"pre-push-cargo-test",
Consumes::Exts(crate::hooks::rust_tools::RUST_PATHS),
),
];
#[test]
fn no_check_declares_a_file_type_it_does_not_consume() {
for (name, _) in CONSUMED {
assert!(
CHECKS.iter().any(|check| check.name == *name),
"CONSUMED names {name:?}, which is not a check"
);
}
for check in CHECKS {
let entry = CONSUMED.iter().find(|(name, _)| *name == check.name);
if check.scope.files.is_empty() && entry.is_none() {
continue;
}
let Some((_, consumes)) = entry else {
panic!(
"{} declares scope.files {:?} but is missing from CONSUMED — \
say what it actually reads",
check.name, check.scope.files
);
};
let Consumes::Exts(consumed) = consumes else {
continue; };
for ext in check.scope.files {
assert!(
consumed.contains(ext),
"{} declares {ext:?} in its scope but never asks for it — \
`amont list` would report a coverage the check does not have",
check.name
);
}
}
}
const HAS_FIXING_CODE: &[(&str, bool)] = &[
("pre-commit-argo-lint", false),
("pre-commit-ban-terms", false),
("pre-commit-cargo-fmt", true),
("pre-commit-clippy", false),
("pre-commit-kube-linter", false),
("pre-commit-kubeconform", false),
("pre-commit-lint-js", false),
("pre-commit-lint-json-yaml", false),
("pre-commit-merge-conflict", false),
("pre-commit-package-lock", false),
("pre-commit-prettier", true),
("pre-commit-pyright", false),
("pre-commit-ruff", true),
("pre-commit-usual-name", false),
("pre-commit-yamllint", false),
("pre-push-branch-protect", false),
("pre-push-branch-pattern", false),
("pre-push-pull-rebase", false),
("pre-push-run-tests-js", false),
("pre-push-cargo-test", false),
];
#[test]
fn every_rewrite_declaration_has_a_fixer() {
let declared: BTreeSet<&str> = CHECKS
.iter()
.filter(|check| check.fix == super::Fix::Rewrite)
.map(|check| check.name)
.collect();
let implemented: BTreeSet<&str> = HAS_FIXING_CODE
.iter()
.filter(|(_, has)| *has)
.map(|(name, _)| *name)
.collect();
assert_eq!(
declared, implemented,
"a check declaring Fix::Rewrite with no fixer lies to `amont list --json`, \
and a check with a fixer that does not declare it can never be reached"
);
let listed: BTreeSet<&str> = HAS_FIXING_CODE.iter().map(|(name, _)| *name).collect();
let all: BTreeSet<&str> = CHECKS.iter().map(|check| check.name).collect();
assert_eq!(listed, all, "HAS_FIXING_CODE does not cover CHECKS");
}
#[test]
fn every_check_declares_a_stage_and_a_scope() {
assert_eq!(CHECKS.len(), 20);
let pre_commit = super::stage_checks(Stage::PreCommit).count();
let pre_push = super::stage_checks(Stage::PrePush).count();
assert_eq!(
pre_commit + pre_push,
CHECKS.len(),
"every check has a stage"
);
}
}