Skip to main content

ralph_core/
git_ops.rs

1//! Git operations for Ralph orchestration.
2//!
3//! Provides utilities for git operations like auto-committing uncommitted changes
4//! before merge queue operations, and git state cleanup during landing.
5
6use std::io;
7use std::path::Path;
8use std::process::Command;
9
10/// Result of an auto-commit operation.
11#[derive(Debug, Clone)]
12pub struct AutoCommitResult {
13    /// Whether a commit was made.
14    pub committed: bool,
15
16    /// The commit SHA if a commit was made.
17    pub commit_sha: Option<String>,
18
19    /// Number of files that were staged.
20    pub files_staged: usize,
21}
22
23impl AutoCommitResult {
24    /// Create a result indicating no commit was made.
25    pub fn no_commit() -> Self {
26        Self {
27            committed: false,
28            commit_sha: None,
29            files_staged: 0,
30        }
31    }
32}
33
34/// Errors that can occur during git operations.
35#[derive(Debug, thiserror::Error)]
36pub enum GitOpsError {
37    /// IO error.
38    #[error("IO error: {0}")]
39    Io(#[from] io::Error),
40
41    /// Git command failed.
42    #[error("Git command failed: {0}")]
43    Git(String),
44
45    /// Git config is missing (user.name or user.email not set).
46    #[error("Git config missing: {0}")]
47    ConfigMissing(String),
48}
49
50/// Check if the working directory has uncommitted changes.
51///
52/// Returns true if there are:
53/// - Untracked files (not in .gitignore)
54/// - Staged changes
55/// - Unstaged modifications
56///
57/// # Arguments
58///
59/// * `path` - Path to the git repository (or worktree)
60pub fn has_uncommitted_changes(path: impl AsRef<Path>) -> Result<bool, GitOpsError> {
61    let path = path.as_ref();
62
63    let output = Command::new("git")
64        .args(["status", "--porcelain"])
65        .current_dir(path)
66        .output()?;
67
68    if !output.status.success() {
69        let stderr = String::from_utf8_lossy(&output.stderr);
70        return Err(GitOpsError::Git(stderr.to_string()));
71    }
72
73    let stdout = String::from_utf8_lossy(&output.stdout);
74    Ok(!stdout.trim().is_empty())
75}
76
77/// Auto-commit any uncommitted changes in the repository.
78///
79/// This stages all changes (untracked, staged, unstaged) and creates a commit
80/// with a standardized message. If there are no uncommitted changes, returns
81/// without creating a commit.
82///
83/// # Arguments
84///
85/// * `path` - Path to the git repository (or worktree)
86/// * `loop_id` - The loop ID to include in the commit message
87///
88/// # Returns
89///
90/// Information about what was committed, or an error if the operation failed.
91///
92/// # Commit Message Format
93///
94/// The commit message follows the format:
95/// `chore: auto-commit before merge (loop {loop_id})`
96pub fn auto_commit_changes(
97    path: impl AsRef<Path>,
98    loop_id: &str,
99) -> Result<AutoCommitResult, GitOpsError> {
100    let path = path.as_ref();
101
102    // Check if there are any uncommitted changes
103    if !has_uncommitted_changes(path)? {
104        return Ok(AutoCommitResult::no_commit());
105    }
106
107    // Stage all changes (including untracked files)
108    let output = Command::new("git")
109        .args(["add", "-A"])
110        .current_dir(path)
111        .output()?;
112
113    if !output.status.success() {
114        let stderr = String::from_utf8_lossy(&output.stderr);
115        return Err(GitOpsError::Git(format!(
116            "Failed to stage changes: {}",
117            stderr
118        )));
119    }
120
121    // Count staged files
122    let files_staged = count_staged_files(path)?;
123
124    // If nothing was staged after git add -A, return no commit
125    if files_staged == 0 {
126        return Ok(AutoCommitResult::no_commit());
127    }
128
129    // Create the commit
130    let commit_message = format!("chore: auto-commit before merge (loop {})", loop_id);
131
132    let output = Command::new("git")
133        .args(["commit", "-m", &commit_message])
134        .current_dir(path)
135        .output()?;
136
137    if !output.status.success() {
138        let stderr = String::from_utf8_lossy(&output.stderr);
139
140        // Check if the failure is due to missing git config
141        if stderr.contains("user.email") || stderr.contains("user.name") {
142            return Err(GitOpsError::ConfigMissing(
143                "user.name or user.email not configured".to_string(),
144            ));
145        }
146
147        return Err(GitOpsError::Git(format!("Failed to commit: {}", stderr)));
148    }
149
150    // Get the commit SHA
151    let commit_sha = get_head_sha(path)?;
152
153    Ok(AutoCommitResult {
154        committed: true,
155        commit_sha: Some(commit_sha),
156        files_staged,
157    })
158}
159
160/// Count the number of files staged for commit.
161fn count_staged_files(path: &Path) -> Result<usize, GitOpsError> {
162    let output = Command::new("git")
163        .args(["diff", "--cached", "--name-only"])
164        .current_dir(path)
165        .output()?;
166
167    if !output.status.success() {
168        let stderr = String::from_utf8_lossy(&output.stderr);
169        return Err(GitOpsError::Git(stderr.to_string()));
170    }
171
172    let stdout = String::from_utf8_lossy(&output.stdout);
173    Ok(stdout.lines().filter(|line| !line.is_empty()).count())
174}
175
176/// Get the HEAD commit SHA.
177pub fn get_head_sha(path: impl AsRef<Path>) -> Result<String, GitOpsError> {
178    let path = path.as_ref();
179    let output = Command::new("git")
180        .args(["rev-parse", "HEAD"])
181        .current_dir(path)
182        .output()?;
183
184    if !output.status.success() {
185        let stderr = String::from_utf8_lossy(&output.stderr);
186        return Err(GitOpsError::Git(stderr.to_string()));
187    }
188
189    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
190}
191
192/// Get the current branch name.
193///
194/// Returns the name of the currently checked out branch, or an error if
195/// in detached HEAD state or if git command fails.
196///
197/// # Arguments
198///
199/// * `path` - Path to the git repository (or worktree)
200pub fn get_current_branch(path: impl AsRef<Path>) -> Result<String, GitOpsError> {
201    let path = path.as_ref();
202    let output = Command::new("git")
203        .args(["rev-parse", "--abbrev-ref", "HEAD"])
204        .current_dir(path)
205        .output()?;
206
207    if !output.status.success() {
208        let stderr = String::from_utf8_lossy(&output.stderr);
209        return Err(GitOpsError::Git(stderr.to_string()));
210    }
211
212    let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
213
214    // "HEAD" indicates detached HEAD state
215    if branch == "HEAD" {
216        return Err(GitOpsError::Git("Detached HEAD state".to_string()));
217    }
218
219    Ok(branch)
220}
221
222/// Clear all git stashes in the repository.
223///
224/// Runs `git stash clear` to remove all stash entries. This is useful for
225/// cleaning up temporary state during landing.
226///
227/// # Arguments
228///
229/// * `path` - Path to the git repository (or worktree)
230///
231/// # Returns
232///
233/// The number of stashes that were cleared (0 if none existed).
234pub fn clean_stashes(path: impl AsRef<Path>) -> Result<usize, GitOpsError> {
235    let path = path.as_ref();
236
237    // First, count existing stashes
238    let output = Command::new("git")
239        .args(["stash", "list"])
240        .current_dir(path)
241        .output()?;
242
243    if !output.status.success() {
244        let stderr = String::from_utf8_lossy(&output.stderr);
245        return Err(GitOpsError::Git(stderr.to_string()));
246    }
247
248    let stash_count = String::from_utf8_lossy(&output.stdout)
249        .lines()
250        .filter(|line| !line.is_empty())
251        .count();
252
253    if stash_count == 0 {
254        return Ok(0);
255    }
256
257    // Clear all stashes
258    let output = Command::new("git")
259        .args(["stash", "clear"])
260        .current_dir(path)
261        .output()?;
262
263    if !output.status.success() {
264        let stderr = String::from_utf8_lossy(&output.stderr);
265        return Err(GitOpsError::Git(format!(
266            "Failed to clear stashes: {}",
267            stderr
268        )));
269    }
270
271    Ok(stash_count)
272}
273
274/// Prune stale remote-tracking references.
275///
276/// Runs `git remote prune origin` to remove local references to remote
277/// branches that no longer exist. This is useful for cleaning up git state.
278///
279/// # Arguments
280///
281/// * `path` - Path to the git repository (or worktree)
282pub fn prune_remote_refs(path: impl AsRef<Path>) -> Result<(), GitOpsError> {
283    let path = path.as_ref();
284
285    // Check if 'origin' remote exists before pruning
286    let output = Command::new("git")
287        .args(["remote"])
288        .current_dir(path)
289        .output()?;
290
291    if !output.status.success() {
292        let stderr = String::from_utf8_lossy(&output.stderr);
293        return Err(GitOpsError::Git(stderr.to_string()));
294    }
295
296    let remotes = String::from_utf8_lossy(&output.stdout);
297    if !remotes.lines().any(|r| r.trim() == "origin") {
298        // No origin remote, nothing to prune
299        return Ok(());
300    }
301
302    let output = Command::new("git")
303        .args(["remote", "prune", "origin"])
304        .current_dir(path)
305        .output()?;
306
307    if !output.status.success() {
308        let stderr = String::from_utf8_lossy(&output.stderr);
309        return Err(GitOpsError::Git(format!(
310            "Failed to prune remote refs: {}",
311            stderr
312        )));
313    }
314
315    Ok(())
316}
317
318/// Check if the working tree is clean (no uncommitted changes).
319///
320/// This is the inverse of `has_uncommitted_changes`, provided for semantic clarity.
321///
322/// # Arguments
323///
324/// * `path` - Path to the git repository (or worktree)
325///
326/// # Returns
327///
328/// `true` if the working tree is clean (no changes), `false` if there are uncommitted changes.
329pub fn is_working_tree_clean(path: impl AsRef<Path>) -> Result<bool, GitOpsError> {
330    has_uncommitted_changes(path).map(|has_changes| !has_changes)
331}
332
333/// Get a short summary of the HEAD commit.
334///
335/// Returns a string like "abc1234: commit message subject"
336///
337/// # Arguments
338///
339/// * `path` - Path to the git repository (or worktree)
340pub fn get_commit_summary(path: impl AsRef<Path>) -> Result<String, GitOpsError> {
341    let path = path.as_ref();
342    let output = Command::new("git")
343        .args(["log", "-1", "--format=%h: %s"])
344        .current_dir(path)
345        .output()?;
346
347    if !output.status.success() {
348        let stderr = String::from_utf8_lossy(&output.stderr);
349        return Err(GitOpsError::Git(stderr.to_string()));
350    }
351
352    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
353}
354
355/// Get a list of files that were modified in the most recent commits.
356///
357/// Returns up to `limit` most recently modified files.
358///
359/// # Arguments
360///
361/// * `path` - Path to the git repository (or worktree)
362/// * `limit` - Maximum number of files to return
363pub fn get_recent_files(path: impl AsRef<Path>, limit: usize) -> Result<Vec<String>, GitOpsError> {
364    let path = path.as_ref();
365
366    // Try different ranges based on available commits
367    // Start with HEAD~5..HEAD, fall back to smaller ranges
368    let ranges = ["HEAD~5..HEAD", "HEAD~2..HEAD", "HEAD~1..HEAD"];
369
370    for range in ranges {
371        let output = Command::new("git")
372            .args(["diff", "--name-only", range, "--"])
373            .current_dir(path)
374            .output()?;
375
376        if output.status.success() {
377            let files = String::from_utf8_lossy(&output.stdout);
378            let file_list: Vec<String> = files
379                .lines()
380                .filter(|line| !line.is_empty())
381                .take(limit)
382                .map(String::from)
383                .collect();
384
385            if !file_list.is_empty() {
386                return Ok(file_list);
387            }
388        }
389    }
390
391    // Fall back to listing all tracked files (for new repos with one commit)
392    let output = Command::new("git")
393        .args(["ls-files", "--"])
394        .current_dir(path)
395        .output()?;
396
397    if !output.status.success() {
398        return Ok(Vec::new());
399    }
400
401    let files: Vec<String> = String::from_utf8_lossy(&output.stdout)
402        .lines()
403        .filter(|line| !line.is_empty())
404        .take(limit)
405        .map(String::from)
406        .collect();
407
408    Ok(files)
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414    use std::fs;
415    use tempfile::TempDir;
416
417    fn init_git_repo(dir: &Path) {
418        Command::new("git")
419            .args(["init", "--initial-branch=main"])
420            .current_dir(dir)
421            .output()
422            .unwrap();
423
424        Command::new("git")
425            .args(["config", "user.email", "test@test.local"])
426            .current_dir(dir)
427            .output()
428            .unwrap();
429
430        Command::new("git")
431            .args(["config", "user.name", "Test User"])
432            .current_dir(dir)
433            .output()
434            .unwrap();
435
436        // Create initial commit
437        fs::write(dir.join("README.md"), "# Test").unwrap();
438        Command::new("git")
439            .args(["add", "README.md"])
440            .current_dir(dir)
441            .output()
442            .unwrap();
443        Command::new("git")
444            .args(["commit", "-m", "Initial commit"])
445            .current_dir(dir)
446            .output()
447            .unwrap();
448    }
449
450    #[test]
451    fn test_has_uncommitted_changes_clean() {
452        let temp = TempDir::new().unwrap();
453        init_git_repo(temp.path());
454
455        assert!(!has_uncommitted_changes(temp.path()).unwrap());
456    }
457
458    #[test]
459    fn test_has_uncommitted_changes_untracked() {
460        let temp = TempDir::new().unwrap();
461        init_git_repo(temp.path());
462
463        fs::write(temp.path().join("new_file.txt"), "content").unwrap();
464
465        assert!(has_uncommitted_changes(temp.path()).unwrap());
466    }
467
468    #[test]
469    fn test_has_uncommitted_changes_staged() {
470        let temp = TempDir::new().unwrap();
471        init_git_repo(temp.path());
472
473        fs::write(temp.path().join("staged.txt"), "content").unwrap();
474        Command::new("git")
475            .args(["add", "staged.txt"])
476            .current_dir(temp.path())
477            .output()
478            .unwrap();
479
480        assert!(has_uncommitted_changes(temp.path()).unwrap());
481    }
482
483    #[test]
484    fn test_has_uncommitted_changes_modified() {
485        let temp = TempDir::new().unwrap();
486        init_git_repo(temp.path());
487
488        fs::write(temp.path().join("README.md"), "# Modified").unwrap();
489
490        assert!(has_uncommitted_changes(temp.path()).unwrap());
491    }
492
493    #[test]
494    fn test_auto_commit_no_changes() {
495        let temp = TempDir::new().unwrap();
496        init_git_repo(temp.path());
497
498        let result = auto_commit_changes(temp.path(), "test-loop").unwrap();
499
500        assert!(!result.committed);
501        assert!(result.commit_sha.is_none());
502        assert_eq!(result.files_staged, 0);
503    }
504
505    #[test]
506    fn test_auto_commit_untracked_files() {
507        let temp = TempDir::new().unwrap();
508        init_git_repo(temp.path());
509
510        fs::write(temp.path().join("feature.txt"), "new feature").unwrap();
511
512        let result = auto_commit_changes(temp.path(), "loop-123").unwrap();
513
514        assert!(result.committed);
515        assert!(result.commit_sha.is_some());
516        assert_eq!(result.files_staged, 1);
517
518        // Verify the commit message
519        let output = Command::new("git")
520            .args(["log", "-1", "--pretty=%s"])
521            .current_dir(temp.path())
522            .output()
523            .unwrap();
524        let message = String::from_utf8_lossy(&output.stdout);
525        assert_eq!(
526            message.trim(),
527            "chore: auto-commit before merge (loop loop-123)"
528        );
529    }
530
531    #[test]
532    fn test_auto_commit_staged_changes() {
533        let temp = TempDir::new().unwrap();
534        init_git_repo(temp.path());
535
536        fs::write(temp.path().join("staged.txt"), "staged content").unwrap();
537        Command::new("git")
538            .args(["add", "staged.txt"])
539            .current_dir(temp.path())
540            .output()
541            .unwrap();
542
543        let result = auto_commit_changes(temp.path(), "loop-456").unwrap();
544
545        assert!(result.committed);
546        assert!(result.commit_sha.is_some());
547        assert_eq!(result.files_staged, 1);
548    }
549
550    #[test]
551    fn test_auto_commit_unstaged_modifications() {
552        let temp = TempDir::new().unwrap();
553        init_git_repo(temp.path());
554
555        fs::write(temp.path().join("README.md"), "# Modified content").unwrap();
556
557        let result = auto_commit_changes(temp.path(), "loop-789").unwrap();
558
559        assert!(result.committed);
560        assert!(result.commit_sha.is_some());
561        assert_eq!(result.files_staged, 1);
562    }
563
564    #[test]
565    fn test_auto_commit_mixed_changes() {
566        let temp = TempDir::new().unwrap();
567        init_git_repo(temp.path());
568
569        // Untracked file
570        fs::write(temp.path().join("new.txt"), "new").unwrap();
571
572        // Staged file
573        fs::write(temp.path().join("staged.txt"), "staged").unwrap();
574        Command::new("git")
575            .args(["add", "staged.txt"])
576            .current_dir(temp.path())
577            .output()
578            .unwrap();
579
580        // Modified tracked file
581        fs::write(temp.path().join("README.md"), "# Modified").unwrap();
582
583        let result = auto_commit_changes(temp.path(), "loop-mixed").unwrap();
584
585        assert!(result.committed);
586        assert!(result.commit_sha.is_some());
587        assert_eq!(result.files_staged, 3);
588    }
589
590    #[test]
591    fn test_auto_commit_working_tree_clean_after() {
592        let temp = TempDir::new().unwrap();
593        init_git_repo(temp.path());
594
595        fs::write(temp.path().join("feature.txt"), "feature").unwrap();
596
597        let result = auto_commit_changes(temp.path(), "loop-clean").unwrap();
598        assert!(result.committed);
599
600        // Working tree should be clean after commit
601        assert!(!has_uncommitted_changes(temp.path()).unwrap());
602    }
603
604    #[test]
605    fn test_auto_commit_returns_correct_sha() {
606        let temp = TempDir::new().unwrap();
607        init_git_repo(temp.path());
608
609        fs::write(temp.path().join("file.txt"), "content").unwrap();
610
611        let result = auto_commit_changes(temp.path(), "loop-sha").unwrap();
612
613        // Verify the returned SHA matches HEAD
614        let head_sha = get_head_sha(temp.path()).unwrap();
615        assert_eq!(result.commit_sha.unwrap(), head_sha);
616    }
617
618    #[test]
619    fn test_auto_commit_only_gitignored_files() {
620        let temp = TempDir::new().unwrap();
621        init_git_repo(temp.path());
622
623        // Add gitignore
624        fs::write(temp.path().join(".gitignore"), "*.log\n").unwrap();
625        Command::new("git")
626            .args(["add", ".gitignore"])
627            .current_dir(temp.path())
628            .output()
629            .unwrap();
630        Command::new("git")
631            .args(["commit", "-m", "Add gitignore"])
632            .current_dir(temp.path())
633            .output()
634            .unwrap();
635
636        // Create only ignored files
637        fs::write(temp.path().join("debug.log"), "log content").unwrap();
638
639        // Should report no uncommitted changes (ignored files don't count)
640        assert!(!has_uncommitted_changes(temp.path()).unwrap());
641
642        let result = auto_commit_changes(temp.path(), "loop-ignored").unwrap();
643        assert!(!result.committed);
644    }
645
646    #[test]
647    fn test_get_current_branch() {
648        let temp = TempDir::new().unwrap();
649        init_git_repo(temp.path());
650
651        let branch = get_current_branch(temp.path()).unwrap();
652        assert_eq!(branch, "main");
653    }
654
655    #[test]
656    fn test_get_current_branch_custom() {
657        let temp = TempDir::new().unwrap();
658        init_git_repo(temp.path());
659
660        // Create and checkout a new branch
661        Command::new("git")
662            .args(["checkout", "-b", "feature-branch"])
663            .current_dir(temp.path())
664            .output()
665            .unwrap();
666
667        let branch = get_current_branch(temp.path()).unwrap();
668        assert_eq!(branch, "feature-branch");
669    }
670
671    #[test]
672    fn test_clean_stashes_empty() {
673        let temp = TempDir::new().unwrap();
674        init_git_repo(temp.path());
675
676        // No stashes initially
677        let cleared = clean_stashes(temp.path()).unwrap();
678        assert_eq!(cleared, 0);
679    }
680
681    #[test]
682    fn test_clean_stashes_with_stash() {
683        let temp = TempDir::new().unwrap();
684        init_git_repo(temp.path());
685
686        // Create a change and stash it
687        fs::write(temp.path().join("README.md"), "# Modified").unwrap();
688        Command::new("git")
689            .args(["stash", "push", "-m", "test stash"])
690            .current_dir(temp.path())
691            .output()
692            .unwrap();
693
694        // Create another change and stash it
695        fs::write(temp.path().join("README.md"), "# Modified again").unwrap();
696        Command::new("git")
697            .args(["stash", "push", "-m", "test stash 2"])
698            .current_dir(temp.path())
699            .output()
700            .unwrap();
701
702        // Clear stashes
703        let cleared = clean_stashes(temp.path()).unwrap();
704        assert_eq!(cleared, 2);
705
706        // Verify stashes are gone
707        let cleared_again = clean_stashes(temp.path()).unwrap();
708        assert_eq!(cleared_again, 0);
709    }
710
711    #[test]
712    fn test_prune_remote_refs_no_origin() {
713        let temp = TempDir::new().unwrap();
714        init_git_repo(temp.path());
715
716        // Should succeed even without origin remote
717        prune_remote_refs(temp.path()).unwrap();
718    }
719
720    #[test]
721    fn test_is_working_tree_clean() {
722        let temp = TempDir::new().unwrap();
723        init_git_repo(temp.path());
724
725        // Clean working tree
726        assert!(is_working_tree_clean(temp.path()).unwrap());
727
728        // Make a change
729        fs::write(temp.path().join("new_file.txt"), "content").unwrap();
730
731        // Now it's dirty
732        assert!(!is_working_tree_clean(temp.path()).unwrap());
733    }
734
735    #[test]
736    fn test_get_commit_summary() {
737        let temp = TempDir::new().unwrap();
738        init_git_repo(temp.path());
739
740        let summary = get_commit_summary(temp.path()).unwrap();
741        assert!(summary.contains("Initial commit"), "Got: {}", summary);
742    }
743
744    #[test]
745    fn test_get_recent_files() {
746        let temp = TempDir::new().unwrap();
747        init_git_repo(temp.path());
748
749        // Create and commit a new file
750        fs::write(temp.path().join("feature.txt"), "content").unwrap();
751        Command::new("git")
752            .args(["add", "feature.txt"])
753            .current_dir(temp.path())
754            .output()
755            .unwrap();
756        Command::new("git")
757            .args(["commit", "-m", "Add feature"])
758            .current_dir(temp.path())
759            .output()
760            .unwrap();
761
762        let files = get_recent_files(temp.path(), 10).unwrap();
763        assert!(
764            files.contains(&"feature.txt".to_string()),
765            "Got: {:?}",
766            files
767        );
768    }
769}