summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorKit Langton <[email protected]>2026-03-24 14:04:22 -0400
committerGitHub <[email protected]>2026-03-24 14:04:22 -0400
commit539b01f20fc3677155b3bdbb428c69423a805578 (patch)
tree7aa2a9162aa9aac645c6c769b582ef5eead1d042 /packages
parent814a515a8a2f474585ea061a99e1058b2bb8b374 (diff)
downloadopencode-539b01f20fc3677155b3bdbb428c69423a805578.tar.gz
opencode-539b01f20fc3677155b3bdbb428c69423a805578.zip
effectify Project service (#18808)
Diffstat (limited to 'packages')
-rw-r--r--packages/opencode/specs/effect-migration.md2
-rw-r--r--packages/opencode/src/project/project.ts712
-rw-r--r--packages/opencode/src/server/routes/project.ts2
-rw-r--r--packages/opencode/test/project/project.test.ts304
4 files changed, 571 insertions, 449 deletions
diff --git a/packages/opencode/specs/effect-migration.md b/packages/opencode/specs/effect-migration.md
index 12017b0e4..cf217871d 100644
--- a/packages/opencode/specs/effect-migration.md
+++ b/packages/opencode/specs/effect-migration.md
@@ -173,6 +173,6 @@ Still open and likely worth migrating:
- [ ] `SessionPrompt`
- [ ] `SessionCompaction`
- [ ] `Provider`
-- [ ] `Project`
+- [x] `Project`
- [ ] `LSP`
- [ ] `MCP`
diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts
index 1cef41c85..3d20f58d4 100644
--- a/packages/opencode/src/project/project.ts
+++ b/packages/opencode/src/project/project.ts
@@ -1,36 +1,23 @@
import z from "zod"
-import { Filesystem } from "../util/filesystem"
-import path from "path"
import { and, Database, eq } from "../storage/db"
import { ProjectTable } from "./project.sql"
import { SessionTable } from "../session/session.sql"
import { Log } from "../util/log"
import { Flag } from "@/flag/flag"
-import { fn } from "@opencode-ai/util/fn"
import { BusEvent } from "@/bus/bus-event"
-import { iife } from "@/util/iife"
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 { 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 {
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
.object({
id: ProjectID.zod,
@@ -73,7 +60,7 @@ export namespace Project {
? { url: row.icon_url ?? undefined, color: row.icon_color ?? undefined }
: undefined
return {
- id: ProjectID.make(row.id),
+ id: row.id,
worktree: row.worktree,
vcs: row.vcs ? Info.shape.vcs.parse(row.vcs) : undefined,
name: row.name ?? undefined,
@@ -88,245 +75,401 @@ export namespace Project {
}
}
- function readCachedId(dir: string) {
- return Filesystem.readText(path.join(dir, "opencode"))
- .then((x) => x.trim())
- .then(ProjectID.make)
- .catch(() => undefined)
+ export const UpdateInput = z.object({
+ projectID: ProjectID.zod,
+ name: z.string().optional(),
+ icon: Info.shape.icon.optional(),
+ 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) {
- log.info("fromDirectory", { directory })
+ export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Project") {}
+
+ type GitResult = { code: number; text: string; stderr: string }
+
+ 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
+
+ const git = Effect.fnUntraced(
+ 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)),
+ )
- const data = await iife(async () => {
- 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 db = <T>(fn: (d: Parameters<typeof Database.use>[0] extends (trx: infer D) => any ? D : never) => T) =>
+ Effect.sync(() => Database.use(fn))
+
+ const emitUpdated = (data: Info) =>
+ Effect.sync(() =>
+ GlobalBus.emit("event", {
+ payload: { type: Event.Updated.type, properties: data },
+ }),
+ )
+
+ const fakeVcs = Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS)
+
+ const resolveGitPath = (cwd: string, name: string) => {
+ if (!name) return cwd
+ name = name.replace(/[\r\n]+$/, "")
+ if (!name) return cwd
+ name = AppFileSystem.windowsPath(name)
+ if (pathSvc.isAbsolute(name)) return pathSvc.normalize(name)
+ return pathSvc.resolve(cwd, name)
+ }
- const gitBinary = which("git")
+ const scope = yield* Scope.Scope
- // cached id calculation
- let id = await readCachedId(dotgit)
+ 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)),
+ )
+ })
- if (!gitBinary) {
- return {
- id: id ?? ProjectID.global,
- worktree: sandbox,
- sandbox,
- vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS),
- }
- }
+ const fromDirectory = Effect.fn("Project.fromDirectory")(function* (directory: string) {
+ log.info("fromDirectory", { directory })
- const worktree = 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)
- })
- .catch(() => undefined)
-
- if (!worktree) {
- return {
- id: id ?? ProjectID.global,
- worktree: sandbox,
- sandbox,
- vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS),
- }
- }
+ // Phase 1: discover git info
+ type DiscoveryResult = { id: ProjectID; worktree: string; sandbox: string; vcs: Info["vcs"] }
- // In the case of a git worktree, it can't cache the id
- // because `.git` is not a folder, but it always needs the
- // same project id as the common dir, so we resolve it now
- if (id == null) {
- id = await readCachedId(path.join(worktree, ".git"))
- }
+ const data: DiscoveryResult = yield* Effect.gen(function* () {
+ const dotgitMatches = yield* fsys.up({ targets: [".git"], start: directory }).pipe(Effect.orDie)
+ const dotgit = dotgitMatches[0]
- // generate id from root commit
- 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) {
+ if (!dotgit) {
return {
id: ProjectID.global,
+ worktree: "/",
+ sandbox: "/",
+ vcs: fakeVcs,
+ }
+ }
+
+ let sandbox = pathSvc.dirname(dotgit)
+ const gitBinary = yield* Effect.sync(() => which("git"))
+ let id = yield* readCachedProjectId(dotgit)
+
+ if (!gitBinary) {
+ return {
+ id: id ?? ProjectID.global,
worktree: sandbox,
sandbox,
- vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS),
+ vcs: fakeVcs,
}
}
- id = roots[0] ? ProjectID.make(roots[0]) : undefined
- if (id) {
- // Write to common dir so the cache is shared across worktrees.
- await Filesystem.write(path.join(worktree, ".git", "opencode"), id).catch(() => undefined)
+ const commonDir = yield* git(["rev-parse", "--git-common-dir"], { 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) {
- return {
- id: ProjectID.global,
- worktree: sandbox,
- sandbox,
- vcs: "git",
+ if (id == null) {
+ id = yield* readCachedProjectId(pathSvc.join(worktree, ".git"))
}
- }
- const top = await git(["rev-parse", "--show-toplevel"], {
- cwd: sandbox,
- })
- .then(async (result) => gitpath(sandbox, await result.text()))
- .catch(() => undefined)
-
- if (!top) {
- return {
- id,
- worktree: sandbox,
- sandbox,
- vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS),
+ 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)
+ }
}
- }
- sandbox = top
+ if (!id) {
+ return { id: ProjectID.global, worktree: sandbox, sandbox, vcs: "git" as const }
+ }
- return {
- id,
- sandbox,
- worktree,
- vcs: "git",
- }
- }
+ 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: ProjectID.global,
- worktree: "/",
- sandbox: "/",
- vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS),
- }
- })
+ return { id, sandbox, worktree, vcs: "git" as const }
+ })
- const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, data.id)).get())
- const existing = row
- ? fromRow(row)
- : {
- id: data.id,
+ // Phase 2: upsert
+ const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, data.id)).get())
+ const existing = row
+ ? fromRow(row)
+ : {
+ id: data.id,
+ worktree: data.worktree,
+ vcs: data.vcs,
+ sandboxes: [] as string[],
+ time: { created: Date.now(), updated: Date.now() },
+ }
+
+ if (Flag.OPENCODE_EXPERIMENTAL_ICON_DISCOVERY)
+ yield* discover(existing).pipe(Effect.ignore, Effect.forkIn(scope))
+
+ const result: Info = {
+ ...existing,
worktree: data.worktree,
- vcs: data.vcs as Info["vcs"],
- sandboxes: [] as string[],
- time: {
- created: Date.now(),
- updated: Date.now(),
- },
+ vcs: data.vcs,
+ time: { ...existing.time, updated: Date.now() },
+ }
+ if (data.sandbox !== result.worktree && !result.sandboxes.includes(data.sandbox))
+ 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 = {
- ...existing,
- worktree: data.worktree,
- vcs: data.vcs as Info["vcs"],
- time: {
- ...existing.time,
- updated: Date.now(),
- },
- }
- if (data.sandbox !== result.worktree && !result.sandboxes.includes(data.sandbox))
- result.sandboxes.push(data.sandbox)
- result.sandboxes = result.sandboxes.filter((x) => existsSync(x))
- const insert = {
- 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,
- }
- const updateSet = {
- 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,
- }
- Database.use((db) =>
- db.insert(ProjectTable).values(insert).onConflictDoUpdate({ target: ProjectTable.id, set: updateSet }).run(),
- )
- // Runs after upsert so the target project row exists (FK constraint).
- // Runs on every startup because sessions created before git init
- // accumulate under "global" and need migrating whenever they appear.
- if (data.id !== ProjectID.global) {
- Database.use((db) =>
- db
- .update(SessionTable)
- .set({ project_id: data.id })
- .where(and(eq(SessionTable.project_id, ProjectID.global), eq(SessionTable.directory, data.worktree)))
- .run(),
- )
- }
- GlobalBus.emit("event", {
- payload: {
- type: Event.Updated.type,
- properties: result,
- },
- })
- return { project: result, sandbox: data.sandbox }
- }
+ const discover = Effect.fn("Project.discover")(function* (input: Info) {
+ if (input.vcs !== "git") return
+ if (input.icon?.override) return
+ if (input.icon?.url) return
- export async function discover(input: Info) {
- if (input.vcs !== "git") return
- 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
+ const matches = yield* fsys
+ .glob("**/favicon.{ico,png,svg,jpg,jpeg,webp}", {
+ cwd: input.worktree,
+ absolute: true,
+ include: "file",
+ })
+ .pipe(Effect.orDie)
+ const shortest = matches.sort((a, b) => a.length - b.length)[0]
+ if (!shortest) return
+
+ const buffer = yield* fsys.readFile(shortest).pipe(Effect.orDie)
+ const base64 = Buffer.from(buffer).toString("base64")
+ const mime = AppFileSystem.mimeType(shortest)
+ const url = `data:${mime};base64,${base64}`
+ yield* update({ projectID: input.id, icon: { url } })
+ })
+
+ const list = Effect.fn("Project.list")(function* () {
+ return yield* db((d) => d.select().from(ProjectTable).all().map(fromRow))
+ })
+
+ const get = Effect.fn("Project.get")(function* (id: ProjectID) {
+ const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
+ return row ? fromRow(row) : undefined
+ })
+
+ const update = Effect.fn("Project.update")(function* (input: UpdateInput) {
+ const result = yield* db((d) =>
+ d
+ .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, input.projectID))
+ .returning()
+ .get(),
+ )
+ if (!result) throw new Error(`Project not found: ${input.projectID}`)
+ const data = fromRow(result)
+ yield* emitUpdated(data)
+ return data
+ })
+
+ const initGit = Effect.fn("Project.initGit")(function* (input: { directory: string; project: Info }) {
+ if (input.project.vcs === "git") return input.project
+ 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) {
+ 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 function setInitialized(id: ProjectID) {
- Database.use((db) =>
- db
- .update(ProjectTable)
- .set({
- time_initialized: Date.now(),
- })
- .where(eq(ProjectTable.id, id))
- .run(),
- )
+ export function discover(input: Info) {
+ return runPromise((svc) => svc.discover(input))
}
export function list() {
@@ -345,112 +488,29 @@ export namespace Project {
return fromRow(row)
}
- export async function initGit(input: { directory: string; project: Info }) {
- if (input.project.vcs === "git") return input.project
- if (!which("git")) throw new Error("Git is not installed")
-
- 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")
- }
+ export function setInitialized(id: ProjectID) {
+ Database.use((db) =>
+ db.update(ProjectTable).set({ time_initialized: Date.now() }).where(eq(ProjectTable.id, id)).run(),
+ )
+ }
- return (await fromDirectory(input.directory)).project
+ export function initGit(input: { directory: string; project: Info }) {
+ return runPromise((svc) => svc.initGit(input))
}
- 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 function update(input: UpdateInput) {
+ return runPromise((svc) => svc.update(input))
+ }
- 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 function sandboxes(id: ProjectID) {
+ return runPromise((svc) => svc.sandboxes(id))
}
- 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 function addSandbox(id: ProjectID, directory: string) {
+ return runPromise((svc) => svc.addSandbox(id, directory))
}
- export async function removeSandbox(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.filter((s) => s !== 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 function removeSandbox(id: ProjectID, directory: string) {
+ return runPromise((svc) => svc.removeSandbox(id, directory))
}
}
diff --git a/packages/opencode/src/server/routes/project.ts b/packages/opencode/src/server/routes/project.ts
index 6cd51ac95..e5dd5782d 100644
--- a/packages/opencode/src/server/routes/project.ts
+++ b/packages/opencode/src/server/routes/project.ts
@@ -107,7 +107,7 @@ export const ProjectRoutes = lazy(() =>
},
}),
validator("param", z.object({ projectID: ProjectID.zod })),
- validator("json", Project.update.schema.omit({ projectID: true })),
+ validator("json", Project.UpdateInput.omit({ projectID: true })),
async (c) => {
const projectID = c.req.valid("param").projectID
const body = c.req.valid("json")
diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts
index a71fe0528..523f0711f 100644
--- a/packages/opencode/test/project/project.test.ts
+++ b/packages/opencode/test/project/project.test.ts
@@ -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 { Log } from "../../src/util/log"
import { $ } from "bun"
import path from "path"
import { tmpdir } from "../fixture/fixture"
-import { Filesystem } from "../../src/util/filesystem"
import { GlobalBus } from "../../src/bus/global"
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 })
-const gitModule = await import("../../src/util/git")
-const originalGit = gitModule.git
-
-type Mode = "none" | "rev-list-fail" | "top-fail" | "common-dir-fail"
-let mode: Mode = "none"
-
-mock.module("../../src/util/git", () => ({
- git: (args: string[], opts: { cwd: string; env?: Record<string, string> }) => {
- const cmd = ["git", ...args].join(" ")
- if (
- mode === "rev-list-fail" &&
- cmd.includes("git rev-list") &&
- cmd.includes("--max-parents=0") &&
- cmd.includes("HEAD")
- ) {
- return Promise.resolve({
- exitCode: 128,
- text: () => Promise.resolve(""),
- stdout: Buffer.from(""),
- stderr: Buffer.from("fatal"),
- })
- }
- if (mode === "top-fail" && cmd.includes("git rev-parse") && cmd.includes("--show-toplevel")) {
- return Promise.resolve({
- exitCode: 128,
- text: () => Promise.resolve(""),
- stdout: Buffer.from(""),
- stderr: Buffer.from("fatal"),
- })
- }
- if (mode === "common-dir-fail" && cmd.includes("git rev-parse") && cmd.includes("--git-common-dir")) {
- return Promise.resolve({
- exitCode: 128,
- text: () => Promise.resolve(""),
- stdout: Buffer.from(""),
- 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
- }
+const encoder = new TextEncoder()
+
+/**
+ * Creates a mock ChildProcessSpawner layer that intercepts git subcommands
+ * matching `failArg` and returns exit code 128, while delegating everything
+ * else to the real CrossSpawnSpawner.
+ */
+function mockGitFailure(failArg: string) {
+ return Layer.effect(
+ ChildProcessSpawner.ChildProcessSpawner,
+ Effect.gen(function* () {
+ const real = yield* ChildProcessSpawner.ChildProcessSpawner
+ return ChildProcessSpawner.make(
+ Effect.fnUntraced(function* (command) {
+ const std = ChildProcess.isStandardCommand(command) ? command : undefined
+ if (std?.command === "git" && std.args.some((a) => a === failArg)) {
+ return ChildProcessSpawner.makeHandle({
+ pid: ChildProcessSpawner.ProcessId(0),
+ exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(128)),
+ isRunning: Effect.succeed(false),
+ kill: () => Effect.void,
+ stdin: { [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") } as any,
+ stdout: Stream.empty,
+ stderr: Stream.make(encoder.encode("fatal: simulated failure\n")),
+ all: Stream.empty,
+ getInputFd: () => ({ [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") }) as any,
+ getOutputFd: () => Stream.empty,
+ })
+ }
+ return yield* real.spawn(command)
+ }),
+ )
+ }),
+ ).pipe(Layer.provide(CrossSpawnSpawner.layer), Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer))
}
-async function loadProject() {
- return (await import("../../src/project/project")).Project
+function projectLayerWithFailure(failArg: string) {
+ return Project.layer.pipe(
+ Layer.provide(mockGitFailure(failArg)),
+ Layer.provide(AppFileSystem.defaultLayer),
+ Layer.provide(NodePath.layer),
+ )
}
describe("Project.fromDirectory", () => {
test("should handle git repository with no commits", async () => {
- const p = await loadProject()
await using tmp = await tmpdir()
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.id).toBe(ProjectID.global)
@@ -80,15 +71,13 @@ describe("Project.fromDirectory", () => {
expect(project.worktree).toBe(tmp.path)
const opencodeFile = path.join(tmp.path, ".git", "opencode")
- const fileExists = await Filesystem.exists(opencodeFile)
- expect(fileExists).toBe(false)
+ expect(await Bun.file(opencodeFile).exists()).toBe(false)
})
test("should handle git repository with commits", async () => {
- const p = await loadProject()
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.id).not.toBe(ProjectID.global)
@@ -96,54 +85,63 @@ describe("Project.fromDirectory", () => {
expect(project.worktree).toBe(tmp.path)
const opencodeFile = path.join(tmp.path, ".git", "opencode")
- const fileExists = await Filesystem.exists(opencodeFile)
- expect(fileExists).toBe(true)
+ expect(await Bun.file(opencodeFile).exists()).toBe(true)
})
- test("keeps git vcs when rev-list exits non-zero with empty output", async () => {
- const p = await loadProject()
+ test("returns global for non-git directory", async () => {
+ 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 $`git init`.cwd(tmp.path).quiet()
- await withMode("rev-list-fail", async () => {
- const { project } = await p.fromDirectory(tmp.path)
- expect(project.vcs).toBe("git")
- expect(project.id).toBe(ProjectID.global)
- expect(project.worktree).toBe(tmp.path)
- })
+ // rev-list fails because HEAD doesn't exist yet — this is the natural scenario
+ const { project } = await Project.fromDirectory(tmp.path)
+ expect(project.vcs).toBe("git")
+ expect(project.id).toBe(ProjectID.global)
+ expect(project.worktree).toBe(tmp.path)
})
- test("keeps git vcs when show-toplevel exits non-zero with empty output", async () => {
- const p = await loadProject()
+ test("handles show-toplevel failure gracefully", async () => {
await using tmp = await tmpdir({ git: true })
+ const layer = projectLayerWithFailure("--show-toplevel")
- await withMode("top-fail", async () => {
- const { project, sandbox } = await p.fromDirectory(tmp.path)
- expect(project.vcs).toBe("git")
- expect(project.worktree).toBe(tmp.path)
- expect(sandbox).toBe(tmp.path)
- })
+ const { project, sandbox } = await Effect.runPromise(
+ Project.Service.use((svc) => svc.fromDirectory(tmp.path)).pipe(Effect.provide(layer)),
+ )
+ expect(project.worktree).toBe(tmp.path)
+ expect(sandbox).toBe(tmp.path)
})
- test("keeps git vcs when git-common-dir exits non-zero with empty output", async () => {
- const p = await loadProject()
+ test("handles git-common-dir failure gracefully", async () => {
await using tmp = await tmpdir({ git: true })
+ const layer = projectLayerWithFailure("--git-common-dir")
- await withMode("common-dir-fail", async () => {
- const { project, sandbox } = await p.fromDirectory(tmp.path)
- expect(project.vcs).toBe("git")
- expect(project.worktree).toBe(tmp.path)
- expect(sandbox).toBe(tmp.path)
- })
+ const { project, sandbox } = await Effect.runPromise(
+ Project.Service.use((svc) => svc.fromDirectory(tmp.path)).pipe(Effect.provide(layer)),
+ )
+ expect(project.worktree).toBe(tmp.path)
+ expect(sandbox).toBe(tmp.path)
})
})
describe("Project.fromDirectory with worktrees", () => {
test("should set worktree to root when called from root", async () => {
- const p = await loadProject()
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(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 () => {
- const p = await loadProject()
await using tmp = await tmpdir({ git: true })
const worktreePath = path.join(tmp.path, "..", path.basename(tmp.path) + "-worktree")
try {
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(sandbox).toBe(worktreePath)
@@ -173,22 +170,21 @@ describe("Project.fromDirectory with worktrees", () => {
})
test("worktree should share project ID with main repo", async () => {
- const p = await loadProject()
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")
try {
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)
// Cache should live in the common .git dir, not the worktree's .git file
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)
} finally {
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 () => {
- const p = await loadProject()
await using tmp = await tmpdir({ git: true })
// 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} ${clone}`.quiet()
- const { project: a } = await p.fromDirectory(tmp.path)
- const { project: b } = await p.fromDirectory(clone)
+ const { project: a } = await Project.fromDirectory(tmp.path)
+ const { project: b } = await Project.fromDirectory(clone)
expect(b.id).toBe(a.id)
} finally {
@@ -219,7 +214,6 @@ describe("Project.fromDirectory with worktrees", () => {
})
test("should accumulate multiple worktrees in sandboxes", async () => {
- const p = await loadProject()
await using tmp = await tmpdir({ git: true })
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 ${worktree2} -b branch-${Date.now() + 1}`.cwd(tmp.path).quiet()
- await p.fromDirectory(worktree1)
- const { project } = await p.fromDirectory(worktree2)
+ await Project.fromDirectory(worktree1)
+ const { project } = await Project.fromDirectory(worktree2)
expect(project.worktree).toBe(tmp.path)
expect(project.sandboxes).toContain(worktree1)
@@ -250,14 +244,13 @@ describe("Project.fromDirectory with worktrees", () => {
describe("Project.discover", () => {
test("should discover favicon.png in root", async () => {
- const p = await loadProject()
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])
await Bun.write(path.join(tmp.path, "favicon.png"), pngData)
- await p.discover(project)
+ await Project.discover(project)
const updated = Project.get(project.id)
expect(updated).toBeDefined()
@@ -268,13 +261,12 @@ describe("Project.discover", () => {
})
test("should not discover non-image files", async () => {
- const p = await loadProject()
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 p.discover(project)
+ await Project.discover(project)
const updated = Project.get(project.id)
expect(updated).toBeDefined()
@@ -344,8 +336,6 @@ describe("Project.update", () => {
})
test("should throw error when project not found", async () => {
- await using tmp = await tmpdir({ git: true })
-
await expect(
Project.update({
projectID: ProjectID.make("nonexistent-project-id"),
@@ -358,22 +348,22 @@ describe("Project.update", () => {
await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path)
- let eventFired = false
let eventPayload: any = null
+ const on = (data: any) => { eventPayload = data }
+ GlobalBus.on("event", on)
- GlobalBus.on("event", (data) => {
- eventFired = true
- eventPayload = data
- })
-
- await Project.update({
- projectID: project.id,
- name: "Updated Name",
- })
+ try {
+ await Project.update({
+ projectID: project.id,
+ name: "Updated Name",
+ })
- expect(eventFired).toBe(true)
- expect(eventPayload.payload.type).toBe("project.updated")
- expect(eventPayload.payload.properties.name).toBe("Updated Name")
+ expect(eventPayload).not.toBeNull()
+ expect(eventPayload.payload.type).toBe("project.updated")
+ expect(eventPayload.payload.properties.name).toBe("Updated Name")
+ } finally {
+ GlobalBus.off("event", on)
+ }
})
test("should update multiple fields at once", async () => {
@@ -393,3 +383,75 @@ describe("Project.update", () => {
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)
+ })
+})