Skip to main content

envo/
hook.rs

1//! Install/remove a managed `pre-commit` hook that runs `envo scan --staged`.
2
3use std::path::{Path, PathBuf};
4
5use anyhow::{bail, Context, Result};
6
7const BEGIN: &str = "# >>> envo >>>";
8const END: &str = "# <<< envo <<<";
9
10/// Install (or update) the managed block in `.git/hooks/pre-commit`.
11/// Returns the path to the hook file.
12pub fn install(dir: &Path, with_check: bool) -> Result<PathBuf> {
13    let git_dir = find_git_dir(dir).context("not inside a git repository")?;
14    let hooks = git_dir.join("hooks");
15    std::fs::create_dir_all(&hooks).with_context(|| format!("creating {}", hooks.display()))?;
16    let hook_path = hooks.join("pre-commit");
17
18    let block = managed_block(with_check);
19    let new_content = if hook_path.exists() {
20        let current = std::fs::read_to_string(&hook_path)
21            .with_context(|| format!("reading {}", hook_path.display()))?;
22        insert_or_replace(&current, &block)
23    } else {
24        format!("#!/bin/sh\n{block}")
25    };
26
27    std::fs::write(&hook_path, new_content)
28        .with_context(|| format!("writing {}", hook_path.display()))?;
29    set_executable(&hook_path)?;
30    Ok(hook_path)
31}
32
33/// Remove the managed block. Returns whether a block was present.
34pub fn uninstall(dir: &Path) -> Result<bool> {
35    let git_dir = find_git_dir(dir).context("not inside a git repository")?;
36    let hook_path = git_dir.join("hooks").join("pre-commit");
37    if !hook_path.exists() {
38        return Ok(false);
39    }
40    let current = std::fs::read_to_string(&hook_path)
41        .with_context(|| format!("reading {}", hook_path.display()))?;
42    let (stripped, removed) = remove_block(&current);
43    if removed {
44        std::fs::write(&hook_path, stripped)
45            .with_context(|| format!("writing {}", hook_path.display()))?;
46    }
47    Ok(removed)
48}
49
50fn managed_block(with_check: bool) -> String {
51    let check_line = if with_check {
52        "  envo check || exit 1\n"
53    } else {
54        ""
55    };
56    format!(
57        "{BEGIN}\n# Managed by envo - do not edit between these markers.\n\
58         if command -v envo >/dev/null 2>&1; then\n\
59         {check_line}  envo scan --staged || exit 1\nfi\n{END}\n"
60    )
61}
62
63fn insert_or_replace(current: &str, block: &str) -> String {
64    if current.contains(BEGIN) && current.contains(END) {
65        let (stripped, _) = remove_block(current);
66        let mut base = stripped.trim_end().to_string();
67        base.push('\n');
68        base.push_str(block);
69        base
70    } else {
71        let mut base = current.trim_end().to_string();
72        base.push('\n');
73        base.push('\n');
74        base.push_str(block);
75        base
76    }
77}
78
79fn remove_block(current: &str) -> (String, bool) {
80    let Some(start) = current.find(BEGIN) else {
81        return (current.to_string(), false);
82    };
83    let Some(end_rel) = current[start..].find(END) else {
84        return (current.to_string(), false);
85    };
86    let end = start + end_rel + END.len();
87    let mut result = String::new();
88    result.push_str(current[..start].trim_end());
89    let tail = current[end..].trim_start_matches('\n');
90    if !tail.is_empty() {
91        result.push('\n');
92        result.push_str(tail);
93    } else {
94        result.push('\n');
95    }
96    (result, true)
97}
98
99/// Locate the `.git` directory, walking up from `dir`. Handles the common
100/// gitdir-file case used by worktrees and submodules.
101fn find_git_dir(dir: &Path) -> Result<PathBuf> {
102    let start = std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf());
103    let mut cur: Option<&Path> = Some(start.as_path());
104    while let Some(c) = cur {
105        let candidate = c.join(".git");
106        if candidate.is_dir() {
107            return Ok(candidate);
108        }
109        if candidate.is_file() {
110            let content = std::fs::read_to_string(&candidate)
111                .with_context(|| format!("reading {}", candidate.display()))?;
112            if let Some(rest) = content.trim().strip_prefix("gitdir:") {
113                let p = PathBuf::from(rest.trim());
114                let resolved = if p.is_absolute() { p } else { c.join(p) };
115                return Ok(resolved);
116            }
117        }
118        cur = c.parent();
119    }
120    bail!("no .git directory found from {}", dir.display())
121}
122
123#[cfg(unix)]
124fn set_executable(path: &Path) -> Result<()> {
125    use std::os::unix::fs::PermissionsExt;
126    let mut perms = std::fs::metadata(path)?.permissions();
127    perms.set_mode(0o755);
128    std::fs::set_permissions(path, perms)?;
129    Ok(())
130}
131
132#[cfg(not(unix))]
133fn set_executable(_path: &Path) -> Result<()> {
134    Ok(())
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    #[test]
142    fn block_roundtrip() {
143        let original = "#!/bin/sh\necho existing\n";
144        let block = managed_block(false);
145        let installed = insert_or_replace(original, &block);
146        assert!(installed.contains("echo existing"));
147        assert!(installed.contains(BEGIN));
148        assert!(installed.contains("envo scan --staged"));
149
150        let (removed, did) = remove_block(&installed);
151        assert!(did);
152        assert!(!removed.contains(BEGIN));
153        assert!(removed.contains("echo existing"));
154    }
155
156    #[test]
157    fn replace_is_idempotent() {
158        let block = managed_block(false);
159        let once = insert_or_replace("#!/bin/sh\n", &block);
160        let twice = insert_or_replace(&once, &block);
161        assert_eq!(once.matches(BEGIN).count(), 1);
162        assert_eq!(twice.matches(BEGIN).count(), 1);
163    }
164}