From c94d80620e8836820ea5cfb35514b3cce9079aac Mon Sep 17 00:00:00 2001 From: Louis Knight-Webb Date: Tue, 17 Jun 2025 00:12:34 -0400 Subject: [PATCH] Implement merge functionality --- Cargo.toml | 2 +- backend/src/models/task_attempt.rs | 190 ++++++++++++++++++++ backend/src/routes/tasks.rs | 34 +++- frontend/src/pages/task-attempt-compare.tsx | 47 +++++ 4 files changed, 271 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index c0980b42..8aba5e04 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["backend"] [workspace.dependencies] tokio = { version = "1.0", features = ["full"] } -axum = "0.7" +axum = { version = "0.7", features = ["macros"] } tower = "0.4" tower-http = { version = "0.5", features = ["cors"] } serde = { version = "1.0", features = ["derive"] } diff --git a/backend/src/models/task_attempt.rs b/backend/src/models/task_attempt.rs index d113338b..c597f810 100644 --- a/backend/src/models/task_attempt.rs +++ b/backend/src/models/task_attempt.rs @@ -280,6 +280,196 @@ impl TaskAttempt { Ok(()) } + /// Perform the actual git merge operations (synchronous) + fn perform_merge_operation( + worktree_path: &str, + main_repo_path: &str, + attempt_id: Uuid, + ) -> Result { + // Open the worktree repository + let worktree_repo = Repository::open(worktree_path)?; + + // Open the main repository + let main_repo = Repository::open(main_repo_path)?; + + // Get the current signature for commits + let signature = main_repo.signature()?; + + // First, commit any uncommitted changes in the worktree + let mut worktree_index = worktree_repo.index()?; + let tree_id = worktree_index.write_tree()?; + let _tree = worktree_repo.find_tree(tree_id)?; + + // Get the current HEAD commit in the worktree + let head = worktree_repo.head()?; + let parent_commit = head.peel_to_commit()?; + + // Check if there are any changes to commit + let status = worktree_repo.statuses(None)?; + let has_changes = status.iter().any(|entry| { + let flags = entry.status(); + flags.contains(git2::Status::INDEX_NEW) + || flags.contains(git2::Status::INDEX_MODIFIED) + || flags.contains(git2::Status::INDEX_DELETED) + || flags.contains(git2::Status::WT_NEW) + || flags.contains(git2::Status::WT_MODIFIED) + || flags.contains(git2::Status::WT_DELETED) + }); + + let mut final_commit = parent_commit.id(); + + if has_changes { + // Stage all changes + let mut worktree_index = worktree_repo.index()?; + worktree_index.add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)?; + worktree_index.write()?; + + let tree_id = worktree_index.write_tree()?; + let tree = worktree_repo.find_tree(tree_id)?; + + // Create commit for the changes + let commit_message = format!("Task attempt {} - Final changes", attempt_id); + final_commit = worktree_repo.commit( + Some("HEAD"), + &signature, + &signature, + &commit_message, + &tree, + &[&parent_commit], + )?; + } + + // Now we need to merge the worktree branch into the main repository + let branch_name = format!("attempt-{}", attempt_id); + + // Get the main branch (usually "main" or "master") + let main_branch = main_repo.head()?.shorthand().unwrap_or("main").to_string(); + + // Fetch the worktree branch into the main repository + let worktree_branch_ref = format!("refs/heads/{}", branch_name); + let main_branch_ref = format!("refs/heads/{}", main_branch); + + // Get the final commit from worktree + let _final_commit_obj = worktree_repo.find_commit(final_commit)?; + + // Create the branch in main repo pointing to the final commit + let branch_oid = main_repo.odb()?.write( + git2::ObjectType::Commit, + &worktree_repo.odb()?.read(final_commit)?.data(), + )?; + + // Create reference in main repo + main_repo.reference( + &worktree_branch_ref, + branch_oid, + true, + "Import worktree changes", + )?; + + // Now merge the branch into main + let main_branch_commit = main_repo + .reference_to_annotated_commit(&main_repo.find_reference(&main_branch_ref)?)?; + let worktree_branch_commit = main_repo + .reference_to_annotated_commit(&main_repo.find_reference(&worktree_branch_ref)?)?; + + // Perform the merge + let mut merge_opts = git2::MergeOptions::new(); + merge_opts.file_favor(git2::FileFavor::Theirs); // Prefer worktree changes in conflicts + + let mut checkout_opts = git2::build::CheckoutBuilder::new(); + checkout_opts.conflict_style_merge(true); + + main_repo.merge( + &[&worktree_branch_commit], + Some(&mut merge_opts), + Some(&mut checkout_opts), + )?; + + // Check if merge was successful (no conflicts) + let merge_head_path = main_repo.path().join("MERGE_HEAD"); + if merge_head_path.exists() { + // Complete the merge by creating a merge commit + let mut index = main_repo.index()?; + let tree_id = index.write_tree()?; + let tree = main_repo.find_tree(tree_id)?; + + let main_commit = main_repo.find_commit(main_branch_commit.id())?; + let worktree_commit = main_repo.find_commit(worktree_branch_commit.id())?; + + let merge_commit_message = + format!("Merge task attempt {} into {}", attempt_id, main_branch); + let merge_commit_id = main_repo.commit( + Some(&main_branch_ref), + &signature, + &signature, + &merge_commit_message, + &tree, + &[&main_commit, &worktree_commit], + )?; + + // Clean up merge state + main_repo.cleanup_state()?; + + Ok(merge_commit_id.to_string()) + } else { + // Fast-forward merge completed + let head_commit = main_repo.head()?.peel_to_commit()?; + let merge_commit_id = head_commit.id(); + + Ok(merge_commit_id.to_string()) + } + } + + /// Merge the worktree changes back to the main repository + pub async fn merge_changes( + pool: &PgPool, + attempt_id: Uuid, + task_id: Uuid, + project_id: Uuid, + ) -> Result { + // Get the task attempt with validation + let attempt = sqlx::query_as!( + TaskAttempt, + r#"SELECT ta.id, ta.task_id, ta.worktree_path, ta.base_commit, ta.merge_commit, ta.executor, ta.stdout, ta.stderr, ta.created_at, ta.updated_at + FROM task_attempts ta + JOIN tasks t ON ta.task_id = t.id + WHERE ta.id = $1 AND t.id = $2 AND t.project_id = $3"#, + attempt_id, + task_id, + project_id + ) + .fetch_optional(pool) + .await? + .ok_or(TaskAttemptError::TaskNotFound)?; + + // Get the task and project + let _task = Task::find_by_id(pool, task_id) + .await? + .ok_or(TaskAttemptError::TaskNotFound)?; + + let project = Project::find_by_id(pool, project_id) + .await? + .ok_or(TaskAttemptError::ProjectNotFound)?; + + // Perform the git merge operations (synchronous) + let merge_commit_id = Self::perform_merge_operation( + &attempt.worktree_path, + &project.git_repo_path, + attempt_id, + )?; + + // Update the task attempt with the merge commit + sqlx::query!( + "UPDATE task_attempts SET merge_commit = $1, updated_at = NOW() WHERE id = $2", + merge_commit_id, + attempt_id + ) + .execute(pool) + .await?; + + Ok(merge_commit_id) + } + /// Get the git diff between the base commit and the current worktree state pub async fn get_diff( pool: &PgPool, diff --git a/backend/src/routes/tasks.rs b/backend/src/routes/tasks.rs index f9eab841..823c3700 100644 --- a/backend/src/routes/tasks.rs +++ b/backend/src/routes/tasks.rs @@ -2,7 +2,7 @@ use axum::{ extract::{Extension, Path}, http::StatusCode, response::Json as ResponseJson, - routing::get, + routing::{get, post}, Json, Router, }; use sqlx::PgPool; @@ -394,6 +394,34 @@ pub async fn get_task_attempt_diff( } } +#[axum::debug_handler] +pub async fn merge_task_attempt( + Path((project_id, task_id, attempt_id)): Path<(Uuid, Uuid, Uuid)>, + Extension(pool): Extension, +) -> Result>, StatusCode> { + // Verify task attempt exists and belongs to the correct task + match TaskAttempt::exists_for_task(&pool, attempt_id, task_id, project_id).await { + Ok(false) => return Err(StatusCode::NOT_FOUND), + Err(e) => { + tracing::error!("Failed to check task attempt existence: {}", e); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + Ok(true) => {} + } + + match TaskAttempt::merge_changes(&pool, attempt_id, task_id, project_id).await { + Ok(_merge_commit_id) => Ok(ResponseJson(ApiResponse { + success: true, + data: None, + message: Some("Changes merged successfully".to_string()), + })), + Err(e) => { + tracing::error!("Failed to merge task attempt {}: {}", attempt_id, e); + Err(StatusCode::INTERNAL_SERVER_ERROR) + } + } +} + pub fn tasks_router() -> Router { use axum::routing::{delete, post, put}; @@ -422,6 +450,10 @@ pub fn tasks_router() -> Router { "/projects/:project_id/tasks/:task_id/attempts/:attempt_id/diff", get(get_task_attempt_diff), ) + .route( + "/projects/:project_id/tasks/:task_id/attempts/:attempt_id/merge", + post(merge_task_attempt), + ) } #[cfg(test)] diff --git a/frontend/src/pages/task-attempt-compare.tsx b/frontend/src/pages/task-attempt-compare.tsx index 3aa6cd51..ea1d4db8 100644 --- a/frontend/src/pages/task-attempt-compare.tsx +++ b/frontend/src/pages/task-attempt-compare.tsx @@ -23,6 +23,8 @@ export function TaskAttemptComparePage() { const [diff, setDiff] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [merging, setMerging] = useState(false); + const [mergeSuccess, setMergeSuccess] = useState(false); useEffect(() => { if (projectId && taskId && attemptId) { @@ -60,6 +62,37 @@ export function TaskAttemptComparePage() { navigate(`/projects/${projectId}/tasks/${taskId}`); }; + const handleMergeClick = async () => { + if (!projectId || !taskId || !attemptId) return; + + try { + setMerging(true); + const response = await makeAuthenticatedRequest( + `/api/projects/${projectId}/tasks/${taskId}/attempts/${attemptId}/merge`, + { + method: 'POST', + } + ); + + if (response.ok) { + const result: ApiResponse = await response.json(); + if (result.success) { + setMergeSuccess(true); + // Optionally refetch the diff to show updated state + fetchDiff(); + } else { + setError("Failed to merge changes"); + } + } else { + setError("Failed to merge changes"); + } + } catch (err) { + setError("Failed to merge changes"); + } finally { + setMerging(false); + } + }; + const getChunkClassName = (chunkType: DiffChunkType) => { const baseClass = "font-mono text-sm whitespace-pre px-3 py-1"; @@ -124,6 +157,20 @@ export function TaskAttemptComparePage() { Compare Changes +
+ {mergeSuccess && ( +
+ Changes merged successfully! +
+ )} + +