codesynapse_core/
hooks.rs1use 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(¬_a_repo).unwrap();
368 let err = install(¬_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 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 let result = status(&repo);
457 assert!(!result.is_empty());
459 }
460}