From 2b9beaf46d06e9afded516977bf9bf7d1295686b Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Thu, 25 Jun 2026 22:47:10 +0000 Subject: [PATCH 1/8] feat: add bundle-based OCM mirror sync --- backend/src/routes/internal/repo-mirror.ts | 228 +++++++++++++++++- .../test/routes/internal/repo-mirror.test.ts | 127 ++++++++++ ocm-cli/bin/ocm.ts | 37 ++- ocm-cli/src/local-repo.ts | 43 +++- ocm-cli/src/manager-api.ts | 68 ++++++ ocm-cli/src/mirror.ts | 129 +++++++++- ocm-cli/test/mirror.test.ts | 60 ++++- 7 files changed, 681 insertions(+), 11 deletions(-) diff --git a/backend/src/routes/internal/repo-mirror.ts b/backend/src/routes/internal/repo-mirror.ts index b933c4486..23763cb44 100644 --- a/backend/src/routes/internal/repo-mirror.ts +++ b/backend/src/routes/internal/repo-mirror.ts @@ -1,7 +1,7 @@ import { Hono } from 'hono' import type { Database } from 'bun:sqlite' import { spawn } from 'child_process' -import { createWriteStream } from 'fs' +import { copyFileSync, createReadStream, createWriteStream, existsSync } from 'fs' import { mkdirSync, mkdtempSync, writeFileSync } from 'fs' import { Readable } from 'stream' import { pipeline } from 'stream/promises' @@ -42,8 +42,94 @@ interface CommitBody { gzip?: boolean } +interface PatchBody { + baseHead?: string | null + patch?: string + force?: boolean +} + 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)' +function gitRaw(repoPath: string, args: string[], env: NodeJS.ProcessEnv = process.env, input?: string): Promise { + return new Promise((resolve, reject) => { + const child = spawn('git', args, { cwd: repoPath, env }) + let stdout = '' + let stderr = '' + child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString() }) + child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString() }) + child.on('error', reject) + child.on('close', (code) => { + if (code === 0) resolve(stdout) + else reject(new Error(stderr.trim() || `git exited with code ${code}`)) + }) + if (input !== undefined) child.stdin.end(input) + }) +} + +async function createMirrorPatch(fullPath: string): Promise { + const untracked = (await gitRaw(fullPath, ['ls-files', '--others', '--exclude-standard', '-z']).catch(() => '')) + .split('\0') + .filter(Boolean) + if (untracked.length === 0) return gitRaw(fullPath, ['diff', '--binary', 'HEAD', '--']) + + const indexPath = (await safeGitOut(fullPath, ['rev-parse', '--git-path', 'index']))?.trim() + const tempIndexDir = mkdtempSync(join(getReposPath(), '.ocm-index-')) + const tempIndex = join(tempIndexDir, 'index') + const env = { ...process.env, GIT_INDEX_FILE: tempIndex } + + try { + if (indexPath && existsSync(join(fullPath, indexPath))) { + copyFileSync(join(fullPath, indexPath), tempIndex) + } + await gitRaw(fullPath, ['add', '-N', '--', ...untracked], env) + return gitRaw(fullPath, ['diff', '--binary', 'HEAD', '--'], env) + } finally { + await fsp.rm(tempIndexDir, { recursive: true, force: true }).catch(() => {}) + } +} + +async function applyMirrorPatch(fullPath: string, patch: string): Promise { + if (!patch) return + await gitRaw(fullPath, ['apply', '--binary', '--whitespace=nowarn', '-'], process.env, patch) +} + +async function importBundle(fullPath: string, bundlePath: string, branch: string | null): Promise { + 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']) + 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) + const sha = trimmed.slice(firstSpace + 1) + await gitRaw(fullPath, ['update-ref', `refs/heads/${name}`, sha]) + } + + 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]) + } + + await gitRaw(fullPath, ['for-each-ref', '--format=%(refname)', 'refs/remotes/ocm-sync']) + .then(async (out) => { + for (const ref of out.split('\n').map((line) => line.trim()).filter(Boolean)) { + await gitRaw(fullPath, ['update-ref', '-d', ref]) + } + }) + .catch(() => {}) +} + +async function createBundle(fullPath: string): Promise { + const stagingRoot = join(getReposPath(), '.ocm-staging') + mkdirSync(stagingRoot, { recursive: true }) + const bundleDir = mkdtempSync(join(stagingRoot, 'bundle-')) + const bundlePath = join(bundleDir, 'repo.bundle') + await gitRaw(fullPath, ['bundle', 'create', bundlePath, '--all']) + return bundlePath +} + export function createInternalRepoMirrorRoutes(db: Database) { const app = new Hono() @@ -214,6 +300,146 @@ export function createInternalRepoMirrorRoutes(db: Database) { return c.json({ ok: true }) }) + app.get('/:repoId/mirror/bundle', async (c) => { + const repoIdRaw = c.req.param('repoId') + const repoId = Number(repoIdRaw) + 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) + + let bundlePath: string | undefined + try { + bundlePath = await createBundle(repo.fullPath) + const stream = createReadStream(bundlePath) + stream.on('close', () => { + if (bundlePath) fsp.rm(join(bundlePath, '..'), { recursive: true, force: true }).catch(() => {}) + }) + return new Response(Readable.toWeb(stream) as ReadableStream, { + headers: { 'Content-Type': 'application/octet-stream' }, + }) + } catch (error) { + logger.error('mirror bundle download failed:', error) + if (bundlePath) await fsp.rm(join(bundlePath, '..'), { recursive: true, force: true }).catch(() => {}) + return c.json({ error: getErrorMessage(error) }, 500) + } + }) + + app.post('/:repoId/mirror/bundle', async (c) => { + const repoIdRaw = c.req.param('repoId') + const repoId = Number(repoIdRaw) + 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') { + return c.json({ error: 'repo_in_use', message: 'open OpenCode sessions are using this repo; rerun with force=1' }, 409) + } + + const rawBody = c.req.raw.body + if (!rawBody) return c.json({ error: 'no body provided' }, 400) + + const stagingRoot = join(getReposPath(), '.ocm-staging') + mkdirSync(stagingRoot, { recursive: true }) + const bundleDir = mkdtempSync(join(stagingRoot, 'bundle-upload-')) + const bundlePath = join(bundleDir, 'repo.bundle') + const branch = c.req.header('x-ocm-branch')?.trim() || null + + try { + const body = Readable.fromWeb(rawBody as unknown as Parameters[0]) + await pipeline(body, createWriteStream(bundlePath)) + await importBundle(repo.fullPath, bundlePath, branch) + + const branchName = await safeGitOut(repo.fullPath, ['rev-parse', '--abbrev-ref', 'HEAD']) + const head = await safeGitOut(repo.fullPath, ['rev-parse', 'HEAD']) + if (branchName) updateRepoBranch(db, repoId, branchName.trim()) + updateLastPulled(db, repoId) + + return c.json({ + repoId, + fullPath: repo.fullPath, + branch: branchName?.trim() || null, + head: head?.trim() || null, + created: false, + }) + } catch (error) { + logger.error('mirror bundle upload failed:', error) + return c.json({ error: getErrorMessage(error) }, 409) + } finally { + await fsp.rm(bundleDir, { recursive: true, force: true }).catch(() => {}) + } + }) + + app.get('/:repoId/mirror/patch', async (c) => { + const repoIdRaw = c.req.param('repoId') + const repoId = Number(repoIdRaw) + 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) + + try { + const branchName = await safeGitOut(repo.fullPath, ['rev-parse', '--abbrev-ref', 'HEAD']) + const head = await safeGitOut(repo.fullPath, ['rev-parse', 'HEAD']) + const patch = await createMirrorPatch(repo.fullPath) + return c.json({ + repoId: repo.id, + branch: branchName?.trim() || null, + head: head?.trim() || null, + patch, + }) + } catch (error) { + logger.error('mirror patch snapshot failed:', error) + return c.json({ error: getErrorMessage(error) }, 500) + } + }) + + app.post('/:repoId/mirror/patch', async (c) => { + const repoIdRaw = c.req.param('repoId') + const repoId = Number(repoIdRaw) + if (!Number.isFinite(repoId)) return c.json({ error: 'invalid repoId' }, 400) + + let body: PatchBody + try { + body = (await c.req.json()) as PatchBody + } catch { + return c.json({ error: 'invalid json body' }, 400) + } + + const repo = getRepoById(db, repoId) + if (!repo) return c.json({ error: 'repo not found' }, 404) + if (!body.patch && body.patch !== '') return c.json({ error: 'patch required' }, 400) + if (body.force !== true && isRepoInUse(db, repoId)) { + return c.json({ error: 'repo_in_use', message: 'open OpenCode sessions are using this repo; rerun with force=1' }, 409) + } + + try { + const currentHead = await safeGitOut(repo.fullPath, ['rev-parse', 'HEAD']) + const currentHeadTrimmed = currentHead?.trim() || null + const baseHead = body.baseHead?.trim() || null + if (baseHead && currentHeadTrimmed && baseHead !== currentHeadTrimmed) { + return c.json({ error: 'head_mismatch', message: 'Manager repo HEAD differs from patch base' }, 409) + } + + await applyMirrorPatch(repo.fullPath, body.patch) + + const branchName = await safeGitOut(repo.fullPath, ['rev-parse', '--abbrev-ref', 'HEAD']) + const head = await safeGitOut(repo.fullPath, ['rev-parse', 'HEAD']) + + if (branchName) updateRepoBranch(db, repoId, branchName.trim()) + updateLastPulled(db, repoId) + + return c.json({ + repoId, + fullPath: repo.fullPath, + branch: branchName?.trim() || null, + head: head?.trim() || null, + created: false, + applied: true, + }) + } catch (error) { + logger.error('mirror patch failed:', error) + return c.json({ error: getErrorMessage(error) }, 409) + } + }) + app.get('/:repoId/mirror', async (c) => { const repoIdRaw = c.req.param('repoId') const repoId = Number(repoIdRaw) diff --git a/backend/test/routes/internal/repo-mirror.test.ts b/backend/test/routes/internal/repo-mirror.test.ts index 6ffce21b5..6e8ffc336 100644 --- a/backend/test/routes/internal/repo-mirror.test.ts +++ b/backend/test/routes/internal/repo-mirror.test.ts @@ -238,6 +238,133 @@ describe('internal-repo-mirror routes', () => { }) }) + describe('patch sync flow', () => { + it('imports a git bundle into an existing manager repo', async () => { + const sourceDir = join(getTmpRoot(), 'bundle-source') + mkdirSync(sourceDir, { recursive: true }) + spawnSync('git', ['init', '-b', 'main'], { cwd: sourceDir, stdio: 'ignore' }) + spawnSync('git', ['config', 'user.email', 'test@test.com'], { cwd: sourceDir, stdio: 'ignore' }) + spawnSync('git', ['config', 'user.name', 'Test'], { cwd: sourceDir, stdio: 'ignore' }) + writeFileSync(join(sourceDir, 'tracked.txt'), 'from bundle\n') + spawnSync('git', ['add', 'tracked.txt'], { cwd: sourceDir, stdio: 'ignore' }) + spawnSync('git', ['commit', '-m', 'source'], { cwd: sourceDir, stdio: 'ignore' }) + spawnSync('git', ['checkout', '-b', 'feature'], { cwd: sourceDir, stdio: 'ignore' }) + writeFileSync(join(sourceDir, 'feature.txt'), 'feature branch\n') + spawnSync('git', ['add', 'feature.txt'], { cwd: sourceDir, stdio: 'ignore' }) + spawnSync('git', ['commit', '-m', 'feature'], { cwd: sourceDir, stdio: 'ignore' }) + spawnSync('git', ['checkout', 'main'], { cwd: sourceDir, stdio: 'ignore' }) + const bundlePath = join(getTmpRoot(), 'source.bundle') + spawnSync('git', ['bundle', 'create', bundlePath, '--all'], { cwd: sourceDir, stdio: 'ignore' }) + + const targetDir = join(getTmpRoot(), 'bundle-target') + mkdirSync(targetDir, { recursive: true }) + spawnSync('git', ['init', '-b', 'main'], { cwd: targetDir, stdio: 'ignore' }) + spawnSync('git', ['config', 'user.email', 'test@test.com'], { cwd: targetDir, stdio: 'ignore' }) + spawnSync('git', ['config', 'user.name', 'Test'], { cwd: targetDir, stdio: 'ignore' }) + writeFileSync(join(targetDir, 'old.txt'), 'old\n') + spawnSync('git', ['add', 'old.txt'], { cwd: targetDir, stdio: 'ignore' }) + spawnSync('git', ['commit', '-m', 'target'], { cwd: targetDir, stdio: 'ignore' }) + + mockGetRepoById.mockReturnValue({ id: 1, fullPath: targetDir }) + mockSafeGitOut.mockImplementation(async (_repoPath: string, args: string[]) => { + if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref') return 'main' + if (args[0] === 'rev-parse' && args[1] === 'HEAD') return 'abc' + return null + }) + + const res = await app.request('/api/internal/repos/1/mirror/bundle', { + method: 'POST', + body: readFileSync(bundlePath), + headers: { 'content-type': 'application/octet-stream', 'x-ocm-branch': 'main' }, + }) + + expect(res.status).toBe(200) + expect(readFileSync(join(targetDir, 'tracked.txt'), 'utf-8')).toBe('from bundle\n') + const featureRef = spawnSync('git', ['rev-parse', '--verify', 'refs/heads/feature'], { cwd: targetDir, encoding: 'utf-8' }) + expect(featureRef.status).toBe(0) + }) + + it('returns a git bundle for pull fast path', async () => { + const repoDir = join(getTmpRoot(), 'bundle-download') + mkdirSync(repoDir, { recursive: true }) + spawnSync('git', ['init', '-b', 'main'], { cwd: repoDir, stdio: 'ignore' }) + spawnSync('git', ['config', 'user.email', 'test@test.com'], { cwd: repoDir, stdio: 'ignore' }) + spawnSync('git', ['config', 'user.name', 'Test'], { cwd: repoDir, stdio: 'ignore' }) + writeFileSync(join(repoDir, 'tracked.txt'), 'bundle payload\n') + spawnSync('git', ['add', 'tracked.txt'], { cwd: repoDir, stdio: 'ignore' }) + spawnSync('git', ['commit', '-m', 'bundle'], { cwd: repoDir, stdio: 'ignore' }) + mockGetRepoById.mockReturnValue({ id: 1, fullPath: repoDir }) + + const res = await app.request('/api/internal/repos/1/mirror/bundle') + const body = Buffer.from(await res.arrayBuffer()) + + expect(res.status).toBe(200) + expect(body.length).toBeGreaterThan(0) + const verifyPath = join(getTmpRoot(), 'download.bundle') + writeFileSync(verifyPath, body) + const verify = spawnSync('git', ['bundle', 'verify', verifyPath], { cwd: repoDir, encoding: 'utf-8' }) + expect(verify.status).toBe(0) + }) + + it('applies a patch to an existing manager repo', async () => { + const repoDir = join(getTmpRoot(), 'patch-repo') + mkdirSync(repoDir, { recursive: true }) + spawnSync('git', ['init'], { cwd: repoDir, stdio: 'ignore' }) + spawnSync('git', ['config', 'user.email', 'test@test.com'], { cwd: repoDir, stdio: 'ignore' }) + spawnSync('git', ['config', 'user.name', 'Test'], { cwd: repoDir, stdio: 'ignore' }) + writeFileSync(join(repoDir, 'tracked.txt'), 'before\n') + spawnSync('git', ['add', 'tracked.txt'], { cwd: repoDir, stdio: 'ignore' }) + spawnSync('git', ['commit', '-m', 'initial'], { cwd: repoDir, stdio: 'ignore' }) + const head = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: repoDir, encoding: 'utf-8' }).stdout.trim() + + mockGetRepoById.mockReturnValue({ id: 1, fullPath: repoDir }) + mockSafeGitOut.mockImplementation(async (_repoPath: string, args: string[]) => { + if (args[0] === 'rev-parse' && args[1] === 'HEAD') return head + if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref') return 'main' + return null + }) + + const patch = 'diff --git a/tracked.txt b/tracked.txt\nindex 6e58d95..7c6cae9 100644\n--- a/tracked.txt\n+++ b/tracked.txt\n@@ -1 +1 @@\n-before\n+after\n' + const res = await app.request('/api/internal/repos/1/mirror/patch', { + method: 'POST', + body: JSON.stringify({ baseHead: head, patch }), + headers: { 'content-type': 'application/json' }, + }) + + expect(res.status).toBe(200) + expect(readFileSync(join(repoDir, 'tracked.txt'), 'utf-8')).toBe('after\n') + expect(mockUpdateLastPulled).toHaveBeenCalledWith(expect.anything(), 1) + }) + + it('returns a patch snapshot for pull fast path', async () => { + const repoDir = join(getTmpRoot(), 'snapshot-repo') + mkdirSync(repoDir, { recursive: true }) + spawnSync('git', ['init'], { cwd: repoDir, stdio: 'ignore' }) + spawnSync('git', ['config', 'user.email', 'test@test.com'], { cwd: repoDir, stdio: 'ignore' }) + spawnSync('git', ['config', 'user.name', 'Test'], { cwd: repoDir, stdio: 'ignore' }) + writeFileSync(join(repoDir, 'tracked.txt'), 'before\n') + spawnSync('git', ['add', 'tracked.txt'], { cwd: repoDir, stdio: 'ignore' }) + spawnSync('git', ['commit', '-m', 'initial'], { cwd: repoDir, stdio: 'ignore' }) + writeFileSync(join(repoDir, 'tracked.txt'), 'after\n') + + mockGetRepoById.mockReturnValue({ id: 1, fullPath: repoDir }) + mockSafeGitOut.mockImplementation(async (_repoPath: string, args: string[]) => { + if (args[0] === 'rev-parse' && args[1] === 'HEAD') return 'abc' + if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref') return 'main' + if (args[0] === 'rev-parse' && args[1] === '--git-path') return '.git/index' + return null + }) + + const res = await app.request('/api/internal/repos/1/mirror/patch') + const json = await res.json() as { patch: string; head: string; branch: string } + + expect(res.status).toBe(200) + expect(json.head).toBe('abc') + expect(json.branch).toBe('main') + expect(json.patch).toContain('diff --git a/tracked.txt b/tracked.txt') + }) + }) + describe('chunked upload flow (begin/parts/commit)', () => { it('creates a repo and populates from chunked tarball', async () => { const targetPath = join(getTmpRoot(), 'test-repo') diff --git a/ocm-cli/bin/ocm.ts b/ocm-cli/bin/ocm.ts index e08a78ad2..f7e127253 100644 --- a/ocm-cli/bin/ocm.ts +++ b/ocm-cli/bin/ocm.ts @@ -3,7 +3,7 @@ import { basename } from 'path' import { readState, writeState, clearState, getStatePath, type OcmState } from '../src/state.js' import { getToken, setToken, deleteToken, KeychainError } from '../src/keychain.js' import { ManagerApi } from '../src/manager-api.js' -import { mirrorUp, mirrorDown, prepareMirror } from '../src/mirror.js' +import { mirrorUp, mirrorDown, mirrorUpFast, mirrorDownFast, prepareMirror, MirrorAbort } from '../src/mirror.js' import type { RemoteRepoSummary, MirrorProgress } from '../src/mirror.js' import { createProgressReporter } from '../src/progress.js' import { getBranchName } from '../src/local-repo.js' @@ -23,8 +23,8 @@ Usage: ocm status Show current manager URL, repo, and whether token is set ocm list List ready repos from the manager ocm use Attach to a specific repo and remember it as last - ocm push [--force] [--create] [--yes] Mirror $PWD to the matching Manager repo (or create one) - ocm pull [--force] Mirror the matching Manager repo over $PWD + ocm push [--force] [--create] [--yes] [--full] Mirror $PWD to the matching Manager repo (fast patch sync by default) + ocm pull [--force] [--full] Mirror the matching Manager repo over $PWD (fast patch sync by default) ocm --version Show the installed ocm version ocm --help Show this help ` @@ -283,11 +283,13 @@ export async function cmdPush(args: string[]): Promise { let force = false let create = false let yes = false + let full = false for (const arg of args) { if (arg === '--force') force = true else if (arg === '--create') create = true else if (arg === '--yes') yes = true + else if (arg === '--full') full = true } const state = requireState() @@ -295,9 +297,6 @@ export async function cmdPush(args: string[]): Promise { const api = new ManagerApi(state.managerUrl, token) const repos = await fetchRepos(state.managerUrl, token) - const progress = createProgressReporter('push') - const onProgress = (p: MirrorProgress) => progress.tick(p.bytesSent) - const remotes: RemoteRepoSummary[] = repos.map((r) => ({ repoId: r.repoId, name: r.name, @@ -329,6 +328,8 @@ export async function cmdPush(args: string[]): Promise { die('stdin is not a TTY; pass --yes to confirm creation') } + const progress = createProgressReporter('push') + const onProgress = (p: MirrorProgress) => progress.tick(p.bytesSent) const result = await mirrorUp(plan, { api, force, @@ -338,6 +339,18 @@ export async function cmdPush(args: string[]): Promise { progress.done() info(`pushed ${plan.repoRoot} -> ${result.created ? 'created' : 'updated'} (repoId=${result.repoId}, branch=${result.branch})`) } else if (plan.matched.length === 1) { + if (!full) { + try { + const result = await mirrorUpFast(plan, { api, force }) + info(`pushed ${plan.repoRoot} -> ${plan.matched[0]!.name} via bundle (repoId=${result.repoId}, branch=${result.branch})`) + return + } catch (error) { + if (error instanceof MirrorAbort) throw error + process.stderr.write(`ocm: patch push failed; falling back to full mirror: ${error instanceof Error ? error.message : String(error)}\n`) + } + } + const progress = createProgressReporter('push') + const onProgress = (p: MirrorProgress) => progress.tick(p.bytesSent) const result = await mirrorUp(plan, { api, force, onProgress }) progress.done() info(`pushed ${plan.repoRoot} -> ${plan.matched[0]!.name} (repoId=${result.repoId}, branch=${result.branch})`) @@ -349,9 +362,11 @@ export async function cmdPush(args: string[]): Promise { async function cmdPull(args: string[]): Promise { let force = false + let full = false for (const arg of args) { if (arg === '--force') force = true + else if (arg === '--full') full = true } const state = requireState() @@ -377,6 +392,16 @@ async function cmdPull(args: string[]): Promise { die(`multiple Manager repos match origin ${plan.localOrigin}: ${names}; disambiguate with \`ocm pull \``) } + if (!full) { + try { + await mirrorDownFast(plan.matched[0]!.repoId, plan.repoRoot, api, { force }) + info(`pulled ${plan.matched[0]!.name} -> ${plan.repoRoot} via bundle`) + return + } catch (error) { + if (error instanceof MirrorAbort && !error.message.includes('falling back')) throw error + process.stderr.write(`ocm: patch pull failed; falling back to full mirror: ${error instanceof Error ? error.message : String(error)}\n`) + } + } const progress = createProgressReporter('pull') try { await mirrorDown(plan.matched[0]!.repoId, plan.repoRoot, api, { force, onProgress: (bytes) => progress.tick(bytes) }) diff --git a/ocm-cli/src/local-repo.ts b/ocm-cli/src/local-repo.ts index 22f660fda..e40901afc 100644 --- a/ocm-cli/src/local-repo.ts +++ b/ocm-cli/src/local-repo.ts @@ -1,11 +1,20 @@ import { spawnSync } from 'child_process' +import { copyFileSync, existsSync, rmSync } from 'fs' +import { join } from 'path' +import { tmpdir } from 'os' -function git(cwd: string, args: string[]): string | null { - const res = spawnSync('git', args, { cwd, encoding: 'utf-8' }) +function git(cwd: string, args: string[], env: NodeJS.ProcessEnv = process.env): string | null { + const res = spawnSync('git', args, { cwd, encoding: 'utf-8', env }) if (res.status !== 0) return null return (res.stdout ?? '').trim() } +function gitRaw(cwd: string, args: string[], env: NodeJS.ProcessEnv = process.env): string | null { + const res = spawnSync('git', args, { cwd, encoding: 'utf-8', env }) + if (res.status !== 0) return null + return res.stdout ?? '' +} + export function getRepoRoot(cwd: string): string | null { return git(cwd, ['rev-parse', '--show-toplevel']) } @@ -39,6 +48,36 @@ export function getBranchName(dir: string): string | null { return git(dir, ['rev-parse', '--abbrev-ref', 'HEAD']) } +export function getWorkingTreeDiff(dir: string): string { + return gitRaw(dir, ['diff', '--binary', 'HEAD', '--']) ?? '' +} + +export function getHeadSha(dir: string): string | null { + return git(dir, ['rev-parse', 'HEAD']) +} + +export function getMirrorPatch(dir: string): string { + const untracked = gitRaw(dir, ['ls-files', '--others', '--exclude-standard', '-z']) + ?.split('\0') + .filter(Boolean) ?? [] + if (untracked.length === 0) return getWorkingTreeDiff(dir) + + const indexPath = git(dir, ['rev-parse', '--git-path', 'index']) + const tempIndex = join(tmpdir(), `ocm-index-${Date.now()}-${Math.random().toString(36).slice(2)}`) + const env = { ...process.env, GIT_INDEX_FILE: tempIndex } + + try { + if (indexPath && existsSync(indexPath)) { + copyFileSync(indexPath, tempIndex) + } + const add = spawnSync('git', ['add', '-N', '--', ...untracked], { cwd: dir, encoding: 'utf-8', env }) + if (add.status !== 0) return getWorkingTreeDiff(dir) + return gitRaw(dir, ['diff', '--binary', 'HEAD', '--'], env) ?? '' + } finally { + rmSync(tempIndex, { force: true }) + } +} + export function urlsEqual(a: string | null | undefined, b: string | null | undefined): boolean { if (!a || !b) return false return normalizeUrl(a) === normalizeUrl(b) diff --git a/ocm-cli/src/manager-api.ts b/ocm-cli/src/manager-api.ts index c9a2a7bc4..c97ace66c 100644 --- a/ocm-cli/src/manager-api.ts +++ b/ocm-cli/src/manager-api.ts @@ -18,6 +18,30 @@ export interface MirrorCommitResult { created: boolean } +export interface MirrorPatchResult { + repoId: number + fullPath: string + branch: string | null + head: string | null + created: false + applied: true +} + +export interface MirrorPatchSnapshot { + repoId: number + branch: string | null + head: string | null + patch: string +} + +export interface MirrorBundleResult { + repoId: number + fullPath: string + branch: string | null + head: string | null + created: false +} + export class ManagerApiError extends Error { constructor( message: string, @@ -115,4 +139,48 @@ export class ManagerApi { if (!res.ok) throw await formatErrorResponse(res, 'mirror download') return res.body! } + + async mirrorPatch(repoId: number, body: { baseHead: string | null; patch: string; force?: boolean }): Promise { + const res = await fetch(`${this.baseUrl}/api/internal/repos/${repoId}/mirror/patch`, { + method: 'POST', + headers: { ...this.headers(), 'Content-Type': 'application/json' }, + body: JSON.stringify({ baseHead: body.baseHead, patch: body.patch, force: body.force === true }), + }) + + if (!res.ok) throw await formatErrorResponse(res, 'mirror patch') + return (await res.json()) as MirrorPatchResult + } + + async mirrorUploadBundle(repoId: number, bundle: Buffer, opts: { branch: string | null; force?: boolean }): Promise { + const query = opts.force === true ? '?force=1' : '' + const headers: Record = { ...this.headers(), 'Content-Type': 'application/octet-stream' } + if (opts.branch) headers['X-OCM-Branch'] = opts.branch + const ab = bundle.buffer.slice(bundle.byteOffset, bundle.byteOffset + bundle.byteLength) + const res = await fetch(`${this.baseUrl}/api/internal/repos/${repoId}/mirror/bundle${query}`, { + method: 'POST', + headers, + body: ab as ArrayBuffer, + }) + + if (!res.ok) throw await formatErrorResponse(res, 'mirror bundle upload') + return (await res.json()) as MirrorBundleResult + } + + async mirrorDownloadBundle(repoId: number): Promise> { + const res = await fetch(`${this.baseUrl}/api/internal/repos/${repoId}/mirror/bundle`, { + headers: this.headers(), + }) + + if (!res.ok) throw await formatErrorResponse(res, 'mirror bundle download') + return res.body! + } + + async mirrorPatchSnapshot(repoId: number): Promise { + const res = await fetch(`${this.baseUrl}/api/internal/repos/${repoId}/mirror/patch`, { + headers: this.headers(), + }) + + if (!res.ok) throw await formatErrorResponse(res, 'mirror patch snapshot') + return (await res.json()) as MirrorPatchSnapshot + } } diff --git a/ocm-cli/src/mirror.ts b/ocm-cli/src/mirror.ts index 566ecc9cb..eae2a779f 100644 --- a/ocm-cli/src/mirror.ts +++ b/ocm-cli/src/mirror.ts @@ -4,7 +4,7 @@ import * as fsp from 'fs/promises' import { Readable } from 'stream' import { join, dirname } from 'path' import { tmpdir } from 'os' -import { getRepoRoot, getOriginUrl, getDirtyPaths, urlsEqual } from './local-repo.js' +import { getRepoRoot, getOriginUrl, getDirtyPaths, getHeadSha, getBranchName, getMirrorPatch, urlsEqual } from './local-repo.js' import type { ManagerApi } from './manager-api.js' import { ManagerApiError } from './manager-api.js' @@ -302,3 +302,130 @@ export async function mirrorDown( throw error } } + +export async function mirrorUpPatch( + plan: MirrorPlan, + opts: Pick, +): Promise<{ repoId: number; fullPath: string; branch: string | null; head: string | null; created: false; applied: true }> { + const repoId = plan.matched[0]!.repoId + const patch = getMirrorPatch(plan.repoRoot) + return opts.api.mirrorPatch(repoId, { baseHead: getHeadSha(plan.repoRoot), patch, force: opts.force }) +} + +function applyPatch(repoRoot: string, patch: string): void { + if (!patch) return + const child = spawnSync('git', ['apply', '--binary', '--whitespace=nowarn', '-'], { + cwd: repoRoot, + input: patch, + encoding: 'utf-8', + }) + if (child.status !== 0) { + const stderr = (child.stderr ?? '').trim() + throw new Error(`git apply failed${stderr ? `: ${stderr}` : ''}`) + } +} + +function runGit(repoRoot: string, args: string[], input?: string): string { + const res = spawnSync('git', args, { cwd: repoRoot, input, encoding: 'utf-8' }) + if (res.status !== 0) { + const stderr = (res.stderr ?? '').trim() + throw new Error(`git ${args.join(' ')} failed${stderr ? `: ${stderr}` : ''}`) + } + return res.stdout ?? '' +} + +async function createLocalBundle(repoRoot: string): Promise { + const bundlePath = join(tmpdir(), `ocm-bundle-${Date.now()}-${Math.random().toString(36).slice(2)}.bundle`) + runGit(repoRoot, ['bundle', 'create', bundlePath, '--all']) + return bundlePath +} + +function importLocalBundle(repoRoot: string, bundlePath: string, branch: string | null): void { + runGit(repoRoot, ['fetch', bundlePath, '+refs/heads/*:refs/remotes/ocm-sync/*', '+refs/tags/*:refs/tags/*']) + const refs = runGit(repoRoot, ['for-each-ref', '--format=%(refname:strip=3) %(objectname)', 'refs/remotes/ocm-sync']) + 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) + const sha = trimmed.slice(firstSpace + 1) + runGit(repoRoot, ['update-ref', `refs/heads/${name}`, sha]) + } + + if (branch) { + runGit(repoRoot, ['checkout', branch]) + const head = runGit(repoRoot, ['rev-parse', `refs/remotes/ocm-sync/${branch}`]).trim() + if (head) runGit(repoRoot, ['reset', '--hard', head]) + } + + const syncRefs = runGit(repoRoot, ['for-each-ref', '--format=%(refname)', 'refs/remotes/ocm-sync']) + for (const ref of syncRefs.split('\n').map((line) => line.trim()).filter(Boolean)) { + runGit(repoRoot, ['update-ref', '-d', ref]) + } +} + +async function writeBundleStream(repoId: number, api: ManagerApi): Promise { + const bundlePath = join(tmpdir(), `ocm-bundle-down-${Date.now()}-${Math.random().toString(36).slice(2)}.bundle`) + const stream = await api.mirrorDownloadBundle(repoId) + const chunks: Buffer[] = [] + for await (const chunk of Readable.fromWeb(stream as unknown as Parameters[0]) as AsyncIterable) { + chunks.push(Buffer.from(chunk)) + } + await fsp.writeFile(bundlePath, Buffer.concat(chunks)) + return bundlePath +} + +export async function mirrorUpFast( + plan: MirrorPlan, + opts: Pick, +): Promise<{ repoId: number; branch: string | null; head: string | null; created: false }> { + const repoId = plan.matched[0]!.repoId + const bundlePath = await createLocalBundle(plan.repoRoot) + try { + const bundle = await fsp.readFile(bundlePath) + await opts.api.mirrorUploadBundle(repoId, bundle, { branch: getBranchName(plan.repoRoot), force: opts.force }) + const patchResult = await mirrorUpPatch(plan, opts) + return { repoId: patchResult.repoId, branch: patchResult.branch, head: patchResult.head, created: false } + } finally { + await fsp.rm(bundlePath, { force: true }).catch(() => {}) + } +} + +export async function mirrorDownFast( + repoId: number, + repoRoot: string, + api: ManagerApi, + opts: { force: boolean } = { force: false }, +): Promise { + if (!opts.force && getDirtyPaths(repoRoot).size > 0) { + throw new MirrorAbort('working tree has uncommitted changes; rerun with --force') + } + + const snapshot = await api.mirrorPatchSnapshot(repoId) + const bundlePath = await writeBundleStream(repoId, api) + try { + importLocalBundle(repoRoot, bundlePath, snapshot.branch) + applyPatch(repoRoot, snapshot.patch) + } finally { + await fsp.rm(bundlePath, { force: true }).catch(() => {}) + } +} + +export async function mirrorDownPatch( + repoId: number, + repoRoot: string, + api: ManagerApi, + opts: { force: boolean } = { force: false }, +): Promise { + if (!opts.force && getDirtyPaths(repoRoot).size > 0) { + throw new MirrorAbort('working tree has uncommitted changes; rerun with --force') + } + + const snapshot = await api.mirrorPatchSnapshot(repoId) + const localHead = getHeadSha(repoRoot) + if (snapshot.head && localHead && snapshot.head !== localHead) { + throw new MirrorAbort('local HEAD differs from Manager HEAD; falling back to full mirror') + } + applyPatch(repoRoot, snapshot.patch) +} diff --git a/ocm-cli/test/mirror.test.ts b/ocm-cli/test/mirror.test.ts index c4d8ff110..336fccda6 100644 --- a/ocm-cli/test/mirror.test.ts +++ b/ocm-cli/test/mirror.test.ts @@ -4,7 +4,7 @@ import { join } from 'path' import { tmpdir } from 'os' import { randomBytes } from 'crypto' import { spawnSync, execSync } from 'child_process' -import { prepareMirror, MirrorAbort, mirrorDown, mirrorUp } from '../src/mirror' +import { prepareMirror, MirrorAbort, mirrorDown, mirrorDownPatch, mirrorUp, mirrorUpPatch } from '../src/mirror' describe('prepareMirror', () => { let tmpDir: string @@ -539,3 +539,61 @@ describe('mirrorUp chunked upload', () => { expect(combined[1]).toBe(0x8b) }) }) + +describe('mirror patch helpers', () => { + let tmpDir: string + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'mirror-diff-test-')) + }) + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }) + }) + + it('pushes a local working tree patch through the manager API', async () => { + const repoRoot = join(tmpDir, 'repo-local-diff') + mkdirSync(repoRoot) + spawnSync('git', ['init'], { cwd: repoRoot, stdio: 'ignore' }) + spawnSync('git', ['config', 'user.email', 'test@test.com'], { cwd: repoRoot, stdio: 'ignore' }) + spawnSync('git', ['config', 'user.name', 'Test'], { cwd: repoRoot, stdio: 'ignore' }) + writeFileSync(join(repoRoot, 'tracked.txt'), 'before\n') + spawnSync('git', ['add', 'tracked.txt'], { cwd: repoRoot, stdio: 'ignore' }) + spawnSync('git', ['commit', '-m', 'initial'], { cwd: repoRoot, stdio: 'ignore' }) + writeFileSync(join(repoRoot, 'tracked.txt'), 'after\n') + + const api = { + mirrorPatch: vi.fn().mockResolvedValue({ repoId: 1, fullPath: '/tmp/remote', branch: 'main', head: 'abc', created: false, applied: true }), + } + + await mirrorUpPatch({ + repoRoot, + localOrigin: 'https://github.com/test/repo.git', + matched: [{ repoId: 1, name: 'test-repo', originUrl: 'https://github.com/test/repo.git', branch: 'main' }], + }, { api: api as any, force: false }) + + expect(api.mirrorPatch).toHaveBeenCalledTimes(1) + const body = api.mirrorPatch.mock.calls[0]![1] + expect(body.patch).toContain('diff --git a/tracked.txt b/tracked.txt') + expect(body.patch).toContain('-before') + expect(body.patch).toContain('+after') + }) + + it('applies a manager patch during pull patch mode', async () => { + const repoRoot = join(tmpDir, 'repo-remote-diff') + mkdirSync(repoRoot) + spawnSync('git', ['init'], { cwd: repoRoot, stdio: 'ignore' }) + spawnSync('git', ['config', 'user.email', 'test@test.com'], { cwd: repoRoot, stdio: 'ignore' }) + spawnSync('git', ['config', 'user.name', 'Test'], { cwd: repoRoot, stdio: 'ignore' }) + writeFileSync(join(repoRoot, 'tracked.txt'), 'before\n') + spawnSync('git', ['add', 'tracked.txt'], { cwd: repoRoot, stdio: 'ignore' }) + spawnSync('git', ['commit', '-m', 'initial'], { cwd: repoRoot, stdio: 'ignore' }) + const head = execSync('git rev-parse HEAD', { cwd: repoRoot, encoding: 'utf-8' }).trim() + const patch = 'diff --git a/tracked.txt b/tracked.txt\nindex 6e58d95..7c6cae9 100644\n--- a/tracked.txt\n+++ b/tracked.txt\n@@ -1 +1 @@\n-before\n+after\n' + const api = { mirrorPatchSnapshot: vi.fn().mockResolvedValue({ repoId: 1, branch: 'main', head, patch }) } + + await mirrorDownPatch(1, repoRoot, api as any, { force: false }) + + expect(readFileSync(join(repoRoot, 'tracked.txt'), 'utf-8')).toBe('after\n') + }) +}) From 0f8f257f66e9aa8176f35285340739635e5ff146 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Fri, 26 Jun 2026 12:41:21 -0400 Subject: [PATCH 2/8] feat: add OpenCode model editor and HEAD-safe bundle import --- backend/src/routes/internal/repo-mirror.ts | 1 + backend/src/routes/settings.test.ts | 79 ++++++++++ backend/src/routes/settings.ts | 50 +++++++ .../test/routes/internal/repo-mirror.test.ts | 41 ++++++ frontend/src/api/settings.ts | 13 ++ .../settings/OpenCodeModelDialog.tsx | 139 +++++++++++++++++- .../settings/OpenCodeModelsEditor.test.tsx | 109 +++++++++++++- frontend/src/test/setup.ts | 4 + ocm-cli/package.json | 2 +- ocm-cli/src/local-repo.ts | 3 +- ocm-cli/src/mirror.ts | 1 + ocm-cli/test/mirror.test.ts | 28 ++++ 12 files changed, 464 insertions(+), 6 deletions(-) diff --git a/backend/src/routes/internal/repo-mirror.ts b/backend/src/routes/internal/repo-mirror.ts index 23763cb44..1277a7d57 100644 --- a/backend/src/routes/internal/repo-mirror.ts +++ b/backend/src/routes/internal/repo-mirror.ts @@ -102,6 +102,7 @@ async function importBundle(fullPath: string, bundlePath: string, branch: string const firstSpace = trimmed.indexOf(' ') if (firstSpace === -1) continue const name = trimmed.slice(0, firstSpace) + if (name === 'HEAD') continue const sha = trimmed.slice(firstSpace + 1) await gitRaw(fullPath, ['update-ref', `refs/heads/${name}`, sha]) } diff --git a/backend/src/routes/settings.test.ts b/backend/src/routes/settings.test.ts index 429a64ca3..1a1d739bd 100644 --- a/backend/src/routes/settings.test.ts +++ b/backend/src/routes/settings.test.ts @@ -371,3 +371,82 @@ describe('settings routes — OpenCode directory file upload', () => { ]) }) }) + +describe('settings routes — opencode model discovery', () => { + let db: Database + let app: Hono + let originalFetch: typeof globalThis.fetch + let fetchMock: ReturnType + + beforeEach(() => { + db = createTestDb() + app = createTestApp(db) + originalFetch = globalThis.fetch + fetchMock = vi.fn() + globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch + }) + + afterEach(() => { + globalThis.fetch = originalFetch + db.close() + }) + + it('returns 400 when baseUrl is missing', async () => { + const res = await app.request('/settings/opencode-discover-models') + expect(res.status).toBe(400) + }) + + it('returns 400 when baseUrl is invalid', async () => { + const res = await app.request('/settings/opencode-discover-models?baseUrl=not-a-url') + expect(res.status).toBe(400) + }) + + it('returns 400 when baseUrl is not http/https', async () => { + const res = await app.request('/settings/opencode-discover-models?baseUrl=ftp://example.com') + expect(res.status).toBe(400) + }) + + it('discovers models from the endpoint', async () => { + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ data: [{ id: 'gpt-4o' }, { id: 'gpt-3.5-turbo' }] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) + + const res = await app.request('/settings/opencode-discover-models?baseUrl=http://localhost:1234&refresh=true') + expect(res.status).toBe(200) + const data = (await res.json()) as { models: string[]; cached: boolean } + expect(data.models).toEqual(['gpt-4o', 'gpt-3.5-turbo']) + expect(data.cached).toBe(false) + }) + + it('returns cached models on subsequent requests without re-fetching', async () => { + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ data: [{ id: 'llama-3' }] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) + + const firstRes = await app.request('/settings/opencode-discover-models?baseUrl=http://localhost:5678&refresh=true') + const firstData = (await firstRes.json()) as { models: string[]; cached: boolean } + expect(firstData.models).toEqual(['llama-3']) + expect(firstData.cached).toBe(false) + + const secondRes = await app.request('/settings/opencode-discover-models?baseUrl=http://localhost:5678') + const secondData = (await secondRes.json()) as { models: string[]; cached: boolean } + expect(secondData.models).toEqual(['llama-3']) + expect(secondData.cached).toBe(true) + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('returns empty models when endpoint is unreachable', async () => { + fetchMock.mockRejectedValue(new Error('connection refused')) + + const res = await app.request('/settings/opencode-discover-models?baseUrl=http://localhost:9999&refresh=true') + expect(res.status).toBe(200) + const data = (await res.json()) as { models: string[] } + expect(data.models).toEqual([]) + }) +}) diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index 3dbc14454..bf3495c3a 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -24,6 +24,13 @@ import { type SkillScope, } from '@opencode-manager/shared' import { logger } from '../utils/logger' +import { + fetchAvailableModels, + generateDiscoveryCacheKey, + getCachedDiscovery, + cacheDiscovery, + ensureDiscoveryCacheDir, +} from '../utils/discovery-cache' import { opencodeServerManager, ConfigReloadError } from '../services/opencode-single-server' import { getOrCreateInternalToken, rotateInternalToken } from '../services/internal-token' import { sseAggregator } from '../services/sse-aggregator' @@ -1876,5 +1883,48 @@ export function createSettingsRoutes(db: Database, gitAuthService: GitAuthServic } }) + app.get('/opencode-discover-models', async (c) => { + try { + const baseUrl = c.req.query('baseUrl') + const apiKey = c.req.query('apiKey') || '' + const forceRefresh = c.req.query('refresh') === 'true' + + if (!baseUrl || !baseUrl.trim()) { + return c.json({ error: 'baseUrl is required' }, 400) + } + + let parsedUrl: URL + try { + parsedUrl = new URL(baseUrl.trim()) + } catch { + return c.json({ error: 'Invalid baseUrl' }, 400) + } + if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') { + return c.json({ error: 'baseUrl must be an http or https URL' }, 400) + } + + const trimmedBaseUrl = baseUrl.trim() + const cacheKey = generateDiscoveryCacheKey(trimmedBaseUrl, apiKey, 'opencode-models') + + if (!forceRefresh) { + const cachedModels = await getCachedDiscovery(cacheKey) + if (cachedModels) { + return c.json({ models: cachedModels, cached: true }) + } + } + + await ensureDiscoveryCacheDir() + logger.info(`Discovering OpenCode models from ${trimmedBaseUrl}`) + + const models = await fetchAvailableModels(trimmedBaseUrl, apiKey, /.*/, []) + await cacheDiscovery(cacheKey, models) + + return c.json({ models, cached: false }) + } catch (error) { + logger.error('Failed to discover OpenCode models:', error) + return c.json({ error: 'Failed to discover models' }, 500) + } + }) + return app } diff --git a/backend/test/routes/internal/repo-mirror.test.ts b/backend/test/routes/internal/repo-mirror.test.ts index 6e8ffc336..8c6945d8d 100644 --- a/backend/test/routes/internal/repo-mirror.test.ts +++ b/backend/test/routes/internal/repo-mirror.test.ts @@ -284,6 +284,47 @@ describe('internal-repo-mirror routes', () => { expect(featureRef.status).toBe(0) }) + it('imports a bundle whose ocm-sync refs include a symbolic HEAD without failing', async () => { + const sourceDir = join(getTmpRoot(), 'bundle-source-head') + mkdirSync(sourceDir, { recursive: true }) + spawnSync('git', ['init', '-b', 'main'], { cwd: sourceDir, stdio: 'ignore' }) + spawnSync('git', ['config', 'user.email', 'test@test.com'], { cwd: sourceDir, stdio: 'ignore' }) + spawnSync('git', ['config', 'user.name', 'Test'], { cwd: sourceDir, stdio: 'ignore' }) + writeFileSync(join(sourceDir, 'tracked.txt'), 'from bundle\n') + spawnSync('git', ['add', 'tracked.txt'], { cwd: sourceDir, stdio: 'ignore' }) + spawnSync('git', ['commit', '-m', 'source'], { cwd: sourceDir, stdio: 'ignore' }) + const headSha = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: sourceDir, encoding: 'utf-8' }).stdout.trim() + spawnSync('git', ['checkout', headSha], { cwd: sourceDir, stdio: 'ignore' }) + const bundlePath = join(getTmpRoot(), 'source-head.bundle') + spawnSync('git', ['bundle', 'create', bundlePath, '--all'], { cwd: sourceDir, stdio: 'ignore' }) + + const targetDir = join(getTmpRoot(), 'bundle-target-head') + mkdirSync(targetDir, { recursive: true }) + spawnSync('git', ['init', '-b', 'main'], { cwd: targetDir, stdio: 'ignore' }) + spawnSync('git', ['config', 'user.email', 'test@test.com'], { cwd: targetDir, stdio: 'ignore' }) + spawnSync('git', ['config', 'user.name', 'Test'], { cwd: targetDir, stdio: 'ignore' }) + writeFileSync(join(targetDir, 'old.txt'), 'old\n') + spawnSync('git', ['add', 'old.txt'], { cwd: targetDir, stdio: 'ignore' }) + spawnSync('git', ['commit', '-m', 'target'], { cwd: targetDir, stdio: 'ignore' }) + + mockGetRepoById.mockReturnValue({ id: 1, fullPath: targetDir }) + mockSafeGitOut.mockImplementation(async (_repoPath: string, args: string[]) => { + if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref') return 'main' + if (args[0] === 'rev-parse' && args[1] === 'HEAD') return headSha + return null + }) + + const res = await app.request('/api/internal/repos/1/mirror/bundle', { + method: 'POST', + body: readFileSync(bundlePath), + headers: { 'content-type': 'application/octet-stream' }, + }) + + expect(res.status).toBe(200) + const headRef = spawnSync('git', ['rev-parse', '--verify', 'refs/heads/HEAD'], { cwd: targetDir, encoding: 'utf-8' }) + expect(headRef.status).not.toBe(0) + }) + it('returns a git bundle for pull fast path', async () => { const repoDir = join(getTmpRoot(), 'bundle-download') mkdirSync(repoDir, { recursive: true }) diff --git a/frontend/src/api/settings.ts b/frontend/src/api/settings.ts index c3cacd04f..3f0787cb9 100644 --- a/frontend/src/api/settings.ts +++ b/frontend/src/api/settings.ts @@ -132,6 +132,19 @@ export const settingsApi = { } }, + discoverOpenCodeModels: async ( + baseUrl: string, + apiKey?: string, + forceRefresh = false, + ): Promise<{ models: string[]; cached: boolean }> => { + const params: Record = { baseUrl } + if (apiKey) params.apiKey = apiKey + if (forceRefresh) params.refresh = 'true' + return fetchWrapper(`${API_BASE_URL}/api/settings/opencode-discover-models`, { + params, + }) + }, + restartOpenCodeServer: async (): Promise<{ success: boolean; message: string; details?: string }> => { return fetchWrapper(`${API_BASE_URL}/api/settings/opencode-restart`, { method: 'POST', diff --git a/frontend/src/components/settings/OpenCodeModelDialog.tsx b/frontend/src/components/settings/OpenCodeModelDialog.tsx index 2ef5a196b..394bd9905 100644 --- a/frontend/src/components/settings/OpenCodeModelDialog.tsx +++ b/frontend/src/components/settings/OpenCodeModelDialog.tsx @@ -1,7 +1,7 @@ import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { z } from 'zod' -import { useCallback, useEffect, useMemo, useRef } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Button } from '@/components/ui/button' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog' import { Input } from '@/components/ui/input' @@ -9,6 +9,10 @@ import { Textarea } from '@/components/ui/textarea' import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Switch } from '@/components/ui/switch' +import { Combobox } from '@/components/ui/combobox' +import { Label } from '@/components/ui/label' +import { RefreshCw, Loader2 } from 'lucide-react' +import { settingsApi } from '@/api/settings' import type { ModelConfig, ProviderConfig } from '@/api/types/settings' type ConfigModel = Partial & { @@ -73,6 +77,18 @@ function parseOptionalNumber(value: string): number | undefined { return Number.isFinite(parsed) ? parsed : undefined } +function sanitizeModelId(modelId: string): string { + return modelId.replace(/[^a-zA-Z0-9._-]/g, '-') +} + +function prettifyModelName(modelId: string): string { + return modelId + .replace(/[-_/]/g, ' ') + .replace(/\s+/g, ' ') + .trim() + .replace(/\b\w/g, (c) => c.toUpperCase()) +} + function jsonObjectField(label: string) { return z.string().superRefine((value, ctx) => { if (!value.trim()) return @@ -248,14 +264,83 @@ export function OpenCodeModelDialog({ const { isValid } = form.formState const createNewProvider = form.watch('createNewProvider') const newProviderType = form.watch('newProviderType') + const watchedProviderId = form.watch('providerId') + const watchedNewProviderBaseUrl = form.watch('newProviderBaseUrl') const resetKeyRef = useRef(null) const resetKey = editingModel ? `edit-${editingModel.providerId}-${editingModel.modelId}` : `create-${selectedProviderId || availableProviders[0] || ''}` + const [discoveredModels, setDiscoveredModels] = useState([]) + const [isLoadingModels, setIsLoadingModels] = useState(false) + const [discoveryError, setDiscoveryError] = useState(null) + const [discoveryApiKey, setDiscoveryApiKey] = useState('') + + const discoveryBaseUrl = useMemo(() => { + if (createNewProvider) { + return watchedNewProviderBaseUrl?.trim() || '' + } + const provider = existingProviders?.[watchedProviderId] + const options = provider?.options as { baseURL?: string } | undefined + return (options?.baseURL || provider?.api || '').trim() + }, [createNewProvider, watchedNewProviderBaseUrl, watchedProviderId, existingProviders]) + + const discoverModels = useCallback(async (forceRefresh = false) => { + if (!discoveryBaseUrl) { + setDiscoveredModels([]) + return + } + try { + new URL(discoveryBaseUrl) + } catch { + setDiscoveredModels([]) + return + } + setIsLoadingModels(true) + setDiscoveryError(null) + try { + const response = await settingsApi.discoverOpenCodeModels(discoveryBaseUrl, discoveryApiKey || undefined, forceRefresh) + setDiscoveredModels(response.models) + } catch { + setDiscoveredModels([]) + setDiscoveryError('Failed to discover models. Check the endpoint URL and API key.') + } finally { + setIsLoadingModels(false) + } + }, [discoveryBaseUrl, discoveryApiKey]) + + useEffect(() => { + if (!open) return + if (!discoveryBaseUrl) { + setDiscoveredModels([]) + setDiscoveryError(null) + return + } + if (createNewProvider && newProviderType !== 'api') return + const timer = setTimeout(() => { + void discoverModels() + }, 600) + return () => clearTimeout(timer) + }, [open, discoveryBaseUrl, createNewProvider, newProviderType, discoverModels]) + + const handleDiscoveredModelSelect = useCallback((modelId: string) => { + const currentModelId = form.getValues('modelId') + const sanitized = sanitizeModelId(modelId) + if (!currentModelId && sanitized) { + form.setValue('modelId', sanitized, { shouldValidate: true, shouldDirty: true }) + } + const currentDisplayName = form.getValues('displayName') + if (!currentDisplayName) { + form.setValue('displayName', prettifyModelName(modelId), { shouldValidate: true, shouldDirty: true }) + } + }, [form]) + useEffect(() => { if (!open) { resetKeyRef.current = null + setDiscoveredModels([]) + setDiscoveryError(null) + setDiscoveryApiKey('') return } @@ -409,6 +494,22 @@ export function OpenCodeModelDialog({ )} /> )} + {newProviderType === 'api' && ( +
+ + setDiscoveryApiKey(e.target.value)} + placeholder="Optional - used to fetch the model list" + /> +

+ Used only to discover available models. Configure provider auth separately via env vars or headers. +

+
+ )} + {newProviderType === 'npm' && ( ( @@ -451,8 +552,40 @@ export function OpenCodeModelDialog({ ( - Provider Model ID - +
+ Provider Model ID + {discoveryBaseUrl && ( + + )} +
+ + { + field.onChange(value) + if (discoveredModels.includes(value)) { + handleDiscoveredModelSelect(value) + } + }} + options={discoveredModels.map((m) => ({ value: m, label: m }))} + placeholder="e.g., MiniMax-M2.7" + disabled={isLoadingModels} + allowCustomValue={true} + /> + + {discoveryError &&

{discoveryError}

} + {!discoveryError && isLoadingModels &&

Discovering models...

} + {!discoveryError && !isLoadingModels && discoveredModels.length > 0 && ( +

{discoveredModels.length} model{discoveredModels.length === 1 ? '' : 's'} found

+ )}
)} /> diff --git a/frontend/src/components/settings/OpenCodeModelsEditor.test.tsx b/frontend/src/components/settings/OpenCodeModelsEditor.test.tsx index 6c2c36f6c..554749124 100644 --- a/frontend/src/components/settings/OpenCodeModelsEditor.test.tsx +++ b/frontend/src/components/settings/OpenCodeModelsEditor.test.tsx @@ -1,8 +1,15 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { render, screen, fireEvent, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { OpenCodeModelsEditor } from './OpenCodeModelsEditor' import type { ConfigProvider, ConfigModel } from './OpenCodeModelsEditor' +import { settingsApi } from '../../api/settings' + +vi.mock('../../api/settings', () => ({ + settingsApi: { + discoverOpenCodeModels: vi.fn(), + }, +})) const mockProviders: Record = { openai: { @@ -351,3 +358,103 @@ describe('OpenCodeModelDialog', () => { }) }) }) + +describe('OpenCodeModelDialog — model discovery', () => { + const discoveryProps = { + open: true, + onOpenChange: vi.fn(), + onSubmit: vi.fn(), + availableProviders: [], + selectedProviderId: '', + } + + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(settingsApi.discoverOpenCodeModels).mockReset() + }) + + afterEach(() => { + vi.mocked(settingsApi.discoverOpenCodeModels).mockReset() + }) + + it('shows a Discover button when a new API provider has a base URL', async () => { + const { OpenCodeModelDialog } = await import('./OpenCodeModelDialog') + render() + + const baseUrlInput = screen.getByPlaceholderText('e.g., https://api.openai.com/v1') + fireEvent.change(baseUrlInput, { target: { value: 'http://localhost:1234' } }) + + await waitFor(() => { + expect(screen.getByRole('button', { name: /discover|refresh/i })).toBeInTheDocument() + }) + }) + + it('discovers models and shows the count when Discover is clicked', async () => { + vi.mocked(settingsApi.discoverOpenCodeModels).mockResolvedValue({ + models: ['gpt-4o', 'gpt-3.5-turbo'], + cached: false, + }) + const { OpenCodeModelDialog } = await import('./OpenCodeModelDialog') + render() + + fireEvent.change(screen.getByPlaceholderText('e.g., https://api.openai.com/v1'), { + target: { value: 'http://localhost:1234' }, + }) + + const discoverButton = await screen.findByRole('button', { name: /discover|refresh/i }) + fireEvent.click(discoverButton) + + await waitFor(() => { + expect(screen.getByText(/2 models found/i)).toBeInTheDocument() + }) + expect(settingsApi.discoverOpenCodeModels).toHaveBeenCalledWith('http://localhost:1234', undefined, true) + }) + + it('auto-populates config key and display name when selecting a discovered model', async () => { + vi.mocked(settingsApi.discoverOpenCodeModels).mockResolvedValue({ + models: ['gpt-4o'], + cached: false, + }) + const { OpenCodeModelDialog } = await import('./OpenCodeModelDialog') + render() + + fireEvent.change(screen.getByPlaceholderText('e.g., https://api.openai.com/v1'), { + target: { value: 'http://localhost:1234' }, + }) + + const discoverButton = await screen.findByRole('button', { name: /discover|refresh/i }) + fireEvent.click(discoverButton) + + await waitFor(() => { + expect(screen.getByText(/1 model found/i)).toBeInTheDocument() + }) + + const providerModelInput = screen.getByPlaceholderText('e.g., MiniMax-M2.7') + fireEvent.focus(providerModelInput) + + const option = await screen.findByRole('button', { name: 'gpt-4o' }) + fireEvent.click(option) + + const modelIdInput = document.querySelector('input[name="modelId"]') as HTMLInputElement + const displayNameInput = document.querySelector('input[name="displayName"]') as HTMLInputElement + expect(modelIdInput).toHaveValue('gpt-4o') + expect(displayNameInput).toHaveValue('Gpt 4o') + }) + + it('shows an error message when discovery fails', async () => { + vi.mocked(settingsApi.discoverOpenCodeModels).mockRejectedValue(new Error('network error')) + const { OpenCodeModelDialog } = await import('./OpenCodeModelDialog') + render() + + fireEvent.change(screen.getByPlaceholderText('e.g., https://api.openai.com/v1'), { + target: { value: 'http://localhost:1234' }, + }) + + const discoverButton = await screen.findByRole('button', { name: /discover|refresh/i }) + fireEvent.click(discoverButton) + + await waitFor(() => { + expect(screen.getByText(/failed to discover models/i)).toBeInTheDocument() + }) + }) +}) diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts index 814277ca8..4480e2213 100644 --- a/frontend/src/test/setup.ts +++ b/frontend/src/test/setup.ts @@ -25,3 +25,7 @@ global.ResizeObserver = class ResizeObserver { unobserve() {} disconnect() {} } + +if (!Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = () => {} +} diff --git a/ocm-cli/package.json b/ocm-cli/package.json index 3a88447b0..aa3d1c697 100644 --- a/ocm-cli/package.json +++ b/ocm-cli/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-manager/ocm-cli", - "version": "0.1.4", + "version": "0.1.5", "description": "OpenCode Manager CLI: attach a local OpenCode TUI to a Manager-hosted repo.", "license": "MIT", "repository": { diff --git a/ocm-cli/src/local-repo.ts b/ocm-cli/src/local-repo.ts index e40901afc..5dd1c679c 100644 --- a/ocm-cli/src/local-repo.ts +++ b/ocm-cli/src/local-repo.ts @@ -45,7 +45,8 @@ function normalizeUrl(url: string): string { } export function getBranchName(dir: string): string | null { - return git(dir, ['rev-parse', '--abbrev-ref', 'HEAD']) + const branch = git(dir, ['rev-parse', '--abbrev-ref', 'HEAD']) + return branch && branch !== 'HEAD' ? branch : null } export function getWorkingTreeDiff(dir: string): string { diff --git a/ocm-cli/src/mirror.ts b/ocm-cli/src/mirror.ts index eae2a779f..9f863430f 100644 --- a/ocm-cli/src/mirror.ts +++ b/ocm-cli/src/mirror.ts @@ -349,6 +349,7 @@ function importLocalBundle(repoRoot: string, bundlePath: string, branch: string const firstSpace = trimmed.indexOf(' ') if (firstSpace === -1) continue const name = trimmed.slice(0, firstSpace) + if (name === 'HEAD') continue const sha = trimmed.slice(firstSpace + 1) runGit(repoRoot, ['update-ref', `refs/heads/${name}`, sha]) } diff --git a/ocm-cli/test/mirror.test.ts b/ocm-cli/test/mirror.test.ts index 336fccda6..74c4a1f45 100644 --- a/ocm-cli/test/mirror.test.ts +++ b/ocm-cli/test/mirror.test.ts @@ -5,6 +5,7 @@ import { tmpdir } from 'os' import { randomBytes } from 'crypto' import { spawnSync, execSync } from 'child_process' import { prepareMirror, MirrorAbort, mirrorDown, mirrorDownPatch, mirrorUp, mirrorUpPatch } from '../src/mirror' +import { getBranchName } from '../src/local-repo' describe('prepareMirror', () => { let tmpDir: string @@ -67,6 +68,33 @@ describe('prepareMirror', () => { }) }) +describe('local repo branch detection', () => { + let tmpDir: string + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'local-repo-test-')) + }) + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }) + }) + + it('treats detached HEAD as no branch', () => { + const repoRoot = join(tmpDir, 'detached') + mkdirSync(repoRoot) + spawnSync('git', ['init'], { cwd: repoRoot, stdio: 'ignore' }) + spawnSync('git', ['config', 'user.email', 'test@test.com'], { cwd: repoRoot, stdio: 'ignore' }) + spawnSync('git', ['config', 'user.name', 'Test'], { cwd: repoRoot, stdio: 'ignore' }) + writeFileSync(join(repoRoot, 'tracked.txt'), 'content\n') + spawnSync('git', ['add', 'tracked.txt'], { cwd: repoRoot, stdio: 'ignore' }) + spawnSync('git', ['commit', '-m', 'initial'], { cwd: repoRoot, stdio: 'ignore' }) + const head = execSync('git rev-parse HEAD', { cwd: repoRoot, encoding: 'utf-8' }).trim() + spawnSync('git', ['checkout', head], { cwd: repoRoot, stdio: 'ignore' }) + + expect(getBranchName(repoRoot)).toBeNull() + }) +}) + describe('cmdPush', () => { let originalArgv: string[] let originalIsTTY: boolean | undefined From c92d0b25d1c3eeb8233a700a27f0b3fc13a2284c Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Fri, 26 Jun 2026 23:40:09 -0400 Subject: [PATCH 3/8] 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 --- README.md | 2 +- backend/src/routes/internal/repo-mirror.ts | 25 +++-- backend/src/routes/settings.ts | 29 ++--- backend/src/routes/stt.ts | 51 +++------ backend/src/routes/tts.ts | 105 +++++++----------- backend/src/utils/discovery-cache.ts | 51 ++++++++- docs/ocm-cli.md | 8 +- .../settings/OpenCodeModelDialog.tsx | 7 +- ocm-cli/README.md | 12 +- ocm-cli/src/local-repo.ts | 30 ++++- ocm-cli/src/manager-api.ts | 4 +- ocm-cli/src/mirror.ts | 56 ++++------ ocm-cli/test/mirror.test.ts | 21 +--- 13 files changed, 197 insertions(+), 204 deletions(-) diff --git a/README.md b/README.md index c9c9c5407..e3dc1e936 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ For OAuth, Passkeys, Push Notifications (VAPID), and advanced configuration, see ## `ocm` CLI -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. +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. See the [`ocm` CLI guide](docs/ocm-cli.md) for setup and commands. diff --git a/backend/src/routes/internal/repo-mirror.ts b/backend/src/routes/internal/repo-mirror.ts index 1277a7d57..98407dbb6 100644 --- a/backend/src/routes/internal/repo-mirror.ts +++ b/backend/src/routes/internal/repo-mirror.ts @@ -19,6 +19,7 @@ import { readUploadMeta, deleteUploadSession, getPartPath, + getStagingRoot, extractPartsToStaging, atomicSwapIntoPlace, carryOverIgnoredFiles, @@ -96,6 +97,7 @@ async function applyMirrorPatch(fullPath: string, patch: string): Promise async function importBundle(fullPath: string, bundlePath: string, branch: string | null): Promise { 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 @@ -104,7 +106,10 @@ async function importBundle(fullPath: string, bundlePath: string, branch: string const name = trimmed.slice(0, firstSpace) if (name === 'HEAD') continue const sha = trimmed.slice(firstSpace + 1) - await gitRaw(fullPath, ['update-ref', `refs/heads/${name}`, sha]) + updates.push(`update refs/heads/${name} ${sha}\n`) + } + if (updates.length > 0) { + await gitRaw(fullPath, ['update-ref', '--stdin'], process.env, updates.join('')) } if (branch) { @@ -113,17 +118,15 @@ async function importBundle(fullPath: string, bundlePath: string, branch: string if (head) await gitRaw(fullPath, ['reset', '--hard', head]) } - await gitRaw(fullPath, ['for-each-ref', '--format=%(refname)', 'refs/remotes/ocm-sync']) - .then(async (out) => { - for (const ref of out.split('\n').map((line) => line.trim()).filter(Boolean)) { - await gitRaw(fullPath, ['update-ref', '-d', ref]) - } - }) - .catch(() => {}) + 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(() => {}) + } } async function createBundle(fullPath: string): Promise { - const stagingRoot = join(getReposPath(), '.ocm-staging') + const stagingRoot = getStagingRoot() mkdirSync(stagingRoot, { recursive: true }) const bundleDir = mkdtempSync(join(stagingRoot, 'bundle-')) const bundlePath = join(bundleDir, 'repo.bundle') @@ -338,7 +341,7 @@ export function createInternalRepoMirrorRoutes(db: Database) { const rawBody = c.req.raw.body if (!rawBody) return c.json({ error: 'no body provided' }, 400) - const stagingRoot = join(getReposPath(), '.ocm-staging') + const stagingRoot = getStagingRoot() mkdirSync(stagingRoot, { recursive: true }) const bundleDir = mkdtempSync(join(stagingRoot, 'bundle-upload-')) const bundlePath = join(bundleDir, 'repo.bundle') @@ -460,7 +463,7 @@ export function createInternalRepoMirrorRoutes(db: Database) { try { const ignored = await gitOut(fullPath, ['ls-files', '--others', '--ignored', '--exclude-standard', '--directory']) if (ignored.trim()) { - const excludeParent = join(getReposPath(), '.ocm-staging') + const excludeParent = getStagingRoot() mkdirSync(excludeParent, { recursive: true }) ignoreFile = mkdtempSync(join(excludeParent, 'exclude-')) writeFileSync(join(ignoreFile, '.gitignore'), ignored) diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index bf3495c3a..e2cd896a9 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -25,11 +25,7 @@ import { } from '@opencode-manager/shared' import { logger } from '../utils/logger' import { - fetchAvailableModels, - generateDiscoveryCacheKey, - getCachedDiscovery, - cacheDiscovery, - ensureDiscoveryCacheDir, + discoverModelsCached, } from '../utils/discovery-cache' import { opencodeServerManager, ConfigReloadError } from '../services/opencode-single-server' import { getOrCreateInternalToken, rotateInternalToken } from '../services/internal-token' @@ -1904,22 +1900,17 @@ export function createSettingsRoutes(db: Database, gitAuthService: GitAuthServic } const trimmedBaseUrl = baseUrl.trim() - const cacheKey = generateDiscoveryCacheKey(trimmedBaseUrl, apiKey, 'opencode-models') - if (!forceRefresh) { - const cachedModels = await getCachedDiscovery(cacheKey) - if (cachedModels) { - return c.json({ models: cachedModels, cached: true }) - } - } - - await ensureDiscoveryCacheDir() - logger.info(`Discovering OpenCode models from ${trimmedBaseUrl}`) - - const models = await fetchAvailableModels(trimmedBaseUrl, apiKey, /.*/, []) - await cacheDiscovery(cacheKey, models) + const { models, cached } = await discoverModelsCached({ + baseUrl: trimmedBaseUrl, + apiKey, + type: 'opencode-models', + filterPattern: /.*/, + defaultModels: [], + forceRefresh, + }) - return c.json({ models, cached: false }) + return c.json({ models, cached }) } catch (error) { logger.error('Failed to discover OpenCode models:', error) return c.json({ error: 'Failed to discover models' }, 500) diff --git a/backend/src/routes/stt.ts b/backend/src/routes/stt.ts index eb3884fe4..7b598ba72 100644 --- a/backend/src/routes/stt.ts +++ b/backend/src/routes/stt.ts @@ -4,11 +4,7 @@ import { SettingsService } from '../services/settings' import { logger } from '../utils/logger' import { normalizeToBaseUrl, - ensureDiscoveryCacheDir, - getCachedDiscovery, - cacheDiscovery, - generateDiscoveryCacheKey, - fetchAvailableModels, + discoverModelsCached, } from '../utils/discovery-cache' import { type STTConfig } from '@opencode-manager/shared' @@ -142,37 +138,26 @@ export function createSTTRoutes(db: Database) { return c.json({ error: 'STT not configured' }, 400) } - const cacheKey = generateDiscoveryCacheKey(sttConfig.endpoint, sttConfig.apiKey, 'models') + const { models, cached } = await discoverModelsCached({ + baseUrl: sttConfig.endpoint, + apiKey: sttConfig.apiKey, + type: 'models', + filterPattern: /whisper|transcri/, + defaultModels: ['whisper-1'], + forceRefresh, + }) - if (!forceRefresh) { - const cachedModels = await getCachedDiscovery(cacheKey) - if (cachedModels) { - logger.info(`STT models cache hit for user ${userId}`) - return c.json({ models: cachedModels, cached: true }) - } + if (!cached) { + await settingsService.updateSettings({ + stt: { + ...sttConfig, + availableModels: models, + lastModelsFetch: Date.now(), + } as STTConfig, + }, userId) } - await ensureDiscoveryCacheDir() - logger.info(`Fetching STT models for user ${userId}`) - - const models = await fetchAvailableModels( - sttConfig.endpoint, - sttConfig.apiKey, - /whisper|transcri/, - ['whisper-1'], - ) - await cacheDiscovery(cacheKey, models) - - await settingsService.updateSettings({ - stt: { - ...sttConfig, - availableModels: models, - lastModelsFetch: Date.now() - } as STTConfig - }, userId) - - logger.info(`Fetched ${models.length} STT models`) - return c.json({ models, cached: false }) + return c.json({ models, cached }) } catch (error) { logger.error('Failed to fetch STT models:', error) return c.json({ error: 'Failed to fetch models' }, 500) diff --git a/backend/src/routes/tts.ts b/backend/src/routes/tts.ts index 52df769ca..244c96fbc 100644 --- a/backend/src/routes/tts.ts +++ b/backend/src/routes/tts.ts @@ -9,11 +9,8 @@ import { logger } from '../utils/logger' import { getWorkspacePath } from '@opencode-manager/shared/config/env' import { normalizeToBaseUrl, - ensureDiscoveryCacheDir, - getCachedDiscovery, - cacheDiscovery, - generateDiscoveryCacheKey, - fetchAvailableModels, + discoverModelsCached, + discoverCached, } from '../utils/discovery-cache' const TTS_CACHE_DIR = join(getWorkspacePath(), 'cache', 'tts') @@ -357,40 +354,26 @@ export function createTTSRoutes(db: Database) { return c.json({ error: 'TTS not configured' }, 400) } - const cacheKey = generateDiscoveryCacheKey(ttsConfig.endpoint, ttsConfig.apiKey, 'models') - - // Check cache first (unless force refresh) - if (!forceRefresh) { - const cachedModels = await getCachedDiscovery(cacheKey) - if (cachedModels) { - logger.info(`Models cache hit for user ${userId}`) - return c.json({ models: cachedModels, cached: true }) - } + const { models, cached } = await discoverModelsCached({ + baseUrl: ttsConfig.endpoint, + apiKey: ttsConfig.apiKey, + type: 'models', + filterPattern: /tts|audio|speech/, + defaultModels: ['tts-1', 'tts-1-hd'], + forceRefresh, + }) + + if (!cached) { + await settingsService.updateSettings({ + tts: { + ...ttsConfig, + availableModels: models, + lastModelsFetch: Date.now(), + }, + }, userId) } - - // Fetch from API - await ensureDiscoveryCacheDir() - logger.info(`Fetching TTS models for user ${userId}`) - - const models = await fetchAvailableModels( - ttsConfig.endpoint, - ttsConfig.apiKey, - /tts|audio|speech/, - ['tts-1', 'tts-1-hd'], - ) - await cacheDiscovery(cacheKey, models) - - // Update user preferences with available models - await settingsService.updateSettings({ - tts: { - ...ttsConfig, - availableModels: models, - lastModelsFetch: Date.now() - } - }, userId) - - logger.info(`Fetched ${models.length} TTS models`) - return c.json({ models, cached: false }) + + return c.json({ models, cached }) } catch (error) { logger.error('Failed to fetch TTS models:', error) return c.json({ error: 'Failed to fetch models' }, 500) @@ -410,35 +393,25 @@ export function createTTSRoutes(db: Database) { return c.json({ error: 'TTS not configured' }, 400) } - const cacheKey = generateDiscoveryCacheKey(ttsConfig.endpoint, ttsConfig.apiKey, 'voices') - - // Check cache first (unless force refresh) - if (!forceRefresh) { - const cachedVoices = await getCachedDiscovery(cacheKey) - if (cachedVoices) { - logger.info(`Voices cache hit for user ${userId}`) - return c.json({ voices: cachedVoices, cached: true }) - } + const { value: voices, cached } = await discoverCached({ + baseUrl: ttsConfig.endpoint, + apiKey: ttsConfig.apiKey, + type: 'voices', + forceRefresh, + fetcher: () => fetchAvailableVoices(ttsConfig.endpoint, ttsConfig.apiKey), + }) + + if (!cached) { + await settingsService.updateSettings({ + tts: { + ...ttsConfig, + availableVoices: voices, + lastVoicesFetch: Date.now(), + }, + }, userId) } - - // Fetch from API - await ensureDiscoveryCacheDir() - logger.info(`Fetching TTS voices for user ${userId}`) - - const voices = await fetchAvailableVoices(ttsConfig.endpoint, ttsConfig.apiKey) - await cacheDiscovery(cacheKey, voices) - - // Update user preferences with available voices - await settingsService.updateSettings({ - tts: { - ...ttsConfig, - availableVoices: voices, - lastVoicesFetch: Date.now() - } - }, userId) - - logger.info(`Fetched ${voices.length} TTS voices`) - return c.json({ voices, cached: false }) + + return c.json({ voices, cached }) } catch (error) { logger.error('Failed to fetch TTS voices:', error) return c.json({ error: 'Failed to fetch voices' }, 500) diff --git a/backend/src/utils/discovery-cache.ts b/backend/src/utils/discovery-cache.ts index 4c76c40f9..689b2a797 100644 --- a/backend/src/utils/discovery-cache.ts +++ b/backend/src/utils/discovery-cache.ts @@ -16,11 +16,11 @@ export function normalizeToBaseUrl(endpoint: string): string { .replace(/\/$/, '') } -export async function ensureDiscoveryCacheDir(): Promise { +async function ensureDiscoveryCacheDir(): Promise { await mkdir(DISCOVERY_CACHE_DIR, { recursive: true }) } -export async function getCachedDiscovery(cacheKey: string): Promise { +async function getCachedDiscovery(cacheKey: string): Promise { try { const filePath = join(DISCOVERY_CACHE_DIR, `${cacheKey}.json`) const fileStat = await stat(filePath) @@ -37,7 +37,7 @@ export async function getCachedDiscovery(cacheKey: string): Promise } } -export async function cacheDiscovery(cacheKey: string, data: T): Promise { +async function cacheDiscovery(cacheKey: string, data: T): Promise { try { await ensureDiscoveryCacheDir() const filePath = join(DISCOVERY_CACHE_DIR, `${cacheKey}.json`) @@ -47,13 +47,13 @@ export async function cacheDiscovery(cacheKey: string, data: T): Promise(opts: { + baseUrl: string + apiKey: string + type: string + forceRefresh: boolean + fetcher: () => Promise +}): Promise<{ value: T; cached: boolean }> { + const cacheKey = generateDiscoveryCacheKey(opts.baseUrl, opts.apiKey, opts.type) + + if (!opts.forceRefresh) { + const cached = await getCachedDiscovery(cacheKey) + if (cached) return { value: cached, cached: true } + } + + await ensureDiscoveryCacheDir() + logger.info(`Discovering ${opts.type} from ${opts.baseUrl}`) + + const value = await opts.fetcher() + await cacheDiscovery(cacheKey, value) + + return { value, cached: false } +} + +export async function discoverModelsCached(opts: { + baseUrl: string + apiKey: string + type: string + filterPattern: RegExp + defaultModels: string[] + forceRefresh: boolean +}): Promise<{ models: string[]; cached: boolean }> { + const { value, cached } = await discoverCached({ + baseUrl: opts.baseUrl, + apiKey: opts.apiKey, + type: opts.type, + forceRefresh: opts.forceRefresh, + fetcher: () => fetchAvailableModels(opts.baseUrl, opts.apiKey, opts.filterPattern, opts.defaultModels), + }) + return { models: value, cached } +} diff --git a/docs/ocm-cli.md b/docs/ocm-cli.md index 82e579589..3fee6f2b2 100644 --- a/docs/ocm-cli.md +++ b/docs/ocm-cli.md @@ -96,8 +96,8 @@ ocm logout Forget saved token (Keychain) and state ocm status Show current manager URL, repo, and whether token is set ocm list List ready repos from the manager ocm use Attach to a specific repo and remember it as last -ocm push [--force] [--create] [--yes] Mirror $PWD to the matching Manager repo -ocm pull [--force] Mirror the matching Manager repo over $PWD +ocm push [--force] [--create] [--yes] [--full] Mirror $PWD to the matching Manager repo (fast bundle/patch sync by default) +ocm pull [--force] [--full] Mirror the matching Manager repo over $PWD (fast bundle/patch sync by default) ocm --help Show this help ``` @@ -125,7 +125,9 @@ The child takes over the terminal (`stdio: inherit`); closing the TUI exits `ocm ### Mirror commands -`ocm push` tarballs `$PWD` (skipping `node_modules`, `dist`, `.next`, `.venv`, `__pycache__`, `.turbo`, and anything matched by `.gitignore`) and streams it to the Manager. `ocm pull` does the reverse. +`ocm push` uses a fast git bundle + working-tree patch by default to sync `$PWD` to the matching Manager repo. Pass `--full` to fall back to the legacy tarball mirror (skipping `node_modules`, `dist`, `.next`, `.venv`, `__pycache__`, `.turbo`, and anything matched by `.gitignore`). The tarball fallback is also used automatically when the fast path fails. + +`ocm pull` uses a fast git bundle + working-tree patch by default to sync the matching Manager repo over `$PWD`. Pass `--full` to fall back to the legacy tarball mirror (also used automatically when the fast path fails). - `--force` skips the dirty-working-tree check on `pull` and the safety bail on `push`. - `--create` (on `push`) creates a new Manager repo when no `origin` match is found. diff --git a/frontend/src/components/settings/OpenCodeModelDialog.tsx b/frontend/src/components/settings/OpenCodeModelDialog.tsx index 394bd9905..7583e622a 100644 --- a/frontend/src/components/settings/OpenCodeModelDialog.tsx +++ b/frontend/src/components/settings/OpenCodeModelDialog.tsx @@ -418,6 +418,11 @@ export function OpenCodeModelDialog({ return availableProviders.map((p: string) => ({ value: p, label: existingProviders?.[p]?.name || p })) }, [availableProviders, existingProviders]) + const discoveredModelOptions = useMemo( + () => discoveredModels.map((m) => ({ value: m, label: m })), + [discoveredModels], + ) + const isEditing = !!editingModel if (!open) return null @@ -575,7 +580,7 @@ export function OpenCodeModelDialog({ handleDiscoveredModelSelect(value) } }} - options={discoveredModels.map((m) => ({ value: m, label: m }))} + options={discoveredModelOptions} placeholder="e.g., MiniMax-M2.7" disabled={isLoadingModels} allowCustomValue={true} diff --git a/ocm-cli/README.md b/ocm-cli/README.md index 6836091b0..8bdc1bd0e 100644 --- a/ocm-cli/README.md +++ b/ocm-cli/README.md @@ -34,8 +34,8 @@ ocm ocm status ocm list ocm use -ocm push [--force] [--create] [--yes] -ocm pull [--force] +ocm push [--force] [--create] [--yes] [--full] +ocm pull [--force] [--full] ocm logout ``` @@ -47,11 +47,15 @@ to local `opencode`. `ocm use ` selects a Manager repo, remembers it as the last repo, and attaches OpenCode to it. -`ocm push` uploads the current git repo to the matching Manager repo. Use +`ocm push` syncs the current git repo to the matching Manager repo using a fast +git bundle + working-tree patch by default. Pass `--full` to fall back to the +legacy tarball mirror (also used automatically when the fast path fails). Use `--create` to create a Manager repo when no origin match exists, and `--yes` to confirm creation in non-interactive shells. -`ocm pull` replaces the current working tree with the matching Manager repo. It +`ocm pull` syncs the matching Manager repo over the current working tree using a +fast git bundle + working-tree patch by default. Pass `--full` to fall back to +the legacy tarball mirror (also used automatically when the fast path fails). It refuses to overwrite uncommitted local changes unless `--force` is passed. ## OpenCode plugin diff --git a/ocm-cli/src/local-repo.ts b/ocm-cli/src/local-repo.ts index 5dd1c679c..933dde611 100644 --- a/ocm-cli/src/local-repo.ts +++ b/ocm-cli/src/local-repo.ts @@ -1,20 +1,42 @@ -import { spawnSync } from 'child_process' +import { spawnSync, type SpawnSyncReturns } from 'child_process' import { copyFileSync, existsSync, rmSync } from 'fs' import { join } from 'path' import { tmpdir } from 'os' +function spawnGit( + cwd: string, + args: string[], + opts: { input?: string; env?: NodeJS.ProcessEnv } = {}, +): SpawnSyncReturns { + return spawnSync('git', args, { cwd, input: opts.input, encoding: 'utf-8', env: opts.env ?? process.env }) +} + function git(cwd: string, args: string[], env: NodeJS.ProcessEnv = process.env): string | null { - const res = spawnSync('git', args, { cwd, encoding: 'utf-8', env }) + const res = spawnGit(cwd, args, { env }) if (res.status !== 0) return null return (res.stdout ?? '').trim() } function gitRaw(cwd: string, args: string[], env: NodeJS.ProcessEnv = process.env): string | null { - const res = spawnSync('git', args, { cwd, encoding: 'utf-8', env }) + const res = spawnGit(cwd, args, { env }) if (res.status !== 0) return null return res.stdout ?? '' } +export function runGit( + cwd: string, + args: string[], + input?: string, + env: NodeJS.ProcessEnv = process.env, +): string { + const res = spawnGit(cwd, args, { input, env }) + if (res.status !== 0) { + const stderr = (res.stderr ?? '').trim() + throw new Error(`git ${args.join(' ')} failed${stderr ? `: ${stderr}` : ''}`) + } + return res.stdout ?? '' +} + export function getRepoRoot(cwd: string): string | null { return git(cwd, ['rev-parse', '--show-toplevel']) } @@ -49,7 +71,7 @@ export function getBranchName(dir: string): string | null { return branch && branch !== 'HEAD' ? branch : null } -export function getWorkingTreeDiff(dir: string): string { +function getWorkingTreeDiff(dir: string): string { return gitRaw(dir, ['diff', '--binary', 'HEAD', '--']) ?? '' } diff --git a/ocm-cli/src/manager-api.ts b/ocm-cli/src/manager-api.ts index c97ace66c..e7d1b26b9 100644 --- a/ocm-cli/src/manager-api.ts +++ b/ocm-cli/src/manager-api.ts @@ -155,11 +155,11 @@ export class ManagerApi { const query = opts.force === true ? '?force=1' : '' const headers: Record = { ...this.headers(), 'Content-Type': 'application/octet-stream' } if (opts.branch) headers['X-OCM-Branch'] = opts.branch - const ab = bundle.buffer.slice(bundle.byteOffset, bundle.byteOffset + bundle.byteLength) + const body = new Uint8Array(bundle.buffer, bundle.byteOffset, bundle.byteLength) const res = await fetch(`${this.baseUrl}/api/internal/repos/${repoId}/mirror/bundle${query}`, { method: 'POST', headers, - body: ab as ArrayBuffer, + body: body as BodyInit, }) if (!res.ok) throw await formatErrorResponse(res, 'mirror bundle upload') diff --git a/ocm-cli/src/mirror.ts b/ocm-cli/src/mirror.ts index 9f863430f..7f56da62e 100644 --- a/ocm-cli/src/mirror.ts +++ b/ocm-cli/src/mirror.ts @@ -1,10 +1,11 @@ import { spawnSync, spawn } from 'child_process' -import { existsSync } from 'fs' +import { createWriteStream, existsSync } from 'fs' import * as fsp from 'fs/promises' import { Readable } from 'stream' +import { pipeline } from 'stream/promises' import { join, dirname } from 'path' import { tmpdir } from 'os' -import { getRepoRoot, getOriginUrl, getDirtyPaths, getHeadSha, getBranchName, getMirrorPatch, urlsEqual } from './local-repo.js' +import { getRepoRoot, getOriginUrl, getDirtyPaths, getHeadSha, getBranchName, getMirrorPatch, urlsEqual, runGit } from './local-repo.js' import type { ManagerApi } from './manager-api.js' import { ManagerApiError } from './manager-api.js' @@ -325,15 +326,6 @@ function applyPatch(repoRoot: string, patch: string): void { } } -function runGit(repoRoot: string, args: string[], input?: string): string { - const res = spawnSync('git', args, { cwd: repoRoot, input, encoding: 'utf-8' }) - if (res.status !== 0) { - const stderr = (res.stderr ?? '').trim() - throw new Error(`git ${args.join(' ')} failed${stderr ? `: ${stderr}` : ''}`) - } - return res.stdout ?? '' -} - async function createLocalBundle(repoRoot: string): Promise { const bundlePath = join(tmpdir(), `ocm-bundle-${Date.now()}-${Math.random().toString(36).slice(2)}.bundle`) runGit(repoRoot, ['bundle', 'create', bundlePath, '--all']) @@ -343,6 +335,7 @@ async function createLocalBundle(repoRoot: string): Promise { function importLocalBundle(repoRoot: string, bundlePath: string, branch: string | null): void { runGit(repoRoot, ['fetch', bundlePath, '+refs/heads/*:refs/remotes/ocm-sync/*', '+refs/tags/*:refs/tags/*']) const refs = runGit(repoRoot, ['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 @@ -351,7 +344,10 @@ function importLocalBundle(repoRoot: string, bundlePath: string, branch: string const name = trimmed.slice(0, firstSpace) if (name === 'HEAD') continue const sha = trimmed.slice(firstSpace + 1) - runGit(repoRoot, ['update-ref', `refs/heads/${name}`, sha]) + updates.push(`update refs/heads/${name} ${sha}\n`) + } + if (updates.length > 0) { + runGit(repoRoot, ['update-ref', '--stdin'], updates.join('')) } if (branch) { @@ -360,20 +356,24 @@ function importLocalBundle(repoRoot: string, bundlePath: string, branch: string if (head) runGit(repoRoot, ['reset', '--hard', head]) } - const syncRefs = runGit(repoRoot, ['for-each-ref', '--format=%(refname)', 'refs/remotes/ocm-sync']) - for (const ref of syncRefs.split('\n').map((line) => line.trim()).filter(Boolean)) { - runGit(repoRoot, ['update-ref', '-d', ref]) + try { + const syncRefsOut = runGit(repoRoot, ['for-each-ref', '--format=%(refname)', 'refs/remotes/ocm-sync']) + const deletes = syncRefsOut.split('\n').map((l) => l.trim()).filter(Boolean).map((ref) => `delete ${ref}\n`) + if (deletes.length > 0) { + runGit(repoRoot, ['update-ref', '--stdin'], deletes.join('')) + } + } catch { + // cleanup of ocm-sync refs is best-effort } } async function writeBundleStream(repoId: number, api: ManagerApi): Promise { const bundlePath = join(tmpdir(), `ocm-bundle-down-${Date.now()}-${Math.random().toString(36).slice(2)}.bundle`) const stream = await api.mirrorDownloadBundle(repoId) - const chunks: Buffer[] = [] - for await (const chunk of Readable.fromWeb(stream as unknown as Parameters[0]) as AsyncIterable) { - chunks.push(Buffer.from(chunk)) - } - await fsp.writeFile(bundlePath, Buffer.concat(chunks)) + await pipeline( + Readable.fromWeb(stream as unknown as Parameters[0]), + createWriteStream(bundlePath), + ) return bundlePath } @@ -413,20 +413,4 @@ export async function mirrorDownFast( } } -export async function mirrorDownPatch( - repoId: number, - repoRoot: string, - api: ManagerApi, - opts: { force: boolean } = { force: false }, -): Promise { - if (!opts.force && getDirtyPaths(repoRoot).size > 0) { - throw new MirrorAbort('working tree has uncommitted changes; rerun with --force') - } - const snapshot = await api.mirrorPatchSnapshot(repoId) - const localHead = getHeadSha(repoRoot) - if (snapshot.head && localHead && snapshot.head !== localHead) { - throw new MirrorAbort('local HEAD differs from Manager HEAD; falling back to full mirror') - } - applyPatch(repoRoot, snapshot.patch) -} diff --git a/ocm-cli/test/mirror.test.ts b/ocm-cli/test/mirror.test.ts index 74c4a1f45..f0c5372a1 100644 --- a/ocm-cli/test/mirror.test.ts +++ b/ocm-cli/test/mirror.test.ts @@ -4,7 +4,7 @@ import { join } from 'path' import { tmpdir } from 'os' import { randomBytes } from 'crypto' import { spawnSync, execSync } from 'child_process' -import { prepareMirror, MirrorAbort, mirrorDown, mirrorDownPatch, mirrorUp, mirrorUpPatch } from '../src/mirror' +import { prepareMirror, MirrorAbort, mirrorDown, mirrorUp, mirrorUpPatch } from '../src/mirror' import { getBranchName } from '../src/local-repo' describe('prepareMirror', () => { @@ -606,22 +606,5 @@ describe('mirror patch helpers', () => { expect(body.patch).toContain('-before') expect(body.patch).toContain('+after') }) - - it('applies a manager patch during pull patch mode', async () => { - const repoRoot = join(tmpDir, 'repo-remote-diff') - mkdirSync(repoRoot) - spawnSync('git', ['init'], { cwd: repoRoot, stdio: 'ignore' }) - spawnSync('git', ['config', 'user.email', 'test@test.com'], { cwd: repoRoot, stdio: 'ignore' }) - spawnSync('git', ['config', 'user.name', 'Test'], { cwd: repoRoot, stdio: 'ignore' }) - writeFileSync(join(repoRoot, 'tracked.txt'), 'before\n') - spawnSync('git', ['add', 'tracked.txt'], { cwd: repoRoot, stdio: 'ignore' }) - spawnSync('git', ['commit', '-m', 'initial'], { cwd: repoRoot, stdio: 'ignore' }) - const head = execSync('git rev-parse HEAD', { cwd: repoRoot, encoding: 'utf-8' }).trim() - const patch = 'diff --git a/tracked.txt b/tracked.txt\nindex 6e58d95..7c6cae9 100644\n--- a/tracked.txt\n+++ b/tracked.txt\n@@ -1 +1 @@\n-before\n+after\n' - const api = { mirrorPatchSnapshot: vi.fn().mockResolvedValue({ repoId: 1, branch: 'main', head, patch }) } - - await mirrorDownPatch(1, repoRoot, api as any, { force: false }) - - expect(readFileSync(join(repoRoot, 'tracked.txt'), 'utf-8')).toBe('after\n') - }) }) + From 77e1e9554b4c894377703f948976774ab699ed2a Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Sun, 28 Jun 2026 11:12:52 -0400 Subject: [PATCH 4/8] feat: extract project-id-resolver, add CLI full-fallback prompt, wire lint into root scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .../routes/internal/opencode-workspaces.ts | 14 +-- .../src/services/project-id-resolver.test.ts | 46 +++++++++- backend/src/services/project-id-resolver.ts | 48 +---------- .../internal-opencode-workspaces.test.ts | 1 + docs/ocm-cli.md | 4 +- .../src/components/schedules/PromptTab.tsx | 2 +- frontend/src/pages/GlobalSchedules.tsx | 2 +- ocm-cli/README.md | 21 +++-- ocm-cli/bin/ocm.ts | 61 ++++++++----- ocm-cli/eslint.config.js | 49 +++++++++++ ocm-cli/package.json | 6 ++ ocm-cli/src/local-repo.ts | 14 --- ocm-cli/src/mirror.ts | 17 ++-- ocm-cli/src/resolve-target.ts | 24 +++--- ocm-cli/test/mirror.test.ts | 66 +++++++------- ocm-cli/test/progress.test.ts | 2 +- ocm-cli/test/resolve-target.test.ts | 69 ++++++++------- package.json | 16 ++-- pnpm-lock.yaml | 31 +++++++ shared/package.json | 3 +- shared/src/project-id-resolver.ts | 86 +++++++++++++++++++ 21 files changed, 391 insertions(+), 191 deletions(-) create mode 100644 ocm-cli/eslint.config.js create mode 100644 shared/src/project-id-resolver.ts diff --git a/backend/src/routes/internal/opencode-workspaces.ts b/backend/src/routes/internal/opencode-workspaces.ts index ae1f83341..adde8e457 100644 --- a/backend/src/routes/internal/opencode-workspaces.ts +++ b/backend/src/routes/internal/opencode-workspaces.ts @@ -1,6 +1,7 @@ import { Hono } from 'hono' import type { Database } from 'bun:sqlite' import { listRepos } from '../../db/queries' +import { resolveProjectId } from '../../services/project-id-resolver' import { logger } from '../../utils/logger' import { getErrorMessage } from '../../utils/error-utils' import path from 'path' @@ -8,12 +9,11 @@ import path from 'path' export function createInternalOpenCodeWorkspacesRoutes(db: Database) { const app = new Hono() - app.get('/', (c) => { + app.get('/', async (c) => { try { - const repos = listRepos(db) - const workspaces = repos - .filter((repo) => repo.cloneStatus === 'ready') - .map((repo) => ({ + const repos = listRepos(db).filter((repo) => repo.cloneStatus === 'ready') + const workspaces = await Promise.all( + repos.map(async (repo) => ({ repoId: repo.id, name: repo.repoUrl ? repo.repoUrl.split('/').slice(-1)[0]?.replace('.git', '') || repo.localPath @@ -24,12 +24,14 @@ export function createInternalOpenCodeWorkspacesRoutes(db: Database) { cloneStatus: repo.cloneStatus, directory: repo.fullPath, originUrl: repo.repoUrl ?? null, + projectId: await resolveProjectId(repo.fullPath).catch(() => null), extra: { repoId: repo.id, localPath: repo.localPath, fullPath: repo.fullPath, }, - })) + })), + ) return c.json({ workspaces }) } catch (error) { logger.error('Failed to list opencode workspaces:', error) diff --git a/backend/src/services/project-id-resolver.test.ts b/backend/src/services/project-id-resolver.test.ts index 6cc7123f7..d7e773513 100644 --- a/backend/src/services/project-id-resolver.test.ts +++ b/backend/src/services/project-id-resolver.test.ts @@ -3,7 +3,51 @@ import { mkdtempSync, rmSync } from 'fs' import { tmpdir } from 'os' import path from 'path' import { execSync } from 'child_process' -import { isGitMainCheckout } from './project-id-resolver' +import { gitRemoteProjectId } from '@opencode-manager/shared/project-id' +import { isGitMainCheckout, resolveProjectId } from './project-id-resolver' + +const GIT_ENV = { + ...process.env, + GIT_AUTHOR_NAME: 't', + GIT_AUTHOR_EMAIL: 't@t', + GIT_COMMITTER_NAME: 't', + GIT_COMMITTER_EMAIL: 't@t', +} + +describe('resolveProjectId', () => { + let base: string + + beforeAll(() => { + base = mkdtempSync(path.join(tmpdir(), 'oc-resolve-project-')) + }) + + afterAll(() => { + rmSync(base, { recursive: true, force: true }) + }) + + it('returns null for a non-git directory', async () => { + const dir = mkdtempSync(path.join(base, 'non-git-')) + expect(await resolveProjectId(dir)).toBeNull() + }) + + it('prefers the normalized origin remote hash', async () => { + const dir = mkdtempSync(path.join(base, 'remote-')) + execSync(`git init -q "${dir}"`) + execSync(`git -C "${dir}" commit -q --allow-empty -m init`, { env: GIT_ENV }) + execSync(`git -C "${dir}" remote add origin git@github.com:Acme/App.git`) + + expect(await resolveProjectId(dir)).toBe(gitRemoteProjectId('git@github.com:Acme/App.git')) + }) + + it('falls back to the sorted first root commit when there is no remote', async () => { + const dir = mkdtempSync(path.join(base, 'root-')) + execSync(`git init -q "${dir}"`) + execSync(`git -C "${dir}" commit -q --allow-empty -m init`, { env: GIT_ENV }) + + const rootCommit = execSync(`git -C "${dir}" rev-list --max-parents=0 HEAD`).toString().trim() + expect(await resolveProjectId(dir)).toBe(rootCommit) + }) +}) describe('isGitMainCheckout', () => { let base: string diff --git a/backend/src/services/project-id-resolver.ts b/backend/src/services/project-id-resolver.ts index 225ac5055..07cee456a 100644 --- a/backend/src/services/project-id-resolver.ts +++ b/backend/src/services/project-id-resolver.ts @@ -1,7 +1,6 @@ import { exec } from 'child_process' -import { readFile } from 'fs/promises' import path from 'path' -import { fileExists } from './file-operations' +import { resolveOpenCodeProjectId } from '@opencode-manager/shared/project-id' const projectIdCache = new Map() @@ -22,50 +21,11 @@ export async function resolveProjectId(repoFullPath: string): Promise() diff --git a/backend/test/routes/internal-opencode-workspaces.test.ts b/backend/test/routes/internal-opencode-workspaces.test.ts index a1e263828..173627e34 100644 --- a/backend/test/routes/internal-opencode-workspaces.test.ts +++ b/backend/test/routes/internal-opencode-workspaces.test.ts @@ -138,6 +138,7 @@ describe('internal-opencode-workspaces routes', () => { expect(workspace).toHaveProperty('branch') expect(workspace).toHaveProperty('cloneStatus') expect(workspace).toHaveProperty('directory') + expect(workspace).toHaveProperty('projectId') expect(workspace).toHaveProperty('extra') expect(workspace.extra).toHaveProperty('repoId') expect(workspace.extra).toHaveProperty('localPath') diff --git a/docs/ocm-cli.md b/docs/ocm-cli.md index 3fee6f2b2..75b2be407 100644 --- a/docs/ocm-cli.md +++ b/docs/ocm-cli.md @@ -125,9 +125,9 @@ The child takes over the terminal (`stdio: inherit`); closing the TUI exits `ocm ### Mirror commands -`ocm push` uses a fast git bundle + working-tree patch by default to sync `$PWD` to the matching Manager repo. Pass `--full` to fall back to the legacy tarball mirror (skipping `node_modules`, `dist`, `.next`, `.venv`, `__pycache__`, `.turbo`, and anything matched by `.gitignore`). The tarball fallback is also used automatically when the fast path fails. +`ocm push` uses a fast git bundle + working-tree patch by default to sync `$PWD` to the matching Manager repo. Pass `--full` to use the legacy tarball mirror (skipping `node_modules`, `dist`, `.next`, `.venv`, `__pycache__`, `.turbo`, and anything matched by `.gitignore`). If the fast path fails, `ocm` prompts before reverting to the tarball mirror (and proceeds automatically when there is no TTY to prompt). -`ocm pull` uses a fast git bundle + working-tree patch by default to sync the matching Manager repo over `$PWD`. Pass `--full` to fall back to the legacy tarball mirror (also used automatically when the fast path fails). +`ocm pull` uses a fast git bundle + working-tree patch by default to sync the matching Manager repo over `$PWD`. Pass `--full` to use the legacy tarball mirror. If the fast path fails, `ocm` prompts before reverting to the tarball mirror (and proceeds automatically when there is no TTY to prompt). - `--force` skips the dirty-working-tree check on `pull` and the safety bail on `push`. - `--create` (on `push`) creates a new Manager repo when no `origin` match is found. diff --git a/frontend/src/components/schedules/PromptTab.tsx b/frontend/src/components/schedules/PromptTab.tsx index 49f7b1da9..16c207ddb 100644 --- a/frontend/src/components/schedules/PromptTab.tsx +++ b/frontend/src/components/schedules/PromptTab.tsx @@ -64,7 +64,7 @@ export function PromptTemplateCard({ template, selected = false, onApply, onEdit ) : (
{content}
)} -
+