feat(core): project copying and tracking directories (#30139)
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { and, eq, sql } from "drizzle-orm"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { ProjectDirectoryTable, ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { 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"
|
||||
@@ -14,6 +14,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { AppProcess } from "@opencode-ai/core/process"
|
||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||
import { ProjectCopy } from "@opencode-ai/core/project/copy"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { AbsolutePath, NonNegativeInt, optionalOmitUndefined } from "@opencode-ai/core/schema"
|
||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||
@@ -139,6 +140,7 @@ export const layer = Layer.effect(
|
||||
const proc = yield* AppProcess.Service
|
||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||
const projectV2 = yield* ProjectV2.Service
|
||||
const projectCopy = yield* ProjectCopy.Service
|
||||
const events = yield* EventV2Bridge.Service
|
||||
const flags = yield* RuntimeFlags.Service
|
||||
const { db } = yield* Database.Service
|
||||
@@ -215,6 +217,38 @@ export const layer = Layer.effect(
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
const saveProjectDirectory = Effect.fn("Project.saveProjectDirectory")(function* (input: {
|
||||
projectID: ProjectV2.ID
|
||||
directory: string
|
||||
}) {
|
||||
if (input.projectID === ProjectV2.ID.global) return
|
||||
const opened = AbsolutePath.make(FSUtil.resolve(input.directory))
|
||||
const type = yield* projectCopy.detect({ directory: opened })
|
||||
|
||||
yield* db
|
||||
.transaction(
|
||||
(d) =>
|
||||
Effect.gen(function* () {
|
||||
const hasMain = yield* d
|
||||
.select({ directory: ProjectDirectoryTable.directory })
|
||||
.from(ProjectDirectoryTable)
|
||||
.where(and(eq(ProjectDirectoryTable.project_id, input.projectID), eq(ProjectDirectoryTable.type, "main")))
|
||||
.get()
|
||||
yield* d
|
||||
.insert(ProjectDirectoryTable)
|
||||
.values({ directory: opened, project_id: input.projectID, type: type ?? (hasMain ? "root" : "main") })
|
||||
.onConflictDoNothing()
|
||||
.run()
|
||||
}),
|
||||
{ behavior: "immediate" },
|
||||
)
|
||||
.pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.sync(() => log.warn("project directory persistence failed", { projectID: input.projectID, cause })),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const fromDirectory = Effect.fn("Project.fromDirectory")(function* (directory: string) {
|
||||
log.info("fromDirectory", { directory })
|
||||
|
||||
@@ -302,6 +336,11 @@ export const layer = Layer.effect(
|
||||
.pipe(Effect.orDie)
|
||||
}
|
||||
|
||||
yield* saveProjectDirectory({
|
||||
projectID,
|
||||
directory: data.directory,
|
||||
})
|
||||
|
||||
yield* emitUpdated(result)
|
||||
if (projectID !== ProjectV2.ID.global && data.vcs?.type === "git") {
|
||||
yield* projectV2.commit({ store: data.vcs.store, id: data.id })
|
||||
@@ -466,6 +505,7 @@ export const layer = Layer.effect(
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(EventV2Bridge.defaultLayer),
|
||||
Layer.provide(ProjectV2.defaultLayer),
|
||||
Layer.provide(ProjectCopy.defaultLayer),
|
||||
Layer.provide(AppProcess.defaultLayer),
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
|
||||
@@ -12,6 +12,7 @@ import { InstanceApi } from "./groups/instance"
|
||||
import { McpApi } from "./groups/mcp"
|
||||
import { PermissionApi } from "./groups/permission"
|
||||
import { ProjectApi } from "./groups/project"
|
||||
import { ProjectCopyApi } from "./groups/project-copy"
|
||||
import { ProviderApi } from "./groups/provider"
|
||||
import { PtyApi, PtyConnectApi } from "./groups/pty"
|
||||
import { QuestionApi } from "./groups/question"
|
||||
@@ -52,6 +53,7 @@ export const InstanceHttpApi = HttpApi.make("opencode-instance")
|
||||
.addHttpApi(InstanceApi)
|
||||
.addHttpApi(McpApi)
|
||||
.addHttpApi(ProjectApi)
|
||||
.addHttpApi(ProjectCopyApi)
|
||||
.addHttpApi(PtyApi)
|
||||
.addHttpApi(QuestionApi)
|
||||
.addHttpApi(PermissionApi)
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { ProjectCopy } from "@opencode-ai/core/project/copy"
|
||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../middleware/authorization"
|
||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
|
||||
import { described } from "./metadata"
|
||||
|
||||
const root = "/experimental/project/:projectID/copy"
|
||||
|
||||
export const CreatePayload = Schema.Struct({
|
||||
strategy: ProjectCopy.StrategyID,
|
||||
directory: ProjectCopy.CreateInput.fields.directory,
|
||||
})
|
||||
export const RemovePayload = Schema.Struct({
|
||||
directory: ProjectCopy.RemoveInput.fields.directory,
|
||||
})
|
||||
|
||||
export const ProjectCopyApi = HttpApi.make("projectCopy").add(
|
||||
HttpApiGroup.make("projectCopy")
|
||||
.add(
|
||||
HttpApiEndpoint.post("create", root, {
|
||||
params: { projectID: ProjectV2.ID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: CreatePayload,
|
||||
success: described(ProjectCopy.Copy, "Project copy created"),
|
||||
error: HttpApiError.BadRequest,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "experimental.projectCopy.create",
|
||||
summary: "Create project copy",
|
||||
description: "Create a local physical copy of a project using the selected strategy.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.delete("remove", root, {
|
||||
params: { projectID: ProjectV2.ID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: RemovePayload,
|
||||
success: described(HttpApiSchema.NoContent, "Project copy removed"),
|
||||
error: HttpApiError.BadRequest,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "experimental.projectCopy.remove",
|
||||
summary: "Remove project copy",
|
||||
description: "Remove a local physical copy of a project using the selected strategy.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("refresh", `${root}/refresh`, {
|
||||
params: { projectID: ProjectV2.ID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: HttpApiSchema.NoContent,
|
||||
success: described(HttpApiSchema.NoContent, "Project copies refreshed"),
|
||||
error: HttpApiError.BadRequest,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "experimental.projectCopy.refresh",
|
||||
summary: "Refresh project copies",
|
||||
description: "Discover local project copies using one or all configured strategies.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(OpenApi.annotations({ title: "projectCopy", description: "Project copy management routes." }))
|
||||
.middleware(InstanceContextMiddleware)
|
||||
.middleware(WorkspaceRoutingMiddleware)
|
||||
.middleware(Authorization),
|
||||
)
|
||||
@@ -62,6 +62,17 @@ export const ProjectApi = HttpApi.make("project")
|
||||
description: "Update project properties such as name, icon, and commands.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("directories", `${root}/:projectID/directories`, {
|
||||
params: { projectID: ProjectV2.ID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(ProjectV2.Directories, "Project directories"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "project.directories",
|
||||
summary: "List project directories",
|
||||
description: "List known local absolute directories for a project.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { ProjectCopy } from "@opencode-ai/core/project/copy"
|
||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
|
||||
import { InstanceHttpApi } from "../api"
|
||||
import { CreatePayload, RemovePayload } from "../groups/project-copy"
|
||||
|
||||
function badRequest<A, R>(effect: Effect.Effect<A, ProjectCopy.Error, R>) {
|
||||
return effect.pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
|
||||
}
|
||||
|
||||
export const projectCopyHandlers = HttpApiBuilder.group(InstanceHttpApi, "projectCopy", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ProjectCopy.Service
|
||||
|
||||
const create = Effect.fn("ProjectCopyHttpApi.create")(function* (ctx: {
|
||||
params: { projectID: ProjectV2.ID }
|
||||
payload: typeof CreatePayload.Type
|
||||
}) {
|
||||
return yield* badRequest(
|
||||
service.create({
|
||||
...ctx.payload,
|
||||
projectID: ctx.params.projectID,
|
||||
sourceDirectory: AbsolutePath.make((yield* InstanceState.context).worktree),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const remove = Effect.fn("ProjectCopyHttpApi.remove")(function* (ctx: {
|
||||
params: { projectID: ProjectV2.ID }
|
||||
payload: typeof RemovePayload.Type
|
||||
}) {
|
||||
yield* badRequest(
|
||||
service.remove({
|
||||
...ctx.payload,
|
||||
projectID: ctx.params.projectID,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const refresh = Effect.fn("ProjectCopyHttpApi.refresh")(function* (ctx: { params: { projectID: ProjectV2.ID } }) {
|
||||
yield* badRequest(
|
||||
service.refresh({
|
||||
projectID: ctx.params.projectID,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
return handlers.handle("create", create).handle("remove", remove).handle("refresh", refresh)
|
||||
}),
|
||||
)
|
||||
@@ -10,6 +10,7 @@ import { markInstanceForReload } from "../lifecycle"
|
||||
export const projectHandlers = HttpApiBuilder.group(InstanceHttpApi, "project", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* Project.Service
|
||||
const project = yield* ProjectV2.Service
|
||||
|
||||
const list = Effect.fn("ProjectHttpApi.list")(function* () {
|
||||
return yield* svc.list()
|
||||
@@ -48,6 +49,15 @@ export const projectHandlers = HttpApiBuilder.group(InstanceHttpApi, "project",
|
||||
)
|
||||
})
|
||||
|
||||
return handlers.handle("list", list).handle("current", current).handle("initGit", initGit).handle("update", update)
|
||||
const directories = Effect.fn("ProjectHttpApi.directories")((ctx: { params: { projectID: ProjectV2.ID } }) =>
|
||||
project.directories({ projectID: ctx.params.projectID }),
|
||||
)
|
||||
|
||||
return handlers
|
||||
.handle("list", list)
|
||||
.handle("current", current)
|
||||
.handle("initGit", initGit)
|
||||
.handle("update", update)
|
||||
.handle("directories", directories)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -26,6 +26,8 @@ import { Installation } from "@/installation"
|
||||
import { InstanceLayer } from "@/project/instance-layer"
|
||||
import { Plugin } from "@/plugin"
|
||||
import { Project } from "@/project/project"
|
||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||
import { ProjectCopy } from "@opencode-ai/core/project/copy"
|
||||
import { ProviderAuth } from "@/provider/auth"
|
||||
import { ModelsDev } from "@opencode-ai/core/models-dev"
|
||||
import { Provider } from "@/provider/provider"
|
||||
@@ -74,6 +76,7 @@ import { instanceHandlers } from "./handlers/instance"
|
||||
import { mcpHandlers } from "./handlers/mcp"
|
||||
import { permissionHandlers } from "./handlers/permission"
|
||||
import { projectHandlers } from "./handlers/project"
|
||||
import { projectCopyHandlers } from "./handlers/project-copy"
|
||||
import { providerHandlers } from "./handlers/provider"
|
||||
import { ptyConnectHandlers, ptyHandlers } from "./handlers/pty"
|
||||
import { questionHandlers } from "./handlers/question"
|
||||
@@ -135,6 +138,7 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe(
|
||||
instanceHandlers,
|
||||
mcpHandlers,
|
||||
projectHandlers,
|
||||
projectCopyHandlers,
|
||||
ptyHandlers,
|
||||
questionHandlers,
|
||||
permissionHandlers,
|
||||
@@ -204,6 +208,8 @@ export function createRoutes(
|
||||
Permission.defaultLayer,
|
||||
Plugin.defaultLayer,
|
||||
Project.defaultLayer,
|
||||
ProjectV2.defaultLayer,
|
||||
ProjectCopy.defaultLayer,
|
||||
ProviderAuth.defaultLayer,
|
||||
Provider.defaultLayer,
|
||||
Pty.defaultLayer,
|
||||
|
||||
169
packages/opencode/test/project/project-directory.test.ts
Normal file
169
packages/opencode/test/project/project-directory.test.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
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 { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { ProjectDirectoryTable, 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 directories(projectID: ProjectV2.ID) {
|
||||
return Database.Service.use(({ db }) =>
|
||||
db
|
||||
.select()
|
||||
.from(ProjectDirectoryTable)
|
||||
.where(eq(ProjectDirectoryTable.project_id, projectID))
|
||||
.all()
|
||||
.pipe(
|
||||
Effect.orDie,
|
||||
Effect.map((rows) =>
|
||||
rows
|
||||
.map((row) => ({ directory: row.directory, type: row.type }))
|
||||
.toSorted((a, b) => a.directory.localeCompare(b.directory)),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
describe("Project directory persistence", () => {
|
||||
it.live("stores the first opened checkout directory", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const project = yield* Project.Service
|
||||
|
||||
const result = yield* project.fromDirectory(tmp)
|
||||
|
||||
expect(yield* directories(result.project.id)).toEqual([{ directory: tmp, type: "main" }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("stores a repeatedly opened checkout directory only once", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const project = yield* Project.Service
|
||||
|
||||
const result = yield* project.fromDirectory(tmp)
|
||||
const next = yield* project.fromDirectory(tmp)
|
||||
|
||||
expect(next.project.id).toBe(result.project.id)
|
||||
expect(yield* directories(result.project.id)).toEqual([{ directory: tmp, type: "main" }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("stores an opened linked worktree directory", () =>
|
||||
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-directory-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-directory-${Date.now()}`.cwd(tmp).quiet())
|
||||
|
||||
yield* project.fromDirectory(worktree)
|
||||
|
||||
expect(yield* directories(main.project.id)).toEqual(
|
||||
[
|
||||
{ directory: tmp, type: "main" as const },
|
||||
{ directory: worktree, type: "git_worktree" as const },
|
||||
].toSorted((a, b) => a.directory.localeCompare(b.directory)),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("stores only the linked copy when first opened from an external linked worktree", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const worktree = path.join(tmp, "..", path.basename(tmp) + "-project-directory-first-worktree")
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(() => $`git worktree remove ${worktree}`.cwd(tmp).quiet().nothrow()).pipe(Effect.ignore),
|
||||
)
|
||||
yield* Effect.promise(() => $`git worktree add --detach ${worktree} HEAD`.cwd(tmp).quiet())
|
||||
const project = yield* Project.Service
|
||||
|
||||
const result = yield* project.fromDirectory(worktree)
|
||||
|
||||
expect(yield* directories(result.project.id)).toEqual([{ directory: worktree, type: "git_worktree" }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("stores a separately opened clone as a secondary directory", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const bare = tmp + "-project-directory-bare"
|
||||
const clone = tmp + "-project-directory-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* directories(main.project.id)).toEqual(
|
||||
[
|
||||
{ directory: tmp, type: "main" as const },
|
||||
{ directory: clone, type: "root" as const },
|
||||
].toSorted((a, b) => a.directory.localeCompare(b.directory)),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("stores only the materialized worktree for a bare repository", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const bare = tmp + "-project-directory-bare-store.git"
|
||||
const worktree = tmp + "-project-directory-bare-worktree"
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(() => $`rm -rf ${bare} ${worktree}`.quiet().nothrow()).pipe(Effect.ignore),
|
||||
)
|
||||
yield* Effect.promise(() => $`git clone --bare ${tmp} ${bare}`.quiet())
|
||||
yield* Effect.promise(() => $`git worktree add ${worktree} HEAD`.cwd(bare).quiet())
|
||||
const project = yield* Project.Service
|
||||
|
||||
const result = yield* project.fromDirectory(worktree)
|
||||
|
||||
expect(yield* directories(result.project.id)).toEqual([{ directory: worktree, type: "git_worktree" }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("records the active directory under its newly resolved 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-directory-test/collision"))
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({
|
||||
id: remoteID,
|
||||
worktree: AbsolutePath.make("/tmp/existing"),
|
||||
vcs: "git",
|
||||
time_created: Date.now(),
|
||||
time_updated: Date.now(),
|
||||
sandboxes: [],
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* Effect.promise(() =>
|
||||
$`git remote add origin git@github.com:project-directory-test/collision.git`.cwd(tmp).quiet(),
|
||||
)
|
||||
|
||||
yield* project.fromDirectory(tmp)
|
||||
|
||||
expect(yield* directories(remoteID)).toEqual([{ directory: tmp, type: "main" }])
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -20,6 +20,7 @@ import { NodePath } from "@effect/platform-node"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { AppProcess } from "@opencode-ai/core/process"
|
||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||
import { ProjectCopy } from "@opencode-ai/core/project/copy"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
@@ -75,6 +76,7 @@ function projectLayerWithFailure(failArg: string) {
|
||||
Layer.provide(AppProcess.layer.pipe(Layer.provide(mockGitFailure(failArg)))),
|
||||
Layer.provide(mockGitFailure(failArg)),
|
||||
Layer.provide(ProjectV2.defaultLayer),
|
||||
Layer.provide(ProjectCopy.defaultLayer),
|
||||
Layer.provide(EventV2Bridge.defaultLayer),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(NodePath.layer),
|
||||
@@ -87,6 +89,7 @@ function projectLayerWithRuntimeFlags(flags: Parameters<typeof RuntimeFlags.laye
|
||||
return Project.layer.pipe(
|
||||
Layer.provide(EventV2Bridge.defaultLayer),
|
||||
Layer.provide(ProjectV2.defaultLayer),
|
||||
Layer.provide(ProjectCopy.defaultLayer),
|
||||
Layer.provide(AppProcess.defaultLayer),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(NodePath.layer),
|
||||
|
||||
@@ -199,6 +199,41 @@ const scenarios: Scenario[] = [
|
||||
},
|
||||
"status",
|
||||
),
|
||||
http.protected
|
||||
.get("/project/{projectID}/directories", "project.directories")
|
||||
.seeded((ctx) => ctx.project())
|
||||
.at((ctx) => ({
|
||||
path: route("/project/{projectID}/directories", { projectID: ctx.state.id }),
|
||||
headers: ctx.headers(),
|
||||
}))
|
||||
.json(200, array, "status"),
|
||||
http.protected
|
||||
.post("/experimental/project/{projectID}/copy", "experimental.projectCopy.create")
|
||||
.seeded((ctx) => ctx.project())
|
||||
.at((ctx) => ({
|
||||
path: route("/experimental/project/{projectID}/copy", { projectID: ctx.state.id }),
|
||||
headers: ctx.headers(),
|
||||
body: {},
|
||||
}))
|
||||
.status(400),
|
||||
http.protected
|
||||
.delete("/experimental/project/{projectID}/copy", "experimental.projectCopy.remove")
|
||||
.seeded((ctx) => ctx.project())
|
||||
.at((ctx) => ({
|
||||
path: route("/experimental/project/{projectID}/copy", { projectID: ctx.state.id }),
|
||||
headers: ctx.headers(),
|
||||
body: {},
|
||||
}))
|
||||
.status(400),
|
||||
http.protected
|
||||
.post("/experimental/project/{projectID}/copy/refresh", "experimental.projectCopy.refresh")
|
||||
.mutating()
|
||||
.seeded((ctx) => ctx.project())
|
||||
.at((ctx) => ({
|
||||
path: route("/experimental/project/{projectID}/copy/refresh", { projectID: ctx.state.id }),
|
||||
headers: ctx.headers(),
|
||||
}))
|
||||
.status(204, undefined, "status"),
|
||||
http.protected.get("/provider", "provider.list").json(),
|
||||
http.protected.get("/provider/auth", "provider.auth").json(),
|
||||
http.protected
|
||||
|
||||
94
packages/opencode/test/server/project-copy.test.ts
Normal file
94
packages/opencode/test/server/project-copy.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { afterEach, describe, expect } from "bun:test"
|
||||
import { $ } from "bun"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { HttpClientResponse } from "effect/unstable/http"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
import { InstanceBootstrap } from "@/project/bootstrap-service"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void }))
|
||||
const testInstanceStore = InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap))
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(
|
||||
FSUtil.defaultLayer,
|
||||
Database.defaultLayer,
|
||||
Snapshot.defaultLayer,
|
||||
testInstanceStore,
|
||||
httpApiLayer,
|
||||
),
|
||||
)
|
||||
|
||||
function request(directory: string, url: string, init: RequestInit = {}) {
|
||||
return requestInDirectory(url, directory, init)
|
||||
}
|
||||
|
||||
function json<T>(response: HttpClientResponse.HttpClientResponse) {
|
||||
return response.json.pipe(Effect.map((value) => value as T))
|
||||
}
|
||||
|
||||
describe("project directories and copies endpoints", () => {
|
||||
it.instance(
|
||||
"lists directories and manages git worktree copies",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const current = yield* request(test.directory, "/project/current")
|
||||
const projectID = (yield* json<{ id: string }>(current)).id
|
||||
const base = `/project/${projectID}`
|
||||
const copies = `/experimental/project/${projectID}/copy`
|
||||
const createdDirectory = path.join(test.directory, "..", path.basename(test.directory) + "-http-copy")
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(() => fs.rm(createdDirectory, { recursive: true, force: true })).pipe(Effect.ignore),
|
||||
)
|
||||
|
||||
const initial = yield* request(test.directory, `${base}/directories`)
|
||||
expect(initial.status).toBe(200)
|
||||
expect(yield* json<string[]>(initial)).toEqual([test.directory])
|
||||
|
||||
const create = yield* request(test.directory, copies, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ strategy: "git_worktree", directory: createdDirectory }),
|
||||
})
|
||||
expect(create.status).toBe(200)
|
||||
const created = yield* json<{ directory: string }>(create)
|
||||
expect(created.directory).toContain("-http-copy")
|
||||
|
||||
const listed = yield* request(test.directory, `${base}/directories`)
|
||||
expect(yield* json<string[]>(listed)).toContain(created.directory)
|
||||
|
||||
const remove = yield* request(test.directory, copies, {
|
||||
method: "DELETE",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ directory: created.directory }),
|
||||
})
|
||||
expect(remove.status).toBe(204)
|
||||
|
||||
const externalDirectory = path.join(test.directory, "..", path.basename(test.directory) + "-http-refresh")
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(() => fs.rm(externalDirectory, { recursive: true, force: true })).pipe(Effect.ignore),
|
||||
)
|
||||
yield* Effect.promise(() => $`git worktree add --detach ${externalDirectory} HEAD`.cwd(test.directory).quiet())
|
||||
const refresh = yield* request(test.directory, `${copies}/refresh`, {
|
||||
method: "POST",
|
||||
})
|
||||
expect(refresh.status).toBe(204)
|
||||
const refreshed = yield* request(test.directory, `${base}/directories`)
|
||||
expect((yield* json<string[]>(refreshed)).length).toBe(2)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user