use crate::cook::execution::errors::{MapReduceError, MapReduceResult};
use crate::cook::orchestrator::ExecutionEnvironment;
use std::path::Path;
use tokio::process::Command;
use tracing::{info, warn};
use super::git_operations::{GitOperationsConfig, GitOperationsService, GitResultExt};
pub struct GitOperations {
service: GitOperationsService,
}
impl Default for GitOperations {
fn default() -> Self {
Self::new()
}
}
impl GitOperations {
pub fn new() -> Self {
Self {
service: GitOperationsService::new(GitOperationsConfig::default()),
}
}
pub async fn create_agent_branch(
&self,
worktree_path: &Path,
branch_name: &str,
) -> MapReduceResult<()> {
let output = Command::new("git")
.args(["checkout", "-b", branch_name])
.current_dir(worktree_path)
.output()
.await
.map_err(|e| self.create_git_error("create_branch", &e.to_string()))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(self.create_git_error("create_branch", &stderr));
}
info!(
"Created branch {} in worktree at {}",
branch_name,
worktree_path.display()
);
Ok(())
}
fn validate_worktree_context(
env: &ExecutionEnvironment,
) -> Result<&std::sync::Arc<std::path::PathBuf>, &'static str> {
if env.worktree_name.is_some() {
Ok(&env.working_dir)
} else {
Err("Cannot merge: not running in a worktree context")
}
}
fn has_incomplete_merge(parent_path: &Path) -> bool {
parent_path.join(".git/MERGE_HEAD").exists()
}
fn should_commit_staged_changes(status_output: &str) -> bool {
!status_output.trim().is_empty()
}
async fn check_git_status(&self, repo_path: &Path) -> MapReduceResult<String> {
let output = Command::new("git")
.args(["status", "--porcelain"])
.current_dir(repo_path)
.output()
.await
.map_err(|e| self.create_git_error("git_status", &e.to_string()))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(self.create_git_error("git_status", &stderr));
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
async fn commit_staged_changes(&self, repo_path: &Path) -> MapReduceResult<()> {
let output = Command::new("git")
.args(["commit", "--no-edit"])
.current_dir(repo_path)
.output()
.await
.map_err(|e| self.create_git_error("git_commit", &e.to_string()))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(self.create_git_error("git_commit", &stderr));
}
Ok(())
}
async fn abort_merge(&self, repo_path: &Path) {
let _ = Command::new("git")
.args(["merge", "--abort"])
.current_dir(repo_path)
.output()
.await;
}
async fn recover_incomplete_merge(
&self,
parent_path: &Path,
agent_branch: &str,
) -> MapReduceResult<()> {
warn!(
"Detected incomplete merge state (MERGE_HEAD exists), cleaning up before merging {}",
agent_branch
);
let status = self.check_git_status(parent_path).await?;
if Self::should_commit_staged_changes(&status) {
warn!("Committing staged changes from incomplete merge");
if self.commit_staged_changes(parent_path).await.is_err() {
warn!("Failed to commit staged changes, aborting merge");
self.abort_merge(parent_path).await;
}
} else {
warn!("No staged changes, aborting incomplete merge");
self.abort_merge(parent_path).await;
}
Ok(())
}
async fn execute_merge(&self, parent_path: &Path, agent_branch: &str) -> MapReduceResult<()> {
let output = Command::new("git")
.args([
"merge",
"--no-ff",
"-m",
&format!("Merge agent {}", agent_branch),
agent_branch,
])
.current_dir(parent_path)
.output()
.await
.map_err(|e| self.create_git_error("merge_agent_branch", &e.to_string()))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
warn!(
"Git merge failed for {}, triggering Claude-assisted merge fallback",
agent_branch
);
self.abort_merge(parent_path).await;
return Err(MapReduceError::General {
message: format!(
"Git merge failed for agent branch '{}'. Claude-assisted merge required. Original error: {}",
agent_branch, stderr.trim()
),
source: None,
});
}
Ok(())
}
async fn has_uncommitted_changes(&self, repo_path: &Path) -> MapReduceResult<bool> {
let status = self.check_git_status(repo_path).await?;
let meaningful_changes = status
.lines()
.filter(|line| {
if let Some(rest) = line.strip_prefix("?? ") {
!rest.trim().ends_with('/')
} else {
true
}
})
.collect::<Vec<_>>();
Ok(!meaningful_changes.is_empty())
}
pub async fn merge_agent_to_parent(
&self,
agent_branch: &str,
env: &ExecutionEnvironment,
) -> MapReduceResult<()> {
let parent_path = Self::validate_worktree_context(env)
.map_err(|msg| self.create_git_error("merge_to_parent", msg))?;
let had_incomplete_merge = Self::has_incomplete_merge(parent_path);
if !had_incomplete_merge && self.has_uncommitted_changes(parent_path).await? {
warn!(
"Working directory has uncommitted changes before merging {}. This indicates previous merge conflicts or incomplete operations.",
agent_branch
);
return Err(MapReduceError::General {
message: format!(
"Merge conflict detected: Working directory has uncommitted changes from previous operations. Claude-assisted merge required for agent branch '{}'.",
agent_branch
),
source: None,
});
}
if had_incomplete_merge {
self.recover_incomplete_merge(parent_path, agent_branch)
.await?;
}
self.execute_merge(parent_path, agent_branch).await?;
info!(
"Successfully merged agent branch {} to parent",
agent_branch
);
Ok(())
}
pub async fn get_worktree_commits(
&mut self,
worktree_path: &Path,
) -> MapReduceResult<Vec<String>> {
let commit_infos = self
.service
.get_worktree_commits(worktree_path, None, None)
.await?;
Ok(commit_infos.to_string_list())
}
pub async fn get_modified_files(
&mut self,
worktree_path: &Path,
) -> MapReduceResult<Vec<String>> {
let file_infos = self
.service
.get_worktree_modified_files(worktree_path, None)
.await?;
Ok(file_infos.to_string_list())
}
pub async fn get_worktree_modified_files(
&mut self,
worktree_path: &Path,
) -> MapReduceResult<Vec<String>> {
self.get_modified_files(worktree_path).await
}
pub async fn branch_exists(&self, branch_name: &str, worktree_path: &Path) -> bool {
let output = Command::new("git")
.args(["rev-parse", "--verify", branch_name])
.current_dir(worktree_path)
.output()
.await
.ok();
output.map(|o| o.status.success()).unwrap_or(false)
}
pub async fn delete_branch(
&self,
branch_name: &str,
worktree_path: &Path,
) -> MapReduceResult<()> {
let output = Command::new("git")
.args(["branch", "-D", branch_name])
.current_dir(worktree_path)
.output()
.await
.map_err(|e| self.create_git_error("delete_branch", &e.to_string()))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if !stderr.contains("not found") {
warn!("Failed to delete branch {}: {}", branch_name, stderr);
}
}
Ok(())
}
fn create_git_error(&self, operation: &str, message: &str) -> MapReduceError {
MapReduceError::General {
message: format!("Git operation '{}' failed: {}", operation, message),
source: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::sync::Arc;
use tempfile::TempDir;
use tokio::process::Command as TokioCommand;
async fn create_test_repo() -> (TempDir, std::path::PathBuf) {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let repo_path = temp_dir.path().to_path_buf();
let init_output = TokioCommand::new("git")
.args(["init"])
.current_dir(&repo_path)
.output()
.await
.expect("Failed to run git init");
assert!(init_output.status.success(), "git init failed");
TokioCommand::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(&repo_path)
.output()
.await
.expect("Failed to config user.name");
TokioCommand::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(&repo_path)
.output()
.await
.expect("Failed to config user.email");
fs::write(repo_path.join("README.md"), "# Test Repo").expect("Failed to write README");
TokioCommand::new("git")
.args(["add", "."])
.current_dir(&repo_path)
.output()
.await
.expect("Failed to git add");
let commit_output = TokioCommand::new("git")
.args(["commit", "-m", "Initial commit"])
.current_dir(&repo_path)
.output()
.await
.expect("Failed to commit");
assert!(commit_output.status.success(), "initial commit failed");
(temp_dir, repo_path)
}
async fn create_test_worktree(parent_path: &Path, worktree_name: &str) -> std::path::PathBuf {
let worktree_path = parent_path.join(worktree_name);
let output = TokioCommand::new("git")
.args([
"worktree",
"add",
worktree_path.to_str().unwrap(),
"-b",
worktree_name,
])
.current_dir(parent_path)
.output()
.await
.expect("Failed to create worktree");
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
panic!(
"Failed to create worktree: {}\nStdout: {}\nStderr: {}",
output.status,
String::from_utf8_lossy(&output.stdout),
stderr
);
}
worktree_path
}
async fn create_commit_in_worktree(worktree_path: &Path, file_name: &str, content: &str) {
fs::write(worktree_path.join(file_name), content).expect("Failed to write file");
TokioCommand::new("git")
.args(["add", "."])
.current_dir(worktree_path)
.output()
.await
.expect("Failed to git add");
let output = TokioCommand::new("git")
.args(["commit", "-m", &format!("Add {}", file_name)])
.current_dir(worktree_path)
.output()
.await
.expect("Failed to commit");
assert!(output.status.success(), "Failed to create commit");
}
async fn create_merge_head(repo_path: &Path, commit_sha: &str) {
let merge_head_path = repo_path.join(".git/MERGE_HEAD");
fs::write(&merge_head_path, format!("{}\n", commit_sha))
.expect("Failed to create MERGE_HEAD");
}
async fn get_current_commit_sha(repo_path: &Path) -> String {
let output = TokioCommand::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(repo_path)
.output()
.await
.expect("Failed to get commit SHA");
String::from_utf8_lossy(&output.stdout).trim().to_string()
}
#[test]
fn test_validate_worktree_context_with_worktree() {
let env = ExecutionEnvironment {
working_dir: Arc::new(std::path::PathBuf::from("/tmp/test")),
project_dir: Arc::new(std::path::PathBuf::from("/tmp/project")),
worktree_name: Some(Arc::from("test-worktree")),
session_id: Arc::from("test-session"),
};
let result = GitOperations::validate_worktree_context(&env);
assert!(result.is_ok());
assert_eq!(**result.unwrap(), std::path::PathBuf::from("/tmp/test"));
}
#[test]
fn test_validate_worktree_context_without_worktree() {
let env = ExecutionEnvironment {
working_dir: Arc::new(std::path::PathBuf::from("/tmp/test")),
project_dir: Arc::new(std::path::PathBuf::from("/tmp/project")),
worktree_name: None,
session_id: Arc::from("test-session"),
};
let result = GitOperations::validate_worktree_context(&env);
assert!(result.is_err());
assert_eq!(
result.unwrap_err(),
"Cannot merge: not running in a worktree context"
);
}
#[tokio::test]
async fn test_has_incomplete_merge_when_merge_head_exists() {
let (_temp_dir, repo_path) = create_test_repo().await;
let commit_sha = get_current_commit_sha(&repo_path).await;
create_merge_head(&repo_path, &commit_sha).await;
assert!(GitOperations::has_incomplete_merge(&repo_path));
}
#[tokio::test]
async fn test_has_incomplete_merge_when_merge_head_absent() {
let (_temp_dir, repo_path) = create_test_repo().await;
assert!(!GitOperations::has_incomplete_merge(&repo_path));
}
#[test]
fn test_should_commit_staged_changes_with_changes() {
let status_with_changes = "M some_file.txt\nA new_file.txt\n";
assert!(GitOperations::should_commit_staged_changes(
status_with_changes
));
}
#[test]
fn test_should_commit_staged_changes_without_changes() {
let status_empty = "";
assert!(!GitOperations::should_commit_staged_changes(status_empty));
let status_whitespace = " \n \n";
assert!(!GitOperations::should_commit_staged_changes(
status_whitespace
));
}
#[tokio::test]
async fn test_merge_agent_to_parent_not_in_worktree_context() {
let git_ops = GitOperations::new();
let env = ExecutionEnvironment {
working_dir: Arc::new(std::path::PathBuf::from("/tmp")),
project_dir: Arc::new(std::path::PathBuf::from("/tmp")),
worktree_name: None,
session_id: Arc::from("test-session"),
};
let result = git_ops.merge_agent_to_parent("agent-branch", &env).await;
assert!(result.is_err());
let err = result.unwrap_err();
match err {
MapReduceError::General { message, .. } => {
assert!(message.contains("not running in a worktree context"));
}
_ => panic!("Expected General error"),
}
}
#[tokio::test]
async fn test_merge_agent_to_parent_clean_merge_success() {
let (_temp_dir, parent_path) = create_test_repo().await;
let worktree_path = create_test_worktree(&parent_path, "agent-worktree").await;
create_commit_in_worktree(&worktree_path, "feature.txt", "New feature").await;
let env = ExecutionEnvironment {
working_dir: Arc::new(parent_path.clone()),
project_dir: Arc::new(parent_path.clone()),
worktree_name: Some(Arc::from("agent-worktree")),
session_id: Arc::from("test-session"),
};
let git_ops = GitOperations::new();
let result = git_ops.merge_agent_to_parent("agent-worktree", &env).await;
assert!(result.is_ok());
let merged_file = parent_path.join("feature.txt");
assert!(merged_file.exists(), "Merged file should exist in parent");
}
#[tokio::test]
async fn test_merge_agent_to_parent_with_merge_head_and_staged_changes_commit_succeeds() {
let (_temp_dir, parent_path) = create_test_repo().await;
let commit_sha = get_current_commit_sha(&parent_path).await;
create_merge_head(&parent_path, &commit_sha).await;
fs::write(parent_path.join("staged.txt"), "staged content")
.expect("Failed to write staged file");
let add_output = TokioCommand::new("git")
.args(["add", "staged.txt"])
.current_dir(&parent_path)
.output()
.await
.expect("Failed to stage file");
assert!(add_output.status.success());
let worktree_path = create_test_worktree(&parent_path, "agent-worktree").await;
create_commit_in_worktree(&worktree_path, "feature.txt", "New feature").await;
let env = ExecutionEnvironment {
working_dir: Arc::new(parent_path.clone()),
project_dir: Arc::new(parent_path.clone()),
worktree_name: Some(Arc::from("agent-worktree")),
session_id: Arc::from("test-session"),
};
let git_ops = GitOperations::new();
let result = git_ops.merge_agent_to_parent("agent-worktree", &env).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_merge_agent_to_parent_with_merge_head_no_staged_changes_abort() {
let (_temp_dir, parent_path) = create_test_repo().await;
let commit_sha = get_current_commit_sha(&parent_path).await;
create_merge_head(&parent_path, &commit_sha).await;
let worktree_path = create_test_worktree(&parent_path, "agent-worktree").await;
create_commit_in_worktree(&worktree_path, "feature.txt", "New feature").await;
let env = ExecutionEnvironment {
working_dir: Arc::new(parent_path.clone()),
project_dir: Arc::new(parent_path.clone()),
worktree_name: Some(Arc::from("agent-worktree")),
session_id: Arc::from("test-session"),
};
let git_ops = GitOperations::new();
let result = git_ops.merge_agent_to_parent("agent-worktree", &env).await;
assert!(result.is_ok());
assert!(!parent_path.join(".git/MERGE_HEAD").exists());
}
#[tokio::test]
async fn test_merge_agent_to_parent_invalid_branch_triggers_claude_fallback() {
let (_temp_dir, parent_path) = create_test_repo().await;
let env = ExecutionEnvironment {
working_dir: Arc::new(parent_path.clone()),
project_dir: Arc::new(parent_path.clone()),
worktree_name: Some(Arc::from("test-worktree")),
session_id: Arc::from("test-session"),
};
let git_ops = GitOperations::new();
let result = git_ops
.merge_agent_to_parent("non-existent-branch", &env)
.await;
assert!(result.is_err());
let err = result.unwrap_err();
match err {
MapReduceError::General { message, .. } => {
assert!(message.contains("Claude-assisted merge required"));
assert!(message.contains("non-existent-branch"));
}
_ => panic!("Expected General error with Claude-assisted merge required"),
}
}
#[tokio::test]
async fn test_merge_conflict_triggers_claude_fallback() {
let (_temp_dir, parent_path) = create_test_repo().await;
let worktree1 = create_test_worktree(&parent_path, "agent-1").await;
let worktree2 = create_test_worktree(&parent_path, "agent-2").await;
fs::write(worktree1.join("README.md"), "# Changed by agent 1")
.expect("Failed to write file");
TokioCommand::new("git")
.args(["add", "."])
.current_dir(&worktree1)
.output()
.await
.expect("Failed to git add");
TokioCommand::new("git")
.args(["commit", "-m", "Change by agent 1"])
.current_dir(&worktree1)
.output()
.await
.expect("Failed to commit");
fs::write(worktree2.join("README.md"), "# Changed by agent 2")
.expect("Failed to write file");
TokioCommand::new("git")
.args(["add", "."])
.current_dir(&worktree2)
.output()
.await
.expect("Failed to git add");
TokioCommand::new("git")
.args(["commit", "-m", "Conflicting change"])
.current_dir(&worktree2)
.output()
.await
.expect("Failed to commit");
let env1 = ExecutionEnvironment {
working_dir: Arc::new(parent_path.clone()),
project_dir: Arc::new(parent_path.clone()),
worktree_name: Some(Arc::from("agent-1")),
session_id: Arc::from("test-session"),
};
let git_ops = GitOperations::new();
let result1 = git_ops.merge_agent_to_parent("agent-1", &env1).await;
assert!(result1.is_ok(), "First merge should succeed");
let env2 = ExecutionEnvironment {
working_dir: Arc::new(parent_path.clone()),
project_dir: Arc::new(parent_path.clone()),
worktree_name: Some(Arc::from("agent-2")),
session_id: Arc::from("test-session"),
};
let result2 = git_ops.merge_agent_to_parent("agent-2", &env2).await;
assert!(result2.is_err(), "Second merge should fail with conflict");
let err = result2.unwrap_err();
match err {
MapReduceError::General { message, .. } => {
assert!(message.contains("Claude-assisted merge required"));
assert!(message.contains("agent-2"));
}
_ => panic!("Expected General error with Claude-assisted merge required"),
}
}
}