diff --git a/Cargo.lock b/Cargo.lock index 756f8f3e..f378e57e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -105,6 +105,7 @@ dependencies = [ "env_logger", "fuzzy-matcher", "git2", + "git2-hooks", "invalidstring", "log", "openssl-sys", @@ -114,7 +115,6 @@ dependencies = [ "scopetime", "serde", "serial_test", - "shellexpand", "tempfile", "thiserror", "unicode-truncate", @@ -712,6 +712,29 @@ dependencies = [ "url", ] +[[package]] +name = "git2-hooks" +version = "0.1.0" +dependencies = [ + "git2", + "git2-testing", + "log", + "pretty_assertions", + "shellexpand", + "tempfile", + "thiserror", +] + +[[package]] +name = "git2-testing" +version = "0.1.0" +dependencies = [ + "env_logger", + "git2", + "log", + "tempfile", +] + [[package]] name = "gitui" version = "0.24.3" diff --git a/Cargo.toml b/Cargo.toml index 59dd96d1..ce834bdf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -69,7 +69,7 @@ trace-libgit = ["asyncgit/trace-libgit"] vendor-openssl = ["asyncgit/vendor-openssl"] [workspace] -members = ["asyncgit", "filetreelist", "scopetime"] +members = ["asyncgit", "filetreelist", "git2-hooks", "git2-testing", "scopetime"] [profile.release] lto = true diff --git a/asyncgit/Cargo.toml b/asyncgit/Cargo.toml index ac91acc6..b055a89f 100644 --- a/asyncgit/Cargo.toml +++ b/asyncgit/Cargo.toml @@ -17,6 +17,7 @@ crossbeam-channel = "0.5" easy-cast = "0.5" fuzzy-matcher = "0.3" git2 = "0.17" +git2-hooks = { path = "../git2-hooks", version = "0.1" } log = "0.4" # git2 = { path = "../../extern/git2-rs", features = ["vendored-openssl"]} # git2 = { git="https://github.com/extrawurst/git2-rs.git", rev="fc13dcc", features = ["vendored-openssl"]} @@ -26,7 +27,6 @@ rayon = "1.8" rayon-core = "1.11" scopetime = { path = "../scopetime", version = "0.1" } serde = { version = "1.0", features = ["derive"] } -shellexpand = "3.1" thiserror = "1.0" unicode-truncate = "0.2.0" url = "2.5" diff --git a/asyncgit/src/error.rs b/asyncgit/src/error.rs index b9d87ea3..6ede0422 100644 --- a/asyncgit/src/error.rs +++ b/asyncgit/src/error.rs @@ -69,14 +69,6 @@ pub enum Error { #[error("EasyCast error:{0}")] EasyCast(#[from] easy_cast::Error), - /// - #[error("shellexpand error:{0}")] - Shell(#[from] shellexpand::LookupError), - - /// - #[error("path string error")] - PathString, - /// #[error("no parent of commit found")] NoParent, @@ -88,6 +80,10 @@ pub enum Error { /// #[error("rayon error: {0}")] ThreadPool(#[from] rayon_core::ThreadPoolBuildError), + + /// + #[error("git hook error: {0}")] + Hooks(#[from] git2_hooks::HooksError), } /// diff --git a/asyncgit/src/sync/hooks.rs b/asyncgit/src/sync/hooks.rs index 00f7a2cd..ab7bd44d 100644 --- a/asyncgit/src/sync/hooks.rs +++ b/asyncgit/src/sync/hooks.rs @@ -1,99 +1,23 @@ use super::{repository::repo, RepoPath}; -use crate::error::{self, Result}; +use crate::error::Result; use scopetime::scope_time; -use std::{ - fs::File, - io::{Read, Write}, - path::{Path, PathBuf}, - process::Command, - str::FromStr, -}; -const HOOK_POST_COMMIT: &str = "post-commit"; -const HOOK_PRE_COMMIT: &str = "pre-commit"; -const HOOK_COMMIT_MSG: &str = "commit-msg"; -const HOOK_COMMIT_MSG_TEMP_FILE: &str = "COMMIT_EDITMSG"; - -struct HookPaths { - git: PathBuf, - hook: PathBuf, - pwd: PathBuf, +/// +#[derive(Debug, PartialEq, Eq)] +pub enum HookResult { + /// Everything went fine + Ok, + /// Hook returned error + NotOk(String), } -impl HookPaths { - pub fn new(repo_path: &RepoPath, hook: &str) -> Result { - let repo = repo(repo_path)?; - let pwd = repo - .workdir() - .unwrap_or_else(|| repo.path()) - .to_path_buf(); - - let git_dir = repo.path().to_path_buf(); - let hooks_path = repo - .config() - .and_then(|config| config.get_string("core.hooksPath")) - .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 = shellexpand::full( - hook.as_os_str() - .to_str() - .ok_or(error::Error::PathString)?, - )?; - - let hook = PathBuf::from_str(hook.as_ref()) - .map_err(|_| error::Error::PathString)?; - - Ok(Self { - git: git_dir, - hook, - pwd, - }) - } - - pub fn is_executable(&self) -> bool { - self.hook.exists() && is_executable(&self.hook) - } - - /// this function calls hook scripts based on conventions documented here - /// see - pub fn run_hook(&self, args: &[&str]) -> Result { - let arg_str = format!("{:?} {}", self.hook, args.join(" ")); - // Use -l to avoid "command not found" on Windows. - let bash_args = - vec!["-l".to_string(), "-c".to_string(), arg_str]; - - log::trace!("run hook '{:?}' in '{:?}'", self.hook, self.pwd); - - let git_bash = find_bash_executable() - .unwrap_or_else(|| PathBuf::from("bash")); - let output = Command::new(git_bash) - .args(bash_args) - .current_dir(&self.pwd) - // This call forces Command to handle the Path environment correctly on windows, - // the specific env set here does not matter - // see https://github.com/rust-lang/rust/issues/37519 - .env( - "DUMMY_ENV_TO_FIX_WINDOWS_CMD_RUNS", - "FixPathHandlingOnWindows", - ) - .output()?; - - if output.status.success() { - Ok(HookResult::Ok) - } else { - let err = String::from_utf8_lossy(&output.stderr); - let out = String::from_utf8_lossy(&output.stdout); - let formatted = format!("{out}{err}"); - - Ok(HookResult::NotOk(formatted)) +impl From for HookResult { + fn from(v: git2_hooks::HookResult) -> Self { + match v { + git2_hooks::HookResult::Ok => Self::Ok, + git2_hooks::HookResult::NotOk { stdout, stderr } => { + Self::NotOk(format!("{stdout}{stderr}")) + } } } } @@ -108,26 +32,9 @@ pub fn hooks_commit_msg( ) -> Result { scope_time!("hooks_commit_msg"); - let hooks_path = HookPaths::new(repo_path, HOOK_COMMIT_MSG)?; + let repo = repo(repo_path)?; - if hooks_path.is_executable() { - let temp_file = - hooks_path.git.join(HOOK_COMMIT_MSG_TEMP_FILE); - File::create(&temp_file)?.write_all(msg.as_bytes())?; - - let res = hooks_path.run_hook(&[temp_file - .as_os_str() - .to_string_lossy() - .as_ref()])?; - - // load possibly altered msg - msg.clear(); - File::open(temp_file)?.read_to_string(msg)?; - - Ok(res) - } else { - Ok(HookResult::Ok) - } + Ok(git2_hooks::hooks_commit_msg(&repo, msg)?.into()) } /// this hook is documented here @@ -135,427 +42,43 @@ pub fn hooks_commit_msg( pub fn hooks_pre_commit(repo_path: &RepoPath) -> Result { scope_time!("hooks_pre_commit"); - let hook = HookPaths::new(repo_path, HOOK_PRE_COMMIT)?; + let repo = repo(repo_path)?; - if hook.is_executable() { - Ok(hook.run_hook(&[])?) - } else { - Ok(HookResult::Ok) - } + Ok(git2_hooks::hooks_pre_commit(&repo)?.into()) } + /// pub fn hooks_post_commit(repo_path: &RepoPath) -> Result { scope_time!("hooks_post_commit"); - let hook = HookPaths::new(repo_path, HOOK_POST_COMMIT)?; + let repo = repo(repo_path)?; - if hook.is_executable() { - Ok(hook.run_hook(&[])?) - } else { - Ok(HookResult::Ok) - } -} - -/// -#[derive(Debug, PartialEq, Eq)] -pub enum HookResult { - /// Everything went fine - Ok, - /// Hook returned error - NotOk(String), -} - -#[cfg(not(windows))] -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)] -/// windows does not consider bash scripts to be executable so we consider everything -/// to be executable (which is not far from the truth for windows platform.) -const fn is_executable(_: &Path) -> bool { - true -} - -// Find bash.exe, and avoid finding wsl's bash.exe on Windows. -// None for non-Windows. -fn find_bash_executable() -> Option { - if cfg!(windows) { - Command::new("where.exe") - .arg("git") - .output() - .ok() - .map(|out| { - PathBuf::from(Into::::into( - String::from_utf8_lossy(&out.stdout), - )) - }) - .as_deref() - .and_then(Path::parent) - .and_then(Path::parent) - .map(|p| p.join("usr/bin/bash.exe")) - .filter(|p| p.exists()) - } else { - None - } + Ok(git2_hooks::hooks_post_commit(&repo)?.into()) } #[cfg(test)] mod tests { use super::*; - use crate::sync::tests::{repo_init, repo_init_bare}; - use std::fs::{self, File}; - use tempfile::TempDir; - - #[test] - fn test_smoke() { - let (_td, repo) = repo_init().unwrap(); - let root = repo.path().parent().unwrap(); - let repo_path: &RepoPath = - &root.as_os_str().to_str().unwrap().into(); - - let mut msg = String::from("test"); - let res = hooks_commit_msg(repo_path, &mut msg).unwrap(); - - assert_eq!(res, HookResult::Ok); - - let res = hooks_post_commit(repo_path).unwrap(); - - assert_eq!(res, HookResult::Ok); - } - - fn create_hook( - path: &RepoPath, - hook: &str, - hook_script: &[u8], - ) -> PathBuf { - let hook = HookPaths::new(path, hook).unwrap(); - - let path = hook.hook.clone(); - - create_hook_in_path(&hook.hook, hook_script); - - path - } - - fn create_hook_in_path(path: &Path, hook_script: &[u8]) { - File::create(path).unwrap().write_all(hook_script).unwrap(); - - #[cfg(not(windows))] - { - Command::new("chmod") - .arg("+x") - .arg(path) - // .current_dir(path) - .output() - .unwrap(); - } - } - - #[test] - fn test_hooks_commit_msg_ok() { - let (_td, repo) = repo_init().unwrap(); - let root = repo.path().parent().unwrap(); - let repo_path: &RepoPath = - &root.as_os_str().to_str().unwrap().into(); - - let hook = b"#!/bin/sh -exit 0 - "; - - create_hook(repo_path, HOOK_COMMIT_MSG, hook); - - let mut msg = String::from("test"); - let res = hooks_commit_msg(repo_path, &mut msg).unwrap(); - - assert_eq!(res, HookResult::Ok); - - assert_eq!(msg, String::from("test")); - } - - #[test] - fn test_hooks_commit_msg_with_shell_command_ok() { - let (_td, repo) = repo_init().unwrap(); - let root = repo.path().parent().unwrap(); - let repo_path: &RepoPath = - &root.as_os_str().to_str().unwrap().into(); - - let hook = br#"#!/bin/sh -COMMIT_MSG="$(cat "$1")" -printf "$COMMIT_MSG" | sed 's/sth/shell_command/g' >"$1" -exit 0 - "#; - - create_hook(repo_path, HOOK_COMMIT_MSG, hook); - - let mut msg = String::from("test_sth"); - let res = hooks_commit_msg(repo_path, &mut msg).unwrap(); - - assert_eq!(res, HookResult::Ok); - - assert_eq!(msg, String::from("test_shell_command")); - } - - #[test] - fn test_pre_commit_sh() { - let (_td, repo) = repo_init().unwrap(); - let root = repo.path().parent().unwrap(); - let repo_path: &RepoPath = - &root.as_os_str().to_str().unwrap().into(); - - let hook = b"#!/bin/sh -exit 0 - "; - - create_hook(repo_path, HOOK_PRE_COMMIT, hook); - let res = hooks_pre_commit(repo_path).unwrap(); - assert_eq!(res, HookResult::Ok); - } - - #[test] - fn test_pre_commit_fail_sh() { - let (_td, repo) = repo_init().unwrap(); - let root = repo.path().parent().unwrap(); - let repo_path: &RepoPath = - &root.as_os_str().to_str().unwrap().into(); - - let hook = b"#!/bin/sh -echo 'rejected' -exit 1 - "; - - create_hook(repo_path, HOOK_PRE_COMMIT, hook); - let res = hooks_pre_commit(repo_path).unwrap(); - assert!(res != HookResult::Ok); - } - - #[test] - fn test_env_containing_path() { - let (_td, repo) = repo_init().unwrap(); - let root = repo.path().parent().unwrap(); - let repo_path: &RepoPath = - &root.as_os_str().to_str().unwrap().into(); - - let hook = b"#!/bin/sh -export -exit 1 - "; - - create_hook(repo_path, HOOK_PRE_COMMIT, hook); - let res = hooks_pre_commit(repo_path).unwrap(); - - let HookResult::NotOk(out) = res else { - unreachable!() - }; - - assert!(out - .lines() - .any(|line| line.starts_with("export PATH"))); - } - - #[test] - fn test_pre_commit_fail_hookspath() { - let (_td, repo) = repo_init().unwrap(); - let root = repo.path().parent().unwrap(); - let hooks = TempDir::new().unwrap(); - let repo_path: &RepoPath = - &root.as_os_str().to_str().unwrap().into(); - - let hook = b"#!/bin/sh -echo 'rejected' -exit 1 - "; - - create_hook_in_path(&hooks.path().join("pre-commit"), hook); - repo.config() - .unwrap() - .set_str( - "core.hooksPath", - hooks.path().as_os_str().to_str().unwrap(), - ) - .unwrap(); - let res = hooks_pre_commit(repo_path).unwrap(); - assert_eq!( - res, - HookResult::NotOk(String::from("rejected\n")) - ); - } - - #[test] - fn test_pre_commit_fail_bare() { - let (git_root, _repo) = repo_init_bare().unwrap(); - let workdir = TempDir::new().unwrap(); - let git_root = git_root.into_path(); - let repo_path = &RepoPath::Workdir { - gitdir: dbg!(git_root), - workdir: dbg!(workdir.into_path()), - }; - - let hook = b"#!/bin/sh -echo 'rejected' -exit 1 - "; - - create_hook(repo_path, HOOK_PRE_COMMIT, hook); - let res = hooks_pre_commit(repo_path).unwrap(); - assert!(res != HookResult::Ok); - } - - #[test] - fn test_pre_commit_py() { - let (_td, repo) = repo_init().unwrap(); - let root = repo.path().parent().unwrap(); - let repo_path: &RepoPath = - &root.as_os_str().to_str().unwrap().into(); - - // mirror how python pre-commmit sets itself up - #[cfg(not(windows))] - let hook = b"#!/usr/bin/env python -import sys -sys.exit(0) - "; - #[cfg(windows)] - let hook = b"#!/bin/env python.exe -import sys -sys.exit(0) - "; - - create_hook(repo_path, HOOK_PRE_COMMIT, hook); - let res = hooks_pre_commit(repo_path).unwrap(); - assert_eq!(res, HookResult::Ok); - } - - #[test] - fn test_pre_commit_fail_py() { - let (_td, repo) = repo_init().unwrap(); - let root = repo.path().parent().unwrap(); - let repo_path: &RepoPath = - &root.as_os_str().to_str().unwrap().into(); - - // mirror how python pre-commmit sets itself up - #[cfg(not(windows))] - let hook = b"#!/usr/bin/env python -import sys -sys.exit(1) - "; - #[cfg(windows)] - let hook = b"#!/bin/env python.exe -import sys -sys.exit(1) - "; - - create_hook(repo_path, HOOK_PRE_COMMIT, hook); - let res = hooks_pre_commit(repo_path).unwrap(); - assert!(res != HookResult::Ok); - } - - #[test] - fn test_hooks_commit_msg_reject() { - let (_td, repo) = repo_init().unwrap(); - let root = repo.path().parent().unwrap(); - let repo_path: &RepoPath = - &root.as_os_str().to_str().unwrap().into(); - - let hook = b"#!/bin/sh -echo 'msg' > $1 -echo 'rejected' -exit 1 - "; - - create_hook(repo_path, HOOK_COMMIT_MSG, hook); - - let mut msg = String::from("test"); - let res = hooks_commit_msg(repo_path, &mut msg).unwrap(); - - assert_eq!( - res, - HookResult::NotOk(String::from("rejected\n")) - ); - - assert_eq!(msg, String::from("msg\n")); - } - - #[test] - fn test_hooks_commit_msg_reject_in_subfolder() { - let (_td, repo) = repo_init().unwrap(); - let root = repo.path().parent().unwrap(); - let repo_path: &RepoPath = - &root.as_os_str().to_str().unwrap().into(); - - let hook = b"#!/bin/sh -echo 'msg' > $1 -echo 'rejected' -exit 1 - "; - - create_hook(repo_path, HOOK_COMMIT_MSG, hook); - - let subfolder = root.join("foo/"); - fs::create_dir_all(&subfolder).unwrap(); - - let mut msg = String::from("test"); - let res = hooks_commit_msg( - &subfolder.to_str().unwrap().into(), - &mut msg, - ) - .unwrap(); - - assert_eq!( - res, - HookResult::NotOk(String::from("rejected\n")) - ); - - assert_eq!(msg, String::from("msg\n")); - } - - #[test] - fn test_commit_msg_no_block_but_alter() { - let (_td, repo) = repo_init().unwrap(); - let root = repo.path().parent().unwrap(); - let repo_path: &RepoPath = - &root.as_os_str().to_str().unwrap().into(); - - let hook = b"#!/bin/sh -echo 'msg' > $1 -exit 0 - "; - - create_hook(repo_path, HOOK_COMMIT_MSG, hook); - - let mut msg = String::from("test"); - let res = hooks_commit_msg(repo_path, &mut msg).unwrap(); - - assert_eq!(res, HookResult::Ok); - assert_eq!(msg, String::from("msg\n")); - } + use crate::sync::tests::repo_init; #[test] fn test_post_commit_hook_reject_in_subfolder() { let (_td, repo) = repo_init().unwrap(); let root = repo.path().parent().unwrap(); - let repo_path: &RepoPath = - &root.as_os_str().to_str().unwrap().into(); let hook = b"#!/bin/sh -echo 'rejected' -exit 1 - "; + echo 'rejected' + exit 1 + "; - create_hook(repo_path, HOOK_POST_COMMIT, hook); + git2_hooks::create_hook( + &repo, + git2_hooks::HOOK_POST_COMMIT, + hook, + ); let subfolder = root.join("foo/"); - fs::create_dir_all(&subfolder).unwrap(); + std::fs::create_dir_all(&subfolder).unwrap(); let res = hooks_post_commit(&subfolder.to_str().unwrap().into()) @@ -581,16 +104,20 @@ exit 1 crate::sync::utils::repo_work_dir(repo_path).unwrap(); let hook = b"#!/bin/sh -echo $(pwd) -exit 1 - "; + echo $(pwd) + exit 1 + "; - create_hook(repo_path, HOOK_PRE_COMMIT, hook); + git2_hooks::create_hook( + &repo, + git2_hooks::HOOK_PRE_COMMIT, + hook, + ); let res = hooks_pre_commit(repo_path).unwrap(); if let HookResult::NotOk(res) = res { assert_eq!( - Path::new(res.trim_end()), - Path::new(&workdir) + std::path::Path::new(res.trim_end()), + std::path::Path::new(&workdir) ); } else { assert!(false); @@ -598,45 +125,37 @@ exit 1 } #[test] - fn test_hook_pwd_in_bare_without_workdir() { - let (_td, _repo) = repo_init_bare().unwrap(); - let git_root = _repo.path().to_path_buf(); - let repo_path = &RepoPath::Path(git_root.clone()); + fn test_hooks_commit_msg_reject_in_subfolder() { + let (_td, repo) = repo_init().unwrap(); + let root = repo.path().parent().unwrap(); - let hook = - HookPaths::new(repo_path, HOOK_POST_COMMIT).unwrap(); + let hook = b"#!/bin/sh + echo 'msg' > $1 + echo 'rejected' + exit 1 + "; - assert_eq!(hook.pwd, dbg!(git_root)); - } + git2_hooks::create_hook( + &repo, + git2_hooks::HOOK_COMMIT_MSG, + hook, + ); - #[test] - fn test_hook_pwd_in_bare_with_workdir() { - let (git_root, _repo) = repo_init_bare().unwrap(); - let workdir = TempDir::new().unwrap(); - let git_root = git_root.into_path(); - let repo_path = &RepoPath::Workdir { - gitdir: dbg!(git_root), - workdir: dbg!(workdir.path().to_path_buf()), - }; + let subfolder = root.join("foo/"); + std::fs::create_dir_all(&subfolder).unwrap(); - let hook = - HookPaths::new(repo_path, HOOK_POST_COMMIT).unwrap(); + let mut msg = String::from("test"); + let res = hooks_commit_msg( + &subfolder.to_str().unwrap().into(), + &mut msg, + ) + .unwrap(); assert_eq!( - hook.pwd.canonicalize().unwrap(), - dbg!(workdir.path().canonicalize().unwrap()) + res, + HookResult::NotOk(String::from("rejected\n")) ); - } - #[test] - fn test_hook_pwd() { - let (_td, _repo) = repo_init().unwrap(); - let git_root = _repo.path().to_path_buf(); - let repo_path = &RepoPath::Path(git_root.clone()); - - let hook = - HookPaths::new(repo_path, HOOK_POST_COMMIT).unwrap(); - - assert_eq!(hook.pwd, git_root.parent().unwrap()); + assert_eq!(msg, String::from("msg\n")); } } diff --git a/git2-hooks/Cargo.toml b/git2-hooks/Cargo.toml new file mode 100644 index 00000000..b7a197c7 --- /dev/null +++ b/git2-hooks/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "git2-hooks" +version = "0.1.0" +authors = ["extrawurst "] +edition = "2021" +description = "adds git hooks support based on git2-rs" +homepage = "https://github.com/extrawurst/gitui" +repository = "https://github.com/extrawurst/gitui" +readme = "README.md" +license = "MIT" +categories = ["development-tools"] +keywords = ["git"] + +[dependencies] +git2 = ">=0.17" +log = "0.4" +shellexpand = "3.1" +thiserror = "1.0" + +[dev-dependencies] +git2-testing = { path = "../git2-testing" } +pretty_assertions = "1.4" +tempfile = "3.4" diff --git a/git2-hooks/LICENSE.md b/git2-hooks/LICENSE.md new file mode 120000 index 00000000..7eabdb1c --- /dev/null +++ b/git2-hooks/LICENSE.md @@ -0,0 +1 @@ +../LICENSE.md \ No newline at end of file diff --git a/git2-hooks/README.md b/git2-hooks/README.md new file mode 100644 index 00000000..732a62a3 --- /dev/null +++ b/git2-hooks/README.md @@ -0,0 +1,4 @@ +# git2-hooks + +adds git hook functionality on top of git2-rs + diff --git a/git2-hooks/src/error.rs b/git2-hooks/src/error.rs new file mode 100644 index 00000000..bcd066d5 --- /dev/null +++ b/git2-hooks/src/error.rs @@ -0,0 +1,28 @@ +use thiserror::Error; + +/// +#[derive(Error, Debug)] +pub enum HooksError { + /// + #[error("`{0}`")] + Generic(String), + + /// + #[error("git error:{0}")] + Git(#[from] git2::Error), + + /// + #[error("io error:{0}")] + Io(#[from] std::io::Error), + + /// + #[error("path string conversion error")] + PathToString, + + /// + #[error("shellexpand error:{0}")] + Shell(#[from] shellexpand::LookupError), +} + +/// +pub type Result = std::result::Result; diff --git a/git2-hooks/src/lib.rs b/git2-hooks/src/lib.rs new file mode 100644 index 00000000..feddd3a4 --- /dev/null +++ b/git2-hooks/src/lib.rs @@ -0,0 +1,497 @@ +mod error; + +use std::{ + fs::File, + io::{Read, Write}, + path::{Path, PathBuf}, + process::Command, + str::FromStr, +}; + +pub use error::HooksError; +use error::Result; +use git2::Repository; + +pub const HOOK_POST_COMMIT: &str = "post-commit"; +pub const HOOK_PRE_COMMIT: &str = "pre-commit"; +pub const HOOK_COMMIT_MSG: &str = "commit-msg"; +pub const HOOK_COMMIT_MSG_TEMP_FILE: &str = "COMMIT_EDITMSG"; + +/// +#[derive(Debug, PartialEq, Eq)] +pub enum HookResult { + /// Everything went fine + Ok, + /// Hook returned error + NotOk { stdout: String, stderr: String }, +} + +struct HookPaths { + git: PathBuf, + hook: PathBuf, + pwd: PathBuf, +} + +impl HookPaths { + pub fn new(repo: &Repository, hook: &str) -> Result { + let pwd = repo + .workdir() + .unwrap_or_else(|| repo.path()) + .to_path_buf(); + + let git_dir = repo.path().to_path_buf(); + let hooks_path = repo + .config() + .and_then(|config| config.get_string("core.hooksPath")) + .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 = shellexpand::full( + hook.as_os_str() + .to_str() + .ok_or(HooksError::PathToString)?, + )?; + + let hook = PathBuf::from_str(hook.as_ref()) + .map_err(|_| HooksError::PathToString)?; + + Ok(Self { + git: git_dir, + hook, + pwd, + }) + } + + pub fn is_executable(&self) -> bool { + self.hook.exists() && is_executable(&self.hook) + } + + /// this function calls hook scripts based on conventions documented here + /// see + pub fn run_hook(&self, args: &[&str]) -> Result { + let arg_str = format!("{:?} {}", self.hook, args.join(" ")); + // Use -l to avoid "command not found" on Windows. + let bash_args = + vec!["-l".to_string(), "-c".to_string(), arg_str]; + + log::trace!("run hook '{:?}' in '{:?}'", self.hook, self.pwd); + + let git_bash = find_bash_executable() + .unwrap_or_else(|| PathBuf::from("bash")); + let output = Command::new(git_bash) + .args(bash_args) + .current_dir(&self.pwd) + // This call forces Command to handle the Path environment correctly on windows, + // the specific env set here does not matter + // see https://github.com/rust-lang/rust/issues/37519 + .env( + "DUMMY_ENV_TO_FIX_WINDOWS_CMD_RUNS", + "FixPathHandlingOnWindows", + ) + .output()?; + + if output.status.success() { + Ok(HookResult::Ok) + } else { + let stderr = + String::from_utf8_lossy(&output.stderr).to_string(); + let stdout = + String::from_utf8_lossy(&output.stdout).to_string(); + + Ok(HookResult::NotOk { stdout, stderr }) + } + } +} + +/// helper method to create git hooks +pub fn create_hook( + r: &Repository, + hook: &str, + hook_script: &[u8], +) -> PathBuf { + let hook = HookPaths::new(r, hook).unwrap(); + + let path = hook.hook.clone(); + + create_hook_in_path(&hook.hook, hook_script); + + path +} + +fn create_hook_in_path(path: &Path, hook_script: &[u8]) { + File::create(path).unwrap().write_all(hook_script).unwrap(); + + #[cfg(not(windows))] + { + Command::new("chmod") + .arg("+x") + .arg(path) + // .current_dir(path) + .output() + .unwrap(); + } +} + +/// this hook is documented here +/// we use the same convention as other git clients to create a temp file containing +/// the commit message at `<.git|hooksPath>/COMMIT_EDITMSG` and pass it's relative path as the only +/// parameter to the hook script. +pub fn hooks_commit_msg( + repo: &Repository, + msg: &mut String, +) -> Result { + let hooks_path = HookPaths::new(repo, HOOK_COMMIT_MSG)?; + + if hooks_path.is_executable() { + let temp_file = + hooks_path.git.join(HOOK_COMMIT_MSG_TEMP_FILE); + File::create(&temp_file)?.write_all(msg.as_bytes())?; + + let res = hooks_path.run_hook(&[temp_file + .as_os_str() + .to_string_lossy() + .as_ref()])?; + + // load possibly altered msg + msg.clear(); + File::open(temp_file)?.read_to_string(msg)?; + + Ok(res) + } else { + Ok(HookResult::Ok) + } +} + +/// this hook is documented here +/// +pub fn hooks_pre_commit(repo: &Repository) -> Result { + let hook = HookPaths::new(repo, HOOK_PRE_COMMIT)?; + + if hook.is_executable() { + Ok(hook.run_hook(&[])?) + } else { + Ok(HookResult::Ok) + } +} +/// +pub fn hooks_post_commit(repo: &Repository) -> Result { + let hook = HookPaths::new(repo, HOOK_POST_COMMIT)?; + + if hook.is_executable() { + Ok(hook.run_hook(&[])?) + } else { + Ok(HookResult::Ok) + } +} + +#[cfg(not(windows))] +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)] +/// windows does not consider bash scripts to be executable so we consider everything +/// to be executable (which is not far from the truth for windows platform.) +const fn is_executable(_: &Path) -> bool { + true +} + +// Find bash.exe, and avoid finding wsl's bash.exe on Windows. +// None for non-Windows. +fn find_bash_executable() -> Option { + if cfg!(windows) { + Command::new("where.exe") + .arg("git") + .output() + .ok() + .map(|out| { + PathBuf::from(Into::::into( + String::from_utf8_lossy(&out.stdout), + )) + }) + .as_deref() + .and_then(Path::parent) + .and_then(Path::parent) + .map(|p| p.join("usr/bin/bash.exe")) + .filter(|p| p.exists()) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + use git2_testing::{repo_init, repo_init_bare}; + use pretty_assertions::assert_eq; + use tempfile::TempDir; + + #[test] + fn test_smoke() { + let (_td, repo) = repo_init(); + + let mut msg = String::from("test"); + let res = hooks_commit_msg(&repo, &mut msg).unwrap(); + + assert_eq!(res, HookResult::Ok); + + let res = hooks_post_commit(&repo).unwrap(); + + assert_eq!(res, HookResult::Ok); + } + + #[test] + fn test_hooks_commit_msg_ok() { + let (_td, repo) = repo_init(); + + let hook = b"#!/bin/sh +exit 0 + "; + + create_hook(&repo, HOOK_COMMIT_MSG, hook); + + let mut msg = String::from("test"); + let res = hooks_commit_msg(&repo, &mut msg).unwrap(); + + assert_eq!(res, HookResult::Ok); + + assert_eq!(msg, String::from("test")); + } + + #[test] + fn test_hooks_commit_msg_with_shell_command_ok() { + let (_td, repo) = repo_init(); + + let hook = br#"#!/bin/sh +COMMIT_MSG="$(cat "$1")" +printf "$COMMIT_MSG" | sed 's/sth/shell_command/g' >"$1" +exit 0 + "#; + + create_hook(&repo, HOOK_COMMIT_MSG, hook); + + let mut msg = String::from("test_sth"); + let res = hooks_commit_msg(&repo, &mut msg).unwrap(); + + assert_eq!(res, HookResult::Ok); + + assert_eq!(msg, String::from("test_shell_command")); + } + + #[test] + fn test_pre_commit_sh() { + let (_td, repo) = repo_init(); + + let hook = b"#!/bin/sh +exit 0 + "; + + create_hook(&repo, HOOK_PRE_COMMIT, hook); + let res = hooks_pre_commit(&repo).unwrap(); + assert_eq!(res, HookResult::Ok); + } + + #[test] + fn test_pre_commit_fail_sh() { + let (_td, repo) = repo_init(); + + let hook = b"#!/bin/sh +echo 'rejected' +exit 1 + "; + + create_hook(&repo, HOOK_PRE_COMMIT, hook); + let res = hooks_pre_commit(&repo).unwrap(); + assert!(res != HookResult::Ok); + } + + #[test] + fn test_env_containing_path() { + let (_td, repo) = repo_init(); + + let hook = b"#!/bin/sh +export +exit 1 + "; + + create_hook(&repo, HOOK_PRE_COMMIT, hook); + let res = hooks_pre_commit(&repo).unwrap(); + + let HookResult::NotOk { stdout, .. } = res else { + unreachable!() + }; + + assert!(stdout + .lines() + .any(|line| line.starts_with("export PATH"))); + } + + #[test] + fn test_pre_commit_fail_hookspath() { + let (_td, repo) = repo_init(); + let hooks = TempDir::new().unwrap(); + + let hook = b"#!/bin/sh +echo 'rejected' +exit 1 + "; + + create_hook_in_path(&hooks.path().join("pre-commit"), hook); + + repo.config() + .unwrap() + .set_str( + "core.hooksPath", + hooks.path().as_os_str().to_str().unwrap(), + ) + .unwrap(); + + let res = hooks_pre_commit(&repo).unwrap(); + + assert_eq!( + res, + HookResult::NotOk { + stdout: String::from("rejected\n"), + stderr: String::new() + } + ); + } + + #[test] + fn test_pre_commit_fail_bare() { + let (_td, repo) = repo_init_bare(); + + let hook = b"#!/bin/sh +echo 'rejected' +exit 1 + "; + + create_hook(&repo, HOOK_PRE_COMMIT, hook); + let res = hooks_pre_commit(&repo).unwrap(); + assert!(res != HookResult::Ok); + } + + #[test] + fn test_pre_commit_py() { + let (_td, repo) = repo_init(); + + // mirror how python pre-commmit sets itself up + #[cfg(not(windows))] + let hook = b"#!/usr/bin/env python +import sys +sys.exit(0) + "; + #[cfg(windows)] + let hook = b"#!/bin/env python.exe +import sys +sys.exit(0) + "; + + create_hook(&repo, HOOK_PRE_COMMIT, hook); + let res = hooks_pre_commit(&repo).unwrap(); + assert_eq!(res, HookResult::Ok); + } + + #[test] + fn test_pre_commit_fail_py() { + let (_td, repo) = repo_init(); + + // mirror how python pre-commmit sets itself up + #[cfg(not(windows))] + let hook = b"#!/usr/bin/env python +import sys +sys.exit(1) + "; + #[cfg(windows)] + let hook = b"#!/bin/env python.exe +import sys +sys.exit(1) + "; + + create_hook(&repo, HOOK_PRE_COMMIT, hook); + let res = hooks_pre_commit(&repo).unwrap(); + assert!(res != HookResult::Ok); + } + + #[test] + fn test_hooks_commit_msg_reject() { + let (_td, repo) = repo_init(); + + let hook = b"#!/bin/sh +echo 'msg' > $1 +echo 'rejected' +exit 1 + "; + + create_hook(&repo, HOOK_COMMIT_MSG, hook); + + let mut msg = String::from("test"); + let res = hooks_commit_msg(&repo, &mut msg).unwrap(); + + assert_eq!( + res, + HookResult::NotOk { + stdout: String::from("rejected\n"), + stderr: String::new() + } + ); + + assert_eq!(msg, String::from("msg\n")); + } + + #[test] + fn test_commit_msg_no_block_but_alter() { + let (_td, repo) = repo_init(); + + let hook = b"#!/bin/sh +echo 'msg' > $1 +exit 0 + "; + + create_hook(&repo, HOOK_COMMIT_MSG, hook); + + let mut msg = String::from("test"); + let res = hooks_commit_msg(&repo, &mut msg).unwrap(); + + assert_eq!(res, HookResult::Ok); + assert_eq!(msg, String::from("msg\n")); + } + + #[test] + fn test_hook_pwd_in_bare_without_workdir() { + let (_td, repo) = repo_init_bare(); + let git_root = repo.path().to_path_buf(); + + let hook = HookPaths::new(&repo, HOOK_POST_COMMIT).unwrap(); + + assert_eq!(hook.pwd, git_root); + } + + #[test] + fn test_hook_pwd() { + let (_td, repo) = repo_init(); + let git_root = repo.path().to_path_buf(); + + let hook = HookPaths::new(&repo, HOOK_POST_COMMIT).unwrap(); + + assert_eq!(hook.pwd, git_root.parent().unwrap()); + } +} diff --git a/git2-testing/Cargo.toml b/git2-testing/Cargo.toml new file mode 100644 index 00000000..de9339bd --- /dev/null +++ b/git2-testing/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "git2-testing" +version = "0.1.0" +authors = ["extrawurst "] +edition = "2021" +description = "convenience functions to write unittests on top of git2-rs" +homepage = "https://github.com/extrawurst/gitui" +repository = "https://github.com/extrawurst/gitui" +readme = "README.md" +license = "MIT" +categories = ["development-tools"] +keywords = ["git"] + +[dependencies] +env_logger = "0.10" +git2 = ">=0.17" +log = "0.4" +tempfile = "3.4" diff --git a/git2-testing/LICENSE.md b/git2-testing/LICENSE.md new file mode 120000 index 00000000..7eabdb1c --- /dev/null +++ b/git2-testing/LICENSE.md @@ -0,0 +1 @@ +../LICENSE.md \ No newline at end of file diff --git a/git2-testing/README.md b/git2-testing/README.md new file mode 100644 index 00000000..86cd8f84 --- /dev/null +++ b/git2-testing/README.md @@ -0,0 +1,4 @@ +# git2-testing + +*convenience functions on top of git2-rs for convenient unittest repository generation* + diff --git a/git2-testing/src/lib.rs b/git2-testing/src/lib.rs new file mode 100644 index 00000000..7628dcb8 --- /dev/null +++ b/git2-testing/src/lib.rs @@ -0,0 +1,85 @@ +use git2::Repository; +use tempfile::TempDir; + +/// +pub fn repo_init_empty() -> (TempDir, Repository) { + init_log(); + + sandbox_config_files(); + + let td = TempDir::new().unwrap(); + let repo = Repository::init(td.path()).unwrap(); + { + let mut config = repo.config().unwrap(); + config.set_str("user.name", "name").unwrap(); + config.set_str("user.email", "email").unwrap(); + } + + (td, repo) +} + +/// +pub fn repo_init() -> (TempDir, Repository) { + init_log(); + + sandbox_config_files(); + + let td = TempDir::new().unwrap(); + let repo = Repository::init(td.path()).unwrap(); + { + let mut config = repo.config().unwrap(); + config.set_str("user.name", "name").unwrap(); + config.set_str("user.email", "email").unwrap(); + + let mut index = repo.index().unwrap(); + let id = index.write_tree().unwrap(); + + let tree = repo.find_tree(id).unwrap(); + let sig = repo.signature().unwrap(); + repo.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[]) + .unwrap(); + } + + (td, repo) +} + +// init log +fn init_log() { + let _ = env_logger::builder() + .is_test(true) + .filter_level(log::LevelFilter::Trace) + .try_init(); +} + +/// Same as `repo_init`, but the repo is a bare repo (--bare) +pub fn repo_init_bare() -> (TempDir, Repository) { + init_log(); + + let tmp_repo_dir = TempDir::new().unwrap(); + let bare_repo = + Repository::init_bare(tmp_repo_dir.path()).unwrap(); + + (tmp_repo_dir, bare_repo) +} + +/// Calling `set_search_path` with an empty directory makes sure that there +/// is no git config interfering with our tests (for example user-local +/// `.gitconfig`). +#[allow(unsafe_code)] +fn sandbox_config_files() { + use git2::{opts::set_search_path, ConfigLevel}; + use std::sync::Once; + + static INIT: Once = Once::new(); + + // Adapted from https://github.com/rust-lang/cargo/pull/9035 + INIT.call_once(|| unsafe { + let temp_dir = TempDir::new().unwrap(); + let path = temp_dir.path(); + + set_search_path(ConfigLevel::System, path).unwrap(); + set_search_path(ConfigLevel::Global, path).unwrap(); + set_search_path(ConfigLevel::XDG, path).unwrap(); + set_search_path(ConfigLevel::ProgramData, path).unwrap(); + }); +}