Skip to main content

grit_lib/
hooks.rs

1//! Hook execution utilities.
2//!
3//! Implements Git's multihook model: hooks from `hook.<name>.*` config plus the
4//! traditional script in the hooks directory (`core.hooksPath` or `.git/hooks/`).
5
6use crate::config::{parse_path, ConfigSet};
7use crate::objects::ObjectId;
8use crate::repo::Repository;
9use crate::state::HeadState;
10use std::collections::{HashMap, HashSet, VecDeque};
11use std::fs;
12use std::io::Write;
13#[cfg(unix)]
14use std::os::unix::fs::PermissionsExt;
15use std::path::{Path, PathBuf};
16use std::process::{Command, Stdio};
17
18#[cfg(unix)]
19const ENOEXEC: i32 = 8;
20
21#[cfg(unix)]
22fn is_enoexec(err: &std::io::Error) -> bool {
23    err.raw_os_error() == Some(ENOEXEC)
24}
25
26fn stdio_piped(piped: bool) -> Stdio {
27    if piped {
28        Stdio::piped()
29    } else {
30        Stdio::inherit()
31    }
32}
33
34/// Environment for commit-style hooks (`GIT_INDEX_FILE`, `GIT_EDITOR`, `GIT_PREFIX`, and extra pairs).
35#[derive(Debug, Clone, Default)]
36pub struct CommitHookEnv<'a> {
37    /// Absolute or cwd-relative index path passed as `GIT_INDEX_FILE`.
38    pub index_file: Option<&'a Path>,
39    /// When set, overrides `GIT_EDITOR` for the hook subprocess (e.g. `":"` when no editor is used).
40    pub git_editor: Option<&'a str>,
41    /// When set, used as `GIT_PREFIX`; when unset, derived from the current directory and work tree.
42    pub git_prefix: Option<&'a str>,
43    /// Additional `KEY=value` pairs for the hook subprocess.
44    pub extra_env: &'a [(&'a str, &'a str)],
45}
46
47fn absolute_index_path(index_file: &Path) -> PathBuf {
48    if index_file.is_absolute() {
49        index_file.to_path_buf()
50    } else if let Ok(cwd) = std::env::current_dir() {
51        cwd.join(index_file)
52    } else {
53        index_file.to_path_buf()
54    }
55}
56
57/// `GIT_PREFIX` for the invoking cwd relative to the work tree (Git sets this from the user's
58/// `pwd`, not from the hook subprocess cwd, which is usually the work tree root).
59fn git_prefix_for_invocation(repo: &Repository, invocation_cwd: &Path) -> String {
60    let Some(wt) = repo.work_tree.as_deref() else {
61        return String::new();
62    };
63    if invocation_cwd == repo.git_dir.as_path() {
64        return String::new();
65    }
66    let wt_canon = wt.canonicalize().unwrap_or_else(|_| wt.to_path_buf());
67    let wd_canon = invocation_cwd
68        .canonicalize()
69        .unwrap_or_else(|_| invocation_cwd.to_path_buf());
70    let rel = wd_canon.strip_prefix(&wt_canon).ok();
71    let Some(rel) = rel else {
72        return String::new();
73    };
74    let Some(s) = rel.to_str() else {
75        return String::new();
76    };
77    if s.is_empty() {
78        return String::new();
79    }
80    let mut out = s.replace('\\', "/");
81    if !out.ends_with('/') {
82        out.push('/');
83    }
84    out
85}
86
87fn build_commit_hook_env(
88    repo: &Repository,
89    work_dir: &Path,
90    opts: &CommitHookEnv<'_>,
91) -> Vec<(String, String)> {
92    let mut env: Vec<(String, String)> = Vec::new();
93    if let Some(p) = opts.index_file {
94        env.push((
95            "GIT_INDEX_FILE".to_string(),
96            absolute_index_path(p).to_string_lossy().into_owned(),
97        ));
98    }
99    let invocation_cwd = std::env::current_dir().unwrap_or_else(|_| work_dir.to_path_buf());
100    let prefix = opts
101        .git_prefix
102        .map(|s| s.to_string())
103        .unwrap_or_else(|| git_prefix_for_invocation(repo, &invocation_cwd));
104    env.push(("GIT_PREFIX".to_string(), prefix));
105    if let Some(ed) = opts.git_editor {
106        env.push(("GIT_EDITOR".to_string(), ed.to_string()));
107    }
108    for (k, v) in opts.extra_env {
109        env.push(((*k).to_string(), (*v).to_string()));
110    }
111    env
112}
113
114/// Git `git_parse_maybe_bool`: `Some(true/false)` or `None` if unrecognized.
115fn parse_maybe_bool(value: &str) -> Option<bool> {
116    let v = value.trim().to_ascii_lowercase();
117    match v.as_str() {
118        "true" | "yes" | "on" | "1" => Some(true),
119        "false" | "no" | "off" | "0" => Some(false),
120        _ => None,
121    }
122}
123
124/// Split `hook.<subsection>.<var>` into `(subsection, var)`.
125fn parse_hook_config_key(key: &str) -> Option<(&str, &str)> {
126    let rest = key.strip_prefix("hook.")?;
127    let (subsection, var) = rest.rsplit_once('.')?;
128    if subsection.is_empty() || var.is_empty() {
129        return None;
130    }
131    Some((subsection, var))
132}
133
134/// Parsed `hook.*` configuration in one pass (Git `hook_config_lookup_all` semantics).
135#[derive(Debug, Default)]
136struct HookConfigTables {
137    /// Friendly name → last-seen command string.
138    commands: HashMap<String, String>,
139    /// Event name → ordered friendly names (last duplicate wins position).
140    event_hooks: HashMap<String, VecDeque<String>>,
141    disabled: HashSet<String>,
142}
143
144impl HookConfigTables {
145    fn apply_entry(&mut self, key: &str, value: Option<&str>) {
146        let Some((hook_name, subkey)) = parse_hook_config_key(key) else {
147            return;
148        };
149        let Some(value) = value else {
150            return;
151        };
152        let hook_name = hook_name.to_string();
153
154        match subkey {
155            "event" => {
156                if value.is_empty() {
157                    for hooks in self.event_hooks.values_mut() {
158                        hooks.retain(|n| n != &hook_name);
159                    }
160                } else {
161                    let event = value.to_string();
162                    let hooks = self.event_hooks.entry(event).or_default();
163                    hooks.retain(|n| n != &hook_name);
164                    hooks.push_back(hook_name);
165                }
166            }
167            "command" => {
168                self.commands.insert(hook_name, value.to_string());
169            }
170            "enabled" => match parse_maybe_bool(value) {
171                Some(false) => {
172                    self.disabled.insert(hook_name);
173                }
174                Some(true) => {
175                    self.disabled.remove(&hook_name);
176                }
177                None => {}
178            },
179            _ => {}
180        }
181    }
182
183    fn from_config(config: &ConfigSet) -> Self {
184        let mut t = Self::default();
185        for e in config.entries() {
186            t.apply_entry(&e.key, e.value.as_deref());
187        }
188        t
189    }
190
191    /// Configured hooks for `event`, in order, excluding disabled entries without a command.
192    ///
193    /// Returns `Err` if a non-disabled hook lacks `hook.<name>.command` (Git dies here).
194    fn hooks_for_event(&self, event: &str) -> Result<Vec<(String, String)>, String> {
195        let Some(names) = self.event_hooks.get(event) else {
196            return Ok(Vec::new());
197        };
198        let mut out = Vec::new();
199        for name in names {
200            if self.disabled.contains(name) {
201                continue;
202            }
203            let Some(cmd) = self.commands.get(name) else {
204                return Err(format!(
205                    "'hook.{name}.command' must be configured or 'hook.{name}.event' must be removed; aborting."
206                ));
207            };
208            out.push((name.clone(), cmd.clone()));
209        }
210        Ok(out)
211    }
212}
213
214/// One hook invocation resolved for an event.
215#[derive(Debug)]
216enum ResolvedHook {
217    Configured { command: String },
218    Traditional { path: PathBuf, argv0: PathBuf },
219}
220
221/// Resolve the hooks directory from config or fall back to `$GIT_DIR/hooks`.
222pub fn resolve_hooks_dir(repo: &Repository) -> PathBuf {
223    resolve_hooks_dir_for_config(
224        Some(&repo.git_dir),
225        ConfigSet::load(Some(&repo.git_dir), true).ok().as_ref(),
226    )
227}
228
229fn resolve_hooks_dir_for_config(git_dir: Option<&Path>, config: Option<&ConfigSet>) -> PathBuf {
230    if let Some(cfg) = config {
231        if let Some(hooks_path) = cfg.get("core.hooksPath") {
232            let expanded = parse_path(&hooks_path);
233            let p = PathBuf::from(expanded);
234            if p.is_absolute() {
235                return p;
236            }
237            if let Ok(cwd) = std::env::current_dir() {
238                return cwd.join(p);
239            }
240        }
241    }
242    git_dir
243        .map(|gd| crate::repo::common_git_dir_for_config(gd).join("hooks"))
244        .unwrap_or_else(|| PathBuf::from("hooks"))
245}
246
247fn hook_argv0(repo: &Repository, hooks_dir: &Path, hook_name: &str, cwd: &Path) -> PathBuf {
248    let default_hooks_dir = repo.git_dir.join("hooks");
249    if hooks_dir == default_hooks_dir.as_path() {
250        if cwd == repo.git_dir.as_path() {
251            return PathBuf::from("hooks").join(hook_name);
252        }
253        if let Some(work_tree) = repo.work_tree.as_deref() {
254            // Only relativise to `.git/hooks/<name>` when the git dir is literally
255            // `<work_tree>/.git`. When `GIT_DIR` points elsewhere (e.g.
256            // `GIT_DIR=clone1/.git` run from the parent dir, which makes the work
257            // tree default to the process cwd), the relative path would resolve
258            // against the wrong directory. In that case fall through to the
259            // absolute hook path, mirroring upstream `hook.c`, which absolutises
260            // the hook path whenever it runs the hook in a specific directory.
261            if cwd == work_tree && same_dir(repo.git_dir.as_path(), &work_tree.join(".git")) {
262                return PathBuf::from(".git").join("hooks").join(hook_name);
263            }
264        }
265    }
266    hooks_dir.join(hook_name)
267}
268
269/// Compare two paths for pointing at the same directory, tolerating symlinks and
270/// non-canonical components by canonicalising both ends when possible.
271fn same_dir(a: &Path, b: &Path) -> bool {
272    if a == b {
273        return true;
274    }
275    match (a.canonicalize(), b.canonicalize()) {
276        (Ok(ca), Ok(cb)) => ca == cb,
277        _ => false,
278    }
279}
280
281// `repo`/`meta` are only read on Unix (executable-bit advice); Windows has no
282// POSIX permission bits, so the candidate is returned without that check.
283#[cfg_attr(not(unix), allow(unused_variables))]
284fn traditional_hook_candidate(
285    repo: &Repository,
286    hooks_dir: &Path,
287    hook_name: &str,
288) -> Option<PathBuf> {
289    let path = hooks_dir.join(hook_name);
290    if !path.exists() {
291        return None;
292    }
293    let meta = fs::metadata(&path).ok()?;
294    #[cfg(unix)]
295    if meta.permissions().mode() & 0o111 == 0 {
296        let config = ConfigSet::load(Some(&repo.git_dir), true).ok();
297        let show_warning = config
298            .as_ref()
299            .and_then(|c| c.get("advice.ignoredHook"))
300            .map(|v| !matches!(v.to_lowercase().as_str(), "false" | "no" | "off" | "0"))
301            .unwrap_or(true);
302        if show_warning {
303            eprintln!(
304                "hint: The '{hook_name}' hook was ignored because it's not set as executable."
305            );
306            eprintln!(
307                "hint: You can disable this warning with `git config set advice.ignoredHook false`."
308            );
309        }
310        return None;
311    }
312    Some(path)
313}
314
315/// Configured hooks only (for out-of-repo `git hook run`).
316fn resolve_configured_hooks_only(
317    hook_name: &str,
318    config: &ConfigSet,
319) -> Result<Vec<ResolvedHook>, String> {
320    let tables = HookConfigTables::from_config(config);
321    let mut seq = Vec::new();
322    for (_friendly, command) in tables.hooks_for_event(hook_name)? {
323        seq.push(ResolvedHook::Configured { command });
324    }
325    Ok(seq)
326}
327
328/// Build ordered hook list: configured hooks first, then the traditional hookdir script.
329fn resolve_hook_sequence(
330    repo: &Repository,
331    hook_name: &str,
332    config: &ConfigSet,
333) -> Result<Vec<ResolvedHook>, String> {
334    let tables = HookConfigTables::from_config(config);
335    let mut seq = Vec::new();
336    for (_friendly, command) in tables.hooks_for_event(hook_name)? {
337        seq.push(ResolvedHook::Configured { command });
338    }
339    let hooks_dir = resolve_hooks_dir_for_config(Some(&repo.git_dir), Some(config));
340    if let Some(path) = traditional_hook_candidate(repo, &hooks_dir, hook_name) {
341        let work_dir = repo.work_tree.as_deref().unwrap_or(&repo.git_dir);
342        let argv0 = hook_argv0(repo, &hooks_dir, hook_name, work_dir);
343        seq.push(ResolvedHook::Traditional { path, argv0 });
344    }
345    Ok(seq)
346}
347
348/// List hook display lines for `git hook list` (configured friendly names, then `hook from hookdir`).
349pub fn list_hooks_display_lines(
350    repo: Option<&Repository>,
351    hook_name: &str,
352    config: &ConfigSet,
353) -> Result<Vec<String>, String> {
354    let git_dir = repo.map(|r| r.git_dir.as_path());
355    let tables = HookConfigTables::from_config(config);
356    let mut lines = Vec::new();
357    for (friendly, _) in tables.hooks_for_event(hook_name)? {
358        lines.push(friendly);
359    }
360    if let Some(r) = repo {
361        let hooks_dir = resolve_hooks_dir_for_config(git_dir, Some(config));
362        if traditional_hook_candidate(r, &hooks_dir, hook_name).is_some() {
363            lines.push("hook from hookdir".to_owned());
364        }
365    }
366    Ok(lines)
367}
368
369/// Spawn a traditional hook executable. On ENOEXEC, retry with `/bin/sh`.
370fn spawn_traditional_hook(
371    argv0: &Path,
372    hook_args: &[&str],
373    cwd: &Path,
374    git_dir: &Path,
375    extra_env: &[(String, String)],
376    stdin_piped: bool,
377    stdout_piped: bool,
378    stderr_piped: bool,
379    use_shell: bool,
380) -> std::io::Result<std::process::Child> {
381    let mut cmd = if use_shell {
382        let mut sh = Command::new("/bin/sh");
383        sh.arg(argv0);
384        sh
385    } else {
386        Command::new(argv0)
387    };
388    cmd.args(hook_args)
389        .current_dir(cwd)
390        .env("GIT_DIR", git_dir)
391        .stdin(stdio_piped(stdin_piped))
392        .stdout(stdio_piped(stdout_piped))
393        .stderr(stdio_piped(stderr_piped));
394    for (k, v) in extra_env {
395        cmd.env(k, v);
396    }
397    match cmd.spawn() {
398        Ok(c) => Ok(c),
399        Err(e) => {
400            #[cfg(unix)]
401            {
402                if !use_shell && is_enoexec(&e) {
403                    return spawn_traditional_hook(
404                        argv0,
405                        hook_args,
406                        cwd,
407                        git_dir,
408                        extra_env,
409                        stdin_piped,
410                        stdout_piped,
411                        stderr_piped,
412                        true,
413                    );
414                }
415            }
416            Err(e)
417        }
418    }
419}
420
421/// Spawn a configured hook (`/bin/sh -c <command>`) with optional extra args as `$1`, `$2`, …
422fn spawn_configured_hook(
423    command: &str,
424    hook_args: &[&str],
425    cwd: &Path,
426    git_dir: Option<&Path>,
427    extra_env: &[(String, String)],
428    stdin_piped: bool,
429    stdout_piped: bool,
430    stderr_piped: bool,
431) -> std::io::Result<std::process::Child> {
432    let mut cmd = Command::new("/bin/sh");
433    cmd.arg("-c")
434        .arg(command)
435        .arg("hook")
436        .args(hook_args)
437        .current_dir(cwd)
438        .stdin(stdio_piped(stdin_piped))
439        .stdout(stdio_piped(stdout_piped))
440        .stderr(stdio_piped(stderr_piped));
441    if let Some(gd) = git_dir {
442        cmd.env("GIT_DIR", gd);
443    }
444    for (k, v) in extra_env {
445        cmd.env(k, v);
446    }
447    cmd.spawn()
448}
449
450fn report_spawn_error(path: &Path, err: &std::io::Error) {
451    let msg = format!("{err}");
452    let p = path.display();
453    if msg.contains("No such file") || msg.contains("not found") {
454        eprintln!("error: cannot exec '{p}': {msg}");
455    } else {
456        eprintln!("error: cannot exec '{p}': {msg}");
457    }
458}
459
460/// Result of running a hook.
461#[derive(Debug)]
462pub enum HookResult {
463    /// Hook ran successfully (exit code 0).
464    Success,
465    /// Hook does not exist or is not executable — treated as success.
466    NotFound,
467    /// Hook ran but returned a non-zero exit code.
468    Failed(i32),
469}
470
471impl HookResult {
472    /// Returns true if the hook was successful or not found.
473    #[must_use]
474    pub fn is_ok(&self) -> bool {
475        matches!(self, HookResult::Success | HookResult::NotFound)
476    }
477
478    /// Returns true if the hook existed and ran (regardless of exit code).
479    #[must_use]
480    pub fn was_executed(&self) -> bool {
481        matches!(self, HookResult::Success | HookResult::Failed(_))
482    }
483}
484
485/// Options for [`run_hook_opts`].
486#[derive(Debug, Clone, Default)]
487pub struct RunHookOptions<'a> {
488    /// When true, hook stdout is merged to stderr (Git default except `pre-push`).
489    pub stdout_to_stderr: bool,
490    /// File path to open and pipe to each hook's stdin (reopened per hook).
491    pub path_to_stdin: Option<&'a Path>,
492    /// In-memory stdin (used when `path_to_stdin` is None).
493    pub stdin_data: Option<&'a [u8]>,
494    /// Extra environment variables for each hook subprocess.
495    pub env_vars: &'a [(&'a str, &'a str)],
496    /// Override the hook process working directory.
497    pub cwd: Option<&'a Path>,
498    /// Commit-style env (`GIT_INDEX_FILE`, `GIT_PREFIX`, author exports, …) merged after `env_vars`.
499    pub commit_env: Option<&'a CommitHookEnv<'a>>,
500}
501
502/// Run all hooks for `hook_name` in Git order; return first non-zero exit or success.
503///
504/// When `repo` is `None`, only configured hooks run (out-of-repo); cwd is the process cwd and
505/// `GIT_DIR` is not set for those hooks.
506///
507/// When `capture_output` is `Some`, each hook's stdout and stderr are appended there (receive-pack /
508/// simulated remote) instead of being written to the process stderr.
509pub fn run_hook_opts(
510    repo: Option<&Repository>,
511    hook_name: &str,
512    args: &[&str],
513    config: &ConfigSet,
514    opts: RunHookOptions<'_>,
515    mut capture_output: Option<&mut Vec<u8>>,
516) -> Result<HookResult, String> {
517    let seq = match repo {
518        Some(r) => resolve_hook_sequence(r, hook_name, config)?,
519        None => resolve_configured_hooks_only(hook_name, config)?,
520    };
521    if seq.is_empty() {
522        return Ok(HookResult::NotFound);
523    }
524
525    let work_dir: PathBuf = opts.cwd.map_or_else(
526        || match repo {
527            Some(r) => r.work_tree.clone().unwrap_or_else(|| r.git_dir.clone()),
528            None => std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
529        },
530        Path::to_path_buf,
531    );
532    let work_dir = work_dir.as_path();
533    let git_dir_for_configured = repo.map(|r| r.git_dir.as_path());
534
535    let mut merged_env: Vec<(String, String)> = opts
536        .env_vars
537        .iter()
538        .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
539        .collect();
540    if let Some(r) = repo {
541        if let Some(ce) = opts.commit_env {
542            merged_env.extend(build_commit_hook_env(r, work_dir, ce));
543        }
544    }
545
546    for h in &seq {
547        let (stdin_piped, stdin_file) = match opts.path_to_stdin {
548            Some(p) => (true, Some(p.to_path_buf())),
549            None => (opts.stdin_data.is_some(), None),
550        };
551
552        let capture_mode = capture_output.is_some();
553        let (stdout_piped, stderr_piped) = if capture_mode {
554            (true, true)
555        } else if opts.stdout_to_stderr {
556            (true, true)
557        } else {
558            (false, false)
559        };
560
561        let mut child = match h {
562            ResolvedHook::Traditional { path, argv0 } => {
563                let Some(r) = repo else {
564                    continue;
565                };
566                let gd = r.git_dir.as_path();
567                let effective_argv0 = path
568                    .parent()
569                    .map(|hooks_dir| hook_argv0(r, hooks_dir, hook_name, work_dir))
570                    .unwrap_or_else(|| argv0.clone());
571                match spawn_traditional_hook(
572                    &effective_argv0,
573                    args,
574                    work_dir,
575                    gd,
576                    &merged_env,
577                    stdin_piped,
578                    stdout_piped,
579                    stderr_piped,
580                    false,
581                ) {
582                    Ok(c) => c,
583                    Err(e) => {
584                        report_spawn_error(path, &e);
585                        return Ok(HookResult::Failed(1));
586                    }
587                }
588            }
589            ResolvedHook::Configured { command } => {
590                match spawn_configured_hook(
591                    command,
592                    args,
593                    work_dir,
594                    git_dir_for_configured,
595                    &merged_env,
596                    stdin_piped,
597                    stdout_piped,
598                    stderr_piped,
599                ) {
600                    Ok(c) => c,
601                    Err(e) => {
602                        eprintln!("error: failed to run configured hook: {e}");
603                        return Ok(HookResult::Failed(1));
604                    }
605                }
606            }
607        };
608
609        if let Some(ref path) = stdin_file {
610            let file = match fs::File::open(path) {
611                Ok(f) => f,
612                Err(e) => {
613                    eprintln!("error: failed to open stdin file {}: {e}", path.display());
614                    return Ok(HookResult::Failed(1));
615                }
616            };
617            if let Some(ref mut stdin) = child.stdin {
618                let mut file = file;
619                let _ = std::io::copy(&mut file, stdin);
620            }
621            drop(child.stdin.take());
622        } else if let Some(data) = opts.stdin_data {
623            if let Some(ref mut stdin) = child.stdin {
624                let _ = stdin.write_all(data);
625            }
626            drop(child.stdin.take());
627        }
628
629        let status = if capture_mode {
630            let output = match child.wait_with_output() {
631                Ok(o) => o,
632                Err(_) => return Ok(HookResult::Failed(1)),
633            };
634            if let Some(buf) = capture_output.as_mut() {
635                buf.extend_from_slice(&output.stdout);
636                buf.extend_from_slice(&output.stderr);
637            }
638            output.status
639        } else if opts.stdout_to_stderr {
640            let output = match child.wait_with_output() {
641                Ok(o) => o,
642                Err(_) => return Ok(HookResult::Failed(1)),
643            };
644            let mut stderr = std::io::stderr().lock();
645            let _ = stderr.write_all(&output.stdout);
646            let _ = stderr.write_all(&output.stderr);
647            output.status
648        } else {
649            match child.wait() {
650                Ok(s) => s,
651                Err(_) => return Ok(HookResult::Failed(1)),
652            }
653        };
654
655        if !status.success() {
656            return Ok(HookResult::Failed(status.code().unwrap_or(1)));
657        }
658    }
659
660    Ok(HookResult::Success)
661}
662
663/// Run commit-style hooks with `GIT_INDEX_FILE`, `GIT_PREFIX`, and related env (Git `run_commit_hook`).
664pub fn run_commit_hook(
665    repo: &Repository,
666    hook_name: &str,
667    args: &[&str],
668    stdin_data: Option<&[u8]>,
669    commit_env: &CommitHookEnv<'_>,
670) -> Result<HookResult, String> {
671    let config = ConfigSet::load(Some(&repo.git_dir), true).map_err(|e| format!("{e}"))?;
672    let stdout_to_stderr = hook_name != "pre-push";
673    run_hook_opts(
674        Some(repo),
675        hook_name,
676        args,
677        &config,
678        RunHookOptions {
679            stdout_to_stderr,
680            path_to_stdin: None,
681            stdin_data,
682            env_vars: &[],
683            cwd: None,
684            commit_env: Some(commit_env),
685        },
686        None,
687    )
688}
689
690/// Run a hook by name with the given arguments (Git-compatible multihooks).
691///
692/// `pre-push` keeps stdout separate; other hooks merge stdout to stderr.
693pub fn run_hook(
694    repo: &Repository,
695    hook_name: &str,
696    args: &[&str],
697    stdin_data: Option<&[u8]>,
698) -> HookResult {
699    let config = match ConfigSet::load(Some(&repo.git_dir), true) {
700        Ok(c) => c,
701        Err(_) => return HookResult::Failed(1),
702    };
703    let stdout_to_stderr = hook_name != "pre-push";
704    match run_hook_opts(
705        Some(repo),
706        hook_name,
707        args,
708        &config,
709        RunHookOptions {
710            stdout_to_stderr,
711            path_to_stdin: None,
712            stdin_data,
713            env_vars: &[],
714            cwd: None,
715            commit_env: None,
716        },
717        None,
718    ) {
719        Ok(r) => r,
720        Err(msg) => {
721            eprintln!("fatal: {msg}");
722            HookResult::Failed(1)
723        }
724    }
725}
726
727/// Run a hook with extra env vars, cwd = `GIT_DIR` (receive-pack and similar).
728pub fn run_hook_in_git_dir(
729    repo: &Repository,
730    hook_name: &str,
731    args: &[&str],
732    stdin_data: Option<&[u8]>,
733    env_vars: &[(&str, &str)],
734) -> (HookResult, Vec<u8>) {
735    let config = match ConfigSet::load(Some(&repo.git_dir), true) {
736        Ok(c) => c,
737        Err(_) => return (HookResult::Failed(1), Vec::new()),
738    };
739    let mut captured = Vec::new();
740    match run_hook_opts(
741        Some(repo),
742        hook_name,
743        args,
744        &config,
745        RunHookOptions {
746            stdout_to_stderr: true,
747            path_to_stdin: None,
748            stdin_data,
749            env_vars,
750            cwd: Some(repo.git_dir.as_path()),
751            commit_env: None,
752        },
753        Some(&mut captured),
754    ) {
755        Ok(r) => (r, captured),
756        Err(_) => (HookResult::Failed(1), captured),
757    }
758}
759
760/// Like `run_hook` but with extra environment variables and captures output.
761pub fn run_hook_with_env(
762    repo: &Repository,
763    hook_name: &str,
764    args: &[&str],
765    stdin_data: Option<&[u8]>,
766    env_vars: &[(&str, &str)],
767) -> (HookResult, Vec<u8>) {
768    let config = match ConfigSet::load(Some(&repo.git_dir), true) {
769        Ok(c) => c,
770        Err(_) => return (HookResult::Failed(1), Vec::new()),
771    };
772    let mut captured = Vec::new();
773    match run_hook_opts(
774        Some(repo),
775        hook_name,
776        args,
777        &config,
778        RunHookOptions {
779            stdout_to_stderr: true,
780            path_to_stdin: None,
781            stdin_data,
782            env_vars,
783            cwd: None,
784            commit_env: None,
785        },
786        Some(&mut captured),
787    ) {
788        Ok(r) => (r, captured),
789        Err(_) => (HookResult::Failed(1), captured),
790    }
791}
792
793pub fn run_hook_capture(
794    repo: &Repository,
795    hook_name: &str,
796    args: &[&str],
797    stdin_data: Option<&[u8]>,
798) -> (HookResult, Vec<u8>) {
799    run_hook_with_env(repo, hook_name, args, stdin_data, &[])
800}
801
802/// `reference-transaction` hook with phase `committed` after updating `HEAD` and (on a branch)
803/// the branch ref to `new_oid`.
804///
805/// `old_head_commit` is the commit OID `HEAD` pointed at before the update, or `None` for an
806/// unborn branch (null old OID in hook stdin).
807#[must_use]
808pub fn run_reference_transaction_committed_for_head_update(
809    repo: &Repository,
810    head: &HeadState,
811    old_head_commit: Option<ObjectId>,
812    new_oid: ObjectId,
813) -> HookResult {
814    let zero = ObjectId::zero();
815    let old_oid = old_head_commit.unwrap_or(zero);
816    let old_hex = if old_oid == zero {
817        "0000000000000000000000000000000000000000".to_owned()
818    } else {
819        old_oid.to_hex()
820    };
821    let new_hex = new_oid.to_hex();
822    let mut stdin = String::new();
823    match head {
824        HeadState::Branch { refname, .. } => {
825            // Git sorts ref updates lexicographically; `HEAD` precedes `refs/...`.
826            stdin.push_str(&format!("{old_hex} {new_hex} HEAD\n"));
827            stdin.push_str(&format!("{old_hex} {new_hex} {refname}\n"));
828        }
829        _ => {
830            stdin.push_str(&format!("{old_hex} {new_hex} HEAD\n"));
831        }
832    }
833    run_hook(
834        repo,
835        "reference-transaction",
836        &["committed"],
837        Some(stdin.as_bytes()),
838    )
839}