fix: allow PR worktrees from existing local branches

Use existing local PR branches instead of blocking worktree creation
Only block PR branches already checked out in another worktree
Clarify local branch validation messages across locales
This commit is contained in:
Bohdan Triapitsyn
2026-05-08 16:59:31 +03:00
parent a591f63ed2
commit 1a0f3bea8e
9 changed files with 37 additions and 9 deletions
@@ -195,13 +195,15 @@ export function GitHubIntegrationDialog({
worktreeName: branchName, worktreeName: branchName,
}); });
const isBlocked = result.errors.some( const blockingError = result.errors.find((entry) => entry.code === 'branch_in_use');
(entry) => entry.code === 'branch_in_use' || entry.code === 'branch_exists'
);
setValidations(prev => new Map(prev).set(branchName, { setValidations(prev => new Map(prev).set(branchName, {
isValid: !isBlocked, isValid: !blockingError,
error: isBlocked ? t('session.githubIntegration.validation.branchAlreadyCheckedOut') : null, error: blockingError
? t(blockingError.code === 'branch_exists'
? 'session.githubIntegration.validation.branchAlreadyExists'
: 'session.githubIntegration.validation.branchAlreadyCheckedOut')
: null,
})); }));
} catch { } catch {
setValidations(prev => new Map(prev).set(branchName, { setValidations(prev => new Map(prev).set(branchName, {
@@ -129,12 +129,24 @@ const sanitizeRemoteName = (value: string): string => {
return normalized || 'pr-head'; return normalized || 'pr-head';
}; };
const resolvePrWorktreeConfig = (pr: GitHubPullRequestSummary, remoteBranches: string[]) => { const resolvePrWorktreeConfig = (pr: GitHubPullRequestSummary, localBranches: string[], remoteBranches: string[]) => {
const headBranch = normalizeBranchName(pr.head || ''); const headBranch = normalizeBranchName(pr.head || '');
if (!headBranch) { if (!headBranch) {
throw new Error('PR head branch is missing'); throw new Error('PR head branch is missing');
} }
if (localBranches.includes(headBranch)) {
return {
existingBranch: headBranch,
setUpstream: undefined,
upstreamRemote: undefined,
upstreamBranch: undefined,
ensureRemoteName: undefined,
ensureRemoteUrl: undefined,
sourceLabel: headBranch,
};
}
const availableRemoteBranch = remoteBranches.find((remoteBranch) => { const availableRemoteBranch = remoteBranches.find((remoteBranch) => {
const slashIndex = remoteBranch.indexOf('/'); const slashIndex = remoteBranch.indexOf('/');
if (slashIndex <= 0 || slashIndex >= remoteBranch.length - 1) { if (slashIndex <= 0 || slashIndex >= remoteBranch.length - 1) {
@@ -734,11 +746,15 @@ export function NewWorktreeDialog({
// Only run server validation if we have values // Only run server validation if we have values
if (normalizedBranch && normalizedWorktree) { if (normalizedBranch && normalizedWorktree) {
const linkedPr = mode === 'new-branch' ? newBranchState.linkedPr : null;
const prConfig = linkedPr ? resolvePrWorktreeConfig(linkedPr, localBranches, remoteBranches) : null;
const result = await validateWorktreeCreate(projectRef, { const result = await validateWorktreeCreate(projectRef, {
mode: mode === 'existing-branch' ? 'existing' : 'new', mode: mode === 'existing-branch' || prConfig ? 'existing' : 'new',
branchName: normalizedBranch, branchName: normalizedBranch,
worktreeName: normalizedWorktree, worktreeName: normalizedWorktree,
existingBranch: mode === 'existing-branch' ? normalizedBranch : undefined, existingBranch: prConfig?.existingBranch ?? (mode === 'existing-branch' ? normalizedBranch : undefined),
...(prConfig?.ensureRemoteName ? { ensureRemoteName: prConfig.ensureRemoteName } : {}),
...(prConfig?.ensureRemoteUrl ? { ensureRemoteUrl: prConfig.ensureRemoteUrl } : {}),
}); });
if (abortController.signal.aborted) return; if (abortController.signal.aborted) return;
@@ -777,8 +793,11 @@ export function NewWorktreeDialog({
projectRef, projectRef,
mode, mode,
newBranchState.branchName, newBranchState.branchName,
newBranchState.linkedPr,
existingBranchState.selectedBranch, existingBranchState.selectedBranch,
currentState.worktreeName, currentState.worktreeName,
localBranches,
remoteBranches,
validation.touched, validation.touched,
validationAbortController, validationAbortController,
isCreating, isCreating,
@@ -846,7 +865,7 @@ export function NewWorktreeDialog({
let sourceLabel = ''; let sourceLabel = '';
const args = (() => { const args = (() => {
if (linkedPr) { if (linkedPr) {
const prConfig = resolvePrWorktreeConfig(linkedPr, remoteBranches); const prConfig = resolvePrWorktreeConfig(linkedPr, localBranches, remoteBranches);
sourceLabel = prConfig.sourceLabel; sourceLabel = prConfig.sourceLabel;
return { return {
preferredName: normalizedBranch || normalizedWorktree, preferredName: normalizedBranch || normalizedWorktree,
+1
View File
@@ -1323,6 +1323,7 @@ export const dict = {
'session.githubIntegration.error.notConnected': 'GitHub not connected', 'session.githubIntegration.error.notConnected': 'GitHub not connected',
'session.githubIntegration.error.loadDataFailed': 'Failed to load data', 'session.githubIntegration.error.loadDataFailed': 'Failed to load data',
'session.githubIntegration.validation.branchAlreadyCheckedOut': 'Branch is already checked out in a worktree', 'session.githubIntegration.validation.branchAlreadyCheckedOut': 'Branch is already checked out in a worktree',
'session.githubIntegration.validation.branchAlreadyExists': 'Branch already exists locally',
'session.githubIntegration.validation.failed': 'Validation failed', 'session.githubIntegration.validation.failed': 'Validation failed',
'chat.fileAttachment.toast.attachFailed': 'Failed to attach file', 'chat.fileAttachment.toast.attachFailed': 'Failed to attach file',
'chat.fileAttachment.toast.someFilesSkipped': 'Some files were skipped:\n{summary}', 'chat.fileAttachment.toast.someFilesSkipped': 'Some files were skipped:\n{summary}',
+1
View File
@@ -1289,6 +1289,7 @@ export const dict: Record<I18nKey, string> = {
"session.githubIntegration.error.notConnected": "GitHub no está conectado", "session.githubIntegration.error.notConnected": "GitHub no está conectado",
"session.githubIntegration.error.loadDataFailed": "No se pudieron cargar los datos", "session.githubIntegration.error.loadDataFailed": "No se pudieron cargar los datos",
"session.githubIntegration.validation.branchAlreadyCheckedOut": "La rama ya está en uso en un worktree", "session.githubIntegration.validation.branchAlreadyCheckedOut": "La rama ya está en uso en un worktree",
"session.githubIntegration.validation.branchAlreadyExists": "La rama ya existe localmente",
"session.githubIntegration.validation.failed": "No se pudo validar", "session.githubIntegration.validation.failed": "No se pudo validar",
"chat.fileAttachment.toast.attachFailed": "No se pudo adjuntar el archivo", "chat.fileAttachment.toast.attachFailed": "No se pudo adjuntar el archivo",
"chat.fileAttachment.toast.someFilesSkipped": "Algunos archivos se omitieron:\n{summary}", "chat.fileAttachment.toast.someFilesSkipped": "Algunos archivos se omitieron:\n{summary}",
+1
View File
@@ -1325,6 +1325,7 @@ export const dict: Record<I18nKey, string> = {
'session.githubIntegration.error.notConnected': 'GitHub에 연결되어 있지 않습니다', 'session.githubIntegration.error.notConnected': 'GitHub에 연결되어 있지 않습니다',
'session.githubIntegration.error.loadDataFailed': '데이터를 불러오지 못했습니다', 'session.githubIntegration.error.loadDataFailed': '데이터를 불러오지 못했습니다',
'session.githubIntegration.validation.branchAlreadyCheckedOut': '브랜치가 이미 워크트리에 체크아웃되어 있습니다', 'session.githubIntegration.validation.branchAlreadyCheckedOut': '브랜치가 이미 워크트리에 체크아웃되어 있습니다',
'session.githubIntegration.validation.branchAlreadyExists': '브랜치가 이미 로컬에 있습니다',
'session.githubIntegration.validation.failed': '유효성 검사에 실패했습니다', 'session.githubIntegration.validation.failed': '유효성 검사에 실패했습니다',
'chat.fileAttachment.toast.attachFailed': '첨부 파일 실패', 'chat.fileAttachment.toast.attachFailed': '첨부 파일 실패',
'chat.fileAttachment.toast.someFilesSkipped': '일부 파일을 건너뛰었습니다:\n{summary}', 'chat.fileAttachment.toast.someFilesSkipped': '일부 파일을 건너뛰었습니다:\n{summary}',
+1
View File
@@ -390,6 +390,7 @@ export const dict: Record<I18nKey, string> = {
'session.githubIntegration.error.notConnected': 'GitHub nie jest połączony', 'session.githubIntegration.error.notConnected': 'GitHub nie jest połączony',
'session.githubIntegration.error.loadDataFailed': 'Nie udało się załadować danych', 'session.githubIntegration.error.loadDataFailed': 'Nie udało się załadować danych',
'session.githubIntegration.validation.branchAlreadyCheckedOut': 'Gałąź jest już wyewidencjonowana w drzewie pracy', 'session.githubIntegration.validation.branchAlreadyCheckedOut': 'Gałąź jest już wyewidencjonowana w drzewie pracy',
'session.githubIntegration.validation.branchAlreadyExists': 'Gałąź już istnieje lokalnie',
'session.githubIntegration.validation.failed': 'Walidacja nie powiodła się', 'session.githubIntegration.validation.failed': 'Walidacja nie powiodła się',
'chat.fileAttachment.toast.attachFailed': 'Nie udało się dołączyć pliku', 'chat.fileAttachment.toast.attachFailed': 'Nie udało się dołączyć pliku',
'chat.fileAttachment.toast.someFilesSkipped': 'Niektóre pliki zostały pominięte:\n{summary}', 'chat.fileAttachment.toast.someFilesSkipped': 'Niektóre pliki zostały pominięte:\n{summary}',
@@ -1289,6 +1289,7 @@ export const dict: Record<I18nKey, string> = {
"session.githubIntegration.error.notConnected": "GitHub não está conectado", "session.githubIntegration.error.notConnected": "GitHub não está conectado",
"session.githubIntegration.error.loadDataFailed": "Não foi possível carregar os dados", "session.githubIntegration.error.loadDataFailed": "Não foi possível carregar os dados",
"session.githubIntegration.validation.branchAlreadyCheckedOut": "A branch já está em uso em um worktree", "session.githubIntegration.validation.branchAlreadyCheckedOut": "A branch já está em uso em um worktree",
"session.githubIntegration.validation.branchAlreadyExists": "A branch já existe localmente",
"session.githubIntegration.validation.failed": "Não foi possível validar", "session.githubIntegration.validation.failed": "Não foi possível validar",
"chat.fileAttachment.toast.attachFailed": "Não foi possível anexar o arquivo", "chat.fileAttachment.toast.attachFailed": "Não foi possível anexar o arquivo",
"chat.fileAttachment.toast.someFilesSkipped": "Alguns arquivos foram omitidos:\n{summary}", "chat.fileAttachment.toast.someFilesSkipped": "Alguns arquivos foram omitidos:\n{summary}",
+1
View File
@@ -1289,6 +1289,7 @@ export const dict: Record<I18nKey, string> = {
"session.githubIntegration.error.notConnected": "GitHub не підключено", "session.githubIntegration.error.notConnected": "GitHub не підключено",
"session.githubIntegration.error.loadDataFailed": "Не вдалося завантажити дані", "session.githubIntegration.error.loadDataFailed": "Не вдалося завантажити дані",
"session.githubIntegration.validation.branchAlreadyCheckedOut": "Гілку вже відкрито в worktree", "session.githubIntegration.validation.branchAlreadyCheckedOut": "Гілку вже відкрито в worktree",
"session.githubIntegration.validation.branchAlreadyExists": "Гілка вже існує локально",
"session.githubIntegration.validation.failed": "Помилка перевірки", "session.githubIntegration.validation.failed": "Помилка перевірки",
"chat.fileAttachment.toast.attachFailed": "Не вдалося прикріпити файл", "chat.fileAttachment.toast.attachFailed": "Не вдалося прикріпити файл",
"chat.fileAttachment.toast.someFilesSkipped": "Деякі файли були пропущені:\n{summary}", "chat.fileAttachment.toast.someFilesSkipped": "Деякі файли були пропущені:\n{summary}",
@@ -1289,6 +1289,7 @@ export const dict: Record<I18nKey, string> = {
'session.githubIntegration.error.notConnected': 'GitHub 未连接', 'session.githubIntegration.error.notConnected': 'GitHub 未连接',
'session.githubIntegration.error.loadDataFailed': '加载数据失败', 'session.githubIntegration.error.loadDataFailed': '加载数据失败',
'session.githubIntegration.validation.branchAlreadyCheckedOut': '该分支已在某个 worktree 中检出', 'session.githubIntegration.validation.branchAlreadyCheckedOut': '该分支已在某个 worktree 中检出',
'session.githubIntegration.validation.branchAlreadyExists': '该分支已在本地存在',
'session.githubIntegration.validation.failed': '校验失败', 'session.githubIntegration.validation.failed': '校验失败',
'chat.fileAttachment.toast.attachFailed': '附加文件失败', 'chat.fileAttachment.toast.attachFailed': '附加文件失败',
'chat.fileAttachment.toast.someFilesSkipped': '以下文件被跳过:\n{summary}', 'chat.fileAttachment.toast.someFilesSkipped': '以下文件被跳过:\n{summary}',