1use anyhow::{anyhow, Context, Result};
29use std::fs;
30#[cfg(unix)]
31use std::os::unix::fs::PermissionsExt;
32use std::path::{Path, PathBuf};
33use std::process::Command;
34
35use crate::hooks::templates::{
36 pre_commit_script, pre_push_script, APERION_HOOK_MARKER,
37};
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum HookInstallOutcome {
44 Installed,
46 Refreshed,
48 UnknownHookPresent,
51 Chained,
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum HookKind {
58 PreCommit,
59 PrePush,
60}
61
62impl HookKind {
63 pub fn filename(self) -> &'static str {
65 match self {
66 HookKind::PreCommit => "pre-commit",
67 HookKind::PrePush => "pre-push",
68 }
69 }
70
71 pub fn body(self) -> String {
74 match self {
75 HookKind::PreCommit => pre_commit_script(),
76 HookKind::PrePush => pre_push_script(),
77 }
78 }
79}
80
81#[derive(Debug)]
83pub struct InstallReport {
84 pub hooks_dir: PathBuf,
85 pub pre_commit: HookInstallOutcome,
86 pub pre_push: HookInstallOutcome,
87}
88
89#[derive(Debug)]
91pub struct UninstallReport {
92 pub hooks_dir: PathBuf,
93 pub pre_commit_removed: bool,
97 pub pre_push_removed: bool,
98 pub pre_commit_chain_restored: bool,
102 pub pre_push_chain_restored: bool,
103}
104
105pub fn resolve_hooks_dir(start: &Path) -> Result<PathBuf> {
110 let output = Command::new("git")
111 .args(["rev-parse", "--git-path", "hooks"])
112 .current_dir(start)
113 .output()
114 .with_context(|| {
115 format!(
116 "couldn't invoke `git rev-parse --git-path hooks` at {} (is git installed?)",
117 start.display()
118 )
119 })?;
120 if !output.status.success() {
121 let stderr = String::from_utf8_lossy(&output.stderr);
122 return Err(anyhow!(
123 "git rev-parse failed at {}: {}",
124 start.display(),
125 stderr.trim()
126 ));
127 }
128 let raw = String::from_utf8_lossy(&output.stdout).trim().to_string();
129 if raw.is_empty() {
130 return Err(anyhow!(
131 "git rev-parse returned an empty hooks path -- is {} inside a git repo?",
132 start.display()
133 ));
134 }
135 let candidate = PathBuf::from(&raw);
139 let absolute = if candidate.is_absolute() {
140 candidate
141 } else {
142 start.join(candidate)
143 };
144 Ok(absolute)
145}
146
147pub fn install(start: &Path, chain_existing: bool) -> Result<InstallReport> {
150 let hooks_dir = resolve_hooks_dir(start)?;
151 fs::create_dir_all(&hooks_dir).with_context(|| {
152 format!("couldn't create hooks dir {}", hooks_dir.display())
153 })?;
154
155 let pre_commit = install_one(&hooks_dir, HookKind::PreCommit, chain_existing)?;
156 let pre_push = install_one(&hooks_dir, HookKind::PrePush, chain_existing)?;
157
158 Ok(InstallReport {
159 hooks_dir,
160 pre_commit,
161 pre_push,
162 })
163}
164
165pub fn uninstall(start: &Path) -> Result<UninstallReport> {
168 let hooks_dir = resolve_hooks_dir(start)?;
169
170 let (pre_commit_removed, pre_commit_chain_restored) =
171 uninstall_one(&hooks_dir, HookKind::PreCommit)?;
172 let (pre_push_removed, pre_push_chain_restored) =
173 uninstall_one(&hooks_dir, HookKind::PrePush)?;
174
175 Ok(UninstallReport {
176 hooks_dir,
177 pre_commit_removed,
178 pre_push_removed,
179 pre_commit_chain_restored,
180 pre_push_chain_restored,
181 })
182}
183
184fn install_one(
185 hooks_dir: &Path,
186 kind: HookKind,
187 chain_existing: bool,
188) -> Result<HookInstallOutcome> {
189 let path = hooks_dir.join(kind.filename());
190 let body = kind.body();
191
192 if !path.exists() {
193 write_hook(&path, &body)?;
194 return Ok(HookInstallOutcome::Installed);
195 }
196
197 let existing = fs::read_to_string(&path).with_context(|| {
199 format!("couldn't read existing hook at {}", path.display())
200 })?;
201 if existing.contains(APERION_HOOK_MARKER) {
202 write_hook(&path, &body)?;
204 return Ok(HookInstallOutcome::Refreshed);
205 }
206
207 if !chain_existing {
208 return Ok(HookInstallOutcome::UnknownHookPresent);
209 }
210
211 let backup_path = path.with_extension("aperion-backup");
215 fs::rename(&path, &backup_path).with_context(|| {
216 format!(
217 "couldn't move existing hook out of the way: {} -> {}",
218 path.display(),
219 backup_path.display()
220 )
221 })?;
222 let chained_body = format!(
223 "{}\n# --- chained existing hook (preserved by --install-hooks --chain-existing) ---\nexec {} \"$@\"\n",
224 body.trim_end(),
225 backup_path.display(),
226 );
227 write_hook(&path, &chained_body)?;
228 Ok(HookInstallOutcome::Chained)
229}
230
231fn uninstall_one(hooks_dir: &Path, kind: HookKind) -> Result<(bool, bool)> {
232 let path = hooks_dir.join(kind.filename());
233 if !path.exists() {
234 return Ok((false, false));
235 }
236 let body = fs::read_to_string(&path).with_context(|| {
237 format!("couldn't read hook at {}", path.display())
238 })?;
239 if !body.contains(APERION_HOOK_MARKER) {
240 return Err(anyhow!(
241 "refusing to remove {}: it isn't an Aperion-installed hook (no marker line found). \
242 Inspect and delete manually if you intend to.",
243 path.display()
244 ));
245 }
246 fs::remove_file(&path)
247 .with_context(|| format!("couldn't remove hook {}", path.display()))?;
248
249 let backup_path = path.with_extension("aperion-backup");
251 if backup_path.exists() {
252 fs::rename(&backup_path, &path).with_context(|| {
253 format!(
254 "couldn't restore chained-aside hook: {} -> {}",
255 backup_path.display(),
256 path.display()
257 )
258 })?;
259 return Ok((true, true));
260 }
261
262 Ok((true, false))
263}
264
265fn write_hook(path: &Path, body: &str) -> Result<()> {
266 fs::write(path, body)
267 .with_context(|| format!("couldn't write hook to {}", path.display()))?;
268 #[cfg(unix)]
275 {
276 let mut perms = fs::metadata(path)?.permissions();
277 perms.set_mode(0o755);
278 fs::set_permissions(path, perms)?;
279 }
280 Ok(())
281}
282
283#[cfg(test)]
284mod tests {
285 use super::*;
286 use std::process::Command;
287 use tempfile::TempDir;
288
289 fn init_git_repo() -> TempDir {
290 let tmp = TempDir::new().expect("create tempdir");
291 let status = Command::new("git")
292 .args(["init", "-q"])
293 .current_dir(tmp.path())
294 .status()
295 .expect("run git init");
296 assert!(status.success(), "git init failed");
297 for (key, val) in [("user.email", "test@aperion.ai"), ("user.name", "Test")] {
300 let s = Command::new("git")
301 .args(["config", "--local", key, val])
302 .current_dir(tmp.path())
303 .status()
304 .expect("git config");
305 assert!(s.success());
306 }
307 tmp
308 }
309
310 #[test]
311 fn install_fresh_writes_both_hooks() {
312 let tmp = init_git_repo();
313 let report = install(tmp.path(), false).expect("install");
314 assert_eq!(report.pre_commit, HookInstallOutcome::Installed);
315 assert_eq!(report.pre_push, HookInstallOutcome::Installed);
316 assert!(report.hooks_dir.join("pre-commit").exists());
317 assert!(report.hooks_dir.join("pre-push").exists());
318 let body = fs::read_to_string(report.hooks_dir.join("pre-commit")).unwrap();
319 assert!(body.contains(APERION_HOOK_MARKER));
320 }
321
322 #[test]
323 fn install_twice_refreshes_idempotently() {
324 let tmp = init_git_repo();
325 install(tmp.path(), false).unwrap();
326 let second = install(tmp.path(), false).expect("re-install");
327 assert_eq!(second.pre_commit, HookInstallOutcome::Refreshed);
328 assert_eq!(second.pre_push, HookInstallOutcome::Refreshed);
329 }
330
331 #[test]
332 fn install_refuses_to_clobber_unknown_hook() {
333 let tmp = init_git_repo();
334 let hooks_dir = resolve_hooks_dir(tmp.path()).unwrap();
335 fs::create_dir_all(&hooks_dir).unwrap();
336 fs::write(
337 hooks_dir.join("pre-commit"),
338 "#!/bin/sh\n# user's husky hook\nexec husky pre-commit\n",
339 )
340 .unwrap();
341
342 let report = install(tmp.path(), false).expect("install");
343 assert_eq!(report.pre_commit, HookInstallOutcome::UnknownHookPresent);
344
345 let body = fs::read_to_string(hooks_dir.join("pre-commit")).unwrap();
347 assert!(body.contains("husky pre-commit"));
348 }
349
350 #[test]
351 fn install_chains_existing_hook_when_asked() {
352 let tmp = init_git_repo();
353 let hooks_dir = resolve_hooks_dir(tmp.path()).unwrap();
354 fs::create_dir_all(&hooks_dir).unwrap();
355 fs::write(
356 hooks_dir.join("pre-commit"),
357 "#!/bin/sh\n# user's husky hook\nexec husky pre-commit\n",
358 )
359 .unwrap();
360
361 let report = install(tmp.path(), true).expect("install with chain");
362 assert_eq!(report.pre_commit, HookInstallOutcome::Chained);
363 assert!(hooks_dir.join("pre-commit.aperion-backup").exists());
365 let body = fs::read_to_string(hooks_dir.join("pre-commit")).unwrap();
367 assert!(body.contains(APERION_HOOK_MARKER));
368 assert!(body.contains("pre-commit.aperion-backup"));
369 }
370
371 #[test]
372 fn uninstall_removes_our_hook_and_restores_chain() {
373 let tmp = init_git_repo();
374 let hooks_dir = resolve_hooks_dir(tmp.path()).unwrap();
375 fs::create_dir_all(&hooks_dir).unwrap();
376 fs::write(
377 hooks_dir.join("pre-commit"),
378 "#!/bin/sh\n# user's husky hook\nexec husky pre-commit\n",
379 )
380 .unwrap();
381 install(tmp.path(), true).unwrap();
382
383 let report = uninstall(tmp.path()).expect("uninstall");
384 assert!(report.pre_commit_removed);
385 assert!(report.pre_commit_chain_restored);
386
387 assert!(!hooks_dir.join("pre-commit.aperion-backup").exists());
389 let body = fs::read_to_string(hooks_dir.join("pre-commit")).unwrap();
390 assert!(body.contains("husky pre-commit"));
391 assert!(!body.contains(APERION_HOOK_MARKER));
392 }
393
394 #[test]
395 fn uninstall_refuses_to_remove_foreign_hook() {
396 let tmp = init_git_repo();
397 let hooks_dir = resolve_hooks_dir(tmp.path()).unwrap();
398 fs::create_dir_all(&hooks_dir).unwrap();
399 fs::write(
400 hooks_dir.join("pre-commit"),
401 "#!/bin/sh\n# not ours\nexit 0\n",
402 )
403 .unwrap();
404 let err = uninstall(tmp.path()).expect_err("should refuse");
405 let msg = format!("{:?}", err);
406 assert!(msg.contains("isn't an Aperion-installed hook"));
407 let body = fs::read_to_string(hooks_dir.join("pre-commit")).unwrap();
409 assert!(body.contains("# not ours"));
410 }
411}