git2-hooks: allows customizing what places to look for hooks (#1975)

* allows customizing what places to look for hooks
This commit is contained in:
extrawurst 2023-12-08 14:33:22 +01:00 committed by GitHub
parent fd400cfcc7
commit 521ab91309
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 288 additions and 113 deletions

2
Cargo.lock generated
View File

@ -714,7 +714,7 @@ dependencies = [
[[package]] [[package]]
name = "git2-hooks" name = "git2-hooks"
version = "0.2.0" version = "0.3.0"
dependencies = [ dependencies = [
"git2", "git2",
"git2-testing", "git2-testing",

View File

@ -17,7 +17,7 @@ crossbeam-channel = "0.5"
easy-cast = "0.5" easy-cast = "0.5"
fuzzy-matcher = "0.3" fuzzy-matcher = "0.3"
git2 = "0.17" git2 = "0.17"
git2-hooks = { path = "../git2-hooks", version = "0.2" } git2-hooks = { path = "../git2-hooks", version = "0.3" }
log = "0.4" log = "0.4"
# git2 = { path = "../../extern/git2-rs", features = ["vendored-openssl"]} # git2 = { path = "../../extern/git2-rs", features = ["vendored-openssl"]}
# git2 = { git="https://github.com/extrawurst/git2-rs.git", rev="fc13dcc", features = ["vendored-openssl"]} # git2 = { git="https://github.com/extrawurst/git2-rs.git", rev="fc13dcc", features = ["vendored-openssl"]}

View File

@ -14,10 +14,13 @@ pub enum HookResult {
impl From<git2_hooks::HookResult> for HookResult { impl From<git2_hooks::HookResult> for HookResult {
fn from(v: git2_hooks::HookResult) -> Self { fn from(v: git2_hooks::HookResult) -> Self {
match v { match v {
git2_hooks::HookResult::Ok => Self::Ok, git2_hooks::HookResult::Ok { .. }
git2_hooks::HookResult::NotOk { stdout, stderr } => { | git2_hooks::HookResult::NoHookFound => Self::Ok,
Self::NotOk(format!("{stdout}{stderr}")) git2_hooks::HookResult::RunNotSuccessful {
} stdout,
stderr,
..
} => Self::NotOk(format!("{stdout}{stderr}")),
} }
} }
} }
@ -34,7 +37,7 @@ pub fn hooks_commit_msg(
let repo = repo(repo_path)?; let repo = repo(repo_path)?;
Ok(git2_hooks::hooks_commit_msg(&repo, msg)?.into()) Ok(git2_hooks::hooks_commit_msg(&repo, None, msg)?.into())
} }
/// this hook is documented here <https://git-scm.com/docs/githooks#_pre_commit> /// this hook is documented here <https://git-scm.com/docs/githooks#_pre_commit>
@ -44,7 +47,7 @@ pub fn hooks_pre_commit(repo_path: &RepoPath) -> Result<HookResult> {
let repo = repo(repo_path)?; let repo = repo(repo_path)?;
Ok(git2_hooks::hooks_pre_commit(&repo)?.into()) Ok(git2_hooks::hooks_pre_commit(&repo, None)?.into())
} }
/// ///
@ -53,7 +56,7 @@ pub fn hooks_post_commit(repo_path: &RepoPath) -> Result<HookResult> {
let repo = repo(repo_path)?; let repo = repo(repo_path)?;
Ok(git2_hooks::hooks_post_commit(&repo)?.into()) Ok(git2_hooks::hooks_post_commit(&repo, None)?.into())
} }
#[cfg(test)] #[cfg(test)]

View File

@ -1,11 +1,12 @@
[package] [package]
name = "git2-hooks" name = "git2-hooks"
version = "0.2.0" version = "0.3.0"
authors = ["extrawurst <mail@rusticorn.com>"] authors = ["extrawurst <mail@rusticorn.com>"]
edition = "2021" edition = "2021"
description = "adds git hooks support based on git2-rs" description = "adds git hooks support based on git2-rs"
homepage = "https://github.com/extrawurst/gitui" homepage = "https://github.com/extrawurst/gitui"
repository = "https://github.com/extrawurst/gitui" repository = "https://github.com/extrawurst/gitui"
documentation = "https://docs.rs/git2-hooks/"
readme = "README.md" readme = "README.md"
license = "MIT" license = "MIT"
categories = ["development-tools"] categories = ["development-tools"]

View File

@ -2,3 +2,7 @@
adds git hook functionality on top of git2-rs adds git hook functionality on top of git2-rs
## todo
- [ ] unittest coverage symlinks from `.git/hooks/<hook>` -> `X`
- [ ] unittest coverage `~` expansion inside `core.hooksPath`

View File

@ -3,6 +3,10 @@ use thiserror::Error;
/// ///
#[derive(Error, Debug)] #[derive(Error, Debug)]
pub enum HooksError { pub enum HooksError {
///
#[error("git error:{0}")]
Git(#[from] git2::Error),
/// ///
#[error("io error:{0}")] #[error("io error:{0}")]
Io(#[from] std::io::Error), Io(#[from] std::io::Error),

View File

@ -12,24 +12,31 @@ pub struct HookPaths {
pub pwd: PathBuf, pub pwd: PathBuf,
} }
const CONFIG_HOOKS_PATH: &str = "core.hooksPath";
const DEFAULT_HOOKS_PATH: &str = "hooks";
impl HookPaths { impl HookPaths {
pub fn new(repo: &Repository, hook: &str) -> Result<Self> { /// `core.hooksPath` always takes precendence.
/// If its defined and there is no hook `hook` this is not considered
/// an error or a reason to search in other paths.
/// If the config is not set we go into search mode and
/// first check standard `.git/hooks` folder and any sub path provided in `other_paths`.
///
/// Note: we try to model as closely as possible what git shell is doing.
pub fn new(
repo: &Repository,
other_paths: Option<&[&str]>,
hook: &str,
) -> Result<Self> {
let pwd = repo let pwd = repo
.workdir() .workdir()
.unwrap_or_else(|| repo.path()) .unwrap_or_else(|| repo.path())
.to_path_buf(); .to_path_buf();
let git_dir = repo.path().to_path_buf(); let git_dir = repo.path().to_path_buf();
let hooks_path = repo
.config() if let Some(config_path) = Self::config_hook_path(repo)? {
.and_then(|config| config.get_string("core.hooksPath")) let hooks_path = PathBuf::from(config_path);
.map_or_else(
|e| {
log::error!("hookspath error: {}", e);
repo.path().to_path_buf().join("hooks/")
},
PathBuf::from,
);
let hook = hooks_path.join(hook); let hook = hooks_path.join(hook);
@ -42,26 +49,69 @@ impl HookPaths {
let hook = PathBuf::from_str(hook.as_ref()) let hook = PathBuf::from_str(hook.as_ref())
.map_err(|_| HooksError::PathToString)?; .map_err(|_| HooksError::PathToString)?;
Ok(Self { return Ok(Self {
git: git_dir, git: git_dir,
hook, hook,
pwd, pwd,
});
}
Ok(Self {
git: git_dir,
hook: Self::find_hook(repo, other_paths, hook),
pwd,
}) })
} }
pub fn is_executable(&self) -> bool { fn config_hook_path(repo: &Repository) -> Result<Option<String>> {
Ok(repo.config()?.get_string(CONFIG_HOOKS_PATH).ok())
}
/// check default hook path first and then followed by `other_paths`.
/// if no hook is found we return the default hook path
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)
}
/// was a hook file found and is it executable
pub fn found(&self) -> bool {
self.hook.exists() && is_executable(&self.hook) self.hook.exists() && is_executable(&self.hook)
} }
/// this function calls hook scripts based on conventions documented here /// this function calls hook scripts based on conventions documented here
/// see <https://git-scm.com/docs/githooks> /// see <https://git-scm.com/docs/githooks>
pub fn run_hook(&self, args: &[&str]) -> Result<HookResult> { pub fn run_hook(&self, args: &[&str]) -> Result<HookResult> {
let arg_str = format!("{:?} {}", self.hook, args.join(" ")); let hook = self.hook.clone();
let arg_str = format!("{:?} {}", hook, args.join(" "));
// Use -l to avoid "command not found" on Windows. // Use -l to avoid "command not found" on Windows.
let bash_args = let bash_args =
vec!["-l".to_string(), "-c".to_string(), arg_str]; vec!["-l".to_string(), "-c".to_string(), arg_str];
log::trace!("run hook '{:?}' in '{:?}'", self.hook, self.pwd); log::trace!("run hook '{:?}' in '{:?}'", hook, self.pwd);
let git_bash = find_bash_executable() let git_bash = find_bash_executable()
.unwrap_or_else(|| PathBuf::from("bash")); .unwrap_or_else(|| PathBuf::from("bash"));
@ -78,19 +128,24 @@ impl HookPaths {
.output()?; .output()?;
if output.status.success() { if output.status.success() {
Ok(HookResult::Ok) Ok(HookResult::Ok { hook })
} else { } else {
let stderr = let stderr =
String::from_utf8_lossy(&output.stderr).to_string(); String::from_utf8_lossy(&output.stderr).to_string();
let stdout = let stdout =
String::from_utf8_lossy(&output.stdout).to_string(); String::from_utf8_lossy(&output.stdout).to_string();
Ok(HookResult::NotOk { stdout, stderr }) Ok(HookResult::RunNotSuccessful {
code: output.status.code(),
stdout,
stderr,
hook,
})
} }
} }
} }
#[cfg(not(windows))] #[cfg(unix)]
fn is_executable(path: &Path) -> bool { fn is_executable(path: &Path) -> bool {
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;

View File

@ -1,6 +1,11 @@
//! git2-rs addon supporting git hooks //! git2-rs addon supporting git hooks
//! //!
//! most basic hook is: [`hooks_pre_commit`]. see also other `hooks_*` functions //! we look for hooks in the following locations:
//! * whatever `config.hooksPath` points to
//! * `.git/hooks/`
//! * whatever list of paths provided as `other_paths` (in order)
//!
//! most basic hook is: [`hooks_pre_commit`]. see also other `hooks_*` functions.
//! //!
//! [`create_hook`] is useful to create git hooks from code (unittest make heavy usage of it) //! [`create_hook`] is useful to create git hooks from code (unittest make heavy usage of it)
mod error; mod error;
@ -28,10 +33,36 @@ const HOOK_COMMIT_MSG_TEMP_FILE: &str = "COMMIT_EDITMSG";
/// ///
#[derive(Debug, PartialEq, Eq)] #[derive(Debug, PartialEq, Eq)]
pub enum HookResult { pub enum HookResult {
/// Everything went fine /// No hook found
Ok, NoHookFound,
/// Hook returned error /// Hook executed with non error return code
NotOk { stdout: String, stderr: String }, Ok {
/// path of the hook that was run
hook: PathBuf,
},
/// Hook executed and returned an error code
RunNotSuccessful {
/// exit code as reported back from process calling the hook
code: Option<i32>,
/// stderr output emitted by hook
stdout: String,
/// stderr output emitted by hook
stderr: String,
/// path of the hook that was run
hook: PathBuf,
},
}
impl HookResult {
/// helper to check if result is ok
pub fn is_ok(&self) -> bool {
matches!(self, HookResult::Ok { .. })
}
/// helper to check if result was run and not rejected
pub fn is_not_successful(&self) -> bool {
matches!(self, HookResult::RunNotSuccessful { .. })
}
} }
/// helper method to create git hooks programmatically (heavy used in unittests) /// helper method to create git hooks programmatically (heavy used in unittests)
@ -40,7 +71,7 @@ pub fn create_hook(
hook: &str, hook: &str,
hook_script: &[u8], hook_script: &[u8],
) -> PathBuf { ) -> PathBuf {
let hook = HookPaths::new(r, hook).unwrap(); let hook = HookPaths::new(r, None, hook).unwrap();
let path = hook.hook.clone(); let path = hook.hook.clone();
@ -52,7 +83,7 @@ pub fn create_hook(
fn create_hook_in_path(path: &Path, hook_script: &[u8]) { fn create_hook_in_path(path: &Path, hook_script: &[u8]) {
File::create(path).unwrap().write_all(hook_script).unwrap(); File::create(path).unwrap().write_all(hook_script).unwrap();
#[cfg(not(windows))] #[cfg(unix)]
{ {
Command::new("chmod") Command::new("chmod")
.arg("+x") .arg("+x")
@ -69,16 +100,19 @@ fn create_hook_in_path(path: &Path, hook_script: &[u8]) {
/// parameter to the hook script. /// parameter to the hook script.
pub fn hooks_commit_msg( pub fn hooks_commit_msg(
repo: &Repository, repo: &Repository,
other_paths: Option<&[&str]>,
msg: &mut String, msg: &mut String,
) -> Result<HookResult> { ) -> Result<HookResult> {
let hooks_path = HookPaths::new(repo, HOOK_COMMIT_MSG)?; let hook = HookPaths::new(repo, other_paths, HOOK_COMMIT_MSG)?;
if hooks_path.is_executable() { if !hook.found() {
let temp_file = return Ok(HookResult::NoHookFound);
hooks_path.git.join(HOOK_COMMIT_MSG_TEMP_FILE); }
let temp_file = hook.git.join(HOOK_COMMIT_MSG_TEMP_FILE);
File::create(&temp_file)?.write_all(msg.as_bytes())?; File::create(&temp_file)?.write_all(msg.as_bytes())?;
let res = hooks_path.run_hook(&[temp_file let res = hook.run_hook(&[temp_file
.as_os_str() .as_os_str()
.to_string_lossy() .to_string_lossy()
.as_ref()])?; .as_ref()])?;
@ -88,31 +122,34 @@ pub fn hooks_commit_msg(
File::open(temp_file)?.read_to_string(msg)?; File::open(temp_file)?.read_to_string(msg)?;
Ok(res) Ok(res)
} else {
Ok(HookResult::Ok)
}
} }
/// this hook is documented here <https://git-scm.com/docs/githooks#_pre_commit> /// this hook is documented here <https://git-scm.com/docs/githooks#_pre_commit>
pub fn hooks_pre_commit(repo: &Repository) -> Result<HookResult> { pub fn hooks_pre_commit(
let hook = HookPaths::new(repo, HOOK_PRE_COMMIT)?; repo: &Repository,
other_paths: Option<&[&str]>,
) -> Result<HookResult> {
let hook = HookPaths::new(repo, other_paths, HOOK_PRE_COMMIT)?;
if hook.is_executable() { if !hook.found() {
Ok(hook.run_hook(&[])?) return Ok(HookResult::NoHookFound);
} else {
Ok(HookResult::Ok)
} }
hook.run_hook(&[])
} }
/// this hook is documented here <https://git-scm.com/docs/githooks#_post_commit> /// this hook is documented here <https://git-scm.com/docs/githooks#_post_commit>
pub fn hooks_post_commit(repo: &Repository) -> Result<HookResult> { pub fn hooks_post_commit(
let hook = HookPaths::new(repo, HOOK_POST_COMMIT)?; repo: &Repository,
other_paths: Option<&[&str]>,
) -> Result<HookResult> {
let hook = HookPaths::new(repo, other_paths, HOOK_POST_COMMIT)?;
if hook.is_executable() { if !hook.found() {
Ok(hook.run_hook(&[])?) return Ok(HookResult::NoHookFound);
} else {
Ok(HookResult::Ok)
} }
hook.run_hook(&[])
} }
#[cfg(test)] #[cfg(test)]
@ -127,13 +164,19 @@ mod tests {
let (_td, repo) = repo_init(); let (_td, repo) = repo_init();
let mut msg = String::from("test"); let mut msg = String::from("test");
let res = hooks_commit_msg(&repo, &mut msg).unwrap(); let res = hooks_commit_msg(&repo, None, &mut msg).unwrap();
assert_eq!(res, HookResult::Ok); assert_eq!(res, HookResult::NoHookFound);
let res = hooks_post_commit(&repo).unwrap(); let hook = b"#!/bin/sh
exit 0
";
assert_eq!(res, HookResult::Ok); create_hook(&repo, HOOK_POST_COMMIT, hook);
let res = hooks_post_commit(&repo, None).unwrap();
assert!(res.is_ok());
} }
#[test] #[test]
@ -147,9 +190,9 @@ exit 0
create_hook(&repo, HOOK_COMMIT_MSG, hook); create_hook(&repo, HOOK_COMMIT_MSG, hook);
let mut msg = String::from("test"); let mut msg = String::from("test");
let res = hooks_commit_msg(&repo, &mut msg).unwrap(); let res = hooks_commit_msg(&repo, None, &mut msg).unwrap();
assert_eq!(res, HookResult::Ok); assert!(res.is_ok());
assert_eq!(msg, String::from("test")); assert_eq!(msg, String::from("test"));
} }
@ -167,9 +210,9 @@ exit 0
create_hook(&repo, HOOK_COMMIT_MSG, hook); create_hook(&repo, HOOK_COMMIT_MSG, hook);
let mut msg = String::from("test_sth"); let mut msg = String::from("test_sth");
let res = hooks_commit_msg(&repo, &mut msg).unwrap(); let res = hooks_commit_msg(&repo, None, &mut msg).unwrap();
assert_eq!(res, HookResult::Ok); assert!(res.is_ok());
assert_eq!(msg, String::from("test_shell_command")); assert_eq!(msg, String::from("test_shell_command"));
} }
@ -183,8 +226,71 @@ exit 0
"; ";
create_hook(&repo, HOOK_PRE_COMMIT, hook); create_hook(&repo, HOOK_PRE_COMMIT, hook);
let res = hooks_pre_commit(&repo).unwrap(); let res = hooks_pre_commit(&repo, None).unwrap();
assert_eq!(res, HookResult::Ok); assert!(res.is_ok());
}
#[test]
fn test_no_hook_found() {
let (_td, repo) = repo_init();
let res = hooks_pre_commit(&repo, None).unwrap();
assert_eq!(res, HookResult::NoHookFound);
}
#[test]
fn test_other_path() {
let (td, repo) = repo_init();
let hook = b"#!/bin/sh
exit 0
";
let custom_hooks_path = td.path().join(".myhooks");
std::fs::create_dir(dbg!(&custom_hooks_path)).unwrap();
create_hook_in_path(
dbg!(custom_hooks_path.join(HOOK_PRE_COMMIT).as_path()),
hook,
);
let res =
hooks_pre_commit(&repo, Some(&["../.myhooks"])).unwrap();
assert!(res.is_ok());
}
#[test]
fn test_other_path_precendence() {
let (td, repo) = repo_init();
{
let hook = b"#!/bin/sh
exit 0
";
create_hook(&repo, HOOK_PRE_COMMIT, hook);
}
{
let reject_hook = b"#!/bin/sh
exit 1
";
let custom_hooks_path = td.path().join(".myhooks");
std::fs::create_dir(dbg!(&custom_hooks_path)).unwrap();
create_hook_in_path(
dbg!(custom_hooks_path
.join(HOOK_PRE_COMMIT)
.as_path()),
reject_hook,
);
}
let res =
hooks_pre_commit(&repo, Some(&["../.myhooks"])).unwrap();
assert!(res.is_ok());
} }
#[test] #[test]
@ -197,8 +303,8 @@ exit 1
"; ";
create_hook(&repo, HOOK_PRE_COMMIT, hook); create_hook(&repo, HOOK_PRE_COMMIT, hook);
let res = hooks_pre_commit(&repo).unwrap(); let res = hooks_pre_commit(&repo, None).unwrap();
assert!(res != HookResult::Ok); assert!(res.is_not_successful());
} }
#[test] #[test]
@ -211,9 +317,9 @@ exit 1
"; ";
create_hook(&repo, HOOK_PRE_COMMIT, hook); create_hook(&repo, HOOK_PRE_COMMIT, hook);
let res = hooks_pre_commit(&repo).unwrap(); let res = hooks_pre_commit(&repo, None).unwrap();
let HookResult::NotOk { stdout, .. } = res else { let HookResult::RunNotSuccessful { stdout, .. } = res else {
unreachable!() unreachable!()
}; };
@ -242,15 +348,15 @@ exit 1
) )
.unwrap(); .unwrap();
let res = hooks_pre_commit(&repo).unwrap(); let res = hooks_pre_commit(&repo, None).unwrap();
assert_eq!( let HookResult::RunNotSuccessful { code, stdout, .. } = res
res, else {
HookResult::NotOk { unreachable!()
stdout: String::from("rejected\n"), };
stderr: String::new()
} assert_eq!(code.unwrap(), 1);
); assert_eq!(&stdout, "rejected\n");
} }
#[test] #[test]
@ -263,8 +369,8 @@ exit 1
"; ";
create_hook(&repo, HOOK_PRE_COMMIT, hook); create_hook(&repo, HOOK_PRE_COMMIT, hook);
let res = hooks_pre_commit(&repo).unwrap(); let res = hooks_pre_commit(&repo, None).unwrap();
assert!(res != HookResult::Ok); assert!(res.is_not_successful());
} }
#[test] #[test]
@ -284,8 +390,8 @@ sys.exit(0)
"; ";
create_hook(&repo, HOOK_PRE_COMMIT, hook); create_hook(&repo, HOOK_PRE_COMMIT, hook);
let res = hooks_pre_commit(&repo).unwrap(); let res = hooks_pre_commit(&repo, None).unwrap();
assert_eq!(res, HookResult::Ok); assert!(res.is_ok());
} }
#[test] #[test]
@ -305,8 +411,8 @@ sys.exit(1)
"; ";
create_hook(&repo, HOOK_PRE_COMMIT, hook); create_hook(&repo, HOOK_PRE_COMMIT, hook);
let res = hooks_pre_commit(&repo).unwrap(); let res = hooks_pre_commit(&repo, None).unwrap();
assert!(res != HookResult::Ok); assert!(res.is_not_successful());
} }
#[test] #[test]
@ -322,15 +428,15 @@ exit 1
create_hook(&repo, HOOK_COMMIT_MSG, hook); create_hook(&repo, HOOK_COMMIT_MSG, hook);
let mut msg = String::from("test"); let mut msg = String::from("test");
let res = hooks_commit_msg(&repo, &mut msg).unwrap(); let res = hooks_commit_msg(&repo, None, &mut msg).unwrap();
assert_eq!( let HookResult::RunNotSuccessful { code, stdout, .. } = res
res, else {
HookResult::NotOk { unreachable!()
stdout: String::from("rejected\n"), };
stderr: String::new()
} assert_eq!(code.unwrap(), 1);
); assert_eq!(&stdout, "rejected\n");
assert_eq!(msg, String::from("msg\n")); assert_eq!(msg, String::from("msg\n"));
} }
@ -347,9 +453,9 @@ exit 0
create_hook(&repo, HOOK_COMMIT_MSG, hook); create_hook(&repo, HOOK_COMMIT_MSG, hook);
let mut msg = String::from("test"); let mut msg = String::from("test");
let res = hooks_commit_msg(&repo, &mut msg).unwrap(); let res = hooks_commit_msg(&repo, None, &mut msg).unwrap();
assert_eq!(res, HookResult::Ok); assert!(res.is_ok());
assert_eq!(msg, String::from("msg\n")); assert_eq!(msg, String::from("msg\n"));
} }
@ -358,7 +464,8 @@ exit 0
let (_td, repo) = repo_init_bare(); let (_td, repo) = repo_init_bare();
let git_root = repo.path().to_path_buf(); let git_root = repo.path().to_path_buf();
let hook = HookPaths::new(&repo, HOOK_POST_COMMIT).unwrap(); let hook =
HookPaths::new(&repo, None, HOOK_POST_COMMIT).unwrap();
assert_eq!(hook.pwd, git_root); assert_eq!(hook.pwd, git_root);
} }
@ -368,7 +475,8 @@ exit 0
let (_td, repo) = repo_init(); let (_td, repo) = repo_init();
let git_root = repo.path().to_path_buf(); let git_root = repo.path().to_path_buf();
let hook = HookPaths::new(&repo, HOOK_POST_COMMIT).unwrap(); let hook =
HookPaths::new(&repo, None, HOOK_POST_COMMIT).unwrap();
assert_eq!(hook.pwd, git_root.parent().unwrap()); assert_eq!(hook.pwd, git_root.parent().unwrap());
} }