use std::path::Path;
use std::process::Command;
use serde::Serialize;
use crate::checks::all_checks;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "scope", rename_all = "snake_case")]
pub enum Scope {
Local,
Global,
Other {
origin: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct SkipEntry {
pub value: String,
pub scope: Scope,
pub suppresses: Vec<&'static str>,
}
impl SkipEntry {
pub fn is_trigger(&self) -> bool {
amont_runtime::TRIGGERS.contains(&self.value.as_str())
}
pub fn is_inert(&self) -> bool {
self.suppresses.is_empty()
}
}
pub fn suppressed_by(value: &str) -> Vec<&'static str> {
if value.is_empty() {
return Vec::new();
}
all_checks()
.into_iter()
.filter(|c| amont_runtime::skip_suppresses(c, value))
.collect()
}
#[cfg(test)]
pub fn for_test(value: &str) -> SkipEntry {
SkipEntry {
value: value.to_string(),
scope: Scope::Local,
suppresses: suppressed_by(value),
}
}
pub fn scope_of(origin: &str) -> Scope {
let path = origin.strip_prefix("file:").unwrap_or(origin);
if path.contains(".git/config") || path.ends_with(".git/config") {
Scope::Local
} else if path.contains(".gitconfig") || path.contains("git/config") {
Scope::Global
} else {
Scope::Other {
origin: path.to_string(),
}
}
}
pub fn read(repo: &Path) -> Vec<SkipEntry> {
let Ok(out) = Command::new("git")
.args(["config", "--show-origin", "--get-all", "hook.skip"])
.current_dir(repo)
.output()
else {
return Vec::new();
};
String::from_utf8_lossy(&out.stdout)
.lines()
.filter_map(|line| {
let (origin, value) = line.split_once('\t')?;
let value = value.trim();
if value.is_empty() {
return None;
}
Some(SkipEntry {
value: value.to_string(),
scope: scope_of(origin),
suppresses: suppressed_by(value),
})
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_resolver_agrees_with_the_dispatchers_rule() {
for value in ["clippy", "pre-commit-clippy", "lint", "e", "t", "zzz"] {
let mine = suppressed_by(value);
let theirs: Vec<&str> = all_checks()
.into_iter()
.filter(|c| amont_runtime::skip_suppresses(c, value))
.collect();
assert_eq!(mine, theirs, "diverged on {value:?}");
}
}
#[test]
fn reach_is_computed_from_the_registry() {
let total = all_checks().len();
assert_eq!(suppressed_by("pre-commit-clippy").len(), 1, "a full id");
assert_eq!(suppressed_by("clippy").len(), 1, "a short name");
let pre_commit = suppressed_by("pre-commit").len();
let pre_push = suppressed_by("pre-push").len();
assert_eq!(pre_commit + pre_push, total, "a trigger reaches its stage");
assert!(pre_commit > 1 && pre_push > 1);
for nothing in ["e", "t", "cargo", "lint", "clip"] {
assert!(
suppressed_by(nothing).is_empty(),
"{nothing:?} should name no check"
);
}
}
#[test]
fn an_empty_value_suppresses_nothing() {
assert!(suppressed_by("").is_empty());
}
#[test]
fn a_trigger_and_a_typo_are_different_questions() {
let exact = SkipEntry {
value: "pre-commit-clippy".into(),
scope: Scope::Local,
suppresses: suppressed_by("pre-commit-clippy"),
};
assert!(!exact.is_trigger());
assert!(!exact.is_inert());
let fragment = SkipEntry {
value: "run-tests-js".into(),
scope: Scope::Local,
suppresses: suppressed_by("run-tests-js"),
};
assert!(!fragment.is_trigger(), "a short name is not a trigger");
assert!(!fragment.is_inert(), "and it does reach its check");
let broad = SkipEntry {
value: "pre-commit".into(),
scope: Scope::Local,
suppresses: suppressed_by("pre-commit"),
};
assert!(broad.is_trigger());
assert!(broad.suppresses.len() > 1);
let typo = SkipEntry {
value: "e".into(),
scope: Scope::Local,
suppresses: suppressed_by("e"),
};
assert!(typo.is_inert(), "`e` names nothing");
assert!(!typo.is_trigger());
}
#[test]
fn origin_is_classified_not_guessed() {
assert_eq!(scope_of("file:/repo/.git/config"), Scope::Local);
assert_eq!(scope_of("file:/Users/me/.gitconfig"), Scope::Global);
assert!(matches!(
scope_of("file:/etc/gitconfig"),
Scope::Other { .. }
));
}
#[test]
fn reads_entries_from_a_real_repo_with_origin() {
let dir = std::env::temp_dir().join(format!("skips-read-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let git = |args: &[&str]| {
Command::new("git")
.args(args)
.current_dir(&dir)
.output()
.expect("git");
};
git(&["init", "-q", "--template=", "."]);
git(&["config", "--add", "hook.skip", "pre-commit-clippy"]);
git(&["config", "--add", "hook.skip", "pre-push"]);
let entries = read(&dir);
assert_eq!(entries.len(), 2, "{entries:?}");
assert_eq!(entries[0].value, "pre-commit-clippy");
assert_eq!(entries[0].scope, Scope::Local);
assert_eq!(entries[0].suppresses, vec!["pre-commit-clippy"]);
assert_eq!(entries[1].value, "pre-push");
assert!(
entries[1].is_trigger(),
"a trigger reaches its whole stage: {:?}",
entries[1].suppresses
);
assert!(entries[1].suppresses.len() > 1);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_repo_with_no_skips_reads_empty() {
let dir = std::env::temp_dir().join(format!("skips-none-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
Command::new("git")
.args(["init", "-q", "--template=", "."])
.current_dir(&dir)
.output()
.expect("git");
assert!(read(&dir).is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct SkipPlan {
pub check: &'static str,
pub action: Action,
pub command: Vec<String>,
pub suppresses: Vec<&'static str>,
pub refuse: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Action {
Add,
Remove,
}
pub fn plan(repo: &Path, check: &'static str) -> SkipPlan {
let existing = read(repo);
let exact = existing.iter().find(|e| e.value == check);
if let Some(e) = exact {
let scoped = match e.scope {
Scope::Local => None,
_ => Some(format!(
"that entry is {}, not local — edit it where it lives",
match e.scope {
Scope::Global => "global".to_string(),
_ => "in another config".to_string(),
}
)),
};
return SkipPlan {
check,
action: Action::Remove,
command: vec![
"config".into(),
"--unset".into(),
"hook.skip".into(),
format!("^{}$", regex_escape(check)),
],
suppresses: Vec::new(),
refuse: scoped,
};
}
let covered = existing
.iter()
.find(|e| amont_runtime::skip_suppresses(check, &e.value));
SkipPlan {
check,
action: Action::Add,
command: vec![
"config".into(),
"--add".into(),
"hook.skip".into(),
check.to_string(),
],
suppresses: suppressed_by(check),
refuse: covered.map(|e| {
format!(
"already skipped by {:?}, which suppresses {} check(s)",
e.value,
e.suppresses.len()
)
}),
}
}
fn regex_escape(s: &str) -> String {
s.chars()
.flat_map(|c| {
let esc = matches!(
c,
'.' | '^' | '$' | '*' | '+' | '?' | '(' | ')' | '[' | ']' | '{' | '}' | '|' | '\\'
);
esc.then_some('\\').into_iter().chain(std::iter::once(c))
})
.collect()
}
pub fn apply(repo: &Path, plan: &SkipPlan) -> Result<(), String> {
if let Some(r) = &plan.refuse {
return Err(r.clone());
}
let out = Command::new("git")
.args(&plan.command)
.current_dir(repo)
.output()
.map_err(|e| e.to_string())?;
let now = read(repo);
let present = now.iter().any(|e| e.value == plan.check);
match plan.action {
Action::Add if !present => Err(format!(
"git reported success but the value is absent: {}",
String::from_utf8_lossy(&out.stderr).trim()
)),
Action::Remove if present => Err(format!(
"git reported success but the value is still there: {}",
String::from_utf8_lossy(&out.stderr).trim()
)),
_ => Ok(()),
}
}
#[cfg(test)]
mod write_tests {
use super::*;
fn repo_at(name: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("skipw-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
Command::new("git")
.args(["init", "-q", "--template=", "."])
.current_dir(&d)
.output()
.expect("git");
d
}
fn values(repo: &Path) -> Vec<String> {
read(repo).into_iter().map(|e| e.value).collect()
}
#[test]
fn add_then_remove_round_trips() {
let d = repo_at("roundtrip");
let p = plan(&d, "pre-commit-clippy");
assert_eq!(p.action, Action::Add);
apply(&d, &p).expect("add");
assert_eq!(values(&d), vec!["pre-commit-clippy"]);
let p2 = plan(&d, "pre-commit-clippy");
assert_eq!(p2.action, Action::Remove, "the toggle flips");
apply(&d, &p2).expect("remove");
assert!(values(&d).is_empty());
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn removal_does_not_take_neighbours_with_it() {
let d = repo_at("neighbour");
for v in ["pre-commit-lint-js", "pre-commit-lint-json-yaml"] {
Command::new("git")
.args(["config", "--add", "hook.skip", v])
.current_dir(&d)
.output()
.unwrap();
}
let p = plan(&d, "pre-commit-lint-js");
assert_eq!(p.action, Action::Remove);
apply(&d, &p).expect("remove");
assert_eq!(
values(&d),
vec!["pre-commit-lint-json-yaml"],
"only the exact value goes"
);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn a_removal_that_git_declines_is_reported_as_failure() {
let d = repo_at("declined");
for _ in 0..2 {
Command::new("git")
.args(["config", "--add", "hook.skip", "pre-commit-clippy"])
.current_dir(&d)
.output()
.unwrap();
}
assert_eq!(values(&d).len(), 2, "duplicates are allowed by git");
let p = plan(&d, "pre-commit-clippy");
assert_eq!(p.action, Action::Remove);
let raw = Command::new("git")
.args(&p.command)
.current_dir(&d)
.output()
.unwrap();
assert!(!raw.status.success(), "git signals the refusal");
assert_eq!(values(&d).len(), 2, "and removed nothing");
let err = apply(&d, &p).expect_err("must report the truth");
assert!(err.contains("still there"), "{err}");
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn adding_something_already_covered_is_refused() {
let d = repo_at("dupe");
Command::new("git")
.args(["config", "--add", "hook.skip", "clippy"])
.current_dir(&d)
.output()
.unwrap();
let p = plan(&d, "pre-commit-clippy");
assert!(p.refuse.is_some(), "{p:?}");
assert!(apply(&d, &p).is_err());
assert_eq!(values(&d), vec!["clippy"], "nothing was written");
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn a_name_that_prefixes_another_reaches_only_itself() {
let d = repo_at("prefix");
let p = plan(&d, "pre-commit-lint-js");
assert_eq!(p.suppresses, vec!["pre-commit-lint-js"]);
let q = plan(&d, "pre-commit-lint-json-yaml");
assert_eq!(q.suppresses, vec!["pre-commit-lint-json-yaml"]);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn the_removal_pattern_is_anchored_and_escaped() {
let d = repo_at("anchor");
Command::new("git")
.args(["config", "--add", "hook.skip", "pre-commit-clippy"])
.current_dir(&d)
.output()
.unwrap();
let p = plan(&d, "pre-commit-clippy");
let pattern = p.command.last().unwrap();
assert!(
pattern.starts_with('^') && pattern.ends_with('$'),
"{pattern}"
);
assert_eq!(regex_escape("a.b"), "a\\.b");
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn a_non_local_entry_is_refused_with_its_scope() {
let e = SkipEntry {
value: "pre-commit-clippy".into(),
scope: Scope::Global,
suppresses: suppressed_by("pre-commit-clippy"),
};
assert_eq!(e.scope, Scope::Global);
assert!(matches!(
scope_of("file:/Users/me/.gitconfig"),
Scope::Global
));
}
}