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