1use 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#[derive(ClapArgs, Debug)]
16pub struct Args {
17 #[command(subcommand)]
19 pub command: HooksCommand,
20}
21
22#[derive(Subcommand, Debug)]
24pub enum HooksCommand {
25 Install(InstallArgs),
27
28 Uninstall(UninstallArgs),
30
31 Status,
33}
34
35#[derive(ClapArgs, Debug)]
37pub struct InstallArgs {
38 #[arg(long, short)]
40 pub force: bool,
41
42 #[arg(long, default_value = "true")]
44 pub pre_push: bool,
45
46 #[arg(long)]
48 pub post_merge: bool,
49}
50
51#[derive(ClapArgs, Debug)]
53pub struct UninstallArgs {
54 #[arg(long)]
56 pub all: bool,
57}
58
59pub 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
75fn 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
119fn 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
155fn 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
185fn 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 #[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
217fn is_adr_hook(path: &Path) -> Result<bool> {
219 let content = fs::read_to_string(path)?;
220 Ok(content.contains("git-adr"))
221}
222
223const 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
267const 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"#;