Fix PR workspace branch checkout (#4003)
This commit is contained in:
parent
a090176de9
commit
f0308a86e6
|
|
@ -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' }
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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']],
|
||||
[
|
||||
[
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
export {
|
||||
resolveComposerBranchNameOverrideForCreate,
|
||||
resolveComposerBranchSelection,
|
||||
type ComposerBranchSelection
|
||||
} from '../../../shared/composer-branch-selection'
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
Loading…
Reference in New Issue