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