feat(worktree): reuse an existing branch when creating a worktree (#5181) (#5781)

* feat(worktree): reuse an existing branch when creating a worktree (#5181)

Adds an explicit "Reuse this branch" checkbox to the new-worktree composer.
When an existing local branch is selected in the smart name field, the
checkbox (default on) checks that branch out instead of creating a new
branch from it — and the choice now survives renaming the worktree folder.

Previously, reusing an existing branch only worked implicitly: it required
keeping the auto-filled worktree name equal to the branch, and editing the
name silently reverted to "create a new branch". There was no discoverable,
durable control — the gap the issue reports.

The fix is renderer-only: picking an existing local branch already sets
baseBranch === branchNameOverride, so the existing backend reuse path
(`git worktree add <path> <branch>`, preserveBranchOnDelete) checks the
branch out correctly across local/SSH/runtime. The checkbox pins that
override via the existing branchNameOverridePreservesNameEdits flag so the
worktree folder can be named independently while the branch is reused;
unchecking creates a fresh branch from the selected ref as base.

- New pure helper resolveComposerBranchReuse (local-vs-remote + default).
- Checkbox hidden for remote-only refs and non-branch sources.
- Tests: pure helper, card render/toggle, and a backend integration test
  proving a renamed folder still reuses the exact branch.

* fix(worktree): refine reuse-branch checkbox (placement, label, eligibility)

Address review feedback on the "Reuse branch" control:

- Move the checkbox directly under the branch (smart) selection instead of the
  Advanced "Name" field — the worktree folder name can legitimately differ from
  the branch, so the choice belongs next to the branch pick and is now visible
  without expanding Advanced.
- Rename the label "Reuse this branch" -> "Reuse branch".
- Make eligibility dynamic: reuse is impossible when the branch is already
  checked out in another worktree (git allows a branch in only one worktree), so
  the checkbox is now hidden in that case (it was already hidden for remote-only
  refs). When a busy branch is picked, the override is no longer pinned to it, so
  creation cleanly falls back to a new branch from that ref as base instead of a
  silently-suffixed branch.

Adds isBranchCheckedOutInWorktrees (pure, unit-tested) and threads the repo's
worktree branch list from the store into the selection logic.

* feat(worktree): animate the reuse-branch row's show/hide

Keep the "Reuse branch" row mounted and collapse it with a grid-rows
transition (same pattern as the Advanced drawer) instead of conditionally
rendering it, so the create-worktree dialog grows and shrinks smoothly when the
option appears/disappears as the selected branch changes. The checkbox is taken
out of the tab order while collapsed.

* refactor(worktree): address review nits on reuse-branch control

From a multi-dimensional review of the branch (all findings low severity):

- Reset reuse state (reuseEligibleBranch, reuseSelectedBranch,
  branchNameOverridePreservesNameEdits) on repo and project switches, matching
  the other reset paths — avoids carrying stale branch-scoped state.
- Disable the reuse checkbox while collapsed so no focusable control lives
  inside the aria-hidden row.
- Extract the busy-branch override decision into a pure resolveComposerReuseOverride
  helper and unit-test it (busy local branch drops the override; remote-only ref
  keeps it) — pins the "no suffixed-branch collision" guarantee.
- Note the worktreesByRepo visibility limitation near the busy-branch check
  (a branch busy only in a hidden external worktree is caught by the backend).
- Strengthen tests: reuse-checkbox toggle in both directions, empty worktree
  list case.
This commit is contained in:
Neil 2026-06-19 02:47:05 -07:00 committed by GitHub
parent 4dfc3608b7
commit ae77156ab2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 473 additions and 13 deletions

View File

@ -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',

View File

@ -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<HTMLInputElement>('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: [

View File

@ -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({
<span>{forkPushWarning}</span>
</p>
) : 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. */}
<div
className={cn(
'grid overflow-hidden transition-[grid-template-rows] duration-200 ease-out',
canReuseSelectedBranch ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'
)}
aria-hidden={!canReuseSelectedBranch}
>
<div className="min-h-0">
<div className="space-y-1 pt-1">
<label className="group flex w-fit items-center gap-2 text-xs text-foreground">
<span
className={cn(
'flex size-4 items-center justify-center rounded-[3px] border shadow-sm transition',
reuseSelectedBranch
? 'border-emerald-500/60 bg-emerald-500 text-white'
: 'border-foreground/20 bg-background dark:border-white/20 dark:bg-muted/10'
)}
>
<Check
className={cn(
'size-3 transition-opacity',
reuseSelectedBranch ? 'opacity-100' : 'opacity-0'
)}
/>
</span>
<input
type="checkbox"
checked={reuseSelectedBranch}
onChange={(event) => onReuseSelectedBranchChange(event.target.checked)}
// Why: while collapsed the row is aria-hidden, so disable the
// input too — keeps a hidden control out of the tab order and
// fully inert (no focusable control inside an aria-hidden tree).
disabled={!canReuseSelectedBranch}
className="sr-only"
/>
<span>
{translate(
'auto.components.NewWorkspaceComposerCard.reuseExistingBranch',
'Reuse branch'
)}
</span>
</label>
<p className="pl-6 text-[11px] text-muted-foreground">
{translate(
'auto.components.NewWorkspaceComposerCard.reuseExistingBranchHint',
'Check out the existing branch instead of creating a new one from it.'
)}
</p>
</div>
</div>
</div>
</div>
<div className="space-y-1" data-contextual-tour-target="workspace-creation-agent">

View File

@ -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 })
})
})

View File

@ -1,5 +1,8 @@
export {
isBranchCheckedOutInWorktrees,
resolveComposerBranchNameOverrideForCreate,
resolveComposerBranchReuse,
resolveComposerBranchSelection,
resolveComposerReuseOverride,
type ComposerBranchSelection
} from '../../../shared/composer-branch-selection'

View File

@ -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<string | undefined>(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<string | null>(null)
const [reuseSelectedBranch, setReuseSelectedBranch] = useState(false)
const [pushTarget, setPushTarget] = useState<GitPushTarget | undefined>(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,

View File

@ -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",

View File

@ -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."

View File

@ -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 を選択します。"

View File

@ -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 바로가기를 표시합니다. 도구 상자에서는 계속 사용할 수 있습니다.",

View File

@ -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。"

View File

@ -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