diff --git a/backend/.sqlx/query-ac5247c8d7fb86e4650c4b0eb9420031614c831b7b085083bac20c1af314c538.json b/backend/.sqlx/query-ac5247c8d7fb86e4650c4b0eb9420031614c831b7b085083bac20c1af314c538.json new file mode 100644 index 00000000..7a59e4c0 --- /dev/null +++ b/backend/.sqlx/query-ac5247c8d7fb86e4650c4b0eb9420031614c831b7b085083bac20c1af314c538.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE task_attempts SET base_branch = $1, updated_at = datetime('now') WHERE id = $2", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "ac5247c8d7fb86e4650c4b0eb9420031614c831b7b085083bac20c1af314c538" +} diff --git a/backend/src/models/task_attempt.rs b/backend/src/models/task_attempt.rs index 0f7a942e..c8ede6ef 100644 --- a/backend/src/models/task_attempt.rs +++ b/backend/src/models/task_attempt.rs @@ -820,10 +820,29 @@ impl TaskAttempt { let new_base_commit = Self::perform_rebase_operation( &worktree_path, &ctx.project.git_repo_path, - effective_base_branch, + effective_base_branch.clone(), )?; - // No need to update database as we now get base_commit live from git + // Update the database with the new base branch if it was changed + if let Some(new_base_branch) = &effective_base_branch { + if new_base_branch != &ctx.task_attempt.base_branch { + // For remote branches, store the local branch name in the database + let db_branch_name = if new_base_branch.starts_with("origin/") { + new_base_branch.strip_prefix("origin/").unwrap() + } else { + new_base_branch + }; + + sqlx::query!( + "UPDATE task_attempts SET base_branch = $1, updated_at = datetime('now') WHERE id = $2", + db_branch_name, + attempt_id + ) + .execute(pool) + .await?; + } + } + Ok(new_base_commit) } diff --git a/backend/src/services/git_service.rs b/backend/src/services/git_service.rs index 2fc8c2a8..aed5d083 100644 --- a/backend/src/services/git_service.rs +++ b/backend/src/services/git_service.rs @@ -1,7 +1,8 @@ use std::path::{Path, PathBuf}; use git2::{ - BranchType, DiffOptions, Error as GitError, RebaseOptions, Repository, WorktreeAddOptions, + BranchType, Cred, DiffOptions, Error as GitError, FetchOptions, RebaseOptions, RemoteCallbacks, + Repository, WorktreeAddOptions, }; use regex; use tracing::{debug, info}; @@ -238,6 +239,19 @@ impl GitService { let worktree_repo = Repository::open(worktree_path)?; let main_repo = self.open_repo()?; + // Check if there's an existing rebase in progress and abort it + let state = worktree_repo.state(); + if state == git2::RepositoryState::Rebase + || state == git2::RepositoryState::RebaseInteractive + || state == git2::RepositoryState::RebaseMerge + { + tracing::warn!("Existing rebase in progress, aborting it first"); + // Try to abort the existing rebase + if let Ok(mut existing_rebase) = worktree_repo.open_rebase(None) { + let _ = existing_rebase.abort(); + } + } + // Get the target base branch reference let base_branch_name = match new_base_branch { Some(branch) => branch.to_string(), @@ -249,10 +263,46 @@ impl GitService { }; let base_branch_name = base_branch_name.as_str(); - // Check if the specified base branch exists in the main repo + // Handle remote branches by fetching them first and creating/updating local tracking branches + let local_branch_name = if base_branch_name.starts_with("origin/") { + // This is a remote branch, fetch it and create/update local tracking branch + let remote_branch_name = base_branch_name.strip_prefix("origin/").unwrap(); + + // First, fetch the latest changes from remote + self.fetch_from_remote(&main_repo)?; + + // Try to find the remote branch after fetch + let remote_branch = main_repo + .find_branch(base_branch_name, BranchType::Remote) + .map_err(|_| GitServiceError::BranchNotFound(base_branch_name.to_string()))?; + + // Check if local tracking branch exists + match main_repo.find_branch(remote_branch_name, BranchType::Local) { + Ok(mut local_branch) => { + // Local tracking branch exists, update it to match remote + let remote_commit = remote_branch.get().peel_to_commit()?; + local_branch + .get_mut() + .set_target(remote_commit.id(), "Update local branch to match remote")?; + } + Err(_) => { + // Local tracking branch doesn't exist, create it + let remote_commit = remote_branch.get().peel_to_commit()?; + main_repo.branch(remote_branch_name, &remote_commit, false)?; + } + } + + // Use the local branch name for rebase + remote_branch_name + } else { + // This is already a local branch + base_branch_name + }; + + // Get the local branch for rebase let base_branch = main_repo - .find_branch(base_branch_name, BranchType::Local) - .map_err(|_| GitServiceError::BranchNotFound(base_branch_name.to_string()))?; + .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(); @@ -1000,6 +1050,40 @@ impl GitService { info!("Pushed branch {} to GitHub using HTTPS", branch_name); Ok(()) } + + /// Fetch from remote repository, with SSH authentication callbacks + fn fetch_from_remote(&self, repo: &Repository) -> Result<(), GitServiceError> { + // Find the “origin” remote + let mut remote = repo.find_remote("origin").map_err(|_| { + GitServiceError::Git(git2::Error::from_str("Remote 'origin' not found")) + })?; + + // Prepare callbacks for authentication + let mut callbacks = RemoteCallbacks::new(); + callbacks.credentials(|_url, username_from_url, _| { + // Try SSH agent first + if let Some(username) = username_from_url { + if let Ok(cred) = Cred::ssh_key_from_agent(username) { + return Ok(cred); + } + } + // Fallback to key file (~/.ssh/id_rsa) + let home = dirs::home_dir() + .ok_or_else(|| git2::Error::from_str("Could not find home directory"))?; + let key_path = home.join(".ssh").join("id_rsa"); + Cred::ssh_key(username_from_url.unwrap_or("git"), None, &key_path, None) + }); + + // Set up fetch options with our callbacks + let mut fetch_opts = FetchOptions::new(); + fetch_opts.remote_callbacks(callbacks); + + // Actually fetch (no specific refspecs = fetch all configured) + remote + .fetch(&[] as &[&str], Some(&mut fetch_opts), None) + .map_err(GitServiceError::Git)?; + Ok(()) + } } #[cfg(test)] diff --git a/frontend/src/components/tasks/BranchSelector.tsx b/frontend/src/components/tasks/BranchSelector.tsx new file mode 100644 index 00000000..d2e8e80e --- /dev/null +++ b/frontend/src/components/tasks/BranchSelector.tsx @@ -0,0 +1,169 @@ +import { useState, useMemo, useRef } from 'react'; +import { Button } from '@/components/ui/button.tsx'; +import { ArrowDown, GitBranch as GitBranchIcon, Search } from 'lucide-react'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu.tsx'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip.tsx'; +import { Input } from '@/components/ui/input.tsx'; +import type { GitBranch } from 'shared/types.ts'; + +type Props = { + branches: GitBranch[]; + selectedBranch: string | null; + onBranchSelect: (branch: string) => void; + placeholder?: string; + className?: string; + excludeCurrentBranch?: boolean; +}; + +function BranchSelector({ + branches, + selectedBranch, + onBranchSelect, + placeholder = 'Select a branch', + className = '', + excludeCurrentBranch = false, +}: Props) { + const [branchSearchTerm, setBranchSearchTerm] = useState(''); + const searchInputRef = useRef(null); + + // Filter branches based on search term and options + const filteredBranches = useMemo(() => { + let filtered = branches; + + // Don't filter out current branch, we'll handle it in the UI + if (branchSearchTerm.trim()) { + filtered = filtered.filter((branch) => + branch.name.toLowerCase().includes(branchSearchTerm.toLowerCase()) + ); + } + + return filtered; + }, [branches, branchSearchTerm]); + + const displayName = useMemo(() => { + if (!selectedBranch) return placeholder; + + // For remote branches, show just the branch name without the remote prefix + if (selectedBranch.includes('/')) { + const parts = selectedBranch.split('/'); + return parts[parts.length - 1]; + } + return selectedBranch; + }, [selectedBranch, placeholder]); + + const handleBranchSelect = (branchName: string) => { + onBranchSelect(branchName); + setBranchSearchTerm(''); + }; + + return ( + + + + + +
+
+ + setBranchSearchTerm(e.target.value)} + className="pl-8" + onKeyDown={(e) => { + // Prevent the dropdown from closing when typing + e.stopPropagation(); + }} + autoFocus + /> +
+
+ +
+ {filteredBranches.length === 0 ? ( +
+ No branches found +
+ ) : ( + filteredBranches.map((branch) => { + const isCurrentAndExcluded = + excludeCurrentBranch && branch.is_current; + + const menuItem = ( + { + if (!isCurrentAndExcluded) { + handleBranchSelect(branch.name); + } + }} + disabled={isCurrentAndExcluded} + className={`${selectedBranch === branch.name ? 'bg-accent' : ''} ${ + isCurrentAndExcluded ? 'opacity-50 cursor-not-allowed' : '' + }`} + > +
+ + {branch.name} + +
+ {branch.is_current && ( + + current + + )} + {branch.is_remote && ( + + remote + + )} +
+
+
+ ); + + if (isCurrentAndExcluded) { + return ( + + + {menuItem} + +

Cannot rebase a branch onto itself

+
+
+
+ ); + } + + return menuItem; + }) + )} +
+
+
+ ); +} + +export default BranchSelector; diff --git a/frontend/src/components/tasks/TaskDetailsToolbar.tsx b/frontend/src/components/tasks/TaskDetailsToolbar.tsx index 1c0bea4f..e433bd90 100644 --- a/frontend/src/components/tasks/TaskDetailsToolbar.tsx +++ b/frontend/src/components/tasks/TaskDetailsToolbar.tsx @@ -232,6 +232,7 @@ function TaskDetailsToolbar() { creatingPR={creatingPR} handleEnterCreateAttemptMode={handleEnterCreateAttemptMode} availableExecutors={availableExecutors} + branches={branches} /> ) : (
diff --git a/frontend/src/components/tasks/Toolbar/CreateAttempt.tsx b/frontend/src/components/tasks/Toolbar/CreateAttempt.tsx index 0079d8bb..e99c9262 100644 --- a/frontend/src/components/tasks/Toolbar/CreateAttempt.tsx +++ b/frontend/src/components/tasks/Toolbar/CreateAttempt.tsx @@ -1,21 +1,12 @@ -import { Dispatch, SetStateAction, useContext, useMemo, useState } from 'react'; +import { Dispatch, SetStateAction, useContext } from 'react'; import { Button } from '@/components/ui/button.tsx'; -import { - ArrowDown, - GitBranch as GitBranchIcon, - Play, - Search, - Settings2, - X, -} from 'lucide-react'; +import { ArrowDown, Play, Settings2, X } from 'lucide-react'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, - DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu.tsx'; -import { Input } from '@/components/ui/input.tsx'; import type { GitBranch, TaskAttempt } from 'shared/types.ts'; import { attemptsApi } from '@/lib/api.ts'; import { @@ -23,6 +14,7 @@ import { TaskDetailsContext, } from '@/components/context/taskDetailsContext.ts'; import { useConfig } from '@/components/config-provider.tsx'; +import BranchSelector from '@/components/tasks/BranchSelector.tsx'; type Props = { branches: GitBranch[]; @@ -58,18 +50,6 @@ function CreateAttempt({ const { isAttemptRunning } = useContext(TaskAttemptDataContext); const { config } = useConfig(); - const [branchSearchTerm, setBranchSearchTerm] = useState(''); - - // Filter branches based on search term - const filteredBranches = useMemo(() => { - if (!branchSearchTerm.trim()) { - return branches; - } - return branches.filter((branch) => - branch.name.toLowerCase().includes(branchSearchTerm.toLowerCase()) - ); - }, [branches, branchSearchTerm]); - const onCreateNewAttempt = async (executor?: string, baseBranch?: string) => { try { await attemptsApi.create(projectId!, task.id, { @@ -122,81 +102,12 @@ function CreateAttempt({ Base branch
- - - - - -
-
- - setBranchSearchTerm(e.target.value)} - className="pl-8" - /> -
-
- -
- {filteredBranches.length === 0 ? ( -
- No branches found -
- ) : ( - filteredBranches.map((branch) => ( - { - setCreateAttemptBranch(branch.name); - setBranchSearchTerm(''); - }} - className={ - createAttemptBranch === branch.name ? 'bg-accent' : '' - } - > -
- - {branch.name} - -
- {branch.is_current && ( - - current - - )} - {branch.is_remote && ( - - remote - - )} -
-
-
- )) - )} -
-
-
+ {/* Step 2: Choose Coding Agent */} diff --git a/frontend/src/components/tasks/Toolbar/CurrentAttempt.tsx b/frontend/src/components/tasks/Toolbar/CurrentAttempt.tsx index 2a4563ed..7af637f0 100644 --- a/frontend/src/components/tasks/Toolbar/CurrentAttempt.tsx +++ b/frontend/src/components/tasks/Toolbar/CurrentAttempt.tsx @@ -7,6 +7,7 @@ import { Plus, RefreshCw, StopCircle, + Settings, } from 'lucide-react'; import { Tooltip, @@ -21,6 +22,15 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu.tsx'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog.tsx'; +import BranchSelector from '@/components/tasks/BranchSelector.tsx'; import { attemptsApi, executionProcessesApi } from '@/lib/api.ts'; import { Dispatch, @@ -34,6 +44,7 @@ import { import type { BranchStatus, ExecutionProcess, + GitBranch, TaskAttempt, } from 'shared/types.ts'; import { @@ -77,6 +88,7 @@ type Props = { id: string; name: string; }[]; + branches: GitBranch[]; }; function CurrentAttempt({ @@ -88,6 +100,7 @@ function CurrentAttempt({ creatingPR, handleEnterCreateAttemptMode, availableExecutors, + branches, }: Props) { const { task, projectId, handleOpenInEditor, projectHasDevScript } = useContext(TaskDetailsContext); @@ -107,6 +120,8 @@ function CurrentAttempt({ const [isHoveringDevServer, setIsHoveringDevServer] = useState(false); const [branchStatus, setBranchStatus] = useState(null); const [branchStatusLoading, setBranchStatusLoading] = useState(false); + const [showRebaseDialog, setShowRebaseDialog] = useState(false); + const [selectedRebaseBranch, setSelectedRebaseBranch] = useState(''); const processedDevServerLogs = useMemo(() => { if (!devServerDetails) return 'No output yet...'; @@ -296,6 +311,38 @@ function CurrentAttempt({ } }; + const handleRebaseWithNewBranch = async (newBaseBranch: string) => { + if (!projectId || !selectedAttempt?.id || !selectedAttempt?.task_id) return; + + try { + setRebasing(true); + await attemptsApi.rebase( + projectId, + selectedAttempt.task_id, + selectedAttempt.id, + newBaseBranch + ); + // Refresh branch status after rebase + fetchBranchStatus(); + setShowRebaseDialog(false); + } catch (err) { + setError('Failed to rebase branch'); + } finally { + setRebasing(false); + } + }; + + const handleRebaseDialogConfirm = () => { + if (selectedRebaseBranch) { + handleRebaseWithNewBranch(selectedRebaseBranch); + } + }; + + const handleRebaseDialogOpen = () => { + setSelectedRebaseBranch(''); + setShowRebaseDialog(true); + }; + const handleCreatePRClick = async () => { if (!projectId || !selectedAttempt?.id || !selectedAttempt?.task_id) return; @@ -355,8 +402,28 @@ function CurrentAttempt({
-
- Base Branch +
+ Base Branch + + + + + + +

Change base branch

+
+
+
@@ -592,6 +659,49 @@ function CurrentAttempt({ )}
+ + {/* Rebase Dialog */} + + + + Rebase Task Attempt + + Choose a new base branch to rebase this task attempt onto. + + + +
+
+ + +
+
+ + + + + +
+
); } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 6d3f3e0b..0ea7744a 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -373,12 +373,19 @@ export const attemptsApi = { rebase: async ( projectId: string, taskId: string, - attemptId: string + attemptId: string, + newBaseBranch?: string ): Promise => { const response = await makeRequest( `/api/projects/${projectId}/tasks/${taskId}/attempts/${attemptId}/rebase`, { method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + new_base_branch: newBaseBranch || null, + }), } ); return handleApiResponse(response);