Add base branch (vibe-kanban) (#100)

* Excellent! Everything compiles successfully.

## Summary

I have successfully implemented the base branch feature as requested. Here's what was accomplished:

###  Completed Tasks

1. **Database Migration**: Created `20250708000000_add_base_branch_to_task_attempts.sql` that adds a `base_branch` column with NOT NULL and default value 'main'

2. **Backend Model Updates**: 
   - Added `base_branch: String` field to the `TaskAttempt` struct
   - Updated all SQL queries to include the new column
   - Modified task attempt creation logic to determine and store the resolved base branch (using provided base_branch or current HEAD branch name with fallback to "main")

3. **Improved Branch Status Logic**: 
   - Replaced the complex branch discovery algorithm with simple use of the stored `base_branch`
   - This gives accurate and consistent results since the base branch is now explicitly known

4. **Updated Operations**:
   - **PR Creation**: Now uses stored base branch as fallback when no explicit base branch is provided
   - **Rebase**: Now uses stored base branch as fallback when no new base branch is specified
   - **Merge**: Already used task context, no changes needed

5. **TypeScript Types**: Regenerated to include the new `base_branch: string` field

### 🔧 Key Implementation Details

- **Base Branch Resolution**: When creating a task attempt, the system now:
  1. Uses the explicitly provided `base_branch` if available
  2. Falls back to the current HEAD branch name of the repository
  3. Defaults to "main" if the repository has no commits or other issues

- **Backward Compatibility**: The migration ensures existing records get a sensible default ("main")

- **API Consistency**: All endpoints that previously calculated base branches now use the stored value, providing consistent behavior

### 🎯 Benefits

- **Accuracy**: No more guessing which branch is the "base" - it's explicitly stored when the task attempt is created
- **Performance**: Eliminates the need to traverse git history to find the "closest" branch
- **Consistency**: Operations like merge, rebase, and PR creation now all reference the same base branch
- **User Control**: Users can specify the base branch when creating task attempts
- **Reliability**: Reduces git-related errors from branch discovery algorithms

The implementation maintains full backward compatibility while providing the accuracy and consistency requested in the task description.

* Perfect! Now the PR creation logic follows the correct precedence:

1. **User's explicit choice**: If the user specifies a `base_branch` in the PR creation request, that takes priority
2. **Stored base branch**: Uses the `base_branch` stored in the task attempt as the default
3. **Config/fallback**: Only falls back to the GitHub config default or "main" if the stored base branch is somehow empty/invalid

This ensures that:
-  The PR defaults to the same base branch that was used when creating the task attempt
-  Users can still override this by explicitly specifying a different base branch when creating the PR
-  There's still a sensible fallback chain in case of edge cases

The behavior now matches the requirement: "The saved base branch should be saved by default, but if the user wishes to change it when creating a PR they can."

* Great! The lint passes with only pre-existing warnings (none related to my changes). 

## Summary

I have successfully updated the "Create GitHub Pull Request" dialog to:

###  Changes Made

1. **Replaced text input with dropdown**: The base branch field is now a `Select` component instead of a text `Input`

2. **Default to task attempt's base branch**: The dropdown now defaults to the `selectedAttempt.base_branch` instead of hardcoded "main"

3. **Populated with available branches**: The dropdown shows all local branches from the `branches` prop, with:
   - Current branch marked with "(current)"
   - Common fallback branches ("main", "master") if not in the list
   - Only local branches (filtered out remote branches)

4. **Proper state management**: 
   - Initial state uses the task attempt's base branch
   - Updates when selected attempt changes via `useEffect`
   - Resets to task attempt's base branch when form is cancelled or submitted

5. **User can still override**: Users can select any available branch from the dropdown, providing the flexibility to change the base branch when creating PRs

### 🎯 User Experience

- **Default behavior**: When opening the PR dialog, the base branch dropdown shows the task attempt's stored base branch as selected
- **Override capability**: Users can click the dropdown to select a different base branch if needed
- **Visual clarity**: The dropdown shows "(current)" next to the current working branch and lists all available local branches
- **Fallback safety**: Even if the stored base branch isn't in the list, common branches like "main" and "master" are available as options

The implementation maintains the principle that the stored base branch is the sensible default while giving users full control to override when necessary.

* prettier

* cargo fmt
This commit is contained in:
Louis Knight-Webb
2025-07-08 15:13:00 +01:00
committed by GitHub
parent cd5e37764f
commit 2829686a71
11 changed files with 199 additions and 162 deletions

View File

@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT id as \"id!: Uuid\", task_id as \"task_id!: Uuid\", worktree_path, branch, merge_commit, executor, pr_url, pr_number, pr_status, pr_merged_at as \"pr_merged_at: DateTime<Utc>\", 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, base_branch, merge_commit, executor, pr_url, pr_number, pr_status, pr_merged_at as \"pr_merged_at: DateTime<Utc>\", created_at as \"created_at!: DateTime<Utc>\", updated_at as \"updated_at!: DateTime<Utc>\"\n FROM task_attempts \n WHERE id = $1",
"describe": {
"columns": [
{
@@ -24,43 +24,48 @@
"type_info": "Text"
},
{
"name": "merge_commit",
"name": "base_branch",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "executor",
"name": "merge_commit",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "pr_url",
"name": "executor",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "pr_number",
"name": "pr_url",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "pr_number",
"ordinal": 8,
"type_info": "Integer"
},
{
"name": "pr_status",
"ordinal": 8,
"ordinal": 9,
"type_info": "Text"
},
{
"name": "pr_merged_at: DateTime<Utc>",
"ordinal": 9,
"ordinal": 10,
"type_info": "Datetime"
},
{
"name": "created_at!: DateTime<Utc>",
"ordinal": 10,
"ordinal": 11,
"type_info": "Text"
},
{
"name": "updated_at!: DateTime<Utc>",
"ordinal": 11,
"ordinal": 12,
"type_info": "Text"
}
],
@@ -72,6 +77,7 @@
false,
false,
false,
false,
true,
true,
true,
@@ -82,5 +88,5 @@
false
]
},
"hash": "934682901882acc939b82cb04fba66dedf5f3d4775601cdb0be9988b35438c09"
"hash": "0cb9073512165a38ad21dc1fdb88548b8e8e0a369eb18228287a19ada5ee402c"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "INSERT INTO task_attempts (id, task_id, worktree_path, branch, merge_commit, executor, pr_url, pr_number, pr_status, pr_merged_at) \n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) \n RETURNING id as \"id!: Uuid\", task_id as \"task_id!: Uuid\", worktree_path, branch, merge_commit, executor, pr_url, pr_number, pr_status, pr_merged_at as \"pr_merged_at: DateTime<Utc>\", 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, base_branch, merge_commit, executor, pr_url, pr_number, pr_status, pr_merged_at) \n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) \n RETURNING id as \"id!: Uuid\", task_id as \"task_id!: Uuid\", worktree_path, branch, base_branch, merge_commit, executor, pr_url, pr_number, pr_status, pr_merged_at as \"pr_merged_at: DateTime<Utc>\", created_at as \"created_at!: DateTime<Utc>\", updated_at as \"updated_at!: DateTime<Utc>\"",
"describe": {
"columns": [
{
@@ -24,54 +24,60 @@
"type_info": "Text"
},
{
"name": "merge_commit",
"name": "base_branch",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "executor",
"name": "merge_commit",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "pr_url",
"name": "executor",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "pr_number",
"name": "pr_url",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "pr_number",
"ordinal": 8,
"type_info": "Integer"
},
{
"name": "pr_status",
"ordinal": 8,
"ordinal": 9,
"type_info": "Text"
},
{
"name": "pr_merged_at: DateTime<Utc>",
"ordinal": 9,
"ordinal": 10,
"type_info": "Datetime"
},
{
"name": "created_at!: DateTime<Utc>",
"ordinal": 10,
"ordinal": 11,
"type_info": "Text"
},
{
"name": "updated_at!: DateTime<Utc>",
"ordinal": 11,
"ordinal": 12,
"type_info": "Text"
}
],
"parameters": {
"Right": 10
"Right": 11
},
"nullable": [
true,
false,
false,
false,
false,
true,
true,
true,
@@ -82,5 +88,5 @@
false
]
},
"hash": "36f9435579ce655f583eeeb419353541c30a8b4c1e047ea89d204645293bd44e"
"hash": "5634c7b542833bc811d47391bfab0af14914e93b0dc1e9ec9de8869fe661a2d4"
}

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.branch, ta.merge_commit, ta.executor, ta.pr_url, ta.pr_number, ta.pr_status, ta.pr_merged_at as \"pr_merged_at: DateTime<Utc>\", 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.base_branch, ta.merge_commit, ta.executor, ta.pr_url, ta.pr_number, ta.pr_status, ta.pr_merged_at as \"pr_merged_at: DateTime<Utc>\", 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": [
{
@@ -24,43 +24,48 @@
"type_info": "Text"
},
{
"name": "merge_commit",
"name": "base_branch",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "executor",
"name": "merge_commit",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "pr_url",
"name": "executor",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "pr_number",
"name": "pr_url",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "pr_number",
"ordinal": 8,
"type_info": "Integer"
},
{
"name": "pr_status",
"ordinal": 8,
"ordinal": 9,
"type_info": "Text"
},
{
"name": "pr_merged_at: DateTime<Utc>",
"ordinal": 9,
"ordinal": 10,
"type_info": "Datetime"
},
{
"name": "created_at!: DateTime<Utc>",
"ordinal": 10,
"ordinal": 11,
"type_info": "Text"
},
{
"name": "updated_at!: DateTime<Utc>",
"ordinal": 11,
"ordinal": 12,
"type_info": "Text"
}
],
@@ -72,6 +77,7 @@
false,
false,
false,
false,
true,
true,
true,
@@ -82,5 +88,5 @@
false
]
},
"hash": "c0e3402045a18d43bcbb882c9ea158f01d116d92f7f7e0fd467af8e2066dc638"
"hash": "6d3d6f10f57247d29724962eb428fee95dbdc4ae87f8c90b7bbd7f5c88d5a271"
}

View File

@@ -1,56 +0,0 @@
{
"db_name": "SQLite",
"query": "SELECT id as \"id!: Uuid\", project_id as \"project_id!: Uuid\", title, description, status as \"status!: TaskStatus\", created_at as \"created_at!: DateTime<Utc>\", updated_at as \"updated_at!: DateTime<Utc>\"\n FROM tasks \n WHERE project_id = $1 AND title = $2\n LIMIT 1",
"describe": {
"columns": [
{
"name": "id!: Uuid",
"ordinal": 0,
"type_info": "Blob"
},
{
"name": "project_id!: Uuid",
"ordinal": 1,
"type_info": "Blob"
},
{
"name": "title",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "description",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "status!: TaskStatus",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "created_at!: DateTime<Utc>",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "updated_at!: DateTime<Utc>",
"ordinal": 6,
"type_info": "Text"
}
],
"parameters": {
"Right": 2
},
"nullable": [
true,
false,
false,
true,
false,
false,
false
]
},
"hash": "7193dead2b112b137880482fe8e8c822c67ef6692e0456683331a438a4aa002f"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT id as \"id!: Uuid\", task_id as \"task_id!: Uuid\", worktree_path, branch, merge_commit, executor, pr_url, pr_number, pr_status, pr_merged_at as \"pr_merged_at: DateTime<Utc>\", 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, base_branch, merge_commit, executor, pr_url, pr_number, pr_status, pr_merged_at as \"pr_merged_at: DateTime<Utc>\", 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": [
{
@@ -24,43 +24,48 @@
"type_info": "Text"
},
{
"name": "merge_commit",
"name": "base_branch",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "executor",
"name": "merge_commit",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "pr_url",
"name": "executor",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "pr_number",
"name": "pr_url",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "pr_number",
"ordinal": 8,
"type_info": "Integer"
},
{
"name": "pr_status",
"ordinal": 8,
"ordinal": 9,
"type_info": "Text"
},
{
"name": "pr_merged_at: DateTime<Utc>",
"ordinal": 9,
"ordinal": 10,
"type_info": "Datetime"
},
{
"name": "created_at!: DateTime<Utc>",
"ordinal": 10,
"ordinal": 11,
"type_info": "Text"
},
{
"name": "updated_at!: DateTime<Utc>",
"ordinal": 11,
"ordinal": 12,
"type_info": "Text"
}
],
@@ -72,6 +77,7 @@
false,
false,
false,
false,
true,
true,
true,
@@ -82,5 +88,5 @@
false
]
},
"hash": "b0e812c0c8e6456e1ae11a89e8c03e29727de9d08652b61350e0555d348ecc59"
"hash": "849ce16b3d341fe420816fe91c345a44475a4383221bfe7711e1ee1b5b3b0aeb"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT ta.id AS \"id!: Uuid\",\n ta.task_id AS \"task_id!: Uuid\",\n ta.worktree_path,\n ta.branch,\n ta.merge_commit,\n ta.executor,\n ta.pr_url,\n ta.pr_number,\n ta.pr_status,\n ta.pr_merged_at AS \"pr_merged_at: DateTime<Utc>\",\n ta.created_at AS \"created_at!: DateTime<Utc>\",\n 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\n AND t.id = $2\n AND t.project_id = $3\n ",
"query": "\n SELECT ta.id AS \"id!: Uuid\",\n ta.task_id AS \"task_id!: Uuid\",\n ta.worktree_path,\n ta.branch,\n ta.base_branch,\n ta.merge_commit,\n ta.executor,\n ta.pr_url,\n ta.pr_number,\n ta.pr_status,\n ta.pr_merged_at AS \"pr_merged_at: DateTime<Utc>\",\n ta.created_at AS \"created_at!: DateTime<Utc>\",\n 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\n AND t.id = $2\n AND t.project_id = $3\n ",
"describe": {
"columns": [
{
@@ -24,43 +24,48 @@
"type_info": "Text"
},
{
"name": "merge_commit",
"name": "base_branch",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "executor",
"name": "merge_commit",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "pr_url",
"name": "executor",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "pr_number",
"name": "pr_url",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "pr_number",
"ordinal": 8,
"type_info": "Integer"
},
{
"name": "pr_status",
"ordinal": 8,
"ordinal": 9,
"type_info": "Text"
},
{
"name": "pr_merged_at: DateTime<Utc>",
"ordinal": 9,
"ordinal": 10,
"type_info": "Datetime"
},
{
"name": "created_at!: DateTime<Utc>",
"ordinal": 10,
"ordinal": 11,
"type_info": "Text"
},
{
"name": "updated_at!: DateTime<Utc>",
"ordinal": 11,
"ordinal": 12,
"type_info": "Text"
}
],
@@ -72,6 +77,7 @@
false,
false,
false,
false,
true,
true,
true,
@@ -82,5 +88,5 @@
false
]
},
"hash": "fe7c982685e4d98b871b03535de042e64b3b28f2c2837d064031159ea5048029"
"hash": "bb7926e3e1698b72ade97fa5f32314f0a2cf3e7eb3f472c32de2962a2cc806d4"
}

View File

@@ -0,0 +1,2 @@
-- Add base_branch column to task_attempts table with default value
ALTER TABLE task_attempts ADD COLUMN base_branch TEXT NOT NULL DEFAULT 'main';

View File

@@ -67,7 +67,8 @@ 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 branch: String, // Git branch name for this task attempt
pub base_branch: String, // Base branch this attempt is based on
pub merge_commit: Option<String>,
pub executor: Option<String>, // Name of the executor to use
pub pr_url: Option<String>, // GitHub PR URL
@@ -175,7 +176,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, branch, merge_commit, executor, pr_url, pr_number, pr_status, pr_merged_at as "pr_merged_at: DateTime<Utc>", 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, base_branch, merge_commit, executor, pr_url, pr_number, pr_status, pr_merged_at as "pr_merged_at: DateTime<Utc>", created_at as "created_at!: DateTime<Utc>", updated_at as "updated_at!: DateTime<Utc>"
FROM task_attempts
WHERE id = $1"#,
id
@@ -190,7 +191,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, branch, merge_commit, executor, pr_url, pr_number, pr_status, pr_merged_at as "pr_merged_at: DateTime<Utc>", 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, base_branch, merge_commit, executor, pr_url, pr_number, pr_status, pr_merged_at as "pr_merged_at: DateTime<Utc>", 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"#,
@@ -231,6 +232,25 @@ impl TaskAttempt {
.await?
.ok_or(TaskAttemptError::ProjectNotFound)?;
// Determine the resolved base branch name first
let resolved_base_branch = if let Some(ref base_branch) = data.base_branch {
base_branch.clone()
} else {
// Default to current HEAD branch name or "main"
let repo = Repository::open(&project.git_repo_path)?;
let default_branch = match repo.head() {
Ok(head_ref) => head_ref.shorthand().unwrap_or("main").to_string(),
Err(e)
if e.class() == git2::ErrorClass::Reference
&& e.code() == git2::ErrorCode::UnbornBranch =>
{
"main".to_string() // Repository has no commits yet
}
Err(_) => "main".to_string(), // Fallback
};
default_branch
};
// Solve scoping issues
{
// Create the worktree using git2
@@ -305,13 +325,14 @@ impl TaskAttempt {
// Insert the record into the database
Ok(sqlx::query_as!(
TaskAttempt,
r#"INSERT INTO task_attempts (id, task_id, worktree_path, branch, merge_commit, executor, pr_url, pr_number, pr_status, pr_merged_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id as "id!: Uuid", task_id as "task_id!: Uuid", worktree_path, branch, merge_commit, executor, pr_url, pr_number, pr_status, pr_merged_at as "pr_merged_at: DateTime<Utc>", 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, base_branch, merge_commit, executor, pr_url, pr_number, pr_status, pr_merged_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING id as "id!: Uuid", task_id as "task_id!: Uuid", worktree_path, branch, base_branch, merge_commit, executor, pr_url, pr_number, pr_status, pr_merged_at as "pr_merged_at: DateTime<Utc>", created_at as "created_at!: DateTime<Utc>", updated_at as "updated_at!: DateTime<Utc>""#,
attempt_id,
task_id,
worktree_path_str,
task_attempt_branch,
resolved_base_branch,
Option::<String>::None, // merge_commit is always None during creation
data.executor,
Option::<String>::None, // pr_url is None during creation
@@ -477,7 +498,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.branch, ta.merge_commit, ta.executor, ta.pr_url, ta.pr_number, ta.pr_status, ta.pr_merged_at as "pr_merged_at: DateTime<Utc>", 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.base_branch, ta.merge_commit, ta.executor, ta.pr_url, ta.pr_number, ta.pr_status, ta.pr_merged_at as "pr_merged_at: DateTime<Utc>", 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"#,
@@ -1122,7 +1143,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.branch, ta.merge_commit, ta.executor, ta.pr_url, ta.pr_number, ta.pr_status, ta.pr_merged_at as "pr_merged_at: DateTime<Utc>", 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.base_branch, ta.merge_commit, ta.executor, ta.pr_url, ta.pr_number, ta.pr_status, ta.pr_merged_at as "pr_merged_at: DateTime<Utc>", 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"#,
@@ -1568,6 +1589,7 @@ impl TaskAttempt {
ta.task_id AS "task_id!: Uuid",
ta.worktree_path,
ta.branch,
ta.base_branch,
ta.merge_commit,
ta.executor,
ta.pr_url,
@@ -1608,47 +1630,33 @@ impl TaskAttempt {
let attempt_oid = attempt_ref.target().unwrap();
// ── determine the base branch & ahead/behind counts ─────────────────────────
let mut base_branch_name = String::from("main"); // sensible default
let mut commits_ahead: usize = 0;
let mut commits_behind: usize = 0;
let mut best_distance: usize = usize::MAX;
let base_branch_name = attempt.base_branch.clone();
// 1. prefer the branchs configured upstream, if any
if let Ok(local_branch) = main_repo.find_branch(&attempt_branch, BranchType::Local) {
if let Ok(upstream) = local_branch.upstream() {
if let Some(name) = upstream.name()? {
if let Some(_name) = upstream.name()? {
if let Some(base_oid) = upstream.get().target() {
let (ahead, behind) =
let (_ahead, _behind) =
main_repo.graph_ahead_behind(attempt_oid, base_oid)?;
base_branch_name = name.to_owned();
commits_ahead = ahead;
commits_behind = behind;
best_distance = ahead + behind;
// Ignore upstream since we use stored base branch
}
}
}
}
// 2. otherwise, take the branch with the smallest ahead+behind distance
if best_distance == usize::MAX {
for br in main_repo.branches(None)? {
let (br, _) = br?;
let name = br.name()?.unwrap_or_default();
if name == attempt_branch {
continue; // skip comparing the branch to itself
// Calculate ahead/behind counts using the stored base branch
let (commits_ahead, commits_behind) =
if let Ok(base_branch) = main_repo.find_branch(&base_branch_name, BranchType::Local) {
if let Some(base_oid) = base_branch.get().target() {
main_repo.graph_ahead_behind(attempt_oid, base_oid)?
} else {
(0, 0) // Base branch has no commits
}
if let Some(base_oid) = br.get().target() {
let (ahead, behind) = main_repo.graph_ahead_behind(attempt_oid, base_oid)?;
let distance = ahead + behind;
if distance < best_distance {
best_distance = distance;
base_branch_name = name.to_owned();
commits_ahead = ahead;
commits_behind = behind;
}
}
}
}
} else {
// Base branch doesn't exist, assume no relationship
(0, 0)
};
// ── detect any uncommitted / untracked changes ───────────────────────────────
let repo_for_status = Repository::open(&project.git_repo_path)?;
@@ -1687,7 +1695,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.branch, ta.merge_commit, ta.executor, ta.pr_url, ta.pr_number, ta.pr_status, ta.pr_merged_at as "pr_merged_at: DateTime<Utc>", 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.base_branch, ta.merge_commit, ta.executor, ta.pr_url, ta.pr_number, ta.pr_status, ta.pr_merged_at as "pr_merged_at: DateTime<Utc>", 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"#,
@@ -1704,11 +1712,14 @@ impl TaskAttempt {
.await?
.ok_or(TaskAttemptError::ProjectNotFound)?;
// Use the stored base branch if no new base branch is provided
let effective_base_branch = new_base_branch.or_else(|| Some(attempt.base_branch.clone()));
// Perform the git rebase operations (synchronous)
let new_base_commit = Self::perform_rebase_operation(
&attempt.worktree_path,
&project.git_repo_path,
new_base_branch,
effective_base_branch,
)?;
// No need to update database as we now get base_commit live from git
@@ -1726,7 +1737,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.branch, ta.merge_commit, ta.executor, ta.pr_url, ta.pr_number, ta.pr_status, ta.pr_merged_at as "pr_merged_at: DateTime<Utc>", 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.base_branch, ta.merge_commit, ta.executor, ta.pr_url, ta.pr_number, ta.pr_status, ta.pr_merged_at as "pr_merged_at: DateTime<Utc>", 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"#,
@@ -1796,7 +1807,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.branch, ta.merge_commit, ta.executor, ta.pr_url, ta.pr_number, ta.pr_status, ta.pr_merged_at as "pr_merged_at: DateTime<Utc>", 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.base_branch, ta.merge_commit, ta.executor, ta.pr_url, ta.pr_number, ta.pr_status, ta.pr_merged_at as "pr_merged_at: DateTime<Utc>", 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"#,
@@ -2041,7 +2052,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.branch, ta.merge_commit, ta.executor, ta.pr_url, ta.pr_number, ta.pr_status, ta.pr_merged_at as "pr_merged_at: DateTime<Utc>", 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.base_branch, ta.merge_commit, ta.executor, ta.pr_url, ta.pr_number, ta.pr_status, ta.pr_merged_at as "pr_merged_at: DateTime<Utc>", 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

@@ -329,10 +329,28 @@ pub async fn create_github_pr(
}
};
let base_branch = request
.base_branch
.or(config.github.default_pr_base)
.unwrap_or_else(|| "main".to_string());
// Get the task attempt to access the stored base branch
let attempt = match TaskAttempt::find_by_id(&app_state.db_pool, attempt_id).await {
Ok(Some(attempt)) => attempt,
Ok(None) => return Err(StatusCode::NOT_FOUND),
Err(e) => {
tracing::error!("Failed to fetch task attempt {}: {}", attempt_id, e);
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
};
let base_branch = request.base_branch.unwrap_or_else(|| {
// Use the stored base branch from the task attempt as the default
// Fall back to config default or "main" only if stored base branch is somehow invalid
if !attempt.base_branch.trim().is_empty() {
attempt.base_branch.clone()
} else {
config
.github
.default_pr_base
.unwrap_or_else(|| "main".to_string())
}
});
match TaskAttempt::create_github_pr(
&app_state.db_pool,

View File

@@ -17,6 +17,13 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
DropdownMenu,
DropdownMenuContent,
@@ -131,7 +138,9 @@ export function TaskDetailsToolbar({
const [showCreatePRDialog, setShowCreatePRDialog] = useState(false);
const [prTitle, setPrTitle] = useState('');
const [prBody, setPrBody] = useState('');
const [prBaseBranch, setPrBaseBranch] = useState('main');
const [prBaseBranch, setPrBaseBranch] = useState(
selectedAttempt?.base_branch || 'main'
);
const [error, setError] = useState<string | null>(null);
// Set create attempt mode when there are no attempts
@@ -139,6 +148,13 @@ export function TaskDetailsToolbar({
setIsInCreateAttemptMode(taskAttempts.length === 0);
}, [taskAttempts.length]);
// Update PR base branch when selected attempt changes
useEffect(() => {
if (selectedAttempt?.base_branch) {
setPrBaseBranch(selectedAttempt.base_branch);
}
}, [selectedAttempt?.base_branch]);
// Branch status fetching
const fetchBranchStatus = useCallback(async () => {
if (!projectId || !selectedAttempt?.id || !selectedAttempt?.task_id) return;
@@ -286,7 +302,7 @@ export function TaskDetailsToolbar({
// Reset form
setPrTitle('');
setPrBody('');
setPrBaseBranch('main');
setPrBaseBranch(selectedAttempt?.base_branch || 'main');
} else {
setError(result.message || 'Failed to create GitHub PR');
}
@@ -908,12 +924,28 @@ export function TaskDetailsToolbar({
</div>
<div className="space-y-2">
<Label htmlFor="pr-base">Base Branch</Label>
<Input
id="pr-base"
value={prBaseBranch}
onChange={(e) => setPrBaseBranch(e.target.value)}
placeholder="main"
/>
<Select value={prBaseBranch} onValueChange={setPrBaseBranch}>
<SelectTrigger>
<SelectValue placeholder="Select base branch" />
</SelectTrigger>
<SelectContent>
{branches
.filter((branch) => !branch.is_remote) // Only show local branches
.map((branch) => (
<SelectItem key={branch.name} value={branch.name}>
{branch.name}
{branch.is_current && ' (current)'}
</SelectItem>
))}
{/* Add common branches as fallback if not in the list */}
{!branches.some((b) => b.name === 'main' && !b.is_remote) && (
<SelectItem value="main">main</SelectItem>
)}
{!branches.some(
(b) => b.name === 'master' && !b.is_remote
) && <SelectItem value="master">master</SelectItem>}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>

View File

@@ -56,7 +56,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, branch: string, merge_commit: string | null, executor: string | null, pr_url: string | null, pr_number: bigint | null, pr_status: string | null, pr_merged_at: string | null, created_at: string, updated_at: string, };
export type TaskAttempt = { id: string, task_id: string, worktree_path: string, branch: string, base_branch: string, merge_commit: string | null, executor: string | null, pr_url: string | null, pr_number: bigint | null, pr_status: string | null, pr_merged_at: string | null, created_at: string, updated_at: string, };
export type CreateTaskAttempt = { executor: string | null, base_branch: string | null, };