diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index 3f342ef26..ca2df50e7 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -1027,6 +1027,66 @@ describe('registerWorktreeHandlers', () => { }) }) + it('reuses an existing local branch when the worktree folder is renamed (#5181)', async () => { + // Why: the reuse checkbox keeps branchNameOverride pinned to the selected + // branch while the worktree folder is named independently. The backend must + // still check out that exact branch (no -b) into the renamed folder. + listWorktreesMock + .mockResolvedValueOnce([ + { + path: '/workspace/repo', + head: 'main', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ]) + .mockResolvedValueOnce([ + { + path: '/workspace/repo', + head: 'main', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + }, + { + path: '/workspace/my-folder', + head: 'abc123', + branch: 'refs/heads/fix/bug-0', + isBare: false, + isMainWorktree: false + } + ]) + + const result = await handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'my-folder', + baseBranch: 'fix/bug-0', + branchNameOverride: 'fix/bug-0' + }) + + expect(getBranchConflictKindMock).not.toHaveBeenCalled() + expect(addWorktreeMock).toHaveBeenCalledWith( + '/workspace/repo', + '/workspace/my-folder', + 'fix/bug-0', + 'fix/bug-0', + false, + false, + { checkoutExistingBranch: true } + ) + expect(store.setWorktreeMeta).toHaveBeenCalledWith( + 'repo-1::/workspace/my-folder', + expect.objectContaining({ preserveBranchOnDelete: true }) + ) + expect(result).toMatchObject({ + worktree: expect.objectContaining({ + path: '/workspace/my-folder', + branch: 'refs/heads/fix/bug-0' + }) + }) + }) + it('suffixes only the path when an existing local branch checkout path already exists', async () => { const mainWorktree = { path: '/workspace/repo', diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx index 2916ca625..899524a11 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx @@ -132,6 +132,9 @@ function renderCard( onSmartLinearIssueSelect={() => {}} smartNameSelection={null} onClearSmartNameSelection={() => {}} + canReuseSelectedBranch={false} + reuseSelectedBranch={false} + onReuseSelectedBranchChange={() => {}} forkPushWarning={null} detectedAgentIds={null} onOpenAgentSettings={() => {}} @@ -205,6 +208,64 @@ describe('NewWorkspaceComposerCard folder task source mode', () => { expect(current.container.querySelectorAll('[data-testid="project-combobox"]')).toHaveLength(1) }) + it('keeps the reuse-branch row collapsed until a local branch is reusable', () => { + // Why: the row stays mounted (for the smooth height transition) but is + // collapsed + aria-hidden when reuse isn't possible. + current = renderCard({ canReuseSelectedBranch: false }) + const collapsedReuse = [...current.container.querySelectorAll('[aria-hidden="true"]')].find( + (el) => el.textContent?.includes('Reuse branch') + ) + expect(collapsedReuse).toBeTruthy() + + act(() => current?.root.unmount()) + current?.container.remove() + + current = renderCard({ canReuseSelectedBranch: true, reuseSelectedBranch: true }) + const reuseLabel = [...current.container.querySelectorAll('label')].find((label) => + label.textContent?.includes('Reuse branch') + ) + expect(reuseLabel).toBeTruthy() + // Visible: not inside an aria-hidden (collapsed) wrapper. + 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.' + ) + }) + + it('emits the toggled value from the reuse checkbox in both directions', () => { + const clickReuseCheckbox = (): void => { + const reuseLabel = [...(current?.container.querySelectorAll('label') ?? [])].find((label) => + label.textContent?.includes('Reuse branch') + ) + const checkbox = reuseLabel?.querySelector('input[type="checkbox"]') + expect(checkbox).toBeTruthy() + act(() => checkbox?.click()) + } + + // Checked -> unchecked (opting out of reuse). + const offChanges: boolean[] = [] + current = renderCard({ + canReuseSelectedBranch: true, + reuseSelectedBranch: true, + onReuseSelectedBranchChange: (next) => offChanges.push(next) + }) + clickReuseCheckbox() + expect(offChanges).toEqual([false]) + + act(() => current?.root.unmount()) + current?.container.remove() + + // Unchecked -> checked (opting into reuse — the action that pins the branch). + const onChanges: boolean[] = [] + current = renderCard({ + canReuseSelectedBranch: true, + reuseSelectedBranch: false, + onReuseSelectedBranchChange: (next) => onChanges.push(next) + }) + clickReuseCheckbox() + expect(onChanges).toEqual([true]) + }) + it('does not disable folder workspace creation when only source lookup needs SSH', () => { current = renderCard({ eligibleRepos: [ diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index 5867b65d5..f4304dbe9 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -84,6 +84,10 @@ type NewWorkspaceComposerCardProps = { onSmartLinearIssueSelect: (issue: LinearIssue) => void smartNameSelection: SmartWorkspaceNameSelection | null onClearSmartNameSelection: () => void + /** True when an existing local branch is selected and can be reused. */ + canReuseSelectedBranch: boolean + reuseSelectedBranch: boolean + onReuseSelectedBranchChange: (next: boolean) => void smartNameGitHubSourceContext?: TaskSourceContext | null /** Advisory shown under the name field when a fork PR can't accept maintainer pushes. */ forkPushWarning: string | null @@ -317,6 +321,9 @@ export default function NewWorkspaceComposerCard({ onSmartLinearIssueSelect, smartNameSelection, onClearSmartNameSelection, + canReuseSelectedBranch, + reuseSelectedBranch, + onReuseSelectedBranchChange, smartNameGitHubSourceContext, forkPushWarning, detectedAgentIds, @@ -658,6 +665,64 @@ export default function NewWorkspaceComposerCard({ {forkPushWarning}

) : null} + {/* Why (#5181): sits right under the branch selection (not the Name + field, which can differ from the branch) so reusing the picked + branch is an explicit, discoverable choice. Stays mounted and + collapses via a grid-rows transition (matching the Advanced + drawer) so the dialog grows/shrinks smoothly as the option + appears. Only offered when reuse is possible — an existing local + branch not already checked out in another worktree. */} +
+
+
+ +

+ {translate( + 'auto.components.NewWorkspaceComposerCard.reuseExistingBranchHint', + 'Check out the existing branch instead of creating a new one from it.' + )} +

+
+
+
diff --git a/src/renderer/src/hooks/composer-branch-selection.test.ts b/src/renderer/src/hooks/composer-branch-selection.test.ts index a61c86fae..2d3799245 100644 --- a/src/renderer/src/hooks/composer-branch-selection.test.ts +++ b/src/renderer/src/hooks/composer-branch-selection.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from 'vitest' import { + isBranchCheckedOutInWorktrees, resolveComposerBranchNameOverrideForCreate, - resolveComposerBranchSelection + resolveComposerBranchReuse, + resolveComposerBranchSelection, + resolveComposerReuseOverride } from './composer-branch-selection' describe('resolveComposerBranchSelection', () => { @@ -76,3 +79,112 @@ describe('resolveComposerBranchSelection', () => { ).toBeUndefined() }) }) + +describe('isBranchCheckedOutInWorktrees', () => { + it('matches a branch against both refs/heads-qualified and short worktree refs', () => { + expect( + isBranchCheckedOutInWorktrees('feature-x', ['refs/heads/main', 'refs/heads/feature-x']) + ).toBe(true) + expect(isBranchCheckedOutInWorktrees('feature-x', ['feature-x'])).toBe(true) + expect(isBranchCheckedOutInWorktrees('feature-x', ['refs/heads/main', ''])).toBe(false) + expect(isBranchCheckedOutInWorktrees('feature-x', [])).toBe(false) + }) +}) + +describe('resolveComposerReuseOverride', () => { + it('keeps the selection override for a reusable (non-busy) local branch', () => { + expect( + resolveComposerReuseOverride({ + refName: 'feature-x', + localBranchName: 'feature-x', + branchNameOverride: 'feature-x', + branchCheckedOutElsewhere: false + }) + ).toBe('feature-x') + }) + + it('drops the override for a local branch checked out in another worktree', () => { + // Why: pinning a busy branch would collide and produce a suffixed branch. + expect( + resolveComposerReuseOverride({ + refName: 'feature-x', + localBranchName: 'feature-x', + branchNameOverride: 'feature-x', + branchCheckedOutElsewhere: true + }) + ).toBeUndefined() + }) + + it('keeps the override for a remote-only ref even if its local name is busy', () => { + // Why: a remote-only ref (ref !== local name) creates a fresh local tracking + // branch, so the busy check on the local name must not drop its override. + expect( + resolveComposerReuseOverride({ + refName: 'origin/feature-x', + localBranchName: 'feature-x', + branchNameOverride: 'feature-x', + branchCheckedOutElsewhere: true + }) + ).toBe('feature-x') + }) +}) + +describe('resolveComposerBranchReuse', () => { + it('marks an existing local branch reusable and defaults reuse ON for an auto-derived name', () => { + expect( + resolveComposerBranchReuse({ + refName: 'feature-x', + localBranchName: 'feature-x', + selectionProducedOverride: true, + branchCheckedOutElsewhere: false + }) + ).toEqual({ reuseEligibleBranch: 'feature-x', defaultReuse: true }) + }) + + it('treats a slash-containing local branch as reusable (ref equals local name)', () => { + expect( + resolveComposerBranchReuse({ + refName: 'fix/bug-0', + localBranchName: 'fix/bug-0', + selectionProducedOverride: true, + branchCheckedOutElsewhere: false + }) + ).toEqual({ reuseEligibleBranch: 'fix/bug-0', defaultReuse: true }) + }) + + it('does not offer reuse for a remote-only ref (ref carries an origin/ prefix)', () => { + expect( + resolveComposerBranchReuse({ + refName: 'origin/feature/something', + localBranchName: 'feature/something', + selectionProducedOverride: true, + branchCheckedOutElsewhere: false + }) + ).toEqual({ reuseEligibleBranch: null, defaultReuse: false }) + }) + + it('does not offer reuse when the branch is already checked out in another worktree', () => { + // Why: git refuses a branch in two worktrees, so reuse is impossible here. + expect( + resolveComposerBranchReuse({ + refName: 'feature-x', + localBranchName: 'feature-x', + selectionProducedOverride: true, + branchCheckedOutElsewhere: true + }) + ).toEqual({ reuseEligibleBranch: null, defaultReuse: false }) + }) + + it('keeps a local branch reuse-eligible but defaults reuse OFF when the user typed a custom name', () => { + // Why: no override means the user is branching off the ref with a custom + // worktree name; reuse stays opt-in (checkbox still shown via eligibility). + expect( + resolveComposerBranchReuse({ + refName: 'feature-x', + localBranchName: 'feature-x', + selectionProducedOverride: false, + branchCheckedOutElsewhere: false + }) + ).toEqual({ reuseEligibleBranch: 'feature-x', defaultReuse: false }) + }) +}) diff --git a/src/renderer/src/hooks/composer-branch-selection.ts b/src/renderer/src/hooks/composer-branch-selection.ts index b88fe16af..e254ac6c9 100644 --- a/src/renderer/src/hooks/composer-branch-selection.ts +++ b/src/renderer/src/hooks/composer-branch-selection.ts @@ -1,5 +1,8 @@ export { + isBranchCheckedOutInWorktrees, resolveComposerBranchNameOverrideForCreate, + resolveComposerBranchReuse, resolveComposerBranchSelection, + resolveComposerReuseOverride, type ComposerBranchSelection } from '../../../shared/composer-branch-selection' diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index ccb3201c7..f74a194d9 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -151,8 +151,11 @@ import { } from '@/lib/workspace-create-error-format' import type { SshConnectionStatus } from '../../../shared/ssh-types' import { + isBranchCheckedOutInWorktrees, resolveComposerBranchNameOverrideForCreate, - resolveComposerBranchSelection + resolveComposerBranchReuse, + resolveComposerBranchSelection, + resolveComposerReuseOverride } from './composer-branch-selection' import { translate } from '@/i18n/i18n' @@ -242,6 +245,13 @@ export type ComposerCardProps = { ) => void smartNameSelection: SmartWorkspaceNameSelection | null onClearSmartNameSelection: () => void + /** True when the selected source is an existing LOCAL branch that can be + * reused (checked out) instead of branched off — gates the reuse checkbox. */ + canReuseSelectedBranch: boolean + /** Whether the selected existing local branch will be reused (checked out) + * rather than used as the base for a new branch. */ + reuseSelectedBranch: boolean + onReuseSelectedBranchChange: (next: boolean) => void agentPrompt: string onAgentPromptChange: (value: string) => void /** Rendered issueCommand template to preview inside the empty prompt @@ -810,6 +820,13 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const [branchNameOverride, setBranchNameOverride] = useState(undefined) const [branchNameOverridePreservesNameEdits, setBranchNameOverridePreservesNameEdits] = useState(false) + // Why (#5181): when the user picks an existing LOCAL branch, let them reuse it + // (check it out) instead of creating a new branch from it. `reuseEligibleBranch` + // is the local branch name eligible for reuse (null = not a reusable local + // branch, e.g. a remote-only ref or non-branch source); `reuseSelectedBranch` + // is the explicit checkbox value driving whether reuse actually happens. + const [reuseEligibleBranch, setReuseEligibleBranch] = useState(null) + const [reuseSelectedBranch, setReuseSelectedBranch] = useState(false) const [pushTarget, setPushTarget] = useState(undefined) // Why: when a repo switch wipes a prior Start-from selection, surface the // reset inline (e.g. "was PR #8778") so the change is recoverable visually @@ -2143,6 +2160,11 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS setCompareBaseRef(undefined) setPushTarget(undefined) setBranchNameOverride(undefined) + // Why (#5181): reuse state is branch-scoped, so a repo switch must clear + // it alongside the branch override (matches the other reset paths). + setBranchNameOverridePreservesNameEdits(false) + setReuseEligibleBranch(null) + setReuseSelectedBranch(false) setForkPushWarning(null) setStartFromResetHint(hint) } @@ -2214,6 +2236,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS setBaseBranch(undefined) setPushTarget(undefined) setBranchNameOverride(undefined) + // Why (#5181): clear branch-scoped reuse state on a project switch too. + setBranchNameOverridePreservesNameEdits(false) + setReuseEligibleBranch(null) + setReuseSelectedBranch(false) setForkPushWarning(null) setStartFromResetHint(null) return @@ -2277,6 +2303,12 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS setCompareBaseRef(undefined) setPushTarget(undefined) setBranchNameOverride(undefined) + // Why (#5181): the Start-from picker means "create a new branch from this + // base", so it never offers branch reuse — clear any reuse state left over + // from a prior smart-field branch pick. + setBranchNameOverridePreservesNameEdits(false) + setReuseEligibleBranch(null) + setReuseSelectedBranch(false) setForkPushWarning(null) branchAutoNameRef.current = '' setStartFromResetHint(null) @@ -2531,18 +2563,65 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS setPushTarget(undefined) setStartFromResetHint(null) setForkPushWarning(null) - setBranchNameOverridePreservesNameEdits(false) + // Why (#5181): reuse an existing local branch (check it out) instead of + // branching off it. Default reuse ON when the worktree name was + // auto-derived from the branch, and preserve name edits so reuse survives + // renaming the worktree folder. Reuse is impossible when the branch is + // already checked out in another worktree (git allows it in only one), so + // gate eligibility on that and don't pin the override to a busy branch. + // Note: worktreesByRepo only covers visible worktrees; a branch busy only + // in a hidden external worktree falls through to the backend conflict + // check, which rejects it with a clear "already exists locally" error. + const branchCheckedOutElsewhere = isBranchCheckedOutInWorktrees( + localBranchName, + (worktreesByRepo[repoId] ?? []).map((worktree) => worktree.branch) + ) + const { reuseEligibleBranch: nextReuseEligibleBranch, defaultReuse } = + resolveComposerBranchReuse({ + refName, + localBranchName, + selectionProducedOverride: selection.branchNameOverride !== undefined, + branchCheckedOutElsewhere + }) + setReuseEligibleBranch(nextReuseEligibleBranch) + setReuseSelectedBranch(defaultReuse) + setBranchNameOverridePreservesNameEdits(defaultReuse) + const effectiveOverride = resolveComposerReuseOverride({ + refName, + localBranchName, + branchNameOverride: selection.branchNameOverride, + branchCheckedOutElsewhere + }) if (selection.name !== undefined && selection.lastAutoName !== undefined) { setName(selection.name) lastAutoNameRef.current = selection.lastAutoName - branchAutoNameRef.current = selection.branchAutoName - setBranchNameOverride(selection.branchNameOverride) + branchAutoNameRef.current = effectiveOverride ? selection.branchAutoName : '' + setBranchNameOverride(effectiveOverride) } else { - setBranchNameOverride(selection.branchNameOverride) - branchAutoNameRef.current = selection.branchAutoName + setBranchNameOverride(effectiveOverride) + branchAutoNameRef.current = effectiveOverride ? selection.branchAutoName : '' } }, - [name] + [name, worktreesByRepo, repoId] + ) + + const handleReuseSelectedBranchChange = useCallback( + (next: boolean): void => { + if (!reuseEligibleBranch) { + return + } + setReuseSelectedBranch(next) + // Why (#5181): reuse pins the exact existing branch as the override and + // preserves it across worktree-name edits, so the folder can be named + // independently while the branch is checked out. Opting out drops the + // override so a fresh branch is created from the selected ref as base. + setBranchNameOverridePreservesNameEdits(next) + setBranchNameOverride(next ? reuseEligibleBranch : undefined) + if (next) { + branchAutoNameRef.current = reuseEligibleBranch + } + }, + [reuseEligibleBranch] ) const handleSmartLinearIssueSelect = useCallback( @@ -2604,6 +2683,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS setCompareBaseRef(undefined) setPushTarget(undefined) setBranchNameOverride(undefined) + setBranchNameOverridePreservesNameEdits(false) + setReuseEligibleBranch(null) + setReuseSelectedBranch(false) setForkPushWarning(null) branchAutoNameRef.current = '' setStartFromResetHint(null) @@ -3528,6 +3610,12 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS smartNameGitHubSourceContext: selectedRepoGitHubSourceContext, smartNameSelection, onClearSmartNameSelection: handleClearSmartNameSelection, + canReuseSelectedBranch: + !isProjectGroupTarget && + reuseEligibleBranch !== null && + smartNameSelection?.kind === 'branch', + reuseSelectedBranch, + onReuseSelectedBranchChange: handleReuseSelectedBranchChange, agentPrompt, onAgentPromptChange: setAgentPrompt, linkedOnlyTemplatePreview: shouldApplyLinkedOnlyTemplate ? linkedOnlyTemplatePrompt : null, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index c8f66aade..25c6577d3 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -1024,6 +1024,8 @@ "d71cd3003e": "+ Assignee" }, "NewWorkspaceComposerCard": { + "reuseExistingBranch": "Reuse branch", + "reuseExistingBranchHint": "Check out the existing branch instead of creating a new one from it.", "cbb47ee0dc": "Only available for local Git projects.", "d861de981b": "Sparse checkout", "090cfedeb4": "Write a note", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index c3a59eff6..0866938eb 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -1076,7 +1076,9 @@ "setupKindFolder": "Folder", "setupHostExistingFolderHelp": "Link a checkout that already exists there, then create this workspace on that host.", "importingHostSetup": "Importing...", - "importHostSetup": "Import" + "importHostSetup": "Import", + "reuseExistingBranch": "Reuse branch", + "reuseExistingBranchHint": "Check out the existing branch instead of creating a new one from it." }, "NewWorkspaceComposerModal": { "fa90f739a5": "Elija el proyecto, el nombre del espacio de trabajo y el agente antes de crear el espacio de trabajo." diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 7b59e624a..50f47f047 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -1076,7 +1076,9 @@ "setupKindFolder": "Folder", "setupHostExistingFolderHelp": "Link a checkout that already exists there, then create this workspace on that host.", "importingHostSetup": "Importing...", - "importHostSetup": "インポート" + "importHostSetup": "インポート", + "reuseExistingBranch": "Reuse branch", + "reuseExistingBranchHint": "Check out the existing branch instead of creating a new one from it." }, "NewWorkspaceComposerModal": { "fa90f739a5": "ワークスペースを作成する前に、プロジェクト、ワークスペース名、および agent を選択します。" diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 147e503d8..919609547 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -1076,7 +1076,9 @@ "setupKindFolder": "Folder", "setupHostExistingFolderHelp": "Link a checkout that already exists there, then create this workspace on that host.", "importingHostSetup": "Importing...", - "importHostSetup": "가져오기" + "importHostSetup": "가져오기", + "reuseExistingBranch": "Reuse branch", + "reuseExistingBranchHint": "Check out the existing branch instead of creating a new one from it." }, "NewWorkspaceComposerModal": { "fa90f739a5": "워크스페이스를 생성하기 전에 프로젝트, 워크스페이스 이름, agent를 선택하세요." @@ -4376,10 +4378,10 @@ "5f5142a62a": "이전 아이콘" }, "AppearancePane": { + "3057983501": "할당되지 않음", "872af9556e": "시스템 트레이", "2edf606c46": "닫을 때 트레이로 최소화", "b707773a0d": "활성화하면 창을 닫아도 Orca가 종료되지 않고 시스템 트레이에서 계속 실행됩니다.", - "3057983501": "할당되지 않음", "0cd9b8228f": "Dock 및 창 전환기에 표시된 앱 아이콘을 선택합니다.", "ca1590d42f": "앱 아이콘", "61d842eca0": "사이드바에 Orca Mobile 바로가기를 표시합니다. 도구 상자에서는 계속 사용할 수 있습니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 43edbc648..1b1569758 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -1076,7 +1076,9 @@ "setupKindFolder": "文件夹", "setupHostExistingFolderHelp": "链接已存在的检出目录,然后在该主机上创建此工作区。", "importingHostSetup": "导入中...", - "importHostSetup": "导入" + "importHostSetup": "导入", + "reuseExistingBranch": "Reuse branch", + "reuseExistingBranchHint": "Check out the existing branch instead of creating a new one from it." }, "NewWorkspaceComposerModal": { "fa90f739a5": "创建工作区之前选择项目、工作区名称和 Agent。" diff --git a/src/shared/composer-branch-selection.ts b/src/shared/composer-branch-selection.ts index a868fd66f..4b8a0dca9 100644 --- a/src/shared/composer-branch-selection.ts +++ b/src/shared/composer-branch-selection.ts @@ -36,6 +36,67 @@ export function resolveComposerBranchSelection(args: { } } +/** + * True when `branchName` is already checked out in one of the given worktree + * branch refs (which may be `refs/heads/foo` or short `foo`). Git refuses to + * check out a branch in two worktrees, so such a branch cannot be reused. + */ +export function isBranchCheckedOutInWorktrees( + branchName: string, + worktreeBranches: readonly string[] +): boolean { + return worktreeBranches.some((ref) => ref.replace(/^refs\/heads\//, '') === branchName) +} + +/** + * Issue #5181: decide whether a picked branch row is an existing LOCAL branch + * that can be reused (checked out) instead of branched off, and whether reuse + * should default ON. + * + * Reuse is only possible for a LOCAL branch (ref === local name; remote-only + * refs carry an `origin/`-style prefix) that is NOT already checked out in + * another worktree — git allows a branch in only one worktree at a time. Reuse + * defaults ON only when the worktree name was auto-derived from the branch (the + * selection produced a branch-name override); a user who typed a custom + * worktree name first is branching off the ref, so reuse stays OFF unless they + * opt in. + */ +export function resolveComposerBranchReuse(args: { + refName: string + localBranchName: string + selectionProducedOverride: boolean + branchCheckedOutElsewhere: boolean +}): { reuseEligibleBranch: string | null; defaultReuse: boolean } { + const reuseEligibleBranch = + args.refName === args.localBranchName && !args.branchCheckedOutElsewhere + ? args.localBranchName + : null + return { + reuseEligibleBranch, + defaultReuse: reuseEligibleBranch !== null && args.selectionProducedOverride + } +} + +/** + * Issue #5181: the branch-name override to apply for a picked branch. A local + * branch already checked out in another worktree can't be reused, so it must + * NOT be pinned as the override — pinning it would collide and silently produce + * a suffixed branch. In that case fall back to letting the worktree name derive + * a fresh branch from the selected ref as base; otherwise use the selection's + * override unchanged. + */ +export function resolveComposerReuseOverride(args: { + refName: string + localBranchName: string + branchNameOverride: string | undefined + branchCheckedOutElsewhere: boolean +}): string | undefined { + if (args.branchCheckedOutElsewhere && args.refName === args.localBranchName) { + return undefined + } + return args.branchNameOverride +} + export function resolveComposerBranchNameOverrideForCreate(args: { branchNameOverride: string | undefined branchAutoName: string