use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::commands::hook_templates::HOOK_TEMPLATES;
#[derive(Parser, Debug)]
pub struct HooksArgs {
#[command(subcommand)]
pub command: HooksCommands,
}
#[derive(Subcommand, Debug)]
pub enum HooksCommands {
Doctor,
Update,
Status,
}
pub fn run(args: HooksArgs) -> Result<()> {
match args.command {
HooksCommands::Doctor => run_doctor(),
HooksCommands::Update => run_update(),
HooksCommands::Status => run_status(),
}
}
struct HookAudit {
hooks_dir: PathBuf,
hooks_path_set: bool,
canonical: Vec<(String, HookStatus)>,
local: Vec<String>,
}
#[derive(Debug, PartialEq, Eq)]
enum HookStatus {
Match,
Drift,
Missing,
}
fn audit() -> Result<HookAudit> {
let root = crate::utils::find_project_root();
let hooks_dir = root.join(".git-hooks");
let hooks_path_set = read_hooks_path(&root)
.map(|p| p.trim() == ".git-hooks")
.unwrap_or(false);
let mut canonical = Vec::with_capacity(HOOK_TEMPLATES.len());
for (name, body) in HOOK_TEMPLATES {
let installed = hooks_dir.join(name);
let status = if !installed.exists() {
HookStatus::Missing
} else {
match std::fs::read_to_string(&installed) {
Ok(content) if content == *body => HookStatus::Match,
_ => HookStatus::Drift,
}
};
canonical.push(((*name).to_string(), status));
}
let mut local = Vec::new();
if hooks_dir.is_dir() {
for entry in std::fs::read_dir(&hooks_dir)?.flatten() {
let name = entry.file_name().to_string_lossy().into_owned();
if let Some(stripped) = name.strip_prefix("local-") {
local.push(stripped.to_string());
}
}
local.sort();
}
Ok(HookAudit {
hooks_dir,
hooks_path_set,
canonical,
local,
})
}
fn read_hooks_path(root: &Path) -> Option<String> {
let out = Command::new("git")
.args(["config", "--get", "core.hooksPath"])
.current_dir(root)
.output()
.ok()?;
if !out.status.success() {
return None;
}
Some(String::from_utf8_lossy(&out.stdout).into_owned())
}
fn run_doctor() -> Result<()> {
let audit = audit()?;
let mut issues = 0u32;
println!("🔎 ResQ hooks doctor");
println!(" .git-hooks/ {}", audit.hooks_dir.display());
if audit.hooks_path_set {
println!(" core.hooksPath ✅ set to .git-hooks");
} else {
println!(" core.hooksPath ❌ not set");
println!(" fix: git config core.hooksPath .git-hooks");
issues += 1;
}
println!("\n Canonical hooks:");
for (name, status) in &audit.canonical {
match status {
HookStatus::Match => println!(" ✅ {name}"),
HookStatus::Drift => {
println!(" ❌ {name} (drifts from embedded canonical)");
issues += 1;
}
HookStatus::Missing => {
println!(" ❌ {name} (missing)");
issues += 1;
}
}
}
if audit.canonical.iter().any(|(_, s)| *s != HookStatus::Match) {
println!(" fix: resq hooks update");
}
println!("\n Local hooks (.git-hooks/local-*):");
if audit.local.is_empty() {
println!(" (none)");
} else {
for name in &audit.local {
println!(" • local-{name}");
}
}
if issues == 0 {
println!("\n✅ All hooks healthy.");
Ok(())
} else {
println!("\n❌ {issues} issue(s) detected.");
anyhow::bail!("hook doctor found {issues} issue(s) — run 'resq hooks update' to fix");
}
}
fn run_update() -> Result<()> {
let root = crate::utils::find_project_root();
let hooks_dir = root.join(".git-hooks");
std::fs::create_dir_all(&hooks_dir)
.with_context(|| format!("Failed to create {}", hooks_dir.display()))?;
let mut updated = 0u32;
for (name, body) in HOOK_TEMPLATES {
let dest = hooks_dir.join(name);
let needs_write = match std::fs::read_to_string(&dest) {
Ok(existing) => existing != *body,
Err(_) => true,
};
if needs_write {
std::fs::write(&dest, body)
.with_context(|| format!("Failed to write {}", dest.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&dest)?.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&dest, perms)?;
}
updated += 1;
println!(" ↻ {name}");
}
}
let status = Command::new("git")
.args(["config", "core.hooksPath", ".git-hooks"])
.current_dir(&root)
.status()
.context("Failed to run git config")?;
if !status.success() {
anyhow::bail!("Failed to set core.hooksPath");
}
if updated == 0 {
println!("✅ Hooks already canonical; nothing to do.");
} else {
println!("✅ {updated} hook(s) updated. Local-* files were not touched.");
}
Ok(())
}
fn run_status() -> Result<()> {
let audit = audit()?;
let canonical_state =
if audit.canonical.iter().all(|(_, s)| *s == HookStatus::Match) && audit.hooks_path_set {
"clean"
} else {
"drift"
};
let local = if audit.local.is_empty() {
"none".to_string()
} else {
audit.local.join(",")
};
println!("installed={canonical_state} local={local}");
Ok(())
}
#[must_use]
pub fn canonical_count() -> usize {
HOOK_TEMPLATES.len()
}