diff --git a/packages/ui/src/components/session/NewWorktreeDialog.tsx b/packages/ui/src/components/session/NewWorktreeDialog.tsx index c733a0ae..eb1a931c 100644 --- a/packages/ui/src/components/session/NewWorktreeDialog.tsx +++ b/packages/ui/src/components/session/NewWorktreeDialog.tsx @@ -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'; diff --git a/packages/vscode/src/gitService.ts b/packages/vscode/src/gitService.ts index c509b022..4b7b8e8b 100644 --- a/packages/vscode/src/gitService.ts +++ b/packages/vscode/src/gitService.ts @@ -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); } diff --git a/packages/web/server/lib/git/service.js b/packages/web/server/lib/git/service.js index 2b26ca84..7631589d 100644 --- a/packages/web/server/lib/git/service.js +++ b/packages/web/server/lib/git/service.js @@ -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); }