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::command_runner::{
7    git_for_windows_tool_dirs, prepend_to_child_path, system_command_runner, CommandEnvironment,
8    CommandRunner, CommandSpec, CommandStdin, CommandStdio, RunningCommand, ScriptInterpreter,
9    ShellInvocation,
10};
11use crate::config::{parse_path, ConfigSet};
12use crate::git_path::path_without_verbatim_prefix;
13use crate::git_path::CanonicalizeExt;
14use crate::objects::ObjectId;
15use crate::repo::Repository;
16use crate::state::HeadState;
17use std::collections::{HashMap, HashSet, VecDeque};
18use std::ffi::OsString;
19use std::fs;
20#[cfg(unix)]
21use std::os::unix::fs::PermissionsExt;
22use std::path::{Path, PathBuf};
23use std::sync::Arc;
24use thiserror::Error;
25
26/// Whether `err` means the OS would not execute the file as a program, so it must be run
27/// through a shell (`ENOEXEC` on Unix, `ERROR_BAD_EXE_FORMAT` on Windows).
28fn is_not_an_executable(err: &std::io::Error) -> bool {
29    #[cfg(unix)]
30    const NOT_EXECUTABLE: i32 = 8;
31    #[cfg(windows)]
32    const NOT_EXECUTABLE: i32 = 193;
33    #[cfg(not(any(unix, windows)))]
34    const NOT_EXECUTABLE: i32 = -1;
35    err.raw_os_error() == Some(NOT_EXECUTABLE)
36}
37
38/// Where hook subprocesses send their stdout and stderr.
39///
40/// The library never writes hook output itself: it either hands the bytes back to the caller
41/// or lets the child write straight to this process's standard streams.
42#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
43pub enum HookStdio {
44    /// Pipe both streams and return them in [`HookResult`] and [`HookError::Failed`].
45    ///
46    /// The default, so an embedder (TUI, GUI, server) can show the output in its own way.
47    #[default]
48    Capture,
49    /// The child writes directly to this process's stderr, as Git does: live output, nothing
50    /// buffered. A hook's stdout goes to stderr too unless
51    /// [`RunHookOptions::stdout_to_stderr`] is false. Returned [`HookOutput`]s are empty.
52    Inherit,
53}
54
55/// Bytes the hooks of one event wrote while [`HookStdio::Capture`] was in effect.
56///
57/// When several hooks run for one event (configured `hook.<name>.*` entries, then the hooks
58/// directory script), their output is appended in run order.
59#[derive(Clone, Default, PartialEq, Eq)]
60pub struct HookOutput {
61    /// Everything the hooks wrote to stdout.
62    pub stdout: Vec<u8>,
63    /// Everything the hooks wrote to stderr.
64    pub stderr: Vec<u8>,
65}
66
67impl std::fmt::Debug for HookOutput {
68    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69        f.debug_struct("HookOutput")
70            .field("stdout", &String::from_utf8_lossy(&self.stdout))
71            .field("stderr", &String::from_utf8_lossy(&self.stderr))
72            .finish()
73    }
74}
75
76impl HookOutput {
77    /// Whether no output was captured on either stream.
78    #[must_use]
79    pub fn is_empty(&self) -> bool {
80        self.stdout.is_empty() && self.stderr.is_empty()
81    }
82}
83
84/// Hook subprocess failure (non-zero exit or spawn error).
85#[derive(Debug, Error)]
86pub enum HookError {
87    /// A hook ran and returned a non-zero exit status.
88    ///
89    /// The message is one line; the hook's own output is in `output`, never in `Display`.
90    #[error("hook {hook_name} failed with exit status {status}")]
91    Failed {
92        /// Basename of the hook script that failed.
93        hook_name: String,
94        /// Process exit code from the hook subprocess.
95        status: i32,
96        /// What the hooks wrote; empty under [`HookStdio::Inherit`].
97        output: HookOutput,
98    },
99    /// The hook (or the shell that runs it) could not be started.
100    #[error("failed to run hook {hook_name} ({})", path.display())]
101    Spawn {
102        /// Hook event name (`pre-commit`, `commit-msg`, …).
103        hook_name: String,
104        /// Program that failed to start: the hook script, or the interpreter chosen for it.
105        path: PathBuf,
106        /// Error returned by the [`CommandRunner`] when spawning.
107        #[source]
108        source: std::io::Error,
109        /// What earlier hooks for the same event wrote before this one could not start
110        /// (configured `hook.<name>.*` entries run before the hooks directory script); empty
111        /// under [`HookStdio::Inherit`].
112        output: HookOutput,
113    },
114    /// Configuration or internal error while resolving hooks.
115    #[error("{0}")]
116    Internal(String),
117}
118
119impl HookError {
120    /// Hook output captured before the failure, or `None` for [`HookError::Internal`].
121    #[must_use]
122    pub fn output(&self) -> Option<&HookOutput> {
123        match self {
124            HookError::Failed { output, .. } | HookError::Spawn { output, .. } => Some(output),
125            HookError::Internal(_) => None,
126        }
127    }
128}
129
130impl From<String> for HookError {
131    fn from(value: String) -> Self {
132        Self::Internal(value)
133    }
134}
135
136fn hook_command_env(git_dir: Option<&Path>, extra_env: &[(String, String)]) -> CommandEnvironment {
137    let mut env = CommandEnvironment::inherit_process_only();
138    if let Some(gd) = git_dir {
139        env.set
140            .push((OsString::from("GIT_DIR"), gd.as_os_str().to_os_string()));
141    }
142    for (k, v) in extra_env {
143        env.set
144            .push((OsString::from(k.as_str()), OsString::from(v.as_str())));
145    }
146    env
147}
148
149/// Environment for commit-style hooks (`GIT_INDEX_FILE`, `GIT_EDITOR`, `GIT_PREFIX`, and extra pairs).
150#[derive(Debug, Clone, Default)]
151pub struct CommitHookEnv<'a> {
152    /// Absolute or cwd-relative index path passed as `GIT_INDEX_FILE`.
153    pub index_file: Option<&'a Path>,
154    /// When set, overrides `GIT_EDITOR` for the hook subprocess (e.g. `":"` when no editor is used).
155    pub git_editor: Option<&'a str>,
156    /// When set, used as `GIT_PREFIX`; when unset, derived from the current directory and work tree.
157    pub git_prefix: Option<&'a str>,
158    /// Additional `KEY=value` pairs for the hook subprocess.
159    pub extra_env: &'a [(&'a str, &'a str)],
160}
161
162fn absolute_index_path(index_file: &Path, cwd: &Path) -> PathBuf {
163    if index_file.is_absolute() {
164        index_file.to_path_buf()
165    } else {
166        cwd.join(index_file)
167    }
168}
169
170/// `GIT_PREFIX` for the invoking cwd relative to the work tree (Git sets this from the user's
171/// `pwd`, not from the hook subprocess cwd, which is usually the work tree root).
172fn git_prefix_for_invocation(repo: &Repository, invocation_cwd: &Path) -> String {
173    let Some(wt) = repo.work_tree() else {
174        return String::new();
175    };
176    if invocation_cwd == repo.git_dir() {
177        return String::new();
178    }
179    let wt_canon = wt
180        .canonicalize_simplified()
181        .unwrap_or_else(|_| wt.to_path_buf());
182    let wd_canon = invocation_cwd
183        .canonicalize_simplified()
184        .unwrap_or_else(|_| invocation_cwd.to_path_buf());
185    let rel = wd_canon.strip_prefix(&wt_canon).ok();
186    let Some(rel) = rel else {
187        return String::new();
188    };
189    let Some(s) = rel.to_str() else {
190        return String::new();
191    };
192    if s.is_empty() {
193        return String::new();
194    }
195    let mut out = s.replace('\\', "/");
196    if !out.ends_with('/') {
197        out.push('/');
198    }
199    out
200}
201
202fn build_commit_hook_env(
203    repo: &Repository,
204    _work_dir: &Path,
205    opts: &CommitHookEnv<'_>,
206) -> Vec<(String, String)> {
207    let mut env: Vec<(String, String)> = Vec::new();
208    if let Some(p) = opts.index_file {
209        env.push((
210            "GIT_INDEX_FILE".to_string(),
211            absolute_index_path(p, &repo.environment().cwd)
212                .to_string_lossy()
213                .into_owned(),
214        ));
215    }
216    let invocation_cwd = repo.environment().discovery_cwd();
217    let prefix = opts
218        .git_prefix
219        .map(|s| s.to_string())
220        .unwrap_or_else(|| git_prefix_for_invocation(repo, &invocation_cwd));
221    env.push(("GIT_PREFIX".to_string(), prefix));
222    if let Some(ed) = opts.git_editor {
223        env.push(("GIT_EDITOR".to_string(), ed.to_string()));
224    }
225    for (k, v) in opts.extra_env {
226        env.push(((*k).to_string(), (*v).to_string()));
227    }
228    env
229}
230
231/// Git `git_parse_maybe_bool`: `Some(true/false)` or `None` if unrecognized.
232fn parse_maybe_bool(value: &str) -> Option<bool> {
233    let v = value.trim().to_ascii_lowercase();
234    match v.as_str() {
235        "true" | "yes" | "on" | "1" => Some(true),
236        "false" | "no" | "off" | "0" => Some(false),
237        _ => None,
238    }
239}
240
241/// Split `hook.<subsection>.<var>` into `(subsection, var)`.
242fn parse_hook_config_key(key: &str) -> Option<(&str, &str)> {
243    let rest = key.strip_prefix("hook.")?;
244    let (subsection, var) = rest.rsplit_once('.')?;
245    if subsection.is_empty() || var.is_empty() {
246        return None;
247    }
248    Some((subsection, var))
249}
250
251/// Parsed `hook.*` configuration in one pass (Git `hook_config_lookup_all` semantics).
252#[derive(Debug, Default)]
253struct HookConfigTables {
254    /// Friendly name → last-seen command string.
255    commands: HashMap<String, String>,
256    /// Event name → ordered friendly names (last duplicate wins position).
257    event_hooks: HashMap<String, VecDeque<String>>,
258    disabled: HashSet<String>,
259}
260
261impl HookConfigTables {
262    fn apply_entry(&mut self, key: &str, value: Option<&str>) {
263        let Some((hook_name, subkey)) = parse_hook_config_key(key) else {
264            return;
265        };
266        let Some(value) = value else {
267            return;
268        };
269        let hook_name = hook_name.to_string();
270
271        match subkey {
272            "event" => {
273                if value.is_empty() {
274                    for hooks in self.event_hooks.values_mut() {
275                        hooks.retain(|n| n != &hook_name);
276                    }
277                } else {
278                    let event = value.to_string();
279                    let hooks = self.event_hooks.entry(event).or_default();
280                    hooks.retain(|n| n != &hook_name);
281                    hooks.push_back(hook_name);
282                }
283            }
284            "command" => {
285                self.commands.insert(hook_name, value.to_string());
286            }
287            "enabled" => match parse_maybe_bool(value) {
288                Some(false) => {
289                    self.disabled.insert(hook_name);
290                }
291                Some(true) => {
292                    self.disabled.remove(&hook_name);
293                }
294                None => {}
295            },
296            _ => {}
297        }
298    }
299
300    fn from_config(config: &ConfigSet) -> Self {
301        let mut t = Self::default();
302        for e in config.entries() {
303            t.apply_entry(&e.key, e.value.as_deref());
304        }
305        t
306    }
307
308    /// Configured hooks for `event`, in order, excluding disabled entries without a command.
309    ///
310    /// Returns `Err` if a non-disabled hook lacks `hook.<name>.command` (Git dies here).
311    fn hooks_for_event(&self, event: &str) -> Result<Vec<(String, String)>, String> {
312        let Some(names) = self.event_hooks.get(event) else {
313            return Ok(Vec::new());
314        };
315        let mut out = Vec::new();
316        for name in names {
317            if self.disabled.contains(name) {
318                continue;
319            }
320            let Some(cmd) = self.commands.get(name) else {
321                return Err(format!(
322                    "'hook.{name}.command' must be configured or 'hook.{name}.event' must be removed; aborting."
323                ));
324            };
325            out.push((name.clone(), cmd.clone()));
326        }
327        Ok(out)
328    }
329}
330
331/// One hook invocation resolved for an event.
332#[derive(Debug)]
333enum ResolvedHook {
334    Configured { command: String },
335    Traditional { path: PathBuf, argv0: PathBuf },
336}
337
338/// Resolve the hooks directory from config or fall back to `$GIT_DIR/hooks`.
339///
340/// A relative `core.hooksPath` is taken relative to the directory hooks run in: the top of
341/// the work tree, or `$GIT_DIR` in a bare repository. It does not depend on the directory
342/// the repository was opened from.
343///
344/// Never carries a Windows `\\?\` prefix, so the path can be shown to users and handed to
345/// hook interpreters such as Git for Windows' `sh`.
346pub fn resolve_hooks_dir(repo: &Repository) -> PathBuf {
347    resolve_hooks_dir_for_config(repo, repo.config().ok().as_deref())
348}
349
350fn resolve_hooks_dir_for_config(repo: &Repository, config: Option<&ConfigSet>) -> PathBuf {
351    let dir = match config.and_then(|cfg| cfg.get("core.hooksPath")) {
352        Some(hooks_path) => {
353            let p = PathBuf::from(parse_path(repo.environment(), &hooks_path));
354            if p.is_absolute() {
355                p
356            } else {
357                hook_run_dir(repo).join(p)
358            }
359        }
360        None => crate::repo::common_git_dir_for_config(repo.git_dir()).join("hooks"),
361    };
362    path_without_verbatim_prefix(&dir).into_owned()
363}
364
365/// Directory hooks run in by default: the work tree top, or `$GIT_DIR` when bare.
366fn hook_run_dir(repo: &Repository) -> &Path {
367    repo.work_tree().unwrap_or(repo.git_dir())
368}
369
370fn hook_argv0(repo: &Repository, hooks_dir: &Path, hook_name: &str, cwd: &Path) -> PathBuf {
371    let default_hooks_dir = repo.git_dir().join("hooks");
372    if same_dir(hooks_dir, &default_hooks_dir) {
373        if cwd == repo.git_dir() {
374            return PathBuf::from("hooks").join(hook_name);
375        }
376        if let Some(work_tree) = repo.work_tree() {
377            // Only relativise to `.git/hooks/<name>` when the git dir is literally
378            // `<work_tree>/.git`. When `GIT_DIR` points elsewhere (e.g.
379            // `GIT_DIR=clone1/.git` run from the parent dir, which makes the work
380            // tree default to the process cwd), the relative path would resolve
381            // against the wrong directory. In that case fall through to the
382            // absolute hook path, mirroring upstream `hook.c`, which absolutises
383            // the hook path whenever it runs the hook in a specific directory.
384            if cwd == work_tree && same_dir(repo.git_dir(), &work_tree.join(".git")) {
385                return PathBuf::from(".git").join("hooks").join(hook_name);
386            }
387        }
388    }
389    hooks_dir.join(hook_name)
390}
391
392/// Compare two paths for pointing at the same directory, tolerating symlinks and
393/// non-canonical components by canonicalising both ends when possible.
394fn same_dir(a: &Path, b: &Path) -> bool {
395    if a == b {
396        return true;
397    }
398    match (a.canonicalize_simplified(), b.canonicalize_simplified()) {
399        (Ok(ca), Ok(cb)) => ca == cb,
400        _ => false,
401    }
402}
403
404// `repo`/`meta` are only read on Unix (executable-bit advice); Windows has no
405// POSIX permission bits, so the candidate is returned without that check.
406#[cfg_attr(not(unix), allow(unused_variables))]
407fn traditional_hook_candidate(
408    repo: &Repository,
409    hooks_dir: &Path,
410    hook_name: &str,
411) -> Option<PathBuf> {
412    let path = hooks_dir.join(hook_name);
413    if !path.exists() {
414        return None;
415    }
416    let meta = fs::metadata(&path).ok()?;
417    #[cfg(unix)]
418    if meta.permissions().mode() & 0o111 == 0 {
419        let config = repo.config().ok();
420        let show_warning = config
421            .as_deref()
422            .and_then(|c| c.get("advice.ignoredHook"))
423            .map(|v| !matches!(v.to_lowercase().as_str(), "false" | "no" | "off" | "0"))
424            .unwrap_or(true);
425        if show_warning {
426            repo.warn(crate::diagnostics::Warning::NonExecutableHookIgnored {
427                hook_name: hook_name.to_string(),
428                hook_path: Some(path.clone()),
429            });
430        }
431        return None;
432    }
433    Some(path)
434}
435
436/// Configured hooks only (for out-of-repo `git hook run`).
437fn resolve_configured_hooks_only(
438    hook_name: &str,
439    config: &ConfigSet,
440) -> Result<Vec<ResolvedHook>, String> {
441    let tables = HookConfigTables::from_config(config);
442    let mut seq = Vec::new();
443    for (_friendly, command) in tables.hooks_for_event(hook_name)? {
444        seq.push(ResolvedHook::Configured { command });
445    }
446    Ok(seq)
447}
448
449/// Build ordered hook list: configured hooks first, then the traditional hookdir script.
450fn resolve_hook_sequence(
451    repo: &Repository,
452    hook_name: &str,
453    config: &ConfigSet,
454) -> Result<Vec<ResolvedHook>, String> {
455    let tables = HookConfigTables::from_config(config);
456    let mut seq = Vec::new();
457    for (_friendly, command) in tables.hooks_for_event(hook_name)? {
458        seq.push(ResolvedHook::Configured { command });
459    }
460    let hooks_dir = resolve_hooks_dir_for_config(repo, Some(config));
461    if let Some(path) = traditional_hook_candidate(repo, &hooks_dir, hook_name) {
462        let argv0 = hook_argv0(repo, &hooks_dir, hook_name, hook_run_dir(repo));
463        seq.push(ResolvedHook::Traditional { path, argv0 });
464    }
465    Ok(seq)
466}
467
468/// Interpreter basename named by a script's `#!` line, or `None` without one.
469///
470/// `#!/bin/sh -e` gives `sh` and `#!/usr/bin/env bash` gives `bash`. Options after the
471/// interpreter are dropped, as Git for Windows does when it runs scripts itself.
472#[cfg_attr(not(windows), allow(dead_code))]
473fn shebang_interpreter(head: &[u8]) -> Option<String> {
474    let rest = head.strip_prefix(b"#!")?;
475    let line_len = rest
476        .iter()
477        .position(|&b| b == b'\n' || b == b'\r')
478        .unwrap_or(rest.len());
479    let line = std::str::from_utf8(&rest[..line_len]).ok()?;
480    let basename = |word: &str| word.rsplit(['/', '\\']).next().map(str::to_owned);
481    let mut words = line.split_ascii_whitespace();
482    let mut name = basename(words.next()?)?;
483    if name == "env" {
484        name = basename(words.find(|w| !w.starts_with('-'))?)?;
485    }
486    (!name.is_empty()).then_some(name)
487}
488
489/// Strip Windows `\\?\` prefixes from the path-valued `GIT_*` variables in `set`.
490///
491/// Repository paths are canonicalized, which makes them verbatim on Windows, but Git for
492/// Windows does not open a verbatim `GIT_INDEX_FILE` (it reads an empty index instead) and
493/// MSYS tools cannot open such paths at all.
494fn strip_verbatim_prefix_from_path_vars(set: &mut [(OsString, OsString)]) {
495    crate::git_path::strip_verbatim_prefix_from_subprocess_path_vars(set);
496}
497
498/// Shell for `hook.<name>.command` entries and for scripts the OS will not execute directly.
499///
500/// See [`crate::command_runner::shell_program`]: `/bin/sh` on Unix, `sh` resolved against the
501/// hook's `PATH` on Windows.
502fn hook_shell(env: &CommandEnvironment) -> ScriptInterpreter {
503    crate::command_runner::shell_interpreter(env)
504}
505
506/// Interpreter from the `#!` line of `script` (Windows cannot execute scripts directly).
507#[cfg(windows)]
508fn script_shebang_interpreter(
509    script: &Path,
510    env: &CommandEnvironment,
511) -> Option<ScriptInterpreter> {
512    use std::io::Read;
513    let mut head = [0u8; 256];
514    let mut file = fs::File::open(script).ok()?;
515    let n = file.read(&mut head).ok()?;
516    let name = shebang_interpreter(&head[..n])?;
517    Some(crate::command_runner::resolve_script_interpreter(
518        &name,
519        crate::command_runner::child_path_var(env).as_deref(),
520    ))
521}
522
523/// Stdio and environment shared by every hook subprocess.
524struct HookSpawnContext<'a> {
525    runner: &'a dyn CommandRunner,
526    cwd: &'a Path,
527    env: &'a CommandEnvironment,
528    stdin_piped: bool,
529    stdout: CommandStdio,
530    stderr: CommandStdio,
531}
532
533impl HookSpawnContext<'_> {
534    fn spec(&self, program: PathBuf, shell: Option<ShellInvocation>, args: &[&str]) -> CommandSpec {
535        CommandSpec {
536            program,
537            args: if shell.is_some() {
538                Vec::new()
539            } else {
540                args.iter().map(OsString::from).collect()
541            },
542            shell,
543            cwd: Some(path_without_verbatim_prefix(self.cwd).into_owned()),
544            env: self.env.clone(),
545            stdin: if self.stdin_piped {
546                CommandStdin::Pipe(Vec::new())
547            } else {
548                CommandStdin::Inherit
549            },
550            stdout: self.stdout,
551            stderr: self.stderr,
552        }
553    }
554
555    /// Spec running `interpreter` with `invocation`; an interpreter from a Git for Windows
556    /// tree also gets that tree's tool directories in front of `PATH`, as Git gives hooks.
557    fn interpreter_spec(
558        &self,
559        interpreter: ScriptInterpreter,
560        invocation: ShellInvocation,
561        args: &[&str],
562    ) -> CommandSpec {
563        let mut spec = self.spec(interpreter.program, Some(invocation), args);
564        if let Some(root) = &interpreter.git_root {
565            prepend_to_child_path(&mut spec.env, &git_for_windows_tool_dirs(root));
566        }
567        spec
568    }
569
570    fn spawn(&self, spec: &CommandSpec, hook_name: &str) -> Result<RunningCommand, HookError> {
571        self.runner.spawn(spec).map_err(|source| HookError::Spawn {
572            hook_name: hook_name.to_owned(),
573            path: spec.program.clone(),
574            source,
575            output: HookOutput::default(),
576        })
577    }
578
579    /// Run `script` (spelled `argv0` for the child) directly, or through a shell when the OS
580    /// will not execute it. On Windows a script with a `#!` line goes straight to that
581    /// interpreter.
582    #[cfg_attr(not(windows), allow(unused_variables))]
583    fn spawn_traditional(
584        &self,
585        hook_name: &str,
586        script: &Path,
587        argv0: &Path,
588        args: &[&str],
589    ) -> Result<RunningCommand, HookError> {
590        let argv0 = path_without_verbatim_prefix(argv0);
591        let via_shell = |shell: ScriptInterpreter| {
592            let invocation = ShellInvocation::ScriptPath {
593                script_path: argv0.to_path_buf(),
594                args: args.iter().map(OsString::from).collect(),
595            };
596            self.interpreter_spec(shell, invocation, args)
597        };
598        #[cfg(windows)]
599        if let Some(interpreter) = script_shebang_interpreter(script, self.env) {
600            return self.spawn(&via_shell(interpreter), hook_name);
601        }
602        let direct = self.spec(argv0.to_path_buf(), None, args);
603        match self.runner.spawn(&direct) {
604            Err(e) if is_not_an_executable(&e) => {
605                self.spawn(&via_shell(hook_shell(self.env)), hook_name)
606            }
607            Err(source) => Err(HookError::Spawn {
608                hook_name: hook_name.to_owned(),
609                path: direct.program,
610                source,
611                output: HookOutput::default(),
612            }),
613            Ok(child) => Ok(child),
614        }
615    }
616
617    /// Run a `hook.<name>.command` entry as `sh -c <command> hook <args>…`.
618    fn spawn_configured(
619        &self,
620        hook_name: &str,
621        command: &str,
622        args: &[&str],
623    ) -> Result<RunningCommand, HookError> {
624        let invocation = ShellInvocation::DashC {
625            script: command.to_owned(),
626            argv0: Some("hook".to_owned()),
627            args: args.iter().map(OsString::from).collect(),
628        };
629        self.spawn(
630            &self.interpreter_spec(hook_shell(self.env), invocation, args),
631            hook_name,
632        )
633    }
634}
635
636/// Well-known lifecycle hooks that porcelain operations run and report in their outcomes
637/// (for example [`crate::porcelain::commit::CommitOutcome::hooks_run`]).
638#[derive(Debug, Clone, Copy, PartialEq, Eq)]
639#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
640#[cfg_attr(feature = "serde-models", serde(rename_all = "kebab-case"))]
641pub enum HookName {
642    /// `.git/hooks/pre-commit` or `hook.pre-commit.*`.
643    PreCommit,
644    /// `.git/hooks/commit-msg` or `hook.commit-msg.*`.
645    CommitMsg,
646    /// `.git/hooks/post-commit` or `hook.post-commit.*`.
647    PostCommit,
648    /// `.git/hooks/post-checkout`, run after `HEAD` moves to another commit or branch.
649    PostCheckout,
650    /// `.git/hooks/post-merge`, run after a merge or fast-forward updates the work tree.
651    PostMerge,
652    /// `.git/hooks/post-rewrite`, run after amend or a history rewrite replaces commits.
653    PostRewrite,
654}
655
656impl HookName {
657    /// Git hook basename (`pre-commit`, `commit-msg`, `post-commit`, `post-checkout`, …).
658    #[must_use]
659    pub const fn as_str(self) -> &'static str {
660        match self {
661            Self::PreCommit => "pre-commit",
662            Self::CommitMsg => "commit-msg",
663            Self::PostCommit => "post-commit",
664            Self::PostCheckout => "post-checkout",
665            Self::PostMerge => "post-merge",
666            Self::PostRewrite => "post-rewrite",
667        }
668    }
669}
670
671/// Whether an operation runs the hooks Git runs once it has completed (`post-checkout`,
672/// `post-merge`, `post-rewrite`).
673///
674/// These hooks are advisory: their exit status never changes the operation's result. Callers
675/// that compose several operations (or an embedder that does not want user scripts to run)
676/// pass [`PostOperationHooks::Skip`].
677#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
678pub enum PostOperationHooks {
679    /// Run the installed post-operation hooks (default, matching Git).
680    #[default]
681    Run,
682    /// Do not run post-operation hooks.
683    Skip,
684}
685
686impl PostOperationHooks {
687    /// Whether hooks should run.
688    #[must_use]
689    pub const fn enabled(self) -> bool {
690        matches!(self, Self::Run)
691    }
692}
693
694/// What kind of rewrite a `post-rewrite` hook is told about (its first argument).
695#[derive(Debug, Clone, Copy, PartialEq, Eq)]
696pub enum RewriteKind {
697    /// `commit --amend` replaced the tip commit.
698    Amend,
699    /// A rebase or history edit replayed commits.
700    Rebase,
701}
702
703impl RewriteKind {
704    /// Hook argument (`amend` or `rebase`).
705    #[must_use]
706    pub const fn as_str(self) -> &'static str {
707        match self {
708            Self::Amend => "amend",
709            Self::Rebase => "rebase",
710        }
711    }
712}
713
714/// Run an advisory hook whose outcome never fails the surrounding operation.
715///
716/// Returns whether a hook ran (a non-zero exit still counts as having run). A hook that cannot
717/// be started, or invalid `hook.*` configuration, is reported as [`Warning::HookNotRun`] on the
718/// repository's diagnostics sink and returns `false`.
719///
720/// [`Warning::HookNotRun`]: crate::diagnostics::Warning::HookNotRun
721pub(crate) fn run_advisory_hook(
722    repo: &Repository,
723    hook: HookName,
724    args: &[&str],
725    stdin_data: Option<&[u8]>,
726    commit_env: Option<&CommitHookEnv<'_>>,
727) -> bool {
728    let result = match commit_env {
729        Some(env) => run_commit_hook(repo, hook.as_str(), args, stdin_data, env),
730        None => run_hook(repo, hook.as_str(), args, stdin_data),
731    };
732    match result {
733        Ok(result) => result.was_executed(),
734        Err(err) => {
735            let mut detail = err.to_string();
736            let mut cause = std::error::Error::source(&err);
737            while let Some(c) = cause {
738                detail.push_str(": ");
739                detail.push_str(&c.to_string());
740                cause = c.source();
741            }
742            repo.warn(crate::diagnostics::Warning::HookNotRun {
743                hook_name: hook.as_str().to_owned(),
744                detail,
745            });
746            false
747        }
748    }
749}
750
751/// Run `post-checkout <old> <new> <flag>` after `HEAD` moved.
752///
753/// `old` is the commit `HEAD` pointed at before (`None` for an unborn branch, passed as the null
754/// object id). `branch_checkout` is `true` when `HEAD` itself moved to another branch or commit
755/// (switch, rebase) and `false` when only paths were checked out.
756///
757/// Returns whether a hook ran (a non-zero exit still counts). The hook's outcome is never an
758/// error: a hook that cannot be started is reported as
759/// [`Warning::HookNotRun`](crate::diagnostics::Warning::HookNotRun) and returns `false`.
760pub fn run_post_checkout(
761    repo: &Repository,
762    old: Option<ObjectId>,
763    new: ObjectId,
764    branch_checkout: bool,
765) -> bool {
766    let old_hex = old
767        .unwrap_or_else(|| ObjectId::null(repo.odb().hash_algo()))
768        .to_hex();
769    let new_hex = new.to_hex();
770    let flag = if branch_checkout { "1" } else { "0" };
771    run_advisory_hook(
772        repo,
773        HookName::PostCheckout,
774        &[old_hex.as_str(), new_hex.as_str(), flag],
775        None,
776        None,
777    )
778}
779
780/// Run `post-merge <flag>` after a merge or fast-forward updated the work tree; the flag is
781/// `1` for a squash merge and `0` otherwise.
782///
783/// Returns whether a hook ran; failures never become errors (see [`run_post_checkout`]).
784pub fn run_post_merge(repo: &Repository, squash: bool) -> bool {
785    run_advisory_hook(
786        repo,
787        HookName::PostMerge,
788        &[if squash { "1" } else { "0" }],
789        None,
790        None,
791    )
792}
793
794/// Bytes fed to `post-rewrite` on stdin: one `<old> <new>` line per rewritten commit.
795#[must_use]
796pub fn post_rewrite_stdin(pairs: &[(ObjectId, ObjectId)]) -> Vec<u8> {
797    let mut out = Vec::with_capacity(pairs.len() * 82);
798    for (old, new) in pairs {
799        out.extend_from_slice(old.to_hex().as_bytes());
800        out.push(b' ');
801        out.extend_from_slice(new.to_hex().as_bytes());
802        out.push(b'\n');
803    }
804    out
805}
806
807/// Run `post-rewrite <amend|rebase>` with `pairs` (`old new`, oldest first) on stdin.
808///
809/// Nothing runs when `pairs` is empty. Returns whether a hook ran; failures never become errors
810/// (see [`run_post_checkout`]).
811pub fn run_post_rewrite(
812    repo: &Repository,
813    kind: RewriteKind,
814    pairs: &[(ObjectId, ObjectId)],
815) -> bool {
816    if pairs.is_empty() {
817        return false;
818    }
819    let stdin = post_rewrite_stdin(pairs);
820    run_advisory_hook(
821        repo,
822        HookName::PostRewrite,
823        &[kind.as_str()],
824        Some(&stdin),
825        None,
826    )
827}
828
829/// Result of running the hooks for one event.
830#[derive(Debug, Clone, PartialEq, Eq)]
831pub enum HookResult {
832    /// Every hook ran and exited 0.
833    Success {
834        /// What the hooks wrote; empty under [`HookStdio::Inherit`].
835        output: HookOutput,
836    },
837    /// No hook is installed for the event (or it is not executable) — treated as success.
838    NotFound,
839    /// A hook exited non-zero; later hooks for the event did not run.
840    Failed {
841        /// Exit code of the failing hook (1 when it had none, e.g. killed by a signal).
842        status: i32,
843        /// What the hooks wrote up to and including the failing one; empty under
844        /// [`HookStdio::Inherit`].
845        output: HookOutput,
846    },
847}
848
849impl HookResult {
850    /// Returns true if the hook was successful or not found.
851    #[must_use]
852    pub fn is_ok(&self) -> bool {
853        matches!(self, HookResult::Success { .. } | HookResult::NotFound)
854    }
855
856    /// Returns true if the hook existed and ran (regardless of exit code).
857    #[must_use]
858    pub fn was_executed(&self) -> bool {
859        !matches!(self, HookResult::NotFound)
860    }
861
862    /// Exit code of the hook run: `Some(0)` on success, `None` when no hook ran.
863    #[must_use]
864    pub fn status(&self) -> Option<i32> {
865        match self {
866            HookResult::Success { .. } => Some(0),
867            HookResult::NotFound => None,
868            HookResult::Failed { status, .. } => Some(*status),
869        }
870    }
871
872    /// Captured output, or `None` when no hook ran.
873    #[must_use]
874    pub fn output(&self) -> Option<&HookOutput> {
875        match self {
876            HookResult::Success { output } | HookResult::Failed { output, .. } => Some(output),
877            HookResult::NotFound => None,
878        }
879    }
880
881    /// Turn a failed run into [`HookError::Failed`] for `hook_name`; returns whether a hook ran.
882    ///
883    /// # Errors
884    ///
885    /// [`HookError::Failed`] carrying the status and captured output when a hook exited non-zero.
886    pub fn check(self, hook_name: &str) -> Result<bool, HookError> {
887        match self {
888            HookResult::Success { .. } => Ok(true),
889            HookResult::NotFound => Ok(false),
890            HookResult::Failed { status, output } => Err(HookError::Failed {
891                hook_name: hook_name.to_owned(),
892                status,
893                output,
894            }),
895        }
896    }
897}
898
899/// Options for [`run_hook_opts`].
900#[derive(Debug, Clone, Default)]
901pub struct RunHookOptions<'a> {
902    /// Capture hook output or let hooks write to this process's stderr (see [`HookStdio`]).
903    ///
904    /// The `run_*` helpers that take a [`Repository`] use [`Repository::hook_stdio`].
905    pub stdio: HookStdio,
906    /// Under [`HookStdio::Inherit`], send hook stdout to this process's stderr (Git does this
907    /// for every hook except `pre-push`). Captured stdout is always returned separately.
908    pub stdout_to_stderr: bool,
909    /// File path to open and pipe to each hook's stdin (reopened per hook).
910    pub path_to_stdin: Option<&'a Path>,
911    /// In-memory stdin (used when `path_to_stdin` is None).
912    pub stdin_data: Option<&'a [u8]>,
913    /// Extra environment variables for each hook subprocess.
914    pub env_vars: &'a [(&'a str, &'a str)],
915    /// Override the hook process working directory.
916    pub cwd: Option<&'a Path>,
917    /// Commit-style env (`GIT_INDEX_FILE`, `GIT_PREFIX`, author exports, …) merged after `env_vars`.
918    pub commit_env: Option<&'a CommitHookEnv<'a>>,
919}
920
921/// Run all hooks for `hook_name` in Git order; return first non-zero exit or success.
922///
923/// When `repo` is `None`, only configured hooks run (out-of-repo); cwd is the process cwd and
924/// `GIT_DIR` is not set for those hooks.
925///
926/// Hook output is handled per [`RunHookOptions::stdio`]: captured into the returned
927/// [`HookOutput`], or written by the child straight to this process's stderr. The library
928/// never re-emits what a hook (or an injected [`CommandRunner`]) produced.
929///
930/// # Errors
931///
932/// [`HookError::Spawn`] when a hook (or the shell that runs it) cannot be started, carrying the
933/// output earlier hooks for the event already wrote, and [`HookError::Internal`] for invalid
934/// `hook.*` configuration. A hook that runs and exits non-zero is `Ok(HookResult::Failed { .. })`.
935pub fn run_hook_opts(
936    repo: Option<&Repository>,
937    hook_name: &str,
938    args: &[&str],
939    config: &ConfigSet,
940    opts: RunHookOptions<'_>,
941) -> Result<HookResult, HookError> {
942    let seq = match repo {
943        Some(r) => resolve_hook_sequence(r, hook_name, config)?,
944        None => resolve_configured_hooks_only(hook_name, config)?,
945    };
946    if seq.is_empty() {
947        return Ok(HookResult::NotFound);
948    }
949
950    let work_dir: PathBuf = opts.cwd.map_or_else(
951        || match repo {
952            Some(r) => hook_run_dir(r).to_path_buf(),
953            None => PathBuf::from("."),
954        },
955        Path::to_path_buf,
956    );
957    let work_dir = work_dir.as_path();
958    let git_dir_for_configured = repo.map(|r| r.git_dir());
959    let runner: Arc<dyn CommandRunner> = match repo {
960        Some(r) => r.command_runner(),
961        None => system_command_runner(),
962    };
963
964    let mut merged_env: Vec<(String, String)> = opts
965        .env_vars
966        .iter()
967        .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
968        .collect();
969    if let Some(r) = repo {
970        if let Some(ce) = opts.commit_env {
971            merged_env.extend(build_commit_hook_env(r, work_dir, ce));
972        }
973    }
974
975    let mut hook_env = match repo {
976        Some(r) => CommandEnvironment::from_repository_environment(r.environment(), &merged_env),
977        None => hook_command_env(git_dir_for_configured, &merged_env),
978    };
979    if cfg!(windows) {
980        strip_verbatim_prefix_from_path_vars(&mut hook_env.set);
981    }
982
983    let capture = opts.stdio == HookStdio::Capture;
984    let (stdout, stderr) = match opts.stdio {
985        HookStdio::Capture => (CommandStdio::Pipe, CommandStdio::Pipe),
986        HookStdio::Inherit if opts.stdout_to_stderr => {
987            (CommandStdio::ParentStderr, CommandStdio::Inherit)
988        }
989        HookStdio::Inherit => (CommandStdio::Inherit, CommandStdio::Inherit),
990    };
991    let mut output = HookOutput::default();
992
993    for h in &seq {
994        let (stdin_piped, stdin_file) = match opts.path_to_stdin {
995            Some(p) => (true, Some(p.to_path_buf())),
996            None => (opts.stdin_data.is_some(), None),
997        };
998
999        let ctx = HookSpawnContext {
1000            runner: runner.as_ref(),
1001            cwd: work_dir,
1002            env: &hook_env,
1003            stdin_piped,
1004            stdout,
1005            stderr,
1006        };
1007        let spawned = match h {
1008            ResolvedHook::Traditional { path, argv0 } => {
1009                let Some(r) = repo else {
1010                    continue;
1011                };
1012                let effective_argv0 = path
1013                    .parent()
1014                    .map(|hooks_dir| hook_argv0(r, hooks_dir, hook_name, work_dir))
1015                    .unwrap_or_else(|| argv0.clone());
1016                ctx.spawn_traditional(hook_name, path, &effective_argv0, args)
1017            }
1018            ResolvedHook::Configured { command } => ctx.spawn_configured(hook_name, command, args),
1019        };
1020        let mut child = match spawned {
1021            Ok(child) => child,
1022            Err(HookError::Spawn {
1023                hook_name,
1024                path,
1025                source,
1026                ..
1027            }) => {
1028                return Err(HookError::Spawn {
1029                    hook_name,
1030                    path,
1031                    source,
1032                    output,
1033                })
1034            }
1035            Err(other) => return Err(other),
1036        };
1037
1038        if let Some(ref path) = stdin_file {
1039            let file = match fs::File::open(path) {
1040                Ok(f) => f,
1041                Err(_e) => {
1042                    return Ok(HookResult::Failed { status: 1, output });
1043                }
1044            };
1045            if let Some(stdin) = child.stdin_mut() {
1046                let mut file = file;
1047                let _ = std::io::copy(&mut file, stdin);
1048            }
1049        } else if let Some(data) = opts.stdin_data {
1050            if let Some(stdin) = child.stdin_mut() {
1051                let _ = stdin.write_all(data);
1052            }
1053        }
1054        if stdin_piped {
1055            child.close_stdin();
1056        }
1057
1058        let waited = if capture {
1059            child.wait_with_output().map(|o| {
1060                output.stdout.extend_from_slice(&o.stdout);
1061                output.stderr.extend_from_slice(&o.stderr);
1062                o.status
1063            })
1064        } else {
1065            child.wait()
1066        };
1067        let exit = match waited {
1068            Ok(exit) => exit,
1069            Err(_) => return Ok(HookResult::Failed { status: 1, output }),
1070        };
1071
1072        if !exit.success {
1073            if let Some(r) = repo {
1074                let _ = r.reload_config();
1075            }
1076            return Ok(HookResult::Failed {
1077                status: exit.code.unwrap_or(1),
1078                output,
1079            });
1080        }
1081    }
1082
1083    if let Some(r) = repo {
1084        let _ = r.reload_config();
1085    }
1086
1087    Ok(HookResult::Success { output })
1088}
1089
1090/// Like [`run_commit_hook`] but a hook that exits non-zero is an error.
1091///
1092/// # Errors
1093///
1094/// [`HookError::Failed`] with the hook's exit status and captured output, or any error from
1095/// [`run_commit_hook`].
1096pub fn run_commit_hook_checked(
1097    repo: &Repository,
1098    hook_name: &str,
1099    args: &[&str],
1100    stdin_data: Option<&[u8]>,
1101    commit_env: &CommitHookEnv<'_>,
1102) -> Result<(), HookError> {
1103    run_commit_hook(repo, hook_name, args, stdin_data, commit_env)?
1104        .check(hook_name)
1105        .map(drop)
1106}
1107
1108/// Run commit-style hooks with `GIT_INDEX_FILE`, `GIT_PREFIX`, and related env (Git `run_commit_hook`).
1109///
1110/// Output follows [`Repository::hook_stdio`].
1111///
1112/// # Errors
1113///
1114/// [`HookError::Spawn`] when a hook cannot be started, [`HookError::Internal`] when the
1115/// repository config cannot be loaded or `hook.*` entries are invalid.
1116pub fn run_commit_hook(
1117    repo: &Repository,
1118    hook_name: &str,
1119    args: &[&str],
1120    stdin_data: Option<&[u8]>,
1121    commit_env: &CommitHookEnv<'_>,
1122) -> Result<HookResult, HookError> {
1123    let config = repo
1124        .config()
1125        .map_err(|e| HookError::Internal(e.to_string()))?
1126        .as_ref()
1127        .clone();
1128    run_hook_opts(
1129        Some(repo),
1130        hook_name,
1131        args,
1132        &config,
1133        RunHookOptions {
1134            stdin_data,
1135            commit_env: Some(commit_env),
1136            ..repo_hook_options(repo, hook_name)
1137        },
1138    )
1139}
1140
1141/// Default [`RunHookOptions`] for `hook_name` on `repo`: [`Repository::hook_stdio`], and stdout
1142/// sent to stderr for every hook except `pre-push`, whose stdout is part of its protocol.
1143fn repo_hook_options<'a>(repo: &Repository, hook_name: &str) -> RunHookOptions<'a> {
1144    RunHookOptions {
1145        stdio: repo.hook_stdio(),
1146        stdout_to_stderr: hook_name != "pre-push",
1147        ..RunHookOptions::default()
1148    }
1149}
1150
1151/// Run a hook by name with the given arguments (Git-compatible multihooks).
1152///
1153/// Output follows [`Repository::hook_stdio`]; when inherited, `pre-push` keeps stdout
1154/// separate and other hooks send stdout to stderr.
1155///
1156/// # Errors
1157///
1158/// [`HookError::Spawn`] when a hook cannot be started, [`HookError::Internal`] when the
1159/// repository config cannot be loaded or `hook.*` entries are invalid.
1160pub fn run_hook(
1161    repo: &Repository,
1162    hook_name: &str,
1163    args: &[&str],
1164    stdin_data: Option<&[u8]>,
1165) -> Result<HookResult, HookError> {
1166    let config = repo
1167        .config()
1168        .map_err(|e| HookError::Internal(e.to_string()))?;
1169    run_hook_opts(
1170        Some(repo),
1171        hook_name,
1172        args,
1173        config.as_ref(),
1174        RunHookOptions {
1175            stdin_data,
1176            ..repo_hook_options(repo, hook_name)
1177        },
1178    )
1179}
1180
1181/// Run a server-side hook (`pre-receive`, `update`, …) with cwd = `GIT_DIR`.
1182///
1183/// The hook sees `GIT_DIR=.`, as Git's receive-pack hooks do, so scripts that run
1184/// `git` commands address the repository they were started in.
1185///
1186/// Output is always captured, whatever [`Repository::hook_stdio`] says, because the server
1187/// relays it to the client over the sideband. A hook that cannot be resolved or started is
1188/// reported as `Failed { status: 1 }` so the push is declined.
1189pub fn run_hook_in_git_dir(
1190    repo: &Repository,
1191    hook_name: &str,
1192    args: &[&str],
1193    stdin_data: Option<&[u8]>,
1194    env_vars: &[(&str, &str)],
1195) -> HookResult {
1196    let declined = || HookResult::Failed {
1197        status: 1,
1198        output: HookOutput::default(),
1199    };
1200    let env_vars: Vec<(&str, &str)> = std::iter::once(("GIT_DIR", "."))
1201        .chain(env_vars.iter().copied())
1202        .collect();
1203    let Some(config) = repo.config().ok() else {
1204        return declined();
1205    };
1206    run_hook_opts(
1207        Some(repo),
1208        hook_name,
1209        args,
1210        config.as_ref(),
1211        RunHookOptions {
1212            stdio: HookStdio::Capture,
1213            stdout_to_stderr: true,
1214            stdin_data,
1215            env_vars: &env_vars,
1216            cwd: Some(repo.git_dir()),
1217            ..RunHookOptions::default()
1218        },
1219    )
1220    .unwrap_or_else(|_| declined())
1221}
1222
1223/// One ref update a push is about to make, as reported to the `pre-push` hook.
1224#[derive(Clone, Debug, PartialEq, Eq)]
1225pub struct PrePushRef {
1226    /// Local source as named by the push (`refs/heads/main`, `HEAD`, …). `None` when the
1227    /// source was a bare object id, or for a deletion.
1228    pub local_ref: Option<String>,
1229    /// Object the remote ref will point at; `None` for a deletion.
1230    pub local_oid: Option<ObjectId>,
1231    /// Destination ref on the remote (`refs/heads/main`).
1232    pub remote_ref: String,
1233    /// Current value of [`Self::remote_ref`] on the remote; `None` when it does not exist.
1234    pub remote_oid: Option<ObjectId>,
1235}
1236
1237impl PrePushRef {
1238    /// The hook's stdin line: `<local ref> SP <local oid> SP <remote ref> SP <remote oid> LF`.
1239    ///
1240    /// A missing object id is written as the all-zero id of width `algo`; a deletion's local
1241    /// ref is `(delete)` and a bare object id source repeats the id.
1242    fn stdin_line(&self, algo: crate::objects::HashAlgo) -> String {
1243        let zero = "0".repeat(algo.hex_len());
1244        let hex = |oid: Option<ObjectId>| oid.map_or_else(|| zero.clone(), |o| o.to_hex());
1245        let local_ref = match (&self.local_ref, self.local_oid) {
1246            (Some(name), _) => name.clone(),
1247            (None, Some(oid)) => oid.to_hex(),
1248            (None, None) => "(delete)".to_owned(),
1249        };
1250        format!(
1251            "{local_ref} {} {} {}\n",
1252            hex(self.local_oid),
1253            self.remote_ref,
1254            hex(self.remote_oid)
1255        )
1256    }
1257}
1258
1259/// Run the client-side `pre-push` hook before a push sends anything.
1260///
1261/// The hook receives `remote` (the remote's name, or its URL for an unnamed remote) and `url`
1262/// as arguments, and one [`PrePushRef`] line per update on stdin. It runs even when `refs` is
1263/// empty. Output follows [`Repository::hook_stdio`]; under [`HookStdio::Inherit`] the hook's
1264/// stdout goes to stderr with its stderr, so a caller's own stdout (for example JSON output)
1265/// stays clean.
1266///
1267/// Returns the hook's captured output (empty under [`HookStdio::Inherit`] or when no hook is
1268/// installed).
1269///
1270/// # Errors
1271///
1272/// [`HookError::Failed`] when the hook exits non-zero (the push must not proceed),
1273/// [`HookError::Spawn`] when it cannot be started, and [`HookError::Internal`] when the
1274/// configuration cannot be loaded or `hook.*` entries are invalid.
1275pub fn run_pre_push_hook(
1276    repo: &Repository,
1277    remote: &str,
1278    url: &str,
1279    refs: &[PrePushRef],
1280) -> Result<HookOutput, HookError> {
1281    let config = repo
1282        .config()
1283        .map_err(|e| HookError::Internal(e.to_string()))?;
1284    let algo = repo.odb().hash_algo();
1285    let stdin: String = refs.iter().map(|r| r.stdin_line(algo)).collect();
1286    let result = run_hook_opts(
1287        Some(repo),
1288        "pre-push",
1289        &[remote, url],
1290        config.as_ref(),
1291        RunHookOptions {
1292            stdout_to_stderr: true,
1293            stdin_data: Some(stdin.as_bytes()),
1294            ..repo_hook_options(repo, "pre-push")
1295        },
1296    )?;
1297    let output = match &result {
1298        HookResult::Success { output } => output.clone(),
1299        HookResult::NotFound | HookResult::Failed { .. } => HookOutput::default(),
1300    };
1301    result.check("pre-push")?;
1302    Ok(output)
1303}
1304
1305/// `reference-transaction` hook with phase `committed` after updating `HEAD` and (on a branch)
1306/// the branch ref to `new_oid`.
1307///
1308/// `old_head_commit` is the commit OID `HEAD` pointed at before the update, or `None` for an
1309/// unborn branch (null old OID in hook stdin).
1310///
1311/// # Errors
1312///
1313/// Same as [`run_hook`].
1314pub fn run_reference_transaction_committed_for_head_update(
1315    repo: &Repository,
1316    head: &HeadState,
1317    old_head_commit: Option<ObjectId>,
1318    new_oid: ObjectId,
1319) -> Result<HookResult, HookError> {
1320    let zero = ObjectId::zero();
1321    let old_oid = old_head_commit.unwrap_or(zero);
1322    let old_hex = if old_oid == zero {
1323        "0000000000000000000000000000000000000000".to_owned()
1324    } else {
1325        old_oid.to_hex()
1326    };
1327    let new_hex = new_oid.to_hex();
1328    let mut stdin = String::new();
1329    match head {
1330        HeadState::Branch { refname, .. } => {
1331            // Git sorts ref updates lexicographically; `HEAD` precedes `refs/...`.
1332            stdin.push_str(&format!("{old_hex} {new_hex} HEAD\n"));
1333            stdin.push_str(&format!("{old_hex} {new_hex} {refname}\n"));
1334        }
1335        _ => {
1336            stdin.push_str(&format!("{old_hex} {new_hex} HEAD\n"));
1337        }
1338    }
1339    run_hook(
1340        repo,
1341        "reference-transaction",
1342        &["committed"],
1343        Some(stdin.as_bytes()),
1344    )
1345}
1346
1347#[cfg(test)]
1348mod tests {
1349    use super::*;
1350
1351    fn touch(path: &Path) {
1352        fs::create_dir_all(path.parent().expect("parent")).expect("mkdir");
1353        fs::write(path, b"").expect("write");
1354    }
1355
1356    fn exe(name: &str) -> String {
1357        format!("{name}{}", std::env::consts::EXE_SUFFIX)
1358    }
1359
1360    #[test]
1361    fn hook_result_reports_status_output_and_checks() {
1362        let output = HookOutput {
1363            stdout: b"out".to_vec(),
1364            stderr: b"err".to_vec(),
1365        };
1366        assert!(!output.is_empty());
1367        assert!(HookOutput::default().is_empty());
1368        assert_eq!(HookError::Internal("bad".into()).output(), None);
1369        assert_eq!(
1370            format!("{output:?}"),
1371            r#"HookOutput { stdout: "out", stderr: "err" }"#
1372        );
1373
1374        let success = HookResult::Success {
1375            output: output.clone(),
1376        };
1377        assert!(success.is_ok() && success.was_executed());
1378        assert_eq!(success.status(), Some(0));
1379        assert_eq!(success.output(), Some(&output));
1380        assert!(success.check("pre-commit").expect("success"));
1381
1382        let missing = HookResult::NotFound;
1383        assert!(missing.is_ok() && !missing.was_executed());
1384        assert_eq!((missing.status(), missing.output()), (None, None));
1385        assert!(!missing.check("pre-commit").expect("not found"));
1386
1387        let failed = HookResult::Failed {
1388            status: 3,
1389            output: output.clone(),
1390        };
1391        assert!(!failed.is_ok() && failed.was_executed());
1392        assert_eq!(failed.status(), Some(3));
1393        match failed.check("commit-msg") {
1394            Err(err @ HookError::Failed { .. }) => {
1395                assert_eq!(err.to_string(), "hook commit-msg failed with exit status 3");
1396                let HookError::Failed {
1397                    hook_name,
1398                    status,
1399                    output: got,
1400                } = err
1401                else {
1402                    unreachable!()
1403                };
1404                assert_eq!((hook_name.as_str(), status), ("commit-msg", 3));
1405                assert_eq!(got, output);
1406            }
1407            other => panic!("expected HookError::Failed, got {other:?}"),
1408        }
1409    }
1410
1411    #[test]
1412    fn post_rewrite_stdin_is_one_old_new_line_per_pair() {
1413        let a = ObjectId::from_hex("1111111111111111111111111111111111111111").expect("a");
1414        let b = ObjectId::from_hex("2222222222222222222222222222222222222222").expect("b");
1415        assert_eq!(post_rewrite_stdin(&[]), b"");
1416        assert_eq!(
1417            post_rewrite_stdin(&[(a, b), (b, a)]),
1418            format!(
1419                "{} {}\n{} {}\n",
1420                a.to_hex(),
1421                b.to_hex(),
1422                b.to_hex(),
1423                a.to_hex()
1424            )
1425            .into_bytes()
1426        );
1427    }
1428
1429    #[test]
1430    fn post_operation_names_and_flags() {
1431        assert_eq!(HookName::PostCheckout.as_str(), "post-checkout");
1432        assert_eq!(HookName::PostMerge.as_str(), "post-merge");
1433        assert_eq!(HookName::PostRewrite.as_str(), "post-rewrite");
1434        assert_eq!(RewriteKind::Amend.as_str(), "amend");
1435        assert_eq!(RewriteKind::Rebase.as_str(), "rebase");
1436        assert!(PostOperationHooks::default().enabled());
1437        assert!(!PostOperationHooks::Skip.enabled());
1438    }
1439
1440    #[test]
1441    fn shebang_interpreter_takes_the_interpreter_basename() {
1442        assert_eq!(
1443            shebang_interpreter(b"#!/bin/sh\nexit 0\n").as_deref(),
1444            Some("sh")
1445        );
1446        assert_eq!(
1447            shebang_interpreter(b"#!/bin/bash -e\n").as_deref(),
1448            Some("bash")
1449        );
1450        assert_eq!(shebang_interpreter(b"#! /bin/sh\n").as_deref(), Some("sh"));
1451        assert_eq!(
1452            shebang_interpreter(b"#!/bin/sh\r\nexit 0\r\n").as_deref(),
1453            Some("sh")
1454        );
1455        assert_eq!(shebang_interpreter(b"#!/bin/sh").as_deref(), Some("sh"));
1456        assert_eq!(
1457            shebang_interpreter(br"#!C:\tools\perl.exe").as_deref(),
1458            Some("perl.exe")
1459        );
1460    }
1461
1462    #[test]
1463    fn shebang_interpreter_looks_through_env() {
1464        assert_eq!(
1465            shebang_interpreter(b"#!/usr/bin/env bash\n").as_deref(),
1466            Some("bash")
1467        );
1468        assert_eq!(
1469            shebang_interpreter(b"#!/usr/bin/env -S python3 -u\n").as_deref(),
1470            Some("python3")
1471        );
1472        assert_eq!(shebang_interpreter(b"#!/usr/bin/env\n"), None);
1473    }
1474
1475    #[test]
1476    fn shebang_interpreter_rejects_non_scripts() {
1477        assert_eq!(shebang_interpreter(b"exit 0\n"), None);
1478        assert_eq!(shebang_interpreter(b""), None);
1479        assert_eq!(shebang_interpreter(b"#!\n"), None);
1480        assert_eq!(shebang_interpreter(b"#!/\n"), None);
1481        assert_eq!(shebang_interpreter(b"\x7fELF\x02\x01"), None);
1482    }
1483
1484    #[cfg(unix)]
1485    #[test]
1486    fn hook_shell_is_bin_sh_on_unix() {
1487        let mut env = CommandEnvironment::default();
1488        env.set.push(("PATH".into(), "/nonexistent".into()));
1489        assert_eq!(hook_shell(&env), ScriptInterpreter::bare("/bin/sh"));
1490    }
1491
1492    #[test]
1493    fn interpreter_from_git_tree_gets_its_tool_dirs_on_path() {
1494        let tmp = tempfile::tempdir().expect("tempdir");
1495        let root = tmp.path().join("Git");
1496        touch(&root.join("cmd").join(exe("git")));
1497        touch(&root.join("mingw64").join("bin").join(exe("git")));
1498        touch(&root.join("usr").join("bin").join(exe("sh")));
1499        let mut env = CommandEnvironment::default();
1500        env.set
1501            .push(("PATH".into(), root.join("cmd").into_os_string()));
1502        let runner = crate::command_runner::RecordingRunner::always_success();
1503        let ctx = HookSpawnContext {
1504            runner: runner.as_ref(),
1505            cwd: tmp.path(),
1506            env: &env,
1507            stdin_piped: false,
1508            stdout: CommandStdio::Inherit,
1509            stderr: CommandStdio::Inherit,
1510        };
1511        let dash_c = || ShellInvocation::DashC {
1512            script: "true".into(),
1513            argv0: Some("hook".into()),
1514            args: Vec::new(),
1515        };
1516        let interpreter = crate::command_runner::resolve_script_interpreter(
1517            "sh",
1518            Some(root.join("cmd").as_os_str()),
1519        );
1520        let spec = ctx.interpreter_spec(interpreter, dash_c(), &[]);
1521        assert_eq!(spec.program, root.join("usr").join("bin").join(exe("sh")));
1522        let expected = std::env::join_paths([
1523            root.join("mingw64").join("bin"),
1524            root.join("usr").join("bin"),
1525            root.join("cmd"),
1526        ])
1527        .expect("join");
1528        assert_eq!(
1529            crate::command_runner::child_path_var(&spec.env),
1530            Some(expected)
1531        );
1532
1533        let bare = ctx.interpreter_spec(ScriptInterpreter::bare("/bin/sh"), dash_c(), &[]);
1534        assert_eq!(
1535            crate::command_runner::child_path_var(&bare.env),
1536            Some(root.join("cmd").into_os_string()),
1537            "interpreters outside a Git tree keep PATH as is"
1538        );
1539    }
1540
1541    #[test]
1542    fn verbatim_prefixes_are_stripped_from_path_vars() {
1543        let mut set: Vec<(OsString, OsString)> = vec![
1544            ("GIT_INDEX_FILE".into(), r"\\?\C:\repo\.git\index".into()),
1545            ("GIT_DIR".into(), r"\\?\UNC\server\share\repo\.git".into()),
1546            ("GIT_WORK_TREE".into(), r"C:\repo".into()),
1547            (
1548                "GIT_OBJECT_DIRECTORY".into(),
1549                r"\\?\C:\repo\.git\objects".into(),
1550            ),
1551            ("GIT_PREFIX".into(), r"\\?\kept".into()),
1552        ];
1553        strip_verbatim_prefix_from_path_vars(&mut set);
1554        let value = |key: &str| {
1555            set.iter()
1556                .find(|(k, _)| k == key)
1557                .map(|(_, v)| v.clone())
1558                .expect("present")
1559        };
1560        assert_eq!(
1561            value("GIT_INDEX_FILE"),
1562            OsString::from(r"C:\repo\.git\index")
1563        );
1564        assert_eq!(
1565            value("GIT_DIR"),
1566            OsString::from(r"\\server\share\repo\.git")
1567        );
1568        assert_eq!(value("GIT_WORK_TREE"), OsString::from(r"C:\repo"));
1569        assert_eq!(
1570            value("GIT_OBJECT_DIRECTORY"),
1571            OsString::from(r"C:\repo\.git\objects")
1572        );
1573        assert_eq!(value("GIT_PREFIX"), OsString::from(r"\\?\kept"));
1574    }
1575
1576    #[test]
1577    fn not_an_executable_matches_only_the_platform_code() {
1578        #[cfg(unix)]
1579        assert!(is_not_an_executable(&std::io::Error::from_raw_os_error(8)));
1580        #[cfg(windows)]
1581        assert!(is_not_an_executable(&std::io::Error::from_raw_os_error(
1582            193
1583        )));
1584        assert!(!is_not_an_executable(&std::io::Error::from(
1585            std::io::ErrorKind::NotFound
1586        )));
1587    }
1588}