use crate::command_runner::{
git_for_windows_tool_dirs, prepend_to_child_path, system_command_runner, CommandEnvironment,
CommandRunner, CommandSpec, CommandStdin, CommandStdio, RunningCommand, ScriptInterpreter,
ShellInvocation,
};
use crate::config::{parse_path, ConfigSet};
use crate::git_path::{path_without_verbatim_prefix, strip_verbatim_path_prefix_str};
use crate::objects::ObjectId;
use crate::repo::Repository;
use crate::state::HeadState;
use std::collections::{HashMap, HashSet, VecDeque};
use std::ffi::OsString;
use std::fs;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use thiserror::Error;
fn is_not_an_executable(err: &std::io::Error) -> bool {
#[cfg(unix)]
const NOT_EXECUTABLE: i32 = 8;
#[cfg(windows)]
const NOT_EXECUTABLE: i32 = 193;
#[cfg(not(any(unix, windows)))]
const NOT_EXECUTABLE: i32 = -1;
err.raw_os_error() == Some(NOT_EXECUTABLE)
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum HookStdio {
#[default]
Capture,
Inherit,
}
#[derive(Clone, Default, PartialEq, Eq)]
pub struct HookOutput {
pub stdout: Vec<u8>,
pub stderr: Vec<u8>,
}
impl std::fmt::Debug for HookOutput {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("HookOutput")
.field("stdout", &String::from_utf8_lossy(&self.stdout))
.field("stderr", &String::from_utf8_lossy(&self.stderr))
.finish()
}
}
impl HookOutput {
#[must_use]
pub fn is_empty(&self) -> bool {
self.stdout.is_empty() && self.stderr.is_empty()
}
}
#[derive(Debug, Error)]
pub enum HookError {
#[error("hook {hook_name} failed with exit status {status}")]
Failed {
hook_name: String,
status: i32,
output: HookOutput,
},
#[error("failed to run hook {hook_name} ({})", path.display())]
Spawn {
hook_name: String,
path: PathBuf,
#[source]
source: std::io::Error,
output: HookOutput,
},
#[error("{0}")]
Internal(String),
}
impl HookError {
#[must_use]
pub fn output(&self) -> Option<&HookOutput> {
match self {
HookError::Failed { output, .. } | HookError::Spawn { output, .. } => Some(output),
HookError::Internal(_) => None,
}
}
}
impl From<String> for HookError {
fn from(value: String) -> Self {
Self::Internal(value)
}
}
fn hook_command_env(git_dir: Option<&Path>, extra_env: &[(String, String)]) -> CommandEnvironment {
let mut env = CommandEnvironment::inherit_process_only();
if let Some(gd) = git_dir {
env.set
.push((OsString::from("GIT_DIR"), gd.as_os_str().to_os_string()));
}
for (k, v) in extra_env {
env.set
.push((OsString::from(k.as_str()), OsString::from(v.as_str())));
}
env
}
#[derive(Debug, Clone, Default)]
pub struct CommitHookEnv<'a> {
pub index_file: Option<&'a Path>,
pub git_editor: Option<&'a str>,
pub git_prefix: Option<&'a str>,
pub extra_env: &'a [(&'a str, &'a str)],
}
fn absolute_index_path(index_file: &Path, cwd: &Path) -> PathBuf {
if index_file.is_absolute() {
index_file.to_path_buf()
} else {
cwd.join(index_file)
}
}
fn git_prefix_for_invocation(repo: &Repository, invocation_cwd: &Path) -> String {
let Some(wt) = repo.work_tree() else {
return String::new();
};
if invocation_cwd == repo.git_dir() {
return String::new();
}
let wt_canon = wt.canonicalize().unwrap_or_else(|_| wt.to_path_buf());
let wd_canon = invocation_cwd
.canonicalize()
.unwrap_or_else(|_| invocation_cwd.to_path_buf());
let rel = wd_canon.strip_prefix(&wt_canon).ok();
let Some(rel) = rel else {
return String::new();
};
let Some(s) = rel.to_str() else {
return String::new();
};
if s.is_empty() {
return String::new();
}
let mut out = s.replace('\\', "/");
if !out.ends_with('/') {
out.push('/');
}
out
}
fn build_commit_hook_env(
repo: &Repository,
_work_dir: &Path,
opts: &CommitHookEnv<'_>,
) -> Vec<(String, String)> {
let mut env: Vec<(String, String)> = Vec::new();
if let Some(p) = opts.index_file {
env.push((
"GIT_INDEX_FILE".to_string(),
absolute_index_path(p, &repo.environment().cwd)
.to_string_lossy()
.into_owned(),
));
}
let invocation_cwd = repo.environment().discovery_cwd();
let prefix = opts
.git_prefix
.map(|s| s.to_string())
.unwrap_or_else(|| git_prefix_for_invocation(repo, &invocation_cwd));
env.push(("GIT_PREFIX".to_string(), prefix));
if let Some(ed) = opts.git_editor {
env.push(("GIT_EDITOR".to_string(), ed.to_string()));
}
for (k, v) in opts.extra_env {
env.push(((*k).to_string(), (*v).to_string()));
}
env
}
fn parse_maybe_bool(value: &str) -> Option<bool> {
let v = value.trim().to_ascii_lowercase();
match v.as_str() {
"true" | "yes" | "on" | "1" => Some(true),
"false" | "no" | "off" | "0" => Some(false),
_ => None,
}
}
fn parse_hook_config_key(key: &str) -> Option<(&str, &str)> {
let rest = key.strip_prefix("hook.")?;
let (subsection, var) = rest.rsplit_once('.')?;
if subsection.is_empty() || var.is_empty() {
return None;
}
Some((subsection, var))
}
#[derive(Debug, Default)]
struct HookConfigTables {
commands: HashMap<String, String>,
event_hooks: HashMap<String, VecDeque<String>>,
disabled: HashSet<String>,
}
impl HookConfigTables {
fn apply_entry(&mut self, key: &str, value: Option<&str>) {
let Some((hook_name, subkey)) = parse_hook_config_key(key) else {
return;
};
let Some(value) = value else {
return;
};
let hook_name = hook_name.to_string();
match subkey {
"event" => {
if value.is_empty() {
for hooks in self.event_hooks.values_mut() {
hooks.retain(|n| n != &hook_name);
}
} else {
let event = value.to_string();
let hooks = self.event_hooks.entry(event).or_default();
hooks.retain(|n| n != &hook_name);
hooks.push_back(hook_name);
}
}
"command" => {
self.commands.insert(hook_name, value.to_string());
}
"enabled" => match parse_maybe_bool(value) {
Some(false) => {
self.disabled.insert(hook_name);
}
Some(true) => {
self.disabled.remove(&hook_name);
}
None => {}
},
_ => {}
}
}
fn from_config(config: &ConfigSet) -> Self {
let mut t = Self::default();
for e in config.entries() {
t.apply_entry(&e.key, e.value.as_deref());
}
t
}
fn hooks_for_event(&self, event: &str) -> Result<Vec<(String, String)>, String> {
let Some(names) = self.event_hooks.get(event) else {
return Ok(Vec::new());
};
let mut out = Vec::new();
for name in names {
if self.disabled.contains(name) {
continue;
}
let Some(cmd) = self.commands.get(name) else {
return Err(format!(
"'hook.{name}.command' must be configured or 'hook.{name}.event' must be removed; aborting."
));
};
out.push((name.clone(), cmd.clone()));
}
Ok(out)
}
}
#[derive(Debug)]
enum ResolvedHook {
Configured { command: String },
Traditional { path: PathBuf, argv0: PathBuf },
}
pub fn resolve_hooks_dir(repo: &Repository) -> PathBuf {
resolve_hooks_dir_for_config(
repo.environment(),
Some(repo.git_dir()),
repo.config().ok().as_deref(),
)
}
fn resolve_hooks_dir_for_config(
env: &crate::environment::Environment,
git_dir: Option<&Path>,
config: Option<&ConfigSet>,
) -> PathBuf {
let dir = match config.and_then(|cfg| cfg.get("core.hooksPath")) {
Some(hooks_path) => {
let p = PathBuf::from(parse_path(env, &hooks_path));
if p.is_absolute() {
p
} else {
env.cwd.join(p)
}
}
None => git_dir
.map(|gd| crate::repo::common_git_dir_for_config(gd).join("hooks"))
.unwrap_or_else(|| PathBuf::from("hooks")),
};
path_without_verbatim_prefix(&dir).into_owned()
}
fn hook_argv0(repo: &Repository, hooks_dir: &Path, hook_name: &str, cwd: &Path) -> PathBuf {
let default_hooks_dir = repo.git_dir().join("hooks");
if same_dir(hooks_dir, &default_hooks_dir) {
if cwd == repo.git_dir() {
return PathBuf::from("hooks").join(hook_name);
}
if let Some(work_tree) = repo.work_tree() {
if cwd == work_tree && same_dir(repo.git_dir(), &work_tree.join(".git")) {
return PathBuf::from(".git").join("hooks").join(hook_name);
}
}
}
hooks_dir.join(hook_name)
}
fn same_dir(a: &Path, b: &Path) -> bool {
if a == b {
return true;
}
match (a.canonicalize(), b.canonicalize()) {
(Ok(ca), Ok(cb)) => ca == cb,
_ => false,
}
}
#[cfg_attr(not(unix), allow(unused_variables))]
fn traditional_hook_candidate(
repo: &Repository,
hooks_dir: &Path,
hook_name: &str,
) -> Option<PathBuf> {
let path = hooks_dir.join(hook_name);
if !path.exists() {
return None;
}
let meta = fs::metadata(&path).ok()?;
#[cfg(unix)]
if meta.permissions().mode() & 0o111 == 0 {
let config = repo.config().ok();
let show_warning = config
.as_deref()
.and_then(|c| c.get("advice.ignoredHook"))
.map(|v| !matches!(v.to_lowercase().as_str(), "false" | "no" | "off" | "0"))
.unwrap_or(true);
if show_warning {
repo.warn(crate::diagnostics::Warning::NonExecutableHookIgnored {
hook_name: hook_name.to_string(),
});
}
return None;
}
Some(path)
}
fn resolve_configured_hooks_only(
hook_name: &str,
config: &ConfigSet,
) -> Result<Vec<ResolvedHook>, String> {
let tables = HookConfigTables::from_config(config);
let mut seq = Vec::new();
for (_friendly, command) in tables.hooks_for_event(hook_name)? {
seq.push(ResolvedHook::Configured { command });
}
Ok(seq)
}
fn resolve_hook_sequence(
repo: &Repository,
hook_name: &str,
config: &ConfigSet,
) -> Result<Vec<ResolvedHook>, String> {
let tables = HookConfigTables::from_config(config);
let mut seq = Vec::new();
for (_friendly, command) in tables.hooks_for_event(hook_name)? {
seq.push(ResolvedHook::Configured { command });
}
let hooks_dir =
resolve_hooks_dir_for_config(repo.environment(), Some(repo.git_dir()), Some(config));
if let Some(path) = traditional_hook_candidate(repo, &hooks_dir, hook_name) {
let work_dir = repo.work_tree().unwrap_or(repo.git_dir());
let argv0 = hook_argv0(repo, &hooks_dir, hook_name, work_dir);
seq.push(ResolvedHook::Traditional { path, argv0 });
}
Ok(seq)
}
#[cfg_attr(not(windows), allow(dead_code))]
fn shebang_interpreter(head: &[u8]) -> Option<String> {
let rest = head.strip_prefix(b"#!")?;
let line_len = rest
.iter()
.position(|&b| b == b'\n' || b == b'\r')
.unwrap_or(rest.len());
let line = std::str::from_utf8(&rest[..line_len]).ok()?;
let basename = |word: &str| word.rsplit(['/', '\\']).next().map(str::to_owned);
let mut words = line.split_ascii_whitespace();
let mut name = basename(words.next()?)?;
if name == "env" {
name = basename(words.find(|w| !w.starts_with('-'))?)?;
}
(!name.is_empty()).then_some(name)
}
const HOOK_PATH_VARS: [&str; 5] = [
"GIT_DIR",
"GIT_COMMON_DIR",
"GIT_WORK_TREE",
"GIT_INDEX_FILE",
"GIT_OBJECT_DIRECTORY",
];
fn strip_verbatim_prefix_from_path_vars(set: &mut [(OsString, OsString)]) {
for (key, value) in set.iter_mut() {
if !HOOK_PATH_VARS.iter().any(|var| key == *var) {
continue;
}
let Some(s) = value.to_str() else {
continue;
};
let stripped = strip_verbatim_path_prefix_str(s);
if stripped.len() != s.len() {
let stripped = OsString::from(stripped.into_owned());
*value = stripped;
}
}
}
fn hook_shell(env: &CommandEnvironment) -> ScriptInterpreter {
crate::command_runner::shell_interpreter(env)
}
#[cfg(windows)]
fn script_shebang_interpreter(
script: &Path,
env: &CommandEnvironment,
) -> Option<ScriptInterpreter> {
use std::io::Read;
let mut head = [0u8; 256];
let mut file = fs::File::open(script).ok()?;
let n = file.read(&mut head).ok()?;
let name = shebang_interpreter(&head[..n])?;
Some(crate::command_runner::resolve_script_interpreter(
&name,
crate::command_runner::child_path_var(env).as_deref(),
))
}
struct HookSpawnContext<'a> {
runner: &'a dyn CommandRunner,
cwd: &'a Path,
env: &'a CommandEnvironment,
stdin_piped: bool,
stdout: CommandStdio,
stderr: CommandStdio,
}
impl HookSpawnContext<'_> {
fn spec(&self, program: PathBuf, shell: Option<ShellInvocation>, args: &[&str]) -> CommandSpec {
CommandSpec {
program,
args: if shell.is_some() {
Vec::new()
} else {
args.iter().map(OsString::from).collect()
},
shell,
cwd: Some(path_without_verbatim_prefix(self.cwd).into_owned()),
env: self.env.clone(),
stdin: if self.stdin_piped {
CommandStdin::Pipe(Vec::new())
} else {
CommandStdin::Inherit
},
stdout: self.stdout,
stderr: self.stderr,
}
}
fn interpreter_spec(
&self,
interpreter: ScriptInterpreter,
invocation: ShellInvocation,
args: &[&str],
) -> CommandSpec {
let mut spec = self.spec(interpreter.program, Some(invocation), args);
if let Some(root) = &interpreter.git_root {
prepend_to_child_path(&mut spec.env, &git_for_windows_tool_dirs(root));
}
spec
}
fn spawn(&self, spec: &CommandSpec, hook_name: &str) -> Result<RunningCommand, HookError> {
self.runner.spawn(spec).map_err(|source| HookError::Spawn {
hook_name: hook_name.to_owned(),
path: spec.program.clone(),
source,
output: HookOutput::default(),
})
}
#[cfg_attr(not(windows), allow(unused_variables))]
fn spawn_traditional(
&self,
hook_name: &str,
script: &Path,
argv0: &Path,
args: &[&str],
) -> Result<RunningCommand, HookError> {
let argv0 = path_without_verbatim_prefix(argv0);
let via_shell = |shell: ScriptInterpreter| {
let invocation = ShellInvocation::ScriptPath {
script_path: argv0.to_path_buf(),
args: args.iter().map(OsString::from).collect(),
};
self.interpreter_spec(shell, invocation, args)
};
#[cfg(windows)]
if let Some(interpreter) = script_shebang_interpreter(script, self.env) {
return self.spawn(&via_shell(interpreter), hook_name);
}
let direct = self.spec(argv0.to_path_buf(), None, args);
match self.runner.spawn(&direct) {
Err(e) if is_not_an_executable(&e) => {
self.spawn(&via_shell(hook_shell(self.env)), hook_name)
}
Err(source) => Err(HookError::Spawn {
hook_name: hook_name.to_owned(),
path: direct.program,
source,
output: HookOutput::default(),
}),
Ok(child) => Ok(child),
}
}
fn spawn_configured(
&self,
hook_name: &str,
command: &str,
args: &[&str],
) -> Result<RunningCommand, HookError> {
let invocation = ShellInvocation::DashC {
script: command.to_owned(),
argv0: Some("hook".to_owned()),
args: args.iter().map(OsString::from).collect(),
};
self.spawn(
&self.interpreter_spec(hook_shell(self.env), invocation, args),
hook_name,
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HookName {
PreCommit,
CommitMsg,
PostCommit,
PostCheckout,
PostMerge,
PostRewrite,
}
impl HookName {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::PreCommit => "pre-commit",
Self::CommitMsg => "commit-msg",
Self::PostCommit => "post-commit",
Self::PostCheckout => "post-checkout",
Self::PostMerge => "post-merge",
Self::PostRewrite => "post-rewrite",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
pub enum PostOperationHooks {
#[default]
Run,
Skip,
}
impl PostOperationHooks {
#[must_use]
pub const fn enabled(self) -> bool {
matches!(self, Self::Run)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RewriteKind {
Amend,
Rebase,
}
impl RewriteKind {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Amend => "amend",
Self::Rebase => "rebase",
}
}
}
pub(crate) fn run_advisory_hook(
repo: &Repository,
hook: HookName,
args: &[&str],
stdin_data: Option<&[u8]>,
commit_env: Option<&CommitHookEnv<'_>>,
) -> bool {
let result = match commit_env {
Some(env) => run_commit_hook(repo, hook.as_str(), args, stdin_data, env),
None => run_hook(repo, hook.as_str(), args, stdin_data),
};
match result {
Ok(result) => result.was_executed(),
Err(err) => {
let mut detail = err.to_string();
let mut cause = std::error::Error::source(&err);
while let Some(c) = cause {
detail.push_str(": ");
detail.push_str(&c.to_string());
cause = c.source();
}
repo.warn(crate::diagnostics::Warning::HookNotRun {
hook_name: hook.as_str().to_owned(),
detail,
});
false
}
}
}
pub fn run_post_checkout(
repo: &Repository,
old: Option<ObjectId>,
new: ObjectId,
branch_checkout: bool,
) -> bool {
let old_hex = old
.unwrap_or_else(|| ObjectId::null(repo.odb().hash_algo()))
.to_hex();
let new_hex = new.to_hex();
let flag = if branch_checkout { "1" } else { "0" };
run_advisory_hook(
repo,
HookName::PostCheckout,
&[old_hex.as_str(), new_hex.as_str(), flag],
None,
None,
)
}
pub fn run_post_merge(repo: &Repository, squash: bool) -> bool {
run_advisory_hook(
repo,
HookName::PostMerge,
&[if squash { "1" } else { "0" }],
None,
None,
)
}
#[must_use]
pub fn post_rewrite_stdin(pairs: &[(ObjectId, ObjectId)]) -> Vec<u8> {
let mut out = Vec::with_capacity(pairs.len() * 82);
for (old, new) in pairs {
out.extend_from_slice(old.to_hex().as_bytes());
out.push(b' ');
out.extend_from_slice(new.to_hex().as_bytes());
out.push(b'\n');
}
out
}
pub fn run_post_rewrite(
repo: &Repository,
kind: RewriteKind,
pairs: &[(ObjectId, ObjectId)],
) -> bool {
if pairs.is_empty() {
return false;
}
let stdin = post_rewrite_stdin(pairs);
run_advisory_hook(
repo,
HookName::PostRewrite,
&[kind.as_str()],
Some(&stdin),
None,
)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HookResult {
Success {
output: HookOutput,
},
NotFound,
Failed {
status: i32,
output: HookOutput,
},
}
impl HookResult {
#[must_use]
pub fn is_ok(&self) -> bool {
matches!(self, HookResult::Success { .. } | HookResult::NotFound)
}
#[must_use]
pub fn was_executed(&self) -> bool {
!matches!(self, HookResult::NotFound)
}
#[must_use]
pub fn status(&self) -> Option<i32> {
match self {
HookResult::Success { .. } => Some(0),
HookResult::NotFound => None,
HookResult::Failed { status, .. } => Some(*status),
}
}
#[must_use]
pub fn output(&self) -> Option<&HookOutput> {
match self {
HookResult::Success { output } | HookResult::Failed { output, .. } => Some(output),
HookResult::NotFound => None,
}
}
pub fn check(self, hook_name: &str) -> Result<bool, HookError> {
match self {
HookResult::Success { .. } => Ok(true),
HookResult::NotFound => Ok(false),
HookResult::Failed { status, output } => Err(HookError::Failed {
hook_name: hook_name.to_owned(),
status,
output,
}),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct RunHookOptions<'a> {
pub stdio: HookStdio,
pub stdout_to_stderr: bool,
pub path_to_stdin: Option<&'a Path>,
pub stdin_data: Option<&'a [u8]>,
pub env_vars: &'a [(&'a str, &'a str)],
pub cwd: Option<&'a Path>,
pub commit_env: Option<&'a CommitHookEnv<'a>>,
}
pub fn run_hook_opts(
repo: Option<&Repository>,
hook_name: &str,
args: &[&str],
config: &ConfigSet,
opts: RunHookOptions<'_>,
) -> Result<HookResult, HookError> {
let seq = match repo {
Some(r) => resolve_hook_sequence(r, hook_name, config)?,
None => resolve_configured_hooks_only(hook_name, config)?,
};
if seq.is_empty() {
return Ok(HookResult::NotFound);
}
let work_dir: PathBuf = opts.cwd.map_or_else(
|| match repo {
Some(r) => r
.work_tree()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| r.git_dir().to_path_buf()),
None => PathBuf::from("."),
},
Path::to_path_buf,
);
let work_dir = work_dir.as_path();
let git_dir_for_configured = repo.map(|r| r.git_dir());
let runner: Arc<dyn CommandRunner> = match repo {
Some(r) => r.command_runner(),
None => system_command_runner(),
};
let mut merged_env: Vec<(String, String)> = opts
.env_vars
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect();
if let Some(r) = repo {
if let Some(ce) = opts.commit_env {
merged_env.extend(build_commit_hook_env(r, work_dir, ce));
}
}
let mut hook_env = match repo {
Some(r) => CommandEnvironment::from_repository_environment(r.environment(), &merged_env),
None => hook_command_env(git_dir_for_configured, &merged_env),
};
if cfg!(windows) {
strip_verbatim_prefix_from_path_vars(&mut hook_env.set);
}
let capture = opts.stdio == HookStdio::Capture;
let (stdout, stderr) = match opts.stdio {
HookStdio::Capture => (CommandStdio::Pipe, CommandStdio::Pipe),
HookStdio::Inherit if opts.stdout_to_stderr => {
(CommandStdio::ParentStderr, CommandStdio::Inherit)
}
HookStdio::Inherit => (CommandStdio::Inherit, CommandStdio::Inherit),
};
let mut output = HookOutput::default();
for h in &seq {
let (stdin_piped, stdin_file) = match opts.path_to_stdin {
Some(p) => (true, Some(p.to_path_buf())),
None => (opts.stdin_data.is_some(), None),
};
let ctx = HookSpawnContext {
runner: runner.as_ref(),
cwd: work_dir,
env: &hook_env,
stdin_piped,
stdout,
stderr,
};
let spawned = match h {
ResolvedHook::Traditional { path, argv0 } => {
let Some(r) = repo else {
continue;
};
let effective_argv0 = path
.parent()
.map(|hooks_dir| hook_argv0(r, hooks_dir, hook_name, work_dir))
.unwrap_or_else(|| argv0.clone());
ctx.spawn_traditional(hook_name, path, &effective_argv0, args)
}
ResolvedHook::Configured { command } => ctx.spawn_configured(hook_name, command, args),
};
let mut child = match spawned {
Ok(child) => child,
Err(HookError::Spawn {
hook_name,
path,
source,
..
}) => {
return Err(HookError::Spawn {
hook_name,
path,
source,
output,
})
}
Err(other) => return Err(other),
};
if let Some(ref path) = stdin_file {
let file = match fs::File::open(path) {
Ok(f) => f,
Err(_e) => {
return Ok(HookResult::Failed { status: 1, output });
}
};
if let Some(stdin) = child.stdin_mut() {
let mut file = file;
let _ = std::io::copy(&mut file, stdin);
}
} else if let Some(data) = opts.stdin_data {
if let Some(stdin) = child.stdin_mut() {
let _ = stdin.write_all(data);
}
}
if stdin_piped {
child.close_stdin();
}
let waited = if capture {
child.wait_with_output().map(|o| {
output.stdout.extend_from_slice(&o.stdout);
output.stderr.extend_from_slice(&o.stderr);
o.status
})
} else {
child.wait()
};
let exit = match waited {
Ok(exit) => exit,
Err(_) => return Ok(HookResult::Failed { status: 1, output }),
};
if !exit.success {
if let Some(r) = repo {
let _ = r.reload_config();
}
return Ok(HookResult::Failed {
status: exit.code.unwrap_or(1),
output,
});
}
}
if let Some(r) = repo {
let _ = r.reload_config();
}
Ok(HookResult::Success { output })
}
pub fn run_commit_hook_checked(
repo: &Repository,
hook_name: &str,
args: &[&str],
stdin_data: Option<&[u8]>,
commit_env: &CommitHookEnv<'_>,
) -> Result<(), HookError> {
run_commit_hook(repo, hook_name, args, stdin_data, commit_env)?
.check(hook_name)
.map(drop)
}
pub fn run_commit_hook(
repo: &Repository,
hook_name: &str,
args: &[&str],
stdin_data: Option<&[u8]>,
commit_env: &CommitHookEnv<'_>,
) -> Result<HookResult, HookError> {
let config = repo
.config()
.map_err(|e| HookError::Internal(e.to_string()))?
.as_ref()
.clone();
run_hook_opts(
Some(repo),
hook_name,
args,
&config,
RunHookOptions {
stdin_data,
commit_env: Some(commit_env),
..repo_hook_options(repo, hook_name)
},
)
}
fn repo_hook_options<'a>(repo: &Repository, hook_name: &str) -> RunHookOptions<'a> {
RunHookOptions {
stdio: repo.hook_stdio(),
stdout_to_stderr: hook_name != "pre-push",
..RunHookOptions::default()
}
}
pub fn run_hook(
repo: &Repository,
hook_name: &str,
args: &[&str],
stdin_data: Option<&[u8]>,
) -> Result<HookResult, HookError> {
let config = repo
.config()
.map_err(|e| HookError::Internal(e.to_string()))?;
run_hook_opts(
Some(repo),
hook_name,
args,
config.as_ref(),
RunHookOptions {
stdin_data,
..repo_hook_options(repo, hook_name)
},
)
}
pub fn run_hook_in_git_dir(
repo: &Repository,
hook_name: &str,
args: &[&str],
stdin_data: Option<&[u8]>,
env_vars: &[(&str, &str)],
) -> HookResult {
let declined = || HookResult::Failed {
status: 1,
output: HookOutput::default(),
};
let env_vars: Vec<(&str, &str)> = std::iter::once(("GIT_DIR", "."))
.chain(env_vars.iter().copied())
.collect();
let Some(config) = repo.config().ok() else {
return declined();
};
run_hook_opts(
Some(repo),
hook_name,
args,
config.as_ref(),
RunHookOptions {
stdio: HookStdio::Capture,
stdout_to_stderr: true,
stdin_data,
env_vars: &env_vars,
cwd: Some(repo.git_dir()),
..RunHookOptions::default()
},
)
.unwrap_or_else(|_| declined())
}
pub fn run_reference_transaction_committed_for_head_update(
repo: &Repository,
head: &HeadState,
old_head_commit: Option<ObjectId>,
new_oid: ObjectId,
) -> Result<HookResult, HookError> {
let zero = ObjectId::zero();
let old_oid = old_head_commit.unwrap_or(zero);
let old_hex = if old_oid == zero {
"0000000000000000000000000000000000000000".to_owned()
} else {
old_oid.to_hex()
};
let new_hex = new_oid.to_hex();
let mut stdin = String::new();
match head {
HeadState::Branch { refname, .. } => {
stdin.push_str(&format!("{old_hex} {new_hex} HEAD\n"));
stdin.push_str(&format!("{old_hex} {new_hex} {refname}\n"));
}
_ => {
stdin.push_str(&format!("{old_hex} {new_hex} HEAD\n"));
}
}
run_hook(
repo,
"reference-transaction",
&["committed"],
Some(stdin.as_bytes()),
)
}
#[cfg(test)]
mod tests {
use super::*;
fn touch(path: &Path) {
fs::create_dir_all(path.parent().expect("parent")).expect("mkdir");
fs::write(path, b"").expect("write");
}
fn exe(name: &str) -> String {
format!("{name}{}", std::env::consts::EXE_SUFFIX)
}
#[test]
fn hook_result_reports_status_output_and_checks() {
let output = HookOutput {
stdout: b"out".to_vec(),
stderr: b"err".to_vec(),
};
assert!(!output.is_empty());
assert!(HookOutput::default().is_empty());
assert_eq!(HookError::Internal("bad".into()).output(), None);
assert_eq!(
format!("{output:?}"),
r#"HookOutput { stdout: "out", stderr: "err" }"#
);
let success = HookResult::Success {
output: output.clone(),
};
assert!(success.is_ok() && success.was_executed());
assert_eq!(success.status(), Some(0));
assert_eq!(success.output(), Some(&output));
assert!(success.check("pre-commit").expect("success"));
let missing = HookResult::NotFound;
assert!(missing.is_ok() && !missing.was_executed());
assert_eq!((missing.status(), missing.output()), (None, None));
assert!(!missing.check("pre-commit").expect("not found"));
let failed = HookResult::Failed {
status: 3,
output: output.clone(),
};
assert!(!failed.is_ok() && failed.was_executed());
assert_eq!(failed.status(), Some(3));
match failed.check("commit-msg") {
Err(err @ HookError::Failed { .. }) => {
assert_eq!(err.to_string(), "hook commit-msg failed with exit status 3");
let HookError::Failed {
hook_name,
status,
output: got,
} = err
else {
unreachable!()
};
assert_eq!((hook_name.as_str(), status), ("commit-msg", 3));
assert_eq!(got, output);
}
other => panic!("expected HookError::Failed, got {other:?}"),
}
}
#[test]
fn post_rewrite_stdin_is_one_old_new_line_per_pair() {
let a = ObjectId::from_hex("1111111111111111111111111111111111111111").expect("a");
let b = ObjectId::from_hex("2222222222222222222222222222222222222222").expect("b");
assert_eq!(post_rewrite_stdin(&[]), b"");
assert_eq!(
post_rewrite_stdin(&[(a, b), (b, a)]),
format!(
"{} {}\n{} {}\n",
a.to_hex(),
b.to_hex(),
b.to_hex(),
a.to_hex()
)
.into_bytes()
);
}
#[test]
fn post_operation_names_and_flags() {
assert_eq!(HookName::PostCheckout.as_str(), "post-checkout");
assert_eq!(HookName::PostMerge.as_str(), "post-merge");
assert_eq!(HookName::PostRewrite.as_str(), "post-rewrite");
assert_eq!(RewriteKind::Amend.as_str(), "amend");
assert_eq!(RewriteKind::Rebase.as_str(), "rebase");
assert!(PostOperationHooks::default().enabled());
assert!(!PostOperationHooks::Skip.enabled());
}
#[test]
fn shebang_interpreter_takes_the_interpreter_basename() {
assert_eq!(
shebang_interpreter(b"#!/bin/sh\nexit 0\n").as_deref(),
Some("sh")
);
assert_eq!(
shebang_interpreter(b"#!/bin/bash -e\n").as_deref(),
Some("bash")
);
assert_eq!(shebang_interpreter(b"#! /bin/sh\n").as_deref(), Some("sh"));
assert_eq!(
shebang_interpreter(b"#!/bin/sh\r\nexit 0\r\n").as_deref(),
Some("sh")
);
assert_eq!(shebang_interpreter(b"#!/bin/sh").as_deref(), Some("sh"));
assert_eq!(
shebang_interpreter(br"#!C:\tools\perl.exe").as_deref(),
Some("perl.exe")
);
}
#[test]
fn shebang_interpreter_looks_through_env() {
assert_eq!(
shebang_interpreter(b"#!/usr/bin/env bash\n").as_deref(),
Some("bash")
);
assert_eq!(
shebang_interpreter(b"#!/usr/bin/env -S python3 -u\n").as_deref(),
Some("python3")
);
assert_eq!(shebang_interpreter(b"#!/usr/bin/env\n"), None);
}
#[test]
fn shebang_interpreter_rejects_non_scripts() {
assert_eq!(shebang_interpreter(b"exit 0\n"), None);
assert_eq!(shebang_interpreter(b""), None);
assert_eq!(shebang_interpreter(b"#!\n"), None);
assert_eq!(shebang_interpreter(b"#!/\n"), None);
assert_eq!(shebang_interpreter(b"\x7fELF\x02\x01"), None);
}
#[cfg(unix)]
#[test]
fn hook_shell_is_bin_sh_on_unix() {
let mut env = CommandEnvironment::default();
env.set.push(("PATH".into(), "/nonexistent".into()));
assert_eq!(hook_shell(&env), ScriptInterpreter::bare("/bin/sh"));
}
#[test]
fn interpreter_from_git_tree_gets_its_tool_dirs_on_path() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().join("Git");
touch(&root.join("cmd").join(exe("git")));
touch(&root.join("mingw64").join("bin").join(exe("git")));
touch(&root.join("usr").join("bin").join(exe("sh")));
let mut env = CommandEnvironment::default();
env.set
.push(("PATH".into(), root.join("cmd").into_os_string()));
let runner = crate::command_runner::RecordingRunner::always_success();
let ctx = HookSpawnContext {
runner: runner.as_ref(),
cwd: tmp.path(),
env: &env,
stdin_piped: false,
stdout: CommandStdio::Inherit,
stderr: CommandStdio::Inherit,
};
let dash_c = || ShellInvocation::DashC {
script: "true".into(),
argv0: Some("hook".into()),
args: Vec::new(),
};
let interpreter = crate::command_runner::resolve_script_interpreter(
"sh",
Some(root.join("cmd").as_os_str()),
);
let spec = ctx.interpreter_spec(interpreter, dash_c(), &[]);
assert_eq!(spec.program, root.join("usr").join("bin").join(exe("sh")));
let expected = std::env::join_paths([
root.join("mingw64").join("bin"),
root.join("usr").join("bin"),
root.join("cmd"),
])
.expect("join");
assert_eq!(
crate::command_runner::child_path_var(&spec.env),
Some(expected)
);
let bare = ctx.interpreter_spec(ScriptInterpreter::bare("/bin/sh"), dash_c(), &[]);
assert_eq!(
crate::command_runner::child_path_var(&bare.env),
Some(root.join("cmd").into_os_string()),
"interpreters outside a Git tree keep PATH as is"
);
}
#[test]
fn verbatim_prefixes_are_stripped_from_path_vars() {
let mut set: Vec<(OsString, OsString)> = vec![
("GIT_INDEX_FILE".into(), r"\\?\C:\repo\.git\index".into()),
("GIT_DIR".into(), r"\\?\UNC\server\share\repo\.git".into()),
("GIT_WORK_TREE".into(), r"C:\repo".into()),
(
"GIT_OBJECT_DIRECTORY".into(),
r"\\?\C:\repo\.git\objects".into(),
),
("GIT_PREFIX".into(), r"\\?\kept".into()),
];
strip_verbatim_prefix_from_path_vars(&mut set);
let value = |key: &str| {
set.iter()
.find(|(k, _)| k == key)
.map(|(_, v)| v.clone())
.expect("present")
};
assert_eq!(
value("GIT_INDEX_FILE"),
OsString::from(r"C:\repo\.git\index")
);
assert_eq!(
value("GIT_DIR"),
OsString::from(r"\\server\share\repo\.git")
);
assert_eq!(value("GIT_WORK_TREE"), OsString::from(r"C:\repo"));
assert_eq!(
value("GIT_OBJECT_DIRECTORY"),
OsString::from(r"C:\repo\.git\objects")
);
assert_eq!(value("GIT_PREFIX"), OsString::from(r"\\?\kept"));
}
#[test]
fn not_an_executable_matches_only_the_platform_code() {
#[cfg(unix)]
assert!(is_not_an_executable(&std::io::Error::from_raw_os_error(8)));
#[cfg(windows)]
assert!(is_not_an_executable(&std::io::Error::from_raw_os_error(
193
)));
assert!(!is_not_an_executable(&std::io::Error::from(
std::io::ErrorKind::NotFound
)));
}
}