use std::fs;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::error::{ActionError, RepoLensError};
const PRE_COMMIT_HOOK: &str = "pre-commit";
const PRE_PUSH_HOOK: &str = "pre-push";
const BACKUP_SUFFIX: &str = ".repolens-backup";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HooksConfig {
#[serde(default = "default_true")]
pub pre_commit: bool,
#[serde(default = "default_true")]
pub pre_push: bool,
#[serde(default)]
pub fail_on_warnings: bool,
}
fn default_true() -> bool {
true
}
impl Default for HooksConfig {
fn default() -> Self {
Self {
pre_commit: true,
pre_push: true,
fail_on_warnings: false,
}
}
}
#[derive(Debug)]
pub struct HooksManager {
hooks_dir: PathBuf,
config: HooksConfig,
}
impl HooksManager {
pub fn new(repo_root: &Path, config: HooksConfig) -> Result<Self, RepoLensError> {
let hooks_dir = find_hooks_dir(repo_root)?;
Ok(Self { hooks_dir, config })
}
pub fn install(&self, force: bool) -> Result<Vec<String>, RepoLensError> {
let mut messages = Vec::new();
fs::create_dir_all(&self.hooks_dir).map_err(|e| {
RepoLensError::Action(ActionError::DirectoryCreate {
path: self.hooks_dir.display().to_string(),
source: e,
})
})?;
if self.config.pre_commit {
let msg = self.install_hook(
PRE_COMMIT_HOOK,
&generate_pre_commit_hook(&self.config),
force,
)?;
messages.push(msg);
}
if self.config.pre_push {
let msg =
self.install_hook(PRE_PUSH_HOOK, &generate_pre_push_hook(&self.config), force)?;
messages.push(msg);
}
Ok(messages)
}
pub fn remove(&self) -> Result<Vec<String>, RepoLensError> {
let mut messages = Vec::new();
let msg = self.remove_hook(PRE_COMMIT_HOOK)?;
messages.push(msg);
let msg = self.remove_hook(PRE_PUSH_HOOK)?;
messages.push(msg);
Ok(messages)
}
fn install_hook(
&self,
hook_name: &str,
content: &str,
force: bool,
) -> Result<String, RepoLensError> {
let hook_path = self.hooks_dir.join(hook_name);
if hook_path.exists() {
if is_repolens_hook(&hook_path)? {
write_hook_file(&hook_path, content)?;
return Ok(format!("Updated existing RepoLens {} hook", hook_name));
}
if !force {
return Err(RepoLensError::Action(ActionError::ExecutionFailed {
message: format!(
"Hook '{}' already exists. Use --force to overwrite (existing hook will be backed up)",
hook_name
),
}));
}
let backup_path = self
.hooks_dir
.join(format!("{}{}", hook_name, BACKUP_SUFFIX));
fs::copy(&hook_path, &backup_path).map_err(|e| {
RepoLensError::Action(ActionError::FileWrite {
path: backup_path.display().to_string(),
source: e,
})
})?;
write_hook_file(&hook_path, content)?;
return Ok(format!(
"Installed {} hook (existing hook backed up to {}{})",
hook_name, hook_name, BACKUP_SUFFIX
));
}
write_hook_file(&hook_path, content)?;
Ok(format!("Installed {} hook", hook_name))
}
fn remove_hook(&self, hook_name: &str) -> Result<String, RepoLensError> {
let hook_path = self.hooks_dir.join(hook_name);
let backup_path = self
.hooks_dir
.join(format!("{}{}", hook_name, BACKUP_SUFFIX));
if !hook_path.exists() {
return Ok(format!("No {} hook to remove", hook_name));
}
if !is_repolens_hook(&hook_path)? {
return Ok(format!(
"Skipped {} hook (not installed by RepoLens)",
hook_name
));
}
fs::remove_file(&hook_path).map_err(|e| {
RepoLensError::Action(ActionError::FileWrite {
path: hook_path.display().to_string(),
source: e,
})
})?;
if backup_path.exists() {
fs::rename(&backup_path, &hook_path).map_err(|e| {
RepoLensError::Action(ActionError::FileWrite {
path: hook_path.display().to_string(),
source: e,
})
})?;
return Ok(format!(
"Removed {} hook and restored previous hook from backup",
hook_name
));
}
Ok(format!("Removed {} hook", hook_name))
}
#[allow(dead_code)]
pub fn hooks_dir(&self) -> &Path {
&self.hooks_dir
}
}
fn find_hooks_dir(repo_root: &Path) -> Result<PathBuf, RepoLensError> {
let git_dir = repo_root.join(".git");
if git_dir.is_dir() {
return Ok(git_dir.join("hooks"));
}
if git_dir.is_file() {
let content = fs::read_to_string(&git_dir).map_err(|e| {
RepoLensError::Action(ActionError::ExecutionFailed {
message: format!("Failed to read .git file: {}", e),
})
})?;
if let Some(gitdir_path) = content.strip_prefix("gitdir: ") {
let gitdir_path = gitdir_path.trim();
let gitdir = if Path::new(gitdir_path).is_absolute() {
PathBuf::from(gitdir_path)
} else {
repo_root.join(gitdir_path)
};
return Ok(gitdir.join("hooks"));
}
}
Err(RepoLensError::Action(ActionError::ExecutionFailed {
message: format!(
"Could not find .git directory in {}. Is this a git repository?",
repo_root.display()
),
}))
}
fn is_repolens_hook(hook_path: &Path) -> Result<bool, RepoLensError> {
let content = fs::read_to_string(hook_path).map_err(|e| {
RepoLensError::Action(ActionError::ExecutionFailed {
message: format!("Failed to read hook file '{}': {}", hook_path.display(), e),
})
})?;
Ok(content.contains("# RepoLens Git Hook"))
}
fn write_hook_file(path: &Path, content: &str) -> Result<(), RepoLensError> {
fs::write(path, content).map_err(|e| {
RepoLensError::Action(ActionError::FileWrite {
path: path.display().to_string(),
source: e,
})
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let permissions = fs::Permissions::from_mode(0o755);
fs::set_permissions(path, permissions).map_err(|e| {
RepoLensError::Action(ActionError::FileWrite {
path: path.display().to_string(),
source: e,
})
})?;
}
Ok(())
}
pub fn generate_pre_commit_hook(config: &HooksConfig) -> String {
let fail_on_warnings = if config.fail_on_warnings {
" --fail-on-warnings"
} else {
""
};
format!(
r#"#!/bin/sh
# RepoLens Git Hook - pre-commit
# This hook was automatically installed by RepoLens.
# It checks for exposed secrets before allowing a commit.
#
# To skip this hook, use: git commit --no-verify
set -e
echo "RepoLens: Checking for exposed secrets..."
if ! command -v repolens >/dev/null 2>&1; then
echo "Warning: repolens is not installed or not in PATH. Skipping pre-commit check."
echo "Install it with: cargo install repolens"
exit 0
fi
if ! repolens plan --only secrets --format terminal{fail_on_warnings} 2>/dev/null; then
echo ""
echo "RepoLens: Secrets detected! Commit aborted."
echo "Please remove or ignore the detected secrets before committing."
echo "To skip this check, use: git commit --no-verify"
exit 1
fi
echo "RepoLens: No secrets detected. Proceeding with commit."
"#
)
}
pub fn generate_pre_push_hook(config: &HooksConfig) -> String {
let fail_on_warnings = if config.fail_on_warnings {
" --fail-on-warnings"
} else {
""
};
format!(
r#"#!/bin/sh
# RepoLens Git Hook - pre-push
# This hook was automatically installed by RepoLens.
# It runs a full audit before allowing a push.
#
# To skip this hook, use: git push --no-verify
set -e
echo "RepoLens: Running full audit before push..."
if ! command -v repolens >/dev/null 2>&1; then
echo "Warning: repolens is not installed or not in PATH. Skipping pre-push check."
echo "Install it with: cargo install repolens"
exit 0
fi
if ! repolens plan --format terminal{fail_on_warnings} 2>/dev/null; then
echo ""
echo "RepoLens: Audit issues found! Push aborted."
echo "Please fix the issues before pushing."
echo "To skip this check, use: git push --no-verify"
exit 1
fi
echo "RepoLens: Audit passed. Proceeding with push."
"#
)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn create_temp_repo() -> TempDir {
let temp_dir = TempDir::new().unwrap();
let git_dir = temp_dir.path().join(".git");
fs::create_dir_all(git_dir.join("hooks")).unwrap();
temp_dir
}
fn create_temp_worktree() -> (TempDir, TempDir) {
let main_repo = TempDir::new().unwrap();
let main_git_dir = main_repo.path().join(".git");
fs::create_dir_all(main_git_dir.join("worktrees").join("wt1")).unwrap();
fs::create_dir_all(main_git_dir.join("hooks")).unwrap();
let worktree = TempDir::new().unwrap();
let wt_git_dir = main_git_dir.join("worktrees").join("wt1");
fs::create_dir_all(&wt_git_dir).unwrap();
let git_file_content = format!("gitdir: {}", wt_git_dir.display());
fs::write(worktree.path().join(".git"), git_file_content).unwrap();
fs::create_dir_all(wt_git_dir.join("hooks")).unwrap();
(main_repo, worktree)
}
#[test]
fn test_hooks_config_default() {
let config = HooksConfig::default();
assert!(config.pre_commit);
assert!(config.pre_push);
assert!(!config.fail_on_warnings);
}
#[test]
fn test_find_hooks_dir_standard_repo() {
let temp_dir = create_temp_repo();
let hooks_dir = find_hooks_dir(temp_dir.path()).unwrap();
assert_eq!(hooks_dir, temp_dir.path().join(".git/hooks"));
}
#[test]
fn test_find_hooks_dir_worktree() {
let (_main_repo, worktree) = create_temp_worktree();
let hooks_dir = find_hooks_dir(worktree.path()).unwrap();
assert!(hooks_dir.to_string_lossy().contains("hooks"));
}
#[test]
fn test_find_hooks_dir_not_a_repo() {
let temp_dir = TempDir::new().unwrap();
let result = find_hooks_dir(temp_dir.path());
assert!(result.is_err());
}
#[test]
fn test_hooks_manager_new() {
let temp_dir = create_temp_repo();
let config = HooksConfig::default();
let manager = HooksManager::new(temp_dir.path(), config);
assert!(manager.is_ok());
}
#[test]
fn test_hooks_manager_new_not_a_repo() {
let temp_dir = TempDir::new().unwrap();
let config = HooksConfig::default();
let result = HooksManager::new(temp_dir.path(), config);
assert!(result.is_err());
}
#[test]
fn test_install_all_hooks() {
let temp_dir = create_temp_repo();
let config = HooksConfig::default();
let manager = HooksManager::new(temp_dir.path(), config).unwrap();
let messages = manager.install(false).unwrap();
assert_eq!(messages.len(), 2);
assert!(temp_dir.path().join(".git/hooks/pre-commit").exists());
assert!(temp_dir.path().join(".git/hooks/pre-push").exists());
}
#[test]
fn test_install_pre_commit_only() {
let temp_dir = create_temp_repo();
let config = HooksConfig {
pre_commit: true,
pre_push: false,
fail_on_warnings: false,
};
let manager = HooksManager::new(temp_dir.path(), config).unwrap();
let messages = manager.install(false).unwrap();
assert_eq!(messages.len(), 1);
assert!(messages[0].contains("pre-commit"));
assert!(temp_dir.path().join(".git/hooks/pre-commit").exists());
assert!(!temp_dir.path().join(".git/hooks/pre-push").exists());
}
#[test]
fn test_install_pre_push_only() {
let temp_dir = create_temp_repo();
let config = HooksConfig {
pre_commit: false,
pre_push: true,
fail_on_warnings: false,
};
let manager = HooksManager::new(temp_dir.path(), config).unwrap();
let messages = manager.install(false).unwrap();
assert_eq!(messages.len(), 1);
assert!(messages[0].contains("pre-push"));
assert!(!temp_dir.path().join(".git/hooks/pre-commit").exists());
assert!(temp_dir.path().join(".git/hooks/pre-push").exists());
}
#[test]
fn test_install_existing_hook_without_force() {
let temp_dir = create_temp_repo();
let hook_path = temp_dir.path().join(".git/hooks/pre-commit");
fs::write(&hook_path, "#!/bin/sh\necho 'existing hook'").unwrap();
let config = HooksConfig {
pre_commit: true,
pre_push: false,
fail_on_warnings: false,
};
let manager = HooksManager::new(temp_dir.path(), config).unwrap();
let result = manager.install(false);
assert!(result.is_err());
}
#[test]
fn test_install_existing_hook_with_force() {
let temp_dir = create_temp_repo();
let hook_path = temp_dir.path().join(".git/hooks/pre-commit");
fs::write(&hook_path, "#!/bin/sh\necho 'existing hook'").unwrap();
let config = HooksConfig {
pre_commit: true,
pre_push: false,
fail_on_warnings: false,
};
let manager = HooksManager::new(temp_dir.path(), config).unwrap();
let messages = manager.install(true).unwrap();
assert_eq!(messages.len(), 1);
assert!(messages[0].contains("backed up"));
let backup_path = temp_dir
.path()
.join(".git/hooks/pre-commit.repolens-backup");
assert!(backup_path.exists());
let backup_content = fs::read_to_string(&backup_path).unwrap();
assert!(backup_content.contains("existing hook"));
let new_content = fs::read_to_string(&hook_path).unwrap();
assert!(new_content.contains("RepoLens Git Hook"));
}
#[test]
fn test_install_overwrites_existing_repolens_hook() {
let temp_dir = create_temp_repo();
let hook_path = temp_dir.path().join(".git/hooks/pre-commit");
fs::write(
&hook_path,
"#!/bin/sh\n# RepoLens Git Hook\necho 'old version'",
)
.unwrap();
let config = HooksConfig {
pre_commit: true,
pre_push: false,
fail_on_warnings: false,
};
let manager = HooksManager::new(temp_dir.path(), config).unwrap();
let messages = manager.install(false).unwrap();
assert_eq!(messages.len(), 1);
assert!(messages[0].contains("Updated"));
let backup_path = temp_dir
.path()
.join(".git/hooks/pre-commit.repolens-backup");
assert!(!backup_path.exists());
}
#[test]
fn test_remove_hooks() {
let temp_dir = create_temp_repo();
let config = HooksConfig::default();
let manager = HooksManager::new(temp_dir.path(), config).unwrap();
manager.install(false).unwrap();
let messages = manager.remove().unwrap();
assert_eq!(messages.len(), 2);
assert!(!temp_dir.path().join(".git/hooks/pre-commit").exists());
assert!(!temp_dir.path().join(".git/hooks/pre-push").exists());
}
#[test]
fn test_remove_nonexistent_hooks() {
let temp_dir = create_temp_repo();
let config = HooksConfig::default();
let manager = HooksManager::new(temp_dir.path(), config).unwrap();
let messages = manager.remove().unwrap();
assert_eq!(messages.len(), 2);
assert!(messages[0].contains("No"));
assert!(messages[1].contains("No"));
}
#[test]
fn test_remove_non_repolens_hook() {
let temp_dir = create_temp_repo();
let hook_path = temp_dir.path().join(".git/hooks/pre-commit");
fs::write(&hook_path, "#!/bin/sh\necho 'custom hook'").unwrap();
let config = HooksConfig::default();
let manager = HooksManager::new(temp_dir.path(), config).unwrap();
let messages = manager.remove().unwrap();
assert!(messages[0].contains("Skipped"));
assert!(hook_path.exists());
}
#[test]
fn test_remove_with_backup_restoration() {
let temp_dir = create_temp_repo();
let hook_path = temp_dir.path().join(".git/hooks/pre-commit");
let backup_path = temp_dir
.path()
.join(".git/hooks/pre-commit.repolens-backup");
fs::write(&hook_path, "#!/bin/sh\necho 'original hook'").unwrap();
let config = HooksConfig {
pre_commit: true,
pre_push: false,
fail_on_warnings: false,
};
let manager = HooksManager::new(temp_dir.path(), config).unwrap();
manager.install(true).unwrap();
assert!(backup_path.exists());
let messages = manager.remove().unwrap();
assert!(messages[0].contains("restored"));
let content = fs::read_to_string(&hook_path).unwrap();
assert!(content.contains("original hook"));
assert!(!backup_path.exists());
}
#[test]
fn test_generate_pre_commit_hook_content() {
let config = HooksConfig::default();
let content = generate_pre_commit_hook(&config);
assert!(content.starts_with("#!/bin/sh"));
assert!(content.contains("# RepoLens Git Hook"));
assert!(content.contains("pre-commit"));
assert!(content.contains("repolens plan --only secrets"));
assert!(content.contains("--no-verify"));
assert!(!content.contains("--fail-on-warnings"));
}
#[test]
fn test_generate_pre_commit_hook_with_fail_on_warnings() {
let config = HooksConfig {
pre_commit: true,
pre_push: true,
fail_on_warnings: true,
};
let content = generate_pre_commit_hook(&config);
assert!(content.contains("--fail-on-warnings"));
}
#[test]
fn test_generate_pre_push_hook_content() {
let config = HooksConfig::default();
let content = generate_pre_push_hook(&config);
assert!(content.starts_with("#!/bin/sh"));
assert!(content.contains("# RepoLens Git Hook"));
assert!(content.contains("pre-push"));
assert!(content.contains("repolens plan"));
assert!(content.contains("--no-verify"));
assert!(!content.contains("--fail-on-warnings"));
}
#[test]
fn test_generate_pre_push_hook_with_fail_on_warnings() {
let config = HooksConfig {
pre_commit: true,
pre_push: true,
fail_on_warnings: true,
};
let content = generate_pre_push_hook(&config);
assert!(content.contains("--fail-on-warnings"));
}
#[test]
fn test_is_repolens_hook() {
let temp_dir = TempDir::new().unwrap();
let repolens_hook = temp_dir.path().join("repolens-hook");
fs::write(&repolens_hook, "#!/bin/sh\n# RepoLens Git Hook\necho test").unwrap();
assert!(is_repolens_hook(&repolens_hook).unwrap());
let other_hook = temp_dir.path().join("other-hook");
fs::write(&other_hook, "#!/bin/sh\necho test").unwrap();
assert!(!is_repolens_hook(&other_hook).unwrap());
}
#[test]
fn test_write_hook_file() {
let temp_dir = TempDir::new().unwrap();
let hook_path = temp_dir.path().join("test-hook");
let content = "#!/bin/sh\necho 'test'";
write_hook_file(&hook_path, content).unwrap();
let written = fs::read_to_string(&hook_path).unwrap();
assert_eq!(written, content);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let metadata = fs::metadata(&hook_path).unwrap();
let mode = metadata.permissions().mode();
assert_eq!(mode & 0o755, 0o755);
}
}
#[test]
fn test_hooks_manager_hooks_dir() {
let temp_dir = create_temp_repo();
let config = HooksConfig::default();
let manager = HooksManager::new(temp_dir.path(), config).unwrap();
assert_eq!(manager.hooks_dir(), temp_dir.path().join(".git/hooks"));
}
#[test]
fn test_install_creates_hooks_dir_if_missing() {
let temp_dir = TempDir::new().unwrap();
let git_dir = temp_dir.path().join(".git");
fs::create_dir_all(&git_dir).unwrap();
let config = HooksConfig {
pre_commit: true,
pre_push: false,
fail_on_warnings: false,
};
let manager = HooksManager::new(temp_dir.path(), config).unwrap();
let messages = manager.install(false).unwrap();
assert_eq!(messages.len(), 1);
assert!(temp_dir.path().join(".git/hooks/pre-commit").exists());
}
#[test]
fn test_install_no_hooks_selected() {
let temp_dir = create_temp_repo();
let config = HooksConfig {
pre_commit: false,
pre_push: false,
fail_on_warnings: false,
};
let manager = HooksManager::new(temp_dir.path(), config).unwrap();
let messages = manager.install(false).unwrap();
assert!(messages.is_empty());
}
#[test]
fn test_find_hooks_dir_invalid_git_file() {
let temp_dir = TempDir::new().unwrap();
fs::write(temp_dir.path().join(".git"), "not a gitdir reference").unwrap();
let result = find_hooks_dir(temp_dir.path());
assert!(result.is_err());
}
#[test]
fn test_hooks_config_serialize_deserialize() {
let config = HooksConfig {
pre_commit: true,
pre_push: false,
fail_on_warnings: true,
};
let toml_str = toml::to_string(&config).unwrap();
let deserialized: HooksConfig = toml::from_str(&toml_str).unwrap();
assert!(deserialized.pre_commit);
assert!(!deserialized.pre_push);
assert!(deserialized.fail_on_warnings);
}
#[test]
fn test_hooks_config_deserialize_from_toml() {
let toml_str = r#"
pre_commit = false
pre_push = true
fail_on_warnings = true
"#;
let config: HooksConfig = toml::from_str(toml_str).unwrap();
assert!(!config.pre_commit);
assert!(config.pre_push);
assert!(config.fail_on_warnings);
}
#[test]
fn test_hooks_config_default_serde() {
let toml_str = "";
let config: HooksConfig = toml::from_str(toml_str).unwrap();
assert!(config.pre_commit);
assert!(config.pre_push);
assert!(!config.fail_on_warnings);
}
#[test]
fn test_find_hooks_dir_worktree_with_absolute_path() {
let main_repo = TempDir::new().unwrap();
let main_git_dir = main_repo.path().join(".git");
fs::create_dir_all(main_git_dir.join("worktrees").join("wt-abs")).unwrap();
fs::create_dir_all(main_git_dir.join("hooks")).unwrap();
let worktree = TempDir::new().unwrap();
let wt_git_dir = main_git_dir.join("worktrees").join("wt-abs");
fs::create_dir_all(wt_git_dir.join("hooks")).unwrap();
let git_file_content = format!("gitdir: {}", wt_git_dir.display());
fs::write(worktree.path().join(".git"), git_file_content).unwrap();
let hooks_dir = find_hooks_dir(worktree.path()).unwrap();
assert!(hooks_dir.to_string_lossy().contains("hooks"));
assert_eq!(hooks_dir, wt_git_dir.join("hooks"));
}
#[test]
fn test_is_repolens_hook_nonexistent_file() {
let temp_dir = TempDir::new().unwrap();
let nonexistent = temp_dir.path().join("nonexistent");
let result = is_repolens_hook(&nonexistent);
assert!(result.is_err());
}
#[test]
fn test_install_hook_content_contains_expected_strings() {
let temp_dir = create_temp_repo();
let config = HooksConfig {
pre_commit: true,
pre_push: true,
fail_on_warnings: false,
};
let manager = HooksManager::new(temp_dir.path(), config).unwrap();
manager.install(false).unwrap();
let pre_commit_content =
fs::read_to_string(temp_dir.path().join(".git/hooks/pre-commit")).unwrap();
assert!(pre_commit_content.contains("#!/bin/sh"));
assert!(pre_commit_content.contains("# RepoLens Git Hook"));
assert!(pre_commit_content.contains("repolens plan --only secrets"));
let pre_push_content =
fs::read_to_string(temp_dir.path().join(".git/hooks/pre-push")).unwrap();
assert!(pre_push_content.contains("#!/bin/sh"));
assert!(pre_push_content.contains("# RepoLens Git Hook"));
assert!(pre_push_content.contains("repolens plan --format terminal"));
}
#[test]
fn test_install_then_reinstall_repolens_hooks() {
let temp_dir = create_temp_repo();
let config = HooksConfig::default();
let manager = HooksManager::new(temp_dir.path(), config).unwrap();
let messages = manager.install(false).unwrap();
assert_eq!(messages.len(), 2);
assert!(messages[0].contains("Installed"));
assert!(messages[1].contains("Installed"));
let messages = manager.install(false).unwrap();
assert_eq!(messages.len(), 2);
assert!(messages[0].contains("Updated"));
assert!(messages[1].contains("Updated"));
}
#[test]
fn test_remove_pre_push_hook() {
let temp_dir = create_temp_repo();
let config = HooksConfig::default();
let manager = HooksManager::new(temp_dir.path(), config).unwrap();
manager.install(false).unwrap();
assert!(temp_dir.path().join(".git/hooks/pre-push").exists());
let messages = manager.remove().unwrap();
assert_eq!(messages.len(), 2);
assert!(messages[0].contains("Removed pre-commit"));
assert!(messages[1].contains("Removed pre-push"));
assert!(!temp_dir.path().join(".git/hooks/pre-commit").exists());
assert!(!temp_dir.path().join(".git/hooks/pre-push").exists());
}
#[test]
fn test_force_install_pre_push_with_existing() {
let temp_dir = create_temp_repo();
let hook_path = temp_dir.path().join(".git/hooks/pre-push");
fs::write(&hook_path, "#!/bin/sh\necho 'existing pre-push'").unwrap();
let config = HooksConfig {
pre_commit: false,
pre_push: true,
fail_on_warnings: false,
};
let manager = HooksManager::new(temp_dir.path(), config).unwrap();
let messages = manager.install(true).unwrap();
assert_eq!(messages.len(), 1);
assert!(messages[0].contains("backed up"));
let backup_path = temp_dir.path().join(".git/hooks/pre-push.repolens-backup");
assert!(backup_path.exists());
}
#[test]
fn test_write_hook_file_error_on_invalid_path() {
let result = write_hook_file(Path::new("/nonexistent/dir/hook"), "content");
assert!(result.is_err());
}
#[test]
fn test_hooks_config_clone() {
let config = HooksConfig {
pre_commit: false,
pre_push: true,
fail_on_warnings: true,
};
let cloned = config.clone();
assert_eq!(cloned.pre_commit, config.pre_commit);
assert_eq!(cloned.pre_push, config.pre_push);
assert_eq!(cloned.fail_on_warnings, config.fail_on_warnings);
}
#[test]
fn test_hooks_config_debug() {
let config = HooksConfig::default();
let debug_str = format!("{:?}", config);
assert!(debug_str.contains("HooksConfig"));
assert!(debug_str.contains("pre_commit"));
}
#[test]
fn test_hooks_manager_debug() {
let temp_dir = create_temp_repo();
let config = HooksConfig::default();
let manager = HooksManager::new(temp_dir.path(), config).unwrap();
let debug_str = format!("{:?}", manager);
assert!(debug_str.contains("HooksManager"));
}
#[test]
fn test_find_hooks_dir_worktree_with_relative_path() {
let temp_dir = TempDir::new().unwrap();
let main_git_dir = temp_dir.path().join(".git");
let wt_git_dir = main_git_dir.join("worktrees").join("wt-rel");
fs::create_dir_all(wt_git_dir.join("hooks")).unwrap();
let worktree_dir = temp_dir.path().join("worktree");
fs::create_dir_all(&worktree_dir).unwrap();
let git_file_content = "gitdir: ../.git/worktrees/wt-rel";
fs::write(worktree_dir.join(".git"), git_file_content).unwrap();
let hooks_dir = find_hooks_dir(&worktree_dir).unwrap();
assert!(hooks_dir.to_string_lossy().contains("hooks"));
}
#[test]
fn test_remove_hook_restores_backup_for_pre_push() {
let temp_dir = create_temp_repo();
let hook_path = temp_dir.path().join(".git/hooks/pre-push");
let backup_path = temp_dir.path().join(".git/hooks/pre-push.repolens-backup");
fs::write(&hook_path, "#!/bin/sh\necho 'original pre-push'").unwrap();
let config = HooksConfig {
pre_commit: false,
pre_push: true,
fail_on_warnings: false,
};
let manager = HooksManager::new(temp_dir.path(), config).unwrap();
manager.install(true).unwrap();
assert!(backup_path.exists());
let messages = manager.remove().unwrap();
assert!(messages[1].contains("restored"));
let content = fs::read_to_string(&hook_path).unwrap();
assert!(content.contains("original pre-push"));
assert!(!backup_path.exists());
}
#[test]
fn test_install_both_hooks_with_force_over_existing() {
let temp_dir = create_temp_repo();
let pre_commit_path = temp_dir.path().join(".git/hooks/pre-commit");
let pre_push_path = temp_dir.path().join(".git/hooks/pre-push");
fs::write(&pre_commit_path, "#!/bin/sh\necho 'old commit hook'").unwrap();
fs::write(&pre_push_path, "#!/bin/sh\necho 'old push hook'").unwrap();
let config = HooksConfig::default();
let manager = HooksManager::new(temp_dir.path(), config).unwrap();
let messages = manager.install(true).unwrap();
assert_eq!(messages.len(), 2);
assert!(messages[0].contains("backed up"));
assert!(messages[1].contains("backed up"));
assert!(
temp_dir
.path()
.join(".git/hooks/pre-commit.repolens-backup")
.exists()
);
assert!(
temp_dir
.path()
.join(".git/hooks/pre-push.repolens-backup")
.exists()
);
let commit_content = fs::read_to_string(&pre_commit_path).unwrap();
assert!(commit_content.contains("RepoLens Git Hook"));
let push_content = fs::read_to_string(&pre_push_path).unwrap();
assert!(push_content.contains("RepoLens Git Hook"));
}
#[test]
fn test_generate_hooks_scripts_structure() {
let config = HooksConfig {
pre_commit: true,
pre_push: true,
fail_on_warnings: false,
};
let pre_commit = generate_pre_commit_hook(&config);
assert!(pre_commit.contains("set -e"));
assert!(pre_commit.contains("command -v repolens"));
assert!(pre_commit.contains("cargo install repolens"));
assert!(pre_commit.contains("exit 0"));
assert!(pre_commit.contains("exit 1"));
let pre_push = generate_pre_push_hook(&config);
assert!(pre_push.contains("set -e"));
assert!(pre_push.contains("command -v repolens"));
assert!(pre_push.contains("cargo install repolens"));
assert!(pre_push.contains("exit 0"));
assert!(pre_push.contains("exit 1"));
}
#[cfg(unix)]
#[test]
fn test_install_fails_when_hooks_dir_not_creatable() {
use std::os::unix::fs::PermissionsExt;
let temp_dir = TempDir::new().unwrap();
let git_dir = temp_dir.path().join(".git");
fs::create_dir_all(&git_dir).unwrap();
fs::set_permissions(&git_dir, fs::Permissions::from_mode(0o444)).unwrap();
let config = HooksConfig::default();
let manager = HooksManager::new(temp_dir.path(), config).unwrap();
let result = manager.install(false);
assert!(result.is_err());
fs::set_permissions(&git_dir, fs::Permissions::from_mode(0o755)).unwrap();
}
#[cfg(unix)]
#[test]
fn test_remove_hook_fails_when_file_not_removable() {
use std::os::unix::fs::PermissionsExt;
let temp_dir = create_temp_repo();
let hooks_dir = temp_dir.path().join(".git/hooks");
let config = HooksConfig::default();
let manager = HooksManager::new(temp_dir.path(), config).unwrap();
manager.install(false).unwrap();
fs::set_permissions(&hooks_dir, fs::Permissions::from_mode(0o555)).unwrap();
let result = manager.remove();
assert!(result.is_err());
fs::set_permissions(&hooks_dir, fs::Permissions::from_mode(0o755)).unwrap();
}
#[cfg(unix)]
#[test]
fn test_install_force_fails_when_backup_not_possible() {
use std::os::unix::fs::PermissionsExt;
let temp_dir = create_temp_repo();
let hooks_dir = temp_dir.path().join(".git/hooks");
let hook_path = hooks_dir.join("pre-commit");
fs::write(&hook_path, "#!/bin/sh\necho 'existing'").unwrap();
fs::set_permissions(&hooks_dir, fs::Permissions::from_mode(0o555)).unwrap();
let config = HooksConfig {
pre_commit: true,
pre_push: false,
fail_on_warnings: false,
};
let manager = HooksManager::new(temp_dir.path(), config).unwrap();
let result = manager.install(true);
assert!(result.is_err());
fs::set_permissions(&hooks_dir, fs::Permissions::from_mode(0o755)).unwrap();
}
#[test]
fn test_remove_all_hooks_with_mixed_state() {
let temp_dir = create_temp_repo();
let config = HooksConfig {
pre_commit: true,
pre_push: false,
fail_on_warnings: false,
};
let manager = HooksManager::new(temp_dir.path(), config).unwrap();
manager.install(false).unwrap();
let pre_push_path = temp_dir.path().join(".git/hooks/pre-push");
fs::write(&pre_push_path, "#!/bin/sh\necho 'custom push hook'").unwrap();
let full_config = HooksConfig::default();
let full_manager = HooksManager::new(temp_dir.path(), full_config).unwrap();
let messages = full_manager.remove().unwrap();
assert!(messages[0].contains("Removed pre-commit"));
assert!(messages[1].contains("Skipped"));
assert!(!temp_dir.path().join(".git/hooks/pre-commit").exists());
assert!(pre_push_path.exists());
}
}