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
281fn traditional_hook_candidate(
282    repo: &Repository,
283    hooks_dir: &Path,
284    hook_name: &str,
285) -> Option<PathBuf> {
286    let path = hooks_dir.join(hook_name);
287    if !path.exists() {
288        return None;
289    }
290    let meta = fs::metadata(&path).ok()?;
291    #[cfg(unix)]
292    if meta.permissions().mode() & 0o111 == 0 {
293        let config = ConfigSet::load(Some(&repo.git_dir), true).ok();
294        let show_warning = config
295            .as_ref()
296            .and_then(|c| c.get("advice.ignoredHook"))
297            .map(|v| !matches!(v.to_lowercase().as_str(), "false" | "no" | "off" | "0"))
298            .unwrap_or(true);
299        if show_warning {
300            eprintln!(
301                "hint: The '{hook_name}' hook was ignored because it's not set as executable."
302            );
303            eprintln!(
304                "hint: You can disable this warning with `git config set advice.ignoredHook false`."
305            );
306        }
307        return None;
308    }
309    Some(path)
310}
311
312/// Configured hooks only (for out-of-repo `git hook run`).
313fn resolve_configured_hooks_only(
314    hook_name: &str,
315    config: &ConfigSet,
316) -> Result<Vec<ResolvedHook>, String> {
317    let tables = HookConfigTables::from_config(config);
318    let mut seq = Vec::new();
319    for (_friendly, command) in tables.hooks_for_event(hook_name)? {
320        seq.push(ResolvedHook::Configured { command });
321    }
322    Ok(seq)
323}
324
325/// Build ordered hook list: configured hooks first, then the traditional hookdir script.
326fn resolve_hook_sequence(
327    repo: &Repository,
328    hook_name: &str,
329    config: &ConfigSet,
330) -> Result<Vec<ResolvedHook>, String> {
331    let tables = HookConfigTables::from_config(config);
332    let mut seq = Vec::new();
333    for (_friendly, command) in tables.hooks_for_event(hook_name)? {
334        seq.push(ResolvedHook::Configured { command });
335    }
336    let hooks_dir = resolve_hooks_dir_for_config(Some(&repo.git_dir), Some(config));
337    if let Some(path) = traditional_hook_candidate(repo, &hooks_dir, hook_name) {
338        let work_dir = repo.work_tree.as_deref().unwrap_or(&repo.git_dir);
339        let argv0 = hook_argv0(repo, &hooks_dir, hook_name, work_dir);
340        seq.push(ResolvedHook::Traditional { path, argv0 });
341    }
342    Ok(seq)
343}
344
345/// List hook display lines for `git hook list` (configured friendly names, then `hook from hookdir`).
346pub fn list_hooks_display_lines(
347    repo: Option<&Repository>,
348    hook_name: &str,
349    config: &ConfigSet,
350) -> Result<Vec<String>, String> {
351    let git_dir = repo.map(|r| r.git_dir.as_path());
352    let tables = HookConfigTables::from_config(config);
353    let mut lines = Vec::new();
354    for (friendly, _) in tables.hooks_for_event(hook_name)? {
355        lines.push(friendly);
356    }
357    if let Some(r) = repo {
358        let hooks_dir = resolve_hooks_dir_for_config(git_dir, Some(config));
359        if traditional_hook_candidate(r, &hooks_dir, hook_name).is_some() {
360            lines.push("hook from hookdir".to_owned());
361        }
362    }
363    Ok(lines)
364}
365
366/// Spawn a traditional hook executable. On ENOEXEC, retry with `/bin/sh`.
367fn spawn_traditional_hook(
368    argv0: &Path,
369    hook_args: &[&str],
370    cwd: &Path,
371    git_dir: &Path,
372    extra_env: &[(String, String)],
373    stdin_piped: bool,
374    stdout_piped: bool,
375    stderr_piped: bool,
376    use_shell: bool,
377) -> std::io::Result<std::process::Child> {
378    let mut cmd = if use_shell {
379        let mut sh = Command::new("/bin/sh");
380        sh.arg(argv0);
381        sh
382    } else {
383        Command::new(argv0)
384    };
385    cmd.args(hook_args)
386        .current_dir(cwd)
387        .env("GIT_DIR", git_dir)
388        .stdin(stdio_piped(stdin_piped))
389        .stdout(stdio_piped(stdout_piped))
390        .stderr(stdio_piped(stderr_piped));
391    for (k, v) in extra_env {
392        cmd.env(k, v);
393    }
394    match cmd.spawn() {
395        Ok(c) => Ok(c),
396        Err(e) => {
397            #[cfg(unix)]
398            {
399                if !use_shell && is_enoexec(&e) {
400                    return spawn_traditional_hook(
401                        argv0,
402                        hook_args,
403                        cwd,
404                        git_dir,
405                        extra_env,
406                        stdin_piped,
407                        stdout_piped,
408                        stderr_piped,
409                        true,
410                    );
411                }
412            }
413            Err(e)
414        }
415    }
416}
417
418/// Spawn a configured hook (`/bin/sh -c <command>`) with optional extra args as `$1`, `$2`, …
419fn spawn_configured_hook(
420    command: &str,
421    hook_args: &[&str],
422    cwd: &Path,
423    git_dir: Option<&Path>,
424    extra_env: &[(String, String)],
425    stdin_piped: bool,
426    stdout_piped: bool,
427    stderr_piped: bool,
428) -> std::io::Result<std::process::Child> {
429    let mut cmd = Command::new("/bin/sh");
430    cmd.arg("-c")
431        .arg(command)
432        .arg("hook")
433        .args(hook_args)
434        .current_dir(cwd)
435        .stdin(stdio_piped(stdin_piped))
436        .stdout(stdio_piped(stdout_piped))
437        .stderr(stdio_piped(stderr_piped));
438    if let Some(gd) = git_dir {
439        cmd.env("GIT_DIR", gd);
440    }
441    for (k, v) in extra_env {
442        cmd.env(k, v);
443    }
444    cmd.spawn()
445}
446
447fn report_spawn_error(path: &Path, err: &std::io::Error) {
448    let msg = format!("{err}");
449    let p = path.display();
450    if msg.contains("No such file") || msg.contains("not found") {
451        eprintln!("error: cannot exec '{p}': {msg}");
452    } else {
453        eprintln!("error: cannot exec '{p}': {msg}");
454    }
455}
456
457/// Result of running a hook.
458#[derive(Debug)]
459pub enum HookResult {
460    /// Hook ran successfully (exit code 0).
461    Success,
462    /// Hook does not exist or is not executable — treated as success.
463    NotFound,
464    /// Hook ran but returned a non-zero exit code.
465    Failed(i32),
466}
467
468impl HookResult {
469    /// Returns true if the hook was successful or not found.
470    #[must_use]
471    pub fn is_ok(&self) -> bool {
472        matches!(self, HookResult::Success | HookResult::NotFound)
473    }
474
475    /// Returns true if the hook existed and ran (regardless of exit code).
476    #[must_use]
477    pub fn was_executed(&self) -> bool {
478        matches!(self, HookResult::Success | HookResult::Failed(_))
479    }
480}
481
482/// Options for [`run_hook_opts`].
483#[derive(Debug, Clone, Default)]
484pub struct RunHookOptions<'a> {
485    /// When true, hook stdout is merged to stderr (Git default except `pre-push`).
486    pub stdout_to_stderr: bool,
487    /// File path to open and pipe to each hook's stdin (reopened per hook).
488    pub path_to_stdin: Option<&'a Path>,
489    /// In-memory stdin (used when `path_to_stdin` is None).
490    pub stdin_data: Option<&'a [u8]>,
491    /// Extra environment variables for each hook subprocess.
492    pub env_vars: &'a [(&'a str, &'a str)],
493    /// Override the hook process working directory.
494    pub cwd: Option<&'a Path>,
495    /// Commit-style env (`GIT_INDEX_FILE`, `GIT_PREFIX`, author exports, …) merged after `env_vars`.
496    pub commit_env: Option<&'a CommitHookEnv<'a>>,
497}
498
499/// Run all hooks for `hook_name` in Git order; return first non-zero exit or success.
500///
501/// When `repo` is `None`, only configured hooks run (out-of-repo); cwd is the process cwd and
502/// `GIT_DIR` is not set for those hooks.
503///
504/// When `capture_output` is `Some`, each hook's stdout and stderr are appended there (receive-pack /
505/// simulated remote) instead of being written to the process stderr.
506pub fn run_hook_opts(
507    repo: Option<&Repository>,
508    hook_name: &str,
509    args: &[&str],
510    config: &ConfigSet,
511    opts: RunHookOptions<'_>,
512    mut capture_output: Option<&mut Vec<u8>>,
513) -> Result<HookResult, String> {
514    let seq = match repo {
515        Some(r) => resolve_hook_sequence(r, hook_name, config)?,
516        None => resolve_configured_hooks_only(hook_name, config)?,
517    };
518    if seq.is_empty() {
519        return Ok(HookResult::NotFound);
520    }
521
522    let work_dir: PathBuf = opts.cwd.map_or_else(
523        || match repo {
524            Some(r) => r.work_tree.clone().unwrap_or_else(|| r.git_dir.clone()),
525            None => std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
526        },
527        Path::to_path_buf,
528    );
529    let work_dir = work_dir.as_path();
530    let git_dir_for_configured = repo.map(|r| r.git_dir.as_path());
531
532    let mut merged_env: Vec<(String, String)> = opts
533        .env_vars
534        .iter()
535        .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
536        .collect();
537    if let Some(r) = repo {
538        if let Some(ce) = opts.commit_env {
539            merged_env.extend(build_commit_hook_env(r, work_dir, ce));
540        }
541    }
542
543    for h in &seq {
544        let (stdin_piped, stdin_file) = match opts.path_to_stdin {
545            Some(p) => (true, Some(p.to_path_buf())),
546            None => (opts.stdin_data.is_some(), None),
547        };
548
549        let capture_mode = capture_output.is_some();
550        let (stdout_piped, stderr_piped) = if capture_mode {
551            (true, true)
552        } else if opts.stdout_to_stderr {
553            (true, true)
554        } else {
555            (false, false)
556        };
557
558        let mut child = match h {
559            ResolvedHook::Traditional { path, argv0 } => {
560                let Some(r) = repo else {
561                    continue;
562                };
563                let gd = r.git_dir.as_path();
564                let effective_argv0 = path
565                    .parent()
566                    .map(|hooks_dir| hook_argv0(r, hooks_dir, hook_name, work_dir))
567                    .unwrap_or_else(|| argv0.clone());
568                match spawn_traditional_hook(
569                    &effective_argv0,
570                    args,
571                    work_dir,
572                    gd,
573                    &merged_env,
574                    stdin_piped,
575                    stdout_piped,
576                    stderr_piped,
577                    false,
578                ) {
579                    Ok(c) => c,
580                    Err(e) => {
581                        report_spawn_error(path, &e);
582                        return Ok(HookResult::Failed(1));
583                    }
584                }
585            }
586            ResolvedHook::Configured { command } => {
587                match spawn_configured_hook(
588                    command,
589                    args,
590                    work_dir,
591                    git_dir_for_configured,
592                    &merged_env,
593                    stdin_piped,
594                    stdout_piped,
595                    stderr_piped,
596                ) {
597                    Ok(c) => c,
598                    Err(e) => {
599                        eprintln!("error: failed to run configured hook: {e}");
600                        return Ok(HookResult::Failed(1));
601                    }
602                }
603            }
604        };
605
606        if let Some(ref path) = stdin_file {
607            let file = match fs::File::open(path) {
608                Ok(f) => f,
609                Err(e) => {
610                    eprintln!("error: failed to open stdin file {}: {e}", path.display());
611                    return Ok(HookResult::Failed(1));
612                }
613            };
614            if let Some(ref mut stdin) = child.stdin {
615                let mut file = file;
616                let _ = std::io::copy(&mut file, stdin);
617            }
618            drop(child.stdin.take());
619        } else if let Some(data) = opts.stdin_data {
620            if let Some(ref mut stdin) = child.stdin {
621                let _ = stdin.write_all(data);
622            }
623            drop(child.stdin.take());
624        }
625
626        let status = if capture_mode {
627            let output = match child.wait_with_output() {
628                Ok(o) => o,
629                Err(_) => return Ok(HookResult::Failed(1)),
630            };
631            if let Some(buf) = capture_output.as_mut() {
632                buf.extend_from_slice(&output.stdout);
633                buf.extend_from_slice(&output.stderr);
634            }
635            output.status
636        } else if opts.stdout_to_stderr {
637            let output = match child.wait_with_output() {
638                Ok(o) => o,
639                Err(_) => return Ok(HookResult::Failed(1)),
640            };
641            let mut stderr = std::io::stderr().lock();
642            let _ = stderr.write_all(&output.stdout);
643            let _ = stderr.write_all(&output.stderr);
644            output.status
645        } else {
646            match child.wait() {
647                Ok(s) => s,
648                Err(_) => return Ok(HookResult::Failed(1)),
649            }
650        };
651
652        if !status.success() {
653            return Ok(HookResult::Failed(status.code().unwrap_or(1)));
654        }
655    }
656
657    Ok(HookResult::Success)
658}
659
660/// Run commit-style hooks with `GIT_INDEX_FILE`, `GIT_PREFIX`, and related env (Git `run_commit_hook`).
661pub fn run_commit_hook(
662    repo: &Repository,
663    hook_name: &str,
664    args: &[&str],
665    stdin_data: Option<&[u8]>,
666    commit_env: &CommitHookEnv<'_>,
667) -> Result<HookResult, String> {
668    let config = ConfigSet::load(Some(&repo.git_dir), true).map_err(|e| format!("{e}"))?;
669    let stdout_to_stderr = hook_name != "pre-push";
670    run_hook_opts(
671        Some(repo),
672        hook_name,
673        args,
674        &config,
675        RunHookOptions {
676            stdout_to_stderr,
677            path_to_stdin: None,
678            stdin_data,
679            env_vars: &[],
680            cwd: None,
681            commit_env: Some(commit_env),
682        },
683        None,
684    )
685}
686
687/// Run a hook by name with the given arguments (Git-compatible multihooks).
688///
689/// `pre-push` keeps stdout separate; other hooks merge stdout to stderr.
690pub fn run_hook(
691    repo: &Repository,
692    hook_name: &str,
693    args: &[&str],
694    stdin_data: Option<&[u8]>,
695) -> HookResult {
696    let config = match ConfigSet::load(Some(&repo.git_dir), true) {
697        Ok(c) => c,
698        Err(_) => return HookResult::Failed(1),
699    };
700    let stdout_to_stderr = hook_name != "pre-push";
701    match run_hook_opts(
702        Some(repo),
703        hook_name,
704        args,
705        &config,
706        RunHookOptions {
707            stdout_to_stderr,
708            path_to_stdin: None,
709            stdin_data,
710            env_vars: &[],
711            cwd: None,
712            commit_env: None,
713        },
714        None,
715    ) {
716        Ok(r) => r,
717        Err(msg) => {
718            eprintln!("fatal: {msg}");
719            HookResult::Failed(1)
720        }
721    }
722}
723
724/// Run a hook with extra env vars, cwd = `GIT_DIR` (receive-pack and similar).
725pub fn run_hook_in_git_dir(
726    repo: &Repository,
727    hook_name: &str,
728    args: &[&str],
729    stdin_data: Option<&[u8]>,
730    env_vars: &[(&str, &str)],
731) -> (HookResult, Vec<u8>) {
732    let config = match ConfigSet::load(Some(&repo.git_dir), true) {
733        Ok(c) => c,
734        Err(_) => return (HookResult::Failed(1), Vec::new()),
735    };
736    let mut captured = Vec::new();
737    match run_hook_opts(
738        Some(repo),
739        hook_name,
740        args,
741        &config,
742        RunHookOptions {
743            stdout_to_stderr: true,
744            path_to_stdin: None,
745            stdin_data,
746            env_vars,
747            cwd: Some(repo.git_dir.as_path()),
748            commit_env: None,
749        },
750        Some(&mut captured),
751    ) {
752        Ok(r) => (r, captured),
753        Err(_) => (HookResult::Failed(1), captured),
754    }
755}
756
757/// Like `run_hook` but with extra environment variables and captures output.
758pub fn run_hook_with_env(
759    repo: &Repository,
760    hook_name: &str,
761    args: &[&str],
762    stdin_data: Option<&[u8]>,
763    env_vars: &[(&str, &str)],
764) -> (HookResult, Vec<u8>) {
765    let config = match ConfigSet::load(Some(&repo.git_dir), true) {
766        Ok(c) => c,
767        Err(_) => return (HookResult::Failed(1), Vec::new()),
768    };
769    let mut captured = Vec::new();
770    match run_hook_opts(
771        Some(repo),
772        hook_name,
773        args,
774        &config,
775        RunHookOptions {
776            stdout_to_stderr: true,
777            path_to_stdin: None,
778            stdin_data,
779            env_vars,
780            cwd: None,
781            commit_env: None,
782        },
783        Some(&mut captured),
784    ) {
785        Ok(r) => (r, captured),
786        Err(_) => (HookResult::Failed(1), captured),
787    }
788}
789
790pub fn run_hook_capture(
791    repo: &Repository,
792    hook_name: &str,
793    args: &[&str],
794    stdin_data: Option<&[u8]>,
795) -> (HookResult, Vec<u8>) {
796    run_hook_with_env(repo, hook_name, args, stdin_data, &[])
797}
798
799/// `reference-transaction` hook with phase `committed` after updating `HEAD` and (on a branch)
800/// the branch ref to `new_oid`.
801///
802/// `old_head_commit` is the commit OID `HEAD` pointed at before the update, or `None` for an
803/// unborn branch (null old OID in hook stdin).
804#[must_use]
805pub fn run_reference_transaction_committed_for_head_update(
806    repo: &Repository,
807    head: &HeadState,
808    old_head_commit: Option<ObjectId>,
809    new_oid: ObjectId,
810) -> HookResult {
811    let zero = ObjectId::zero();
812    let old_oid = old_head_commit.unwrap_or(zero);
813    let old_hex = if old_oid == zero {
814        "0000000000000000000000000000000000000000".to_owned()
815    } else {
816        old_oid.to_hex()
817    };
818    let new_hex = new_oid.to_hex();
819    let mut stdin = String::new();
820    match head {
821        HeadState::Branch { refname, .. } => {
822            // Git sorts ref updates lexicographically; `HEAD` precedes `refs/...`.
823            stdin.push_str(&format!("{old_hex} {new_hex} HEAD\n"));
824            stdin.push_str(&format!("{old_hex} {new_hex} {refname}\n"));
825        }
826        _ => {
827            stdin.push_str(&format!("{old_hex} {new_hex} HEAD\n"));
828        }
829    }
830    run_hook(
831        repo,
832        "reference-transaction",
833        &["committed"],
834        Some(stdin.as_bytes()),
835    )
836}