diff --git a/crates/db/.sqlx/query-659169a5aedc023fe0aad16dd3f5e07cfa9177808cec69c2034dfaeec3baffec.json b/crates/db/.sqlx/query-08e34e1618c0fa396fcb4d98b27c87810b1d1281d52463981910160257f60948.json similarity index 55% rename from crates/db/.sqlx/query-659169a5aedc023fe0aad16dd3f5e07cfa9177808cec69c2034dfaeec3baffec.json rename to crates/db/.sqlx/query-08e34e1618c0fa396fcb4d98b27c87810b1d1281d52463981910160257f60948.json index 1a0ec25e..69596af5 100644 --- a/crates/db/.sqlx/query-659169a5aedc023fe0aad16dd3f5e07cfa9177808cec69c2034dfaeec3baffec.json +++ b/crates/db/.sqlx/query-08e34e1618c0fa396fcb4d98b27c87810b1d1281d52463981910160257f60948.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT\n ep.id as \"id!: Uuid\",\n ep.task_attempt_id as \"task_attempt_id!: Uuid\",\n ep.after_head_commit as after_head_commit,\n prev.after_head_commit as prev_after_head_commit,\n ta.base_branch as base_branch,\n p.git_repo_path as git_repo_path\n FROM execution_processes ep\n JOIN task_attempts ta ON ta.id = ep.task_attempt_id\n JOIN tasks t ON t.id = ta.task_id\n JOIN projects p ON p.id = t.project_id\n LEFT JOIN execution_processes prev\n ON prev.task_attempt_id = ep.task_attempt_id\n AND prev.created_at = (\n SELECT max(created_at) FROM execution_processes\n WHERE task_attempt_id = ep.task_attempt_id\n AND created_at < ep.created_at\n )\n WHERE ep.before_head_commit IS NULL\n AND ep.after_head_commit IS NOT NULL", + "query": "SELECT\n ep.id as \"id!: Uuid\",\n ep.task_attempt_id as \"task_attempt_id!: Uuid\",\n ep.after_head_commit as after_head_commit,\n prev.after_head_commit as prev_after_head_commit,\n ta.target_branch as target_branch,\n p.git_repo_path as git_repo_path\n FROM execution_processes ep\n JOIN task_attempts ta ON ta.id = ep.task_attempt_id\n JOIN tasks t ON t.id = ta.task_id\n JOIN projects p ON p.id = t.project_id\n LEFT JOIN execution_processes prev\n ON prev.task_attempt_id = ep.task_attempt_id\n AND prev.created_at = (\n SELECT max(created_at) FROM execution_processes\n WHERE task_attempt_id = ep.task_attempt_id\n AND created_at < ep.created_at\n )\n WHERE ep.before_head_commit IS NULL\n AND ep.after_head_commit IS NOT NULL", "describe": { "columns": [ { @@ -24,7 +24,7 @@ "type_info": "Text" }, { - "name": "base_branch", + "name": "target_branch", "ordinal": 4, "type_info": "Text" }, @@ -46,5 +46,5 @@ false ] }, - "hash": "659169a5aedc023fe0aad16dd3f5e07cfa9177808cec69c2034dfaeec3baffec" + "hash": "08e34e1618c0fa396fcb4d98b27c87810b1d1281d52463981910160257f60948" } diff --git a/crates/db/.sqlx/query-0b8f4a030c51a0910ac0b6c79965ec94a957a5d680d004aed4e56302c90f22a0.json b/crates/db/.sqlx/query-0b8f4a030c51a0910ac0b6c79965ec94a957a5d680d004aed4e56302c90f22a0.json new file mode 100644 index 00000000..70530f2b --- /dev/null +++ b/crates/db/.sqlx/query-0b8f4a030c51a0910ac0b6c79965ec94a957a5d680d004aed4e56302c90f22a0.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE task_attempts SET target_branch = $1, updated_at = datetime('now') WHERE id = $2", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "0b8f4a030c51a0910ac0b6c79965ec94a957a5d680d004aed4e56302c90f22a0" +} diff --git a/crates/db/.sqlx/query-a465d763c8cf09aeb8a48873bdd926b02119a6592fa0df5679ebc54a8ec54dcc.json b/crates/db/.sqlx/query-23ce2ba671dd7236facb742a1e51bcabfb074983538a898252fe999ade7a252b.json similarity index 68% rename from crates/db/.sqlx/query-a465d763c8cf09aeb8a48873bdd926b02119a6592fa0df5679ebc54a8ec54dcc.json rename to crates/db/.sqlx/query-23ce2ba671dd7236facb742a1e51bcabfb074983538a898252fe999ade7a252b.json index d8a52f12..faa7ba4b 100644 --- a/crates/db/.sqlx/query-a465d763c8cf09aeb8a48873bdd926b02119a6592fa0df5679ebc54a8ec54dcc.json +++ b/crates/db/.sqlx/query-23ce2ba671dd7236facb742a1e51bcabfb074983538a898252fe999ade7a252b.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id AS \"id!: Uuid\",\n task_id AS \"task_id!: Uuid\",\n container_ref,\n branch,\n base_branch,\n executor AS \"executor!\",\n worktree_deleted AS \"worktree_deleted!: bool\",\n setup_completed_at AS \"setup_completed_at: DateTime\",\n created_at AS \"created_at!: DateTime\",\n 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\",\n task_id AS \"task_id!: Uuid\",\n container_ref,\n branch,\n target_branch,\n executor AS \"executor!\",\n worktree_deleted AS \"worktree_deleted!: bool\",\n setup_completed_at AS \"setup_completed_at: DateTime\",\n created_at AS \"created_at!: DateTime\",\n updated_at AS \"updated_at!: DateTime\"\n FROM task_attempts\n WHERE task_id = $1\n ORDER BY created_at DESC", "describe": { "columns": [ { @@ -24,7 +24,7 @@ "type_info": "Text" }, { - "name": "base_branch", + "name": "target_branch", "ordinal": 4, "type_info": "Text" }, @@ -61,7 +61,7 @@ true, false, true, - true, + false, false, true, false, @@ -70,5 +70,5 @@ false ] }, - "hash": "a465d763c8cf09aeb8a48873bdd926b02119a6592fa0df5679ebc54a8ec54dcc" + "hash": "23ce2ba671dd7236facb742a1e51bcabfb074983538a898252fe999ade7a252b" } diff --git a/crates/db/.sqlx/query-d14bc3b05882d31c51c7732b2a46cca37c7feb638791d44a2cc83647d1a73624.json b/crates/db/.sqlx/query-6957d71eeee1487e46c2941cd4a5ca4944fed47c1bee6d8723f5bc283a3c0f28.json similarity index 65% rename from crates/db/.sqlx/query-d14bc3b05882d31c51c7732b2a46cca37c7feb638791d44a2cc83647d1a73624.json rename to crates/db/.sqlx/query-6957d71eeee1487e46c2941cd4a5ca4944fed47c1bee6d8723f5bc283a3c0f28.json index 8c4b1005..a403d304 100644 --- a/crates/db/.sqlx/query-d14bc3b05882d31c51c7732b2a46cca37c7feb638791d44a2cc83647d1a73624.json +++ b/crates/db/.sqlx/query-6957d71eeee1487e46c2941cd4a5ca4944fed47c1bee6d8723f5bc283a3c0f28.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT ta.id AS \"id!: Uuid\",\n ta.task_id AS \"task_id!: Uuid\",\n ta.container_ref,\n ta.branch,\n ta.base_branch,\n ta.executor AS \"executor!\",\n ta.worktree_deleted AS \"worktree_deleted!: bool\",\n ta.setup_completed_at AS \"setup_completed_at: DateTime\",\n ta.created_at AS \"created_at!: DateTime\",\n ta.updated_at AS \"updated_at!: DateTime\"\n FROM task_attempts ta\n JOIN tasks t ON ta.task_id = t.id\n JOIN projects p ON t.project_id = p.id\n WHERE ta.id = $1 AND t.id = $2 AND p.id = $3", + "query": "SELECT ta.id AS \"id!: Uuid\",\n ta.task_id AS \"task_id!: Uuid\",\n ta.container_ref,\n ta.branch,\n ta.target_branch,\n ta.executor AS \"executor!\",\n ta.worktree_deleted AS \"worktree_deleted!: bool\",\n ta.setup_completed_at AS \"setup_completed_at: DateTime\",\n ta.created_at AS \"created_at!: DateTime\",\n ta.updated_at AS \"updated_at!: DateTime\"\n FROM task_attempts ta\n JOIN tasks t ON ta.task_id = t.id\n JOIN projects p ON t.project_id = p.id\n WHERE ta.id = $1 AND t.id = $2 AND p.id = $3", "describe": { "columns": [ { @@ -24,7 +24,7 @@ "type_info": "Text" }, { - "name": "base_branch", + "name": "target_branch", "ordinal": 4, "type_info": "Text" }, @@ -61,7 +61,7 @@ true, false, true, - true, + false, false, true, false, @@ -70,5 +70,5 @@ false ] }, - "hash": "d14bc3b05882d31c51c7732b2a46cca37c7feb638791d44a2cc83647d1a73624" + "hash": "6957d71eeee1487e46c2941cd4a5ca4944fed47c1bee6d8723f5bc283a3c0f28" } diff --git a/crates/db/.sqlx/query-ac5247c8d7fb86e4650c4b0eb9420031614c831b7b085083bac20c1af314c538.json b/crates/db/.sqlx/query-ac5247c8d7fb86e4650c4b0eb9420031614c831b7b085083bac20c1af314c538.json deleted file mode 100644 index 7a59e4c0..00000000 --- a/crates/db/.sqlx/query-ac5247c8d7fb86e4650c4b0eb9420031614c831b7b085083bac20c1af314c538.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "UPDATE task_attempts SET base_branch = $1, updated_at = datetime('now') WHERE id = $2", - "describe": { - "columns": [], - "parameters": { - "Right": 2 - }, - "nullable": [] - }, - "hash": "ac5247c8d7fb86e4650c4b0eb9420031614c831b7b085083bac20c1af314c538" -} diff --git a/crates/db/.sqlx/query-c952513c0d53c8a15d8b0c212fa754f2817e3a810ebe4199dd0d85eedc640164.json b/crates/db/.sqlx/query-adfc9d782a740933bb88f1f718627ab9302b0de9b4b500a582cba8d4ecbad3b2.json similarity index 68% rename from crates/db/.sqlx/query-c952513c0d53c8a15d8b0c212fa754f2817e3a810ebe4199dd0d85eedc640164.json rename to crates/db/.sqlx/query-adfc9d782a740933bb88f1f718627ab9302b0de9b4b500a582cba8d4ecbad3b2.json index 37780364..92cdca70 100644 --- a/crates/db/.sqlx/query-c952513c0d53c8a15d8b0c212fa754f2817e3a810ebe4199dd0d85eedc640164.json +++ b/crates/db/.sqlx/query-adfc9d782a740933bb88f1f718627ab9302b0de9b4b500a582cba8d4ecbad3b2.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "INSERT INTO task_attempts (id, task_id, container_ref, branch, base_branch, executor, worktree_deleted, setup_completed_at)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8)\n RETURNING id as \"id!: Uuid\", task_id as \"task_id!: Uuid\", container_ref, branch, base_branch, executor as \"executor!\", worktree_deleted as \"worktree_deleted!: bool\", setup_completed_at as \"setup_completed_at: DateTime\", created_at as \"created_at!: DateTime\", updated_at as \"updated_at!: DateTime\"", + "query": "INSERT INTO task_attempts (id, task_id, container_ref, branch, target_branch, executor, worktree_deleted, setup_completed_at)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8)\n RETURNING id as \"id!: Uuid\", task_id as \"task_id!: Uuid\", container_ref, branch, target_branch, executor as \"executor!\", worktree_deleted as \"worktree_deleted!: bool\", setup_completed_at as \"setup_completed_at: DateTime\", created_at as \"created_at!: DateTime\", updated_at as \"updated_at!: DateTime\"", "describe": { "columns": [ { @@ -24,7 +24,7 @@ "type_info": "Text" }, { - "name": "base_branch", + "name": "target_branch", "ordinal": 4, "type_info": "Text" }, @@ -61,7 +61,7 @@ true, false, true, - true, + false, false, true, false, @@ -70,5 +70,5 @@ false ] }, - "hash": "c952513c0d53c8a15d8b0c212fa754f2817e3a810ebe4199dd0d85eedc640164" + "hash": "adfc9d782a740933bb88f1f718627ab9302b0de9b4b500a582cba8d4ecbad3b2" } diff --git a/crates/db/.sqlx/query-003a304a303ff4e55405edab31fe1d3a7b70eb75047fbf7b750a5f72c2401dec.json b/crates/db/.sqlx/query-be12b49c2ae7efc4cbfa3cbd1b1160a2764bb452e13afd738254d6d4a3e61939.json similarity index 70% rename from crates/db/.sqlx/query-003a304a303ff4e55405edab31fe1d3a7b70eb75047fbf7b750a5f72c2401dec.json rename to crates/db/.sqlx/query-be12b49c2ae7efc4cbfa3cbd1b1160a2764bb452e13afd738254d6d4a3e61939.json index 24d74a5b..7a236965 100644 --- a/crates/db/.sqlx/query-003a304a303ff4e55405edab31fe1d3a7b70eb75047fbf7b750a5f72c2401dec.json +++ b/crates/db/.sqlx/query-be12b49c2ae7efc4cbfa3cbd1b1160a2764bb452e13afd738254d6d4a3e61939.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id AS \"id!: Uuid\",\n task_id AS \"task_id!: Uuid\",\n container_ref,\n branch,\n base_branch,\n executor AS \"executor!\",\n worktree_deleted AS \"worktree_deleted!: bool\",\n setup_completed_at AS \"setup_completed_at: DateTime\",\n created_at AS \"created_at!: DateTime\",\n updated_at AS \"updated_at!: DateTime\"\n FROM task_attempts\n WHERE id = $1", + "query": "SELECT id AS \"id!: Uuid\",\n task_id AS \"task_id!: Uuid\",\n container_ref,\n branch,\n target_branch,\n executor AS \"executor!\",\n worktree_deleted AS \"worktree_deleted!: bool\",\n setup_completed_at AS \"setup_completed_at: DateTime\",\n created_at AS \"created_at!: DateTime\",\n updated_at AS \"updated_at!: DateTime\"\n FROM task_attempts\n WHERE id = $1", "describe": { "columns": [ { @@ -24,7 +24,7 @@ "type_info": "Text" }, { - "name": "base_branch", + "name": "target_branch", "ordinal": 4, "type_info": "Text" }, @@ -61,7 +61,7 @@ true, false, true, - true, + false, false, true, false, @@ -70,5 +70,5 @@ false ] }, - "hash": "003a304a303ff4e55405edab31fe1d3a7b70eb75047fbf7b750a5f72c2401dec" + "hash": "be12b49c2ae7efc4cbfa3cbd1b1160a2764bb452e13afd738254d6d4a3e61939" } diff --git a/crates/db/.sqlx/query-ce908743b4ad501211d530c4b25ce8ab99a94962d5aa92117a6039201ffa6c2c.json b/crates/db/.sqlx/query-ce908743b4ad501211d530c4b25ce8ab99a94962d5aa92117a6039201ffa6c2c.json deleted file mode 100644 index 383ee706..00000000 --- a/crates/db/.sqlx/query-ce908743b4ad501211d530c4b25ce8ab99a94962d5aa92117a6039201ffa6c2c.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "UPDATE task_attempts SET branch = $1, updated_at = $2 WHERE id = $3", - "describe": { - "columns": [], - "parameters": { - "Right": 3 - }, - "nullable": [] - }, - "hash": "ce908743b4ad501211d530c4b25ce8ab99a94962d5aa92117a6039201ffa6c2c" -} diff --git a/crates/db/.sqlx/query-a2a728b99b3bd2dfd2e7d9ce57c10e0b37f187025c1e5ac567ca4fbd43b9d9be.json b/crates/db/.sqlx/query-e1e95d9c5f4c13404e222f101438c15e30611d4cd86a7c05a3fb20618e9ce2f2.json similarity index 70% rename from crates/db/.sqlx/query-a2a728b99b3bd2dfd2e7d9ce57c10e0b37f187025c1e5ac567ca4fbd43b9d9be.json rename to crates/db/.sqlx/query-e1e95d9c5f4c13404e222f101438c15e30611d4cd86a7c05a3fb20618e9ce2f2.json index 544df60e..307e9445 100644 --- a/crates/db/.sqlx/query-a2a728b99b3bd2dfd2e7d9ce57c10e0b37f187025c1e5ac567ca4fbd43b9d9be.json +++ b/crates/db/.sqlx/query-e1e95d9c5f4c13404e222f101438c15e30611d4cd86a7c05a3fb20618e9ce2f2.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id AS \"id!: Uuid\",\n task_id AS \"task_id!: Uuid\",\n container_ref,\n branch,\n base_branch,\n executor AS \"executor!\",\n worktree_deleted AS \"worktree_deleted!: bool\",\n setup_completed_at AS \"setup_completed_at: DateTime\",\n created_at AS \"created_at!: DateTime\",\n updated_at AS \"updated_at!: DateTime\"\n FROM task_attempts\n WHERE rowid = $1", + "query": "SELECT id AS \"id!: Uuid\",\n task_id AS \"task_id!: Uuid\",\n container_ref,\n branch,\n target_branch,\n executor AS \"executor!\",\n worktree_deleted AS \"worktree_deleted!: bool\",\n setup_completed_at AS \"setup_completed_at: DateTime\",\n created_at AS \"created_at!: DateTime\",\n updated_at AS \"updated_at!: DateTime\"\n FROM task_attempts\n WHERE rowid = $1", "describe": { "columns": [ { @@ -24,7 +24,7 @@ "type_info": "Text" }, { - "name": "base_branch", + "name": "target_branch", "ordinal": 4, "type_info": "Text" }, @@ -61,7 +61,7 @@ true, false, true, - true, + false, false, true, false, @@ -70,5 +70,5 @@ false ] }, - "hash": "a2a728b99b3bd2dfd2e7d9ce57c10e0b37f187025c1e5ac567ca4fbd43b9d9be" + "hash": "e1e95d9c5f4c13404e222f101438c15e30611d4cd86a7c05a3fb20618e9ce2f2" } diff --git a/crates/db/.sqlx/query-dbf12fb4f86a70f59781c533a299ff855581b5842f493a7fb7ebed2618db7af9.json b/crates/db/.sqlx/query-f8ace3ce8e2f0170a0e1646656c308e89a2ff964beda07a785d2be4cd95d173b.json similarity index 69% rename from crates/db/.sqlx/query-dbf12fb4f86a70f59781c533a299ff855581b5842f493a7fb7ebed2618db7af9.json rename to crates/db/.sqlx/query-f8ace3ce8e2f0170a0e1646656c308e89a2ff964beda07a785d2be4cd95d173b.json index a8332178..f21153b8 100644 --- a/crates/db/.sqlx/query-dbf12fb4f86a70f59781c533a299ff855581b5842f493a7fb7ebed2618db7af9.json +++ b/crates/db/.sqlx/query-f8ace3ce8e2f0170a0e1646656c308e89a2ff964beda07a785d2be4cd95d173b.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id AS \"id!: Uuid\",\n task_id AS \"task_id!: Uuid\",\n container_ref,\n branch,\n base_branch,\n executor AS \"executor!\",\n worktree_deleted AS \"worktree_deleted!: bool\",\n setup_completed_at AS \"setup_completed_at: DateTime\",\n created_at AS \"created_at!: DateTime\",\n updated_at AS \"updated_at!: DateTime\"\n FROM task_attempts\n ORDER BY created_at DESC", + "query": "SELECT id AS \"id!: Uuid\",\n task_id AS \"task_id!: Uuid\",\n container_ref,\n branch,\n target_branch,\n executor AS \"executor!\",\n worktree_deleted AS \"worktree_deleted!: bool\",\n setup_completed_at AS \"setup_completed_at: DateTime\",\n created_at AS \"created_at!: DateTime\",\n updated_at AS \"updated_at!: DateTime\"\n FROM task_attempts\n ORDER BY created_at DESC", "describe": { "columns": [ { @@ -24,7 +24,7 @@ "type_info": "Text" }, { - "name": "base_branch", + "name": "target_branch", "ordinal": 4, "type_info": "Text" }, @@ -61,7 +61,7 @@ true, false, true, - true, + false, false, true, false, @@ -70,5 +70,5 @@ false ] }, - "hash": "dbf12fb4f86a70f59781c533a299ff855581b5842f493a7fb7ebed2618db7af9" + "hash": "f8ace3ce8e2f0170a0e1646656c308e89a2ff964beda07a785d2be4cd95d173b" } diff --git a/crates/db/migrations/20250923000000_make_branch_non_null.sql b/crates/db/migrations/20250923000000_make_branch_non_null.sql new file mode 100644 index 00000000..35e9c5cd --- /dev/null +++ b/crates/db/migrations/20250923000000_make_branch_non_null.sql @@ -0,0 +1,19 @@ +-- Make branch column NOT NULL by recreating it +-- First update any NULL values to 'main' +-- Note: NULL values should not exist in practice, this is just a safety measure +UPDATE task_attempts SET branch = 'main' WHERE branch IS NULL; + +-- 1) Create replacement column (NOT NULL TEXT) +ALTER TABLE task_attempts ADD COLUMN branch_new TEXT NOT NULL DEFAULT 'main'; + +-- 2) Copy existing values +UPDATE task_attempts SET branch_new = branch; + +-- 3) Remove the old nullable column +ALTER TABLE task_attempts DROP COLUMN branch; + +-- 4) Keep the original column name +ALTER TABLE task_attempts RENAME COLUMN branch_new TO branch; + +-- Rename base_branch to target_branch now that we only need one column +ALTER TABLE task_attempts RENAME COLUMN base_branch TO target_branch; \ No newline at end of file diff --git a/crates/db/src/models/execution_process.rs b/crates/db/src/models/execution_process.rs index 6bd80aae..02fdcb90 100644 --- a/crates/db/src/models/execution_process.rs +++ b/crates/db/src/models/execution_process.rs @@ -100,7 +100,7 @@ pub struct MissingBeforeContext { pub id: Uuid, pub task_attempt_id: Uuid, pub prev_after_head_commit: Option, - pub base_branch: String, + pub target_branch: String, pub git_repo_path: Option, } @@ -130,7 +130,7 @@ impl ExecutionProcess { ep.task_attempt_id as "task_attempt_id!: Uuid", ep.after_head_commit as after_head_commit, prev.after_head_commit as prev_after_head_commit, - ta.base_branch as base_branch, + ta.target_branch as target_branch, p.git_repo_path as git_repo_path FROM execution_processes ep JOIN task_attempts ta ON ta.id = ep.task_attempt_id @@ -155,7 +155,7 @@ impl ExecutionProcess { id: r.id, task_attempt_id: r.task_attempt_id, prev_after_head_commit: r.prev_after_head_commit, - base_branch: r.base_branch, + target_branch: r.target_branch, git_repo_path: Some(r.git_repo_path), }) .collect(); diff --git a/crates/db/src/models/task_attempt.rs b/crates/db/src/models/task_attempt.rs index 84c33d1b..dd2b977d 100644 --- a/crates/db/src/models/task_attempt.rs +++ b/crates/db/src/models/task_attempt.rs @@ -39,8 +39,8 @@ pub struct TaskAttempt { pub id: Uuid, pub task_id: Uuid, // Foreign key to Task pub container_ref: Option, // Path to a worktree (local), or cloud container id - pub branch: Option, // Git branch name for this task attempt - pub base_branch: String, // Base branch this attempt is based on + pub branch: String, // Git branch name for this task attempt + pub target_branch: String, // Target branch for this attempt pub executor: String, // Name of the base coding agent to use ("AMP", "CLAUDE_CODE", // "GEMINI", etc.) pub worktree_deleted: bool, // Flag indicating if worktree has been cleaned up @@ -83,6 +83,7 @@ pub struct TaskAttemptContext { pub struct CreateTaskAttempt { pub executor: BaseCodingAgent, pub base_branch: String, + pub branch: String, } impl TaskAttempt { @@ -102,7 +103,7 @@ impl TaskAttempt { task_id AS "task_id!: Uuid", container_ref, branch, - base_branch, + target_branch, executor AS "executor!", worktree_deleted AS "worktree_deleted!: bool", setup_completed_at AS "setup_completed_at: DateTime", @@ -122,7 +123,7 @@ impl TaskAttempt { task_id AS "task_id!: Uuid", container_ref, branch, - base_branch, + target_branch, executor AS "executor!", worktree_deleted AS "worktree_deleted!: bool", setup_completed_at AS "setup_completed_at: DateTime", @@ -153,7 +154,7 @@ impl TaskAttempt { ta.task_id AS "task_id!: Uuid", ta.container_ref, ta.branch, - ta.base_branch, + ta.target_branch, ta.executor AS "executor!", ta.worktree_deleted AS "worktree_deleted!: bool", ta.setup_completed_at AS "setup_completed_at: DateTime", @@ -205,23 +206,6 @@ impl TaskAttempt { Ok(()) } - pub async fn update_branch( - pool: &SqlitePool, - attempt_id: Uuid, - branch: &str, - ) -> Result<(), sqlx::Error> { - let now = Utc::now(); - sqlx::query!( - "UPDATE task_attempts SET branch = $1, updated_at = $2 WHERE id = $3", - branch, - now, - attempt_id - ) - .execute(pool) - .await?; - Ok(()) - } - /// Helper function to mark a worktree as deleted in the database pub async fn mark_worktree_deleted( pool: &SqlitePool, @@ -243,7 +227,7 @@ impl TaskAttempt { task_id AS "task_id!: Uuid", container_ref, branch, - base_branch, + target_branch, executor AS "executor!", worktree_deleted AS "worktree_deleted!: bool", setup_completed_at AS "setup_completed_at: DateTime", @@ -264,7 +248,7 @@ impl TaskAttempt { task_id AS "task_id!: Uuid", container_ref, branch, - base_branch, + target_branch, executor AS "executor!", worktree_deleted AS "worktree_deleted!: bool", setup_completed_at AS "setup_completed_at: DateTime", @@ -381,21 +365,21 @@ impl TaskAttempt { pub async fn create( pool: &SqlitePool, data: &CreateTaskAttempt, + id: Uuid, task_id: Uuid, ) -> Result { - let attempt_id = Uuid::new_v4(); // let prefixed_id = format!("vibe-kanban-{}", attempt_id); // Insert the record into the database Ok(sqlx::query_as!( TaskAttempt, - r#"INSERT INTO task_attempts (id, task_id, container_ref, branch, base_branch, executor, worktree_deleted, setup_completed_at) + r#"INSERT INTO task_attempts (id, task_id, container_ref, branch, target_branch, executor, worktree_deleted, setup_completed_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - RETURNING id as "id!: Uuid", task_id as "task_id!: Uuid", container_ref, branch, base_branch, executor as "executor!", worktree_deleted as "worktree_deleted!: bool", setup_completed_at as "setup_completed_at: DateTime", created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime""#, - attempt_id, + RETURNING id as "id!: Uuid", task_id as "task_id!: Uuid", container_ref, branch, target_branch, executor as "executor!", worktree_deleted as "worktree_deleted!: bool", setup_completed_at as "setup_completed_at: DateTime", created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime""#, + id, task_id, Option::::None, // Container isn't known yet - Option::::None, // branch name isn't known yet - data.base_branch, + data.branch, + data.base_branch, // Target branch is same as base branch during creation data.executor, false, // worktree_deleted is false during creation Option::>::None // setup_completed_at is None during creation @@ -404,14 +388,14 @@ impl TaskAttempt { .await?) } - pub async fn update_base_branch( + pub async fn update_target_branch( pool: &SqlitePool, attempt_id: Uuid, - new_base_branch: &str, + new_target_branch: &str, ) -> Result<(), TaskAttemptError> { sqlx::query!( - "UPDATE task_attempts SET base_branch = $1, updated_at = datetime('now') WHERE id = $2", - new_base_branch, + "UPDATE task_attempts SET target_branch = $1, updated_at = datetime('now') WHERE id = $2", + new_target_branch, attempt_id, ) .execute(pool) diff --git a/crates/deployment/src/lib.rs b/crates/deployment/src/lib.rs index bc37f8d2..e0621a48 100644 --- a/crates/deployment/src/lib.rs +++ b/crates/deployment/src/lib.rs @@ -215,14 +215,14 @@ pub trait Deployment: Clone + Send + Sync + 'static { std::path::Path::new(row.git_repo_path.as_deref().unwrap_or_default()); match self .git() - .get_branch_oid(repo_path, row.base_branch.as_str()) + .get_branch_oid(repo_path, row.target_branch.as_str()) { Ok(oid) => before = Some(oid), Err(e) => { tracing::warn!( "Backfill: Failed to resolve base branch OID for attempt {} (branch {}): {}", row.task_attempt_id, - row.base_branch, + row.target_branch, e ); } diff --git a/crates/local-deployment/src/container.rs b/crates/local-deployment/src/container.rs index abe626eb..cb0a217d 100644 --- a/crates/local-deployment/src/container.rs +++ b/crates/local-deployment/src/container.rs @@ -556,11 +556,6 @@ impl LocalContainerService { format!("{}-{}", short_uuid(attempt_id), task_title_id) } - pub fn git_branch_from_task_attempt(attempt_id: &Uuid, task_title: &str) -> String { - let task_title_id = git_branch_id(task_title); - format!("vk/{}-{}", short_uuid(attempt_id), task_title_id) - } - async fn track_child_msgs_in_store(&self, id: Uuid, child: &mut AsyncGroupChild) { let store = Arc::new(MsgStore::new()); @@ -880,9 +875,6 @@ impl ContainerService for LocalContainerService { LocalContainerService::dir_name_from_task_attempt(&task_attempt.id, &task.title); let worktree_path = WorktreeManager::get_worktree_base_dir().join(&worktree_dir_name); - let git_branch_name = - LocalContainerService::git_branch_from_task_attempt(&task_attempt.id, &task.title); - let project = task .parent_project(&self.db.pool) .await? @@ -890,9 +882,9 @@ impl ContainerService for LocalContainerService { WorktreeManager::create_worktree( &project.git_repo_path, - &git_branch_name, + &task_attempt.branch, &worktree_path, - &task_attempt.base_branch, + &task_attempt.target_branch, true, // create new branch ) .await?; @@ -925,8 +917,6 @@ impl ContainerService for LocalContainerService { ) .await?; - TaskAttempt::update_branch(&self.db.pool, task_attempt.id, &git_branch_name).await?; - Ok(worktree_path.to_string_lossy().to_string()) } @@ -979,14 +969,9 @@ impl ContainerService for LocalContainerService { })?; let worktree_path = PathBuf::from(container_ref); - let branch_name = task_attempt - .branch - .as_ref() - .ok_or_else(|| ContainerError::Other(anyhow!("Branch not found for task attempt")))?; - WorktreeManager::ensure_worktree_exists( &project.git_repo_path, - branch_name, + &task_attempt.branch, &worktree_path, ) .await?; @@ -1117,18 +1102,11 @@ impl ContainerService for LocalContainerService { let project_repo_path = self.get_project_repo_path(task_attempt).await?; let latest_merge = Merge::find_latest_by_task_attempt_id(&self.db.pool, task_attempt.id).await?; - let task_branch = task_attempt - .branch - .clone() - .ok_or(ContainerError::Other(anyhow!( - "Task attempt {} does not have a branch", - task_attempt.id - )))?; let is_ahead = if let Ok((ahead, _)) = self.git().get_branch_status( &project_repo_path, - &task_branch, - &task_attempt.base_branch, + &task_attempt.branch, + &task_attempt.target_branch, ) { ahead > 0 } else { @@ -1147,8 +1125,8 @@ impl ContainerService for LocalContainerService { let worktree_path = PathBuf::from(container_ref); let base_commit = self.git().get_base_commit( &project_repo_path, - &task_branch, - &task_attempt.base_branch, + &task_attempt.branch, + &task_attempt.target_branch, )?; self.create_live_diff_stream(&worktree_path, &base_commit, stats_only) diff --git a/crates/server/src/bin/generate_types.rs b/crates/server/src/bin/generate_types.rs index f35ed2b8..0abe2e7f 100644 --- a/crates/server/src/bin/generate_types.rs +++ b/crates/server/src/bin/generate_types.rs @@ -45,6 +45,8 @@ fn generate_types_content() -> String { server::routes::task_attempts::CreateFollowUpAttempt::decl(), server::routes::task_attempts::FollowUpDraftResponse::decl(), server::routes::task_attempts::UpdateFollowUpDraftRequest::decl(), + server::routes::task_attempts::ChangeTargetBranchRequest::decl(), + server::routes::task_attempts::ChangeTargetBranchResponse::decl(), server::routes::tasks::CreateAndStartTaskRequest::decl(), server::routes::task_attempts::CreateGitHubPrRequest::decl(), server::routes::images::ImageResponse::decl(), diff --git a/crates/server/src/routes/task_attempts.rs b/crates/server/src/routes/task_attempts.rs index f179bad7..1ca6b606 100644 --- a/crates/server/src/routes/task_attempts.rs +++ b/crates/server/src/routes/task_attempts.rs @@ -46,6 +46,7 @@ use crate::{DeploymentImpl, error::ApiError, middleware::load_task_attempt_middl #[derive(Debug, Deserialize, Serialize, TS)] pub struct RebaseTaskAttemptRequest { + pub old_base_branch: Option, pub new_base_branch: Option, } @@ -84,7 +85,7 @@ pub struct ReplaceProcessResult { pub struct CreateGitHubPrRequest { pub title: String, pub body: Option, - pub base_branch: Option, + pub target_branch: Option, } #[derive(Debug, Serialize)] @@ -142,13 +143,23 @@ pub async fn create_task_attempt( Json(payload): Json, ) -> Result>, ApiError> { let executor_profile_id = payload.get_executor_profile_id(); + let task = Task::find_by_id(&deployment.db().pool, payload.task_id) + .await? + .ok_or(SqlxError::RowNotFound)?; + + let attempt_id = Uuid::new_v4(); + let git_branch_name = deployment + .container() + .git_branch_from_task_attempt(&attempt_id, &task.title); let task_attempt = TaskAttempt::create( &deployment.db().pool, &CreateTaskAttempt { executor: executor_profile_id.executor, base_branch: payload.base_branch.clone(), + branch: git_branch_name.clone(), }, + attempt_id, payload.task_id, ) .await?; @@ -1115,25 +1126,18 @@ pub async fn merge_task_attempt( commit_message.push_str(description); } - // Get branch name from task attempt - let branch_name = ctx.task_attempt.branch.as_ref().ok_or_else(|| { - ApiError::TaskAttempt(TaskAttemptError::ValidationError( - "No branch found for task attempt".to_string(), - )) - })?; - let merge_commit_id = deployment.git().merge_changes( &ctx.project.git_repo_path, worktree_path, - branch_name, - &ctx.task_attempt.base_branch, + &ctx.task_attempt.branch, + &ctx.task_attempt.target_branch, &commit_message, )?; Merge::create_direct( pool, task_attempt.id, - &ctx.task_attempt.base_branch, + &ctx.task_attempt.target_branch, &merge_commit_id, ) .await?; @@ -1165,11 +1169,6 @@ pub async fn push_task_attempt_branch( let github_service = GitHubService::new(&github_token)?; github_service.check_token().await?; - let branch_name = task_attempt.branch.as_ref().ok_or_else(|| { - ApiError::TaskAttempt(TaskAttemptError::ValidationError( - "No branch found for task attempt".to_string(), - )) - })?; let ws_path = PathBuf::from( deployment .container() @@ -1179,7 +1178,7 @@ pub async fn push_task_attempt_branch( deployment .git() - .push_to_github(&ws_path, branch_name, &github_token)?; + .push_to_github(&ws_path, &task_attempt.branch, &github_token)?; Ok(ResponseJson(ApiResponse::success(()))) } @@ -1196,12 +1195,12 @@ pub async fn create_github_pr( }; // Create GitHub service instance let github_service = GitHubService::new(&github_token)?; - // Get the task attempt to access the stored base branch - 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 !task_attempt.base_branch.trim().is_empty() { - task_attempt.base_branch.clone() + // Get the task attempt to access the stored target branch + let target_branch = request.target_branch.unwrap_or_else(|| { + // Use the stored target branch from the task attempt as the default + // Fall back to config default or "main" only if stored target branch is somehow invalid + if !task_attempt.target_branch.trim().is_empty() { + task_attempt.target_branch.clone() } else { github_config .default_pr_base @@ -1219,12 +1218,6 @@ pub async fn create_github_pr( .await? .ok_or(ApiError::Project(ProjectError::ProjectNotFound))?; - // Get branch name from task attempt - let branch_name = task_attempt.branch.as_ref().ok_or_else(|| { - ApiError::TaskAttempt(TaskAttemptError::ValidationError( - "No branch found for task attempt".to_string(), - )) - })?; let workspace_path = PathBuf::from( deployment .container() @@ -1233,9 +1226,10 @@ pub async fn create_github_pr( ); // Push the branch to GitHub first - if let Err(e) = deployment - .git() - .push_to_github(&workspace_path, branch_name, &github_token) + if let Err(e) = + deployment + .git() + .push_to_github(&workspace_path, &task_attempt.branch, &github_token) { tracing::error!("Failed to push branch to GitHub: {}", e); let gh_e = GitHubServiceError::from(e); @@ -1248,31 +1242,31 @@ pub async fn create_github_pr( } } - let norm_base_branch_name = if matches!( + let norm_target_branch_name = if matches!( deployment .git() - .find_branch_type(&project.git_repo_path, &base_branch)?, + .find_branch_type(&project.git_repo_path, &target_branch)?, BranchType::Remote ) { // Remote branches are formatted as {remote}/{branch} locally. // For PR APIs, we must provide just the branch name. let remote = deployment .git() - .get_remote_name_from_branch_name(&workspace_path, &base_branch)?; + .get_remote_name_from_branch_name(&workspace_path, &target_branch)?; let remote_prefix = format!("{}/", remote); - base_branch + target_branch .strip_prefix(&remote_prefix) - .unwrap_or(&base_branch) + .unwrap_or(&target_branch) .to_string() } else { - base_branch + target_branch }; // Create the PR using GitHub service let pr_request = CreatePrRequest { title: request.title.clone(), body: request.body.clone(), - head_branch: branch_name.clone(), - base_branch: norm_base_branch_name.clone(), + head_branch: task_attempt.branch.clone(), + base_branch: norm_target_branch_name.clone(), }; // Use GitService to get the remote URL, then create GitHubRepoInfo let repo_info = deployment @@ -1285,7 +1279,7 @@ pub async fn create_github_pr( if let Err(e) = Merge::create_pr( pool, task_attempt.id, - &norm_base_branch_name, + &norm_target_branch_name, pr_info.number, &pr_info.url, ) @@ -1394,7 +1388,7 @@ pub struct BranchStatus { pub head_oid: Option, pub uncommitted_count: Option, pub untracked_count: Option, - pub base_branch_name: String, + pub target_branch_name: String, pub remote_commits_behind: Option, pub remote_commits_ahead: Option, pub merges: Vec, @@ -1462,21 +1456,15 @@ pub async fn get_task_attempt_branch_status( } }; - let task_branch = - task_attempt - .branch - .ok_or(ApiError::TaskAttempt(TaskAttemptError::ValidationError( - "No branch found for task attempt".to_string(), - )))?; - let base_branch_type = deployment + let target_branch_type = deployment .git() - .find_branch_type(&ctx.project.git_repo_path, &task_attempt.base_branch)?; + .find_branch_type(&ctx.project.git_repo_path, &task_attempt.target_branch)?; - let (commits_ahead, commits_behind) = if matches!(base_branch_type, BranchType::Local) { + let (commits_ahead, commits_behind) = if matches!(target_branch_type, BranchType::Local) { let (a, b) = deployment.git().get_branch_status( &ctx.project.git_repo_path, - &task_branch, - &task_attempt.base_branch, + &task_attempt.branch, + &task_attempt.target_branch, )?; (Some(a), Some(b)) } else { @@ -1494,7 +1482,7 @@ pub async fn get_task_attempt_branch_status( remote_commits_ahead: None, remote_commits_behind: None, merges, - base_branch_name: task_attempt.base_branch.clone(), + target_branch_name: task_attempt.target_branch.clone(), is_rebase_in_progress, conflict_op, conflicted_files, @@ -1512,25 +1500,25 @@ pub async fn get_task_attempt_branch_status( ) }); - // check remote status if the attempt has an open PR or the base_branch is a remote branch - if has_open_pr || base_branch_type == BranchType::Remote { + // check remote status if the attempt has an open PR or the target_branch is a remote branch + if has_open_pr || target_branch_type == BranchType::Remote { let github_config = deployment.config().read().await.github.clone(); let token = github_config .token() .ok_or(ApiError::GitHubService(GitHubServiceError::TokenInvalid))?; - // For an attempt with a remote base branch, we compare against that + // For an attempt with a remote target branch, we compare against that // After opening a PR, the attempt has a remote branch itself, so we use that - let remote_base_branch = if base_branch_type == BranchType::Remote && !has_open_pr { - Some(task_attempt.base_branch) + let remote_target_branch = if target_branch_type == BranchType::Remote && !has_open_pr { + Some(task_attempt.target_branch) } else { None }; let (remote_commits_ahead, remote_commits_behind) = deployment.git().get_remote_branch_status( &ctx.project.git_repo_path, - &task_branch, - remote_base_branch.as_deref(), + &task_attempt.branch, + remote_target_branch.as_deref(), token, )?; branch_status.remote_commits_ahead = Some(remote_commits_ahead); @@ -1539,15 +1527,80 @@ pub async fn get_task_attempt_branch_status( Ok(ResponseJson(ApiResponse::success(branch_status))) } +#[derive(serde::Deserialize, Debug, TS)] +pub struct ChangeTargetBranchRequest { + pub new_target_branch: String, +} + +#[derive(serde::Serialize, Debug, TS)] +pub struct ChangeTargetBranchResponse { + pub new_target_branch: String, + pub status: (usize, usize), +} + +#[axum::debug_handler] +pub async fn change_target_branch( + Extension(task_attempt): Extension, + State(deployment): State, + Json(payload): Json, +) -> Result>, ApiError> { + // Extract new base branch from request body if provided + let new_target_branch = payload.new_target_branch; + let task = task_attempt + .parent_task(&deployment.db().pool) + .await? + .ok_or(ApiError::TaskAttempt(TaskAttemptError::TaskNotFound))?; + let project = Project::find_by_id(&deployment.db().pool, task.project_id) + .await? + .ok_or(ApiError::Project(ProjectError::ProjectNotFound))?; + match deployment + .git() + .check_branch_exists(&project.git_repo_path, &new_target_branch)? + { + true => { + TaskAttempt::update_target_branch( + &deployment.db().pool, + task_attempt.id, + &new_target_branch, + ) + .await?; + } + false => { + return Ok(ResponseJson(ApiResponse::error( + format!( + "Branch '{}' does not exist in the repository", + new_target_branch + ) + .as_str(), + ))); + } + } + let status = deployment.git().get_branch_status( + &project.git_repo_path, + &task_attempt.branch, + &new_target_branch, + )?; + + Ok(ResponseJson(ApiResponse::success( + ChangeTargetBranchResponse { + new_target_branch, + status, + }, + ))) +} + #[axum::debug_handler] pub async fn rebase_task_attempt( Extension(task_attempt): Extension, State(deployment): State, - request_body: Option>, + Json(payload): Json, ) -> Result>, ApiError> { - // Extract new base branch from request body if provided - let new_base_branch = request_body.and_then(|body| body.new_base_branch.clone()); - + let old_base_branch = payload + .old_base_branch + .unwrap_or(task_attempt.target_branch.clone()); + let new_base_branch = payload + .new_base_branch + .unwrap_or(task_attempt.target_branch.clone()); let github_config = deployment.config().read().await.github.clone(); let pool = &deployment.db().pool; @@ -1557,10 +1610,28 @@ pub async fn rebase_task_attempt( .await? .ok_or(ApiError::TaskAttempt(TaskAttemptError::TaskNotFound))?; let ctx = TaskAttempt::load_context(pool, task_attempt.id, task.id, task.project_id).await?; - - // Use the stored base branch if no new base branch is provided - let effective_base_branch = - new_base_branch.or_else(|| Some(ctx.task_attempt.base_branch.clone())); + match deployment + .git() + .check_branch_exists(&ctx.project.git_repo_path, &new_base_branch)? + { + true => { + TaskAttempt::update_target_branch( + &deployment.db().pool, + task_attempt.id, + &new_base_branch, + ) + .await?; + } + false => { + return Ok(ResponseJson(ApiResponse::error( + format!( + "Branch '{}' does not exist in the repository", + new_base_branch + ) + .as_str(), + ))); + } + } let container_ref = deployment .container() @@ -1571,8 +1642,9 @@ pub async fn rebase_task_attempt( let result = deployment.git().rebase_branch( &ctx.project.git_repo_path, worktree_path, - effective_base_branch.clone().as_deref(), - &ctx.task_attempt.base_branch.clone(), + &new_base_branch, + &old_base_branch, + &task_attempt.branch.clone(), github_config.token(), ); if let Err(e) = result { @@ -1596,14 +1668,6 @@ pub async fn rebase_task_attempt( other => Err(ApiError::GitService(other)), }; } - - if let Some(new_base_branch) = &effective_base_branch - && new_base_branch != &ctx.task_attempt.base_branch - { - TaskAttempt::update_base_branch(&deployment.db().pool, task_attempt.id, new_base_branch) - .await?; - } - Ok(ResponseJson(ApiResponse::success(()))) } @@ -1783,6 +1847,7 @@ pub fn router(deployment: &DeploymentImpl) -> Router { .route("/delete-file", post(delete_task_attempt_file)) .route("/children", get(get_task_attempt_children)) .route("/stop", post(stop_task_attempt_execution)) + .route("/change-target-branch", post(change_target_branch)) .layer(from_fn_with_state( deployment.clone(), load_task_attempt_middleware, diff --git a/crates/server/src/routes/tasks.rs b/crates/server/src/routes/tasks.rs index 6352f253..d23c4a25 100644 --- a/crates/server/src/routes/tasks.rs +++ b/crates/server/src/routes/tasks.rs @@ -163,13 +163,19 @@ pub async fn create_task_and_start( }), ) .await; + let attempt_id = Uuid::new_v4(); + let git_branch_name = deployment + .container() + .git_branch_from_task_attempt(&attempt_id, &task.title); let task_attempt = TaskAttempt::create( &deployment.db().pool, &CreateTaskAttempt { executor: payload.executor_profile_id.executor, base_branch: payload.base_branch, + branch: git_branch_name, }, + attempt_id, task.id, ) .await?; diff --git a/crates/services/src/services/container.rs b/crates/services/src/services/container.rs index e7ac906e..92588974 100644 --- a/crates/services/src/services/container.rs +++ b/crates/services/src/services/container.rs @@ -33,7 +33,11 @@ use futures::{StreamExt, future}; use sqlx::Error as SqlxError; use thiserror::Error; use tokio::{sync::RwLock, task::JoinHandle}; -use utils::{log_msg::LogMsg, msg_store::MsgStore}; +use utils::{ + log_msg::LogMsg, + msg_store::MsgStore, + text::{git_branch_id, short_uuid}, +}; use uuid::Uuid; use crate::services::{ @@ -206,6 +210,11 @@ pub trait ContainerService { map.get(uuid).cloned() } + fn git_branch_from_task_attempt(&self, attempt_id: &Uuid, task_title: &str) -> String { + let task_title_id = git_branch_id(task_title); + format!("vk/{}-{}", short_uuid(attempt_id), task_title_id) + } + async fn stream_raw_logs( &self, id: &Uuid, diff --git a/crates/services/src/services/git.rs b/crates/services/src/services/git.rs index a2ec3d79..eef1738e 100644 --- a/crates/services/src/services/git.rs +++ b/crates/services/src/services/git.rs @@ -1311,8 +1311,9 @@ impl GitService { &self, repo_path: &Path, worktree_path: &Path, - new_base_branch: Option<&str>, + new_base_branch: &str, old_base_branch: &str, + task_branch: &str, github_token: Option, ) -> Result { let worktree_repo = Repository::open(worktree_path)?; @@ -1331,32 +1332,19 @@ impl GitService { } // Get the target base branch reference - let new_base_branch_name = match new_base_branch { - Some(branch) => branch.to_string(), - None => main_repo - .head() - .ok() - .and_then(|head| head.shorthand().map(|s| s.to_string())) - .unwrap_or_else(|| "main".to_string()), - }; - let nbr = Self::find_branch(&main_repo, &new_base_branch_name)?.into_reference(); + let nbr = Self::find_branch(&main_repo, new_base_branch)?.into_reference(); // If the target base is remote, update it first so CLI sees latest if nbr.is_remote() { let github_token = github_token.ok_or(GitServiceError::TokenUnavailable)?; let remote = self.get_remote_from_branch_ref(&main_repo, &nbr)?; // First, fetch the latest changes from remote - self.fetch_branch_from_remote( - &main_repo, - &github_token, - &remote, - &new_base_branch_name, - )?; + self.fetch_branch_from_remote(&main_repo, &github_token, &remote, new_base_branch)?; } // Ensure identity for any commits produced by rebase self.ensure_cli_commit_identity(worktree_path)?; // Use git CLI rebase to carry out the operation safely - match git.rebase_onto(worktree_path, &new_base_branch_name, old_base_branch) { + match git.rebase_onto(worktree_path, new_base_branch, old_base_branch, task_branch) { Ok(()) => {} Err(GitCliError::RebaseInProgress) => { return Err(GitServiceError::RebaseInProgress); @@ -1394,7 +1382,7 @@ impl GitService { } }; let msg = format!( - "Rebase encountered merge conflicts while rebasing '{attempt_branch}' onto '{new_base_branch_name}'.{files_part} Resolve conflicts and then continue or abort." + "Rebase encountered merge conflicts while rebasing '{attempt_branch}' onto '{new_base_branch}'.{files_part} Resolve conflicts and then continue or abort." ); return Err(GitServiceError::MergeConflicts(msg)); } @@ -1434,6 +1422,21 @@ impl GitService { } } + pub fn check_branch_exists( + &self, + repo_path: &Path, + branch_name: &str, + ) -> Result { + let repo = self.open_repo(repo_path)?; + match repo.find_branch(branch_name, BranchType::Local) { + Ok(_) => Ok(true), + Err(_) => match repo.find_branch(branch_name, BranchType::Remote) { + Ok(_) => Ok(true), + Err(_) => Ok(false), + }, + } + } + /// Return true if a rebase is currently in progress in this worktree. pub fn is_rebase_in_progress(&self, worktree_path: &Path) -> Result { let git = GitCli::new(); diff --git a/crates/services/src/services/git_cli.rs b/crates/services/src/services/git_cli.rs index e2657128..b1a67a17 100644 --- a/crates/services/src/services/git_cli.rs +++ b/crates/services/src/services/git_cli.rs @@ -401,19 +401,37 @@ impl GitCli { out } - /// Perform `git rebase --onto ` on the current branch in `worktree_path`. + /// Return the merge base commit sha of two refs in the given worktree. + /// If `git merge-base --fork-point` fails, falls back to regular `merge-base`. + fn merge_base(&self, worktree_path: &Path, a: &str, b: &str) -> Result { + let out = self + .git(worktree_path, ["merge-base", "--fork-point", a, b]) + .unwrap_or(self.git(worktree_path, ["merge-base", a, b])?); + Ok(out.trim().to_string()) + } + + /// Perform `git rebase --onto ` on in `worktree_path`. pub fn rebase_onto( &self, worktree_path: &Path, new_base: &str, old_base: &str, + task_branch: &str, ) -> Result<(), GitCliError> { // If a rebase is in progress, refuse to proceed. The caller can // choose to abort or continue; we avoid destructive actions here. if self.is_rebase_in_progress(worktree_path).unwrap_or(false) { return Err(GitCliError::RebaseInProgress); } - self.git(worktree_path, ["rebase", "--onto", new_base, old_base])?; + // compute the merge base of task_branch from old_base + let merge_base = self + .merge_base(worktree_path, old_base, task_branch) + .unwrap_or(old_base.to_string()); + + self.git( + worktree_path, + ["rebase", "--onto", new_base, &merge_base, task_branch], + )?; Ok(()) } diff --git a/crates/services/tests/git_ops_safety.rs b/crates/services/tests/git_ops_safety.rs index cb7d660e..9f292ef4 100644 --- a/crates/services/tests/git_ops_safety.rs +++ b/crates/services/tests/git_ops_safety.rs @@ -417,8 +417,9 @@ fn rebase_preserves_untracked_files() { let res = service.rebase_branch( &repo_path, &worktree_path, - Some("new-base"), + "new-base", "old-base", + "feature", None, ); assert!(res.is_ok(), "rebase should succeed: {res:?}"); @@ -439,8 +440,9 @@ fn rebase_aborts_on_uncommitted_tracked_changes() { let res = service.rebase_branch( &repo_path, &worktree_path, - Some("new-base"), + "new-base", "old-base", + "feature", None, ); assert!(res.is_err(), "rebase should fail on dirty worktree"); @@ -460,8 +462,9 @@ fn rebase_aborts_if_untracked_would_be_overwritten_by_base() { let res = service.rebase_branch( &repo_path, &worktree_path, - Some("new-base"), + "new-base", "old-base", + "feature", None, ); assert!( @@ -691,8 +694,9 @@ fn rebase_refuses_to_abort_existing_rebase() { .rebase_branch( &repo_path, &worktree_path, - Some("new-base"), + "new-base", "old-base", + "feature", None, ) .expect_err("first rebase should error and leave in-progress state"); @@ -702,8 +706,9 @@ fn rebase_refuses_to_abort_existing_rebase() { let res = service.rebase_branch( &repo_path, &worktree_path, - Some("new-base"), + "new-base", "old-base", + "feature", None, ); assert!(res.is_err(), "should error because rebase is in progress"); @@ -722,8 +727,9 @@ fn rebase_fast_forwards_when_no_unique_commits() { .rebase_branch( &repo_path, &worktree_path, - Some("new-base"), + "new-base", "old-base", + "feature", None, ) .expect("rebase should succeed"); @@ -753,8 +759,9 @@ fn rebase_applies_multiple_commits_onto_ahead_base() { .rebase_branch( &repo_path, &worktree_path, - Some("new-base"), + "new-base", "old-base", + "feature", None, ) .expect("rebase should succeed"); @@ -898,12 +905,12 @@ fn rebase_preserves_rename_changes() { .rebase_branch( &repo_path, &worktree_path, - Some("new-base"), + "new-base", "old-base", + "feature", None, ) .expect("rebase should succeed"); - // after rebase, renamed file present; original absent assert!(worktree_path.join("feat_renamed.txt").exists()); assert!(!worktree_path.join("feat.txt").exists()); diff --git a/frontend/src/components/dialogs/index.ts b/frontend/src/components/dialogs/index.ts index 415b6fe0..0e19da71 100644 --- a/frontend/src/components/dialogs/index.ts +++ b/frontend/src/components/dialogs/index.ts @@ -42,6 +42,11 @@ export { type TaskTemplateEditDialogProps, type TaskTemplateEditResult, } from './tasks/TaskTemplateEditDialog'; +export { + ChangeTargetBranchDialog, + type ChangeTargetBranchDialogProps, + type ChangeTargetBranchDialogResult, +} from './tasks/ChangeTargetBranchDialog'; export { RebaseDialog, type RebaseDialogProps, diff --git a/frontend/src/components/dialogs/tasks/ChangeTargetBranchDialog.tsx b/frontend/src/components/dialogs/tasks/ChangeTargetBranchDialog.tsx new file mode 100644 index 00000000..d73f8333 --- /dev/null +++ b/frontend/src/components/dialogs/tasks/ChangeTargetBranchDialog.tsx @@ -0,0 +1,102 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import BranchSelector from '@/components/tasks/BranchSelector'; +import type { GitBranch } from 'shared/types'; +import NiceModal, { useModal } from '@ebay/nice-modal-react'; + +export interface ChangeTargetBranchDialogProps { + branches: GitBranch[]; + isChangingTargetBranch?: boolean; +} + +export type ChangeTargetBranchDialogResult = { + action: 'confirmed' | 'canceled'; + branchName?: string; +}; + +export const ChangeTargetBranchDialog = + NiceModal.create( + ({ branches, isChangingTargetBranch: isChangingTargetBranch = false }) => { + const modal = useModal(); + const { t } = useTranslation(['tasks', 'common']); + const [selectedBranch, setSelectedBranch] = useState(''); + + const handleConfirm = () => { + if (selectedBranch) { + modal.resolve({ + action: 'confirmed', + branchName: selectedBranch, + } as ChangeTargetBranchDialogResult); + modal.hide(); + } + }; + + const handleCancel = () => { + modal.resolve({ action: 'canceled' } as ChangeTargetBranchDialogResult); + modal.hide(); + }; + + const handleOpenChange = (open: boolean) => { + if (!open) { + handleCancel(); + } + }; + + return ( + + + + + {t('branches.changeTarget.dialog.title')} + + + {t('branches.changeTarget.dialog.description')} + + + +
+
+ + +
+
+ + + + + +
+
+ ); + } + ); diff --git a/frontend/src/components/dialogs/tasks/CreatePRDialog.tsx b/frontend/src/components/dialogs/tasks/CreatePRDialog.tsx index 70d16baf..e475a52a 100644 --- a/frontend/src/components/dialogs/tasks/CreatePRDialog.tsx +++ b/frontend/src/components/dialogs/tasks/CreatePRDialog.tsx @@ -50,9 +50,9 @@ const CreatePrDialog = NiceModal.create(() => { .then((projectBranches) => { setBranches(projectBranches); - // Set smart default: task base branch OR current branch - if (data.attempt.base_branch) { - setPrBaseBranch(data.attempt.base_branch); + // Set smart default: task target branch OR current branch + if (data.attempt.target_branch) { + setPrBaseBranch(data.attempt.target_branch); } else { const currentBranch = projectBranches.find((b) => b.is_current); if (currentBranch) { @@ -77,7 +77,7 @@ const CreatePrDialog = NiceModal.create(() => { const result = await attemptsApi.createPR(data.attempt.id, { title: prTitle, body: prBody || null, - base_branch: prBaseBranch || null, + target_branch: prBaseBranch || null, }); if (result.success) { diff --git a/frontend/src/components/dialogs/tasks/RebaseDialog.tsx b/frontend/src/components/dialogs/tasks/RebaseDialog.tsx index 58507161..69033b00 100644 --- a/frontend/src/components/dialogs/tasks/RebaseDialog.tsx +++ b/frontend/src/components/dialogs/tasks/RebaseDialog.tsx @@ -1,4 +1,6 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; +import { ChevronRight } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; import { Dialog, DialogContent, @@ -15,23 +17,52 @@ import NiceModal, { useModal } from '@ebay/nice-modal-react'; export interface RebaseDialogProps { branches: GitBranch[]; isRebasing?: boolean; + initialTargetBranch?: string; + initialUpstreamBranch?: string; } export type RebaseDialogResult = { action: 'confirmed' | 'canceled'; branchName?: string; + upstreamBranch?: string; }; export const RebaseDialog = NiceModal.create( - ({ branches, isRebasing = false }) => { + ({ + branches, + isRebasing = false, + initialTargetBranch, + initialUpstreamBranch, + }) => { const modal = useModal(); - const [selectedBranch, setSelectedBranch] = useState(''); + const { t } = useTranslation(['tasks', 'common']); + const [selectedBranch, setSelectedBranch] = useState( + initialTargetBranch ?? '' + ); + const [selectedUpstream, setSelectedUpstream] = useState( + initialUpstreamBranch ?? '' + ); + + useEffect(() => { + if (initialTargetBranch) { + setSelectedBranch(initialTargetBranch); + } + }, [initialTargetBranch]); + + useEffect(() => { + if (initialUpstreamBranch) { + setSelectedUpstream(initialUpstreamBranch); + } + }, [initialUpstreamBranch]); + + const [showAdvanced, setShowAdvanced] = useState(false); const handleConfirm = () => { if (selectedBranch) { modal.resolve({ action: 'confirmed', branchName: selectedBranch, + upstreamBranch: selectedUpstream, } as RebaseDialogResult); modal.hide(); } @@ -52,25 +83,54 @@ export const RebaseDialog = NiceModal.create( - Rebase Task Attempt + {t('rebase.dialog.title')} - Choose a new base branch to rebase this task attempt onto. + {t('rebase.dialog.description')}
-
+
+ + {showAdvanced && ( +
+ + +
+ )} +
@@ -79,13 +139,15 @@ export const RebaseDialog = NiceModal.create( onClick={handleCancel} disabled={isRebasing} > - Cancel + {t('common:buttons.cancel')}
diff --git a/frontend/src/components/dialogs/tasks/TaskFormDialog.tsx b/frontend/src/components/dialogs/tasks/TaskFormDialog.tsx index d5a84c2b..f0a9ab56 100644 --- a/frontend/src/components/dialogs/tasks/TaskFormDialog.tsx +++ b/frontend/src/components/dialogs/tasks/TaskFormDialog.tsx @@ -219,7 +219,7 @@ export const TaskFormDialog = NiceModal.create( attemptsApi .get(parentTaskAttemptId) .then((attempt) => { - const parentBranch = attempt.branch || attempt.base_branch; + const parentBranch = attempt.branch || attempt.target_branch; if (parentBranch && branches.some((b) => b.name === parentBranch)) { setSelectedBranch(parentBranch); } diff --git a/frontend/src/components/tasks/AttemptHeaderCard.tsx b/frontend/src/components/tasks/AttemptHeaderCard.tsx index 33b9c436..8e486f2c 100644 --- a/frontend/src/components/tasks/AttemptHeaderCard.tsx +++ b/frontend/src/components/tasks/AttemptHeaderCard.tsx @@ -18,6 +18,7 @@ import { useAttemptExecution } from '@/hooks/useAttemptExecution'; import { useMemo, useState } from 'react'; import NiceModal from '@ebay/nice-modal-react'; import { OpenInIdeButton } from '@/components/ide/OpenInIdeButton'; +import { useTranslation } from 'react-i18next'; interface AttemptHeaderCardProps { attemptNumber: number; @@ -37,6 +38,7 @@ export function AttemptHeaderCard({ projectId, onJumpToDiffFullScreen, }: AttemptHeaderCardProps) { + const { t } = useTranslation('tasks'); const { start: startDevServer, stop: stopDevServer, @@ -113,7 +115,7 @@ export function AttemptHeaderCard({ const handleRebaseClick = async () => { setRebasing(true); try { - await rebaseMutation.mutateAsync(undefined); + await rebaseMutation.mutateAsync({}); } catch (error) { // Error handling is done by the mutation } finally { @@ -136,16 +138,22 @@ export function AttemptHeaderCard({

- Attempt · + + {t('attempt.labels.attempt')} ·{' '} + {attemptNumber}/{totalAttempts}

- Agent · + + {t('attempt.labels.agent')} ·{' '} + {selectedAttempt?.executor}

{selectedAttempt?.branch && (

- Branch · + + {t('attempt.labels.branch')} ·{' '} + {selectedAttempt.branch}

)} @@ -157,7 +165,7 @@ export function AttemptHeaderCard({ className="h-4 p-0" onClick={onJumpToDiffFullScreen} > - Diffs + {t('attempt.labels.diffs')} {' '} · +{added}{' '} -{deleted} @@ -179,7 +187,7 @@ export function AttemptHeaderCard({ className="h-10 w-10 p-0 shrink-0" > - Open menu + {t('attempt.actions.openMenu')} @@ -187,7 +195,7 @@ export function AttemptHeaderCard({ onClick={() => openInEditor()} disabled={!selectedAttempt} > - Open in IDE + {t('attempt.actions.openInIde')} @@ -196,7 +204,9 @@ export function AttemptHeaderCard({ disabled={!selectedAttempt} className={runningDevServer ? 'text-destructive' : ''} > - {runningDevServer ? 'Stop dev server' : 'Start dev server'} + {runningDevServer + ? t('attempt.actions.stopDevServer') + : t('attempt.actions.startDevServer')} {selectedAttempt && branchStatus && @@ -206,14 +216,16 @@ export function AttemptHeaderCard({ onClick={handleRebaseClick} disabled={rebasing || isAttemptRunning || hasConflicts} > - {rebasing ? 'Rebasing...' : 'Rebase'} + {rebasing + ? t('rebase.common.inProgress') + : t('rebase.common.action')} )} - Create PR + {t('git.states.createPr')} {selectedAttempt && branchStatus && !mergeInfo.hasMergedPR && ( - {merging ? 'Merging...' : 'Merge'} + {merging ? t('git.states.merging') : t('git.states.merge')} )} {/* { - if (!selectedBranch) return placeholder; - - // For remote branches, show just the branch name without the remote prefix - if (selectedBranch.includes('/')) { - const parts = selectedBranch.split('/'); - return parts[parts.length - 1]; - } - return selectedBranch; - }, [selectedBranch, placeholder]); - const handleBranchSelect = (branchName: string) => { onBranchSelect(branchName); setBranchSearchTerm(''); @@ -94,7 +83,7 @@ function BranchSelector({ >
- {displayName} + {selectedBranch || placeholder}
diff --git a/frontend/src/components/tasks/TaskDetailsToolbar.tsx b/frontend/src/components/tasks/TaskDetailsToolbar.tsx index 7b56063d..90bd2db8 100644 --- a/frontend/src/components/tasks/TaskDetailsToolbar.tsx +++ b/frontend/src/components/tasks/TaskDetailsToolbar.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useReducer, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { Play } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { projectsApi, attemptsApi } from '@/lib/api'; @@ -9,59 +9,15 @@ import type { } from 'shared/types'; import type { ExecutorProfileId } from 'shared/types'; -import { useAttemptExecution } from '@/hooks'; +import { useAttemptExecution, useBranchStatus } from '@/hooks'; import { useTaskStopping } from '@/stores/useTaskDetailsUiStore'; import CreateAttempt from '@/components/tasks/Toolbar/CreateAttempt.tsx'; import CurrentAttempt from '@/components/tasks/Toolbar/CurrentAttempt.tsx'; +import GitOperations from '@/components/tasks/Toolbar/GitOperations.tsx'; import { useUserSystem } from '@/components/config-provider'; import { Card } from '../ui/card'; -// UI State Management -type UiAction = - | { type: 'OPEN_CREATE_PR' } - | { type: 'CLOSE_CREATE_PR' } - | { type: 'CREATE_PR_START' } - | { type: 'CREATE_PR_DONE' } - | { type: 'ENTER_CREATE_MODE' } - | { type: 'LEAVE_CREATE_MODE' } - | { type: 'SET_ERROR'; payload: string | null }; - -interface UiState { - showCreatePRDialog: boolean; - creatingPR: boolean; - userForcedCreateMode: boolean; - error: string | null; -} - -const initialUi: UiState = { - showCreatePRDialog: false, - creatingPR: false, - userForcedCreateMode: false, - error: null, -}; - -function uiReducer(state: UiState, action: UiAction): UiState { - switch (action.type) { - case 'OPEN_CREATE_PR': - return { ...state, showCreatePRDialog: true }; - case 'CLOSE_CREATE_PR': - return { ...state, showCreatePRDialog: false }; - case 'CREATE_PR_START': - return { ...state, creatingPR: true }; - case 'CREATE_PR_DONE': - return { ...state, creatingPR: false }; - case 'ENTER_CREATE_MODE': - return { ...state, userForcedCreateMode: true }; - case 'LEAVE_CREATE_MODE': - return { ...state, userForcedCreateMode: false }; - case 'SET_ERROR': - return { ...state, error: action.payload }; - default: - return state; - } -} - function TaskDetailsToolbar({ task, projectId, @@ -86,9 +42,11 @@ function TaskDetailsToolbar({ // const { setLoading } = useTaskLoading(task.id); const { isStopping } = useTaskStopping(task.id); const { isAttemptRunning } = useAttemptExecution(selectedAttempt?.id); + const { data: branchStatus } = useBranchStatus(selectedAttempt?.id); - // UI state using reducer - const [ui, dispatch] = useReducer(uiReducer, initialUi); + // UI state + const [userForcedCreateMode, setUserForcedCreateMode] = useState(false); + const [error, setError] = useState(null); // Data state const [branches, setBranches] = useState([]); @@ -111,8 +69,7 @@ function TaskDetailsToolbar({ // Derived state const isInCreateAttemptMode = - forceCreateAttempt ?? - (ui.userForcedCreateMode || taskAttempts.length === 0); + forceCreateAttempt ?? (userForcedCreateMode || taskAttempts.length === 0); // Derive createAttemptBranch for backward compatibility const createAttemptBranch = useMemo(() => { @@ -124,10 +81,10 @@ function TaskDetailsToolbar({ // 2. Latest attempt's base branch (existing behavior for resume/rerun) if ( - latestAttempt?.base_branch && - branches.some((b: GitBranch) => b.name === latestAttempt.base_branch) + latestAttempt?.target_branch && + branches.some((b: GitBranch) => b.name === latestAttempt.target_branch) ) { - return latestAttempt.base_branch; + return latestAttempt.target_branch; } // 3. Parent task attempt's base branch (NEW - for inherited tasks) @@ -178,52 +135,30 @@ function TaskDetailsToolbar({ // Handle entering create attempt mode const handleEnterCreateAttemptMode = useCallback(() => { - dispatch({ type: 'ENTER_CREATE_MODE' }); + setUserForcedCreateMode(true); }, []); - // Stub handlers for backward compatibility with CreateAttempt - const setCreateAttemptBranch = useCallback( - (branch: string | null | ((prev: string | null) => string | null)) => { - if (typeof branch === 'function') { - setSelectedBranch((prev) => branch(prev)); - } else { - setSelectedBranch(branch); - } - // This is now derived state, so no-op - }, - [] - ); - const setIsInCreateAttemptMode = useCallback( (value: boolean | ((prev: boolean) => boolean)) => { const boolValue = typeof value === 'function' ? value(isInCreateAttemptMode) : value; if (boolValue) { - dispatch({ type: 'ENTER_CREATE_MODE' }); + setUserForcedCreateMode(true); } else { if (onLeaveForceCreateAttempt) onLeaveForceCreateAttempt(); - dispatch({ type: 'LEAVE_CREATE_MODE' }); + setUserForcedCreateMode(false); } }, [isInCreateAttemptMode, onLeaveForceCreateAttempt] ); - // Wrapper functions for UI state dispatch - const setError = useCallback( - (value: string | null | ((prev: string | null) => string | null)) => { - const errorValue = typeof value === 'function' ? value(ui.error) : value; - dispatch({ type: 'SET_ERROR', payload: errorValue }); - }, - [ui.error] - ); - return ( <>
{/* Error Display */} - {ui.error && ( + {error && (
-
{ui.error}
+
{error}
)} @@ -235,7 +170,7 @@ function TaskDetailsToolbar({ selectedProfile={selectedProfile} taskAttempts={taskAttempts} branches={branches} - setCreateAttemptBranch={setCreateAttemptBranch} + setCreateAttemptBranch={setSelectedBranch} setIsInCreateAttemptMode={setIsInCreateAttemptMode} setSelectedProfile={setSelectedProfile} availableProfiles={profiles} @@ -256,11 +191,7 @@ function TaskDetailsToolbar({ projectHasDevScript={projectHasDevScript ?? false} selectedAttempt={selectedAttempt} taskAttempts={taskAttempts} - selectedBranch={selectedBranch} - setError={setError} - creatingPR={ui.creatingPR} handleEnterCreateAttemptMode={handleEnterCreateAttemptMode} - branches={branches} setSelectedAttempt={setSelectedAttempt} /> ) : ( @@ -291,6 +222,20 @@ function TaskDetailsToolbar({
)} + + {/* Independent Git Operations Section */} + {selectedAttempt && branchStatus && ( + + )} ); diff --git a/frontend/src/components/tasks/TaskFollowUpSection.tsx b/frontend/src/components/tasks/TaskFollowUpSection.tsx index 43778b1e..a8d7b6bc 100644 --- a/frontend/src/components/tasks/TaskFollowUpSection.tsx +++ b/frontend/src/components/tasks/TaskFollowUpSection.tsx @@ -63,13 +63,13 @@ export function TaskFollowUpSection({ if (!hasConflicts) return null; return buildResolveConflictsInstructions( attemptBranch, - branchStatus?.base_branch_name, + branchStatus?.target_branch_name, branchStatus?.conflicted_files || [], branchStatus?.conflict_op ?? null ); }, [ attemptBranch, - branchStatus?.base_branch_name, + branchStatus?.target_branch_name, branchStatus?.conflicted_files, branchStatus?.conflict_op, ]); diff --git a/frontend/src/components/tasks/Toolbar/CreateAttempt.tsx b/frontend/src/components/tasks/Toolbar/CreateAttempt.tsx index 7632afa8..7e4725ce 100644 --- a/frontend/src/components/tasks/Toolbar/CreateAttempt.tsx +++ b/frontend/src/components/tasks/Toolbar/CreateAttempt.tsx @@ -22,7 +22,7 @@ type Props = { selectedProfile: ExecutorProfileId | null; selectedBranch: string | null; setIsInCreateAttemptMode: Dispatch>; - setCreateAttemptBranch: Dispatch>; + setCreateAttemptBranch: (branch: string | null) => void; setSelectedProfile: Dispatch>; availableProfiles: Record | null; selectedAttempt: TaskAttempt | null; diff --git a/frontend/src/components/tasks/Toolbar/CurrentAttempt.tsx b/frontend/src/components/tasks/Toolbar/CurrentAttempt.tsx index 51ffc0d4..9ec603da 100644 --- a/frontend/src/components/tasks/Toolbar/CurrentAttempt.tsx +++ b/frontend/src/components/tasks/Toolbar/CurrentAttempt.tsx @@ -1,14 +1,10 @@ import { ExternalLink, - GitBranch as GitBranchIcon, GitFork, - GitPullRequest, History, Play, Plus, - RefreshCw, ScrollText, - Settings, StopCircle, } from 'lucide-react'; import { @@ -24,36 +20,16 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu.tsx'; -import { - Dispatch, - SetStateAction, - useCallback, - useMemo, - useRef, - useState, - useEffect, -} from 'react'; -import type { - GitBranch, - TaskAttempt, - TaskWithAttemptStatus, -} from 'shared/types'; +import { useCallback, useMemo, useRef, useState, useEffect } from 'react'; +import type { TaskAttempt, TaskWithAttemptStatus } from 'shared/types'; import { useBranchStatus, useOpenInEditor } from '@/hooks'; import { useAttemptExecution } from '@/hooks/useAttemptExecution'; import { useDevServer } from '@/hooks/useDevServer'; -import { useRebase } from '@/hooks/useRebase'; -import { useMerge } from '@/hooks/useMerge'; -import NiceModal from '@ebay/nice-modal-react'; -import { Err } from '@/lib/api'; -import type { GitOperationError } from 'shared/types'; -import { displayConflictOpLabel } from '@/lib/conflicts'; -import { usePush } from '@/hooks/usePush'; import { useUserSystem } from '@/components/config-provider.tsx'; import { writeClipboardViaBridge } from '@/vscode/bridge'; import { useProcessSelection } from '@/contexts/ProcessSelectionContext'; import { openTaskForm } from '@/lib/openTaskForm'; -import { showModal } from '@/lib/modals'; // Helper function to get the display name for different editor types function getEditorDisplayName(editorType: string): string { @@ -81,14 +57,9 @@ type Props = { task: TaskWithAttemptStatus; projectId: string; projectHasDevScript: boolean; - setError: Dispatch>; - - selectedBranch: string | null; selectedAttempt: TaskAttempt; taskAttempts: TaskAttempt[]; - creatingPR: boolean; handleEnterCreateAttemptMode: () => void; - branches: GitBranch[]; setSelectedAttempt: (attempt: TaskAttempt | null) => void; }; @@ -96,13 +67,9 @@ function CurrentAttempt({ task, projectId, projectHasDevScript, - setError, - selectedBranch, selectedAttempt, taskAttempts, - creatingPR, handleEnterCreateAttemptMode, - branches, setSelectedAttempt, }: Props) { const { config } = useUserSystem(); @@ -117,10 +84,6 @@ function CurrentAttempt({ () => Boolean((branchStatus?.conflicted_files?.length ?? 0) > 0), [branchStatus?.conflicted_files] ); - const conflictOpLabel = useMemo( - () => displayConflictOpLabel(branchStatus?.conflict_op), - [branchStatus?.conflict_op] - ); const handleOpenInEditor = useOpenInEditor(selectedAttempt?.id); const { jumpToProcess } = useProcessSelection(); @@ -132,16 +95,8 @@ function CurrentAttempt({ runningDevServer, latestDevServerProcess, } = useDevServer(selectedAttempt?.id); - const rebaseMutation = useRebase(selectedAttempt?.id, projectId); - const mergeMutation = useMerge(selectedAttempt?.id); - const pushMutation = usePush(selectedAttempt?.id); - const [merging, setMerging] = useState(false); - const [pushing, setPushing] = useState(false); - const [rebasing, setRebasing] = useState(false); const [copied, setCopied] = useState(false); - const [mergeSuccess, setMergeSuccess] = useState(false); - const [pushSuccess, setPushSuccess] = useState(false); const handleViewDevServerLogs = () => { if (latestDevServerProcess) { @@ -152,7 +107,8 @@ function CurrentAttempt({ const handleCreateSubtaskClick = () => { openTaskForm({ projectId, - initialBaseBranch: selectedAttempt.branch || selectedAttempt.base_branch, + initialBaseBranch: + selectedAttempt.branch || selectedAttempt.target_branch, parentTaskAttemptId: selectedAttempt.id, }); }; @@ -167,106 +123,6 @@ function CurrentAttempt({ [setSelectedAttempt] ); - const handleMergeClick = async () => { - if (!projectId || !selectedAttempt?.id || !selectedAttempt?.task_id) return; - - // Directly perform merge without checking branch status - await performMerge(); - }; - - const handlePushClick = async () => { - try { - setPushing(true); - await pushMutation.mutateAsync(); - setError(null); // Clear any previous errors on success - setPushSuccess(true); - setTimeout(() => setPushSuccess(false), 2000); - } catch (error: any) { - setError(error.message || 'Failed to push changes'); - } finally { - setPushing(false); - } - }; - - const performMerge = async () => { - try { - setMerging(true); - await mergeMutation.mutateAsync(); - setError(null); // Clear any previous errors on success - setMergeSuccess(true); - setTimeout(() => setMergeSuccess(false), 2000); - } catch (error) { - // @ts-expect-error it is type ApiError - setError(error.message || 'Failed to merge changes'); - } finally { - setMerging(false); - } - }; - - const handleRebaseClick = async () => { - setRebasing(true); - await rebaseMutation - .mutateAsync(undefined) - .then(() => setError(null)) - .catch((err: Err) => { - const data = err?.error; - const isConflict = - data?.type === 'merge_conflicts' || - data?.type === 'rebase_in_progress'; - if (!isConflict) setError(err.message || 'Failed to rebase branch'); - }); - setRebasing(false); - }; - - const handleRebaseWithNewBranch = async (newBaseBranch: string) => { - setRebasing(true); - await rebaseMutation - .mutateAsync(newBaseBranch) - .then(() => setError(null)) - .catch((err: Err) => { - const data = err?.error; - const isConflict = - data?.type === 'merge_conflicts' || - data?.type === 'rebase_in_progress'; - if (!isConflict) setError(err.message || 'Failed to rebase branch'); - }); - setRebasing(false); - }; - - const handleRebaseDialogOpen = async () => { - try { - const result = await showModal<{ - action: 'confirmed' | 'canceled'; - branchName?: string; - }>('rebase-dialog', { - branches, - isRebasing: rebasing, - }); - - if (result.action === 'confirmed' && result.branchName) { - await handleRebaseWithNewBranch(result.branchName); - } - } catch (error) { - // User cancelled - do nothing - } - }; - - const handlePRButtonClick = async () => { - if (!projectId || !selectedAttempt?.id || !selectedAttempt?.task_id) return; - - // If PR already exists, push to it - if (mergeInfo.hasOpenPR) { - await handlePushClick(); - return; - } - - NiceModal.show('create-pr', { - attempt: selectedAttempt, - task, - projectId, - }); - }; - // Refresh branch status when a process completes (e.g., rebase resolved by agent) const prevRunningRef = useRef(isAttemptRunning); useEffect(() => { @@ -276,60 +132,12 @@ function CurrentAttempt({ prevRunningRef.current = isAttemptRunning; }, [isAttemptRunning, selectedAttempt?.id, refetchBranchStatus]); - // Get display name for selected branch - const selectedBranchDisplayName = useMemo(() => { - if (!selectedBranch) return 'current'; - - // For remote branches, show just the branch name without the remote prefix - if (selectedBranch.includes('/')) { - const parts = selectedBranch.split('/'); - return parts[parts.length - 1]; - } - return selectedBranch; - }, [selectedBranch]); - // Get display name for the configured editor const editorDisplayName = useMemo(() => { if (!config?.editor?.editor_type) return 'Editor'; return getEditorDisplayName(config.editor.editor_type); }, [config?.editor?.editor_type]); - // Memoize merge status information to avoid repeated calculations - const mergeInfo = useMemo(() => { - if (!branchStatus?.merges) - return { - hasOpenPR: false, - openPR: null, - hasMergedPR: false, - mergedPR: null, - hasMerged: false, - latestMerge: null, - }; - - const openPR = branchStatus.merges.find( - (m) => m.type === 'pr' && m.pr_info.status === 'open' - ); - - const mergedPR = branchStatus.merges.find( - (m) => m.type === 'pr' && m.pr_info.status === 'merged' - ); - - const merges = branchStatus.merges.filter( - (m) => - m.type === 'direct' || - (m.type === 'pr' && m.pr_info.status === 'merged') - ); - - return { - hasOpenPR: !!openPR, - openPR, - hasMergedPR: !!mergedPR, - mergedPR, - hasMerged: merges.length > 0, - latestMerge: branchStatus.merges[0] || null, // Most recent merge - }; - }, [branchStatus?.merges]); - const handleCopyWorktreePath = useCallback(async () => { try { await writeClipboardViaBridge(selectedAttempt.container_ref || ''); @@ -340,172 +148,16 @@ function CurrentAttempt({ } }, [selectedAttempt.container_ref]); - // Get status information for display - const getStatusInfo = useCallback(() => { - if (hasConflicts) { - return { - dotColor: 'bg-orange-500', - textColor: 'text-orange-700', - text: `${conflictOpLabel} conflicts`, - isClickable: false, - } as const; - } - if (branchStatus?.is_rebase_in_progress) { - return { - dotColor: 'bg-orange-500', - textColor: 'text-orange-700', - text: 'Rebase in progress', - isClickable: false, - } as const; - } - if (mergeInfo.hasMergedPR && mergeInfo.mergedPR?.type === 'pr') { - const prMerge = mergeInfo.mergedPR; - return { - dotColor: 'bg-green-500', - textColor: 'text-green-700', - text: `PR #${prMerge.pr_info.number} merged`, - isClickable: true, - onClick: () => window.open(prMerge.pr_info.url, '_blank'), - }; - } - if ( - mergeInfo.hasMerged && - mergeInfo.latestMerge?.type === 'direct' && - (branchStatus?.commits_ahead ?? 0) === 0 - ) { - return { - dotColor: 'bg-green-500', - textColor: 'text-green-700', - text: `Merged`, - isClickable: false, - }; - } - - if (mergeInfo.hasOpenPR && mergeInfo.openPR?.type === 'pr') { - const prMerge = mergeInfo.openPR; - return { - dotColor: 'bg-blue-500', - textColor: 'text-blue-700 dark:text-blue-400', - text: `PR #${prMerge.pr_info.number}`, - isClickable: true, - onClick: () => window.open(prMerge.pr_info.url, '_blank'), - }; - } - - if ((branchStatus?.commits_behind ?? 0) > 0) { - return { - dotColor: 'bg-orange-500', - textColor: 'text-orange-700', - text: `Rebase needed${branchStatus?.has_uncommitted_changes ? ' (dirty)' : ''}`, - isClickable: false, - }; - } - - if ((branchStatus?.commits_ahead ?? 0) > 0) { - return { - dotColor: 'bg-yellow-500', - textColor: 'text-yellow-700', - text: - branchStatus?.commits_ahead === 1 - ? `1 commit ahead${branchStatus?.has_uncommitted_changes ? ' (dirty)' : ''}` - : `${branchStatus?.commits_ahead} commits ahead${branchStatus?.has_uncommitted_changes ? ' (dirty)' : ''}`, - isClickable: false, - }; - } - - return { - dotColor: 'bg-gray-500', - textColor: 'text-gray-700', - text: `Up to date${branchStatus?.has_uncommitted_changes ? ' (dirty)' : ''}`, - isClickable: false, - }; - }, [mergeInfo, branchStatus]); - return (
{/*
*/} -
+
Agent
{selectedAttempt.executor}
- -
-
- Task Branch -
-
- - - {selectedAttempt.branch} - -
-
- -
-
- Base Branch - - - - - - -

Change base branch

-
-
-
-
-
- - - {branchStatus?.base_branch_name || selectedBranchDisplayName} - -
-
- -
-
- Status -
-
- {(() => { - const statusInfo = getStatusInfo(); - return ( - <> -
- {statusInfo.isClickable ? ( - - ) : ( - - {statusInfo.text} - - )} - - ); - })()} -
-
@@ -546,9 +198,10 @@ function CurrentAttempt({
-
-
-
+
+
+ {/* Column 1: Start Dev / View Logs */} +
@@ -582,7 +235,7 @@ function CurrentAttempt({ variant="outline" size="xs" onClick={handleViewDevServerLogs} - className="gap-1" + className="gap-1 px-2" > @@ -594,81 +247,9 @@ function CurrentAttempt({ )}
- {/* Git Operations */} - {selectedAttempt && branchStatus && !mergeInfo.hasMergedPR && ( - <> - {(branchStatus.commits_behind ?? 0) > 0 && ( - - )} - <> - - - - - )} -
+ {/* Column 2: New Attempt + History (shared flex-1) */} +
{isStopping || isAttemptRunning ? ( )} - {taskAttempts.length > 1 && ( + + {taskAttempts.length > 1 && !isStopping && !isAttemptRunning && ( - @@ -731,11 +317,13 @@ function CurrentAttempt({ )}
+ + {/* Column 3: Create Subtask */} + + +

{t('branches.changeTarget.dialog.title')}

+
+ + +
+
+ + {/* Bottom Row: Status Information */} +
+
+ {(() => { + const commitsAhead = branchStatus?.commits_ahead ?? 0; + const showAhead = commitsAhead > 0; + + if (showAhead) { + return ( + + {commitsAhead}{' '} + {t('git.status.commits', { count: commitsAhead })}{' '} + {t('git.status.ahead')} + + ); + } + return null; + })()} +
+ +
+ {(() => { + const commitsAhead = branchStatus?.commits_ahead ?? 0; + const commitsBehind = branchStatus?.commits_behind ?? 0; + const showAhead = commitsAhead > 0; + const showBehind = commitsBehind > 0; + + // Handle special states (PR, conflicts, etc.) - center under arrow + if (hasConflictsCalculated) { + return ( +
+ + + {t('git.status.conflicts')} + +
+ ); + } + + if (branchStatus?.is_rebase_in_progress) { + return ( +
+ + + {t('git.states.rebasing')} + +
+ ); + } + + // Check for merged PR + if (mergeInfo.hasMergedPR) { + return ( +
+ + + {t('git.states.merged')} + +
+ ); + } + + // Check for open PR - center under arrow + if (mergeInfo.hasOpenPR && mergeInfo.openPR?.type === 'pr') { + const prMerge = mergeInfo.openPR; + return ( + + ); + } + + // If showing ahead/behind, don't show anything in center + if (showAhead || showBehind) { + return null; + } + + // Default: up to date - center under arrow + return ( + + {t('git.status.upToDate')} + + ); + })()} +
+ +
+ {(() => { + const commitsBehind = branchStatus?.commits_behind ?? 0; + const showBehind = commitsBehind > 0; + + if (showBehind) { + return ( + + {commitsBehind}{' '} + {t('git.status.commits', { count: commitsBehind })}{' '} + {t('git.status.behind')} + + ); + } + return null; + })()} +
+
+
+ + {/* Git Operations */} +
+ + + +
+
+
+ ); +} + +export default GitOperations; diff --git a/frontend/src/components/tasks/follow-up/FollowUpConflictSection.tsx b/frontend/src/components/tasks/follow-up/FollowUpConflictSection.tsx index 347bd046..e07235a6 100644 --- a/frontend/src/components/tasks/follow-up/FollowUpConflictSection.tsx +++ b/frontend/src/components/tasks/follow-up/FollowUpConflictSection.tsx @@ -45,7 +45,7 @@ export function FollowUpConflictSection({ <> void, + onError?: (err: unknown) => void +) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (newTargetBranch) => { + if (!attemptId) { + throw new Error('Attempt id is not set'); + } + + const payload: ChangeTargetBranchRequest = { + new_target_branch: newTargetBranch, + }; + return attemptsApi.change_target_branch(attemptId, payload); + }, + onSuccess: (data) => { + if (attemptId) { + queryClient.invalidateQueries({ + queryKey: ['branchStatus', attemptId], + }); + } + + if (projectId) { + queryClient.invalidateQueries({ + queryKey: ['projectBranches', projectId], + }); + } + + onSuccess?.(data); + }, + onError: (err) => { + console.error('Failed to change target branch:', err); + if (attemptId) { + queryClient.invalidateQueries({ + queryKey: ['branchStatus', attemptId], + }); + } + onError?.(err); + }, + }); +} diff --git a/frontend/src/hooks/useRebase.ts b/frontend/src/hooks/useRebase.ts index a86f14fc..96426787 100644 --- a/frontend/src/hooks/useRebase.ts +++ b/frontend/src/hooks/useRebase.ts @@ -1,7 +1,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { attemptsApi, Result } from '@/lib/api'; -import type { GitOperationError } from 'shared/types'; import type { RebaseTaskAttemptRequest } from 'shared/types'; +import type { GitOperationError } from 'shared/types'; export function useRebase( attemptId: string | undefined, @@ -11,14 +11,22 @@ export function useRebase( ) { const queryClient = useQueryClient(); - return useMutation, string | undefined>( + type RebaseMutationArgs = { + newBaseBranch?: string; + oldBaseBranch?: string; + }; + + return useMutation, RebaseMutationArgs>( { - mutationFn: (newBaseBranch?: string) => { + mutationFn: (args) => { if (!attemptId) return Promise.resolve(); + const { newBaseBranch, oldBaseBranch } = args ?? {}; const data: RebaseTaskAttemptRequest = { - new_base_branch: newBaseBranch || null, + old_base_branch: oldBaseBranch ?? null, + new_base_branch: newBaseBranch ?? null, }; + return attemptsApi.rebase(attemptId, data).then((res) => { if (!res.success) { // Propagate typed failure Result for caller to handle (no manual ApiError construction) diff --git a/frontend/src/i18n/locales/en/tasks.json b/frontend/src/i18n/locales/en/tasks.json index 7fc49892..6b42528b 100644 --- a/frontend/src/i18n/locales/en/tasks.json +++ b/frontend/src/i18n/locales/en/tasks.json @@ -7,5 +7,85 @@ }, "actions": { "addTask": "Add task" + }, + "rebase": { + "common": { + "action": "Rebase", + "inProgress": "Rebasing...", + "withTarget": "Rebase onto {{branch}}" + }, + "dialog": { + "title": "Rebase Task Attempt", + "description": "Choose a new base branch to rebase this task attempt onto.", + "upstreamLabel": "Upstream Branch", + "upstreamPlaceholder": "Select an upstream branch", + "targetLabel": "Target Branch", + "targetPlaceholder": "Select a target branch", + "advanced": "Advanced" + }, + "status": { + "inProgress": "Rebase in progress{{counts}}", + "needed": "Rebase needed{{dirty}}{{counts}}", + "dirtyMarker": " (dirty)" + } + }, + "branches": { + "changeTarget": { + "dialog": { + "title": "Change target branch", + "description": "Choose a new target branch for the task attempt.", + "placeholder": "Select a target branch", + "action": "Change Branch", + "inProgress": "Changing..." + } + } + }, + "attempt": { + "labels": { + "attempt": "Attempt", + "agent": "Agent", + "branch": "Branch", + "diffs": "Diffs" + }, + "actions": { + "openInIde": "Open in IDE", + "openMenu": "Open menu", + "startDevServer": "Start dev server", + "stopDevServer": "Stop dev server" + } + }, + "git": { + "labels": { + "taskBranch": "Task Branch" + }, + "branch": { + "current": "current" + }, + "status": { + "commits_one": "commit", + "commits_other": "commits", + "conflicts": "Conflicts", + "upToDate": "Up to date", + "ahead": "ahead", + "behind": "behind" + }, + "states": { + "merged": "Merged!", + "merging": "Merging...", + "merge": "Merge", + "rebasing": "Rebasing...", + "rebase": "Rebase", + "pushed": "Pushed!", + "pushing": "Pushing...", + "push": "Push", + "creating": "Creating...", + "createPr": "Create PR" + }, + "errors": { + "changeTargetBranch": "Failed to change target branch", + "pushChanges": "Failed to push changes", + "mergeChanges": "Failed to merge changes", + "rebaseBranch": "Failed to rebase branch" + } } } diff --git a/frontend/src/i18n/locales/es/tasks.json b/frontend/src/i18n/locales/es/tasks.json index 27d01c8a..b2cd6aa8 100644 --- a/frontend/src/i18n/locales/es/tasks.json +++ b/frontend/src/i18n/locales/es/tasks.json @@ -7,5 +7,85 @@ }, "actions": { "addTask": "Agregar tarea" + }, + "rebase": { + "common": { + "action": "Rebase", + "inProgress": "Rebaseando...", + "withTarget": "Rebase sobre {{branch}}" + }, + "dialog": { + "title": "Rebase del intento de tarea", + "description": "Elige una nueva rama base para hacer rebase de este intento de tarea.", + "upstreamLabel": "Rama upstream", + "upstreamPlaceholder": "Selecciona una rama upstream", + "targetLabel": "Rama de destino", + "targetPlaceholder": "Selecciona una rama de destino", + "advanced": "Avanzado" + }, + "status": { + "inProgress": "Rebase en progreso{{counts}}", + "needed": "Rebase necesario{{dirty}}{{counts}}", + "dirtyMarker": " (sucio)" + } + }, + "branches": { + "changeTarget": { + "dialog": { + "title": "Cambiar rama de destino", + "description": "Elige una nueva rama de destino para el intento de tarea.", + "placeholder": "Selecciona una rama de destino", + "action": "Cambiar rama", + "inProgress": "Cambiando..." + } + } + }, + "attempt": { + "labels": { + "attempt": "Intento", + "agent": "Agente", + "branch": "Rama", + "diffs": "Diferencias" + }, + "actions": { + "openInIde": "Abrir en IDE", + "openMenu": "Abrir menú", + "startDevServer": "Iniciar servidor de desarrollo", + "stopDevServer": "Detener servidor de desarrollo" + } + }, + "git": { + "labels": { + "taskBranch": "Rama de tarea" + }, + "branch": { + "current": "actual" + }, + "status": { + "commits_one": "commit", + "commits_other": "commits", + "conflicts": "Conflictos", + "upToDate": "Al día", + "ahead": "adelante", + "behind": "atrás" + }, + "states": { + "merged": "¡Fusionado!", + "merging": "Fusionando...", + "merge": "Fusionar", + "rebasing": "Rebaseando...", + "rebase": "Rebase", + "pushed": "¡Enviado!", + "pushing": "Enviando...", + "push": "Enviar", + "creating": "Creando...", + "createPr": "Crear PR" + }, + "errors": { + "changeTargetBranch": "Error al cambiar rama de destino", + "pushChanges": "Error al enviar cambios", + "mergeChanges": "Error al fusionar cambios", + "rebaseBranch": "Error al hacer rebase de la rama" + } } } diff --git a/frontend/src/i18n/locales/ja/tasks.json b/frontend/src/i18n/locales/ja/tasks.json index 5485a03e..678be02b 100644 --- a/frontend/src/i18n/locales/ja/tasks.json +++ b/frontend/src/i18n/locales/ja/tasks.json @@ -7,5 +7,85 @@ }, "actions": { "addTask": "タスクを追加" + }, + "rebase": { + "common": { + "action": "リベース", + "inProgress": "リベース中...", + "withTarget": "{{branch}} にリベース" + }, + "dialog": { + "title": "タスク試行をリベース", + "description": "このタスク試行をリベースする新しいベースブランチを選択してください。", + "upstreamLabel": "アップストリームブランチ", + "upstreamPlaceholder": "アップストリームブランチを選択", + "targetLabel": "ターゲットブランチ", + "targetPlaceholder": "ターゲットブランチを選択", + "advanced": "詳細設定" + }, + "status": { + "inProgress": "リベース進行中{{counts}}", + "needed": "リベースが必要です{{dirty}}{{counts}}", + "dirtyMarker": " (未コミットあり)" + } + }, + "branches": { + "changeTarget": { + "dialog": { + "title": "ターゲットブランチを変更", + "description": "タスク試行の新しいターゲットブランチを選択してください。", + "placeholder": "ターゲットブランチを選択", + "action": "ブランチを変更", + "inProgress": "変更中..." + } + } + }, + "attempt": { + "labels": { + "attempt": "試行", + "agent": "エージェント", + "branch": "ブランチ", + "diffs": "差分" + }, + "actions": { + "openInIde": "IDEで開く", + "openMenu": "メニューを開く", + "startDevServer": "開発サーバーを開始", + "stopDevServer": "開発サーバーを停止" + } + }, + "git": { + "labels": { + "taskBranch": "タスクブランチ" + }, + "branch": { + "current": "現在" + }, + "status": { + "commits_one": "コミット", + "commits_other": "コミット", + "conflicts": "競合", + "upToDate": "最新", + "ahead": "先行", + "behind": "遅れ" + }, + "states": { + "merged": "マージ完了!", + "merging": "マージ中...", + "merge": "マージ", + "rebasing": "リベース中...", + "rebase": "リベース", + "pushed": "プッシュ完了!", + "pushing": "プッシュ中...", + "push": "プッシュ", + "creating": "作成中...", + "createPr": "PRを作成" + }, + "errors": { + "changeTargetBranch": "ターゲットブランチの変更に失敗しました", + "pushChanges": "変更のプッシュに失敗しました", + "mergeChanges": "変更のマージに失敗しました", + "rebaseBranch": "ブランチのリベースに失敗しました" + } } } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 34b0769c..56f215c8 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -22,7 +22,6 @@ import { GitBranch, Project, CreateProject, - RebaseTaskAttemptRequest, RepositoryInfo, SearchResult, Task, @@ -43,6 +42,9 @@ import { UpdateFollowUpDraftRequest, GitOperationError, ApprovalResponse, + RebaseTaskAttemptRequest, + ChangeTargetBranchRequest, + ChangeTargetBranchResponse, } from 'shared/types'; // Re-export types for convenience @@ -493,6 +495,20 @@ export const attemptsApi = { return handleApiResponseAsResult(response); }, + change_target_branch: async ( + attemptId: string, + data: ChangeTargetBranchRequest + ): Promise => { + const response = await makeRequest( + `/api/task-attempts/${attemptId}/change-target-branch`, + { + method: 'POST', + body: JSON.stringify(data), + } + ); + return handleApiResponse(response); + }, + abortConflicts: async (attemptId: string): Promise => { const response = await makeRequest( `/api/task-attempts/${attemptId}/conflicts/abort`, diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 9a8a8eed..a0d10363 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -24,6 +24,7 @@ import { DeleteTaskConfirmationDialog, FolderPickerDialog, TaskTemplateEditDialog, + ChangeTargetBranchDialog, RebaseDialog, CreateConfigurationDialog, DeleteConfigurationDialog, @@ -46,6 +47,7 @@ NiceModal.register('task-form', TaskFormDialog); NiceModal.register('editor-selection', EditorSelectionDialog); NiceModal.register('folder-picker', FolderPickerDialog); NiceModal.register('task-template-edit', TaskTemplateEditDialog); +NiceModal.register('change-target-branch-dialog', ChangeTargetBranchDialog); NiceModal.register('rebase-dialog', RebaseDialog); NiceModal.register('create-configuration', CreateConfigurationDialog); NiceModal.register('delete-configuration', DeleteConfigurationDialog); diff --git a/shared/types.ts b/shared/types.ts index 6b595546..148fff2d 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -78,9 +78,13 @@ export type FollowUpDraftResponse = { task_attempt_id: string, prompt: string, q export type UpdateFollowUpDraftRequest = { prompt: string | null, variant: string | null | null, image_ids: Array | null, version: bigint | null, }; +export type ChangeTargetBranchRequest = { new_target_branch: string, }; + +export type ChangeTargetBranchResponse = { new_target_branch: string, status: [number, number], }; + export type CreateAndStartTaskRequest = { task: CreateTask, executor_profile_id: ExecutorProfileId, base_branch: string, }; -export type CreateGitHubPrRequest = { title: string, body: string | null, base_branch: string | null, }; +export type CreateGitHubPrRequest = { title: string, body: string | null, target_branch: string | null, }; export type ImageResponse = { id: string, file_path: string, original_name: string, mime_type: string | null, size_bytes: bigint, hash: string, created_at: string, updated_at: string, }; @@ -190,7 +194,7 @@ export type CreateTaskAttemptBody = { task_id: string, */ executor_profile_id: ExecutorProfileId, base_branch: string, }; -export type RebaseTaskAttemptRequest = { new_base_branch: string | null, }; +export type RebaseTaskAttemptRequest = { old_base_branch: string | null, new_base_branch: string | null, }; export type GitOperationError = { "type": "merge_conflicts", message: string, op: ConflictOp, } | { "type": "rebase_in_progress" }; @@ -218,7 +222,7 @@ perform_git_reset: boolean | null, }; export type CommitInfo = { sha: string, subject: string, }; -export type BranchStatus = { commits_behind: number | null, commits_ahead: number | null, has_uncommitted_changes: boolean | null, head_oid: string | null, uncommitted_count: number | null, untracked_count: number | null, base_branch_name: string, remote_commits_behind: number | null, remote_commits_ahead: number | null, merges: Array, +export type BranchStatus = { commits_behind: number | null, commits_ahead: number | null, has_uncommitted_changes: boolean | null, head_oid: string | null, uncommitted_count: number | null, untracked_count: number | null, target_branch_name: string, remote_commits_behind: number | null, remote_commits_ahead: number | null, merges: Array, /** * True if a `git rebase` is currently in progress in this worktree */ @@ -234,7 +238,7 @@ conflicted_files: Array, }; export type ConflictOp = "rebase" | "merge" | "cherry_pick" | "revert"; -export type TaskAttempt = { id: string, task_id: string, container_ref: string | null, branch: string | null, base_branch: string, executor: string, worktree_deleted: boolean, setup_completed_at: string | null, created_at: string, updated_at: string, }; +export type TaskAttempt = { id: string, task_id: string, container_ref: string | null, branch: string, target_branch: string, executor: string, worktree_deleted: boolean, setup_completed_at: string | null, created_at: string, updated_at: string, }; export type ExecutionProcess = { id: string, task_attempt_id: string, run_reason: ExecutionProcessRunReason, executor_action: ExecutorAction, /**