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 create_retro_pr(
242 project_path: &str,
243 files: &[(&str, &str)],
244 commit_message: &str,
245 pr_title: &str,
246 pr_body: &str,
247) -> Result<Option<String>, CoreError> {
248 let original_dir = std::env::current_dir()
249 .map_err(|e| CoreError::Io(format!("getting cwd: {e}")))?;
250 std::env::set_current_dir(project_path)
251 .map_err(|e| CoreError::Io(format!("changing to {project_path}: {e}")))?;
252
253 let result = create_retro_pr_inner(project_path, files, commit_message, pr_title, pr_body);
254
255 let _ = std::env::set_current_dir(&original_dir);
257
258 result
259}
260
261fn create_retro_pr_inner(
262 project_path: &str,
263 files: &[(&str, &str)],
264 commit_message: &str,
265 pr_title: &str,
266 pr_body: &str,
267) -> Result<Option<String>, CoreError> {
268 let original_branch = current_branch()?;
269 let default = default_branch()?;
270 let _ = fetch_branch(&default);
271
272 let stashed = stash_push()?;
273
274 let timestamp = chrono::Utc::now().format("%Y%m%d-%H%M%S");
275 let branch_name = format!("retro/updates-{timestamp}");
276 if let Err(e) = create_branch(&branch_name, Some(&format!("origin/{default}"))) {
277 if stashed {
278 let _ = stash_pop();
279 }
280 return Err(e);
281 }
282
283 let result = do_pr_work(project_path, files, commit_message, pr_title, pr_body, &default);
285
286 let _ = checkout_branch(&original_branch);
288 if stashed {
289 let _ = stash_pop();
290 }
291
292 result
293}
294
295fn do_pr_work(
298 project_path: &str,
299 files: &[(&str, &str)],
300 commit_message: &str,
301 pr_title: &str,
302 pr_body: &str,
303 default: &str,
304) -> Result<Option<String>, CoreError> {
305 for (path, content) in files {
307 let full_path = std::path::Path::new(project_path).join(path);
308 if let Some(parent) = full_path.parent() {
309 let _ = std::fs::create_dir_all(parent);
310 }
311 std::fs::write(&full_path, content)
312 .map_err(|e| CoreError::Io(format!("writing {path}: {e}")))?;
313 }
314
315 let file_paths: Vec<&str> = files.iter().map(|(p, _)| *p).collect();
317 commit_files(&file_paths, commit_message)?;
318
319 let pr_url = match push_current_branch() {
321 Ok(()) => {
322 if is_gh_available() {
323 match create_pr(pr_title, pr_body, default) {
324 Ok(url) => Some(url),
325 Err(_) => None,
326 }
327 } else {
328 None
329 }
330 }
331 Err(_) => None,
332 };
333
334 Ok(pr_url)
335}
336
337pub fn pr_state(pr_url: &str) -> Result<String, CoreError> {
339 let output = Command::new("gh")
340 .args(["pr", "view", pr_url, "--json", "state", "-q", ".state"])
341 .output()
342 .map_err(|e| CoreError::Io(format!("gh pr view: {e}")))?;
343
344 if !output.status.success() {
345 let stderr = String::from_utf8_lossy(&output.stderr);
346 return Err(CoreError::Io(format!("gh pr view failed: {stderr}")));
347 }
348
349 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
350}
351
352#[derive(Debug, PartialEq)]
354pub enum HookInstallResult {
355 Installed,
357 Updated,
359 UpToDate,
361}
362
363pub fn install_hooks(repo_root: &str) -> Result<Vec<(String, HookInstallResult)>, CoreError> {
366 let hooks_dir = Path::new(repo_root).join(".git").join("hooks");
367 let mut results = Vec::new();
368
369 let post_commit_path = hooks_dir.join("post-commit");
371 let hook_lines = format!("{HOOK_MARKER}\nretro ingest --auto 2>>~/.retro/hook-stderr.log &\n");
372 let result = install_hook_lines(&post_commit_path, &hook_lines)?;
373 results.push(("post-commit".to_string(), result));
374
375 let post_merge_path = hooks_dir.join("post-merge");
377 if post_merge_path.exists()
378 && let Ok(content) = std::fs::read_to_string(&post_merge_path)
379 && content.contains(HOOK_MARKER)
380 {
381 let cleaned = remove_hook_lines(&content);
382 if cleaned.trim() == "#!/bin/sh" || cleaned.trim().is_empty() {
383 std::fs::remove_file(&post_merge_path).ok();
384 } else {
385 std::fs::write(&post_merge_path, cleaned).ok();
386 }
387 }
388
389 Ok(results)
390}
391
392fn install_hook_lines(hook_path: &Path, lines: &str) -> Result<HookInstallResult, CoreError> {
396 let existing = if hook_path.exists() {
397 std::fs::read_to_string(hook_path)
398 .map_err(|e| CoreError::Io(format!("reading hook {}: {e}", hook_path.display())))?
399 } else {
400 String::new()
401 };
402
403 let (base_content, was_present) = if existing.contains(HOOK_MARKER) {
404 if existing.contains(lines.trim()) {
406 return Ok(HookInstallResult::UpToDate);
407 }
408 (remove_hook_lines(&existing), true)
410 } else {
411 (existing, false)
412 };
413
414 let mut content = if base_content.is_empty() {
415 "#!/bin/sh\n".to_string()
416 } else {
417 let mut s = base_content;
418 if !s.ends_with('\n') {
419 s.push('\n');
420 }
421 s
422 };
423
424 content.push_str(lines);
425
426 std::fs::write(hook_path, &content)
427 .map_err(|e| CoreError::Io(format!("writing hook {}: {e}", hook_path.display())))?;
428
429 #[cfg(unix)]
431 {
432 use std::os::unix::fs::PermissionsExt;
433 let perms = std::fs::Permissions::from_mode(0o755);
434 std::fs::set_permissions(hook_path, perms)
435 .map_err(|e| CoreError::Io(format!("chmod hook: {e}")))?;
436 }
437
438 Ok(if was_present {
439 HookInstallResult::Updated
440 } else {
441 HookInstallResult::Installed
442 })
443}
444
445pub fn remove_hooks(repo_root: &str) -> Result<Vec<String>, CoreError> {
448 let hooks_dir = Path::new(repo_root).join(".git").join("hooks");
449 if !hooks_dir.exists() {
450 return Ok(Vec::new());
451 }
452
453 let mut modified = Vec::new();
454
455 for hook_name in &["post-commit", "post-merge"] {
456 let hook_path = hooks_dir.join(hook_name);
457 if !hook_path.exists() {
458 continue;
459 }
460
461 let content = std::fs::read_to_string(&hook_path)
462 .map_err(|e| CoreError::Io(format!("reading hook: {e}")))?;
463
464 if !content.contains(HOOK_MARKER) {
465 continue;
466 }
467
468 let cleaned = remove_hook_lines(&content);
469
470 let trimmed = cleaned.trim();
472 if trimmed.is_empty() || trimmed == "#!/bin/sh" || trimmed == "#!/bin/bash" {
473 std::fs::remove_file(&hook_path)
474 .map_err(|e| CoreError::Io(format!("removing hook file: {e}")))?;
475 } else {
476 std::fs::write(&hook_path, &cleaned)
477 .map_err(|e| CoreError::Io(format!("writing cleaned hook: {e}")))?;
478 }
479
480 modified.push(hook_name.to_string());
481 }
482
483 Ok(modified)
484}
485
486fn remove_hook_lines(content: &str) -> String {
489 let mut result = Vec::new();
490 let mut skip_next = false;
491
492 for line in content.lines() {
493 if skip_next {
494 skip_next = false;
495 continue;
496 }
497 if line.trim() == HOOK_MARKER {
498 skip_next = true;
499 continue;
500 }
501 result.push(line);
502 }
503
504 let mut output = result.join("\n");
505 if !output.is_empty() && content.ends_with('\n') {
506 output.push('\n');
507 }
508 output
509}
510
511#[cfg(test)]
512mod tests {
513 use super::*;
514
515 #[test]
516 fn test_remove_hook_lines_basic() {
517 let content = "#!/bin/sh\n# retro hook - do not remove\nretro ingest 2>/dev/null &\n";
518 let result = remove_hook_lines(content);
519 assert_eq!(result, "#!/bin/sh\n");
520 }
521
522 #[test]
523 fn test_remove_hook_lines_preserves_other_hooks() {
524 let content = "#!/bin/sh\nsome-other-tool run\n# retro hook - do not remove\nretro ingest 2>/dev/null &\nanother-command\n";
525 let result = remove_hook_lines(content);
526 assert_eq!(result, "#!/bin/sh\nsome-other-tool run\nanother-command\n");
527 }
528
529 #[test]
530 fn test_remove_hook_lines_no_marker() {
531 let content = "#!/bin/sh\nsome-command\n";
532 let result = remove_hook_lines(content);
533 assert_eq!(result, "#!/bin/sh\nsome-command\n");
534 }
535
536 #[test]
537 fn test_remove_hook_lines_multiple_markers() {
538 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";
539 let result = remove_hook_lines(content);
540 assert_eq!(result, "#!/bin/sh\n");
541 }
542
543 #[test]
544 fn test_install_hooks_only_post_commit() {
545 let dir = tempfile::tempdir().unwrap();
546 let hooks_dir = dir.path().join(".git").join("hooks");
547 std::fs::create_dir_all(&hooks_dir).unwrap();
548
549 let results = install_hooks(dir.path().to_str().unwrap()).unwrap();
550
551 assert_eq!(results.len(), 1);
552 assert_eq!(results[0].0, "post-commit");
553 assert_eq!(results[0].1, HookInstallResult::Installed);
554
555 let post_commit = std::fs::read_to_string(hooks_dir.join("post-commit")).unwrap();
556 assert!(post_commit.contains("retro ingest --auto"));
557
558 assert!(!hooks_dir.join("post-merge").exists());
560 }
561
562 #[test]
563 fn test_install_hooks_removes_old_post_merge() {
564 let dir = tempfile::tempdir().unwrap();
565 let hooks_dir = dir.path().join(".git").join("hooks");
566 std::fs::create_dir_all(&hooks_dir).unwrap();
567
568 let old_content =
570 "#!/bin/sh\n# retro hook - do not remove\nretro analyze --auto 2>/dev/null &\n";
571 std::fs::write(hooks_dir.join("post-merge"), old_content).unwrap();
572
573 install_hooks(dir.path().to_str().unwrap()).unwrap();
574
575 assert!(!hooks_dir.join("post-merge").exists());
577 }
578
579 #[test]
580 fn test_install_hooks_preserves_non_retro_post_merge() {
581 let dir = tempfile::tempdir().unwrap();
582 let hooks_dir = dir.path().join(".git").join("hooks");
583 std::fs::create_dir_all(&hooks_dir).unwrap();
584
585 let mixed = "#!/bin/sh\nother-tool run\n# retro hook - do not remove\nretro analyze --auto 2>/dev/null &\n";
587 std::fs::write(hooks_dir.join("post-merge"), mixed).unwrap();
588
589 install_hooks(dir.path().to_str().unwrap()).unwrap();
590
591 let content = std::fs::read_to_string(hooks_dir.join("post-merge")).unwrap();
593 assert!(content.contains("other-tool run"));
594 assert!(!content.contains("retro"));
595 }
596
597 #[test]
598 fn test_install_hooks_updates_old_redirect() {
599 let dir = tempfile::tempdir().unwrap();
600 let hooks_dir = dir.path().join(".git").join("hooks");
601 std::fs::create_dir_all(&hooks_dir).unwrap();
602
603 let old_content =
605 "#!/bin/sh\n# retro hook - do not remove\nretro ingest --auto 2>/dev/null &\n";
606 std::fs::write(hooks_dir.join("post-commit"), old_content).unwrap();
607
608 let results = install_hooks(dir.path().to_str().unwrap()).unwrap();
609
610 assert_eq!(results.len(), 1);
611 assert_eq!(results[0].0, "post-commit");
612 assert_eq!(results[0].1, HookInstallResult::Updated);
613
614 let content = std::fs::read_to_string(hooks_dir.join("post-commit")).unwrap();
616 assert!(content.contains("2>>~/.retro/hook-stderr.log"));
617 assert!(!content.contains("2>/dev/null"));
618 }
619
620 #[test]
621 fn test_install_hooks_up_to_date() {
622 let dir = tempfile::tempdir().unwrap();
623 let hooks_dir = dir.path().join(".git").join("hooks");
624 std::fs::create_dir_all(&hooks_dir).unwrap();
625
626 let results = install_hooks(dir.path().to_str().unwrap()).unwrap();
628 assert_eq!(results[0].1, HookInstallResult::Installed);
629
630 let results = install_hooks(dir.path().to_str().unwrap()).unwrap();
632 assert_eq!(results[0].1, HookInstallResult::UpToDate);
633 }
634
635 #[test]
636 fn test_install_hooks_updates_preserves_other_hooks() {
637 let dir = tempfile::tempdir().unwrap();
638 let hooks_dir = dir.path().join(".git").join("hooks");
639 std::fs::create_dir_all(&hooks_dir).unwrap();
640
641 let old_content = "#!/bin/sh\nother-tool run\n# retro hook - do not remove\nretro ingest --auto 2>/dev/null &\n";
643 std::fs::write(hooks_dir.join("post-commit"), old_content).unwrap();
644
645 let results = install_hooks(dir.path().to_str().unwrap()).unwrap();
646
647 assert_eq!(results[0].1, HookInstallResult::Updated);
648
649 let content = std::fs::read_to_string(hooks_dir.join("post-commit")).unwrap();
650 assert!(content.contains("other-tool run"));
651 assert!(content.contains("2>>~/.retro/hook-stderr.log"));
652 assert!(!content.contains("2>/dev/null"));
653 }
654}