Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion backend/src/routes/internal/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
387 changes: 303 additions & 84 deletions backend/src/routes/internal/repo-mirror.ts

Large diffs are not rendered by default.

5 changes: 2 additions & 3 deletions backend/src/routes/repo-directory.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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 },
),
)

Expand Down
31 changes: 25 additions & 6 deletions backend/src/services/repo-workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -21,13 +24,30 @@ export class RepoWorkspaceService {
private readonly scheduleWorktrees: Pick<ScheduleService, 'removeWorktrees'>,
) {}

async create(repo: Repo, options: { name?: string; ref?: string } = {}) {
async create(
repo: Repo,
options: { name?: string; ref?: string; initialize?: (directory: string) => Promise<void> } = {},
) {
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 }
}
Expand Down Expand Up @@ -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<void> {
Expand Down
81 changes: 24 additions & 57 deletions backend/src/services/repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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<MirrorTargetPlan> {
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()
Expand Down Expand Up @@ -1181,13 +1126,20 @@ export function createRepoRow(
return { repo, created: true }
}

export function isDirectoryInUse(directory: string): boolean {
const target = canonicalPathSync(path.resolve(directory))
return sseAggregator.getActiveDirectories().some(
(activeDirectory) => canonicalPathSync(path.resolve(activeDirectory)) === target,
)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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<string> {
Expand Down Expand Up @@ -1340,6 +1292,21 @@ export async function getSiblingRepos(
return [...repoSiblings, ...worktreeSiblings]
}

/**
* Lists a repository's siblings in its OpenCode project without resolving their branches,
* optionally narrowed by a caller-supplied filter (for example worktree-only siblings).
*/
export async function listRepoSiblings(
database: Database,
repoId: number,
gitEnv: Record<string, string>,
openCodeClient: OpenCodeClient,
filter?: (sibling: RepoSibling) => boolean,
): Promise<RepoSibling[]> {
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 {
Expand Down
Loading