From bd7c3e3fefbcfb0b49627f220c2fb1154494d284 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:47:02 -0400 Subject: [PATCH 1/3] feat(ocm): move sessions into OpenCode worktrees instead of replacing the Manager checkout /ocm-move previously failed when the Manager checkout was dirty, behind, or on another branch. Choose the destination instead: replace in place only when the checkout is on the local branch, clean, and has nothing the local branch lacks; otherwise reuse an existing OpenCode worktree on that branch when it is equally safe; otherwise create a new OpenCode worktree through the same service the UI and multi-runs use, so it appears in the repo's worktree lists. The new worktree's branch is the local branch when free, else -ocm, -ocm-2 and so on. Server work is never discarded, the confirmation states the destination and why the main checkout was skipped, and a detached HEAD is refused. The mirror push endpoints (bundle upload and patch) accept a worktree directory, rejected unless it belongs to the repo, and a new endpoint creates the worktree. The old - path that registered a plain git worktree as a separate Manager repo is removed. --- backend/src/routes/internal/index.ts | 2 +- backend/src/routes/internal/repo-mirror.ts | 197 +++++++--- backend/src/services/repo.ts | 63 +--- .../test/routes/internal/repo-mirror.test.ts | 344 +++++++++++------- backend/test/services/repo-git.test.ts | 35 -- .../test/services/repo-mirror-target.test.ts | 146 -------- ocm-cli/README.md | 38 +- ocm-cli/src/manager-api.ts | 46 ++- ocm-cli/src/mirror.ts | 71 +++- ocm-cli/src/tui-plugin.ts | 85 ++--- ocm-cli/test/mirror.test.ts | 280 +++++++++++--- ocm-cli/test/tui-plugin.test.ts | 132 +++++-- shared/src/schemas/repo.ts | 33 +- 13 files changed, 891 insertions(+), 581 deletions(-) delete mode 100644 backend/test/services/repo-mirror-target.test.ts 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..a2f2fb12c 100644 --- a/backend/src/routes/internal/repo-mirror.ts +++ b/backend/src/routes/internal/repo-mirror.ts @@ -10,11 +10,25 @@ import * as fsp from 'fs/promises' import { getReposPath } from '@opencode-manager/shared/config/env' import { MirrorTargetBranchRequestSchema, - type MirrorTargetEnsureResponse, - type MirrorTargetPlanResponse, + type MirrorCheckoutState, + type MirrorMoveTargetResponse, + type MirrorWorktreeCreateResponse, } from '@opencode-manager/shared/schemas' +import { isWorktreeSibling, sanitizeRepoDirectoryName } 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, + getSiblingRepos, + 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' @@ -55,6 +69,7 @@ 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)' @@ -127,7 +142,52 @@ 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 checkoutState(directory: string): Promise { + const branch = await safeGitOut(directory, ['symbolic-ref', '--quiet', '--short', 'HEAD']) + const head = await safeGitOut(directory, ['rev-parse', 'HEAD']) + const status = await safeGitOut(directory, ['status', '--porcelain', '--untracked-files=all']) + return { + directory, + branch: branch?.trim() || null, + head: head?.trim() || null, + dirty: (status?.trim().length ?? 0) > 0, + } +} + +async function listMirrorWorktreeSiblings( + db: Database, + repo: Repo, + gitAuthService: GitAuthService, + openCodeClient: OpenCodeClient, +): Promise> { + const siblings = await getSiblingRepos(db, repo.id, gitAuthService.getGitEnvironment(), openCodeClient, { includeBranch: false }) + return siblings.filter((sibling) => isWorktreeSibling(sibling) && sibling.worktreeSource !== 'schedule') +} + +async function resolveMirrorDirectory( + db: Database, + repo: Repo, + directory: string | undefined, + gitAuthService: GitAuthService, + openCodeClient: OpenCodeClient, +): Promise { + return resolveRepoWorkingDirectory(repo, directory, () => + listMirrorWorktreeSiblings(db, repo, gitAuthService, openCodeClient), + ) +} + +function nextWorktreeBranch(branch: string, localBranches: Set, checkedOut: Set): string { + if (!checkedOut.has(branch)) return branch + let candidate = `${branch}-ocm` + let suffix = 2 + while (localBranches.has(candidate) || checkedOut.has(candidate)) { + candidate = `${branch}-ocm-${suffix}` + suffix += 1 + } + return candidate +} + +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 +209,28 @@ 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 skipRef = targetBranch ?? branch const updates: string[] = [] for (const [name, sha] of incoming) { - if (targetSha !== undefined && name === branch) continue + if (skipRef !== null && name === skipRef) continue if (locked.has(name)) continue updates.push(`update refs/heads/${name} ${sha}\n`) } @@ -198,7 +260,12 @@ async function createBundle(fullPath: string): Promise { return bundlePath } -export function createInternalRepoMirrorRoutes(db: Database) { +export function createInternalRepoMirrorRoutes( + db: Database, + openCodeClient: OpenCodeClient, + gitAuthService: GitAuthService, + repoWorkspaces: RepoWorkspaceService, +) { const app = new Hono() app.post('/:repoId/mirror', (c) => { @@ -398,8 +465,12 @@ 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) } @@ -411,21 +482,24 @@ export function createInternalRepoMirrorRoutes(db: Database) { const bundleDir = mkdtempSync(join(stagingRoot, 'bundle-upload-')) const bundlePath = join(bundleDir, 'repo.bundle') const branch = c.req.header('x-ocm-branch')?.trim() || null + const targetBranch = c.req.header('x-ocm-target-branch')?.trim() || branch const requireCurrentBranch = c.req.header('x-ocm-require-current-branch')?.trim() === '1' try { const body = Readable.fromWeb(rawBody as unknown as Parameters[0]) await pipeline(body, createWriteStream(bundlePath)) - await importBundle(repo.fullPath, bundlePath, branch, requireCurrentBranch, force) + await importBundle(directory, bundlePath, branch, requireCurrentBranch, force, targetBranch) - 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 branchName = await safeGitOut(directory, ['rev-parse', '--abbrev-ref', 'HEAD']) + const head = await safeGitOut(directory, ['rev-parse', 'HEAD']) + if (directory === repo.fullPath) { + if (branchName) updateRepoBranch(db, repoId, branchName.trim()) + updateLastPulled(db, repoId) + } return c.json({ repoId, - fullPath: repo.fullPath, + fullPath: directory, branch: branchName?.trim() || null, head: head?.trim() || null, created: false, @@ -448,18 +522,35 @@ export function createInternalRepoMirrorRoutes(db: Database) { if (!repo) return c.json({ error: 'repo not found' }, 404) try { - const plan = await planMirrorTarget(db, repo, branch) - const response: MirrorTargetPlanResponse = plan.kind === 'new' - ? { kind: plan.kind, repoId: null, fullPath: plan.fullPath, localPath: plan.localPath, branch, currentBranch: plan.currentBranch } - : { kind: plan.kind, repoId: plan.repo.id, fullPath: plan.repo.fullPath, localPath: plan.repo.localPath, branch, currentBranch: plan.currentBranch } + const main = await checkoutState(repo.fullPath) + const siblings = await listMirrorWorktreeSiblings(db, repo, gitAuthService, openCodeClient) + let branchWorktree: MirrorCheckoutState | null = null + for (const sibling of siblings) { + const state = await checkoutState(sibling.fullPath) + if (state.branch === branch) { + branchWorktree = state + break + } + } + + const gitWorktrees = await listGitWorktrees(repo.fullPath, gitAuthService.getGitEnvironment()) + const checkedOut = new Set( + gitWorktrees.map((worktree) => worktree.branch).filter((name): name is string => name !== null), + ) + const localBranches = await listLocalBranchNames(repo.fullPath) + const response: MirrorMoveTargetResponse = { + main, + branchWorktree, + newWorktreeBranch: nextWorktreeBranch(branch, localBranches, checkedOut), + } return c.json(response) } catch (error) { - logger.error('mirror target plan failed:', error) + logger.error('mirror target 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 @@ -474,18 +565,17 @@ export function createInternalRepoMirrorRoutes(db: Database) { const repo = getRepoById(db, repoId) if (!repo) return c.json({ error: 'repo not found' }, 404) + const gitWorktrees = await listGitWorktrees(repo.fullPath, gitAuthService.getGitEnvironment()) + if (gitWorktrees.some((worktree) => worktree.branch === branch)) { + return c.json({ error: 'branch_in_use', message: `branch '${branch}' is checked out in a worktree of this repository` }, 409) + } + 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 worktree = await repoWorkspaces.create(repo, { name: sanitizeRepoDirectoryName(branch, 'move') }) + const response: MirrorWorktreeCreateResponse = { directory: worktree.directory, branch } 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) } }) @@ -497,14 +587,12 @@ 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 state = await checkoutState(repo.fullPath) 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, }) }) @@ -559,29 +647,38 @@ 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 currentHead = await safeGitOut(directory, ['rev-parse', 'HEAD']) const currentHeadTrimmed = currentHead?.trim() || null const baseHead = body.baseHead?.trim() || null if (baseHead && currentHeadTrimmed && baseHead !== currentHeadTrimmed) { return c.json({ error: 'head_mismatch', message: 'Manager repo HEAD differs from patch base' }, 409) } - await applyMirrorPatch(repo.fullPath, body.patch) + await applyMirrorPatch(directory, body.patch) - const branchName = await safeGitOut(repo.fullPath, ['rev-parse', '--abbrev-ref', 'HEAD']) - const head = await safeGitOut(repo.fullPath, ['rev-parse', 'HEAD']) + const branchName = await safeGitOut(directory, ['rev-parse', '--abbrev-ref', 'HEAD']) + const head = await safeGitOut(directory, ['rev-parse', 'HEAD']) - if (branchName) updateRepoBranch(db, repoId, branchName.trim()) - updateLastPulled(db, repoId) + if (directory === repo.fullPath) { + if (branchName) updateRepoBranch(db, repoId, branchName.trim()) + updateLastPulled(db, repoId) + } return c.json({ repoId, - fullPath: repo.fullPath, + fullPath: directory, branch: branchName?.trim() || null, head: head?.trim() || null, created: false, diff --git a/backend/src/services/repo.ts b/backend/src/services/repo.ts index 4b8ba817f..0ba58b881 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,17 @@ export function createRepoRow( return { repo, created: true } } +export function isDirectoryInUse(directory: string): boolean { + return sseAggregator.getActiveDirectories().includes(directory) +} + 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 { diff --git a/backend/test/routes/internal/repo-mirror.test.ts b/backend/test/routes/internal/repo-mirror.test.ts index a1897799a..6e6558461 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,17 +56,26 @@ 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 mockGetSiblingRepos = 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), + getSiblingRepos: (...args: unknown[]) => mockGetSiblingRepos(...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 @@ -148,11 +158,20 @@ 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([]) 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 + }) + mockGetSiblingRepos.mockResolvedValue([]) + mockListGitWorktrees.mockResolvedValue([]) + mockGetGitEnvironment.mockReturnValue({}) + mockRepoWorkspaces.create.mockReset() mockGetRepoById.mockReturnValue(null) }) @@ -246,7 +265,7 @@ describe('internal-repo-mirror routes', () => { 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] === 'symbolic-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) @@ -260,6 +279,21 @@ describe('internal-repo-mirror routes', () => { expect(json.dirty).toBe(true) }) + it('reports a detached HEAD as a null branch', async () => { + mockGetRepoById.mockReturnValue({ id: 9, fullPath: join(getTmpRoot(), 'repo9') }) + mockSafeGitOut.mockImplementation((_path: string, args: string[]) => { + if (args[0] === 'symbolic-ref') return Promise.resolve(null) + if (args[0] === 'rev-parse') return Promise.resolve('abc123\n') + if (args[0] === 'status') return Promise.resolve('') + return Promise.resolve(null) + }) + + 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[]) => { @@ -447,6 +481,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 }) } @@ -1138,98 +1212,123 @@ describe('internal-repo-mirror routes', () => { }) describe('GET /:repoId/mirror/target', () => { + let mainDir: string let repo: { id: number; fullPath: string; localPath: string } beforeEach(() => { - repo = { id: 1, fullPath: join(getTmpRoot(), 'test-repo'), localPath: 'test-repo' } + mainDir = join(getTmpRoot(), 'target-repo') + mkdirSync(mainDir, { recursive: true }) + spawnSync('git', ['init', '-b', 'main'], { cwd: mainDir, stdio: 'ignore' }) + repo = { id: 1, fullPath: mainDir, localPath: 'target-repo' } + mockGetRepoById.mockReturnValue(repo) }) - it('returns a new-worktree plan with repoId null when no worktree exists', async () => { - mockGetRepoById.mockReturnValue(repo) - mockPlanMirrorTarget.mockResolvedValue({ kind: 'new', localPath: 'test-repo-feature', fullPath: join(getTmpRoot(), 'test-repo-feature'), currentBranch: 'main' }) + function mockCheckoutStates(states: Record) { + mockSafeGitOut.mockImplementation(async (dir: string, args: string[]) => { + const state = states[dir] + if (!state) return null + if (args[0] === 'symbolic-ref') return state.branch + if (args[0] === 'rev-parse') return state.head + if (args[0] === 'status') return state.dirty ? ' M file.txt\n' : '' + return null + }) + } + + it('reports main state and the branch itself when it is not checked out anywhere', async () => { + mockGetSiblingRepos.mockResolvedValue([]) + mockListGitWorktrees.mockResolvedValue([{ path: mainDir, branch: 'main' }]) + mockCheckoutStates({ [mainDir]: { branch: 'main', head: 'abc', dirty: false } }) const res = await app.request('/api/internal/repos/1/mirror/target?branch=feature') expect(res.status).toBe(200) expect(await res.json()).toEqual({ - kind: 'new', - repoId: null, - fullPath: join(getTmpRoot(), 'test-repo-feature'), - localPath: 'test-repo-feature', - branch: 'feature', - currentBranch: 'main', + main: { directory: mainDir, branch: 'main', head: 'abc', dirty: false }, + branchWorktree: null, + newWorktreeBranch: 'feature', }) - 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('returns the worktree already sitting on the requested branch with its state', async () => { + const worktreeDir = join(getTmpRoot(), 'target-repo-feature') + mockGetSiblingRepos.mockResolvedValue([{ fullPath: worktreeDir, worktreeSource: 'opencode' }]) + mockListGitWorktrees.mockResolvedValue([{ path: mainDir, branch: 'main' }]) + mockCheckoutStates({ + [mainDir]: { branch: 'main', head: 'mainhead', dirty: false }, + [worktreeDir]: { branch: 'feature', head: 'wthead', dirty: true }, + }) const res = await app.request('/api/internal/repos/1/mirror/target?branch=feature') expect(res.status).toBe(200) - expect(await res.json()).toEqual({ - kind: 'in-place', - repoId: 1, - fullPath: repo.fullPath, - localPath: 'test-repo', + const json = (await res.json()) as { main: unknown; branchWorktree: unknown; newWorktreeBranch: string } + expect(json.branchWorktree).toEqual({ + directory: worktreeDir, branch: 'feature', - currentBranch: 'feature', + head: 'wthead', + dirty: true, }) + expect(json.newWorktreeBranch).toBe('feature') }) - it('returns an existing-worktree plan with the worktree repo id', async () => { - mockGetRepoById.mockReturnValue(repo) - const worktreeRepo = { id: 5, fullPath: join(getTmpRoot(), 'test-repo-wt'), localPath: 'test-repo-wt' } - mockPlanMirrorTarget.mockResolvedValue({ kind: 'existing', repo: worktreeRepo, currentBranch: 'main' }) + it('ignores schedule worktrees when matching the branch worktree', async () => { + const scheduleDir = join(getTmpRoot(), 'target-repo-schedule') + mockGetSiblingRepos.mockResolvedValue([{ fullPath: scheduleDir, worktreeSource: 'schedule' }]) + mockListGitWorktrees.mockResolvedValue([{ path: mainDir, branch: 'main' }]) + mockCheckoutStates({ + [mainDir]: { branch: 'main', head: 'mainhead', dirty: false }, + [scheduleDir]: { branch: 'feature', head: 'sched', dirty: false }, + }) const res = await app.request('/api/internal/repos/1/mirror/target?branch=feature') expect(res.status).toBe(200) - expect(await res.json()).toEqual({ - kind: 'existing', - repoId: 5, - fullPath: worktreeRepo.fullPath, - localPath: 'test-repo-wt', - branch: 'feature', - currentBranch: 'main', - }) + const json = (await res.json()) as { branchWorktree: unknown } + expect(json.branchWorktree).toBeNull() }) - 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('suffixes the branch when it is already checked out in a worktree', async () => { + mockGetSiblingRepos.mockResolvedValue([]) + mockListGitWorktrees.mockResolvedValue([{ path: mainDir, branch: 'feature' }]) + mockCheckoutStates({ [mainDir]: { branch: 'feature', head: 'abc', dirty: false } }) - 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() + const res = await app.request('/api/internal/repos/1/mirror/target?branch=feature') + + expect(res.status).toBe(200) + const json = (await res.json()) as { newWorktreeBranch: string } + expect(json.newWorktreeBranch).toBe('feature-ocm') }) - 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('skips suffixed branch names that already exist locally', async () => { + spawnSync('git', ['config', 'user.email', 'test@test.com'], { cwd: mainDir, stdio: 'ignore' }) + spawnSync('git', ['config', 'user.name', 'Test'], { cwd: mainDir, stdio: 'ignore' }) + spawnSync('git', ['commit', '--allow-empty', '-m', 'init'], { cwd: mainDir, stdio: 'ignore' }) + spawnSync('git', ['branch', 'feature-ocm'], { cwd: mainDir, stdio: 'ignore' }) + spawnSync('git', ['branch', 'feature-ocm-2'], { cwd: mainDir, stdio: 'ignore' }) + mockGetSiblingRepos.mockResolvedValue([]) + mockListGitWorktrees.mockResolvedValue([{ path: mainDir, branch: 'feature' }]) + mockCheckoutStates({ [mainDir]: { branch: 'feature', head: 'abc', dirty: false } }) - expect(res.status).toBe(400) - const json = (await res.json()) as { error: string } - expect(json.error).toBe('branch required') - expect(mockPlanMirrorTarget).not.toHaveBeenCalled() - }) + const res = await app.request('/api/internal/repos/1/mirror/target?branch=feature') - it('trims the branch before calling the plan service', async () => { - mockGetRepoById.mockReturnValue(repo) - mockPlanMirrorTarget.mockResolvedValue({ kind: 'in-place', repo, currentBranch: 'feature' }) + expect(res.status).toBe(200) + const json = (await res.json()) as { newWorktreeBranch: string } + expect(json.newWorktreeBranch).toBe('feature-ocm-3') + }) - await app.request('/api/internal/repos/1/mirror/target?branch=%20feature%20') + it('returns 400 for a missing branch', async () => { + const res = await app.request('/api/internal/repos/1/mirror/target') - expect(mockPlanMirrorTarget).toHaveBeenCalledWith({}, repo, 'feature') + expect(res.status).toBe(400) + const json = (await res.json()) as { error: string } + expect(json.error).toBe('branch required') + expect(mockGetRepoById).not.toHaveBeenCalled() }) it('returns 400 for an invalid repoId', async () => { const res = await app.request('/api/internal/repos/abc/mirror/target?branch=feature') expect(res.status).toBe(400) - expect(mockPlanMirrorTarget).not.toHaveBeenCalled() }) it('returns 404 for a non-existent repo', async () => { @@ -1238,76 +1337,55 @@ describe('internal-repo-mirror routes', () => { const res = await app.request('/api/internal/repos/99999/mirror/target?branch=feature') expect(res.status).toBe(404) - expect(mockPlanMirrorTarget).not.toHaveBeenCalled() - }) - - it('returns 500 when planning fails', async () => { - mockGetRepoById.mockReturnValue(repo) - mockPlanMirrorTarget.mockRejectedValue(new Error('worktree occupied')) - - const res = await app.request('/api/internal/repos/1/mirror/target?branch=feature') - - expect(res.status).toBe(500) - const json = (await res.json()) as { error: string } - expect(json.error).toContain('worktree occupied') }) }) - describe('POST /:repoId/mirror/target', () => { + describe('POST /:repoId/mirror/worktree', () => { let repo: { id: number; fullPath: string; localPath: string } beforeEach(() => { repo = { id: 1, fullPath: join(getTmpRoot(), 'test-repo'), localPath: 'test-repo' } + mockGetRepoById.mockReturnValue(repo) }) async function request(branchBody: unknown): Promise { - const res = await app.request('/api/internal/repos/1/mirror/target', { + return app.request('/api/internal/repos/1/mirror/worktree', { method: 'POST', body: JSON.stringify(branchBody), headers: { 'content-type': 'application/json' }, }) - return res } - it('ensures the target and returns the target repo contract', async () => { - mockGetRepoById.mockReturnValue(repo) - const targetRepo = { id: 5, fullPath: join(getTmpRoot(), 'test-repo-wt'), localPath: 'test-repo-wt' } - mockEnsureMirrorTarget.mockResolvedValue({ repo: targetRepo, created: true }) + it('creates an OpenCode worktree named after the branch', async () => { + const worktreeDir = join(getTmpRoot(), 'feature-x') + mockListGitWorktrees.mockResolvedValue([{ path: repo.fullPath, branch: 'main' }]) + mockRepoWorkspaces.create.mockResolvedValue({ directory: worktreeDir, branch: 'feature/x' }) - const res = await request({ branch: 'feature' }) + const res = await request({ branch: 'feature/x' }) expect(res.status).toBe(200) - expect(await res.json()).toEqual({ - repoId: 5, - fullPath: targetRepo.fullPath, - localPath: 'test-repo-wt', - branch: 'feature', - created: true, - }) - expect(mockEnsureMirrorTarget).toHaveBeenCalledWith({}, repo, 'feature') + expect(await res.json()).toEqual({ directory: worktreeDir, branch: 'feature/x' }) + expect(mockRepoWorkspaces.create).toHaveBeenCalledWith(repo, { name: sanitizeRepoDirectoryName('feature/x', 'move') }) }) - it('reports created=false when the target already exists', async () => { - mockGetRepoById.mockReturnValue(repo) - mockEnsureMirrorTarget.mockResolvedValue({ repo, created: false }) + it('returns 409 when the branch is already checked out in a worktree', async () => { + mockListGitWorktrees.mockResolvedValue([{ path: repo.fullPath, branch: 'feature' }]) const res = await request({ branch: 'feature' }) - expect(res.status).toBe(200) - const json = (await res.json()) as { created: boolean; repoId: number } - expect(json.created).toBe(false) - expect(json.repoId).toBe(1) + expect(res.status).toBe(409) + expect(mockRepoWorkspaces.create).not.toHaveBeenCalled() }) it('returns 400 for malformed JSON body without calling services', async () => { - const res = await app.request('/api/internal/repos/1/mirror/target', { + const res = await app.request('/api/internal/repos/1/mirror/worktree', { method: 'POST', body: 'not json{', headers: { 'content-type': 'application/json' }, }) expect(res.status).toBe(400) - expect(mockEnsureMirrorTarget).not.toHaveBeenCalled() + expect(mockRepoWorkspaces.create).not.toHaveBeenCalled() expect(mockGetRepoById).not.toHaveBeenCalled() }) @@ -1317,46 +1395,18 @@ describe('internal-repo-mirror routes', () => { expect(res.status).toBe(400) const json = (await res.json()) as { error: string } expect(json.error).toBe('branch required') - expect(mockEnsureMirrorTarget).not.toHaveBeenCalled() - expect(mockGetRepoById).not.toHaveBeenCalled() - }) - - it.each([ - ['null branch', null], - ['numeric branch', 123], - ['object branch', { branch: 'feature' }], - ])('returns 400 for %s without calling services', async (_label, branchValue) => { - const res = await request({ branch: branchValue }) - - expect(res.status).toBe(400) - expect(mockEnsureMirrorTarget).not.toHaveBeenCalled() - }) - - it('returns 400 for a whitespace-only branch without calling services', async () => { - const res = await request({ branch: ' ' }) - - expect(res.status).toBe(400) - expect(mockEnsureMirrorTarget).not.toHaveBeenCalled() - }) - - it('trims the branch before calling the ensure service', async () => { - mockGetRepoById.mockReturnValue(repo) - mockEnsureMirrorTarget.mockResolvedValue({ repo, created: false }) - - await request({ branch: ' feature ' }) - - expect(mockEnsureMirrorTarget).toHaveBeenCalledWith({}, repo, 'feature') + 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' }, }) expect(res.status).toBe(400) - expect(mockEnsureMirrorTarget).not.toHaveBeenCalled() + expect(mockRepoWorkspaces.create).not.toHaveBeenCalled() }) it('returns 404 for a non-existent repo', async () => { @@ -1365,18 +1415,50 @@ describe('internal-repo-mirror routes', () => { const res = await request({ branch: 'feature' }) 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: repo.fullPath, branch: 'main' }]) + mockRepoWorkspaces.create.mockRejectedValue(new Error('worktree create failed')) const res = await request({ branch: 'feature' }) expect(res.status).toBe(409) const json = (await res.json()) as { error: string } - expect(json.error).toContain('git worktree add failed') + expect(json.error).toContain('worktree create failed') + }) + }) + + describe('mirror directory scoping', () => { + 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/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-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/ocm-cli/README.md b/ocm-cli/README.md index beadfda3e..b443c6181 100644 --- a/ocm-cli/README.md +++ b/ocm-cli/README.md @@ -141,23 +141,24 @@ 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-side work is never discarded. 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 an existing clean OpenCode worktree on your branch +when one exists, or creating a new OpenCode worktree. When your branch is already +checked out on the server, the new worktree uses a suffixed `-ocm` +branch, and the server checkout is left untouched. 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. +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 +170,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/src/manager-api.ts b/ocm-cli/src/manager-api.ts index 366afe733..1c9fbc970 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, + MirrorMoveTargetResponseSchema, + MirrorWorktreeCreateResponseSchema, MultiRunSchema, SessionGoalSchema, type FuseMultiRunRequest, type LaunchMultiRunRequest, - type MirrorTargetEnsureResponse, - type MirrorTargetPlanResponse, + type MirrorMoveTargetResponse, + type MirrorWorktreeCreateResponse, type MultiRun, type SessionGoal, type StartSessionGoalRequest, @@ -244,11 +244,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 +263,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}`, { @@ -286,24 +302,24 @@ export class ManagerApi { return (await res.json()) as MirrorHead } - async mirrorTargetPlan(repoId: number, branch: string): Promise { + async mirrorMoveTarget(repoId: number, branch: string): Promise { const res = await fetch(`${this.baseUrl}/api/internal/repos/${repoId}/mirror/target?branch=${encodeURIComponent(branch)}`, { headers: this.headers(), }) - if (!res.ok) throw await formatErrorResponse(res, 'mirror target plan') - return MirrorTargetPlanResponseSchema.parse(await res.json()) + if (!res.ok) throw await formatErrorResponse(res, 'mirror move target') + return MirrorMoveTargetResponseSchema.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, branch: string): Promise { + 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 }), }) - if (!res.ok) throw await formatErrorResponse(res, 'mirror target') - return MirrorTargetEnsureResponseSchema.parse(await res.json()) + if (!res.ok) throw await formatErrorResponse(res, 'mirror create worktree') + return MirrorWorktreeCreateResponseSchema.parse(await res.json()) } async mirrorContains(repoId: number, sha: string): Promise<{ contained: boolean }> { diff --git a/ocm-cli/src/mirror.ts b/ocm-cli/src/mirror.ts index 1afb8b5c1..85bfc4f64 100644 --- a/ocm-cli/src/mirror.ts +++ b/ocm-cli/src/mirror.ts @@ -7,6 +7,7 @@ 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, MirrorMoveTargetResponse } from '@opencode-manager/shared/schemas' import type { ManagerApi } from './manager-api.js' import { ManagerApiError } from './manager-api.js' import { delay } from './delay.js' @@ -86,7 +87,14 @@ export interface PushDivergence { 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 + 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,6 +110,59 @@ export async function checkPushDivergence(repoRoot: string, api: ManagerApi, rep return { serverHead, serverBranch, serverDirty, diverged: true, lostCommits } } +export type MoveDestinationKind = 'in-place' | 'existing-worktree' | 'new-worktree' + +export interface MoveDestination { + kind: MoveDestinationKind + directory: string | null + branch: string + reasons: string[] +} + +function isCheckoutSafe(repoRoot: string, state: MirrorCheckoutState): boolean { + return !state.dirty && !assessPushDivergence(repoRoot, state).diverged +} + +function checkoutUnusableReasons( + repoRoot: string, + label: string, + state: MirrorCheckoutState, + localBranch: string, +): string[] { + if (state.branch !== localBranch) return [`${label} is on ${state.branch ?? 'a detached HEAD'}`] + const reasons: string[] = [] + if (state.dirty) reasons.push(`${label} has uncommitted changes`) + if (assessPushDivergence(repoRoot, state).diverged) reasons.push(`${label} has commits not in your local branch`) + return reasons +} + +export function chooseMoveDestination( + repoRoot: string, + localBranch: string, + target: MirrorMoveTargetResponse, +): MoveDestination { + if (target.main.branch === localBranch && isCheckoutSafe(repoRoot, target.main)) { + return { kind: 'in-place', directory: target.main.directory, branch: localBranch, reasons: [] } + } + + if (target.branchWorktree && isCheckoutSafe(repoRoot, target.branchWorktree)) { + return { kind: 'existing-worktree', directory: target.branchWorktree.directory, branch: localBranch, reasons: [] } + } + + const reasons = checkoutUnusableReasons(repoRoot, 'the server checkout', target.main, localBranch) + if (target.branchWorktree) { + reasons.push( + ...checkoutUnusableReasons( + repoRoot, + `the worktree on ${target.branchWorktree.branch ?? 'another branch'}`, + target.branchWorktree, + localBranch, + ), + ) + } + return { kind: 'new-worktree', directory: null, branch: target.newWorktreeBranch, reasons } +} + export function describePushDivergence(div: PushDivergence): string[] { const reasons: string[] = [] if (div.diverged) { @@ -371,11 +432,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 { @@ -532,6 +593,8 @@ export type MirrorUpFastPhase = type MirrorUpFastOpts = Pick & { requireCurrentBranch?: boolean + directory?: string + targetBranch?: string onPhase?: (phase: MirrorUpFastPhase) => void } @@ -550,6 +613,8 @@ export async function mirrorUpFast( branch: getBranchName(plan.repoRoot), force: opts.force, requireCurrentBranch: opts.requireCurrentBranch, + directory: opts.directory, + targetBranch: opts.targetBranch, onProgress: onPhase ? (bytesSent) => { onPhase({ kind: 'uploading', bytesSent, totalBytes: size }) diff --git a/ocm-cli/src/tui-plugin.ts b/ocm-cli/src/tui-plugin.ts index 27b86b9a9..b372fc2f5 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} is checked out on another branch; branch ${destination.branch} 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\n${localBranch} is checked out on the server, so ${destination.branch} is used 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,25 +180,33 @@ 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 target = await managerApi.mirrorMoveTarget(matched.repoId, localBranch) + const destination = chooseMoveDestination(plan.repoRoot, localBranch, target) 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 + let pushDirectory: string | undefined + let pushTargetBranch: string | undefined + if (destination.kind === 'new-worktree') { + setMoveProgress({ label: `creating worktree ${destination.branch}`, fraction: null }) + const worktree = await managerApi.mirrorCreateWorktree(matched.repoId, destination.branch) + pushDirectory = worktree.directory + pushTargetBranch = destination.branch + } else if (destination.kind === 'existing-worktree') { + pushDirectory = destination.directory ?? undefined } - 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, + requireCurrentBranch: destination.kind !== 'new-worktree', + directory: pushDirectory, + targetBranch: pushTargetBranch, onPhase: (phase) => setMoveProgress(pushPhaseProgress(phase)), }) const remoteDirectory = pushed.fullPath @@ -232,10 +227,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/mirror.test.ts b/ocm-cli/test/mirror.test.ts index 12ff10b70..af60760de 100644 --- a/ocm-cli/test/mirror.test.ts +++ b/ocm-cli/test/mirror.test.ts @@ -4,7 +4,7 @@ import { join } from 'path' import { tmpdir } from 'os' import { randomBytes } from 'crypto' import { spawnSync, execSync } from 'child_process' -import { prepareMirror, MirrorAbort, mirrorDown, 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, type MirrorUpFastPhase } from '../src/mirror' import { getBranchName } from '../src/local-repo' import { ManagerApi } from '../src/manager-api' import { gitRemoteProjectId } from '@opencode-manager/shared/project-id' @@ -685,6 +685,158 @@ 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) => ({ directory, branch, head, dirty }) + + 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', { + main: checkout('/repos/repo', 'main', head), + branchWorktree: null, + newWorktreeBranch: 'main', + }) + + expect(destination).toEqual({ kind: 'in-place', directory: '/repos/repo', branch: 'main', reasons: [] }) + }) + + it('reuses a safe branch worktree when the main checkout is on another branch', () => { + const repoRoot = initRepo('reuse-worktree') + const head = commit(repoRoot, 'a.txt', 'a') + + const destination = chooseMoveDestination(repoRoot, 'feature', { + main: checkout('/repos/repo', 'main', head), + branchWorktree: checkout('/repos/repo-feature', 'feature', head), + newWorktreeBranch: 'feature', + }) + + expect(destination).toEqual({ kind: 'existing-worktree', directory: '/repos/repo-feature', branch: 'feature', reasons: [] }) + }) + + it('creates a suffixed worktree and reports only the branch when the main checkout is on another branch', () => { + const repoRoot = initRepo('new-worktree') + const head = commit(repoRoot, 'a.txt', 'a') + + const destination = chooseMoveDestination(repoRoot, 'feature', { + main: checkout('/repos/repo', 'main', head, true), + branchWorktree: null, + newWorktreeBranch: 'feature-ocm', + }) + + expect(destination.kind).toBe('new-worktree') + expect(destination.directory).toBeNull() + expect(destination.branch).toBe('feature-ocm') + expect(destination.reasons).toEqual(['the server checkout is on main']) + }) + + 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', { + main: checkout('/repos/repo', 'main', head), + branchWorktree: checkout('/repos/repo-feature', 'feature', head, true), + newWorktreeBranch: 'feature-ocm', + }) + + expect(destination.kind).toBe('new-worktree') + expect(destination.reasons).toContain('the worktree on feature has uncommitted changes') + }) + + it('reports a diverged main checkout as commits not in the local branch', () => { + 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', { + main: checkout('/repos/repo', 'feature', serverHead), + branchWorktree: null, + newWorktreeBranch: 'feature', + }) + + expect(destination.kind).toBe('new-worktree') + expect(destination.reasons).toContain('the server checkout has commits not in your local branch') + }) +}) + describe('checkPullDivergence', () => { let tmpDir: string @@ -1006,6 +1158,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', () => { @@ -1187,84 +1370,72 @@ describe('mirrorDownFast preflight', () => { }) }) -describe('ManagerApi target response validation', () => { +describe('ManagerApi move target and worktree validation', () => { afterEach(() => { vi.unstubAllGlobals() }) const api = new ManagerApi('http://localhost:5003', 'test-token') - it('parses a valid new-worktree target plan response', async () => { - vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ kind: 'new', repoId: null, fullPath: '/repos/repo-feature', localPath: 'repo-feature', branch: 'feature', currentBranch: 'main' }), - })) - - const plan = await api.mirrorTargetPlan(1, 'feature') - - expect(plan.kind).toBe('new') - expect(plan.repoId).toBeNull() - expect(plan.branch).toBe('feature') - }) + const checkout = (directory: string, branch: string | null, head: string | null, dirty = false) => ({ directory, branch, head, dirty }) - it('parses a valid existing-worktree target plan response', async () => { + it('parses a valid move target response with a branch worktree', 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 }), + json: () => Promise.resolve({ + main: checkout('/repos/repo', 'main', 'abc'), + branchWorktree: checkout('/repos/repo-feature', 'feature', 'def'), + newWorktreeBranch: 'feature', + }), })) - const plan = await api.mirrorTargetPlan(1, 'feature') + const target = await api.mirrorMoveTarget(1, 'feature') - expect(plan.kind).toBe('existing') - expect(plan.repoId).toBe(5) + expect(target.main.branch).toBe('main') + expect(target.branchWorktree?.directory).toBe('/repos/repo-feature') + expect(target.newWorktreeBranch).toBe('feature') }) - it('rejects a target plan response whose repoId contradicts its kind', async () => { + it('parses a move target response with no branch worktree and a suffixed branch', 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' }), + json: () => Promise.resolve({ main: checkout('/repos/repo', 'main', 'abc'), branchWorktree: null, newWorktreeBranch: 'feature-ocm' }), })) - await expect(api.mirrorTargetPlan(1, 'feature')).rejects.toThrow() - }) + const target = await api.mirrorMoveTarget(1, 'feature') - it('rejects a target plan response with an unknown kind', async () => { - vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ kind: 'bogus', repoId: 1, fullPath: '/x', localPath: 'x', branch: 'main', currentBranch: null }), - })) - - await expect(api.mirrorTargetPlan(1, 'main')).rejects.toThrow() + expect(target.branchWorktree).toBeNull() + expect(target.newWorktreeBranch).toBe('feature-ocm') }) - it('parses a valid ensure target response', async () => { + it('rejects a move target response missing the main checkout', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, - json: () => Promise.resolve({ repoId: 9, fullPath: '/repos/repo-feature', localPath: 'repo-feature', branch: 'feature', created: true }), + json: () => Promise.resolve({ branchWorktree: null, newWorktreeBranch: 'feature' }), })) - const target = await api.mirrorEnsureTarget(1, 'feature') - - expect(target.repoId).toBe(9) - expect(target.created).toBe(true) + await expect(api.mirrorMoveTarget(1, 'feature')).rejects.toThrow() }) - it('rejects an ensure target response missing fields', async () => { + it('parses a valid worktree create response', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, - json: () => Promise.resolve({ repoId: 9, fullPath: '/repos/repo-feature' }), + json: () => Promise.resolve({ directory: '/repos/repo-feature-ocm', branch: 'feature-ocm' }), })) - await expect(api.mirrorEnsureTarget(1, 'feature')).rejects.toThrow() + const created = await api.mirrorCreateWorktree(1, 'feature-ocm') + + expect(created.directory).toBe('/repos/repo-feature-ocm') + expect(created.branch).toBe('feature-ocm') }) - it('rejects an ensure target response with a non-positive repoId', async () => { + it('rejects a worktree create response missing the branch', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, - json: () => Promise.resolve({ repoId: 0, fullPath: '/x', localPath: 'x', branch: 'main', created: false }), + json: () => Promise.resolve({ directory: '/repos/repo-feature-ocm' }), })) - await expect(api.mirrorEnsureTarget(1, 'main')).rejects.toThrow() + await expect(api.mirrorCreateWorktree(1, 'feature-ocm')).rejects.toThrow() }) }) @@ -1306,4 +1477,29 @@ 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') + }) }) diff --git a/ocm-cli/test/tui-plugin.test.ts b/ocm-cli/test/tui-plugin.test.ts index 9e14bea0f..ffac785b3 100644 --- a/ocm-cli/test/tui-plugin.test.ts +++ b/ocm-cli/test/tui-plugin.test.ts @@ -12,15 +12,15 @@ 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(), + mirrorMoveTarget: vi.fn(), + mirrorCreateWorktree: vi.fn(), warmRepoProxy: vi.fn(), })) @@ -38,8 +38,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 +62,8 @@ vi.mock('../src/manager-api.js', () => ({ public readonly managerUrl: string, public readonly token: string, ) {} - mirrorTargetPlan = mocks.mirrorTargetPlan - mirrorEnsureTarget = vi.fn() + mirrorMoveTarget = mocks.mirrorMoveTarget + mirrorCreateWorktree = mocks.mirrorCreateWorktree }, ManagerApiError: class ManagerApiError extends Error { constructor( @@ -163,22 +162,17 @@ 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.mirrorMoveTarget.mockResolvedValue({ + main: { directory: '/workspace/repos/repo', branch: 'main', head: null, dirty: false }, + branchWorktree: null, + newWorktreeBranch: 'main', + }) + 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.mirrorMoveTarget).not.toHaveBeenCalled() expect(fake.confirm).not.toHaveBeenCalled() expect(mocks.mirrorUpFast).not.toHaveBeenCalled() expect(fake.exportMock).not.toHaveBeenCalled() @@ -401,4 +395,100 @@ 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.mirrorMoveTarget).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.mirrorMoveTarget).toHaveBeenCalledWith(1, 'main') + expect(mocks.mirrorCreateWorktree).not.toHaveBeenCalled() + expect(mocks.mirrorUpFast).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ force: true, requireCurrentBranch: true, directory: undefined, targetBranch: undefined }), + ) + expect(importSession).toHaveBeenCalledWith('/workspace/repos/repo', expect.anything()) + }) + + it('pushes into an existing 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', + branch: 'feature', + reasons: [], + }) + mocks.mirrorUpFast.mockResolvedValue({ + repoId: 1, + fullPath: '/workspace/repos/repo-feature', + branch: 'feature', + head: null, + created: false, + }) + + await invokeMove(fake) + + expect(mocks.mirrorCreateWorktree).not.toHaveBeenCalled() + expect(mocks.mirrorUpFast).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ force: true, requireCurrentBranch: true, directory: '/workspace/repos/repo-feature' }), + ) + expect(importSession).toHaveBeenCalledWith('/workspace/repos/repo-feature', expect.anything()) + }) + + it('creates a new worktree and pushes with a target branch 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.mirrorCreateWorktree.mockResolvedValue({ directory: '/workspace/repos/repo-feature-ocm', branch: 'feature-ocm' }) + mocks.mirrorUpFast.mockResolvedValue({ + repoId: 1, + fullPath: '/workspace/repos/repo-feature-ocm', + branch: 'feature-ocm', + head: null, + created: false, + }) + + await invokeMove(fake) + + expect(mocks.mirrorCreateWorktree).toHaveBeenCalledWith(1, 'feature-ocm') + expect(mocks.mirrorUpFast).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + force: true, + requireCurrentBranch: false, + directory: '/workspace/repos/repo-feature-ocm', + targetBranch: 'feature-ocm', + }), + ) + 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..fae5c971e 100644 --- a/shared/src/schemas/repo.ts +++ b/shared/src/schemas/repo.ts @@ -147,27 +147,26 @@ 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 MirrorMoveTargetResponseSchema = z.object({ + main: MirrorCheckoutStateSchema, + branchWorktree: MirrorCheckoutStateSchema.nullable(), + newWorktreeBranch: z.string().min(1), +}) -export type MirrorTargetPlanResponse = z.infer +export type MirrorMoveTargetResponse = z.infer -export const MirrorTargetEnsureResponseSchema = z.object({ - repoId: z.number().int().positive(), - fullPath: z.string().min(1), - localPath: z.string().min(1), +export const MirrorWorktreeCreateResponseSchema = z.object({ + directory: z.string().min(1), branch: z.string().min(1), - created: z.boolean(), }) -export type MirrorTargetEnsureResponse = z.infer +export type MirrorWorktreeCreateResponse = z.infer From 67f70e9635170e1bb763c113039cb5fc81c0975a Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Thu, 8 Oct 2026 23:23:00 -0400 Subject: [PATCH 2/3] fix(ocm): make /ocm-move worktree moves safe and round-trippable - Answer the old /mirror/target routes with a 410 upgrade message and serve the lookup at GET /mirror/checkouts; detect older Managers via the token probe (MANAGER_FEATURE_MISSING). - Create the move worktree and import the bundle in one call; worktree setup runs after the import and the worktree is removed on failure. - Never rewind a server branch: suffix the new worktree branch when the server branch has commits the local branch lacks, and skip both the source and target branch when updating refs. - Reuse clean -ocm[-N] worktrees and legacy worktree repo rows instead of creating new ones on every move. - Let ocm push/pull target the worktree on the local branch via a directory parameter on the mirror read routes; no tarball fallback for worktrees. - Share one sibling loader (listRepoSiblings), validate branch names, compare canonical paths in isDirectoryInUse, and cut redundant git work in the checkouts lookup and the CLI destination choice. --- backend/src/routes/internal/repo-mirror.ts | 259 ++++--- backend/src/routes/repo-directory.ts | 5 +- backend/src/services/repo-workspace.ts | 31 +- backend/src/services/repo.ts | 20 +- .../test/routes/internal/repo-mirror.test.ts | 474 ++++++++---- .../test/routes/repo-project-config.test.ts | 6 +- .../test/routes/repo-terminal-socket.test.ts | 6 +- backend/test/routes/repo-terminals.test.ts | 6 +- backend/test/routes/repos.test.ts | 17 +- .../test/services/repo-mirror-helpers.test.ts | 39 + backend/test/services/repo-workspace.test.ts | 88 ++- backend/test/services/repo.test.ts | 31 +- ocm-cli/README.md | 39 +- ocm-cli/bin/ocm.ts | 46 +- ocm-cli/src/manager-api.ts | 77 +- ocm-cli/src/mirror.ts | 210 ++++-- ocm-cli/src/tui-plugin.ts | 38 +- ocm-cli/test/manager-api.test.ts | 44 ++ ocm-cli/test/mirror.test.ts | 689 ++++++++++++++++-- ocm-cli/test/tui-plugin.test.ts | 86 ++- shared/src/schemas/repo.ts | 19 +- 21 files changed, 1745 insertions(+), 485 deletions(-) diff --git a/backend/src/routes/internal/repo-mirror.ts b/backend/src/routes/internal/repo-mirror.ts index a2f2fb12c..35b5fc8a0 100644 --- a/backend/src/routes/internal/repo-mirror.ts +++ b/backend/src/routes/internal/repo-mirror.ts @@ -5,16 +5,16 @@ import { copyFileSync, createReadStream, createWriteStream, existsSync } from 'f import { mkdtempSync, writeFileSync } from 'fs' import { Readable } 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 MirrorCheckoutState, - type MirrorMoveTargetResponse, + type MirrorCheckoutsResponse, type MirrorWorktreeCreateResponse, } from '@opencode-manager/shared/schemas' -import { isWorktreeSibling, sanitizeRepoDirectoryName } from '@opencode-manager/shared/utils' +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 { @@ -22,7 +22,7 @@ import { createRepoRow, isDirectoryInUse, isRepoInUse, - getSiblingRepos, + listRepoSiblings, listGitWorktrees, resolveRepoWorkingDirectory, } from '../../services/repo' @@ -31,7 +31,7 @@ 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, @@ -73,6 +73,7 @@ interface PatchBody { } 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) => { @@ -142,26 +143,39 @@ async function listLocalBranchNames(fullPath: string): Promise> { return new Set(out.split('\n').map((l) => l.trim()).filter(Boolean)) } +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 = await safeGitOut(directory, ['symbolic-ref', '--quiet', '--short', 'HEAD']) - const head = await safeGitOut(directory, ['rev-parse', 'HEAD']) - const status = await safeGitOut(directory, ['status', '--porcelain', '--untracked-files=all']) + const [branch, head, status] = await Promise.all([ + currentBranchName(directory), + safeGitOut(directory, ['rev-parse', 'HEAD']), + safeGitOut(directory, ['status', '--porcelain', '--untracked-files=all']), + ]) return { directory, - branch: branch?.trim() || null, + branch, head: head?.trim() || null, dirty: (status?.trim().length ?? 0) > 0, } } -async function listMirrorWorktreeSiblings( +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> { - const siblings = await getSiblingRepos(db, repo.id, gitAuthService.getGitEnvironment(), openCodeClient, { includeBranch: false }) - return siblings.filter((sibling) => isWorktreeSibling(sibling) && sibling.worktreeSource !== 'schedule') +): Promise { + return listRepoSiblings(db, repo.id, gitAuthService.getGitEnvironment(), openCodeClient, isMirrorCheckoutSibling(repo)) } async function resolveMirrorDirectory( @@ -172,19 +186,31 @@ async function resolveMirrorDirectory( openCodeClient: OpenCodeClient, ): Promise { return resolveRepoWorkingDirectory(repo, directory, () => - listMirrorWorktreeSiblings(db, repo, gitAuthService, openCodeClient), + listMirrorCheckoutSiblings(db, repo, gitAuthService, openCodeClient), ) } -function nextWorktreeBranch(branch: string, localBranches: Set, checkedOut: Set): string { - if (!checkedOut.has(branch)) return branch - let candidate = `${branch}-ocm` - let suffix = 2 - while (localBranches.has(candidate) || checkedOut.has(candidate)) { - candidate = `${branch}-ocm-${suffix}` +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 } - return candidate +} + +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 { @@ -227,10 +253,12 @@ async function importBundle(fullPath: string, bundlePath: string, branch: string } } - const skipRef = targetBranch ?? branch + 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 (skipRef !== null && name === skipRef) continue + if (skipRefs.has(name)) continue if (locked.has(name)) continue updates.push(`update refs/heads/${name} ${sha}\n`) } @@ -260,6 +288,21 @@ async function createBundle(fullPath: string): Promise { return bundlePath } +async function stageBundleFromBody(body: ReadableStream, dirPrefix: string): Promise<{ bundleDir: string; bundlePath: 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]) + await pipeline(readable, createWriteStream(bundlePath)) + return { bundleDir, bundlePath } + } catch (error) { + await fsp.rm(bundleDir, { recursive: true, force: true }).catch(() => {}) + throw error + } +} + export function createInternalRepoMirrorRoutes( db: Database, openCodeClient: OpenCodeClient, @@ -442,9 +485,12 @@ export function createInternalRepoMirrorRoutes( 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(() => {}) @@ -477,42 +523,46 @@ export function createInternalRepoMirrorRoutes( 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(directory, bundlePath, branch, requireCurrentBranch, force, targetBranch) + const staged = await stageBundleFromBody(rawBody, 'bundle-upload-') + bundleDir = staged.bundleDir + await importBundle(directory, staged.bundlePath, branch, requireCurrentBranch, force, targetBranch) - const branchName = await safeGitOut(directory, ['rev-parse', '--abbrev-ref', 'HEAD']) - const head = await safeGitOut(directory, ['rev-parse', 'HEAD']) + const state = await checkoutState(directory) if (directory === repo.fullPath) { - if (branchName) updateRepoBranch(db, repoId, branchName.trim()) + if (state.branch) updateRepoBranch(db, repoId, state.branch) updateLastPulled(db, repoId) } return c.json({ repoId, fullPath: directory, - branch: branchName?.trim() || null, - head: head?.trim() || null, + 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') }) @@ -520,32 +570,48 @@ export function createInternalRepoMirrorRoutes( 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 main = await checkoutState(repo.fullPath) - const siblings = await listMirrorWorktreeSiblings(db, repo, gitAuthService, openCodeClient) - let branchWorktree: MirrorCheckoutState | null = null - for (const sibling of siblings) { - const state = await checkoutState(sibling.fullPath) - if (state.branch === branch) { - branchWorktree = state - break - } + 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 gitWorktrees = await listGitWorktrees(repo.fullPath, gitAuthService.getGitEnvironment()) - const checkedOut = new Set( - gitWorktrees.map((worktree) => worktree.branch).filter((name): name is string => name !== null), - ) - const localBranches = await listLocalBranchNames(repo.fullPath) - const response: MirrorMoveTargetResponse = { + 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, - branchWorktree, - newWorktreeBranch: nextWorktreeBranch(branch, localBranches, checkedOut), + worktrees, + branchHead, + branchCheckedOut: checkedOut.has(branch), + suffixedWorktreeBranch: nextSuffixedWorktreeBranch(branch, localBranches, checkedOut), } return c.json(response) } catch (error) { - logger.error('mirror target resolution failed:', error) + logger.error('mirror checkouts resolution failed:', error) return c.json({ error: getErrorMessage(error) }, 500) } }) @@ -553,30 +619,49 @@ export function createInternalRepoMirrorRoutes( 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 gitWorktrees = await listGitWorktrees(repo.fullPath, gitAuthService.getGitEnvironment()) - if (gitWorktrees.some((worktree) => worktree.branch === branch)) { - return c.json({ error: 'branch_in_use', message: `branch '${branch}' is checked out in a worktree of this repository` }, 409) + 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 worktree = await repoWorkspaces.create(repo, { name: sanitizeRepoDirectoryName(branch, 'move') }) - const response: MirrorWorktreeCreateResponse = { directory: worktree.directory, branch } + const staged = await stageBundleFromBody(rawBody, 'bundle-upload-') + bundleDir = staged.bundleDir + const worktree = await repoWorkspaces.create(repo, { + name: sanitizeRepoDirectoryName(targetBranch, 'move'), + initialize: (directory) => importBundle(directory, staged.bundlePath, branch, false, true, targetBranch), + }) + 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 worktree create failed:', error) return c.json({ error: getErrorMessage(error) }, 409) + } finally { + if (bundleDir) await fsp.rm(bundleDir, { recursive: true, force: true }).catch(() => {}) } }) @@ -587,7 +672,10 @@ export function createInternalRepoMirrorRoutes( const repo = getRepoById(db, repoId) if (!repo) return c.json({ error: 'repo not found' }, 404) - const state = await checkoutState(repo.fullPath) + 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: state.branch, @@ -605,7 +693,10 @@ export function createInternalRepoMirrorRoutes( 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 }) }) @@ -616,14 +707,16 @@ export function createInternalRepoMirrorRoutes( 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) { @@ -659,28 +752,24 @@ export function createInternalRepoMirrorRoutes( } try { - const currentHead = await safeGitOut(directory, ['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(directory, body.patch) - const branchName = await safeGitOut(directory, ['rev-parse', '--abbrev-ref', 'HEAD']) - const head = await safeGitOut(directory, ['rev-parse', 'HEAD']) - if (directory === repo.fullPath) { - if (branchName) updateRepoBranch(db, repoId, branchName.trim()) + if (state.branch) updateRepoBranch(db, repoId, state.branch) updateLastPulled(db, repoId) } return c.json({ repoId, fullPath: directory, - branch: branchName?.trim() || null, - head: head?.trim() || null, + 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 0ba58b881..f40da99a0 100644 --- a/backend/src/services/repo.ts +++ b/backend/src/services/repo.ts @@ -1127,7 +1127,10 @@ export function createRepoRow( } export function isDirectoryInUse(directory: string): boolean { - return sseAggregator.getActiveDirectories().includes(directory) + const target = canonicalPathSync(path.resolve(directory)) + return sseAggregator.getActiveDirectories().some( + (activeDirectory) => canonicalPathSync(path.resolve(activeDirectory)) === target, + ) } export function isRepoInUse(db: Database, repoId: number): boolean { @@ -1289,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 6e6558461..09fc6f535 100644 --- a/backend/test/routes/internal/repo-mirror.test.ts +++ b/backend/test/routes/internal/repo-mirror.test.ts @@ -58,7 +58,7 @@ const mockCreateRepoRow = vi.fn() const mockIsRepoInUse = vi.fn() const mockIsDirectoryInUse = vi.fn() const mockResolveRepoWorkingDirectory = vi.fn() -const mockGetSiblingRepos = vi.fn() +const mockListRepoSiblings = vi.fn() const mockListGitWorktrees = vi.fn() vi.mock('../../../src/services/repo', () => ({ @@ -67,7 +67,7 @@ vi.mock('../../../src/services/repo', () => ({ isRepoInUse: (...args: unknown[]) => mockIsRepoInUse(...args), isDirectoryInUse: (...args: unknown[]) => mockIsDirectoryInUse(...args), resolveRepoWorkingDirectory: (...args: unknown[]) => mockResolveRepoWorkingDirectory(...args), - getSiblingRepos: (...args: unknown[]) => mockGetSiblingRepos(...args), + listRepoSiblings: (...args: unknown[]) => mockListRepoSiblings(...args), listGitWorktrees: (...args: unknown[]) => mockListGitWorktrees(...args), })) @@ -80,6 +80,37 @@ import { createInternalRepoMirrorRoutes } from '../../../src/routes/internal/rep 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 @@ -160,6 +191,10 @@ describe('internal-repo-mirror routes', () => { app = new Hono() 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) @@ -168,7 +203,13 @@ describe('internal-repo-mirror routes', () => { if (directory === undefined || directory === repo.fullPath) return repo.fullPath return null }) - mockGetSiblingRepos.mockResolvedValue([]) + 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() @@ -263,30 +304,25 @@ 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] === 'symbolic-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 () => { - mockGetRepoById.mockReturnValue({ id: 9, fullPath: join(getTmpRoot(), 'repo9') }) - mockSafeGitOut.mockImplementation((_path: string, args: string[]) => { - if (args[0] === 'symbolic-ref') return Promise.resolve(null) - if (args[0] === 'rev-parse') return Promise.resolve('abc123\n') - if (args[0] === 'status') return Promise.resolve('') - return Promise.resolve(null) - }) + 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) @@ -295,17 +331,41 @@ describe('internal-repo-mirror routes', () => { }) 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') @@ -338,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', () => { @@ -895,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') @@ -1211,122 +1294,144 @@ describe('internal-repo-mirror routes', () => { }) }) - describe('GET /:repoId/mirror/target', () => { - let mainDir: string - 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(() => { - mainDir = join(getTmpRoot(), 'target-repo') - mkdirSync(mainDir, { recursive: true }) - spawnSync('git', ['init', '-b', 'main'], { cwd: mainDir, stdio: 'ignore' }) - repo = { id: 1, fullPath: mainDir, localPath: 'target-repo' } - mockGetRepoById.mockReturnValue(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' }) }) - function mockCheckoutStates(states: Record) { - mockSafeGitOut.mockImplementation(async (dir: string, args: string[]) => { - const state = states[dir] - if (!state) return null - if (args[0] === 'symbolic-ref') return state.branch - if (args[0] === 'rev-parse') return state.head - if (args[0] === 'status') return state.dirty ? ' M file.txt\n' : '' - return null - }) + function addWorktree(branch: string, dirName: string): string { + const dir = join(getTmpRoot(), dirName) + runGit(baseDir, ['worktree', 'add', '-b', branch, dir]) + return dir } - it('reports main state and the branch itself when it is not checked out anywhere', async () => { - mockGetSiblingRepos.mockResolvedValue([]) - mockListGitWorktrees.mockResolvedValue([{ path: mainDir, branch: 'main' }]) - mockCheckoutStates({ [mainDir]: { branch: 'main', head: 'abc', dirty: false } }) - - 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({ - main: { directory: mainDir, branch: 'main', head: 'abc', dirty: false }, - branchWorktree: null, - newWorktreeBranch: 'feature', + 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', }) }) - it('returns the worktree already sitting on the requested branch with its state', async () => { - const worktreeDir = join(getTmpRoot(), 'target-repo-feature') - mockGetSiblingRepos.mockResolvedValue([{ fullPath: worktreeDir, worktreeSource: 'opencode' }]) - mockListGitWorktrees.mockResolvedValue([{ path: mainDir, branch: 'main' }]) - mockCheckoutStates({ - [mainDir]: { branch: 'main', head: 'mainhead', dirty: false }, - [worktreeDir]: { branch: 'feature', head: 'wthead', dirty: true }, - }) + 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) - const json = (await res.json()) as { main: unknown; branchWorktree: unknown; newWorktreeBranch: string } - expect(json.branchWorktree).toEqual({ - directory: worktreeDir, - branch: 'feature', - head: 'wthead', - dirty: true, + expect(await res.json()).toEqual({ + main: { directory: baseDir, branch: 'main', head: revOf(baseDir, 'HEAD'), dirty: false }, + worktrees: [], + branchHead: null, + branchCheckedOut: false, + suffixedWorktreeBranch: 'ghost-ocm', }) - expect(json.newWorktreeBranch).toBe('feature') }) - it('ignores schedule worktrees when matching the branch worktree', async () => { - const scheduleDir = join(getTmpRoot(), 'target-repo-schedule') - mockGetSiblingRepos.mockResolvedValue([{ fullPath: scheduleDir, worktreeSource: 'schedule' }]) - mockListGitWorktrees.mockResolvedValue([{ path: mainDir, branch: 'main' }]) - mockCheckoutStates({ - [mainDir]: { branch: 'main', head: 'mainhead', dirty: false }, - [scheduleDir]: { branch: 'feature', head: 'sched', dirty: false }, - }) + 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) - const json = (await res.json()) as { branchWorktree: unknown } - expect(json.branchWorktree).toBeNull() + 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('suffixes the branch when it is already checked out in a worktree', async () => { - mockGetSiblingRepos.mockResolvedValue([]) - mockListGitWorktrees.mockResolvedValue([{ path: mainDir, branch: 'feature' }]) - mockCheckoutStates({ [mainDir]: { branch: 'feature', head: 'abc', dirty: false } }) - - const res = await app.request('/api/internal/repos/1/mirror/target?branch=feature') + it('returns 400 for a missing branch', async () => { + const res = await app.request('/api/internal/repos/1/mirror/checkouts') - expect(res.status).toBe(200) - const json = (await res.json()) as { newWorktreeBranch: string } - expect(json.newWorktreeBranch).toBe('feature-ocm') + expect(res.status).toBe(400) + const json = (await res.json()) as { error: string } + expect(json.error).toBe('branch required') + expect(mockGetRepoById).not.toHaveBeenCalled() }) - it('skips suffixed branch names that already exist locally', async () => { - spawnSync('git', ['config', 'user.email', 'test@test.com'], { cwd: mainDir, stdio: 'ignore' }) - spawnSync('git', ['config', 'user.name', 'Test'], { cwd: mainDir, stdio: 'ignore' }) - spawnSync('git', ['commit', '--allow-empty', '-m', 'init'], { cwd: mainDir, stdio: 'ignore' }) - spawnSync('git', ['branch', 'feature-ocm'], { cwd: mainDir, stdio: 'ignore' }) - spawnSync('git', ['branch', 'feature-ocm-2'], { cwd: mainDir, stdio: 'ignore' }) - mockGetSiblingRepos.mockResolvedValue([]) - mockListGitWorktrees.mockResolvedValue([{ path: mainDir, branch: 'feature' }]) - mockCheckoutStates({ [mainDir]: { branch: 'feature', head: 'abc', dirty: false } }) + 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)}`) - const res = await app.request('/api/internal/repos/1/mirror/target?branch=feature') - - expect(res.status).toBe(200) - const json = (await res.json()) as { newWorktreeBranch: string } - expect(json.newWorktreeBranch).toBe('feature-ocm-3') + expect(res.status).toBe(400) + const json = (await res.json()) as { error: string } + expect(json.error).toBe('invalid branch name') + expect(mockListGitWorktrees).not.toHaveBeenCalled() }) - it('returns 400 for a missing branch', async () => { - const res = await app.request('/api/internal/repos/1/mirror/target') + it('accepts a valid branch name', async () => { + mockListGitWorktrees.mockResolvedValue([{ path: baseDir, branch: 'main' }]) - expect(res.status).toBe(400) - const json = (await res.json()) as { error: string } - expect(json.error).toBe('branch required') - expect(mockGetRepoById).not.toHaveBeenCalled() + const res = await app.request('/api/internal/repos/1/mirror/checkouts?branch=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) }) @@ -1334,75 +1439,116 @@ describe('internal-repo-mirror routes', () => { 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) }) }) 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 { + 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 }, }) } - it('creates an OpenCode worktree named after the branch', async () => { - const worktreeDir = join(getTmpRoot(), 'feature-x') - mockListGitWorktrees.mockResolvedValue([{ path: repo.fullPath, branch: 'main' }]) - mockRepoWorkspaces.create.mockResolvedValue({ directory: worktreeDir, branch: 'feature/x' }) + 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/x' }) + 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({ directory: worktreeDir, branch: 'feature/x' }) - expect(mockRepoWorkspaces.create).toHaveBeenCalledWith(repo, { name: sanitizeRepoDirectoryName('feature/x', 'move') }) + expect(await res.json()).toEqual({ + repoId: 1, + fullPath: worktreeDir, + branch: 'feature-ocm', + head: incomingFeatureSha, + created: true, + worktreeSetup: { status: 'none' }, + }) + 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('returns 409 when the branch is already checked out in a worktree', async () => { - mockListGitWorktrees.mockResolvedValue([{ path: repo.fullPath, branch: 'feature' }]) + it('returns 409 when the target branch is already checked out', async () => { + mockListGitWorktrees.mockResolvedValue([{ path: baseDir, branch: 'feature-ocm' }]) - 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).toBe('branch_in_use') expect(mockRepoWorkspaces.create).not.toHaveBeenCalled() }) - it('returns 400 for malformed JSON body without calling services', async () => { - const res = await app.request('/api/internal/repos/1/mirror/worktree', { - method: 'POST', - body: 'not json{', - headers: { 'content-type': 'application/json' }, - }) + 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(mockRepoWorkspaces.create).not.toHaveBeenCalled() - expect(mockGetRepoById).not.toHaveBeenCalled() }) - it('returns 400 for a missing branch without calling services', async () => { - const res = await request({}) + 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(mockRepoWorkspaces.create).not.toHaveBeenCalled() + }) + + 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('branch required') + expect(json.error).toBe('invalid branch name') + expect(mockListGitWorktrees).not.toHaveBeenCalled() + expect(mockRepoWorkspaces.create).not.toHaveBeenCalled() + }) + + 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(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/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) @@ -1412,17 +1558,17 @@ describe('internal-repo-mirror routes', () => { 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(mockRepoWorkspaces.create).not.toHaveBeenCalled() }) it('returns 409 when worktree creation fails', async () => { - mockListGitWorktrees.mockResolvedValue([{ path: repo.fullPath, branch: 'main' }]) + 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 } @@ -1431,6 +1577,60 @@ describe('internal-repo-mirror routes', () => { }) 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) 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-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-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 b443c6181..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: @@ -143,16 +156,26 @@ entrypoint automatically. When attached to a Manager via `ocm`, the plugin shows show nothing. It registers `/ocm-move`, which keeps the local session and 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-side work is never discarded. 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 an existing clean OpenCode worktree on your branch -when one exists, or creating a new OpenCode worktree. When your branch is already -checked out on the server, the new worktree uses a suffixed `-ocm` -branch, and the server checkout is left untouched. A detached HEAD is refused +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. +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 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 1c9fbc970..172f21141 100644 --- a/ocm-cli/src/manager-api.ts +++ b/ocm-cli/src/manager-api.ts @@ -1,13 +1,13 @@ import { createReadStream } from 'fs' import { Readable } from 'stream' import { - MirrorMoveTargetResponseSchema, + MirrorCheckoutsResponseSchema, MirrorWorktreeCreateResponseSchema, MultiRunSchema, SessionGoalSchema, type FuseMultiRunRequest, type LaunchMultiRunRequest, - type MirrorMoveTargetResponse, + type MirrorCheckoutsResponse, type MirrorWorktreeCreateResponse, type MultiRun, type SessionGoal, @@ -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, @@ -293,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(), }) @@ -302,28 +313,42 @@ export class ManagerApi { return (await res.json()) as MirrorHead } - async mirrorMoveTarget(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 move target') - return MirrorMoveTargetResponseSchema.parse(await res.json()) + if (!res.ok) throw await this.handleErrorResponse(res, 'mirror checkouts', MOVE_FEATURE_NAME) + return MirrorCheckoutsResponseSchema.parse(await res.json()) } - async mirrorCreateWorktree(repoId: number, branch: string): Promise { + async mirrorCreateWorktree( + repoId: number, + bundlePath: string, + opts: { branch: string; targetBranch: string; onProgress?: (bytesSent: number) => void }, + ): Promise { + const headers: Record = { + ...this.headers(), + 'Content-Type': 'application/octet-stream', + 'X-OCM-Branch': opts.branch, + '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/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 create worktree') + 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(), }) @@ -331,8 +356,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(), }) @@ -340,8 +365,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 85bfc4f64..c72f109c0 100644 --- a/ocm-cli/src/mirror.ts +++ b/ocm-cli/src/mirror.ts @@ -7,9 +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, MirrorMoveTargetResponse } from '@opencode-manager/shared/schemas' +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', '._*'] @@ -85,8 +85,13 @@ export interface PushDivergence { lostCommits: number } -export async function checkPushDivergence(repoRoot: string, api: ManagerApi, repoId: number): Promise { - const info = await api.mirrorHead(repoId) +export async function checkPushDivergence( + repoRoot: string, + api: ManagerApi, + repoId: number, + directory?: string, +): Promise { + const info = await api.mirrorHead(repoId, directory) return assessPushDivergence(repoRoot, info) } @@ -110,19 +115,13 @@ export function assessPushDivergence( return { serverHead, serverBranch, serverDirty, diverged: true, lostCommits } } -export type MoveDestinationKind = 'in-place' | 'existing-worktree' | 'new-worktree' - export interface MoveDestination { - kind: MoveDestinationKind + kind: 'in-place' | 'existing-worktree' | 'new-worktree' directory: string | null branch: string reasons: string[] } -function isCheckoutSafe(repoRoot: string, state: MirrorCheckoutState): boolean { - return !state.dirty && !assessPushDivergence(repoRoot, state).diverged -} - function checkoutUnusableReasons( repoRoot: string, label: string, @@ -130,47 +129,64 @@ function checkoutUnusableReasons( localBranch: string, ): string[] { if (state.branch !== localBranch) return [`${label} is on ${state.branch ?? 'a detached HEAD'}`] - const reasons: string[] = [] - if (state.dirty) reasons.push(`${label} has uncommitted changes`) - if (assessPushDivergence(repoRoot, state).diverged) reasons.push(`${label} has commits not in your local branch`) - return reasons + 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, - target: MirrorMoveTargetResponse, + checkouts: MirrorCheckoutsResponse, ): MoveDestination { - if (target.main.branch === localBranch && isCheckoutSafe(repoRoot, target.main)) { - return { kind: 'in-place', directory: target.main.directory, branch: localBranch, reasons: [] } + 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: [] } } - if (target.branchWorktree && isCheckoutSafe(repoRoot, target.branchWorktree)) { - return { kind: 'existing-worktree', directory: target.branchWorktree.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 reasons = checkoutUnusableReasons(repoRoot, 'the server checkout', target.main, localBranch) - if (target.branchWorktree) { - reasons.push( - ...checkoutUnusableReasons( - repoRoot, - `the worktree on ${target.branchWorktree.branch ?? 'another branch'}`, - target.branchWorktree, - localBranch, - ), - ) + 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, } - return { kind: 'new-worktree', directory: null, branch: target.newWorktreeBranch, reasons } } -export function describePushDivergence(div: PushDivergence): string[] { +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 } @@ -180,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 @@ -198,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 } @@ -520,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']) @@ -537,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`) } @@ -554,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`) @@ -565,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']) } @@ -575,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), @@ -595,13 +657,23 @@ 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' }) @@ -609,18 +681,38 @@ export async function mirrorUpFast( try { const { size } = await fsp.stat(bundlePath) onPhase?.({ kind: 'uploading', bytesSent: 0, totalBytes: size }) + const onProgress = onPhase + ? (bytesSent: number) => { + onPhase({ kind: 'uploading', bytesSent, totalBytes: size }) + if (bytesSent >= size) 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 created = await opts.api.mirrorCreateWorktree(repoId, bundlePath, { + branch, + targetBranch: opts.createWorktree.targetBranch, + onProgress, + }) + return { + repoId: created.repoId, + fullPath: created.fullPath, + branch: created.branch, + head: created.head, + created: true, + worktreeSetup: created.worktreeSetup, + } + } + await opts.api.mirrorUploadBundle(repoId, bundlePath, { branch: getBranchName(plan.repoRoot), force: opts.force, requireCurrentBranch: opts.requireCurrentBranch, directory: opts.directory, targetBranch: opts.targetBranch, - onProgress: onPhase - ? (bytesSent) => { - onPhase({ kind: 'uploading', bytesSent, totalBytes: size }) - if (bytesSent >= size) onPhase({ kind: 'processing' }) - } - : undefined, + onProgress, }) onPhase?.({ kind: 'patching' }) const patchResult = await mirrorUpPatch(plan, opts) @@ -634,16 +726,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 b372fc2f5..9cf1a09b0 100644 --- a/ocm-cli/src/tui-plugin.ts +++ b/ocm-cli/src/tui-plugin.ts @@ -127,13 +127,13 @@ function moveConfirmMessage(repoName: string, destination: MoveDestination, loca case 'in-place': return `Replace the repo state of ${repoName} (${destination.directory}) with your local working tree and move this session there?` case 'existing-worktree': - return `${repoName} is checked out on another branch; branch ${destination.branch} lives in worktree ${destination.directory}.\n\nReplace that worktree with your local working tree and move this session there?` + 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\n${localBranch} is checked out on the server, so ${destination.branch} is used instead.` + ? `\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}` } @@ -180,8 +180,8 @@ async function runSessionMove(context: Context, setMoveProgress: MoveProgressSet } const managerApi = new ManagerApi(auth.managerUrl, auth.token) - const target = await managerApi.mirrorMoveTarget(matched.repoId, localBranch) - const destination = chooseMoveDestination(plan.repoRoot, localBranch, target) + 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', @@ -189,28 +189,30 @@ async function runSessionMove(context: Context, setMoveProgress: MoveProgressSet }) if (!proceed) return - let pushDirectory: string | undefined - let pushTargetBranch: string | undefined - if (destination.kind === 'new-worktree') { - setMoveProgress({ label: `creating worktree ${destination.branch}`, fraction: null }) - const worktree = await managerApi.mirrorCreateWorktree(matched.repoId, destination.branch) - pushDirectory = worktree.directory - pushTargetBranch = destination.branch - } else if (destination.kind === 'existing-worktree') { - pushDirectory = destination.directory ?? undefined - } - const selectedPlan: MirrorPlan = { ...plan, matched: [matched] } const pushed = await mirrorUpFast(selectedPlan, { api: managerApi, force: true, - requireCurrentBranch: destination.kind !== 'new-worktree', - directory: pushDirectory, - targetBranch: pushTargetBranch, + ...(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 }, { 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 af60760de..21f1bd742 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, chooseMoveDestination, assessPushDivergence, 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' @@ -760,65 +761,128 @@ describe('chooseMoveDestination', () => { return execSync('git rev-parse HEAD', { cwd: repoRoot, encoding: 'utf-8' }).trim() } - const checkout = (directory: string, branch: string | null, head: string | null, dirty = false) => ({ directory, branch, head, dirty }) + 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', { - main: checkout('/repos/repo', 'main', head), - branchWorktree: null, - newWorktreeBranch: 'main', - }) + 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 a safe branch worktree when the main checkout is on another branch', () => { - const repoRoot = initRepo('reuse-worktree') + 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', { - main: checkout('/repos/repo', 'main', head), - branchWorktree: checkout('/repos/repo-feature', 'feature', head), - newWorktreeBranch: 'feature', - }) + 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('creates a suffixed worktree and reports only the branch when the main checkout is on another branch', () => { - const repoRoot = initRepo('new-worktree') + 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', { - main: checkout('/repos/repo', 'main', head, true), - branchWorktree: null, - newWorktreeBranch: 'feature-ocm', - }) + 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-ocm') + 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', { - main: checkout('/repos/repo', 'main', head), - branchWorktree: checkout('/repos/repo-feature', 'feature', head, true), - newWorktreeBranch: 'feature-ocm', - }) + 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 as commits not in the local branch', () => { + 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' }) @@ -826,14 +890,10 @@ describe('chooseMoveDestination', () => { spawnSync('git', ['checkout', '-'], { cwd: repoRoot, stdio: 'ignore' }) commit(repoRoot, 'local.txt', 'local-work') - const destination = chooseMoveDestination(repoRoot, 'feature', { - main: checkout('/repos/repo', 'feature', serverHead), - branchWorktree: null, - newWorktreeBranch: 'feature', - }) + const destination = chooseMoveDestination(repoRoot, 'feature', checkouts(checkout('/repos/repo', 'feature', serverHead))) expect(destination.kind).toBe('new-worktree') - expect(destination.reasons).toContain('the server checkout has commits not in your local branch') + expect(destination.reasons).toContain('the server checkout is 1 commit(s) ahead of your local branch') }) }) @@ -1368,74 +1428,151 @@ 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 move target and worktree 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') const checkout = (directory: string, branch: string | null, head: string | null, dirty = false) => ({ directory, branch, head, dirty }) - it('parses a valid move target response with a branch worktree', async () => { - vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ - main: checkout('/repos/repo', 'main', 'abc'), - branchWorktree: checkout('/repos/repo-feature', 'feature', 'def'), - newWorktreeBranch: 'feature', - }), - })) + const checkoutsBody = { + main: checkout('/repos/repo', 'main', 'abc'), + worktrees: [checkout('/repos/repo-feature', 'feature', 'def')], + branchHead: 'def', + branchCheckedOut: true, + suffixedWorktreeBranch: 'feature-ocm', + } + + 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) - const target = await api.mirrorMoveTarget(1, 'feature') + const result = await api.mirrorCheckouts(1, 'feature') - expect(target.main.branch).toBe('main') - expect(target.branchWorktree?.directory).toBe('/repos/repo-feature') - expect(target.newWorktreeBranch).toBe('feature') + 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('parses a move target response with no branch worktree and a suffixed branch', async () => { - vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ main: checkout('/repos/repo', 'main', 'abc'), branchWorktree: null, newWorktreeBranch: 'feature-ocm' }), - })) + 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.mirrorCheckouts(1, 'feature')).rejects.toThrow() + }) + + 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 target = await api.mirrorMoveTarget(1, 'feature') + const error = await api.mirrorCheckouts(1, 'feature').catch((err) => err) - expect(target.branchWorktree).toBeNull() - expect(target.newWorktreeBranch).toBe('feature-ocm') + 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('rejects a move target response missing the main checkout', async () => { - vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + it('uploads a bundle to the worktree route with branch headers', async () => { + const bundlePath = join(tmpDir, 'repo.bundle') + writeFileSync(bundlePath, 'bundle bytes') + const fetchMock = vi.fn().mockResolvedValue({ ok: true, - json: () => Promise.resolve({ branchWorktree: null, newWorktreeBranch: 'feature' }), - })) + json: () => Promise.resolve({ + repoId: 1, + fullPath: '/repos/repo-feature-ocm', + branch: 'feature-ocm', + head: 'abc', + created: true, + worktreeSetup: { status: 'none' }, + }), + }) + vi.stubGlobal('fetch', fetchMock) - await expect(api.mirrorMoveTarget(1, 'feature')).rejects.toThrow() + const created = await api.mirrorCreateWorktree(1, bundlePath, { branch: 'feature', targetBranch: 'feature-ocm' }) + + 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' }) + expect(init.duplex).toBe('half') }) - it('parses a valid worktree create response', async () => { - vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ directory: '/repos/repo-feature-ocm', branch: 'feature-ocm' }), - })) + 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 created = await api.mirrorCreateWorktree(1, 'feature-ocm') + const error = await api.mirrorCreateWorktree(1, bundlePath, { branch: 'feature', targetBranch: 'feature-ocm' }).catch((err) => err) - expect(created.directory).toBe('/repos/repo-feature-ocm') - expect(created.branch).toBe('feature-ocm') + 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 a worktree create response missing the branch', async () => { - vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ directory: '/repos/repo-feature-ocm' }), + 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.mirrorCreateWorktree(1, 'feature-ocm')).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') + } }) }) @@ -1503,3 +1640,405 @@ describe('ManagerApi mirrorUploadBundle strict branch header', () => { 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('creates the worktree through the worktree route 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' }) + + 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(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 ffac785b3..42f9ea172 100644 --- a/ocm-cli/test/tui-plugin.test.ts +++ b/ocm-cli/test/tui-plugin.test.ts @@ -19,8 +19,7 @@ const mocks = vi.hoisted(() => ({ createManagerSessionTransfer: vi.fn(), setPendingWarp: vi.fn(), runPendingWarp: vi.fn(), - mirrorMoveTarget: vi.fn(), - mirrorCreateWorktree: vi.fn(), + mirrorCheckouts: vi.fn(), warmRepoProxy: vi.fn(), })) @@ -62,8 +61,7 @@ vi.mock('../src/manager-api.js', () => ({ public readonly managerUrl: string, public readonly token: string, ) {} - mirrorMoveTarget = mocks.mirrorMoveTarget - mirrorCreateWorktree = mocks.mirrorCreateWorktree + mirrorCheckouts = mocks.mirrorCheckouts }, ManagerApiError: class ManagerApiError extends Error { constructor( @@ -162,10 +160,12 @@ 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.mirrorMoveTarget.mockResolvedValue({ + mocks.mirrorCheckouts.mockResolvedValue({ main: { directory: '/workspace/repos/repo', branch: 'main', head: null, dirty: false }, - branchWorktree: null, - newWorktreeBranch: 'main', + worktrees: [], + branchHead: null, + branchCheckedOut: false, + suffixedWorktreeBranch: 'main-ocm', }) mocks.chooseMoveDestination.mockReturnValue({ kind: 'in-place', @@ -325,7 +325,7 @@ describe('ocm.session.move command', () => { }) function expectNothingPushed(fake: ReturnType, importSession: ReturnType) { - expect(mocks.mirrorMoveTarget).not.toHaveBeenCalled() + expect(mocks.mirrorCheckouts).not.toHaveBeenCalled() expect(fake.confirm).not.toHaveBeenCalled() expect(mocks.mirrorUpFast).not.toHaveBeenCalled() expect(fake.exportMock).not.toHaveBeenCalled() @@ -404,7 +404,7 @@ describe('ocm.session.move command', () => { await invokeMove(fake) - expect(mocks.mirrorMoveTarget).not.toHaveBeenCalled() + expect(mocks.mirrorCheckouts).not.toHaveBeenCalled() expect(mocks.mirrorUpFast).not.toHaveBeenCalled() expect(fake.exportMock).not.toHaveBeenCalled() expect(importSession).not.toHaveBeenCalled() @@ -419,45 +419,51 @@ describe('ocm.session.move command', () => { await invokeMove(fake) - expect(mocks.mirrorMoveTarget).toHaveBeenCalledWith(1, 'main') - expect(mocks.mirrorCreateWorktree).not.toHaveBeenCalled() + expect(mocks.mirrorCheckouts).toHaveBeenCalledWith(1, 'main') expect(mocks.mirrorUpFast).toHaveBeenCalledWith( expect.anything(), - expect.objectContaining({ force: true, requireCurrentBranch: true, directory: undefined, targetBranch: undefined }), + 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 worktree directory with the strict current-branch guard', async () => { + 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', - branch: 'feature', + directory: '/workspace/repos/repo-feature-ocm', + branch: 'feature-ocm', reasons: [], }) mocks.mirrorUpFast.mockResolvedValue({ repoId: 1, - fullPath: '/workspace/repos/repo-feature', - branch: 'feature', + fullPath: '/workspace/repos/repo-feature-ocm', + branch: 'feature-ocm', head: null, created: false, }) await invokeMove(fake) - expect(mocks.mirrorCreateWorktree).not.toHaveBeenCalled() expect(mocks.mirrorUpFast).toHaveBeenCalledWith( expect.anything(), - expect.objectContaining({ force: true, requireCurrentBranch: true, directory: '/workspace/repos/repo-feature' }), + expect.objectContaining({ + force: true, + requireCurrentBranch: true, + directory: '/workspace/repos/repo-feature-ocm', + targetBranch: 'feature-ocm', + }), ) - expect(importSession).toHaveBeenCalledWith('/workspace/repos/repo-feature', expect.anything()) + expect(importSession).toHaveBeenCalledWith('/workspace/repos/repo-feature-ocm', expect.anything()) }) - it('creates a new worktree and pushes with a target branch when no safe checkout exists', async () => { + it('creates a new worktree through the fast push when no safe checkout exists', async () => { const fake = createFakeContext() configureMove(fake) const { importSession } = stubTransfer() @@ -468,27 +474,53 @@ describe('ocm.session.move command', () => { branch: 'feature-ocm', reasons: ['the server checkout is on main'], }) - mocks.mirrorCreateWorktree.mockResolvedValue({ directory: '/workspace/repos/repo-feature-ocm', branch: 'feature-ocm' }) mocks.mirrorUpFast.mockResolvedValue({ repoId: 1, fullPath: '/workspace/repos/repo-feature-ocm', branch: 'feature-ocm', head: null, - created: false, + created: true, + worktreeSetup: { status: 'none' }, }) await invokeMove(fake) - expect(mocks.mirrorCreateWorktree).toHaveBeenCalledWith(1, 'feature-ocm') expect(mocks.mirrorUpFast).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ force: true, - requireCurrentBranch: false, - directory: '/workspace/repos/repo-feature-ocm', - targetBranch: 'feature-ocm', + 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 fae5c971e..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']) @@ -156,17 +157,23 @@ export const MirrorCheckoutStateSchema = z.object({ export type MirrorCheckoutState = z.infer -export const MirrorMoveTargetResponseSchema = z.object({ +export const MirrorCheckoutsResponseSchema = z.object({ main: MirrorCheckoutStateSchema, - branchWorktree: MirrorCheckoutStateSchema.nullable(), - newWorktreeBranch: z.string().min(1), + worktrees: z.array(MirrorCheckoutStateSchema), + branchHead: z.string().min(1).nullable(), + branchCheckedOut: z.boolean(), + suffixedWorktreeBranch: z.string().min(1), }) -export type MirrorMoveTargetResponse = z.infer +export type MirrorCheckoutsResponse = z.infer export const MirrorWorktreeCreateResponseSchema = z.object({ - directory: z.string().min(1), - branch: z.string().min(1), + repoId: z.number(), + fullPath: z.string().min(1), + branch: z.string().min(1).nullable(), + head: z.string().min(1).nullable(), + created: z.literal(true), + worktreeSetup: WorktreeSetupResultSchema, }) export type MirrorWorktreeCreateResponse = z.infer From a3af0866b94c948835396f8ba5f815c1737117a6 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Fri, 9 Oct 2026 00:04:00 -0400 Subject: [PATCH 3/3] fix(ocm): apply working-tree changes when moving into a new worktree Moving a session into a new worktree uploaded only the git bundle, so staged, unstaged and untracked changes were dropped, unlike moves into the main checkout or an existing worktree. The CLI now sends the working-tree patch with the worktree-create request behind an X-OCM-Patch-Bytes header, and the backend applies it right after importing the bundle and before worktree setup runs, so setup sees the edits. A patch that fails to apply removes the new worktree instead of leaving it half-created. --- backend/src/routes/internal/repo-mirror.ts | 45 +++++++++++-- .../test/routes/internal/repo-mirror.test.ts | 67 +++++++++++++++++++ ocm-cli/src/manager-api.ts | 12 +++- ocm-cli/src/mirror.ts | 23 ++++--- ocm-cli/test/mirror.test.ts | 21 ++++-- 5 files changed, 147 insertions(+), 21 deletions(-) diff --git a/backend/src/routes/internal/repo-mirror.ts b/backend/src/routes/internal/repo-mirror.ts index 35b5fc8a0..e71225a0a 100644 --- a/backend/src/routes/internal/repo-mirror.ts +++ b/backend/src/routes/internal/repo-mirror.ts @@ -3,7 +3,7 @@ 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, resolve } from 'path' import * as fsp from 'fs/promises' @@ -288,15 +288,38 @@ async function createBundle(fullPath: string): Promise { return bundlePath } -async function stageBundleFromBody(body: ReadableStream, dirPrefix: string): Promise<{ bundleDir: string; bundlePath: string }> { +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]) - await pipeline(readable, createWriteStream(bundlePath)) - return { bundleDir, bundlePath } + 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 @@ -631,6 +654,13 @@ export function createInternalRepoMirrorRoutes( 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) @@ -641,11 +671,14 @@ export function createInternalRepoMirrorRoutes( let bundleDir: string | undefined try { - const staged = await stageBundleFromBody(rawBody, 'bundle-upload-') + const staged = await stageBundleFromBody(rawBody, 'bundle-upload-', patchBytes) bundleDir = staged.bundleDir const worktree = await repoWorkspaces.create(repo, { name: sanitizeRepoDirectoryName(targetBranch, 'move'), - initialize: (directory) => importBundle(directory, staged.bundlePath, branch, false, true, targetBranch), + 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 = { diff --git a/backend/test/routes/internal/repo-mirror.test.ts b/backend/test/routes/internal/repo-mirror.test.ts index 09fc6f535..7cdb726d8 100644 --- a/backend/test/routes/internal/repo-mirror.test.ts +++ b/backend/test/routes/internal/repo-mirror.test.ts @@ -1499,6 +1499,73 @@ describe('internal-repo-mirror routes', () => { expect(readFileSync(join(worktreeDir, 'feature.txt'), 'utf-8')).toBe('source feature\n') }) + 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(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) + 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.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) + 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 409 when the declared patch length exceeds the body', async () => { + mockListGitWorktrees.mockResolvedValue([{ path: baseDir, branch: 'main' }]) + + 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).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' }]) diff --git a/ocm-cli/src/manager-api.ts b/ocm-cli/src/manager-api.ts index 172f21141..043f072aa 100644 --- a/ocm-cli/src/manager-api.ts +++ b/ocm-cli/src/manager-api.ts @@ -326,15 +326,23 @@ export class ManagerApi { async mirrorCreateWorktree( repoId: number, bundlePath: string, - opts: { branch: string; targetBranch: string; onProgress?: (bytesSent: number) => void }, + 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 fileStream = Readable.toWeb(createReadStream(bundlePath)) as unknown as ReadableStream + 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', diff --git a/ocm-cli/src/mirror.ts b/ocm-cli/src/mirror.ts index c72f109c0..11967fa2d 100644 --- a/ocm-cli/src/mirror.ts +++ b/ocm-cli/src/mirror.ts @@ -680,21 +680,25 @@ export async function mirrorUpFast( const bundlePath = await createLocalBundle(plan.repoRoot) try { const { size } = await fsp.stat(bundlePath) - onPhase?.({ kind: 'uploading', bytesSent: 0, totalBytes: size }) - const onProgress = onPhase - ? (bytesSent: number) => { - onPhase({ kind: 'uploading', bytesSent, totalBytes: size }) - if (bytesSent >= size) onPhase({ kind: 'processing' }) - } - : undefined + 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, - onProgress, + patch, + onProgress: progressFor(totalBytes), }) return { repoId: created.repoId, @@ -706,6 +710,9 @@ export async function mirrorUpFast( } } + onPhase?.({ kind: 'uploading', bytesSent: 0, totalBytes: size }) + const onProgress = progressFor(size) + await opts.api.mirrorUploadBundle(repoId, bundlePath, { branch: getBranchName(plan.repoRoot), force: opts.force, diff --git a/ocm-cli/test/mirror.test.ts b/ocm-cli/test/mirror.test.ts index 21f1bd742..ccacf21ec 100644 --- a/ocm-cli/test/mirror.test.ts +++ b/ocm-cli/test/mirror.test.ts @@ -1515,9 +1515,10 @@ describe('ManagerApi mirror checkouts and worktree', () => { expect((error as ManagerApiError).message).toContain('too old for /ocm-move worktrees') }) - it('uploads a bundle to the worktree route with branch headers', async () => { + 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({ @@ -1531,15 +1532,21 @@ describe('ManagerApi mirror checkouts and worktree', () => { }) vi.stubGlobal('fetch', fetchMock) - const created = await api.mirrorCreateWorktree(1, bundlePath, { branch: 'feature', targetBranch: 'feature-ocm' }) + 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' }) + 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('maps an old Manager 401 to MANAGER_FEATURE_MISSING for worktree creation', async () => { @@ -1550,7 +1557,7 @@ describe('ManagerApi mirror checkouts and worktree', () => { .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' }).catch((err) => err) + const error = await api.mirrorCreateWorktree(1, bundlePath, { branch: 'feature', targetBranch: 'feature-ocm', patch: '' }).catch((err) => err) expect(error).toBeInstanceOf(ManagerApiError) expect((error as ManagerApiError).code).toBe(MANAGER_FEATURE_MISSING) @@ -1769,7 +1776,7 @@ describe('mirrorUpFast worktree creation', () => { rmSync(tmpDir, { recursive: true, force: true }) }) - it('creates the worktree through the worktree route and skips the patch shortcut', async () => { + 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' }) @@ -1778,6 +1785,8 @@ describe('mirrorUpFast worktree creation', () => { 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({ @@ -1812,6 +1821,8 @@ describe('mirrorUpFast worktree creation', () => { 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' })