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 repository root directory.
30pub 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
43/// Get the current git branch name.
44pub 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
57/// Create and checkout a new git branch from a specific start point.
58/// Use `start_point` like `"origin/main"` to branch from the remote default branch.
59pub 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
78/// Detect the repository's default branch name via `gh`.
79pub 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
97/// Fetch a specific branch from origin.
98pub 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
112/// Stash uncommitted changes. Returns true if something was stashed.
113pub 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    // "No local changes to save" means nothing was stashed
126    Ok(!stdout.contains("No local changes"))
127}
128
129/// Pop the most recent stash entry.
130pub 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
144/// Push the current branch to origin.
145pub 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
159/// Switch back to a branch.
160pub 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
174/// Stage specific files and commit.
175/// Retries once on commit failure to handle pre-commit hooks that auto-fix files
176/// (e.g., end-of-file-fixer, trailing-whitespace) — these hooks modify files and
177/// return exit code 1, expecting a re-stage + re-commit.
178pub 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    // Pre-commit hooks may have auto-fixed files. Re-stage and retry once.
191    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
223/// Create a PR using `gh pr create`. Returns the PR URL on success.
224/// `base` specifies the target branch for the PR (e.g., "main").
225pub 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
239/// Create a retro PR: chdir → stash → branch from default → write files → commit → push → PR → restore.
240/// Returns the PR URL on success, or None if PR creation was skipped.
241pub 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    // Always restore original directory
256    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    // All operations from here must restore state on error
284    let result = do_pr_work(project_path, files, commit_message, pr_title, pr_body, &default);
285
286    // Always restore original branch and stash
287    let _ = checkout_branch(&original_branch);
288    if stashed {
289        let _ = stash_pop();
290    }
291
292    result
293}
294
295/// Helper that performs the file write → commit → push → PR work.
296/// Separated so that `create_retro_pr_inner` can always restore branch/stash on error.
297fn 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    // Write files
306    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    // Commit
316    let file_paths: Vec<&str> = files.iter().map(|(p, _)| *p).collect();
317    commit_files(&file_paths, commit_message)?;
318
319    // Push + PR
320    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
337/// Check the state of a PR by its URL. Returns "OPEN", "CLOSED", or "MERGED".
338pub 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/// Result of installing hook lines into a file.
353#[derive(Debug, PartialEq)]
354pub enum HookInstallResult {
355    /// Hook was freshly installed (no retro marker existed before).
356    Installed,
357    /// Hook was updated (old retro lines replaced with new ones).
358    Updated,
359    /// Hook already had the exact same lines — no change needed.
360    UpToDate,
361}
362
363/// Install retro git hooks (post-commit only) into the repository.
364/// Also cleans up old post-merge hooks that were retro-managed.
365pub 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    // Single post-commit hook: ingest + opportunistic analyze/apply
370    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    // Remove old post-merge hook if it was retro-managed
376    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
392/// Install hook lines into a hook file.
393/// If retro lines already exist, removes them first and re-adds the new lines.
394/// Returns the install result (Installed, Updated, or UpToDate).
395fn 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        // Check if the existing lines are already exactly what we want
405        if existing.contains(lines.trim()) {
406            return Ok(HookInstallResult::UpToDate);
407        }
408        // Remove old retro lines so we can add the new ones
409        (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    // Make executable
430    #[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
445/// Remove retro hook lines from git hooks in the given repository.
446/// Returns the list of hooks that were modified.
447pub 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        // If only the shebang remains (or empty), remove the file
471        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
486/// Remove retro hook lines from hook content.
487/// Removes the marker line and the command line immediately after it.
488fn 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        // post-merge should NOT exist
559        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        // Simulate old retro post-merge hook
569        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        // post-merge should be removed (was retro-only)
576        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        // post-merge with retro + other content
586        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        // post-merge should still exist with other-tool preserved
592        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        // Simulate old hook with 2>/dev/null redirect
604        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        // Verify new redirect is in place
615        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        // First install
627        let results = install_hooks(dir.path().to_str().unwrap()).unwrap();
628        assert_eq!(results[0].1, HookInstallResult::Installed);
629
630        // Second install — should be up to date
631        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        // Simulate old hook with other tool + old retro redirect
642        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}