feat(core): refactor project copies for v2 (#31943)

This commit is contained in:
James Long 2026-06-12 14:45:16 -04:00 committed by GitHub
commit c2e6b18076
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 1461 additions and 829 deletions

View file

@ -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 },
])
}),
)
})

View file

@ -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),

View file

@ -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())

View file

@ -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 },