diff --git a/backend/src/bin/generate_types.rs b/backend/src/bin/generate_types.rs index a3928799..0f2cf665 100644 --- a/backend/src/bin/generate_types.rs +++ b/backend/src/bin/generate_types.rs @@ -89,6 +89,7 @@ fn main() { vibe_kanban::models::project::UpdateProject::decl(), vibe_kanban::models::project::SearchResult::decl(), vibe_kanban::models::project::SearchMatchType::decl(), + vibe_kanban::models::project::GitBranch::decl(), vibe_kanban::models::task::CreateTask::decl(), vibe_kanban::models::task::CreateTaskAndStart::decl(), vibe_kanban::models::task::TaskStatus::decl(), diff --git a/backend/src/models/project.rs b/backend/src/models/project.rs index 121a1a7f..3cf66ad5 100644 --- a/backend/src/models/project.rs +++ b/backend/src/models/project.rs @@ -1,5 +1,5 @@ use chrono::{DateTime, Utc}; -use git2::Repository; +use git2::{BranchType, Repository}; use serde::{Deserialize, Serialize}; use sqlx::{FromRow, SqlitePool}; use ts_rs::TS; @@ -71,6 +71,14 @@ pub enum SearchMatchType { FullPath, } +#[derive(Debug, Serialize, TS)] +#[ts(export)] +pub struct GitBranch { + pub name: String, + pub is_current: bool, + pub is_remote: bool, +} + impl Project { pub async fn find_all(pool: &SqlitePool) -> Result, sqlx::Error> { sqlx::query_as!( @@ -205,4 +213,56 @@ impl Project { updated_at: self.updated_at, } } + + pub fn get_all_branches(&self) -> Result, git2::Error> { + let repo = Repository::open(&self.git_repo_path)?; + let current_branch = self.get_current_branch().unwrap_or_default(); + let mut branches = Vec::new(); + + // Get local branches + let local_branches = repo.branches(Some(BranchType::Local))?; + for branch_result in local_branches { + let (branch, _) = branch_result?; + if let Some(name) = branch.name()? { + branches.push(GitBranch { + name: name.to_string(), + is_current: name == current_branch, + is_remote: false, + }); + } + } + + // Get remote branches + let remote_branches = repo.branches(Some(BranchType::Remote))?; + for branch_result in remote_branches { + let (branch, _) = branch_result?; + if let Some(name) = branch.name()? { + // Skip remote HEAD references + if !name.ends_with("/HEAD") { + branches.push(GitBranch { + name: name.to_string(), + is_current: false, + is_remote: true, + }); + } + } + } + + // Sort branches: current first, then local, then remote + branches.sort_by(|a, b| { + if a.is_current && !b.is_current { + std::cmp::Ordering::Less + } else if !a.is_current && b.is_current { + std::cmp::Ordering::Greater + } else if !a.is_remote && b.is_remote { + std::cmp::Ordering::Less + } else if a.is_remote && !b.is_remote { + std::cmp::Ordering::Greater + } else { + a.name.cmp(&b.name) + } + }); + + Ok(branches) + } } diff --git a/backend/src/models/task_attempt.rs b/backend/src/models/task_attempt.rs index 46b14352..b69f9872 100644 --- a/backend/src/models/task_attempt.rs +++ b/backend/src/models/task_attempt.rs @@ -2,7 +2,8 @@ use std::path::Path; use chrono::{DateTime, Utc}; use git2::{ - build::CheckoutBuilder, Error as GitError, MergeOptions, Oid, RebaseOptions, Repository, + build::CheckoutBuilder, Error as GitError, MergeOptions, Oid, RebaseOptions, Reference, + Repository, WorktreeAddOptions, }; use serde::{Deserialize, Serialize}; use sqlx::{FromRow, SqlitePool, Type}; @@ -20,6 +21,7 @@ pub enum TaskAttemptError { TaskNotFound, ProjectNotFound, ValidationError(String), + BranchNotFound(String), } impl std::fmt::Display for TaskAttemptError { @@ -30,6 +32,7 @@ impl std::fmt::Display for TaskAttemptError { TaskAttemptError::TaskNotFound => write!(f, "Task not found"), TaskAttemptError::ProjectNotFound => write!(f, "Project not found"), TaskAttemptError::ValidationError(e) => write!(f, "Validation error: {}", e), + TaskAttemptError::BranchNotFound(e) => write!(f, "Branch not found: {}", e), } } } @@ -77,6 +80,7 @@ pub struct TaskAttempt { #[ts(export)] pub struct CreateTaskAttempt { pub executor: Option, // Optional executor name (defaults to "echo") + pub base_branch: Option, // Optional base branch to checkout (defaults to current HEAD) } #[derive(Debug, Deserialize, TS)] @@ -163,9 +167,11 @@ impl TaskAttempt { pub async fn create( pool: &SqlitePool, data: &CreateTaskAttempt, - attempt_id: Uuid, task_id: Uuid, ) -> Result { + let attempt_id = Uuid::new_v4(); + let prefixed_id = format!("vibe-kanban-{}", attempt_id); + // First, get the task to get the project_id let task = Task::find_by_id(pool, task_id) .await? @@ -178,24 +184,55 @@ impl TaskAttempt { // Generate worktree path automatically using cross-platform temporary directory let temp_dir = std::env::temp_dir(); - let worktree_path = temp_dir.join(format!("mission-control-worktree-{}", attempt_id)); + let worktree_path = temp_dir.join(&prefixed_id); let worktree_path_str = worktree_path.to_string_lossy().to_string(); - // Create the worktree using git2 - let repo = Repository::open(&project.git_repo_path)?; + // Solve scoping issues + { + // Create the worktree using git2 + let repo = Repository::open(&project.git_repo_path)?; - // We no longer store base_commit in the database - it's retrieved live via git2 + let mut worktree_opts = WorktreeAddOptions::new(); + let new_base_ref: Reference; - // Create the worktree directory if it doesn't exist - if let Some(parent) = worktree_path.parent() { - std::fs::create_dir_all(parent) - .map_err(|e| TaskAttemptError::Git(GitError::from_str(&e.to_string())))?; + if let Some(base_branch) = data.base_branch.clone() { + let base_ref = Some(str::trim(base_branch.as_str())) // chop off any whitespace + .filter(|b| !b.is_empty()) // ditch empty strings + .and_then(|branch| { + // pick the right ref name + let candidate = if branch.starts_with("origin/") || branch.contains('/') { + format!("refs/remotes/{}", branch) + } else { + let local = format!("refs/heads/{}", branch); + if repo.find_reference(&local).is_ok() { + local + } else { + format!("refs/remotes/origin/{}", branch) + } + }; + // try to look it up, turning Ok(r) → Some(r), Err(_) → None + repo.find_reference(&candidate).ok() + }) + .ok_or(TaskAttemptError::BranchNotFound(base_branch))?; + + let target_commit = base_ref.peel_to_commit()?; + repo.branch(&prefixed_id, &target_commit, false)?; + new_base_ref = repo.find_reference(&format!("refs/heads/{}", prefixed_id))?; + + worktree_opts.reference(Some(&new_base_ref)); + } + + // Create the worktree directory if it doesn't exist + if let Some(parent) = worktree_path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| TaskAttemptError::Git(GitError::from_str(&e.to_string())))?; + } + + // Create the worktree at the specified path + let branch_name = format!("attempt-{}", attempt_id); + repo.worktree(&branch_name, &worktree_path, Some(&worktree_opts))?; } - // Create the worktree at the specified path - let branch_name = format!("attempt-{}", attempt_id); - repo.worktree(&branch_name, &worktree_path, None)?; - // Insert the record into the database Ok(sqlx::query_as!( TaskAttempt, diff --git a/backend/src/routes/projects.rs b/backend/src/routes/projects.rs index 26772a44..386470f0 100644 --- a/backend/src/routes/projects.rs +++ b/backend/src/routes/projects.rs @@ -12,7 +12,8 @@ use uuid::Uuid; use crate::models::{ project::{ - CreateProject, Project, ProjectWithBranch, SearchMatchType, SearchResult, UpdateProject, + CreateProject, GitBranch, Project, ProjectWithBranch, SearchMatchType, SearchResult, + UpdateProject, }, ApiResponse, }; @@ -69,6 +70,30 @@ pub async fn get_project_with_branch( } } +pub async fn get_project_branches( + Path(id): Path, + Extension(pool): Extension, +) -> Result>>, StatusCode> { + match Project::find_by_id(&pool, id).await { + Ok(Some(project)) => match project.get_all_branches() { + Ok(branches) => Ok(ResponseJson(ApiResponse { + success: true, + data: Some(branches), + message: None, + })), + Err(e) => { + tracing::error!("Failed to get branches for project {}: {}", id, e); + Err(StatusCode::INTERNAL_SERVER_ERROR) + } + }, + Ok(None) => Err(StatusCode::NOT_FOUND), + Err(e) => { + tracing::error!("Failed to fetch project: {}", e); + Err(StatusCode::INTERNAL_SERVER_ERROR) + } + } +} + pub async fn create_project( Extension(pool): Extension, Json(payload): Json, @@ -411,5 +436,6 @@ pub fn projects_router() -> Router { get(get_project).put(update_project).delete(delete_project), ) .route("/projects/:id/with-branch", get(get_project_with_branch)) + .route("/projects/:id/branches", get(get_project_branches)) .route("/projects/:id/search", get(search_project_files)) } diff --git a/backend/src/routes/task_attempts.rs b/backend/src/routes/task_attempts.rs index fbb49d27..3548af93 100644 --- a/backend/src/routes/task_attempts.rs +++ b/backend/src/routes/task_attempts.rs @@ -99,9 +99,7 @@ pub async fn create_task_attempt( Ok(true) => {} } - let id = Uuid::new_v4(); - - match TaskAttempt::create(&pool, &payload, id, task_id).await { + match TaskAttempt::create(&pool, &payload, task_id).await { Ok(attempt) => { // Start execution asynchronously (don't block the response) let pool_clone = pool.clone(); diff --git a/backend/src/routes/tasks.rs b/backend/src/routes/tasks.rs index dc2db7b5..2961ee15 100644 --- a/backend/src/routes/tasks.rs +++ b/backend/src/routes/tasks.rs @@ -136,7 +136,6 @@ pub async fn create_task_and_start( }; // Create task attempt - let attempt_id = Uuid::new_v4(); let executor_string = payload.executor.as_ref().map(|exec| match exec { crate::executor::ExecutorConfig::Echo => "echo".to_string(), crate::executor::ExecutorConfig::Claude => "claude".to_string(), @@ -146,9 +145,10 @@ pub async fn create_task_and_start( }); let attempt_payload = CreateTaskAttempt { executor: executor_string, + base_branch: None, // Not supported in task creation endpoint, only in task attempts }; - match TaskAttempt::create(&pool, &attempt_payload, attempt_id, task_id).await { + match TaskAttempt::create(&pool, &attempt_payload, task_id).await { Ok(attempt) => { // Start execution asynchronously (don't block the response) let pool_clone = pool.clone(); diff --git a/frontend/src/components/tasks/TaskDetailsPanel.tsx b/frontend/src/components/tasks/TaskDetailsPanel.tsx index 07298d84..bb470c2e 100644 --- a/frontend/src/components/tasks/TaskDetailsPanel.tsx +++ b/frontend/src/components/tasks/TaskDetailsPanel.tsx @@ -49,6 +49,8 @@ export function TaskDetailsPanel({ followUpError, isStartingDevServer, devServerDetails, + branches, + selectedBranch, runningDevServer, isAttemptRunning, @@ -58,6 +60,7 @@ export function TaskDetailsPanel({ setFollowUpMessage, setFollowUpError, setIsHoveringDevServer, + setSelectedBranch, handleAttemptChange, createNewAttempt, stopAllExecutions, @@ -150,10 +153,13 @@ export function TaskDetailsPanel({ isStartingDevServer={isStartingDevServer} devServerDetails={devServerDetails} processedDevServerLogs={processedDevServerLogs} + branches={branches} + selectedBranch={selectedBranch} onAttemptChange={handleAttemptChange} onCreateNewAttempt={createNewAttempt} onStopAllExecutions={stopAllExecutions} onSetSelectedExecutor={setSelectedExecutor} + onSetSelectedBranch={setSelectedBranch} onStartDevServer={startDevServer} onStopDevServer={stopDevServer} onOpenInEditor={handleOpenInEditor} diff --git a/frontend/src/components/tasks/TaskDetailsToolbar.tsx b/frontend/src/components/tasks/TaskDetailsToolbar.tsx index cb2fac7a..e8525c74 100644 --- a/frontend/src/components/tasks/TaskDetailsToolbar.tsx +++ b/frontend/src/components/tasks/TaskDetailsToolbar.tsx @@ -6,6 +6,7 @@ import { Play, GitCompare, ExternalLink, + GitBranch as GitBranchIcon, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { @@ -27,6 +28,7 @@ import type { ExecutionProcessSummary, ExecutionProcess, Project, + GitBranch, } from 'shared/types'; interface TaskDetailsToolbarProps { @@ -42,10 +44,13 @@ interface TaskDetailsToolbarProps { isStartingDevServer: boolean; devServerDetails: ExecutionProcess | null; processedDevServerLogs: string; + branches: GitBranch[]; + selectedBranch: string | null; onAttemptChange: (attemptId: string) => void; - onCreateNewAttempt: (executor?: string) => void; + onCreateNewAttempt: (executor?: string, baseBranch?: string) => void; onStopAllExecutions: () => void; onSetSelectedExecutor: (executor: string) => void; + onSetSelectedBranch: (branch: string) => void; onStartDevServer: () => void; onStopDevServer: () => void; onOpenInEditor: () => void; @@ -73,10 +78,13 @@ export function TaskDetailsToolbar({ isStartingDevServer, devServerDetails, processedDevServerLogs, + branches, + selectedBranch, onAttemptChange, onCreateNewAttempt, onStopAllExecutions, onSetSelectedExecutor, + onSetSelectedBranch, onStartDevServer, onStopDevServer, onOpenInEditor, @@ -183,7 +191,12 @@ export function TaskDetailsToolbar({ + + + +

Choose base branch: {selectedBranch || 'current'}

+
+ + + + {branches.map((branch) => ( + onSetSelectedBranch(branch.name)} + className={ + selectedBranch === branch.name ? 'bg-accent' : '' + } + > +
+ + {branch.name} + +
+ {branch.is_current && ( + + current + + )} + {branch.is_remote && ( + + remote + + )} +
+
+
+ ))} +
+ @@ -213,7 +277,7 @@ export function TaskDetailsToolbar({ -

Choose executor

+

Choose executor: {selectedExecutor}

diff --git a/frontend/src/hooks/useTaskDetails.ts b/frontend/src/hooks/useTaskDetails.ts index 73391c2c..2bcfa417 100644 --- a/frontend/src/hooks/useTaskDetails.ts +++ b/frontend/src/hooks/useTaskDetails.ts @@ -9,6 +9,7 @@ import type { ExecutionProcess, ExecutionProcessSummary, EditorType, + GitBranch, } from 'shared/types'; export function useTaskDetails( @@ -39,6 +40,8 @@ export function useTaskDetails( const [devServerDetails, setDevServerDetails] = useState(null); const [isHoveringDevServer, setIsHoveringDevServer] = useState(false); + const [branches, setBranches] = useState([]); + const [selectedBranch, setSelectedBranch] = useState(null); const { config } = useConfig(); @@ -242,6 +245,26 @@ export function useTaskDetails( } }, [runningDevServer, task, selectedAttempt, projectId]); + // Fetch project branches + const fetchProjectBranches = useCallback(async () => { + try { + const response = await makeRequest(`/api/projects/${projectId}/branches`); + if (response.ok) { + const result: ApiResponse = await response.json(); + if (result.success && result.data) { + setBranches(result.data); + // Set current branch as default + const currentBranch = result.data.find((b) => b.is_current); + if (currentBranch && !selectedBranch) { + setSelectedBranch(currentBranch.name); + } + } + } + } catch (err) { + console.error('Failed to fetch project branches:', err); + } + }, [projectId, selectedBranch]); + // Set default executor from config useEffect(() => { if (config) { @@ -252,8 +275,9 @@ export function useTaskDetails( useEffect(() => { if (task && isOpen) { fetchTaskAttempts(); + fetchProjectBranches(); } - }, [task, isOpen, fetchTaskAttempts]); + }, [task, isOpen, fetchTaskAttempts, fetchProjectBranches]); // Polling for updates when attempt is running useEffect(() => { @@ -288,7 +312,7 @@ export function useTaskDetails( } }; - const createNewAttempt = async (executor?: string) => { + const createNewAttempt = async (executor?: string, baseBranch?: string) => { if (!task) return; try { @@ -301,6 +325,7 @@ export function useTaskDetails( }, body: JSON.stringify({ executor: executor || selectedExecutor, + base_branch: baseBranch || selectedBranch, }), } ); @@ -482,6 +507,8 @@ export function useTaskDetails( isStartingDevServer, devServerDetails, isHoveringDevServer, + branches, + selectedBranch, // Computed runningDevServer, @@ -494,6 +521,7 @@ export function useTaskDetails( setFollowUpMessage, setFollowUpError, setIsHoveringDevServer, + setSelectedBranch, handleAttemptChange, createNewAttempt, stopAllExecutions, diff --git a/shared/types.ts b/shared/types.ts index e7cb5ecf..ce105fd9 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -36,6 +36,8 @@ export type SearchResult = { path: string, is_file: boolean, match_type: SearchM export type SearchMatchType = "FileName" | "DirectoryName" | "FullPath"; +export type GitBranch = { name: string, is_current: boolean, is_remote: boolean, }; + export type CreateTask = { project_id: string, title: string, description: string | null, }; export type CreateTaskAndStart = { project_id: string, title: string, description: string | null, executor: ExecutorConfig | null, }; @@ -52,7 +54,7 @@ export type TaskAttemptStatus = "setuprunning" | "setupcomplete" | "setupfailed" export type TaskAttempt = { id: string, task_id: string, worktree_path: string, merge_commit: string | null, executor: string | null, created_at: string, updated_at: string, }; -export type CreateTaskAttempt = { executor: string | null, }; +export type CreateTaskAttempt = { executor: string | null, base_branch: string | null, }; export type UpdateTaskAttempt = Record;