mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 10:27:33 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
+6
-7
@@ -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({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user