@@ -8,8 +8,13 @@ import { pipeline } from 'stream/promises'
88import { join } from 'path'
99import * as fsp from 'fs/promises'
1010import { getReposPath } from '@opencode-manager/shared/config/env'
11+ import {
12+ MirrorTargetBranchRequestSchema ,
13+ type MirrorTargetEnsureResponse ,
14+ type MirrorTargetPlanResponse ,
15+ } from '@opencode-manager/shared/schemas'
1116import { getRepoById , updateLastPulled , updateRepoBranch , deleteRepo } from '../../db/queries'
12- import { ensureMirrorTargetPath , createRepoRow , isRepoInUse } from '../../services/repo'
17+ import { ensureMirrorTargetPath , createRepoRow , isRepoInUse , planMirrorTarget , ensureMirrorTarget } from '../../services/repo'
1318import { logger } from '../../utils/logger'
1419import { getErrorMessage } from '../../utils/error-utils'
1520import { mkdirSyncSafe } from '../../utils/fs-safe'
@@ -97,34 +102,90 @@ async function applyMirrorPatch(fullPath: string, patch: string): Promise<void>
97102 await gitRaw ( fullPath , [ 'apply' , '--binary' , '--whitespace=nowarn' , '-' ] , process . env , patch )
98103}
99104
100- async function importBundle ( fullPath : string , bundlePath : string , branch : string | null ) : Promise < void > {
101- await gitRaw ( fullPath , [ 'fetch' , bundlePath , '+refs/heads/*:refs/remotes/ocm-sync/*' , '+refs/tags/*:refs/tags/*' ] )
102- const refs = await gitRaw ( fullPath , [ 'for-each-ref' , '--format=%(refname:strip=3) %(objectname)' , 'refs/remotes/ocm-sync' ] )
103- const updates : string [ ] = [ ]
104- for ( const line of refs . split ( '\n' ) ) {
105- const trimmed = line . trim ( )
106- if ( ! trimmed ) continue
107- const firstSpace = trimmed . indexOf ( ' ' )
105+ async function branchesCheckedOutElsewhere ( fullPath : string ) : Promise < Set < string > > {
106+ const ownBranch = ( await gitRaw ( fullPath , [ 'symbolic-ref' , '--quiet' , '--short' , 'HEAD' ] ) . catch ( ( ) => '' ) ) . trim ( )
107+ const out = await gitRaw ( fullPath , [ 'for-each-ref' , '--format=%(refname:strip=2) %(worktreepath)' , 'refs/heads' ] )
108+ const locked = new Set < string > ( )
109+ for ( const line of out . split ( '\n' ) ) {
110+ const firstSpace = line . indexOf ( ' ' )
108111 if ( firstSpace === - 1 ) continue
109- const name = trimmed . slice ( 0 , firstSpace )
110- if ( name === 'HEAD' ) continue
111- const sha = trimmed . slice ( firstSpace + 1 )
112- updates . push ( `update refs/heads/${ name } ${ sha } \n` )
113- }
114- if ( updates . length > 0 ) {
115- await gitRaw ( fullPath , [ 'update-ref' , '--stdin' ] , process . env , updates . join ( '' ) )
112+ const name = line . slice ( 0 , firstSpace )
113+ const worktreePath = line . slice ( firstSpace + 1 ) . trim ( )
114+ if ( worktreePath && name !== ownBranch ) locked . add ( name )
116115 }
116+ return locked
117+ }
117118
118- if ( branch ) {
119- await gitRaw ( fullPath , [ 'checkout' , branch ] )
120- const head = ( await gitRaw ( fullPath , [ 'rev-parse' , `refs/remotes/ocm-sync/${ branch } ` ] ) ) . trim ( )
121- if ( head ) await gitRaw ( fullPath , [ 'reset' , '--hard' , head ] )
122- }
119+ async function currentBranchName ( fullPath : string ) : Promise < string | null > {
120+ const out = await gitRaw ( fullPath , [ 'symbolic-ref' , '--quiet' , '--short' , 'HEAD' ] ) . catch ( ( ) => '' )
121+ const trimmed = out . trim ( )
122+ return trimmed . length > 0 ? trimmed : null
123+ }
124+
125+ async function listLocalBranchNames ( fullPath : string ) : Promise < Set < string > > {
126+ const out = await gitRaw ( fullPath , [ 'for-each-ref' , '--format=%(refname:strip=2)' , 'refs/heads' ] )
127+ return new Set ( out . split ( '\n' ) . map ( ( l ) => l . trim ( ) ) . filter ( Boolean ) )
128+ }
129+
130+ async function importBundle ( fullPath : string , bundlePath : string , branch : string | null , requireCurrentBranch : boolean , force : boolean ) : Promise < void > {
131+ await gitRaw ( fullPath , [ 'fetch' , bundlePath , '+refs/heads/*:refs/remotes/ocm-sync/*' , '+refs/tags/*:refs/tags/*' ] )
132+ try {
133+ const refs = await gitRaw ( fullPath , [ 'for-each-ref' , '--format=%(refname:strip=3) %(objectname)' , 'refs/remotes/ocm-sync' ] )
134+ const incoming = new Map < string , string > ( )
135+ for ( const line of refs . split ( '\n' ) ) {
136+ const trimmed = line . trim ( )
137+ if ( ! trimmed ) continue
138+ const firstSpace = trimmed . indexOf ( ' ' )
139+ if ( firstSpace === - 1 ) continue
140+ const name = trimmed . slice ( 0 , firstSpace )
141+ if ( name === 'HEAD' ) continue
142+ incoming . set ( name , trimmed . slice ( firstSpace + 1 ) )
143+ }
144+
145+ const locked = await branchesCheckedOutElsewhere ( fullPath )
146+ const actualBranch = await currentBranchName ( fullPath )
123147
124- const syncRefsOut = await gitRaw ( fullPath , [ 'for-each-ref' , '--format=%(refname)' , 'refs/remotes/ocm-sync' ] ) . catch ( ( ) => '' )
125- const deletes = syncRefsOut . split ( '\n' ) . map ( ( l ) => l . trim ( ) ) . filter ( Boolean ) . map ( ( ref ) => `delete ${ ref } \n` )
126- if ( deletes . length > 0 ) {
127- await gitRaw ( fullPath , [ 'update-ref' , '--stdin' ] , process . env , deletes . join ( '' ) ) . catch ( ( ) => { } )
148+ let targetSha : string | undefined
149+ if ( branch ) {
150+ const incomingSha = incoming . get ( branch )
151+ if ( ! incomingSha ) throw new Error ( `incoming bundle has no branch '${ branch } '` )
152+ if ( locked . has ( branch ) ) {
153+ throw new Error ( `branch '${ branch } ' is checked out in another worktree; release it there before pushing` )
154+ }
155+ if ( requireCurrentBranch && actualBranch !== branch ) {
156+ throw new Error ( `repo is on branch '${ actualBranch ?? 'detached HEAD' } ' but the bundle targets '${ branch } '` )
157+ }
158+ targetSha = incomingSha
159+ if ( actualBranch !== branch ) {
160+ const checkoutArgs = force ? [ '-f' ] : [ ]
161+ if ( ( await listLocalBranchNames ( fullPath ) ) . has ( branch ) ) {
162+ await gitRaw ( fullPath , [ 'checkout' , ...checkoutArgs , branch ] )
163+ } else {
164+ await gitRaw ( fullPath , [ 'checkout' , ...checkoutArgs , '-b' , branch , incomingSha ] )
165+ }
166+ }
167+ }
168+
169+ const updates : string [ ] = [ ]
170+ for ( const [ name , sha ] of incoming ) {
171+ if ( targetSha !== undefined && name === branch ) continue
172+ if ( locked . has ( name ) ) continue
173+ updates . push ( `update refs/heads/${ name } ${ sha } \n` )
174+ }
175+ if ( updates . length > 0 ) {
176+ await gitRaw ( fullPath , [ 'update-ref' , '--stdin' ] , process . env , updates . join ( '' ) )
177+ }
178+
179+ if ( branch && targetSha !== undefined ) {
180+ await gitRaw ( fullPath , [ 'reset' , '--hard' , targetSha ] )
181+ await gitRaw ( fullPath , [ 'clean' , '-fd' ] )
182+ }
183+ } finally {
184+ const syncRefsOut = await gitRaw ( fullPath , [ 'for-each-ref' , '--format=%(refname)' , 'refs/remotes/ocm-sync' ] ) . catch ( ( ) => '' )
185+ const deletes = syncRefsOut . split ( '\n' ) . map ( ( l ) => l . trim ( ) ) . filter ( Boolean ) . map ( ( ref ) => `delete ${ ref } \n` )
186+ if ( deletes . length > 0 ) {
187+ await gitRaw ( fullPath , [ 'update-ref' , '--stdin' ] , process . env , deletes . join ( '' ) ) . catch ( ( ) => { } )
188+ }
128189 }
129190}
130191
@@ -337,7 +398,8 @@ export function createInternalRepoMirrorRoutes(db: Database) {
337398 if ( ! Number . isFinite ( repoId ) ) return c . json ( { error : 'invalid repoId' } , 400 )
338399 const repo = getRepoById ( db , repoId )
339400 if ( ! repo ) return c . json ( { error : 'repo not found' } , 404 )
340- if ( isRepoInUse ( db , repoId ) && c . req . query ( 'force' ) !== '1' ) {
401+ const force = c . req . query ( 'force' ) === '1'
402+ if ( isRepoInUse ( db , repoId ) && ! force ) {
341403 return c . json ( { error : 'repo_in_use' , message : 'open OpenCode sessions are using this repo; rerun with force=1' } , 409 )
342404 }
343405
@@ -349,11 +411,12 @@ export function createInternalRepoMirrorRoutes(db: Database) {
349411 const bundleDir = mkdtempSync ( join ( stagingRoot , 'bundle-upload-' ) )
350412 const bundlePath = join ( bundleDir , 'repo.bundle' )
351413 const branch = c . req . header ( 'x-ocm-branch' ) ?. trim ( ) || null
414+ const requireCurrentBranch = c . req . header ( 'x-ocm-require-current-branch' ) ?. trim ( ) === '1'
352415
353416 try {
354417 const body = Readable . fromWeb ( rawBody as unknown as Parameters < typeof Readable . fromWeb > [ 0 ] )
355418 await pipeline ( body , createWriteStream ( bundlePath ) )
356- await importBundle ( repo . fullPath , bundlePath , branch )
419+ await importBundle ( repo . fullPath , bundlePath , branch , requireCurrentBranch , force )
357420
358421 const branchName = await safeGitOut ( repo . fullPath , [ 'rev-parse' , '--abbrev-ref' , 'HEAD' ] )
359422 const head = await safeGitOut ( repo . fullPath , [ 'rev-parse' , 'HEAD' ] )
@@ -375,6 +438,58 @@ export function createInternalRepoMirrorRoutes(db: Database) {
375438 }
376439 } )
377440
441+ app . get ( '/:repoId/mirror/target' , async ( c ) => {
442+ const repoId = Number ( c . req . param ( 'repoId' ) )
443+ if ( ! Number . isFinite ( repoId ) ) return c . json ( { error : 'invalid repoId' } , 400 )
444+ const branchParsed = MirrorTargetBranchRequestSchema . safeParse ( { branch : c . req . query ( 'branch' ) } )
445+ if ( ! branchParsed . success ) return c . json ( { error : 'branch required' } , 400 )
446+ const { branch } = branchParsed . data
447+ const repo = getRepoById ( db , repoId )
448+ if ( ! repo ) return c . json ( { error : 'repo not found' } , 404 )
449+
450+ try {
451+ const plan = await planMirrorTarget ( db , repo , branch )
452+ const response : MirrorTargetPlanResponse = plan . kind === 'new'
453+ ? { kind : plan . kind , repoId : null , fullPath : plan . fullPath , localPath : plan . localPath , branch, currentBranch : plan . currentBranch }
454+ : { kind : plan . kind , repoId : plan . repo . id , fullPath : plan . repo . fullPath , localPath : plan . repo . localPath , branch, currentBranch : plan . currentBranch }
455+ return c . json ( response )
456+ } catch ( error ) {
457+ logger . error ( 'mirror target plan failed:' , error )
458+ return c . json ( { error : getErrorMessage ( error ) } , 500 )
459+ }
460+ } )
461+
462+ app . post ( '/:repoId/mirror/target' , async ( c ) => {
463+ const repoId = Number ( c . req . param ( 'repoId' ) )
464+ if ( ! Number . isFinite ( repoId ) ) return c . json ( { error : 'invalid repoId' } , 400 )
465+ let json : unknown
466+ try {
467+ json = await c . req . json ( )
468+ } catch {
469+ return c . json ( { error : 'invalid json body' } , 400 )
470+ }
471+ const branchParsed = MirrorTargetBranchRequestSchema . safeParse ( json )
472+ if ( ! branchParsed . success ) return c . json ( { error : 'branch required' } , 400 )
473+ const { branch } = branchParsed . data
474+ const repo = getRepoById ( db , repoId )
475+ if ( ! repo ) return c . json ( { error : 'repo not found' } , 404 )
476+
477+ try {
478+ const { repo : target , created } = await ensureMirrorTarget ( db , repo , branch )
479+ const response : MirrorTargetEnsureResponse = {
480+ repoId : target . id ,
481+ fullPath : target . fullPath ,
482+ localPath : target . localPath ,
483+ branch,
484+ created,
485+ }
486+ return c . json ( response )
487+ } catch ( error ) {
488+ logger . error ( 'mirror target ensure failed:' , error )
489+ return c . json ( { error : getErrorMessage ( error ) } , 409 )
490+ }
491+ } )
492+
378493 app . get ( '/:repoId/mirror/head' , async ( c ) => {
379494 const repoIdRaw = c . req . param ( 'repoId' )
380495 const repoId = Number ( repoIdRaw )
0 commit comments