use crate::error::{Autom8Error, Result};
use std::process::Command;
pub fn is_git_repo() -> bool {
Command::new("git")
.args(["rev-parse", "--git-dir"])
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
pub fn current_branch() -> Result<String> {
let output = Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.output()?;
if !output.status.success() {
return Err(Autom8Error::GitError(
String::from_utf8_lossy(&output.stderr).to_string(),
));
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
pub fn branch_exists(branch: &str) -> Result<bool> {
let local = Command::new("git")
.args([
"show-ref",
"--verify",
"--quiet",
&format!("refs/heads/{}", branch),
])
.output()?;
if local.status.success() {
return Ok(true);
}
let remote = Command::new("git")
.args([
"show-ref",
"--verify",
"--quiet",
&format!("refs/remotes/origin/{}", branch),
])
.output()?;
Ok(remote.status.success())
}
pub fn ensure_branch(branch: &str) -> Result<()> {
let current = current_branch()?;
if current == branch {
return Ok(());
}
if branch_exists(branch)? {
checkout(branch)?;
} else {
create_and_checkout(branch)?;
}
Ok(())
}
pub fn checkout(branch: &str) -> Result<()> {
let output = Command::new("git").args(["checkout", branch]).output()?;
if !output.status.success() {
return Err(Autom8Error::GitError(format!(
"Failed to checkout branch '{}': {}",
branch,
String::from_utf8_lossy(&output.stderr)
)));
}
Ok(())
}
fn create_and_checkout(branch: &str) -> Result<()> {
let output = Command::new("git")
.args(["checkout", "-b", branch])
.output()?;
if !output.status.success() {
return Err(Autom8Error::GitError(format!(
"Failed to create branch '{}': {}",
branch,
String::from_utf8_lossy(&output.stderr)
)));
}
Ok(())
}
pub fn is_clean() -> Result<bool> {
let output = Command::new("git")
.args(["status", "--porcelain"])
.output()?;
if !output.status.success() {
return Err(Autom8Error::GitError(
String::from_utf8_lossy(&output.stderr).to_string(),
));
}
Ok(output.stdout.is_empty())
}
pub fn latest_commit_short() -> Result<String> {
let output = Command::new("git")
.args(["rev-parse", "--short", "HEAD"])
.output()?;
if !output.status.success() {
return Err(Autom8Error::GitError(
String::from_utf8_lossy(&output.stderr).to_string(),
));
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DiffStatus {
Added,
Modified,
Deleted,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiffEntry {
pub path: std::path::PathBuf,
pub additions: u32,
pub deletions: u32,
pub status: DiffStatus,
}
impl DiffEntry {
pub fn from_numstat_line(line: &str) -> Option<Self> {
let parts: Vec<&str> = line.split('\t').collect();
if parts.len() != 3 {
return None;
}
let additions = parts[0].parse().unwrap_or(0);
let deletions = parts[1].parse().unwrap_or(0);
let path = std::path::PathBuf::from(parts[2]);
let status = if deletions == 0 && additions > 0 {
DiffStatus::Modified
} else if additions == 0 && deletions > 0 {
DiffStatus::Modified
} else {
DiffStatus::Modified
};
Some(DiffEntry {
path,
additions,
deletions,
status,
})
}
fn parse_name_status_line(line: &str) -> Option<(std::path::PathBuf, DiffStatus)> {
let parts: Vec<&str> = line.split('\t').collect();
if parts.is_empty() {
return None;
}
let status_char = parts[0].chars().next()?;
let status = match status_char {
'A' => DiffStatus::Added,
'D' => DiffStatus::Deleted,
'M' | 'R' | 'C' | 'T' => DiffStatus::Modified,
_ => DiffStatus::Modified,
};
let path = if status_char == 'R' || status_char == 'C' {
parts.get(2).map(|p| std::path::PathBuf::from(*p))?
} else {
parts.get(1).map(|p| std::path::PathBuf::from(*p))?
};
Some((path, status))
}
}
pub fn get_head_commit() -> Result<String> {
let output = Command::new("git").args(["rev-parse", "HEAD"]).output()?;
if !output.status.success() {
return Err(Autom8Error::GitError(
String::from_utf8_lossy(&output.stderr).trim().to_string(),
));
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
pub fn get_diff_since(base_commit: &str) -> Result<Vec<DiffEntry>> {
if !is_git_repo() {
return Ok(Vec::new());
}
let numstat_output = Command::new("git")
.args(["diff", "--numstat", base_commit])
.output()?;
let name_status_output = Command::new("git")
.args(["diff", "--name-status", base_commit])
.output()?;
if !numstat_output.status.success() || !name_status_output.status.success() {
return Ok(Vec::new());
}
let name_status_stdout = String::from_utf8_lossy(&name_status_output.stdout);
let status_map: std::collections::HashMap<std::path::PathBuf, DiffStatus> = name_status_stdout
.lines()
.filter_map(DiffEntry::parse_name_status_line)
.collect();
let numstat_stdout = String::from_utf8_lossy(&numstat_output.stdout);
let entries: Vec<DiffEntry> = numstat_stdout
.lines()
.filter(|line| !line.is_empty())
.filter_map(|line| {
let mut entry = DiffEntry::from_numstat_line(line)?;
if let Some(status) = status_map.get(&entry.path) {
entry.status = status.clone();
}
Some(entry)
})
.collect();
Ok(entries)
}
pub fn get_uncommitted_changes() -> Result<Vec<DiffEntry>> {
if !is_git_repo() {
return Ok(Vec::new());
}
let numstat_output = Command::new("git")
.args(["diff", "HEAD", "--numstat"])
.output()?;
let name_status_output = Command::new("git")
.args(["diff", "HEAD", "--name-status"])
.output()?;
if !numstat_output.status.success() || !name_status_output.status.success() {
return Ok(Vec::new());
}
let name_status_stdout = String::from_utf8_lossy(&name_status_output.stdout);
let status_map: std::collections::HashMap<std::path::PathBuf, DiffStatus> = name_status_stdout
.lines()
.filter_map(DiffEntry::parse_name_status_line)
.collect();
let numstat_stdout = String::from_utf8_lossy(&numstat_output.stdout);
let entries: Vec<DiffEntry> = numstat_stdout
.lines()
.filter(|line| !line.is_empty())
.filter_map(|line| {
let mut entry = DiffEntry::from_numstat_line(line)?;
if let Some(status) = status_map.get(&entry.path) {
entry.status = status.clone();
}
Some(entry)
})
.collect();
Ok(entries)
}
pub fn get_new_files_since(base_commit: &str) -> Result<Vec<std::path::PathBuf>> {
if !is_git_repo() {
return Ok(Vec::new());
}
let output = Command::new("git")
.args(["diff", "--name-only", "--diff-filter=A", base_commit])
.output()?;
if !output.status.success() {
return Ok(Vec::new());
}
let stdout = String::from_utf8_lossy(&output.stdout);
let files: Vec<std::path::PathBuf> = stdout
.lines()
.filter(|line| !line.is_empty())
.map(std::path::PathBuf::from)
.collect();
Ok(files)
}
#[derive(Debug, Clone, PartialEq)]
pub struct CommitInfo {
pub short_hash: String,
pub full_hash: String,
pub message: String,
pub author: String,
pub date: String,
}
pub fn get_branch_commits(base_branch: &str) -> Result<Vec<CommitInfo>> {
let output = Command::new("git")
.args([
"log",
&format!("{}..HEAD", base_branch),
"--no-merges",
"--pretty=format:%H|%h|%s|%an|%ai",
])
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(Autom8Error::GitError(format!(
"Failed to get branch commits: {}",
stderr.trim()
)));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let commits: Vec<CommitInfo> = stdout
.lines()
.filter(|line| !line.is_empty())
.filter_map(|line| {
let parts: Vec<&str> = line.splitn(5, '|').collect();
if parts.len() >= 5 {
Some(CommitInfo {
full_hash: parts[0].to_string(),
short_hash: parts[1].to_string(),
message: parts[2].to_string(),
author: parts[3].to_string(),
date: parts[4].to_string(),
})
} else {
None
}
})
.collect();
Ok(commits)
}
pub fn detect_base_branch() -> Result<String> {
if branch_exists("main")? {
return Ok("main".to_string());
}
if branch_exists("master")? {
return Ok("master".to_string());
}
let output = Command::new("git")
.args(["remote", "show", "origin"])
.output();
if let Ok(out) = output {
if out.status.success() {
let stdout = String::from_utf8_lossy(&out.stdout);
for line in stdout.lines() {
if line.contains("HEAD branch:") {
if let Some(branch) = line.split(':').nth(1) {
return Ok(branch.trim().to_string());
}
}
}
}
}
Ok("main".to_string())
}
pub fn get_current_branch_commits() -> Result<Vec<CommitInfo>> {
let base_branch = detect_base_branch()?;
get_branch_commits(&base_branch)
}
pub fn get_commit_diff(commit_hash: &str) -> Result<String> {
let output = Command::new("git")
.args(["show", "--format=", commit_hash])
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(Autom8Error::GitError(format!(
"Failed to get commit diff: {}",
stderr.trim()
)));
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
#[derive(Debug, Clone, PartialEq)]
pub enum PushResult {
Success,
AlreadyUpToDate,
Error(String),
}
#[derive(Debug, Clone, PartialEq)]
pub enum CommitResult {
Success(String),
NothingToCommit,
Error(String),
}
pub fn has_uncommitted_changes() -> Result<bool> {
let output = Command::new("git")
.args(["status", "--porcelain"])
.output()?;
if !output.status.success() {
return Err(Autom8Error::GitError(
String::from_utf8_lossy(&output.stderr).to_string(),
));
}
Ok(!output.stdout.is_empty())
}
pub fn stage_all_changes() -> Result<()> {
let output = Command::new("git").args(["add", "-A"]).output()?;
if !output.status.success() {
return Err(Autom8Error::GitError(format!(
"Failed to stage changes: {}",
String::from_utf8_lossy(&output.stderr)
)));
}
Ok(())
}
pub fn create_commit(message: &str) -> Result<CommitResult> {
let output = Command::new("git")
.args(["commit", "-m", message])
.output()?;
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
if output.status.success() {
let hash = latest_commit_short().unwrap_or_else(|_| "unknown".to_string());
return Ok(CommitResult::Success(hash));
}
let combined = format!("{} {}", stdout, stderr);
if combined.to_lowercase().contains("nothing to commit")
|| combined.to_lowercase().contains("no changes added")
{
return Ok(CommitResult::NothingToCommit);
}
Ok(CommitResult::Error(stderr.trim().to_string()))
}
pub fn commit_and_push_pr_fixes(
pr_number: u32,
commit_enabled: bool,
push_enabled: bool,
) -> Result<(Option<CommitResult>, Option<PushResult>)> {
if !commit_enabled {
return Ok((None, None));
}
if !has_uncommitted_changes()? {
return Ok((Some(CommitResult::NothingToCommit), None));
}
stage_all_changes()?;
let commit_message = format!(
"fix: address PR #{} review feedback\n\nApply fixes based on PR review comments.",
pr_number
);
let commit_result = create_commit(&commit_message)?;
let push_result = match (&commit_result, push_enabled) {
(CommitResult::Success(_), true) => {
let branch = current_branch()?;
Some(push_branch(&branch)?)
}
_ => None,
};
Ok((Some(commit_result), push_result))
}
pub fn push_branch(branch: &str) -> Result<PushResult> {
let output = Command::new("git")
.args(["push", "--set-upstream", "origin", branch])
.output()?;
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
if output.status.success() {
if stderr.contains("Everything up-to-date") {
return Ok(PushResult::AlreadyUpToDate);
}
return Ok(PushResult::Success);
}
let error_msg = if stderr.is_empty() {
stdout.trim().to_string()
} else {
stderr.trim().to_string()
};
if error_msg.contains("non-fast-forward")
|| error_msg.contains("rejected")
|| error_msg.contains("failed to push")
{
let force_output = Command::new("git")
.args([
"push",
"--force-with-lease",
"--set-upstream",
"origin",
branch,
])
.output()?;
if force_output.status.success() {
return Ok(PushResult::Success);
}
let force_stderr = String::from_utf8_lossy(&force_output.stderr);
return Ok(PushResult::Error(format!(
"Failed to push branch (even with --force-with-lease): {}",
force_stderr.trim()
)));
}
Ok(PushResult::Error(error_msg))
}
pub fn get_merge_base(base_branch: &str) -> Result<String> {
let output = Command::new("git")
.args(["merge-base", base_branch, "HEAD"])
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(Autom8Error::GitError(format!(
"Failed to find merge-base with '{}': {}",
base_branch,
stderr.trim()
)));
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
pub fn get_merge_base_auto() -> Result<String> {
let base_branch = detect_base_branch()?;
get_merge_base(&base_branch)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_diff_entry_from_numstat_line_basic() {
let line = "10\t5\tsrc/lib.rs";
let entry = DiffEntry::from_numstat_line(line);
assert!(entry.is_some());
let entry = entry.unwrap();
assert_eq!(entry.path, std::path::PathBuf::from("src/lib.rs"));
assert_eq!(entry.additions, 10);
assert_eq!(entry.deletions, 5);
}
#[test]
fn test_diff_entry_from_numstat_line_binary_file() {
let line = "-\t-\timage.png";
let entry = DiffEntry::from_numstat_line(line).unwrap();
assert_eq!(entry.path, std::path::PathBuf::from("image.png"));
assert_eq!(entry.additions, 0);
assert_eq!(entry.deletions, 0);
}
#[test]
fn test_diff_entry_from_numstat_line_path_with_spaces() {
let line = "5\t3\tpath/to/my file.rs";
let entry = DiffEntry::from_numstat_line(line).unwrap();
assert_eq!(entry.path, std::path::PathBuf::from("path/to/my file.rs"));
}
#[test]
fn test_diff_entry_from_numstat_line_invalid() {
assert!(DiffEntry::from_numstat_line("10\t5").is_none());
assert!(DiffEntry::from_numstat_line("").is_none());
}
#[test]
fn test_diff_entry_parse_name_status_variants() {
let (path, status) = DiffEntry::parse_name_status_line("A\tsrc/new_file.rs").unwrap();
assert_eq!(path, std::path::PathBuf::from("src/new_file.rs"));
assert_eq!(status, DiffStatus::Added);
let (path, status) = DiffEntry::parse_name_status_line("M\tsrc/changed.rs").unwrap();
assert_eq!(path, std::path::PathBuf::from("src/changed.rs"));
assert_eq!(status, DiffStatus::Modified);
let (path, status) = DiffEntry::parse_name_status_line("D\tsrc/removed.rs").unwrap();
assert_eq!(path, std::path::PathBuf::from("src/removed.rs"));
assert_eq!(status, DiffStatus::Deleted);
let (path, status) =
DiffEntry::parse_name_status_line("R100\told_name.rs\tnew_name.rs").unwrap();
assert_eq!(path, std::path::PathBuf::from("new_name.rs"));
assert_eq!(status, DiffStatus::Modified);
assert!(DiffEntry::parse_name_status_line("").is_none());
}
#[test]
fn test_commit_and_push_with_commit_disabled_returns_none() {
let result = commit_and_push_pr_fixes(123, false, false);
assert!(result.is_ok());
let (commit_result, push_result) = result.unwrap();
assert!(commit_result.is_none());
assert!(push_result.is_none());
}
}