Add rebase functionality and update merge

This commit is contained in:
Louis Knight-Webb
2025-06-19 18:59:47 -04:00
parent f069270c69
commit 47da9f6318
10 changed files with 577 additions and 142 deletions

View File

@@ -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"
}

View File

@@ -68,6 +68,8 @@ export {}
export {} export {}
export {}
export {}"#, export {}"#,
vibe_kanban::models::ApiResponse::<()>::decl(), vibe_kanban::models::ApiResponse::<()>::decl(),
vibe_kanban::models::config::Config::decl(), vibe_kanban::models::config::Config::decl(),
@@ -94,6 +96,7 @@ export {}"#,
vibe_kanban::models::task_attempt::DiffChunk::decl(), vibe_kanban::models::task_attempt::DiffChunk::decl(),
vibe_kanban::models::task_attempt::FileDiff::decl(), vibe_kanban::models::task_attempt::FileDiff::decl(),
vibe_kanban::models::task_attempt::WorktreeDiff::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(); std::fs::write(shared_path.join("types.ts"), consolidated_content).unwrap();

View File

@@ -1,4 +1,5 @@
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use git2::Repository;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::Mutex; use tokio::sync::Mutex;
@@ -24,6 +25,65 @@ pub struct AppState {
pub db_pool: sqlx::SqlitePool, 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<dyn std::error::Error + Send + Sync>> {
// 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<dyn std::error::Error + Send + Sync>>(());
}
// 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) { pub async fn execution_monitor(app_state: AppState) {
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(5)); 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); tracing::info!("Execution {} {}{}", execution_id, status_text, exit_text);
// Create task attempt activity with appropriate completion status // Get task attempt to access worktree path for committing changes
let activity_id = Uuid::new_v4(); if let Ok(Some(task_attempt)) =
let status = if success { TaskAttempt::find_by_id(&app_state.db_pool, task_attempt_id).await
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); // Commit any unstaged changes after execution completion
} else { if let Err(e) =
tracing::info!( commit_execution_changes(&task_attempt.worktree_path, task_attempt_id).await
"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
{ {
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)) = if let Ok(Some(task)) =
Task::find_by_id(&app_state.db_pool, task_attempt.task_id).await 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
);
} }
} }
} }

View File

@@ -1,5 +1,5 @@
use async_trait::async_trait; use async_trait::async_trait;
use tokio::process::{Child, Command}; use tokio::process::Child;
use uuid::Uuid; use uuid::Uuid;
use crate::executor::{Executor, ExecutorError}; use crate::executor::{Executor, ExecutorError};

View File

@@ -25,7 +25,7 @@ impl Executor for EchoExecutor {
.await? .await?
.ok_or(ExecutorError::TaskNotFound)?; .ok_or(ExecutorError::TaskNotFound)?;
let message = format!( let _message = format!(
"Executing task: {} - {}", "Executing task: {} - {}",
task.title, task.title,
task.description.as_deref().unwrap_or("No description") task.description.as_deref().unwrap_or("No description")

View File

@@ -1,5 +1,7 @@
use anyhow::anyhow;
use chrono::{DateTime, Utc}; 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 serde::{Deserialize, Serialize};
use sqlx::{FromRow, SqlitePool, Type}; use sqlx::{FromRow, SqlitePool, Type};
use std::path::Path; use std::path::Path;
@@ -16,6 +18,7 @@ pub enum TaskAttemptError {
Git(GitError), Git(GitError),
TaskNotFound, TaskNotFound,
ProjectNotFound, ProjectNotFound,
GitOutOfSync(anyhow::Error),
} }
impl std::fmt::Display for TaskAttemptError { 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::Git(e) => write!(f, "Git error: {}", e),
TaskAttemptError::TaskNotFound => write!(f, "Task not found"), TaskAttemptError::TaskNotFound => write!(f, "Task not found"),
TaskAttemptError::ProjectNotFound => write!(f, "Project 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<FileDiff>, pub files: Vec<FileDiff>,
} }
#[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 { impl TaskAttempt {
pub async fn find_by_id(pool: &SqlitePool, id: Uuid) -> Result<Option<Self>, sqlx::Error> { pub async fn find_by_id(pool: &SqlitePool, id: Uuid) -> Result<Option<Self>, sqlx::Error> {
sqlx::query_as!( sqlx::query_as!(
@@ -302,49 +315,10 @@ impl TaskAttempt {
// Get the current signature for commits // Get the current signature for commits
let signature = main_repo.signature()?; let signature = main_repo.signature()?;
// First, commit any uncommitted changes in the worktree // Get the current HEAD commit in the worktree (changes should already be committed by execution monitor)
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 head = worktree_repo.head()?;
let parent_commit = head.peel_to_commit()?; let parent_commit = head.peel_to_commit()?;
let final_commit = parent_commit.id();
// 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 // Now we need to merge the worktree branch into the main repository
let branch_name = format!("attempt-{}", attempt_id); let branch_name = format!("attempt-{}", attempt_id);
@@ -642,7 +616,7 @@ impl TaskAttempt {
Ok(()) 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( pub async fn get_diff(
pool: &SqlitePool, pool: &SqlitePool,
attempt_id: Uuid, attempt_id: Uuid,
@@ -687,64 +661,267 @@ impl TaskAttempt {
let base_commit = worktree_repo.find_commit(base_oid)?; let base_commit = worktree_repo.find_commit(base_oid)?;
let base_tree = base_commit.tree()?; let base_tree = base_commit.tree()?;
// Get status of all files in the worktree // Get the current HEAD commit in the worktree
let statuses = worktree_repo.statuses(None)?; 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(&current_tree), None)?;
let mut files = Vec::new(); let mut files = Vec::new();
for status_entry in statuses.iter() { // Process each diff delta (file change)
if let Some(path_str) = status_entry.path() { diff.foreach(
let path = std::path::Path::new(path_str); &mut |delta, _progress| {
let full_path = std::path::Path::new(&attempt.worktree_path).join(path); 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 // Get old content
let old_content = match base_tree.get_path(path) { let old_content = if !old_file.id().is_zero() {
Ok(entry) => match entry.to_object(&worktree_repo) { match worktree_repo.find_blob(old_file.id()) {
Ok(obj) => { Ok(blob) => String::from_utf8_lossy(blob.content()).to_string(),
if let Some(blob) = obj.as_blob() { Err(_) => String::new(),
String::from_utf8_lossy(blob.content()).to_string()
} else {
String::new()
}
} }
Err(_) => String::new(), } else {
}, String::new() // File didn't exist in base commit
Err(_) => String::new(), // File didn't exist in base commit };
};
// Get new content from working directory // Get new content
let new_content = std::fs::read_to_string(&full_path).unwrap_or_default(); 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 // Generate diff chunks using dissimilar
if old_content != new_content { if old_content != new_content {
let chunks = dissimilar::diff(&old_content, &new_content); let chunks = dissimilar::diff(&old_content, &new_content);
let mut diff_chunks = Vec::new(); let mut diff_chunks = Vec::new();
for chunk in chunks { for chunk in chunks {
let diff_chunk = match chunk { let diff_chunk = match chunk {
dissimilar::Chunk::Equal(text) => DiffChunk { dissimilar::Chunk::Equal(text) => DiffChunk {
chunk_type: DiffChunkType::Equal, chunk_type: DiffChunkType::Equal,
content: text.to_string(), content: text.to_string(),
}, },
dissimilar::Chunk::Delete(text) => DiffChunk { dissimilar::Chunk::Delete(text) => DiffChunk {
chunk_type: DiffChunkType::Delete, chunk_type: DiffChunkType::Delete,
content: text.to_string(), content: text.to_string(),
}, },
dissimilar::Chunk::Insert(text) => DiffChunk { dissimilar::Chunk::Insert(text) => DiffChunk {
chunk_type: DiffChunkType::Insert, chunk_type: DiffChunkType::Insert,
content: text.to_string(), content: text.to_string(),
}, },
}; };
diff_chunks.push(diff_chunk); 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 }) 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<BranchStatus, TaskAttemptError> {
// 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<Utc>", ta.updated_at as "updated_at!: DateTime<Utc>"
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<String, TaskAttemptError> {
// 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 (wont overwrite)
.conflict_style_merge(false) // dont 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<String, TaskAttemptError> {
// 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<Utc>", ta.updated_at as "updated_at!: DateTime<Utc>"
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)
}
} }

View File

@@ -1,9 +1,5 @@
use axum::{ use axum::{
extract::{Extension, Query}, extract::Query, http::StatusCode, response::Json as ResponseJson, routing::get, Router,
http::StatusCode,
response::Json as ResponseJson,
routing::get,
Json, Router,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::fs; use std::fs;

View File

@@ -11,7 +11,7 @@ use uuid::Uuid;
use crate::models::{ use crate::models::{
project::Project, project::Project,
task::{CreateTask, Task, TaskStatus, TaskWithAttemptStatus, UpdateTask}, task::{CreateTask, Task, TaskStatus, TaskWithAttemptStatus, UpdateTask},
task_attempt::{CreateTaskAttempt, TaskAttempt, TaskAttemptStatus, WorktreeDiff}, task_attempt::{BranchStatus, CreateTaskAttempt, TaskAttempt, TaskAttemptStatus, WorktreeDiff},
task_attempt_activity::{CreateTaskAttemptActivity, TaskAttemptActivity}, task_attempt_activity::{CreateTaskAttemptActivity, TaskAttemptActivity},
ApiResponse, 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<SqlitePool>,
) -> Result<ResponseJson<ApiResponse<BranchStatus>>, 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<SqlitePool>,
) -> 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::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 { pub fn tasks_router() -> Router {
use axum::routing::{delete, post, put}; use axum::routing::post;
Router::new() Router::new()
.route( .route(
@@ -533,6 +586,14 @@ pub fn tasks_router() -> Router {
"/projects/:project_id/tasks/:task_id/attempts/:attempt_id/merge", "/projects/:project_id/tasks/:task_id/attempts/:attempt_id/merge",
post(merge_task_attempt), 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( .route(
"/projects/:project_id/tasks/:task_id/attempts/:attempt_id/open-editor", "/projects/:project_id/tasks/:task_id/attempts/:attempt_id/open-editor",
post(open_task_attempt_in_editor), post(open_task_attempt_in_editor),

View File

@@ -2,9 +2,9 @@ import { useState, useEffect } from "react";
import { useParams, useNavigate } from "react-router-dom"; import { useParams, useNavigate } from "react-router-dom";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; 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 { makeRequest } from "@/lib/api";
import type { WorktreeDiff, DiffChunkType, DiffChunk } from "shared/types"; import type { WorktreeDiff, DiffChunkType, DiffChunk, BranchStatus } from "shared/types";
interface ApiResponse<T> { interface ApiResponse<T> {
success: boolean; success: boolean;
@@ -21,15 +21,20 @@ export function TaskAttemptComparePage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [diff, setDiff] = useState<WorktreeDiff | null>(null); const [diff, setDiff] = useState<WorktreeDiff | null>(null);
const [branchStatus, setBranchStatus] = useState<BranchStatus | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [branchStatusLoading, setBranchStatusLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [merging, setMerging] = useState(false); const [merging, setMerging] = useState(false);
const [rebasing, setRebasing] = useState(false);
const [mergeSuccess, setMergeSuccess] = useState(false); const [mergeSuccess, setMergeSuccess] = useState(false);
const [rebaseSuccess, setRebaseSuccess] = useState(false);
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set()); const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set());
useEffect(() => { useEffect(() => {
if (projectId && taskId && attemptId) { if (projectId && taskId && attemptId) {
fetchDiff(); fetchDiff();
fetchBranchStatus();
} }
}, [projectId, taskId, attemptId]); }, [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<BranchStatus> = 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 = () => { const handleBackClick = () => {
navigate(`/projects/${projectId}/tasks/${taskId}`); 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<string> = 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 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";
@@ -255,7 +318,7 @@ export function TaskAttemptComparePage() {
}); });
}; };
if (loading) { if (loading || branchStatusLoading) {
return ( return (
<div className="min-h-screen bg-background flex items-center justify-center"> <div className="min-h-screen bg-background flex items-center justify-center">
<div className="text-center"> <div className="text-center">
@@ -293,19 +356,58 @@ export function TaskAttemptComparePage() {
Compare Changes Compare Changes
</h1> </h1>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-4">
{/* Branch Status */}
{!branchStatusLoading && branchStatus && (
<div className="flex items-center gap-2 text-sm">
<GitBranch className="h-4 w-4" />
{branchStatus.up_to_date ? (
<span className="text-green-600">Up to date</span>
) : branchStatus.is_behind === true ? (
<span className="text-orange-600">
{branchStatus.commits_behind} commit{branchStatus.commits_behind !== 1 ? 's' : ''} behind main
</span>
) : (
<span className="text-blue-600">
{branchStatus.commits_ahead} commit{branchStatus.commits_ahead !== 1 ? 's' : ''} ahead of main
</span>
)}
</div>
)}
{/* Success Messages */}
{rebaseSuccess && (
<div className="text-green-600 text-sm">
Branch rebased successfully!
</div>
)}
{mergeSuccess && ( {mergeSuccess && (
<div className="text-green-600 text-sm"> <div className="text-green-600 text-sm">
Changes merged successfully! Changes merged successfully!
</div> </div>
)} )}
<Button
onClick={handleMergeClick} {/* Action Buttons */}
disabled={merging || !diff || diff.files.length === 0} <div className="flex items-center gap-2">
className="bg-green-600 hover:bg-green-700" {branchStatus && branchStatus.is_behind === true && (
> <Button
{merging ? "Merging..." : "Merge Changes"} onClick={handleRebaseClick}
</Button> disabled={rebasing || branchStatusLoading}
variant="outline"
className="border-orange-300 text-orange-700 hover:bg-orange-50"
>
<RefreshCw className={`mr-2 h-4 w-4 ${rebasing ? 'animate-spin' : ''}`} />
{rebasing ? "Rebasing..." : "Rebase onto Main"}
</Button>
)}
<Button
onClick={handleMergeClick}
disabled={merging || !diff || diff.files.length === 0 || Boolean(branchStatus?.is_behind)}
className="bg-green-600 hover:bg-green-700 disabled:bg-gray-400"
>
{merging ? "Merging..." : "Merge Changes"}
</Button>
</div>
</div> </div>
</div> </div>

View File

@@ -49,4 +49,6 @@ export type DiffChunk = { chunk_type: DiffChunkType, content: string, };
export type FileDiff = { path: string, chunks: Array<DiffChunk>, }; export type FileDiff = { path: string, chunks: Array<DiffChunk>, };
export type WorktreeDiff = { files: Array<FileDiff>, }; export type WorktreeDiff = { files: Array<FileDiff>, };
export type BranchStatus = { is_behind: boolean, commits_behind: number, commits_ahead: number, up_to_date: boolean, };