use crate::types::{HookSubCommand, OperationResult, RepositoryOperation};
use anyhow::{Context, Result};
use git2::Repository as Git2Repository;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use tracing::{debug, info};
const HOOK_TYPES: &[&str] = &[
"applypatch-msg",
"pre-applypatch",
"post-applypatch",
"pre-commit",
"prepare-commit-msg",
"commit-msg",
"post-commit",
"pre-rebase",
"post-checkout",
"post-merge",
"pre-push",
"pre-receive",
"update",
"post-receive",
"post-update",
"push-to-checkout",
"pre-auto-gc",
"post-rewrite",
"sendemail-validate",
];
pub struct HookOperation {
subcommand: HookSubCommand,
}
impl HookOperation {
pub fn new(subcommand: HookSubCommand) -> Self {
Self { subcommand }
}
async fn list_hooks(&self, repo_path: &Path) -> Result<OperationResult> {
let git_hooks_path = repo_path.join(".git/hooks");
if !git_hooks_path.exists() {
return Ok(OperationResult::Failure(format!(
"Hooks directory not found: {}",
git_hooks_path.display()
)));
}
let mut hooks = Vec::new();
for entry in fs::read_dir(&git_hooks_path)
.context(format!("Failed to read hooks directory: {}", git_hooks_path.display()))?
{
let entry = entry?;
let path = entry.path();
if path.is_file() {
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if !name.ends_with(".sample") && HOOK_TYPES.contains(&name) {
hooks.push(name.to_string());
}
}
}
}
if hooks.is_empty() {
Ok(OperationResult::Success(format!(
"No hooks found in {}",
repo_path.display()
)))
} else {
Ok(OperationResult::Success(format!(
"Hooks in {}: {}",
repo_path.display(),
hooks.join(", ")
)))
}
}
async fn create_hook(
&self,
repo_path: &Path,
hook_type: &str,
script_path: &Path,
) -> Result<OperationResult> {
if !HOOK_TYPES.contains(&hook_type) {
return Ok(OperationResult::Failure(format!(
"Invalid hook type: {}. Valid types are: {}",
hook_type,
HOOK_TYPES.join(", ")
)));
}
if !script_path.exists() {
return Ok(OperationResult::Failure(format!(
"Script file not found: {}",
script_path.display()
)));
}
let git_hooks_path = repo_path.join(".git/hooks");
if !git_hooks_path.exists() {
return Ok(OperationResult::Failure(format!(
"Hooks directory not found: {}",
git_hooks_path.display()
)));
}
let target_hook_path = git_hooks_path.join(hook_type);
let script_content = fs::read_to_string(script_path)
.context(format!("Failed to read script: {}", script_path.display()))?;
let mut file = fs::File::create(&target_hook_path)
.context(format!("Failed to create hook: {}", target_hook_path.display()))?;
file.write_all(script_content.as_bytes())
.context("Failed to write hook content")?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&target_hook_path)?.permissions();
perms.set_mode(0o755); fs::set_permissions(&target_hook_path, perms)?;
}
Ok(OperationResult::Success(format!(
"Created hook {} in {}",
hook_type,
repo_path.display()
)))
}
async fn install_hook(&self, repo_path: &Path, hook_type: &str) -> Result<OperationResult> {
if !HOOK_TYPES.contains(&hook_type) {
return Ok(OperationResult::Failure(format!(
"Invalid hook type: {}. Valid types are: {}",
hook_type,
HOOK_TYPES.join(", ")
)));
}
let git_hooks_path = repo_path.join(".git/hooks");
if !git_hooks_path.exists() {
return Ok(OperationResult::Failure(format!(
"Hooks directory not found: {}",
git_hooks_path.display()
)));
}
let sample_hook_path = git_hooks_path.join(format!("{}.sample", hook_type));
if !sample_hook_path.exists() {
return Ok(OperationResult::Failure(format!(
"No sample hook found for {} in {}",
hook_type,
repo_path.display()
)));
}
let target_hook_path = git_hooks_path.join(hook_type);
fs::copy(&sample_hook_path, &target_hook_path)
.context(format!("Failed to copy sample hook from {}", sample_hook_path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&target_hook_path)?.permissions();
perms.set_mode(0o755); fs::set_permissions(&target_hook_path, perms)?;
}
Ok(OperationResult::Success(format!(
"Installed {} hook in {}",
hook_type,
repo_path.display()
)))
}
async fn remove_hook(&self, repo_path: &Path, hook_type: &str) -> Result<OperationResult> {
if !HOOK_TYPES.contains(&hook_type) {
return Ok(OperationResult::Failure(format!(
"Invalid hook type: {}. Valid types are: {}",
hook_type,
HOOK_TYPES.join(", ")
)));
}
let hook_path = repo_path.join(".git/hooks").join(hook_type);
if !hook_path.exists() {
return Ok(OperationResult::Skipped(format!(
"Hook {} not found in {}",
hook_type,
repo_path.display()
)));
}
fs::remove_file(&hook_path)
.context(format!("Failed to remove hook: {}", hook_path.display()))?;
Ok(OperationResult::Success(format!(
"Removed {} hook from {}",
hook_type,
repo_path.display()
)))
}
}
impl RepositoryOperation for HookOperation {
fn name(&self) -> &'static str {
"Hook"
}
fn description(&self) -> &'static str {
"Manage Git hooks across repositories"
}
fn execute<'a>(&'a self, repo_path: &'a Path) ->
std::pin::Pin<Box<dyn std::future::Future<Output = Result<OperationResult>> + Send + 'a>> {
Box::pin(async move {
match Git2Repository::open(repo_path) {
Ok(_) => {
debug!("Valid Git repository: {}", repo_path.display());
}
Err(e) => {
debug!("Invalid Git repository {}: {}", repo_path.display(), e);
return Ok(OperationResult::Skipped(format!(
"Not a valid Git repository: {}",
repo_path.display()
)));
}
}
match &self.subcommand {
HookSubCommand::List => self.list_hooks(repo_path).await,
HookSubCommand::Create { hook_type, script_path } => {
self.create_hook(repo_path, hook_type, script_path).await
}
HookSubCommand::Install { hook_type } => {
self.install_hook(repo_path, hook_type).await
}
HookSubCommand::Remove { hook_type } => {
self.remove_hook(repo_path, hook_type).await
}
}
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs::File;
use tempfile::TempDir;
use tokio::fs::create_dir_all;
#[tokio::test]
async fn test_hook_list() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path();
let git_dir = repo_path.join(".git");
let hooks_dir = git_dir.join("hooks");
create_dir_all(&hooks_dir).await.unwrap();
let hook_path = hooks_dir.join("pre-commit");
File::create(&hook_path).unwrap().write_all(b"#!/bin/sh\nexit 0\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&hook_path).unwrap().permissions();
perms.set_mode(0o755);
fs::set_permissions(&hook_path, perms).unwrap();
}
let sample_hook_path = hooks_dir.join("post-commit.sample");
File::create(&sample_hook_path).unwrap().write_all(b"#!/bin/sh\nexit 0\n").unwrap();
let hook_op = HookOperation::new(HookSubCommand::List);
let result = hook_op.execute(repo_path).await.unwrap();
match result {
OperationResult::Success(msg) => {
assert!(msg.contains("pre-commit"));
assert!(!msg.contains("post-commit.sample"));
}
_ => panic!("Expected Success result"),
}
}
#[tokio::test]
async fn test_hook_create() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path();
let git_dir = repo_path.join(".git");
let hooks_dir = git_dir.join("hooks");
create_dir_all(&hooks_dir).await.unwrap();
let script_dir = temp_dir.path().join("scripts");
create_dir_all(&script_dir).await.unwrap();
let script_path = script_dir.join("test-hook.sh");
File::create(&script_path).unwrap().write_all(b"#!/bin/sh\necho 'Test hook'\nexit 0\n").unwrap();
let hook_op = HookOperation::new(HookSubCommand::Create {
hook_type: "pre-commit".to_string(),
script_path: script_path.clone(),
});
let result = hook_op.execute(repo_path).await.unwrap();
match result {
OperationResult::Success(_) => {
let created_hook_path = hooks_dir.join("pre-commit");
assert!(created_hook_path.exists());
let content = fs::read_to_string(created_hook_path).unwrap();
assert_eq!(content, "#!/bin/sh\necho 'Test hook'\nexit 0\n");
}
_ => panic!("Expected Success result"),
}
}
}