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 <noreply@paperclip.ing>

* 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 <noreply@paperclip.ing>

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Devin Foley 2026-04-01 18:00:49 -07:00 committed by GitHub
parent 056a5ee32a
commit 1e24e6e84c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 147 additions and 1 deletions

View file

@ -297,6 +297,32 @@ function gitErrorIncludes(error: unknown, needle: string) {
return message.toLowerCase().includes(needle.toLowerCase());
}
async function detectDefaultBranch(repoRoot: string): Promise<string | null> {
// 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 });