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/// Check the state of a PR by its URL. Returns "OPEN", "CLOSED", or "MERGED".
240pub 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/// Result of installing hook lines into a file.
255#[derive(Debug, PartialEq)]
256pub enum HookInstallResult {
257    /// Hook was freshly installed (no retro marker existed before).
258    Installed,
259    /// Hook was updated (old retro lines replaced with new ones).
260    Updated,
261    /// Hook already had the exact same lines — no change needed.
262    UpToDate,
263}
264
265/// Install retro git hooks (post-commit only) into the repository.
266/// Also cleans up old post-merge hooks that were retro-managed.
267pub 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    // Single post-commit hook: ingest + opportunistic analyze/apply
272    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    // Remove old post-merge hook if it was retro-managed
278    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
294/// Install hook lines into a hook file.
295/// If retro lines already exist, removes them first and re-adds the new lines.
296/// Returns the install result (Installed, Updated, or UpToDate).
297fn 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        // Check if the existing lines are already exactly what we want
307        if existing.contains(lines.trim()) {
308            return Ok(HookInstallResult::UpToDate);
309        }
310        // Remove old retro lines so we can add the new ones
311        (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    // Make executable
332    #[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
347/// Remove retro hook lines from git hooks in the given repository.
348/// Returns the list of hooks that were modified.
349pub 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        // If only the shebang remains (or empty), remove the file
373        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
388/// Remove retro hook lines from hook content.
389/// Removes the marker line and the command line immediately after it.
390fn 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        // post-merge should NOT exist
461        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        // Simulate old retro post-merge hook
471        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        // post-merge should be removed (was retro-only)
478        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        // post-merge with retro + other content
488        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        // post-merge should still exist with other-tool preserved
494        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        // Simulate old hook with 2>/dev/null redirect
506        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        // Verify new redirect is in place
517        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        // First install
529        let results = install_hooks(dir.path().to_str().unwrap()).unwrap();
530        assert_eq!(results[0].1, HookInstallResult::Installed);
531
532        // Second install — should be up to date
533        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        // Simulate old hook with other tool + old retro redirect
544        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}