1use std::fs;
7use std::path::Path;
8
9use thiserror::Error;
10
11const HOOK_MARKER_START: &str = "# graphify-hook-start";
13const HOOK_MARKER_END: &str = "# graphify-hook-end";
14
15const HOOK_SCRIPT: &str = r#"
17# graphify-hook-start
18# Auto-run graphify-rs AST extraction on commit (code-only, no LLM)
19if command -v graphify-rs >/dev/null 2>&1; then
20 graphify-rs build --code-only --output graphify-out &
21fi
22# graphify-hook-end
23"#;
24
25const MANAGED_HOOKS: &[&str] = &["post-commit", "post-checkout"];
27
28#[derive(Debug, Error)]
30pub enum HookError {
31 #[error("IO error: {0}")]
32 Io(#[from] std::io::Error),
33
34 #[error("not a git repository (missing .git/hooks): {0}")]
35 NotGitRepo(String),
36}
37
38pub fn install_hooks(repo_root: &Path) -> Result<String, HookError> {
43 let hooks_dir = repo_root.join(".git/hooks");
44 if !hooks_dir.exists() {
45 return Err(HookError::NotGitRepo(repo_root.display().to_string()));
46 }
47
48 for hook_name in MANAGED_HOOKS {
49 install_single_hook(&hooks_dir, hook_name)?;
50 }
51
52 Ok("Git hooks installed (post-commit, post-checkout)".to_string())
53}
54
55fn install_single_hook(hooks_dir: &Path, name: &str) -> Result<(), HookError> {
57 let hook_path = hooks_dir.join(name);
58
59 let mut content = if hook_path.exists() {
60 fs::read_to_string(&hook_path)?
61 } else {
62 "#!/bin/sh\n".to_string()
63 };
64
65 content = strip_marker_block(&content);
67
68 content.push_str(HOOK_SCRIPT);
70
71 fs::write(&hook_path, &content)?;
72
73 #[cfg(unix)]
75 {
76 use std::os::unix::fs::PermissionsExt;
77 fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o755))?;
78 }
79
80 Ok(())
81}
82
83pub fn uninstall_hooks(repo_root: &Path) -> Result<String, HookError> {
89 let hooks_dir = repo_root.join(".git/hooks");
90 if !hooks_dir.exists() {
91 return Err(HookError::NotGitRepo(repo_root.display().to_string()));
92 }
93
94 for hook_name in MANAGED_HOOKS {
95 uninstall_single_hook(&hooks_dir, hook_name)?;
96 }
97
98 Ok("Git hooks removed (post-commit, post-checkout)".to_string())
99}
100
101fn uninstall_single_hook(hooks_dir: &Path, name: &str) -> Result<(), HookError> {
103 let hook_path = hooks_dir.join(name);
104 if !hook_path.exists() {
105 return Ok(());
106 }
107
108 let content = fs::read_to_string(&hook_path)?;
109 let cleaned = strip_marker_block(&content);
110 let trimmed = cleaned.trim();
111
112 if trimmed.is_empty() || trimmed == "#!/bin/sh" || trimmed == "#!/bin/bash" {
114 fs::remove_file(&hook_path)?;
115 } else {
116 fs::write(&hook_path, &cleaned)?;
117 }
118
119 Ok(())
120}
121
122pub fn hook_status(repo_root: &Path) -> Result<String, HookError> {
126 let hooks_dir = repo_root.join(".git/hooks");
127 if !hooks_dir.exists() {
128 return Err(HookError::NotGitRepo(repo_root.display().to_string()));
129 }
130
131 let mut installed = Vec::new();
132 let mut missing = Vec::new();
133
134 for hook_name in MANAGED_HOOKS {
135 let hook_path = hooks_dir.join(hook_name);
136 if hook_path.exists() {
137 let content = fs::read_to_string(&hook_path)?;
138 if content.contains(HOOK_MARKER_START) {
139 installed.push(*hook_name);
140 } else {
141 missing.push(*hook_name);
142 }
143 } else {
144 missing.push(*hook_name);
145 }
146 }
147
148 if missing.is_empty() {
149 Ok(format!("All hooks installed: {}", installed.join(", ")))
150 } else if installed.is_empty() {
151 Ok("No graphify hooks installed".to_string())
152 } else {
153 Ok(format!(
154 "Installed: {}; Missing: {}",
155 installed.join(", "),
156 missing.join(", ")
157 ))
158 }
159}
160
161fn strip_marker_block(content: &str) -> String {
166 if let Some(start_idx) = content.find(HOOK_MARKER_START) {
167 if let Some(end_marker_start) = content[start_idx..].find(HOOK_MARKER_END) {
168 let end_idx = start_idx + end_marker_start + HOOK_MARKER_END.len();
169 let end_idx = if content[end_idx..].starts_with('\n') {
171 end_idx + 1
172 } else {
173 end_idx
174 };
175 let start_idx = if start_idx > 0 && content.as_bytes()[start_idx - 1] == b'\n' {
177 start_idx - 1
178 } else {
179 start_idx
180 };
181 let mut result = String::with_capacity(content.len());
182 result.push_str(&content[..start_idx]);
183 result.push_str(&content[end_idx..]);
184 result
185 } else {
186 content[..start_idx].to_string()
188 }
189 } else {
190 content.to_string()
191 }
192}
193
194#[cfg(test)]
199mod tests {
200 use super::*;
201 use std::fs;
202
203 fn setup_fake_repo(dir: &Path) {
204 let hooks_dir = dir.join(".git/hooks");
205 fs::create_dir_all(&hooks_dir).unwrap();
206 }
207
208 #[test]
209 fn test_strip_marker_block_empty() {
210 assert_eq!(strip_marker_block("no markers here"), "no markers here");
211 }
212
213 #[test]
214 fn test_strip_marker_block() {
215 let input = "#!/bin/sh\n# graphify-hook-start\nsome stuff\n# graphify-hook-end\nother";
216 let result = strip_marker_block(input);
217 assert_eq!(result, "#!/bin/shother");
218
219 let input2 = "#!/bin/sh\n\n# graphify-hook-start\nsome stuff\n# graphify-hook-end\nother";
221 let result2 = strip_marker_block(input2);
222 assert_eq!(result2, "#!/bin/sh\nother");
223 }
224
225 #[test]
226 fn test_strip_marker_block_no_end() {
227 let input = "#!/bin/sh\n# graphify-hook-start\norphan";
228 let result = strip_marker_block(input);
229 assert_eq!(result, "#!/bin/sh\n");
230 }
231
232 #[test]
233 fn test_install_not_git_repo() {
234 let tmp = tempfile::tempdir().unwrap();
235 let result = install_hooks(tmp.path());
236 assert!(matches!(result, Err(HookError::NotGitRepo(_))));
237 }
238
239 #[test]
240 fn test_install_and_status() {
241 let tmp = tempfile::tempdir().unwrap();
242 setup_fake_repo(tmp.path());
243
244 let msg = install_hooks(tmp.path()).unwrap();
245 assert!(msg.contains("installed"));
246
247 let post_commit = tmp.path().join(".git/hooks/post-commit");
249 assert!(post_commit.exists());
250 let content = fs::read_to_string(&post_commit).unwrap();
251 assert!(content.contains(HOOK_MARKER_START));
252 assert!(content.contains(HOOK_MARKER_END));
253 assert!(content.starts_with("#!/bin/sh"));
254
255 let status = hook_status(tmp.path()).unwrap();
257 assert!(status.contains("All hooks installed"));
258 }
259
260 #[test]
261 fn test_install_idempotent() {
262 let tmp = tempfile::tempdir().unwrap();
263 setup_fake_repo(tmp.path());
264
265 install_hooks(tmp.path()).unwrap();
266 install_hooks(tmp.path()).unwrap();
267
268 let content = fs::read_to_string(tmp.path().join(".git/hooks/post-commit")).unwrap();
269 let count = content.matches(HOOK_MARKER_START).count();
271 assert_eq!(count, 1, "Hook block should not be duplicated");
272 }
273
274 #[test]
275 fn test_install_preserves_existing() {
276 let tmp = tempfile::tempdir().unwrap();
277 setup_fake_repo(tmp.path());
278
279 let hook_path = tmp.path().join(".git/hooks/post-commit");
281 fs::write(&hook_path, "#!/bin/sh\necho 'existing'\n").unwrap();
282
283 install_hooks(tmp.path()).unwrap();
284
285 let content = fs::read_to_string(&hook_path).unwrap();
286 assert!(content.contains("echo 'existing'"));
287 assert!(content.contains(HOOK_MARKER_START));
288 }
289
290 #[test]
291 fn test_uninstall() {
292 let tmp = tempfile::tempdir().unwrap();
293 setup_fake_repo(tmp.path());
294
295 install_hooks(tmp.path()).unwrap();
296 let msg = uninstall_hooks(tmp.path()).unwrap();
297 assert!(msg.contains("removed"));
298
299 let post_commit = tmp.path().join(".git/hooks/post-commit");
301 assert!(!post_commit.exists());
302
303 let status = hook_status(tmp.path()).unwrap();
305 assert!(status.contains("No graphify hooks installed"));
306 }
307
308 #[test]
309 fn test_uninstall_preserves_other_content() {
310 let tmp = tempfile::tempdir().unwrap();
311 setup_fake_repo(tmp.path());
312
313 let hook_path = tmp.path().join(".git/hooks/post-commit");
314 fs::write(&hook_path, "#!/bin/sh\necho 'keep me'\n").unwrap();
315
316 install_hooks(tmp.path()).unwrap();
317 uninstall_hooks(tmp.path()).unwrap();
318
319 assert!(hook_path.exists());
321 let content = fs::read_to_string(&hook_path).unwrap();
322 assert!(content.contains("echo 'keep me'"));
323 assert!(!content.contains(HOOK_MARKER_START));
324 }
325
326 #[test]
327 fn test_hook_status_not_git_repo() {
328 let tmp = tempfile::tempdir().unwrap();
329 let result = hook_status(tmp.path());
330 assert!(matches!(result, Err(HookError::NotGitRepo(_))));
331 }
332}