feat: add mobile git sync transport

This commit is contained in:
lucaronin
2026-05-12 16:06:17 +02:00
parent 8eaf5a530d
commit a367293d9a
25 changed files with 1499 additions and 236 deletions

View File

@@ -10,5 +10,15 @@ config.resolver.nodeModulesPaths = [
path.resolve(projectRoot, 'node_modules'),
path.resolve(workspaceRoot, 'node_modules'),
]
config.resolver.resolveRequest = (context, moduleName, platform) => {
if (moduleName === 'isomorphic-git') {
return {
filePath: path.resolve(workspaceRoot, 'node_modules/isomorphic-git/index.js'),
type: 'sourceFile',
}
}
return context.resolveRequest(context, moduleName, platform)
}
module.exports = config

View File

@@ -15,14 +15,16 @@
"dependencies": {
"@10play/tentap-editor": "^1.0.1",
"@tolaria/markdown": "workspace:*",
"expo": "~55.0.20",
"buffer": "^6.0.3",
"expo": "~55.0.23",
"expo-auth-session": "~55.0.15",
"expo-dev-client": "~55.0.32",
"expo-file-system": "55.0.17",
"expo-modules-core": "55.0.24",
"expo-file-system": "55.0.19",
"expo-modules-core": "55.0.25",
"expo-secure-store": "~55.0.13",
"expo-status-bar": "~55.0.5",
"expo-web-browser": "~55.0.14",
"expo-status-bar": "~55.0.6",
"expo-web-browser": "~55.0.15",
"isomorphic-git": "^1.37.6",
"phosphor-react-native": "^3.0.6",
"react": "19.2.0",
"react-native": "0.83.6",

View File

@@ -74,8 +74,7 @@ import { useMobileVaultRuntimeLoader } from './useMobileVaultRuntimeLoader'
import type { MobileNotePropertyPatch } from './mobileNoteProperties'
import { useMobileGitSyncFlow } from './useMobileGitSyncFlow'
import { useMobileAiSettingsFlow } from './useMobileAiSettingsFlow'
import { createNativeMobileGitTransport } from './mobileNativeGitTransport'
import { loadExpoMobileGitNativeModule } from './mobileExpoNativeGitModule'
import { createNativeIsomorphicMobileGitTransport } from './mobileNativeIsomorphicGitTransport'
import { applyMobileRawNoteContent } from './mobileRawNoteProjection'
import {
createMobileSidebarSections,
@@ -96,7 +95,7 @@ export function MobileApp() {
const aiSettingsStorage = useMemo(() => createNativeMobileAiSettingsStorage(), [])
const appStateStorage = useMemo(() => createNativeMobileAppStateStorage(), [])
const gitCredentialStorage = useMemo(() => createNativeMobileGitCredentialStorage(), [])
const gitTransport = useMemo(() => createNativeMobileGitTransport(loadExpoMobileGitNativeModule()), [])
const gitTransport = useMemo(() => createNativeIsomorphicMobileGitTransport(gitCredentialStorage), [gitCredentialStorage])
const gitHubOAuthClientIdState = useMemo(() => currentMobileGitHubOAuthClientIdState(), [])
const vaultMetadataStorage = useMemo(() => createNativeMobileVaultMetadataStorage(), [])
const [activeVaultMetadata, setActiveVaultMetadata] = useState(defaultMobileVaultMetadata)
@@ -118,12 +117,22 @@ export function MobileApp() {
)
const selectedEditorMode = editorModeByNoteId[selectedNote.id] ?? 'rich'
const selectedSaveState = saveStateByNoteId[selectedNote.id] ?? idleMobileEditorSaveState
const reloadSyncedVault = useCallback(() => {
void loadDemoVaultNotes(activeVaultMetadata)
.then((notes) => {
setAvailableNotes(notes)
setCompactNavigation((state) => selectLoadedNote(state, notes, state.selectedNoteId))
})
.catch(() => {})
}, [activeVaultMetadata])
const gitSyncFlow = useMobileGitSyncFlow({
createGitHubOAuthSession: createNativeMobileGitHubOAuthSessionFromEnvironment,
credentialStorage: gitCredentialStorage,
gitTransport,
onSynced: reloadSyncedVault,
vault: activeVaultMetadata,
})
const markGitLocalChanges = gitSyncFlow.markLocalChanges
const aiSettingsFlow = useMobileAiSettingsFlow({
secretStorage: aiProviderSecretStorage,
settingsStorage: aiSettingsStorage,
@@ -137,10 +146,11 @@ export function MobileApp() {
setSaveStateByNoteId((state) => ({ ...state, [noteId]: saveState }))
},
onSavedDraft: (draft) => {
markGitLocalChanges()
setAvailableNotes((notes) => applySavedMobileEditorDraft({ draft, notes }))
},
}),
[activeVaultMetadata],
[activeVaultMetadata, markGitLocalChanges],
)
const applyLoadedVaultRuntime = useCallback(({ activeVault, notes, selectedNoteId }: MobileVaultRuntime) => {
@@ -196,13 +206,19 @@ export function MobileApp() {
? { label: 'Saved', state: 'saved' }
: { label: 'Save failed', state: 'failed' },
}))
if (result.status === 'saved') {
markGitLocalChanges()
}
})
.catch(() => {
setSaveStateByNoteId((state) => ({ ...state, [noteId]: { label: 'Save failed', state: 'failed' } }))
})
}, [activeVaultMetadata, selectedNote.id])
}, [activeVaultMetadata, markGitLocalChanges, selectedNote.id])
const deleteFlow = useMobileNoteDeleteFlow({
deleteNote: (noteId) => deleteDemoVaultNote(noteId, activeVaultMetadata),
deleteNote: async (noteId) => {
await deleteDemoVaultNote(noteId, activeVaultMetadata)
markGitLocalChanges()
},
loadNotes: () => loadDemoVaultNotes(activeVaultMetadata),
notes: availableNotes,
onNotesLoaded: setAvailableNotes,
@@ -212,6 +228,7 @@ export function MobileApp() {
const createFlow = useMobileNoteCreateFlow({
createNote: (title) => createDemoVaultNote({ title, vaultMetadata: activeVaultMetadata }),
onCreated: (note) => {
markGitLocalChanges()
setAvailableNotes((notes) => [note, ...notes.filter((item) => item.id !== note.id)])
selectNoteId(note.id)
},
@@ -219,11 +236,17 @@ export function MobileApp() {
const propertiesFlow = useMobileNotePropertiesFlow({
loadNotes: () => loadDemoVaultNotes(activeVaultMetadata),
onNotesLoaded: setAvailableNotes,
saveFrontmatter: (noteId, metadata) => saveDemoVaultNoteFrontmatter({
metadata,
noteId,
vaultMetadata: activeVaultMetadata,
}),
saveFrontmatter: async (noteId, metadata) => {
const result = await saveDemoVaultNoteFrontmatter({
metadata,
noteId,
vaultMetadata: activeVaultMetadata,
})
if (result.status === 'saved') {
markGitLocalChanges()
}
return result
},
selectedNote,
})
const toggleSelectedArchive = useCallback(() => {

View File

@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest'
import { shouldSeedDemoVault } from './mobileDemoVaultSeedPolicy'
describe('mobile demo vault loading', () => {
it('does not seed demo notes into remote-backed vaults', () => {
expect(shouldSeedDemoVault({
id: 'personal',
name: 'Personal Journal',
remoteUrl: 'https://github.com/refactoringhq/tolaria.git',
})).toBe(false)
})
it('keeps local-only vaults seeded for first-run simulator QA', () => {
expect(shouldSeedDemoVault({ id: 'personal', name: 'Personal Journal' })).toBe(true)
})
})

View File

@@ -13,12 +13,15 @@ import { createNativeMobileVaultStorage } from './mobileNativeVaultStorage'
import { createStoredMobileVaultRepository } from './mobileVaultRepository'
import { seedMobileVaultIfEmpty } from './mobileVaultSeed'
import type { MobileVaultFile } from './mobileVaultStorage'
import { shouldSeedDemoVault } from './mobileDemoVaultSeedPolicy'
export async function loadDemoVaultNotes(vaultMetadata = defaultMobileVaultMetadata) {
const storage = createNativeMobileVaultStorage()
const demoVault = createDemoVaultConfig(vaultMetadata)
await seedMobileVaultIfEmpty({ files: demoVaultFiles(), storage, vault: demoVault })
await addMissingDemoVaultFiles({ files: demoVaultFiles(), storage, vault: demoVault })
if (shouldSeedDemoVault(vaultMetadata)) {
await seedMobileVaultIfEmpty({ files: demoVaultFiles(), storage, vault: demoVault })
await addMissingDemoVaultFiles({ files: demoVaultFiles(), storage, vault: demoVault })
}
return createStoredMobileVaultRepository({ storage, vault: demoVault }).listNotes()
}
@@ -80,6 +83,7 @@ function createDemoVaultConfig(vaultMetadata: MobileVaultMetadata) {
return createMobileVaultConfigFromMetadata(vaultMetadata)
}
function createDemoVaultStorageContext(vaultMetadata: MobileVaultMetadata) {
return {
storage: createNativeMobileVaultStorage(),

View File

@@ -0,0 +1,5 @@
import type { MobileVaultMetadata } from './mobileVaultMetadata'
export function shouldSeedDemoVault(vaultMetadata: MobileVaultMetadata) {
return !vaultMetadata.remoteUrl?.trim()
}

View File

@@ -0,0 +1,310 @@
import { Buffer } from 'buffer'
import { createMobileVaultConfigFromMetadata } from './mobileVaultMetadata'
import type { MobileVaultMetadata } from './mobileVaultMetadata'
import type { ExpoMobileVaultFileInfo, ExpoMobileVaultFileSystem } from './mobileExpoVaultStorage'
import type { PromiseFsClient } from 'isomorphic-git'
type MobileGitStat = {
ctime: Date
ctimeMs: number
dev: number
gid: number
ino: number
isDirectory: () => boolean
isFile: () => boolean
isSymbolicLink: () => boolean
mode: number
mtime: Date
mtimeMs: number
size: number
uid: number
}
type MobileGitReadOptions = 'utf8' | { encoding?: 'base64' | 'utf8' }
type MobileGitWriteOptions = { encoding?: 'base64' | 'utf8' }
type MobileGitFileData = string | Uint8Array
type ExistingPathInput = {
fileSystem: ExpoMobileVaultFileSystem
path: string
rootUri: string
}
export type MobileExpoGitFileSystemContext = {
dir: string
fs: PromiseFsClient
}
export function createMobileExpoGitFileSystem({
fileSystem,
vault,
}: {
fileSystem: ExpoMobileVaultFileSystem
vault: MobileVaultMetadata
}): MobileExpoGitFileSystemContext {
const rootUri = mobileExpoGitVaultRootUri({ fileSystem, vault })
const dir = '/vault'
return {
dir,
fs: {
promises: {
chmod: async () => {},
lstat: (path: string) => statPath({ fileSystem, path, rootUri }),
mkdir: (path: string) => mkdirPath({ fileSystem, path, rootUri }),
readFile: (path: string, options?: MobileGitReadOptions) => readFile({ fileSystem, options, path, rootUri }),
readlink: async () => {
throw createFileSystemError('ENOTSUP', 'Symbolic links are not supported in mobile vaults.')
},
readdir: (path: string) => readDirectory({ fileSystem, path, rootUri }),
rmdir: (path: string) => removeDirectory({ fileSystem, path, rootUri }),
stat: (path: string) => statPath({ fileSystem, path, rootUri }),
symlink: async () => {
throw createFileSystemError('ENOTSUP', 'Symbolic links are not supported in mobile vaults.')
},
unlink: (path: string) => unlinkPath({ fileSystem, path, rootUri }),
writeFile: (path: string, data: MobileGitFileData, options?: MobileGitWriteOptions) =>
writeFile({ data, fileSystem, options, path, rootUri }),
},
},
}
}
export function mobileExpoGitVaultRootUri({
fileSystem,
vault,
}: {
fileSystem: ExpoMobileVaultFileSystem
vault: MobileVaultMetadata
}) {
if (!fileSystem.documentDirectory) {
throw new Error('Expo FileSystem documentDirectory is unavailable')
}
const vaultConfig = createMobileVaultConfigFromMetadata(vault)
return appendUri({
root: fileSystem.documentDirectory,
segments: ['vaults', vaultConfig.storage.directoryName || vaultConfig.id],
})
}
async function mkdirPath({
fileSystem,
path,
rootUri,
}: {
fileSystem: ExpoMobileVaultFileSystem
path: string
rootUri: string
}) {
const uri = uriForPath({ path, rootUri })
const info = await fileSystem.getInfoAsync(uri)
if (info.exists && !info.isDirectory) {
throw createFileSystemError('ENOTDIR', `Path is not a directory: ${path}`)
}
if (!info.exists) {
await fileSystem.makeDirectoryAsync(uri, { intermediates: true })
}
}
async function readDirectory({
fileSystem,
path,
rootUri,
}: ExistingPathInput) {
await existingDirectory({ fileSystem, path, rootUri })
return fileSystem.readDirectoryAsync(uriForPath({ path, rootUri }))
}
async function readFile({
fileSystem,
options,
path,
rootUri,
}: {
fileSystem: ExpoMobileVaultFileSystem
options?: MobileGitReadOptions
path: string
rootUri: string
}) {
const info = await existingInfo({ fileSystem, path, rootUri })
if (info.isDirectory) {
throw createFileSystemError('EISDIR', `Path is a directory: ${path}`)
}
const uri = uriForPath({ path, rootUri })
return readAsUtf8(options)
? fileSystem.readAsStringAsync(uri, { encoding: 'utf8' })
: Buffer.from(await fileSystem.readAsStringAsync(uri, { encoding: 'base64' }), 'base64')
}
async function writeFile({
data,
fileSystem,
options,
path,
rootUri,
}: {
data: MobileGitFileData
fileSystem: ExpoMobileVaultFileSystem
options?: MobileGitWriteOptions
path: string
rootUri: string
}) {
await ensureParentDirectory({ fileSystem, path, rootUri })
if (typeof data === 'string' && writeAsUtf8(options)) {
await fileSystem.writeAsStringAsync(uriForPath({ path, rootUri }), data, { encoding: 'utf8' })
return
}
await fileSystem.writeAsStringAsync(
uriForPath({ path, rootUri }),
dataToBase64(data),
{ encoding: 'base64' },
)
}
async function unlinkPath({
fileSystem,
path,
rootUri,
}: ExistingPathInput) {
await existingFile({ fileSystem, path, rootUri })
await fileSystem.deleteAsync(uriForPath({ path, rootUri }))
}
async function removeDirectory({
fileSystem,
path,
rootUri,
}: ExistingPathInput) {
await existingDirectory({ fileSystem, path, rootUri })
await fileSystem.deleteAsync(uriForPath({ path, rootUri }))
}
async function statPath({
fileSystem,
path,
rootUri,
}: {
fileSystem: ExpoMobileVaultFileSystem
path: string
rootUri: string
}): Promise<MobileGitStat> {
return statForInfo(await existingInfo({ fileSystem, path, rootUri }))
}
async function existingInfo({
fileSystem,
path,
rootUri,
}: ExistingPathInput) {
const info = await fileSystem.getInfoAsync(uriForPath({ path, rootUri }))
if (!info.exists) {
throw createFileSystemError('ENOENT', `Path does not exist: ${path}`)
}
return info
}
async function existingDirectory(input: ExistingPathInput) {
const info = await existingInfo(input)
if (!info.isDirectory) {
throw createFileSystemError('ENOTDIR', `Path is not a directory: ${input.path}`)
}
}
async function existingFile(input: ExistingPathInput) {
const info = await existingInfo(input)
if (info.isDirectory) {
throw createFileSystemError('EISDIR', `Path is a directory: ${input.path}`)
}
}
function statForInfo(info: ExpoMobileVaultFileInfo): MobileGitStat {
const modifiedMs = Math.round((info.modificationTime ?? 0) * 1000)
const modified = new Date(modifiedMs)
const isDirectory = info.isDirectory === true
return {
ctime: modified,
ctimeMs: modifiedMs,
dev: 0,
gid: 0,
ino: 0,
isDirectory: () => isDirectory,
isFile: () => !isDirectory,
isSymbolicLink: () => false,
mode: isDirectory ? 0o040000 : 0o100644,
mtime: modified,
mtimeMs: modifiedMs,
size: info.size ?? 0,
uid: 0,
}
}
async function ensureParentDirectory({
fileSystem,
path,
rootUri,
}: {
fileSystem: ExpoMobileVaultFileSystem
path: string
rootUri: string
}) {
const segments = normalizedSegments(path)
const parentSegments = segments.slice(0, -1)
if (parentSegments.length === 0) {
return
}
await fileSystem.makeDirectoryAsync(appendUri({ root: rootUri, segments: parentSegments }), { intermediates: true })
}
function uriForPath({ path, rootUri }: { path: string; rootUri: string }) {
return appendUri({ root: rootUri, segments: normalizedSegments(path) })
}
function normalizedSegments(path: string) {
const pathWithoutRoot = path.replace(/^\/+vault\/?/, '')
const segments = pathWithoutRoot.replaceAll('\\', '/').split('/').filter(Boolean)
if (segments.some(isUnsafeSegment)) {
throw createFileSystemError('EINVAL', `Unsafe mobile Git path: ${path}`)
}
return segments
}
function isUnsafeSegment(segment: string) {
return segment === '.' || segment === '..' || segment.includes('/')
}
function readAsUtf8(options?: MobileGitReadOptions) {
return options === 'utf8' || (typeof options === 'object' && options.encoding === 'utf8')
}
function writeAsUtf8(options?: MobileGitWriteOptions) {
return options?.encoding === 'utf8'
}
function dataToBase64(data: MobileGitFileData) {
return typeof data === 'string'
? Buffer.from(data, 'utf8').toString('base64')
: Buffer.from(data).toString('base64')
}
function appendUri(input: { root: string; segments: string[] }) {
const base = input.root.replace(/\/+$/, '')
const path = input.segments.filter(Boolean).join('/')
return path ? `${base}/${path}` : base
}
function createFileSystemError(code: string, message: string) {
const error = new Error(message)
return Object.assign(error, { code })
}

View File

@@ -4,6 +4,8 @@ import type { MobileVaultFile, MobileVaultStorageDriver } from './mobileVaultSto
export type ExpoMobileVaultFileInfo = {
exists: boolean
isDirectory?: boolean
modificationTime?: number
size?: number
}
export type ExpoMobileVaultFileSystem = {
@@ -11,15 +13,21 @@ export type ExpoMobileVaultFileSystem = {
documentDirectory: string | null
getInfoAsync: (uri: string) => Promise<ExpoMobileVaultFileInfo>
makeDirectoryAsync: (uri: string, options: { intermediates: true }) => Promise<void>
readAsStringAsync: (uri: string) => Promise<string>
readAsStringAsync: (uri: string, options?: ExpoMobileVaultReadOptions) => Promise<string>
readDirectoryAsync: (uri: string) => Promise<string[]>
writeAsStringAsync: (uri: string, content: string) => Promise<void>
writeAsStringAsync: (uri: string, content: string, options?: ExpoMobileVaultWriteOptions) => Promise<void>
}
type VaultPathInput = {
path: string
}
export type ExpoMobileVaultReadOptions = {
encoding?: 'base64' | 'utf8'
}
export type ExpoMobileVaultWriteOptions = ExpoMobileVaultReadOptions
type DirectoryListingInput = {
fileSystem: ExpoMobileVaultFileSystem
rootUri: string

View File

@@ -58,6 +58,7 @@ function memoryCredentialStorage(): MobileGitCredentialStorage {
let isAvailable = false
return {
loadRecord: async () => null,
loadState: async () => isAvailable ? { state: 'available' } : { state: 'missing' },
remove: async () => {
isAvailable = false
@@ -70,6 +71,7 @@ function memoryCredentialStorage(): MobileGitCredentialStorage {
function noopCredentialStorage(): MobileGitCredentialStorage {
return {
loadRecord: async () => null,
loadState: async () => ({ state: 'missing' }),
remove: async () => {},
saveRecord: async () => {},

View File

@@ -35,6 +35,7 @@ function memoryCredentialStorage(): MobileGitCredentialStorage {
const records = new Map<string, ReturnType<typeof createMobileGitCredentialRecord>>()
return {
loadRecord: async (requirement) => records.get(`${requirement.strategy}:${requirement.host}`) ?? null,
loadState: async (requirement) => records.has(`${requirement.strategy}:${requirement.host}`)
? { state: 'available' }
: { state: 'missing' },
@@ -49,6 +50,9 @@ function memoryCredentialStorage(): MobileGitCredentialStorage {
function failingCredentialStorage(): MobileGitCredentialStorage {
return {
loadRecord: async () => {
throw new Error('should not load credentials')
},
loadState: async () => {
throw new Error('should not load credentials')
},

View File

@@ -19,6 +19,7 @@ export type MobileGitCredentialRecord = {
}
export type MobileGitCredentialStorage = {
loadRecord: (requirement: MobileVaultAuthRequirement) => Promise<MobileGitCredentialRecord | null>
loadState: (requirement: MobileVaultAuthRequirement) => Promise<MobileGitCredentialState>
remove: (requirement: MobileVaultAuthRequirement) => Promise<void>
saveRecord: (record: MobileGitCredentialRecord) => Promise<void>

View File

@@ -55,6 +55,7 @@ function baseActionInput(events: string[]) {
return {
activeOperation: null,
credentialStorage: {
loadRecord: async () => null,
loadState: async () => ({ state: 'missing' as const }),
remove: async () => {},
saveRecord: async () => {},

View File

@@ -20,6 +20,7 @@ export function runMobileGitSyncFlowAction({
createGitHubOAuthSession,
gitSyncPlan,
gitTransport,
onSynced,
refreshCredentials,
setActiveOperation,
setFailure,
@@ -30,6 +31,7 @@ export function runMobileGitSyncFlowAction({
createGitHubOAuthSession: () => MobileGitHubOAuthSession
gitSyncPlan: MobileGitSyncPlan
gitTransport: MobileGitTransport
onSynced?: () => void
refreshCredentials: () => void
setActiveOperation: (operation: MobileGitOperation | null) => void
setFailure: (failure: MobileGitSyncFlowFailure | null) => void
@@ -55,6 +57,7 @@ export function runMobileGitSyncFlowAction({
if (action.state === 'transport') {
runTransportAction({
gitTransport,
onSynced,
operation: action.operation,
remote: action.remote,
setActiveOperation,
@@ -103,6 +106,7 @@ function runAuthenticationAction({
function runTransportAction({
gitTransport,
onSynced,
operation,
remote,
setActiveOperation,
@@ -110,6 +114,7 @@ function runTransportAction({
vault,
}: {
gitTransport: MobileGitTransport
onSynced?: () => void
operation: 'pull' | 'push'
remote: Parameters<typeof runMobileGitTransportOperation>[0]['remote']
setActiveOperation: (operation: MobileGitOperation | null) => void
@@ -127,7 +132,10 @@ function runTransportAction({
.then((result) => {
if (result.state === 'failed') {
setFailure({ message: result.message, operation })
return
}
onSynced?.()
})
.catch(() => setFailure({ message: 'Mobile Git sync failed.', operation }))
.finally(() => setActiveOperation(null))

View File

@@ -10,11 +10,13 @@ import type { MobileVaultMetadata } from './mobileVaultMetadata'
export function createMobileGitSyncPlanForVault({
credentials = { state: 'missing' },
failure,
hasLocalChanges = false,
operation,
vault,
}: {
credentials?: MobileGitCredentialState
failure?: { message: string; operation: MobileGitOperation }
hasLocalChanges?: boolean
operation?: MobileGitOperation
vault: MobileVaultMetadata
}): MobileGitSyncPlan {
@@ -26,6 +28,7 @@ export function createMobileGitSyncPlanForVault({
return createMobileGitSyncPlan({
credentials,
failure,
hasLocalChanges,
operation,
sync: result.config.sync,
})

View File

@@ -16,9 +16,21 @@ export type MobileGitTransportResult =
state: 'failed'
}
export type MobileGitRepositoryStatus =
| {
hasLocalChanges: boolean
isRepository: boolean
state: 'available'
}
| {
message: string
state: 'failed'
}
export type MobileGitTransport = {
pull: (request: MobileGitTransportRequest) => Promise<MobileGitTransportResult>
push: (request: MobileGitTransportRequest) => Promise<MobileGitTransportResult>
status?: (request: MobileGitTransportRequest) => Promise<MobileGitRepositoryStatus>
}
export function createUnavailableMobileGitTransport(): MobileGitTransport {

View File

@@ -0,0 +1,161 @@
import { describe, expect, it } from 'vitest'
import type { StatusRow } from 'isomorphic-git'
import type { MobileGitCredentialStorage } from './mobileGitCredentialStorage'
import type { ExpoMobileVaultFileSystem } from './mobileExpoVaultStorage'
import {
createIsomorphicMobileGitTransport,
type MobileIsoGitClient,
} from './mobileIsomorphicGitTransport'
import type { MobileGitTransportRequest } from './mobileGitTransport'
describe('isomorphic mobile git transport', () => {
it('clones a remote vault when the app-local directory is not a git repository', async () => {
const { events, transport } = transportFixture({ hasRepository: false })
await expect(transport.pull(request())).resolves.toEqual({ state: 'completed' })
expect(events).toEqual(['clone:https://github.com/refactoringhq/tolaria.git'])
})
it('pulls an existing app-local repository', async () => {
const { events, transport } = transportFixture({ hasRepository: true })
await expect(transport.pull(request())).resolves.toEqual({ state: 'completed' })
expect(events).toEqual(['pull'])
})
it('autocommits changed and deleted files before pushing', async () => {
const events: string[] = []
const transport = createIsomorphicMobileGitTransport({
credentialStorage: credentialStorage(),
fileSystem: fileSystem({ hasRepository: true }),
gitClient: gitClient({
events,
statusRows: [
['changed.md', 1, 2, 1],
['deleted.md', 1, 0, 1],
],
}),
})
await expect(transport.push(request())).resolves.toEqual({ state: 'completed' })
expect(events).toEqual(['add:changed.md', 'remove:deleted.md', 'commit', 'push'])
})
it('reports dirty repository state for sync planning', async () => {
const transport = createIsomorphicMobileGitTransport({
credentialStorage: credentialStorage(),
fileSystem: fileSystem({ hasRepository: true }),
gitClient: gitClient({ statusRows: [['changed.md', 1, 2, 1]] }),
})
await expect(transport.status?.(request())).resolves.toEqual({
hasLocalChanges: true,
isRepository: true,
state: 'available',
})
})
})
function transportFixture({
hasRepository,
statusRows,
}: {
hasRepository: boolean
statusRows?: StatusRow[]
}) {
const events: string[] = []
const transport = createIsomorphicMobileGitTransport({
credentialStorage: credentialStorage(),
fileSystem: fileSystem({ hasRepository }),
gitClient: gitClient({ events, statusRows }),
})
return { events, transport }
}
function gitClient({
events = [],
statusRows = [],
}: {
events?: string[]
statusRows?: StatusRow[]
} = {}): MobileIsoGitClient {
return {
add: async ({ filepath }) => {
events.push(`add:${filepath}`)
},
clone: async ({ url }) => {
events.push(`clone:${url}`)
},
commit: async () => {
events.push('commit')
return 'commit-oid'
},
pull: async () => {
events.push('pull')
},
push: async () => {
events.push('push')
return { error: null, ok: true }
},
remove: async ({ filepath }) => {
events.push(`remove:${filepath}`)
},
statusMatrix: async () => statusRows,
}
}
function credentialStorage(): MobileGitCredentialStorage {
return {
loadRecord: async () => ({
host: 'github.com',
kind: 'githubOAuthToken',
secret: {
accessToken: 'github-token',
tokenType: 'bearer',
},
strategy: 'githubOAuth',
storedAt: '2026-05-12T12:00:00.000Z',
}),
loadState: async () => ({ state: 'available' }),
remove: async () => {},
saveRecord: async () => {},
}
}
function fileSystem({ hasRepository }: { hasRepository: boolean }): ExpoMobileVaultFileSystem {
return {
deleteAsync: async () => {},
documentDirectory: 'file:///docs/',
getInfoAsync: async (uri) => ({
exists: uri === 'file:///docs/vaults/personal-journal' || (hasRepository && uri.endsWith('/.git')),
isDirectory: true,
modificationTime: 0,
size: 0,
}),
makeDirectoryAsync: async () => {},
readAsStringAsync: async () => '',
readDirectoryAsync: async () => [],
writeAsStringAsync: async () => {},
}
}
function request(): MobileGitTransportRequest {
return {
remote: {
authStrategy: 'githubOAuth',
host: 'github.com',
owner: 'refactoringhq',
repository: 'tolaria',
url: 'https://github.com/refactoringhq/tolaria.git',
},
vault: {
id: 'personal',
name: 'Personal Journal',
remoteUrl: 'https://github.com/refactoringhq/tolaria.git',
},
}
}

View File

@@ -0,0 +1,256 @@
import * as git from 'isomorphic-git'
import http from 'isomorphic-git/http/web'
import type { AuthCallback, FsClient, HttpClient, StatusRow } from 'isomorphic-git'
import type { ExpoMobileVaultFileSystem } from './mobileExpoVaultStorage'
import { createMobileExpoGitFileSystem } from './mobileExpoGitFileSystem'
import type { MobileGitCredentialStorage } from './mobileGitCredentialStorage'
import type { MobileGitRemote } from './mobileGitRemote'
import type {
MobileGitRepositoryStatus,
MobileGitTransport,
MobileGitTransportRequest,
MobileGitTransportResult,
} from './mobileGitTransport'
export type MobileIsoGitClient = {
add: (input: { dir: string; filepath: string; fs: FsClient }) => Promise<void>
clone: (input: { depth?: number; dir: string; fs: FsClient; http: HttpClient; onAuth: AuthCallback; singleBranch: true; url: string }) => Promise<void>
commit: (input: {
author: { email: string; name: string }
dir: string
fs: FsClient
message: string
}) => Promise<string>
pull: (input: {
author: { email: string; name: string }
dir: string
fastForwardOnly: true
fs: FsClient
http: HttpClient
onAuth: AuthCallback
singleBranch: true
}) => Promise<void>
push: (input: { dir: string; fs: FsClient; http: HttpClient; onAuth: AuthCallback }) => Promise<{ ok: boolean; error: string | null }>
remove: (input: { dir: string; filepath: string; fs: FsClient }) => Promise<void>
statusMatrix: (input: { dir: string; fs: FsClient }) => Promise<StatusRow[]>
}
export function createIsomorphicMobileGitTransport({
credentialStorage,
fileSystem,
gitClient = defaultGitClient,
httpClient = http,
}: {
credentialStorage: MobileGitCredentialStorage
fileSystem: ExpoMobileVaultFileSystem
gitClient?: MobileIsoGitClient
httpClient?: HttpClient
}): MobileGitTransport {
return {
pull: (request) => runGitOperation(() => pullOrClone({ credentialStorage, fileSystem, gitClient, httpClient, request })),
push: (request) => runGitOperation(() => commitAndPush({ credentialStorage, fileSystem, gitClient, httpClient, request })),
status: (request) => repositoryStatus({ fileSystem, gitClient, request }),
}
}
async function pullOrClone({
credentialStorage,
fileSystem,
gitClient,
httpClient,
request,
}: {
credentialStorage: MobileGitCredentialStorage
fileSystem: ExpoMobileVaultFileSystem
gitClient: MobileIsoGitClient
httpClient: HttpClient
request: MobileGitTransportRequest
}) {
const context = createMobileExpoGitFileSystem({ fileSystem, vault: request.vault })
const onAuth = await authForRequest({ credentialStorage, remote: request.remote })
if (await isGitRepository(context)) {
await gitClient.pull({
author: mobileGitAuthor(),
dir: context.dir,
fastForwardOnly: true,
fs: context.fs,
http: httpClient,
onAuth,
singleBranch: true,
})
return
}
await gitClient.clone({
depth: 1,
dir: context.dir,
fs: context.fs,
http: httpClient,
onAuth,
singleBranch: true,
url: request.remote.url,
})
}
async function commitAndPush({
credentialStorage,
fileSystem,
gitClient,
httpClient,
request,
}: {
credentialStorage: MobileGitCredentialStorage
fileSystem: ExpoMobileVaultFileSystem
gitClient: MobileIsoGitClient
httpClient: HttpClient
request: MobileGitTransportRequest
}) {
const context = createMobileExpoGitFileSystem({ fileSystem, vault: request.vault })
if (!await isGitRepository(context)) {
throw new Error('Pull the remote vault before pushing mobile changes.')
}
await commitLocalChanges({ context, gitClient })
const result = await gitClient.push({
dir: context.dir,
fs: context.fs,
http: httpClient,
onAuth: await authForRequest({ credentialStorage, remote: request.remote }),
})
if (!result.ok) {
throw new Error(result.error ?? 'Git push failed.')
}
}
async function repositoryStatus({
fileSystem,
gitClient,
request,
}: {
fileSystem: ExpoMobileVaultFileSystem
gitClient: MobileIsoGitClient
request: MobileGitTransportRequest
}): Promise<MobileGitRepositoryStatus> {
try {
const context = createMobileExpoGitFileSystem({ fileSystem, vault: request.vault })
if (!await isGitRepository(context)) {
return { hasLocalChanges: false, isRepository: false, state: 'available' }
}
return {
hasLocalChanges: hasChangedRows(await gitClient.statusMatrix({ dir: context.dir, fs: context.fs })),
isRepository: true,
state: 'available',
}
} catch {
return { message: 'Could not inspect mobile Git repository state.', state: 'failed' }
}
}
async function commitLocalChanges({
context,
gitClient,
}: {
context: MobileExpoGitContext
gitClient: MobileIsoGitClient
}) {
const matrix = await gitClient.statusMatrix({ dir: context.dir, fs: context.fs })
const changedRows = matrix.filter(isChangedRow)
await Promise.all(changedRows.map((row) => stageStatusRow({ context, gitClient, row })))
if (changedRows.length > 0) {
await gitClient.commit({
author: mobileGitAuthor(),
dir: context.dir,
fs: context.fs,
message: `Mobile sync ${new Date().toISOString()}`,
})
}
}
async function stageStatusRow({
context,
gitClient,
row,
}: {
context: MobileExpoGitContext
gitClient: MobileIsoGitClient
row: StatusRow
}) {
const filepath = row[0]
if (row[2] === 0) {
await gitClient.remove({ dir: context.dir, filepath, fs: context.fs })
return
}
await gitClient.add({ dir: context.dir, filepath, fs: context.fs })
}
async function authForRequest({
credentialStorage,
remote,
}: {
credentialStorage: MobileGitCredentialStorage
remote: MobileGitRemote
}): Promise<AuthCallback> {
if (remote.authStrategy !== 'githubOAuth') {
throw new Error('Mobile Git sync currently supports GitHub OAuth remotes only.')
}
const record = await credentialStorage.loadRecord({ host: remote.host, strategy: remote.authStrategy })
if (!record?.secret?.accessToken) {
throw new Error('GitHub credentials are missing. Connect GitHub before syncing.')
}
return () => ({ username: record.secret?.accessToken })
}
async function isGitRepository(context: MobileExpoGitContext) {
try {
await context.fs.promises.stat(`${context.dir}/.git`)
return true
} catch {
return false
}
}
async function runGitOperation(operation: () => Promise<void>): Promise<MobileGitTransportResult> {
try {
await operation()
return { state: 'completed' }
} catch (error) {
return { message: gitFailureMessage(error), state: 'failed' }
}
}
function hasChangedRows(rows: StatusRow[]) {
return rows.some(isChangedRow)
}
function isChangedRow(row: StatusRow) {
return row[1] !== row[2] || row[2] !== row[3]
}
function mobileGitAuthor() {
return {
email: 'mobile@tolaria.local',
name: 'Tolaria Mobile',
}
}
function gitFailureMessage(error: unknown) {
return error instanceof Error ? error.message : 'Mobile Git sync failed.'
}
type MobileExpoGitContext = ReturnType<typeof createMobileExpoGitFileSystem>
const defaultGitClient: MobileIsoGitClient = {
add: git.add,
clone: git.clone,
commit: git.commit,
pull: git.pull,
push: git.push,
remove: git.remove,
statusMatrix: git.statusMatrix,
}

View File

@@ -0,0 +1,10 @@
import * as ExpoFileSystem from 'expo-file-system/legacy'
import type { MobileGitCredentialStorage } from './mobileGitCredentialStorage'
import { createIsomorphicMobileGitTransport } from './mobileIsomorphicGitTransport'
export function createNativeIsomorphicMobileGitTransport(credentialStorage: MobileGitCredentialStorage) {
return createIsomorphicMobileGitTransport({
credentialStorage,
fileSystem: ExpoFileSystem,
})
}

View File

@@ -17,10 +17,15 @@ describe('createMobileSecureGitCredentialStorage', () => {
}))
await expect(storage.loadState(requirement)).resolves.toEqual({ state: 'available' })
await expect(storage.loadRecord(requirement)).resolves.toMatchObject({
host: 'github.com',
kind: 'githubOAuthToken',
})
await storage.remove(requirement)
await expect(storage.loadState(requirement)).resolves.toEqual({ state: 'missing' })
await expect(storage.loadRecord(requirement)).resolves.toBeNull()
})
it('treats malformed secure-store content as missing credentials', async () => {

View File

@@ -17,6 +17,8 @@ export function createMobileSecureGitCredentialStorage(
secureStore: MobileSecureStore,
): MobileGitCredentialStorage {
return {
loadRecord: async (requirement) =>
parseMobileGitCredentialRecord(await secureStore.getItemAsync(createMobileGitCredentialKey(requirement))),
loadState: async (requirement) => mobileGitCredentialState({
record: parseMobileGitCredentialRecord(await secureStore.getItemAsync(createMobileGitCredentialKey(requirement))),
requirement,

View File

@@ -7,53 +7,161 @@ import { runMobileGitSyncFlowAction, type MobileGitSyncFlowFailure } from './mob
import type { MobileGitTransport } from './mobileGitTransport'
import type { MobileGitHubOAuthSession } from './mobileGitHubOAuthFlow'
import type { MobileVaultMetadata } from './mobileVaultMetadata'
import { createMobileVaultConfig } from './mobileVaultConfig'
export function useMobileGitSyncFlow({
createGitHubOAuthSession,
credentialStorage,
gitTransport,
onSynced,
vault,
}: {
createGitHubOAuthSession: () => MobileGitHubOAuthSession
credentialStorage: MobileGitCredentialStorage
gitTransport: MobileGitTransport
onSynced?: () => void
vault: MobileVaultMetadata
}) {
const [failure, setFailure] = useState<MobileGitSyncFlowFailure | null>(null)
const [credentials, setCredentials] = useState<MobileGitCredentialState>({ state: 'missing' })
const [activeOperation, setActiveOperation] = useState<MobileGitOperation | null>(null)
const { credentials, refreshCredentials } = useMobileGitCredentials({ credentialStorage, vault })
const repositoryStatus = useMobileGitRepositoryStatus({ gitTransport, vault })
const gitSyncPlan = useMemo(
() => createMobileGitSyncPlanForVault({
credentials,
...(failure ? { failure } : {}),
hasLocalChanges: repositoryStatus.hasLocalChanges,
...(activeOperation ? { operation: activeOperation } : {}),
vault,
}),
[activeOperation, credentials, failure, repositoryStatus.hasLocalChanges, vault],
)
const runPrimaryAction = useMobileGitPrimaryAction({
activeOperation,
createGitHubOAuthSession,
credentialStorage,
gitSyncPlan,
gitTransport,
onSynced,
refreshCredentials,
repositoryStatus,
setActiveOperation,
setFailure,
vault,
})
useEffect(refreshCredentials, [refreshCredentials])
useEffect(repositoryStatus.refresh, [repositoryStatus.refresh])
return {
gitSyncPlan,
markLocalChanges: repositoryStatus.markLocalChanges,
refreshRepositoryStatus: repositoryStatus.refresh,
runPrimaryAction,
}
}
function useMobileGitCredentials({
credentialStorage,
vault,
}: {
credentialStorage: MobileGitCredentialStorage
vault: MobileVaultMetadata
}) {
const [credentials, setCredentials] = useState<MobileGitCredentialState>({ state: 'missing' })
const refreshCredentials = useCallback(() => {
void loadMobileGitCredentialStateForVault({ credentialStorage, vault })
.then(setCredentials)
.catch(() => setCredentials({ state: 'missing' }))
}, [credentialStorage, vault])
const gitSyncPlan = useMemo(
() => createMobileGitSyncPlanForVault({
credentials,
...(failure ? { failure } : {}),
...(activeOperation ? { operation: activeOperation } : {}),
vault,
}),
[activeOperation, credentials, failure, vault],
)
const runPrimaryAction = useCallback(() => {
return { credentials, refreshCredentials }
}
function useMobileGitRepositoryStatus({
gitTransport,
vault,
}: {
gitTransport: MobileGitTransport
vault: MobileVaultMetadata
}) {
const [hasLocalChanges, setHasLocalChanges] = useState(false)
const refresh = useCallback(() => {
const remote = mobileGitRemoteForVault(vault)
if (!gitTransport.status || !remote) {
setHasLocalChanges(false)
return
}
void gitTransport.status({ remote, vault })
.then((status) => setHasLocalChanges(status.state === 'available' && status.hasLocalChanges))
.catch(() => setHasLocalChanges(false))
}, [gitTransport, vault])
const markLocalChanges = useCallback(() => setHasLocalChanges(true), [])
return { hasLocalChanges, markLocalChanges, refresh, setHasLocalChanges }
}
function useMobileGitPrimaryAction({
activeOperation,
createGitHubOAuthSession,
credentialStorage,
gitSyncPlan,
gitTransport,
onSynced,
refreshCredentials,
repositoryStatus,
setActiveOperation,
setFailure,
vault,
}: {
activeOperation: MobileGitOperation | null
createGitHubOAuthSession: () => MobileGitHubOAuthSession
credentialStorage: MobileGitCredentialStorage
gitSyncPlan: ReturnType<typeof createMobileGitSyncPlanForVault>
gitTransport: MobileGitTransport
onSynced?: () => void
refreshCredentials: () => void
repositoryStatus: ReturnType<typeof useMobileGitRepositoryStatus>
setActiveOperation: (operation: MobileGitOperation | null) => void
setFailure: (failure: MobileGitSyncFlowFailure | null) => void
vault: MobileVaultMetadata
}) {
return useCallback(() => {
runMobileGitSyncFlowAction({
activeOperation,
credentialStorage,
createGitHubOAuthSession,
gitSyncPlan,
gitTransport,
onSynced: () => {
repositoryStatus.setHasLocalChanges(false)
repositoryStatus.refresh()
onSynced?.()
},
refreshCredentials,
setActiveOperation,
setFailure,
vault,
})
}, [activeOperation, createGitHubOAuthSession, credentialStorage, gitSyncPlan, gitTransport, refreshCredentials, vault])
useEffect(refreshCredentials, [refreshCredentials])
return {
}, [
activeOperation,
createGitHubOAuthSession,
credentialStorage,
gitSyncPlan,
runPrimaryAction,
}
gitTransport,
onSynced,
refreshCredentials,
repositoryStatus,
setActiveOperation,
setFailure,
vault,
])
}
function mobileGitRemoteForVault(vault: MobileVaultMetadata) {
const result = createMobileVaultConfig(vault)
return result.ok && result.config.sync.state === 'remoteReady'
? result.config.sync.remote
: null
}

View File

@@ -464,11 +464,11 @@ interface PulseCommit {
### Mobile Git Transport
The mobile app routes Git pull/push through `MobileGitTransport`. React Native UI and sync state code depend only on this TypeScript contract; the native implementation is introduced behind `createNativeMobileGitTransport`.
The mobile app routes Git pull/push through `MobileGitTransport`. React Native UI and sync state code depend only on this TypeScript contract; transport implementations can be JavaScript or native as long as they keep credentials behind the credential storage boundary and never place tokens in remote URLs.
`createNativeMobileGitTransport` resolves the optional Expo native module named `TolariaGit` through `expo-modules-core`. Until that module is wired into Expo development builds, the native transport adapter returns a clear unavailable-module failure. This keeps authentication, sync status, retry UI, and future libgit2/Rust work testable without pretending that a JavaScript fallback is production Git.
`createIsomorphicMobileGitTransport` is the current usable transport. It uses `isomorphic-git` plus an Expo FileSystem adapter rooted at the app-managed vault directory. For GitHub HTTPS remotes, it loads the OAuth token from SecureStore, clones the repository when `.git` is missing, uses fast-forward-only pull for existing repositories, and autocommits changed/deleted files before push.
The native boundary request is intentionally narrow for the first spike: active vault id, app-managed vault directory name, remote URL, remote host, and required auth strategy. Credentials stay behind SecureStore/OAuth/native Git credential callbacks, and remote URLs must not embed tokens.
`createNativeMobileGitTransport` still resolves the optional Expo native module named `TolariaGit` through `expo-modules-core`. That native boundary remains the later replacement path for SSH, non-GitHub remotes, conflict resolution, and performance work that proves unsuitable for the JavaScript transport.
### Auto-Sync

View File

@@ -124,15 +124,18 @@ This file is the resumable working log for Tolaria mobile. The strategy and road
- Tightened relationship writes so mobile relationship additions save canonical wikilink refs, dedupe by canonical target, resolve aliases/plain titles for chip display/opening, and avoid introducing new loose id/string values from the properties UI.
- Demoted saved views from the primary mobile sidebar until real persisted view definitions exist; fixture nested-view definitions remain available to tests but are no longer exposed as if they were desktop-equivalent saved views.
- Continued the properties quality pass by grouping the panel into system, relationships, custom properties, info, and history; adding read-only derived inverse relationship groups; and demoting custom scalar properties to read-only until typed mobile controls exist.
- Added the first usable mobile Git transport with `isomorphic-git` and an Expo FileSystem adapter: GitHub OAuth remotes can clone into app-local vault storage, fast-forward pull, autocommit local changes, and push.
- Disabled demo vault seeding for remote-backed vaults so cloned real vaults are not polluted with fixture notes.
- Created [ADR-0116](./adr/0116-isomorphic-git-for-mobile-real-vault-sync.md) for the JavaScript Git transport decision and its native-replacement boundary.
- Set the booted iPad simulator keyboard preferences to Italian (`it_IT@sw=QWERTY;hw=Automatic`).
## Next Action
Continue Phase 4 as a quality remediation pass before new feature work:
1. Run iPad simulator QA over sidebar filters, favorites, properties sections, relationship add/remove, raw wikilink autocomplete, and AI/settings input states.
2. Add real persisted view definition loading before re-exposing Views in the sidebar.
3. Implement usable native Git support so a real remote-backed vault can be cloned/synced locally.
1. Run simulator QA for the real-vault sync path: configure a GitHub remote, connect OAuth, clone, inspect loaded notes, edit locally, push, and relaunch.
2. Run iPad simulator QA over sidebar filters, favorites, properties sections, relationship add/remove, raw wikilink autocomplete, AI/settings input states, and Git status transitions.
3. Add real persisted view definition loading before re-exposing Views in the sidebar.
4. Install a simulator runtime matching the active Xcode SDK, or switch Xcode to one matching the installed iOS 17.5/18.6 runtimes, then retry the iOS development-client build.
## Verification Log
@@ -444,6 +447,12 @@ Continue Phase 4 as a quality remediation pass before new feature work:
- CodeScene after the direct-create/properties editability update: `apps/mobile/src/MobileApp.tsx`, `apps/mobile/src/MobileEditablePropertyPickers.tsx`, `apps/mobile/src/MobileEditorAdapter.tsx`, `apps/mobile/src/MobileEditorBreadcrumb.tsx`, `apps/mobile/src/MobilePropertiesPanel.tsx`, `apps/mobile/src/mobileNoteProperties.ts`, `apps/mobile/src/mobilePropertyPicker.ts`, `apps/mobile/src/styles/breadcrumbStyles.ts`, `apps/mobile/src/styles/commonStyles.ts`, `apps/mobile/src/styles/editorStyles.ts`, `apps/mobile/src/styles/noteListStyles.ts`, `apps/mobile/src/styles/propertyChipStyles.ts`, and `apps/mobile/src/useMobileNoteCreateFlow.ts` scored `10`; `apps/mobile/src/styles.ts` reported no scorable score.
- CodeScene pre-commit safeguard passed after splitting the new styles.
- `pnpm --filter @tolaria/mobile exec expo export --platform ios --output-dir /tmp/tolaria-mobile-export` passed after the direct-create/properties editability update.
- `pnpm --filter @tolaria/mobile exec expo install --check` passed after aligning Expo SDK 55 patch dependencies and adding the mobile Git transport dependencies.
- `pnpm --filter @tolaria/mobile typecheck` passed after the isomorphic-git mobile sync transport.
- `pnpm --filter @tolaria/mobile test` passed after the isomorphic-git mobile sync transport: 59 files / 191 tests.
- CodeScene after the isomorphic-git mobile sync transport: `apps/mobile/src/MobileApp.tsx`, `apps/mobile/src/mobileIsomorphicGitTransport.ts`, `apps/mobile/src/mobileExpoGitFileSystem.ts`, `apps/mobile/src/useMobileGitSyncFlow.ts`, `apps/mobile/src/mobileGitSyncFlowAction.ts`, `apps/mobile/src/mobileGitTransport.ts`, `apps/mobile/src/mobileIsomorphicGitTransport.test.ts`, `apps/mobile/src/mobileNativeIsomorphicGitTransport.ts`, `apps/mobile/src/mobileDemoVaultSeedPolicy.ts`, `apps/mobile/src/mobileDemoVault.test.ts`, `apps/mobile/src/mobileSecureGitCredentialStorage.ts`, `apps/mobile/src/mobileDemoVault.ts`, `apps/mobile/src/mobileGitCredentialStorage.ts`, and the touched credential/sync tests scored `10`; `apps/mobile/src/mobileGitSyncRuntimePlan.ts` reported no scorable score.
- CodeScene pre-commit safeguard passed after the isomorphic-git mobile sync transport.
- `pnpm --filter @tolaria/mobile exec expo export --platform ios --output-dir /tmp/tolaria-mobile-export-git-sync` passed after the isomorphic-git mobile sync transport.
## Risks / Watch Items

View File

@@ -0,0 +1,29 @@
# ADR-0116: Isomorphic-Git for Mobile Real-Vault Sync
Date: 2026-05-12
## Status
Accepted
## Context
Tolaria mobile needs a path to clone and sync a real vault before the native `TolariaGit` module is available. The Expo development-client build is still blocked locally by an Xcode simulator runtime mismatch, but the product needs simulator-testable vault sync now so iPad and iPhone workflow quality can be evaluated against real Markdown content.
The existing mobile architecture already has app-local vault storage through Expo FileSystem, OAuth through Expo AuthSession, and GitHub tokens stored in SecureStore. What is missing is a Git transport that can use those boundaries without shelling out to terminal Git.
## Decision
Use `isomorphic-git` as the first mobile Git implementation behind the existing `MobileGitTransport` contract. Add a small Expo FileSystem adapter that maps the app-managed vault directory to the promise-style filesystem API expected by `isomorphic-git`.
The first production path supports GitHub HTTPS remotes authenticated by the stored OAuth token. Pull clones the repository into app-local vault storage when `.git` is missing, then uses fast-forward-only pull for existing repositories. Push stages changed/deleted files, creates an automatic mobile checkpoint commit, and pushes through the same OAuth credential callback.
Keep the native `TolariaGit` boundary available for later replacement if libgit2/Rust becomes necessary for performance, SSH, conflict handling, or full desktop parity.
## Consequences
- Real GitHub-backed vaults can be cloned and exercised in the simulator without waiting for a native build.
- OTA updates can improve the JavaScript transport and sync UX, as long as no new native dependency is introduced.
- The first sync path is GitHub OAuth over HTTPS only; SSH and arbitrary Git hosts remain a later native/credential-management slice.
- Conflict resolution remains deliberately out of scope for this slice. Pull is fast-forward-only so divergent histories fail visibly instead of inventing mobile merge behavior.
- The demo seed path must stay disabled for remote-backed vaults, otherwise cloned vaults would be polluted with fixture notes.

658
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff