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