diff --git a/backend/src/routes/internal/index.ts b/backend/src/routes/internal/index.ts index 4ca67c54e..9d58bafe8 100644 --- a/backend/src/routes/internal/index.ts +++ b/backend/src/routes/internal/index.ts @@ -49,7 +49,7 @@ export function createInternalRoutes( repos.route('/', createInternalRepoRoutes(db, settingsService, gitAuthService)) repos.route('/:id/schedules', createScheduleRoutes(scheduleService)) repos.route('/', createInternalRepoSyncRoutes(db)) - repos.route('/', mirrorRoutes(db)) + repos.route('/', mirrorRoutes(db, openCodeClient, gitAuthService, repoWorkspaces)) app.route('/repos', repos) app.route('/opencode-workspaces', createInternalOpenCodeWorkspacesRoutes(db)) app.route('/sessions', createInternalSessionRoutes(db, openCodeClient, permissionModes, repoWorkspaces)) diff --git a/backend/src/routes/internal/repo-mirror.ts b/backend/src/routes/internal/repo-mirror.ts index 817b1573d..e71225a0a 100644 --- a/backend/src/routes/internal/repo-mirror.ts +++ b/backend/src/routes/internal/repo-mirror.ts @@ -3,21 +3,35 @@ import type { Database } from 'bun:sqlite' import { spawn } from 'child_process' import { copyFileSync, createReadStream, createWriteStream, existsSync } from 'fs' import { mkdtempSync, writeFileSync } from 'fs' -import { Readable } from 'stream' +import { Readable, Transform } from 'stream' import { pipeline } from 'stream/promises' -import { join } from 'path' +import { join, resolve } from 'path' import * as fsp from 'fs/promises' import { getReposPath } from '@opencode-manager/shared/config/env' import { MirrorTargetBranchRequestSchema, - type MirrorTargetEnsureResponse, - type MirrorTargetPlanResponse, + type MirrorCheckoutState, + type MirrorCheckoutsResponse, + type MirrorWorktreeCreateResponse, } from '@opencode-manager/shared/schemas' +import { isWorktreeSibling, sanitizeRepoDirectoryName, type RepoSibling } from '@opencode-manager/shared/utils' +import type { Repo } from '@opencode-manager/shared/types' import { getRepoById, updateLastPulled, updateRepoBranch, deleteRepo } from '../../db/queries' -import { ensureMirrorTargetPath, createRepoRow, isRepoInUse, planMirrorTarget, ensureMirrorTarget } from '../../services/repo' +import { + ensureMirrorTargetPath, + createRepoRow, + isDirectoryInUse, + isRepoInUse, + listRepoSiblings, + listGitWorktrees, + resolveRepoWorkingDirectory, +} from '../../services/repo' +import type { RepoWorkspaceService } from '../../services/repo-workspace' +import type { GitAuthService } from '../../services/git-auth' +import type { OpenCodeClient } from '../../services/opencode/client' import { logger } from '../../utils/logger' import { getErrorMessage } from '../../utils/error-utils' -import { mkdirSyncSafe } from '../../utils/fs-safe' +import { canonicalPathSync, mkdirSyncSafe } from '../../utils/fs-safe' import { safeGitOut, gitOut } from './repo-sync-helpers' import { MIRROR_CHUNK_SIZE, @@ -55,9 +69,11 @@ interface PatchBody { baseHead?: string | null patch?: string force?: boolean + directory?: 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)' +const LEGACY_MOVE_UPGRADE_MESSAGE = 'this ocm CLI is too old for this server; upgrade to ocm-cli >= 0.4.0 (/ocm-move now moves sessions into OpenCode worktrees)' function gitRaw(repoPath: string, args: string[], env: NodeJS.ProcessEnv = process.env, input?: string): Promise { return new Promise((resolve, reject) => { @@ -127,7 +143,77 @@ async function listLocalBranchNames(fullPath: string): Promise> { 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 { +async function isValidBranchName(repoPath: string, name: string): Promise { + if (name.startsWith('-')) return false + return (await safeGitOut(repoPath, ['check-ref-format', '--branch', name])) !== null +} + +async function checkoutState(directory: string): Promise { + const [branch, head, status] = await Promise.all([ + currentBranchName(directory), + safeGitOut(directory, ['rev-parse', 'HEAD']), + safeGitOut(directory, ['status', '--porcelain', '--untracked-files=all']), + ]) + return { + directory, + branch, + head: head?.trim() || null, + dirty: (status?.trim().length ?? 0) > 0, + } +} + +function isMirrorCheckoutSibling(repo: Repo): (sibling: RepoSibling) => boolean { + return (sibling) => { + if (isWorktreeSibling(sibling)) return sibling.worktreeSource !== 'schedule' + return sibling.isWorktree === true && sibling.id > 0 && sibling.id !== repo.id + } +} + +async function listMirrorCheckoutSiblings( + db: Database, + repo: Repo, + gitAuthService: GitAuthService, + openCodeClient: OpenCodeClient, +): Promise { + return listRepoSiblings(db, repo.id, gitAuthService.getGitEnvironment(), openCodeClient, isMirrorCheckoutSibling(repo)) +} + +async function resolveMirrorDirectory( + db: Database, + repo: Repo, + directory: string | undefined, + gitAuthService: GitAuthService, + openCodeClient: OpenCodeClient, +): Promise { + return resolveRepoWorkingDirectory(repo, directory, () => + listMirrorCheckoutSiblings(db, repo, gitAuthService, openCodeClient), + ) +} + +function mirrorWorktreeBranchRank(worktreeBranch: string | null, branch: string): number | null { + if (worktreeBranch === branch) return 0 + const prefix = `${branch}-ocm` + if (worktreeBranch === prefix) return 1 + if (!worktreeBranch || !worktreeBranch.startsWith(`${prefix}-`)) return null + const suffix = Number(worktreeBranch.slice(prefix.length + 1)) + return Number.isInteger(suffix) && suffix >= 2 ? suffix : null +} + +function nextSuffixedWorktreeBranch(branch: string, localBranches: Set, checkedOut: Set): string { + let suffix = 1 + while (true) { + const candidate = suffix === 1 ? `${branch}-ocm` : `${branch}-ocm-${suffix}` + if (!localBranches.has(candidate) && !checkedOut.has(candidate)) return candidate + suffix += 1 + } +} + +async function branchHeadSha(fullPath: string, branch: string): Promise { + const sha = await safeGitOut(fullPath, ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`]) + return sha?.trim() || null +} + +async function importBundle(fullPath: string, bundlePath: string, branch: string | null, requireCurrentBranch: boolean, force: boolean, targetBranch: string | null = branch): Promise { await gitRaw(fullPath, ['fetch', bundlePath, '+refs/heads/*:refs/remotes/ocm-sync/*', '+refs/tags/*:refs/tags/*']) try { const refs = await gitRaw(fullPath, ['for-each-ref', '--format=%(refname:strip=3) %(objectname)', 'refs/remotes/ocm-sync']) @@ -149,26 +235,30 @@ async function importBundle(fullPath: string, bundlePath: string, branch: string 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`) + const target = targetBranch ?? branch + if (locked.has(target)) { + throw new Error(`branch '${target}' 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}'`) + if (requireCurrentBranch && actualBranch !== target) { + throw new Error(`repo is on branch '${actualBranch ?? 'detached HEAD'}' but the bundle targets '${target}'`) } targetSha = incomingSha - if (actualBranch !== branch) { + if (actualBranch !== target) { const checkoutArgs = force ? ['-f'] : [] - if ((await listLocalBranchNames(fullPath)).has(branch)) { - await gitRaw(fullPath, ['checkout', ...checkoutArgs, branch]) + if ((await listLocalBranchNames(fullPath)).has(target)) { + await gitRaw(fullPath, ['checkout', ...checkoutArgs, target]) } else { - await gitRaw(fullPath, ['checkout', ...checkoutArgs, '-b', branch, incomingSha]) + await gitRaw(fullPath, ['checkout', ...checkoutArgs, '-b', target, incomingSha]) } } } + const skipRefs = new Set() + if (branch !== null) skipRefs.add(branch) + if (targetBranch !== null) skipRefs.add(targetBranch) const updates: string[] = [] for (const [name, sha] of incoming) { - if (targetSha !== undefined && name === branch) continue + if (skipRefs.has(name)) continue if (locked.has(name)) continue updates.push(`update refs/heads/${name} ${sha}\n`) } @@ -198,7 +288,50 @@ async function createBundle(fullPath: string): Promise { return bundlePath } -export function createInternalRepoMirrorRoutes(db: Database) { +async function stageBundleFromBody(body: ReadableStream, dirPrefix: string, patchBytes = 0): Promise<{ bundleDir: string; bundlePath: string; patch: string }> { + const stagingRoot = getStagingRoot() + mkdirSyncSafe(stagingRoot) + const bundleDir = mkdtempSync(join(stagingRoot, dirPrefix)) + try { + const bundlePath = join(bundleDir, 'repo.bundle') + const readable = Readable.fromWeb(body as unknown as Parameters[0]) + const patchChunks: Buffer[] = [] + let patchRead = 0 + const splitPatch = new Transform({ + transform(chunk, _encoding, callback) { + if (patchRead >= patchBytes) { + this.push(chunk) + callback() + return + } + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + const remaining = patchBytes - patchRead + if (buffer.length <= remaining) { + patchChunks.push(buffer) + patchRead += buffer.length + } else { + patchChunks.push(buffer.subarray(0, remaining)) + patchRead += remaining + this.push(buffer.subarray(remaining)) + } + callback() + }, + }) + await pipeline(readable, splitPatch, createWriteStream(bundlePath)) + if (patchRead < patchBytes) throw new Error('request body ended before the declared patch length') + return { bundleDir, bundlePath, patch: Buffer.concat(patchChunks).toString('utf-8') } + } catch (error) { + await fsp.rm(bundleDir, { recursive: true, force: true }).catch(() => {}) + throw error + } +} + +export function createInternalRepoMirrorRoutes( + db: Database, + openCodeClient: OpenCodeClient, + gitAuthService: GitAuthService, + repoWorkspaces: RepoWorkspaceService, +) { const app = new Hono() app.post('/:repoId/mirror', (c) => { @@ -375,9 +508,12 @@ export function createInternalRepoMirrorRoutes(db: Database) { const repo = getRepoById(db, repoId) if (!repo) return c.json({ error: 'repo not found' }, 404) + const directory = await resolveMirrorDirectory(db, repo, c.req.query('directory'), gitAuthService, openCodeClient) + if (!directory) return c.json({ error: 'Directory is not part of this repository' }, 400) + let bundlePath: string | undefined try { - bundlePath = await createBundle(repo.fullPath) + bundlePath = await createBundle(directory) const stream = createReadStream(bundlePath) stream.on('close', () => { if (bundlePath) fsp.rm(join(bundlePath, '..'), { recursive: true, force: true }).catch(() => {}) @@ -398,47 +534,58 @@ 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) + + const directory = await resolveMirrorDirectory(db, repo, c.req.query('directory'), gitAuthService, openCodeClient) + if (!directory) return c.json({ error: 'Directory is not part of this repository' }, 400) + const force = c.req.query('force') === '1' - if (isRepoInUse(db, repoId) && !force) { + if (isDirectoryInUse(directory) && !force) { return c.json({ error: 'repo_in_use', message: 'open OpenCode sessions are using this repo; rerun with force=1' }, 409) } const rawBody = c.req.raw.body if (!rawBody) return c.json({ error: 'no body provided' }, 400) - const stagingRoot = getStagingRoot() - mkdirSyncSafe(stagingRoot) - const bundleDir = mkdtempSync(join(stagingRoot, 'bundle-upload-')) - const bundlePath = join(bundleDir, 'repo.bundle') const branch = c.req.header('x-ocm-branch')?.trim() || null + const targetBranch = c.req.header('x-ocm-target-branch')?.trim() || branch const requireCurrentBranch = c.req.header('x-ocm-require-current-branch')?.trim() === '1' + let bundleDir: string | undefined try { - const body = Readable.fromWeb(rawBody as unknown as Parameters[0]) - await pipeline(body, createWriteStream(bundlePath)) - 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']) - if (branchName) updateRepoBranch(db, repoId, branchName.trim()) - updateLastPulled(db, repoId) + const staged = await stageBundleFromBody(rawBody, 'bundle-upload-') + bundleDir = staged.bundleDir + await importBundle(directory, staged.bundlePath, branch, requireCurrentBranch, force, targetBranch) + + const state = await checkoutState(directory) + if (directory === repo.fullPath) { + if (state.branch) updateRepoBranch(db, repoId, state.branch) + updateLastPulled(db, repoId) + } return c.json({ repoId, - fullPath: repo.fullPath, - branch: branchName?.trim() || null, - head: head?.trim() || null, + fullPath: directory, + branch: state.branch, + head: state.head, created: false, }) } catch (error) { logger.error('mirror bundle upload failed:', error) return c.json({ error: getErrorMessage(error) }, 409) } finally { - await fsp.rm(bundleDir, { recursive: true, force: true }).catch(() => {}) + if (bundleDir) await fsp.rm(bundleDir, { recursive: true, force: true }).catch(() => {}) } }) - app.get('/:repoId/mirror/target', async (c) => { + app.get('/:repoId/mirror/target', (c) => { + return c.json({ error: 'cli_too_old', message: LEGACY_MOVE_UPGRADE_MESSAGE }, 410) + }) + + app.post('/:repoId/mirror/target', (c) => { + return c.json({ error: 'cli_too_old', message: LEGACY_MOVE_UPGRADE_MESSAGE }, 410) + }) + + app.get('/:repoId/mirror/checkouts', async (c) => { const repoId = Number(c.req.param('repoId')) if (!Number.isFinite(repoId)) return c.json({ error: 'invalid repoId' }, 400) const branchParsed = MirrorTargetBranchRequestSchema.safeParse({ branch: c.req.query('branch') }) @@ -446,47 +593,108 @@ export function createInternalRepoMirrorRoutes(db: Database) { const { branch } = branchParsed.data const repo = getRepoById(db, repoId) if (!repo) return c.json({ error: 'repo not found' }, 404) + if (!(await isValidBranchName(repo.fullPath, branch))) return c.json({ error: 'invalid branch name' }, 400) try { - const plan = await planMirrorTarget(db, repo, branch) - const response: MirrorTargetPlanResponse = plan.kind === 'new' - ? { kind: plan.kind, repoId: null, fullPath: plan.fullPath, localPath: plan.localPath, branch, currentBranch: plan.currentBranch } - : { kind: plan.kind, repoId: plan.repo.id, fullPath: plan.repo.fullPath, localPath: plan.repo.localPath, branch, currentBranch: plan.currentBranch } + const env = gitAuthService.getGitEnvironment() + const [main, siblings, gitWorktrees, localBranches, branchHead] = await Promise.all([ + checkoutState(repo.fullPath), + listMirrorCheckoutSiblings(db, repo, gitAuthService, openCodeClient), + listGitWorktrees(repo.fullPath, env), + listLocalBranchNames(repo.fullPath), + branchHeadSha(repo.fullPath, branch), + ]) + + const branchByCanonicalPath = new Map() + const checkedOut = new Set() + for (const worktree of gitWorktrees) { + branchByCanonicalPath.set(canonicalPathSync(resolve(worktree.path)), worktree.branch) + if (worktree.branch) checkedOut.add(worktree.branch) + } + + const candidates = siblings + .map((sibling) => ({ + sibling, + rank: mirrorWorktreeBranchRank( + branchByCanonicalPath.get(canonicalPathSync(resolve(sibling.fullPath))) ?? null, + branch, + ), + })) + .filter((candidate): candidate is { sibling: RepoSibling; rank: number } => candidate.rank !== null) + .sort((left, right) => left.rank - right.rank) + + const worktrees = await Promise.all(candidates.map((candidate) => checkoutState(candidate.sibling.fullPath))) + + const response: MirrorCheckoutsResponse = { + main, + worktrees, + branchHead, + branchCheckedOut: checkedOut.has(branch), + suffixedWorktreeBranch: nextSuffixedWorktreeBranch(branch, localBranches, checkedOut), + } return c.json(response) } catch (error) { - logger.error('mirror target plan failed:', error) + logger.error('mirror checkouts resolution failed:', error) return c.json({ error: getErrorMessage(error) }, 500) } }) - app.post('/:repoId/mirror/target', async (c) => { + app.post('/:repoId/mirror/worktree', async (c) => { const repoId = Number(c.req.param('repoId')) if (!Number.isFinite(repoId)) return c.json({ error: 'invalid repoId' }, 400) - let json: unknown - try { - json = await c.req.json() - } catch { - return c.json({ error: 'invalid json body' }, 400) - } - const branchParsed = MirrorTargetBranchRequestSchema.safeParse(json) - if (!branchParsed.success) return c.json({ error: 'branch required' }, 400) - const { branch } = branchParsed.data + + const branch = c.req.header('x-ocm-branch')?.trim() + const targetBranch = c.req.header('x-ocm-target-branch')?.trim() + if (!branch || !targetBranch) return c.json({ error: 'branch and target branch required' }, 400) + const repo = getRepoById(db, repoId) if (!repo) return c.json({ error: 'repo not found' }, 404) + if (!(await isValidBranchName(repo.fullPath, branch)) || !(await isValidBranchName(repo.fullPath, targetBranch))) { + return c.json({ error: 'invalid branch name' }, 400) + } + + const patchBytesHeader = c.req.header('x-ocm-patch-bytes') + let patchBytes = 0 + if (patchBytesHeader !== undefined) { + if (!/^\d+$/.test(patchBytesHeader.trim())) return c.json({ error: 'invalid patch length' }, 400) + patchBytes = Number(patchBytesHeader.trim()) + } + + const gitWorktrees = await listGitWorktrees(repo.fullPath, gitAuthService.getGitEnvironment()) + if (gitWorktrees.some((worktree) => worktree.branch === targetBranch)) { + return c.json({ error: 'branch_in_use', message: `branch '${targetBranch}' is checked out in a worktree of this repository` }, 409) + } + + const rawBody = c.req.raw.body + if (!rawBody) return c.json({ error: 'no body provided' }, 400) + + let bundleDir: string | undefined try { - const { repo: target, created } = await ensureMirrorTarget(db, repo, branch) - const response: MirrorTargetEnsureResponse = { - repoId: target.id, - fullPath: target.fullPath, - localPath: target.localPath, - branch, - created, + const staged = await stageBundleFromBody(rawBody, 'bundle-upload-', patchBytes) + bundleDir = staged.bundleDir + const worktree = await repoWorkspaces.create(repo, { + name: sanitizeRepoDirectoryName(targetBranch, 'move'), + initialize: async (directory) => { + await importBundle(directory, staged.bundlePath, branch, false, true, targetBranch) + await applyMirrorPatch(directory, staged.patch) + }, + }) + const state = await checkoutState(worktree.directory) + const response: MirrorWorktreeCreateResponse = { + repoId: repo.id, + fullPath: worktree.directory, + branch: state.branch, + head: state.head, + created: true, + worktreeSetup: worktree.worktreeSetup, } return c.json(response) } catch (error) { - logger.error('mirror target ensure failed:', error) + logger.error('mirror worktree create failed:', error) return c.json({ error: getErrorMessage(error) }, 409) + } finally { + if (bundleDir) await fsp.rm(bundleDir, { recursive: true, force: true }).catch(() => {}) } }) @@ -497,14 +705,15 @@ export function createInternalRepoMirrorRoutes(db: Database) { const repo = getRepoById(db, repoId) if (!repo) return c.json({ error: 'repo not found' }, 404) - const branchName = await safeGitOut(repo.fullPath, ['rev-parse', '--abbrev-ref', 'HEAD']) - const head = await safeGitOut(repo.fullPath, ['rev-parse', 'HEAD']) - const status = await safeGitOut(repo.fullPath, ['status', '--porcelain', '--untracked-files=all']) + const directory = await resolveMirrorDirectory(db, repo, c.req.query('directory'), gitAuthService, openCodeClient) + if (!directory) return c.json({ error: 'Directory is not part of this repository' }, 400) + + const state = await checkoutState(directory) return c.json({ repoId: repo.id, - branch: branchName?.trim() || null, - head: head?.trim() || null, - dirty: (status?.trim().length ?? 0) > 0, + branch: state.branch, + head: state.head, + dirty: state.dirty, }) }) @@ -517,7 +726,10 @@ export function createInternalRepoMirrorRoutes(db: Database) { const repo = getRepoById(db, repoId) if (!repo) return c.json({ error: 'repo not found' }, 404) - const ancestry = await safeGitOut(repo.fullPath, ['merge-base', '--is-ancestor', sha, 'HEAD']) + const directory = await resolveMirrorDirectory(db, repo, c.req.query('directory'), gitAuthService, openCodeClient) + if (!directory) return c.json({ error: 'Directory is not part of this repository' }, 400) + + const ancestry = await safeGitOut(directory, ['merge-base', '--is-ancestor', sha, 'HEAD']) return c.json({ repoId: repo.id, contained: ancestry !== null }) }) @@ -528,14 +740,16 @@ export function createInternalRepoMirrorRoutes(db: Database) { const repo = getRepoById(db, repoId) if (!repo) return c.json({ error: 'repo not found' }, 404) + const directory = await resolveMirrorDirectory(db, repo, c.req.query('directory'), gitAuthService, openCodeClient) + if (!directory) return c.json({ error: 'Directory is not part of this repository' }, 400) + try { - const branchName = await safeGitOut(repo.fullPath, ['rev-parse', '--abbrev-ref', 'HEAD']) - const head = await safeGitOut(repo.fullPath, ['rev-parse', 'HEAD']) - const patch = await createMirrorPatch(repo.fullPath) + const state = await checkoutState(directory) + const patch = await createMirrorPatch(directory) return c.json({ repoId: repo.id, - branch: branchName?.trim() || null, - head: head?.trim() || null, + branch: state.branch, + head: state.head, patch, }) } catch (error) { @@ -559,31 +773,36 @@ export function createInternalRepoMirrorRoutes(db: Database) { const repo = getRepoById(db, repoId) if (!repo) return c.json({ error: 'repo not found' }, 404) if (!body.patch && body.patch !== '') return c.json({ error: 'patch required' }, 400) - if (body.force !== true && isRepoInUse(db, repoId)) { + if (body.directory !== undefined && typeof body.directory !== 'string') { + return c.json({ error: 'Directory is not part of this repository' }, 400) + } + + const directory = await resolveMirrorDirectory(db, repo, body.directory, gitAuthService, openCodeClient) + if (!directory) return c.json({ error: 'Directory is not part of this repository' }, 400) + + if (body.force !== true && isDirectoryInUse(directory)) { return c.json({ error: 'repo_in_use', message: 'open OpenCode sessions are using this repo; rerun with force=1' }, 409) } try { - const currentHead = await safeGitOut(repo.fullPath, ['rev-parse', 'HEAD']) - const currentHeadTrimmed = currentHead?.trim() || null + const state = await checkoutState(directory) const baseHead = body.baseHead?.trim() || null - if (baseHead && currentHeadTrimmed && baseHead !== currentHeadTrimmed) { + if (baseHead && state.head && baseHead !== state.head) { return c.json({ error: 'head_mismatch', message: 'Manager repo HEAD differs from patch base' }, 409) } - await applyMirrorPatch(repo.fullPath, body.patch) - - const branchName = await safeGitOut(repo.fullPath, ['rev-parse', '--abbrev-ref', 'HEAD']) - const head = await safeGitOut(repo.fullPath, ['rev-parse', 'HEAD']) + await applyMirrorPatch(directory, body.patch) - if (branchName) updateRepoBranch(db, repoId, branchName.trim()) - updateLastPulled(db, repoId) + if (directory === repo.fullPath) { + if (state.branch) updateRepoBranch(db, repoId, state.branch) + updateLastPulled(db, repoId) + } return c.json({ repoId, - fullPath: repo.fullPath, - branch: branchName?.trim() || null, - head: head?.trim() || null, + fullPath: directory, + branch: state.branch, + head: state.head, created: false, applied: true, }) diff --git a/backend/src/routes/repo-directory.ts b/backend/src/routes/repo-directory.ts index dae4cf28f..24b2b29dd 100644 --- a/backend/src/routes/repo-directory.ts +++ b/backend/src/routes/repo-directory.ts @@ -1,7 +1,7 @@ import type { Context } from 'hono' import type { Database } from 'bun:sqlite' import type { Repo } from '@opencode-manager/shared/types' -import { getSiblingRepos, resolveRepoOrAssistant, resolveRepoWorkingDirectory } from '../services/repo' +import { listRepoSiblings, resolveRepoOrAssistant, resolveRepoWorkingDirectory } from '../services/repo' import { getRepoById } from '../db/queries' import type { GitAuthService } from '../services/git-auth' import type { OpenCodeClient } from '../services/opencode/client' @@ -36,12 +36,11 @@ export async function resolveRepoRequestDirectory( } const resolved = await resolveRepoWorkingDirectory(repo, directory, () => - getSiblingRepos( + listRepoSiblings( deps.database, repo.id, deps.gitAuthService.getGitEnvironment(), deps.openCodeClient, - { includeBranch: false }, ), ) diff --git a/backend/src/services/repo-workspace.ts b/backend/src/services/repo-workspace.ts index 4a393b764..0b90f4f43 100644 --- a/backend/src/services/repo-workspace.ts +++ b/backend/src/services/repo-workspace.ts @@ -7,10 +7,13 @@ import type { OpenCodeClient } from './opencode/client' import type { ProjectConfigService } from './project-config' import type { TerminalService } from './terminal' import type { ScheduleService } from './schedules' -import { findSiblingByDirectory, getSiblingRepos, removeWorktree, RepoWorkspaceError, resolveRepoProjectId } from './repo' +import { findSiblingByDirectory, listRepoSiblings, removeWorktree, RepoWorkspaceError, resolveRepoProjectId } from './repo' import { getErrorMessage } from '../utils/error-utils' -/** Single owner of repo worktree lifecycle side effects: worktree setup on create, and terminal cleanup plus owner-specific removal on remove. */ +/** + * Single owner of repo worktree lifecycle side effects: worktree setup on create (after an + * optional caller-provided initializer), and terminal cleanup plus owner-specific removal on remove. + */ export class RepoWorkspaceService { constructor( private readonly database: Database, @@ -21,13 +24,30 @@ export class RepoWorkspaceService { private readonly scheduleWorktrees: Pick, ) {} - async create(repo: Repo, options: { name?: string; ref?: string } = {}) { + async create( + repo: Repo, + options: { name?: string; ref?: string; initialize?: (directory: string) => Promise } = {}, + ) { const projectID = await resolveRepoProjectId(this.openCodeClient, repo.fullPath) const worktree = await this.openCodeClient.api.worktree.create({ projectID, ...(options.name ? { name: options.name } : {}), ...(options.ref ? { branch: options.ref } : {}), }) + + if (options.initialize) { + try { + await options.initialize(worktree.directory) + } catch (error: unknown) { + try { + await this.openCodeClient.api.worktree.remove({ projectID, directory: worktree.directory, force: true }) + } catch (removeError: unknown) { + logger.warn(`Failed to remove worktree ${worktree.directory} after initialization failed:`, removeError) + } + throw error + } + } + const worktreeSetup = await this.projectConfigService.runWorktreeSetupForRepo(repo, worktree.directory, this.terminalService) return { ...worktree, worktreeSetup } } @@ -77,14 +97,13 @@ export class RepoWorkspaceService { } private async listWorktreeSiblings(repoId: number) { - const siblings = await getSiblingRepos( + return listRepoSiblings( this.database, repoId, this.gitAuthService.getGitEnvironment(), this.openCodeClient, - { includeBranch: false }, + isWorktreeSibling, ) - return siblings.filter(isWorktreeSibling) } private async removeTerminals(directory: string): Promise { diff --git a/backend/src/services/repo.ts b/backend/src/services/repo.ts index 4b8ba817f..f40da99a0 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 } from '@opencode-manager/shared/config/env' -import { normalizeRepoDirectoryName, sanitizeRepoDirectoryName, sanitizeBranchForDirectory, getRepoBaseDirectoryName, normalizeRepoUrlForCompare, isSSHUrl, normalizeSSHUrl, SCP_STYLE_URL_PATTERN, ASSISTANT_REPO_ID, type RepoSibling, type RepoWorktreeSource } from '@opencode-manager/shared/utils' +import { normalizeRepoDirectoryName, sanitizeRepoDirectoryName, sanitizeBranchForDirectory, normalizeRepoUrlForCompare, isSSHUrl, normalizeSSHUrl, SCP_STYLE_URL_PATTERN, ASSISTANT_REPO_ID, type RepoSibling, type RepoWorktreeSource } from '@opencode-manager/shared/utils' import type { GitAuthService } from './git-auth' import { isGitHubHttpsUrl } from '../utils/git-auth' import path from 'path' @@ -1076,61 +1076,6 @@ 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 fullPath = path.join(getReposPath(), localPath) - const existing = getRepoByLocalPath(database, localPath) - - if (existing) { - if (existing.branch !== branch) { - throw new Error(`Mirror target '${localPath}' is occupied by repo ${existing.id} registered for branch '${existing.branch ?? 'none'}' instead of '${branch}'`) - } - - if (!existsSync(existing.fullPath)) { - throw new Error(`Repo ${existing.id} for branch '${branch}' is missing its worktree directory at '${existing.fullPath}'`) - } - - const checkedOutBranch = await safeGetCurrentBranch(existing.fullPath, {}) - if (checkedOutBranch !== branch) { - throw new Error(`Repo ${existing.id} for branch '${branch}' has branch '${checkedOutBranch ?? 'none'}' checked out at '${existing.fullPath}'`) - } - - return { kind: 'existing', repo: existing, currentBranch } - } - - return { kind: 'new', localPath, fullPath, currentBranch } -} - -export async function ensureMirrorTarget(database: Database, repo: Repo, branch: string): Promise<{ repo: Repo; created: boolean }> { - const plan = await planMirrorTarget(database, repo, branch) - if (plan.kind !== 'new') return { repo: plan.repo, created: false } - - await createWorktreeSafely(repo.fullPath, plan.fullPath, branch, {}) - - try { - const worktreeRepo = createRepo(database, repo.repoUrl - ? { repoUrl: repo.repoUrl, localPath: plan.localPath, branch, defaultBranch: branch, cloneStatus: 'ready', clonedAt: Date.now(), isWorktree: true } - : { isLocal: true, localPath: plan.localPath, branch, defaultBranch: branch, cloneStatus: 'ready', clonedAt: Date.now(), isWorktree: true }) - - if (worktreeRepo.localPath !== plan.localPath) { - throw new Error(`branch ${branch} is already registered as repo ${worktreeRepo.id} at ${worktreeRepo.fullPath}`) - } - - return { repo: worktreeRepo, created: true } - } catch (error: unknown) { - await removeWorktree(repo.fullPath, plan.fullPath) - throw error - } -} - export function ensureMirrorTargetPath(name: string): { fullPath: string; localPath: string } { const slugified = name .toLowerCase() @@ -1181,13 +1126,20 @@ export function createRepoRow( return { repo, created: true } } +export function isDirectoryInUse(directory: string): boolean { + const target = canonicalPathSync(path.resolve(directory)) + return sseAggregator.getActiveDirectories().some( + (activeDirectory) => canonicalPathSync(path.resolve(activeDirectory)) === target, + ) +} + export function isRepoInUse(db: Database, repoId: number): boolean { const repo = getRepoById(db, repoId) if (!repo) { return false } - return sseAggregator.getActiveDirectories().includes(repo.fullPath) + return isDirectoryInUse(repo.fullPath) } export async function resolveRepoProjectId(openCodeClient: OpenCodeClient, directory: string): Promise { @@ -1340,6 +1292,21 @@ export async function getSiblingRepos( return [...repoSiblings, ...worktreeSiblings] } +/** + * Lists a repository's siblings in its OpenCode project without resolving their branches, + * optionally narrowed by a caller-supplied filter (for example worktree-only siblings). + */ +export async function listRepoSiblings( + database: Database, + repoId: number, + gitEnv: Record, + openCodeClient: OpenCodeClient, + filter?: (sibling: RepoSibling) => boolean, +): Promise { + const siblings = await getSiblingRepos(database, repoId, gitEnv, openCodeClient, { includeBranch: false }) + return filter ? siblings.filter(filter) : siblings +} + async function listOpenCodeWorktrees(openCodeClient: OpenCodeClient | undefined, directory: string) { if (!openCodeClient) return [] try { diff --git a/backend/test/routes/internal/repo-mirror.test.ts b/backend/test/routes/internal/repo-mirror.test.ts index a1897799a..7cdb726d8 100644 --- a/backend/test/routes/internal/repo-mirror.test.ts +++ b/backend/test/routes/internal/repo-mirror.test.ts @@ -4,6 +4,7 @@ import { spawnSync } from 'child_process' import { mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'fs' import { join } from 'path' import { tmpdir } from 'os' +import { sanitizeRepoDirectoryName } from '@opencode-manager/shared/utils' const { mockGetActiveDirectories, mockGitOut, mockSafeGitOut, getTmpRoot, setTmpRoot } = vi.hoisted(() => { const mockGetActiveDirectories = vi.fn().mockReturnValue([]) @@ -55,21 +56,61 @@ 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() +const mockIsDirectoryInUse = vi.fn() +const mockResolveRepoWorkingDirectory = vi.fn() +const mockListRepoSiblings = vi.fn() +const mockListGitWorktrees = 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), + isDirectoryInUse: (...args: unknown[]) => mockIsDirectoryInUse(...args), + resolveRepoWorkingDirectory: (...args: unknown[]) => mockResolveRepoWorkingDirectory(...args), + listRepoSiblings: (...args: unknown[]) => mockListRepoSiblings(...args), + listGitWorktrees: (...args: unknown[]) => mockListGitWorktrees(...args), })) +const mockGetGitEnvironment = vi.fn().mockReturnValue({}) +const mockGitAuthService = { getGitEnvironment: mockGetGitEnvironment } +const mockRepoWorkspaces = { create: vi.fn() } +const mockOpenCodeClient = {} + import { createInternalRepoMirrorRoutes } from '../../../src/routes/internal/repo-mirror' const CHUNK_SIZE = 8 * 1024 * 1024 +let mockSiblingRows: Array> = [] + +function runGit(cwd: string, args: string[], input?: string) { + return spawnSync('git', args, { cwd, encoding: 'utf-8', input }) +} + +function initRepoAt(dir: string): string { + mkdirSync(dir, { recursive: true }) + runGit(dir, ['init', '-b', 'main'], '') + runGit(dir, ['config', 'user.email', 'test@test.com']) + runGit(dir, ['config', 'user.name', 'Test']) + return dir +} + +function commitFileAt(dir: string, file: string, content: string): string { + writeFileSync(join(dir, file), content) + runGit(dir, ['add', file]) + runGit(dir, ['commit', '-m', `add ${file}`]) + return runGit(dir, ['rev-parse', 'HEAD']).stdout.trim() +} + +function revOf(dir: string, ref: string): string { + return runGit(dir, ['rev-parse', '--verify', ref], '').stdout.trim() +} + +function bundleOf(dir: string, name: string): Buffer { + const bundlePath = join(getTmpRoot(), `${name}.bundle`) + runGit(dir, ['bundle', 'create', bundlePath, '--all']) + return readFileSync(bundlePath) +} + interface BeginResponse { uploadId: string repoId: number @@ -148,11 +189,30 @@ describe('internal-repo-mirror routes', () => { setTmpRoot(tmpRootValue) mkdirSync(tmpRootValue, { recursive: true }) app = new Hono() - app.route('/api/internal/repos', createInternalRepoMirrorRoutes({} as any)) + app.route('/api/internal/repos', createInternalRepoMirrorRoutes({} as any, mockOpenCodeClient as any, mockGitAuthService as any, mockRepoWorkspaces as any)) mockGetActiveDirectories.mockReturnValue([]) + mockSafeGitOut.mockImplementation(async (dir: string, args: string[]) => { + const result = runGit(dir, args, '') + return result.status === 0 ? result.stdout : null + }) mockEnsureMirrorTargetPath.mockReturnValue({ localPath: 'test-repo', fullPath: join(getTmpRoot(), 'test-repo') }) mockCreateRepoRow.mockImplementation((_db: any, input: any) => ({ repo: { id: 1, fullPath: input.fullPath, localPath: input.localPath }, created: true })) mockIsRepoInUse.mockReturnValue(false) + mockIsDirectoryInUse.mockReturnValue(false) + mockResolveRepoWorkingDirectory.mockImplementation(async (repo: any, directory: string | undefined) => { + if (directory === undefined || directory === repo.fullPath) return repo.fullPath + return null + }) + mockSiblingRows = [] + mockListRepoSiblings.mockImplementation(async (...args: unknown[]) => { + const filter = args[4] + return typeof filter === 'function' + ? mockSiblingRows.filter(filter as (sibling: Record) => boolean) + : mockSiblingRows + }) + mockListGitWorktrees.mockResolvedValue([]) + mockGetGitEnvironment.mockReturnValue({}) + mockRepoWorkspaces.create.mockReset() mockGetRepoById.mockReturnValue(null) }) @@ -244,34 +304,68 @@ describe('internal-repo-mirror routes', () => { describe('GET /:repoId/mirror/head', () => { it('returns head, branch and dirty state for the manager repo', async () => { - mockGetRepoById.mockReturnValue({ id: 7, fullPath: join(getTmpRoot(), 'repo7') }) - mockSafeGitOut.mockImplementation((_path: string, args: string[]) => { - if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref') return Promise.resolve('feature\n') - if (args[0] === 'rev-parse') return Promise.resolve('abc123\n') - if (args[0] === 'status') return Promise.resolve(' M file.txt\n') - return Promise.resolve(null) - }) + const repoDir = initRepoAt(join(getTmpRoot(), 'head-repo')) + const head = commitFileAt(repoDir, 'tracked.txt', 'first\n') + runGit(repoDir, ['checkout', '-b', 'feature']) + writeFileSync(join(repoDir, 'tracked.txt'), 'dirty\n') + mockGetRepoById.mockReturnValue({ id: 7, fullPath: repoDir }) const res = await app.request('/api/internal/repos/7/mirror/head') expect(res.status).toBe(200) const json = (await res.json()) as { head: string; branch: string; dirty: boolean } - expect(json.head).toBe('abc123') + expect(json.head).toBe(head) expect(json.branch).toBe('feature') expect(json.dirty).toBe(true) }) + it('reports a detached HEAD as a null branch', async () => { + const repoDir = initRepoAt(join(getTmpRoot(), 'head-detached')) + const head = commitFileAt(repoDir, 'tracked.txt', 'first\n') + runGit(repoDir, ['checkout', head]) + mockGetRepoById.mockReturnValue({ id: 9, fullPath: repoDir }) + + const res = await app.request('/api/internal/repos/9/mirror/head') + expect(res.status).toBe(200) + const json = (await res.json()) as { branch: string | null } + expect(json.branch).toBeNull() + }) + it('reports a clean repo as not dirty', async () => { - mockGetRepoById.mockReturnValue({ id: 8, fullPath: join(getTmpRoot(), 'repo8') }) - mockSafeGitOut.mockImplementation((_path: string, args: string[]) => { - if (args[0] === 'status') return Promise.resolve('') - return Promise.resolve('abc123\n') - }) + const repoDir = initRepoAt(join(getTmpRoot(), 'head-clean')) + commitFileAt(repoDir, 'tracked.txt', 'first\n') + mockGetRepoById.mockReturnValue({ id: 8, fullPath: repoDir }) const res = await app.request('/api/internal/repos/8/mirror/head') const json = (await res.json()) as { dirty: boolean } expect(json.dirty).toBe(false) }) + it('reads the checkout state of a directory inside the repository', async () => { + const repoDir = initRepoAt(join(getTmpRoot(), 'head-base')) + commitFileAt(repoDir, 'main.txt', 'base\n') + const worktreeDir = join(getTmpRoot(), 'head-worktree') + runGit(repoDir, ['worktree', 'add', '-b', 'side', worktreeDir]) + const sideHead = revOf(worktreeDir, 'HEAD') + mockGetRepoById.mockReturnValue({ id: 7, fullPath: repoDir }) + mockResolveRepoWorkingDirectory.mockResolvedValue(worktreeDir) + + const res = await app.request(`/api/internal/repos/7/mirror/head?directory=${encodeURIComponent(worktreeDir)}`) + expect(res.status).toBe(200) + const json = (await res.json()) as { head: string; branch: string } + expect(json.branch).toBe('side') + expect(json.head).toBe(sideHead) + }) + + it('returns 400 when the directory is not part of the repository', async () => { + mockGetRepoById.mockReturnValue({ id: 7, fullPath: join(getTmpRoot(), 'head-repo') }) + mockResolveRepoWorkingDirectory.mockResolvedValue(null) + + const res = await app.request('/api/internal/repos/7/mirror/head?directory=/elsewhere') + expect(res.status).toBe(400) + const json = (await res.json()) as { error: string } + expect(json.error).toBe('Directory is not part of this repository') + }) + it('returns 404 for a non-existent repo', async () => { mockGetRepoById.mockReturnValue(null) const res = await app.request('/api/internal/repos/99999/mirror/head') @@ -304,6 +398,29 @@ describe('internal-repo-mirror routes', () => { const res = await app.request('/api/internal/repos/7/mirror/contains/not-a-sha') expect(res.status).toBe(400) }) + + it('checks containment against a directory inside the repository', async () => { + const worktreeDir = join(getTmpRoot(), 'contains-worktree') + mockGetRepoById.mockReturnValue({ id: 7, fullPath: join(getTmpRoot(), 'repo7') }) + mockResolveRepoWorkingDirectory.mockResolvedValue(worktreeDir) + mockSafeGitOut.mockResolvedValue('') + + const res = await app.request(`/api/internal/repos/7/mirror/contains/abc1234?directory=${encodeURIComponent(worktreeDir)}`) + expect(res.status).toBe(200) + const json = (await res.json()) as { contained: boolean } + expect(json.contained).toBe(true) + expect(mockSafeGitOut).toHaveBeenCalledWith(worktreeDir, ['merge-base', '--is-ancestor', 'abc1234', 'HEAD']) + }) + + it('returns 400 when the directory is not part of the repository', async () => { + mockGetRepoById.mockReturnValue({ id: 7, fullPath: join(getTmpRoot(), 'repo7') }) + mockResolveRepoWorkingDirectory.mockResolvedValue(null) + + const res = await app.request('/api/internal/repos/7/mirror/contains/abc1234?directory=/elsewhere') + expect(res.status).toBe(400) + const json = (await res.json()) as { error: string } + expect(json.error).toBe('Directory is not part of this repository') + }) }) describe('patch sync flow', () => { @@ -447,6 +564,46 @@ describe('internal-repo-mirror routes', () => { expect(spawnSync('git', ['status', '--porcelain'], { cwd: baseDir, encoding: 'utf-8' }).stdout.trim()).toBe('') }) + it('checks out the target branch on a detached worktree while the main checkout keeps the source branch', async () => { + const sourceDir = initGitRepo('target-branch-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 bundle = makeBundle(sourceDir, 'target-branch') + + const baseDir = initGitRepo('target-branch-base') + gitCommitFile(baseDir, 'main.txt', 'base main\n') + gitCmd(baseDir, ['checkout', '-b', 'feature']) + const baseFeatureSha = gitCommitFile(baseDir, 'base-feature.txt', 'base feature\n') + + const worktreeDir = join(getTmpRoot(), 'target-branch-wt') + gitCmd(baseDir, ['worktree', 'add', '--detach', worktreeDir, baseFeatureSha]) + expect(currentBranchOf(worktreeDir)).toBe('HEAD') + + mockGetRepoById.mockReturnValue({ id: 1, fullPath: baseDir }) + mockResolveRepoWorkingDirectory.mockResolvedValue(worktreeDir) + + const res = await app.request(`/api/internal/repos/1/mirror/bundle?directory=${encodeURIComponent(worktreeDir)}`, { + method: 'POST', + body: bundle, + headers: { + 'content-type': 'application/octet-stream', + 'x-ocm-branch': 'feature', + 'x-ocm-target-branch': 'feature-ocm', + }, + }) + + expect(res.status).toBe(200) + expect(currentBranchOf(worktreeDir)).toBe('feature-ocm') + expect(revRef(worktreeDir, 'HEAD')).toBe(incomingFeatureSha) + expect(revRef(worktreeDir, 'refs/heads/feature-ocm')).toBe(incomingFeatureSha) + expect(currentBranchOf(baseDir)).toBe('feature') + expect(revRef(baseDir, 'refs/heads/feature')).toBe(baseFeatureSha) + expect(mockUpdateLastPulled).not.toHaveBeenCalled() + expect(mockUpdateRepoBranch).not.toHaveBeenCalled() + }) + function gitCmd(cwd: string, args: string[], input?: string) { return spawnSync('git', args, { cwd, encoding: 'utf-8', input }) } @@ -821,7 +978,7 @@ describe('internal-repo-mirror routes', () => { it('returns a patch snapshot for pull fast path', async () => { const repoDir = join(getTmpRoot(), 'snapshot-repo') mkdirSync(repoDir, { recursive: true }) - spawnSync('git', ['init'], { cwd: repoDir, stdio: 'ignore' }) + spawnSync('git', ['init', '-b', 'main'], { cwd: repoDir, stdio: 'ignore' }) spawnSync('git', ['config', 'user.email', 'test@test.com'], { cwd: repoDir, stdio: 'ignore' }) spawnSync('git', ['config', 'user.name', 'Test'], { cwd: repoDir, stdio: 'ignore' }) writeFileSync(join(repoDir, 'tracked.txt'), 'before\n') @@ -1137,246 +1294,438 @@ describe('internal-repo-mirror routes', () => { }) }) - describe('GET /:repoId/mirror/target', () => { - let repo: { id: number; fullPath: string; localPath: string } + describe('legacy mirror target routes', () => { + it('returns 410 for the old GET contract', async () => { + const res = await app.request('/api/internal/repos/1/mirror/target?branch=feature') + expect(res.status).toBe(410) + const json = (await res.json()) as { error: string; message: string } + expect(json.error).toBe('cli_too_old') + expect(json.message).toMatch(/upgrade to ocm-cli/i) + }) + + it('returns 410 for the old POST contract', async () => { + const res = await app.request('/api/internal/repos/1/mirror/target', { + method: 'POST', + body: JSON.stringify({ branch: 'feature' }), + headers: { 'content-type': 'application/json' }, + }) + expect(res.status).toBe(410) + const json = (await res.json()) as { error: string } + expect(json.error).toBe('cli_too_old') + }) + }) + + describe('GET /:repoId/mirror/checkouts', () => { + let baseDir: string beforeEach(() => { - repo = { id: 1, fullPath: join(getTmpRoot(), 'test-repo'), localPath: 'test-repo' } + baseDir = initRepoAt(join(getTmpRoot(), 'checkouts-base')) + commitFileAt(baseDir, 'main.txt', 'base\n') + runGit(baseDir, ['checkout', '-b', 'feature']) + commitFileAt(baseDir, 'feature.txt', 'feature\n') + runGit(baseDir, ['checkout', 'main']) + mockGetRepoById.mockReturnValue({ id: 1, fullPath: baseDir, localPath: 'checkouts-base' }) }) - 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' }) + function addWorktree(branch: string, dirName: string): string { + const dir = join(getTmpRoot(), dirName) + runGit(baseDir, ['worktree', 'add', '-b', branch, dir]) + return dir + } - const res = await app.request('/api/internal/repos/1/mirror/target?branch=feature') + it('orders matching worktrees, includes legacy repo rows and excludes schedule worktrees', async () => { + const featureHead = revOf(baseDir, 'refs/heads/feature') + const wtFeature = join(getTmpRoot(), 'checkouts-wt-feature') + runGit(baseDir, ['worktree', 'add', wtFeature, 'feature']) + const wtFeatureOcm = addWorktree('feature-ocm', 'checkouts-wt-ocm') + const legacyDir = addWorktree('feature-ocm-2', 'checkouts-legacy') + const wtFeatureOcm3 = addWorktree('feature-ocm-3', 'checkouts-wt-ocm-3') + const scheduleDir = join(getTmpRoot(), 'checkouts-schedule') + + mockSiblingRows = [ + { id: -1, fullPath: wtFeature, worktreeSource: 'opencode', isWorktree: true }, + { id: -1, fullPath: wtFeatureOcm, worktreeSource: 'opencode', isWorktree: true }, + { id: 5, fullPath: legacyDir, isWorktree: true }, + { id: -1, fullPath: scheduleDir, worktreeSource: 'schedule', isWorktree: true }, + { id: -1, fullPath: wtFeatureOcm3, worktreeSource: 'git', isWorktree: true }, + ] + mockListGitWorktrees.mockResolvedValue([ + { path: baseDir, branch: 'main' }, + { path: wtFeature, branch: 'feature' }, + { path: wtFeatureOcm, branch: 'feature-ocm' }, + { path: legacyDir, branch: 'feature-ocm-2' }, + { path: wtFeatureOcm3, branch: 'feature-ocm-3' }, + ]) + + const res = await app.request('/api/internal/repos/1/mirror/checkouts?branch=feature') expect(res.status).toBe(200) + const mainHead = revOf(baseDir, 'HEAD') expect(await res.json()).toEqual({ - kind: 'new', - repoId: null, - fullPath: join(getTmpRoot(), 'test-repo-feature'), - localPath: 'test-repo-feature', - branch: 'feature', - currentBranch: 'main', + main: { directory: baseDir, branch: 'main', head: mainHead, dirty: false }, + worktrees: [ + { directory: wtFeature, branch: 'feature', head: featureHead, dirty: false }, + { directory: wtFeatureOcm, branch: 'feature-ocm', head: mainHead, dirty: false }, + { directory: legacyDir, branch: 'feature-ocm-2', head: mainHead, dirty: false }, + { directory: wtFeatureOcm3, branch: 'feature-ocm-3', head: mainHead, dirty: false }, + ], + branchHead: featureHead, + branchCheckedOut: true, + suffixedWorktreeBranch: 'feature-ocm-4', }) - 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' }) + it('reports an absent branch with no worktrees and a free suffixed name', async () => { + mockListGitWorktrees.mockResolvedValue([{ path: baseDir, branch: 'main' }]) - const res = await app.request('/api/internal/repos/1/mirror/target?branch=feature') + const res = await app.request('/api/internal/repos/1/mirror/checkouts?branch=ghost') 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', + main: { directory: baseDir, branch: 'main', head: revOf(baseDir, 'HEAD'), dirty: false }, + worktrees: [], + branchHead: null, + branchCheckedOut: false, + suffixedWorktreeBranch: 'ghost-ocm', }) }) - 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' }) + it('skips suffixed names that already exist as local branches', async () => { + runGit(baseDir, ['branch', 'feature-ocm']) + runGit(baseDir, ['branch', 'feature-ocm-2']) + mockListGitWorktrees.mockResolvedValue([{ path: baseDir, branch: 'main' }]) - const res = await app.request('/api/internal/repos/1/mirror/target?branch=feature') + const res = await app.request('/api/internal/repos/1/mirror/checkouts?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', - }) + const json = (await res.json()) as { suffixedWorktreeBranch: string; branchCheckedOut: boolean; worktrees: unknown[] } + expect(json.suffixedWorktreeBranch).toBe('feature-ocm-3') + expect(json.branchCheckedOut).toBe(false) + expect(json.worktrees).toEqual([]) }) - it('returns 400 for a missing branch without calling the plan service', async () => { - const res = await app.request('/api/internal/repos/1/mirror/target') + it('returns 400 for a missing branch', async () => { + const res = await app.request('/api/internal/repos/1/mirror/checkouts') 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') + it.each(['-x', 'a..b'])('returns 400 for the invalid branch name %s', async (branch) => { + const res = await app.request(`/api/internal/repos/1/mirror/checkouts?branch=${encodeURIComponent(branch)}`) expect(res.status).toBe(400) const json = (await res.json()) as { error: string } - expect(json.error).toBe('branch required') - expect(mockPlanMirrorTarget).not.toHaveBeenCalled() + expect(json.error).toBe('invalid branch name') + expect(mockListGitWorktrees).not.toHaveBeenCalled() }) - it('trims the branch before calling the plan service', async () => { - mockGetRepoById.mockReturnValue(repo) - mockPlanMirrorTarget.mockResolvedValue({ kind: 'in-place', repo, currentBranch: 'feature' }) + it('accepts a valid branch name', async () => { + mockListGitWorktrees.mockResolvedValue([{ path: baseDir, branch: 'main' }]) - await app.request('/api/internal/repos/1/mirror/target?branch=%20feature%20') + const res = await app.request('/api/internal/repos/1/mirror/checkouts?branch=feature') - expect(mockPlanMirrorTarget).toHaveBeenCalledWith({}, repo, 'feature') + expect(res.status).toBe(200) }) it('returns 400 for an invalid repoId', async () => { - const res = await app.request('/api/internal/repos/abc/mirror/target?branch=feature') + const res = await app.request('/api/internal/repos/abc/mirror/checkouts?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') + const res = await app.request('/api/internal/repos/99999/mirror/checkouts?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', () => { + describe('POST /:repoId/mirror/worktree', () => { + let baseDir: string let repo: { id: number; fullPath: string; localPath: string } beforeEach(() => { - repo = { id: 1, fullPath: join(getTmpRoot(), 'test-repo'), localPath: 'test-repo' } + baseDir = initRepoAt(join(getTmpRoot(), 'wt-base')) + commitFileAt(baseDir, 'main.txt', 'base\n') + repo = { id: 1, fullPath: baseDir, localPath: 'wt-base' } + mockGetRepoById.mockReturnValue(repo) }) - async function request(branchBody: unknown): Promise { - const res = await app.request('/api/internal/repos/1/mirror/target', { + async function request(body: Buffer, headers: Record): Promise { + return app.request('/api/internal/repos/1/mirror/worktree', { method: 'POST', - body: JSON.stringify(branchBody), - headers: { 'content-type': 'application/json' }, + body, + headers: { 'content-type': 'application/octet-stream', ...headers }, }) - 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 }) + it('streams a bundle into a new worktree and reports its checkout state', async () => { + const sourceDir = initRepoAt(join(getTmpRoot(), 'wt-source')) + commitFileAt(sourceDir, 'main.txt', 'source main\n') + runGit(sourceDir, ['checkout', '-b', 'feature']) + const incomingFeatureSha = commitFileAt(sourceDir, 'feature.txt', 'source feature\n') + runGit(sourceDir, ['checkout', 'main']) + const bundle = bundleOf(sourceDir, 'wt-source') + + const worktreeDir = join(getTmpRoot(), 'wt-created') + runGit(baseDir, ['worktree', 'add', '-b', 'feature-ocm', worktreeDir]) + + mockListGitWorktrees.mockResolvedValue([{ path: baseDir, branch: 'main' }]) + mockRepoWorkspaces.create.mockImplementation(async (_repo: unknown, options: { initialize: (directory: string) => Promise }) => { + await options.initialize(worktreeDir) + return { directory: worktreeDir, worktreeSetup: { status: 'none' } } + }) - const res = await request({ branch: 'feature' }) + const res = await request(bundle, { 'x-ocm-branch': 'feature', 'x-ocm-target-branch': 'feature-ocm' }) expect(res.status).toBe(200) expect(await res.json()).toEqual({ - repoId: 5, - fullPath: targetRepo.fullPath, - localPath: 'test-repo-wt', - branch: 'feature', + repoId: 1, + fullPath: worktreeDir, + branch: 'feature-ocm', + head: incomingFeatureSha, created: true, + worktreeSetup: { status: 'none' }, }) - expect(mockEnsureMirrorTarget).toHaveBeenCalledWith({}, repo, 'feature') + expect(mockRepoWorkspaces.create).toHaveBeenCalledWith( + expect.objectContaining({ id: 1, fullPath: baseDir }), + expect.objectContaining({ name: sanitizeRepoDirectoryName('feature-ocm', 'move') }), + ) + expect(readFileSync(join(worktreeDir, 'feature.txt'), 'utf-8')).toBe('source feature\n') }) - it('reports created=false when the target already exists', async () => { - mockGetRepoById.mockReturnValue(repo) - mockEnsureMirrorTarget.mockResolvedValue({ repo, created: false }) + it('applies the leading working-tree patch inside initialize before the worktree is reported', async () => { + const sourceDir = initRepoAt(join(getTmpRoot(), 'wt-patch-source')) + commitFileAt(sourceDir, 'main.txt', 'source main\n') + runGit(sourceDir, ['checkout', '-b', 'feature']) + commitFileAt(sourceDir, 'feature.txt', 'source feature\n') + const bundle = bundleOf(sourceDir, 'wt-patch-source') + + writeFileSync(join(sourceDir, 'main.txt'), 'patched main\n') + writeFileSync(join(sourceDir, 'untracked.txt'), 'patched untracked\n') + runGit(sourceDir, ['add', '-N', 'untracked.txt']) + const patchBuf = Buffer.from(runGit(sourceDir, ['diff', '--binary', 'HEAD']).stdout, 'utf-8') + + const worktreeDir = join(getTmpRoot(), 'wt-patch-created') + runGit(baseDir, ['worktree', 'add', '-b', 'feature-ocm', worktreeDir]) + + mockListGitWorktrees.mockResolvedValue([{ path: baseDir, branch: 'main' }]) + let mainDuringInitialize: string | undefined + let untrackedDuringInitialize: string | undefined + mockRepoWorkspaces.create.mockImplementation(async (_repo: unknown, options: { initialize: (directory: string) => Promise }) => { + await options.initialize(worktreeDir) + mainDuringInitialize = readFileSync(join(worktreeDir, 'main.txt'), 'utf-8') + untrackedDuringInitialize = readFileSync(join(worktreeDir, 'untracked.txt'), 'utf-8') + return { directory: worktreeDir, worktreeSetup: { status: 'none' } } + }) - const res = await request({ branch: 'feature' }) + const res = await request(Buffer.concat([patchBuf, bundle]), { + 'x-ocm-branch': 'feature', + 'x-ocm-target-branch': 'feature-ocm', + 'x-ocm-patch-bytes': String(patchBuf.length), + }) 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) + expect(mainDuringInitialize).toBe('patched main\n') + expect(untrackedDuringInitialize).toBe('patched untracked\n') + expect(readFileSync(join(worktreeDir, 'main.txt'), 'utf-8')).toBe('patched main\n') + expect(readFileSync(join(worktreeDir, 'untracked.txt'), 'utf-8')).toBe('patched untracked\n') }) - 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' }, + it.each(['abc', '-1', '1.5'])('returns 400 for an invalid x-ocm-patch-bytes value %s', async (value) => { + const res = await request(Buffer.from('bundle'), { + 'x-ocm-branch': 'feature', + 'x-ocm-target-branch': 'feature-ocm', + 'x-ocm-patch-bytes': value, }) expect(res.status).toBe(400) - expect(mockEnsureMirrorTarget).not.toHaveBeenCalled() - expect(mockGetRepoById).not.toHaveBeenCalled() + const json = (await res.json()) as { error: string } + expect(json.error).toBe('invalid patch length') + expect(mockListGitWorktrees).not.toHaveBeenCalled() + expect(mockRepoWorkspaces.create).not.toHaveBeenCalled() }) - it('returns 400 for a missing branch without calling services', async () => { - const res = await request({}) + it('returns 409 when the declared patch length exceeds the body', async () => { + mockListGitWorktrees.mockResolvedValue([{ path: baseDir, branch: 'main' }]) - expect(res.status).toBe(400) + const res = await request(Buffer.from('bundle-bytes'), { + 'x-ocm-branch': 'feature', + 'x-ocm-target-branch': 'feature-ocm', + 'x-ocm-patch-bytes': '999999', + }) + + expect(res.status).toBe(409) const json = (await res.json()) as { error: string } - expect(json.error).toBe('branch required') - expect(mockEnsureMirrorTarget).not.toHaveBeenCalled() - expect(mockGetRepoById).not.toHaveBeenCalled() + expect(json.error).toContain('patch length') + expect(mockRepoWorkspaces.create).not.toHaveBeenCalled() + }) + + it('returns 409 when the target branch is already checked out', async () => { + mockListGitWorktrees.mockResolvedValue([{ path: baseDir, branch: 'feature-ocm' }]) + + const res = await request(Buffer.from('bundle'), { 'x-ocm-branch': 'feature', 'x-ocm-target-branch': 'feature-ocm' }) + + expect(res.status).toBe(409) + const json = (await res.json()) as { error: string } + expect(json.error).toBe('branch_in_use') + expect(mockRepoWorkspaces.create).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 }) + it('returns 400 when the branch header is missing', async () => { + const res = await request(Buffer.from('bundle'), { 'x-ocm-target-branch': 'feature-ocm' }) expect(res.status).toBe(400) - expect(mockEnsureMirrorTarget).not.toHaveBeenCalled() + expect(mockRepoWorkspaces.create).not.toHaveBeenCalled() }) - it('returns 400 for a whitespace-only branch without calling services', async () => { - const res = await request({ branch: ' ' }) + it('returns 400 when the target branch header is missing', async () => { + const res = await request(Buffer.from('bundle'), { 'x-ocm-branch': 'feature' }) expect(res.status).toBe(400) - expect(mockEnsureMirrorTarget).not.toHaveBeenCalled() + expect(mockRepoWorkspaces.create).not.toHaveBeenCalled() }) - it('trims the branch before calling the ensure service', async () => { - mockGetRepoById.mockReturnValue(repo) - mockEnsureMirrorTarget.mockResolvedValue({ repo, created: false }) + it.each(['-x', 'a..b'])('returns 400 when the branch header is %s', async (branch) => { + const res = await request(Buffer.from('bundle'), { 'x-ocm-branch': branch, 'x-ocm-target-branch': 'feature-ocm' }) + + expect(res.status).toBe(400) + const json = (await res.json()) as { error: string } + expect(json.error).toBe('invalid branch name') + expect(mockListGitWorktrees).not.toHaveBeenCalled() + expect(mockRepoWorkspaces.create).not.toHaveBeenCalled() + }) - await request({ branch: ' feature ' }) + it.each(['-x', 'a..b'])('returns 400 when the target branch header is %s', async (targetBranch) => { + const res = await request(Buffer.from('bundle'), { 'x-ocm-branch': 'feature', 'x-ocm-target-branch': targetBranch }) - expect(mockEnsureMirrorTarget).toHaveBeenCalledWith({}, repo, 'feature') + expect(res.status).toBe(400) + const json = (await res.json()) as { error: string } + expect(json.error).toBe('invalid branch name') + expect(mockListGitWorktrees).not.toHaveBeenCalled() + expect(mockRepoWorkspaces.create).not.toHaveBeenCalled() }) it('returns 400 for an invalid repoId', async () => { - const res = await app.request('/api/internal/repos/abc/mirror/target', { + const res = await app.request('/api/internal/repos/abc/mirror/worktree', { method: 'POST', - body: JSON.stringify({ branch: 'feature' }), - headers: { 'content-type': 'application/json' }, + body: Buffer.from('bundle'), + headers: { 'content-type': 'application/octet-stream', 'x-ocm-branch': 'feature', 'x-ocm-target-branch': 'feature-ocm' }, }) expect(res.status).toBe(400) - expect(mockEnsureMirrorTarget).not.toHaveBeenCalled() + expect(mockRepoWorkspaces.create).not.toHaveBeenCalled() }) it('returns 404 for a non-existent repo', async () => { mockGetRepoById.mockReturnValue(null) - const res = await request({ branch: 'feature' }) + const res = await request(Buffer.from('bundle'), { 'x-ocm-branch': 'feature', 'x-ocm-target-branch': 'feature-ocm' }) expect(res.status).toBe(404) - expect(mockEnsureMirrorTarget).not.toHaveBeenCalled() + expect(mockRepoWorkspaces.create).not.toHaveBeenCalled() }) - it('returns 409 when ensure fails', async () => { - mockGetRepoById.mockReturnValue(repo) - mockEnsureMirrorTarget.mockRejectedValue(new Error('git worktree add failed')) + it('returns 409 when worktree creation fails', async () => { + mockListGitWorktrees.mockResolvedValue([{ path: baseDir, branch: 'main' }]) + mockRepoWorkspaces.create.mockRejectedValue(new Error('worktree create failed')) - const res = await request({ branch: 'feature' }) + const res = await request(Buffer.from('bundle'), { 'x-ocm-branch': 'feature', 'x-ocm-target-branch': 'feature-ocm' }) expect(res.status).toBe(409) const json = (await res.json()) as { error: string } - expect(json.error).toContain('git worktree add failed') + expect(json.error).toContain('worktree create failed') + }) + }) + + describe('mirror directory scoping', () => { + it('serves a bundle for a directory inside the repository', async () => { + const repoDir = initRepoAt(join(getTmpRoot(), 'dir-bundle-base')) + commitFileAt(repoDir, 'main.txt', 'base\n') + const worktreeDir = join(getTmpRoot(), 'dir-bundle-wt') + runGit(repoDir, ['worktree', 'add', '-b', 'side', worktreeDir]) + mockGetRepoById.mockReturnValue({ id: 1, fullPath: repoDir }) + mockResolveRepoWorkingDirectory.mockResolvedValue(worktreeDir) + + const res = await app.request(`/api/internal/repos/1/mirror/bundle?directory=${encodeURIComponent(worktreeDir)}`) + + expect(res.status).toBe(200) + const body = Buffer.from(await res.arrayBuffer()) + expect(body.length).toBeGreaterThan(0) + }) + + it('returns 400 when the bundle download directory is not part of the repository', async () => { + mockGetRepoById.mockReturnValue({ id: 1, fullPath: join(getTmpRoot(), 'repo') }) + mockResolveRepoWorkingDirectory.mockResolvedValue(null) + + const res = await app.request('/api/internal/repos/1/mirror/bundle?directory=/elsewhere') + + expect(res.status).toBe(400) + const json = (await res.json()) as { error: string } + expect(json.error).toBe('Directory is not part of this repository') + }) + + it('serves a patch snapshot for a directory inside the repository', async () => { + const repoDir = initRepoAt(join(getTmpRoot(), 'dir-patch-base')) + commitFileAt(repoDir, 'main.txt', 'base\n') + const worktreeDir = join(getTmpRoot(), 'dir-patch-wt') + runGit(repoDir, ['worktree', 'add', '-b', 'side', worktreeDir]) + writeFileSync(join(worktreeDir, 'main.txt'), 'changed\n') + mockGetRepoById.mockReturnValue({ id: 1, fullPath: repoDir }) + mockResolveRepoWorkingDirectory.mockResolvedValue(worktreeDir) + + const res = await app.request(`/api/internal/repos/1/mirror/patch?directory=${encodeURIComponent(worktreeDir)}`) + + expect(res.status).toBe(200) + const json = (await res.json()) as { branch: string; patch: string } + expect(json.branch).toBe('side') + expect(json.patch).toContain('main.txt') + }) + + it('returns 400 when the patch download directory is not part of the repository', async () => { + mockGetRepoById.mockReturnValue({ id: 1, fullPath: join(getTmpRoot(), 'repo') }) + mockResolveRepoWorkingDirectory.mockResolvedValue(null) + + const res = await app.request('/api/internal/repos/1/mirror/patch?directory=/elsewhere') + + expect(res.status).toBe(400) + const json = (await res.json()) as { error: string } + expect(json.error).toBe('Directory is not part of this repository') + }) + + it('returns 400 when the bundle directory is not part of the repository', async () => { + mockGetRepoById.mockReturnValue({ id: 1, fullPath: join(getTmpRoot(), 'repo') }) + mockResolveRepoWorkingDirectory.mockResolvedValue(null) + + const res = await app.request('/api/internal/repos/1/mirror/bundle?directory=/elsewhere', { + method: 'POST', + body: Buffer.from('bundle'), + headers: { 'content-type': 'application/octet-stream' }, + }) + + expect(res.status).toBe(400) + const json = (await res.json()) as { error: string } + expect(json.error).toBe('Directory is not part of this repository') + }) + + it('returns 400 when the patch directory is not part of the repository', async () => { + mockGetRepoById.mockReturnValue({ id: 1, fullPath: join(getTmpRoot(), 'repo') }) + mockResolveRepoWorkingDirectory.mockResolvedValue(null) + + const res = await app.request('/api/internal/repos/1/mirror/patch', { + method: 'POST', + body: JSON.stringify({ patch: '', directory: '/elsewhere' }), + headers: { 'content-type': 'application/json' }, + }) + + expect(res.status).toBe(400) + const json = (await res.json()) as { error: string } + expect(json.error).toBe('Directory is not part of this repository') }) }) }) diff --git a/backend/test/routes/repo-project-config.test.ts b/backend/test/routes/repo-project-config.test.ts index d63117ce1..5bd9bda19 100644 --- a/backend/test/routes/repo-project-config.test.ts +++ b/backend/test/routes/repo-project-config.test.ts @@ -18,10 +18,10 @@ import type { TerminalInfo } from '@opencode-manager/shared/types' vi.mock('../../src/services/repo', () => ({ resolveRepoWorkingDirectory: vi.fn(), - getSiblingRepos: vi.fn(), + listRepoSiblings: vi.fn(), })) -import { resolveRepoWorkingDirectory, getSiblingRepos } from '../../src/services/repo' +import { resolveRepoWorkingDirectory, listRepoSiblings } from '../../src/services/repo' const gitAuthService = { getGitEnvironment: () => ({}) } as unknown as GitAuthService const openCodeClient = createStubOpenCodeClient() @@ -62,7 +62,7 @@ describe('Repo Project Config Routes', () => { beforeEach(() => { vi.clearAllMocks() - vi.mocked(getSiblingRepos).mockResolvedValue([]) + vi.mocked(listRepoSiblings).mockResolvedValue([]) vi.mocked(resolveRepoWorkingDirectory).mockImplementation(async (repo, directory) => { if (directory === undefined || directory === repo.fullPath) return repo.fullPath return null diff --git a/backend/test/routes/repo-terminal-socket.test.ts b/backend/test/routes/repo-terminal-socket.test.ts index 4a2685dcc..3531c55b4 100644 --- a/backend/test/routes/repo-terminal-socket.test.ts +++ b/backend/test/routes/repo-terminal-socket.test.ts @@ -16,10 +16,10 @@ import { startWebSocketServer, type WebSocketTestServer } from '../helpers/webso vi.mock('../../src/services/repo', () => ({ resolveRepoOrAssistant: vi.fn(), resolveRepoWorkingDirectory: vi.fn(), - getSiblingRepos: vi.fn(), + listRepoSiblings: vi.fn(), })) -import { resolveRepoOrAssistant, resolveRepoWorkingDirectory, getSiblingRepos } from '../../src/services/repo' +import { resolveRepoOrAssistant, resolveRepoWorkingDirectory, listRepoSiblings } from '../../src/services/repo' const readyRepo = { id: 1, fullPath: '/tmp/repo', cloneStatus: 'ready' } as Repo const database = {} as Database @@ -202,7 +202,7 @@ beforeAll(async () => { if (directory === undefined || directory === '/tmp/repo') return '/tmp/repo' return null }) - vi.mocked(getSiblingRepos).mockResolvedValue([]) + vi.mocked(listRepoSiblings).mockResolvedValue([]) }) afterAll(async () => { diff --git a/backend/test/routes/repo-terminals.test.ts b/backend/test/routes/repo-terminals.test.ts index 5e824041e..806f2ea5a 100644 --- a/backend/test/routes/repo-terminals.test.ts +++ b/backend/test/routes/repo-terminals.test.ts @@ -12,10 +12,10 @@ import { createStubOpenCodeClient } from '../helpers/stub-opencode-client' vi.mock('../../src/services/repo', () => ({ resolveRepoOrAssistant: vi.fn(), resolveRepoWorkingDirectory: vi.fn(), - getSiblingRepos: vi.fn(), + listRepoSiblings: vi.fn(), })) -import { resolveRepoOrAssistant, resolveRepoWorkingDirectory, getSiblingRepos } from '../../src/services/repo' +import { resolveRepoOrAssistant, resolveRepoWorkingDirectory, listRepoSiblings } from '../../src/services/repo' const database = {} as Database const gitAuthService = { getGitEnvironment: vi.fn(() => ({})) } as unknown as GitAuthService @@ -62,7 +62,7 @@ describe('Repo Terminal Routes', () => { if (directory === undefined || directory === '/tmp/repo') return '/tmp/repo' return null }) - vi.mocked(getSiblingRepos).mockResolvedValue([]) + vi.mocked(listRepoSiblings).mockResolvedValue([]) }) describe('GET /:id/terminals', () => { diff --git a/backend/test/routes/repos.test.ts b/backend/test/routes/repos.test.ts index a5c592438..f2a56ce4e 100644 --- a/backend/test/routes/repos.test.ts +++ b/backend/test/routes/repos.test.ts @@ -37,6 +37,7 @@ vi.mock('../../src/services/repo', () => ({ createBranch: vi.fn(), deleteRepoFiles: vi.fn(), getSiblingRepos: vi.fn(), + listRepoSiblings: vi.fn(), resolveRepoOrAssistant: vi.fn(), findSiblingByDirectory: vi.fn(), resolveRepoWorkingDirectory: vi.fn(), @@ -145,6 +146,7 @@ describe('Repo Routes', () => { vi.mocked(db.getRepoGitCredentialId).mockReturnValue(null) vi.mocked(db.getRepoSetting).mockReturnValue(null) vi.mocked(repoService.resolveRepoProjectId).mockResolvedValue('commit-A') + vi.mocked(repoService.listRepoSiblings).mockResolvedValue([]) vi.mocked(repoService.resolveRepoOrAssistant).mockImplementation( (_database, id) => vi.mocked(db.getRepoById)(_database, id) ?? (id === ASSISTANT_REPO_ID ? buildAssistantRepo() : null), ) @@ -911,10 +913,13 @@ describe('Repo Routes', () => { it('removes terminals for OpenCode workspace siblings but not manager worktree repos', async () => { vi.mocked(db.getRepoById).mockReturnValue(createMockRepo({ id: 1, fullPath: '/tmp/repos/test-repo' })) vi.mocked(repoService.deleteRepoFiles).mockResolvedValue(undefined) - vi.mocked(repoService.getSiblingRepos).mockResolvedValue([ - { ...createMockRepo({ id: 2, fullPath: '/tmp/repos/manager-worktree', isWorktree: true }), currentBranch: undefined }, - { ...createMockRepo({ id: -1, fullPath: '/tmp/plugin-workspace' }), currentBranch: undefined, worktreeSource: 'opencode' }, - ]) + vi.mocked(repoService.listRepoSiblings).mockImplementation(async (_database, _repoId, _gitEnv, _client, filter) => { + const siblings = [ + { ...createMockRepo({ id: 2, fullPath: '/tmp/repos/manager-worktree', isWorktree: true }), currentBranch: undefined }, + { ...createMockRepo({ id: -1, fullPath: '/tmp/plugin-workspace' }), currentBranch: undefined, worktreeSource: 'opencode' as const }, + ] + return filter ? siblings.filter(filter) : siblings + }) const app = createTestRoutes() const res = await app.request('/1', { method: 'DELETE' }) @@ -923,14 +928,14 @@ describe('Repo Routes', () => { expect(mockTerminalService.removeAll).toHaveBeenCalledWith('/tmp/repos/test-repo') expect(mockTerminalService.removeAll).toHaveBeenCalledWith('/tmp/plugin-workspace') expect(mockTerminalService.removeAll).not.toHaveBeenCalledWith('/tmp/repos/manager-worktree') - expect(repoService.getSiblingRepos).toHaveBeenCalledWith(mockDb, 1, {}, expect.anything(), { includeBranch: false }) + expect(repoService.listRepoSiblings).toHaveBeenCalledWith(mockDb, 1, {}, expect.anything(), expect.any(Function)) expect(repoService.deleteRepoFiles).toHaveBeenCalledWith(mockDb, 1) }) it('still deletes the repo when listing workspace siblings throws', async () => { vi.mocked(db.getRepoById).mockReturnValue(createMockRepo({ id: 1 })) vi.mocked(repoService.deleteRepoFiles).mockResolvedValue(undefined) - vi.mocked(repoService.getSiblingRepos).mockRejectedValue(new Error('siblings failed')) + vi.mocked(repoService.listRepoSiblings).mockRejectedValue(new Error('siblings failed')) const app = createTestRoutes() const res = await app.request('/1', { method: 'DELETE' }) diff --git a/backend/test/services/repo-git.test.ts b/backend/test/services/repo-git.test.ts index d5c423313..1d77238e1 100644 --- a/backend/test/services/repo-git.test.ts +++ b/backend/test/services/repo-git.test.ts @@ -774,41 +774,6 @@ describe('repo service real git', () => { }) describe('mirror helpers', () => { - it('plans and ensures a mirror worktree', async () => { - const { ensureMirrorTarget, planMirrorTarget } = await import('../../src/services/repo') - const baseName = uniqueName('mirror-base') - const basePath = path.join(reposPath, baseName) - createCommittedRepo(basePath) - const base = registerLocalRepo(baseName) - - const inPlace = await planMirrorTarget(db, base, 'main') - expect(inPlace.kind).toBe('in-place') - - const planned = await planMirrorTarget(db, base, 'feature/x') - expect(planned.kind).toBe('new') - - const created = await ensureMirrorTarget(db, base, 'feature/x') - expect(created.created).toBe(true) - expect(existsSync(created.repo.fullPath)).toBe(true) - - const existing = await ensureMirrorTarget(db, base, 'feature/x') - expect(existing.created).toBe(false) - expect(existing.repo.id).toBe(created.repo.id) - }) - - it('resolves the base directory name from a worktree repo row', async () => { - const { ensureMirrorTarget, planMirrorTarget } = await import('../../src/services/repo') - const baseName = uniqueName('mirror-name-base') - const basePath = path.join(reposPath, baseName) - createCommittedRepo(basePath) - const base = registerLocalRepo(baseName) - const created = await ensureMirrorTarget(db, base, 'feature/x') - - const planned = await planMirrorTarget(db, created.repo, 'other') - - expect(planned).toMatchObject({ kind: 'new', localPath: `${baseName}-other` }) - }) - it('creates a unique mirror target path', async () => { const { ensureMirrorTargetPath } = await import('../../src/services/repo') const name = uniqueName('Mirror Name') diff --git a/backend/test/services/repo-mirror-helpers.test.ts b/backend/test/services/repo-mirror-helpers.test.ts index 9095470ef..c8659df5a 100644 --- a/backend/test/services/repo-mirror-helpers.test.ts +++ b/backend/test/services/repo-mirror-helpers.test.ts @@ -243,6 +243,45 @@ describe('createRepoRow', () => { }) }) +describe('isDirectoryInUse', () => { + beforeEach(() => { + vi.clearAllMocks() + tmpRoot = path.join(os.tmpdir(), `mirror-test-${Date.now()}-${Math.random().toString(36).slice(2)}`) + fs.mkdirSync(tmpRoot, { recursive: true }) + }) + + afterEach(() => { + fs.rmSync(tmpRoot, { recursive: true, force: true }) + }) + + it('returns true when the active directory differs only by a trailing slash', async () => { + const active = path.join(tmpRoot, 'demo') + fs.mkdirSync(active, { recursive: true }) + mockGetActiveDirectories.mockReturnValue([`${active}${path.sep}`]) + + const { isDirectoryInUse } = await import('../../src/services/repo') + expect(isDirectoryInUse(active)).toBe(true) + }) + + it('returns true when the active directory is a symlink to the queried directory', async () => { + const real = path.join(tmpRoot, 'real') + const link = path.join(tmpRoot, 'link') + fs.mkdirSync(real, { recursive: true }) + fs.symlinkSync(real, link) + mockGetActiveDirectories.mockReturnValue([link]) + + const { isDirectoryInUse } = await import('../../src/services/repo') + expect(isDirectoryInUse(real)).toBe(true) + }) + + it('returns false when no active directory matches', async () => { + mockGetActiveDirectories.mockReturnValue([path.join(tmpRoot, 'other')]) + + const { isDirectoryInUse } = await import('../../src/services/repo') + expect(isDirectoryInUse(path.join(tmpRoot, 'demo'))).toBe(false) + }) +}) + describe('isRepoInUse', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/backend/test/services/repo-mirror-target.test.ts b/backend/test/services/repo-mirror-target.test.ts deleted file mode 100644 index eca8f6ee7..000000000 --- a/backend/test/services/repo-mirror-target.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest' -import { execSync } from 'child_process' -import { mkdtempSync, existsSync } from 'fs' -import { tmpdir } from 'os' -import { join } 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(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`) - 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: 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(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('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') - 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/backend/test/services/repo-workspace.test.ts b/backend/test/services/repo-workspace.test.ts index d4c640882..e34c13912 100644 --- a/backend/test/services/repo-workspace.test.ts +++ b/backend/test/services/repo-workspace.test.ts @@ -9,7 +9,7 @@ import { RepoWorkspaceError } from '../../src/services/repo' import { RepoWorkspaceService } from '../../src/services/repo-workspace' const mocks = vi.hoisted(() => ({ - getSiblingRepos: vi.fn(), + listRepoSiblings: vi.fn(), resolveRepoProjectId: vi.fn(), loggerWarn: vi.fn(), })) @@ -33,7 +33,16 @@ vi.mock('../../src/services/repo', () => { } return { - getSiblingRepos: mocks.getSiblingRepos, + listRepoSiblings: async ( + _database: unknown, + _repoId: unknown, + _gitEnv: unknown, + _client: unknown, + filter?: (sibling: { worktreeSource?: string }) => boolean, + ) => { + const siblings = await mocks.listRepoSiblings() as Array<{ worktreeSource?: string }> + return filter ? siblings.filter(filter) : siblings + }, resolveRepoProjectId: mocks.resolveRepoProjectId, findSiblingByDirectory: (siblings: T[], directory: string): T | undefined => siblings.find((sibling) => sibling.fullPath === directory), @@ -93,7 +102,7 @@ describe('RepoWorkspaceService', () => { beforeEach(() => { vi.clearAllMocks() mocks.resolveRepoProjectId.mockResolvedValue('project-1') - mocks.getSiblingRepos.mockResolvedValue([]) + mocks.listRepoSiblings.mockResolvedValue([]) }) describe('create', () => { @@ -125,13 +134,70 @@ describe('RepoWorkspaceService', () => { expect(worktreeCreate).toHaveBeenCalledWith({ projectID: 'project-1' }) }) + + it('runs the initializer before the worktree setup', async () => { + const order: string[] = [] + const worktreeCreate = vi.fn(async () => { + order.push('create') + return { directory: '/worktrees/feature-x' } + }) + const initialize = vi.fn(async () => { + order.push('initialize') + }) + const runWorktreeSetupForRepo = vi.fn(async () => { + order.push('setup') + return { status: 'started' as const } + }) + const { service } = createService({ worktreeCreate, runWorktreeSetupForRepo }) + + await service.create(REPO, { initialize }) + + expect(initialize).toHaveBeenCalledWith('/worktrees/feature-x') + expect(order).toEqual(['create', 'initialize', 'setup']) + }) + + it('removes the worktree and rethrows when the initializer fails, without running the setup', async () => { + const initializeError = new Error('seed failed') + const worktreeCreate = vi.fn(async () => ({ directory: '/worktrees/feature-x' })) + const worktreeRemove = vi.fn(async () => undefined) + const initialize = vi.fn(async () => { + throw initializeError + }) + const runWorktreeSetupForRepo = vi.fn(async () => ({ status: 'started' as const })) + const { service } = createService({ worktreeCreate, worktreeRemove, runWorktreeSetupForRepo }) + + await expect(service.create(REPO, { initialize })).rejects.toThrow(initializeError) + + expect(worktreeRemove).toHaveBeenCalledWith({ + projectID: 'project-1', + directory: '/worktrees/feature-x', + force: true, + }) + expect(runWorktreeSetupForRepo).not.toHaveBeenCalled() + }) + + it('logs and rethrows the initializer error when removing the worktree also fails', async () => { + const initializeError = new Error('seed failed') + const worktreeCreate = vi.fn(async () => ({ directory: '/worktrees/feature-x' })) + const worktreeRemove = vi.fn(async () => { + throw new Error('remove failed') + }) + const initialize = vi.fn(async () => { + throw initializeError + }) + const { service } = createService({ worktreeCreate, worktreeRemove }) + + await expect(service.create(REPO, { initialize })).rejects.toThrow(initializeError) + + expect(mocks.loggerWarn).toHaveBeenCalled() + }) }) describe('remove', () => { it('throws a 400 when the directory is not a worktree sibling and touches nothing', async () => { const worktreeRemove = vi.fn(async () => undefined) const removeAll = vi.fn(async () => undefined) - mocks.getSiblingRepos.mockResolvedValue([{ fullPath: '/worktrees/other', worktreeSource: 'opencode' }]) + mocks.listRepoSiblings.mockResolvedValue([{ fullPath: '/worktrees/other', worktreeSource: 'opencode' }]) const { service } = createService({ worktreeRemove, removeAll }) const error = await service.remove(REPO, '/worktrees/unknown').catch((caught: unknown) => caught) @@ -145,7 +211,7 @@ describe('RepoWorkspaceService', () => { it('removes terminals for the matched sibling before removing the worktree', async () => { const worktreeRemove = vi.fn(async () => undefined) const removeAll = vi.fn(async () => undefined) - mocks.getSiblingRepos.mockResolvedValue([{ fullPath: '/worktrees/feature-x', worktreeSource: 'opencode' }]) + mocks.listRepoSiblings.mockResolvedValue([{ fullPath: '/worktrees/feature-x', worktreeSource: 'opencode' }]) const { service } = createService({ worktreeRemove, removeAll }) await service.remove(REPO, '/worktrees/feature-x') @@ -164,7 +230,7 @@ describe('RepoWorkspaceService', () => { const removeAll = vi.fn(async () => { throw new Error('pty cleanup failed') }) - mocks.getSiblingRepos.mockResolvedValue([{ fullPath: '/worktrees/feature-x', worktreeSource: 'opencode' }]) + mocks.listRepoSiblings.mockResolvedValue([{ fullPath: '/worktrees/feature-x', worktreeSource: 'opencode' }]) const { service } = createService({ worktreeRemove, removeAll }) await expect(service.remove(REPO, '/worktrees/feature-x')).resolves.toBeUndefined() @@ -179,7 +245,7 @@ describe('RepoWorkspaceService', () => { it('removes a schedule worktree through its schedule without touching terminals', async () => { const worktreeRemove = vi.fn(async () => undefined) const removeAll = vi.fn(async () => undefined) - mocks.getSiblingRepos.mockResolvedValue([{ + mocks.listRepoSiblings.mockResolvedValue([{ fullPath: '/schedule-worktrees/job-7-shared', worktreeSource: 'schedule', schedule: { repoId: 3, jobId: 7, runId: null, inUse: false, name: 'Shared job' }, @@ -195,7 +261,7 @@ describe('RepoWorkspaceService', () => { it('refuses to remove a schedule worktree a running run is using', async () => { const removeAll = vi.fn(async () => undefined) - mocks.getSiblingRepos.mockResolvedValue([{ + mocks.listRepoSiblings.mockResolvedValue([{ fullPath: '/schedule-worktrees/job-7-run-2', worktreeSource: 'schedule', schedule: { repoId: 1, jobId: 7, runId: 2, inUse: true, name: 'Run job' }, @@ -209,7 +275,7 @@ describe('RepoWorkspaceService', () => { it('reports a plain git worktree that git refuses to remove', async () => { const worktreeRemove = vi.fn(async () => undefined) - mocks.getSiblingRepos.mockResolvedValue([{ fullPath: '/worktrees/manual', worktreeSource: 'git' }]) + mocks.listRepoSiblings.mockResolvedValue([{ fullPath: '/worktrees/manual', worktreeSource: 'git' }]) const { service } = createService({ worktreeRemove }) const error = await service.remove(REPO, '/worktrees/manual').catch((caught: unknown) => caught) @@ -223,7 +289,7 @@ describe('RepoWorkspaceService', () => { describe('removeRepoTerminals', () => { it('removes terminals for the repo directory and each worktree sibling', async () => { const removeAll = vi.fn(async () => undefined) - mocks.getSiblingRepos.mockResolvedValue([ + mocks.listRepoSiblings.mockResolvedValue([ { fullPath: '/worktrees/a', worktreeSource: 'opencode' }, { fullPath: '/worktrees/b', worktreeSource: 'opencode' }, { fullPath: '/repos/manager-worktree', isWorktree: true }, @@ -240,7 +306,7 @@ describe('RepoWorkspaceService', () => { it('still removes the repo terminals when listing siblings fails', async () => { const removeAll = vi.fn(async () => undefined) - mocks.getSiblingRepos.mockRejectedValue(new Error('siblings failed')) + mocks.listRepoSiblings.mockRejectedValue(new Error('siblings failed')) const { service } = createService({ removeAll }) await expect(service.removeRepoTerminals(REPO)).resolves.toBeUndefined() diff --git a/backend/test/services/repo.test.ts b/backend/test/services/repo.test.ts index 7e5159724..e0fe94996 100644 --- a/backend/test/services/repo.test.ts +++ b/backend/test/services/repo.test.ts @@ -5,7 +5,7 @@ import { getReposPath } from '@opencode-manager/shared/config/env' import type { GitAuthService } from '../../src/services/git-auth' import type { OpenCodeClient } from '../../src/services/opencode/client' import type { Repo } from '../../src/types/repo' -import type { RepoSibling } from '@opencode-manager/shared/utils' +import { isWorktreeSibling, type RepoSibling } from '@opencode-manager/shared/utils' import { migrate } from '../../src/db/migration-runner' import { allMigrations } from '../../src/db/migrations' @@ -606,6 +606,35 @@ describe('getSiblingRepos worktree API', () => { expect(siblings).toHaveLength(1) expect(siblings[0]?.id).toBe(1) }) + + describe('listRepoSiblings', () => { + it('returns every sibling without branch resolution when no filter is given', async () => { + const { listRepoSiblings } = await import('../../src/services/repo') + executeCommand.mockClear() + const client = createClient([{ directory: '/worktrees/feature-x', strategy: 'git' }]) + + const siblings = await listRepoSiblings(db, 1, {}, client) + + expect(siblings.map((sibling) => sibling.fullPath)).toEqual([ + path.join(getReposPath(), 'repo-a'), + '/worktrees/feature-x', + ]) + expect(siblings.every((sibling) => sibling.currentBranch === undefined)).toBe(true) + expect(executeCommand.mock.calls.some(([args]) => (args as string[]).includes('--abbrev-ref'))).toBe(false) + }) + + it('applies the filter to the listed siblings', async () => { + const { listRepoSiblings } = await import('../../src/services/repo') + const client = createClient([ + { directory: '/worktrees/feature-x', strategy: 'git' }, + { directory: '/worktrees/feature-y', strategy: 'git' }, + ]) + + const siblings = await listRepoSiblings(db, 1, {}, client, isWorktreeSibling) + + expect(siblings.map((sibling) => sibling.fullPath)).toEqual(['/worktrees/feature-x', '/worktrees/feature-y']) + }) + }) }) describe('resolveRepoForDirectory', () => { diff --git a/ocm-cli/README.md b/ocm-cli/README.md index beadfda3e..3a02cebaa 100644 --- a/ocm-cli/README.md +++ b/ocm-cli/README.md @@ -22,6 +22,10 @@ Use ocm 0.2.x with OpenCode Manager < 0.19.0 and OpenCode 1.x, installed pinned `@opencode-manager/ocm-cli@0.2`. ocm is published together with each OpenCode Manager release. +`/ocm-move` requires ocm 0.4.0+ and a Manager that supports worktree moves; an +older CLI is told to upgrade, and an older Manager is reported as needing an +upgrade. + ## Install ```bash @@ -126,6 +130,15 @@ work (commits not present locally, or uncommitted changes on the Manager), and `pull` asks before discarding local commits the Manager does not have. Without a TTY both refuse; pass `--force` to override. +When the Manager's main checkout is not on your local branch but a worktree of +that repo is (including a `-ocm` / `-ocm-N` worktree created by +`/ocm-move`), `push` and `pull` target that worktree instead. Pulling from a +suffixed worktree updates your local branch, so no `-ocm` branch is created +locally. A targeted worktree has no tarball fallback, and `--full` is refused +because it would overwrite the main checkout. Against an older Manager that does +not expose the checkout routes, both commands keep the previous behavior and +target the main checkout. + 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: @@ -141,23 +154,34 @@ package export. Configure the package name and OpenCode resolves that TUI entrypoint automatically. When attached to a Manager via `ocm`, the plugin shows a ` · ` indicator in the prompt and home footers; local launches show nothing. It registers `/ocm-move`, which keeps the local session and -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. The session moves by exporting it from the local OpenCode 2 -server and importing it through the Manager proxy, followed by a synthetic -reminder. 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 +copies the active session to the Manager after pushing your local working tree +(commits, staged, unstaged, and untracked files; gitignored files on the Manager +are preserved). Server checkouts and branches on your branch are never +overwritten when they hold work your local branch lacks; other server branches +present in your local repo are still updated to your local refs by the push, +unless checked out in another worktree. The Manager checkout is replaced in +place only when it is already on your branch, clean, and has nothing your local +branch lacks; otherwise the push lands in an OpenCode worktree of the same +Manager repo — reusing the first clean worktree on your branch or on a +`-ocm` / `-ocm-N` branch whose commits are all in your local +branch (including worktrees registered as Manager repos by older versions), or +creating a new OpenCode worktree in one step (bundle upload, import, then the +worktree setup commands; the worktree is removed if the import fails). A new +worktree uses your branch name unless that branch is already checked out on the +server or the server's branch has commits your local branch lacks; it then uses +the next free `-ocm[-N]` branch, so the server branch is never rewound, +and the existing server checkout is left untouched. A failed worktree setup is +reported as a warning and the session still moves. A detached HEAD is refused +before anything is pushed. 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 and states the destination path and branch, +and why the server checkout was not used. +The session moves by exporting it from the local OpenCode 2 server and importing +it through the Manager proxy, followed by a synthetic reminder. 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 (`ocm push --create` if needed). It refuses, before pushing anything, a session that is already on the Manager and a subagent session whose parent is not. @@ -169,7 +193,8 @@ that is already attached, the picker offers the other Manager repos (the current one is listed as disabled) and **Local opencode**, which stays available even when the Manager cannot be reached. Switching exits the TUI and reattaches; switching to a repo also remembers it as the last repo. The current -session stays where it is (use `/ocm-move` to bring a local session along). +session stays where it is (use `/ocm-move` to push your local state and bring a +local session along). The plugin also registers two Manager-backed commands, both of which need an attached Manager repo through `ocm`: diff --git a/ocm-cli/bin/ocm.ts b/ocm-cli/bin/ocm.ts index 478de2ff8..cdcc9029b 100644 --- a/ocm-cli/bin/ocm.ts +++ b/ocm-cli/bin/ocm.ts @@ -5,7 +5,7 @@ import { readState, writeState, clearState, getStatePath, writeInstallNotice, ty import { installVendoredOcm, resolveOpenCodeConfigDir, InstallError, OCM_PLUGIN_SPEC } from '../src/vendor-install.js' import { 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, describePushDivergence } from '../src/mirror.js' +import { mirrorUp, mirrorDown, mirrorUpFast, mirrorDownFast, prepareMirror, MirrorAbort, checkPushDivergence, checkPullDivergence, describePushDivergence, resolveBranchCheckout } 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' @@ -408,9 +408,10 @@ export async function cmdPush(args: string[]): Promise { progress.done() info(`pushed ${plan.repoRoot} -> ${result.created ? 'created' : 'updated'} (repoId=${result.repoId}, branch=${result.branch})`) } else if (plan.matched.length === 1) { + const selection = await resolveBranchCheckout(api, plan.matched[0]!.repoId, plan.repoRoot) if (!force) { try { - const divergence = await checkPushDivergence(plan.repoRoot, api, plan.matched[0]!.repoId) + const divergence = await checkPushDivergence(plan.repoRoot, api, plan.matched[0]!.repoId, selection.directory) if (divergence.diverged || divergence.serverDirty) { force = guardDivergentPush(plan.matched[0]!.name, divergence) } @@ -418,12 +419,25 @@ export async function cmdPush(args: string[]): Promise { if (!(error instanceof ManagerApiError && error.status === 404)) throw error } } + if (selection.directory && full) { + die(`a Manager worktree is checked out for branch ${selection.targetBranch ?? 'this branch'}; --full would overwrite the main checkout. Run without --full.`) + } if (!full) { try { - const result = await mirrorUpFast(plan, { api, force }) - info(`pushed ${plan.repoRoot} -> ${plan.matched[0]!.name} via bundle (repoId=${result.repoId}, branch=${result.branch})`) + const result = await mirrorUpFast(plan, { + api, + force, + directory: selection.directory, + targetBranch: selection.targetBranch, + requireCurrentBranch: selection.directory !== undefined, + }) + const via = selection.directory ? ` into Manager worktree ${selection.directory}` : '' + info(`pushed ${plan.repoRoot} -> ${plan.matched[0]!.name}${via} via bundle (repoId=${result.repoId}, branch=${result.branch})`) return } catch (error) { + if (selection.directory) { + die(`patch push to Manager worktree ${selection.directory} failed: ${error instanceof Error ? error.message : String(error)}. Not falling back to a full mirror because it would overwrite the main checkout.`) + } if (error instanceof MirrorAbort) throw error process.stderr.write(`ocm: patch push failed: ${error instanceof Error ? error.message : String(error)}\n`) confirmFullFallback() @@ -439,7 +453,7 @@ export async function cmdPush(args: string[]): Promise { } } -async function cmdPull(args: string[]): Promise { +export async function cmdPull(args: string[]): Promise { const parsed = parseRepoIdPositional(args, PULL_FLAGS) if (parsed.error) die(parsed.error) let force = false @@ -469,9 +483,12 @@ async function cmdPull(args: string[]): Promise { dieAmbiguousProjectMatch('pull', plan.localProjectId, repos, plan.matched) } + const localBranch = getBranchName(plan.repoRoot) + const selection = await resolveBranchCheckout(api, plan.matched[0]!.repoId, plan.repoRoot) + if (!force) { try { - const divergence = await checkPullDivergence(plan.repoRoot, api, plan.matched[0]!.repoId) + const divergence = await checkPullDivergence(plan.repoRoot, api, plan.matched[0]!.repoId, selection.directory) if (divergence.diverged) { force = guardDivergentPull(plan.matched[0]!.name, divergence) } @@ -480,12 +497,25 @@ async function cmdPull(args: string[]): Promise { } } + if (selection.directory && full) { + die(`a Manager worktree is checked out for branch ${selection.targetBranch ?? 'this branch'}; --full cannot target it. Run without --full.`) + } + if (!full) { try { - await mirrorDownFast(plan.matched[0]!.repoId, plan.repoRoot, api, { force }) - info(`pulled ${plan.matched[0]!.name} -> ${plan.repoRoot} via bundle`) + await mirrorDownFast(plan.matched[0]!.repoId, plan.repoRoot, api, { + force, + directory: selection.directory, + sourceBranch: selection.targetBranch, + targetBranch: selection.targetBranch ? (localBranch ?? undefined) : undefined, + }) + const from = selection.directory ? ` from Manager worktree ${selection.directory}` : '' + info(`pulled ${plan.matched[0]!.name}${from} -> ${plan.repoRoot} via bundle`) return } catch (error) { + if (selection.directory) { + die(`patch pull from Manager worktree ${selection.directory} failed: ${error instanceof Error ? error.message : String(error)}. Not falling back to a full mirror because it would overwrite the main checkout.`) + } if (error instanceof MirrorAbort && !error.message.includes('falling back')) throw error process.stderr.write(`ocm: patch pull failed: ${error instanceof Error ? error.message : String(error)}\n`) confirmFullFallback() diff --git a/ocm-cli/src/manager-api.ts b/ocm-cli/src/manager-api.ts index 366afe733..043f072aa 100644 --- a/ocm-cli/src/manager-api.ts +++ b/ocm-cli/src/manager-api.ts @@ -1,14 +1,14 @@ import { createReadStream } from 'fs' import { Readable } from 'stream' import { - MirrorTargetEnsureResponseSchema, - MirrorTargetPlanResponseSchema, + MirrorCheckoutsResponseSchema, + MirrorWorktreeCreateResponseSchema, MultiRunSchema, SessionGoalSchema, type FuseMultiRunRequest, type LaunchMultiRunRequest, - type MirrorTargetEnsureResponse, - type MirrorTargetPlanResponse, + type MirrorCheckoutsResponse, + type MirrorWorktreeCreateResponse, type MultiRun, type SessionGoal, type StartSessionGoalRequest, @@ -78,6 +78,13 @@ function createByteCounter(onProgress: (bytesSent: number) => void): TransformSt export const MANAGER_FEATURE_MISSING = 'MANAGER_FEATURE_MISSING' +const DEFAULT_FEATURE_NAME = 'ocm goals and multi-runs' +const MOVE_FEATURE_NAME = '/ocm-move worktrees' + +function directoryQuery(directory: string | undefined): string { + return directory ? `?${new URLSearchParams({ directory }).toString()}` : '' +} + export class ManagerApiError extends Error { constructor( message: string, @@ -141,9 +148,9 @@ export class ManagerApi { path: string, operation: string, parse: (body: unknown) => T, - init?: RequestInit & { feature?: boolean }, + init?: RequestInit & { feature?: boolean; featureName?: string }, ): Promise { - const { feature, ...requestInit } = init ?? {} + const { feature, featureName, ...requestInit } = init ?? {} const baseHeaders = this.headers( typeof requestInit.body === 'string' ? { 'Content-Type': 'application/json' } : {}, ) @@ -151,25 +158,29 @@ export class ManagerApi { ...requestInit, headers: { ...baseHeaders, ...(requestInit.headers as Record | undefined) }, }) - if (!res.ok) throw await this.handleErrorResponse(res, operation, feature === true, requestInit.signal ?? undefined) + if (!res.ok) { + const featureLabel = feature === true ? (featureName ?? DEFAULT_FEATURE_NAME) : null + throw await this.handleErrorResponse(res, operation, featureLabel, requestInit.signal ?? undefined) + } return parse(await res.json()) } private async handleErrorResponse( res: Response, operation: string, - feature: boolean, + featureName: string | null, signal?: AbortSignal, ): Promise { const error = await formatErrorResponse(res, operation) - if (feature && error.status === 401) { - return this.probeFeatureSupport(operation, error, signal) + if (featureName !== null && error.status === 401) { + return this.probeFeatureSupport(operation, featureName, error, signal) } return error } private async probeFeatureSupport( operation: string, + featureName: string, fallback: ManagerApiError, signal?: AbortSignal, ): Promise { @@ -177,7 +188,7 @@ export class ManagerApi { const res = await fetch(`${this.baseUrl}/api/internal/opencode-workspaces`, { headers: this.headers(), signal }) if (res.ok) { return new ManagerApiError( - `${operation} failed: this OpenCode Manager is too old for ocm goals and multi-runs; upgrade the Manager.`, + `${operation} failed: this OpenCode Manager is too old for ${featureName}; upgrade the Manager.`, 401, MANAGER_FEATURE_MISSING, operation, @@ -244,11 +255,16 @@ export class ManagerApi { return res.body! } - async mirrorPatch(repoId: number, body: { baseHead: string | null; patch: string; force?: boolean }): Promise { + async mirrorPatch( + repoId: number, + body: { baseHead: string | null; patch: string; force?: boolean; directory?: string }, + ): Promise { + const payload: Record = { baseHead: body.baseHead, patch: body.patch, force: body.force === true } + if (body.directory) payload.directory = body.directory const res = await fetch(`${this.baseUrl}/api/internal/repos/${repoId}/mirror/patch`, { method: 'POST', headers: { ...this.headers(), 'Content-Type': 'application/json' }, - body: JSON.stringify({ baseHead: body.baseHead, patch: body.patch, force: body.force === true }), + body: JSON.stringify(payload), }) if (!res.ok) throw await formatErrorResponse(res, 'mirror patch') @@ -258,12 +274,23 @@ export class ManagerApi { async mirrorUploadBundle( repoId: number, bundlePath: string, - opts: { branch: string | null; force?: boolean; requireCurrentBranch?: boolean; onProgress?: (bytesSent: number) => void }, + opts: { + branch: string | null + force?: boolean + requireCurrentBranch?: boolean + directory?: string + targetBranch?: string + onProgress?: (bytesSent: number) => void + }, ): Promise { - const query = opts.force === true ? '?force=1' : '' + const params = new URLSearchParams() + if (opts.force === true) params.set('force', '1') + if (opts.directory) params.set('directory', opts.directory) + const query = params.size > 0 ? `?${params.toString()}` : '' 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' + if (opts.targetBranch) headers['X-OCM-Target-Branch'] = opts.targetBranch 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}`, { @@ -277,8 +304,8 @@ export class ManagerApi { return (await res.json()) as MirrorBundleResult } - async mirrorHead(repoId: number): Promise { - const res = await fetch(`${this.baseUrl}/api/internal/repos/${repoId}/mirror/head`, { + async mirrorHead(repoId: number, directory?: string): Promise { + const res = await fetch(`${this.baseUrl}/api/internal/repos/${repoId}/mirror/head${directoryQuery(directory)}`, { headers: this.headers(), }) @@ -286,28 +313,50 @@ 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)}`, { + async mirrorCheckouts(repoId: number, branch: string): Promise { + const params = new URLSearchParams({ branch }) + const res = await fetch(`${this.baseUrl}/api/internal/repos/${repoId}/mirror/checkouts?${params.toString()}`, { headers: this.headers(), }) - if (!res.ok) throw await formatErrorResponse(res, 'mirror target plan') - return MirrorTargetPlanResponseSchema.parse(await res.json()) + if (!res.ok) throw await this.handleErrorResponse(res, 'mirror checkouts', MOVE_FEATURE_NAME) + return MirrorCheckoutsResponseSchema.parse(await res.json()) } - async mirrorEnsureTarget(repoId: number, branch: string): Promise { - const res = await fetch(`${this.baseUrl}/api/internal/repos/${repoId}/mirror/target`, { + async mirrorCreateWorktree( + repoId: number, + bundlePath: string, + opts: { branch: string; targetBranch: string; patch: string; onProgress?: (bytesSent: number) => void }, + ): Promise { + const patchBuf = Buffer.from(opts.patch, 'utf-8') + const headers: Record = { + ...this.headers(), + 'Content-Type': 'application/octet-stream', + 'X-OCM-Branch': opts.branch, + 'X-OCM-Target-Branch': opts.targetBranch, + 'X-OCM-Patch-Bytes': String(patchBuf.length), + } + const source = Readable.from( + (async function* () { + if (patchBuf.length > 0) yield patchBuf + yield* createReadStream(bundlePath) + })(), + ) + const fileStream = Readable.toWeb(source) 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/worktree`, { method: 'POST', - headers: { ...this.headers(), 'Content-Type': 'application/json' }, - body: JSON.stringify({ branch }), - }) + headers, + body: body as BodyInit, + duplex: 'half', + } as RequestInit & { duplex: 'half' }) - if (!res.ok) throw await formatErrorResponse(res, 'mirror target') - return MirrorTargetEnsureResponseSchema.parse(await res.json()) + if (!res.ok) throw await this.handleErrorResponse(res, 'mirror create worktree', MOVE_FEATURE_NAME) + return MirrorWorktreeCreateResponseSchema.parse(await res.json()) } - async mirrorContains(repoId: number, sha: string): Promise<{ contained: boolean }> { - const res = await fetch(`${this.baseUrl}/api/internal/repos/${repoId}/mirror/contains/${sha}`, { + async mirrorContains(repoId: number, sha: string, directory?: string): Promise<{ contained: boolean }> { + const res = await fetch(`${this.baseUrl}/api/internal/repos/${repoId}/mirror/contains/${sha}${directoryQuery(directory)}`, { headers: this.headers(), }) @@ -315,8 +364,8 @@ export class ManagerApi { return (await res.json()) as { repoId: number; contained: boolean } } - async mirrorDownloadBundle(repoId: number): Promise> { - const res = await fetch(`${this.baseUrl}/api/internal/repos/${repoId}/mirror/bundle`, { + async mirrorDownloadBundle(repoId: number, directory?: string): Promise> { + const res = await fetch(`${this.baseUrl}/api/internal/repos/${repoId}/mirror/bundle${directoryQuery(directory)}`, { headers: this.headers(), }) @@ -324,8 +373,8 @@ export class ManagerApi { return res.body! } - async mirrorPatchSnapshot(repoId: number): Promise { - const res = await fetch(`${this.baseUrl}/api/internal/repos/${repoId}/mirror/patch`, { + async mirrorPatchSnapshot(repoId: number, directory?: string): Promise { + const res = await fetch(`${this.baseUrl}/api/internal/repos/${repoId}/mirror/patch${directoryQuery(directory)}`, { headers: this.headers(), }) diff --git a/ocm-cli/src/mirror.ts b/ocm-cli/src/mirror.ts index 1afb8b5c1..11967fa2d 100644 --- a/ocm-cli/src/mirror.ts +++ b/ocm-cli/src/mirror.ts @@ -7,8 +7,9 @@ import { join, dirname } from 'path' import { tmpdir } from 'os' import { getRepoRoot, getDirtyPaths, getHeadSha, getBranchName, getMirrorPatch, runGit, hasCommit, isAncestor, countCommitsAhead } from './local-repo.js' import { resolveOpenCodeProjectId } from '@opencode-manager/shared/project-id' +import type { MirrorCheckoutState, MirrorCheckoutsResponse, WorktreeSetupResult } from '@opencode-manager/shared/schemas' import type { ManagerApi } from './manager-api.js' -import { ManagerApiError } from './manager-api.js' +import { ManagerApiError, isManagerRouteMissing } from './manager-api.js' import { delay } from './delay.js' const HARDCODED_EXCLUDES = ['node_modules', 'dist', '.next', '.venv', '__pycache__', '.turbo', '.DS_Store', '._*'] @@ -84,9 +85,21 @@ export interface PushDivergence { lostCommits: number } -export async function checkPushDivergence(repoRoot: string, api: ManagerApi, repoId: number): Promise { - const info = await api.mirrorHead(repoId) - const { head: serverHead, branch: serverBranch, dirty: serverDirty } = info +export async function checkPushDivergence( + repoRoot: string, + api: ManagerApi, + repoId: number, + directory?: string, +): Promise { + const info = await api.mirrorHead(repoId, directory) + return assessPushDivergence(repoRoot, info) +} + +export function assessPushDivergence( + repoRoot: string, + state: { head: string | null; branch: string | null; dirty: boolean }, +): PushDivergence { + const { head: serverHead, branch: serverBranch, dirty: serverDirty } = state const localHead = getHeadSha(repoRoot) if (!serverHead || serverHead === localHead) { @@ -102,14 +115,78 @@ export async function checkPushDivergence(repoRoot: string, api: ManagerApi, rep return { serverHead, serverBranch, serverDirty, diverged: true, lostCommits } } -export function describePushDivergence(div: PushDivergence): string[] { +export interface MoveDestination { + kind: 'in-place' | 'existing-worktree' | 'new-worktree' + directory: string | null + branch: string + reasons: string[] +} + +function checkoutUnusableReasons( + repoRoot: string, + label: string, + state: MirrorCheckoutState, + localBranch: string, +): string[] { + if (state.branch !== localBranch) return [`${label} is on ${state.branch ?? 'a detached HEAD'}`] + return describePushDivergence(assessPushDivergence(repoRoot, state), label) +} + +function worktreeUnusableReasons( + repoRoot: string, + state: MirrorCheckoutState, +): string[] { + return describePushDivergence(assessPushDivergence(repoRoot, state), `the worktree on ${state.branch ?? 'another branch'}`) +} + +function localContainsCommit(repoRoot: string, sha: string): boolean { + const localHead = getHeadSha(repoRoot) + if (!localHead) return false + return hasCommit(repoRoot, sha) && isAncestor(repoRoot, sha, localHead) +} + +export function chooseMoveDestination( + repoRoot: string, + localBranch: string, + checkouts: MirrorCheckoutsResponse, +): MoveDestination { + const mainReasons = checkoutUnusableReasons(repoRoot, 'the server checkout', checkouts.main, localBranch) + if (mainReasons.length === 0) { + return { kind: 'in-place', directory: checkouts.main.directory, branch: localBranch, reasons: [] } + } + + const worktreeReasons: string[] = [] + for (const worktree of checkouts.worktrees) { + const reasons = worktreeUnusableReasons(repoRoot, worktree) + if (reasons.length === 0) { + return { kind: 'existing-worktree', directory: worktree.directory, branch: worktree.branch ?? localBranch, reasons: [] } + } + worktreeReasons.push(...reasons) + } + + const branchHeadMissing = checkouts.branchHead !== null && !localContainsCommit(repoRoot, checkouts.branchHead) + const useSuffix = checkouts.branchCheckedOut || branchHeadMissing + const reasons = [ + ...mainReasons, + ...worktreeReasons, + ...(branchHeadMissing ? [`the server branch ${localBranch} has commits not in your local branch`] : []), + ] + return { + kind: 'new-worktree', + directory: null, + branch: useSuffix ? checkouts.suffixedWorktreeBranch : localBranch, + reasons, + } +} + +export function describePushDivergence(div: PushDivergence, subject = 'the server'): 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') + ? `${subject} is ${div.lostCommits} commit(s) ahead of your local branch` + : `${subject} has commit(s) not present in your local branch`) } - if (div.serverDirty) reasons.push('the server has uncommitted changes') + if (div.serverDirty) reasons.push(`${subject} has uncommitted changes`) return reasons } @@ -119,17 +196,22 @@ export interface PullDivergence { serverHead: string | null } -export async function checkPullDivergence(repoRoot: string, api: ManagerApi, repoId: number): Promise { +export async function checkPullDivergence( + repoRoot: string, + api: ManagerApi, + repoId: number, + directory?: string, +): Promise { const localHead = getHeadSha(repoRoot) if (!localHead) return { diverged: false, lostCommits: 0, serverHead: null } - const { contained } = await api.mirrorContains(repoId, localHead) + const { contained } = await api.mirrorContains(repoId, localHead, directory) if (contained) return { diverged: false, lostCommits: 0, serverHead: null } let serverHead: string | null = null let lostCommits = -1 try { - serverHead = (await api.mirrorHead(repoId)).head + serverHead = (await api.mirrorHead(repoId, directory)).head if (serverHead) lostCommits = countCommitsAhead(repoRoot, serverHead, localHead) } catch { // best-effort: count is informational only @@ -137,6 +219,36 @@ export async function checkPullDivergence(repoRoot: string, api: ManagerApi, rep return { diverged: true, lostCommits, serverHead } } +export interface BranchCheckoutSelection { + directory?: string + targetBranch?: string +} + +export async function resolveBranchCheckout( + api: ManagerApi, + repoId: number, + repoRoot: string, +): Promise { + const localBranch = getBranchName(repoRoot) + if (!localBranch) return {} + + let checkouts: MirrorCheckoutsResponse + try { + checkouts = await api.mirrorCheckouts(repoId, localBranch) + } catch (error) { + if (isManagerRouteMissing(error) || (error instanceof ManagerApiError && error.status === 410)) return {} + throw error + } + + if (checkouts.main.branch === localBranch) return {} + const worktree = checkouts.worktrees[0] + if (!worktree) return {} + return { + directory: worktree.directory, + targetBranch: worktree.branch !== null && worktree.branch !== localBranch ? worktree.branch : undefined, + } +} + export interface MirrorProgress { bytesSent: number } @@ -371,11 +483,11 @@ export async function mirrorDown( export async function mirrorUpPatch( plan: MirrorPlan, - opts: Pick, + opts: Pick & { directory?: string }, ): Promise<{ repoId: number; fullPath: string; branch: string | null; head: string | null; created: false; applied: true }> { const repoId = plan.matched[0]!.repoId const patch = getMirrorPatch(plan.repoRoot) - return opts.api.mirrorPatch(repoId, { baseHead: getHeadSha(plan.repoRoot), patch, force: opts.force }) + return opts.api.mirrorPatch(repoId, { baseHead: getHeadSha(plan.repoRoot), patch, force: opts.force, directory: opts.directory }) } function applyPatch(repoRoot: string, patch: string): void { @@ -459,7 +571,13 @@ function deleteSyncRefs(repoRoot: string): void { } } -function importLocalBundle(repoRoot: string, bundlePath: string, branch: string | null, force: boolean): void { +function importLocalBundle( + repoRoot: string, + bundlePath: string, + branch: string | null, + force: boolean, + sourceBranch?: string, +): 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']) @@ -476,10 +594,11 @@ function importLocalBundle(repoRoot: string, bundlePath: string, branch: string const lockedElsewhere = branchOwnedElsewhere(listWorktreeBranches(repoRoot), repoRoot) const currentBranch = getBranchName(repoRoot) + const source = sourceBranch ?? branch - if (branch) { - const incomingSha = incoming.get(branch) - if (!incomingSha) throw new MirrorAbort(`no incoming branch '${branch}' in the server bundle`) + if (branch && source) { + const incomingSha = incoming.get(source) + if (!incomingSha) throw new MirrorAbort(`no incoming branch '${source}' in the server bundle`) if (lockedElsewhere.has(branch)) { throw new MirrorAbort(`branch '${branch}' is checked out in another worktree; release it there before pulling`) } @@ -493,9 +612,13 @@ function importLocalBundle(repoRoot: string, bundlePath: string, branch: string } } + const skipRefs = new Set() + if (branch) skipRefs.add(branch) + if (source) skipRefs.add(source) + const updates: string[] = [] for (const [name, sha] of incoming) { - if (branch === name) continue + if (skipRefs.has(name)) continue if (lockedElsewhere.has(name)) continue if (branch === null && name === currentBranch) continue updates.push(`update refs/heads/${name} ${sha}\n`) @@ -504,8 +627,8 @@ function importLocalBundle(repoRoot: string, bundlePath: string, branch: string runGit(repoRoot, ['update-ref', '--stdin'], updates.join('')) } - if (branch) { - const targetSha = incoming.get(branch)! + if (branch && source) { + const targetSha = incoming.get(source)! runGit(repoRoot, ['reset', '--hard', targetSha]) runGit(repoRoot, ['clean', '-fd']) } @@ -514,9 +637,9 @@ function importLocalBundle(repoRoot: string, bundlePath: string, branch: string } } -async function writeBundleStream(repoId: number, api: ManagerApi): Promise { +async function writeBundleStream(repoId: number, api: ManagerApi, directory?: string): Promise { const bundlePath = join(tmpdir(), `ocm-bundle-down-${Date.now()}-${Math.random().toString(36).slice(2)}.bundle`) - const stream = await api.mirrorDownloadBundle(repoId) + const stream = await api.mirrorDownloadBundle(repoId, directory) await pipeline( Readable.fromWeb(stream as unknown as Parameters[0]), createWriteStream(bundlePath), @@ -532,30 +655,71 @@ export type MirrorUpFastPhase = type MirrorUpFastOpts = Pick & { requireCurrentBranch?: boolean + directory?: string + targetBranch?: string + createWorktree?: { targetBranch: string } onPhase?: (phase: MirrorUpFastPhase) => void } +export interface MirrorUpFastResult { + repoId: number + fullPath: string + branch: string | null + head: string | null + created: boolean + worktreeSetup?: WorktreeSetupResult +} + export async function mirrorUpFast( plan: MirrorPlan, opts: MirrorUpFastOpts, -): Promise<{ repoId: number; fullPath: string; branch: string | null; head: string | null; created: false }> { +): Promise { const repoId = plan.matched[0]!.repoId const onPhase = opts.onPhase onPhase?.({ kind: 'bundling' }) const bundlePath = await createLocalBundle(plan.repoRoot) try { const { size } = await fsp.stat(bundlePath) + const progressFor = (totalBytes: number) => + onPhase + ? (bytesSent: number) => { + onPhase({ kind: 'uploading', bytesSent, totalBytes }) + if (bytesSent >= totalBytes) onPhase({ kind: 'processing' }) + } + : undefined + + if (opts.createWorktree) { + const branch = getBranchName(plan.repoRoot) + if (!branch) throw new MirrorAbort('cannot create a worktree from a detached HEAD') + const patch = getMirrorPatch(plan.repoRoot) + const totalBytes = size + Buffer.byteLength(patch) + onPhase?.({ kind: 'uploading', bytesSent: 0, totalBytes }) + const created = await opts.api.mirrorCreateWorktree(repoId, bundlePath, { + branch, + targetBranch: opts.createWorktree.targetBranch, + patch, + onProgress: progressFor(totalBytes), + }) + return { + repoId: created.repoId, + fullPath: created.fullPath, + branch: created.branch, + head: created.head, + created: true, + worktreeSetup: created.worktreeSetup, + } + } + onPhase?.({ kind: 'uploading', bytesSent: 0, totalBytes: size }) + const onProgress = progressFor(size) + 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 }) - if (bytesSent >= size) onPhase({ kind: 'processing' }) - } - : undefined, + directory: opts.directory, + targetBranch: opts.targetBranch, + onProgress, }) onPhase?.({ kind: 'patching' }) const patchResult = await mirrorUpPatch(plan, opts) @@ -569,16 +733,18 @@ export async function mirrorDownFast( repoId: number, repoRoot: string, api: ManagerApi, - opts: { force: boolean } = { force: false }, + opts: { force: boolean; directory?: string; sourceBranch?: string; targetBranch?: string } = { force: false }, ): Promise { if (!opts.force && getDirtyPaths(repoRoot).size > 0) { throw new MirrorAbort('working tree has uncommitted changes; rerun with --force') } - const snapshot = await api.mirrorPatchSnapshot(repoId) - const bundlePath = await writeBundleStream(repoId, api) + const snapshot = await api.mirrorPatchSnapshot(repoId, opts.directory) + const bundlePath = await writeBundleStream(repoId, api, opts.directory) try { - importLocalBundle(repoRoot, bundlePath, snapshot.branch, opts.force) + const branch = opts.targetBranch ?? snapshot.branch + const sourceBranch = opts.sourceBranch ?? branch ?? undefined + importLocalBundle(repoRoot, bundlePath, branch, opts.force, sourceBranch) applyPatch(repoRoot, snapshot.patch) } finally { await fsp.rm(bundlePath, { force: true }).catch(() => {}) diff --git a/ocm-cli/src/tui-plugin.ts b/ocm-cli/src/tui-plugin.ts index 27b86b9a9..9cf1a09b0 100644 --- a/ocm-cli/src/tui-plugin.ts +++ b/ocm-cli/src/tui-plugin.ts @@ -2,10 +2,9 @@ import type { Context } from '@opencode/plugin/tui/context' import { readInstallNotice } from './state.js' import { resolveManagerAuth } from './manager-auth.js' import { fetchRepos, toRemoteRepoSummaries } from './manager-repos.js' -import { ManagerApi, ManagerApiError } 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 { ManagerApi } from './manager-api.js' +import { prepareMirror, mirrorUpFast, pickMatchedRepo, chooseMoveDestination } from './mirror.js' +import type { MirrorPlan, MoveDestination } from './mirror.js' import { getBranchName } from './local-repo.js' import { transferSession, moveReminderText } from './session-move.js' import { createManagerSessionTransfer } from './remote-session.js' @@ -123,37 +122,22 @@ async function describeMoveBlocker(transfer: ManagerSessionTransfer, sessionID: return null } -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 describeMoveTarget(repoName: string, target: MirrorTargetPlanResponse): string { - switch (target.kind) { +function moveConfirmMessage(repoName: string, destination: MoveDestination, localBranch: string): string { + switch (destination.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?` - } -} - -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 { - if (!localBranch) { - return { kind: 'in-place', repoId: matched.repoId, fullPath: remoteDirectory, localPath: remoteDirectory, branch: '', currentBranch: null } + return `Replace the repo state of ${repoName} (${destination.directory}) with your local working tree and move this session there?` + case 'existing-worktree': + return `${repoName}'s main checkout is on another branch, but branch ${destination.branch} already lives in worktree ${destination.directory}.\n\nReplace that worktree with your local working tree and move this session there?` + case 'new-worktree': { + const reasons = destination.reasons.length > 0 + ? `\n\nThe server checkout was not used because:\n${destination.reasons.map((r) => ` - ${r}`).join('\n')}` + : '' + const suffix = destination.branch !== localBranch + ? `\n\nYour local branch ${localBranch} cannot be pushed directly, so a new worktree on ${destination.branch} is created instead.` + : '' + return `The ${repoName} server checkout will not be touched.${reasons}\n\nCreate a new OpenCode worktree for branch ${destination.branch}, push your local working tree there, and move this session?${suffix}` + } } - return managerApi.mirrorTargetPlan(matched.repoId, localBranch) } async function runSessionMove(context: Context, setMoveProgress: MoveProgressSetter): Promise { @@ -177,11 +161,14 @@ async function runSessionMove(context: Context, setMoveProgress: MoveProgressSet } const localBranch = getBranchName(plan.repoRoot) + if (!localBranch) { + context.ui.toast.show({ variant: 'error', message: 'Check out a branch before moving; a detached HEAD cannot be moved.' }) + return + } const matched = pickMatchedRepo(plan.matched, localBranch) ?? await selectDialog(context, '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)! await warmRepoProxy(auth.managerUrl, auth.token, matchedRepoId) @@ -193,29 +180,39 @@ async function runSessionMove(context: Context, setMoveProgress: MoveProgressSet } const managerApi = new ManagerApi(auth.managerUrl, auth.token) - const target = await resolveMoveTarget(managerApi, matched, remoteRepo.directory, localBranch) - const discardReasons = target.repoId === null ? [] : await describeRemoteDiscard(plan.repoRoot, managerApi, target.repoId) + const checkouts = await managerApi.mirrorCheckouts(matched.repoId, localBranch) + const destination = chooseMoveDestination(plan.repoRoot, localBranch, checkouts) const proceed = await confirmDialog(context, { title: 'Move session to Manager', - message: moveConfirmMessage(matched.name, target, discardReasons), + message: moveConfirmMessage(matched.name, destination, localBranch), }) if (!proceed) return - 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 selectedPlan: MirrorPlan = { ...plan, matched: [matched] } const pushed = await mirrorUpFast(selectedPlan, { api: managerApi, force: true, - requireCurrentBranch: true, + ...(destination.kind === 'new-worktree' + ? { createWorktree: { targetBranch: destination.branch } } + : destination.kind === 'existing-worktree' + ? { + requireCurrentBranch: true, + directory: destination.directory ?? undefined, + targetBranch: destination.branch !== localBranch ? destination.branch : undefined, + } + : { requireCurrentBranch: true }), onPhase: (phase) => setMoveProgress(pushPhaseProgress(phase)), }) const remoteDirectory = pushed.fullPath + if (pushed.worktreeSetup?.status === 'failed') { + context.ui.toast.show({ + variant: 'warning', + message: `Worktree setup failed: ${pushed.worktreeSetup.error}. The session was still moved.`, + }) + } + const result = await transferSession( { sessionID, localRoot: plan.repoRoot, remoteDirectory }, { @@ -232,10 +229,10 @@ async function runSessionMove(context: Context, setMoveProgress: MoveProgressSet setMoveProgress(null) const warp = await confirmDialog(context, { title: 'Attach to moved session?', message: 'Exit this TUI and attach to the moved session on the Manager now?' }) if (warp) { - await warmRepoProxy(auth.managerUrl, auth.token, pushed.repoId) + await warmRepoProxy(auth.managerUrl, auth.token, matched.repoId) setPendingWarp({ kind: 'attach', - target: { managerUrl: auth.managerUrl, token: auth.token, repoId: pushed.repoId, sessionID: result.sessionID, repoName: matched.name }, + target: { managerUrl: auth.managerUrl, token: auth.token, repoId: matched.repoId, sessionID: result.sessionID, repoName: matched.name }, }) context.keymap.dispatch('app.exit') return diff --git a/ocm-cli/test/manager-api.test.ts b/ocm-cli/test/manager-api.test.ts index 5c75a0d0e..00668cebf 100644 --- a/ocm-cli/test/manager-api.test.ts +++ b/ocm-cli/test/manager-api.test.ts @@ -278,6 +278,50 @@ describe('ManagerApi feature support', () => { }) }) +describe('ManagerApi mirror routes', () => { + const api = new ManagerApi(BASE_URL, 'tok') + + it('maps a 401 from mirrorCheckouts to MANAGER_FEATURE_MISSING with the move message', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(errorResponse(401, JSON.stringify({ error: 'Unauthorized' }))) + .mockResolvedValueOnce(okResponse({ workspaces: [] })) + vi.stubGlobal('fetch', fetchMock) + + const error = await api.mirrorCheckouts(1, 'feature').catch((err) => err) + + expect(error).toBeInstanceOf(ManagerApiError) + expect((error as ManagerApiError).code).toBe(MANAGER_FEATURE_MISSING) + expect((error as ManagerApiError).message).toContain('too old for /ocm-move worktrees') + expect(isManagerRouteMissing(error)).toBe(true) + expect(fetchMock).toHaveBeenNthCalledWith(2, `${BASE_URL}/api/internal/opencode-workspaces`, { + headers: { Authorization: 'Bearer tok' }, + }) + }) + + it('keeps the goal and multi-run feature message unchanged', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(errorResponse(401, JSON.stringify({ error: 'Unauthorized' }))) + .mockResolvedValueOnce(okResponse({ workspaces: [] })) + vi.stubGlobal('fetch', fetchMock) + + const error = await api.listMultiRuns(1).catch((err) => err) + + expect((error as ManagerApiError).code).toBe(MANAGER_FEATURE_MISSING) + expect((error as ManagerApiError).message).toContain('too old for ocm goals and multi-runs') + }) + + it('sends the directory query on mirrorHead and mirrorContains', async () => { + const fetchMock = vi.fn().mockResolvedValue(okResponse({})) + vi.stubGlobal('fetch', fetchMock) + + await api.mirrorHead(1, '/repos/work trees/repo') + await api.mirrorContains(1, 'abc1234', '/repos/work trees/repo') + + expect(new URL(String(fetchMock.mock.calls[0]![0])).searchParams.get('directory')).toBe('/repos/work trees/repo') + expect(new URL(String(fetchMock.mock.calls[1]![0])).searchParams.get('directory')).toBe('/repos/work trees/repo') + }) +}) + describe('isManagerRouteMissing', () => { const api = new ManagerApi(BASE_URL, 'tok') diff --git a/ocm-cli/test/mirror.test.ts b/ocm-cli/test/mirror.test.ts index 12ff10b70..ccacf21ec 100644 --- a/ocm-cli/test/mirror.test.ts +++ b/ocm-cli/test/mirror.test.ts @@ -4,9 +4,10 @@ import { join } from 'path' import { tmpdir } from 'os' import { randomBytes } from 'crypto' import { spawnSync, execSync } from 'child_process' -import { prepareMirror, MirrorAbort, mirrorDown, mirrorDownFast, mirrorUp, mirrorUpPatch, mirrorUpFast, checkPushDivergence, checkPullDivergence, describePushDivergence, pickMatchedRepo, type MirrorUpFastPhase } from '../src/mirror' +import { prepareMirror, MirrorAbort, mirrorDown, mirrorDownFast, mirrorUp, mirrorUpPatch, mirrorUpFast, checkPushDivergence, checkPullDivergence, describePushDivergence, pickMatchedRepo, chooseMoveDestination, assessPushDivergence, resolveBranchCheckout, type MirrorUpFastPhase } from '../src/mirror' import { getBranchName } from '../src/local-repo' -import { ManagerApi } from '../src/manager-api' +import { ManagerApi, ManagerApiError, MANAGER_FEATURE_MISSING, isManagerRouteMissing } from '../src/manager-api' +import type { MirrorCheckoutState, MirrorCheckoutsResponse } from '@opencode-manager/shared/schemas' import { gitRemoteProjectId } from '@opencode-manager/shared/project-id' import { mockStateModule, mockTokenStoreModule } from './helpers/token-store-mocks.js' @@ -685,6 +686,217 @@ describe('describePushDivergence', () => { }) }) +describe('assessPushDivergence', () => { + let tmpDir: string + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'assess-divergence-test-')) + }) + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }) + }) + + function initRepo(name: string): string { + const repoRoot = join(tmpDir, name) + mkdirSync(repoRoot) + spawnSync('git', ['init'], { cwd: repoRoot, stdio: 'ignore' }) + spawnSync('git', ['config', 'user.email', 'test@test.com'], { cwd: repoRoot, stdio: 'ignore' }) + spawnSync('git', ['config', 'user.name', 'Test'], { cwd: repoRoot, stdio: 'ignore' }) + return repoRoot + } + + function commit(repoRoot: string, file: string, content: string): string { + writeFileSync(join(repoRoot, file), content) + spawnSync('git', ['add', '.'], { cwd: repoRoot, stdio: 'ignore' }) + spawnSync('git', ['commit', '-m', `add ${file}`], { cwd: repoRoot, stdio: 'ignore' }) + return execSync('git rev-parse HEAD', { cwd: repoRoot, encoding: 'utf-8' }).trim() + } + + it('reports no divergence from server state alone when heads match', () => { + const repoRoot = initRepo('assess-equal') + const head = commit(repoRoot, 'a.txt', 'a') + + const result = assessPushDivergence(repoRoot, { head, branch: 'main', dirty: false }) + + expect(result.diverged).toBe(false) + expect(result.serverBranch).toBe('main') + }) + + it('surfaces the dirty flag from the server state', () => { + const repoRoot = initRepo('assess-dirty') + const head = commit(repoRoot, 'a.txt', 'a') + + const result = assessPushDivergence(repoRoot, { head, branch: 'main', dirty: true }) + + expect(result.serverDirty).toBe(true) + expect(result.diverged).toBe(false) + }) +}) + +describe('chooseMoveDestination', () => { + let tmpDir: string + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'move-destination-test-')) + }) + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }) + }) + + function initRepo(name: string): string { + const repoRoot = join(tmpDir, name) + mkdirSync(repoRoot) + spawnSync('git', ['init'], { cwd: repoRoot, stdio: 'ignore' }) + spawnSync('git', ['config', 'user.email', 'test@test.com'], { cwd: repoRoot, stdio: 'ignore' }) + spawnSync('git', ['config', 'user.name', 'Test'], { cwd: repoRoot, stdio: 'ignore' }) + return repoRoot + } + + function commit(repoRoot: string, file: string, content: string): string { + writeFileSync(join(repoRoot, file), content) + spawnSync('git', ['add', '.'], { cwd: repoRoot, stdio: 'ignore' }) + spawnSync('git', ['commit', '-m', `add ${file}`], { cwd: repoRoot, stdio: 'ignore' }) + return execSync('git rev-parse HEAD', { cwd: repoRoot, encoding: 'utf-8' }).trim() + } + + const checkout = (directory: string, branch: string | null, head: string | null, dirty = false): MirrorCheckoutState => ({ directory, branch, head, dirty }) + + const checkouts = ( + main: MirrorCheckoutState, + worktrees: MirrorCheckoutState[] = [], + overrides: Partial = {}, + ): MirrorCheckoutsResponse => ({ + main, + worktrees, + branchHead: null, + branchCheckedOut: false, + suffixedWorktreeBranch: 'feature-ocm', + ...overrides, + }) + + it('replaces the main checkout in place when it is on the local branch, clean, and not ahead', () => { + const repoRoot = initRepo('in-place') + const head = commit(repoRoot, 'a.txt', 'a') + + const destination = chooseMoveDestination(repoRoot, 'main', checkouts(checkout('/repos/repo', 'main', head))) + + expect(destination).toEqual({ kind: 'in-place', directory: '/repos/repo', branch: 'main', reasons: [] }) + }) + + it('reuses an exact-branch worktree when the main checkout is on another branch', () => { + const repoRoot = initRepo('reuse-exact-worktree') + const head = commit(repoRoot, 'a.txt', 'a') + + const destination = chooseMoveDestination(repoRoot, 'feature', checkouts( + checkout('/repos/repo', 'main', head), + [checkout('/repos/repo-feature', 'feature', head)], + )) + + expect(destination).toEqual({ kind: 'existing-worktree', directory: '/repos/repo-feature', branch: 'feature', reasons: [] }) + }) + + it('reuses a suffixed worktree branch that the server already checked out', () => { + const repoRoot = initRepo('reuse-suffixed-worktree') + const head = commit(repoRoot, 'a.txt', 'a') + + const destination = chooseMoveDestination(repoRoot, 'feature', checkouts( + checkout('/repos/repo', 'main', head), + [checkout('/repos/repo-feature-ocm', 'feature-ocm', head)], + )) + + expect(destination).toEqual({ kind: 'existing-worktree', directory: '/repos/repo-feature-ocm', branch: 'feature-ocm', reasons: [] }) + }) + + it('skips dirty or diverged worktrees and reuses the first safe one', () => { + const repoRoot = initRepo('skip-worktrees') + const head = commit(repoRoot, 'a.txt', 'a') + + const destination = chooseMoveDestination(repoRoot, 'feature', checkouts( + checkout('/repos/repo', 'main', head), + [ + checkout('/repos/repo-dirty', 'feature', head, true), + checkout('/repos/repo-feature', 'feature', head), + ], + )) + + expect(destination).toEqual({ kind: 'existing-worktree', directory: '/repos/repo-feature', branch: 'feature', reasons: [] }) + }) + + it('creates a new worktree on the local branch when it is free and its server head is local', () => { + const repoRoot = initRepo('new-worktree-plain') + const head = commit(repoRoot, 'a.txt', 'a') + + const destination = chooseMoveDestination(repoRoot, 'feature', checkouts( + checkout('/repos/repo', 'main', head), + [], + { branchHead: head, branchCheckedOut: false }, + )) + + expect(destination.kind).toBe('new-worktree') + expect(destination.directory).toBeNull() + expect(destination.branch).toBe('feature') + expect(destination.reasons).toEqual(['the server checkout is on main']) + }) + + it('uses the suffixed branch when the local branch is checked out on the server', () => { + const repoRoot = initRepo('new-worktree-suffix-checked-out') + const head = commit(repoRoot, 'a.txt', 'a') + + const destination = chooseMoveDestination(repoRoot, 'feature', checkouts( + checkout('/repos/repo', 'main', head), + [], + { branchCheckedOut: true, suffixedWorktreeBranch: 'feature-ocm' }, + )) + + expect(destination.kind).toBe('new-worktree') + expect(destination.branch).toBe('feature-ocm') + }) + + it('uses the suffixed branch and explains when the server branch head is not local', () => { + const repoRoot = initRepo('new-worktree-suffix-head') + const head = commit(repoRoot, 'a.txt', 'a') + + const destination = chooseMoveDestination(repoRoot, 'feature', checkouts( + checkout('/repos/repo', 'main', head), + [], + { branchHead: '0000000000000000000000000000000000000000', branchCheckedOut: false, suffixedWorktreeBranch: 'feature-ocm' }, + )) + + expect(destination.kind).toBe('new-worktree') + expect(destination.branch).toBe('feature-ocm') + expect(destination.reasons).toContain('the server branch feature has commits not in your local branch') + }) + + it('explains why an unusable branch worktree was skipped', () => { + const repoRoot = initRepo('unusable-worktree') + const head = commit(repoRoot, 'a.txt', 'a') + + const destination = chooseMoveDestination(repoRoot, 'feature', checkouts( + checkout('/repos/repo', 'main', head), + [checkout('/repos/repo-feature', 'feature', head, true)], + )) + + expect(destination.kind).toBe('new-worktree') + expect(destination.reasons).toContain('the worktree on feature has uncommitted changes') + }) + + it('reports a diverged main checkout with the commit count', () => { + const repoRoot = initRepo('diverged-main') + commit(repoRoot, 'a.txt', 'a') + spawnSync('git', ['checkout', '-b', 'server'], { cwd: repoRoot, stdio: 'ignore' }) + const serverHead = commit(repoRoot, 'server.txt', 'server-work') + spawnSync('git', ['checkout', '-'], { cwd: repoRoot, stdio: 'ignore' }) + commit(repoRoot, 'local.txt', 'local-work') + + const destination = chooseMoveDestination(repoRoot, 'feature', checkouts(checkout('/repos/repo', 'feature', serverHead))) + + expect(destination.kind).toBe('new-worktree') + expect(destination.reasons).toContain('the server checkout is 1 commit(s) ahead of your local branch') + }) +}) + describe('checkPullDivergence', () => { let tmpDir: string @@ -1006,6 +1218,37 @@ describe('mirrorUpFast targets the selected repo', () => { expect(uploadCalls).toEqual([3]) expect(patchCalls).toEqual([3]) }) + + it('forwards the worktree directory and target branch to the bundle upload and patch', async () => { + const repoRoot = join(tmpDir, 'repo-worktree-target') + 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, 'a.txt'), 'a\n') + spawnSync('git', ['add', '.'], { cwd: repoRoot, stdio: 'ignore' }) + spawnSync('git', ['commit', '-m', 'init'], { cwd: repoRoot, stdio: 'ignore' }) + writeFileSync(join(repoRoot, 'a.txt'), 'a-updated\n') + + const api = { + mirrorUploadBundle: vi.fn().mockResolvedValue(undefined), + mirrorPatch: vi.fn().mockResolvedValue({ repoId: 1, fullPath: '/repos/repo-feature-ocm', branch: 'feature-ocm', 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: true, directory: '/repos/repo-feature-ocm', targetBranch: 'feature-ocm' }) + + const bundleOpts = api.mirrorUploadBundle.mock.calls[0]![2] + expect(bundleOpts.directory).toBe('/repos/repo-feature-ocm') + expect(bundleOpts.targetBranch).toBe('feature-ocm') + expect(bundleOpts.requireCurrentBranch).toBeUndefined() + expect(api.mirrorPatch.mock.calls[0]![1].directory).toBe('/repos/repo-feature-ocm') + }) }) describe('mirrorDownFast preflight', () => { @@ -1185,86 +1428,158 @@ describe('mirrorDownFast preflight', () => { expect(existsSync(join(local, 'untracked.txt'))).toBe(true) expect(syncRefCount(local)).toBe(0) }) + + it('updates the local branch from a suffixed worktree branch without creating it locally', async () => { + const server = initRepo('server-suffixed') + commitFile(server, 'main.txt', 'server-main\n') + execSync('git checkout -b feature-ocm', { cwd: server, stdio: 'ignore' }) + const serverFeatureOcmSha = commitFile(server, 'feature.txt', 'server-feature\n') + execSync('git checkout main', { cwd: server, stdio: 'ignore' }) + commitFile(server, 'main.txt', 'server-main-2\n') + const bundle = createBundle(server, 'server-suffixed') + + const local = initRepo('local-suffixed') + commitFile(local, 'main.txt', 'local-main\n') + execSync('git checkout -b feature', { cwd: local, stdio: 'ignore' }) + commitFile(local, 'feature.txt', 'local-feature\n') + + await mirrorDownFast(1, local, fastApi('feature-ocm', bundle) as any, { + force: true, + directory: '/repos/repo-feature-ocm', + sourceBranch: 'feature-ocm', + targetBranch: 'feature', + }) + + expect(getBranchName(local)).toBe('feature') + expect(revRef(local, 'HEAD')).toBe(serverFeatureOcmSha) + expect(revRef(local, 'feature')).toBe(serverFeatureOcmSha) + const localBranches = spawnSync('git', ['for-each-ref', '--format=%(refname:short)', 'refs/heads'], { cwd: local, encoding: 'utf-8' }).stdout.trim().split('\n') + expect(localBranches).not.toContain('feature-ocm') + expect(syncRefCount(local)).toBe(0) + }) }) -describe('ManagerApi target response validation', () => { +describe('ManagerApi mirror checkouts and worktree', () => { + let tmpDir: string + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'mirror-checkouts-test-')) + }) + afterEach(() => { vi.unstubAllGlobals() + rmSync(tmpDir, { recursive: true, force: true }) }) 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 checkout = (directory: string, branch: string | null, head: string | null, dirty = false) => ({ directory, branch, head, dirty }) - const plan = await api.mirrorTargetPlan(1, 'feature') + const checkoutsBody = { + main: checkout('/repos/repo', 'main', 'abc'), + worktrees: [checkout('/repos/repo-feature', 'feature', 'def')], + branchHead: 'def', + branchCheckedOut: true, + suffixedWorktreeBranch: 'feature-ocm', + } - expect(plan.kind).toBe('new') - expect(plan.repoId).toBeNull() - expect(plan.branch).toBe('feature') - }) + it('parses a checkouts response and requests the branch', async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve(checkoutsBody) }) + vi.stubGlobal('fetch', fetchMock) - 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 result = await api.mirrorCheckouts(1, 'feature') - const plan = await api.mirrorTargetPlan(1, 'feature') - - expect(plan.kind).toBe('existing') - expect(plan.repoId).toBe(5) + expect(result.main.branch).toBe('main') + expect(result.worktrees[0]!.directory).toBe('/repos/repo-feature') + expect(result.suffixedWorktreeBranch).toBe('feature-ocm') + expect(String(fetchMock.mock.calls[0]![0])).toContain('/mirror/checkouts?branch=feature') }) - 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' }), - })) + it('rejects a checkouts response that fails schema validation', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({ main: checkout('/repos/repo', 'main', 'abc') }) })) - await expect(api.mirrorTargetPlan(1, 'feature')).rejects.toThrow() + await expect(api.mirrorCheckouts(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 }), - })) + it('maps an old Manager 401 to MANAGER_FEATURE_MISSING for checkouts', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce({ ok: false, status: 401, text: () => Promise.resolve(JSON.stringify({ error: 'Unauthorized' })) }) + .mockResolvedValueOnce({ ok: true, status: 200, json: () => Promise.resolve({ workspaces: [] }) }) + vi.stubGlobal('fetch', fetchMock) + + const error = await api.mirrorCheckouts(1, 'feature').catch((err) => err) - await expect(api.mirrorTargetPlan(1, 'main')).rejects.toThrow() + expect(error).toBeInstanceOf(ManagerApiError) + expect((error as ManagerApiError).code).toBe(MANAGER_FEATURE_MISSING) + expect(isManagerRouteMissing(error)).toBe(true) + expect((error as ManagerApiError).message).toContain('too old for /ocm-move worktrees') }) - it('parses a valid ensure target response', async () => { - vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + it('uploads a bundle prefixed with the patch to the worktree route with branch headers', async () => { + const bundlePath = join(tmpDir, 'repo.bundle') + writeFileSync(bundlePath, 'bundle bytes') + const patch = 'diff --git a/a.txt b/a.txt\n+patched\n' + const fetchMock = 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) + json: () => Promise.resolve({ + repoId: 1, + fullPath: '/repos/repo-feature-ocm', + branch: 'feature-ocm', + head: 'abc', + created: true, + worktreeSetup: { status: 'none' }, + }), + }) + vi.stubGlobal('fetch', fetchMock) + + const created = await api.mirrorCreateWorktree(1, bundlePath, { branch: 'feature', targetBranch: 'feature-ocm', patch }) + + expect(created.fullPath).toBe('/repos/repo-feature-ocm') + expect(created.created).toBe(true) + expect(created.worktreeSetup).toEqual({ status: 'none' }) + const init = fetchMock.mock.calls[0]![1] as RequestInit + expect(init.method).toBe('POST') + expect(init.headers).toMatchObject({ + 'X-OCM-Branch': 'feature', + 'X-OCM-Target-Branch': 'feature-ocm', + 'X-OCM-Patch-Bytes': String(Buffer.byteLength(patch, 'utf-8')), + }) + expect(init.duplex).toBe('half') + const body = await new Response(init.body as ReadableStream).text() + expect(body).toBe(patch + 'bundle bytes') }) - 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' }), - })) + it('maps an old Manager 401 to MANAGER_FEATURE_MISSING for worktree creation', async () => { + const bundlePath = join(tmpDir, 'repo.bundle') + writeFileSync(bundlePath, 'bundle bytes') + const fetchMock = vi.fn() + .mockResolvedValueOnce({ ok: false, status: 401, text: () => Promise.resolve(JSON.stringify({ error: 'Unauthorized' })) }) + .mockResolvedValueOnce({ ok: true, status: 200, json: () => Promise.resolve({ workspaces: [] }) }) + vi.stubGlobal('fetch', fetchMock) + + const error = await api.mirrorCreateWorktree(1, bundlePath, { branch: 'feature', targetBranch: 'feature-ocm', patch: '' }).catch((err) => err) - await expect(api.mirrorEnsureTarget(1, 'feature')).rejects.toThrow() + expect(error).toBeInstanceOf(ManagerApiError) + expect((error as ManagerApiError).code).toBe(MANAGER_FEATURE_MISSING) + expect((error as ManagerApiError).message).toContain('too old for /ocm-move worktrees') }) - 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 }), + it('sends the directory query on head, contains, bundle, and patch requests', async () => { + const calls: string[] = [] + vi.stubGlobal('fetch', vi.fn().mockImplementation(async (url: unknown) => { + calls.push(String(url)) + return { ok: true, json: () => Promise.resolve({}), body: null } })) - await expect(api.mirrorEnsureTarget(1, 'main')).rejects.toThrow() + await api.mirrorHead(1, '/repos/work trees/repo') + await api.mirrorContains(1, 'abc1234', '/repos/work trees/repo') + await api.mirrorDownloadBundle(1, '/repos/work trees/repo') + await api.mirrorPatchSnapshot(1, '/repos/work trees/repo') + + expect(calls).toHaveLength(4) + for (const url of calls) { + expect(new URL(url).searchParams.get('directory')).toBe('/repos/work trees/repo') + } }) }) @@ -1306,4 +1621,435 @@ describe('ManagerApi mirrorUploadBundle strict branch header', () => { expect(headers['X-OCM-Require-Current-Branch']).toBeUndefined() expect(headers['X-OCM-Branch']).toBe('main') }) + + it('sends the target branch header and URL-encoded directory query', async () => { + const bundlePath = join(tmpDir, 'repo.bundle') + writeFileSync(bundlePath, 'bundle bytes') + let capturedUrl: string | undefined + let capturedHeaders: Record | undefined + vi.stubGlobal('fetch', vi.fn().mockImplementation(async (url: unknown, init?: RequestInit) => { + capturedUrl = String(url) + capturedHeaders = init!.headers as Record + return { ok: true, json: () => Promise.resolve({ repoId: 1, fullPath: '/repos/x', branch: 'feature-ocm', head: 'abc', created: false }) } + })) + + await api.mirrorUploadBundle(1, bundlePath, { + branch: 'feature', + force: true, + directory: '/repos/work trees/repo', + targetBranch: 'feature-ocm', + }) + + const parsed = new URL(capturedUrl!) + expect(parsed.searchParams.get('force')).toBe('1') + expect(parsed.searchParams.get('directory')).toBe('/repos/work trees/repo') + expect(capturedHeaders!['X-OCM-Target-Branch']).toBe('feature-ocm') + expect(capturedHeaders!['X-OCM-Branch']).toBe('feature') + }) +}) + +describe('resolveBranchCheckout', () => { + let tmpDir: string + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'branch-checkout-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' }) + writeFileSync(join(repoRoot, 'a.txt'), 'a\n') + spawnSync('git', ['add', '.'], { cwd: repoRoot, stdio: 'ignore' }) + spawnSync('git', ['commit', '-m', 'init'], { cwd: repoRoot, stdio: 'ignore' }) + return repoRoot + } + + const checkout = (directory: string, branch: string | null, head: string | null, dirty = false): MirrorCheckoutState => ({ directory, branch, head, dirty }) + + const body = (overrides: Partial = {}): MirrorCheckoutsResponse => ({ + main: checkout('/repos/repo', 'main', 'abc'), + worktrees: [], + branchHead: null, + branchCheckedOut: false, + suffixedWorktreeBranch: 'feature-ocm', + ...overrides, + }) + + it('returns nothing when the local HEAD is detached', async () => { + const repoRoot = initRepo('detached') + execSync('git checkout --detach', { cwd: repoRoot, stdio: 'ignore' }) + const api = { mirrorCheckouts: vi.fn() } as any + + await expect(resolveBranchCheckout(api, 1, repoRoot)).resolves.toEqual({}) + expect(api.mirrorCheckouts).not.toHaveBeenCalled() + }) + + it('returns nothing when the main checkout is already on the local branch', async () => { + const repoRoot = initRepo('main-branch') + const api = { mirrorCheckouts: vi.fn().mockResolvedValue(body()) } as any + + await expect(resolveBranchCheckout(api, 1, repoRoot)).resolves.toEqual({}) + }) + + it('selects the first worktree and its branch when main is elsewhere', async () => { + const repoRoot = initRepo('worktree') + execSync('git checkout -b feature', { cwd: repoRoot, stdio: 'ignore' }) + const api = { + mirrorCheckouts: vi.fn().mockResolvedValue(body({ + worktrees: [checkout('/repos/repo-feature-ocm', 'feature-ocm', 'def')], + })), + } as any + + await expect(resolveBranchCheckout(api, 1, repoRoot)).resolves.toEqual({ + directory: '/repos/repo-feature-ocm', + targetBranch: 'feature-ocm', + }) + }) + + it('omits the target branch when the worktree is on the local branch', async () => { + const repoRoot = initRepo('worktree-exact') + execSync('git checkout -b feature', { cwd: repoRoot, stdio: 'ignore' }) + const api = { + mirrorCheckouts: vi.fn().mockResolvedValue(body({ + worktrees: [checkout('/repos/repo-feature', 'feature', 'def')], + })), + } as any + + await expect(resolveBranchCheckout(api, 1, repoRoot)).resolves.toEqual({ + directory: '/repos/repo-feature', + targetBranch: undefined, + }) + }) + + it('returns nothing when no worktree matches', async () => { + const repoRoot = initRepo('no-worktree') + execSync('git checkout -b feature', { cwd: repoRoot, stdio: 'ignore' }) + const api = { mirrorCheckouts: vi.fn().mockResolvedValue(body()) } as any + + await expect(resolveBranchCheckout(api, 1, repoRoot)).resolves.toEqual({}) + }) + + it('falls back silently when the route is missing on an older Manager', async () => { + const repoRoot = initRepo('route-missing') + execSync('git checkout -b feature', { cwd: repoRoot, stdio: 'ignore' }) + const api = { + mirrorCheckouts: vi.fn().mockRejectedValue(new ManagerApiError('missing', 401, MANAGER_FEATURE_MISSING, 'op')), + } as any + + await expect(resolveBranchCheckout(api, 1, repoRoot)).resolves.toEqual({}) + }) + + it('falls back silently on the old target protocol 410', async () => { + const repoRoot = initRepo('gone') + execSync('git checkout -b feature', { cwd: repoRoot, stdio: 'ignore' }) + const api = { + mirrorCheckouts: vi.fn().mockRejectedValue(new ManagerApiError('gone', 410, 'cli_too_old', 'op')), + } as any + + await expect(resolveBranchCheckout(api, 1, repoRoot)).resolves.toEqual({}) + }) + + it('rethrows unrelated failures', async () => { + const repoRoot = initRepo('boom') + execSync('git checkout -b feature', { cwd: repoRoot, stdio: 'ignore' }) + const api = { mirrorCheckouts: vi.fn().mockRejectedValue(new Error('boom')) } as any + + await expect(resolveBranchCheckout(api, 1, repoRoot)).rejects.toThrow('boom') + }) +}) + +describe('mirrorUpFast worktree creation', () => { + let tmpDir: string + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'mirror-upfast-worktree-test-')) + }) + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }) + }) + + it('sends the working-tree patch with the worktree create request and skips the patch shortcut', async () => { + const repoRoot = join(tmpDir, 'repo') + 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, 'a.txt'), 'a\n') + spawnSync('git', ['add', '.'], { cwd: repoRoot, stdio: 'ignore' }) + spawnSync('git', ['commit', '-m', 'init'], { cwd: repoRoot, stdio: 'ignore' }) + writeFileSync(join(repoRoot, 'a.txt'), 'a changed\n') + writeFileSync(join(repoRoot, 'b.txt'), 'untracked\n') + + const api = { + mirrorCreateWorktree: vi.fn().mockResolvedValue({ + repoId: 1, + fullPath: '/repos/repo-feature-ocm', + branch: 'feature-ocm', + head: 'abc', + created: true, + worktreeSetup: { status: 'none' }, + }), + mirrorUploadBundle: vi.fn(), + mirrorPatch: vi.fn(), + } + + const plan = { + repoRoot, + localProjectId: 'proj', + matched: [{ repoId: 1, name: 'repo', projectId: 'proj', branch: 'main' }], + } + + const phases: MirrorUpFastPhase[] = [] + const result = await mirrorUpFast(plan, { + api: api as any, + force: true, + createWorktree: { targetBranch: 'feature-ocm' }, + onPhase: (p) => phases.push(p), + }) + + expect(api.mirrorUploadBundle).not.toHaveBeenCalled() + expect(api.mirrorPatch).not.toHaveBeenCalled() + expect(api.mirrorCreateWorktree).toHaveBeenCalledTimes(1) + const opts = api.mirrorCreateWorktree.mock.calls[0]![2] + expect(opts.branch).toBe(getBranchName(repoRoot)) + expect(opts.targetBranch).toBe('feature-ocm') + expect(opts.patch).toContain('a.txt') + expect(opts.patch).toContain('b.txt') + expect(result.created).toBe(true) + expect(result.fullPath).toBe('/repos/repo-feature-ocm') + expect(result.worktreeSetup).toEqual({ status: 'none' }) + expect(phases.map((p) => p.kind)).toEqual(['bundling', 'uploading']) + }) +}) + +describe('divergence checks target the selected directory', () => { + let tmpDir: string + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'divergence-directory-test-')) + }) + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }) + }) + + it('passes the directory to mirrorHead for push divergence', async () => { + const api = { mirrorHead: vi.fn().mockResolvedValue({ repoId: 1, branch: 'feature-ocm', head: null, dirty: false }) } as any + + await checkPushDivergence('/repo', api, 1, '/repos/repo-feature-ocm') + + expect(api.mirrorHead).toHaveBeenCalledWith(1, '/repos/repo-feature-ocm') + }) + + it('passes the directory to mirrorContains and mirrorHead for pull divergence', async () => { + const repoRoot = join(tmpDir, 'repo') + 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, 'a.txt'), 'a\n') + spawnSync('git', ['add', '.'], { cwd: repoRoot, stdio: 'ignore' }) + spawnSync('git', ['commit', '-m', 'init'], { cwd: repoRoot, stdio: 'ignore' }) + + const api = { + mirrorContains: vi.fn().mockResolvedValue({ contained: false }), + mirrorHead: vi.fn().mockResolvedValue({ repoId: 1, branch: 'feature-ocm', head: null, dirty: false }), + } as any + + await checkPullDivergence(repoRoot, api, 1, '/repos/repo-feature-ocm') + + expect(api.mirrorContains).toHaveBeenCalledWith(1, expect.any(String), '/repos/repo-feature-ocm') + expect(api.mirrorHead).toHaveBeenCalledWith(1, '/repos/repo-feature-ocm') + }) +}) + +describe('cmdPush and cmdPull checkout selection', () => { + let tmpDir: string + let repoRoot: string + let originalCwd: string + + const REPO = { + repoId: 1, + name: 'repo', + branch: 'feature', + cloneStatus: 'ready', + directory: '/repos/repo', + projectId: ME_REPO_ID, + extra: { repoId: 1, localPath: 'repo', fullPath: '/repos/repo' }, + } + + const suffixedCheckouts = { + main: { directory: '/repos/repo', branch: 'main', head: 'abc', dirty: false }, + worktrees: [{ directory: '/repos/repo-feature-ocm', branch: 'feature-ocm', head: 'abc', dirty: false }], + branchHead: 'abc', + branchCheckedOut: true, + suffixedWorktreeBranch: 'feature-ocm', + } + + const exactCheckouts = { + main: { directory: '/repos/repo', branch: 'main', head: 'abc', dirty: false }, + worktrees: [{ directory: '/repos/repo-feature', branch: 'feature', head: 'abc', dirty: false }], + branchHead: 'abc', + branchCheckedOut: true, + suffixedWorktreeBranch: 'feature-ocm', + } + + const streamOf = (buf: Buffer): ReadableStream => + new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(buf)) + controller.close() + }, + }) + + beforeEach(() => { + originalCwd = process.cwd() + tmpDir = mkdtempSync(join(tmpdir(), 'ocm-push-pull-test-')) + repoRoot = join(tmpDir, 'repo') + mkdirSync(repoRoot) + spawnSync('git', ['init', '-b', 'feature'], { 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' }) + spawnSync('git', ['remote', 'add', 'origin', 'https://github.com/me/repo.git'], { cwd: repoRoot, stdio: 'ignore' }) + writeFileSync(join(repoRoot, 'a.txt'), 'a\n') + spawnSync('git', ['add', '.'], { cwd: repoRoot, stdio: 'ignore' }) + spawnSync('git', ['commit', '-m', 'init'], { cwd: repoRoot, stdio: 'ignore' }) + process.chdir(repoRoot) + }) + + afterEach(() => { + process.chdir(originalCwd) + rmSync(tmpDir, { recursive: true, force: true }) + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + async function loadCli() { + vi.resetModules() + mockStateModule({ managerUrl: 'http://localhost:5003' }) + mockTokenStoreModule({ token: 'tok' }) + return import('../bin/ocm') + } + + it('threads the selected worktree directory into a fast push', async () => { + const calls: { url: string; init?: RequestInit }[] = [] + const localHead = execSync('git rev-parse HEAD', { cwd: repoRoot, encoding: 'utf-8' }).trim() + vi.stubGlobal('fetch', vi.fn().mockImplementation(async (url: unknown, init?: RequestInit) => { + const u = String(url) + calls.push({ url: u, init }) + if (u.includes('/opencode-workspaces')) return { ok: true, status: 200, json: () => Promise.resolve({ workspaces: [REPO] }) } + if (u.includes('/mirror/checkouts')) return { ok: true, status: 200, json: () => Promise.resolve(suffixedCheckouts) } + if (u.includes('/mirror/head')) return { ok: true, status: 200, json: () => Promise.resolve({ repoId: 1, branch: 'feature-ocm', head: localHead, dirty: false }) } + if (u.includes('/mirror/bundle')) return { ok: true, status: 200, json: () => Promise.resolve({ repoId: 1, fullPath: '/repos/repo-feature-ocm', branch: 'feature-ocm', head: 'abc', created: false }) } + if (u.includes('/mirror/patch')) return { ok: true, status: 200, json: () => Promise.resolve({ repoId: 1, fullPath: '/repos/repo-feature-ocm', branch: 'feature-ocm', head: 'abc', created: false, applied: true }) } + throw new Error(`unexpected fetch: ${u}`) + })) + + const { cmdPush } = await loadCli() + await cmdPush([]) + + const headCall = calls.find((c) => c.url.includes('/mirror/head')) + expect(new URL(headCall!.url).searchParams.get('directory')).toBe('/repos/repo-feature-ocm') + const bundleCall = calls.find((c) => c.url.includes('/mirror/bundle')) + expect(new URL(bundleCall!.url).searchParams.get('directory')).toBe('/repos/repo-feature-ocm') + expect((bundleCall!.init!.headers as Record)['X-OCM-Target-Branch']).toBe('feature-ocm') + expect(calls.some((c) => c.url.includes('/mirror/begin'))).toBe(false) + }) + + it('dies without falling back to a full push when the worktree push fails', async () => { + const calls: { url: string }[] = [] + const stderr: string[] = [] + const localHead = execSync('git rev-parse HEAD', { cwd: repoRoot, encoding: 'utf-8' }).trim() + vi.stubGlobal('fetch', vi.fn().mockImplementation(async (url: unknown) => { + const u = String(url) + calls.push({ url: u }) + if (u.includes('/opencode-workspaces')) return { ok: true, status: 200, json: () => Promise.resolve({ workspaces: [REPO] }) } + if (u.includes('/mirror/checkouts')) return { ok: true, status: 200, json: () => Promise.resolve(suffixedCheckouts) } + if (u.includes('/mirror/head')) return { ok: true, status: 200, json: () => Promise.resolve({ repoId: 1, branch: 'feature-ocm', head: localHead, dirty: false }) } + if (u.includes('/mirror/bundle')) return { ok: false, status: 409, text: () => Promise.resolve(JSON.stringify({ error: 'branch_in_use' })) } + throw new Error(`unexpected fetch: ${u}`) + })) + vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => { + stderr.push(String(chunk)) + return true + }) + vi.spyOn(process, 'exit').mockImplementation((() => { throw new Error('exit') }) as never) + + const { cmdPush } = await loadCli() + await expect(cmdPush([])).rejects.toThrow('exit') + + expect(stderr.join('')).toContain('Not falling back to a full mirror') + expect(calls.some((c) => c.url.includes('/mirror/begin'))).toBe(false) + }) + + it('threads the selected worktree directory into a fast pull', async () => { + const server = join(tmpDir, 'server') + mkdirSync(server) + spawnSync('git', ['init', '-b', 'feature'], { cwd: server, stdio: 'ignore' }) + spawnSync('git', ['config', 'user.email', 'test@test.com'], { cwd: server, stdio: 'ignore' }) + spawnSync('git', ['config', 'user.name', 'Test'], { cwd: server, stdio: 'ignore' }) + writeFileSync(join(server, 'a.txt'), 'server\n') + spawnSync('git', ['add', '.'], { cwd: server, stdio: 'ignore' }) + spawnSync('git', ['commit', '-m', 'server'], { cwd: server, stdio: 'ignore' }) + const serverSha = execSync('git rev-parse HEAD', { cwd: server, encoding: 'utf-8' }).trim() + const bundleFile = join(tmpDir, 'server.bundle') + execSync(`git bundle create "${bundleFile}" --all`, { cwd: server }) + const bundleBytes = readFileSync(bundleFile) + + const calls: { url: string }[] = [] + vi.stubGlobal('fetch', vi.fn().mockImplementation(async (url: unknown) => { + const u = String(url) + calls.push({ url: u }) + if (u.includes('/opencode-workspaces')) return { ok: true, status: 200, json: () => Promise.resolve({ workspaces: [REPO] }) } + if (u.includes('/mirror/checkouts')) return { ok: true, status: 200, json: () => Promise.resolve(exactCheckouts) } + if (u.includes('/mirror/contains')) return { ok: true, status: 200, json: () => Promise.resolve({ repoId: 1, contained: true }) } + if (u.includes('/mirror/head')) return { ok: true, status: 200, json: () => Promise.resolve({ repoId: 1, branch: 'feature', head: serverSha, dirty: false }) } + if (u.includes('/mirror/patch')) return { ok: true, status: 200, json: () => Promise.resolve({ repoId: 1, branch: 'feature', head: serverSha, patch: '' }) } + if (u.includes('/mirror/bundle')) return { ok: true, status: 200, body: streamOf(bundleBytes) } + throw new Error(`unexpected fetch: ${u}`) + })) + + const { cmdPull } = await loadCli() + await cmdPull([]) + + expect(getBranchName(repoRoot)).toBe('feature') + expect(execSync('git rev-parse HEAD', { cwd: repoRoot, encoding: 'utf-8' }).trim()).toBe(serverSha) + for (const path of ['/mirror/contains', '/mirror/bundle', '/mirror/patch']) { + const call = calls.find((c) => c.url.includes(path)) + expect(new URL(call!.url).searchParams.get('directory')).toBe('/repos/repo-feature') + } + expect(calls.some((c) => c.url.includes('/mirror?'))).toBe(false) + }) + + it('dies without falling back to a full pull when the worktree pull fails', async () => { + const calls: { url: string }[] = [] + const stderr: string[] = [] + vi.stubGlobal('fetch', vi.fn().mockImplementation(async (url: unknown) => { + const u = String(url) + calls.push({ url: u }) + if (u.includes('/opencode-workspaces')) return { ok: true, status: 200, json: () => Promise.resolve({ workspaces: [REPO] }) } + if (u.includes('/mirror/checkouts')) return { ok: true, status: 200, json: () => Promise.resolve(exactCheckouts) } + if (u.includes('/mirror/patch')) return { ok: false, status: 500, text: () => Promise.resolve('boom') } + throw new Error(`unexpected fetch: ${u}`) + })) + vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => { + stderr.push(String(chunk)) + return true + }) + vi.spyOn(process, 'exit').mockImplementation((() => { throw new Error('exit') }) as never) + + const { cmdPull } = await loadCli() + await expect(cmdPull(['--force'])).rejects.toThrow('exit') + + expect(stderr.join('')).toContain('Not falling back to a full mirror') + expect(calls.some((c) => c.url.includes('/mirror?'))).toBe(false) + }) }) diff --git a/ocm-cli/test/tui-plugin.test.ts b/ocm-cli/test/tui-plugin.test.ts index 9e14bea0f..42f9ea172 100644 --- a/ocm-cli/test/tui-plugin.test.ts +++ b/ocm-cli/test/tui-plugin.test.ts @@ -12,15 +12,14 @@ const mocks = vi.hoisted(() => ({ fetchRepos: vi.fn(), toRemoteRepoSummaries: vi.fn((repos: unknown) => repos), prepareMirror: vi.fn(), - checkPushDivergence: vi.fn(), - describePushDivergence: vi.fn(() => []), + chooseMoveDestination: vi.fn(), mirrorUpFast: vi.fn(), pickMatchedRepo: vi.fn(), getBranchName: vi.fn(), createManagerSessionTransfer: vi.fn(), setPendingWarp: vi.fn(), runPendingWarp: vi.fn(), - mirrorTargetPlan: vi.fn(), + mirrorCheckouts: vi.fn(), warmRepoProxy: vi.fn(), })) @@ -38,8 +37,7 @@ vi.mock('../src/manager-repos.js', () => ({ vi.mock('../src/mirror.js', () => ({ prepareMirror: mocks.prepareMirror, - checkPushDivergence: mocks.checkPushDivergence, - describePushDivergence: mocks.describePushDivergence, + chooseMoveDestination: mocks.chooseMoveDestination, mirrorUpFast: mocks.mirrorUpFast, pickMatchedRepo: mocks.pickMatchedRepo, })) @@ -63,8 +61,7 @@ vi.mock('../src/manager-api.js', () => ({ public readonly managerUrl: string, public readonly token: string, ) {} - mirrorTargetPlan = mocks.mirrorTargetPlan - mirrorEnsureTarget = vi.fn() + mirrorCheckouts = mocks.mirrorCheckouts }, ManagerApiError: class ManagerApiError extends Error { constructor( @@ -163,22 +160,19 @@ function configureMove(fake: ReturnType) { mocks.prepareMirror.mockResolvedValue({ repoRoot: '/Users/x/repo', localProjectId: 'proj_1', matched: [matched] }) mocks.getBranchName.mockReturnValue('main') mocks.pickMatchedRepo.mockReturnValue(matched) - mocks.mirrorTargetPlan.mockResolvedValue({ + mocks.mirrorCheckouts.mockResolvedValue({ + main: { directory: '/workspace/repos/repo', branch: 'main', head: null, dirty: false }, + worktrees: [], + branchHead: null, + branchCheckedOut: false, + suffixedWorktreeBranch: 'main-ocm', + }) + mocks.chooseMoveDestination.mockReturnValue({ kind: 'in-place', - repoId: 1, - fullPath: '/workspace/repos/repo', - localPath: '/workspace/repos/repo', + directory: '/workspace/repos/repo', branch: 'main', - currentBranch: null, + reasons: [], }) - mocks.checkPushDivergence.mockResolvedValue({ - serverHead: null, - serverBranch: 'main', - serverDirty: false, - diverged: false, - lostCommits: 0, - }) - mocks.describePushDivergence.mockReturnValue([]) mocks.mirrorUpFast.mockResolvedValue({ repoId: 1, fullPath: '/workspace/repos/repo', @@ -331,7 +325,7 @@ describe('ocm.session.move command', () => { }) function expectNothingPushed(fake: ReturnType, importSession: ReturnType) { - expect(mocks.mirrorTargetPlan).not.toHaveBeenCalled() + expect(mocks.mirrorCheckouts).not.toHaveBeenCalled() expect(fake.confirm).not.toHaveBeenCalled() expect(mocks.mirrorUpFast).not.toHaveBeenCalled() expect(fake.exportMock).not.toHaveBeenCalled() @@ -401,4 +395,132 @@ describe('ocm.session.move command', () => { expect(importSession).not.toHaveBeenCalled() expect(fake.toast).toHaveBeenCalledWith({ variant: 'error', message: tooOld }) }) + + it('refuses to move from a detached HEAD before pushing anything', async () => { + const fake = createFakeContext() + configureMove(fake) + const { importSession } = stubTransfer() + mocks.getBranchName.mockReturnValue(null) + + await invokeMove(fake) + + expect(mocks.mirrorCheckouts).not.toHaveBeenCalled() + expect(mocks.mirrorUpFast).not.toHaveBeenCalled() + expect(fake.exportMock).not.toHaveBeenCalled() + expect(importSession).not.toHaveBeenCalled() + expect(fake.toast).toHaveBeenCalledWith(expect.objectContaining({ variant: 'error', message: expect.stringContaining('detached HEAD') })) + }) + + it('pushes in place with the strict current-branch guard when the main checkout is safe', async () => { + const fake = createFakeContext() + configureMove(fake) + const { importSession } = stubTransfer() + fake.exportMock.mockResolvedValue(makeTransfer()) + + await invokeMove(fake) + + expect(mocks.mirrorCheckouts).toHaveBeenCalledWith(1, 'main') + expect(mocks.mirrorUpFast).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ force: true, requireCurrentBranch: true }), + ) + const pushOpts = mocks.mirrorUpFast.mock.calls[0]![1] as { directory?: string; targetBranch?: string } + expect(pushOpts.directory).toBeUndefined() + expect(pushOpts.targetBranch).toBeUndefined() + expect(importSession).toHaveBeenCalledWith('/workspace/repos/repo', expect.anything()) + }) + + it('pushes into an existing suffixed worktree directory with the strict current-branch guard', async () => { + const fake = createFakeContext() + configureMove(fake) + const { importSession } = stubTransfer() + fake.exportMock.mockResolvedValue(makeTransfer()) + mocks.chooseMoveDestination.mockReturnValue({ + kind: 'existing-worktree', + directory: '/workspace/repos/repo-feature-ocm', + branch: 'feature-ocm', + reasons: [], + }) + mocks.mirrorUpFast.mockResolvedValue({ + repoId: 1, + fullPath: '/workspace/repos/repo-feature-ocm', + branch: 'feature-ocm', + head: null, + created: false, + }) + + await invokeMove(fake) + + expect(mocks.mirrorUpFast).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + force: true, + requireCurrentBranch: true, + directory: '/workspace/repos/repo-feature-ocm', + targetBranch: 'feature-ocm', + }), + ) + expect(importSession).toHaveBeenCalledWith('/workspace/repos/repo-feature-ocm', expect.anything()) + }) + + it('creates a new worktree through the fast push when no safe checkout exists', async () => { + const fake = createFakeContext() + configureMove(fake) + const { importSession } = stubTransfer() + fake.exportMock.mockResolvedValue(makeTransfer()) + mocks.chooseMoveDestination.mockReturnValue({ + kind: 'new-worktree', + directory: null, + branch: 'feature-ocm', + reasons: ['the server checkout is on main'], + }) + mocks.mirrorUpFast.mockResolvedValue({ + repoId: 1, + fullPath: '/workspace/repos/repo-feature-ocm', + branch: 'feature-ocm', + head: null, + created: true, + worktreeSetup: { status: 'none' }, + }) + + await invokeMove(fake) + + expect(mocks.mirrorUpFast).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + force: true, + createWorktree: { targetBranch: 'feature-ocm' }, + }), + ) + expect(importSession).toHaveBeenCalledWith('/workspace/repos/repo-feature-ocm', expect.anything()) + }) + + it('warns without aborting when worktree setup fails', async () => { + const fake = createFakeContext() + configureMove(fake) + const { importSession } = stubTransfer() + fake.exportMock.mockResolvedValue(makeTransfer()) + mocks.chooseMoveDestination.mockReturnValue({ + kind: 'new-worktree', + directory: null, + branch: 'feature-ocm', + reasons: [], + }) + mocks.mirrorUpFast.mockResolvedValue({ + repoId: 1, + fullPath: '/workspace/repos/repo-feature-ocm', + branch: 'feature-ocm', + head: null, + created: true, + worktreeSetup: { status: 'failed', error: 'setup script exploded' }, + }) + + await invokeMove(fake) + + expect(fake.toast).toHaveBeenCalledWith(expect.objectContaining({ + variant: 'warning', + message: expect.stringContaining('setup script exploded'), + })) + expect(importSession).toHaveBeenCalledWith('/workspace/repos/repo-feature-ocm', expect.anything()) + }) }) diff --git a/shared/src/schemas/repo.ts b/shared/src/schemas/repo.ts index 0f1fd2e8f..b6bdad39a 100644 --- a/shared/src/schemas/repo.ts +++ b/shared/src/schemas/repo.ts @@ -1,5 +1,6 @@ import { z } from 'zod' import { getRepoDirectoryNameError } from '../utils/repo' +import { WorktreeSetupResultSchema } from './project-config' export const RepoStatusSchema = z.enum(['cloning', 'ready', 'error']) @@ -147,27 +148,32 @@ export const MirrorTargetBranchRequestSchema = z.object({ 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 MirrorCheckoutStateSchema = z.object({ + directory: z.string().min(1), + branch: z.string().min(1).nullable(), + head: z.string().min(1).nullable(), + dirty: z.boolean(), }) -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 MirrorCheckoutState = z.infer + +export const MirrorCheckoutsResponseSchema = z.object({ + main: MirrorCheckoutStateSchema, + worktrees: z.array(MirrorCheckoutStateSchema), + branchHead: z.string().min(1).nullable(), + branchCheckedOut: z.boolean(), + suffixedWorktreeBranch: z.string().min(1), +}) -export type MirrorTargetPlanResponse = z.infer +export type MirrorCheckoutsResponse = z.infer -export const MirrorTargetEnsureResponseSchema = z.object({ - repoId: z.number().int().positive(), +export const MirrorWorktreeCreateResponseSchema = z.object({ + repoId: z.number(), fullPath: z.string().min(1), - localPath: z.string().min(1), - branch: z.string().min(1), - created: z.boolean(), + branch: z.string().min(1).nullable(), + head: z.string().min(1).nullable(), + created: z.literal(true), + worktreeSetup: WorktreeSetupResultSchema, }) -export type MirrorTargetEnsureResponse = z.infer +export type MirrorWorktreeCreateResponse = z.infer