From 91b3f12f03084f6aaa6483552943cfec1d4282a8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 12 Nov 2026 15:12:55 -0800 Subject: [PATCH] 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 --- .../SmartWorkspaceSourceRow.test.tsx | 45 +++ .../components/SmartWorkspaceSourceRow.tsx | 6 +- .../smart-source-search-requests.ts | 6 +- .../source-control/mobile-pr-create-draft.ts | 3 +- .../mobile-tasks-workspace-create-drawer.tsx | 7 +- .../mobile-tasks-workspace-option-pickers.tsx | 6 +- ...-mobile-tasks-workspace-source-effects.tsx | 4 +- src/cli/output/command-result-output.test.ts | 13 + src/cli/projects/repo-format.ts | 5 +- src/cli/runtime/websocket-transport.ts | 2 + src/main/git/command-runner/git-exec-file.ts | 4 + .../git-ref-mutation-state.test.ts | 88 +++++ .../command-runner/git-ref-mutation-state.ts | 23 ++ .../git/command-runner/git-stream-stdout.ts | 4 +- src/main/git/refs/git-ref-change-signal.ts | 44 +++ .../base-ref-search-selector.test.ts | 21 -- .../refs/resolution/repo-base-ref-search.ts | 162 +-------- .../refs/resolution/repo-branch-conflict.ts | 2 +- .../refs/resolution/repo-legacy-ref-search.ts | 170 ++++++++++ .../refs/resolution/repo-remote-ref-search.ts | 47 ++- .../refs/snapshot/branch-ref-snapshot-data.ts | 117 +++++++ .../snapshot/branch-ref-snapshot-identity.ts | 61 ++++ .../snapshot/branch-ref-snapshot-metadata.ts | 52 +++ .../snapshot/branch-ref-snapshot-owners.ts | 174 ++++++++++ .../snapshot/branch-ref-snapshot-read.test.ts | 39 +++ .../refs/snapshot/branch-ref-snapshot-read.ts | 111 ++++++ .../branch-ref-snapshot-real-git.test.ts | 302 ++++++++++++++++ .../branch-ref-snapshot-routing.test.ts | 105 ++++++ .../branch-ref-snapshot-store.test.ts | 321 ++++++++++++++++++ .../snapshot/branch-ref-snapshot-store.ts | 174 ++++++++++ .../git/refs/snapshot/branch-ref-snapshot.ts | 80 +++++ src/main/git/remotes/repo-remote-queries.ts | 14 +- .../branch-switch-target-real-git.test.ts | 23 +- src/main/git/worktrees/checkout/checkout.ts | 71 ++-- .../runtime-repository-ref-queries.ts | 2 +- ...ewWorkspaceComposerNameSection.fixture.tsx | 13 +- .../smart-workspace-source-row-content.tsx | 7 +- .../repo/CreateFromPicker.fixture.tsx | 55 +++ .../components/repo/CreateFromPicker.test.tsx | 146 ++++++++ .../src/components/repo/CreateFromPicker.tsx | 141 ++++---- .../source-control/BaseRefPicker.tsx | 15 +- .../BaseRefPicker.visibility.test.tsx | 46 ++- .../hooks/composer-branch-selection.test.ts | 15 + src/shared/base-ref-search-result.ts | 6 + src/shared/composer-branch-selection.ts | 3 +- .../git/metadata/git-repository-path-key.ts | 12 + src/shared/git/refs/branch-ref-label.ts | 33 ++ .../smart-workspace-source-results.ts | 9 +- .../new-workspace/workspace-source.test.ts | 18 + src/shared/new-workspace/workspace-source.ts | 3 +- .../hosted-review/hosted-review-refs.test.ts | 22 +- .../hosted-review/hosted-review-refs.ts | 5 +- 52 files changed, 2492 insertions(+), 365 deletions(-) create mode 100644 mobile/src/components/SmartWorkspaceSourceRow.test.tsx create mode 100644 src/main/git/command-runner/git-ref-mutation-state.test.ts create mode 100644 src/main/git/command-runner/git-ref-mutation-state.ts create mode 100644 src/main/git/refs/git-ref-change-signal.ts create mode 100644 src/main/git/refs/resolution/repo-legacy-ref-search.ts create mode 100644 src/main/git/refs/snapshot/branch-ref-snapshot-data.ts create mode 100644 src/main/git/refs/snapshot/branch-ref-snapshot-identity.ts create mode 100644 src/main/git/refs/snapshot/branch-ref-snapshot-metadata.ts create mode 100644 src/main/git/refs/snapshot/branch-ref-snapshot-owners.ts create mode 100644 src/main/git/refs/snapshot/branch-ref-snapshot-read.test.ts create mode 100644 src/main/git/refs/snapshot/branch-ref-snapshot-read.ts create mode 100644 src/main/git/refs/snapshot/branch-ref-snapshot-real-git.test.ts create mode 100644 src/main/git/refs/snapshot/branch-ref-snapshot-routing.test.ts create mode 100644 src/main/git/refs/snapshot/branch-ref-snapshot-store.test.ts create mode 100644 src/main/git/refs/snapshot/branch-ref-snapshot-store.ts create mode 100644 src/main/git/refs/snapshot/branch-ref-snapshot.ts create mode 100644 src/renderer/src/components/repo/CreateFromPicker.fixture.tsx create mode 100644 src/renderer/src/components/repo/CreateFromPicker.test.tsx create mode 100644 src/shared/git/metadata/git-repository-path-key.ts create mode 100644 src/shared/git/refs/branch-ref-label.ts diff --git a/mobile/src/components/SmartWorkspaceSourceRow.test.tsx b/mobile/src/components/SmartWorkspaceSourceRow.test.tsx new file mode 100644 index 00000000000..c3f37566639 --- /dev/null +++ b/mobile/src/components/SmartWorkspaceSourceRow.test.tsx @@ -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' } + }) +}) diff --git a/mobile/src/components/SmartWorkspaceSourceRow.tsx b/mobile/src/components/SmartWorkspaceSourceRow.tsx index b97379e6128..3af8c135f86 100644 --- a/mobile/src/components/SmartWorkspaceSourceRow.tsx +++ b/mobile/src/components/SmartWorkspaceSourceRow.tsx @@ -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: , - 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 { diff --git a/mobile/src/new-workspace/source-selection/smart-source-search-requests.ts b/mobile/src/new-workspace/source-selection/smart-source-search-requests.ts index a422f5b8692..ea998a34180 100644 --- a/mobile/src/new-workspace/source-selection/smart-source-search-requests.ts +++ b/mobile/src/new-workspace/source-selection/smart-source-search-requests.ts @@ -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) } diff --git a/mobile/src/source-control/mobile-pr-create-draft.ts b/mobile/src/source-control/mobile-pr-create-draft.ts index d2431ffabde..ae5f965dc7e 100644 --- a/mobile/src/source-control/mobile-pr-create-draft.ts +++ b/mobile/src/source-control/mobile-pr-create-draft.ts @@ -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) ) } diff --git a/mobile/src/tasks/workspace-creation/mobile-tasks-workspace-create-drawer.tsx b/mobile/src/tasks/workspace-creation/mobile-tasks-workspace-create-drawer.tsx index 83fb92aa837..dcbc1333ac3 100644 --- a/mobile/src/tasks/workspace-creation/mobile-tasks-workspace-create-drawer.tsx +++ b/mobile/src/tasks/workspace-creation/mobile-tasks-workspace-create-drawer.tsx @@ -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) }} /> diff --git a/mobile/src/tasks/workspace-creation/mobile-tasks-workspace-option-pickers.tsx b/mobile/src/tasks/workspace-creation/mobile-tasks-workspace-option-pickers.tsx index 768d0cce684..04a5632b933 100644 --- a/mobile/src/tasks/workspace-creation/mobile-tasks-workspace-option-pickers.tsx +++ b/mobile/src/tasks/workspace-creation/mobile-tasks-workspace-option-pickers.tsx @@ -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 ( ( ({ refName, localBranchName: refName })) + result.refDetails ?? (result.refs ?? []).map(legacyBaseRefSearchResult) ) }) .catch((err) => { diff --git a/src/cli/output/command-result-output.test.ts b/src/cli/output/command-result-output.test.ts index 261c32738e5..dbfd6d5a44a 100644 --- a/src/cli/output/command-result-output.test.ts +++ b/src/cli/output/command-result-output.test.ts @@ -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 diff --git a/src/cli/projects/repo-format.ts b/src/cli/projects/repo-format.ts index b9961d9b553..6f2d75af2e2 100644 --- a/src/cli/projects/repo-format.ts +++ b/src/cli/projects/repo-format.ts @@ -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 } diff --git a/src/cli/runtime/websocket-transport.ts b/src/cli/runtime/websocket-transport.ts index d68d2ebf01b..e79ac676dff 100644 --- a/src/cli/runtime/websocket-transport.ts +++ b/src/cli/runtime/websocket-transport.ts @@ -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 diff --git a/src/main/git/command-runner/git-exec-file.ts b/src/main/git/command-runner/git-exec-file.ts index bdfa4412410..eb7865fac23 100644 --- a/src/main/git/command-runner/git-exec-file.ts +++ b/src/main/git/command-runner/git-exec-file.ts @@ -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 | 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() } }) } diff --git a/src/main/git/command-runner/git-ref-mutation-state.test.ts b/src/main/git/command-runner/git-ref-mutation-state.test.ts new file mode 100644 index 00000000000..ba65218930c --- /dev/null +++ b/src/main/git/command-runner/git-ref-mutation-state.test.ts @@ -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()), + 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((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(setImmediate) + expect(events).toEqual(['a', 'b', 'new']) +}) diff --git a/src/main/git/command-runner/git-ref-mutation-state.ts b/src/main/git/command-runner/git-ref-mutation-state.ts new file mode 100644 index 00000000000..06209798328 --- /dev/null +++ b/src/main/git/command-runner/git-ref-mutation-state.ts @@ -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) +} diff --git a/src/main/git/command-runner/git-stream-stdout.ts b/src/main/git/command-runner/git-stream-stdout.ts index d501e1c3eb0..940b5e6371f 100644 --- a/src/main/git/command-runner/git-stream-stdout.ts +++ b/src/main/git/command-runner/git-stream-stdout.ts @@ -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 diff --git a/src/main/git/refs/git-ref-change-signal.ts b/src/main/git/refs/git-ref-change-signal.ts new file mode 100644 index 00000000000..9cd7f2c9e28 --- /dev/null +++ b/src/main/git/refs/git-ref-change-signal.ts @@ -0,0 +1,44 @@ +import { gitRepositoryPathKey } from '../../../shared/git/metadata/git-repository-path-key' +type Listener = () => void +const listeners = new Map>() + +export function subscribeGitRefChanges(commonDir: string, listener: Listener): () => void { + commonDir = gitRepositoryPathKey(commonDir) + const subscribers = listeners.get(commonDir) ?? new Set() + 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) + } +} diff --git a/src/main/git/refs/resolution/base-ref-search-selector.test.ts b/src/main/git/refs/resolution/base-ref-search-selector.test.ts index 86b9adeca68..d5b94a9ae6b 100644 --- a/src/main/git/refs/resolution/base-ref-search-selector.test.ts +++ b/src/main/git/refs/resolution/base-ref-search-selector.test.ts @@ -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' } - ]) - }) }) diff --git a/src/main/git/refs/resolution/repo-base-ref-search.ts b/src/main/git/refs/resolution/repo-base-ref-search.ts index 4aed3dd18f4..59e17cb9180 100644 --- a/src/main/git/refs/resolution/repo-base-ref-search.ts +++ b/src/main/git/refs/resolution/repo-base-ref-search.ts @@ -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() - 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 { 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() - 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, '') -} diff --git a/src/main/git/refs/resolution/repo-branch-conflict.ts b/src/main/git/refs/resolution/repo-branch-conflict.ts index 0d6efa0ca93..973e3225ae9 100644 --- a/src/main/git/refs/resolution/repo-branch-conflict.ts +++ b/src/main/git/refs/resolution/repo-branch-conflict.ts @@ -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' diff --git a/src/main/git/refs/resolution/repo-legacy-ref-search.ts b/src/main/git/refs/resolution/repo-legacy-ref-search.ts new file mode 100644 index 00000000000..ca952513b24 --- /dev/null +++ b/src/main/git/refs/resolution/repo-legacy-ref-search.ts @@ -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() + 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 { + 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 { + 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() + 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) + } +} diff --git a/src/main/git/refs/resolution/repo-remote-ref-search.ts b/src/main/git/refs/resolution/repo-remote-ref-search.ts index 59cece9edb9..45837f6359f 100644 --- a/src/main/git/refs/resolution/repo-remote-ref-search.ts +++ b/src/main/git/refs/resolution/repo-remote-ref-search.ts @@ -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 } } diff --git a/src/main/git/refs/snapshot/branch-ref-snapshot-data.ts b/src/main/git/refs/snapshot/branch-ref-snapshot-data.ts new file mode 100644 index 00000000000..758d8f95613 --- /dev/null +++ b/src/main/git/refs/snapshot/branch-ref-snapshot-data.ts @@ -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, + 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) + } + } +} diff --git a/src/main/git/refs/snapshot/branch-ref-snapshot-identity.ts b/src/main/git/refs/snapshot/branch-ref-snapshot-identity.ts new file mode 100644 index 00000000000..b033ab634c9 --- /dev/null +++ b/src/main/git/refs/snapshot/branch-ref-snapshot-identity.ts @@ -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 { + 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 + } +} diff --git a/src/main/git/refs/snapshot/branch-ref-snapshot-metadata.ts b/src/main/git/refs/snapshot/branch-ref-snapshot-metadata.ts new file mode 100644 index 00000000000..77c393f4bdd --- /dev/null +++ b/src/main/git/refs/snapshot/branch-ref-snapshot-metadata.ts @@ -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 { + const owner = this.cache.owner(identity) + return owner + ? this.context(owner, identity).remotes + : readBranchRemoteNames(identity.cwd, identity.git) + } +} diff --git a/src/main/git/refs/snapshot/branch-ref-snapshot-owners.ts b/src/main/git/refs/snapshot/branch-ref-snapshot-owners.ts new file mode 100644 index 00000000000..0d4685e46fd --- /dev/null +++ b/src/main/git/refs/snapshot/branch-ref-snapshot-owners.ts @@ -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 + leases: Set +} +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 } + contexts: Map + base?: Lease +} +export type BranchSnapshotSignals = { + subscribe(commonDir: string, listener: () => void): () => void +} + +export class BranchSnapshotOwners { + private readonly owners = new Map() + 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 { + 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) + } + } +} diff --git a/src/main/git/refs/snapshot/branch-ref-snapshot-read.test.ts b/src/main/git/refs/snapshot/branch-ref-snapshot-read.test.ts new file mode 100644 index 00000000000..419d56412a1 --- /dev/null +++ b/src/main/git/refs/snapshot/branch-ref-snapshot-read.test.ts @@ -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' + ]) +}) diff --git a/src/main/git/refs/snapshot/branch-ref-snapshot-read.ts b/src/main/git/refs/snapshot/branch-ref-snapshot-read.ts new file mode 100644 index 00000000000..1ebae032216 --- /dev/null +++ b/src/main/git/refs/snapshot/branch-ref-snapshot-read.ts @@ -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 { + 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 { + 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) + } +} diff --git a/src/main/git/refs/snapshot/branch-ref-snapshot-real-git.test.ts b/src/main/git/refs/snapshot/branch-ref-snapshot-real-git.test.ts new file mode 100644 index 00000000000..65969258625 --- /dev/null +++ b/src/main/git/refs/snapshot/branch-ref-snapshot-real-git.test.ts @@ -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' } + ]) +}) diff --git a/src/main/git/refs/snapshot/branch-ref-snapshot-routing.test.ts b/src/main/git/refs/snapshot/branch-ref-snapshot-routing.test.ts new file mode 100644 index 00000000000..f4947725675 --- /dev/null +++ b/src/main/git/refs/snapshot/branch-ref-snapshot-routing.test.ts @@ -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()), + listRunningWslDistrosAsync: async () => ['Ubuntu'] +})) +vi.mock('node:fs/promises', async (original) => ({ + ...(await original()), + 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()), + 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() + } +}) diff --git a/src/main/git/refs/snapshot/branch-ref-snapshot-store.test.ts b/src/main/git/refs/snapshot/branch-ref-snapshot-store.test.ts new file mode 100644 index 00000000000..d0bea0134d6 --- /dev/null +++ b/src/main/git/refs/snapshot/branch-ref-snapshot-store.test.ts @@ -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 => []) +})) +vi.mock('./branch-ref-snapshot-read', async (original) => ({ + ...(await original()), + 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() { + let resolve!: (value: T) => void + let reject!: (reason: unknown) => void + const promise = new Promise((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() + const old = deferred>() + 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() + const old = deferred>() + 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() + const old = deferred>() + 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(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() + const old = deferred>() + 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(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() + const held = deferred() + 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() + const fill = deferred>() + 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) => { + 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() + const old = deferred>() + 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) +}) diff --git a/src/main/git/refs/snapshot/branch-ref-snapshot-store.ts b/src/main/git/refs/snapshot/branch-ref-snapshot-store.ts new file mode 100644 index 00000000000..09e30addd0e --- /dev/null +++ b/src/main/git/refs/snapshot/branch-ref-snapshot-store.ts @@ -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 { + 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 => { + 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 { + 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 { + 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) +} diff --git a/src/main/git/refs/snapshot/branch-ref-snapshot.ts b/src/main/git/refs/snapshot/branch-ref-snapshot.ts new file mode 100644 index 00000000000..71317810d8e --- /dev/null +++ b/src/main/git/refs/snapshot/branch-ref-snapshot.ts @@ -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( + path: string, + git: LocalGitExecOptions, + read: (identity: BranchSnapshotIdentity) => Promise +): Promise { + 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 { + return withIdentity(path, git, (identity) => snapshots.search(identity, query, limit, remoteOnly)) +} + +export function snapshotRemoteNames( + path: string, + git: LocalGitExecOptions = {} +): Promise { + 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() + 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() +} diff --git a/src/main/git/remotes/repo-remote-queries.ts b/src/main/git/remotes/repo-remote-queries.ts index 4de9c4e5cef..fd66a1f14e8 100644 --- a/src/main/git/remotes/repo-remote-queries.ts +++ b/src/main/git/remotes/repo-remote-queries.ts @@ -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 { 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' diff --git a/src/main/git/worktrees/checkout/branch-switch-target-real-git.test.ts b/src/main/git/worktrees/checkout/branch-switch-target-real-git.test.ts index 1a2b1d22279..47b7d4301da 100644 --- a/src/main/git/worktrees/checkout/branch-switch-target-real-git.test.ts +++ b/src/main/git/worktrees/checkout/branch-switch-target-real-git.test.ts @@ -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() diff --git a/src/main/git/worktrees/checkout/checkout.ts b/src/main/git/worktrees/checkout/checkout.ts index bd1b0626701..9cc162159df 100644 --- a/src/main/git/worktrees/checkout/checkout.ts +++ b/src/main/git/worktrees/checkout/checkout.ts @@ -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 { - 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 = Object.create(null) const worktreePaths: Record = 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 } } diff --git a/src/main/runtime/repositories/runtime-repository-ref-queries.ts b/src/main/runtime/repositories/runtime-repository-ref-queries.ts index c47baeb4fc8..c5151d72932 100644 --- a/src/main/runtime/repositories/runtime-repository-ref-queries.ts +++ b/src/main/runtime/repositories/runtime-repository-ref-queries.ts @@ -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. diff --git a/src/renderer/src/components/new-workspace/NewWorkspaceComposerNameSection.fixture.tsx b/src/renderer/src/components/new-workspace/NewWorkspaceComposerNameSection.fixture.tsx index 89440567c0d..e489aa76369 100644 --- a/src/renderer/src/components/new-workspace/NewWorkspaceComposerNameSection.fixture.tsx +++ b/src/renderer/src/components/new-workspace/NewWorkspaceComposerNameSection.fixture.tsx @@ -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(initialBranchName) const [name, setName] = useState('Feature review') + const baseBranch = remote + ? `refs/remotes/origin/${existingBranch}` + : `refs/heads/${existingBranch}` return (
{}} 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={() => {}} />
diff --git a/src/renderer/src/components/new-workspace/smart-workspace-source-row-content.tsx b/src/renderer/src/components/new-workspace/smart-workspace-source-row-content.tsx index 491faeb5873..e22af1ec0e2 100644 --- a/src/renderer/src/components/new-workspace/smart-workspace-source-row-content.tsx +++ b/src/renderer/src/components/new-workspace/smart-workspace-source-row-content.tsx @@ -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 {row.refName} + return ( + + {row.label ?? branchRefLabel(row.refName)} + + ) } if (row.kind === 'jira') { const siteLabel = jiraSite diff --git a/src/renderer/src/components/repo/CreateFromPicker.fixture.tsx b/src/renderer/src/components/repo/CreateFromPicker.fixture.tsx new file mode 100644 index 00000000000..91030005d82 --- /dev/null +++ b/src/renderer/src/components/repo/CreateFromPicker.fixture.tsx @@ -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: () => ( +
+ {}} + /> +
+ ), + interact: async (frame) => { + frame.querySelector('button[role="combobox"]')?.click() + await new Promise((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 }) + }) + } +}) diff --git a/src/renderer/src/components/repo/CreateFromPicker.test.tsx b/src/renderer/src/components/repo/CreateFromPicker.test.tsx new file mode 100644 index 00000000000..f842851a9cc --- /dev/null +++ b/src/renderer/src/components/repo/CreateFromPicker.test.tsx @@ -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() + 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( + + {}} + /> + + ) + 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) => ( + + {}} + onSetDefault={onSetDefault} + /> + + ) + 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( + + + + ) + 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( + + + + ) + 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') + } +) diff --git a/src/renderer/src/components/repo/CreateFromPicker.tsx b/src/renderer/src/components/repo/CreateFromPicker.tsx index 2df1c6440ee..d5636ae517b 100644 --- a/src/renderer/src/components/repo/CreateFromPicker.tsx +++ b/src/renderer/src/components/repo/CreateFromPicker.tsx @@ -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() + 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([]) 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() - 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 ( - - {row} - - { - void onSetDefault(branch) - }} - > - - {isDefault - ? translate('auto.components.agent.AgentCombobox.1b0d6965fa', 'Current default') - : translate('auto.components.agent.AgentCombobox.9c6b59fe58', 'Set as default')} - - - - ) - }, - [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 ( + + {row} + + { + void onSetDefault(branch) + }} + > + + {isDefault + ? t('auto.components.agent.AgentCombobox.1b0d6965fa', 'Current default') + : t('auto.components.agent.AgentCombobox.9c6b59fe58', 'Set as default')} + + + + ) + } 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({ )} {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')} /> - {branch} + {labels.get(branch)} ) diff --git a/src/renderer/src/components/source-control/BaseRefPicker.tsx b/src/renderer/src/components/source-control/BaseRefPicker.tsx index 3666fb45ee7..b0035657045 100644 --- a/src/renderer/src/components/source-control/BaseRefPicker.tsx +++ b/src/renderer/src/components/source-control/BaseRefPicker.tsx @@ -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 (
- {effectiveBaseRef ?? + {(effectiveBaseRef ? branchRefLabel(effectiveBaseRef) : null) ?? t('auto.components.settings.BaseRefPicker.ee110e1830', 'No default base ref')}

@@ -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) => (