use git2::Repository;
use crate::{error::Result, HookResult, HooksError};
use std::{
ffi::{OsStr, OsString},
path::{Path, PathBuf},
process::Command,
str::FromStr,
};
pub struct HookPaths {
pub git: PathBuf,
pub hook: PathBuf,
pub pwd: PathBuf,
}
const CONFIG_HOOKS_PATH: &str = "core.hooksPath";
const DEFAULT_HOOKS_PATH: &str = "hooks";
const ENOEXEC: i32 = 8;
impl HookPaths {
pub fn new(
repo: &Repository,
other_paths: Option<&[&str]>,
hook: &str,
) -> Result<Self> {
let pwd = repo
.workdir()
.unwrap_or_else(|| repo.path())
.to_path_buf();
let git_dir = repo.path().to_path_buf();
if let Some(config_path) = Self::config_hook_path(repo)? {
let hooks_path = PathBuf::from(config_path);
let hook =
Self::expand_path(&hooks_path.join(hook), &pwd)?;
return Ok(Self {
git: git_dir,
hook,
pwd,
});
}
Ok(Self {
git: git_dir,
hook: Self::find_hook(repo, other_paths, hook),
pwd,
})
}
fn expand_path(path: &Path, pwd: &Path) -> Result<PathBuf> {
let hook_expanded = shellexpand::full(
path.as_os_str()
.to_str()
.ok_or(HooksError::PathToString)?,
)?;
let hook_expanded = PathBuf::from_str(hook_expanded.as_ref())
.map_err(|_| HooksError::PathToString)?;
Ok({
if hook_expanded.is_absolute() {
hook_expanded
} else {
pwd.join(hook_expanded)
}
})
}
fn config_hook_path(repo: &Repository) -> Result<Option<String>> {
Ok(repo.config()?.get_string(CONFIG_HOOKS_PATH).ok())
}
fn find_hook(
repo: &Repository,
other_paths: Option<&[&str]>,
hook: &str,
) -> PathBuf {
let mut paths = vec![DEFAULT_HOOKS_PATH.to_string()];
if let Some(others) = other_paths {
paths.extend(
others
.iter()
.map(|p| p.trim_end_matches('/').to_string()),
);
}
for p in paths {
let p = repo.path().to_path_buf().join(p).join(hook);
if p.exists() {
return p;
}
}
repo.path()
.to_path_buf()
.join(DEFAULT_HOOKS_PATH)
.join(hook)
}
pub fn found(&self) -> bool {
self.hook.exists() && is_executable(&self.hook)
}
pub fn run_hook(&self, args: &[&str]) -> Result<HookResult> {
self.run_hook_os_str(args)
}
pub fn run_hook_os_str<I, S>(&self, args: I) -> Result<HookResult>
where
I: IntoIterator<Item = S> + Copy,
S: AsRef<OsStr>,
{
self.run_hook_os_str_with_stdin(args, None)
}
pub fn run_hook_os_str_with_stdin<I, S>(
&self,
args: I,
stdin: Option<&[u8]>,
) -> Result<HookResult>
where
I: IntoIterator<Item = S> + Copy,
S: AsRef<OsStr>,
{
let hook = self.hook.clone();
log::trace!(
"run hook '{}' in '{}'",
hook.display(),
self.pwd.display()
);
let run_command = |command: &mut Command| {
let mut child = command
.args(args)
.current_dir(&self.pwd)
.with_no_window()
.stdin(if stdin.is_some() {
std::process::Stdio::piped()
} else {
std::process::Stdio::null()
})
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()?;
if let (Some(mut stdin_handle), Some(input)) =
(child.stdin.take(), stdin)
{
use std::io::{ErrorKind, Write};
let _ =
stdin_handle.write_all(input).inspect_err(|e| {
match e.kind() {
ErrorKind::BrokenPipe => {
log::debug!(
"Hook closed stdin early"
);
}
_ => log::warn!(
"Failed to write stdin to hook: {e}"
),
}
});
}
child.wait_with_output()
};
let output = if cfg!(windows) {
let command = {
const REPLACEMENT: &str = concat!(
"'", "\\'", "'", );
let mut os_str = OsString::new();
os_str.push("'");
if let Some(hook) = hook.to_str() {
os_str.push(hook.replace('\'', REPLACEMENT));
} else {
#[cfg(windows)]
{
use std::os::windows::ffi::OsStrExt;
if hook
.as_os_str()
.encode_wide()
.any(|x| x == u16::from(b'\''))
{
return Err(HooksError::PathToString);
}
}
os_str.push(hook.as_os_str());
}
os_str.push("'");
os_str.push(" \"$@\"");
os_str
};
run_command(
sh_command().arg("-c").arg(command).arg(&hook),
)
} else {
match run_command(&mut Command::new(&hook)) {
Err(err) if err.raw_os_error() == Some(ENOEXEC) => {
run_command(sh_command().arg(&hook))
}
result => result,
}
}?;
let stderr =
String::from_utf8_lossy(&output.stderr).to_string();
let stdout =
String::from_utf8_lossy(&output.stdout).to_string();
let code =
output.status.code().ok_or(HooksError::NoExitCode)?;
Ok(HookResult::Run(crate::HookRunResponse {
hook,
stdout,
stderr,
code,
}))
}
}
fn sh_command() -> Command {
let mut command = Command::new(gix_path::env::shell());
if cfg!(windows) {
command.env(
"DUMMY_ENV_TO_FIX_WINDOWS_CMD_RUNS",
"FixPathHandlingOnWindows",
);
command.arg("-l");
}
command
}
#[cfg(unix)]
fn is_executable(path: &Path) -> bool {
use std::os::unix::fs::PermissionsExt;
let metadata = match path.metadata() {
Ok(metadata) => metadata,
Err(e) => {
log::error!("metadata error: {e}");
return false;
}
};
let permissions = metadata.permissions();
permissions.mode() & 0o111 != 0
}
#[cfg(windows)]
const fn is_executable(_: &Path) -> bool {
true
}
trait CommandExt {
#[cfg(windows)]
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
fn with_no_window(&mut self) -> &mut Self;
}
impl CommandExt for Command {
#[inline]
fn with_no_window(&mut self) -> &mut Self {
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
self.creation_flags(Self::CREATE_NO_WINDOW);
}
self
}
}
#[cfg(test)]
mod test {
use super::HookPaths;
use std::path::Path;
#[test]
fn test_hookspath_relative() {
assert_eq!(
HookPaths::expand_path(
Path::new("pre-commit"),
Path::new("example_git_root"),
)
.unwrap(),
Path::new("example_git_root").join("pre-commit")
);
}
#[test]
fn test_hookspath_absolute() {
let absolute_hook =
std::env::current_dir().unwrap().join("pre-commit");
assert_eq!(
HookPaths::expand_path(
&absolute_hook,
Path::new("example_git_root"),
)
.unwrap(),
absolute_hook
);
}
}