| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299 |
- export * as ProjectCopy from "./copy"
- import { Context, Effect, Layer, Schema } from "effect"
- import path from "path"
- import { AbsolutePath } from "../schema"
- import { FSUtil } from "../fs-util"
- import { Git } from "../git"
- import { LayerNode } from "../effect/layer-node"
- import { Project } from "../project"
- import { ProjectDirectories } from "./directories"
- import { makeGitWorktreeStrategy } from "./copy-strategies"
- import { Slug } from "../util/slug"
- import { EventV2 } from "../event"
- import { Database } from "../database/database"
- import { Location } from "../location"
- import { PluginBoot } from "../plugin/boot"
- export const StrategyID = Schema.Trim.pipe(Schema.check(Schema.isNonEmpty()), Schema.brand("ProjectCopy.StrategyID"))
- export type StrategyID = typeof StrategyID.Type
- export const CreateInput = Schema.Struct({
- projectID: Project.ID,
- strategy: StrategyID,
- sourceDirectory: AbsolutePath,
- directory: AbsolutePath,
- name: Schema.optional(Schema.String),
- }).annotate({ identifier: "ProjectCopy.CreateInput" })
- export type CreateInput = typeof CreateInput.Type
- export const RemoveInput = Schema.Struct({
- projectID: Project.ID,
- directory: AbsolutePath,
- force: Schema.Boolean,
- }).annotate({ identifier: "ProjectCopy.RemoveInput" })
- export type RemoveInput = typeof RemoveInput.Type
- export const RefreshInput = Schema.Struct({
- projectID: Project.ID,
- }).annotate({ identifier: "ProjectCopy.RefreshInput" })
- export type RefreshInput = typeof RefreshInput.Type
- export const RefreshResult = Schema.Struct({
- updated: Schema.Array(AbsolutePath),
- removed: Schema.Array(AbsolutePath),
- }).annotate({ identifier: "ProjectCopy.RefreshResult" })
- export type RefreshResult = typeof RefreshResult.Type
- export const Copy = Schema.Struct({
- directory: AbsolutePath,
- }).annotate({ identifier: "ProjectCopy.Copy" })
- export type Copy = typeof Copy.Type
- export const ListEntry = Schema.Struct({
- directory: AbsolutePath,
- type: Schema.Literals(["root", "copy"]),
- }).annotate({ identifier: "ProjectCopy.ListEntry" })
- export type ListEntry = typeof ListEntry.Type
- export class SourceDirectoryNotFoundError extends Schema.TaggedErrorClass<SourceDirectoryNotFoundError>()(
- "ProjectCopy.SourceDirectoryNotFoundError",
- { directory: AbsolutePath },
- ) {}
- export class DestinationExistsError extends Schema.TaggedErrorClass<DestinationExistsError>()(
- "ProjectCopy.DestinationExistsError",
- { directory: AbsolutePath },
- ) {}
- export class DirectoryUnavailableError extends Schema.TaggedErrorClass<DirectoryUnavailableError>()(
- "ProjectCopy.DirectoryUnavailableError",
- { directory: AbsolutePath },
- ) {}
- export class InvalidDirectoryError extends Schema.TaggedErrorClass<InvalidDirectoryError>()(
- "ProjectCopy.InvalidDirectoryError",
- { directory: AbsolutePath },
- ) {}
- export class StrategyUnavailableError extends Schema.TaggedErrorClass<StrategyUnavailableError>()(
- "ProjectCopy.StrategyUnavailableError",
- { strategy: StrategyID },
- ) {}
- export class DuplicateStrategyError extends Schema.TaggedErrorClass<DuplicateStrategyError>()(
- "ProjectCopy.DuplicateStrategyError",
- { strategy: StrategyID },
- ) {}
- export type Error =
- | SourceDirectoryNotFoundError
- | DestinationExistsError
- | DirectoryUnavailableError
- | InvalidDirectoryError
- | StrategyUnavailableError
- | Git.WorktreeError
- export interface Strategy {
- readonly id: StrategyID
- readonly create: (input: {
- sourceDirectory: AbsolutePath
- directory: AbsolutePath
- }) => Effect.Effect<Copy, Git.WorktreeError | DirectoryUnavailableError>
- readonly remove: (input: {
- directory: AbsolutePath
- force: boolean
- }) => Effect.Effect<void, Git.WorktreeError | DirectoryUnavailableError>
- readonly list: (directory: AbsolutePath) => Effect.Effect<ListEntry[], Git.WorktreeError | DirectoryUnavailableError>
- }
- export const Event = {
- Updated: EventV2.define({
- type: "project.directories.updated",
- schema: { projectID: Project.ID },
- }),
- }
- export interface Interface {
- readonly register: (strategy: Strategy) => Effect.Effect<void, DuplicateStrategyError>
- readonly create: (input: CreateInput) => Effect.Effect<Copy, Error>
- readonly remove: (input: RemoveInput) => Effect.Effect<void, Error>
- readonly refresh: (input: RefreshInput) => Effect.Effect<RefreshResult, Error>
- }
- export class Service extends Context.Service<Service, Interface>()("@opencode/ProjectCopy") {}
- export const refreshAfterBoot = Effect.gen(function* () {
- const location = yield* Location.Service
- const boot = yield* PluginBoot.Service
- const copies = yield* Service
- yield* Effect.gen(function* () {
- yield* boot.wait()
- yield* Effect.logInfo("project copy refresh started", { projectID: location.project.id })
- const result = yield* copies.refresh({ projectID: location.project.id })
- yield* Effect.logInfo("project copy refresh done", {
- projectID: location.project.id,
- updated: result.updated,
- removed: result.removed,
- })
- }).pipe(
- Effect.catchCause((cause) => Effect.logWarning("project copy refresh failed", { cause })),
- Effect.forkScoped,
- Effect.asVoid,
- )
- })
- export const layer = Layer.effect(
- Service,
- Effect.gen(function* () {
- const fs = yield* FSUtil.Service
- const git = yield* Git.Service
- const directories = yield* ProjectDirectories.Service
- const db = (yield* Database.Service).db
- const events = yield* EventV2.Service
- const changed = Effect.fnUntraced(function* (projectID: Project.ID, update: boolean) {
- if (update) yield* events.publish(Event.Updated, { projectID })
- })
- const canonical = Effect.fnUntraced(function* (input: AbsolutePath) {
- const resolved = AbsolutePath.make(FSUtil.resolve(input))
- if (!(yield* fs.isDir(resolved))) return yield* new DirectoryUnavailableError({ directory: input })
- return resolved
- })
- const registry = new Map<StrategyID, Strategy>()
- const register = Effect.fn("ProjectCopy.register")(function* (strategy: Strategy) {
- if (registry.has(strategy.id)) return yield* new DuplicateStrategyError({ strategy: strategy.id })
- registry.set(strategy.id, strategy)
- })
- // Register default strategies
- yield* register(makeGitWorktreeStrategy({ git, canonical })).pipe(Effect.orDie)
- const strategies = () => Array.from(registry.values())
- const source = Effect.fnUntraced(function* (input: AbsolutePath, projectID: Project.ID) {
- const sourceDirectory = yield* canonical(input)
- if (!(yield* directories.contains({ projectID, directory: sourceDirectory })))
- return yield* new SourceDirectoryNotFoundError({ directory: sourceDirectory })
- return sourceDirectory
- })
- const getStrategy = Effect.fnUntraced(function* (id: StrategyID) {
- const found = registry.get(id)
- if (!found) return yield* new StrategyUnavailableError({ strategy: id })
- return found
- })
- const create = Effect.fn("ProjectCopy.create")(function* (input: CreateInput) {
- const selected = yield* getStrategy(input.strategy)
- const sourceDirectory = yield* source(input.sourceDirectory, input.projectID)
- yield* fs.makeDirectory(input.directory, { recursive: true }).pipe(Effect.orDie)
- const name = input.name ?? Slug.create()
- let suffix = 1
- let copyDirectory = AbsolutePath.make(path.join(input.directory, name))
- while (yield* fs.existsSafe(copyDirectory)) {
- suffix++
- if (suffix > 10) return yield* new DestinationExistsError({ directory: copyDirectory })
- copyDirectory = AbsolutePath.make(path.join(input.directory, `${name}-${suffix}`))
- }
- const result = yield* selected.create({
- directory: copyDirectory,
- sourceDirectory,
- })
- yield* changed(
- input.projectID,
- yield* directories.create({
- projectID: input.projectID,
- directory: result.directory,
- strategy: input.strategy,
- behavior: "replace",
- }),
- )
- return result
- })
- const remove = Effect.fn("ProjectCopy.remove")(function* (input: RemoveInput) {
- const copyDirectory = yield* canonical(input.directory)
- const stored = yield* directories.get({ projectID: input.projectID, directory: copyDirectory })
- if (!stored?.strategy) return yield* new InvalidDirectoryError({ directory: copyDirectory })
- yield* (yield* getStrategy(StrategyID.make(stored.strategy))).remove({
- directory: copyDirectory,
- force: input.force,
- })
- yield* changed(
- input.projectID,
- yield* directories.remove({ projectID: input.projectID, directory: copyDirectory }),
- )
- })
- const refresh = Effect.fn("ProjectCopy.refresh")(function* (input: RefreshInput) {
- const stored = yield* directories.list(input.projectID)
- const checked = yield* Effect.forEach(
- stored,
- (item) => fs.isDir(item.directory).pipe(Effect.map((exists) => ({ ...item, exists }))),
- { concurrency: "unbounded" },
- )
- const sourceDirectories = checked
- .filter((item) => item.strategy === undefined && item.exists)
- .map((item) => item.directory)
- const discovered = yield* Effect.forEach(
- sourceDirectories,
- (sourceDirectory) =>
- Effect.forEach(strategies(), (strategy) =>
- strategy.list(sourceDirectory).pipe(
- Effect.map((items) =>
- items.map((item) => ({
- directory: item.directory,
- strategy: item.type === "copy" ? strategy.id : undefined,
- })),
- ),
- ),
- ),
- { concurrency: "unbounded" },
- ).pipe(
- Effect.map((sets) => new Map(sets.flat(2).map((item) => [item.directory, item] as const)).values().toArray()),
- )
- const removed = checked.filter((item) => !item.exists).map((item) => item.directory)
- const result = yield* db
- .transaction((tx) =>
- Effect.all({
- updated: Effect.forEach(discovered, (item) =>
- directories.create(
- {
- projectID: input.projectID,
- directory: item.directory,
- strategy: item.strategy,
- behavior: "replace",
- },
- tx,
- ),
- ),
- removed: Effect.forEach(removed, (directory) =>
- directories.remove({ projectID: input.projectID, directory }, tx),
- ),
- }),
- )
- .pipe(Effect.orDie)
- const changes = {
- updated: discovered.filter((_, index) => result.updated[index]).map((item) => item.directory),
- removed: removed.filter((_, index) => result.removed[index]),
- }
- yield* changed(input.projectID, changes.updated.length > 0 || changes.removed.length > 0)
- return changes
- })
- return Service.of({
- register,
- create,
- remove,
- refresh,
- })
- }),
- )
- export const locationLayer = layer
- export const node = LayerNode.make(layer, [FSUtil.node, Git.node, ProjectDirectories.node, EventV2.node, Database.node])
|