1use crate::errors::CoreError;
2use std::path::Path;
3use std::process::Command;
4
5const HOOK_MARKER: &str = "# retro hook - do not remove";
6
7pub fn is_in_git_repo() -> bool {
9 Command::new("git")
10 .args(["rev-parse", "--is-inside-work-tree"])
11 .stdout(std::process::Stdio::null())
12 .stderr(std::process::Stdio::null())
13 .status()
14 .map(|s| s.success())
15 .unwrap_or(false)
16}
17
18pub fn is_gh_available() -> bool {
20 Command::new("gh")
21 .arg("--version")
22 .stdout(std::process::Stdio::null())
23 .stderr(std::process::Stdio::null())
24 .status()
25 .map(|s| s.success())
26 .unwrap_or(false)
27}
28
29pub fn git_root() -> Result<String, CoreError> {
31 let output = Command::new("git")
32 .args(["rev-parse", "--show-toplevel"])
33 .output()
34 .map_err(|e| CoreError::Io(format!("running git: {e}")))?;
35
36 if !output.status.success() {
37 return Err(CoreError::Io("not inside a git repository".to_string()));
38 }
39
40 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
41}
42
43pub fn current_branch() -> Result<String, CoreError> {
45 let output = Command::new("git")
46 .args(["rev-parse", "--abbrev-ref", "HEAD"])
47 .output()
48 .map_err(|e| CoreError::Io(format!("getting current branch: {e}")))?;
49
50 if !output.status.success() {
51 return Err(CoreError::Io("failed to get current branch".to_string()));
52 }
53
54 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
55}
56
57pub fn create_branch(name: &str, start_point: Option<&str>) -> Result<(), CoreError> {
60 let mut args = vec!["checkout", "-b", name];
61 if let Some(sp) = start_point {
62 args.push(sp);
63 }
64
65 let output = Command::new("git")
66 .args(&args)
67 .output()
68 .map_err(|e| CoreError::Io(format!("creating branch: {e}")))?;
69
70 if !output.status.success() {
71 let stderr = String::from_utf8_lossy(&output.stderr);
72 return Err(CoreError::Io(format!("git checkout -b failed: {stderr}")));
73 }
74
75 Ok(())
76}
77
78pub fn default_branch() -> Result<String, CoreError> {
80 let output = Command::new("gh")
81 .args(["repo", "view", "--json", "defaultBranchRef", "-q", ".defaultBranchRef.name"])
82 .output()
83 .map_err(|e| CoreError::Io(format!("gh repo view: {e}")))?;
84
85 if !output.status.success() {
86 let stderr = String::from_utf8_lossy(&output.stderr);
87 return Err(CoreError::Io(format!("failed to detect default branch: {stderr}")));
88 }
89
90 let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
91 if name.is_empty() {
92 return Err(CoreError::Io("default branch name is empty".to_string()));
93 }
94 Ok(name)
95}
96
97pub fn fetch_branch(branch: &str) -> Result<(), CoreError> {
99 let output = Command::new("git")
100 .args(["fetch", "origin", branch])
101 .output()
102 .map_err(|e| CoreError::Io(format!("git fetch: {e}")))?;
103
104 if !output.status.success() {
105 let stderr = String::from_utf8_lossy(&output.stderr);
106 return Err(CoreError::Io(format!("git fetch origin {branch} failed: {stderr}")));
107 }
108
109 Ok(())
110}
111
112pub fn stash_push() -> Result<bool, CoreError> {
114 let output = Command::new("git")
115 .args(["stash", "push", "-m", "retro: temporary stash for branch switch"])
116 .output()
117 .map_err(|e| CoreError::Io(format!("git stash: {e}")))?;
118
119 if !output.status.success() {
120 let stderr = String::from_utf8_lossy(&output.stderr);
121 return Err(CoreError::Io(format!("git stash failed: {stderr}")));
122 }
123
124 let stdout = String::from_utf8_lossy(&output.stdout);
125 Ok(!stdout.contains("No local changes"))
127}
128
129pub fn stash_pop() -> Result<(), CoreError> {
131 let output = Command::new("git")
132 .args(["stash", "pop"])
133 .output()
134 .map_err(|e| CoreError::Io(format!("git stash pop: {e}")))?;
135
136 if !output.status.success() {
137 let stderr = String::from_utf8_lossy(&output.stderr);
138 return Err(CoreError::Io(format!("git stash pop failed: {stderr}")));
139 }
140
141 Ok(())
142}
143
144pub fn push_current_branch() -> Result<(), CoreError> {
146 let output = Command::new("git")
147 .args(["push", "-u", "origin", "HEAD"])
148 .output()
149 .map_err(|e| CoreError::Io(format!("git push: {e}")))?;
150
151 if !output.status.success() {
152 let stderr = String::from_utf8_lossy(&output.stderr);
153 return Err(CoreError::Io(format!("git push failed: {stderr}")));
154 }
155
156 Ok(())
157}
158
159pub fn checkout_branch(name: &str) -> Result<(), CoreError> {
161 let output = Command::new("git")
162 .args(["checkout", name])
163 .output()
164 .map_err(|e| CoreError::Io(format!("checking out branch: {e}")))?;
165
166 if !output.status.success() {
167 let stderr = String::from_utf8_lossy(&output.stderr);
168 return Err(CoreError::Io(format!("git checkout failed: {stderr}")));
169 }
170
171 Ok(())
172}
173
174pub fn commit_files(files: &[&str], message: &str) -> Result<(), CoreError> {
179 stage_files(files)?;
180
181 let output = Command::new("git")
182 .args(["commit", "-m", message])
183 .output()
184 .map_err(|e| CoreError::Io(format!("git commit: {e}")))?;
185
186 if output.status.success() {
187 return Ok(());
188 }
189
190 stage_files(files)?;
192
193 let output = Command::new("git")
194 .args(["commit", "-m", message])
195 .output()
196 .map_err(|e| CoreError::Io(format!("git commit (retry): {e}")))?;
197
198 if !output.status.success() {
199 let stderr = String::from_utf8_lossy(&output.stderr);
200 return Err(CoreError::Io(format!("git commit failed: {stderr}")));
201 }
202
203 Ok(())
204}
205
206fn stage_files(files: &[&str]) -> Result<(), CoreError> {
207 let mut args = vec!["add", "--"];
208 args.extend(files);
209
210 let output = Command::new("git")
211 .args(&args)
212 .output()
213 .map_err(|e| CoreError::Io(format!("git add: {e}")))?;
214
215 if !output.status.success() {
216 let stderr = String::from_utf8_lossy(&output.stderr);
217 return Err(CoreError::Io(format!("git add failed: {stderr}")));
218 }
219
220 Ok(())
221}
222
223pub fn create_pr(title: &str, body: &str, base: &str) -> Result<String, CoreError> {
226 let output = Command::new("gh")
227 .args(["pr", "create", "--title", title, "--body", body, "--base", base])
228 .output()
229 .map_err(|e| CoreError::Io(format!("gh pr create: {e}")))?;
230
231 if !output.status.success() {
232 let stderr = String::from_utf8_lossy(&output.stderr);
233 return Err(CoreError::Io(format!("gh pr create failed: {stderr}")));
234 }
235
236 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
237}
238
239pub fn pr_state(pr_url: &str) -> Result<String, CoreError> {
241 let output = Command::new("gh")
242 .args(["pr", "view", pr_url, "--json", "state", "-q", ".state"])
243 .output()
244 .map_err(|e| CoreError::Io(format!("gh pr view: {e}")))?;
245
246 if !output.status.success() {
247 let stderr = String::from_utf8_lossy(&output.stderr);
248 return Err(CoreError::Io(format!("gh pr view failed: {stderr}")));
249 }
250
251 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
252}
253
254#[derive(Debug, PartialEq)]
256pub enum HookInstallResult {
257 Installed,
259 Updated,
261 UpToDate,
263}
264
265pub fn install_hooks(repo_root: &str) -> Result<Vec<(String, HookInstallResult)>, CoreError> {
268 let hooks_dir = Path::new(repo_root).join(".git").join("hooks");
269 let mut results = Vec::new();
270
271 let post_commit_path = hooks_dir.join("post-commit");
273 let hook_lines = format!("{HOOK_MARKER}\nretro ingest --auto 2>>~/.retro/hook-stderr.log &\n");
274 let result = install_hook_lines(&post_commit_path, &hook_lines)?;
275 results.push(("post-commit".to_string(), result));
276
277 let post_merge_path = hooks_dir.join("post-merge");
279 if post_merge_path.exists()
280 && let Ok(content) = std::fs::read_to_string(&post_merge_path)
281 && content.contains(HOOK_MARKER)
282 {
283 let cleaned = remove_hook_lines(&content);
284 if cleaned.trim() == "#!/bin/sh" || cleaned.trim().is_empty() {
285 std::fs::remove_file(&post_merge_path).ok();
286 } else {
287 std::fs::write(&post_merge_path, cleaned).ok();
288 }
289 }
290
291 Ok(results)
292}
293
294fn install_hook_lines(hook_path: &Path, lines: &str) -> Result<HookInstallResult, CoreError> {
298 let existing = if hook_path.exists() {
299 std::fs::read_to_string(hook_path)
300 .map_err(|e| CoreError::Io(format!("reading hook {}: {e}", hook_path.display())))?
301 } else {
302 String::new()
303 };
304
305 let (base_content, was_present) = if existing.contains(HOOK_MARKER) {
306 if existing.contains(lines.trim()) {
308 return Ok(HookInstallResult::UpToDate);
309 }
310 (remove_hook_lines(&existing), true)
312 } else {
313 (existing, false)
314 };
315
316 let mut content = if base_content.is_empty() {
317 "#!/bin/sh\n".to_string()
318 } else {
319 let mut s = base_content;
320 if !s.ends_with('\n') {
321 s.push('\n');
322 }
323 s
324 };
325
326 content.push_str(lines);
327
328 std::fs::write(hook_path, &content)
329 .map_err(|e| CoreError::Io(format!("writing hook {}: {e}", hook_path.display())))?;
330
331 #[cfg(unix)]
333 {
334 use std::os::unix::fs::PermissionsExt;
335 let perms = std::fs::Permissions::from_mode(0o755);
336 std::fs::set_permissions(hook_path, perms)
337 .map_err(|e| CoreError::Io(format!("chmod hook: {e}")))?;
338 }
339
340 Ok(if was_present {
341 HookInstallResult::Updated
342 } else {
343 HookInstallResult::Installed
344 })
345}
346
347pub fn remove_hooks(repo_root: &str) -> Result<Vec<String>, CoreError> {
350 let hooks_dir = Path::new(repo_root).join(".git").join("hooks");
351 if !hooks_dir.exists() {
352 return Ok(Vec::new());
353 }
354
355 let mut modified = Vec::new();
356
357 for hook_name in &["post-commit", "post-merge"] {
358 let hook_path = hooks_dir.join(hook_name);
359 if !hook_path.exists() {
360 continue;
361 }
362
363 let content = std::fs::read_to_string(&hook_path)
364 .map_err(|e| CoreError::Io(format!("reading hook: {e}")))?;
365
366 if !content.contains(HOOK_MARKER) {
367 continue;
368 }
369
370 let cleaned = remove_hook_lines(&content);
371
372 let trimmed = cleaned.trim();
374 if trimmed.is_empty() || trimmed == "#!/bin/sh" || trimmed == "#!/bin/bash" {
375 std::fs::remove_file(&hook_path)
376 .map_err(|e| CoreError::Io(format!("removing hook file: {e}")))?;
377 } else {
378 std::fs::write(&hook_path, &cleaned)
379 .map_err(|e| CoreError::Io(format!("writing cleaned hook: {e}")))?;
380 }
381
382 modified.push(hook_name.to_string());
383 }
384
385 Ok(modified)
386}
387
388fn remove_hook_lines(content: &str) -> String {
391 let mut result = Vec::new();
392 let mut skip_next = false;
393
394 for line in content.lines() {
395 if skip_next {
396 skip_next = false;
397 continue;
398 }
399 if line.trim() == HOOK_MARKER {
400 skip_next = true;
401 continue;
402 }
403 result.push(line);
404 }
405
406 let mut output = result.join("\n");
407 if !output.is_empty() && content.ends_with('\n') {
408 output.push('\n');
409 }
410 output
411}
412
413#[cfg(test)]
414mod tests {
415 use super::*;
416
417 #[test]
418 fn test_remove_hook_lines_basic() {
419 let content = "#!/bin/sh\n# retro hook - do not remove\nretro ingest 2>/dev/null &\n";
420 let result = remove_hook_lines(content);
421 assert_eq!(result, "#!/bin/sh\n");
422 }
423
424 #[test]
425 fn test_remove_hook_lines_preserves_other_hooks() {
426 let content = "#!/bin/sh\nsome-other-tool run\n# retro hook - do not remove\nretro ingest 2>/dev/null &\nanother-command\n";
427 let result = remove_hook_lines(content);
428 assert_eq!(result, "#!/bin/sh\nsome-other-tool run\nanother-command\n");
429 }
430
431 #[test]
432 fn test_remove_hook_lines_no_marker() {
433 let content = "#!/bin/sh\nsome-command\n";
434 let result = remove_hook_lines(content);
435 assert_eq!(result, "#!/bin/sh\nsome-command\n");
436 }
437
438 #[test]
439 fn test_remove_hook_lines_multiple_markers() {
440 let content = "#!/bin/sh\n# retro hook - do not remove\nretro ingest 2>/dev/null &\n# retro hook - do not remove\nretro analyze --auto 2>/dev/null &\n";
441 let result = remove_hook_lines(content);
442 assert_eq!(result, "#!/bin/sh\n");
443 }
444
445 #[test]
446 fn test_install_hooks_only_post_commit() {
447 let dir = tempfile::tempdir().unwrap();
448 let hooks_dir = dir.path().join(".git").join("hooks");
449 std::fs::create_dir_all(&hooks_dir).unwrap();
450
451 let results = install_hooks(dir.path().to_str().unwrap()).unwrap();
452
453 assert_eq!(results.len(), 1);
454 assert_eq!(results[0].0, "post-commit");
455 assert_eq!(results[0].1, HookInstallResult::Installed);
456
457 let post_commit = std::fs::read_to_string(hooks_dir.join("post-commit")).unwrap();
458 assert!(post_commit.contains("retro ingest --auto"));
459
460 assert!(!hooks_dir.join("post-merge").exists());
462 }
463
464 #[test]
465 fn test_install_hooks_removes_old_post_merge() {
466 let dir = tempfile::tempdir().unwrap();
467 let hooks_dir = dir.path().join(".git").join("hooks");
468 std::fs::create_dir_all(&hooks_dir).unwrap();
469
470 let old_content =
472 "#!/bin/sh\n# retro hook - do not remove\nretro analyze --auto 2>/dev/null &\n";
473 std::fs::write(hooks_dir.join("post-merge"), old_content).unwrap();
474
475 install_hooks(dir.path().to_str().unwrap()).unwrap();
476
477 assert!(!hooks_dir.join("post-merge").exists());
479 }
480
481 #[test]
482 fn test_install_hooks_preserves_non_retro_post_merge() {
483 let dir = tempfile::tempdir().unwrap();
484 let hooks_dir = dir.path().join(".git").join("hooks");
485 std::fs::create_dir_all(&hooks_dir).unwrap();
486
487 let mixed = "#!/bin/sh\nother-tool run\n# retro hook - do not remove\nretro analyze --auto 2>/dev/null &\n";
489 std::fs::write(hooks_dir.join("post-merge"), mixed).unwrap();
490
491 install_hooks(dir.path().to_str().unwrap()).unwrap();
492
493 let content = std::fs::read_to_string(hooks_dir.join("post-merge")).unwrap();
495 assert!(content.contains("other-tool run"));
496 assert!(!content.contains("retro"));
497 }
498
499 #[test]
500 fn test_install_hooks_updates_old_redirect() {
501 let dir = tempfile::tempdir().unwrap();
502 let hooks_dir = dir.path().join(".git").join("hooks");
503 std::fs::create_dir_all(&hooks_dir).unwrap();
504
505 let old_content =
507 "#!/bin/sh\n# retro hook - do not remove\nretro ingest --auto 2>/dev/null &\n";
508 std::fs::write(hooks_dir.join("post-commit"), old_content).unwrap();
509
510 let results = install_hooks(dir.path().to_str().unwrap()).unwrap();
511
512 assert_eq!(results.len(), 1);
513 assert_eq!(results[0].0, "post-commit");
514 assert_eq!(results[0].1, HookInstallResult::Updated);
515
516 let content = std::fs::read_to_string(hooks_dir.join("post-commit")).unwrap();
518 assert!(content.contains("2>>~/.retro/hook-stderr.log"));
519 assert!(!content.contains("2>/dev/null"));
520 }
521
522 #[test]
523 fn test_install_hooks_up_to_date() {
524 let dir = tempfile::tempdir().unwrap();
525 let hooks_dir = dir.path().join(".git").join("hooks");
526 std::fs::create_dir_all(&hooks_dir).unwrap();
527
528 let results = install_hooks(dir.path().to_str().unwrap()).unwrap();
530 assert_eq!(results[0].1, HookInstallResult::Installed);
531
532 let results = install_hooks(dir.path().to_str().unwrap()).unwrap();
534 assert_eq!(results[0].1, HookInstallResult::UpToDate);
535 }
536
537 #[test]
538 fn test_install_hooks_updates_preserves_other_hooks() {
539 let dir = tempfile::tempdir().unwrap();
540 let hooks_dir = dir.path().join(".git").join("hooks");
541 std::fs::create_dir_all(&hooks_dir).unwrap();
542
543 let old_content = "#!/bin/sh\nother-tool run\n# retro hook - do not remove\nretro ingest --auto 2>/dev/null &\n";
545 std::fs::write(hooks_dir.join("post-commit"), old_content).unwrap();
546
547 let results = install_hooks(dir.path().to_str().unwrap()).unwrap();
548
549 assert_eq!(results[0].1, HookInstallResult::Updated);
550
551 let content = std::fs::read_to_string(hooks_dir.join("post-commit")).unwrap();
552 assert!(content.contains("other-tool run"));
553 assert!(content.contains("2>>~/.retro/hook-stderr.log"));
554 assert!(!content.contains("2>/dev/null"));
555 }
556}