feat(desktop): serve mobile Source Control history, compare and review from the host lane

The shell translated every Source Control operation, so the page's own
projections lived on the phone and the Desktop answered raw Git. Move the
bounding and the mobile projection to the Desktop, where the result can be
clipped before it crosses the bridge, and expose the writes the page reaches
directly through the generic host allowlist.

Branch compare answers in one clipped response, so the shell's continuation
cache and its revision handshake have nothing left to do.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-09-07 15:03:22 -04:00
parent b68f2246e9
commit cb14aeb33e
29 changed files with 1704 additions and 477 deletions
+14
View File
@@ -4,6 +4,13 @@ import { MOBILE_WEB_SESSION_CAPABILITIES_METHOD } from './mobile-web-session-cap
import { MOBILE_WEB_SESSION_QUICK_COMMAND_METHODS } from './mobile-web-session-quick-commands'
import { MOBILE_WEB_SESSION_BROWSER_CREATE_METHOD } from './mobile-web-session-browser-create'
import { MOBILE_WEB_SOURCE_CONTROL_READ_METHODS } from './mobile-web-source-control-reads'
import { MOBILE_WEB_SOURCE_CONTROL_HISTORY_METHODS } from './mobile-web-source-control-history'
import { MOBILE_WEB_SOURCE_CONTROL_COMPARE_METHODS } from './mobile-web-source-control-compare'
import { MOBILE_WEB_SOURCE_CONTROL_REPOSITORY_METHODS } from './mobile-web-source-control-repository'
import { MOBILE_WEB_SOURCE_CONTROL_REVIEW_METADATA_METHODS } from './mobile-web-source-control-review-metadata'
import { MOBILE_WEB_SOURCE_CONTROL_REVIEW_LINK_METHODS } from './mobile-web-source-control-review-link'
import { MOBILE_WEB_SOURCE_CONTROL_REVIEW_DIFF_METHODS } from './mobile-web-source-control-review-diff'
import { MOBILE_WEB_SOURCE_CONTROL_REVIEW_TERMINAL_METHODS } from './mobile-web-source-control-review-terminal-send'
import { MOBILE_WEB_SESSION_TERMINAL_CREATION_METHODS } from './mobile-web-session-terminal-creation'
import { MOBILE_WEB_NATIVE_CHAT_FILE_METHODS } from './mobile-web-native-chat-files'
import { MOBILE_WEB_TERMINAL_ACTION_METHODS } from './mobile-web-terminal-actions'
@@ -116,6 +123,13 @@ export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [
...UPDATER_METHODS,
...MOBILE_WEB_FILE_READ_METHODS,
...MOBILE_WEB_SOURCE_CONTROL_READ_METHODS,
...MOBILE_WEB_SOURCE_CONTROL_HISTORY_METHODS,
...MOBILE_WEB_SOURCE_CONTROL_COMPARE_METHODS,
...MOBILE_WEB_SOURCE_CONTROL_REPOSITORY_METHODS,
...MOBILE_WEB_SOURCE_CONTROL_REVIEW_METADATA_METHODS,
...MOBILE_WEB_SOURCE_CONTROL_REVIEW_LINK_METHODS,
...MOBILE_WEB_SOURCE_CONTROL_REVIEW_DIFF_METHODS,
...MOBILE_WEB_SOURCE_CONTROL_REVIEW_TERMINAL_METHODS,
MOBILE_WEB_FILE_WATCH_METHOD,
...MOBILE_WEB_TERMINAL_ACTION_METHODS,
...MOBILE_WEB_NATIVE_CHAT_METHODS,
@@ -4,6 +4,33 @@ import { mobileWebHostUnsubscribeMethod } from '../../../../shared/mobile-web/ho
export const MOBILE_WEB_HOST_RPC_METHODS = new Set([
'mobileWeb.sourceControl.status',
'mobileWeb.sourceControl.diff',
'mobileWeb.sourceControl.repositoryState',
'mobileWeb.sourceControl.branches',
'mobileWeb.sourceControl.history',
'mobileWeb.sourceControl.branchCompare',
'mobileWeb.sourceControl.commitCompare',
'mobileWeb.sourceControl.reviewMetadata',
'mobileWeb.sourceControl.reviewMetadataUpdate',
'mobileWeb.sourceControl.reviewLink',
'mobileWeb.sourceControl.reviewLinkUpdate',
'mobileWeb.sourceControl.reviewDiff',
'mobileWeb.sourceControl.reviewTerminalSend',
'git.stage',
'git.bulkStage',
'git.unstage',
'git.bulkUnstage',
'git.discard',
'git.bulkDiscard',
'git.commit',
'git.checkout',
'git.fetch',
'git.pull',
'git.fastForward',
'git.push',
'git.rebaseFromBase',
'git.abortMerge',
'git.abortRebase',
'files.openDiff',
'mobileWeb.files.readDir',
'files.readChunk',
'mobileWeb.files.searchPaths',
@@ -0,0 +1,51 @@
import { defineMethod } from '../core'
import {
MOBILE_WEB_PAGE_IDENTITY,
MobileWebWorktreeScope,
sourceControlHostMethod
} from './mobile-web-source-control-host-method'
import {
MobileWebGitObjectIdSchema,
MobileWebGitRefNameSchema
} from '../../../../shared/mobile-web/source-control-history-contract'
import {
projectMobileWebBranchCompare,
projectMobileWebCommitCompare
} from '../../../../shared/mobile-web/source-control-history-presentation'
import { withoutMobileWebWorkspaceId } from './mobile-web-source-control-workspace-id'
const branchCompare = sourceControlHostMethod('git.branchCompare')
const commitCompare = sourceControlHostMethod('git.commitCompare')
export const MOBILE_WEB_SOURCE_CONTROL_COMPARE_METHODS = [
defineMethod({
name: 'mobileWeb.sourceControl.branchCompare',
params: MobileWebWorktreeScope.extend({ baseRef: MobileWebGitRefNameSchema }),
handler: async (params, context) =>
withoutMobileWebWorkspaceId(
projectMobileWebBranchCompare(
await branchCompare.handler(
{ worktree: params.worktree, baseRef: params.baseRef },
context
),
MOBILE_WEB_PAGE_IDENTITY,
params.baseRef
)
)
}),
defineMethod({
name: 'mobileWeb.sourceControl.commitCompare',
params: MobileWebWorktreeScope.extend({ commitId: MobileWebGitObjectIdSchema }),
handler: async (params, context) =>
withoutMobileWebWorkspaceId(
projectMobileWebCommitCompare(
await commitCompare.handler(
{ worktree: params.worktree, commitId: params.commitId },
context
),
MOBILE_WEB_PAGE_IDENTITY,
params.commitId
)
)
})
]
@@ -0,0 +1,19 @@
export const MOBILE_WEB_SOURCE_CONTROL_MAX_RESULT_BYTES = 512 * 1024
/** Escaped line text can exceed the byte budget even within the row-count limit the page asked
* for, so the last rows are dropped until the page fits and the page resumes from `nextOffset`. */
export function clipMobileWebDiffResult<T extends object>(page: T): T {
const rows = 'rows' in page ? (page as { rows: unknown }).rows : undefined
if (!Array.isArray(rows) || typeof (page as { offset?: unknown }).offset !== 'number') {
return page
}
const clipped = page as T & { offset: number; rows: unknown[]; nextOffset: number | null }
while (
Buffer.byteLength(JSON.stringify(clipped)) > MOBILE_WEB_SOURCE_CONTROL_MAX_RESULT_BYTES &&
clipped.rows.length > 1
) {
clipped.rows.pop()
clipped.nextOffset = clipped.offset + clipped.rows.length
}
return clipped
}
@@ -0,0 +1,128 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { RpcContext } from '../core'
import { GIT_METHODS } from './git'
import { MOBILE_WEB_SOURCE_CONTROL_COMPARE_METHODS } from './mobile-web-source-control-compare'
import { MOBILE_WEB_SOURCE_CONTROL_HISTORY_METHODS } from './mobile-web-source-control-history'
const context = { signal: new AbortController().signal } as RpcContext
const worktree = 'id:private-host-workspace'
const OID = 'a'.repeat(40)
function fixture(name: string, sourceMethod: string, raw: unknown) {
const source = GIT_METHODS.find((method) => method.name === sourceMethod)!
const handler = vi.spyOn(source, 'handler').mockResolvedValue(raw)
const method = [
...MOBILE_WEB_SOURCE_CONTROL_HISTORY_METHODS,
...MOBILE_WEB_SOURCE_CONTROL_COMPARE_METHODS
].find((entry) => entry.name === name)!
return {
handler,
run: async (params: Record<string, unknown> = {}) =>
method.handler(method.params!.parse({ worktree, ...params }), context)
}
}
afterEach(() => vi.restoreAllMocks())
describe('bounded host Source Control history reads', () => {
it('caps a branch list the page cannot render and reports the true total', async () => {
const f = fixture('mobileWeb.sourceControl.branches', 'git.localBranches', {
current: 'main',
branches: Array.from({ length: 500 }, (_, index) => `branch-${index}`)
})
const result = (await f.run()) as { branches: string[]; totalCount: number; truncated: boolean }
expect(result.branches).toHaveLength(128)
expect(result).toMatchObject({ totalCount: 500, truncated: true })
expect(JSON.stringify(result)).not.toContain('workspaceId')
expect(f.handler).toHaveBeenCalledWith({ worktree }, context)
})
it('drops history items that would overrun the bridge budget and marks the page incomplete', async () => {
const raw = {
items: Array.from({ length: 100 }, (_, index) => ({
id: index.toString(16).padStart(40, '0'),
parentIds: [],
subject: 'subject',
message: 'x'.repeat(16 * 1024),
references: []
})),
hasIncomingChanges: false,
hasOutgoingChanges: false,
hasMore: false,
limit: 100
}
const f = fixture('mobileWeb.sourceControl.history', 'git.history', raw)
const result = (await f.run({ limit: 100 })) as {
items: { message: string }[]
hasMore: boolean
}
expect(result.items.length).toBeLessThan(100)
expect(result.hasMore).toBe(true)
expect(result.items[0]!.message).toHaveLength(8 * 1024)
expect(Buffer.byteLength(JSON.stringify(result))).toBeLessThan(192 * 1024)
expect(f.handler).toHaveBeenCalledWith({ worktree, limit: 100 }, context)
})
it('forwards the requested base ref and defaults the history limit', async () => {
const f = fixture('mobileWeb.sourceControl.history', 'git.history', {
items: [],
hasIncomingChanges: false,
hasOutgoingChanges: false,
hasMore: false,
limit: 50
})
await f.run({ baseRef: 'origin/main' })
expect(f.handler).toHaveBeenCalledWith({ worktree, limit: 50, baseRef: 'origin/main' }, context)
await expect(f.run({ baseRef: '--upload-pack=evil' })).rejects.toThrow()
})
})
describe('bounded host Source Control compares', () => {
it('clips a branch compare to the response budget and keeps the reported file count', async () => {
const raw = {
summary: {
baseRef: 'main',
baseOid: OID,
compareRef: 'HEAD',
headOid: 'b'.repeat(40),
mergeBase: OID,
changedFiles: 6_000,
status: 'ready'
},
entries: Array.from({ length: 6_000 }, (_, index) => ({
path: `src/${'deep/'.repeat(8)}file-${index}.ts`,
status: 'modified'
}))
}
const f = fixture('mobileWeb.sourceControl.branchCompare', 'git.branchCompare', raw)
const result = (await f.run({ baseRef: 'main' })) as {
entries: unknown[]
changedFiles: number
truncated: boolean
}
expect(result.entries.length).toBeLessThan(4_000)
expect(result).toMatchObject({ changedFiles: 6_000, truncated: true })
expect(Buffer.byteLength(JSON.stringify(result))).toBeLessThan(192 * 1024)
expect(f.handler).toHaveBeenCalledWith({ worktree, baseRef: 'main' }, context)
})
it('answers a commit compare in one page and refuses a short commit id', async () => {
const f = fixture('mobileWeb.sourceControl.commitCompare', 'git.commitCompare', {
summary: {
commitOid: OID,
parentOid: null,
compareRef: 'HEAD',
baseRef: 'parent',
changedFiles: 1,
status: 'ready'
},
entries: [{ path: 'src/app.ts', status: 'modified', added: 2, removed: 1 }]
})
await expect(f.run({ commitId: OID })).resolves.toMatchObject({
commitId: OID,
entries: [{ relativePath: 'src/app.ts', added: 2, removed: 1 }],
truncated: false
})
await expect(f.run({ commitId: 'abc1234' })).rejects.toThrow()
})
})
@@ -0,0 +1,61 @@
import { z } from 'zod'
import { defineMethod } from '../core'
import {
MOBILE_WEB_PAGE_IDENTITY,
MobileWebWorktreeScope,
sourceControlHostMethod
} from './mobile-web-source-control-host-method'
import {
MOBILE_WEB_SOURCE_CONTROL_HISTORY_DEFAULT_LIMIT,
MOBILE_WEB_SOURCE_CONTROL_HISTORY_MAX_LIMIT,
MobileWebGitRefNameSchema
} from '../../../../shared/mobile-web/source-control-history-contract'
import {
projectMobileWebBranches,
projectMobileWebHistory
} from '../../../../shared/mobile-web/source-control-history-presentation'
import { withoutMobileWebWorkspaceId } from './mobile-web-source-control-workspace-id'
const branches = sourceControlHostMethod('git.localBranches')
const history = sourceControlHostMethod('git.history')
export const MOBILE_WEB_SOURCE_CONTROL_HISTORY_METHODS = [
defineMethod({
name: 'mobileWeb.sourceControl.branches',
params: MobileWebWorktreeScope,
handler: async (params, context) =>
withoutMobileWebWorkspaceId(
projectMobileWebBranches(
await branches.handler({ worktree: params.worktree }, context),
MOBILE_WEB_PAGE_IDENTITY
)
)
}),
defineMethod({
name: 'mobileWeb.sourceControl.history',
params: MobileWebWorktreeScope.extend({
limit: z
.number()
.int()
.min(1)
.max(MOBILE_WEB_SOURCE_CONTROL_HISTORY_MAX_LIMIT)
.default(MOBILE_WEB_SOURCE_CONTROL_HISTORY_DEFAULT_LIMIT),
baseRef: MobileWebGitRefNameSchema.optional()
}),
handler: async (params, context) =>
withoutMobileWebWorkspaceId(
projectMobileWebHistory(
await history.handler(
{
worktree: params.worktree,
limit: params.limit,
...(params.baseRef === undefined ? {} : { baseRef: params.baseRef })
},
context
),
MOBILE_WEB_PAGE_IDENTITY,
params.limit
)
)
})
]
@@ -0,0 +1,23 @@
import { z } from 'zod'
import { isStreamingMethod, type RpcMethod } from '../core'
import { GIT_METHODS } from './git'
import { REPO_METHODS } from './repo'
import { WORKTREE_METHODS } from './worktree'
export const MobileWebWorktreeScope = z.object({ worktree: z.string().min(1).max(4096) })
/** The page never sees a workspace handle in a wrapper result: the shell rewrote its own handle
* into `worktree`, so the projection carries this placeholder and the page restores its handle. */
export const MOBILE_WEB_PAGE_IDENTITY = 'page'
const HOST_METHODS = new Map(
[...GIT_METHODS, ...WORKTREE_METHODS, ...REPO_METHODS].map((method) => [method.name, method])
)
export function sourceControlHostMethod(name: string): RpcMethod {
const method = HOST_METHODS.get(name)
if (!method || isStreamingMethod(method)) {
throw new Error(`Missing source control host method: ${name}`)
}
return method
}
@@ -1,6 +1,9 @@
import { z } from 'zod'
import { defineMethod, isStreamingMethod } from '../core'
import { GIT_METHODS } from './git'
import { defineMethod } from '../core'
import {
MOBILE_WEB_PAGE_IDENTITY,
MobileWebWorktreeScope,
sourceControlHostMethod
} from './mobile-web-source-control-host-method'
import {
MobileWebSourceControlDiffPayloadSchema,
MobileWebSourceControlStatusPayloadSchema
@@ -9,36 +12,28 @@ import {
sanitizeMobileWebSourceControlDiff,
sanitizeMobileWebSourceControlStatus
} from '../../../../shared/mobile-web/source-control-host-presentation'
import {
clipMobileWebDiffResult,
MOBILE_WEB_SOURCE_CONTROL_MAX_RESULT_BYTES
} from './mobile-web-source-control-diff-clip'
import { withoutMobileWebWorkspaceId } from './mobile-web-source-control-workspace-id'
const Worktree = z.string().min(1).max(4096)
const PAGE_IDENTITY = 'page'
const MAX_RESULT_BYTES = 512 * 1024
function sourceMethod(name: string) {
const method = GIT_METHODS.find((entry) => entry.name === name)
if (!method || isStreamingMethod(method)) {
throw new Error(`Missing unary source control method: ${name}`)
}
return method
}
const status = sourceMethod('git.status')
const diff = sourceMethod('git.diff')
const status = sourceControlHostMethod('git.status')
const diff = sourceControlHostMethod('git.diff')
export const MOBILE_WEB_SOURCE_CONTROL_READ_METHODS = [
defineMethod({
name: 'mobileWeb.sourceControl.status',
params: MobileWebSourceControlStatusPayloadSchema.omit({ workspaceId: true }).extend({
worktree: Worktree
}),
params: MobileWebSourceControlStatusPayloadSchema.omit({ workspaceId: true }).extend(
MobileWebWorktreeScope.shape
),
handler: async (params, context) => {
const raw = await status.handler({ worktree: params.worktree, reuseLineStats: true }, context)
const { workspaceId: _workspaceId, ...result } = sanitizeMobileWebSourceControlStatus(
raw,
PAGE_IDENTITY,
params.limit
const result = withoutMobileWebWorkspaceId(
sanitizeMobileWebSourceControlStatus(raw, MOBILE_WEB_PAGE_IDENTITY, params.limit)
)
while (
Buffer.byteLength(JSON.stringify(result)) > MAX_RESULT_BYTES &&
Buffer.byteLength(JSON.stringify(result)) > MOBILE_WEB_SOURCE_CONTROL_MAX_RESULT_BYTES &&
result.entries.length
) {
result.entries.pop()
@@ -49,9 +44,9 @@ export const MOBILE_WEB_SOURCE_CONTROL_READ_METHODS = [
}),
defineMethod({
name: 'mobileWeb.sourceControl.diff',
params: MobileWebSourceControlDiffPayloadSchema.omit({ workspaceId: true }).extend({
worktree: Worktree
}),
params: MobileWebSourceControlDiffPayloadSchema.omit({ workspaceId: true }).extend(
MobileWebWorktreeScope.shape
),
handler: async (params, context) => {
const raw = await diff.handler(
{
@@ -61,21 +56,14 @@ export const MOBILE_WEB_SOURCE_CONTROL_READ_METHODS = [
},
context
)
const { workspaceId: _workspaceId, ...result } = sanitizeMobileWebSourceControlDiff(raw, {
...params,
workspaceId: PAGE_IDENTITY
})
// Escaped line text can exceed the byte budget even within the row-count limit.
if (result.kind === 'text') {
while (
Buffer.byteLength(JSON.stringify(result)) > MAX_RESULT_BYTES &&
result.rows.length > 1
) {
result.rows.pop()
result.nextOffset = result.offset + result.rows.length
}
}
return result
return clipMobileWebDiffResult(
withoutMobileWebWorkspaceId(
sanitizeMobileWebSourceControlDiff(raw, {
...params,
workspaceId: MOBILE_WEB_PAGE_IDENTITY
})
)
)
}
})
]
@@ -0,0 +1,53 @@
import { defineMethod, type RpcContext } from '../core'
import {
MOBILE_WEB_PAGE_IDENTITY,
MobileWebWorktreeScope,
sourceControlHostMethod
} from './mobile-web-source-control-host-method'
import { projectMobileWebRepositoryState } from '../../../../shared/mobile-web/source-control-repository-presentation'
import { withoutMobileWebWorkspaceId } from './mobile-web-source-control-workspace-id'
const status = sourceControlHostMethod('git.status')
const upstream = sourceControlHostMethod('git.upstreamStatus')
const worktreeShow = sourceControlHostMethod('worktree.show')
const repoBaseRefDefault = sourceControlHostMethod('repo.baseRefDefault')
export const MOBILE_WEB_SOURCE_CONTROL_REPOSITORY_METHODS = [
defineMethod({
name: 'mobileWeb.sourceControl.repositoryState',
params: MobileWebWorktreeScope,
handler: async (params, context) => {
const [statusResult, upstreamResult, baseRef] = await Promise.all([
status.handler({ worktree: params.worktree }, context),
upstream.handler({ worktree: params.worktree }, context),
resolveBaseRef(params.worktree, context)
])
return withoutMobileWebWorkspaceId(
projectMobileWebRepositoryState({
status: statusResult,
upstream: upstreamResult,
baseRef,
workspaceId: MOBILE_WEB_PAGE_IDENTITY
})
)
}
})
]
/** The workspace ref wins; the project default only fills in a workspace that never pinned one. */
async function resolveBaseRef(worktree: string, context: RpcContext): Promise<unknown> {
const shown = await worktreeShow.handler({ worktree }, context)
const record = isRecord(shown) && isRecord(shown.worktree) ? shown.worktree : undefined
if (typeof record?.baseRef === 'string' && record.baseRef.length > 0) {
return record.baseRef
}
if (typeof record?.repoId !== 'string' || record.repoId.length === 0) {
return null
}
const fallback = await repoBaseRefDefault.handler({ repo: `id:${record.repoId}` }, context)
return isRecord(fallback) ? fallback.defaultBaseRef : null
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
@@ -0,0 +1,58 @@
import { defineMethod } from '../core'
import {
MOBILE_WEB_PAGE_IDENTITY,
MobileWebWorktreeScope,
sourceControlHostMethod
} from './mobile-web-source-control-host-method'
import {
MobileWebSourceControlReviewDiffShape,
rejectMissingReviewCompareIdentity
} from '../../../../shared/mobile-web/source-control-review-contract'
import { sanitizeMobileWebSourceControlDiff } from '../../../../shared/mobile-web/source-control-host-presentation'
import { clipMobileWebDiffResult } from './mobile-web-source-control-diff-clip'
const diff = sourceControlHostMethod('git.diff')
const branchDiff = sourceControlHostMethod('git.branchDiff')
export const MOBILE_WEB_SOURCE_CONTROL_REVIEW_DIFF_METHODS = [
defineMethod({
name: 'mobileWeb.sourceControl.reviewDiff',
params: MobileWebWorktreeScope.extend(MobileWebSourceControlReviewDiffShape)
.strict()
.superRefine(rejectMissingReviewCompareIdentity),
handler: async (params, context) => {
const raw =
params.scope === 'branch'
? await branchDiff.handler(
{
worktree: params.worktree,
filePath: params.relativePath,
...(params.oldRelativePath ? { oldPath: params.oldRelativePath } : {}),
compare: params.compare
},
context
)
: await diff.handler(
{
worktree: params.worktree,
filePath: params.relativePath,
staged: params.scope === 'staged'
},
context
)
const {
workspaceId: _workspaceId,
area: _area,
...page
} = sanitizeMobileWebSourceControlDiff(raw, {
workspaceId: MOBILE_WEB_PAGE_IDENTITY,
relativePath: params.relativePath,
area: params.scope === 'staged' ? 'staged' : 'unstaged',
offset: params.offset,
limit: params.limit,
...(params.expectedRevision ? { expectedRevision: params.expectedRevision } : {})
})
return clipMobileWebDiffResult({ ...page, scope: params.scope })
}
})
]
@@ -0,0 +1,56 @@
import { z } from 'zod'
import { defineMethod, type RpcContext } from '../core'
import {
MOBILE_WEB_PAGE_IDENTITY,
MobileWebWorktreeScope,
sourceControlHostMethod
} from './mobile-web-source-control-host-method'
import {
MobileWebSourceControlReviewLinkUpdatePayloadSchema,
type MobileWebSourceControlReviewLinkResult
} from '../../../../shared/mobile-web/source-control-review-contract'
import {
mobileWebReviewLinkWorktreeField,
projectMobileWebReviewLink
} from '../../../../shared/mobile-web/source-control-review-presentation'
import { withoutMobileWebWorkspaceId } from './mobile-web-source-control-workspace-id'
const worktreeShow = sourceControlHostMethod('worktree.show')
const worktreeSet = sourceControlHostMethod('worktree.set')
const UpdateParams = MobileWebWorktreeScope.extend(
MobileWebSourceControlReviewLinkUpdatePayloadSchema.omit({ workspaceId: true }).shape
)
export const MOBILE_WEB_SOURCE_CONTROL_REVIEW_LINK_METHODS = [
defineMethod({
name: 'mobileWeb.sourceControl.reviewLink',
params: MobileWebWorktreeScope,
handler: async (params, context) =>
withoutMobileWebWorkspaceId(await readReviewLink(params.worktree, context))
}),
defineMethod({
name: 'mobileWeb.sourceControl.reviewLinkUpdate',
params: UpdateParams,
handler: async (params, context) => {
await worktreeSet.handler(
{
worktree: params.worktree,
...mobileWebReviewLinkWorktreeField(params.provider, params.number),
...(params.baseRef ? { baseRef: params.baseRef } : {})
},
context
)
return withoutMobileWebWorkspaceId(await readReviewLink(params.worktree, context))
}
})
]
async function readReviewLink(
worktree: string,
context: RpcContext
): Promise<MobileWebSourceControlReviewLinkResult> {
const shown = await worktreeShow.handler({ worktree }, context)
const record = z.object({ worktree: z.unknown() }).parse(shown).worktree
return projectMobileWebReviewLink(record, MOBILE_WEB_PAGE_IDENTITY)
}
@@ -0,0 +1,69 @@
import { z } from 'zod'
import { defineMethod, type RpcContext } from '../core'
import {
MOBILE_WEB_PAGE_IDENTITY,
MobileWebWorktreeScope,
sourceControlHostMethod
} from './mobile-web-source-control-host-method'
import {
MobileWebSourceControlReviewMetadataUpdateShape,
rejectDuplicateReviewMetadataKeys,
type MobileWebSourceControlReviewMetadataResult
} from '../../../../shared/mobile-web/source-control-review-contract'
import {
mobileWebReviewMetadataWorktreeFields,
projectMobileWebReviewMetadata
} from '../../../../shared/mobile-web/source-control-review-presentation'
import { withoutMobileWebWorkspaceId } from './mobile-web-source-control-workspace-id'
const worktreeShow = sourceControlHostMethod('worktree.show')
const worktreeSet = sourceControlHostMethod('worktree.set')
const UpdateParams = MobileWebWorktreeScope.extend(MobileWebSourceControlReviewMetadataUpdateShape)
.strict()
.superRefine(rejectDuplicateReviewMetadataKeys)
export const MOBILE_WEB_SOURCE_CONTROL_REVIEW_METADATA_METHODS = [
defineMethod({
name: 'mobileWeb.sourceControl.reviewMetadata',
params: MobileWebWorktreeScope,
handler: async (params, context) =>
withoutMobileWebWorkspaceId(await readReviewMetadata(params.worktree, context))
}),
defineMethod({
name: 'mobileWeb.sourceControl.reviewMetadataUpdate',
params: UpdateParams,
handler: async (params, context) => {
const current = await readReviewMetadata(params.worktree, context)
if (current.revision !== params.expectedRevision) {
throw new Error('conflict')
}
// worktree.set has no compare-and-set, so another writer can still win after this read.
await worktreeSet.handler(
{
worktree: params.worktree,
...mobileWebReviewMetadataWorktreeFields({
worktreeId: worktreeIdFromSelector(params.worktree),
comments: params.comments,
reviewState: params.reviewState
})
},
context
)
return withoutMobileWebWorkspaceId(await readReviewMetadata(params.worktree, context))
}
})
]
async function readReviewMetadata(
worktree: string,
context: RpcContext
): Promise<MobileWebSourceControlReviewMetadataResult> {
const shown = await worktreeShow.handler({ worktree }, context)
const record = z.object({ worktree: z.unknown() }).parse(shown).worktree
return projectMobileWebReviewMetadata(record, MOBILE_WEB_PAGE_IDENTITY)
}
function worktreeIdFromSelector(worktree: string): string {
return worktree.startsWith('id:') ? worktree.slice('id:'.length) : worktree
}
@@ -0,0 +1,69 @@
import { z } from 'zod'
import { defineMethod, isStreamingMethod, type RpcContext } from '../core'
import { TERMINAL_SEND_METHODS } from './terminal/terminal-send-method'
import { MobileWebWorktreeScope } from './mobile-web-source-control-host-method'
import { MOBILE_WEB_REVIEW_TERMINAL_TEXT_MAX_CHARACTERS } from '../../../../shared/mobile-web/source-control-review-contract'
const Tab = z.object({
id: z.string(),
type: z.literal('terminal'),
status: z.literal('ready'),
terminal: z.string().min(1).max(256)
})
const send = TERMINAL_SEND_METHODS.find((method) => method.name === 'terminal.send')
if (!send || isStreamingMethod(send)) {
throw new Error('Missing terminal.send method')
}
const terminalSend = send
export const MOBILE_WEB_SOURCE_CONTROL_REVIEW_TERMINAL_METHODS = [
defineMethod({
name: 'mobileWeb.sourceControl.reviewTerminalSend',
params: MobileWebWorktreeScope.extend({
tabId: z.string().min(1).max(512),
text: z.string().min(1).max(MOBILE_WEB_REVIEW_TERMINAL_TEXT_MAX_CHARACTERS)
}),
handler: async (params, context) => {
if (!context.clientId) {
throw new Error('runtime_unavailable')
}
// The handle comes from the host tab list for this worktree, never from the request.
const terminal = await resolveTerminal(context, params.worktree, params.tabId)
const result = await terminalSend.handler(
terminalSend.params!.parse({
terminal,
text: params.text,
enter: true,
client: { id: context.clientId, type: 'mobile' }
}),
context
)
return { accepted: acceptedSend(result) }
}
})
]
async function resolveTerminal(
context: RpcContext,
worktree: string,
tabId: string
): Promise<string> {
const snapshot = await context.runtime.listMobileSessionTabs(worktree, context.pairedDeviceId)
if (`id:${snapshot.worktree}` !== worktree) {
throw new Error('selector_not_found')
}
const parsed = Tab.safeParse(snapshot.tabs.find((tab) => tab.id === tabId))
if (!parsed.success) {
throw new Error('selector_not_found')
}
return parsed.data.terminal
}
function acceptedSend(result: unknown): boolean {
const parsed = z.object({ send: z.object({ accepted: z.boolean() }) }).safeParse(result)
if (!parsed.success) {
throw new Error('host_error')
}
return parsed.data.send.accepted
}
@@ -0,0 +1,246 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { RpcAnyMethod, RpcContext, RpcMethod } from '../core'
import { GIT_METHODS } from './git'
import { REPO_METHODS } from './repo'
import { WORKTREE_METHODS } from './worktree'
import { TERMINAL_SEND_METHODS } from './terminal/terminal-send-method'
import { MOBILE_WEB_SOURCE_CONTROL_REPOSITORY_METHODS } from './mobile-web-source-control-repository'
import { MOBILE_WEB_SOURCE_CONTROL_REVIEW_METADATA_METHODS } from './mobile-web-source-control-review-metadata'
import { MOBILE_WEB_SOURCE_CONTROL_REVIEW_LINK_METHODS } from './mobile-web-source-control-review-link'
import { MOBILE_WEB_SOURCE_CONTROL_REVIEW_DIFF_METHODS } from './mobile-web-source-control-review-diff'
import { MOBILE_WEB_SOURCE_CONTROL_REVIEW_TERMINAL_METHODS } from './mobile-web-source-control-review-terminal-send'
import { mobileWebReviewMetadataRevision } from '../../../../shared/mobile-web/source-control-review-presentation'
const worktree = 'id:private-host-workspace'
const OID = 'a'.repeat(40)
const METHODS = [
...MOBILE_WEB_SOURCE_CONTROL_REPOSITORY_METHODS,
...MOBILE_WEB_SOURCE_CONTROL_REVIEW_METADATA_METHODS,
...MOBILE_WEB_SOURCE_CONTROL_REVIEW_LINK_METHODS,
...MOBILE_WEB_SOURCE_CONTROL_REVIEW_DIFF_METHODS,
...MOBILE_WEB_SOURCE_CONTROL_REVIEW_TERMINAL_METHODS
]
function hostMethod(name: string): RpcAnyMethod {
return [...GIT_METHODS, ...WORKTREE_METHODS, ...REPO_METHODS, ...TERMINAL_SEND_METHODS].find(
(method) => method.name === name
)!
}
function stub(name: string, result: unknown) {
return vi.spyOn(hostMethod(name) as RpcMethod, 'handler').mockResolvedValue(result)
}
async function run(
name: string,
params: Record<string, unknown> = {},
context?: Partial<RpcContext>
) {
const method = METHODS.find((entry) => entry.name === name)!
return method.handler(method.params!.parse({ worktree, ...params }), {
signal: new AbortController().signal,
...context
} as RpcContext)
}
const comment = {
id: 'comment-1',
relativePath: 'src/app.ts',
lineNumber: 4,
body: 'needs a test',
createdAt: 1,
side: 'modified' as const
}
afterEach(() => vi.restoreAllMocks())
describe('host repository state', () => {
it('composes status, upstream and the workspace base ref into one page-shaped read', async () => {
stub('git.status', { head: OID, branch: 'main', conflictOperation: 'none' })
stub('git.upstreamStatus', { hasUpstream: true, ahead: 2, behind: 0, upstreamName: 'origin/x' })
stub('worktree.show', { worktree: { id: 'wt', repoId: 'repo-1', baseRef: 'origin/main' } })
await expect(run('mobileWeb.sourceControl.repositoryState')).resolves.toEqual({
head: OID,
branch: 'main',
conflictOperation: 'unknown',
baseRef: 'origin/main',
upstream: {
hasUpstream: true,
upstreamName: 'origin/x',
ahead: 2,
behind: 0,
hasConfiguredPushTarget: false,
behindCommitsArePatchEquivalent: false
}
})
})
it('falls back to the project default when the workspace pinned no base ref', async () => {
stub('git.status', { head: null, branch: 'main', conflictOperation: 'rebase' })
stub('git.upstreamStatus', { hasUpstream: false, ahead: 0, behind: 0 })
stub('worktree.show', { worktree: { id: 'wt', repoId: 'repo-1', path: '/private/repo' } })
const baseRefDefault = stub('repo.baseRefDefault', { defaultBaseRef: 'origin/trunk' })
const result = await run('mobileWeb.sourceControl.repositoryState')
expect(result).toMatchObject({ baseRef: 'origin/trunk', conflictOperation: 'rebase' })
expect(baseRefDefault.mock.calls[0]![0]).toEqual({ repo: 'id:repo-1' })
expect(JSON.stringify(result)).not.toContain('private')
})
})
describe('host review metadata', () => {
it('projects only review fields off the workspace record', async () => {
stub('worktree.show', {
worktree: {
id: 'wt',
path: '/private/repo',
setupScript: 'curl evil',
diffComments: [{ ...comment, filePath: 'src/app.ts' }],
mobileDiffReview: { version: 1, files: {} }
}
})
const result = (await run('mobileWeb.sourceControl.reviewMetadata')) as {
comments: unknown[]
revision: string
}
expect(result.comments).toEqual([comment])
expect(JSON.stringify(result)).not.toMatch(/private|setupScript|workspaceId/)
})
it('refuses a stale write and sends only review fields to the workspace record', async () => {
const record = {
id: 'wt',
path: '/private/repo',
diffComments: [],
mobileDiffReview: { version: 1, files: {} }
}
stub('worktree.show', { worktree: record })
const set = stub('worktree.set', { worktree: record })
const reviewState = { version: 1 as const, files: [] }
const revision = mobileWebReviewMetadataRevision({
comments: [],
reviewState: { version: 1, files: [] }
})
await expect(
run('mobileWeb.sourceControl.reviewMetadataUpdate', {
expectedRevision: 'b'.repeat(64),
comments: [],
reviewState
})
).rejects.toThrow('conflict')
expect(set).not.toHaveBeenCalled()
await run('mobileWeb.sourceControl.reviewMetadataUpdate', {
expectedRevision: revision,
comments: [comment],
reviewState
})
expect(set.mock.calls[0]![0]).toEqual({
worktree,
diffComments: [
{
id: 'comment-1',
worktreeId: 'private-host-workspace',
filePath: 'src/app.ts',
lineNumber: 4,
body: 'needs a test',
createdAt: 1,
side: 'modified'
}
],
mobileDiffReview: { version: 1, files: {} }
})
})
it('rejects a write that names another workspace', async () => {
stub('worktree.show', { worktree: { id: 'wt' } })
await expect(
run('mobileWeb.sourceControl.reviewMetadataUpdate', {
expectedRevision: 'b'.repeat(64),
comments: [],
reviewState: { version: 1, files: [] },
workspaceId: 'other-workspace'
})
).rejects.toThrow()
})
})
describe('host review link', () => {
it('reads the linked review numbers and writes one provider field', async () => {
const record = { id: 'wt', path: '/private/repo', baseRef: 'main', linkedGitLabMR: 7 }
stub('worktree.show', { worktree: record })
const set = stub('worktree.set', { worktree: record })
await expect(run('mobileWeb.sourceControl.reviewLink')).resolves.toEqual({
baseRef: 'main',
linkedGitHubPR: null,
linkedGitLabMR: 7,
linkedBitbucketPR: null,
linkedAzureDevOpsPR: null,
linkedGiteaPR: null
})
await run('mobileWeb.sourceControl.reviewLinkUpdate', { provider: 'github', number: 12 })
expect(set.mock.calls[0]![0]).toEqual({ worktree, linkedPR: 12 })
})
})
describe('host review diff', () => {
it('pages a staged diff and refuses a branch diff without compare identity', async () => {
stub('git.diff', { kind: 'text', originalContent: 'old\n', modifiedContent: 'new\n' })
await expect(
run('mobileWeb.sourceControl.reviewDiff', { relativePath: 'src/app.ts', scope: 'staged' })
).resolves.toMatchObject({ kind: 'text', scope: 'staged', relativePath: 'src/app.ts' })
await expect(
run('mobileWeb.sourceControl.reviewDiff', { relativePath: 'src/app.ts', scope: 'branch' })
).rejects.toThrow()
})
})
describe('host review terminal send', () => {
it('resolves the terminal from the requested workspace tab list', async () => {
const send = stub('terminal.send', { send: { accepted: true } })
const listMobileSessionTabs = vi.fn().mockResolvedValue({
worktree: 'private-host-workspace',
tabs: [{ id: 'tab-1', type: 'terminal', status: 'ready', terminal: 'terminal-1' }]
})
await expect(
run('mobileWeb.sourceControl.reviewTerminalSend', { tabId: 'tab-1', text: 'review this' }, {
clientId: 'device-1',
runtime: { listMobileSessionTabs }
} as unknown as RpcContext)
).resolves.toEqual({ accepted: true })
expect(send.mock.calls[0]![0]).toMatchObject({
terminal: 'terminal-1',
text: 'review this',
enter: true,
client: { id: 'device-1', type: 'mobile' }
})
})
it('refuses a tab that belongs to another workspace', async () => {
const send = stub('terminal.send', { send: { accepted: true } })
const listMobileSessionTabs = vi.fn().mockResolvedValue({
worktree: 'other-workspace',
tabs: [{ id: 'tab-1', type: 'terminal', status: 'ready', terminal: 'terminal-1' }]
})
await expect(
run('mobileWeb.sourceControl.reviewTerminalSend', { tabId: 'tab-1', text: 'review this' }, {
clientId: 'device-1',
runtime: { listMobileSessionTabs }
} as unknown as RpcContext)
).rejects.toThrow('selector_not_found')
expect(send).not.toHaveBeenCalled()
})
it('refuses a tab id the requested workspace does not list', async () => {
const send = stub('terminal.send', { send: { accepted: true } })
const listMobileSessionTabs = vi.fn().mockResolvedValue({
worktree: 'private-host-workspace',
tabs: [{ id: 'tab-2', type: 'terminal', status: 'ready', terminal: 'terminal-2' }]
})
await expect(
run('mobileWeb.sourceControl.reviewTerminalSend', { tabId: 'tab-1', text: 'review this' }, {
clientId: 'device-1',
runtime: { listMobileSessionTabs }
} as unknown as RpcContext)
).rejects.toThrow('selector_not_found')
expect(send).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,8 @@
/** Strips the placeholder identity a shared page projection carries, so the wrapper result never
* asserts a workspace handle the Desktop does not own. */
export function withoutMobileWebWorkspaceId<T extends { workspaceId: string }>(
value: T
): Omit<T, 'workspaceId'> {
const { workspaceId: _workspaceId, ...rest } = value
return rest
}
@@ -0,0 +1,91 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { expect, it, vi } from 'vitest'
import { OrcaRuntimeRpcServer } from './runtime-rpc'
import { DeviceRegistry } from './device-registry'
import { createMobileRpcSurfaceRuntime } from './runtime-rpc-mobile-method-allowlist-fixtures'
/** Every Source Control write the page reaches through the generic host lane, with the runtime
* call each one must land on. The shell rewrote the page handle into `worktree`; nothing else in
* the request may choose the workspace. */
const MUTATIONS: [string, string, Record<string, unknown>, unknown[]][] = [
['git.stage', 'stageRuntimeGitPath', { filePath: 'src/app.ts' }, ['id:bound', 'src/app.ts']],
[
'git.bulkStage',
'bulkStageRuntimeGitPaths',
{ filePaths: ['src/app.ts'] },
['id:bound', ['src/app.ts']]
],
['git.unstage', 'unstageRuntimeGitPath', { filePath: 'src/app.ts' }, ['id:bound', 'src/app.ts']],
[
'git.bulkUnstage',
'bulkUnstageRuntimeGitPaths',
{ filePaths: ['src/app.ts'] },
['id:bound', ['src/app.ts']]
],
['git.discard', 'discardRuntimeGitPath', { filePath: 'src/app.ts' }, ['id:bound', 'src/app.ts']],
[
'git.bulkDiscard',
'bulkDiscardRuntimeGitPaths',
{ filePaths: ['src/app.ts'] },
['id:bound', ['src/app.ts']]
],
['git.commit', 'commitRuntimeGit', { message: 'feat: mobile' }, ['id:bound', 'feat: mobile']],
['git.checkout', 'checkoutRuntimeGitBranch', { branch: 'main' }, ['id:bound', 'main']],
['git.fetch', 'fetchRuntimeGit', {}, ['id:bound']],
['git.pull', 'pullRuntimeGit', {}, ['id:bound']],
['git.fastForward', 'fastForwardRuntimeGit', {}, ['id:bound']],
['git.push', 'pushRuntimeGit', { publish: true }, ['id:bound', true]],
['git.rebaseFromBase', 'rebaseRuntimeGitFromBase', { baseRef: 'main' }, ['id:bound', 'main']],
['git.abortMerge', 'abortRuntimeGitMerge', {}, ['id:bound']],
['git.abortRebase', 'abortRuntimeGitRebase', {}, ['id:bound']],
[
'files.openDiff',
'openMobileDiff',
{ relativePath: 'src/app.ts', staged: true },
['id:bound', 'src/app.ts', true]
]
]
it('binds every page-reachable Source Control write to the shell-supplied worktree', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-mobile-source-control-'))
const { runtime } = createMobileRpcSurfaceRuntime()
const calls = new Map<string, ReturnType<typeof vi.fn>>()
for (const [, runtimeMethod] of MUTATIONS) {
const spy = vi.fn().mockResolvedValue({ ok: true, branch: 'main', success: true })
calls.set(runtimeMethod, spy)
Object.assign(runtime, { [runtimeMethod]: spy })
}
const server = new OrcaRuntimeRpcServer({ runtime, userDataPath, enableWebSocket: false })
server['deviceRegistry'] = new DeviceRegistry(userDataPath)
const mobile = server['deviceRegistry']!.addDevice('phone', 'mobile')
async function dispatch(method: string, params: unknown) {
const responses: { ok: boolean; error?: { code: string } }[] = []
await server['handleWebSocketMessage'](
JSON.stringify({ id: 'request', method, params, deviceToken: mobile.token }),
(response) => responses.push(JSON.parse(response)),
() => {}
)
return responses[0]!
}
try {
for (const [method, runtimeMethod, params, expected] of MUTATIONS) {
// A second workspace field in the body must not redirect the write.
const response = await dispatch(method, {
...params,
worktree: 'id:bound',
worktreeId: 'id:other',
workspaceId: 'other'
})
expect(response.error, method).toBeUndefined()
expect(
calls.get(runtimeMethod)!.mock.calls.at(-1)?.slice(0, expected.length),
method
).toEqual(expected)
}
} finally {
await server.stop()
rmSync(userDataPath, { recursive: true, force: true })
}
})
@@ -1,59 +1,58 @@
import { describe, expect, it } from 'vitest'
import {
MOBILE_WEB_COMMIT_MESSAGE_MAX_CHARACTERS,
MOBILE_WEB_COMMIT_STAGED_ENTRY_LIMIT,
MOBILE_WEB_COMMIT_RESULT_ERROR_MAX_CHARACTERS,
MobileWebSourceControlCommitPayloadSchema,
MobileWebSourceControlCommitResultSchema,
MobileWebSourceControlGenerateCommitMessageResultSchema
} from './source-control-commit-contract'
const staged = {
relativePath: 'src/app.ts',
status: 'modified' as const,
area: 'staged' as const
}
describe('mobile web source-control commit contract', () => {
it('accepts a bounded commit tied to a full HEAD and staged snapshot', () => {
it('accepts a bounded commit message', () => {
expect(
MobileWebSourceControlCommitPayloadSchema.parse({
workspaceId: 'workspace-1',
expectedHead: 'a'.repeat(40),
stagedEntries: [staged],
message: 'feat: add mobile commit'
})
).toMatchObject({ stagedEntries: [staged] })
).toEqual({ workspaceId: 'workspace-1', message: 'feat: add mobile commit' })
})
it('rejects blank or oversized messages and unsafe staged snapshots', () => {
it('rejects blank, oversized and unexpectedly extended commit requests', () => {
for (const candidate of [
{ message: ' ', stagedEntries: [staged] },
{
message: 'x'.repeat(MOBILE_WEB_COMMIT_MESSAGE_MAX_CHARACTERS + 1),
stagedEntries: [staged]
},
{ message: 'feat: duplicate', stagedEntries: [staged, staged] },
{
message: 'feat: unresolved',
stagedEntries: [{ ...staged, conflictStatus: 'unresolved' }]
},
{
message: 'feat: too many',
stagedEntries: Array.from(
{ length: MOBILE_WEB_COMMIT_STAGED_ENTRY_LIMIT + 1 },
(_, index) => ({ ...staged, relativePath: `src/${index}.ts` })
)
}
{ message: ' ' },
{ message: 'x'.repeat(MOBILE_WEB_COMMIT_MESSAGE_MAX_CHARACTERS + 1) },
{ message: 'feat: amend', amend: true }
]) {
expect(() =>
MobileWebSourceControlCommitPayloadSchema.parse({
expect(
MobileWebSourceControlCommitPayloadSchema.safeParse({
workspaceId: 'workspace-1',
expectedHead: 'a'.repeat(40),
...candidate
})
).toThrow()
}).success
).toBe(false)
}
})
it('reads a refused commit from the result and bounds its error text', () => {
expect(
MobileWebSourceControlCommitResultSchema.parse({
success: false,
error: 'pre-commit hook failed'
})
).toEqual({ success: false, error: 'pre-commit hook failed' })
expect(
MobileWebSourceControlCommitResultSchema.safeParse({
success: false,
error: 'x'.repeat(MOBILE_WEB_COMMIT_RESULT_ERROR_MAX_CHARACTERS + 1)
}).success
).toBe(false)
expect(
MobileWebSourceControlCommitResultSchema.safeParse({
success: true,
hostPath: '/private/repo'
}).success
).toBe(false)
})
it('bounds generated messages and strips undeclared host fields', () => {
expect(() =>
MobileWebSourceControlGenerateCommitMessageResultSchema.parse({
@@ -1,48 +1,38 @@
import { z } from 'zod'
import { MobileWebWorkspaceIdSchema } from './bridge-operation-contract'
import { isMobileWebGitObjectId } from './protocol-token-contract'
import { MobileWebSourceControlMutationEntrySchema } from './source-control-mutation-contract'
export const MOBILE_WEB_COMMIT_MESSAGE_MAX_CHARACTERS = 10_000
export const MOBILE_WEB_COMMIT_RESULT_ERROR_MAX_CHARACTERS = 2_000
export const MOBILE_WEB_COMMIT_AGENT_LABEL_MAX_CHARACTERS = 160
export const MOBILE_WEB_COMMIT_STAGED_ENTRY_LIMIT = 64
const FullGitObjectIdSchema = z.string().refine(isMobileWebGitObjectId)
export const MobileWebSourceControlCommitEntrySchema =
MobileWebSourceControlMutationEntrySchema.extend({
area: z.literal('staged')
}).superRefine((entry, context) => {
if (entry.conflictStatus === 'unresolved') {
context.addIssue({ code: 'custom', message: 'Unresolved entries cannot be committed' })
}
})
const CommitSnapshotShape = {
workspaceId: MobileWebWorkspaceIdSchema,
expectedHead: FullGitObjectIdSchema,
stagedEntries: z
.array(MobileWebSourceControlCommitEntrySchema)
.min(1)
.max(MOBILE_WEB_COMMIT_STAGED_ENTRY_LIMIT)
} as const
export const MobileWebSourceControlCommitPayloadSchema = z
.object({
...CommitSnapshotShape,
workspaceId: MobileWebWorkspaceIdSchema,
message: z
.string()
.max(MOBILE_WEB_COMMIT_MESSAGE_MAX_CHARACTERS)
.refine((message) => message.trim().length > 0, 'Commit message is required')
})
.strict()
.superRefine(validateUniqueStagedEntries)
/** The Desktop reports a refused commit in the result, not as an RPC error, so the page reads both
* outcomes from the same shape it would get on a native route. */
export const MobileWebSourceControlCommitResultSchema = z
.object({
success: z.boolean(),
error: z.string().max(MOBILE_WEB_COMMIT_RESULT_ERROR_MAX_CHARACTERS).optional()
})
.strict()
export const MobileWebSourceControlGenerateCommitMessagePayloadSchema = z
.object(CommitSnapshotShape)
.object({
workspaceId: MobileWebWorkspaceIdSchema,
expectedHead: FullGitObjectIdSchema
})
.strict()
.superRefine(validateUniqueStagedEntries)
export const MobileWebSourceControlCancelCommitMessagePayloadSchema = z
.object({ workspaceId: MobileWebWorkspaceIdSchema })
@@ -53,23 +43,6 @@ const CommitResultIdentityShape = {
previousHead: FullGitObjectIdSchema
} as const
export const MobileWebSourceControlCommitResultSchema = z.discriminatedUnion('status', [
z
.object({
...CommitResultIdentityShape,
status: z.literal('committed'),
head: FullGitObjectIdSchema.nullable()
})
.strict(),
z
.object({
...CommitResultIdentityShape,
status: z.literal('failed'),
error: z.string().min(1).max(MOBILE_WEB_COMMIT_RESULT_ERROR_MAX_CHARACTERS)
})
.strict()
])
export const MobileWebSourceControlGenerateCommitMessageResultSchema = z.discriminatedUnion(
'status',
[
@@ -104,41 +77,21 @@ export const MobileWebSourceControlCancelCommitMessageResultSchema = z
})
.strict()
export type MobileWebSourceControlCommitEntry = z.infer<
typeof MobileWebSourceControlCommitEntrySchema
>
export type MobileWebSourceControlCommitPayload = z.infer<
typeof MobileWebSourceControlCommitPayloadSchema
>
export type MobileWebSourceControlCommitResult = z.infer<
typeof MobileWebSourceControlCommitResultSchema
>
export type MobileWebSourceControlGenerateCommitMessagePayload = z.infer<
typeof MobileWebSourceControlGenerateCommitMessagePayloadSchema
>
export type MobileWebSourceControlCancelCommitMessagePayload = z.infer<
typeof MobileWebSourceControlCancelCommitMessagePayloadSchema
>
export type MobileWebSourceControlCommitResult = z.infer<
typeof MobileWebSourceControlCommitResultSchema
>
export type MobileWebSourceControlGenerateCommitMessageResult = z.infer<
typeof MobileWebSourceControlGenerateCommitMessageResultSchema
>
export type MobileWebSourceControlCancelCommitMessageResult = z.infer<
typeof MobileWebSourceControlCancelCommitMessageResultSchema
>
function validateUniqueStagedEntries(
payload: { stagedEntries: readonly { relativePath: string }[] },
context: z.RefinementCtx
): void {
const paths = new Set<string>()
payload.stagedEntries.forEach((entry, index) => {
if (paths.has(entry.relativePath)) {
context.addIssue({
code: 'custom',
message: 'Duplicate staged path',
path: ['stagedEntries', index, 'relativePath']
})
}
paths.add(entry.relativePath)
})
}
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import {
MOBILE_WEB_SOURCE_CONTROL_BRANCH_LIMIT,
MOBILE_WEB_SOURCE_CONTROL_COMPARE_ENTRY_LIMIT,
MOBILE_WEB_SOURCE_CONTROL_COMPARE_MAX_ENTRIES,
MOBILE_WEB_SOURCE_CONTROL_HISTORY_MAX_LIMIT,
MobileWebSourceControlBranchComparePayloadSchema,
MobileWebSourceControlBranchCompareResultSchema,
@@ -87,22 +87,24 @@ describe('mobile web source-control history contract', () => {
baseOid: OID,
headOid: 'b'.repeat(40),
mergeBase: OID,
changedFiles: MOBILE_WEB_SOURCE_CONTROL_COMPARE_ENTRY_LIMIT,
changedFiles: MOBILE_WEB_SOURCE_CONTROL_COMPARE_MAX_ENTRIES,
status: 'ready',
revision: 'c'.repeat(64),
offset: 0,
totalEntries: MOBILE_WEB_SOURCE_CONTROL_COMPARE_ENTRY_LIMIT,
entries: Array.from(
{ length: MOBILE_WEB_SOURCE_CONTROL_COMPARE_ENTRY_LIMIT },
{ length: MOBILE_WEB_SOURCE_CONTROL_COMPARE_MAX_ENTRIES },
(_, index) => ({
relativePath: `src/file-${index}.ts`,
status: 'modified'
})
),
nextOffset: null,
truncated: false
}
expect(MobileWebSourceControlBranchCompareResultSchema.safeParse(result).success).toBe(true)
expect(
MobileWebSourceControlBranchCompareResultSchema.safeParse({
...result,
entries: [...result.entries, { relativePath: 'src/extra.ts', status: 'modified' }]
}).success
).toBe(false)
expect(
MobileWebSourceControlBranchCompareResultSchema.safeParse({
...result,
@@ -3,14 +3,13 @@ import {
MobileWebRelativePathSchema,
MobileWebWorkspaceIdSchema
} from './bridge-operation-contract'
import { isMobileWebGitObjectId, isMobileWebSha256 } from './protocol-token-contract'
import { isMobileWebGitObjectId } from './protocol-token-contract'
export const MOBILE_WEB_SOURCE_CONTROL_BRANCH_LIMIT = 128
export const MOBILE_WEB_SOURCE_CONTROL_HISTORY_DEFAULT_LIMIT = 50
export const MOBILE_WEB_SOURCE_CONTROL_HISTORY_MAX_LIMIT = 100
export const MOBILE_WEB_SOURCE_CONTROL_HISTORY_PARENT_LIMIT = 16
export const MOBILE_WEB_SOURCE_CONTROL_HISTORY_REFERENCE_LIMIT = 32
export const MOBILE_WEB_SOURCE_CONTROL_COMPARE_ENTRY_LIMIT = 128
export const MOBILE_WEB_SOURCE_CONTROL_COMPARE_MAX_ENTRIES = 4_000
export const MOBILE_WEB_SOURCE_CONTROL_HISTORY_RESPONSE_MAX_BYTES = 192 * 1024
export const MOBILE_WEB_SOURCE_CONTROL_COMPARE_RESPONSE_MAX_BYTES = 192 * 1024
@@ -98,15 +97,7 @@ export const MobileWebSourceControlHistoryResultSchema = z
export const MobileWebSourceControlBranchComparePayloadSchema = z
.object({
workspaceId: MobileWebWorkspaceIdSchema,
baseRef: MobileWebGitRefNameSchema,
offset: z.number().int().min(0).max(MOBILE_WEB_SOURCE_CONTROL_COMPARE_MAX_ENTRIES).default(0),
limit: z
.number()
.int()
.min(1)
.max(MOBILE_WEB_SOURCE_CONTROL_COMPARE_ENTRY_LIMIT)
.default(MOBILE_WEB_SOURCE_CONTROL_COMPARE_ENTRY_LIMIT),
expectedRevision: z.string().refine(isMobileWebSha256).optional()
baseRef: MobileWebGitRefNameSchema
})
.strict()
@@ -138,18 +129,9 @@ export const MobileWebSourceControlBranchCompareResultSchema = z
changedFiles: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER),
commitsAhead: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER).optional(),
status: z.enum(['ready', 'invalid-base', 'unborn-head', 'no-merge-base', 'error']),
revision: z.string().refine(isMobileWebSha256),
offset: z.number().int().min(0).max(MOBILE_WEB_SOURCE_CONTROL_COMPARE_MAX_ENTRIES),
totalEntries: z.number().int().min(0).max(MOBILE_WEB_SOURCE_CONTROL_COMPARE_MAX_ENTRIES),
entries: z
.array(MobileWebSourceControlCompareEntrySchema)
.max(MOBILE_WEB_SOURCE_CONTROL_COMPARE_ENTRY_LIMIT),
nextOffset: z
.number()
.int()
.min(1)
.max(MOBILE_WEB_SOURCE_CONTROL_COMPARE_MAX_ENTRIES)
.nullable(),
.max(MOBILE_WEB_SOURCE_CONTROL_COMPARE_MAX_ENTRIES),
truncated: z.boolean()
})
.strict()
@@ -166,7 +148,7 @@ export const MobileWebSourceControlCommitCompareResultSchema = z
status: z.enum(['ready', 'invalid-commit', 'error']),
entries: z
.array(MobileWebSourceControlCompareEntrySchema)
.max(MOBILE_WEB_SOURCE_CONTROL_COMPARE_ENTRY_LIMIT),
.max(MOBILE_WEB_SOURCE_CONTROL_COMPARE_MAX_ENTRIES),
truncated: z.boolean()
})
.strict()
@@ -6,7 +6,7 @@ import {
MobileWebSourceControlHistoryRefSchema,
type MobileWebSourceControlHistoryItem,
type MobileWebSourceControlHistoryRef
} from '../../../src/shared/mobile-web/source-control-history-contract'
} from './source-control-history-contract'
export function sanitizeMobileWebHistoryItem(
candidate: unknown
@@ -0,0 +1,249 @@
import {
MOBILE_WEB_SOURCE_CONTROL_BRANCH_LIMIT,
MOBILE_WEB_SOURCE_CONTROL_COMPARE_MAX_ENTRIES,
MOBILE_WEB_SOURCE_CONTROL_COMPARE_RESPONSE_MAX_BYTES,
MOBILE_WEB_SOURCE_CONTROL_HISTORY_RESPONSE_MAX_BYTES,
MobileWebGitObjectIdSchema,
MobileWebGitRefNameSchema,
MobileWebSourceControlBranchCompareResultSchema,
MobileWebSourceControlBranchesResultSchema,
MobileWebSourceControlCommitCompareResultSchema,
MobileWebSourceControlCompareEntrySchema,
MobileWebSourceControlHistoryResultSchema,
type MobileWebSourceControlBranchCompareResult,
type MobileWebSourceControlBranchesResult,
type MobileWebSourceControlCommitCompareResult,
type MobileWebSourceControlCompareEntry,
type MobileWebSourceControlHistoryItem,
type MobileWebSourceControlHistoryResult
} from './source-control-history-contract'
import { MobileWebBrokerError } from './bridge-operation-error'
import {
sanitizeMobileWebHistoryItem,
sanitizeMobileWebHistoryRef
} from './source-control-history-item-presentation'
const RESPONSE_BUDGET_RESERVE_BYTES = 8 * 1024
export function projectMobileWebBranches(
result: unknown,
workspaceId: string
): MobileWebSourceControlBranchesResult {
if (!isRecord(result) || !Array.isArray(result.branches)) {
throw new MobileWebBrokerError('host_error')
}
const branches = result.branches
.slice(0, MOBILE_WEB_SOURCE_CONTROL_BRANCH_LIMIT)
.flatMap((candidate) => {
const branch = safeGitRef(candidate)
return branch ? [branch] : []
})
return MobileWebSourceControlBranchesResultSchema.parse({
workspaceId,
current: safeGitRef(result.current),
branches,
totalCount: result.branches.length,
truncated: branches.length < result.branches.length
})
}
export function projectMobileWebHistory(
result: unknown,
workspaceId: string,
limit: number
): MobileWebSourceControlHistoryResult {
if (!isRecord(result) || !Array.isArray(result.items)) {
throw new MobileWebBrokerError('host_error')
}
const items: MobileWebSourceControlHistoryItem[] = []
let retainedBytes = 0
let droppedByBudget = false
for (const candidate of result.items.slice(0, limit)) {
const item = sanitizeMobileWebHistoryItem(candidate)
if (!item) {
continue
}
const nextBytes = encodedByteLength(item) + 1
if (
retainedBytes + nextBytes >
MOBILE_WEB_SOURCE_CONTROL_HISTORY_RESPONSE_MAX_BYTES - RESPONSE_BUDGET_RESERVE_BYTES
) {
droppedByBudget = true
break
}
retainedBytes += nextBytes
items.push(item)
}
const currentRef = sanitizeMobileWebHistoryRef(result.currentRef)
const remoteRef = sanitizeMobileWebHistoryRef(result.remoteRef)
const baseRef = sanitizeMobileWebHistoryRef(result.baseRef)
const mergeBase = safeObjectId(result.mergeBase)
return MobileWebSourceControlHistoryResultSchema.parse({
workspaceId,
items,
...(currentRef ? { currentRef } : {}),
...(remoteRef ? { remoteRef } : {}),
...(baseRef ? { baseRef } : {}),
...(mergeBase ? { mergeBase } : {}),
hasIncomingChanges: result.hasIncomingChanges === true,
hasOutgoingChanges: result.hasOutgoingChanges === true,
hasMore:
result.hasMore === true ||
droppedByBudget ||
result.items.length > limit ||
items.length < Math.min(result.items.length, limit),
limit
})
}
export function projectMobileWebBranchCompare(
result: unknown,
workspaceId: string,
baseRef: string
): MobileWebSourceControlBranchCompareResult {
const summary = compareSummary(result)
const page = compareEntryPage(result)
const changedFiles = Math.max(page.reportedCount, safeNonnegativeInteger(summary.changedFiles))
const commitsAhead = optionalNonnegativeInteger(summary.commitsAhead)
return MobileWebSourceControlBranchCompareResultSchema.parse({
workspaceId,
baseRef,
compareRef: boundedString(summary.compareRef, 240) ?? 'HEAD',
baseOid: safeObjectId(summary.baseOid),
headOid: safeObjectId(summary.headOid),
mergeBase: safeObjectId(summary.mergeBase),
changedFiles,
...(commitsAhead === undefined ? {} : { commitsAhead }),
status: branchCompareStatus(summary.status),
entries: page.entries,
truncated: page.truncated || changedFiles > page.entries.length
})
}
export function projectMobileWebCommitCompare(
result: unknown,
workspaceId: string,
commitId: string
): MobileWebSourceControlCommitCompareResult {
const summary = compareSummary(result)
const page = compareEntryPage(result)
const changedFiles = Math.max(page.reportedCount, safeNonnegativeInteger(summary.changedFiles))
return MobileWebSourceControlCommitCompareResultSchema.parse({
workspaceId,
commitId,
commitOid: safeObjectId(summary.commitOid),
parentOid: safeObjectId(summary.parentOid),
compareRef: boundedString(summary.compareRef, 240) ?? commitId.slice(0, 12),
baseRef: boundedString(summary.baseRef, 240) ?? 'parent',
changedFiles,
status: commitCompareStatus(summary.status),
entries: page.entries,
truncated: page.truncated || changedFiles > page.entries.length
})
}
/** One response carries the whole compare, so the entry list is bounded by both the entry cap and
* the bridge response budget rather than by a resumable offset the page would have to drive. */
function compareEntryPage(result: unknown): {
entries: MobileWebSourceControlCompareEntry[]
reportedCount: number
truncated: boolean
} {
if (!isRecord(result) || !Array.isArray(result.entries)) {
throw new MobileWebBrokerError('host_error')
}
const entries: MobileWebSourceControlCompareEntry[] = []
let retainedBytes = 0
let droppedByBudget = false
for (const candidate of result.entries.slice(0, MOBILE_WEB_SOURCE_CONTROL_COMPARE_MAX_ENTRIES)) {
const entry = compareEntry(candidate)
if (!entry) {
continue
}
const nextBytes = encodedByteLength(entry) + 1
if (
retainedBytes + nextBytes >
MOBILE_WEB_SOURCE_CONTROL_COMPARE_RESPONSE_MAX_BYTES - RESPONSE_BUDGET_RESERVE_BYTES
) {
droppedByBudget = true
break
}
retainedBytes += nextBytes
entries.push(entry)
}
return {
entries,
reportedCount: result.entries.length,
truncated: droppedByBudget || entries.length < result.entries.length
}
}
function compareEntry(candidate: unknown): MobileWebSourceControlCompareEntry | null {
if (!isRecord(candidate)) {
return null
}
const parsed = MobileWebSourceControlCompareEntrySchema.safeParse({
relativePath: candidate.path,
...(candidate.oldPath === undefined ? {} : { oldRelativePath: candidate.oldPath }),
status: candidate.status,
...(optionalNonnegativeInteger(candidate.added) === undefined
? {}
: { added: candidate.added }),
...(optionalNonnegativeInteger(candidate.removed) === undefined
? {}
: { removed: candidate.removed })
})
return parsed.success ? parsed.data : null
}
function compareSummary(result: unknown): Record<string, unknown> {
if (!isRecord(result) || !isRecord(result.summary)) {
throw new MobileWebBrokerError('host_error')
}
return result.summary
}
function branchCompareStatus(
value: unknown
): 'ready' | 'invalid-base' | 'unborn-head' | 'no-merge-base' | 'error' {
return value === 'ready' ||
value === 'invalid-base' ||
value === 'unborn-head' ||
value === 'no-merge-base'
? value
: 'error'
}
function commitCompareStatus(value: unknown): 'ready' | 'invalid-commit' | 'error' {
return value === 'ready' || value === 'invalid-commit' ? value : 'error'
}
function safeGitRef(value: unknown): string | null {
const parsed = MobileWebGitRefNameSchema.safeParse(value)
return parsed.success ? parsed.data : null
}
function safeObjectId(value: unknown): string | null {
const parsed = MobileWebGitObjectIdSchema.safeParse(value)
return parsed.success ? parsed.data : null
}
function safeNonnegativeInteger(value: unknown): number {
return optionalNonnegativeInteger(value) ?? 0
}
function optionalNonnegativeInteger(value: unknown): number | undefined {
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : undefined
}
function boundedString(value: unknown, limit: number): string | undefined {
return typeof value === 'string' && value.length > 0 ? value.slice(0, limit) : undefined
}
function encodedByteLength(value: unknown): number {
return new TextEncoder().encode(JSON.stringify(value)).byteLength
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
@@ -1,70 +1,49 @@
import { describe, expect, it } from 'vitest'
import {
MOBILE_WEB_SOURCE_CONTROL_MUTATION_LIMIT,
MobileWebSourceControlDiscardPayloadSchema,
MobileWebSourceControlStagePayloadSchema,
MobileWebSourceControlUnstagePayloadSchema
MobileWebSourceControlMutationPayloadSchema
} from './source-control-mutation-contract'
const modified = {
relativePath: 'src/app.ts',
status: 'modified' as const,
area: 'unstaged' as const
}
describe('mobile web source-control mutation contract', () => {
it('accepts bounded stage and unstage snapshots', () => {
it('accepts a bounded set of workspace-relative paths', () => {
expect(
MobileWebSourceControlStagePayloadSchema.parse({
MobileWebSourceControlMutationPayloadSchema.parse({
workspaceId: 'workspace-1',
expectedHead: 'a'.repeat(40),
entries: [modified]
relativePaths: ['src/app.ts', 'src/other.ts']
})
).toMatchObject({ entries: [modified] })
expect(
MobileWebSourceControlUnstagePayloadSchema.parse({
workspaceId: 'workspace-1',
expectedHead: null,
entries: [{ ...modified, area: 'staged' }]
})
).toMatchObject({ entries: [{ area: 'staged' }] })
).toEqual({ workspaceId: 'workspace-1', relativePaths: ['src/app.ts', 'src/other.ts'] })
})
it('requires explicit confirmation and eligible discard entries', () => {
expect(() =>
MobileWebSourceControlDiscardPayloadSchema.parse({
it('rejects duplicate, empty and oversized path sets', () => {
expect(
MobileWebSourceControlMutationPayloadSchema.safeParse({
workspaceId: 'workspace-1',
expectedHead: null,
entries: [modified]
})
).toThrow()
expect(() =>
MobileWebSourceControlDiscardPayloadSchema.parse({
relativePaths: ['src/app.ts', 'src/app.ts']
}).success
).toBe(false)
expect(
MobileWebSourceControlMutationPayloadSchema.safeParse({
workspaceId: 'workspace-1',
expectedHead: null,
confirmation: 'discard-confirmed',
entries: [{ ...modified, area: 'staged' }]
})
).toThrow()
})
it('rejects duplicate and oversized path sets', () => {
expect(() =>
MobileWebSourceControlStagePayloadSchema.parse({
relativePaths: []
}).success
).toBe(false)
expect(
MobileWebSourceControlMutationPayloadSchema.safeParse({
workspaceId: 'workspace-1',
expectedHead: null,
entries: [modified, modified]
})
).toThrow()
expect(() =>
MobileWebSourceControlStagePayloadSchema.parse({
workspaceId: 'workspace-1',
expectedHead: null,
entries: Array.from(
relativePaths: Array.from(
{ length: MOBILE_WEB_SOURCE_CONTROL_MUTATION_LIMIT + 1 },
(_, index) => ({ ...modified, relativePath: `src/${index}.ts` })
(_, index) => `src/${index}.ts`
)
})
).toThrow()
}).success
).toBe(false)
})
it('rejects a path that escapes the workspace', () => {
expect(
MobileWebSourceControlMutationPayloadSchema.safeParse({
workspaceId: 'workspace-1',
relativePaths: ['../outside.ts']
}).success
).toBe(false)
})
})
@@ -1,127 +1,27 @@
import { z } from 'zod'
import { MobileWebWorkspaceIdSchema } from './bridge-operation-contract'
import { isMobileWebGitObjectId } from './protocol-token-contract'
import { MobileWebSourceControlStatusEntrySchema } from './source-control-operation-contract'
export const MOBILE_WEB_SOURCE_CONTROL_MUTATION_LIMIT = 32
export const MobileWebSourceControlMutationOperationSchema = z.enum(['stage', 'unstage', 'discard'])
export const MobileWebSourceControlMutationEntrySchema =
MobileWebSourceControlStatusEntrySchema.pick({
relativePath: true,
oldRelativePath: true,
status: true,
area: true,
conflictStatus: true
})
const ExpectedHeadSchema = z.string().refine(isMobileWebGitObjectId).nullable()
const MutationPayloadShape = {
workspaceId: MobileWebWorkspaceIdSchema,
expectedHead: ExpectedHeadSchema,
entries: z
.array(MobileWebSourceControlMutationEntrySchema)
.min(1)
.max(MOBILE_WEB_SOURCE_CONTROL_MUTATION_LIMIT)
} as const
export const MobileWebSourceControlStagePayloadSchema = z
.object(MutationPayloadShape)
.strict()
.superRefine((payload, context) => {
validateUniquePaths(payload.entries, context)
payload.entries.forEach((entry, index) => {
if (entry.area === 'staged' || entry.conflictStatus === 'unresolved') {
context.addIssue({
code: 'custom',
message: 'Entry cannot be staged',
path: ['entries', index]
})
}
})
})
export const MobileWebSourceControlUnstagePayloadSchema = z
.object(MutationPayloadShape)
.strict()
.superRefine((payload, context) => {
validateUniquePaths(payload.entries, context)
payload.entries.forEach((entry, index) => {
if (entry.area !== 'staged') {
context.addIssue({
code: 'custom',
message: 'Entry cannot be unstaged',
path: ['entries', index]
})
}
})
})
export const MobileWebSourceControlDiscardPayloadSchema = z
.object({
...MutationPayloadShape,
confirmation: z.literal('discard-confirmed')
})
.strict()
.superRefine((payload, context) => {
validateUniquePaths(payload.entries, context)
payload.entries.forEach((entry, index) => {
if (entry.area === 'staged' || entry.conflictStatus === 'unresolved') {
context.addIssue({
code: 'custom',
message: 'Entry cannot be discarded',
path: ['entries', index]
})
}
})
})
export const MobileWebSourceControlMutationResultSchema = z
/** The Desktop decides whether a path may be staged, unstaged or discarded; the page sends the
* paths it saw and reads the refusal back from the Desktop. */
export const MobileWebSourceControlMutationPayloadSchema = z
.object({
workspaceId: MobileWebWorkspaceIdSchema,
operation: MobileWebSourceControlMutationOperationSchema,
relativePaths: z
.array(MobileWebSourceControlStatusEntrySchema.shape.relativePath)
.min(1)
.max(MOBILE_WEB_SOURCE_CONTROL_MUTATION_LIMIT),
mutated: z.literal(true)
.max(MOBILE_WEB_SOURCE_CONTROL_MUTATION_LIMIT)
.refine((paths) => new Set(paths).size === paths.length, 'Duplicate mutation path')
})
.strict()
export type MobileWebSourceControlMutationOperation = z.infer<
typeof MobileWebSourceControlMutationOperationSchema
>
export type MobileWebSourceControlMutationEntry = z.infer<
typeof MobileWebSourceControlMutationEntrySchema
export type MobileWebSourceControlMutationPayload = z.infer<
typeof MobileWebSourceControlMutationPayloadSchema
>
export type MobileWebSourceControlStagePayload = z.infer<
typeof MobileWebSourceControlStagePayloadSchema
>
export type MobileWebSourceControlUnstagePayload = z.infer<
typeof MobileWebSourceControlUnstagePayloadSchema
>
export type MobileWebSourceControlDiscardPayload = z.infer<
typeof MobileWebSourceControlDiscardPayloadSchema
>
export type MobileWebSourceControlMutationResult = z.infer<
typeof MobileWebSourceControlMutationResultSchema
>
function validateUniquePaths(
entries: readonly { relativePath: string }[],
context: z.RefinementCtx
): void {
const paths = new Set<string>()
entries.forEach((entry, index) => {
if (paths.has(entry.relativePath)) {
context.addIssue({
code: 'custom',
message: 'Duplicate mutation path',
path: ['entries', index, 'relativePath']
})
}
paths.add(entry.relativePath)
})
}
@@ -0,0 +1,71 @@
import { MobileWebGitRefNameSchema } from './source-control-history-contract'
import {
MobileWebSourceControlRepositoryStateSchema,
MobileWebSourceControlUpstreamSnapshotSchema,
type MobileWebSourceControlRepositoryState,
type MobileWebSourceControlUpstreamSnapshot
} from './source-control-sync-contract'
import { MobileWebBrokerError } from './bridge-operation-error'
export function projectMobileWebRepositoryState(args: {
status: unknown
upstream: unknown
baseRef: unknown
workspaceId: string
}): MobileWebSourceControlRepositoryState {
if (!isRecord(args.status)) {
throw new MobileWebBrokerError('host_error')
}
return MobileWebSourceControlRepositoryStateSchema.parse({
workspaceId: args.workspaceId,
head: safeHead(args.status.head),
branch: safeBranch(args.status.branch),
conflictOperation: safeConflictOperation(args.status.conflictOperation),
baseRef: safeBranch(args.baseRef),
upstream: projectMobileWebUpstreamSnapshot(args.upstream)
})
}
export function projectMobileWebUpstreamSnapshot(
value: unknown
): MobileWebSourceControlUpstreamSnapshot {
if (!isRecord(value)) {
throw new MobileWebBrokerError('host_error')
}
const upstreamName = boundedString(value.upstreamName, 240)
return MobileWebSourceControlUpstreamSnapshotSchema.parse({
hasUpstream: value.hasUpstream === true,
...(upstreamName ? { upstreamName } : {}),
ahead: safeNonnegativeInteger(value.ahead),
behind: safeNonnegativeInteger(value.behind),
hasConfiguredPushTarget: value.hasConfiguredPushTarget === true,
behindCommitsArePatchEquivalent: value.behindCommitsArePatchEquivalent === true
})
}
function safeHead(value: unknown): string | null {
return typeof value === 'string' && /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(value) ? value : null
}
function safeBranch(value: unknown): string | null {
const parsed = MobileWebGitRefNameSchema.safeParse(value)
return parsed.success ? parsed.data : null
}
function safeConflictOperation(value: unknown): 'merge' | 'rebase' | 'cherry-pick' | 'unknown' {
return value === 'merge' || value === 'rebase' || value === 'cherry-pick' ? value : 'unknown'
}
function safeNonnegativeInteger(value: unknown): number {
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : 0
}
function boundedString(value: unknown, limit: number): string | undefined {
return typeof value === 'string' && value.trim().length > 0
? value.trim().slice(0, limit)
: undefined
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
@@ -111,12 +111,17 @@ export const MobileWebSourceControlReviewMetadataResultSchema = z
.strict()
.superRefine(rejectDuplicateReviewMetadataKeys)
/** Shared with the Desktop wrapper, which addresses the workspace by worktree selector instead. */
export const MobileWebSourceControlReviewMetadataUpdateShape = {
expectedRevision: RevisionSchema,
comments: ReviewMetadataShape.comments,
reviewState: ReviewMetadataShape.reviewState
} as const
export const MobileWebSourceControlReviewMetadataUpdatePayloadSchema = z
.object({
workspaceId: MobileWebWorkspaceIdSchema,
expectedRevision: RevisionSchema,
comments: ReviewMetadataShape.comments,
reviewState: ReviewMetadataShape.reviewState
...MobileWebSourceControlReviewMetadataUpdateShape
})
.strict()
.superRefine(rejectDuplicateReviewMetadataKeys)
@@ -130,32 +135,41 @@ export const MobileWebSourceControlReviewCompareSchema = z
})
.strict()
export const MobileWebSourceControlReviewDiffShape = {
relativePath: MobileWebRelativePathSchema,
oldRelativePath: MobileWebRelativePathSchema.optional(),
scope: ReviewScopeSchema,
compare: MobileWebSourceControlReviewCompareSchema.optional(),
offset: z.number().int().min(0).max(MOBILE_WEB_DIFF_MAX_ROWS).default(0),
limit: z
.number()
.int()
.min(1)
.max(MOBILE_WEB_DIFF_PAGE_LIMIT)
.default(MOBILE_WEB_DIFF_PAGE_LIMIT),
expectedRevision: RevisionSchema.optional()
} as const
export function rejectMissingReviewCompareIdentity(
value: { scope: string; compare?: unknown },
context: z.RefinementCtx
): void {
if ((value.scope === 'branch') !== Boolean(value.compare)) {
context.addIssue({
code: 'custom',
path: ['compare'],
message: 'Branch diffs require exact compare identity'
})
}
}
export const MobileWebSourceControlReviewDiffPayloadSchema = z
.object({
workspaceId: MobileWebWorkspaceIdSchema,
relativePath: MobileWebRelativePathSchema,
oldRelativePath: MobileWebRelativePathSchema.optional(),
scope: ReviewScopeSchema,
compare: MobileWebSourceControlReviewCompareSchema.optional(),
offset: z.number().int().min(0).max(MOBILE_WEB_DIFF_MAX_ROWS).default(0),
limit: z
.number()
.int()
.min(1)
.max(MOBILE_WEB_DIFF_PAGE_LIMIT)
.default(MOBILE_WEB_DIFF_PAGE_LIMIT),
expectedRevision: RevisionSchema.optional()
...MobileWebSourceControlReviewDiffShape
})
.strict()
.superRefine((value, context) => {
if ((value.scope === 'branch') !== Boolean(value.compare)) {
context.addIssue({
code: 'custom',
path: ['compare'],
message: 'Branch diffs require exact compare identity'
})
}
})
.superRefine(rejectMissingReviewCompareIdentity)
const ReviewDiffIdentityShape = {
workspaceId: MobileWebWorkspaceIdSchema,
@@ -248,7 +262,7 @@ export type MobileWebSourceControlReviewTerminalSendResult = z.infer<
typeof MobileWebSourceControlReviewTerminalSendResultSchema
>
function rejectDuplicateReviewMetadataKeys(
export function rejectDuplicateReviewMetadataKeys(
value: {
comments: { id: string }[]
reviewState: { files: { key: string }[] }
@@ -0,0 +1,212 @@
import { sha256 } from '../sha256'
import {
MOBILE_WEB_REVIEW_COMMENT_LIMIT,
MOBILE_WEB_REVIEW_FILE_STATE_LIMIT,
MobileWebSourceControlReviewCommentSchema,
MobileWebSourceControlReviewFileStateSchema,
MobileWebSourceControlReviewLinkResultSchema,
MobileWebSourceControlReviewMetadataResultSchema,
type MobileWebSourceControlReviewComment,
type MobileWebSourceControlReviewLinkResult,
type MobileWebSourceControlReviewMetadataResult,
type MobileWebSourceControlReviewState
} from './source-control-review-contract'
import { MobileWebBrokerError } from './bridge-operation-error'
export function projectMobileWebReviewMetadata(
worktree: unknown,
workspaceId: string
): MobileWebSourceControlReviewMetadataResult {
if (!isRecord(worktree)) {
throw new MobileWebBrokerError('host_error')
}
const rawComments = Array.isArray(worktree.diffComments) ? worktree.diffComments : []
const rawReview = isRecord(worktree.mobileDiffReview) ? worktree.mobileDiffReview : {}
const rawFiles = isRecord(rawReview.files) ? Object.values(rawReview.files) : []
if (
rawComments.length > MOBILE_WEB_REVIEW_COMMENT_LIMIT ||
rawFiles.length > MOBILE_WEB_REVIEW_FILE_STATE_LIMIT
) {
throw new MobileWebBrokerError('too_large')
}
const comments = rawComments.map(projectComment)
const reviewState: MobileWebSourceControlReviewState = {
version: 1,
...(safeTimestamp(rawReview.updatedAt) === undefined
? {}
: { updatedAt: safeTimestamp(rawReview.updatedAt) }),
...(safeTimestamp(rawReview.completedAt) === undefined
? {}
: { completedAt: safeTimestamp(rawReview.completedAt) }),
files: rawFiles.map(projectFileState)
}
return MobileWebSourceControlReviewMetadataResultSchema.parse({
workspaceId,
revision: mobileWebReviewMetadataRevision({ comments, reviewState }),
comments,
reviewState
})
}
export function mobileWebReviewMetadataRevision(value: unknown): string {
return Array.from(sha256(new TextEncoder().encode(JSON.stringify(value))), (byte) =>
byte.toString(16).padStart(2, '0')
).join('')
}
/** The only worktree fields a review write may touch. Everything else on the record stays out of
* reach of the page. */
export function mobileWebReviewMetadataWorktreeFields(args: {
worktreeId: string
comments: readonly MobileWebSourceControlReviewComment[]
reviewState: MobileWebSourceControlReviewState
}) {
return {
diffComments: args.comments.map((comment) => ({
id: comment.id,
worktreeId: args.worktreeId,
filePath: comment.relativePath,
...(comment.oldRelativePath ? { oldPath: comment.oldRelativePath } : {}),
...(comment.source ? { source: comment.source } : {}),
...(comment.selectedText === undefined ? {} : { selectedText: comment.selectedText }),
...(comment.startLine === undefined ? {} : { startLine: comment.startLine }),
lineNumber: comment.lineNumber,
body: comment.body,
createdAt: comment.createdAt,
...(comment.updatedAt === undefined ? {} : { updatedAt: comment.updatedAt }),
...(comment.sentAt === undefined ? {} : { sentAt: comment.sentAt }),
...(comment.scope ? { scope: comment.scope } : {}),
...(comment.diffIdentity ? { diffIdentity: comment.diffIdentity } : {}),
side: 'modified'
})),
mobileDiffReview: {
version: 1,
...(args.reviewState.updatedAt === undefined
? {}
: { updatedAt: args.reviewState.updatedAt }),
...(args.reviewState.completedAt === undefined
? {}
: { completedAt: args.reviewState.completedAt }),
files: Object.fromEntries(
args.reviewState.files.map((file) => [
file.key,
{
key: file.key,
filePath: file.relativePath,
...(file.oldRelativePath ? { oldPath: file.oldRelativePath } : {}),
scope: file.scope,
...(file.lastOpenedAt === undefined ? {} : { lastOpenedAt: file.lastOpenedAt }),
...(file.lastSeenDiffIdentity
? { lastSeenDiffIdentity: file.lastSeenDiffIdentity }
: {}),
...(file.reviewedAt === undefined ? {} : { reviewedAt: file.reviewedAt }),
...(file.reviewDiffIdentity ? { reviewDiffIdentity: file.reviewDiffIdentity } : {})
}
])
)
}
}
}
export function projectMobileWebReviewLink(
worktree: unknown,
workspaceId: string
): MobileWebSourceControlReviewLinkResult {
if (!isRecord(worktree)) {
throw new MobileWebBrokerError('host_error')
}
return MobileWebSourceControlReviewLinkResultSchema.parse({
workspaceId,
baseRef: boundedText(worktree.baseRef, 512),
linkedGitHubPR: positiveInteger(worktree.linkedPR),
linkedGitLabMR: positiveInteger(worktree.linkedGitLabMR),
linkedBitbucketPR: positiveInteger(worktree.linkedBitbucketPR),
linkedAzureDevOpsPR: positiveInteger(worktree.linkedAzureDevOpsPR),
linkedGiteaPR: positiveInteger(worktree.linkedGiteaPR)
})
}
export function mobileWebReviewLinkWorktreeField(
provider: string,
number: number | null
): Record<string, number | null> {
if (provider === 'github') {
return { linkedPR: number }
}
if (provider === 'gitlab') {
return { linkedGitLabMR: number }
}
if (provider === 'bitbucket') {
return { linkedBitbucketPR: number }
}
if (provider === 'azure-devops') {
return { linkedAzureDevOpsPR: number }
}
return { linkedGiteaPR: number }
}
function projectComment(value: unknown): MobileWebSourceControlReviewComment {
if (!isRecord(value)) {
throw new MobileWebBrokerError('host_error')
}
const parsed = MobileWebSourceControlReviewCommentSchema.safeParse({
id: value.id,
relativePath: value.filePath,
...(value.oldPath === undefined ? {} : { oldRelativePath: value.oldPath }),
...(value.source === undefined ? {} : { source: value.source }),
...(value.selectedText === undefined ? {} : { selectedText: value.selectedText }),
...(value.startLine === undefined ? {} : { startLine: value.startLine }),
lineNumber: value.lineNumber,
body: value.body,
createdAt: value.createdAt,
...(value.updatedAt === undefined ? {} : { updatedAt: value.updatedAt }),
...(value.sentAt === undefined ? {} : { sentAt: value.sentAt }),
...(value.scope === undefined ? {} : { scope: value.scope }),
...(value.diffIdentity === undefined ? {} : { diffIdentity: value.diffIdentity }),
side: 'modified'
})
if (!parsed.success) {
throw new MobileWebBrokerError('host_error')
}
return parsed.data
}
function projectFileState(value: unknown) {
if (!isRecord(value)) {
throw new MobileWebBrokerError('host_error')
}
const parsed = MobileWebSourceControlReviewFileStateSchema.safeParse({
key: value.key,
relativePath: value.filePath,
...(value.oldPath === undefined ? {} : { oldRelativePath: value.oldPath }),
scope: value.scope,
...(value.lastOpenedAt === undefined ? {} : { lastOpenedAt: value.lastOpenedAt }),
...(value.lastSeenDiffIdentity === undefined
? {}
: { lastSeenDiffIdentity: value.lastSeenDiffIdentity }),
...(value.reviewedAt === undefined ? {} : { reviewedAt: value.reviewedAt }),
...(value.reviewDiffIdentity === undefined
? {}
: { reviewDiffIdentity: value.reviewDiffIdentity })
})
if (!parsed.success) {
throw new MobileWebBrokerError('host_error')
}
return parsed.data
}
function safeTimestamp(value: unknown): number | undefined {
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : undefined
}
function positiveInteger(value: unknown): number | null {
return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? value : null
}
function boundedText(value: unknown, limit: number): string | null {
return typeof value === 'string' && value.length > 0 ? value.slice(0, limit) : null
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
@@ -2,8 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
MobileWebSourceControlCheckoutPayloadSchema,
MobileWebSourceControlPushPayloadSchema,
MobileWebSourceControlRepositoryStateSchema,
MobileWebSourceControlSyncResultSchema
MobileWebSourceControlRepositoryStateSchema
} from './source-control-sync-contract'
const HEAD = 'a'.repeat(40)
@@ -17,53 +16,33 @@ const upstream = {
}
describe('mobile web source-control sync contract', () => {
it('requires an explicit checkout confirmation and a non-option local ref', () => {
const identity = {
workspaceId: 'workspace-1',
expectedHead: HEAD,
expectedBranch: 'main'
}
it('requires a non-option local ref to check out', () => {
expect(
MobileWebSourceControlCheckoutPayloadSchema.safeParse({
...identity,
branch: 'feature/mobile',
confirmation: 'checkout-confirmed'
workspaceId: 'workspace-1',
branch: 'feature/mobile'
}).success
).toBe(true)
expect(
MobileWebSourceControlCheckoutPayloadSchema.safeParse({
...identity,
branch: '--force',
confirmation: 'checkout-confirmed'
}).success
).toBe(false)
expect(
MobileWebSourceControlCheckoutPayloadSchema.safeParse({
...identity,
branch: 'feature/mobile'
workspaceId: 'workspace-1',
branch: '--force'
}).success
).toBe(false)
})
it('requires an exact bounded upstream snapshot and push confirmation', () => {
it('carries only the push mode the Desktop reauthorizes', () => {
expect(
MobileWebSourceControlPushPayloadSchema.safeParse({
workspaceId: 'workspace-1',
expectedHead: HEAD,
expectedBranch: 'main',
expectedUpstream: upstream,
mode: 'push',
confirmation: 'push-confirmed'
mode: 'publish'
}).success
).toBe(true)
expect(
MobileWebSourceControlPushPayloadSchema.safeParse({
workspaceId: 'workspace-1',
expectedHead: HEAD,
expectedBranch: 'main',
expectedUpstream: { ...upstream, ahead: -1 },
mode: 'push',
confirmation: 'push-confirmed'
forceWithLease: true
}).success
).toBe(false)
})
@@ -88,7 +67,7 @@ describe('mobile web source-control sync contract', () => {
}
})
it('keeps repository and action results strict and request-identifiable', () => {
it('keeps the repository state strict', () => {
const repository = {
workspaceId: 'workspace-1',
head: HEAD,
@@ -104,26 +83,5 @@ describe('mobile web source-control sync contract', () => {
hostPath: '/private/repository'
}).success
).toBe(false)
expect(
MobileWebSourceControlSyncResultSchema.safeParse({
workspaceId: 'workspace-1',
operation: 'push',
previousHead: HEAD,
previousBranch: 'main',
repository,
completed: true
}).success
).toBe(true)
expect(
MobileWebSourceControlSyncResultSchema.safeParse({
workspaceId: 'workspace-1',
operation: 'push',
previousHead: HEAD,
previousBranch: 'main',
branch: 'feature/mobile',
repository,
completed: true
}).success
).toBe(false)
})
})
@@ -43,110 +43,63 @@ export const MobileWebSourceControlRepositoryStateSchema = z
})
.strict()
export const MobileWebSourceControlUpstreamPayloadSchema = z
export const MobileWebSourceControlRepositoryStatePayloadSchema = z
.object({ workspaceId: MobileWebWorkspaceIdSchema })
.strict()
const ExpectedRepositoryShape = {
workspaceId: MobileWebWorkspaceIdSchema,
expectedHead: NullableGitObjectIdSchema,
expectedBranch: NullableGitRefNameSchema
} as const
const ExpectedRemoteRepositoryShape = {
...ExpectedRepositoryShape,
expectedUpstream: MobileWebSourceControlUpstreamSnapshotSchema
} as const
export const MobileWebSourceControlCheckoutPayloadSchema = z
.object({
...ExpectedRepositoryShape,
branch: MobileWebGitRefNameSchema,
confirmation: z.literal('checkout-confirmed')
workspaceId: MobileWebWorkspaceIdSchema,
branch: MobileWebGitRefNameSchema
})
.strict()
export const MobileWebSourceControlFetchPayloadSchema = z.object(ExpectedRepositoryShape).strict()
export const MobileWebSourceControlSyncPayloadSchema = z
.object({ workspaceId: MobileWebWorkspaceIdSchema })
.strict()
export const MobileWebSourceControlPullPayloadSchema = z
.object({
...ExpectedRemoteRepositoryShape,
strategy: z.enum(['fast-forward', 'merge']),
confirmation: z.literal('pull-confirmed')
workspaceId: MobileWebWorkspaceIdSchema,
strategy: z.enum(['fast-forward', 'merge'])
})
.strict()
export const MobileWebSourceControlPushPayloadSchema = z
.object({
...ExpectedRemoteRepositoryShape,
mode: z.enum(['push', 'publish']),
confirmation: z.literal('push-confirmed')
workspaceId: MobileWebWorkspaceIdSchema,
mode: z.enum(['push', 'publish'])
})
.strict()
export const MobileWebSourceControlRebasePayloadSchema = z
.object({
...ExpectedRemoteRepositoryShape,
baseRef: MobileWebGitRefNameSchema,
confirmation: z.literal('rebase-confirmed')
workspaceId: MobileWebWorkspaceIdSchema,
baseRef: MobileWebGitRefNameSchema
})
.strict()
export const MobileWebSourceControlAbortPayloadSchema = z
.object({
...ExpectedRepositoryShape,
conflictOperation: z.enum(['merge', 'rebase']),
confirmation: z.literal('abort-confirmed')
workspaceId: MobileWebWorkspaceIdSchema,
conflictOperation: z.enum(['merge', 'rebase'])
})
.strict()
export const MobileWebSourceControlSyncOperationSchema = z.enum([
'branch',
'fetch',
'pull',
'push',
'rebase',
'abort'
])
const MobileWebSourceControlSyncResultShape = {
workspaceId: MobileWebWorkspaceIdSchema,
previousHead: NullableGitObjectIdSchema,
previousBranch: NullableGitRefNameSchema,
repository: MobileWebSourceControlRepositoryStateSchema.nullable(),
completed: z.literal(true)
} as const
export const MobileWebSourceControlSyncResultSchema = z.discriminatedUnion('operation', [
z
.object({
...MobileWebSourceControlSyncResultShape,
operation: z.literal('branch'),
branch: MobileWebGitRefNameSchema
})
.strict(),
z
.object({
...MobileWebSourceControlSyncResultShape,
operation: z.enum(['fetch', 'pull', 'push', 'rebase', 'abort'])
})
.strict()
])
export type MobileWebSourceControlUpstreamSnapshot = z.infer<
typeof MobileWebSourceControlUpstreamSnapshotSchema
>
export type MobileWebSourceControlRepositoryState = z.infer<
typeof MobileWebSourceControlRepositoryStateSchema
>
export type MobileWebSourceControlUpstreamPayload = z.infer<
typeof MobileWebSourceControlUpstreamPayloadSchema
export type MobileWebSourceControlRepositoryStatePayload = z.infer<
typeof MobileWebSourceControlRepositoryStatePayloadSchema
>
export type MobileWebSourceControlCheckoutPayload = z.infer<
typeof MobileWebSourceControlCheckoutPayloadSchema
>
export type MobileWebSourceControlFetchPayload = z.infer<
typeof MobileWebSourceControlFetchPayloadSchema
export type MobileWebSourceControlSyncPayload = z.infer<
typeof MobileWebSourceControlSyncPayloadSchema
>
export type MobileWebSourceControlPullPayload = z.infer<
typeof MobileWebSourceControlPullPayloadSchema
@@ -160,9 +113,3 @@ export type MobileWebSourceControlRebasePayload = z.infer<
export type MobileWebSourceControlAbortPayload = z.infer<
typeof MobileWebSourceControlAbortPayloadSchema
>
export type MobileWebSourceControlSyncOperation = z.infer<
typeof MobileWebSourceControlSyncOperationSchema
>
export type MobileWebSourceControlSyncResult = z.infer<
typeof MobileWebSourceControlSyncResultSchema
>