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