From 7e404097e204c4804267356ec49205a7634c8361 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Tue, 1 Sep 2026 19:33:51 -0400 Subject: [PATCH 1/4] feat(ocm-move): plan worktree mirror targets and stream move progress - Plan/ensure mirror targets per branch via new internal endpoints, creating a worktree repo when the target branch is not checked out in the source repo - Skip branches checked out in other worktrees during bundle import and reset/clean before checkout on both server and CLI - Rewrite workspace paths in message.updated events when replaying moved sessions - Stream live move progress (bundle, upload, import, patch, replay) in the TUI - Prefer branch-matched repos when moving sessions and share push-divergence descriptions --- backend/src/routes/internal/repo-mirror.ts | 67 +++++++++- backend/src/services/repo.ts | 34 ++++- .../test/routes/internal/repo-mirror.test.ts | 95 ++++++++++++++ .../test/services/repo-mirror-target.test.ts | 85 +++++++++++++ ocm-cli/README.md | 15 ++- ocm-cli/bin/ocm.ts | 12 +- ocm-cli/src/manager-api.ts | 39 ++++++ ocm-cli/src/mirror.ts | 23 +++- ocm-cli/src/move-progress.ts | 41 ++++++ ocm-cli/src/progress.ts | 6 +- ocm-cli/src/session-move.ts | 48 ++++--- ocm-cli/src/tui-plugin.ts | 117 ++++++++++-------- ocm-cli/src/tui.tsx | 32 ++++- ocm-cli/test/mirror.test.ts | 42 ++++++- ocm-cli/test/move-progress.test.ts | 48 +++++++ ocm-cli/test/session-move.test.ts | 38 ++++++ 16 files changed, 653 insertions(+), 89 deletions(-) create mode 100644 backend/test/services/repo-mirror-target.test.ts create mode 100644 ocm-cli/src/move-progress.ts create mode 100644 ocm-cli/test/move-progress.test.ts diff --git a/backend/src/routes/internal/repo-mirror.ts b/backend/src/routes/internal/repo-mirror.ts index d0182cc4b..0c04f72a9 100644 --- a/backend/src/routes/internal/repo-mirror.ts +++ b/backend/src/routes/internal/repo-mirror.ts @@ -9,7 +9,7 @@ import { join } from 'path' import * as fsp from 'fs/promises' import { getReposPath } from '@opencode-manager/shared/config/env' import { getRepoById, updateLastPulled, updateRepoBranch, deleteRepo } from '../../db/queries' -import { ensureMirrorTargetPath, createRepoRow, isRepoInUse } from '../../services/repo' +import { ensureMirrorTargetPath, createRepoRow, isRepoInUse, planMirrorTarget, ensureMirrorTarget } from '../../services/repo' import { logger } from '../../utils/logger' import { getErrorMessage } from '../../utils/error-utils' import { mkdirSyncSafe } from '../../utils/fs-safe' @@ -52,6 +52,10 @@ interface PatchBody { force?: boolean } +interface TargetBody { + branch?: string +} + 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 { @@ -97,9 +101,24 @@ async function applyMirrorPatch(fullPath: string, patch: string): Promise await gitRaw(fullPath, ['apply', '--binary', '--whitespace=nowarn', '-'], process.env, patch) } +async function branchesCheckedOutElsewhere(fullPath: string): Promise> { + const ownBranch = (await gitRaw(fullPath, ['symbolic-ref', '--quiet', '--short', 'HEAD']).catch(() => '')).trim() + const out = await gitRaw(fullPath, ['for-each-ref', '--format=%(refname:strip=2) %(worktreepath)', 'refs/heads']) + const locked = new Set() + for (const line of out.split('\n')) { + const firstSpace = line.indexOf(' ') + if (firstSpace === -1) continue + const name = line.slice(0, firstSpace) + const worktreePath = line.slice(firstSpace + 1).trim() + if (worktreePath && name !== ownBranch) locked.add(name) + } + return locked +} + 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 locked = await branchesCheckedOutElsewhere(fullPath) const updates: string[] = [] for (const line of refs.split('\n')) { const trimmed = line.trim() @@ -107,7 +126,7 @@ async function importBundle(fullPath: string, bundlePath: string, branch: string const firstSpace = trimmed.indexOf(' ') if (firstSpace === -1) continue const name = trimmed.slice(0, firstSpace) - if (name === 'HEAD') continue + if (name === 'HEAD' || locked.has(name)) continue const sha = trimmed.slice(firstSpace + 1) updates.push(`update refs/heads/${name} ${sha}\n`) } @@ -116,6 +135,8 @@ async function importBundle(fullPath: string, bundlePath: string, branch: string } if (branch) { + await gitRaw(fullPath, ['reset', '--hard']) + await gitRaw(fullPath, ['clean', '-fd']) 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]) @@ -375,6 +396,48 @@ export function createInternalRepoMirrorRoutes(db: Database) { } }) + app.get('/:repoId/mirror/target', async (c) => { + const repoId = Number(c.req.param('repoId')) + if (!Number.isFinite(repoId)) return c.json({ error: 'invalid repoId' }, 400) + const branch = c.req.query('branch')?.trim() + if (!branch) return c.json({ error: 'branch required' }, 400) + const repo = getRepoById(db, repoId) + if (!repo) return c.json({ error: 'repo not found' }, 404) + + try { + const plan = await planMirrorTarget(db, repo, branch) + return c.json(plan.kind === 'new' + ? { kind: plan.kind, repoId: null, fullPath: plan.fullPath, localPath: plan.localPath, branch, currentBranch: plan.currentBranch } + : { kind: plan.kind, repoId: plan.repo.id, fullPath: plan.repo.fullPath, localPath: plan.repo.localPath, branch, currentBranch: plan.currentBranch }) + } catch (error) { + logger.error('mirror target plan failed:', error) + return c.json({ error: getErrorMessage(error) }, 500) + } + }) + + app.post('/:repoId/mirror/target', async (c) => { + const repoId = Number(c.req.param('repoId')) + if (!Number.isFinite(repoId)) return c.json({ error: 'invalid repoId' }, 400) + let body: TargetBody + try { + body = (await c.req.json()) as TargetBody + } catch { + return c.json({ error: 'invalid json body' }, 400) + } + const branch = body.branch?.trim() + if (!branch) return c.json({ error: 'branch required' }, 400) + const repo = getRepoById(db, repoId) + if (!repo) return c.json({ error: 'repo not found' }, 404) + + try { + const { repo: target, created } = await ensureMirrorTarget(db, repo, branch) + return c.json({ repoId: target.id, fullPath: target.fullPath, localPath: target.localPath, branch, created }) + } catch (error) { + logger.error('mirror target ensure failed:', error) + return c.json({ error: getErrorMessage(error) }, 409) + } + }) + app.get('/:repoId/mirror/head', async (c) => { const repoIdRaw = c.req.param('repoId') const repoId = Number(repoIdRaw) diff --git a/backend/src/services/repo.ts b/backend/src/services/repo.ts index 024316bdf..302546b19 100644 --- a/backend/src/services/repo.ts +++ b/backend/src/services/repo.ts @@ -7,7 +7,7 @@ import type { Database } from 'bun:sqlite' import type { Repo, CreateRepoInput } from '../types/repo' import { logger } from '../utils/logger' import { getReposPath, getScheduleWorktreesPath } from '@opencode-manager/shared/config/env' -import { normalizeRepoDirectoryName, sanitizeRepoDirectoryName, sanitizeBranchForDirectory, normalizeRepoUrlForCompare, isSSHUrl, normalizeSSHUrl, SCP_STYLE_URL_PATTERN } from '@opencode-manager/shared/utils' +import { normalizeRepoDirectoryName, sanitizeRepoDirectoryName, sanitizeBranchForDirectory, getRepoBaseDirectoryName, normalizeRepoUrlForCompare, isSSHUrl, normalizeSSHUrl, SCP_STYLE_URL_PATTERN } from '@opencode-manager/shared/utils' import type { GitAuthService } from './git-auth' import { isGitHubHttpsUrl } from '../utils/git-auth' import path from 'path' @@ -1056,6 +1056,38 @@ export async function createWorktreeSafely(baseRepoPath: string, worktreePath: s } } +export type MirrorTargetPlan = + | { kind: 'in-place'; repo: Repo; currentBranch: string | null } + | { kind: 'existing'; repo: Repo; currentBranch: string | null } + | { kind: 'new'; localPath: string; fullPath: string; currentBranch: string | null } + +export async function planMirrorTarget(database: Database, repo: Repo, branch: string): Promise { + const currentBranch = await safeGetCurrentBranch(repo.fullPath, {}) + if (currentBranch === branch) return { kind: 'in-place', repo, currentBranch } + + const localPath = `${getRepoBaseDirectoryName(repo)}-${sanitizeBranchForDirectory(branch)}` + const existing = getRepoByLocalPath(database, localPath) + if (existing && existsSync(existing.fullPath)) return { kind: 'existing', repo: existing, currentBranch } + + return { kind: 'new', localPath, fullPath: path.join(getReposPath(), localPath), currentBranch } +} + +export async function ensureMirrorTarget(database: Database, repo: Repo, branch: string): Promise<{ repo: Repo; created: boolean }> { + const plan = await planMirrorTarget(database, repo, branch) + if (plan.kind !== 'new') return { repo: plan.repo, created: false } + + await createWorktreeSafely(repo.fullPath, plan.fullPath, branch, {}) + const worktreeRepo = createRepo(database, repo.repoUrl + ? { repoUrl: repo.repoUrl, localPath: plan.localPath, branch, defaultBranch: branch, cloneStatus: 'ready', clonedAt: Date.now(), isWorktree: true } + : { isLocal: true, localPath: plan.localPath, branch, defaultBranch: branch, cloneStatus: 'ready', clonedAt: Date.now(), isWorktree: true }) + + if (worktreeRepo.localPath !== plan.localPath) { + await removeWorktree(repo.fullPath, plan.fullPath) + throw new Error(`branch ${branch} is already registered as repo ${worktreeRepo.id} at ${worktreeRepo.fullPath}`) + } + return { repo: worktreeRepo, created: true } +} + export function ensureMirrorTargetPath(name: string): { fullPath: string; localPath: string } { const slugified = name .toLowerCase() diff --git a/backend/test/routes/internal/repo-mirror.test.ts b/backend/test/routes/internal/repo-mirror.test.ts index 4abe95d3b..c9f66c82c 100644 --- a/backend/test/routes/internal/repo-mirror.test.ts +++ b/backend/test/routes/internal/repo-mirror.test.ts @@ -348,6 +348,101 @@ describe('internal-repo-mirror routes', () => { expect(featureRef.status).toBe(0) }) + it('replaces a dirty manager working tree when importing a bundle', async () => { + const sourceDir = join(getTmpRoot(), 'bundle-source-dirty') + 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, '.gitignore'), 'ignored.txt\n') + writeFileSync(join(sourceDir, 'tracked.txt'), 'from bundle\n') + spawnSync('git', ['add', '.gitignore', 'tracked.txt'], { cwd: sourceDir, stdio: 'ignore' }) + spawnSync('git', ['commit', '-m', 'source'], { cwd: sourceDir, stdio: 'ignore' }) + const bundlePath = join(getTmpRoot(), 'source-dirty.bundle') + spawnSync('git', ['bundle', 'create', bundlePath, '--all'], { cwd: sourceDir, stdio: 'ignore' }) + + const targetDir = join(getTmpRoot(), 'bundle-target-dirty') + 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, '.gitignore'), 'ignored.txt\n') + writeFileSync(join(targetDir, 'tracked.txt'), 'old\n') + spawnSync('git', ['add', '.gitignore', 'tracked.txt'], { cwd: targetDir, stdio: 'ignore' }) + spawnSync('git', ['commit', '-m', 'target'], { cwd: targetDir, stdio: 'ignore' }) + writeFileSync(join(targetDir, 'tracked.txt'), 'server-side edit\n') + writeFileSync(join(targetDir, 'stale-untracked.txt'), 'stale\n') + writeFileSync(join(targetDir, 'ignored.txt'), 'keep me\n') + + 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?force=1', { + 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') + expect(existsSync(join(targetDir, 'stale-untracked.txt'))).toBe(false) + expect(existsSync(join(targetDir, 'ignored.txt'))).toBe(true) + const status = spawnSync('git', ['status', '--porcelain'], { cwd: targetDir, encoding: 'utf-8' }).stdout + expect(status.trim()).toBe('') + }) + + it('does not move branches checked out in other worktrees when importing into a worktree', async () => { + const sourceDir = join(getTmpRoot(), 'bundle-source-wt') + 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'), 'main from laptop\n') + spawnSync('git', ['add', 'tracked.txt'], { cwd: sourceDir, stdio: 'ignore' }) + spawnSync('git', ['commit', '-m', 'laptop main'], { cwd: sourceDir, stdio: 'ignore' }) + spawnSync('git', ['checkout', '-b', 'feature'], { cwd: sourceDir, stdio: 'ignore' }) + writeFileSync(join(sourceDir, 'feature.txt'), 'feature\n') + spawnSync('git', ['add', 'feature.txt'], { cwd: sourceDir, stdio: 'ignore' }) + spawnSync('git', ['commit', '-m', 'feature'], { cwd: sourceDir, stdio: 'ignore' }) + const bundlePath = join(getTmpRoot(), 'source-wt.bundle') + spawnSync('git', ['bundle', 'create', bundlePath, '--all'], { cwd: sourceDir, stdio: 'ignore' }) + + const baseDir = join(getTmpRoot(), 'wt-base') + mkdirSync(baseDir, { recursive: true }) + spawnSync('git', ['init', '-b', 'main'], { cwd: baseDir, stdio: 'ignore' }) + spawnSync('git', ['config', 'user.email', 'test@test.com'], { cwd: baseDir, stdio: 'ignore' }) + spawnSync('git', ['config', 'user.name', 'Test'], { cwd: baseDir, stdio: 'ignore' }) + writeFileSync(join(baseDir, 'tracked.txt'), 'server main\n') + spawnSync('git', ['add', 'tracked.txt'], { cwd: baseDir, stdio: 'ignore' }) + spawnSync('git', ['commit', '-m', 'server main'], { cwd: baseDir, stdio: 'ignore' }) + const serverMainHead = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: baseDir, encoding: 'utf-8' }).stdout.trim() + const worktreeDir = join(getTmpRoot(), 'wt-base-feature') + spawnSync('git', ['worktree', 'add', '-b', 'feature', worktreeDir], { cwd: baseDir, stdio: 'ignore' }) + + mockGetRepoById.mockReturnValue({ id: 2, fullPath: worktreeDir }) + mockSafeGitOut.mockImplementation(async (_repoPath: string, args: string[]) => { + if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref') return 'feature' + if (args[0] === 'rev-parse' && args[1] === 'HEAD') return 'abc' + return null + }) + + const res = await app.request('/api/internal/repos/2/mirror/bundle?force=1', { + method: 'POST', + body: readFileSync(bundlePath), + headers: { 'content-type': 'application/octet-stream', 'x-ocm-branch': 'feature' }, + }) + + expect(res.status).toBe(200) + expect(readFileSync(join(worktreeDir, 'feature.txt'), 'utf-8')).toBe('feature\n') + expect(readFileSync(join(baseDir, 'tracked.txt'), 'utf-8')).toBe('server main\n') + expect(spawnSync('git', ['rev-parse', 'refs/heads/main'], { cwd: baseDir, encoding: 'utf-8' }).stdout.trim()).toBe(serverMainHead) + expect(spawnSync('git', ['status', '--porcelain'], { cwd: baseDir, encoding: 'utf-8' }).stdout.trim()).toBe('') + }) + 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 }) diff --git a/backend/test/services/repo-mirror-target.test.ts b/backend/test/services/repo-mirror-target.test.ts new file mode 100644 index 000000000..9553a4e51 --- /dev/null +++ b/backend/test/services/repo-mirror-target.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest' +import { execSync } from 'child_process' +import { mkdtempSync, existsSync } from 'fs' +import { tmpdir } from 'os' +import path from 'path' +import { rm } from 'fs/promises' +import type { Database } from 'bun:sqlite' +import type { Repo } from '../../src/types/repo' + +let tmpRoot: string +vi.mock('@opencode-manager/shared/config/env', async (importOriginal) => { + const actual = await importOriginal() + return { + ...(actual as object), + getReposPath: () => tmpRoot, + getWorkspacePath: vi.fn(() => '/tmp/fake-workspace'), + } +}) + +describe('mirror target resolution', () => { + let db: Database + let base: Repo + let baseRepoPath: string + + beforeAll(async () => { + tmpRoot = mkdtempSync(path.join(tmpdir(), 'repo-mirror-target-')) + baseRepoPath = path.join(tmpRoot, 'my-app') + execSync(`git init -b main "${baseRepoPath}"`) + execSync(`git -C "${baseRepoPath}" config user.email test@test.com`) + execSync(`git -C "${baseRepoPath}" config user.name Test`) + execSync(`git -C "${baseRepoPath}" commit --allow-empty -m "Initial commit"`) + + const { createTestDb } = await import('../helpers/assistant-workspace') + const { createRepo } = await import('../../src/db/queries') + db = createTestDb() + base = createRepo(db, { isLocal: true, localPath: 'my-app', branch: 'main', defaultBranch: 'main', cloneStatus: 'ready', clonedAt: Date.now() }) + }) + + afterAll(async () => { + await rm(tmpRoot, { recursive: true, force: true }) + }) + + it('targets the repo in place when its checked-out branch matches', async () => { + const { planMirrorTarget } = await import('../../src/services/repo') + const plan = await planMirrorTarget(db, base, 'main') + expect(plan.kind).toBe('in-place') + expect(plan.currentBranch).toBe('main') + }) + + it('plans a new sibling worktree when the checked-out branch differs', async () => { + const { planMirrorTarget } = await import('../../src/services/repo') + const plan = await planMirrorTarget(db, base, 'feature/x') + expect(plan).toMatchObject({ kind: 'new', localPath: 'my-app-feature-x', fullPath: path.join(tmpRoot, 'my-app-feature-x'), currentBranch: 'main' }) + }) + + it('creates the worktree, the branch, and a worktree repo row without touching the base checkout', async () => { + const { ensureMirrorTarget, planMirrorTarget } = await import('../../src/services/repo') + const { repo: target, created } = await ensureMirrorTarget(db, base, 'feature/x') + + expect(created).toBe(true) + expect(target.id).not.toBe(base.id) + expect(target.isWorktree).toBe(true) + expect(target.branch).toBe('feature/x') + expect(target.fullPath).toBe(path.join(tmpRoot, 'my-app-feature-x')) + expect(existsSync(target.fullPath)).toBe(true) + expect(execSync(`git -C "${target.fullPath}" rev-parse --abbrev-ref HEAD`, { encoding: 'utf-8' }).trim()).toBe('feature/x') + expect(execSync(`git -C "${baseRepoPath}" rev-parse --abbrev-ref HEAD`, { encoding: 'utf-8' }).trim()).toBe('main') + + const again = await planMirrorTarget(db, base, 'feature/x') + expect(again.kind).toBe('existing') + if (again.kind === 'existing') expect(again.repo.id).toBe(target.id) + + const second = await ensureMirrorTarget(db, base, 'feature/x') + expect(second.created).toBe(false) + expect(second.repo.id).toBe(target.id) + }) + + it('resolves the base directory name when asked from a worktree repo row', async () => { + const { planMirrorTarget } = await import('../../src/services/repo') + const { getRepoByLocalPath } = await import('../../src/db/queries') + const worktreeRepo = getRepoByLocalPath(db, 'my-app-feature-x')! + const plan = await planMirrorTarget(db, worktreeRepo, 'other') + expect(plan).toMatchObject({ kind: 'new', localPath: 'my-app-other' }) + }) +}) diff --git a/ocm-cli/README.md b/ocm-cli/README.md index 85007500c..8b61f993a 100644 --- a/ocm-cli/README.md +++ b/ocm-cli/README.md @@ -84,9 +84,18 @@ Configure the package name and OpenCode resolves that TUI entrypoint automatically. When attached to a Manager via `ocm`, the plugin shows a `REMOTE · ` indicator at the bottom of the TUI; local launches show nothing. It registers `/ocm-move`, which keeps the local session and -copies the active session to the Manager after pushing the current repo state. -When multiple Manager repos match, a picker dialog lets you choose the -destination. A confirmation dialog gates the move before any push. On success +copies the active session to the Manager after replacing the Manager repo's +working tree with your local one (commits, staged, unstaged, and untracked +files; gitignored files on the Manager are preserved). The Manager's current +checkout is never switched: if it is on your branch the repo is replaced in +place; otherwise your branch goes into a sibling worktree (`-`, +registered as its own Manager repo), created on demand if it does not exist +yet. When multiple Manager repos match, the one already on your branch is +chosen; otherwise a picker dialog lets you choose. A confirmation dialog gates +the move before any push, states where the state will land, and lists any +server-side work (uncommitted changes or commits not present locally) that will +be discarded there. While the move runs, a spinner with the current phase and a +progress bar is shown next to the prompt. On success you can optionally warp — exit the local TUI and attach to the moved session on the Manager immediately. Use it from inside an OpenCode session after `ocm login` and after the repo already exists on the Manager diff --git a/ocm-cli/bin/ocm.ts b/ocm-cli/bin/ocm.ts index c56f2731c..0a8371c52 100644 --- a/ocm-cli/bin/ocm.ts +++ b/ocm-cli/bin/ocm.ts @@ -3,7 +3,7 @@ import { basename } from 'path' import { readState, writeState, clearState, getStatePath, type OcmState } from '../src/state.js' import { getToken, setToken, deleteToken, hasStoredToken, describeTokenStore, describeTokenWriteTarget, envToken, TOKEN_ENV, TokenStoreError } from '../src/internal-token-store.js' import { ManagerApi, ManagerApiError } from '../src/manager-api.js' -import { mirrorUp, mirrorDown, mirrorUpFast, mirrorDownFast, prepareMirror, MirrorAbort, checkPushDivergence, checkPullDivergence } from '../src/mirror.js' +import { mirrorUp, mirrorDown, mirrorUpFast, mirrorDownFast, prepareMirror, MirrorAbort, checkPushDivergence, checkPullDivergence, describePushDivergence } from '../src/mirror.js' import type { RemoteRepoSummary, MirrorProgress, PushDivergence, PullDivergence } from '../src/mirror.js' import { createProgressReporter } from '../src/progress.js' import { getBranchName, getOriginUrl } from '../src/local-repo.js' @@ -72,17 +72,9 @@ function confirmOverwrite(headline: string, reasons: string[], question: string, } 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, + describePushDivergence(div), 'Overwrite server-side work and push anyway?', 'This work is likely from OpenCode agent sessions on the manager.', ) diff --git a/ocm-cli/src/manager-api.ts b/ocm-cli/src/manager-api.ts index 63d40fcc8..153b116b4 100644 --- a/ocm-cli/src/manager-api.ts +++ b/ocm-cli/src/manager-api.ts @@ -52,6 +52,25 @@ export interface MirrorBundleResult { created: false } +export type MirrorTargetKind = 'in-place' | 'existing' | 'new' + +export interface MirrorTargetPlan { + kind: MirrorTargetKind + repoId: number | null + fullPath: string + localPath: string + branch: string + currentBranch: string | null +} + +export interface MirrorTarget { + repoId: number + fullPath: string + localPath: string + branch: string + created: boolean +} + function createByteCounter(onProgress: (bytesSent: number) => void): TransformStream { let bytesSent = 0 return new TransformStream({ @@ -202,6 +221,26 @@ export class ManagerApi { return (await res.json()) as MirrorHead } + async mirrorTargetPlan(repoId: number, branch: string): Promise { + const res = await fetch(`${this.baseUrl}/api/internal/repos/${repoId}/mirror/target?branch=${encodeURIComponent(branch)}`, { + headers: this.headers(), + }) + + if (!res.ok) throw await formatErrorResponse(res, 'mirror target plan') + return (await res.json()) as MirrorTargetPlan + } + + async mirrorEnsureTarget(repoId: number, branch: string): Promise { + const res = await fetch(`${this.baseUrl}/api/internal/repos/${repoId}/mirror/target`, { + method: 'POST', + headers: { ...this.headers(), 'Content-Type': 'application/json' }, + body: JSON.stringify({ branch }), + }) + + if (!res.ok) throw await formatErrorResponse(res, 'mirror target') + return (await res.json()) as MirrorTarget + } + 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(), diff --git a/ocm-cli/src/mirror.ts b/ocm-cli/src/mirror.ts index 377fbcbc1..13c972743 100644 --- a/ocm-cli/src/mirror.ts +++ b/ocm-cli/src/mirror.ts @@ -69,6 +69,12 @@ export async function prepareMirror(cwd: string, remotes: RemoteRepoSummary[]): return { repoRoot, localProjectId, matched } } +export function pickMatchedRepo(matched: RemoteRepoSummary[], localBranch: string | null): RemoteRepoSummary | null { + const onBranch = matched.filter((r) => localBranch !== null && r.branch === localBranch) + if (onBranch.length === 1) return onBranch[0]! + return matched.length === 1 ? matched[0]! : null +} + export interface PushDivergence { serverHead: string | null serverBranch: string | null @@ -95,6 +101,17 @@ export async function checkPushDivergence(repoRoot: string, api: ManagerApi, rep return { serverHead, serverBranch, serverDirty, diverged: true, lostCommits } } +export function describePushDivergence(div: PushDivergence): string[] { + 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 reasons +} + export interface PullDivergence { diverged: boolean lostCommits: number @@ -412,6 +429,8 @@ function importLocalBundle(repoRoot: string, bundlePath: string, branch: string } if (branch) { + runGit(repoRoot, ['reset', '--hard']) + runGit(repoRoot, ['clean', '-fd']) runGit(repoRoot, ['checkout', branch]) const head = runGit(repoRoot, ['rev-parse', `refs/remotes/ocm-sync/${branch}`]).trim() if (head) runGit(repoRoot, ['reset', '--hard', head]) @@ -447,7 +466,7 @@ export type MirrorUpFastPhase = export async function mirrorUpFast( plan: MirrorPlan, opts: Pick & { onPhase?: (phase: MirrorUpFastPhase) => void }, -): Promise<{ repoId: number; branch: string | null; head: string | null; created: false }> { +): Promise<{ repoId: number; fullPath: string; branch: string | null; head: string | null; created: false }> { const repoId = plan.matched[0]!.repoId const onPhase = opts.onPhase onPhase?.({ kind: 'bundling' }) @@ -467,7 +486,7 @@ export async function mirrorUpFast( }) onPhase?.({ kind: 'patching' }) const patchResult = await mirrorUpPatch(plan, opts) - return { repoId: patchResult.repoId, branch: patchResult.branch, head: patchResult.head, created: false } + return { repoId: patchResult.repoId, fullPath: patchResult.fullPath, branch: patchResult.branch, head: patchResult.head, created: false } } finally { await fsp.rm(bundlePath, { force: true }).catch(() => {}) } diff --git a/ocm-cli/src/move-progress.ts b/ocm-cli/src/move-progress.ts new file mode 100644 index 000000000..f16cd3704 --- /dev/null +++ b/ocm-cli/src/move-progress.ts @@ -0,0 +1,41 @@ +import type { MirrorUpFastPhase } from './mirror.js' +import { formatBytes, SPINNER_FRAMES } from './progress.js' + +export interface MoveProgress { + label: string + fraction: number | null +} + +const BAR_WIDTH = 12 + +export function renderProgressBar(fraction: number, width = BAR_WIDTH): string { + const clamped = Math.min(1, Math.max(0, fraction)) + const filled = Math.round(clamped * width) + return `${'█'.repeat(filled)}${'░'.repeat(width - filled)} ${Math.round(clamped * 100)}%` +} + +export function formatMoveProgress(progress: MoveProgress, frame: number): string { + const spinner = SPINNER_FRAMES[frame % SPINNER_FRAMES.length] + const bar = progress.fraction === null ? '' : ` ${renderProgressBar(progress.fraction)}` + return `${spinner} ocm-move: ${progress.label}${bar}` +} + +export function pushPhaseProgress(phase: MirrorUpFastPhase): MoveProgress { + switch (phase.kind) { + case 'bundling': + return { label: 'creating git bundle', fraction: null } + case 'uploading': + return { + label: `uploading ${formatBytes(phase.bytesSent)} / ${formatBytes(phase.totalBytes)}`, + fraction: phase.totalBytes > 0 ? phase.bytesSent / phase.totalBytes : null, + } + case 'processing': + return { label: 'server importing bundle', fraction: null } + case 'patching': + return { label: 'applying local changes', fraction: null } + } +} + +export function replayProgress(replayed: number, total: number): MoveProgress { + return { label: `replaying session ${replayed}/${total} events`, fraction: total > 0 ? replayed / total : null } +} diff --git a/ocm-cli/src/progress.ts b/ocm-cli/src/progress.ts index be7dec98d..73d89dcc0 100644 --- a/ocm-cli/src/progress.ts +++ b/ocm-cli/src/progress.ts @@ -1,4 +1,4 @@ -const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] +export const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] export function formatBytes(bytes: number): string { if (bytes < 1024) { @@ -42,8 +42,8 @@ export function createProgressReporter( if (isTTY) { if (t - lastRenderAt < 80) return lastRenderAt = t - out.write(`\r\x1b[K${label}: ${FRAMES[frameIndex]} ${formatBytes(bytes)}`) - frameIndex = (frameIndex + 1) % FRAMES.length + out.write(`\r\x1b[K${label}: ${SPINNER_FRAMES[frameIndex]} ${formatBytes(bytes)}`) + frameIndex = (frameIndex + 1) % SPINNER_FRAMES.length } else { if (t - lastNonTtyTickAt < 1000) return lastNonTtyTickAt = t diff --git a/ocm-cli/src/session-move.ts b/ocm-cli/src/session-move.ts index 415b737a0..5d7d68123 100644 --- a/ocm-cli/src/session-move.ts +++ b/ocm-cli/src/session-move.ts @@ -53,6 +53,10 @@ export function rewriteEventsForRemote(events: ReplayEvent[], ctx: RewriteContex stripWorkspaceID(data, 'location') } + if (isMessageUpdated(event.type)) { + rewriteMessagePath(data, ctx) + } + coerceTimestamp(data) return { ...event, data } @@ -67,28 +71,44 @@ function isMovedEvent(type: string): boolean { return type.startsWith('session.next.moved') } +function isMessageUpdated(type: string): boolean { + return type.startsWith('message.updated') +} + +function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' ? (value as Record) : null +} + +function relocatePath(path: string, ctx: RewriteContext): string { + if (path === ctx.localRoot) return ctx.remoteRoot + if (path.startsWith(ctx.localRoot + '/')) return ctx.remoteRoot + path.slice(ctx.localRoot.length) + return path +} + +function relocateField(obj: Record, key: string, ctx: RewriteContext): void { + const value = obj[key] + if (typeof value === 'string') obj[key] = relocatePath(value, ctx) +} + function rewriteDirectory( data: Record, key: 'info' | 'location', ctx: RewriteContext, ): void { - const container = data[key] - if (!container || typeof container !== 'object') return - const obj = container as Record - const dir = obj.directory - if (typeof dir !== 'string') return - if (dir === ctx.localRoot) { - obj.directory = ctx.remoteRoot - } else if (dir.startsWith(ctx.localRoot + '/')) { - obj.directory = ctx.remoteRoot + dir.slice(ctx.localRoot.length) - } + const container = asRecord(data[key]) + if (container) relocateField(container, 'directory', ctx) +} + +function rewriteMessagePath(data: Record, ctx: RewriteContext): void { + const path = asRecord(asRecord(data.info)?.path) + if (!path) return + relocateField(path, 'cwd', ctx) + relocateField(path, 'root', ctx) } function stripWorkspaceID(data: Record, key: 'info' | 'location'): void { - const container = data[key] - if (!container || typeof container !== 'object') return - const obj = container as Record - delete obj.workspaceID + const container = asRecord(data[key]) + if (container) delete container.workspaceID } /** Per commit 686c820d: coerce `data.timestamp` from ISO string to epoch ms. */ diff --git a/ocm-cli/src/tui-plugin.ts b/ocm-cli/src/tui-plugin.ts index 30e9dc01a..08695d31e 100644 --- a/ocm-cli/src/tui-plugin.ts +++ b/ocm-cli/src/tui-plugin.ts @@ -4,16 +4,21 @@ import { getToken } from './internal-token-store.js' import { TokenStoreError } from './token-store.js' import { fetchRepos, toRemoteRepoSummaries } from './manager-repos.js' import { ManagerApi, ManagerApiError } from './manager-api.js' -import { prepareMirror, checkPushDivergence, mirrorUpFast } from './mirror.js' -import type { MirrorPlan, MirrorUpFastPhase } from './mirror.js' -import { formatBytes } from './progress.js' +import type { MirrorTargetPlan } from './manager-api.js' +import { prepareMirror, checkPushDivergence, describePushDivergence, mirrorUpFast, pickMatchedRepo } from './mirror.js' +import type { MirrorPlan, RemoteRepoSummary } from './mirror.js' +import { getBranchName } from './local-repo.js' import { transferSession, moveReminderText } from './session-move.js' import { createManagerReplay, createManagerPromptAsync } from './remote-replay.js' import { readSessionEvents } from './local-history.js' import { confirmDialog, selectDialog } from './tui-dialogs.js' import { setPendingWarp, runPendingWarp } from './warp.js' +import { pushPhaseProgress, replayProgress } from './move-progress.js' +import type { MoveProgress } from './move-progress.js' -export async function setupOcm(api: TuiPluginApi): Promise { +export type MoveProgressSetter = (progress: MoveProgress | null) => void + +export async function setupOcm(api: TuiPluginApi, setMoveProgress: MoveProgressSetter): Promise { showInstallNotice(api) api.keymap.registerLayer({ commands: [ @@ -24,7 +29,7 @@ export async function setupOcm(api: TuiPluginApi): Promise { category: 'OpenCode Manager', namespace: 'palette', slashName: 'ocm-move', - run: () => runSessionMove(api), + run: () => runSessionMove(api, setMoveProgress), }, ], }) @@ -45,32 +50,40 @@ function showInstallNotice(api: TuiPluginApi): void { }) } -function pushPhaseMessage(phase: MirrorUpFastPhase): string { - switch (phase.kind) { - case 'bundling': - return 'Pushing repo state: creating git bundle…' - case 'uploading': - return `Pushing repo state: uploading ${formatBytes(phase.bytesSent)} / ${formatBytes(phase.totalBytes)}…` - case 'processing': - return 'Pushing repo state: waiting for server to import bundle…' - case 'patching': - return 'Pushing repo state: applying local changes…' +async function describeRemoteDiscard(repoRoot: string, managerApi: ManagerApi, repoId: number): Promise { + try { + return describePushDivergence(await checkPushDivergence(repoRoot, managerApi, repoId)) + } catch (error) { + if (error instanceof ManagerApiError && error.status === 404) return [] + throw error } } -function createPushPhaseToaster(api: TuiPluginApi): (phase: MirrorUpFastPhase) => void { - let lastUploadToastAt = 0 - return (phase) => { - if (phase.kind === 'uploading') { - const now = Date.now() - if (now - lastUploadToastAt < 1000) return - lastUploadToastAt = now - } - api.ui.toast({ message: pushPhaseMessage(phase) }) +function describeMoveTarget(repoName: string, target: MirrorTargetPlan): string { + switch (target.kind) { + case 'in-place': + return `Replace the repo state of ${repoName} (${target.fullPath}) with your local working tree and move this session there?` + case 'existing': + return `${repoName} is checked out on ${target.currentBranch ?? 'another branch'}; branch ${target.branch} lives in worktree ${target.localPath} (${target.fullPath}).\n\nReplace that worktree with your local working tree and move this session there?` + case 'new': + return `${repoName} is checked out on ${target.currentBranch ?? 'another branch'}; it will not be touched.\n\nCreate worktree ${target.localPath} (${target.fullPath}) for branch ${target.branch}, push your local working tree there, and move this session?` } } -async function runSessionMove(api: TuiPluginApi): Promise { +function moveConfirmMessage(repoName: string, target: MirrorTargetPlan, discardReasons: string[]): string { + const base = describeMoveTarget(repoName, target) + if (discardReasons.length === 0) return base + return `${base}\n\nThis discards server-side work:\n${discardReasons.map((r) => ` - ${r}`).join('\n')}` +} + +async function resolveMoveTarget(managerApi: ManagerApi, matched: RemoteRepoSummary, remoteDirectory: string, localBranch: string | null): Promise { + if (!localBranch) { + return { kind: 'in-place', repoId: matched.repoId, fullPath: remoteDirectory, localPath: remoteDirectory, branch: '', currentBranch: null } + } + return managerApi.mirrorTargetPlan(matched.repoId, localBranch) +} + +async function runSessionMove(api: TuiPluginApi, setMoveProgress: MoveProgressSetter): Promise { try { const current = api.route.current if (current.name !== 'session' || !current.params) { @@ -112,50 +125,50 @@ async function runSessionMove(api: TuiPluginApi): Promise { return } - let matched = plan.matched[0]! - if (plan.matched.length > 1) { - const chosen = await selectDialog(api, 'Move session to Manager repo', plan.matched.map((r) => ({ title: r.name, description: `id=${r.repoId}`, value: r }))) - if (!chosen) return - matched = chosen - } - const repoId = matched.repoId - const remoteRepo = repos.find((r) => r.repoId === repoId) - const remoteDirectory = remoteRepo!.directory - - const proceed = await confirmDialog(api, { title: 'Move session to Manager', message: `Push repo state and move this session to ${matched.name} (${remoteDirectory})?` }) - if (!proceed) return + const localBranch = getBranchName(plan.repoRoot) + const matched = pickMatchedRepo(plan.matched, localBranch) + ?? await selectDialog(api, 'Move session to Manager repo', plan.matched.map((r) => ({ title: r.name, description: `id=${r.repoId} branch=${r.branch ?? '-'}`, value: r }))) + if (!matched) return + const matchedRepoId = matched.repoId + const remoteRepo = repos.find((r) => r.repoId === matchedRepoId)! const managerApi = new ManagerApi(state.managerUrl, token) + const target = await resolveMoveTarget(managerApi, matched, remoteRepo.directory, localBranch) + const discardReasons = target.repoId === null ? [] : await describeRemoteDiscard(plan.repoRoot, managerApi, target.repoId) - try { - const divergence = await checkPushDivergence(plan.repoRoot, managerApi, repoId) - if (divergence.diverged || divergence.serverDirty) { - api.ui.toast({ variant: 'error', title: 'Remote has diverged', message: 'Remote has diverged; resolve with `ocm push --force` first' }) - return - } - } catch (error) { - if (!(error instanceof ManagerApiError && error.status === 404)) throw error - } + const proceed = await confirmDialog(api, { + title: 'Move session to Manager', + message: moveConfirmMessage(matched.name, target, discardReasons), + }) + if (!proceed) return - const selectedPlan: MirrorPlan = { ...plan, matched: [matched] } - await mirrorUpFast(selectedPlan, { + let targetRepoId = target.repoId + if (targetRepoId === null) { + setMoveProgress({ label: `creating worktree ${target.localPath}`, fraction: null }) + targetRepoId = (await managerApi.mirrorEnsureTarget(matched.repoId, target.branch)).repoId + } + const selectedPlan: MirrorPlan = { ...plan, matched: [{ ...matched, repoId: targetRepoId }] } + const pushed = await mirrorUpFast(selectedPlan, { api: managerApi, - force: false, - onPhase: createPushPhaseToaster(api), + force: true, + onPhase: (phase) => setMoveProgress(pushPhaseProgress(phase)), }) + const remoteDirectory = pushed.fullPath const result = await transferSession( { sessionID, localRoot: plan.repoRoot, remoteDirectory }, { fetchLocalHistory: () => readSessionEvents(sessionID), replayEvents: createManagerReplay(state.managerUrl, token), - onProgress: (replayed, total) => api.ui.toast({ message: `Moving session… ${replayed}/${total} events`, duration: 2000 }), + onProgress: (replayed, total) => setMoveProgress(replayProgress(replayed, total)), }, ) switch (result.kind) { case 'moved': { + setMoveProgress({ label: 'notifying moved session', fraction: null }) await createManagerPromptAsync(state.managerUrl, token)(remoteDirectory, result.sessionID, moveReminderText(remoteDirectory)).catch(() => undefined) + setMoveProgress(null) const warp = await confirmDialog(api, { title: 'Attach to moved session?', message: 'Exit this TUI and attach to the moved session on the Manager now?' }) if (warp) { await fetch(`${state.managerUrl}/api/opencode-proxy/session?directory=${encodeURIComponent(remoteDirectory)}`, { headers: { authorization: `Bearer ${token}` } }).catch(() => undefined) @@ -178,6 +191,8 @@ async function runSessionMove(api: TuiPluginApi): Promise { } } catch (err) { api.ui.toast({ variant: 'error', message: err instanceof Error ? err.message : String(err) }) + } finally { + setMoveProgress(null) } } diff --git a/ocm-cli/src/tui.tsx b/ocm-cli/src/tui.tsx index 4d0a0e645..4f9834fb7 100644 --- a/ocm-cli/src/tui.tsx +++ b/ocm-cli/src/tui.tsx @@ -1,9 +1,37 @@ /** @jsxImportSource @opentui/solid */ +import { createSignal, createEffect, onCleanup, Show } from 'solid-js' import { setupOcm, readRemoteContext } from './tui-plugin.js' +import { formatMoveProgress } from './move-progress.js' +import type { MoveProgress } from './move-progress.js' import type { TuiPluginApi, TuiPluginModule, TuiSlotContext } from './tui-types.js' +const SPINNER_INTERVAL_MS = 80 + const tui = async (api: TuiPluginApi): Promise => { - await setupOcm(api) + const [moveProgress, setMoveProgress] = createSignal(null) + await setupOcm(api, setMoveProgress) + + const moveIndicator = (ctx: TuiSlotContext) => { + const theme = ctx.theme.current + const [frame, setFrame] = createSignal(0) + createEffect(() => { + if (!moveProgress()) return + const timer = setInterval(() => setFrame((f) => f + 1), SPINNER_INTERVAL_MS) + onCleanup(() => clearInterval(timer)) + }) + return ( + + + {(progress) => {formatMoveProgress(progress(), frame())}} + + + ) + } + + api.slots.register({ + order: 290, + slots: { session_prompt_right: moveIndicator }, + }) const remote = readRemoteContext(process.env) if (!remote) return @@ -29,4 +57,4 @@ const tui = async (api: TuiPluginApi): Promise => { }) } -export default { id: 'ocm', tui } satisfies TuiPluginModule \ No newline at end of file +export default { id: 'ocm', tui } satisfies TuiPluginModule diff --git a/ocm-cli/test/mirror.test.ts b/ocm-cli/test/mirror.test.ts index 74d28d785..403be397e 100644 --- a/ocm-cli/test/mirror.test.ts +++ b/ocm-cli/test/mirror.test.ts @@ -4,7 +4,7 @@ import { join } from 'path' import { tmpdir } from 'os' import { randomBytes } from 'crypto' import { spawnSync, execSync } from 'child_process' -import { prepareMirror, MirrorAbort, mirrorDown, mirrorUp, mirrorUpPatch, mirrorUpFast, checkPushDivergence, checkPullDivergence, type MirrorUpFastPhase } from '../src/mirror' +import { prepareMirror, MirrorAbort, mirrorDown, mirrorUp, mirrorUpPatch, mirrorUpFast, checkPushDivergence, checkPullDivergence, describePushDivergence, pickMatchedRepo, type MirrorUpFastPhase } from '../src/mirror' import { getBranchName } from '../src/local-repo' import { gitRemoteProjectId } from '@opencode-manager/shared/project-id' import { mockStateModule, mockTokenStoreModule } from './helpers/token-store-mocks.js' @@ -644,6 +644,46 @@ describe('checkPushDivergence', () => { }) }) +describe('pickMatchedRepo', () => { + const main = { repoId: 1, name: 'app', projectId: 'p', branch: 'main' } + const feature = { repoId: 2, name: 'app-feature', projectId: 'p', branch: 'feature' } + + it('returns the single match regardless of branch', () => { + expect(pickMatchedRepo([main], 'feature')).toBe(main) + expect(pickMatchedRepo([main], null)).toBe(main) + }) + + it('prefers the repo already on the local branch when several match', () => { + expect(pickMatchedRepo([main, feature], 'feature')).toBe(feature) + }) + + it('returns null when several match and none is on the local branch', () => { + expect(pickMatchedRepo([main, feature], 'other')).toBeNull() + expect(pickMatchedRepo([main, feature], null)).toBeNull() + }) +}) + +describe('describePushDivergence', () => { + const base = { serverHead: 'abc', serverBranch: 'main', lostCommits: 0 } + + it('returns no reasons when the push is a clean fast-forward', () => { + expect(describePushDivergence({ ...base, serverDirty: false, diverged: false })).toEqual([]) + }) + + it('describes a counted divergence and dirty server together', () => { + expect(describePushDivergence({ ...base, serverDirty: true, diverged: true, lostCommits: 2 })).toEqual([ + 'the server is 2 commit(s) ahead of your local branch', + 'the server has uncommitted changes', + ]) + }) + + it('describes divergence with an unknown commit count', () => { + expect(describePushDivergence({ ...base, serverDirty: false, diverged: true, lostCommits: -1 })).toEqual([ + 'the server has commit(s) not present in your local branch', + ]) + }) +}) + describe('checkPullDivergence', () => { let tmpDir: string diff --git a/ocm-cli/test/move-progress.test.ts b/ocm-cli/test/move-progress.test.ts new file mode 100644 index 000000000..3d2f45237 --- /dev/null +++ b/ocm-cli/test/move-progress.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from 'vitest' +import { renderProgressBar, formatMoveProgress, pushPhaseProgress, replayProgress } from '../src/move-progress' + +describe('renderProgressBar', () => { + it('fills proportionally and reports a percentage', () => { + expect(renderProgressBar(0, 10)).toBe('░░░░░░░░░░ 0%') + expect(renderProgressBar(0.5, 10)).toBe('█████░░░░░ 50%') + expect(renderProgressBar(1, 10)).toBe('██████████ 100%') + }) + + it('clamps out-of-range fractions', () => { + expect(renderProgressBar(-1, 4)).toBe('░░░░ 0%') + expect(renderProgressBar(7, 4)).toBe('████ 100%') + }) +}) + +describe('formatMoveProgress', () => { + it('shows spinner frame and label without a bar for indeterminate phases', () => { + expect(formatMoveProgress({ label: 'creating git bundle', fraction: null }, 0)).toBe('⠋ ocm-move: creating git bundle') + }) + + it('appends a bar for determinate phases and cycles spinner frames', () => { + const text = formatMoveProgress({ label: 'uploading', fraction: 0.25 }, 11) + expect(text.startsWith('⠙ ocm-move: uploading ')).toBe(true) + expect(text.endsWith(' 25%')).toBe(true) + }) +}) + +describe('phase mapping', () => { + it('maps upload bytes to a fraction', () => { + expect(pushPhaseProgress({ kind: 'uploading', bytesSent: 512, totalBytes: 2048 })).toEqual({ label: 'uploading 512 B / 2.0 KB', fraction: 0.25 }) + }) + + it('treats a zero-byte upload as indeterminate', () => { + expect(pushPhaseProgress({ kind: 'uploading', bytesSent: 0, totalBytes: 0 }).fraction).toBeNull() + }) + + it('maps server phases to indeterminate labels', () => { + expect(pushPhaseProgress({ kind: 'bundling' })).toEqual({ label: 'creating git bundle', fraction: null }) + expect(pushPhaseProgress({ kind: 'processing' })).toEqual({ label: 'server importing bundle', fraction: null }) + expect(pushPhaseProgress({ kind: 'patching' })).toEqual({ label: 'applying local changes', fraction: null }) + }) + + it('maps replay counts to a fraction', () => { + expect(replayProgress(3, 12)).toEqual({ label: 'replaying session 3/12 events', fraction: 0.25 }) + expect(replayProgress(0, 0).fraction).toBeNull() + }) +}) diff --git a/ocm-cli/test/session-move.test.ts b/ocm-cli/test/session-move.test.ts index 2b3560c46..f22610b8e 100644 --- a/ocm-cli/test/session-move.test.ts +++ b/ocm-cli/test/session-move.test.ts @@ -118,6 +118,44 @@ describe('rewriteEventsForRemote', () => { expect(result[0]!.data.location).toEqual({ directory: '/workspace/repos/repo/src' }) }) + it('rewrites assistant message path cwd and root', () => { + const events: ReplayEvent[] = [ + { + id: 'e1', aggregateID: 'ses_a', seq: 3, + type: 'message.updated.1', + data: { info: { id: 'msg_1', role: 'assistant', path: { cwd: '/Users/x/repo/src', root: '/Users/x/repo' } } }, + }, + ] + + const result = rewriteEventsForRemote(events, ctx) + + expect(result[0]!.data.info).toEqual({ + id: 'msg_1', + role: 'assistant', + path: { cwd: '/workspace/repos/repo/src', root: '/workspace/repos/repo' }, + }) + }) + + it('leaves message paths outside the local root untouched', () => { + const events: ReplayEvent[] = [ + { + id: 'e1', aggregateID: 'ses_a', seq: 3, + type: 'message.updated.1', + data: { info: { id: 'msg_1', role: 'assistant', path: { cwd: '/Users/x/repo-other', root: '/Users/x/repo-other' } } }, + }, + { + id: 'e2', aggregateID: 'ses_a', seq: 4, + type: 'message.updated.1', + data: { info: { id: 'msg_2', role: 'user' } }, + }, + ] + + const result = rewriteEventsForRemote(events, ctx) + + expect(result[0]!.data.info).toEqual({ id: 'msg_1', role: 'assistant', path: { cwd: '/Users/x/repo-other', root: '/Users/x/repo-other' } }) + expect(result[1]!.data.info).toEqual({ id: 'msg_2', role: 'user' }) + }) + it('strips workspaceID from info', () => { const events: ReplayEvent[] = [ { From 9068937895ca612cb6930688c1a0fd8634c23be6 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Tue, 1 Sep 2026 19:36:26 -0400 Subject: [PATCH 2/4] chore(ocm-cli): bump version to 0.2.7 --- ocm-cli/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ocm-cli/package.json b/ocm-cli/package.json index 55322dcec..77abdd308 100644 --- a/ocm-cli/package.json +++ b/ocm-cli/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-manager/ocm-cli", - "version": "0.2.6", + "version": "0.2.7", "description": "OpenCode Manager CLI: attach a local OpenCode TUI to a Manager-hosted repo.", "license": "MIT", "repository": { From 0f0b4d7f51744a2a4cd6807d9f5a8ebd2ddd7ef2 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Wed, 2 Sep 2026 01:57:33 +0000 Subject: [PATCH 3/4] fix(ocm): harden worktree mirror synchronization --- .../routes/internal/opencode-workspaces.ts | 1 + backend/src/routes/internal/repo-mirror.ts | 138 +++-- backend/src/services/repo.ts | 39 +- .../internal-opencode-workspaces.test.ts | 10 +- .../test/routes/internal/repo-mirror.test.ts | 525 ++++++++++++++++++ .../test/services/repo-mirror-target.test.ts | 71 ++- ocm-cli/README.md | 12 +- ocm-cli/bin/ocm.ts | 35 +- ocm-cli/src/manager-api.ts | 36 +- ocm-cli/src/manager-repos.ts | 1 + ocm-cli/src/mirror.ts | 117 +++- ocm-cli/src/resolve-target.ts | 64 +++ ocm-cli/src/tui-plugin.ts | 9 +- ocm-cli/test/mirror.test.ts | 328 ++++++++++- ocm-cli/test/resolve-target.test.ts | 112 +++- shared/src/schemas/repo.ts | 31 ++ 16 files changed, 1403 insertions(+), 126 deletions(-) diff --git a/backend/src/routes/internal/opencode-workspaces.ts b/backend/src/routes/internal/opencode-workspaces.ts index dfd5d6f93..97c731207 100644 --- a/backend/src/routes/internal/opencode-workspaces.ts +++ b/backend/src/routes/internal/opencode-workspaces.ts @@ -20,6 +20,7 @@ export function createInternalOpenCodeWorkspacesRoutes(db: Database) { directory: repo.fullPath, originUrl: repo.repoUrl ?? null, projectId: await resolveProjectId(repo.fullPath).catch(() => null), + isWorktree: repo.isWorktree === true, extra: { repoId: repo.id, localPath: repo.localPath, diff --git a/backend/src/routes/internal/repo-mirror.ts b/backend/src/routes/internal/repo-mirror.ts index 0c04f72a9..817b1573d 100644 --- a/backend/src/routes/internal/repo-mirror.ts +++ b/backend/src/routes/internal/repo-mirror.ts @@ -8,6 +8,11 @@ import { pipeline } from 'stream/promises' import { join } from 'path' import * as fsp from 'fs/promises' import { getReposPath } from '@opencode-manager/shared/config/env' +import { + MirrorTargetBranchRequestSchema, + type MirrorTargetEnsureResponse, + type MirrorTargetPlanResponse, +} from '@opencode-manager/shared/schemas' import { getRepoById, updateLastPulled, updateRepoBranch, deleteRepo } from '../../db/queries' import { ensureMirrorTargetPath, createRepoRow, isRepoInUse, planMirrorTarget, ensureMirrorTarget } from '../../services/repo' import { logger } from '../../utils/logger' @@ -52,10 +57,6 @@ interface PatchBody { force?: boolean } -interface TargetBody { - branch?: string -} - 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 { @@ -115,37 +116,76 @@ async function branchesCheckedOutElsewhere(fullPath: string): Promise { +async function currentBranchName(fullPath: string): Promise { + const out = await gitRaw(fullPath, ['symbolic-ref', '--quiet', '--short', 'HEAD']).catch(() => '') + const trimmed = out.trim() + return trimmed.length > 0 ? trimmed : null +} + +async function listLocalBranchNames(fullPath: string): Promise> { + const out = await gitRaw(fullPath, ['for-each-ref', '--format=%(refname:strip=2)', 'refs/heads']) + return new Set(out.split('\n').map((l) => l.trim()).filter(Boolean)) +} + +async function importBundle(fullPath: string, bundlePath: string, branch: string | null, requireCurrentBranch: boolean, force: boolean): Promise { 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 locked = await branchesCheckedOutElsewhere(fullPath) - 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' || locked.has(name)) 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('')) - } + try { + const refs = await gitRaw(fullPath, ['for-each-ref', '--format=%(refname:strip=3) %(objectname)', 'refs/remotes/ocm-sync']) + const incoming = new Map() + for (const line of refs.split('\n')) { + const trimmed = line.trim() + if (!trimmed) continue + const firstSpace = trimmed.indexOf(' ') + if (firstSpace === -1) continue + const name = trimmed.slice(0, firstSpace) + if (name === 'HEAD') continue + incoming.set(name, trimmed.slice(firstSpace + 1)) + } - if (branch) { - await gitRaw(fullPath, ['reset', '--hard']) - await gitRaw(fullPath, ['clean', '-fd']) - 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 locked = await branchesCheckedOutElsewhere(fullPath) + const actualBranch = await currentBranchName(fullPath) - const syncRefsOut = await gitRaw(fullPath, ['for-each-ref', '--format=%(refname)', 'refs/remotes/ocm-sync']).catch(() => '') - const deletes = syncRefsOut.split('\n').map((l) => l.trim()).filter(Boolean).map((ref) => `delete ${ref}\n`) - if (deletes.length > 0) { - await gitRaw(fullPath, ['update-ref', '--stdin'], process.env, deletes.join('')).catch(() => {}) + let targetSha: string | undefined + if (branch) { + const incomingSha = incoming.get(branch) + if (!incomingSha) throw new Error(`incoming bundle has no branch '${branch}'`) + if (locked.has(branch)) { + throw new Error(`branch '${branch}' is checked out in another worktree; release it there before pushing`) + } + if (requireCurrentBranch && actualBranch !== branch) { + throw new Error(`repo is on branch '${actualBranch ?? 'detached HEAD'}' but the bundle targets '${branch}'`) + } + targetSha = incomingSha + if (actualBranch !== branch) { + const checkoutArgs = force ? ['-f'] : [] + if ((await listLocalBranchNames(fullPath)).has(branch)) { + await gitRaw(fullPath, ['checkout', ...checkoutArgs, branch]) + } else { + await gitRaw(fullPath, ['checkout', ...checkoutArgs, '-b', branch, incomingSha]) + } + } + } + + const updates: string[] = [] + for (const [name, sha] of incoming) { + if (targetSha !== undefined && name === branch) continue + if (locked.has(name)) continue + updates.push(`update refs/heads/${name} ${sha}\n`) + } + if (updates.length > 0) { + await gitRaw(fullPath, ['update-ref', '--stdin'], process.env, updates.join('')) + } + + if (branch && targetSha !== undefined) { + await gitRaw(fullPath, ['reset', '--hard', targetSha]) + await gitRaw(fullPath, ['clean', '-fd']) + } + } finally { + const syncRefsOut = await gitRaw(fullPath, ['for-each-ref', '--format=%(refname)', 'refs/remotes/ocm-sync']).catch(() => '') + const deletes = syncRefsOut.split('\n').map((l) => l.trim()).filter(Boolean).map((ref) => `delete ${ref}\n`) + if (deletes.length > 0) { + await gitRaw(fullPath, ['update-ref', '--stdin'], process.env, deletes.join('')).catch(() => {}) + } } } @@ -358,7 +398,8 @@ export function createInternalRepoMirrorRoutes(db: Database) { if (!Number.isFinite(repoId)) return c.json({ error: 'invalid repoId' }, 400) const repo = getRepoById(db, repoId) if (!repo) return c.json({ error: 'repo not found' }, 404) - if (isRepoInUse(db, repoId) && c.req.query('force') !== '1') { + const force = c.req.query('force') === '1' + if (isRepoInUse(db, repoId) && !force) { return c.json({ error: 'repo_in_use', message: 'open OpenCode sessions are using this repo; rerun with force=1' }, 409) } @@ -370,11 +411,12 @@ export function createInternalRepoMirrorRoutes(db: Database) { const bundleDir = mkdtempSync(join(stagingRoot, 'bundle-upload-')) const bundlePath = join(bundleDir, 'repo.bundle') const branch = c.req.header('x-ocm-branch')?.trim() || null + const requireCurrentBranch = c.req.header('x-ocm-require-current-branch')?.trim() === '1' try { const body = Readable.fromWeb(rawBody as unknown as Parameters[0]) await pipeline(body, createWriteStream(bundlePath)) - await importBundle(repo.fullPath, bundlePath, branch) + await importBundle(repo.fullPath, bundlePath, branch, requireCurrentBranch, force) const branchName = await safeGitOut(repo.fullPath, ['rev-parse', '--abbrev-ref', 'HEAD']) const head = await safeGitOut(repo.fullPath, ['rev-parse', 'HEAD']) @@ -399,16 +441,18 @@ export function createInternalRepoMirrorRoutes(db: Database) { app.get('/:repoId/mirror/target', async (c) => { const repoId = Number(c.req.param('repoId')) if (!Number.isFinite(repoId)) return c.json({ error: 'invalid repoId' }, 400) - const branch = c.req.query('branch')?.trim() - if (!branch) return c.json({ error: 'branch required' }, 400) + const branchParsed = MirrorTargetBranchRequestSchema.safeParse({ branch: c.req.query('branch') }) + if (!branchParsed.success) return c.json({ error: 'branch required' }, 400) + const { branch } = branchParsed.data const repo = getRepoById(db, repoId) if (!repo) return c.json({ error: 'repo not found' }, 404) try { const plan = await planMirrorTarget(db, repo, branch) - return c.json(plan.kind === 'new' + const response: MirrorTargetPlanResponse = plan.kind === 'new' ? { kind: plan.kind, repoId: null, fullPath: plan.fullPath, localPath: plan.localPath, branch, currentBranch: plan.currentBranch } - : { kind: plan.kind, repoId: plan.repo.id, fullPath: plan.repo.fullPath, localPath: plan.repo.localPath, branch, currentBranch: plan.currentBranch }) + : { kind: plan.kind, repoId: plan.repo.id, fullPath: plan.repo.fullPath, localPath: plan.repo.localPath, branch, currentBranch: plan.currentBranch } + return c.json(response) } catch (error) { logger.error('mirror target plan failed:', error) return c.json({ error: getErrorMessage(error) }, 500) @@ -418,20 +462,28 @@ export function createInternalRepoMirrorRoutes(db: Database) { app.post('/:repoId/mirror/target', async (c) => { const repoId = Number(c.req.param('repoId')) if (!Number.isFinite(repoId)) return c.json({ error: 'invalid repoId' }, 400) - let body: TargetBody + let json: unknown try { - body = (await c.req.json()) as TargetBody + json = await c.req.json() } catch { return c.json({ error: 'invalid json body' }, 400) } - const branch = body.branch?.trim() - if (!branch) return c.json({ error: 'branch required' }, 400) + const branchParsed = MirrorTargetBranchRequestSchema.safeParse(json) + if (!branchParsed.success) return c.json({ error: 'branch required' }, 400) + const { branch } = branchParsed.data const repo = getRepoById(db, repoId) if (!repo) return c.json({ error: 'repo not found' }, 404) try { const { repo: target, created } = await ensureMirrorTarget(db, repo, branch) - return c.json({ repoId: target.id, fullPath: target.fullPath, localPath: target.localPath, branch, created }) + const response: MirrorTargetEnsureResponse = { + repoId: target.id, + fullPath: target.fullPath, + localPath: target.localPath, + branch, + created, + } + return c.json(response) } catch (error) { logger.error('mirror target ensure failed:', error) return c.json({ error: getErrorMessage(error) }, 409) diff --git a/backend/src/services/repo.ts b/backend/src/services/repo.ts index 302546b19..f17fd7954 100644 --- a/backend/src/services/repo.ts +++ b/backend/src/services/repo.ts @@ -1066,10 +1066,27 @@ export async function planMirrorTarget(database: Database, repo: Repo, branch: s if (currentBranch === branch) return { kind: 'in-place', repo, currentBranch } const localPath = `${getRepoBaseDirectoryName(repo)}-${sanitizeBranchForDirectory(branch)}` + const fullPath = path.join(getReposPath(), localPath) const existing = getRepoByLocalPath(database, localPath) - if (existing && existsSync(existing.fullPath)) return { kind: 'existing', repo: existing, currentBranch } - return { kind: 'new', localPath, fullPath: path.join(getReposPath(), localPath), currentBranch } + if (existing) { + if (existing.branch !== branch) { + throw new Error(`Mirror target '${localPath}' is occupied by repo ${existing.id} registered for branch '${existing.branch ?? 'none'}' instead of '${branch}'`) + } + + if (!existsSync(existing.fullPath)) { + throw new Error(`Repo ${existing.id} for branch '${branch}' is missing its worktree directory at '${existing.fullPath}'`) + } + + const checkedOutBranch = await safeGetCurrentBranch(existing.fullPath, {}) + if (checkedOutBranch !== branch) { + throw new Error(`Repo ${existing.id} for branch '${branch}' has branch '${checkedOutBranch ?? 'none'}' checked out at '${existing.fullPath}'`) + } + + return { kind: 'existing', repo: existing, currentBranch } + } + + return { kind: 'new', localPath, fullPath, currentBranch } } export async function ensureMirrorTarget(database: Database, repo: Repo, branch: string): Promise<{ repo: Repo; created: boolean }> { @@ -1077,15 +1094,21 @@ export async function ensureMirrorTarget(database: Database, repo: Repo, branch: if (plan.kind !== 'new') return { repo: plan.repo, created: false } await createWorktreeSafely(repo.fullPath, plan.fullPath, branch, {}) - const worktreeRepo = createRepo(database, repo.repoUrl - ? { repoUrl: repo.repoUrl, localPath: plan.localPath, branch, defaultBranch: branch, cloneStatus: 'ready', clonedAt: Date.now(), isWorktree: true } - : { isLocal: true, localPath: plan.localPath, branch, defaultBranch: branch, cloneStatus: 'ready', clonedAt: Date.now(), isWorktree: true }) - if (worktreeRepo.localPath !== plan.localPath) { + try { + const worktreeRepo = createRepo(database, repo.repoUrl + ? { repoUrl: repo.repoUrl, localPath: plan.localPath, branch, defaultBranch: branch, cloneStatus: 'ready', clonedAt: Date.now(), isWorktree: true } + : { isLocal: true, localPath: plan.localPath, branch, defaultBranch: branch, cloneStatus: 'ready', clonedAt: Date.now(), isWorktree: true }) + + if (worktreeRepo.localPath !== plan.localPath) { + throw new Error(`branch ${branch} is already registered as repo ${worktreeRepo.id} at ${worktreeRepo.fullPath}`) + } + + return { repo: worktreeRepo, created: true } + } catch (error: unknown) { await removeWorktree(repo.fullPath, plan.fullPath) - throw new Error(`branch ${branch} is already registered as repo ${worktreeRepo.id} at ${worktreeRepo.fullPath}`) + throw error } - return { repo: worktreeRepo, created: true } } export function ensureMirrorTargetPath(name: string): { fullPath: string; localPath: string } { diff --git a/backend/test/routes/internal-opencode-workspaces.test.ts b/backend/test/routes/internal-opencode-workspaces.test.ts index d842a69a6..80df34b71 100644 --- a/backend/test/routes/internal-opencode-workspaces.test.ts +++ b/backend/test/routes/internal-opencode-workspaces.test.ts @@ -126,15 +126,16 @@ describe('internal-opencode-workspaces routes', () => { it('GET /api/internal/opencode-workspaces returns workspace structure', async () => { mockListRepos.mockReturnValue([ makeRepo({ id: 1, localPath: 'test-repo', cloneStatus: 'ready' }), + makeRepo({ id: 2, localPath: 'worktree-repo', cloneStatus: 'ready', isWorktree: true }), ]) const res = await app.request('/api/internal/opencode-workspaces', { headers: { authorization: `Bearer ${token}` }, }) expect(res.status).toBe(200) - const body = await res.json() as { workspaces: Array<{ repoId: number; name: string; branch: string | null; cloneStatus: string; directory: string; extra: { repoId: number; localPath: string; fullPath: string } }> } - expect(body.workspaces.length).toBe(1) - const workspace = body.workspaces[0]! + const body = await res.json() as { workspaces: Array<{ repoId: number; name: string; branch: string | null; cloneStatus: string; directory: string; isWorktree: boolean; extra: { repoId: number; localPath: string; fullPath: string } }> } + expect(body.workspaces.length).toBe(2) + const workspace = body.workspaces.find((w) => w.repoId === 1)! expect(workspace).toHaveProperty('repoId') expect(workspace).toHaveProperty('name') expect(workspace).toHaveProperty('branch') @@ -145,5 +146,8 @@ describe('internal-opencode-workspaces routes', () => { expect(workspace.extra).toHaveProperty('repoId') expect(workspace.extra).toHaveProperty('localPath') expect(workspace.extra).toHaveProperty('fullPath') + const worktreeWorkspace = body.workspaces.find((w) => w.repoId === 2)! + expect(workspace.isWorktree).toBe(false) + expect(worktreeWorkspace.isWorktree).toBe(true) }) }) diff --git a/backend/test/routes/internal/repo-mirror.test.ts b/backend/test/routes/internal/repo-mirror.test.ts index c9f66c82c..a1897799a 100644 --- a/backend/test/routes/internal/repo-mirror.test.ts +++ b/backend/test/routes/internal/repo-mirror.test.ts @@ -55,11 +55,15 @@ vi.mock('../../../src/db/queries', () => ({ const mockEnsureMirrorTargetPath = vi.fn() const mockCreateRepoRow = vi.fn() const mockIsRepoInUse = vi.fn() +const mockPlanMirrorTarget = vi.fn() +const mockEnsureMirrorTarget = vi.fn() vi.mock('../../../src/services/repo', () => ({ ensureMirrorTargetPath: (...args: unknown[]) => mockEnsureMirrorTargetPath(...args), createRepoRow: (...args: unknown[]) => mockCreateRepoRow(...args), isRepoInUse: (...args: unknown[]) => mockIsRepoInUse(...args), + planMirrorTarget: (...args: unknown[]) => mockPlanMirrorTarget(...args), + ensureMirrorTarget: (...args: unknown[]) => mockEnsureMirrorTarget(...args), })) import { createInternalRepoMirrorRoutes } from '../../../src/routes/internal/repo-mirror' @@ -443,6 +447,284 @@ describe('internal-repo-mirror routes', () => { expect(spawnSync('git', ['status', '--porcelain'], { cwd: baseDir, encoding: 'utf-8' }).stdout.trim()).toBe('') }) + function gitCmd(cwd: string, args: string[], input?: string) { + return spawnSync('git', args, { cwd, encoding: 'utf-8', input }) + } + + function initGitRepo(name: string): string { + const dir = join(getTmpRoot(), name) + mkdirSync(dir, { recursive: true }) + gitCmd(dir, ['init', '-b', 'main'], '') + gitCmd(dir, ['config', 'user.email', 'test@test.com']) + gitCmd(dir, ['config', 'user.name', 'Test']) + return dir + } + + function gitCommitFile(dir: string, file: string, content: string): string { + writeFileSync(join(dir, file), content) + gitCmd(dir, ['add', file]) + gitCmd(dir, ['commit', '-m', `add ${file}`]) + return gitCmd(dir, ['rev-parse', 'HEAD']).stdout.trim() + } + + function makeBundle(dir: string, name: string): Buffer { + const bundlePath = join(getTmpRoot(), `${name}.bundle`) + gitCmd(dir, ['bundle', 'create', bundlePath, '--all']) + return readFileSync(bundlePath) + } + + async function postBundle(app: Hono, urlRepoId: number, bundle: Buffer, headers: Record): Promise { + return app.request(`/api/internal/repos/${urlRepoId}/mirror/bundle`, { + method: 'POST', + body: bundle, + headers: { 'content-type': 'application/octet-stream', ...headers }, + }) + } + + const currentBranchOf = (dir: string): string => gitCmd(dir, ['rev-parse', '--abbrev-ref', 'HEAD']).stdout.trim() + const revRef = (dir: string, ref: string): string => gitCmd(dir, ['rev-parse', '--verify', ref], '').stdout.trim() + const refExists = (dir: string, ref: string): boolean => gitCmd(dir, ['rev-parse', '--verify', '--quiet', ref], '').status === 0 + const syncRefCount = (dir: string): number => + gitCmd(dir, ['for-each-ref', '--format=%(refname)', 'refs/remotes/ocm-sync']).stdout.trim().split('\n').filter(Boolean).length + + it('rejects a strict bundle upload when the repo branch differs and preserves all local state', async () => { + const sourceDir = initGitRepo('strict-mismatch-source') + gitCommitFile(sourceDir, 'main.txt', 'source main\n') + gitCmd(sourceDir, ['checkout', '-b', 'feature']) + const incomingFeatureSha = gitCommitFile(sourceDir, 'feature.txt', 'source feature\n') + gitCmd(sourceDir, ['checkout', 'main']) + const incomingMainSha = gitCmd(sourceDir, ['rev-parse', 'HEAD']).stdout.trim() + const bundle = makeBundle(sourceDir, 'strict-mismatch') + + const targetDir = initGitRepo('strict-mismatch-target') + const targetMainSha = gitCommitFile(targetDir, 'main.txt', 'target main\n') + writeFileSync(join(targetDir, 'untracked.txt'), 'keep me\n') + mockGetRepoById.mockReturnValue({ id: 1, fullPath: targetDir }) + + const res = await postBundle(app, 1, bundle, { + 'x-ocm-branch': 'feature', + 'x-ocm-require-current-branch': '1', + }) + + expect(res.status).toBe(409) + const json = (await res.json()) as { error: string } + expect(json.error).toContain('targets') + expect(gitCmd(targetDir, ['symbolic-ref', '--quiet', '--short', 'HEAD']).stdout.trim()).toBe('main') + expect(revRef(targetDir, 'refs/heads/main')).toBe(targetMainSha) + expect(refExists(targetDir, 'refs/heads/feature')).toBe(false) + expect(readFileSync(join(targetDir, 'main.txt'), 'utf-8')).toBe('target main\n') + expect(readFileSync(join(targetDir, 'untracked.txt'), 'utf-8')).toBe('keep me\n') + expect(syncRefCount(targetDir)).toBe(0) + expect(incomingFeatureSha).not.toBe(targetMainSha) + expect(incomingMainSha).not.toBe(targetMainSha) + expect(mockUpdateLastPulled).not.toHaveBeenCalled() + }) + + it('rejects a bundle upload when the requested branch is missing and preserves all local state', async () => { + const sourceDir = initGitRepo('missing-branch-source') + gitCommitFile(sourceDir, 'main.txt', 'source main\n') + const bundle = makeBundle(sourceDir, 'missing-branch') + + const targetDir = initGitRepo('missing-branch-target') + const targetMainSha = gitCommitFile(targetDir, 'main.txt', 'target main\n') + writeFileSync(join(targetDir, 'untracked.txt'), 'keep me\n') + writeFileSync(join(targetDir, 'tracked-local.txt'), 'modified\n') + gitCmd(targetDir, ['add', 'tracked-local.txt']) + mockGetRepoById.mockReturnValue({ id: 1, fullPath: targetDir }) + + const res = await postBundle(app, 1, bundle, { 'x-ocm-branch': 'ghost' }) + + expect(res.status).toBe(409) + const json = (await res.json()) as { error: string } + expect(json.error).toContain("no branch 'ghost'") + expect(gitCmd(targetDir, ['symbolic-ref', '--quiet', '--short', 'HEAD']).stdout.trim()).toBe('main') + expect(revRef(targetDir, 'refs/heads/main')).toBe(targetMainSha) + expect(readFileSync(join(targetDir, 'untracked.txt'), 'utf-8')).toBe('keep me\n') + expect(gitCmd(targetDir, ['status', '--porcelain']).stdout).toContain('A tracked-local.txt') + expect(syncRefCount(targetDir)).toBe(0) + expect(mockUpdateLastPulled).not.toHaveBeenCalled() + }) + + it('rejects a bundle upload targeting a branch locked by another worktree and preserves all local state', async () => { + const sourceDir = initGitRepo('locked-target-source') + gitCommitFile(sourceDir, 'main.txt', 'source main\n') + gitCmd(sourceDir, ['checkout', '-b', 'shared']) + const sourceSharedSha = gitCommitFile(sourceDir, 'shared.txt', 'source shared\n') + gitCmd(sourceDir, ['checkout', 'main']) + const bundle = makeBundle(sourceDir, 'locked-target') + + const baseDir = initGitRepo('locked-target-base') + const baseMainSha = gitCommitFile(baseDir, 'main.txt', 'base main\n') + const wt1Dir = join(getTmpRoot(), 'locked-target-wt1') + gitCmd(baseDir, ['worktree', 'add', '-b', 'other', wt1Dir]) + const wt2Dir = join(getTmpRoot(), 'locked-target-wt2') + gitCmd(baseDir, ['worktree', 'add', '-b', 'shared', wt2Dir]) + const wt2SharedSha = revRef(wt2Dir, 'refs/heads/shared') + writeFileSync(join(wt1Dir, 'untracked.txt'), 'keep me\n') + mockGetRepoById.mockReturnValue({ id: 2, fullPath: wt1Dir }) + + const res = await postBundle(app, 2, bundle, { 'x-ocm-branch': 'shared' }) + + expect(res.status).toBe(409) + const json = (await res.json()) as { error: string } + expect(json.error).toContain('checked out in another worktree') + expect(currentBranchOf(wt1Dir)).toBe('other') + expect(readFileSync(join(wt1Dir, 'untracked.txt'), 'utf-8')).toBe('keep me\n') + expect(revRef(baseDir, 'refs/heads/main')).toBe(baseMainSha) + expect(revRef(baseDir, 'refs/heads/shared')).toBe(wt2SharedSha) + expect(gitCmd(wt2Dir, ['status', '--porcelain']).stdout.trim()).toBe('') + expect(syncRefCount(wt1Dir)).toBe(0) + expect(sourceSharedSha).not.toBe(wt2SharedSha) + expect(mockUpdateLastPulled).not.toHaveBeenCalled() + }) + + it('switches to a new target branch from the incoming bundle on a normal non-strict push', async () => { + const sourceDir = initGitRepo('cross-branch-source') + gitCommitFile(sourceDir, 'main.txt', 'source main\n') + gitCmd(sourceDir, ['checkout', '-b', 'topic']) + const incomingTopicSha = gitCommitFile(sourceDir, 'topic.txt', 'source topic\n') + gitCmd(sourceDir, ['checkout', 'main']) + const incomingMainSha = gitCmd(sourceDir, ['rev-parse', 'HEAD']).stdout.trim() + const bundle = makeBundle(sourceDir, 'cross-branch') + + const targetDir = initGitRepo('cross-branch-target') + gitCommitFile(targetDir, 'main.txt', 'target main\n') + writeFileSync(join(targetDir, 'stale-untracked.txt'), 'stale\n') + mockGetRepoById.mockReturnValue({ id: 1, fullPath: targetDir }) + mockSafeGitOut.mockImplementation(async (_repoPath: string, args: string[]) => { + if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref') return 'topic' + if (args[0] === 'rev-parse' && args[1] === 'HEAD') return incomingTopicSha + return null + }) + + const res = await postBundle(app, 1, bundle, { 'x-ocm-branch': 'topic' }) + + expect(res.status).toBe(200) + expect(currentBranchOf(targetDir)).toBe('topic') + expect(revRef(targetDir, 'HEAD')).toBe(incomingTopicSha) + expect(revRef(targetDir, 'refs/heads/topic')).toBe(incomingTopicSha) + expect(revRef(targetDir, 'refs/heads/main')).toBe(incomingMainSha) + expect(readFileSync(join(targetDir, 'topic.txt'), 'utf-8')).toBe('source topic\n') + expect(existsSync(join(targetDir, 'stale-untracked.txt'))).toBe(false) + expect(syncRefCount(targetDir)).toBe(0) + expect(mockUpdateRepoBranch).toHaveBeenCalledWith({}, 1, 'topic') + }) + + it('moves an existing local target branch to the incoming sha and resets it on a normal non-strict push', async () => { + const sourceDir = initGitRepo('cross-branch-existing-source') + gitCommitFile(sourceDir, 'main.txt', 'source main\n') + gitCmd(sourceDir, ['checkout', '-b', 'topic']) + const incomingTopicSha = gitCommitFile(sourceDir, 'topic.txt', 'source topic\n') + gitCmd(sourceDir, ['checkout', 'main']) + const incomingMainSha = gitCmd(sourceDir, ['rev-parse', 'HEAD']).stdout.trim() + const bundle = makeBundle(sourceDir, 'cross-branch-existing') + + const targetDir = initGitRepo('cross-branch-existing-target') + gitCommitFile(targetDir, 'main.txt', 'target main\n') + gitCmd(targetDir, ['branch', 'topic'], '') + const localTopicSha = revRef(targetDir, 'refs/heads/topic') + mockGetRepoById.mockReturnValue({ id: 1, fullPath: targetDir }) + mockSafeGitOut.mockResolvedValue(null) + + const res = await postBundle(app, 1, bundle, { 'x-ocm-branch': 'topic' }) + + expect(res.status).toBe(200) + expect(currentBranchOf(targetDir)).toBe('topic') + expect(revRef(targetDir, 'HEAD')).toBe(incomingTopicSha) + expect(revRef(targetDir, 'refs/heads/topic')).toBe(incomingTopicSha) + expect(localTopicSha).not.toBe(incomingTopicSha) + expect(revRef(targetDir, 'refs/heads/main')).toBe(incomingMainSha) + expect(readFileSync(join(targetDir, 'topic.txt'), 'utf-8')).toBe('source topic\n') + expect(existsSync(join(targetDir, 'main.txt'))).toBe(true) + expect(syncRefCount(targetDir)).toBe(0) + }) + + it('replaces dirty tracked and untracked state when switching branches on a forced normal push', async () => { + const sourceDir = initGitRepo('forced-cross-source') + gitCommitFile(sourceDir, 'main.txt', 'source main\n') + gitCmd(sourceDir, ['checkout', '-b', 'topic']) + const incomingTopicSha = gitCommitFile(sourceDir, 'topic.txt', 'source topic\n') + gitCmd(sourceDir, ['checkout', 'main']) + const incomingMainSha = gitCmd(sourceDir, ['rev-parse', 'HEAD']).stdout.trim() + const bundle = makeBundle(sourceDir, 'forced-cross') + + const targetDir = initGitRepo('forced-cross-target') + gitCommitFile(targetDir, 'main.txt', 'target main\n') + writeFileSync(join(targetDir, 'main.txt'), 'dirty edit\n') + writeFileSync(join(targetDir, 'stale-untracked.txt'), 'stale\n') + mockGetRepoById.mockReturnValue({ id: 1, fullPath: targetDir }) + mockSafeGitOut.mockResolvedValue(null) + + const forcedRes = await app.request('/api/internal/repos/1/mirror/bundle?force=1', { + method: 'POST', + body: bundle, + headers: { 'content-type': 'application/octet-stream', 'x-ocm-branch': 'topic' }, + }) + + expect(forcedRes.status).toBe(200) + expect(currentBranchOf(targetDir)).toBe('topic') + expect(revRef(targetDir, 'HEAD')).toBe(incomingTopicSha) + expect(revRef(targetDir, 'refs/heads/main')).toBe(incomingMainSha) + expect(readFileSync(join(targetDir, 'topic.txt'), 'utf-8')).toBe('source topic\n') + expect(readFileSync(join(targetDir, 'main.txt'), 'utf-8')).toBe('source main\n') + expect(gitCmd(targetDir, ['status', '--porcelain']).stdout.trim()).toBe('') + expect(existsSync(join(targetDir, 'stale-untracked.txt'))).toBe(false) + expect(syncRefCount(targetDir)).toBe(0) + expect(incomingTopicSha).not.toBe(incomingMainSha) + }) + + it('preserves dirty state and refs when a non-forced cross-branch import fails checkout', async () => { + const sourceDir = initGitRepo('nonforce-cross-source') + gitCommitFile(sourceDir, 'main.txt', 'source main\n') + gitCmd(sourceDir, ['checkout', '-b', 'topic']) + gitCommitFile(sourceDir, 'topic.txt', 'source topic\n') + gitCmd(sourceDir, ['checkout', 'main']) + const bundle = makeBundle(sourceDir, 'nonforce-cross') + + const targetDir = initGitRepo('nonforce-cross-target') + const targetMainSha = gitCommitFile(targetDir, 'main.txt', 'target main\n') + writeFileSync(join(targetDir, 'main.txt'), 'dirty edit\n') + writeFileSync(join(targetDir, 'untracked.txt'), 'keep me\n') + mockGetRepoById.mockReturnValue({ id: 1, fullPath: targetDir }) + mockSafeGitOut.mockResolvedValue(null) + + const res = await postBundle(app, 1, bundle, { 'x-ocm-branch': 'topic' }) + + expect(res.status).toBe(409) + expect(currentBranchOf(targetDir)).toBe('main') + expect(readFileSync(join(targetDir, 'main.txt'), 'utf-8')).toBe('dirty edit\n') + expect(readFileSync(join(targetDir, 'untracked.txt'), 'utf-8')).toBe('keep me\n') + expect(revRef(targetDir, 'refs/heads/main')).toBe(targetMainSha) + expect(refExists(targetDir, 'refs/heads/topic')).toBe(false) + expect(syncRefCount(targetDir)).toBe(0) + expect(mockUpdateLastPulled).not.toHaveBeenCalled() + }) + + it('accepts a strict bundle upload when the repo already sits on the requested branch', async () => { + const sourceDir = initGitRepo('strict-same-source') + gitCommitFile(sourceDir, 'main.txt', 'source main\n') + const incomingMainSha = gitCmd(sourceDir, ['rev-parse', 'HEAD']).stdout.trim() + const bundle = makeBundle(sourceDir, 'strict-same') + + const targetDir = initGitRepo('strict-same-target') + gitCommitFile(targetDir, 'main.txt', 'target main\n') + writeFileSync(join(targetDir, 'stale-untracked.txt'), 'stale\n') + mockGetRepoById.mockReturnValue({ id: 1, fullPath: targetDir }) + mockSafeGitOut.mockResolvedValue(null) + + const res = await postBundle(app, 1, bundle, { + 'x-ocm-branch': 'main', + 'x-ocm-require-current-branch': '1', + }) + + expect(res.status).toBe(200) + expect(currentBranchOf(targetDir)).toBe('main') + expect(revRef(targetDir, 'HEAD')).toBe(incomingMainSha) + expect(readFileSync(join(targetDir, 'main.txt'), 'utf-8')).toBe('source main\n') + expect(existsSync(join(targetDir, 'stale-untracked.txt'))).toBe(false) + expect(syncRefCount(targetDir)).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 }) @@ -854,4 +1136,247 @@ describe('internal-repo-mirror routes', () => { expect(commitRes.status).toBe(404) }) }) + + describe('GET /:repoId/mirror/target', () => { + let repo: { id: number; fullPath: string; localPath: string } + + beforeEach(() => { + repo = { id: 1, fullPath: join(getTmpRoot(), 'test-repo'), localPath: 'test-repo' } + }) + + it('returns a new-worktree plan with repoId null when no worktree exists', async () => { + mockGetRepoById.mockReturnValue(repo) + mockPlanMirrorTarget.mockResolvedValue({ kind: 'new', localPath: 'test-repo-feature', fullPath: join(getTmpRoot(), 'test-repo-feature'), currentBranch: 'main' }) + + const res = await app.request('/api/internal/repos/1/mirror/target?branch=feature') + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + kind: 'new', + repoId: null, + fullPath: join(getTmpRoot(), 'test-repo-feature'), + localPath: 'test-repo-feature', + branch: 'feature', + currentBranch: 'main', + }) + expect(mockPlanMirrorTarget).toHaveBeenCalledWith({}, repo, 'feature') + }) + + it('returns an in-place plan with the repo id', async () => { + mockGetRepoById.mockReturnValue(repo) + mockPlanMirrorTarget.mockResolvedValue({ kind: 'in-place', repo, currentBranch: 'feature' }) + + const res = await app.request('/api/internal/repos/1/mirror/target?branch=feature') + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + kind: 'in-place', + repoId: 1, + fullPath: repo.fullPath, + localPath: 'test-repo', + branch: 'feature', + currentBranch: 'feature', + }) + }) + + it('returns an existing-worktree plan with the worktree repo id', async () => { + mockGetRepoById.mockReturnValue(repo) + const worktreeRepo = { id: 5, fullPath: join(getTmpRoot(), 'test-repo-wt'), localPath: 'test-repo-wt' } + mockPlanMirrorTarget.mockResolvedValue({ kind: 'existing', repo: worktreeRepo, currentBranch: 'main' }) + + const res = await app.request('/api/internal/repos/1/mirror/target?branch=feature') + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + kind: 'existing', + repoId: 5, + fullPath: worktreeRepo.fullPath, + localPath: 'test-repo-wt', + branch: 'feature', + currentBranch: 'main', + }) + }) + + it('returns 400 for a missing branch without calling the plan service', async () => { + const res = await app.request('/api/internal/repos/1/mirror/target') + + expect(res.status).toBe(400) + const json = (await res.json()) as { error: string } + expect(json.error).toBe('branch required') + expect(mockPlanMirrorTarget).not.toHaveBeenCalled() + expect(mockGetRepoById).not.toHaveBeenCalled() + }) + + it('returns 400 for a whitespace-only branch without calling the plan service', async () => { + const res = await app.request('/api/internal/repos/1/mirror/target?branch=%20%20%20') + + expect(res.status).toBe(400) + const json = (await res.json()) as { error: string } + expect(json.error).toBe('branch required') + expect(mockPlanMirrorTarget).not.toHaveBeenCalled() + }) + + it('trims the branch before calling the plan service', async () => { + mockGetRepoById.mockReturnValue(repo) + mockPlanMirrorTarget.mockResolvedValue({ kind: 'in-place', repo, currentBranch: 'feature' }) + + await app.request('/api/internal/repos/1/mirror/target?branch=%20feature%20') + + expect(mockPlanMirrorTarget).toHaveBeenCalledWith({}, repo, 'feature') + }) + + it('returns 400 for an invalid repoId', async () => { + const res = await app.request('/api/internal/repos/abc/mirror/target?branch=feature') + + expect(res.status).toBe(400) + expect(mockPlanMirrorTarget).not.toHaveBeenCalled() + }) + + it('returns 404 for a non-existent repo', async () => { + mockGetRepoById.mockReturnValue(null) + + const res = await app.request('/api/internal/repos/99999/mirror/target?branch=feature') + + expect(res.status).toBe(404) + expect(mockPlanMirrorTarget).not.toHaveBeenCalled() + }) + + it('returns 500 when planning fails', async () => { + mockGetRepoById.mockReturnValue(repo) + mockPlanMirrorTarget.mockRejectedValue(new Error('worktree occupied')) + + const res = await app.request('/api/internal/repos/1/mirror/target?branch=feature') + + expect(res.status).toBe(500) + const json = (await res.json()) as { error: string } + expect(json.error).toContain('worktree occupied') + }) + }) + + describe('POST /:repoId/mirror/target', () => { + let repo: { id: number; fullPath: string; localPath: string } + + beforeEach(() => { + repo = { id: 1, fullPath: join(getTmpRoot(), 'test-repo'), localPath: 'test-repo' } + }) + + async function request(branchBody: unknown): Promise { + const res = await app.request('/api/internal/repos/1/mirror/target', { + method: 'POST', + body: JSON.stringify(branchBody), + headers: { 'content-type': 'application/json' }, + }) + return res + } + + it('ensures the target and returns the target repo contract', async () => { + mockGetRepoById.mockReturnValue(repo) + const targetRepo = { id: 5, fullPath: join(getTmpRoot(), 'test-repo-wt'), localPath: 'test-repo-wt' } + mockEnsureMirrorTarget.mockResolvedValue({ repo: targetRepo, created: true }) + + const res = await request({ branch: 'feature' }) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + repoId: 5, + fullPath: targetRepo.fullPath, + localPath: 'test-repo-wt', + branch: 'feature', + created: true, + }) + expect(mockEnsureMirrorTarget).toHaveBeenCalledWith({}, repo, 'feature') + }) + + it('reports created=false when the target already exists', async () => { + mockGetRepoById.mockReturnValue(repo) + mockEnsureMirrorTarget.mockResolvedValue({ repo, created: false }) + + const res = await request({ branch: 'feature' }) + + expect(res.status).toBe(200) + const json = (await res.json()) as { created: boolean; repoId: number } + expect(json.created).toBe(false) + expect(json.repoId).toBe(1) + }) + + it('returns 400 for malformed JSON body without calling services', async () => { + const res = await app.request('/api/internal/repos/1/mirror/target', { + method: 'POST', + body: 'not json{', + headers: { 'content-type': 'application/json' }, + }) + + expect(res.status).toBe(400) + expect(mockEnsureMirrorTarget).not.toHaveBeenCalled() + expect(mockGetRepoById).not.toHaveBeenCalled() + }) + + it('returns 400 for a missing branch without calling services', async () => { + const res = await request({}) + + expect(res.status).toBe(400) + const json = (await res.json()) as { error: string } + expect(json.error).toBe('branch required') + expect(mockEnsureMirrorTarget).not.toHaveBeenCalled() + expect(mockGetRepoById).not.toHaveBeenCalled() + }) + + it.each([ + ['null branch', null], + ['numeric branch', 123], + ['object branch', { branch: 'feature' }], + ])('returns 400 for %s without calling services', async (_label, branchValue) => { + const res = await request({ branch: branchValue }) + + expect(res.status).toBe(400) + expect(mockEnsureMirrorTarget).not.toHaveBeenCalled() + }) + + it('returns 400 for a whitespace-only branch without calling services', async () => { + const res = await request({ branch: ' ' }) + + expect(res.status).toBe(400) + expect(mockEnsureMirrorTarget).not.toHaveBeenCalled() + }) + + it('trims the branch before calling the ensure service', async () => { + mockGetRepoById.mockReturnValue(repo) + mockEnsureMirrorTarget.mockResolvedValue({ repo, created: false }) + + await request({ branch: ' feature ' }) + + expect(mockEnsureMirrorTarget).toHaveBeenCalledWith({}, repo, 'feature') + }) + + it('returns 400 for an invalid repoId', async () => { + const res = await app.request('/api/internal/repos/abc/mirror/target', { + method: 'POST', + body: JSON.stringify({ branch: 'feature' }), + headers: { 'content-type': 'application/json' }, + }) + + expect(res.status).toBe(400) + expect(mockEnsureMirrorTarget).not.toHaveBeenCalled() + }) + + it('returns 404 for a non-existent repo', async () => { + mockGetRepoById.mockReturnValue(null) + + const res = await request({ branch: 'feature' }) + + expect(res.status).toBe(404) + expect(mockEnsureMirrorTarget).not.toHaveBeenCalled() + }) + + it('returns 409 when ensure fails', async () => { + mockGetRepoById.mockReturnValue(repo) + mockEnsureMirrorTarget.mockRejectedValue(new Error('git worktree add failed')) + + const res = await request({ branch: 'feature' }) + + expect(res.status).toBe(409) + const json = (await res.json()) as { error: string } + expect(json.error).toContain('git worktree add failed') + }) + }) }) diff --git a/backend/test/services/repo-mirror-target.test.ts b/backend/test/services/repo-mirror-target.test.ts index 9553a4e51..eca8f6ee7 100644 --- a/backend/test/services/repo-mirror-target.test.ts +++ b/backend/test/services/repo-mirror-target.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest' import { execSync } from 'child_process' import { mkdtempSync, existsSync } from 'fs' import { tmpdir } from 'os' -import path from 'path' +import { join } from 'path' import { rm } from 'fs/promises' import type { Database } from 'bun:sqlite' import type { Repo } from '../../src/types/repo' @@ -23,8 +23,8 @@ describe('mirror target resolution', () => { let baseRepoPath: string beforeAll(async () => { - tmpRoot = mkdtempSync(path.join(tmpdir(), 'repo-mirror-target-')) - baseRepoPath = path.join(tmpRoot, 'my-app') + tmpRoot = mkdtempSync(join(tmpdir(), 'repo-mirror-target-')) + baseRepoPath = join(tmpRoot, 'my-app') execSync(`git init -b main "${baseRepoPath}"`) execSync(`git -C "${baseRepoPath}" config user.email test@test.com`) execSync(`git -C "${baseRepoPath}" config user.name Test`) @@ -50,7 +50,7 @@ describe('mirror target resolution', () => { it('plans a new sibling worktree when the checked-out branch differs', async () => { const { planMirrorTarget } = await import('../../src/services/repo') const plan = await planMirrorTarget(db, base, 'feature/x') - expect(plan).toMatchObject({ kind: 'new', localPath: 'my-app-feature-x', fullPath: path.join(tmpRoot, 'my-app-feature-x'), currentBranch: 'main' }) + expect(plan).toMatchObject({ kind: 'new', localPath: 'my-app-feature-x', fullPath: join(tmpRoot, 'my-app-feature-x'), currentBranch: 'main' }) }) it('creates the worktree, the branch, and a worktree repo row without touching the base checkout', async () => { @@ -61,7 +61,7 @@ describe('mirror target resolution', () => { expect(target.id).not.toBe(base.id) expect(target.isWorktree).toBe(true) expect(target.branch).toBe('feature/x') - expect(target.fullPath).toBe(path.join(tmpRoot, 'my-app-feature-x')) + expect(target.fullPath).toBe(join(tmpRoot, 'my-app-feature-x')) expect(existsSync(target.fullPath)).toBe(true) expect(execSync(`git -C "${target.fullPath}" rev-parse --abbrev-ref HEAD`, { encoding: 'utf-8' }).trim()).toBe('feature/x') expect(execSync(`git -C "${baseRepoPath}" rev-parse --abbrev-ref HEAD`, { encoding: 'utf-8' }).trim()).toBe('main') @@ -75,6 +75,67 @@ describe('mirror target resolution', () => { expect(second.repo.id).toBe(target.id) }) + it('rejects a branch whose sanitized path is occupied by a worktree for another branch and preserves that worktree', async () => { + const { planMirrorTarget } = await import('../../src/services/repo') + const { getRepoByLocalPath } = await import('../../src/db/queries') + + const occupiedPath = join(tmpRoot, 'my-app-feature-x') + expect(existsSync(occupiedPath)).toBe(true) + + await expect(planMirrorTarget(db, base, 'feature-x')).rejects.toThrow(/occupied by repo .* 'feature\/x' instead of 'feature-x'/) + + const ownerRow = getRepoByLocalPath(db, 'my-app-feature-x')! + expect(ownerRow.branch).toBe('feature/x') + expect(existsSync(occupiedPath)).toBe(true) + expect(execSync(`git -C "${occupiedPath}" rev-parse --abbrev-ref HEAD`, { encoding: 'utf-8' }).trim()).toBe('feature/x') + }) + + it('rejects an existing row matching the branch when its directory has a different branch checked out', async () => { + const { planMirrorTarget } = await import('../../src/services/repo') + const { createRepo } = await import('../../src/db/queries') + + const stalePath = join(tmpRoot, 'my-app-stale') + execSync(`git clone "${baseRepoPath}" "${stalePath}"`) + expect(execSync(`git -C "${stalePath}" rev-parse --abbrev-ref HEAD`, { encoding: 'utf-8' }).trim()).toBe('main') + + createRepo(db, { isLocal: true, localPath: 'my-app-stale', branch: 'stale', defaultBranch: 'stale', cloneStatus: 'ready', clonedAt: Date.now() }) + + await expect(planMirrorTarget(db, base, 'stale')).rejects.toThrow(/has branch 'main' checked out at/) + expect(existsSync(stalePath)).toBe(true) + }) + + it('rejects an existing row matching the branch when its worktree directory is missing', async () => { + const { planMirrorTarget } = await import('../../src/services/repo') + const { createRepo } = await import('../../src/db/queries') + + createRepo(db, { isLocal: true, localPath: 'my-app-ghost', branch: 'ghost', defaultBranch: 'ghost', cloneStatus: 'ready', clonedAt: Date.now() }) + + await expect(planMirrorTarget(db, base, 'ghost')).rejects.toThrow(/missing its worktree directory/) + expect(existsSync(join(tmpRoot, 'my-app-ghost'))).toBe(false) + }) + + it('removes the created worktree and rethrows when registration fails', async () => { + const { ensureMirrorTarget } = await import('../../src/services/repo') + const { getRepoByLocalPath } = await import('../../src/db/queries') + + db.exec(`CREATE TRIGGER fail_mirror_repo_insert BEFORE INSERT ON repos + WHEN NEW.local_path = 'my-app-feature-fail' + BEGIN + SELECT RAISE(ABORT, 'registration failed'); + END;`) + + try { + const failedPath = join(tmpRoot, 'my-app-feature-fail') + await expect(ensureMirrorTarget(db, base, 'feature/fail')).rejects.toThrow(/registration failed/) + + expect(existsSync(failedPath)).toBe(false) + expect(execSync(`git -C "${baseRepoPath}" worktree list`, { encoding: 'utf-8' })).not.toContain('feature-fail') + expect(getRepoByLocalPath(db, 'my-app-feature-fail')).toBeNull() + } finally { + db.exec('DROP TRIGGER fail_mirror_repo_insert') + } + }) + it('resolves the base directory name when asked from a worktree repo row', async () => { const { planMirrorTarget } = await import('../../src/services/repo') const { getRepoByLocalPath } = await import('../../src/db/queries') diff --git a/ocm-cli/README.md b/ocm-cli/README.md index 8b61f993a..f55829a5b 100644 --- a/ocm-cli/README.md +++ b/ocm-cli/README.md @@ -48,8 +48,8 @@ ocm ocm status ocm list ocm use -ocm push [--force] [--create] [--yes] [--full] -ocm pull [--force] [--full] +ocm push [repoId] [--force] [--create] [--yes] [--full] +ocm pull [repoId] [--force] [--full] ocm logout ``` @@ -77,6 +77,14 @@ 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. +A base repo and one of its worktrees can both be registered as ready Manager +repos sharing the same OpenCode project id. When that happens, `ocm push` and +`ocm pull` accept an optional positional repo id to pick the target: +`ocm push [repoId]` / `ocm pull [repoId]`. The id must belong to one of the +repos matching the current project (the command fails clearly otherwise), and +any ambiguity message lists each match with its id, kind (repo or worktree), +branch, and path. The default attach reports the same details. + ## OpenCode TUI plugin The package exposes an OpenCode TUI plugin through its `./tui` package export. diff --git a/ocm-cli/bin/ocm.ts b/ocm-cli/bin/ocm.ts index 0a8371c52..362d6accc 100644 --- a/ocm-cli/bin/ocm.ts +++ b/ocm-cli/bin/ocm.ts @@ -8,7 +8,7 @@ import type { RemoteRepoSummary, MirrorProgress, PushDivergence, PullDivergence import { createProgressReporter } from '../src/progress.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 { resolveTarget, formatRepoIdentities, parseRepoIdPositional, restrictMatchesToRequestedRepo } from '../src/resolve-target.js' import { buildRemoteAttachEnv } from '../src/remote-context.js' import { type ManagerRepo, fetchRepos, toRemoteRepoSummaries } from '../src/manager-repos.js' import packageJson from '../package.json' with { type: 'json' } @@ -26,8 +26,10 @@ 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] [--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 push [repoId] [--force] [--create] [--yes] [--full] + Mirror $PWD to the matching Manager repo (fast patch sync by default) + ocm pull [repoId] [--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 ` @@ -347,8 +349,7 @@ async function cmdDefault(): Promise { return } case 'cwd-ambiguous': { - const names = result.matches.map((r) => `${r.name} (id=${r.repoId})`).join(', ') - die(`multiple Manager repos match project ${result.localProjectId}: ${names}; disambiguate with \`ocm use \``) + die(`multiple Manager repos match project ${result.localProjectId}: ${formatRepoIdentities(result.matches)}; disambiguate with \`ocm use \``) break } case 'local': @@ -379,7 +380,17 @@ function toManagerRepo(repo: { repoId: number; name: string; branch: string | nu } } +const PUSH_FLAGS = ['--force', '--create', '--yes', '--full'] as const +const PULL_FLAGS = ['--force', '--full'] as const + +function dieAmbiguousProjectMatch(command: 'push' | 'pull', localProjectId: string, repos: readonly ManagerRepo[], matches: readonly RemoteRepoSummary[]): never { + const matchedIds = new Set(matches.map((m) => m.repoId)) + die(`multiple Manager repos match project ${localProjectId}: ${formatRepoIdentities(repos.filter((r) => matchedIds.has(r.repoId)))}; disambiguate with \`ocm ${command} \``) +} + export async function cmdPush(args: string[]): Promise { + const parsed = parseRepoIdPositional(args, PUSH_FLAGS) + if (parsed.error) die(parsed.error) let force = false let create = false let yes = false @@ -400,6 +411,9 @@ export async function cmdPush(args: string[]): Promise { const remotes: RemoteRepoSummary[] = toRemoteRepoSummaries(repos) const plan = await prepareMirror(process.cwd(), remotes) + const restriction = restrictMatchesToRequestedRepo(plan.matched, parsed.repoId, plan.localProjectId) + if (restriction.error) die(restriction.error) + plan.matched = restriction.matches if (plan.matched.length === 0) { if (!create) { @@ -456,12 +470,13 @@ export async function cmdPush(args: string[]): Promise { 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 project ${plan.localProjectId}: ${names}; disambiguate with \`ocm push \``) + dieAmbiguousProjectMatch('push', plan.localProjectId, repos, plan.matched) } } async function cmdPull(args: string[]): Promise { + const parsed = parseRepoIdPositional(args, PULL_FLAGS) + if (parsed.error) die(parsed.error) let force = false let full = false @@ -478,14 +493,16 @@ async function cmdPull(args: string[]): Promise { const remotes: RemoteRepoSummary[] = toRemoteRepoSummaries(repos) const plan = await prepareMirror(process.cwd(), remotes) + const restriction = restrictMatchesToRequestedRepo(plan.matched, parsed.repoId, plan.localProjectId) + if (restriction.error) die(restriction.error) + plan.matched = restriction.matches if (plan.matched.length === 0) { 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 project ${plan.localProjectId}: ${names}; disambiguate with \`ocm pull \``) + dieAmbiguousProjectMatch('pull', plan.localProjectId, repos, plan.matched) } if (!force) { diff --git a/ocm-cli/src/manager-api.ts b/ocm-cli/src/manager-api.ts index 153b116b4..806b46af0 100644 --- a/ocm-cli/src/manager-api.ts +++ b/ocm-cli/src/manager-api.ts @@ -1,5 +1,11 @@ import { createReadStream } from 'fs' import { Readable } from 'stream' +import { + MirrorTargetEnsureResponseSchema, + MirrorTargetPlanResponseSchema, + type MirrorTargetEnsureResponse, + type MirrorTargetPlanResponse, +} from '@opencode-manager/shared/schemas' export interface MirrorBeginOpts { force?: boolean @@ -52,25 +58,6 @@ export interface MirrorBundleResult { created: false } -export type MirrorTargetKind = 'in-place' | 'existing' | 'new' - -export interface MirrorTargetPlan { - kind: MirrorTargetKind - repoId: number | null - fullPath: string - localPath: string - branch: string - currentBranch: string | null -} - -export interface MirrorTarget { - repoId: number - fullPath: string - localPath: string - branch: string - created: boolean -} - function createByteCounter(onProgress: (bytesSent: number) => void): TransformStream { let bytesSent = 0 return new TransformStream({ @@ -194,11 +181,12 @@ export class ManagerApi { async mirrorUploadBundle( repoId: number, bundlePath: string, - opts: { branch: string | null; force?: boolean; onProgress?: (bytesSent: number) => void }, + opts: { branch: string | null; force?: boolean; requireCurrentBranch?: boolean; onProgress?: (bytesSent: number) => void }, ): 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 + if (opts.requireCurrentBranch === true) headers['X-OCM-Require-Current-Branch'] = '1' const fileStream = Readable.toWeb(createReadStream(bundlePath)) as unknown as ReadableStream const body = opts.onProgress ? fileStream.pipeThrough(createByteCounter(opts.onProgress)) : fileStream const res = await fetch(`${this.baseUrl}/api/internal/repos/${repoId}/mirror/bundle${query}`, { @@ -221,16 +209,16 @@ export class ManagerApi { return (await res.json()) as MirrorHead } - async mirrorTargetPlan(repoId: number, branch: string): Promise { + async mirrorTargetPlan(repoId: number, branch: string): Promise { const res = await fetch(`${this.baseUrl}/api/internal/repos/${repoId}/mirror/target?branch=${encodeURIComponent(branch)}`, { headers: this.headers(), }) if (!res.ok) throw await formatErrorResponse(res, 'mirror target plan') - return (await res.json()) as MirrorTargetPlan + return MirrorTargetPlanResponseSchema.parse(await res.json()) } - async mirrorEnsureTarget(repoId: number, branch: string): Promise { + async mirrorEnsureTarget(repoId: number, branch: string): Promise { const res = await fetch(`${this.baseUrl}/api/internal/repos/${repoId}/mirror/target`, { method: 'POST', headers: { ...this.headers(), 'Content-Type': 'application/json' }, @@ -238,7 +226,7 @@ export class ManagerApi { }) if (!res.ok) throw await formatErrorResponse(res, 'mirror target') - return (await res.json()) as MirrorTarget + return MirrorTargetEnsureResponseSchema.parse(await res.json()) } async mirrorContains(repoId: number, sha: string): Promise<{ contained: boolean }> { diff --git a/ocm-cli/src/manager-repos.ts b/ocm-cli/src/manager-repos.ts index 3495f9657..a4e71126d 100644 --- a/ocm-cli/src/manager-repos.ts +++ b/ocm-cli/src/manager-repos.ts @@ -7,6 +7,7 @@ export interface ManagerRepo { cloneStatus: string directory: string projectId?: string | null + isWorktree?: boolean extra: { repoId: number; localPath: string; fullPath: string } } diff --git a/ocm-cli/src/mirror.ts b/ocm-cli/src/mirror.ts index 13c972743..ba688f5ad 100644 --- a/ocm-cli/src/mirror.ts +++ b/ocm-cli/src/mirror.ts @@ -410,32 +410,38 @@ async function createLocalBundle(repoRoot: string): Promise { 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('')) +function listWorktreeBranches(repoRoot: string): Map { + const out = runGit(repoRoot, ['worktree', 'list', '--porcelain']) + const ownership = new Map() + let currentPath: string | null = null + for (const line of out.split('\n')) { + if (line.startsWith('worktree ')) { + currentPath = line.slice('worktree '.length).trim() + } else if (line.startsWith('branch ')) { + if (!currentPath) continue + const branch = line.slice('branch '.length).trim().replace(/^refs\/heads\//, '') + const paths = ownership.get(branch) ?? [] + paths.push(currentPath) + ownership.set(branch, paths) + } } + return ownership +} - if (branch) { - runGit(repoRoot, ['reset', '--hard']) - runGit(repoRoot, ['clean', '-fd']) - runGit(repoRoot, ['checkout', branch]) - const head = runGit(repoRoot, ['rev-parse', `refs/remotes/ocm-sync/${branch}`]).trim() - if (head) runGit(repoRoot, ['reset', '--hard', head]) +function listLocalBranches(repoRoot: string): Set { + const out = runGit(repoRoot, ['for-each-ref', '--format=%(refname:short)', 'refs/heads']) + return new Set(out.split('\n').map((l) => l.trim()).filter(Boolean)) +} + +function branchOwnedElsewhere(ownership: Map, repoRoot: string): Set { + const locked = new Set() + for (const [branch, paths] of ownership) { + if (paths.some((path) => path !== repoRoot)) locked.add(branch) } + return locked +} +function deleteSyncRefs(repoRoot: string): void { 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`) @@ -443,7 +449,61 @@ function importLocalBundle(repoRoot: string, bundlePath: string, branch: string runGit(repoRoot, ['update-ref', '--stdin'], deletes.join('')) } } catch { - // cleanup of ocm-sync refs is best-effort + return + } +} + +function importLocalBundle(repoRoot: string, bundlePath: string, branch: string | null, force: boolean): void { + runGit(repoRoot, ['fetch', bundlePath, '+refs/heads/*:refs/remotes/ocm-sync/*', '+refs/tags/*:refs/tags/*']) + try { + const refs = runGit(repoRoot, ['for-each-ref', '--format=%(refname:strip=3) %(objectname)', 'refs/remotes/ocm-sync']) + const incoming = new Map() + for (const line of refs.split('\n')) { + const trimmed = line.trim() + if (!trimmed) continue + const firstSpace = trimmed.indexOf(' ') + if (firstSpace === -1) continue + const name = trimmed.slice(0, firstSpace) + if (name === 'HEAD') continue + incoming.set(name, trimmed.slice(firstSpace + 1)) + } + + const lockedElsewhere = branchOwnedElsewhere(listWorktreeBranches(repoRoot), repoRoot) + + if (branch) { + const incomingSha = incoming.get(branch) + if (!incomingSha) throw new MirrorAbort(`no incoming branch '${branch}' in the server bundle`) + if (lockedElsewhere.has(branch)) { + throw new MirrorAbort(`branch '${branch}' is checked out in another worktree; release it there before pulling`) + } + const currentBranch = getBranchName(repoRoot) + if (currentBranch !== branch) { + const checkoutArgs = force ? ['-f'] : [] + if (listLocalBranches(repoRoot).has(branch)) { + runGit(repoRoot, ['checkout', ...checkoutArgs, branch]) + } else { + runGit(repoRoot, ['checkout', ...checkoutArgs, '-b', branch, incomingSha]) + } + } + } + + const updates: string[] = [] + for (const [name, sha] of incoming) { + if (branch === name) continue + if (lockedElsewhere.has(name)) continue + updates.push(`update refs/heads/${name} ${sha}\n`) + } + if (updates.length > 0) { + runGit(repoRoot, ['update-ref', '--stdin'], updates.join('')) + } + + if (branch) { + const targetSha = incoming.get(branch)! + runGit(repoRoot, ['reset', '--hard', targetSha]) + runGit(repoRoot, ['clean', '-fd']) + } + } finally { + deleteSyncRefs(repoRoot) } } @@ -463,9 +523,14 @@ export type MirrorUpFastPhase = | { kind: 'processing' } | { kind: 'patching' } +type MirrorUpFastOpts = Pick & { + requireCurrentBranch?: boolean + onPhase?: (phase: MirrorUpFastPhase) => void +} + export async function mirrorUpFast( plan: MirrorPlan, - opts: Pick & { onPhase?: (phase: MirrorUpFastPhase) => void }, + opts: MirrorUpFastOpts, ): Promise<{ repoId: number; fullPath: string; branch: string | null; head: string | null; created: false }> { const repoId = plan.matched[0]!.repoId const onPhase = opts.onPhase @@ -477,6 +542,7 @@ export async function mirrorUpFast( await opts.api.mirrorUploadBundle(repoId, bundlePath, { branch: getBranchName(plan.repoRoot), force: opts.force, + requireCurrentBranch: opts.requireCurrentBranch, onProgress: onPhase ? (bytesSent) => { onPhase({ kind: 'uploading', bytesSent, totalBytes: size }) @@ -505,11 +571,10 @@ export async function mirrorDownFast( const snapshot = await api.mirrorPatchSnapshot(repoId) const bundlePath = await writeBundleStream(repoId, api) try { - importLocalBundle(repoRoot, bundlePath, snapshot.branch) + importLocalBundle(repoRoot, bundlePath, snapshot.branch, opts.force) 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 ce2cc5d91..5f87d0f7b 100644 --- a/ocm-cli/src/resolve-target.ts +++ b/ocm-cli/src/resolve-target.ts @@ -1,4 +1,5 @@ import { getRepoRoot } from './local-repo.js' +import type { RemoteRepoSummary } from './mirror.js' export interface TargetRepo { repoId: number @@ -6,6 +7,69 @@ export interface TargetRepo { branch: string | null directory: string projectId?: string | null + isWorktree?: boolean +} + +export interface RepoIdentity { + repoId: number + name: string + branch: string | null + isWorktree?: boolean + directory?: string +} + +export function formatRepoIdentity(repo: RepoIdentity): string { + const parts = [`id=${repo.repoId}`, repo.isWorktree ? 'worktree' : 'repo', `branch=${repo.branch ?? 'n/a'}`] + if (repo.directory) parts.push(`path=${repo.directory}`) + return `${repo.name} (${parts.join(', ')})` +} + +export function formatRepoIdentities(repos: readonly RepoIdentity[]): string { + return repos.map(formatRepoIdentity).join(', ') +} + +export interface RepoIdParseResult { + repoId: number | null + error?: string +} + +export function parseRepoIdPositional(args: readonly string[], knownFlags: readonly string[]): RepoIdParseResult { + let repoId: number | null = null + for (const arg of args) { + if (knownFlags.includes(arg)) continue + if (arg.startsWith('-')) { + return { repoId: null, error: `unknown option: ${arg}` } + } + const id = Number(arg) + if (!/^\d+$/.test(arg) || !Number.isSafeInteger(id) || id <= 0) { + return { repoId: null, error: `invalid repo id: ${arg}; expected a positive integer` } + } + if (repoId !== null) { + return { repoId: null, error: `multiple repo ids given: ${repoId} and ${arg}` } + } + repoId = id + } + return { repoId } +} + +export interface MatchRestriction { + matches: RemoteRepoSummary[] + error?: string +} + +export function restrictMatchesToRequestedRepo(matches: readonly RemoteRepoSummary[], requestedRepoId: number | null, localProjectId: string): MatchRestriction { + if (requestedRepoId === null) return { matches: [...matches] } + const selected = matches.filter((m) => m.repoId === requestedRepoId) + if (selected.length === 0) { + const available = formatRepoIdentities(matches) + return { + matches: [], + error: available + ? `repo ${requestedRepoId} does not match project ${localProjectId}; matching repos: ${available}` + : `repo ${requestedRepoId} does not match project ${localProjectId}; no Manager repo matches this project`, + } + } + return { matches: selected } } export type ResolveResult = diff --git a/ocm-cli/src/tui-plugin.ts b/ocm-cli/src/tui-plugin.ts index 08695d31e..c7a72356a 100644 --- a/ocm-cli/src/tui-plugin.ts +++ b/ocm-cli/src/tui-plugin.ts @@ -4,7 +4,7 @@ import { getToken } from './internal-token-store.js' import { TokenStoreError } from './token-store.js' import { fetchRepos, toRemoteRepoSummaries } from './manager-repos.js' import { ManagerApi, ManagerApiError } from './manager-api.js' -import type { MirrorTargetPlan } from './manager-api.js' +import type { MirrorTargetPlanResponse } from '@opencode-manager/shared/schemas' import { prepareMirror, checkPushDivergence, describePushDivergence, mirrorUpFast, pickMatchedRepo } from './mirror.js' import type { MirrorPlan, RemoteRepoSummary } from './mirror.js' import { getBranchName } from './local-repo.js' @@ -59,7 +59,7 @@ async function describeRemoteDiscard(repoRoot: string, managerApi: ManagerApi, r } } -function describeMoveTarget(repoName: string, target: MirrorTargetPlan): string { +function describeMoveTarget(repoName: string, target: MirrorTargetPlanResponse): string { switch (target.kind) { case 'in-place': return `Replace the repo state of ${repoName} (${target.fullPath}) with your local working tree and move this session there?` @@ -70,13 +70,13 @@ function describeMoveTarget(repoName: string, target: MirrorTargetPlan): string } } -function moveConfirmMessage(repoName: string, target: MirrorTargetPlan, discardReasons: string[]): string { +function moveConfirmMessage(repoName: string, target: MirrorTargetPlanResponse, discardReasons: string[]): string { const base = describeMoveTarget(repoName, target) if (discardReasons.length === 0) return base return `${base}\n\nThis discards server-side work:\n${discardReasons.map((r) => ` - ${r}`).join('\n')}` } -async function resolveMoveTarget(managerApi: ManagerApi, matched: RemoteRepoSummary, remoteDirectory: string, localBranch: string | null): Promise { +async function resolveMoveTarget(managerApi: ManagerApi, matched: RemoteRepoSummary, remoteDirectory: string, localBranch: string | null): Promise { if (!localBranch) { return { kind: 'in-place', repoId: matched.repoId, fullPath: remoteDirectory, localPath: remoteDirectory, branch: '', currentBranch: null } } @@ -151,6 +151,7 @@ async function runSessionMove(api: TuiPluginApi, setMoveProgress: MoveProgressSe const pushed = await mirrorUpFast(selectedPlan, { api: managerApi, force: true, + requireCurrentBranch: true, onPhase: (phase) => setMoveProgress(pushPhaseProgress(phase)), }) const remoteDirectory = pushed.fullPath diff --git a/ocm-cli/test/mirror.test.ts b/ocm-cli/test/mirror.test.ts index 403be397e..86c922ad4 100644 --- a/ocm-cli/test/mirror.test.ts +++ b/ocm-cli/test/mirror.test.ts @@ -4,8 +4,9 @@ import { join } from 'path' import { tmpdir } from 'os' import { randomBytes } from 'crypto' import { spawnSync, execSync } from 'child_process' -import { prepareMirror, MirrorAbort, mirrorDown, mirrorUp, mirrorUpPatch, mirrorUpFast, checkPushDivergence, checkPullDivergence, describePushDivergence, pickMatchedRepo, type MirrorUpFastPhase } from '../src/mirror' +import { prepareMirror, MirrorAbort, mirrorDown, mirrorDownFast, mirrorUp, mirrorUpPatch, mirrorUpFast, checkPushDivergence, checkPullDivergence, describePushDivergence, pickMatchedRepo, type MirrorUpFastPhase } from '../src/mirror' import { getBranchName } from '../src/local-repo' +import { ManagerApi } from '../src/manager-api' import { gitRemoteProjectId } from '@opencode-manager/shared/project-id' import { mockStateModule, mockTokenStoreModule } from './helpers/token-store-mocks.js' @@ -848,6 +849,58 @@ describe('mirrorUpFast targets the selected repo', () => { expect(result.repoId).toBe(99) }) + it('forwards the strict current-branch option to the bundle upload', async () => { + const repoRoot = join(tmpDir, 'repo-strict-forward') + 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 api = { + mirrorUploadBundle: vi.fn().mockResolvedValue(undefined), + mirrorPatch: vi.fn().mockResolvedValue({ repoId: 1, fullPath: '/tmp/x', branch: 'main', head: 'abc', created: false, applied: true }), + } + + const plan = { + repoRoot, + localProjectId: 'proj', + matched: [{ repoId: 1, name: 'repo-A', projectId: 'proj', branch: 'main' }], + } + + await mirrorUpFast(plan, { api: api as any, force: false, requireCurrentBranch: true }) + + expect(api.mirrorUploadBundle.mock.calls[0]![2].requireCurrentBranch).toBe(true) + }) + + it('omits the strict current-branch option by default', async () => { + const repoRoot = join(tmpDir, 'repo-strict-default') + 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 api = { + mirrorUploadBundle: vi.fn().mockResolvedValue(undefined), + mirrorPatch: vi.fn().mockResolvedValue({ repoId: 1, fullPath: '/tmp/x', branch: 'main', head: 'abc', created: false, applied: true }), + } + + const plan = { + repoRoot, + localProjectId: 'proj', + matched: [{ repoId: 1, name: 'repo-A', projectId: 'proj', branch: 'main' }], + } + + await mirrorUpFast(plan, { api: api as any, force: false }) + + expect(api.mirrorUploadBundle.mock.calls[0]![2].requireCurrentBranch).toBeUndefined() + }) + it('reports bundling, uploading, and patching phases in order', async () => { const repoRoot = join(tmpDir, 'repo-phases') mkdirSync(repoRoot) @@ -955,3 +1008,276 @@ describe('mirrorUpFast targets the selected repo', () => { }) }) +describe('mirrorDownFast preflight', () => { + let tmpDir: string + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'mirror-downfast-test-')) + }) + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }) + }) + + function initRepo(name: string): string { + const repoRoot = join(tmpDir, name) + mkdirSync(repoRoot) + spawnSync('git', ['init', '-b', 'main'], { 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 commitFile(repoRoot: string, file: string, content: string): string { + writeFileSync(join(repoRoot, file), content) + spawnSync('git', ['add', '.'], { cwd: repoRoot, stdio: 'ignore' }) + spawnSync('git', ['commit', '-m', `update ${file}`], { cwd: repoRoot, stdio: 'ignore' }) + return execSync('git rev-parse HEAD', { cwd: repoRoot, encoding: 'utf-8' }).trim() + } + + function revRef(repoRoot: string, ref: string): string { + return execSync(`git rev-parse ${ref}`, { cwd: repoRoot, encoding: 'utf-8' }).trim() + } + + function createBundle(repoRoot: string, name: string): Buffer { + const bundleFile = join(tmpDir, `${name}.bundle`) + execSync(`git bundle create "${bundleFile}" --all`, { cwd: repoRoot }) + return readFileSync(bundleFile) + } + + const streamOf = (buf: Buffer): ReadableStream => + new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(buf)) + controller.close() + }, + }) + + const fastApi = (branch: string | null, bundle: Buffer) => ({ + mirrorPatchSnapshot: vi.fn().mockResolvedValue({ branch, patch: '' }), + mirrorDownloadBundle: vi.fn().mockResolvedValue(streamOf(bundle)), + }) + + const syncRefCount = (repoRoot: string): number => + execSync('git for-each-ref refs/remotes/ocm-sync', { cwd: repoRoot, encoding: 'utf-8' }).trim().split('\n').filter(Boolean).length + + it('updates the same branch target and leaves no ocm-sync refs behind', async () => { + const server = initRepo('server-same') + commitFile(server, 'tracked.txt', 'server-base\n') + execSync('git checkout -b feature', { cwd: server, stdio: 'ignore' }) + commitFile(server, 'feature.txt', 'feature\n') + execSync('git checkout main', { cwd: server, stdio: 'ignore' }) + commitFile(server, 'tracked.txt', 'server-head\n') + const serverMainSha = revRef(server, 'main') + const serverFeatureSha = revRef(server, 'feature') + const bundle = createBundle(server, 'server-same') + + const local = initRepo('local-same') + commitFile(local, 'tracked.txt', 'local-head\n') + + await mirrorDownFast(1, local, fastApi('main', bundle) as any, { force: false }) + + expect(getBranchName(local)).toBe('main') + expect(revRef(local, 'HEAD')).toBe(serverMainSha) + expect(readFileSync(join(local, 'tracked.txt'), 'utf-8')).toBe('server-head\n') + expect(revRef(local, 'feature')).toBe(serverFeatureSha) + expect(syncRefCount(local)).toBe(0) + }) + + it('switches branches when clean and selects the incoming target before resetting', async () => { + const server = initRepo('server-cross') + commitFile(server, 'main.txt', 'server-main\n') + execSync('git checkout -b topic', { cwd: server, stdio: 'ignore' }) + const topicSha = commitFile(server, 'topic.txt', 'server-topic\n') + execSync('git checkout main', { cwd: server, stdio: 'ignore' }) + commitFile(server, 'main.txt', 'server-main-2\n') + const serverMainSha = revRef(server, 'main') + const bundle = createBundle(server, 'server-cross') + + const local = initRepo('local-cross') + commitFile(local, 'main.txt', 'local-main\n') + execSync('git branch topic', { cwd: local, stdio: 'ignore' }) + + await mirrorDownFast(1, local, fastApi('topic', bundle) as any, { force: false }) + + expect(getBranchName(local)).toBe('topic') + expect(revRef(local, 'HEAD')).toBe(topicSha) + expect(revRef(local, 'topic')).toBe(topicSha) + expect(revRef(local, 'main')).toBe(serverMainSha) + expect(readFileSync(join(local, 'topic.txt'), 'utf-8')).toBe('server-topic\n') + expect(syncRefCount(local)).toBe(0) + }) + + it('rejects a target checked out in another worktree and preserves the current worktree state', async () => { + const server = initRepo('server-wt') + commitFile(server, 'main.txt', 'server-main\n') + execSync('git checkout -b checked', { cwd: server, stdio: 'ignore' }) + commitFile(server, 'checked.txt', 'server-checked\n') + execSync('git checkout main', { cwd: server, stdio: 'ignore' }) + const bundle = createBundle(server, 'server-wt') + + const local = initRepo('local-wt') + commitFile(local, 'main.txt', 'local-base\n') + execSync(`git worktree add "${join(tmpDir, 'checked-wt')}" -b checked`, { cwd: local, stdio: 'ignore' }) + const localMainSha = revRef(local, 'main') + const localCheckedSha = revRef(local, 'checked') + writeFileSync(join(local, 'untracked.txt'), 'keep-me\n') + + const thrown = await mirrorDownFast(1, local, fastApi('checked', bundle) as any, { force: true }).then( + () => null, + (err: unknown) => err, + ) + expect(thrown).toBeInstanceOf(MirrorAbort) + expect((thrown as MirrorAbort).message).toContain('checked out in another worktree') + + expect(getBranchName(local)).toBe('main') + expect(existsSync(join(local, 'untracked.txt'))).toBe(true) + expect(revRef(local, 'main')).toBe(localMainSha) + expect(revRef(local, 'checked')).toBe(localCheckedSha) + expect(existsSync(join(tmpDir, 'checked-wt', 'checked.txt'))).toBe(false) + expect(syncRefCount(local)).toBe(0) + }) + + it('rejects a missing target ref and preserves local refs and worktree state', async () => { + const server = initRepo('server-missing') + commitFile(server, 'main.txt', 'server-main\n') + const bundle = createBundle(server, 'server-missing') + + const local = initRepo('local-missing') + const localMainSha = commitFile(local, 'main.txt', 'local-main\n') + writeFileSync(join(local, 'untracked.txt'), 'keep-me\n') + + const thrown = await mirrorDownFast(1, local, fastApi('ghost', bundle) as any, { force: true }).then( + () => null, + (err: unknown) => err, + ) + expect(thrown).toBeInstanceOf(MirrorAbort) + expect((thrown as MirrorAbort).message).toContain("no incoming branch 'ghost'") + + expect(getBranchName(local)).toBe('main') + expect(revRef(local, 'main')).toBe(localMainSha) + expect(existsSync(join(local, 'untracked.txt'))).toBe(true) + expect(syncRefCount(local)).toBe(0) + }) +}) + +describe('ManagerApi target response validation', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + const api = new ManagerApi('http://localhost:5003', 'test-token') + + it('parses a valid new-worktree target plan response', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ kind: 'new', repoId: null, fullPath: '/repos/repo-feature', localPath: 'repo-feature', branch: 'feature', currentBranch: 'main' }), + })) + + const plan = await api.mirrorTargetPlan(1, 'feature') + + expect(plan.kind).toBe('new') + expect(plan.repoId).toBeNull() + expect(plan.branch).toBe('feature') + }) + + it('parses a valid existing-worktree target plan response', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ kind: 'existing', repoId: 5, fullPath: '/repos/repo-feature', localPath: 'repo-feature', branch: 'feature', currentBranch: null }), + })) + + const plan = await api.mirrorTargetPlan(1, 'feature') + + expect(plan.kind).toBe('existing') + expect(plan.repoId).toBe(5) + }) + + it('rejects a target plan response whose repoId contradicts its kind', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ kind: 'new', repoId: 7, fullPath: '/repos/repo-feature', localPath: 'repo-feature', branch: 'feature', currentBranch: 'main' }), + })) + + await expect(api.mirrorTargetPlan(1, 'feature')).rejects.toThrow() + }) + + it('rejects a target plan response with an unknown kind', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ kind: 'bogus', repoId: 1, fullPath: '/x', localPath: 'x', branch: 'main', currentBranch: null }), + })) + + await expect(api.mirrorTargetPlan(1, 'main')).rejects.toThrow() + }) + + it('parses a valid ensure target response', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ repoId: 9, fullPath: '/repos/repo-feature', localPath: 'repo-feature', branch: 'feature', created: true }), + })) + + const target = await api.mirrorEnsureTarget(1, 'feature') + + expect(target.repoId).toBe(9) + expect(target.created).toBe(true) + }) + + it('rejects an ensure target response missing fields', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ repoId: 9, fullPath: '/repos/repo-feature' }), + })) + + await expect(api.mirrorEnsureTarget(1, 'feature')).rejects.toThrow() + }) + + it('rejects an ensure target response with a non-positive repoId', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ repoId: 0, fullPath: '/x', localPath: 'x', branch: 'main', created: false }), + })) + + await expect(api.mirrorEnsureTarget(1, 'main')).rejects.toThrow() + }) +}) + +describe('ManagerApi mirrorUploadBundle strict branch header', () => { + let tmpDir: string + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'bundle-header-test-')) + }) + + afterEach(() => { + vi.unstubAllGlobals() + rmSync(tmpDir, { recursive: true, force: true }) + }) + + const api = new ManagerApi('http://localhost:5003', 'test-token') + + async function captureUpload(opts: { branch: string | null; requireCurrentBranch?: boolean }): Promise> { + const bundlePath = join(tmpDir, 'repo.bundle') + writeFileSync(bundlePath, 'bundle bytes') + let capturedHeaders: Record | undefined + vi.stubGlobal('fetch', vi.fn().mockImplementation(async (_url: unknown, init?: RequestInit) => { + capturedHeaders = init!.headers as Record + return { ok: true, json: () => Promise.resolve({ repoId: 1, fullPath: '/repos/x', branch: 'main', head: 'abc', created: false }) } + })) + await api.mirrorUploadBundle(1, bundlePath, opts) + expect(capturedHeaders).toBeDefined() + return capturedHeaders! + } + + it('sends the require-current-branch header when requested', async () => { + const headers = await captureUpload({ branch: 'main', requireCurrentBranch: true }) + expect(headers['X-OCM-Require-Current-Branch']).toBe('1') + expect(headers['X-OCM-Branch']).toBe('main') + }) + + it('omits the require-current-branch header for normal push', async () => { + const headers = await captureUpload({ branch: 'main' }) + expect(headers['X-OCM-Require-Current-Branch']).toBeUndefined() + expect(headers['X-OCM-Branch']).toBe('main') + }) +}) diff --git a/ocm-cli/test/resolve-target.test.ts b/ocm-cli/test/resolve-target.test.ts index 4a23673cb..c63187406 100644 --- a/ocm-cli/test/resolve-target.test.ts +++ b/ocm-cli/test/resolve-target.test.ts @@ -3,7 +3,8 @@ import { mkdtempSync, mkdirSync, rmSync } from 'fs' import { join } from 'path' import { tmpdir } from 'os' import { spawnSync } from 'child_process' -import { resolveTarget, type TargetRepo } from '../src/resolve-target' +import { resolveTarget, formatRepoIdentity, formatRepoIdentities, parseRepoIdPositional, restrictMatchesToRequestedRepo, type TargetRepo, type RepoIdentity } from '../src/resolve-target' +import type { RemoteRepoSummary } from '../src/mirror' const LAST = { repoId: 99, @@ -141,3 +142,112 @@ describe('resolveTarget', () => { } }) }) + +describe('formatRepoIdentities', () => { + it('shows id, kind, branch, and path for a worktree and a base repo', () => { + const worktree = { + repoId: 3, + name: 'my-app-feat-x', + branch: 'feat/x', + isWorktree: true, + directory: '/repos/my-app-feat-x', + } + const base = { + repoId: 2, + name: 'my-app', + branch: null, + directory: '/repos/my-app', + } + + expect(formatRepoIdentity(worktree)).toBe('my-app-feat-x (id=3, worktree, branch=feat/x, path=/repos/my-app-feat-x)') + expect(formatRepoIdentity(base)).toBe('my-app (id=2, repo, branch=n/a, path=/repos/my-app)') + expect(formatRepoIdentities([worktree, base])).toBe( + 'my-app-feat-x (id=3, worktree, branch=feat/x, path=/repos/my-app-feat-x), my-app (id=2, repo, branch=n/a, path=/repos/my-app)', + ) + }) + + it('accepts readonly identity arrays', () => { + const identities: readonly RepoIdentity[] = [ + { repoId: 1, name: 'a', branch: 'main', isWorktree: false, directory: '/repos/a' }, + ] + expect(formatRepoIdentities(identities)).toBe('a (id=1, repo, branch=main, path=/repos/a)') + }) + + it('formats cwd-ambiguous target matches with worktree metadata included', () => { + const ambiguous: readonly TargetRepo[] = [ + { repoId: 1, name: 'a', branch: 'main', directory: '/repos/a', isWorktree: false }, + { repoId: 2, name: 'b', branch: 'feat', directory: '/repos/b', isWorktree: true }, + ] + const formatted = formatRepoIdentities(ambiguous) + expect(formatted).toContain('a (id=1, repo, branch=main, path=/repos/a)') + expect(formatted).toContain('b (id=2, worktree, branch=feat, path=/repos/b)') + }) +}) + +describe('parseRepoIdPositional', () => { + const flags = ['--force', '--create', '--yes', '--full'] + + it('returns null when no positional repo id is given and ignores known flags', () => { + expect(parseRepoIdPositional([], flags)).toEqual({ repoId: null }) + expect(parseRepoIdPositional(['--force', '--full'], flags)).toEqual({ repoId: null }) + }) + + it('parses a single positive integer repo id among flags', () => { + expect(parseRepoIdPositional(['--force', '7', '--full'], flags)).toEqual({ repoId: 7 }) + expect(parseRepoIdPositional(['3'], flags)).toEqual({ repoId: 3 }) + }) + + it('rejects non-positive-integer repo ids', () => { + expect(parseRepoIdPositional(['abc'], flags).error).toMatch(/invalid repo id: abc/) + expect(parseRepoIdPositional(['0'], flags).error).toMatch(/invalid repo id: 0/) + expect(parseRepoIdPositional(['1.5'], flags).error).toMatch(/invalid repo id/) + expect(parseRepoIdPositional(['1e3'], flags).error).toMatch(/invalid repo id/) + }) + + it('rejects oversized repo ids that are not safe integers', () => { + expect(parseRepoIdPositional(['9007199254740993'], flags).error).toMatch(/invalid repo id: 9007199254740993/) + expect(parseRepoIdPositional(['99999999999999999999'], flags).error).toMatch(/invalid repo id: 99999999999999999999/) + }) + + it('rejects unknown options', () => { + expect(parseRepoIdPositional(['--bogus'], flags).error).toBe('unknown option: --bogus') + expect(parseRepoIdPositional(['-x'], flags).error).toBe('unknown option: -x') + }) + + it('rejects duplicate positional repo ids', () => { + expect(parseRepoIdPositional(['3', '4'], flags).error).toMatch(/multiple repo ids given: 3 and 4/) + }) +}) + +describe('restrictMatchesToRequestedRepo', () => { + const match = (id: number): RemoteRepoSummary => ({ + repoId: id, + name: `repo-${id}`, + projectId: 'project-a', + branch: 'main', + }) + + it('passes matches through when no repo id is requested', () => { + const matches = [match(1), match(2)] + expect(restrictMatchesToRequestedRepo(matches, null, 'project-a')).toEqual({ matches }) + }) + + it('restricts matches to the exact requested repo', () => { + const result = restrictMatchesToRequestedRepo([match(1), match(2)], 2, 'project-a') + expect(result.error).toBeUndefined() + expect(result.matches).toEqual([match(2)]) + }) + + it('fails clearly when the requested repo is not one of the project matches', () => { + const result = restrictMatchesToRequestedRepo([match(1), match(2)], 7, 'project-a') + expect(result.error).toContain('repo 7 does not match project project-a') + expect(result.error).toContain('repo-1 (id=1, repo, branch=main') + expect(result.matches).toEqual([]) + }) + + it('fails clearly when no repo matches the project', () => { + const result = restrictMatchesToRequestedRepo([], 7, 'project-a') + expect(result.error).toBe('repo 7 does not match project project-a; no Manager repo matches this project') + expect(result.matches).toEqual([]) + }) +}) diff --git a/shared/src/schemas/repo.ts b/shared/src/schemas/repo.ts index 5e7def713..52e2ca9d6 100644 --- a/shared/src/schemas/repo.ts +++ b/shared/src/schemas/repo.ts @@ -110,3 +110,34 @@ export const AssistantModeInitRequestSchema = z.object({ overwriteAgentsMd: z.boolean().optional(), overwriteOpenCodeConfig: z.boolean().optional(), }) + +export const MirrorTargetBranchRequestSchema = z.object({ + branch: z.string().trim().min(1), +}) + +export type MirrorTargetBranchRequest = z.infer + +const MirrorTargetPlanBaseSchema = z.object({ + fullPath: z.string().min(1), + localPath: z.string().min(1), + branch: z.string().min(1), + currentBranch: z.string().min(1).nullable(), +}) + +export const MirrorTargetPlanResponseSchema = z.discriminatedUnion('kind', [ + MirrorTargetPlanBaseSchema.extend({ kind: z.literal('new'), repoId: z.literal(null) }), + MirrorTargetPlanBaseSchema.extend({ kind: z.literal('in-place'), repoId: z.number().int().positive() }), + MirrorTargetPlanBaseSchema.extend({ kind: z.literal('existing'), repoId: z.number().int().positive() }), +]) + +export type MirrorTargetPlanResponse = z.infer + +export const MirrorTargetEnsureResponseSchema = z.object({ + repoId: z.number().int().positive(), + fullPath: z.string().min(1), + localPath: z.string().min(1), + branch: z.string().min(1), + created: z.boolean(), +}) + +export type MirrorTargetEnsureResponse = z.infer From 80daae846f4fba39ea397477ac6c9a58d60eba37 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Wed, 2 Sep 2026 02:12:14 +0000 Subject: [PATCH 4/4] fix(ocm): protect active branch during branchless pull --- ocm-cli/src/mirror.ts | 3 ++- ocm-cli/test/mirror.test.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/ocm-cli/src/mirror.ts b/ocm-cli/src/mirror.ts index ba688f5ad..a7b754308 100644 --- a/ocm-cli/src/mirror.ts +++ b/ocm-cli/src/mirror.ts @@ -469,6 +469,7 @@ function importLocalBundle(repoRoot: string, bundlePath: string, branch: string } const lockedElsewhere = branchOwnedElsewhere(listWorktreeBranches(repoRoot), repoRoot) + const currentBranch = getBranchName(repoRoot) if (branch) { const incomingSha = incoming.get(branch) @@ -476,7 +477,6 @@ function importLocalBundle(repoRoot: string, bundlePath: string, branch: string if (lockedElsewhere.has(branch)) { throw new MirrorAbort(`branch '${branch}' is checked out in another worktree; release it there before pulling`) } - const currentBranch = getBranchName(repoRoot) if (currentBranch !== branch) { const checkoutArgs = force ? ['-f'] : [] if (listLocalBranches(repoRoot).has(branch)) { @@ -491,6 +491,7 @@ function importLocalBundle(repoRoot: string, bundlePath: string, branch: string for (const [name, sha] of incoming) { if (branch === name) continue if (lockedElsewhere.has(name)) continue + if (branch === null && name === currentBranch) continue updates.push(`update refs/heads/${name} ${sha}\n`) } if (updates.length > 0) { diff --git a/ocm-cli/test/mirror.test.ts b/ocm-cli/test/mirror.test.ts index 86c922ad4..12ff10b70 100644 --- a/ocm-cli/test/mirror.test.ts +++ b/ocm-cli/test/mirror.test.ts @@ -1108,6 +1108,32 @@ describe('mirrorDownFast preflight', () => { expect(syncRefCount(local)).toBe(0) }) + it('keeps the active branch untouched when the snapshot has no target branch', async () => { + const server = initRepo('server-null-target') + commitFile(server, 'a.txt', 'server-a\n') + execSync('git checkout -b feature', { cwd: server, stdio: 'ignore' }) + const serverFeatureSha = commitFile(server, 'feature.txt', 'feature\n') + execSync('git checkout main', { cwd: server, stdio: 'ignore' }) + commitFile(server, 'b.txt', 'server-b\n') + const bundle = createBundle(server, 'server-null-target') + + const local = initRepo('local-null-target') + const localMainSha = commitFile(local, 'a.txt', 'local-a\n') + execSync('git checkout -b feature', { cwd: local, stdio: 'ignore' }) + commitFile(local, 'local-feature.txt', 'local-feature\n') + execSync('git checkout main', { cwd: local, stdio: 'ignore' }) + + await mirrorDownFast(1, local, fastApi(null, bundle) as any, { force: false }) + + expect(getBranchName(local)).toBe('main') + expect(revRef(local, 'main')).toBe(localMainSha) + expect(revRef(local, 'HEAD')).toBe(localMainSha) + expect(readFileSync(join(local, 'a.txt'), 'utf-8')).toBe('local-a\n') + expect(execSync('git status --porcelain', { cwd: local, encoding: 'utf-8' })).toBe('') + expect(revRef(local, 'feature')).toBe(serverFeatureSha) + expect(syncRefCount(local)).toBe(0) + }) + it('rejects a target checked out in another worktree and preserves the current worktree state', async () => { const server = initRepo('server-wt') commitFile(server, 'main.txt', 'server-main\n')