feat(core): project copying and tracking paths

This commit is contained in:
James Long
2026-05-31 15:48:01 -04:00
parent abaabdcb73
commit 49f3ceebc2
15 changed files with 2586 additions and 5 deletions
@@ -0,0 +1,11 @@
CREATE TABLE `project_path` (
`project_id` text NOT NULL,
`path` text NOT NULL,
`primary` integer DEFAULT false NOT NULL,
`time_created` integer NOT NULL,
`time_updated` integer NOT NULL,
CONSTRAINT `project_path_pk` PRIMARY KEY(`project_id`, `path`),
CONSTRAINT `fk_project_path_project_id_project_id_fk` FOREIGN KEY (`project_id`) REFERENCES `project`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
CREATE UNIQUE INDEX `project_path_project_primary_idx` ON `project_path` (`project_id`) WHERE "project_path"."primary" = 1;
File diff suppressed because it is too large Load Diff
@@ -23,5 +23,6 @@ export const migrations = (
import("./migration/20260510033149_session_usage"),
import("./migration/20260511000411_data_migration_state"),
import("./migration/20260530232709_lovely_romulus"),
import("./migration/20260531185402_jittery_blink"),
])
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
@@ -0,0 +1,22 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260531185402_jittery_blink",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`
CREATE TABLE \`project_path\` (
\`project_id\` text NOT NULL,
\`path\` text NOT NULL,
\`primary\` integer DEFAULT false NOT NULL,
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL,
CONSTRAINT \`project_path_pk\` PRIMARY KEY(\`project_id\`, \`path\`),
CONSTRAINT \`fk_project_path_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`CREATE UNIQUE INDEX \`project_path_project_primary_idx\` ON \`project_path\` (\`project_id\`) WHERE "project_path"."primary" = 1;`)
})
},
} satisfies DatabaseMigration.Migration
+48 -2
View File
@@ -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 { AppFileSystem } from "./filesystem"
@@ -26,10 +26,20 @@ export interface Repo {
readonly store: AbsolutePath
}
export class WorktreeError extends Schema.TaggedErrorClass<WorktreeError>()("Git.WorktreeError", {
operation: Schema.Literals(["create", "remove", "list"]),
message: Schema.String,
path: 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>
readonly roots: (repo: Repo) => Effect.Effect<string[]>
readonly worktreeCreate: (input: { repo: Repo; path: AbsolutePath }) => Effect.Effect<void, WorktreeError>
readonly worktreeRemove: (input: { repo: Repo; path: AbsolutePath }) => Effect.Effect<void, WorktreeError>
readonly worktreeList: (repo: Repo) => Effect.Effect<AbsolutePath[], WorktreeError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/GitV2") {}
@@ -75,7 +85,43 @@ export const layer = Layer.effect(
.toSorted()
})
return Service.of({ find, remote, roots })
const worktree = Effect.fnUntraced(function* (
operation: "create" | "remove" | "list",
repo: Repo,
args: string[],
worktreePath?: AbsolutePath,
) {
const result = yield* proc
.run(ChildProcess.make("git", args, { cwd: repo.directory, extendEnv: true, stdin: "ignore" }))
.pipe(
Effect.mapError(
(cause) => new WorktreeError({ operation, path: worktreePath, message: cause.message, cause }),
),
)
if (result.exitCode === 0) return result.stdout.toString("utf8")
return yield* new WorktreeError({
operation,
path: worktreePath,
message: result.stderr.toString("utf8").trim() || result.stdout.toString("utf8").trim() || "Git failed",
})
})
const worktreeCreate = Effect.fn("Git.worktreeCreate")(function* (input: { repo: Repo; path: AbsolutePath }) {
yield* worktree("create", input.repo, ["worktree", "add", "--detach", input.path, "HEAD"], input.path)
})
const worktreeRemove = Effect.fn("Git.worktreeRemove")(function* (input: { repo: Repo; path: AbsolutePath }) {
yield* worktree("remove", input.repo, ["worktree", "remove", "--force", input.path], input.path)
})
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, roots, worktreeCreate, worktreeRemove, worktreeList })
}),
)
+36 -2
View File
@@ -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 { Database } from "./database/database"
import { AppFileSystem } from "./filesystem"
import { Git } from "./git"
import { Hash } from "./util/hash"
import { ProjectPathTable } from "./project/path.sql"
export const ID = Schema.String.pipe(
Schema.brand("Project.ID"),
@@ -28,7 +31,21 @@ export class Info extends Schema.Class<Info>("Project.Info")({
id: ID,
}) {}
export const PathsInput = Schema.Struct({
projectID: ID,
}).annotate({ identifier: "Project.PathsInput" })
export type PathsInput = typeof PathsInput.Type
export const Paths = Schema.Array(
Schema.Struct({
path: AbsolutePath,
primary: Schema.Boolean,
}),
).annotate({ identifier: "Project.Paths" })
export type Paths = typeof Paths.Type
export interface Interface {
readonly paths: (input: PathsInput) => Effect.Effect<Paths>
readonly resolve: (input: AbsolutePath) => Effect.Effect<
{
previous?: ID
@@ -55,9 +72,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* AppFileSystem.Service
const git = yield* Git.Service
const paths = Effect.fn("Project.paths")(function* (input: PathsInput) {
const rows = yield* db
.select({ path: ProjectPathTable.path, primary: ProjectPathTable.primary })
.from(ProjectPathTable)
.where(eq(ProjectPathTable.project_id, input.projectID))
.all()
.pipe(Effect.orDie)
return rows
.toSorted((a, b) => a.path.localeCompare(b.path))
.map((row) => ({ path: AbsolutePath.make(row.path), primary: row.primary }))
})
const cached = Effect.fnUntraced(function* (dir: string) {
return yield* fs.readFileString(path.join(dir, "opencode")).pipe(
Effect.map((value) => value.trim()),
@@ -122,8 +152,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({ paths, resolve, commit })
}),
)
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Git.defaultLayer))
export const defaultLayer = layer.pipe(
Layer.provide(Database.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Git.defaultLayer),
)
@@ -0,0 +1,42 @@
import { Effect } from "effect"
import { AbsolutePath } from "../schema"
import { Git } from "../git"
import { type Copy, type Strategy, type StrategyID } from "./copy"
import type { PathUnavailableError } from "./copy"
export function makeStrategies(input: {
git: Git.Interface
canonical: (path: AbsolutePath) => Effect.Effect<AbsolutePath, PathUnavailableError>
}) {
const repo = (primaryPath: AbsolutePath) => ({ directory: primaryPath, store: primaryPath } satisfies Git.Repo)
const gitWorktree: Strategy = {
id: "git_worktree",
name: "Git worktree",
create: Effect.fn("ProjectCopy.GitWorktree.create")(function* (options) {
yield* input.git.worktreeCreate({ repo: repo(options.primaryPath), path: options.path })
return { path: yield* input.canonical(options.path) }
}),
remove: Effect.fn("ProjectCopy.GitWorktree.remove")(function* (options) {
const entries = yield* gitWorktree.list(options)
if (!entries.some((entry) => entry.path === options.path)) {
return yield* new Git.WorktreeError({
operation: "remove",
path: options.path,
message: "Path is not a linked Git worktree",
})
}
yield* input.git.worktreeRemove({ repo: repo(options.primaryPath), path: options.path })
}),
list: Effect.fn("ProjectCopy.GitWorktree.list")(function* (options) {
const entries = yield* input.git.worktreeList(repo(options.primaryPath))
return yield* Effect.forEach(entries, (entry) =>
entry === options.primaryPath
? Effect.succeed(undefined)
: input.canonical(entry).pipe(Effect.map((path) => ({ path }))),
).pipe(Effect.map((items) => items.filter((item): item is Copy => item !== undefined)))
}),
}
return new Map<StrategyID, Strategy>([[gitWorktree.id, gitWorktree]])
}
+236
View File
@@ -0,0 +1,236 @@
export * as ProjectCopy from "./copy"
import { and, eq } from "drizzle-orm"
import { Context, Effect, Layer, Schema } from "effect"
import { AbsolutePath } from "../schema"
import { AppFileSystem } from "../filesystem"
import { Git } from "../git"
import { Database } from "../database/database"
import { EventV2 } from "../event"
import { Project } from "../project"
import { ProjectPathTable } from "./path.sql"
import { makeStrategies } from "./copy-strategies"
export const StrategyID = Schema.Literal("git_worktree")
export type StrategyID = typeof StrategyID.Type
export const StrategiesInput = Schema.Struct({
projectID: Project.ID,
}).annotate({ identifier: "ProjectCopy.StrategiesInput" })
export type StrategiesInput = typeof StrategiesInput.Type
export const CreateInput = Schema.Struct({
projectID: Project.ID,
strategy: StrategyID,
path: AbsolutePath,
}).annotate({ identifier: "ProjectCopy.CreateInput" })
export type CreateInput = typeof CreateInput.Type
export const RemoveInput = CreateInput.annotate({ identifier: "ProjectCopy.RemoveInput" })
export type RemoveInput = typeof RemoveInput.Type
export const RefreshInput = Schema.Struct({
projectID: Project.ID,
strategy: Schema.optional(StrategyID),
}).annotate({ identifier: "ProjectCopy.RefreshInput" })
export type RefreshInput = typeof RefreshInput.Type
export const StrategyInfo = Schema.Struct({
id: StrategyID,
name: Schema.String,
}).annotate({ identifier: "ProjectCopy.StrategyInfo" })
export type StrategyInfo = typeof StrategyInfo.Type
export const Copy = Schema.Struct({
path: AbsolutePath,
}).annotate({ identifier: "ProjectCopy.Copy" })
export type Copy = typeof Copy.Type
export class PrimaryPathNotFoundError extends Schema.TaggedErrorClass<PrimaryPathNotFoundError>()(
"ProjectCopy.PrimaryPathNotFoundError",
{ projectID: Project.ID },
) {}
export class CannotRemovePrimaryPathError extends Schema.TaggedErrorClass<CannotRemovePrimaryPathError>()(
"ProjectCopy.CannotRemovePrimaryPathError",
{ projectID: Project.ID, path: AbsolutePath },
) {}
export class DestinationExistsError extends Schema.TaggedErrorClass<DestinationExistsError>()(
"ProjectCopy.DestinationExistsError",
{ path: AbsolutePath },
) {}
export class PathUnavailableError extends Schema.TaggedErrorClass<PathUnavailableError>()(
"ProjectCopy.PathUnavailableError",
{ path: AbsolutePath },
) {}
export type Error =
| PrimaryPathNotFoundError
| CannotRemovePrimaryPathError
| DestinationExistsError
| PathUnavailableError
| Git.WorktreeError
export interface Strategy {
readonly id: StrategyID
readonly name: string
readonly create: (input: {
projectID: Project.ID
primaryPath: AbsolutePath
path: AbsolutePath
}) => Effect.Effect<Copy, Git.WorktreeError | PathUnavailableError>
readonly remove: (input: {
projectID: Project.ID
primaryPath: AbsolutePath
path: AbsolutePath
}) => Effect.Effect<void, Git.WorktreeError | PathUnavailableError>
readonly list: (input: {
projectID: Project.ID
primaryPath: AbsolutePath
}) => Effect.Effect<Copy[], Git.WorktreeError | PathUnavailableError>
}
export const Event = {
Updated: EventV2.define({
type: "project.paths.updated",
schema: { projectID: Project.ID },
}),
}
export interface Interface {
readonly strategies: (input: StrategiesInput) => Effect.Effect<StrategyInfo[]>
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* AppFileSystem.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 real = yield* fs.realPath(input).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!real || !(yield* fs.isDir(real))) return yield* new PathUnavailableError({ path: input })
return AbsolutePath.make(AppFileSystem.normalizePath(real))
})
const registry = makeStrategies({ git, canonical })
const primary = Effect.fnUntraced(function* (projectID: Project.ID) {
const row = yield* db
.select({ path: ProjectPathTable.path })
.from(ProjectPathTable)
.where(and(eq(ProjectPathTable.project_id, projectID), eq(ProjectPathTable.primary, true)))
.get()
.pipe(Effect.orDie)
if (!row) return yield* new PrimaryPathNotFoundError({ projectID })
return yield* canonical(AbsolutePath.make(row.path))
})
const insert = Effect.fnUntraced(function* (projectID: Project.ID, copyPath: AbsolutePath) {
return yield* db
.transaction(
(tx) =>
Effect.gen(function* () {
const row = yield* tx
.select({ path: ProjectPathTable.path })
.from(ProjectPathTable)
.where(and(eq(ProjectPathTable.project_id, projectID), eq(ProjectPathTable.path, copyPath)))
.get()
if (row) return false
yield* tx.insert(ProjectPathTable).values({ project_id: projectID, path: copyPath, primary: false }).run()
return true
}),
{ behavior: "immediate" },
)
.pipe(Effect.orDie)
})
const removeStored = Effect.fnUntraced(function* (projectID: Project.ID, copyPath: AbsolutePath) {
const row = yield* db
.select({ path: ProjectPathTable.path })
.from(ProjectPathTable)
.where(and(eq(ProjectPathTable.project_id, projectID), eq(ProjectPathTable.path, copyPath)))
.get()
.pipe(Effect.orDie)
if (!row) return false
yield* db
.delete(ProjectPathTable)
.where(and(eq(ProjectPathTable.project_id, projectID), eq(ProjectPathTable.path, copyPath)))
.run()
.pipe(Effect.orDie)
return true
})
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 strategies = Effect.fn("ProjectCopy.strategies")(function* (_input: StrategiesInput) {
return Array.from(registry.values()).map((strategy) => ({ id: strategy.id, name: strategy.name }))
})
const create = Effect.fn("ProjectCopy.create")(function* (input: CreateInput) {
if (yield* fs.existsSafe(input.path)) return yield* new DestinationExistsError({ path: input.path })
const result = yield* strategy(input.strategy).create({ ...input, primaryPath: yield* primary(input.projectID) })
yield* changed(input.projectID, yield* insert(input.projectID, result.path))
return result
})
const remove = Effect.fn("ProjectCopy.remove")(function* (input: RemoveInput) {
const primaryPath = yield* primary(input.projectID)
const copyPath = yield* canonical(input.path)
if (copyPath === primaryPath) {
return yield* new CannotRemovePrimaryPathError({ projectID: input.projectID, path: copyPath })
}
yield* strategy(input.strategy).remove({ ...input, path: copyPath, primaryPath })
yield* changed(input.projectID, yield* removeStored(input.projectID, copyPath))
})
const refresh = Effect.fn("ProjectCopy.refresh")(function* (input: RefreshInput) {
const primaryPath = yield* primary(input.projectID)
const strategies = input.strategy ? [strategy(input.strategy)] : Array.from(registry.values())
const discovered = yield* Effect.forEach(strategies, (strategy) => strategy.list({ ...input, primaryPath }), {
concurrency: "unbounded",
}).pipe(Effect.map((sets) => new Set(sets.flatMap((set) => set.map((item) => item.path)))))
const stored = yield* db
.select({ path: ProjectPathTable.path, primary: ProjectPathTable.primary })
.from(ProjectPathTable)
.where(eq(ProjectPathTable.project_id, input.projectID))
.all()
.pipe(Effect.orDie)
const inserted = yield* Effect.forEach(discovered, (copyPath) => insert(input.projectID, copyPath)).pipe(
Effect.map((items) => items.some(Boolean)),
)
const removed = yield* Effect.forEach(
stored.filter((item) => !item.primary),
(item) =>
fs.isDir(item.path).pipe(
Effect.flatMap((exists) =>
exists ? Effect.succeed(false) : removeStored(input.projectID, AbsolutePath.make(item.path)),
),
),
).pipe(Effect.map((items) => items.some(Boolean)))
yield* changed(input.projectID, inserted || removed)
})
return Service.of({ strategies, create, remove, refresh })
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(Database.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Git.defaultLayer),
Layer.provide(EventV2.defaultLayer),
)
+24
View File
@@ -0,0 +1,24 @@
import { sql } from "drizzle-orm"
import { integer, primaryKey, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"
import { Timestamps } from "../database/schema.sql"
import { ProjectTable } from "./sql"
import { ProjectV2 } from "../project"
export const ProjectPathTable = sqliteTable(
"project_path",
{
project_id: text()
.$type<ProjectV2.ID>()
.notNull()
.references(() => ProjectTable.id, { onDelete: "cascade" }),
path: text().notNull(),
primary: integer({ mode: "boolean" }).notNull().default(false),
...Timestamps,
},
(table) => [
primaryKey({ columns: [table.project_id, table.path] }),
uniqueIndex("project_path_project_primary_idx")
.on(table.project_id)
.where(sql`${table.primary} = 1`),
],
)
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect } from "bun:test"
import { $ } from "bun"
import fs from "fs/promises"
import { Effect } from "effect"
import { Git } from "@opencode-ai/core/git"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const it = testEffect(Git.defaultLayer)
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(`${directory}/.git`) }
yield* git.worktreeCreate({ repo, path: worktree })
expect((yield* git.worktreeList(repo)).some((entry) => entry.endsWith("-git-worktree"))).toBe(true)
yield* git.worktreeRemove({ repo, path: worktree })
expect((yield* git.worktreeList(repo)).some((entry) => entry.endsWith("-git-worktree"))).toBe(false)
}),
)
})
+1
View File
@@ -9,6 +9,7 @@ const ref = { directory: AbsolutePath.make("/repo/packages/app"), workspaceID: "
const projectLayer = Layer.succeed(
Project.Service,
Project.Service.of({
paths: () => Effect.succeed([]),
resolve: () =>
Effect.succeed({
id: Project.ID.make("project"),
+149
View File
@@ -0,0 +1,149 @@
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 { AppFileSystem } from "@opencode-ai/core/filesystem"
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 { ProjectPathTable } from "@opencode-ai/core/project/path.sql"
import { 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(AppFileSystem.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 primaryPath = 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: root.path, sandboxes: [], time_created: 1, time_updated: 1 })
.run()
.pipe(Effect.orDie)
yield* db
.insert(ProjectPathTable)
.values({ project_id: projectID, path: primaryPath, primary: true })
.run()
.pipe(Effect.orDie)
return { root, primaryPath, projectID, db }
})
}
function stored(projectID: Project.ID) {
return Database.Service.use(({ db }) =>
db
.select({ path: ProjectPathTable.path, primary: ProjectPathTable.primary })
.from(ProjectPathTable)
.where(eq(ProjectPathTable.project_id, projectID))
.all()
.pipe(Effect.orDie, Effect.map((rows) => rows.toSorted((a, b) => a.path.localeCompare(b.path)))),
)
}
describe("ProjectCopy", () => {
it.effect("lists the initial git worktree strategy", () =>
Effect.gen(function* () {
const copy = yield* ProjectCopy.Service
expect(yield* copy.strategies({ projectID: Project.ID.make("project") })).toEqual([
{ id: "git_worktree", name: "Git worktree" },
])
}),
)
it.live("creates and removes a git worktree path", () =>
Effect.gen(function* () {
const input = yield* setup()
const copy = yield* ProjectCopy.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 created = yield* copy.create({ projectID: input.projectID, strategy: "git_worktree", path: target })
expect(yield* stored(input.projectID)).toEqual([
{ path: input.primaryPath, primary: true },
{ path: created.path, primary: false },
].toSorted((a, b) => a.path.localeCompare(b.path)))
yield* copy.remove({ projectID: input.projectID, strategy: "git_worktree", path: created.path })
expect(yield* stored(input.projectID)).toEqual([{ path: input.primaryPath, primary: true }])
expect(yield* Effect.promise(() => Bun.file(target).exists())).toBe(false)
}),
)
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, strategy: "git_worktree" })
const discovered = abs(yield* Effect.promise(() => fs.realpath(target)))
expect(yield* stored(input.projectID)).toEqual([
{ path: input.primaryPath, primary: true },
{ path: discovered, primary: false },
].toSorted((a, b) => a.path.localeCompare(b.path)))
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, strategy: "git_worktree" })
expect(yield* stored(input.projectID)).toEqual([{ path: input.primaryPath, primary: true }])
}),
)
it.live("refuses to remove the primary project path", () =>
Effect.gen(function* () {
const input = yield* setup()
const copy = yield* ProjectCopy.Service
const result = yield* Effect.exit(
copy.remove({ projectID: input.projectID, strategy: "git_worktree", path: input.primaryPath }),
)
expect(result._tag).toBe("Failure")
}),
)
})
+61 -1
View File
@@ -2,14 +2,30 @@ 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 { ProjectPathTable } from "@opencode-ai/core/project/path.sql"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { Database } from "@opencode-ai/core/database/database"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
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 pathsIt = testEffect(
Layer.mergeAll(
ProjectV2.layer.pipe(
Layer.provide(databaseLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Git.defaultLayer),
),
databaseLayer,
),
)
function remoteID(remote: string) {
return ProjectV2.ID.make(Hash.fast(`git-remote:${remote}`))
@@ -37,6 +53,50 @@ async function rootCommit(dir: string) {
return (await $`git rev-list --max-parents=0 HEAD`.cwd(dir).text()).trim()
}
describe("Project paths schemas", () => {
it.effect("decodes project path input and inline path results", () =>
Effect.sync(() => {
expect(Schema.decodeUnknownSync(ProjectV2.PathsInput)({ projectID: ProjectV2.ID.make("project") })).toEqual({
projectID: ProjectV2.ID.make("project"),
})
expect(
Schema.decodeUnknownSync(ProjectV2.Paths)([{ path: AbsolutePath.make("/tmp/project"), primary: true }]),
).toEqual([{ path: AbsolutePath.make("/tmp/project"), primary: true }])
}),
)
pathsIt.effect("lists stored project paths only for the requested project", () =>
Effect.gen(function* () {
const project = yield* ProjectV2.Service
const { db } = yield* Database.Service
const projectID = ProjectV2.ID.make("paths-project")
const otherID = ProjectV2.ID.make("paths-other")
yield* db
.insert(ProjectTable)
.values([
{ id: projectID, worktree: "/repo", sandboxes: [], time_created: 1, time_updated: 1 },
{ id: otherID, worktree: "/other", sandboxes: [], time_created: 1, time_updated: 1 },
])
.run()
.pipe(Effect.orDie)
yield* db
.insert(ProjectPathTable)
.values([
{ project_id: projectID, path: AbsolutePath.make("/repo/z"), primary: false },
{ project_id: projectID, path: AbsolutePath.make("/repo/a"), primary: true },
{ project_id: otherID, path: AbsolutePath.make("/other"), primary: true },
])
.run()
.pipe(Effect.orDie)
expect(yield* project.paths({ projectID })).toEqual([
{ path: AbsolutePath.make("/repo/a"), primary: true },
{ path: AbsolutePath.make("/repo/z"), primary: false },
])
}),
)
})
describe("ProjectV2.resolve", () => {
it.live("returns global for non-git directory", () =>
Effect.gen(function* () {
+56
View File
@@ -1,6 +1,7 @@
import { and, eq, sql } from "drizzle-orm"
import { Database } from "@opencode-ai/core/database/database"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { ProjectPathTable } from "@opencode-ai/core/project/path.sql"
import { PermissionTable, SessionTable } from "@opencode-ai/core/session/sql"
import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql"
import * as Log from "@opencode-ai/core/util/log"
@@ -249,6 +250,56 @@ export const layer = Layer.effect(
.pipe(Effect.orDie)
})
const rememberProjectPath = Effect.fn("Project.rememberProjectPath")(function* (input: {
projectID: ProjectV2.ID
path: string
}) {
if (input.projectID === ProjectV2.ID.global) return
yield* db
.transaction(
(d) =>
Effect.gen(function* () {
const existing = yield* d
.select()
.from(ProjectPathTable)
.where(and(eq(ProjectPathTable.project_id, input.projectID), eq(ProjectPathTable.path, input.path)))
.get()
const primary = yield* d
.select({ path: ProjectPathTable.path })
.from(ProjectPathTable)
.where(and(eq(ProjectPathTable.project_id, input.projectID), eq(ProjectPathTable.primary, true)))
.get()
if (existing) {
if (!primary) {
yield* d
.update(ProjectPathTable)
.set({ primary: true })
.where(and(eq(ProjectPathTable.project_id, input.projectID), eq(ProjectPathTable.path, input.path)))
.run()
}
return
}
yield* d
.insert(ProjectPathTable)
.values({
path: input.path,
primary: !primary,
project_id: input.projectID,
})
.run()
}),
{ behavior: "immediate" },
)
.pipe(
Effect.catchCause((cause) =>
Effect.sync(() => log.warn("project path persistence failed", { projectID: input.projectID, cause })),
),
)
})
const fromDirectory = Effect.fn("Project.fromDirectory")(function* (directory: string) {
log.info("fromDirectory", { directory })
@@ -336,6 +387,11 @@ export const layer = Layer.effect(
.pipe(Effect.orDie)
}
yield* rememberProjectPath({
projectID,
path: data.directory,
})
yield* emitUpdated(result)
if (projectID !== ProjectV2.ID.global && data.vcs?.type === "git") {
yield* projectV2.commit({ store: data.vcs.store, id: data.id })
@@ -0,0 +1,128 @@
import { describe, expect } from "bun:test"
import { $ } from "bun"
import path from "path"
import { eq } from "drizzle-orm"
import { Effect, Layer } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Hash } from "@opencode-ai/core/util/hash"
import { Database } from "@opencode-ai/core/database/database"
import { ProjectPathTable } from "@opencode-ai/core/project/path.sql"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { ProjectV2 } from "@opencode-ai/core/project"
import { Project } from "@/project/project"
import { tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const it = testEffect(Layer.mergeAll(Project.defaultLayer, Database.defaultLayer, CrossSpawnSpawner.defaultLayer))
function paths(projectID: ProjectV2.ID) {
return Database.Service.use(({ db }) =>
db
.select()
.from(ProjectPathTable)
.where(eq(ProjectPathTable.project_id, projectID))
.all()
.pipe(
Effect.orDie,
Effect.map((rows) =>
rows.map((row) => ({ path: row.path, primary: row.primary })).toSorted((a, b) => a.path.localeCompare(b.path)),
),
),
)
}
describe("Project path persistence", () => {
it.live("stores the first opened checkout as the primary path", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const project = yield* Project.Service
const result = yield* project.fromDirectory(tmp)
expect(yield* paths(result.project.id)).toEqual([{ path: tmp, primary: true }])
}),
)
it.live("stores an opened linked worktree as a secondary path", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const project = yield* Project.Service
const main = yield* project.fromDirectory(tmp)
const worktree = path.join(tmp, "..", path.basename(tmp) + "-project-path-worktree")
yield* Effect.addFinalizer(() =>
Effect.promise(() => $`git worktree remove ${worktree}`.cwd(tmp).quiet().nothrow()).pipe(Effect.ignore),
)
yield* Effect.promise(() => $`git worktree add ${worktree} -b project-path-${Date.now()}`.cwd(tmp).quiet())
yield* project.fromDirectory(worktree)
expect(yield* paths(main.project.id)).toEqual(
[
{ path: tmp, primary: true },
{ path: worktree, primary: false },
].toSorted((a, b) => a.path.localeCompare(b.path)),
)
}),
)
it.live("stores a separately opened clone as a secondary path", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const bare = tmp + "-project-path-bare"
const clone = tmp + "-project-path-clone"
yield* Effect.addFinalizer(() =>
Effect.promise(() => $`rm -rf ${bare} ${clone}`.quiet().nothrow()).pipe(Effect.ignore),
)
yield* Effect.promise(() => $`git clone --bare ${tmp} ${bare}`.quiet())
yield* Effect.promise(() => $`git clone ${bare} ${clone}`.quiet())
const project = yield* Project.Service
const main = yield* project.fromDirectory(tmp)
yield* project.fromDirectory(clone)
expect(yield* paths(main.project.id)).toEqual(
[
{ path: tmp, primary: true },
{ path: clone, primary: false },
].toSorted((a, b) => a.path.localeCompare(b.path)),
)
}),
)
it.live("keeps an existing remote project primary when migrating an inferred project id", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const project = yield* Project.Service
yield* project.fromDirectory(tmp)
const remoteID = ProjectV2.ID.make(Hash.fast("git-remote:github.com/project-path-test/collision"))
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({
id: remoteID,
worktree: "/tmp/existing",
vcs: "git",
time_created: Date.now(),
time_updated: Date.now(),
sandboxes: [],
})
.run()
.pipe(Effect.orDie)
yield* db
.insert(ProjectPathTable)
.values({ project_id: remoteID, path: "/tmp/existing", primary: true })
.run()
.pipe(Effect.orDie)
yield* Effect.promise(() => $`git remote add origin git@github.com:project-path-test/collision.git`.cwd(tmp).quiet())
yield* project.fromDirectory(tmp)
expect(yield* paths(remoteID)).toEqual(
[
{ path: "/tmp/existing", primary: true },
{ path: tmp, primary: false },
].toSorted((a, b) => a.path.localeCompare(b.path)),
)
}),
)
})