perf: reuse snapshots for mobile source control

This commit is contained in:
Neil
2026-07-30 08:41:55 -07:00
parent 0b2662a400
commit 92ac61850d
19 changed files with 1451 additions and 324 deletions
@@ -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
@@ -1,66 +0,0 @@
import { describe, expect, it } from 'vitest'
import { readMobileDiffReviewRepositorySnapshot } from './mobile-diff-review-repository-snapshot'
function snapshot(overrides: Record<string, unknown> = {}) {
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<string, unknown>).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<string, unknown>).currentGeneration = 4
const mismatched = snapshot()
;(mismatched.freshness.conflicts as Record<string, unknown>).identity = 'other-status'
for (const value of [truncated, malformed, generationStale, mismatched]) {
expect(readMobileDiffReviewRepositorySnapshot(value)).toBeNull()
}
})
})
@@ -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<string, unknown> {
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
}
+3 -98
View File
@@ -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
}
@@ -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
@@ -0,0 +1,200 @@
import { describe, expect, it } from 'vitest'
import {
readMobileDiffReviewRepositorySnapshot,
readMobileSourceControlRepositorySnapshot
} from './mobile-git-repository-snapshot'
function snapshot(overrides: Record<string, unknown> = {}) {
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<string, unknown>).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<string, unknown>).currentGeneration = 4
const mismatched = snapshot()
;(mismatched.freshness.conflicts as Record<string, unknown>).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()
}
})
})
@@ -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<string, unknown> {
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<string, unknown>, 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 }
}
@@ -0,0 +1,109 @@
import type {
MobileGitFileStatus,
MobileGitStagingArea,
MobileGitStatusEntry,
MobileGitStatusResult,
MobileGitUpstreamStatus
} from './mobile-git-status'
function isRecord(value: unknown): value is Record<string, unknown> {
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)
}
}
@@ -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 =
@@ -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'
@@ -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<boolean>
preferRepositorySnapshot: boolean
}
export type GitRequestError = Error & { code?: string }
@@ -116,17 +119,10 @@ export const SOURCE_CONTROL_ACTION_ICONS: Record<MobileSourceControlActionIcon,
history: History
}
export const SELECTOR_RETRY_COUNT = 3
export const SELECTOR_RETRY_DELAY_MS = 250
export function firstParam(value: string | string[] | undefined): string {
return Array.isArray(value) ? (value[0] ?? '') : (value ?? '')
}
export function wait(ms: number): Promise<void> {
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)
@@ -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<RpcResponse> => {
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<RpcResponse>((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<RpcResponse>((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()
})
})
@@ -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<void> {
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<MobileSourceControlStatusReadResult> {
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')
}
@@ -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<boolean>
}
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])
}
@@ -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<typeof useMobileSourceControlLoaders> | 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<void> {
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<RpcResponse>((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<RpcResponse>((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<RpcResponse>((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()
})
})
@@ -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<MobileBranchCompareState>({
kind: 'idle'
})
const currentStatusIdentityRef = useRef('')
const currentStatusLoadContextRef = useRef<MobileSourceControlStatusLoadContext>({
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,
@@ -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)
})
})
@@ -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.
@@ -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:<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.