From c9fada89793d89f5df41d662119c44b1a13eb5ef Mon Sep 17 00:00:00 2001 From: Louis Knight-Webb Date: Tue, 1 Jul 2025 15:11:51 +0100 Subject: [PATCH] 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 --- ...88c89bf61b1aa97505d91a9a94fede58bb46.json} | 18 +- ...fff4d96652e204dac4de4b46d29dfb198e40.json} | 20 +- ...9987d618f7c4ffc9e979cd610507ea1a69c0.json} | 18 +- ...aec60292b0f567970403a35f5db5b5915aad.json} | 18 +- backend/Cargo.toml | 1 + ...0701000000_add_branch_to_task_attempts.sql | 2 + backend/src/models/task_attempt.rs | 354 ++++++++---------- backend/src/routes/task_attempts.rs | 13 +- backend/src/utils.rs | 1 + backend/src/utils/text.rs | 24 ++ shared/types.ts | 2 +- 11 files changed, 250 insertions(+), 221 deletions(-) rename backend/.sqlx/{query-acb63e12f7fa91c1f1cd5513b6dae0d8bff94f8b675c0c5b81f429e44158d8a8.json => query-1be393764ab52d9fc12a786783a088c89bf61b1aa97505d91a9a94fede58bb46.json} (66%) rename backend/.sqlx/{query-0fc0dec5876cbe904d288a8e3bef15e0b7f75eb8e3c0fc1aeccd533ae73aab05.json => query-1dabc7e92286b7ae53f9a98063d7fff4d96652e204dac4de4b46d29dfb198e40.json} (66%) rename backend/.sqlx/{query-a6058c6a30c6e3011e02b0ad81b2a98c5eca3bd88dec9632284ef7489f784fcd.json => query-6aa1f617ad17fcb5ae0eb37913ac9987d618f7c4ffc9e979cd610507ea1a69c0.json} (69%) rename backend/.sqlx/{query-58bd05ee7354bb2aa7abffa7fa962f7143e071b9a1d39bca44e2e94512931209.json => query-d0817b3befb23062a2a07817d799aec60292b0f567970403a35f5db5b5915aad.json} (71%) create mode 100644 backend/migrations/20250701000000_add_branch_to_task_attempts.sql create mode 100644 backend/src/utils/text.rs diff --git a/backend/.sqlx/query-acb63e12f7fa91c1f1cd5513b6dae0d8bff94f8b675c0c5b81f429e44158d8a8.json b/backend/.sqlx/query-1be393764ab52d9fc12a786783a088c89bf61b1aa97505d91a9a94fede58bb46.json similarity index 66% rename from backend/.sqlx/query-acb63e12f7fa91c1f1cd5513b6dae0d8bff94f8b675c0c5b81f429e44158d8a8.json rename to backend/.sqlx/query-1be393764ab52d9fc12a786783a088c89bf61b1aa97505d91a9a94fede58bb46.json index c97a7de5..3316bc44 100644 --- a/backend/.sqlx/query-acb63e12f7fa91c1f1cd5513b6dae0d8bff94f8b675c0c5b81f429e44158d8a8.json +++ b/backend/.sqlx/query-1be393764ab52d9fc12a786783a088c89bf61b1aa97505d91a9a94fede58bb46.json @@ -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\", ta.updated_at as \"updated_at!: DateTime\"\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\", ta.updated_at as \"updated_at!: DateTime\"\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", + "name": "executor", "ordinal": 5, "type_info": "Text" }, { - "name": "updated_at!: DateTime", + "name": "created_at!: DateTime", "ordinal": 6, "type_info": "Text" + }, + { + "name": "updated_at!: DateTime", + "ordinal": 7, + "type_info": "Text" } ], "parameters": { @@ -46,11 +51,12 @@ true, false, false, + false, true, true, false, false ] }, - "hash": "acb63e12f7fa91c1f1cd5513b6dae0d8bff94f8b675c0c5b81f429e44158d8a8" + "hash": "1be393764ab52d9fc12a786783a088c89bf61b1aa97505d91a9a94fede58bb46" } diff --git a/backend/.sqlx/query-0fc0dec5876cbe904d288a8e3bef15e0b7f75eb8e3c0fc1aeccd533ae73aab05.json b/backend/.sqlx/query-1dabc7e92286b7ae53f9a98063d7fff4d96652e204dac4de4b46d29dfb198e40.json similarity index 66% rename from backend/.sqlx/query-0fc0dec5876cbe904d288a8e3bef15e0b7f75eb8e3c0fc1aeccd533ae73aab05.json rename to backend/.sqlx/query-1dabc7e92286b7ae53f9a98063d7fff4d96652e204dac4de4b46d29dfb198e40.json index 2b12d8a4..9aa896a6 100644 --- a/backend/.sqlx/query-0fc0dec5876cbe904d288a8e3bef15e0b7f75eb8e3c0fc1aeccd533ae73aab05.json +++ b/backend/.sqlx/query-1dabc7e92286b7ae53f9a98063d7fff4d96652e204dac4de4b46d29dfb198e40.json @@ -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\", updated_at as \"updated_at!: DateTime\"", + "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\", updated_at as \"updated_at!: DateTime\"", "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", + "name": "executor", "ordinal": 5, "type_info": "Text" }, { - "name": "updated_at!: DateTime", + "name": "created_at!: DateTime", "ordinal": 6, "type_info": "Text" + }, + { + "name": "updated_at!: DateTime", + "ordinal": 7, + "type_info": "Text" } ], "parameters": { - "Right": 5 + "Right": 6 }, "nullable": [ true, false, false, + false, true, true, false, false ] }, - "hash": "0fc0dec5876cbe904d288a8e3bef15e0b7f75eb8e3c0fc1aeccd533ae73aab05" + "hash": "1dabc7e92286b7ae53f9a98063d7fff4d96652e204dac4de4b46d29dfb198e40" } diff --git a/backend/.sqlx/query-a6058c6a30c6e3011e02b0ad81b2a98c5eca3bd88dec9632284ef7489f784fcd.json b/backend/.sqlx/query-6aa1f617ad17fcb5ae0eb37913ac9987d618f7c4ffc9e979cd610507ea1a69c0.json similarity index 69% rename from backend/.sqlx/query-a6058c6a30c6e3011e02b0ad81b2a98c5eca3bd88dec9632284ef7489f784fcd.json rename to backend/.sqlx/query-6aa1f617ad17fcb5ae0eb37913ac9987d618f7c4ffc9e979cd610507ea1a69c0.json index 25717aea..e1279398 100644 --- a/backend/.sqlx/query-a6058c6a30c6e3011e02b0ad81b2a98c5eca3bd88dec9632284ef7489f784fcd.json +++ b/backend/.sqlx/query-6aa1f617ad17fcb5ae0eb37913ac9987d618f7c4ffc9e979cd610507ea1a69c0.json @@ -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\", updated_at as \"updated_at!: DateTime\"\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\", updated_at as \"updated_at!: DateTime\"\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", + "name": "executor", "ordinal": 5, "type_info": "Text" }, { - "name": "updated_at!: DateTime", + "name": "created_at!: DateTime", "ordinal": 6, "type_info": "Text" + }, + { + "name": "updated_at!: DateTime", + "ordinal": 7, + "type_info": "Text" } ], "parameters": { @@ -46,11 +51,12 @@ true, false, false, + false, true, true, false, false ] }, - "hash": "a6058c6a30c6e3011e02b0ad81b2a98c5eca3bd88dec9632284ef7489f784fcd" + "hash": "6aa1f617ad17fcb5ae0eb37913ac9987d618f7c4ffc9e979cd610507ea1a69c0" } diff --git a/backend/.sqlx/query-58bd05ee7354bb2aa7abffa7fa962f7143e071b9a1d39bca44e2e94512931209.json b/backend/.sqlx/query-d0817b3befb23062a2a07817d799aec60292b0f567970403a35f5db5b5915aad.json similarity index 71% rename from backend/.sqlx/query-58bd05ee7354bb2aa7abffa7fa962f7143e071b9a1d39bca44e2e94512931209.json rename to backend/.sqlx/query-d0817b3befb23062a2a07817d799aec60292b0f567970403a35f5db5b5915aad.json index c3bc6a7a..f6c1fee5 100644 --- a/backend/.sqlx/query-58bd05ee7354bb2aa7abffa7fa962f7143e071b9a1d39bca44e2e94512931209.json +++ b/backend/.sqlx/query-d0817b3befb23062a2a07817d799aec60292b0f567970403a35f5db5b5915aad.json @@ -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\", updated_at as \"updated_at!: DateTime\"\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\", updated_at as \"updated_at!: DateTime\"\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", + "name": "executor", "ordinal": 5, "type_info": "Text" }, { - "name": "updated_at!: DateTime", + "name": "created_at!: DateTime", "ordinal": 6, "type_info": "Text" + }, + { + "name": "updated_at!: DateTime", + "ordinal": 7, + "type_info": "Text" } ], "parameters": { @@ -46,11 +51,12 @@ true, false, false, + false, true, true, false, false ] }, - "hash": "58bd05ee7354bb2aa7abffa7fa962f7143e071b9a1d39bca44e2e94512931209" + "hash": "d0817b3befb23062a2a07817d799aec60292b0f567970403a35f5db5b5915aad" } diff --git a/backend/Cargo.toml b/backend/Cargo.toml index eac70c22..621f48e6 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -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"] } diff --git a/backend/migrations/20250701000000_add_branch_to_task_attempts.sql b/backend/migrations/20250701000000_add_branch_to_task_attempts.sql new file mode 100644 index 00000000..47c24288 --- /dev/null +++ b/backend/migrations/20250701000000_add_branch_to_task_attempts.sql @@ -0,0 +1,2 @@ +-- Add branch column to task_attempts table +ALTER TABLE task_attempts ADD COLUMN branch TEXT NOT NULL DEFAULT ''; diff --git a/backend/src/models/task_attempt.rs b/backend/src/models/task_attempt.rs index b69f9872..853d7eff 100644 --- a/backend/src/models/task_attempt.rs +++ b/backend/src/models/task_attempt.rs @@ -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, pub executor: Option, // Name of the executor to use pub created_at: DateTime, @@ -139,7 +137,7 @@ impl TaskAttempt { pub async fn find_by_id(pool: &SqlitePool, id: Uuid) -> Result, 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", updated_at as "updated_at!: DateTime" + r#"SELECT id as "id!: Uuid", task_id as "task_id!: Uuid", worktree_path, branch, merge_commit, executor, created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime" FROM task_attempts WHERE id = $1"#, id @@ -154,7 +152,7 @@ impl TaskAttempt { ) -> Result, 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", updated_at as "updated_at!: DateTime" + r#"SELECT id as "id!: Uuid", task_id as "task_id!: Uuid", worktree_path, branch, merge_commit, executor, created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime" FROM task_attempts WHERE task_id = $1 ORDER BY created_at DESC"#, @@ -170,57 +168,55 @@ impl TaskAttempt { task_id: Uuid, ) -> Result { 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)?; + // 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 { + repo.head()? + }; + + // Create branch + repo.branch( + &task_attempt_branch, + &base_reference.peel_to_commit()?, + false, + )?; + + let branch = repo.find_branch(&task_attempt_branch, BranchType::Local)?; + let branch_ref = branch.into_reference(); 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) - } else { - let local = format!("refs/heads/{}", branch); - if repo.find_reference(&local).is_ok() { - local - } else { - format!("refs/remotes/origin/{}", branch) - } - }; - // 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))?; - - worktree_opts.reference(Some(&new_base_ref)); - } + 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", updated_at as "updated_at!: DateTime""#, + 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", updated_at as "updated_at!: DateTime""#, attempt_id, task_id, worktree_path_str, + task_attempt_branch, Option::::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 { + // 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, ) -> Result { // 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)?)?; + // 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.", + ))); + } - // 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()) + // 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", ta.updated_at as "updated_at!: DateTime" + 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", ta.updated_at as "updated_at!: DateTime" FROM task_attempts ta JOIN tasks t ON ta.task_id = t.id WHERE ta.id = $1 AND t.id = $2 AND t.project_id = $3"#, @@ -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", ta.updated_at as "updated_at!: DateTime" + 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", ta.updated_at as "updated_at!: DateTime" FROM task_attempts ta JOIN tasks t ON ta.task_id = t.id WHERE ta.id = $1 AND t.id = $2 AND t.project_id = $3"#, @@ -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", ta.updated_at as "updated_at!: DateTime" + 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", ta.updated_at as "updated_at!: DateTime" FROM task_attempts ta JOIN tasks t ON ta.task_id = t.id WHERE ta.id = $1 AND t.id = $2 AND t.project_id = $3"#, @@ -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 { - 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 = 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, ) -> Result { // Get the task attempt with validation let attempt = sqlx::query_as!( TaskAttempt, - r#"SELECT ta.id as "id!: Uuid", ta.task_id as "task_id!: Uuid", ta.worktree_path, ta.merge_commit, ta.executor, ta.created_at as "created_at!: DateTime", ta.updated_at as "updated_at!: DateTime" + 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", ta.updated_at as "updated_at!: DateTime" FROM task_attempts ta JOIN tasks t ON ta.task_id = t.id WHERE ta.id = $1 AND t.id = $2 AND t.project_id = $3"#, @@ -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", ta.updated_at as "updated_at!: DateTime" + 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", ta.updated_at as "updated_at!: DateTime" FROM task_attempts ta JOIN tasks t ON ta.task_id = t.id WHERE ta.id = $1 AND t.id = $2 AND t.project_id = $3"#, diff --git a/backend/src/routes/task_attempts.rs b/backend/src/routes/task_attempts.rs index 3548af93..61aff8d1 100644 --- a/backend/src/routes/task_attempts.rs +++ b/backend/src/routes/task_attempts.rs @@ -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, +} + pub async fn get_task_attempts( Path((project_id, task_id)): Path<(Uuid, Uuid)>, Extension(pool): Extension, @@ -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, + request_body: Option>, ) -> Result>, StatusCode> { // Verify task attempt exists and belongs to the correct task match TaskAttempt::exists_for_task(&pool, attempt_id, task_id, project_id).await { @@ -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, diff --git a/backend/src/utils.rs b/backend/src/utils.rs index 6292bc44..f102df99 100644 --- a/backend/src/utils.rs +++ b/backend/src/utils.rs @@ -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) { diff --git a/backend/src/utils/text.rs b/backend/src/utils/text.rs new file mode 100644 index 00000000..94831b04 --- /dev/null +++ b/backend/src/utils/text.rs @@ -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 +} diff --git a/shared/types.ts b/shared/types.ts index f3d6cade..60742439 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -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, };