diff --git a/.env.example b/.env.example
index c0d36920..156739ce 100644
--- a/.env.example
+++ b/.env.example
@@ -17,6 +17,11 @@ LOG_LEVEL=info
OPENCODE_SERVER_PORT=5551
OPENCODE_HOST=127.0.0.1
+# Optional - import an existing standalone OpenCode install on first startup
+# Useful for Docker when your host OpenCode data is bind-mounted into the container
+# OPENCODE_IMPORT_CONFIG_PATH=/import/opencode-config/opencode.json
+# OPENCODE_IMPORT_STATE_PATH=/import/opencode-state
+
# ============================================
# Database
# ============================================
@@ -27,6 +32,11 @@ DATABASE_PATH=./data/opencode.db
# ============================================
WORKSPACE_PATH=./workspace
+# Optional - convenience vars for Docker bind mounts documented in docs/configuration/docker.md
+# OCM_REPOS_HOST_PATH=/Users/you/Development
+# OCM_OPENCODE_CONFIG_HOST_PATH=/Users/you/.config/opencode
+# OCM_OPENCODE_STATE_HOST_PATH=/Users/you/.local/share/opencode
+
# ============================================
# Timeouts (milliseconds)
# ============================================
diff --git a/backend/src/db/migrations/009-repo-source-path.ts b/backend/src/db/migrations/009-repo-source-path.ts
new file mode 100644
index 00000000..be6247d5
--- /dev/null
+++ b/backend/src/db/migrations/009-repo-source-path.ts
@@ -0,0 +1,27 @@
+import type { Migration } from '../migration-runner'
+
+interface ColumnInfo {
+ name: string
+}
+
+const migration: Migration = {
+ version: 9,
+ name: 'repo-source-path',
+
+ up(db) {
+ const tableInfo = db.prepare('PRAGMA table_info(repos)').all() as ColumnInfo[]
+ const existing = new Set(tableInfo.map((column) => column.name))
+
+ if (!existing.has('source_path')) {
+ db.run('ALTER TABLE repos ADD COLUMN source_path TEXT')
+ }
+
+ db.run('CREATE INDEX IF NOT EXISTS idx_repo_source_path ON repos(source_path)')
+ },
+
+ down(db) {
+ db.run('DROP INDEX IF EXISTS idx_repo_source_path')
+ },
+}
+
+export default migration
diff --git a/backend/src/db/migrations/index.ts b/backend/src/db/migrations/index.ts
index 9a163eb8..2e46fb63 100644
--- a/backend/src/db/migrations/index.ts
+++ b/backend/src/db/migrations/index.ts
@@ -5,6 +5,7 @@ import migration003 from './003-repos-add-columns'
import migration004 from './004-repos-indexes'
import migration005 from './005-repos-local-path-prefix'
import migration006 from './006-git-token-to-credentials'
+import migration009 from './009-repo-source-path'
export const allMigrations: Migration[] = [
migration001,
@@ -13,4 +14,5 @@ export const allMigrations: Migration[] = [
migration004,
migration005,
migration006,
+ migration009,
]
diff --git a/backend/src/db/queries.ts b/backend/src/db/queries.ts
index 5ac39b4b..d626099f 100644
--- a/backend/src/db/queries.ts
+++ b/backend/src/db/queries.ts
@@ -8,6 +8,7 @@ export interface RepoRow {
id: number
repo_url?: string
local_path: string
+ source_path?: string
branch?: string
default_branch: string
clone_status: string
@@ -19,11 +20,14 @@ export interface RepoRow {
}
function rowToRepo(row: RepoRow): Repo {
+ const fullPath = row.source_path || path.join(getReposPath(), row.local_path)
+
return {
id: row.id,
repoUrl: row.repo_url,
localPath: row.local_path,
- fullPath: path.join(getReposPath(), row.local_path),
+ fullPath,
+ sourcePath: row.source_path,
branch: row.branch,
defaultBranch: row.default_branch,
cloneStatus: row.clone_status as Repo['cloneStatus'],
@@ -39,7 +43,9 @@ export function createRepo(db: Database, repo: CreateRepoInput): Repo {
const normalizedPath = repo.localPath.trim().replace(/\/+$/, '')
const existing = repo.isLocal
- ? getRepoByLocalPath(db, normalizedPath)
+ ? repo.sourcePath
+ ? getRepoBySourcePath(db, repo.sourcePath) ?? getRepoByLocalPath(db, normalizedPath)
+ : getRepoByLocalPath(db, normalizedPath)
: getRepoByUrlAndBranch(db, repo.repoUrl, repo.branch)
if (existing) {
@@ -47,14 +53,15 @@ export function createRepo(db: Database, repo: CreateRepoInput): Repo {
}
const stmt = db.prepare(`
- INSERT INTO repos (repo_url, local_path, branch, default_branch, clone_status, cloned_at, is_worktree, is_local)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ INSERT INTO repos (repo_url, local_path, source_path, branch, default_branch, clone_status, cloned_at, is_worktree, is_local)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`)
try {
const result = stmt.run(
repo.repoUrl || null,
normalizedPath,
+ repo.sourcePath || null,
repo.branch || null,
repo.defaultBranch,
repo.cloneStatus,
@@ -72,7 +79,9 @@ export function createRepo(db: Database, repo: CreateRepoInput): Repo {
const errorMessage = getErrorMessage(error)
if (errorMessage.includes('UNIQUE constraint failed') || (error && typeof error === 'object' && 'code' in error && error.code === 'SQLITE_CONSTRAINT_UNIQUE')) {
const conflictRepo = repo.isLocal
- ? getRepoByLocalPath(db, normalizedPath)
+ ? repo.sourcePath
+ ? getRepoBySourcePath(db, repo.sourcePath) ?? getRepoByLocalPath(db, normalizedPath)
+ : getRepoByLocalPath(db, normalizedPath)
: getRepoByUrlAndBranch(db, repo.repoUrl, repo.branch)
if (conflictRepo) {
@@ -114,6 +123,13 @@ export function getRepoByLocalPath(db: Database, localPath: string): Repo | null
return row ? rowToRepo(row) : null
}
+export function getRepoBySourcePath(db: Database, sourcePath: string): Repo | null {
+ const stmt = db.prepare('SELECT * FROM repos WHERE source_path = ?')
+ const row = stmt.get(sourcePath) as RepoRow | undefined
+
+ return row ? rowToRepo(row) : null
+}
+
export function listRepos(db: Database, repoOrder?: number[]): Repo[] {
const stmt = db.prepare('SELECT * FROM repos ORDER BY cloned_at DESC')
const rows = stmt.all() as RepoRow[]
@@ -146,7 +162,7 @@ export function listRepos(db: Database, repoOrder?: number[]): Repo[] {
function getRepoName(repo: Repo): string {
return repo.repoUrl
? repo.repoUrl.split('/').slice(-1)[0]?.replace('.git', '') || repo.localPath
- : repo.localPath
+ : repo.sourcePath ? path.basename(repo.sourcePath) : repo.localPath
}
export function updateRepoStatus(db: Database, id: number, cloneStatus: Repo['cloneStatus']): void {
diff --git a/backend/src/index.ts b/backend/src/index.ts
index 4fff13ac..c6ec51fb 100644
--- a/backend/src/index.ts
+++ b/backend/src/index.ts
@@ -4,7 +4,8 @@ import { cors } from 'hono/cors'
import { serveStatic } from '@hono/node-server/serve-static'
import os from 'os'
import path from 'path'
-import { readFile } from 'fs/promises'
+import { cp, readdir, readFile, rm } from 'fs/promises'
+import { Database as SQLiteDatabase } from 'bun:sqlite'
import { initializeDatabase } from './db/schema'
import { createRepoRoutes } from './routes/repos'
import { createIPCServer, type IPCServer } from './ipc/ipcServer'
@@ -81,6 +82,71 @@ import { DEFAULT_AGENTS_MD } from './constants'
let ipcServer: IPCServer | undefined
const gitAuthService = new GitAuthService()
+const OPENCODE_STATE_DB_FILENAMES = new Set(['opencode.db', 'opencode.db-shm', 'opencode.db-wal'])
+
+function getImportPathCandidates(envKey: string, fallbackPath: string): string[] {
+ const candidates = [process.env[envKey], fallbackPath]
+ .filter((value): value is string => Boolean(value))
+ .map((value) => path.resolve(value))
+
+ return Array.from(new Set(candidates))
+}
+
+async function getFirstExistingPath(paths: string[]): Promise {
+ for (const candidate of paths) {
+ if (await fileExists(candidate)) {
+ return candidate
+ }
+ }
+
+ return undefined
+}
+
+function escapeSqliteValue(value: string): string {
+ return value.replace(/'/g, "''")
+}
+
+async function copyOpenCodeStateFiles(sourcePath: string, targetPath: string): Promise {
+ const entries = await readdir(sourcePath, { withFileTypes: true })
+
+ for (const entry of entries) {
+ if (OPENCODE_STATE_DB_FILENAMES.has(entry.name)) {
+ continue
+ }
+
+ await cp(path.join(sourcePath, entry.name), path.join(targetPath, entry.name), {
+ recursive: true,
+ force: false,
+ errorOnExist: false,
+ })
+ }
+}
+
+async function snapshotOpenCodeDatabase(sourcePath: string, targetPath: string): Promise {
+ await rm(targetPath, { force: true })
+
+ const database = new SQLiteDatabase(sourcePath)
+
+ try {
+ database.exec(`VACUUM INTO '${escapeSqliteValue(targetPath)}'`)
+ } finally {
+ database.close()
+ }
+}
+
+async function importOpenCodeStateDirectory(sourcePath: string, targetPath: string): Promise {
+ await ensureDirectoryExists(targetPath)
+ await copyOpenCodeStateFiles(sourcePath, targetPath)
+
+ const sourceDbPath = path.join(sourcePath, 'opencode.db')
+ if (!await fileExists(sourceDbPath)) {
+ return
+ }
+
+ await rm(path.join(targetPath, 'opencode.db-shm'), { force: true })
+ await rm(path.join(targetPath, 'opencode.db-wal'), { force: true })
+ await snapshotOpenCodeDatabase(sourceDbPath, path.join(targetPath, 'opencode.db'))
+}
async function ensureDefaultConfigExists(): Promise {
const settingsService = new SettingsService(db)
@@ -118,11 +184,17 @@ async function ensureDefaultConfigExists(): Promise {
}
}
- const homeConfigPath = path.join(os.homedir(), '.config/opencode/opencode.json')
- if (await fileExists(homeConfigPath)) {
- logger.info(`Found home config at ${homeConfigPath}, importing...`)
+ const importConfigPath = await getFirstExistingPath(
+ getImportPathCandidates(
+ 'OPENCODE_IMPORT_CONFIG_PATH',
+ path.join(os.homedir(), '.config/opencode/opencode.json')
+ )
+ )
+
+ if (importConfigPath) {
+ logger.info(`Found importable OpenCode config at ${importConfigPath}, importing...`)
try {
- const rawContent = await readFileContent(homeConfigPath)
+ const rawContent = await readFileContent(importConfigPath)
const parsed = parseJsonc(rawContent)
const validation = OpenCodeConfigSchema.safeParse(parsed)
@@ -142,11 +214,11 @@ async function ensureDefaultConfigExists(): Promise {
}
await writeFileContent(workspaceConfigPath, rawContent)
- logger.info('Imported home config to workspace')
+ logger.info(`Imported OpenCode config from ${importConfigPath} to workspace`)
return
}
} catch (error) {
- logger.warn('Failed to import home config', error)
+ logger.warn(`Failed to import OpenCode config from ${importConfigPath}`, error)
}
}
@@ -171,6 +243,35 @@ async function ensureDefaultConfigExists(): Promise {
logger.info('Created minimal seed config')
}
+async function ensureHomeStateImported(): Promise {
+ try {
+ const workspaceStateRoot = path.join(getWorkspacePath(), '.opencode', 'state')
+ const workspaceStatePath = path.join(workspaceStateRoot, 'opencode')
+ const workspaceStateDbPath = path.join(workspaceStatePath, 'opencode.db')
+
+ if (await fileExists(workspaceStateDbPath)) {
+ return
+ }
+
+ const importStatePath = await getFirstExistingPath(
+ getImportPathCandidates(
+ 'OPENCODE_IMPORT_STATE_PATH',
+ path.join(os.homedir(), '.local', 'share', 'opencode')
+ )
+ )
+
+ if (!importStatePath) {
+ return
+ }
+
+ await ensureDirectoryExists(workspaceStateRoot)
+ await importOpenCodeStateDirectory(importStatePath, workspaceStatePath)
+ logger.info(`Imported OpenCode state from ${importStatePath}`)
+ } catch (error) {
+ logger.warn('Failed to import OpenCode state, continuing without imported state', error)
+ }
+}
+
async function ensureDefaultAgentsMdExists(): Promise {
const agentsMdPath = getAgentsMdPath()
const exists = await fileExists(agentsMdPath)
@@ -197,6 +298,7 @@ try {
await cleanupExpiredCache()
await ensureDefaultConfigExists()
+ await ensureHomeStateImported()
await ensureDefaultAgentsMdExists()
const settingsService = new SettingsService(db)
diff --git a/backend/src/routes/repos.ts b/backend/src/routes/repos.ts
index 5d92f97b..6b6baf9b 100644
--- a/backend/src/routes/repos.ts
+++ b/backend/src/routes/repos.ts
@@ -1,6 +1,7 @@
import { Hono } from 'hono'
import type { ContentfulStatusCode } from 'hono/utils/http-status'
import type { Database } from 'bun:sqlite'
+import { DiscoverReposRequestSchema } from '@opencode-manager/shared/schemas'
import * as db from '../db/queries'
import * as repoService from '../services/repo'
import * as archiveService from '../services/archive'
@@ -10,7 +11,7 @@ import { opencodeServerManager } from '../services/opencode-single-server'
import { proxyToOpenCodeWithDirectory } from '../services/proxy'
import { logger } from '../utils/logger'
import { getErrorMessage, getStatusCode } from '../utils/error-utils'
-import { getOpenCodeConfigFilePath, getReposPath } from '@opencode-manager/shared/config/env'
+import { getOpenCodeConfigFilePath } from '@opencode-manager/shared/config/env'
import { createRepoGitRoutes } from './repo-git'
import type { GitAuthService } from '../services/git-auth'
import path from 'path'
@@ -68,7 +69,30 @@ export function createRepoRoutes(database: Database, gitAuthService: GitAuthServ
return c.json({ error: getErrorMessage(error) }, getStatusCode(error) as ContentfulStatusCode)
}
})
-
+
+ app.post('/discover', async (c) => {
+ try {
+ const body = await c.req.json()
+ const result = DiscoverReposRequestSchema.safeParse(body)
+
+ if (!result.success) {
+ return c.json({ error: result.error.issues[0]?.message || 'Invalid request' }, 400)
+ }
+
+ const discovery = await repoService.discoverLocalRepos(
+ database,
+ gitAuthService,
+ result.data.rootPath,
+ result.data.maxDepth
+ )
+
+ return c.json(discovery)
+ } catch (error: unknown) {
+ logger.error('Failed to discover repos:', error)
+ return c.json({ error: getErrorMessage(error) }, getStatusCode(error) as ContentfulStatusCode)
+ }
+ })
+
app.get('/', async (c) => {
try {
const settingsService = new SettingsService(database)
@@ -267,8 +291,8 @@ app.get('/', async (c) => {
return c.json({ error: 'Repo not found' }, 404)
}
- const repoPath = path.resolve(getReposPath(), repo.localPath)
- const repoName = path.basename(repo.localPath)
+ const repoPath = repo.fullPath
+ const repoName = path.basename(repo.fullPath)
const includeGit = c.req.query('includeGit') === 'true'
const includePathsParam = c.req.query('includePaths')
diff --git a/backend/src/services/notification.ts b/backend/src/services/notification.ts
index 0a7e9120..f43a8c86 100644
--- a/backend/src/services/notification.ts
+++ b/backend/src/services/notification.ts
@@ -9,7 +9,7 @@ import {
} from "@opencode-manager/shared/schemas";
import { SettingsService } from "./settings";
import { sseAggregator, type SSEEvent } from "./sse-aggregator";
-import { getRepoByLocalPath } from "../db/queries";
+import { getRepoByLocalPath, getRepoBySourcePath } from "../db/queries";
import { getReposPath } from "@opencode-manager/shared/config/env";
import path from "path";
@@ -221,7 +221,7 @@ export class NotificationService {
if (_directory) {
const reposBasePath = getReposPath();
const localPath = path.relative(reposBasePath, _directory);
- const repo = getRepoByLocalPath(this.db, localPath);
+ const repo = getRepoBySourcePath(this.db, path.resolve(_directory)) ?? getRepoByLocalPath(this.db, localPath);
if (repo) {
repoId = repo.id;
diff --git a/backend/src/services/repo.ts b/backend/src/services/repo.ts
index 6f4933b5..86343d4c 100644
--- a/backend/src/services/repo.ts
+++ b/backend/src/services/repo.ts
@@ -1,3 +1,4 @@
+import fs from 'fs/promises'
import { existsSync, rmSync } from 'node:fs'
import { executeCommand } from '../utils/process'
import { ensureDirectoryExists } from './file-operations'
@@ -13,6 +14,8 @@ import { parseSSHHost } from '../utils/ssh-key-manager'
import { getErrorMessage } from '../utils/error-utils'
const GIT_CLONE_TIMEOUT = 300000
+const DEFAULT_DISCOVERY_MAX_DEPTH = 4
+const DISCOVERY_SKIP_DIRECTORIES = new Set(['.git', 'node_modules'])
function enhanceCloneError(error: unknown, repoUrl: string, originalMessage: string): Error {
const message = originalMessage.toLowerCase()
@@ -58,24 +61,154 @@ async function isValidGitRepo(repoPath: string, env: Record): Pr
}
}
-async function checkRepoNameAvailable(name: string): Promise {
- const reposPath = getReposPath()
- const targetPath = path.join(reposPath, name)
+function normalizeInputPath(input: string): string {
+ return input.trim().replace(/[\\/]+$/, '')
+}
+
+function normalizeAbsolutePath(input: string): string {
+ return path.resolve(normalizeInputPath(input))
+}
+
+async function pathExists(targetPath: string): Promise {
try {
- await executeCommand(['test', '-e', targetPath], { silent: true })
- return false
- } catch {
+ await fs.lstat(targetPath)
return true
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
+ return false
+ }
+ throw error
}
}
-async function copyRepoToWorkspace(sourcePath: string, targetName: string, env: Record): Promise {
- const reposPath = getReposPath()
- const targetPath = path.join(reposPath, targetName)
-
- logger.info(`Copying repo from ${sourcePath} to ${targetPath}`)
- await executeCommand(['git', 'clone', '--local', sourcePath, targetName], { cwd: reposPath, env })
- logger.info(`Successfully copied repo to ${targetPath}`)
+async function isGitRepoRootPath(targetPath: string): Promise {
+ try {
+ const gitPath = path.join(targetPath, '.git')
+ const stats = await fs.lstat(gitPath)
+ return stats.isDirectory() || stats.isFile()
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
+ return false
+ }
+ throw error
+ }
+}
+
+async function isGitWorktreeRepo(targetPath: string): Promise {
+ try {
+ return (await fs.lstat(path.join(targetPath, '.git'))).isFile()
+ } catch {
+ return false
+ }
+}
+
+function sanitizeWorkspaceAliasSegment(segment: string): string {
+ const sanitized = segment
+ .trim()
+ .replace(/[^a-zA-Z0-9._-]+/g, '-')
+ .replace(/^-+/, '')
+ .replace(/-+$/, '')
+
+ return sanitized || 'repo'
+}
+
+function buildWorkspaceAliasCandidates(sourcePath: string, rootPath?: string): string[] {
+ const candidates: string[] = []
+ const baseName = sanitizeWorkspaceAliasSegment(path.basename(sourcePath))
+ candidates.push(baseName)
+
+ if (rootPath) {
+ const relativePath = path.relative(rootPath, sourcePath)
+ if (relativePath && !relativePath.startsWith('..')) {
+ const relativeAlias = relativePath
+ .split(path.sep)
+ .map(sanitizeWorkspaceAliasSegment)
+ .filter(Boolean)
+ .join('--')
+
+ if (relativeAlias && !candidates.includes(relativeAlias)) {
+ candidates.push(relativeAlias)
+ }
+ }
+ }
+
+ return candidates
+}
+
+function getWorkspaceLocalPathForRepo(sourcePath: string): string | null {
+ const reposPath = path.resolve(getReposPath())
+ const normalizedSourcePath = path.resolve(sourcePath)
+
+ if (normalizedSourcePath === reposPath) {
+ return null
+ }
+
+ if (!normalizedSourcePath.startsWith(`${reposPath}${path.sep}`)) {
+ return null
+ }
+
+ return path.relative(reposPath, normalizedSourcePath)
+}
+
+async function isWorkspaceAliasAvailable(alias: string, sourcePath?: string): Promise {
+ const aliasPath = path.join(getReposPath(), alias)
+
+ try {
+ const stats = await fs.lstat(aliasPath)
+ if (!sourcePath || !stats.isSymbolicLink()) {
+ return false
+ }
+
+ const existingTarget = await fs.readlink(aliasPath)
+ return path.resolve(path.dirname(aliasPath), existingTarget) === sourcePath
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
+ return true
+ }
+ throw error
+ }
+}
+
+async function createWorkspaceLink(alias: string, sourcePath: string): Promise {
+ const aliasPath = path.join(getReposPath(), alias)
+ const available = await isWorkspaceAliasAvailable(alias, sourcePath)
+
+ if (!available) {
+ throw new Error(`A repository named '${alias}' already exists in the workspace. Please remove it first or use a different source directory.`)
+ }
+
+ if (await pathExists(aliasPath)) {
+ return
+ }
+
+ await fs.mkdir(path.dirname(aliasPath), { recursive: true })
+ await fs.symlink(sourcePath, aliasPath, process.platform === 'win32' ? 'junction' : 'dir')
+}
+
+async function pickWorkspaceAlias(database: Database, sourcePath: string, rootPath?: string): Promise {
+ const existingRepo = db.getRepoBySourcePath(database, sourcePath)
+ if (existingRepo) {
+ return existingRepo.localPath
+ }
+
+ const candidates = buildWorkspaceAliasCandidates(sourcePath, rootPath)
+ for (const candidate of candidates) {
+ const existingByLocalPath = db.getRepoByLocalPath(database, candidate)
+ if (!existingByLocalPath && await isWorkspaceAliasAvailable(candidate, sourcePath)) {
+ return candidate
+ }
+ }
+
+ const baseCandidate = candidates[0] || 'repo'
+ let suffix = 2
+ while (true) {
+ const candidate = `${baseCandidate}-${suffix}`
+ const existingByLocalPath = db.getRepoByLocalPath(database, candidate)
+ if (!existingByLocalPath && await isWorkspaceAliasAvailable(candidate, sourcePath)) {
+ return candidate
+ }
+ suffix += 1
+ }
}
@@ -97,6 +230,151 @@ async function safeGetCurrentBranch(repoPath: string, env: Record {
+ const normalizedSourcePath = normalizeAbsolutePath(sourcePath)
+ const env = gitAuthService.getGitEnvironment()
+ const existingBySourcePath = db.getRepoBySourcePath(database, normalizedSourcePath)
+
+ if (existingBySourcePath) {
+ logger.info(`Local repo already exists in database: ${normalizedSourcePath}`)
+ return { repo: existingBySourcePath, existed: true }
+ }
+
+ const exists = await pathExists(normalizedSourcePath)
+ if (!exists) {
+ throw new Error(`No such file or directory: '${normalizedSourcePath}'`)
+ }
+
+ const isGitRepo = await isValidGitRepo(normalizedSourcePath, env)
+ if (!isGitRepo) {
+ throw new Error(`Directory exists but is not a valid Git repository. Use folder discovery to scan nested repositories.`)
+ }
+
+ if (branch) {
+ const currentBranch = await safeGetCurrentBranch(normalizedSourcePath, env)
+ if (currentBranch !== branch) {
+ await checkoutBranchSafely(normalizedSourcePath, branch, env)
+ }
+ }
+
+ const currentBranch = await safeGetCurrentBranch(normalizedSourcePath, env)
+ const workspaceLocalPath = getWorkspaceLocalPathForRepo(normalizedSourcePath)
+
+ if (workspaceLocalPath) {
+ const existingByLocalPath = db.getRepoByLocalPath(database, workspaceLocalPath)
+ if (existingByLocalPath) {
+ logger.info(`Workspace repo already exists in database: ${workspaceLocalPath}`)
+ return { repo: existingByLocalPath, existed: true }
+ }
+ }
+
+ const repoLocalPath = workspaceLocalPath || await pickWorkspaceAlias(database, normalizedSourcePath, rootPath)
+ if (!workspaceLocalPath) {
+ await createWorkspaceLink(repoLocalPath, normalizedSourcePath)
+ }
+
+ const repo = db.createRepo(database, {
+ localPath: repoLocalPath,
+ sourcePath: workspaceLocalPath ? undefined : normalizedSourcePath,
+ branch: branch || currentBranch || undefined,
+ defaultBranch: branch || currentBranch || 'main',
+ cloneStatus: 'ready',
+ clonedAt: Date.now(),
+ isLocal: true,
+ isWorktree: await isGitWorktreeRepo(normalizedSourcePath),
+ })
+
+ logger.info(`Registered local repo at ${normalizedSourcePath} as ${repoLocalPath}`)
+ return { repo, existed: false }
+}
+
+export async function discoverLocalRepos(
+ database: Database,
+ gitAuthService: GitAuthService,
+ rootPath: string,
+ maxDepth: number = DEFAULT_DISCOVERY_MAX_DEPTH
+): Promise<{
+ repos: Repo[]
+ discoveredCount: number
+ existingCount: number
+ errors: Array<{ path: string; error: string }>
+}> {
+ const normalizedRootPath = normalizeAbsolutePath(rootPath)
+ const rootStats = await fs.stat(normalizedRootPath).catch((error: unknown) => {
+ throw new Error(`Failed to access '${normalizedRootPath}': ${getErrorMessage(error)}`)
+ })
+
+ if (!rootStats.isDirectory()) {
+ throw new Error(`Path is not a directory: '${normalizedRootPath}'`)
+ }
+
+ const repoPaths: string[] = []
+ const errors: Array<{ path: string; error: string }> = []
+
+ const walk = async (currentPath: string, depth: number): Promise => {
+ try {
+ if (await isGitRepoRootPath(currentPath)) {
+ repoPaths.push(currentPath)
+ return
+ }
+
+ if (depth >= maxDepth) {
+ return
+ }
+
+ const entries = await fs.readdir(currentPath, { withFileTypes: true })
+ for (const entry of entries) {
+ if (!entry.isDirectory() || entry.isSymbolicLink() || DISCOVERY_SKIP_DIRECTORIES.has(entry.name)) {
+ continue
+ }
+
+ await walk(path.join(currentPath, entry.name), depth + 1)
+ }
+ } catch (error: unknown) {
+ errors.push({
+ path: currentPath,
+ error: getErrorMessage(error),
+ })
+ }
+ }
+
+ await walk(normalizedRootPath, 0)
+
+ const repos: Repo[] = []
+ let discoveredCount = 0
+ let existingCount = 0
+
+ for (const repoPath of repoPaths.sort((left, right) => left.localeCompare(right))) {
+ try {
+ const result = await registerExistingLocalRepo(database, gitAuthService, repoPath, undefined, normalizedRootPath)
+ repos.push(result.repo)
+ if (result.existed) {
+ existingCount += 1
+ } else {
+ discoveredCount += 1
+ }
+ } catch (error: unknown) {
+ errors.push({
+ path: repoPath,
+ error: getErrorMessage(error),
+ })
+ }
+ }
+
+ return {
+ repos,
+ discoveredCount,
+ existingCount,
+ errors,
+ }
+}
+
async function checkoutBranchSafely(repoPath: string, branch: string, env: Record): Promise {
const sanitizedBranch = branch
.replace(/^refs\/heads\//, '')
@@ -137,55 +415,15 @@ export async function initLocalRepo(
localPath: string,
branch?: string
): Promise {
- const normalizedInputPath = localPath.trim().replace(/\/+$/, '')
- const env = gitAuthService.getGitEnvironment()
-
- let targetPath: string
- let repoLocalPath: string
- let sourceWasGitRepo = false
-
+ const normalizedInputPath = normalizeInputPath(localPath)
+
if (path.isAbsolute(normalizedInputPath)) {
- logger.info(`Absolute path detected: ${normalizedInputPath}`)
-
- try {
- const exists = await executeCommand(['test', '-d', normalizedInputPath], { silent: true })
- .then(() => true)
- .catch(() => false)
-
- if (!exists) {
- throw new Error(`No such file or directory: '${normalizedInputPath}'`)
- }
-
- const isGit = await isValidGitRepo(normalizedInputPath, env)
-
- if (isGit) {
- sourceWasGitRepo = true
- const baseName = path.basename(normalizedInputPath)
-
- const isAvailable = await checkRepoNameAvailable(baseName)
- if (!isAvailable) {
- throw new Error(`A repository named '${baseName}' already exists in the workspace. Please remove it first or use a different source directory.`)
- }
-
- repoLocalPath = baseName
-
- logger.info(`Copying existing git repo from ${normalizedInputPath} to workspace as ${baseName}`)
- await copyRepoToWorkspace(normalizedInputPath, baseName, env)
- targetPath = path.join(getReposPath(), baseName)
- } else {
- throw new Error(`Directory exists but is not a valid Git repository. Please provide either a Git repository path or a simple directory name to create a new empty repository.`)
- }
- } catch (error: unknown) {
- if (getErrorMessage(error).includes('No such file or directory')) {
- throw error
- }
- throw new Error(`Failed to process absolute path '${normalizedInputPath}': ${getErrorMessage(error)}`)
- }
- } else {
- repoLocalPath = normalizedInputPath
- targetPath = path.join(getReposPath(), repoLocalPath)
+ const result = await registerExistingLocalRepo(database, gitAuthService, normalizedInputPath, branch)
+ return result.repo
}
-
+
+ const repoLocalPath = normalizedInputPath
+ const targetPath = path.join(getReposPath(), repoLocalPath)
const existing = db.getRepoByLocalPath(database, repoLocalPath)
if (existing) {
logger.info(`Local repo already exists in database: ${repoLocalPath}`)
@@ -213,26 +451,15 @@ export async function initLocalRepo(
}
try {
- if (!sourceWasGitRepo) {
- await ensureDirectoryExists(targetPath)
- directoryCreated = true
- logger.info(`Created directory for local repo: ${targetPath}`)
-
- logger.info(`Initializing git repository: ${targetPath}`)
- await executeCommand(['git', 'init'], { cwd: targetPath })
-
- if (branch && branch !== 'main') {
- await executeCommand(['git', '-C', targetPath, 'checkout', '-b', branch])
- }
- } else {
- if (branch) {
- logger.info(`Switching to branch ${branch} for copied repo`)
- const currentBranch = await safeGetCurrentBranch(targetPath, env)
-
- if (currentBranch !== branch) {
- await checkoutBranchSafely(targetPath, branch, env)
- }
- }
+ await ensureDirectoryExists(targetPath)
+ directoryCreated = true
+ logger.info(`Created directory for local repo: ${targetPath}`)
+
+ logger.info(`Initializing git repository: ${targetPath}`)
+ await executeCommand(['git', 'init'], { cwd: targetPath })
+
+ if (branch && branch !== 'main') {
+ await executeCommand(['git', '-C', targetPath, 'checkout', '-b', branch])
}
const isGitRepo = await executeCommand(['git', '-C', targetPath, 'rev-parse', '--git-dir'])
@@ -256,20 +483,13 @@ export async function initLocalRepo(
logger.error(`Failed to rollback database record for repo id ${repo.id}:`, getErrorMessage(dbError))
}
- if (directoryCreated && !sourceWasGitRepo) {
+ if (directoryCreated) {
try {
await executeCommand(['rm', '-rf', repoLocalPath], getReposPath())
logger.info(`Rolled back directory: ${repoLocalPath}`)
} catch (fsError: unknown) {
logger.error(`Failed to rollback directory ${repoLocalPath}:`, getErrorMessage(fsError))
}
- } else if (sourceWasGitRepo) {
- try {
- await executeCommand(['rm', '-rf', repoLocalPath], getReposPath())
- logger.info(`Cleaned up copied directory: ${repoLocalPath}`)
- } catch (fsError: unknown) {
- logger.error(`Failed to clean up copied directory ${repoLocalPath}:`, getErrorMessage(fsError))
- }
}
throw new Error(`Failed to initialize local repository '${repoLocalPath}': ${getErrorMessage(error)}`)
@@ -518,7 +738,7 @@ export async function cloneRepo(
}
export async function getCurrentBranch(repo: Repo, env: Record): Promise {
- const repoPath = path.resolve(getReposPath(), repo.localPath)
+ const repoPath = path.resolve(repo.fullPath)
const branch = await safeGetCurrentBranch(repoPath, env)
return branch || repo.branch || repo.defaultBranch || null
}
@@ -535,7 +755,7 @@ export async function switchBranch(
}
try {
- const repoPath = path.resolve(getReposPath(), repo.localPath)
+ const repoPath = path.resolve(repo.fullPath)
const env = gitAuthService.getGitEnvironment()
const sanitizedBranch = branch
@@ -565,7 +785,7 @@ export async function createBranch(database: Database, gitAuthService: GitAuthSe
}
try {
- const repoPath = path.resolve(getReposPath(), repo.localPath)
+ const repoPath = path.resolve(repo.fullPath)
const env = gitAuthService.getGitEnvironment()
const sanitizedBranch = branch
@@ -603,7 +823,7 @@ export async function pullRepo(
const env = gitAuthService.getGitEnvironment()
logger.info(`Pulling repo: ${repo.repoUrl}`)
- await executeCommand(['git', '-C', path.resolve(getReposPath(), repo.localPath), 'pull'], { env })
+ await executeCommand(['git', '-C', path.resolve(repo.fullPath), 'pull'], { env })
db.updateLastPulled(database, repoId)
logger.info(`Repo pulled successfully: ${repo.repoUrl}`)
@@ -619,8 +839,7 @@ export async function deleteRepoFiles(database: Database, repoId: number): Promi
throw new Error(`Repo not found: ${repoId}`)
}
- const dirName = repo.localPath.split('/').pop() || repo.localPath
- const fullPath = path.resolve(getReposPath(), dirName)
+ const fullPath = path.resolve(getReposPath(), repo.localPath)
if (repo.isWorktree && repo.repoUrl) {
const { name: repoName } = normalizeRepoUrl(repo.repoUrl)
@@ -635,7 +854,7 @@ export async function deleteRepoFiles(database: Database, repoId: number): Promi
}
}
- await executeCommand(['rm', '-rf', dirName], getReposPath())
+ await executeCommand(['rm', '-rf', repo.localPath], getReposPath())
db.deleteRepo(database, repoId)
}
@@ -718,4 +937,4 @@ async function createWorktreeSafely(baseRepoPath: string, worktreePath: string,
} else {
await executeCommand(['git', '-C', baseRepoPath, 'worktree', 'add', '-b', branch, worktreePath], { env })
}
-}
\ No newline at end of file
+}
diff --git a/backend/src/types/repo.ts b/backend/src/types/repo.ts
index 1486f274..81736697 100644
--- a/backend/src/types/repo.ts
+++ b/backend/src/types/repo.ts
@@ -8,6 +8,7 @@ export interface Repo extends BaseRepo {
interface CreateRepoInputBase {
localPath: string
+ sourcePath?: string
branch?: string
defaultBranch: string
cloneStatus: 'cloning' | 'ready' | 'error'
diff --git a/backend/test/db/queries.test.ts b/backend/test/db/queries.test.ts
index 0d275194..5fc66f18 100644
--- a/backend/test/db/queries.test.ts
+++ b/backend/test/db/queries.test.ts
@@ -16,6 +16,7 @@ vi.mock('bun:sqlite', () => ({
describe('Database Queries', () => {
beforeEach(() => {
vi.clearAllMocks()
+ mockDb.prepare.mockReset()
})
describe('createRepo', () => {
@@ -23,14 +24,20 @@ describe('Database Queries', () => {
const repo = {
repoUrl: 'https://github.com/test/repo',
localPath: 'repos/test-repo',
+ sourcePath: '/Users/test/repos/test-repo',
branch: 'main',
defaultBranch: 'main',
cloneStatus: 'ready' as const,
clonedAt: Date.now(),
- isWorktree: false
+ isWorktree: false,
+ isLocal: true,
}
- const existingCheckStmt = {
+ const existingCheckSourceStmt = {
+ get: vi.fn().mockReturnValue(undefined)
+ }
+
+ const existingCheckLocalStmt = {
get: vi.fn().mockReturnValue(undefined)
}
@@ -43,6 +50,7 @@ describe('Database Queries', () => {
id: 1,
repo_url: repo.repoUrl,
local_path: repo.localPath,
+ source_path: repo.sourcePath,
branch: repo.branch,
default_branch: repo.defaultBranch,
clone_status: repo.cloneStatus,
@@ -52,7 +60,8 @@ describe('Database Queries', () => {
}
mockDb.prepare
- .mockReturnValueOnce(existingCheckStmt)
+ .mockReturnValueOnce(existingCheckSourceStmt)
+ .mockReturnValueOnce(existingCheckLocalStmt)
.mockReturnValueOnce(insertStmt)
.mockReturnValueOnce(selectStmt)
@@ -62,12 +71,13 @@ describe('Database Queries', () => {
expect(insertStmt.run).toHaveBeenCalledWith(
repo.repoUrl,
repo.localPath,
+ repo.sourcePath,
repo.branch || null,
repo.defaultBranch,
repo.cloneStatus,
repo.clonedAt,
repo.isWorktree ? 1 : 0,
- 0
+ 1
)
expect(result.id).toBe(1)
})
@@ -80,6 +90,7 @@ describe('Database Queries', () => {
id: 1,
repo_url: 'https://github.com/test/repo',
local_path: 'repos/test-repo',
+ source_path: '/Users/test/repos/test-repo',
branch: 'main',
default_branch: 'main',
clone_status: 'ready',
@@ -101,7 +112,8 @@ describe('Database Queries', () => {
id: 1,
repoUrl: 'https://github.com/test/repo',
localPath: 'repos/test-repo',
- fullPath: expect.stringContaining('repos/test-repo'),
+ fullPath: '/Users/test/repos/test-repo',
+ sourcePath: '/Users/test/repos/test-repo',
branch: 'main',
defaultBranch: 'main',
cloneStatus: 'ready',
diff --git a/backend/test/routes/git.test.ts b/backend/test/routes/git.test.ts
index 42dc7bee..df20570b 100644
--- a/backend/test/routes/git.test.ts
+++ b/backend/test/routes/git.test.ts
@@ -81,6 +81,20 @@ describe('Git Routes', () => {
})
})
+ describe('POST /discover', () => {
+ it('should reject invalid maxDepth values', async () => {
+ const response = await app.request('/discover', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ rootPath: '/Users/test/projects', maxDepth: 99 }),
+ })
+ const body = await response.json()
+
+ expect(response.status).toBe(400)
+ expect(body).toHaveProperty('error')
+ })
+ })
+
describe('POST /:id/git/fetch', () => {
it('should return 404 when repo does not exist', async () => {
getRepoByIdMock.mockReturnValue(null)
diff --git a/backend/test/services/repo.test.ts b/backend/test/services/repo.test.ts
index a5102eeb..89a17177 100644
--- a/backend/test/services/repo.test.ts
+++ b/backend/test/services/repo.test.ts
@@ -1,4 +1,5 @@
-import { describe, it, expect, vi, beforeEach } from 'vitest'
+import path from 'path'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getReposPath } from '@opencode-manager/shared/config/env'
import type { GitAuthService } from '../../src/services/git-auth'
@@ -6,10 +7,30 @@ const executeCommand = vi.fn()
const ensureDirectoryExists = vi.fn()
const getRepoByLocalPath = vi.fn()
+const getRepoBySourcePath = vi.fn()
const createRepo = vi.fn()
const updateRepoStatus = vi.fn()
+const updateRepoBranch = vi.fn()
const deleteRepo = vi.fn()
+const lstat = vi.fn()
+const stat = vi.fn()
+const readdir = vi.fn()
+const mkdir = vi.fn()
+const symlink = vi.fn()
+const readlink = vi.fn()
+
+vi.mock('fs/promises', () => ({
+ default: {
+ lstat,
+ stat,
+ readdir,
+ mkdir,
+ symlink,
+ readlink,
+ },
+}))
+
vi.mock('../../src/utils/process', () => ({
executeCommand,
}))
@@ -20,195 +41,310 @@ vi.mock('../../src/services/file-operations', () => ({
vi.mock('../../src/db/queries', () => ({
getRepoByLocalPath,
+ getRepoBySourcePath,
createRepo,
updateRepoStatus,
+ updateRepoBranch,
deleteRepo,
}))
-vi.mock('../../src/services/settings', () => ({
- SettingsService: vi.fn().mockImplementation(() => ({
- getSettings: () => ({
- preferences: {
- gitCredentials: [],
- },
- updatedAt: Date.now(),
- }),
- })),
-}))
-
const mockGitAuthService = {
getGitEnvironment: vi.fn().mockReturnValue({}),
} as unknown as GitAuthService
-describe('initLocalRepo', () => {
+function createDirectoryStat() {
+ return {
+ isDirectory: () => true,
+ isFile: () => false,
+ isSymbolicLink: () => false,
+ }
+}
+
+function createFileStat() {
+ return {
+ isDirectory: () => false,
+ isFile: () => true,
+ isSymbolicLink: () => false,
+ }
+}
+
+function createDirent(name: string) {
+ return {
+ name,
+ isDirectory: () => true,
+ isSymbolicLink: () => false,
+ }
+}
+
+function createEnoentError(targetPath: string) {
+ return Object.assign(new Error(`ENOENT: ${targetPath}`), { code: 'ENOENT' })
+}
+
+describe('repo service', () => {
beforeEach(() => {
vi.clearAllMocks()
- executeCommand.mockResolvedValue('')
ensureDirectoryExists.mockResolvedValue(undefined)
+ mkdir.mockResolvedValue(undefined)
+ symlink.mockResolvedValue(undefined)
+ readlink.mockResolvedValue('')
+ readdir.mockResolvedValue([])
+ stat.mockResolvedValue(createDirectoryStat())
+ executeCommand.mockImplementation(async (args: string[]) => {
+ if (args.includes('--git-dir')) {
+ return '.git'
+ }
+
+ if (args.includes('HEAD') && !args.includes('--abbrev-ref')) {
+ return 'abc123'
+ }
+
+ if (args.includes('--abbrev-ref')) {
+ return 'main'
+ }
+
+ return ''
+ })
})
- it('creates new empty git repo for relative path', async () => {
+ it('creates a new empty git repo for a relative path', async () => {
const { initLocalRepo } = await import('../../src/services/repo')
- const database = {} as any
- const localPath = 'my-new-repo'
-
+ const database = {} as never
+
getRepoByLocalPath.mockReturnValue(null)
- createRepo.mockReturnValue({
+ createRepo.mockImplementation((_, input) => ({
id: 1,
- repoUrl: undefined,
- localPath: 'my-new-repo',
- defaultBranch: 'main',
- cloneStatus: 'cloning',
- clonedAt: Date.now(),
+ localPath: input.localPath,
+ fullPath: path.join(getReposPath(), input.localPath),
+ defaultBranch: input.defaultBranch,
+ cloneStatus: input.cloneStatus,
+ clonedAt: input.clonedAt,
isLocal: true,
- })
-
- const result = await initLocalRepo(database, mockGitAuthService, localPath)
+ }))
+
+ const result = await initLocalRepo(database, mockGitAuthService, 'my-new-repo')
- expect(executeCommand).toHaveBeenCalledWith(['git', 'init'], expect.any(Object))
expect(ensureDirectoryExists).toHaveBeenCalledWith(expect.stringContaining('my-new-repo'))
+ expect(executeCommand).toHaveBeenCalledWith(['git', 'init'], expect.any(Object))
expect(updateRepoStatus).toHaveBeenCalledWith(database, 1, 'ready')
expect(result.cloneStatus).toBe('ready')
+ expect(result.fullPath).toContain('my-new-repo')
})
- it('copies existing git repo from absolute path', async () => {
+ it('links an absolute git repo in place and preserves its source path', async () => {
const { initLocalRepo } = await import('../../src/services/repo')
- const database = {} as any
+ const database = {} as never
const absolutePath = '/Users/test/existing-repo'
+ const aliasPath = path.join(getReposPath(), 'existing-repo')
+ getRepoBySourcePath.mockReturnValue(null)
getRepoByLocalPath.mockReturnValue(null)
createRepo.mockImplementation((_, input) => ({
id: 2,
- repoUrl: undefined,
localPath: input.localPath,
- defaultBranch: 'main',
- cloneStatus: 'cloning',
- clonedAt: Date.now(),
+ sourcePath: input.sourcePath,
+ fullPath: input.sourcePath ?? path.join(getReposPath(), input.localPath),
+ branch: input.branch,
+ defaultBranch: input.defaultBranch,
+ cloneStatus: input.cloneStatus,
+ clonedAt: input.clonedAt,
isLocal: true,
+ isWorktree: input.isWorktree,
}))
- let callCount = 0
- executeCommand.mockImplementation(async () => {
- callCount++
- if (callCount === 2) return '.git'
- if (callCount === 3) throw new Error('not found')
- if (callCount === 5) return '.git'
- return ''
+ lstat.mockImplementation(async (targetPath: string) => {
+ if (targetPath === absolutePath) {
+ return createDirectoryStat()
+ }
+
+ if (targetPath === path.join(absolutePath, '.git')) {
+ return createDirectoryStat()
+ }
+
+ if (targetPath === aliasPath) {
+ throw createEnoentError(targetPath)
+ }
+
+ throw createEnoentError(targetPath)
})
const result = await initLocalRepo(database, mockGitAuthService, absolutePath)
- expect(executeCommand).toHaveBeenCalledWith(['test', '-d', '/Users/test/existing-repo'], { silent: true })
- expect(executeCommand).toHaveBeenCalledWith(['git', '-C', '/Users/test/existing-repo', 'rev-parse', '--git-dir'], expect.objectContaining({ silent: true }))
- expect(executeCommand).toHaveBeenCalledWith(['git', 'clone', '--local', '/Users/test/existing-repo', 'existing-repo'], expect.objectContaining({ cwd: getReposPath() }))
- expect(updateRepoStatus).toHaveBeenCalledWith(database, 2, 'ready')
- expect(result.cloneStatus).toBe('ready')
+ expect(createRepo).toHaveBeenCalledWith(database, expect.objectContaining({
+ localPath: 'existing-repo',
+ sourcePath: absolutePath,
+ cloneStatus: 'ready',
+ isLocal: true,
+ }))
+ expect(symlink).toHaveBeenCalledWith(absolutePath, aliasPath, 'dir')
expect(result.localPath).toBe('existing-repo')
+ expect(result.sourcePath).toBe(absolutePath)
+ expect(result.fullPath).toBe(absolutePath)
})
- it('returns existing repo if local path already in database (relative)', async () => {
- const { initLocalRepo } = await import('../../src/services/repo')
- const database = {} as any
- const localPath = 'existing-repo'
+ it('discovers nested repos and keeps existing registrations', async () => {
+ const { discoverLocalRepos } = await import('../../src/services/repo')
+ const database = {} as never
+ const rootPath = '/Users/test/projects'
const existingRepo = {
- id: 100,
- localPath: 'existing-repo',
+ id: 9,
+ localPath: 'app-two',
+ sourcePath: '/Users/test/projects/nested/app-two',
+ fullPath: '/Users/test/projects/nested/app-two',
+ branch: 'main',
+ defaultBranch: 'main',
cloneStatus: 'ready' as const,
+ clonedAt: Date.now(),
+ isLocal: true,
+ isWorktree: true,
}
- getRepoByLocalPath.mockReturnValue(existingRepo)
+ getRepoByLocalPath.mockReturnValue(null)
+ getRepoBySourcePath.mockImplementation((_, sourcePath: string) => {
+ if (sourcePath === existingRepo.sourcePath) {
+ return existingRepo
+ }
- const result = await initLocalRepo(database, mockGitAuthService, localPath)
+ return null
+ })
+ createRepo.mockImplementation((_, input) => ({
+ id: 3,
+ localPath: input.localPath,
+ sourcePath: input.sourcePath,
+ fullPath: input.sourcePath ?? path.join(getReposPath(), input.localPath),
+ branch: input.branch,
+ defaultBranch: input.defaultBranch,
+ cloneStatus: input.cloneStatus,
+ clonedAt: input.clonedAt,
+ isLocal: true,
+ isWorktree: input.isWorktree,
+ }))
- expect(result).toBe(existingRepo)
- expect(createRepo).not.toHaveBeenCalled()
- expect(executeCommand).not.toHaveBeenCalled()
- })
+ lstat.mockImplementation(async (targetPath: string) => {
+ if (targetPath === path.join(rootPath, 'app-one')) {
+ return createDirectoryStat()
+ }
- it('throws error when absolute path does not exist', async () => {
- const { initLocalRepo } = await import('../../src/services/repo')
- const database = {} as any
- const nonExistentPath = '/Users/test/non-existent'
+ if (targetPath === path.join(rootPath, 'nested')) {
+ return createDirectoryStat()
+ }
- executeCommand.mockRejectedValueOnce(new Error('Command failed'))
+ if (targetPath === path.join(rootPath, 'nested', 'app-two')) {
+ return createDirectoryStat()
+ }
- await expect(initLocalRepo(database, mockGitAuthService, nonExistentPath)).rejects.toThrow("No such file or directory")
- })
+ if (targetPath === path.join(rootPath, '.git')) {
+ throw createEnoentError(targetPath)
+ }
- it('throws error when repo name already exists in workspace', async () => {
- const { initLocalRepo } = await import('../../src/services/repo')
- const database = {} as any
- const absolutePath = '/Users/test/existing-repo'
+ if (targetPath === path.join(rootPath, 'app-one', '.git')) {
+ return createDirectoryStat()
+ }
- let callCount = 0
- executeCommand.mockImplementation(async () => {
- callCount++
- if (callCount === 2) return '.git'
- if (callCount === 3) return ''
- return ''
- })
+ if (targetPath === path.join(rootPath, 'nested', '.git')) {
+ throw createEnoentError(targetPath)
+ }
- await expect(initLocalRepo(database, mockGitAuthService, absolutePath)).rejects.toThrow("A repository named 'existing-repo' already exists in the workspace")
- })
+ if (targetPath === path.join(rootPath, 'nested', 'app-two', '.git')) {
+ return createFileStat()
+ }
- it('throws error when absolute path is not a git repo', async () => {
- const { initLocalRepo } = await import('../../src/services/repo')
- const database = {} as any
- const nonGitPath = '/Users/test/not-a-repo'
+ if (targetPath === path.join(getReposPath(), 'app-one')) {
+ throw createEnoentError(targetPath)
+ }
- let callCount = 0
- executeCommand.mockImplementation(async () => {
- callCount++
- if (callCount === 2) throw new Error('Not a git repo')
- return ''
+ throw createEnoentError(targetPath)
})
- await expect(initLocalRepo(database, mockGitAuthService, nonGitPath)).rejects.toThrow("Directory exists but is not a valid Git repository")
- })
+ readdir.mockImplementation(async (targetPath: string) => {
+ if (targetPath === rootPath) {
+ return [createDirent('app-one'), createDirent('nested')]
+ }
- it('creates new empty repo with custom branch', async () => {
- const { initLocalRepo } = await import('../../src/services/repo')
- const database = {} as any
- const localPath = 'custom-branch-repo'
- const branch = 'develop'
+ if (targetPath === path.join(rootPath, 'nested')) {
+ return [createDirent('app-two')]
+ }
- getRepoByLocalPath.mockReturnValue(null)
- createRepo.mockReturnValue({
- id: 3,
- repoUrl: undefined,
- localPath: 'custom-branch-repo',
- branch: 'develop',
- defaultBranch: 'develop',
- cloneStatus: 'cloning',
- clonedAt: Date.now(),
- isLocal: true,
+ return []
})
- const result = await initLocalRepo(database, mockGitAuthService, localPath, branch)
+ const result = await discoverLocalRepos(database, mockGitAuthService, rootPath)
- expect(executeCommand).toHaveBeenCalledWith(['git', 'init'], expect.any(Object))
- expect(executeCommand).toHaveBeenCalledWith(['git', '-C', expect.any(String), 'checkout', '-b', 'develop'])
- expect(result.defaultBranch).toBe('develop')
+ expect(result.discoveredCount).toBe(1)
+ expect(result.existingCount).toBe(1)
+ expect(result.errors).toEqual([])
+ expect(result.repos).toHaveLength(2)
+ expect(result.repos.map((repo) => repo.fullPath)).toContain('/Users/test/projects/app-one')
+ expect(result.repos.map((repo) => repo.fullPath)).toContain(existingRepo.fullPath)
+ expect(symlink).toHaveBeenCalledTimes(1)
})
- it('normalizes trailing slashes in path', async () => {
- const { initLocalRepo } = await import('../../src/services/repo')
- const database = {} as any
- const localPath = 'my-repo/'
+ it('continues discovery when a nested directory cannot be read', async () => {
+ const { discoverLocalRepos } = await import('../../src/services/repo')
+ const database = {} as never
+ const rootPath = '/Users/test/projects'
getRepoByLocalPath.mockReturnValue(null)
- createRepo.mockReturnValue({
+ getRepoBySourcePath.mockReturnValue(null)
+ createRepo.mockImplementation((_, input) => ({
id: 4,
- repoUrl: undefined,
- localPath: 'my-repo',
- defaultBranch: 'main',
- cloneStatus: 'cloning',
- clonedAt: Date.now(),
+ localPath: input.localPath,
+ sourcePath: input.sourcePath,
+ fullPath: input.sourcePath ?? path.join(getReposPath(), input.localPath),
+ branch: input.branch,
+ defaultBranch: input.defaultBranch,
+ cloneStatus: input.cloneStatus,
+ clonedAt: input.clonedAt,
isLocal: true,
+ isWorktree: input.isWorktree,
+ }))
+
+ lstat.mockImplementation(async (targetPath: string) => {
+ if (targetPath === rootPath || targetPath === path.join(rootPath, 'app-one') || targetPath === path.join(rootPath, 'restricted')) {
+ return createDirectoryStat()
+ }
+
+ if (targetPath === path.join(rootPath, '.git')) {
+ throw createEnoentError(targetPath)
+ }
+
+ if (targetPath === path.join(rootPath, 'app-one', '.git')) {
+ return createDirectoryStat()
+ }
+
+ if (targetPath === path.join(rootPath, 'restricted', '.git')) {
+ throw createEnoentError(targetPath)
+ }
+
+ if (targetPath === path.join(getReposPath(), 'app-one')) {
+ throw createEnoentError(targetPath)
+ }
+
+ throw createEnoentError(targetPath)
})
- const result = await initLocalRepo(database, mockGitAuthService, localPath)
+ readdir.mockImplementation(async (targetPath: string) => {
+ if (targetPath === rootPath) {
+ return [createDirent('app-one'), createDirent('restricted')]
+ }
+
+ if (targetPath === path.join(rootPath, 'restricted')) {
+ throw new Error('EACCES: permission denied')
+ }
- expect(result.localPath).toBe('my-repo')
+ return []
+ })
+
+ const result = await discoverLocalRepos(database, mockGitAuthService, rootPath)
+
+ expect(result.discoveredCount).toBe(1)
+ expect(result.existingCount).toBe(0)
+ expect(result.repos).toHaveLength(1)
+ expect(result.repos[0]?.fullPath).toBe('/Users/test/projects/app-one')
+ expect(result.errors).toEqual([
+ {
+ path: '/Users/test/projects/restricted',
+ error: 'EACCES: permission denied',
+ },
+ ])
})
})
diff --git a/docs/configuration/docker.md b/docs/configuration/docker.md
index 1cd204b6..191d888d 100644
--- a/docs/configuration/docker.md
+++ b/docs/configuration/docker.md
@@ -203,6 +203,40 @@ Contains:
Uses a named volume for data persistence.
+### Import Existing OpenCode Chats From Your Host
+
+If you already use standalone OpenCode on your machine and want Dockerized OpenCode Manager to show those chats on first setup, bind your host OpenCode config/state into the container and bind your repo root to the same absolute path that standalone OpenCode used.
+
+Add to `.env`:
+
+```bash
+OCM_REPOS_HOST_PATH=/Users/you/Development
+OCM_OPENCODE_CONFIG_HOST_PATH=/Users/you/.config/opencode
+OCM_OPENCODE_STATE_HOST_PATH=/Users/you/.local/share/opencode
+```
+
+Then add a compose override:
+
+```yaml
+services:
+ app:
+ environment:
+ - OPENCODE_IMPORT_CONFIG_PATH=/import/opencode-config/opencode.json
+ - OPENCODE_IMPORT_STATE_PATH=/import/opencode-state
+ volumes:
+ - ${OCM_REPOS_HOST_PATH}:${OCM_REPOS_HOST_PATH}:ro
+ - ${OCM_OPENCODE_CONFIG_HOST_PATH}:/import/opencode-config:ro
+ - ${OCM_OPENCODE_STATE_HOST_PATH}:/import/opencode-state:ro
+```
+
+Why the repo mount uses the host path as the container path:
+
+- standalone OpenCode stores chats against absolute directory paths
+- mounting `${OCM_REPOS_HOST_PATH}` to the same path inside the container preserves those paths exactly
+- OpenCode Manager can then discover that folder and create its normal workspace links under `/workspace/repos`
+
+With a fresh Docker volume, first startup imports the host OpenCode config and state, and after you add `${OCM_REPOS_HOST_PATH}` in the Manager UI, previously existing chats appear under the discovered repositories.
+
## Health Checks
The container includes health checks:
diff --git a/docs/features/git.md b/docs/features/git.md
index 770aaed6..d42c7738 100644
--- a/docs/features/git.md
+++ b/docs/features/git.md
@@ -11,6 +11,18 @@ Clone any git repository:
3. Paste the repository URL
4. Click **Clone**
+## Discovering Existing Repositories
+
+Import repositories you already have on disk without recloning them:
+
+1. Click the **Repositories** button in the sidebar
+2. Click **Add Repository**
+3. Select **Folder Discovery**
+4. Enter a parent folder such as `/Users/you/Development`
+5. Click **Discover Repositories**
+
+OpenCode Manager scans that folder for nested git repositories and links each one into the workspace. If standalone OpenCode already has chats stored for those same paths, the sessions show up automatically.
+
### Private Repositories
For private repositories, configure a GitHub Personal Access Token:
@@ -119,4 +131,3 @@ The UI shows your branch's relationship to its remote:
- **↑ N** - You have N commits not pushed to remote
- **↓ N** - Remote has N commits you haven't pulled
- **↑ N ↓ M** - Both local and remote have diverged
-
diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md
index 05a23210..0326b748 100644
--- a/docs/getting-started/quickstart.md
+++ b/docs/getting-started/quickstart.md
@@ -50,19 +50,34 @@ Before chatting, you need to configure at least one AI provider:
4. Enter your **API key** or click **Add OAuth** for supported providers
5. Click **Save**
-## 4. Clone a Repository
+## 4. Add Repositories
+
+Choose the onboarding flow that matches your setup:
+
+### Clone a Remote Repository
+
+1. Click the **folder icon** in the sidebar
+2. Click **Add Repository**
+3. Select **Remote Repository**
+4. Paste a repository URL (HTTPS or SSH)
+5. Click **Add Repository**
+
+### Discover Existing Local Repositories
1. Click the **folder icon** in the sidebar
-2. Click **Clone Repository**
-3. Paste a repository URL (HTTPS or SSH)
-4. Click **Clone**
+2. Click **Add Repository**
+3. Select **Folder Discovery**
+4. Enter a parent folder such as `/Users/you/Development`
+5. Click **Discover Repositories**
+
+If you already used standalone OpenCode in those repositories, existing chats appear as soon as the discovered repo path matches the original OpenCode path.
!!! note "Private Repositories"
For private repos, configure a GitHub Personal Access Token in Settings > Credentials first.
## 5. Start Chatting
-1. Select your cloned repository from the sidebar
+1. Select your repository from the sidebar
2. Click **New Session** or type `/new`
3. Type your message
4. Press **Enter** to send
diff --git a/docs/images/session-discovery/01-folder-discovery-dialog.png b/docs/images/session-discovery/01-folder-discovery-dialog.png
new file mode 100644
index 00000000..0866cd49
Binary files /dev/null and b/docs/images/session-discovery/01-folder-discovery-dialog.png differ
diff --git a/docs/images/session-discovery/02-discovered-repositories.png b/docs/images/session-discovery/02-discovered-repositories.png
new file mode 100644
index 00000000..51e346a0
Binary files /dev/null and b/docs/images/session-discovery/02-discovered-repositories.png differ
diff --git a/docs/images/session-discovery/03-imported-opencode-sessions.png b/docs/images/session-discovery/03-imported-opencode-sessions.png
new file mode 100644
index 00000000..e1f9e3bf
Binary files /dev/null and b/docs/images/session-discovery/03-imported-opencode-sessions.png differ
diff --git a/docs/index.md b/docs/index.md
index 74902e53..ae288cbd 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -19,7 +19,7 @@ Open [http://localhost:5003](http://localhost:5003) and create your admin accoun
OpenCode Manager provides a web-based interface for OpenCode AI agents, allowing you to:
-- **Manage repositories** - Clone, browse, and work with multiple git repos
+- **Manage repositories** - Clone repos or discover existing local repos from a parent folder
- **Chat with AI** - Real-time streaming chat with file mentions and slash commands
- **View diffs** - See code changes with syntax highlighting
- **Control from anywhere** - Mobile-first PWA with push notifications
@@ -27,7 +27,7 @@ OpenCode Manager provides a web-based interface for OpenCode AI agents, allowing
## Key Features
-- **Multi-Repository Support** - Clone and manage multiple git repos with private repo support
+- **Multi-Repository Support** - Clone repos, discover local repo folders, and reconnect existing OpenCode chats
- **Git Integration** - View diffs, manage branches, create PRs directly from the UI
- **Real-time Chat** - Stream responses with file mentions and custom slash commands
- **Mobile-First PWA** - Install as an app on any device with push notifications
diff --git a/frontend/src/api/repos.ts b/frontend/src/api/repos.ts
index f6df024c..df7f5f43 100644
--- a/frontend/src/api/repos.ts
+++ b/frontend/src/api/repos.ts
@@ -1,6 +1,7 @@
import type { Repo } from './types'
import { FetchError, fetchWrapper, fetchWrapperVoid, fetchWrapperBlob } from './fetchWrapper'
import { API_BASE_URL } from '@/config'
+import type { DiscoverReposResponse } from '@opencode-manager/shared/types'
export async function createRepo(
repoUrl?: string,
@@ -21,6 +22,14 @@ export async function listRepos(): Promise {
return fetchWrapper(`${API_BASE_URL}/api/repos`)
}
+export async function discoverRepos(rootPath: string, maxDepth?: number): Promise {
+ return fetchWrapper(`${API_BASE_URL}/api/repos/discover`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ rootPath, maxDepth }),
+ })
+}
+
export async function getRepo(id: number): Promise {
return fetchWrapper(`${API_BASE_URL}/api/repos/${id}`)
}
diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts
index 2a0f2cab..02e5c85e 100644
--- a/frontend/src/api/types.ts
+++ b/frontend/src/api/types.ts
@@ -1,8 +1,9 @@
export interface Repo {
id: number
- repoUrl: string
+ repoUrl?: string
localPath: string
fullPath: string
+ sourcePath?: string
branch?: string
currentBranch?: string
defaultBranch: string
@@ -11,6 +12,7 @@ export interface Repo {
lastPulled?: number
openCodeConfigName?: string
isWorktree?: boolean
+ isLocal?: boolean
}
import type { components } from './opencode-types'
diff --git a/frontend/src/components/repo/AddRepoDialog.tsx b/frontend/src/components/repo/AddRepoDialog.tsx
index 64b4cc63..0c8a11a8 100644
--- a/frontend/src/components/repo/AddRepoDialog.tsx
+++ b/frontend/src/components/repo/AddRepoDialog.tsx
@@ -1,10 +1,13 @@
import { useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
-import { createRepo } from '@/api/repos'
+import { createRepo, discoverRepos } from '@/api/repos'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Loader2 } from 'lucide-react'
+import { showToast } from '@/lib/toast'
+import type { DiscoverReposResponse } from '@opencode-manager/shared/types'
+import type { Repo } from '@/api/types'
interface AddRepoDialogProps {
open: boolean
@@ -12,9 +15,10 @@ interface AddRepoDialogProps {
}
export function AddRepoDialog({ open, onOpenChange }: AddRepoDialogProps) {
- const [repoType, setRepoType] = useState<'remote' | 'local'>('remote')
+ const [repoType, setRepoType] = useState<'remote' | 'local' | 'folder'>('remote')
const [repoUrl, setRepoUrl] = useState('')
const [localPath, setLocalPath] = useState('')
+ const [folderPath, setFolderPath] = useState('')
const [branch, setBranch] = useState('')
const [skipSSHVerification, setSkipSSHVerification] = useState(false)
const queryClient = useQueryClient()
@@ -25,29 +29,63 @@ export function AddRepoDialog({ open, onOpenChange }: AddRepoDialogProps) {
const showSkipSSHCheckbox = repoType === 'remote' && isSSHUrl(repoUrl)
+ type AddRepoResult =
+ | { mode: 'single'; repo: Repo }
+ | ({ mode: 'discover' } & DiscoverReposResponse)
+
const mutation = useMutation({
- mutationFn: () => {
+ mutationFn: async (): Promise => {
if (repoType === 'local') {
- return createRepo(undefined, localPath, branch || undefined, undefined, false)
- } else {
- return createRepo(repoUrl, undefined, branch || undefined, undefined, false, skipSSHVerification)
+ const repo = await createRepo(undefined, localPath, branch || undefined, undefined, false)
+ return { mode: 'single', repo }
+ }
+
+ if (repoType === 'folder') {
+ const result = await discoverRepos(folderPath)
+ return { mode: 'discover', ...result }
}
+
+ const repo = await createRepo(repoUrl, undefined, branch || undefined, undefined, false, skipSSHVerification)
+ return { mode: 'single', repo }
},
- onSuccess: () => {
+ onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['repos'] })
queryClient.invalidateQueries({ queryKey: ['reposGitStatus'] })
setRepoUrl('')
setLocalPath('')
+ setFolderPath('')
setBranch('')
setRepoType('remote')
setSkipSSHVerification(false)
+
+ if (result.mode === 'discover') {
+ const summary = [
+ result.discoveredCount > 0 ? `${result.discoveredCount} new` : null,
+ result.existingCount > 0 ? `${result.existingCount} existing` : null,
+ ].filter(Boolean).join(', ')
+
+ if (result.errors.length > 0) {
+ showToast.warning('Repository discovery completed with issues', {
+ description: `${summary || 'No repos imported'}. ${result.errors[0]?.error || 'Some folders could not be imported.'}`,
+ })
+ } else if (result.discoveredCount === 0 && result.existingCount === 0) {
+ showToast.info('No Git repositories found in that folder')
+ } else {
+ showToast.success('Repository discovery complete', {
+ description: summary,
+ })
+ }
+ } else {
+ showToast.success('Repository added')
+ }
+
onOpenChange(false)
},
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
- if ((repoType === 'remote' && repoUrl) || (repoType === 'local' && localPath)) {
+ if ((repoType === 'remote' && repoUrl) || (repoType === 'local' && localPath) || (repoType === 'folder' && folderPath)) {
mutation.mutate()
}
}
@@ -95,6 +133,18 @@ export function AddRepoDialog({ open, onOpenChange }: AddRepoDialogProps) {
/>
Local Repository
+
@@ -112,7 +162,7 @@ export function AddRepoDialog({ open, onOpenChange }: AddRepoDialogProps) {
Full URL or shorthand format (owner/repo for GitHub)
- ) : (
+ ) : repoType === 'local' ? (
- Directory name for new repo, OR absolute path to existing Git repo (will be copied to workspace)
+ Directory name for a new repo, or an absolute path to link an existing Git repo in place so its OpenCode sessions stay attached
+
- {branch
+ {repoType === 'folder'
+ ? 'Folder discovery links each repository on its current branch'
+ : branch
? repoType === 'remote'
? `Clones repository directly to '${branch}' branch`
: localPath?.startsWith('/')
- ? `Copies repo and checks out '${branch}' branch (creates if needed)`
+ ? `Links the repo in place and checks out '${branch}' branch`
: `Initializes repository with '${branch}' branch`
: repoType === 'remote'
? "Clones repository to default branch"
: localPath?.startsWith('/')
- ? "Copies repo and checks out current branch"
+ ? 'Links the repo in place and keeps its current branch'
: "Initializes repository with 'main' branch"
}