Skip to main content

aperion_shield/hooks/
install.rs

1//! Install / uninstall the `pre-commit` and `pre-push` hooks for a git
2//! repository.
3//!
4//! Design points:
5//!
6//!  * **Idempotent.** Running `--install-hooks` twice is a no-op the
7//!    second time. We detect our own hook by matching the marker line
8//!    from `templates::APERION_HOOK_MARKER`, not by comparing whole-file
9//!    checksums -- this lets us refresh the hook body across Shield
10//!    versions without losing the ability to recognise our own.
11//!
12//!  * **Husky-compatible coexistence.** If a non-Aperion hook is
13//!    already present, we don't clobber it. Instead we:
14//!      1. Preserve the existing file as `<hook>.aperion-backup`.
15//!      2. Write our hook in its place.
16//!      3. Have *our* hook `exec` the backup at the end so the chain
17//!         survives. Husky / pre-commit / lefthook users keep their
18//!         existing pipeline; Shield slots in as the first link.
19//!    This is opt-in via `--chain-existing`; without it we refuse to
20//!    overwrite an unrecognised hook and tell the user how to chain
21//!    manually. Failing closed is safer than guessing.
22//!
23//!  * **Resolves the .git dir correctly** for worktrees and submodules
24//!    by parsing `git rev-parse --git-path hooks`. We do NOT assume
25//!    `.git/hooks/` exists at the repo root -- that's wrong for
26//!    worktrees and breaks loudly when used inside `git worktree add`.
27
28use 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/// Outcome categories the installer reports back to the CLI. Kept
40/// granular so the `--install-hooks` log line is informative without
41/// requiring callers to re-inspect the filesystem.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum HookInstallOutcome {
44    /// Wrote a fresh hook (no prior file existed).
45    Installed,
46    /// Re-wrote our own hook from a prior version (idempotent refresh).
47    Refreshed,
48    /// A non-Aperion hook is in place; we did NOT overwrite. Caller
49    /// must re-invoke with `chain_existing = true` to proceed.
50    UnknownHookPresent,
51    /// `chain_existing = true` was supplied and we moved the prior
52    /// hook aside + installed ours, chaining via `exec` at the end.
53    Chained,
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum HookKind {
58    PreCommit,
59    PrePush,
60}
61
62impl HookKind {
63    /// Filename inside the git hooks directory.
64    pub fn filename(self) -> &'static str {
65        match self {
66            HookKind::PreCommit => "pre-commit",
67            HookKind::PrePush => "pre-push",
68        }
69    }
70
71    /// Render the hook body. Kept here so callers don't have to import
72    /// the templates module directly.
73    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/// Result of an install / uninstall pass over both hook files.
82#[derive(Debug)]
83pub struct InstallReport {
84    pub hooks_dir: PathBuf,
85    pub pre_commit: HookInstallOutcome,
86    pub pre_push: HookInstallOutcome,
87}
88
89/// Result of an uninstall pass.
90#[derive(Debug)]
91pub struct UninstallReport {
92    pub hooks_dir: PathBuf,
93    /// `true` if the hook existed and was ours (removed).
94    /// `false` if no hook existed (nothing to do).
95    /// Errors if the hook existed but wasn't ours.
96    pub pre_commit_removed: bool,
97    pub pre_push_removed: bool,
98    /// `true` if a `<hook>.aperion-backup` chain partner was restored
99    /// in place of the removed hook (i.e. user had a prior hook chained
100    /// by `--chain-existing`; we put it back).
101    pub pre_commit_chain_restored: bool,
102    pub pre_push_chain_restored: bool,
103}
104
105/// Resolve the absolute path to `<repo>/.git/hooks` for the repo whose
106/// working tree contains `start`. Honors worktrees and submodules by
107/// shelling out to `git rev-parse --git-path hooks`. Returns an error
108/// when `start` is not inside a git repository.
109pub 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    // `git rev-parse --git-path hooks` may return a relative path
136    // (relative to `start`). Canonicalise so the rest of the installer
137    // can treat it as absolute without surprises.
138    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
147/// Top-level install entrypoint. Writes / refreshes the pre-commit and
148/// pre-push hooks for the repository at `start`.
149pub 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
165/// Top-level uninstall entrypoint. Removes Aperion-installed hooks
166/// (and restores any chained-aside originals).
167pub 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    // Hook file exists -- is it ours?
198    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        // Our own hook; refresh body in case the template evolved.
203        write_hook(&path, &body)?;
204        return Ok(HookInstallOutcome::Refreshed);
205    }
206
207    if !chain_existing {
208        return Ok(HookInstallOutcome::UnknownHookPresent);
209    }
210
211    // Chain mode: move existing aside, write ours, append a chain tail
212    // that execs the moved-aside hook. Preserves husky / pre-commit /
213    // lefthook setups.
214    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    // Was a chain partner left aside? Restore it.
250    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    // git-for-windows runs hooks via msys bash and treats any file in
269    // `.git/hooks/` as executable regardless of NTFS bits, so the chmod
270    // is a Unix-only concern. On Windows we skip it entirely; on Unix
271    // we must set 0755 explicitly because `fs::write` honours the
272    // process umask and may produce a 0644 file the kernel refuses to
273    // exec when git calls it directly.
274    #[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        // git complains about missing user.* in some CI environments;
298        // set them locally so subsequent commits in tests succeed.
299        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        // and the original was NOT modified
346        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        // backup exists
364        assert!(hooks_dir.join("pre-commit.aperion-backup").exists());
365        // new hook exists and chains to backup
366        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        // backup is gone, original husky hook is back in place
388        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        // original still there, intact
408        let body = fs::read_to_string(hooks_dir.join("pre-commit")).unwrap();
409        assert!(body.contains("# not ours"));
410    }
411}