core: remove dependency on remeda to simplify dependencies

This commit is contained in:
Dax Raad 2026-01-26 11:11:09 -05:00
commit acdcf7fa88
2 changed files with 127 additions and 107 deletions

View file

@ -1,5 +1,4 @@
import z from "zod" import z from "zod"
import fs from "fs/promises"
import { Filesystem } from "../util/filesystem" import { Filesystem } from "../util/filesystem"
import path from "path" import path from "path"
import { $ } from "bun" import { $ } from "bun"
@ -13,7 +12,6 @@ import { fn } from "@opencode-ai/util/fn"
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import { iife } from "@/util/iife" import { iife } from "@/util/iife"
import { GlobalBus } from "@/bus/global" import { GlobalBus } from "@/bus/global"
import { existsSync } from "fs"
export namespace Project { export namespace Project {
const log = Log.create({ service: "project" }) const log = Log.create({ service: "project" })
@ -61,7 +59,7 @@ export namespace Project {
return { return {
id: row.id, id: row.id,
worktree: row.worktree, worktree: row.worktree,
vcs: row.vcs as Info["vcs"], vcs: row.vcs ? Info.shape.vcs.parse(row.vcs) : undefined,
name: row.name ?? undefined, name: row.name ?? undefined,
icon, icon,
time: { time: {
@ -76,63 +74,60 @@ export namespace Project {
export async function fromDirectory(directory: string) { export async function fromDirectory(directory: string) {
log.info("fromDirectory", { directory }) log.info("fromDirectory", { directory })
const { id, sandbox, worktree, vcs } = await iife(async () => { const data = await iife(async () => {
const matches = Filesystem.up({ targets: [".git"], start: directory }) const matches = Filesystem.up({ targets: [".git"], start: directory })
const git = await matches.next().then((x) => x.value) const git = await matches.next().then((x) => x.value)
await matches.return() await matches.return()
if (git) { if (git) {
let sandbox = path.dirname(git) const sandbox = path.dirname(git)
const bin = Bun.which("git")
const gitBinary = Bun.which("git") const cached = await Bun.file(path.join(git, "opencode"))
// cached id calculation
let id = await Bun.file(path.join(git, "opencode"))
.text() .text()
.then((x) => x.trim()) .then((x) => x.trim())
.catch(() => undefined) .catch(() => undefined)
if (!gitBinary) { if (!bin) {
return { return {
id: id ?? "global", id: cached ?? "global",
worktree: sandbox, worktree: sandbox,
sandbox: sandbox, sandbox: sandbox,
vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS), vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS),
} }
} }
// generate id from root commit const roots = cached
if (!id) { ? undefined
const roots = await $`git rev-list --max-parents=0 --all` : await $`git rev-list --max-parents=0 --all`
.quiet() .quiet()
.nothrow() .nothrow()
.cwd(sandbox) .cwd(sandbox)
.text() .text()
.then((x) => .then((x) =>
x x
.split("\n") .split("\n")
.filter(Boolean) .filter(Boolean)
.map((x) => x.trim()) .map((x) => x.trim())
.toSorted(), .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) {
void Bun.file(path.join(git, "opencode"))
.write(id)
.catch(() => undefined) .catch(() => undefined)
if (!cached && !roots) {
return {
id: "global",
worktree: sandbox,
sandbox: sandbox,
vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS),
} }
} }
const id = cached ?? roots?.[0]
if (!cached && id) {
void Bun.file(path.join(git, "opencode"))
.write(id)
.catch(() => undefined)
}
if (!id) { if (!id) {
return { return {
id: "global", id: "global",
@ -159,33 +154,31 @@ export namespace Project {
} }
} }
sandbox = top const tree = await $`git rev-parse --git-common-dir`
const worktree = await $`git rev-parse --git-common-dir`
.quiet() .quiet()
.nothrow() .nothrow()
.cwd(sandbox) .cwd(top)
.text() .text()
.then((x) => { .then((x) => {
const dirname = path.dirname(x.trim()) const dirname = path.dirname(x.trim())
if (dirname === ".") return sandbox if (dirname === ".") return top
return dirname return dirname
}) })
.catch(() => undefined) .catch(() => undefined)
if (!worktree) { if (!tree) {
return { return {
id, id,
sandbox, sandbox: top,
worktree: sandbox, worktree: top,
vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS), vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS),
} }
} }
return { return {
id, id,
sandbox, sandbox: top,
worktree, worktree: tree,
vcs: "git", vcs: "git",
} }
} }
@ -198,21 +191,21 @@ export namespace Project {
} }
}) })
const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get()) const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, data.id)).get())
const existing = await iife(async () => { const existing = await iife(async () => {
if (row) return fromRow(row) if (row) return fromRow(row)
const fresh: Info = { const fresh: Info = {
id, id: data.id,
worktree, worktree: data.worktree,
vcs: vcs as Info["vcs"], vcs: data.vcs as Info["vcs"],
sandboxes: [], sandboxes: [],
time: { time: {
created: Date.now(), created: Date.now(),
updated: Date.now(), updated: Date.now(),
}, },
} }
if (id !== "global") { if (data.id !== "global") {
await migrateFromGlobal(id, worktree) await migrateFromGlobal(data.id, data.worktree)
} }
return fresh return fresh
}) })
@ -221,19 +214,27 @@ export namespace Project {
const result: Info = { const result: Info = {
...existing, ...existing,
worktree, worktree: data.worktree,
vcs: vcs as Info["vcs"], vcs: data.vcs as Info["vcs"],
time: { time: {
...existing.time, ...existing.time,
updated: Date.now(), updated: Date.now(),
}, },
} }
if (sandbox !== result.worktree && !result.sandboxes.includes(sandbox)) result.sandboxes.push(sandbox) if (data.sandbox !== result.worktree && !result.sandboxes.includes(data.sandbox))
result.sandboxes = result.sandboxes.filter((x) => existsSync(x)) result.sandboxes.push(data.sandbox)
const sandboxes: string[] = []
for (const x of result.sandboxes) {
const stat = await Bun.file(x)
.stat()
.catch(() => undefined)
if (stat) sandboxes.push(x)
}
result.sandboxes = sandboxes
const insert = { const insert = {
id: result.id, id: result.id,
worktree: result.worktree, worktree: result.worktree,
vcs: result.vcs, vcs: result.vcs ?? null,
name: result.name, name: result.name,
icon_url: result.icon?.url, icon_url: result.icon?.url,
icon_color: result.icon?.color, icon_color: result.icon?.color,
@ -244,7 +245,7 @@ export namespace Project {
} }
const updateSet = { const updateSet = {
worktree: result.worktree, worktree: result.worktree,
vcs: result.vcs, vcs: result.vcs ?? null,
name: result.name, name: result.name,
icon_url: result.icon?.url, icon_url: result.icon?.url,
icon_color: result.icon?.color, icon_color: result.icon?.color,
@ -261,7 +262,7 @@ export namespace Project {
properties: result, properties: result,
}, },
}) })
return { project: result, sandbox } return { project: result, sandbox: data.sandbox }
} }
export async function discover(input: Info) { export async function discover(input: Info) {
@ -294,38 +295,36 @@ export namespace Project {
return return
} }
async function migrateFromGlobal(newProjectID: string, worktree: string) { async function migrateFromGlobal(id: string, worktree: string) {
const globalRow = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, "global")).get()) const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, "global")).get())
if (!globalRow) return if (!row) return
const globalSessions = Database.use((db) => const sessions = Database.use((db) =>
db.select().from(SessionTable).where(eq(SessionTable.project_id, "global")).all(), db.select().from(SessionTable).where(eq(SessionTable.project_id, "global")).all(),
) )
if (globalSessions.length === 0) return if (sessions.length === 0) return
log.info("migrating sessions from global", { newProjectID, worktree, count: globalSessions.length }) log.info("migrating sessions from global", { newProjectID: id, worktree, count: sessions.length })
await work(10, globalSessions, async (row) => { await work(10, sessions, async (row) => {
// Skip sessions that belong to a different directory // Skip sessions that belong to a different directory
if (row.directory && row.directory !== worktree) return if (row.directory && row.directory !== worktree) return
log.info("migrating session", { sessionID: row.id, from: "global", to: newProjectID }) log.info("migrating session", { sessionID: row.id, from: "global", to: id })
Database.use((db) => Database.use((db) => db.update(SessionTable).set({ project_id: id }).where(eq(SessionTable.id, row.id)).run())
db.update(SessionTable).set({ project_id: newProjectID }).where(eq(SessionTable.id, row.id)).run(),
)
}).catch((error) => { }).catch((error) => {
log.error("failed to migrate sessions from global to project", { error, projectId: newProjectID }) log.error("failed to migrate sessions from global to project", { error, projectId: id })
}) })
} }
export function setInitialized(projectID: string) { export function setInitialized(id: string) {
Database.use((db) => Database.use((db) =>
db db
.update(ProjectTable) .update(ProjectTable)
.set({ .set({
time_initialized: Date.now(), time_initialized: Date.now(),
}) })
.where(eq(ProjectTable.id, projectID)) .where(eq(ProjectTable.id, id))
.run(), .run(),
) )
} }
@ -340,8 +339,8 @@ export namespace Project {
) )
} }
export function get(projectID: string): Info | undefined { export function get(id: string): Info | undefined {
const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, projectID)).get()) const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
if (!row) return undefined if (!row) return undefined
return fromRow(row) return fromRow(row)
} }
@ -379,32 +378,34 @@ export namespace Project {
}, },
) )
export async function sandboxes(projectID: string) { export async function sandboxes(id: string) {
const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, projectID)).get()) const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
if (!row) return [] if (!row) return []
const data = fromRow(row) const data = fromRow(row)
const valid: string[] = [] const valid: string[] = []
for (const dir of data.sandboxes) { for (const dir of data.sandboxes) {
const stat = await fs.stat(dir).catch(() => undefined) const stat = await Bun.file(dir)
.stat()
.catch(() => undefined)
if (stat?.isDirectory()) valid.push(dir) if (stat?.isDirectory()) valid.push(dir)
} }
return valid return valid
} }
export async function addSandbox(projectID: string, directory: string) { export async function addSandbox(id: string, directory: string) {
const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, projectID)).get()) const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
if (!row) throw new Error(`Project not found: ${projectID}`) if (!row) throw new Error(`Project not found: ${id}`)
const sandboxes = [...row.sandboxes] const sandboxes = [...row.sandboxes]
if (!sandboxes.includes(directory)) sandboxes.push(directory) if (!sandboxes.includes(directory)) sandboxes.push(directory)
const result = Database.use((db) => const result = Database.use((db) =>
db db
.update(ProjectTable) .update(ProjectTable)
.set({ sandboxes, time_updated: Date.now() }) .set({ sandboxes, time_updated: Date.now() })
.where(eq(ProjectTable.id, projectID)) .where(eq(ProjectTable.id, id))
.returning() .returning()
.get(), .get(),
) )
if (!result) throw new Error(`Project not found: ${projectID}`) if (!result) throw new Error(`Project not found: ${id}`)
const data = fromRow(result) const data = fromRow(result)
GlobalBus.emit("event", { GlobalBus.emit("event", {
payload: { payload: {
@ -415,19 +416,19 @@ export namespace Project {
return data return data
} }
export async function removeSandbox(projectID: string, directory: string) { export async function removeSandbox(id: string, directory: string) {
const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, projectID)).get()) const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
if (!row) throw new Error(`Project not found: ${projectID}`) if (!row) throw new Error(`Project not found: ${id}`)
const sandboxes = row.sandboxes.filter((s: string) => s !== directory) const sandboxes = row.sandboxes.filter((s) => s !== directory)
const result = Database.use((db) => const result = Database.use((db) =>
db db
.update(ProjectTable) .update(ProjectTable)
.set({ sandboxes, time_updated: Date.now() }) .set({ sandboxes, time_updated: Date.now() })
.where(eq(ProjectTable.id, projectID)) .where(eq(ProjectTable.id, id))
.returning() .returning()
.get(), .get(),
) )
if (!result) throw new Error(`Project not found: ${projectID}`) if (!result) throw new Error(`Project not found: ${id}`)
const data = fromRow(result) const data = fromRow(result)
GlobalBus.emit("event", { GlobalBus.emit("event", {
payload: { payload: {

View file

@ -4,7 +4,6 @@ import { Snapshot } from "../snapshot"
import { MessageV2 } from "./message-v2" import { MessageV2 } from "./message-v2"
import { Session } from "." import { Session } from "."
import { Log } from "../util/log" import { Log } from "../util/log"
import { splitWhen } from "remeda"
import { Database, eq } from "../storage/db" import { Database, eq } from "../storage/db"
import { SessionDiffTable, MessageTable, PartTable } from "./session.sql" import { SessionDiffTable, MessageTable, PartTable } from "./session.sql"
import { Bus } from "../bus" import { Bus } from "../bus"
@ -97,26 +96,46 @@ export namespace SessionRevert {
export async function cleanup(session: Session.Info) { export async function cleanup(session: Session.Info) {
if (!session.revert) return if (!session.revert) return
const sessionID = session.id const sessionID = session.id
let msgs = await Session.messages({ sessionID }) const msgs = await Session.messages({ sessionID })
const messageID = session.revert.messageID const messageID = session.revert.messageID
const [preserve, remove] = splitWhen(msgs, (x) => x.info.id === messageID) const preserve = [] as MessageV2.WithParts[]
msgs = preserve const remove = [] as MessageV2.WithParts[]
let target: MessageV2.WithParts | undefined
for (const msg of msgs) {
if (msg.info.id < messageID) {
preserve.push(msg)
continue
}
if (msg.info.id > messageID) {
remove.push(msg)
continue
}
if (session.revert.partID) {
preserve.push(msg)
target = msg
continue
}
remove.push(msg)
}
for (const msg of remove) { for (const msg of remove) {
Database.use((db) => db.delete(MessageTable).where(eq(MessageTable.id, msg.info.id)).run()) Database.use((db) => db.delete(MessageTable).where(eq(MessageTable.id, msg.info.id)).run())
await Bus.publish(MessageV2.Event.Removed, { sessionID: sessionID, messageID: msg.info.id }) await Bus.publish(MessageV2.Event.Removed, { sessionID: sessionID, messageID: msg.info.id })
} }
const last = preserve.at(-1) if (session.revert.partID && target) {
if (session.revert.partID && last) {
const partID = session.revert.partID const partID = session.revert.partID
const [preserveParts, removeParts] = splitWhen(last.parts, (x) => x.id === partID) const removeStart = target.parts.findIndex((part) => part.id === partID)
last.parts = preserveParts if (removeStart >= 0) {
for (const part of removeParts) { const preserveParts = target.parts.slice(0, removeStart)
Database.use((db) => db.delete(PartTable).where(eq(PartTable.id, part.id)).run()) const removeParts = target.parts.slice(removeStart)
await Bus.publish(MessageV2.Event.PartRemoved, { target.parts = preserveParts
sessionID: sessionID, for (const part of removeParts) {
messageID: last.info.id, Database.use((db) => db.delete(PartTable).where(eq(PartTable.id, part.id)).run())
partID: part.id, await Bus.publish(MessageV2.Event.PartRemoved, {
}) sessionID: sessionID,
messageID: target.info.id,
partID: part.id,
})
}
} }
} }
await Session.clearRevert(sessionID) await Session.clearRevert(sessionID)