use std::sync::Mutex;
use crate::check::{Check, Outcome, Severity, Stage, Verdict};
use crate::configured_skips;
use crate::registry::{all_stage_checks, Ctx, Overrides};
use crate::ui::{highlight, valid_sign, warning_sign};
fn selected(stage: Stage) -> Vec<&'static dyn Check> {
selected_during(stage, &[])
}
fn selected_during(
stage: Stage,
in_progress: &[crate::check::GitState],
) -> Vec<&'static dyn Check> {
let skips = configured_skips();
let (kept, dropped): (Vec<_>, Vec<_>) = all_stage_checks(stage)
.into_iter()
.partition(|c| !skips.iter().any(|s| crate::skip_suppresses(c.name(), s)));
let names: Vec<&str> = dropped.iter().map(|c| c.name()).collect();
announce_skips(&names);
let (kept, paused): (Vec<_>, Vec<_>) = kept.into_iter().partition(|check| {
!check
.scope()
.not_during
.iter()
.any(|state| in_progress.contains(state))
});
if !paused.is_empty() {
let what = in_progress
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(" and ");
println!(
"{} {} check(s) paused during {what}: {}",
warning_sign(),
paused.len(),
paused
.iter()
.map(|c| c.name())
.collect::<Vec<_>>()
.join(", ")
);
}
kept
}
fn announce_skips(dropped: &[&str]) {
if dropped.is_empty() {
return;
}
let plural = if dropped.len() == 1 {
"check"
} else {
"checks"
};
println!(
"{} {} {plural} skipped by {}: {}",
warning_sign(),
dropped.len(),
highlight("hook.skip"),
dropped.join(", ")
);
}
fn run_concurrently<T, R, F>(items: &[T], run: F, if_thread_died: R) -> Vec<R>
where
T: Sync,
R: Send + Sync + Clone,
F: Fn(&T) -> R + Sync,
{
let slots: Vec<Mutex<Option<R>>> = items.iter().map(|_| Mutex::new(None)).collect();
std::thread::scope(|scope| {
for (item, slot) in items.iter().zip(&slots) {
let run = &run;
let died = &if_thread_died;
scope.spawn(move || {
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(item)))
.unwrap_or_else(|_| died.clone());
*slot.lock().expect("poisoned") = Some(outcome);
});
}
});
slots
.into_iter()
.map(|s| {
s.into_inner()
.expect("poisoned")
.unwrap_or_else(|| if_thread_died.clone())
})
.collect()
}
fn hold_unstaged() -> Result<crate::staged_only::StagedOnly, Verdict> {
crate::staged_only::install_signal_handler();
match crate::staged_only::StagedOnly::enter() {
Ok(guard) => Ok(guard),
Err(e) => {
eprintln!("{e}");
Err(Verdict::Block)
}
}
}
pub fn pre_commit(ctx: &Ctx) -> Verdict {
let in_progress = crate::git_states_in_progress();
let checks = selected_during(Stage::PreCommit, &in_progress);
let held = match hold_unstaged() {
Ok(guard) => guard,
Err(verdict) => return verdict,
};
let verdict = run_stage(&checks, ctx, &Overrides::read());
drop(held);
verdict
}
fn run_stage(checks: &[&'static dyn Check], ctx: &Ctx, severities: &Overrides) -> Verdict {
if checks.is_empty() {
return Verdict::Proceed;
}
let outcomes = run_concurrently(
checks,
|check| {
let sub = Ctx {
name: check.name(),
args: ctx.args,
hooks_dir: ctx.hooks_dir,
push: ctx.push,
};
check.run(&sub)
},
Outcome::Failed,
);
let report = classify(checks, &outcomes, severities);
announce(&report);
report.verdict()
}
#[derive(Debug, Default, PartialEq, Eq)]
struct Report<'a> {
fixed: Vec<&'a str>,
blocked: Vec<&'a str>,
downgraded: Vec<&'a str>,
unavailable: Vec<&'a str>,
}
impl Report<'_> {
fn verdict(&self) -> Verdict {
Verdict::blocking(!self.blocked.is_empty())
}
}
fn classify<'a>(
checks: &[&'a dyn Check],
outcomes: &[Outcome],
severities: &Overrides,
) -> Report<'a> {
let mut report = Report::default();
for (check, outcome) in checks.iter().zip(outcomes) {
match outcome {
Outcome::Passed | Outcome::Warned => {}
Outcome::Fixed => report.fixed.push(check.name()),
Outcome::Unavailable => report.unavailable.push(check.name()),
Outcome::Failed => match severities.of(*check) {
Severity::Block => report.blocked.push(check.name()),
Severity::Warn => report.downgraded.push(check.name()),
},
}
}
report
}
fn announce(report: &Report) {
if !report.fixed.is_empty() {
println!(
"{} {} check(s) fixed and re-staged: {}",
valid_sign(),
report.fixed.len(),
report.fixed.join(", ")
);
}
if !report.unavailable.is_empty() {
println!(
"{} {} check(s) could not run: {}",
warning_sign(),
report.unavailable.len(),
report.unavailable.join(", ")
);
}
if !report.downgraded.is_empty() {
println!(
"{} {} check(s) reported a problem but are set to warn: {}",
warning_sign(),
report.downgraded.len(),
report.downgraded.join(", ")
);
}
if report.blocked.is_empty() {
return;
}
println!("\n🚨 Error raised by:");
for name in &report.blocked {
println!(" - {}", highlight(name));
}
}
pub fn enter_all_files_mode() {
crate::hooks::common::override_file_set(
crate::git::stdout_paths(&["ls-files"]).unwrap_or_default(),
);
}
pub fn run_all(ctx: &Ctx, all_files: bool) -> Verdict {
if all_files {
enter_all_files_mode();
if crate::hooks::common::fixing_requested() {
println!(
"{} {} is set, but fixing is off for {}: the input set is the \
working tree, not the index",
warning_sign(),
highlight("amont.fix"),
highlight("--all-files")
);
}
return run_stage(&selected(Stage::PreCommit), ctx, &Overrides::read());
}
let held = match hold_unstaged() {
Ok(guard) => guard,
Err(verdict) => return verdict,
};
let verdict = run_stage(&selected(Stage::PreCommit), ctx, &Overrides::read());
drop(held);
verdict
}
pub fn run_named(ctx: &Ctx, name: &str, all_files: bool) -> Option<Verdict> {
let run_check = crate::registry::lookup(name)?;
if all_files {
enter_all_files_mode();
return Some(run_check(ctx));
}
let is_pre_commit_check =
crate::registry::one_named(name).is_some_and(|c| c.stage() == Stage::PreCommit);
if !is_pre_commit_check {
return Some(run_check(ctx));
}
let held = match hold_unstaged() {
Ok(guard) => guard,
Err(verdict) => return Some(verdict),
};
let verdict = run_check(ctx);
drop(held);
Some(verdict)
}
pub fn pre_push(ctx: &Ctx) -> Verdict {
let severities = Overrides::read();
let in_progress = crate::git_states_in_progress();
for check in selected_during(Stage::PrePush, &in_progress) {
let sub = Ctx {
name: check.name(),
args: ctx.args,
hooks_dir: ctx.hooks_dir,
push: ctx.push,
};
match check.run(&sub) {
Outcome::Passed => {}
Outcome::Unavailable => {
println!(
"{} {} could not run",
warning_sign(),
highlight(check.name())
)
}
Outcome::Warned => {}
Outcome::Fixed => {}
Outcome::Failed => match severities.of(check) {
Severity::Warn => println!(
"{} {} reported a problem (severity warn)",
warning_sign(),
highlight(check.name())
),
Severity::Block => {
println!("\n🚨 Error raised by hook {}", highlight(check.name()));
return Verdict::Block;
}
},
}
}
Verdict::Proceed
}
#[cfg(test)]
mod tests {
use super::*;
use crate::check::{Builtin, Scope};
use std::sync::atomic::{AtomicUsize, Ordering};
const fn stub(name: &'static str, severity: Severity) -> Builtin {
Builtin {
name,
stage: Stage::PreCommit,
scope: Scope::ALWAYS,
severity,
run: |_| Outcome::Passed,
fix: crate::check::Fix::None,
}
}
fn none() -> Overrides {
Overrides::default()
}
static BLOCKER: Builtin = stub("stub-blocker", Severity::Block);
static WARNER: Builtin = stub("stub-warner", Severity::Warn);
const fn as_checks(cs: [&'static Builtin; 3]) -> [&'static dyn Check; 3] {
[cs[0], cs[1], cs[2]]
}
#[test]
fn every_outcome_lands_in_the_right_bucket() {
let checks: [&dyn Check; 4] = [&BLOCKER, &BLOCKER, &WARNER, &BLOCKER];
let got = classify(
&checks,
&[
Outcome::Passed,
Outcome::Unavailable,
Outcome::Failed,
Outcome::Failed,
],
&none(),
);
assert_eq!(got.blocked, ["stub-blocker"], "{got:?}");
assert_eq!(got.downgraded, ["stub-warner"], "{got:?}");
assert_eq!(got.unavailable, ["stub-blocker"], "{got:?}");
}
#[test]
fn a_clean_stage_has_nothing_to_report() {
let checks: [&dyn Check; 2] = [&BLOCKER, &WARNER];
let got = classify(&checks, &[Outcome::Passed, Outcome::Warned], &none());
assert_eq!(got, Report::default());
assert_eq!(got.verdict(), Verdict::Proceed);
}
#[test]
fn a_blocking_failure_is_the_only_thing_that_fails_the_commit() {
let b: &dyn Check = &BLOCKER;
let w: &dyn Check = &WARNER;
assert_eq!(
classify(&[b], &[Outcome::Failed], &none()).verdict(),
Verdict::Block
);
assert_eq!(
classify(&[b], &[Outcome::Passed], &none()).verdict(),
Verdict::Proceed
);
assert_eq!(
classify(&[b], &[Outcome::Warned], &none()).verdict(),
Verdict::Proceed
);
assert_eq!(
classify(&[b], &[Outcome::Unavailable], &none()).verdict(),
Verdict::Proceed
);
assert_eq!(
classify(&[w], &[Outcome::Failed], &none()).verdict(),
Verdict::Proceed
);
}
#[test]
fn one_blocking_failure_among_many_still_fails() {
let checks = as_checks([&BLOCKER, &WARNER, &BLOCKER]);
assert_eq!(
classify(
&checks,
&[Outcome::Unavailable, Outcome::Failed, Outcome::Failed],
&none()
)
.verdict(),
Verdict::Block
);
assert_eq!(
classify(
&checks,
&[Outcome::Unavailable, Outcome::Failed, Outcome::Passed],
&none()
)
.verdict(),
Verdict::Proceed
);
}
#[test]
fn a_panicking_check_blocks_the_commit() {
static DIES: Builtin = Builtin {
name: "stub-dies",
stage: Stage::PreCommit,
scope: Scope::ALWAYS,
severity: Severity::Block,
run: |_| panic!("this check died"),
fix: crate::check::Fix::None,
};
let hook = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {}));
let push = crate::pushrefs::PushRefs::default();
let ctx = Ctx {
name: "pre-commit",
args: &[],
hooks_dir: std::path::Path::new("."),
push: &push,
};
let verdict = run_stage(&[&DIES], &ctx, &none());
std::panic::set_hook(hook);
assert_eq!(
verdict,
Verdict::Block,
"a check that died must not let the commit through"
);
}
#[test]
fn a_thread_that_dies_leaves_a_failure_behind() {
let hook = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {}));
let items = ["a", "b", "c"];
let out = run_concurrently(
&items,
|n: &&str| {
if *n == "b" {
panic!("this check died");
}
Outcome::Passed
},
Outcome::Failed,
);
std::panic::set_hook(hook);
assert_eq!(
out,
vec![Outcome::Passed, Outcome::Failed, Outcome::Passed],
"a dead check must not read as one that passed, \
and must not take the other checks down with it"
);
}
use std::time::{Duration, Instant};
#[test]
fn run_concurrently_actually_overlaps() {
static ARRIVED: AtomicUsize = AtomicUsize::new(0);
ARRIVED.store(0, Ordering::SeqCst);
let names: Vec<&'static str> = vec!["a", "b", "c", "d"];
let n = names.len();
let out = run_concurrently(
&names,
move |_: &&str| {
ARRIVED.fetch_add(1, Ordering::SeqCst);
let deadline = Instant::now() + Duration::from_secs(10);
while ARRIVED.load(Ordering::SeqCst) < n {
if Instant::now() > deadline {
return 1; }
std::thread::yield_now();
}
0
},
1,
);
assert!(
out.iter().all(|c| *c == 0),
"tasks did not overlap: {out:?}"
);
}
#[test]
fn results_come_back_in_input_order() {
let names: Vec<&'static str> = vec!["first", "second", "third"];
let out = run_concurrently(&names, |n| if *n == "second" { 7 } else { 0 }, -1);
assert_eq!(out, vec![0, 7, 0], "results keep the input order");
}
#[test]
fn skips_are_filtered_by_the_shared_resolver() {
let all = ["pre-commit-ruff", "pre-commit-prettier"];
let skips = ["ruff".to_string()];
let kept: Vec<_> = all
.iter()
.copied()
.filter(|n| !skips.iter().any(|s| crate::skip_suppresses(n, s)))
.collect();
assert_eq!(kept, vec!["pre-commit-prettier"]);
}
}