Skip to main content

release_kit/
hooks.rs

1//! What rk reads about a project's git hooks, for its reports alone.
2//!
3//! rk runs no hook manager. `rk integrate` asks git to run the clone's
4//! `pre-merge-commit` hook, and git runs whatever the project installed.
5//! This module only reads: which hook manager wrote each installed hook,
6//! for `rk doctor`, and which nested hook configuration prek would run as a
7//! project of its own, for `rk status`. Neither answer changes what any
8//! verb runs.
9//!
10//! SATISFIES tool-requirements:the-doctor-reports-the-hook-runner
11//! SATISFIES tool-requirements:a-nested-hook-configuration-is-reported
12
13use std::path::{Path, PathBuf};
14use std::process::Command;
15
16use crate::maintenance::GIT_HOOK_VARS;
17
18/// The hook events the landed block and the integration gate use.
19pub const EVENTS: [&str; 4] = ["pre-commit", "commit-msg", "pre-push", "pre-merge-commit"];
20
21/// Who wrote one installed hook, read from the header its manager writes.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum Writer {
24    /// prek: `# File generated by prek`.
25    Prek,
26    /// pre-commit: `# File generated by pre-commit`.
27    PreCommit,
28    /// No known manager: the project's own hook.
29    Own,
30    /// A hook git's configuration defines, by `hook.<name>.event`, which
31    /// git 2.54 and newer run beside the hooks directory.
32    Configured,
33}
34
35impl Writer {
36    /// The manager's binary, where a known one wrote the hook.
37    #[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/// One installed hook: its event and who wrote it.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct Installed {
50    /// The hook event, for example `pre-merge-commit`.
51    pub event: &'static str,
52    /// Who wrote it.
53    pub writer: Writer,
54}
55
56/// A git call in `dir`, with the hook variables scrubbed and git's own
57/// words untranslated, because `git hook list` translates the name it
58/// gives the hooks directory's file; `None` where git does not answer.
59fn 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/// The hooks installed in the repository at `dir`, through git's own path
75/// to its hooks directory, `core.hooksPath` included; `None` outside a
76/// repository.
77#[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
120/// The hooks git's configuration defines for `event` and runs, by name.
121/// `git hook list` names each, names the hooks directory's own file as
122/// `hook from hookdir`, which [`installed`] already read, and marks a
123/// hook that `hook.<name>.enabled` turns off with `disabled` and a tab,
124/// which git does not run. A git older than 2.54 has no `list` and
125/// defines none.
126fn 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
138/// Whether `path` is a file git would run as a hook.
139fn 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/// The nested hook configurations of `target` that prek would run.
154///
155/// Every tracked `.pre-commit-config.yaml` below the root whose every
156/// directory prek's walk enters. prek judges each directory by three
157/// layers of ignore rules, the first that names it winning: the
158/// `.prekignore` files of the root and of each directory above it, then
159/// the `.ignore` files, then git's own rules, `.gitignore`,
160/// `info/exclude`, and the global excludes. A hidden directory is skipped
161/// unless a rule keeps it, and a Cookiecutter template directory always.
162/// git's own matcher judges each layer: the two custom layers in a
163/// scratch repository that holds their files as `.gitignore`, and git's
164/// rules in the target.
165#[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    // Every directory a nested configuration sits in or below, in walk
183    // order, each judged once.
184    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/// What a rule says of a directory.
223#[derive(Debug, Clone, Copy, PartialEq, Eq)]
224enum Rule {
225    /// A pattern ignores it.
226    Ignore,
227    /// A negated pattern keeps it.
228    Keep,
229}
230
231/// The directories of `dirs` that one custom layer names: every `name`
232/// file of the root and of the directories above each one, copied as
233/// `.gitignore` into a scratch repository that also holds each
234/// directory, so git judges the patterns, the directory-only ones too,
235/// with no other rule of the host's.
236fn 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
265/// A scratch directory under the system's temporary directory, removed
266/// on drop.
267struct 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
291/// The directories of `dirs` that the ignore rules of the repository at
292/// `repo` name, with `config` before the command: each matched one, as
293/// ignored or kept by a negated pattern.
294fn 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            // The names go in from a thread of their own while the answer
315            // is read, so neither pipe can fill while the other waits.
316            // No trailing slash: git then matches a negated directory
317            // pattern too, and it judges the path as a directory because
318            // the directory exists.
319            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    // Each record is the source, the line, the pattern, and the path.
338    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    /// A configured hook that `hook.<name>.enabled` turns off is no hook
378    /// git runs.
379    #[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    /// A list of directories whose names and answer each pass the size of
395    /// a pipe is judged to the end.
396    #[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}