use std::io;
use std::path::Path;
use std::process::Command;
#[derive(Debug, Clone)]
pub struct AutoCommitResult {
pub committed: bool,
pub commit_sha: Option<String>,
pub files_staged: usize,
}
impl AutoCommitResult {
pub fn no_commit() -> Self {
Self {
committed: false,
commit_sha: None,
files_staged: 0,
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum GitOpsError {
#[error("IO error: {0}")]
Io(#[from] io::Error),
#[error("Git command failed: {0}")]
Git(String),
#[error("Git config missing: {0}")]
ConfigMissing(String),
}
pub fn has_uncommitted_changes(path: impl AsRef<Path>) -> Result<bool, GitOpsError> {
let path = path.as_ref();
let output = Command::new("git")
.args(["status", "--porcelain"])
.current_dir(path)
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(GitOpsError::Git(stderr.to_string()));
}
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(!stdout.trim().is_empty())
}
pub fn auto_commit_changes(
path: impl AsRef<Path>,
loop_id: &str,
) -> Result<AutoCommitResult, GitOpsError> {
let path = path.as_ref();
if !has_uncommitted_changes(path)? {
return Ok(AutoCommitResult::no_commit());
}
let output = Command::new("git")
.args(["add", "-A"])
.current_dir(path)
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(GitOpsError::Git(format!(
"Failed to stage changes: {}",
stderr
)));
}
let files_staged = count_staged_files(path)?;
if files_staged == 0 {
return Ok(AutoCommitResult::no_commit());
}
let commit_message = format!("chore: auto-commit before merge (loop {})", loop_id);
let output = Command::new("git")
.args(["commit", "-m", &commit_message])
.current_dir(path)
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("user.email") || stderr.contains("user.name") {
return Err(GitOpsError::ConfigMissing(
"user.name or user.email not configured".to_string(),
));
}
return Err(GitOpsError::Git(format!("Failed to commit: {}", stderr)));
}
let commit_sha = get_head_sha(path)?;
Ok(AutoCommitResult {
committed: true,
commit_sha: Some(commit_sha),
files_staged,
})
}
fn count_staged_files(path: &Path) -> Result<usize, GitOpsError> {
let output = Command::new("git")
.args(["diff", "--cached", "--name-only"])
.current_dir(path)
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(GitOpsError::Git(stderr.to_string()));
}
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(stdout.lines().filter(|line| !line.is_empty()).count())
}
pub fn get_head_sha(path: impl AsRef<Path>) -> Result<String, GitOpsError> {
let path = path.as_ref();
let output = Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(path)
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(GitOpsError::Git(stderr.to_string()));
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
pub fn get_current_branch(path: impl AsRef<Path>) -> Result<String, GitOpsError> {
let path = path.as_ref();
let output = Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.current_dir(path)
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(GitOpsError::Git(stderr.to_string()));
}
let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
if branch == "HEAD" {
return Err(GitOpsError::Git("Detached HEAD state".to_string()));
}
Ok(branch)
}
pub fn clean_stashes(path: impl AsRef<Path>) -> Result<usize, GitOpsError> {
let path = path.as_ref();
let output = Command::new("git")
.args(["stash", "list"])
.current_dir(path)
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(GitOpsError::Git(stderr.to_string()));
}
let stash_count = String::from_utf8_lossy(&output.stdout)
.lines()
.filter(|line| !line.is_empty())
.count();
if stash_count == 0 {
return Ok(0);
}
let output = Command::new("git")
.args(["stash", "clear"])
.current_dir(path)
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(GitOpsError::Git(format!(
"Failed to clear stashes: {}",
stderr
)));
}
Ok(stash_count)
}
pub fn prune_remote_refs(path: impl AsRef<Path>) -> Result<(), GitOpsError> {
let path = path.as_ref();
let output = Command::new("git")
.args(["remote"])
.current_dir(path)
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(GitOpsError::Git(stderr.to_string()));
}
let remotes = String::from_utf8_lossy(&output.stdout);
if !remotes.lines().any(|r| r.trim() == "origin") {
return Ok(());
}
let output = Command::new("git")
.args(["remote", "prune", "origin"])
.current_dir(path)
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(GitOpsError::Git(format!(
"Failed to prune remote refs: {}",
stderr
)));
}
Ok(())
}
pub fn is_working_tree_clean(path: impl AsRef<Path>) -> Result<bool, GitOpsError> {
has_uncommitted_changes(path).map(|has_changes| !has_changes)
}
pub fn get_commit_summary(path: impl AsRef<Path>) -> Result<String, GitOpsError> {
let path = path.as_ref();
let output = Command::new("git")
.args(["log", "-1", "--format=%h: %s"])
.current_dir(path)
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(GitOpsError::Git(stderr.to_string()));
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
pub fn get_recent_files(path: impl AsRef<Path>, limit: usize) -> Result<Vec<String>, GitOpsError> {
let path = path.as_ref();
let ranges = ["HEAD~5..HEAD", "HEAD~2..HEAD", "HEAD~1..HEAD"];
for range in ranges {
let output = Command::new("git")
.args(["diff", "--name-only", range, "--"])
.current_dir(path)
.output()?;
if output.status.success() {
let files = String::from_utf8_lossy(&output.stdout);
let file_list: Vec<String> = files
.lines()
.filter(|line| !line.is_empty())
.take(limit)
.map(String::from)
.collect();
if !file_list.is_empty() {
return Ok(file_list);
}
}
}
let output = Command::new("git")
.args(["ls-files", "--"])
.current_dir(path)
.output()?;
if !output.status.success() {
return Ok(Vec::new());
}
let files: Vec<String> = String::from_utf8_lossy(&output.stdout)
.lines()
.filter(|line| !line.is_empty())
.take(limit)
.map(String::from)
.collect();
Ok(files)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn init_git_repo(dir: &Path) {
Command::new("git")
.args(["init", "--initial-branch=main"])
.current_dir(dir)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.email", "test@test.local"])
.current_dir(dir)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(dir)
.output()
.unwrap();
fs::write(dir.join("README.md"), "# Test").unwrap();
Command::new("git")
.args(["add", "README.md"])
.current_dir(dir)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "Initial commit"])
.current_dir(dir)
.output()
.unwrap();
}
#[test]
fn test_has_uncommitted_changes_clean() {
let temp = TempDir::new().unwrap();
init_git_repo(temp.path());
assert!(!has_uncommitted_changes(temp.path()).unwrap());
}
#[test]
fn test_has_uncommitted_changes_untracked() {
let temp = TempDir::new().unwrap();
init_git_repo(temp.path());
fs::write(temp.path().join("new_file.txt"), "content").unwrap();
assert!(has_uncommitted_changes(temp.path()).unwrap());
}
#[test]
fn test_has_uncommitted_changes_staged() {
let temp = TempDir::new().unwrap();
init_git_repo(temp.path());
fs::write(temp.path().join("staged.txt"), "content").unwrap();
Command::new("git")
.args(["add", "staged.txt"])
.current_dir(temp.path())
.output()
.unwrap();
assert!(has_uncommitted_changes(temp.path()).unwrap());
}
#[test]
fn test_has_uncommitted_changes_modified() {
let temp = TempDir::new().unwrap();
init_git_repo(temp.path());
fs::write(temp.path().join("README.md"), "# Modified").unwrap();
assert!(has_uncommitted_changes(temp.path()).unwrap());
}
#[test]
fn test_auto_commit_no_changes() {
let temp = TempDir::new().unwrap();
init_git_repo(temp.path());
let result = auto_commit_changes(temp.path(), "test-loop").unwrap();
assert!(!result.committed);
assert!(result.commit_sha.is_none());
assert_eq!(result.files_staged, 0);
}
#[test]
fn test_auto_commit_untracked_files() {
let temp = TempDir::new().unwrap();
init_git_repo(temp.path());
fs::write(temp.path().join("feature.txt"), "new feature").unwrap();
let result = auto_commit_changes(temp.path(), "loop-123").unwrap();
assert!(result.committed);
assert!(result.commit_sha.is_some());
assert_eq!(result.files_staged, 1);
let output = Command::new("git")
.args(["log", "-1", "--pretty=%s"])
.current_dir(temp.path())
.output()
.unwrap();
let message = String::from_utf8_lossy(&output.stdout);
assert_eq!(
message.trim(),
"chore: auto-commit before merge (loop loop-123)"
);
}
#[test]
fn test_auto_commit_staged_changes() {
let temp = TempDir::new().unwrap();
init_git_repo(temp.path());
fs::write(temp.path().join("staged.txt"), "staged content").unwrap();
Command::new("git")
.args(["add", "staged.txt"])
.current_dir(temp.path())
.output()
.unwrap();
let result = auto_commit_changes(temp.path(), "loop-456").unwrap();
assert!(result.committed);
assert!(result.commit_sha.is_some());
assert_eq!(result.files_staged, 1);
}
#[test]
fn test_auto_commit_unstaged_modifications() {
let temp = TempDir::new().unwrap();
init_git_repo(temp.path());
fs::write(temp.path().join("README.md"), "# Modified content").unwrap();
let result = auto_commit_changes(temp.path(), "loop-789").unwrap();
assert!(result.committed);
assert!(result.commit_sha.is_some());
assert_eq!(result.files_staged, 1);
}
#[test]
fn test_auto_commit_mixed_changes() {
let temp = TempDir::new().unwrap();
init_git_repo(temp.path());
fs::write(temp.path().join("new.txt"), "new").unwrap();
fs::write(temp.path().join("staged.txt"), "staged").unwrap();
Command::new("git")
.args(["add", "staged.txt"])
.current_dir(temp.path())
.output()
.unwrap();
fs::write(temp.path().join("README.md"), "# Modified").unwrap();
let result = auto_commit_changes(temp.path(), "loop-mixed").unwrap();
assert!(result.committed);
assert!(result.commit_sha.is_some());
assert_eq!(result.files_staged, 3);
}
#[test]
fn test_auto_commit_working_tree_clean_after() {
let temp = TempDir::new().unwrap();
init_git_repo(temp.path());
fs::write(temp.path().join("feature.txt"), "feature").unwrap();
let result = auto_commit_changes(temp.path(), "loop-clean").unwrap();
assert!(result.committed);
assert!(!has_uncommitted_changes(temp.path()).unwrap());
}
#[test]
fn test_auto_commit_returns_correct_sha() {
let temp = TempDir::new().unwrap();
init_git_repo(temp.path());
fs::write(temp.path().join("file.txt"), "content").unwrap();
let result = auto_commit_changes(temp.path(), "loop-sha").unwrap();
let head_sha = get_head_sha(temp.path()).unwrap();
assert_eq!(result.commit_sha.unwrap(), head_sha);
}
#[test]
fn test_auto_commit_only_gitignored_files() {
let temp = TempDir::new().unwrap();
init_git_repo(temp.path());
fs::write(temp.path().join(".gitignore"), "*.log\n").unwrap();
Command::new("git")
.args(["add", ".gitignore"])
.current_dir(temp.path())
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "Add gitignore"])
.current_dir(temp.path())
.output()
.unwrap();
fs::write(temp.path().join("debug.log"), "log content").unwrap();
assert!(!has_uncommitted_changes(temp.path()).unwrap());
let result = auto_commit_changes(temp.path(), "loop-ignored").unwrap();
assert!(!result.committed);
}
#[test]
fn test_get_current_branch() {
let temp = TempDir::new().unwrap();
init_git_repo(temp.path());
let branch = get_current_branch(temp.path()).unwrap();
assert_eq!(branch, "main");
}
#[test]
fn test_get_current_branch_custom() {
let temp = TempDir::new().unwrap();
init_git_repo(temp.path());
Command::new("git")
.args(["checkout", "-b", "feature-branch"])
.current_dir(temp.path())
.output()
.unwrap();
let branch = get_current_branch(temp.path()).unwrap();
assert_eq!(branch, "feature-branch");
}
#[test]
fn test_clean_stashes_empty() {
let temp = TempDir::new().unwrap();
init_git_repo(temp.path());
let cleared = clean_stashes(temp.path()).unwrap();
assert_eq!(cleared, 0);
}
#[test]
fn test_clean_stashes_with_stash() {
let temp = TempDir::new().unwrap();
init_git_repo(temp.path());
fs::write(temp.path().join("README.md"), "# Modified").unwrap();
Command::new("git")
.args(["stash", "push", "-m", "test stash"])
.current_dir(temp.path())
.output()
.unwrap();
fs::write(temp.path().join("README.md"), "# Modified again").unwrap();
Command::new("git")
.args(["stash", "push", "-m", "test stash 2"])
.current_dir(temp.path())
.output()
.unwrap();
let cleared = clean_stashes(temp.path()).unwrap();
assert_eq!(cleared, 2);
let cleared_again = clean_stashes(temp.path()).unwrap();
assert_eq!(cleared_again, 0);
}
#[test]
fn test_prune_remote_refs_no_origin() {
let temp = TempDir::new().unwrap();
init_git_repo(temp.path());
prune_remote_refs(temp.path()).unwrap();
}
#[test]
fn test_is_working_tree_clean() {
let temp = TempDir::new().unwrap();
init_git_repo(temp.path());
assert!(is_working_tree_clean(temp.path()).unwrap());
fs::write(temp.path().join("new_file.txt"), "content").unwrap();
assert!(!is_working_tree_clean(temp.path()).unwrap());
}
#[test]
fn test_get_commit_summary() {
let temp = TempDir::new().unwrap();
init_git_repo(temp.path());
let summary = get_commit_summary(temp.path()).unwrap();
assert!(summary.contains("Initial commit"), "Got: {}", summary);
}
#[test]
fn test_get_recent_files() {
let temp = TempDir::new().unwrap();
init_git_repo(temp.path());
fs::write(temp.path().join("feature.txt"), "content").unwrap();
Command::new("git")
.args(["add", "feature.txt"])
.current_dir(temp.path())
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "Add feature"])
.current_dir(temp.path())
.output()
.unwrap();
let files = get_recent_files(temp.path(), 10).unwrap();
assert!(
files.contains(&"feature.txt".to_string()),
"Got: {:?}",
files
);
}
}