Skip to main content

retro_core/
git.rs

1use crate::errors::CoreError;
2use std::path::Path;
3use std::process::Command;
4
5const HOOK_MARKER: &str = "# retro hook - do not remove";
6
7/// Check if we are inside a git repository.
8pub 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
18/// Check if the `gh` CLI is available on PATH.
19pub 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
29/// Get the git remote origin URL, if available.
30pub 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
43/// Get the git repository root directory.
44pub 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
57/// Get the current git branch name.
58pub 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
71/// Create and checkout a new git branch from a specific start point.
72/// Use `start_point` like `"origin/main"` to branch from the remote default branch.
73pub 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
92/// Detect the repository's default branch name via `gh`.
93pub 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
111/// Fetch a specific branch from origin.
112pub 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
126/// Stash uncommitted changes. Returns true if something was stashed.
127pub 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    // "No local changes to save" means nothing was stashed
140    Ok(!stdout.contains("No local changes"))
141}
142
143/// Pop the most recent stash entry.
144pub 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
158/// Push the current branch to origin.
159pub 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
173/// Switch back to a branch.
174pub 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
188/// Stage specific files and commit.
189/// Retries once on commit failure to handle pre-commit hooks that auto-fix files
190/// (e.g., end-of-file-fixer, trailing-whitespace) — these hooks modify files and
191/// return exit code 1, expecting a re-stage + re-commit.
192pub 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    // Pre-commit hooks may have auto-fixed files. Re-stage and retry once.
205    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
237/// Create a PR using `gh pr create`. Returns the PR URL on success.
238/// `base` specifies the target branch for the PR (e.g., "main").
239pub 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
253/// Create a retro PR: chdir → stash → branch from default → write files → commit → push → PR → restore.
254/// Returns the PR URL on success, or None if PR creation was skipped.
255pub 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    // Always restore original directory
270    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    // All operations from here must restore state on error
298    let result = do_pr_work(project_path, files, commit_message, pr_title, pr_body, &default);
299
300    // Always restore original branch and stash
301    let _ = checkout_branch(&original_branch);
302    if stashed {
303        let _ = stash_pop();
304    }
305
306    result
307}
308
309/// Helper that performs the file write → commit → push → PR work.
310/// Separated so that `create_retro_pr_inner` can always restore branch/stash on error.
311fn 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    // Write files
320    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    // Commit
330    let file_paths: Vec<&str> = files.iter().map(|(p, _)| *p).collect();
331    commit_files(&file_paths, commit_message)?;
332
333    // Push + PR
334    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
351/// Check the state of a PR by its URL. Returns "OPEN", "CLOSED", or "MERGED".
352pub 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/// Result of installing hook lines into a file.
367#[derive(Debug, PartialEq)]
368pub enum HookInstallResult {
369    /// Hook was freshly installed (no retro marker existed before).
370    Installed,
371    /// Hook was updated (old retro lines replaced with new ones).
372    Updated,
373    /// Hook already had the exact same lines — no change needed.
374    UpToDate,
375}
376
377/// Install retro git hooks (post-commit only) into the repository.
378/// Also cleans up old post-merge hooks that were retro-managed.
379pub 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    // Single post-commit hook: ingest + opportunistic analyze/apply
384    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    // Remove old post-merge hook if it was retro-managed
390    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
406/// Install hook lines into a hook file.
407/// If retro lines already exist, removes them first and re-adds the new lines.
408/// Returns the install result (Installed, Updated, or UpToDate).
409fn 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        // Check if the existing lines are already exactly what we want
419        if existing.contains(lines.trim()) {
420            return Ok(HookInstallResult::UpToDate);
421        }
422        // Remove old retro lines so we can add the new ones
423        (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    // Make executable
444    #[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
459/// Remove retro hook lines from git hooks in the given repository.
460/// Returns the list of hooks that were modified.
461pub 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        // If only the shebang remains (or empty), remove the file
485        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
500/// Remove retro hook lines from hook content.
501/// Removes the marker line and the command line immediately after it.
502fn 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        // post-merge should NOT exist
573        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        // Simulate old retro post-merge hook
583        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        // post-merge should be removed (was retro-only)
590        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        // post-merge with retro + other content
600        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        // post-merge should still exist with other-tool preserved
606        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        // Simulate old hook with 2>/dev/null redirect
618        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        // Verify new redirect is in place
629        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        // First install
641        let results = install_hooks(dir.path().to_str().unwrap()).unwrap();
642        assert_eq!(results[0].1, HookInstallResult::Installed);
643
644        // Second install — should be up to date
645        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        // Simulate old hook with other tool + old retro redirect
656        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}