Skip to content
Open
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
3 changes: 3 additions & 0 deletions packages/opencode/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import path from "path"
import { Global } from "./global"
import { JsonMigration } from "./storage/json-migration"
import { Database } from "./storage/db"
import { Project } from "./project/project"

process.on("unhandledRejection", (e) => {
Log.Default.error("rejection", {
Expand Down Expand Up @@ -118,6 +119,8 @@ let cli = yargs(hideBin(process.argv))
}
process.stderr.write("Database migration complete." + EOL)
}

await Project.repairAll()
})
.usage("\n" + UI.logo())
.completion("completion", "generate shell completion script")
Expand Down
276 changes: 218 additions & 58 deletions packages/opencode/src/project/project.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import z from "zod"
import { Filesystem } from "../util/filesystem"
import path from "path"
import { Database, eq } from "../storage/db"
import { Database, eq, inArray, sql } from "../storage/db"
import { ProjectTable } from "./project.sql"
import { SessionTable } from "../session/session.sql"
import { PermissionTable, SessionTable } from "../session/session.sql"
import { Log } from "../util/log"
import { Flag } from "@/flag/flag"
import { work } from "../util/queue"
Expand All @@ -13,6 +13,7 @@ import { iife } from "@/util/iife"
import { GlobalBus } from "@/bus/global"
import { existsSync } from "fs"
import { git } from "../util/git"
import { EOL } from "os"
import { Glob } from "../util/glob"

export namespace Project {
Expand Down Expand Up @@ -66,6 +67,145 @@ export namespace Project {

type Row = typeof ProjectTable.$inferSelect

function canonical(worktree: string) {
const projects = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.worktree, worktree)).all())
if (projects.length === 0) return
if (projects.length === 1) return projects[0]

// Prefer git-backed projects when present. Worktrees/sandboxes are a git feature and
// the split-brain project ID issue only occurs for git repos.
const gitProjects = projects.filter((p) => p.vcs === "git")
const pool = gitProjects.length ? gitProjects : projects

return Database.use((db) =>
pool
.map((p) => ({
p,
sessions:
db
.select({ count: sql<number>`count(*)` })
.from(SessionTable)
.where(eq(SessionTable.project_id, p.id))
.get()?.count ?? 0,
}))
.toSorted((a, b) => b.sessions - a.sessions || a.p.time_created - b.p.time_created)[0]?.p,
)
}

function duplicateWorktrees() {
return Database.use((db) =>
db
.select({ worktree: ProjectTable.worktree })
.from(ProjectTable)
.groupBy(ProjectTable.worktree)
// Only repair git-backed duplicates. Sandboxes/worktrees are a git feature.
.having(sql`sum(case when ${ProjectTable.vcs} = 'git' then 1 else 0 end) > 1`)
.all()
.map((x) => x.worktree),
)
}

function cachePath(commonDir: string) {
return path.join(commonDir, "opencode")
}

function merge(existing: Info, dupes: Row[]) {
return {
name: existing.name ?? dupes.map((d) => d.name).find(Boolean) ?? undefined,
commands: existing.commands ?? dupes.map((d) => d.commands).find(Boolean) ?? undefined,
icon_url: existing.icon?.url ?? dupes.map((d) => d.icon_url).find(Boolean) ?? undefined,
icon_color: existing.icon?.color ?? dupes.map((d) => d.icon_color).find(Boolean) ?? undefined,
sandboxes: [...new Set([...existing.sandboxes, ...dupes.flatMap((d) => d.sandboxes)])],
}
}

async function commonDir(worktree: string) {
if (!Bun.which("git")) return
const common = await git(["rev-parse", "--git-common-dir"], { cwd: worktree })
.then((result) => result.text().trim())
.catch(() => undefined)
if (!common) return
return gitpath(worktree, common)
}

function writeCache(commonDir: string, id: string) {
void Bun.file(cachePath(commonDir))
.write(id)
.catch(() => undefined)
}

async function repairWorktree(worktree: string) {
const row = canonical(worktree)
if (!row) return
if (row.vcs !== "git") return

const projects = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.worktree, worktree)).all())
const gitProjects = projects.filter((p) => p.vcs === "git")
if (gitProjects.length <= 1) return
const dupes = gitProjects.filter((p) => p.id !== row.id)
if (dupes.length === 0) return

const meta = merge(fromRow(row), dupes)
Database.transaction((db) => {
const ids = dupes.map((d) => d.id)
const now = Date.now()

db.update(SessionTable)
.set({ project_id: row.id })
.where(inArray(SessionTable.project_id, ids))
.run()

const keep = db.select().from(PermissionTable).where(eq(PermissionTable.project_id, row.id)).get()
if (!keep) {
const first = db.select().from(PermissionTable).where(inArray(PermissionTable.project_id, ids)).get()
if (first) {
db.insert(PermissionTable)
.values({
project_id: row.id,
data: first.data,
time_created: now,
time_updated: now,
})
.run()
}
}
db.delete(PermissionTable)
.where(inArray(PermissionTable.project_id, ids))
.run()

db.update(ProjectTable)
.set({
name: meta.name,
icon_url: meta.icon_url,
icon_color: meta.icon_color,
commands: meta.commands,
sandboxes: meta.sandboxes,
time_updated: now,
})
.where(eq(ProjectTable.id, row.id))
.run()

db.delete(ProjectTable)
.where(inArray(ProjectTable.id, ids))
.run()
})

const common = await commonDir(worktree)
if (common) writeCache(common, row.id)
}

export async function repairAll() {
const worktrees = duplicateWorktrees()
if (worktrees.length === 0) return
process.stderr.write(`Found duplicate projects. Creating DB backup...${EOL}`)
const backup = await Database.backup("project-repair")
process.stderr.write(`DB backup created at: ${backup}${EOL}`)
for (const worktree of worktrees) await repairWorktree(worktree)
process.stderr.write(
`Duplicate project repair complete. To revert: stop opencode and restore ${backup} (and .bak-wal/.bak-shm if present).${EOL}`,
)
}

export function fromRow(row: Row): Info {
const icon =
row.icon_url || row.icon_color
Expand Down Expand Up @@ -99,8 +239,9 @@ export namespace Project {

const gitBinary = Bun.which("git")

// cached id calculation
let id = await Filesystem.readText(path.join(dotgit, "opencode"))
// cached id calculation (fallback for non-git environments)
let id = await Bun.file(path.join(dotgit, "opencode"))
.text()
.then((x) => x.trim())
.catch(() => undefined)

Expand All @@ -113,53 +254,20 @@ export namespace Project {
}
}

// generate id from root commit
if (!id) {
const roots = await git(["rev-list", "--max-parents=0", "--all"], {
cwd: sandbox,
})
.then(async (result) =>
(await result.text())
.split("\n")
.filter(Boolean)
.map((x) => x.trim())
.toSorted(),
)
.catch(() => undefined)

if (!roots) {
return {
id: "global",
worktree: sandbox,
sandbox: sandbox,
vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS),
}
}

id = roots[0]
if (id) {
await Filesystem.write(path.join(dotgit, "opencode"), id).catch(() => undefined)
}
}

if (!id) {
return {
id: "global",
worktree: sandbox,
sandbox: sandbox,
vcs: "git",
}
}

// Resolve the worktree root for this directory.
// NOTE: This must happen before computing a project ID. In worktrees, `.git` can be
// per-worktree and `git rev-list ...` can observe different refs depending on cwd.
// Normalizing to the top-level and using the git common dir keeps the computed ID
// stable across all worktrees for the same repo.
const top = await git(["rev-parse", "--show-toplevel"], {
cwd: sandbox,
})
.then(async (result) => gitpath(sandbox, await result.text()))
.then((result) => gitpath(sandbox, result.text()))
.catch(() => undefined)

if (!top) {
return {
id,
id: id ?? "global",
sandbox,
worktree: sandbox,
vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS),
Expand All @@ -168,30 +276,71 @@ export namespace Project {

sandbox = top

const worktree = await git(["rev-parse", "--git-common-dir"], {
// Resolve the git *common* dir so all worktrees share the same project ID cache.
const common = await git(["rev-parse", "--git-common-dir"], {
cwd: sandbox,
})
.then(async (result) => {
const common = gitpath(sandbox, await result.text())
// Avoid going to parent of sandbox when git-common-dir is empty.
return common === sandbox ? sandbox : path.dirname(common)
})
.then((result) => result.text().trim())
.catch(() => undefined)

if (!worktree) {
if (!common) {
return {
id,
id: id ?? "global",
sandbox,
worktree: sandbox,
vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS),
vcs: "git",
}
}

const commonDir = gitpath(sandbox, common)
const worktree = path.dirname(commonDir)
const cacheFile = cachePath(commonDir)

// NOTE: Cache the project ID in the git *common* dir. In git worktrees `.git` is
// per-worktree, so caching in `.git/opencode` can cause split-brain project IDs.
id =
(await Bun.file(cacheFile)
.text()
.then((x) => x.trim())
.catch(() => undefined)) ?? id

if (id) writeCache(commonDir, id)

if (!id) {
// Generate a stable ID seed for this repo. Using HEAD avoids `--all` ref differences
// across worktrees while still resolving the same root commit for the repo history.
const roots = await git(["rev-list", "--max-parents=0", "HEAD"], {
cwd: sandbox,
env: {
// Ensure the git command is evaluated against the common dir, not the worktree.
GIT_DIR: commonDir,
GIT_WORK_TREE: sandbox,
},
})
.then((result) => result.text().split("\n").filter(Boolean).map((x) => x.trim()).toSorted())
.catch(() => undefined)

id = roots?.[0]
if (id) {
writeCache(commonDir, id)
}
}

if (!id) {
return {
id: "global",
sandbox,
worktree,
vcs: "git",
}
}

return {
id,
sandbox,
worktree,
sandbox,
vcs: "git",
cache: cacheFile,
}
}

Expand All @@ -203,11 +352,22 @@ export namespace Project {
}
})

const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, data.id)).get())
// If the DB already has a project row for this worktree, reuse it to keep existing
// sessions/icons/permissions compatible.
const canonicalRow = data.id === "global" ? undefined : canonical(data.worktree)

const id = canonicalRow?.id ?? data.id
if (id !== data.id && data.cache) {
void Bun.file(data.cache)
.write(id)
.catch(() => undefined)
}

const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
const existing = await iife(async () => {
if (row) return fromRow(row)
const fresh: Info = {
id: data.id,
id,
worktree: data.worktree,
vcs: data.vcs as Info["vcs"],
sandboxes: [],
Expand All @@ -216,8 +376,8 @@ export namespace Project {
updated: Date.now(),
},
}
if (data.id !== "global") {
await migrateFromGlobal(data.id, data.worktree)
if (id !== "global") {
await migrateFromGlobal(id, data.worktree)
}
return fresh
})
Expand Down
13 changes: 13 additions & 0 deletions packages/opencode/src/storage/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,19 @@ const log = Log.create({ service: "db" })

export namespace Database {
export const Path = path.join(Global.Path.data, "opencode.db")

export async function backup(tag: string) {
const stamp = new Date().toISOString().replace(/[:.]/g, "")
const backup = path.join(Global.Path.data, `opencode.db.before-${tag}.${stamp}.bak`)

await Bun.write(backup, Bun.file(Path))
for (const ext of ["-wal", "-shm"]) {
const file = `${Path}${ext}`
if (Bun.file(file).size) await Bun.write(`${backup}${ext}`, Bun.file(file))
}

return backup
}
type Schema = typeof schema
export type Transaction = SQLiteTransaction<"sync", void, Schema>

Expand Down
Loading
Loading