Skip to main content

resq_cli/commands/
hooks.rs

1/*
2 * Copyright 2026 ResQ
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *     http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17//! `resq hooks` — visibility and maintenance for installed git hooks.
18//!
19//! - `resq hooks doctor` reports drift between installed `.git-hooks/<file>`
20//!   and the canonical content embedded in this binary.
21//! - `resq hooks update` rewrites the canonical hooks (preserving any
22//!   `local-*` files the repo committed).
23//! - `resq hooks status` prints a one-line summary suitable for shells.
24
25use 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/// Arguments for the `hooks` command.
33#[derive(Parser, Debug)]
34pub struct HooksArgs {
35    /// Hooks subcommand to execute.
36    #[command(subcommand)]
37    pub command: HooksCommands,
38}
39
40/// Hooks subcommands.
41#[derive(Subcommand, Debug)]
42pub enum HooksCommands {
43    /// Install canonical hooks into `.git-hooks/` and set `core.hooksPath`.
44    Install,
45    /// Scaffold a repo-specific `.git-hooks/local-<hook>` from a kind template.
46    ScaffoldLocal(crate::commands::dev::ScaffoldLocalHookArgs),
47    /// Report installed hook status; exit 1 if any drift / missing file detected.
48    Doctor,
49    /// Rewrite installed canonical hooks from embedded templates (preserves `local-*`).
50    Update,
51    /// Print a one-line summary for scripts (e.g. `installed=clean local=pre-push`).
52    Status,
53}
54
55/// Executes a `hooks` subcommand.
56///
57/// # Errors
58/// Returns an error if filesystem access or `git config` invocation fails.
59pub fn run(args: HooksArgs) -> Result<()> {
60    match args.command {
61        // `install` + `scaffold-local` share their implementation with the
62        // legacy `dev install-hooks` / `dev scaffold-local-hook` paths.
63        // When the old paths are removed, move the fn bodies here.
64        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
72/// Result of inspecting the installed hooks layout.
73struct HookAudit {
74    hooks_dir: PathBuf,
75    hooks_path_set: bool,
76    /// (name, status). status is `Match` / `Drift` / `Missing`.
77    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    // Defensive: only enumerate when .git-hooks is actually a directory.
112    // A regular file or symlink-to-non-dir would otherwise return an error.
113    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        // Return a non-zero exit via anyhow so `Drop` runs normally. main()
191        // prints the error on a new line after our report.
192        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/// Returns the canonical hook count — used by docs/tests.
259#[must_use]
260pub fn canonical_count() -> usize {
261    HOOK_TEMPLATES.len()
262}