diff --git a/packages/opencode/src/tasks/job-commands.ts b/packages/opencode/src/tasks/job-commands.ts index ae99a2540c8d..0020b2ec4821 100644 --- a/packages/opencode/src/tasks/job-commands.ts +++ b/packages/opencode/src/tasks/job-commands.ts @@ -84,21 +84,45 @@ export async function executeStart(projectId: string, params: any, ctx: any): Pr try { const { $ } = await import("bun") const base = await PulseUtils.defaultBranch(cwd) - const result = await $`git checkout -b ${safeFeatureBranch} ${base}`.cwd(cwd).quiet().nothrow() - if (result.exitCode !== 0) { - const stderr = result.stderr ? new TextDecoder().decode(result.stderr) : "Unknown error" - log.error("failed to create feature branch", { issueNumber, featureBranch: safeFeatureBranch, error: stderr }) - throw new Error(`Failed to create feature branch ${safeFeatureBranch}: ${stderr}`) + + // Check if branch already exists + const branchCheck = await $`git rev-parse --verify ${safeFeatureBranch}`.cwd(cwd).quiet().nothrow() + + if (branchCheck.exitCode === 0) { + // Branch exists — check it out instead of creating + const checkoutResult = await $`git checkout ${safeFeatureBranch}`.cwd(cwd).quiet().nothrow() + if (checkoutResult.exitCode !== 0) { + const stderr = checkoutResult.stderr ? new TextDecoder().decode(checkoutResult.stderr) : "Unknown error" + log.error("failed to checkout existing branch", { issueNumber, featureBranch: safeFeatureBranch, error: stderr }) + throw new Error(`Failed to checkout existing branch ${safeFeatureBranch}: ${stderr}`) + } + log.info("reusing existing feature branch", { issueNumber, featureBranch: safeFeatureBranch }) + } else { + // Branch doesn't exist — create it + const result = await $`git checkout -b ${safeFeatureBranch} ${base}`.cwd(cwd).quiet().nothrow() + if (result.exitCode !== 0) { + const stderr = result.stderr ? new TextDecoder().decode(result.stderr) : "Unknown error" + log.error("failed to create feature branch", { issueNumber, featureBranch: safeFeatureBranch, error: stderr }) + throw new Error(`Failed to create feature branch ${safeFeatureBranch}: ${stderr}`) + } + log.info("feature branch created", { issueNumber, featureBranch: safeFeatureBranch }) } // Push the feature branch to origin - MUST succeed before creating job const pushResult = await $`git push -u origin ${safeFeatureBranch}`.cwd(cwd).quiet().nothrow() if (pushResult.exitCode !== 0) { const stderr = pushResult.stderr ? new TextDecoder().decode(pushResult.stderr) : "Unknown error" - log.error("failed to push feature branch to origin", { issueNumber, featureBranch: safeFeatureBranch, error: stderr }) - throw new Error(`Failed to push feature branch ${safeFeatureBranch} to origin: ${stderr}`) + // If push fails because remote branch exists, that's okay — just set upstream + if (stderr.includes("already exists") || stderr.includes("would clobber")) { + log.info("remote branch already exists, setting upstream", { issueNumber, featureBranch: safeFeatureBranch }) + await $`git branch --set-upstream-to=origin/${safeFeatureBranch} ${safeFeatureBranch}`.cwd(cwd).quiet().nothrow() + } else { + log.error("failed to push feature branch to origin", { issueNumber, featureBranch: safeFeatureBranch, error: stderr }) + throw new Error(`Failed to push feature branch ${safeFeatureBranch} to origin: ${stderr}`) + } + } else { + log.info("feature branch pushed to origin", { issueNumber, featureBranch: safeFeatureBranch }) } - log.info("feature branch created and pushed", { issueNumber, featureBranch: safeFeatureBranch }) } catch (e) { log.error("error creating feature branch", { issueNumber, featureBranch: safeFeatureBranch, error: String(e) }) throw e diff --git a/packages/opencode/src/tasks/pulse-scheduler.ts b/packages/opencode/src/tasks/pulse-scheduler.ts index 9daee169789e..94f6b7c53685 100644 --- a/packages/opencode/src/tasks/pulse-scheduler.ts +++ b/packages/opencode/src/tasks/pulse-scheduler.ts @@ -367,7 +367,7 @@ async function spawnAdversarial(task: Task, jobId: string, projectId: string, pm try { adversarialSession = await Session.createNext({ parentID: pmSessionId, - directory: parentSession.directory, + directory: safeWorktree, title: `Adversarial: ${task.title}`, permission: [], }) diff --git a/packages/opencode/test/fixture/fixture.ts b/packages/opencode/test/fixture/fixture.ts index ad9a13cbb0e7..588ea9d00f7b 100644 --- a/packages/opencode/test/fixture/fixture.ts +++ b/packages/opencode/test/fixture/fixture.ts @@ -21,6 +21,8 @@ export async function tmpdir(options?: TmpDirOptions) { if (options?.git) { await $`git init`.cwd(dirpath).quiet() await $`git commit --allow-empty -m "root commit ${dirpath}"`.cwd(dirpath).quiet() + // Ensure we're on a main branch (modern git may auto-create it, or we may be on master/detached) + await $`git checkout -b main`.cwd(dirpath).quiet().nothrow() } if (options?.config) { await Bun.write( diff --git a/packages/opencode/test/tasks/branch-resilience.test.ts b/packages/opencode/test/tasks/branch-resilience.test.ts new file mode 100644 index 000000000000..73e007dc4098 --- /dev/null +++ b/packages/opencode/test/tasks/branch-resilience.test.ts @@ -0,0 +1,94 @@ +import { describe, test, expect } from "bun:test" +import { $ } from "bun" +import path from "path" +import { tmpdir } from "../fixture/fixture" +import fs from "fs/promises" + +describe("taskctl start: branch creation resilience", () => { + test("checks out existing branch instead of creating duplicate", async () => { + await using tmp = await tmpdir({ git: true }) + + const branchName = "feature/test-branch" + + // Create the branch once + await $`git checkout -b ${branchName}`.cwd(tmp.path).quiet().nothrow() + const firstCheckout = await $`git rev-parse --abbrev-ref HEAD`.cwd(tmp.path).quiet().nothrow() + expect(new TextDecoder().decode(firstCheckout.stdout).trim()).toBe(branchName) + + // Make a commit to establish history + await Bun.write(path.join(tmp.path, "test.txt"), "content") + await $`git add test.txt`.cwd(tmp.path).quiet().nothrow() + await $`git commit -m "test commit"`.cwd(tmp.path).quiet().nothrow() + + // Switch back to main + await $`git checkout -`.cwd(tmp.path).quiet().nothrow() + + // Verify branch exists + const branchCheck = await $`git rev-parse --verify ${branchName}`.cwd(tmp.path).quiet().nothrow() + expect(branchCheck.exitCode).toBe(0) + + // Simulate the start command logic: check if branch exists + const existsCheck = await $`git rev-parse --verify ${branchName}`.cwd(tmp.path).quiet().nothrow() + + if (existsCheck.exitCode === 0) { + // Branch exists — check it out + const checkoutResult = await $`git checkout ${branchName}`.cwd(tmp.path).quiet().nothrow() + expect(checkoutResult.exitCode).toBe(0) + + const currentBranch = await $`git rev-parse --abbrev-ref HEAD`.cwd(tmp.path).quiet().nothrow() + expect(new TextDecoder().decode(currentBranch.stdout).trim()).toBe(branchName) + } else { + throw new Error("Branch should exist") + } + }) + + test("creates new branch when it doesn't exist", async () => { + await using tmp = await tmpdir({ git: true }) + + const branchName = "feature/new-branch-test" + + // Verify branch doesn't exist + const branchCheck = await $`git rev-parse --verify ${branchName}`.cwd(tmp.path).quiet().nothrow() + expect(branchCheck.exitCode).not.toBe(0) + + // Simulate the start command logic: branch doesn't exist, create it + const base = "main" + const createResult = await $`git checkout -b ${branchName} ${base}`.cwd(tmp.path).quiet().nothrow() + expect(createResult.exitCode).toBe(0) + + const currentBranch = await $`git rev-parse --abbrev-ref HEAD`.cwd(tmp.path).quiet().nothrow() + expect(new TextDecoder().decode(currentBranch.stdout).trim()).toBe(branchName) + }) + + test("handles remote branch already exists gracefully", async () => { + await using tmp = await tmpdir({ git: true }) + + const branchName = "feature/remote-exists-test" + + // Create branch + await $`git checkout -b ${branchName}`.cwd(tmp.path).quiet().nothrow() + await Bun.write(path.join(tmp.path, "test.txt"), "content") + await $`git add test.txt`.cwd(tmp.path).quiet().nothrow() + await $`git commit -m "test commit"`.cwd(tmp.path).quiet().nothrow() + + // Add a fake remote that points to the current directory + const remotePath = tmp.path + await $`git remote add test-remote ${remotePath}`.cwd(tmp.path).quiet().nothrow() + + // Push to the remote + const pushResult = await $`git push test-remote ${branchName}`.cwd(tmp.path).quiet().nothrow().nothrow() + + // If push failed with "already exists" error (which it shouldn't in this case, but we test the logic) + // then we would set upstream manually + if (pushResult.exitCode !== 0) { + const stderr = pushResult.stderr ? new TextDecoder().decode(pushResult.stderr) : "" + if (stderr.includes("already exists") || stderr.includes("would clobber")) { + await $`git branch --set-upstream-to=test-remote/${branchName} ${branchName}`.cwd(tmp.path).quiet().nothrow() + } + } + + // The branch should be checked out and ready + const currentBranch = await $`git rev-parse --abbrev-ref HEAD`.cwd(tmp.path).quiet().nothrow() + expect(new TextDecoder().decode(currentBranch.stdout).trim()).toBe(branchName) + }) +}) \ No newline at end of file