From 92ac61850d95d3af0ee8d96f578d4803ec00c1b4 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 30 Jul 2026 08:41:55 -0700 Subject: [PATCH] perf: reuse snapshots for mobile source control --- .../src/session/mobile-diff-review-loaders.ts | 4 +- ...le-diff-review-repository-snapshot.test.ts | 66 ---- .../mobile-diff-review-repository-snapshot.ts | 67 ---- mobile/src/session/mobile-diff-review-rpc.ts | 101 +----- .../session/use-mobile-pr-branch-context.ts | 3 +- .../mobile-git-repository-snapshot.test.ts | 200 +++++++++++ .../mobile-git-repository-snapshot.ts | 152 +++++++++ .../source-control/mobile-git-status-rpc.ts | 109 ++++++ .../mobile-hosted-review-git-preparation.ts | 2 +- .../source-control/mobile-open-pr-prefill.ts | 2 +- .../mobile-source-control-screen-state.ts | 10 +- .../mobile-source-control-status-read.test.ts | 259 +++++++++++++++ .../mobile-source-control-status-read.ts | 94 ++++++ .../use-mobile-source-control-initial-load.ts | 41 +++ .../use-mobile-source-control-loaders.test.ts | 310 ++++++++++++++++++ .../use-mobile-source-control-loaders.ts | 130 ++++---- ...ca-runtime-git-repository-snapshot.test.ts | 46 ++- ...repository-snapshot-consumer-2026-07-30.md | 18 +- ...repository-snapshot-consumer-2026-07-30.md | 161 +++++++++ 19 files changed, 1451 insertions(+), 324 deletions(-) delete mode 100644 mobile/src/session/mobile-diff-review-repository-snapshot.test.ts delete mode 100644 mobile/src/session/mobile-diff-review-repository-snapshot.ts create mode 100644 mobile/src/source-control/mobile-git-repository-snapshot.test.ts create mode 100644 mobile/src/source-control/mobile-git-repository-snapshot.ts create mode 100644 mobile/src/source-control/mobile-git-status-rpc.ts create mode 100644 mobile/src/source-control/mobile-source-control-status-read.test.ts create mode 100644 mobile/src/source-control/mobile-source-control-status-read.ts create mode 100644 mobile/src/source-control/use-mobile-source-control-initial-load.ts create mode 100644 mobile/src/source-control/use-mobile-source-control-loaders.test.ts create mode 100644 tools/benchmarks/results/phase2-mobile-source-control-repository-snapshot-consumer-2026-07-30.md diff --git a/mobile/src/session/mobile-diff-review-loaders.ts b/mobile/src/session/mobile-diff-review-loaders.ts index d7a244aa459..cb33c342646 100644 --- a/mobile/src/session/mobile-diff-review-loaders.ts +++ b/mobile/src/session/mobile-diff-review-loaders.ts @@ -9,11 +9,11 @@ import { buildMobileDiffHunks } from './mobile-diff-hunks' import { highlightMobileDiffLines, resolveMobileSyntaxLanguage } from './mobile-file-syntax' import { readMobileBranchCompareResult, - readMobileGitStatusResult, readMobileReviewGitDiffResult, readMobileReviewWorktreeMetadata } from './mobile-diff-review-rpc' -import { readMobileDiffReviewRepositorySnapshot } from './mobile-diff-review-repository-snapshot' +import { readMobileDiffReviewRepositorySnapshot } from '../source-control/mobile-git-repository-snapshot' +import { readMobileGitStatusResult } from '../source-control/mobile-git-status-rpc' import { canOpenMobileBranchCompareDiff, type MobileGitBranchCompareResult diff --git a/mobile/src/session/mobile-diff-review-repository-snapshot.test.ts b/mobile/src/session/mobile-diff-review-repository-snapshot.test.ts deleted file mode 100644 index 4d467bf9413..00000000000 --- a/mobile/src/session/mobile-diff-review-repository-snapshot.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { readMobileDiffReviewRepositorySnapshot } from './mobile-diff-review-repository-snapshot' - -function snapshot(overrides: Record = {}) { - const freshness = { - state: 'fresh', - generation: 3, - currentGeneration: 3, - revision: 7, - identity: 'default-status' - } - return { - repositoryIdentity: { head: 'abc123', branch: 'feature/mobile' }, - status: { - entries: [{ path: 'src/app.ts', status: 'modified', area: 'unstaged' }], - retentionTruncated: false - }, - conflicts: 'merge', - upstream: { hasUpstream: true, ahead: 99, behind: 99 }, - freshness: { - repositoryIdentity: { ...freshness }, - status: { ...freshness }, - conflicts: { ...freshness } - }, - ...overrides - } -} - -describe('mobile diff review repository snapshot parser', () => { - it('projects only status, repository identity, and conflict state', () => { - expect(readMobileDiffReviewRepositorySnapshot(snapshot())).toEqual({ - entries: [{ path: 'src/app.ts', status: 'modified', area: 'unstaged' }], - conflictOperation: 'merge', - branch: 'feature/mobile', - head: 'abc123', - upstreamStatus: undefined - }) - }) - - it.each([ - ['status', 'missing'], - ['status', 'stale'], - ['status', 'failed'], - ['repositoryIdentity', 'stale'], - ['conflicts', 'stale'] - ] as const)('rejects a %s projection in state %s', (projection, state) => { - const value = snapshot() - ;(value.freshness[projection] as Record).state = state - expect(readMobileDiffReviewRepositorySnapshot(value)).toBeNull() - }) - - it('rejects truncated, malformed, generation-stale, and identity-mismatched projections', () => { - const truncated = snapshot() - truncated.status.retentionTruncated = true - const malformed = snapshot() - malformed.status.entries = [{ path: 'src/app.ts', status: 'invalid', area: 'unstaged' }] - const generationStale = snapshot() - ;(generationStale.freshness.status as Record).currentGeneration = 4 - const mismatched = snapshot() - ;(mismatched.freshness.conflicts as Record).identity = 'other-status' - - for (const value of [truncated, malformed, generationStale, mismatched]) { - expect(readMobileDiffReviewRepositorySnapshot(value)).toBeNull() - } - }) -}) diff --git a/mobile/src/session/mobile-diff-review-repository-snapshot.ts b/mobile/src/session/mobile-diff-review-repository-snapshot.ts deleted file mode 100644 index 95020fd0380..00000000000 --- a/mobile/src/session/mobile-diff-review-repository-snapshot.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { MobileGitStatusResult } from '../source-control/mobile-git-status' -import { readMobileGitStatusResult } from './mobile-diff-review-rpc' - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null -} - -function readFreshSnapshotIdentity(value: unknown): string | null { - if ( - !isRecord(value) || - value.state !== 'fresh' || - typeof value.generation !== 'number' || - !Number.isFinite(value.generation) || - value.generation !== value.currentGeneration || - typeof value.revision !== 'number' || - !Number.isFinite(value.revision) || - typeof value.identity !== 'string' || - value.identity.length === 0 - ) { - return null - } - return value.identity -} - -export function readMobileDiffReviewRepositorySnapshot( - value: unknown -): MobileGitStatusResult | null { - if ( - !isRecord(value) || - !isRecord(value.status) || - !Array.isArray(value.status.entries) || - value.status.retentionTruncated !== false || - !isRecord(value.repositoryIdentity) || - !isRecord(value.freshness) - ) { - return null - } - const statusIdentity = readFreshSnapshotIdentity(value.freshness.status) - if ( - !statusIdentity || - readFreshSnapshotIdentity(value.freshness.repositoryIdentity) !== statusIdentity || - readFreshSnapshotIdentity(value.freshness.conflicts) !== statusIdentity - ) { - return null - } - const head = value.repositoryIdentity.head - const branch = value.repositoryIdentity.branch - const conflicts = value.conflicts - if ( - (head !== null && typeof head !== 'string') || - (branch !== null && typeof branch !== 'string') || - (conflicts !== null && - conflicts !== 'merge' && - conflicts !== 'rebase' && - conflicts !== 'cherry-pick' && - conflicts !== 'unknown') - ) { - return null - } - const status = readMobileGitStatusResult({ - entries: value.status.entries, - conflictOperation: conflicts ?? 'unknown', - ...(head ? { head } : {}), - ...(branch ? { branch } : {}) - }) - return status && status.entries.length === value.status.entries.length ? status : null -} diff --git a/mobile/src/session/mobile-diff-review-rpc.ts b/mobile/src/session/mobile-diff-review-rpc.ts index b3d1f25ed89..f5bcca281af 100644 --- a/mobile/src/session/mobile-diff-review-rpc.ts +++ b/mobile/src/session/mobile-diff-review-rpc.ts @@ -3,13 +3,8 @@ import type { MobileGitBranchCompareResult, MobileGitBranchCompareSummary } from '../source-control/mobile-branch-compare' -import type { - MobileGitFileStatus, - MobileGitStagingArea, - MobileGitStatusEntry, - MobileGitStatusResult, - MobileGitUpstreamStatus -} from '../source-control/mobile-git-status' +import { readMobileGitFileStatus } from '../source-control/mobile-git-status-rpc' +export { readMobileGitStatusResult } from '../source-control/mobile-git-status-rpc' export type MobileReviewGitDiffResult = | { @@ -43,96 +38,6 @@ function readNumber(value: unknown): number | undefined { return typeof value === 'number' && Number.isFinite(value) ? value : undefined } -function readBoolean(value: unknown): boolean | undefined { - return typeof value === 'boolean' ? value : undefined -} - -function readFileStatus(value: unknown): MobileGitFileStatus | null { - return value === 'modified' || - value === 'added' || - value === 'deleted' || - value === 'renamed' || - value === 'untracked' || - value === 'copied' - ? value - : null -} - -function readStagingArea(value: unknown): MobileGitStagingArea | null { - return value === 'staged' || value === 'unstaged' || value === 'untracked' ? value : null -} - -function readConflictOperation(value: unknown): MobileGitStatusResult['conflictOperation'] { - return value === 'merge' || value === 'rebase' || value === 'cherry-pick' || value === 'unknown' - ? value - : 'unknown' -} - -function readUpstreamStatus(value: unknown): MobileGitUpstreamStatus | undefined { - if (!isRecord(value)) { - return undefined - } - const hasUpstream = readBoolean(value.hasUpstream) - const ahead = readNumber(value.ahead) - const behind = readNumber(value.behind) - if (hasUpstream === undefined || ahead === undefined || behind === undefined) { - return undefined - } - return { - hasUpstream, - upstreamName: readString(value.upstreamName), - ahead, - behind, - hasConfiguredPushTarget: readBoolean(value.hasConfiguredPushTarget), - behindCommitsArePatchEquivalent: readBoolean(value.behindCommitsArePatchEquivalent) - } -} - -function readStatusEntry(value: unknown): MobileGitStatusEntry | null { - if (!isRecord(value)) { - return null - } - const path = readString(value.path) - const status = readFileStatus(value.status) - const area = readStagingArea(value.area) - if (!path || !status || !area) { - return null - } - return { - path, - status, - area, - oldPath: readString(value.oldPath), - conflictKind: undefined, - conflictStatus: - value.conflictStatus === 'unresolved' || value.conflictStatus === 'resolved_locally' - ? value.conflictStatus - : undefined, - conflictStatusSource: - value.conflictStatusSource === 'git' || value.conflictStatusSource === 'session' - ? value.conflictStatusSource - : undefined, - added: readNumber(value.added), - removed: readNumber(value.removed) - } -} - -export function readMobileGitStatusResult(value: unknown): MobileGitStatusResult | null { - if (!isRecord(value) || !Array.isArray(value.entries)) { - return null - } - return { - entries: value.entries.flatMap((entry): MobileGitStatusEntry[] => { - const parsed = readStatusEntry(entry) - return parsed ? [parsed] : [] - }), - conflictOperation: readConflictOperation(value.conflictOperation), - branch: readString(value.branch), - head: readString(value.head), - upstreamStatus: readUpstreamStatus(value.upstreamStatus) - } -} - function readBranchStatus(value: unknown): MobileGitBranchCompareSummary['status'] { return value === 'ready' || value === 'invalid-base' || @@ -149,7 +54,7 @@ function readBranchEntry(value: unknown): MobileGitBranchChangeEntry | null { return null } const path = readString(value.path) - const status = readFileStatus(value.status) + const status = readMobileGitFileStatus(value.status) if (!path || !status || status === 'untracked') { return null } diff --git a/mobile/src/session/use-mobile-pr-branch-context.ts b/mobile/src/session/use-mobile-pr-branch-context.ts index d0816b02f71..f8e1464fdad 100644 --- a/mobile/src/session/use-mobile-pr-branch-context.ts +++ b/mobile/src/session/use-mobile-pr-branch-context.ts @@ -5,7 +5,8 @@ import type { MobileGitBranchCompareResult } from '../source-control/mobile-bran import type { MobileGitStatusResult } from '../source-control/mobile-git-status' import { resolveMobileBranchCompareBaseRef } from '../source-control/mobile-branch-base-ref' import { fetchGithubRepoSlug } from './github-pr-rpc' -import { readMobileBranchCompareResult, readMobileGitStatusResult } from './mobile-diff-review-rpc' +import { readMobileBranchCompareResult } from './mobile-diff-review-rpc' +import { readMobileGitStatusResult } from '../source-control/mobile-git-status-rpc' export type MobilePrBranchContext = { branch: string | null diff --git a/mobile/src/source-control/mobile-git-repository-snapshot.test.ts b/mobile/src/source-control/mobile-git-repository-snapshot.test.ts new file mode 100644 index 00000000000..fca492dedb4 --- /dev/null +++ b/mobile/src/source-control/mobile-git-repository-snapshot.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, it } from 'vitest' +import { + readMobileDiffReviewRepositorySnapshot, + readMobileSourceControlRepositorySnapshot +} from './mobile-git-repository-snapshot' + +function snapshot(overrides: Record = {}) { + const freshness = { + state: 'fresh', + generation: 3, + currentGeneration: 3, + revision: 7, + identity: 'default-status' + } + return { + repositoryIdentity: { head: 'abc123', branch: 'feature/mobile' }, + status: { + entries: [{ path: 'src/app.ts', status: 'modified', area: 'unstaged' }], + retentionTruncated: false + }, + conflicts: 'merge', + upstream: { hasUpstream: true, ahead: 99, behind: 99 }, + freshness: { + repositoryIdentity: { ...freshness }, + status: { ...freshness }, + conflicts: { ...freshness }, + upstream: { ...freshness, identity: 'status:default-status' } + }, + ...overrides + } +} + +describe('mobile diff review repository snapshot parser', () => { + it('projects only status, repository identity, and conflict state', () => { + expect(readMobileDiffReviewRepositorySnapshot(snapshot())).toEqual({ + entries: [{ path: 'src/app.ts', status: 'modified', area: 'unstaged' }], + conflictOperation: 'merge', + branch: 'feature/mobile', + head: 'abc123', + upstreamStatus: undefined + }) + }) + + it.each([ + ['status', 'missing'], + ['status', 'stale'], + ['status', 'failed'], + ['repositoryIdentity', 'stale'], + ['conflicts', 'stale'] + ] as const)('rejects a %s projection in state %s', (projection, state) => { + const value = snapshot() + ;(value.freshness[projection] as Record).state = state + expect(readMobileDiffReviewRepositorySnapshot(value)).toBeNull() + }) + + it('rejects truncated, malformed, generation-stale, and identity-mismatched projections', () => { + const truncated = snapshot() + truncated.status.retentionTruncated = true + const malformed = snapshot() + malformed.status.entries = [{ path: 'src/app.ts', status: 'invalid', area: 'unstaged' }] + const generationStale = snapshot() + ;(generationStale.freshness.status as Record).currentGeneration = 4 + const mismatched = snapshot() + ;(mismatched.freshness.conflicts as Record).identity = 'other-status' + + for (const value of [truncated, malformed, generationStale, mismatched]) { + expect(readMobileDiffReviewRepositorySnapshot(value)).toBeNull() + } + }) +}) + +describe('mobile Source Control repository snapshot parser', () => { + it('projects a fresh configured upstream without equating its identity to status', () => { + const value = snapshot({ + upstream: { hasUpstream: true, upstreamName: 'origin/main', ahead: 2, behind: 0 } + }) + + expect(readMobileSourceControlRepositorySnapshot(value)).toMatchObject({ + branch: 'feature/mobile', + head: 'abc123', + upstreamStatus: { + hasUpstream: true, + upstreamName: 'origin/main', + ahead: 2, + behind: 0 + } + }) + }) + + it('preserves a fresh no-upstream projection', () => { + const value = snapshot({ + upstream: { hasUpstream: false, ahead: 0, behind: 0 } + }) + + expect(readMobileSourceControlRepositorySnapshot(value)?.upstreamStatus).toEqual({ + hasUpstream: false, + ahead: 0, + behind: 0, + upstreamName: undefined, + hasConfiguredPushTarget: undefined, + behindCommitsArePatchEquivalent: undefined + }) + }) + + it('preserves a fresh no-upstream projection with a configured push target', () => { + const value = snapshot({ + upstream: { + hasUpstream: false, + ahead: 0, + behind: 0, + hasConfiguredPushTarget: true + } + }) + + expect(readMobileSourceControlRepositorySnapshot(value)?.upstreamStatus).toMatchObject({ + hasUpstream: false, + ahead: 0, + behind: 0, + hasConfiguredPushTarget: true + }) + }) + + it.each(['missing', 'stale', 'failed'] as const)( + 'rejects a configured-upstream projection in state %s', + (state) => { + const value = snapshot({ + upstream: { hasUpstream: true, upstreamName: 'origin/main', ahead: 0, behind: 0 } + }) + value.freshness.upstream.state = state + expect(readMobileSourceControlRepositorySnapshot(value)).toBeNull() + } + ) + + it.each([ + ['missing upstream name', { hasUpstream: true, ahead: 0, behind: 0 }], + ['empty upstream name', { hasUpstream: true, upstreamName: '', ahead: 0, behind: 0 }], + ['wrong-typed upstream name', { hasUpstream: true, upstreamName: 42, ahead: 0, behind: 0 }], + [ + 'wrong-typed configured-push flag', + { + hasUpstream: true, + upstreamName: 'origin/main', + ahead: 0, + behind: 0, + hasConfiguredPushTarget: 'yes' + } + ], + [ + 'wrong-typed patch-equivalence flag', + { + hasUpstream: true, + upstreamName: 'origin/main', + ahead: 1, + behind: 1, + behindCommitsArePatchEquivalent: 'yes' + } + ] + ])('rejects %s', (_label, upstream) => { + expect(readMobileSourceControlRepositorySnapshot(snapshot({ upstream }))).toBeNull() + }) + + it.each([ + ['negative ahead', { hasUpstream: true, upstreamName: 'origin/main', ahead: -1, behind: 0 }], + ['negative behind', { hasUpstream: true, upstreamName: 'origin/main', ahead: 0, behind: -1 }], + ['fractional ahead', { hasUpstream: true, upstreamName: 'origin/main', ahead: 0.5, behind: 0 }], + ['fractional behind', { hasUpstream: true, upstreamName: 'origin/main', ahead: 0, behind: 0.5 }] + ])('rejects %s', (_label, upstream) => { + expect(readMobileSourceControlRepositorySnapshot(snapshot({ upstream }))).toBeNull() + }) + + it.each([ + ['an upstream name', { hasUpstream: false, upstreamName: 'origin/main', ahead: 0, behind: 0 }], + ['a nonzero ahead count', { hasUpstream: false, ahead: 1, behind: 0 }], + ['a nonzero behind count', { hasUpstream: false, ahead: 0, behind: 1 }], + [ + 'a false configured-push flag', + { hasUpstream: false, ahead: 0, behind: 0, hasConfiguredPushTarget: false } + ], + [ + 'patch equivalence', + { hasUpstream: false, ahead: 0, behind: 0, behindCommitsArePatchEquivalent: false } + ] + ])('rejects a no-upstream projection with %s', (_label, upstream) => { + expect(readMobileSourceControlRepositorySnapshot(snapshot({ upstream }))).toBeNull() + }) + + it('rejects malformed, ambiguous, and generation-mismatched configured upstreams', () => { + const malformed = snapshot({ upstream: { hasUpstream: true, ahead: 0 } }) + const ambiguous = snapshot({ upstream: { hasUpstream: true, ahead: 1, behind: 1 } }) + const generationMismatched = snapshot({ + upstream: { hasUpstream: true, ahead: 0, behind: 0 } + }) + generationMismatched.freshness.upstream.generation = 4 + generationMismatched.freshness.upstream.currentGeneration = 4 + + for (const value of [malformed, ambiguous, generationMismatched]) { + expect(readMobileSourceControlRepositorySnapshot(value)).toBeNull() + } + }) +}) diff --git a/mobile/src/source-control/mobile-git-repository-snapshot.ts b/mobile/src/source-control/mobile-git-repository-snapshot.ts new file mode 100644 index 00000000000..ad05a056a0f --- /dev/null +++ b/mobile/src/source-control/mobile-git-repository-snapshot.ts @@ -0,0 +1,152 @@ +import type { MobileGitStatusResult, MobileGitUpstreamStatus } from './mobile-git-status' +import { readMobileGitStatusResult, readMobileGitUpstreamStatus } from './mobile-git-status-rpc' + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +type FreshSnapshotProjection = { + identity: string + generation: number +} + +function readFreshSnapshotProjection(value: unknown): FreshSnapshotProjection | null { + if ( + !isRecord(value) || + value.state !== 'fresh' || + typeof value.generation !== 'number' || + !Number.isFinite(value.generation) || + value.generation !== value.currentGeneration || + typeof value.revision !== 'number' || + !Number.isFinite(value.revision) || + typeof value.identity !== 'string' || + value.identity.length === 0 + ) { + return null + } + return { identity: value.identity, generation: value.currentGeneration } +} + +function readMobileRepositoryStatusProjection(value: unknown): MobileGitStatusResult | null { + if ( + !isRecord(value) || + !isRecord(value.status) || + !Array.isArray(value.status.entries) || + value.status.retentionTruncated !== false || + !isRecord(value.repositoryIdentity) || + !isRecord(value.freshness) + ) { + return null + } + const statusProjection = readFreshSnapshotProjection(value.freshness.status) + const repositoryIdentityProjection = readFreshSnapshotProjection( + value.freshness.repositoryIdentity + ) + const conflictsProjection = readFreshSnapshotProjection(value.freshness.conflicts) + if ( + !statusProjection || + !repositoryIdentityProjection || + !conflictsProjection || + repositoryIdentityProjection.identity !== statusProjection.identity || + conflictsProjection.identity !== statusProjection.identity || + repositoryIdentityProjection.generation !== statusProjection.generation || + conflictsProjection.generation !== statusProjection.generation + ) { + return null + } + const head = value.repositoryIdentity.head + const branch = value.repositoryIdentity.branch + const conflicts = value.conflicts + if ( + (head !== null && typeof head !== 'string') || + (branch !== null && typeof branch !== 'string') || + (conflicts !== null && + conflicts !== 'merge' && + conflicts !== 'rebase' && + conflicts !== 'cherry-pick' && + conflicts !== 'unknown') + ) { + return null + } + const status = readMobileGitStatusResult({ + entries: value.status.entries, + conflictOperation: conflicts ?? 'unknown', + ...(head ? { head } : {}), + ...(branch ? { branch } : {}) + }) + return status && status.entries.length === value.status.entries.length ? status : null +} + +function hasOwn(value: Record, key: string): boolean { + return Object.prototype.hasOwnProperty.call(value, key) +} + +function readMobileRepositoryUpstream(value: unknown): MobileGitUpstreamStatus | null { + if ( + !isRecord(value) || + typeof value.hasUpstream !== 'boolean' || + typeof value.ahead !== 'number' || + !Number.isSafeInteger(value.ahead) || + value.ahead < 0 || + typeof value.behind !== 'number' || + !Number.isSafeInteger(value.behind) || + value.behind < 0 + ) { + return null + } + const hasUpstreamName = hasOwn(value, 'upstreamName') + const hasConfiguredPushTarget = hasOwn(value, 'hasConfiguredPushTarget') + const hasPatchEquivalence = hasOwn(value, 'behindCommitsArePatchEquivalent') + if ( + (hasConfiguredPushTarget && typeof value.hasConfiguredPushTarget !== 'boolean') || + (hasPatchEquivalence && typeof value.behindCommitsArePatchEquivalent !== 'boolean') + ) { + return null + } + if (value.hasUpstream) { + if (typeof value.upstreamName !== 'string' || value.upstreamName.length === 0) { + return null + } + } else if ( + value.ahead !== 0 || + value.behind !== 0 || + hasUpstreamName || + hasPatchEquivalence || + (hasConfiguredPushTarget && value.hasConfiguredPushTarget !== true) + ) { + return null + } + return readMobileGitUpstreamStatus(value) ?? null +} + +export function readMobileDiffReviewRepositorySnapshot( + value: unknown +): MobileGitStatusResult | null { + return readMobileRepositoryStatusProjection(value) +} + +export function readMobileSourceControlRepositorySnapshot( + value: unknown +): MobileGitStatusResult | null { + if (!isRecord(value) || !isRecord(value.freshness)) { + return null + } + const statusProjection = readFreshSnapshotProjection(value.freshness.status) + const upstreamProjection = readFreshSnapshotProjection(value.freshness.upstream) + const status = readMobileRepositoryStatusProjection(value) + const upstreamStatus = readMobileRepositoryUpstream(value.upstream) + if ( + !statusProjection || + !upstreamProjection || + upstreamProjection.generation !== statusProjection.generation || + !status || + !upstreamStatus || + (!upstreamStatus.hasUpstream && (upstreamStatus.ahead !== 0 || upstreamStatus.behind !== 0)) || + (upstreamStatus.ahead > 0 && + upstreamStatus.behind > 0 && + upstreamStatus.behindCommitsArePatchEquivalent === undefined) + ) { + return null + } + return { ...status, upstreamStatus } +} diff --git a/mobile/src/source-control/mobile-git-status-rpc.ts b/mobile/src/source-control/mobile-git-status-rpc.ts new file mode 100644 index 00000000000..15121ad1645 --- /dev/null +++ b/mobile/src/source-control/mobile-git-status-rpc.ts @@ -0,0 +1,109 @@ +import type { + MobileGitFileStatus, + MobileGitStagingArea, + MobileGitStatusEntry, + MobileGitStatusResult, + MobileGitUpstreamStatus +} from './mobile-git-status' + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function readString(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined +} + +function readNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined +} + +function readBoolean(value: unknown): boolean | undefined { + return typeof value === 'boolean' ? value : undefined +} + +export function readMobileGitFileStatus(value: unknown): MobileGitFileStatus | null { + return value === 'modified' || + value === 'added' || + value === 'deleted' || + value === 'renamed' || + value === 'untracked' || + value === 'copied' + ? value + : null +} + +function readStagingArea(value: unknown): MobileGitStagingArea | null { + return value === 'staged' || value === 'unstaged' || value === 'untracked' ? value : null +} + +function readConflictOperation(value: unknown): MobileGitStatusResult['conflictOperation'] { + return value === 'merge' || value === 'rebase' || value === 'cherry-pick' || value === 'unknown' + ? value + : 'unknown' +} + +export function readMobileGitUpstreamStatus(value: unknown): MobileGitUpstreamStatus | undefined { + if (!isRecord(value)) { + return undefined + } + const hasUpstream = readBoolean(value.hasUpstream) + const ahead = readNumber(value.ahead) + const behind = readNumber(value.behind) + if (hasUpstream === undefined || ahead === undefined || behind === undefined) { + return undefined + } + return { + hasUpstream, + upstreamName: readString(value.upstreamName), + ahead, + behind, + hasConfiguredPushTarget: readBoolean(value.hasConfiguredPushTarget), + behindCommitsArePatchEquivalent: readBoolean(value.behindCommitsArePatchEquivalent) + } +} + +function readStatusEntry(value: unknown): MobileGitStatusEntry | null { + if (!isRecord(value)) { + return null + } + const path = readString(value.path) + const status = readMobileGitFileStatus(value.status) + const area = readStagingArea(value.area) + if (!path || !status || !area) { + return null + } + return { + path, + status, + area, + oldPath: readString(value.oldPath), + conflictKind: undefined, + conflictStatus: + value.conflictStatus === 'unresolved' || value.conflictStatus === 'resolved_locally' + ? value.conflictStatus + : undefined, + conflictStatusSource: + value.conflictStatusSource === 'git' || value.conflictStatusSource === 'session' + ? value.conflictStatusSource + : undefined, + added: readNumber(value.added), + removed: readNumber(value.removed) + } +} + +export function readMobileGitStatusResult(value: unknown): MobileGitStatusResult | null { + if (!isRecord(value) || !Array.isArray(value.entries)) { + return null + } + return { + entries: value.entries.flatMap((entry): MobileGitStatusEntry[] => { + const parsed = readStatusEntry(entry) + return parsed ? [parsed] : [] + }), + conflictOperation: readConflictOperation(value.conflictOperation), + branch: readString(value.branch), + head: readString(value.head), + upstreamStatus: readMobileGitUpstreamStatus(value.upstreamStatus) + } +} diff --git a/mobile/src/source-control/mobile-hosted-review-git-preparation.ts b/mobile/src/source-control/mobile-hosted-review-git-preparation.ts index 93de10eb357..8267a57c309 100644 --- a/mobile/src/source-control/mobile-hosted-review-git-preparation.ts +++ b/mobile/src/source-control/mobile-hosted-review-git-preparation.ts @@ -1,6 +1,6 @@ import type { RpcClient } from '../transport/rpc-client' import type { RpcSuccess } from '../transport/types' -import { readMobileGitStatusResult } from '../session/mobile-diff-review-rpc' +import { readMobileGitStatusResult } from './mobile-git-status-rpc' import type { MobileGitStatusResult } from './mobile-git-status' export type MobileHostedReviewStatusReadResult = diff --git a/mobile/src/source-control/mobile-open-pr-prefill.ts b/mobile/src/source-control/mobile-open-pr-prefill.ts index 705b4dc68e5..5be309519ea 100644 --- a/mobile/src/source-control/mobile-open-pr-prefill.ts +++ b/mobile/src/source-control/mobile-open-pr-prefill.ts @@ -1,5 +1,5 @@ import type { RpcClient } from '../transport/rpc-client' -import { readMobileGitStatusResult } from '../session/mobile-diff-review-rpc' +import { readMobileGitStatusResult } from './mobile-git-status-rpc' import type { MobileGitStatusResult } from './mobile-git-status' import { resolveMobilePrPrefill, type MobilePrPrefill } from './mobile-pr-create' diff --git a/mobile/src/source-control/mobile-source-control-screen-state.ts b/mobile/src/source-control/mobile-source-control-screen-state.ts index 78a588d19ff..b1e5e19578a 100644 --- a/mobile/src/source-control/mobile-source-control-screen-state.ts +++ b/mobile/src/source-control/mobile-source-control-screen-state.ts @@ -19,6 +19,7 @@ import type { MobileGitBranchCompareResult, MobileGitBranchCompareSummary } from './mobile-branch-compare' +import type { ConnectionState } from '../transport/types' import { canOpenMobileGitStatusEntry, isMobileGitDiscardableEntry, @@ -43,7 +44,9 @@ export type LoadStatusOptions = { export type StatusLoadInFlight = { key: string client: unknown + connState: ConnectionState promise: Promise + preferRepositorySnapshot: boolean } export type GitRequestError = Error & { code?: string } @@ -116,17 +119,10 @@ export const SOURCE_CONTROL_ACTION_ICONS: Record { - return new Promise((resolve) => setTimeout(resolve, ms)) -} - export function formatBranchLabel(branch: string | undefined, head: string | undefined): string { if (branch?.startsWith('refs/heads/')) { return branch.slice('refs/heads/'.length) diff --git a/mobile/src/source-control/mobile-source-control-status-read.test.ts b/mobile/src/source-control/mobile-source-control-status-read.test.ts new file mode 100644 index 00000000000..b8a3597e732 --- /dev/null +++ b/mobile/src/source-control/mobile-source-control-status-read.test.ts @@ -0,0 +1,259 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import type { RpcResponse } from '../transport/types' +import { + isMobileSourceControlStatusLoadContextCurrent, + readMobileSourceControlStatus, + type MobileSourceControlStatusLoadContext +} from './mobile-source-control-status-read' + +function ok(result: unknown): RpcResponse { + return { id: 'r', ok: true, result, _meta: { runtimeId: 'runtime-1' } } +} + +function failure(code: string, message: string): RpcResponse { + return { id: 'r', ok: false, error: { code, message }, _meta: { runtimeId: 'runtime-1' } } +} + +function repositorySnapshot() { + const freshness = { + state: 'fresh', + generation: 2, + currentGeneration: 2, + revision: 5, + identity: 'default-status' + } + return { + repositoryIdentity: { head: 'abc', branch: 'feature/mobile' }, + status: { + entries: [{ path: 'app.ts', status: 'modified', area: 'unstaged' }], + retentionTruncated: false + }, + conflicts: 'unknown', + upstream: { hasUpstream: true, upstreamName: 'origin/main', ahead: 1, behind: 0 }, + freshness: { + repositoryIdentity: { ...freshness }, + status: { ...freshness }, + conflicts: { ...freshness }, + upstream: { ...freshness, identity: 'status:default-status' } + } + } +} + +function clientWith(responses: RpcResponse[]) { + const sendRequest = vi.fn(async (): Promise => { + const response = responses.shift() + if (!response) { + throw new Error('Unexpected RPC') + } + return response + }) + return { client: { sendRequest } as unknown as RpcClient, sendRequest } +} + +afterEach(() => { + vi.useRealTimers() +}) + +describe('readMobileSourceControlStatus', () => { + it('uses an admissible repository snapshot without status work', async () => { + const { client, sendRequest } = clientWith([ok(repositorySnapshot())]) + + const result = await readMobileSourceControlStatus({ + client, + worktreeId: 'repo::wt', + preferRepositorySnapshot: true, + isCurrent: () => true + }) + + expect(result).toMatchObject({ kind: 'ready', status: { head: 'abc' } }) + expect(sendRequest).toHaveBeenCalledOnce() + expect(sendRequest).toHaveBeenCalledWith('git.repositorySnapshot', { + worktree: 'id:repo::wt' + }) + }) + + it.each([ + ['old runtime', failure('method_not_found', 'Unknown method')], + ['missing snapshot', ok(null)], + ['malformed snapshot', ok({ nope: true })], + [ + 'stale snapshot', + (() => { + const value = repositorySnapshot() + value.freshness.upstream.state = 'stale' + return ok(value) + })() + ], + [ + 'truncated snapshot', + (() => { + const value = repositorySnapshot() + value.status.retentionTruncated = true + return ok(value) + })() + ], + [ + 'status-identity mismatch', + (() => { + const value = repositorySnapshot() + value.freshness.conflicts.identity = 'other-status' + return ok(value) + })() + ] + ])('falls back to fresh status for %s', async (_label, snapshotResponse) => { + const { client, sendRequest } = clientWith([ + snapshotResponse, + ok({ + entries: [], + conflictOperation: 'unknown', + upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 } + }) + ]) + + const result = await readMobileSourceControlStatus({ + client, + worktreeId: 'repo::wt', + preferRepositorySnapshot: true, + isCurrent: () => true + }) + + expect(result.kind).toBe('ready') + expect(sendRequest.mock.calls.map(([method]) => method)).toEqual([ + 'git.repositorySnapshot', + 'git.status' + ]) + }) + + it('preserves selector retries on the fresh fallback', async () => { + vi.useFakeTimers() + const { client, sendRequest } = clientWith([ + ok(null), + failure('selector_not_found', 'Not ready'), + ok({ + entries: [], + conflictOperation: 'unknown', + upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 } + }) + ]) + + const pending = readMobileSourceControlStatus({ + client, + worktreeId: 'repo::wt', + preferRepositorySnapshot: true, + isCurrent: () => true + }) + await vi.runAllTimersAsync() + + await expect(pending).resolves.toMatchObject({ kind: 'ready' }) + expect(sendRequest).toHaveBeenCalledTimes(3) + }) + + it('falls back when the best-effort snapshot query rejects', async () => { + const sendRequest = vi + .fn() + .mockRejectedValueOnce(new Error('disconnected')) + .mockResolvedValueOnce( + ok({ + entries: [], + conflictOperation: 'unknown', + upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 } + }) + ) + + await expect( + readMobileSourceControlStatus({ + client: { sendRequest } as unknown as RpcClient, + worktreeId: 'repo::wt', + preferRepositorySnapshot: true, + isCurrent: () => true + }) + ).resolves.toMatchObject({ kind: 'ready' }) + expect(sendRequest.mock.calls.map(([method]) => method)).toEqual([ + 'git.repositorySnapshot', + 'git.status' + ]) + }) + + it('preserves the fresh status unavailable result after a rejected snapshot', async () => { + const { client } = clientWith([ + ok(null), + failure('method_not_found', 'Update Orca desktop to use Source Control on mobile.') + ]) + + await expect( + readMobileSourceControlStatus({ + client, + worktreeId: 'repo::wt', + preferRepositorySnapshot: true, + isCurrent: () => true + }) + ).resolves.toEqual({ kind: 'unavailable' }) + }) + + it('does not launch fallback status after a late snapshot loses liveness', async () => { + let resolveSnapshot!: (response: RpcResponse) => void + let current = true + const sendRequest = vi.fn( + () => + new Promise((resolve) => { + resolveSnapshot = resolve + }) + ) + const pending = readMobileSourceControlStatus({ + client: { sendRequest } as unknown as RpcClient, + worktreeId: 'repo::wt', + preferRepositorySnapshot: true, + isCurrent: () => current + }) + + current = false + resolveSnapshot(ok(repositorySnapshot())) + + await expect(pending).resolves.toEqual({ kind: 'cancelled' }) + expect(sendRequest).toHaveBeenCalledOnce() + }) + + it.each([ + [ + 'same-identity client replacement', + (captured: MobileSourceControlStatusLoadContext) => ({ + ...captured, + client: { sendRequest: vi.fn() } as unknown as RpcClient + }) + ], + [ + 'disconnection', + (captured: MobileSourceControlStatusLoadContext) => ({ + ...captured, + connState: 'disconnected' as const + }) + ] + ])('suppresses a pending snapshot after render-current %s', async (_label, replaceContext) => { + let resolveSnapshot!: (response: RpcResponse) => void + const sendRequest = vi.fn( + () => + new Promise((resolve) => { + resolveSnapshot = resolve + }) + ) + const captured: MobileSourceControlStatusLoadContext = { + client: { sendRequest } as unknown as RpcClient, + connState: 'connected', + statusIdentityKey: 'host-1\0wt-1' + } + let current = captured + const pending = readMobileSourceControlStatus({ + client: captured.client!, + worktreeId: 'wt-1', + preferRepositorySnapshot: true, + isCurrent: () => isMobileSourceControlStatusLoadContextCurrent(captured, current) + }) + + current = replaceContext(captured) + resolveSnapshot(ok(repositorySnapshot())) + + await expect(pending).resolves.toEqual({ kind: 'cancelled' }) + expect(sendRequest).toHaveBeenCalledOnce() + }) +}) diff --git a/mobile/src/source-control/mobile-source-control-status-read.ts b/mobile/src/source-control/mobile-source-control-status-read.ts new file mode 100644 index 00000000000..a290499d8dd --- /dev/null +++ b/mobile/src/source-control/mobile-source-control-status-read.ts @@ -0,0 +1,94 @@ +import type { RpcClient } from '../transport/rpc-client' +import type { ConnectionState, RpcSuccess } from '../transport/types' +import { readMobileSourceControlRepositorySnapshot } from './mobile-git-repository-snapshot' +import { + isMobileGitUnavailable, + isMobileGitTransientRefreshError, + type MobileGitStatusResult +} from './mobile-git-status' + +const SELECTOR_RETRY_COUNT = 3 +const SELECTOR_RETRY_DELAY_MS = 250 + +function wait(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +export type MobileSourceControlStatusReadResult = + | { kind: 'ready'; status: MobileGitStatusResult } + | { kind: 'unavailable' } + | { kind: 'cancelled' } + +type StatusReadInput = { + client: RpcClient + worktreeId: string + preferRepositorySnapshot: boolean + isCurrent: () => boolean +} + +export type MobileSourceControlStatusLoadContext = { + client: RpcClient | null + connState: ConnectionState + statusIdentityKey: string +} + +export function isMobileSourceControlStatusLoadContextCurrent( + captured: MobileSourceControlStatusLoadContext, + current: MobileSourceControlStatusLoadContext +): boolean { + return ( + captured.client === current.client && + captured.connState === current.connState && + captured.statusIdentityKey === current.statusIdentityKey + ) +} + +export async function readMobileSourceControlStatus( + input: StatusReadInput +): Promise { + const selector = { worktree: `id:${input.worktreeId}` } + if (input.preferRepositorySnapshot) { + try { + const response = await input.client.sendRequest('git.repositorySnapshot', selector) + if (!input.isCurrent()) { + return { kind: 'cancelled' } + } + const status = response.ok ? readMobileSourceControlRepositorySnapshot(response.result) : null + if (status) { + return { kind: 'ready', status } + } + } catch { + if (!input.isCurrent()) { + return { kind: 'cancelled' } + } + } + } + + for (let attempt = 0; attempt <= SELECTOR_RETRY_COUNT; attempt += 1) { + const response = await input.client.sendRequest('git.status', selector) + if (!input.isCurrent()) { + return { kind: 'cancelled' } + } + if (response.ok) { + return { + kind: 'ready', + status: (response as RpcSuccess).result as MobileGitStatusResult + } + } + if (isMobileGitUnavailable(response.error?.code, response.error?.message)) { + return { kind: 'unavailable' } + } + const shouldRetry = + response.error?.code === 'selector_not_found' || + isMobileGitTransientRefreshError(response.error?.code, response.error?.message) + if (shouldRetry && attempt < SELECTOR_RETRY_COUNT) { + await wait(SELECTOR_RETRY_DELAY_MS) + if (!input.isCurrent()) { + return { kind: 'cancelled' } + } + continue + } + throw new Error(response.error?.message || 'Unable to load source control') + } + throw new Error('Unable to load source control') +} diff --git a/mobile/src/source-control/use-mobile-source-control-initial-load.ts b/mobile/src/source-control/use-mobile-source-control-initial-load.ts new file mode 100644 index 00000000000..6f5859e46a7 --- /dev/null +++ b/mobile/src/source-control/use-mobile-source-control-initial-load.ts @@ -0,0 +1,41 @@ +import { useEffect, useRef } from 'react' +import type { RpcClient } from '../transport/rpc-client' +import type { ConnectionState } from '../transport/types' +import type { LoadStatusOptions } from './mobile-source-control-screen-state' + +type InitialLoadInput = { + client: RpcClient | null + connState: ConnectionState + statusIdentityKey: string + loadStatus: (options?: LoadStatusOptions, preferRepositorySnapshot?: boolean) => Promise +} + +export function useMobileSourceControlInitialLoad(input: InitialLoadInput): void { + const initialLoadRef = useRef<{ + client: RpcClient | null + statusIdentityKey: string + connState: ConnectionState + usedRepositorySnapshot: boolean + } | null>(null) + + useEffect(() => { + const current = initialLoadRef.current + const sameContext = + current?.client === input.client && current.statusIdentityKey === input.statusIdentityKey + if (sameContext && current.connState === input.connState) { + return + } + const preferRepositorySnapshot = + input.client !== null && + input.connState === 'connected' && + !(sameContext && current.usedRepositorySnapshot) + initialLoadRef.current = { + client: input.client, + statusIdentityKey: input.statusIdentityKey, + connState: input.connState, + usedRepositorySnapshot: + preferRepositorySnapshot || (sameContext && current.usedRepositorySnapshot) + } + void input.loadStatus(undefined, preferRepositorySnapshot) + }, [input.client, input.connState, input.loadStatus, input.statusIdentityKey]) +} diff --git a/mobile/src/source-control/use-mobile-source-control-loaders.test.ts b/mobile/src/source-control/use-mobile-source-control-loaders.test.ts new file mode 100644 index 00000000000..fa971761fef --- /dev/null +++ b/mobile/src/source-control/use-mobile-source-control-loaders.test.ts @@ -0,0 +1,310 @@ +import { createElement, StrictMode } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import type { ConnectionState, RpcResponse } from '../transport/types' +import { useMobileSourceControlLoaders } from './use-mobile-source-control-loaders' + +vi.mock('./mobile-branch-base-ref', () => ({ + resolveMobileBranchCompareBaseRef: vi.fn().mockResolvedValue(null) +})) + +function ok(result: unknown): RpcResponse { + return { id: 'r', ok: true, result, _meta: { runtimeId: 'runtime-1' } } +} + +function failure(code: string, message: string): RpcResponse { + return { id: 'r', ok: false, error: { code, message }, _meta: { runtimeId: 'runtime-1' } } +} + +function repositorySnapshot() { + const freshness = { + state: 'fresh', + generation: 2, + currentGeneration: 2, + revision: 5, + identity: 'default-status' + } + return { + repositoryIdentity: { head: 'snapshot', branch: 'feature/mobile' }, + status: { entries: [], retentionTruncated: false }, + conflicts: 'unknown', + upstream: { hasUpstream: false, ahead: 0, behind: 0 }, + freshness: { + repositoryIdentity: { ...freshness }, + status: { ...freshness }, + conflicts: { ...freshness }, + upstream: { ...freshness, identity: 'status:default-status' } + } + } +} + +function freshStatus(head = 'fresh') { + return { + entries: [], + conflictOperation: 'unknown', + head, + branch: 'feature/mobile', + upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 } + } +} + +type HarnessProps = { + client: RpcClient + connState?: ConnectionState + statusIdentityKey?: string + worktreeId?: string +} + +let captured: ReturnType | null = null + +function Harness(props: HarnessProps): null { + captured = useMobileSourceControlLoaders({ + client: props.client, + connState: props.connState ?? 'connected', + statusIdentityKey: props.statusIdentityKey ?? 'host-1\0wt-1', + worktreeId: props.worktreeId ?? 'wt-1', + setActionError: vi.fn() + }) + return null +} + +async function flush(): Promise { + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) +} + +describe('useMobileSourceControlLoaders initial repository snapshot', () => { + let renderer: ReactTestRenderer | null = null + + beforeEach(() => { + captured = null + vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + if ( + typeof args[0] !== 'string' || + (!args[0].includes('react-test-renderer is deprecated') && + !args[0].includes('The current testing environment is not configured')) + ) { + throw new Error(String(args[0])) + } + }) + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + vi.restoreAllMocks() + }) + + it('coalesces the first connected StrictMode effect', async () => { + const sendRequest = vi.fn(async () => ok(repositorySnapshot())) + const client = { sendRequest } as unknown as RpcClient + + await act(async () => { + renderer = create(createElement(StrictMode, null, createElement(Harness, { client }))) + }) + await flush() + + expect(sendRequest).toHaveBeenCalledOnce() + expect(sendRequest).toHaveBeenCalledWith('git.repositorySnapshot', { + worktree: 'id:wt-1' + }) + }) + + it('preserves the snapshot opportunity until an initially disconnected context connects', async () => { + const sendRequest = vi.fn(async () => ok(repositorySnapshot())) + const client = { sendRequest } as unknown as RpcClient + + await act(async () => { + renderer = create(createElement(Harness, { client, connState: 'disconnected' })) + }) + expect(sendRequest).not.toHaveBeenCalled() + + await act(async () => { + renderer?.update(createElement(Harness, { client, connState: 'connected' })) + }) + await flush() + expect(sendRequest.mock.calls.map(([method]) => method)).toEqual(['git.repositorySnapshot']) + }) + + it('uses fresh status after reconnect and for retry, refresh, and forced mutation loads', async () => { + const sendRequest = vi.fn(async (method: string) => + method === 'git.repositorySnapshot' ? ok(repositorySnapshot()) : ok(freshStatus()) + ) + const client = { sendRequest } as unknown as RpcClient + + await act(async () => { + renderer = create(createElement(Harness, { client })) + }) + await act(async () => { + renderer?.update(createElement(Harness, { client, connState: 'disconnected' })) + }) + await act(async () => { + renderer?.update(createElement(Harness, { client, connState: 'connected' })) + }) + await act(async () => { + await captured?.loadStatus() + await captured?.loadStatus({ preserveReadyOnFailure: true }) + await captured?.loadStatus({ force: true, preserveReadyOnFailure: true }) + }) + + expect(sendRequest.mock.calls.map(([method]) => method)).toEqual([ + 'git.repositorySnapshot', + 'git.status', + 'git.status', + 'git.status', + 'git.status' + ]) + }) + + it('gives replacement host, worktree, and client contexts one new snapshot opportunity', async () => { + const firstSend = vi.fn(async () => ok(repositorySnapshot())) + const secondSend = vi.fn(async () => ok(repositorySnapshot())) + const firstClient = { sendRequest: firstSend } as unknown as RpcClient + const secondClient = { sendRequest: secondSend } as unknown as RpcClient + + await act(async () => { + renderer = create(createElement(Harness, { client: firstClient })) + }) + await act(async () => { + renderer?.update( + createElement(Harness, { + client: firstClient, + statusIdentityKey: 'host-2\0wt-1' + }) + ) + }) + await act(async () => { + renderer?.update( + createElement(Harness, { + client: firstClient, + statusIdentityKey: 'host-2\0wt-2', + worktreeId: 'wt-2' + }) + ) + }) + await act(async () => { + renderer?.update( + createElement(Harness, { + client: secondClient, + statusIdentityKey: 'host-2\0wt-2', + worktreeId: 'wt-2' + }) + ) + }) + + expect(firstSend.mock.calls.map(([method]) => method)).toEqual([ + 'git.repositorySnapshot', + 'git.repositorySnapshot', + 'git.repositorySnapshot' + ]) + expect(secondSend.mock.calls.map(([method]) => method)).toEqual(['git.repositorySnapshot']) + }) + + it('suppresses a late snapshot after client replacement', async () => { + let resolveFirstSnapshot!: (response: RpcResponse) => void + const firstSend = vi.fn( + () => + new Promise((resolve) => { + resolveFirstSnapshot = resolve + }) + ) + const secondSend = vi.fn(async () => ok(repositorySnapshot())) + const firstClient = { sendRequest: firstSend } as unknown as RpcClient + const secondClient = { sendRequest: secondSend } as unknown as RpcClient + + await act(async () => { + renderer = create(createElement(Harness, { client: firstClient })) + }) + await act(async () => { + renderer?.update(createElement(Harness, { client: secondClient })) + }) + await act(async () => { + resolveFirstSnapshot(ok({ ...repositorySnapshot(), repositoryIdentity: { head: 'stale' } })) + await Promise.resolve() + }) + + expect(captured?.screenState).toMatchObject({ + kind: 'ready', + status: { head: 'snapshot' } + }) + expect(firstSend).toHaveBeenCalledOnce() + expect(secondSend).toHaveBeenCalledOnce() + }) + + it('preserves the existing unavailable screen copy after snapshot fallback', async () => { + const sendRequest = vi + .fn() + .mockResolvedValueOnce(ok(null)) + .mockResolvedValueOnce( + failure('method_not_found', 'Update Orca desktop to use Source Control on mobile.') + ) + const client = { sendRequest } as unknown as RpcClient + + await act(async () => { + renderer = create(createElement(Harness, { client })) + }) + + expect(captured?.screenState).toEqual({ + kind: 'unavailable', + message: 'Update Orca desktop to use Source Control on mobile.' + }) + }) + + it('suppresses a late snapshot after a fresh load replaces it', async () => { + let resolveSnapshot!: (response: RpcResponse) => void + const sendRequest = vi.fn((method: string) => + method === 'git.repositorySnapshot' + ? new Promise((resolve) => { + resolveSnapshot = resolve + }) + : Promise.resolve(ok(freshStatus('fresh-wins'))) + ) + const client = { sendRequest } as unknown as RpcClient + + await act(async () => { + renderer = create(createElement(Harness, { client })) + }) + await act(async () => { + await captured?.loadStatus({ force: true }) + }) + await act(async () => { + resolveSnapshot(ok(repositorySnapshot())) + await Promise.resolve() + }) + + expect(captured?.screenState).toMatchObject({ + kind: 'ready', + status: { head: 'fresh-wins' } + }) + expect(sendRequest.mock.calls.map(([method]) => method)).toEqual([ + 'git.repositorySnapshot', + 'git.status' + ]) + }) + + it('suppresses a late snapshot after the mounted screen detaches', async () => { + let resolveSnapshot!: (response: RpcResponse) => void + const sendRequest = vi.fn( + () => + new Promise((resolve) => { + resolveSnapshot = resolve + }) + ) + const client = { sendRequest } as unknown as RpcClient + + await act(async () => { + renderer = create(createElement(Harness, { client })) + }) + captured?.setRootRef(null) + await act(async () => { + resolveSnapshot(ok(repositorySnapshot())) + await Promise.resolve() + }) + + expect(captured?.screenState).toEqual({ kind: 'loading' }) + expect(sendRequest).toHaveBeenCalledOnce() + }) +}) diff --git a/mobile/src/source-control/use-mobile-source-control-loaders.ts b/mobile/src/source-control/use-mobile-source-control-loaders.ts index 86055d21d3f..9d502ae2459 100644 --- a/mobile/src/source-control/use-mobile-source-control-loaders.ts +++ b/mobile/src/source-control/use-mobile-source-control-loaders.ts @@ -1,23 +1,22 @@ -import { useCallback, useEffect, useRef, useState, type MutableRefObject } from 'react' +import { useCallback, useRef, useState, type MutableRefObject } from 'react' import { View } from 'react-native' import type { RpcClient } from '../transport/rpc-client' import type { ConnectionState, RpcSuccess } from '../transport/types' import { resolveMobileBranchCompareBaseRef } from './mobile-branch-base-ref' -import { - isMobileGitUnavailable, - isMobileGitTransientRefreshError, - type MobileGitStatusResult -} from './mobile-git-status' +import { isMobileGitUnavailable } from './mobile-git-status' import type { MobileGitBranchCompareResult } from './mobile-branch-compare' -import { - SELECTOR_RETRY_COUNT, - SELECTOR_RETRY_DELAY_MS, - wait, - type LoadStatusOptions, - type MobileBranchCompareState, - type ScreenState, - type StatusLoadInFlight +import type { + LoadStatusOptions, + MobileBranchCompareState, + ScreenState, + StatusLoadInFlight } from './mobile-source-control-screen-state' +import { + isMobileSourceControlStatusLoadContextCurrent, + readMobileSourceControlStatus, + type MobileSourceControlStatusLoadContext +} from './mobile-source-control-status-read' +import { useMobileSourceControlInitialLoad } from './use-mobile-source-control-initial-load' type Params = { client: RpcClient | null @@ -49,7 +48,11 @@ export function useMobileSourceControlLoaders(params: Params): MobileSourceContr const [branchCompareState, setBranchCompareState] = useState({ kind: 'idle' }) - const currentStatusIdentityRef = useRef('') + const currentStatusLoadContextRef = useRef({ + client, + connState, + statusIdentityKey + }) const currentBranchCompareIdentityRef = useRef('') const loadGenerationRef = useRef(0) const branchCompareGenerationRef = useRef(0) @@ -65,7 +68,7 @@ export function useMobileSourceControlLoaders(params: Params): MobileSourceContr setScreenState({ kind: 'loading' }) setBranchCompareState({ kind: 'idle' }) } - currentStatusIdentityRef.current = statusIdentityKey + currentStatusLoadContextRef.current = { client, connState, statusIdentityKey } currentBranchCompareIdentityRef.current = statusIdentityKey const setRootRef = useCallback((node: View | null): void => { @@ -159,20 +162,35 @@ export function useMobileSourceControlLoaders(params: Params): MobileSourceContr ) const loadStatus = useCallback( - async (options?: LoadStatusOptions) => { + async (options?: LoadStatusOptions, preferRepositorySnapshot = false) => { const loadKey = statusIdentityKey const inFlight = statusLoadInFlightRef.current - if (inFlight && !options?.force && inFlight.key === loadKey && inFlight.client === client) { + if ( + inFlight && + !options?.force && + inFlight.key === loadKey && + inFlight.client === client && + inFlight.connState === connState && + (preferRepositorySnapshot || !inFlight.preferRepositorySnapshot) + ) { return await inFlight.promise } + const capturedStatusLoadContext = { + client, + connState, + statusIdentityKey: loadKey + } const loadPromise = (async () => { const generation = loadGenerationRef.current + 1 loadGenerationRef.current = generation const isCurrentLoad = () => mountedRef.current && loadGenerationRef.current === generation && - currentStatusIdentityRef.current === loadKey + isMobileSourceControlStatusLoadContextCurrent( + capturedStatusLoadContext, + currentStatusLoadContextRef.current + ) if (!worktreeId) { if (isCurrentLoad()) { setScreenState({ kind: 'loading' }) @@ -194,44 +212,31 @@ export function useMobileSourceControlLoaders(params: Params): MobileSourceContr } setScreenState((prev) => (prev.kind === 'ready' ? prev : { kind: 'loading' })) try { - for (let attempt = 0; attempt <= SELECTOR_RETRY_COUNT; attempt += 1) { - const response = await client.sendRequest('git.status', { - worktree: `id:${worktreeId}` - }) - if (!isCurrentLoad()) { - return false - } - if (response.ok) { - const result = (response as RpcSuccess).result as MobileGitStatusResult - setScreenState({ kind: 'ready', status: result }) - void loadBranchCompare({ preserveReadyOnFailure: true }) - if (options?.clearActionErrorOnSuccess !== false) { - setActionError(null) - } - // Why: recovery prompts are based on a specific failed commit - // snapshot; a fresh status means that snapshot may be stale. - onStatusLoadSuccess?.() - return true - } - if (isMobileGitUnavailable(response.error?.code, response.error?.message)) { - setScreenState({ - kind: 'unavailable', - message: 'Update Orca desktop to use Source Control on mobile.' - }) - return false - } - const shouldRetry = - response.error?.code === 'selector_not_found' || - isMobileGitTransientRefreshError(response.error?.code, response.error?.message) - if (shouldRetry && attempt < SELECTOR_RETRY_COUNT) { - await wait(SELECTOR_RETRY_DELAY_MS) - if (!isCurrentLoad()) { - return false - } - continue - } - throw new Error(response.error?.message || 'Unable to load source control') + const result = await readMobileSourceControlStatus({ + client, + worktreeId, + preferRepositorySnapshot, + isCurrent: isCurrentLoad + }) + if (result.kind === 'cancelled') { + return false } + if (result.kind === 'unavailable') { + setScreenState({ + kind: 'unavailable', + message: 'Update Orca desktop to use Source Control on mobile.' + }) + return false + } + setScreenState({ kind: 'ready', status: result.status }) + void loadBranchCompare({ preserveReadyOnFailure: true }) + if (options?.clearActionErrorOnSuccess !== false) { + setActionError(null) + } + // Why: recovery prompts are based on a specific failed commit + // snapshot; a new status means that snapshot may be stale. + onStatusLoadSuccess?.() + return true } catch (err) { if (!isCurrentLoad()) { return false @@ -248,10 +253,15 @@ export function useMobileSourceControlLoaders(params: Params): MobileSourceContr }) return false } - return false })() - statusLoadInFlightRef.current = { key: loadKey, client, promise: loadPromise } + statusLoadInFlightRef.current = { + key: loadKey, + client, + connState, + promise: loadPromise, + preferRepositorySnapshot + } try { return await loadPromise } finally { @@ -271,9 +281,7 @@ export function useMobileSourceControlLoaders(params: Params): MobileSourceContr ] ) - useEffect(() => { - void loadStatus() - }, [loadStatus]) + useMobileSourceControlInitialLoad({ client, connState, statusIdentityKey, loadStatus }) return { screenState, diff --git a/src/main/runtime/orca-runtime-git-repository-snapshot.test.ts b/src/main/runtime/orca-runtime-git-repository-snapshot.test.ts index 76ef3696e91..dd0289ccf24 100644 --- a/src/main/runtime/orca-runtime-git-repository-snapshot.test.ts +++ b/src/main/runtime/orca-runtime-git-repository-snapshot.test.ts @@ -208,7 +208,7 @@ describe('RuntimeGitCommands repository snapshot query', () => { ) }) - it('measures mobile Source Control plus review as two local or WSL statuses versus one', async () => { + it('measures mobile Source Control, review, and remount as two local or WSL statuses versus one', async () => { mocks.getStatus.mockResolvedValue({ entries: [], conflictOperation: 'unknown' }) mocks.getGitRepositorySnapshot.mockReturnValue({ revision: 2 }) const commands = new RuntimeGitCommands({ @@ -220,26 +220,37 @@ describe('RuntimeGitCommands repository snapshot query', () => { }) await commands.getRuntimeGitStatus('id:wt-1') + await commands.getRuntimeGitRepositorySnapshot('id:wt-1') await commands.getRuntimeGitStatus('id:wt-1') - const baselineStatus = mocks.getStatus.mock.calls.length + const baseline = { + status: mocks.getStatus.mock.calls.length, + snapshot: mocks.getGitRepositorySnapshot.mock.calls.length + } mocks.getStatus.mockClear() + mocks.getGitRepositorySnapshot.mockClear() + await commands.getRuntimeGitRepositorySnapshot('id:wt-1') await commands.getRuntimeGitStatus('id:wt-1') await commands.getRuntimeGitRepositorySnapshot('id:wt-1') - const migratedStatus = mocks.getStatus.mock.calls.length + await commands.getRuntimeGitRepositorySnapshot('id:wt-1') + const migrated = { + status: mocks.getStatus.mock.calls.length, + snapshot: mocks.getGitRepositorySnapshot.mock.calls.length + } - expect({ baselineStatus, migratedStatus }).toEqual({ - baselineStatus: 2, - migratedStatus: 1 + expect({ baseline, migrated }).toEqual({ + baseline: { status: 2, snapshot: 1 }, + migrated: { status: 1, snapshot: 3 } }) expect(mocks.getGitRepositorySnapshot).toHaveBeenCalledWith( '/workspace/feature', { wslDistro: 'Ubuntu-24.04' }, undefined ) + expect(mocks.getGitRepositorySnapshot).toHaveBeenCalledTimes(3) }) - it('measures mobile Source Control plus review as two SSH statuses versus one', async () => { + it('measures mobile Source Control, review, and remount as two SSH statuses versus one', async () => { const provider = { getStatus: vi.fn().mockResolvedValue({ entries: [], conflictOperation: 'unknown' }), getRepositorySnapshot: vi.fn().mockReturnValue({ revision: 2 }) @@ -254,22 +265,33 @@ describe('RuntimeGitCommands repository snapshot query', () => { }) await commands.getRuntimeGitStatus('id:wt-1') + await commands.getRuntimeGitRepositorySnapshot('id:wt-1') await commands.getRuntimeGitStatus('id:wt-1') - const baselineStatus = provider.getStatus.mock.calls.length + const baseline = { + status: provider.getStatus.mock.calls.length, + snapshot: provider.getRepositorySnapshot.mock.calls.length + } provider.getStatus.mockClear() + provider.getRepositorySnapshot.mockClear() + await commands.getRuntimeGitRepositorySnapshot('id:wt-1') await commands.getRuntimeGitStatus('id:wt-1') await commands.getRuntimeGitRepositorySnapshot('id:wt-1') - const migratedStatus = provider.getStatus.mock.calls.length + await commands.getRuntimeGitRepositorySnapshot('id:wt-1') + const migrated = { + status: provider.getStatus.mock.calls.length, + snapshot: provider.getRepositorySnapshot.mock.calls.length + } - expect({ baselineStatus, migratedStatus }).toEqual({ - baselineStatus: 2, - migratedStatus: 1 + expect({ baseline, migrated }).toEqual({ + baseline: { status: 2, snapshot: 1 }, + migrated: { status: 1, snapshot: 3 } }) expect(provider.getRepositorySnapshot).toHaveBeenCalledWith( '/remote/repo', undefined, undefined ) + expect(provider.getRepositorySnapshot).toHaveBeenCalledTimes(3) }) }) diff --git a/tools/benchmarks/results/phase2-mobile-diff-review-repository-snapshot-consumer-2026-07-30.md b/tools/benchmarks/results/phase2-mobile-diff-review-repository-snapshot-consumer-2026-07-30.md index da13956ad5c..bf405986bd3 100644 --- a/tools/benchmarks/results/phase2-mobile-diff-review-repository-snapshot-consumer-2026-07-30.md +++ b/tools/benchmarks/results/phase2-mobile-diff-review-repository-snapshot-consumer-2026-07-30.md @@ -35,7 +35,9 @@ diff-review load. the fresh path, while the existing generation gate suppresses late results after client or context replacement. - Mutation reloads, retry actions, explicit refreshes, reconciliation, PR creation/prefill safety - reads, Source Control, and every non-initial loader call remain fresh. + reads, and every non-initial diff-review loader call remain fresh. Source Control's later, + separately bounded initial-load consumer is documented in + `phase2-mobile-source-control-repository-snapshot-consumer-2026-07-30.md`. - Folder-workspace routing and exclusions, provider-neutral review behavior, Git 2.25 behavior, and relay/runtime ownership are unchanged. @@ -58,12 +60,13 @@ Focused consumer command: ```sh pnpm --dir mobile exec vitest run \ - src/session/mobile-diff-review-repository-snapshot.test.ts \ + src/source-control/mobile-git-repository-snapshot.test.ts \ src/session/mobile-diff-review-loaders.test.ts \ src/session/use-mobile-diff-review-controller.test.ts ``` -Result: 22 tests passed across three files. +Result: 28 tests passed across three files after the shared mobile snapshot parser moved to its +Source Control domain boundary. Focused runtime command: @@ -121,11 +124,10 @@ references, with zero missing or escaping paths and zero static cycles. files. - Focused runtime snapshot, mobile allowlist, and Git RPC suite: 42 passed across three files. - Source inspection found no import cycle. The runtime chain is - `mobile-diff-review-loaders.ts` → `mobile-diff-review-repository-snapshot.ts` → - `mobile-diff-review-rpc.ts`; the loader also imports the RPC parser directly, forming a DAG. - The RPC parser's remaining edges are type-only imports to `mobile-branch-compare.ts` and - `mobile-git-status.ts`, which point only to shared Git types and never back to the session - modules. + `mobile-diff-review-loaders.ts` → `mobile-git-repository-snapshot.ts` → + `mobile-git-status-rpc.ts` → `mobile-git-status.ts` → shared Git status types. The loader also + imports the status RPC parser directly, forming a DAG; none of the Source Control snapshot/parser + modules imports back into the session directory. - `pnpm --dir mobile typecheck`, `pnpm run typecheck:node`, and `pnpm run typecheck:web`: passed. - Targeted `oxlint --deny-warnings` and `oxfmt --check`: passed. - `pnpm run check:max-lines-ratchet`: passed with 354 grandfathered suppressions and no new bypass. diff --git a/tools/benchmarks/results/phase2-mobile-source-control-repository-snapshot-consumer-2026-07-30.md b/tools/benchmarks/results/phase2-mobile-source-control-repository-snapshot-consumer-2026-07-30.md new file mode 100644 index 00000000000..b06e8b24e4c --- /dev/null +++ b/tools/benchmarks/results/phase2-mobile-source-control-repository-snapshot-consumer-2026-07-30.md @@ -0,0 +1,161 @@ +# Phase 2 mobile Source Control repository-snapshot consumer — 2026-07-30 + +## Decision + +Retain snapshot-first loading for only the first connected automatic load of a mounted mobile Source +Control client and status-identity context. On return from diff review, a fresh runtime-owner +projection now avoids a second physical status command; missing or inadmissible projections +immediately retain the existing fresh `git.status` path. + +Checkpoint: `0b2662a400308c4fb98e1c8c9c575238adf0ed96`. + +This change adds no cache, subscription, timer, TTL, second status identity, or desktop/web +consumer. Pull-to-refresh, Retry, mutation and commit reloads, branch operations, reconciliation, +PR creation/prefill safety reads, forced loads, and reconnects within the same mounted context stay +fresh. + +## Boundary and admission + +- The best-effort query sends only `{ worktree: "id:" }`, selecting the exact default + status identity already published by mobile Source Control. +- The runtime continues to resolve the exact worktree, native versus exact WSL distro, current SSH + connection/provider incarnation, shared-link paths, and repository owner. The query reads that + owner's existing projection without launching Git or SSH provider status work. +- Admission requires fresh current-generation status, repository-identity, conflict, and + configured-upstream projections with finite revisions. The three status sibling identities must + match and share a generation; upstream must share the generation but is not required to share the + status identity. +- Status retention must be untruncated. Every retained status entry, repository head/branch, and + conflict operation passes the existing mobile status parsing boundary. Snapshot-only upstream + admission additionally requires non-negative safe-integer counts, a nonempty name when upstream + exists, declared types for present optional fields, and the exact configured no-upstream shape: + zero counts, no name or patch-equivalence, and only an optional + `hasConfiguredPushTarget: true`; ambiguous diverged upstream without patch-equivalence is + rejected. +- Missing, old-runtime, disconnected, rejected, malformed, stale, truncated, + generation-mismatched, identity-mismatched, or unsafe-upstream results fall through to the + unchanged selector-retrying `git.status` request. Existing unavailable and error copy is + preserved. +- Initial disconnected/connecting effects do not consume the opportunity. Identical StrictMode + effect re-entry coalesces, while later disconnect/reconnect transitions in the same mount use the + fresh path. A replacement client, host/worktree identity, or remount is a new context with one + opportunity. +- Load generations and identity/liveness gates suppress late projection commits after fresh-load, + client, context, or mounted-screen replacement. Render-current client and connection-state guards + make an in-flight read stale during replacement render, before the following passive effect can + increment its load generation. +- Folder-workspace routing and exclusions, provider-neutral review behavior, and Git 2.25 command + compatibility are unchanged; the query launches no new Git command. + +## Deterministic physical measurement + +`src/main/runtime/orca-runtime-git-repository-snapshot.test.ts` models mobile Source Control status, +opening diff review, and returning to a newly mounted Source Control screen. The checkpoint already +uses a snapshot for initial diff review. + +| Boundary | A — checkpoint | B — remount snapshot | +| ---------------------------------------------------- | -------------: | -------------------: | +| Native or exact-WSL physical status loads | 2 | 1 | +| Runtime-owned SSH `git.status` provider/mux requests | 2 | 1 | +| Read-only repository-owner snapshot queries | 1 | 3 | +| Snapshot-query Git/provider status work | 0 | 0 | + +A issues fresh status for the initial Source Control mount, one memory-only diff-review snapshot +query, and fresh status on Source Control remount. B models a cold first mount as one missing +snapshot query plus its fresh fallback, then uses memory-only snapshot queries for diff review and +the Source Control remount. Thus physical native/WSL status work and SSH provider/mux status work +fall from 2 to 1; the two additional B queries are owner-memory reads. + +Focused mobile command: + +```sh +pnpm --dir mobile exec vitest run \ + src/source-control/mobile-git-repository-snapshot.test.ts \ + src/source-control/mobile-source-control-status-read.test.ts \ + src/source-control/use-mobile-source-control-loaders.test.ts \ + src/session/mobile-diff-review-loaders.test.ts \ + src/session/use-mobile-diff-review-controller.test.ts +``` + +Result: 64 tests passed across five files after the strict snapshot-only upstream admission and +render-current liveness regressions. + +Focused runtime command: + +```sh +pnpm exec vitest run \ + src/main/runtime/orca-runtime-git-repository-snapshot.test.ts \ + src/main/runtime/mobile-rpc-allowlist.test.ts \ + src/main/runtime/rpc/methods/git.test.ts +``` + +Result: 42 tests passed across three files. + +## Production bundle evidence + +Fresh production output: + +- A/current exact Electron archive: + `/tmp/orca-phase2-mobile-diff-review-snapshot-b-final.jZQlRn/out` +- B/final-source rebuild: + `/tmp/orca-phase2-mobile-source-control-snapshot-b-final.JOCet4/out` + +The new source consumer is in the separate mobile application; the only host change is a test. +`diff -qr` found the complete Electron `out` trees byte-identical. The fresh build and +`pnpm run check:electron-bundle-budgets` passed with only the two known +`::highlight(markdown-preview-search-*)` CSS optimizer warnings. + +| Entry | A raw / gzip | B raw / gzip | Change | +| ---------------- | --------------------- | --------------------- | ------ | +| Electron main | 795,061 / 177,873 | 795,061 / 177,873 | 0 / 0 | +| Electron preload | 131,910 / 20,835 | 131,910 / 20,835 | 0 / 0 | +| Renderer index | 8,416,603 / 1,877,763 | 8,416,603 / 1,877,763 | 0 / 0 | +| Renderer popout | 4,507,253 / 984,605 | 4,507,253 / 984,605 | 0 / 0 | +| Renderer web | 4,360,948 / 928,460 | 4,360,948 / 928,460 | 0 / 0 | + +Renderer counts are unchanged: index 292 JS / 2 CSS, popout 77 JS / 2 CSS, and web 33 JS / +1 CSS. The main entry SHA-256 is +`425f9aad981977acd42720465a067fc9cd5657c116b9234a210f8e397c26e468`. + +Sorted relative-path-plus-file-byte tree hashes are identical in A and B: + +| Tree | SHA-256 | +| -------- | ------------------------------------------------------------------ | +| Main | `69466b79451ee1539a0fc26062ac8aef55a60fa83d24d8fca2cefb584f400177` | +| Preload | `ac78d8c93d022df3fab158ae37cfd62ac0ad8d094f79f7a82b0daf1827ddf091` | +| Renderer | `1b93b2ab43337ae7efe972786cbb9d4a0cb8667cc2c98832422fae7ea0f970a1` | + +Complete A/B renderer validation found 779 manifest records, three HTML entries, 6,251 static +edges, 213 dynamic edges, and 861 emitted references, with zero missing or escaping paths, zero +missing manifest edges, and zero static cycles. + +## Validation + +- Focused mobile snapshot/parser/loader/controller suite: 64 passed across five files. +- Broad mobile Source Control and session suite: 1,083 passed across 136 files. +- Focused runtime snapshot/allowlist/Git RPC suite: 42 passed across three files. +- `pnpm --dir mobile typecheck`, `pnpm run typecheck:node`, and `pnpm run typecheck:web`: passed. +- Targeted `oxlint --deny-warnings` and `oxfmt --check`: passed. +- `pnpm run check:max-lines-ratchet`: passed with 354 grandfathered suppressions and no new bypass. +- Fresh `pnpm run build:electron-vite` and `pnpm run check:electron-bundle-budgets`: passed. +- Complete renderer manifest/path/static-cycle validation and `git diff --check`: passed. +- Source inspection found an acyclic consumer chain: + `use-mobile-source-control-loaders.ts` → `mobile-source-control-status-read.ts` → + `mobile-git-repository-snapshot.ts` → `mobile-git-status-rpc.ts` → + `mobile-git-status.ts` → shared Git status types. Diff review points into the same + Source Control parser boundary; none of those parser/reader modules imports back into session. + +## Limitations and residual risk + +- The 2-to-1 saving requires an admissible default projection when the Source Control screen + remounts. Cold initial mounts and rejected projections pay one best-effort query plus the existing + fresh status work. +- An old runtime can receive one failed `git.repositorySnapshot` method call on the first connected + automatic load of each mount before the fresh fallback. No capability bit was added for this + bounded query-only consumer. +- Counts are deterministic host command/provider-boundary measurements, not packaged latency + samples on every supported OS, WSL distribution, SSH host, or provider. +- No live device, relay, or packaged SSH smoke was run. Existing runtime target, provider + incarnation, mobile route, parser, and cancellation tests cover those unchanged boundaries + deterministically. +- This tranche adds no runtime subscription transport and no desktop or web snapshot consumer.