effectify Project service (#18808)

This commit is contained in:
Kit Langton
2026-03-24 14:04:22 -04:00
committed by GitHub
parent 814a515a8a
commit 539b01f20f
4 changed files with 578 additions and 456 deletions
+1 -1
View File
@@ -173,6 +173,6 @@ Still open and likely worth migrating:
- [ ] `SessionPrompt` - [ ] `SessionPrompt`
- [ ] `SessionCompaction` - [ ] `SessionCompaction`
- [ ] `Provider` - [ ] `Provider`
- [ ] `Project` - [x] `Project`
- [ ] `LSP` - [ ] `LSP`
- [ ] `MCP` - [ ] `MCP`
+394 -334
View File
@@ -1,36 +1,23 @@
import z from "zod" import z from "zod"
import { Filesystem } from "../util/filesystem"
import path from "path"
import { and, Database, eq } from "../storage/db" import { and, Database, eq } from "../storage/db"
import { ProjectTable } from "./project.sql" import { ProjectTable } from "./project.sql"
import { SessionTable } from "../session/session.sql" import { SessionTable } from "../session/session.sql"
import { Log } from "../util/log" import { Log } from "../util/log"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
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 { GlobalBus } from "@/bus/global" import { GlobalBus } from "@/bus/global"
import { existsSync } from "fs"
import { git } from "../util/git"
import { Glob } from "../util/glob"
import { which } from "../util/which" import { which } from "../util/which"
import { ProjectID } from "./schema" import { ProjectID } from "./schema"
import { Effect, Layer, Path, Scope, ServiceMap, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { makeRunPromise } from "@/effect/run-service"
import { AppFileSystem } from "@/filesystem"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
export namespace Project { export namespace Project {
const log = Log.create({ service: "project" }) const log = Log.create({ service: "project" })
function gitpath(cwd: string, name: string) {
if (!name) return cwd
// git output includes trailing newlines; keep path whitespace intact.
name = name.replace(/[\r\n]+$/, "")
if (!name) return cwd
name = Filesystem.windowsPath(name)
if (path.isAbsolute(name)) return path.normalize(name)
return path.resolve(cwd, name)
}
export const Info = z export const Info = z
.object({ .object({
id: ProjectID.zod, id: ProjectID.zod,
@@ -73,7 +60,7 @@ export namespace Project {
? { url: row.icon_url ?? undefined, color: row.icon_color ?? undefined } ? { url: row.icon_url ?? undefined, color: row.icon_color ?? undefined }
: undefined : undefined
return { return {
id: ProjectID.make(row.id), id: row.id,
worktree: row.worktree, worktree: row.worktree,
vcs: row.vcs ? Info.shape.vcs.parse(row.vcs) : undefined, vcs: row.vcs ? Info.shape.vcs.parse(row.vcs) : undefined,
name: row.name ?? undefined, name: row.name ?? undefined,
@@ -88,245 +75,401 @@ export namespace Project {
} }
} }
function readCachedId(dir: string) { export const UpdateInput = z.object({
return Filesystem.readText(path.join(dir, "opencode")) projectID: ProjectID.zod,
.then((x) => x.trim()) name: z.string().optional(),
.then(ProjectID.make) icon: Info.shape.icon.optional(),
.catch(() => undefined) commands: Info.shape.commands.optional(),
})
export type UpdateInput = z.infer<typeof UpdateInput>
// ---------------------------------------------------------------------------
// Effect service
// ---------------------------------------------------------------------------
export interface Interface {
readonly fromDirectory: (directory: string) => Effect.Effect<{ project: Info; sandbox: string }>
readonly discover: (input: Info) => Effect.Effect<void>
readonly list: () => Effect.Effect<Info[]>
readonly get: (id: ProjectID) => Effect.Effect<Info | undefined>
readonly update: (input: UpdateInput) => Effect.Effect<Info>
readonly initGit: (input: { directory: string; project: Info }) => Effect.Effect<Info>
readonly setInitialized: (id: ProjectID) => Effect.Effect<void>
readonly sandboxes: (id: ProjectID) => Effect.Effect<string[]>
readonly addSandbox: (id: ProjectID, directory: string) => Effect.Effect<void>
readonly removeSandbox: (id: ProjectID, directory: string) => Effect.Effect<void>
} }
export async function fromDirectory(directory: string) { export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Project") {}
log.info("fromDirectory", { directory })
const data = await iife(async () => { type GitResult = { code: number; text: string; stderr: string }
const matches = Filesystem.up({ targets: [".git"], start: directory })
const dotgit = await matches.next().then((x) => x.value)
await matches.return()
if (dotgit) {
let sandbox = path.dirname(dotgit)
const gitBinary = which("git") export const layer: Layer.Layer<
Service,
never,
AppFileSystem.Service | Path.Path | ChildProcessSpawner.ChildProcessSpawner
> = Layer.effect(
Service,
Effect.gen(function* () {
const fsys = yield* AppFileSystem.Service
const pathSvc = yield* Path.Path
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
// cached id calculation const git = Effect.fnUntraced(
let id = await readCachedId(dotgit) function* (args: string[], opts?: { cwd?: string }) {
const handle = yield* spawner.spawn(
ChildProcess.make("git", args, { cwd: opts?.cwd, extendEnv: true, stdin: "ignore" }),
)
const [text, stderr] = yield* Effect.all(
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
{ concurrency: 2 },
)
const code = yield* handle.exitCode
return { code, text, stderr } satisfies GitResult
},
Effect.scoped,
Effect.catch(() => Effect.succeed({ code: 1, text: "", stderr: "" } satisfies GitResult)),
)
if (!gitBinary) { const db = <T>(fn: (d: Parameters<typeof Database.use>[0] extends (trx: infer D) => any ? D : never) => T) =>
return { Effect.sync(() => Database.use(fn))
id: id ?? ProjectID.global,
worktree: sandbox,
sandbox,
vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS),
}
}
const worktree = await git(["rev-parse", "--git-common-dir"], { const emitUpdated = (data: Info) =>
cwd: sandbox, Effect.sync(() =>
}) GlobalBus.emit("event", {
.then(async (result) => { payload: { type: Event.Updated.type, properties: data },
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)
})
.catch(() => undefined)
if (!worktree) { const fakeVcs = Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS)
return {
id: id ?? ProjectID.global,
worktree: sandbox,
sandbox,
vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS),
}
}
// In the case of a git worktree, it can't cache the id const resolveGitPath = (cwd: string, name: string) => {
// because `.git` is not a folder, but it always needs the if (!name) return cwd
// same project id as the common dir, so we resolve it now name = name.replace(/[\r\n]+$/, "")
if (id == null) { if (!name) return cwd
id = await readCachedId(path.join(worktree, ".git")) name = AppFileSystem.windowsPath(name)
} if (pathSvc.isAbsolute(name)) return pathSvc.normalize(name)
return pathSvc.resolve(cwd, name)
}
// generate id from root commit const scope = yield* Scope.Scope
if (!id) {
const roots = await git(["rev-list", "--max-parents=0", "HEAD"], {
cwd: sandbox,
})
.then(async (result) =>
(await result.text())
.split("\n")
.filter(Boolean)
.map((x) => x.trim())
.toSorted(),
)
.catch(() => undefined)
if (!roots) { const readCachedProjectId = Effect.fnUntraced(function* (dir: string) {
return yield* fsys.readFileString(pathSvc.join(dir, "opencode")).pipe(
Effect.map((x) => x.trim()),
Effect.map(ProjectID.make),
Effect.catch(() => Effect.succeed(undefined)),
)
})
const fromDirectory = Effect.fn("Project.fromDirectory")(function* (directory: string) {
log.info("fromDirectory", { directory })
// Phase 1: discover git info
type DiscoveryResult = { id: ProjectID; worktree: string; sandbox: string; vcs: Info["vcs"] }
const data: DiscoveryResult = yield* Effect.gen(function* () {
const dotgitMatches = yield* fsys.up({ targets: [".git"], start: directory }).pipe(Effect.orDie)
const dotgit = dotgitMatches[0]
if (!dotgit) {
return { return {
id: ProjectID.global, id: ProjectID.global,
worktree: sandbox, worktree: "/",
sandbox, sandbox: "/",
vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS), vcs: fakeVcs,
} }
} }
id = roots[0] ? ProjectID.make(roots[0]) : undefined let sandbox = pathSvc.dirname(dotgit)
if (id) { const gitBinary = yield* Effect.sync(() => which("git"))
// Write to common dir so the cache is shared across worktrees. let id = yield* readCachedProjectId(dotgit)
await Filesystem.write(path.join(worktree, ".git", "opencode"), id).catch(() => undefined)
}
}
if (!id) { if (!gitBinary) {
return { return {
id: ProjectID.global, id: id ?? ProjectID.global,
worktree: sandbox, worktree: sandbox,
sandbox, sandbox,
vcs: "git", vcs: fakeVcs,
}
} }
}
const top = await git(["rev-parse", "--show-toplevel"], { const commonDir = yield* git(["rev-parse", "--git-common-dir"], { cwd: sandbox })
cwd: sandbox, if (commonDir.code !== 0) {
return {
id: id ?? ProjectID.global,
worktree: sandbox,
sandbox,
vcs: fakeVcs,
}
}
const worktree = (() => {
const common = resolveGitPath(sandbox, commonDir.text.trim())
return common === sandbox ? sandbox : pathSvc.dirname(common)
})()
if (id == null) {
id = yield* readCachedProjectId(pathSvc.join(worktree, ".git"))
}
if (!id) {
const revList = yield* git(["rev-list", "--max-parents=0", "HEAD"], { cwd: sandbox })
const roots = revList.text
.split("\n")
.filter(Boolean)
.map((x) => x.trim())
.toSorted()
id = roots[0] ? ProjectID.make(roots[0]) : undefined
if (id) {
yield* fsys.writeFileString(pathSvc.join(worktree, ".git", "opencode"), id).pipe(Effect.ignore)
}
}
if (!id) {
return { id: ProjectID.global, worktree: sandbox, sandbox, vcs: "git" as const }
}
const topLevel = yield* git(["rev-parse", "--show-toplevel"], { cwd: sandbox })
if (topLevel.code !== 0) {
return {
id,
worktree: sandbox,
sandbox,
vcs: fakeVcs,
}
}
sandbox = resolveGitPath(sandbox, topLevel.text.trim())
return { id, sandbox, worktree, vcs: "git" as const }
}) })
.then(async (result) => gitpath(sandbox, await result.text()))
.catch(() => undefined)
if (!top) { // Phase 2: upsert
return { const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, data.id)).get())
id, const existing = row
worktree: sandbox, ? fromRow(row)
sandbox, : {
vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS), id: data.id,
} worktree: data.worktree,
} vcs: data.vcs,
sandboxes: [] as string[],
time: { created: Date.now(), updated: Date.now() },
}
sandbox = top if (Flag.OPENCODE_EXPERIMENTAL_ICON_DISCOVERY)
yield* discover(existing).pipe(Effect.ignore, Effect.forkIn(scope))
return { const result: Info = {
id, ...existing,
sandbox,
worktree,
vcs: "git",
}
}
return {
id: ProjectID.global,
worktree: "/",
sandbox: "/",
vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS),
}
})
const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, data.id)).get())
const existing = row
? fromRow(row)
: {
id: data.id,
worktree: data.worktree, worktree: data.worktree,
vcs: data.vcs as Info["vcs"], vcs: data.vcs,
sandboxes: [] as string[], time: { ...existing.time, updated: Date.now() },
time: { }
created: Date.now(), if (data.sandbox !== result.worktree && !result.sandboxes.includes(data.sandbox))
updated: Date.now(), result.sandboxes.push(data.sandbox)
}, result.sandboxes = yield* Effect.forEach(
result.sandboxes,
(s) =>
fsys.exists(s).pipe(
Effect.orDie,
Effect.map((exists) => (exists ? s : undefined)),
),
{ concurrency: "unbounded" },
).pipe(Effect.map((arr) => arr.filter((x): x is string => x !== undefined)))
yield* db((d) =>
d
.insert(ProjectTable)
.values({
id: result.id,
worktree: result.worktree,
vcs: result.vcs ?? null,
name: result.name,
icon_url: result.icon?.url,
icon_color: result.icon?.color,
time_created: result.time.created,
time_updated: result.time.updated,
time_initialized: result.time.initialized,
sandboxes: result.sandboxes,
commands: result.commands,
})
.onConflictDoUpdate({
target: ProjectTable.id,
set: {
worktree: result.worktree,
vcs: result.vcs ?? null,
name: result.name,
icon_url: result.icon?.url,
icon_color: result.icon?.color,
time_updated: result.time.updated,
time_initialized: result.time.initialized,
sandboxes: result.sandboxes,
commands: result.commands,
},
})
.run(),
)
if (data.id !== ProjectID.global) {
yield* db((d) =>
d
.update(SessionTable)
.set({ project_id: data.id })
.where(and(eq(SessionTable.project_id, ProjectID.global), eq(SessionTable.directory, data.worktree)))
.run(),
)
} }
if (Flag.OPENCODE_EXPERIMENTAL_ICON_DISCOVERY) discover(existing) yield* emitUpdated(result)
return { project: result, sandbox: data.sandbox }
})
const result: Info = { const discover = Effect.fn("Project.discover")(function* (input: Info) {
...existing, if (input.vcs !== "git") return
worktree: data.worktree, if (input.icon?.override) return
vcs: data.vcs as Info["vcs"], if (input.icon?.url) return
time: {
...existing.time, const matches = yield* fsys
updated: Date.now(), .glob("**/favicon.{ico,png,svg,jpg,jpeg,webp}", {
}, cwd: input.worktree,
} absolute: true,
if (data.sandbox !== result.worktree && !result.sandboxes.includes(data.sandbox)) include: "file",
result.sandboxes.push(data.sandbox) })
result.sandboxes = result.sandboxes.filter((x) => existsSync(x)) .pipe(Effect.orDie)
const insert = { const shortest = matches.sort((a, b) => a.length - b.length)[0]
id: result.id, if (!shortest) return
worktree: result.worktree,
vcs: result.vcs ?? null, const buffer = yield* fsys.readFile(shortest).pipe(Effect.orDie)
name: result.name, const base64 = Buffer.from(buffer).toString("base64")
icon_url: result.icon?.url, const mime = AppFileSystem.mimeType(shortest)
icon_color: result.icon?.color, const url = `data:${mime};base64,${base64}`
time_created: result.time.created, yield* update({ projectID: input.id, icon: { url } })
time_updated: result.time.updated, })
time_initialized: result.time.initialized,
sandboxes: result.sandboxes, const list = Effect.fn("Project.list")(function* () {
commands: result.commands, return yield* db((d) => d.select().from(ProjectTable).all().map(fromRow))
} })
const updateSet = {
worktree: result.worktree, const get = Effect.fn("Project.get")(function* (id: ProjectID) {
vcs: result.vcs ?? null, const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
name: result.name, return row ? fromRow(row) : undefined
icon_url: result.icon?.url, })
icon_color: result.icon?.color,
time_updated: result.time.updated, const update = Effect.fn("Project.update")(function* (input: UpdateInput) {
time_initialized: result.time.initialized, const result = yield* db((d) =>
sandboxes: result.sandboxes, d
commands: result.commands, .update(ProjectTable)
} .set({
Database.use((db) => name: input.name,
db.insert(ProjectTable).values(insert).onConflictDoUpdate({ target: ProjectTable.id, set: updateSet }).run(), icon_url: input.icon?.url,
) icon_color: input.icon?.color,
// Runs after upsert so the target project row exists (FK constraint). commands: input.commands,
// Runs on every startup because sessions created before git init time_updated: Date.now(),
// accumulate under "global" and need migrating whenever they appear. })
if (data.id !== ProjectID.global) { .where(eq(ProjectTable.id, input.projectID))
Database.use((db) => .returning()
db .get(),
.update(SessionTable) )
.set({ project_id: data.id }) if (!result) throw new Error(`Project not found: ${input.projectID}`)
.where(and(eq(SessionTable.project_id, ProjectID.global), eq(SessionTable.directory, data.worktree))) const data = fromRow(result)
.run(), yield* emitUpdated(data)
) return data
} })
GlobalBus.emit("event", {
payload: { const initGit = Effect.fn("Project.initGit")(function* (input: { directory: string; project: Info }) {
type: Event.Updated.type, if (input.project.vcs === "git") return input.project
properties: result, if (!(yield* Effect.sync(() => which("git")))) throw new Error("Git is not installed")
}, const result = yield* git(["init", "--quiet"], { cwd: input.directory })
}) if (result.code !== 0) {
return { project: result, sandbox: data.sandbox } throw new Error(result.stderr.trim() || result.text.trim() || "Failed to initialize git repository")
}
const { project } = yield* fromDirectory(input.directory)
return project
})
const setInitialized = Effect.fn("Project.setInitialized")(function* (id: ProjectID) {
yield* db((d) =>
d.update(ProjectTable).set({ time_initialized: Date.now() }).where(eq(ProjectTable.id, id)).run(),
)
})
const sandboxes = Effect.fn("Project.sandboxes")(function* (id: ProjectID) {
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
if (!row) return []
const data = fromRow(row)
return yield* Effect.forEach(
data.sandboxes,
(dir) => fsys.isDir(dir).pipe(Effect.orDie, Effect.map((ok) => (ok ? dir : undefined))),
{ concurrency: "unbounded" },
).pipe(Effect.map((arr) => arr.filter((x): x is string => x !== undefined)))
})
const addSandbox = Effect.fn("Project.addSandbox")(function* (id: ProjectID, directory: string) {
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
if (!row) throw new Error(`Project not found: ${id}`)
const sboxes = [...row.sandboxes]
if (!sboxes.includes(directory)) sboxes.push(directory)
const result = yield* db((d) =>
d
.update(ProjectTable)
.set({ sandboxes: sboxes, time_updated: Date.now() })
.where(eq(ProjectTable.id, id))
.returning()
.get(),
)
if (!result) throw new Error(`Project not found: ${id}`)
yield* emitUpdated(fromRow(result))
})
const removeSandbox = Effect.fn("Project.removeSandbox")(function* (id: ProjectID, directory: string) {
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
if (!row) throw new Error(`Project not found: ${id}`)
const sboxes = row.sandboxes.filter((s) => s !== directory)
const result = yield* db((d) =>
d
.update(ProjectTable)
.set({ sandboxes: sboxes, time_updated: Date.now() })
.where(eq(ProjectTable.id, id))
.returning()
.get(),
)
if (!result) throw new Error(`Project not found: ${id}`)
yield* emitUpdated(fromRow(result))
})
return Service.of({
fromDirectory,
discover,
list,
get,
update,
initGit,
setInitialized,
sandboxes,
addSandbox,
removeSandbox,
})
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(CrossSpawnSpawner.layer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(NodeFileSystem.layer),
Layer.provide(NodePath.layer),
)
const runPromise = makeRunPromise(Service, defaultLayer)
// ---------------------------------------------------------------------------
// Promise-based API (delegates to Effect service via runPromise)
// ---------------------------------------------------------------------------
export function fromDirectory(directory: string) {
return runPromise((svc) => svc.fromDirectory(directory))
} }
export async function discover(input: Info) { export function discover(input: Info) {
if (input.vcs !== "git") return return runPromise((svc) => svc.discover(input))
if (input.icon?.override) return
if (input.icon?.url) return
const matches = await Glob.scan("**/favicon.{ico,png,svg,jpg,jpeg,webp}", {
cwd: input.worktree,
absolute: true,
include: "file",
})
const shortest = matches.sort((a, b) => a.length - b.length)[0]
if (!shortest) return
const buffer = await Filesystem.readBytes(shortest)
const base64 = buffer.toString("base64")
const mime = Filesystem.mimeType(shortest) || "image/png"
const url = `data:${mime};base64,${base64}`
await update({
projectID: input.id,
icon: {
url,
},
})
return
}
export function setInitialized(id: ProjectID) {
Database.use((db) =>
db
.update(ProjectTable)
.set({
time_initialized: Date.now(),
})
.where(eq(ProjectTable.id, id))
.run(),
)
} }
export function list() { export function list() {
@@ -345,112 +488,29 @@ export namespace Project {
return fromRow(row) return fromRow(row)
} }
export async function initGit(input: { directory: string; project: Info }) { export function setInitialized(id: ProjectID) {
if (input.project.vcs === "git") return input.project Database.use((db) =>
if (!which("git")) throw new Error("Git is not installed") db.update(ProjectTable).set({ time_initialized: Date.now() }).where(eq(ProjectTable.id, id)).run(),
const result = await git(["init", "--quiet"], {
cwd: input.directory,
})
if (result.exitCode !== 0) {
const text = result.stderr.toString().trim() || result.text().trim()
throw new Error(text || "Failed to initialize git repository")
}
return (await fromDirectory(input.directory)).project
}
export const update = fn(
z.object({
projectID: ProjectID.zod,
name: z.string().optional(),
icon: Info.shape.icon.optional(),
commands: Info.shape.commands.optional(),
}),
async (input) => {
const id = ProjectID.make(input.projectID)
const result = Database.use((db) =>
db
.update(ProjectTable)
.set({
name: input.name,
icon_url: input.icon?.url,
icon_color: input.icon?.color,
commands: input.commands,
time_updated: Date.now(),
})
.where(eq(ProjectTable.id, id))
.returning()
.get(),
)
if (!result) throw new Error(`Project not found: ${input.projectID}`)
const data = fromRow(result)
GlobalBus.emit("event", {
payload: {
type: Event.Updated.type,
properties: data,
},
})
return data
},
)
export async function sandboxes(id: ProjectID) {
const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
if (!row) return []
const data = fromRow(row)
const valid: string[] = []
for (const dir of data.sandboxes) {
const s = Filesystem.stat(dir)
if (s?.isDirectory()) valid.push(dir)
}
return valid
}
export async function addSandbox(id: ProjectID, directory: string) {
const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
if (!row) throw new Error(`Project not found: ${id}`)
const sandboxes = [...row.sandboxes]
if (!sandboxes.includes(directory)) sandboxes.push(directory)
const result = Database.use((db) =>
db
.update(ProjectTable)
.set({ sandboxes, time_updated: Date.now() })
.where(eq(ProjectTable.id, id))
.returning()
.get(),
) )
if (!result) throw new Error(`Project not found: ${id}`)
const data = fromRow(result)
GlobalBus.emit("event", {
payload: {
type: Event.Updated.type,
properties: data,
},
})
return data
} }
export async function removeSandbox(id: ProjectID, directory: string) { export function initGit(input: { directory: string; project: Info }) {
const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get()) return runPromise((svc) => svc.initGit(input))
if (!row) throw new Error(`Project not found: ${id}`) }
const sandboxes = row.sandboxes.filter((s) => s !== directory)
const result = Database.use((db) => export function update(input: UpdateInput) {
db return runPromise((svc) => svc.update(input))
.update(ProjectTable) }
.set({ sandboxes, time_updated: Date.now() })
.where(eq(ProjectTable.id, id)) export function sandboxes(id: ProjectID) {
.returning() return runPromise((svc) => svc.sandboxes(id))
.get(), }
)
if (!result) throw new Error(`Project not found: ${id}`) export function addSandbox(id: ProjectID, directory: string) {
const data = fromRow(result) return runPromise((svc) => svc.addSandbox(id, directory))
GlobalBus.emit("event", { }
payload: {
type: Event.Updated.type, export function removeSandbox(id: ProjectID, directory: string) {
properties: data, return runPromise((svc) => svc.removeSandbox(id, directory))
},
})
return data
} }
} }
@@ -107,7 +107,7 @@ export const ProjectRoutes = lazy(() =>
}, },
}), }),
validator("param", z.object({ projectID: ProjectID.zod })), validator("param", z.object({ projectID: ProjectID.zod })),
validator("json", Project.update.schema.omit({ projectID: true })), validator("json", Project.UpdateInput.omit({ projectID: true })),
async (c) => { async (c) => {
const projectID = c.req.valid("param").projectID const projectID = c.req.valid("param").projectID
const body = c.req.valid("json") const body = c.req.valid("json")
+182 -120
View File
@@ -1,78 +1,69 @@
import { describe, expect, mock, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { Project } from "../../src/project/project" import { Project } from "../../src/project/project"
import { Log } from "../../src/util/log" import { Log } from "../../src/util/log"
import { $ } from "bun" import { $ } from "bun"
import path from "path" import path from "path"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
import { Filesystem } from "../../src/util/filesystem"
import { GlobalBus } from "../../src/bus/global" import { GlobalBus } from "../../src/bus/global"
import { ProjectID } from "../../src/project/schema" import { ProjectID } from "../../src/project/schema"
import { Effect, Layer, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { AppFileSystem } from "../../src/filesystem"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
Log.init({ print: false }) Log.init({ print: false })
const gitModule = await import("../../src/util/git") const encoder = new TextEncoder()
const originalGit = gitModule.git
type Mode = "none" | "rev-list-fail" | "top-fail" | "common-dir-fail" /**
let mode: Mode = "none" * Creates a mock ChildProcessSpawner layer that intercepts git subcommands
* matching `failArg` and returns exit code 128, while delegating everything
mock.module("../../src/util/git", () => ({ * else to the real CrossSpawnSpawner.
git: (args: string[], opts: { cwd: string; env?: Record<string, string> }) => { */
const cmd = ["git", ...args].join(" ") function mockGitFailure(failArg: string) {
if ( return Layer.effect(
mode === "rev-list-fail" && ChildProcessSpawner.ChildProcessSpawner,
cmd.includes("git rev-list") && Effect.gen(function* () {
cmd.includes("--max-parents=0") && const real = yield* ChildProcessSpawner.ChildProcessSpawner
cmd.includes("HEAD") return ChildProcessSpawner.make(
) { Effect.fnUntraced(function* (command) {
return Promise.resolve({ const std = ChildProcess.isStandardCommand(command) ? command : undefined
exitCode: 128, if (std?.command === "git" && std.args.some((a) => a === failArg)) {
text: () => Promise.resolve(""), return ChildProcessSpawner.makeHandle({
stdout: Buffer.from(""), pid: ChildProcessSpawner.ProcessId(0),
stderr: Buffer.from("fatal"), exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(128)),
}) isRunning: Effect.succeed(false),
} kill: () => Effect.void,
if (mode === "top-fail" && cmd.includes("git rev-parse") && cmd.includes("--show-toplevel")) { stdin: { [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") } as any,
return Promise.resolve({ stdout: Stream.empty,
exitCode: 128, stderr: Stream.make(encoder.encode("fatal: simulated failure\n")),
text: () => Promise.resolve(""), all: Stream.empty,
stdout: Buffer.from(""), getInputFd: () => ({ [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") }) as any,
stderr: Buffer.from("fatal"), getOutputFd: () => Stream.empty,
}) })
} }
if (mode === "common-dir-fail" && cmd.includes("git rev-parse") && cmd.includes("--git-common-dir")) { return yield* real.spawn(command)
return Promise.resolve({ }),
exitCode: 128, )
text: () => Promise.resolve(""), }),
stdout: Buffer.from(""), ).pipe(Layer.provide(CrossSpawnSpawner.layer), Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer))
stderr: Buffer.from("fatal"),
})
}
return originalGit(args, opts)
},
}))
async function withMode(next: Mode, run: () => Promise<void>) {
const prev = mode
mode = next
try {
await run()
} finally {
mode = prev
}
} }
async function loadProject() { function projectLayerWithFailure(failArg: string) {
return (await import("../../src/project/project")).Project return Project.layer.pipe(
Layer.provide(mockGitFailure(failArg)),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(NodePath.layer),
)
} }
describe("Project.fromDirectory", () => { describe("Project.fromDirectory", () => {
test("should handle git repository with no commits", async () => { test("should handle git repository with no commits", async () => {
const p = await loadProject()
await using tmp = await tmpdir() await using tmp = await tmpdir()
await $`git init`.cwd(tmp.path).quiet() await $`git init`.cwd(tmp.path).quiet()
const { project } = await p.fromDirectory(tmp.path) const { project } = await Project.fromDirectory(tmp.path)
expect(project).toBeDefined() expect(project).toBeDefined()
expect(project.id).toBe(ProjectID.global) expect(project.id).toBe(ProjectID.global)
@@ -80,15 +71,13 @@ describe("Project.fromDirectory", () => {
expect(project.worktree).toBe(tmp.path) expect(project.worktree).toBe(tmp.path)
const opencodeFile = path.join(tmp.path, ".git", "opencode") const opencodeFile = path.join(tmp.path, ".git", "opencode")
const fileExists = await Filesystem.exists(opencodeFile) expect(await Bun.file(opencodeFile).exists()).toBe(false)
expect(fileExists).toBe(false)
}) })
test("should handle git repository with commits", async () => { test("should handle git repository with commits", async () => {
const p = await loadProject()
await using tmp = await tmpdir({ git: true }) await using tmp = await tmpdir({ git: true })
const { project } = await p.fromDirectory(tmp.path) const { project } = await Project.fromDirectory(tmp.path)
expect(project).toBeDefined() expect(project).toBeDefined()
expect(project.id).not.toBe(ProjectID.global) expect(project.id).not.toBe(ProjectID.global)
@@ -96,54 +85,63 @@ describe("Project.fromDirectory", () => {
expect(project.worktree).toBe(tmp.path) expect(project.worktree).toBe(tmp.path)
const opencodeFile = path.join(tmp.path, ".git", "opencode") const opencodeFile = path.join(tmp.path, ".git", "opencode")
const fileExists = await Filesystem.exists(opencodeFile) expect(await Bun.file(opencodeFile).exists()).toBe(true)
expect(fileExists).toBe(true)
}) })
test("keeps git vcs when rev-list exits non-zero with empty output", async () => { test("returns global for non-git directory", async () => {
const p = await loadProject() await using tmp = await tmpdir()
const { project } = await Project.fromDirectory(tmp.path)
expect(project.id).toBe(ProjectID.global)
})
test("derives stable project ID from root commit", async () => {
await using tmp = await tmpdir({ git: true })
const { project: a } = await Project.fromDirectory(tmp.path)
const { project: b } = await Project.fromDirectory(tmp.path)
expect(b.id).toBe(a.id)
})
})
describe("Project.fromDirectory git failure paths", () => {
test("keeps vcs when rev-list exits non-zero (no commits)", async () => {
await using tmp = await tmpdir() await using tmp = await tmpdir()
await $`git init`.cwd(tmp.path).quiet() await $`git init`.cwd(tmp.path).quiet()
await withMode("rev-list-fail", async () => { // rev-list fails because HEAD doesn't exist yet — this is the natural scenario
const { project } = await p.fromDirectory(tmp.path) const { project } = await Project.fromDirectory(tmp.path)
expect(project.vcs).toBe("git") expect(project.vcs).toBe("git")
expect(project.id).toBe(ProjectID.global) expect(project.id).toBe(ProjectID.global)
expect(project.worktree).toBe(tmp.path) expect(project.worktree).toBe(tmp.path)
})
}) })
test("keeps git vcs when show-toplevel exits non-zero with empty output", async () => { test("handles show-toplevel failure gracefully", async () => {
const p = await loadProject()
await using tmp = await tmpdir({ git: true }) await using tmp = await tmpdir({ git: true })
const layer = projectLayerWithFailure("--show-toplevel")
await withMode("top-fail", async () => { const { project, sandbox } = await Effect.runPromise(
const { project, sandbox } = await p.fromDirectory(tmp.path) Project.Service.use((svc) => svc.fromDirectory(tmp.path)).pipe(Effect.provide(layer)),
expect(project.vcs).toBe("git") )
expect(project.worktree).toBe(tmp.path) expect(project.worktree).toBe(tmp.path)
expect(sandbox).toBe(tmp.path) expect(sandbox).toBe(tmp.path)
})
}) })
test("keeps git vcs when git-common-dir exits non-zero with empty output", async () => { test("handles git-common-dir failure gracefully", async () => {
const p = await loadProject()
await using tmp = await tmpdir({ git: true }) await using tmp = await tmpdir({ git: true })
const layer = projectLayerWithFailure("--git-common-dir")
await withMode("common-dir-fail", async () => { const { project, sandbox } = await Effect.runPromise(
const { project, sandbox } = await p.fromDirectory(tmp.path) Project.Service.use((svc) => svc.fromDirectory(tmp.path)).pipe(Effect.provide(layer)),
expect(project.vcs).toBe("git") )
expect(project.worktree).toBe(tmp.path) expect(project.worktree).toBe(tmp.path)
expect(sandbox).toBe(tmp.path) expect(sandbox).toBe(tmp.path)
})
}) })
}) })
describe("Project.fromDirectory with worktrees", () => { describe("Project.fromDirectory with worktrees", () => {
test("should set worktree to root when called from root", async () => { test("should set worktree to root when called from root", async () => {
const p = await loadProject()
await using tmp = await tmpdir({ git: true }) await using tmp = await tmpdir({ git: true })
const { project, sandbox } = await p.fromDirectory(tmp.path) const { project, sandbox } = await Project.fromDirectory(tmp.path)
expect(project.worktree).toBe(tmp.path) expect(project.worktree).toBe(tmp.path)
expect(sandbox).toBe(tmp.path) expect(sandbox).toBe(tmp.path)
@@ -151,14 +149,13 @@ describe("Project.fromDirectory with worktrees", () => {
}) })
test("should set worktree to root when called from a worktree", async () => { test("should set worktree to root when called from a worktree", async () => {
const p = await loadProject()
await using tmp = await tmpdir({ git: true }) await using tmp = await tmpdir({ git: true })
const worktreePath = path.join(tmp.path, "..", path.basename(tmp.path) + "-worktree") const worktreePath = path.join(tmp.path, "..", path.basename(tmp.path) + "-worktree")
try { try {
await $`git worktree add ${worktreePath} -b test-branch-${Date.now()}`.cwd(tmp.path).quiet() await $`git worktree add ${worktreePath} -b test-branch-${Date.now()}`.cwd(tmp.path).quiet()
const { project, sandbox } = await p.fromDirectory(worktreePath) const { project, sandbox } = await Project.fromDirectory(worktreePath)
expect(project.worktree).toBe(tmp.path) expect(project.worktree).toBe(tmp.path)
expect(sandbox).toBe(worktreePath) expect(sandbox).toBe(worktreePath)
@@ -173,22 +170,21 @@ describe("Project.fromDirectory with worktrees", () => {
}) })
test("worktree should share project ID with main repo", async () => { test("worktree should share project ID with main repo", async () => {
const p = await loadProject()
await using tmp = await tmpdir({ git: true }) await using tmp = await tmpdir({ git: true })
const { project: main } = await p.fromDirectory(tmp.path) const { project: main } = await Project.fromDirectory(tmp.path)
const worktreePath = path.join(tmp.path, "..", path.basename(tmp.path) + "-wt-shared") const worktreePath = path.join(tmp.path, "..", path.basename(tmp.path) + "-wt-shared")
try { try {
await $`git worktree add ${worktreePath} -b shared-${Date.now()}`.cwd(tmp.path).quiet() await $`git worktree add ${worktreePath} -b shared-${Date.now()}`.cwd(tmp.path).quiet()
const { project: wt } = await p.fromDirectory(worktreePath) const { project: wt } = await Project.fromDirectory(worktreePath)
expect(wt.id).toBe(main.id) expect(wt.id).toBe(main.id)
// Cache should live in the common .git dir, not the worktree's .git file // Cache should live in the common .git dir, not the worktree's .git file
const cache = path.join(tmp.path, ".git", "opencode") const cache = path.join(tmp.path, ".git", "opencode")
const exists = await Filesystem.exists(cache) const exists = await Bun.file(cache).exists()
expect(exists).toBe(true) expect(exists).toBe(true)
} finally { } finally {
await $`git worktree remove ${worktreePath}` await $`git worktree remove ${worktreePath}`
@@ -199,7 +195,6 @@ describe("Project.fromDirectory with worktrees", () => {
}) })
test("separate clones of the same repo should share project ID", async () => { test("separate clones of the same repo should share project ID", async () => {
const p = await loadProject()
await using tmp = await tmpdir({ git: true }) await using tmp = await tmpdir({ git: true })
// Create a bare remote, push, then clone into a second directory // Create a bare remote, push, then clone into a second directory
@@ -209,8 +204,8 @@ describe("Project.fromDirectory with worktrees", () => {
await $`git clone --bare ${tmp.path} ${bare}`.quiet() await $`git clone --bare ${tmp.path} ${bare}`.quiet()
await $`git clone ${bare} ${clone}`.quiet() await $`git clone ${bare} ${clone}`.quiet()
const { project: a } = await p.fromDirectory(tmp.path) const { project: a } = await Project.fromDirectory(tmp.path)
const { project: b } = await p.fromDirectory(clone) const { project: b } = await Project.fromDirectory(clone)
expect(b.id).toBe(a.id) expect(b.id).toBe(a.id)
} finally { } finally {
@@ -219,7 +214,6 @@ describe("Project.fromDirectory with worktrees", () => {
}) })
test("should accumulate multiple worktrees in sandboxes", async () => { test("should accumulate multiple worktrees in sandboxes", async () => {
const p = await loadProject()
await using tmp = await tmpdir({ git: true }) await using tmp = await tmpdir({ git: true })
const worktree1 = path.join(tmp.path, "..", path.basename(tmp.path) + "-wt1") const worktree1 = path.join(tmp.path, "..", path.basename(tmp.path) + "-wt1")
@@ -228,8 +222,8 @@ describe("Project.fromDirectory with worktrees", () => {
await $`git worktree add ${worktree1} -b branch-${Date.now()}`.cwd(tmp.path).quiet() await $`git worktree add ${worktree1} -b branch-${Date.now()}`.cwd(tmp.path).quiet()
await $`git worktree add ${worktree2} -b branch-${Date.now() + 1}`.cwd(tmp.path).quiet() await $`git worktree add ${worktree2} -b branch-${Date.now() + 1}`.cwd(tmp.path).quiet()
await p.fromDirectory(worktree1) await Project.fromDirectory(worktree1)
const { project } = await p.fromDirectory(worktree2) const { project } = await Project.fromDirectory(worktree2)
expect(project.worktree).toBe(tmp.path) expect(project.worktree).toBe(tmp.path)
expect(project.sandboxes).toContain(worktree1) expect(project.sandboxes).toContain(worktree1)
@@ -250,14 +244,13 @@ describe("Project.fromDirectory with worktrees", () => {
describe("Project.discover", () => { describe("Project.discover", () => {
test("should discover favicon.png in root", async () => { test("should discover favicon.png in root", async () => {
const p = await loadProject()
await using tmp = await tmpdir({ git: true }) await using tmp = await tmpdir({ git: true })
const { project } = await p.fromDirectory(tmp.path) const { project } = await Project.fromDirectory(tmp.path)
const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
await Bun.write(path.join(tmp.path, "favicon.png"), pngData) await Bun.write(path.join(tmp.path, "favicon.png"), pngData)
await p.discover(project) await Project.discover(project)
const updated = Project.get(project.id) const updated = Project.get(project.id)
expect(updated).toBeDefined() expect(updated).toBeDefined()
@@ -268,13 +261,12 @@ describe("Project.discover", () => {
}) })
test("should not discover non-image files", async () => { test("should not discover non-image files", async () => {
const p = await loadProject()
await using tmp = await tmpdir({ git: true }) await using tmp = await tmpdir({ git: true })
const { project } = await p.fromDirectory(tmp.path) const { project } = await Project.fromDirectory(tmp.path)
await Bun.write(path.join(tmp.path, "favicon.txt"), "not an image") await Bun.write(path.join(tmp.path, "favicon.txt"), "not an image")
await p.discover(project) await Project.discover(project)
const updated = Project.get(project.id) const updated = Project.get(project.id)
expect(updated).toBeDefined() expect(updated).toBeDefined()
@@ -344,8 +336,6 @@ describe("Project.update", () => {
}) })
test("should throw error when project not found", async () => { test("should throw error when project not found", async () => {
await using tmp = await tmpdir({ git: true })
await expect( await expect(
Project.update({ Project.update({
projectID: ProjectID.make("nonexistent-project-id"), projectID: ProjectID.make("nonexistent-project-id"),
@@ -358,22 +348,22 @@ describe("Project.update", () => {
await using tmp = await tmpdir({ git: true }) await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path) const { project } = await Project.fromDirectory(tmp.path)
let eventFired = false
let eventPayload: any = null let eventPayload: any = null
const on = (data: any) => { eventPayload = data }
GlobalBus.on("event", on)
GlobalBus.on("event", (data) => { try {
eventFired = true await Project.update({
eventPayload = data projectID: project.id,
}) name: "Updated Name",
})
await Project.update({ expect(eventPayload).not.toBeNull()
projectID: project.id, expect(eventPayload.payload.type).toBe("project.updated")
name: "Updated Name", expect(eventPayload.payload.properties.name).toBe("Updated Name")
}) } finally {
GlobalBus.off("event", on)
expect(eventFired).toBe(true) }
expect(eventPayload.payload.type).toBe("project.updated")
expect(eventPayload.payload.properties.name).toBe("Updated Name")
}) })
test("should update multiple fields at once", async () => { test("should update multiple fields at once", async () => {
@@ -393,3 +383,75 @@ describe("Project.update", () => {
expect(updated.commands?.start).toBe("make start") expect(updated.commands?.start).toBe("make start")
}) })
}) })
describe("Project.list and Project.get", () => {
test("list returns all projects", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path)
const all = Project.list()
expect(all.length).toBeGreaterThan(0)
expect(all.find((p) => p.id === project.id)).toBeDefined()
})
test("get returns project by id", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path)
const found = Project.get(project.id)
expect(found).toBeDefined()
expect(found!.id).toBe(project.id)
})
test("get returns undefined for unknown id", () => {
const found = Project.get(ProjectID.make("nonexistent"))
expect(found).toBeUndefined()
})
})
describe("Project.setInitialized", () => {
test("sets time_initialized on project", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path)
expect(project.time.initialized).toBeUndefined()
Project.setInitialized(project.id)
const updated = Project.get(project.id)
expect(updated?.time.initialized).toBeDefined()
})
})
describe("Project.addSandbox and Project.removeSandbox", () => {
test("addSandbox adds directory and removeSandbox removes it", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path)
const sandboxDir = path.join(tmp.path, "sandbox-test")
await Project.addSandbox(project.id, sandboxDir)
let found = Project.get(project.id)
expect(found?.sandboxes).toContain(sandboxDir)
await Project.removeSandbox(project.id, sandboxDir)
found = Project.get(project.id)
expect(found?.sandboxes).not.toContain(sandboxDir)
})
test("addSandbox emits GlobalBus event", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path)
const sandboxDir = path.join(tmp.path, "sandbox-event")
const events: any[] = []
const on = (evt: any) => events.push(evt)
GlobalBus.on("event", on)
await Project.addSandbox(project.id, sandboxDir)
GlobalBus.off("event", on)
expect(events.some((e) => e.payload.type === Project.Event.Updated.type)).toBe(true)
})
})