//! Hook manager for GitKS. //! //! Manages the installation, listing, and removal of git hooks //! for bare repositories. Supports server hooks, custom hooks, //! and gRPC callback hooks. use std::path::{Path, PathBuf}; use std::time::Duration; use crate::error::{GitError, GitResult}; use crate::hooks::runner::{HookResult, generate_hook_runner_script, run_hook_dir}; use crate::hooks::sanitize::{validate_hook_content, validate_hook_name}; /// Manages git hooks across repositories. #[derive(Debug, Clone)] pub struct HookManager { repo_prefix: PathBuf, server_hooks_dir: Option, hook_callback_addr: Option, hook_timeout: Duration, allow_custom_hooks: bool, } impl HookManager { pub fn new( repo_prefix: PathBuf, server_hooks_dir: Option, hook_callback_addr: Option, hook_timeout: Duration, allow_custom_hooks: bool, ) -> Self { Self { repo_prefix, server_hooks_dir, hook_callback_addr, hook_timeout, allow_custom_hooks, } } /// Install gitks hook runner scripts into a repository's hooks directory. /// Called during repository initialization. pub fn install_hooks(&self, repo_path: &Path) -> GitResult<()> { let hooks_dir = repo_path.join("hooks"); std::fs::create_dir_all(&hooks_dir).map_err(GitError::Io)?; let relative_path = repo_path .strip_prefix(&self.repo_prefix) .unwrap_or(repo_path) .to_string_lossy() .trim_start_matches('/') .to_string(); let server_hooks_dir_str = self .server_hooks_dir .as_ref() .map(|p| p.to_string_lossy().into_owned()); let callback_addr_str = self.hook_callback_addr.clone(); for hook_type in &["pre-receive", "update", "post-receive"] { let script_content = generate_hook_runner_script( hook_type, &relative_path, server_hooks_dir_str.as_deref(), callback_addr_str.as_deref(), self.hook_timeout.as_secs(), ); let hook_path = hooks_dir.join(hook_type); std::fs::write(&hook_path, script_content).map_err(GitError::Io)?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let mut perms = std::fs::metadata(&hook_path) .map_err(GitError::Io)? .permissions(); perms.set_mode(0o755); std::fs::set_permissions(&hook_path, perms).map_err(GitError::Io)?; } tracing::debug!( hook_type = %hook_type, path = %hook_path.display(), "installed gitks hook runner" ); } tracing::info!( path = %repo_path.display(), "hooks installed for repository" ); Ok(()) } /// Set a custom hook script for a repository. pub fn set_custom_hook( &self, repo_path: &Path, hook_name: &str, content: &str, ) -> GitResult<()> { validate_hook_name(hook_name)?; if !self.allow_custom_hooks { return Err(GitError::PermissionDenied( "custom hooks are not allowed on this server".into(), )); } validate_hook_content(content)?; let custom_hooks_dir = repo_path.join("custom_hooks").join(hook_name).join("d"); std::fs::create_dir_all(&custom_hooks_dir).map_err(GitError::Io)?; let script_name = format!("gitks_custom_{}", hook_name.replace('-', "_")); let script_path = custom_hooks_dir.join(&script_name); std::fs::write(&script_path, content).map_err(GitError::Io)?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let mut perms = std::fs::metadata(&script_path) .map_err(GitError::Io)? .permissions(); perms.set_mode(0o755); std::fs::set_permissions(&script_path, perms).map_err(GitError::Io)?; } tracing::info!( repo = %repo_path.display(), hook_name = %hook_name, "custom hook set" ); Ok(()) } /// Remove a custom hook from a repository. pub fn remove_custom_hook(&self, repo_path: &Path, hook_name: &str) -> GitResult<()> { validate_hook_name(hook_name)?; let custom_hooks_dir = repo_path.join("custom_hooks").join(hook_name).join("d"); if custom_hooks_dir.exists() { std::fs::remove_dir_all(&custom_hooks_dir).map_err(GitError::Io)?; tracing::info!( repo = %repo_path.display(), hook_name = %hook_name, "custom hook removed" ); } Ok(()) } /// List all hooks for a repository. pub fn list_hooks(&self, repo_path: &Path) -> GitResult> { let mut hooks = Vec::new(); let hooks_dir = repo_path.join("hooks"); if hooks_dir.exists() { for hook_type in &["pre-receive", "update", "post-receive"] { let hook_path = hooks_dir.join(hook_type); if hook_path.exists() { hooks.push(HookInfo { hook_type: hook_type.to_string(), level: HookLevel::Server, path: hook_path.to_string_lossy().into_owned(), }); } } } if self.allow_custom_hooks { let custom_dir = repo_path.join("custom_hooks"); if custom_dir.exists() { for entry in std::fs::read_dir(&custom_dir).map_err(GitError::Io)? { let entry = entry.map_err(GitError::Io)?; let name = entry.file_name().to_string_lossy().into_owned(); if validate_hook_name(&name).is_ok() { let path = entry.path(); let d_dir = path.join("d"); if d_dir.exists() { let script_count = std::fs::read_dir(&d_dir).map_err(GitError::Io)?.count(); if script_count > 0 { hooks.push(HookInfo { hook_type: name, level: HookLevel::Custom, path: d_dir.to_string_lossy().into_owned(), }); } } } } } } Ok(hooks) } /// Execute a hook by running server hooks then custom hooks. pub fn execute_hook(&self, repo_path: &Path, hook_type: &str, stdin_data: &[u8]) -> HookResult { if let Some(ref server_dir) = self.server_hooks_dir { let dir = server_dir.join(hook_type).join("d"); let result = run_hook_dir(&dir, hook_type, stdin_data, self.hook_timeout); if !result.accepted { return result; } } if self.allow_custom_hooks { let custom_dir = repo_path.join("custom_hooks").join(hook_type).join("d"); let result = run_hook_dir(&custom_dir, hook_type, stdin_data, self.hook_timeout); if !result.accepted { return result; } } HookResult::accepted() } } /// Information about a hook. #[derive(Debug, Clone)] pub struct HookInfo { pub hook_type: String, pub level: HookLevel, pub path: String, } /// Hook level (server vs custom). #[derive(Debug, Clone, PartialEq)] pub enum HookLevel { Server, Custom, } impl std::fmt::Display for HookLevel { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { HookLevel::Server => write!(f, "server"), HookLevel::Custom => write!(f, "custom"), } } }