fix: preserve slashes in branch-created worktrees (#2091)

This commit is contained in:
Leynier Gutiérrez González 2026-05-17 16:19:43 -06:00 committed by GitHub
parent 10b63bd1a0
commit a99929e42e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 858 additions and 104 deletions

View File

@ -4,7 +4,14 @@ import { mkdtempSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import path from 'path'
import { getDefaultBaseRef, getRemoteCount, searchBaseRefs } from './repo'
import {
getDefaultBaseRef,
getBranchConflictKind,
getRemoteCount,
parseAndFilterSearchRefDetails,
searchBaseRefDetails,
searchBaseRefs
} from './repo'
// Why: these tests exercise real git state (not mocked gitExecFileAsync)
// because the change under test is in the `for-each-ref` glob argument
@ -102,6 +109,72 @@ describe('searchBaseRefs (widened glob)', () => {
expect(results).toContain('local-only')
})
it('returns the local branch name for a remote ref with slashes', async () => {
const sha = getHeadSha(tmpDir)
git(tmpDir, ['remote', 'add', 'origin', 'https://example.invalid/repo.git'])
createRemoteRef(tmpDir, 'origin/feature/something', sha)
const results = await searchBaseRefDetails(tmpDir, 'origin/feature/something')
expect(results).toContainEqual({
refName: 'origin/feature/something',
localBranchName: 'feature/something'
})
})
it('keeps local branch names unchanged in detailed search results', async () => {
git(tmpDir, ['branch', 'feature/something'])
const results = await searchBaseRefDetails(tmpDir, 'feature/something')
expect(results).toContainEqual({
refName: 'feature/something',
localBranchName: 'feature/something'
})
})
it('allows creating a local branch from the selected matching remote base ref', async () => {
const sha = getHeadSha(tmpDir)
createRemoteRef(tmpDir, 'origin/feature/something', sha)
const result = await getBranchConflictKind(
tmpDir,
'feature/something',
'origin/feature/something'
)
expect(result).toBeNull()
})
it('still reports a remote conflict for a different tracking ref with the same branch name', async () => {
const sha = getHeadSha(tmpDir)
createRemoteRef(tmpDir, 'origin/feature/something', sha)
createRemoteRef(tmpDir, 'upstream/feature/something', sha)
const result = await getBranchConflictKind(
tmpDir,
'feature/something',
'origin/feature/something'
)
expect(result).toBe('remote')
})
it('uses the longest configured remote name when deriving local branch names', () => {
const results = parseAndFilterSearchRefDetails(
'refs/remotes/foo/bar/feature/something\u0000foo/bar/feature/something\n',
10,
['foo', 'foo/bar']
)
expect(results).toEqual([
{
refName: 'foo/bar/feature/something',
localBranchName: 'feature/something'
}
])
})
it('returns [] for a repo with no matching refs', async () => {
const results = await searchBaseRefs(tmpDir, 'nonexistent-query-xyz')

View File

@ -4,6 +4,7 @@ import { existsSync, statSync } from 'fs'
import { join, basename } from 'path'
import hostedGitInfo from 'hosted-git-info'
import { gitExecFileSync, gitExecFileAsync } from './runner'
import type { BaseRefSearchResult } from '../../shared/types'
/**
* Ordered probe list used to resolve a repo's default base ref when no
@ -463,6 +464,14 @@ export async function getDefaultRemote(path: string): Promise<string> {
}
export async function searchBaseRefs(path: string, query: string, limit = 25): Promise<string[]> {
return (await searchBaseRefDetails(path, query, limit)).map((entry) => entry.refName)
}
export async function searchBaseRefDetails(
path: string,
query: string,
limit = 25
): Promise<BaseRefSearchResult[]> {
const normalizedQuery = normalizeRefSearchQuery(query)
if (!normalizedQuery) {
return []
@ -471,11 +480,12 @@ export async function searchBaseRefs(path: string, query: string, limit = 25): P
try {
// Why: argv (including the two-remote-glob rationale) lives in
// buildSearchBaseRefsArgv so the SSH sibling cannot drift.
const { stdout } = await gitExecFileAsync(buildSearchBaseRefsArgv(normalizedQuery), {
cwd: path
})
const [{ stdout }, remotes] = await Promise.all([
gitExecFileAsync(buildSearchBaseRefsArgv(normalizedQuery), { cwd: path }),
listRemoteNames(path)
])
return parseAndFilterSearchRefs(stdout, limit)
return parseAndFilterSearchRefDetails(stdout, limit, remotes)
} catch (err) {
// Why: surface the failure for diagnostics; callers treat `[]` as "no
// matches", but silently swallowing the error makes a missing result
@ -486,6 +496,18 @@ export async function searchBaseRefs(path: string, query: string, limit = 25): P
}
}
async function listRemoteNames(path: string): Promise<string[]> {
try {
const { stdout } = await gitExecFileAsync(['remote'], { cwd: path })
return stdout
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
} catch {
return []
}
}
/**
* Parse `git for-each-ref --format=%(refname)%00%(refname:short)` stdout
* into a deduped list of short refs, filtering out `<remote>/HEAD`
@ -498,7 +520,16 @@ export async function searchBaseRefs(path: string, query: string, limit = 25): P
* lived in a single location; two copies double the regression surface.
*/
export function parseAndFilterSearchRefs(stdout: string, limit: number): string[] {
return parseAndFilterSearchRefDetails(stdout, limit).map((entry) => entry.refName)
}
export function parseAndFilterSearchRefDetails(
stdout: string,
limit: number,
remotes: string[] = []
): BaseRefSearchResult[] {
const seen = new Set<string>()
const sortedRemotes = [...remotes].sort((a, b) => b.length - a.length)
return (
stdout
.split('\n')
@ -531,7 +562,10 @@ export function parseAndFilterSearchRefs(stdout: string, limit: number): string[
seen.add(short)
return true
})
.map(({ short }) => short)
.map(({ full, short }) => ({
refName: short,
localBranchName: resolveLocalBranchName(full, short, sortedRemotes)
}))
// Why: `Math.max(0, limit)` — treat pathological `limit <= 0` as
// "zero results" rather than "at least 1". More honest than silently
// returning a single ref when the caller explicitly asked for none.
@ -539,6 +573,19 @@ export function parseAndFilterSearchRefs(stdout: string, limit: number): string[
)
}
function resolveLocalBranchName(fullRef: string, shortRef: string, remotes: string[]): string {
const remoteRefPrefix = 'refs/remotes/'
if (!fullRef.startsWith(remoteRefPrefix)) {
return shortRef
}
const remoteAndBranch = fullRef.slice(remoteRefPrefix.length)
const remote = remotes.find((candidate) => remoteAndBranch.startsWith(`${candidate}/`))
if (remote) {
return remoteAndBranch.slice(remote.length + 1)
}
return remoteAndBranch.split('/').slice(1).join('/') || shortRef
}
export function normalizeRefSearchQuery(query: string): string {
return query.trim().replace(/[*?[\]\\]/g, '')
}
@ -556,7 +603,8 @@ export type BranchConflictKind = 'local' | 'remote'
export async function getBranchConflictKind(
path: string,
branchName: string
branchName: string,
allowedBaseRef?: string
): Promise<BranchConflictKind | null> {
if (await hasGitRefAsync(path, `refs/heads/${branchName}`)) {
return 'local'
@ -571,7 +619,11 @@ export async function getBranchConflictKind(
// first three segments so that e.g. "feature/dashboard" only matches
// "refs/remotes/origin/feature/dashboard", not "refs/remotes/origin/other/feature/dashboard".
const hasRemoteConflict = stdout.split('\n').some((ref) => {
const parts = ref.trim().split('/')
const trimmed = ref.trim()
if (isAllowedRemoteBaseRef(trimmed, allowedBaseRef)) {
return false
}
const parts = trimmed.split('/')
return parts.slice(3).join('/') === branchName
})
@ -581,6 +633,16 @@ export async function getBranchConflictKind(
}
}
function isAllowedRemoteBaseRef(refName: string, allowedBaseRef: string | undefined): boolean {
if (!allowedBaseRef) {
return false
}
const normalizedAllowedRef = allowedBaseRef.startsWith('refs/remotes/')
? allowedBaseRef
: `refs/remotes/${allowedBaseRef}`
return refName === normalizedAllowedRef
}
/**
* Build a hosted URL (e.g. GitHub, GitLab, Bitbucket) for a specific file
* and line in the repo. Returns null when the remote isn't a recognized host.

View File

@ -689,7 +689,7 @@ describe('repos:searchBaseRefs SSH relay', () => {
await handlers.get('repos:searchBaseRefs')!(null, { repoId: 'r1', query: 'upstream' })
expect(mockGitProvider.exec).toHaveBeenCalledTimes(1)
expect(mockGitProvider.exec).toHaveBeenCalledTimes(2)
const [argv, path] = mockGitProvider.exec.mock.calls[0]
expect(path).toBe('/remote/repo')
expect(argv[0]).toBe('for-each-ref')
@ -698,6 +698,7 @@ describe('repos:searchBaseRefs SSH relay', () => {
expect(argv).toContain('refs/heads/*upstream*')
// Guard against regression to the old origin-only glob.
expect(argv).not.toContain('refs/remotes/origin/*upstream*')
expect(mockGitProvider.exec.mock.calls[1]).toEqual([['remote'], '/remote/repo'])
})
it('sends segmented argv for display-format queries like `upstream/main`', async () => {
@ -717,7 +718,7 @@ describe('repos:searchBaseRefs SSH relay', () => {
await handlers.get('repos:searchBaseRefs')!(null, { repoId: 'r1', query: 'upstream/main' })
expect(mockGitProvider.exec).toHaveBeenCalledTimes(1)
expect(mockGitProvider.exec).toHaveBeenCalledTimes(2)
const [argv] = mockGitProvider.exec.mock.calls[0]
expect(argv).toContain('refs/remotes/*upstream*/*main*')
expect(argv).toContain('refs/heads/*upstream*/*main*')
@ -726,6 +727,7 @@ describe('repos:searchBaseRefs SSH relay', () => {
// which fnmatch cannot match because `*` doesn't cross `/`.
expect(argv).not.toContain('refs/remotes/*upstream/main*/*')
expect(argv).not.toContain('refs/remotes/*/*upstream/main*')
expect(mockGitProvider.exec.mock.calls[1]).toEqual([['remote'], '/remote/repo'])
})
it('parses NUL-delimited stdout and filters <remote>/HEAD pseudo-refs', async () => {

View File

@ -5,7 +5,12 @@ import type { BrowserWindow } from 'electron'
import { dialog, ipcMain } from 'electron'
import { randomUUID } from 'crypto'
import type { Store } from '../persistence'
import type { Repo, BaseRefDefaultResult, SparsePreset } from '../../shared/types'
import type {
BaseRefSearchResult,
Repo,
BaseRefDefaultResult,
SparsePreset
} from '../../shared/types'
import { isFolderRepo } from '../../shared/repo-kind'
import { DEFAULT_REPO_BADGE_COLOR } from '../../shared/constants'
import { invalidateAuthorizedRootsCache } from './filesystem-auth'
@ -20,11 +25,11 @@ import {
getBaseRefDefault,
getRemoteCount,
normalizeRefSearchQuery,
parseAndFilterSearchRefs,
parseAndFilterSearchRefDetails,
parseRemoteCount,
resolveDefaultBaseRefViaExec,
buildSearchBaseRefsArgv,
searchBaseRefs
searchBaseRefDetails
} from '../git/repo'
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
import { getActiveMultiplexer } from './ssh'
@ -75,6 +80,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
ipcMain.removeHandler('repos:getGitUsername')
ipcMain.removeHandler('repos:getBaseRefDefault')
ipcMain.removeHandler('repos:searchBaseRefs')
ipcMain.removeHandler('repos:searchBaseRefDetails')
ipcMain.removeHandler('repos:addRemote')
ipcMain.removeHandler('repos:create')
ipcMain.removeHandler('sparsePresets:list')
@ -801,46 +807,67 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
ipcMain.handle(
'repos:searchBaseRefs',
async (_event, args: { repoId: string; query: string; limit?: number }) => {
const repo = store.getRepo(args.repoId)
if (!repo || isFolderRepo(repo)) {
return []
}
const limit = args.limit ?? 25
// Why: remote repos need the relay to list branches on the remote host.
if (repo.connectionId) {
const provider = getSshGitProvider(repo.connectionId)
if (!provider) {
return []
}
// Why: mirror the local path's sanitization (normalizeRefSearchQuery
// in ../git/repo.ts) — strip glob metacharacters to prevent glob
// injection via the SSH branch, and short-circuit empty queries so
// we don't leak every ref. Without this the SSH path diverges from
// the local path's behavior.
const normalizedQuery = normalizeRefSearchQuery(args.query)
if (!normalizedQuery) {
return []
}
try {
// Why: argv (including the two-remote-glob rationale) lives in
// buildSearchBaseRefsArgv so the SSH and local paths cannot drift.
const result = await provider.exec(buildSearchBaseRefsArgv(normalizedQuery), repo.path)
// Why: delegate the NUL-parse + HEAD filter + dedup + limit pipeline
// to the shared helper so the SSH and local paths cannot diverge.
// See parseAndFilterSearchRefs in ../git/repo.ts for the dedup +
// HEAD-filter rationale.
return parseAndFilterSearchRefs(result.stdout, limit)
} catch (err) {
console.warn('[repos:searchBaseRefs] SSH for-each-ref failed', {
path: repo.path,
err
})
return []
}
}
return searchBaseRefs(repo.path, args.query, limit)
return (await searchBaseRefDetailsForRepo(store, args)).map((entry) => entry.refName)
}
)
ipcMain.handle(
'repos:searchBaseRefDetails',
async (_event, args: { repoId: string; query: string; limit?: number }) => {
return searchBaseRefDetailsForRepo(store, args)
}
)
}
async function searchBaseRefDetailsForRepo(
store: Store,
args: { repoId: string; query: string; limit?: number }
): Promise<BaseRefSearchResult[]> {
const repo = store.getRepo(args.repoId)
if (!repo || isFolderRepo(repo)) {
return []
}
const limit = args.limit ?? 25
// Why: remote repos need the relay to list branches on the remote host.
if (repo.connectionId) {
const provider = getSshGitProvider(repo.connectionId)
if (!provider) {
return []
}
// Why: mirror the local path's sanitization (normalizeRefSearchQuery
// in ../git/repo.ts) — strip glob metacharacters to prevent glob
// injection via the SSH branch, and short-circuit empty queries so
// we don't leak every ref. Without this the SSH path diverges from
// the local path's behavior.
const normalizedQuery = normalizeRefSearchQuery(args.query)
if (!normalizedQuery) {
return []
}
try {
// Why: argv (including the two-remote-glob rationale) lives in
// buildSearchBaseRefsArgv so the SSH and local paths cannot drift.
const [result, remotesResult] = await Promise.all([
provider.exec(buildSearchBaseRefsArgv(normalizedQuery), repo.path),
provider.exec(['remote'], repo.path).catch(() => ({ stdout: '' }))
])
// Why: delegate the NUL-parse + HEAD filter + dedup + limit pipeline
// to the shared helper so the SSH and local paths cannot diverge.
// See parseAndFilterSearchRefs in ../git/repo.ts for the dedup +
// HEAD-filter rationale.
const remotes = remotesResult.stdout
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
return parseAndFilterSearchRefDetails(result.stdout, limit, remotes)
} catch (err) {
console.warn('[repos:searchBaseRefs] SSH for-each-ref failed', {
path: repo.path,
err
})
return []
}
}
return searchBaseRefDetails(repo.path, args.query, limit)
}
function notifyReposChanged(mainWindow: BrowserWindow): void {

View File

@ -90,6 +90,41 @@ async function findRemoteForUrl(repoPath: string, remoteUrl: string): Promise<st
return null
}
async function resolveCreateBranchName(
repoPath: string,
branchNameOverride: string | undefined,
sanitizedName: string,
settings: { branchPrefix: string; branchPrefixCustom?: string },
username: string | null
): Promise<string> {
if (!branchNameOverride) {
return computeBranchName(sanitizedName, settings, username)
}
if (branchNameOverride.startsWith('-')) {
throw new Error('Branch name must not start with "-"')
}
await gitExecFileAsync(['check-ref-format', '--branch', branchNameOverride], { cwd: repoPath })
return branchNameOverride
}
async function resolveCreateBranchNameSsh(
provider: SshGitProvider,
repoPath: string,
branchNameOverride: string | undefined,
sanitizedName: string,
settings: { branchPrefix: string; branchPrefixCustom?: string },
username: string | null
): Promise<string> {
if (!branchNameOverride) {
return computeBranchName(sanitizedName, settings, username)
}
if (branchNameOverride.startsWith('-')) {
throw new Error('Branch name must not start with "-"')
}
await provider.exec(['check-ref-format', '--branch', branchNameOverride], repoPath)
return branchNameOverride
}
async function ensureUniqueRemoteName(repoPath: string, preferred: string): Promise<string> {
const { stdout } = await gitExecFileAsync(['remote'], { cwd: repoPath })
const existing = new Set(
@ -305,7 +340,14 @@ export async function createRemoteWorktree(
/* no username configured */
}
const branchName = computeBranchName(sanitizedName, settings, username)
const branchName = await resolveCreateBranchNameSsh(
provider,
repo.path,
args.branchNameOverride,
sanitizedName,
settings,
username
)
// Check branch conflict on remote
try {
@ -578,8 +620,18 @@ export async function createLocalWorktree(
? `${requestedName}-${suffix}`
: effectiveSanitizedName
branchName = computeBranchName(effectiveSanitizedName, settings, username)
lastBranchConflictKind = await getBranchConflictKind(repo.path, branchName)
branchName = await resolveCreateBranchName(
repo.path,
suffix === 1 && args.branchNameOverride
? args.branchNameOverride
: args.branchNameOverride
? `${args.branchNameOverride}-${suffix}`
: undefined,
effectiveSanitizedName,
settings,
username
)
lastBranchConflictKind = await getBranchConflictKind(repo.path, branchName, baseBranch)
if (lastBranchConflictKind) {
continue
}

View File

@ -390,6 +390,81 @@ describe('registerWorktreeHandlers', () => {
})
})
it('uses branchNameOverride for the git branch while keeping the sanitized worktree path', async () => {
listWorktreesMock.mockResolvedValue([
{
path: '/workspace/feature-something',
head: 'abc123',
branch: 'feature/something',
isBare: false,
isMainWorktree: false
}
])
const result = await handlers['worktrees:create'](null, {
repoId: 'repo-1',
name: 'feature/something',
branchNameOverride: 'feature/something'
})
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
['check-ref-format', '--branch', 'feature/something'],
{ cwd: '/workspace/repo' }
)
expect(addWorktreeMock).toHaveBeenCalledWith(
'/workspace/repo',
'/workspace/feature-something',
'feature/something',
'origin/main',
false
)
expect(result).toEqual({
worktree: expect.objectContaining({
path: '/workspace/feature-something',
branch: 'feature/something'
})
})
})
it('suffixes branchNameOverride without flattening slashes when the first branch collides', async () => {
getBranchConflictKindMock.mockImplementation(async (_repoPath: string, branch: string) =>
branch === 'feature/something' ? 'remote' : null
)
listWorktreesMock.mockResolvedValue([
{
path: '/workspace/feature-something-2',
head: 'abc123',
branch: 'feature/something-2',
isBare: false,
isMainWorktree: false
}
])
const result = await handlers['worktrees:create'](null, {
repoId: 'repo-1',
name: 'feature/something',
branchNameOverride: 'feature/something'
})
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',
false
)
expect(result).toEqual({
worktree: expect.objectContaining({
path: '/workspace/feature-something-2',
branch: 'feature/something-2'
})
})
})
it('persists a sanitized artifact title as the worktree display name', async () => {
listWorktreesMock.mockResolvedValue([
{

View File

@ -18,7 +18,7 @@ import {
runHook,
shouldRunSetupForCreate
} from '../hooks'
import { getDefaultBaseRef } from '../git/repo'
import { getBranchConflictKind, getDefaultBaseRef } from '../git/repo'
import { OrchestrationDb } from './orchestration/db'
import { OrcaRuntimeService } from './orca-runtime'
import {
@ -582,6 +582,46 @@ describe('OrcaRuntimeService', () => {
})
})
it('creates a branchNameOverride worktree from the selected matching remote base ref', async () => {
const runtime = new OrcaRuntimeService(store)
vi.spyOn(gitRunner, 'gitExecFileAsync').mockResolvedValue({ stdout: '', stderr: '' })
computeWorktreePathMock.mockReturnValue('/tmp/workspaces/feature-something')
ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/feature-something')
vi.mocked(listWorktrees).mockResolvedValueOnce([
{
path: '/tmp/workspaces/feature-something',
head: 'def',
branch: 'feature/something',
isBare: false,
isMainWorktree: false
}
])
const result = await runtime.createManagedWorktree({
repoSelector: 'id:repo-1',
name: 'feature/something',
baseBranch: 'origin/feature/something',
branchNameOverride: 'feature/something'
})
expect(getBranchConflictKind).toHaveBeenCalledWith(
TEST_REPO_PATH,
'feature/something',
'origin/feature/something'
)
expect(addWorktree).toHaveBeenCalledWith(
TEST_REPO_PATH,
'/tmp/workspaces/feature-something',
'feature/something',
'origin/feature/something',
false
)
expect(result.worktree).toMatchObject({
path: '/tmp/workspaces/feature-something',
branch: 'feature/something'
})
})
it('does not run local git when runtime worktree creation targets an SSH repo', async () => {
vi.mocked(listWorktrees).mockClear()
vi.mocked(addWorktree).mockClear()

View File

@ -15,6 +15,7 @@ import { mkdir, readdir, rm, stat } from 'fs/promises'
import { OrchestrationDb } from './orchestration/db'
import { formatMessagesForInjection } from './orchestration/formatter'
import type {
BaseRefSearchResult,
CreateWorktreeResult,
GitPushTarget,
GitWorktreeInfo,
@ -194,10 +195,10 @@ import {
getBranchConflictKind,
isGitRepo,
getRepoName,
searchBaseRefs,
searchBaseRefDetails,
getRemoteCount,
normalizeRefSearchQuery,
parseAndFilterSearchRefs,
parseAndFilterSearchRefDetails,
parseRemoteCount,
resolveDefaultBaseRefViaExec,
buildSearchBaseRefsArgv,
@ -486,6 +487,23 @@ function omitUndefinedProperties<T extends Record<string, unknown>>(value: T): P
) as Partial<T>
}
async function resolveCreateBranchName(
repoPath: string,
branchNameOverride: string | undefined,
sanitizedName: string,
settings: { branchPrefix: string; branchPrefixCustom?: string },
username: string | null
): Promise<string> {
if (!branchNameOverride) {
return computeBranchName(sanitizedName, settings, username)
}
if (branchNameOverride.startsWith('-')) {
throw new Error('Branch name must not start with "-"')
}
await gitExecFileAsync(['check-ref-format', '--branch', branchNameOverride], { cwd: repoPath })
return branchNameOverride
}
type ResolvedWorktree = Worktree & {
parentWorktreeId: string | null
childWorktreeIds: string[]
@ -4383,12 +4401,13 @@ export class OrcaRuntimeService {
truncated: false
}
}
const refs = repo.connectionId
const refDetails = repo.connectionId
? await this.searchRemoteRepoRefs(repo, query, limit + 1)
: await searchBaseRefs(repo.path, query, limit + 1)
: await searchBaseRefDetails(repo.path, query, limit + 1)
return {
refs: refs.slice(0, limit),
truncated: refs.length > limit
refs: refDetails.slice(0, limit).map((entry) => entry.refName),
refDetails: refDetails.slice(0, limit),
truncated: refDetails.length > limit
}
}
@ -4444,7 +4463,11 @@ export class OrcaRuntimeService {
return { defaultBaseRef, remoteCount }
}
private async searchRemoteRepoRefs(repo: Repo, query: string, limit: number): Promise<string[]> {
private async searchRemoteRepoRefs(
repo: Repo,
query: string,
limit: number
): Promise<BaseRefSearchResult[]> {
const provider = repo.connectionId ? getSshGitProvider(repo.connectionId) : null
if (!provider) {
return []
@ -4454,8 +4477,15 @@ export class OrcaRuntimeService {
return []
}
try {
const result = await provider.exec(buildSearchBaseRefsArgv(normalizedQuery), repo.path)
return parseAndFilterSearchRefs(result.stdout, limit)
const [result, remotesResult] = await Promise.all([
provider.exec(buildSearchBaseRefsArgv(normalizedQuery), repo.path),
provider.exec(['remote'], repo.path).catch(() => ({ stdout: '' }))
])
const remotes = remotesResult.stdout
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
return parseAndFilterSearchRefDetails(result.stdout, limit, remotes)
} catch (err) {
console.warn('[runtime:repo.searchRefs] SSH for-each-ref failed', {
path: repo.path,
@ -5147,6 +5177,7 @@ export class OrcaRuntimeService {
repoSelector: string
name: string
baseBranch?: string
branchNameOverride?: string
linkedIssue?: number | null
linkedPR?: number | null
linkedLinearIssue?: string
@ -5184,9 +5215,26 @@ export class OrcaRuntimeService {
const requestedDisplayName = args.displayName?.trim() || undefined
const sanitizedName = sanitizeWorktreeName(args.name)
const username = getGitUsername(repo.path)
const branchName = computeBranchName(sanitizedName, settings, username)
const branchName = await resolveCreateBranchName(
repo.path,
args.branchNameOverride,
sanitizedName,
settings,
username
)
const branchConflictKind = await getBranchConflictKind(repo.path, branchName)
const baseBranch = args.baseBranch || repo.worktreeBaseRef || getDefaultBaseRef(repo.path)
if (!baseBranch) {
// Why: getDefaultBaseRef returns null when no suitable ref exists.
// Don't fabricate 'origin/main' — passing it to addWorktree would
// produce an opaque git failure. Surface a clear error so the CLI
// caller can pick an explicit --base ref.
throw new Error(
'Could not resolve a default base ref for this repo. Pass an explicit --base and try again.'
)
}
const branchConflictKind = await getBranchConflictKind(repo.path, branchName, baseBranch)
if (branchConflictKind) {
throw new Error(
`Branch "${branchName}" already exists ${branchConflictKind === 'local' ? 'locally' : 'on a remote'}.`
@ -5213,17 +5261,6 @@ export class OrcaRuntimeService {
const wslHome = wslInfo ? getWslHome(wslInfo.distro) : null
const workspaceRoot = wslHome ? join(wslHome, 'orca', 'workspaces') : settings.workspaceDir
worktreePath = ensurePathWithinWorkspace(worktreePath, workspaceRoot)
const baseBranch = args.baseBranch || repo.worktreeBaseRef || getDefaultBaseRef(repo.path)
if (!baseBranch) {
// Why: getDefaultBaseRef returns null when no suitable ref exists.
// Don't fabricate 'origin/main' — passing it to addWorktree would
// produce an opaque git failure. Surface a clear error so the CLI
// caller can pick an explicit --base ref.
throw new Error(
'Could not resolve a default base ref for this repo. Pass an explicit --base and try again.'
)
}
const remote = baseBranch.includes('/') ? baseBranch.split('/')[0] : 'origin'
// Why (§3.3 Lifecycle): route through the shared fetch cache so back-to-back
// CLI creates on the same repo don't each pay the round-trip, and so a

View File

@ -20,6 +20,7 @@ describe('worktree RPC methods', () => {
makeRequest('worktree.create', {
repo: 'repo-1',
name: 'feature',
branchNameOverride: 'feature/something',
baseBranch: 'origin/main',
setupDecision: 'skip',
displayName: 'Feature title',
@ -35,6 +36,7 @@ describe('worktree RPC methods', () => {
expect(runtime.createManagedWorktree).toHaveBeenCalledWith({
repoSelector: 'repo-1',
name: 'feature',
branchNameOverride: 'feature/something',
baseBranch: 'origin/main',
linkedIssue: 123,
linkedPR: 456,

View File

@ -37,6 +37,7 @@ const WorktreeCreate = z
.pipe(z.string().min(1, 'Missing repo selector')),
name: OptionalString,
baseBranch: OptionalString,
branchNameOverride: OptionalString,
linkedIssue: TriStateLinkedIssue,
linkedPR: TriStateLinkedIssue,
linkedLinearIssue: z.string().optional(),
@ -191,6 +192,7 @@ export const WORKTREE_METHODS: RpcMethod[] = [
repoSelector: params.repo,
name: params.name ?? '',
baseBranch: params.baseBranch,
branchNameOverride: params.branchNameOverride,
linkedIssue: params.linkedIssue,
linkedPR: params.linkedPR,
linkedLinearIssue: params.linkedLinearIssue,

View File

@ -10,6 +10,7 @@ import type {
import type { AppIdentity } from '../shared/app-identity'
import type {
BaseRefDefaultResult,
BaseRefSearchResult,
BrowserCookieImportResult,
BrowserLoadError,
BrowserSessionProfile,
@ -577,6 +578,11 @@ export type PreloadApi = {
getGitUsername: (args: { repoId: string }) => Promise<string>
getBaseRefDefault: (args: { repoId: string }) => Promise<BaseRefDefaultResult>
searchBaseRefs: (args: { repoId: string; query: string; limit?: number }) => Promise<string[]>
searchBaseRefDetails: (args: {
repoId: string
query: string
limit?: number
}) => Promise<BaseRefSearchResult[]>
onChanged: (callback: () => void) => () => void
}
sparsePresets: {

View File

@ -9,6 +9,7 @@ import type { AppIdentity } from '../shared/app-identity'
import type { CliInstallStatus } from '../shared/cli-install-types'
import type { AgentHookInstallStatus } from '../shared/agent-hook-types'
import type {
BaseRefSearchResult,
BaseRefDefaultResult,
BrowserViewportOverride,
CreateWorktreeArgs,
@ -411,6 +412,12 @@ const api = {
searchBaseRefs: (args: { repoId: string; query: string; limit?: number }): Promise<string[]> =>
ipcRenderer.invoke('repos:searchBaseRefs', args),
searchBaseRefDetails: (args: {
repoId: string
query: string
limit?: number
}): Promise<BaseRefSearchResult[]> => ipcRenderer.invoke('repos:searchBaseRefDetails', args),
onChanged: (callback: () => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent) => callback()
ipcRenderer.on('repos:changed', listener)

View File

@ -49,7 +49,7 @@ type NewWorkspaceComposerCardProps = {
onNameValueChange: (value: string) => void
onSmartGitHubItemSelect: (item: GitHubWorkItem) => void
onSmartGitLabItemSelect: (item: GitLabWorkItem) => void
onSmartBranchSelect: (refName: string) => void
onSmartBranchSelect: (refName: string, localBranchName: string) => void
onSmartLinearIssueSelect: (issue: LinearIssue) => void
smartNameSelection: SmartWorkspaceNameSelection | null
onClearSmartNameSelection: () => void

View File

@ -41,7 +41,13 @@ import {
import { parseGitLabIssueOrMRLink } from '@/lib/gitlab-links'
import { cn } from '@/lib/utils'
import { LinearIcon } from '@/components/icons/LinearIcon'
import type { GitHubWorkItem, GitLabWorkItem, LinearIssue } from '../../../../shared/types'
import { searchRuntimeRepoBaseRefDetails } from '@/runtime/runtime-repo-client'
import type {
BaseRefSearchResult,
GitHubWorkItem,
GitLabWorkItem,
LinearIssue
} from '../../../../shared/types'
type SmartNameMode = 'smart' | 'github' | 'gitlab' | 'branches' | 'linear' | 'text'
@ -68,7 +74,7 @@ type SmartWorkspaceNameFieldProps = {
/** Optional so callers that pre-date GitLab support don't need to wire
* it. When omitted, GitLab paste-URL detection is silently skipped. */
onGitLabItemSelect?: (item: GitLabWorkItem) => void
onBranchSelect: (refName: string) => void
onBranchSelect: (refName: string, localBranchName: string) => void
onLinearIssueSelect: (issue: LinearIssue) => void
selectedSource: SmartWorkspaceNameSelection | null
onClearSelectedSource: () => void
@ -122,7 +128,7 @@ type RowEntry =
| { kind: 'create-branch'; value: string; name: string }
| { kind: 'github'; value: string; item: GitHubWorkItem }
| { kind: 'gitlab'; value: string; item: GitLabWorkItem }
| { kind: 'branch'; value: string; refName: string }
| { kind: 'branch'; value: string; refName: string; localBranchName: string }
| { kind: 'linear'; value: string; issue: LinearIssue }
export default function SmartWorkspaceNameField({
@ -150,7 +156,8 @@ export default function SmartWorkspaceNameField({
linearStatus,
linearStatusChecked,
listLinearIssues,
searchLinearIssues
searchLinearIssues,
settings
} = useAppStore(
useShallow((s) => ({
addRepo: s.addRepo,
@ -160,7 +167,8 @@ export default function SmartWorkspaceNameField({
linearStatus: s.linearStatus,
linearStatusChecked: s.linearStatusChecked,
listLinearIssues: s.listLinearIssues,
searchLinearIssues: s.searchLinearIssues
searchLinearIssues: s.searchLinearIssues,
settings: s.settings
}))
)
const selectedRepo = useMemo(
@ -173,7 +181,7 @@ export default function SmartWorkspaceNameField({
const [debouncedQuery, setDebouncedQuery] = useState(value)
const [githubItems, setGithubItems] = useState<GitHubWorkItem[]>([])
const [gitlabItems, setGitlabItems] = useState<GitLabWorkItem[]>([])
const [branches, setBranches] = useState<string[]>([])
const [branches, setBranches] = useState<BaseRefSearchResult[]>([])
const [linearIssues, setLinearIssues] = useState<LinearIssue[]>([])
const [githubLoading, setGithubLoading] = useState(false)
const [gitlabLoading, setGitlabLoading] = useState(false)
@ -386,12 +394,12 @@ export default function SmartWorkspaceNameField({
}
let stale = false
setBranchesLoading(true)
void window.api.repos
.searchBaseRefs({
repoId: selectedRepo.id,
query: debouncedQuery.trim(),
limit: RESULT_LIMIT
})
void searchRuntimeRepoBaseRefDetails(
settings,
selectedRepo.id,
debouncedQuery.trim(),
RESULT_LIMIT
)
.then((results) => {
if (!stale) {
setBranches(results)
@ -410,7 +418,7 @@ export default function SmartWorkspaceNameField({
return () => {
stale = true
}
}, [debouncedQuery, disabled, selectedRepo, shouldQueryBranches])
}, [debouncedQuery, disabled, selectedRepo, settings, shouldQueryBranches])
useEffect(() => {
if (disabled || !shouldQueryLinear || !linearStatus.connected) {
@ -577,7 +585,10 @@ export default function SmartWorkspaceNameField({
// for a branch-creation row that's pinned above existing-branch results
// (suppressed when an existing branch matches exactly so we don't offer
// to "create" something that already exists).
const branchExactMatch = mode === 'branches' && trimmed.length > 0 && branches.includes(trimmed)
const branchExactMatch =
mode === 'branches' &&
trimmed.length > 0 &&
branches.some((branch) => branch.refName === trimmed || branch.localBranchName === trimmed)
// Why: the "Use … as workspace name" row only makes sense in Smart
// mode, where the user might be typing a free-form name. On dedicated
// source tabs (GitHub/Linear/Branches) it's off-topic — the user is
@ -620,10 +631,11 @@ export default function SmartWorkspaceNameField({
nextRows.push(createBranchRow)
}
nextRows.push(
...branches.map((refName) => ({
...branches.map((branch) => ({
kind: 'branch' as const,
value: `branch-${refName}`,
refName
value: `branch-${branch.refName}`,
refName: branch.refName,
localBranchName: branch.localBranchName
}))
)
}
@ -719,7 +731,7 @@ export default function SmartWorkspaceNameField({
// no-op for hosts that haven't wired GitLab support yet.
onGitLabItemSelect?.(row.item)
} else if (row.kind === 'branch') {
onBranchSelect(row.refName)
onBranchSelect(row.refName, row.localBranchName)
} else {
onLinearIssueSelect(row.issue)
}

View File

@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest'
import { resolveComposerBranchSelection } from './composer-branch-selection'
describe('resolveComposerBranchSelection', () => {
it('keeps selected remote ref as base while using the local branch name for create', () => {
expect(
resolveComposerBranchSelection({
refName: 'origin/feature/something',
localBranchName: 'feature/something',
currentName: '',
lastAutoName: ''
})
).toEqual({
baseBranch: 'origin/feature/something',
branchNameOverride: 'feature/something',
branchAutoName: 'feature/something',
name: 'feature/something',
lastAutoName: 'feature/something'
})
})
it('does not override a user-edited workspace name', () => {
expect(
resolveComposerBranchSelection({
refName: 'origin/feature/something',
localBranchName: 'feature/something',
currentName: 'custom-name',
lastAutoName: 'previous-auto'
})
).toMatchObject({
baseBranch: 'origin/feature/something',
branchNameOverride: undefined,
name: undefined
})
})
})

View File

@ -0,0 +1,32 @@
export type ComposerBranchSelection = {
baseBranch: string
branchNameOverride: string | undefined
branchAutoName: string
name: string | undefined
lastAutoName: string | undefined
}
export function resolveComposerBranchSelection(args: {
refName: string
localBranchName: string
currentName: string
lastAutoName: string
}): ComposerBranchSelection {
const shouldAutoName = !args.currentName.trim() || args.currentName === args.lastAutoName
if (!shouldAutoName) {
return {
baseBranch: args.refName,
branchNameOverride: undefined,
branchAutoName: '',
name: undefined,
lastAutoName: undefined
}
}
return {
baseBranch: args.refName,
branchNameOverride: args.localBranchName,
branchAutoName: args.localBranchName,
name: args.localBranchName,
lastAutoName: args.localBranchName
}
}

View File

@ -74,6 +74,7 @@ import {
type WorkspaceCreateErrorDisplay
} from '@/lib/workspace-create-error-format'
import type { SshConnectionStatus } from '../../../shared/ssh-types'
import { resolveComposerBranchSelection } from './composer-branch-selection'
export type UseComposerStateOptions = {
initialRepoId?: string
@ -117,7 +118,7 @@ export type ComposerCardProps = {
onNameValueChange: (value: string) => void
onSmartGitHubItemSelect: (item: GitHubWorkItem) => void
onSmartGitLabItemSelect: (item: GitLabWorkItem) => void
onSmartBranchSelect: (refName: string) => void
onSmartBranchSelect: (refName: string, localBranchName: string) => void
onSmartLinearIssueSelect: (issue: LinearIssue) => void
/** GitLab parallel of onBaseBranchPrSelect. */
onBaseBranchMrSelect?: (baseBranch: string, item: GitLabWorkItem) => void
@ -373,6 +374,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
const [baseBranch, setBaseBranch] = useState<string | undefined>(
persistDraft ? newWorkspaceDraft?.baseBranch : initialBaseBranch
)
const [branchNameOverride, setBranchNameOverride] = useState<string | undefined>(undefined)
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
@ -433,6 +435,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
const lastAutoNameRef = useRef<string>(
persistDraft ? (newWorkspaceDraft?.name ?? initialName) : initialName
)
const branchAutoNameRef = useRef<string>('')
// Why: tracks the note value we auto-prefilled from a Start-from PR pick, so
// a subsequent PR change can replace it without clobbering user-typed text.
const lastAutoNoteRef = useRef<string>('')
@ -1026,6 +1029,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
setName(suggestedName)
lastAutoNameRef.current = suggestedName
}
setBranchNameOverride(undefined)
},
[name]
)
@ -1064,6 +1068,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
setName(suggestedName)
lastAutoNameRef.current = suggestedName
}
setBranchNameOverride(undefined)
},
[name]
)
@ -1108,10 +1113,14 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
} else if (name !== lastAutoNameRef.current) {
lastAutoNameRef.current = ''
}
if (branchNameOverride && nextName !== branchAutoNameRef.current) {
setBranchNameOverride(undefined)
branchAutoNameRef.current = ''
}
setName(nextName)
setCreateError(null)
},
[name]
[branchNameOverride, name]
)
const addComposerAttachments = useCallback((paths: string[]): void => {
@ -1369,6 +1378,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
// makes the field fall back to the new repo's effective base ref.
setBaseBranch(undefined)
setPushTarget(undefined)
setBranchNameOverride(undefined)
setStartFromResetHint(hint)
},
[baseBranch, linkedWorkItem, repoId, setRepoId]
@ -1389,6 +1399,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
const handleBaseBranchChange = useCallback((next: string | undefined): void => {
setBaseBranch(next)
setPushTarget(undefined)
setBranchNameOverride(undefined)
branchAutoNameRef.current = ''
setStartFromResetHint(null)
}, [])
@ -1396,6 +1408,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
(nextBaseBranch: string, item: GitHubWorkItem, nextPushTarget?: GitPushTarget): void => {
setBaseBranch(nextBaseBranch)
setPushTarget(nextPushTarget)
setBranchNameOverride(undefined)
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
@ -1423,6 +1437,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
const handleBaseBranchMrSelect = useCallback(
(nextBaseBranch: string, item: GitLabWorkItem): void => {
setBaseBranch(nextBaseBranch)
setBranchNameOverride(undefined)
branchAutoNameRef.current = ''
setStartFromResetHint(null)
applyLinkedGitLabWorkItem(item)
if (item.type === 'mr') {
@ -1440,6 +1456,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
const handleSmartGitHubItemSelect = useCallback(
(item: GitHubWorkItem): void => {
setStartFromResetHint(null)
setBranchNameOverride(undefined)
branchAutoNameRef.current = ''
const repoForItem = eligibleRepos.find((repo) => repo.id === item.repoId) ?? selectedRepo
if (item.type !== 'pr' || !repoForItem) {
setPushTarget(undefined)
@ -1499,6 +1517,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
(item: GitLabWorkItem): void => {
applyLinkedGitLabWorkItem(item)
setStartFromResetHint(null)
setBranchNameOverride(undefined)
branchAutoNameRef.current = ''
const repoForItem = eligibleRepos.find((repo) => repo.id === item.repoId) ?? selectedRepo
if (item.type !== 'mr' || !repoForItem) {
return
@ -1523,13 +1543,24 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
)
const handleSmartBranchSelect = useCallback(
(refName: string): void => {
setBaseBranch(refName)
(refName: string, localBranchName: string): void => {
const selection = resolveComposerBranchSelection({
refName,
localBranchName,
currentName: name,
lastAutoName: lastAutoNameRef.current
})
setBaseBranch(selection.baseBranch)
setPushTarget(undefined)
setStartFromResetHint(null)
if (!name.trim() || name === lastAutoNameRef.current) {
setName(refName)
lastAutoNameRef.current = refName
if (selection.name !== undefined && selection.lastAutoName !== undefined) {
setName(selection.name)
lastAutoNameRef.current = selection.lastAutoName
branchAutoNameRef.current = selection.branchAutoName
setBranchNameOverride(selection.branchNameOverride)
} else {
setBranchNameOverride(selection.branchNameOverride)
branchAutoNameRef.current = selection.branchAutoName
}
},
[name]
@ -1552,6 +1583,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
setName(suggestedName)
lastAutoNameRef.current = suggestedName
}
setBranchNameOverride(undefined)
branchAutoNameRef.current = ''
// Why: match the GitHub issue/PR flow — paste only the URL as a draft
// into the agent's input (no auto-submit). The launch path already
// drafts `linkedWorkItem.url` when the note is empty; auto-filling the
@ -1567,6 +1600,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
setLinkedWorkItem(null)
setBaseBranch(undefined)
setPushTarget(undefined)
setBranchNameOverride(undefined)
branchAutoNameRef.current = ''
setStartFromResetHint(null)
if (name === lastAutoNameRef.current) {
setName('')
@ -1654,6 +1689,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
}
const linkedLinearIssue = linkedWorkItem?.linearIdentifier
const effectiveBranchNameOverride =
branchNameOverride && workspaceName === branchAutoNameRef.current
? branchNameOverride
: undefined
const result = await createWorktree(
repoId,
workspaceName,
@ -1672,6 +1711,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
pushTarget,
tuiAgent,
linkedLinearIssue,
effectiveBranchNameOverride,
resolvedInitialWorkspaceStatus
)
const worktree = result.worktree
@ -1750,6 +1790,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
}
}, [
baseBranch,
branchNameOverride,
clearNewWorkspaceDraft,
createWorktree,
applyWorktreeMeta,
@ -1846,6 +1887,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
: ((submitResolvedSetupDecision ?? 'inherit') as SetupDecision)
const linkedLinearIssue = linkedWorkItem?.linearIdentifier
const effectiveBranchNameOverride =
branchNameOverride && workspaceName === branchAutoNameRef.current
? branchNameOverride
: undefined
const result = await createWorktree(
repoId,
workspaceName,
@ -1864,6 +1909,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
pushTarget,
agent ?? undefined,
linkedLinearIssue,
effectiveBranchNameOverride,
resolvedInitialWorkspaceStatus
)
const worktree = result.worktree
@ -1988,6 +2034,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
[
applyWorktreeMeta,
baseBranch,
branchNameOverride,
clearNewWorkspaceDraft,
createWorktree,
fallbackCreatureName,

View File

@ -1,4 +1,5 @@
import type { GlobalSettings } from '../../../shared/types'
import type { BaseRefSearchResult, GlobalSettings } from '../../../shared/types'
import { legacyBaseRefSearchResult } from '../../../shared/base-ref-search-result'
import { callRuntimeRpc, getActiveRuntimeTarget } from './runtime-rpc-client'
export type RuntimeRepoBaseRefDefault = {
@ -40,3 +41,21 @@ export async function searchRuntimeRepoBaseRefs(
)
return result.refs
}
export async function searchRuntimeRepoBaseRefDetails(
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined,
repoId: string,
query: string,
limit: number
): Promise<BaseRefSearchResult[]> {
const target = getActiveRuntimeTarget(settings)
if (target.kind !== 'environment') {
return window.api.repos.searchBaseRefDetails({ repoId, query, limit })
}
const result = await callRuntimeRpc<{
refs: string[]
refDetails?: BaseRefSearchResult[]
truncated: boolean
}>(target, 'repo.searchRefs', { repo: repoId, query, limit }, { timeoutMs: 15_000 })
return result.refDetails ?? result.refs.map(legacyBaseRefSearchResult)
}

View File

@ -86,6 +86,7 @@ export type WorktreeSlice = {
pushTarget?: GitPushTarget,
createdWithAgent?: TuiAgent,
linkedLinearIssue?: string,
branchNameOverride?: string,
workspaceStatus?: WorkspaceStatus
) => Promise<CreateWorktreeResult>
removeWorktree: (

View File

@ -610,6 +610,7 @@ describe('createWorktree base status merge', () => {
undefined,
'codex',
'ENG-123',
undefined,
'in-review'
)
@ -633,6 +634,93 @@ describe('createWorktree base status merge', () => {
})
})
it('passes branchNameOverride through the local create IPC payload', async () => {
const store = createTestStore()
const wt = makeWorktree({
id: 'repo1::/path/feature-something',
repoId: 'repo1',
path: '/path/feature-something',
branch: 'feature/something'
})
mockApi.worktrees.create.mockResolvedValue({ worktree: wt })
await store
.getState()
.createWorktree(
'repo1',
'feature/something',
'origin/main',
'inherit',
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
'feature/something'
)
expect(mockApi.worktrees.create).toHaveBeenCalledWith(
expect.objectContaining({
repoId: 'repo1',
name: 'feature/something',
baseBranch: 'origin/main',
branchNameOverride: 'feature/something'
})
)
})
it('suffixes branchNameOverride when local IPC returns the SSH branch-exists error', 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 result = await store
.getState()
.createWorktree(
'repo1',
'feature/something',
'origin/main',
'inherit',
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
'feature/something'
)
expect(result).toEqual({ worktree: wt })
expect(mockApi.worktrees.create).toHaveBeenNthCalledWith(
1,
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 () => {
const store = createTestStore()
const wt = makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1', path: '/path/wt1' })
@ -1060,6 +1148,66 @@ describe('worktree remote runtime mutations', () => {
expect(store.getState().worktreesByRepo.repo1).toEqual([wt])
})
it('suffixes branchNameOverride when retrying a runtime create 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' }
})
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'
)
expect(result).toEqual({ worktree: wt })
expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
params: expect.objectContaining({
name: 'feature/something',
branchNameOverride: 'feature/something'
})
})
)
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 () => {
const store = createTestStore()
const wt = makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1', path: '/path/wt1' })

View File

@ -423,24 +423,34 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
pushTarget,
createdWithAgent,
linkedLinearIssue,
branchNameOverride,
workspaceStatus
) => {
const retryableConflictPatterns = [
/already exists locally/i,
/already exists on a remote/i,
/^Branch ".+" already exists\./i,
/already has pr #\d+/i
]
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)
try {
for (let attempt = 0; attempt < 25; attempt += 1) {
const candidateName = nextCandidateName(name, attempt)
const candidateBranchNameOverride = nextCandidateBranchName(branchNameOverride, attempt)
try {
const createArgs = {
repoId,
name: candidateName,
baseBranch,
...(candidateBranchNameOverride
? { branchNameOverride: candidateBranchNameOverride }
: {}),
setupDecision,
sparseCheckout,
...(displayName ? { displayName } : {}),
@ -463,6 +473,9 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
repo: repoId,
name: candidateName,
baseBranch,
...(candidateBranchNameOverride
? { branchNameOverride: candidateBranchNameOverride }
: {}),
setupDecision,
sparseCheckout,
...(displayName ? { displayName } : {}),

View File

@ -20,6 +20,7 @@ import {
getDefaultUIState,
getDefaultWorkspaceSession
} from '../../../shared/constants'
import { legacyBaseRefSearchResult } from '../../../shared/base-ref-search-result'
import { createE2EConfig } from '../../../shared/e2e-config'
import { relativePathInsideRoot } from '../../../shared/cross-platform-path'
import type { RateLimitState } from '../../../shared/rate-limit-types'
@ -297,6 +298,17 @@ function createReposApi(): NonNullable<Partial<PreloadApi>['repos']> {
limit
})
).refs,
searchBaseRefDetails: async ({ repoId, query, limit }) => {
const result = await callRuntimeResult<{
refs: string[]
refDetails?: { refName: string; localBranchName: string }[]
}>('repo.searchRefs', {
repo: repoId,
query,
limit
})
return result.refDetails ?? result.refs.map(legacyBaseRefSearchResult)
},
onChanged: () => noopUnsubscribe
}
}
@ -313,6 +325,7 @@ function createWorktreesApi(): NonNullable<Partial<PreloadApi>['worktrees']> {
repo: args.repoId,
name: args.name,
baseBranch: args.baseBranch,
branchNameOverride: args.branchNameOverride,
linkedIssue: args.linkedIssue,
linkedPR: args.linkedPR,
displayName: args.displayName,

View File

@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest'
import { deriveLegacyLocalBranchName, legacyBaseRefSearchResult } from './base-ref-search-result'
describe('legacyBaseRefSearchResult', () => {
it('derives local branch names for common remote refs returned by older runtimes', () => {
expect(deriveLegacyLocalBranchName('origin/feature/something')).toBe('feature/something')
expect(deriveLegacyLocalBranchName('upstream/release/1.2')).toBe('release/1.2')
})
it('keeps local branch refs unchanged when a remote prefix is not known', () => {
expect(legacyBaseRefSearchResult('feature/something')).toEqual({
refName: 'feature/something',
localBranchName: 'feature/something'
})
})
})

View File

@ -0,0 +1,21 @@
import type { BaseRefSearchResult } from './types'
const LEGACY_REMOTE_REF_PREFIXES = ['origin/', 'upstream/']
export function deriveLegacyLocalBranchName(refName: string): string {
// Why: mixed-version runtimes only return display refs. Keep common remote
// refs from reintroducing `origin/feature/foo` as the local branch name.
for (const prefix of LEGACY_REMOTE_REF_PREFIXES) {
if (refName.startsWith(prefix) && refName.length > prefix.length) {
return refName.slice(prefix.length)
}
}
return refName
}
export function legacyBaseRefSearchResult(refName: string): BaseRefSearchResult {
return {
refName,
localBranchName: deriveLegacyLocalBranchName(refName)
}
}

View File

@ -1,5 +1,6 @@
/* eslint-disable max-lines -- Why: shared type definitions for all runtime RPC methods live in one file for discoverability and import simplicity. */
import type {
BaseRefSearchResult,
BrowserCookieImportResult,
BrowserSessionProfile,
BrowserSessionProfileSource,
@ -348,6 +349,7 @@ export type RuntimeRepoList = {
export type RuntimeRepoSearchRefs = {
refs: string[]
refDetails?: BaseRefSearchResult[]
truncated: boolean
}

View File

@ -101,6 +101,11 @@ export type BaseRefDefaultResult = {
remoteCount: number
}
export type BaseRefSearchResult = {
refName: string
localBranchName: string
}
// ─── Worktree (git-level) ────────────────────────────────────────────
export type GitWorktreeInfo = {
path: string
@ -1020,6 +1025,10 @@ export type CreateWorktreeArgs = {
* Linear artifact whose title should remain readable in the sidebar. */
displayName?: string
baseBranch?: string
/** Optional git branch to create, separate from the filesystem-safe worktree
* name. Used when creating from an existing branch whose local branch name
* legitimately contains `/` while the worktree directory must not. */
branchNameOverride?: string
setupDecision?: SetupDecision
sparseCheckout?: CreateSparseCheckoutRequest
linkedIssue?: number