Implement merge functionality
This commit is contained in:
@@ -4,7 +4,7 @@ members = ["backend"]
|
|||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
tokio = { version = "1.0", features = ["full"] }
|
tokio = { version = "1.0", features = ["full"] }
|
||||||
axum = "0.7"
|
axum = { version = "0.7", features = ["macros"] }
|
||||||
tower = "0.4"
|
tower = "0.4"
|
||||||
tower-http = { version = "0.5", features = ["cors"] }
|
tower-http = { version = "0.5", features = ["cors"] }
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
|||||||
@@ -280,6 +280,196 @@ impl TaskAttempt {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Perform the actual git merge operations (synchronous)
|
||||||
|
fn perform_merge_operation(
|
||||||
|
worktree_path: &str,
|
||||||
|
main_repo_path: &str,
|
||||||
|
attempt_id: Uuid,
|
||||||
|
) -> Result<String, TaskAttemptError> {
|
||||||
|
// 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<String, TaskAttemptError> {
|
||||||
|
// 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
|
/// Get the git diff between the base commit and the current worktree state
|
||||||
pub async fn get_diff(
|
pub async fn get_diff(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use axum::{
|
|||||||
extract::{Extension, Path},
|
extract::{Extension, Path},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::Json as ResponseJson,
|
response::Json as ResponseJson,
|
||||||
routing::get,
|
routing::{get, post},
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
use sqlx::PgPool;
|
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<PgPool>,
|
||||||
|
) -> Result<ResponseJson<ApiResponse<()>>, 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 {
|
pub fn tasks_router() -> Router {
|
||||||
use axum::routing::{delete, post, put};
|
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",
|
"/projects/:project_id/tasks/:task_id/attempts/:attempt_id/diff",
|
||||||
get(get_task_attempt_diff),
|
get(get_task_attempt_diff),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/projects/:project_id/tasks/:task_id/attempts/:attempt_id/merge",
|
||||||
|
post(merge_task_attempt),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ export function TaskAttemptComparePage() {
|
|||||||
const [diff, setDiff] = useState<WorktreeDiff | null>(null);
|
const [diff, setDiff] = useState<WorktreeDiff | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [merging, setMerging] = useState(false);
|
||||||
|
const [mergeSuccess, setMergeSuccess] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (projectId && taskId && attemptId) {
|
if (projectId && taskId && attemptId) {
|
||||||
@@ -60,6 +62,37 @@ export function TaskAttemptComparePage() {
|
|||||||
navigate(`/projects/${projectId}/tasks/${taskId}`);
|
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<string> = 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 getChunkClassName = (chunkType: DiffChunkType) => {
|
||||||
const baseClass = "font-mono text-sm whitespace-pre px-3 py-1";
|
const baseClass = "font-mono text-sm whitespace-pre px-3 py-1";
|
||||||
|
|
||||||
@@ -124,6 +157,20 @@ export function TaskAttemptComparePage() {
|
|||||||
Compare Changes
|
Compare Changes
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{mergeSuccess && (
|
||||||
|
<div className="text-green-600 text-sm">
|
||||||
|
Changes merged successfully!
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
onClick={handleMergeClick}
|
||||||
|
disabled={merging || !diff || diff.files.length === 0}
|
||||||
|
className="bg-green-600 hover:bg-green-700"
|
||||||
|
>
|
||||||
|
{merging ? "Merging..." : "Merge Changes"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
|
|||||||
Reference in New Issue
Block a user