use std::path::{Path, PathBuf};
use std::process::Command;
use crate::maintenance::GIT_HOOK_VARS;
pub const EVENTS: [&str; 4] = ["pre-commit", "commit-msg", "pre-push", "pre-merge-commit"];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Writer {
Prek,
PreCommit,
Own,
Configured,
}
impl Writer {
#[must_use]
pub const fn manager(self) -> Option<&'static str> {
match self {
Self::Prek => Some("prek"),
Self::PreCommit => Some("pre-commit"),
Self::Own | Self::Configured => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Installed {
pub event: &'static str,
pub writer: Writer,
}
fn git(dir: &Path, args: &[&str]) -> Option<std::process::Output> {
let mut command = Command::new(crate::probes::git_bin());
for var in GIT_HOOK_VARS {
command.env_remove(var);
}
command.env("LC_ALL", "C");
command
.arg("-C")
.arg(dir)
.args(args)
.output()
.ok()
.filter(|output| output.status.success())
}
#[must_use]
pub fn installed(dir: &Path) -> Option<Vec<Installed>> {
git(dir, &["rev-parse", "--is-inside-work-tree"])?;
let mut found = Vec::new();
for event in EVENTS {
let Some(answer) = git(dir, &["rev-parse", "--git-path", &format!("hooks/{event}")]) else {
continue;
};
let path = PathBuf::from(String::from_utf8_lossy(&answer.stdout).trim());
let path = if path.is_relative() {
dir.join(path)
} else {
path
};
if !executable(&path) {
continue;
}
let head: String = std::fs::read_to_string(&path)
.unwrap_or_default()
.lines()
.take(5)
.collect::<Vec<_>>()
.join("\n");
let writer = if head.contains("# File generated by prek") {
Writer::Prek
} else if head.contains("# File generated by pre-commit") {
Writer::PreCommit
} else {
Writer::Own
};
found.push(Installed { event, writer });
}
for event in EVENTS {
for _ in configured(dir, event) {
found.push(Installed {
event,
writer: Writer::Configured,
});
}
}
Some(found)
}
fn configured(dir: &Path, event: &str) -> Vec<String> {
git(dir, &["hook", "list", event]).map_or_else(Vec::new, |listed| {
String::from_utf8_lossy(&listed.stdout)
.lines()
.filter(|line| !line.starts_with("disabled\t"))
.map(str::trim)
.filter(|name| !name.is_empty() && *name != "hook from hookdir")
.map(str::to_owned)
.collect()
})
}
fn executable(path: &Path) -> bool {
std::fs::metadata(path).is_ok_and(|meta| {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
meta.is_file() && meta.permissions().mode() & 0o111 != 0
}
#[cfg(not(unix))]
{
meta.is_file()
}
})
}
#[must_use]
pub fn unexcluded_nested_configs(target: &Path) -> Vec<String> {
let target = std::path::absolute(target).unwrap_or_else(|_| target.to_path_buf());
let Some(listed) = git(
&target,
&["ls-files", "-z", "--", ":(glob)**/.pre-commit-config.yaml"],
) else {
return Vec::new();
};
let nested: Vec<String> = String::from_utf8_lossy(&listed.stdout)
.split('\0')
.filter(|path| path.contains('/'))
.map(str::to_owned)
.collect();
if nested.is_empty() {
return nested;
}
let mut dirs: Vec<String> = Vec::new();
for path in &nested {
let parts: Vec<&str> = path.split('/').collect();
for depth in 1..parts.len() {
let dir = parts[..depth].join("/");
if !dirs.contains(&dir) {
dirs.push(dir);
}
}
}
let custom = layer(&target, &dirs, ".prekignore");
let ignore = layer(&target, &dirs, ".ignore");
let git_rules = judged(&target, &dirs, &[]);
let entered = |dir: &str| {
let name = dir.rsplit('/').next().unwrap_or(dir);
if name.starts_with("{{") && name.ends_with("}}") && name.contains("cookiecutter") {
return false;
}
let matched = custom
.get(dir)
.or_else(|| ignore.get(dir))
.or_else(|| git_rules.get(dir))
.copied();
match matched {
Some(Rule::Ignore) => false,
Some(Rule::Keep) => true,
None => !name.starts_with('.'),
}
};
nested
.into_iter()
.filter(|path| {
let parts: Vec<&str> = path.split('/').collect();
(1..parts.len()).all(|depth| entered(&parts[..depth].join("/")))
})
.collect()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Rule {
Ignore,
Keep,
}
fn layer(target: &Path, dirs: &[String], name: &str) -> std::collections::HashMap<String, Rule> {
let mut sources: Vec<&str> = vec![""];
sources.extend(dirs.iter().map(String::as_str));
if !sources
.iter()
.any(|dir| target.join(dir).join(name).is_file())
{
return std::collections::HashMap::new();
}
let Some(scratch) = Scratch::new() else {
return std::collections::HashMap::new();
};
if git(scratch.path(), &["init", "-q"]).is_none() {
return std::collections::HashMap::new();
}
for dir in &sources {
let _ = std::fs::create_dir_all(scratch.path().join(dir));
let rules = target.join(dir).join(name);
if rules.is_file() {
let _ = std::fs::copy(&rules, scratch.path().join(dir).join(".gitignore"));
}
}
let empty = scratch.path().join(".git/rk-no-excludes");
let _ = std::fs::write(&empty, "");
let _ = std::fs::write(scratch.path().join(".git/info/exclude"), "");
let excludes = format!("core.excludesFile={}", empty.display());
judged(scratch.path(), dirs, &["-c", &excludes])
}
struct Scratch(PathBuf);
impl Scratch {
fn new() -> Option<Self> {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |since| since.as_nanos());
let path =
std::env::temp_dir().join(format!("rk-prekignore-{}-{nanos}", std::process::id()));
std::fs::create_dir(&path).ok()?;
Some(Self(path))
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn judged(
repo: &Path,
dirs: &[String],
config: &[&str],
) -> std::collections::HashMap<String, Rule> {
let mut command = Command::new(crate::probes::git_bin());
for var in GIT_HOOK_VARS {
command.env_remove(var);
}
let checked = command
.arg("-C")
.arg(repo)
.args(config)
.args(["check-ignore", "--no-index", "-v", "-n", "-z", "--stdin"])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.spawn()
.and_then(|mut child| {
use std::io::Write as _;
let input: Vec<u8> = dirs
.iter()
.flat_map(|dir| dir.bytes().chain(std::iter::once(0)))
.collect();
let writer = child.stdin.take().map(|mut stdin| {
std::thread::spawn(move || {
let _ = stdin.write_all(&input);
})
});
let output = child.wait_with_output();
if let Some(writer) = writer {
let _ = writer.join();
}
output
});
let Ok(output) = checked else {
return std::collections::HashMap::new();
};
let fields: Vec<String> = String::from_utf8_lossy(&output.stdout)
.split('\0')
.map(str::to_owned)
.collect();
let mut found = std::collections::HashMap::new();
for record in fields.as_chunks::<4>().0 {
let (source, pattern, path) = (&record[0], &record[2], &record[3]);
if source.is_empty() {
continue;
}
let rule = if pattern.starts_with('!') {
Rule::Keep
} else {
Rule::Ignore
};
found.insert(path.trim_end_matches('/').to_owned(), rule);
}
found
}
#[cfg(test)]
mod tests {
use super::{Rule, configured, judged};
use std::process::Command;
fn git_in(dir: &std::path::Path, args: &[&str]) {
let mut command = Command::new(crate::probes::git_bin());
for var in crate::maintenance::GIT_HOOK_VARS {
command.env_remove(var);
}
let status = command
.arg("-C")
.arg(dir)
.args(args)
.status()
.expect("git runs");
assert!(status.success(), "{args:?}");
}
#[test]
fn a_disabled_configured_hook_is_not_counted() {
let repo = tempfile::tempdir().expect("a scratch repository");
git_in(repo.path(), &["init", "-q"]);
git_in(
repo.path(),
&["config", "hook.review.event", "pre-merge-commit"],
);
git_in(repo.path(), &["config", "hook.review.command", "false"]);
git_in(repo.path(), &["config", "hook.review.enabled", "false"]);
assert!(configured(repo.path(), "pre-merge-commit").is_empty());
git_in(repo.path(), &["config", "hook.review.enabled", "true"]);
assert_eq!(configured(repo.path(), "pre-merge-commit"), vec!["review"]);
}
#[test]
fn a_long_list_is_judged_without_a_stall() {
let repo = tempfile::tempdir().expect("a scratch repository");
git_in(repo.path(), &["init", "-q"]);
std::fs::write(repo.path().join(".gitignore"), "skip-*/\n").expect("writes");
let dirs: Vec<String> = (0..20_000)
.map(|n| format!("project-directory-{n:05}"))
.chain(std::iter::once("skip-me".to_owned()))
.collect();
std::fs::create_dir(repo.path().join("skip-me")).expect("the dir creates");
let path = repo.path().to_path_buf();
let (sender, receiver) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let _ = sender.send(judged(&path, &dirs, &[]));
});
let found = receiver
.recv_timeout(std::time::Duration::from_secs(60))
.expect("the judgment finishes");
assert_eq!(found.get("skip-me"), Some(&Rule::Ignore));
assert_eq!(found.len(), 1);
}
}