Fix PR workspace branch checkout (#4003)

This commit is contained in:
Jinjing 2026-05-31 00:06:57 -07:00 committed by GitHub
parent a090176de9
commit f0308a86e6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 1330 additions and 188 deletions

View File

@ -141,8 +141,13 @@ describe('resolveGitHubPrStartPoint', () => {
})
})
it('returns a tracking ref and push target when same-repo branch fetch succeeds', async () => {
const gitExec = vi.fn(async () => ({ stdout: '', stderr: '' }))
it('returns the verified head SHA, branch override, and push target when same-repo branch fetch succeeds', async () => {
const gitExec = vi.fn(async (args: string[]) => {
if (args[0] === 'rev-parse') {
return { stdout: 'abc123\n', stderr: '' }
}
return { stdout: '', stderr: '' }
})
const result = await resolveGitHubPrStartPoint({
repoPath: '/repo-root',
@ -159,7 +164,9 @@ describe('resolveGitHubPrStartPoint', () => {
])
expect(gitExec).toHaveBeenCalledWith(['rev-parse', '--verify', 'origin/feature/add-feature'])
expect(result).toEqual({
baseBranch: 'origin/feature/add-feature',
baseBranch: 'abc123',
headSha: 'abc123',
branchNameOverride: 'feature/add-feature',
pushTarget: { remoteName: 'origin', branchName: 'feature/add-feature' }
})
})

View File

@ -1,4 +1,4 @@
import type { GitPushTarget } from '../../shared/types'
import type { GitHubPrStartPoint, GitPushTarget } from '../../shared/types'
import { isMissingRemoteRefGitError } from '../git/fetch-error-classification'
import { getPullRequestPushTarget, getWorkItem } from './client'
@ -14,9 +14,7 @@ type ResolveGitHubPrStartPointArgs = {
resolveRemote: () => Promise<string>
}
type ResolveGitHubPrStartPointResult =
| { baseBranch: string; pushTarget?: GitPushTarget }
| { error: string }
type ResolveGitHubPrStartPointResult = GitHubPrStartPoint | { error: string }
export async function resolveGitHubPrStartPoint(
args: ResolveGitHubPrStartPointArgs
@ -122,14 +120,21 @@ export async function resolveGitHubPrStartPoint(
}
const remoteRef = `${remote}/${headRefName}`
let headSha: string
try {
await args.gitExec(['rev-parse', '--verify', remoteRef])
const { stdout } = await args.gitExec(['rev-parse', '--verify', remoteRef])
headSha = stdout.trim()
} catch {
return { error: `Remote ref ${remoteRef} does not exist after fetch.` }
}
if (!headSha) {
return { error: `Empty SHA resolving PR #${args.prNumber} head.` }
}
return {
baseBranch: remoteRef,
baseBranch: headSha,
headSha,
branchNameOverride: headRefName,
pushTarget: { remoteName: remote, branchName: headRefName }
}
}

View File

@ -59,7 +59,7 @@ import {
areWorktreePathsEqual
} from './worktree-logic'
import { getRepoIdFromWorktreeId } from '../../shared/worktree-id'
import { invalidateAuthorizedRootsCache } from './filesystem-auth'
import { invalidateAuthorizedRootsCache, isENOENT } from './filesystem-auth'
import { createWorktreeSymlinks } from './worktree-symlinks'
import { normalizeSparseDirectories } from './sparse-checkout-directories'
import { joinWorktreeRelativePath } from '../runtime/runtime-relative-paths'
@ -160,19 +160,34 @@ async function canCheckoutExistingLocalBranch(
branchName: string,
baseBranch: string
): Promise<boolean> {
if (normalizeLocalBranchName(baseBranch) !== branchName) {
return false
}
let localHead = ''
try {
await gitExecFileAsync(
const { stdout } = await gitExecFileAsync(
['rev-parse', '--verify', '--quiet', `refs/heads/${branchName}^{commit}`],
{
cwd: repoPath
}
)
localHead = stdout.trim()
} catch {
return false
}
if (normalizeLocalBranchName(baseBranch) !== branchName) {
if (!localHead) {
return false
}
try {
const { stdout } = await gitExecFileAsync(
['rev-parse', '--verify', '--quiet', `${baseBranch}^{commit}`],
{ cwd: repoPath }
)
if (stdout.trim() !== localHead) {
return false
}
} catch {
return false
}
}
const worktrees = await listWorktrees(repoPath)
return !worktrees.some((worktree) => normalizeLocalBranchName(worktree.branch) === branchName)
}
@ -183,21 +198,96 @@ async function canCheckoutExistingLocalBranchSsh(
branchName: string,
baseBranch: string
): Promise<boolean> {
if (normalizeLocalBranchName(baseBranch) !== branchName) {
return false
}
let localHead = ''
try {
await provider.exec(
const { stdout } = await provider.exec(
['rev-parse', '--verify', '--quiet', `refs/heads/${branchName}^{commit}`],
repoPath
)
localHead = stdout.trim()
} catch {
return false
}
if (normalizeLocalBranchName(baseBranch) !== branchName) {
if (!localHead) {
return false
}
try {
const { stdout } = await provider.exec(
['rev-parse', '--verify', '--quiet', `${baseBranch}^{commit}`],
repoPath
)
if (stdout.trim() !== localHead) {
return false
}
} catch {
return false
}
}
const worktrees = await provider.listWorktrees(repoPath)
return !worktrees.some((worktree) => normalizeLocalBranchName(worktree.branch) === branchName)
}
type SelectedPrBranchInput = Pick<
CreateWorktreeArgs,
'branchNameOverride' | 'linkedPR' | 'pushTarget'
>
function isSelectedGitHubPrBranchOverride(
args: SelectedPrBranchInput,
branchName: string
): boolean {
return typeof args.linkedPR === 'number' && args.branchNameOverride === branchName
}
function isMatchingSelectedGitHubPr(
existingPR: Awaited<ReturnType<typeof getPRForBranch>>,
args: SelectedPrBranchInput,
branchName: string
): boolean {
return Boolean(
existingPR &&
isSelectedGitHubPrBranchOverride(args, branchName) &&
existingPR.number === args.linkedPR
)
}
function isAllowedPushTargetRemoteConflict(
conflictKind: 'local' | 'remote' | null,
branchName: string,
args: SelectedPrBranchInput
): boolean {
return (
conflictKind === 'remote' &&
isSelectedGitHubPrBranchOverride(args, branchName) &&
args.pushTarget?.branchName === branchName
)
}
function remoteSiblingWorktreePath(repoPath: string, sanitizedName: string): string {
return isWindowsAbsolutePathLike(repoPath)
? win32.join(win32.dirname(repoPath), sanitizedName)
: `${repoPath}/../${sanitizedName}`
}
async function remotePathExists(
fsProvider: IFilesystemProvider | null | undefined,
pathValue: string
): Promise<boolean> {
if (!fsProvider) {
return false
}
try {
await fsProvider.stat(pathValue)
return true
} catch (error) {
if (isENOENT(error)) {
return false
}
throw error
}
}
async function ensureUniqueRemoteName(repoPath: string, preferred: string): Promise<string> {
const { stdout } = await gitExecFileAsync(['remote'], { cwd: repoPath })
const existing = new Set(
@ -714,10 +804,12 @@ export async function createRemoteWorktree(
mainWindow: BrowserWindow
): Promise<CreateWorktreeResult> {
const provider = requireSshGitProvider(repo.connectionId!)
const fsProvider = getSshFilesystemProvider(repo.connectionId!)
const settings = store.getSettings()
const requestedName = args.name
let effectiveRequestedName = args.name
const sanitizedName = sanitizeWorktreeName(args.name)
let effectiveSanitizedName = sanitizedName
const requestedDisplayName = args.displayName
? sanitizeWorktreeDisplayName(args.displayName)
: undefined
@ -735,8 +827,7 @@ export async function createRemoteWorktree(
username
)
// Compute worktree path relative to the repo's parent on the remote
const remotePath = `${repo.path}/../${sanitizedName}`
let remotePath = remoteSiblingWorktreePath(repo.path, effectiveSanitizedName)
// Determine base branch
// Why: previously fell back to a hardcoded 'origin/main' when
@ -782,6 +873,27 @@ export async function createRemoteWorktree(
}
}
let remotePathResolved = !args.branchNameOverride
for (let suffix = 1; args.branchNameOverride && suffix < 100; suffix += 1) {
effectiveSanitizedName = suffix === 1 ? sanitizedName : `${sanitizedName}-${suffix}`
effectiveRequestedName =
suffix === 1
? args.name
: args.name.trim()
? `${args.name}-${suffix}`
: effectiveSanitizedName
remotePath = remoteSiblingWorktreePath(repo.path, effectiveSanitizedName)
if (!(await remotePathExists(fsProvider, remotePath))) {
remotePathResolved = true
break
}
}
if (!remotePathResolved) {
throw new Error(
`Could not find an available remote worktree path for "${sanitizedName}". Pick a different worktree name.`
)
}
const sparseDirectories = args.sparseCheckout
? normalizeSparseDirectories(args.sparseCheckout.directories)
: []
@ -837,7 +949,6 @@ export async function createRemoteWorktree(
? await refreshLocalBaseRefForRemoteWorktreeCreate(provider, repo.path, remoteTrackingBase)
: undefined
const fsProvider = getSshFilesystemProvider(repo.connectionId!)
if (fsProvider) {
const primaryHooks = await readRemoteEffectiveHooks(repo, fsProvider, repo.path)
if (primaryHooks?.scripts.setup) {
@ -937,7 +1048,7 @@ export async function createRemoteWorktree(
// Re-list to get the created worktree info
const gitWorktrees = await provider.listWorktrees(repo.path)
const created = gitWorktrees.find(
(gw) => gw.branch?.endsWith(branchName) || gw.path.endsWith(sanitizedName)
(gw) => gw.branch?.endsWith(branchName) || gw.path.endsWith(effectiveSanitizedName)
)
if (!created) {
throw new Error('Worktree created but not found in listing')
@ -978,8 +1089,8 @@ export async function createRemoteWorktree(
...(configuredPushTarget ? { pushTarget: configuredPushTarget } : {}),
...(requestedDisplayName
? { displayName: requestedDisplayName }
: shouldSetDisplayName(requestedName, branchName, sanitizedName)
? { displayName: requestedName }
: shouldSetDisplayName(effectiveRequestedName, branchName, effectiveSanitizedName)
? { displayName: effectiveRequestedName }
: {}),
...(isTuiAgent(args.createdWithAgent) ? { createdWithAgent: args.createdWithAgent } : {}),
...(sparseDirectories.length > 0
@ -1184,11 +1295,9 @@ export async function createLocalWorktree(
repo.path,
selectedExistingLocalBranchName
? selectedExistingLocalBranchName
: suffix === 1 && args.branchNameOverride
: args.branchNameOverride
? args.branchNameOverride
: args.branchNameOverride
? `${args.branchNameOverride}-${suffix}`
: undefined,
: undefined,
effectiveSanitizedName,
settings,
username
@ -1202,7 +1311,32 @@ export async function createLocalWorktree(
lastBranchConflictKind = checkoutExistingBranch
? null
: await getBranchConflictKind(repo.path, branchName, baseBranch)
const allowedPushTargetRemoteConflict =
lastBranchConflictKind &&
isAllowedPushTargetRemoteConflict(lastBranchConflictKind, branchName, args)
if (lastBranchConflictKind) {
if (allowedPushTargetRemoteConflict) {
lastExistingPR = null
let lookupFailed = false
try {
lastExistingPR = await getPRForBranch(repo.path, branchName)
} catch {
lookupFailed = true
}
if (!lookupFailed && isMatchingSelectedGitHubPr(lastExistingPR, args, branchName)) {
lastBranchConflictKind = null
} else if (lastExistingPR) {
break
}
}
}
if (lastBranchConflictKind) {
// Why: PR resolver-provided branch names are exact branch identity.
// Retrying with a suffixed branch would silently detach the worktree
// from the PR being opened.
if (args.branchNameOverride) {
break
}
continue
}
@ -1219,7 +1353,10 @@ export async function createLocalWorktree(
} catch {
// GitHub API may be unreachable, rate-limited, or token missing
}
if (lastExistingPR) {
if (lastExistingPR && !isMatchingSelectedGitHubPr(lastExistingPR, args, branchName)) {
if (args.branchNameOverride) {
break
}
continue
}
}

View File

@ -687,43 +687,248 @@ describe('registerWorktreeHandlers', () => {
})
})
it('suffixes branchNameOverride without flattening slashes when the first branch collides', async () => {
it('does not suffix branchNameOverride when the requested branch collides', async () => {
getBranchConflictKindMock.mockImplementation(async (_repoPath: string, branch: string) =>
branch === 'feature/something' ? 'remote' : null
)
await expect(
handlers['worktrees:create'](null, {
repoId: 'repo-1',
name: 'feature/something',
branchNameOverride: 'feature/something'
})
).rejects.toThrow(
'Branch "feature/something" already exists on a remote. Pick a different worktree name.'
)
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
['check-ref-format', '--branch', 'feature/something'],
{ cwd: '/workspace/repo' }
)
expect(addWorktreeMock).not.toHaveBeenCalled()
})
it('allows a resolver-provided PR branch override to match its remote push target', async () => {
getBranchConflictKindMock.mockImplementation(async (_repoPath: string, branch: string) =>
branch === 'feature/fix' ? 'remote' : null
)
listWorktreesMock.mockResolvedValue([
{
path: '/workspace/feature-something-2',
path: '/workspace/fix-title',
head: 'abc123',
branch: 'feature/something-2',
branch: 'refs/heads/feature/fix',
isBare: false,
isMainWorktree: false
}
])
const result = await handlers['worktrees:create'](null, {
repoId: 'repo-1',
name: 'feature/something',
branchNameOverride: 'feature/something'
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
getPRForBranchMock.mockResolvedValueOnce({
number: 42,
title: 'Selected PR',
state: 'open',
url: 'https://example.com/pr/42',
checksStatus: 'success',
updatedAt: '2026-05-21T00:00:00Z',
mergeable: 'UNKNOWN'
})
await handlers['worktrees:create'](null, {
repoId: 'repo-1',
name: 'fix-title',
baseBranch: 'abc123',
branchNameOverride: 'feature/fix',
linkedPR: 42,
pushTarget: { remoteName: 'origin', branchName: 'feature/fix' }
})
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
['check-ref-format', '--branch', 'feature/something-2'],
{ cwd: '/workspace/repo' }
)
expect(addWorktreeMock).toHaveBeenCalledWith(
'/workspace/repo',
'/workspace/feature-something-2',
'feature/something-2',
'origin/main',
'/workspace/fix-title',
'feature/fix',
'abc123',
false
)
expect(result).toEqual({
worktree: expect.objectContaining({
path: '/workspace/feature-something-2',
branch: 'feature/something-2'
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
['branch', '--set-upstream-to', 'origin/feature/fix', 'feature/fix'],
{ cwd: '/workspace/fix-title' }
)
expect(getPRForBranchMock).toHaveBeenCalledWith('/workspace/repo', 'feature/fix')
})
it('rejects a matching push target branch without selected PR metadata', async () => {
getBranchConflictKindMock.mockImplementation(async (_repoPath: string, branch: string) =>
branch === 'feature/fix' ? 'remote' : null
)
await expect(
handlers['worktrees:create'](null, {
repoId: 'repo-1',
name: 'fix-title',
baseBranch: 'abc123',
branchNameOverride: 'feature/fix',
pushTarget: { remoteName: 'origin', branchName: 'feature/fix' }
})
).rejects.toThrow(
'Branch "feature/fix" already exists on a remote. Pick a different worktree name.'
)
expect(addWorktreeMock).not.toHaveBeenCalled()
})
it('rejects a matching push target branch when selected PR metadata has no PR number', async () => {
getBranchConflictKindMock.mockImplementation(async (_repoPath: string, branch: string) =>
branch === 'feature/fix' ? 'remote' : null
)
await expect(
handlers['worktrees:create'](null, {
repoId: 'repo-1',
name: 'fix-title',
baseBranch: 'abc123',
branchNameOverride: 'feature/fix',
linkedPR: null,
pushTarget: { remoteName: 'origin', branchName: 'feature/fix' }
})
).rejects.toThrow(
'Branch "feature/fix" already exists on a remote. Pick a different worktree name.'
)
expect(addWorktreeMock).not.toHaveBeenCalled()
})
it('rejects a matching push target branch when the existing PR is different', async () => {
getBranchConflictKindMock.mockImplementation(async (_repoPath: string, branch: string) =>
branch === 'feature/fix' ? 'remote' : null
)
getPRForBranchMock.mockResolvedValueOnce({
number: 43,
title: 'Different PR',
state: 'open',
url: 'https://example.com/pr/43',
checksStatus: 'success',
updatedAt: '2026-05-21T00:00:00Z',
mergeable: 'UNKNOWN'
})
await expect(
handlers['worktrees:create'](null, {
repoId: 'repo-1',
name: 'fix-title',
baseBranch: 'abc123',
branchNameOverride: 'feature/fix',
linkedPR: 42,
pushTarget: { remoteName: 'origin', branchName: 'feature/fix' }
})
).rejects.toThrow('Branch "feature/fix" already has PR #43. Pick a different worktree name.')
expect(getPRForBranchMock).toHaveBeenCalledWith('/workspace/repo', 'feature/fix')
expect(addWorktreeMock).not.toHaveBeenCalled()
})
it('rejects a selected PR remote conflict when the PR lookup fails', async () => {
getBranchConflictKindMock.mockImplementation(async (_repoPath: string, branch: string) =>
branch === 'feature/fix' ? 'remote' : null
)
getPRForBranchMock.mockRejectedValueOnce(new Error('gh unavailable'))
await expect(
handlers['worktrees:create'](null, {
repoId: 'repo-1',
name: 'fix-title',
baseBranch: 'abc123',
branchNameOverride: 'feature/fix',
linkedPR: 42,
pushTarget: { remoteName: 'origin', branchName: 'feature/fix' }
})
).rejects.toThrow(
'Branch "feature/fix" already exists on a remote. Pick a different worktree name.'
)
expect(getPRForBranchMock).toHaveBeenCalledWith('/workspace/repo', 'feature/fix')
expect(addWorktreeMock).not.toHaveBeenCalled()
})
it('checks out an unused existing PR branch only when it is at the resolved head SHA', async () => {
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
if (args[0] === 'rev-parse' && args.includes('refs/heads/feature/fix^{commit}')) {
return { stdout: 'abc123\n', stderr: '' }
}
if (args[0] === 'rev-parse' && args.includes('abc123^{commit}')) {
return { stdout: 'abc123\n', stderr: '' }
}
return { stdout: '', stderr: '' }
})
listWorktreesMock
.mockResolvedValueOnce([
{
path: '/workspace/repo',
head: 'main',
branch: 'refs/heads/main',
isBare: false,
isMainWorktree: true
}
])
.mockResolvedValueOnce([
{
path: '/workspace/repo',
head: 'main',
branch: 'refs/heads/main',
isBare: false,
isMainWorktree: true
},
{
path: '/workspace/fix-title',
head: 'abc123',
branch: 'refs/heads/feature/fix',
isBare: false,
isMainWorktree: false
}
])
await handlers['worktrees:create'](null, {
repoId: 'repo-1',
name: 'fix-title',
baseBranch: 'abc123',
branchNameOverride: 'feature/fix'
})
expect(getBranchConflictKindMock).not.toHaveBeenCalled()
expect(addWorktreeMock).toHaveBeenCalledWith(
'/workspace/repo',
'/workspace/fix-title',
'feature/fix',
'abc123',
false,
false,
{ checkoutExistingBranch: true }
)
})
it('rejects an existing PR branch when its tip differs from the resolved head SHA', async () => {
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
if (args[0] === 'rev-parse' && args.includes('refs/heads/feature/fix^{commit}')) {
return { stdout: 'old123\n', stderr: '' }
}
if (args[0] === 'rev-parse' && args.includes('abc123^{commit}')) {
return { stdout: 'abc123\n', stderr: '' }
}
return { stdout: '', stderr: '' }
})
getBranchConflictKindMock.mockResolvedValueOnce('local')
await expect(
handlers['worktrees:create'](null, {
repoId: 'repo-1',
name: 'fix-title',
baseBranch: 'abc123',
branchNameOverride: 'feature/fix'
})
).rejects.toThrow(
'Branch "feature/fix" already exists locally. Pick a different worktree name.'
)
expect(addWorktreeMock).not.toHaveBeenCalled()
})
it('persists a sanitized artifact title as the worktree display name', async () => {
@ -974,6 +1179,41 @@ describe('registerWorktreeHandlers', () => {
})
})
it('returns the same-repo PR head SHA and exact branch override when resolving a PR base', async () => {
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
if (args[0] === 'rev-parse') {
return { stdout: 'def456\n', stderr: '' }
}
return { stdout: '', stderr: '' }
})
const result = await handlers['worktrees:resolvePrBase'](null, {
repoId: 'repo-1',
prNumber: 42,
headRefName: 'feature/add-feature',
isCrossRepository: false
})
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
[
'fetch',
'origin',
'+refs/heads/feature/add-feature:refs/remotes/origin/feature/add-feature'
],
{ cwd: '/workspace/repo' }
)
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
['rev-parse', '--verify', 'origin/feature/add-feature'],
{ cwd: '/workspace/repo' }
)
expect(result).toEqual({
baseBranch: 'def456',
headSha: 'def456',
branchNameOverride: 'feature/add-feature',
pushTarget: { remoteName: 'origin', branchName: 'feature/add-feature' }
})
})
it('resolves a fork PR base even when push-target discovery fails', async () => {
getPullRequestPushTargetMock.mockRejectedValueOnce(new Error('lookup failed'))
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
@ -1425,6 +1665,92 @@ describe('registerWorktreeHandlers', () => {
})
})
it('suffixes only the SSH worktree path when an exact PR branch checkout path exists', async () => {
const repo = {
id: 'repo-ssh',
path: '/remote/repo',
displayName: 'ssh',
badgeColor: '#000',
addedAt: 0,
connectionId: 'conn-1',
worktreeBaseRef: 'abc123'
}
const provider = {
exec: vi.fn().mockImplementation(async (args: string[]) => {
if (args[0] === 'remote') {
return { stdout: 'origin\n', stderr: '' }
}
if (args[0] === 'rev-parse' && args.includes('refs/heads/feature/fix^{commit}')) {
return { stdout: 'abc123\n', stderr: '' }
}
if (args[0] === 'rev-parse' && args.includes('abc123^{commit}')) {
return { stdout: 'abc123\n', stderr: '' }
}
return { stdout: '', stderr: '' }
}),
fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined),
addWorktree: vi.fn().mockResolvedValue(undefined),
removeWorktree: vi.fn().mockResolvedValue(undefined),
listWorktrees: vi
.fn()
.mockResolvedValueOnce([
{
path: '/remote/repo',
head: 'main',
branch: 'refs/heads/main',
isBare: false,
isMainWorktree: true
}
])
.mockResolvedValueOnce([
{
path: '/remote/fix-title-2',
head: 'abc123',
branch: 'refs/heads/feature/fix',
isBare: false,
isMainWorktree: false
}
])
}
const fsProvider = {
stat: vi.fn().mockImplementation(async (pathValue: string) => {
if (pathValue === '/remote/repo/../fix-title') {
return { size: 0, type: 'directory', mtime: 0 }
}
const error = new Error('missing') as Error & { code: string }
error.code = 'ENOENT'
throw error
}),
readFile: vi.fn().mockRejectedValue(new Error('missing'))
}
const mux = {
request: vi.fn().mockResolvedValue(undefined),
notify: vi.fn()
}
store.getRepos.mockReturnValue([repo])
store.getRepo.mockReturnValue(repo)
getSshGitProviderMock.mockReturnValue(provider)
getSshFilesystemProviderMock.mockReturnValue(fsProvider)
getActiveMultiplexerMock.mockReturnValue(mux)
await handlers['worktrees:create'](null, {
repoId: 'repo-ssh',
name: 'fix-title',
baseBranch: 'abc123',
branchNameOverride: 'feature/fix'
})
expect(provider.addWorktree).toHaveBeenCalledWith(
'/remote/repo',
'feature/fix',
'/remote/repo/../fix-title-2',
{ checkoutExistingBranch: true }
)
expect(mux.request).toHaveBeenCalledWith('session.registerRoot', {
rootPath: '/remote/repo/../fix-title-2'
})
})
it('unsets SSH branch base config before removing a sparse worktree after setup failure', async () => {
const repo = {
id: 'repo-ssh',

View File

@ -13,6 +13,7 @@ import type {
DetectedWorktree,
DetectedWorktreeListResult,
ForceDeleteWorktreeBranchResult,
GitHubPrStartPoint,
GitPushTarget,
GitWorktreeInfo,
OrcaHooks,
@ -891,7 +892,7 @@ export function registerWorktreeHandlers(
headRefName?: string
isCrossRepository?: boolean
}
): Promise<{ baseBranch: string; pushTarget?: GitPushTarget } | { error: string }> => {
): Promise<GitHubPrStartPoint | { error: string }> => {
const repo = store.getRepo(args.repoId)
if (!repo) {
return { error: 'Repo not found' }

View File

@ -1220,6 +1220,9 @@ describe('OrcaRuntimeService', () => {
ensurePathWithinWorkspaceMock.mockReturnValue(createdWorktree.path)
vi.mocked(listWorktrees).mockResolvedValueOnce([createdWorktree])
const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation(async (args) => {
if (args[0] === 'rev-parse' && args.includes('refs/heads/cli-fresh-base^{commit}')) {
throw new Error('branch not found')
}
if (args[0] === 'remote') {
return { stdout: 'origin\n', stderr: '' }
}
@ -1404,6 +1407,351 @@ describe('OrcaRuntimeService', () => {
}
})
it('creates a same-repo PR branch override from a resolved head SHA and matching push target', async () => {
const runtime = new OrcaRuntimeService(store)
const createdWorktree = {
path: '/tmp/workspaces/fix-title',
head: 'abc123',
branch: 'refs/heads/feature/fix',
isBare: false,
isMainWorktree: false
}
computeWorktreePathMock.mockReturnValue(createdWorktree.path)
ensurePathWithinWorkspaceMock.mockReturnValue(createdWorktree.path)
vi.mocked(getBranchConflictKind).mockResolvedValueOnce('remote')
vi.mocked(listWorktrees).mockResolvedValueOnce([createdWorktree])
getPRForBranchMock.mockResolvedValueOnce({
number: 42,
title: 'Selected PR',
state: 'open',
url: 'https://example.com/pr/42',
checksStatus: 'success',
updatedAt: '2026-05-21T00:00:00Z',
mergeable: 'UNKNOWN'
})
const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockResolvedValue({
stdout: '',
stderr: ''
})
try {
const result = await runtime.createManagedWorktree({
repoSelector: 'id:repo-1',
name: 'fix-title',
baseBranch: 'abc123',
branchNameOverride: 'feature/fix',
linkedPR: 42,
pushTarget: { remoteName: 'origin', branchName: 'feature/fix' }
})
expect(getBranchConflictKind).toHaveBeenCalledWith(TEST_REPO_PATH, 'feature/fix', 'abc123')
expect(getPRForBranchMock).toHaveBeenCalledWith(TEST_REPO_PATH, 'feature/fix')
expect(addWorktree).toHaveBeenCalledWith(
TEST_REPO_PATH,
createdWorktree.path,
'feature/fix',
'abc123',
false
)
expect(gitSpy).toHaveBeenCalledWith(
['branch', '--set-upstream-to', 'origin/feature/fix', 'feature/fix'],
{ cwd: createdWorktree.path }
)
expect(result.worktree).toMatchObject({
path: createdWorktree.path,
branch: 'refs/heads/feature/fix'
})
} finally {
gitSpy.mockRestore()
}
})
it('rejects an existing PR when a matching push target lacks selected PR metadata', async () => {
const runtime = new OrcaRuntimeService(store)
computeWorktreePathMock.mockReturnValue('/tmp/workspaces/fix-title')
ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/fix-title')
vi.mocked(getBranchConflictKind).mockResolvedValueOnce(null)
getPRForBranchMock.mockResolvedValueOnce({
number: 42,
title: 'Existing PR',
state: 'open',
url: 'https://example.com/pr/42',
checksStatus: 'success',
updatedAt: '2026-05-21T00:00:00Z',
mergeable: 'UNKNOWN'
})
const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation(async (args) => {
if (args[0] === 'rev-parse' && args.includes('refs/heads/feature/fix^{commit}')) {
throw new Error('missing local branch')
}
return { stdout: '', stderr: '' }
})
try {
await expect(
runtime.createManagedWorktree({
repoSelector: 'id:repo-1',
name: 'fix-title',
baseBranch: 'abc123',
branchNameOverride: 'feature/fix',
pushTarget: { remoteName: 'origin', branchName: 'feature/fix' }
})
).rejects.toThrow('Branch "feature/fix" already has PR #42.')
expect(getPRForBranchMock).toHaveBeenCalledWith(TEST_REPO_PATH, 'feature/fix')
expect(addWorktree).not.toHaveBeenCalled()
} finally {
gitSpy.mockRestore()
}
})
it('rejects a matching push target branch when selected PR metadata has no PR number', async () => {
const runtime = new OrcaRuntimeService(store)
computeWorktreePathMock.mockReturnValue('/tmp/workspaces/fix-title')
ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/fix-title')
vi.mocked(getBranchConflictKind).mockResolvedValueOnce('remote')
const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation(async (args) => {
if (args[0] === 'rev-parse' && args.includes('refs/heads/feature/fix^{commit}')) {
throw new Error('missing local branch')
}
return { stdout: '', stderr: '' }
})
try {
await expect(
runtime.createManagedWorktree({
repoSelector: 'id:repo-1',
name: 'fix-title',
baseBranch: 'abc123',
branchNameOverride: 'feature/fix',
linkedPR: null,
pushTarget: { remoteName: 'origin', branchName: 'feature/fix' }
})
).rejects.toThrow('Branch "feature/fix" already exists on a remote.')
expect(getPRForBranchMock).not.toHaveBeenCalled()
expect(addWorktree).not.toHaveBeenCalled()
} finally {
gitSpy.mockRestore()
}
})
it('rejects a matching push target branch when the existing PR is different', async () => {
const runtime = new OrcaRuntimeService(store)
computeWorktreePathMock.mockReturnValue('/tmp/workspaces/fix-title')
ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/fix-title')
vi.mocked(getBranchConflictKind).mockResolvedValueOnce('remote')
getPRForBranchMock.mockResolvedValueOnce({
number: 43,
title: 'Different PR',
state: 'open',
url: 'https://example.com/pr/43',
checksStatus: 'success',
updatedAt: '2026-05-21T00:00:00Z',
mergeable: 'UNKNOWN'
})
const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation(async (args) => {
if (args[0] === 'rev-parse' && args.includes('refs/heads/feature/fix^{commit}')) {
throw new Error('missing local branch')
}
return { stdout: '', stderr: '' }
})
try {
await expect(
runtime.createManagedWorktree({
repoSelector: 'id:repo-1',
name: 'fix-title',
baseBranch: 'abc123',
branchNameOverride: 'feature/fix',
linkedPR: 42,
pushTarget: { remoteName: 'origin', branchName: 'feature/fix' }
})
).rejects.toThrow('Branch "feature/fix" already has PR #43.')
expect(getPRForBranchMock).toHaveBeenCalledWith(TEST_REPO_PATH, 'feature/fix')
expect(addWorktree).not.toHaveBeenCalled()
} finally {
gitSpy.mockRestore()
}
})
it('rejects a selected PR remote conflict when the PR lookup fails', async () => {
const runtime = new OrcaRuntimeService(store)
computeWorktreePathMock.mockReturnValue('/tmp/workspaces/fix-title')
ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/fix-title')
vi.mocked(getBranchConflictKind).mockResolvedValueOnce('remote')
getPRForBranchMock.mockRejectedValueOnce(new Error('gh unavailable'))
const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation(async (args) => {
if (args[0] === 'rev-parse' && args.includes('refs/heads/feature/fix^{commit}')) {
throw new Error('missing local branch')
}
return { stdout: '', stderr: '' }
})
try {
await expect(
runtime.createManagedWorktree({
repoSelector: 'id:repo-1',
name: 'fix-title',
baseBranch: 'abc123',
branchNameOverride: 'feature/fix',
linkedPR: 42,
pushTarget: { remoteName: 'origin', branchName: 'feature/fix' }
})
).rejects.toThrow('Could not verify selected PR branch "feature/fix". Try again.')
expect(getPRForBranchMock).toHaveBeenCalledWith(TEST_REPO_PATH, 'feature/fix')
expect(addWorktree).not.toHaveBeenCalled()
} finally {
gitSpy.mockRestore()
}
})
it('checks out an unused runtime PR branch only when it is at the resolved head SHA', async () => {
const runtime = new OrcaRuntimeService(store)
const createdWorktree = {
path: '/tmp/workspaces/fix-title',
head: 'abc123',
branch: 'refs/heads/feature/fix',
isBare: false,
isMainWorktree: false
}
computeWorktreePathMock.mockReturnValue(createdWorktree.path)
ensurePathWithinWorkspaceMock.mockReturnValue(createdWorktree.path)
vi.mocked(getBranchConflictKind).mockClear()
vi.mocked(listWorktrees)
.mockResolvedValueOnce([
{
path: TEST_REPO_PATH,
head: 'main',
branch: 'refs/heads/main',
isBare: false,
isMainWorktree: true
}
])
.mockResolvedValueOnce([createdWorktree])
const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation(async (args) => {
if (args[0] === 'rev-parse' && args.includes('refs/heads/feature/fix^{commit}')) {
return { stdout: 'abc123\n', stderr: '' }
}
if (args[0] === 'rev-parse' && args.includes('abc123^{commit}')) {
return { stdout: 'abc123\n', stderr: '' }
}
return { stdout: '', stderr: '' }
})
try {
await runtime.createManagedWorktree({
repoSelector: 'id:repo-1',
name: 'fix-title',
baseBranch: 'abc123',
branchNameOverride: 'feature/fix'
})
expect(getBranchConflictKind).not.toHaveBeenCalled()
expect(addWorktree).toHaveBeenCalledWith(
TEST_REPO_PATH,
createdWorktree.path,
'feature/fix',
'abc123',
false,
false,
{ checkoutExistingBranch: true }
)
} finally {
gitSpy.mockRestore()
}
})
it('suffixes only the runtime worktree path when an exact PR branch checkout path exists', async () => {
const runtime = new OrcaRuntimeService(store)
const createdWorktree = {
path: '/tmp/workspaces/fix-title-2',
head: 'abc123',
branch: 'refs/heads/feature/fix',
isBare: false,
isMainWorktree: false
}
computeWorktreePathMock.mockImplementation((sanitizedName: string) =>
sanitizedName === 'fix-title' ? process.cwd() : `/tmp/workspaces/${sanitizedName}`
)
ensurePathWithinWorkspaceMock.mockImplementation((pathValue: string) => pathValue)
vi.mocked(getBranchConflictKind).mockClear()
vi.mocked(listWorktrees)
.mockResolvedValueOnce([
{
path: TEST_REPO_PATH,
head: 'main',
branch: 'refs/heads/main',
isBare: false,
isMainWorktree: true
}
])
.mockResolvedValueOnce([createdWorktree])
const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation(async (args) => {
if (args[0] === 'rev-parse' && args.includes('refs/heads/feature/fix^{commit}')) {
return { stdout: 'abc123\n', stderr: '' }
}
if (args[0] === 'rev-parse' && args.includes('abc123^{commit}')) {
return { stdout: 'abc123\n', stderr: '' }
}
return { stdout: '', stderr: '' }
})
try {
await runtime.createManagedWorktree({
repoSelector: 'id:repo-1',
name: 'fix-title',
baseBranch: 'abc123',
branchNameOverride: 'feature/fix'
})
expect(getBranchConflictKind).not.toHaveBeenCalled()
expect(addWorktree).toHaveBeenCalledWith(
TEST_REPO_PATH,
createdWorktree.path,
'feature/fix',
'abc123',
false,
false,
{ checkoutExistingBranch: true }
)
} finally {
gitSpy.mockRestore()
}
})
it('rejects when every exact PR branch checkout path suffix is occupied', async () => {
const runtime = new OrcaRuntimeService(store)
computeWorktreePathMock.mockReturnValue(process.cwd())
ensurePathWithinWorkspaceMock.mockImplementation((pathValue: string) => pathValue)
vi.mocked(getBranchConflictKind).mockResolvedValueOnce(null)
const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation(async (args) => {
if (args[0] === 'rev-parse' && args.includes('refs/heads/feature/fix^{commit}')) {
throw new Error('missing local branch')
}
return { stdout: '', stderr: '' }
})
try {
await expect(
runtime.createManagedWorktree({
repoSelector: 'id:repo-1',
name: 'fix-title',
baseBranch: 'abc123',
branchNameOverride: 'feature/fix'
})
).rejects.toThrow(
'Could not find an available worktree path for "fix-title". Pick a different worktree name.'
)
expect(addWorktree).not.toHaveBeenCalled()
} finally {
gitSpy.mockRestore()
}
})
it('creates SSH-backed worktrees through the SSH provider for mobile/runtime callers', async () => {
vi.mocked(listWorktrees).mockClear()
vi.mocked(addWorktree).mockClear()

View File

@ -34,6 +34,7 @@ import type {
DetectedWorktree,
DetectedWorktreeListResult,
ForceDeleteWorktreeBranchResult,
GitHubPrStartPoint,
GitPushTarget,
GitWorktreeInfo,
GitHubCreateIssueFields,
@ -930,23 +931,87 @@ async function canCheckoutExistingLocalBranch(
branchName: string,
baseBranch: string
): Promise<boolean> {
if (normalizeLocalBranchName(baseBranch) !== branchName) {
return false
}
let localHead = ''
try {
await gitExecFileAsync(
const { stdout } = await gitExecFileAsync(
['rev-parse', '--verify', '--quiet', `refs/heads/${branchName}^{commit}`],
{
cwd: repoPath
}
)
localHead = stdout.trim()
} catch {
return false
}
if (normalizeLocalBranchName(baseBranch) !== branchName) {
if (!localHead) {
return false
}
try {
const { stdout } = await gitExecFileAsync(
['rev-parse', '--verify', '--quiet', `${baseBranch}^{commit}`],
{ cwd: repoPath }
)
if (stdout.trim() !== localHead) {
return false
}
} catch {
return false
}
}
const worktrees = await listWorktrees(repoPath)
return !worktrees.some((worktree) => normalizeLocalBranchName(worktree.branch) === branchName)
}
type SelectedPrBranchInput = {
branchNameOverride?: string
linkedPR?: number | null
pushTarget?: GitPushTarget
}
function isSelectedGitHubPrBranchOverride(
args: SelectedPrBranchInput,
branchName: string
): boolean {
return typeof args.linkedPR === 'number' && args.branchNameOverride === branchName
}
function isMatchingSelectedGitHubPr(
existingPR: Awaited<ReturnType<typeof getPRForBranch>>,
args: SelectedPrBranchInput,
branchName: string
): boolean {
return Boolean(
existingPR &&
isSelectedGitHubPrBranchOverride(args, branchName) &&
existingPR.number === args.linkedPR
)
}
function isAllowedPushTargetRemoteConflict(
conflictKind: 'local' | 'remote' | null,
branchName: string,
args: SelectedPrBranchInput
): boolean {
return (
conflictKind === 'remote' &&
isSelectedGitHubPrBranchOverride(args, branchName) &&
args.pushTarget?.branchName === branchName
)
}
async function pathExists(pathValue: string): Promise<boolean> {
try {
await stat(pathValue)
return true
} catch (error) {
if (isENOENT(error)) {
return false
}
throw error
}
}
type ResolvedWorktree = Worktree & {
parentWorktreeId: string | null
childWorktreeIds: string[]
@ -7566,9 +7631,10 @@ export class OrcaRuntimeService {
args.lineage || args.comment ? { ...args.lineage, comment: args.comment } : undefined
const lineageResolution = await this.resolveLineageForWorktreeCreate(lineageInput)
const settings = createSettings
const requestedName = args.name
let effectiveRequestedName = args.name
const requestedDisplayName = args.displayName?.trim() || undefined
const sanitizedName = sanitizeWorktreeName(args.name)
let effectiveSanitizedName = sanitizedName
const username = getGitUsername(repo.path)
const branchName = await resolveCreateBranchName(
repo.path,
@ -7594,10 +7660,12 @@ export class OrcaRuntimeService {
branchName,
baseBranch
)
const branchConflictKind = checkoutExistingBranch
let branchConflictKind = checkoutExistingBranch
? null
: await getBranchConflictKind(repo.path, branchName, baseBranch)
if (branchConflictKind) {
const allowedPushTargetRemoteConflict =
branchConflictKind && isAllowedPushTargetRemoteConflict(branchConflictKind, branchName, args)
if (branchConflictKind && !allowedPushTargetRemoteConflict) {
throw new Error(
`Branch "${branchName}" already exists ${branchConflictKind === 'local' ? 'locally' : 'on a remote'}.`
)
@ -7608,23 +7676,57 @@ export class OrcaRuntimeService {
try {
existingPR = await getPRForBranch(repo.path, branchName)
} catch {
if (allowedPushTargetRemoteConflict) {
throw new Error(`Could not verify selected PR branch "${branchName}". Try again.`)
}
// Why: worktree creation should not hard-fail on transient GitHub reachability
// issues because git state is still the source of truth for whether the
// worktree can be created locally.
}
if (existingPR) {
if (
allowedPushTargetRemoteConflict &&
!isMatchingSelectedGitHubPr(existingPR, args, branchName)
) {
if (existingPR) {
throw new Error(`Branch "${branchName}" already has PR #${existingPR.number}.`)
}
throw new Error(`Branch "${branchName}" already exists on a remote.`)
}
if (existingPR && !isMatchingSelectedGitHubPr(existingPR, args, branchName)) {
throw new Error(`Branch "${branchName}" already has PR #${existingPR.number}.`)
}
}
let worktreePath = computeWorktreePath(sanitizedName, repo.path, settings)
// Why: CLI-managed WSL worktrees live under ~/orca/workspaces inside the
// distro filesystem. If home lookup fails, still validate against the
// configured workspace dir so the traversal guard is never bypassed.
const wslInfo = isWslPath(repo.path) ? parseWslPath(repo.path) : null
const wslHome = wslInfo ? getWslHome(wslInfo.distro) : null
const workspaceRoot = wslHome ? join(wslHome, 'orca', 'workspaces') : settings.workspaceDir
worktreePath = ensurePathWithinWorkspace(worktreePath, workspaceRoot)
let worktreePath = ''
let worktreePathResolved = !args.branchNameOverride
for (let suffix = 1; suffix < 100; suffix += 1) {
effectiveSanitizedName = suffix === 1 ? sanitizedName : `${sanitizedName}-${suffix}`
effectiveRequestedName =
suffix === 1
? args.name
: args.name.trim()
? `${args.name}-${suffix}`
: effectiveSanitizedName
worktreePath = ensurePathWithinWorkspace(
computeWorktreePath(effectiveSanitizedName, repo.path, settings),
workspaceRoot
)
if (!args.branchNameOverride || !(await pathExists(worktreePath))) {
worktreePathResolved = true
break
}
}
if (!worktreePathResolved) {
throw new Error(
`Could not find an available worktree path for "${sanitizedName}". Pick a different worktree name.`
)
}
const remoteTrackingBase = await this.resolveRemoteTrackingBase(repo.path, baseBranch)
if (remoteTrackingBase) {
const hadLocalBaseRef = await this.hasRemoteTrackingRef(repo.path, remoteTrackingBase)
@ -7732,8 +7834,8 @@ export class OrcaRuntimeService {
const now = Date.now()
const displayNameMeta = requestedDisplayName
? { displayName: requestedDisplayName }
: shouldSetDisplayName(requestedName, branchName, sanitizedName)
? { displayName: requestedName }
: shouldSetDisplayName(effectiveRequestedName, branchName, effectiveSanitizedName)
? { displayName: effectiveRequestedName }
: {}
const meta = this.store.setWorktreeMeta(worktreeId, {
// Why: worktree IDs are path-derived. If a path is deleted outside Orca
@ -8605,7 +8707,7 @@ export class OrcaRuntimeService {
prNumber: number
headRefName?: string
isCrossRepository?: boolean
}): Promise<{ baseBranch: string; pushTarget?: GitPushTarget } | { error: string }> {
}): Promise<GitHubPrStartPoint | { error: string }> {
if (!this.store) {
throw new Error('runtime_unavailable')
}

View File

@ -174,10 +174,15 @@ describe('worktree RPC methods', () => {
expect(runtime.createManagedWorktree).not.toHaveBeenCalled()
})
it('passes explicit repo selectors to PR base resolution', async () => {
it('passes explicit repo selectors to PR base resolution and preserves start-point fields', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
resolveManagedPrBase: vi.fn().mockResolvedValue({ baseBranch: 'origin/pr-head' })
resolveManagedPrBase: vi.fn().mockResolvedValue({
baseBranch: 'abc123',
headSha: 'abc123',
branchNameOverride: 'feature/pr-head',
pushTarget: { remoteName: 'origin', branchName: 'feature/pr-head' }
})
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS })
@ -191,6 +196,14 @@ describe('worktree RPC methods', () => {
)
expect(response).toMatchObject({ ok: true })
expect(response).toMatchObject({
result: {
baseBranch: 'abc123',
headSha: 'abc123',
branchNameOverride: 'feature/pr-head',
pushTarget: { remoteName: 'origin', branchName: 'feature/pr-head' }
}
})
expect(runtime.resolveManagedPrBase).toHaveBeenCalledWith({
repoSelector: 'id:repo-1',
prNumber: 42,

View File

@ -40,6 +40,7 @@ import type {
GitHubAssignableUser,
GitHubPRFile,
GitHubPRFileContents,
GitHubPrStartPoint,
GitHubPRReviewCommentInput,
GitHubCommentResult,
GitHubOwnerRepo,
@ -749,7 +750,7 @@ export type PreloadApi = {
prNumber: number
headRefName?: string
isCrossRepository?: boolean
}) => Promise<{ baseBranch: string; pushTarget?: GitPushTarget } | { error: string }>
}) => Promise<GitHubPrStartPoint | { error: string }>
/** GitLab parallel of resolvePrBase. For same-project MRs returns
* `<remote>/<source_branch>`; for fork MRs fetches
* refs/merge-requests/<iid>/head and returns the SHA. */

View File

@ -31,6 +31,7 @@ describe('validateGitExecArgs', () => {
[['config', '--list']],
[['config', '-l']],
[['config', '--get-regexp', 'user']],
[['check-ref-format', '--branch', 'feature/ssh-pr-head']],
[['for-each-ref', '--format=%(refname)', 'refs/remotes']],
[
[

View File

@ -19,6 +19,7 @@ const ALLOWED_GIT_SUBCOMMANDS = new Set([
'diff',
'ls-files',
'for-each-ref',
'check-ref-format',
'config'
])
const CONFIG_READ_ONLY_FLAGS = new Set(['--get', '--get-all', '--list', '--get-regexp', '-l'])

View File

@ -1,5 +1,8 @@
import { describe, expect, it } from 'vitest'
import { resolveComposerBranchSelection } from './composer-branch-selection'
import {
resolveComposerBranchNameOverrideForCreate,
resolveComposerBranchSelection
} from './composer-branch-selection'
describe('resolveComposerBranchSelection', () => {
it('keeps selected remote ref as base while using the local branch name for create', () => {
@ -50,4 +53,26 @@ describe('resolveComposerBranchSelection', () => {
lastAutoName: 'fix/bug-0'
})
})
it('keeps resolver-provided PR branch overrides when the workspace name changes', () => {
expect(
resolveComposerBranchNameOverrideForCreate({
branchNameOverride: 'feature/fix',
branchAutoName: '',
workspaceName: 'edited display name',
preserveWorkspaceNameEdits: true
})
).toBe('feature/fix')
})
it('keeps existing branch picker override behavior tied to the auto-name', () => {
expect(
resolveComposerBranchNameOverrideForCreate({
branchNameOverride: 'feature/fix',
branchAutoName: 'feature/fix',
workspaceName: 'edited display name',
preserveWorkspaceNameEdits: false
})
).toBeUndefined()
})
})

View File

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

View File

@ -21,6 +21,7 @@ import { isGitRepoKind } from '../../../shared/repo-kind'
import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
import type {
GitHubWorkItem,
GitHubPrStartPoint,
GitPushTarget,
GitLabWorkItem,
LinearIssue,
@ -86,7 +87,10 @@ import {
type WorkspaceCreateErrorDisplay
} from '@/lib/workspace-create-error-format'
import type { SshConnectionStatus } from '../../../shared/ssh-types'
import { resolveComposerBranchSelection } from './composer-branch-selection'
import {
resolveComposerBranchNameOverrideForCreate,
resolveComposerBranchSelection
} from './composer-branch-selection'
export type UseComposerStateOptions = {
initialRepoId?: string
@ -179,7 +183,8 @@ export type ComposerCardProps = {
onBaseBranchPrSelect: (
baseBranch: string,
item: GitHubWorkItem,
pushTarget?: GitPushTarget
pushTarget?: GitPushTarget,
branchNameOverride?: string
) => void
/** PR number selected via the Start-from picker (when applicable). Used so the
* field can render "PR #N" copy. */
@ -394,6 +399,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
persistDraft ? newWorkspaceDraft?.baseBranch : initialBaseBranch
)
const [branchNameOverride, setBranchNameOverride] = useState<string | undefined>(undefined)
const [branchNameOverridePreservesNameEdits, setBranchNameOverridePreservesNameEdits] =
useState(false)
const [pushTarget, setPushTarget] = useState<GitPushTarget | undefined>(undefined)
// Why: when a repo switch wipes a prior Start-from selection, surface the
// reset inline (e.g. "was PR #8778") so the change is recoverable visually
@ -1082,7 +1089,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
}, [linkPopoverOpen, normalizedLinkQuery.directNumber, selectedRepo, selectedRepoIsGit])
const applyLinkedWorkItem = useCallback(
(item: GitHubWorkItem): void => {
(item: GitHubWorkItem, options: { preserveBranchNameOverride?: boolean } = {}): void => {
if (item.type === 'issue') {
setLinkedIssue(String(item.number))
setLinkedPR(null)
@ -1101,7 +1108,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
setName(suggestedName)
lastAutoNameRef.current = suggestedName
}
setBranchNameOverride(undefined)
if (!options.preserveBranchNameOverride) {
setBranchNameOverride(undefined)
}
},
[name]
)
@ -1227,14 +1236,18 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
} else if (name !== lastAutoNameRef.current) {
lastAutoNameRef.current = ''
}
if (branchNameOverride && nextName !== branchAutoNameRef.current) {
if (
branchNameOverride &&
!branchNameOverridePreservesNameEdits &&
nextName !== branchAutoNameRef.current
) {
setBranchNameOverride(undefined)
branchAutoNameRef.current = ''
}
setName(nextName)
setCreateError(null)
},
[branchNameOverride, name]
[branchNameOverride, branchNameOverridePreservesNameEdits, name]
)
const addComposerAttachments = useCallback((paths: string[]): void => {
@ -1511,16 +1524,22 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
}, [])
const handleBaseBranchPrSelect = useCallback(
(nextBaseBranch: string, item: GitHubWorkItem, nextPushTarget?: GitPushTarget): void => {
(
nextBaseBranch: string,
item: GitHubWorkItem,
nextPushTarget?: GitPushTarget,
nextBranchNameOverride?: string
): void => {
setBaseBranch(nextBaseBranch)
setPushTarget(nextPushTarget)
setBranchNameOverride(undefined)
setBranchNameOverride(nextBranchNameOverride)
setBranchNameOverridePreservesNameEdits(Boolean(nextBranchNameOverride))
branchAutoNameRef.current = ''
setStartFromResetHint(null)
// Why: per spec, a PR selection in the Start-from picker is also a
// linkedWorkItem assignment. Reuse applyLinkedWorkItem so auto-name and
// linkedPR state stay in a single code path.
applyLinkedWorkItem(item)
applyLinkedWorkItem(item, { preserveBranchNameOverride: Boolean(nextBranchNameOverride) })
// Why: starting a worktree from a PR is a strong hint for what the
// worktree's comment should surface (`orca worktree current`, sidebar).
// Prefill the note if it's empty or still equal to a prior auto-fill, so
@ -1583,7 +1602,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
? { isCrossRepository: item.isCrossRepository }
: {})
})
: callRuntimeRpc<{ baseBranch: string; pushTarget?: GitPushTarget } | { error: string }>(
: callRuntimeRpc<GitHubPrStartPoint | { error: string }>(
target,
'worktree.resolvePrBase',
{
@ -1604,7 +1623,12 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
toast.error(result.error)
return
}
handleBaseBranchPrSelect(result.baseBranch, item, result.pushTarget)
handleBaseBranchPrSelect(
result.baseBranch,
item,
result.pushTarget,
result.branchNameOverride
)
})
.catch((error: unknown) => {
setBaseBranch(undefined)
@ -1660,6 +1684,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
setBaseBranch(selection.baseBranch)
setPushTarget(undefined)
setStartFromResetHint(null)
setBranchNameOverridePreservesNameEdits(false)
if (selection.name !== undefined && selection.lastAutoName !== undefined) {
setName(selection.name)
lastAutoNameRef.current = selection.lastAutoName
@ -1838,10 +1863,12 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
}
const linkedLinearIssue = submitLinkedWorkItem?.linearIdentifier
const effectiveBranchNameOverride =
branchNameOverride && workspaceName === branchAutoNameRef.current
? branchNameOverride
: undefined
const effectiveBranchNameOverride = resolveComposerBranchNameOverrideForCreate({
branchNameOverride,
branchAutoName: branchAutoNameRef.current,
workspaceName,
preserveWorkspaceNameEdits: branchNameOverridePreservesNameEdits
})
const result = await createWorktree(
repoId,
workspaceName,
@ -1946,6 +1973,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
attachmentPaths,
baseBranch,
branchNameOverride,
branchNameOverridePreservesNameEdits,
clearNewWorkspaceDraft,
createWorktree,
applyWorktreeMeta,
@ -2057,10 +2085,12 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
: ((submitResolvedSetupDecision ?? 'inherit') as SetupDecision)
const linkedLinearIssue = submitLinkedWorkItem?.linearIdentifier
const effectiveBranchNameOverride =
branchNameOverride && workspaceName === branchAutoNameRef.current
? branchNameOverride
: undefined
const effectiveBranchNameOverride = resolveComposerBranchNameOverrideForCreate({
branchNameOverride,
branchAutoName: branchAutoNameRef.current,
workspaceName,
preserveWorkspaceNameEdits: branchNameOverridePreservesNameEdits
})
const result = await createWorktree(
repoId,
workspaceName,
@ -2208,6 +2238,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
applyWorktreeMeta,
baseBranch,
branchNameOverride,
branchNameOverridePreservesNameEdits,
clearNewWorkspaceDraft,
createWorktree,
fallbackCreatureName,

View File

@ -0,0 +1,141 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AppState } from '@/store'
const storeState = vi.hoisted(() => ({
value: {} as Partial<AppState> & {
ensureDetectedAgents: ReturnType<typeof vi.fn>
createWorktree: ReturnType<typeof vi.fn>
updateWorktreeMeta: ReturnType<typeof vi.fn>
setSidebarOpen: ReturnType<typeof vi.fn>
}
}))
vi.mock('@/store', () => ({
useAppStore: {
getState: () => storeState.value
}
}))
vi.mock('sonner', () => ({
toast: {
error: vi.fn(),
message: vi.fn()
}
}))
vi.mock('@/lib/agent-paste-draft', () => ({
pasteDraftWhenAgentReady: vi.fn()
}))
vi.mock('@/lib/tui-agent-startup', () => ({
buildAgentDraftLaunchPlan: vi.fn(() => null),
buildAgentStartupPlan: vi.fn(() => null)
}))
vi.mock('../../../shared/tui-agent-selection', () => ({
pickTuiAgent: vi.fn(() => null)
}))
vi.mock('@/lib/worktree-activation', () => ({
activateAndRevealWorktree: vi.fn(() => ({ primaryTabId: 'tab-1' }))
}))
vi.mock('@/runtime/runtime-rpc-client', () => ({
callRuntimeRpc: vi.fn(),
getActiveRuntimeTarget: vi.fn(() => ({ kind: 'local' }))
}))
vi.mock('@/lib/new-workspace', () => ({
CLIENT_PLATFORM: 'darwin',
getLinkedWorkItemSuggestedName: (item: { title: string }) => item.title,
getSetupConfig: vi.fn(() => null),
getWorkspaceSeedName: ({ explicitName }: { explicitName?: string }) => explicitName ?? '',
isGitLabIssueUrl: vi.fn(() => false)
}))
vi.mock('@/lib/ensure-hooks-confirmed', () => ({
ensureHooksConfirmed: vi.fn(async () => 'run')
}))
vi.mock('@/runtime/runtime-hooks-client', () => ({
checkRuntimeHooks: vi.fn(async () => ({ hasHooks: false, hooks: null, mayNeedUpdate: false }))
}))
vi.mock('@/lib/telemetry', () => ({
track: vi.fn(),
tuiAgentToAgentKind: vi.fn(() => 'codex')
}))
import { launchWorkItemDirect } from './launch-work-item-direct'
const mockApi = {
worktrees: {
resolvePrBase: vi.fn()
}
}
describe('launchWorkItemDirect', () => {
beforeEach(() => {
vi.clearAllMocks()
mockApi.worktrees.resolvePrBase.mockResolvedValue({
baseBranch: 'abc123',
headSha: 'abc123',
branchNameOverride: 'feature/fix',
pushTarget: { remoteName: 'origin', branchName: 'feature/fix' }
})
storeState.value = {
repos: [
{
id: 'repo-1',
path: '/repo',
displayName: 'Repo',
badgeColor: '#000',
addedAt: 0
}
],
settings: {},
ensureDetectedAgents: vi.fn(async () => []),
createWorktree: vi.fn(async () => ({
worktree: { id: 'wt-1', path: '/repo/../worktrees/fix' }
})),
updateWorktreeMeta: vi.fn(async () => undefined),
setSidebarOpen: vi.fn()
} as typeof storeState.value
// @ts-expect-error -- test shim
globalThis.window = { api: mockApi }
})
it('passes a resolved PR branch override while keeping the PR title as the workspace display name', async () => {
await launchWorkItemDirect({
repoId: 'repo-1',
launchSource: 'task_page',
telemetrySource: 'sidebar',
openModalFallback: vi.fn(),
item: {
type: 'pr',
number: 42,
title: 'Fix the bug',
url: 'https://github.com/acme/repo/pull/42'
}
})
expect(storeState.value.createWorktree).toHaveBeenCalledWith(
'repo-1',
'Fix the bug',
'abc123',
'inherit',
undefined,
'sidebar',
'Fix the bug',
undefined,
42,
{ remoteName: 'origin', branchName: 'feature/fix' },
undefined,
undefined,
'feature/fix',
undefined,
undefined,
undefined
)
})
})

View File

@ -22,6 +22,7 @@ import { checkRuntimeHooks } from '@/runtime/runtime-hooks-client'
import { track, tuiAgentToAgentKind } from '@/lib/telemetry'
import type {
GitPushTarget,
GitHubPrStartPoint,
OrcaHooks,
RepoHookSettings,
SetupDecision,
@ -78,14 +79,17 @@ async function resolveDirectPrStartPoint(
repoId: string,
prNumber: number,
settings: AppState['settings']
): Promise<{ baseBranch: string; pushTarget?: GitPushTarget }> {
): Promise<GitHubPrStartPoint> {
const target = getActiveRuntimeTarget(settings)
const result =
target.kind === 'local'
? await window.api.worktrees.resolvePrBase({ repoId, prNumber })
: await callRuntimeRpc<
{ baseBranch: string; pushTarget?: GitPushTarget } | { error: string }
>(target, 'worktree.resolvePrBase', { repo: repoId, prNumber }, { timeoutMs: 30_000 })
: await callRuntimeRpc<GitHubPrStartPoint | { error: string }>(
target,
'worktree.resolvePrBase',
{ repo: repoId, prNumber },
{ timeoutMs: 30_000 }
)
if ('error' in result) {
throw new Error(result.error)
}
@ -217,6 +221,7 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom
})
let resolvedBaseBranch = baseBranch
let resolvedPushTarget: GitPushTarget | undefined
let resolvedBranchNameOverride: string | undefined
if (!resolvedBaseBranch && item.type === 'pr' && item.number) {
try {
// Why: direct "Use PR" launches bypass the Start-from picker, so they
@ -224,6 +229,7 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom
const result = await resolveDirectPrStartPoint(repoId, item.number, settings)
resolvedBaseBranch = result.baseBranch
resolvedPushTarget = result.pushTarget
resolvedBranchNameOverride = result.branchNameOverride
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to resolve PR head.')
openModalFallback()
@ -251,7 +257,7 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom
resolvedPushTarget,
undefined,
item.linearIdentifier,
undefined,
resolvedBranchNameOverride,
undefined,
item.type === 'mr' && item.number ? item.number : undefined,
item.type === 'issue' && item.number && isGitLabIssueUrl(item.url) ? item.number : undefined

View File

@ -1241,53 +1241,40 @@ describe('createWorktree base status merge', () => {
)
})
it('suffixes branchNameOverride when local IPC returns the SSH branch-exists error', async () => {
it('does not suffix branchNameOverride when local IPC reports a branch conflict', async () => {
const store = createTestStore()
const wt = makeWorktree({
id: 'repo1::/path/feature-something-2',
repoId: 'repo1',
path: '/path/feature-something-2',
branch: 'feature/something-2'
})
mockApi.worktrees.create
.mockRejectedValueOnce(
new Error('Branch "feature/something" already exists. Pick a different worktree name.')
)
.mockResolvedValueOnce({ worktree: wt })
const error = new Error(
'Branch "feature/something" already exists. Pick a different worktree name.'
)
mockApi.worktrees.create.mockRejectedValueOnce(error)
const result = await store
.getState()
.createWorktree(
'repo1',
'feature/something',
'origin/main',
'inherit',
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
'feature/something'
)
await expect(
store
.getState()
.createWorktree(
'repo1',
'feature/something',
'origin/main',
'inherit',
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
'feature/something'
)
).rejects.toThrow(error.message)
expect(result).toEqual({ worktree: wt })
expect(mockApi.worktrees.create).toHaveBeenNthCalledWith(
1,
expect(mockApi.worktrees.create).toHaveBeenCalledTimes(1)
expect(mockApi.worktrees.create).toHaveBeenCalledWith(
expect.objectContaining({
name: 'feature/something',
branchNameOverride: 'feature/something'
})
)
expect(mockApi.worktrees.create).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
name: 'feature/something-2',
branchNameOverride: 'feature/something-2'
})
)
})
it('does not overwrite a newer reconcile status with the initial checking status', async () => {
@ -1808,48 +1795,36 @@ describe('worktree remote runtime mutations', () => {
)
})
it('suffixes branchNameOverride when retrying a runtime create conflict', async () => {
it('does not suffix branchNameOverride when runtime create reports a branch conflict', async () => {
const store = createTestStore()
const wt = makeWorktree({
id: 'repo1::/path/feature-something-2',
repoId: 'repo1',
path: '/path/feature-something-2',
branch: 'feature/something-2'
})
runtimeEnvironmentCall
.mockRejectedValueOnce(new Error('Branch already exists on a remote'))
.mockResolvedValueOnce({
id: 'rpc-create',
ok: true,
result: { worktree: wt },
_meta: { runtimeId: 'runtime-remote' }
})
runtimeEnvironmentCall.mockRejectedValueOnce(new Error('Branch already exists on a remote'))
store.setState({
settings: { activeRuntimeEnvironmentId: 'env-1' } as never,
worktreesByRepo: { repo1: [] }
} as Partial<AppState>)
const result = await store
.getState()
.createWorktree(
'repo1',
'feature/something',
'origin/main',
'skip',
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
'feature/something'
)
await expect(
store
.getState()
.createWorktree(
'repo1',
'feature/something',
'origin/main',
'skip',
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
'feature/something'
)
).rejects.toThrow('Branch already exists on a remote')
expect(result).toEqual({ worktree: wt })
expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(
1,
expect(runtimeEnvironmentCall).toHaveBeenCalledTimes(1)
expect(runtimeEnvironmentCall).toHaveBeenCalledWith(
expect.objectContaining({
params: expect.objectContaining({
name: 'feature/something',
@ -1857,15 +1832,6 @@ describe('worktree remote runtime mutations', () => {
})
})
)
expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
params: expect.objectContaining({
name: 'feature/something-2',
branchNameOverride: 'feature/something-2'
})
})
)
})
it('removes worktrees through the active remote runtime environment', async () => {

View File

@ -8,6 +8,7 @@ import type {
LocalBaseRefRefreshResult,
Repo,
ForceDeleteWorktreeBranchResult,
GitHubPrStartPoint,
Worktree,
WorkspaceVisibleTabType,
GitPushTarget,
@ -580,9 +581,12 @@ async function resolveLinkedPrPushTarget(
const result =
target.kind === 'local'
? await window.api.worktrees.resolvePrBase({ repoId, prNumber })
: await callRuntimeRpc<
{ baseBranch: string; pushTarget?: GitPushTarget } | { error: string }
>(target, 'worktree.resolvePrBase', { repo: repoId, prNumber }, { timeoutMs: 30_000 })
: await callRuntimeRpc<GitHubPrStartPoint | { error: string }>(
target,
'worktree.resolvePrBase',
{ repo: repoId, prNumber },
{ timeoutMs: 30_000 }
)
if ('error' in result) {
console.warn(`Failed to resolve push target for PR #${prNumber}: ${result.error}`)
return undefined
@ -1030,15 +1034,18 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
]
const nextCandidateName = (current: string, attempt: number): string =>
attempt === 0 ? current : `${current}-${attempt + 1}`
const nextCandidateBranchName = (
current: string | undefined,
attempt: number
): string | undefined => (current ? nextCandidateName(current, attempt) : undefined)
const isBranchNameOverrideConflict = (error: Error): boolean =>
Boolean(
branchNameOverride &&
(/^Branch ".+" already exists\./i.test(error.message) ||
/already exists locally/i.test(error.message) ||
/already exists on a remote/i.test(error.message) ||
/already has pr #\d+/i.test(error.message))
)
try {
for (let attempt = 0; attempt < 25; attempt += 1) {
const candidateName = nextCandidateName(name, attempt)
const candidateBranchNameOverride = nextCandidateBranchName(branchNameOverride, attempt)
try {
// Why: Manual sort is user-authored order. Stamp new workspaces
// deliberately at the top instead of relying on sortOrder fallback.
@ -1047,9 +1054,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
repoId,
name: candidateName,
baseBranch,
...(candidateBranchNameOverride
? { branchNameOverride: candidateBranchNameOverride }
: {}),
...(branchNameOverride ? { branchNameOverride } : {}),
setupDecision,
sparseCheckout,
...(displayName ? { displayName } : {}),
@ -1075,9 +1080,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
repo: repoId,
name: candidateName,
baseBranch,
...(candidateBranchNameOverride
? { branchNameOverride: candidateBranchNameOverride }
: {}),
...(branchNameOverride ? { branchNameOverride } : {}),
setupDecision,
sparseCheckout,
...(displayName ? { displayName } : {}),
@ -1131,6 +1134,9 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
const shouldRetry = retryableConflictPatterns.some((pattern) => pattern.test(message))
if (error instanceof Error && isBranchNameOverrideConflict(error)) {
throw error
}
if (!shouldRetry || attempt === 24) {
throw error
}

View File

@ -35,3 +35,18 @@ export function resolveComposerBranchSelection(args: {
lastAutoName: args.localBranchName
}
}
export function resolveComposerBranchNameOverrideForCreate(args: {
branchNameOverride: string | undefined
branchAutoName: string
workspaceName: string
preserveWorkspaceNameEdits: boolean
}): string | undefined {
if (!args.branchNameOverride) {
return undefined
}
if (args.preserveWorkspaceNameEdits) {
return args.branchNameOverride
}
return args.workspaceName === args.branchAutoName ? args.branchNameOverride : undefined
}

View File

@ -275,6 +275,15 @@ export type GitPushTarget = {
remoteCreated?: boolean
}
export type GitHubPrStartPoint = {
baseBranch: string
pushTarget?: GitPushTarget
/** Verified PR head commit. Present when checkout can be tied to a stable SHA. */
headSha?: string
/** Exact local branch name to create/reuse when the PR head is a safe same-repo branch. */
branchNameOverride?: string
}
// ─── Worktree metadata (persisted user-authored fields only) ─────────
export type WorktreeMeta = {
/** Immutable per-workspace-instance ID used to reject stale lineage after path reuse. */