* 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:
parent
4dfc3608b7
commit
ae77156ab2
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
export {
|
||||
isBranchCheckedOutInWorktrees,
|
||||
resolveComposerBranchNameOverrideForCreate,
|
||||
resolveComposerBranchReuse,
|
||||
resolveComposerBranchSelection,
|
||||
resolveComposerReuseOverride,
|
||||
type ComposerBranchSelection
|
||||
} from '../../../shared/composer-branch-selection'
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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 を選択します。"
|
||||
|
|
|
|||
|
|
@ -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 바로가기를 표시합니다. 도구 상자에서는 계속 사용할 수 있습니다.",
|
||||
|
|
|
|||
|
|
@ -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。"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue