Add mobile source control actions (#2193)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong
2026-05-17 19:35:55 -07:00
committed by GitHub
co-authored by Orca
parent 8f3740330c
commit afe90a616f
15 changed files with 2204 additions and 104 deletions
+15
View File
@@ -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,
+17 -1
View File
@@ -24,6 +24,7 @@ import {
Folder,
File,
FileText,
GitBranch,
Mic,
Monitor,
Plus,
@@ -2372,6 +2373,19 @@ export default function SessionScreen() {
</Text>
</View>
</View>
<Pressable
style={({ pressed }) => [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"
>
<GitBranch size={18} color={colors.textSecondary} strokeWidth={2.1} />
</Pressable>
<Pressable
style={({ pressed }) => [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,
File diff suppressed because it is too large Load Diff
+4
View File
@@ -12,6 +12,10 @@ export default function HostGroupLayout() {
<Stack.Screen name="[hostId]/index" options={{ title: 'Host' }} />
<Stack.Screen name="[hostId]/accounts" options={{ title: 'Accounts' }} />
<Stack.Screen name="[hostId]/session/[worktreeId]" options={{ title: 'Terminal' }} />
<Stack.Screen
name="[hostId]/source-control/[worktreeId]"
options={{ title: 'Source Control' }}
/>
</Stack>
)
}
+120
View File
@@ -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<string>): 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<string>): 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}`))
}
+41 -8
View File
@@ -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
<View key={action.label}>
{i > 0 && <View style={styles.separator} />}
<Pressable
style={({ pressed }) => [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}
/>
<Text
style={[styles.actionText, action.destructive && styles.actionTextDestructive]}
>
{action.label}
</Text>
<View style={styles.actionTextBlock}>
<Text
style={[
styles.actionText,
action.destructive && styles.actionTextDestructive,
action.disabled && styles.actionTextDisabled
]}
>
{action.label}
</Text>
{action.hint ? <Text style={styles.actionHint}>{action.hint}</Text> : null}
</View>
{action.loading ? (
<ActivityIndicator size="small" color={colors.textSecondary} />
) : null}
</Pressable>
</View>
)
@@ -81,7 +99,7 @@ export function ActionSheetContent({ title, message, actions, onClose }: Content
export function ActionSheetModal({ visible, title, message, actions, onClose }: Props) {
return (
<BottomDrawer visible={visible} onClose={onClose}>
<BottomDrawer visible={visible} onClose={onClose} dragContentToDismiss>
<ActionSheetContent title={title} message={message} actions={actions} onClose={onClose} />
</BottomDrawer>
)
@@ -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
}
})
+145 -31
View File
@@ -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 (
<MountedBottomDrawer
visible={visible}
onClose={onClose}
onHidden={() => setMounted(false)}
dragContentToDismiss={dragContentToDismiss}
>
{children}
</MountedBottomDrawer>
)
}
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 (
<Animated.View style={[styles.overlay, pointerStyle]} accessibilityViewIsModal aria-modal>
<GestureHandlerRootView style={styles.root}>
@@ -173,22 +256,53 @@ export function BottomDrawer({ visible, onClose, children }: Props) {
drawerStyle
]}
>
<GestureDetector gesture={panGesture}>
<Animated.View
style={styles.handleHitArea}
accessibilityRole="button"
accessibilityLabel="Dismiss drawer"
>
<View style={styles.handle} />
</Animated.View>
</GestureDetector>
<ScrollView
bounces={false}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{children}
</ScrollView>
{dragContentToDismiss ? (
<>
<GestureDetector gesture={handlePanGesture}>
<Animated.View
style={styles.handleHitArea}
accessibilityRole="button"
accessibilityLabel="Dismiss drawer"
>
<View style={styles.handle} />
</Animated.View>
</GestureDetector>
<GestureDetector gesture={contentPanGesture}>
<Animated.View collapsable={false}>
<GestureDetector gesture={scrollGesture}>
<Animated.ScrollView
bounces={false}
keyboardShouldPersistTaps="handled"
onScroll={scrollHandler}
scrollEventThrottle={16}
showsVerticalScrollIndicator={false}
>
{children}
</Animated.ScrollView>
</GestureDetector>
</Animated.View>
</GestureDetector>
</>
) : (
<>
<GestureDetector gesture={handlePanGesture}>
<Animated.View
style={styles.handleHitArea}
accessibilityRole="button"
accessibilityLabel="Dismiss drawer"
>
<View style={styles.handle} />
</Animated.View>
</GestureDetector>
<ScrollView
bounces={false}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{children}
</ScrollView>
</>
)}
<View style={styles.bottomExtension} />
</Animated.View>
</View>
@@ -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<MobileGitStatusResult>().toEqualTypeOf<GitStatusResult>()
})
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
)
})
})
@@ -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<TEntry extends MobileGitStatusEntry = MobileGitStatusEntry> =
{
area: MobileGitStagingArea
title: string
data: TEntry[]
}
const AREA_ORDER: MobileGitStagingArea[] = ['unstaged', 'untracked', 'staged']
const AREA_TITLES: Record<MobileGitStagingArea, string> = {
unstaged: 'Changes',
untracked: 'Untracked Files',
staged: 'Staged Changes'
}
export const MOBILE_GIT_STATUS_LABELS: Record<MobileGitFileStatus, string> = {
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<TEntry extends MobileGitStatusEntry>(
entries: readonly TEntry[]
): MobileSourceControlSection<TEntry>[] {
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'
}
+32
View File
@@ -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: '' })
+76 -3
View File
@@ -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<string, unknown>),
() => {}
)
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<string, unknown>),
() => {}
)
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<string, unknown>),
() => {}
)
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<string, unknown>),
() => {}
)
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<string, unknown>),
() => {}
)
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<string, unknown>),
() => {}
)
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 () => {
+11
View File
@@ -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',
+11
View File
@@ -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)
+61
View File
@@ -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'
+16 -61
View File
@@ -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