mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 16:03:26 +00:00
perf: reuse snapshots for mobile source control
This commit is contained in:
@@ -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,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)
|
||||
})
|
||||
})
|
||||
|
||||
+10
-8
@@ -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.
|
||||
|
||||
+161
@@ -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.
|
||||
Reference in New Issue
Block a user