feat(core): project copying and tracking directories (#30139)
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE `project_directory` (
|
||||
`project_id` text NOT NULL,
|
||||
`directory` text NOT NULL,
|
||||
`type` text NOT NULL,
|
||||
`time_created` integer NOT NULL,
|
||||
CONSTRAINT `project_directory_pk` PRIMARY KEY(`project_id`, `directory`),
|
||||
CONSTRAINT `fk_project_directory_project_id_project_id_fk` FOREIGN KEY (`project_id`) REFERENCES `project`(`id`) ON DELETE CASCADE
|
||||
);
|
||||
1746
packages/core/migration/20260602182828_add_project_directories/snapshot.json
generated
Normal file
1746
packages/core/migration/20260602182828_add_project_directories/snapshot.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
1
packages/core/src/database/migration.gen.ts
generated
1
packages/core/src/database/migration.gen.ts
generated
@@ -26,5 +26,6 @@ export const migrations = (
|
||||
import("./migration/20260601010001_normalize_storage_paths"),
|
||||
import("./migration/20260601202201_amazing_prowler"),
|
||||
import("./migration/20260602002951_lowly_union_jack"),
|
||||
import("./migration/20260602182828_add_project_directories"),
|
||||
])
|
||||
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260602182828_add_project_directories",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`
|
||||
CREATE TABLE \`project_directory\` (
|
||||
\`project_id\` text NOT NULL,
|
||||
\`directory\` text NOT NULL,
|
||||
\`type\` text NOT NULL,
|
||||
\`time_created\` integer NOT NULL,
|
||||
CONSTRAINT \`project_directory_pk\` PRIMARY KEY(\`project_id\`, \`directory\`),
|
||||
CONSTRAINT \`fk_project_directory_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as Git from "./git"
|
||||
|
||||
import path from "path"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { AbsolutePath } from "./schema"
|
||||
import { FSUtil } from "./fs-util"
|
||||
@@ -26,6 +26,13 @@ export interface Repo {
|
||||
readonly store: AbsolutePath
|
||||
}
|
||||
|
||||
export class WorktreeError extends Schema.TaggedErrorClass<WorktreeError>()("Git.WorktreeError", {
|
||||
operation: Schema.Literals(["create", "remove", "list"]),
|
||||
message: Schema.String,
|
||||
directory: Schema.optional(AbsolutePath),
|
||||
cause: Schema.optional(Schema.Defect),
|
||||
}) {}
|
||||
|
||||
export interface Interface {
|
||||
readonly find: (input: AbsolutePath) => Effect.Effect<Repo | undefined>
|
||||
readonly remote: (repo: Repo, name?: string) => Effect.Effect<string | undefined>
|
||||
@@ -45,6 +52,9 @@ export interface Interface {
|
||||
readonly fetchBranch: (directory: string, branch: string) => Effect.Effect<Result, AppProcess.AppProcessError>
|
||||
readonly checkout: (directory: string, branch: string) => Effect.Effect<Result, AppProcess.AppProcessError>
|
||||
readonly reset: (directory: string, target: string) => Effect.Effect<Result, AppProcess.AppProcessError>
|
||||
readonly worktreeCreate: (input: { repo: Repo; directory: AbsolutePath }) => Effect.Effect<void, WorktreeError>
|
||||
readonly worktreeRemove: (input: { repo: Repo; directory: AbsolutePath }) => Effect.Effect<void, WorktreeError>
|
||||
readonly worktreeList: (repo: Repo) => Effect.Effect<AbsolutePath[], WorktreeError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/GitV2") {}
|
||||
@@ -149,6 +159,43 @@ export const layer = Layer.effect(
|
||||
execute(directory, proc)(["reset", "--hard", target]),
|
||||
)
|
||||
|
||||
const worktree = Effect.fnUntraced(function* (
|
||||
operation: "create" | "remove" | "list",
|
||||
repo: Repo,
|
||||
args: string[],
|
||||
worktreeDirectory?: AbsolutePath,
|
||||
cwd = repo.directory,
|
||||
) {
|
||||
const result = yield* proc
|
||||
.run(ChildProcess.make("git", args, { cwd, extendEnv: true, stdin: "ignore" }))
|
||||
.pipe(
|
||||
Effect.mapError(
|
||||
(cause) => new WorktreeError({ operation, directory: worktreeDirectory, message: cause.message, cause }),
|
||||
),
|
||||
)
|
||||
if (result.exitCode === 0) return result.stdout.toString("utf8")
|
||||
return yield* new WorktreeError({
|
||||
operation,
|
||||
directory: worktreeDirectory,
|
||||
message: result.stderr.toString("utf8").trim() || result.stdout.toString("utf8").trim() || "Git failed",
|
||||
})
|
||||
})
|
||||
|
||||
const worktreeCreate = Effect.fn("Git.worktreeCreate")(function* (input: { repo: Repo; directory: AbsolutePath }) {
|
||||
yield* worktree("create", input.repo, ["worktree", "add", "--detach", input.directory, "HEAD"], input.directory)
|
||||
})
|
||||
|
||||
const worktreeRemove = Effect.fn("Git.worktreeRemove")(function* (input: { repo: Repo; directory: AbsolutePath }) {
|
||||
yield* worktree("remove", input.repo, ["worktree", "remove", "--force", input.directory], input.directory, input.repo.store)
|
||||
})
|
||||
|
||||
const worktreeList = Effect.fn("Git.worktreeList")(function* (repo: Repo) {
|
||||
return (yield* worktree("list", repo, ["worktree", "list", "--porcelain"]))
|
||||
.split("\n")
|
||||
.filter((line) => line.startsWith("worktree "))
|
||||
.map((line) => AbsolutePath.make(resolvePath(repo.directory, line.slice("worktree ".length).trim())))
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
find,
|
||||
remote,
|
||||
@@ -163,6 +210,9 @@ export const layer = Layer.effect(
|
||||
fetchBranch,
|
||||
checkout,
|
||||
reset,
|
||||
worktreeCreate,
|
||||
worktreeRemove,
|
||||
worktreeList,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -2,11 +2,14 @@ export * as ProjectV2 from "./project"
|
||||
export * as Project from "./project"
|
||||
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { 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 { Hash } from "./util/hash"
|
||||
import { ProjectDirectoryTable } from "./project/sql"
|
||||
|
||||
export const ID = Schema.String.pipe(
|
||||
Schema.brand("Project.ID"),
|
||||
@@ -28,7 +31,16 @@ 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(AbsolutePath).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
|
||||
@@ -55,9 +67,22 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Pr
|
||||
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 })
|
||||
.from(ProjectDirectoryTable)
|
||||
.where(eq(ProjectDirectoryTable.project_id, input.projectID))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
return rows
|
||||
.toSorted((a, b) => a.directory.localeCompare(b.directory))
|
||||
.map((row) => AbsolutePath.make(row.directory))
|
||||
})
|
||||
|
||||
const cached = Effect.fnUntraced(function* (dir: string) {
|
||||
return yield* fs.readFileString(path.join(dir, "opencode")).pipe(
|
||||
Effect.map((value) => value.trim()),
|
||||
@@ -109,7 +134,6 @@ export const layer = Layer.effect(
|
||||
|
||||
const previous = yield* cached(repo.store)
|
||||
const id = (yield* remote(repo)) ?? previous ?? (yield* root(repo))
|
||||
|
||||
return {
|
||||
previous,
|
||||
id: id ?? ID.global,
|
||||
@@ -122,8 +146,12 @@ export const layer = Layer.effect(
|
||||
yield* fs.writeFileString(path.join(input.store, "opencode"), input.id).pipe(Effect.ignore)
|
||||
})
|
||||
|
||||
return Service.of({ resolve, commit })
|
||||
return Service.of({ directories, resolve, commit })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(Git.defaultLayer))
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(Git.defaultLayer),
|
||||
)
|
||||
|
||||
38
packages/core/src/project/copy-strategies.ts
Normal file
38
packages/core/src/project/copy-strategies.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { AbsolutePath } from "../schema"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { Git } from "../git"
|
||||
import { DirectoryUnavailableError, type Copy, type Strategy, type StrategyID } from "./copy"
|
||||
|
||||
export function makeStrategies(input: {
|
||||
git: Git.Interface
|
||||
fs: FSUtil.Interface
|
||||
canonical: (directory: AbsolutePath) => Effect.Effect<AbsolutePath, DirectoryUnavailableError>
|
||||
}) {
|
||||
const repo = (sourceDirectory: AbsolutePath) => ({ directory: sourceDirectory, store: sourceDirectory }) satisfies Git.Repo
|
||||
|
||||
const gitWorktree: Strategy = {
|
||||
id: "git_worktree",
|
||||
create: Effect.fn("ProjectCopy.GitWorktree.create")(function* (options) {
|
||||
yield* input.git.worktreeCreate({ repo: repo(options.sourceDirectory), directory: options.directory })
|
||||
return { directory: yield* input.canonical(options.directory) }
|
||||
}),
|
||||
remove: Effect.fn("ProjectCopy.GitWorktree.remove")(function* (directory) {
|
||||
const found = yield* input.git.find(directory)
|
||||
if (!found) return yield* new DirectoryUnavailableError({ directory })
|
||||
yield* input.git.worktreeRemove({ repo: found, directory })
|
||||
}),
|
||||
list: Effect.fn("ProjectCopy.GitWorktree.list")(function* (directory) {
|
||||
const entries = yield* input.git.worktreeList(repo(directory))
|
||||
return yield* Effect.forEach(entries, (entry) =>
|
||||
entry === directory ? Effect.succeed(undefined) : input.canonical(entry).pipe(Effect.map((directory) => ({ directory }))),
|
||||
).pipe(Effect.map((items) => items.filter((item): item is Copy => item !== undefined)))
|
||||
}),
|
||||
detect: Effect.fn("ProjectCopy.GitWorktree.detect")(function* (inputDirectory) {
|
||||
return yield* input.fs.isFile(path.join(inputDirectory, ".git"))
|
||||
}),
|
||||
}
|
||||
|
||||
return new Map<StrategyID, Strategy>([[gitWorktree.id, gitWorktree]])
|
||||
}
|
||||
241
packages/core/src/project/copy.ts
Normal file
241
packages/core/src/project/copy.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
export * as ProjectCopy from "./copy"
|
||||
|
||||
import { and, eq, inArray } from "drizzle-orm"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { AbsolutePath } from "../schema"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { Git } from "../git"
|
||||
import { Database } from "../database/database"
|
||||
import { EventV2 } from "../event"
|
||||
import { Project } from "../project"
|
||||
import { ProjectDirectoryTable } from "./sql"
|
||||
import { makeStrategies } from "./copy-strategies"
|
||||
|
||||
export const StrategyID = Schema.Literal("git_worktree")
|
||||
export type StrategyID = typeof StrategyID.Type
|
||||
|
||||
export const DetectInput = Schema.Struct({
|
||||
directory: AbsolutePath,
|
||||
}).annotate({ identifier: "ProjectCopy.DetectInput" })
|
||||
export type DetectInput = typeof DetectInput.Type
|
||||
|
||||
export const CreateInput = Schema.Struct({
|
||||
projectID: Project.ID,
|
||||
strategy: StrategyID,
|
||||
sourceDirectory: AbsolutePath,
|
||||
directory: AbsolutePath,
|
||||
}).annotate({ identifier: "ProjectCopy.CreateInput" })
|
||||
export type CreateInput = typeof CreateInput.Type
|
||||
|
||||
export const RemoveInput = Schema.Struct({
|
||||
projectID: Project.ID,
|
||||
directory: AbsolutePath,
|
||||
}).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 Copy = Schema.Struct({
|
||||
directory: AbsolutePath,
|
||||
}).annotate({ identifier: "ProjectCopy.Copy" })
|
||||
export type Copy = typeof Copy.Type
|
||||
|
||||
export type DirectoryType = "main" | "root" | StrategyID
|
||||
|
||||
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 StrategyNotFoundError extends Schema.TaggedErrorClass<StrategyNotFoundError>()(
|
||||
"ProjectCopy.StrategyNotFoundError",
|
||||
{ directory: AbsolutePath },
|
||||
) {}
|
||||
|
||||
export type Error =
|
||||
| SourceDirectoryNotFoundError
|
||||
| DestinationExistsError
|
||||
| DirectoryUnavailableError
|
||||
| StrategyNotFoundError
|
||||
| Git.WorktreeError
|
||||
|
||||
export interface Strategy {
|
||||
readonly id: StrategyID
|
||||
readonly create: (input: {
|
||||
sourceDirectory: AbsolutePath
|
||||
directory: AbsolutePath
|
||||
}) => Effect.Effect<Copy, Git.WorktreeError | DirectoryUnavailableError>
|
||||
readonly remove: (directory: AbsolutePath) => Effect.Effect<void, Git.WorktreeError | DirectoryUnavailableError>
|
||||
readonly list: (directory: AbsolutePath) => Effect.Effect<Copy[], Git.WorktreeError | DirectoryUnavailableError>
|
||||
readonly detect: (directory: AbsolutePath) => Effect.Effect<boolean>
|
||||
}
|
||||
|
||||
export const Event = {
|
||||
Updated: EventV2.define({
|
||||
type: "project.directories.updated",
|
||||
schema: { projectID: Project.ID },
|
||||
}),
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly detect: (input: DetectInput) => Effect.Effect<StrategyID | undefined>
|
||||
readonly create: (input: CreateInput) => Effect.Effect<Copy, Error>
|
||||
readonly remove: (input: RemoveInput) => Effect.Effect<void, Error>
|
||||
readonly refresh: (input: RefreshInput) => Effect.Effect<void, Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ProjectCopy") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const git = yield* Git.Service
|
||||
const events = yield* EventV2.Service
|
||||
const db = (yield* Database.Service).db
|
||||
|
||||
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 = makeStrategies({ git, fs, canonical })
|
||||
|
||||
const source = Effect.fnUntraced(function* (input: AbsolutePath, projectID: Project.ID) {
|
||||
const sourceDirectory = yield* canonical(input)
|
||||
const row = yield* db
|
||||
.select({ directory: ProjectDirectoryTable.directory })
|
||||
.from(ProjectDirectoryTable)
|
||||
.where(and(eq(ProjectDirectoryTable.project_id, projectID), eq(ProjectDirectoryTable.directory, sourceDirectory)))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!row) return yield* new SourceDirectoryNotFoundError({ directory: sourceDirectory })
|
||||
return sourceDirectory
|
||||
})
|
||||
|
||||
const insert = Effect.fnUntraced(function* (projectID: Project.ID, copyDirectory: AbsolutePath, type: StrategyID) {
|
||||
return yield* db
|
||||
.transaction(
|
||||
(tx) =>
|
||||
Effect.gen(function* () {
|
||||
const row = yield* tx
|
||||
.select({ directory: ProjectDirectoryTable.directory })
|
||||
.from(ProjectDirectoryTable)
|
||||
.where(and(eq(ProjectDirectoryTable.project_id, projectID), eq(ProjectDirectoryTable.directory, copyDirectory)))
|
||||
.get()
|
||||
if (row) return false
|
||||
yield* tx.insert(ProjectDirectoryTable).values({ project_id: projectID, directory: copyDirectory, type }).run()
|
||||
return true
|
||||
}),
|
||||
{ behavior: "immediate" },
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
const removeStored = Effect.fnUntraced(function* (projectID: Project.ID, copyDirectory: AbsolutePath) {
|
||||
return (
|
||||
(yield* db
|
||||
.delete(ProjectDirectoryTable)
|
||||
.where(and(eq(ProjectDirectoryTable.project_id, projectID), eq(ProjectDirectoryTable.directory, copyDirectory)))
|
||||
.returning({ directory: ProjectDirectoryTable.directory })
|
||||
.get()
|
||||
.pipe(Effect.orDie)) !== undefined
|
||||
)
|
||||
})
|
||||
|
||||
const changed = Effect.fnUntraced(function* (projectID: Project.ID, update: boolean) {
|
||||
if (update) yield* events.publish(Event.Updated, { projectID })
|
||||
})
|
||||
|
||||
const strategy = (id: StrategyID) => registry.get(id) as Strategy
|
||||
|
||||
const detect = Effect.fn("ProjectCopy.detect")(function* (input: DetectInput) {
|
||||
for (const strategy of registry.values()) {
|
||||
if (yield* strategy.detect(input.directory)) return strategy.id
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
const create = Effect.fn("ProjectCopy.create")(function* (input: CreateInput) {
|
||||
if (yield* fs.existsSafe(input.directory)) return yield* new DestinationExistsError({ directory: input.directory })
|
||||
const result = yield* strategy(input.strategy).create({
|
||||
directory: input.directory,
|
||||
sourceDirectory: yield* source(input.sourceDirectory, input.projectID),
|
||||
})
|
||||
yield* changed(input.projectID, yield* insert(input.projectID, result.directory, input.strategy))
|
||||
return result
|
||||
})
|
||||
|
||||
const remove = Effect.fn("ProjectCopy.remove")(function* (input: RemoveInput) {
|
||||
const copyDirectory = yield* canonical(input.directory)
|
||||
const id = yield* detect({ directory: copyDirectory })
|
||||
if (!id) return yield* new StrategyNotFoundError({ directory: copyDirectory })
|
||||
yield* strategy(id).remove(copyDirectory)
|
||||
yield* changed(input.projectID, yield* removeStored(input.projectID, copyDirectory))
|
||||
})
|
||||
|
||||
const refresh = Effect.fn("ProjectCopy.refresh")(function* (input: RefreshInput) {
|
||||
const roots = yield* db
|
||||
.select({ directory: ProjectDirectoryTable.directory })
|
||||
.from(ProjectDirectoryTable)
|
||||
.where(and(eq(ProjectDirectoryTable.project_id, input.projectID), inArray(ProjectDirectoryTable.type, ["main", "root"])))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
const sourceDirectories = yield* Effect.forEach(roots, (item) => canonical(AbsolutePath.make(item.directory)), {
|
||||
concurrency: "unbounded",
|
||||
})
|
||||
const discovered = yield* Effect.forEach(
|
||||
sourceDirectories,
|
||||
(sourceDirectory) =>
|
||||
Effect.forEach(registry.values(), (strategy) =>
|
||||
strategy
|
||||
.list(sourceDirectory)
|
||||
.pipe(Effect.map((items) => items.map((item) => ({ ...item, type: strategy.id })))),
|
||||
),
|
||||
{ concurrency: "unbounded" },
|
||||
).pipe(Effect.map((sets) => new Map(sets.flat(2).map((item) => [item.directory, item] as const)).values().toArray()))
|
||||
const stored = yield* db
|
||||
.select({ directory: ProjectDirectoryTable.directory })
|
||||
.from(ProjectDirectoryTable)
|
||||
.where(eq(ProjectDirectoryTable.project_id, input.projectID))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
const inserted = yield* Effect.forEach(discovered, (item) => insert(input.projectID, item.directory, item.type)).pipe(
|
||||
Effect.map((items) => items.some(Boolean)),
|
||||
)
|
||||
const removed = yield* Effect.forEach(stored, (item) =>
|
||||
fs
|
||||
.isDir(item.directory)
|
||||
.pipe(
|
||||
Effect.flatMap((exists) =>
|
||||
exists ? Effect.succeed(false) : removeStored(input.projectID, AbsolutePath.make(item.directory)),
|
||||
),
|
||||
),
|
||||
).pipe(Effect.map((items) => items.some(Boolean)))
|
||||
yield* changed(input.projectID, inserted || removed)
|
||||
})
|
||||
|
||||
return Service.of({ detect, create, remove, refresh })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(Git.defaultLayer),
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
)
|
||||
@@ -1,4 +1,4 @@
|
||||
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"
|
||||
import { sqliteTable, text, integer, primaryKey } from "drizzle-orm/sqlite-core"
|
||||
import * as DatabasePath from "../database/path"
|
||||
import { Timestamps } from "../database/schema.sql"
|
||||
import { ProjectV2 } from "../project"
|
||||
@@ -16,3 +16,19 @@ export const ProjectTable = sqliteTable("project", {
|
||||
sandboxes: DatabasePath.absoluteArrayColumn().notNull(),
|
||||
commands: text({ mode: "json" }).$type<{ start?: string }>(),
|
||||
})
|
||||
|
||||
export const ProjectDirectoryTable = sqliteTable(
|
||||
"project_directory",
|
||||
{
|
||||
project_id: text()
|
||||
.$type<ProjectV2.ID>()
|
||||
.notNull()
|
||||
.references(() => ProjectTable.id, { onDelete: "cascade" }),
|
||||
directory: text().notNull(),
|
||||
type: text().$type<"main" | "root" | "git_worktree">().notNull(),
|
||||
time_created: integer()
|
||||
.notNull()
|
||||
.$default(() => Date.now()),
|
||||
},
|
||||
(table) => [primaryKey({ columns: [table.project_id, table.directory] })],
|
||||
)
|
||||
|
||||
@@ -43,7 +43,7 @@ describe("DatabaseMigration", () => {
|
||||
expect(yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session'`)).toEqual({
|
||||
name: "session",
|
||||
})
|
||||
expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: 24 })
|
||||
expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: 25 })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { $ } from "bun"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { Git } from "@opencode-ai/core/git"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { branch, commit, gitRemote } from "./fixture/git"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
@@ -64,3 +66,41 @@ function withRemote<A, E, R>(body: (fixture: Awaited<ReturnType<typeof gitRemote
|
||||
function read(file: string) {
|
||||
return Effect.promise(() => fs.readFile(file, "utf8")).pipe(Effect.map((content) => content.replace(/\r\n/g, "\n")))
|
||||
}
|
||||
|
||||
async function initRepo(directory: string) {
|
||||
await $`git init`.cwd(directory).quiet()
|
||||
await $`git config core.fsmonitor false`.cwd(directory).quiet()
|
||||
await $`git config commit.gpgsign false`.cwd(directory).quiet()
|
||||
await $`git config user.email test@opencode.test`.cwd(directory).quiet()
|
||||
await $`git config user.name Test`.cwd(directory).quiet()
|
||||
await $`git commit --allow-empty -m root`.cwd(directory).quiet()
|
||||
}
|
||||
|
||||
describe("Git worktrees", () => {
|
||||
it.live("creates, lists, and removes linked worktrees", () =>
|
||||
Effect.gen(function* () {
|
||||
const root = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
)
|
||||
yield* Effect.promise(() => initRepo(root.path))
|
||||
const directory = AbsolutePath.make(yield* Effect.promise(() => fs.realpath(root.path)))
|
||||
const worktree = AbsolutePath.make(`${root.path}-git-worktree`)
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(() => fs.rm(worktree, { recursive: true, force: true })).pipe(Effect.ignore),
|
||||
)
|
||||
const git = yield* Git.Service
|
||||
const repo = { directory, store: AbsolutePath.make(path.join(directory, ".git")) }
|
||||
|
||||
yield* git.worktreeCreate({ repo, directory: worktree })
|
||||
|
||||
expect((yield* git.worktreeList(repo)).some((entry) => entry.endsWith("-git-worktree"))).toBe(true)
|
||||
const linked = yield* git.find(worktree)
|
||||
expect(linked?.directory).toBe(AbsolutePath.make(yield* Effect.promise(() => fs.realpath(worktree))))
|
||||
expect(linked?.store).toBe(repo.store)
|
||||
if (!linked) throw new Error("Linked worktree not found")
|
||||
yield* git.worktreeRemove({ repo: linked, directory: worktree })
|
||||
expect((yield* git.worktreeList(repo)).some((entry) => entry.endsWith("-git-worktree"))).toBe(false)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -9,6 +9,7 @@ const ref = { directory: AbsolutePath.make("/repo/packages/app"), workspaceID: "
|
||||
const projectLayer = Layer.succeed(
|
||||
Project.Service,
|
||||
Project.Service.of({
|
||||
directories: () => Effect.succeed([]),
|
||||
resolve: () =>
|
||||
Effect.succeed({
|
||||
id: Project.ID.make("project"),
|
||||
|
||||
191
packages/core/test/project-copy.test.ts
Normal file
191
packages/core/test/project-copy.test.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { $ } from "bun"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { Effect, Fiber, Layer, Stream } from "effect"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Git } from "@opencode-ai/core/git"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectDirectoryTable, ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { ProjectCopy } from "@opencode-ai/core/project/copy"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const databaseLayer = Database.layerFromPath(":memory:")
|
||||
const eventLayer = EventV2.layer.pipe(Layer.provide(databaseLayer))
|
||||
const copyLayer = ProjectCopy.layer.pipe(
|
||||
Layer.provide(databaseLayer),
|
||||
Layer.provide(eventLayer),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(Git.defaultLayer),
|
||||
)
|
||||
const it = testEffect(Layer.mergeAll(copyLayer, databaseLayer, eventLayer))
|
||||
|
||||
function abs(input: string) {
|
||||
return AbsolutePath.make(input)
|
||||
}
|
||||
|
||||
async function initRepo(directory: string) {
|
||||
await $`git init`.cwd(directory).quiet()
|
||||
await $`git config core.fsmonitor false`.cwd(directory).quiet()
|
||||
await $`git config commit.gpgsign false`.cwd(directory).quiet()
|
||||
await $`git config user.email test@opencode.test`.cwd(directory).quiet()
|
||||
await $`git config user.name Test`.cwd(directory).quiet()
|
||||
await $`git commit --allow-empty -m root`.cwd(directory).quiet()
|
||||
}
|
||||
|
||||
function setup() {
|
||||
return Effect.gen(function* () {
|
||||
const root = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
)
|
||||
yield* Effect.promise(() => initRepo(root.path))
|
||||
const sourceDirectory = abs(yield* Effect.promise(() => fs.realpath(root.path)))
|
||||
const projectID = Project.ID.make("copy-project")
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: projectID, worktree: sourceDirectory, sandboxes: [], time_created: 1, time_updated: 1 })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.insert(ProjectDirectoryTable)
|
||||
.values({ project_id: projectID, directory: sourceDirectory, type: "main" })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
return { root, sourceDirectory, projectID, db }
|
||||
})
|
||||
}
|
||||
|
||||
function stored(projectID: Project.ID) {
|
||||
return Database.Service.use(({ db }) =>
|
||||
db
|
||||
.select({ directory: ProjectDirectoryTable.directory, type: ProjectDirectoryTable.type })
|
||||
.from(ProjectDirectoryTable)
|
||||
.where(eq(ProjectDirectoryTable.project_id, projectID))
|
||||
.all()
|
||||
.pipe(
|
||||
Effect.orDie,
|
||||
Effect.map((rows) => rows.toSorted((a, b) => a.directory.localeCompare(b.directory))),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
describe("ProjectCopy", () => {
|
||||
it.live("detects linked git worktrees but not root checkouts", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const copy = yield* ProjectCopy.Service
|
||||
const target = abs(`${input.root.path}-copy-detected`)
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(() => fs.rm(target, { recursive: true, force: true })).pipe(Effect.ignore),
|
||||
)
|
||||
yield* Effect.promise(() => $`git worktree add --detach ${target} HEAD`.cwd(input.root.path).quiet())
|
||||
|
||||
expect(yield* copy.detect({ directory: input.sourceDirectory })).toBeUndefined()
|
||||
expect(yield* copy.detect({ directory: target })).toBe("git_worktree")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("creates and removes a git worktree directory", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const copy = yield* ProjectCopy.Service
|
||||
const events = yield* EventV2.Service
|
||||
const target = abs(`${input.root.path}-copy-created`)
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(() => fs.rm(target, { recursive: true, force: true })).pipe(Effect.ignore),
|
||||
)
|
||||
const fiber = yield* events
|
||||
.subscribe(ProjectCopy.Event.Updated)
|
||||
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
|
||||
const created = yield* copy.create({
|
||||
projectID: input.projectID,
|
||||
strategy: "git_worktree",
|
||||
sourceDirectory: input.sourceDirectory,
|
||||
directory: target,
|
||||
})
|
||||
expect(yield* stored(input.projectID)).toEqual(
|
||||
[
|
||||
{ directory: input.sourceDirectory, type: "main" as const },
|
||||
{ directory: created.directory, type: "git_worktree" as const },
|
||||
].toSorted((a, b) => a.directory.localeCompare(b.directory)),
|
||||
)
|
||||
expect(Array.from(yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID })
|
||||
|
||||
yield* copy.remove({ projectID: input.projectID, directory: created.directory })
|
||||
|
||||
expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, type: "main" as const }])
|
||||
expect(yield* Effect.promise(() => Bun.file(target).exists())).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("does not publish an event when refresh finds no directory changes", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const copy = yield* ProjectCopy.Service
|
||||
const events = yield* EventV2.Service
|
||||
const event = yield* events.subscribe(ProjectCopy.Event.Updated).pipe(
|
||||
Stream.take(1),
|
||||
Stream.runCollect,
|
||||
Effect.forkScoped,
|
||||
Effect.flatMap((fiber) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.yieldNow
|
||||
yield* copy.refresh({ projectID: input.projectID })
|
||||
return yield* Fiber.join(fiber).pipe(Effect.timeoutOption("50 millis"))
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(event._tag).toBe("None")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("refresh discovers and prunes an externally managed git worktree", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const copy = yield* ProjectCopy.Service
|
||||
const events = yield* EventV2.Service
|
||||
const target = abs(`${input.root.path}-copy-external`)
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(() => fs.rm(target, { recursive: true, force: true })).pipe(Effect.ignore),
|
||||
)
|
||||
yield* Effect.promise(() => $`git worktree add --detach ${target} HEAD`.cwd(input.root.path).quiet())
|
||||
const fiber = yield* events
|
||||
.subscribe(ProjectCopy.Event.Updated)
|
||||
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
|
||||
yield* copy.refresh({ projectID: input.projectID })
|
||||
|
||||
const discovered = abs(yield* Effect.promise(() => fs.realpath(target)))
|
||||
expect(yield* stored(input.projectID)).toEqual(
|
||||
[
|
||||
{ directory: input.sourceDirectory, type: "main" as const },
|
||||
{ directory: discovered, type: "git_worktree" as const },
|
||||
].toSorted((a, b) => a.directory.localeCompare(b.directory)),
|
||||
)
|
||||
expect(Array.from(yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID })
|
||||
|
||||
yield* Effect.promise(() => $`git worktree remove --force ${target}`.cwd(input.root.path).quiet())
|
||||
yield* copy.refresh({ projectID: input.projectID })
|
||||
expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, type: "main" as const }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("refresh with no roots is a no-op", () =>
|
||||
Effect.gen(function* () {
|
||||
const copy = yield* ProjectCopy.Service
|
||||
|
||||
yield* copy.refresh({ projectID: Project.ID.make("missing-project") })
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -2,14 +2,28 @@ import { describe, expect } from "bun:test"
|
||||
import { $ } from "bun"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||
import { ProjectDirectoryTable, ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Git } from "@opencode-ai/core/git"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Hash } from "@opencode-ai/core/util/hash"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(ProjectV2.defaultLayer)
|
||||
const databaseLayer = Database.layerFromPath(":memory:")
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(
|
||||
ProjectV2.layer.pipe(
|
||||
Layer.provide(databaseLayer),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(Git.defaultLayer),
|
||||
),
|
||||
databaseLayer,
|
||||
),
|
||||
)
|
||||
|
||||
function remoteID(remote: string) {
|
||||
return ProjectV2.ID.make(Hash.fast(`git-remote:${remote}`))
|
||||
@@ -37,6 +51,50 @@ async function rootCommit(dir: string) {
|
||||
return (await $`git rev-list --max-parents=0 HEAD`.cwd(dir).text()).trim()
|
||||
}
|
||||
|
||||
describe("Project directories schemas", () => {
|
||||
it.effect("decodes project directory input and inline directory results", () =>
|
||||
Effect.sync(() => {
|
||||
expect(Schema.decodeUnknownSync(ProjectV2.DirectoriesInput)({ projectID: ProjectV2.ID.make("project") })).toEqual({
|
||||
projectID: ProjectV2.ID.make("project"),
|
||||
})
|
||||
expect(Schema.decodeUnknownSync(ProjectV2.Directories)([AbsolutePath.make("/tmp/project")])).toEqual([
|
||||
AbsolutePath.make("/tmp/project"),
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lists stored project directories only for the requested project", () =>
|
||||
Effect.gen(function* () {
|
||||
const project = yield* ProjectV2.Service
|
||||
const { db } = yield* Database.Service
|
||||
const projectID = ProjectV2.ID.make("directories-project")
|
||||
const otherID = ProjectV2.ID.make("directories-other")
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values([
|
||||
{ id: projectID, worktree: AbsolutePath.make("/repo"), sandboxes: [], time_created: 1, time_updated: 1 },
|
||||
{ id: otherID, worktree: AbsolutePath.make("/other"), sandboxes: [], time_created: 1, time_updated: 1 },
|
||||
])
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.insert(ProjectDirectoryTable)
|
||||
.values([
|
||||
{ project_id: projectID, directory: AbsolutePath.make("/repo/z"), type: "root" },
|
||||
{ project_id: projectID, directory: AbsolutePath.make("/repo/a"), type: "main" },
|
||||
{ project_id: otherID, directory: AbsolutePath.make("/other"), type: "main" },
|
||||
])
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
expect(yield* project.directories({ projectID })).toEqual([
|
||||
AbsolutePath.make("/repo/a"),
|
||||
AbsolutePath.make("/repo/z"),
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("ProjectV2.resolve", () => {
|
||||
it.live("returns global for non-git directory", () =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
Reference in New Issue
Block a user