diff --git a/backend/src/models/task_attempt.rs b/backend/src/models/task_attempt.rs index b74c0f4a..f7234b88 100644 --- a/backend/src/models/task_attempt.rs +++ b/backend/src/models/task_attempt.rs @@ -540,12 +540,13 @@ impl TaskAttempt { worktree_path: &str, main_repo_path: &str, new_base_branch: Option, + old_base_branch: String, ) -> Result { let git_service = GitService::new(main_repo_path)?; let worktree_path = Path::new(worktree_path); git_service - .rebase_branch(worktree_path, new_base_branch.as_deref()) + .rebase_branch(worktree_path, new_base_branch.as_deref(), &old_base_branch) .map_err(TaskAttemptError::from) } @@ -820,11 +821,11 @@ impl TaskAttempt { let worktree_path = Self::ensure_worktree_exists(pool, attempt_id, project_id, "rebase").await?; - // Perform the git rebase operations (synchronous) let new_base_commit = Self::perform_rebase_operation( &worktree_path, &ctx.project.git_repo_path, effective_base_branch.clone(), + ctx.task_attempt.base_branch.clone(), )?; // Update the database with the new base branch if it was changed diff --git a/backend/src/services/git_service.rs b/backend/src/services/git_service.rs index fddf84f1..4f8211aa 100644 --- a/backend/src/services/git_service.rs +++ b/backend/src/services/git_service.rs @@ -1,8 +1,8 @@ use std::path::{Path, PathBuf}; use git2::{ - build::CheckoutBuilder, BranchType, Cred, DiffOptions, Error as GitError, FetchOptions, - RebaseOptions, RemoteCallbacks, Repository, WorktreeAddOptions, + build::CheckoutBuilder, BranchType, CherrypickOptions, Cred, DiffOptions, Error as GitError, + FetchOptions, RemoteCallbacks, Repository, WorktreeAddOptions, }; use regex; use tracing::{debug, info}; @@ -334,6 +334,7 @@ impl GitService { &self, worktree_path: &Path, new_base_branch: Option<&str>, + old_base_branch: &str, ) -> Result { let worktree_repo = Repository::open(worktree_path)?; let main_repo = self.open_repo()?; @@ -403,48 +404,51 @@ impl GitService { .find_branch(local_branch_name, BranchType::Local) .map_err(|_| GitServiceError::BranchNotFound(local_branch_name.to_string()))?; - let base_commit_id = base_branch.get().peel_to_commit()?.id(); + let new_base_commit_id = base_branch.get().peel_to_commit()?.id(); // Get the HEAD commit of the worktree (the changes to rebase) let head = worktree_repo.head()?; + let task_branch_commit_id = head.peel_to_commit()?.id(); - // Set up rebase - let mut rebase_opts = RebaseOptions::new(); let signature = worktree_repo.signature()?; - // Start the rebase - let head_annotated = worktree_repo.reference_to_annotated_commit(&head)?; - let base_annotated = worktree_repo.find_annotated_commit(base_commit_id)?; + // Find the old base branch + let old_base_branch_ref = if old_base_branch.starts_with("origin/") { + // Remote branch - get local tracking branch name + let remote_branch_name = old_base_branch.strip_prefix("origin/").unwrap(); + main_repo + .find_branch(remote_branch_name, BranchType::Local) + .map_err(|_| GitServiceError::BranchNotFound(remote_branch_name.to_string()))? + } else { + // Local branch + main_repo + .find_branch(old_base_branch, BranchType::Local) + .map_err(|_| GitServiceError::BranchNotFound(old_base_branch.to_string()))? + }; - let mut rebase = worktree_repo.rebase( - Some(&head_annotated), - Some(&base_annotated), - None, // onto (use upstream if None) - Some(&mut rebase_opts), + let old_base_commit_id = old_base_branch_ref.get().peel_to_commit()?.id(); + + // Find commits unique to the task branch + let unique_commits = Self::find_unique_commits( + &worktree_repo, + task_branch_commit_id, + old_base_commit_id, + new_base_commit_id, )?; - // Process each rebase operation - while let Some(operation) = rebase.next() { - let _operation = operation?; + if !unique_commits.is_empty() { + // Reset HEAD to the new base branch + let new_base_commit = worktree_repo.find_commit(new_base_commit_id)?; + worktree_repo.reset(new_base_commit.as_object(), git2::ResetType::Hard, None)?; - // Check for conflicts - let index = worktree_repo.index()?; - if index.has_conflicts() { - // For now, abort the rebase on conflicts - rebase.abort()?; - return Err(GitServiceError::MergeConflicts( - "Rebase failed due to conflicts. Please resolve conflicts manually." - .to_string(), - )); - } - - // Commit the rebased operation - rebase.commit(None, &signature, None)?; + // Cherry-pick the unique commits + Self::cherry_pick_commits(&worktree_repo, &unique_commits, &signature)?; + } else { + // No unique commits to rebase, just reset to new base + let new_base_commit = worktree_repo.find_commit(new_base_commit_id)?; + worktree_repo.reset(new_base_commit.as_object(), git2::ResetType::Hard, None)?; } - // Finish the rebase - rebase.finish(None)?; - // Get the final commit ID after rebase let final_head = worktree_repo.head()?; let final_commit = final_head.peel_to_commit()?; @@ -1183,6 +1187,98 @@ impl GitService { .map_err(GitServiceError::Git)?; Ok(()) } + + /// Find the merge-base between two commits + fn get_merge_base( + repo: &Repository, + commit1: git2::Oid, + commit2: git2::Oid, + ) -> Result { + repo.merge_base(commit1, commit2) + .map_err(GitServiceError::Git) + } + + /// Find commits that are unique to the task branch (not in either base branch) + fn find_unique_commits( + repo: &Repository, + task_branch_commit: git2::Oid, + old_base_commit: git2::Oid, + new_base_commit: git2::Oid, + ) -> Result, GitServiceError> { + // Find merge-base between task branch and old base branch + let task_old_base_merge_base = + Self::get_merge_base(repo, task_branch_commit, old_base_commit)?; + + // Find merge-base between old base and new base + let old_new_base_merge_base = Self::get_merge_base(repo, old_base_commit, new_base_commit)?; + + // Get all commits from task branch back to the merge-base with old base + let mut walker = repo.revwalk()?; + walker.push(task_branch_commit)?; + walker.hide(task_old_base_merge_base)?; + + let mut task_commits = Vec::new(); + for commit_id in walker { + let commit_id = commit_id?; + + // Check if this commit is not in the old base branch lineage + // (i.e., it's not between old_new_base_merge_base and old_base_commit) + let is_in_old_base = repo + .graph_descendant_of(commit_id, old_new_base_merge_base) + .unwrap_or(false) + && repo + .graph_descendant_of(old_base_commit, commit_id) + .unwrap_or(false); + + if !is_in_old_base { + task_commits.push(commit_id); + } + } + + // Reverse to get chronological order for cherry-picking + task_commits.reverse(); + Ok(task_commits) + } + + /// Cherry-pick specific commits onto a new base + fn cherry_pick_commits( + repo: &Repository, + commits: &[git2::Oid], + signature: &git2::Signature, + ) -> Result<(), GitServiceError> { + for &commit_id in commits { + let commit = repo.find_commit(commit_id)?; + + // Cherry-pick the commit + let mut cherrypick_opts = CherrypickOptions::new(); + repo.cherrypick(&commit, Some(&mut cherrypick_opts))?; + + // Check for conflicts + let mut index = repo.index()?; + if index.has_conflicts() { + return Err(GitServiceError::MergeConflicts(format!( + "Cherry-pick failed due to conflicts on commit {}", + commit_id + ))); + } + + // Commit the cherry-pick + let tree_id = index.write_tree()?; + let tree = repo.find_tree(tree_id)?; + let head_commit = repo.head()?.peel_to_commit()?; + + repo.commit( + Some("HEAD"), + signature, + signature, + commit.message().unwrap_or("Cherry-picked commit"), + &tree, + &[&head_commit], + )?; + } + + Ok(()) + } } #[cfg(test)]