git_adr/cli/
hooks.rs

1//! Git hooks management for ADR workflows.
2
3use anyhow::Result;
4use clap::{Args as ClapArgs, Subcommand};
5use colored::Colorize;
6use std::fs;
7use std::path::Path;
8
9#[cfg(unix)]
10use std::os::unix::fs::PermissionsExt;
11
12use crate::core::Git;
13
14/// Arguments for the hooks command.
15#[derive(ClapArgs, Debug)]
16pub struct Args {
17    /// Hooks subcommand.
18    #[command(subcommand)]
19    pub command: HooksCommand,
20}
21
22/// Hooks subcommands.
23#[derive(Subcommand, Debug)]
24pub enum HooksCommand {
25    /// Install ADR git hooks.
26    Install(InstallArgs),
27
28    /// Uninstall ADR git hooks.
29    Uninstall(UninstallArgs),
30
31    /// Show hook installation status.
32    Status,
33}
34
35/// Arguments for hooks install.
36#[derive(ClapArgs, Debug)]
37pub struct InstallArgs {
38    /// Force overwrite existing hooks.
39    #[arg(long, short)]
40    pub force: bool,
41
42    /// Install pre-push hook for ADR validation.
43    #[arg(long, default_value = "true")]
44    pub pre_push: bool,
45
46    /// Install post-merge hook for ADR sync.
47    #[arg(long)]
48    pub post_merge: bool,
49}
50
51/// Arguments for hooks uninstall.
52#[derive(ClapArgs, Debug)]
53pub struct UninstallArgs {
54    /// Remove all ADR hooks.
55    #[arg(long)]
56    pub all: bool,
57}
58
59/// Run the hooks command.
60///
61/// # Errors
62///
63/// Returns an error if hook operations fail.
64pub fn run(args: Args) -> Result<()> {
65    let git = Git::new();
66    git.check_repository()?;
67
68    match args.command {
69        HooksCommand::Install(install_args) => run_install(install_args, &git),
70        HooksCommand::Uninstall(uninstall_args) => run_uninstall(uninstall_args, &git),
71        HooksCommand::Status => run_status(&git),
72    }
73}
74
75/// Install git hooks.
76fn run_install(args: InstallArgs, git: &Git) -> Result<()> {
77    let hooks_dir = git.repo_root()?.join(".git/hooks");
78
79    if !hooks_dir.exists() {
80        fs::create_dir_all(&hooks_dir)?;
81    }
82
83    eprintln!("{} Installing ADR git hooks...", "→".blue());
84    let mut installed = 0;
85
86    if args.pre_push {
87        let hook_path = hooks_dir.join("pre-push");
88        if install_hook(&hook_path, PRE_PUSH_HOOK, args.force)? {
89            eprintln!("  {} Installed pre-push hook", "✓".green());
90            installed += 1;
91        }
92    }
93
94    if args.post_merge {
95        let hook_path = hooks_dir.join("post-merge");
96        if install_hook(&hook_path, POST_MERGE_HOOK, args.force)? {
97            eprintln!("  {} Installed post-merge hook", "✓".green());
98            installed += 1;
99        }
100    }
101
102    if installed == 0 {
103        eprintln!(
104            "{} No hooks were installed. Use --force to overwrite existing hooks.",
105            "!".yellow()
106        );
107    } else {
108        eprintln!();
109        eprintln!(
110            "{} Installed {} hook(s)",
111            "✓".green(),
112            installed.to_string().cyan()
113        );
114    }
115
116    Ok(())
117}
118
119/// Uninstall git hooks.
120fn run_uninstall(args: UninstallArgs, git: &Git) -> Result<()> {
121    let hooks_dir = git.repo_root()?.join(".git/hooks");
122
123    let hooks_to_remove = if args.all {
124        vec!["pre-push", "post-merge"]
125    } else {
126        vec!["pre-push"]
127    };
128
129    eprintln!("{} Uninstalling ADR git hooks...", "→".blue());
130    let mut removed = 0;
131
132    for hook_name in hooks_to_remove {
133        let hook_path = hooks_dir.join(hook_name);
134        if hook_path.exists() && is_adr_hook(&hook_path)? {
135            fs::remove_file(&hook_path)?;
136            eprintln!("  {} Removed {} hook", "✓".green(), hook_name);
137            removed += 1;
138        }
139    }
140
141    if removed == 0 {
142        eprintln!("{} No ADR hooks found to remove", "→".yellow());
143    } else {
144        eprintln!();
145        eprintln!(
146            "{} Removed {} hook(s)",
147            "✓".green(),
148            removed.to_string().cyan()
149        );
150    }
151
152    Ok(())
153}
154
155/// Show hook status.
156fn run_status(git: &Git) -> Result<()> {
157    let hooks_dir = git.repo_root()?.join(".git/hooks");
158
159    eprintln!("{} ADR Hook Status:", "→".blue());
160    println!();
161
162    let hooks = [
163        ("pre-push", "Validates ADR references before push"),
164        ("post-merge", "Syncs ADRs after merge"),
165    ];
166
167    for (name, description) in &hooks {
168        let hook_path = hooks_dir.join(name);
169        let status = if hook_path.exists() {
170            if is_adr_hook(&hook_path)? {
171                "installed".green().to_string()
172            } else {
173                "exists (not ADR)".yellow().to_string()
174            }
175        } else {
176            "not installed".dimmed().to_string()
177        };
178
179        println!("  {} {} [{}]", name.bold(), description.dimmed(), status);
180    }
181
182    Ok(())
183}
184
185/// Install a single hook.
186fn install_hook(path: &Path, content: &str, force: bool) -> Result<bool> {
187    if path.exists() && !force {
188        if is_adr_hook(path)? {
189            eprintln!(
190                "  {} {} already installed (use --force to reinstall)",
191                "→".yellow(),
192                path.file_name().unwrap_or_default().to_string_lossy()
193            );
194        } else {
195            eprintln!(
196                "  {} {} exists but is not an ADR hook (use --force to overwrite)",
197                "!".yellow(),
198                path.file_name().unwrap_or_default().to_string_lossy()
199            );
200        }
201        return Ok(false);
202    }
203
204    fs::write(path, content)?;
205
206    // Make executable (Unix only)
207    #[cfg(unix)]
208    {
209        let mut perms = fs::metadata(path)?.permissions();
210        perms.set_mode(0o755);
211        fs::set_permissions(path, perms)?;
212    }
213
214    Ok(true)
215}
216
217/// Check if a hook is an ADR hook (contains marker).
218fn is_adr_hook(path: &Path) -> Result<bool> {
219    let content = fs::read_to_string(path)?;
220    Ok(content.contains("git-adr"))
221}
222
223/// Pre-push hook content.
224const PRE_PUSH_HOOK: &str = r#"#!/bin/sh
225# git-adr pre-push hook
226# Validates ADR references in commits before push
227
228# Get the remote and URL
229remote="$1"
230url="$2"
231
232# Read stdin for refs being pushed
233while read local_ref local_sha remote_ref remote_sha; do
234    # Skip if deleting a branch
235    if [ "$local_sha" = "0000000000000000000000000000000000000000" ]; then
236        continue
237    fi
238
239    # Get the range of commits being pushed
240    if [ "$remote_sha" = "0000000000000000000000000000000000000000" ]; then
241        # New branch - check all commits
242        range="$local_sha"
243    else
244        range="$remote_sha..$local_sha"
245    fi
246
247    # Check for ADR references in commit messages
248    commits_with_adr=$(git log --format="%H %s" "$range" 2>/dev/null | grep -i "ADR-" || true)
249
250    if [ -n "$commits_with_adr" ]; then
251        echo "→ Found commits referencing ADRs:"
252        echo "$commits_with_adr" | while read line; do
253            echo "  $line"
254        done
255
256        # Sync ADRs to ensure they're pushed
257        if command -v git-adr >/dev/null 2>&1; then
258            echo "→ Syncing ADR notes..."
259            git-adr sync push --quiet 2>/dev/null || true
260        fi
261    fi
262done
263
264exit 0
265"#;
266
267/// Post-merge hook content.
268const POST_MERGE_HOOK: &str = r#"#!/bin/sh
269# git-adr post-merge hook
270# Syncs ADRs after merge
271
272echo "→ Syncing ADR notes after merge..."
273
274if command -v git-adr >/dev/null 2>&1; then
275    git-adr sync pull --quiet 2>/dev/null || true
276fi
277
278exit 0
279"#;