use anyhow::{bail, Context, Result};
use std::fs;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
const HOOK_MARKER_START: &str = "# --- clearedforpush hook start ---";
const HOOK_MARKER_END: &str = "# --- clearedforpush hook end ---";
const HOOK_SCRIPT: &str = r#"# --- clearedforpush hook start ---
# Installed by clearedforpush (https://github.com/sanjayrohith/clearedforpush)
# Runs conflict check before push. Use --no-verify to bypass.
if command -v clearedforpush &> /dev/null; then
echo ""
clearedforpush check
CFP_EXIT=$?
if [ $CFP_EXIT -eq 1 ]; then
echo ""
echo "Push blocked: merge conflicts detected."
echo "Resolve conflicts or use 'git push --no-verify' to bypass."
exit 1
elif [ $CFP_EXIT -eq 2 ]; then
echo ""
echo "clearedforpush encountered an error. Allowing push to continue."
fi
fi
# --- clearedforpush hook end ---"#;
fn get_hook_path() -> Result<PathBuf> {
let output = std::process::Command::new("git")
.args(["rev-parse", "--git-dir"])
.output()
.context("Failed to run git rev-parse")?;
if !output.status.success() {
bail!("Not a git repository");
}
let git_dir = String::from_utf8_lossy(&output.stdout).trim().to_string();
Ok(PathBuf::from(git_dir).join("hooks").join("pre-push"))
}
fn has_clearedforpush_hook(content: &str) -> bool {
content.contains(HOOK_MARKER_START) && content.contains(HOOK_MARKER_END)
}
pub fn install_hook(force: bool) -> Result<HookInstallResult> {
let hook_path = get_hook_path()?;
if let Some(parent) = hook_path.parent() {
fs::create_dir_all(parent).context("Failed to create hooks directory")?;
}
if hook_path.exists() {
let existing_content =
fs::read_to_string(&hook_path).context("Failed to read existing hook")?;
if has_clearedforpush_hook(&existing_content) {
return Ok(HookInstallResult::AlreadyInstalled);
}
if !force {
return Ok(HookInstallResult::ExistingHookFound {
path: hook_path.display().to_string(),
});
}
let new_content = format!("{}\n\n{}", existing_content.trim_end(), HOOK_SCRIPT);
fs::write(&hook_path, &new_content).context("Failed to write hook file")?;
make_executable(&hook_path)?;
return Ok(HookInstallResult::Chained);
}
let content = format!("#!/bin/sh\n\n{}\n", HOOK_SCRIPT);
fs::write(&hook_path, &content).context("Failed to write hook file")?;
make_executable(&hook_path)?;
Ok(HookInstallResult::Installed {
path: hook_path.display().to_string(),
})
}
pub fn uninstall_hook() -> Result<HookUninstallResult> {
let hook_path = get_hook_path()?;
if !hook_path.exists() {
return Ok(HookUninstallResult::NoHookFound);
}
let content = fs::read_to_string(&hook_path).context("Failed to read hook file")?;
if !has_clearedforpush_hook(&content) {
return Ok(HookUninstallResult::NotInstalled);
}
let cleaned = remove_clearedforpush_section(&content);
let trimmed = cleaned.trim();
if trimmed.is_empty() || trimmed == "#!/bin/sh" || trimmed == "#!/bin/bash" {
fs::remove_file(&hook_path).context("Failed to remove hook file")?;
return Ok(HookUninstallResult::Removed);
}
fs::write(&hook_path, &cleaned).context("Failed to write hook file")?;
Ok(HookUninstallResult::SectionRemoved)
}
fn remove_clearedforpush_section(content: &str) -> String {
let mut result = String::new();
let mut skipping = false;
for line in content.lines() {
if line.trim() == HOOK_MARKER_START {
skipping = true;
continue;
}
if line.trim() == HOOK_MARKER_END {
skipping = false;
continue;
}
if !skipping {
result.push_str(line);
result.push('\n');
}
}
result
}
#[cfg(unix)]
fn make_executable(path: &PathBuf) -> Result<()> {
let mut perms = fs::metadata(path)
.context("Failed to read file metadata")?
.permissions();
perms.set_mode(0o755);
fs::set_permissions(path, perms).context("Failed to set file permissions")?;
Ok(())
}
#[cfg(not(unix))]
fn make_executable(_path: &PathBuf) -> Result<()> {
Ok(())
}
#[derive(Debug)]
pub enum HookInstallResult {
Installed { path: String },
Chained,
AlreadyInstalled,
ExistingHookFound { path: String },
}
#[derive(Debug)]
pub enum HookUninstallResult {
Removed,
SectionRemoved,
NoHookFound,
NotInstalled,
}