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/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/routes/internal/repo-mirror.ts b/backend/src/routes/internal/repo-mirror.ts index b933c4486..fd0ec3f2a 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' @@ -19,6 +19,7 @@ import { readUploadMeta, deleteUploadSession, getPartPath, + getStagingRoot, extractPartsToStaging, atomicSwapIntoPlace, carryOverIgnoredFiles, @@ -42,8 +43,97 @@ 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']) + const updates: string[] = [] + for (const line of refs.split('\n')) { + const trimmed = line.trim() + if (!trimmed) continue + const firstSpace = trimmed.indexOf(' ') + if (firstSpace === -1) continue + const name = trimmed.slice(0, firstSpace) + if (name === 'HEAD') continue + const sha = trimmed.slice(firstSpace + 1) + updates.push(`update refs/heads/${name} ${sha}\n`) + } + if (updates.length > 0) { + await gitRaw(fullPath, ['update-ref', '--stdin'], process.env, updates.join('')) + } + + 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]) + } + + 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 = getStagingRoot() + 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 +304,177 @@ 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 = getStagingRoot() + 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/head', 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) + + const branchName = await safeGitOut(repo.fullPath, ['rev-parse', '--abbrev-ref', 'HEAD']) + const head = await safeGitOut(repo.fullPath, ['rev-parse', 'HEAD']) + const status = await safeGitOut(repo.fullPath, ['status', '--porcelain', '--untracked-files=all']) + return c.json({ + repoId: repo.id, + branch: branchName?.trim() || null, + head: head?.trim() || null, + dirty: (status?.trim().length ?? 0) > 0, + }) + }) + + app.get('/:repoId/mirror/contains/:sha', async (c) => { + const repoIdRaw = c.req.param('repoId') + const repoId = Number(repoIdRaw) + if (!Number.isFinite(repoId)) return c.json({ error: 'invalid repoId' }, 400) + const sha = c.req.param('sha') + if (!/^[0-9a-f]{7,64}$/i.test(sha)) return c.json({ error: 'invalid sha' }, 400) + const repo = getRepoById(db, repoId) + if (!repo) return c.json({ error: 'repo not found' }, 404) + + const ancestry = await safeGitOut(repo.fullPath, ['merge-base', '--is-ancestor', sha, 'HEAD']) + return c.json({ repoId: repo.id, contained: ancestry !== null }) + }) + + 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) @@ -233,7 +494,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.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..e2cd896a9 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -24,6 +24,9 @@ import { type SkillScope, } from '@opencode-manager/shared' import { logger } from '../utils/logger' +import { + discoverModelsCached, +} 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 +1879,43 @@ 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 { models, cached } = await discoverModelsCached({ + baseUrl: trimmedBaseUrl, + apiKey, + type: 'opencode-models', + filterPattern: /.*/, + defaultModels: [], + forceRefresh, + }) + + return c.json({ models, cached }) + } 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/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/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/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/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/backend/test/routes/internal/repo-mirror.test.ts b/backend/test/routes/internal/repo-mirror.test.ts index 6ffce21b5..e800ef208 100644 --- a/backend/test/routes/internal/repo-mirror.test.ts +++ b/backend/test/routes/internal/repo-mirror.test.ts @@ -238,6 +238,238 @@ describe('internal-repo-mirror routes', () => { }) }) + describe('GET /:repoId/mirror/head', () => { + it('returns head, branch and dirty state for the manager repo', async () => { + mockGetRepoById.mockReturnValue({ id: 7, fullPath: join(getTmpRoot(), 'repo7') }) + mockSafeGitOut.mockImplementation((_path: string, args: string[]) => { + if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref') return Promise.resolve('feature\n') + if (args[0] === 'rev-parse') return Promise.resolve('abc123\n') + if (args[0] === 'status') return Promise.resolve(' M file.txt\n') + return Promise.resolve(null) + }) + + const res = await app.request('/api/internal/repos/7/mirror/head') + expect(res.status).toBe(200) + const json = (await res.json()) as { head: string; branch: string; dirty: boolean } + expect(json.head).toBe('abc123') + expect(json.branch).toBe('feature') + expect(json.dirty).toBe(true) + }) + + it('reports a clean repo as not dirty', async () => { + mockGetRepoById.mockReturnValue({ id: 8, fullPath: join(getTmpRoot(), 'repo8') }) + mockSafeGitOut.mockImplementation((_path: string, args: string[]) => { + if (args[0] === 'status') return Promise.resolve('') + return Promise.resolve('abc123\n') + }) + + const res = await app.request('/api/internal/repos/8/mirror/head') + const json = (await res.json()) as { dirty: boolean } + expect(json.dirty).toBe(false) + }) + + it('returns 404 for a non-existent repo', async () => { + mockGetRepoById.mockReturnValue(null) + const res = await app.request('/api/internal/repos/99999/mirror/head') + expect(res.status).toBe(404) + }) + }) + + describe('GET /:repoId/mirror/contains/:sha', () => { + it('returns contained=true when local sha is an ancestor of server HEAD', async () => { + mockGetRepoById.mockReturnValue({ id: 7, fullPath: join(getTmpRoot(), 'repo7') }) + mockSafeGitOut.mockResolvedValue('') + + const res = await app.request('/api/internal/repos/7/mirror/contains/abc1234') + expect(res.status).toBe(200) + const json = (await res.json()) as { contained: boolean } + expect(json.contained).toBe(true) + }) + + it('returns contained=false when ancestry check fails (sha not in server history)', async () => { + mockGetRepoById.mockReturnValue({ id: 7, fullPath: join(getTmpRoot(), 'repo7') }) + mockSafeGitOut.mockResolvedValue(null) + + const res = await app.request('/api/internal/repos/7/mirror/contains/abc1234') + const json = (await res.json()) as { contained: boolean } + expect(json.contained).toBe(false) + }) + + it('rejects a malformed sha with 400', async () => { + mockGetRepoById.mockReturnValue({ id: 7, fullPath: join(getTmpRoot(), 'repo7') }) + const res = await app.request('/api/internal/repos/7/mirror/contains/not-a-sha') + expect(res.status).toBe(400) + }) + }) + + 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('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 }) + 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/docs/ocm-cli.md b/docs/ocm-cli.md index 82e579589..75b2be407 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 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 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/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/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}
)} -
+
+ )} +
+ + { + field.onChange(value) + if (discoveredModels.includes(value)) { + handleDiscoveredModelSelect(value) + } + }} + options={discoveredModelOptions} + 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/pages/GlobalSchedules.tsx b/frontend/src/pages/GlobalSchedules.tsx index 93e851020..dce33b828 100644 --- a/frontend/src/pages/GlobalSchedules.tsx +++ b/frontend/src/pages/GlobalSchedules.tsx @@ -862,7 +862,7 @@ export function GlobalSchedules() {
-
+
{} +} diff --git a/ocm-cli/README.md b/ocm-cli/README.md index 6836091b0..47d757f3e 100644 --- a/ocm-cli/README.md +++ b/ocm-cli/README.md @@ -34,24 +34,31 @@ 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 ``` -Running `ocm` with no command tries to match the current git repo's `origin` -against ready Manager repos. If one repo matches, it attaches OpenCode to that -Manager repo. If no repo matches, it falls back to the last selected repo, then -to local `opencode`. +Running `ocm` with no command computes the current git repo's OpenCode project +id (the same identity OpenCode uses: normalized origin remote hash, else the +cached id, else the root commit) and matches it against ready Manager repos. If +one repo matches, it attaches OpenCode to that Manager repo. If no repo matches, +it falls back to the last selected repo, then 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 -`--create` to create a Manager repo when no origin match exists, and `--yes` to +`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 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). Use +`--create` to create a Manager repo when no project 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 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). It refuses to overwrite uncommitted local changes unless `--force` is passed. ## OpenCode plugin diff --git a/ocm-cli/bin/ocm.ts b/ocm-cli/bin/ocm.ts index e08a78ad2..6c478ed28 100644 --- a/ocm-cli/bin/ocm.ts +++ b/ocm-cli/bin/ocm.ts @@ -2,17 +2,18 @@ import { spawn, spawnSync } from 'child_process' 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 type { RemoteRepoSummary, MirrorProgress } from '../src/mirror.js' +import { ManagerApi, ManagerApiError } from '../src/manager-api.js' +import { mirrorUp, mirrorDown, mirrorUpFast, mirrorDownFast, prepareMirror, MirrorAbort, checkPushDivergence, checkPullDivergence } from '../src/mirror.js' +import type { RemoteRepoSummary, MirrorProgress, PushDivergence, PullDivergence } from '../src/mirror.js' import { createProgressReporter } from '../src/progress.js' -import { getBranchName } from '../src/local-repo.js' +import { getBranchName, getOriginUrl } from '../src/local-repo.js' +import { resolveOpenCodeProjectId } from '@opencode-manager/shared/project-id' import { resolveTarget } from '../src/resolve-target.js' import packageJson from '../package.json' with { type: 'json' } const VERSION = packageJson.version -const USAGE = `ocm - OpenCode Manager workspace launcher +const USAGE = `ocm v${VERSION} - OpenCode Manager workspace launcher Usage: ocm Attach to the Manager repo matching $PWD's git origin, @@ -23,8 +24,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 ` @@ -35,7 +36,7 @@ interface ManagerRepo { branch: string | null cloneStatus: string directory: string - originUrl?: string | null + projectId?: string | null extra: { repoId: number; localPath: string; fullPath: string } } @@ -48,6 +49,65 @@ function info(msg: string): void { process.stdout.write(`${msg}\n`) } +function promptYesNo(question: string): boolean { + process.stderr.write(`${question} [y/N] `) + const res = spawnSync('bash', ['-c', 'read LINE && printf "%s" "$LINE"'], { + stdio: ['inherit', 'pipe', 'inherit'], + encoding: 'utf-8', + }) + const answer = (res.stdout ?? '').trim().toLowerCase() + return answer === 'y' || answer === 'yes' +} + +function confirmFullFallback(): void { + if (!process.stdin.isTTY) return + if (!promptYesNo('Fall back to a full mirror? This replaces the entire server working tree (no merge, no conflict resolution).')) { + die('aborted') + } +} + +function confirmOverwrite(headline: string, reasons: string[], question: string, note?: string): boolean { + process.stderr.write(`ocm: warning: ${headline}\n`) + for (const reason of reasons) process.stderr.write(` - ${reason}\n`) + if (note) process.stderr.write(` ${note}\n`) + if (!process.stdin.isTTY) { + die('refusing to discard work; re-run with --force to override') + } + if (!promptYesNo(question)) { + die('aborted') + } + return true +} + +function guardDivergentPush(repoName: string, div: PushDivergence): boolean { + const reasons: string[] = [] + if (div.diverged) { + reasons.push(div.lostCommits >= 0 + ? `the server is ${div.lostCommits} commit(s) ahead of your local branch` + : 'the server has commit(s) not present in your local branch') + } + if (div.serverDirty) reasons.push('the server has uncommitted changes') + + return confirmOverwrite( + `pushing to ${repoName} will discard server-side work:`, + reasons, + 'Overwrite server-side work and push anyway?', + 'This work is likely from OpenCode agent sessions on the manager.', + ) +} + +function guardDivergentPull(repoName: string, div: PullDivergence): boolean { + const reasons = [div.lostCommits >= 0 + ? `your local branch is ${div.lostCommits} commit(s) ahead of ${repoName}` + : `your local branch has commit(s) not present on ${repoName}`] + + return confirmOverwrite( + `pulling ${repoName} will discard local commits:`, + reasons, + 'Discard local commits and pull anyway?', + ) +} + function requireState(): OcmState { const state = readState() if (!state || !state.managerUrl) { @@ -215,6 +275,7 @@ async function cmdUse(args: string[]): Promise { } async function cmdDefault(): Promise { + info(`ocm v${VERSION}`) const state = requireState() const token = requireToken(state) @@ -227,13 +288,15 @@ async function cmdDefault(): Promise { } : undefined + info('connecting...') const repos = await fetchRepos(state.managerUrl, token) - const result = resolveTarget({ cwd: process.cwd(), repos, last }) + const localProjectId = await resolveOpenCodeProjectId(process.cwd()) + const result = resolveTarget({ cwd: process.cwd(), repos, localProjectId, last }) switch (result.kind) { case 'cwd-match': { const repo = result.repo - info(`attaching to ${repo.name} (matched $PWD origin)`) + info(`attaching to ${repo.name}`) writeState({ ...state, lastRepoId: repo.repoId, @@ -244,12 +307,16 @@ async function cmdDefault(): Promise { attach(state.managerUrl, token, toManagerRepo(repo)) return } - case 'last': - attach(state.managerUrl, token, toManagerRepo(result.repo)) + case 'last': { + const repo = result.repo + info(`attaching to ${repo.name} (last used)`) + attach(state.managerUrl, token, toManagerRepo(repo)) return + } case 'cwd-ambiguous': { const names = result.matches.map((r) => `${r.name} (id=${r.repoId})`).join(', ') - die(`multiple Manager repos match origin ${result.localOrigin}: ${names}; disambiguate with \`ocm use \``) + die(`multiple Manager repos match project ${result.localProjectId}: ${names}; disambiguate with \`ocm use \``) + break } case 'local': runLocalOpencode(result.reason) @@ -283,11 +350,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,63 +364,82 @@ 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, - originUrl: r.originUrl ?? null, + projectId: r.projectId ?? null, branch: r.branch, })) - const plan = prepareMirror(process.cwd(), remotes) + const plan = await prepareMirror(process.cwd(), remotes) if (plan.matched.length === 0) { if (!create) { - die(`no matching Manager repo for origin ${plan.localOrigin}. Re-run with --create to create one.`) + die(`no matching Manager repo for project ${plan.localProjectId}. Re-run with --create to create one.`) } const name = basename(plan.repoRoot) const branch = getBranchName(plan.repoRoot) + const originUrl = getOriginUrl(plan.repoRoot) if (process.stdin.isTTY && !yes) { - process.stderr.write(`Create Manager repo "${name}" by uploading ${plan.repoRoot} (origin: ${plan.localOrigin})? [y/N] `) - const res = spawnSync('bash', ['-c', 'read LINE && printf "%s" "$LINE"'], { - stdio: ['inherit', 'pipe', 'inherit'], - encoding: 'utf-8', - }) - const answer = (res.stdout ?? '').trim().toLowerCase() - if (answer !== 'y' && answer !== 'yes') { + if (!promptYesNo(`Create Manager repo "${name}" by uploading ${plan.repoRoot} (project: ${plan.localProjectId})?`)) { die('aborted') } } else if (!process.stdin.isTTY && !yes) { 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, - create: { name, originUrl: plan.localOrigin, branch }, + create: { name, originUrl, branch }, onProgress, }) progress.done() info(`pushed ${plan.repoRoot} -> ${result.created ? 'created' : 'updated'} (repoId=${result.repoId}, branch=${result.branch})`) } else if (plan.matched.length === 1) { + if (!force) { + try { + const divergence = await checkPushDivergence(plan.repoRoot, api, plan.matched[0]!.repoId) + if (divergence.diverged || divergence.serverDirty) { + force = guardDivergentPush(plan.matched[0]!.name, divergence) + } + } catch (error) { + if (!(error instanceof ManagerApiError && error.status === 404)) throw error + } + } + 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: ${error instanceof Error ? error.message : String(error)}\n`) + confirmFullFallback() + } + } + 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})`) } else { const names = plan.matched.map((r) => `${r.name} (id=${r.repoId})`).join(', ') - die(`multiple Manager repos match origin ${plan.localOrigin}: ${names}; disambiguate with \`ocm push \``) + die(`multiple Manager repos match project ${plan.localProjectId}: ${names}; disambiguate with \`ocm push \``) } } 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() @@ -362,21 +450,43 @@ async function cmdPull(args: string[]): Promise { const remotes: RemoteRepoSummary[] = repos.map((r) => ({ repoId: r.repoId, name: r.name, - originUrl: r.originUrl ?? null, + projectId: r.projectId ?? null, branch: r.branch, })) - const plan = prepareMirror(process.cwd(), remotes) + const plan = await prepareMirror(process.cwd(), remotes) if (plan.matched.length === 0) { - die(`no matching Manager repo for origin ${plan.localOrigin}.`) + die(`no matching Manager repo for project ${plan.localProjectId}.`) } if (plan.matched.length > 1) { const names = plan.matched.map((r) => `${r.name} (id=${r.repoId})`).join(', ') - die(`multiple Manager repos match origin ${plan.localOrigin}: ${names}; disambiguate with \`ocm pull \``) + die(`multiple Manager repos match project ${plan.localProjectId}: ${names}; disambiguate with \`ocm pull \``) + } + + if (!force) { + try { + const divergence = await checkPullDivergence(plan.repoRoot, api, plan.matched[0]!.repoId) + if (divergence.diverged) { + force = guardDivergentPull(plan.matched[0]!.name, divergence) + } + } catch (error) { + if (!(error instanceof ManagerApiError && error.status === 404)) throw error + } } + 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: ${error instanceof Error ? error.message : String(error)}\n`) + confirmFullFallback() + } + } 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/eslint.config.js b/ocm-cli/eslint.config.js new file mode 100644 index 000000000..5d9f9265f --- /dev/null +++ b/ocm-cli/eslint.config.js @@ -0,0 +1,49 @@ +import js from '@eslint/js' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist', '.env*', 'coverage']), + { + files: ['**/*.ts'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + ], + languageOptions: { + ecmaVersion: 2022, + globals: { + process: 'readonly', + Buffer: 'readonly', + console: 'readonly', + }, + parserOptions: { + projectService: { + defaultProject: './tsconfig.json', + }, + }, + }, + rules: { + 'no-console': 'off', + '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/no-unused-vars': 'error', + 'no-useless-escape': 'warn', + }, + }, + { + files: ['test/**/*.ts', 'scripts/**/*.ts', 'vitest.config.ts'], + languageOptions: { + parserOptions: { + projectService: false, + }, + }, + rules: { + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unused-vars': ['error', { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + }], + 'no-control-regex': 'off', + }, + }, +]) diff --git a/ocm-cli/package.json b/ocm-cli/package.json index 3a88447b0..a5cf8a853 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.2.0", "description": "OpenCode Manager CLI: attach a local OpenCode TUI to a Manager-hosted repo.", "license": "MIT", "repository": { @@ -26,14 +26,20 @@ "build": "bun scripts/build.ts", "postinstall": "node scripts/postinstall.mjs || true", "typecheck": "tsc --noEmit", + "lint": "eslint . --ext .ts", + "lint:fix": "eslint . --ext .ts --fix", "test": "bun scripts/build.ts && vitest run", "test:watch": "vitest", "prepublishOnly": "bun scripts/build.ts" }, "dependencies": {}, "devDependencies": { + "@eslint/js": "^9.36.0", + "@opencode-manager/shared": "workspace:*", "@types/node": "^22.0.0", + "eslint": "^9.39.1", "typescript": "^5.5.0", + "typescript-eslint": "^8.45.0", "vitest": "^3.1.0" } } diff --git a/ocm-cli/src/local-repo.ts b/ocm-cli/src/local-repo.ts index 22f660fda..31443da2b 100644 --- a/ocm-cli/src/local-repo.ts +++ b/ocm-cli/src/local-repo.ts @@ -1,11 +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 git(cwd: string, args: string[]): string | null { - const res = spawnSync('git', args, { cwd, encoding: 'utf-8' }) +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 = 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 = 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']) } @@ -26,20 +57,51 @@ export function getDirtyPaths(dir: string): Set { return paths } -function normalizeUrl(url: string): string { - return url - .trim() - .replace(/\.git$/, '') - .replace(/^git@([^:]+):/, 'ssh://git@$1/') - .replace(/\/+$/, '') - .toLowerCase() +export function getBranchName(dir: string): string | null { + const branch = git(dir, ['rev-parse', '--abbrev-ref', 'HEAD']) + return branch && branch !== 'HEAD' ? branch : null } -export function getBranchName(dir: string): string | null { - return git(dir, ['rev-parse', '--abbrev-ref', 'HEAD']) +function getWorkingTreeDiff(dir: string): string { + return gitRaw(dir, ['diff', '--binary', 'HEAD', '--']) ?? '' } -export function urlsEqual(a: string | null | undefined, b: string | null | undefined): boolean { - if (!a || !b) return false - return normalizeUrl(a) === normalizeUrl(b) +export function getHeadSha(dir: string): string | null { + return git(dir, ['rev-parse', 'HEAD']) +} + +export function hasCommit(dir: string, sha: string): boolean { + return git(dir, ['cat-file', '-e', `${sha}^{commit}`]) !== null +} + +export function isAncestor(dir: string, ancestor: string, descendant: string): boolean { + return spawnGit(dir, ['merge-base', '--is-ancestor', ancestor, descendant]).status === 0 +} + +export function countCommitsAhead(dir: string, from: string, to: string): number { + const out = git(dir, ['rev-list', '--count', `${from}..${to}`]) + const n = out ? Number(out) : NaN + return Number.isInteger(n) ? n : -1 +} + +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 }) + } } diff --git a/ocm-cli/src/manager-api.ts b/ocm-cli/src/manager-api.ts index c9a2a7bc4..6d83f7967 100644 --- a/ocm-cli/src/manager-api.ts +++ b/ocm-cli/src/manager-api.ts @@ -1,3 +1,6 @@ +import { createReadStream } from 'fs' +import { Readable } from 'stream' + export interface MirrorBeginOpts { force?: boolean create?: { name: string; originUrl: string | null; branch: string | null } @@ -18,6 +21,37 @@ 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 MirrorHead { + repoId: number + branch: string | null + head: string | null + dirty: boolean +} + +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 +149,67 @@ 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, bundlePath: string, 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 body = Readable.toWeb(createReadStream(bundlePath)) as ReadableStream + const res = await fetch(`${this.baseUrl}/api/internal/repos/${repoId}/mirror/bundle${query}`, { + method: 'POST', + headers, + body: body as BodyInit, + duplex: 'half', + } as RequestInit & { duplex: 'half' }) + + if (!res.ok) throw await formatErrorResponse(res, 'mirror bundle upload') + return (await res.json()) as MirrorBundleResult + } + + async mirrorHead(repoId: number): Promise { + const res = await fetch(`${this.baseUrl}/api/internal/repos/${repoId}/mirror/head`, { + headers: this.headers(), + }) + + if (!res.ok) throw await formatErrorResponse(res, 'mirror head') + return (await res.json()) as MirrorHead + } + + async mirrorContains(repoId: number, sha: string): Promise<{ contained: boolean }> { + const res = await fetch(`${this.baseUrl}/api/internal/repos/${repoId}/mirror/contains/${sha}`, { + headers: this.headers(), + }) + + if (!res.ok) throw await formatErrorResponse(res, 'mirror contains') + return (await res.json()) as { repoId: number; contained: boolean } + } + + 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..b0f62d32a 100644 --- a/ocm-cli/src/mirror.ts +++ b/ocm-cli/src/mirror.ts @@ -1,10 +1,12 @@ 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, urlsEqual } from './local-repo.js' +import { getRepoRoot, getDirtyPaths, getHeadSha, getBranchName, getMirrorPatch, runGit, hasCommit, isAncestor, countCommitsAhead } from './local-repo.js' +import { resolveOpenCodeProjectId } from '@opencode-manager/shared/project-id' import type { ManagerApi } from './manager-api.js' import { ManagerApiError } from './manager-api.js' @@ -45,26 +47,76 @@ export class MirrorAbort extends Error { export interface RemoteRepoSummary { repoId: number name: string - originUrl: string | null + projectId: string | null branch: string | null } export interface MirrorPlan { repoRoot: string - localOrigin: string + localProjectId: string matched: RemoteRepoSummary[] } -export function prepareMirror(cwd: string, remotes: RemoteRepoSummary[]): MirrorPlan { +export async function prepareMirror(cwd: string, remotes: RemoteRepoSummary[]): Promise { const repoRoot = getRepoRoot(cwd) if (!repoRoot) throw new MirrorAbort('not in a git repository') - const localOrigin = getOriginUrl(repoRoot) - if (!localOrigin) throw new MirrorAbort('no origin URL found') + const localProjectId = await resolveOpenCodeProjectId(repoRoot) + if (!localProjectId) throw new MirrorAbort('could not resolve an OpenCode project id for this repository') - const matched = remotes.filter((r) => urlsEqual(localOrigin, r.originUrl)) + const matched = remotes.filter((r) => r.projectId && r.projectId === localProjectId) - return { repoRoot, localOrigin, matched } + return { repoRoot, localProjectId, matched } +} + +export interface PushDivergence { + serverHead: string | null + serverBranch: string | null + serverDirty: boolean + diverged: boolean + lostCommits: number +} + +export async function checkPushDivergence(repoRoot: string, api: ManagerApi, repoId: number): Promise { + const info = await api.mirrorHead(repoId) + const { head: serverHead, branch: serverBranch, dirty: serverDirty } = info + const localHead = getHeadSha(repoRoot) + + if (!serverHead || serverHead === localHead) { + return { serverHead, serverBranch, serverDirty, diverged: false, lostCommits: 0 } + } + if (!hasCommit(repoRoot, serverHead)) { + return { serverHead, serverBranch, serverDirty, diverged: true, lostCommits: -1 } + } + if (localHead && isAncestor(repoRoot, serverHead, localHead)) { + return { serverHead, serverBranch, serverDirty, diverged: false, lostCommits: 0 } + } + const lostCommits = localHead ? countCommitsAhead(repoRoot, localHead, serverHead) : -1 + return { serverHead, serverBranch, serverDirty, diverged: true, lostCommits } +} + +export interface PullDivergence { + diverged: boolean + lostCommits: number + serverHead: string | null +} + +export async function checkPullDivergence(repoRoot: string, api: ManagerApi, repoId: number): Promise { + const localHead = getHeadSha(repoRoot) + if (!localHead) return { diverged: false, lostCommits: 0, serverHead: null } + + const { contained } = await api.mirrorContains(repoId, localHead) + if (contained) return { diverged: false, lostCommits: 0, serverHead: null } + + let serverHead: string | null = null + let lostCommits = -1 + try { + serverHead = (await api.mirrorHead(repoId)).head + if (serverHead) lostCommits = countCommitsAhead(repoRoot, serverHead, localHead) + } catch { + // best-effort: count is informational only + } + return { diverged: true, lostCommits, serverHead } } export interface MirrorProgress { @@ -302,3 +354,113 @@ 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}` : ''}`) + } +} + +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']) + const updates: string[] = [] + for (const line of refs.split('\n')) { + const trimmed = line.trim() + if (!trimmed) continue + const firstSpace = trimmed.indexOf(' ') + if (firstSpace === -1) continue + const name = trimmed.slice(0, firstSpace) + if (name === 'HEAD') continue + const sha = trimmed.slice(firstSpace + 1) + updates.push(`update refs/heads/${name} ${sha}\n`) + } + if (updates.length > 0) { + runGit(repoRoot, ['update-ref', '--stdin'], updates.join('')) + } + + 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]) + } + + 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) + await pipeline( + Readable.fromWeb(stream as unknown as Parameters[0]), + createWriteStream(bundlePath), + ) + 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 { + await opts.api.mirrorUploadBundle(repoId, bundlePath, { 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(() => {}) + } +} + + diff --git a/ocm-cli/src/resolve-target.ts b/ocm-cli/src/resolve-target.ts index c4fc97103..ce2cc5d91 100644 --- a/ocm-cli/src/resolve-target.ts +++ b/ocm-cli/src/resolve-target.ts @@ -1,36 +1,38 @@ -import { getRepoRoot, getOriginUrl, urlsEqual } from './local-repo.js' +import { getRepoRoot } from './local-repo.js' export interface TargetRepo { repoId: number name: string branch: string | null directory: string - originUrl?: string | null + projectId?: string | null } export type ResolveResult = | { kind: 'cwd-match'; repo: TargetRepo; repoRoot: string } | { kind: 'last'; repo: TargetRepo } - | { kind: 'cwd-ambiguous'; matches: TargetRepo[]; localOrigin: string; repoRoot: string } + | { kind: 'cwd-ambiguous'; matches: TargetRepo[]; localProjectId: string; repoRoot: string } | { kind: 'local'; reason: 'no-match' | 'no-target'; repoRoot: string | null } export interface ResolveInput { cwd: string repos: TargetRepo[] + localProjectId: string | null last?: { repoId: number; name: string; directory: string; branch: string | null } } export function resolveTarget(input: ResolveInput): ResolveResult { const repoRoot = getRepoRoot(input.cwd) - const localOrigin = repoRoot ? getOriginUrl(repoRoot) : null - if (repoRoot && localOrigin) { - const matches = input.repos.filter((r) => urlsEqual(localOrigin, r.originUrl)) - if (matches.length === 1) { - return { kind: 'cwd-match', repo: matches[0]!, repoRoot } - } - if (matches.length > 1) { - return { kind: 'cwd-ambiguous', matches, localOrigin, repoRoot } + if (repoRoot) { + if (input.localProjectId) { + const matches = input.repos.filter((r) => r.projectId && r.projectId === input.localProjectId) + if (matches.length === 1) { + return { kind: 'cwd-match', repo: matches[0]!, repoRoot } + } + if (matches.length > 1) { + return { kind: 'cwd-ambiguous', matches, localProjectId: input.localProjectId, repoRoot } + } } return { kind: 'local', reason: 'no-match', repoRoot } } diff --git a/ocm-cli/test/mirror.test.ts b/ocm-cli/test/mirror.test.ts index c4d8ff110..31f35181f 100644 --- a/ocm-cli/test/mirror.test.ts +++ b/ocm-cli/test/mirror.test.ts @@ -4,7 +4,12 @@ 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, mirrorUp, mirrorUpPatch, checkPushDivergence, checkPullDivergence } from '../src/mirror' +import { getBranchName } from '../src/local-repo' +import { gitRemoteProjectId } from '@opencode-manager/shared/project-id' + +const ME_REPO_ID = gitRemoteProjectId('https://github.com/me/repo.git')! +const OTHER_REPO_ID = gitRemoteProjectId('https://github.com/other/repo.git')! describe('prepareMirror', () => { let tmpDir: string @@ -17,53 +22,80 @@ describe('prepareMirror', () => { rmSync(tmpDir, { recursive: true, force: true }) }) - it('rejects when not in a git repo', () => { + it('rejects when not in a git repo', async () => { const nonGitDir = join(tmpDir, 'non-git') mkdirSync(nonGitDir) - expect(() => prepareMirror(nonGitDir, [])).toThrow(MirrorAbort) - expect(() => prepareMirror(nonGitDir, [])).toThrow('not in a git repository') + await expect(prepareMirror(nonGitDir, [])).rejects.toThrow(MirrorAbort) + await expect(prepareMirror(nonGitDir, [])).rejects.toThrow('not in a git repository') }) - it('rejects when no origin URL found', () => { + it('rejects when no project id can be resolved', async () => { const gitDir = join(tmpDir, 'git-no-origin') mkdirSync(gitDir) spawnSync('git', ['init'], { cwd: gitDir, stdio: 'ignore' }) - expect(() => prepareMirror(gitDir, [])).toThrow(MirrorAbort) - expect(() => prepareMirror(gitDir, [])).toThrow('no origin URL found') + await expect(prepareMirror(gitDir, [])).rejects.toThrow(MirrorAbort) + await expect(prepareMirror(gitDir, [])).rejects.toThrow('could not resolve an OpenCode project id') }) - it('returns empty matched array when no remote matches', () => { + it('returns empty matched array when no remote matches', async () => { const gitDir = join(tmpDir, 'git-mismatch') mkdirSync(gitDir) spawnSync('git', ['init'], { cwd: gitDir, stdio: 'ignore' }) spawnSync('git', ['remote', 'add', 'origin', 'https://github.com/other/repo.git'], { cwd: gitDir, stdio: 'ignore' }) const remotes = [ - { repoId: 1, name: 'my-repo', originUrl: 'https://github.com/me/repo.git', branch: 'main' }, + { repoId: 1, name: 'my-repo', projectId: ME_REPO_ID, branch: 'main' }, ] - const plan = prepareMirror(gitDir, remotes) + const plan = await prepareMirror(gitDir, remotes) expect(plan.matched).toHaveLength(0) - expect(plan.localOrigin).toContain('other/repo') + expect(plan.localProjectId).toBe(OTHER_REPO_ID) }) - it('returns matching repos when origin matches', () => { + it('returns matching repos when the project id matches', async () => { const gitDir = join(tmpDir, 'git-match') mkdirSync(gitDir) spawnSync('git', ['init'], { cwd: gitDir, stdio: 'ignore' }) spawnSync('git', ['remote', 'add', 'origin', 'https://github.com/me/repo.git'], { cwd: gitDir, stdio: 'ignore' }) const remotes = [ - { repoId: 1, name: 'my-repo', originUrl: 'https://github.com/me/repo.git', branch: 'main' }, - { repoId: 2, name: 'other-repo', originUrl: 'https://github.com/other/repo.git', branch: 'main' }, + { repoId: 1, name: 'my-repo', projectId: ME_REPO_ID, branch: 'main' }, + { repoId: 2, name: 'other-repo', projectId: OTHER_REPO_ID, branch: 'main' }, ] - const plan = prepareMirror(gitDir, remotes) + const plan = await prepareMirror(gitDir, remotes) expect(plan.matched).toHaveLength(1) expect(plan.matched[0]!.repoId).toBe(1) - expect(plan.localOrigin).toContain('me/repo') + expect(plan.localProjectId).toBe(ME_REPO_ID) + }) +}) + +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() }) }) @@ -91,7 +123,7 @@ describe('cmdPush', () => { stderrOutput += typeof msg === 'string' ? msg : new TextDecoder().decode(msg) return true }) - vi.spyOn(process, 'exit').mockImplementation((code?: string | number | null) => { + vi.spyOn(process, 'exit').mockImplementation((_code?: string | number | null) => { throw new Error(stderrOutput.trim()) }) @@ -133,7 +165,7 @@ describe('mirrorDown', () => { function createGzipTarball(dir: string): Buffer { const tarFile = join(tmpDir, 'test.tar.gz') execSync(`tar -czf "${tarFile}" -C "${dir}" .`) - return require('fs').readFileSync(tarFile) + return readFileSync(tarFile) } const streamOf = (buf: Buffer): ReadableStream => @@ -420,8 +452,8 @@ describe('mirrorUp chunked upload', () => { const ctx = makeMockApi() const plan = { repoRoot, - localOrigin: 'https://github.com/test/repo.git', - matched: [{ repoId: 1, name: 'test-repo', originUrl: 'https://github.com/test/repo.git', branch: 'main' }], + localProjectId: 'test-project', + matched: [{ repoId: 1, name: 'test-repo', projectId: 'test-project', branch: 'main' }], } await mirrorUp(plan, { api: ctx.api as any, force: false }) @@ -443,8 +475,8 @@ describe('mirrorUp chunked upload', () => { const ctx = makeMockApi({ chunkSize: 128 * 1024 }) const plan = { repoRoot, - localOrigin: 'https://github.com/test/repo.git', - matched: [{ repoId: 1, name: 'test-repo', originUrl: 'https://github.com/test/repo.git', branch: 'main' }], + localProjectId: 'test-project', + matched: [{ repoId: 1, name: 'test-repo', projectId: 'test-project', branch: 'main' }], } await mirrorUp(plan, { api: ctx.api as any, force: false }) @@ -462,8 +494,8 @@ describe('mirrorUp chunked upload', () => { const ctx = makeMockApi({ partFailures: { 0: 2 }, chunkSize: 64 * 1024 }) const plan = { repoRoot, - localOrigin: 'https://github.com/test/repo.git', - matched: [{ repoId: 1, name: 'test-repo', originUrl: 'https://github.com/test/repo.git', branch: 'main' }], + localProjectId: 'test-project', + matched: [{ repoId: 1, name: 'test-repo', projectId: 'test-project', branch: 'main' }], } await mirrorUp(plan, { api: ctx.api as any, force: false }) @@ -484,8 +516,8 @@ describe('mirrorUp chunked upload', () => { const plan = { repoRoot, - localOrigin: 'https://github.com/test/repo.git', - matched: [{ repoId: 1, name: 'test-repo', originUrl: 'https://github.com/test/repo.git', branch: 'main' }], + localProjectId: 'test-project', + matched: [{ repoId: 1, name: 'test-repo', projectId: 'test-project', branch: 'main' }], } await expect(mirrorUp(plan, { api: ctx.api as any, force: false })).rejects.toThrow('boom') @@ -502,8 +534,8 @@ describe('mirrorUp chunked upload', () => { const onProgress = vi.fn() const plan = { repoRoot, - localOrigin: 'https://github.com/test/repo.git', - matched: [{ repoId: 1, name: 'test-repo', originUrl: 'https://github.com/test/repo.git', branch: 'main' }], + localProjectId: 'test-project', + matched: [{ repoId: 1, name: 'test-repo', projectId: 'test-project', branch: 'main' }], } await mirrorUp(plan, { api: ctx.api as any, force: false, onProgress }) @@ -526,8 +558,8 @@ describe('mirrorUp chunked upload', () => { const ctx = makeMockApi() const plan = { repoRoot, - localOrigin: 'https://github.com/test/repo.git', - matched: [{ repoId: 1, name: 'test-repo', originUrl: 'https://github.com/test/repo.git', branch: 'main' }], + localProjectId: 'test-project', + matched: [{ repoId: 1, name: 'test-repo', projectId: 'test-project', branch: 'main' }], } await mirrorUp(plan, { api: ctx.api as any, force: false }) @@ -539,3 +571,204 @@ describe('mirrorUp chunked upload', () => { expect(combined[1]).toBe(0x8b) }) }) + +describe('checkPushDivergence', () => { + let tmpDir: string + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'mirror-divergence-test-')) + }) + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }) + }) + + function initRepo(name: string): string { + const repoRoot = join(tmpDir, name) + 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' }) + return repoRoot + } + + function commit(repoRoot: string, file: string, content: string): string { + writeFileSync(join(repoRoot, file), content) + spawnSync('git', ['add', '.'], { cwd: repoRoot, stdio: 'ignore' }) + spawnSync('git', ['commit', '-m', `add ${file}`], { cwd: repoRoot, stdio: 'ignore' }) + return execSync('git rev-parse HEAD', { cwd: repoRoot, encoding: 'utf-8' }).trim() + } + + const apiWith = (head: string | null, dirty = false) => ({ + mirrorHead: vi.fn().mockResolvedValue({ repoId: 1, branch: 'main', head, dirty }), + }) as any + + it('reports no divergence when server head equals local head', async () => { + const repoRoot = initRepo('equal') + const head = commit(repoRoot, 'a.txt', 'a') + + const result = await checkPushDivergence(repoRoot, apiWith(head), 1) + expect(result.diverged).toBe(false) + expect(result.lostCommits).toBe(0) + }) + + it('reports no divergence (fast-forward) when server head is an ancestor of local head', async () => { + const repoRoot = initRepo('ff') + const first = commit(repoRoot, 'a.txt', 'a') + commit(repoRoot, 'b.txt', 'b') + + const result = await checkPushDivergence(repoRoot, apiWith(first), 1) + expect(result.diverged).toBe(false) + expect(result.lostCommits).toBe(0) + }) + + it('reports divergence with unknown count when server head is not present locally', async () => { + const repoRoot = initRepo('unknown') + commit(repoRoot, 'a.txt', 'a') + + const result = await checkPushDivergence(repoRoot, apiWith('0000000000000000000000000000000000000000'), 1) + expect(result.diverged).toBe(true) + expect(result.lostCommits).toBe(-1) + }) + + it('reports divergence with a count when server head exists but is not an ancestor', async () => { + const repoRoot = initRepo('diverged') + const base = commit(repoRoot, 'a.txt', 'a') + spawnSync('git', ['checkout', '-b', 'server', base], { cwd: repoRoot, stdio: 'ignore' }) + const serverHead = commit(repoRoot, 'server.txt', 'server-work') + spawnSync('git', ['checkout', '-'], { cwd: repoRoot, stdio: 'ignore' }) + commit(repoRoot, 'local.txt', 'local-work') + + const result = await checkPushDivergence(repoRoot, apiWith(serverHead), 1) + expect(result.diverged).toBe(true) + expect(result.lostCommits).toBe(1) + }) + + it('surfaces server dirty state even when histories match', async () => { + const repoRoot = initRepo('dirty') + const head = commit(repoRoot, 'a.txt', 'a') + + const result = await checkPushDivergence(repoRoot, apiWith(head, true), 1) + expect(result.diverged).toBe(false) + expect(result.serverDirty).toBe(true) + }) +}) + +describe('checkPullDivergence', () => { + let tmpDir: string + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'pull-divergence-test-')) + }) + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }) + }) + + function initRepo(name: string): string { + const repoRoot = join(tmpDir, name) + 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' }) + return repoRoot + } + + function commit(repoRoot: string, file: string, content: string): string { + writeFileSync(join(repoRoot, file), content) + spawnSync('git', ['add', '.'], { cwd: repoRoot, stdio: 'ignore' }) + spawnSync('git', ['commit', '-m', `add ${file}`], { cwd: repoRoot, stdio: 'ignore' }) + return execSync('git rev-parse HEAD', { cwd: repoRoot, encoding: 'utf-8' }).trim() + } + + it('reports no divergence when the server contains the local head (fast-forward pull)', async () => { + const repoRoot = initRepo('behind') + commit(repoRoot, 'a.txt', 'a') + + const api = { mirrorContains: vi.fn().mockResolvedValue({ contained: true }) } as any + const result = await checkPullDivergence(repoRoot, api, 1) + + expect(result.diverged).toBe(false) + expect(api.mirrorContains).toHaveBeenCalledTimes(1) + }) + + it('reports divergence with a count when the server lacks local commits', async () => { + const repoRoot = initRepo('ahead') + const base = commit(repoRoot, 'a.txt', 'a') + commit(repoRoot, 'b.txt', 'b') + + const api = { + mirrorContains: vi.fn().mockResolvedValue({ contained: false }), + mirrorHead: vi.fn().mockResolvedValue({ repoId: 1, branch: 'main', head: base, dirty: false }), + } as any + const result = await checkPullDivergence(repoRoot, api, 1) + + expect(result.diverged).toBe(true) + expect(result.lostCommits).toBe(1) + }) + + it('reports divergence with unknown count when the server head is not present locally', async () => { + const repoRoot = initRepo('unknown') + commit(repoRoot, 'a.txt', 'a') + + const api = { + mirrorContains: vi.fn().mockResolvedValue({ contained: false }), + mirrorHead: vi.fn().mockResolvedValue({ repoId: 1, branch: 'main', head: '0000000000000000000000000000000000000000', dirty: false }), + } as any + const result = await checkPullDivergence(repoRoot, api, 1) + + expect(result.diverged).toBe(true) + expect(result.lostCommits).toBe(-1) + }) + + it('reports no divergence for an empty local repo with no commits', async () => { + const repoRoot = initRepo('empty') + + const api = { mirrorContains: vi.fn() } as any + const result = await checkPullDivergence(repoRoot, api, 1) + + expect(result.diverged).toBe(false) + expect(api.mirrorContains).not.toHaveBeenCalled() + }) +}) + +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, + localProjectId: 'test-project', + matched: [{ repoId: 1, name: 'test-repo', projectId: 'test-project', 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') + }) +}) + diff --git a/ocm-cli/test/progress.test.ts b/ocm-cli/test/progress.test.ts index 845310d3c..413638604 100644 --- a/ocm-cli/test/progress.test.ts +++ b/ocm-cli/test/progress.test.ts @@ -162,7 +162,7 @@ describe('createProgressReporter', () => { describe('finished guard', () => { it('tick is a no-op after done()', () => { - let fakeTime = 100_000 + const fakeTime = 100_000 const now = () => fakeTime const { sink, writes } = createSink(true) const reporter = createProgressReporter('Test', sink, now) diff --git a/ocm-cli/test/resolve-target.test.ts b/ocm-cli/test/resolve-target.test.ts index f04caa4f4..4a23673cb 100644 --- a/ocm-cli/test/resolve-target.test.ts +++ b/ocm-cli/test/resolve-target.test.ts @@ -12,12 +12,9 @@ const LAST = { branch: 'main', } -function gitInit(dir: string, originUrl?: string): void { +function gitInit(dir: string): void { mkdirSync(dir, { recursive: true }) spawnSync('git', ['init'], { cwd: dir, stdio: 'ignore' }) - if (originUrl) { - spawnSync('git', ['remote', 'add', 'origin', originUrl], { cwd: dir, stdio: 'ignore' }) - } } describe('resolveTarget', () => { @@ -31,21 +28,22 @@ describe('resolveTarget', () => { rmSync(tmp, { recursive: true, force: true }) }) - const repo = (id: number, originUrl: string, name = `repo-${id}`): TargetRepo => ({ + const repo = (id: number, projectId: string, name = `repo-${id}`): TargetRepo => ({ repoId: id, name, branch: 'main', directory: `/manager/${name}`, - originUrl, + projectId, }) - it('returns cwd-match when $PWD origin matches exactly one Manager repo', () => { + it('returns cwd-match when local project id matches exactly one Manager repo', () => { const dir = join(tmp, 'work') - gitInit(dir, 'https://github.com/me/repo.git') + gitInit(dir) const result = resolveTarget({ cwd: dir, - repos: [repo(1, 'https://github.com/me/repo.git', 'my-repo'), repo(2, 'https://github.com/other/repo.git')], + localProjectId: 'project-a', + repos: [repo(1, 'project-a', 'my-repo'), repo(2, 'project-b')], last: LAST, }) @@ -56,32 +54,32 @@ describe('resolveTarget', () => { } }) - it('returns cwd-ambiguous when multiple Manager repos match', () => { + it('returns cwd-ambiguous when multiple Manager repos share the project id', () => { const dir = join(tmp, 'work') - gitInit(dir, 'https://github.com/me/repo.git') + gitInit(dir) const result = resolveTarget({ cwd: dir, - repos: [ - repo(1, 'https://github.com/me/repo.git', 'a'), - repo(2, 'https://github.com/me/repo.git', 'b'), - ], + localProjectId: 'project-a', + repos: [repo(1, 'project-a', 'a'), repo(2, 'project-a', 'b')], last: LAST, }) expect(result.kind).toBe('cwd-ambiguous') if (result.kind === 'cwd-ambiguous') { expect(result.matches).toHaveLength(2) + expect(result.localProjectId).toBe('project-a') } }) - it('returns local(no-match) when in a git repo with no origin match (even if last is set)', () => { + it('returns local(no-match) in a git repo when no project id matches (even if last is set)', () => { const dir = join(tmp, 'work') - gitInit(dir, 'https://github.com/me/repo.git') + gitInit(dir) const result = resolveTarget({ cwd: dir, - repos: [repo(1, 'https://github.com/other/repo.git')], + localProjectId: 'project-a', + repos: [repo(1, 'project-b')], last: LAST, }) @@ -92,13 +90,31 @@ describe('resolveTarget', () => { } }) + it('returns local(no-match) in a git repo when the local project id cannot be resolved', () => { + const dir = join(tmp, 'work') + gitInit(dir) + + const result = resolveTarget({ + cwd: dir, + localProjectId: null, + repos: [repo(1, 'project-a')], + last: LAST, + }) + + expect(result.kind).toBe('local') + if (result.kind === 'local') { + expect(result.reason).toBe('no-match') + } + }) + it('returns last when not in a git repo and last is set', () => { const dir = join(tmp, 'not-git') mkdirSync(dir) const result = resolveTarget({ cwd: dir, - repos: [repo(1, 'https://github.com/me/repo.git')], + localProjectId: null, + repos: [repo(1, 'project-a')], last: LAST, }) @@ -114,7 +130,8 @@ describe('resolveTarget', () => { const result = resolveTarget({ cwd: dir, - repos: [repo(1, 'https://github.com/me/repo.git')], + localProjectId: null, + repos: [repo(1, 'project-a')], }) expect(result.kind).toBe('local') @@ -123,16 +140,4 @@ describe('resolveTarget', () => { expect(result.repoRoot).toBeNull() } }) - - it('normalises .git suffix when matching origin', () => { - const dir = join(tmp, 'work') - gitInit(dir, 'https://github.com/me/repo') - - const result = resolveTarget({ - cwd: dir, - repos: [repo(1, 'https://github.com/me/repo.git', 'my-repo')], - }) - - expect(result.kind).toBe('cwd-match') - }) }) diff --git a/package.json b/package.json index 1d74157b1..4f9888b79 100644 --- a/package.json +++ b/package.json @@ -12,22 +12,24 @@ "start": "concurrently \"pnpm:start:backend\" \"pnpm:start:frontend\"", "start:backend": "bun run backend/dist/index.js", "start:frontend": "pnpm --filter frontend preview", - "build": "pnpm run build:backend && pnpm run build:frontend", + "build": "pnpm run build:cli && pnpm run build:backend && pnpm run build:frontend", + "build:cli": "pnpm --filter @opencode-manager/ocm-cli build", "build:backend": "pnpm --filter backend build", "build:frontend": "pnpm --filter frontend build", - "typecheck": "pnpm run typecheck:frontend && pnpm run typecheck:backend", + "typecheck": "pnpm run typecheck:cli && pnpm run typecheck:frontend && pnpm run typecheck:backend", + "typecheck:cli": "pnpm --filter @opencode-manager/ocm-cli typecheck", "typecheck:frontend": "pnpm --filter frontend typecheck", "typecheck:backend": "pnpm --filter backend typecheck", - "test": "pnpm run test:backend && pnpm run test:frontend", + "test": "pnpm run test:cli && pnpm run test:backend && pnpm run test:frontend", + "test:cli": "pnpm --filter @opencode-manager/ocm-cli test", "test:frontend": "pnpm --filter frontend exec vitest run", "test:backend": "pnpm --filter backend run test", - "lint": "pnpm run lint:frontend && pnpm run lint:backend", + "lint": "pnpm run lint:cli && pnpm run lint:frontend && pnpm run lint:backend", + "lint:cli": "pnpm --filter @opencode-manager/ocm-cli lint", "lint:frontend": "pnpm --filter frontend lint", "lint:backend": "pnpm --filter backend lint", - "lint:fix": "pnpm run lint:frontend -- --fix && pnpm run lint:backend -- --fix", + "lint:fix": "pnpm run lint:cli -- --fix && pnpm run lint:frontend -- --fix && pnpm run lint:backend -- --fix", "generate:openapi": "bun scripts/generate-openapi.ts", - "test:cli": "pnpm --filter @opencode-manager/ocm-cli test", - "typecheck:cli": "pnpm --filter @opencode-manager/ocm-cli typecheck", "docker:build": "docker-compose build", "docker:up": "docker-compose up -d", "docker:down": "docker-compose down -v", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 17a19afa2..4f249b436 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -275,12 +275,24 @@ importers: ocm-cli: devDependencies: + '@eslint/js': + specifier: ^9.36.0 + version: 9.39.2 + '@opencode-manager/shared': + specifier: workspace:* + version: link:../shared '@types/node': specifier: ^22.0.0 version: 22.19.19 + eslint: + specifier: ^9.39.1 + version: 9.39.2(jiti@2.6.1) typescript: specifier: ^5.5.0 version: 5.9.3 + typescript-eslint: + specifier: ^8.45.0 + version: 8.51.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) vitest: specifier: ^3.1.0 version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.19)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.9.0) @@ -1327,56 +1339,67 @@ packages: resolution: {integrity: sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.54.0': resolution: {integrity: sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.54.0': resolution: {integrity: sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.54.0': resolution: {integrity: sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.54.0': resolution: {integrity: sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-gnu@4.54.0': resolution: {integrity: sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.54.0': resolution: {integrity: sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.54.0': resolution: {integrity: sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.54.0': resolution: {integrity: sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.54.0': resolution: {integrity: sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.54.0': resolution: {integrity: sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openharmony-arm64@4.54.0': resolution: {integrity: sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==} @@ -1454,24 +1477,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.1.18': resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.1.18': resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.1.18': resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.1.18': resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} @@ -2988,24 +3015,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.30.2: resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.30.2: resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.30.2: resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.30.2: resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} diff --git a/shared/package.json b/shared/package.json index 606f6162b..fa591a736 100644 --- a/shared/package.json +++ b/shared/package.json @@ -15,7 +15,8 @@ "./config/env": "./src/config/env.ts", "./config/client": "./src/config/client.ts", "./utils": "./src/utils/index.ts", - "./notifications": "./src/notifications/index.ts" + "./notifications": "./src/notifications/index.ts", + "./project-id": "./src/project-id-resolver.ts" }, "dependencies": { "jsonc-parser": "^3.3.1", diff --git a/shared/src/project-id-resolver.ts b/shared/src/project-id-resolver.ts new file mode 100644 index 000000000..90126610c --- /dev/null +++ b/shared/src/project-id-resolver.ts @@ -0,0 +1,94 @@ +import { execFile } from 'child_process' +import { createHash } from 'crypto' +import { readFile } from 'fs/promises' +import path from 'path' + +function git(cwd: string, args: string[]): Promise { + return new Promise((resolve) => { + execFile('git', args, { cwd }, (error, stdout) => { + resolve(error ? null : stdout.trim()) + }) + }) +} + +function trimSlashes(value: string, leading: boolean, trailing: boolean): string { + let start = 0 + let end = value.length + if (leading) while (start < end && value[start] === '/') start++ + if (trailing) while (end > start && value[end - 1] === '/') end-- + return value.slice(start, end) +} + +function gitRemoteParts(host: string, name: string): string | undefined { + let pathname = trimSlashes(name, true, false) + if (pathname.endsWith('.git')) pathname = pathname.slice(0, -4) + else if (pathname.endsWith('.git/')) pathname = pathname.slice(0, -5) + pathname = trimSlashes(pathname, false, true) + if (!host || !pathname) return undefined + return `${host.toLowerCase()}/${pathname}` +} + +/** + * Normalizes a git remote URL into OpenCode's `host/path` identity form, or + * returns undefined for unsupported remotes (e.g. `file:` URLs). Mirrors the + * normalization in OpenCode's `ProjectV2.resolve`. + */ +export function normalizeGitRemote(input: string): string | undefined { + const value = input.trim() + if (!value) return undefined + + try { + const parsed = new URL(value) + if (parsed.protocol === 'file:') return undefined + return gitRemoteParts(parsed.hostname, parsed.pathname) + } catch { + const scp = value.match(/^([^@/:]+@)?([^/:]+):(.+)$/) + if (scp) return gitRemoteParts(scp[2]!, scp[3]!) + return undefined + } +} + +/** + * Computes the OpenCode remote-backed project ID for a git origin URL, or + * undefined when the remote cannot be normalized. + */ +export function gitRemoteProjectId(originUrl: string): string | undefined { + const normalized = normalizeGitRemote(originUrl) + if (!normalized) return undefined + return createHash('sha1').update(`git-remote:${normalized}`).digest('hex') +} + +/** + * Resolves the OpenCode project ID for a git working directory using the same + * precedence OpenCode applies: normalized origin remote hash, then the cached + * `/opencode` id, then the sorted first root commit. Returns + * null when the directory is not a git repository or no id can be derived. + */ +export async function resolveOpenCodeProjectId(repoDir: string): Promise { + const worktree = await git(repoDir, ['rev-parse', '--show-toplevel']) + if (!worktree) return null + + const origin = await git(worktree, ['remote', 'get-url', 'origin']) + if (origin) { + const id = gitRemoteProjectId(origin) + if (id) return id + } + + const commonDir = await git(repoDir, ['rev-parse', '--path-format=absolute', '--git-common-dir']) + if (commonDir) { + try { + const cached = (await readFile(path.join(commonDir, 'opencode'), 'utf-8')).trim() + if (cached) return cached + } catch { + // cache file absent or unreadable + } + } + + const rootsOutput = await git(worktree, ['rev-list', '--max-parents=0', 'HEAD']) + if (rootsOutput) { + const root = rootsOutput.split('\n').map((line) => line.trim()).filter(Boolean).sort()[0] + if (root) return root + } + + return null +}