Skip to main content

codesynapse_core/
hooks.rs

1use std::fs;
2#[cfg(unix)]
3use std::os::unix::fs::PermissionsExt;
4use std::path::{Path, PathBuf};
5use std::process::Command;
6
7use crate::error::CodeSynapseError;
8
9pub const HOOK_MARKER: &str = "# codesynapse-hook-start";
10pub const HOOK_MARKER_END: &str = "# codesynapse-hook-end";
11pub const CHECKOUT_MARKER: &str = "# codesynapse-checkout-hook-start";
12pub const CHECKOUT_MARKER_END: &str = "# codesynapse-checkout-hook-end";
13
14const HOOK_SCRIPT: &str = r#"# codesynapse-hook-start
15# Auto-rebuilds the knowledge graph after each commit.
16# Installed by: codesynapse hook install
17
18GIT_DIR=$(git rev-parse --git-dir 2>/dev/null)
19[ -d "$GIT_DIR/rebase-merge" ] && exit 0
20[ -d "$GIT_DIR/rebase-apply" ] && exit 0
21[ -f "$GIT_DIR/MERGE_HEAD" ] && exit 0
22[ -f "$GIT_DIR/CHERRY_PICK_HEAD" ] && exit 0
23
24[ "${CODESYNAPSE_SKIP_HOOK:-0}" = "1" ] && exit 0
25
26CHANGED=$(git diff --name-only HEAD~1 HEAD 2>/dev/null || git diff --name-only HEAD 2>/dev/null)
27if [ -z "$CHANGED" ]; then
28    exit 0
29fi
30
31_NON_GRAPH=$(echo "$CHANGED" | grep -v '^codesynapse-out/' || true)
32if [ -z "$_NON_GRAPH" ]; then
33    exit 0
34fi
35
36_CODESYNAPSE_LOG="${HOME}/.cache/codesynapse-rebuild.log"
37mkdir -p "$(dirname "$_CODESYNAPSE_LOG")"
38echo "[codesynapse hook] launching background rebuild (log: $_CODESYNAPSE_LOG)"
39
40if command -v codesynapse >/dev/null 2>&1; then
41    nohup codesynapse build . >> "$_CODESYNAPSE_LOG" 2>&1 < /dev/null &
42    disown 2>/dev/null || true
43fi
44# codesynapse-hook-end
45"#;
46
47const CHECKOUT_SCRIPT: &str = r#"# codesynapse-checkout-hook-start
48# Auto-rebuilds the knowledge graph when switching branches.
49# Installed by: codesynapse hook install
50
51PREV_HEAD=$1
52NEW_HEAD=$2
53BRANCH_SWITCH=$3
54
55if [ "$BRANCH_SWITCH" != "1" ]; then
56    exit 0
57fi
58
59if [ ! -d "codesynapse-out" ]; then
60    exit 0
61fi
62
63GIT_DIR=$(git rev-parse --git-dir 2>/dev/null)
64[ -d "$GIT_DIR/rebase-merge" ] && exit 0
65[ -d "$GIT_DIR/rebase-apply" ] && exit 0
66[ -f "$GIT_DIR/MERGE_HEAD" ] && exit 0
67[ -f "$GIT_DIR/CHERRY_PICK_HEAD" ] && exit 0
68
69_CODESYNAPSE_LOG="${HOME}/.cache/codesynapse-rebuild.log"
70mkdir -p "$(dirname "$_CODESYNAPSE_LOG")"
71echo "[codesynapse] Branch switched - launching background rebuild (log: $_CODESYNAPSE_LOG)"
72
73if command -v codesynapse >/dev/null 2>&1; then
74    nohup codesynapse build . >> "$_CODESYNAPSE_LOG" 2>&1 < /dev/null &
75    disown 2>/dev/null || true
76fi
77# codesynapse-checkout-hook-end
78"#;
79
80pub fn git_root(path: &Path) -> Option<PathBuf> {
81    let current = path.canonicalize().ok()?;
82    let candidates = std::iter::once(current.as_path()).chain(current.ancestors().skip(1));
83    for dir in candidates {
84        if dir.join(".git").exists() {
85            return Some(dir.to_path_buf());
86        }
87    }
88    None
89}
90
91pub fn hooks_dir(root: &Path) -> PathBuf {
92    let result = Command::new("git")
93        .args([
94            "-C",
95            &root.to_string_lossy(),
96            "rev-parse",
97            "--git-path",
98            "hooks",
99        ])
100        .output();
101
102    if let Ok(out) = result {
103        if out.status.success() {
104            let raw = String::from_utf8_lossy(&out.stdout);
105            let raw = raw.trim();
106            if !raw.is_empty() && !raw.contains('\n') && !raw.contains('\r') && !raw.contains('\0')
107            {
108                let p = Path::new(raw);
109                let resolved = if p.is_absolute() {
110                    p.to_path_buf()
111                } else {
112                    root.join(p)
113                };
114                let _ = fs::create_dir_all(&resolved);
115                return resolved;
116            }
117        }
118    }
119
120    let default = root.join(".git").join("hooks");
121    let _ = fs::create_dir_all(&default);
122    default
123}
124
125fn install_hook(
126    hooks_dir: &Path,
127    name: &str,
128    script: &str,
129    marker: &str,
130) -> Result<String, CodeSynapseError> {
131    let hook_path = hooks_dir.join(name);
132    if hook_path.exists() {
133        let content = fs::read_to_string(&hook_path)?;
134        if content.contains(marker) {
135            return Ok(format!("already installed at {}", hook_path.display()));
136        }
137        let new_content = format!("{}\n\n{}", content.trim_end(), script);
138        fs::write(&hook_path, new_content)?;
139        return Ok(format!(
140            "appended to existing {} hook at {}",
141            name,
142            hook_path.display()
143        ));
144    }
145    let content = format!("#!/bin/sh\n{}", script);
146    fs::write(&hook_path, &content)?;
147    #[cfg(unix)]
148    {
149        let mut perms = fs::metadata(&hook_path)?.permissions();
150        perms.set_mode(0o755);
151        fs::set_permissions(&hook_path, perms)?;
152    }
153    Ok(format!("installed at {}", hook_path.display()))
154}
155
156fn uninstall_hook(hooks_dir: &Path, name: &str, marker: &str, marker_end: &str) -> String {
157    let hook_path = hooks_dir.join(name);
158    if !hook_path.exists() {
159        return format!("no {} hook found - nothing to remove.", name);
160    }
161    let content = match fs::read_to_string(&hook_path) {
162        Ok(c) => c,
163        Err(_) => return format!("no {} hook found - nothing to remove.", name),
164    };
165    if !content.contains(marker) {
166        return format!(
167            "codesynapse hook not found in {} - nothing to remove.",
168            name
169        );
170    }
171
172    let pattern = format!(
173        "{}{}{}",
174        regex::escape(marker),
175        r"[\s\S]*?",
176        regex::escape(marker_end)
177    );
178    let re = regex::Regex::new(&pattern).unwrap();
179    let new_content = re.replace(&content, "").to_string();
180    let new_content = new_content.trim();
181
182    if new_content.is_empty() || new_content == "#!/bin/bash" || new_content == "#!/bin/sh" {
183        let _ = fs::remove_file(&hook_path);
184        return format!("removed {} hook at {}", name, hook_path.display());
185    }
186    let _ = fs::write(&hook_path, format!("{}\n", new_content));
187    format!(
188        "codesynapse removed from {} at {} (other hook content preserved)",
189        name,
190        hook_path.display()
191    )
192}
193
194pub fn install(path: &Path) -> Result<String, CodeSynapseError> {
195    let root = git_root(path).ok_or_else(|| {
196        CodeSynapseError::Validation(format!(
197            "No git repository found at or above {}",
198            path.display()
199        ))
200    })?;
201    let hdir = hooks_dir(&root);
202    let commit_msg = install_hook(&hdir, "post-commit", HOOK_SCRIPT, HOOK_MARKER)?;
203    let checkout_msg = install_hook(&hdir, "post-checkout", CHECKOUT_SCRIPT, CHECKOUT_MARKER)?;
204    Ok(format!(
205        "post-commit: {}\npost-checkout: {}",
206        commit_msg, checkout_msg
207    ))
208}
209
210pub fn uninstall(path: &Path) -> Result<String, CodeSynapseError> {
211    let root = git_root(path).ok_or_else(|| {
212        CodeSynapseError::Validation(format!(
213            "No git repository found at or above {}",
214            path.display()
215        ))
216    })?;
217    let hdir = hooks_dir(&root);
218    let commit_msg = uninstall_hook(&hdir, "post-commit", HOOK_MARKER, HOOK_MARKER_END);
219    let checkout_msg = uninstall_hook(&hdir, "post-checkout", CHECKOUT_MARKER, CHECKOUT_MARKER_END);
220    Ok(format!(
221        "post-commit: {}\npost-checkout: {}",
222        commit_msg, checkout_msg
223    ))
224}
225
226pub fn status(path: &Path) -> String {
227    let root = match git_root(path) {
228        Some(r) => r,
229        None => return "Not in a git repository.".to_string(),
230    };
231    let hdir = hooks_dir(&root);
232
233    let check = |name: &str, marker: &str| -> String {
234        let p = hdir.join(name);
235        if !p.exists() {
236            return "not installed".to_string();
237        }
238        match fs::read_to_string(&p) {
239            Ok(c) if c.contains(marker) => "installed".to_string(),
240            _ => "not installed (hook exists but codesynapse not found)".to_string(),
241        }
242    };
243
244    let commit = check("post-commit", HOOK_MARKER);
245    let checkout = check("post-checkout", CHECKOUT_MARKER);
246    format!("post-commit: {}\npost-checkout: {}", commit, checkout)
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252    use std::os::unix::fs::PermissionsExt;
253    use tempfile::TempDir;
254
255    const PYTHON_DETECT: &str = r#"# Detect the correct interpreter (handles pipx, venv, system installs)
256CODESYNAPSE_BIN=$(command -v codesynapse 2>/dev/null)
257if [ -n "$CODESYNAPSE_BIN" ]; then
258    case "$CODESYNAPSE_BIN" in
259        *.exe) _SHEBANG="" ;;
260        *)     _SHEBANG=$(head -1 "$CODESYNAPSE_BIN" | sed 's/^#![[:space:]]*//') ;;
261    esac
262fi
263"#;
264
265    fn make_git_repo(tmp: &TempDir) -> PathBuf {
266        let path = tmp.path().to_path_buf();
267        Command::new("git")
268            .args(["init", path.to_str().unwrap()])
269            .output()
270            .expect("git init failed");
271        path
272    }
273
274    #[test]
275    fn test_install_creates_hook() {
276        let tmp = TempDir::new().unwrap();
277        let repo = make_git_repo(&tmp);
278        let result = install(&repo).unwrap();
279        let hook = repo.join(".git/hooks/post-commit");
280        assert!(hook.exists());
281        let content = fs::read_to_string(&hook).unwrap();
282        assert!(content.contains(HOOK_MARKER));
283        assert!(result.contains("installed"));
284    }
285
286    #[test]
287    fn test_install_is_executable() {
288        let tmp = TempDir::new().unwrap();
289        let repo = make_git_repo(&tmp);
290        install(&repo).unwrap();
291        let hook = repo.join(".git/hooks/post-commit");
292        let mode = fs::metadata(&hook).unwrap().permissions().mode();
293        assert!(mode & 0o111 != 0);
294    }
295
296    #[test]
297    fn test_install_idempotent() {
298        let tmp = TempDir::new().unwrap();
299        let repo = make_git_repo(&tmp);
300        install(&repo).unwrap();
301        let result = install(&repo).unwrap();
302        assert!(result.contains("already installed"));
303        let hook = repo.join(".git/hooks/post-commit");
304        let content = fs::read_to_string(&hook).unwrap();
305        assert_eq!(content.matches(HOOK_MARKER).count(), 1);
306    }
307
308    #[test]
309    fn test_install_appends_to_existing_hook() {
310        let tmp = TempDir::new().unwrap();
311        let repo = make_git_repo(&tmp);
312        let hook = repo.join(".git/hooks/post-commit");
313        fs::create_dir_all(hook.parent().unwrap()).unwrap();
314        fs::write(&hook, "#!/bin/bash\necho existing\n").unwrap();
315        #[cfg(unix)]
316        {
317            let mut perms = fs::metadata(&hook).unwrap().permissions();
318            perms.set_mode(0o755);
319            fs::set_permissions(&hook, perms).unwrap();
320        }
321        install(&repo).unwrap();
322        let content = fs::read_to_string(&hook).unwrap();
323        assert!(content.contains("existing"));
324        assert!(content.contains(HOOK_MARKER));
325    }
326
327    #[test]
328    fn test_uninstall_removes_hook() {
329        let tmp = TempDir::new().unwrap();
330        let repo = make_git_repo(&tmp);
331        install(&repo).unwrap();
332        let result = uninstall(&repo).unwrap();
333        let hook = repo.join(".git/hooks/post-commit");
334        assert!(!hook.exists());
335        assert!(result.to_lowercase().contains("removed"));
336    }
337
338    #[test]
339    fn test_uninstall_no_hook() {
340        let tmp = TempDir::new().unwrap();
341        let repo = make_git_repo(&tmp);
342        let result = uninstall(&repo).unwrap();
343        assert!(result.contains("nothing to remove"));
344    }
345
346    #[test]
347    fn test_status_installed() {
348        let tmp = TempDir::new().unwrap();
349        let repo = make_git_repo(&tmp);
350        install(&repo).unwrap();
351        let result = status(&repo);
352        assert!(result.contains("installed"));
353    }
354
355    #[test]
356    fn test_status_not_installed() {
357        let tmp = TempDir::new().unwrap();
358        let repo = make_git_repo(&tmp);
359        let result = status(&repo);
360        assert!(result.contains("not installed"));
361    }
362
363    #[test]
364    fn test_no_git_repo_raises() {
365        let tmp = TempDir::new().unwrap();
366        let not_a_repo = tmp.path().join("not_a_repo");
367        fs::create_dir_all(&not_a_repo).unwrap();
368        let err = install(&not_a_repo).unwrap_err();
369        assert!(err.to_string().contains("No git repository"));
370    }
371
372    #[test]
373    fn test_install_creates_post_checkout_hook() {
374        let tmp = TempDir::new().unwrap();
375        let repo = make_git_repo(&tmp);
376        install(&repo).unwrap();
377        let hook = repo.join(".git/hooks/post-checkout");
378        assert!(hook.exists());
379        let content = fs::read_to_string(&hook).unwrap();
380        assert!(content.contains(CHECKOUT_MARKER));
381    }
382
383    #[test]
384    fn test_install_post_checkout_is_executable() {
385        let tmp = TempDir::new().unwrap();
386        let repo = make_git_repo(&tmp);
387        install(&repo).unwrap();
388        let hook = repo.join(".git/hooks/post-checkout");
389        let mode = fs::metadata(&hook).unwrap().permissions().mode();
390        assert!(mode & 0o111 != 0);
391    }
392
393    #[test]
394    fn test_uninstall_removes_post_checkout_hook() {
395        let tmp = TempDir::new().unwrap();
396        let repo = make_git_repo(&tmp);
397        install(&repo).unwrap();
398        uninstall(&repo).unwrap();
399        let hook = repo.join(".git/hooks/post-checkout");
400        assert!(!hook.exists());
401    }
402
403    #[test]
404    fn test_status_shows_both_hooks() {
405        let tmp = TempDir::new().unwrap();
406        let repo = make_git_repo(&tmp);
407        install(&repo).unwrap();
408        let result = status(&repo);
409        assert!(result.contains("post-commit"));
410        assert!(result.contains("post-checkout"));
411        assert!(result.matches("installed").count() >= 2);
412    }
413
414    #[test]
415    fn test_hooks_dir_resolves_relative_git_hooks_path() {
416        let tmp = TempDir::new().unwrap();
417        let repo = make_git_repo(&tmp);
418        let hdir = hooks_dir(&repo);
419        assert!(hdir.is_absolute());
420        assert!(hdir.to_string_lossy().contains("hooks"));
421    }
422
423    #[test]
424    fn test_hooks_dir_rejects_multiline_git_output() {
425        let tmp = TempDir::new().unwrap();
426        let repo = make_git_repo(&tmp);
427        let hdir = hooks_dir(&repo);
428        let hdir_str = hdir.to_string_lossy();
429        assert!(!hdir_str.contains('\n'));
430        assert!(!hdir_str.contains('\r'));
431    }
432
433    #[test]
434    fn test_hooks_dir_accepts_absolute_git_hooks_path() {
435        let tmp = TempDir::new().unwrap();
436        let repo = make_git_repo(&tmp);
437        let hdir = hooks_dir(&repo);
438        assert!(hdir.is_absolute());
439    }
440
441    #[test]
442    fn test_hook_skips_head_on_exe() {
443        assert!(PYTHON_DETECT.contains("*.exe)"));
444    }
445
446    #[test]
447    fn test_hook_check_no_additional_context() {
448        // hook-check: when graph.json exists in codesynapse-out/, status() should
449        // return without error and produce no unexpected output
450        let tmp = TempDir::new().unwrap();
451        let repo = make_git_repo(&tmp);
452        let out = repo.join("codesynapse-out");
453        fs::create_dir_all(&out).unwrap();
454        fs::write(out.join("graph.json"), "{}").unwrap();
455        // status() returns a string (no panic/error) — equivalent to returncode==0
456        let result = status(&repo);
457        // Should return a valid status string, not panic
458        assert!(!result.is_empty());
459    }
460}