1use std::path::{Path, PathBuf};
4
5use anyhow::{bail, Context, Result};
6
7const BEGIN: &str = "# >>> envo >>>";
8const END: &str = "# <<< envo <<<";
9
10pub 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(¤t, &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
33pub 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(¤t);
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
99fn 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}