export * as ProjectV2 from "./project" export * as Project from "./project" import { Context, Effect, Layer, Schema } from "effect" import { asc, desc, eq } from "drizzle-orm" import path from "path" import { AbsolutePath, withStatics } from "./schema" import { FSUtil } from "./fs-util" import { Database } from "./database/database" import { Git } from "./git" import { LayerNode } from "./effect/layer-node" import { Hash } from "./util/hash" import { ProjectDirectoryTable } from "./project/sql" export const ID = Schema.String.pipe( Schema.brand("Project.ID"), withStatics((schema) => ({ global: schema.make("global"), })), ) export type ID = typeof ID.Type export const Vcs = Schema.Union([ Schema.Struct({ type: Schema.Literal("git"), store: AbsolutePath, }), ]) export type Vcs = typeof Vcs.Type export class Info extends Schema.Class("Project.Info")({ id: ID, }) {} export const DirectoriesInput = Schema.Struct({ projectID: ID, }).annotate({ identifier: "Project.DirectoriesInput" }) export type DirectoriesInput = typeof DirectoriesInput.Type export const Directories = Schema.Array( Schema.Struct({ directory: AbsolutePath, type: Schema.Literals(["main", "root", "git_worktree"]), }), ).annotate({ identifier: "Project.Directories" }) export type Directories = typeof Directories.Type export interface Interface { readonly directories: (input: DirectoriesInput) => Effect.Effect readonly resolve: (input: AbsolutePath) => Effect.Effect< { previous?: ID id: ID directory: AbsolutePath vcs?: Vcs }, never > /** * Temporary bridge method for writing the resolved project ID to the repo-local cache. * * This exists while the old opencode project service and this core project * service work together: core resolves the ID, while the old service still owns * database migration and persistence. The old service should call this after it * finishes migrating from `resolve().previous` to `resolve().id`; once project * persistence moves into core, this separate bridge method can go away. */ readonly commit: (input: { store: AbsolutePath; id: ID }) => Effect.Effect } export class Service extends Context.Service()("@opencode/ProjectV2") {} export const layer = Layer.effect( Service, Effect.gen(function* () { const db = (yield* Database.Service).db const fs = yield* FSUtil.Service const git = yield* Git.Service const directories = Effect.fn("Project.directories")(function* (input: DirectoriesInput) { const rows = yield* db .select({ directory: ProjectDirectoryTable.directory, type: ProjectDirectoryTable.type }) .from(ProjectDirectoryTable) .where(eq(ProjectDirectoryTable.project_id, input.projectID)) .orderBy(desc(ProjectDirectoryTable.time_created), asc(ProjectDirectoryTable.directory)) .all() .pipe(Effect.orDie) return rows.map((row) => ({ directory: AbsolutePath.make(row.directory), type: row.type })) }) const cached = Effect.fnUntraced(function* (dir: string) { return yield* fs.readFileString(path.join(dir, "opencode")).pipe( Effect.map((value) => value.trim()), Effect.map((value) => (value ? ID.make(value) : undefined)), Effect.catch(() => Effect.succeed(undefined)), ) }) const remote = Effect.fnUntraced(function* (repo: Git.Repo) { const origin = yield* git.remote(repo) if (!origin) return undefined const normalized = url(origin) if (!normalized) return undefined return ID.make(Hash.fast(`git-remote:${normalized}`)) }) function url(input: string) { const value = input.trim() if (!value) return undefined try { const parsed = new URL(value) if (parsed.protocol === "file:") return undefined return parts(parsed.hostname, parsed.pathname) } catch { const scp = value.match(/^([^@/:]+@)?([^/:]+):(.+)$/) if (scp) return parts(scp[2], scp[3]) return undefined } } function parts(host: string, name: string) { const pathname = name .replace(/^\/+/, "") .replace(/\.git\/?$/, "") .replace(/\/+$/, "") if (!host || !pathname) return undefined return `${host.toLowerCase()}/${pathname}` } const root = Effect.fnUntraced(function* (repo: Git.Repo) { const root = (yield* git.roots(repo))[0] return root ? ID.make(root) : undefined }) const resolve = Effect.fn("Project.resolve")(function* (input: AbsolutePath) { const repo = yield* git.find(input) if (!repo) return { id: ID.global, directory: AbsolutePath.make(path.parse(input).root), vcs: undefined } const previous = yield* cached(repo.store) const id = (yield* remote(repo)) ?? previous ?? (yield* root(repo)) return { previous, id: id ?? ID.global, directory: repo.directory, vcs: { type: "git" as const, store: repo.store }, } }) const commit = Effect.fn("Project.commit")(function* (input: { store: AbsolutePath; id: ID }) { yield* fs.writeFileString(path.join(input.store, "opencode"), input.id).pipe(Effect.ignore) }) return Service.of({ directories, resolve, commit }) }), ) export const defaultLayer = layer.pipe( Layer.provide(Database.defaultLayer), Layer.provide(FSUtil.defaultLayer), Layer.provide(Git.defaultLayer), ) export const node = LayerNode.make(layer, [Database.node, FSUtil.node, Git.node])