diff --git a/src/main/git/repo.test.ts b/src/main/git/repo.test.ts index 41a78afc8..2a8bced11 100644 --- a/src/main/git/repo.test.ts +++ b/src/main/git/repo.test.ts @@ -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') diff --git a/src/main/git/repo.ts b/src/main/git/repo.ts index 762ca6dd0..9b593f178 100644 --- a/src/main/git/repo.ts +++ b/src/main/git/repo.ts @@ -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 { } export async function searchBaseRefs(path: string, query: string, limit = 25): Promise { + return (await searchBaseRefDetails(path, query, limit)).map((entry) => entry.refName) +} + +export async function searchBaseRefDetails( + path: string, + query: string, + limit = 25 +): Promise { 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 { + 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 `/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() + 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 { 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. diff --git a/src/main/ipc/repos-remote.test.ts b/src/main/ipc/repos-remote.test.ts index 52f9e7d6b..e99aa1fc1 100644 --- a/src/main/ipc/repos-remote.test.ts +++ b/src/main/ipc/repos-remote.test.ts @@ -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 /HEAD pseudo-refs', async () => { diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index ee8930152..ff81578d3 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -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 { + 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 { diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index a8c6f2b25..a1a35665a 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -90,6 +90,41 @@ async function findRemoteForUrl(repoPath: string, remoteUrl: string): Promise { + 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 { + 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 { 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 } diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index 9632883b7..d2d9f4ddb 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -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([ { diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index a34ef8c00..2d0a98e62 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -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() diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 632bfc1ba..b4d46100b 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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>(value: T): P ) as Partial } +async function resolveCreateBranchName( + repoPath: string, + branchNameOverride: string | undefined, + sanitizedName: string, + settings: { branchPrefix: string; branchPrefixCustom?: string }, + username: string | null +): Promise { + 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 { + private async searchRemoteRepoRefs( + repo: Repo, + query: string, + limit: number + ): Promise { 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 diff --git a/src/main/runtime/rpc/methods/worktree.test.ts b/src/main/runtime/rpc/methods/worktree.test.ts index 8a3c62f17..cf5e520be 100644 --- a/src/main/runtime/rpc/methods/worktree.test.ts +++ b/src/main/runtime/rpc/methods/worktree.test.ts @@ -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, diff --git a/src/main/runtime/rpc/methods/worktree.ts b/src/main/runtime/rpc/methods/worktree.ts index 3ac7e384a..13d0a73c3 100644 --- a/src/main/runtime/rpc/methods/worktree.ts +++ b/src/main/runtime/rpc/methods/worktree.ts @@ -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, diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index de9621d2a..81d4a6b89 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -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 getBaseRefDefault: (args: { repoId: string }) => Promise searchBaseRefs: (args: { repoId: string; query: string; limit?: number }) => Promise + searchBaseRefDetails: (args: { + repoId: string + query: string + limit?: number + }) => Promise onChanged: (callback: () => void) => () => void } sparsePresets: { diff --git a/src/preload/index.ts b/src/preload/index.ts index 733ee7f18..614049e9e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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 => ipcRenderer.invoke('repos:searchBaseRefs', args), + searchBaseRefDetails: (args: { + repoId: string + query: string + limit?: number + }): Promise => ipcRenderer.invoke('repos:searchBaseRefDetails', args), + onChanged: (callback: () => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent) => callback() ipcRenderer.on('repos:changed', listener) diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index ad9ca631f..558e9108e 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -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 diff --git a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx index 5bab5970e..925baa124 100644 --- a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx +++ b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx @@ -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([]) const [gitlabItems, setGitlabItems] = useState([]) - const [branches, setBranches] = useState([]) + const [branches, setBranches] = useState([]) const [linearIssues, setLinearIssues] = useState([]) 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) } diff --git a/src/renderer/src/hooks/composer-branch-selection.test.ts b/src/renderer/src/hooks/composer-branch-selection.test.ts new file mode 100644 index 000000000..e3224ac09 --- /dev/null +++ b/src/renderer/src/hooks/composer-branch-selection.test.ts @@ -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 + }) + }) +}) diff --git a/src/renderer/src/hooks/composer-branch-selection.ts b/src/renderer/src/hooks/composer-branch-selection.ts new file mode 100644 index 000000000..042ab14ba --- /dev/null +++ b/src/renderer/src/hooks/composer-branch-selection.ts @@ -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 + } +} diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index 3b1fc857c..efeadc8c1 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -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( persistDraft ? newWorkspaceDraft?.baseBranch : initialBaseBranch ) + const [branchNameOverride, setBranchNameOverride] = useState(undefined) const [pushTarget, setPushTarget] = useState(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( persistDraft ? (newWorkspaceDraft?.name ?? initialName) : initialName ) + const branchAutoNameRef = useRef('') // 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('') @@ -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, diff --git a/src/renderer/src/runtime/runtime-repo-client.ts b/src/renderer/src/runtime/runtime-repo-client.ts index bdd596c5d..c9d7a1e6d 100644 --- a/src/renderer/src/runtime/runtime-repo-client.ts +++ b/src/renderer/src/runtime/runtime-repo-client.ts @@ -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 | null | undefined, + repoId: string, + query: string, + limit: number +): Promise { + 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) +} diff --git a/src/renderer/src/store/slices/worktree-helpers.ts b/src/renderer/src/store/slices/worktree-helpers.ts index da668954a..b241a3a1d 100644 --- a/src/renderer/src/store/slices/worktree-helpers.ts +++ b/src/renderer/src/store/slices/worktree-helpers.ts @@ -86,6 +86,7 @@ export type WorktreeSlice = { pushTarget?: GitPushTarget, createdWithAgent?: TuiAgent, linkedLinearIssue?: string, + branchNameOverride?: string, workspaceStatus?: WorkspaceStatus ) => Promise removeWorktree: ( diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index 1294c21ee..44b5dfaba 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -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) + + 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' }) diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 24e6ad6b9..657be3bcf 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -423,24 +423,34 @@ export const createWorktreeSlice: StateCreator 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 repo: repoId, name: candidateName, baseBranch, + ...(candidateBranchNameOverride + ? { branchNameOverride: candidateBranchNameOverride } + : {}), setupDecision, sparseCheckout, ...(displayName ? { displayName } : {}), diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index e419d8bfc..2662cb125 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -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['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['worktrees']> { repo: args.repoId, name: args.name, baseBranch: args.baseBranch, + branchNameOverride: args.branchNameOverride, linkedIssue: args.linkedIssue, linkedPR: args.linkedPR, displayName: args.displayName, diff --git a/src/shared/base-ref-search-result.test.ts b/src/shared/base-ref-search-result.test.ts new file mode 100644 index 000000000..53783ec1e --- /dev/null +++ b/src/shared/base-ref-search-result.test.ts @@ -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' + }) + }) +}) diff --git a/src/shared/base-ref-search-result.ts b/src/shared/base-ref-search-result.ts new file mode 100644 index 000000000..895ecf28f --- /dev/null +++ b/src/shared/base-ref-search-result.ts @@ -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) + } +} diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index 9afa5e142..3b26b55c1 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -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 } diff --git a/src/shared/types.ts b/src/shared/types.ts index c537f6480..1a53e9f58 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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