From bc55fe3cb0b6dd6bd88a454aed77dc206e64854a Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 28 Oct 2026 20:48:16 -0700 Subject: [PATCH] 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 --- ...ree.runtime-capabilities-status.get-1.json | 124 ++++--- .../goldens/tw-capabilities-advertised.json | 34 +- .../tw-capabilities-cutover-retried.json | 38 +- .../tw-capabilities-legacy-idempotency.json | 12 +- mobile/src/components/NewWorktreeModal.tsx | 8 +- .../SmartWorkspaceAdvancedFields.tsx | 94 +++-- .../use-new-workspace-create-submit.ts | 6 +- mobile/src/i18n/locales/de.json | 12 +- mobile/src/i18n/locales/en.json | 15 +- mobile/src/i18n/locales/es.json | 12 +- mobile/src/i18n/locales/fr.json | 12 +- mobile/src/i18n/locales/ja.json | 12 +- mobile/src/i18n/locales/ko.json | 12 +- mobile/src/i18n/locales/pt.json | 12 +- mobile/src/i18n/locales/ru.json | 12 +- mobile/src/i18n/locales/tr.json | 12 +- mobile/src/i18n/locales/zh-Hant.json | 12 +- mobile/src/i18n/locales/zh.json | 12 +- .../tasks/composer-linked-work-item.test.ts | 4 +- .../src/tasks/source-workspace-create.test.ts | 85 +++++ mobile/src/tasks/source-workspace-create.ts | 16 +- ...use-mobile-composer-name-ownership.test.ts | 25 ++ .../src/tasks/use-mobile-composer-source.ts | 17 +- ...orktree-create-capability-recovery.test.ts | 72 +++- .../tasks/worktree-create-capability.test.ts | 18 + .../src/tasks/worktree-create-capability.ts | 23 +- mobile/src/tasks/worktree-create-retry.ts | 22 +- .../adapters/workspace-settings-mounts.ts | 1 + src/main/git/repo-branch-conflict.ts | 10 +- src/main/git/worktree-add.ts | 29 +- src/main/git/worktree-operation-options.ts | 3 + src/main/ipc/worktree-remote.ts | 131 ++++--- ...worktrees-existing-branch-checkout.test.ts | 167 +++++---- .../rpc/methods/worktree-create-args.test.ts | 6 +- .../runtime-branch-reuse-real-git.test.ts | 340 ++++++++++++++++++ ...untime-local-create-rearm-ordering.test.ts | 25 +- .../runtime-local-git-worktree-create.ts | 11 +- ...runtime-local-worktree-create-candidate.ts | 60 +++- ...ime-local-worktree-create-legacy-branch.ts | 38 ++ ...-local-worktree-create-pr-conflict.test.ts | 22 +- ...local-worktree-create-pr-conflicts.test.ts | 24 +- ...time-local-worktree-create-test-harness.ts | 2 +- .../runtime-local-worktree-create.test.ts | 26 +- .../runtime/runtime-local-worktree-create.ts | 1 + .../runtime-local-worktree-materialization.ts | 4 +- .../runtime-worktree-create-git.test.ts | 82 ++++- .../runtime/runtime-worktree-create-git.ts | 49 ++- .../runtime/workspaces/workspace-creation.ts | 3 +- ...wWorkspaceComposerCard.start-from.test.tsx | 11 + .../NewWorkspaceComposerCard.test.tsx | 20 +- .../new-workspace/ComposerBranchChoice.tsx | 144 ++++++++ .../NewWorkspaceComposerAdvancedSection.tsx | 3 + ...ewWorkspaceComposerNameSection.fixture.tsx | 86 +++++ .../NewWorkspaceComposerNameSection.test.tsx | 135 +++++++ .../NewWorkspaceComposerNameSection.tsx | 61 ++-- .../new-workspace-composer-card-props.ts | 2 + src/renderer/src/components/ui/input.tsx | 9 +- .../hooks/composer-branch-selection.test.ts | 34 +- .../composer-state/composer-card-contract.ts | 2 + .../composer-state/composer-card-props.ts | 28 ++ .../composer-name-ownership.test.ts | 1 + .../composer-state/composer-source-state.ts | 3 +- .../hooks/composer-state/identity-model.ts | 5 +- .../quick-creation-execution.test.ts | 4 +- .../quick-submit-preparation.ts | 3 +- .../composer-state/source-identity-actions.ts | 13 +- .../work-item-source-actions.test.ts | 64 +++- .../work-item-source-actions.ts | 15 +- .../useComposerState.integration.test.ts | 97 ++++- src/renderer/src/i18n/locales/de.json | 12 +- src/renderer/src/i18n/locales/en.json | 12 +- src/renderer/src/i18n/locales/es.json | 12 +- src/renderer/src/i18n/locales/fr.json | 12 +- src/renderer/src/i18n/locales/ja.json | 12 +- src/renderer/src/i18n/locales/ko.json | 12 +- src/renderer/src/i18n/locales/pt.json | 12 +- src/renderer/src/i18n/locales/ru.json | 12 +- src/renderer/src/i18n/locales/tr.json | 12 +- src/renderer/src/i18n/locales/zh-Hant.json | 12 +- src/renderer/src/i18n/locales/zh.json | 12 +- .../src/lib/pending-worktree-creation.ts | 3 +- .../lib/worktree-runtime-owner-index.test.ts | 16 +- .../worktrees-remote-runtime-create.test.ts | 104 ++++-- .../worktrees/create/create-worktree.ts | 39 +- src/shared/composer-branch-selection.ts | 29 +- .../rpc-contract/worktree-create-params.ts | 2 +- src/shared/worktree-runtime-capabilities.ts | 7 + src/shared/worktree/create-types.ts | 4 +- 88 files changed, 2313 insertions(+), 530 deletions(-) create mode 100644 src/main/runtime/runtime-branch-reuse-real-git.test.ts create mode 100644 src/main/runtime/runtime-local-worktree-create-legacy-branch.ts create mode 100644 src/renderer/src/components/new-workspace/ComposerBranchChoice.tsx create mode 100644 src/renderer/src/components/new-workspace/NewWorkspaceComposerNameSection.fixture.tsx create mode 100644 src/renderer/src/components/new-workspace/NewWorkspaceComposerNameSection.test.tsx diff --git a/mobile/rpc-foundation/goldens/matrix-worktree.runtime-capabilities-status.get-1.json b/mobile/rpc-foundation/goldens/matrix-worktree.runtime-capabilities-status.get-1.json index f6cd0bde235..1208fd130a2 100644 --- a/mobile/rpc-foundation/goldens/matrix-worktree.runtime-capabilities-status.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-worktree.runtime-capabilities-status.get-1.json @@ -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": [] } }, diff --git a/mobile/rpc-foundation/goldens/tw-capabilities-advertised.json b/mobile/rpc-foundation/goldens/tw-capabilities-advertised.json index 685bf3dad64..76ab3e03a1e 100644 --- a/mobile/rpc-foundation/goldens/tw-capabilities-advertised.json +++ b/mobile/rpc-foundation/goldens/tw-capabilities-advertised.json @@ -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": [] } } diff --git a/mobile/rpc-foundation/goldens/tw-capabilities-cutover-retried.json b/mobile/rpc-foundation/goldens/tw-capabilities-cutover-retried.json index 2c99de3ceb4..faef552a71e 100644 --- a/mobile/rpc-foundation/goldens/tw-capabilities-cutover-retried.json +++ b/mobile/rpc-foundation/goldens/tw-capabilities-cutover-retried.json @@ -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": [] } } diff --git a/mobile/rpc-foundation/goldens/tw-capabilities-legacy-idempotency.json b/mobile/rpc-foundation/goldens/tw-capabilities-legacy-idempotency.json index 3681c1ff7c5..7ead5211d8b 100644 --- a/mobile/rpc-foundation/goldens/tw-capabilities-legacy-idempotency.json +++ b/mobile/rpc-foundation/goldens/tw-capabilities-legacy-idempotency.json @@ -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": [] } } diff --git a/mobile/src/components/NewWorktreeModal.tsx b/mobile/src/components/NewWorktreeModal.tsx index 2c2eec2f9af..d236ad29434 100644 --- a/mobile/src/components/NewWorktreeModal.tsx +++ b/mobile/src/components/NewWorktreeModal.tsx @@ -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, diff --git a/mobile/src/components/SmartWorkspaceAdvancedFields.tsx b/mobile/src/components/SmartWorkspaceAdvancedFields.tsx index 88481aaee9e..f20423bd50a 100644 --- a/mobile/src/components/SmartWorkspaceAdvancedFields.tsx +++ b/mobile/src/components/SmartWorkspaceAdvancedFields.tsx @@ -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(null) const selection = composer.smartNameSelection const showBranchOverride = selectedRepoIsGit && (!selection || selection.kind === 'branch') return ( @@ -39,7 +42,7 @@ export function SmartWorkspaceAdvancedFields({ composer, selectedRepoIsGit }: Pr ) : null} - {showBranchOverride ? ( + {showBranchOverride && !composer.reuseEligibleBranch ? ( {t('components.smartWorkspaceAdvancedFields.branchName', 'Branch name')} @@ -61,24 +64,52 @@ export function SmartWorkspaceAdvancedFields({ composer, selectedRepoIsGit }: Pr {composer.reuseEligibleBranch ? ( - - - {t( - 'components.smartWorkspaceAdvancedFields.reuseBranch', - 'Reuse branch “{{branch}}”', - { - branch: composer.reuseEligibleBranch - } - )} - - - + + {t('workspace.branchChoice.title', 'Branch for this worktree')} + + { + composer.setReuseSelectedBranch(value === 'existing') + if (value === 'new') { + branchNameInputRef.current?.focus() + } + }} + fill + /> + {composer.reuseSelectedBranch ? ( + + {composer.reuseEligibleBranch} + + {t('workspace.branchChoice.existingHint', 'New commits go on this branch.')} + + + ) : ( + + + {t('workspace.branchChoice.newHint', 'Create a new local branch')} + + + {t('workspace.branchChoice.newName', 'New branch name')} + + + + )} ) : 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 }] } })) diff --git a/mobile/src/components/use-new-workspace-create-submit.ts b/mobile/src/components/use-new-workspace-create-submit.ts index 6af4ff98809..874dd851cd2 100644 --- a/mobile/src/components/use-new-workspace-create-submit.ts +++ b/mobile/src/components/use-new-workspace-create-submit.ts @@ -59,6 +59,7 @@ export function useNewWorkspaceCreateSubmit(args: { setTrustedOrcaHooks: (trust: PersistedTrustedOrcaHooks) => void getWorktreeCreateCutoverSupport: () => Promise getAgentLaunchSupport: () => Promise + getBranchIntentSupport: (intent: 'open-existing-v2' | 'create-new') => Promise transitionDrawer: (view: Exclude) => void setError: Dispatch> 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, diff --git a/mobile/src/i18n/locales/de.json b/mobile/src/i18n/locales/de.json index 1b0a848bf79..63cb00ac86a 100644 --- a/mobile/src/i18n/locales/de.json +++ b/mobile/src/i18n/locales/de.json @@ -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" + } } } diff --git a/mobile/src/i18n/locales/en.json b/mobile/src/i18n/locales/en.json index cdd07fb9ca4..defe98f7d5f 100644 --- a/mobile/src/i18n/locales/en.json +++ b/mobile/src/i18n/locales/en.json @@ -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." + } } } diff --git a/mobile/src/i18n/locales/es.json b/mobile/src/i18n/locales/es.json index e6c7fc48c92..3356a08fd1a 100644 --- a/mobile/src/i18n/locales/es.json +++ b/mobile/src/i18n/locales/es.json @@ -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" + } } } diff --git a/mobile/src/i18n/locales/fr.json b/mobile/src/i18n/locales/fr.json index 0df0623a177..4091f6cac84 100644 --- a/mobile/src/i18n/locales/fr.json +++ b/mobile/src/i18n/locales/fr.json @@ -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" + } } } diff --git a/mobile/src/i18n/locales/ja.json b/mobile/src/i18n/locales/ja.json index 283784ea309..2d10f4241e7 100644 --- a/mobile/src/i18n/locales/ja.json +++ b/mobile/src/i18n/locales/ja.json @@ -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": "自動で命名" + } } } diff --git a/mobile/src/i18n/locales/ko.json b/mobile/src/i18n/locales/ko.json index 840d75d5656..e05fb4de30b 100644 --- a/mobile/src/i18n/locales/ko.json +++ b/mobile/src/i18n/locales/ko.json @@ -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": "자동 이름" + } } } diff --git a/mobile/src/i18n/locales/pt.json b/mobile/src/i18n/locales/pt.json index a6361840ed1..d2a34ba5543 100644 --- a/mobile/src/i18n/locales/pt.json +++ b/mobile/src/i18n/locales/pt.json @@ -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" + } } } diff --git a/mobile/src/i18n/locales/ru.json b/mobile/src/i18n/locales/ru.json index 49dc7a0db0e..12699944c2c 100644 --- a/mobile/src/i18n/locales/ru.json +++ b/mobile/src/i18n/locales/ru.json @@ -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": "Автоматическое имя" + } } } diff --git a/mobile/src/i18n/locales/tr.json b/mobile/src/i18n/locales/tr.json index e9a5be0e07f..5b8c60a60e8 100644 --- a/mobile/src/i18n/locales/tr.json +++ b/mobile/src/i18n/locales/tr.json @@ -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" + } } } diff --git a/mobile/src/i18n/locales/zh-Hant.json b/mobile/src/i18n/locales/zh-Hant.json index 51e9fe44be7..fc95994c6a4 100644 --- a/mobile/src/i18n/locales/zh-Hant.json +++ b/mobile/src/i18n/locales/zh-Hant.json @@ -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": "自動命名" + } } } diff --git a/mobile/src/i18n/locales/zh.json b/mobile/src/i18n/locales/zh.json index b1cfa6e0296..49e8d28adab 100644 --- a/mobile/src/i18n/locales/zh.json +++ b/mobile/src/i18n/locales/zh.json @@ -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": "自动命名" + } } } diff --git a/mobile/src/tasks/composer-linked-work-item.test.ts b/mobile/src/tasks/composer-linked-work-item.test.ts index faf5d80cd73..7b26e64226b 100644 --- a/mobile/src/tasks/composer-linked-work-item.test.ts +++ b/mobile/src/tasks/composer-linked-work-item.test.ts @@ -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() }) }) diff --git a/mobile/src/tasks/source-workspace-create.test.ts b/mobile/src/tasks/source-workspace-create.test.ts index f460ac05144..3e7a5e1d74e 100644 --- a/mobile/src/tasks/source-workspace-create.test.ts +++ b/mobile/src/tasks/source-workspace-create.test.ts @@ -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 } @@ -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) diff --git a/mobile/src/tasks/source-workspace-create.ts b/mobile/src/tasks/source-workspace-create.ts index 2136a048355..a084edb0db2 100644 --- a/mobile/src/tasks/source-workspace-create.ts +++ b/mobile/src/tasks/source-workspace-create.ts @@ -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 } export async function createWorkspaceFromComposerSource( @@ -173,6 +175,7 @@ async function createBranchWorkspace(args: { note: string | undefined worktreeCreateIdempotency: WorktreeCreateIdempotencyProbe agentLaunchSupported: WorktreeCreateAgentLaunch['supported'] + branchIntentSupported?: boolean | Promise }): Promise { 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) } diff --git a/mobile/src/tasks/use-mobile-composer-name-ownership.test.ts b/mobile/src/tasks/use-mobile-composer-name-ownership.test.ts index 270a0439eba..41d8058073c 100644 --- a/mobile/src/tasks/use-mobile-composer-name-ownership.test.ts +++ b/mobile/src/tasks/use-mobile-composer-name-ownership.test.ts @@ -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', diff --git a/mobile/src/tasks/use-mobile-composer-source.ts b/mobile/src/tasks/use-mobile-composer-source.ts index 5574b313079..eada3a05f2e 100644 --- a/mobile/src/tasks/use-mobile-composer-source.ts +++ b/mobile/src/tasks/use-mobile-composer-source.ts @@ -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(null) - const [base, setBase] = useState(EMPTY_BASE) + const [base, setBase] = useState({}) const [reuseEligibleBranch, setReuseEligibleBranch] = useState(null) const [reuseSelectedBranch, setReuseSelectedBranch] = useState(false) const [forkPushWarning, setForkPushWarning] = useState(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, diff --git a/mobile/src/tasks/worktree-create-capability-recovery.test.ts b/mobile/src/tasks/worktree-create-capability-recovery.test.ts index 9d88f433abb..cfbc73b0358 100644 --- a/mobile/src/tasks/worktree-create-capability-recovery.test.ts +++ b/mobile/src/tasks/worktree-create-capability-recovery.test.ts @@ -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> = { 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> = { @@ -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>) => 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() diff --git a/mobile/src/tasks/worktree-create-capability.test.ts b/mobile/src/tasks/worktree-create-capability.test.ts index 51af0e1a7b7..03125ae3f9f 100644 --- a/mobile/src/tasks/worktree-create-capability.test.ts +++ b/mobile/src/tasks/worktree-create-capability.test.ts @@ -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, diff --git a/mobile/src/tasks/worktree-create-capability.ts b/mobile/src/tasks/worktree-create-capability.ts index 2e6d207edc4..87b45e0fc78 100644 --- a/mobile/src/tasks/worktree-create-capability.ts +++ b/mobile/src/tasks/worktree-create-capability.ts @@ -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 getAgentLaunchSupport: () => Promise + getBranchIntentSupport: (intent: 'open-existing-v2' | 'create-new') => Promise } { 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 } } diff --git a/mobile/src/tasks/worktree-create-retry.ts b/mobile/src/tasks/worktree-create-retry.ts index 2aa081b6938..e2c2d5050ac 100644 --- a/mobile/src/tasks/worktree-create-retry.ts +++ b/mobile/src/tasks/worktree-create-retry.ts @@ -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 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 } } diff --git a/mobile/src/test-support/rpc-recording/adapters/workspace-settings-mounts.ts b/mobile/src/test-support/rpc-recording/adapters/workspace-settings-mounts.ts index 3a5ef735c9c..53a17673548 100644 --- a/mobile/src/test-support/rpc-recording/adapters/workspace-settings-mounts.ts +++ b/mobile/src/test-support/rpc-recording/adapters/workspace-settings-mounts.ts @@ -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), diff --git a/src/main/git/repo-branch-conflict.ts b/src/main/git/repo-branch-conflict.ts index 3c79e16defe..63408b405d2 100644 --- a/src/main/git/repo-branch-conflict.ts +++ b/src/main/git/repo-branch-conflict.ts @@ -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, diff --git a/src/main/git/worktree-add.ts b/src/main/git/worktree-add.ts index f4d3874d258..90a958fb698 100644 --- a/src/main/git/worktree-add.ts +++ b/src/main/git/worktree-add.ts @@ -157,9 +157,9 @@ export async function trackSameNameRemoteBase( baseBranch: string, effectiveBase: string, options: AddWorktreeOptions -): Promise { +): Promise { 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 } : {}) } } diff --git a/src/main/git/worktree-operation-options.ts b/src/main/git/worktree-operation-options.ts index 72387782dac..fed997eb5c7 100644 --- a/src/main/git/worktree-operation-options.ts +++ b/src/main/git/worktree-operation-options.ts @@ -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 diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index e5e16b1d379..181c1c2be2a 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -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 { - 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> | 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(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() } } diff --git a/src/main/ipc/worktrees-existing-branch-checkout.test.ts b/src/main/ipc/worktrees-existing-branch-checkout.test.ts index 8dd4d9b43d7..690283f48c1 100644 --- a/src/main/ipc/worktrees-existing-branch-checkout.test.ts +++ b/src/main/ipc/worktrees-existing-branch-checkout.test.ts @@ -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) => diff --git a/src/main/runtime/rpc/methods/worktree-create-args.test.ts b/src/main/runtime/rpc/methods/worktree-create-args.test.ts index 883fb481b54..08898b1fe3e 100644 --- a/src/main/runtime/rpc/methods/worktree-create-args.test.ts +++ b/src/main/runtime/rpc/methods/worktree-create-args.test.ts @@ -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', () => { diff --git a/src/main/runtime/runtime-branch-reuse-real-git.test.ts b/src/main/runtime/runtime-branch-reuse-real-git.test.ts new file mode 100644 index 00000000000..fd133bcaec7 --- /dev/null +++ b/src/main/runtime/runtime-branch-reuse-real-git.test.ts @@ -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 { + 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 = {}) => { + 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 = {}) => { + 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) + }) +}) diff --git a/src/main/runtime/runtime-local-create-rearm-ordering.test.ts b/src/main/runtime/runtime-local-create-rearm-ordering.test.ts index 34603f0a704..79ff1c47418 100644 --- a/src/main/runtime/runtime-local-create-rearm-ordering.test.ts +++ b/src/main/runtime/runtime-local-create-rearm-ordering.test.ts @@ -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() diff --git a/src/main/runtime/runtime-local-git-worktree-create.ts b/src/main/runtime/runtime-local-git-worktree-create.ts index ce56cdbe038..9994a3a150a 100644 --- a/src/main/runtime/runtime-local-git-worktree-create.ts +++ b/src/main/runtime/runtime-local-git-worktree-create.ts @@ -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' diff --git a/src/main/runtime/runtime-local-worktree-create-candidate.ts b/src/main/runtime/runtime-local-worktree-create-candidate.ts index fc7510ce984..d42ab18c894 100644 --- a/src/main/runtime/runtime-local-worktree-create-candidate.ts +++ b/src/main/runtime/runtime-local-worktree-create-candidate.ts @@ -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 => { 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 diff --git a/src/main/runtime/runtime-local-worktree-create-legacy-branch.ts b/src/main/runtime/runtime-local-worktree-create-legacy-branch.ts new file mode 100644 index 00000000000..f5d11d0cdaa --- /dev/null +++ b/src/main/runtime/runtime-local-worktree-create-legacy-branch.ts @@ -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 { + 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 +} diff --git a/src/main/runtime/runtime-local-worktree-create-pr-conflict.test.ts b/src/main/runtime/runtime-local-worktree-create-pr-conflict.test.ts index 222714dc048..76ced412881 100644 --- a/src/main/runtime/runtime-local-worktree-create-pr-conflict.test.ts +++ b/src/main/runtime/runtime-local-worktree-create-pr-conflict.test.ts @@ -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() } ) diff --git a/src/main/runtime/runtime-local-worktree-create-pr-conflicts.test.ts b/src/main/runtime/runtime-local-worktree-create-pr-conflicts.test.ts index 13232794c30..54ea1a1a9cb 100644 --- a/src/main/runtime/runtime-local-worktree-create-pr-conflicts.test.ts +++ b/src/main/runtime/runtime-local-worktree-create-pr-conflicts.test.ts @@ -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[] = [] @@ -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() } ) diff --git a/src/main/runtime/runtime-local-worktree-create-test-harness.ts b/src/main/runtime/runtime-local-worktree-create-test-harness.ts index c25f00ff34e..ede95c23f89 100644 --- a/src/main/runtime/runtime-local-worktree-create-test-harness.ts +++ b/src/main/runtime/runtime-local-worktree-create-test-harness.ts @@ -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[] = [] diff --git a/src/main/runtime/runtime-local-worktree-create.test.ts b/src/main/runtime/runtime-local-worktree-create.test.ts index b53252d47ce..57981b56cb1 100644 --- a/src/main/runtime/runtime-local-worktree-create.test.ts +++ b/src/main/runtime/runtime-local-worktree-create.test.ts @@ -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() diff --git a/src/main/runtime/runtime-local-worktree-create.ts b/src/main/runtime/runtime-local-worktree-create.ts index 31a421a20a9..d7ca1c24623 100644 --- a/src/main/runtime/runtime-local-worktree-create.ts +++ b/src/main/runtime/runtime-local-worktree-create.ts @@ -165,6 +165,7 @@ async function performRuntimeLocalWorktreeCreate( username, store, baseBranch: base.baseBranch, + remoteTrackingBase: base.remoteTrackingBase, localWorktreeGitOptions: worktreeGitOptions, hostedReviewExecutionContext: args.hostedReviewExecutionContext, pathClaim diff --git a/src/main/runtime/runtime-local-worktree-materialization.ts b/src/main/runtime/runtime-local-worktree-materialization.ts index 8af73de28e9..1d6973be7c0 100644 --- a/src/main/runtime/runtime-local-worktree-materialization.ts +++ b/src/main/runtime/runtime-local-worktree-materialization.ts @@ -85,7 +85,9 @@ export async function materializeRuntimeLocalWorktree(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 ? { diff --git a/src/main/runtime/runtime-worktree-create-git.test.ts b/src/main/runtime/runtime-worktree-create-git.test.ts index 8fe1027b2bd..47ff892606d 100644 --- a/src/main/runtime/runtime-worktree-create-git.test.ts +++ b/src/main/runtime/runtime-worktree-create-git.test.ts @@ -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()), 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', () => { diff --git a/src/main/runtime/runtime-worktree-create-git.ts b/src/main/runtime/runtime-worktree-create-git.ts index 376beeb814b..926cffbeb36 100644 --- a/src/main/runtime/runtime-worktree-create-git.ts +++ b/src/main/runtime/runtime-worktree-create-git.ts @@ -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 { 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 => { + // 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( diff --git a/src/main/runtime/workspaces/workspace-creation.ts b/src/main/runtime/workspaces/workspace-creation.ts index 0b02d02c089..7065bdb4451 100644 --- a/src/main/runtime/workspaces/workspace-creation.ts +++ b/src/main/runtime/workspaces/workspace-creation.ts @@ -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() diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.start-from.test.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.start-from.test.tsx index 7fa8caef83b..27c59a1aae1 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.start-from.test.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.start-from.test.tsx @@ -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() diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx index 9e1470260e2..b4a79b06be6 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx @@ -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('input[type="checkbox"]') + const checkbox = reuseLabel?.querySelector('[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]) }) diff --git a/src/renderer/src/components/new-workspace/ComposerBranchChoice.tsx b/src/renderer/src/components/new-workspace/ComposerBranchChoice.tsx new file mode 100644 index 00000000000..0b162eeaee6 --- /dev/null +++ b/src/renderer/src/components/new-workspace/ComposerBranchChoice.tsx @@ -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(null) + const showOpenWorkspace = reuseSelectedBranch && Boolean(onOpenSelectedBranchWorkspace) + return ( +
+ + {translate('workspace.branchChoice.title', 'Branch for this worktree')} + + onReuseSelectedBranchChange(value === 'existing')} + disabled={disabled} + > +
+ + {showOpenWorkspace ? ( +
+ +
+ ) : null} +
+
+ + {!reuseSelectedBranch ? ( +
+ + onBranchNameOverrideChange(event.target.value)} + placeholder={translate('workspace.branchChoice.automaticName', 'Automatic name')} + autoCapitalize="none" + spellCheck={false} + /> +
+ ) : null} +
+
+
+ ) +} diff --git a/src/renderer/src/components/new-workspace/NewWorkspaceComposerAdvancedSection.tsx b/src/renderer/src/components/new-workspace/NewWorkspaceComposerAdvancedSection.tsx index 9849ab14258..3bf7e45bd59 100644 --- a/src/renderer/src/components/new-workspace/NewWorkspaceComposerAdvancedSection.tsx +++ b/src/renderer/src/components/new-workspace/NewWorkspaceComposerAdvancedSection.tsx @@ -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') ? (