/
Alex_Ural
/
opencode
Обзор
Документация
Войти
/
Alex_Ural
/
opencode
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
Аналитика
Безопасность
dev
packages/core/src/project/copy.ts
292 строки
11 KB
James Long
refactor(core): move path resolve into fs service (#35202)
03 июл 2026, 20:25
Не верифицирован
03 июл 2026, 20:25
96d53c6
Код
Авторство
О чём код?
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 { makeLocationNode } from "../effect/app-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 { Event } from "@opencode-ai/schema/project-directories" import { ProjectCopy } from "@opencode-ai/schema/project-copy" export const StrategyID = ProjectCopy.StrategyID export type StrategyID = typeof StrategyID.Type export const CreateInput = ProjectCopy.CreateInput export type CreateInput = typeof CreateInput.Type export const RemoveInput = 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 = 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 { Event } 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 copies = yield* Service yield* Effect.gen(function* () { 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, ) }) 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(yield* fs.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.catchTag("ProjectCopy.DirectoryUnavailableError", () => Effect.succeed([])), 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 = makeLocationNode({ service: Service, layer: layer, deps: [FSUtil.node, Git.node, ProjectDirectories.node, EventV2.node, Database.node], }) export const refreshNode = makeLocationNode({ name: "project-copy-refresh", layer: Layer.effectDiscard(refreshAfterBoot), deps: [node, Location.node], })