diff --git a/src/main/git/worktree-add-remote-branch-upstream-real-git.test.ts b/src/main/git/worktree-add-remote-branch-upstream-real-git.test.ts index 91a44d0a497..a1f45720b25 100644 --- a/src/main/git/worktree-add-remote-branch-upstream-real-git.test.ts +++ b/src/main/git/worktree-add-remote-branch-upstream-real-git.test.ts @@ -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') + ) }) diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 45ff482f5e3..cfb5320e96b 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -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> | 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) { diff --git a/src/main/ipc/worktrees-existing-branch-checkout.test.ts b/src/main/ipc/worktrees-existing-branch-checkout.test.ts index d663b1ee61e..8dd4d9b43d7 100644 --- a/src/main/ipc/worktrees-existing-branch-checkout.test.ts +++ b/src/main/ipc/worktrees-existing-branch-checkout.test.ts @@ -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}` }) }) }) diff --git a/src/main/runtime/rpc/methods/worktree-create-args.test.ts b/src/main/runtime/rpc/methods/worktree-create-args.test.ts index 9a2063d68e3..883fb481b54 100644 --- a/src/main/runtime/rpc/methods/worktree-create-args.test.ts +++ b/src/main/runtime/rpc/methods/worktree-create-args.test.ts @@ -12,6 +12,19 @@ const build = (params: Record) => 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') diff --git a/src/main/runtime/rpc/methods/worktree-create-args.ts b/src/main/runtime/rpc/methods/worktree-create-args.ts index cff413c7d60..8522004bff0 100644 --- a/src/main/runtime/rpc/methods/worktree-create-args.ts +++ b/src/main/runtime/rpc/methods/worktree-create-args.ts @@ -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, diff --git a/src/main/runtime/runtime-local-worktree-create-candidate.ts b/src/main/runtime/runtime-local-worktree-create-candidate.ts index eab4fac881b..d8f68b149b7 100644 --- a/src/main/runtime/runtime-local-worktree-create-candidate.ts +++ b/src/main/runtime/runtime-local-worktree-create-candidate.ts @@ -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 => { + 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 } diff --git a/src/main/runtime/runtime-local-worktree-create.test.ts b/src/main/runtime/runtime-local-worktree-create.test.ts index 0ca48a6bc6d..856519135de 100644 --- a/src/main/runtime/runtime-local-worktree-create.test.ts +++ b/src/main/runtime/runtime-local-worktree-create.test.ts @@ -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[] = [] 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 () => { diff --git a/src/main/runtime/runtime-managed-worktree-create-types.ts b/src/main/runtime/runtime-managed-worktree-create-types.ts index cf8e5926fa6..74ef7ba9a28 100644 --- a/src/main/runtime/runtime-managed-worktree-create-types.ts +++ b/src/main/runtime/runtime-managed-worktree-create-types.ts @@ -24,6 +24,7 @@ export type RuntimeManagedWorktreeCreateArgs = { baseBranch?: string compareBaseRef?: string branchNameOverride?: string + branchIntent?: CreateWorktreeArgs['branchIntent'] linkedIssue?: number | null linkedPR?: number | null linkedLinearIssue?: string diff --git a/src/renderer/src/hooks/composer-state/composer-review-base-test-fixture.ts b/src/renderer/src/hooks/composer-state/composer-review-base-test-fixture.ts index dd42f5c0a63..afd068e4f67 100644 --- a/src/renderer/src/hooks/composer-state/composer-review-base-test-fixture.ts +++ b/src/renderer/src/hooks/composer-state/composer-review-base-test-fixture.ts @@ -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() const [compareBaseRef, setCompareBaseRef] = useState() const [branchNameOverride, setBranchNameOverride] = useState() + const [, setBranchIntent] = useState() 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(), diff --git a/src/renderer/src/hooks/composer-state/composer-source-state.ts b/src/renderer/src/hooks/composer-state/composer-source-state.ts index df2e8fc96de..045c1ed074d 100644 --- a/src/renderer/src/hooks/composer-state/composer-source-state.ts +++ b/src/renderer/src/hooks/composer-state/composer-source-state.ts @@ -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, diff --git a/src/renderer/src/hooks/composer-state/composer-submit-model.ts b/src/renderer/src/hooks/composer-state/composer-submit-model.ts index 977887a7372..e6afadda3b9 100644 --- a/src/renderer/src/hooks/composer-state/composer-submit-model.ts +++ b/src/renderer/src/hooks/composer-state/composer-submit-model.ts @@ -30,6 +30,7 @@ export type QuickSubmitSource = { } export type PreparedQuickSubmit = QuickSubmitSource & { + branchIntent?: WorktreeCreationRequest['branchIntent'] effectiveSetupDecision: SetupDecision hookPreparation: WorktreeCreationRequest['hookPreparation'] linkedLinearIssue: string | undefined diff --git a/src/renderer/src/hooks/composer-state/composer-submit-orchestration.ts b/src/renderer/src/hooks/composer-state/composer-submit-orchestration.ts index 86a33910db9..42e2a840e12 100644 --- a/src/renderer/src/hooks/composer-state/composer-submit-orchestration.ts +++ b/src/renderer/src/hooks/composer-state/composer-submit-orchestration.ts @@ -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, diff --git a/src/renderer/src/hooks/composer-state/identity-model.ts b/src/renderer/src/hooks/composer-state/identity-model.ts index be289c4e137..190ffb55b49 100644 --- a/src/renderer/src/hooks/composer-state/identity-model.ts +++ b/src/renderer/src/hooks/composer-state/identity-model.ts @@ -19,6 +19,8 @@ export type ComposerIdentityModel = { compareBaseRef: string | undefined setCompareBaseRef: React.Dispatch> branchNameOverride: string | undefined + branchIntent?: 'open-existing' + setBranchIntent: React.Dispatch> setBranchNameOverride: React.Dispatch> parentWorktreeId: string | null setParentWorktreeId: React.Dispatch> diff --git a/src/renderer/src/hooks/composer-state/quick-creation-execution.test.ts b/src/renderer/src/hooks/composer-state/quick-creation-execution.test.ts index 02486d8d5b8..becde3aa646 100644 --- a/src/renderer/src/hooks/composer-state/quick-creation-execution.test.ts +++ b/src/renderer/src/hooks/composer-state/quick-creation-execution.test.ts @@ -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({ diff --git a/src/renderer/src/hooks/composer-state/quick-creation-execution.ts b/src/renderer/src/hooks/composer-state/quick-creation-execution.ts index 9ee8db0f138..b66ddfe53ed 100644 --- a/src/renderer/src/hooks/composer-state/quick-creation-execution.ts +++ b/src/renderer/src/hooks/composer-state/quick-creation-execution.ts @@ -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, diff --git a/src/renderer/src/hooks/composer-state/quick-creation-request.ts b/src/renderer/src/hooks/composer-state/quick-creation-request.ts index 71a69372d5f..d90de4d70ce 100644 --- a/src/renderer/src/hooks/composer-state/quick-creation-request.ts +++ b/src/renderer/src/hooks/composer-state/quick-creation-request.ts @@ -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 diff --git a/src/renderer/src/hooks/composer-state/quick-submit-input-contract.ts b/src/renderer/src/hooks/composer-state/quick-submit-input-contract.ts index d9bd86d8106..ea5fa328e69 100644 --- a/src/renderer/src/hooks/composer-state/quick-submit-input-contract.ts +++ b/src/renderer/src/hooks/composer-state/quick-submit-input-contract.ts @@ -3,6 +3,7 @@ import type { ComposerModel } from './composer-model' export type QuickSubmitPreparationInput = Pick< ComposerModel, | 'branchAutoNameRef' + | 'branchIntent' | 'branchNameOverridePreservesNameEdits' | 'checkedHooksContextKey' | 'commitHookCheckIfCurrent' diff --git a/src/renderer/src/hooks/composer-state/quick-submit-preparation.ts b/src/renderer/src/hooks/composer-state/quick-submit-preparation.ts index 2f7753ed650..8216b965db4 100644 --- a/src/renderer/src/hooks/composer-state/quick-submit-preparation.ts +++ b/src/renderer/src/hooks/composer-state/quick-submit-preparation.ts @@ -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, diff --git a/src/renderer/src/hooks/composer-state/work-item-source-actions.test.ts b/src/renderer/src/hooks/composer-state/work-item-source-actions.test.ts new file mode 100644 index 00000000000..7788e31d75a --- /dev/null +++ b/src/renderer/src/hooks/composer-state/work-item-source-actions.test.ts @@ -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(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() +}) diff --git a/src/renderer/src/hooks/composer-state/work-item-source-actions.ts b/src/renderer/src/hooks/composer-state/work-item-source-actions.ts index 0aec5184e2e..2afc0727cee 100644 --- a/src/renderer/src/hooks/composer-state/work-item-source-actions.ts +++ b/src/renderer/src/hooks/composer-state/work-item-source-actions.ts @@ -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, diff --git a/src/renderer/src/hooks/composer-state/workspace-identity-state.ts b/src/renderer/src/hooks/composer-state/workspace-identity-state.ts index b28a2557d71..50a0ee1e015 100644 --- a/src/renderer/src/hooks/composer-state/workspace-identity-state.ts +++ b/src/renderer/src/hooks/composer-state/workspace-identity-state.ts @@ -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( + const [branchNameOverride, updateBranchNameOverride] = useState( initialLinearBranchName ) + const [branchIntent, setBranchIntent] = useState() + const setBranchNameOverride = useCallback((value: SetStateAction) => { + setBranchIntent(undefined) + updateBranchNameOverride(value) + }, []) const [parentWorktreeId, setParentWorktreeId] = useState(null) @@ -213,6 +219,8 @@ export function useWorkspaceIdentityState(input: WorkspaceIdentityStateInput) { compareBaseRef, setCompareBaseRef, branchNameOverride, + branchIntent, + setBranchIntent, setBranchNameOverride, parentWorktreeId, setParentWorktreeId, diff --git a/src/renderer/src/lib/pending-worktree-creation.ts b/src/renderer/src/lib/pending-worktree-creation.ts index a01a87f7cb7..80e55a67e52 100644 --- a/src/renderer/src/lib/pending-worktree-creation.ts +++ b/src/renderer/src/lib/pending-worktree-creation.ts @@ -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 diff --git a/src/renderer/src/lib/worktree-creation-flow-execute.ts b/src/renderer/src/lib/worktree-creation-flow-execute.ts index ee2e015cd21..b68ff35a1fb 100644 --- a/src/renderer/src/lib/worktree-creation-flow-execute.ts +++ b/src/renderer/src/lib/worktree-creation-flow-execute.ts @@ -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 } : {}), diff --git a/src/renderer/src/store/slices/worktrees-remote-runtime-create.test.ts b/src/renderer/src/store/slices/worktrees-remote-runtime-create.test.ts index 242c6ad30eb..9c322acb3e3 100644 --- a/src/renderer/src/store/slices/worktrees-remote-runtime-create.test.ts +++ b/src/renderer/src/store/slices/worktrees-remote-runtime-create.test.ts @@ -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 = ['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 = ['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() diff --git a/src/renderer/src/store/slices/worktrees/create/create-worktree.ts b/src/renderer/src/store/slices/worktrees/create/create-worktree.ts index c3779228cb8..17c2e2b36c6 100644 --- a/src/renderer/src/store/slices/worktrees/create/create-worktree.ts +++ b/src/renderer/src/store/slices/worktrees/create/create-worktree.ts @@ -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 } diff --git a/src/renderer/src/store/slices/worktrees/create/worktree-create-payload.ts b/src/renderer/src/store/slices/worktrees/create/worktree-create-payload.ts index 0a12986da20..48192acbba9 100644 --- a/src/renderer/src/store/slices/worktrees/create/worktree-create-payload.ts +++ b/src/renderer/src/store/slices/worktrees/create/worktree-create-payload.ts @@ -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, diff --git a/src/renderer/src/web/preload-api/web-worktrees-api.ts b/src/renderer/src/web/preload-api/web-worktrees-api.ts index bb5c7668c9d..e87bd111eed 100644 --- a/src/renderer/src/web/preload-api/web-worktrees-api.ts +++ b/src/renderer/src/web/preload-api/web-worktrees-api.ts @@ -64,6 +64,7 @@ export function createWorktreesApi(): NonNullable['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 } : {}), diff --git a/src/shared/rpc-contract/worktree-create-params.ts b/src/shared/rpc-contract/worktree-create-params.ts index 65f16e9742b..69cd8d3b015 100644 --- a/src/shared/rpc-contract/worktree-create-params.ts +++ b/src/shared/rpc-contract/worktree-create-params.ts @@ -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(), diff --git a/src/shared/worktree-runtime-capabilities.ts b/src/shared/worktree-runtime-capabilities.ts index bd9b7ebbb7a..748ed154322 100644 --- a/src/shared/worktree-runtime-capabilities.ts +++ b/src/shared/worktree-runtime-capabilities.ts @@ -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, diff --git a/src/shared/worktree/create-types.ts b/src/shared/worktree/create-types.ts index 79a07b9ecc2..3874eb404cf 100644 --- a/src/shared/worktree/create-types.ts +++ b/src/shared/worktree/create-types.ts @@ -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