Skip to content

Commit 01e50a7

Browse files
feat: add bundle-based OCM mirror sync (#289)
* feat: add bundle-based OCM mirror sync * feat: add OpenCode model editor and HEAD-safe bundle import * fix: consolidate discovery-cache, batch bundle refs, remove dead code (#290) * fix: consolidate discovery-cache, batch bundle refs, remove dead code * refactor: consolidate discovery cache into generic discoverCached, extract spawnGit helper * feat: extract project-id-resolver, add CLI full-fallback prompt, wire lint into root scripts - Extract shared project-id-resolver for origin URL matching (replaces local-repo duplicates) - Add promptYesNo / confirmFullFallback to ocm CLI before slow full mirror - Backend: consolidate opencode-workspaces routes with project-id-resolver - Frontend: fix schedules prompt/global page navigation - CLI: add eslint config, lint/lint:fix scripts, fix no-fallthrough in ocm.ts - Root: wire CLI into build, test, typecheck, lint, lint:fix chains - Fix test lint issues (_code, const, require→import) * fix: replace polynomial regex with linear-time string ops in gitRemoteParts * feat: add divergence guards for push/pull, streaming bundle upload, server-side ancestry check - Backend: GET /:repoId/mirror/head (head+branch+dirty) and GET /:repoId/mirror/contains/:sha (ancestry check) - CLI: checkPushDivergence warns before overwriting server commits; checkPullDivergence warns before discarding local commits; both require confirm or --force - Bundle upload now streams from disk instead of buffering entire file in memory - Version visible in --help and default (no-arg) output - Version bump 0.1.5 → 0.2.0 * fix: show connecting message before fetching repos in default command * fix: update default command messages to reflect actual matching behavior
1 parent bb1df25 commit 01e50a7

34 files changed

Lines changed: 2059 additions & 315 deletions

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ For OAuth, Passkeys, Push Notifications (VAPID), and advanced configuration, see
9999

100100
## `ocm` CLI
101101

102-
OpenCode Manager ships an `ocm` CLI (from `ocm-cli/`) that attaches your local OpenCode TUI to a repo hosted on the Manager. It lists ready repos, attaches via the Manager's `/api/opencode-proxy` (so prompts run on the Manager's filesystem against a single shared OpenCode server), and can tarball-sync the working tree up or down with `ocm push` / `ocm pull`. Running `ocm` inside a local clone auto-detects the matching Manager repo by `origin` URL.
102+
OpenCode Manager ships an `ocm` CLI (from `ocm-cli/`) that attaches your local OpenCode TUI to a repo hosted on the Manager. It lists ready repos, attaches via the Manager's `/api/opencode-proxy` (so prompts run on the Manager's filesystem against a single shared OpenCode server), and can sync the working tree up or down with `ocm push` / `ocm pull` (fast git bundle + working-tree patch by default; pass `--full` for the legacy tarball mirror). Running `ocm` inside a local clone auto-detects the matching Manager repo by `origin` URL.
103103

104104
See the [`ocm` CLI guide](docs/ocm-cli.md) for setup and commands.
105105

backend/src/routes/internal/opencode-workspaces.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,19 @@
11
import { Hono } from 'hono'
22
import type { Database } from 'bun:sqlite'
33
import { listRepos } from '../../db/queries'
4+
import { resolveProjectId } from '../../services/project-id-resolver'
45
import { logger } from '../../utils/logger'
56
import { getErrorMessage } from '../../utils/error-utils'
67
import path from 'path'
78

89
export function createInternalOpenCodeWorkspacesRoutes(db: Database) {
910
const app = new Hono()
1011

11-
app.get('/', (c) => {
12+
app.get('/', async (c) => {
1213
try {
13-
const repos = listRepos(db)
14-
const workspaces = repos
15-
.filter((repo) => repo.cloneStatus === 'ready')
16-
.map((repo) => ({
14+
const repos = listRepos(db).filter((repo) => repo.cloneStatus === 'ready')
15+
const workspaces = await Promise.all(
16+
repos.map(async (repo) => ({
1717
repoId: repo.id,
1818
name: repo.repoUrl
1919
? repo.repoUrl.split('/').slice(-1)[0]?.replace('.git', '') || repo.localPath
@@ -24,12 +24,14 @@ export function createInternalOpenCodeWorkspacesRoutes(db: Database) {
2424
cloneStatus: repo.cloneStatus,
2525
directory: repo.fullPath,
2626
originUrl: repo.repoUrl ?? null,
27+
projectId: await resolveProjectId(repo.fullPath).catch(() => null),
2728
extra: {
2829
repoId: repo.id,
2930
localPath: repo.localPath,
3031
fullPath: repo.fullPath,
3132
},
32-
}))
33+
})),
34+
)
3335
return c.json({ workspaces })
3436
} catch (error) {
3537
logger.error('Failed to list opencode workspaces:', error)

backend/src/routes/internal/repo-mirror.ts

Lines changed: 263 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Hono } from 'hono'
22
import type { Database } from 'bun:sqlite'
33
import { spawn } from 'child_process'
4-
import { createWriteStream } from 'fs'
4+
import { copyFileSync, createReadStream, createWriteStream, existsSync } from 'fs'
55
import { mkdirSync, mkdtempSync, writeFileSync } from 'fs'
66
import { Readable } from 'stream'
77
import { pipeline } from 'stream/promises'
@@ -19,6 +19,7 @@ import {
1919
readUploadMeta,
2020
deleteUploadSession,
2121
getPartPath,
22+
getStagingRoot,
2223
extractPartsToStaging,
2324
atomicSwapIntoPlace,
2425
carryOverIgnoredFiles,
@@ -42,8 +43,97 @@ interface CommitBody {
4243
gzip?: boolean
4344
}
4445

46+
interface PatchBody {
47+
baseHead?: string | null
48+
patch?: string
49+
force?: boolean
50+
}
51+
4552
const LEGACY_UPGRADE_MESSAGE = 'this ocm CLI is too old for this server; upgrade to ocm-cli >= 0.1.2 (the mirror upload protocol changed to chunked uploads)'
4653

54+
function gitRaw(repoPath: string, args: string[], env: NodeJS.ProcessEnv = process.env, input?: string): Promise<string> {
55+
return new Promise((resolve, reject) => {
56+
const child = spawn('git', args, { cwd: repoPath, env })
57+
let stdout = ''
58+
let stderr = ''
59+
child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString() })
60+
child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString() })
61+
child.on('error', reject)
62+
child.on('close', (code) => {
63+
if (code === 0) resolve(stdout)
64+
else reject(new Error(stderr.trim() || `git exited with code ${code}`))
65+
})
66+
if (input !== undefined) child.stdin.end(input)
67+
})
68+
}
69+
70+
async function createMirrorPatch(fullPath: string): Promise<string> {
71+
const untracked = (await gitRaw(fullPath, ['ls-files', '--others', '--exclude-standard', '-z']).catch(() => ''))
72+
.split('\0')
73+
.filter(Boolean)
74+
if (untracked.length === 0) return gitRaw(fullPath, ['diff', '--binary', 'HEAD', '--'])
75+
76+
const indexPath = (await safeGitOut(fullPath, ['rev-parse', '--git-path', 'index']))?.trim()
77+
const tempIndexDir = mkdtempSync(join(getReposPath(), '.ocm-index-'))
78+
const tempIndex = join(tempIndexDir, 'index')
79+
const env = { ...process.env, GIT_INDEX_FILE: tempIndex }
80+
81+
try {
82+
if (indexPath && existsSync(join(fullPath, indexPath))) {
83+
copyFileSync(join(fullPath, indexPath), tempIndex)
84+
}
85+
await gitRaw(fullPath, ['add', '-N', '--', ...untracked], env)
86+
return gitRaw(fullPath, ['diff', '--binary', 'HEAD', '--'], env)
87+
} finally {
88+
await fsp.rm(tempIndexDir, { recursive: true, force: true }).catch(() => {})
89+
}
90+
}
91+
92+
async function applyMirrorPatch(fullPath: string, patch: string): Promise<void> {
93+
if (!patch) return
94+
await gitRaw(fullPath, ['apply', '--binary', '--whitespace=nowarn', '-'], process.env, patch)
95+
}
96+
97+
async function importBundle(fullPath: string, bundlePath: string, branch: string | null): Promise<void> {
98+
await gitRaw(fullPath, ['fetch', bundlePath, '+refs/heads/*:refs/remotes/ocm-sync/*', '+refs/tags/*:refs/tags/*'])
99+
const refs = await gitRaw(fullPath, ['for-each-ref', '--format=%(refname:strip=3) %(objectname)', 'refs/remotes/ocm-sync'])
100+
const updates: string[] = []
101+
for (const line of refs.split('\n')) {
102+
const trimmed = line.trim()
103+
if (!trimmed) continue
104+
const firstSpace = trimmed.indexOf(' ')
105+
if (firstSpace === -1) continue
106+
const name = trimmed.slice(0, firstSpace)
107+
if (name === 'HEAD') continue
108+
const sha = trimmed.slice(firstSpace + 1)
109+
updates.push(`update refs/heads/${name} ${sha}\n`)
110+
}
111+
if (updates.length > 0) {
112+
await gitRaw(fullPath, ['update-ref', '--stdin'], process.env, updates.join(''))
113+
}
114+
115+
if (branch) {
116+
await gitRaw(fullPath, ['checkout', branch])
117+
const head = (await gitRaw(fullPath, ['rev-parse', `refs/remotes/ocm-sync/${branch}`])).trim()
118+
if (head) await gitRaw(fullPath, ['reset', '--hard', head])
119+
}
120+
121+
const syncRefsOut = await gitRaw(fullPath, ['for-each-ref', '--format=%(refname)', 'refs/remotes/ocm-sync']).catch(() => '')
122+
const deletes = syncRefsOut.split('\n').map((l) => l.trim()).filter(Boolean).map((ref) => `delete ${ref}\n`)
123+
if (deletes.length > 0) {
124+
await gitRaw(fullPath, ['update-ref', '--stdin'], process.env, deletes.join('')).catch(() => {})
125+
}
126+
}
127+
128+
async function createBundle(fullPath: string): Promise<string> {
129+
const stagingRoot = getStagingRoot()
130+
mkdirSync(stagingRoot, { recursive: true })
131+
const bundleDir = mkdtempSync(join(stagingRoot, 'bundle-'))
132+
const bundlePath = join(bundleDir, 'repo.bundle')
133+
await gitRaw(fullPath, ['bundle', 'create', bundlePath, '--all'])
134+
return bundlePath
135+
}
136+
47137
export function createInternalRepoMirrorRoutes(db: Database) {
48138
const app = new Hono()
49139

@@ -214,6 +304,177 @@ export function createInternalRepoMirrorRoutes(db: Database) {
214304
return c.json({ ok: true })
215305
})
216306

307+
app.get('/:repoId/mirror/bundle', async (c) => {
308+
const repoIdRaw = c.req.param('repoId')
309+
const repoId = Number(repoIdRaw)
310+
if (!Number.isFinite(repoId)) return c.json({ error: 'invalid repoId' }, 400)
311+
const repo = getRepoById(db, repoId)
312+
if (!repo) return c.json({ error: 'repo not found' }, 404)
313+
314+
let bundlePath: string | undefined
315+
try {
316+
bundlePath = await createBundle(repo.fullPath)
317+
const stream = createReadStream(bundlePath)
318+
stream.on('close', () => {
319+
if (bundlePath) fsp.rm(join(bundlePath, '..'), { recursive: true, force: true }).catch(() => {})
320+
})
321+
return new Response(Readable.toWeb(stream) as ReadableStream, {
322+
headers: { 'Content-Type': 'application/octet-stream' },
323+
})
324+
} catch (error) {
325+
logger.error('mirror bundle download failed:', error)
326+
if (bundlePath) await fsp.rm(join(bundlePath, '..'), { recursive: true, force: true }).catch(() => {})
327+
return c.json({ error: getErrorMessage(error) }, 500)
328+
}
329+
})
330+
331+
app.post('/:repoId/mirror/bundle', async (c) => {
332+
const repoIdRaw = c.req.param('repoId')
333+
const repoId = Number(repoIdRaw)
334+
if (!Number.isFinite(repoId)) return c.json({ error: 'invalid repoId' }, 400)
335+
const repo = getRepoById(db, repoId)
336+
if (!repo) return c.json({ error: 'repo not found' }, 404)
337+
if (isRepoInUse(db, repoId) && c.req.query('force') !== '1') {
338+
return c.json({ error: 'repo_in_use', message: 'open OpenCode sessions are using this repo; rerun with force=1' }, 409)
339+
}
340+
341+
const rawBody = c.req.raw.body
342+
if (!rawBody) return c.json({ error: 'no body provided' }, 400)
343+
344+
const stagingRoot = getStagingRoot()
345+
mkdirSync(stagingRoot, { recursive: true })
346+
const bundleDir = mkdtempSync(join(stagingRoot, 'bundle-upload-'))
347+
const bundlePath = join(bundleDir, 'repo.bundle')
348+
const branch = c.req.header('x-ocm-branch')?.trim() || null
349+
350+
try {
351+
const body = Readable.fromWeb(rawBody as unknown as Parameters<typeof Readable.fromWeb>[0])
352+
await pipeline(body, createWriteStream(bundlePath))
353+
await importBundle(repo.fullPath, bundlePath, branch)
354+
355+
const branchName = await safeGitOut(repo.fullPath, ['rev-parse', '--abbrev-ref', 'HEAD'])
356+
const head = await safeGitOut(repo.fullPath, ['rev-parse', 'HEAD'])
357+
if (branchName) updateRepoBranch(db, repoId, branchName.trim())
358+
updateLastPulled(db, repoId)
359+
360+
return c.json({
361+
repoId,
362+
fullPath: repo.fullPath,
363+
branch: branchName?.trim() || null,
364+
head: head?.trim() || null,
365+
created: false,
366+
})
367+
} catch (error) {
368+
logger.error('mirror bundle upload failed:', error)
369+
return c.json({ error: getErrorMessage(error) }, 409)
370+
} finally {
371+
await fsp.rm(bundleDir, { recursive: true, force: true }).catch(() => {})
372+
}
373+
})
374+
375+
app.get('/:repoId/mirror/head', async (c) => {
376+
const repoIdRaw = c.req.param('repoId')
377+
const repoId = Number(repoIdRaw)
378+
if (!Number.isFinite(repoId)) return c.json({ error: 'invalid repoId' }, 400)
379+
const repo = getRepoById(db, repoId)
380+
if (!repo) return c.json({ error: 'repo not found' }, 404)
381+
382+
const branchName = await safeGitOut(repo.fullPath, ['rev-parse', '--abbrev-ref', 'HEAD'])
383+
const head = await safeGitOut(repo.fullPath, ['rev-parse', 'HEAD'])
384+
const status = await safeGitOut(repo.fullPath, ['status', '--porcelain', '--untracked-files=all'])
385+
return c.json({
386+
repoId: repo.id,
387+
branch: branchName?.trim() || null,
388+
head: head?.trim() || null,
389+
dirty: (status?.trim().length ?? 0) > 0,
390+
})
391+
})
392+
393+
app.get('/:repoId/mirror/contains/:sha', async (c) => {
394+
const repoIdRaw = c.req.param('repoId')
395+
const repoId = Number(repoIdRaw)
396+
if (!Number.isFinite(repoId)) return c.json({ error: 'invalid repoId' }, 400)
397+
const sha = c.req.param('sha')
398+
if (!/^[0-9a-f]{7,64}$/i.test(sha)) return c.json({ error: 'invalid sha' }, 400)
399+
const repo = getRepoById(db, repoId)
400+
if (!repo) return c.json({ error: 'repo not found' }, 404)
401+
402+
const ancestry = await safeGitOut(repo.fullPath, ['merge-base', '--is-ancestor', sha, 'HEAD'])
403+
return c.json({ repoId: repo.id, contained: ancestry !== null })
404+
})
405+
406+
app.get('/:repoId/mirror/patch', async (c) => {
407+
const repoIdRaw = c.req.param('repoId')
408+
const repoId = Number(repoIdRaw)
409+
if (!Number.isFinite(repoId)) return c.json({ error: 'invalid repoId' }, 400)
410+
const repo = getRepoById(db, repoId)
411+
if (!repo) return c.json({ error: 'repo not found' }, 404)
412+
413+
try {
414+
const branchName = await safeGitOut(repo.fullPath, ['rev-parse', '--abbrev-ref', 'HEAD'])
415+
const head = await safeGitOut(repo.fullPath, ['rev-parse', 'HEAD'])
416+
const patch = await createMirrorPatch(repo.fullPath)
417+
return c.json({
418+
repoId: repo.id,
419+
branch: branchName?.trim() || null,
420+
head: head?.trim() || null,
421+
patch,
422+
})
423+
} catch (error) {
424+
logger.error('mirror patch snapshot failed:', error)
425+
return c.json({ error: getErrorMessage(error) }, 500)
426+
}
427+
})
428+
429+
app.post('/:repoId/mirror/patch', async (c) => {
430+
const repoIdRaw = c.req.param('repoId')
431+
const repoId = Number(repoIdRaw)
432+
if (!Number.isFinite(repoId)) return c.json({ error: 'invalid repoId' }, 400)
433+
434+
let body: PatchBody
435+
try {
436+
body = (await c.req.json()) as PatchBody
437+
} catch {
438+
return c.json({ error: 'invalid json body' }, 400)
439+
}
440+
441+
const repo = getRepoById(db, repoId)
442+
if (!repo) return c.json({ error: 'repo not found' }, 404)
443+
if (!body.patch && body.patch !== '') return c.json({ error: 'patch required' }, 400)
444+
if (body.force !== true && isRepoInUse(db, repoId)) {
445+
return c.json({ error: 'repo_in_use', message: 'open OpenCode sessions are using this repo; rerun with force=1' }, 409)
446+
}
447+
448+
try {
449+
const currentHead = await safeGitOut(repo.fullPath, ['rev-parse', 'HEAD'])
450+
const currentHeadTrimmed = currentHead?.trim() || null
451+
const baseHead = body.baseHead?.trim() || null
452+
if (baseHead && currentHeadTrimmed && baseHead !== currentHeadTrimmed) {
453+
return c.json({ error: 'head_mismatch', message: 'Manager repo HEAD differs from patch base' }, 409)
454+
}
455+
456+
await applyMirrorPatch(repo.fullPath, body.patch)
457+
458+
const branchName = await safeGitOut(repo.fullPath, ['rev-parse', '--abbrev-ref', 'HEAD'])
459+
const head = await safeGitOut(repo.fullPath, ['rev-parse', 'HEAD'])
460+
461+
if (branchName) updateRepoBranch(db, repoId, branchName.trim())
462+
updateLastPulled(db, repoId)
463+
464+
return c.json({
465+
repoId,
466+
fullPath: repo.fullPath,
467+
branch: branchName?.trim() || null,
468+
head: head?.trim() || null,
469+
created: false,
470+
applied: true,
471+
})
472+
} catch (error) {
473+
logger.error('mirror patch failed:', error)
474+
return c.json({ error: getErrorMessage(error) }, 409)
475+
}
476+
})
477+
217478
app.get('/:repoId/mirror', async (c) => {
218479
const repoIdRaw = c.req.param('repoId')
219480
const repoId = Number(repoIdRaw)
@@ -233,7 +494,7 @@ export function createInternalRepoMirrorRoutes(db: Database) {
233494
try {
234495
const ignored = await gitOut(fullPath, ['ls-files', '--others', '--ignored', '--exclude-standard', '--directory'])
235496
if (ignored.trim()) {
236-
const excludeParent = join(getReposPath(), '.ocm-staging')
497+
const excludeParent = getStagingRoot()
237498
mkdirSync(excludeParent, { recursive: true })
238499
ignoreFile = mkdtempSync(join(excludeParent, 'exclude-'))
239500
writeFileSync(join(ignoreFile, '.gitignore'), ignored)

0 commit comments

Comments
 (0)