Skip to content

Commit 68d586f

Browse files
feat(ocm-move): plan worktree mirror targets and stream move progress (#345)
* feat(ocm-move): plan worktree mirror targets and stream move progress - Plan/ensure mirror targets per branch via new internal endpoints, creating a worktree repo when the target branch is not checked out in the source repo - Skip branches checked out in other worktrees during bundle import and reset/clean before checkout on both server and CLI - Rewrite workspace paths in message.updated events when replaying moved sessions - Stream live move progress (bundle, upload, import, patch, replay) in the TUI - Prefer branch-matched repos when moving sessions and share push-divergence descriptions * chore(ocm-cli): bump version to 0.2.7 * fix(ocm): harden worktree mirror synchronization * fix(ocm): protect active branch during branchless pull
1 parent 4ed3460 commit 68d586f

23 files changed

Lines changed: 2023 additions & 155 deletions

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export function createInternalOpenCodeWorkspacesRoutes(db: Database) {
2020
directory: repo.fullPath,
2121
originUrl: repo.repoUrl ?? null,
2222
projectId: await resolveProjectId(repo.fullPath).catch(() => null),
23+
isWorktree: repo.isWorktree === true,
2324
extra: {
2425
repoId: repo.id,
2526
localPath: repo.localPath,

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

Lines changed: 142 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,13 @@ import { pipeline } from 'stream/promises'
88
import { join } from 'path'
99
import * as fsp from 'fs/promises'
1010
import { getReposPath } from '@opencode-manager/shared/config/env'
11+
import {
12+
MirrorTargetBranchRequestSchema,
13+
type MirrorTargetEnsureResponse,
14+
type MirrorTargetPlanResponse,
15+
} from '@opencode-manager/shared/schemas'
1116
import { 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'
1318
import { logger } from '../../utils/logger'
1419
import { getErrorMessage } from '../../utils/error-utils'
1520
import { 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)

‎backend/src/services/repo.ts‎

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import type { Database } from 'bun:sqlite'
77
import type { Repo, CreateRepoInput } from '../types/repo'
88
import { logger } from '../utils/logger'
99
import { getReposPath, getScheduleWorktreesPath } from '@opencode-manager/shared/config/env'
10-
import { normalizeRepoDirectoryName, sanitizeRepoDirectoryName, sanitizeBranchForDirectory, normalizeRepoUrlForCompare, isSSHUrl, normalizeSSHUrl, SCP_STYLE_URL_PATTERN } from '@opencode-manager/shared/utils'
10+
import { normalizeRepoDirectoryName, sanitizeRepoDirectoryName, sanitizeBranchForDirectory, getRepoBaseDirectoryName, normalizeRepoUrlForCompare, isSSHUrl, normalizeSSHUrl, SCP_STYLE_URL_PATTERN } from '@opencode-manager/shared/utils'
1111
import type { GitAuthService } from './git-auth'
1212
import { isGitHubHttpsUrl } from '../utils/git-auth'
1313
import path from 'path'
@@ -1056,6 +1056,61 @@ export async function createWorktreeSafely(baseRepoPath: string, worktreePath: s
10561056
}
10571057
}
10581058

1059+
export type MirrorTargetPlan =
1060+
| { kind: 'in-place'; repo: Repo; currentBranch: string | null }
1061+
| { kind: 'existing'; repo: Repo; currentBranch: string | null }
1062+
| { kind: 'new'; localPath: string; fullPath: string; currentBranch: string | null }
1063+
1064+
export async function planMirrorTarget(database: Database, repo: Repo, branch: string): Promise<MirrorTargetPlan> {
1065+
const currentBranch = await safeGetCurrentBranch(repo.fullPath, {})
1066+
if (currentBranch === branch) return { kind: 'in-place', repo, currentBranch }
1067+
1068+
const localPath = `${getRepoBaseDirectoryName(repo)}-${sanitizeBranchForDirectory(branch)}`
1069+
const fullPath = path.join(getReposPath(), localPath)
1070+
const existing = getRepoByLocalPath(database, localPath)
1071+
1072+
if (existing) {
1073+
if (existing.branch !== branch) {
1074+
throw new Error(`Mirror target '${localPath}' is occupied by repo ${existing.id} registered for branch '${existing.branch ?? 'none'}' instead of '${branch}'`)
1075+
}
1076+
1077+
if (!existsSync(existing.fullPath)) {
1078+
throw new Error(`Repo ${existing.id} for branch '${branch}' is missing its worktree directory at '${existing.fullPath}'`)
1079+
}
1080+
1081+
const checkedOutBranch = await safeGetCurrentBranch(existing.fullPath, {})
1082+
if (checkedOutBranch !== branch) {
1083+
throw new Error(`Repo ${existing.id} for branch '${branch}' has branch '${checkedOutBranch ?? 'none'}' checked out at '${existing.fullPath}'`)
1084+
}
1085+
1086+
return { kind: 'existing', repo: existing, currentBranch }
1087+
}
1088+
1089+
return { kind: 'new', localPath, fullPath, currentBranch }
1090+
}
1091+
1092+
export async function ensureMirrorTarget(database: Database, repo: Repo, branch: string): Promise<{ repo: Repo; created: boolean }> {
1093+
const plan = await planMirrorTarget(database, repo, branch)
1094+
if (plan.kind !== 'new') return { repo: plan.repo, created: false }
1095+
1096+
await createWorktreeSafely(repo.fullPath, plan.fullPath, branch, {})
1097+
1098+
try {
1099+
const worktreeRepo = createRepo(database, repo.repoUrl
1100+
? { repoUrl: repo.repoUrl, localPath: plan.localPath, branch, defaultBranch: branch, cloneStatus: 'ready', clonedAt: Date.now(), isWorktree: true }
1101+
: { isLocal: true, localPath: plan.localPath, branch, defaultBranch: branch, cloneStatus: 'ready', clonedAt: Date.now(), isWorktree: true })
1102+
1103+
if (worktreeRepo.localPath !== plan.localPath) {
1104+
throw new Error(`branch ${branch} is already registered as repo ${worktreeRepo.id} at ${worktreeRepo.fullPath}`)
1105+
}
1106+
1107+
return { repo: worktreeRepo, created: true }
1108+
} catch (error: unknown) {
1109+
await removeWorktree(repo.fullPath, plan.fullPath)
1110+
throw error
1111+
}
1112+
}
1113+
10591114
export function ensureMirrorTargetPath(name: string): { fullPath: string; localPath: string } {
10601115
const slugified = name
10611116
.toLowerCase()

‎backend/test/routes/internal-opencode-workspaces.test.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -126,15 +126,16 @@ describe('internal-opencode-workspaces routes', () => {
126126
it('GET /api/internal/opencode-workspaces returns workspace structure', async () => {
127127
mockListRepos.mockReturnValue([
128128
makeRepo({ id: 1, localPath: 'test-repo', cloneStatus: 'ready' }),
129+
makeRepo({ id: 2, localPath: 'worktree-repo', cloneStatus: 'ready', isWorktree: true }),
129130
])
130131

131132
const res = await app.request('/api/internal/opencode-workspaces', {
132133
headers: { authorization: `Bearer ${token}` },
133134
})
134135
expect(res.status).toBe(200)
135-
const body = await res.json() as { workspaces: Array<{ repoId: number; name: string; branch: string | null; cloneStatus: string; directory: string; extra: { repoId: number; localPath: string; fullPath: string } }> }
136-
expect(body.workspaces.length).toBe(1)
137-
const workspace = body.workspaces[0]!
136+
const body = await res.json() as { workspaces: Array<{ repoId: number; name: string; branch: string | null; cloneStatus: string; directory: string; isWorktree: boolean; extra: { repoId: number; localPath: string; fullPath: string } }> }
137+
expect(body.workspaces.length).toBe(2)
138+
const workspace = body.workspaces.find((w) => w.repoId === 1)!
138139
expect(workspace).toHaveProperty('repoId')
139140
expect(workspace).toHaveProperty('name')
140141
expect(workspace).toHaveProperty('branch')
@@ -145,5 +146,8 @@ describe('internal-opencode-workspaces routes', () => {
145146
expect(workspace.extra).toHaveProperty('repoId')
146147
expect(workspace.extra).toHaveProperty('localPath')
147148
expect(workspace.extra).toHaveProperty('fullPath')
149+
const worktreeWorkspace = body.workspaces.find((w) => w.repoId === 2)!
150+
expect(workspace.isWorktree).toBe(false)
151+
expect(worktreeWorkspace.isWorktree).toBe(true)
148152
})
149153
})

0 commit comments

Comments
 (0)