Просмотр исходного кода

feat: unwrap project namespaces to flat exports + barrel (#22743)

Kit Langton 1 день назад
Родитель
Сommit
a427a28fa9

+ 1 - 1
packages/opencode/src/cli/cmd/debug/scrap.ts

@@ -1,5 +1,5 @@
 import { EOL } from "os"
-import { Project } from "../../../project/project"
+import { Project } from "../../../project"
 import { Log } from "../../../util"
 import { cmd } from "../cmd"
 

+ 1 - 1
packages/opencode/src/cli/cmd/stats.ts

@@ -4,7 +4,7 @@ import { Session } from "../../session"
 import { bootstrap } from "../bootstrap"
 import { Database } from "../../storage/db"
 import { SessionTable } from "../../session/session.sql"
-import { Project } from "../../project/project"
+import { Project } from "../../project"
 import { Instance } from "../../project/instance"
 import { AppRuntime } from "@/effect/app-runtime"
 

+ 1 - 1
packages/opencode/src/control-plane/workspace.ts

@@ -2,7 +2,7 @@ import z from "zod"
 import { setTimeout as sleep } from "node:timers/promises"
 import { fn } from "@/util/fn"
 import { Database, asc, eq, inArray } from "@/storage/db"
-import { Project } from "@/project/project"
+import { Project } from "@/project"
 import { BusEvent } from "@/bus/bus-event"
 import { GlobalBus } from "@/bus/global"
 import { SyncEvent } from "@/sync"

+ 2 - 2
packages/opencode/src/effect/app-runtime.ts

@@ -40,8 +40,8 @@ import { Command } from "@/command"
 import { Truncate } from "@/tool/truncate"
 import { ToolRegistry } from "@/tool/registry"
 import { Format } from "@/format"
-import { Project } from "@/project/project"
-import { Vcs } from "@/project/vcs"
+import { Project } from "@/project"
+import { Vcs } from "@/project"
 import { Worktree } from "@/worktree"
 import { Pty } from "@/pty"
 import { Installation } from "@/installation"

+ 1 - 1
packages/opencode/src/effect/bootstrap-runtime.ts

@@ -7,7 +7,7 @@ import { FileWatcher } from "@/file/watcher"
 import { Format } from "@/format"
 import { ShareNext } from "@/share/share-next"
 import { File } from "@/file"
-import { Vcs } from "@/project/vcs"
+import { Vcs } from "@/project"
 import { Snapshot } from "@/snapshot"
 import { Bus } from "@/bus"
 import { Observability } from "./observability"

+ 2 - 2
packages/opencode/src/project/bootstrap.ts

@@ -3,8 +3,8 @@ import { Format } from "../format"
 import { LSP } from "../lsp"
 import { File } from "../file"
 import { Snapshot } from "../snapshot"
-import { Project } from "./project"
-import { Vcs } from "./vcs"
+import { Project } from "."
+import { Vcs } from "."
 import { Bus } from "../bus"
 import { Command } from "../command"
 import { Instance } from "./instance"

+ 2 - 0
packages/opencode/src/project/index.ts

@@ -0,0 +1,2 @@
+export * as Vcs from "./vcs"
+export * as Project from "./project"

+ 1 - 1
packages/opencode/src/project/instance.ts

@@ -5,7 +5,7 @@ import { AppFileSystem } from "@opencode-ai/shared/filesystem"
 import { iife } from "@/util/iife"
 import { Log } from "@/util"
 import { LocalContext } from "../util"
-import { Project } from "./project"
+import { Project } from "."
 import { WorkspaceContext } from "@/control-plane/workspace-context"
 
 export interface InstanceContext {

+ 412 - 414
packages/opencode/src/project/project.ts

@@ -14,474 +14,472 @@ import { NodePath } from "@effect/platform-node"
 import { AppFileSystem } from "@opencode-ai/shared/filesystem"
 import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
 
-export namespace Project {
-  const log = Log.create({ service: "project" })
-
-  export const Info = z
-    .object({
-      id: ProjectID.zod,
-      worktree: z.string(),
-      vcs: z.literal("git").optional(),
-      name: z.string().optional(),
-      icon: z
-        .object({
-          url: z.string().optional(),
-          override: z.string().optional(),
-          color: z.string().optional(),
-        })
-        .optional(),
-      commands: z
-        .object({
-          start: z.string().optional().describe("Startup script to run when creating a new workspace (worktree)"),
-        })
-        .optional(),
-      time: z.object({
-        created: z.number(),
-        updated: z.number(),
-        initialized: z.number().optional(),
-      }),
-      sandboxes: z.array(z.string()),
-    })
-    .meta({
-      ref: "Project",
-    })
-  export type Info = z.infer<typeof Info>
+const log = Log.create({ service: "project" })
+
+export const Info = z
+  .object({
+    id: ProjectID.zod,
+    worktree: z.string(),
+    vcs: z.literal("git").optional(),
+    name: z.string().optional(),
+    icon: z
+      .object({
+        url: z.string().optional(),
+        override: z.string().optional(),
+        color: z.string().optional(),
+      })
+      .optional(),
+    commands: z
+      .object({
+        start: z.string().optional().describe("Startup script to run when creating a new workspace (worktree)"),
+      })
+      .optional(),
+    time: z.object({
+      created: z.number(),
+      updated: z.number(),
+      initialized: z.number().optional(),
+    }),
+    sandboxes: z.array(z.string()),
+  })
+  .meta({
+    ref: "Project",
+  })
+export type Info = z.infer<typeof Info>
 
-  export const Event = {
-    Updated: BusEvent.define("project.updated", Info),
+export const Event = {
+  Updated: BusEvent.define("project.updated", Info),
+}
+
+type Row = typeof ProjectTable.$inferSelect
+
+export function fromRow(row: Row): Info {
+  const icon =
+    row.icon_url || row.icon_color
+      ? { url: row.icon_url ?? undefined, color: row.icon_color ?? undefined }
+      : undefined
+  return {
+    id: row.id,
+    worktree: row.worktree,
+    vcs: row.vcs ? Info.shape.vcs.parse(row.vcs) : undefined,
+    name: row.name ?? undefined,
+    icon,
+    time: {
+      created: row.time_created,
+      updated: row.time_updated,
+      initialized: row.time_initialized ?? undefined,
+    },
+    sandboxes: row.sandboxes,
+    commands: row.commands ?? undefined,
   }
+}
 
-  type Row = typeof ProjectTable.$inferSelect
-
-  export function fromRow(row: Row): Info {
-    const icon =
-      row.icon_url || row.icon_color
-        ? { url: row.icon_url ?? undefined, color: row.icon_color ?? undefined }
-        : undefined
-    return {
-      id: row.id,
-      worktree: row.worktree,
-      vcs: row.vcs ? Info.shape.vcs.parse(row.vcs) : undefined,
-      name: row.name ?? undefined,
-      icon,
-      time: {
-        created: row.time_created,
-        updated: row.time_updated,
-        initialized: row.time_initialized ?? 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 class Service extends Context.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 fs = 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
       },
-      sandboxes: row.sandboxes,
-      commands: row.commands ?? undefined,
-    }
-  }
+      Effect.scoped,
+      Effect.catch(() => Effect.succeed({ code: 1, text: "", stderr: "" } satisfies GitResult)),
+    )
 
-  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>
-  }
+    const db = <T>(fn: (d: Parameters<typeof Database.use>[0] extends (trx: infer D) => any ? D : never) => T) =>
+      Effect.sync(() => Database.use(fn))
 
-  export class Service extends Context.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 fs = 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 emitUpdated = (data: Info) =>
+      Effect.sync(() =>
+        GlobalBus.emit("event", {
+          directory: "global",
+          project: data.id,
+          payload: { type: Event.Updated.type, properties: data },
+        }),
       )
 
-      const db = <T>(fn: (d: Parameters<typeof Database.use>[0] extends (trx: infer D) => any ? D : never) => T) =>
-        Effect.sync(() => Database.use(fn))
+    const fakeVcs = Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS)
 
-      const emitUpdated = (data: Info) =>
-        Effect.sync(() =>
-          GlobalBus.emit("event", {
-            directory: "global",
-            project: data.id,
-            payload: { type: Event.Updated.type, properties: data },
-          }),
-        )
+    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 fakeVcs = Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS)
+    const scope = yield* Scope.Scope
 
-      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 readCachedProjectId = Effect.fnUntraced(function* (dir: string) {
+      return yield* fs.readFileString(pathSvc.join(dir, "opencode")).pipe(
+        Effect.map((x) => x.trim()),
+        Effect.map(ProjectID.make),
+        Effect.catch(() => Effect.void),
+      )
+    })
 
-      const scope = yield* Scope.Scope
+    const fromDirectory = Effect.fn("Project.fromDirectory")(function* (directory: string) {
+      log.info("fromDirectory", { directory })
 
-      const readCachedProjectId = Effect.fnUntraced(function* (dir: string) {
-        return yield* fs.readFileString(pathSvc.join(dir, "opencode")).pipe(
-          Effect.map((x) => x.trim()),
-          Effect.map(ProjectID.make),
-          Effect.catch(() => Effect.void),
-        )
-      })
+      // Phase 1: discover git info
+      type DiscoveryResult = { id: ProjectID; worktree: string; sandbox: string; vcs: Info["vcs"] }
 
-      const fromDirectory = Effect.fn("Project.fromDirectory")(function* (directory: string) {
-        log.info("fromDirectory", { directory })
+      const data: DiscoveryResult = yield* Effect.gen(function* () {
+        const dotgitMatches = yield* fs.up({ targets: [".git"], start: directory }).pipe(Effect.orDie)
+        const dotgit = dotgitMatches[0]
 
-        // Phase 1: discover git info
-        type DiscoveryResult = { id: ProjectID; worktree: string; sandbox: string; vcs: Info["vcs"] }
+        if (!dotgit) {
+          return {
+            id: ProjectID.global,
+            worktree: "/",
+            sandbox: "/",
+            vcs: fakeVcs,
+          }
+        }
 
-        const data: DiscoveryResult = yield* Effect.gen(function* () {
-          const dotgitMatches = yield* fs.up({ targets: [".git"], start: directory }).pipe(Effect.orDie)
-          const dotgit = dotgitMatches[0]
+        let sandbox = pathSvc.dirname(dotgit)
+        const gitBinary = yield* Effect.sync(() => which("git"))
+        let id = yield* readCachedProjectId(dotgit)
 
-          if (!dotgit) {
-            return {
-              id: ProjectID.global,
-              worktree: "/",
-              sandbox: "/",
-              vcs: fakeVcs,
-            }
+        if (!gitBinary) {
+          return {
+            id: id ?? ProjectID.global,
+            worktree: sandbox,
+            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: fakeVcs,
-            }
+        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)
+        })()
 
-          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 == null) {
+          id = yield* readCachedProjectId(pathSvc.join(worktree, ".git"))
+        }
 
-          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* fs.writeFileString(pathSvc.join(worktree, ".git", "opencode"), id).pipe(Effect.ignore)
           }
+        }
 
-          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* fs.writeFileString(pathSvc.join(worktree, ".git", "opencode"), id).pipe(Effect.ignore)
-            }
-          }
+        if (!id) {
+          return { id: ProjectID.global, worktree: sandbox, sandbox, vcs: "git" as const }
+        }
 
-          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())
 
-          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 }
+      })
 
-          return { id, sandbox, worktree, vcs: "git" as const }
-        })
+      // 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() },
+          }
 
-        // 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,
-          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) =>
-            fs.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)))
+      if (Flag.OPENCODE_EXPERIMENTAL_ICON_DISCOVERY)
+        yield* discover(existing).pipe(Effect.ignore, Effect.forkIn(scope))
 
-        yield* db((d) =>
-          d
-            .insert(ProjectTable)
-            .values({
-              id: result.id,
+      const result: Info = {
+        ...existing,
+        worktree: data.worktree,
+        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) =>
+          fs.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_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 (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(),
-          )
-        }
-
-        yield* emitUpdated(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
+      yield* emitUpdated(result)
+      return { project: result, sandbox: data.sandbox }
+    })
 
-        const matches = yield* fs
-          .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* fs.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 discover = Effect.fn("Project.discover")(function* (input: Info) {
+      if (input.vcs !== "git") return
+      if (input.icon?.override) return
+      if (input.icon?.url) return
 
-      const list = Effect.fn("Project.list")(function* () {
-        return yield* db((d) => d.select().from(ProjectTable).all().map(fromRow))
-      })
+      const matches = yield* fs
+        .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* fs.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 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 list = Effect.fn("Project.list")(function* () {
+      return yield* db((d) => d.select().from(ProjectTable).all().map(fromRow))
+    })
 
-      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 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 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 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 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 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 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) =>
-            fs.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 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 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 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) =>
+          fs.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 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))
-      })
+    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))
+    })
 
-      return Service.of({
-        fromDirectory,
-        discover,
-        list,
-        get,
-        update,
-        initGit,
-        setInitialized,
-        sandboxes,
-        addSandbox,
-        removeSandbox,
-      })
-    }),
-  )
+    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))
+    })
 
-  export const defaultLayer = layer.pipe(
-    Layer.provide(CrossSpawnSpawner.defaultLayer),
-    Layer.provide(AppFileSystem.defaultLayer),
-    Layer.provide(NodePath.layer),
+    return Service.of({
+      fromDirectory,
+      discover,
+      list,
+      get,
+      update,
+      initGit,
+      setInitialized,
+      sandboxes,
+      addSandbox,
+      removeSandbox,
+    })
+  }),
+)
+
+export const defaultLayer = layer.pipe(
+  Layer.provide(CrossSpawnSpawner.defaultLayer),
+  Layer.provide(AppFileSystem.defaultLayer),
+  Layer.provide(NodePath.layer),
+)
+
+export function list() {
+  return Database.use((db) =>
+    db
+      .select()
+      .from(ProjectTable)
+      .all()
+      .map((row) => fromRow(row)),
   )
+}
 
-  export function list() {
-    return Database.use((db) =>
-      db
-        .select()
-        .from(ProjectTable)
-        .all()
-        .map((row) => fromRow(row)),
-    )
-  }
-
-  export function get(id: ProjectID): Info | undefined {
-    const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
-    if (!row) return undefined
-    return fromRow(row)
-  }
+export function get(id: ProjectID): Info | undefined {
+  const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
+  if (!row) return undefined
+  return fromRow(row)
+}
 
-  export function setInitialized(id: ProjectID) {
-    Database.use((db) =>
-      db.update(ProjectTable).set({ time_initialized: Date.now() }).where(eq(ProjectTable.id, id)).run(),
-    )
-  }
+export function setInitialized(id: ProjectID) {
+  Database.use((db) =>
+    db.update(ProjectTable).set({ time_initialized: Date.now() }).where(eq(ProjectTable.id, id)).run(),
+  )
 }

+ 205 - 207
packages/opencode/src/project/vcs.ts

@@ -11,223 +11,221 @@ import { Log } from "@/util"
 import { Instance } from "./instance"
 import z from "zod"
 
-export namespace Vcs {
-  const log = Log.create({ service: "vcs" })
-
-  const count = (text: string) => {
-    if (!text) return 0
-    if (!text.endsWith("\n")) return text.split("\n").length
-    return text.slice(0, -1).split("\n").length
-  }
-
-  const work = Effect.fnUntraced(function* (fs: AppFileSystem.Interface, cwd: string, file: string) {
-    const full = path.join(cwd, file)
-    if (!(yield* fs.exists(full).pipe(Effect.orDie))) return ""
-    const buf = yield* fs.readFile(full).pipe(Effect.catch(() => Effect.succeed(new Uint8Array())))
-    if (Buffer.from(buf).includes(0)) return ""
-    return Buffer.from(buf).toString("utf8")
-  })
+const log = Log.create({ service: "vcs" })
 
-  const nums = (list: Git.Stat[]) =>
-    new Map(list.map((item) => [item.file, { additions: item.additions, deletions: item.deletions }] as const))
+const count = (text: string) => {
+  if (!text) return 0
+  if (!text.endsWith("\n")) return text.split("\n").length
+  return text.slice(0, -1).split("\n").length
+}
 
-  const merge = (...lists: Git.Item[][]) => {
-    const out = new Map<string, Git.Item>()
-    lists.flat().forEach((item) => {
-      if (!out.has(item.file)) out.set(item.file, item)
-    })
-    return [...out.values()]
-  }
-
-  const files = Effect.fnUntraced(function* (
-    fs: AppFileSystem.Interface,
-    git: Git.Interface,
-    cwd: string,
-    ref: string | undefined,
-    list: Git.Item[],
-    map: Map<string, { additions: number; deletions: number }>,
-  ) {
-    const base = ref ? yield* git.prefix(cwd) : ""
-    const patch = (file: string, before: string, after: string) =>
-      formatPatch(structuredPatch(file, file, before, after, "", "", { context: Number.MAX_SAFE_INTEGER }))
-    const next = yield* Effect.forEach(
-      list,
-      (item) =>
-        Effect.gen(function* () {
-          const before = item.status === "added" || !ref ? "" : yield* git.show(cwd, ref, item.file, base)
-          const after = item.status === "deleted" ? "" : yield* work(fs, cwd, item.file)
-          const stat = map.get(item.file)
-          return {
-            file: item.file,
-            patch: patch(item.file, before, after),
-            additions: stat?.additions ?? (item.status === "added" ? count(after) : 0),
-            deletions: stat?.deletions ?? (item.status === "deleted" ? count(before) : 0),
-            status: item.status,
-          } satisfies FileDiff
-        }),
-      { concurrency: 8 },
-    )
-    return next.toSorted((a, b) => a.file.localeCompare(b.file))
+const work = Effect.fnUntraced(function* (fs: AppFileSystem.Interface, cwd: string, file: string) {
+  const full = path.join(cwd, file)
+  if (!(yield* fs.exists(full).pipe(Effect.orDie))) return ""
+  const buf = yield* fs.readFile(full).pipe(Effect.catch(() => Effect.succeed(new Uint8Array())))
+  if (Buffer.from(buf).includes(0)) return ""
+  return Buffer.from(buf).toString("utf8")
+})
+
+const nums = (list: Git.Stat[]) =>
+  new Map(list.map((item) => [item.file, { additions: item.additions, deletions: item.deletions }] as const))
+
+const merge = (...lists: Git.Item[][]) => {
+  const out = new Map<string, Git.Item>()
+  lists.flat().forEach((item) => {
+    if (!out.has(item.file)) out.set(item.file, item)
   })
+  return [...out.values()]
+}
 
-  const track = Effect.fnUntraced(function* (
-    fs: AppFileSystem.Interface,
-    git: Git.Interface,
-    cwd: string,
-    ref: string | undefined,
-  ) {
-    if (!ref) return yield* files(fs, git, cwd, ref, yield* git.status(cwd), new Map())
-    const [list, stats] = yield* Effect.all([git.status(cwd), git.stats(cwd, ref)], { concurrency: 2 })
-    return yield* files(fs, git, cwd, ref, list, nums(stats))
+const files = Effect.fnUntraced(function* (
+  fs: AppFileSystem.Interface,
+  git: Git.Interface,
+  cwd: string,
+  ref: string | undefined,
+  list: Git.Item[],
+  map: Map<string, { additions: number; deletions: number }>,
+) {
+  const base = ref ? yield* git.prefix(cwd) : ""
+  const patch = (file: string, before: string, after: string) =>
+    formatPatch(structuredPatch(file, file, before, after, "", "", { context: Number.MAX_SAFE_INTEGER }))
+  const next = yield* Effect.forEach(
+    list,
+    (item) =>
+      Effect.gen(function* () {
+        const before = item.status === "added" || !ref ? "" : yield* git.show(cwd, ref, item.file, base)
+        const after = item.status === "deleted" ? "" : yield* work(fs, cwd, item.file)
+        const stat = map.get(item.file)
+        return {
+          file: item.file,
+          patch: patch(item.file, before, after),
+          additions: stat?.additions ?? (item.status === "added" ? count(after) : 0),
+          deletions: stat?.deletions ?? (item.status === "deleted" ? count(before) : 0),
+          status: item.status,
+        } satisfies FileDiff
+      }),
+    { concurrency: 8 },
+  )
+  return next.toSorted((a, b) => a.file.localeCompare(b.file))
+})
+
+const track = Effect.fnUntraced(function* (
+  fs: AppFileSystem.Interface,
+  git: Git.Interface,
+  cwd: string,
+  ref: string | undefined,
+) {
+  if (!ref) return yield* files(fs, git, cwd, ref, yield* git.status(cwd), new Map())
+  const [list, stats] = yield* Effect.all([git.status(cwd), git.stats(cwd, ref)], { concurrency: 2 })
+  return yield* files(fs, git, cwd, ref, list, nums(stats))
+})
+
+const compare = Effect.fnUntraced(function* (
+  fs: AppFileSystem.Interface,
+  git: Git.Interface,
+  cwd: string,
+  ref: string,
+) {
+  const [list, stats, extra] = yield* Effect.all([git.diff(cwd, ref), git.stats(cwd, ref), git.status(cwd)], {
+    concurrency: 3,
   })
+  return yield* files(
+    fs,
+    git,
+    cwd,
+    ref,
+    merge(
+      list,
+      extra.filter((item) => item.code === "??"),
+    ),
+    nums(stats),
+  )
+})
 
-  const compare = Effect.fnUntraced(function* (
-    fs: AppFileSystem.Interface,
-    git: Git.Interface,
-    cwd: string,
-    ref: string,
-  ) {
-    const [list, stats, extra] = yield* Effect.all([git.diff(cwd, ref), git.stats(cwd, ref), git.status(cwd)], {
-      concurrency: 3,
-    })
-    return yield* files(
-      fs,
-      git,
-      cwd,
-      ref,
-      merge(
-        list,
-        extra.filter((item) => item.code === "??"),
-      ),
-      nums(stats),
-    )
+export const Mode = z.enum(["git", "branch"])
+export type Mode = z.infer<typeof Mode>
+
+export const Event = {
+  BranchUpdated: BusEvent.define(
+    "vcs.branch.updated",
+    z.object({
+      branch: z.string().optional(),
+    }),
+  ),
+}
+
+export const Info = z
+  .object({
+    branch: z.string().optional(),
+    default_branch: z.string().optional(),
+  })
+  .meta({
+    ref: "VcsInfo",
+  })
+export type Info = z.infer<typeof Info>
+
+export const FileDiff = z
+  .object({
+    file: z.string(),
+    patch: z.string(),
+    additions: z.number(),
+    deletions: z.number(),
+    status: z.enum(["added", "deleted", "modified"]).optional(),
+  })
+  .meta({
+    ref: "VcsFileDiff",
   })
+export type FileDiff = z.infer<typeof FileDiff>
 
-  export const Mode = z.enum(["git", "branch"])
-  export type Mode = z.infer<typeof Mode>
+export interface Interface {
+  readonly init: () => Effect.Effect<void>
+  readonly branch: () => Effect.Effect<string | undefined>
+  readonly defaultBranch: () => Effect.Effect<string | undefined>
+  readonly diff: (mode: Mode) => Effect.Effect<FileDiff[]>
+}
+
+interface State {
+  current: string | undefined
+  root: Git.Base | undefined
+}
 
-  export const Event = {
-    BranchUpdated: BusEvent.define(
-      "vcs.branch.updated",
-      z.object({
-        branch: z.string().optional(),
+export class Service extends Context.Service<Service, Interface>()("@opencode/Vcs") {}
+
+export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Git.Service | Bus.Service> = Layer.effect(
+  Service,
+  Effect.gen(function* () {
+    const fs = yield* AppFileSystem.Service
+    const git = yield* Git.Service
+    const bus = yield* Bus.Service
+
+    const state = yield* InstanceState.make<State>(
+      Effect.fn("Vcs.state")(function* (ctx) {
+        if (ctx.project.vcs !== "git") {
+          return { current: undefined, root: undefined }
+        }
+
+        const get = Effect.fnUntraced(function* () {
+          return yield* git.branch(ctx.directory)
+        })
+        const [current, root] = yield* Effect.all([git.branch(ctx.directory), git.defaultBranch(ctx.directory)], {
+          concurrency: 2,
+        })
+        const value = { current, root }
+        log.info("initialized", { branch: value.current, default_branch: value.root?.name })
+
+        yield* bus.subscribe(FileWatcher.Event.Updated).pipe(
+          Stream.filter((evt) => evt.properties.file.endsWith("HEAD")),
+          Stream.runForEach((_evt) =>
+            Effect.gen(function* () {
+              const next = yield* get()
+              if (next !== value.current) {
+                log.info("branch changed", { from: value.current, to: next })
+                value.current = next
+                yield* bus.publish(Event.BranchUpdated, { branch: next })
+              }
+            }),
+          ),
+          Effect.forkScoped,
+        )
+
+        return value
       }),
-    ),
-  }
+    )
 
-  export const Info = z
-    .object({
-      branch: z.string().optional(),
-      default_branch: z.string().optional(),
-    })
-    .meta({
-      ref: "VcsInfo",
-    })
-  export type Info = z.infer<typeof Info>
-
-  export const FileDiff = z
-    .object({
-      file: z.string(),
-      patch: z.string(),
-      additions: z.number(),
-      deletions: z.number(),
-      status: z.enum(["added", "deleted", "modified"]).optional(),
-    })
-    .meta({
-      ref: "VcsFileDiff",
-    })
-  export type FileDiff = z.infer<typeof FileDiff>
-
-  export interface Interface {
-    readonly init: () => Effect.Effect<void>
-    readonly branch: () => Effect.Effect<string | undefined>
-    readonly defaultBranch: () => Effect.Effect<string | undefined>
-    readonly diff: (mode: Mode) => Effect.Effect<FileDiff[]>
-  }
-
-  interface State {
-    current: string | undefined
-    root: Git.Base | undefined
-  }
-
-  export class Service extends Context.Service<Service, Interface>()("@opencode/Vcs") {}
-
-  export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Git.Service | Bus.Service> = Layer.effect(
-    Service,
-    Effect.gen(function* () {
-      const fs = yield* AppFileSystem.Service
-      const git = yield* Git.Service
-      const bus = yield* Bus.Service
-
-      const state = yield* InstanceState.make<State>(
-        Effect.fn("Vcs.state")(function* (ctx) {
-          if (ctx.project.vcs !== "git") {
-            return { current: undefined, root: undefined }
-          }
-
-          const get = Effect.fnUntraced(function* () {
-            return yield* git.branch(ctx.directory)
-          })
-          const [current, root] = yield* Effect.all([git.branch(ctx.directory), git.defaultBranch(ctx.directory)], {
-            concurrency: 2,
-          })
-          const value = { current, root }
-          log.info("initialized", { branch: value.current, default_branch: value.root?.name })
-
-          yield* bus.subscribe(FileWatcher.Event.Updated).pipe(
-            Stream.filter((evt) => evt.properties.file.endsWith("HEAD")),
-            Stream.runForEach((_evt) =>
-              Effect.gen(function* () {
-                const next = yield* get()
-                if (next !== value.current) {
-                  log.info("branch changed", { from: value.current, to: next })
-                  value.current = next
-                  yield* bus.publish(Event.BranchUpdated, { branch: next })
-                }
-              }),
-            ),
-            Effect.forkScoped,
+    return Service.of({
+      init: Effect.fn("Vcs.init")(function* () {
+        yield* InstanceState.get(state)
+      }),
+      branch: Effect.fn("Vcs.branch")(function* () {
+        return yield* InstanceState.use(state, (x) => x.current)
+      }),
+      defaultBranch: Effect.fn("Vcs.defaultBranch")(function* () {
+        return yield* InstanceState.use(state, (x) => x.root?.name)
+      }),
+      diff: Effect.fn("Vcs.diff")(function* (mode: Mode) {
+        const value = yield* InstanceState.get(state)
+        if (Instance.project.vcs !== "git") return []
+        if (mode === "git") {
+          return yield* track(
+            fs,
+            git,
+            Instance.directory,
+            (yield* git.hasHead(Instance.directory)) ? "HEAD" : undefined,
           )
+        }
 
-          return value
-        }),
-      )
-
-      return Service.of({
-        init: Effect.fn("Vcs.init")(function* () {
-          yield* InstanceState.get(state)
-        }),
-        branch: Effect.fn("Vcs.branch")(function* () {
-          return yield* InstanceState.use(state, (x) => x.current)
-        }),
-        defaultBranch: Effect.fn("Vcs.defaultBranch")(function* () {
-          return yield* InstanceState.use(state, (x) => x.root?.name)
-        }),
-        diff: Effect.fn("Vcs.diff")(function* (mode: Mode) {
-          const value = yield* InstanceState.get(state)
-          if (Instance.project.vcs !== "git") return []
-          if (mode === "git") {
-            return yield* track(
-              fs,
-              git,
-              Instance.directory,
-              (yield* git.hasHead(Instance.directory)) ? "HEAD" : undefined,
-            )
-          }
-
-          if (!value.root) return []
-          if (value.current && value.current === value.root.name) return []
-          const ref = yield* git.mergeBase(Instance.directory, value.root.ref)
-          if (!ref) return []
-          return yield* compare(fs, git, Instance.directory, ref)
-        }),
-      })
-    }),
-  )
-
-  export const defaultLayer = layer.pipe(
-    Layer.provide(Git.defaultLayer),
-    Layer.provide(AppFileSystem.defaultLayer),
-    Layer.provide(Bus.layer),
-  )
-}
+        if (!value.root) return []
+        if (value.current && value.current === value.root.name) return []
+        const ref = yield* git.mergeBase(Instance.directory, value.root.ref)
+        if (!ref) return []
+        return yield* compare(fs, git, Instance.directory, ref)
+      }),
+    })
+  }),
+)
+
+export const defaultLayer = layer.pipe(
+  Layer.provide(Git.defaultLayer),
+  Layer.provide(AppFileSystem.defaultLayer),
+  Layer.provide(Bus.layer),
+)

+ 1 - 1
packages/opencode/src/server/instance/experimental.ts

@@ -5,7 +5,7 @@ import { ProviderID, ModelID } from "../../provider/schema"
 import { ToolRegistry } from "../../tool/registry"
 import { Worktree } from "../../worktree"
 import { Instance } from "../../project/instance"
-import { Project } from "../../project/project"
+import { Project } from "../../project"
 import { MCP } from "../../mcp"
 import { Session } from "../../session"
 import { Config } from "../../config"

+ 1 - 1
packages/opencode/src/server/instance/index.ts

@@ -6,7 +6,7 @@ import z from "zod"
 import { Format } from "../../format"
 import { TuiRoutes } from "./tui"
 import { Instance } from "../../project/instance"
-import { Vcs } from "../../project/vcs"
+import { Vcs } from "../../project"
 import { Agent } from "../../agent/agent"
 import { Skill } from "../../skill"
 import { Global } from "../../global"

+ 1 - 1
packages/opencode/src/server/instance/project.ts

@@ -2,7 +2,7 @@ import { Hono } from "hono"
 import { describeRoute, validator } from "hono-openapi"
 import { resolver } from "hono-openapi"
 import { Instance } from "../../project/instance"
-import { Project } from "../../project/project"
+import { Project } from "../../project"
 import z from "zod"
 import { ProjectID } from "../../project/schema"
 import { errors } from "../error"

+ 1 - 1
packages/opencode/src/worktree/worktree.ts

@@ -3,7 +3,7 @@ import { NamedError } from "@opencode-ai/shared/util/error"
 import { Global } from "../global"
 import { Instance } from "../project/instance"
 import { InstanceBootstrap } from "../project/bootstrap"
-import { Project } from "../project/project"
+import { Project } from "../project"
 import { Database, eq } from "../storage/db"
 import { ProjectTable } from "../project/project.sql"
 import type { ProjectID } from "../project/schema"

+ 1 - 1
packages/opencode/test/project/migrate-global.test.ts

@@ -1,5 +1,5 @@
 import { describe, expect, test } from "bun:test"
-import { Project } from "../../src/project/project"
+import { Project } from "../../src/project"
 import { Database, eq } from "../../src/storage/db"
 import { SessionTable } from "../../src/session/session.sql"
 import { ProjectTable } from "../../src/project/project.sql"

+ 1 - 1
packages/opencode/test/project/project.test.ts

@@ -1,5 +1,5 @@
 import { describe, expect, test } from "bun:test"
-import { Project } from "../../src/project/project"
+import { Project } from "../../src/project"
 import { Log } from "../../src/util"
 import { $ } from "bun"
 import path from "path"

+ 1 - 1
packages/opencode/test/project/vcs.test.ts

@@ -8,7 +8,7 @@ import { AppRuntime } from "../../src/effect/app-runtime"
 import { FileWatcher } from "../../src/file/watcher"
 import { Instance } from "../../src/project/instance"
 import { GlobalBus } from "../../src/bus/global"
-import { Vcs } from "../../src/project/vcs"
+import { Vcs } from "../../src/project"
 
 // Skip in CI — native @parcel/watcher binding needed
 const describeVcs = FileWatcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip

+ 1 - 1
packages/opencode/test/server/global-session-list.test.ts

@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
 import { Effect } from "effect"
 import z from "zod"
 import { Instance } from "../../src/project/instance"
-import { Project } from "../../src/project/project"
+import { Project } from "../../src/project"
 import { Session as SessionNs } from "../../src/session"
 import { Log } from "../../src/util"
 import { tmpdir } from "../fixture/fixture"