| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163 |
- 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<Info>("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<Directories>
- 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<void>
- }
- export class Service extends Context.Service<Service, Interface>()("@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])
|