1use std::path::{Path, PathBuf};
14use std::process::Command;
15
16use crate::maintenance::GIT_HOOK_VARS;
17
18pub const EVENTS: [&str; 4] = ["pre-commit", "commit-msg", "pre-push", "pre-merge-commit"];
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum Writer {
24 Prek,
26 PreCommit,
28 Own,
30 Configured,
33}
34
35impl Writer {
36 #[must_use]
38 pub const fn manager(self) -> Option<&'static str> {
39 match self {
40 Self::Prek => Some("prek"),
41 Self::PreCommit => Some("pre-commit"),
42 Self::Own | Self::Configured => None,
43 }
44 }
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct Installed {
50 pub event: &'static str,
52 pub writer: Writer,
54}
55
56fn git(dir: &Path, args: &[&str]) -> Option<std::process::Output> {
60 let mut command = Command::new(crate::probes::git_bin());
61 for var in GIT_HOOK_VARS {
62 command.env_remove(var);
63 }
64 command.env("LC_ALL", "C");
65 command
66 .arg("-C")
67 .arg(dir)
68 .args(args)
69 .output()
70 .ok()
71 .filter(|output| output.status.success())
72}
73
74#[must_use]
78pub fn installed(dir: &Path) -> Option<Vec<Installed>> {
79 git(dir, &["rev-parse", "--is-inside-work-tree"])?;
80 let mut found = Vec::new();
81 for event in EVENTS {
82 let Some(answer) = git(dir, &["rev-parse", "--git-path", &format!("hooks/{event}")]) else {
83 continue;
84 };
85 let path = PathBuf::from(String::from_utf8_lossy(&answer.stdout).trim());
86 let path = if path.is_relative() {
87 dir.join(path)
88 } else {
89 path
90 };
91 if !executable(&path) {
92 continue;
93 }
94 let head: String = std::fs::read_to_string(&path)
95 .unwrap_or_default()
96 .lines()
97 .take(5)
98 .collect::<Vec<_>>()
99 .join("\n");
100 let writer = if head.contains("# File generated by prek") {
101 Writer::Prek
102 } else if head.contains("# File generated by pre-commit") {
103 Writer::PreCommit
104 } else {
105 Writer::Own
106 };
107 found.push(Installed { event, writer });
108 }
109 for event in EVENTS {
110 for _ in configured(dir, event) {
111 found.push(Installed {
112 event,
113 writer: Writer::Configured,
114 });
115 }
116 }
117 Some(found)
118}
119
120fn configured(dir: &Path, event: &str) -> Vec<String> {
127 git(dir, &["hook", "list", event]).map_or_else(Vec::new, |listed| {
128 String::from_utf8_lossy(&listed.stdout)
129 .lines()
130 .filter(|line| !line.starts_with("disabled\t"))
131 .map(str::trim)
132 .filter(|name| !name.is_empty() && *name != "hook from hookdir")
133 .map(str::to_owned)
134 .collect()
135 })
136}
137
138fn executable(path: &Path) -> bool {
140 std::fs::metadata(path).is_ok_and(|meta| {
141 #[cfg(unix)]
142 {
143 use std::os::unix::fs::PermissionsExt as _;
144 meta.is_file() && meta.permissions().mode() & 0o111 != 0
145 }
146 #[cfg(not(unix))]
147 {
148 meta.is_file()
149 }
150 })
151}
152
153#[must_use]
166pub fn unexcluded_nested_configs(target: &Path) -> Vec<String> {
167 let target = std::path::absolute(target).unwrap_or_else(|_| target.to_path_buf());
168 let Some(listed) = git(
169 &target,
170 &["ls-files", "-z", "--", ":(glob)**/.pre-commit-config.yaml"],
171 ) else {
172 return Vec::new();
173 };
174 let nested: Vec<String> = String::from_utf8_lossy(&listed.stdout)
175 .split('\0')
176 .filter(|path| path.contains('/'))
177 .map(str::to_owned)
178 .collect();
179 if nested.is_empty() {
180 return nested;
181 }
182 let mut dirs: Vec<String> = Vec::new();
185 for path in &nested {
186 let parts: Vec<&str> = path.split('/').collect();
187 for depth in 1..parts.len() {
188 let dir = parts[..depth].join("/");
189 if !dirs.contains(&dir) {
190 dirs.push(dir);
191 }
192 }
193 }
194 let custom = layer(&target, &dirs, ".prekignore");
195 let ignore = layer(&target, &dirs, ".ignore");
196 let git_rules = judged(&target, &dirs, &[]);
197 let entered = |dir: &str| {
198 let name = dir.rsplit('/').next().unwrap_or(dir);
199 if name.starts_with("{{") && name.ends_with("}}") && name.contains("cookiecutter") {
200 return false;
201 }
202 let matched = custom
203 .get(dir)
204 .or_else(|| ignore.get(dir))
205 .or_else(|| git_rules.get(dir))
206 .copied();
207 match matched {
208 Some(Rule::Ignore) => false,
209 Some(Rule::Keep) => true,
210 None => !name.starts_with('.'),
211 }
212 };
213 nested
214 .into_iter()
215 .filter(|path| {
216 let parts: Vec<&str> = path.split('/').collect();
217 (1..parts.len()).all(|depth| entered(&parts[..depth].join("/")))
218 })
219 .collect()
220}
221
222#[derive(Debug, Clone, Copy, PartialEq, Eq)]
224enum Rule {
225 Ignore,
227 Keep,
229}
230
231fn layer(target: &Path, dirs: &[String], name: &str) -> std::collections::HashMap<String, Rule> {
237 let mut sources: Vec<&str> = vec![""];
238 sources.extend(dirs.iter().map(String::as_str));
239 if !sources
240 .iter()
241 .any(|dir| target.join(dir).join(name).is_file())
242 {
243 return std::collections::HashMap::new();
244 }
245 let Some(scratch) = Scratch::new() else {
246 return std::collections::HashMap::new();
247 };
248 if git(scratch.path(), &["init", "-q"]).is_none() {
249 return std::collections::HashMap::new();
250 }
251 for dir in &sources {
252 let _ = std::fs::create_dir_all(scratch.path().join(dir));
253 let rules = target.join(dir).join(name);
254 if rules.is_file() {
255 let _ = std::fs::copy(&rules, scratch.path().join(dir).join(".gitignore"));
256 }
257 }
258 let empty = scratch.path().join(".git/rk-no-excludes");
259 let _ = std::fs::write(&empty, "");
260 let _ = std::fs::write(scratch.path().join(".git/info/exclude"), "");
261 let excludes = format!("core.excludesFile={}", empty.display());
262 judged(scratch.path(), dirs, &["-c", &excludes])
263}
264
265struct Scratch(PathBuf);
268
269impl Scratch {
270 fn new() -> Option<Self> {
271 let nanos = std::time::SystemTime::now()
272 .duration_since(std::time::UNIX_EPOCH)
273 .map_or(0, |since| since.as_nanos());
274 let path =
275 std::env::temp_dir().join(format!("rk-prekignore-{}-{nanos}", std::process::id()));
276 std::fs::create_dir(&path).ok()?;
277 Some(Self(path))
278 }
279
280 fn path(&self) -> &Path {
281 &self.0
282 }
283}
284
285impl Drop for Scratch {
286 fn drop(&mut self) {
287 let _ = std::fs::remove_dir_all(&self.0);
288 }
289}
290
291fn judged(
295 repo: &Path,
296 dirs: &[String],
297 config: &[&str],
298) -> std::collections::HashMap<String, Rule> {
299 let mut command = Command::new(crate::probes::git_bin());
300 for var in GIT_HOOK_VARS {
301 command.env_remove(var);
302 }
303 let checked = command
304 .arg("-C")
305 .arg(repo)
306 .args(config)
307 .args(["check-ignore", "--no-index", "-v", "-n", "-z", "--stdin"])
308 .stdin(std::process::Stdio::piped())
309 .stdout(std::process::Stdio::piped())
310 .stderr(std::process::Stdio::null())
311 .spawn()
312 .and_then(|mut child| {
313 use std::io::Write as _;
314 let input: Vec<u8> = dirs
320 .iter()
321 .flat_map(|dir| dir.bytes().chain(std::iter::once(0)))
322 .collect();
323 let writer = child.stdin.take().map(|mut stdin| {
324 std::thread::spawn(move || {
325 let _ = stdin.write_all(&input);
326 })
327 });
328 let output = child.wait_with_output();
329 if let Some(writer) = writer {
330 let _ = writer.join();
331 }
332 output
333 });
334 let Ok(output) = checked else {
335 return std::collections::HashMap::new();
336 };
337 let fields: Vec<String> = String::from_utf8_lossy(&output.stdout)
339 .split('\0')
340 .map(str::to_owned)
341 .collect();
342 let mut found = std::collections::HashMap::new();
343 for record in fields.as_chunks::<4>().0 {
344 let (source, pattern, path) = (&record[0], &record[2], &record[3]);
345 if source.is_empty() {
346 continue;
347 }
348 let rule = if pattern.starts_with('!') {
349 Rule::Keep
350 } else {
351 Rule::Ignore
352 };
353 found.insert(path.trim_end_matches('/').to_owned(), rule);
354 }
355 found
356}
357
358#[cfg(test)]
359mod tests {
360 use super::{Rule, configured, judged};
361 use std::process::Command;
362
363 fn git_in(dir: &std::path::Path, args: &[&str]) {
364 let mut command = Command::new(crate::probes::git_bin());
365 for var in crate::maintenance::GIT_HOOK_VARS {
366 command.env_remove(var);
367 }
368 let status = command
369 .arg("-C")
370 .arg(dir)
371 .args(args)
372 .status()
373 .expect("git runs");
374 assert!(status.success(), "{args:?}");
375 }
376
377 #[test]
380 fn a_disabled_configured_hook_is_not_counted() {
381 let repo = tempfile::tempdir().expect("a scratch repository");
382 git_in(repo.path(), &["init", "-q"]);
383 git_in(
384 repo.path(),
385 &["config", "hook.review.event", "pre-merge-commit"],
386 );
387 git_in(repo.path(), &["config", "hook.review.command", "false"]);
388 git_in(repo.path(), &["config", "hook.review.enabled", "false"]);
389 assert!(configured(repo.path(), "pre-merge-commit").is_empty());
390 git_in(repo.path(), &["config", "hook.review.enabled", "true"]);
391 assert_eq!(configured(repo.path(), "pre-merge-commit"), vec!["review"]);
392 }
393
394 #[test]
397 fn a_long_list_is_judged_without_a_stall() {
398 let repo = tempfile::tempdir().expect("a scratch repository");
399 git_in(repo.path(), &["init", "-q"]);
400 std::fs::write(repo.path().join(".gitignore"), "skip-*/\n").expect("writes");
401 let dirs: Vec<String> = (0..20_000)
402 .map(|n| format!("project-directory-{n:05}"))
403 .chain(std::iter::once("skip-me".to_owned()))
404 .collect();
405 std::fs::create_dir(repo.path().join("skip-me")).expect("the dir creates");
406 let path = repo.path().to_path_buf();
407 let (sender, receiver) = std::sync::mpsc::channel();
408 std::thread::spawn(move || {
409 let _ = sender.send(judged(&path, &dirs, &[]));
410 });
411 let found = receiver
412 .recv_timeout(std::time::Duration::from_secs(60))
413 .expect("the judgment finishes");
414 assert_eq!(found.get("skip-me"), Some(&Rule::Ignore));
415 assert_eq!(found.len(), 1);
416 }
417}