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    /// Report installed hook status; exit 1 if any drift / missing file detected.
44    Doctor,
45    /// Rewrite installed canonical hooks from embedded templates (preserves `local-*`).
46    Update,
47    /// Print a one-line summary for scripts (e.g. `installed=clean local=pre-push`).
48    Status,
49}
50
51/// Executes a `hooks` subcommand.
52///
53/// # Errors
54/// Returns an error if filesystem access or `git config` invocation fails.
55pub 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
63/// Result of inspecting the installed hooks layout.
64struct HookAudit {
65    hooks_dir: PathBuf,
66    hooks_path_set: bool,
67    /// (name, status). status is `Match` / `Drift` / `Missing`.
68    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    // Defensive: only enumerate when .git-hooks is actually a directory.
103    // A regular file or symlink-to-non-dir would otherwise return an error.
104    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        // Return a non-zero exit via anyhow so `Drop` runs normally. main()
182        // prints the error on a new line after our report.
183        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/// Returns the canonical hook count — used by docs/tests.
250#[must_use]
251pub fn canonical_count() -> usize {
252    HOOK_TEMPLATES.len()
253}