perf(git): reuse branch snapshots across searches (#41558)

* perf(git): share branch snapshots across repository searches

* fix(git): read branch occupancy and current checkout live

* Preserve branch occupancy and unborn checkout parity

* Keep native searches for clients without qualified refs

* fix(git): preserve compiled branch option caching

* fix(git): derive selected source labels from branch identity

* test(git): resolve selected source fixture import

* fix(git): keep branch default menu translations reactive

* test(git): type mobile branch row mock parameters

* fix(git): keep branch metadata expiry bounded during streaming
This commit is contained in:
Brennan Benson
2026-11-12 15:12:55 -08:00
committed by GitHub
parent 59d99ed139
commit 91b3f12f03
52 changed files with 2492 additions and 365 deletions
@@ -0,0 +1,45 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, expect, it, vi } from 'vitest'
import { SmartWorkspaceSourceRow } from './SmartWorkspaceSourceRow'
vi.mock('react-native', () => ({ Text: 'Text', View: 'View' }))
vi.mock('lucide-react-native', () => ({ GitBranch: 'Icon', Sparkles: 'Icon' }))
vi.mock('../theme/mobile-theme', () => ({ colors: {}, radii: {}, spacing: {} }))
vi.mock('../theme/themed-styles', () => ({ themedStyleSheet: (make: () => unknown) => make() }))
vi.mock('../theme/use-mobile-color-scheme', () => ({ useMobileColorScheme: () => 'dark' }))
vi.mock('../i18n/use-mobile-translation', () => ({
useMobileTranslation: () => (_key: string, fallback: string) => fallback
}))
vi.mock('./TaskProviderLogo', () => ({ TaskProviderLogo: () => null }))
vi.mock('./list/ListGroup', () => ({ ListRow: 'ListRow' }))
let renderer: ReactTestRenderer
afterEach(() => {
act(() => renderer?.unmount())
})
it('renders a natural remote label without a namespace subtitle and keeps collision qualification', async () => {
const onPress = vi.fn()
const row = {
kind: 'branch' as const,
value: 'branch-refs/remotes/origin/topic',
refName: 'refs/remotes/origin/topic',
localBranchName: 'topic'
}
await act(async () => {
renderer = create(createElement(SmartWorkspaceSourceRow, { row, onPress }))
})
expect(renderer.toJSON()).toMatchObject({ props: { title: 'origin/topic', subtitle: 'topic' } })
await act(async () => {
renderer.update(
createElement(SmartWorkspaceSourceRow, {
row: { ...row, label: 'refs/remotes/origin/topic' },
onPress
})
)
})
expect(renderer.toJSON()).toMatchObject({
props: { title: 'refs/remotes/origin/topic', subtitle: 'topic' }
})
})
@@ -1,3 +1,4 @@
import { branchRefLabel } from '../../../src/shared/git/refs/branch-ref-label'
import { Text, View } from 'react-native'
import { GitBranch, Sparkles } from 'lucide-react-native'
import type { SmartWorkspaceSourceRow as SourceRow } from '../../../src/shared/new-workspace/smart-workspace-source-results'
@@ -70,8 +71,9 @@ function resolveRowContent(row: SourceRow, t: Translate): RowContent {
case 'branch':
return {
icon: <GitBranch size={18} color={colors.textSecondary} />,
title: row.localBranchName || row.refName,
subtitle: row.refName
title: row.label ?? branchRefLabel(row.refName),
subtitle:
row.localBranchName !== branchRefLabel(row.refName) ? row.localBranchName : undefined
}
case 'linear':
return {
@@ -1,3 +1,4 @@
import { legacyBaseRefSearchResult } from '../../../../src/shared/base-ref-search-result'
import type { GitHubWorkItem } from '../../../../src/shared/github/work-items/work-item-types'
import type { GitLabWorkItem } from '../../../../src/shared/gitlab-types'
import type { LinearIssue } from '../../../../src/shared/linear/issue-types'
@@ -124,8 +125,5 @@ export async function searchBranches(
{ timeoutMs: 30_000 }
)
const result = repoBaseRefSearchRead.interpret(reply)
return (
result.refDetails ??
(result.refs ?? []).map((refName) => ({ refName, localBranchName: refName }))
)
return result.refDetails ?? (result.refs ?? []).map(legacyBaseRefSearchResult)
}
@@ -1,3 +1,4 @@
import { legacyBaseRefSearchResult } from '../../../src/shared/base-ref-search-result'
import { translate } from '../i18n/mobile-i18n'
import { refusedRpcMessageOrFallback } from '../transport/rpc/rpc-refusal-message'
import type { UnvalidatedRpcRequestPort } from '../transport/rpc/unvalidated-rpc-request-port'
@@ -137,7 +138,7 @@ export async function searchMobilePrBaseRefs(
)
const result = repoBaseRefSearchRead.interpret(reply)
return normalizeCreateReviewBaseSearchResults(
result.refDetails ?? (result.refs ?? []).map((refName) => ({ refName, localBranchName: '' }))
result.refDetails ?? (result.refs ?? []).map(legacyBaseRefSearchResult)
)
}
@@ -1,3 +1,4 @@
import { branchRefLabel } from '../../../../src/shared/git/refs/branch-ref-label'
import type { ConnectionPresentationModel } from '../screen-state/use-mobile-tasks-connection-presentation'
import { BottomDrawer } from '../../components/bottom-drawer/BottomDrawer'
import { View, Text } from 'react-native'
@@ -146,12 +147,14 @@ export function renderMobileTasksWorkspaceCreateDrawer(model: ConnectionPresenta
key="startFrom"
title={translate('tasks.workspaceCreateDrawer.startFrom', 'Start from')}
value={
workspaceBaseBranch?.refName ??
(workspaceBaseBranch ? branchRefLabel(workspaceBaseBranch.refName) : null) ??
translate('tasks.workspaceCreateDrawer.defaultBranch', 'Default branch')
}
navigates
onPress={() => {
setWorkspaceBaseBranchQuery(workspaceBaseBranch?.refName ?? '')
setWorkspaceBaseBranchQuery(
workspaceBaseBranch ? branchRefLabel(workspaceBaseBranch.refName) : ''
)
setShowWorkspaceBaseBranchPicker(true)
}}
/>
@@ -1,3 +1,4 @@
import { branchRefLabels, branchRefLabel } from '../../../../src/shared/git/refs/branch-ref-label'
import type { ConnectionPresentationModel } from '../screen-state/use-mobile-tasks-connection-presentation'
import { BottomDrawer } from '../../components/bottom-drawer/BottomDrawer'
import { View, Text, Pressable, ActivityIndicator } from 'react-native'
@@ -24,6 +25,7 @@ export function renderMobileTasksWorkspaceBaseBranchPicker(model: ConnectionPres
workspaceBaseBranchResults,
workspaceCreateDraft
} = model
const labels = branchRefLabels(workspaceBaseBranchResults.map((branch) => branch.refName))
return (
<BottomDrawer
visible={taskUiReady && workspaceCreateDraft != null && showWorkspaceBaseBranchPicker}
@@ -70,9 +72,9 @@ export function renderMobileTasksWorkspaceBaseBranchPicker(model: ConnectionPres
{workspaceBaseBranchResults.map((branch) => (
<ListRow
key={`${branch.refName}:${branch.localBranchName}`}
title={branch.refName}
title={labels.get(branch.refName) ?? branchRefLabel(branch.refName)}
subtitle={
branch.localBranchName !== branch.refName
branch.localBranchName !== branchRefLabel(branch.refName)
? translate(
'tasks.workspaceOptionPickers.branchName',
'Branch name: {{name}}',
@@ -1,3 +1,4 @@
import { legacyBaseRefSearchResult } from '../../../../src/shared/base-ref-search-result'
import type { WorkspaceCreateProjectionModel } from '../project-board/use-mobile-tasks-workspace-create-projection'
import type { SparsePreset } from '../../../../src/shared/worktree/create-types'
import { useEffect } from 'react'
@@ -146,8 +147,7 @@ export function useMobileTasksWorkspaceSourceEffects<Model extends WorkspaceSour
}
const result = repoBaseRefSearchRead.interpret(reply)
setWorkspaceBaseBranchResults(
result.refDetails ??
(result.refs ?? []).map((refName) => ({ refName, localBranchName: refName }))
result.refDetails ?? (result.refs ?? []).map(legacyBaseRefSearchResult)
)
})
.catch((err) => {
@@ -4,9 +4,22 @@ import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { printResult } from './command-result-output'
import { formatRepoRefs } from '../projects/repo-format'
let testScreenshotDir: string | null = null
it('prints readable branches while distinguishing colliding action identities', () => {
expect(
formatRepoRefs({
refs: ['refs/heads/topic', 'refs/heads/origin/main', 'refs/remotes/origin/main'],
truncated: false
})
).toBe('topic\nrefs/heads/origin/main\nrefs/remotes/origin/main')
expect(
formatRepoRefs({ refs: ['origin/main', 'refs/remotes/origin/main'], truncated: false })
).toBe('origin/main\norigin/main')
})
afterEach(() => {
vi.restoreAllMocks()
delete process.env.ORCA_COMPUTER_SCREENSHOT_TMPDIR
+4 -1
View File
@@ -1,3 +1,4 @@
import { branchRefLabels } from '../../shared/git/refs/branch-ref-label'
import type {
RuntimeRepoList,
RuntimeRepoSearchRefs
@@ -28,5 +29,7 @@ export function formatRepoRefs(result: RuntimeRepoSearchRefs): string {
if (result.refs.length === 0) {
return 'No refs found.'
}
return result.truncated ? `${result.refs.join('\n')}\n\ntruncated: yes` : result.refs.join('\n')
const labels = branchRefLabels(result.refs)
const text = result.refs.map((ref) => labels.get(ref) ?? ref).join('\n')
return result.truncated ? `${text}\n\ntruncated: yes` : text
}
+2
View File
@@ -6,6 +6,7 @@ import type {
import { RemoteRuntimeClientError } from '../../shared/remote-runtime/remote-runtime-client-error'
import { AGENT_LAUNCH_RUNTIME_CAPABILITY } from '../../shared/agent-launch/contracts/agent-launch-runtime-capability'
import { AGENT_SESSION_RENAME_ONLY_CLIENT_CAPABILITY } from '../../shared/agent-session/structured-agent-session-surface-capabilities'
import { REPO_SEARCH_QUALIFIED_REFS_RUNTIME_CAPABILITY } from '../../shared/worktree-runtime-capabilities'
import { sendRemoteRuntimeRequestOnSocket } from '../../shared/remote-runtime/requests/remote-runtime-request-socket'
import type { RuntimeStatus } from '../../shared/runtime-types'
import { RuntimeClientError, type RuntimeRpcResponse } from './types'
@@ -13,6 +14,7 @@ import { RuntimeClientError, type RuntimeRpcResponse } from './types'
// Paired CLI callers are not exempt from the host's agent.launch or chat rename gates like the local socket is.
const CLI_CLIENT_CAPABILITIES = [
AGENT_LAUNCH_RUNTIME_CAPABILITY,
REPO_SEARCH_QUALIFIED_REFS_RUNTIME_CAPABILITY,
AGENT_SESSION_RENAME_ONLY_CLIENT_CAPABILITY
] as const
@@ -30,6 +30,7 @@ import { createAbortError } from './process-capture/abort-error'
import { readCapturedGitBuffer, readCapturedGitString } from './process-capture/captured-git-output'
import { runUnlessGitCwdKnownMissing } from './host-routing/missing-git-spawn-cwd'
import { trackGitWorktreeMutation } from './git-worktree-mutation-state'
import { trackGitRefMutation } from './git-ref-mutation-state'
import { routeLocalGitOptions, runAdmittedWslGit } from './host-routing/local-git-runtime-route'
import { gitRunsIn } from './host-routing/git-runs-in'
@@ -190,6 +191,7 @@ export function gitExecFileAsync(
): Promise<{ stdout: string; stderr: string }> {
const options = routeLocalGitOptions(callerOptions)
const releaseMutation = trackGitWorktreeMutation(args, options)
const releaseRefMutation = trackGitRefMutation(args)
const terminationState: { current: Promise<void> | null } = { current: null }
const command = resolveGitFetchHeadCommand(args, options.cwd)
// Missing folders start no child and must not record a git.exec span.
@@ -209,8 +211,10 @@ export function gitExecFileAsync(
// A timed-out caller can settle before its child exits.
if (terminationState.current) {
void terminationState.current.then(releaseMutation)
void terminationState.current.then(releaseRefMutation)
} else {
releaseMutation()
releaseRefMutation()
}
})
}
@@ -0,0 +1,88 @@
import { afterEach, expect, it, vi } from 'vitest'
import type * as Capture from './process-capture/exec-file-capture'
const io = vi.hoisted(() => ({ capture: vi.fn() }))
vi.mock('./process-capture/exec-file-capture', async (original) => ({
...(await original<typeof Capture>()),
execFileCapture: io.capture
}))
import { trackGitRefMutation } from './git-ref-mutation-state'
import { gitExecFileAsync } from './git-exec-file'
import { subscribeGitRefChanges } from '../refs/git-ref-change-signal'
const disposers: (() => void)[] = []
afterEach(() => {
disposers.splice(0).forEach((dispose) => dispose())
io.capture.mockReset()
})
it('preserves warm refs for reads and proven object/index operations, and fences all for ref writes', () => {
const events: string[] = []
disposers.push(subscribeGitRefChanges('/a/.git', () => events.push('a')))
disposers.push(subscribeGitRefChanges('/b/.git', () => events.push('b')))
for (const args of [
['for-each-ref'],
['remote'],
['config', '--get', 'remote.origin.url'],
['worktree', 'list', '--porcelain'],
['ls-remote'],
['add', '.'],
['update-index', '--refresh'],
['hash-object', '-w', 'file'],
['restore', '--staged', '.'],
['apply', '--cached'],
['merge-tree', '--write-tree']
]) {
trackGitRefMutation(args)()
}
expect(events).toEqual([])
for (const args of [
['commit'],
['push'],
['pull'],
['fetch'],
['branch', 'topic'],
['config', 'remote.origin.url', 'url'],
['worktree', 'add', 'path'],
['custom-alias']
]) {
const end = trackGitRefMutation(args)
expect(events.splice(0)).toEqual(['a', 'b'])
end()
end()
expect(events.splice(0)).toEqual(['a', 'b'])
}
})
it('keeps the runner completion fence at child termination after a timed-out caller settles', async () => {
const events: string[] = []
disposers.push(subscribeGitRefChanges('/runner-a/.git', () => events.push('a')))
disposers.push(subscribeGitRefChanges('/runner-b/.git', () => events.push('b')))
let started!: () => void
const ready = new Promise<void>((resolve) => {
started = resolve
})
let terminated!: () => void
let rejectCapture!: (error: Error) => void
io.capture.mockImplementationOnce((_command, _args, options) => {
terminated = options.onChildTerminated
started()
return new Promise((_resolve, reject) => {
rejectCapture = reject
})
})
const result = gitExecFileAsync(['branch', 'topic'], {
cwd: process.cwd(),
admissionExempt: true
})
expect(events.splice(0)).toEqual(['a', 'b'])
await ready
const failure = expect(result).rejects.toThrow('deadline')
rejectCapture(new Error('deadline'))
await failure
expect(events).toEqual([])
disposers.push(subscribeGitRefChanges('/runner-new/.git', () => events.push('new')))
terminated()
await new Promise<void>(setImmediate)
expect(events).toEqual(['a', 'b', 'new'])
})
@@ -0,0 +1,23 @@
import {
classifyGitCommand,
findGitSubcommandIndex
} from '../../../shared/git/commands/git-command-classification'
import { beginGitRefMutation } from '../refs/git-ref-change-signal'
export function trackGitRefMutation(args: readonly string[]): () => void {
if (classifyGitCommand(args) === 'read') {
return () => {}
}
const index = findGitSubcommandIndex(args)
const command = args[index]
const flags = args.slice(index + 1)
if (
['ls-remote', 'add', 'update-index', 'hash-object'].includes(command) ||
(command === 'restore' && flags.includes('--staged')) ||
(command === 'apply' && flags.includes('--cached')) ||
(command === 'merge-tree' && flags.includes('--write-tree'))
) {
return () => {}
}
return beginGitRefMutation(null)
}
@@ -41,6 +41,8 @@ export type GitStreamOptions = {
signal?: AbortSignal
/** Byte backstop; defaults to DEFAULT_GIT_MAX_BUFFER. */
maxBuffer?: number
/** Independent stderr cap for parsers that stream an arbitrarily large population. */
maxStderrBuffer?: number
/** Explicit wall-clock deadline; read commands default to the production backstop. */
timeoutMs?: number
/** Overrides only the default read deadline in tests. */
@@ -212,7 +214,7 @@ function streamInSpan(
}
function onStderrData(chunk: Buffer): void {
stderrBytes += chunk.byteLength
if (stderrBytes > maxBuffer) {
if (stderrBytes > (options.maxStderrBuffer ?? maxBuffer)) {
void killSpawnedCommandTree(child)
finish(new Error('git stderr exceeded maxBuffer.'))
return
@@ -0,0 +1,44 @@
import { gitRepositoryPathKey } from '../../../shared/git/metadata/git-repository-path-key'
type Listener = () => void
const listeners = new Map<string, Set<Listener>>()
export function subscribeGitRefChanges(commonDir: string, listener: Listener): () => void {
commonDir = gitRepositoryPathKey(commonDir)
const subscribers = listeners.get(commonDir) ?? new Set<Listener>()
subscribers.add(listener)
listeners.set(commonDir, subscribers)
return () => {
subscribers.delete(listener)
if (subscribers.size === 0 && listeners.get(commonDir) === subscribers) {
listeners.delete(commonDir)
}
}
}
/** Unknown checkout ownership fences every live reader without delaying the mutation for discovery. */
export function publishGitRefChange(commonDir: string | null): void {
commonDir = commonDir === null ? null : gitRepositoryPathKey(commonDir)
const targets = commonDir === null ? [...listeners.values()] : [listeners.get(commonDir)]
for (const subscribers of targets) {
const callbacks = Array.from(subscribers ?? [])
for (const listener of callbacks) {
try {
listener()
} catch {
// Cache bookkeeping must never refuse a Git action.
}
}
}
}
export function beginGitRefMutation(commonDir: string | null): () => void {
publishGitRefChange(commonDir)
let ended = false
return () => {
if (ended) {
return
}
ended = true
publishGitRefChange(commonDir)
}
}
@@ -1,5 +1,4 @@
import { describe, expect, it } from 'vitest'
import { parseAndFilterSearchRefDetails } from './repo-base-ref-search'
import { resolveBaseRefSearchSelector } from './base-ref-search-selector'
describe('base ref search selectors', () => {
@@ -39,24 +38,4 @@ describe('base ref search selectors', () => {
])('preserves the identity of %s with short field %s', (full, short, expected) => {
expect(resolveBaseRefSearchSelector(full, short)).toBe(expected)
})
it('filters unsupported selectors before the page limit and deduplicates recovered full refs', () => {
const stdout = [
'refs/heads/feature/加\0feature/�',
'refs/heads/feature/加\0feature/�',
'refs/remotes/origin/feature/加\0origin/feature/�',
'refs/heads/safe-one\0safe-one',
'refs/heads/safe-two\0safe-two',
'refs/heads/safe-three\0safe-three'
].join('\n')
expect(parseAndFilterSearchRefDetails(stdout, 2, ['origin'], false)).toEqual([
{ refName: 'safe-one', localBranchName: 'safe-one' },
{ refName: 'safe-two', localBranchName: 'safe-two' }
])
expect(parseAndFilterSearchRefDetails(stdout, 3, ['origin'])).toEqual([
{ refName: 'refs/heads/feature/加', localBranchName: 'feature/加' },
{ refName: 'refs/remotes/origin/feature/加', localBranchName: 'feature/加' },
{ refName: 'safe-one', localBranchName: 'safe-one' }
])
})
})
@@ -5,41 +5,9 @@ import {
REPO_SEARCH_REFS_DEFAULT_LIMIT,
isRepoSearchRefsRequestLimit
} from '../../../../shared/repo-search-limits'
import { isSafeGitRefName } from '../../../../shared/git/status/git-status-upstream-ref'
import { isRemoteHeadRef } from '../../../../shared/source-control/hosted-review/hosted-review-refs'
import { runSearchBaseRefsGit } from './repo-ref-search-command'
import { gitExecOptions, type LocalGitExecOptions } from './repo-default-base-ref'
import { gitExecFileAsync } from '../../command-runner/git-exec-file'
import { isQualifiedBaseRef, resolveBaseRefSearchSelector } from './base-ref-search-selector'
type RefSearchPatternGroup = 'all' | 'segmented' | 'branchRoot'
function getRefSearchTokens(query: string) {
return query.split('/').filter(Boolean)
}
export function mergeBaseRefSearchResultGroups(
groups: readonly BaseRefSearchResult[][],
limit: number
): BaseRefSearchResult[] {
const seen = new Set<string>()
const merged: BaseRefSearchResult[] = []
const maxLength = Math.max(0, ...groups.map((group) => group.length))
for (let index = 0; index < maxLength && merged.length < limit; index += 1) {
for (const group of groups) {
const entry = group[index]
if (!entry || seen.has(entry.refName)) {
continue
}
seen.add(entry.refName)
merged.push(entry)
if (merged.length >= limit) {
break
}
}
}
return merged
}
import { searchBranchRefSnapshot, snapshotRemoteNames } from '../snapshot/branch-ref-snapshot'
import { searchLegacyBaseRefDetails } from './repo-legacy-ref-search'
import type { LocalGitExecOptions } from './repo-default-base-ref'
export async function searchBaseRefs(
path: string,
@@ -63,42 +31,13 @@ export async function searchBaseRefDetails(
if (!isRepoSearchRefsRequestLimit(limit)) {
return []
}
const boundedScanLimit = clampRepoSearchRefsScanLimit(limit)
const normalizedQuery = normalizeRefSearchQuery(query)
if (!includeQualifiedRefs) {
return searchLegacyBaseRefDetails(path, query, limit, git)
}
try {
const remotes = await listRemoteNames(path, git)
const search = (patternGroup?: RefSearchPatternGroup): Promise<{ stdout: string }> =>
runSearchBaseRefsGit(path, normalizedQuery, boundedScanLimit, {
remoteNames: remotes,
patternGroup,
git
})
const tokens = getRefSearchTokens(normalizedQuery)
if (tokens.length > 1) {
const results = await Promise.all([search('segmented'), search('branchRoot')])
return mergeBaseRefSearchResultGroups(
results.map((entry) =>
parseAndFilterSearchRefDetails(
entry.stdout,
boundedScanLimit,
remotes,
includeQualifiedRefs
)
),
boundedScanLimit
)
}
const result = await search()
return parseAndFilterSearchRefDetails(
result.stdout,
boundedScanLimit,
remotes,
includeQualifiedRefs
)
return await searchBranchRefSnapshot(path, query, clampRepoSearchRefsScanLimit(limit), git)
} catch (err) {
console.warn('[searchBaseRefs] for-each-ref failed', { path, err })
console.warn('[searchBaseRefs] branch snapshot failed', { path, err })
return []
}
}
@@ -108,91 +47,8 @@ export async function listRemoteNames(
options: LocalGitExecOptions = {}
): Promise<string[]> {
try {
const { stdout } = await gitExecFileAsync(['remote'], gitExecOptions(path, options))
return stdout
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
return await snapshotRemoteNames(path, options)
} catch {
return []
}
}
export function parseAndFilterSearchRefDetails(
stdout: string,
limit: number,
remotes: string[] = [],
includeQualifiedRefs = true
): BaseRefSearchResult[] {
const seen = new Set<string>()
const sortedRemotes = [...remotes].sort((a, b) => b.length - a.length)
return stdout
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0)
.map((line) => {
const nul = line.indexOf('\0')
if (nul === -1) {
return null
}
const full = line.slice(0, nul)
const gitShort = line.slice(nul + 1)
return { full, short: resolveBaseRefSearchSelector(full, gitShort) }
})
.filter((entry): entry is { full: string; short: string } => entry !== null)
.filter(({ full }) => isSafeGitRefName(full) && !isRemoteHeadRef(full, sortedRemotes))
.filter(({ short }) => includeQualifiedRefs || !isQualifiedBaseRef(short))
.filter(({ short }) => {
if (seen.has(short)) {
return false
}
seen.add(short)
return true
})
.map(({ full, short }) => ({
refName: short,
localBranchName: resolveLocalBranchName(full, short, sortedRemotes)
}))
.slice(0, Math.max(0, limit))
}
export function resolveConfiguredRemoteBranchName(
fullRef: string,
longestFirstRemoteNames: readonly string[]
): string | null {
const remoteRefPrefix = 'refs/remotes/'
if (!fullRef.startsWith(remoteRefPrefix)) {
return null
}
const remoteAndBranch = fullRef.slice(remoteRefPrefix.length)
const remote = longestFirstRemoteNames.find((candidate) =>
remoteAndBranch.startsWith(`${candidate}/`)
)
return remote ? remoteAndBranch.slice(remote.length + 1) || null : null
}
export function resolveLocalBranchName(
fullRef: string,
shortRef: string,
remotes: string[]
): string {
const localRefPrefix = 'refs/heads/'
if (fullRef.startsWith(localRefPrefix)) {
return fullRef.slice(localRefPrefix.length) || shortRef
}
const remoteRefPrefix = 'refs/remotes/'
if (!fullRef.startsWith(remoteRefPrefix)) {
return shortRef
}
const configuredBranch = resolveConfiguredRemoteBranchName(fullRef, remotes)
if (configuredBranch) {
return configuredBranch
}
const remoteAndBranch = fullRef.slice(remoteRefPrefix.length)
return remoteAndBranch.split('/').slice(1).join('/') || shortRef
}
export function normalizeRefSearchQuery(query: string): string {
return query.trim().replace(/[*?[\]\\]/g, '')
}
@@ -1,4 +1,4 @@
import { resolveConfiguredRemoteBranchName } from './repo-base-ref-search'
import { resolveConfiguredRemoteBranchName } from '../snapshot/branch-ref-snapshot-data'
import { gitExecOptions, type LocalGitExecOptions } from './repo-default-base-ref'
import { gitExecFileAsync } from '../../command-runner/git-exec-file'
import { isSafeGitRefName } from '../../../../shared/git/status/git-status-upstream-ref'
@@ -0,0 +1,170 @@
import { resolveLocalBranchName } from '../snapshot/branch-ref-snapshot-data'
import type { BaseRefSearchResult } from '../../../../shared/repo-types'
import {
clampRepoSearchRefsScanLimit,
REPO_SEARCH_REFS_DEFAULT_LIMIT,
isRepoSearchRefsRequestLimit
} from '../../../../shared/repo-search-limits'
import { isSafeGitRefName } from '../../../../shared/git/status/git-status-upstream-ref'
import { isRemoteHeadRef } from '../../../../shared/source-control/hosted-review/hosted-review-refs'
import { runSearchBaseRefsGit } from './repo-ref-search-command'
import { gitExecOptions, type LocalGitExecOptions } from './repo-default-base-ref'
import { gitExecFileAsync } from '../../command-runner/git-exec-file'
import { isQualifiedBaseRef, resolveBaseRefSearchSelector } from './base-ref-search-selector'
type RefSearchPatternGroup = 'all' | 'segmented' | 'branchRoot'
function getRefSearchTokens(query: string) {
return query.split('/').filter(Boolean)
}
export function mergeBaseRefSearchResultGroups(
groups: readonly BaseRefSearchResult[][],
limit: number
): BaseRefSearchResult[] {
const seen = new Set<string>()
const merged: BaseRefSearchResult[] = []
const maxLength = Math.max(0, ...groups.map((group) => group.length))
for (let index = 0; index < maxLength && merged.length < limit; index += 1) {
for (const group of groups) {
const entry = group[index]
if (!entry || seen.has(entry.refName)) {
continue
}
seen.add(entry.refName)
merged.push(entry)
if (merged.length >= limit) {
break
}
}
}
return merged
}
export async function searchLegacyBaseRefDetails(
path: string,
query: string,
limit = REPO_SEARCH_REFS_DEFAULT_LIMIT,
git: LocalGitExecOptions = {}
): Promise<BaseRefSearchResult[]> {
if (!isRepoSearchRefsRequestLimit(limit)) {
return []
}
const boundedScanLimit = clampRepoSearchRefsScanLimit(limit)
const normalizedQuery = normalizeRefSearchQuery(query)
try {
const remotes = await listRemoteNames(path, git)
const search = (patternGroup?: RefSearchPatternGroup): Promise<{ stdout: string }> =>
runSearchBaseRefsGit(path, normalizedQuery, boundedScanLimit, {
remoteNames: remotes,
patternGroup,
git
})
const tokens = getRefSearchTokens(normalizedQuery)
if (tokens.length > 1) {
const results = await Promise.all([search('segmented'), search('branchRoot')])
return mergeBaseRefSearchResultGroups(
results.map((entry) =>
parseAndFilterSearchRefDetails(entry.stdout, boundedScanLimit, remotes, false)
),
boundedScanLimit
)
}
const result = await search()
return parseAndFilterSearchRefDetails(result.stdout, boundedScanLimit, remotes, false)
} catch (err) {
console.warn('[searchBaseRefs] for-each-ref failed', { path, err })
return []
}
}
export async function listRemoteNames(
path: string,
options: LocalGitExecOptions = {}
): Promise<string[]> {
try {
const { stdout } = await gitExecFileAsync(['remote'], gitExecOptions(path, options))
return stdout
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
} catch {
return []
}
}
export function parseAndFilterSearchRefDetails(
stdout: string,
limit: number,
remotes: string[] = [],
includeQualifiedRefs = true
): BaseRefSearchResult[] {
const seen = new Set<string>()
const sortedRemotes = [...remotes].sort((a, b) => b.length - a.length)
return stdout
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0)
.map((line) => {
const nul = line.indexOf('\0')
if (nul === -1) {
return null
}
const full = line.slice(0, nul)
const gitShort = line.slice(nul + 1)
return { full, short: resolveBaseRefSearchSelector(full, gitShort) }
})
.filter((entry): entry is { full: string; short: string } => entry !== null)
.filter(({ full }) => isSafeGitRefName(full) && !isRemoteHeadRef(full, sortedRemotes))
.filter(({ short }) => includeQualifiedRefs || !isQualifiedBaseRef(short))
.filter(({ short }) => {
if (seen.has(short)) {
return false
}
seen.add(short)
return true
})
.map(({ full, short }) => ({
refName: short,
localBranchName: resolveLocalBranchName(full, short, sortedRemotes)
}))
.slice(0, Math.max(0, limit))
}
export function normalizeRefSearchQuery(query: string): string {
return query.trim().replace(/[*?[\]\\]/g, '')
}
export async function searchLegacyRemoteRefPage(
path: string,
query: string,
limit: number
): Promise<{ refDetails: BaseRefSearchResult[]; truncated: boolean }> {
const { stdout } = await gitExecFileAsync(['remote'], { cwd: path })
const remoteNames = stdout.split('\n').filter(Boolean)
const normalized = normalizeRefSearchQuery(query)
const groups: ('segmented' | 'branchRoot' | 'all')[] =
normalized.split('/').filter(Boolean).length > 1 ? ['segmented', 'branchRoot'] : ['all']
const results = await Promise.all(
groups.map((patternGroup) =>
runSearchBaseRefsGit(path, normalized, limit + 1, {
namespace: 'remote',
remoteNames,
patternGroup
})
)
)
const candidates = mergeBaseRefSearchResultGroups(
results.map((result) => parseAndFilterSearchRefDetails(result.stdout, limit + 1, remoteNames)),
limit + 1
)
return {
refDetails: candidates.slice(0, limit),
truncated:
candidates.length > limit ||
results.some((result) => result.stdout.split('\n').filter(Boolean).length >= (limit + 1) * 4)
}
}
@@ -1,39 +1,30 @@
import { searchLegacyRemoteRefPage } from './repo-legacy-ref-search'
import type { BaseRefSearchResult } from '../../../../shared/repo-types'
import { gitExecFileAsync } from '../../command-runner/git-exec-file'
import { runSearchBaseRefsGit } from './repo-ref-search-command'
import {
normalizeRefSearchQuery,
parseAndFilterSearchRefDetails,
mergeBaseRefSearchResultGroups
} from './repo-base-ref-search'
clampRepoSearchRefsLimit,
getRepoSearchRefsProbeLimit
} from '../../../../shared/repo-search-limits'
import { searchBranchRefSnapshot } from '../snapshot/branch-ref-snapshot'
export async function searchRemoteRefPage(
path: string,
query: string,
limit: number
limit: number,
includeQualifiedRefs = true
): Promise<{ refDetails: BaseRefSearchResult[]; truncated: boolean }> {
const { stdout } = await gitExecFileAsync(['remote'], { cwd: path })
const remoteNames = stdout.split('\n').filter(Boolean)
const normalized = normalizeRefSearchQuery(query)
const groups: ('segmented' | 'branchRoot' | 'all')[] =
normalized.split('/').filter(Boolean).length > 1 ? ['segmented', 'branchRoot'] : ['all']
const results = await Promise.all(
groups.map((patternGroup) =>
runSearchBaseRefsGit(path, normalized, limit + 1, {
namespace: 'remote',
remoteNames,
patternGroup
})
)
)
const candidates = mergeBaseRefSearchResultGroups(
results.map((result) => parseAndFilterSearchRefDetails(result.stdout, limit + 1, remoteNames)),
limit + 1
if (!includeQualifiedRefs) {
return searchLegacyRemoteRefPage(path, query, limit)
}
const bounded = clampRepoSearchRefsLimit(limit)
const candidates = await searchBranchRefSnapshot(
path,
query,
getRepoSearchRefsProbeLimit(bounded),
{},
true
)
return {
refDetails: candidates.slice(0, limit),
truncated:
candidates.length > limit ||
results.some((result) => result.stdout.split('\n').filter(Boolean).length >= (limit + 1) * 4)
refDetails: candidates.slice(0, bounded),
truncated: limit > bounded || candidates.length > bounded
}
}
@@ -0,0 +1,117 @@
import type { BaseRefSearchResult } from '../../../../shared/repo-types'
import { branchRefLabel } from '../../../../shared/git/refs/branch-ref-label'
import { isSafeGitRefName } from '../../../../shared/git/status/git-status-upstream-ref'
import { isRemoteHeadRef } from '../../../../shared/source-control/hosted-review/hosted-review-refs'
export type BranchRef = {
refName: string
oid: string
committedAt: number
symref: string
label: string
searchName: string
}
export const BRANCH_REF_SNAPSHOT_ARGS = [
'for-each-ref',
'--sort=refname',
'--sort=-committerdate',
'--format=%(refname)%00%(objectname)%00%(committerdate:unix)%00%(symref)',
'refs/heads',
'refs/remotes'
]
export function parseBranchRef(line: string): BranchRef | null {
const [refName, oid, timestamp, symref = ''] = line.replace(/\r$/, '').split('\0')
if (!refName || !oid || !isSafeGitRefName(refName) || !/^refs\/(heads|remotes)\//.test(refName)) {
return null
}
const label = branchRefLabel(refName)
return {
refName,
oid,
committedAt: Number.isFinite(Number(timestamp)) ? Number(timestamp) * 1000 : 0,
symref,
label,
searchName: label.toLowerCase()
}
}
export function resolveConfiguredRemoteBranchName(
fullRef: string,
longestFirstRemoteNames: readonly string[]
): string | null {
if (!fullRef.startsWith('refs/remotes/')) {
return null
}
const remoteAndBranch = fullRef.slice('refs/remotes/'.length)
const remote = longestFirstRemoteNames.find((name) => remoteAndBranch.startsWith(`${name}/`))
return remote ? remoteAndBranch.slice(remote.length + 1) || null : null
}
export function resolveLocalBranchName(
fullRef: string,
fallback: string,
remotes: string[]
): string {
if (fullRef.startsWith('refs/heads/')) {
return fullRef.slice('refs/heads/'.length) || fallback
}
if (!fullRef.startsWith('refs/remotes/')) {
return fallback
}
return (
(resolveConfiguredRemoteBranchName(fullRef, remotes) ??
fullRef.slice('refs/remotes/'.length).split('/').slice(1).join('/')) ||
fallback
)
}
export function matchesBranchRef(ref: BranchRef, query: string, remoteOnly: boolean): boolean {
return (!remoteOnly || ref.refName.startsWith('refs/remotes/')) && ref.searchName.includes(query)
}
export function projectBranchRefs(
refs: Iterable<BranchRef>,
remotes: string[],
query: string,
limit: number,
remoteOnly = false
): BaseRefSearchResult[] {
const results: BaseRefSearchResult[] = []
const project = createBranchRefProjection(remotes, query, remoteOnly)
for (const ref of refs) {
const result = project(ref)
if (result) {
results.push(result)
}
if (results.length >= limit) {
break
}
}
return results
}
export function createBranchRefProjection(
remotes: string[],
query: string,
remoteOnly = false
): (ref: BranchRef) => BaseRefSearchResult | null {
const normalized = query
.trim()
.replace(/[*?[\]\\]/g, '')
.toLowerCase()
const longestFirst = [...remotes].sort((left, right) => right.length - left.length)
return (ref) => {
if (
!matchesBranchRef(ref, normalized, remoteOnly) ||
isRemoteHeadRef(ref.refName, longestFirst)
) {
return null
}
return {
refName: ref.refName,
localBranchName: resolveLocalBranchName(ref.refName, ref.label, longestFirst)
}
}
}
@@ -0,0 +1,61 @@
import { realpath, stat } from 'node:fs/promises'
import { dirname } from 'node:path'
import { resolveGitCommonDirectory } from '../../../../shared/git/metadata/git-common-directory'
import { resolveWorktreeHostPath } from '../../../../shared/git/metadata/git-metadata-path'
import { gitRepositoryPathKey } from '../../../../shared/git/metadata/git-repository-path-key'
import { wslDistroForCommand } from '../../../../shared/wsl-paths'
import { routeLocalGitOptions } from '../../command-runner/host-routing/local-git-runtime-route'
import { prepareWslLinkedWorktreeGitRouting } from '../../command-runner/host-routing/wsl-linked-worktree-git-routing'
import { blockedWslDistroForPath } from '../../../wsl/filesystem/wsl-unc-access-gate'
import type { LocalGitExecOptions } from '../resolution/repo-default-base-ref'
import { resolveGitDir } from '../../source-control/resolve-git-dir'
export type BranchSnapshotIdentity = {
key: string
commonDir: string
gitDir: string
checkoutKey: string
cwd: string
git: LocalGitExecOptions
}
export async function resolveBranchSnapshotIdentity(
cwd: string,
git: LocalGitExecOptions
): Promise<BranchSnapshotIdentity | null> {
const routed = routeLocalGitOptions({ cwd, ...git })
if (await blockedWslDistroForPath(cwd, routed.wslDistro)) {
throw new Error('The project Git runtime is not running')
}
const distro = (await prepareWslLinkedWorktreeGitRouting(cwd, routed.wslDistro))
? null
: wslDistroForCommand(cwd, routed.wslDistro)
const runtime = distro ? `wsl:${distro.toLowerCase()}` : 'host'
let hostRoot = await realpath(resolveWorktreeHostPath(cwd, routed) ?? cwd)
let commonDir = await resolveGitCommonDirectory(hostRoot, routed)
while (!commonDir) {
const parent = dirname(hostRoot)
if (parent === hostRoot) {
return null
}
hostRoot = parent
commonDir = await resolveGitCommonDirectory(hostRoot, routed)
}
const physicalCommonDir = await realpath(commonDir)
const commonStat = await stat(physicalCommonDir, { bigint: true })
const checkoutGitDir = await resolveGitDir(hostRoot, routed)
const gitDir = await stat(checkoutGitDir).then(
(entry) => (entry.isDirectory() ? checkoutGitDir : commonDir),
() => commonDir
)
const physicalGitDir = await realpath(gitDir)
const checkoutStat = await stat(physicalGitDir, { bigint: true })
return {
key: `${runtime}\0${gitRepositoryPathKey(physicalCommonDir)}\0${commonStat.dev}:${commonStat.ino}:${commonStat.birthtimeNs}`,
commonDir: physicalCommonDir,
gitDir: physicalGitDir,
checkoutKey: `${gitRepositoryPathKey(physicalGitDir)}\0${checkoutStat.dev}:${checkoutStat.ino}:${checkoutStat.birthtimeNs}`,
cwd,
git: routed
}
}
@@ -0,0 +1,52 @@
import type { BranchSnapshotIdentity } from './branch-ref-snapshot-identity'
import type { BranchSnapshotOwners, Owner, Context } from './branch-ref-snapshot-owners'
import { readBranchRemoteNames } from './branch-ref-snapshot-read'
const MAX_CONTEXTS = 64
export class BranchSnapshotMetadata {
constructor(private readonly cache: BranchSnapshotOwners) {}
context(owner: Owner, identity: BranchSnapshotIdentity): Context {
const key = identity.checkoutKey
const existing = owner.contexts.get(key)
if (existing) {
return existing
}
if (owner.contexts.size >= MAX_CONTEXTS) {
const oldest = owner.contexts.keys().next().value
if (oldest !== undefined) {
this.cache.retireContext(owner, oldest, owner.contexts.get(oldest)!)
}
}
const context: Context = {
leases: new Set(),
remotes: Promise.resolve([])
}
const current = () => !owner.disposed && owner.contexts.get(key) === context
const lease = this.cache.lease(owner, current)
owner.contexts.set(key, context)
context.remotes = readBranchRemoteNames(identity.cwd, identity.git)
.then(async (remotes) => {
await this.cache.validate(identity, owner)
if (lease.reserve(256 + remotes.reduce((sum, name) => sum + 48 + name.length * 2, 0))) {
context.leases.add(lease)
} else {
this.cache.retireContext(owner, key, context)
}
return remotes
})
.catch((error: unknown) => {
this.cache.retireContext(owner, key, context)
throw error
})
return context
}
async remoteNames(identity: BranchSnapshotIdentity): Promise<string[]> {
const owner = this.cache.owner(identity)
return owner
? this.context(owner, identity).remotes
: readBranchRemoteNames(identity.cwd, identity.git)
}
}
@@ -0,0 +1,174 @@
import type { BaseRefSearchResult } from '../../../../shared/repo-types'
import {
resolveBranchSnapshotIdentity,
type BranchSnapshotIdentity
} from './branch-ref-snapshot-identity'
import type { BranchRef } from './branch-ref-snapshot-data'
import {
BRANCH_SNAPSHOT_MAX_BYTES,
type BranchRefSnapshot,
type BranchSnapshotRetention
} from './branch-ref-snapshot-read'
export const BRANCH_SNAPSHOT_TTL_MS = 2 * 60_000
const MAX_OWNERS = 64
export class BranchSnapshotIdentityChanged extends Error {}
export type Lease = BranchSnapshotRetention & { bytes: number }
export type Context = {
remotes: Promise<string[]>
leases: Set<Lease>
}
export type Capture = {
snapshot: BranchRefSnapshot | null
request: string
page: BaseRefSearchResult[]
locals: BranchRef[] | null
}
export type Owner = {
key: string
generation: number
expiresAt: number
disposed: boolean
unsubscribe: () => void
snapshot: BranchRefSnapshot | null
snapshotLease?: Lease
oversized: boolean
localRefs?: readonly BranchRef[]
localLease?: Lease
flight?: { generation: number; promise: Promise<Capture> }
contexts: Map<string, Context>
base?: Lease
}
export type BranchSnapshotSignals = {
subscribe(commonDir: string, listener: () => void): () => void
}
export class BranchSnapshotOwners {
private readonly owners = new Map<string, Owner>()
private allocatedBytes = 0
constructor(private readonly signals: BranchSnapshotSignals) {}
lease(owner: Owner, valid = () => true): Lease {
const lease: Lease = {
bytes: 0,
reserve: (bytes) => {
if (owner.disposed || !valid()) {
return false
}
for (const candidate of this.owners.values()) {
if (this.allocatedBytes + bytes <= BRANCH_SNAPSHOT_MAX_BYTES) {
break
}
if (candidate !== owner && !candidate.flight) {
this.retire(candidate)
}
}
if (this.allocatedBytes + bytes > BRANCH_SNAPSHOT_MAX_BYTES) {
return false
}
this.allocatedBytes += bytes
lease.bytes += bytes
return true
},
release: () => {
this.allocatedBytes -= lease.bytes
lease.bytes = 0
}
}
return lease
}
retireContext(owner: Owner, key: string, context: Context): void {
if (owner.contexts.get(key) === context) {
owner.contexts.delete(key)
}
for (const lease of context.leases) {
lease.release()
}
context.leases.clear()
}
private invalidate(owner: Owner): void {
owner.generation++
owner.expiresAt = Date.now() + BRANCH_SNAPSHOT_TTL_MS
owner.oversized = false
owner.snapshot = null
owner.localRefs = undefined
owner.snapshotLease?.release()
owner.localLease?.release()
for (const [key, context] of owner.contexts) {
this.retireContext(owner, key, context)
}
owner.contexts.clear()
}
retire(owner: Owner): void {
if (owner.disposed) {
return
}
owner.disposed = true
if (this.owners.get(owner.key) === owner) {
this.owners.delete(owner.key)
}
owner.unsubscribe()
this.invalidate(owner)
owner.base?.release()
}
owner(identity: BranchSnapshotIdentity): Owner | null {
const now = Date.now()
for (const candidate of this.owners.values()) {
if (!candidate.flight && candidate.expiresAt <= now) {
this.retire(candidate)
}
}
let owner = this.owners.get(identity.key)
if (owner) {
this.owners.delete(identity.key)
this.owners.set(identity.key, owner)
return owner
}
if (this.owners.size >= MAX_OWNERS) {
const oldest = [...this.owners.values()].find((entry) => !entry.flight)
if (!oldest) {
return null
}
this.retire(oldest)
}
owner = {
key: identity.key,
generation: 0,
expiresAt: now + BRANCH_SNAPSHOT_TTL_MS,
disposed: false,
unsubscribe: () => {},
snapshot: null,
oversized: false,
contexts: new Map()
}
owner.base = this.lease(owner)
if (!owner.base.reserve(1024 + 2 * (identity.key.length + identity.commonDir.length))) {
return null
}
this.owners.set(identity.key, owner)
const created = owner
created.unsubscribe = this.signals.subscribe(identity.commonDir, () => this.invalidate(created))
return created
}
async validate(identity: BranchSnapshotIdentity, owner?: Owner): Promise<void> {
const current = await resolveBranchSnapshotIdentity(identity.cwd, identity.git)
if (!current || current.key !== identity.key || current.checkoutKey !== identity.checkoutKey) {
if (owner) {
this.retire(owner)
}
throw new BranchSnapshotIdentityChanged()
}
}
reset(): void {
for (const owner of this.owners.values()) {
this.retire(owner)
}
}
}
@@ -0,0 +1,39 @@
import { afterEach, expect, it, vi } from 'vitest'
const stream = vi.hoisted(() => vi.fn())
vi.mock('../../command-runner/git-stream-stdout', () => ({ gitStreamStdout: stream }))
import { readBranchRefSnapshot } from './branch-ref-snapshot-read'
afterEach(() => stream.mockReset())
it('keeps raw output complete under aggregate pressure and distinguishes an intrinsic bound', async () => {
stream.mockImplementation(async (_args, options) => {
for (const name of ['first', 'second']) {
if (options.onStdout(`refs/heads/${name}\0${'a'.repeat(40)}\u00001\0\n`) === true) {
return { stoppedEarly: true }
}
}
return { stoppedEarly: false }
})
const page: string[] = []
const pressured = await readBranchRefSnapshot(
'/repo',
{},
(ref) => {
page.push(ref.refName)
return page.length === 2
},
undefined,
{ reserve: () => false, release: () => {} }
)
expect(page).toEqual(['refs/heads/first', 'refs/heads/second'])
expect(pressured).toEqual({ snapshot: null, oversized: false })
expect(await readBranchRefSnapshot('/repo', {}, () => true, 100)).toEqual({
snapshot: null,
oversized: true
})
const recovered = await readBranchRefSnapshot('/repo', {}, () => true)
expect(recovered.snapshot?.refs.map((ref) => ref.refName)).toEqual([
'refs/heads/first',
'refs/heads/second'
])
})
@@ -0,0 +1,111 @@
import { gitExecFileAsync } from '../../command-runner/git-exec-file'
import { gitStreamStdout } from '../../command-runner/git-stream-stdout'
import type { LocalGitExecOptions } from '../resolution/repo-default-base-ref'
import { gitOptionsForWorktree as gitExecOptions } from '../../command-runner/git-runtime-options'
import {
BRANCH_REF_SNAPSHOT_ARGS,
parseBranchRef,
type BranchRef
} from './branch-ref-snapshot-data'
export const BRANCH_SNAPSHOT_MAX_BYTES = 64 * 1024 * 1024
export type BranchRefSnapshot = {
refs: readonly BranchRef[]
}
export type BranchSnapshotRetention = {
reserve(bytes: number): boolean
release(): void
}
export async function readBranchRefSnapshot(
path: string,
git: LocalGitExecOptions,
onRef: (ref: BranchRef) => boolean | void,
maxRetainedBytes = BRANCH_SNAPSHOT_MAX_BYTES,
retention?: BranchSnapshotRetention
): Promise<{ snapshot: BranchRefSnapshot | null; oversized: boolean }> {
let refs: BranchRef[] | null = []
let estimatedBytes = 256
let oversized = false
if (retention && !retention.reserve(estimatedBytes)) {
refs = null
}
await readRefLines(BRANCH_REF_SNAPSHOT_ARGS, path, git, (line) => {
const ref = parseBranchRef(line)
if (!ref) {
return
}
const pageComplete = onRef(ref)
if (refs) {
const bytes =
192 +
2 *
(ref.refName.length +
ref.oid.length +
ref.symref.length +
ref.label.length +
ref.searchName.length)
estimatedBytes += bytes
if (estimatedBytes > maxRetainedBytes || (retention && !retention.reserve(bytes))) {
oversized = estimatedBytes > maxRetainedBytes && maxRetainedBytes > 0
refs = null
retention?.release()
} else {
refs.push(ref)
}
}
return !refs && pageComplete === true
}).catch((error: unknown) => {
retention?.release()
throw error
})
return { snapshot: refs ? { refs } : null, oversized }
}
export async function readBranchRemoteNames(
path: string,
git: LocalGitExecOptions
): Promise<string[]> {
const { stdout } = await gitExecFileAsync(['remote'], {
...gitExecOptions(path, git),
maxBuffer: 1024 * 1024
})
return stdout
.split(/\r?\n/)
.map((name) => name.trim())
.filter(Boolean)
}
async function readRefLines(
args: string[],
path: string,
git: LocalGitExecOptions,
onLine: (line: string) => boolean | void
): Promise<void> {
let carry = ''
await gitStreamStdout(args, {
...gitExecOptions(path, git),
// The parser retains a bounded line/page when a population cannot be retained.
maxBuffer: Number.MAX_SAFE_INTEGER,
maxStderrBuffer: 64 * 1024,
onStdout: (chunk) => {
const lines = (carry + chunk).split('\n')
carry = lines.pop() ?? ''
if (carry.length > 64 * 1024) {
throw new Error('Git returned an oversized branch ref record')
}
for (const line of lines) {
if (onLine(line) === true) {
carry = ''
return true
}
}
return false
}
})
if (carry) {
onLine(carry)
}
}
@@ -0,0 +1,302 @@
import { mkdtemp, mkdir, realpath, rename, rm, symlink } from 'node:fs/promises'
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, expect, it, vi } from 'vitest'
import { z } from 'zod'
import { gitExecFileAsync } from '../../command-runner/git-exec-file'
import { listLocalBranches } from '../../worktrees/checkout/checkout'
import { _resetBranchRefSnapshotsForTests } from './branch-ref-snapshot'
import { searchBaseRefDetails } from '../resolution/repo-base-ref-search'
import { searchRemoteRefPage } from '../resolution/repo-remote-ref-search'
import { RuntimeRepositoryRefQueries } from '../../../runtime/repositories/runtime-repository-ref-queries'
import {
repoDispatcher,
makeRequest
} from '../../../runtime/rpc/methods/projects/repo-rpc.test-fixture'
import type { Repo } from '../../../../shared/repo-types'
import { drainSubprocessSpawnStats } from '../../../diagnostics/main-thread-churn-probe'
import { beginGitRefMutation, subscribeGitRefChanges } from '../git-ref-change-signal'
import { resolveBranchSnapshotIdentity } from './branch-ref-snapshot-identity'
const roots: string[] = []
const git = async (cwd: string, ...args: string[]) =>
(await gitExecFileAsync(args, { cwd })).stdout.trim()
afterEach(async () => {
vi.unstubAllEnvs()
_resetBranchRefSnapshotsForTests()
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
it('discovers the physical parent of a symlink to a repository subdirectory', async () => {
const { root } = await fixture()
const parent = await mkdtemp(join(tmpdir(), 'orca-ref-alias-'))
roots.push(parent)
await mkdir(join(root, 'nested'))
await symlink(join(root, 'nested'), join(parent, 'alias'), 'junction')
expect(await searchBaseRefDetails(join(parent, 'alias'), 'collision', 20)).toEqual([
{ refName: 'refs/heads/collision', localBranchName: 'collision' }
])
})
it.each([10, 100])(
'loads one population for %i refs and filters repeated entrypoint searches without children',
async (population) => {
const { root, tagOid } = await fixture()
await gitExecFileAsync(['update-ref', '--stdin'], {
cwd: root,
stdin: Array.from(
{ length: population },
(_, i) => `create refs/heads/scale-${i} ${tagOid}\n`
).join('')
})
const repo: Repo = { id: 'scale', path: root, displayName: 'scale', badgeColor: '', addedAt: 0 }
const runtime = new RuntimeRepositoryRefQueries({ resolveRepo: async () => repo })
vi.stubEnv('ORCA_MAIN_THREAD_DIAGNOSTICS', '1')
drainSubprocessSpawnStats()
expect((await searchBaseRefDetails(root, 'scale-', 30)).length).toBe(Math.min(population, 30))
expect(drainSubprocessSpawnStats()['git for-each-ref']?.count).toBe(1)
// Queries only need an existing generation; each surface's limit is a bounded projection.
for (let query = 0; query < 10; query++) {
await searchBaseRefDetails(root, `scale-${query}`, 30)
await runtime.search('scale', 'scale-', 20)
await runtime.search('scale', 'absent', 20, true, 'remote')
}
expect(drainSubprocessSpawnStats()).toEqual({})
const identity = await resolveBranchSnapshotIdentity(root, {})
const end = beginGitRefMutation(identity!.commonDir)
await searchBaseRefDetails(root, 'scale-', 30)
drainSubprocessSpawnStats()
await runtime.search('scale', 'scale-1', 20)
await searchBaseRefDetails(root, 'absent', 30)
expect(drainSubprocessSpawnStats()).toEqual({})
end()
}
)
async function fixture(): Promise<{ root: string; branchOid: string; tagOid: string }> {
const root = await mkdtemp(join(tmpdir(), 'orca-branch-snapshot-'))
roots.push(root)
await git(root, 'init', '-q', '-b', 'main')
const commit = () =>
git(
root,
'-c',
'user.name=Fixture',
'-c',
'user.email=fixture@example.test',
'commit',
'--allow-empty',
'-qm',
'fixture'
)
await commit()
const branchOid = await git(root, 'rev-parse', 'HEAD')
await git(root, 'branch', 'collision')
await commit()
const tagOid = await git(root, 'rev-parse', 'HEAD')
await git(root, 'tag', 'collision')
await git(root, 'remote', 'add', 'origin', 'https://example.invalid/repo.git')
await git(root, 'remote', 'add', 'team/fork', 'https://example.invalid/fork.git')
await gitExecFileAsync(['update-ref', '--stdin'], {
cwd: root,
stdin: [
'refs/heads/Feature/emoji😀',
'refs/heads/origin/main',
'refs/remotes/origin/main',
'refs/remotes/origin/feature/HEAD',
'refs/remotes/team/fork/release/topic'
]
.map((ref) => `create ${ref} ${tagOid}\n`)
.join('')
})
await git(root, 'symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main')
return { root, branchOid, tagOid }
}
it('searches readable branch names while keeping branch actions unambiguous', async () => {
const { root, branchOid, tagOid } = await fixture()
expect(branchOid).not.toBe(tagOid)
expect(await searchBaseRefDetails(root, 'feature/EMOJI', 30)).toEqual([
{ refName: 'refs/heads/Feature/emoji😀', localBranchName: 'Feature/emoji😀' }
])
expect(await searchBaseRefDetails(root, 'refs/heads', 30)).toEqual([])
expect(await searchBaseRefDetails(root, 'collision', 20)).toEqual([
{ refName: 'refs/heads/collision', localBranchName: 'collision' }
])
expect(await git(root, 'rev-parse', 'refs/heads/collision')).toBe(branchOid)
expect(await searchBaseRefDetails(root, 'origin/main', 20)).toEqual([
{ refName: 'refs/heads/origin/main', localBranchName: 'origin/main' },
{ refName: 'refs/remotes/origin/main', localBranchName: 'main' }
])
expect(await searchRemoteRefPage(root, '', 2)).toEqual({
refDetails: [
{ refName: 'refs/remotes/origin/feature/HEAD', localBranchName: 'feature/HEAD' },
{ refName: 'refs/remotes/origin/main', localBranchName: 'main' }
],
truncated: true
})
})
it('keeps ordinary branches available to a client without qualified-ref support', async () => {
const { root } = await fixture()
const repo: Repo = {
id: 'snapshot-test',
path: root,
displayName: 'fixture',
badgeColor: '',
addedAt: 0
}
const queries = new RuntimeRepositoryRefQueries({ resolveRepo: async () => repo })
const dispatcher = repoDispatcher({
getRuntimeId: () => 'snapshot-test',
searchRepoRefs: (repo, query, limit, qualified, namespace) =>
queries.search(repo, query, limit ?? 25, qualified, namespace)
})
const response = await dispatcher.dispatch(
makeRequest('repo.searchRefs', {
repo: 'snapshot-test',
query: '',
limit: 20
}),
{ clientCapabilities: [] }
)
const result = z
.object({ result: z.object({ refs: z.array(z.string()) }) })
.parse(response).result
expect(result.refs).toContain('main')
expect(result.refs).not.toContain('collision')
expect(result.refs).not.toContain('refs/heads/collision')
expect(result.refs).not.toContain('refs/heads/Feature/emoji😀')
expect(result.refs.some((ref) => ref.startsWith('refs/'))).toBe(false)
await git(root, 'update-ref', 'refs/heads/origin/a', 'HEAD')
await git(root, 'update-ref', 'refs/remotes/origin/a', 'HEAD')
await git(root, 'update-ref', 'refs/remotes/origin/b', 'HEAD')
const remoteResponse = await dispatcher.dispatch(
makeRequest('repo.searchRefs', {
repo: 'snapshot-test',
query: '',
limit: 1,
namespace: 'remote'
}),
{ clientCapabilities: [] }
)
expect(
z
.object({ result: z.object({ refs: z.array(z.string()), truncated: z.boolean() }) })
.parse(remoteResponse).result
).toEqual({ refs: [], truncated: true })
})
it('fences prior results even when our mutating command fails', async () => {
const { root } = await fixture()
expect(await searchBaseRefDetails(root, 'external', 20)).toEqual([])
await promisify(execFile)('git', ['branch', 'external'], { cwd: root })
await expect(git(root, 'branch', 'main')).rejects.toThrow()
expect(await searchBaseRefDetails(root, 'external', 20)).toEqual([
{ refName: 'refs/heads/external', localBranchName: 'external' }
])
})
it('shares linked checkout branches while reading current branch and occupancy per request', async () => {
const { root } = await fixture()
const linkedParent = await mkdtemp(join(tmpdir(), 'orca-branch-linked-'))
roots.push(linkedParent)
const linked = join(linkedParent, 'checkout')
await git(root, 'worktree', 'add', '-qb', 'other', linked)
const nested = join(linked, 'nested')
await mkdir(nested)
expect(await searchBaseRefDetails(nested, 'collision', 20)).toEqual([
{ refName: 'refs/heads/collision', localBranchName: 'collision' }
])
const mainBranches = await listLocalBranches(root)
const linkedBranches = await listLocalBranches(nested)
expect(mainBranches.current).toBe('main')
expect(mainBranches.branches[0]).toBe('main')
expect(linkedBranches.current).toBe('other')
expect(linkedBranches.branches[0]).toBe('other')
expect(linkedBranches.worktreePaths?.other).toBe(await realpath(linked))
await git(root, 'branch', 'fresh')
expect(await searchBaseRefDetails(nested, 'fresh', 20)).toEqual([
{ refName: 'refs/heads/fresh', localBranchName: 'fresh' }
])
})
it('fences all live repositories for own writes from linked, known and unknown paths', async () => {
const { root } = await fixture()
const { root: other } = await fixture()
const linkedParent = await realpath(await mkdtemp(join(tmpdir(), 'orca-ref-scope-')))
roots.push(linkedParent)
const linked = join(linkedParent, 'checkout')
await git(root, 'worktree', 'add', '-qb', 'linked-scope', linked)
expect((await listLocalBranches(root)).worktreePaths?.['linked-scope']).toBe(linked)
await searchBaseRefDetails(other, '', 20)
const a = await resolveBranchSnapshotIdentity(root, {})
const b = await resolveBranchSnapshotIdentity(other, {})
const events: string[] = []
const offA = subscribeGitRefChanges(a!.commonDir, () => events.push('a'))
const offB = subscribeGitRefChanges(b!.commonDir, () => events.push('b'))
try {
await git(linked, 'branch', 'linked-one')
expect(events.splice(0)).toEqual(['a', 'b', 'a', 'b'])
await git(linked, 'branch', 'linked-two')
expect(events.splice(0)).toEqual(['a', 'b', 'a', 'b'])
await git(other, 'branch', 'other-one')
expect(events.splice(0)).toEqual(['a', 'b', 'a', 'b'])
const unknown = await mkdtemp(join(tmpdir(), 'orca-ref-unknown-'))
roots.push(unknown)
await expect(git(unknown, 'branch', 'fails')).rejects.toThrow()
expect(events).toEqual(['a', 'b', 'a', 'b'])
} finally {
offA()
offB()
}
})
it('uses each linked checkout effective remote config against the shared raw population', async () => {
const { root, tagOid } = await fixture()
const linkedParent = await mkdtemp(join(tmpdir(), 'orca-ref-config-'))
roots.push(linkedParent)
const linked = join(linkedParent, 'checkout')
await git(root, 'worktree', 'add', '-qb', 'config-context', linked)
await git(root, 'config', 'extensions.worktreeConfig', 'true')
await git(
linked,
'config',
'--worktree',
'remote.checkout/fork.url',
'https://example.invalid/context.git'
)
await git(root, 'update-ref', 'refs/remotes/checkout/fork/HEAD', tagOid)
expect(await searchBaseRefDetails(root, 'checkout/fork/HEAD', 20)).toEqual([
{ refName: 'refs/remotes/checkout/fork/HEAD', localBranchName: 'fork/HEAD' }
])
expect(await searchBaseRefDetails(linked, 'checkout/fork/HEAD', 20)).toEqual([])
})
it('rebinds a repository replaced at the same common-directory path', async () => {
const { root } = await fixture()
expect(await searchBaseRefDetails(root, 'collision', 20)).toEqual([
{ refName: 'refs/heads/collision', localBranchName: 'collision' }
])
await rename(join(root, '.git'), join(root, '.git-old'))
const externalGit = (...args: string[]) => promisify(execFile)('git', args, { cwd: root })
await externalGit('init', '-q', '-b', 'main')
await externalGit(
'-c',
'user.name=Fixture',
'-c',
'user.email=fixture@example.test',
'commit',
'--allow-empty',
'-qm',
'replacement'
)
await externalGit('branch', 'replacement')
expect(await searchBaseRefDetails(root, 'collision', 20)).toEqual([])
expect(await searchBaseRefDetails(root, 'replacement', 20)).toEqual([
{ refName: 'refs/heads/replacement', localBranchName: 'replacement' }
])
})
@@ -0,0 +1,105 @@
import { afterEach, expect, it, vi } from 'vitest'
import type * as FsPromises from 'node:fs/promises'
import type * as SnapshotReader from './branch-ref-snapshot-read'
import type * as DistroCatalog from '../../../wsl/discovery/distro-catalog'
const cwd = String.raw`C:\repo-linked`
const commonDir = String.raw`C:\repo-main\.git`
const gitDir = String.raw`C:\repo-main\.git\worktrees\linked`
const io = vi.hoisted(() => ({ remote: vi.fn(), refs: vi.fn() }))
vi.mock('../../../wsl/discovery/distro-catalog', async (original) => ({
...(await original<typeof DistroCatalog>()),
listRunningWslDistrosAsync: async () => ['Ubuntu']
}))
vi.mock('node:fs/promises', async (original) => ({
...(await original<typeof FsPromises>()),
realpath: async (path: string) => path,
stat: async (path: string) => ({
isFile: () => path.endsWith('.git'),
isDirectory: () => !path.endsWith('.git'),
dev: 1n,
ino: 2n,
birthtimeNs: 3n
}),
readFile: async () => String.raw`gitdir: C:\repo-main\.git\worktrees\linked`
}))
vi.mock('../../../../shared/git/metadata/git-common-directory', () => ({
resolveGitCommonDirectory: async () => String.raw`C:\repo-main\.git`
}))
vi.mock('../../source-control/resolve-git-dir', () => ({
resolveGitDir: async () => String.raw`C:\repo-main\.git\worktrees\linked`
}))
vi.mock('./branch-ref-snapshot-read', async (original) => ({
...(await original<typeof SnapshotReader>()),
readBranchRemoteNames: io.remote,
readBranchRefSnapshot: io.refs
}))
import { searchBranchRefSnapshot, _resetBranchRefSnapshotsForTests } from './branch-ref-snapshot'
import {
prepareWslLinkedWorktreeGitRouting,
resetWslLinkedWorktreeGitRoutingForTests
} from '../../command-runner/host-routing/wsl-linked-worktree-git-routing'
import { resolveBranchSnapshotIdentity } from './branch-ref-snapshot-identity'
import { trackGitRefMutation } from '../../command-runner/git-ref-mutation-state'
import { subscribeGitRefChanges } from '../git-ref-change-signal'
import { _resetWslBackgroundDistroGateForTests } from '../../../wsl/discovery/wsl-background-distro-gate'
const platform = process.platform
afterEach(() => {
_resetBranchRefSnapshotsForTests()
resetWslLinkedWorktreeGitRoutingForTests()
_resetWslBackgroundDistroGateForTests()
Object.defineProperty(process, 'platform', { configurable: true, value: platform })
vi.restoreAllMocks()
io.remote.mockReset()
io.refs.mockReset()
})
it('keeps a live host snapshot across expiry of the filesystem-only linked-route proof', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
const clock = vi.spyOn(Date, 'now').mockReturnValue(10_000)
// The reader boundary models execution's existing route settlement; it is not a real WSL benchmark.
io.remote.mockImplementation(async () => {
await prepareWslLinkedWorktreeGitRouting(cwd, 'Ubuntu')
return []
})
io.refs.mockResolvedValue({
snapshot: {
refs: [
{
refName: 'refs/heads/topic',
label: 'topic',
searchName: 'topic',
oid: 'a'.repeat(40),
committedAt: 0,
symref: ''
}
]
},
oversized: false
})
expect(await searchBranchRefSnapshot(cwd, 'topic', 20, { wslDistro: 'Ubuntu' })).toEqual([
{ refName: 'refs/heads/topic', localBranchName: 'topic' }
])
clock.mockReturnValue(40_001)
expect(await resolveBranchSnapshotIdentity(cwd, { wslDistro: 'Ubuntu' })).toMatchObject({
commonDir,
gitDir
})
expect(await searchBranchRefSnapshot(cwd, 'topic', 20, { wslDistro: 'Ubuntu' })).toEqual([
{ refName: 'refs/heads/topic', localBranchName: 'topic' }
])
expect(io.remote).toHaveBeenCalledTimes(1)
expect(io.refs).toHaveBeenCalledTimes(1)
clock.mockReturnValue(70_002)
const events: string[] = []
const offOwner = subscribeGitRefChanges(commonDir, () => events.push('owner'))
const offOther = subscribeGitRefChanges(String.raw`C:\other\.git`, () => events.push('other'))
try {
trackGitRefMutation(['commit'])()
expect(events).toEqual(['owner', 'other', 'owner', 'other'])
} finally {
offOwner()
offOther()
}
})
@@ -0,0 +1,321 @@
import { afterEach, expect, it, vi } from 'vitest'
import type { BranchSnapshotIdentity } from './branch-ref-snapshot-identity'
import type { BranchRefSnapshot } from './branch-ref-snapshot-read'
import type { BranchRef } from './branch-ref-snapshot-data'
import type * as SnapshotReader from './branch-ref-snapshot-read'
const mocks = vi.hoisted(() => ({
read: vi.fn(),
identity: vi.fn(),
remotes: vi.fn(async (): Promise<string[]> => [])
}))
vi.mock('./branch-ref-snapshot-read', async (original) => ({
...(await original<typeof SnapshotReader>()),
readBranchRefSnapshot: mocks.read,
readBranchRemoteNames: mocks.remotes
}))
vi.mock('./branch-ref-snapshot-identity', () => ({ resolveBranchSnapshotIdentity: mocks.identity }))
import { BranchRefSnapshotStore } from './branch-ref-snapshot-store'
import { searchBaseRefDetails } from '../resolution/repo-base-ref-search'
import { _resetBranchRefSnapshotsForTests } from './branch-ref-snapshot'
import { trackGitRefMutation } from '../../command-runner/git-ref-mutation-state'
import { publishGitRefChange, subscribeGitRefChanges } from '../git-ref-change-signal'
const identity: BranchSnapshotIdentity = {
key: 'host\0/repo/.git',
commonDir: '/repo/.git',
gitDir: '/repo/.git',
checkoutKey: '/repo/.git',
cwd: '/repo',
git: {}
}
const stores: BranchRefSnapshotStore[] = []
function store() {
const result = new BranchRefSnapshotStore({
subscribe: subscribeGitRefChanges
})
stores.push(result)
mocks.identity.mockResolvedValue(identity)
return result
}
function snapshot(name: string): { snapshot: BranchRefSnapshot; oversized: boolean } {
const ref: BranchRef = {
refName: `refs/heads/${name}`,
label: name,
searchName: name.toLowerCase(),
oid: 'a'.repeat(40),
committedAt: 0,
symref: ''
}
return { snapshot: { refs: [ref] }, oversized: false }
}
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason: unknown) => void
const promise = new Promise<T>((done, fail) => {
resolve = done
reject = fail
})
return { promise, resolve, reject }
}
afterEach(() => {
for (const value of stores.splice(0)) {
value.reset()
}
_resetBranchRefSnapshotsForTests()
vi.restoreAllMocks()
mocks.read.mockReset()
mocks.identity.mockReset()
mocks.remotes.mockReset().mockResolvedValue([])
})
it('does not publish a capture that finishes after a ref change', async () => {
const value = store()
const started = deferred<void>()
const old = deferred<ReturnType<typeof snapshot>>()
mocks.read.mockImplementationOnce(() => {
started.resolve()
return old.promise
})
mocks.read.mockResolvedValue(snapshot('new'))
const result = value.search(identity, '', 20)
await started.promise
publishGitRefChange(identity.commonDir)
old.resolve(snapshot('old'))
expect(await result).toEqual([{ refName: 'refs/heads/old', localBranchName: 'old' }])
expect(await value.search(identity, 'OLD', 20)).toEqual([])
})
it('serves a post-fence caller from the next physical read without making the earlier caller retry', async () => {
const value = store()
const started = deferred<void>()
const old = deferred<ReturnType<typeof snapshot>>()
mocks.read.mockImplementationOnce(() => {
started.resolve()
return old.promise
})
mocks.read.mockImplementationOnce(() => {
// Another event during the successor read must not prevent its response from settling.
publishGitRefChange(identity.commonDir)
return Promise.resolve(snapshot('fresh'))
})
const before = value.search(identity, '', 20)
await started.promise
publishGitRefChange(identity.commonDir)
const after = value.search(identity, '', 20)
old.resolve(snapshot('old'))
expect(await before).toEqual([{ refName: 'refs/heads/old', localBranchName: 'old' }])
expect(await after).toEqual([{ refName: 'refs/heads/fresh', localBranchName: 'fresh' }])
})
it('starts the new generation after an obsolete physical read fails', async () => {
const value = store()
const started = deferred<void>()
const old = deferred<ReturnType<typeof snapshot>>()
mocks.read.mockImplementationOnce(() => {
started.resolve()
return old.promise
})
mocks.read.mockResolvedValue(snapshot('fresh'))
const before = expect(value.search(identity, '', 20)).rejects.toThrow('obsolete failure')
await started.promise
publishGitRefChange(identity.commonDir)
const after = value.search(identity, '', 20)
await new Promise<void>(setImmediate)
old.reject(new Error('obsolete failure'))
await before
expect(await after).toEqual([{ refName: 'refs/heads/fresh', localBranchName: 'fresh' }])
})
it('settles public searches across repeated ref publications without restarting the request', async () => {
mocks.identity.mockResolvedValue(identity)
const started = deferred<void>()
const old = deferred<ReturnType<typeof snapshot>>()
mocks.read.mockImplementationOnce(() => {
started.resolve()
return old.promise
})
mocks.read.mockImplementation(async () => {
publishGitRefChange(identity.commonDir)
return snapshot('successor')
})
const before = searchBaseRefDetails(identity.cwd, '', 20)
await started.promise
publishGitRefChange(identity.commonDir)
const after = searchBaseRefDetails(identity.cwd, '', 20)
await new Promise<void>(setImmediate)
old.resolve(snapshot('overlapping'))
expect(await before).toEqual([
{ refName: 'refs/heads/overlapping', localBranchName: 'overlapping' }
])
expect(await after).toEqual([{ refName: 'refs/heads/successor', localBranchName: 'successor' }])
})
it('returns the initiating local-list output when the capture cannot be retained', async () => {
const value = store()
mocks.read.mockImplementationOnce(async (_cwd, _git, onRef) => {
for (const ref of snapshot('local').snapshot.refs) {
onRef(ref)
}
return { snapshot: null, oversized: true }
})
expect((await value.localRefs(identity)).map((ref) => ref.refName)).toEqual(['refs/heads/local'])
})
it('bounds concurrent capture retention and admits a temporarily pressured repository again', async () => {
const value = store()
const other = {
...identity,
key: 'host\0/other/.git',
cwd: '/other',
commonDir: '/other/.git',
checkoutKey: '/other/.git'
}
mocks.identity.mockImplementation(async (cwd) => (cwd === '/other' ? other : identity))
const started = deferred<void>()
const held = deferred<void>()
mocks.read.mockImplementationOnce(async (_cwd, _git, _onRef, _max, lease) => {
expect(lease.reserve(40 * 1024 * 1024)).toBe(true)
started.resolve()
await held.promise
return snapshot('first')
})
mocks.read.mockImplementationOnce(async (_cwd, _git, onRef, _max, lease) => {
expect(lease.reserve(40 * 1024 * 1024)).toBe(false)
onRef(snapshot('pressured').snapshot.refs[0])
return { snapshot: null, oversized: false }
})
const first = value.search(identity, '', 20)
await started.promise
expect(await value.search(other, '', 20)).toEqual([
{ refName: 'refs/heads/pressured', localBranchName: 'pressured' }
])
held.resolve()
await first
mocks.read.mockImplementationOnce(async (_cwd, _git, _onRef, _max, lease) => {
expect(lease.reserve(40 * 1024 * 1024)).toBe(true)
return snapshot('retained')
})
expect(await value.search(other, '', 20)).toEqual([
{ refName: 'refs/heads/retained', localBranchName: 'retained' }
])
mocks.read.mockRejectedValue(new Error('a retained repeat must not reread'))
expect(await value.search(other, '', 20)).toEqual([
{ refName: 'refs/heads/retained', localBranchName: 'retained' }
])
})
it('shares a fill while independently projecting each caller query', async () => {
const value = store()
const started = deferred<void>()
const fill = deferred<ReturnType<typeof snapshot>>()
mocks.read.mockImplementation(() => {
started.resolve()
return fill.promise
})
const matching = value.search(identity, 'TOPIC', 20)
const absent = value.search(identity, 'unmatched', 20)
await started.promise
fill.resolve(snapshot('topic'))
expect(await matching).toEqual([{ refName: 'refs/heads/topic', localBranchName: 'topic' }])
expect(await absent).toEqual([])
})
it('retries failed fills without extending the original metadata deadline', async () => {
const clock = vi.spyOn(Date, 'now').mockReturnValue(0)
const value = store()
expect(await value.remoteNames(identity)).toEqual([])
clock.mockReturnValue(60_000)
mocks.read.mockRejectedValueOnce(new Error('temporary read error'))
await expect(value.search(identity, '', 20)).rejects.toThrow('temporary read error')
mocks.read.mockResolvedValue(snapshot('first'))
expect(await value.search(identity, '', 20)).toEqual([
{ refName: 'refs/heads/first', localBranchName: 'first' }
])
clock.mockReturnValue(120_001)
mocks.read.mockResolvedValue(snapshot('second'))
expect(await value.search(identity, '', 20)).toEqual([
{ refName: 'refs/heads/second', localBranchName: 'second' }
])
})
it('refreshes remote metadata and recovers retention after repeated oversized captures', async () => {
const clock = vi.spyOn(Date, 'now').mockReturnValue(0)
const value = store()
mocks.remotes.mockResolvedValue(['origin'])
const ref: BranchRef = {
refName: 'refs/remotes/team/fork/topic',
label: 'team/fork/topic',
searchName: 'team/fork/topic',
oid: 'a'.repeat(40),
committedAt: 0,
symref: ''
}
let oversized = true
mocks.read.mockImplementation(
async (...args: Parameters<typeof SnapshotReader.readBranchRefSnapshot>) => {
const [, , onRef, maxBytes = 0] = args
onRef(ref)
return { snapshot: !oversized && maxBytes > 0 ? { refs: [ref] } : null, oversized }
}
)
expect(await value.search(identity, 'topic', 20)).toEqual([
{ refName: 'refs/remotes/team/fork/topic', localBranchName: 'fork/topic' }
])
mocks.remotes.mockResolvedValue(['origin', 'team/fork'])
for (const now of [60_000, 119_000]) {
clock.mockReturnValue(now)
expect(await value.search(identity, 'topic', 20)).toEqual([
{ refName: 'refs/remotes/team/fork/topic', localBranchName: 'fork/topic' }
])
}
clock.mockReturnValue(120_001)
oversized = false
expect(await value.search(identity, 'topic', 20)).toEqual([
{ refName: 'refs/remotes/team/fork/topic', localBranchName: 'topic' }
])
expect(await value.remoteNames(identity)).toEqual(['origin', 'team/fork'])
mocks.read.mockRejectedValue(new Error('retained queries must survive an unavailable reader'))
expect(await value.search(identity, 'TOPIC', 20)).toEqual([
{ refName: 'refs/remotes/team/fork/topic', localBranchName: 'topic' }
])
})
it('does not resurrect a retired fill after a store reset', async () => {
const value = store()
const started = deferred<void>()
const old = deferred<ReturnType<typeof snapshot>>()
mocks.read.mockImplementationOnce(() => {
started.resolve()
return old.promise
})
mocks.read.mockResolvedValue(snapshot('fresh'))
const result = value.search(identity, '', 20)
await started.promise
value.reset()
old.resolve(snapshot('retired'))
expect(await result).toEqual([{ refName: 'refs/heads/retired', localBranchName: 'retired' }])
expect(await value.search(identity, '', 20)).toEqual([
{ refName: 'refs/heads/fresh', localBranchName: 'fresh' }
])
})
it('keeps demanded captures warm during a mutation and invalidates late owners only on termination', async () => {
const other = { ...identity, key: 'host\0/late/.git', commonDir: '/late/.git', cwd: '/late' }
mocks.identity.mockImplementation(async (cwd: string) => (cwd === '/late' ? other : identity))
mocks.read.mockResolvedValue(snapshot('topic'))
await searchBaseRefDetails(identity.cwd, 'topic', 20)
expect(mocks.read).toHaveBeenCalledTimes(1)
const end = trackGitRefMutation(['commit'])
expect(mocks.read).toHaveBeenCalledTimes(1)
await searchBaseRefDetails(identity.cwd, 'TOPIC', 20)
await searchBaseRefDetails(identity.cwd, 'absent', 20)
await searchBaseRefDetails(other.cwd, 'topic', 20)
await searchBaseRefDetails(other.cwd, 'absent', 20)
expect(mocks.read).toHaveBeenCalledTimes(3)
end()
expect(mocks.read).toHaveBeenCalledTimes(3)
await searchBaseRefDetails(identity.cwd, 'topic', 20)
await searchBaseRefDetails(other.cwd, 'topic', 20)
expect(mocks.read).toHaveBeenCalledTimes(5)
})
@@ -0,0 +1,174 @@
import type { BaseRefSearchResult } from '../../../../shared/repo-types'
import type { BranchSnapshotIdentity } from './branch-ref-snapshot-identity'
import {
BranchSnapshotOwners,
BranchSnapshotIdentityChanged,
type Owner,
type Capture,
type BranchSnapshotSignals
} from './branch-ref-snapshot-owners'
import { BranchSnapshotMetadata } from './branch-ref-snapshot-metadata'
import {
createBranchRefProjection,
projectBranchRefs,
type BranchRef
} from './branch-ref-snapshot-data'
import {
BRANCH_SNAPSHOT_MAX_BYTES,
readBranchRefSnapshot,
readBranchRemoteNames
} from './branch-ref-snapshot-read'
export { BranchSnapshotIdentityChanged } from './branch-ref-snapshot-owners'
/** One physical capture per owner; each caller projects its own page or local list. */
export class BranchRefSnapshotStore {
private readonly cache: BranchSnapshotOwners
private readonly metadata: BranchSnapshotMetadata
constructor(signals: BranchSnapshotSignals) {
this.cache = new BranchSnapshotOwners(signals)
this.metadata = new BranchSnapshotMetadata(this.cache)
}
private async capture(
owner: Owner | null,
identity: BranchSnapshotIdentity,
request: string,
onRef: (ref: BranchRef) => boolean | void,
page: BaseRefSearchResult[],
locals: BranchRef[] | null
): Promise<Capture> {
const admitted = owner?.generation
// A post-fence caller waits for the physical old read, but never consumes that generation.
if (owner?.flight && owner.flight.generation !== admitted) {
await owner.flight.promise.catch((error: unknown) => {
if (error instanceof BranchSnapshotIdentityChanged) {
throw error
}
})
}
if (owner?.snapshot) {
return { snapshot: owner.snapshot, request, page, locals }
}
if (owner?.flight) {
return owner.flight.promise
}
const generation = owner?.generation
const lease = owner ? this.cache.lease(owner, () => owner.generation === generation) : undefined
const promise = readBranchRefSnapshot(
identity.cwd,
identity.git,
onRef,
!owner || owner.oversized ? 0 : BRANCH_SNAPSHOT_MAX_BYTES,
lease
)
.then(async (result): Promise<Capture> => {
await this.cache.validate(identity, owner ?? undefined)
if (owner && !owner.disposed && owner.generation === generation) {
owner.snapshot = result.snapshot
owner.oversized ||= result.oversized
if (result.snapshot) {
owner.snapshotLease = lease
} else {
lease?.release()
}
} else {
lease?.release()
}
return { snapshot: result.snapshot, request, page, locals }
})
.catch((error: unknown) => {
lease?.release()
throw error
})
.finally(() => {
if (owner?.flight?.promise === promise) {
owner.flight = undefined
}
})
if (owner) {
owner.flight = { generation: generation!, promise }
}
return promise
}
async localRefs(identity: BranchSnapshotIdentity): Promise<readonly BranchRef[]> {
const owner = this.cache.owner(identity)
const locals: BranchRef[] = []
const collect = (ref: BranchRef): void => {
if (ref.refName.startsWith('refs/heads/')) {
locals.push(ref)
}
}
const capture = await this.capture(owner, identity, 'locals', collect, [], locals)
if (capture.snapshot && owner?.snapshot === capture.snapshot && owner.localRefs) {
return owner.localRefs
}
let refs = capture.snapshot
? sortLocalRefs(capture.snapshot.refs)
: capture.locals
? sortLocalRefs(capture.locals)
: null
if (!refs) {
// A different page initiated an unretainable capture; local output still requires the full population.
await readBranchRefSnapshot(identity.cwd, identity.git, collect, 0)
await this.cache.validate(identity)
refs = sortLocalRefs(locals)
}
if (owner && capture.snapshot && owner.snapshot === capture.snapshot) {
const lease = this.cache.lease(owner)
if (lease.reserve(64 + refs.length * 8)) {
owner.localRefs = refs
owner.localLease = lease
}
}
return refs
}
async search(
identity: BranchSnapshotIdentity,
query: string,
limit: number,
remoteOnly = false
): Promise<BaseRefSearchResult[]> {
const owner = this.cache.owner(identity)
const context = owner ? this.metadata.context(owner, identity) : undefined
const remotes = await (context?.remotes ?? readBranchRemoteNames(identity.cwd, identity.git))
const request = JSON.stringify([identity.checkoutKey, query, limit, remoteOnly])
const page: BaseRefSearchResult[] = []
const project = createBranchRefProjection(remotes, query, remoteOnly)
const collect = (ref: BranchRef): boolean => {
if (page.length < limit) {
const row = project(ref)
if (row) {
page.push(row)
}
}
return page.length >= limit
}
const capture = await this.capture(owner, identity, request, collect, page, null)
if (capture.snapshot) {
return projectBranchRefs(capture.snapshot.refs, remotes, query, limit, remoteOnly)
}
if (capture.request === request) {
return capture.page
}
await readBranchRefSnapshot(identity.cwd, identity.git, collect, 0)
await this.cache.validate(identity, owner ?? undefined)
return page
}
remoteNames(identity: BranchSnapshotIdentity) {
return this.metadata.remoteNames(identity)
}
reset(): void {
this.cache.reset()
}
}
function sortLocalRefs(refs: readonly BranchRef[]): BranchRef[] {
const local = refs
.filter((ref) => ref.refName.startsWith('refs/heads/'))
.map((ref) => ({ ref, key: Buffer.from(ref.refName) }))
local.sort((left, right) => Buffer.compare(left.key, right.key))
return local.map(({ ref }) => ref)
}
@@ -0,0 +1,80 @@
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import type { BaseRefSearchResult } from '../../../../shared/repo-types'
import type { LocalGitExecOptions } from '../resolution/repo-default-base-ref'
import {
resolveBranchSnapshotIdentity,
type BranchSnapshotIdentity
} from './branch-ref-snapshot-identity'
import { BranchRefSnapshotStore, BranchSnapshotIdentityChanged } from './branch-ref-snapshot-store'
import { subscribeGitRefChanges } from '../git-ref-change-signal'
import { readTranslatedWorktreeGraph } from '../../worktrees/catalog/worktree-list-reader'
const snapshots = new BranchRefSnapshotStore({ subscribe: subscribeGitRefChanges })
async function withIdentity<T>(
path: string,
git: LocalGitExecOptions,
read: (identity: BranchSnapshotIdentity) => Promise<T>
): Promise<T> {
for (let attempt = 0; attempt < 2; attempt++) {
const identity = await resolveBranchSnapshotIdentity(path, git)
if (!identity) {
throw new Error('The project is not a Git repository')
}
try {
return await read(identity)
} catch (error) {
if (!(error instanceof BranchSnapshotIdentityChanged)) {
throw error
}
}
}
throw new Error('The Git repository changed while reading branches. Try again.')
}
export function searchBranchRefSnapshot(
path: string,
query: string,
limit: number,
git: LocalGitExecOptions = {},
remoteOnly = false
): Promise<BaseRefSearchResult[]> {
return withIdentity(path, git, (identity) => snapshots.search(identity, query, limit, remoteOnly))
}
export function snapshotRemoteNames(
path: string,
git: LocalGitExecOptions = {}
): Promise<string[]> {
return withIdentity(path, git, (identity) => snapshots.remoteNames(identity))
}
export function snapshotLocalBranchMetadata(path: string, git: LocalGitExecOptions = {}) {
return withIdentity(path, git, async (identity) => {
const [refs, worktrees, head] = await Promise.all([
snapshots.localRefs(identity),
readTranslatedWorktreeGraph(identity.cwd, identity.git),
readFile(join(identity.gitDir, 'HEAD'), 'utf8').catch(() => '')
])
const currentRef = /^ref: (refs\/heads\/[^\r\n]+)/.exec(head)?.[1]
const current =
currentRef &&
worktrees.some(
(worktree) => worktree.branch === currentRef && /[1-9a-f]/i.test(worktree.head)
)
? currentRef.slice('refs/heads/'.length)
: null
const worktreePaths = new Map<string, string>()
for (const worktree of worktrees) {
if (worktree.branch.startsWith('refs/heads/')) {
worktreePaths.set(worktree.branch.slice('refs/heads/'.length), worktree.path)
}
}
return { refs, worktreePaths, current }
})
}
export function _resetBranchRefSnapshotsForTests(): void {
snapshots.reset()
}
+3 -11
View File
@@ -1,3 +1,4 @@
import { snapshotRemoteNames } from '../refs/snapshot/branch-ref-snapshot'
import { parseGitRevListAheadBehindCounts } from '../../../shared/git/refs/git-rev-list-output'
import { buildHostedRemoteCommitUrl, buildHostedRemoteFileUrl } from './hosted-remote-url'
import { getRemoteFileTarget } from './repo-remote-file-target'
@@ -72,18 +73,13 @@ export async function getRecentDriftSubjects(
}
}
function parseRemoteCount(stdout: string): number {
return stdout.split('\n').filter((line) => line.trim().length > 0).length
}
/** Count configured remotes; zero means either none or unavailable. */
export async function getRemoteCount(
path: string,
options: LocalGitExecOptions = {}
): Promise<number> {
try {
const { stdout } = await gitExecFileAsync(['remote'], gitExecOptions(path, options))
return parseRemoteCount(stdout)
return (await snapshotRemoteNames(path, options)).length
} catch (err) {
console.warn('[getRemoteCount] git remote failed', { path, err })
return 0
@@ -118,11 +114,7 @@ export async function getDefaultRemote(
try {
let remotes = knownRemoteNames
if (remotes === undefined) {
const { stdout } = await gitExecFileAsync(['remote'], gitExecOptions(path, options))
remotes = stdout
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
remotes = await snapshotRemoteNames(path, options)
}
if (remotes.includes('origin')) {
return 'origin'
@@ -233,11 +233,32 @@ it('reads live worktree occupancy and discovers remotes despite 50 local matches
})
expect(transaction.code).toBe(0)
const page = await searchRemoteRefPage(directory, 'topic', 50)
expect(page.refDetails).toEqual([{ refName: 'origin/topic', localBranchName: 'topic' }])
expect(page.refDetails).toEqual([
{ refName: 'refs/remotes/origin/topic', localBranchName: 'topic' }
])
const occupied = join(directory, '.git', 'checkout-test')
await git('worktree', 'add', '-q', occupied, 'topic-0')
expect((await listLocalBranches(directory)).worktreePaths?.['topic-0']).toBe(occupied)
await git('worktree', 'remove', occupied)
expect((await listLocalBranches(directory)).worktreePaths?.['topic-0']).toBeUndefined()
await git('worktree', 'add', '-q', occupied, 'topic-1')
await rm(occupied, { recursive: true })
expect((await listLocalBranches(directory)).worktreePaths?.['topic-1']).toBe(occupied)
await git('checkout', '-qb', 'external-current')
const updated = await listLocalBranches(directory)
expect(updated.current).toBe('external-current')
expect(updated.branches[0]).toBe('external-current')
expect(updated.branches).toContain('topic-0')
expect(updated.branches).toContain('topic-9')
})
it('does not overlay an unborn local branch over a remote branch', async () => {
await git('update-ref', 'refs/remotes/origin/main', initialHead)
await git('update-ref', '-d', 'refs/heads/main')
const local = await listLocalBranches(directory)
expect(local.current).toBeNull()
expect(local.branches).toEqual([])
expect((await searchRemoteRefPage(directory, 'main', 30)).refDetails).toEqual([
{ refName: 'refs/remotes/origin/main', localBranchName: 'main' }
])
})
installRealWorkerThreadChildProcesses()
+22 -49
View File
@@ -1,3 +1,4 @@
import { snapshotLocalBranchMetadata } from '../../refs/snapshot/branch-ref-snapshot'
import type { RuntimeGitLocalBranches } from '../../../../shared/runtime-worktree-contracts'
import type { BranchSwitchCreation } from '../../../../shared/git/branch-switch-target'
@@ -42,61 +43,33 @@ export async function checkoutBranch(
return { skippedPaths: parseCheckoutSkippedPaths(stderr) }
}
/** for-each-ref avoids localized branch decoration; return the current branch first. */
/** Current checkout state stays request-specific while the shared branch population is reused. */
export async function listLocalBranches(
worktreePath: string,
options: GitRuntimeOptions = {}
): Promise<RuntimeGitLocalBranches> {
const execution = gitOptionsForWorktree(worktreePath, options)
let extended = true
const { stdout } = await gitExecFileAsync(
[
'for-each-ref',
'--format=%(HEAD)%09%(refname:lstrip=2)%09%(committerdate:unix)%09%(worktreepath)',
'refs/heads/'
],
execution
).catch((error) => {
if (options.signal?.aborted || (error instanceof Error && error.name === 'AbortError')) {
throw error
options.signal?.throwIfAborted()
const {
refs,
current,
worktreePaths: occupied
} = await snapshotLocalBranchMetadata(worktreePath, options)
options.signal?.throwIfAborted()
const branches = refs.map((ref) => ref.label)
if (current) {
const index = branches.indexOf(current)
if (index !== -1) {
branches.splice(index, 1)
}
extended = false
return gitExecFileAsync(
['for-each-ref', '--format=%(HEAD)%09%(refname:lstrip=2)', 'refs/heads/'],
execution
)
})
let current: string | null = null
const branches: string[] = []
branches.unshift(current)
}
const committedAt: Record<string, number> = Object.create(null)
const worktreePaths: Record<string, string> = Object.create(null)
for (const line of stdout.split('\n')) {
if (line.length === 0) {
continue
}
const [marker, name, timestamp, occupiedPath] = line.split('\t')
if (!name) {
continue
}
if (marker === '*') {
current = name
}
branches.push(name)
if (timestamp && Number.isFinite(Number(timestamp))) {
committedAt[name] = Number(timestamp) * 1000
}
if (occupiedPath) {
worktreePaths[name] = translateWorktreePath(occupiedPath, worktreePath, options)
}
for (const ref of refs) {
committedAt[ref.label] = ref.committedAt
}
branches.sort((a, b) => {
if (a === current) {
return -1
}
if (b === current) {
return 1
}
return 0
})
return { current, branches, ...(extended ? { committedAt, worktreePaths } : {}) }
for (const [name, path] of occupied) {
worktreePaths[name] = translateWorktreePath(path, worktreePath, options)
}
return { current, branches, committedAt, worktreePaths }
}
@@ -40,7 +40,7 @@ export class RuntimeRepositoryRefQueries {
if (getRepoRoutingConnectionId(repo)) {
throw new Error('This project is not served by this host.')
}
const page = await searchRemoteRefPage(repo.path, query, effectiveLimit)
const page = await searchRemoteRefPage(repo.path, query, effectiveLimit, includeQualifiedRefs)
return { ...page, refs: page.refDetails.map((row) => row.refName), namespace }
}
// Unserved SSH hosts report no refs; never search local files for them.
@@ -1,6 +1,7 @@
import { useState } from 'react'
import { defineFixture } from '../../component-fixtures/component-fixture'
import { NewWorkspaceComposerNameSection } from './NewWorkspaceComposerNameSection'
import { buildWorkspaceSourceSelection } from '../../../../shared/new-workspace/workspace-source'
function BranchReusePreview({
busy = false,
@@ -18,6 +19,9 @@ function BranchReusePreview({
const [reuse, setReuse] = useState(initialReuse)
const [branchName, setBranchName] = useState<string | undefined>(initialBranchName)
const [name, setName] = useState('Feature review')
const baseBranch = remote
? `refs/remotes/origin/${existingBranch}`
: `refs/heads/${existingBranch}`
return (
<div className="rounded-xl border border-border bg-background p-4">
<NewWorkspaceComposerNameSection
@@ -30,10 +34,7 @@ function BranchReusePreview({
onSmartGitLabItemSelect={() => {}}
onSmartBranchSelect={() => {}}
onSmartLinearIssueSelect={() => {}}
smartNameSelection={{
kind: 'branch',
label: remote ? `origin/${existingBranch}` : existingBranch
}}
smartNameSelection={buildWorkspaceSourceSelection({ linkedWorkItem: null, baseBranch })}
onClearSmartNameSelection={() => {}}
selectedRepoRequiresConnection={false}
selectedRepoIsGit
@@ -49,9 +50,7 @@ function BranchReusePreview({
setBranchName(undefined)
}}
onOpenSelectedBranchWorkspace={busy && reuse ? () => {} : undefined}
baseBranch={
remote ? `refs/remotes/origin/${existingBranch}` : `refs/heads/${existingBranch}`
}
baseBranch={baseBranch}
onNamePlainEnter={() => {}}
/>
</div>
@@ -12,6 +12,7 @@ import { JiraIcon } from '@/components/icons/JiraIcon'
import { Trans } from 'react-i18next'
import { i18n } from '@/i18n/i18n'
import { useTranslate } from '@/i18n/use-translate'
import { branchRefLabel } from '../../../../shared/git/refs/branch-ref-label'
import type { JiraSite } from '../../../../shared/jira/jira-types'
import type { RowEntry, SmartWorkspaceNameSelection } from './smart-workspace-name-field-model'
@@ -130,7 +131,11 @@ export function RowLabel({
)
}
if (row.kind === 'branch') {
return <span className="min-w-0 truncate font-mono text-2xs">{row.refName}</span>
return (
<span className="min-w-0 truncate font-mono text-2xs">
{row.label ?? branchRefLabel(row.refName)}
</span>
)
}
if (row.kind === 'jira') {
const siteLabel = jiraSite
@@ -0,0 +1,55 @@
import { defineFixture } from '../../component-fixtures/component-fixture'
import { CreateFromPicker } from './CreateFromPicker'
const repo = {
id: 'branch-snapshot-fixture',
path: '/repo',
displayName: 'Orca',
badgeColor: '',
addedAt: 0
}
export const collidingBranches = defineFixture({
width: 380,
setup: () => {
window.api.repos.getBaseRefDefault = async () => ({
defaultBaseRef: 'origin/main',
remoteCount: 1
})
window.api.repos.searchBaseRefs = async () => [
'refs/heads/origin/main',
'refs/remotes/origin/main',
'refs/heads/feature/a-long-branch-name-for-review'
]
},
render: () => (
<div className="min-h-72 p-4">
<CreateFromPicker
repoId={repo.id}
repoMap={new Map([[repo.id, repo]])}
worktrees={[]}
value=""
onValueChange={() => {}}
/>
</div>
),
interact: async (frame) => {
frame.querySelector<HTMLButtonElement>('button[role="combobox"]')?.click()
await new Promise<void>((resolve, reject) => {
if (document.querySelectorAll('[cmdk-item]').length >= 3) {
return resolve()
}
const timeout = setTimeout(() => {
observer.disconnect()
reject(new Error('Branch fixture did not load'))
}, 5000)
const observer = new MutationObserver(() => {
if (document.querySelectorAll('[cmdk-item]').length >= 3) {
observer.disconnect()
clearTimeout(timeout)
resolve()
}
})
observer.observe(document.body, { childList: true, subtree: true })
})
}
})
@@ -0,0 +1,146 @@
// @vitest-environment happy-dom
import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import { CreateFromPicker } from './CreateFromPicker'
import { TooltipProvider } from '../ui/tooltip'
import { makeWorktree } from '../../store/slices/worktrees/worktree.test-fixture'
const refs = vi.hoisted(() => ({
defaultRef: 'origin/main',
values: ['refs/heads/origin/main', 'refs/remotes/origin/main']
}))
vi.mock('@/runtime/source-control/references/runtime-repo-client', () => ({
getRuntimeRepoBaseRefDefault: async () => ({ defaultBaseRef: refs.defaultRef, remoteCount: 1 }),
searchRuntimeRepoBaseRefs: async () => refs.values
}))
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
refs.defaultRef = 'origin/main'
refs.values = ['refs/heads/origin/main', 'refs/remotes/origin/main']
})
it('keeps deferred search focus through typing and cancels it when the picker closes', async () => {
const frames = new Map<number, FrameRequestCallback>()
let nextFrame = 0
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
frames.set(++nextFrame, callback)
return nextFrame
})
vi.stubGlobal('cancelAnimationFrame', (id: number) => frames.delete(id))
const flushFrames = () => {
const pending = [...frames.values()]
frames.clear()
act(() => pending.forEach((callback) => callback(0)))
}
const repo = { id: 'repo', path: '/repo', displayName: 'Repo', badgeColor: '', addedAt: 0 }
const view = render(
<TooltipProvider>
<CreateFromPicker
repoId="repo"
repoMap={new Map([['repo', repo]])}
worktrees={[]}
value=""
onValueChange={() => {}}
/>
</TooltipProvider>
)
const trigger = view.getByRole('combobox')
fireEvent.click(trigger)
const input = await view.findByPlaceholderText('Search repo branches...')
fireEvent.change(input, { target: { value: 'origin' } })
flushFrames()
expect(document.activeElement).toBe(input)
fireEvent.click(trigger)
fireEvent.click(trigger)
const reopened = await view.findByPlaceholderText('Search repo branches...')
const focus = vi.spyOn(reopened, 'focus')
fireEvent.click(trigger)
flushFrames()
expect(focus).not.toHaveBeenCalled()
})
it('uses the latest project default and default action after a rerender', async () => {
refs.values = ['refs/heads/main', 'refs/heads/topic']
refs.defaultRef = 'main'
const initial = vi.fn()
const latest = vi.fn()
const repo = { id: 'repo', path: '/repo', displayName: 'Repo', badgeColor: '', addedAt: 0 }
const picker = (defaultRef: string, onSetDefault: (branch: string) => void) => (
<TooltipProvider>
<CreateFromPicker
repoId="repo"
repoMap={new Map([['repo', { ...repo, worktreeBaseRef: defaultRef }]])}
worktrees={[]}
value=""
onValueChange={() => {}}
onSetDefault={onSetDefault}
/>
</TooltipProvider>
)
const view = render(picker('refs/heads/main', initial))
fireEvent.click(view.getByRole('combobox'))
await view.findByRole('option', { name: 'topic' })
view.rerender(picker('refs/heads/topic', latest))
fireEvent.contextMenu(view.getByRole('option', { name: 'topic (default)' }))
expect(
(await view.findByRole('menuitem', { name: 'Current default' })).getAttribute('aria-disabled')
).toBe('true')
fireEvent.keyDown(view.getByRole('menu'), { key: 'Escape' })
await waitFor(() => expect(view.queryByRole('menu')).toBeNull())
fireEvent.contextMenu(view.getByRole('option', { name: 'main' }))
fireEvent.click(view.getByRole('menuitem', { name: 'Set as default' }))
expect(initial).not.toHaveBeenCalled()
expect(latest).toHaveBeenCalledWith('refs/heads/main')
})
it('keeps the colliding local branch selectable beside a remote project default', async () => {
const selected = vi.fn()
const repo = { id: 'repo', path: '/repo', displayName: 'Repo', badgeColor: '', addedAt: 0 }
const view = render(
<TooltipProvider>
<CreateFromPicker
repoId="repo"
repoMap={new Map([['repo', repo]])}
worktrees={[]}
value=""
onValueChange={selected}
/>
</TooltipProvider>
)
fireEvent.click(view.getByRole('combobox'))
const local = await view.findByRole('option', { name: 'refs/heads/origin/main' })
expect(view.getByRole('option', { name: 'origin/main (default)' })).toBeTruthy()
fireEvent.click(local)
expect(selected).toHaveBeenCalledWith('refs/heads/origin/main')
})
it.each(['main', 'origin/main'])(
'merges an older server default %s without changing its action',
async (ref) => {
refs.defaultRef = ref
refs.values = [ref, 'topic']
const selected = vi.fn()
const repo = { id: 'repo', path: '/repo', displayName: 'Repo', badgeColor: '', addedAt: 0 }
const view = render(
<TooltipProvider>
<CreateFromPicker
repoId="repo"
repoMap={new Map([['repo', repo]])}
worktrees={
ref === 'main' ? [makeWorktree({ id: 'main', repoId: 'repo', branch: 'main' })] : []
}
value=""
onValueChange={selected}
/>
</TooltipProvider>
)
fireEvent.click(view.getByRole('combobox'))
const topic = await view.findByRole('option', { name: 'topic' })
expect(view.getAllByRole('option')).toHaveLength(2)
expect(view.getByRole('option', { name: `${ref} (default)` })).toBeTruthy()
fireEvent.click(topic)
expect(selected).toHaveBeenCalledWith('topic')
}
)
@@ -16,6 +16,11 @@ import {
} from '@/components/ui/context-menu'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { cn } from '@/lib/class-names'
import {
branchRefLabel,
branchRefLabels,
probedDefaultBranchRef
} from '../../../../shared/git/refs/branch-ref-label'
import type { Repo } from '../../../../shared/repo-types'
import type { Worktree } from '../../../../shared/worktree/types'
import { getRepoExecutionHostId } from '../../../../shared/execution-host'
@@ -24,14 +29,40 @@ import {
searchRuntimeRepoBaseRefs
} from '@/runtime/source-control/references/runtime-repo-client'
import { isRuntimeRepoRefSearchQueryWithinLimit } from '@/runtime/source-control/references/runtime-repo-search-bounds'
import { translate } from '@/i18n/i18n'
import { useTranslate } from '@/i18n/use-translate'
import { FilePathCursorTooltip } from '@/components/file-path-cursor-tooltip'
const DEFAULT_VALUE = '__project_default__'
function displayBranchName(branch: string): string {
return branch.replace(/^refs\/heads\//, '')
function createBranchOptions(
effectiveDefault: string | null,
searchResults: readonly string[],
worktrees: readonly Worktree[]
) {
const options = new Set<string>()
if (effectiveDefault) {
options.add(effectiveDefault)
}
for (const worktree of worktrees) {
const branch = worktree.branch.trim().replace(/^refs\/heads\//, '')
if (branch) {
options.add(searchResults.includes(branch) ? branch : `refs/heads/${branch}`)
}
}
for (const branch of searchResults) {
options.add(branch)
}
const merged = Array.from(options)
const labels = branchRefLabels(merged)
merged.sort((left, right) => (labels.get(left) ?? left).localeCompare(labels.get(right) ?? right))
return { options: merged, labels }
}
function cancelFocusFrame(frame: { current: number | null }): void {
if (frame.current !== null) {
cancelAnimationFrame(frame.current)
frame.current = null
}
}
export function CreateFromPicker({
@@ -66,93 +97,77 @@ export function CreateFromPicker({
const [searchResults, setSearchResults] = React.useState<string[]>([])
const [isSearching, setIsSearching] = React.useState(false)
const listId = React.useId()
const effectiveDefault = repo?.worktreeBaseRef ?? defaultBaseRef
const effectiveDefault =
repo?.worktreeBaseRef ?? probedDefaultBranchRef(defaultBaseRef, searchResults)
const selectedValue = value || DEFAULT_VALUE
const projectDefaultLabel = t(
'auto.components.automations.CreateFromPicker.ef6d762538',
'Project default'
)
const selectedLabel =
value || (effectiveDefault ? `${effectiveDefault} (default)` : projectDefaultLabel)
const compactLabel = value || effectiveDefault || projectDefaultLabel
const branchOptions = React.useMemo(() => {
const options = new Set<string>()
if (effectiveDefault) {
options.add(effectiveDefault)
}
for (const worktree of worktrees) {
const branch = displayBranchName(worktree.branch).trim()
if (branch) {
options.add(branch)
}
}
for (const branch of searchResults) {
options.add(branch)
}
return Array.from(options).sort((left, right) => left.localeCompare(right))
}, [effectiveDefault, searchResults, worktrees])
const renderBranchContextMenu = React.useCallback(
(branch: string, row: React.ReactNode): React.ReactNode => {
if (!onSetDefault || !branch) {
return row
}
const isDefault = branch === effectiveDefault
return (
<ContextMenu key={branch}>
<ContextMenuTrigger asChild>{row}</ContextMenuTrigger>
<ContextMenuContent className="z-[70]">
<ContextMenuItem
disabled={isDefault}
onSelect={() => {
void onSetDefault(branch)
}}
>
<Star className="size-3.5" />
{isDefault
? translate('auto.components.agent.AgentCombobox.1b0d6965fa', 'Current default')
: translate('auto.components.agent.AgentCombobox.9c6b59fe58', 'Set as default')}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
},
[effectiveDefault, onSetDefault]
(value ? branchRefLabel(value) : null) ||
(effectiveDefault ? `${branchRefLabel(effectiveDefault)} (default)` : projectDefaultLabel)
const compactLabel = value
? branchRefLabel(value)
: effectiveDefault
? branchRefLabel(effectiveDefault)
: projectDefaultLabel
const { options: branchOptions, labels } = React.useMemo(
() => createBranchOptions(effectiveDefault, searchResults, worktrees),
[effectiveDefault, searchResults, worktrees]
)
const cancelFocusFrame = React.useCallback((): void => {
if (focusFrameRef.current !== null) {
cancelAnimationFrame(focusFrameRef.current)
focusFrameRef.current = null
function renderBranchContextMenu(branch: string, row: React.ReactNode): React.ReactNode {
if (!onSetDefault || !branch) {
return row
}
}, [])
const isDefault = effectiveDefault === branch
return (
<ContextMenu key={branch}>
<ContextMenuTrigger asChild>{row}</ContextMenuTrigger>
<ContextMenuContent className="z-[70]">
<ContextMenuItem
disabled={isDefault}
onSelect={() => {
void onSetDefault(branch)
}}
>
<Star className="size-3.5" />
{isDefault
? t('auto.components.agent.AgentCombobox.1b0d6965fa', 'Current default')
: t('auto.components.agent.AgentCombobox.9c6b59fe58', 'Set as default')}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
}
const setInputNode = React.useCallback(
(node: HTMLInputElement | null): void => {
if (node === null) {
cancelFocusFrame()
cancelFocusFrame(focusFrameRef)
}
inputRef.current = node
},
[cancelFocusFrame]
[focusFrameRef, inputRef]
)
const focusSearchInput = React.useCallback(() => {
cancelFocusFrame()
cancelFocusFrame(focusFrameRef)
focusFrameRef.current = requestAnimationFrame(() => {
focusFrameRef.current = null
inputRef.current?.focus()
})
}, [cancelFocusFrame])
}, [focusFrameRef, inputRef])
const handleOpenChange = React.useCallback(
(nextOpen: boolean) => {
setOpen(nextOpen)
if (!nextOpen) {
cancelFocusFrame()
cancelFocusFrame(focusFrameRef)
}
},
[cancelFocusFrame]
[focusFrameRef, setOpen]
)
React.useEffect(() => {
@@ -331,7 +346,7 @@ export function CreateFromPicker({
? t(
'auto.components.automations.CreateFromPicker.e53d306056',
'{{value0}} (default)',
{ value0: effectiveDefault }
{ value0: branchRefLabel(effectiveDefault) }
)
: t(
'auto.components.automations.CreateFromPicker.ef6d762538',
@@ -342,7 +357,7 @@ export function CreateFromPicker({
</CommandItem>
)}
{branchOptions
.filter((branch) => branch !== effectiveDefault)
.filter((branch) => !(effectiveDefault === branch))
.map((branch) =>
renderBranchContextMenu(
branch,
@@ -358,7 +373,7 @@ export function CreateFromPicker({
className={cn('size-4', value === branch ? 'opacity-100' : 'opacity-0')}
/>
<FilePathCursorTooltip path={branch}>
<span className="min-w-0 truncate">{branch}</span>
<span className="min-w-0 truncate">{labels.get(branch)}</span>
</FilePathCursorTooltip>
</CommandItem>
)
@@ -9,6 +9,11 @@ import {
} from '@/runtime/source-control/references/runtime-repo-client'
import { isRuntimeRepoRefSearchQueryWithinLimit } from '@/runtime/source-control/references/runtime-repo-search-bounds'
import { useTranslate } from '@/i18n/use-translate'
import {
branchRefLabel,
branchRefLabels,
probedDefaultBranchRef
} from '../../../../shared/git/refs/branch-ref-label'
import { getRepoExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host'
type BaseRefPickerProps = {
@@ -132,14 +137,15 @@ export function BaseRefPicker({
}
}, [baseRefQuery, ownerHostId, repoId, viewActive])
const effectiveBaseRef = currentBaseRef ?? defaultBaseRef
const effectiveBaseRef = currentBaseRef ?? probedDefaultBranchRef(defaultBaseRef, baseRefResults)
const labels = branchRefLabels(baseRefResults)
return (
<div className="space-y-2.5">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<div className="text-sm font-medium text-foreground">
{effectiveBaseRef ??
{(effectiveBaseRef ? branchRefLabel(effectiveBaseRef) : null) ??
t('auto.components.settings.BaseRefPicker.ee110e1830', 'No default base ref')}
</div>
<p className="text-xs text-muted-foreground">
@@ -149,7 +155,7 @@ export function BaseRefPicker({
? t(
'auto.components.settings.BaseRefPicker.086ce7f369',
'Following primary branch ({{value0}})',
{ value0: defaultBaseRef }
{ value0: branchRefLabel(defaultBaseRef) }
)
: t(
'auto.components.settings.BaseRefPicker.9a14ec7400',
@@ -204,6 +210,7 @@ export function BaseRefPicker({
{baseRefResults.map((ref) => (
<button
key={ref}
aria-current={effectiveBaseRef === ref ? 'true' : undefined}
onClick={() => {
// Return to rest after selection so search results cannot imply the new pin is still pending.
setBaseRefQuery('')
@@ -216,7 +223,7 @@ export function BaseRefPicker({
: 'text-foreground'
}`}
>
<span className="truncate">{ref}</span>
<span className="truncate">{labels.get(ref)}</span>
{effectiveBaseRef === ref ? (
<span className="text-[10px] uppercase tracking-[0.18em]">
{t('auto.components.settings.BaseRefPicker.d166ff883d', 'Current')}
@@ -1,20 +1,31 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render } from '@testing-library/react'
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
import { create } from 'zustand'
import { AppStateViewContext } from '@/store/retained-views/app-state-view'
import { BaseRefPicker } from './BaseRefPicker'
const refs = vi.hoisted<{ values: string[]; defaultRef: string | null }>(() => ({
values: [],
defaultRef: null
}))
vi.mock('@/runtime/source-control/references/runtime-repo-client', () => ({
watchRuntimeRepoBaseRefDefault: () => () => {},
searchRuntimeRepoBaseRefs: async () => []
watchRuntimeRepoBaseRefDefault: (_host, _repo, handlers) => {
handlers.onResult({ defaultBaseRef: refs.defaultRef, remoteCount: 1 })
return () => {}
},
searchRuntimeRepoBaseRefs: async () => refs.values
}))
const snapshot = useAppStore.getState()
const frozen = create(() => snapshot)
afterEach(cleanup)
afterEach(() => {
cleanup()
refs.values = []
refs.defaultRef = null
})
it('retains the branch search input when a section pauses and resumes observation', () => {
const picker = (
@@ -34,3 +45,30 @@ it('retains the branch search input when a section pauses and resumes observatio
)
expect(view.getByRole('textbox')).toHaveProperty('value', 'feature/unfinished')
})
it('distinguishes colliding actions and marks the remote identified by the default probe', async () => {
refs.defaultRef = 'origin/main'
refs.values = ['refs/heads/origin/main', 'refs/remotes/origin/main']
const select = vi.fn()
const view = render(<BaseRefPicker repoId="repo" hostId="local" onSelect={select} />)
fireEvent.change(view.getByRole('textbox'), { target: { value: 'origin/main' } })
const local = await view.findByRole('button', { name: 'refs/heads/origin/main' })
const remote = view.getByRole('button', { name: 'refs/remotes/origin/main Current' })
expect(local.textContent).toBe('refs/heads/origin/main')
expect(local.getAttribute('aria-current')).toBeNull()
expect(remote.getAttribute('aria-current')).toBe('true')
fireEvent.click(remote)
await waitFor(() => expect(select).toHaveBeenCalledWith('refs/remotes/origin/main'))
})
it.each(['main', 'origin/main'])('keeps an older server default marked for %s', async (ref) => {
refs.defaultRef = ref
refs.values = [ref]
const select = vi.fn()
const view = render(<BaseRefPicker repoId="repo" hostId="local" onSelect={select} />)
fireEvent.change(view.getByRole('textbox'), { target: { value: ref } })
const current = await view.findByRole('button', { name: `${ref} Current` })
expect(current.getAttribute('aria-current')).toBe('true')
fireEvent.click(current)
expect(select).toHaveBeenCalledWith(ref)
})
@@ -9,6 +9,21 @@ import {
} from './composer-branch-selection'
describe('resolveComposerBranchSelection', () => {
it('completes a remote-qualified typed prefix while retaining the canonical base', () => {
expect(
resolveComposerBranchSelection({
refName: 'refs/remotes/origin/feature/topic',
localBranchName: 'feature/topic',
currentName: 'origin/fea',
lastAutoName: ''
})
).toMatchObject({
baseBranch: 'refs/remotes/origin/feature/topic',
branchNameOverride: 'feature/topic',
name: 'feature/topic'
})
})
it('keeps selected remote ref as base while using the local branch name for create', () => {
expect(
resolveComposerBranchSelection({
+6
View File
@@ -3,6 +3,12 @@ import type { BaseRefSearchResult } from './repo-types'
const LEGACY_REMOTE_REF_PREFIXES = ['origin/', 'upstream/']
export function deriveLegacyLocalBranchName(refName: string): string {
if (refName.startsWith('refs/heads/')) {
return refName.slice('refs/heads/'.length)
}
if (refName.startsWith('refs/remotes/')) {
return refName.slice('refs/remotes/'.length).split('/').slice(1).join('/')
}
// Older runtimes return display refs only; strip common remote prefixes so origin/feature/foo cannot become the local name.
for (const prefix of LEGACY_REMOTE_REF_PREFIXES) {
if (refName.startsWith(prefix) && refName.length > prefix.length) {
+2 -1
View File
@@ -1,4 +1,5 @@
import type { GitPushTarget } from './worktree/types'
import { branchRefLabel } from './git/refs/branch-ref-label'
export type ComposerBranchSelection = {
baseBranch: string
@@ -19,7 +20,7 @@ export function resolveComposerBranchSelection(args: {
!trimmedCurrentName ||
args.currentName === args.lastAutoName ||
args.localBranchName.startsWith(trimmedCurrentName) ||
args.refName.startsWith(trimmedCurrentName)
branchRefLabel(args.refName).startsWith(trimmedCurrentName)
if (!shouldAutoName) {
return {
baseBranch: args.refName,
@@ -0,0 +1,12 @@
import { posix, win32 } from 'node:path'
import { foldWslUncPathCaseInsensitiveParts, isWslUncPath } from '../../wsl-paths'
/** Preserve POSIX bytes; comparison keys must never become filesystem paths. */
export function gitRepositoryPathKey(value: string): string {
if (isWslUncPath(value)) {
return foldWslUncPathCaseInsensitiveParts(win32.normalize(value)) ?? value
}
return /^[a-z]:[\\/]/i.test(value) || value.startsWith('\\\\')
? win32.normalize(value).toLowerCase()
: posix.normalize(value)
}
+33
View File
@@ -0,0 +1,33 @@
export function branchRefLabel(ref: string): string {
return ref.replace(/^refs\/(heads|remotes)\//, '')
}
/** Prefer the server's selector; otherwise the probe proves local main/master or a remote HEAD/fallback. */
export function probedDefaultBranchRef(
ref: string | null,
results: readonly string[] = []
): string | null {
if (!ref || ref.startsWith('refs/') || results.includes(ref)) {
return ref
}
return ref === 'main' || ref === 'master' ? `refs/heads/${ref}` : `refs/remotes/${ref}`
}
export function branchRefLabels(refs: readonly string[]): ReadonlyMap<string, string> {
const identities = new Map<string, Set<string>>()
for (const ref of refs) {
if (!/^refs\/(heads|remotes)\//.test(ref)) {
continue
}
const label = branchRefLabel(ref)
const existing = identities.get(label) ?? new Set<string>()
existing.add(ref)
identities.set(label, existing)
}
return new Map(
refs.map((ref) => {
const label = branchRefLabel(ref)
return [ref, (identities.get(label)?.size ?? 0) > 1 ? ref : label]
})
)
}
@@ -4,6 +4,7 @@ import type { JiraIssue } from '../jira/jira-types'
import type { LinearIssue } from '../linear/issue-types'
import type { LinearCollectionResult } from '../linear/workspace-types'
import type { BaseRefSearchResult } from '../repo-types'
import { branchRefLabel, branchRefLabels } from '../git/refs/branch-ref-label'
import { parseJiraIssueUrl } from '../jira/jira-issue-url'
import { buildJiraTextMatchJql } from '../jira/jira-search-input-jql'
import type { GitHubIssueOrPRLink } from '../github/repositories/links'
@@ -25,7 +26,7 @@ export type SmartWorkspaceSourceRow =
| { 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; localBranchName: string }
| { kind: 'branch'; value: string; refName: string; localBranchName: string; label?: string }
| { kind: 'linear'; value: string; issue: LinearIssue }
| { kind: 'jira'; value: string; issue: JiraIssue }
@@ -278,16 +279,20 @@ export function buildSmartWorkspaceSourceRows({
const shouldShowBranches = mode === 'branches' || (mode === 'smart' && trimmed.length > 0)
if (shouldShowBranches) {
const branchExactMatch = branches.some(
(branch) => branch.refName === trimmed || branch.localBranchName === trimmed
(branch) => branchRefLabel(branch.refName) === trimmed || branch.localBranchName === trimmed
)
if (trimmed && mode === 'branches' && !branchExactMatch) {
nextRows.push({ kind: 'create-branch', value: 'create-branch', name: trimmed })
}
const labels = branchRefLabels(branches.map((branch) => branch.refName))
nextRows.push(
...branches.map((branch) => ({
kind: 'branch' as const,
value: `branch-${branch.refName}`,
refName: branch.refName,
...(labels.get(branch.refName) !== branchRefLabel(branch.refName)
? { label: labels.get(branch.refName) }
: {}),
localBranchName: branch.localBranchName
}))
)
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest'
import { resolveComposerBranchSelection } from '../composer-branch-selection'
import {
buildLinearWorkspaceSource,
buildWorkspaceSourceSelection,
@@ -8,6 +9,23 @@ import {
} from './workspace-source'
describe('workspace source policy', () => {
it.each([
['refs/remotes/origin/topic', 'origin/topic'],
['refs/heads/topic', 'topic'],
['origin/topic', 'origin/topic']
])('shows selected branch %s without rewriting its action', (refName, label) => {
const selection = resolveComposerBranchSelection({
refName,
localBranchName: 'topic',
currentName: '',
lastAutoName: ''
})
expect(
buildWorkspaceSourceSelection({ linkedWorkItem: null, baseBranch: selection.baseBranch })
).toEqual({ kind: 'branch', label })
expect(selection.baseBranch).toBe(refName)
})
it.each(['347', '002', '#347', 'https://github.com/fixture/repo/issues/347'])(
'keeps manual name %s after the query field becomes a selected source',
(currentName) => {
+2 -1
View File
@@ -1,4 +1,5 @@
import { getLinearOrganizationUrlKeyFromIssueUrl } from '../linear/links'
import { branchRefLabel } from '../git/refs/branch-ref-label'
import type { WorkspaceLinkedItem as FolderWorkspaceLinkedTask } from '../worktree/types'
import type { JiraIssue } from '../jira/jira-types'
import type { LinearIssue } from '../linear/issue-types'
@@ -191,7 +192,7 @@ export function buildWorkspaceSourceSelection(args: {
}): WorkspaceSourceSelection | null {
const { linkedWorkItem, baseBranch } = args
if (!linkedWorkItem) {
return baseBranch ? { kind: 'branch', label: baseBranch } : null
return baseBranch ? { kind: 'branch', label: branchRefLabel(baseBranch) } : null
}
const provider = getWorkspaceSourceProvider(linkedWorkItem)
const kind: WorkspaceSourceSelectionKind =
@@ -1,5 +1,25 @@
import { describe, expect, it } from 'vitest'
import { isRemoteHeadRef } from './hosted-review-refs'
import { isRemoteHeadRef, normalizeCreateReviewBaseSearchResults } from './hosted-review-refs'
import { legacyBaseRefSearchResult } from '../../base-ref-search-result'
it('normalizes old-server refs-only replies before treating provider details as authoritative', () => {
expect(
normalizeCreateReviewBaseSearchResults(
['refs/heads/feature/topic', 'refs/remotes/origin/main', 'origin/topic'].map(
legacyBaseRefSearchResult
)
)
).toEqual(['feature/topic', 'main', 'topic'])
})
it('keeps authoritative provider branch names from canonical search details', () => {
expect(
normalizeCreateReviewBaseSearchResults([
{ refName: 'refs/heads/origin/topic', localBranchName: 'origin/topic' },
{ refName: 'refs/remotes/team/fork/release/topic', localBranchName: 'release/topic' }
])
).toEqual(['origin/topic', 'release/topic'])
})
describe('isRemoteHeadRef', () => {
it('recognizes only a remote symbolic HEAD slot', () => {
@@ -36,7 +36,10 @@ export function normalizeCreateReviewBaseSearchResults(
const branches: string[] = []
for (const result of results) {
// APIs need branch names; detailed search resolves remote-qualified refs, including slash-containing remote names.
const branch = normalizeHostedReviewBaseRef((result.localBranchName || result.refName).trim())
const branch =
result.refName.startsWith('refs/') && result.localBranchName
? result.localBranchName.trim()
: normalizeHostedReviewBaseRef((result.localBranchName || result.refName).trim())
if (!branch || seen.has(branch)) {
continue
}