Skip to main content

graphify_hooks/
lib.rs

1//! Git hook integration for graphify.
2//!
3//! Installs/uninstalls post-commit and post-checkout hooks that trigger
4//! incremental graph rebuilds. Port of Python `hooks.py`.
5
6use std::fs;
7use std::path::Path;
8
9use thiserror::Error;
10
11/// Marker delimiters used to identify the graphify hook block.
12const HOOK_MARKER_START: &str = "# graphify-hook-start";
13const HOOK_MARKER_END: &str = "# graphify-hook-end";
14
15/// The hook script block injected into git hooks.
16const 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
25/// Hook names that graphify manages.
26const MANAGED_HOOKS: &[&str] = &["post-commit", "post-checkout"];
27
28/// Errors from hook management.
29#[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
38/// Install graphify git hooks in the repository at `repo_root`.
39///
40/// Installs post-commit and post-checkout hooks. If the hook files already
41/// exist, the graphify block is appended (or replaced if already present).
42pub 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
55/// Install a single hook file, preserving any existing content.
56fn 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    // Remove old marker block if present
66    content = strip_marker_block(&content);
67
68    // Append the new hook script
69    content.push_str(HOOK_SCRIPT);
70
71    fs::write(&hook_path, &content)?;
72
73    // Make executable on Unix
74    #[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
83/// Uninstall graphify git hooks from the repository at `repo_root`.
84///
85/// Removes the graphify marker block from each managed hook file. If the
86/// resulting file contains only the shebang line (or is empty), the hook
87/// file is deleted.
88pub 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
101/// Remove the graphify block from a single hook file.
102fn 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 only shebang remains (or empty), remove the file
113    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
122/// Check whether graphify hooks are installed in the repository at `repo_root`.
123///
124/// Returns a human-readable status string.
125pub 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
161/// Strip the graphify marker block from hook content.
162///
163/// Removes everything between (and including) the start and end markers,
164/// plus any surrounding blank lines.
165fn 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            // Also consume the trailing newline if present
170            let end_idx = if content[end_idx..].starts_with('\n') {
171                end_idx + 1
172            } else {
173                end_idx
174            };
175            // Strip leading newline before marker if present
176            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            // Malformed: start without end, remove from start to end of file
187            content[..start_idx].to_string()
188        }
189    } else {
190        content.to_string()
191    }
192}
193
194// ---------------------------------------------------------------------------
195// Tests
196// ---------------------------------------------------------------------------
197
198#[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        // With trailing newline after end marker
220        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        // Verify files exist
248        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        // Status should report all installed
256        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        // Should only contain one copy of the marker
270        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        // Write an existing hook
280        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        // Hook files with only shebang should be deleted
300        let post_commit = tmp.path().join(".git/hooks/post-commit");
301        assert!(!post_commit.exists());
302
303        // Status should report none installed
304        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        // File should still exist with the original content
320        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}