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:
committed by
GitHub
parent
bdad912ea5
commit
604ac682c2
@@ -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';
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user