From 6fbeb2abda6e987f459e452942aedbcec937ecb7 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 30 Jul 2026 05:00:39 -0700 Subject: [PATCH] perf: consume repository snapshots in checks --- .../git/git-repository-snapshot-owner.test.ts | 58 ++++++ .../git/git-repository-snapshot-projection.ts | 44 +---- src/main/ipc/filesystem.test.ts | 109 +++++++++++ src/main/ipc/filesystem.ts | 48 +++++ src/main/providers/ssh-git-provider.test.ts | 64 ++++++ src/preload/api-types.ts | 9 + src/preload/index.ts | 9 + .../components/right-sidebar/ChecksPanel.tsx | 51 +++-- .../checks-panel-git-status-snapshot.test.ts | 142 ++++++++++++++ .../checks-panel-git-status-snapshot.ts | 33 +++- ...top-git-repository-snapshot-client.test.ts | 91 +++++++++ .../desktop-git-repository-snapshot-client.ts | 36 ++++ src/shared/git-repository-snapshot.ts | 33 ++++ ...repository-snapshot-consumer-2026-07-30.md | 182 ++++++++++++++++++ 14 files changed, 863 insertions(+), 46 deletions(-) create mode 100644 src/renderer/src/runtime/desktop-git-repository-snapshot-client.test.ts create mode 100644 src/renderer/src/runtime/desktop-git-repository-snapshot-client.ts create mode 100644 src/shared/git-repository-snapshot.ts create mode 100644 tools/benchmarks/results/phase2-checks-repository-snapshot-consumer-2026-07-30.md diff --git a/src/main/git/git-repository-snapshot-owner.test.ts b/src/main/git/git-repository-snapshot-owner.test.ts index 88ebbeec826..3da392aced2 100644 --- a/src/main/git/git-repository-snapshot-owner.test.ts +++ b/src/main/git/git-repository-snapshot-owner.test.ts @@ -107,6 +107,64 @@ describe('GitRepositorySnapshotOwner', () => { expect(second?.freshness.upstream.revision).toBe(2) }) + it('reduces settled polling plus Checks demand from two physical loads to one per projection', async () => { + const pushTarget = { + remoteName: 'fork', + branchName: 'feature', + remoteUrl: 'ssh://git.example/repo', + remoteCreated: false + } + const upstreamResult = { + hasUpstream: true, + upstreamName: 'fork/feature', + ahead: 1, + behind: 0 + } + const baselineOwner = new GitRepositorySnapshotOwner() + const baselineStatusLoads = vi.fn(async () => statusResult) + const baselineUpstreamLoads = vi.fn(async () => upstreamResult) + await baselineOwner.readStatus( + native, + '/repo', + defaultStatusIdentity, + undefined, + baselineStatusLoads + ) + await baselineOwner.readUpstream(native, '/repo', pushTarget, baselineUpstreamLoads) + await baselineOwner.readStatus( + native, + '/repo', + defaultStatusIdentity, + undefined, + baselineStatusLoads + ) + await baselineOwner.readUpstream(native, '/repo', pushTarget, baselineUpstreamLoads) + + const migratedOwner = new GitRepositorySnapshotOwner() + const migratedStatusLoads = vi.fn(async () => statusResult) + const migratedUpstreamLoads = vi.fn(async () => upstreamResult) + await migratedOwner.readStatus( + native, + '/repo', + defaultStatusIdentity, + undefined, + migratedStatusLoads + ) + await migratedOwner.readUpstream(native, '/repo', pushTarget, migratedUpstreamLoads) + const snapshot = migratedOwner.getSnapshot( + query(native, '/repo', defaultStatusIdentity, pushTarget) + ) + + expect(baselineStatusLoads).toHaveBeenCalledTimes(2) + expect(baselineUpstreamLoads).toHaveBeenCalledTimes(2) + expect(migratedStatusLoads).toHaveBeenCalledOnce() + expect(migratedUpstreamLoads).toHaveBeenCalledOnce() + expect(snapshot).toMatchObject({ + freshness: { status: { state: 'fresh' }, upstream: { state: 'fresh' } }, + upstream: { upstreamName: 'fork/feature' } + }) + }) + it('does not publish rejected reads and always retries settled work', async () => { const owner = new GitRepositorySnapshotOwner() const failure = new Error('read failed') diff --git a/src/main/git/git-repository-snapshot-projection.ts b/src/main/git/git-repository-snapshot-projection.ts index ddf444f80b1..f25082a2431 100644 --- a/src/main/git/git-repository-snapshot-projection.ts +++ b/src/main/git/git-repository-snapshot-projection.ts @@ -1,12 +1,20 @@ import type { - GitConflictOperation, GitPushTarget, GitStatusEntry, GitStatusResult, GitUpstreamStatus } from '../../shared/types' +import type { + GitRepositoryProjectionFreshness, + GitRepositorySnapshot +} from '../../shared/git-repository-snapshot' import { stableInFlightKey } from '../../shared/in-flight-promise-dedupe' +export type { + GitRepositoryProjectionFreshness, + GitRepositorySnapshot +} from '../../shared/git-repository-snapshot' + export const MAX_GIT_REPOSITORY_SNAPSHOT_STATUS_ENTRIES = 1_000 export const MAX_GIT_REPOSITORY_SNAPSHOT_IGNORED_PATHS = 1_000 export const MAX_GIT_REPOSITORY_SNAPSHOT_TEXT_CODE_UNITS = 64 * 1_024 @@ -24,43 +32,11 @@ export type GitRepositoryStatusIdentity = { sharedLinkPaths: readonly string[] } -export type GitRepositoryProjectionFreshness = Readonly<{ - state: 'missing' | 'fresh' | 'stale' | 'failed' | 'placeholder' - generation: number - currentGeneration: number - revision: number | null - identity: string | null -}> - -export type GitRepositorySnapshot = Readonly<{ - revision: number - generatedAt: number - repositoryIdentity: Readonly<{ head: string | null; branch: string | null }> - status: Readonly<{ - entries: readonly Readonly[] - didHitLimit: boolean - statusLength: number | null - ignoredPaths: readonly string[] - lineStatsState: 'missing' | 'complete' | 'skipped-at-limit' - retentionTruncated: boolean - }> - upstream: Readonly | null - conflicts: GitConflictOperation | null - worktreeGraphVersion: number - freshness: Readonly<{ - repositoryIdentity: GitRepositoryProjectionFreshness - status: GitRepositoryProjectionFreshness - upstream: GitRepositoryProjectionFreshness - conflicts: GitRepositoryProjectionFreshness - worktreeGraph: GitRepositoryProjectionFreshness - }> -}> - export type GitRepositoryStatusProjection = { repositoryIdentity: Readonly<{ head: string | null; branch: string | null }> status: GitRepositorySnapshot['status'] upstream: Readonly | null - conflicts: GitConflictOperation + conflicts: GitStatusResult['conflictOperation'] } export type GitRepositorySnapshotQuery = { diff --git a/src/main/ipc/filesystem.test.ts b/src/main/ipc/filesystem.test.ts index e712a2938fc..2047c936aac 100644 --- a/src/main/ipc/filesystem.test.ts +++ b/src/main/ipc/filesystem.test.ts @@ -21,6 +21,7 @@ const { lstatMock, commitChangesMock, getStatusMock, + getGitRepositorySnapshotMock, abortMergeMock, abortRebaseMock, getDiffMock, @@ -66,6 +67,7 @@ const { lstatMock: vi.fn(), commitChangesMock: vi.fn(), getStatusMock: vi.fn(), + getGitRepositorySnapshotMock: vi.fn(), abortMergeMock: vi.fn(), abortRebaseMock: vi.fn(), getDiffMock: vi.fn(), @@ -139,6 +141,7 @@ vi.mock('../local-downloaded-folder-promotion', () => ({ vi.mock('../git/status', () => ({ commitChanges: commitChangesMock, getStatus: getStatusMock, + getGitRepositorySnapshot: getGitRepositorySnapshotMock, abortMerge: abortMergeMock, abortRebase: abortRebaseMock, getDiff: getDiffMock, @@ -287,6 +290,7 @@ describe('registerFilesystemHandlers', () => { recordCrashBreadcrumbMock, commitChangesMock, getStatusMock, + getGitRepositorySnapshotMock, abortMergeMock, abortRebaseMock, getDiffMock, @@ -1521,6 +1525,111 @@ describe('registerFilesystemHandlers', () => { }) }) + it('reads exact local and SSH repository snapshot identities without running Git', async () => { + const pushTarget = { + remoteName: 'fork', + branchName: 'feature/checks', + remoteUrl: 'git@github.com:org/repo.git', + remoteCreated: false + } + const snapshot = { revision: 3 } + const sharedStore = { + ...store, + getRepos: () => [{ ...store.getRepos()[0], symlinkPaths: ['node_modules'] }], + getAllWorktreeMeta: () => ({ [`repo-1::${WORKTREE_FEATURE_PATH}`]: {} }) + } + registerWorktreeRootsForRepo(sharedStore as never, 'repo-1', [REPO_PATH, WORKTREE_FEATURE_PATH]) + getGitRepositorySnapshotMock.mockReturnValue(snapshot) + const sshProvider = { getRepositorySnapshot: vi.fn().mockReturnValue(snapshot) } + getSshGitProviderMock.mockReturnValue(sshProvider) + registerFilesystemHandlers(sharedStore as never) + const args = { + includeIgnored: true, + bypassEffectiveUpstreamNegativeCache: true, + reuseLineStats: true, + pushTarget + } + + await expect( + handlers.get('git:repositorySnapshot')!(null, { + worktreePath: WORKTREE_FEATURE_PATH, + ...args + }) + ).resolves.toBe(snapshot) + await expect( + handlers.get('git:repositorySnapshot')!(null, { + worktreePath: '/remote/repo', + connectionId: 'ssh-1', + ...args + }) + ).resolves.toBe(snapshot) + + expect(getGitRepositorySnapshotMock).toHaveBeenCalledWith( + WORKTREE_FEATURE_PATH, + { + includeIgnored: true, + bypassEffectiveUpstreamNegativeCache: true, + reuseLineStats: true, + sharedLinkPaths: ['node_modules'] + }, + pushTarget + ) + expect(sshProvider.getRepositorySnapshot).toHaveBeenCalledWith( + '/remote/repo', + { + includeIgnored: true, + bypassEffectiveUpstreamNegativeCache: true, + reuseLineStats: true + }, + pushTarget + ) + expect(getStatusMock).not.toHaveBeenCalled() + }) + + it('reads repository snapshots from the exact configured WSL distro', async () => { + await withPlatform('win32', async () => { + const wslStore = { + ...store, + getRepos: () => [ + { + id: 'repo-1', + path: WORKTREE_FEATURE_PATH, + displayName: 'repo', + badgeColor: '#000', + addedAt: 0 + } + ], + getProjects: () => [ + { + id: 'project-1', + sourceRepoIds: ['repo-1'], + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + } + ], + getSettings: () => ({ + workspaceDir: WORKSPACE_DIR, + localWindowsRuntimeDefault: { kind: 'windows-host' } + }) + } + registerWorktreeRootsForRepo(wslStore as never, 'repo-1', [WORKTREE_FEATURE_PATH]) + getGitRepositorySnapshotMock.mockReturnValue({ revision: 1 }) + registerFilesystemHandlers(wslStore as never) + + await handlers.get('git:repositorySnapshot')!(null, { + worktreePath: WORKTREE_FEATURE_PATH + }) + + expect(getGitRepositorySnapshotMock).toHaveBeenCalledWith( + WORKTREE_FEATURE_PATH, + { + includeIgnored: false, + wslDistro: 'Ubuntu' + }, + undefined + ) + }) + }) + it('aborts tokenized local status without crossing renderer boundaries', async () => { registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, WORKTREE_FEATURE_PATH]) const statusSignals: AbortSignal[] = [] diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index 466166cfcad..2a6791feda0 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -29,6 +29,7 @@ import type { TuiAgent } from '../../shared/types' import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history' +import type { GitRepositorySnapshot } from '../../shared/git-repository-snapshot' import type { SshMutationExpectation } from '../../shared/ssh-types' import { assertSshMutationExpectation } from '../ssh/ssh-connection-generation' import { @@ -41,6 +42,7 @@ import { } from '../../shared/text-search' import { getStatus, + getGitRepositorySnapshot, getSubmoduleStatus, abortMerge, abortRebase, @@ -1141,6 +1143,52 @@ export function registerFilesystemHandlers( gitStatusCancellations.cancel(event, args.requestToken) }) + ipcMain.handle( + 'git:repositorySnapshot', + async ( + _event, + args: { + worktreePath: string + connectionId?: string + includeIgnored?: boolean + bypassEffectiveUpstreamNegativeCache?: boolean + reuseLineStats?: boolean + pushTarget?: GitPushTarget + } + ): Promise => { + if (args.pushTarget) { + assertGitPushTargetShape(args.pushTarget) + } + const options = { + includeIgnored: args.includeIgnored ?? false, + ...(args.reuseLineStats === true ? { reuseLineStats: true } : {}), + ...(args.bypassEffectiveUpstreamNegativeCache === true + ? { bypassEffectiveUpstreamNegativeCache: true } + : {}) + } + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.getRepositorySnapshot(args.worktreePath, options, args.pushTarget) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const repo = getLocalRepoForRegisteredWorktree(store, args.worktreePath, worktreePath) + const gitOptions = getLocalGitOptionsForRepo(store, repo) + const sharedLinkPaths = repo ? getWorktreeSharedLinkPaths(repo) : [] + return getGitRepositorySnapshot( + worktreePath, + { + ...options, + ...gitOptions, + ...(sharedLinkPaths.length > 0 ? { sharedLinkPaths } : {}) + }, + args.pushTarget + ) + } + ) + // Why: parent status reports only one gitlink row per submodule; fetch inner per-file changes from the submodule's own worktree. ipcMain.handle( 'git:submoduleStatus', diff --git a/src/main/providers/ssh-git-provider.test.ts b/src/main/providers/ssh-git-provider.test.ts index 71910c6b322..8c19092652c 100644 --- a/src/main/providers/ssh-git-provider.test.ts +++ b/src/main/providers/ssh-git-provider.test.ts @@ -1161,6 +1161,70 @@ describe('SshGitProvider', () => { expect(replacement.getRepositorySnapshot('/home/user/repo')).toBeNull() }) + it('reduces settled polling plus Checks demand from two RPCs to one per projection', async () => { + const pushTarget = { + remoteName: 'fork', + branchName: 'feature', + remoteUrl: 'ssh://git.example/repo', + remoteCreated: false + } + mux.request.mockImplementation((method) => { + if (method === 'git.status') { + return Promise.resolve({ + entries: [], + conflictOperation: 'unknown', + head: 'abc123', + branch: 'feature' + }) + } + if (method === 'git.upstreamStatus') { + return Promise.resolve({ + hasUpstream: true, + upstreamName: 'fork/feature', + ahead: 1, + behind: 0 + }) + } + return Promise.resolve(undefined) + }) + + await provider.getStatus('/home/user/repo') + await provider.getUpstreamStatus('/home/user/repo', pushTarget) + await provider.getStatus('/home/user/repo') + await provider.getUpstreamStatus('/home/user/repo', pushTarget) + const baselineCounts = { + status: mux.request.mock.calls.filter(([method]) => method === 'git.status').length, + upstream: mux.request.mock.calls.filter(([method]) => method === 'git.upstreamStatus').length + } + + mux.request.mockClear() + const migratedProvider = new SshGitProvider('conn-1', mux as never) + await migratedProvider.getStatus('/home/user/repo') + await migratedProvider.getUpstreamStatus('/home/user/repo', pushTarget) + const snapshot = migratedProvider.getRepositorySnapshot('/home/user/repo', {}, pushTarget) + const migratedCounts = { + status: mux.request.mock.calls.filter(([method]) => method === 'git.status').length, + upstream: mux.request.mock.calls.filter(([method]) => method === 'git.upstreamStatus').length + } + + expect(baselineCounts).toEqual({ status: 2, upstream: 2 }) + expect(migratedCounts).toEqual({ status: 1, upstream: 1 }) + expect(snapshot).toMatchObject({ + freshness: { status: { state: 'fresh' }, upstream: { state: 'fresh' } }, + upstream: { upstreamName: 'fork/feature' } + }) + expect(mux.request).toHaveBeenNthCalledWith( + 1, + 'git.status', + { worktreePath: '/home/user/repo' }, + { signal: expect.any(AbortSignal) } + ) + expect(mux.request).toHaveBeenNthCalledWith(2, 'git.upstreamStatus', { + worktreePath: '/home/user/repo', + pushTarget + }) + }) + it('marks retained snapshots stale across the existing SSH mutation fence', async () => { mux.request.mockImplementation((method) => { if (method === 'git.status') { diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index dc35a768f9e..4ce5c976ce4 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -282,6 +282,7 @@ import type { import type { SetupScriptImportCandidate } from '../shared/setup-script-imports' import type { GitHistoryOptions, GitHistoryResult } from '../shared/git-history' +import type { GitRepositorySnapshot } from '../shared/git-repository-snapshot' import type { PublicKnownRuntimeEnvironment } from '../shared/runtime-environments' import type { EphemeralVmRecipeDoctorResult } from '../shared/ephemeral-vm-recipes' import type { EphemeralVmRecipeResultWarning } from '../shared/ephemeral-vm-recipe-diagnostics' @@ -2837,6 +2838,14 @@ export type PreloadApi = { requestToken?: string }) => Promise cancelStatus: (args: { requestToken: string }) => Promise + repositorySnapshot: (args: { + worktreePath: string + connectionId?: string + includeIgnored?: boolean + bypassEffectiveUpstreamNegativeCache?: boolean + reuseLineStats?: boolean + pushTarget?: GitPushTarget + }) => Promise submoduleStatus: (args: { worktreePath: string submodulePath: string diff --git a/src/preload/index.ts b/src/preload/index.ts index 09d82ad6aba..c8d33670676 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -14,6 +14,7 @@ import type { AgentHookInstallStatus } from '../shared/agent-hook-types' import type { CodexConfigSyncStatus } from '../shared/codex-config-sync-types' import type { TerminalPaneSplitSource } from '../shared/feature-education-telemetry' import type { ProjectExecutionRuntimeResolution } from '../shared/project-execution-runtime' +import type { GitRepositorySnapshot } from '../shared/git-repository-snapshot' import type { StartupCommandDelivery } from '../shared/codex-startup-delivery' import type { AgentProviderSessionMetadata, @@ -3175,6 +3176,14 @@ const api = { }): Promise => ipcRenderer.invoke('git:status', args), cancelStatus: (args: { requestToken: string }): Promise => ipcRenderer.invoke('git:cancelStatus', args), + repositorySnapshot: (args: { + worktreePath: string + connectionId?: string + includeIgnored?: boolean + bypassEffectiveUpstreamNegativeCache?: boolean + reuseLineStats?: boolean + pushTarget?: GitPushTarget + }): Promise => ipcRenderer.invoke('git:repositorySnapshot', args), submoduleStatus: (args: { worktreePath: string submodulePath: string diff --git a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx index 063aad0366e..0d500523845 100644 --- a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx +++ b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx @@ -122,8 +122,10 @@ import { getRuntimeGitUpstreamStatus, type RuntimeGeneratePullRequestFieldsOverrides } from '@/runtime/runtime-git-client' +import { getDesktopGitRepositorySnapshot } from '@/runtime/desktop-git-repository-snapshot-client' import { buildChecksPanelGitStatusContextKey, + readChecksPanelRepositorySnapshot, readChecksPanelPublishActionGitStatus, readChecksPanelGitStatusSnapshot, readChecksPanelRefreshGitIdentitySnapshot, @@ -1511,6 +1513,33 @@ export default function ChecksPanel(): React.JSX.Element { connectionId } void (async () => { + let repositorySnapshot = readChecksPanelRepositorySnapshot( + await getDesktopGitRepositorySnapshot( + context, + activeWorktreePushTarget ? { pushTarget: activeWorktreePushTarget } : undefined + ), + requestContextKey, + branch + ) + if (!repositorySnapshot) { + repositorySnapshot = readChecksPanelRepositorySnapshot( + await getDesktopGitRepositorySnapshot(context, { + reuseLineStats: true, + ...(activeWorktreePushTarget ? { pushTarget: activeWorktreePushTarget } : {}) + }), + requestContextKey, + branch + ) + } + if (repositorySnapshot) { + if ( + !stale && + shouldCommitChecksPanelGitStatusSnapshot(panelContextKeyRef.current, requestContextKey) + ) { + updateWorktreeGitIdentity(activeWorktreeId, repositorySnapshot.gitIdentity ?? {}) + } + return repositorySnapshot + } const status = await getRuntimeGitStatus(context) if ( !stale && @@ -1533,22 +1562,22 @@ export default function ChecksPanel(): React.JSX.Element { ) { freshRemoteStatus = await getRuntimeGitUpstreamStatus(context) } - return { status, remoteStatus: freshRemoteStatus } + return { + contextKey: requestContextKey, + hasUncommittedChanges: status.entries.length > 0, + remoteStatus: freshRemoteStatus, + gitIdentity: { + head: status.head, + branch: status.branch ?? (status.head ? null : undefined) + } + } })() - .then(({ status, remoteStatus }) => { + .then((snapshot) => { if ( !stale && shouldCommitChecksPanelGitStatusSnapshot(panelContextKeyRef.current, requestContextKey) ) { - setGitStatusSnapshot({ - contextKey: requestContextKey, - hasUncommittedChanges: status.entries.length > 0, - remoteStatus, - gitIdentity: { - head: status.head, - branch: status.branch ?? (status.head ? null : undefined) - } - }) + setGitStatusSnapshot(snapshot) // A fresh probe succeeded, so this context is no longer in the "could not check branch status" state. setGitStatusProbeErrorContextKey((key) => (key === requestContextKey ? null : key)) } diff --git a/src/renderer/src/components/right-sidebar/checks-panel-git-status-snapshot.test.ts b/src/renderer/src/components/right-sidebar/checks-panel-git-status-snapshot.test.ts index ffbb647dfb2..bd2e964c0fc 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel-git-status-snapshot.test.ts +++ b/src/renderer/src/components/right-sidebar/checks-panel-git-status-snapshot.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest' +import type { GitRepositorySnapshot } from '../../../../shared/git-repository-snapshot' import { buildChecksPanelGitStatusContextKey, + readChecksPanelRepositorySnapshot, readChecksPanelPublishActionGitStatus, readChecksPanelRefreshGitIdentitySnapshot, readChecksPanelGitStatusSnapshot, @@ -26,6 +28,40 @@ const SNAPSHOT: ChecksPanelGitStatusSnapshot = { } } +function repositorySnapshot(overrides: Partial = {}): GitRepositorySnapshot { + const freshness = { + state: 'fresh' as const, + generation: 2, + currentGeneration: 2, + revision: 4, + identity: 'identity' + } + return { + revision: 4, + generatedAt: 100, + repositoryIdentity: { head: 'abc123', branch: 'feature/checks' }, + status: { + entries: [{ path: 'src/app.ts', status: 'modified', area: 'unstaged' }], + didHitLimit: false, + statusLength: 1, + ignoredPaths: [], + lineStatsState: 'complete', + retentionTruncated: false + }, + upstream: { hasUpstream: true, upstreamName: 'origin/main', ahead: 1, behind: 0 }, + conflicts: null, + worktreeGraphVersion: 0, + freshness: { + repositoryIdentity: freshness, + status: freshness, + upstream: freshness, + conflicts: freshness, + worktreeGraph: { ...freshness, state: 'placeholder' } + }, + ...overrides + } +} + describe('buildChecksPanelGitStatusContextKey', () => { it('changes when an explicit push target changes', () => { const base = { @@ -50,6 +86,26 @@ describe('buildChecksPanelGitStatusContextKey', () => { ) }) + it('distinguishes every explicit target field including absent versus false', () => { + const base = { + repoId: 'repo-1', + worktreeId: 'worktree-1', + worktreePath: 'repo-worktree', + branch: 'feature/checks', + runtimeEnvironmentId: null, + repoConnectionId: null + } + const context = ( + pushTarget: NonNullable< + Parameters[0]['pushTarget'] + > + ) => buildChecksPanelGitStatusContextKey({ ...base, pushTarget }) + const target = { remoteName: 'origin', branchName: 'feature/checks' } + + expect(context(target)).not.toBe(context({ ...target, remoteCreated: false })) + expect(context(target)).not.toBe(context({ ...target, remoteUrl: 'ssh://git.example/repo' })) + }) + it('changes when linked hosted review metadata changes', () => { const base = { repoId: 'repo-1', @@ -112,6 +168,92 @@ describe('buildChecksPanelGitStatusContextKey', () => { }) }) +describe('readChecksPanelRepositorySnapshot', () => { + it('projects a complete current-generation host snapshot without mutating it', () => { + const snapshot = repositorySnapshot() + + expect(readChecksPanelRepositorySnapshot(snapshot, 'panel-context', 'feature/checks')).toEqual({ + contextKey: 'panel-context', + hasUncommittedChanges: true, + remoteStatus: snapshot.upstream, + gitIdentity: { head: 'abc123', branch: 'feature/checks' } + }) + expect(snapshot.status.entries).toHaveLength(1) + }) + + it.each(['missing', 'stale', 'failed'] as const)('rejects a %s status projection', (state) => { + const snapshot = repositorySnapshot({ + freshness: { + ...repositorySnapshot().freshness, + status: { ...repositorySnapshot().freshness.status, state } + } + }) + + expect( + readChecksPanelRepositorySnapshot(snapshot, 'panel-context', 'feature/checks') + ).toBeNull() + }) + + it.each(['missing', 'stale', 'failed'] as const)('rejects a %s upstream projection', (state) => { + expect( + readChecksPanelRepositorySnapshot( + repositorySnapshot({ + freshness: { + ...repositorySnapshot().freshness, + upstream: { ...repositorySnapshot().freshness.upstream, state } + } + }), + 'panel-context', + 'feature/checks' + ) + ).toBeNull() + }) + + it('rejects missing upstream data and retention-truncated status projections', () => { + expect( + readChecksPanelRepositorySnapshot( + repositorySnapshot({ + upstream: null + }), + 'panel-context', + 'feature/checks' + ) + ).toBeNull() + expect( + readChecksPanelRepositorySnapshot( + repositorySnapshot({ + status: { ...repositorySnapshot().status, retentionTruncated: true } + }), + 'panel-context', + 'feature/checks' + ) + ).toBeNull() + }) + + it('rejects an ambiguous embedded upstream projection', () => { + expect( + readChecksPanelRepositorySnapshot( + repositorySnapshot({ + upstream: { + hasUpstream: true, + upstreamName: 'origin/main', + ahead: 1, + behind: 1 + } + }), + 'panel-context', + 'feature/checks' + ) + ).toBeNull() + }) + + it('rejects a fresh projection from another branch', () => { + expect( + readChecksPanelRepositorySnapshot(repositorySnapshot(), 'panel-context', 'feature/other') + ).toBeNull() + }) +}) + describe('readChecksPanelGitStatusSnapshot', () => { it('returns status inputs for the matching panel context', () => { expect(readChecksPanelGitStatusSnapshot(SNAPSHOT, SNAPSHOT.contextKey)).toEqual({ diff --git a/src/renderer/src/components/right-sidebar/checks-panel-git-status-snapshot.ts b/src/renderer/src/components/right-sidebar/checks-panel-git-status-snapshot.ts index 78063f3b211..3ed427e74c0 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel-git-status-snapshot.ts +++ b/src/renderer/src/components/right-sidebar/checks-panel-git-status-snapshot.ts @@ -1,4 +1,5 @@ import type { GitPushTarget, GitStatusEntry, GitUpstreamStatus } from '../../../../shared/types' +import type { GitRepositorySnapshot } from '../../../../shared/git-repository-snapshot' export type ChecksPanelGitStatusContextInput = { repoId: string | null | undefined @@ -70,12 +71,42 @@ export function buildChecksPanelGitStatusContextKey( remoteName: input.pushTarget.remoteName, branchName: input.pushTarget.branchName, remoteUrl: input.pushTarget.remoteUrl ?? null, - remoteCreated: input.pushTarget.remoteCreated ?? false + remoteCreated: input.pushTarget.remoteCreated ?? null } : null }) } +export function readChecksPanelRepositorySnapshot( + snapshot: GitRepositorySnapshot | null, + contextKey: string, + expectedBranch: string +): ChecksPanelGitStatusSnapshot | null { + if ( + !snapshot || + snapshot.freshness.status.state !== 'fresh' || + snapshot.freshness.upstream.state !== 'fresh' || + snapshot.status.retentionTruncated || + !snapshot.upstream || + (snapshot.upstream.ahead > 0 && + snapshot.upstream.behind > 0 && + snapshot.upstream.behindCommitsArePatchEquivalent === undefined) || + canonicalBranchIdentity(snapshot.repositoryIdentity.branch) !== + canonicalBranchIdentity(expectedBranch) + ) { + return null + } + return { + contextKey, + hasUncommittedChanges: snapshot.status.entries.length > 0, + remoteStatus: snapshot.upstream, + gitIdentity: { + head: snapshot.repositoryIdentity.head ?? undefined, + branch: snapshot.repositoryIdentity.branch + } + } +} + export function shouldPollChecksPanelRuntimeSshStatus(input: { isPanelVisible: boolean runtimeEnvironmentId: string | null diff --git a/src/renderer/src/runtime/desktop-git-repository-snapshot-client.test.ts b/src/renderer/src/runtime/desktop-git-repository-snapshot-client.test.ts new file mode 100644 index 00000000000..492bed74053 --- /dev/null +++ b/src/renderer/src/runtime/desktop-git-repository-snapshot-client.test.ts @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { getDesktopGitRepositorySnapshot } from './desktop-git-repository-snapshot-client' + +const repositorySnapshot = vi.fn() +const runtimeEnvironmentCall = vi.fn() + +beforeEach(() => { + repositorySnapshot.mockReset() + runtimeEnvironmentCall.mockReset() + vi.stubGlobal('window', { + api: { + git: { repositorySnapshot }, + runtimeEnvironments: { call: runtimeEnvironmentCall } + } + }) +}) + +describe('getDesktopGitRepositorySnapshot', () => { + it('reads the exact desktop host snapshot identity', async () => { + repositorySnapshot.mockResolvedValue(null) + const pushTarget = { + remoteName: 'fork', + branchName: 'feature/checks', + remoteUrl: 'ssh://git.example/repo', + remoteCreated: false + } + + await expect( + getDesktopGitRepositorySnapshot( + { + settings: { activeRuntimeEnvironmentId: null }, + worktreeId: 'wt-1', + worktreePath: '/repo', + connectionId: 'ssh-1' + }, + { + includeIgnored: true, + bypassEffectiveUpstreamNegativeCache: true, + reuseLineStats: true, + pushTarget + } + ) + ).resolves.toBeNull() + + expect(repositorySnapshot).toHaveBeenCalledWith({ + worktreePath: '/repo', + connectionId: 'ssh-1', + includeIgnored: true, + bypassEffectiveUpstreamNegativeCache: true, + reuseLineStats: true, + pushTarget + }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + it('uses the backing folder path for a local folder workspace', async () => { + repositorySnapshot.mockResolvedValue(null) + const workspaceId = '123e4567-e89b-12d3-a456-426614174000' + + await getDesktopGitRepositorySnapshot({ + settings: { activeRuntimeEnvironmentId: null }, + worktreeId: `folder-repo::/home/user::workspace:${workspaceId}`, + worktreePath: `/home/user::workspace:${workspaceId}` + }) + + expect(repositorySnapshot).toHaveBeenCalledWith({ + worktreePath: '/home/user', + connectionId: undefined + }) + }) + + it('leaves active runtime-environment transport to a later slice', async () => { + await expect( + getDesktopGitRepositorySnapshot( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/repo' + }, + { + includeIgnored: true, + bypassEffectiveUpstreamNegativeCache: true, + reuseLineStats: true + } + ) + ).resolves.toBeNull() + + expect(repositorySnapshot).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/runtime/desktop-git-repository-snapshot-client.ts b/src/renderer/src/runtime/desktop-git-repository-snapshot-client.ts new file mode 100644 index 00000000000..3ee64a91941 --- /dev/null +++ b/src/renderer/src/runtime/desktop-git-repository-snapshot-client.ts @@ -0,0 +1,36 @@ +import type { GitPushTarget } from '../../../shared/types' +import type { GitRepositorySnapshot } from '../../../shared/git-repository-snapshot' +import { splitWorktreeIdForFilesystem } from '../../../shared/worktree-id' +import { getActiveRuntimeTarget } from './runtime-rpc-client' +import type { RuntimeGitContext } from './runtime-git-client' + +function resolveDesktopSnapshotWorktreePath(context: RuntimeGitContext): string { + return context.worktreeId + ? (splitWorktreeIdForFilesystem(context.worktreeId)?.worktreePath ?? context.worktreePath) + : context.worktreePath +} + +export async function getDesktopGitRepositorySnapshot( + context: RuntimeGitContext, + options: { + includeIgnored?: boolean + bypassEffectiveUpstreamNegativeCache?: boolean + reuseLineStats?: boolean + pushTarget?: GitPushTarget + } = {} +): Promise { + const target = getActiveRuntimeTarget(context.settings) + if (target.kind !== 'local' && context.worktreeId) { + return null + } + return window.api.git.repositorySnapshot({ + worktreePath: resolveDesktopSnapshotWorktreePath(context), + connectionId: context.connectionId, + ...(options.includeIgnored ? { includeIgnored: true } : {}), + ...(options.bypassEffectiveUpstreamNegativeCache + ? { bypassEffectiveUpstreamNegativeCache: true } + : {}), + ...(options.reuseLineStats ? { reuseLineStats: true } : {}), + ...(options.pushTarget ? { pushTarget: options.pushTarget } : {}) + }) +} diff --git a/src/shared/git-repository-snapshot.ts b/src/shared/git-repository-snapshot.ts new file mode 100644 index 00000000000..7136ae64446 --- /dev/null +++ b/src/shared/git-repository-snapshot.ts @@ -0,0 +1,33 @@ +import type { GitConflictOperation, GitStatusEntry, GitUpstreamStatus } from './types' + +export type GitRepositoryProjectionFreshness = Readonly<{ + state: 'missing' | 'fresh' | 'stale' | 'failed' | 'placeholder' + generation: number + currentGeneration: number + revision: number | null + identity: string | null +}> + +export type GitRepositorySnapshot = Readonly<{ + revision: number + generatedAt: number + repositoryIdentity: Readonly<{ head: string | null; branch: string | null }> + status: Readonly<{ + entries: readonly Readonly[] + didHitLimit: boolean + statusLength: number | null + ignoredPaths: readonly string[] + lineStatsState: 'missing' | 'complete' | 'skipped-at-limit' + retentionTruncated: boolean + }> + upstream: Readonly | null + conflicts: GitConflictOperation | null + worktreeGraphVersion: number + freshness: Readonly<{ + repositoryIdentity: GitRepositoryProjectionFreshness + status: GitRepositoryProjectionFreshness + upstream: GitRepositoryProjectionFreshness + conflicts: GitRepositoryProjectionFreshness + worktreeGraph: GitRepositoryProjectionFreshness + }> +}> diff --git a/tools/benchmarks/results/phase2-checks-repository-snapshot-consumer-2026-07-30.md b/tools/benchmarks/results/phase2-checks-repository-snapshot-consumer-2026-07-30.md new file mode 100644 index 00000000000..49b11ecb4ed --- /dev/null +++ b/tools/benchmarks/results/phase2-checks-repository-snapshot-consumer-2026-07-30.md @@ -0,0 +1,182 @@ +# Phase 2 Checks repository-snapshot consumer — 2026-07-30 + +## Decision + +Retain a read-only desktop projection query and migrate automatic Checks eligibility reads to the +host-owned `GitRepositorySnapshot` seam. When active polling has already published a complete +current-generation status and upstream projection, Checks now uses that immutable projection +instead of repeating status and upstream work. Manual Checks Refresh and every incomplete, +stale, failed, truncated, branch-mismatched, or runtime-routed case keep the existing fresh path. + +Checkpoint: `98134f6f7b8921f0964d99395a2fa91b91ea806e`. + +This is intentionally not a TTL cache. Existing status and upstream APIs still run fresh after +settlement, and the new IPC query never runs Git or an SSH RPC. + +## Consumer boundary + +The active polling path remains the producer. Its existing native, exact-WSL-distro, and +per-`SshGitProvider` owners publish status and upstream projections through +`GitRepositorySnapshotOwner`; existing pre/post mutation fences continue to mark those projections +stale and suppress late publication into the current generation. + +Automatic Checks now: + +1. queries the exact desktop owner identity; +2. admits only `fresh` status and upstream projections; +3. rejects retention-truncated status, a missing upstream projection, and a branch mismatch; and +4. falls back to the unchanged fresh status/upstream path when admission fails. + +The query checks both existing polling status identities: the ordinary identity and the safety +poll identity with `reuseLineStats: true`. These remain separate owner identities; the second +lookup is read-only and does not merge them. Identity also retains worktree path, native versus +exact WSL distro, shared-link paths, `includeIgnored`, negative-cache bypass, and every explicit +push-target field. The Checks context key now preserves absent `remoteCreated` versus explicit +`false`, as well as absent versus present `remoteUrl`. + +SSH lookup is routed to the current provider instance for `connectionId`, so replacement and +reconnect incarnations cannot share projections. Local folder-workspace IDs are reduced to their +registered backing folder path before IPC. Active runtime-environment ownership returns `null` +from the desktop client and uses the existing runtime Git path; runtime/mobile snapshot transport +is not added in this slice. + +Manual Checks Refresh remains on the existing fresh APIs. No Git command, SSH RPC method or +payload, retry timer, visibility interval, slow-task scheduler, signal, mutation wrapper, relay +routing, or provider-specific review behavior changed. + +## Deterministic physical-owner measurement + +The focused owner and SSH-provider tests model the same settled overlap: active polling completes +status plus upstream publication, then automatic Checks requests the same identity in that refresh +horizon. + +| Boundary | A — fresh Checks | B — snapshot query | +| ------------------------------------------ | ---------------: | -----------------: | +| Local/WSL physical status loads | 2 | 1 | +| Local/WSL physical explicit-upstream loads | 2 | 1 | +| SSH `git.status` RPCs | 2 | 1 | +| SSH `git.upstreamStatus` RPCs | 2 | 1 | + +The B query itself adds two read-only owner lookups in the worst case—ordinary and +`reuseLineStats` identities—but zero subprocesses and zero mux requests. If neither snapshot is +admissible, the original fresh load count and behavior are preserved. + +The physical status command remains: + +```text +git -c core.quotePath=false status --porcelain=v2 --branch --untracked-files=all +``` + +The configured-upstream chain remains: + +```text +git symbolic-ref --quiet --short HEAD +git rev-parse --abbrev-ref HEAD@{u} +git rev-list --left-right --count HEAD...origin/main +git log --oneline --cherry-mark --right-only HEAD...origin/main -- +``` + +The SSH methods and payload omission rules remain: + +```json +[ + { + "method": "git.status", + "payload": { "worktreePath": "/home/user/repo" } + }, + { + "method": "git.upstreamStatus", + "payload": { + "worktreePath": "/home/user/repo", + "pushTarget": { + "remoteName": "fork", + "branchName": "feature", + "remoteUrl": "ssh://git.example/repo", + "remoteCreated": false + } + } + } +] +``` + +Absent options and absent `pushTarget` remain omitted. Existing command and provider suites +continue to cover configured upstreams, explicit publish targets, ahead/behind and patch +equivalence, no-upstream behavior, Git 2.25 compatibility, WSL path handling, SSH/system-SSH, and +relay routing. + +## Production A/B + +Fresh production builds were archived outside the worktree: + +- A: `/tmp/orca-phase2-checks-snapshot-a.hBrWy8/out` +- B: `/tmp/orca-phase2-checks-snapshot-b-reviewed.bNuaXj/out` + +| Artifact | A raw / gzip | B raw / gzip | Change | A SHA-256 | B SHA-256 | +| ---------------------- | ----------------- | ----------------- | ---------- | ------------------------------------------------------------------ | ------------------------------------------------------------------ | +| Electron main entry | 789,517 / 176,812 | 789,869 / 176,858 | +352 / +46 | `6413904f6420882168b8993c1240614bacde1de92dfb26a55c4db67c84007b8e` | `7f4984a54c65cf5b749a87ed7ec7f2074c5b844992215ad25e8c17d179045660` | +| Electron preload entry | 130,798 / 20,642 | 130,891 / 20,658 | +93 / +16 | `c388a39cdca9609760e286d95b87ad1e53793720450e507f830ff1f6c5bd259f` | `afa8494e803f0e57cabdb5d2ff636f30fc8021069a7694d57d2ecdcdd6837a8a` | +| Renderer manifest | 403,000 / 48,291 | 403,000 / 48,288 | 0 / -3 | `92ad617f8c8aee6d4c4a23622f524266cbe9c93b3fe65544d1acb833dc3f0d67` | `9f57d550a1c9f6c75bf2d3db4f5ff53e53c4c52eceb8b10e9b86d8659a48d7fe` | + +The Electron-main raw budget remains 825,109 bytes; B retains 35,240 bytes of headroom. File +counts remain 184 main, one preload, and 787 renderer artifacts. + +| Renderer entry | A raw / gzip | B raw / gzip | Change | A JS/CSS | B JS/CSS | +| -------------- | --------------------- | --------------------- | ------ | -------- | -------- | +| Index | 8,416,540 / 1,877,823 | 8,416,540 / 1,877,824 | 0 / +1 | 292 / 2 | 292 / 2 | +| Popout | 4,507,253 / 984,615 | 4,507,253 / 984,615 | 0 / 0 | 77 / 2 | 77 / 2 | +| Web | 4,360,652 / 928,355 | 4,360,652 / 928,352 | 0 / -3 | 33 / 1 | 33 / 1 | + +Deterministic tree hashes use sorted relative paths plus file bytes: + +| Tree | A SHA-256 | B SHA-256 | +| -------- | ------------------------------------------------------------------ | ------------------------------------------------------------------ | +| Main | `70eee832a22181051dd0a807ac8192f2d2863b0f6706c21199b98245abb11d82` | `8d24293dafe6879300ff618f62582ebc68483a1af85f09c943dcdf63009f2696` | +| Preload | `2d85a03ecdb77d9762630a9fe2783b4da2042e09e470a43250b697f24008f209` | `f211a353bdfcb09de909134f1c2d91c920819f20b3f8b26baca5e72ba9f6e05b` | +| Renderer | `0b5610733f4e17999a72ea356965276cd728320400af97fdd8c83c42a50a0e71` | `2d792b983f3d45c8d3a2e270599705518f45c2b4a936925256bbea04505b0d52` | + +Complete A and B manifest validation found 778 records, three HTML entries, 6,247 static edges, +213 dynamic edges, and 860 emitted references, with zero missing or escaping targets, zero +cross-entry imports, and zero cycles. + +An initial rejected renderer arm placed the desktop query in broad `runtime-git-client.ts`. Its +archive is `/tmp/orca-phase2-checks-snapshot-b.IaVlMF/out`; it added 643 raw bytes to each of +index, popout, and web. Moving the query to the concrete +`desktop-git-repository-snapshot-client.ts` boundary eliminates that eager raw-byte tax while +keeping the same main/preload cost and call-count win. + +## Validation + +- Focused owner/provider/filesystem/runtime/Checks suite: + `pnpm exec vitest run ...` — 276 passed, one skipped after the independent review added an + ambiguous embedded-upstream fallback regression. +- Broad host owner, status/cache, native/WSL, SSH provider/dispatch, and filesystem suite: + `pnpm exec vitest run ...` — 364 passed, one skipped. +- Broad renderer runtime, status refresh/polling, push-target cache, Checks, and folder-workspace + suite: `pnpm exec vitest run --config config/vitest.config.ts ...` — 128 passed. +- Independent combined host/renderer review suite: 481 passed, one skipped. +- `pnpm run typecheck:node`: passed. +- `pnpm run typecheck:web`: passed. +- Targeted `pnpm exec oxlint --deny-warnings`: passed. +- Targeted `pnpm exec oxfmt --check`: passed. +- `pnpm run check:max-lines-ratchet`: passed with 354 grandfathered suppressions and no new bypass. +- Fresh A and B `pnpm run build:electron-vite`: passed. +- `pnpm run check:electron-bundle-budgets`: passed against fresh B. +- Complete renderer manifest/path and static-cycle validation: passed for A and B. +- `git diff --check`: passed. +- Added/changed max-lines-disable scan: zero. + +## Limitations and next seam + +- Savings occur when polling has published an admissible projection before automatic Checks + demand. A cold Checks open, stale projection, mutation fence, branch transition, truncated + status, ambiguous upstream, or failed read deliberately pays the existing fresh work. +- The counts are deterministic mocked physical-owner and mux-boundary measurements, not live + subprocess latency measurements across every supported host. +- The read-only IPC is desktop-only. Active runtime environments intentionally fall back to their + existing fresh RPC route; runtime/mobile projection transport remains later work. +- This slice does not subscribe Checks to snapshot revisions. The next coherent seam is a bounded + host-to-renderer revision subscription that invalidates consumers without adding another polling + loop, followed by migration of remaining renderer status consumers. +- No packaged smoke was run on Windows, WSL, Linux, or a live SSH host. Existing routing, + identity, folder-workspace, provider, mutation-fence, and Git-command suites cover the unchanged + boundaries.