feat(core): refactor project copies for v2 (#31943)
This commit is contained in:
parent
8d97c8d412
commit
c2e6b18076
33 changed files with 1461 additions and 829 deletions
|
|
@ -2,6 +2,7 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
|||
import { and, eq, sql } from "drizzle-orm"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { ProjectDirectoryTable, ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { ProjectDirectories } from "@opencode-ai/core/project/directories"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
|
|
@ -14,7 +15,6 @@ 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"
|
||||
|
|
@ -138,7 +138,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 projectDirectories = yield* ProjectDirectories.Service
|
||||
const events = yield* EventV2Bridge.Service
|
||||
const flags = yield* RuntimeFlags.Service
|
||||
const { db } = yield* Database.Service
|
||||
|
|
@ -197,6 +197,12 @@ export const layer = Layer.effect(
|
|||
.run()
|
||||
}
|
||||
|
||||
// Project directories may be shared across distinct
|
||||
// checkouts which have diverged. Clear the directory
|
||||
// list and rely on it being re-populated to ensure
|
||||
// accuracy
|
||||
yield* d.delete(ProjectDirectoryTable).where(eq(ProjectDirectoryTable.project_id, oldID)).run()
|
||||
|
||||
yield* d
|
||||
.update(SessionTable)
|
||||
.set({ project_id: newID, time_updated: sql`${SessionTable.time_updated}` })
|
||||
|
|
@ -221,27 +227,11 @@ export const layer = Layer.effect(
|
|||
}) {
|
||||
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" },
|
||||
)
|
||||
yield* projectDirectories
|
||||
.create({
|
||||
directory: opened,
|
||||
projectID: input.projectID,
|
||||
})
|
||||
.pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logWarning("project directory persistence failed", { projectID: input.projectID, cause }),
|
||||
|
|
@ -505,7 +495,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(ProjectDirectories.defaultLayer),
|
||||
Layer.provide(AppProcess.defaultLayer),
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
|
|
@ -520,7 +510,7 @@ export const node = LayerNode.make(layer, [
|
|||
AppProcess.node,
|
||||
CrossSpawnSpawner.node,
|
||||
ProjectV2.node,
|
||||
ProjectCopy.node,
|
||||
ProjectDirectories.node,
|
||||
EventV2Bridge.node,
|
||||
RuntimeFlags.node,
|
||||
Database.node,
|
||||
|
|
|
|||
|
|
@ -1,87 +1,31 @@
|
|||
import { ProjectCopy } from "@opencode-ai/core/project/copy"
|
||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../middleware/authorization"
|
||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||
import {
|
||||
WorkspaceRoutingMiddleware,
|
||||
WorkspaceRoutingQuery,
|
||||
WorkspaceRoutingQueryFields,
|
||||
} from "../middleware/workspace-routing"
|
||||
import { described } from "./metadata"
|
||||
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
|
||||
|
||||
const root = "/experimental/project/:projectID/copy"
|
||||
const CopyQuery = Schema.Struct({
|
||||
workspace: WorkspaceRoutingQueryFields.workspace,
|
||||
export const GenerateNamePayload = Schema.Struct({
|
||||
context: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
export const CreatePayload = Schema.Struct({
|
||||
strategy: ProjectCopy.StrategyID,
|
||||
directory: ProjectCopy.CreateInput.fields.directory,
|
||||
name: ProjectCopy.CreateInput.fields.name,
|
||||
context: ProjectCopy.CreateInput.fields.context,
|
||||
})
|
||||
export const RemovePayload = Schema.Struct({
|
||||
directory: ProjectCopy.RemoveInput.fields.directory,
|
||||
force: ProjectCopy.RemoveInput.fields.force,
|
||||
})
|
||||
|
||||
export class ApiProjectCopyError extends Schema.ErrorClass<ApiProjectCopyError>("ProjectCopyError")(
|
||||
{
|
||||
name: Schema.Literal("ProjectCopyError"),
|
||||
data: Schema.Struct({
|
||||
message: Schema.String,
|
||||
forceRequired: Schema.optional(Schema.Boolean),
|
||||
}),
|
||||
},
|
||||
{ httpApiStatus: 400 },
|
||||
) {}
|
||||
|
||||
export const ProjectCopyApi = HttpApi.make("projectCopy").add(
|
||||
HttpApiGroup.make("projectCopy")
|
||||
export const ProjectCopyApi = HttpApi.make("projectCopyName").add(
|
||||
HttpApiGroup.make("projectCopyName")
|
||||
.add(
|
||||
HttpApiEndpoint.post("create", root, {
|
||||
params: { projectID: ProjectV2.ID },
|
||||
query: CopyQuery,
|
||||
payload: CreatePayload,
|
||||
success: described(ProjectCopy.Copy, "Project copy created"),
|
||||
error: ApiProjectCopyError,
|
||||
}).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: CopyQuery,
|
||||
payload: RemovePayload,
|
||||
success: described(HttpApiSchema.NoContent, "Project copy removed"),
|
||||
error: ApiProjectCopyError,
|
||||
}).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`, {
|
||||
HttpApiEndpoint.post("generateName", "/experimental/project/:projectID/copy/generate-name", {
|
||||
params: { projectID: ProjectV2.ID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: HttpApiSchema.NoContent,
|
||||
success: described(HttpApiSchema.NoContent, "Project copies refreshed"),
|
||||
error: ApiProjectCopyError,
|
||||
payload: GenerateNamePayload,
|
||||
success: Schema.Struct({ name: Schema.String }),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "experimental.projectCopy.refresh",
|
||||
summary: "Refresh project copies",
|
||||
description: "Discover local project copies using one or all configured strategies.",
|
||||
identifier: "experimental.projectCopy.generateName",
|
||||
summary: "Generate project copy name",
|
||||
description: "Generate a short name for a project copy from task context.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(OpenApi.annotations({ title: "projectCopy", description: "Project copy management routes." }))
|
||||
.annotateMerge(OpenApi.annotations({ title: "projectCopy", description: "Project copy naming routes." }))
|
||||
.middleware(InstanceContextMiddleware)
|
||||
.middleware(WorkspaceRoutingMiddleware)
|
||||
.middleware(Authorization),
|
||||
|
|
|
|||
|
|
@ -1,75 +1,45 @@
|
|||
import { ProjectCopy } from "@opencode-ai/core/project/copy"
|
||||
import { Git } from "@opencode-ai/core/git"
|
||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { LLM } from "@/session/llm"
|
||||
import { MessageID, SessionID } from "@/session/schema"
|
||||
import { Slug } from "@opencode-ai/core/util/slug"
|
||||
import { LLMEvent } from "@opencode-ai/llm"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { InstanceHttpApi } from "../api"
|
||||
import { ApiProjectCopyError, CreatePayload, RemovePayload } from "../groups/project-copy"
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { LLM } from "@/session/llm"
|
||||
import { LLMEvent } from "@opencode-ai/llm"
|
||||
import { MessageID, SessionID } from "@/session/schema"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { Slug } from "@opencode-ai/core/util/slug"
|
||||
|
||||
const FALLBACK_AGENT: Agent.Info = {
|
||||
name: "title",
|
||||
mode: "primary" as const,
|
||||
const COPY_NAME_AGENT: Agent.Info = {
|
||||
name: "project-copy-name",
|
||||
mode: "primary",
|
||||
permission: [],
|
||||
options: {},
|
||||
native: true,
|
||||
prompt: "",
|
||||
}
|
||||
|
||||
function badRequest<A, R>(effect: Effect.Effect<A, ProjectCopy.Error, R>) {
|
||||
return effect.pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new ApiProjectCopyError({
|
||||
name: "ProjectCopyError",
|
||||
data: {
|
||||
message: message(error),
|
||||
forceRequired: error instanceof Git.WorktreeError ? error.forceRequired : undefined,
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export const projectCopyHandlers = HttpApiBuilder.group(InstanceHttpApi, "projectCopy", (handlers) =>
|
||||
export const projectCopyHandlers = HttpApiBuilder.group(InstanceHttpApi, "projectCopyName", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* LLM.Service
|
||||
const agent = yield* Agent.Service
|
||||
const provider = yield* Provider.Service
|
||||
const service = yield* ProjectCopy.Service
|
||||
|
||||
const generateName = Effect.fn("ProjectCopyHttpApi.generateName")(function* (context: string | undefined) {
|
||||
const text = context?.trim()
|
||||
if (!text) return Slug.create()
|
||||
const [titleAgent, fallback] = yield* Effect.all(
|
||||
[
|
||||
agent.get("title").pipe(Effect.catch(() => Effect.succeed(FALLBACK_AGENT))),
|
||||
provider.defaultModel().pipe(Effect.catch(() => Effect.succeed(undefined))),
|
||||
],
|
||||
{ concurrency: 2 },
|
||||
)
|
||||
const fallback = yield* provider.defaultModel().pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (!fallback) return Slug.create()
|
||||
const model = titleAgent.model
|
||||
? yield* provider.getModel(titleAgent.model.providerID, titleAgent.model.modelID)
|
||||
: ((yield* provider.getSmallModel(fallback.providerID)) ??
|
||||
(yield* provider.getModel(fallback.providerID, fallback.modelID)))
|
||||
const model =
|
||||
(yield* provider.getSmallModel(fallback.providerID)) ??
|
||||
(yield* provider.getModel(fallback.providerID, fallback.modelID))
|
||||
const sessionID = SessionID.descending()
|
||||
const result = yield* llm
|
||||
.stream({
|
||||
agent: titleAgent,
|
||||
agent: COPY_NAME_AGENT,
|
||||
user: {
|
||||
id: MessageID.ascending(),
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: { created: Date.now() },
|
||||
agent: titleAgent.name,
|
||||
agent: COPY_NAME_AGENT.name,
|
||||
model: { providerID: model.providerID, modelID: model.id },
|
||||
},
|
||||
system: [],
|
||||
|
|
@ -78,12 +48,7 @@ export const projectCopyHandlers = HttpApiBuilder.group(InstanceHttpApi, "projec
|
|||
model,
|
||||
sessionID,
|
||||
retries: 2,
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: `Generate a short 3-4 word name that describes this task:\n${text}`,
|
||||
},
|
||||
],
|
||||
messages: [{ role: "user", content: `Generate a short 2-3 word name that describes this task:\n${text}` }],
|
||||
})
|
||||
.pipe(
|
||||
Stream.filter(LLMEvent.is.textDelta),
|
||||
|
|
@ -91,47 +56,20 @@ export const projectCopyHandlers = HttpApiBuilder.group(InstanceHttpApi, "projec
|
|||
Stream.mkString,
|
||||
)
|
||||
const output = result.trim()
|
||||
return output ? slugify(output.split(/\s+/).slice(0, 4).join(" ")) : Slug.create()
|
||||
return output ? slugify(output.split(/\s+/).slice(0, 3).join(" ")) : Slug.create()
|
||||
})
|
||||
|
||||
const create = Effect.fn("ProjectCopyHttpApi.create")(function* (ctx: {
|
||||
params: { projectID: ProjectV2.ID }
|
||||
payload: typeof CreatePayload.Type
|
||||
}) {
|
||||
const name =
|
||||
ctx.payload.name ??
|
||||
(yield* generateName(ctx.payload.context).pipe(Effect.catch(() => Effect.succeed(Slug.create()))))
|
||||
return yield* badRequest(
|
||||
service.create({
|
||||
...ctx.payload,
|
||||
name,
|
||||
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)
|
||||
return handlers.handle("generateName", (ctx) =>
|
||||
generateName(ctx.payload.context).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logWarning("project copy name generation failed", {
|
||||
projectID: ctx.params.projectID,
|
||||
cause,
|
||||
}).pipe(Effect.as(Slug.create())),
|
||||
),
|
||||
Effect.map((name) => ({ name })),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -143,15 +81,3 @@ function slugify(input: string) {
|
|||
.replace(/^-+/, "")
|
||||
.replace(/-+$/, "")
|
||||
}
|
||||
|
||||
function message(error: ProjectCopy.Error) {
|
||||
if (error instanceof ProjectCopy.SourceDirectoryNotFoundError)
|
||||
return `Project copy source not found: ${error.directory}`
|
||||
if (error instanceof ProjectCopy.DestinationExistsError)
|
||||
return `Project copy destination already exists: ${error.directory}`
|
||||
if (error instanceof ProjectCopy.DirectoryUnavailableError)
|
||||
return `Project copy directory unavailable: ${error.directory}`
|
||||
if (error instanceof ProjectCopy.StrategyNotFoundError)
|
||||
return `Project copy strategy not found for: ${error.directory}`
|
||||
return error.message
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ function directories(projectID: ProjectV2.ID) {
|
|||
Effect.orDie,
|
||||
Effect.map((rows) =>
|
||||
rows
|
||||
.map((row) => ({ directory: row.directory, type: row.type }))
|
||||
.map((row) => ({ directory: row.directory, strategy: row.strategy ?? undefined }))
|
||||
.toSorted((a, b) => a.directory.localeCompare(b.directory)),
|
||||
),
|
||||
),
|
||||
|
|
@ -41,7 +41,9 @@ describe("Project directory persistence", () => {
|
|||
|
||||
const result = yield* project.fromDirectory(tmp)
|
||||
|
||||
expect(yield* directories(result.project.id)).toEqual([{ directory: tmp, type: "main" }])
|
||||
expect(yield* directories(result.project.id)).toEqual([
|
||||
{ directory: AbsolutePath.make(tmp), strategy: undefined },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -54,7 +56,9 @@ describe("Project directory persistence", () => {
|
|||
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" }])
|
||||
expect(yield* directories(result.project.id)).toEqual([
|
||||
{ directory: AbsolutePath.make(tmp), strategy: undefined },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -73,8 +77,8 @@ describe("Project directory persistence", () => {
|
|||
|
||||
expect(yield* directories(main.project.id)).toEqual(
|
||||
[
|
||||
{ directory: tmp, type: "main" as const },
|
||||
{ directory: worktree, type: "git_worktree" as const },
|
||||
{ directory: AbsolutePath.make(tmp), strategy: undefined },
|
||||
{ directory: AbsolutePath.make(worktree), strategy: undefined },
|
||||
].toSorted((a, b) => a.directory.localeCompare(b.directory)),
|
||||
)
|
||||
}),
|
||||
|
|
@ -92,7 +96,9 @@ describe("Project directory persistence", () => {
|
|||
|
||||
const result = yield* project.fromDirectory(worktree)
|
||||
|
||||
expect(yield* directories(result.project.id)).toEqual([{ directory: worktree, type: "git_worktree" }])
|
||||
expect(yield* directories(result.project.id)).toEqual([
|
||||
{ directory: AbsolutePath.make(worktree), strategy: undefined },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -113,8 +119,8 @@ describe("Project directory persistence", () => {
|
|||
|
||||
expect(yield* directories(main.project.id)).toEqual(
|
||||
[
|
||||
{ directory: tmp, type: "main" as const },
|
||||
{ directory: clone, type: "root" as const },
|
||||
{ directory: AbsolutePath.make(tmp), strategy: undefined },
|
||||
{ directory: AbsolutePath.make(clone), strategy: undefined },
|
||||
].toSorted((a, b) => a.directory.localeCompare(b.directory)),
|
||||
)
|
||||
}),
|
||||
|
|
@ -134,7 +140,9 @@ describe("Project directory persistence", () => {
|
|||
|
||||
const result = yield* project.fromDirectory(worktree)
|
||||
|
||||
expect(yield* directories(result.project.id)).toEqual([{ directory: worktree, type: "git_worktree" }])
|
||||
expect(yield* directories(result.project.id)).toEqual([
|
||||
{ directory: AbsolutePath.make(worktree), strategy: undefined },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -163,7 +171,35 @@ describe("Project directory persistence", () => {
|
|||
|
||||
yield* project.fromDirectory(tmp)
|
||||
|
||||
expect(yield* directories(remoteID)).toEqual([{ directory: tmp, type: "main" }])
|
||||
expect(yield* directories(remoteID)).toEqual([
|
||||
{ directory: AbsolutePath.make(tmp), strategy: undefined },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("clears stale directories when the project id changes", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const project = yield* Project.Service
|
||||
const original = yield* project.fromDirectory(tmp)
|
||||
const stale = AbsolutePath.make(tmp + "-stale-checkout")
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
.insert(ProjectDirectoryTable)
|
||||
.values({ project_id: original.project.id, directory: stale })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const remoteID = ProjectV2.ID.make(Hash.fast("git-remote:github.com/project-directory-test/migration"))
|
||||
yield* Effect.promise(() =>
|
||||
$`git remote add origin git@github.com:project-directory-test/migration.git`.cwd(tmp).quiet(),
|
||||
)
|
||||
|
||||
yield* project.fromDirectory(tmp)
|
||||
|
||||
expect(yield* directories(original.project.id)).toEqual([])
|
||||
expect(yield* directories(remoteID)).toEqual([
|
||||
{ directory: AbsolutePath.make(tmp), strategy: undefined },
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -19,7 +19,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 { ProjectDirectories } from "@opencode-ai/core/project/directories"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
|
|
@ -73,7 +73,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(ProjectDirectories.defaultLayer),
|
||||
Layer.provide(EventV2Bridge.defaultLayer),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(NodePath.layer),
|
||||
|
|
@ -86,7 +86,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(ProjectDirectories.defaultLayer),
|
||||
Layer.provide(AppProcess.defaultLayer),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(NodePath.layer),
|
||||
|
|
|
|||
|
|
@ -222,6 +222,18 @@ const scenarios: Scenario[] = [
|
|||
headers: ctx.headers(),
|
||||
}))
|
||||
.json(200, array, "status"),
|
||||
http.protected
|
||||
.post("/experimental/project/{projectID}/copy/generate-name", "experimental.projectCopy.generateName")
|
||||
.seeded((ctx) => ctx.project())
|
||||
.at((ctx) => ({
|
||||
path: route("/experimental/project/{projectID}/copy/generate-name", { projectID: ctx.state.id }),
|
||||
headers: ctx.headers(),
|
||||
body: {},
|
||||
}))
|
||||
.json(200, (body) => {
|
||||
object(body)
|
||||
check(typeof body.name === "string" && body.name.length > 0, "generated copy name should be non-empty")
|
||||
}),
|
||||
http.protected
|
||||
.post("/experimental/project/{projectID}/copy", "experimental.projectCopy.create")
|
||||
.seeded((ctx) => ctx.project())
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ function json<T>(response: HttpClientResponse.HttpClientResponse) {
|
|||
}
|
||||
|
||||
describe("project directories and copies endpoints", () => {
|
||||
type ProjectDirectory = { directory: string; type: "main" | "root" | "git_worktree" }
|
||||
type ProjectDirectory = { directory: string; strategy?: string }
|
||||
|
||||
it.instance(
|
||||
"lists directories and manages git worktree copies",
|
||||
|
|
@ -44,7 +44,7 @@ describe("project directories and copies endpoints", () => {
|
|||
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 copies = `/experimental/project/${projectID}/copy?location%5Bdirectory%5D=${encodeURIComponent(test.directory)}`
|
||||
const createdParent = path.join(test.directory, "..", path.basename(test.directory) + "-http-copy")
|
||||
const createdDirectory = path.join(createdParent, "copy")
|
||||
yield* Effect.addFinalizer(() =>
|
||||
|
|
@ -53,7 +53,15 @@ describe("project directories and copies endpoints", () => {
|
|||
|
||||
const initial = yield* request(test.directory, `${base}/directories`)
|
||||
expect(initial.status).toBe(200)
|
||||
expect(yield* json<ProjectDirectory[]>(initial)).toEqual([{ directory: test.directory, type: "main" }])
|
||||
expect(yield* json<ProjectDirectory[]>(initial)).toEqual([{ directory: test.directory }])
|
||||
|
||||
const generated = yield* request(test.directory, `/experimental/project/${projectID}/copy/generate-name`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ context: undefined }),
|
||||
})
|
||||
expect(generated.status).toBe(200)
|
||||
expect((yield* json<{ name: string }>(generated)).name).toBeString()
|
||||
|
||||
const create = yield* request(test.directory, copies, {
|
||||
method: "POST",
|
||||
|
|
@ -67,7 +75,7 @@ describe("project directories and copies endpoints", () => {
|
|||
const listed = yield* request(test.directory, `${base}/directories`)
|
||||
expect(yield* json<ProjectDirectory[]>(listed)).toContainEqual({
|
||||
directory: created.directory,
|
||||
type: "git_worktree",
|
||||
strategy: "git_worktree",
|
||||
})
|
||||
|
||||
yield* Effect.promise(() => Bun.write(path.join(created.directory, "dirty.txt"), "dirty"))
|
||||
|
|
@ -94,14 +102,18 @@ describe("project directories and copies endpoints", () => {
|
|||
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`, {
|
||||
const refresh = yield* request(
|
||||
test.directory,
|
||||
`/experimental/project/${projectID}/copy/refresh?location%5Bdirectory%5D=${encodeURIComponent(test.directory)}`,
|
||||
{
|
||||
method: "POST",
|
||||
})
|
||||
},
|
||||
)
|
||||
expect(refresh.status).toBe(204)
|
||||
const refreshed = yield* request(test.directory, `${base}/directories`)
|
||||
expect(yield* json<ProjectDirectory[]>(refreshed)).toEqual([
|
||||
{ directory: externalDirectory, type: "git_worktree" },
|
||||
{ directory: test.directory, type: "main" },
|
||||
{ directory: externalDirectory, strategy: "git_worktree" },
|
||||
{ directory: test.directory },
|
||||
])
|
||||
}),
|
||||
{ git: true },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue