Add rebase functionality and update merge
This commit is contained in:
12
backend/.sqlx/query-501ae2ecc428a7de30055b1b2b195ee772fbcec729bb5f58c5fefe8dadc1c460.json
generated
Normal file
12
backend/.sqlx/query-501ae2ecc428a7de30055b1b2b195ee772fbcec729bb5f58c5fefe8dadc1c460.json
generated
Normal 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"
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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<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) {
|
||||
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(5));
|
||||
|
||||
@@ -155,6 +215,26 @@ pub async fn execution_monitor(app_state: AppState) {
|
||||
|
||||
tracing::info!("Execution {} {}{}", execution_id, status_text, exit_text);
|
||||
|
||||
// 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
|
||||
{
|
||||
// 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 {
|
||||
@@ -183,10 +263,7 @@ pub async fn execution_monitor(app_state: AppState) {
|
||||
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
|
||||
{
|
||||
// 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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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<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 {
|
||||
pub async fn find_by_id(pool: &SqlitePool, id: Uuid) -> Result<Option<Self>, 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,32 +661,42 @@ 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(), // 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 {
|
||||
@@ -743,8 +727,201 @@ impl TaskAttempt {
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
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<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 (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<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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<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 {
|
||||
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),
|
||||
|
||||
@@ -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<T> {
|
||||
success: boolean;
|
||||
@@ -21,15 +21,20 @@ export function TaskAttemptComparePage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [diff, setDiff] = useState<WorktreeDiff | null>(null);
|
||||
const [branchStatus, setBranchStatus] = useState<BranchStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [branchStatusLoading, setBranchStatusLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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<Set<string>>(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<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 = () => {
|
||||
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 baseClass = "font-mono text-sm whitespace-pre px-3 py-1";
|
||||
|
||||
@@ -255,7 +318,7 @@ export function TaskAttemptComparePage() {
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
if (loading || branchStatusLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
@@ -293,21 +356,60 @@ export function TaskAttemptComparePage() {
|
||||
Compare Changes
|
||||
</h1>
|
||||
</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 && (
|
||||
<div className="text-green-600 text-sm">
|
||||
Changes merged successfully!
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center gap-2">
|
||||
{branchStatus && branchStatus.is_behind === true && (
|
||||
<Button
|
||||
onClick={handleRebaseClick}
|
||||
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}
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
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>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
@@ -50,3 +50,5 @@ export type DiffChunk = { chunk_type: DiffChunkType, content: string, };
|
||||
export type FileDiff = { path: string, chunks: Array<DiffChunk>, };
|
||||
|
||||
export type WorktreeDiff = { files: Array<FileDiff>, };
|
||||
|
||||
export type BranchStatus = { is_behind: boolean, commits_behind: number, commits_ahead: number, up_to_date: boolean, };
|
||||
Reference in New Issue
Block a user