fix(worktree): track explicit remote-only branch (#31645)

* fix(worktree): track explicit remote branch

* test(worktree): cover remote branch path retry

* fix(worktree): resolve cached remote branches canonically

* fix(git): retain commit validation in bounded ref probes

* fix(worktree): preserve selected remote identity during creation

* test(worktree): count cached resolver Git work during path retries

* Fix branch intent state in composer source fixture

* Make Git and daemon regression fixtures portable

* Use the selected base for existing branch creation

---------

Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
This commit is contained in:
Pr1p
2026-10-26 13:13:56 -07:00
committed by GitHub
co-authored by Brennan Benson
parent 161927f562
commit fdcf193cd4
30 changed files with 434 additions and 73 deletions
@@ -78,6 +78,9 @@ it('tracks the remote branch it was created from, and nothing else', async () =>
const fixPath = join(root, 'fix')
await addWorktree(repo, fixPath, 'alex/fix', 'origin/alex/fix')
expect(await upstreamOf(fixPath, 'alex/fix')).toBe('origin/alex/fix')
const ref = 'refs/remotes/origin/alex/fix'
expect(await git(fixPath, 'config', '--get', 'branch.alex/fix.base')).toBe(ref)
expect(await git(fixPath, 'config', '--get', 'push.autoSetupRemote')).toBe('true')
expect(await git(fixPath, 'rev-parse', 'HEAD')).toBe(
await git(repo, 'rev-parse', 'refs/remotes/origin/alex/fix')
)
@@ -112,4 +115,10 @@ it('tracks the remote branch when a prepared checkout finalizes the create', asy
prepared.lockReason
)
expect(await upstreamOf(fixPath, 'alex/fix')).toBe('origin/alex/fix')
const ref = 'refs/remotes/origin/alex/fix'
expect(await git(fixPath, 'config', '--get', 'branch.alex/fix.base')).toBe(ref)
expect(await git(fixPath, 'config', '--get', 'push.autoSetupRemote')).toBe('true')
expect(await git(fixPath, 'rev-parse', 'HEAD')).toBe(
await git(repo, 'rev-parse', 'refs/remotes/origin/alex/fix')
)
})
+45 -33
View File
@@ -1143,11 +1143,46 @@ async function performLocalWorktreeCreate(
let resolved = false
let checkoutExistingBranch = false
let selectedExistingLocalBranchName: string | null = null
let selectedRemoteBranchName: string | null = null
let lastBranchConflictKind: 'local' | 'remote' | null = null
let lastExistingPR: Awaited<ReturnType<typeof getPRForBranch>> | null = null
let lastExistingReviewNumber: number | null = null
const shouldRetireGeneratedName =
args.nameWasGenerated === true && isGeneratedWorktreeCreateName(sanitizedName)
if (
args.branchIntent === 'open-existing' &&
args.branchNameOverride &&
!args.pushTarget &&
getSelectedReviewBranch(args) === null
) {
branchName = await resolveCreateBranchName(
repo.path,
args.branchNameOverride,
sanitizedName,
settings,
username,
localWorktreeGitOptions
)
const conflict = await getBranchConflictKind(
repo.path,
branchName,
baseBranch,
localWorktreeGitOptions
)
if (conflict === null) {
selectedRemoteBranchName = branchName
}
}
const pathIsAvailable = (): boolean => {
worktreePath = ensurePathWithinWorkspace(
computeWorktreePath(effectiveSanitizedName, repo.path, worktreePathSettings, workspaceRoot),
workspaceRoot
)
// Pending removals still own paths Git may already have deleted; skip them until teardown settles.
return (
!existsSync(worktreePath) && !findPendingWorktreeRemovalConflict(repo.path, { worktreePath })
)
}
await timing.time('resolve_name', async () => {
const retiredNameRegistry = shouldRetireGeneratedName
? await getRetiredNameRegistryForRepo(store, repo, store.getRepos(), settings)
@@ -1175,6 +1210,13 @@ async function performLocalWorktreeCreate(
continue
}
attempts += 1
if (selectedRemoteBranchName) {
if (pathIsAvailable()) {
resolved = true
break
}
continue
}
lastExistingReviewNumber = null
branchName = await resolveCreateBranchName(
@@ -1274,15 +1316,7 @@ async function performLocalWorktreeCreate(
}
}
worktreePath = ensurePathWithinWorkspace(
computeWorktreePath(effectiveSanitizedName, repo.path, worktreePathSettings, workspaceRoot),
workspaceRoot
)
// Pending removals still own paths Git may already have deleted; skip them until teardown settles.
if (
existsSync(worktreePath) ||
findPendingWorktreeRemovalConflict(repo.path, { worktreePath })
) {
if (!pathIsAvailable()) {
continue
}
@@ -1405,17 +1439,6 @@ async function performLocalWorktreeCreate(
addProjectGitOptions(existingBranchOption)
)
}
if (suggestLocalBaseRefUpdate) {
return addSparseWorktree(
repo.path,
worktreePath,
branchName,
sparseDirectories,
baseBranch,
settings.refreshLocalBaseRefOnWorktreeCreate,
addProjectGitOptions({ ...remoteTrackingBaseOption, suggestLocalBaseRefUpdate })
)
}
return addSparseWorktree(
repo.path,
worktreePath,
@@ -1423,7 +1446,7 @@ async function performLocalWorktreeCreate(
sparseDirectories,
baseBranch,
settings.refreshLocalBaseRefOnWorktreeCreate,
addProjectGitOptions(remoteTrackingBaseOption)
preparedWorktreeOptions
)
}
@@ -1438,17 +1461,6 @@ async function performLocalWorktreeCreate(
addProjectGitOptions(existingBranchOption)
)
}
if (suggestLocalBaseRefUpdate) {
return addWorktree(
repo.path,
worktreePath,
branchName,
baseBranch,
settings.refreshLocalBaseRefOnWorktreeCreate,
false,
addProjectGitOptions({ ...remoteTrackingBaseOption, suggestLocalBaseRefUpdate })
)
}
return addWorktree(
repo.path,
worktreePath,
@@ -1456,7 +1468,7 @@ async function performLocalWorktreeCreate(
baseBranch,
settings.refreshLocalBaseRefOnWorktreeCreate,
false,
addProjectGitOptions(remoteTrackingBaseOption)
preparedWorktreeOptions
)
})) ?? {}
} catch (error) {
@@ -213,7 +213,20 @@ describe('registerWorktreeHandlers', () => {
})
})
it('suffixes only the path when an existing local branch checkout path already exists', async () => {
it.each(['remote', 'local', 'busy'])('picker allocation (%s)', async (kind) => {
if (kind !== 'busy') {
getPRForBranchMock.mockResolvedValue({ number: 42 })
}
const branchName = kind === 'busy' ? 'fix/bug-0-2' : 'fix/bug-0'
getBranchConflictKindMock.mockImplementation(async (_repo, branch) =>
kind !== 'remote' && branch === 'fix/bug-0' ? 'local' : null
)
gitExecFileAsyncMock.mockImplementation(async (argv) => {
if (argv.includes('refs/heads/fix/bug-0-2^{commit}')) {
throw new Error('missing')
}
return { stdout: 'abc123', stderr: '' }
})
const mainWorktree = {
path: '/workspace/repo',
head: 'main',
@@ -224,42 +237,42 @@ describe('registerWorktreeHandlers', () => {
computeWorktreePathMock.mockImplementation((sanitizedName: string) =>
sanitizedName === 'fix-bug-0' ? process.cwd() : `/workspace/${sanitizedName}`
)
listWorktreesMock
.mockResolvedValueOnce([mainWorktree])
.mockResolvedValueOnce([mainWorktree])
.mockResolvedValueOnce([
mainWorktree,
{
path: '/workspace/fix-bug-0-2',
head: 'abc123',
branch: 'refs/heads/fix/bug-0',
isBare: false,
isMainWorktree: false
}
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 result = await handlers['worktrees:create'](null, {
repoId: 'repo-1',
name: 'fix/bug-0',
baseBranch: 'fix/bug-0',
branchNameOverride: 'fix/bug-0'
baseBranch: 'origin/fix/bug-0',
branchNameOverride: 'fix/bug-0',
branchIntent: 'open-existing'
})
expect(getBranchConflictKindMock).not.toHaveBeenCalled()
expect(getPRForBranchMock).not.toHaveBeenCalled()
expect(getBranchConflictKindMock).toHaveBeenCalledTimes(kind === 'busy' ? 3 : 1)
expect(getPRForBranchMock.mock.calls.map(([, branch]) => branch)).toEqual(
kind === 'busy' ? ['fix/bug-0-2'] : []
)
expect(addWorktreeMock).toHaveBeenCalledWith(
'/workspace/repo',
'/workspace/fix-bug-0-2',
'fix/bug-0',
'fix/bug-0',
branchName,
'origin/fix/bug-0',
false,
false,
{ checkoutExistingBranch: true }
kind === 'local' ? { checkoutExistingBranch: true } : {}
)
expect(result).toMatchObject({
worktree: expect.objectContaining({
path: '/workspace/fix-bug-0-2',
branch: 'refs/heads/fix/bug-0'
branch: `refs/heads/${branchName}`
})
})
})
@@ -12,6 +12,19 @@ const build = (params: Record<string, unknown>) =>
buildManagedWorktreeCreateArgs(WorktreeCreate.parse(params), PROVENANCE)
describe('buildManagedWorktreeCreateArgs', () => {
it('carries explicit existing intent through the RPC schema without inferring it for legacy names', () => {
const request = {
repo: 'repo-1',
name: 'feature/test',
baseBranch: 'origin/main',
branchNameOverride: 'feature/test'
}
expect(build(request)).not.toHaveProperty('branchIntent')
expect(build({ ...request, branchIntent: 'open-existing' })).toMatchObject({
branchIntent: 'open-existing'
})
})
it('omits name provenance when the client did not claim a generated name', () => {
// Absent means user-typed: default true would permanently retire deliberate names such as orca and molly.
expect(build({ repo: 'id:repo-1', name: 'nautilus' })).not.toHaveProperty('nameWasGenerated')
@@ -36,6 +36,7 @@ export function buildManagedWorktreeCreateArgs(
baseBranch: params.baseBranch,
compareBaseRef: params.compareBaseRef,
branchNameOverride: params.branchNameOverride,
...(params.branchIntent ? { branchIntent: params.branchIntent } : {}),
linkedIssue: params.linkedIssue,
linkedPR: params.linkedPR,
linkedLinearIssue: params.linkedLinearIssue,
@@ -67,6 +67,7 @@ export async function resolveRuntimeLocalWorktreeCreateCandidate(args: {
let branchName = ''
let checkoutExistingBranch = false
let selectedExistingLocalBranchName: string | null = null
let selectedRemoteBranchName: string | null = null
let branchConflictKind: 'local' | 'remote' | null = null
let worktreePath = ''
let worktreePathResolved = false
@@ -85,6 +86,41 @@ 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
) {
branchName = await resolveCreateBranchName(
args.repo.path,
args.request.branchNameOverride,
sanitizedName,
args.settings,
args.username,
args.localWorktreeGitOptions
)
const conflict = await getBranchConflictKind(
args.repo.path,
branchName,
args.baseBranch,
args.localWorktreeGitOptions
)
if (conflict === null) {
selectedRemoteBranchName = branchName
}
}
const pathIsAvailable = async (): Promise<boolean> => {
worktreePath = ensurePathWithinWorkspace(
computeWorktreePath(effectiveSanitizedName, args.repo.path, args.worktreePathSettings),
args.workspaceRoot
)
// Orca owns the path until background removal settles.
return (
!findPendingWorktreeRemovalConflict(args.repo.path, { worktreePath }) &&
!(await runtimePathExists(worktreePath))
)
}
for (let suffix = 1, attempts = 0; attempts < WORKTREE_CREATE_MAX_SUFFIX_ATTEMPTS; suffix += 1) {
effectiveSanitizedName = shouldRetireGeneratedName
? getGeneratedWorktreeCreateCandidate(
@@ -102,6 +138,13 @@ export async function resolveRuntimeLocalWorktreeCreateCandidate(args: {
continue
}
attempts += 1
if (selectedRemoteBranchName) {
if (await pathIsAvailable()) {
worktreePathResolved = true
break
}
continue
}
branchName = await resolveCreateBranchName(
args.repo.path,
selectedExistingLocalBranchName ??
@@ -186,15 +229,7 @@ export async function resolveRuntimeLocalWorktreeCreateCandidate(args: {
continue
}
}
worktreePath = ensurePathWithinWorkspace(
computeWorktreePath(effectiveSanitizedName, args.repo.path, args.worktreePathSettings),
args.workspaceRoot
)
// Orca owns the path until background removal settles.
if (
!findPendingWorktreeRemovalConflict(args.repo.path, { worktreePath }) &&
!(await runtimePathExists(worktreePath))
) {
if (await pathIsAvailable()) {
worktreePathResolved = true
break
}
@@ -21,6 +21,7 @@ const mocks = vi.hoisted(() => ({
hasBase: vi.fn(),
branchName: vi.fn(),
canCheckout: vi.fn(),
pathExists: vi.fn(),
branchConflict: vi.fn(),
githubPr: vi.fn(),
consume: vi.fn(),
@@ -64,7 +65,7 @@ vi.mock('./runtime-worktree-create-git', () => ({
getLocalGitHubPrForBranch: mocks.githubPr,
getSelectedHostedReviewForBranch: vi.fn()
}))
vi.mock('./runtime-worktree-filesystem', () => ({ runtimePathExists: async () => false }))
vi.mock('./runtime-worktree-filesystem', () => ({ runtimePathExists: mocks.pathExists }))
vi.mock('../worktree-create-preparation', () => ({ consumePreparedWorktreeCreate: mocks.consume }))
vi.mock('../git/worktree', () => ({ addWorktree: mocks.add, addSparseWorktree: mocks.addSparse }))
vi.mock('../ipc/worktree-remote', () => ({ configureCreatedWorktreePushTarget: mocks.pushTarget }))
@@ -97,6 +98,12 @@ import { withoutInFlightWorktreeCreates } from '../worktree-create-in-flight'
const worktreePath = resolve('/worktrees', 'app')
const pickedBranch = {
baseBranch: 'origin/main',
branchNameOverride: 'main',
branchIntent: 'open-existing'
} as const
const writtenMeta: Partial<WorktreeMeta>[] = []
const WEB_PRESET: SparsePreset = {
id: 'preset-web',
@@ -162,6 +169,7 @@ beforeEach(() => {
mocks.hasBase.mockResolvedValue(true)
mocks.branchName.mockResolvedValue('app')
mocks.canCheckout.mockResolvedValue(false)
mocks.pathExists.mockResolvedValue(false)
mocks.branchConflict.mockResolvedValue(null)
mocks.githubPr.mockResolvedValue(null)
mocks.consume.mockResolvedValue({ status: 'hit', result: {}, rearm: mocks.rearm })
@@ -340,9 +348,14 @@ describe('runtime create timing', () => {
})
const timing = createWorktreeCreateTimingRecorder()
await createWorktree({}, undefined, timing)
mocks.branchName.mockResolvedValue('main')
await createWorktree(pickedBranch, undefined, timing)
expect(mocks.consume).toHaveBeenCalledWith(expect.objectContaining({ timing }))
expect(mocks.consume).toHaveBeenCalledWith(
expect.objectContaining({ timing, branch: 'main', baseBranch: 'origin/main' })
)
expect(mocks.add).not.toHaveBeenCalled()
expect(mocks.consume.mock.calls[0][0].options).toEqual({})
const finished = timing.finish()
expect(finished.phases.map((phase) => phase.phase)).toEqual(
expect.arrayContaining([
@@ -490,7 +503,79 @@ describe('runtime create PR conflict probe', () => {
}
beforeEach(() => {
mocks.branchName.mockImplementation(async (_path, _override, name: string) => name)
mocks.branchName.mockImplementation(async (_path, override, name: string) => override ?? name)
})
it.each([1, 10])(
'keeps the selected name and source through %i occupied paths, without PR eligibility',
async (occupied) => {
const options = { wslDistro: 'Ubuntu' }
mocks.routing.mockReturnValue(options)
mocks.remoteBase.mockResolvedValue(remoteTrackingBase)
mocks.githubPr.mockResolvedValue({ number: 42 })
let pathChecks = 0
mocks.pathExists.mockImplementation(async () => pathChecks++ < occupied)
await createWorktree({ ...pickedBranch, sparseCheckout: { directories: ['src'] } })
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.pathExists).toHaveBeenCalledTimes(occupied + 1)
expect(mocks.githubPr).not.toHaveBeenCalled()
expect(mocks.consume).not.toHaveBeenCalled()
expect(mocks.addSparse).toHaveBeenCalledExactlyOnceWith(
'/repo',
resolve('/worktrees', `app-${occupied + 1}`),
'main',
['src'],
remoteTrackingBase.base,
false,
expect.objectContaining({ remoteTrackingBase, ...options })
)
}
)
it.each([true, false])(
'keeps local eligibility with picker intent (reusable: %s)',
async (reusable) => {
mocks.branchConflict.mockImplementation(async (_path, branch: string) =>
branch === 'main' ? 'local' : null
)
mocks.canCheckout.mockImplementation(
async (_path, branch: string) => reusable && branch === 'main'
)
mocks.pathExists.mockImplementation(async (path) => path === worktreePath)
mocks.consume.mockResolvedValue({ status: 'miss', reason: 'not_prepared' })
await createWorktree(pickedBranch)
expect(mocks.add).toHaveBeenCalledWith(
'/repo',
resolve('/worktrees', 'app-2'),
reusable ? 'main' : 'main-2',
'origin/main',
false,
false,
expect.objectContaining(reusable ? { 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)
}
)
it('keeps unflagged legacy, mobile New branch and Advanced collisions on baseline allocation', async () => {
mocks.consume.mockResolvedValue({ status: 'miss', reason: 'not_prepared' })
mocks.branchName.mockImplementation(async (_path, override, name: string) => override ?? name)
mocks.branchConflict.mockImplementation(async (_path, branch: string) =>
branch === 'feature/test' ? 'remote' : null
)
await createWorktree({ branchNameOverride: 'feature/test', baseBranch: 'origin/main' })
expect(mocks.add.mock.calls[0][2]).toBe('feature/test-2')
})
it('runs the first name PR lookup while the base fetch is still in flight', async () => {
@@ -24,6 +24,7 @@ export type RuntimeManagedWorktreeCreateArgs = {
baseBranch?: string
compareBaseRef?: string
branchNameOverride?: string
branchIntent?: CreateWorktreeArgs['branchIntent']
linkedIssue?: number | null
linkedPR?: number | null
linkedLinearIssue?: string
@@ -6,6 +6,7 @@ import type { GitLabWorkItem } from '../../../../shared/gitlab-types'
import type { ProjectGroup } from '../../../../shared/project-group-types'
import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host'
import type { GitPushTarget } from '../../../../shared/worktree/types'
import type { CreateWorktreeArgs } from '../../../../shared/worktree/create-types'
import type { SmartSourceSelection } from './source-selection-decisions'
import { useGitHubSourceApplication } from './github-source-application'
import { useSourceIdentityActions } from './source-identity-actions'
@@ -75,6 +76,7 @@ export function useReviewBaseSelectionFixture(initialReview?: GitHubWorkItem) {
const [pushTarget, setPushTarget] = useState<GitPushTarget | undefined>()
const [compareBaseRef, setCompareBaseRef] = useState<string | undefined>()
const [branchNameOverride, setBranchNameOverride] = useState<string | undefined>()
const [, setBranchIntent] = useState<CreateWorktreeArgs['branchIntent']>()
const [branchNameOverridePreservesNameEdits, setBranchNameOverridePreservesNameEdits] =
useState(false)
const [initialSelection] = useState(() =>
@@ -110,6 +112,7 @@ export function useReviewBaseSelectionFixture(initialReview?: GitHubWorkItem) {
selectedRepoIsGit: true,
folderSourceRepos: [],
setBranchNameOverride,
setBranchIntent,
setBranchNameOverridePreservesNameEdits,
setCreateError: vi.fn(),
setForkPushWarning: vi.fn(),
@@ -209,6 +209,7 @@ export function useComposerSourceState(
setStartFromResetHint: target.workspaceIdentityState.setStartFromResetHint
})
const workItemSourceActions = useWorkItemSourceActions({
setBranchIntent: target.workspaceIdentityState.setBranchIntent,
branchAutoNameRef: target.asyncComposerState.branchAutoNameRef,
lastAutoNameRef: target.asyncComposerState.lastAutoNameRef,
name: target.sourceContextState.name,
@@ -30,6 +30,7 @@ export type QuickSubmitSource = {
}
export type PreparedQuickSubmit = QuickSubmitSource & {
branchIntent?: WorktreeCreationRequest['branchIntent']
effectiveSetupDecision: SetupDecision
hookPreparation: WorktreeCreationRequest['hookPreparation']
linkedLinearIssue: string | undefined
@@ -66,6 +66,7 @@ export function useComposerSubmitOrchestration(
pushTarget: target.workspaceIdentityState.pushTarget
})
const quickSubmitPreparation = useQuickSubmitPreparation({
branchIntent: target.workspaceIdentityState.branchIntent,
branchAutoNameRef: target.asyncComposerState.branchAutoNameRef,
branchNameOverridePreservesNameEdits:
target.workspaceIdentityState.branchNameOverridePreservesNameEdits,
@@ -19,6 +19,8 @@ export type ComposerIdentityModel = {
compareBaseRef: string | undefined
setCompareBaseRef: React.Dispatch<React.SetStateAction<string | undefined>>
branchNameOverride: string | undefined
branchIntent?: 'open-existing'
setBranchIntent: React.Dispatch<React.SetStateAction<'open-existing' | undefined>>
setBranchNameOverride: React.Dispatch<React.SetStateAction<string | undefined>>
parentWorktreeId: string | null
setParentWorktreeId: React.Dispatch<React.SetStateAction<string | null>>
@@ -38,7 +38,8 @@ it('carries the accepted route across preparation and retains CLI startup/prompt
linkedLinearIssue: undefined,
linkedLinearIssueWorkspaceId: undefined,
linkedLinearIssueOrganizationUrlKey: undefined,
effectiveBranchNameOverride: undefined,
effectiveBranchNameOverride: 'feature/test',
branchIntent: 'open-existing',
submitBaseBranch: undefined,
createDisplayName: undefined,
pendingFirstAgentMessageRename: false,
@@ -105,7 +106,9 @@ it('carries the accepted route across preparation and retains CLI startup/prompt
expect(request).toMatchObject({
agent: 'opencode',
agentLaunchRoute: route,
quickPrompt: 'Fix the checkout'
quickPrompt: 'Fix the checkout',
branchNameOverride: 'feature/test',
branchIntent: 'open-existing'
})
if (route === 'terminal-tui') {
expect(request.startup).toMatchObject({
@@ -114,6 +114,7 @@ export function useQuickCreationExecution(input: QuickCreationExecutionInput) {
linkedLinearIssueWorkspaceId,
linkedLinearIssueOrganizationUrlKey,
effectiveBranchNameOverride,
branchIntent,
submitBaseBranch,
createDisplayName,
pendingFirstAgentMessageRename,
@@ -221,6 +222,7 @@ export function useQuickCreationExecution(input: QuickCreationExecutionInput) {
linkedLinearIssueWorkspaceId,
linkedLinearIssueOrganizationUrlKey,
branchNameOverride: effectiveBranchNameOverride,
branchIntent,
parentWorktreeId,
workspaceStatus: resolvedInitialWorkspaceStatus,
linkedGitLabMR,
@@ -36,6 +36,7 @@ export type QuickCreationRequestInput = {
linkedLinearIssueWorkspaceId: string | undefined
linkedLinearIssueOrganizationUrlKey: string | undefined
branchNameOverride: string | undefined
branchIntent?: WorktreeCreationRequest['branchIntent']
parentWorktreeId: string | null
workspaceStatus: WorktreeCreationRequest['workspaceStatus']
linkedGitLabMR: number | null
@@ -102,6 +103,7 @@ export function buildQuickCreationRequest(
? { linkedLinearIssueOrganizationUrlKey: input.linkedLinearIssueOrganizationUrlKey }
: {}),
...(input.branchNameOverride ? { branchNameOverride: input.branchNameOverride } : {}),
...(input.branchIntent ? { branchIntent: input.branchIntent } : {}),
...(input.parentWorktreeId ? { parentWorktreeId: input.parentWorktreeId } : {}),
...(input.workspaceStatus ? { workspaceStatus: input.workspaceStatus } : {}),
...(input.includeGitLabLinks && input.linkedGitLabMR != null
@@ -3,6 +3,7 @@ import type { ComposerModel } from './composer-model'
export type QuickSubmitPreparationInput = Pick<
ComposerModel,
| 'branchAutoNameRef'
| 'branchIntent'
| 'branchNameOverridePreservesNameEdits'
| 'checkedHooksContextKey'
| 'commitHookCheckIfCurrent'
@@ -18,6 +18,7 @@ import type { PendingSmartGitHubSubmitResolution } from './source-selection-deci
export function useQuickSubmitPreparation(input: QuickSubmitPreparationInput) {
const {
branchAutoNameRef,
branchIntent,
branchNameOverridePreservesNameEdits,
checkedHooksContextKey,
commitHookCheckIfCurrent,
@@ -194,6 +195,10 @@ export function useQuickSubmitPreparation(input: QuickSubmitPreparationInput) {
linkedLinearIssueWorkspaceId,
linkedLinearIssueOrganizationUrlKey,
effectiveBranchNameOverride,
branchIntent:
smartGitHubResolution.kind === 'none' && effectiveBranchNameOverride
? branchIntent
: undefined,
submitBaseBranch,
createDisplayName,
pendingFirstAgentMessageRename,
@@ -202,6 +207,7 @@ export function useQuickSubmitPreparation(input: QuickSubmitPreparationInput) {
},
[
branchNameOverridePreservesNameEdits,
branchIntent,
checkedHooksContextKey,
commitHookCheckIfCurrent,
enableIssueAutomation,
@@ -0,0 +1,46 @@
// @vitest-environment happy-dom
import { act, cleanup, renderHook } from '@testing-library/react'
import { useRef } from 'react'
import { afterEach, expect, it, vi } from 'vitest'
import { useWorkspaceIdentityState } from './workspace-identity-state'
import { useWorkItemSourceActions } from './work-item-source-actions'
import type { SmartSourceSelection } from './source-selection-decisions'
afterEach(cleanup)
it('records existing intent only for a remote picker selection and clears it on Advanced edits', () => {
const { result } = renderHook(() => {
const identity = useWorkspaceIdentityState({
initialBaseBranch: undefined,
initialLinearBranchName: undefined,
initialLinkedWorkItem: null,
linkedWorkItem: null,
linkedWorkItemSeedIdentity: null,
name: '',
newWorkspaceDraft: null,
persistDraft: false,
selectedRepoConnectionId: null,
selectedRepoRuntimeEnvironmentId: null,
settings: null
})
const actions = useWorkItemSourceActions({
...identity,
branchAutoNameRef: useRef(''),
lastAutoNameRef: useRef(''),
smartSourceSelectionRef: useRef<SmartSourceSelection | null>(null),
name: '',
repoId: 'repo-1',
setName: vi.fn(),
worktreesByRepo: {}
})
return { identity, actions }
})
act(() => result.current.actions.handleSmartBranchSelect('origin/feature/test', 'feature/test'))
expect(result.current.identity.branchIntent).toBe('open-existing')
expect(result.current.identity.branchNameOverride).toBe('feature/test')
act(() => result.current.identity.setBranchNameOverride('feature/test'))
expect(result.current.identity.branchIntent).toBeUndefined()
act(() => result.current.actions.handleSmartBranchSelect('feature/test', 'feature/test'))
expect(result.current.identity.branchIntent).toBeUndefined()
})
@@ -10,6 +10,7 @@ type WorkItemSourceActionsInput = Pick<
| 'setBaseBranch'
| 'setBaseBranchNamesWorkspace'
| 'setBranchNameOverride'
| 'setBranchIntent'
| 'setBranchNameOverridePreservesNameEdits'
| 'setCompareBaseRef'
| 'setForkPushWarning'
@@ -38,6 +39,7 @@ export function useWorkItemSourceActions(input: WorkItemSourceActionsInput) {
setBaseBranch,
setBaseBranchNamesWorkspace,
setBranchNameOverride,
setBranchIntent,
setBranchNameOverridePreservesNameEdits,
setCompareBaseRef,
setForkPushWarning,
@@ -80,6 +82,13 @@ export function useWorkItemSourceActions(input: WorkItemSourceActionsInput) {
setBranchNameOverride(selection.branchNameOverride)
branchAutoNameRef.current = selection.branchNameOverride ? selection.branchAutoName : ''
}
if (
selection.branchNameOverride &&
refName !== localBranchName &&
refName !== `refs/heads/${localBranchName}`
) {
setBranchIntent('open-existing')
}
},
[
name,
@@ -90,6 +99,7 @@ export function useWorkItemSourceActions(input: WorkItemSourceActionsInput) {
setBaseBranch,
setBaseBranchNamesWorkspace,
setBranchNameOverride,
setBranchIntent,
setBranchNameOverridePreservesNameEdits,
setCompareBaseRef,
setForkPushWarning,
@@ -15,7 +15,8 @@ type WorkspaceIdentityStateInput = Pick<
| 'settings'
>
import { useState, useMemo } from 'react'
import { useState, useMemo, useCallback, type SetStateAction } from 'react'
import type { CreateWorktreeArgs } from '../../../../shared/worktree/create-types'
import { getLinkedWorkItemProvider, isGitLabIssueUrl } from '@/lib/new-workspace'
import {
type SmartNameMode,
@@ -110,9 +111,14 @@ export function useWorkspaceIdentityState(input: WorkspaceIdentityStateInput) {
persistDraft ? newWorkspaceDraft?.compareBaseRef : undefined
)
const [branchNameOverride, setBranchNameOverride] = useState<string | undefined>(
const [branchNameOverride, updateBranchNameOverride] = useState<string | undefined>(
initialLinearBranchName
)
const [branchIntent, setBranchIntent] = useState<CreateWorktreeArgs['branchIntent']>()
const setBranchNameOverride = useCallback((value: SetStateAction<string | undefined>) => {
setBranchIntent(undefined)
updateBranchNameOverride(value)
}, [])
const [parentWorktreeId, setParentWorktreeId] = useState<string | null>(null)
@@ -213,6 +219,8 @@ export function useWorkspaceIdentityState(input: WorkspaceIdentityStateInput) {
compareBaseRef,
setCompareBaseRef,
branchNameOverride,
branchIntent,
setBranchIntent,
setBranchNameOverride,
parentWorktreeId,
setParentWorktreeId,
@@ -85,6 +85,7 @@ export type WorktreeCreationRequest = {
linkedLinearIssueWorkspaceId?: string | null
linkedLinearIssueOrganizationUrlKey?: string | null
branchNameOverride?: string
branchIntent?: 'open-existing'
/** Parent picked in the composer's Advanced drawer. Sidebar nesting only, no git effect. */
parentWorktreeId?: string
workspaceStatus?: WorkspaceStatus
@@ -102,6 +102,7 @@ export async function executeWorktreeCreation(
preparedRequest.compareBaseRef,
{
setupOnly: preparedRequest.setupOnly,
...(preparedRequest.branchIntent ? { branchIntent: preparedRequest.branchIntent } : {}),
...(hostAgentLaunch ? { hostAgentLaunch } : {}),
executionHostId: creationHostId,
...(preparedRequest.nameWasGenerated ? { nameWasGenerated: true } : {}),
@@ -1,3 +1,4 @@
import { setLocalRuntimeCapabilitiesForTests } from '../../runtime/local-runtime-capabilities'
import type { ExecutionHostId } from '../../../../shared/execution-host'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AppState } from '../types'
@@ -34,6 +35,79 @@ vi.mock('@/components/worktree-base-fallback-notice', () => ({
beforeEach(resetWorktreeSliceModuleMemory)
describe('worktree remote runtime mutations', () => {
it.each([false, true])(
'gates existing-branch intent on host capability (%s), retaining legacy suffix retry only without it',
async (supported) => {
const status = createCompatibleRuntimeStatusResponse('runtime-remote')
if (status.ok && !supported) {
status.result.capabilities = status.result.capabilities?.filter(
(cap) => cap !== 'worktree.open-existing-branch.v1'
)
}
runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) =>
args.method === 'status.get' ? status : runtimeEnvironmentCall(args)
)
const store = createRepoTestStore()
seedRepoHost(store, 'runtime:env-1')
const create = store.getState().createWorktree
const args: Parameters<typeof create> = ['repo1', 'feature/test', 'origin/main', 'skip']
args[12] = 'feature/test'
args[25] = { branchIntent: 'open-existing' }
runtimeEnvironmentCall.mockRejectedValueOnce(new Error('Branch already has PR #42'))
runtimeEnvironmentCall.mockResolvedValue({
id: 'create',
ok: true,
result: {
worktree: makeWorktree({
id: 'repo1::/path/feature-test-2',
repoId: 'repo1',
path: '/path/feature-test-2'
})
}
})
await (supported
? expect(create(...args)).rejects.toThrow('Branch already has PR #42')
: create(...args))
const first = runtimeEnvironmentCall.mock.calls[0][0].params
if (supported) {
expect(first).toMatchObject({
branchIntent: 'open-existing',
branchNameOverride: 'feature/test'
})
expect(runtimeEnvironmentCall).toHaveBeenCalledTimes(1)
} else {
expect(first).not.toHaveProperty('branchIntent')
expect(runtimeEnvironmentCall).toHaveBeenCalledTimes(2)
expect(runtimeEnvironmentCall.mock.calls[1][0].params).toMatchObject({
branchNameOverride: 'feature/test-2'
})
}
}
)
it('sends picker intent through the capable local host without suffixing a Git failure', async () => {
setLocalRuntimeCapabilitiesForTests(['worktree.open-existing-branch.v1'])
try {
const store = createRepoTestStore()
const create = store.getState().createWorktree
const args: Parameters<typeof create> = ['repo1', 'feature/test', 'origin/main', 'skip']
args[12] = 'feature/test'
args[25] = { branchIntent: 'open-existing' }
mockApi.worktrees.create.mockRejectedValueOnce(new Error('Branch already exists locally'))
await expect(create(...args)).rejects.toThrow('Branch already exists locally')
expect(mockApi.worktrees.create).toHaveBeenCalledTimes(1)
expect(mockApi.worktrees.create).toHaveBeenCalledWith(
expect.objectContaining({
branchIntent: 'open-existing',
branchNameOverride: 'feature/test'
})
)
} finally {
setLocalRuntimeCapabilitiesForTests(null)
}
})
beforeEach(() => {
vi.clearAllMocks()
resetRemoteRuntimeMocks()
@@ -18,10 +18,15 @@ import {
getGeneratedWorktreeCreateRetryCandidate,
isRetryableWorktreeCreateConflict
} from '../../../../../../shared/new-workspace/worktree-create-retry-policy'
import { assertRuntimeEnvironmentCapability } from '../../../../runtime/runtime-rpc-client'
import {
assertRuntimeEnvironmentCapability,
runtimeEnvironmentSupportsCapability
} from '../../../../runtime/runtime-rpc-client'
import type { RuntimeClientTarget } from '../../../../../../shared/runtime-client-target'
import { runtimeTargetForRepoOwner } from '@/lib/repo-runtime-owner'
import { WORKTREE_LINKED_WORK_ITEM_CONTEXT_RUNTIME_CAPABILITY } from '../../../../../../shared/protocol-version'
import { WORKTREE_OPEN_EXISTING_BRANCH_RUNTIME_CAPABILITY } from '../../../../../../shared/worktree-runtime-capabilities'
import { ensureLocalRuntimeCapabilities } from '../../../../runtime/local-runtime-capabilities'
import { showLocalBaseRefUpdateSuggestionToast } from '@/components/sidebar/local-base-ref-suggestion-toast'
import { requestWorktreeBaseFallbackNotice } from '@/components/worktree-base-fallback-notice'
import { showLocalBaseRefRefreshToast } from './local-base-ref-refresh-toast'
@@ -190,6 +195,20 @@ export function createCreateWorktree(
requestedHostId: options?.executionHostId,
resolveOwnHostId: () => repoHostId(get(), repoId)
})
if (options?.branchIntent === 'open-existing') {
const supported =
target.kind === 'environment'
? await runtimeEnvironmentSupportsCapability(
target.environmentId,
WORKTREE_OPEN_EXISTING_BRANCH_RUNTIME_CAPABILITY
).catch(() => false)
: (await ensureLocalRuntimeCapabilities())?.includes(
WORKTREE_OPEN_EXISTING_BRANCH_RUNTIME_CAPABILITY
) === true
if (supported) {
request.branchIntent = 'open-existing'
}
}
// Why outside the retry loop: a branch-name conflict retry must not re-warn about the same dropped pick.
const parent = resolveWorktreeCreateParent(get(), options?.parentWorktreeId, creationHostId)
let warnedParentDropped = false
@@ -264,7 +283,8 @@ export function createCreateWorktree(
return result
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
const shouldRetry = isRetryableWorktreeCreateConflict(message)
const shouldRetry =
request.branchIntent !== 'open-existing' && isRetryableWorktreeCreateConflict(message)
if (!shouldRetry || attempt === CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS - 1) {
throw error
}
@@ -11,6 +11,7 @@ import type { WorkspaceLinkedItem } from '../../../../../../shared/worktree/type
/** Trailing bag for `createWorktree` args that outgrew its positional list. */
export type CreateWorktreeCallOptions = {
branchIntent?: CreateWorktreeArgs['branchIntent']
/** Captured before background preparation so navigation cannot retarget creation. */
executionHostId?: ExecutionHostId
automationProvenanceRequest?: CreateWorktreeArgs['automationProvenanceRequest']
@@ -68,6 +69,7 @@ function sharedCreateFields(
baseBranch: request.baseBranch,
...(request.compareBaseRef ? { compareBaseRef: request.compareBaseRef } : {}),
...(attempt.branchNameOverride ? { branchNameOverride: attempt.branchNameOverride } : {}),
...(request.branchIntent ? { branchIntent: request.branchIntent } : {}),
setupDecision: request.setupDecision,
...(options?.setupOnly !== undefined ? { setupOnly: options.setupOnly } : {}),
sparseCheckout: request.sparseCheckout,
@@ -64,6 +64,7 @@ export function createWorktreesApi(): NonNullable<Partial<PreloadApi>['worktrees
baseBranch: args.baseBranch,
compareBaseRef: args.compareBaseRef,
branchNameOverride: args.branchNameOverride,
...(args.branchIntent ? { branchIntent: args.branchIntent } : {}),
linkedIssue: args.linkedIssue,
linkedPR: args.linkedPR,
...(args.linkedItems !== undefined ? { linkedItems: args.linkedItems } : {}),
@@ -35,6 +35,7 @@ export const WorktreeCreate = z
baseBranch: OptionalString,
compareBaseRef: OptionalString,
branchNameOverride: OptionalString,
branchIntent: z.literal('open-existing').optional(),
linkedIssue: TriStateLinkedIssue,
linkedPR: TriStateLinkedIssue,
linkedLinearIssue: z.string().optional(),
@@ -1,5 +1,8 @@
import { WORKSPACE_ATTACHMENT_RUNTIME_CAPABILITIES } from './workspace-attachment-capabilities'
export const WORKTREE_OPEN_EXISTING_BRANCH_RUNTIME_CAPABILITY =
'worktree.open-existing-branch.v1' as const
export const PROJECT_HOST_SETUP_RUNTIME_CAPABILITY = 'project-host-setup.v1' as const
// Why: older hosts strip `hostId` from setup update/delete and act on the first same-id twin.
export const PROJECT_HOST_SETUP_HOST_TARGET_RUNTIME_CAPABILITY =
@@ -31,6 +34,7 @@ export const WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY =
'worktree.background-removal.v1' as const
export const WORKTREE_RUNTIME_CAPABILITIES = [
WORKTREE_OPEN_EXISTING_BRANCH_RUNTIME_CAPABILITY,
PROJECT_HOST_SETUP_RUNTIME_CAPABILITY,
PROJECT_HOST_SETUP_HOST_TARGET_RUNTIME_CAPABILITY,
WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY,
+2
View File
@@ -97,6 +97,8 @@ export type CreateWorktreeArgs = {
compareBaseRef?: string
/** Existing local branches may contain slashes that the filesystem-safe worktree name cannot. */
branchNameOverride?: string
/** Only an explicit existing-branch pick opts out of new-branch allocation. */
branchIntent?: 'open-existing'
setupDecision?: SetupDecision
/** Per-create choice; omitted callers use the execution host's saved default. */
setupOnly?: boolean