resq_cli/commands/
hooks.rs1use anyhow::{Context, Result};
26use clap::{Parser, Subcommand};
27use std::path::{Path, PathBuf};
28use std::process::Command;
29
30use crate::commands::hook_templates::HOOK_TEMPLATES;
31
32#[derive(Parser, Debug)]
34pub struct HooksArgs {
35 #[command(subcommand)]
37 pub command: HooksCommands,
38}
39
40#[derive(Subcommand, Debug)]
42pub enum HooksCommands {
43 Doctor,
45 Update,
47 Status,
49}
50
51pub fn run(args: HooksArgs) -> Result<()> {
56 match args.command {
57 HooksCommands::Doctor => run_doctor(),
58 HooksCommands::Update => run_update(),
59 HooksCommands::Status => run_status(),
60 }
61}
62
63struct HookAudit {
65 hooks_dir: PathBuf,
66 hooks_path_set: bool,
67 canonical: Vec<(String, HookStatus)>,
69 local: Vec<String>,
70}
71
72#[derive(Debug, PartialEq, Eq)]
73enum HookStatus {
74 Match,
75 Drift,
76 Missing,
77}
78
79fn audit() -> Result<HookAudit> {
80 let root = crate::utils::find_project_root();
81 let hooks_dir = root.join(".git-hooks");
82
83 let hooks_path_set = read_hooks_path(&root)
84 .map(|p| p.trim() == ".git-hooks")
85 .unwrap_or(false);
86
87 let mut canonical = Vec::with_capacity(HOOK_TEMPLATES.len());
88 for (name, body) in HOOK_TEMPLATES {
89 let installed = hooks_dir.join(name);
90 let status = if !installed.exists() {
91 HookStatus::Missing
92 } else {
93 match std::fs::read_to_string(&installed) {
94 Ok(content) if content == *body => HookStatus::Match,
95 _ => HookStatus::Drift,
96 }
97 };
98 canonical.push(((*name).to_string(), status));
99 }
100
101 let mut local = Vec::new();
102 if hooks_dir.is_dir() {
105 for entry in std::fs::read_dir(&hooks_dir)?.flatten() {
106 let name = entry.file_name().to_string_lossy().into_owned();
107 if let Some(stripped) = name.strip_prefix("local-") {
108 local.push(stripped.to_string());
109 }
110 }
111 local.sort();
112 }
113
114 Ok(HookAudit {
115 hooks_dir,
116 hooks_path_set,
117 canonical,
118 local,
119 })
120}
121
122fn read_hooks_path(root: &Path) -> Option<String> {
123 let out = Command::new("git")
124 .args(["config", "--get", "core.hooksPath"])
125 .current_dir(root)
126 .output()
127 .ok()?;
128 if !out.status.success() {
129 return None;
130 }
131 Some(String::from_utf8_lossy(&out.stdout).into_owned())
132}
133
134fn run_doctor() -> Result<()> {
135 let audit = audit()?;
136 let mut issues = 0u32;
137
138 println!("š ResQ hooks doctor");
139 println!(" .git-hooks/ {}", audit.hooks_dir.display());
140
141 if audit.hooks_path_set {
142 println!(" core.hooksPath ā
set to .git-hooks");
143 } else {
144 println!(" core.hooksPath ā not set");
145 println!(" fix: git config core.hooksPath .git-hooks");
146 issues += 1;
147 }
148
149 println!("\n Canonical hooks:");
150 for (name, status) in &audit.canonical {
151 match status {
152 HookStatus::Match => println!(" ā
{name}"),
153 HookStatus::Drift => {
154 println!(" ā {name} (drifts from embedded canonical)");
155 issues += 1;
156 }
157 HookStatus::Missing => {
158 println!(" ā {name} (missing)");
159 issues += 1;
160 }
161 }
162 }
163 if audit.canonical.iter().any(|(_, s)| *s != HookStatus::Match) {
164 println!(" fix: resq hooks update");
165 }
166
167 println!("\n Local hooks (.git-hooks/local-*):");
168 if audit.local.is_empty() {
169 println!(" (none)");
170 } else {
171 for name in &audit.local {
172 println!(" ⢠local-{name}");
173 }
174 }
175
176 if issues == 0 {
177 println!("\nā
All hooks healthy.");
178 Ok(())
179 } else {
180 println!("\nā {issues} issue(s) detected.");
181 anyhow::bail!("hook doctor found {issues} issue(s) ā run 'resq hooks update' to fix");
184 }
185}
186
187fn run_update() -> Result<()> {
188 let root = crate::utils::find_project_root();
189 let hooks_dir = root.join(".git-hooks");
190 std::fs::create_dir_all(&hooks_dir)
191 .with_context(|| format!("Failed to create {}", hooks_dir.display()))?;
192
193 let mut updated = 0u32;
194 for (name, body) in HOOK_TEMPLATES {
195 let dest = hooks_dir.join(name);
196 let needs_write = match std::fs::read_to_string(&dest) {
197 Ok(existing) => existing != *body,
198 Err(_) => true,
199 };
200 if needs_write {
201 std::fs::write(&dest, body)
202 .with_context(|| format!("Failed to write {}", dest.display()))?;
203 #[cfg(unix)]
204 {
205 use std::os::unix::fs::PermissionsExt;
206 let mut perms = std::fs::metadata(&dest)?.permissions();
207 perms.set_mode(0o755);
208 std::fs::set_permissions(&dest, perms)?;
209 }
210 updated += 1;
211 println!(" ā» {name}");
212 }
213 }
214
215 let status = Command::new("git")
216 .args(["config", "core.hooksPath", ".git-hooks"])
217 .current_dir(&root)
218 .status()
219 .context("Failed to run git config")?;
220 if !status.success() {
221 anyhow::bail!("Failed to set core.hooksPath");
222 }
223
224 if updated == 0 {
225 println!("ā
Hooks already canonical; nothing to do.");
226 } else {
227 println!("ā
{updated} hook(s) updated. Local-* files were not touched.");
228 }
229 Ok(())
230}
231
232fn run_status() -> Result<()> {
233 let audit = audit()?;
234 let canonical_state =
235 if audit.canonical.iter().all(|(_, s)| *s == HookStatus::Match) && audit.hooks_path_set {
236 "clean"
237 } else {
238 "drift"
239 };
240 let local = if audit.local.is_empty() {
241 "none".to_string()
242 } else {
243 audit.local.join(",")
244 };
245 println!("installed={canonical_state} local={local}");
246 Ok(())
247}
248
249#[must_use]
251pub fn canonical_count() -> usize {
252 HOOK_TEMPLATES.len()
253}