Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/src/routes/internal/opencode-workspaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export function createInternalOpenCodeWorkspacesRoutes(db: Database) {
directory: repo.fullPath,
originUrl: repo.repoUrl ?? null,
projectId: await resolveProjectId(repo.fullPath).catch(() => null),
isWorktree: repo.isWorktree === true,
extra: {
repoId: repo.id,
localPath: repo.localPath,
Expand Down
169 changes: 142 additions & 27 deletions backend/src/routes/internal/repo-mirror.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,13 @@ import { pipeline } from 'stream/promises'
import { join } from 'path'
import * as fsp from 'fs/promises'
import { getReposPath } from '@opencode-manager/shared/config/env'
import {
MirrorTargetBranchRequestSchema,
type MirrorTargetEnsureResponse,
type MirrorTargetPlanResponse,
} from '@opencode-manager/shared/schemas'
import { getRepoById, updateLastPulled, updateRepoBranch, deleteRepo } from '../../db/queries'
import { ensureMirrorTargetPath, createRepoRow, isRepoInUse } from '../../services/repo'
import { ensureMirrorTargetPath, createRepoRow, isRepoInUse, planMirrorTarget, ensureMirrorTarget } from '../../services/repo'
import { logger } from '../../utils/logger'
import { getErrorMessage } from '../../utils/error-utils'
import { mkdirSyncSafe } from '../../utils/fs-safe'
Expand Down Expand Up @@ -97,34 +102,90 @@ async function applyMirrorPatch(fullPath: string, patch: string): Promise<void>
await gitRaw(fullPath, ['apply', '--binary', '--whitespace=nowarn', '-'], process.env, patch)
}

async function importBundle(fullPath: string, bundlePath: string, branch: string | null): Promise<void> {
await gitRaw(fullPath, ['fetch', bundlePath, '+refs/heads/*:refs/remotes/ocm-sync/*', '+refs/tags/*:refs/tags/*'])
const refs = await gitRaw(fullPath, ['for-each-ref', '--format=%(refname:strip=3) %(objectname)', 'refs/remotes/ocm-sync'])
const updates: string[] = []
for (const line of refs.split('\n')) {
const trimmed = line.trim()
if (!trimmed) continue
const firstSpace = trimmed.indexOf(' ')
async function branchesCheckedOutElsewhere(fullPath: string): Promise<Set<string>> {
const ownBranch = (await gitRaw(fullPath, ['symbolic-ref', '--quiet', '--short', 'HEAD']).catch(() => '')).trim()
const out = await gitRaw(fullPath, ['for-each-ref', '--format=%(refname:strip=2) %(worktreepath)', 'refs/heads'])
const locked = new Set<string>()
for (const line of out.split('\n')) {
const firstSpace = line.indexOf(' ')
if (firstSpace === -1) continue
const name = trimmed.slice(0, firstSpace)
if (name === 'HEAD') continue
const sha = trimmed.slice(firstSpace + 1)
updates.push(`update refs/heads/${name} ${sha}\n`)
}
if (updates.length > 0) {
await gitRaw(fullPath, ['update-ref', '--stdin'], process.env, updates.join(''))
const name = line.slice(0, firstSpace)
const worktreePath = line.slice(firstSpace + 1).trim()
if (worktreePath && name !== ownBranch) locked.add(name)
}
return locked
}

if (branch) {
await gitRaw(fullPath, ['checkout', branch])
const head = (await gitRaw(fullPath, ['rev-parse', `refs/remotes/ocm-sync/${branch}`])).trim()
if (head) await gitRaw(fullPath, ['reset', '--hard', head])
}
async function currentBranchName(fullPath: string): Promise<string | null> {
const out = await gitRaw(fullPath, ['symbolic-ref', '--quiet', '--short', 'HEAD']).catch(() => '')
const trimmed = out.trim()
return trimmed.length > 0 ? trimmed : null
}

async function listLocalBranchNames(fullPath: string): Promise<Set<string>> {
const out = await gitRaw(fullPath, ['for-each-ref', '--format=%(refname:strip=2)', 'refs/heads'])
return new Set(out.split('\n').map((l) => l.trim()).filter(Boolean))
}

async function importBundle(fullPath: string, bundlePath: string, branch: string | null, requireCurrentBranch: boolean, force: boolean): Promise<void> {
await gitRaw(fullPath, ['fetch', bundlePath, '+refs/heads/*:refs/remotes/ocm-sync/*', '+refs/tags/*:refs/tags/*'])
try {
const refs = await gitRaw(fullPath, ['for-each-ref', '--format=%(refname:strip=3) %(objectname)', 'refs/remotes/ocm-sync'])
const incoming = new Map<string, string>()
for (const line of refs.split('\n')) {
const trimmed = line.trim()
if (!trimmed) continue
const firstSpace = trimmed.indexOf(' ')
if (firstSpace === -1) continue
const name = trimmed.slice(0, firstSpace)
if (name === 'HEAD') continue
incoming.set(name, trimmed.slice(firstSpace + 1))
}

const locked = await branchesCheckedOutElsewhere(fullPath)
const actualBranch = await currentBranchName(fullPath)

const syncRefsOut = await gitRaw(fullPath, ['for-each-ref', '--format=%(refname)', 'refs/remotes/ocm-sync']).catch(() => '')
const deletes = syncRefsOut.split('\n').map((l) => l.trim()).filter(Boolean).map((ref) => `delete ${ref}\n`)
if (deletes.length > 0) {
await gitRaw(fullPath, ['update-ref', '--stdin'], process.env, deletes.join('')).catch(() => {})
let targetSha: string | undefined
if (branch) {
const incomingSha = incoming.get(branch)
if (!incomingSha) throw new Error(`incoming bundle has no branch '${branch}'`)
if (locked.has(branch)) {
throw new Error(`branch '${branch}' is checked out in another worktree; release it there before pushing`)
}
if (requireCurrentBranch && actualBranch !== branch) {
throw new Error(`repo is on branch '${actualBranch ?? 'detached HEAD'}' but the bundle targets '${branch}'`)
}
targetSha = incomingSha
if (actualBranch !== branch) {
const checkoutArgs = force ? ['-f'] : []
if ((await listLocalBranchNames(fullPath)).has(branch)) {
await gitRaw(fullPath, ['checkout', ...checkoutArgs, branch])
} else {
await gitRaw(fullPath, ['checkout', ...checkoutArgs, '-b', branch, incomingSha])
}
}
}

const updates: string[] = []
for (const [name, sha] of incoming) {
if (targetSha !== undefined && name === branch) continue
if (locked.has(name)) continue
updates.push(`update refs/heads/${name} ${sha}\n`)
}
if (updates.length > 0) {
await gitRaw(fullPath, ['update-ref', '--stdin'], process.env, updates.join(''))
}

if (branch && targetSha !== undefined) {
await gitRaw(fullPath, ['reset', '--hard', targetSha])
await gitRaw(fullPath, ['clean', '-fd'])
}
} finally {
const syncRefsOut = await gitRaw(fullPath, ['for-each-ref', '--format=%(refname)', 'refs/remotes/ocm-sync']).catch(() => '')
const deletes = syncRefsOut.split('\n').map((l) => l.trim()).filter(Boolean).map((ref) => `delete ${ref}\n`)
if (deletes.length > 0) {
await gitRaw(fullPath, ['update-ref', '--stdin'], process.env, deletes.join('')).catch(() => {})
}
}
}

Expand Down Expand Up @@ -337,7 +398,8 @@ export function createInternalRepoMirrorRoutes(db: Database) {
if (!Number.isFinite(repoId)) return c.json({ error: 'invalid repoId' }, 400)
const repo = getRepoById(db, repoId)
if (!repo) return c.json({ error: 'repo not found' }, 404)
if (isRepoInUse(db, repoId) && c.req.query('force') !== '1') {
const force = c.req.query('force') === '1'
if (isRepoInUse(db, repoId) && !force) {
return c.json({ error: 'repo_in_use', message: 'open OpenCode sessions are using this repo; rerun with force=1' }, 409)
}

Expand All @@ -349,11 +411,12 @@ export function createInternalRepoMirrorRoutes(db: Database) {
const bundleDir = mkdtempSync(join(stagingRoot, 'bundle-upload-'))
const bundlePath = join(bundleDir, 'repo.bundle')
const branch = c.req.header('x-ocm-branch')?.trim() || null
const requireCurrentBranch = c.req.header('x-ocm-require-current-branch')?.trim() === '1'

try {
const body = Readable.fromWeb(rawBody as unknown as Parameters<typeof Readable.fromWeb>[0])
await pipeline(body, createWriteStream(bundlePath))
await importBundle(repo.fullPath, bundlePath, branch)
await importBundle(repo.fullPath, bundlePath, branch, requireCurrentBranch, force)

const branchName = await safeGitOut(repo.fullPath, ['rev-parse', '--abbrev-ref', 'HEAD'])
const head = await safeGitOut(repo.fullPath, ['rev-parse', 'HEAD'])
Expand All @@ -375,6 +438,58 @@ export function createInternalRepoMirrorRoutes(db: Database) {
}
})

app.get('/:repoId/mirror/target', async (c) => {
const repoId = Number(c.req.param('repoId'))
if (!Number.isFinite(repoId)) return c.json({ error: 'invalid repoId' }, 400)
const branchParsed = MirrorTargetBranchRequestSchema.safeParse({ branch: c.req.query('branch') })
if (!branchParsed.success) return c.json({ error: 'branch required' }, 400)
const { branch } = branchParsed.data
const repo = getRepoById(db, repoId)
if (!repo) return c.json({ error: 'repo not found' }, 404)

try {
const plan = await planMirrorTarget(db, repo, branch)
const response: MirrorTargetPlanResponse = plan.kind === 'new'
? { kind: plan.kind, repoId: null, fullPath: plan.fullPath, localPath: plan.localPath, branch, currentBranch: plan.currentBranch }
: { kind: plan.kind, repoId: plan.repo.id, fullPath: plan.repo.fullPath, localPath: plan.repo.localPath, branch, currentBranch: plan.currentBranch }
return c.json(response)
} catch (error) {
logger.error('mirror target plan failed:', error)
return c.json({ error: getErrorMessage(error) }, 500)
}
})

app.post('/:repoId/mirror/target', async (c) => {
const repoId = Number(c.req.param('repoId'))
if (!Number.isFinite(repoId)) return c.json({ error: 'invalid repoId' }, 400)
let json: unknown
try {
json = await c.req.json()
} catch {
return c.json({ error: 'invalid json body' }, 400)
}
const branchParsed = MirrorTargetBranchRequestSchema.safeParse(json)
if (!branchParsed.success) return c.json({ error: 'branch required' }, 400)
const { branch } = branchParsed.data
const repo = getRepoById(db, repoId)
if (!repo) return c.json({ error: 'repo not found' }, 404)

try {
const { repo: target, created } = await ensureMirrorTarget(db, repo, branch)
const response: MirrorTargetEnsureResponse = {
repoId: target.id,
fullPath: target.fullPath,
localPath: target.localPath,
branch,
created,
}
return c.json(response)
} catch (error) {
logger.error('mirror target ensure failed:', error)
return c.json({ error: getErrorMessage(error) }, 409)
}
})

app.get('/:repoId/mirror/head', async (c) => {
const repoIdRaw = c.req.param('repoId')
const repoId = Number(repoIdRaw)
Expand Down
57 changes: 56 additions & 1 deletion backend/src/services/repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type { Database } from 'bun:sqlite'
import type { Repo, CreateRepoInput } from '../types/repo'
import { logger } from '../utils/logger'
import { getReposPath, getScheduleWorktreesPath } from '@opencode-manager/shared/config/env'
import { normalizeRepoDirectoryName, sanitizeRepoDirectoryName, sanitizeBranchForDirectory, normalizeRepoUrlForCompare, isSSHUrl, normalizeSSHUrl, SCP_STYLE_URL_PATTERN } from '@opencode-manager/shared/utils'
import { normalizeRepoDirectoryName, sanitizeRepoDirectoryName, sanitizeBranchForDirectory, getRepoBaseDirectoryName, normalizeRepoUrlForCompare, isSSHUrl, normalizeSSHUrl, SCP_STYLE_URL_PATTERN } from '@opencode-manager/shared/utils'
import type { GitAuthService } from './git-auth'
import { isGitHubHttpsUrl } from '../utils/git-auth'
import path from 'path'
Expand Down Expand Up @@ -1056,6 +1056,61 @@ export async function createWorktreeSafely(baseRepoPath: string, worktreePath: s
}
}

export type MirrorTargetPlan =
| { kind: 'in-place'; repo: Repo; currentBranch: string | null }
| { kind: 'existing'; repo: Repo; currentBranch: string | null }
| { kind: 'new'; localPath: string; fullPath: string; currentBranch: string | null }

export async function planMirrorTarget(database: Database, repo: Repo, branch: string): Promise<MirrorTargetPlan> {
const currentBranch = await safeGetCurrentBranch(repo.fullPath, {})
if (currentBranch === branch) return { kind: 'in-place', repo, currentBranch }

const localPath = `${getRepoBaseDirectoryName(repo)}-${sanitizeBranchForDirectory(branch)}`
const fullPath = path.join(getReposPath(), localPath)
const existing = getRepoByLocalPath(database, localPath)

if (existing) {
if (existing.branch !== branch) {
throw new Error(`Mirror target '${localPath}' is occupied by repo ${existing.id} registered for branch '${existing.branch ?? 'none'}' instead of '${branch}'`)
}

if (!existsSync(existing.fullPath)) {
throw new Error(`Repo ${existing.id} for branch '${branch}' is missing its worktree directory at '${existing.fullPath}'`)
}

const checkedOutBranch = await safeGetCurrentBranch(existing.fullPath, {})
if (checkedOutBranch !== branch) {
throw new Error(`Repo ${existing.id} for branch '${branch}' has branch '${checkedOutBranch ?? 'none'}' checked out at '${existing.fullPath}'`)
}

return { kind: 'existing', repo: existing, currentBranch }
}

return { kind: 'new', localPath, fullPath, currentBranch }
}

export async function ensureMirrorTarget(database: Database, repo: Repo, branch: string): Promise<{ repo: Repo; created: boolean }> {
const plan = await planMirrorTarget(database, repo, branch)
if (plan.kind !== 'new') return { repo: plan.repo, created: false }

await createWorktreeSafely(repo.fullPath, plan.fullPath, branch, {})
Comment thread
coderabbitai[bot] marked this conversation as resolved.

try {
const worktreeRepo = createRepo(database, repo.repoUrl
? { repoUrl: repo.repoUrl, localPath: plan.localPath, branch, defaultBranch: branch, cloneStatus: 'ready', clonedAt: Date.now(), isWorktree: true }
: { isLocal: true, localPath: plan.localPath, branch, defaultBranch: branch, cloneStatus: 'ready', clonedAt: Date.now(), isWorktree: true })

if (worktreeRepo.localPath !== plan.localPath) {
throw new Error(`branch ${branch} is already registered as repo ${worktreeRepo.id} at ${worktreeRepo.fullPath}`)
}

return { repo: worktreeRepo, created: true }
} catch (error: unknown) {
await removeWorktree(repo.fullPath, plan.fullPath)
throw error
}
}

export function ensureMirrorTargetPath(name: string): { fullPath: string; localPath: string } {
const slugified = name
.toLowerCase()
Expand Down
10 changes: 7 additions & 3 deletions backend/test/routes/internal-opencode-workspaces.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,15 +126,16 @@ describe('internal-opencode-workspaces routes', () => {
it('GET /api/internal/opencode-workspaces returns workspace structure', async () => {
mockListRepos.mockReturnValue([
makeRepo({ id: 1, localPath: 'test-repo', cloneStatus: 'ready' }),
makeRepo({ id: 2, localPath: 'worktree-repo', cloneStatus: 'ready', isWorktree: true }),
])

const res = await app.request('/api/internal/opencode-workspaces', {
headers: { authorization: `Bearer ${token}` },
})
expect(res.status).toBe(200)
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 } }> }
expect(body.workspaces.length).toBe(1)
const workspace = body.workspaces[0]!
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 } }> }
expect(body.workspaces.length).toBe(2)
const workspace = body.workspaces.find((w) => w.repoId === 1)!
expect(workspace).toHaveProperty('repoId')
expect(workspace).toHaveProperty('name')
expect(workspace).toHaveProperty('branch')
Expand All @@ -145,5 +146,8 @@ describe('internal-opencode-workspaces routes', () => {
expect(workspace.extra).toHaveProperty('repoId')
expect(workspace.extra).toHaveProperty('localPath')
expect(workspace.extra).toHaveProperty('fullPath')
const worktreeWorkspace = body.workspaces.find((w) => w.repoId === 2)!
expect(workspace.isWorktree).toBe(false)
expect(worktreeWorkspace.isWorktree).toBe(true)
})
})
Loading