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:
Louis Knight-Webb
2025-07-01 15:11:51 +01:00
committed by GitHub
parent a1c97f787e
commit c9fada8979
11 changed files with 250 additions and 221 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,2 @@
-- Add branch column to task_attempts table
ALTER TABLE task_attempts ADD COLUMN branch TEXT NOT NULL DEFAULT '';

View File

@@ -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)?;
// 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<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)?)?;
// 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<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"#,

View File

@@ -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,

View File

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

View File

@@ -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, };