11import { Hono } from 'hono'
22import type { Database } from 'bun:sqlite'
33import { spawn } from 'child_process'
4- import { createWriteStream } from 'fs'
4+ import { copyFileSync , createReadStream , createWriteStream , existsSync } from 'fs'
55import { mkdirSync , mkdtempSync , writeFileSync } from 'fs'
66import { Readable } from 'stream'
77import { 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+
4552const 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+
47137export 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 - 9 a - 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