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 remote_url() -> Option<String> {
31 let output = Command::new("git")
32 .args(["remote", "get-url", "origin"])
33 .output()
34 .ok()?;
35 if output.status.success() {
36 let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
37 if url.is_empty() { None } else { Some(url) }
38 } else {
39 None
40 }
41}
42
43pub fn git_root() -> Result<String, CoreError> {
45 let output = Command::new("git")
46 .args(["rev-parse", "--show-toplevel"])
47 .output()
48 .map_err(|e| CoreError::Io(format!("running git: {e}")))?;
49
50 if !output.status.success() {
51 return Err(CoreError::Io("not inside a git repository".to_string()));
52 }
53
54 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
55}
56
57pub fn current_branch() -> Result<String, CoreError> {
59 let output = Command::new("git")
60 .args(["rev-parse", "--abbrev-ref", "HEAD"])
61 .output()
62 .map_err(|e| CoreError::Io(format!("getting current branch: {e}")))?;
63
64 if !output.status.success() {
65 return Err(CoreError::Io("failed to get current branch".to_string()));
66 }
67
68 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
69}
70
71pub fn create_branch(name: &str, start_point: Option<&str>) -> Result<(), CoreError> {
74 let mut args = vec!["checkout", "-b", name];
75 if let Some(sp) = start_point {
76 args.push(sp);
77 }
78
79 let output = Command::new("git")
80 .args(&args)
81 .output()
82 .map_err(|e| CoreError::Io(format!("creating branch: {e}")))?;
83
84 if !output.status.success() {
85 let stderr = String::from_utf8_lossy(&output.stderr);
86 return Err(CoreError::Io(format!("git checkout -b failed: {stderr}")));
87 }
88
89 Ok(())
90}
91
92pub fn default_branch() -> Result<String, CoreError> {
94 let output = Command::new("gh")
95 .args(["repo", "view", "--json", "defaultBranchRef", "-q", ".defaultBranchRef.name"])
96 .output()
97 .map_err(|e| CoreError::Io(format!("gh repo view: {e}")))?;
98
99 if !output.status.success() {
100 let stderr = String::from_utf8_lossy(&output.stderr);
101 return Err(CoreError::Io(format!("failed to detect default branch: {stderr}")));
102 }
103
104 let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
105 if name.is_empty() {
106 return Err(CoreError::Io("default branch name is empty".to_string()));
107 }
108 Ok(name)
109}
110
111pub fn fetch_branch(branch: &str) -> Result<(), CoreError> {
113 let output = Command::new("git")
114 .args(["fetch", "origin", branch])
115 .output()
116 .map_err(|e| CoreError::Io(format!("git fetch: {e}")))?;
117
118 if !output.status.success() {
119 let stderr = String::from_utf8_lossy(&output.stderr);
120 return Err(CoreError::Io(format!("git fetch origin {branch} failed: {stderr}")));
121 }
122
123 Ok(())
124}
125
126pub fn stash_push() -> Result<bool, CoreError> {
128 let output = Command::new("git")
129 .args(["stash", "push", "-m", "retro: temporary stash for branch switch"])
130 .output()
131 .map_err(|e| CoreError::Io(format!("git stash: {e}")))?;
132
133 if !output.status.success() {
134 let stderr = String::from_utf8_lossy(&output.stderr);
135 return Err(CoreError::Io(format!("git stash failed: {stderr}")));
136 }
137
138 let stdout = String::from_utf8_lossy(&output.stdout);
139 Ok(!stdout.contains("No local changes"))
141}
142
143pub fn stash_pop() -> Result<(), CoreError> {
145 let output = Command::new("git")
146 .args(["stash", "pop"])
147 .output()
148 .map_err(|e| CoreError::Io(format!("git stash pop: {e}")))?;
149
150 if !output.status.success() {
151 let stderr = String::from_utf8_lossy(&output.stderr);
152 return Err(CoreError::Io(format!("git stash pop failed: {stderr}")));
153 }
154
155 Ok(())
156}
157
158pub fn push_current_branch() -> Result<(), CoreError> {
160 let output = Command::new("git")
161 .args(["push", "-u", "origin", "HEAD"])
162 .output()
163 .map_err(|e| CoreError::Io(format!("git push: {e}")))?;
164
165 if !output.status.success() {
166 let stderr = String::from_utf8_lossy(&output.stderr);
167 return Err(CoreError::Io(format!("git push failed: {stderr}")));
168 }
169
170 Ok(())
171}
172
173pub fn checkout_branch(name: &str) -> Result<(), CoreError> {
175 let output = Command::new("git")
176 .args(["checkout", name])
177 .output()
178 .map_err(|e| CoreError::Io(format!("checking out branch: {e}")))?;
179
180 if !output.status.success() {
181 let stderr = String::from_utf8_lossy(&output.stderr);
182 return Err(CoreError::Io(format!("git checkout failed: {stderr}")));
183 }
184
185 Ok(())
186}
187
188pub fn commit_files(files: &[&str], message: &str) -> Result<(), CoreError> {
193 stage_files(files)?;
194
195 let output = Command::new("git")
196 .args(["commit", "-m", message])
197 .output()
198 .map_err(|e| CoreError::Io(format!("git commit: {e}")))?;
199
200 if output.status.success() {
201 return Ok(());
202 }
203
204 stage_files(files)?;
206
207 let output = Command::new("git")
208 .args(["commit", "-m", message])
209 .output()
210 .map_err(|e| CoreError::Io(format!("git commit (retry): {e}")))?;
211
212 if !output.status.success() {
213 let stderr = String::from_utf8_lossy(&output.stderr);
214 return Err(CoreError::Io(format!("git commit failed: {stderr}")));
215 }
216
217 Ok(())
218}
219
220fn stage_files(files: &[&str]) -> Result<(), CoreError> {
221 let mut args = vec!["add", "--"];
222 args.extend(files);
223
224 let output = Command::new("git")
225 .args(&args)
226 .output()
227 .map_err(|e| CoreError::Io(format!("git add: {e}")))?;
228
229 if !output.status.success() {
230 let stderr = String::from_utf8_lossy(&output.stderr);
231 return Err(CoreError::Io(format!("git add failed: {stderr}")));
232 }
233
234 Ok(())
235}
236
237pub fn create_pr(title: &str, body: &str, base: &str) -> Result<String, CoreError> {
240 let output = Command::new("gh")
241 .args(["pr", "create", "--title", title, "--body", body, "--base", base])
242 .output()
243 .map_err(|e| CoreError::Io(format!("gh pr create: {e}")))?;
244
245 if !output.status.success() {
246 let stderr = String::from_utf8_lossy(&output.stderr);
247 return Err(CoreError::Io(format!("gh pr create failed: {stderr}")));
248 }
249
250 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
251}
252
253pub fn create_retro_pr(
256 project_path: &str,
257 files: &[(&str, &str)],
258 commit_message: &str,
259 pr_title: &str,
260 pr_body: &str,
261) -> Result<Option<String>, CoreError> {
262 let original_dir = std::env::current_dir()
263 .map_err(|e| CoreError::Io(format!("getting cwd: {e}")))?;
264 std::env::set_current_dir(project_path)
265 .map_err(|e| CoreError::Io(format!("changing to {project_path}: {e}")))?;
266
267 let result = create_retro_pr_inner(project_path, files, commit_message, pr_title, pr_body);
268
269 let _ = std::env::set_current_dir(&original_dir);
271
272 result
273}
274
275fn create_retro_pr_inner(
276 project_path: &str,
277 files: &[(&str, &str)],
278 commit_message: &str,
279 pr_title: &str,
280 pr_body: &str,
281) -> Result<Option<String>, CoreError> {
282 let original_branch = current_branch()?;
283 let default = default_branch()?;
284 let _ = fetch_branch(&default);
285
286 let stashed = stash_push()?;
287
288 let timestamp = chrono::Utc::now().format("%Y%m%d-%H%M%S");
289 let branch_name = format!("retro/updates-{timestamp}");
290 if let Err(e) = create_branch(&branch_name, Some(&format!("origin/{default}"))) {
291 if stashed {
292 let _ = stash_pop();
293 }
294 return Err(e);
295 }
296
297 let result = do_pr_work(project_path, files, commit_message, pr_title, pr_body, &default);
299
300 let _ = checkout_branch(&original_branch);
302 if stashed {
303 let _ = stash_pop();
304 }
305
306 result
307}
308
309fn do_pr_work(
312 project_path: &str,
313 files: &[(&str, &str)],
314 commit_message: &str,
315 pr_title: &str,
316 pr_body: &str,
317 default: &str,
318) -> Result<Option<String>, CoreError> {
319 for (path, content) in files {
321 let full_path = std::path::Path::new(project_path).join(path);
322 if let Some(parent) = full_path.parent() {
323 let _ = std::fs::create_dir_all(parent);
324 }
325 std::fs::write(&full_path, content)
326 .map_err(|e| CoreError::Io(format!("writing {path}: {e}")))?;
327 }
328
329 let file_paths: Vec<&str> = files.iter().map(|(p, _)| *p).collect();
331 commit_files(&file_paths, commit_message)?;
332
333 let pr_url = match push_current_branch() {
335 Ok(()) => {
336 if is_gh_available() {
337 match create_pr(pr_title, pr_body, default) {
338 Ok(url) => Some(url),
339 Err(_) => None,
340 }
341 } else {
342 None
343 }
344 }
345 Err(_) => None,
346 };
347
348 Ok(pr_url)
349}
350
351pub fn pr_state(pr_url: &str) -> Result<String, CoreError> {
353 let output = Command::new("gh")
354 .args(["pr", "view", pr_url, "--json", "state", "-q", ".state"])
355 .output()
356 .map_err(|e| CoreError::Io(format!("gh pr view: {e}")))?;
357
358 if !output.status.success() {
359 let stderr = String::from_utf8_lossy(&output.stderr);
360 return Err(CoreError::Io(format!("gh pr view failed: {stderr}")));
361 }
362
363 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
364}
365
366#[derive(Debug, PartialEq)]
368pub enum HookInstallResult {
369 Installed,
371 Updated,
373 UpToDate,
375}
376
377pub fn install_hooks(repo_root: &str) -> Result<Vec<(String, HookInstallResult)>, CoreError> {
380 let hooks_dir = Path::new(repo_root).join(".git").join("hooks");
381 let mut results = Vec::new();
382
383 let post_commit_path = hooks_dir.join("post-commit");
385 let hook_lines = format!("{HOOK_MARKER}\nretro ingest --auto 2>>~/.retro/hook-stderr.log &\n");
386 let result = install_hook_lines(&post_commit_path, &hook_lines)?;
387 results.push(("post-commit".to_string(), result));
388
389 let post_merge_path = hooks_dir.join("post-merge");
391 if post_merge_path.exists()
392 && let Ok(content) = std::fs::read_to_string(&post_merge_path)
393 && content.contains(HOOK_MARKER)
394 {
395 let cleaned = remove_hook_lines(&content);
396 if cleaned.trim() == "#!/bin/sh" || cleaned.trim().is_empty() {
397 std::fs::remove_file(&post_merge_path).ok();
398 } else {
399 std::fs::write(&post_merge_path, cleaned).ok();
400 }
401 }
402
403 Ok(results)
404}
405
406fn install_hook_lines(hook_path: &Path, lines: &str) -> Result<HookInstallResult, CoreError> {
410 let existing = if hook_path.exists() {
411 std::fs::read_to_string(hook_path)
412 .map_err(|e| CoreError::Io(format!("reading hook {}: {e}", hook_path.display())))?
413 } else {
414 String::new()
415 };
416
417 let (base_content, was_present) = if existing.contains(HOOK_MARKER) {
418 if existing.contains(lines.trim()) {
420 return Ok(HookInstallResult::UpToDate);
421 }
422 (remove_hook_lines(&existing), true)
424 } else {
425 (existing, false)
426 };
427
428 let mut content = if base_content.is_empty() {
429 "#!/bin/sh\n".to_string()
430 } else {
431 let mut s = base_content;
432 if !s.ends_with('\n') {
433 s.push('\n');
434 }
435 s
436 };
437
438 content.push_str(lines);
439
440 std::fs::write(hook_path, &content)
441 .map_err(|e| CoreError::Io(format!("writing hook {}: {e}", hook_path.display())))?;
442
443 #[cfg(unix)]
445 {
446 use std::os::unix::fs::PermissionsExt;
447 let perms = std::fs::Permissions::from_mode(0o755);
448 std::fs::set_permissions(hook_path, perms)
449 .map_err(|e| CoreError::Io(format!("chmod hook: {e}")))?;
450 }
451
452 Ok(if was_present {
453 HookInstallResult::Updated
454 } else {
455 HookInstallResult::Installed
456 })
457}
458
459pub fn remove_hooks(repo_root: &str) -> Result<Vec<String>, CoreError> {
462 let hooks_dir = Path::new(repo_root).join(".git").join("hooks");
463 if !hooks_dir.exists() {
464 return Ok(Vec::new());
465 }
466
467 let mut modified = Vec::new();
468
469 for hook_name in &["post-commit", "post-merge"] {
470 let hook_path = hooks_dir.join(hook_name);
471 if !hook_path.exists() {
472 continue;
473 }
474
475 let content = std::fs::read_to_string(&hook_path)
476 .map_err(|e| CoreError::Io(format!("reading hook: {e}")))?;
477
478 if !content.contains(HOOK_MARKER) {
479 continue;
480 }
481
482 let cleaned = remove_hook_lines(&content);
483
484 let trimmed = cleaned.trim();
486 if trimmed.is_empty() || trimmed == "#!/bin/sh" || trimmed == "#!/bin/bash" {
487 std::fs::remove_file(&hook_path)
488 .map_err(|e| CoreError::Io(format!("removing hook file: {e}")))?;
489 } else {
490 std::fs::write(&hook_path, &cleaned)
491 .map_err(|e| CoreError::Io(format!("writing cleaned hook: {e}")))?;
492 }
493
494 modified.push(hook_name.to_string());
495 }
496
497 Ok(modified)
498}
499
500fn remove_hook_lines(content: &str) -> String {
503 let mut result = Vec::new();
504 let mut skip_next = false;
505
506 for line in content.lines() {
507 if skip_next {
508 skip_next = false;
509 continue;
510 }
511 if line.trim() == HOOK_MARKER {
512 skip_next = true;
513 continue;
514 }
515 result.push(line);
516 }
517
518 let mut output = result.join("\n");
519 if !output.is_empty() && content.ends_with('\n') {
520 output.push('\n');
521 }
522 output
523}
524
525#[cfg(test)]
526mod tests {
527 use super::*;
528
529 #[test]
530 fn test_remove_hook_lines_basic() {
531 let content = "#!/bin/sh\n# retro hook - do not remove\nretro ingest 2>/dev/null &\n";
532 let result = remove_hook_lines(content);
533 assert_eq!(result, "#!/bin/sh\n");
534 }
535
536 #[test]
537 fn test_remove_hook_lines_preserves_other_hooks() {
538 let content = "#!/bin/sh\nsome-other-tool run\n# retro hook - do not remove\nretro ingest 2>/dev/null &\nanother-command\n";
539 let result = remove_hook_lines(content);
540 assert_eq!(result, "#!/bin/sh\nsome-other-tool run\nanother-command\n");
541 }
542
543 #[test]
544 fn test_remove_hook_lines_no_marker() {
545 let content = "#!/bin/sh\nsome-command\n";
546 let result = remove_hook_lines(content);
547 assert_eq!(result, "#!/bin/sh\nsome-command\n");
548 }
549
550 #[test]
551 fn test_remove_hook_lines_multiple_markers() {
552 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";
553 let result = remove_hook_lines(content);
554 assert_eq!(result, "#!/bin/sh\n");
555 }
556
557 #[test]
558 fn test_install_hooks_only_post_commit() {
559 let dir = tempfile::tempdir().unwrap();
560 let hooks_dir = dir.path().join(".git").join("hooks");
561 std::fs::create_dir_all(&hooks_dir).unwrap();
562
563 let results = install_hooks(dir.path().to_str().unwrap()).unwrap();
564
565 assert_eq!(results.len(), 1);
566 assert_eq!(results[0].0, "post-commit");
567 assert_eq!(results[0].1, HookInstallResult::Installed);
568
569 let post_commit = std::fs::read_to_string(hooks_dir.join("post-commit")).unwrap();
570 assert!(post_commit.contains("retro ingest --auto"));
571
572 assert!(!hooks_dir.join("post-merge").exists());
574 }
575
576 #[test]
577 fn test_install_hooks_removes_old_post_merge() {
578 let dir = tempfile::tempdir().unwrap();
579 let hooks_dir = dir.path().join(".git").join("hooks");
580 std::fs::create_dir_all(&hooks_dir).unwrap();
581
582 let old_content =
584 "#!/bin/sh\n# retro hook - do not remove\nretro analyze --auto 2>/dev/null &\n";
585 std::fs::write(hooks_dir.join("post-merge"), old_content).unwrap();
586
587 install_hooks(dir.path().to_str().unwrap()).unwrap();
588
589 assert!(!hooks_dir.join("post-merge").exists());
591 }
592
593 #[test]
594 fn test_install_hooks_preserves_non_retro_post_merge() {
595 let dir = tempfile::tempdir().unwrap();
596 let hooks_dir = dir.path().join(".git").join("hooks");
597 std::fs::create_dir_all(&hooks_dir).unwrap();
598
599 let mixed = "#!/bin/sh\nother-tool run\n# retro hook - do not remove\nretro analyze --auto 2>/dev/null &\n";
601 std::fs::write(hooks_dir.join("post-merge"), mixed).unwrap();
602
603 install_hooks(dir.path().to_str().unwrap()).unwrap();
604
605 let content = std::fs::read_to_string(hooks_dir.join("post-merge")).unwrap();
607 assert!(content.contains("other-tool run"));
608 assert!(!content.contains("retro"));
609 }
610
611 #[test]
612 fn test_install_hooks_updates_old_redirect() {
613 let dir = tempfile::tempdir().unwrap();
614 let hooks_dir = dir.path().join(".git").join("hooks");
615 std::fs::create_dir_all(&hooks_dir).unwrap();
616
617 let old_content =
619 "#!/bin/sh\n# retro hook - do not remove\nretro ingest --auto 2>/dev/null &\n";
620 std::fs::write(hooks_dir.join("post-commit"), old_content).unwrap();
621
622 let results = install_hooks(dir.path().to_str().unwrap()).unwrap();
623
624 assert_eq!(results.len(), 1);
625 assert_eq!(results[0].0, "post-commit");
626 assert_eq!(results[0].1, HookInstallResult::Updated);
627
628 let content = std::fs::read_to_string(hooks_dir.join("post-commit")).unwrap();
630 assert!(content.contains("2>>~/.retro/hook-stderr.log"));
631 assert!(!content.contains("2>/dev/null"));
632 }
633
634 #[test]
635 fn test_install_hooks_up_to_date() {
636 let dir = tempfile::tempdir().unwrap();
637 let hooks_dir = dir.path().join(".git").join("hooks");
638 std::fs::create_dir_all(&hooks_dir).unwrap();
639
640 let results = install_hooks(dir.path().to_str().unwrap()).unwrap();
642 assert_eq!(results[0].1, HookInstallResult::Installed);
643
644 let results = install_hooks(dir.path().to_str().unwrap()).unwrap();
646 assert_eq!(results[0].1, HookInstallResult::UpToDate);
647 }
648
649 #[test]
650 fn test_install_hooks_updates_preserves_other_hooks() {
651 let dir = tempfile::tempdir().unwrap();
652 let hooks_dir = dir.path().join(".git").join("hooks");
653 std::fs::create_dir_all(&hooks_dir).unwrap();
654
655 let old_content = "#!/bin/sh\nother-tool run\n# retro hook - do not remove\nretro ingest --auto 2>/dev/null &\n";
657 std::fs::write(hooks_dir.join("post-commit"), old_content).unwrap();
658
659 let results = install_hooks(dir.path().to_str().unwrap()).unwrap();
660
661 assert_eq!(results[0].1, HookInstallResult::Updated);
662
663 let content = std::fs::read_to_string(hooks_dir.join("post-commit")).unwrap();
664 assert!(content.contains("other-tool run"));
665 assert!(content.contains("2>>~/.retro/hook-stderr.log"));
666 assert!(!content.contains("2>/dev/null"));
667 }
668}