From 47da9f63182a3d0a9978860c21f88668382ba151 Mon Sep 17 00:00:00 2001 From: Louis Knight-Webb Date: Thu, 19 Jun 2025 18:59:47 -0400 Subject: [PATCH] Add rebase functionality and update merge --- ...95ee772fbcec729bb5f58c5fefe8dadc1c460.json | 12 + backend/src/bin/generate_types.rs | 3 + backend/src/execution_monitor.rs | 142 +++++-- backend/src/executors/amp.rs | 2 +- backend/src/executors/echo.rs | 2 +- backend/src/models/task_attempt.rs | 359 +++++++++++++----- backend/src/routes/filesystem.rs | 6 +- backend/src/routes/tasks.rs | 65 +++- frontend/src/pages/task-attempt-compare.tsx | 124 +++++- shared/types.ts | 4 +- 10 files changed, 577 insertions(+), 142 deletions(-) create mode 100644 backend/.sqlx/query-501ae2ecc428a7de30055b1b2b195ee772fbcec729bb5f58c5fefe8dadc1c460.json diff --git a/backend/.sqlx/query-501ae2ecc428a7de30055b1b2b195ee772fbcec729bb5f58c5fefe8dadc1c460.json b/backend/.sqlx/query-501ae2ecc428a7de30055b1b2b195ee772fbcec729bb5f58c5fefe8dadc1c460.json new file mode 100644 index 00000000..8a218939 --- /dev/null +++ b/backend/.sqlx/query-501ae2ecc428a7de30055b1b2b195ee772fbcec729bb5f58c5fefe8dadc1c460.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE task_attempts SET base_commit = ?, updated_at = datetime('now') WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "501ae2ecc428a7de30055b1b2b195ee772fbcec729bb5f58c5fefe8dadc1c460" +} diff --git a/backend/src/bin/generate_types.rs b/backend/src/bin/generate_types.rs index 5643f394..ecafa4ea 100644 --- a/backend/src/bin/generate_types.rs +++ b/backend/src/bin/generate_types.rs @@ -68,6 +68,8 @@ export {} export {} +export {} + export {}"#, vibe_kanban::models::ApiResponse::<()>::decl(), vibe_kanban::models::config::Config::decl(), @@ -94,6 +96,7 @@ export {}"#, vibe_kanban::models::task_attempt::DiffChunk::decl(), vibe_kanban::models::task_attempt::FileDiff::decl(), vibe_kanban::models::task_attempt::WorktreeDiff::decl(), + vibe_kanban::models::task_attempt::BranchStatus::decl(), ); std::fs::write(shared_path.join("types.ts"), consolidated_content).unwrap(); diff --git a/backend/src/execution_monitor.rs b/backend/src/execution_monitor.rs index 199d8280..e784586e 100644 --- a/backend/src/execution_monitor.rs +++ b/backend/src/execution_monitor.rs @@ -1,4 +1,5 @@ use chrono::{DateTime, Utc}; +use git2::Repository; use std::collections::HashMap; use std::sync::Arc; use tokio::sync::Mutex; @@ -24,6 +25,65 @@ pub struct AppState { pub db_pool: sqlx::SqlitePool, } +/// Commit any unstaged changes in the worktree after execution completion +async fn commit_execution_changes( + worktree_path: &str, + attempt_id: Uuid, +) -> Result<(), Box> { + // Run git operations in a blocking task since git2 is synchronous + let worktree_path = worktree_path.to_string(); + tokio::task::spawn_blocking(move || { + let worktree_repo = Repository::open(&worktree_path)?; + + // 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) + }); + + if !has_changes { + return Ok::<(), Box>(()); + } + + // Get the current signature for commits + let signature = worktree_repo.signature()?; + + // Get the current HEAD commit + let head = worktree_repo.head()?; + let parent_commit = head.peel_to_commit()?; + + // 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); + worktree_repo.commit( + Some("HEAD"), + &signature, + &signature, + &commit_message, + &tree, + &[&parent_commit], + )?; + + Ok(()) + }) + .await??; + + Ok(()) +} + pub async fn execution_monitor(app_state: AppState) { let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(5)); @@ -155,38 +215,55 @@ pub async fn execution_monitor(app_state: AppState) { tracing::info!("Execution {} {}{}", execution_id, status_text, exit_text); - // Create task attempt activity with appropriate completion status - let activity_id = Uuid::new_v4(); - let status = if success { - TaskAttemptStatus::ExecutorComplete - } else { - TaskAttemptStatus::ExecutorFailed - }; - let create_activity = CreateTaskAttemptActivity { - task_attempt_id, - status: Some(status.clone()), - note: Some(format!("Execution completed{}", exit_text)), - }; - - if let Err(e) = TaskAttemptActivity::create( - &app_state.db_pool, - &create_activity, - activity_id, - status, - ) - .await + // Get task attempt to access worktree path for committing changes + if let Ok(Some(task_attempt)) = + TaskAttempt::find_by_id(&app_state.db_pool, task_attempt_id).await { - tracing::error!("Failed to create paused activity: {}", e); - } else { - tracing::info!( - "Task attempt {} set to paused after execution completion", - task_attempt_id - ); - - // Get task attempt and task to access task_id and project_id for status update - if let Ok(Some(task_attempt)) = - TaskAttempt::find_by_id(&app_state.db_pool, task_attempt_id).await + // Commit any unstaged changes after execution completion + if let Err(e) = + commit_execution_changes(&task_attempt.worktree_path, task_attempt_id).await { + tracing::error!( + "Failed to commit execution changes for attempt {}: {}", + task_attempt_id, + e + ); + } else { + tracing::info!( + "Successfully committed execution changes for attempt {}", + task_attempt_id + ); + } + + // Create task attempt activity with appropriate completion status + let activity_id = Uuid::new_v4(); + let status = if success { + TaskAttemptStatus::ExecutorComplete + } else { + TaskAttemptStatus::ExecutorFailed + }; + let create_activity = CreateTaskAttemptActivity { + task_attempt_id, + status: Some(status.clone()), + note: Some(format!("Execution completed{}", exit_text)), + }; + + if let Err(e) = TaskAttemptActivity::create( + &app_state.db_pool, + &create_activity, + activity_id, + status, + ) + .await + { + tracing::error!("Failed to create paused activity: {}", e); + } else { + tracing::info!( + "Task attempt {} set to paused after execution completion", + task_attempt_id + ); + + // Get task to access task_id and project_id for status update if let Ok(Some(task)) = Task::find_by_id(&app_state.db_pool, task_attempt.task_id).await { @@ -203,6 +280,11 @@ pub async fn execution_monitor(app_state: AppState) { } } } + } else { + tracing::error!( + "Failed to find task attempt {} for execution completion", + task_attempt_id + ); } } } diff --git a/backend/src/executors/amp.rs b/backend/src/executors/amp.rs index 53bad420..a8e5a91d 100644 --- a/backend/src/executors/amp.rs +++ b/backend/src/executors/amp.rs @@ -1,5 +1,5 @@ use async_trait::async_trait; -use tokio::process::{Child, Command}; +use tokio::process::Child; use uuid::Uuid; use crate::executor::{Executor, ExecutorError}; diff --git a/backend/src/executors/echo.rs b/backend/src/executors/echo.rs index d1543031..ee0ccb99 100644 --- a/backend/src/executors/echo.rs +++ b/backend/src/executors/echo.rs @@ -25,7 +25,7 @@ impl Executor for EchoExecutor { .await? .ok_or(ExecutorError::TaskNotFound)?; - let message = format!( + let _message = format!( "Executing task: {} - {}", task.title, task.description.as_deref().unwrap_or("No description") diff --git a/backend/src/models/task_attempt.rs b/backend/src/models/task_attempt.rs index 28769624..8b22ac81 100644 --- a/backend/src/models/task_attempt.rs +++ b/backend/src/models/task_attempt.rs @@ -1,5 +1,7 @@ +use anyhow::anyhow; use chrono::{DateTime, Utc}; -use git2::{Error as GitError, Repository}; +use git2::build::CheckoutBuilder; +use git2::{Error as GitError, MergeOptions, RebaseOptions, Repository}; use serde::{Deserialize, Serialize}; use sqlx::{FromRow, SqlitePool, Type}; use std::path::Path; @@ -16,6 +18,7 @@ pub enum TaskAttemptError { Git(GitError), TaskNotFound, ProjectNotFound, + GitOutOfSync(anyhow::Error), } impl std::fmt::Display for TaskAttemptError { @@ -25,6 +28,7 @@ impl std::fmt::Display for TaskAttemptError { TaskAttemptError::Git(e) => write!(f, "Git error: {}", e), TaskAttemptError::TaskNotFound => write!(f, "Task not found"), TaskAttemptError::ProjectNotFound => write!(f, "Project not found"), + TaskAttemptError::GitOutOfSync(e) => write!(f, "Git out of sync: {}", e), } } } @@ -120,6 +124,15 @@ pub struct WorktreeDiff { pub files: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct BranchStatus { + pub is_behind: bool, + pub commits_behind: usize, + pub commits_ahead: usize, + pub up_to_date: bool, +} + impl TaskAttempt { pub async fn find_by_id(pool: &SqlitePool, id: Uuid) -> Result, sqlx::Error> { sqlx::query_as!( @@ -302,49 +315,10 @@ impl TaskAttempt { // 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 + // Get the current HEAD commit in the worktree (changes should already be committed by execution monitor) 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], - )?; - } + let final_commit = parent_commit.id(); // Now we need to merge the worktree branch into the main repository let branch_name = format!("attempt-{}", attempt_id); @@ -642,7 +616,7 @@ impl TaskAttempt { Ok(()) } - /// Get the git diff between the base commit and the current worktree state + /// Get the git diff between the base commit and the current committed worktree state pub async fn get_diff( pool: &SqlitePool, attempt_id: Uuid, @@ -687,64 +661,267 @@ impl TaskAttempt { let base_commit = worktree_repo.find_commit(base_oid)?; let base_tree = base_commit.tree()?; - // Get status of all files in the worktree - let statuses = worktree_repo.statuses(None)?; + // Get the current HEAD commit in the worktree + let head = worktree_repo.head()?; + let current_commit = head.peel_to_commit()?; + let current_tree = current_commit.tree()?; + + // Create a diff between the base tree and current tree + let diff = worktree_repo.diff_tree_to_tree(Some(&base_tree), Some(¤t_tree), None)?; + let mut files = Vec::new(); - for status_entry in statuses.iter() { - if let Some(path_str) = status_entry.path() { - let path = std::path::Path::new(path_str); - let full_path = std::path::Path::new(&attempt.worktree_path).join(path); + // Process each diff delta (file change) + diff.foreach( + &mut |delta, _progress| { + if let Some(path_str) = delta.new_file().path().and_then(|p| p.to_str()) { + let old_file = delta.old_file(); + let new_file = delta.new_file(); - // Get old content from base commit - let old_content = match base_tree.get_path(path) { - Ok(entry) => match entry.to_object(&worktree_repo) { - Ok(obj) => { - if let Some(blob) = obj.as_blob() { - String::from_utf8_lossy(blob.content()).to_string() - } else { - String::new() - } + // Get old content + let old_content = if !old_file.id().is_zero() { + match worktree_repo.find_blob(old_file.id()) { + Ok(blob) => String::from_utf8_lossy(blob.content()).to_string(), + Err(_) => String::new(), } - Err(_) => String::new(), - }, - Err(_) => String::new(), // File didn't exist in base commit - }; + } else { + String::new() // File didn't exist in base commit + }; - // Get new content from working directory - let new_content = std::fs::read_to_string(&full_path).unwrap_or_default(); + // Get new content + let new_content = if !new_file.id().is_zero() { + match worktree_repo.find_blob(new_file.id()) { + Ok(blob) => String::from_utf8_lossy(blob.content()).to_string(), + Err(_) => String::new(), + } + } else { + String::new() // File was deleted + }; - // Generate diff chunks using dissimilar - if old_content != new_content { - let chunks = dissimilar::diff(&old_content, &new_content); - let mut diff_chunks = Vec::new(); + // Generate diff chunks using dissimilar + if old_content != new_content { + let chunks = dissimilar::diff(&old_content, &new_content); + let mut diff_chunks = Vec::new(); - for chunk in chunks { - let diff_chunk = match chunk { - dissimilar::Chunk::Equal(text) => DiffChunk { - chunk_type: DiffChunkType::Equal, - content: text.to_string(), - }, - dissimilar::Chunk::Delete(text) => DiffChunk { - chunk_type: DiffChunkType::Delete, - content: text.to_string(), - }, - dissimilar::Chunk::Insert(text) => DiffChunk { - chunk_type: DiffChunkType::Insert, - content: text.to_string(), - }, - }; - diff_chunks.push(diff_chunk); + for chunk in chunks { + let diff_chunk = match chunk { + dissimilar::Chunk::Equal(text) => DiffChunk { + chunk_type: DiffChunkType::Equal, + content: text.to_string(), + }, + dissimilar::Chunk::Delete(text) => DiffChunk { + chunk_type: DiffChunkType::Delete, + content: text.to_string(), + }, + dissimilar::Chunk::Insert(text) => DiffChunk { + chunk_type: DiffChunkType::Insert, + content: text.to_string(), + }, + }; + diff_chunks.push(diff_chunk); + } + + files.push(FileDiff { + path: path_str.to_string(), + chunks: diff_chunks, + }); } - - files.push(FileDiff { - path: path_str.to_string(), - chunks: diff_chunks, - }); } - } - } + true // Continue processing + }, + None, + None, + None, + )?; Ok(WorktreeDiff { files }) } + + /// Get the branch status for this task attempt (ahead/behind main) + pub async fn get_branch_status( + pool: &SqlitePool, + 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 as "id!: Uuid", ta.task_id as "task_id!: Uuid", ta.worktree_path, ta.base_commit, ta.merge_commit, ta.executor, ta.stdout, ta.stderr, ta.created_at as "created_at!: DateTime", ta.updated_at as "updated_at!: DateTime" + 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)?; + + let base_commit = git2::Oid::from_str(&attempt.base_commit.ok_or( + TaskAttemptError::GitOutOfSync(anyhow!("Base commit missing")), + )?)?; + + // Get the project + let project = Project::find_by_id(pool, project_id) + .await? + .ok_or(TaskAttemptError::ProjectNotFound)?; + + // Open the main repository + let main_repo = Repository::open(&project.git_repo_path)?; + + // Open the worktree repository + let worktree_repo = Repository::open(&attempt.worktree_path)?; + + // Get the current HEAD of main branch in the main repo + let main_head = main_repo.head()?.peel_to_commit()?; + let main_oid = main_head.id(); + + // Get the current HEAD of the worktree + let worktree_head = worktree_repo.head()?.peel_to_commit()?; + let worktree_oid = worktree_head.id(); + + if main_oid == base_commit { + // Branches are at the same commit + return Ok(BranchStatus { + is_behind: false, + commits_behind: 0, + commits_ahead: 0, + up_to_date: true, + }); + } + + // Count commits ahead/behind + let mut revwalk = main_repo.revwalk()?; + + // Count commits behind (main has commits that worktree doesn't) + revwalk.push(main_oid)?; + revwalk.hide(worktree_oid)?; + let commits_behind = revwalk.count(); + + // Count commits ahead (worktree has commits that main doesn't) + let mut revwalk = main_repo.revwalk()?; + revwalk.push(worktree_oid)?; + revwalk.hide(main_oid)?; + let commits_ahead = revwalk.count(); + + Ok(BranchStatus { + is_behind: commits_behind > 0, + commits_behind, + commits_ahead, + up_to_date: commits_behind == 0 && commits_ahead == 0, + }) + } + + /// Perform the actual git rebase operations (synchronous) + fn perform_rebase_operation( + worktree_path: &str, + main_repo_path: &str, + ) -> Result { + // Open both repos + let main_repo = Repository::open(main_repo_path)?; + let worktree_repo = Repository::open(worktree_path)?; + + // Figure out the new base + let main_head = main_repo.head()?.peel_to_commit()?; + let main_oid = main_head.id(); + let new_base = main_oid.to_string(); + + // If already up-to-date: + let worktree_oid = worktree_repo.head()?.peel_to_commit()?.id(); + if main_oid == worktree_oid { + return Ok(new_base); + } + + // Build an in-memory rebase + let mut rebase_opts = RebaseOptions::new(); + rebase_opts + .inmemory(true) // never touch the workdir :contentReference[oaicite:0]{index=0} + .merge_options(MergeOptions::new()); // defaults are fine here + + // (Optional) tweak checkout so it really refuses to write conflicts: + let mut co = CheckoutBuilder::new(); + co.safe() // safe-mode checkout (won’t overwrite) + .conflict_style_merge(false) // don’t write any merge markers :contentReference[oaicite:1]{index=1} + .skip_unmerged(true); // skip files with conflicts + rebase_opts.checkout_options(co); + + // Prepare the annotated commits + let main_annot = worktree_repo.find_annotated_commit(main_oid)?; + let wt_annot = worktree_repo.find_annotated_commit(worktree_oid)?; + + // Start the rebase in-memory + let mut rebase = worktree_repo.rebase( + Some(&wt_annot), // branch to rebase + None, // upstream (none = simple) + Some(&main_annot), // onto + Some(&mut rebase_opts), + )?; + + // Iterate operations + let sig = worktree_repo.signature()?; + loop { + match rebase.next() { + Some(Ok(op)) => { + // apply commit in-memory + let c = worktree_repo.find_commit(op.id())?; + rebase.commit(None, &sig, Some(c.message().map(|s| s).unwrap_or("")))?; + } + Some(Err(e)) => { + // conflict or other error → abort cleanly + rebase.abort()?; + return Err(TaskAttemptError::Git(e)); + } + None => break, + } + } + + // finish (still in-memory, so no tree magic happened) + rebase.finish(None)?; + Ok(new_base) + } + + /// Rebase the worktree branch onto main + pub async fn rebase_onto_main( + pool: &SqlitePool, + 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 as "id!: Uuid", ta.task_id as "task_id!: Uuid", ta.worktree_path, ta.base_commit, ta.merge_commit, ta.executor, ta.stdout, ta.stderr, ta.created_at as "created_at!: DateTime", ta.updated_at as "updated_at!: DateTime" + 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 project + let project = Project::find_by_id(pool, project_id) + .await? + .ok_or(TaskAttemptError::ProjectNotFound)?; + + // Perform the git rebase operations (synchronous) + let new_base_commit = + Self::perform_rebase_operation(&attempt.worktree_path, &project.git_repo_path)?; + + // Update the base_commit in the database + sqlx::query!( + "UPDATE task_attempts SET base_commit = ?, updated_at = datetime('now') WHERE id = ?", + new_base_commit, + attempt_id + ) + .execute(pool) + .await?; + + Ok(new_base_commit) + } } diff --git a/backend/src/routes/filesystem.rs b/backend/src/routes/filesystem.rs index 0fecdf7b..fb5b83ed 100644 --- a/backend/src/routes/filesystem.rs +++ b/backend/src/routes/filesystem.rs @@ -1,9 +1,5 @@ use axum::{ - extract::{Extension, Query}, - http::StatusCode, - response::Json as ResponseJson, - routing::get, - Json, Router, + extract::Query, http::StatusCode, response::Json as ResponseJson, routing::get, Router, }; use serde::{Deserialize, Serialize}; use std::fs; diff --git a/backend/src/routes/tasks.rs b/backend/src/routes/tasks.rs index 62f24dba..060c2511 100644 --- a/backend/src/routes/tasks.rs +++ b/backend/src/routes/tasks.rs @@ -11,7 +11,7 @@ use uuid::Uuid; use crate::models::{ project::Project, task::{CreateTask, Task, TaskStatus, TaskWithAttemptStatus, UpdateTask}, - task_attempt::{CreateTaskAttempt, TaskAttempt, TaskAttemptStatus, WorktreeDiff}, + task_attempt::{BranchStatus, CreateTaskAttempt, TaskAttempt, TaskAttemptStatus, WorktreeDiff}, task_attempt_activity::{CreateTaskAttemptActivity, TaskAttemptActivity}, ApiResponse, }; @@ -501,8 +501,61 @@ pub async fn open_task_attempt_in_editor( } } +pub async fn get_task_attempt_branch_status( + Path((project_id, task_id, attempt_id)): Path<(Uuid, Uuid, Uuid)>, + Extension(pool): Extension, +) -> Result>, StatusCode> { + match TaskAttempt::get_branch_status(&pool, attempt_id, task_id, project_id).await { + Ok(status) => Ok(ResponseJson(ApiResponse { + success: true, + data: Some(status), + message: None, + })), + Err(e) => { + tracing::error!( + "Failed to get branch status for task attempt {}: {}", + attempt_id, + e + ); + Err(StatusCode::INTERNAL_SERVER_ERROR) + } + } +} + +#[axum::debug_handler] +pub async fn rebase_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::rebase_onto_main(&pool, attempt_id, task_id, project_id).await { + Ok(_new_base_commit) => Ok(ResponseJson(ApiResponse { + success: true, + data: None, + message: Some("Branch rebased successfully".to_string()), + })), + Err(e) => { + tracing::error!("Failed to rebase task attempt {}: {}", attempt_id, e); + Ok(ResponseJson(ApiResponse { + success: false, + data: None, + message: Some(e.to_string()), + })) + } + } +} + pub fn tasks_router() -> Router { - use axum::routing::{delete, post, put}; + use axum::routing::post; Router::new() .route( @@ -533,6 +586,14 @@ pub fn tasks_router() -> Router { "/projects/:project_id/tasks/:task_id/attempts/:attempt_id/merge", post(merge_task_attempt), ) + .route( + "/projects/:project_id/tasks/:task_id/attempts/:attempt_id/branch-status", + get(get_task_attempt_branch_status), + ) + .route( + "/projects/:project_id/tasks/:task_id/attempts/:attempt_id/rebase", + post(rebase_task_attempt), + ) .route( "/projects/:project_id/tasks/:task_id/attempts/:attempt_id/open-editor", post(open_task_attempt_in_editor), diff --git a/frontend/src/pages/task-attempt-compare.tsx b/frontend/src/pages/task-attempt-compare.tsx index 9f003771..33b711a6 100644 --- a/frontend/src/pages/task-attempt-compare.tsx +++ b/frontend/src/pages/task-attempt-compare.tsx @@ -2,9 +2,9 @@ import { useState, useEffect } from "react"; import { useParams, useNavigate } from "react-router-dom"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; -import { ArrowLeft, FileText, ChevronDown, ChevronUp } from "lucide-react"; +import { ArrowLeft, FileText, ChevronDown, ChevronUp, RefreshCw, GitBranch } from "lucide-react"; import { makeRequest } from "@/lib/api"; -import type { WorktreeDiff, DiffChunkType, DiffChunk } from "shared/types"; +import type { WorktreeDiff, DiffChunkType, DiffChunk, BranchStatus } from "shared/types"; interface ApiResponse { success: boolean; @@ -21,15 +21,20 @@ export function TaskAttemptComparePage() { const navigate = useNavigate(); const [diff, setDiff] = useState(null); + const [branchStatus, setBranchStatus] = useState(null); const [loading, setLoading] = useState(true); + const [branchStatusLoading, setBranchStatusLoading] = useState(true); const [error, setError] = useState(null); const [merging, setMerging] = useState(false); + const [rebasing, setRebasing] = useState(false); const [mergeSuccess, setMergeSuccess] = useState(false); + const [rebaseSuccess, setRebaseSuccess] = useState(false); const [expandedSections, setExpandedSections] = useState>(new Set()); useEffect(() => { if (projectId && taskId && attemptId) { fetchDiff(); + fetchBranchStatus(); } }, [projectId, taskId, attemptId]); @@ -59,6 +64,32 @@ export function TaskAttemptComparePage() { } }; + const fetchBranchStatus = async () => { + if (!projectId || !taskId || !attemptId) return; + + try { + setBranchStatusLoading(true); + const response = await makeRequest( + `/api/projects/${projectId}/tasks/${taskId}/attempts/${attemptId}/branch-status` + ); + + if (response.ok) { + const result: ApiResponse = await response.json(); + if (result.success && result.data) { + setBranchStatus(result.data); + } else { + setError("Failed to load branch status"); + } + } else { + setError("Failed to load branch status"); + } + } catch (err) { + setError("Failed to load branch status"); + } finally { + setBranchStatusLoading(false); + } + }; + const handleBackClick = () => { navigate(`/projects/${projectId}/tasks/${taskId}`); }; @@ -94,6 +125,38 @@ export function TaskAttemptComparePage() { } }; + const handleRebaseClick = async () => { + if (!projectId || !taskId || !attemptId) return; + + try { + setRebasing(true); + const response = await makeRequest( + `/api/projects/${projectId}/tasks/${taskId}/attempts/${attemptId}/rebase`, + { + method: 'POST', + } + ); + + if (response.ok) { + const result: ApiResponse = await response.json(); + if (result.success) { + setRebaseSuccess(true); + // Refresh both diff and branch status after rebase + fetchDiff(); + fetchBranchStatus(); + } else { + setError(result.message || "Failed to rebase branch"); + } + } else { + setError("Failed to rebase branch"); + } + } catch (err) { + setError("Failed to rebase branch"); + } finally { + setRebasing(false); + } + }; + const getChunkClassName = (chunkType: DiffChunkType) => { const baseClass = "font-mono text-sm whitespace-pre px-3 py-1"; @@ -255,7 +318,7 @@ export function TaskAttemptComparePage() { }); }; - if (loading) { + if (loading || branchStatusLoading) { return (
@@ -293,19 +356,58 @@ export function TaskAttemptComparePage() { Compare Changes
-
+
+ {/* Branch Status */} + {!branchStatusLoading && branchStatus && ( +
+ + {branchStatus.up_to_date ? ( + Up to date + ) : branchStatus.is_behind === true ? ( + + {branchStatus.commits_behind} commit{branchStatus.commits_behind !== 1 ? 's' : ''} behind main + + ) : ( + + {branchStatus.commits_ahead} commit{branchStatus.commits_ahead !== 1 ? 's' : ''} ahead of main + + )} +
+ )} + + {/* Success Messages */} + {rebaseSuccess && ( +
+ Branch rebased successfully! +
+ )} {mergeSuccess && (
Changes merged successfully!
)} - + + {/* Action Buttons */} +
+ {branchStatus && branchStatus.is_behind === true && ( + + )} + +
diff --git a/shared/types.ts b/shared/types.ts index a2c1e176..7b983f2f 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -49,4 +49,6 @@ export type DiffChunk = { chunk_type: DiffChunkType, content: string, }; export type FileDiff = { path: string, chunks: Array, }; -export type WorktreeDiff = { files: Array, }; \ No newline at end of file +export type WorktreeDiff = { files: Array, }; + +export type BranchStatus = { is_behind: boolean, commits_behind: number, commits_ahead: number, up_to_date: boolean, }; \ No newline at end of file