diff --git a/packages/opencode/src/cli/cmd/debug/scrap.ts b/packages/opencode/src/cli/cmd/debug/scrap.ts index 464b165d72..300a7b9656 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 527a6ac952..d66ac252fa 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 dfd018db7e..f38b27e6f8 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 f9f811e711..7608e9c701 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 d8400c52ae..7d34b4bd48 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 f00d8ffd9b..c88eb8e039 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 0000000000..d9f168f6ff --- /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 a8a5218751..b95962ae08 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 9c4ed58ce8..99fe88ff16 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" }) +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 - - 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, - } - } - - export const UpdateInput = z.object({ - projectID: ProjectID.zod, +export const Info = z + .object({ + id: ProjectID.zod, + worktree: z.string(), + vcs: z.literal("git").optional(), name: z.string().optional(), - icon: Info.shape.icon.optional(), - commands: Info.shape.commands.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()), }) - export type UpdateInput = z.infer + .meta({ + ref: "Project", + }) +export type Info = z.infer - // --------------------------------------------------------------------------- - // Effect service - // --------------------------------------------------------------------------- +export const Event = { + Updated: BusEvent.define("project.updated", Info), +} - 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 +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, } +} - export class Service extends Context.Service()("@opencode/Project") {} +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 - type GitResult = { code: number; text: string; stderr: string } +// --------------------------------------------------------------------------- +// Effect service +// --------------------------------------------------------------------------- - 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 +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 +} - 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)), +export class Service extends Context.Service()("@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 db = (fn: (d: Parameters[0] extends (trx: infer D) => any ? D : never) => T) => + Effect.sync(() => Database.use(fn)) + + const emitUpdated = (data: Info) => + Effect.sync(() => + GlobalBus.emit("event", { + directory: "global", + project: data.id, + payload: { type: Event.Updated.type, properties: data }, + }), ) - const db = (fn: (d: Parameters[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 data: DiscoveryResult = yield* Effect.gen(function* () { + const dotgitMatches = yield* fs.up({ targets: [".git"], start: directory }).pipe(Effect.orDie) + const dotgit = dotgitMatches[0] + + if (!dotgit) { + return { + id: ProjectID.global, + worktree: "/", + sandbox: "/", + vcs: fakeVcs, + } + } + + let sandbox = pathSvc.dirname(dotgit) + const gitBinary = yield* Effect.sync(() => which("git")) + let id = yield* readCachedProjectId(dotgit) + + if (!gitBinary) { + return { + id: id ?? ProjectID.global, + worktree: sandbox, + sandbox, + vcs: 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) + })() + + 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) { + 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 } }) - 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* fs.up({ targets: [".git"], start: directory }).pipe(Effect.orDie) - const dotgit = dotgitMatches[0] - - if (!dotgit) { - return { - id: ProjectID.global, - worktree: "/", - sandbox: "/", - vcs: fakeVcs, - } + // 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() }, } - let sandbox = pathSvc.dirname(dotgit) - const gitBinary = yield* Effect.sync(() => which("git")) - let id = yield* readCachedProjectId(dotgit) + if (Flag.OPENCODE_EXPERIMENTAL_ICON_DISCOVERY) + yield* discover(existing).pipe(Effect.ignore, Effect.forkIn(scope)) - if (!gitBinary) { - return { - id: id ?? ProjectID.global, - worktree: sandbox, - sandbox, - vcs: fakeVcs, - } - } + 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))) - 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* fs.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 } - }) - - // 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))) - - yield* db((d) => - d - .insert(ProjectTable) - .values({ - id: result.id, + 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 } + }) - 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 - 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* 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 matches = yield* fs - .glob("**/favicon.{ico,png,svg,jpg,jpeg,webp}", { - cwd: input.worktree, - absolute: true, - include: "file", + 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 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(), }) - .pipe(Effect.orDie) - const shortest = matches.sort((a, b) => a.length - b.length)[0] - if (!shortest) return + .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 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 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 list = Effect.fn("Project.list")(function* () { - return yield* db((d) => d.select().from(ProjectTable).all().map(fromRow)) - }) + 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 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 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 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 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 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 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 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(), - ) - }) + return Service.of({ + fromDirectory, + discover, + list, + get, + update, + initGit, + setInitialized, + sandboxes, + addSandbox, + removeSandbox, + }) + }), +) - 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))) - }) +export const defaultLayer = layer.pipe( + Layer.provide(CrossSpawnSpawner.defaultLayer), + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(NodePath.layer), +) - 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 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 setInitialized(id: ProjectID) { + Database.use((db) => + db.update(ProjectTable).set({ time_initialized: Date.now() }).where(eq(ProjectTable.id, id)).run(), ) - - 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 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(), - ) - } } diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index cb0b46adcb..559371859f 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 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 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() - 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, - ) { - 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), - ) - }) - - export const Mode = z.enum(["git", "branch"]) - export type Mode = z.infer - - 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 - - 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 - - export interface Interface { - readonly init: () => Effect.Effect - readonly branch: () => Effect.Effect - readonly defaultBranch: () => Effect.Effect - readonly diff: (mode: Mode) => Effect.Effect - } - - interface State { - current: string | undefined - root: Git.Base | undefined - } - - export class Service extends Context.Service()("@opencode/Vcs") {} - - export const layer: Layer.Layer = 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( - 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 - }), - ) - - 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), - ) +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 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() + 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, +) { + 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), + ) +}) + +export const Mode = z.enum(["git", "branch"]) +export type Mode = z.infer + +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 + +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 + +export interface Interface { + readonly init: () => Effect.Effect + readonly branch: () => Effect.Effect + readonly defaultBranch: () => Effect.Effect + readonly diff: (mode: Mode) => Effect.Effect +} + +interface State { + current: string | undefined + root: Git.Base | undefined +} + +export class Service extends Context.Service()("@opencode/Vcs") {} + +export const layer: Layer.Layer = 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( + 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 + }), + ) + + 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), +) diff --git a/packages/opencode/src/server/instance/experimental.ts b/packages/opencode/src/server/instance/experimental.ts index 6e1a47ed20..610d67df08 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 874790f1cc..9ef6da63ac 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 7a8e0353a2..eea741596d 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 86ef95f0e6..8eea6445aa 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 c399d8872d..a63ac1cd98 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 4c272b7949..4dc9ee5efa 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 5461de5c33..8f0eaecc27 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 0edabd8e65..d0f71b8fd3 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"