diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx
index ff45db42fa9..ed6b9509a9b 100644
--- a/mobile/app/h/[hostId]/index.tsx
+++ b/mobile/app/h/[hostId]/index.tsx
@@ -15,6 +15,7 @@ import {
X,
Pin,
Bell,
+ GitBranch,
GitPullRequest,
SlidersHorizontal,
Layers,
@@ -1070,6 +1071,20 @@ export default function HostScreen() {
actions={
actionTarget
? [
+ {
+ label: 'Source Control',
+ icon: GitBranch,
+ onPress: () => {
+ const params = new URLSearchParams({
+ name: actionTarget.displayName || actionTarget.repo,
+ origin: 'host'
+ })
+ router.push(
+ `/h/${hostId}/source-control/${encodeURIComponent(actionTarget.worktreeId)}?${params.toString()}`
+ )
+ setActionTarget(null)
+ }
+ },
{
label: 'Sleep',
icon: Moon,
diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx
index 49b4fc1a337..d92199758e8 100644
--- a/mobile/app/h/[hostId]/session/[worktreeId].tsx
+++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx
@@ -24,6 +24,7 @@ import {
Folder,
File,
FileText,
+ GitBranch,
Mic,
Monitor,
Plus,
@@ -2372,6 +2373,19 @@ export default function SessionScreen() {
+ [styles.filesButton, pressed && styles.filesButtonPressed]}
+ onPress={() =>
+ router.push({
+ pathname: '/h/[hostId]/source-control/[worktreeId]',
+ params: { hostId, worktreeId, name: worktreeName || '', origin: 'session' }
+ })
+ }
+ hitSlop={8}
+ accessibilityLabel="Open source control"
+ >
+
+
[styles.filesButton, pressed && styles.filesButtonPressed]}
onPress={() =>
@@ -3197,7 +3211,9 @@ const styles = StyleSheet.create({
alignItems: 'center'
},
toastText: {
- backgroundColor: 'rgba(20, 22, 39, 0.92)',
+ backgroundColor: colors.bgRaised,
+ borderWidth: StyleSheet.hairlineWidth,
+ borderColor: colors.borderSubtle,
color: colors.textPrimary,
fontSize: 13,
paddingHorizontal: spacing.lg,
diff --git a/mobile/app/h/[hostId]/source-control/[worktreeId].tsx b/mobile/app/h/[hostId]/source-control/[worktreeId].tsx
new file mode 100644
index 00000000000..7186bc64f9d
--- /dev/null
+++ b/mobile/app/h/[hostId]/source-control/[worktreeId].tsx
@@ -0,0 +1,1443 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import {
+ ActivityIndicator,
+ Keyboard,
+ Platform,
+ Pressable,
+ SectionList,
+ StyleSheet,
+ Text,
+ TextInput,
+ View,
+ type SectionListRenderItem
+} from 'react-native'
+import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
+import { useLocalSearchParams, useRouter } from 'expo-router'
+import {
+ ChevronLeft,
+ ArrowDown,
+ ArrowDownUp,
+ ArrowUp,
+ Check,
+ CloudUpload,
+ FileText,
+ GitBranch,
+ GitPullRequest,
+ Minus,
+ MoreHorizontal,
+ Plus,
+ RefreshCw,
+ Trash2
+} from 'lucide-react-native'
+import { useHostClient } from '../../../../src/transport/client-context'
+import type { RpcSuccess } from '../../../../src/transport/types'
+import {
+ ActionSheetModal,
+ type ActionSheetAction
+} from '../../../../src/components/ActionSheetModal'
+import { ConfirmModal } from '../../../../src/components/ConfirmModal'
+import { triggerError, triggerSelection, triggerSuccess } from '../../../../src/platform/haptics'
+import { colors, radii, spacing, typography } from '../../../../src/theme/mobile-theme'
+import {
+ MOBILE_GIT_STATUS_LABELS,
+ buildMobileSourceControlSections,
+ countStagedEntries,
+ countUnstagedEntries,
+ getStageablePaths,
+ getUnstageablePaths,
+ isMobileGitDiscardableEntry,
+ isMobileGitStageableEntry,
+ isMobileGitUnavailable,
+ isMobileGitTransientRefreshError,
+ type MobileGitFileStatus,
+ type MobileGitStatusEntry,
+ type MobileGitStatusResult,
+ type MobileGitUpstreamStatus,
+ type MobileSourceControlSection
+} from '../../../../src/source-control/mobile-git-status'
+
+type ScreenState =
+ | { kind: 'loading' }
+ | { kind: 'ready'; status: MobileGitStatusResult }
+ | { kind: 'unavailable'; message: string }
+ | { kind: 'error'; message: string }
+
+type LoadStatusOptions = {
+ preserveReadyOnFailure?: boolean
+ clearActionErrorOnSuccess?: boolean
+ force?: boolean
+}
+
+type StatusLoadInFlight = {
+ key: string
+ client: unknown
+ promise: Promise
+}
+
+type GitRequestError = Error & { code?: string }
+type GitCommitResult = { success: boolean; error?: string }
+
+type MobileGitStatusEntryView = MobileGitStatusEntry & {
+ canDiscard: boolean
+ canOpen: boolean
+ canStage: boolean
+ discardActionId: string
+ stageActionId: string
+ unstageActionId: string
+}
+
+const KEYBOARD_COMMIT_BAR_CLEARANCE = 10
+const SELECTOR_RETRY_COUNT = 3
+const SELECTOR_RETRY_DELAY_MS = 250
+
+function firstParam(value: string | string[] | undefined): string {
+ return Array.isArray(value) ? (value[0] ?? '') : (value ?? '')
+}
+
+function wait(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms))
+}
+
+function getWorktreeLabel(name: string | undefined, worktreeId: string): string {
+ if (name?.trim()) {
+ return name.trim()
+ }
+ const pathPart = worktreeId.includes('::')
+ ? worktreeId.slice(worktreeId.indexOf('::') + 2)
+ : worktreeId
+ const normalized = pathPart.replace(/\\/g, '/').replace(/\/+$/, '')
+ return normalized.slice(normalized.lastIndexOf('/') + 1) || 'Worktree'
+}
+
+function formatBranchLabel(branch: string | undefined, head: string | undefined): string {
+ if (branch?.startsWith('refs/heads/')) {
+ return branch.slice('refs/heads/'.length)
+ }
+ return branch || head?.slice(0, 7) || 'No branch'
+}
+
+function statusColor(status: MobileGitFileStatus): string {
+ switch (status) {
+ case 'added':
+ case 'copied':
+ return colors.statusGreen
+ case 'deleted':
+ return colors.statusRed
+ case 'renamed':
+ return colors.accentBlue
+ case 'untracked':
+ return colors.statusAmber
+ case 'modified':
+ default:
+ return colors.textSecondary
+ }
+}
+
+export default function MobileSourceControlScreen() {
+ const params = useLocalSearchParams<{
+ hostId?: string | string[]
+ worktreeId?: string | string[]
+ name?: string | string[]
+ origin?: string | string[]
+ }>()
+ const hostId = firstParam(params.hostId)
+ const worktreeId = firstParam(params.worktreeId)
+ const name = firstParam(params.name)
+ const origin = firstParam(params.origin)
+ const router = useRouter()
+ const insets = useSafeAreaInsets()
+ const { client, state: connState } = useHostClient(hostId)
+ const [screenState, setScreenState] = useState({ kind: 'loading' })
+ const [busyAction, setBusyAction] = useState(null)
+ const [commitMessage, setCommitMessage] = useState('')
+ const [discardTarget, setDiscardTarget] = useState(null)
+ const [showActionSheet, setShowActionSheet] = useState(false)
+ const [actionError, setActionError] = useState(null)
+ const [keyboardLift, setKeyboardLift] = useState(0)
+ const [openingPath, setOpeningPath] = useState(null)
+ const busyActionRef = useRef(null)
+ const currentStatusIdentityRef = useRef('')
+ const loadGenerationRef = useRef(0)
+ const mountedRef = useRef(true)
+ const openingPathRef = useRef(null)
+ const statusLoadInFlightRef = useRef(null)
+ const worktreeLabel = getWorktreeLabel(name, worktreeId)
+ const statusIdentityKey = `${hostId}\0${worktreeId}`
+ currentStatusIdentityRef.current = statusIdentityKey
+
+ useEffect(() => {
+ return () => {
+ mountedRef.current = false
+ loadGenerationRef.current += 1
+ }
+ }, [])
+
+ useEffect(() => {
+ const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'
+ const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide'
+
+ const onShow = Keyboard.addListener(showEvent, (event) => {
+ const height = event.endCoordinates.height - (Platform.OS === 'ios' ? insets.bottom : 0)
+ setKeyboardLift(Math.max(0, height))
+ })
+ const onHide = Keyboard.addListener(hideEvent, () => setKeyboardLift(0))
+
+ return () => {
+ onShow.remove()
+ onHide.remove()
+ }
+ }, [insets.bottom])
+
+ const loadStatus = useCallback(
+ async (options?: LoadStatusOptions) => {
+ const loadKey = statusIdentityKey
+ const inFlight = statusLoadInFlightRef.current
+ if (inFlight && !options?.force && inFlight.key === loadKey && inFlight.client === client) {
+ return await inFlight.promise
+ }
+
+ const loadPromise = (async () => {
+ const generation = loadGenerationRef.current + 1
+ loadGenerationRef.current = generation
+ const isCurrentLoad = () =>
+ mountedRef.current &&
+ loadGenerationRef.current === generation &&
+ currentStatusIdentityRef.current === loadKey
+ if (!worktreeId) {
+ if (isCurrentLoad()) {
+ setScreenState({ kind: 'loading' })
+ }
+ return false
+ }
+ if (!client || connState !== 'connected') {
+ if (isCurrentLoad()) {
+ setScreenState({
+ kind: 'error',
+ message:
+ connState === 'connected' ? 'Connecting to desktop...' : 'Waiting for desktop...'
+ })
+ }
+ return false
+ }
+ if (!isCurrentLoad()) return false
+ 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 })
+ if (options?.clearActionErrorOnSuccess !== false) {
+ setActionError(null)
+ }
+ 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')
+ }
+ } catch (err) {
+ if (!isCurrentLoad()) return false
+ const message = err instanceof Error ? err.message : 'Unable to load source control'
+ setScreenState((prev) => {
+ // Why: git mutations can succeed while the immediate status refresh
+ // races a desktop abort; keep the last good screen instead of flashing
+ // a full-screen error that Retry fixes a moment later.
+ if (options?.preserveReadyOnFailure && prev.kind === 'ready') {
+ return prev
+ }
+ return { kind: 'error', message }
+ })
+ return false
+ }
+ return false
+ })()
+
+ statusLoadInFlightRef.current = { key: loadKey, client, promise: loadPromise }
+ try {
+ return await loadPromise
+ } finally {
+ if (statusLoadInFlightRef.current?.promise === loadPromise) {
+ statusLoadInFlightRef.current = null
+ }
+ }
+ },
+ [client, connState, statusIdentityKey, worktreeId]
+ )
+
+ useEffect(() => {
+ void loadStatus()
+ }, [loadStatus])
+
+ const status = screenState.kind === 'ready' ? screenState.status : null
+ const entries = status?.entries ?? []
+ const derivedEntries = useMemo(
+ () =>
+ entries.map((entry) => ({
+ ...entry,
+ canDiscard: isMobileGitDiscardableEntry(entry),
+ canOpen: entry.status !== 'deleted' && entry.conflictStatus !== 'unresolved',
+ canStage: isMobileGitStageableEntry(entry),
+ discardActionId: `discard:${entry.path}`,
+ stageActionId: `stage:${entry.path}`,
+ unstageActionId: `unstage:${entry.path}`
+ })),
+ [entries]
+ )
+ const sections = useMemo(() => buildMobileSourceControlSections(derivedEntries), [derivedEntries])
+ const stageablePaths = useMemo(() => getStageablePaths(entries), [entries])
+ const unstageablePaths = useMemo(() => getUnstageablePaths(entries), [entries])
+ const stagedCount = useMemo(() => countStagedEntries(entries), [entries])
+ const unstagedCount = useMemo(() => countUnstagedEntries(entries), [entries])
+ const branchLabel = formatBranchLabel(status?.branch, status?.head)
+ const upstream = status?.upstreamStatus
+ const upstreamKnown = upstream !== undefined
+ const syncLabel =
+ upstream && upstream.hasUpstream
+ ? `${upstream.ahead} ahead, ${upstream.behind} behind`
+ : upstream && !upstream.hasUpstream
+ ? 'No upstream'
+ : null
+
+ const sendGitRequest = useCallback(
+ async (method: string, params?: Record): Promise => {
+ if (!client || connState !== 'connected') {
+ throw new Error('Waiting for desktop...')
+ }
+ const response = await client.sendRequest(method, {
+ worktree: `id:${worktreeId}`,
+ ...params
+ })
+ if (!response.ok) {
+ const error = new Error(
+ response.error?.message || 'Source control action failed'
+ ) as GitRequestError
+ error.code = response.error?.code
+ throw error
+ }
+ return (response as RpcSuccess).result as T
+ },
+ [client, connState, worktreeId]
+ )
+
+ const sendCommitRequest = useCallback(
+ async (message: string): Promise => {
+ const result = await sendGitRequest('git.commit', { message })
+ if (!result || result.success !== true) {
+ throw new Error(result?.error || 'Commit failed')
+ }
+ return result
+ },
+ [sendGitRequest]
+ )
+
+ const readUpstreamStatusForSync = useCallback(async (): Promise => {
+ try {
+ return await sendGitRequest('git.upstreamStatus')
+ } catch (err) {
+ const code = err instanceof Error ? (err as GitRequestError).code : undefined
+ const message = err instanceof Error ? err.message : String(err)
+ if (!isMobileGitUnavailable(code, message)) {
+ throw err
+ }
+ const status = await sendGitRequest('git.status')
+ if (!status.upstreamStatus) {
+ throw new Error('Branch status unavailable')
+ }
+ return status.upstreamStatus
+ }
+ }, [sendGitRequest])
+
+ const runGitSyncSteps = useCallback(async () => {
+ await sendGitRequest('git.fetch')
+ await sendGitRequest('git.pull')
+ const nextUpstream = await readUpstreamStatusForSync()
+ if (nextUpstream.ahead > 0) {
+ await sendGitRequest('git.push')
+ }
+ }, [readUpstreamStatusForSync, sendGitRequest])
+
+ const runGitWorkflow = useCallback(
+ async (
+ actionId: string,
+ runner: () => Promise,
+ options?: { clearCommitMessage?: boolean }
+ ) => {
+ if (busyActionRef.current) return false
+ busyActionRef.current = actionId
+ setBusyAction(actionId)
+ setActionError(null)
+ try {
+ await runner()
+ if (!mountedRef.current) return false
+ if (options?.clearCommitMessage) {
+ setCommitMessage('')
+ }
+ triggerSuccess()
+ await loadStatus({ preserveReadyOnFailure: true, force: true })
+ return true
+ } catch (err) {
+ if (!mountedRef.current) return false
+ triggerError()
+ setActionError(err instanceof Error ? err.message : 'Source control action failed')
+ return false
+ } finally {
+ if (busyActionRef.current === actionId) {
+ busyActionRef.current = null
+ if (mountedRef.current) {
+ setBusyAction(null)
+ }
+ }
+ }
+ },
+ [loadStatus]
+ )
+
+ const runGitAction = useCallback(
+ async (actionId: string, method: string, params: Record) => {
+ return await runGitWorkflow(actionId, async () => {
+ await sendGitRequest(method, params)
+ })
+ },
+ [runGitWorkflow, sendGitRequest]
+ )
+
+ const runGitSequence = useCallback(
+ async (
+ actionId: string,
+ steps: Array<{ method: string; params?: Record }>,
+ options?: { clearCommitMessage?: boolean }
+ ) => {
+ return await runGitWorkflow(
+ actionId,
+ async () => {
+ for (const step of steps) {
+ await sendGitRequest(step.method, step.params)
+ }
+ },
+ options
+ )
+ },
+ [runGitWorkflow, sendGitRequest]
+ )
+
+ const runGitSync = useCallback(
+ async (actionId: string) => await runGitWorkflow(actionId, runGitSyncSteps),
+ [runGitSyncSteps, runGitWorkflow]
+ )
+
+ const stageAll = useCallback(async () => {
+ const filePaths = stageablePaths
+ if (filePaths.length === 0) return
+ await runGitAction('stage-all', 'git.bulkStage', { filePaths })
+ }, [runGitAction, stageablePaths])
+
+ const unstageAll = useCallback(async () => {
+ const filePaths = unstageablePaths
+ if (filePaths.length === 0) return
+ await runGitAction('unstage-all', 'git.bulkUnstage', { filePaths })
+ }, [runGitAction, unstageablePaths])
+
+ const commit = useCallback(async () => {
+ const message = commitMessage.trim()
+ if (!message) return false
+ return await runGitWorkflow(
+ 'commit',
+ async () => {
+ await sendCommitRequest(message)
+ },
+ { clearCommitMessage: true }
+ )
+ }, [commitMessage, runGitWorkflow, sendCommitRequest])
+
+ const runCommitFollowUps = useCallback(
+ async (actionId: string, afterCommit: () => Promise) => {
+ const message = commitMessage.trim()
+ if (!message) return false
+ if (busyActionRef.current) return false
+ busyActionRef.current = actionId
+ setBusyAction(actionId)
+ setActionError(null)
+ let didCommit = false
+ try {
+ await sendCommitRequest(message)
+ didCommit = true
+ await afterCommit()
+ if (!mountedRef.current) return false
+ setCommitMessage('')
+ triggerSuccess()
+ await loadStatus({ preserveReadyOnFailure: true, force: true })
+ return true
+ } catch (err) {
+ if (!mountedRef.current) return false
+ triggerError()
+ const message = err instanceof Error ? err.message : 'Source control action failed'
+ if (didCommit) {
+ setCommitMessage('')
+ await loadStatus({
+ preserveReadyOnFailure: true,
+ clearActionErrorOnSuccess: false,
+ force: true
+ })
+ }
+ setActionError(message)
+ return false
+ } finally {
+ if (busyActionRef.current === actionId) {
+ busyActionRef.current = null
+ if (mountedRef.current) {
+ setBusyAction(null)
+ }
+ }
+ }
+ },
+ [commitMessage, loadStatus, sendCommitRequest]
+ )
+
+ const runCommitSequence = useCallback(
+ async (
+ actionId: string,
+ afterCommit: Array<{ method: string; params?: Record }>
+ ) => {
+ return await runCommitFollowUps(actionId, async () => {
+ for (const step of afterCommit) {
+ await sendGitRequest(step.method, step.params)
+ }
+ })
+ },
+ [runCommitFollowUps, sendGitRequest]
+ )
+
+ const runCommitSyncSequence = useCallback(async () => {
+ return await runCommitFollowUps('commit-sync', runGitSyncSteps)
+ }, [runCommitFollowUps, runGitSyncSteps])
+
+ const runActionSheetCommit = useCallback(async () => {
+ await commit()
+ setShowActionSheet(false)
+ }, [commit])
+
+ const runActionSheetCommitSequence = useCallback(
+ async (
+ actionId: string,
+ afterCommit: Array<{ method: string; params?: Record }>
+ ) => {
+ await runCommitSequence(actionId, afterCommit)
+ setShowActionSheet(false)
+ },
+ [runCommitSequence]
+ )
+
+ const runActionSheetCommitSync = useCallback(async () => {
+ await runCommitSyncSequence()
+ setShowActionSheet(false)
+ }, [runCommitSyncSequence])
+
+ const runActionSheetGitSequence = useCallback(
+ async (
+ actionId: string,
+ steps: Array<{ method: string; params?: Record }>
+ ) => {
+ await runGitSequence(actionId, steps)
+ setShowActionSheet(false)
+ },
+ [runGitSequence]
+ )
+
+ const runActionSheetGitSync = useCallback(async () => {
+ await runGitSync('sync')
+ setShowActionSheet(false)
+ }, [runGitSync])
+
+ const openFile = useCallback(
+ async (entry: MobileGitStatusEntry) => {
+ if (entry.status === 'deleted' || entry.conflictStatus === 'unresolved') return
+ if (openingPathRef.current || busyActionRef.current) return
+ if (!client || connState !== 'connected') {
+ if (!mountedRef.current) return
+ setActionError('Waiting for desktop...')
+ return
+ }
+ openingPathRef.current = entry.path
+ setOpeningPath(entry.path)
+ try {
+ setActionError(null)
+ const response = await client.sendRequest('files.open', {
+ worktree: `id:${worktreeId}`,
+ relativePath: entry.path
+ })
+ if (!response.ok) {
+ throw new Error(response.error?.message || 'Unable to open file')
+ }
+ if (!mountedRef.current) return
+ triggerSelection()
+ if (origin === 'session') {
+ router.back()
+ return
+ }
+ const params = new URLSearchParams()
+ if (name) {
+ params.set('name', name)
+ }
+ const query = params.toString()
+ router.replace(
+ `/h/${encodeURIComponent(hostId)}/session/${encodeURIComponent(worktreeId)}${query ? `?${query}` : ''}`
+ )
+ } catch (err) {
+ if (!mountedRef.current) return
+ triggerError()
+ setActionError(err instanceof Error ? err.message : 'Unable to open file')
+ } finally {
+ if (openingPathRef.current === entry.path) {
+ openingPathRef.current = null
+ if (mountedRef.current) {
+ setOpeningPath(null)
+ }
+ }
+ }
+ },
+ [client, connState, hostId, name, origin, router, worktreeId]
+ )
+
+ const actionSheetActions = useMemo(() => {
+ const hasMessage = commitMessage.trim().length > 0
+ const hasStaged = stagedCount > 0
+ const hasUpstream = upstream?.hasUpstream === true
+ const ahead = upstream?.ahead ?? 0
+ const behind = upstream?.behind ?? 0
+ const busy = busyAction !== null
+ const commitHint = !hasStaged
+ ? 'Stage at least one file'
+ : !hasMessage
+ ? 'Enter a commit message'
+ : undefined
+ const remoteHint = !upstreamKnown
+ ? 'Checking branch status...'
+ : hasUpstream
+ ? undefined
+ : 'Publish Branch first'
+ const createPrHint = 'Pull requests are not available on mobile yet'
+
+ return [
+ {
+ label: 'Commit',
+ icon: Check,
+ disabled: busy || !!commitHint,
+ hint: commitHint,
+ loading: busyAction === 'commit',
+ skipAutoClose: true,
+ onPress: () => void runActionSheetCommit()
+ },
+ {
+ label: 'Commit & Push',
+ icon: ArrowUp,
+ disabled: busy || !!commitHint || !upstreamKnown || !hasUpstream,
+ hint: commitHint ?? remoteHint,
+ loading: busyAction === 'commit-push',
+ skipAutoClose: true,
+ onPress: () => void runActionSheetCommitSequence('commit-push', [{ method: 'git.push' }])
+ },
+ {
+ label: 'Commit & Sync',
+ icon: ArrowDownUp,
+ disabled: busy || !!commitHint || !upstreamKnown || !hasUpstream || behind === 0,
+ hint:
+ commitHint ??
+ (!upstreamKnown || !hasUpstream
+ ? remoteHint
+ : behind === 0
+ ? 'Nothing to pull'
+ : undefined),
+ loading: busyAction === 'commit-sync',
+ skipAutoClose: true,
+ onPress: () => void runActionSheetCommitSync()
+ },
+ {
+ label: ahead > 0 ? `Push (${ahead})` : 'Push',
+ icon: ArrowUp,
+ disabled: busy || !upstreamKnown || !hasUpstream || ahead === 0,
+ hint: !hasUpstream ? remoteHint : ahead === 0 ? 'Nothing to push' : undefined,
+ loading: busyAction === 'push',
+ skipAutoClose: true,
+ onPress: () => void runActionSheetGitSequence('push', [{ method: 'git.push' }])
+ },
+ {
+ label: 'Create PR',
+ icon: GitPullRequest,
+ disabled: true,
+ hint: createPrHint,
+ onPress: () => {}
+ },
+ {
+ label: 'Push & Create PR',
+ icon: GitPullRequest,
+ disabled: true,
+ hint: createPrHint,
+ onPress: () => {}
+ },
+ {
+ label: behind > 0 ? `Pull (${behind})` : 'Pull',
+ icon: ArrowDown,
+ disabled: busy || !upstreamKnown || !hasUpstream || behind === 0,
+ hint: !hasUpstream ? remoteHint : behind === 0 ? 'Nothing to pull' : undefined,
+ loading: busyAction === 'pull',
+ skipAutoClose: true,
+ onPress: () => void runActionSheetGitSequence('pull', [{ method: 'git.pull' }])
+ },
+ {
+ label: ahead > 0 || behind > 0 ? `Sync (↓${behind} ↑${ahead})` : 'Sync',
+ icon: ArrowDownUp,
+ disabled: busy || !upstreamKnown || !hasUpstream || (ahead === 0 && behind === 0),
+ hint:
+ !upstreamKnown || !hasUpstream
+ ? remoteHint
+ : ahead === 0 && behind === 0
+ ? 'Branch is up to date'
+ : undefined,
+ loading: busyAction === 'sync',
+ skipAutoClose: true,
+ onPress: () => void runActionSheetGitSync()
+ },
+ {
+ label: 'Fetch',
+ icon: RefreshCw,
+ disabled: busy,
+ loading: busyAction === 'fetch',
+ skipAutoClose: true,
+ onPress: () => void runActionSheetGitSequence('fetch', [{ method: 'git.fetch' }])
+ },
+ {
+ label: 'Publish Branch',
+ icon: CloudUpload,
+ disabled: busy || !upstreamKnown || hasUpstream,
+ hint: !upstreamKnown
+ ? 'Checking branch status...'
+ : hasUpstream
+ ? 'Branch is already published'
+ : undefined,
+ loading: busyAction === 'publish',
+ skipAutoClose: true,
+ onPress: () =>
+ void runActionSheetGitSequence('publish', [
+ { method: 'git.push', params: { publish: true } }
+ ])
+ }
+ ]
+ }, [
+ busyAction,
+ commitMessage,
+ runActionSheetCommit,
+ runActionSheetCommitSequence,
+ runActionSheetCommitSync,
+ runActionSheetGitSequence,
+ runActionSheetGitSync,
+ stagedCount,
+ upstream,
+ upstreamKnown
+ ])
+
+ const renderItem = useCallback<
+ SectionListRenderItem<
+ MobileGitStatusEntryView,
+ MobileSourceControlSection
+ >
+ >(
+ ({ item }) => {
+ const rowBusy =
+ busyAction === item.stageActionId ||
+ busyAction === item.unstageActionId ||
+ busyAction === item.discardActionId ||
+ openingPath === item.path
+ const rowDisabled = !item.canOpen || busyAction !== null || openingPath !== null
+ return (
+ [
+ styles.fileRow,
+ pressed && item.canOpen && styles.fileRowPressed,
+ rowDisabled && styles.fileRowDisabled,
+ !item.canOpen && styles.fileRowUnavailable
+ ]}
+ onPress={() => void openFile(item)}
+ disabled={rowDisabled}
+ accessibilityLabel={`Open changed file ${item.path}`}
+ >
+
+
+ {MOBILE_GIT_STATUS_LABELS[item.status]}
+
+
+
+
+
+ {item.path}
+
+ {item.oldPath ? (
+
+ from {item.oldPath}
+
+ ) : item.conflictStatus === 'unresolved' ? (
+
+ Unresolved conflict
+
+ ) : null}
+
+ {rowBusy ? (
+
+ ) : item.area === 'staged' ? (
+ [
+ styles.iconButton,
+ (busyAction !== null || openingPath !== null) && styles.iconButtonDisabled,
+ pressed && styles.iconButtonPressed
+ ]}
+ disabled={busyAction !== null || openingPath !== null}
+ onPress={() =>
+ void runGitAction(item.unstageActionId, 'git.unstage', { filePath: item.path })
+ }
+ hitSlop={8}
+ accessibilityLabel={`Unstage ${item.path}`}
+ >
+
+
+ ) : item.canStage || item.canDiscard ? (
+
+ {item.canStage ? (
+ [
+ styles.iconButton,
+ (busyAction !== null || openingPath !== null) && styles.iconButtonDisabled,
+ pressed && styles.iconButtonPressed
+ ]}
+ disabled={busyAction !== null || openingPath !== null}
+ onPress={() =>
+ void runGitAction(item.stageActionId, 'git.stage', { filePath: item.path })
+ }
+ hitSlop={8}
+ accessibilityLabel={`Stage ${item.path}`}
+ >
+
+
+ ) : null}
+ {item.canDiscard ? (
+ [
+ styles.iconButton,
+ (busyAction !== null || openingPath !== null) && styles.iconButtonDisabled,
+ pressed && styles.iconButtonPressed
+ ]}
+ disabled={busyAction !== null || openingPath !== null}
+ onPress={() => setDiscardTarget(item)}
+ hitSlop={8}
+ accessibilityLabel={`Discard ${item.path}`}
+ >
+
+
+ ) : null}
+
+ ) : null}
+
+ )
+ },
+ [busyAction, openFile, openingPath, runGitAction]
+ )
+
+ const keyExtractor = useCallback(
+ (item: MobileGitStatusEntryView) => `${item.area}:${item.path}:${item.oldPath ?? ''}`,
+ []
+ )
+
+ const renderSectionHeader = useCallback(
+ ({ section }: { section: MobileSourceControlSection }) => (
+
+ {section.title}
+ {section.data.length}
+
+ ),
+ []
+ )
+
+ return (
+
+
+
+ [styles.backButton, pressed && styles.backButtonPressed]}
+ onPress={() => router.back()}
+ hitSlop={8}
+ accessibilityLabel="Back to session"
+ >
+
+
+
+
+ Source Control
+
+
+ {worktreeLabel}
+
+
+ [
+ styles.refreshButton,
+ (busyAction !== null || openingPath !== null) && styles.refreshButtonDisabled,
+ pressed && styles.refreshButtonPressed
+ ]}
+ onPress={() => void loadStatus()}
+ disabled={busyAction !== null || openingPath !== null}
+ hitSlop={8}
+ accessibilityLabel="Refresh source control"
+ >
+
+
+
+
+
+ {screenState.kind === 'loading' ? (
+
+
+
+ ) : screenState.kind === 'error' || screenState.kind === 'unavailable' ? (
+
+
+ {screenState.kind === 'unavailable' ? 'Source Control Unavailable' : 'Unable to Load'}
+
+ {screenState.message}
+ {screenState.kind === 'error' ? (
+ void loadStatus()}>
+ Retry
+
+ ) : null}
+
+ ) : (
+ <>
+
+
+
+
+
+ {branchLabel}
+
+
+ {syncLabel ? {syncLabel} : null}
+
+
+ {unstagedCount} changed
+ {stagedCount} staged
+ {status && status.conflictOperation !== 'unknown' ? (
+ {status.conflictOperation}
+ ) : null}
+
+ {actionError ? (
+
+
+ {actionError}
+
+
+ ) : null}
+
+ [
+ styles.bulkButton,
+ (stageablePaths.length === 0 || busyAction !== null || openingPath !== null) &&
+ styles.bulkButtonDisabled,
+ pressed && styles.bulkButtonPressed
+ ]}
+ onPress={() => void stageAll()}
+ disabled={
+ busyAction !== null || openingPath !== null || stageablePaths.length === 0
+ }
+ >
+ {busyAction === 'stage-all' ? (
+
+ ) : (
+
+ )}
+ Stage All
+
+ [
+ styles.bulkButton,
+ (unstageablePaths.length === 0 || busyAction !== null || openingPath !== null) &&
+ styles.bulkButtonDisabled,
+ pressed && styles.bulkButtonPressed
+ ]}
+ onPress={() => void unstageAll()}
+ disabled={
+ busyAction !== null || openingPath !== null || unstageablePaths.length === 0
+ }
+ >
+ {busyAction === 'unstage-all' ? (
+
+ ) : (
+
+ )}
+ Unstage All
+
+ [
+ styles.bulkMenuButton,
+ pressed && styles.bulkButtonPressed,
+ (busyAction !== null || openingPath !== null) && styles.bulkButtonDisabled
+ ]}
+ onPress={() => setShowActionSheet(true)}
+ disabled={busyAction !== null || openingPath !== null}
+ hitSlop={8}
+ accessibilityLabel="Open source control actions"
+ >
+
+
+
+
+
+ {entries.length === 0 ? (
+
+ No Changes
+ Working tree is clean.
+
+ ) : (
+
+ )}
+
+ 0 ? keyboardLift + KEYBOARD_COMMIT_BAR_CLEARANCE : keyboardLift,
+ paddingBottom: keyboardLift > 0 ? spacing.md : spacing.md + insets.bottom
+ }
+ ]}
+ >
+
+ {stagedCount === 0 ? (
+
+ No staged files
+
+ ) : (
+ void commit()}
+ />
+ )}
+ [
+ styles.commitButton,
+ (!commitMessage.trim() ||
+ stagedCount === 0 ||
+ busyAction !== null ||
+ openingPath !== null) &&
+ styles.commitButtonDisabled,
+ pressed && styles.commitButtonPressed
+ ]}
+ onPress={() => void commit()}
+ disabled={
+ !commitMessage.trim() ||
+ stagedCount === 0 ||
+ busyAction !== null ||
+ openingPath !== null
+ }
+ >
+ {busyAction === 'commit' ? (
+
+ ) : (
+ Commit
+ )}
+
+
+
+ >
+ )}
+
+ setShowActionSheet(false)}
+ />
+
+ {
+ if (discardTarget) {
+ void runGitAction(`discard:${discardTarget.path}`, 'git.discard', {
+ filePath: discardTarget.path
+ })
+ }
+ }}
+ onCancel={() => setDiscardTarget(null)}
+ />
+
+ )
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: colors.bgBase
+ },
+ header: {
+ backgroundColor: colors.bgPanel,
+ borderBottomWidth: StyleSheet.hairlineWidth,
+ borderBottomColor: colors.borderSubtle
+ },
+ topBar: {
+ minHeight: 58,
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingHorizontal: spacing.sm
+ },
+ backButton: {
+ width: 36,
+ height: 36,
+ borderRadius: 18,
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginRight: spacing.xs
+ },
+ backButtonPressed: {
+ backgroundColor: colors.bgRaised
+ },
+ titleBlock: {
+ flex: 1,
+ minWidth: 0
+ },
+ title: {
+ color: colors.textPrimary,
+ fontSize: 16,
+ fontWeight: '700'
+ },
+ meta: {
+ color: colors.textSecondary,
+ fontSize: typography.metaSize,
+ marginTop: 2
+ },
+ refreshButton: {
+ width: 36,
+ height: 36,
+ borderRadius: radii.button,
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginLeft: spacing.xs
+ },
+ refreshButtonPressed: {
+ backgroundColor: colors.bgRaised
+ },
+ refreshButtonDisabled: {
+ opacity: 0.45
+ },
+ summaryCard: {
+ margin: spacing.lg,
+ marginBottom: spacing.sm,
+ padding: spacing.md,
+ borderRadius: radii.card,
+ backgroundColor: colors.bgPanel,
+ borderWidth: StyleSheet.hairlineWidth,
+ borderColor: colors.borderSubtle
+ },
+ summaryHeader: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ gap: spacing.md
+ },
+ branchLine: {
+ flex: 1,
+ minWidth: 0,
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.xs
+ },
+ branchText: {
+ flex: 1,
+ color: colors.textPrimary,
+ fontSize: typography.bodySize,
+ fontWeight: '600'
+ },
+ syncText: {
+ color: colors.textSecondary,
+ fontSize: typography.metaSize
+ },
+ countRow: {
+ flexDirection: 'row',
+ gap: spacing.md,
+ marginTop: spacing.sm
+ },
+ countText: {
+ color: colors.textSecondary,
+ fontSize: typography.metaSize
+ },
+ conflictText: {
+ color: colors.statusAmber,
+ fontSize: typography.metaSize,
+ textTransform: 'capitalize'
+ },
+ actionError: {
+ marginTop: spacing.sm,
+ paddingHorizontal: spacing.md,
+ paddingVertical: spacing.sm,
+ borderRadius: radii.button,
+ backgroundColor: colors.bgRaised,
+ borderWidth: StyleSheet.hairlineWidth,
+ borderColor: colors.statusRed
+ },
+ actionErrorText: {
+ color: colors.textPrimary,
+ fontSize: typography.metaSize,
+ lineHeight: 16
+ },
+ bulkRow: {
+ flexDirection: 'row',
+ gap: spacing.sm,
+ marginTop: spacing.md
+ },
+ bulkButton: {
+ flex: 1,
+ minHeight: 36,
+ borderRadius: radii.button,
+ backgroundColor: colors.bgRaised,
+ alignItems: 'center',
+ justifyContent: 'center',
+ flexDirection: 'row',
+ gap: spacing.xs
+ },
+ bulkMenuButton: {
+ width: 42,
+ minHeight: 36,
+ borderRadius: radii.button,
+ backgroundColor: colors.bgRaised,
+ alignItems: 'center',
+ justifyContent: 'center'
+ },
+ bulkButtonDisabled: {
+ opacity: 0.45
+ },
+ bulkButtonPressed: {
+ opacity: 0.75
+ },
+ bulkButtonText: {
+ color: colors.textPrimary,
+ fontSize: typography.bodySize,
+ fontWeight: '600'
+ },
+ listContent: {
+ paddingHorizontal: spacing.lg,
+ paddingBottom: 136
+ },
+ sectionHeader: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ paddingTop: spacing.md,
+ paddingBottom: spacing.xs
+ },
+ sectionTitle: {
+ color: colors.textSecondary,
+ fontSize: 11,
+ fontWeight: '700',
+ textTransform: 'uppercase'
+ },
+ sectionCount: {
+ color: colors.textMuted,
+ fontSize: typography.metaSize,
+ fontWeight: '600'
+ },
+ fileRow: {
+ minHeight: 50,
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.sm,
+ paddingVertical: spacing.sm,
+ borderBottomWidth: StyleSheet.hairlineWidth,
+ borderBottomColor: colors.borderSubtle
+ },
+ fileRowPressed: {
+ backgroundColor: colors.bgPanel
+ },
+ fileRowDisabled: {
+ opacity: 0.78
+ },
+ fileRowUnavailable: {
+ opacity: 0.72
+ },
+ statusBadge: {
+ width: 24,
+ alignItems: 'center'
+ },
+ statusBadgeText: {
+ fontFamily: typography.monoFamily,
+ fontSize: typography.metaSize,
+ fontWeight: '700'
+ },
+ fileTextBlock: {
+ flex: 1,
+ minWidth: 0
+ },
+ filePath: {
+ color: colors.textPrimary,
+ fontSize: typography.bodySize
+ },
+ filePathDisabled: {
+ color: colors.textSecondary
+ },
+ fileMeta: {
+ color: colors.textMuted,
+ fontSize: typography.metaSize,
+ marginTop: 2
+ },
+ rowActions: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.xs
+ },
+ iconButton: {
+ width: 32,
+ height: 32,
+ borderRadius: radii.button,
+ alignItems: 'center',
+ justifyContent: 'center'
+ },
+ iconButtonPressed: {
+ backgroundColor: colors.bgRaised
+ },
+ iconButtonDisabled: {
+ opacity: 0.45
+ },
+ commitBar: {
+ position: 'absolute',
+ left: 0,
+ right: 0,
+ gap: spacing.xs,
+ padding: spacing.lg,
+ paddingTop: spacing.md,
+ backgroundColor: colors.bgPanel,
+ borderTopWidth: StyleSheet.hairlineWidth,
+ borderTopColor: colors.borderSubtle
+ },
+ commitRow: {
+ flexDirection: 'row',
+ gap: spacing.sm
+ },
+ commitInput: {
+ flex: 1,
+ minHeight: 42,
+ borderRadius: radii.input,
+ borderWidth: StyleSheet.hairlineWidth,
+ borderColor: colors.borderSubtle,
+ backgroundColor: colors.bgBase,
+ color: colors.textPrimary,
+ paddingHorizontal: spacing.md,
+ fontSize: typography.bodySize
+ },
+ commitInputDisabled: {
+ backgroundColor: colors.bgPanel,
+ borderColor: colors.borderSubtle,
+ borderStyle: 'dashed',
+ alignItems: 'center',
+ justifyContent: 'center'
+ },
+ commitInputDisabledText: {
+ color: colors.textMuted,
+ fontSize: typography.bodySize,
+ fontWeight: '600'
+ },
+ commitButton: {
+ minWidth: 88,
+ minHeight: 42,
+ borderRadius: radii.button,
+ backgroundColor: colors.textPrimary,
+ alignItems: 'center',
+ justifyContent: 'center',
+ paddingHorizontal: spacing.md
+ },
+ commitButtonDisabled: {
+ opacity: 0.45
+ },
+ commitButtonPressed: {
+ opacity: 0.75
+ },
+ commitButtonText: {
+ color: colors.bgBase,
+ fontSize: typography.bodySize,
+ fontWeight: '700'
+ },
+ state: {
+ flex: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ padding: spacing.xl
+ },
+ stateTitle: {
+ color: colors.textPrimary,
+ fontSize: 16,
+ fontWeight: '700',
+ marginBottom: spacing.xs
+ },
+ stateText: {
+ color: colors.textSecondary,
+ fontSize: typography.bodySize,
+ lineHeight: 20,
+ textAlign: 'center'
+ },
+ retryButton: {
+ marginTop: spacing.md,
+ paddingHorizontal: spacing.lg,
+ paddingVertical: spacing.sm,
+ borderRadius: radii.button,
+ backgroundColor: colors.bgRaised
+ },
+ retryText: {
+ color: colors.textPrimary,
+ fontSize: typography.bodySize,
+ fontWeight: '600'
+ }
+})
diff --git a/mobile/app/h/_layout.tsx b/mobile/app/h/_layout.tsx
index 1bd3fec4185..c4071605cd2 100644
--- a/mobile/app/h/_layout.tsx
+++ b/mobile/app/h/_layout.tsx
@@ -12,6 +12,10 @@ export default function HostGroupLayout() {
+
)
}
diff --git a/mobile/scripts/mock-server.ts b/mobile/scripts/mock-server.ts
index 14c7ab2c3db..64660ff4dc1 100644
--- a/mobile/scripts/mock-server.ts
+++ b/mobile/scripts/mock-server.ts
@@ -4,6 +4,7 @@
// runtime exposes, with realistic fake data. Supports E2EE handshake.
import { WebSocketServer, type WebSocket } from 'ws'
import nacl from 'tweetnacl'
+import type { MobileGitStatusEntry } from '../src/source-control/mobile-git-status'
const PORT = Number(process.env.PORT) || 6768
const AUTH_TOKEN = 'mock-device-token'
@@ -111,6 +112,19 @@ const STREAMING_CHUNKS = [
'\nUpdating src/auth/middleware.ts...\n'
]
+type FakeGitEntry = MobileGitStatusEntry & {
+ stagedFromUntracked?: boolean
+}
+
+let fakeGitEntries: FakeGitEntry[] = [
+ { path: 'src/auth/middleware.ts', status: 'modified', area: 'unstaged' },
+ { path: 'src/auth/jwt.ts', status: 'untracked', area: 'untracked' },
+ { path: 'README.md', status: 'modified', area: 'staged' }
+]
+let fakeAhead = 1
+let fakeBehind = 0
+let fakeHasUpstream = true
+
type RpcRequest = {
id: string
method: string
@@ -127,6 +141,27 @@ type RpcResponse = {
_meta: { runtimeId: string }
}
+function toGitStatusEntry(entry: FakeGitEntry): MobileGitStatusEntry {
+ const { stagedFromUntracked: _stagedFromUntracked, ...statusEntry } = entry
+ return statusEntry
+}
+
+function stageFakeGitEntry(entry: FakeGitEntry, filePaths: Set): FakeGitEntry {
+ if (!filePaths.has(entry.path)) return entry
+ if (entry.area === 'untracked') {
+ return { ...entry, area: 'staged', status: 'added', stagedFromUntracked: true }
+ }
+ return { ...entry, area: 'staged' }
+}
+
+function unstageFakeGitEntry(entry: FakeGitEntry, filePaths: Set): FakeGitEntry {
+ if (!filePaths.has(entry.path)) return entry
+ if (entry.stagedFromUntracked) {
+ return { ...entry, area: 'untracked', status: 'untracked', stagedFromUntracked: false }
+ }
+ return { ...entry, area: 'unstaged' }
+}
+
function success(id: string, result: unknown, streaming?: boolean): RpcResponse {
const resp: RpcResponse = { id, ok: true, result, _meta: { runtimeId: 'mock-runtime' } }
if (streaming) {
@@ -203,6 +238,91 @@ function handleRequest(
send(success(request.id, { unsubscribed: true }))
break
+ case 'git.status':
+ send(
+ success(request.id, {
+ entries: fakeGitEntries.map(toGitStatusEntry),
+ conflictOperation: 'unknown',
+ branch: 'refs/heads/feature/auth-refactor',
+ upstreamStatus: {
+ hasUpstream: fakeHasUpstream,
+ upstreamName: 'origin/feature/auth-refactor',
+ ahead: fakeAhead,
+ behind: fakeBehind
+ }
+ })
+ )
+ break
+
+ case 'git.upstreamStatus':
+ send(
+ success(request.id, {
+ hasUpstream: fakeHasUpstream,
+ upstreamName: 'origin/feature/auth-refactor',
+ ahead: fakeAhead,
+ behind: fakeBehind
+ })
+ )
+ break
+
+ case 'git.stage': {
+ const filePath = String(request.params?.filePath ?? '')
+ fakeGitEntries = fakeGitEntries.map((entry) => stageFakeGitEntry(entry, new Set([filePath])))
+ send(success(request.id, { ok: true }))
+ break
+ }
+
+ case 'git.bulkStage': {
+ const filePaths = new Set((request.params?.filePaths as string[] | undefined) ?? [])
+ fakeGitEntries = fakeGitEntries.map((entry) => stageFakeGitEntry(entry, filePaths))
+ send(success(request.id, { ok: true }))
+ break
+ }
+
+ case 'git.unstage': {
+ const filePath = String(request.params?.filePath ?? '')
+ fakeGitEntries = fakeGitEntries.map((entry) =>
+ unstageFakeGitEntry(entry, new Set([filePath]))
+ )
+ send(success(request.id, { ok: true }))
+ break
+ }
+
+ case 'git.bulkUnstage': {
+ const filePaths = new Set((request.params?.filePaths as string[] | undefined) ?? [])
+ fakeGitEntries = fakeGitEntries.map((entry) => unstageFakeGitEntry(entry, filePaths))
+ send(success(request.id, { ok: true }))
+ break
+ }
+
+ case 'git.discard': {
+ const filePath = String(request.params?.filePath ?? '')
+ fakeGitEntries = fakeGitEntries.filter((entry) => entry.path !== filePath)
+ send(success(request.id, { ok: true }))
+ break
+ }
+
+ case 'git.commit':
+ fakeGitEntries = fakeGitEntries.filter((entry) => entry.area !== 'staged')
+ fakeAhead += 1
+ send(success(request.id, { success: true }))
+ break
+
+ case 'git.fetch':
+ send(success(request.id, { ok: true }))
+ break
+
+ case 'git.pull':
+ fakeBehind = 0
+ send(success(request.id, { ok: true }))
+ break
+
+ case 'git.push':
+ fakeHasUpstream = true
+ fakeAhead = 0
+ send(success(request.id, { ok: true }))
+ break
+
default:
send(error(request.id, 'method_not_found', `Unknown method: ${request.method}`))
}
diff --git a/mobile/src/components/ActionSheetModal.tsx b/mobile/src/components/ActionSheetModal.tsx
index a68b08ea637..f7483c8d1d5 100644
--- a/mobile/src/components/ActionSheetModal.tsx
+++ b/mobile/src/components/ActionSheetModal.tsx
@@ -1,4 +1,4 @@
-import { View, Text, Pressable, StyleSheet } from 'react-native'
+import { ActivityIndicator, View, Text, Pressable, StyleSheet } from 'react-native'
import { Edit3, Trash2, type LucideIcon } from 'lucide-react-native'
import { colors, spacing, typography } from '../theme/mobile-theme'
import { BottomDrawer } from './BottomDrawer'
@@ -7,6 +7,9 @@ export type ActionSheetAction = {
label: string
icon?: LucideIcon
destructive?: boolean
+ disabled?: boolean
+ hint?: string
+ loading?: boolean
skipAutoClose?: boolean
onPress: () => void
}
@@ -53,7 +56,12 @@ export function ActionSheetContent({ title, message, actions, onClose }: Content
{i > 0 && }
[styles.action, pressed && styles.actionPressed]}
+ style={({ pressed }) => [
+ styles.action,
+ action.disabled && styles.actionDisabled,
+ pressed && !action.disabled && !action.loading && styles.actionPressed
+ ]}
+ disabled={action.disabled || action.loading}
onPress={() => {
action.onPress()
if (!action.skipAutoClose && onClose) {
@@ -65,11 +73,21 @@ export function ActionSheetContent({ title, message, actions, onClose }: Content
size={16}
color={action.destructive ? colors.statusRed : colors.textSecondary}
/>
-
- {action.label}
-
+
+
+ {action.label}
+
+ {action.hint ? {action.hint} : null}
+
+ {action.loading ? (
+
+ ) : null}
)
@@ -81,7 +99,7 @@ export function ActionSheetContent({ title, message, actions, onClose }: Content
export function ActionSheetModal({ visible, title, message, actions, onClose }: Props) {
return (
-
+
)
@@ -119,15 +137,30 @@ const styles = StyleSheet.create({
paddingVertical: spacing.md,
paddingHorizontal: spacing.md + 2
},
+ actionDisabled: {
+ opacity: 0.58
+ },
actionPressed: {
backgroundColor: colors.bgRaised
},
+ actionTextBlock: {
+ flex: 1,
+ minWidth: 0
+ },
actionText: {
fontSize: typography.bodySize,
fontWeight: '500',
color: colors.textPrimary
},
+ actionTextDisabled: {
+ color: colors.textSecondary
+ },
actionTextDestructive: {
color: colors.statusRed
+ },
+ actionHint: {
+ marginTop: 2,
+ fontSize: typography.metaSize,
+ color: colors.textMuted
}
})
diff --git a/mobile/src/components/BottomDrawer.tsx b/mobile/src/components/BottomDrawer.tsx
index 2698353fb92..4d98f2a92e2 100644
--- a/mobile/src/components/BottomDrawer.tsx
+++ b/mobile/src/components/BottomDrawer.tsx
@@ -14,6 +14,7 @@ import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-g
import Animated, {
useSharedValue,
useAnimatedStyle,
+ useAnimatedScrollHandler,
withSpring,
withTiming,
runOnJS,
@@ -30,20 +31,17 @@ const SPRING_CONFIG = { damping: 28, stiffness: 400 }
const RUBBER_BAND_FACTOR = 0.25
const SHOW_DURATION = 180
const HIDE_DURATION = 150
+const TOP_SCROLL_EPSILON = 1
type Props = {
visible: boolean
onClose: () => void
children: ReactNode
+ dragContentToDismiss?: boolean
}
-export function BottomDrawer({ visible, onClose, children }: Props) {
+export function BottomDrawer({ visible, onClose, children, dragContentToDismiss = false }: Props) {
const [mounted, setMounted] = useState(visible)
- const translateY = useSharedValue(0)
- const progress = useSharedValue(0)
- const keyboardOffset = useSharedValue(0)
- const { height: screenHeight } = useWindowDimensions()
- const insets = useSafeAreaInsets()
useEffect(() => {
if (visible) {
@@ -51,21 +49,56 @@ export function BottomDrawer({ visible, onClose, children }: Props) {
}
}, [visible])
- useEffect(() => {
- if (!mounted) return
+ // Why: hidden drawers are rendered by parent screens even while closed; keep
+ // their Reanimated/Gesture setup out of hot paths like commit-message typing.
+ if (!mounted) return null
+ return (
+ setMounted(false)}
+ dragContentToDismiss={dragContentToDismiss}
+ >
+ {children}
+
+ )
+}
+
+type MountedBottomDrawerProps = Props & {
+ onHidden: () => void
+}
+
+function MountedBottomDrawer({
+ visible,
+ onClose,
+ onHidden,
+ children,
+ dragContentToDismiss = false
+}: MountedBottomDrawerProps) {
+ const translateY = useSharedValue(0)
+ const progress = useSharedValue(0)
+ const keyboardOffset = useSharedValue(0)
+ const scrollOffsetY = useSharedValue(0)
+ const contentDragStartY = useSharedValue(0)
+ const contentDragCanDismiss = useSharedValue(false)
+ const { height: screenHeight } = useWindowDimensions()
+ const insets = useSafeAreaInsets()
+
+ useEffect(() => {
if (visible) {
translateY.value = 0
+ scrollOffsetY.value = 0
progress.value = withTiming(1, { duration: SHOW_DURATION })
} else {
Keyboard.dismiss()
progress.value = withTiming(0, { duration: HIDE_DURATION }, (finished) => {
if (finished) {
- runOnJS(setMounted)(false)
+ runOnJS(onHidden)()
}
})
}
- }, [mounted, visible])
+ }, [onHidden, visible])
// Why: KeyboardAvoidingView and useAnimatedKeyboard are both unreliable
// inside Modal (iOS ignores KAV; Android needs adjustNothing for
@@ -106,7 +139,14 @@ export function BottomDrawer({ visible, onClose, children }: Props) {
onClose()
}, [onClose])
- const panGesture = Gesture.Pan()
+ const scrollHandler = useAnimatedScrollHandler((event) => {
+ scrollOffsetY.value = Math.max(event.contentOffset.y, 0)
+ })
+
+ const scrollGesture = Gesture.Native()
+ const handlePanGesture = Gesture.Pan()
+ .activeOffsetY([-8, 8])
+ .simultaneousWithExternalGesture(scrollGesture)
.onUpdate((e) => {
if (e.translationY > 0) {
translateY.value = e.translationY
@@ -127,6 +167,53 @@ export function BottomDrawer({ visible, onClose, children }: Props) {
translateY.value = withSpring(0, SPRING_CONFIG)
}
})
+ const contentPanGesture = Gesture.Pan()
+ .activeOffsetY([-8, 8])
+ .simultaneousWithExternalGesture(scrollGesture)
+ .onBegin(() => {
+ contentDragStartY.value = 0
+ contentDragCanDismiss.value = scrollOffsetY.value <= TOP_SCROLL_EPSILON
+ })
+ .onUpdate((e) => {
+ // Why: action-sheet content can be taller than the drawer; downward drags
+ // should scroll back to the top before they start dismissing the sheet.
+ if (scrollOffsetY.value > TOP_SCROLL_EPSILON) {
+ contentDragCanDismiss.value = false
+ contentDragStartY.value = 0
+ if (translateY.value !== 0) {
+ translateY.value = withSpring(0, SPRING_CONFIG)
+ }
+ return
+ }
+
+ if (!contentDragCanDismiss.value) {
+ contentDragCanDismiss.value = true
+ contentDragStartY.value = e.translationY
+ }
+
+ const translationY = e.translationY - contentDragStartY.value
+ if (translationY > 0) {
+ translateY.value = translationY
+ } else {
+ translateY.value = translationY * RUBBER_BAND_FACTOR
+ }
+ })
+ .onEnd((e) => {
+ if (!contentDragCanDismiss.value || scrollOffsetY.value > TOP_SCROLL_EPSILON) return
+
+ const translationY = e.translationY - contentDragStartY.value
+ if (translationY > DISMISS_THRESHOLD || e.velocityY > 500) {
+ const velocity = Math.max(e.velocityY, 800)
+ const remaining = screenHeight - translationY
+ const duration = Math.min(Math.max((remaining / velocity) * 1000, 120), 300)
+ translateY.value = withTiming(screenHeight, { duration })
+ progress.value = withTiming(0, { duration }, () => {
+ runOnJS(dismiss)()
+ })
+ } else {
+ translateY.value = withSpring(0, SPRING_CONFIG)
+ }
+ })
const drawerStyle = useAnimatedStyle(() => ({
transform: [
@@ -151,10 +238,6 @@ export function BottomDrawer({ visible, onClose, children }: Props) {
}) as { pointerEvents: 'auto' | 'none' }
)
- // Why: hidden drawers can contain auto-focused inputs; keeping them mounted
- // lets Android open the keyboard even when the drawer is offscreen.
- if (!mounted) return null
-
return (
@@ -173,22 +256,53 @@ export function BottomDrawer({ visible, onClose, children }: Props) {
drawerStyle
]}
>
-
-
-
-
-
-
- {children}
-
+ {dragContentToDismiss ? (
+ <>
+
+
+
+
+
+
+
+
+
+ {children}
+
+
+
+
+ >
+ ) : (
+ <>
+
+
+
+
+
+
+ {children}
+
+ >
+ )}
diff --git a/mobile/src/source-control/mobile-git-status.test.ts b/mobile/src/source-control/mobile-git-status.test.ts
new file mode 100644
index 00000000000..9c3575a75b2
--- /dev/null
+++ b/mobile/src/source-control/mobile-git-status.test.ts
@@ -0,0 +1,109 @@
+import { describe, expect, expectTypeOf, it } from 'vitest'
+import type { GitStatusResult } from '../../../src/shared/git-status-types'
+import {
+ buildMobileSourceControlSections,
+ countStagedEntries,
+ countUnstagedEntries,
+ getStageablePaths,
+ getUnstageablePaths,
+ isMobileGitDiscardableEntry,
+ isMobileGitStageableEntry,
+ isMobileGitTransientRefreshError,
+ isMobileGitUnavailable,
+ type MobileGitStatusEntry,
+ type MobileGitStatusResult
+} from './mobile-git-status'
+
+const entries: MobileGitStatusEntry[] = [
+ { path: 'b.ts', status: 'modified', area: 'staged' },
+ { path: 'a.ts', status: 'modified', area: 'unstaged' },
+ { path: 'new.ts', status: 'untracked', area: 'untracked' }
+]
+
+describe('mobile source control status helpers', () => {
+ it('keeps the mobile RPC status type in lockstep with the shared git contract', () => {
+ expectTypeOf().toEqualTypeOf()
+ })
+
+ it('builds sections in the mobile source control order', () => {
+ const sections = buildMobileSourceControlSections(entries)
+
+ expect(sections.map((section) => section.title)).toEqual([
+ 'Changes',
+ 'Untracked Files',
+ 'Staged Changes'
+ ])
+ })
+
+ it('computes actionable path sets', () => {
+ expect(countUnstagedEntries(entries)).toBe(2)
+ expect(countStagedEntries(entries)).toBe(1)
+ expect(getStageablePaths(entries)).toEqual(['a.ts', 'new.ts'])
+ expect(getUnstageablePaths(entries)).toEqual(['b.ts'])
+ })
+
+ it('keeps unresolved conflicts out of stage actions', () => {
+ const conflictedEntries: MobileGitStatusEntry[] = [
+ { path: 'ready.ts', status: 'modified', area: 'unstaged' },
+ {
+ path: 'conflicted.ts',
+ status: 'modified',
+ area: 'unstaged',
+ conflictStatus: 'unresolved'
+ },
+ {
+ path: 'resolved.ts',
+ status: 'modified',
+ area: 'unstaged',
+ conflictStatus: 'resolved_locally'
+ }
+ ]
+
+ expect(getStageablePaths(conflictedEntries)).toEqual(['ready.ts', 'resolved.ts'])
+ expect(isMobileGitStageableEntry(conflictedEntries[1])).toBe(false)
+ expect(isMobileGitDiscardableEntry(conflictedEntries[1])).toBe(false)
+ expect(isMobileGitDiscardableEntry(conflictedEntries[2])).toBe(false)
+ })
+
+ it('sorts entries by desktop-compatible conflict rank, then path', () => {
+ const sections = buildMobileSourceControlSections([
+ { path: 'zeta.ts', status: 'modified', area: 'unstaged' },
+ {
+ path: 'beta.ts',
+ status: 'modified',
+ area: 'unstaged',
+ conflictStatus: 'resolved_locally'
+ },
+ {
+ path: 'alpha.ts',
+ status: 'modified',
+ area: 'unstaged',
+ conflictStatus: 'unresolved'
+ },
+ { path: 'aardvark.ts', status: 'added', area: 'unstaged' }
+ ])
+
+ expect(sections[0].data.map((entry) => entry.path)).toEqual([
+ 'alpha.ts',
+ 'beta.ts',
+ 'aardvark.ts',
+ 'zeta.ts'
+ ])
+ })
+
+ it('recognizes old-desktop unavailable responses', () => {
+ expect(isMobileGitUnavailable('forbidden', 'Method is not available to mobile clients')).toBe(
+ true
+ )
+ expect(isMobileGitUnavailable('method_not_found', 'Unknown method')).toBe(true)
+ expect(isMobileGitUnavailable('bad_request', 'Missing worktree selector')).toBe(false)
+ })
+
+ it('recognizes transient status refresh aborts', () => {
+ expect(isMobileGitTransientRefreshError('runtime_error', 'Aborting')).toBe(true)
+ expect(isMobileGitTransientRefreshError('request_aborted', 'request_aborted')).toBe(true)
+ expect(isMobileGitTransientRefreshError('runtime_error', 'fatal: not a git repository')).toBe(
+ false
+ )
+ })
+})
diff --git a/mobile/src/source-control/mobile-git-status.ts b/mobile/src/source-control/mobile-git-status.ts
new file mode 100644
index 00000000000..9c78b0f9d0b
--- /dev/null
+++ b/mobile/src/source-control/mobile-git-status.ts
@@ -0,0 +1,103 @@
+import type {
+ GitFileStatus,
+ GitStagingArea,
+ GitStatusEntry,
+ GitStatusResult,
+ GitUpstreamStatus
+} from '../../../src/shared/git-status-types'
+
+export type MobileGitFileStatus = GitFileStatus
+export type MobileGitStagingArea = GitStagingArea
+export type MobileGitStatusEntry = GitStatusEntry
+export type MobileGitUpstreamStatus = GitUpstreamStatus
+export type MobileGitStatusResult = GitStatusResult
+
+export type MobileSourceControlSection =
+ {
+ area: MobileGitStagingArea
+ title: string
+ data: TEntry[]
+ }
+
+const AREA_ORDER: MobileGitStagingArea[] = ['unstaged', 'untracked', 'staged']
+
+const AREA_TITLES: Record = {
+ unstaged: 'Changes',
+ untracked: 'Untracked Files',
+ staged: 'Staged Changes'
+}
+
+export const MOBILE_GIT_STATUS_LABELS: Record = {
+ modified: 'M',
+ added: 'A',
+ deleted: 'D',
+ renamed: 'R',
+ untracked: 'U',
+ copied: 'C'
+}
+
+function compareGitStatusEntries(a: MobileGitStatusEntry, b: MobileGitStatusEntry): number {
+ return (
+ getConflictSortRank(a) - getConflictSortRank(b) ||
+ a.path.localeCompare(b.path, undefined, { numeric: true })
+ )
+}
+
+function getConflictSortRank(entry: MobileGitStatusEntry): number {
+ if (entry.conflictStatus === 'unresolved') return 0
+ if (entry.conflictStatus === 'resolved_locally') return 1
+ return 2
+}
+
+export function buildMobileSourceControlSections(
+ entries: readonly TEntry[]
+): MobileSourceControlSection[] {
+ return AREA_ORDER.map((area) => ({
+ area,
+ title: AREA_TITLES[area],
+ data: entries.filter((entry) => entry.area === area).sort(compareGitStatusEntries)
+ })).filter((section) => section.data.length > 0)
+}
+
+export function countStagedEntries(entries: readonly MobileGitStatusEntry[]): number {
+ return entries.filter((entry) => entry.area === 'staged').length
+}
+
+export function countUnstagedEntries(entries: readonly MobileGitStatusEntry[]): number {
+ return entries.filter((entry) => entry.area === 'unstaged' || entry.area === 'untracked').length
+}
+
+export function getStageablePaths(entries: readonly MobileGitStatusEntry[]): string[] {
+ return entries.filter(isMobileGitStageableEntry).map((entry) => entry.path)
+}
+
+export function getUnstageablePaths(entries: readonly MobileGitStatusEntry[]): string[] {
+ return entries.filter((entry) => entry.area === 'staged').map((entry) => entry.path)
+}
+
+export function isMobileGitStageableEntry(entry: MobileGitStatusEntry): boolean {
+ return (
+ (entry.area === 'unstaged' || entry.area === 'untracked') &&
+ entry.conflictStatus !== 'unresolved'
+ )
+}
+
+export function isMobileGitDiscardableEntry(entry: MobileGitStatusEntry): boolean {
+ return entry.conflictStatus !== 'unresolved' && entry.conflictStatus !== 'resolved_locally'
+}
+
+export function isMobileGitUnavailable(code: string | undefined, message: string | undefined) {
+ return (
+ code === 'forbidden' ||
+ code === 'method_not_found' ||
+ message?.includes('not available to mobile clients') === true
+ )
+}
+
+export function isMobileGitTransientRefreshError(
+ code: string | undefined,
+ message: string | undefined
+) {
+ const normalized = message?.trim().toLowerCase()
+ return code === 'request_aborted' || normalized === 'aborting' || normalized === 'request_aborted'
+}
diff --git a/src/main/git/remote.test.ts b/src/main/git/remote.test.ts
index c9213fd8100..0b9d6e65577 100644
--- a/src/main/git/remote.test.ts
+++ b/src/main/git/remote.test.ts
@@ -155,6 +155,38 @@ describe('git remote operations', () => {
)
})
+ it('normalizes pull dirty-worktree aborts to a friendly message', async () => {
+ gitExecFileAsyncMock.mockRejectedValueOnce(
+ new Error(
+ 'Command failed: git pull\n' +
+ 'error: Your local changes to the following files would be overwritten by merge:\n' +
+ '\tsrc/app.ts\n' +
+ 'Please commit your changes or stash them before you merge.\n' +
+ 'Aborting'
+ )
+ )
+
+ await expect(gitPull('/repo')).rejects.toThrow(
+ 'Pull would overwrite local changes. Commit, stash, or discard them before pulling.'
+ )
+ })
+
+ it('normalizes pull untracked-file aborts to a friendly message', async () => {
+ gitExecFileAsyncMock.mockRejectedValueOnce(
+ new Error(
+ 'Command failed: git pull\n' +
+ 'error: The following untracked working tree files would be overwritten by merge:\n' +
+ '\tsrc/new.ts\n' +
+ 'Please move or remove them before you merge.\n' +
+ 'Aborting'
+ )
+ )
+
+ await expect(gitPull('/repo')).rejects.toThrow(
+ 'Pull would overwrite untracked files. Move, remove, or add them before pulling.'
+ )
+ })
+
it('runs fetch with prune', async () => {
gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' })
diff --git a/src/main/runtime/runtime-rpc.test.ts b/src/main/runtime/runtime-rpc.test.ts
index 8d66b351c5b..2ed6eaf58a8 100644
--- a/src/main/runtime/runtime-rpc.test.ts
+++ b/src/main/runtime/runtime-rpc.test.ts
@@ -595,6 +595,14 @@ describe('OrcaRuntimeRpcServer', () => {
const selectCodexAccount = vi.fn().mockResolvedValue({ ok: true })
const removeClaudeAccount = vi.fn().mockResolvedValue({ ok: true })
const readTerminal = vi.fn().mockResolvedValue({ tail: ['ok'] })
+ const getRuntimeGitStatus = vi
+ .fn()
+ .mockResolvedValue({ entries: [], conflictOperation: 'unknown' })
+ const getRuntimeGitUpstreamStatus = vi
+ .fn()
+ .mockResolvedValue({ hasUpstream: true, ahead: 1, behind: 0 })
+ const bulkStageRuntimeGitPaths = vi.fn().mockResolvedValue({ ok: true })
+ const bulkUnstageRuntimeGitPaths = vi.fn().mockResolvedValue({ ok: true })
const runtime = {
getRuntimeId: () => 'test-runtime',
getStatus: vi.fn().mockResolvedValue({ graphStatus: 'ok' }),
@@ -602,7 +610,11 @@ describe('OrcaRuntimeRpcServer', () => {
selectClaudeAccount,
selectCodexAccount,
removeClaudeAccount,
- readTerminal
+ readTerminal,
+ getRuntimeGitStatus,
+ getRuntimeGitUpstreamStatus,
+ bulkStageRuntimeGitPaths,
+ bulkUnstageRuntimeGitPaths
} as unknown as OrcaRuntimeService
const server = new OrcaRuntimeRpcServer({ runtime, userDataPath, enableWebSocket: false })
server['deviceRegistry'] = new DeviceRegistry(userDataPath)
@@ -612,7 +624,7 @@ describe('OrcaRuntimeRpcServer', () => {
await server['handleWebSocketMessage'](
JSON.stringify({
id: 'req_forbidden',
- method: 'git.push',
+ method: 'git.generateCommitMessage',
deviceToken: mobile.token,
params: { worktree: 'id:wt-1' }
}),
@@ -628,6 +640,56 @@ describe('OrcaRuntimeRpcServer', () => {
(response) => replies.push(JSON.parse(response) as Record),
() => {}
)
+ await server['handleWebSocketMessage'](
+ JSON.stringify({
+ id: 'req_git_status',
+ method: 'git.status',
+ deviceToken: mobile.token,
+ params: { worktree: 'id:wt-1' }
+ }),
+ (response) => replies.push(JSON.parse(response) as Record),
+ () => {}
+ )
+ await server['handleWebSocketMessage'](
+ JSON.stringify({
+ id: 'req_git_push',
+ method: 'git.push',
+ deviceToken: mobile.token,
+ params: { worktree: 'id:wt-1', publish: true }
+ }),
+ (response) => replies.push(JSON.parse(response) as Record),
+ () => {}
+ )
+ await server['handleWebSocketMessage'](
+ JSON.stringify({
+ id: 'req_git_upstream',
+ method: 'git.upstreamStatus',
+ deviceToken: mobile.token,
+ params: { worktree: 'id:wt-1' }
+ }),
+ (response) => replies.push(JSON.parse(response) as Record),
+ () => {}
+ )
+ await server['handleWebSocketMessage'](
+ JSON.stringify({
+ id: 'req_git_bulk_stage',
+ method: 'git.bulkStage',
+ deviceToken: mobile.token,
+ params: { worktree: 'id:wt-1', filePaths: ['a.ts', 'b.ts'] }
+ }),
+ (response) => replies.push(JSON.parse(response) as Record),
+ () => {}
+ )
+ await server['handleWebSocketMessage'](
+ JSON.stringify({
+ id: 'req_git_bulk_unstage',
+ method: 'git.bulkUnstage',
+ deviceToken: mobile.token,
+ params: { worktree: 'id:wt-1', filePaths: ['c.ts'] }
+ }),
+ (response) => replies.push(JSON.parse(response) as Record),
+ () => {}
+ )
await server['handleWebSocketMessage'](
JSON.stringify({
id: 'req_select_claude',
@@ -677,6 +739,13 @@ describe('OrcaRuntimeRpcServer', () => {
})
)
expect(replies).toContainEqual(expect.objectContaining({ id: 'req_allowed', ok: true }))
+ expect(replies).toContainEqual(expect.objectContaining({ id: 'req_git_status', ok: true }))
+ expect(replies).toContainEqual(expect.objectContaining({ id: 'req_git_push', ok: true }))
+ expect(replies).toContainEqual(expect.objectContaining({ id: 'req_git_upstream', ok: true }))
+ expect(replies).toContainEqual(expect.objectContaining({ id: 'req_git_bulk_stage', ok: true }))
+ expect(replies).toContainEqual(
+ expect.objectContaining({ id: 'req_git_bulk_unstage', ok: true })
+ )
expect(replies).toContainEqual(expect.objectContaining({ id: 'req_select_claude', ok: true }))
expect(replies).toContainEqual(expect.objectContaining({ id: 'req_select_codex', ok: true }))
expect(replies).toContainEqual(expect.objectContaining({ id: 'req_terminal_read', ok: true }))
@@ -690,8 +759,12 @@ describe('OrcaRuntimeRpcServer', () => {
expect(selectClaudeAccount).toHaveBeenCalledWith('claude-account')
expect(selectCodexAccount).toHaveBeenCalledWith(null)
expect(readTerminal).toHaveBeenCalledWith('term-1', { cursor: undefined })
+ expect(getRuntimeGitStatus).toHaveBeenCalledWith('id:wt-1')
+ expect(pushRuntimeGit).toHaveBeenCalledWith('id:wt-1', true, undefined)
+ expect(getRuntimeGitUpstreamStatus).toHaveBeenCalledWith('id:wt-1')
+ expect(bulkStageRuntimeGitPaths).toHaveBeenCalledWith('id:wt-1', ['a.ts', 'b.ts'])
+ expect(bulkUnstageRuntimeGitPaths).toHaveBeenCalledWith('id:wt-1', ['c.ts'])
expect(removeClaudeAccount).not.toHaveBeenCalled()
- expect(pushRuntimeGit).not.toHaveBeenCalled()
})
it('rejects WebSocket requests whose request token differs from the authenticated channel token', async () => {
diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts
index 6f940d5b6eb..c0fc8256f2e 100644
--- a/src/main/runtime/runtime-rpc.ts
+++ b/src/main/runtime/runtime-rpc.ts
@@ -126,6 +126,17 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
'files.list',
'files.open',
'files.read',
+ 'git.bulkStage',
+ 'git.bulkUnstage',
+ 'git.commit',
+ 'git.discard',
+ 'git.fetch',
+ 'git.pull',
+ 'git.push',
+ 'git.stage',
+ 'git.status',
+ 'git.unstage',
+ 'git.upstreamStatus',
'markdown.readTab',
'markdown.saveTab',
'notifications.subscribe',
diff --git a/src/shared/git-remote-error.ts b/src/shared/git-remote-error.ts
index 717da5d8ea0..07bf656c92d 100644
--- a/src/shared/git-remote-error.ts
+++ b/src/shared/git-remote-error.ts
@@ -64,6 +64,17 @@ export function normalizeGitErrorMessage(error: unknown, operation?: GitRemoteOp
return 'Branch has no upstream. Publish the branch first.'
}
+ if (
+ raw.includes('Your local changes to the following files would be overwritten') ||
+ raw.includes('Your local changes would be overwritten')
+ ) {
+ return 'Pull would overwrite local changes. Commit, stash, or discard them before pulling.'
+ }
+
+ if (raw.includes('untracked working tree files would be overwritten')) {
+ return 'Pull would overwrite untracked files. Move, remove, or add them before pulling.'
+ }
+
// Fallthrough: extract only the tail stderr line. `raw` was already
// credential-scrubbed at the top of the function, so no further scrub needed.
return extractTailLine(raw)
diff --git a/src/shared/git-status-types.ts b/src/shared/git-status-types.ts
new file mode 100644
index 00000000000..741bf4ce7e9
--- /dev/null
+++ b/src/shared/git-status-types.ts
@@ -0,0 +1,61 @@
+export type GitFileStatus = 'modified' | 'added' | 'deleted' | 'renamed' | 'untracked' | 'copied'
+export type GitStagingArea = 'staged' | 'unstaged' | 'untracked'
+export type GitConflictKind =
+ | 'both_modified'
+ | 'both_added'
+ | 'both_deleted'
+ | 'added_by_us'
+ | 'added_by_them'
+ | 'deleted_by_us'
+ | 'deleted_by_them'
+
+export type GitConflictResolutionStatus = 'unresolved' | 'resolved_locally'
+export type GitConflictStatusSource = 'git' | 'session'
+export type GitConflictOperation = 'merge' | 'rebase' | 'cherry-pick' | 'unknown'
+
+// Compatibility note for non-upgraded consumers:
+// Any consumer that has not been upgraded to read `conflictStatus` may still
+// render `modified` styling via the `status` field (which is a compatibility
+// fallback, not a semantic claim). However, such consumers must NOT offer
+// file-existence-dependent affordances (diff loading, drag payloads, editable-
+// file opening) for entries where `conflictStatus === 'unresolved'` — the file
+// may not exist on disk (e.g. both_deleted). This affects file explorer
+// decorations, tab badges, and any surface outside Source Control.
+//
+// `conflictStatusSource` is never set by the main process. The renderer stamps
+// 'git' for live u-records and 'session' for Resolved locally state.
+export type GitUncommittedEntry = {
+ path: string
+ status: GitFileStatus
+ area: GitStagingArea
+ oldPath?: string
+ conflictKind?: GitConflictKind
+ conflictStatus?: GitConflictResolutionStatus
+ conflictStatusSource?: GitConflictStatusSource
+}
+
+export type GitStatusEntry = GitUncommittedEntry
+
+export type GitStatusResult = {
+ entries: GitStatusEntry[]
+ conflictOperation: GitConflictOperation
+ head?: string
+ branch?: string
+ // Why: porcelain v2 status already includes upstream/ahead/behind metadata.
+ // Folding it in lets refresh polling avoid a second pair of git subprocesses.
+ upstreamStatus?: GitUpstreamStatus
+ ignoredPaths?: string[]
+}
+
+// Why: when hasUpstream is false, ahead/behind are placeholder zeros, not a
+// "sync" signal — callers must check hasUpstream before treating 0/0 as in-sync.
+// Kept as a named type because explicit upstream refreshes can still fail for
+// reasons unrelated to working-tree status (e.g., no upstream is expected).
+export type GitUpstreamStatus = {
+ hasUpstream: boolean
+ upstreamName?: string
+ ahead: number
+ behind: number
+}
+
+export type GitBranchChangeStatus = 'modified' | 'added' | 'deleted' | 'renamed' | 'copied'
diff --git a/src/shared/types.ts b/src/shared/types.ts
index a0a9fa6c44a..81360ef3e9b 100644
--- a/src/shared/types.ts
+++ b/src/shared/types.ts
@@ -12,11 +12,25 @@ import type { VoiceSettings } from './speech-types'
import type { WorkspaceCleanupUIState } from './workspace-cleanup'
import type { GitLabProjectSettings } from './gitlab-types'
import type { TaskProvider } from './task-providers'
+import type { GitBranchChangeStatus } from './git-status-types'
// Re-exported for backward compat with renderer call sites that import
// `WorkspaceCreateTelemetrySource` from '../../../shared/types'.
export type { WorkspaceSource as WorkspaceCreateTelemetrySource } from './telemetry-events'
export type { TaskProvider } from './task-providers'
+export type {
+ GitBranchChangeStatus,
+ GitConflictKind,
+ GitConflictOperation,
+ GitConflictResolutionStatus,
+ GitConflictStatusSource,
+ GitFileStatus,
+ GitStagingArea,
+ GitStatusEntry,
+ GitStatusResult,
+ GitUncommittedEntry,
+ GitUpstreamStatus
+} from './git-status-types'
// ─── Shell PATH hydration ────────────────────────────────────────────
// Why: shared so the main-side `HydrationResult` discriminator and the
@@ -1970,67 +1984,8 @@ export type FsChangedPayload = {
}
// ─── Git Status ─────────────────────────────────────────────
-export type GitFileStatus = 'modified' | 'added' | 'deleted' | 'renamed' | 'untracked' | 'copied'
-export type GitStagingArea = 'staged' | 'unstaged' | 'untracked'
-export type GitConflictKind =
- | 'both_modified'
- | 'both_added'
- | 'both_deleted'
- | 'added_by_us'
- | 'added_by_them'
- | 'deleted_by_us'
- | 'deleted_by_them'
-
-export type GitConflictResolutionStatus = 'unresolved' | 'resolved_locally'
-export type GitConflictStatusSource = 'git' | 'session'
-export type GitConflictOperation = 'merge' | 'rebase' | 'cherry-pick' | 'unknown'
-
-// Compatibility note for non-upgraded consumers:
-// Any consumer that has not been upgraded to read `conflictStatus` may still
-// render `modified` styling via the `status` field (which is a compatibility
-// fallback, not a semantic claim). However, such consumers must NOT offer
-// file-existence-dependent affordances (diff loading, drag payloads, editable-
-// file opening) for entries where `conflictStatus === 'unresolved'` — the file
-// may not exist on disk (e.g. both_deleted). This affects file explorer
-// decorations, tab badges, and any surface outside Source Control.
-//
-// `conflictStatusSource` is never set by the main process. The renderer stamps
-// 'git' for live u-records and 'session' for Resolved locally state.
-export type GitUncommittedEntry = {
- path: string
- status: GitFileStatus
- area: GitStagingArea
- oldPath?: string
- conflictKind?: GitConflictKind
- conflictStatus?: GitConflictResolutionStatus
- conflictStatusSource?: GitConflictStatusSource
-}
-
-export type GitStatusEntry = GitUncommittedEntry
-
-export type GitStatusResult = {
- entries: GitStatusEntry[]
- conflictOperation: GitConflictOperation
- head?: string
- branch?: string
- // Why: porcelain v2 status already includes upstream/ahead/behind metadata.
- // Folding it in lets refresh polling avoid a second pair of git subprocesses.
- upstreamStatus?: GitUpstreamStatus
- ignoredPaths?: string[]
-}
-
-// Why: when hasUpstream is false, ahead/behind are placeholder zeros, not a
-// "sync" signal — callers must check hasUpstream before treating 0/0 as in-sync.
-// Kept as a named type because explicit upstream refreshes can still fail for
-// reasons unrelated to working-tree status (e.g., no upstream is expected).
-export type GitUpstreamStatus = {
- hasUpstream: boolean
- upstreamName?: string
- ahead: number
- behind: number
-}
-
-export type GitBranchChangeStatus = 'modified' | 'added' | 'deleted' | 'renamed' | 'copied'
+// Re-exported from git-status-types.ts so mobile can share the runtime git
+// wire contract without importing this desktop-oriented aggregate type module.
export type GitBranchChangeEntry = {
path: string