use crate::subprocess::{ProcessCommandBuilder, SubprocessManager};
use anyhow::Result;
use async_trait::async_trait;
use std::path::Path;
use std::sync::Arc;
use tokio::sync::Mutex;
#[async_trait]
pub trait GitOperations: Send + Sync {
async fn git_command(&self, args: &[&str], description: &str) -> Result<std::process::Output>;
async fn git_command_in_dir(
&self,
args: &[&str],
description: &str,
working_dir: &Path,
) -> Result<std::process::Output>;
async fn get_last_commit_message(&self) -> Result<String>;
async fn check_git_status(&self) -> Result<String>;
async fn stage_all_changes(&self) -> Result<()>;
async fn create_commit(&self, message: &str) -> Result<()>;
async fn is_git_repo(&self) -> bool;
async fn create_worktree(&self, name: &str, path: &Path) -> Result<()>;
async fn get_current_branch(&self) -> Result<String>;
async fn switch_branch(&self, branch: &str) -> Result<()>;
}
pub struct RealGitOperations {
git_mutex: Arc<Mutex<()>>,
subprocess: SubprocessManager,
}
impl RealGitOperations {
#[must_use]
pub fn new() -> Self {
Self {
git_mutex: Arc::new(Mutex::new(())),
subprocess: SubprocessManager::production(),
}
}
#[cfg(test)]
pub fn with_subprocess(subprocess: SubprocessManager) -> Self {
Self {
git_mutex: Arc::new(Mutex::new(())),
subprocess,
}
}
}
impl Default for RealGitOperations {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl GitOperations for RealGitOperations {
async fn git_command(&self, args: &[&str], description: &str) -> Result<std::process::Output> {
let _guard = self.git_mutex.lock().await;
let command = ProcessCommandBuilder::new("git").args(args).build();
let output = self
.subprocess
.runner()
.run(command)
.await
.map_err(|e| anyhow::anyhow!("Failed to execute git {}: {}", description, e))?;
if !output.status.success() {
let stderr = &output.stderr;
return Err(anyhow::anyhow!(
"Git {} failed: {}",
description,
stderr.trim()
));
}
Ok(std::process::Output {
status: std::process::ExitStatus::from_raw(output.status.code().unwrap_or(0)),
stdout: output.stdout.into_bytes(),
stderr: output.stderr.into_bytes(),
})
}
async fn get_last_commit_message(&self) -> Result<String> {
let output = self
.git_command(&["log", "-1", "--pretty=format:%s"], "log")
.await?;
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
async fn check_git_status(&self) -> Result<String> {
let output = self
.git_command(&["status", "--porcelain"], "status")
.await?;
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
async fn stage_all_changes(&self) -> Result<()> {
self.git_command(&["add", "."], "add").await?;
Ok(())
}
async fn create_commit(&self, message: &str) -> Result<()> {
self.git_command(&["commit", "-m", message], "commit")
.await?;
Ok(())
}
async fn is_git_repo(&self) -> bool {
#[cfg(test)]
let command = ProcessCommandBuilder::new("git")
.args(["rev-parse", "--git-dir"])
.suppress_stderr()
.build();
#[cfg(not(test))]
let command = ProcessCommandBuilder::new("git")
.args(["rev-parse", "--git-dir"])
.build();
self.subprocess
.runner()
.run(command)
.await
.map(|output| output.status.success())
.unwrap_or(false)
}
async fn git_command_in_dir(
&self,
args: &[&str],
description: &str,
working_dir: &Path,
) -> Result<std::process::Output> {
let _guard = self.git_mutex.lock().await;
let command = ProcessCommandBuilder::new("git")
.args(args)
.current_dir(working_dir)
.build();
let output = self
.subprocess
.runner()
.run(command)
.await
.map_err(|e| anyhow::anyhow!("Failed to execute git {}: {}", description, e))?;
if !output.status.success() {
let stderr = &output.stderr;
return Err(anyhow::anyhow!(
"Git {} failed: {}",
description,
stderr.trim()
));
}
Ok(std::process::Output {
status: std::process::ExitStatus::from_raw(output.status.code().unwrap_or(0)),
stdout: output.stdout.into_bytes(),
stderr: output.stderr.into_bytes(),
})
}
async fn create_worktree(&self, name: &str, path: &Path) -> Result<()> {
let path_str = path
.to_str()
.ok_or_else(|| anyhow::anyhow!("Invalid path"))?;
self.git_command(&["worktree", "add", path_str, "-b", name], "worktree add")
.await?;
Ok(())
}
async fn get_current_branch(&self) -> Result<String> {
let output = self
.git_command(&["rev-parse", "--abbrev-ref", "HEAD"], "get current branch")
.await?;
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
async fn switch_branch(&self, branch: &str) -> Result<()> {
self.git_command(&["checkout", branch], "checkout").await?;
Ok(())
}
}
pub struct MockGitOperations {
pub command_responses: Arc<Mutex<Vec<Result<std::process::Output>>>>,
pub is_repo: bool,
pub called_commands: Arc<Mutex<Vec<Vec<String>>>>,
}
use crate::abstractions::exit_status::ExitStatusExt;
impl MockGitOperations {
#[must_use]
pub fn new() -> Self {
Self {
command_responses: Arc::new(Mutex::new(Vec::new())),
is_repo: true,
called_commands: Arc::new(Mutex::new(Vec::new())),
}
}
pub async fn add_response(&self, response: Result<std::process::Output>) {
self.command_responses.lock().await.push(response);
}
pub async fn add_success_response(&self, stdout: &str) {
let output = std::process::Output {
status: std::process::ExitStatus::from_raw(0),
stdout: stdout.as_bytes().to_vec(),
stderr: Vec::new(),
};
self.add_response(Ok(output)).await;
}
pub async fn add_error_response(&self, error: &str) {
let error_string = error.to_string();
self.add_response(Err(anyhow::anyhow!(error_string))).await;
}
pub async fn get_called_commands(&self) -> Vec<Vec<String>> {
self.called_commands.lock().await.clone()
}
}
impl Default for MockGitOperations {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl GitOperations for MockGitOperations {
async fn git_command(&self, args: &[&str], _description: &str) -> Result<std::process::Output> {
let cmd_vec: Vec<String> = args.iter().map(|s| (*s).to_string()).collect();
self.called_commands.lock().await.push(cmd_vec);
let mut responses = self.command_responses.lock().await;
if responses.is_empty() {
return Err(anyhow::anyhow!("No mock response configured"));
}
responses.remove(0)
}
async fn get_last_commit_message(&self) -> Result<String> {
let output = self
.git_command(&["log", "-1", "--pretty=format:%s"], "log")
.await?;
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
async fn check_git_status(&self) -> Result<String> {
let output = self
.git_command(&["status", "--porcelain"], "status")
.await?;
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
async fn stage_all_changes(&self) -> Result<()> {
self.git_command(&["add", "."], "add").await?;
Ok(())
}
async fn create_commit(&self, message: &str) -> Result<()> {
self.git_command(&["commit", "-m", message], "commit")
.await?;
Ok(())
}
async fn is_git_repo(&self) -> bool {
self.is_repo
}
async fn create_worktree(&self, name: &str, path: &Path) -> Result<()> {
let path_str = path
.to_str()
.ok_or_else(|| anyhow::anyhow!("Invalid path"))?;
self.git_command(&["worktree", "add", path_str, "-b", name], "worktree add")
.await?;
Ok(())
}
async fn get_current_branch(&self) -> Result<String> {
let output = self
.git_command(&["rev-parse", "--abbrev-ref", "HEAD"], "get current branch")
.await?;
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
async fn switch_branch(&self, branch: &str) -> Result<()> {
self.git_command(&["checkout", branch], "checkout").await?;
Ok(())
}
async fn git_command_in_dir(
&self,
args: &[&str],
description: &str,
_working_dir: &Path,
) -> Result<std::process::Output> {
self.git_command(args, description).await
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_mock_git_operations() {
let mock = MockGitOperations::new();
mock.add_success_response("test commit message").await;
mock.add_success_response("M src/main.rs\nA src/new.rs")
.await;
let msg = mock.get_last_commit_message().await.unwrap();
assert_eq!(msg, "test commit message");
let status = mock.check_git_status().await.unwrap();
assert!(status.contains("M src/main.rs"));
let commands = mock.get_called_commands().await;
assert_eq!(commands.len(), 2);
assert_eq!(commands[0], vec!["log", "-1", "--pretty=format:%s"]);
assert_eq!(commands[1], vec!["status", "--porcelain"]);
}
#[tokio::test]
async fn test_mock_git_error() {
let mock = MockGitOperations::new();
mock.add_error_response("fatal: not a git repository").await;
let result = mock.get_last_commit_message().await;
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("not a git repository"));
}
#[tokio::test]
async fn test_real_git_operations_is_git_repo() {
use crate::subprocess::builder::ProcessCommandBuilder;
use crate::subprocess::SubprocessManager;
use std::process::Command;
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let output = Command::new("git")
.args(["init"])
.current_dir(temp_dir.path())
.output()
.expect("Failed to run git init");
assert!(output.status.success(), "Git init should succeed");
let subprocess = SubprocessManager::production();
let command = ProcessCommandBuilder::new("git")
.args(["rev-parse", "--git-dir"])
.current_dir(temp_dir.path())
.suppress_stderr()
.build();
let result = subprocess.runner().run(command).await;
assert!(
result.is_ok() && result.unwrap().status.success(),
"Should detect git repository in {}",
temp_dir.path().display()
);
}
}
#[cfg(test)]
mod real_git_tests {
use super::*;
use tempfile::TempDir;
#[tokio::test]
async fn test_git_command_success() {
let git_ops = RealGitOperations::new();
let result = git_ops.git_command(&["--version"], "version check").await;
assert!(result.is_ok());
let output = result.unwrap();
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("git version"));
}
#[tokio::test]
async fn test_git_command_failure() {
let git_ops = RealGitOperations::new();
let result = git_ops
.git_command(&["invalid-command"], "invalid command")
.await;
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Git invalid command failed"));
}
#[tokio::test]
async fn test_stage_all_changes_and_commit() {
let git_ops = RealGitOperations::new();
if !git_ops.is_git_repo().await {
return;
}
let temp_dir = TempDir::new().unwrap();
let test_file = temp_dir.path().join("test.txt");
std::fs::write(&test_file, "test content").unwrap();
let _ = git_ops.check_git_status().await;
}
#[tokio::test]
async fn test_get_current_branch() {
let git_ops = RealGitOperations::new();
if !git_ops.is_git_repo().await {
return;
}
let result = git_ops.get_current_branch().await;
match result {
Ok(branch) => {
assert!(!branch.is_empty());
}
Err(e) => {
let error_msg = e.to_string();
if error_msg.contains("not a git repository") {
return;
}
if error_msg.contains("Unable to read current working directory") {
return;
}
assert!(
error_msg.contains("HEAD") || error_msg.contains("detached"),
"Unexpected error: {error_msg}"
);
}
}
}
#[tokio::test]
async fn test_create_worktree_invalid_path() {
let git_ops = RealGitOperations::new();
if git_ops.is_git_repo().await {
let invalid_path = Path::new("/\0invalid");
let result = git_ops.create_worktree("test-branch", invalid_path).await;
assert!(result.is_err());
}
}
}