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 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 { interface NewWorktreeDialogProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
@@ -730,36 +788,55 @@ Nice-to-have:
try { try {
const setupCommands = await getWorktreeSetupCommands(projectRef); const setupCommands = await getWorktreeSetupCommands(projectRef);
const linkedPr = mode === 'new-branch' ? newBranchState.linkedPr : null;
// Determine source branch - use PR base if PR is selected, otherwise use selected source branch const sourceBranch = newBranchState.sourceBranch;
const effectiveSourceBranch = newBranchState.linkedPr
? newBranchState.linkedPr.base let sourceLabel = '';
: newBranchState.sourceBranch; const args = (() => {
if (linkedPr) {
const args = { const prConfig = resolvePrWorktreeConfig(linkedPr, remoteBranches);
preferredName: normalizedBranch || normalizedWorktree, sourceLabel = prConfig.sourceLabel;
mode: mode === 'existing-branch' ? 'existing' as const : 'new' as const, return {
branchName: mode === 'existing-branch' ? undefined : normalizedBranch, preferredName: normalizedBranch || normalizedWorktree,
worktreeName: normalizedWorktree, mode: 'existing' as const,
existingBranch: mode === 'existing-branch' ? normalizedBranch : undefined, branchName: normalizedBranch,
setupCommands, worktreeName: normalizedWorktree,
...(effectiveSourceBranch && mode === 'new-branch' ? { startRef: effectiveSourceBranch } : {}), 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 resolvedArgs = await withWorktreeUpstreamDefaults(projectDirectory, args);
const metadata = await createWorktree(projectRef, resolvedArgs); const metadata = await createWorktree(projectRef, resolvedArgs);
const linkedIssue = mode === 'new-branch' ? newBranchState.linkedIssue : null; 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; const includePrDiff = mode === 'new-branch' ? newBranchState.includePrDiff : false;
let createdSessionId: string | null = null; let createdSessionId: string | null = null;
if (linkedIssue || linkedPr) { if (linkedIssue || linkedPrState) {
const sessionTitle = linkedIssue const sessionTitle = linkedIssue
? `#${linkedIssue.number} ${linkedIssue.title}`.trim() ? `#${linkedIssue.number} ${linkedIssue.title}`.trim()
: linkedPr : linkedPrState
? `#${linkedPr.number} ${linkedPr.title}`.trim() ? `#${linkedPrState.number} ${linkedPrState.title}`.trim()
: 'New session'; : 'New session';
const session = await useSessionStore.getState().createSession(sessionTitle, metadata.path, null); const session = await useSessionStore.getState().createSession(sessionTitle, metadata.path, null);
@@ -783,7 +860,7 @@ Nice-to-have:
} }
toast.success('Worktree created', { toast.success('Worktree created', {
description: `${metadata.branch || metadata.name}${effectiveSourceBranch ? ` from ${effectiveSourceBranch}` : ''}`, description: `${metadata.branch || metadata.name}${sourceLabel ? ` from ${sourceLabel}` : ''}`,
}); });
try { try {
@@ -799,7 +876,7 @@ Nice-to-have:
void sendLinkedContextMessage({ void sendLinkedContextMessage({
sessionId: createdSessionId, sessionId: createdSessionId,
issue: linkedIssue, issue: linkedIssue,
pr: linkedPr, pr: linkedPrState,
includeDiff: includePrDiff, includeDiff: includePrDiff,
}).catch((error) => { }).catch((error) => {
const message = error instanceof Error ? error.message : 'Failed to send GitHub context'; 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 normalizeUpstreamTarget = (remote: string | undefined, branch: string | undefined) => {
const remoteName = String(remote || '').trim(); const remoteName = String(remote || '').trim();
const branchName = String(branch || '').trim(); const branchName = String(branch || '').trim();
@@ -1442,7 +1462,7 @@ export async function validateWorktreeCreate(directory: string, input: CreateGit
if (mode === 'existing') { if (mode === 'existing') {
try { try {
const requestedExistingBranch = String(input?.existingBranch || '').trim(); const requestedExistingBranch = String(input?.existingBranch || '').trim();
const parsedExistingRemote = parseRemoteBranchRef(requestedExistingBranch); const parsedExistingRemote = await resolveRemoteBranchRef(context.primaryWorktree, requestedExistingBranch);
if (parsedExistingRemote && ensureRemoteName && ensureRemoteUrl && ensureRemoteName === parsedExistingRemote.remote) { if (parsedExistingRemote && ensureRemoteName && ensureRemoteUrl && ensureRemoteName === parsedExistingRemote.remote) {
const lsRemote = await runGitCommand( const lsRemote = await runGitCommand(
context.primaryWorktree, context.primaryWorktree,
@@ -1484,7 +1504,7 @@ export async function validateWorktreeCreate(directory: string, input: CreateGit
localBranch = preferredBranchName; localBranch = preferredBranchName;
} }
const parsedRemoteRef = parseRemoteBranchRef(startRef); const parsedRemoteRef = await resolveRemoteBranchRef(context.primaryWorktree, startRef);
if (startRef && startRef !== 'HEAD') { if (startRef && startRef !== 'HEAD') {
if (parsedRemoteRef && ensureRemoteName && ensureRemoteUrl && ensureRemoteName === parsedRemoteRef.remote) { if (parsedRemoteRef && ensureRemoteName && ensureRemoteUrl && ensureRemoteName === parsedRemoteRef.remote) {
const remoteCheck = await checkRemoteBranchExists( const remoteCheck = await checkRemoteBranchExists(
@@ -1587,7 +1607,7 @@ export async function createWorktree(directory: string, input: CreateGitWorktree
if (mode === 'existing') { if (mode === 'existing') {
const requestedExistingBranch = String(input?.existingBranch || '').trim(); const requestedExistingBranch = String(input?.existingBranch || '').trim();
const parsedExistingRemote = parseRemoteBranchRef(requestedExistingBranch); const parsedExistingRemote = await resolveRemoteBranchRef(context.primaryWorktree, requestedExistingBranch);
if (parsedExistingRemote && ensureRemoteName && ensureRemoteUrl && parsedExistingRemote.remote === ensureRemoteName) { if (parsedExistingRemote && ensureRemoteName && ensureRemoteUrl && parsedExistingRemote.remote === ensureRemoteName) {
await ensureRemoteWithUrl(context.primaryWorktree, ensureRemoteName, ensureRemoteUrl); await ensureRemoteWithUrl(context.primaryWorktree, ensureRemoteName, ensureRemoteUrl);
await fetchRemoteBranchRef(context.primaryWorktree, parsedExistingRemote.remote, parsedExistingRemote.branch); await fetchRemoteBranchRef(context.primaryWorktree, parsedExistingRemote.remote, parsedExistingRemote.branch);
@@ -1633,7 +1653,7 @@ export async function createWorktree(directory: string, input: CreateGitWorktree
worktreeAddArgs.push(startRef); worktreeAddArgs.push(startRef);
} }
const parsedRemoteStartRef = parseRemoteBranchRef(startRef); const parsedRemoteStartRef = await resolveRemoteBranchRef(context.primaryWorktree, startRef);
if (parsedRemoteStartRef) { if (parsedRemoteStartRef) {
inferredUpstream = { inferredUpstream = {
remote: parsedRemoteStartRef.remote, remote: parsedRemoteStartRef.remote,
@@ -1647,7 +1667,7 @@ export async function createWorktree(directory: string, input: CreateGitWorktree
} }
if (mode === 'new') { if (mode === 'new') {
const parsedRemoteStartRef = parseRemoteBranchRef(startRef); const parsedRemoteStartRef = await resolveRemoteBranchRef(context.primaryWorktree, startRef);
if (parsedRemoteStartRef) { if (parsedRemoteStartRef) {
await fetchRemoteBranchRef(context.primaryWorktree, parsedRemoteStartRef.remote, parsedRemoteStartRef.branch); 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 normalizeUpstreamTarget = (remote, branch) => {
const remoteName = String(remote || '').trim(); const remoteName = String(remote || '').trim();
const branchName = String(branch || '').trim(); const branchName = String(branch || '').trim();
@@ -1925,7 +1945,7 @@ export async function validateWorktreeCreate(directory, input = {}) {
if (mode === 'existing') { if (mode === 'existing') {
try { try {
const requestedExistingBranch = String(input?.existingBranch || '').trim(); const requestedExistingBranch = String(input?.existingBranch || '').trim();
const parsedExistingRemote = parseRemoteBranchRef(requestedExistingBranch); const parsedExistingRemote = await resolveRemoteBranchRef(context.primaryWorktree, requestedExistingBranch);
if (parsedExistingRemote && ensureRemoteName && ensureRemoteUrl && ensureRemoteName === parsedExistingRemote.remote) { if (parsedExistingRemote && ensureRemoteName && ensureRemoteUrl && ensureRemoteName === parsedExistingRemote.remote) {
const lsRemote = await runGitCommand( const lsRemote = await runGitCommand(
context.primaryWorktree, context.primaryWorktree,
@@ -1970,7 +1990,7 @@ export async function validateWorktreeCreate(directory, input = {}) {
localBranch = preferredBranchName; localBranch = preferredBranchName;
} }
const parsedRemoteRef = parseRemoteBranchRef(startRef); const parsedRemoteRef = await resolveRemoteBranchRef(context.primaryWorktree, startRef);
if (startRef && startRef !== 'HEAD') { if (startRef && startRef !== 'HEAD') {
if (parsedRemoteRef && ensureRemoteName && ensureRemoteUrl && ensureRemoteName === parsedRemoteRef.remote) { if (parsedRemoteRef && ensureRemoteName && ensureRemoteUrl && ensureRemoteName === parsedRemoteRef.remote) {
const remoteCheck = await checkRemoteBranchExists( const remoteCheck = await checkRemoteBranchExists(
@@ -2107,7 +2127,7 @@ export async function createWorktree(directory, input = {}) {
if (mode === 'existing') { if (mode === 'existing') {
const requestedExistingBranch = String(input?.existingBranch || '').trim(); const requestedExistingBranch = String(input?.existingBranch || '').trim();
const parsedExistingRemote = parseRemoteBranchRef(requestedExistingBranch); const parsedExistingRemote = await resolveRemoteBranchRef(context.primaryWorktree, requestedExistingBranch);
if (parsedExistingRemote && ensureRemoteName && ensureRemoteUrl && parsedExistingRemote.remote === ensureRemoteName) { if (parsedExistingRemote && ensureRemoteName && ensureRemoteUrl && parsedExistingRemote.remote === ensureRemoteName) {
await ensureRemoteWithUrl(context.primaryWorktree, ensureRemoteName, ensureRemoteUrl); await ensureRemoteWithUrl(context.primaryWorktree, ensureRemoteName, ensureRemoteUrl);
await fetchRemoteBranchRef(context.primaryWorktree, parsedExistingRemote.remote, parsedExistingRemote.branch); await fetchRemoteBranchRef(context.primaryWorktree, parsedExistingRemote.remote, parsedExistingRemote.branch);
@@ -2153,7 +2173,7 @@ export async function createWorktree(directory, input = {}) {
worktreeAddArgs.push(startRef); worktreeAddArgs.push(startRef);
} }
const parsedRemoteStartRef = parseRemoteBranchRef(startRef); const parsedRemoteStartRef = await resolveRemoteBranchRef(context.primaryWorktree, startRef);
if (parsedRemoteStartRef) { if (parsedRemoteStartRef) {
inferredUpstream = { inferredUpstream = {
remote: parsedRemoteStartRef.remote, remote: parsedRemoteStartRef.remote,
@@ -2167,7 +2187,7 @@ export async function createWorktree(directory, input = {}) {
} }
if (mode === 'new') { if (mode === 'new') {
const parsedRemoteStartRef = parseRemoteBranchRef(startRef); const parsedRemoteStartRef = await resolveRemoteBranchRef(context.primaryWorktree, startRef);
if (parsedRemoteStartRef) { if (parsedRemoteStartRef) {
await fetchRemoteBranchRef(context.primaryWorktree, parsedRemoteStartRef.remote, parsedRemoteStartRef.branch); await fetchRemoteBranchRef(context.primaryWorktree, parsedRemoteStartRef.remote, parsedRemoteStartRef.branch);
} }