fix: restore correct worktree source resolution for branches and PRs (#578)

Prevent local branches with slashes from being treated as remotes during worktree creation
Use PR head refs (including fork remotes) when creating PR-linked worktrees
Keep upstream tracking aligned so pushes target the intended PR branch
This commit is contained in:
Bohdan Triapitsyn
2026-03-03 00:52:36 +02:00
committed by GitHub
parent bdad912ea5
commit 604ac682c2
3 changed files with 148 additions and 31 deletions
@@ -109,6 +109,64 @@ const slugifyWorktreeName = (value: string): string => {
const LAST_SOURCE_BRANCH_KEY = 'oc:lastWorktreeSourceBranch';
const sanitizeRemoteName = (value: string): string => {
const normalized = String(value || '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-+|-+$/g, '');
return normalized || 'pr-head';
};
const resolvePrWorktreeConfig = (pr: GitHubPullRequestSummary, remoteBranches: string[]) => {
const headBranch = normalizeBranchName(pr.head || '');
if (!headBranch) {
throw new Error('PR head branch is missing');
}
const availableRemoteBranch = remoteBranches.find((remoteBranch) => {
const slashIndex = remoteBranch.indexOf('/');
if (slashIndex <= 0 || slashIndex >= remoteBranch.length - 1) {
return false;
}
return remoteBranch.slice(slashIndex + 1) === headBranch;
});
if (availableRemoteBranch) {
const slashIndex = availableRemoteBranch.indexOf('/');
const remoteName = availableRemoteBranch.slice(0, slashIndex);
return {
existingBranch: `remotes/${availableRemoteBranch}`,
setUpstream: true as const,
upstreamRemote: remoteName,
upstreamBranch: headBranch,
ensureRemoteName: undefined,
ensureRemoteUrl: undefined,
sourceLabel: `${remoteName}/${headBranch}`,
};
}
const ownerFromLabel = String(pr.headLabel || '').split(':')[0]?.trim();
const remoteSeed = pr.headRepo?.owner || ownerFromLabel || 'pr-head';
const remoteName = `pr-${sanitizeRemoteName(remoteSeed)}`;
const remoteUrl = pr.headRepo?.sshUrl || pr.headRepo?.cloneUrl || '';
if (!remoteUrl) {
throw new Error('PR head repository URL is unavailable');
}
return {
existingBranch: `remotes/${remoteName}/${headBranch}`,
setUpstream: true as const,
upstreamRemote: remoteName,
upstreamBranch: headBranch,
ensureRemoteName: remoteName,
ensureRemoteUrl: remoteUrl,
sourceLabel: `${remoteName}/${headBranch}`,
};
};
interface NewWorktreeDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
@@ -730,36 +788,55 @@ Nice-to-have:
try {
const setupCommands = await getWorktreeSetupCommands(projectRef);
// Determine source branch - use PR base if PR is selected, otherwise use selected source branch
const effectiveSourceBranch = newBranchState.linkedPr
? newBranchState.linkedPr.base
: newBranchState.sourceBranch;
const args = {
preferredName: normalizedBranch || normalizedWorktree,
mode: mode === 'existing-branch' ? 'existing' as const : 'new' as const,
branchName: mode === 'existing-branch' ? undefined : normalizedBranch,
worktreeName: normalizedWorktree,
existingBranch: mode === 'existing-branch' ? normalizedBranch : undefined,
setupCommands,
...(effectiveSourceBranch && mode === 'new-branch' ? { startRef: effectiveSourceBranch } : {}),
};
const linkedPr = mode === 'new-branch' ? newBranchState.linkedPr : null;
const sourceBranch = newBranchState.sourceBranch;
let sourceLabel = '';
const args = (() => {
if (linkedPr) {
const prConfig = resolvePrWorktreeConfig(linkedPr, remoteBranches);
sourceLabel = prConfig.sourceLabel;
return {
preferredName: normalizedBranch || normalizedWorktree,
mode: 'existing' as const,
branchName: normalizedBranch,
worktreeName: normalizedWorktree,
existingBranch: prConfig.existingBranch,
setupCommands,
setUpstream: prConfig.setUpstream,
upstreamRemote: prConfig.upstreamRemote,
upstreamBranch: prConfig.upstreamBranch,
...(prConfig.ensureRemoteName ? { ensureRemoteName: prConfig.ensureRemoteName } : {}),
...(prConfig.ensureRemoteUrl ? { ensureRemoteUrl: prConfig.ensureRemoteUrl } : {}),
};
}
sourceLabel = mode === 'new-branch' ? sourceBranch : '';
return {
preferredName: normalizedBranch || normalizedWorktree,
mode: mode === 'existing-branch' ? 'existing' as const : 'new' as const,
branchName: mode === 'existing-branch' ? undefined : normalizedBranch,
worktreeName: normalizedWorktree,
existingBranch: mode === 'existing-branch' ? normalizedBranch : undefined,
setupCommands,
...(sourceBranch && mode === 'new-branch' ? { startRef: sourceBranch } : {}),
};
})();
const resolvedArgs = await withWorktreeUpstreamDefaults(projectDirectory, args);
const metadata = await createWorktree(projectRef, resolvedArgs);
const linkedIssue = mode === 'new-branch' ? newBranchState.linkedIssue : null;
const linkedPr = mode === 'new-branch' ? newBranchState.linkedPr : null;
const linkedPrState = mode === 'new-branch' ? newBranchState.linkedPr : null;
const includePrDiff = mode === 'new-branch' ? newBranchState.includePrDiff : false;
let createdSessionId: string | null = null;
if (linkedIssue || linkedPr) {
if (linkedIssue || linkedPrState) {
const sessionTitle = linkedIssue
? `#${linkedIssue.number} ${linkedIssue.title}`.trim()
: linkedPr
? `#${linkedPr.number} ${linkedPr.title}`.trim()
: linkedPrState
? `#${linkedPrState.number} ${linkedPrState.title}`.trim()
: 'New session';
const session = await useSessionStore.getState().createSession(sessionTitle, metadata.path, null);
@@ -783,7 +860,7 @@ Nice-to-have:
}
toast.success('Worktree created', {
description: `${metadata.branch || metadata.name}${effectiveSourceBranch ? ` from ${effectiveSourceBranch}` : ''}`,
description: `${metadata.branch || metadata.name}${sourceLabel ? ` from ${sourceLabel}` : ''}`,
});
try {
@@ -799,7 +876,7 @@ Nice-to-have:
void sendLinkedContextMessage({
sessionId: createdSessionId,
issue: linkedIssue,
pr: linkedPr,
pr: linkedPrState,
includeDiff: includePrDiff,
}).catch((error) => {
const message = error instanceof Error ? error.message : 'Failed to send GitHub context';
+25 -5
View File
@@ -928,6 +928,26 @@ const parseRemoteBranchRef = (value: string) => {
};
};
const resolveRemoteBranchRef = async (primaryWorktree: string, value: string) => {
const raw = String(value || '').trim();
const parsed = parseRemoteBranchRef(raw);
if (!parsed) {
return null;
}
if (raw.startsWith('refs/remotes/') || raw.startsWith('remotes/')) {
return parsed;
}
const localRef = `refs/heads/${raw}`;
const localExists = await runGitCommand(primaryWorktree, ['show-ref', '--verify', '--quiet', localRef]);
if (localExists.success) {
return null;
}
return parsed;
};
const normalizeUpstreamTarget = (remote: string | undefined, branch: string | undefined) => {
const remoteName = String(remote || '').trim();
const branchName = String(branch || '').trim();
@@ -1442,7 +1462,7 @@ export async function validateWorktreeCreate(directory: string, input: CreateGit
if (mode === 'existing') {
try {
const requestedExistingBranch = String(input?.existingBranch || '').trim();
const parsedExistingRemote = parseRemoteBranchRef(requestedExistingBranch);
const parsedExistingRemote = await resolveRemoteBranchRef(context.primaryWorktree, requestedExistingBranch);
if (parsedExistingRemote && ensureRemoteName && ensureRemoteUrl && ensureRemoteName === parsedExistingRemote.remote) {
const lsRemote = await runGitCommand(
context.primaryWorktree,
@@ -1484,7 +1504,7 @@ export async function validateWorktreeCreate(directory: string, input: CreateGit
localBranch = preferredBranchName;
}
const parsedRemoteRef = parseRemoteBranchRef(startRef);
const parsedRemoteRef = await resolveRemoteBranchRef(context.primaryWorktree, startRef);
if (startRef && startRef !== 'HEAD') {
if (parsedRemoteRef && ensureRemoteName && ensureRemoteUrl && ensureRemoteName === parsedRemoteRef.remote) {
const remoteCheck = await checkRemoteBranchExists(
@@ -1587,7 +1607,7 @@ export async function createWorktree(directory: string, input: CreateGitWorktree
if (mode === 'existing') {
const requestedExistingBranch = String(input?.existingBranch || '').trim();
const parsedExistingRemote = parseRemoteBranchRef(requestedExistingBranch);
const parsedExistingRemote = await resolveRemoteBranchRef(context.primaryWorktree, requestedExistingBranch);
if (parsedExistingRemote && ensureRemoteName && ensureRemoteUrl && parsedExistingRemote.remote === ensureRemoteName) {
await ensureRemoteWithUrl(context.primaryWorktree, ensureRemoteName, ensureRemoteUrl);
await fetchRemoteBranchRef(context.primaryWorktree, parsedExistingRemote.remote, parsedExistingRemote.branch);
@@ -1633,7 +1653,7 @@ export async function createWorktree(directory: string, input: CreateGitWorktree
worktreeAddArgs.push(startRef);
}
const parsedRemoteStartRef = parseRemoteBranchRef(startRef);
const parsedRemoteStartRef = await resolveRemoteBranchRef(context.primaryWorktree, startRef);
if (parsedRemoteStartRef) {
inferredUpstream = {
remote: parsedRemoteStartRef.remote,
@@ -1647,7 +1667,7 @@ export async function createWorktree(directory: string, input: CreateGitWorktree
}
if (mode === 'new') {
const parsedRemoteStartRef = parseRemoteBranchRef(startRef);
const parsedRemoteStartRef = await resolveRemoteBranchRef(context.primaryWorktree, startRef);
if (parsedRemoteStartRef) {
await fetchRemoteBranchRef(context.primaryWorktree, parsedRemoteStartRef.remote, parsedRemoteStartRef.branch);
}
+25 -5
View File
@@ -366,6 +366,26 @@ const parseRemoteBranchRef = (value) => {
};
};
const resolveRemoteBranchRef = async (primaryWorktree, value) => {
const raw = String(value || '').trim();
const parsed = parseRemoteBranchRef(raw);
if (!parsed) {
return null;
}
if (raw.startsWith('refs/remotes/') || raw.startsWith('remotes/')) {
return parsed;
}
const localRef = `refs/heads/${raw}`;
const localExists = await runGitCommand(primaryWorktree, ['show-ref', '--verify', '--quiet', localRef]);
if (localExists.success) {
return null;
}
return parsed;
};
const normalizeUpstreamTarget = (remote, branch) => {
const remoteName = String(remote || '').trim();
const branchName = String(branch || '').trim();
@@ -1925,7 +1945,7 @@ export async function validateWorktreeCreate(directory, input = {}) {
if (mode === 'existing') {
try {
const requestedExistingBranch = String(input?.existingBranch || '').trim();
const parsedExistingRemote = parseRemoteBranchRef(requestedExistingBranch);
const parsedExistingRemote = await resolveRemoteBranchRef(context.primaryWorktree, requestedExistingBranch);
if (parsedExistingRemote && ensureRemoteName && ensureRemoteUrl && ensureRemoteName === parsedExistingRemote.remote) {
const lsRemote = await runGitCommand(
context.primaryWorktree,
@@ -1970,7 +1990,7 @@ export async function validateWorktreeCreate(directory, input = {}) {
localBranch = preferredBranchName;
}
const parsedRemoteRef = parseRemoteBranchRef(startRef);
const parsedRemoteRef = await resolveRemoteBranchRef(context.primaryWorktree, startRef);
if (startRef && startRef !== 'HEAD') {
if (parsedRemoteRef && ensureRemoteName && ensureRemoteUrl && ensureRemoteName === parsedRemoteRef.remote) {
const remoteCheck = await checkRemoteBranchExists(
@@ -2107,7 +2127,7 @@ export async function createWorktree(directory, input = {}) {
if (mode === 'existing') {
const requestedExistingBranch = String(input?.existingBranch || '').trim();
const parsedExistingRemote = parseRemoteBranchRef(requestedExistingBranch);
const parsedExistingRemote = await resolveRemoteBranchRef(context.primaryWorktree, requestedExistingBranch);
if (parsedExistingRemote && ensureRemoteName && ensureRemoteUrl && parsedExistingRemote.remote === ensureRemoteName) {
await ensureRemoteWithUrl(context.primaryWorktree, ensureRemoteName, ensureRemoteUrl);
await fetchRemoteBranchRef(context.primaryWorktree, parsedExistingRemote.remote, parsedExistingRemote.branch);
@@ -2153,7 +2173,7 @@ export async function createWorktree(directory, input = {}) {
worktreeAddArgs.push(startRef);
}
const parsedRemoteStartRef = parseRemoteBranchRef(startRef);
const parsedRemoteStartRef = await resolveRemoteBranchRef(context.primaryWorktree, startRef);
if (parsedRemoteStartRef) {
inferredUpstream = {
remote: parsedRemoteStartRef.remote,
@@ -2167,7 +2187,7 @@ export async function createWorktree(directory, input = {}) {
}
if (mode === 'new') {
const parsedRemoteStartRef = parseRemoteBranchRef(startRef);
const parsedRemoteStartRef = await resolveRemoteBranchRef(context.primaryWorktree, startRef);
if (parsedRemoteStartRef) {
await fetchRemoteBranchRef(context.primaryWorktree, parsedRemoteStartRef.remote, parsedRemoteStartRef.branch);
}