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