summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--packages/opencode/src/cli/cmd/debug/scrap.ts2
-rw-r--r--packages/opencode/src/cli/cmd/stats.ts2
-rw-r--r--packages/opencode/src/control-plane/workspace.ts2
-rw-r--r--packages/opencode/src/effect/app-runtime.ts4
-rw-r--r--packages/opencode/src/effect/bootstrap-runtime.ts2
-rw-r--r--packages/opencode/src/project/bootstrap.ts4
-rw-r--r--packages/opencode/src/project/index.ts2
-rw-r--r--packages/opencode/src/project/instance.ts2
-rw-r--r--packages/opencode/src/project/project.ts826
-rw-r--r--packages/opencode/src/project/vcs.ts412
-rw-r--r--packages/opencode/src/server/instance/experimental.ts2
-rw-r--r--packages/opencode/src/server/instance/index.ts2
-rw-r--r--packages/opencode/src/server/instance/project.ts2
-rw-r--r--packages/opencode/src/worktree/worktree.ts2
-rw-r--r--packages/opencode/test/project/migrate-global.test.ts2
-rw-r--r--packages/opencode/test/project/project.test.ts2
-rw-r--r--packages/opencode/test/project/vcs.test.ts2
-rw-r--r--packages/opencode/test/server/global-session-list.test.ts2
18 files changed, 636 insertions, 638 deletions
diff --git a/packages/opencode/src/cli/cmd/debug/scrap.ts b/packages/opencode/src/cli/cmd/debug/scrap.ts
index 464b165d7..300a7b965 100644
--- a/packages/opencode/src/cli/cmd/debug/scrap.ts
+++ b/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"
diff --git a/packages/opencode/src/cli/cmd/stats.ts b/packages/opencode/src/cli/cmd/stats.ts
index 527a6ac95..d66ac252f 100644
--- a/packages/opencode/src/cli/cmd/stats.ts
+++ b/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"
diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts
index dfd018db7..f38b27e6f 100644
--- a/packages/opencode/src/control-plane/workspace.ts
+++ b/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"
diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts
index f9f811e71..7608e9c70 100644
--- a/packages/opencode/src/effect/app-runtime.ts
+++ b/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"
diff --git a/packages/opencode/src/effect/bootstrap-runtime.ts b/packages/opencode/src/effect/bootstrap-runtime.ts
index d8400c52a..7d34b4bd4 100644
--- a/packages/opencode/src/effect/bootstrap-runtime.ts
+++ b/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"
diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts
index f00d8ffd9..c88eb8e03 100644
--- a/packages/opencode/src/project/bootstrap.ts
+++ b/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"
diff --git a/packages/opencode/src/project/index.ts b/packages/opencode/src/project/index.ts
new file mode 100644
index 000000000..d9f168f6f
--- /dev/null
+++ b/packages/opencode/src/project/index.ts
@@ -0,0 +1,2 @@
+export * as Vcs from "./vcs"
+export * as Project from "./project"
diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts
index a8a521875..b95962ae0 100644
--- a/packages/opencode/src/project/instance.ts
+++ b/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 {
diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts
index 9c4ed58ce..99fe88ff1 100644
--- a/packages/opencode/src/project/project.ts
+++ b/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(),
+ )
}
diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts
index cb0b46adc..559371859 100644
--- a/packages/opencode/src/project/vcs.ts
+++ b/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),
+)
diff --git a/packages/opencode/src/server/instance/experimental.ts b/packages/opencode/src/server/instance/experimental.ts
index 6e1a47ed2..610d67df0 100644
--- a/packages/opencode/src/server/instance/experimental.ts
+++ b/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"
diff --git a/packages/opencode/src/server/instance/index.ts b/packages/opencode/src/server/instance/index.ts
index 874790f1c..9ef6da63a 100644
--- a/packages/opencode/src/server/instance/index.ts
+++ b/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"
diff --git a/packages/opencode/src/server/instance/project.ts b/packages/opencode/src/server/instance/project.ts
index 7a8e0353a..eea741596 100644
--- a/packages/opencode/src/server/instance/project.ts
+++ b/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"
diff --git a/packages/opencode/src/worktree/worktree.ts b/packages/opencode/src/worktree/worktree.ts
index 86ef95f0e..8eea6445a 100644
--- a/packages/opencode/src/worktree/worktree.ts
+++ b/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"
diff --git a/packages/opencode/test/project/migrate-global.test.ts b/packages/opencode/test/project/migrate-global.test.ts
index c399d8872..a63ac1cd9 100644
--- a/packages/opencode/test/project/migrate-global.test.ts
+++ b/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"
diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts
index 4c272b794..4dc9ee5ef 100644
--- a/packages/opencode/test/project/project.test.ts
+++ b/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"
diff --git a/packages/opencode/test/project/vcs.test.ts b/packages/opencode/test/project/vcs.test.ts
index 5461de5c3..8f0eaecc2 100644
--- a/packages/opencode/test/project/vcs.test.ts
+++ b/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
diff --git a/packages/opencode/test/server/global-session-list.test.ts b/packages/opencode/test/server/global-session-list.test.ts
index 0edabd8e6..d0f71b8fd 100644
--- a/packages/opencode/test/server/global-session-list.test.ts
+++ b/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"