From 1e24e6e84c3b5b0c44005992012bc662d72fb3c1 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Wed, 1 Apr 2026 18:00:49 -0700 Subject: [PATCH] fix: auto-detect default branch for worktree creation when baseRef not configured (#2463) * fix: auto-detect default branch for worktree creation when baseRef not configured When creating git worktrees, if no explicit baseRef is configured in the project workspace strategy and no repoRef is set, the system now auto-detects the repository's default branch instead of blindly falling back to "HEAD". Detection strategy: 1. Check refs/remotes/origin/HEAD (set by git clone / remote set-head) 2. Fall back to probing refs/remotes/origin/main, then origin/master 3. Final fallback: HEAD (preserves existing behavior) This prevents failures like "fatal: invalid reference: main" when a project's workspace strategy has no baseRef and the repo uses a non-standard default branch name. Co-Authored-By: Paperclip * fix: address Greptile review - fix misleading comment and add symbolic-ref test - Corrected comment to clarify that the existing test exercises the heuristic fallback path (not symbolic-ref) - Added new test case that explicitly sets refs/remotes/origin/HEAD via `git remote set-head` to exercise the symbolic-ref code path Co-Authored-By: Paperclip --------- Co-authored-by: Paperclip --- .../src/__tests__/workspace-runtime.test.ts | 115 ++++++++++++++++++ server/src/services/workspace-runtime.ts | 33 ++++- 2 files changed, 147 insertions(+), 1 deletion(-) diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index 6c98b2cf..dc54b810 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -656,6 +656,121 @@ describe("realizeExecutionWorkspace", () => { expect(actualHead).toBe(expectedHead); }); + it("auto-detects the default branch when baseRef is not configured", async () => { + // Create a repo with "master" as default branch (not "main") + const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-master-")); + await runGit(repoRoot, ["init", "-b", "master"]); + await runGit(repoRoot, ["config", "user.email", "paperclip@example.com"]); + await runGit(repoRoot, ["config", "user.name", "Paperclip Test"]); + await fs.writeFile(path.join(repoRoot, "README.md"), "hello\n", "utf8"); + await runGit(repoRoot, ["add", "README.md"]); + await runGit(repoRoot, ["commit", "-m", "Initial commit"]); + + // Set up a bare remote and push master so refs/remotes/origin/master + // exists locally. Note: refs/remotes/origin/HEAD is NOT set by a manual + // fetch — that requires git clone or git remote set-head. This test + // exercises the heuristic fallback path in detectDefaultBranch. + const bareRemote = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-bare-")); + await runGit(bareRemote, ["init", "--bare"]); + await runGit(repoRoot, ["remote", "add", "origin", bareRemote]); + await runGit(repoRoot, ["push", "-u", "origin", "master"]); + await runGit(repoRoot, ["fetch", "origin"]); + + const { recorder, operations } = createWorkspaceOperationRecorderDouble(); + + 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", + // No baseRef configured — should auto-detect "master" + }, + }, + issue: { + id: "issue-1", + identifier: "PAP-460", + title: "Auto detect default branch", + }, + agent: { + id: "agent-1", + name: "Codex Coder", + companyId: "company-1", + }, + recorder, + }); + + expect(workspace.strategy).toBe("git_worktree"); + expect(workspace.created).toBe(true); + // The worktree should have been created successfully (baseRef resolved to "master") + const worktreeOp = operations.find(op => op.phase === "worktree_prepare" && op.metadata?.created); + expect(worktreeOp).toBeDefined(); + expect(worktreeOp!.metadata!.baseRef).toBe("master"); + }); + + it("auto-detects the default branch via symbolic-ref when origin/HEAD is set", async () => { + // Create a repo with "master" as default branch + const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-symref-")); + await runGit(repoRoot, ["init", "-b", "master"]); + await runGit(repoRoot, ["config", "user.email", "paperclip@example.com"]); + await runGit(repoRoot, ["config", "user.name", "Paperclip Test"]); + await fs.writeFile(path.join(repoRoot, "README.md"), "hello\n", "utf8"); + await runGit(repoRoot, ["add", "README.md"]); + await runGit(repoRoot, ["commit", "-m", "Initial commit"]); + + // Set up a bare remote and push + const bareRemote = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-bare-symref-")); + await runGit(bareRemote, ["init", "--bare"]); + await runGit(repoRoot, ["remote", "add", "origin", bareRemote]); + await runGit(repoRoot, ["push", "-u", "origin", "master"]); + await runGit(repoRoot, ["fetch", "origin"]); + // Explicitly set refs/remotes/origin/HEAD to exercise the symbolic-ref path + // (git remote set-head -a requires the remote to advertise HEAD, so we set it manually) + await runGit(repoRoot, ["remote", "set-head", "origin", "master"]); + + const { recorder, operations } = createWorkspaceOperationRecorderDouble(); + + 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", + // No baseRef configured — should auto-detect "master" via symbolic-ref + }, + }, + issue: { + id: "issue-1", + identifier: "PAP-461", + title: "Auto detect default branch via symref", + }, + agent: { + id: "agent-1", + name: "Codex Coder", + companyId: "company-1", + }, + recorder, + }); + + expect(workspace.strategy).toBe("git_worktree"); + 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"); + }); + it("removes a created git worktree and branch during cleanup", async () => { const repoRoot = await createTempRepo(); diff --git a/server/src/services/workspace-runtime.ts b/server/src/services/workspace-runtime.ts index dd51d9c6..f191ece4 100644 --- a/server/src/services/workspace-runtime.ts +++ b/server/src/services/workspace-runtime.ts @@ -297,6 +297,32 @@ function gitErrorIncludes(error: unknown, needle: string) { return message.toLowerCase().includes(needle.toLowerCase()); } +async function detectDefaultBranch(repoRoot: string): Promise { + // 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; + } catch { + // Not set — fall through to heuristic + } + + // Fallback: check for common default branch names on the remote + for (const candidate of ["main", "master"]) { + try { + await runGit(["rev-parse", "--verify", `refs/remotes/origin/${candidate}`], repoRoot); + return candidate; + } catch { + // Not found — try next + } + } + + return null; +} + async function directoryExists(value: string) { return fs.stat(value).then((stats) => stats.isDirectory()).catch(() => false); } @@ -601,7 +627,12 @@ export async function realizeExecutionWorkspace(input: { ? resolveConfiguredPath(configuredParentDir, repoRoot) : path.join(repoRoot, ".paperclip", "worktrees"); const worktreePath = path.join(worktreeParentDir, branchName); - const baseRef = asString(rawStrategy.baseRef, input.base.repoRef ?? "HEAD"); + const configuredBaseRef = typeof rawStrategy.baseRef === "string" && rawStrategy.baseRef.length > 0 + ? rawStrategy.baseRef + : input.base.repoRef ?? null; + const baseRef = configuredBaseRef + ?? await detectDefaultBranch(repoRoot) + ?? "HEAD"; await fs.mkdir(worktreeParentDir, { recursive: true });