mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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')
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user