use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use std::process::ExitCode;
use anyhow::Result;
use serde::Serialize;
use crate::output::CommandReport;
use crate::paths::write;
const CCD_MARKER: &str = "# CCD-MANAGED";
const HOOK_NAME: &str = "pre-commit";
fn hook_script() -> String {
format!(
r#"#!/bin/sh
{CCD_MARKER}
# Installed by: ccd hooks install
# Gates: ccd check + ccd doctor + ccd sync --check
set -e
# Resolve ccd binary (same search order as ccd-start).
if [ -n "$CCD_BIN" ] && [ -x "$CCD_BIN" ]; then
CCD="$CCD_BIN"
elif command -v ccd >/dev/null 2>&1; then
CCD=ccd
elif [ -x "$HOME/.ccd/bin/ccd" ]; then
CCD="$HOME/.ccd/bin/ccd"
elif [ -x "$HOME/.cargo/bin/ccd" ]; then
CCD="$HOME/.cargo/bin/ccd"
else
echo "ccd: not found — skipping pre-commit gates"
exit 0
fi
CHECK_OUTPUT="$(mktemp "${{TMPDIR:-/tmp}}/ccd-check.XXXXXX")"
if ! "$CCD" check --path . >"$CHECK_OUTPUT" 2>&1; then
cat "$CHECK_OUTPUT"
rm -f "$CHECK_OUTPUT"
exit 1
fi
rm -f "$CHECK_OUTPUT"
"$CCD" doctor --path . --skip-repo-native-checks
"$CCD" sync --path . --check
"#
)
}
#[derive(Serialize)]
pub struct InstallReport {
command: &'static str,
ok: bool,
action: &'static str,
hook: String,
message: String,
#[serde(skip_serializing_if = "Option::is_none")]
note: Option<String>,
}
impl CommandReport for InstallReport {
fn exit_code(&self) -> ExitCode {
if self.ok {
ExitCode::SUCCESS
} else {
ExitCode::from(1)
}
}
fn render_text(&self) {
println!("{}", self.message);
if let Some(note) = &self.note {
println!("Note: {note}");
}
}
}
pub fn install(repo_root: &Path, force: bool) -> Result<InstallReport> {
let git_dir = repo_root.join(".git");
if !git_dir.is_dir() {
return Ok(InstallReport {
command: "hooks install",
ok: false,
action: "error",
hook: HOOK_NAME.to_owned(),
message: "Not a git repository — cannot install hooks.".to_owned(),
note: None,
});
}
let ci_note = detect_github_ci(repo_root);
let hooks_dir = git_dir.join("hooks");
fs::create_dir_all(&hooks_dir)?;
let hook_path = hooks_dir.join(HOOK_NAME);
if hook_path.exists() {
let existing = fs::read_to_string(&hook_path)?;
if existing.contains(CCD_MARKER) {
write_hook(&hook_path, true)?;
return Ok(InstallReport {
command: "hooks install",
ok: true,
action: "updated",
hook: HOOK_NAME.to_owned(),
message: format!("Updated CCD {HOOK_NAME} hook."),
note: ci_note,
});
}
if !force {
return Ok(InstallReport {
command: "hooks install",
ok: false,
action: "refused",
hook: HOOK_NAME.to_owned(),
message: format!(
"Existing {HOOK_NAME} hook is not CCD-managed — \
refusing to overwrite. Use --force to replace it."
),
note: None,
});
}
write_hook(&hook_path, true)?;
return Ok(InstallReport {
command: "hooks install",
ok: true,
action: "installed",
hook: HOOK_NAME.to_owned(),
message: format!("Installed CCD {HOOK_NAME} hook."),
note: ci_note,
});
}
write_hook(&hook_path, false)?;
Ok(InstallReport {
command: "hooks install",
ok: true,
action: "installed",
hook: HOOK_NAME.to_owned(),
message: format!("Installed CCD {HOOK_NAME} hook."),
note: ci_note,
})
}
fn detect_github_ci(repo_root: &Path) -> Option<String> {
let has_workflows = repo_root.join(".github/workflows").is_dir();
if has_workflows {
return Some(
"This repo has GitHub Actions workflows. \
Consider CI-based enforcement (ccd doctor/sync in a workflow) \
as a stronger alternative to local hooks."
.to_owned(),
);
}
None
}
fn write_hook(path: &Path, replace: bool) -> Result<()> {
let result = if replace {
write::replace_text(path, &hook_script(), Some(0o755))
} else {
write::create_text(path, &hook_script(), Some(0o755))
};
result?;
Ok(())
}
#[derive(Serialize)]
pub struct CheckReport {
command: &'static str,
ok: bool,
checks: Vec<HookCheck>,
}
#[derive(Serialize)]
struct HookCheck {
hook: String,
status: &'static str,
severity: &'static str,
message: String,
}
impl CommandReport for CheckReport {
fn exit_code(&self) -> ExitCode {
let has_error = self.checks.iter().any(|c| c.severity == "error");
if has_error {
ExitCode::from(1)
} else {
ExitCode::SUCCESS
}
}
fn render_text(&self) {
for c in &self.checks {
let label = match c.status {
"pass" => "PASS",
"warn" => "WARN",
"fail" => "FAIL",
_ => "INFO",
};
println!("[{label}] {}", c.message);
}
}
}
pub fn check(repo_root: &Path) -> Result<CheckReport> {
let mut checks = Vec::new();
let git_dir = repo_root.join(".git");
if !git_dir.is_dir() {
checks.push(HookCheck {
hook: HOOK_NAME.to_owned(),
status: "fail",
severity: "error",
message: "Not a git repository.".to_owned(),
});
return Ok(CheckReport {
command: "hooks check",
ok: false,
checks,
});
}
let hook_path = git_dir.join("hooks").join(HOOK_NAME);
if !hook_path.exists() {
checks.push(HookCheck {
hook: HOOK_NAME.to_owned(),
status: "warn",
severity: "warning",
message: format!(
"{HOOK_NAME} hook is not installed. Run `ccd hooks install` to add it."
),
});
return Ok(CheckReport {
command: "hooks check",
ok: true,
checks,
});
}
let contents = fs::read_to_string(&hook_path)?;
let is_ccd = contents.contains(CCD_MARKER);
if is_ccd {
checks.push(HookCheck {
hook: HOOK_NAME.to_owned(),
status: "pass",
severity: "info",
message: format!("{HOOK_NAME} hook is installed and CCD-managed."),
});
} else {
checks.push(HookCheck {
hook: HOOK_NAME.to_owned(),
status: "warn",
severity: "warning",
message: format!(
"{HOOK_NAME} hook exists but is not CCD-managed. \
Run `ccd hooks install --force` to replace it."
),
});
}
let metadata = fs::metadata(&hook_path)?;
let perms = metadata.permissions();
if perms.mode() & 0o111 == 0 {
checks.push(HookCheck {
hook: HOOK_NAME.to_owned(),
status: "warn",
severity: "warning",
message: format!("{HOOK_NAME} hook is not executable."),
});
}
let ok = !checks.iter().any(|c| c.severity == "error");
Ok(CheckReport {
command: "hooks check",
ok,
checks,
})
}