Support explicit remote branch reuse across workspaces (#34295)

* Support explicit remote branch reuse across workspaces

- Preserve selected branch identity, tracking, and busy-branch handling
- Add explicit branch-off intent with v2 runtime capability gating

* Style existing workspace action in branch reuse UI

* Preserve explicit branch reuse with versioned host support

- Gate strict reuse on a new request capability while retaining legacy behavior
- Warn when a reused remote branch cannot be configured as the push upstream

* Clarify selected branch checkout options

* Offer existing or new branch choices for worktrees

* Preserve new-branch intent when reusing a branch name

* Adapt branch composer tests and catalog after rebase

* Refresh mobile RPC recordings for branch intent capabilities

* Remove retired Russian localization entries

* Remove retired German and Turkish localization entries

* Add branch selection translations for existing workspaces

* Localize mobile worktree branch selection
This commit is contained in:
Jinjing
2026-10-28 20:48:16 -07:00
committed by GitHub
parent 05922ccde6
commit bc55fe3cb0
88 changed files with 2313 additions and 530 deletions
@@ -4,19 +4,6 @@
"family": "worktree.runtime-capabilities",
"namedDeltas": [],
"values": {
"041e5e32563c": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"agentLaunch": false,
"hostPlatform": {
"$rpc": "null"
},
"tasksSupported": false,
"worktreeCreateIdempotency": false
}
},
"0474a1603b70": {
"name": "status.get#1",
"ordinal": 1,
@@ -83,18 +70,6 @@
}
}
},
"120979f40a68": {
"capabilities": {
"agentLaunch": false,
"hostPlatform": {
"$rpc": "null"
},
"tasksSupported": true,
"worktreeCreateIdempotency": {
"dedupeTtlMs": 45000
}
}
},
"16a71e555cb2": {
"name": "status.get#1",
"ordinal": 1,
@@ -200,21 +175,6 @@
}
}
},
"3a7704ccec26": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"agentLaunch": false,
"hostPlatform": {
"$rpc": "null"
},
"tasksSupported": true,
"worktreeCreateIdempotency": {
"dedupeTtlMs": 45000
}
}
},
"41e8e39cc47e": {
"name": "status.get#1",
"ordinal": 1,
@@ -252,6 +212,20 @@
}
}
},
"456a1f9383e3": {
"capabilities": {
"agentLaunch": false,
"branchIntentV2": false,
"branchIntentV2Request": false,
"hostPlatform": {
"$rpc": "null"
},
"tasksSupported": true,
"worktreeCreateIdempotency": {
"dedupeTtlMs": 45000
}
}
},
"59a45eb98b67": {
"capabilities": {
"$rpc": "null"
@@ -360,6 +334,38 @@
}
}
},
"a0a149765d23": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"agentLaunch": false,
"branchIntentV2": false,
"branchIntentV2Request": false,
"hostPlatform": {
"$rpc": "null"
},
"tasksSupported": false,
"worktreeCreateIdempotency": false
}
},
"b62568733b13": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"agentLaunch": false,
"branchIntentV2": false,
"branchIntentV2Request": false,
"hostPlatform": {
"$rpc": "null"
},
"tasksSupported": true,
"worktreeCreateIdempotency": {
"dedupeTtlMs": 45000
}
}
},
"b799ba7b0c33": {
"name": "status.get#1",
"ordinal": 1,
@@ -397,6 +403,18 @@
"ordinal": 2,
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"status.get\"}"
},
"dc4e306d6dfc": {
"capabilities": {
"agentLaunch": false,
"branchIntentV2": false,
"branchIntentV2Request": false,
"hostPlatform": {
"$rpc": "null"
},
"tasksSupported": false,
"worktreeCreateIdempotency": false
}
},
"e90520ab92af": {
"name": "status.get#1",
"ordinal": 1,
@@ -439,16 +457,6 @@
"value": {
"$rpc": "null"
}
},
"f793cb0dfb40": {
"capabilities": {
"agentLaunch": false,
"hostPlatform": {
"$rpc": "null"
},
"tasksSupported": false,
"worktreeCreateIdempotency": false
}
}
},
"recording": {
@@ -460,9 +468,9 @@
"sender": ["216adda2100c"],
"payloads": ["d08f74d65ee6"],
"settlements": {
"probe": "3a7704ccec26"
"probe": "b62568733b13"
},
"state": "120979f40a68",
"state": "456a1f9383e3",
"effects": []
}
},
@@ -496,9 +504,9 @@
"sender": ["7b0d9b0d2428"],
"payloads": ["d08f74d65ee6"],
"settlements": {
"probe": "041e5e32563c"
"probe": "a0a149765d23"
},
"state": "f793cb0dfb40",
"state": "dc4e306d6dfc",
"effects": []
}
},
@@ -508,9 +516,9 @@
"sender": ["0474a1603b70"],
"payloads": ["d08f74d65ee6"],
"settlements": {
"probe": "041e5e32563c"
"probe": "a0a149765d23"
},
"state": "f793cb0dfb40",
"state": "dc4e306d6dfc",
"effects": []
}
},
@@ -520,9 +528,9 @@
"sender": ["41e8e39cc47e"],
"payloads": ["d08f74d65ee6"],
"settlements": {
"probe": "041e5e32563c"
"probe": "a0a149765d23"
},
"state": "f793cb0dfb40",
"state": "dc4e306d6dfc",
"effects": []
}
},
@@ -4,18 +4,6 @@
"family": "worktree.runtime-capabilities",
"namedDeltas": [],
"values": {
"120979f40a68": {
"capabilities": {
"agentLaunch": false,
"hostPlatform": {
"$rpc": "null"
},
"tasksSupported": true,
"worktreeCreateIdempotency": {
"dedupeTtlMs": 45000
}
}
},
"216adda2100c": {
"name": "status.get#1",
"ordinal": 1,
@@ -54,12 +42,28 @@
}
}
},
"3a7704ccec26": {
"456a1f9383e3": {
"capabilities": {
"agentLaunch": false,
"branchIntentV2": false,
"branchIntentV2Request": false,
"hostPlatform": {
"$rpc": "null"
},
"tasksSupported": true,
"worktreeCreateIdempotency": {
"dedupeTtlMs": 45000
}
}
},
"b62568733b13": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"agentLaunch": false,
"branchIntentV2": false,
"branchIntentV2Request": false,
"hostPlatform": {
"$rpc": "null"
},
@@ -84,9 +88,9 @@
"sender": ["216adda2100c"],
"payloads": ["d08f74d65ee6"],
"settlements": {
"probe": "3a7704ccec26"
"probe": "b62568733b13"
},
"state": "120979f40a68",
"state": "456a1f9383e3",
"effects": []
}
}
@@ -7,6 +7,21 @@
"32354557bece": {
"capabilities": "unprobed"
},
"446c5090cdcc": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"agentLaunch": false,
"branchIntentV2": false,
"branchIntentV2Request": false,
"hostPlatform": {
"$rpc": "null"
},
"tasksSupported": true,
"worktreeCreateIdempotency": false
}
},
"452610f318ae": {
"name": "status.get#2",
"ordinal": 3,
@@ -70,26 +85,15 @@
}
}
},
"8a8da145ef52": {
"capabilities": {
"agentLaunch": false,
"hostPlatform": {
"$rpc": "null"
},
"tasksSupported": true,
"worktreeCreateIdempotency": false
}
},
"9270aeb7d9c6": {
"status": "pending",
"startedAt": 0
},
"c7a06214cf1f": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"a161e33447b4": {
"capabilities": {
"agentLaunch": false,
"branchIntentV2": false,
"branchIntentV2Request": false,
"hostPlatform": {
"$rpc": "null"
},
@@ -172,10 +176,10 @@
"sender": ["523bdccb77ba", "cc2699f90bbe"],
"payloads": ["d08f74d65ee6", "f1834177e593"],
"settlements": {
"probe": "c7a06214cf1f",
"probe": "446c5090cdcc",
"migrate": "eb79a9b3682a"
},
"state": "8a8da145ef52",
"state": "a161e33447b4",
"effects": []
}
}
@@ -4,12 +4,14 @@
"family": "worktree.runtime-capabilities",
"namedDeltas": [],
"values": {
"ab209a152529": {
"d07813454621": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"agentLaunch": false,
"branchIntentV2": false,
"branchIntentV2Request": false,
"hostPlatform": {
"$rpc": "null"
},
@@ -58,9 +60,11 @@
}
}
},
"f079a530dc44": {
"f8024fca8c7d": {
"capabilities": {
"agentLaunch": false,
"branchIntentV2": false,
"branchIntentV2Request": false,
"hostPlatform": {
"$rpc": "null"
},
@@ -80,9 +84,9 @@
"sender": ["eaed1eb137b5"],
"payloads": ["d08f74d65ee6"],
"settlements": {
"probe": "ab209a152529"
"probe": "d07813454621"
},
"state": "f079a530dc44",
"state": "f8024fca8c7d",
"effects": []
}
}
+7 -1
View File
@@ -85,7 +85,12 @@ function NewWorktreeModalContent(props: NewWorktreeModalProps) {
const [error, setError] = useState('')
const runtime = useNewWorkspaceRuntimeContext(client, visible, hostId, repoServer)
const repoRuntime = useNewWorktreeRuntimeCapabilities(client, visible)
const { tasksSupported, getWorktreeCreateCutoverSupport, getAgentLaunchSupport } = repoRuntime
const {
tasksSupported,
getWorktreeCreateCutoverSupport,
getAgentLaunchSupport,
getBranchIntentSupport
} = repoRuntime
// The desktop's own run target is named for the desktop, whichever host the picked repo is on.
const desktopPlatform = useHostDescriptor(hostId)?.platform ?? null
const hostPlatform = repoServer ? desktopPlatform : repoRuntime.hostPlatform
@@ -151,6 +156,7 @@ function NewWorktreeModalContent(props: NewWorktreeModalProps) {
trustedOrcaHooks: runtime.trustedOrcaHooks,
setTrustedOrcaHooks: runtime.setTrustedOrcaHooks,
getWorktreeCreateCutoverSupport,
getBranchIntentSupport,
getAgentLaunchSupport,
transitionDrawer: navigation.transitionDrawer,
setError,
@@ -1,9 +1,11 @@
import { Platform, Switch, Text, TextInput, View } from 'react-native'
import { useRef } from 'react'
import { Platform, Text, TextInput, View } from 'react-native'
import type { MobileComposerSource } from '../tasks/use-mobile-composer-source'
import { colors, radii, spacing } from '../theme/mobile-theme'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import { themedStyleSheet } from '../theme/themed-styles'
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
import { useMobileTranslation } from '../i18n/use-mobile-translation'
import { MobileSegmentedControl } from './MobileSegmentedControl'
import { TEXT_INPUT_FONT_SIZE } from '../platform/text-input-font-size'
type Props = {
@@ -15,6 +17,7 @@ type Props = {
export function SmartWorkspaceAdvancedFields({ composer, selectedRepoIsGit }: Props) {
useMobileColorScheme()
const t = useMobileTranslation()
const branchNameInputRef = useRef<TextInput>(null)
const selection = composer.smartNameSelection
const showBranchOverride = selectedRepoIsGit && (!selection || selection.kind === 'branch')
return (
@@ -39,7 +42,7 @@ export function SmartWorkspaceAdvancedFields({ composer, selectedRepoIsGit }: Pr
</View>
) : null}
{showBranchOverride ? (
{showBranchOverride && !composer.reuseEligibleBranch ? (
<View style={styles.field}>
<Text style={styles.label}>
{t('components.smartWorkspaceAdvancedFields.branchName', 'Branch name')}
@@ -61,24 +64,52 @@ export function SmartWorkspaceAdvancedFields({ composer, selectedRepoIsGit }: Pr
{composer.reuseEligibleBranch ? (
<View style={styles.field}>
<View style={styles.reuseRow}>
<Text style={styles.reuseLabel} numberOfLines={1}>
{t(
'components.smartWorkspaceAdvancedFields.reuseBranch',
'Reuse branch “{{branch}}”',
{
branch: composer.reuseEligibleBranch
}
)}
</Text>
<Switch
value={composer.reuseSelectedBranch}
onValueChange={composer.setReuseSelectedBranch}
trackColor={{ false: colors.borderSubtle, true: colors.textSecondary }}
thumbColor={colors.textPrimary}
style={styles.reuseSwitch}
/>
</View>
<Text style={styles.label}>
{t('workspace.branchChoice.title', 'Branch for this worktree')}
</Text>
<MobileSegmentedControl
segments={[
{ value: 'existing', label: t('workspace.branchChoice.existing', 'Existing branch') },
{ value: 'new', label: t('workspace.branchChoice.new', 'New branch') }
]}
selected={composer.reuseSelectedBranch ? 'existing' : 'new'}
onSelect={(value) => {
composer.setReuseSelectedBranch(value === 'existing')
if (value === 'new') {
branchNameInputRef.current?.focus()
}
}}
fill
/>
{composer.reuseSelectedBranch ? (
<View style={styles.branchDetail}>
<Text style={styles.branchName}>{composer.reuseEligibleBranch}</Text>
<Text style={styles.description}>
{t('workspace.branchChoice.existingHint', 'New commits go on this branch.')}
</Text>
</View>
) : (
<View style={styles.branchDetail}>
<Text style={styles.description}>
{t('workspace.branchChoice.newHint', 'Create a new local branch')}
</Text>
<Text style={styles.label}>
{t('workspace.branchChoice.newName', 'New branch name')}
</Text>
<TextInput
ref={branchNameInputRef}
accessibilityLabel={t('workspace.branchChoice.newName', 'New branch name')}
autoFocus
style={styles.input}
value={composer.branchNameOverride ?? ''}
onChangeText={composer.handleBranchNameOverrideChange}
placeholder={t('workspace.branchChoice.automaticName', 'Automatic name')}
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
/>
</View>
)}
</View>
) : null}
</>
@@ -105,18 +136,17 @@ const styles = themedStyleSheet(() => ({
borderWidth: 1,
borderColor: colors.borderSubtle
},
reuseRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: spacing.sm
branchDetail: {
marginTop: spacing.sm,
gap: spacing.xs
},
reuseLabel: {
flex: 1,
fontSize: 13,
branchName: {
fontFamily: typography.monoFamily,
fontSize: typography.metaSize,
color: colors.textPrimary
},
description: {
fontSize: typography.metaSize,
color: colors.textSecondary
},
reuseSwitch: {
transform: [{ scaleX: 0.7 }, { scaleY: 0.7 }]
}
}))
@@ -59,6 +59,7 @@ export function useNewWorkspaceCreateSubmit(args: {
setTrustedOrcaHooks: (trust: PersistedTrustedOrcaHooks) => void
getWorktreeCreateCutoverSupport: () => Promise<WorktreeCreateIdempotencySupport | false>
getAgentLaunchSupport: () => Promise<AgentLaunchSupport | false>
getBranchIntentSupport: (intent: 'open-existing-v2' | 'create-new') => Promise<boolean>
transitionDrawer: (view: Exclude<NewWorktreeDrawerView, 'transition'>) => void
setError: Dispatch<SetStateAction<string>>
onCreated: (worktreeId: string, name: string, warning?: string) => void
@@ -172,7 +173,10 @@ export function useNewWorkspaceCreateSubmit(args: {
note: trimmedNote,
nameIsAutoManaged: args.composer.isNameAutoManaged,
worktreeCreateIdempotency: args.getWorktreeCreateCutoverSupport(),
agentLaunchSupported: args.getAgentLaunchSupport()
agentLaunchSupported: args.getAgentLaunchSupport(),
branchIntentSupported: args.getBranchIntentSupport(
selection.kind === 'branch' && selection.reuse ? 'open-existing-v2' : 'create-new'
)
})
: await createBlankWorkspace({
client,
+11 -1
View File
@@ -533,7 +533,6 @@
"branchName": "Branch-Name",
"derivedFromName": "Vom Namen abgeleitet",
"name": "Name",
"reuseBranch": "Branch „{{branch}}“ wiederverwenden",
"workspaceName": "Name des Arbeitsbereichs"
},
"smartWorkspaceSourceDrawer": {
@@ -1492,5 +1491,16 @@
"inReview": "Im Review",
"pinned": "Angeheftet"
}
},
"workspace": {
"branchChoice": {
"title": "Branch für diesen Worktree",
"existing": "Bestehender Branch",
"existingHint": "Neue Commits werden diesem Branch hinzugefügt.",
"new": "Neuer Branch",
"newHint": "Einen neuen lokalen Branch erstellen",
"newName": "Name des neuen Branches",
"automaticName": "Automatischer Name"
}
}
}
+13 -2
View File
@@ -1116,8 +1116,7 @@
"name": "Name",
"workspaceName": "Workspace name",
"branchName": "Branch name",
"derivedFromName": "Derived from name",
"reuseBranch": "Reuse branch “{{branch}}”"
"derivedFromName": "Derived from name"
},
"smartWorkspaceSourceDrawer": {
"title": "Name or 'Create From'",
@@ -1492,5 +1491,17 @@
"noHumanComments": "No human comments.",
"noComments": "No comments yet."
}
},
"workspace": {
"branchChoice": {
"title": "Branch for this worktree",
"existing": "Existing branch",
"existingHint": "New commits go on this branch.",
"new": "New branch",
"newHint": "Create a new local branch",
"newName": "New branch name",
"automaticName": "Automatic name",
"hostUpdateRequired": "Update Orca on the workspace host to reuse a branch or explicitly create a separate branch."
}
}
}
+11 -1
View File
@@ -544,7 +544,6 @@
"branchName": "Nombre de rama",
"derivedFromName": "Derivado del nombre",
"name": "Nombre",
"reuseBranch": "Reutilizar la rama “{{branch}}”",
"workspaceName": "Nombre del espacio de trabajo"
},
"smartWorkspaceSourceDrawer": {
@@ -1527,5 +1526,16 @@
"inReview": "En revisión",
"pinned": "Fijados"
}
},
"workspace": {
"branchChoice": {
"title": "Rama para este árbol de trabajo",
"existing": "Rama existente",
"existingHint": "Los nuevos commits se añaden a esta rama.",
"new": "Nueva rama",
"newHint": "Crear una nueva rama local",
"newName": "Nombre de la nueva rama",
"automaticName": "Nombre automático"
}
}
}
+11 -1
View File
@@ -544,7 +544,6 @@
"branchName": "Nom de branche",
"derivedFromName": "Dérivé du nom",
"name": "Nom",
"reuseBranch": "Réutiliser la branche « {{branch}} »",
"workspaceName": "Nom de l'espace de travail"
},
"smartWorkspaceSourceDrawer": {
@@ -1527,5 +1526,16 @@
"inReview": "En revue",
"pinned": "Épinglés"
}
},
"workspace": {
"branchChoice": {
"title": "Branche pour cet arbre de travail",
"existing": "Branche existante",
"existingHint": "Les nouveaux commits sont ajoutés à cette branche.",
"new": "Nouvelle branche",
"newHint": "Créer une nouvelle branche locale",
"newName": "Nom de la nouvelle branche",
"automaticName": "Nom automatique"
}
}
}
+11 -1
View File
@@ -533,7 +533,6 @@
"branchName": "ブランチ名",
"derivedFromName": "名前から生成",
"name": "名前",
"reuseBranch": "ブランチ「{{branch}}」を再利用",
"workspaceName": "ワークスペース名"
},
"smartWorkspaceSourceDrawer": {
@@ -1490,5 +1489,16 @@
"inReview": "レビュー中",
"pinned": "ピン留め済み"
}
},
"workspace": {
"branchChoice": {
"title": "このワークツリーのブランチ",
"existing": "既存のブランチ",
"existingHint": "新しいコミットはこのブランチに追加されます。",
"new": "新しいブランチ",
"newHint": "新しいローカルブランチを作成",
"newName": "新しいブランチ名",
"automaticName": "自動で命名"
}
}
}
+11 -1
View File
@@ -533,7 +533,6 @@
"branchName": "브랜치 이름",
"derivedFromName": "이름을 바탕으로 생성",
"name": "이름",
"reuseBranch": "“{{branch}}” 브랜치 재사용",
"workspaceName": "워크스페이스 이름"
},
"smartWorkspaceSourceDrawer": {
@@ -1490,5 +1489,16 @@
"inReview": "리뷰 중",
"pinned": "고정됨"
}
},
"workspace": {
"branchChoice": {
"title": "이 워크트리의 브랜치",
"existing": "기존 브랜치",
"existingHint": "새 커밋이 이 브랜치에 추가됩니다.",
"new": "새 브랜치",
"newHint": "새 로컬 브랜치 만들기",
"newName": "새 브랜치 이름",
"automaticName": "자동 이름"
}
}
}
+11 -1
View File
@@ -544,7 +544,6 @@
"branchName": "Nome da branch",
"derivedFromName": "Derivado do nome",
"name": "Nome",
"reuseBranch": "Reutilizar branch “{{branch}}”",
"workspaceName": "Nome do espaço de trabalho"
},
"smartWorkspaceSourceDrawer": {
@@ -1527,5 +1526,16 @@
"inReview": "Em revisão",
"pinned": "Fixados"
}
},
"workspace": {
"branchChoice": {
"title": "Branch para esta árvore de trabalho",
"existing": "Branch existente",
"existingHint": "Novos commits são adicionados a esta branch.",
"new": "Nova branch",
"newHint": "Criar uma nova branch local",
"newName": "Nome da nova branch",
"automaticName": "Nome automático"
}
}
}
+11 -1
View File
@@ -555,7 +555,6 @@
"branchName": "Название ветки",
"derivedFromName": "На основе названия",
"name": "Название",
"reuseBranch": "Повторно использовать ветку «{{branch}}»",
"workspaceName": "Название рабочего пространства"
},
"smartWorkspaceSourceDrawer": {
@@ -1562,5 +1561,16 @@
"inReview": "На проверке",
"pinned": "Закреплено"
}
},
"workspace": {
"branchChoice": {
"title": "Ветка для этого рабочего дерева",
"existing": "Существующая ветка",
"existingHint": "Новые коммиты добавляются в эту ветку.",
"new": "Новая ветка",
"newHint": "Создать новую локальную ветку",
"newName": "Имя новой ветки",
"automaticName": "Автоматическое имя"
}
}
}
+11 -1
View File
@@ -533,7 +533,6 @@
"branchName": "Branch adı",
"derivedFromName": "Addan türetilir",
"name": "Ad",
"reuseBranch": "“{{branch}}” branch'ini yeniden kullan",
"workspaceName": "Çalışma alanı adı"
},
"smartWorkspaceSourceDrawer": {
@@ -1492,5 +1491,16 @@
"inReview": "İncelemede",
"pinned": "Sabitlenenler"
}
},
"workspace": {
"branchChoice": {
"title": "Bu çalışma ağacı için dal",
"existing": "Mevcut dal",
"existingHint": "Yeni commitler bu dala eklenir.",
"new": "Yeni dal",
"newHint": "Yeni bir yerel dal oluştur",
"newName": "Yeni dal adı",
"automaticName": "Otomatik ad"
}
}
}
+11 -1
View File
@@ -533,7 +533,6 @@
"branchName": "分支名稱",
"derivedFromName": "依名稱產生",
"name": "名稱",
"reuseBranch": "重複使用分支「{{branch}}」",
"workspaceName": "工作區名稱"
},
"smartWorkspaceSourceDrawer": {
@@ -1490,5 +1489,16 @@
"inReview": "審查中",
"pinned": "已固定"
}
},
"workspace": {
"branchChoice": {
"title": "此工作樹的分支",
"existing": "現有分支",
"existingHint": "新提交會加入此分支。",
"new": "新分支",
"newHint": "建立新的本機分支",
"newName": "新分支名稱",
"automaticName": "自動命名"
}
}
}
+11 -1
View File
@@ -533,7 +533,6 @@
"branchName": "分支名称",
"derivedFromName": "根据名称生成",
"name": "名称",
"reuseBranch": "复用分支“{{branch}}”",
"workspaceName": "工作区名称"
},
"smartWorkspaceSourceDrawer": {
@@ -1490,5 +1489,16 @@
"inReview": "评审中",
"pinned": "已固定"
}
},
"workspace": {
"branchChoice": {
"title": "此工作树使用的分支",
"existing": "现有分支",
"existingHint": "新提交将添加到此分支。",
"new": "新分支",
"newHint": "创建新的本地分支",
"newName": "新分支名称",
"automaticName": "自动命名"
}
}
}
@@ -232,8 +232,8 @@ describe('resolveComposerBranchPick', () => {
lastAutoName: '',
worktreeBranches: ['refs/heads/feature']
})
expect(pick.reuseEligibleBranch).toBeNull()
expect(pick.reuseSelectedBranch).toBe(false)
expect(pick.reuseEligibleBranch).toBe('feature')
expect(pick.reuseSelectedBranch).toBe(true)
expect(pick.base.branchNameOverride).toBeUndefined()
})
})
@@ -5,6 +5,7 @@ import type { AgentLaunchResult } from '../../../src/shared/agent-launch-intent'
import { createWorkspaceFromComposerSource } from './source-workspace-create'
import type { MobileComposerCreateSelection } from './mobile-composer-source-types'
import { WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS } from './worktree-create-idempotency-policy'
import { readNewWorktreeRuntimeCapabilities } from './worktree-create-capability'
type Call = { method: string; params: Record<string, unknown> }
@@ -45,10 +46,94 @@ const baseArgs = {
workspaceName: undefined,
note: undefined,
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
branchIntentSupported: true,
agentLaunchSupported: false as const
}
describe('createWorkspaceFromComposerSource', () => {
it.each([
{ capabilities: [] },
{ capabilities: ['worktree.open-existing-branch.v1'] },
{ capabilities: ['worktree.open-existing-branch.v2'] }
])('refuses remote reuse on an older host before any write (%j)', async ({ capabilities }) => {
const methods: string[] = []
const client = {
...createFakeRpcClient(),
sendRequest: async (method: string) => {
methods.push(method)
return {
id: 'status',
ok: true as const,
result: { capabilities },
_meta: { runtimeId: 'r' }
}
}
}
const result = await createWorkspaceFromComposerSource({
...baseArgs,
client,
branchIntentSupported: readNewWorktreeRuntimeCapabilities(client).then(
(support) => support?.branchIntentV2Request === true
),
selection: {
kind: 'branch',
baseBranch: 'origin/topic',
refName: 'origin/topic',
localBranchName: 'topic',
reuse: true
}
})
expect(result).toEqual({
error: expect.stringContaining('Update Orca on the workspace host')
})
expect(methods).toEqual(['status.get'])
})
it('sends explicit remote reuse and a separate edited workspace label', async () => {
const calls: Call[] = []
const client = fakeClient(() => ({ worktree: { id: 'remote-feature' } }), calls)
await createWorkspaceFromComposerSource({
...baseArgs,
client,
workspaceName: 'Review workspace',
nameIsAutoManaged: false,
selection: {
kind: 'branch',
baseBranch: 'refs/remotes/origin/feature/test',
refName: 'refs/remotes/origin/feature/test',
localBranchName: 'feature/test',
reuse: true
}
})
expect(calls).toHaveLength(1)
expect(calls[0]?.params).toMatchObject({
branchIntent: 'open-existing-v2',
branchNameOverride: 'feature/test',
baseBranch: 'refs/remotes/origin/feature/test',
displayName: 'Review workspace',
displayNameKind: 'user'
})
})
it('retains an upstream warning returned by the workspace host', async () => {
const calls: Call[] = []
const client = fakeClient(
() => ({ worktree: { id: 'remote-feature' }, warning: 'Check the push destination.' }),
calls
)
const result = await createWorkspaceFromComposerSource({
...baseArgs,
client,
selection: {
kind: 'branch',
baseBranch: 'origin/topic',
refName: 'origin/topic',
localBranchName: 'topic',
reuse: true
}
})
expect(result).toMatchObject({ warning: 'Check the push destination.' })
})
it('creates a GitHub issue workspace linking the issue to its own repo', async () => {
const calls: Call[] = []
const client = fakeClient(() => ({ worktree: { id: 'wt-1' } }), calls)
+13 -3
View File
@@ -17,6 +17,7 @@ import {
import { createWorktreeWithNameRetry, type WorktreeCreateResult } from './worktree-create-retry'
import type { WorktreeCreateAgentLaunch } from './agent-launch-request'
import type { WorktreeCreateIdempotencyProbe } from './worktree-create-idempotency-policy'
import { getClientWorktreeCreateCandidate } from '../../../src/shared/new-workspace/worktree-create-retry-policy'
// Host resolution applies agent permission flags and shell quoting.
export type WorkspaceCreateAgentBundle = {
@@ -34,6 +35,7 @@ export type CreateWorkspaceFromComposerArgs = {
note: string | undefined
worktreeCreateIdempotency: WorktreeCreateIdempotencyProbe
agentLaunchSupported: WorktreeCreateAgentLaunch['supported']
branchIntentSupported?: boolean | Promise<boolean>
}
export async function createWorkspaceFromComposerSource(
@@ -173,6 +175,7 @@ async function createBranchWorkspace(args: {
note: string | undefined
worktreeCreateIdempotency: WorktreeCreateIdempotencyProbe
agentLaunchSupported: WorktreeCreateAgentLaunch['supported']
branchIntentSupported?: boolean | Promise<boolean>
}): Promise<WorktreeCreateResult> {
const {
client,
@@ -208,6 +211,7 @@ async function createBranchWorkspace(args: {
worktreeCreateIdempotency: args.worktreeCreateIdempotency,
...(agentLaunch ? { agentLaunch } : {}),
maxAttempts: 1,
branchIntentSupported: args.branchIntentSupported,
buildParams: (name) =>
applyCommon({
repo: `id:${targetRepoId}`,
@@ -217,7 +221,8 @@ async function createBranchWorkspace(args: {
: {}),
setupDecision,
baseBranch: selection.refName,
branchNameOverride: selection.localBranchName
branchNameOverride: selection.localBranchName,
branchIntent: 'open-existing-v2'
})
})
}
@@ -230,19 +235,24 @@ async function createBranchWorkspace(args: {
client,
baseName,
worktreeCreateIdempotency: args.worktreeCreateIdempotency,
branchIntentSupported: args.branchIntentSupported,
...(agentLaunch ? { agentLaunch } : {}),
buildParams: (candidate) => {
buildParams: (candidate, attempt) => {
const params: WorkspaceCreateParams = {
repo: `id:${targetRepoId}`,
name: candidate,
setupDecision,
branchIntent: 'create-new',
baseBranch: selection.baseBranch,
...(manualDisplayName
? { displayName: manualDisplayName, displayNameKind: 'user' as const }
: {})
}
if (selection.branchNameOverride) {
params.branchNameOverride = candidate
params.branchNameOverride = getClientWorktreeCreateCandidate(
selection.branchNameOverride,
attempt
)
}
return applyCommon(params)
}
@@ -50,6 +50,31 @@ function issue(number: number): GitHubWorkItem {
}
describe('mobile workspace name ownership', () => {
it('pins a reused remote branch through name edits and supports branching off explicitly', () => {
const result = renderMobileComposerSource()
act(() => result.current.setName('Review workspace'))
act(() =>
result.current.handleSmartBranchSelect('refs/remotes/origin/feature/test', 'feature/test')
)
expect(result.current.reuseEligibleBranch).toBe('feature/test')
expect(result.current.name).toBe('Review workspace')
expect(result.current.reuseSelectedBranch).toBe(true)
expect(result.current.createSelection).toMatchObject({
reuse: true,
localBranchName: 'feature/test'
})
act(() => result.current.setReuseSelectedBranch(false))
expect(result.current.createSelection).toMatchObject({
reuse: false,
branchNameOverride: undefined
})
act(() => result.current.setReuseSelectedBranch(true))
expect(result.current.name).toBe('Review workspace')
expect(result.current.createSelection).toMatchObject({
reuse: true,
localBranchName: 'feature/test'
})
})
const linearIssue: LinearIssue = {
id: 'linear-9',
identifier: 'ENG-9',
+11 -6
View File
@@ -31,7 +31,6 @@ import type {
SmartNameSelection
} from './mobile-composer-source-types'
import { useMobileComposerName } from './use-mobile-composer-name'
const EMPTY_BASE: ComposerBaseState = {}
export type UseMobileComposerSourceArgs = {
client: RpcClient | null
@@ -43,12 +42,11 @@ export type UseMobileComposerSourceArgs = {
export function useMobileComposerSource(args: UseMobileComposerSourceArgs) {
const { client, selectedRepoId, worktreeBranches = [], onError } = args
const [linkedWorkItem, setLinkedWorkItem] = useState<MobileLinkedWorkItem | null>(null)
const [base, setBase] = useState<ComposerBaseState>(EMPTY_BASE)
const [base, setBase] = useState<ComposerBaseState>({})
const [reuseEligibleBranch, setReuseEligibleBranch] = useState<string | null>(null)
const [reuseSelectedBranch, setReuseSelectedBranch] = useState(false)
const [forkPushWarning, setForkPushWarning] = useState<string | null>(null)
const [resolvingBase, setResolvingBase] = useState(false)
// New-by-name preserves the typed git branch, including slashes; only the folder is sanitized.
const [branchCreateIntent, setBranchCreateIntent] = useState(false)
const branchSelectionRef = useRef<{ refName: string; localBranchName: string } | null>(null)
@@ -60,7 +58,7 @@ export function useMobileComposerSource(args: UseMobileComposerSourceArgs) {
const clearBaseAndBranch = useCallback(() => {
branchSelectionRef.current = null
setBranchCreateIntent(false)
setBase(EMPTY_BASE)
setBase({})
setReuseEligibleBranch(null)
setReuseSelectedBranch(false)
setForkPushWarning(null)
@@ -83,7 +81,7 @@ export function useMobileComposerSource(args: UseMobileComposerSourceArgs) {
if (resolveTokenRef.current !== token) {
return
}
setBase(EMPTY_BASE)
setBase({})
onError?.(error instanceof Error ? error.message : 'Failed to resolve base branch.')
})
.finally(() => {
@@ -261,6 +259,7 @@ export function useMobileComposerSource(args: UseMobileComposerSourceArgs) {
const handleBranchNameOverrideChange = useCallback(
(value: string) => {
setReuseSelectedBranch(false)
const { forkPushWarning: nextWarning, ...nextBase } = resolveComposerManualBranchNameChange({
value,
pushTarget: base.pushTarget,
@@ -299,7 +298,13 @@ export function useMobileComposerSource(args: UseMobileComposerSourceArgs) {
handleBranchNameOverrideChange,
reuseEligibleBranch,
reuseSelectedBranch,
setReuseSelectedBranch,
setReuseSelectedBranch: (next: boolean) => {
setReuseSelectedBranch(next)
setBase((current) => ({
...current,
branchNameOverride: next ? (reuseEligibleBranch ?? undefined) : undefined
}))
},
forkPushWarning,
resolvingBase,
isNameAutoManaged,
@@ -5,6 +5,7 @@ import type { RpcClient } from '../transport/rpc-client'
import { FakeSession } from '../transport/mobile-endpoint-supervisor-test-fakes'
import { createStableLogicalRpcClient } from '../transport/stable-logical-rpc-client'
import { useNewWorktreeRuntimeCapabilities } from './worktree-create-capability'
import { createWorkspaceFromComposerSource } from './source-workspace-create'
function connection() {
let generation = 1
@@ -134,12 +135,14 @@ describe('capability probe recovery', () => {
const logical = createStableLogicalRpcClient(lan, 'lan')
await render(logical)
const launch = current?.getAgentLaunchSupport()
const branchIntent = current?.getBranchIntentSupport('open-existing-v2')
const idempotency = current?.getWorktreeCreateCutoverSupport()
await act(async () => {
await logical.migrateTo(relay.client, 'relay')
dropLan?.(new Error('socket closed'))
})
await expect(launch).resolves.toEqual({ replay: false })
await expect(branchIntent).resolves.toBe(false)
await expect(idempotency).resolves.toBeTruthy()
})
it.each(['before', 'after'])(
@@ -165,7 +168,13 @@ describe('capability probe recovery', () => {
const oldReply: Awaited<ReturnType<typeof host.sendRequest>> = {
id: 'old',
ok: true,
result: { capabilities: ['mobile.tasks.v1', 'worktree.create-idempotency.v1'] },
result: {
capabilities: [
'mobile.tasks.v1',
'worktree.create-idempotency.v1',
'worktree.open-existing-branch.v2'
]
},
_meta: { runtimeId: 'old' }
}
const newReply: Awaited<ReturnType<typeof host.sendRequest>> = {
@@ -186,11 +195,12 @@ describe('capability probe recovery', () => {
}
expect(current?.tasksSupported).toBe(false)
await expect(current?.getWorktreeCreateCutoverSupport()).resolves.toBe(false)
await expect(current?.getBranchIntentSupport('open-existing-v2')).resolves.toBe(false)
expect(host.sendRequest).toHaveBeenCalledTimes(2)
}
)
it('shares one pending status barrier between create callers and the sheet', async () => {
it('shares one pending status barrier with branch creation without a second status request', async () => {
const host = connection()
let resolve: ((value: Awaited<ReturnType<typeof host.sendRequest>>) => void) | undefined
host.sendRequest.mockImplementationOnce(
@@ -203,21 +213,77 @@ describe('capability probe recovery', () => {
const first = current?.getWorktreeCreateCutoverSupport()
const second = current?.getWorktreeCreateCutoverSupport()
const launch = current?.getAgentLaunchSupport()
const branchIntent = current?.getBranchIntentSupport('open-existing-v2')
host.sendRequest.mockResolvedValue({
id: 'created',
ok: true,
result: { worktree: { id: 'new-branch-workspace' } },
_meta: { runtimeId: 'test' }
})
const creation = createWorkspaceFromComposerSource({
client: host.client,
selection: {
kind: 'branch',
refName: 'refs/remotes/origin/topic',
baseBranch: 'origin/topic',
localBranchName: 'topic',
reuse: true
},
targetRepoId: 'repo',
workspaceName: undefined,
note: undefined,
agent: { choice: 'blank' },
setupDecision: 'skip',
worktreeCreateIdempotency: current!.getWorktreeCreateCutoverSupport(),
agentLaunchSupported: current!.getAgentLaunchSupport(),
branchIntentSupported: current!.getBranchIntentSupport('open-existing-v2')
})
expect(host.sendRequest).toHaveBeenCalledTimes(1)
await act(async () =>
resolve?.({
id: 'shared',
ok: true,
result: { capabilities: ['mobile.tasks.v1', 'worktree.create-idempotency.v1'] },
result: {
capabilities: [
'mobile.tasks.v1',
'worktree.create-idempotency.v1',
'worktree.open-existing-branch.v2-request.v1'
]
},
_meta: { runtimeId: 'test' }
})
)
await expect(first).resolves.toBeTruthy()
await expect(second).resolves.toBeTruthy()
await expect(launch).resolves.toBe(false)
await expect(branchIntent).resolves.toBe(true)
await expect(creation).resolves.toMatchObject({ worktreeId: 'new-branch-workspace' })
expect(host.sendRequest.mock.calls.map(([method]) => method)).toEqual([
'status.get',
'worktree.create'
])
expect(host.sendRequest).toHaveBeenLastCalledWith(
'worktree.create',
expect.objectContaining({ branchIntent: 'open-existing-v2' }),
expect.anything()
)
expect(current?.tasksSupported).toBe(true)
})
it('retains create-new support on an older V2 host without granting V2 reuse', async () => {
const host = connection()
host.sendRequest.mockResolvedValue({
id: 'status',
ok: true,
result: { capabilities: ['worktree.open-existing-branch.v2'] },
_meta: { runtimeId: 'test' }
})
await render(host.client)
await expect(current?.getBranchIntentSupport('create-new')).resolves.toBe(true)
await expect(current?.getBranchIntentSupport('open-existing-v2')).resolves.toBe(false)
expect(host.sendRequest).toHaveBeenCalledTimes(1)
})
it('cancels backoff while disabled and cannot rearm after unmount', async () => {
vi.useFakeTimers()
const host = connection()
@@ -56,6 +56,8 @@ describe('readNewWorktreeRuntimeCapabilities', () => {
])
)
).resolves.toEqual({
branchIntentV2: false,
branchIntentV2Request: false,
tasksSupported: true,
worktreeCreateIdempotency: { dedupeTtlMs: 20_000 },
agentLaunch: false,
@@ -70,6 +72,8 @@ describe('readNewWorktreeRuntimeCapabilities', () => {
statusClient([{ capabilities: [AGENT_LAUNCH_RUNTIME_CAPABILITY] }])
)
).resolves.toEqual({
branchIntentV2: false,
branchIntentV2Request: false,
tasksSupported: false,
worktreeCreateIdempotency: false,
// Method support does not imply ledger support; older hosts may silently strip operationId.
@@ -91,6 +95,8 @@ describe('readNewWorktreeRuntimeCapabilities', () => {
])
)
).resolves.toEqual({
branchIntentV2: false,
branchIntentV2Request: false,
tasksSupported: false,
worktreeCreateIdempotency: false,
agentLaunch: { replay: true },
@@ -123,6 +129,8 @@ describe('readNewWorktreeRuntimeCapabilities', () => {
await expect(
readNewWorktreeRuntimeCapabilities(statusClient([['worktree.create-idempotency.v1']]))
).resolves.toEqual({
branchIntentV2: false,
branchIntentV2Request: false,
tasksSupported: false,
worktreeCreateIdempotency: { dedupeTtlMs: 60_000 },
agentLaunch: false,
@@ -141,6 +149,8 @@ describe('readNewWorktreeRuntimeCapabilities', () => {
])
)
).resolves.toEqual({
branchIntentV2: false,
branchIntentV2Request: false,
tasksSupported: false,
worktreeCreateIdempotency: { dedupeTtlMs: 0 },
agentLaunch: false,
@@ -166,6 +176,8 @@ describe('readNewWorktreeRuntimeCapabilities', () => {
])
)
).resolves.toEqual({
branchIntentV2: false,
branchIntentV2Request: false,
tasksSupported: false,
worktreeCreateIdempotency: { dedupeTtlMs: 0 },
agentLaunch: false,
@@ -185,6 +197,8 @@ describe('readNewWorktreeRuntimeCapabilities', () => {
])
)
).resolves.toEqual({
branchIntentV2: false,
branchIntentV2Request: false,
tasksSupported: false,
worktreeCreateIdempotency: { dedupeTtlMs: 60_000 },
agentLaunch: false,
@@ -205,6 +219,8 @@ describe('readNewWorktreeRuntimeCapabilities', () => {
])
)
).resolves.toEqual({
branchIntentV2: false,
branchIntentV2Request: false,
tasksSupported: false,
worktreeCreateIdempotency: { dedupeTtlMs: 0 },
agentLaunch: false,
@@ -225,6 +241,8 @@ describe('readNewWorktreeRuntimeCapabilities', () => {
])
)
).resolves.toEqual({
branchIntentV2: false,
branchIntentV2Request: false,
tasksSupported: false,
worktreeCreateIdempotency: { dedupeTtlMs: 30_000 },
agentLaunch: false,
+22 -1
View File
@@ -5,6 +5,10 @@ import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-cli
import { readMobileRuntimeHostPlatform } from '../transport/mobile-runtime-host-platform'
import { worktreeCreateCapabilityRead } from './mobile-workspace-create-operations'
import { MOBILE_TASKS_CAPABILITY } from './mobile-tasks-capability'
import {
WORKTREE_OPEN_EXISTING_BRANCH_V2_RUNTIME_CAPABILITY,
WORKTREE_OPEN_EXISTING_BRANCH_V2_REQUEST_RUNTIME_CAPABILITY
} from '../../../src/shared/worktree-runtime-capabilities'
import {
WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS,
resolveWorktreeCreateIdempotencySupport,
@@ -20,6 +24,8 @@ export type NewWorktreeRuntimeCapabilities = {
tasksSupported: boolean
worktreeCreateIdempotency: WorktreeCreateIdempotencySupport | false
agentLaunch: AgentLaunchSupport | false
branchIntentV2: boolean
branchIntentV2Request: boolean
hostPlatform: NodeJS.Platform | null
}
@@ -44,6 +50,10 @@ export async function readNewWorktreeRuntimeCapabilities(
return {
tasksSupported: capabilities.includes(MOBILE_TASKS_CAPABILITY),
agentLaunch: readAgentLaunchSupport(capabilities),
branchIntentV2: capabilities.includes(WORKTREE_OPEN_EXISTING_BRANCH_V2_RUNTIME_CAPABILITY),
branchIntentV2Request: capabilities.includes(
WORKTREE_OPEN_EXISTING_BRANCH_V2_REQUEST_RUNTIME_CAPABILITY
),
worktreeCreateIdempotency: supportsIdempotency
? advertisedIdempotency === undefined
? { dedupeTtlMs: WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS }
@@ -75,6 +85,7 @@ export function useNewWorktreeRuntimeCapabilities(
hostPlatform: NodeJS.Platform | null
getWorktreeCreateCutoverSupport: () => Promise<WorktreeCreateIdempotencySupport | false>
getAgentLaunchSupport: () => Promise<AgentLaunchSupport | false>
getBranchIntentSupport: (intent: 'open-existing-v2' | 'create-new') => Promise<boolean>
} {
const [capabilityState, setCapabilityState] = useState<{
client: RpcClient
@@ -191,6 +202,15 @@ export function useNewWorktreeRuntimeCapabilities(
() => getCapabilities().then((capabilities) => capabilities?.agentLaunch ?? false),
[getCapabilities]
)
const getBranchIntentSupport = useCallback(
(intent: 'open-existing-v2' | 'create-new') =>
getCapabilities().then((capabilities) =>
intent === 'open-existing-v2'
? capabilities?.branchIntentV2Request === true
: capabilities?.branchIntentV2 === true
),
[getCapabilities]
)
const capabilities =
capabilityState?.client === client &&
capabilityState?.generation === client?.getGeneration?.() &&
@@ -201,6 +221,7 @@ export function useNewWorktreeRuntimeCapabilities(
tasksSupported: capabilities?.tasksSupported ?? false,
hostPlatform: capabilities?.hostPlatform ?? null,
getWorktreeCreateCutoverSupport,
getAgentLaunchSupport
getAgentLaunchSupport,
getBranchIntentSupport
}
}
+19 -3
View File
@@ -1,3 +1,4 @@
import { translate } from '../i18n/mobile-i18n'
import type { TuiAgent } from '../../../src/shared/tui-agent'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcResponse } from '../transport/types'
@@ -40,8 +41,9 @@ export type CreateWorktreeWithNameRetryArgs = {
client: RpcClient
baseName: string
nameWasGenerated?: boolean
buildParams: (name: string) => WorkspaceCreateParams
buildParams: (name: string, attempt: number) => WorkspaceCreateParams
worktreeCreateIdempotency: WorktreeCreateIdempotencyProbe
branchIntentSupported?: boolean | Promise<boolean>
agentLaunch?: WorktreeCreateAgentLaunch
maxAttempts?: number
mintMutationId?: () => string
@@ -64,7 +66,17 @@ export async function createWorktreeWithNameRetry(
const candidateName = args.nameWasGenerated
? getGeneratedWorktreeCreateRetryCandidate(baseName, attempt)
: getClientWorktreeCreateCandidate(baseName, attempt)
const candidateParams = buildParams(candidateName)
const candidateParams = buildParams(candidateName, attempt)
if (attempt === 0 && candidateParams.branchIntent) {
if ((await args.branchIntentSupported) !== true) {
return {
error: translate(
'workspace.branchChoice.hostUpdateRequired',
'Update Orca on the workspace host to reuse a branch or explicitly create a separate branch.'
)
}
}
}
// Only advertised idempotency permits replay; use one key per candidate and a new key for each collision suffix.
const params = worktreeCreateIdempotency
? { ...candidateParams, clientMutationId: mintMutationId() }
@@ -106,7 +118,11 @@ export async function createWorktreeWithNameRetry(
break
}
lastError = response.error.message
if (launch?.replay || !isRetryableWorktreeCreateConflict(lastError ?? '')) {
if (
candidateParams.branchIntent === 'open-existing-v2' ||
launch?.replay ||
!isRetryableWorktreeCreateConflict(lastError ?? '')
) {
break
}
}
@@ -97,6 +97,7 @@ export function workspaceSettingsMounts(
runSetup: false,
trustedOrcaHooks: {},
getWorktreeCreateCutoverSupport: async () => false,
getBranchIntentSupport: async () => false,
// Pin an older host so create remains worktree.create rather than agent.launch.
getAgentLaunchSupport: async () => false,
transitionDrawer: (view: unknown) => context.effect('drawer', view),
+6 -4
View File
@@ -69,20 +69,22 @@ async function hasCaseFoldedLocalConflict(
['for-each-ref', '--format=%(refname)', 'refs/heads/'],
options
)
const foldRef = (name: string) =>
name.normalize('NFD').toLowerCase().toUpperCase().normalize('NFD')
const foldedRef = foldRef(ref)
const foldedRef = foldGitRefNameCase(ref)
for (const existingRef of stdout.split(/\r?\n/).filter(Boolean)) {
if (!existingRef.startsWith('refs/heads/') || !isSafeGitRefName(existingRef)) {
throw new Error('Cannot verify branch-name case conflicts.')
}
if (foldRef(existingRef) === foldedRef) {
if (foldGitRefNameCase(existingRef) === foldedRef) {
return true
}
}
return false
}
export function foldGitRefNameCase(name: string): string {
return name.normalize('NFD').toLowerCase().toUpperCase().normalize('NFD')
}
function buildRemoteBranchConflictRefs(
remoteNames: readonly string[],
branchName: string,
+25 -4
View File
@@ -157,9 +157,9 @@ export async function trackSameNameRemoteBase(
baseBranch: string,
effectiveBase: string,
options: AddWorktreeOptions
): Promise<void> {
): Promise<boolean> {
if (!effectiveBase.startsWith('refs/remotes/')) {
return
return false
}
const parsed = parseRemoteTrackingLocalBaseRef(
baseBranch,
@@ -167,15 +167,17 @@ export async function trackSameNameRemoteBase(
options.remoteTrackingBase
)
if (parsed?.localBranch !== branch) {
return
return false
}
try {
// Git 1.8+ resolves slash-containing remote names via fetch refspecs without another probe.
await gitExecFileAsync(['branch', '--set-upstream-to', effectiveBase, branch], {
...gitExecOptions(worktreePath, options)
})
return true
} catch (error) {
console.warn(`addWorktree: failed to track ${effectiveBase} for ${branch}`, error)
return false
}
}
@@ -223,6 +225,9 @@ async function performAddWorktree(
let localBaseRefUpdateSuggestion: LocalBaseRefUpdateSuggestion | undefined
const args = [...windowsLongPathGitArgs(repoPath), 'worktree', 'add']
let effectiveBase: string | undefined
if (options.trackRemoteBranch && !baseBranch) {
throw new Error('Reuse branch requires a remote-tracking ref when the local branch is missing.')
}
if (noCheckout) {
args.push('--no-checkout')
}
@@ -240,6 +245,11 @@ async function performAddWorktree(
branch
)
effectiveBase = baseContext.effectiveBase
if (options.trackRemoteBranch && !effectiveBase.startsWith('refs/remotes/')) {
throw new Error(
'Reuse branch requires a remote-tracking ref when the local branch is missing.'
)
}
pendingLocalBaseRefRefresh = baseContext.pendingLocalBaseRefRefresh
localBaseRefUpdateSuggestion = baseContext.localBaseRefUpdateSuggestion
args.push(effectiveBase)
@@ -271,9 +281,19 @@ async function performAddWorktree(
return {}
}
let upstreamWarning: string | undefined
if (effectiveBase && baseBranch) {
await persistWorktreeCreationBase(worktreePath, branch, effectiveBase, options)
await trackSameNameRemoteBase(worktreePath, branch, baseBranch, effectiveBase, options)
const tracked = await trackSameNameRemoteBase(
worktreePath,
branch,
baseBranch,
effectiveBase,
options
)
if (options.trackRemoteBranch && !tracked) {
upstreamWarning = `Could not set ${effectiveBase} as the upstream of "${branch}". Check the push destination before pushing; Git may use a different remote.`
}
}
// Git >= 2.37 can set an upstream on first push; --local intentionally writes shared repo config.
@@ -281,6 +301,7 @@ async function performAddWorktree(
const localBaseRefRefresh = await pendingLocalBaseRefRefresh
return {
...(localBaseRefRefresh ? { localBaseRefRefresh } : {}),
...(upstreamWarning ? { upstreamWarning } : {}),
...(localBaseRefUpdateSuggestion ? { localBaseRefUpdateSuggestion } : {})
}
}
@@ -11,6 +11,7 @@ import type { WorktreeCheckoutProgressListener } from './worktree-checkout-progr
export type AddWorktreeResult = {
localBaseRefRefresh?: LocalBaseRefRefreshResult
localBaseRefUpdateSuggestion?: LocalBaseRefUpdateSuggestion
upstreamWarning?: string
}
export type SparseWorktreeCreateError = Error & {
@@ -32,6 +33,8 @@ export type WorktreeRemovalPreflightOptions = GitWorktreeExecOptions & {
export type AddWorktreeOptions = GitWorktreeExecOptions & {
checkoutExistingBranch?: boolean
/** Require a remote-tracking start point; set its upstream after creation when Git permits it. */
trackRemoteBranch?: boolean
/** Progress of the checkout `git worktree add` performs; never called for `--no-checkout`. */
onCheckoutProgress?: WorktreeCheckoutProgressListener
suggestLocalBaseRefUpdate?: boolean
+72 -59
View File
@@ -1,3 +1,4 @@
import { canCheckoutExistingLocalBranch } from '../runtime/runtime-worktree-create-git'
/* eslint-disable max-lines */
import { selectWorktreeCreateBranchSeed } from '../worktree-create-branch-seed'
@@ -26,7 +27,7 @@ import type {
WorktreeHeadIdentity
} from '../../shared/worktree/types'
import { getPRForBranch } from '../github/client'
import { listWorktrees, addWorktree, addSparseWorktree } from '../git/worktree'
import { addWorktree, addSparseWorktree } from '../git/worktree'
import type { AddWorktreeOptions, AddWorktreeResult } from '../git/worktree'
import {
consumePreparedWorktreeCreate,
@@ -454,49 +455,6 @@ async function resolveCreateBranchName(
return branchNameOverride
}
function normalizeLocalBranchName(branchName: string | undefined): string {
return branchName?.replace(/^refs\/heads\//, '') ?? ''
}
async function canCheckoutExistingLocalBranch(
repoPath: string,
branchName: string,
baseBranch: string,
gitOptions: { wslDistro?: string } = {}
): Promise<boolean> {
let localHead = ''
try {
const { stdout } = await gitExecFileAsync(
['rev-parse', '--verify', '--quiet', `refs/heads/${branchName}^{commit}`],
{
cwd: repoPath,
...gitOptions
}
)
localHead = stdout.trim()
} catch {
return false
}
if (normalizeLocalBranchName(baseBranch) !== branchName) {
if (!localHead) {
return false
}
try {
const { stdout } = await gitExecFileAsync(
['rev-parse', '--verify', '--quiet', `${baseBranch}^{commit}`],
{ cwd: repoPath, ...gitOptions }
)
if (stdout.trim() !== localHead) {
return false
}
} catch {
return false
}
}
const worktrees = await listWorktrees(repoPath, gitOptions)
return !worktrees.some((worktree) => normalizeLocalBranchName(worktree.branch) === branchName)
}
function hasLocalGitOptions(gitOptions: { wslDistro?: string }): boolean {
return Object.keys(gitOptions).length > 0
}
@@ -1155,7 +1113,7 @@ async function performLocalWorktreeCreate(
let resolved = false
let checkoutExistingBranch = false
let selectedExistingLocalBranchName: string | null = null
let selectedRemoteBranchName: string | null = null
let selectedReuseBranchName: string | null = null
let lastBranchConflictKind: 'local' | 'remote' | null = null
let lastExistingPR: Awaited<ReturnType<typeof getPRForBranch>> | null = null
let lastExistingReviewNumber: number | null = null
@@ -1182,9 +1140,50 @@ async function performLocalWorktreeCreate(
localWorktreeGitOptions
)
if (conflict === null) {
selectedRemoteBranchName = branchName
selectedReuseBranchName = branchName
}
}
if (args.branchIntent === 'open-existing-v2') {
if (!args.branchNameOverride || args.pushTarget || getSelectedReviewBranch(args)) {
throw new Error(
'Reuse branch requires an explicit branch selection without a review push target.'
)
}
branchName = await resolveCreateBranchName(
repo.path,
args.branchNameOverride,
sanitizedName,
settings,
username,
localWorktreeGitOptions
)
checkoutExistingBranch = await canCheckoutExistingLocalBranch(
repo.path,
branchName,
baseBranch,
localWorktreeGitOptions,
true
)
if (!checkoutExistingBranch) {
if (remoteTrackingBase?.branch !== branchName) {
throw new Error(
`Branch "${branchName}" was not found locally. Select its remote branch again, or turn off Reuse branch.`
)
}
const conflict = await getBranchConflictKind(
repo.path,
branchName,
remoteTrackingBase.ref,
localWorktreeGitOptions
)
if (conflict) {
throw new WorktreeCreateCollisionError(
`Cannot reuse branch "${branchName}" because its name conflicts with an existing branch. Choose a different branch or turn off Reuse branch.`
)
}
}
selectedReuseBranchName = branchName
}
const pathIsAvailable = (): boolean => {
worktreePath = ensurePathWithinWorkspace(
computeWorktreePath(effectiveSanitizedName, repo.path, worktreePathSettings, workspaceRoot),
@@ -1224,7 +1223,7 @@ async function performLocalWorktreeCreate(
continue
}
attempts += 1
if (selectedRemoteBranchName) {
if (selectedReuseBranchName) {
if (pathIsAvailable()) {
resolved = true
break
@@ -1256,15 +1255,17 @@ async function performLocalWorktreeCreate(
const preferExistingBranch = Boolean(
args.branchNameOverride || selectedExistingLocalBranchName
)
checkoutExistingBranch = preferExistingBranch && (await tryExistingBranch())
const allowImplicitReuse = args.branchIntent !== 'create-new'
checkoutExistingBranch =
allowImplicitReuse && preferExistingBranch && (await tryExistingBranch())
lastBranchConflictKind = checkoutExistingBranch
? null
: await getBranchConflictKind(
repo.path,
branchName,
baseBranch,
allowImplicitReuse ? baseBranch : undefined,
localWorktreeGitOptions,
preferExistingBranch ? undefined : tryExistingBranch
allowImplicitReuse && !preferExistingBranch ? tryExistingBranch : undefined
)
if (checkoutExistingBranch && !selectedExistingLocalBranchName) {
// Path retries must retain the adopted branch.
@@ -1404,6 +1405,9 @@ async function performLocalWorktreeCreate(
const remoteTrackingBaseOption = remoteTrackingBase ? { remoteTrackingBase } : undefined
const existingBranchOption = {
checkoutExistingBranch,
...(args.branchIntent === 'open-existing-v2' && !checkoutExistingBranch
? { trackRemoteBranch: true }
: {}),
...remoteTrackingBaseOption,
...(suggestLocalBaseRefUpdate ? { suggestLocalBaseRefUpdate } : {})
}
@@ -1412,11 +1416,19 @@ async function performLocalWorktreeCreate(
? { ...remoteTrackingBaseOption, suggestLocalBaseRefUpdate }
: remoteTrackingBaseOption
)
const newBranchOptions =
args.branchIntent === 'open-existing-v2'
? addProjectGitOptions(existingBranchOption)
: preparedWorktreeOptions
let addResult: AddWorktreeResult
try {
addResult =
(await timing.time('git_worktree_add', async () => {
if (sparseDirectories.length === 0 && !checkoutExistingBranch) {
if (
sparseDirectories.length === 0 &&
!checkoutExistingBranch &&
args.branchIntent !== 'open-existing-v2'
) {
const prepared = await consumePreparedWorktreeCreate({
repoPath: repo.path,
workspaceRoot,
@@ -1460,7 +1472,7 @@ async function performLocalWorktreeCreate(
sparseDirectories,
baseBranch,
settings.refreshLocalBaseRefOnWorktreeCreate,
preparedWorktreeOptions
newBranchOptions
)
}
@@ -1482,7 +1494,7 @@ async function performLocalWorktreeCreate(
baseBranch,
settings.refreshLocalBaseRefOnWorktreeCreate,
false,
preparedWorktreeOptions
newBranchOptions
)
})) ?? {}
} catch (error) {
@@ -1545,7 +1557,9 @@ async function performLocalWorktreeCreate(
...(args.automationProvenance ? { automationProvenance: args.automationProvenance } : {}),
...(args.cliProvenance ? { cliProvenance: args.cliProvenance } : {}),
baseRef: metadataBaseRef,
...(checkoutExistingBranch ? { preserveBranchOnDelete: true } : {}),
...(checkoutExistingBranch || args.branchIntent === 'open-existing-v2'
? { preserveBranchOnDelete: true }
: {}),
...(configuredPushTarget ? { pushTarget: configuredPushTarget } : {}),
...resolveWorktreeCreateDisplayNameMeta(
requestedDisplayName,
@@ -1729,6 +1743,9 @@ async function performLocalWorktreeCreate(
repo.id,
worktreeLineage?.parentWorktreeId
)
const createWarning = [includeCopyWarning, addResult.upstreamWarning, stagedStartup.warning]
.filter((warning): warning is string => Boolean(warning))
.reduce<string | undefined>(appendWorktreeCreateWarning, undefined)
return {
worktree: {
...worktree,
@@ -1757,11 +1774,7 @@ async function performLocalWorktreeCreate(
: {}),
...(stagedStartup.startupTerminal ? { startupTerminal: stagedStartup.startupTerminal } : {}),
...(baseFallback ? { baseFallback } : {}),
...(stagedStartup.warning
? { warning: appendWorktreeCreateWarning(includeCopyWarning, stagedStartup.warning) }
: includeCopyWarning
? { warning: includeCopyWarning }
: {}),
...(createWarning ? { warning: createWarning } : {}),
timing: timing.finish()
}
}
@@ -213,19 +213,24 @@ describe('registerWorktreeHandlers', () => {
})
})
it.each(['remote', 'local', 'busy'])('picker allocation (%s)', async (kind) => {
if (kind !== 'busy') {
getPRForBranchMock.mockResolvedValue({ number: 42 })
it.each(['remote', 'local', 'busy'])('preserves explicit picker intent (%s)', async (kind) => {
const branchName = 'fix/bug-0'
const remoteTrackingBase = {
remote: 'origin',
branch: branchName,
base: `origin/${branchName}`,
ref: `refs/remotes/origin/${branchName}`
}
const branchName = kind === 'busy' ? 'fix/bug-0-2' : 'fix/bug-0'
getBranchConflictKindMock.mockImplementation(async (_repo, branch) =>
kind !== 'remote' && branch === 'fix/bug-0' ? 'local' : null
)
runtimeStub.resolveRemoteTrackingBase.mockResolvedValue(remoteTrackingBase)
runtimeStub.hasRemoteTrackingRef.mockResolvedValue(true)
gitExecFileAsyncMock.mockImplementation(async (argv) => {
if (argv.includes('refs/heads/fix/bug-0-2^{commit}')) {
throw new Error('missing')
if (kind === 'remote' && argv.includes(`refs/heads/${branchName}^{commit}`)) {
throw Object.assign(new Error('missing'), { code: 1 })
}
return { stdout: 'abc123', stderr: '' }
if (argv[0] === 'for-each-ref') {
return { stdout: `refs/heads/${branchName}\n`, stderr: '' }
}
return { stdout: 'local-ahead', stderr: '' }
})
const mainWorktree = {
path: '/workspace/repo',
@@ -234,83 +239,97 @@ describe('registerWorktreeHandlers', () => {
isBare: false,
isMainWorktree: true
}
computeWorktreePathMock.mockImplementation((sanitizedName: string) =>
sanitizedName === 'fix-bug-0' ? process.cwd() : `/workspace/${sanitizedName}`
computeWorktreePathMock.mockImplementation((name: string) =>
name === 'fix-bug-0' ? process.cwd() : `/workspace/${name}`
)
listWorktreesMock.mockResolvedValue([
{ path: '/workspace/fix-bug-0-2', branch: `refs/heads/${branchName}` }
])
if (kind === 'local') {
listWorktreesMock.mockResolvedValueOnce([mainWorktree]).mockResolvedValueOnce([mainWorktree])
} else if (kind === 'busy') {
listWorktreesMock.mockResolvedValueOnce([
{ path: '/workspace/busy', branch: 'refs/heads/fix/bug-0' }
])
const created = {
path: '/workspace/fix-bug-0-2',
head: 'local-ahead',
branch: `refs/heads/${branchName}`,
isBare: false,
isMainWorktree: false
}
const result = await handlers['worktrees:create'](null, {
listWorktreesMock.mockResolvedValue([mainWorktree, created])
if (kind === 'local') {
listWorktreesMock.mockResolvedValueOnce([mainWorktree])
} else if (kind === 'remote') {
addWorktreeMock.mockResolvedValueOnce({ upstreamWarning: 'Check the push destination.' })
} else if (kind === 'busy') {
listWorktreesMock.mockResolvedValueOnce([{ ...created, path: '/workspace/busy' }])
}
const creation = handlers['worktrees:create'](null, {
repoId: 'repo-1',
name: 'fix/bug-0',
baseBranch: 'origin/fix/bug-0',
branchNameOverride: 'fix/bug-0',
branchIntent: 'open-existing'
baseBranch: remoteTrackingBase.base,
branchNameOverride: branchName,
branchIntent: 'open-existing-v2'
})
expect(getBranchConflictKindMock).toHaveBeenCalledTimes(kind === 'busy' ? 3 : 1)
expect(getPRForBranchMock.mock.calls.map(([, branch]) => branch)).toEqual(
kind === 'busy' ? ['fix/bug-0-2'] : []
)
expect(addWorktreeMock).toHaveBeenCalledWith(
if (kind === 'busy') {
await expect(creation).rejects.toThrow('already checked out at "/workspace/busy"')
expect(addWorktreeMock).not.toHaveBeenCalled()
return
}
const result = await creation
if (kind === 'remote') {
expect(result).toMatchObject({ warning: 'Check the push destination.' })
}
expect(getPRForBranchMock).not.toHaveBeenCalled()
expect(addWorktreeMock).toHaveBeenCalledExactlyOnceWith(
'/workspace/repo',
'/workspace/fix-bug-0-2',
branchName,
'origin/fix/bug-0',
remoteTrackingBase.base,
false,
false,
kind === 'local' ? { checkoutExistingBranch: true } : {}
expect.objectContaining(
kind === 'local' ? { checkoutExistingBranch: true } : { trackRemoteBranch: true }
)
)
expect(result).toMatchObject({
worktree: expect.objectContaining({
path: '/workspace/fix-bug-0-2',
branch: `refs/heads/${branchName}`
expect(result).toMatchObject({ worktree: { branch: `refs/heads/${branchName}` } })
})
it.each([undefined, 'open-existing'] as const)(
'suffixes a divergent local branch collision for intent %s',
async (branchIntent) => {
getBranchConflictKindMock.mockImplementation(async (_repoPath: string, branch: string) =>
branch === 'feature/something' ? 'local' : null
)
gitExecFileAsyncMock.mockImplementation(async (argv) => ({
stdout: argv.some((arg) => arg.startsWith('refs/heads/')) ? 'local-tip' : 'remote-tip',
stderr: ''
}))
listWorktreesMock.mockResolvedValue([
{
path: '/workspace/feature-something-2',
head: 'abc123',
branch: 'refs/heads/feature/something-2',
isBare: false,
isMainWorktree: false
}
])
await handlers['worktrees:create'](null, {
repoId: 'repo-1',
name: 'feature/something',
branchNameOverride: 'feature/something',
branchIntent
})
})
})
it('suffixes branchNameOverride when the requested branch collides', async () => {
getBranchConflictKindMock.mockImplementation(async (_repoPath: string, branch: string) =>
branch === 'feature/something' ? 'remote' : null
)
listWorktreesMock.mockResolvedValue([
{
path: '/workspace/feature-something-2',
head: 'abc123',
branch: 'refs/heads/feature/something-2',
isBare: false,
isMainWorktree: false
}
])
await handlers['worktrees:create'](null, {
repoId: 'repo-1',
name: 'feature/something',
branchNameOverride: 'feature/something'
})
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
['check-ref-format', '--branch', 'feature/something-2'],
{ cwd: '/workspace/repo' }
)
expect(addWorktreeMock).toHaveBeenCalledWith(
'/workspace/repo',
'/workspace/feature-something-2',
'feature/something-2',
'origin/main',
false,
false,
{}
)
})
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
['check-ref-format', '--branch', 'feature/something-2'],
{ cwd: '/workspace/repo' }
)
expect(addWorktreeMock).toHaveBeenCalledWith(
'/workspace/repo',
'/workspace/feature-something-2',
'feature/something-2',
'origin/main',
false,
false,
{}
)
}
)
it('allows a resolver-provided PR branch override to match its remote push target', async () => {
getBranchConflictKindMock.mockImplementation(async (_repoPath: string, branch: string) =>
@@ -20,9 +20,9 @@ describe('buildManagedWorktreeCreateArgs', () => {
branchNameOverride: 'feature/test'
}
expect(build(request)).not.toHaveProperty('branchIntent')
expect(build({ ...request, branchIntent: 'open-existing' })).toMatchObject({
branchIntent: 'open-existing'
})
for (const branchIntent of ['open-existing', 'open-existing-v2', 'create-new']) {
expect(build({ ...request, branchIntent })).toMatchObject({ branchIntent })
}
})
it('omits name provenance when the client did not claim a generated name', () => {
@@ -0,0 +1,340 @@
import { mkdir, mkdtemp, realpath, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import type { Repo } from '../../shared/repo-types'
import type { CreateWorktreeArgs } from '../../shared/worktree/create-types'
import { gitExecFileAsync } from '../git/runner'
import { addSparseWorktree, addWorktree } from '../git/worktree'
import { openWorktreeCreatePathClaim } from '../worktree-create-in-flight'
import { WorktreeCreate } from '../../shared/rpc-contract/worktree-create-params'
import { buildManagedWorktreeCreateArgs } from './rpc/methods/worktree-create-args'
import { RuntimeRemoteFetchController } from './runtime-remote-fetch-controller'
import { resolveRuntimeLocalWorktreeCreateCandidate } from './runtime-local-worktree-create-candidate'
const roots: string[] = []
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
async function git(cwd: string, ...argv: string[]): Promise<string> {
return (await gitExecFileAsync(argv, { cwd })).stdout.trim()
}
async function fixture(remote = 'origin') {
const root = await realpath(await mkdtemp(join(tmpdir(), 'orca-branch-reuse-')))
roots.push(root)
await git(root, 'init', '--quiet', 'repo')
const repoPath = join(root, 'repo')
await git(repoPath, 'symbolic-ref', 'HEAD', 'refs/heads/main')
await git(repoPath, 'config', 'user.name', 'Branch Reuse Test')
await git(repoPath, 'config', 'user.email', 'test@example.com')
await git(repoPath, 'config', 'core.hooksPath', join(root, 'no-hooks'))
await git(repoPath, 'commit', '--quiet', '--allow-empty', '-m', 'base')
const branch = 'feature/nested'
const base = `refs/remotes/${remote}/${branch}`
await git(repoPath, 'remote', 'add', remote, repoPath)
await git(repoPath, 'update-ref', base, 'HEAD')
const initialTip = await git(repoPath, 'rev-parse', 'HEAD')
const remoteTrackingBase = await new RuntimeRemoteFetchController().resolveRemoteTrackingBase(
repoPath,
base
)
const repo: Repo = { id: 'repo', path: repoPath, displayName: 'Repo', badgeColor: '', addedAt: 0 }
const workspaceRoot = join(root, 'workspaces')
const settings = { workspaceDir: workspaceRoot, nestWorkspaces: false, branchPrefix: '' }
const candidate = async (request: Partial<CreateWorktreeArgs> = {}) => {
const pathClaim = openWorktreeCreatePathClaim()
try {
return await resolveRuntimeLocalWorktreeCreateCandidate({
request: {
repoSelector: repo.id,
name: 'My workspace',
baseBranch: base,
branchNameOverride: branch,
branchIntent: 'open-existing-v2',
...request
},
repo,
settings,
worktreePathSettings: settings,
workspaceRoot,
username: '',
baseBranch: base,
remoteTrackingBase,
localWorktreeGitOptions: {},
pathClaim
})
} finally {
pathClaim.release()
}
}
const create = async (request: Partial<CreateWorktreeArgs> = {}) => {
const picked = await candidate(request)
const addResult = await addWorktree(
repoPath,
picked.worktreePath,
picked.branchName,
base,
true,
false,
{
checkoutExistingBranch: picked.checkoutExistingBranch,
trackRemoteBranch:
(request.branchIntent ?? 'open-existing-v2') === 'open-existing-v2' &&
!picked.checkoutExistingBranch,
...(remoteTrackingBase ? { remoteTrackingBase } : {})
}
)
return { ...picked, addResult }
}
return {
root,
repoPath,
branch,
base,
initialTip,
workspaceRoot,
candidate,
create,
remoteTrackingBase
}
}
describe('explicit branch reuse with real Git', () => {
it('preserves an old V1 remote pick when the same-name local branch has diverged', async () => {
const f = await fixture()
await git(f.repoPath, 'checkout', '--quiet', '-b', f.branch)
await git(f.repoPath, 'commit', '--quiet', '--allow-empty', '-m', 'local work')
const localTip = await git(f.repoPath, 'rev-parse', 'HEAD')
await git(f.repoPath, 'checkout', '--quiet', 'main')
await git(f.repoPath, 'commit', '--quiet', '--allow-empty', '-m', 'remote work')
await git(f.repoPath, 'update-ref', f.base, 'HEAD')
const remoteTip = await git(f.repoPath, 'rev-parse', f.base)
const request = buildManagedWorktreeCreateArgs(
WorktreeCreate.parse({
repo: 'repo',
name: 'Legacy workspace',
branchNameOverride: f.branch,
baseBranch: f.base,
branchIntent: 'open-existing'
}),
{}
)
const picked = await f.create({
name: request.name,
branchNameOverride: request.branchNameOverride,
branchIntent: request.branchIntent
})
expect(picked.branchName).toBe(`${f.branch}-2`)
expect(picked.checkoutExistingBranch).toBe(false)
expect(await git(picked.worktreePath, 'rev-parse', 'HEAD')).toBe(remoteTip)
expect(await git(f.repoPath, 'rev-parse', `refs/heads/${f.branch}`)).toBe(localTip)
expect(await git(f.repoPath, 'rev-parse', f.base)).toBe(remoteTip)
})
it('keeps a V1 remote-only branch pinned across a workspace path collision', async () => {
const f = await fixture()
await mkdir(join(f.workspaceRoot, 'My-workspace'), { recursive: true })
const picked = await f.create({ branchIntent: 'open-existing' })
expect(picked.branchName).toBe(f.branch)
expect(picked.worktreePath).toBe(join(f.workspaceRoot, 'My-workspace-2'))
expect(await git(picked.worktreePath, 'rev-parse', 'HEAD')).toBe(f.initialTip)
})
for (const alias of ['feature/NESTED', 'FEATURE/nested']) {
it(`rejects a loose ref alias of a busy branch (${alias})`, async (context) => {
const f = await fixture()
const busy = join(f.root, 'external-workspace')
await git(f.repoPath, 'worktree', 'add', '--no-track', '-b', f.branch, busy, f.base)
try {
await git(f.repoPath, 'rev-parse', '--verify', `refs/heads/${alias}`)
} catch {
context.skip()
return
}
await expect(f.create({ branchNameOverride: alias })).rejects.toThrow(
'Select its exact branch name'
)
expect(
(await git(f.repoPath, 'worktree', 'list', '--porcelain')).match(/^worktree /gm)
).toHaveLength(2)
expect(await git(f.repoPath, 'rev-parse', `refs/heads/${f.branch}`)).toBe(f.initialTip)
})
}
it('rejects the exact branch when an older checkout recorded a case alias', async (context) => {
const f = await fixture()
const alias = 'feature/NESTED'
const busy = join(f.root, 'alias-workspace')
await git(f.repoPath, 'branch', f.branch)
try {
await git(f.repoPath, 'rev-parse', '--verify', `refs/heads/${alias}`)
} catch {
context.skip()
return
}
await git(f.repoPath, 'worktree', 'add', busy, alias)
expect(await git(busy, 'symbolic-ref', 'HEAD')).toBe(`refs/heads/${alias}`)
await expect(f.create()).rejects.toThrow(`already checked out at "${busy}"`)
expect(
(await git(f.repoPath, 'worktree', 'list', '--porcelain')).match(/^worktree /gm)
).toHaveLength(2)
})
it('allows distinct case-sensitive branches even when their commits are identical', async (context) => {
const f = await fixture()
await git(
f.repoPath,
'worktree',
'add',
'--no-track',
'-b',
f.branch,
join(f.root, 'busy'),
f.base
)
const other = 'feature/NESTED'
const resolvesAlias = await git(
f.repoPath,
'rev-parse',
'--verify',
`refs/heads/${other}`
).then(
() => true,
() => false
)
if (resolvesAlias) {
context.skip()
return
}
await git(f.repoPath, 'branch', other, f.base)
const picked = await f.create({ branchNameOverride: other })
expect(picked.checkoutExistingBranch).toBe(true)
expect(await git(picked.worktreePath, 'symbolic-ref', 'HEAD')).toBe(`refs/heads/${other}`)
})
it.each([false, true])(
'creates from a ref outside a restricted fetch refspec (sparse=%s)',
async (sparse) => {
const f = await fixture('team/upstream')
await git(
f.repoPath,
'config',
'remote.team/upstream.fetch',
'+refs/heads/main:refs/remotes/team/upstream/main'
)
const picked = await f.candidate()
const create = sparse
? addSparseWorktree(
f.repoPath,
picked.worktreePath,
picked.branchName,
['src'],
f.base,
false,
{ trackRemoteBranch: true, remoteTrackingBase: f.remoteTrackingBase ?? undefined }
)
: addWorktree(f.repoPath, picked.worktreePath, picked.branchName, f.base, false, false, {
trackRemoteBranch: true,
remoteTrackingBase: f.remoteTrackingBase ?? undefined
})
const result = await create
expect(result.upstreamWarning).toContain('Check the push destination')
expect(result.upstreamWarning).toContain(f.base)
expect(await git(picked.worktreePath, 'rev-parse', 'HEAD')).toBe(f.initialTip)
expect(await git(picked.worktreePath, 'symbolic-ref', 'HEAD')).toBe(`refs/heads/${f.branch}`)
await expect(git(picked.worktreePath, 'rev-parse', '@{upstream}')).rejects.toThrow()
expect(await git(f.repoPath, 'config', 'remote.team/upstream.fetch')).toBe(
'+refs/heads/main:refs/remotes/team/upstream/main'
)
}
)
it.each(['origin', 'team/upstream'])(
'materializes a remote-only branch and tracks the selected remote (%s)',
async (remote) => {
const f = await fixture(remote)
const picked = await f.create()
expect(picked.branchName).toBe(f.branch)
expect(picked.checkoutExistingBranch).toBe(false)
expect(picked.addResult.upstreamWarning).toBeUndefined()
expect(await git(picked.worktreePath, 'rev-parse', 'HEAD')).toBe(f.initialTip)
expect(
await git(picked.worktreePath, 'rev-parse', '--symbolic-full-name', '@{upstream}')
).toBe(f.base)
}
)
it.each(['ahead', 'diverged', 'behind'])(
'reuses a %s local branch without changing its tip or upstream',
async (state) => {
const f = await fixture()
await git(f.repoPath, 'branch', f.branch)
await git(f.repoPath, 'config', `branch.${f.branch}.remote`, 'origin')
await git(f.repoPath, 'config', `branch.${f.branch}.merge`, 'refs/heads/main')
await git(f.repoPath, 'checkout', '--quiet', f.branch)
if (state !== 'behind') {
await git(f.repoPath, 'commit', '--quiet', '--allow-empty', '-m', 'local work')
}
const localTip = await git(f.repoPath, 'rev-parse', 'HEAD')
await git(f.repoPath, 'checkout', '--quiet', 'main')
if (state !== 'ahead') {
await git(f.repoPath, 'commit', '--quiet', '--allow-empty', '-m', 'remote work')
await git(f.repoPath, 'update-ref', f.base, 'HEAD')
}
const remoteTip = await git(f.repoPath, 'rev-parse', f.base)
await mkdir(join(f.workspaceRoot, 'My-workspace'), { recursive: true })
const picked = await f.create()
expect(picked.checkoutExistingBranch).toBe(true)
expect(picked.branchName).toBe(f.branch)
expect(await git(picked.worktreePath, 'rev-parse', 'HEAD')).toBe(localTip)
expect(await git(f.repoPath, 'rev-parse', f.base)).toBe(remoteTip)
expect(await git(f.repoPath, 'config', `branch.${f.branch}.merge`)).toBe('refs/heads/main')
}
)
it('reports the checkout path of a busy branch and allocates no replacement branch', async () => {
const f = await fixture()
const busy = join(f.root, 'external-workspace')
await git(f.repoPath, 'worktree', 'add', '--no-track', '-b', f.branch, busy, f.base)
await expect(f.create()).rejects.toThrow(`already checked out at "${busy}"`)
expect(await git(f.repoPath, 'for-each-ref', '--format=%(refname)', 'refs/heads/')).toBe(
`refs/heads/${f.branch}\nrefs/heads/main`
)
})
it('pins the branch across path collisions and refuses a branch created after preflight', async () => {
const f = await fixture()
await mkdir(join(f.workspaceRoot, 'My-workspace'), { recursive: true })
const picked = await f.candidate()
expect(picked.worktreePath).toBe(join(f.workspaceRoot, 'My-workspace-2'))
expect(picked.branchName).toBe(f.branch)
await git(f.repoPath, 'branch', f.branch)
await expect(
addWorktree(f.repoPath, picked.worktreePath, picked.branchName, f.base, false, false, {
trackRemoteBranch: true
})
).rejects.toThrow()
expect(await git(f.repoPath, 'rev-parse', `refs/heads/${f.branch}`)).toBe(f.initialTip)
await expect(
git(f.repoPath, 'rev-parse', '--verify', `refs/heads/${f.branch}-2`)
).rejects.toThrow()
})
it('keeps intentional branch-off creation separate from an available local branch', async () => {
const f = await fixture()
await git(f.repoPath, 'branch', f.branch)
const picked = await f.create({ name: f.branch, branchIntent: 'create-new' })
expect(picked.branchName).toBe(`${f.branch}-2`)
expect(picked.checkoutExistingBranch).toBe(false)
expect(await git(f.repoPath, 'rev-parse', `refs/heads/${f.branch}`)).toBe(f.initialTip)
await expect(git(picked.worktreePath, 'rev-parse', '@{upstream}')).rejects.toThrow()
})
it('fails on a conflicting branch namespace without choosing a different name', async () => {
const f = await fixture()
await git(f.repoPath, 'branch', 'feature')
await expect(f.create()).rejects.toThrow()
expect(await git(f.repoPath, 'rev-parse', 'refs/heads/feature')).toBe(f.initialTip)
})
})
@@ -21,10 +21,10 @@ vi.mock('./runtime-local-worktree-terminal-startup', () => ({
}))
vi.mock('./runtime-local-worktree-setup', () => ({
prepareRuntimeLocalWorktreeSetup: vi.fn(async () => ({
prepareRuntimeLocalWorktreeSetup: vi.fn(async (args: { warning?: string }) => ({
setup: undefined,
defaultTabs: undefined,
warning: undefined,
warning: args.warning,
effectiveDecision: 'skip',
hookFound: false,
shouldRunSetup: false,
@@ -98,10 +98,10 @@ describe('runtime local create prepared-pool re-arm ordering', () => {
metadataResult: { lineage: null, workspaceLineage: null, warnings: [] }
}
})
startTerminalsMock.mockImplementation(async () => {
startTerminalsMock.mockImplementation(async (args: { warning?: string }) => {
calls.order.push('terminals')
return {
warning: undefined,
warning: args.warning,
returnedSetup: undefined,
didSpawnSetup: false,
didSpawnStartup: false,
@@ -123,6 +123,23 @@ describe('runtime local create prepared-pool re-arm ordering', () => {
expect(calls.order).toEqual(['terminals', 'rearm'])
})
it('returns upstream warnings with other creation warnings', async () => {
createLocalMock.mockResolvedValueOnce({
worktree,
worktreePath: worktree.path,
includeCopyWarning: 'Some files were skipped.',
created: { path: worktree.path, head: 'abc', branch: 'app' },
addResult: { upstreamWarning: 'Check the push destination.' },
metadataResult: { lineage: null, workspaceLineage: null, warnings: [] }
})
const result = await makeRuntime().createManagedWorktree({
repoSelector: 'repo-1',
name: 'app'
})
expect(result.warning).toContain('Some files were skipped.')
expect(result.warning).toContain('Check the push destination.')
})
it('still arms the pool when terminal launch fails', async () => {
startTerminalsMock.mockRejectedValue(new Error('spawn failed'))
const runtime = makeRuntime()
@@ -121,7 +121,10 @@ export async function createRuntimeLocalGitWorktree(args: {
}
const addOptions: AddWorktreeOptions = {
...preparedWorktreeOptions,
...(args.checkoutExistingBranch ? { checkoutExistingBranch: true } : {})
...(args.checkoutExistingBranch ? { checkoutExistingBranch: true } : {}),
...(args.request.branchIntent === 'open-existing-v2' && !args.checkoutExistingBranch
? { trackRemoteBranch: true }
: {})
}
const shouldRetireGeneratedName =
args.request.nameWasGenerated === true &&
@@ -130,7 +133,11 @@ export async function createRuntimeLocalGitWorktree(args: {
let addResult: AddWorktreeResult
try {
addResult = await args.timing.time('git_worktree_add', async () => {
if (sparseDirectories.length > 0 || args.checkoutExistingBranch) {
if (
sparseDirectories.length > 0 ||
args.checkoutExistingBranch ||
args.request.branchIntent === 'open-existing-v2'
) {
args.timing.recordPreparedCheckout({
status: 'miss',
reason: sparseDirectories.length > 0 ? 'sparse_checkout' : 'checkout_existing_branch'
@@ -37,6 +37,8 @@ import { findPendingWorktreeRemovalConflict } from '../worktree-removal-table'
import type { WorktreeCreatePathClaim } from '../worktree-create-in-flight'
import type { RuntimeStore } from './runtime-store-contract'
import type { HostedReviewExecutionOptions } from '../source-control/hosted-review-git-options'
import type { RemoteTrackingBase } from './runtime-remote-fetch-controller'
import { resolveLegacyRemoteBranch } from './runtime-local-worktree-create-legacy-branch'
export type RuntimeLocalWorktreeCreateCandidate = {
effectiveRequestedName: string
@@ -61,6 +63,7 @@ export async function resolveRuntimeLocalWorktreeCreateCandidate(args: {
username: string
store?: RuntimeStore
baseBranch: string
remoteTrackingBase?: RemoteTrackingBase | null
localWorktreeGitOptions: { wslDistro?: string }
hostedReviewExecutionContext?: HostedReviewExecutionOptions
pathClaim: WorktreeCreatePathClaim
@@ -75,7 +78,7 @@ export async function resolveRuntimeLocalWorktreeCreateCandidate(args: {
let branchName = ''
let checkoutExistingBranch = false
let selectedExistingLocalBranchName: string | null = null
let selectedRemoteBranchName: string | null = null
let selectedReuseBranchName: string | null = null
let branchConflictKind: 'local' | 'remote' | null = null
let worktreePath = ''
let worktreePathResolved = false
@@ -94,12 +97,20 @@ export async function resolveRuntimeLocalWorktreeCreateCandidate(args: {
)
: null
const isRetiredName = retiredNameRegistry ? createRetiredNameLookup(retiredNameRegistry) : null
if (
args.request.branchIntent === 'open-existing' &&
args.request.branchNameOverride &&
!args.request.pushTarget &&
getSelectedReviewBranch(args.request) === null
) {
selectedReuseBranchName = await resolveLegacyRemoteBranch({ ...args, sanitizedName })
if (selectedReuseBranchName) {
branchName = selectedReuseBranchName
}
if (args.request.branchIntent === 'open-existing-v2') {
if (
!args.request.branchNameOverride ||
args.request.pushTarget ||
getSelectedReviewBranch(args.request)
) {
throw new Error(
'Reuse branch requires an explicit branch selection without a review push target.'
)
}
branchName = await resolveCreateBranchName(
args.repo.path,
args.request.branchNameOverride,
@@ -108,15 +119,32 @@ export async function resolveRuntimeLocalWorktreeCreateCandidate(args: {
args.username,
args.localWorktreeGitOptions
)
const conflict = await getBranchConflictKind(
checkoutExistingBranch = await canCheckoutExistingLocalBranch(
args.repo.path,
branchName,
args.baseBranch,
args.localWorktreeGitOptions
args.localWorktreeGitOptions,
true
)
if (conflict === null) {
selectedRemoteBranchName = branchName
if (!checkoutExistingBranch) {
if (args.remoteTrackingBase?.branch !== branchName) {
throw new Error(
`Branch "${branchName}" was not found locally. Select its remote branch again, or turn off Reuse branch.`
)
}
const conflict = await getBranchConflictKind(
args.repo.path,
branchName,
args.remoteTrackingBase.ref,
args.localWorktreeGitOptions
)
if (conflict) {
throw new WorktreeCreateCollisionError(
`Cannot reuse branch "${branchName}" because its name conflicts with an existing branch. Choose a different branch or turn off Reuse branch.`
)
}
}
selectedReuseBranchName = branchName
}
const pathIsAvailable = async (): Promise<boolean> => {
worktreePath = ensurePathWithinWorkspace(
@@ -147,7 +175,7 @@ export async function resolveRuntimeLocalWorktreeCreateCandidate(args: {
continue
}
attempts += 1
if (selectedRemoteBranchName) {
if (selectedReuseBranchName) {
if (await pathIsAvailable()) {
worktreePathResolved = true
break
@@ -175,15 +203,17 @@ export async function resolveRuntimeLocalWorktreeCreateCandidate(args: {
const preferExistingBranch = Boolean(
args.request.branchNameOverride || selectedExistingLocalBranchName
)
checkoutExistingBranch = preferExistingBranch && (await tryExistingBranch())
const allowImplicitReuse = args.request.branchIntent !== 'create-new'
checkoutExistingBranch =
allowImplicitReuse && preferExistingBranch && (await tryExistingBranch())
branchConflictKind = checkoutExistingBranch
? null
: await getBranchConflictKind(
args.repo.path,
branchName,
args.baseBranch,
allowImplicitReuse ? args.baseBranch : undefined,
args.localWorktreeGitOptions,
preferExistingBranch ? undefined : tryExistingBranch
allowImplicitReuse && !preferExistingBranch ? tryExistingBranch : undefined
)
if (checkoutExistingBranch && !selectedExistingLocalBranchName) {
selectedExistingLocalBranchName = branchName
@@ -0,0 +1,38 @@
import type { RuntimeManagedWorktreeCreateArgs } from './runtime-managed-worktree-create-types'
import { getBranchConflictKind } from '../git/repo'
import { getSelectedReviewBranch } from './selected-review-branch'
import { resolveCreateBranchName } from './runtime-worktree-create-git'
export async function resolveLegacyRemoteBranch(args: {
request: RuntimeManagedWorktreeCreateArgs
repo: { path: string }
sanitizedName: string
settings: { branchPrefix: string; branchPrefixCustom?: string }
username: string
baseBranch: string
localWorktreeGitOptions: { wslDistro?: string }
}): Promise<string | null> {
if (
args.request.branchIntent !== 'open-existing' ||
!args.request.branchNameOverride ||
args.request.pushTarget ||
getSelectedReviewBranch(args.request) !== null
) {
return null
}
const branch = await resolveCreateBranchName(
args.repo.path,
args.request.branchNameOverride,
args.sanitizedName,
args.settings,
args.username,
args.localWorktreeGitOptions
)
const conflict = await getBranchConflictKind(
args.repo.path,
branch,
args.baseBranch,
args.localWorktreeGitOptions
)
return conflict === null ? branch : null
}
@@ -89,7 +89,9 @@ describe('runtime create PR conflict probe', () => {
expect(mocks.remoteBase.mock.calls).toEqual([['/repo', 'origin/main', options]])
expect(mocks.branchName).toHaveBeenCalledOnce()
expect(mocks.branchConflict.mock.calls).toEqual([['/repo', 'main', 'origin/main', options]])
expect(mocks.branchConflict.mock.calls).toEqual([
['/repo', 'main', remoteTrackingBase.ref, options]
])
expect(mocks.pathExists).toHaveBeenCalledTimes(occupied + 1)
expect(mocks.githubPr).not.toHaveBeenCalled()
expect(mocks.consume).not.toHaveBeenCalled()
@@ -100,7 +102,7 @@ describe('runtime create PR conflict probe', () => {
['src'],
remoteTrackingBase.base,
false,
expect.objectContaining({ remoteTrackingBase, ...options })
expect.objectContaining({ remoteTrackingBase, trackRemoteBranch: true, ...options })
)
}
)
@@ -117,21 +119,23 @@ describe('runtime create PR conflict probe', () => {
mocks.pathExists.mockImplementation(async (path) => path === worktreePath)
mocks.consume.mockResolvedValue({ status: 'miss', reason: 'not_prepared' })
if (!reusable) {
await expect(createWorktree(pickedBranch)).rejects.toThrow('was not found locally')
expect(mocks.add).not.toHaveBeenCalled()
expect(mocks.consume).not.toHaveBeenCalled()
return
}
await createWorktree(pickedBranch)
expect(mocks.add).toHaveBeenCalledWith(
'/repo',
resolve('/worktrees', 'app-2'),
reusable ? 'main' : 'main-2',
'main',
'origin/main',
false,
false,
expect.objectContaining(reusable ? { checkoutExistingBranch: true } : {})
expect.objectContaining({ checkoutExistingBranch: true })
)
expect(mocks.githubPr.mock.calls.map(([, branch]) => branch)).toEqual(
reusable ? [] : ['main-2']
)
expect(mocks.add.mock.calls[0][6].checkoutExistingBranch ?? false).toBe(reusable)
expect(mocks.githubPr).not.toHaveBeenCalled()
}
)
@@ -105,7 +105,7 @@ const worktreePath = resolve('/worktrees', 'app')
const pickedBranch = {
baseBranch: 'origin/main',
branchNameOverride: 'main',
branchIntent: 'open-existing'
branchIntent: 'open-existing-v2'
} as const
const writtenMeta: Partial<WorktreeMeta>[] = []
@@ -228,7 +228,9 @@ describe('runtime create PR conflict probe', () => {
expect(mocks.remoteBase.mock.calls).toEqual([['/repo', 'origin/main', options]])
expect(mocks.branchName).toHaveBeenCalledOnce()
expect(mocks.branchConflict.mock.calls).toEqual([['/repo', 'main', 'origin/main', options]])
expect(mocks.branchConflict.mock.calls).toEqual([
['/repo', 'main', remoteTrackingBase.ref, options]
])
expect(mocks.pathExists).toHaveBeenCalledTimes(occupied + 1)
expect(mocks.githubPr).not.toHaveBeenCalled()
expect(mocks.consume).not.toHaveBeenCalled()
@@ -239,7 +241,7 @@ describe('runtime create PR conflict probe', () => {
['src'],
remoteTrackingBase.base,
false,
expect.objectContaining({ remoteTrackingBase, ...options })
expect.objectContaining({ remoteTrackingBase, trackRemoteBranch: true, ...options })
)
}
)
@@ -256,21 +258,23 @@ describe('runtime create PR conflict probe', () => {
mocks.pathExists.mockImplementation(async (path) => path === worktreePath)
mocks.consume.mockResolvedValue({ status: 'miss', reason: 'not_prepared' })
if (!reusable) {
await expect(createWorktree(pickedBranch)).rejects.toThrow('was not found locally')
expect(mocks.add).not.toHaveBeenCalled()
expect(mocks.consume).not.toHaveBeenCalled()
return
}
await createWorktree(pickedBranch)
expect(mocks.add).toHaveBeenCalledWith(
'/repo',
resolve('/worktrees', 'app-2'),
reusable ? 'main' : 'main-2',
'main',
'origin/main',
false,
false,
expect.objectContaining(reusable ? { checkoutExistingBranch: true } : {})
expect.objectContaining({ checkoutExistingBranch: true })
)
expect(mocks.githubPr.mock.calls.map(([, branch]) => branch)).toEqual(
reusable ? [] : ['main-2']
)
expect(mocks.add.mock.calls[0][6].checkoutExistingBranch ?? false).toBe(reusable)
expect(mocks.githubPr).not.toHaveBeenCalled()
}
)
@@ -84,7 +84,7 @@ export const worktreePath = resolve('/worktrees', 'app')
export const pickedBranch = {
baseBranch: 'origin/main',
branchNameOverride: 'main',
branchIntent: 'open-existing'
branchIntent: 'open-existing-v2'
} as const
export const writtenMeta: Partial<WorktreeMeta>[] = []
@@ -290,7 +290,7 @@ describe('runtime create timing', () => {
const timing = createWorktreeCreateTimingRecorder()
mocks.branchName.mockResolvedValue('main')
await createWorktree(pickedBranch, undefined, timing)
await createWorktree({ ...pickedBranch, branchIntent: undefined }, undefined, timing)
expect(mocks.consume).toHaveBeenCalledWith(
expect.objectContaining({ timing, branch: 'main', baseBranch: 'origin/main' })
@@ -310,6 +310,30 @@ describe('runtime create timing', () => {
expect(finished).toMatchObject({ executionHost: 'local', worktreeCount: 3 })
})
it('materializes remote reuse with tracking instead of consuming a prepared checkout', async () => {
mocks.branchName.mockResolvedValue('main')
mocks.remoteBase.mockResolvedValue({
remote: 'origin',
branch: 'main',
base: 'origin/main',
ref: 'refs/remotes/origin/main'
})
await createWorktree(pickedBranch)
expect(mocks.consume).not.toHaveBeenCalled()
expect(mocks.add).toHaveBeenCalledWith(
'/repo',
worktreePath,
'main',
'origin/main',
false,
false,
expect.objectContaining({ trackRemoteBranch: true })
)
expect(writtenMeta).toEqual(
expect.arrayContaining([expect.objectContaining({ preserveBranchOnDelete: true })])
)
})
it('records a sparse create as a miss without consulting the prepared checkout', async () => {
const timing = createWorktreeCreateTimingRecorder()
@@ -165,6 +165,7 @@ async function performRuntimeLocalWorktreeCreate<T>(
username,
store,
baseBranch: base.baseBranch,
remoteTrackingBase: base.remoteTrackingBase,
localWorktreeGitOptions: worktreeGitOptions,
hostedReviewExecutionContext: args.hostedReviewExecutionContext,
pathClaim
@@ -85,7 +85,9 @@ export async function materializeRuntimeLocalWorktree<T>(args: {
orcaCreationWorkspaceLayout: getWorktreeCreationLayout(repo, settings),
...displayNameMeta,
baseRef: metadataBaseRef,
...(checkoutExistingBranch ? { preserveBranchOnDelete: true } : {}),
...(checkoutExistingBranch || request.branchIntent === 'open-existing-v2'
? { preserveBranchOnDelete: true }
: {}),
...(configuredPushTarget ? { pushTarget: configuredPushTarget } : {}),
...(sparseDirectories.length > 0
? {
@@ -2,17 +2,97 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as GitHubClient from '../github/client'
const getPRForBranchMock = vi.hoisted(() => vi.fn())
const git = vi.hoisted(() => vi.fn())
const worktrees = vi.hoisted(() => vi.fn())
vi.mock('../git/runner', () => ({ gitExecFileAsync: git }))
vi.mock('../git/worktree', () => ({ listWorktrees: worktrees }))
vi.mock('../github/client', async (importOriginal) => ({
...(await importOriginal<typeof GitHubClient>()),
getPRForBranch: getPRForBranchMock
}))
import { getLocalGitHubPrForBranch } from './runtime-worktree-create-git'
import {
canCheckoutExistingLocalBranch,
getLocalGitHubPrForBranch
} from './runtime-worktree-create-git'
beforeEach(() => {
getPRForBranchMock.mockReset()
getPRForBranchMock.mockResolvedValue(null)
git.mockReset()
worktrees.mockReset()
})
describe('explicit local branch reuse on the execution host', () => {
it('routes all probes through the WSL distro and does not compare or reset the remote tip', async () => {
git.mockResolvedValueOnce({ stdout: 'local-ahead\n' })
git.mockResolvedValueOnce({ stdout: 'refs/heads/feature/test\n' })
worktrees.mockResolvedValue([])
await expect(
canCheckoutExistingLocalBranch(
'/repo',
'feature/test',
'origin/feature/test',
{ wslDistro: 'Ubuntu' },
true
)
).resolves.toBe(true)
expect(git.mock.calls).toEqual([
[
['rev-parse', '--verify', '--quiet', 'refs/heads/feature/test^{commit}'],
{ cwd: '/repo', wslDistro: 'Ubuntu' }
],
[
['for-each-ref', '--format=%(refname)', 'refs/heads/feature/test'],
{ cwd: '/repo', wslDistro: 'Ubuntu', captureWslLoginShellOutput: true }
]
])
expect(worktrees).toHaveBeenCalledWith('/repo', { wslDistro: 'Ubuntu' })
})
it('rejects a filesystem alias before attempting checkout through WSL', async () => {
git.mockResolvedValueOnce({ stdout: 'local-ahead\n' })
git.mockResolvedValueOnce({ stdout: '' })
await expect(
canCheckoutExistingLocalBranch(
'/repo',
'feature/TEST',
'origin/feature/test',
{ wslDistro: 'Ubuntu' },
true
)
).rejects.toThrow('Select its exact branch name')
expect(worktrees).not.toHaveBeenCalled()
})
it('detects a busy alias recorded by an older checkout through WSL', async () => {
git.mockResolvedValueOnce({ stdout: 'local-ahead\n' })
git.mockResolvedValueOnce({ stdout: 'refs/heads/feature/test\n' })
git.mockResolvedValueOnce({ stdout: '' })
worktrees.mockResolvedValue([{ path: '/busy', branch: 'refs/heads/feature/TEST' }])
await expect(
canCheckoutExistingLocalBranch(
'/repo',
'feature/test',
'origin/feature/test',
{ wslDistro: 'Ubuntu' },
true
)
).rejects.toThrow('already checked out at "/busy"')
expect(git).toHaveBeenLastCalledWith(
['for-each-ref', '--format=%(refname)', 'refs/heads/feature/TEST'],
{ cwd: '/repo', wslDistro: 'Ubuntu', captureWslLoginShellOutput: true }
)
})
it('propagates an unreadable local ref instead of treating it as permission to create', async () => {
git.mockRejectedValue(Object.assign(new Error('permission denied'), { code: 128 }))
await expect(
canCheckoutExistingLocalBranch('/repo', 'feature/test', 'origin/feature/test', {}, true)
).rejects.toThrow('permission denied')
expect(worktrees).not.toHaveBeenCalled()
})
})
describe('getLocalGitHubPrForBranch', () => {
@@ -4,6 +4,7 @@ import type { Repo } from '../../shared/repo-types'
import { getPRForBranch } from '../github/client'
import type { GitAdmissionTier } from '../../shared/rpc-contract/git-admission-tier-params'
import { gitExecFileAsync } from '../git/runner'
import { foldGitRefNameCase } from '../git/repo-branch-conflict'
import { listWorktrees } from '../git/worktree'
import { computeValidatedBranchName } from '../ipc/worktree-branch-name'
import { getHostedReviewForBranch } from '../source-control/hosted-review'
@@ -44,7 +45,8 @@ export async function canCheckoutExistingLocalBranch(
repoPath: string,
branchName: string,
baseBranch: string,
gitOptions: { wslDistro?: string; admissionTier?: GitAdmissionTier } = {}
gitOptions: { wslDistro?: string; admissionTier?: GitAdmissionTier } = {},
explicitReuse = false
): Promise<boolean> {
let localHead = ''
try {
@@ -53,10 +55,31 @@ export async function canCheckoutExistingLocalBranch(
{ cwd: repoPath, ...gitOptions }
)
localHead = stdout.trim()
} catch {
} catch (error) {
if (explicitReuse && !(error instanceof Error && 'code' in error && error.code === 1)) {
throw error
}
return false
}
if (normalizeLocalBranchName(baseBranch) !== branchName) {
if (explicitReuse && !localHead) {
return false
}
const hasExactBranchRef = async (name: string): Promise<boolean> => {
// Loose refs can resolve aliases on case-insensitive filesystems; require Git's stored spelling.
const ref = `refs/heads/${name}`
const { stdout } = await gitExecFileAsync(['for-each-ref', '--format=%(refname)', ref], {
cwd: repoPath,
...gitOptions,
captureWslLoginShellOutput: true
})
return stdout.split(/\r?\n/).includes(ref)
}
if (explicitReuse && !(await hasExactBranchRef(branchName))) {
throw new Error(
`Branch "${branchName}" resolves to a differently named ref. Select its exact branch name, or turn off Reuse branch.`
)
}
if (!explicitReuse && normalizeLocalBranchName(baseBranch) !== branchName) {
if (!localHead) {
return false
}
@@ -73,7 +96,25 @@ export async function canCheckoutExistingLocalBranch(
}
}
const worktrees = await listWorktrees(repoPath, gitOptions)
return !worktrees.some((worktree) => normalizeLocalBranchName(worktree.branch) === branchName)
let busy = worktrees.find((worktree) => normalizeLocalBranchName(worktree.branch) === branchName)
if (explicitReuse && !busy) {
for (const worktree of worktrees) {
const checkedOutBranch = normalizeLocalBranchName(worktree.branch)
if (
foldGitRefNameCase(checkedOutBranch) === foldGitRefNameCase(branchName) &&
!(await hasExactBranchRef(checkedOutBranch))
) {
busy = worktree
break
}
}
}
if (explicitReuse && busy) {
throw new Error(
`Branch "${branchName}" is already checked out at "${busy.path}". Open that workspace, or turn off Reuse branch to create a separate branch.`
)
}
return !busy
}
export function getLocalGitHubPrForBranch(
@@ -187,7 +187,8 @@ export abstract class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWi
this.authoritativeWindowId !== null ||
Boolean(effectiveStartup) ||
Boolean(this.ptyController?.spawn),
warning: includeCopyWarning
warning:
[includeCopyWarning, addResult.upstreamWarning].filter(Boolean).join(' ') || undefined
})
this.invalidateResolvedWorktreeCache()
@@ -165,6 +165,17 @@ describe('NewWorkspaceComposerCard start from', () => {
container = null
})
it('offers opening a known workspace for the reused branch', () => {
const openWorkspace = vi.fn()
container = renderCard({
canReuseSelectedBranch: true,
reuseSelectedBranch: true,
onOpenSelectedBranchWorkspace: openWorkspace
})
clickButton(container, 'Open existing workspace')
expect(openWorkspace).toHaveBeenCalledOnce()
})
it('offers a base ref while a Jira issue names the workspace', () => {
container = renderCard()
@@ -441,7 +441,7 @@ describe('NewWorkspaceComposerCard folder task source mode', () => {
// Reuse stays mounted for height animation; unavailable rows are collapsed and aria-hidden.
current = renderCard({ canReuseSelectedBranch: false })
const collapsedReuse = [...current.container.querySelectorAll('[aria-hidden="true"]')].find(
(el) => el.textContent?.includes('Reuse branch')
(el) => el.textContent?.includes('Existing branch')
)
expect(collapsedReuse).toBeTruthy()
@@ -449,21 +449,19 @@ describe('NewWorkspaceComposerCard folder task source mode', () => {
current = renderCard({ canReuseSelectedBranch: true, reuseSelectedBranch: true })
const reuseLabel = [...current.container.querySelectorAll('label')].find((label) =>
label.textContent?.includes('Reuse branch')
label.textContent?.includes('Existing branch')
)
expect(reuseLabel).toBeTruthy()
expect(reuseLabel?.closest('[aria-hidden="true"]')).toBeNull()
expect(current.container.textContent).toContain(
'Check out the existing branch instead of creating a new one from it.'
)
expect(current.container.textContent).toContain('New commits go on this branch.')
})
it('emits the toggled value from the reuse checkbox in both directions', () => {
const clickReuseCheckbox = (): void => {
it('emits the selected branch choice in both directions', () => {
const chooseBranch = (labelText: string): void => {
const reuseLabel = [...(current?.container.querySelectorAll('label') ?? [])].find((label) =>
label.textContent?.includes('Reuse branch')
label.textContent?.includes(labelText)
)
const checkbox = reuseLabel?.querySelector<HTMLInputElement>('input[type="checkbox"]')
const checkbox = reuseLabel?.querySelector<HTMLButtonElement>('[role="radio"]')
expect(checkbox).toBeTruthy()
act(() => checkbox?.click())
}
@@ -474,7 +472,7 @@ describe('NewWorkspaceComposerCard folder task source mode', () => {
reuseSelectedBranch: true,
onReuseSelectedBranchChange: (next) => offChanges.push(next)
})
clickReuseCheckbox()
chooseBranch('New branch')
expect(offChanges).toEqual([false])
unmountCurrent()
@@ -485,7 +483,7 @@ describe('NewWorkspaceComposerCard folder task source mode', () => {
reuseSelectedBranch: false,
onReuseSelectedBranchChange: (next) => onChanges.push(next)
})
clickReuseCheckbox()
chooseBranch('Existing branch')
expect(onChanges).toEqual([true])
})
@@ -0,0 +1,144 @@
import { useId, useRef } from 'react'
import { ArrowRight } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
type Props = {
existingBranchName?: string | null
reuseSelectedBranch: boolean
onReuseSelectedBranchChange: (next: boolean) => void
branchNameOverride: string | undefined
onBranchNameOverrideChange: (value: string | undefined) => void
onOpenSelectedBranchWorkspace?: () => void
disabled: boolean
}
export function ComposerBranchChoice({
existingBranchName,
reuseSelectedBranch,
onReuseSelectedBranchChange,
branchNameOverride,
onBranchNameOverrideChange,
onOpenSelectedBranchWorkspace,
disabled
}: Props) {
const id = useId()
const branchNameInputRef = useRef<HTMLInputElement>(null)
const showOpenWorkspace = reuseSelectedBranch && Boolean(onOpenSelectedBranchWorkspace)
return (
<fieldset className="min-w-0 space-y-2" disabled={disabled}>
<legend className="text-xs font-medium text-muted-foreground">
{translate('workspace.branchChoice.title', 'Branch for this worktree')}
</legend>
<RadioGroup
aria-label={translate('workspace.branchChoice.title', 'Branch for this worktree')}
value={reuseSelectedBranch ? 'existing' : 'new'}
onValueChange={(value) => onReuseSelectedBranchChange(value === 'existing')}
disabled={disabled}
>
<div
className={cn(
'min-w-0 rounded-md border border-border',
reuseSelectedBranch && 'bg-accent/50 text-accent-foreground'
)}
>
<label className="flex cursor-pointer items-start gap-2 p-3">
<RadioGroupItem
value="existing"
className="mt-0.5"
aria-labelledby={`${id}-existing`}
aria-describedby={
existingBranchName
? `${id}-existing-name ${id}-existing-description`
: `${id}-existing-description`
}
/>
<span className="min-w-0 space-y-1">
<span className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-1">
<span id={`${id}-existing`} className="shrink-0 text-xs font-medium">
{translate('workspace.branchChoice.existing', 'Existing branch')}
</span>
{existingBranchName ? (
<span
id={`${id}-existing-name`}
className="min-w-0 max-w-full break-all font-mono text-xs"
>
{existingBranchName}
</span>
) : null}
</span>
<span
id={`${id}-existing-description`}
className="block text-xs text-muted-foreground"
>
{translate('workspace.branchChoice.existingHint', 'New commits go on this branch.')}
</span>
</span>
</label>
{showOpenWorkspace ? (
<div className="px-3 pb-3 pl-9">
<Button
type="button"
variant="outline"
size="xs"
onClick={onOpenSelectedBranchWorkspace}
>
{translate(
'auto.components.NewWorkspaceComposerCard.openExistingWorkspace',
'Open existing workspace'
)}
<ArrowRight aria-hidden="true" />
</Button>
</div>
) : null}
</div>
<div
className={cn(
'min-w-0 rounded-md border border-border',
!reuseSelectedBranch && 'bg-accent/50 text-accent-foreground'
)}
>
<label className="flex cursor-pointer items-start gap-2 p-3">
<RadioGroupItem
value="new"
onClick={() => branchNameInputRef.current?.focus()}
className="mt-0.5"
aria-labelledby={`${id}-new`}
aria-describedby={`${id}-new-description`}
/>
<span className="min-w-0 space-y-1">
<span id={`${id}-new`} className="block text-xs font-medium">
{translate('workspace.branchChoice.new', 'New branch')}
</span>
<span id={`${id}-new-description`} className="block text-xs text-muted-foreground">
{translate('workspace.branchChoice.newHint', 'Create a new local branch')}
</span>
</span>
</label>
{!reuseSelectedBranch ? (
<div className="space-y-1 px-3 pb-3 pl-9">
<label htmlFor={`${id}-name`} className="text-xs font-medium">
{translate('workspace.branchChoice.newName', 'New branch name')}
</label>
<Input
id={`${id}-name`}
ref={branchNameInputRef}
variant="code"
surface="background"
autoFocus
value={branchNameOverride ?? ''}
onChange={(event) => onBranchNameOverrideChange(event.target.value)}
placeholder={translate('workspace.branchChoice.automaticName', 'Automatic name')}
autoCapitalize="none"
spellCheck={false}
/>
</div>
) : null}
</div>
</RadioGroup>
</fieldset>
)
}
@@ -36,6 +36,7 @@ type NewWorkspaceComposerAdvancedSectionProps = Pick<
| 'onNameValueChange'
| 'selectedRepoIsGit'
| 'branchesEnabled'
| 'canReuseSelectedBranch'
| 'branchNameOverride'
| 'onBranchNameOverrideChange'
| 'parentWorktreeId'
@@ -81,6 +82,7 @@ export function NewWorkspaceComposerAdvancedSection({
onNameValueChange,
selectedRepoIsGit,
branchesEnabled = true,
canReuseSelectedBranch,
branchNameInputId,
branchNameOverride,
onBranchNameOverrideChange,
@@ -185,6 +187,7 @@ export function NewWorkspaceComposerAdvancedSection({
{selectedRepoIsGit &&
branchesEnabled &&
!canReuseSelectedBranch &&
(!smartNameSelection || smartNameSelection.kind === 'branch') ? (
<div className="space-y-1">
<label
@@ -0,0 +1,86 @@
import { useState } from 'react'
import { defineFixture } from '../../component-fixtures/component-fixture'
import { NewWorkspaceComposerNameSection } from './NewWorkspaceComposerNameSection'
function BranchReusePreview({
busy = false,
remote = true,
initialReuse = true,
initialBranchName,
existingBranch = 'feature/nested'
}: {
busy?: boolean
remote?: boolean
initialReuse?: boolean
initialBranchName?: string
existingBranch?: string
}) {
const [reuse, setReuse] = useState(initialReuse)
const [branchName, setBranchName] = useState<string | undefined>(initialBranchName)
const [name, setName] = useState('Feature review')
return (
<div className="rounded-xl border border-border bg-background p-4">
<NewWorkspaceComposerNameSection
eligibleRepos={[]}
repoId="repo"
onRepoChange={() => {}}
name={name}
onNameValueChange={setName}
onSmartGitHubItemSelect={() => {}}
onSmartGitLabItemSelect={() => {}}
onSmartBranchSelect={() => {}}
onSmartLinearIssueSelect={() => {}}
smartNameSelection={{
kind: 'branch',
label: remote ? `origin/${existingBranch}` : existingBranch
}}
onClearSmartNameSelection={() => {}}
selectedRepoRequiresConnection={false}
selectedRepoIsGit
branchesEnabled
forkPushWarning={null}
branchNameOverride={branchName}
onBranchNameOverrideChange={setBranchName}
reuseEligibleBranch={existingBranch}
canReuseSelectedBranch
reuseSelectedBranch={reuse}
onReuseSelectedBranchChange={(next) => {
setReuse(next)
setBranchName(undefined)
}}
onOpenSelectedBranchWorkspace={busy && reuse ? () => {} : undefined}
baseBranch={
remote ? `refs/remotes/origin/${existingBranch}` : `refs/heads/${existingBranch}`
}
onNamePlainEnter={() => {}}
/>
</div>
)
}
export const remoteBranch = defineFixture({ width: 480, render: () => <BranchReusePreview /> })
export const busyBranch = defineFixture({ width: 480, render: () => <BranchReusePreview busy /> })
export const narrow = defineFixture({
width: 320,
render: () => (
<BranchReusePreview busy existingBranch="nwparker/perf-hosted-review-refs-early-reject" />
)
})
export const localBranch = defineFixture({
width: 480,
render: () => <BranchReusePreview remote={false} />
})
export const newBranch = defineFixture({
width: 480,
render: () => <BranchReusePreview key="new-branch" initialReuse={false} />
})
export const namedNewBranch = defineFixture({
width: 480,
render: () => (
<BranchReusePreview
key="named-new"
initialReuse={false}
initialBranchName="feature/my-review"
/>
)
})
@@ -0,0 +1,135 @@
// @vitest-environment happy-dom
import React from 'react'
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { NewWorkspaceComposerNameSection } from './NewWorkspaceComposerNameSection'
vi.mock('@/components/new-workspace/SmartWorkspaceNameField', () => ({
default: ({ value, selectedSource }: { value: string; selectedSource?: { label: string } }) => (
<div>
<span>{selectedSource?.label}</span>
<input aria-label="Workspace name" value={value} readOnly />
</div>
)
}))
function branchProps(): React.ComponentProps<typeof NewWorkspaceComposerNameSection> {
return {
eligibleRepos: [],
repoId: 'repo',
onRepoChange: vi.fn(),
name: 'Payments review',
onNameValueChange: vi.fn(),
onSmartGitHubItemSelect: vi.fn(),
onSmartGitLabItemSelect: vi.fn(),
onSmartBranchSelect: vi.fn(),
onSmartLinearIssueSelect: vi.fn(),
smartNameSelection: { kind: 'branch', label: 'origin/feature/payments' },
onClearSmartNameSelection: vi.fn(),
selectedRepoRequiresConnection: false,
selectedRepoIsGit: true,
branchesEnabled: true,
forkPushWarning: null,
canReuseSelectedBranch: true,
reuseEligibleBranch: 'feature/payments',
branchNameOverride: undefined,
onBranchNameOverrideChange: vi.fn(),
reuseSelectedBranch: true,
onReuseSelectedBranchChange: vi.fn(),
baseBranch: 'refs/remotes/origin/feature/payments',
onNamePlainEnter: vi.fn()
}
}
afterEach(cleanup)
describe('NewWorkspaceComposerNameSection branch reuse', () => {
it('chooses a new branch by pointer and the existing branch by keyboard without changing the source', async () => {
const user = userEvent.setup()
const props = branchProps()
const { rerender } = render(<NewWorkspaceComposerNameSection {...props} />)
expect(screen.getByText('origin/feature/payments')).toBeTruthy()
expect(screen.getByText('feature/payments')).toBeTruthy()
expect(screen.queryByRole('textbox', { name: 'New branch name' })).toBeNull()
expect(
screen.getByRole('radio', { name: 'Existing branch' }).getAttribute('aria-checked')
).toBe('true')
await user.click(screen.getByRole('radio', { name: 'New branch' }))
expect(props.onReuseSelectedBranchChange).toHaveBeenLastCalledWith(false)
rerender(<NewWorkspaceComposerNameSection {...props} reuseSelectedBranch={false} />)
expect(
screen.getByRole('radio', { name: 'Existing branch' }).getAttribute('aria-checked')
).toBe('false')
expect(document.activeElement).toBe(screen.getByRole('textbox', { name: 'New branch name' }))
await user.paste('review/payments')
expect(props.onBranchNameOverrideChange).toHaveBeenLastCalledWith('review/payments')
await user.click(screen.getByRole('radio', { name: 'New branch' }))
expect(document.activeElement).toBe(screen.getByRole('textbox', { name: 'New branch name' }))
screen.getByRole('radio', { name: 'New branch' }).focus()
await user.keyboard('{ArrowUp>}')
await waitFor(() => expect(props.onReuseSelectedBranchChange).toHaveBeenLastCalledWith(true))
await user.keyboard('{/ArrowUp}')
expect(props.onNameValueChange).not.toHaveBeenCalled()
expect(props.onClearSmartNameSelection).not.toHaveBeenCalled()
})
it('opens the existing workspace with the keyboard without submitting or changing branch intent', async () => {
const user = userEvent.setup()
const props = branchProps()
const openWorkspace = vi.fn()
const submit = vi.fn((event: React.FormEvent) => event.preventDefault())
render(
<form onSubmit={submit}>
<NewWorkspaceComposerNameSection {...props} onOpenSelectedBranchWorkspace={openWorkspace} />
</form>
)
const openButton = screen.getByRole('button', { name: 'Open existing workspace' })
await user.tab()
await user.tab()
await user.tab()
expect(document.activeElement).toBe(openButton)
await user.keyboard('{Enter}')
expect(openWorkspace).toHaveBeenCalledOnce()
expect(submit).not.toHaveBeenCalled()
expect(props.onReuseSelectedBranchChange).not.toHaveBeenCalled()
expect(props.onNameValueChange).not.toHaveBeenCalled()
expect(props.onClearSmartNameSelection).not.toHaveBeenCalled()
})
it('removes the open action when the composer clears its existing-workspace target', () => {
const props = branchProps()
const { rerender } = render(
<NewWorkspaceComposerNameSection {...props} onOpenSelectedBranchWorkspace={vi.fn()} />
)
expect(screen.getByRole('button', { name: 'Open existing workspace' })).toBeTruthy()
rerender(<NewWorkspaceComposerNameSection {...props} reuseSelectedBranch={false} />)
expect(screen.queryByRole('button', { name: 'Open existing workspace' })).toBeNull()
expect(
screen.getByRole('radio', { name: 'Existing branch' }).getAttribute('aria-checked')
).toBe('false')
})
it('keeps reuse controls out of the accessible UI for a folder workspace', () => {
render(
<NewWorkspaceComposerNameSection
{...branchProps()}
selectedRepoIsGit={false}
smartNameSelection={null}
canReuseSelectedBranch={false}
reuseSelectedBranch={false}
/>
)
expect(screen.queryByRole('radio', { name: 'Existing branch' })).toBeNull()
expect(screen.queryByRole('button', { name: 'Open existing workspace' })).toBeNull()
expect(screen.getByRole('textbox', { name: 'Workspace name' })).toBeTruthy()
})
})
@@ -1,10 +1,11 @@
import React from 'react'
import { AlertTriangle, Check } from 'lucide-react'
import { AlertTriangle } from 'lucide-react'
import SmartWorkspaceNameField from '@/components/new-workspace/SmartWorkspaceNameField'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import { shouldShowComposerBaseRefPicker } from './base-ref-picker-visibility'
import { ComposerBaseRefPicker } from './ComposerBaseRefPicker'
import { ComposerBranchChoice } from './ComposerBranchChoice'
import type { NewWorkspaceComposerCardProps } from './new-workspace-composer-card-props'
type NewWorkspaceComposerNameSectionProps = Pick<
@@ -38,7 +39,11 @@ type NewWorkspaceComposerNameSectionProps = Pick<
| 'forkPushWarning'
| 'canReuseSelectedBranch'
| 'reuseSelectedBranch'
| 'reuseEligibleBranch'
| 'branchNameOverride'
| 'onBranchNameOverrideChange'
| 'onReuseSelectedBranchChange'
| 'onOpenSelectedBranchWorkspace'
| 'baseBranch'
| 'onBaseBranchChange'
| 'startFromResetHint'
@@ -77,7 +82,11 @@ export function NewWorkspaceComposerNameSection({
forkPushWarning,
canReuseSelectedBranch,
reuseSelectedBranch,
reuseEligibleBranch,
branchNameOverride,
onBranchNameOverrideChange,
onReuseSelectedBranchChange,
onOpenSelectedBranchWorkspace,
baseBranch,
onBaseBranchChange,
startFromResetHint
@@ -154,49 +163,23 @@ export function NewWorkspaceComposerNameSection({
) : null}
<div
className={cn(
'grid overflow-hidden transition-[grid-template-rows] duration-200 ease-out',
'grid overflow-hidden transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none',
canReuseSelectedBranch ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'
)}
aria-hidden={!canReuseSelectedBranch}
inert={!canReuseSelectedBranch}
>
<div className="min-h-0">
<div className="space-y-1 pt-1">
<label className="group flex w-fit items-center gap-2 text-xs text-foreground">
<span
className={cn(
'flex size-4 items-center justify-center rounded-[3px] border shadow-sm transition',
reuseSelectedBranch
? 'border-emerald-500/60 bg-emerald-500 text-white'
: 'border-foreground/20 bg-background dark:border-white/20 dark:bg-muted/10'
)}
>
<Check
className={cn(
'size-3 transition-opacity',
reuseSelectedBranch ? 'opacity-100' : 'opacity-0'
)}
/>
</span>
<input
type="checkbox"
checked={reuseSelectedBranch}
onChange={(event) => onReuseSelectedBranchChange(event.target.checked)}
disabled={!canReuseSelectedBranch}
className="sr-only"
/>
<span>
{translate(
'auto.components.NewWorkspaceComposerCard.reuseExistingBranch',
'Reuse branch'
)}
</span>
</label>
<p className="pl-6 text-[11px] text-muted-foreground">
{translate(
'auto.components.NewWorkspaceComposerCard.reuseExistingBranchHint',
'Check out the existing branch instead of creating a new one from it.'
)}
</p>
<div className="pt-3">
<ComposerBranchChoice
existingBranchName={reuseEligibleBranch ?? smartNameSelection?.label}
reuseSelectedBranch={reuseSelectedBranch}
onReuseSelectedBranchChange={onReuseSelectedBranchChange}
branchNameOverride={branchNameOverride}
onBranchNameOverrideChange={onBranchNameOverrideChange}
onOpenSelectedBranchWorkspace={onOpenSelectedBranchWorkspace}
disabled={!canReuseSelectedBranch}
/>
</div>
</div>
</div>
@@ -95,9 +95,11 @@ export type NewWorkspaceComposerCardProps = {
onOpenJiraSettings?: () => void
smartNameSelection: SmartWorkspaceNameSelection | null
onClearSmartNameSelection: () => void
reuseEligibleBranch?: string | null
canReuseSelectedBranch: boolean
reuseSelectedBranch: boolean
onReuseSelectedBranchChange: (next: boolean) => void
onOpenSelectedBranchWorkspace?: () => void
showCreateMultiple?: boolean
createMultiple?: boolean
onCreateMultipleChange?: (next: boolean) => void
+7 -2
View File
@@ -10,6 +10,11 @@ const inputVariants = cva('', {
default: '',
code: 'font-mono text-xs md:text-xs'
},
surface: {
default: '',
background:
'bg-background text-foreground placeholder:text-muted-foreground dark:bg-background'
},
density: {
default: '',
compact: 'h-7 border-border bg-input/50 px-2 text-xs shadow-none md:text-xs'
@@ -21,7 +26,7 @@ const inputVariants = cva('', {
const Input = React.forwardRef<
HTMLInputElement,
React.ComponentProps<'input'> & VariantProps<typeof inputVariants>
>(({ className, type, variant, density, ...props }, ref) => {
>(({ className, type, variant, surface, density, ...props }, ref) => {
return (
<ImeInput
ref={ref}
@@ -31,7 +36,7 @@ const Input = React.forwardRef<
'h-9 w-full min-w-0 appearance-none rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground/60 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30',
'focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50',
'aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40',
inputVariants({ variant, density }),
inputVariants({ variant, surface, density }),
className
)}
{...props}
@@ -246,7 +246,7 @@ describe('resolveComposerReuseOverride', () => {
).toBeUndefined()
})
it('keeps the override for a remote-only ref even if its local name is busy', () => {
it('drops the branch-off override when the remote branch has a busy local copy', () => {
// Remote-only refs need fresh local tracking branches; local-name busy checks must preserve their override.
expect(
resolveComposerReuseOverride({
@@ -255,17 +255,16 @@ describe('resolveComposerReuseOverride', () => {
branchNameOverride: 'feature-x',
branchCheckedOutElsewhere: true
})
).toBe('feature-x')
).toBeUndefined()
})
})
describe('resolveComposerBranchReuse', () => {
it('reuses a qualified local selector but does not reuse a qualified remote selector', () => {
it('offers reuse for qualified local and remote selectors', () => {
expect(
resolveComposerBranchReuse({
refName: 'refs/heads/feature/加',
localBranchName: 'feature/加',
selectionProducedOverride: true,
branchCheckedOutElsewhere: false
})
).toEqual({ reuseEligibleBranch: 'feature/加', defaultReuse: true })
@@ -273,10 +272,9 @@ describe('resolveComposerBranchReuse', () => {
resolveComposerBranchReuse({
refName: 'refs/remotes/origin/feature/加',
localBranchName: 'feature/加',
selectionProducedOverride: true,
branchCheckedOutElsewhere: false
})
).toEqual({ reuseEligibleBranch: null, defaultReuse: false })
).toEqual({ reuseEligibleBranch: 'feature/加', defaultReuse: true })
expect(
resolveComposerReuseOverride({
refName: 'refs/heads/feature/加',
@@ -291,44 +289,28 @@ describe('resolveComposerBranchReuse', () => {
resolveComposerBranchReuse({
refName: 'feature-x',
localBranchName: 'feature-x',
selectionProducedOverride: true,
branchCheckedOutElsewhere: false
})
).toEqual({ reuseEligibleBranch: 'feature-x', defaultReuse: true })
})
it('does not offer reuse for a remote-only ref (ref carries an origin/ prefix)', () => {
it('offers reuse for a remote-only ref', () => {
expect(
resolveComposerBranchReuse({
refName: 'origin/feature/something',
localBranchName: 'feature/something',
selectionProducedOverride: true,
branchCheckedOutElsewhere: false
})
).toEqual({ reuseEligibleBranch: null, defaultReuse: false })
).toEqual({ reuseEligibleBranch: 'feature/something', defaultReuse: true })
})
it('does not offer reuse when the branch is already checked out in another worktree', () => {
// Why: git refuses a branch in two worktrees, so reuse is impossible here.
it('retains reuse intent for a busy branch so the host can report or open its workspace', () => {
expect(
resolveComposerBranchReuse({
refName: 'feature-x',
localBranchName: 'feature-x',
selectionProducedOverride: true,
branchCheckedOutElsewhere: true
})
).toEqual({ reuseEligibleBranch: null, defaultReuse: false })
})
it('keeps a local branch reuse-eligible but defaults reuse OFF when the user typed a custom name', () => {
// Without an override, custom-name creation branches off the ref; reuse remains opt-in.
expect(
resolveComposerBranchReuse({
refName: 'feature-x',
localBranchName: 'feature-x',
selectionProducedOverride: false,
branchCheckedOutElsewhere: false
})
).toEqual({ reuseEligibleBranch: 'feature-x', defaultReuse: false })
).toEqual({ reuseEligibleBranch: 'feature-x', defaultReuse: true })
})
})
@@ -82,7 +82,9 @@ export type ComposerCardActionProps = {
smartNameJiraSourceContext?: ComposerModel['smartNameJiraSourceContext']
onBaseBranchMrSelect?: ComposerModel['handleBaseBranchMrSelect']
onClearSmartNameSelection: ComposerModel['handleClearSmartNameSelection']
reuseEligibleBranch?: ComposerModel['reuseEligibleBranch']
canReuseSelectedBranch: boolean
onOpenSelectedBranchWorkspace?: () => void
onReuseSelectedBranchChange: ComposerModel['handleReuseSelectedBranchChange']
showCreateMultiple: boolean
onCreateMultipleChange: ComposerModel['setCreateMultiple']
@@ -4,9 +4,22 @@ import { getQuickComposerCreateDisabled } from '@/lib/new-workspace-create-gates
import type { ComposerModel } from './composer-model'
import { resolveComposerAgentRows } from './composer-agent-rows-target'
import type { ComposerCardActionProps, ComposerCardSourceProps } from './composer-card-contract'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { worktreeOwnerHostIds } from '@/lib/worktree-runtime-owner-index'
import { selectWorktreesByRepo } from '@/store/workspace-record-selectors'
import { useAppStore } from '@/store'
export function buildComposerCardProps(state: ComposerModel) {
const attachmentTarget = resolveComposerAttachmentTarget(state)
const targetHostId = attachmentTarget.hostId
const existingBranchWorkspace =
state.reuseSelectedBranch && state.reuseEligibleBranch && targetHostId
? selectWorktreesByRepo(useAppStore.getState())[state.repoId]?.find(
(worktree) =>
worktreeOwnerHostIds(worktree).includes(targetHostId) &&
worktree.branch.replace(/^refs\/heads\//, '') === state.reuseEligibleBranch
)
: undefined
const {
advancedOpen,
agentPrompt,
@@ -171,12 +184,27 @@ export function buildComposerCardProps(state: ComposerModel) {
smartNameMode,
smartNameSelection,
onClearSmartNameSelection: handleClearSmartNameSelection,
reuseEligibleBranch: isProjectGroupTarget ? null : reuseEligibleBranch,
canReuseSelectedBranch:
!isProjectGroupTarget &&
reuseEligibleBranch !== null &&
smartNameSelection?.kind === 'branch',
reuseSelectedBranch,
onReuseSelectedBranchChange: handleReuseSelectedBranchChange,
onOpenSelectedBranchWorkspace:
existingBranchWorkspace && !isProjectGroupTarget
? () => {
if (
activateAndRevealWorktree(existingBranchWorkspace.id, {
executionHostId: attachmentTarget.hostId ?? undefined,
navigationIntent: 'user-open'
}) !== false
) {
state.closeModal()
state.onCreated?.()
}
}
: undefined,
// Create multiple applies to Git worktrees; folder creation still closes afterward.
showCreateMultiple: !isProjectGroupTarget,
createMultiple,
@@ -63,6 +63,7 @@ function useNameOwnership(draft?: { name: string; linkedWorkItem: LinkedWorkItem
forkPushWarning: null,
pushTarget: undefined,
setBranchNameOverride: vi.fn(),
setBranchIntent: vi.fn(),
setBranchNameOverridePreservesNameEdits: vi.fn(),
setCreateError: vi.fn(),
setForkPushWarning: vi.fn(),
@@ -19,6 +19,7 @@ export function useComposerSourceState(
const sourceIdentityActions = useSourceIdentityActions({
applyLinkedWorkItem: external.githubSourceApplication.applyLinkedWorkItem,
branchAutoNameRef: target.asyncComposerState.branchAutoNameRef,
branchIntent: target.workspaceIdentityState.branchIntent,
branchNameOverride: target.workspaceIdentityState.branchNameOverride,
branchNameOverridePreservesNameEdits:
target.workspaceIdentityState.branchNameOverridePreservesNameEdits,
@@ -28,6 +29,7 @@ export function useComposerSourceState(
name: target.sourceContextState.name,
pushTarget: target.workspaceIdentityState.pushTarget,
setBranchNameOverride: target.workspaceIdentityState.setBranchNameOverride,
setBranchIntent: target.workspaceIdentityState.setBranchIntent,
setBranchNameOverridePreservesNameEdits:
target.workspaceIdentityState.setBranchNameOverridePreservesNameEdits,
setCreateError: target.asyncComposerState.setCreateError,
@@ -44,7 +46,6 @@ export function useComposerSourceState(
setLinkedWorkItem: target.sourceContextState.setLinkedWorkItem,
setName: target.sourceContextState.setName,
setPushTarget: target.workspaceIdentityState.setPushTarget,
setReuseEligibleBranch: target.workspaceIdentityState.setReuseEligibleBranch,
setReuseSelectedBranch: target.workspaceIdentityState.setReuseSelectedBranch,
smartSourceSelectionRef: target.asyncComposerState.smartSourceSelectionRef
})
@@ -2,6 +2,7 @@ import type * as React from 'react'
import type { TuiAgent } from '../../../../shared/tui-agent'
import type { GitPushTarget } from '../../../../shared/worktree/types'
import type { SmartNameMode } from '../../../../shared/new-workspace/smart-workspace-source-results'
import type { CreateWorktreeArgs } from '../../../../shared/worktree/create-types'
export type ComposerIdentityModel = {
linkedIssue: string
@@ -19,8 +20,8 @@ export type ComposerIdentityModel = {
compareBaseRef: string | undefined
setCompareBaseRef: React.Dispatch<React.SetStateAction<string | undefined>>
branchNameOverride: string | undefined
branchIntent?: 'open-existing'
setBranchIntent: React.Dispatch<React.SetStateAction<'open-existing' | undefined>>
branchIntent?: CreateWorktreeArgs['branchIntent']
setBranchIntent: React.Dispatch<React.SetStateAction<CreateWorktreeArgs['branchIntent']>>
setBranchNameOverride: React.Dispatch<React.SetStateAction<string | undefined>>
parentWorktreeId: string | null
setParentWorktreeId: React.Dispatch<React.SetStateAction<string | null>>
@@ -39,7 +39,7 @@ it('carries the accepted route across preparation and retains CLI startup/prompt
linkedLinearIssueWorkspaceId: undefined,
linkedLinearIssueOrganizationUrlKey: undefined,
effectiveBranchNameOverride: 'feature/test',
branchIntent: 'open-existing',
branchIntent: 'open-existing-v2',
submitBaseBranch: undefined,
createDisplayName: undefined,
pendingFirstAgentMessageRename: false,
@@ -108,7 +108,7 @@ it('carries the accepted route across preparation and retains CLI startup/prompt
agentLaunchRoute: route,
quickPrompt: 'Fix the checkout',
branchNameOverride: 'feature/test',
branchIntent: 'open-existing'
branchIntent: 'open-existing-v2'
})
if (route === 'terminal-tui') {
expect(request.startup).toMatchObject({
@@ -191,7 +191,8 @@ export function useQuickSubmitPreparation(input: QuickSubmitPreparationInput) {
linkedLinearIssueOrganizationUrlKey,
effectiveBranchNameOverride,
branchIntent:
smartGitHubResolution.kind === 'none' && effectiveBranchNameOverride
smartGitHubResolution.kind === 'none' &&
(effectiveBranchNameOverride || branchIntent === 'create-new')
? branchIntent
: undefined,
submitBaseBranch,
@@ -4,6 +4,7 @@ type SourceIdentityActionsInput = Pick<
ComposerModel,
| 'applyLinkedWorkItem'
| 'branchAutoNameRef'
| 'branchIntent'
| 'branchNameOverride'
| 'branchNameOverridePreservesNameEdits'
| 'forkPushWarning'
@@ -12,6 +13,7 @@ type SourceIdentityActionsInput = Pick<
| 'name'
| 'pushTarget'
| 'setBranchNameOverride'
| 'setBranchIntent'
| 'setBranchNameOverridePreservesNameEdits'
| 'setCreateError'
| 'setForkPushWarning'
@@ -27,7 +29,6 @@ type SourceIdentityActionsInput = Pick<
| 'setLinkedWorkItem'
| 'setName'
| 'setPushTarget'
| 'setReuseEligibleBranch'
| 'setReuseSelectedBranch'
| 'smartSourceSelectionRef'
>
@@ -44,6 +45,7 @@ export function useSourceIdentityActions(input: SourceIdentityActionsInput) {
const {
applyLinkedWorkItem,
branchAutoNameRef,
branchIntent,
branchNameOverride,
branchNameOverridePreservesNameEdits,
forkPushWarning,
@@ -52,6 +54,7 @@ export function useSourceIdentityActions(input: SourceIdentityActionsInput) {
name,
pushTarget,
setBranchNameOverride,
setBranchIntent,
setBranchNameOverridePreservesNameEdits,
setCreateError,
setForkPushWarning,
@@ -67,7 +70,6 @@ export function useSourceIdentityActions(input: SourceIdentityActionsInput) {
setLinkedWorkItem,
setName,
setPushTarget,
setReuseEligibleBranch,
setReuseSelectedBranch,
smartSourceSelectionRef
} = input
@@ -249,10 +251,12 @@ export function useSourceIdentityActions(input: SourceIdentityActionsInput) {
forkPushWarning
})
setBranchNameOverride(next.branchNameOverride)
if (branchIntent === 'create-new') {
setBranchIntent('create-new')
}
setBranchNameOverridePreservesNameEdits(Boolean(next.branchNameOverride))
setPushTarget(next.pushTarget)
setForkPushWarning(next.forkPushWarning)
setReuseEligibleBranch(null)
setReuseSelectedBranch(false)
branchAutoNameRef.current = ''
},
@@ -260,11 +264,12 @@ export function useSourceIdentityActions(input: SourceIdentityActionsInput) {
forkPushWarning,
pushTarget,
branchAutoNameRef,
branchIntent,
setBranchNameOverride,
setBranchIntent,
setBranchNameOverridePreservesNameEdits,
setForkPushWarning,
setPushTarget,
setReuseEligibleBranch,
setReuseSelectedBranch,
smartSourceSelectionRef
]
@@ -1,14 +1,15 @@
// @vitest-environment happy-dom
import { act, cleanup, renderHook } from '@testing-library/react'
import { useRef } from 'react'
import { useRef, useState } from 'react'
import { afterEach, expect, it, vi } from 'vitest'
import { useWorkspaceIdentityState } from './workspace-identity-state'
import { useWorkItemSourceActions } from './work-item-source-actions'
import type { SmartSourceSelection } from './source-selection-decisions'
import { resolveComposerBranchNameOverrideForCreate } from '../../../../shared/composer-branch-selection'
afterEach(cleanup)
it('records existing intent only for a remote picker selection and clears it on Advanced edits', () => {
it('records reuse intent for remote and local selections and clears it on Advanced edits', () => {
const { result } = renderHook(() => {
const identity = useWorkspaceIdentityState({
initialBaseBranch: undefined,
@@ -37,10 +38,65 @@ it('records existing intent only for a remote picker selection and clears it on
})
act(() => result.current.actions.handleSmartBranchSelect('origin/feature/test', 'feature/test'))
expect(result.current.identity.branchIntent).toBe('open-existing')
expect(result.current.identity.branchIntent).toBe('open-existing-v2')
expect(result.current.identity.branchNameOverride).toBe('feature/test')
act(() => result.current.identity.setBranchNameOverride('feature/test'))
expect(result.current.identity.branchIntent).toBeUndefined()
act(() => result.current.actions.handleSmartBranchSelect('feature/test', 'feature/test'))
expect(result.current.identity.branchIntent).toBeUndefined()
expect(result.current.identity.branchIntent).toBe('open-existing-v2')
act(() => result.current.actions.handleReuseSelectedBranchChange(false))
expect(result.current.identity.branchIntent).toBe('create-new')
expect(result.current.identity.branchNameOverride).toBeUndefined()
act(() => result.current.actions.handleReuseSelectedBranchChange(true))
expect(result.current.identity.branchIntent).toBe('open-existing-v2')
expect(result.current.identity.branchNameOverride).toBe('feature/test')
})
it('defaults to the existing remote branch while preserving a custom workspace name', () => {
const { result } = renderHook(() => {
const [name, setName] = useState('Review workspace')
const identity = useWorkspaceIdentityState({
initialBaseBranch: undefined,
initialLinearBranchName: undefined,
initialLinkedWorkItem: null,
linkedWorkItem: null,
linkedWorkItemSeedIdentity: null,
name,
newWorkspaceDraft: null,
persistDraft: false,
selectedRepoConnectionId: null,
selectedRepoRuntimeEnvironmentId: null,
settings: null
})
const actions = useWorkItemSourceActions({
...identity,
branchAutoNameRef: useRef(''),
lastAutoNameRef: useRef(''),
smartSourceSelectionRef: useRef<SmartSourceSelection | null>(null),
name,
repoId: 'repo',
setName,
worktreesByRepo: {}
})
return { identity, actions, name, setName }
})
act(() =>
result.current.actions.handleSmartBranchSelect(
'refs/remotes/origin/feature/test',
'feature/test'
)
)
expect(result.current.name).toBe('Review workspace')
expect(result.current.identity.reuseSelectedBranch).toBe(true)
act(() => result.current.setName('Renamed review workspace'))
expect(result.current.identity.baseBranch).toBe('refs/remotes/origin/feature/test')
expect(result.current.identity.branchIntent).toBe('open-existing-v2')
expect(
resolveComposerBranchNameOverrideForCreate({
branchNameOverride: result.current.identity.branchNameOverride,
branchAutoName: 'feature/test',
workspaceName: result.current.name,
preserveWorkspaceNameEdits: result.current.identity.branchNameOverridePreservesNameEdits
})
).toBe('feature/test')
})
@@ -68,7 +68,6 @@ export function useWorkItemSourceActions(input: WorkItemSourceActionsInput) {
setPushTarget(undefined)
setStartFromResetHint(null)
setForkPushWarning(null)
// Git allows one worktree per branch: visible busy branches cannot reuse (#5181); hidden external conflicts remain backend-checked.
const { reuseEligibleBranch: nextReuseEligibleBranch, defaultReuse } = selection
setReuseEligibleBranch(nextReuseEligibleBranch)
setReuseSelectedBranch(defaultReuse)
@@ -77,18 +76,12 @@ export function useWorkItemSourceActions(input: WorkItemSourceActionsInput) {
setName(selection.name)
lastAutoNameRef.current = selection.lastAutoName
branchAutoNameRef.current = selection.branchNameOverride ? selection.branchAutoName : ''
setBranchNameOverride(selection.branchNameOverride)
setBranchNameOverride(defaultReuse ? (nextReuseEligibleBranch ?? undefined) : undefined)
} else {
setBranchNameOverride(selection.branchNameOverride)
setBranchNameOverride(defaultReuse ? (nextReuseEligibleBranch ?? undefined) : undefined)
branchAutoNameRef.current = selection.branchNameOverride ? selection.branchAutoName : ''
}
if (
selection.branchNameOverride &&
refName !== localBranchName &&
refName !== `refs/heads/${localBranchName}`
) {
setBranchIntent('open-existing')
}
setBranchIntent(defaultReuse ? 'open-existing-v2' : 'create-new')
},
[
name,
@@ -121,6 +114,7 @@ export function useWorkItemSourceActions(input: WorkItemSourceActionsInput) {
// Reuse pins the branch across name edits; opting out restores fresh-branch creation from the ref (#5181).
setBranchNameOverridePreservesNameEdits(next)
setBranchNameOverride(next ? reuseEligibleBranch : undefined)
setBranchIntent(next ? 'open-existing-v2' : 'create-new')
if (next) {
branchAutoNameRef.current = reuseEligibleBranch
}
@@ -129,6 +123,7 @@ export function useWorkItemSourceActions(input: WorkItemSourceActionsInput) {
reuseEligibleBranch,
branchAutoNameRef,
setBranchNameOverride,
setBranchIntent,
setBranchNameOverridePreservesNameEdits,
setReuseSelectedBranch
]
@@ -1,15 +1,22 @@
// @vitest-environment happy-dom
import { act, renderHook } from '@testing-library/react'
import { act, render, renderHook, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { createElement } from 'react'
import { TooltipProvider } from '@/components/ui/tooltip'
import { NewWorkspaceComposerNameSection } from '@/components/new-workspace/NewWorkspaceComposerNameSection'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
import { EMPTY_RETIRED_NAME_REGISTRY } from '../../../shared/worktree/retired-name-registry'
import type { CreateWorktreeResult } from '../../../shared/worktree/create-types'
import { makeWorktree } from '@/store/slices/worktrees-slice-test-fixtures'
import type { Repo } from '../../../shared/repo-types'
import { projectHostSetupProjectionFromRepos } from '../../../shared/project-host-setup-projection'
import { normalizeTaskSourceContext } from '../../../shared/task-source-context'
import { useComposerState } from './useComposerState'
import * as publicComposerDecisions from './useComposerState'
import * as composerDecisions from './composer-state/composer-decisions'
import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-capabilities'
let originalApiDescriptor: PropertyDescriptor | undefined
@@ -123,6 +130,94 @@ describe('useComposerState integrated lifecycle', () => {
composer.unmount()
})
it('preserves new-branch intent through name edits and submission in the real composer', async () => {
const original = useAppStore.getState()
const response = Promise.withResolvers<CreateWorktreeResult>()
const create = vi.fn<Window['api']['worktrees']['create']>().mockReturnValue(response.promise)
const gh = {
repoSlug: vi.fn<Window['api']['gh']['repoSlug']>().mockResolvedValue(null),
listWorkItems: vi.fn<Window['api']['gh']['listWorkItems']>().mockResolvedValue({
items: [],
sources: { issues: null, prs: null, originCandidate: null, upstreamCandidate: null }
})
} satisfies Pick<Window['api']['gh'], 'repoSlug' | 'listWorkItems'>
Object.defineProperty(window, 'api', {
configurable: true,
value: { ...window.api, gh, worktrees: { ...window.api.worktrees, create } }
})
useAppStore.setState(useAppStore.getInitialState(), true)
const repo: Repo = {
id: 'branch-repo',
path: '/branch-repo',
displayName: 'Branch repo',
badgeColor: '',
addedAt: 0
}
const projection = projectHostSetupProjectionFromRepos([repo])
useAppStore.setState({
repos: [repo],
projects: [...projection.projects],
projectHostSetups: [...projection.setups],
sparsePresetsByRepo: { [repo.id]: [] }
})
const holder: { composer?: ReturnType<typeof useComposerState> } = {}
function BranchEditor() {
const composer = useComposerState({ initialRepoId: repo.id, persistDraft: false })
holder.composer = composer
return createElement(NewWorkspaceComposerNameSection, {
...composer.cardProps,
nameInputRef: composer.nameInputRef,
onNamePlainEnter: () => {}
})
}
const user = userEvent.setup()
let view: ReturnType<typeof render> | undefined
setLocalRuntimeCapabilitiesForTests(['worktree.open-existing-branch.v2'])
try {
view = render(createElement(TooltipProvider, null, createElement(BranchEditor)))
act(() => holder.composer?.cardProps.onSmartBranchSelect('feature/source', 'feature/source'))
await user.click(screen.getByRole('radio', { name: 'New branch' }))
const input = screen.getByRole<HTMLInputElement>('textbox', { name: 'New branch name' })
await user.type(input, 'feature/my-review')
expect(screen.getByRole('textbox', { name: 'New branch name' })).toBe(input)
expect(input.value).toBe('feature/my-review')
expect(document.activeElement).toBe(input)
expect(holder.composer?.cardProps.baseBranch).toBe('feature/source')
await user.clear(input)
await user.type(input, 'feature/source')
expect(input.value).toBe('feature/source')
await act(async () => holder.composer?.submitQuick(null))
await waitFor(() => expect(create).toHaveBeenCalledOnce())
expect(create).toHaveBeenCalledWith(
expect.objectContaining({
branchIntent: 'create-new',
baseBranch: 'feature/source',
branchNameOverride: 'feature/source'
})
)
await act(async () =>
response.resolve({
worktree: makeWorktree({
id: 'branch-repo::feature-source',
repoId: repo.id,
path: '/branch-repo-source'
})
})
)
await waitFor(() =>
expect(useAppStore.getState().worktreesByRepo[repo.id]?.[0].id).toBe(
'branch-repo::feature-source'
)
)
await user.click(screen.getByRole('radio', { name: 'Existing branch' }))
expect(screen.queryByRole('textbox', { name: 'New branch name' })).toBeNull()
} finally {
view?.unmount()
setLocalRuntimeCapabilitiesForTests(null)
useAppStore.setState(original, true)
}
})
it('composes two live composers and exposes the parent-worktree control state', () => {
useAppStore.setState({
repos: [],
+10 -2
View File
@@ -2265,8 +2265,7 @@
"estimateValue": "Schätzung {{estimate}}"
},
"NewWorkspaceComposerCard": {
"reuseExistingBranch": "Branch wiederverwenden",
"reuseExistingBranchHint": "Den vorhandenen Branch auschecken, statt daraus einen neuen zu erstellen.",
"openExistingWorkspace": "Bestehenden Arbeitsbereich öffnen",
"createMultiple": "Weitere erstellen",
"cbb47ee0dc": "Nur für lokale Git-Projekte verfügbar.",
"d861de981b": "Partieller Checkout",
@@ -20799,6 +20798,15 @@
"openDefault": "In Standard-App öffnen"
},
"workspace": {
"branchChoice": {
"title": "Branch für diesen Worktree",
"existing": "Bestehender Branch",
"existingHint": "Neue Commits werden diesem Branch hinzugefügt.",
"new": "Neuer Branch",
"newHint": "Einen neuen lokalen Branch erstellen",
"newName": "Name des neuen Branches",
"automaticName": "Automatischer Name"
},
"links": {
"foreignReview": "Dieses Review gehört zu einem anderen Repository. Reviews aus dem Repository dieses Arbeitsbereichs hinzufügen.",
"repositoryLoading": "Die Repository-Identität wird noch geladen. Erneut versuchen, sobald sie verfügbar ist.",
+10 -2
View File
@@ -2281,8 +2281,7 @@
"estimateValue": "Estimate {{estimate}}"
},
"NewWorkspaceComposerCard": {
"reuseExistingBranch": "Reuse branch",
"reuseExistingBranchHint": "Check out the existing branch instead of creating a new one from it.",
"openExistingWorkspace": "Open existing workspace",
"createMultiple": "Create more",
"cbb47ee0dc": "Only available for local Git projects.",
"d861de981b": "Sparse checkout",
@@ -20831,6 +20830,15 @@
"openDefault": "Open in Default App"
},
"workspace": {
"branchChoice": {
"title": "Branch for this worktree",
"existing": "Existing branch",
"existingHint": "New commits go on this branch.",
"new": "New branch",
"newHint": "Create a new local branch",
"newName": "New branch name",
"automaticName": "Automatic name"
},
"links": {
"foreignReview": "This review belongs to another repository. Add reviews from this workspace’s repository.",
"repositoryLoading": "Repository identity is still loading. Try again once it is available.",
+10 -2
View File
@@ -1947,6 +1947,7 @@
"estimateValue": "Estimación {{estimate}}"
},
"NewWorkspaceComposerCard": {
"openExistingWorkspace": "Abrir espacio de trabajo existente",
"cbb47ee0dc": "Solo disponible para proyectos Git locales.",
"d861de981b": "Sparse checkout",
"090cfedeb4": "Escribe una nota",
@@ -1980,8 +1981,6 @@
"notConnected": "No conectado",
"runOn": "Ejecutar en",
"notePasteTooLarge": "El contenido pegado es demasiado grande para el campo de nota.",
"reuseExistingBranch": "Reutilizar rama",
"reuseExistingBranchHint": "Haz checkout de la rama existente en lugar de crear una nueva a partir de ella.",
"createMultiple": "Crear más",
"waitForSetupBeforeAgent": "Esperar a que termine el setup antes de iniciar el agente",
"destroyDisabled": "destroy deshabilitado",
@@ -21165,6 +21164,15 @@
"alwaysForceDeleteBranches": "Eliminar siempre las ramas conservadas"
},
"workspace": {
"branchChoice": {
"title": "Rama para este árbol de trabajo",
"existing": "Rama existente",
"existingHint": "Los nuevos commits se añaden a esta rama.",
"new": "Nueva rama",
"newHint": "Crear una nueva rama local",
"newName": "Nombre de la nueva rama",
"automaticName": "Nombre automático"
},
"links": {
"foreignReview": "Esta revisión pertenece a otro repositorio. Agrega revisiones del repositorio de este espacio de trabajo.",
"repositoryLoading": "La identidad del repositorio aún se está cargando. Vuelve a intentarlo cuando esté disponible.",
+10 -2
View File
@@ -2133,8 +2133,7 @@
"estimateValue": "Estimation {{estimate}}"
},
"NewWorkspaceComposerCard": {
"reuseExistingBranch": "Réutiliser la branche",
"reuseExistingBranchHint": "Faites un checkout de la branche existante au lieu d'en créer une nouvelle à partir de celle-ci.",
"openExistingWorkspace": "Ouvrir l’espace de travail existant",
"createMultiple": "Créer plus",
"cbb47ee0dc": "Disponible uniquement pour les projets Git locaux.",
"d861de981b": "Sparse checkout",
@@ -21165,6 +21164,15 @@
"moreBranchActions": "Plus d'options de suppression de branche"
},
"workspace": {
"branchChoice": {
"title": "Branche pour cet arbre de travail",
"existing": "Branche existante",
"existingHint": "Les nouveaux commits sont ajoutés à cette branche.",
"new": "Nouvelle branche",
"newHint": "Créer une nouvelle branche locale",
"newName": "Nom de la nouvelle branche",
"automaticName": "Nom automatique"
},
"links": {
"foreignReview": "Cette revue appartient à un autre dépôt. Ajoutez des revues du dépôt de cet espace de travail.",
"repositoryLoading": "L'identité du dépôt est encore en cours de chargement. Réessayez dès qu'elle est disponible.",
+10 -2
View File
@@ -1917,6 +1917,7 @@
"estimateValue": "見積もり {{estimate}}"
},
"NewWorkspaceComposerCard": {
"openExistingWorkspace": "既存のワークスペースを開く",
"cbb47ee0dc": "ローカル Git プロジェクトでのみ使用できます。",
"d861de981b": "まばらなチェックアウト",
"090cfedeb4": "メモを書く",
@@ -1950,8 +1951,6 @@
"notConnected": "未接続",
"runOn": "実行先",
"notePasteTooLarge": "メモ欄への貼り付け内容が大きすぎます。",
"reuseExistingBranch": "ブランチを再利用",
"reuseExistingBranchHint": "既存のブランチから新規ブランチを作成せず、そのブランチをチェックアウトします。",
"createMultiple": "さらに作成",
"waitForSetupBeforeAgent": "セットアップが完了するまで待ってから Agent を開始してください",
"destroyDisabled": "破壊は無効です",
@@ -20515,6 +20514,15 @@
"alwaysForceDeleteBranchesTooltip": "将来のワークスペースの削除後に保持されるブランチに適用されます。"
},
"workspace": {
"branchChoice": {
"title": "このワークツリーのブランチ",
"existing": "既存のブランチ",
"existingHint": "新しいコミットはこのブランチに追加されます。",
"new": "新しいブランチ",
"newHint": "新しいローカルブランチを作成",
"newName": "新しいブランチ名",
"automaticName": "自動で命名"
},
"links": {
"foreignReview": "このレビューは別のリポジトリに属しています。このワークスペースのリポジトリのレビューを追加してください。",
"repositoryLoading": "リポジトリの識別情報をまだ読み込んでいます。利用可能になってから再試行してください。",
+10 -2
View File
@@ -1917,6 +1917,7 @@
"estimateValue": "추정치 {{estimate}}"
},
"NewWorkspaceComposerCard": {
"openExistingWorkspace": "기존 워크스페이스 열기",
"cbb47ee0dc": "로컬 Git 프로젝트에만 사용할 수 있습니다.",
"d861de981b": "스파스 체크아웃",
"090cfedeb4": "메모 쓰기",
@@ -1950,8 +1951,6 @@
"notConnected": "연결되지 않음",
"runOn": "실행 위치",
"notePasteTooLarge": "메모 필드에 붙여넣을 내용이 너무 큽니다.",
"reuseExistingBranch": "브랜치 재사용",
"reuseExistingBranchHint": "기존 브랜치에서 새 브랜치를 만들지 않고 해당 브랜치를 체크아웃합니다.",
"createMultiple": "더 만들기",
"waitForSetupBeforeAgent": "agent를 시작하기 전에 설정이 완료될 때까지 기다리세요.",
"destroyDisabled": "삭제 비활성화됨",
@@ -20515,6 +20514,15 @@
"alwaysForceDeleteBranches": "보존된 브랜치 항상 삭제"
},
"workspace": {
"branchChoice": {
"title": "이 워크트리의 브랜치",
"existing": "기존 브랜치",
"existingHint": "새 커밋이 이 브랜치에 추가됩니다.",
"new": "새 브랜치",
"newHint": "새 로컬 브랜치 만들기",
"newName": "새 브랜치 이름",
"automaticName": "자동 이름"
},
"links": {
"foreignReview": "이 리뷰는 다른 리포지토리에 속합니다. 이 워크스페이스의 리포지토리에서 리뷰를 추가하세요.",
"repositoryLoading": "리포지토리 정보를 아직 불러오는 중입니다. 사용할 수 있게 되면 다시 시도하세요.",
+10 -2
View File
@@ -16832,8 +16832,7 @@
"typeHtml": "HTML"
},
"NewWorkspaceComposerCard": {
"reuseExistingBranch": "Reutilizar branch",
"reuseExistingBranchHint": "Faça checkout da branch existente em vez de criar uma nova a partir dela.",
"openExistingWorkspace": "Abrir espaço de trabalho existente",
"createMultiple": "Criar mais",
"cbb47ee0dc": "Disponível apenas para projetos Git locais.",
"d861de981b": "Checkout esparso",
@@ -21111,6 +21110,15 @@
"openDefault": "Abrir no aplicativo padrão"
},
"workspace": {
"branchChoice": {
"title": "Branch para esta árvore de trabalho",
"existing": "Branch existente",
"existingHint": "Novos commits são adicionados a esta branch.",
"new": "Nova branch",
"newHint": "Criar uma nova branch local",
"newName": "Nome da nova branch",
"automaticName": "Nome automático"
},
"links": {
"foreignReview": "Esta revisão pertence a outro repositório. Adicione revisões do repositório deste espaço de trabalho.",
"repositoryLoading": "A identidade do repositório ainda está carregando. Tente novamente quando estiver disponível.",
+10 -2
View File
@@ -2265,8 +2265,7 @@
"estimateValue": "Оценка {{estimate}}"
},
"NewWorkspaceComposerCard": {
"reuseExistingBranch": "Использовать существующую ветку",
"reuseExistingBranchHint": "Переключиться на существующую ветку вместо создания новой на её основе.",
"openExistingWorkspace": "Открыть существующее рабочее пространство",
"createMultiple": "Создать ещё",
"cbb47ee0dc": "Доступно только для локальных Git-проектов.",
"d861de981b": "Выборочная выгрузка",
@@ -21438,6 +21437,15 @@
"openDefault": "Открыть приложением по умолчанию"
},
"workspace": {
"branchChoice": {
"title": "Ветка для этого рабочего дерева",
"existing": "Существующая ветка",
"existingHint": "Новые коммиты добавляются в эту ветку.",
"new": "Новая ветка",
"newHint": "Создать новую локальную ветку",
"newName": "Имя новой ветки",
"automaticName": "Автоматическое имя"
},
"links": {
"foreignReview": "Это ревью относится к другому репозиторию. Добавляйте ревью из репозитория этого рабочего пространства.",
"repositoryLoading": "Данные репозитория ещё загружаются. Повторите попытку после загрузки.",
+10 -2
View File
@@ -2243,8 +2243,7 @@
"estimateValue": "Tahmin {{estimate}}"
},
"NewWorkspaceComposerCard": {
"reuseExistingBranch": "Branch'i yeniden kullan",
"reuseExistingBranchHint": "Ondan yeni bir tane oluşturmak yerine mevcut branch'i checkout edin.",
"openExistingWorkspace": "Mevcut çalışma alanını aç",
"createMultiple": "Daha fazla oluştur",
"cbb47ee0dc": "Yalnızca yerel Git projeleri için kullanılabilir.",
"d861de981b": "Sparse checkout",
@@ -20780,6 +20779,15 @@
"openDefault": "Varsayılan uygulamada aç"
},
"workspace": {
"branchChoice": {
"title": "Bu çalışma ağacı için dal",
"existing": "Mevcut dal",
"existingHint": "Yeni commitler bu dala eklenir.",
"new": "Yeni dal",
"newHint": "Yeni bir yerel dal oluştur",
"newName": "Yeni dal adı",
"automaticName": "Otomatik ad"
},
"links": {
"foreignReview": "Bu inceleme başka bir repo'ya ait. Bu çalışma alanının repo'sundaki incelemeleri ekleyin.",
"repositoryLoading": "Repo kimliği hâlâ yükleniyor. Kullanılabilir olduğunda yeniden deneyin.",
+10 -2
View File
@@ -2239,8 +2239,7 @@
"estimateValue": "估計值 {{estimate}}"
},
"NewWorkspaceComposerCard": {
"reuseExistingBranch": "重複使用分支",
"reuseExistingBranchHint": "檢出已有分支,而不是從它建立新分支。",
"openExistingWorkspace": "開啟現有工作區",
"createMultiple": "建立更多",
"cbb47ee0dc": "僅適用於本機 Git 專案。",
"d861de981b": "稀疏檢出",
@@ -20508,6 +20507,15 @@
"openDefault": "在預設應用程式中開啟"
},
"workspace": {
"branchChoice": {
"title": "此工作樹的分支",
"existing": "現有分支",
"existingHint": "新提交會加入此分支。",
"new": "新分支",
"newHint": "建立新的本機分支",
"newName": "新分支名稱",
"automaticName": "自動命名"
},
"links": {
"foreignReview": "此審查屬於另一個存放庫。請新增來自此工作區存放庫的審查。",
"repositoryLoading": "存放庫身分資訊仍在載入。待其可用後重試。",
+10 -2
View File
@@ -1917,6 +1917,7 @@
"estimateValue": "估计值 {{estimate}}"
},
"NewWorkspaceComposerCard": {
"openExistingWorkspace": "打开现有工作区",
"cbb47ee0dc": "仅适用于本地 Git 项目。",
"d861de981b": "稀疏检出",
"090cfedeb4": "写备注",
@@ -1950,8 +1951,6 @@
"notConnected": "未连接",
"runOn": "运行于",
"notePasteTooLarge": "笔记字段的粘贴内容过大。",
"reuseExistingBranch": "复用分支",
"reuseExistingBranchHint": "检出已有分支,而不是从它创建新分支。",
"createMultiple": "创建更多",
"waitForSetupBeforeAgent": "等待设置完成后再启动智能体",
"destroyDisabled": "已禁用销毁",
@@ -20515,6 +20514,15 @@
"alwaysForceDeleteBranches": "始终删除保留的分支"
},
"workspace": {
"branchChoice": {
"title": "此工作树使用的分支",
"existing": "现有分支",
"existingHint": "新提交将添加到此分支。",
"new": "新分支",
"newHint": "创建新的本地分支",
"newName": "新分支名称",
"automaticName": "自动命名"
},
"links": {
"foreignReview": "此评审属于另一个仓库。请添加来自此工作区仓库的评审。",
"repositoryLoading": "仓库身份仍在加载。待其可用后重试。",
@@ -5,6 +5,7 @@ import type { TuiAgent } from '../../../shared/tui-agent'
import type { WorkspaceSource as WorkspaceCreateTelemetrySource } from '../../../shared/workspace-source'
import type {
CreateSparseCheckoutRequest,
CreateWorktreeArgs,
SetupDecision,
WorktreeCheckoutProgress
} from '../../../shared/worktree/create-types'
@@ -85,7 +86,7 @@ export type WorktreeCreationRequest = {
linkedLinearIssueWorkspaceId?: string | null
linkedLinearIssueOrganizationUrlKey?: string | null
branchNameOverride?: string
branchIntent?: 'open-existing'
branchIntent?: CreateWorktreeArgs['branchIntent']
/** Parent picked in the composer's Advanced drawer. Sidebar nesting only, no git effect. */
parentWorktreeId?: string
workspaceStatus?: WorkspaceStatus
@@ -1,7 +1,19 @@
import { describe, expect, it } from 'vitest'
import { findIndexedWorktreeOwnerForHost } from './worktree-runtime-owner-index'
import {
findIndexedWorktreeOwnerForHost,
worktreeOwnerHostIds
} from './worktree-runtime-owner-index'
describe('worktree runtime owner index', () => {
it('treats a legacy worktree without a host as local', () => {
const legacy = { id: 'legacy', repoId: 'repo-1' }
const worktreesByRepo = { 'repo-1': [legacy] }
expect(findIndexedWorktreeOwnerForHost(worktreesByRepo, legacy.id, 'local')).toBe(legacy)
expect(findIndexedWorktreeOwnerForHost(worktreesByRepo, legacy.id, 'runtime:hub-a')).toBeNull()
expect(worktreeOwnerHostIds(legacy)).toEqual(['local'])
})
it('indexes paired worktrees by both runtime owner and physical host', () => {
const paired = {
id: 'repo-1::same-id',
@@ -16,6 +28,8 @@ describe('worktree runtime owner index', () => {
}
const worktreesByRepo = { 'repo-1': [paired, directSsh] }
expect(worktreeOwnerHostIds(paired)).toEqual(['ssh:private-target', 'runtime:hub-a'])
expect(findIndexedWorktreeOwnerForHost(worktreesByRepo, paired.id, 'runtime:hub-a')).toBe(
paired
)
@@ -1,4 +1,5 @@
import { setLocalRuntimeCapabilitiesForTests } from '../../runtime/local-runtime-capabilities'
import { toast } from 'sonner'
import type { ExecutionHostId } from '../../../../shared/execution-host'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AppState } from '../types'
@@ -35,13 +36,32 @@ vi.mock('@/components/worktree-base-fallback-notice', () => ({
beforeEach(resetWorktreeSliceModuleMemory)
describe('worktree remote runtime mutations', () => {
it('shows a host warning about the push destination after creating the workspace', async () => {
const store = createRepoTestStore()
seedRepoHost(store, 'runtime:env-1')
runtimeEnvironmentCall.mockResolvedValue({
id: 'create',
ok: true,
result: {
worktree: makeWorktree({
id: 'repo1::/path/warning',
repoId: 'repo1',
path: '/path/warning'
}),
warning: 'Check the push destination.'
}
})
await store.getState().createWorktree('repo1', 'warning', 'origin/main', 'skip')
expect(toast.warning).toHaveBeenCalledWith('Check the push destination.')
})
it.each([false, true])(
'gates existing-branch intent on host capability (%s), retaining legacy suffix retry only without it',
'requires strict reuse support before creating on a remote host (%s)',
async (supported) => {
const status = createCompatibleRuntimeStatusResponse('runtime-remote')
if (status.ok && !supported) {
status.result.capabilities = status.result.capabilities?.filter(
(cap) => cap !== 'worktree.open-existing-branch.v1'
(cap) => cap !== 'worktree.open-existing-branch.v2-request.v1'
)
}
runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) =>
@@ -52,7 +72,7 @@ describe('worktree remote runtime mutations', () => {
const create = store.getState().createWorktree
const args: Parameters<typeof create> = ['repo1', 'feature/test', 'origin/main', 'skip']
args[12] = 'feature/test'
args[25] = { branchIntent: 'open-existing' }
args[25] = { branchIntent: 'open-existing-v2' }
runtimeEnvironmentCall.mockRejectedValueOnce(new Error('Branch already has PR #42'))
runtimeEnvironmentCall.mockResolvedValue({
id: 'create',
@@ -66,40 +86,34 @@ describe('worktree remote runtime mutations', () => {
}
})
await (supported
? expect(create(...args)).rejects.toThrow('Branch already has PR #42')
: create(...args))
const first = runtimeEnvironmentCall.mock.calls[0][0].params
if (supported) {
expect(first).toMatchObject({
branchIntent: 'open-existing',
branchNameOverride: 'feature/test'
})
expect(runtimeEnvironmentCall).toHaveBeenCalledTimes(1)
} else {
expect(first).not.toHaveProperty('branchIntent')
expect(runtimeEnvironmentCall).toHaveBeenCalledTimes(2)
expect(runtimeEnvironmentCall.mock.calls[1][0].params).toMatchObject({
branchNameOverride: 'feature/test-2'
})
if (!supported) {
await expect(create(...args)).rejects.toThrow('Update Orca on the workspace host')
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
return
}
await expect(create(...args)).rejects.toThrow('Branch already has PR #42')
expect(runtimeEnvironmentCall.mock.calls[0][0].params).toMatchObject({
branchIntent: 'open-existing-v2',
branchNameOverride: 'feature/test'
})
expect(runtimeEnvironmentCall).toHaveBeenCalledTimes(1)
}
)
it('sends picker intent through the capable local host without suffixing a Git failure', async () => {
setLocalRuntimeCapabilitiesForTests(['worktree.open-existing-branch.v1'])
setLocalRuntimeCapabilitiesForTests(['worktree.open-existing-branch.v2-request.v1'])
try {
const store = createRepoTestStore()
const create = store.getState().createWorktree
const args: Parameters<typeof create> = ['repo1', 'feature/test', 'origin/main', 'skip']
args[12] = 'feature/test'
args[25] = { branchIntent: 'open-existing' }
args[25] = { branchIntent: 'open-existing-v2' }
mockApi.worktrees.create.mockRejectedValueOnce(new Error('Branch already exists locally'))
await expect(create(...args)).rejects.toThrow('Branch already exists locally')
expect(mockApi.worktrees.create).toHaveBeenCalledTimes(1)
expect(mockApi.worktrees.create).toHaveBeenCalledWith(
expect.objectContaining({
branchIntent: 'open-existing',
branchIntent: 'open-existing-v2',
branchNameOverride: 'feature/test'
})
)
@@ -108,6 +122,52 @@ describe('worktree remote runtime mutations', () => {
}
})
it('allows create-new on a host that advertises the previous V2 capability', async () => {
setLocalRuntimeCapabilitiesForTests(['worktree.open-existing-branch.v2'])
try {
const store = createRepoTestStore()
const create = store.getState().createWorktree
const args: Parameters<typeof create> = ['repo1', 'feature', 'origin/main', 'skip']
args[25] = { branchIntent: 'create-new' }
mockApi.worktrees.create.mockResolvedValue({
worktree: makeWorktree({
id: 'repo1::/path/feature',
repoId: 'repo1',
path: '/path/feature'
})
})
await create(...args)
expect(mockApi.worktrees.create).toHaveBeenCalledWith(
expect.objectContaining({ branchIntent: 'create-new' })
)
} finally {
setLocalRuntimeCapabilitiesForTests(null)
}
})
it.each(['open-existing-v2', 'create-new'] as const)(
'refuses %s on a V1-only local host before any mutation',
async (branchIntent) => {
setLocalRuntimeCapabilitiesForTests(['worktree.open-existing-branch.v1'])
try {
const store = createRepoTestStore()
const create = store.getState().createWorktree
const args: Parameters<typeof create> = [
'repo1',
'feature/test',
'origin/feature/test',
'skip'
]
args[12] = 'feature/test'
args[25] = { branchIntent }
await expect(create(...args)).rejects.toThrow('Update Orca on the workspace host')
expect(mockApi.worktrees.create).not.toHaveBeenCalled()
} finally {
setLocalRuntimeCapabilitiesForTests(null)
}
}
)
beforeEach(() => {
vi.clearAllMocks()
resetRemoteRuntimeMocks()
@@ -1,4 +1,5 @@
import { createWorktreeOnRuntimeHost } from './host-planned-agent-launch'
import { toast } from 'sonner'
import { runtimeRpcTargetForAuthorityKey } from '@/runtime/runtime-client-target'
import { repoHostId } from '../listing/worktree-host-ownership'
import {
@@ -25,7 +26,11 @@ import {
import type { RuntimeClientTarget } from '../../../../../../shared/runtime-client-target'
import { runtimeTargetForRepoOwner } from '@/lib/repo-runtime-owner'
import { WORKTREE_LINKED_WORK_ITEM_CONTEXT_RUNTIME_CAPABILITY } from '../../../../../../shared/protocol-version'
import { WORKTREE_OPEN_EXISTING_BRANCH_RUNTIME_CAPABILITY } from '../../../../../../shared/worktree-runtime-capabilities'
import {
WORKTREE_OPEN_EXISTING_BRANCH_RUNTIME_CAPABILITY,
WORKTREE_OPEN_EXISTING_BRANCH_V2_RUNTIME_CAPABILITY,
WORKTREE_OPEN_EXISTING_BRANCH_V2_REQUEST_RUNTIME_CAPABILITY
} from '../../../../../../shared/worktree-runtime-capabilities'
import { ensureLocalRuntimeCapabilities } from '../../../../runtime/local-runtime-capabilities'
import { showLocalBaseRefUpdateSuggestionToast } from '@/components/sidebar/local-base-ref-suggestion-toast'
import { requestWorktreeBaseFallbackNotice } from '@/components/worktree-base-fallback-notice'
@@ -195,19 +200,25 @@ export function createCreateWorktree(
requestedHostId: options?.executionHostId,
resolveOwnHostId: () => repoHostId(get(), repoId)
})
if (options?.branchIntent === 'open-existing') {
if (options?.branchIntent) {
const capability =
options.branchIntent === 'open-existing-v2'
? WORKTREE_OPEN_EXISTING_BRANCH_V2_REQUEST_RUNTIME_CAPABILITY
: options.branchIntent === 'create-new'
? WORKTREE_OPEN_EXISTING_BRANCH_V2_RUNTIME_CAPABILITY
: WORKTREE_OPEN_EXISTING_BRANCH_RUNTIME_CAPABILITY
const supported =
target.kind === 'environment'
? await runtimeEnvironmentSupportsCapability(
target.environmentId,
WORKTREE_OPEN_EXISTING_BRANCH_RUNTIME_CAPABILITY
).catch(() => false)
: (await ensureLocalRuntimeCapabilities())?.includes(
WORKTREE_OPEN_EXISTING_BRANCH_RUNTIME_CAPABILITY
) === true
if (supported) {
request.branchIntent = 'open-existing'
? await runtimeEnvironmentSupportsCapability(target.environmentId, capability).catch(
() => false
)
: (await ensureLocalRuntimeCapabilities())?.includes(capability) === true
if (!supported) {
throw new Error(
'Update Orca on the workspace host to reuse a branch or explicitly create a separate branch.'
)
}
request.branchIntent = options.branchIntent
}
// Why outside the retry loop: a branch-name conflict retry must not re-warn about the same dropped pick.
const parent = resolveWorktreeCreateParent(get(), options?.parentWorktreeId, creationHostId)
@@ -267,6 +278,9 @@ export function createCreateWorktree(
holdCreatedSidebarWorkspace({ worktree: created })
}
const { result } = outcome
if (result.warning) {
toast.warning(result.warning)
}
showLocalBaseRefRefreshToast(result.localBaseRefRefresh, result.worktree, {
updateSettings: get().updateSettings,
executionHostId: creationHostId
@@ -284,7 +298,8 @@ export function createCreateWorktree(
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
const shouldRetry =
request.branchIntent !== 'open-existing' && isRetryableWorktreeCreateConflict(message)
request.branchIntent !== 'open-existing-v2' &&
isRetryableWorktreeCreateConflict(message)
if (!shouldRetry || attempt === CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS - 1) {
throw error
}
+14 -15
View File
@@ -55,38 +55,38 @@ export function getComposerRepoWorktreeBranches(
: []
}
function isLocalBranchSelection(refName: string, localBranchName: string): boolean {
return refName === localBranchName || refName === `refs/heads/${localBranchName}`
function isBranchSelection(refName: string, localBranchName: string): boolean {
return (
refName === localBranchName ||
refName === `refs/heads/${localBranchName}` ||
((!refName.startsWith('refs/') || refName.startsWith('refs/remotes/')) &&
refName.endsWith(`/${localBranchName}`))
)
}
/** Reuse only local branches not checked out elsewhere. Default on when selection auto-derived the workspace name; user-edited names default to branching unless explicitly opting in (#5181). */
/** Reuse pins branch identity independently of the workspace label. Busy branches are checked by the host. */
export function resolveComposerBranchReuse(args: {
refName: string
localBranchName: string
selectionProducedOverride: boolean
branchCheckedOutElsewhere: boolean
}): { reuseEligibleBranch: string | null; defaultReuse: boolean } {
const reuseEligibleBranch =
isLocalBranchSelection(args.refName, args.localBranchName) && !args.branchCheckedOutElsewhere
? args.localBranchName
: null
const reuseEligibleBranch = isBranchSelection(args.refName, args.localBranchName)
? args.localBranchName
: null
return {
reuseEligibleBranch,
defaultReuse: reuseEligibleBranch !== null && args.selectionProducedOverride
defaultReuse: reuseEligibleBranch !== null
}
}
/** Do not pin checked-out local branches as overrides: collision suffixes would silently create another branch. Derive a fresh name from the selected base instead (#5181). */
/** Branch-off creation must not pin a busy branch; explicit reuse supplies its own pinned override. */
export function resolveComposerReuseOverride(args: {
refName: string
localBranchName: string
branchNameOverride: string | undefined
branchCheckedOutElsewhere: boolean
}): string | undefined {
if (
args.branchCheckedOutElsewhere &&
isLocalBranchSelection(args.refName, args.localBranchName)
) {
if (args.branchCheckedOutElsewhere && isBranchSelection(args.refName, args.localBranchName)) {
return undefined
}
return args.branchNameOverride
@@ -112,7 +112,6 @@ export function resolveComposerBranchPick(args: {
const reuse = resolveComposerBranchReuse({
refName: args.refName,
localBranchName: args.localBranchName,
selectionProducedOverride: selection.branchNameOverride !== undefined,
branchCheckedOutElsewhere
})
return {
@@ -35,7 +35,7 @@ export const WorktreeCreate = z
baseBranch: OptionalString,
compareBaseRef: OptionalString,
branchNameOverride: OptionalString,
branchIntent: z.literal('open-existing').optional(),
branchIntent: z.enum(['open-existing', 'open-existing-v2', 'create-new']).optional(),
linkedIssue: TriStateLinkedIssue,
linkedPR: TriStateLinkedIssue,
linkedLinearIssue: z.string().optional(),
@@ -5,6 +5,11 @@ export const WORKTREE_RENAME_ERROR_DISMISS_RUNTIME_CAPABILITY =
export const WORKTREE_OPEN_EXISTING_BRANCH_RUNTIME_CAPABILITY =
'worktree.open-existing-branch.v1' as const
// Older hosts advertised V2 while still accepting the V1 wire value.
export const WORKTREE_OPEN_EXISTING_BRANCH_V2_RUNTIME_CAPABILITY =
'worktree.open-existing-branch.v2' as const
export const WORKTREE_OPEN_EXISTING_BRANCH_V2_REQUEST_RUNTIME_CAPABILITY =
'worktree.open-existing-branch.v2-request.v1' as const
export const PROJECT_HOST_SETUP_RUNTIME_CAPABILITY = 'project-host-setup.v1' as const
// Why: older hosts strip `hostId` from setup update/delete and act on the first same-id twin.
@@ -39,6 +44,8 @@ export const WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY =
export const WORKTREE_RUNTIME_CAPABILITIES = [
WORKTREE_RENAME_ERROR_DISMISS_RUNTIME_CAPABILITY,
WORKTREE_OPEN_EXISTING_BRANCH_RUNTIME_CAPABILITY,
WORKTREE_OPEN_EXISTING_BRANCH_V2_RUNTIME_CAPABILITY,
WORKTREE_OPEN_EXISTING_BRANCH_V2_REQUEST_RUNTIME_CAPABILITY,
PROJECT_HOST_SETUP_RUNTIME_CAPABILITY,
PROJECT_HOST_SETUP_HOST_TARGET_RUNTIME_CAPABILITY,
WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY,
+2 -2
View File
@@ -97,8 +97,8 @@ export type CreateWorktreeArgs = {
compareBaseRef?: string
/** Existing local branches may contain slashes that the filesystem-safe worktree name cannot. */
branchNameOverride?: string
/** Only an explicit existing-branch pick opts out of new-branch allocation. */
branchIntent?: 'open-existing'
/** V1 open-existing retains legacy tip/collision policy; V2 pins explicit reuse regardless of remote divergence. */
branchIntent?: 'open-existing' | 'open-existing-v2' | 'create-new'
setupDecision?: SetupDecision
/** Per-create choice; omitted callers use the execution host's saved default. */
setupOnly?: boolean