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 {}
|
||||||
|
|
||||||
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();
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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(¤t_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 (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::{
|
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;
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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, };
|
||||||
Reference in New Issue
Block a user