fix: preserve slashes in branch-created worktrees (#2091)
This commit is contained in:
parent
10b63bd1a0
commit
a99929e42e
|
|
@ -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')
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ export type WorktreeSlice = {
|
|||
pushTarget?: GitPushTarget,
|
||||
createdWithAgent?: TuiAgent,
|
||||
linkedLinearIssue?: string,
|
||||
branchNameOverride?: string,
|
||||
workspaceStatus?: WorkspaceStatus
|
||||
) => Promise<CreateWorktreeResult>
|
||||
removeWorktree: (
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue