diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index f1a17c79861..43e80193080 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -62,6 +62,10 @@ async function runGit(cwd: string, args: string[]) { await execFileAsync("git", args, { cwd }); } +async function readGit(cwd: string, args: string[]) { + return (await execFileAsync("git", args, { cwd })).stdout.trim(); +} + async function runPnpm(cwd: string, args: string[]) { await execFileAsync("pnpm", args, { cwd }); } @@ -304,6 +308,57 @@ describe("ensureServerWorkspaceLinksCurrent", () => { }); describe("realizeExecutionWorkspace", () => { + it("defaults new git worktrees to freshly fetched origin/master", async () => { + const sourceRepo = await createTempRepo("master"); + const remoteDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-remote-")); + const remotePath = path.join(remoteDir, "paperclip.git"); + await execFileAsync("git", ["clone", "--bare", sourceRepo, remotePath]); + + const cloneRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-clone-")); + const repoRoot = path.join(cloneRoot, "paperclip"); + await execFileAsync("git", ["clone", remotePath, repoRoot]); + await runGit(repoRoot, ["config", "user.email", "paperclip@example.com"]); + await runGit(repoRoot, ["config", "user.name", "Paperclip Test"]); + + await fs.writeFile(path.join(sourceRepo, "auth-fix.txt"), "cookie fix\n", "utf8"); + await runGit(sourceRepo, ["add", "auth-fix.txt"]); + await runGit(sourceRepo, ["commit", "-m", "Add auth fix"]); + await runGit(sourceRepo, ["push", remotePath, "master"]); + const expectedRemoteHead = await readGit(sourceRepo, ["rev-parse", "master"]); + expect(await readGit(repoRoot, ["rev-parse", "origin/master"])).not.toBe(expectedRemoteHead); + + const workspace = await realizeExecutionWorkspace({ + base: { + baseCwd: repoRoot, + source: "project_primary", + projectId: "project-1", + workspaceId: "workspace-1", + repoUrl: null, + repoRef: null, + }, + config: { + workspaceStrategy: { + type: "git_worktree", + branchTemplate: "{{issue.identifier}}-{{slug}}", + }, + }, + issue: { + id: "issue-1", + identifier: "PAP-447", + title: "Add Worktree Support", + }, + agent: { + id: "agent-1", + name: "Codex Coder", + companyId: "company-1", + }, + }); + + expect(workspace.baseRefSha).toBe(expectedRemoteHead); + expect(await readGit(repoRoot, ["rev-parse", "origin/master"])).toBe(expectedRemoteHead); + expect(await readGit(workspace.cwd, ["rev-parse", "HEAD"])).toBe(expectedRemoteHead); + }); + it("creates and reuses a git worktree for an issue-scoped branch", async () => { const repoRoot = await createTempRepo(); @@ -1842,7 +1897,7 @@ describe("realizeExecutionWorkspace", () => { config: { workspaceStrategy: { type: "git_worktree", - // No baseRef configured — should auto-detect "master" + // No baseRef configured — should default to origin/master. }, }, issue: { @@ -1860,10 +1915,10 @@ describe("realizeExecutionWorkspace", () => { expect(workspace.strategy).toBe("git_worktree"); expect(workspace.created).toBe(true); - // The worktree should have been created successfully (baseRef resolved to "master") + // The worktree should have been created successfully from the canonical remote base. const worktreeOp = operations.find(op => op.phase === "worktree_prepare" && op.metadata?.created); expect(worktreeOp).toBeDefined(); - expect(worktreeOp!.metadata!.baseRef).toBe("master"); + expect(worktreeOp!.metadata!.baseRef).toBe("origin/master"); }, 10_000); it("auto-detects the default branch via symbolic-ref when origin/HEAD is set", async () => { @@ -1894,7 +1949,7 @@ describe("realizeExecutionWorkspace", () => { config: { workspaceStrategy: { type: "git_worktree", - // No baseRef configured — should auto-detect "master" via symbolic-ref + // No baseRef configured — origin/master is preferred over the symbolic-ref. }, }, issue: { @@ -1914,7 +1969,7 @@ describe("realizeExecutionWorkspace", () => { expect(workspace.created).toBe(true); const worktreeOp = operations.find(op => op.phase === "worktree_prepare" && op.metadata?.created); expect(worktreeOp).toBeDefined(); - expect(worktreeOp!.metadata!.baseRef).toBe("master"); + expect(worktreeOp!.metadata!.baseRef).toBe("origin/master"); }, 10_000); it("removes a created git worktree and branch during cleanup", async () => { diff --git a/server/src/services/workspace-runtime.ts b/server/src/services/workspace-runtime.ts index 69b4d972133..11345d4f8fd 100644 --- a/server/src/services/workspace-runtime.ts +++ b/server/src/services/workspace-runtime.ts @@ -691,22 +691,27 @@ async function isGitCheckout(cwd: string): Promise { } async function detectDefaultBranch(repoRoot: string): Promise { + const originMasterRef = "origin/master"; + await refreshRemoteTrackingBaseRef(repoRoot, originMasterRef); + if (await resolveBaseRefSha(repoRoot, originMasterRef)) { + return originMasterRef; + } + // Try the explicit remote HEAD first (set by git clone or git remote set-head) try { const remoteHead = await runGit( ["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"], repoRoot, ); - const branch = remoteHead?.startsWith("origin/") ? remoteHead.slice("origin/".length) : remoteHead; - if (branch) return branch; + if (remoteHead) return remoteHead; } catch { // Not set — fall through to heuristic } // Fallback: check for common default branch names on the remote - for (const candidate of ["main", "master"]) { + for (const candidate of ["origin/main", "main", "master"]) { try { - await runGit(["rev-parse", "--verify", `refs/remotes/origin/${candidate}`], repoRoot); + await runGit(["rev-parse", "--verify", `${candidate}^{commit}`], repoRoot); return candidate; } catch { // Not found — try next