pub mod agents_md;
pub mod check;
pub mod commit_style;
pub mod config;
pub mod dispatch;
pub mod git;
pub mod hookfile;
pub mod hooks;
pub mod install;
pub mod json;
pub mod manifest;
pub mod pushed_tree;
pub mod pushrefs;
pub mod registry;
pub mod setup;
pub mod staged_only;
pub mod trust;
pub mod ui;
pub mod vocabulary;
use std::path::Path;
use std::process::{Command, Stdio};
pub const TRIGGERS: [&str; 2] = ["pre-commit", "pre-push"];
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Match {
Trigger,
ShortName,
FullId,
}
pub fn short_name(check: &str) -> &str {
for trigger in TRIGGERS {
if let Some(short) = check
.strip_prefix(trigger)
.and_then(|rest| rest.strip_prefix('-'))
{
return short;
}
}
check
}
pub fn names_check(check: &str, pattern: &str) -> Option<Match> {
if check == pattern {
return Some(Match::FullId);
}
for trigger in TRIGGERS {
let Some(short) = check
.strip_prefix(trigger)
.and_then(|rest| rest.strip_prefix('-'))
else {
continue;
};
if pattern == trigger {
return Some(Match::Trigger);
}
if pattern == short {
return Some(Match::ShortName);
}
return None;
}
None
}
pub fn skip_suppresses(check: &str, skip: &str) -> bool {
names_check(check, skip).is_some()
}
#[derive(Debug, Clone)]
pub struct CheckListing {
pub id: String,
pub short_name: String,
pub stage: check::Stage,
pub source: Source,
pub declared_severity: check::Severity,
pub effective_severity: check::Severity,
pub severity_overridden: bool,
pub fix: check::Fix,
pub status: Status,
pub reason: String,
pub scope_files: Vec<String>,
pub scope_opt_in: Vec<String>,
pub command: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Source {
Builtin,
Declared,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Status {
Runs,
Inert,
Skipped,
Unusable,
}
pub struct ListOptions {
pub json: bool,
pub stage: Option<check::Stage>,
pub pushed: bool,
}
pub fn gather_checks(stage_filter: Option<check::Stage>, paths: &[String]) -> Vec<CheckListing> {
use crate::check::Stage;
let stages: Vec<Stage> = match stage_filter {
Some(s) => vec![s],
None => vec![Stage::PreCommit, Stage::PrePush],
};
let skips = configured_skips();
let overrides = registry::Overrides::read();
let externals_by_id: std::collections::BTreeMap<&str, &manifest::External> =
manifest::externals()
.iter()
.map(|e| (e.id.as_str(), e))
.collect();
let mut out = Vec::new();
for stage in stages {
for check in registry::all_stage_checks(stage) {
let name = check.name();
let external = externals_by_id.get(name).copied();
let skipped = skips.iter().any(|s| skip_suppresses(name, s));
let applies = check.scope().matches(paths);
let unusable_why = external.and_then(|e| match &e.kind {
manifest::Kind::Unusable { why } => Some(why.as_str()),
manifest::Kind::Runnable { .. } => None,
});
let (status, reason) = if let Some(w) = unusable_why {
(Status::Unusable, format!("{} {w}", manifest::MANIFEST))
} else if skipped {
(Status::Skipped, "skipped via hook.skip".to_string())
} else if applies {
(Status::Runs, String::new())
} else {
(
Status::Inert,
format!("inert here — needs {}", describe(check.scope())),
)
};
let command = external.and_then(|e| match &e.kind {
manifest::Kind::Runnable { program, args, .. } => Some(
std::iter::once(program.as_str())
.chain(args.iter().map(String::as_str))
.collect::<Vec<_>>()
.join(" "),
),
manifest::Kind::Unusable { .. } => None,
});
let declared_severity = check.severity();
let effective_severity = overrides.of(check);
out.push(CheckListing {
id: name.to_string(),
short_name: short_name(name).to_string(),
stage,
source: if external.is_some() {
Source::Declared
} else {
Source::Builtin
},
declared_severity,
effective_severity,
severity_overridden: declared_severity != effective_severity,
fix: check.fix(),
status,
reason,
scope_files: check.scope().files.iter().map(|s| s.to_string()).collect(),
scope_opt_in: check.scope().opt_in.iter().map(|s| s.to_string()).collect(),
command,
});
}
}
out
}
pub fn print_text(listings: &[CheckListing]) {
let mut current: Option<check::Stage> = None;
for l in listings {
if current != Some(l.stage) {
println!("{}", ui::highlight(l.stage.as_str()));
current = Some(l.stage);
}
let glyph = match l.status {
Status::Unusable => '✗',
Status::Skipped => '⊘',
Status::Runs => '●',
Status::Inert => '○',
};
let short_name = ui::sanitize(&l.short_name);
let label = match l.source {
Source::Declared => format!("{short_name} (declared)"),
Source::Builtin => short_name,
};
println!(" {glyph} {label:<26} {}", ui::sanitize(&l.reason));
}
println!();
println!(" ● runs here ○ inert ⊘ skipped via hook.skip ✗ declaration unusable");
}
pub fn print_commit_style(style: &commit_style::Style, rows: &[commit_style::Setting]) {
println!();
println!("{}", ui::highlight("commit style"));
for r in rows {
let origin = if r.set_here {
format!("{} ({})", r.key, r.scope.as_str())
} else {
String::new()
};
println!(" {:<18} {:<10} {origin}", r.label, r.value);
}
println!();
for w in style.warnings() {
println!(" {} {w}", ui::warning_sign().trim());
}
println!(" `amont setup` to change any of these");
}
fn commit_style_json(style: &commit_style::Style, rows: &[commit_style::Setting]) -> String {
let setting = |r: &commit_style::Setting, value: String, default: String| {
json::object(&[
format!("\"value\":{value}"),
format!("\"default\":{default}"),
json::bool_field("overridden", r.overridden),
json::bool_field("set_here", r.set_here),
json::string_field("source", r.scope.as_str()),
json::string_field("key", r.key),
])
};
let d = commit_style::Style::default();
let quoted = |s: &str| format!("\"{}\"", json::escape(s));
let fields: Vec<String> = rows
.iter()
.map(|r| {
let (value, default) = match r.key {
commit_style::KEY_GITMOJI => {
(quoted(style.gitmoji.as_str()), quoted(d.gitmoji.as_str()))
}
commit_style::KEY_SUBJECT_MAX => {
(style.subject_max.to_string(), d.subject_max.to_string())
}
commit_style::KEY_DESCRIPTION_MAX => (
style.description_max.to_string(),
d.description_max.to_string(),
),
_ => (style.body_wrap.to_string(), d.body_wrap.to_string()),
};
let name = r.key.rsplit('.').next().unwrap_or(r.key);
format!("\"{}\":{}", json::escape(name), setting(r, value, default))
})
.collect();
let warnings: Vec<String> = style.warnings();
let mut all = fields;
all.push(json::string_array_field("warnings", &warnings));
json::object(&all)
}
pub fn print_json(stage_filter: Option<check::Stage>, pushed: bool, listings: &[CheckListing]) {
let checks: Vec<String> = listings
.iter()
.map(|l| {
json::object(&[
json::string_field("id", &l.id),
json::string_field("short_name", &l.short_name),
json::string_field("stage", l.stage.as_str()),
json::string_field(
"source",
match l.source {
Source::Builtin => "builtin",
Source::Declared => "declared",
},
),
json::string_field("declared_severity", l.declared_severity.as_str()),
json::string_field("effective_severity", l.effective_severity.as_str()),
json::bool_field("severity_overridden", l.severity_overridden),
json::string_field("fix", l.fix.as_str()),
json::string_field(
"status",
match l.status {
Status::Runs => "runs",
Status::Inert => "inert",
Status::Skipped => "skipped",
Status::Unusable => "unusable",
},
),
json::string_field("reason", &l.reason),
json::string_array_field("scope_files", &l.scope_files),
json::string_array_field("scope_opt_in", &l.scope_opt_in),
json::opt_string_field("command", l.command.as_deref()),
])
})
.collect();
let (style, rows) = commit_style::describe();
println!(
"{}",
json::object(&[
json::opt_string_field("stage_filter", stage_filter.map(check::Stage::as_str)),
json::bool_field("pushed", pushed),
format!("\"checks\":{}", json::array(&checks)),
format!("\"commit_style\":{}", commit_style_json(&style, &rows)),
format!("\"branch_style\":{}", branch_style_json()),
])
);
}
fn branch_style_json() -> String {
let prefixes: Vec<String> = vocabulary::BRANCH_PREFIXES
.iter()
.map(|p| p.name.to_string())
.collect();
json::object(&[
json::string_field("shape", "<prefix>/<name>"),
json::string_field("pattern", &vocabulary::branch_contract()),
json::string_array_field("prefixes", &prefixes),
])
}
fn tracked_paths() -> Vec<String> {
git::stdout_paths(&["ls-files"]).unwrap_or_default()
}
fn pushed_paths() -> Result<Vec<String>, String> {
let synthetic = pushrefs::synthetic_from_upstream()?;
Ok(pushrefs::changed_files(&[synthetic]))
}
pub fn list_checks(opts: ListOptions) -> i32 {
let paths = if opts.pushed {
match pushed_paths() {
Ok(p) => p,
Err(msg) => {
if opts.json {
println!("{}", json::object(&[json::string_field("error", &msg)]));
} else {
eprintln!("amont: {msg}");
}
return 2;
}
}
} else {
tracked_paths()
};
let listings = gather_checks(opts.stage, &paths);
if opts.json {
print_json(opts.stage, opts.pushed, &listings);
} else {
print_text(&listings);
let (style, rows) = commit_style::describe();
print_commit_style(&style, &rows);
}
0
}
fn describe(s: crate::check::Scope) -> String {
let files = if s.files.is_empty() {
String::new()
} else {
s.files.join(" ")
};
let opt = s.opt_in.join(" | ");
match (files.is_empty(), opt.is_empty()) {
(false, false) => format!("{files} + {opt}"),
(false, true) => files,
(true, false) => opt,
(true, true) => "nothing".into(),
}
}
pub fn configured_skips() -> Vec<String> {
let Ok(out) = Command::new("git")
.args(["config", "--get-all", "hook.skip"])
.stderr(Stdio::null())
.output()
else {
return Vec::new();
};
String::from_utf8_lossy(&out.stdout)
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(str::to_owned)
.collect()
}
pub fn git_states_in_progress() -> Vec<crate::check::GitState> {
let Some(git_dir) = crate::git::stdout(&["rev-parse", "--git-dir"]) else {
return Vec::new();
};
let git_dir = Path::new(&git_dir);
crate::check::GitState::ALL
.into_iter()
.filter(|state| {
state
.markers()
.iter()
.any(|marker| git_dir.join(marker).exists())
})
.collect()
}
pub fn cherry_pick_in_progress() -> bool {
crate::git::stdout(&["rev-parse", "--git-dir"])
.map(|d| Path::new(&d).join("CHERRY_PICK_HEAD").exists())
.unwrap_or(false)
}
#[cfg(test)]
mod naming {
use super::*;
#[test]
fn three_ways_to_name_a_check() {
assert_eq!(
names_check("pre-commit-clippy", "pre-commit-clippy"),
Some(Match::FullId)
);
assert_eq!(
names_check("pre-commit-clippy", "pre-commit"),
Some(Match::Trigger)
);
assert_eq!(
names_check("pre-commit-clippy", "clippy"),
Some(Match::ShortName)
);
}
#[test]
fn nothing_matches_by_accident() {
for pattern in ["e", "t", "i", ""] {
assert_eq!(
names_check("pre-commit-clippy", pattern),
None,
"{pattern:?}"
);
}
assert_eq!(names_check("pre-commit-clippy", "clip"), None);
assert_eq!(names_check("pre-commit-clippy", "lint"), None);
assert_eq!(names_check("pre-commit-clippy", "pre-push"), None);
assert_eq!(names_check("pre-commit-clippy", ""), None);
}
#[test]
fn a_short_name_does_not_reach_a_longer_one() {
assert!(names_check("pre-commit-lint-json-yaml", "lint-js").is_none());
assert_eq!(
names_check("pre-commit-lint-js", "lint-js"),
Some(Match::ShortName)
);
assert_eq!(
names_check("pre-commit-lint-json-yaml", "lint-json-yaml"),
Some(Match::ShortName)
);
}
#[test]
fn the_fleets_only_skip_still_resolves() {
assert_eq!(
names_check("pre-push-run-tests-js", "run-tests-js"),
Some(Match::ShortName)
);
}
#[test]
fn a_trigger_reaches_its_own_stage_only() {
let pre_commit = registry::CHECKS
.iter()
.filter(|c| names_check(c.name, "pre-commit").is_some())
.count();
let pre_push = registry::CHECKS
.iter()
.filter(|c| names_check(c.name, "pre-push").is_some())
.count();
assert_eq!(pre_commit + pre_push, registry::CHECKS.len());
assert!(pre_commit > 0 && pre_push > 0);
}
#[test]
fn a_full_id_outranks_a_short_name_outranks_a_trigger() {
assert!(Match::FullId > Match::ShortName);
assert!(Match::ShortName > Match::Trigger);
}
#[test]
fn every_id_agrees_with_its_declared_stage() {
for check in registry::CHECKS {
assert_eq!(
names_check(check.name, check.stage.as_str()),
Some(Match::Trigger),
"{} declares {:?} but its id says otherwise",
check.name,
check.stage
);
}
}
}