use anyhow::{Context, Result};
use tracing::debug;
use crate::hook::template::HOOK_TEMPLATE;
const CORA_HOOK_SENTINEL: &str = "# cora-managed-hook";
pub fn install_hook() -> Result<String> {
let hooks_dir = find_git_hooks_dir()?;
let hook_path = hooks_dir.join("pre-commit");
if hook_path.is_file() {
let existing = std::fs::read_to_string(&hook_path)?;
if existing.contains(CORA_HOOK_SENTINEL) {
debug!("existing hook is cora-managed, overwriting");
} else {
let backup = hooks_dir.join("pre-commit.pre-cora.bak");
std::fs::copy(&hook_path, &backup)?;
debug!(path = %backup.display(), "backed up existing non-cora hook");
let wrapper = format!(
"{existing}\n\n# cora-managed-hook — the section below was added by `cora hook install`\n{HOOK_TEMPLATE}"
);
std::fs::write(&hook_path, &wrapper)
.with_context(|| format!("failed to write {}", hook_path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o755);
std::fs::set_permissions(&hook_path, perms)?;
}
let path_str = hook_path.display().to_string();
debug!(path = %path_str, "installed pre-commit hook (wrapped existing)");
return Ok(path_str);
}
}
std::fs::write(&hook_path, HOOK_TEMPLATE)
.with_context(|| format!("failed to write {}", hook_path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o755);
std::fs::set_permissions(&hook_path, perms)?;
}
let path_str = hook_path.display().to_string();
debug!(path = %path_str, "installed pre-commit hook");
Ok(path_str)
}
pub fn uninstall_hook() -> Result<()> {
let hooks_dir = find_git_hooks_dir()?;
let hook_path = hooks_dir.join("pre-commit");
let pre_backup = hooks_dir.join("pre-commit.pre-cora.bak");
if !hook_path.is_file() {
return Ok(()); }
let content = std::fs::read_to_string(&hook_path).unwrap_or_default();
if !content.contains("cora") {
debug!("hook exists but is not a cora hook — leaving it");
return Ok(());
}
if pre_backup.is_file() {
std::fs::rename(&pre_backup, &hook_path)
.context("failed to restore pre-cora backup hook")?;
debug!("restored pre-cora hook from backup");
} else {
std::fs::remove_file(&hook_path).context("failed to remove hook")?;
debug!("removed cora hook");
}
Ok(())
}
fn find_git_hooks_dir() -> Result<std::path::PathBuf> {
let output = std::process::Command::new("git")
.args(["rev-parse", "--git-dir"])
.output()
.context("failed to run git — are you in a git repository?")?;
if !output.status.success() {
anyhow::bail!("not inside a git repository");
}
let git_dir = String::from_utf8(output.stdout)
.context("git dir path is not valid UTF-8")?
.trim()
.to_string();
let hooks_dir = std::path::PathBuf::from(&git_dir).join("hooks");
if !hooks_dir.exists() {
std::fs::create_dir_all(&hooks_dir)
.with_context(|| format!("failed to create {}", hooks_dir.display()))?;
}
Ok(hooks_dir)
}
#[allow(dead_code)]
pub fn is_hook_installed() -> Result<bool> {
let hooks_dir = find_git_hooks_dir()?;
let hook_path = hooks_dir.join("pre-commit");
if !hook_path.is_file() {
return Ok(false);
}
let content = std::fs::read_to_string(&hook_path).unwrap_or_default();
Ok(content.contains("cora"))
}
#[cfg(test)]
mod tests {
use std::fs;
use std::process::Command;
fn temp_git_repo() -> tempfile::TempDir {
let tmp = tempfile::tempdir().unwrap();
Command::new("git")
.args(["init"])
.current_dir(tmp.path())
.output()
.unwrap();
tmp
}
#[test]
fn backup_filename_is_pre_cora_bak() {
let tmp = temp_git_repo();
let hooks_dir = tmp.path().join(".git/hooks");
fs::create_dir_all(&hooks_dir).unwrap();
let hook_path = hooks_dir.join("pre-commit");
fs::write(&hook_path, "#!/bin/sh\necho my-hook\n").unwrap();
let expected_backup = "pre-commit.pre-cora.bak";
let source = include_str!("install.rs");
assert!(
!source.contains("\"pre-commit.bak\""),
"install.rs should not use generic 'pre-commit.bak' — it was the root cause of data loss"
);
assert!(
!source.contains("\"pre-commit.cora.bak\""),
"install.rs should not use 'pre-commit.cora.bak' — uninstall never wrote this name"
);
assert!(
source.contains(&format!("\"{expected_backup}\"")),
"install.rs should consistently use '{expected_backup}' for backup"
);
}
}