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