mirror of
https://github.com/anomalyco/opencode.git
synced 2026-06-01 23:09:37 +00:00
129 lines
4 KiB
TypeScript
129 lines
4 KiB
TypeScript
export * as Project from "./project"
|
|
|
|
import { Context, Effect, Layer, Schema } from "effect"
|
|
import path from "path"
|
|
import { AbsolutePath, withStatics } from "./schema"
|
|
import { AppFileSystem } from "./filesystem"
|
|
import { Git } from "./git"
|
|
import { Hash } from "./util/hash"
|
|
|
|
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,
|
|
vcs: Schema.optional(Vcs),
|
|
}) {}
|
|
|
|
export interface Interface {
|
|
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 fs = yield* AppFileSystem.Service
|
|
const git = yield* Git.Service
|
|
|
|
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: input, 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({ resolve, commit })
|
|
}),
|
|
)
|
|
|
|
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Git.defaultLayer))
|