Improve branch, merge, rebase (#37)
* Always create task branch * Create new ref * Save new branch name in DB * Refactor rebase backend * Update merging functionality * Clippy
This commit is contained in:
committed by
GitHub
parent
a1c97f787e
commit
c9fada8979
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT ta.id as \"id!: Uuid\", ta.task_id as \"task_id!: Uuid\", ta.worktree_path, ta.merge_commit, ta.executor, ta.created_at as \"created_at!: DateTime<Utc>\", ta.updated_at as \"updated_at!: DateTime<Utc>\"\n FROM task_attempts ta \n JOIN tasks t ON ta.task_id = t.id \n WHERE ta.id = $1 AND t.id = $2 AND t.project_id = $3",
|
||||
"query": "SELECT ta.id as \"id!: Uuid\", ta.task_id as \"task_id!: Uuid\", ta.worktree_path, ta.branch, ta.merge_commit, ta.executor, ta.created_at as \"created_at!: DateTime<Utc>\", ta.updated_at as \"updated_at!: DateTime<Utc>\"\n FROM task_attempts ta \n JOIN tasks t ON ta.task_id = t.id \n WHERE ta.id = $1 AND t.id = $2 AND t.project_id = $3",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -19,24 +19,29 @@
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "merge_commit",
|
||||
"name": "branch",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "executor",
|
||||
"name": "merge_commit",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "created_at!: DateTime<Utc>",
|
||||
"name": "executor",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "updated_at!: DateTime<Utc>",
|
||||
"name": "created_at!: DateTime<Utc>",
|
||||
"ordinal": 6,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "updated_at!: DateTime<Utc>",
|
||||
"ordinal": 7,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -46,11 +51,12 @@
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "acb63e12f7fa91c1f1cd5513b6dae0d8bff94f8b675c0c5b81f429e44158d8a8"
|
||||
"hash": "1be393764ab52d9fc12a786783a088c89bf61b1aa97505d91a9a94fede58bb46"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT INTO task_attempts (id, task_id, worktree_path, merge_commit, executor) \n VALUES ($1, $2, $3, $4, $5) \n RETURNING id as \"id!: Uuid\", task_id as \"task_id!: Uuid\", worktree_path, merge_commit, executor, created_at as \"created_at!: DateTime<Utc>\", updated_at as \"updated_at!: DateTime<Utc>\"",
|
||||
"query": "INSERT INTO task_attempts (id, task_id, worktree_path, branch, merge_commit, executor) \n VALUES ($1, $2, $3, $4, $5, $6) \n RETURNING id as \"id!: Uuid\", task_id as \"task_id!: Uuid\", worktree_path, branch, merge_commit, executor, created_at as \"created_at!: DateTime<Utc>\", updated_at as \"updated_at!: DateTime<Utc>\"",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -19,38 +19,44 @@
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "merge_commit",
|
||||
"name": "branch",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "executor",
|
||||
"name": "merge_commit",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "created_at!: DateTime<Utc>",
|
||||
"name": "executor",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "updated_at!: DateTime<Utc>",
|
||||
"name": "created_at!: DateTime<Utc>",
|
||||
"ordinal": 6,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "updated_at!: DateTime<Utc>",
|
||||
"ordinal": 7,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 5
|
||||
"Right": 6
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "0fc0dec5876cbe904d288a8e3bef15e0b7f75eb8e3c0fc1aeccd533ae73aab05"
|
||||
"hash": "1dabc7e92286b7ae53f9a98063d7fff4d96652e204dac4de4b46d29dfb198e40"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT id as \"id!: Uuid\", task_id as \"task_id!: Uuid\", worktree_path, merge_commit, executor, created_at as \"created_at!: DateTime<Utc>\", updated_at as \"updated_at!: DateTime<Utc>\"\n FROM task_attempts \n WHERE id = $1",
|
||||
"query": "SELECT id as \"id!: Uuid\", task_id as \"task_id!: Uuid\", worktree_path, branch, merge_commit, executor, created_at as \"created_at!: DateTime<Utc>\", updated_at as \"updated_at!: DateTime<Utc>\"\n FROM task_attempts \n WHERE task_id = $1 \n ORDER BY created_at DESC",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -19,24 +19,29 @@
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "merge_commit",
|
||||
"name": "branch",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "executor",
|
||||
"name": "merge_commit",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "created_at!: DateTime<Utc>",
|
||||
"name": "executor",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "updated_at!: DateTime<Utc>",
|
||||
"name": "created_at!: DateTime<Utc>",
|
||||
"ordinal": 6,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "updated_at!: DateTime<Utc>",
|
||||
"ordinal": 7,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -46,11 +51,12 @@
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "a6058c6a30c6e3011e02b0ad81b2a98c5eca3bd88dec9632284ef7489f784fcd"
|
||||
"hash": "6aa1f617ad17fcb5ae0eb37913ac9987d618f7c4ffc9e979cd610507ea1a69c0"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT id as \"id!: Uuid\", task_id as \"task_id!: Uuid\", worktree_path, merge_commit, executor, created_at as \"created_at!: DateTime<Utc>\", updated_at as \"updated_at!: DateTime<Utc>\"\n FROM task_attempts \n WHERE task_id = $1 \n ORDER BY created_at DESC",
|
||||
"query": "SELECT id as \"id!: Uuid\", task_id as \"task_id!: Uuid\", worktree_path, branch, merge_commit, executor, created_at as \"created_at!: DateTime<Utc>\", updated_at as \"updated_at!: DateTime<Utc>\"\n FROM task_attempts \n WHERE id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -19,24 +19,29 @@
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "merge_commit",
|
||||
"name": "branch",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "executor",
|
||||
"name": "merge_commit",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "created_at!: DateTime<Utc>",
|
||||
"name": "executor",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "updated_at!: DateTime<Utc>",
|
||||
"name": "created_at!: DateTime<Utc>",
|
||||
"ordinal": 6,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "updated_at!: DateTime<Utc>",
|
||||
"ordinal": 7,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -46,11 +51,12 @@
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "58bd05ee7354bb2aa7abffa7fa962f7143e071b9a1d39bca44e2e94512931209"
|
||||
"hash": "d0817b3befb23062a2a07817d799aec60292b0f567970403a35f5db5b5915aad"
|
||||
}
|
||||
@@ -39,6 +39,7 @@ nix = { version = "0.29", features = ["signal", "process"] }
|
||||
openssl-sys = { workspace = true }
|
||||
rmcp = { version = "0.1.5", features = ["server", "transport-io"] }
|
||||
schemars = "0.8"
|
||||
regex = "1.11.1"
|
||||
|
||||
[build-dependencies]
|
||||
ts-rs = { version = "9.0", features = ["uuid-impl", "chrono-impl"] }
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Add branch column to task_attempts table
|
||||
ALTER TABLE task_attempts ADD COLUMN branch TEXT NOT NULL DEFAULT '';
|
||||
@@ -1,13 +1,10 @@
|
||||
use std::path::Path;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use git2::{
|
||||
build::CheckoutBuilder, Error as GitError, MergeOptions, Oid, RebaseOptions, Reference,
|
||||
Repository, WorktreeAddOptions,
|
||||
};
|
||||
use git2::{BranchType, Error as GitError, RebaseOptions, Repository, WorktreeAddOptions};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{FromRow, SqlitePool, Type};
|
||||
use tracing::{debug, error, info};
|
||||
use tracing::{debug, info};
|
||||
use ts_rs::TS;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -70,6 +67,7 @@ pub struct TaskAttempt {
|
||||
pub id: Uuid,
|
||||
pub task_id: Uuid, // Foreign key to Task
|
||||
pub worktree_path: String,
|
||||
pub branch: String, // Git branch name for this task attempt
|
||||
pub merge_commit: Option<String>,
|
||||
pub executor: Option<String>, // Name of the executor to use
|
||||
pub created_at: DateTime<Utc>,
|
||||
@@ -139,7 +137,7 @@ impl TaskAttempt {
|
||||
pub async fn find_by_id(pool: &SqlitePool, id: Uuid) -> Result<Option<Self>, sqlx::Error> {
|
||||
sqlx::query_as!(
|
||||
TaskAttempt,
|
||||
r#"SELECT id as "id!: Uuid", task_id as "task_id!: Uuid", worktree_path, merge_commit, executor, created_at as "created_at!: DateTime<Utc>", updated_at as "updated_at!: DateTime<Utc>"
|
||||
r#"SELECT id as "id!: Uuid", task_id as "task_id!: Uuid", worktree_path, branch, merge_commit, executor, created_at as "created_at!: DateTime<Utc>", updated_at as "updated_at!: DateTime<Utc>"
|
||||
FROM task_attempts
|
||||
WHERE id = $1"#,
|
||||
id
|
||||
@@ -154,7 +152,7 @@ impl TaskAttempt {
|
||||
) -> Result<Vec<Self>, sqlx::Error> {
|
||||
sqlx::query_as!(
|
||||
TaskAttempt,
|
||||
r#"SELECT id as "id!: Uuid", task_id as "task_id!: Uuid", worktree_path, merge_commit, executor, created_at as "created_at!: DateTime<Utc>", updated_at as "updated_at!: DateTime<Utc>"
|
||||
r#"SELECT id as "id!: Uuid", task_id as "task_id!: Uuid", worktree_path, branch, merge_commit, executor, created_at as "created_at!: DateTime<Utc>", updated_at as "updated_at!: DateTime<Utc>"
|
||||
FROM task_attempts
|
||||
WHERE task_id = $1
|
||||
ORDER BY created_at DESC"#,
|
||||
@@ -170,57 +168,55 @@ impl TaskAttempt {
|
||||
task_id: Uuid,
|
||||
) -> Result<Self, TaskAttemptError> {
|
||||
let attempt_id = Uuid::new_v4();
|
||||
let prefixed_id = format!("vibe-kanban-{}", attempt_id);
|
||||
// let prefixed_id = format!("vibe-kanban-{}", attempt_id);
|
||||
|
||||
// First, get the task to get the project_id
|
||||
let task = Task::find_by_id(pool, task_id)
|
||||
.await?
|
||||
.ok_or(TaskAttemptError::TaskNotFound)?;
|
||||
|
||||
// Create a unique and helpful branch name
|
||||
let task_title_id = crate::utils::text::git_branch_id(&task.title);
|
||||
let task_attempt_branch = format!(
|
||||
"vk-{}-{}",
|
||||
crate::utils::text::short_uuid(&attempt_id),
|
||||
task_title_id
|
||||
);
|
||||
|
||||
// Generate worktree path automatically using cross-platform temporary directory
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let worktree_path = temp_dir.join(&task_attempt_branch);
|
||||
let worktree_path_str = worktree_path.to_string_lossy().to_string();
|
||||
|
||||
// Then get the project using the project_id
|
||||
let project = Project::find_by_id(pool, task.project_id)
|
||||
.await?
|
||||
.ok_or(TaskAttemptError::ProjectNotFound)?;
|
||||
|
||||
// Generate worktree path automatically using cross-platform temporary directory
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let worktree_path = temp_dir.join(&prefixed_id);
|
||||
let worktree_path_str = worktree_path.to_string_lossy().to_string();
|
||||
|
||||
// Solve scoping issues
|
||||
{
|
||||
// Create the worktree using git2
|
||||
let repo = Repository::open(&project.git_repo_path)?;
|
||||
|
||||
let mut worktree_opts = WorktreeAddOptions::new();
|
||||
let new_base_ref: Reference;
|
||||
|
||||
if let Some(base_branch) = data.base_branch.clone() {
|
||||
let base_ref = Some(str::trim(base_branch.as_str())) // chop off any whitespace
|
||||
.filter(|b| !b.is_empty()) // ditch empty strings
|
||||
.and_then(|branch| {
|
||||
// pick the right ref name
|
||||
let candidate = if branch.starts_with("origin/") || branch.contains('/') {
|
||||
format!("refs/remotes/{}", branch)
|
||||
// Choose base reference, based on whether user specified base branch
|
||||
let base_reference = if let Some(base_branch) = data.base_branch.clone() {
|
||||
let branch = repo.find_branch(base_branch.as_str(), BranchType::Local)?;
|
||||
branch.into_reference()
|
||||
} else {
|
||||
let local = format!("refs/heads/{}", branch);
|
||||
if repo.find_reference(&local).is_ok() {
|
||||
local
|
||||
} else {
|
||||
format!("refs/remotes/origin/{}", branch)
|
||||
}
|
||||
repo.head()?
|
||||
};
|
||||
// try to look it up, turning Ok(r) → Some(r), Err(_) → None
|
||||
repo.find_reference(&candidate).ok()
|
||||
})
|
||||
.ok_or(TaskAttemptError::BranchNotFound(base_branch))?;
|
||||
|
||||
let target_commit = base_ref.peel_to_commit()?;
|
||||
repo.branch(&prefixed_id, &target_commit, false)?;
|
||||
new_base_ref = repo.find_reference(&format!("refs/heads/{}", prefixed_id))?;
|
||||
// Create branch
|
||||
repo.branch(
|
||||
&task_attempt_branch,
|
||||
&base_reference.peel_to_commit()?,
|
||||
false,
|
||||
)?;
|
||||
|
||||
worktree_opts.reference(Some(&new_base_ref));
|
||||
}
|
||||
let branch = repo.find_branch(&task_attempt_branch, BranchType::Local)?;
|
||||
let branch_ref = branch.into_reference();
|
||||
let mut worktree_opts = WorktreeAddOptions::new();
|
||||
worktree_opts.reference(Some(&branch_ref));
|
||||
|
||||
// Create the worktree directory if it doesn't exist
|
||||
if let Some(parent) = worktree_path.parent() {
|
||||
@@ -229,19 +225,19 @@ impl TaskAttempt {
|
||||
}
|
||||
|
||||
// Create the worktree at the specified path
|
||||
let branch_name = format!("attempt-{}", attempt_id);
|
||||
repo.worktree(&branch_name, &worktree_path, Some(&worktree_opts))?;
|
||||
repo.worktree(&task_attempt_branch, &worktree_path, Some(&worktree_opts))?;
|
||||
}
|
||||
|
||||
// Insert the record into the database
|
||||
Ok(sqlx::query_as!(
|
||||
TaskAttempt,
|
||||
r#"INSERT INTO task_attempts (id, task_id, worktree_path, merge_commit, executor)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id as "id!: Uuid", task_id as "task_id!: Uuid", worktree_path, merge_commit, executor, created_at as "created_at!: DateTime<Utc>", updated_at as "updated_at!: DateTime<Utc>""#,
|
||||
r#"INSERT INTO task_attempts (id, task_id, worktree_path, branch, merge_commit, executor)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id as "id!: Uuid", task_id as "task_id!: Uuid", worktree_path, branch, merge_commit, executor, created_at as "created_at!: DateTime<Utc>", updated_at as "updated_at!: DateTime<Utc>""#,
|
||||
attempt_id,
|
||||
task_id,
|
||||
worktree_path_str,
|
||||
task_attempt_branch,
|
||||
Option::<String>::None, // merge_commit is always None during creation
|
||||
data.executor
|
||||
)
|
||||
@@ -268,102 +264,129 @@ impl TaskAttempt {
|
||||
Ok(result.is_some())
|
||||
}
|
||||
|
||||
/// Perform the actual git merge operations (synchronous)
|
||||
/// Perform the actual merge operation (synchronous)
|
||||
fn perform_merge_operation(
|
||||
worktree_path: &str,
|
||||
main_repo_path: &str,
|
||||
attempt_id: Uuid,
|
||||
branch_name: &str,
|
||||
task_title: &str,
|
||||
) -> Result<String, TaskAttemptError> {
|
||||
// Open the main repository
|
||||
let main_repo = Repository::open(main_repo_path)?;
|
||||
|
||||
// Open the worktree repository to get the latest commit
|
||||
let worktree_repo = Repository::open(worktree_path)?;
|
||||
let worktree_head = worktree_repo.head()?;
|
||||
let worktree_commit = worktree_head.peel_to_commit()?;
|
||||
|
||||
// Verify the branch exists in the main repo
|
||||
main_repo
|
||||
.find_branch(branch_name, BranchType::Local)
|
||||
.map_err(|_| TaskAttemptError::BranchNotFound(branch_name.to_string()))?;
|
||||
|
||||
// Get the current HEAD of the main repo (usually main/master)
|
||||
let main_head = main_repo.head()?;
|
||||
let main_commit = main_head.peel_to_commit()?;
|
||||
|
||||
// Get the signature for the merge commit
|
||||
let signature = main_repo.signature()?;
|
||||
|
||||
// Get the tree from the worktree commit and find it in the main repo
|
||||
let worktree_tree_id = worktree_commit.tree_id();
|
||||
let main_tree = main_repo.find_tree(worktree_tree_id)?;
|
||||
|
||||
// Find the worktree commit in the main repo
|
||||
let main_worktree_commit = main_repo.find_commit(worktree_commit.id())?;
|
||||
|
||||
// Create a merge commit
|
||||
let merge_commit_id = main_repo.commit(
|
||||
Some("HEAD"), // Update HEAD
|
||||
&signature, // Author
|
||||
&signature, // Committer
|
||||
&format!("Merge: {} (vibe-kanban)", task_title), // Message using task title
|
||||
&main_tree, // Use the tree from main repo
|
||||
&[&main_commit, &main_worktree_commit], // Parents: main HEAD and worktree commit
|
||||
)?;
|
||||
|
||||
info!("Created merge commit: {}", merge_commit_id);
|
||||
|
||||
Ok(merge_commit_id.to_string())
|
||||
}
|
||||
|
||||
/// Perform the actual git rebase operations (synchronous)
|
||||
fn perform_rebase_operation(
|
||||
worktree_path: &str,
|
||||
main_repo_path: &str,
|
||||
new_base_branch: Option<String>,
|
||||
) -> Result<String, TaskAttemptError> {
|
||||
// Open the worktree repository
|
||||
let worktree_repo = Repository::open(worktree_path)?;
|
||||
|
||||
// Open the main repository
|
||||
// Open the main repository to get the target base commit
|
||||
let main_repo = Repository::open(main_repo_path)?;
|
||||
|
||||
// Get the current signature for commits
|
||||
let signature = main_repo.signature()?;
|
||||
// Get the target base branch reference
|
||||
let base_branch_name = new_base_branch.unwrap_or_else(|| {
|
||||
main_repo
|
||||
.head()
|
||||
.ok()
|
||||
.and_then(|head| head.shorthand().map(|s| s.to_string()))
|
||||
.unwrap_or_else(|| "main".to_string())
|
||||
});
|
||||
|
||||
// Get the current HEAD commit in the worktree (changes should already be committed by execution monitor)
|
||||
// Check if the specified base branch exists in the main repo
|
||||
let base_branch = main_repo
|
||||
.find_branch(&base_branch_name, BranchType::Local)
|
||||
.map_err(|_| TaskAttemptError::BranchNotFound(base_branch_name.clone()))?;
|
||||
|
||||
let base_commit_id = base_branch.get().peel_to_commit()?.id();
|
||||
|
||||
// Get the HEAD commit of the worktree (the changes to rebase)
|
||||
let head = worktree_repo.head()?;
|
||||
let parent_commit = head.peel_to_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);
|
||||
// Set up rebase
|
||||
let mut rebase_opts = RebaseOptions::new();
|
||||
let signature = worktree_repo.signature()?;
|
||||
|
||||
// Get the current base branch name (e.g., "main", "master", "develop", etc.)
|
||||
let main_branch = main_repo.head()?.shorthand().unwrap_or("main").to_string();
|
||||
// Start the rebase
|
||||
let head_annotated = worktree_repo.reference_to_annotated_commit(&head)?;
|
||||
let base_annotated = worktree_repo.find_annotated_commit(base_commit_id)?;
|
||||
|
||||
// Fetch the worktree branch into the main repository
|
||||
let worktree_branch_ref = format!("refs/heads/{}", branch_name);
|
||||
let main_branch_ref = format!("refs/heads/{}", main_branch);
|
||||
|
||||
// Create the branch in main repo pointing to the final commit
|
||||
let branch_oid = main_repo.odb()?.write(
|
||||
git2::ObjectType::Commit,
|
||||
worktree_repo.odb()?.read(final_commit)?.data(),
|
||||
let mut rebase = worktree_repo.rebase(
|
||||
Some(&head_annotated),
|
||||
Some(&base_annotated),
|
||||
None, // onto (use upstream if None)
|
||||
Some(&mut rebase_opts),
|
||||
)?;
|
||||
|
||||
// Create reference in main repo
|
||||
main_repo.reference(
|
||||
&worktree_branch_ref,
|
||||
branch_oid,
|
||||
true,
|
||||
"Import worktree changes",
|
||||
)?;
|
||||
// Process each rebase operation
|
||||
while let Some(operation) = rebase.next() {
|
||||
let _operation = operation?;
|
||||
|
||||
// Now merge the branch into the base branch
|
||||
let main_branch_commit = main_repo
|
||||
.reference_to_annotated_commit(&main_repo.find_reference(&main_branch_ref)?)?;
|
||||
let worktree_branch_commit = main_repo
|
||||
.reference_to_annotated_commit(&main_repo.find_reference(&worktree_branch_ref)?)?;
|
||||
|
||||
// Perform the merge
|
||||
let mut merge_opts = git2::MergeOptions::new();
|
||||
merge_opts.file_favor(git2::FileFavor::Theirs); // Prefer worktree changes in conflicts
|
||||
|
||||
let mut checkout_opts = git2::build::CheckoutBuilder::new();
|
||||
checkout_opts.conflict_style_merge(true);
|
||||
|
||||
main_repo.merge(
|
||||
&[&worktree_branch_commit],
|
||||
Some(&mut merge_opts),
|
||||
Some(&mut checkout_opts),
|
||||
)?;
|
||||
|
||||
// Check if merge was successful (no conflicts)
|
||||
let merge_head_path = main_repo.path().join("MERGE_HEAD");
|
||||
if merge_head_path.exists() {
|
||||
// Complete the merge by creating a merge commit
|
||||
let mut index = main_repo.index()?;
|
||||
let tree_id = index.write_tree()?;
|
||||
let tree = main_repo.find_tree(tree_id)?;
|
||||
|
||||
let main_commit = main_repo.find_commit(main_branch_commit.id())?;
|
||||
let worktree_commit = main_repo.find_commit(worktree_branch_commit.id())?;
|
||||
|
||||
let merge_commit_message = format!("Merge task: {} into {}", task_title, main_branch);
|
||||
let merge_commit_id = main_repo.commit(
|
||||
Some(&main_branch_ref),
|
||||
&signature,
|
||||
&signature,
|
||||
&merge_commit_message,
|
||||
&tree,
|
||||
&[&main_commit, &worktree_commit],
|
||||
)?;
|
||||
|
||||
// Clean up merge state
|
||||
main_repo.cleanup_state()?;
|
||||
|
||||
Ok(merge_commit_id.to_string())
|
||||
} else {
|
||||
// Fast-forward merge completed
|
||||
let head_commit = main_repo.head()?.peel_to_commit()?;
|
||||
let merge_commit_id = head_commit.id();
|
||||
|
||||
Ok(merge_commit_id.to_string())
|
||||
// Check for conflicts
|
||||
let index = worktree_repo.index()?;
|
||||
if index.has_conflicts() {
|
||||
// For now, abort the rebase on conflicts
|
||||
rebase.abort()?;
|
||||
return Err(TaskAttemptError::Git(GitError::from_str(
|
||||
"Rebase failed due to conflicts. Please resolve conflicts manually.",
|
||||
)));
|
||||
}
|
||||
|
||||
// Commit the rebased operation
|
||||
rebase.commit(None, &signature, None)?;
|
||||
}
|
||||
|
||||
// Finish the rebase
|
||||
rebase.finish(None)?;
|
||||
|
||||
// Get the final commit ID after rebase
|
||||
let final_head = worktree_repo.head()?;
|
||||
let final_commit = final_head.peel_to_commit()?;
|
||||
|
||||
info!("Rebase completed. New HEAD: {}", final_commit.id());
|
||||
|
||||
Ok(final_commit.id().to_string())
|
||||
}
|
||||
|
||||
/// Merge the worktree changes back to the main repository
|
||||
@@ -376,7 +399,7 @@ impl TaskAttempt {
|
||||
// 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.merge_commit, ta.executor, ta.created_at as "created_at!: DateTime<Utc>", ta.updated_at as "updated_at!: DateTime<Utc>"
|
||||
r#"SELECT ta.id as "id!: Uuid", ta.task_id as "task_id!: Uuid", ta.worktree_path, ta.branch, ta.merge_commit, ta.executor, 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"#,
|
||||
@@ -397,11 +420,11 @@ impl TaskAttempt {
|
||||
.await?
|
||||
.ok_or(TaskAttemptError::ProjectNotFound)?;
|
||||
|
||||
// Perform the git merge operations (synchronous)
|
||||
// Perform the actual merge operation
|
||||
let merge_commit_id = Self::perform_merge_operation(
|
||||
&attempt.worktree_path,
|
||||
&project.git_repo_path,
|
||||
attempt_id,
|
||||
&attempt.branch,
|
||||
&task.title,
|
||||
)?;
|
||||
|
||||
@@ -1006,7 +1029,7 @@ impl TaskAttempt {
|
||||
// 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.merge_commit, ta.executor, ta.created_at as "created_at!: DateTime<Utc>", ta.updated_at as "updated_at!: DateTime<Utc>"
|
||||
r#"SELECT ta.id as "id!: Uuid", ta.task_id as "task_id!: Uuid", ta.worktree_path, ta.branch, ta.merge_commit, ta.executor, 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"#,
|
||||
@@ -1302,7 +1325,7 @@ impl TaskAttempt {
|
||||
// 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.merge_commit, ta.executor, ta.created_at as "created_at!: DateTime<Utc>", ta.updated_at as "updated_at!: DateTime<Utc>"
|
||||
r#"SELECT ta.id as "id!: Uuid", ta.task_id as "task_id!: Uuid", ta.worktree_path, ta.branch, ta.merge_commit, ta.executor, 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"#,
|
||||
@@ -1389,78 +1412,18 @@ impl TaskAttempt {
|
||||
})
|
||||
}
|
||||
|
||||
/// Perform the actual git rebase operations (synchronous)
|
||||
fn perform_rebase_operation(
|
||||
worktree_path: &str,
|
||||
main_repo_path: &str,
|
||||
) -> Result<String, TaskAttemptError> {
|
||||
let main_repo = Repository::open(main_repo_path)?;
|
||||
let repo = Repository::open(worktree_path)?;
|
||||
|
||||
// 1️⃣ get main HEAD oid
|
||||
let main_oid = main_repo.head()?.peel_to_commit()?.id();
|
||||
|
||||
// 2️⃣ early exit if up-to-date
|
||||
let orig_oid = repo.head()?.peel_to_commit()?.id();
|
||||
if orig_oid == main_oid {
|
||||
return Ok(orig_oid.to_string());
|
||||
}
|
||||
|
||||
// 3️⃣ prepare upstream
|
||||
let main_annot = repo.find_annotated_commit(main_oid)?;
|
||||
|
||||
// 4️⃣ set up in-memory rebase
|
||||
let mut opts = RebaseOptions::new();
|
||||
opts.inmemory(true).merge_options(MergeOptions::new());
|
||||
|
||||
// 5️⃣ start rebase of HEAD onto main
|
||||
let mut reb = repo.rebase(None, Some(&main_annot), None, Some(&mut opts))?;
|
||||
|
||||
// 6️⃣ replay commits, remember last OID
|
||||
let sig = repo.signature()?;
|
||||
let mut last_oid: Option<Oid> = None;
|
||||
while let Some(res) = reb.next() {
|
||||
match res {
|
||||
Ok(_op) => {
|
||||
let new_oid = reb.commit(None, &sig, None)?;
|
||||
last_oid = Some(new_oid);
|
||||
}
|
||||
Err(e) => {
|
||||
error!("rebase op failed: {}", e);
|
||||
reb.abort()?;
|
||||
return Err(TaskAttemptError::Git(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 7️⃣ finish (still in-memory)
|
||||
reb.finish(Some(&sig))?;
|
||||
|
||||
// 8️⃣ repoint your branch ref (HEAD is a symbolic to this ref)
|
||||
if let Some(target) = last_oid {
|
||||
let head_ref = repo.head()?; // symbolic HEAD
|
||||
let branch_name = head_ref.name().unwrap(); // e.g. "refs/heads/feature"
|
||||
let mut r = repo.find_reference(branch_name)?;
|
||||
r.set_target(target, "rebase: update branch")?;
|
||||
}
|
||||
|
||||
// 9️⃣ update working tree
|
||||
repo.checkout_head(Some(CheckoutBuilder::new().force()))?;
|
||||
|
||||
Ok(main_oid.to_string())
|
||||
}
|
||||
|
||||
/// Rebase the worktree branch onto main
|
||||
pub async fn rebase_onto_main(
|
||||
/// Rebase the worktree branch onto specified base branch (or current HEAD if none specified)
|
||||
pub async fn rebase_attempt(
|
||||
pool: &SqlitePool,
|
||||
attempt_id: Uuid,
|
||||
task_id: Uuid,
|
||||
project_id: Uuid,
|
||||
new_base_branch: Option<String>,
|
||||
) -> 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.merge_commit, ta.executor, ta.created_at as "created_at!: DateTime<Utc>", ta.updated_at as "updated_at!: DateTime<Utc>"
|
||||
r#"SELECT ta.id as "id!: Uuid", ta.task_id as "task_id!: Uuid", ta.worktree_path, ta.branch, ta.merge_commit, ta.executor, 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"#,
|
||||
@@ -1478,8 +1441,11 @@ impl TaskAttempt {
|
||||
.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)?;
|
||||
let new_base_commit = Self::perform_rebase_operation(
|
||||
&attempt.worktree_path,
|
||||
&project.git_repo_path,
|
||||
new_base_branch,
|
||||
)?;
|
||||
|
||||
// No need to update database as we now get base_commit live from git
|
||||
Ok(new_base_commit)
|
||||
@@ -1496,7 +1462,7 @@ impl TaskAttempt {
|
||||
// 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.merge_commit, ta.executor, ta.created_at as "created_at!: DateTime<Utc>", ta.updated_at as "updated_at!: DateTime<Utc>"
|
||||
r#"SELECT ta.id as "id!: Uuid", ta.task_id as "task_id!: Uuid", ta.worktree_path, ta.branch, ta.merge_commit, ta.executor, 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"#,
|
||||
|
||||
@@ -7,6 +7,7 @@ use axum::{
|
||||
routing::get,
|
||||
Json, Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::SqlitePool;
|
||||
use tokio::sync::RwLock;
|
||||
use uuid::Uuid;
|
||||
@@ -24,6 +25,11 @@ use crate::models::{
|
||||
ApiResponse,
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct RebaseTaskAttemptRequest {
|
||||
pub new_base_branch: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn get_task_attempts(
|
||||
Path((project_id, task_id)): Path<(Uuid, Uuid)>,
|
||||
Extension(pool): Extension<SqlitePool>,
|
||||
@@ -369,6 +375,7 @@ pub async fn get_task_attempt_branch_status(
|
||||
pub async fn rebase_task_attempt(
|
||||
Path((project_id, task_id, attempt_id)): Path<(Uuid, Uuid, Uuid)>,
|
||||
Extension(pool): Extension<SqlitePool>,
|
||||
request_body: Option<Json<RebaseTaskAttemptRequest>>,
|
||||
) -> 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 {
|
||||
@@ -380,7 +387,11 @@ pub async fn rebase_task_attempt(
|
||||
Ok(true) => {}
|
||||
}
|
||||
|
||||
match TaskAttempt::rebase_onto_main(&pool, attempt_id, task_id, project_id).await {
|
||||
// Extract new base branch from request body if provided
|
||||
let new_base_branch = request_body.and_then(|body| body.new_base_branch.clone());
|
||||
|
||||
match TaskAttempt::rebase_attempt(&pool, attempt_id, task_id, project_id, new_base_branch).await
|
||||
{
|
||||
Ok(_new_base_commit) => Ok(ResponseJson(ApiResponse {
|
||||
success: true,
|
||||
data: None,
|
||||
|
||||
@@ -3,6 +3,7 @@ use std::env;
|
||||
use directories::ProjectDirs;
|
||||
|
||||
pub mod shell;
|
||||
pub mod text;
|
||||
|
||||
pub fn asset_dir() -> std::path::PathBuf {
|
||||
let proj = if cfg!(debug_assertions) {
|
||||
|
||||
24
backend/src/utils/text.rs
Normal file
24
backend/src/utils/text.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
use regex::Regex;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub fn git_branch_id(input: &str) -> String {
|
||||
// 1. lowercase
|
||||
let lower = input.to_lowercase();
|
||||
|
||||
// 2. replace non-alphanumerics with hyphens
|
||||
let re = Regex::new(r"[^a-z0-9]+").unwrap();
|
||||
let slug = re.replace_all(&lower, "-");
|
||||
|
||||
// 3. trim extra hyphens
|
||||
let trimmed = slug.trim_matches('-');
|
||||
|
||||
// 4. take up to 10 chars, then trim trailing hyphens again
|
||||
let cut: String = trimmed.chars().take(10).collect();
|
||||
cut.trim_end_matches('-').to_string()
|
||||
}
|
||||
|
||||
pub fn short_uuid(u: &Uuid) -> String {
|
||||
// to_simple() gives you a 32-char hex string with no hyphens
|
||||
let full = u.simple().to_string();
|
||||
full.chars().take(4).collect() // grab the first 4 chars
|
||||
}
|
||||
@@ -54,7 +54,7 @@ export type UpdateTask = { title: string | null, description: string | null, sta
|
||||
|
||||
export type TaskAttemptStatus = "setuprunning" | "setupcomplete" | "setupfailed" | "executorrunning" | "executorcomplete" | "executorfailed";
|
||||
|
||||
export type TaskAttempt = { id: string, task_id: string, worktree_path: string, merge_commit: string | null, executor: string | null, created_at: string, updated_at: string, };
|
||||
export type TaskAttempt = { id: string, task_id: string, worktree_path: string, branch: string, merge_commit: string | null, executor: string | null, created_at: string, updated_at: string, };
|
||||
|
||||
export type CreateTaskAttempt = { executor: string | null, base_branch: string | null, };
|
||||
|
||||
|
||||
Reference in New Issue
Block a user