Skip to main content

grit_lib/
hooks.rs

1//! Hook execution utilities.
2//!
3//! Provides a reusable function for running Git hooks from `.git/hooks/`
4//! or from the directory configured via `core.hooksPath`.
5
6use crate::config::ConfigSet;
7use crate::repo::Repository;
8use std::fs;
9use std::os::unix::fs::PermissionsExt;
10use std::path::{Path, PathBuf};
11use std::process::{Command, Stdio};
12
13#[cfg(unix)]
14const ENOEXEC: i32 = 8;
15
16#[cfg(unix)]
17fn is_enoexec(err: &std::io::Error) -> bool {
18    err.raw_os_error() == Some(ENOEXEC)
19}
20
21fn stdio_piped(piped: bool) -> Stdio {
22    if piped {
23        Stdio::piped()
24    } else {
25        Stdio::inherit()
26    }
27}
28
29/// Spawn a hook script. If the kernel rejects direct execution (e.g. no shebang, ENOEXEC), run it
30/// with `/bin/sh` like Git does.
31fn spawn_hook_child(
32    hook_path: &Path,
33    hook_args: &[&str],
34    cwd: &Path,
35    git_dir: &Path,
36    extra_env: &[(&str, &str)],
37    stdin_piped: bool,
38    stdout_piped: bool,
39    stderr_piped: bool,
40    use_shell: bool,
41) -> std::io::Result<std::process::Child> {
42    let mut cmd = if use_shell {
43        let mut sh = Command::new("/bin/sh");
44        sh.arg(hook_path);
45        sh
46    } else {
47        Command::new(hook_path)
48    };
49    cmd.args(hook_args)
50        .current_dir(cwd)
51        .env("GIT_DIR", git_dir)
52        .stdin(stdio_piped(stdin_piped))
53        .stdout(stdio_piped(stdout_piped))
54        .stderr(stdio_piped(stderr_piped));
55    for (k, v) in extra_env {
56        cmd.env(k, v);
57    }
58    match cmd.spawn() {
59        Ok(c) => Ok(c),
60        Err(e) => {
61            #[cfg(unix)]
62            {
63                if !use_shell && is_enoexec(&e) {
64                    return spawn_hook_child(
65                        hook_path,
66                        hook_args,
67                        cwd,
68                        git_dir,
69                        extra_env,
70                        stdin_piped,
71                        stdout_piped,
72                        stderr_piped,
73                        true,
74                    );
75                }
76            }
77            Err(e)
78        }
79    }
80}
81
82/// Result of running a hook.
83#[derive(Debug)]
84pub enum HookResult {
85    /// Hook ran successfully (exit code 0).
86    Success,
87    /// Hook does not exist or is not executable — treated as success.
88    NotFound,
89    /// Hook ran but returned a non-zero exit code.
90    Failed(i32),
91}
92
93impl HookResult {
94    /// Returns true if the hook was successful or not found.
95    pub fn is_ok(&self) -> bool {
96        matches!(self, HookResult::Success | HookResult::NotFound)
97    }
98
99    /// Returns true if the hook existed and ran (regardless of exit code).
100    pub fn was_executed(&self) -> bool {
101        matches!(self, HookResult::Success | HookResult::Failed(_))
102    }
103}
104
105/// Resolve the hooks directory from config or fall back to `$GIT_DIR/hooks`.
106pub fn resolve_hooks_dir(repo: &Repository) -> PathBuf {
107    let config = ConfigSet::load(Some(&repo.git_dir), true).ok();
108
109    if let Some(ref config) = config {
110        if let Some(hooks_path) = config.get("core.hooksPath") {
111            let expanded = crate::config::parse_path(&hooks_path);
112            let p = PathBuf::from(expanded);
113            if p.is_absolute() {
114                return p;
115            }
116            // Relative to the working directory (git behaviour).
117            if let Ok(cwd) = std::env::current_dir() {
118                return cwd.join(p);
119            }
120        }
121    }
122
123    repo.git_dir.join("hooks")
124}
125
126fn hook_command_path(repo: &Repository, hooks_dir: &Path, hook_name: &str, cwd: &Path) -> PathBuf {
127    let default_hooks_dir = repo.git_dir.join("hooks");
128    if hooks_dir == default_hooks_dir {
129        if cwd == repo.git_dir {
130            return PathBuf::from("hooks").join(hook_name);
131        }
132        if let Some(work_tree) = repo.work_tree.as_deref() {
133            if cwd == work_tree {
134                return PathBuf::from(".git").join("hooks").join(hook_name);
135            }
136        }
137    }
138    hooks_dir.join(hook_name)
139}
140
141/// Run a hook by name with the given arguments.
142///
143/// The hook is looked up in the hooks directory (respecting `core.hooksPath`).
144/// If the hook file doesn't exist or isn't executable, returns `HookResult::NotFound`.
145///
146/// `stdin_data` can optionally provide data to write to the hook's stdin.
147pub fn run_hook(
148    repo: &Repository,
149    hook_name: &str,
150    args: &[&str],
151    stdin_data: Option<&[u8]>,
152) -> HookResult {
153    let hooks_dir = resolve_hooks_dir(repo);
154    let hook_path = hooks_dir.join(hook_name);
155
156    // If the hook doesn't exist, silently succeed (git behaviour).
157    if !hook_path.exists() {
158        return HookResult::NotFound;
159    }
160
161    // Check if executable.
162    let meta = match fs::metadata(&hook_path) {
163        Ok(m) => m,
164        Err(_) => return HookResult::NotFound,
165    };
166    if meta.permissions().mode() & 0o111 == 0 {
167        // Warn that the hook exists but is not executable (like git does)
168        let config = ConfigSet::load(Some(&repo.git_dir), true).ok();
169        let show_warning = config
170            .as_ref()
171            .and_then(|c| c.get("advice.ignoredHook"))
172            .map(|v| !matches!(v.to_lowercase().as_str(), "false" | "no" | "off" | "0"))
173            .unwrap_or(true);
174        if show_warning {
175            eprintln!(
176                "hint: The '{}' hook was ignored because it's not set as executable.",
177                hook_name
178            );
179            eprintln!(
180                "hint: You can disable this warning with `git config set advice.ignoredHook false`."
181            );
182        }
183        return HookResult::NotFound;
184    }
185
186    let work_dir = repo.work_tree.as_deref().unwrap_or(&repo.git_dir);
187    let command_path = hook_command_path(repo, &hooks_dir, hook_name, work_dir);
188
189    let stdin_piped = stdin_data.is_some();
190
191    let mut child = match spawn_hook_child(
192        &command_path,
193        args,
194        work_dir,
195        &repo.git_dir,
196        &[],
197        stdin_piped,
198        false,
199        false,
200        false,
201    ) {
202        Ok(c) => c,
203        Err(_) => return HookResult::Failed(1),
204    };
205
206    if let Some(data) = stdin_data {
207        if let Some(ref mut stdin) = child.stdin {
208            use std::io::Write;
209            let _ = stdin.write_all(data);
210        }
211        // Drop stdin to signal EOF
212        drop(child.stdin.take());
213    }
214
215    match child.wait() {
216        Ok(status) => {
217            if status.success() {
218                HookResult::Success
219            } else {
220                HookResult::Failed(status.code().unwrap_or(1))
221            }
222        }
223        Err(_) => HookResult::Failed(1),
224    }
225}
226
227/// Like `run_hook` but captures stdout and returns it alongside the result.
228/// Run a hook with extra env vars, setting cwd to GIT_DIR (for receive-side hooks).
229pub fn run_hook_in_git_dir(
230    repo: &Repository,
231    hook_name: &str,
232    args: &[&str],
233    stdin_data: Option<&[u8]>,
234    env_vars: &[(&str, &str)],
235) -> (HookResult, Vec<u8>) {
236    let hooks_dir = resolve_hooks_dir(repo);
237    let hook_path = hooks_dir.join(hook_name);
238
239    if !hook_path.exists() {
240        return (HookResult::NotFound, Vec::new());
241    }
242
243    let meta = match fs::metadata(&hook_path) {
244        Ok(m) => m,
245        Err(_) => return (HookResult::NotFound, Vec::new()),
246    };
247    if meta.permissions().mode() & 0o111 == 0 {
248        return (HookResult::NotFound, Vec::new());
249    }
250
251    let command_path = hook_command_path(repo, &hooks_dir, hook_name, &repo.git_dir);
252    let stdin_piped = stdin_data.is_some();
253
254    let mut child = match spawn_hook_child(
255        &command_path,
256        args,
257        &repo.git_dir,
258        &repo.git_dir,
259        env_vars,
260        stdin_piped,
261        true,
262        true,
263        false,
264    ) {
265        Ok(c) => c,
266        Err(_) => return (HookResult::Failed(1), Vec::new()),
267    };
268
269    if let Some(data) = stdin_data {
270        if let Some(ref mut stdin) = child.stdin {
271            use std::io::Write;
272            let _ = stdin.write_all(data);
273        }
274        drop(child.stdin.take());
275    }
276
277    match child.wait_with_output() {
278        Ok(output) => {
279            let mut combined = output.stdout;
280            combined.extend_from_slice(&output.stderr);
281            let result = if output.status.success() {
282                HookResult::Success
283            } else {
284                HookResult::Failed(output.status.code().unwrap_or(1))
285            };
286            (result, combined)
287        }
288        Err(_) => (HookResult::Failed(1), Vec::new()),
289    }
290}
291
292/// Like `run_hook` but with extra environment variables and captures output.
293pub fn run_hook_with_env(
294    repo: &Repository,
295    hook_name: &str,
296    args: &[&str],
297    stdin_data: Option<&[u8]>,
298    env_vars: &[(&str, &str)],
299) -> (HookResult, Vec<u8>) {
300    let hooks_dir = resolve_hooks_dir(repo);
301    let hook_path = hooks_dir.join(hook_name);
302
303    if !hook_path.exists() {
304        return (HookResult::NotFound, Vec::new());
305    }
306
307    let meta = match fs::metadata(&hook_path) {
308        Ok(m) => m,
309        Err(_) => return (HookResult::NotFound, Vec::new()),
310    };
311    if meta.permissions().mode() & 0o111 == 0 {
312        return (HookResult::NotFound, Vec::new());
313    }
314
315    let work_dir = repo.work_tree.as_deref().unwrap_or(&repo.git_dir);
316    let command_path = hook_command_path(repo, &hooks_dir, hook_name, work_dir);
317
318    let stdin_piped = stdin_data.is_some();
319
320    let mut child = match spawn_hook_child(
321        &command_path,
322        args,
323        work_dir,
324        &repo.git_dir,
325        env_vars,
326        stdin_piped,
327        true,
328        true,
329        false,
330    ) {
331        Ok(c) => c,
332        Err(_) => return (HookResult::Failed(1), Vec::new()),
333    };
334
335    if let Some(data) = stdin_data {
336        if let Some(ref mut stdin) = child.stdin {
337            use std::io::Write;
338            let _ = stdin.write_all(data);
339        }
340        drop(child.stdin.take());
341    }
342
343    match child.wait_with_output() {
344        Ok(output) => {
345            let mut combined = output.stdout;
346            combined.extend_from_slice(&output.stderr);
347            let result = if output.status.success() {
348                HookResult::Success
349            } else {
350                HookResult::Failed(output.status.code().unwrap_or(1))
351            };
352            (result, combined)
353        }
354        Err(_) => (HookResult::Failed(1), Vec::new()),
355    }
356}
357
358pub fn run_hook_capture(
359    repo: &Repository,
360    hook_name: &str,
361    args: &[&str],
362    stdin_data: Option<&[u8]>,
363) -> (HookResult, Vec<u8>) {
364    let hooks_dir = resolve_hooks_dir(repo);
365    let hook_path = hooks_dir.join(hook_name);
366
367    if !hook_path.exists() {
368        return (HookResult::NotFound, Vec::new());
369    }
370
371    let meta = match fs::metadata(&hook_path) {
372        Ok(m) => m,
373        Err(_) => return (HookResult::NotFound, Vec::new()),
374    };
375    if meta.permissions().mode() & 0o111 == 0 {
376        return (HookResult::NotFound, Vec::new());
377    }
378
379    let work_dir = repo.work_tree.as_deref().unwrap_or(&repo.git_dir);
380    let command_path = hook_command_path(repo, &hooks_dir, hook_name, work_dir);
381
382    let stdin_piped = stdin_data.is_some();
383
384    let mut child = match spawn_hook_child(
385        &command_path,
386        args,
387        work_dir,
388        &repo.git_dir,
389        &[],
390        stdin_piped,
391        true,
392        true,
393        false,
394    ) {
395        Ok(c) => c,
396        Err(_) => return (HookResult::Failed(1), Vec::new()),
397    };
398
399    if let Some(data) = stdin_data {
400        if let Some(ref mut stdin) = child.stdin {
401            use std::io::Write;
402            let _ = stdin.write_all(data);
403        }
404        drop(child.stdin.take());
405    }
406
407    match child.wait_with_output() {
408        Ok(output) => {
409            let mut combined = output.stdout;
410            combined.extend_from_slice(&output.stderr);
411            let result = if output.status.success() {
412                HookResult::Success
413            } else {
414                HookResult::Failed(output.status.code().unwrap_or(1))
415            };
416            (result, combined)
417        }
418        Err(_) => (HookResult::Failed(1), Vec::new()),
419    }
420}