Răsfoiți Sursa

effectify Project service (#18808)

Kit Langton 1 lună în urmă
părinte
comite
539b01f20f

+ 1 - 1
packages/opencode/specs/effect-migration.md

@@ -173,6 +173,6 @@ Still open and likely worth migrating:
 - [ ] `SessionPrompt`
 - [ ] `SessionCompaction`
 - [ ] `Provider`
-- [ ] `Project`
+- [x] `Project`
 - [ ] `LSP`
 - [ ] `MCP`

+ 386 - 326
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))
   }
 }

+ 1 - 1
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")

+ 183 - 121
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)
+  })
+})