mirror of
https://github.com/gitui-org/gitui
synced 2026-05-22 08:29:20 +00:00
move git hooks support into separate crate (#1971)
* unique error type name * git2 dependency future * return hook out/err separately
This commit is contained in:
parent
fabed3238b
commit
d4dd58f6ca
14 changed files with 757 additions and 558 deletions
25
Cargo.lock
generated
25
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -69,14 +69,6 @@ pub enum Error {
|
|||
#[error("EasyCast error:{0}")]
|
||||
EasyCast(#[from] easy_cast::Error),
|
||||
|
||||
///
|
||||
#[error("shellexpand error:{0}")]
|
||||
Shell(#[from] shellexpand::LookupError<std::env::VarError>),
|
||||
|
||||
///
|
||||
#[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),
|
||||
}
|
||||
|
||||
///
|
||||
|
|
|
|||
|
|
@ -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<Self> {
|
||||
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 <https://git-scm.com/docs/githooks>
|
||||
pub fn run_hook(&self, args: &[&str]) -> Result<HookResult> {
|
||||
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<git2_hooks::HookResult> 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<HookResult> {
|
||||
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 <https://git-scm.com/docs/githooks#_pre_commit>
|
||||
|
|
@ -135,427 +42,43 @@ pub fn hooks_commit_msg(
|
|||
pub fn hooks_pre_commit(repo_path: &RepoPath) -> Result<HookResult> {
|
||||
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<HookResult> {
|
||||
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<PathBuf> {
|
||||
if cfg!(windows) {
|
||||
Command::new("where.exe")
|
||||
.arg("git")
|
||||
.output()
|
||||
.ok()
|
||||
.map(|out| {
|
||||
PathBuf::from(Into::<String>::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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
23
git2-hooks/Cargo.toml
Normal file
23
git2-hooks/Cargo.toml
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
[package]
|
||||
name = "git2-hooks"
|
||||
version = "0.1.0"
|
||||
authors = ["extrawurst <mail@rusticorn.com>"]
|
||||
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"
|
||||
1
git2-hooks/LICENSE.md
Symbolic link
1
git2-hooks/LICENSE.md
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../LICENSE.md
|
||||
4
git2-hooks/README.md
Normal file
4
git2-hooks/README.md
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# git2-hooks
|
||||
|
||||
adds git hook functionality on top of git2-rs
|
||||
|
||||
28
git2-hooks/src/error.rs
Normal file
28
git2-hooks/src/error.rs
Normal file
|
|
@ -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<std::env::VarError>),
|
||||
}
|
||||
|
||||
///
|
||||
pub type Result<T> = std::result::Result<T, HooksError>;
|
||||
497
git2-hooks/src/lib.rs
Normal file
497
git2-hooks/src/lib.rs
Normal file
|
|
@ -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<Self> {
|
||||
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 <https://git-scm.com/docs/githooks>
|
||||
pub fn run_hook(&self, args: &[&str]) -> Result<HookResult> {
|
||||
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 <https://git-scm.com/docs/githooks#_commit_msg>
|
||||
/// 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<HookResult> {
|
||||
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 <https://git-scm.com/docs/githooks#_pre_commit>
|
||||
///
|
||||
pub fn hooks_pre_commit(repo: &Repository) -> Result<HookResult> {
|
||||
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<HookResult> {
|
||||
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<PathBuf> {
|
||||
if cfg!(windows) {
|
||||
Command::new("where.exe")
|
||||
.arg("git")
|
||||
.output()
|
||||
.ok()
|
||||
.map(|out| {
|
||||
PathBuf::from(Into::<String>::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());
|
||||
}
|
||||
}
|
||||
18
git2-testing/Cargo.toml
Normal file
18
git2-testing/Cargo.toml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
[package]
|
||||
name = "git2-testing"
|
||||
version = "0.1.0"
|
||||
authors = ["extrawurst <mail@rusticorn.com>"]
|
||||
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"
|
||||
1
git2-testing/LICENSE.md
Symbolic link
1
git2-testing/LICENSE.md
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../LICENSE.md
|
||||
4
git2-testing/README.md
Normal file
4
git2-testing/README.md
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# git2-testing
|
||||
|
||||
*convenience functions on top of git2-rs for convenient unittest repository generation*
|
||||
|
||||
85
git2-testing/src/lib.rs
Normal file
85
git2-testing/src/lib.rs
Normal file
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue