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, ConcurrentStdinFeed, RunningCommand,
9    ScriptInterpreter, ShellInvocation,
10};
11use crate::config::{parse_path, ConfigSet};
12use crate::environment::Environment;
13use crate::git_path::path_without_verbatim_prefix;
14use crate::git_path::CanonicalizeExt;
15use crate::objects::HashAlgo;
16use crate::objects::ObjectId;
17use crate::repo::Repository;
18use std::collections::{HashMap, HashSet, VecDeque};
19use std::ffi::OsString;
20use std::fs;
21#[cfg(unix)]
22use std::os::unix::fs::PermissionsExt;
23use std::path::{Path, PathBuf};
24use std::sync::Arc;
25use std::time::Duration;
26use thiserror::Error;
27
28use crate::progress::{Cancel, NeverCancel};
29
30/// Whether `err` means the OS would not execute the file as a program, so it must be run
31/// through a shell (`ENOEXEC` on Unix, `ERROR_BAD_EXE_FORMAT` on Windows).
32fn is_not_an_executable(err: &std::io::Error) -> bool {
33    #[cfg(unix)]
34    const NOT_EXECUTABLE: i32 = 8;
35    #[cfg(windows)]
36    const NOT_EXECUTABLE: i32 = 193;
37    #[cfg(not(any(unix, windows)))]
38    const NOT_EXECUTABLE: i32 = -1;
39    err.raw_os_error() == Some(NOT_EXECUTABLE)
40}
41
42/// Where hook subprocesses send their stdout and stderr.
43///
44/// The library never writes hook output itself: it either hands the bytes back to the caller
45/// or lets the child write straight to this process's standard streams.
46#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
47pub enum HookStdio {
48    /// Pipe both streams and return them in [`HookResult`] and [`HookError::Failed`].
49    ///
50    /// The default, so an embedder (TUI, GUI, server) can show the output in its own way.
51    #[default]
52    Capture,
53    /// The child writes directly to this process's stderr, as Git does: live output, nothing
54    /// buffered. A hook's stdout goes to stderr too unless
55    /// [`RunHookOptions::stdout_to_stderr`] is false. Returned [`HookOutput`]s are empty.
56    Inherit,
57}
58
59/// Bytes the hooks of one event wrote while [`HookStdio::Capture`] was in effect.
60///
61/// When several hooks run for one event (configured `hook.<name>.*` entries, then the hooks
62/// directory script), their output is appended in run order.
63#[derive(Clone, Default, PartialEq, Eq)]
64pub struct HookOutput {
65    /// Everything the hooks wrote to stdout.
66    pub stdout: Vec<u8>,
67    /// Everything the hooks wrote to stderr.
68    pub stderr: Vec<u8>,
69}
70
71impl std::fmt::Debug for HookOutput {
72    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73        f.debug_struct("HookOutput")
74            .field("stdout", &String::from_utf8_lossy(&self.stdout))
75            .field("stderr", &String::from_utf8_lossy(&self.stderr))
76            .finish()
77    }
78}
79
80impl HookOutput {
81    /// Whether no output was captured on either stream.
82    #[must_use]
83    pub fn is_empty(&self) -> bool {
84        self.stdout.is_empty() && self.stderr.is_empty()
85    }
86}
87
88/// Hook subprocess failure (non-zero exit or spawn error).
89#[derive(Debug, Error)]
90pub enum HookError {
91    /// A hook ran and returned a non-zero exit status.
92    ///
93    /// The message is one line; the hook's own output is in `output`, never in `Display`.
94    #[error("hook {hook_name} failed with exit status {status}")]
95    Failed {
96        /// Basename of the hook script that failed.
97        hook_name: String,
98        /// Process exit code from the hook subprocess.
99        status: i32,
100        /// What the hooks wrote; empty under [`HookStdio::Inherit`].
101        output: HookOutput,
102    },
103    /// The hook (or the shell that runs it) could not be started.
104    #[error("failed to run hook {hook_name} ({})", path.display())]
105    Spawn {
106        /// Hook event name (`pre-commit`, `commit-msg`, …).
107        hook_name: String,
108        /// Program that failed to start: the hook script, or the interpreter chosen for it.
109        path: PathBuf,
110        /// Error returned by the [`CommandRunner`] when spawning.
111        #[source]
112        source: std::io::Error,
113        /// What earlier hooks for the same event wrote before this one could not start
114        /// (configured `hook.<name>.*` entries run before the hooks directory script); empty
115        /// under [`HookStdio::Inherit`].
116        output: HookOutput,
117    },
118    /// Configuration or internal error while resolving hooks.
119    #[error("{0}")]
120    Internal(String),
121    /// The hook subprocess was stopped because the operation was cancelled.
122    #[error("hook run cancelled")]
123    Cancelled,
124}
125
126impl HookError {
127    /// Hook output captured before the failure, or `None` for [`HookError::Internal`].
128    #[must_use]
129    pub fn output(&self) -> Option<&HookOutput> {
130        match self {
131            HookError::Failed { output, .. } | HookError::Spawn { output, .. } => Some(output),
132            HookError::Internal(_) | HookError::Cancelled => None,
133        }
134    }
135}
136
137impl From<String> for HookError {
138    fn from(value: String) -> Self {
139        Self::Internal(value)
140    }
141}
142
143fn hook_command_env(git_dir: Option<&Path>, extra_env: &[(String, String)]) -> CommandEnvironment {
144    let mut env = CommandEnvironment::inherit_process_only();
145    if let Some(gd) = git_dir {
146        env.set
147            .push((OsString::from("GIT_DIR"), gd.as_os_str().to_os_string()));
148    }
149    for (k, v) in extra_env {
150        env.set
151            .push((OsString::from(k.as_str()), OsString::from(v.as_str())));
152    }
153    env
154}
155
156/// Environment for commit-style hooks (`GIT_INDEX_FILE`, `GIT_EDITOR`, `GIT_PREFIX`, and extra pairs).
157#[derive(Debug, Clone, Default)]
158pub struct CommitHookEnv<'a> {
159    /// Absolute or cwd-relative index path passed as `GIT_INDEX_FILE`.
160    pub index_file: Option<&'a Path>,
161    /// When set, overrides `GIT_EDITOR` for the hook subprocess (e.g. `":"` when no editor is used).
162    pub git_editor: Option<&'a str>,
163    /// When set, used as `GIT_PREFIX`; when unset, derived from the current directory and work tree.
164    pub git_prefix: Option<&'a str>,
165    /// Additional `KEY=value` pairs for the hook subprocess.
166    pub extra_env: &'a [(&'a str, &'a str)],
167}
168
169fn absolute_index_path(index_file: &Path, cwd: &Path) -> PathBuf {
170    if index_file.is_absolute() {
171        index_file.to_path_buf()
172    } else {
173        cwd.join(index_file)
174    }
175}
176
177/// `GIT_PREFIX` for the invoking cwd relative to the work tree (Git sets this from the user's
178/// `pwd`, not from the hook subprocess cwd, which is usually the work tree root).
179fn git_prefix_for_invocation(repo: &Repository, invocation_cwd: &Path) -> String {
180    let Some(wt) = repo.work_tree() else {
181        return String::new();
182    };
183    if invocation_cwd == repo.git_dir() {
184        return String::new();
185    }
186    let wt_canon = wt
187        .canonicalize_simplified()
188        .unwrap_or_else(|_| wt.to_path_buf());
189    let wd_canon = invocation_cwd
190        .canonicalize_simplified()
191        .unwrap_or_else(|_| invocation_cwd.to_path_buf());
192    let rel = wd_canon.strip_prefix(&wt_canon).ok();
193    let Some(rel) = rel else {
194        return String::new();
195    };
196    let Some(s) = rel.to_str() else {
197        return String::new();
198    };
199    if s.is_empty() {
200        return String::new();
201    }
202    let mut out = s.replace('\\', "/");
203    if !out.ends_with('/') {
204        out.push('/');
205    }
206    out
207}
208
209fn build_commit_hook_env(
210    repo: &Repository,
211    _work_dir: &Path,
212    opts: &CommitHookEnv<'_>,
213) -> Vec<(String, String)> {
214    let mut env: Vec<(String, String)> = Vec::new();
215    if let Some(p) = opts.index_file {
216        env.push((
217            "GIT_INDEX_FILE".to_string(),
218            absolute_index_path(p, &repo.environment().cwd)
219                .to_string_lossy()
220                .into_owned(),
221        ));
222    }
223    let invocation_cwd = repo.environment().discovery_cwd();
224    let prefix = opts
225        .git_prefix
226        .map(|s| s.to_string())
227        .unwrap_or_else(|| git_prefix_for_invocation(repo, &invocation_cwd));
228    env.push(("GIT_PREFIX".to_string(), prefix));
229    if let Some(ed) = opts.git_editor {
230        env.push(("GIT_EDITOR".to_string(), ed.to_string()));
231    }
232    for (k, v) in opts.extra_env {
233        env.push(((*k).to_string(), (*v).to_string()));
234    }
235    env
236}
237
238/// Git `git_parse_maybe_bool`: `Some(true/false)` or `None` if unrecognized.
239fn parse_maybe_bool(value: &str) -> Option<bool> {
240    let v = value.trim().to_ascii_lowercase();
241    match v.as_str() {
242        "true" | "yes" | "on" | "1" => Some(true),
243        "false" | "no" | "off" | "0" => Some(false),
244        _ => None,
245    }
246}
247
248/// Split `hook.<subsection>.<var>` into `(subsection, var)`.
249fn parse_hook_config_key(key: &str) -> Option<(&str, &str)> {
250    let rest = key.strip_prefix("hook.")?;
251    let (subsection, var) = rest.rsplit_once('.')?;
252    if subsection.is_empty() || var.is_empty() {
253        return None;
254    }
255    Some((subsection, var))
256}
257
258/// Parsed `hook.*` configuration in one pass (Git `hook_config_lookup_all` semantics).
259#[derive(Debug, Default)]
260struct HookConfigTables {
261    /// Friendly name → last-seen command string.
262    commands: HashMap<String, String>,
263    /// Event name → ordered friendly names (last duplicate wins position).
264    event_hooks: HashMap<String, VecDeque<String>>,
265    disabled: HashSet<String>,
266}
267
268impl HookConfigTables {
269    fn apply_entry(&mut self, key: &str, value: Option<&str>) {
270        let Some((hook_name, subkey)) = parse_hook_config_key(key) else {
271            return;
272        };
273        let Some(value) = value else {
274            return;
275        };
276        let hook_name = hook_name.to_string();
277
278        match subkey {
279            "event" => {
280                if value.is_empty() {
281                    for hooks in self.event_hooks.values_mut() {
282                        hooks.retain(|n| n != &hook_name);
283                    }
284                } else {
285                    let event = value.to_string();
286                    let hooks = self.event_hooks.entry(event).or_default();
287                    hooks.retain(|n| n != &hook_name);
288                    hooks.push_back(hook_name);
289                }
290            }
291            "command" => {
292                self.commands.insert(hook_name, value.to_string());
293            }
294            "enabled" => match parse_maybe_bool(value) {
295                Some(false) => {
296                    self.disabled.insert(hook_name);
297                }
298                Some(true) => {
299                    self.disabled.remove(&hook_name);
300                }
301                None => {}
302            },
303            _ => {}
304        }
305    }
306
307    fn from_config(config: &ConfigSet) -> Self {
308        let mut t = Self::default();
309        for e in config.entries() {
310            t.apply_entry(&e.key, e.value.as_deref());
311        }
312        t
313    }
314
315    /// Configured hooks for `event`, in order, excluding disabled entries without a command.
316    ///
317    /// Returns `Err` if a non-disabled hook lacks `hook.<name>.command` (Git dies here).
318    fn hooks_for_event(&self, event: &str) -> Result<Vec<(String, String)>, String> {
319        let Some(names) = self.event_hooks.get(event) else {
320            return Ok(Vec::new());
321        };
322        let mut out = Vec::new();
323        for name in names {
324            if self.disabled.contains(name) {
325                continue;
326            }
327            let Some(cmd) = self.commands.get(name) else {
328                return Err(format!(
329                    "'hook.{name}.command' must be configured or 'hook.{name}.event' must be removed; aborting."
330                ));
331            };
332            out.push((name.clone(), cmd.clone()));
333        }
334        Ok(out)
335    }
336}
337
338/// One hook invocation resolved for an event.
339#[derive(Debug)]
340enum ResolvedHook {
341    Configured { command: String },
342    Traditional { path: PathBuf, argv0: PathBuf },
343}
344
345/// Resolve the hooks directory from config or fall back to `$GIT_DIR/hooks`.
346///
347/// A relative `core.hooksPath` is taken relative to the directory hooks run in: the top of
348/// the work tree, or `$GIT_DIR` in a bare repository. It does not depend on the directory
349/// the repository was opened from.
350///
351/// Never carries a Windows `\\?\` prefix, so the path can be shown to users and handed to
352/// hook interpreters such as Git for Windows' `sh`.
353pub fn resolve_hooks_dir(repo: &Repository) -> PathBuf {
354    resolve_hooks_dir_for_config(repo, repo.config().ok().as_deref())
355}
356
357fn resolve_hooks_dir_for_config(repo: &Repository, config: Option<&ConfigSet>) -> PathBuf {
358    let dir = match config.and_then(|cfg| cfg.get("core.hooksPath")) {
359        Some(hooks_path) => {
360            let p = PathBuf::from(parse_path(repo.environment(), &hooks_path));
361            if p.is_absolute() {
362                p
363            } else {
364                hook_run_dir(repo).join(p)
365            }
366        }
367        None => crate::repo::common_git_dir_for_config(repo.git_dir()).join("hooks"),
368    };
369    path_without_verbatim_prefix(&dir).into_owned()
370}
371
372/// Directory hooks run in by default: the work tree top, or `$GIT_DIR` when bare.
373fn hook_run_dir(repo: &Repository) -> &Path {
374    repo.work_tree().unwrap_or(repo.git_dir())
375}
376
377fn hook_argv0(repo: &Repository, hooks_dir: &Path, hook_name: &str, cwd: &Path) -> PathBuf {
378    let default_hooks_dir = repo.git_dir().join("hooks");
379    if same_dir(hooks_dir, &default_hooks_dir) {
380        if cwd == repo.git_dir() {
381            return PathBuf::from("hooks").join(hook_name);
382        }
383        if let Some(work_tree) = repo.work_tree() {
384            // Only relativise to `.git/hooks/<name>` when the git dir is literally
385            // `<work_tree>/.git`. When `GIT_DIR` points elsewhere (e.g.
386            // `GIT_DIR=clone1/.git` run from the parent dir, which makes the work
387            // tree default to the process cwd), the relative path would resolve
388            // against the wrong directory. In that case fall through to the
389            // absolute hook path, mirroring upstream `hook.c`, which absolutises
390            // the hook path whenever it runs the hook in a specific directory.
391            if cwd == work_tree && same_dir(repo.git_dir(), &work_tree.join(".git")) {
392                return PathBuf::from(".git").join("hooks").join(hook_name);
393            }
394        }
395    }
396    hooks_dir.join(hook_name)
397}
398
399/// Compare two paths for pointing at the same directory, tolerating symlinks and
400/// non-canonical components by canonicalising both ends when possible.
401fn same_dir(a: &Path, b: &Path) -> bool {
402    if a == b {
403        return true;
404    }
405    match (a.canonicalize_simplified(), b.canonicalize_simplified()) {
406        (Ok(ca), Ok(cb)) => ca == cb,
407        _ => false,
408    }
409}
410
411// `repo`/`meta` are only read on Unix (executable-bit advice); Windows has no
412// POSIX permission bits, so the candidate is returned without that check.
413#[cfg_attr(not(unix), allow(unused_variables))]
414fn traditional_hook_candidate(
415    repo: &Repository,
416    hooks_dir: &Path,
417    hook_name: &str,
418) -> Option<PathBuf> {
419    let path = hooks_dir.join(hook_name);
420    if !path.exists() {
421        return None;
422    }
423    let meta = fs::metadata(&path).ok()?;
424    #[cfg(unix)]
425    if meta.permissions().mode() & 0o111 == 0 {
426        let config = repo.config().ok();
427        let show_warning = config
428            .as_deref()
429            .and_then(|c| c.get("advice.ignoredHook"))
430            .map(|v| !matches!(v.to_lowercase().as_str(), "false" | "no" | "off" | "0"))
431            .unwrap_or(true);
432        if show_warning {
433            repo.warn(crate::diagnostics::Warning::NonExecutableHookIgnored {
434                hook_name: hook_name.to_string(),
435                hook_path: Some(path.clone()),
436            });
437        }
438        return None;
439    }
440    Some(path)
441}
442
443/// Configured hooks only (for out-of-repo `git hook run`).
444fn resolve_configured_hooks_only(
445    hook_name: &str,
446    config: &ConfigSet,
447) -> Result<Vec<ResolvedHook>, String> {
448    let tables = HookConfigTables::from_config(config);
449    let mut seq = Vec::new();
450    for (_friendly, command) in tables.hooks_for_event(hook_name)? {
451        seq.push(ResolvedHook::Configured { command });
452    }
453    Ok(seq)
454}
455
456/// Build ordered hook list: configured hooks first, then the traditional hookdir script.
457fn resolve_hook_sequence(
458    repo: &Repository,
459    hook_name: &str,
460    config: &ConfigSet,
461) -> Result<Vec<ResolvedHook>, String> {
462    let tables = HookConfigTables::from_config(config);
463    let mut seq = Vec::new();
464    for (_friendly, command) in tables.hooks_for_event(hook_name)? {
465        seq.push(ResolvedHook::Configured { command });
466    }
467    let hooks_dir = resolve_hooks_dir_for_config(repo, Some(config));
468    if let Some(path) = traditional_hook_candidate(repo, &hooks_dir, hook_name) {
469        let argv0 = hook_argv0(repo, &hooks_dir, hook_name, hook_run_dir(repo));
470        seq.push(ResolvedHook::Traditional { path, argv0 });
471    }
472    Ok(seq)
473}
474
475/// Interpreter basename named by a script's `#!` line, or `None` without one.
476///
477/// `#!/bin/sh -e` gives `sh` and `#!/usr/bin/env bash` gives `bash`. Options after the
478/// interpreter are dropped, as Git for Windows does when it runs scripts itself.
479#[cfg_attr(not(windows), allow(dead_code))]
480fn shebang_interpreter(head: &[u8]) -> Option<String> {
481    let rest = head.strip_prefix(b"#!")?;
482    let line_len = rest
483        .iter()
484        .position(|&b| b == b'\n' || b == b'\r')
485        .unwrap_or(rest.len());
486    let line = std::str::from_utf8(&rest[..line_len]).ok()?;
487    let basename = |word: &str| word.rsplit(['/', '\\']).next().map(str::to_owned);
488    let mut words = line.split_ascii_whitespace();
489    let mut name = basename(words.next()?)?;
490    if name == "env" {
491        name = basename(words.find(|w| !w.starts_with('-'))?)?;
492    }
493    (!name.is_empty()).then_some(name)
494}
495
496/// Strip Windows `\\?\` prefixes from the path-valued `GIT_*` variables in `set`.
497///
498/// Repository paths are canonicalized, which makes them verbatim on Windows, but Git for
499/// Windows does not open a verbatim `GIT_INDEX_FILE` (it reads an empty index instead) and
500/// MSYS tools cannot open such paths at all.
501fn strip_verbatim_prefix_from_path_vars(set: &mut [(OsString, OsString)]) {
502    crate::git_path::strip_verbatim_prefix_from_subprocess_path_vars(set);
503}
504
505/// Shell for `hook.<name>.command` entries and for scripts the OS will not execute directly.
506///
507/// See [`crate::command_runner::shell_program`]: `/bin/sh` on Unix, `sh` resolved against the
508/// hook's `PATH` on Windows.
509fn hook_shell(env: &CommandEnvironment) -> ScriptInterpreter {
510    crate::command_runner::shell_interpreter(env)
511}
512
513/// Interpreter from the `#!` line of `script` (Windows cannot execute scripts directly).
514#[cfg(windows)]
515fn script_shebang_interpreter(
516    script: &Path,
517    env: &CommandEnvironment,
518) -> Option<ScriptInterpreter> {
519    use std::io::Read;
520    let mut head = [0u8; 256];
521    let mut file = fs::File::open(script).ok()?;
522    let n = file.read(&mut head).ok()?;
523    let name = shebang_interpreter(&head[..n])?;
524    Some(crate::command_runner::resolve_script_interpreter(
525        &name,
526        crate::command_runner::child_path_var(env).as_deref(),
527    ))
528}
529
530/// Stdio and environment shared by every hook subprocess.
531struct HookSpawnContext<'a> {
532    runner: &'a dyn CommandRunner,
533    cwd: &'a Path,
534    env: &'a CommandEnvironment,
535    stdin_piped: bool,
536    stdout: CommandStdio,
537    stderr: CommandStdio,
538    server_receive_hook: bool,
539}
540
541impl HookSpawnContext<'_> {
542    fn spec(&self, program: PathBuf, shell: Option<ShellInvocation>, args: &[&str]) -> CommandSpec {
543        CommandSpec {
544            program,
545            args: if shell.is_some() {
546                Vec::new()
547            } else {
548                args.iter().map(OsString::from).collect()
549            },
550            shell,
551            cwd: Some(path_without_verbatim_prefix(self.cwd).into_owned()),
552            env: self.env.clone(),
553            stdin: if self.stdin_piped {
554                CommandStdin::Pipe(Vec::new())
555            } else {
556                CommandStdin::Inherit
557            },
558            stdout: self.stdout,
559            stderr: self.stderr,
560        }
561    }
562
563    /// Spec running `interpreter` with `invocation`; an interpreter from a Git for Windows
564    /// tree also gets that tree's tool directories in front of `PATH`, as Git gives hooks.
565    fn interpreter_spec(
566        &self,
567        interpreter: ScriptInterpreter,
568        invocation: ShellInvocation,
569        args: &[&str],
570    ) -> CommandSpec {
571        let mut spec = self.spec(interpreter.program, Some(invocation), args);
572        if let Some(root) = &interpreter.git_root {
573            prepend_to_child_path(&mut spec.env, &git_for_windows_tool_dirs(root));
574        }
575        spec
576    }
577
578    fn spawn(&self, spec: &CommandSpec, hook_name: &str) -> Result<RunningCommand, HookError> {
579        let spawn = if self.server_receive_hook {
580            self.runner.spawn_server_receive_hook(spec)
581        } else {
582            self.runner.spawn(spec)
583        };
584        spawn.map_err(|source| HookError::Spawn {
585            hook_name: hook_name.to_owned(),
586            path: spec.program.clone(),
587            source,
588            output: HookOutput::default(),
589        })
590    }
591
592    /// Run `script` (spelled `argv0` for the child) directly, or through a shell when the OS
593    /// will not execute it. On Windows a script with a `#!` line goes straight to that
594    /// interpreter.
595    #[cfg_attr(not(windows), allow(unused_variables))]
596    fn spawn_traditional(
597        &self,
598        hook_name: &str,
599        script: &Path,
600        argv0: &Path,
601        args: &[&str],
602    ) -> Result<RunningCommand, HookError> {
603        let argv0 = path_without_verbatim_prefix(argv0);
604        let via_shell = |shell: ScriptInterpreter| {
605            let invocation = ShellInvocation::ScriptPath {
606                script_path: argv0.to_path_buf(),
607                args: args.iter().map(OsString::from).collect(),
608            };
609            self.interpreter_spec(shell, invocation, args)
610        };
611        #[cfg(windows)]
612        if let Some(interpreter) = script_shebang_interpreter(script, self.env) {
613            return self.spawn(&via_shell(interpreter), hook_name);
614        }
615        let direct = self.spec(argv0.to_path_buf(), None, args);
616        match self.spawn(&direct, hook_name) {
617            Err(HookError::Spawn { source, path, .. }) if is_not_an_executable(&source) => {
618                self.spawn(&via_shell(hook_shell(self.env)), hook_name)
619            }
620            other => other,
621        }
622    }
623
624    /// Run a `hook.<name>.command` entry as `sh -c <command> hook <args>…`.
625    fn spawn_configured(
626        &self,
627        hook_name: &str,
628        command: &str,
629        args: &[&str],
630    ) -> Result<RunningCommand, HookError> {
631        let invocation = ShellInvocation::DashC {
632            script: command.to_owned(),
633            argv0: Some("hook".to_owned()),
634            args: args.iter().map(OsString::from).collect(),
635        };
636        self.spawn(
637            &self.interpreter_spec(hook_shell(self.env), invocation, args),
638            hook_name,
639        )
640    }
641}
642
643/// Well-known lifecycle hooks that porcelain operations run and report in their outcomes
644/// (for example [`crate::porcelain::commit::CommitOutcome::hooks_run`]).
645#[derive(Debug, Clone, Copy, PartialEq, Eq)]
646#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
647#[cfg_attr(feature = "serde-models", serde(rename_all = "kebab-case"))]
648pub enum HookName {
649    /// `.git/hooks/pre-commit` or `hook.pre-commit.*`.
650    PreCommit,
651    /// `.git/hooks/pre-merge-commit`, run before a clean merge records its commit.
652    PreMergeCommit,
653    /// `.git/hooks/prepare-commit-msg`, run on the message file before `commit-msg`; not skipped
654    /// by `--no-verify`.
655    PrepareCommitMsg,
656    /// `.git/hooks/commit-msg` or `hook.commit-msg.*`.
657    CommitMsg,
658    /// `.git/hooks/post-commit` or `hook.post-commit.*`.
659    PostCommit,
660    /// `.git/hooks/post-checkout`, run after `HEAD` moves to another commit or branch.
661    PostCheckout,
662    /// `.git/hooks/post-merge`, run after a merge or fast-forward updates the work tree.
663    PostMerge,
664    /// `.git/hooks/post-rewrite`, run after amend or a history rewrite replaces commits.
665    PostRewrite,
666    /// `.git/hooks/pre-rebase`, run before a rebase rewrites anything; a non-zero exit refuses
667    /// the rebase.
668    PreRebase,
669}
670
671impl HookName {
672    /// Git hook basename (`pre-commit`, `commit-msg`, `post-commit`, `post-checkout`, …).
673    #[must_use]
674    pub const fn as_str(self) -> &'static str {
675        match self {
676            Self::PreCommit => "pre-commit",
677            Self::PreMergeCommit => "pre-merge-commit",
678            Self::PrepareCommitMsg => "prepare-commit-msg",
679            Self::PreRebase => "pre-rebase",
680            Self::CommitMsg => "commit-msg",
681            Self::PostCommit => "post-commit",
682            Self::PostCheckout => "post-checkout",
683            Self::PostMerge => "post-merge",
684            Self::PostRewrite => "post-rewrite",
685        }
686    }
687}
688
689/// Whether an operation runs the hooks Git runs once it has completed (`post-checkout`,
690/// `post-merge`, `post-rewrite`).
691///
692/// These hooks are advisory: their exit status never changes the operation's result. Callers
693/// that compose several operations (or an embedder that does not want user scripts to run)
694/// pass [`PostOperationHooks::Skip`].
695#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
696pub enum PostOperationHooks {
697    /// Run the installed post-operation hooks (default, matching Git).
698    #[default]
699    Run,
700    /// Do not run post-operation hooks.
701    Skip,
702}
703
704impl PostOperationHooks {
705    /// Whether hooks should run.
706    #[must_use]
707    pub const fn enabled(self) -> bool {
708        matches!(self, Self::Run)
709    }
710}
711
712/// What kind of rewrite a `post-rewrite` hook is told about (its first argument).
713#[derive(Debug, Clone, Copy, PartialEq, Eq)]
714pub enum RewriteKind {
715    /// `commit --amend` replaced the tip commit.
716    Amend,
717    /// A rebase or history edit replayed commits.
718    Rebase,
719}
720
721impl RewriteKind {
722    /// Hook argument (`amend` or `rebase`).
723    #[must_use]
724    pub const fn as_str(self) -> &'static str {
725        match self {
726            Self::Amend => "amend",
727            Self::Rebase => "rebase",
728        }
729    }
730}
731
732/// Run an advisory hook whose outcome never fails the surrounding operation.
733///
734/// Returns whether a hook ran (a non-zero exit still counts as having run). A hook that cannot
735/// be started, or invalid `hook.*` configuration, is reported as [`Warning::HookNotRun`] on the
736/// repository's diagnostics sink and returns `false`.
737///
738/// [`Warning::HookNotRun`]: crate::diagnostics::Warning::HookNotRun
739pub(crate) fn run_advisory_hook(
740    repo: &Repository,
741    hook: HookName,
742    args: &[&str],
743    stdin_data: Option<&[u8]>,
744    commit_env: Option<&CommitHookEnv<'_>>,
745) -> bool {
746    let result = match commit_env {
747        Some(env) => run_commit_hook(repo, hook.as_str(), args, stdin_data, env),
748        None => run_hook(repo, hook.as_str(), args, stdin_data),
749    };
750    match result {
751        Ok(result) => result.was_executed(),
752        Err(err) => {
753            let mut detail = err.to_string();
754            let mut cause = std::error::Error::source(&err);
755            while let Some(c) = cause {
756                detail.push_str(": ");
757                detail.push_str(&c.to_string());
758                cause = c.source();
759            }
760            repo.warn(crate::diagnostics::Warning::HookNotRun {
761                hook_name: hook.as_str().to_owned(),
762                detail,
763            });
764            false
765        }
766    }
767}
768
769/// Run `post-checkout <old> <new> <flag>` after `HEAD` moved.
770///
771/// `old` is the commit `HEAD` pointed at before (`None` for an unborn branch, passed as the null
772/// object id). `branch_checkout` is `true` when `HEAD` itself moved to another branch or commit
773/// (switch, rebase) and `false` when only paths were checked out.
774///
775/// Returns whether a hook ran (a non-zero exit still counts). The hook's outcome is never an
776/// error: a hook that cannot be started is reported as
777/// [`Warning::HookNotRun`](crate::diagnostics::Warning::HookNotRun) and returns `false`.
778pub fn run_post_checkout(
779    repo: &Repository,
780    old: Option<ObjectId>,
781    new: ObjectId,
782    branch_checkout: bool,
783) -> bool {
784    let old_hex = old
785        .unwrap_or_else(|| ObjectId::null(repo.odb().hash_algo()))
786        .to_hex();
787    let new_hex = new.to_hex();
788    let flag = if branch_checkout { "1" } else { "0" };
789    run_advisory_hook(
790        repo,
791        HookName::PostCheckout,
792        &[old_hex.as_str(), new_hex.as_str(), flag],
793        None,
794        None,
795    )
796}
797
798/// Run `post-merge <flag>` after a merge or fast-forward updated the work tree; the flag is
799/// `1` for a squash merge and `0` otherwise.
800///
801/// Returns whether a hook ran; failures never become errors (see [`run_post_checkout`]).
802pub fn run_post_merge(repo: &Repository, squash: bool) -> bool {
803    run_advisory_hook(
804        repo,
805        HookName::PostMerge,
806        &[if squash { "1" } else { "0" }],
807        None,
808        None,
809    )
810}
811
812/// Run `pre-rebase <upstream>` before a rebase of the checked-out branch rewrites anything.
813///
814/// `upstream` is the upstream exactly as the user named it (Git passes the argument, not the
815/// resolved commit). `None` means the branch's configured upstream, which the hook receives as
816/// its full ref name (`refs/remotes/origin/main`), as Git does. The hook gets no second
817/// argument because the branch being rebased is the current one.
818///
819/// Returns whether a hook ran.
820///
821/// # Errors
822///
823/// [`HookError::Failed`] when the hook exits non-zero, which refuses the rebase;
824/// [`HookError::Spawn`] / [`HookError::Internal`] as for [`run_hook`].
825pub fn run_pre_rebase(repo: &Repository, upstream: Option<&str>) -> Result<bool, HookError> {
826    let configured;
827    let upstream_arg = match upstream {
828        Some(arg) => arg,
829        None => {
830            configured = crate::state::resolve_head_for_repository(repo)
831                .ok()
832                .and_then(|head| {
833                    head.branch_name().and_then(|branch| {
834                        crate::branch_tracking::upstream_tracking_full_ref(repo, branch)
835                    })
836                })
837                .unwrap_or_else(|| "@{upstream}".to_owned());
838            configured.as_str()
839        }
840    };
841    let name = HookName::PreRebase.as_str();
842    run_hook(repo, name, &[upstream_arg], None)?.check(name)
843}
844
845/// Bytes fed to `post-rewrite` on stdin: one `<old> <new>` line per rewritten commit.
846#[must_use]
847pub fn post_rewrite_stdin(pairs: &[(ObjectId, ObjectId)]) -> Vec<u8> {
848    let mut out = Vec::with_capacity(pairs.len() * 82);
849    for (old, new) in pairs {
850        out.extend_from_slice(old.to_hex().as_bytes());
851        out.push(b' ');
852        out.extend_from_slice(new.to_hex().as_bytes());
853        out.push(b'\n');
854    }
855    out
856}
857
858/// Run `post-rewrite <amend|rebase>` with `pairs` (`old new`, oldest first) on stdin.
859///
860/// Nothing runs when `pairs` is empty. Returns whether a hook ran; failures never become errors
861/// (see [`run_post_checkout`]).
862pub fn run_post_rewrite(
863    repo: &Repository,
864    kind: RewriteKind,
865    pairs: &[(ObjectId, ObjectId)],
866) -> bool {
867    if pairs.is_empty() {
868        return false;
869    }
870    let stdin = post_rewrite_stdin(pairs);
871    run_advisory_hook(
872        repo,
873        HookName::PostRewrite,
874        &[kind.as_str()],
875        Some(&stdin),
876        None,
877    )
878}
879
880/// Result of running the hooks for one event.
881#[derive(Debug, Clone, PartialEq, Eq)]
882pub enum HookResult {
883    /// Every hook ran and exited 0.
884    Success {
885        /// What the hooks wrote; empty under [`HookStdio::Inherit`].
886        output: HookOutput,
887    },
888    /// No hook is installed for the event (or it is not executable) — treated as success.
889    NotFound,
890    /// A hook exited non-zero; later hooks for the event did not run.
891    Failed {
892        /// Exit code of the failing hook (1 when it had none, e.g. killed by a signal).
893        status: i32,
894        /// What the hooks wrote up to and including the failing one; empty under
895        /// [`HookStdio::Inherit`].
896        output: HookOutput,
897    },
898}
899
900impl HookResult {
901    /// Returns true if the hook was successful or not found.
902    #[must_use]
903    pub fn is_ok(&self) -> bool {
904        matches!(self, HookResult::Success { .. } | HookResult::NotFound)
905    }
906
907    /// Returns true if the hook existed and ran (regardless of exit code).
908    #[must_use]
909    pub fn was_executed(&self) -> bool {
910        !matches!(self, HookResult::NotFound)
911    }
912
913    /// Exit code of the hook run: `Some(0)` on success, `None` when no hook ran.
914    #[must_use]
915    pub fn status(&self) -> Option<i32> {
916        match self {
917            HookResult::Success { .. } => Some(0),
918            HookResult::NotFound => None,
919            HookResult::Failed { status, .. } => Some(*status),
920        }
921    }
922
923    /// Captured output, or `None` when no hook ran.
924    #[must_use]
925    pub fn output(&self) -> Option<&HookOutput> {
926        match self {
927            HookResult::Success { output } | HookResult::Failed { output, .. } => Some(output),
928            HookResult::NotFound => None,
929        }
930    }
931
932    /// Turn a failed run into [`HookError::Failed`] for `hook_name`; returns whether a hook ran.
933    ///
934    /// # Errors
935    ///
936    /// [`HookError::Failed`] carrying the status and captured output when a hook exited non-zero.
937    pub fn check(self, hook_name: &str) -> Result<bool, HookError> {
938        match self {
939            HookResult::Success { .. } => Ok(true),
940            HookResult::NotFound => Ok(false),
941            HookResult::Failed { status, output } => Err(HookError::Failed {
942                hook_name: hook_name.to_owned(),
943                status,
944                output,
945            }),
946        }
947    }
948}
949
950/// Options for [`run_hook_opts`].
951#[derive(Debug, Clone, Default)]
952pub struct RunHookOptions<'a> {
953    /// Capture hook output or let hooks write to this process's stderr (see [`HookStdio`]).
954    ///
955    /// The `run_*` helpers that take a [`Repository`] use [`Repository::hook_stdio`].
956    pub stdio: HookStdio,
957    /// Under [`HookStdio::Inherit`], send hook stdout to this process's stderr (Git does this
958    /// for every hook except `pre-push`). Captured stdout is always returned separately.
959    pub stdout_to_stderr: bool,
960    /// File path to open and pipe to each hook's stdin (reopened per hook).
961    pub path_to_stdin: Option<&'a Path>,
962    /// In-memory stdin (used when `path_to_stdin` is None).
963    pub stdin_data: Option<&'a [u8]>,
964    /// Extra environment variables for each hook subprocess.
965    pub env_vars: &'a [(&'a str, &'a str)],
966    /// Override the hook process working directory.
967    pub cwd: Option<&'a Path>,
968    /// Commit-style env (`GIT_INDEX_FILE`, `GIT_PREFIX`, author exports, …) merged after `env_vars`.
969    pub commit_env: Option<&'a CommitHookEnv<'a>>,
970}
971
972/// Run all hooks for `hook_name` in Git order; return first non-zero exit or success.
973///
974/// When `repo` is `None`, only configured hooks run (out-of-repo); cwd is the process cwd and
975/// `GIT_DIR` is not set for those hooks.
976///
977/// Hook output is handled per [`RunHookOptions::stdio`]: captured into the returned
978/// [`HookOutput`], or written by the child straight to this process's stderr. The library
979/// never re-emits what a hook (or an injected [`CommandRunner`]) produced.
980///
981const HOOK_WAIT_POLL: Duration = Duration::from_millis(50);
982
983fn kill_pid_sigkill(pid: u32, kill_process_group: bool) {
984    crate::command_runner::force_kill_process_pid(pid, kill_process_group);
985}
986
987fn wait_hook_command(
988    child: RunningCommand,
989    capture: bool,
990    cancel: &dyn Cancel,
991    output: &mut HookOutput,
992) -> Result<crate::command_runner::CommandExit, HookError> {
993    use crate::command_runner::CommandOutput;
994
995    if cancel.is_cancelled() {
996        return stop_hook_child(child, capture, output);
997    }
998
999    if child.child_pid().is_none() {
1000        return finish_hook_wait(child, capture, output);
1001    }
1002
1003    let pid = child.child_pid();
1004    let kill_process_group = child.kill_process_group();
1005    let shared = std::sync::Arc::new(std::sync::Mutex::new(Some(child)));
1006    let shared_wait = std::sync::Arc::clone(&shared);
1007    let (tx, rx) = std::sync::mpsc::sync_channel(1);
1008    std::thread::spawn(move || {
1009        let child = shared_wait.lock().unwrap_or_else(|e| e.into_inner()).take();
1010        let Some(child) = child else {
1011            return;
1012        };
1013        let result = if capture {
1014            child.wait_with_output()
1015        } else {
1016            child.wait().map(|status| CommandOutput {
1017                status,
1018                stdout: Vec::new(),
1019                stderr: Vec::new(),
1020            })
1021        };
1022        let _ = tx.send(result);
1023    });
1024
1025    loop {
1026        if cancel.is_cancelled() {
1027            if let Some(mut child) = shared.lock().unwrap_or_else(|e| e.into_inner()).take() {
1028                let _ = child.kill();
1029                if capture {
1030                    if let Ok(out) = child.wait_with_output() {
1031                        output.stdout.extend_from_slice(&out.stdout);
1032                        output.stderr.extend_from_slice(&out.stderr);
1033                    }
1034                } else {
1035                    let _ = child.wait();
1036                }
1037            }
1038            if let Some(pid) = pid {
1039                kill_pid_sigkill(pid, kill_process_group);
1040            }
1041            if let Ok(Ok(out)) = rx.recv_timeout(HOOK_WAIT_POLL.saturating_mul(20)) {
1042                if capture {
1043                    output.stdout.extend_from_slice(&out.stdout);
1044                    output.stderr.extend_from_slice(&out.stderr);
1045                }
1046            }
1047            return Err(HookError::Cancelled);
1048        }
1049        match rx.try_recv() {
1050            Ok(Ok(out)) => {
1051                if capture {
1052                    output.stdout.extend_from_slice(&out.stdout);
1053                    output.stderr.extend_from_slice(&out.stderr);
1054                }
1055                return Ok(out.status);
1056            }
1057            Ok(Err(_)) => {
1058                return Ok(crate::command_runner::CommandExit {
1059                    success: false,
1060                    code: Some(1),
1061                });
1062            }
1063            Err(std::sync::mpsc::TryRecvError::Empty) => {
1064                std::thread::sleep(HOOK_WAIT_POLL);
1065            }
1066            Err(std::sync::mpsc::TryRecvError::Disconnected) => {
1067                return Ok(crate::command_runner::CommandExit {
1068                    success: false,
1069                    code: Some(1),
1070                });
1071            }
1072        }
1073    }
1074}
1075
1076fn stop_hook_child(
1077    child: RunningCommand,
1078    capture: bool,
1079    output: &mut HookOutput,
1080) -> Result<crate::command_runner::CommandExit, HookError> {
1081    let pid = child.child_pid();
1082    let kill_process_group = child.kill_process_group();
1083    let mut child = child;
1084    let _ = child.kill();
1085    let _ = finish_hook_wait(child, capture, output);
1086    if let Some(pid) = pid {
1087        kill_pid_sigkill(pid, kill_process_group);
1088    }
1089    Err(HookError::Cancelled)
1090}
1091
1092fn finish_hook_wait(
1093    child: RunningCommand,
1094    capture: bool,
1095    output: &mut HookOutput,
1096) -> Result<crate::command_runner::CommandExit, HookError> {
1097    if capture {
1098        match child.wait_with_output() {
1099            Ok(out) => {
1100                output.stdout.extend_from_slice(&out.stdout);
1101                output.stderr.extend_from_slice(&out.stderr);
1102                Ok(out.status)
1103            }
1104            Err(_) => Ok(crate::command_runner::CommandExit {
1105                success: false,
1106                code: Some(1),
1107            }),
1108        }
1109    } else {
1110        match child.wait() {
1111            Ok(status) => Ok(status),
1112            Err(_) => Ok(crate::command_runner::CommandExit {
1113                success: false,
1114                code: Some(1),
1115            }),
1116        }
1117    }
1118}
1119
1120/// # Errors
1121///
1122/// [`HookError::Spawn`] when a hook (or the shell that runs it) cannot be started, carrying the
1123/// output earlier hooks for the event already wrote, and [`HookError::Internal`] for invalid
1124/// `hook.*` configuration. A hook that runs and exits non-zero is `Ok(HookResult::Failed { .. })`.
1125pub fn run_hook_opts(
1126    repo: Option<&Repository>,
1127    hook_name: &str,
1128    args: &[&str],
1129    config: &ConfigSet,
1130    opts: RunHookOptions<'_>,
1131) -> Result<HookResult, HookError> {
1132    run_hook_opts_with_cancel(repo, hook_name, args, config, opts, &NeverCancel, false)
1133}
1134
1135fn run_hook_opts_with_cancel(
1136    repo: Option<&Repository>,
1137    hook_name: &str,
1138    args: &[&str],
1139    config: &ConfigSet,
1140    opts: RunHookOptions<'_>,
1141    cancel: &dyn Cancel,
1142    server_receive_hook: bool,
1143) -> Result<HookResult, HookError> {
1144    let seq = match repo {
1145        Some(r) => resolve_hook_sequence(r, hook_name, config)?,
1146        None => resolve_configured_hooks_only(hook_name, config)?,
1147    };
1148    if seq.is_empty() {
1149        return Ok(HookResult::NotFound);
1150    }
1151
1152    let work_dir: PathBuf = opts.cwd.map_or_else(
1153        || match repo {
1154            Some(r) => hook_run_dir(r).to_path_buf(),
1155            None => PathBuf::from("."),
1156        },
1157        Path::to_path_buf,
1158    );
1159    let work_dir = work_dir.as_path();
1160    let git_dir_for_configured = repo.map(|r| r.git_dir());
1161    let runner: Arc<dyn CommandRunner> = match repo {
1162        Some(r) => r.command_runner(),
1163        None => system_command_runner(),
1164    };
1165
1166    let mut merged_env: Vec<(String, String)> = opts
1167        .env_vars
1168        .iter()
1169        .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
1170        .collect();
1171    if let Some(r) = repo {
1172        if let Some(ce) = opts.commit_env {
1173            merged_env.extend(build_commit_hook_env(r, work_dir, ce));
1174        }
1175    }
1176
1177    let mut hook_env = match repo {
1178        Some(r) => CommandEnvironment::from_repository_environment(r.environment(), &merged_env),
1179        None => hook_command_env(git_dir_for_configured, &merged_env),
1180    };
1181    if cfg!(windows) {
1182        strip_verbatim_prefix_from_path_vars(&mut hook_env.set);
1183    }
1184
1185    let capture = opts.stdio == HookStdio::Capture;
1186    let (stdout, stderr) = match opts.stdio {
1187        HookStdio::Capture => (CommandStdio::Pipe, CommandStdio::Pipe),
1188        HookStdio::Inherit if opts.stdout_to_stderr => {
1189            (CommandStdio::ParentStderr, CommandStdio::Inherit)
1190        }
1191        HookStdio::Inherit => (CommandStdio::Inherit, CommandStdio::Inherit),
1192    };
1193    let mut output = HookOutput::default();
1194
1195    for h in &seq {
1196        let (stdin_piped, stdin_file) = match opts.path_to_stdin {
1197            Some(p) => (true, Some(p.to_path_buf())),
1198            None => (opts.stdin_data.is_some(), None),
1199        };
1200
1201        let ctx = HookSpawnContext {
1202            runner: runner.as_ref(),
1203            cwd: work_dir,
1204            env: &hook_env,
1205            stdin_piped,
1206            stdout,
1207            stderr,
1208            server_receive_hook,
1209        };
1210        let spawned = match h {
1211            ResolvedHook::Traditional { path, argv0 } => {
1212                let Some(r) = repo else {
1213                    continue;
1214                };
1215                let effective_argv0 = path
1216                    .parent()
1217                    .map(|hooks_dir| hook_argv0(r, hooks_dir, hook_name, work_dir))
1218                    .unwrap_or_else(|| argv0.clone());
1219                ctx.spawn_traditional(hook_name, path, &effective_argv0, args)
1220            }
1221            ResolvedHook::Configured { command } => ctx.spawn_configured(hook_name, command, args),
1222        };
1223        let mut child = match spawned {
1224            Ok(child) => child,
1225            Err(HookError::Spawn {
1226                hook_name,
1227                path,
1228                source,
1229                ..
1230            }) => {
1231                return Err(HookError::Spawn {
1232                    hook_name,
1233                    path,
1234                    source,
1235                    output,
1236                })
1237            }
1238            Err(other) => return Err(other),
1239        };
1240
1241        let exit = if capture && stdin_piped && (stdin_file.is_some() || opts.stdin_data.is_some())
1242        {
1243            if cancel.is_cancelled() {
1244                let pid = child.child_pid();
1245                let kill_process_group = child.kill_process_group();
1246                let mut child = child;
1247                let _ = child.kill();
1248                if let Some(pid) = pid {
1249                    kill_pid_sigkill(pid, kill_process_group);
1250                }
1251                return Err(HookError::Cancelled);
1252            }
1253            let output_result = if let Some(ref path) = stdin_file {
1254                let file = match fs::File::open(path) {
1255                    Ok(f) => f,
1256                    Err(_e) => {
1257                        return Ok(HookResult::Failed { status: 1, output });
1258                    }
1259                };
1260                child.feed_stdin_and_wait_with_output(Some(ConcurrentStdinFeed::File(file)))
1261            } else if let Some(data) = opts.stdin_data {
1262                child.feed_stdin_and_wait_with_output(Some(ConcurrentStdinFeed::Bytes(data)))
1263            } else {
1264                child.close_stdin();
1265                child.wait_with_output()
1266            };
1267            match output_result {
1268                Ok(o) => {
1269                    output.stdout.extend_from_slice(&o.stdout);
1270                    output.stderr.extend_from_slice(&o.stderr);
1271                    o.status
1272                }
1273                Err(_) => crate::command_runner::CommandExit {
1274                    success: false,
1275                    code: Some(1),
1276                },
1277            }
1278        } else {
1279            if let Some(ref path) = stdin_file {
1280                let file = match fs::File::open(path) {
1281                    Ok(f) => f,
1282                    Err(_e) => {
1283                        return Ok(HookResult::Failed { status: 1, output });
1284                    }
1285                };
1286                if let Some(stdin) = child.stdin_mut() {
1287                    let mut file = file;
1288                    let _ = crate::sigpipe::copy(&mut file, stdin);
1289                }
1290            } else if let Some(data) = opts.stdin_data {
1291                if let Some(stdin) = child.stdin_mut() {
1292                    let _ = crate::sigpipe::write_all(stdin, data);
1293                }
1294            }
1295            if stdin_piped {
1296                child.close_stdin();
1297            }
1298            match wait_hook_command(child, capture, cancel, &mut output) {
1299                Ok(exit) => exit,
1300                Err(HookError::Cancelled) => return Err(HookError::Cancelled),
1301                Err(other) => return Err(other),
1302            }
1303        };
1304
1305        if !exit.success {
1306            if let Some(r) = repo {
1307                let _ = r.reload_config();
1308            }
1309            return Ok(HookResult::Failed {
1310                status: exit.code.unwrap_or(1),
1311                output,
1312            });
1313        }
1314    }
1315
1316    if let Some(r) = repo {
1317        let _ = r.reload_config();
1318    }
1319
1320    Ok(HookResult::Success { output })
1321}
1322
1323/// Like [`run_commit_hook`] but a hook that exits non-zero is an error.
1324///
1325/// # Errors
1326///
1327/// [`HookError::Failed`] with the hook's exit status and captured output, or any error from
1328/// [`run_commit_hook`].
1329pub fn run_commit_hook_checked(
1330    repo: &Repository,
1331    hook_name: &str,
1332    args: &[&str],
1333    stdin_data: Option<&[u8]>,
1334    commit_env: &CommitHookEnv<'_>,
1335) -> Result<(), HookError> {
1336    run_commit_hook(repo, hook_name, args, stdin_data, commit_env)?
1337        .check(hook_name)
1338        .map(drop)
1339}
1340
1341/// Run commit-style hooks with `GIT_INDEX_FILE`, `GIT_PREFIX`, and related env (Git `run_commit_hook`).
1342///
1343/// Output follows [`Repository::hook_stdio`].
1344///
1345/// # Errors
1346///
1347/// [`HookError::Spawn`] when a hook cannot be started, [`HookError::Internal`] when the
1348/// repository config cannot be loaded or `hook.*` entries are invalid.
1349pub fn run_commit_hook(
1350    repo: &Repository,
1351    hook_name: &str,
1352    args: &[&str],
1353    stdin_data: Option<&[u8]>,
1354    commit_env: &CommitHookEnv<'_>,
1355) -> Result<HookResult, HookError> {
1356    run_commit_hook_with_cancel(repo, hook_name, args, stdin_data, commit_env, &NeverCancel)
1357}
1358
1359/// Like [`run_commit_hook`] but honours [`Cancel`] while hook subprocesses run.
1360pub(crate) fn run_commit_hook_with_cancel(
1361    repo: &Repository,
1362    hook_name: &str,
1363    args: &[&str],
1364    stdin_data: Option<&[u8]>,
1365    commit_env: &CommitHookEnv<'_>,
1366    cancel: &dyn Cancel,
1367) -> Result<HookResult, HookError> {
1368    let config = repo
1369        .config()
1370        .map_err(|e| HookError::Internal(e.to_string()))?;
1371    run_hook_opts_with_cancel(
1372        Some(repo),
1373        hook_name,
1374        args,
1375        config.as_ref(),
1376        RunHookOptions {
1377            stdin_data,
1378            commit_env: Some(commit_env),
1379            ..repo_hook_options(repo, hook_name)
1380        },
1381        cancel,
1382        false,
1383    )
1384}
1385
1386/// Default [`RunHookOptions`] for `hook_name` on `repo`: [`Repository::hook_stdio`], and stdout
1387/// sent to stderr for every hook except `pre-push`, whose stdout is part of its protocol.
1388fn repo_hook_options<'a>(repo: &Repository, hook_name: &str) -> RunHookOptions<'a> {
1389    RunHookOptions {
1390        stdio: repo.hook_stdio(),
1391        stdout_to_stderr: hook_name != "pre-push",
1392        ..RunHookOptions::default()
1393    }
1394}
1395
1396/// Run a hook by name with the given arguments (Git-compatible multihooks).
1397///
1398/// Output follows [`Repository::hook_stdio`]; when inherited, `pre-push` keeps stdout
1399/// separate and other hooks send stdout to stderr.
1400///
1401/// # Errors
1402///
1403/// [`HookError::Spawn`] when a hook cannot be started, [`HookError::Internal`] when the
1404/// repository config cannot be loaded or `hook.*` entries are invalid.
1405pub fn run_hook(
1406    repo: &Repository,
1407    hook_name: &str,
1408    args: &[&str],
1409    stdin_data: Option<&[u8]>,
1410) -> Result<HookResult, HookError> {
1411    let config = repo
1412        .config()
1413        .map_err(|e| HookError::Internal(e.to_string()))?;
1414    run_hook_opts(
1415        Some(repo),
1416        hook_name,
1417        args,
1418        config.as_ref(),
1419        RunHookOptions {
1420            stdin_data,
1421            ..repo_hook_options(repo, hook_name)
1422        },
1423    )
1424}
1425
1426/// Run a server-side hook (`pre-receive`, `update`, …) with cwd = `GIT_DIR`.
1427///
1428/// The hook sees `GIT_DIR=.`, as Git's receive-pack hooks do, so scripts that run
1429/// `git` commands address the repository they were started in.
1430///
1431/// Output is always captured, whatever [`Repository::hook_stdio`] says, because the server
1432/// relays it to the client over the sideband. A hook that cannot be resolved or started is
1433/// reported as `Failed { status: 1 }` so the push is declined.
1434pub fn run_hook_in_git_dir(
1435    repo: &Repository,
1436    hook_name: &str,
1437    args: &[&str],
1438    stdin_data: Option<&[u8]>,
1439    env_vars: &[(&str, &str)],
1440) -> HookResult {
1441    run_hook_in_git_dir_with_cancel(repo, hook_name, args, stdin_data, env_vars, &NeverCancel)
1442        .unwrap_or_else(|_| hook_declined())
1443}
1444
1445fn hook_declined() -> HookResult {
1446    HookResult::Failed {
1447        status: 1,
1448        output: HookOutput::default(),
1449    }
1450}
1451
1452/// Like [`run_hook_in_git_dir`], but honours [`Cancel`] and can terminate the hook process group.
1453///
1454/// # Errors
1455///
1456/// [`HookError::Cancelled`] when `cancel` is set while a hook subprocess runs.
1457pub(crate) fn run_hook_in_git_dir_with_cancel(
1458    repo: &Repository,
1459    hook_name: &str,
1460    args: &[&str],
1461    stdin_data: Option<&[u8]>,
1462    env_vars: &[(&str, &str)],
1463    cancel: &dyn Cancel,
1464) -> Result<HookResult, HookError> {
1465    let env_vars: Vec<(&str, &str)> = std::iter::once(("GIT_DIR", "."))
1466        .chain(env_vars.iter().copied())
1467        .collect();
1468    let config = repo
1469        .config()
1470        .map_err(|e| HookError::Internal(e.to_string()))?;
1471    run_hook_opts_with_cancel(
1472        Some(repo),
1473        hook_name,
1474        args,
1475        config.as_ref(),
1476        RunHookOptions {
1477            stdio: HookStdio::Capture,
1478            stdout_to_stderr: true,
1479            stdin_data,
1480            env_vars: &env_vars,
1481            cwd: Some(repo.git_dir()),
1482            ..RunHookOptions::default()
1483        },
1484        cancel,
1485        true,
1486    )
1487}
1488
1489/// One ref update a push is about to make, as reported to the `pre-push` hook.
1490#[derive(Clone, Debug, PartialEq, Eq)]
1491pub struct PrePushRef {
1492    /// Local source as named by the push (`refs/heads/main`, `HEAD`, …). `None` when the
1493    /// source was a bare object id, or for a deletion.
1494    pub local_ref: Option<String>,
1495    /// Object the remote ref will point at; `None` for a deletion.
1496    pub local_oid: Option<ObjectId>,
1497    /// Destination ref on the remote (`refs/heads/main`).
1498    pub remote_ref: String,
1499    /// Current value of [`Self::remote_ref`] on the remote; `None` when it does not exist.
1500    pub remote_oid: Option<ObjectId>,
1501}
1502
1503impl PrePushRef {
1504    /// The hook's stdin line: `<local ref> SP <local oid> SP <remote ref> SP <remote oid> LF`.
1505    ///
1506    /// A missing object id is written as the all-zero id of width `algo`; a deletion's local
1507    /// ref is `(delete)` and a bare object id source repeats the id.
1508    fn stdin_line(&self, algo: crate::objects::HashAlgo) -> String {
1509        let zero = "0".repeat(algo.hex_len());
1510        let hex = |oid: Option<ObjectId>| oid.map_or_else(|| zero.clone(), |o| o.to_hex());
1511        let local_ref = match (&self.local_ref, self.local_oid) {
1512            (Some(name), _) => name.clone(),
1513            (None, Some(oid)) => oid.to_hex(),
1514            (None, None) => "(delete)".to_owned(),
1515        };
1516        format!(
1517            "{local_ref} {} {} {}\n",
1518            hex(self.local_oid),
1519            self.remote_ref,
1520            hex(self.remote_oid)
1521        )
1522    }
1523}
1524
1525/// Run the client-side `pre-push` hook before a push sends anything.
1526///
1527/// The hook receives `remote` (the remote's name, or its URL for an unnamed remote) and `url`
1528/// as arguments, and one [`PrePushRef`] line per update on stdin. It runs even when `refs` is
1529/// empty. Output follows [`Repository::hook_stdio`]; under [`HookStdio::Inherit`] the hook's
1530/// stdout goes to stderr with its stderr, so a caller's own stdout (for example JSON output)
1531/// stays clean.
1532///
1533/// Returns the hook's captured output (empty under [`HookStdio::Inherit`] or when no hook is
1534/// installed).
1535///
1536/// # Errors
1537///
1538/// [`HookError::Failed`] when the hook exits non-zero (the push must not proceed),
1539/// [`HookError::Spawn`] when it cannot be started, and [`HookError::Internal`] when the
1540/// configuration cannot be loaded or `hook.*` entries are invalid.
1541pub fn run_pre_push_hook(
1542    repo: &Repository,
1543    remote: &str,
1544    url: &str,
1545    refs: &[PrePushRef],
1546) -> Result<HookOutput, HookError> {
1547    let config = repo
1548        .config()
1549        .map_err(|e| HookError::Internal(e.to_string()))?;
1550    let algo = repo.odb().hash_algo();
1551    let stdin: String = refs.iter().map(|r| r.stdin_line(algo)).collect();
1552    let result = run_hook_opts(
1553        Some(repo),
1554        "pre-push",
1555        &[remote, url],
1556        config.as_ref(),
1557        RunHookOptions {
1558            stdout_to_stderr: true,
1559            stdin_data: Some(stdin.as_bytes()),
1560            ..repo_hook_options(repo, "pre-push")
1561        },
1562    )?;
1563    let output = match &result {
1564        HookResult::Success { output } => output.clone(),
1565        HookResult::NotFound | HookResult::Failed { .. } => HookOutput::default(),
1566    };
1567    result.check("pre-push")?;
1568    Ok(output)
1569}
1570
1571/// Phase argument for the [`reference-transaction`](https://git-scm.com/docs/githooks#_reference_transaction) hook.
1572#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1573pub(crate) enum ReferenceTransactionPhase {
1574    /// Before any ref write in the transaction; non-zero exit aborts the batch.
1575    Prepared,
1576    /// After refs were written successfully.
1577    Committed,
1578    /// After the transaction was aborted without writing (or after `prepared` refused).
1579    Aborted,
1580}
1581
1582impl ReferenceTransactionPhase {
1583    fn as_arg(self) -> &'static str {
1584        match self {
1585            Self::Prepared => "prepared",
1586            Self::Committed => "committed",
1587            Self::Aborted => "aborted",
1588        }
1589    }
1590}
1591
1592/// Cached hook execution context for ref-store transactions (no full [`Repository`] handle).
1593#[derive(Clone)]
1594pub struct RefHookContext {
1595    git_dir: PathBuf,
1596    work_tree: Option<PathBuf>,
1597    environment: Arc<Environment>,
1598    command_runner: Arc<dyn CommandRunner>,
1599    hook_stdio: HookStdio,
1600    #[cfg_attr(not(unix), allow(dead_code))]
1601    diagnostics: crate::diagnostics::DiagnosticsHandle,
1602    hash_algo: HashAlgo,
1603}
1604
1605impl RefHookContext {
1606    /// Build hook context for a repository being opened.
1607    #[allow(clippy::too_many_arguments)]
1608    pub(crate) fn new(
1609        git_dir: PathBuf,
1610        work_tree: Option<PathBuf>,
1611        environment: Arc<Environment>,
1612        command_runner: Arc<dyn CommandRunner>,
1613        hook_stdio: HookStdio,
1614        diagnostics: crate::diagnostics::DiagnosticsHandle,
1615        hash_algo: HashAlgo,
1616    ) -> Self {
1617        Self {
1618            git_dir,
1619            work_tree,
1620            environment,
1621            command_runner,
1622            hook_stdio,
1623            diagnostics,
1624            hash_algo,
1625        }
1626    }
1627
1628    /// Git directory this context was opened for (main or linked worktree admin dir).
1629    #[must_use]
1630    pub(crate) fn git_dir(&self) -> &Path {
1631        &self.git_dir
1632    }
1633
1634    /// Null object id for this repository's hash algorithm.
1635    #[must_use]
1636    pub(crate) fn null_oid(&self) -> ObjectId {
1637        ObjectId::null(self.hash_algo)
1638    }
1639
1640    fn hook_run_dir(&self) -> &Path {
1641        self.work_tree.as_deref().unwrap_or(&self.git_dir)
1642    }
1643
1644    fn load_config(&self) -> Result<Arc<crate::config::ConfigSet>, HookError> {
1645        crate::config::ConfigSet::load(&self.environment, Some(&self.git_dir), true)
1646            .map(Arc::new)
1647            .map_err(|e| HookError::Internal(e.to_string()))
1648    }
1649
1650    /// Run the `reference-transaction` hook for one phase and stdin payload.
1651    ///
1652    /// A failure on [`ReferenceTransactionPhase::Prepared`] is returned as [`HookError::Failed`]
1653    /// or [`HookError::Spawn`]; `committed` and `aborted` ignore hook failures.
1654    pub(crate) fn run_reference_transaction(
1655        &self,
1656        phase: ReferenceTransactionPhase,
1657        stdin: &[u8],
1658    ) -> Result<(), HookError> {
1659        if stdin.is_empty() {
1660            return Ok(());
1661        }
1662        let config = self.load_config()?;
1663        let result = run_hook_opts_for_ref_context(
1664            self,
1665            "reference-transaction",
1666            &[phase.as_arg()],
1667            config.as_ref(),
1668            RunHookOptions {
1669                stdin_data: Some(stdin),
1670                stdout_to_stderr: true,
1671                stdio: self.hook_stdio,
1672                ..RunHookOptions::default()
1673            },
1674            &NeverCancel,
1675        )?;
1676        if phase == ReferenceTransactionPhase::Prepared {
1677            result.check("reference-transaction")?;
1678        }
1679        Ok(())
1680    }
1681}
1682
1683fn resolve_hooks_dir_for_ref(ctx: &RefHookContext, config: &ConfigSet) -> PathBuf {
1684    let dir = match config.get("core.hooksPath") {
1685        Some(hooks_path) => {
1686            let p = PathBuf::from(parse_path(ctx.environment.as_ref(), &hooks_path));
1687            if p.is_absolute() {
1688                p
1689            } else {
1690                ctx.hook_run_dir().join(p)
1691            }
1692        }
1693        None => crate::repo::common_git_dir_for_config(&ctx.git_dir).join("hooks"),
1694    };
1695    path_without_verbatim_prefix(&dir).into_owned()
1696}
1697
1698fn hook_argv0_for_ref(
1699    ctx: &RefHookContext,
1700    hooks_dir: &Path,
1701    hook_name: &str,
1702    cwd: &Path,
1703) -> PathBuf {
1704    let default_hooks_dir = ctx.git_dir.join("hooks");
1705    if same_dir(hooks_dir, &default_hooks_dir) {
1706        if cwd == ctx.git_dir.as_path() {
1707            return PathBuf::from("hooks").join(hook_name);
1708        }
1709        if let Some(work_tree) = &ctx.work_tree {
1710            if cwd == work_tree.as_path() && same_dir(&ctx.git_dir, &work_tree.join(".git")) {
1711                return PathBuf::from(".git").join("hooks").join(hook_name);
1712            }
1713        }
1714    }
1715    hooks_dir.join(hook_name)
1716}
1717
1718fn resolve_hook_sequence_for_ref(
1719    ctx: &RefHookContext,
1720    hook_name: &str,
1721    config: &ConfigSet,
1722) -> Result<Vec<ResolvedHook>, String> {
1723    let tables = HookConfigTables::from_config(config);
1724    let mut seq = Vec::new();
1725    for (_friendly, command) in tables.hooks_for_event(hook_name)? {
1726        seq.push(ResolvedHook::Configured { command });
1727    }
1728    let hooks_dir = resolve_hooks_dir_for_ref(ctx, config);
1729    if let Some(path) = traditional_hook_candidate_for_ref(ctx, &hooks_dir, hook_name) {
1730        let argv0 = hook_argv0_for_ref(ctx, &hooks_dir, hook_name, ctx.hook_run_dir());
1731        seq.push(ResolvedHook::Traditional { path, argv0 });
1732    }
1733    Ok(seq)
1734}
1735
1736#[cfg_attr(not(unix), allow(unused_variables))]
1737fn traditional_hook_candidate_for_ref(
1738    ctx: &RefHookContext,
1739    hooks_dir: &Path,
1740    hook_name: &str,
1741) -> Option<PathBuf> {
1742    let path = hooks_dir.join(hook_name);
1743    if !path.exists() {
1744        return None;
1745    }
1746    let meta = fs::metadata(&path).ok()?;
1747    #[cfg(unix)]
1748    if meta.permissions().mode() & 0o111 == 0 {
1749        let show_warning = ctx
1750            .load_config()
1751            .ok()
1752            .and_then(|c| c.get("advice.ignoredHook"))
1753            .map(|v| !matches!(v.to_lowercase().as_str(), "false" | "no" | "off" | "0"))
1754            .unwrap_or(true);
1755        if show_warning {
1756            ctx.diagnostics
1757                .warn(crate::diagnostics::Warning::NonExecutableHookIgnored {
1758                    hook_name: hook_name.to_string(),
1759                    hook_path: Some(path.clone()),
1760                });
1761        }
1762        return None;
1763    }
1764    Some(path)
1765}
1766
1767fn run_hook_opts_for_ref_context(
1768    ctx: &RefHookContext,
1769    hook_name: &str,
1770    args: &[&str],
1771    config: &ConfigSet,
1772    opts: RunHookOptions<'_>,
1773    cancel: &dyn Cancel,
1774) -> Result<HookResult, HookError> {
1775    let seq = resolve_hook_sequence_for_ref(ctx, hook_name, config)?;
1776    if seq.is_empty() {
1777        return Ok(HookResult::NotFound);
1778    }
1779
1780    let work_dir = opts
1781        .cwd
1782        .map(Path::to_path_buf)
1783        .unwrap_or_else(|| ctx.hook_run_dir().to_path_buf());
1784    let work_dir = work_dir.as_path();
1785    let runner = ctx.command_runner.as_ref();
1786
1787    let merged_env: Vec<(String, String)> = opts
1788        .env_vars
1789        .iter()
1790        .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
1791        .collect();
1792
1793    let mut hook_env =
1794        CommandEnvironment::from_repository_environment(ctx.environment.as_ref(), &merged_env);
1795    if cfg!(windows) {
1796        strip_verbatim_prefix_from_path_vars(&mut hook_env.set);
1797    }
1798    hook_env.set.push((
1799        OsString::from("GIT_DIR"),
1800        ctx.git_dir.as_os_str().to_os_string(),
1801    ));
1802
1803    let capture = opts.stdio == HookStdio::Capture;
1804    let (stdout, stderr) = match opts.stdio {
1805        HookStdio::Capture => (CommandStdio::Pipe, CommandStdio::Pipe),
1806        HookStdio::Inherit if opts.stdout_to_stderr => {
1807            (CommandStdio::ParentStderr, CommandStdio::Inherit)
1808        }
1809        HookStdio::Inherit => (CommandStdio::Inherit, CommandStdio::Inherit),
1810    };
1811    let mut output = HookOutput::default();
1812
1813    for h in &seq {
1814        let (stdin_piped, stdin_file) = match opts.path_to_stdin {
1815            Some(p) => (true, Some(p.to_path_buf())),
1816            None => (opts.stdin_data.is_some(), None),
1817        };
1818
1819        let hook_ctx = HookSpawnContext {
1820            runner,
1821            cwd: work_dir,
1822            env: &hook_env,
1823            stdin_piped,
1824            stdout,
1825            stderr,
1826            server_receive_hook: false,
1827        };
1828        let spawned = match h {
1829            ResolvedHook::Traditional { path, argv0 } => {
1830                let effective_argv0 = path
1831                    .parent()
1832                    .map(|hooks_dir| hook_argv0_for_ref(ctx, hooks_dir, hook_name, work_dir))
1833                    .unwrap_or_else(|| argv0.clone());
1834                hook_ctx.spawn_traditional(hook_name, path, &effective_argv0, args)
1835            }
1836            ResolvedHook::Configured { command } => {
1837                hook_ctx.spawn_configured(hook_name, command, args)
1838            }
1839        };
1840        let mut child = match spawned {
1841            Ok(child) => child,
1842            Err(HookError::Spawn {
1843                hook_name,
1844                path,
1845                source,
1846                ..
1847            }) => {
1848                return Err(HookError::Spawn {
1849                    hook_name,
1850                    path,
1851                    source,
1852                    output,
1853                })
1854            }
1855            Err(other) => return Err(other),
1856        };
1857
1858        let exit = if capture && stdin_piped && (stdin_file.is_some() || opts.stdin_data.is_some())
1859        {
1860            if cancel.is_cancelled() {
1861                let pid = child.child_pid();
1862                let kill_process_group = child.kill_process_group();
1863                let mut child = child;
1864                let _ = child.kill();
1865                if let Some(pid) = pid {
1866                    kill_pid_sigkill(pid, kill_process_group);
1867                }
1868                return Err(HookError::Cancelled);
1869            }
1870            let output_result = if let Some(ref path) = stdin_file {
1871                let file = match fs::File::open(path) {
1872                    Ok(f) => f,
1873                    Err(_e) => {
1874                        return Ok(HookResult::Failed { status: 1, output });
1875                    }
1876                };
1877                child.feed_stdin_and_wait_with_output(Some(ConcurrentStdinFeed::File(file)))
1878            } else if let Some(data) = opts.stdin_data {
1879                child.feed_stdin_and_wait_with_output(Some(ConcurrentStdinFeed::Bytes(data)))
1880            } else {
1881                child.close_stdin();
1882                child.wait_with_output()
1883            };
1884            match output_result {
1885                Ok(o) => {
1886                    output.stdout.extend_from_slice(&o.stdout);
1887                    output.stderr.extend_from_slice(&o.stderr);
1888                    o.status
1889                }
1890                Err(_) => crate::command_runner::CommandExit {
1891                    success: false,
1892                    code: Some(1),
1893                },
1894            }
1895        } else {
1896            if let Some(ref path) = stdin_file {
1897                let file = match fs::File::open(path) {
1898                    Ok(f) => f,
1899                    Err(_e) => {
1900                        return Ok(HookResult::Failed { status: 1, output });
1901                    }
1902                };
1903                if let Some(stdin) = child.stdin_mut() {
1904                    let mut file = file;
1905                    let _ = crate::sigpipe::copy(&mut file, stdin);
1906                }
1907            } else if let Some(data) = opts.stdin_data {
1908                if let Some(stdin) = child.stdin_mut() {
1909                    let _ = crate::sigpipe::write_all(stdin, data);
1910                }
1911            }
1912            if stdin_piped {
1913                child.close_stdin();
1914            }
1915            match wait_hook_command(child, capture, cancel, &mut output) {
1916                Ok(exit) => exit,
1917                Err(HookError::Cancelled) => return Err(HookError::Cancelled),
1918                Err(other) => return Err(other),
1919            }
1920        };
1921
1922        if !exit.success {
1923            return Ok(HookResult::Failed {
1924                status: exit.code.unwrap_or(1),
1925                output,
1926            });
1927        }
1928    }
1929
1930    Ok(HookResult::Success { output })
1931}
1932
1933#[cfg(test)]
1934mod tests {
1935    use super::*;
1936
1937    fn touch(path: &Path) {
1938        fs::create_dir_all(path.parent().expect("parent")).expect("mkdir");
1939        fs::write(path, b"").expect("write");
1940    }
1941
1942    fn exe(name: &str) -> String {
1943        format!("{name}{}", std::env::consts::EXE_SUFFIX)
1944    }
1945
1946    #[test]
1947    fn hook_result_reports_status_output_and_checks() {
1948        let output = HookOutput {
1949            stdout: b"out".to_vec(),
1950            stderr: b"err".to_vec(),
1951        };
1952        assert!(!output.is_empty());
1953        assert!(HookOutput::default().is_empty());
1954        assert_eq!(HookError::Internal("bad".into()).output(), None);
1955        assert_eq!(
1956            format!("{output:?}"),
1957            r#"HookOutput { stdout: "out", stderr: "err" }"#
1958        );
1959
1960        let success = HookResult::Success {
1961            output: output.clone(),
1962        };
1963        assert!(success.is_ok() && success.was_executed());
1964        assert_eq!(success.status(), Some(0));
1965        assert_eq!(success.output(), Some(&output));
1966        assert!(success.check("pre-commit").expect("success"));
1967
1968        let missing = HookResult::NotFound;
1969        assert!(missing.is_ok() && !missing.was_executed());
1970        assert_eq!((missing.status(), missing.output()), (None, None));
1971        assert!(!missing.check("pre-commit").expect("not found"));
1972
1973        let failed = HookResult::Failed {
1974            status: 3,
1975            output: output.clone(),
1976        };
1977        assert!(!failed.is_ok() && failed.was_executed());
1978        assert_eq!(failed.status(), Some(3));
1979        match failed.check("commit-msg") {
1980            Err(err @ HookError::Failed { .. }) => {
1981                assert_eq!(err.to_string(), "hook commit-msg failed with exit status 3");
1982                let HookError::Failed {
1983                    hook_name,
1984                    status,
1985                    output: got,
1986                } = err
1987                else {
1988                    unreachable!()
1989                };
1990                assert_eq!((hook_name.as_str(), status), ("commit-msg", 3));
1991                assert_eq!(got, output);
1992            }
1993            other => panic!("expected HookError::Failed, got {other:?}"),
1994        }
1995    }
1996
1997    #[test]
1998    fn post_rewrite_stdin_is_one_old_new_line_per_pair() {
1999        let a = ObjectId::from_hex("1111111111111111111111111111111111111111").expect("a");
2000        let b = ObjectId::from_hex("2222222222222222222222222222222222222222").expect("b");
2001        assert_eq!(post_rewrite_stdin(&[]), b"");
2002        assert_eq!(
2003            post_rewrite_stdin(&[(a, b), (b, a)]),
2004            format!(
2005                "{} {}\n{} {}\n",
2006                a.to_hex(),
2007                b.to_hex(),
2008                b.to_hex(),
2009                a.to_hex()
2010            )
2011            .into_bytes()
2012        );
2013    }
2014
2015    #[test]
2016    fn post_operation_names_and_flags() {
2017        assert_eq!(HookName::PostCheckout.as_str(), "post-checkout");
2018        assert_eq!(HookName::PostMerge.as_str(), "post-merge");
2019        assert_eq!(HookName::PostRewrite.as_str(), "post-rewrite");
2020        assert_eq!(HookName::PreMergeCommit.as_str(), "pre-merge-commit");
2021        assert_eq!(HookName::PrepareCommitMsg.as_str(), "prepare-commit-msg");
2022        assert_eq!(HookName::PreRebase.as_str(), "pre-rebase");
2023        assert_eq!(RewriteKind::Amend.as_str(), "amend");
2024        assert_eq!(RewriteKind::Rebase.as_str(), "rebase");
2025        assert!(PostOperationHooks::default().enabled());
2026        assert!(!PostOperationHooks::Skip.enabled());
2027    }
2028
2029    #[test]
2030    fn shebang_interpreter_takes_the_interpreter_basename() {
2031        assert_eq!(
2032            shebang_interpreter(b"#!/bin/sh\nexit 0\n").as_deref(),
2033            Some("sh")
2034        );
2035        assert_eq!(
2036            shebang_interpreter(b"#!/bin/bash -e\n").as_deref(),
2037            Some("bash")
2038        );
2039        assert_eq!(shebang_interpreter(b"#! /bin/sh\n").as_deref(), Some("sh"));
2040        assert_eq!(
2041            shebang_interpreter(b"#!/bin/sh\r\nexit 0\r\n").as_deref(),
2042            Some("sh")
2043        );
2044        assert_eq!(shebang_interpreter(b"#!/bin/sh").as_deref(), Some("sh"));
2045        assert_eq!(
2046            shebang_interpreter(br"#!C:\tools\perl.exe").as_deref(),
2047            Some("perl.exe")
2048        );
2049    }
2050
2051    #[test]
2052    fn shebang_interpreter_looks_through_env() {
2053        assert_eq!(
2054            shebang_interpreter(b"#!/usr/bin/env bash\n").as_deref(),
2055            Some("bash")
2056        );
2057        assert_eq!(
2058            shebang_interpreter(b"#!/usr/bin/env -S python3 -u\n").as_deref(),
2059            Some("python3")
2060        );
2061        assert_eq!(shebang_interpreter(b"#!/usr/bin/env\n"), None);
2062    }
2063
2064    #[test]
2065    fn shebang_interpreter_rejects_non_scripts() {
2066        assert_eq!(shebang_interpreter(b"exit 0\n"), None);
2067        assert_eq!(shebang_interpreter(b""), None);
2068        assert_eq!(shebang_interpreter(b"#!\n"), None);
2069        assert_eq!(shebang_interpreter(b"#!/\n"), None);
2070        assert_eq!(shebang_interpreter(b"\x7fELF\x02\x01"), None);
2071    }
2072
2073    #[cfg(unix)]
2074    #[test]
2075    fn hook_shell_is_bin_sh_on_unix() {
2076        let mut env = CommandEnvironment::default();
2077        env.set.push(("PATH".into(), "/nonexistent".into()));
2078        assert_eq!(hook_shell(&env), ScriptInterpreter::bare("/bin/sh"));
2079    }
2080
2081    #[test]
2082    fn interpreter_from_git_tree_gets_its_tool_dirs_on_path() {
2083        let tmp = tempfile::tempdir().expect("tempdir");
2084        let root = tmp.path().join("Git");
2085        touch(&root.join("cmd").join(exe("git")));
2086        touch(&root.join("mingw64").join("bin").join(exe("git")));
2087        touch(&root.join("usr").join("bin").join(exe("sh")));
2088        let mut env = CommandEnvironment::default();
2089        env.set
2090            .push(("PATH".into(), root.join("cmd").into_os_string()));
2091        let runner = crate::command_runner::RecordingRunner::always_success();
2092        let ctx = HookSpawnContext {
2093            runner: runner.as_ref(),
2094            cwd: tmp.path(),
2095            env: &env,
2096            stdin_piped: false,
2097            stdout: CommandStdio::Inherit,
2098            stderr: CommandStdio::Inherit,
2099            server_receive_hook: false,
2100        };
2101        let dash_c = || ShellInvocation::DashC {
2102            script: "true".into(),
2103            argv0: Some("hook".into()),
2104            args: Vec::new(),
2105        };
2106        let interpreter = crate::command_runner::resolve_script_interpreter(
2107            "sh",
2108            Some(root.join("cmd").as_os_str()),
2109        );
2110        let spec = ctx.interpreter_spec(interpreter, dash_c(), &[]);
2111        assert_eq!(spec.program, root.join("usr").join("bin").join(exe("sh")));
2112        let expected = std::env::join_paths([
2113            root.join("mingw64").join("bin"),
2114            root.join("usr").join("bin"),
2115            root.join("cmd"),
2116        ])
2117        .expect("join");
2118        assert_eq!(
2119            crate::command_runner::child_path_var(&spec.env),
2120            Some(expected)
2121        );
2122
2123        let bare = ctx.interpreter_spec(ScriptInterpreter::bare("/bin/sh"), dash_c(), &[]);
2124        assert_eq!(
2125            crate::command_runner::child_path_var(&bare.env),
2126            Some(root.join("cmd").into_os_string()),
2127            "interpreters outside a Git tree keep PATH as is"
2128        );
2129    }
2130
2131    #[test]
2132    fn verbatim_prefixes_are_stripped_from_path_vars() {
2133        let mut set: Vec<(OsString, OsString)> = vec![
2134            ("GIT_INDEX_FILE".into(), r"\\?\C:\repo\.git\index".into()),
2135            ("GIT_DIR".into(), r"\\?\UNC\server\share\repo\.git".into()),
2136            ("GIT_WORK_TREE".into(), r"C:\repo".into()),
2137            (
2138                "GIT_OBJECT_DIRECTORY".into(),
2139                r"\\?\C:\repo\.git\objects".into(),
2140            ),
2141            ("GIT_PREFIX".into(), r"\\?\kept".into()),
2142        ];
2143        strip_verbatim_prefix_from_path_vars(&mut set);
2144        let value = |key: &str| {
2145            set.iter()
2146                .find(|(k, _)| k == key)
2147                .map(|(_, v)| v.clone())
2148                .expect("present")
2149        };
2150        assert_eq!(
2151            value("GIT_INDEX_FILE"),
2152            OsString::from(r"C:\repo\.git\index")
2153        );
2154        assert_eq!(
2155            value("GIT_DIR"),
2156            OsString::from(r"\\server\share\repo\.git")
2157        );
2158        assert_eq!(value("GIT_WORK_TREE"), OsString::from(r"C:\repo"));
2159        assert_eq!(
2160            value("GIT_OBJECT_DIRECTORY"),
2161            OsString::from(r"C:\repo\.git\objects")
2162        );
2163        assert_eq!(value("GIT_PREFIX"), OsString::from(r"\\?\kept"));
2164    }
2165
2166    #[cfg(unix)]
2167    #[test]
2168    fn run_hook_in_git_dir_drains_stderr_while_reading_large_stdin() {
2169        use crate::repo::{InitOptions, Repository};
2170
2171        let dir = tempfile::tempdir().expect("tempdir");
2172        let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
2173        let hook = repo.git_dir().join("hooks").join("pre-receive");
2174        fs::write(
2175            &hook,
2176            b"#!/bin/sh\nwhile read old new ref; do echo \"checking $ref\" >&2; done\nexit 0\n",
2177        )
2178        .expect("write hook");
2179        let mut perms = fs::metadata(&hook).expect("meta").permissions();
2180        perms.set_mode(0o755);
2181        fs::set_permissions(&hook, perms).expect("chmod");
2182
2183        let stdin: Vec<u8> = (1..=3000)
2184            .map(|i| {
2185                format!(
2186                    "1111111111111111111111111111111111111111 \
2187                     2222222222222222222222222222222222222222 refs/heads/b{i}\n"
2188                )
2189            })
2190            .collect::<String>()
2191            .into_bytes();
2192
2193        let started = std::time::Instant::now();
2194        let result = run_hook_in_git_dir(&repo, "pre-receive", &[], Some(&stdin), &[]);
2195        assert!(
2196            started.elapsed() < std::time::Duration::from_secs(5),
2197            "pre-receive hook I/O took {:?}",
2198            started.elapsed()
2199        );
2200        assert!(result.is_ok());
2201        let output = result.output().expect("hook output");
2202        assert!(output.stderr.len() > 64 * 1024);
2203        assert!(output.stderr.starts_with(b"checking "));
2204    }
2205
2206    #[test]
2207    fn not_an_executable_matches_only_the_platform_code() {
2208        #[cfg(unix)]
2209        assert!(is_not_an_executable(&std::io::Error::from_raw_os_error(8)));
2210        #[cfg(windows)]
2211        assert!(is_not_an_executable(&std::io::Error::from_raw_os_error(
2212            193
2213        )));
2214        assert!(!is_not_an_executable(&std::io::Error::from(
2215            std::io::ErrorKind::NotFound
2216        )));
2217    }
2218}