feat(core): project copying and tracking directories (#30139)

This commit is contained in:
James Long 2026-06-02 23:26:10 -04:00 committed by GitHub
parent 7a66eae586
commit 147c6c4d51
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 3638 additions and 10 deletions

View File

@ -0,0 +1,8 @@
CREATE TABLE `project_directory` (
`project_id` text NOT NULL,
`directory` text NOT NULL,
`type` text NOT NULL,
`time_created` integer NOT NULL,
CONSTRAINT `project_directory_pk` PRIMARY KEY(`project_id`, `directory`),
CONSTRAINT `fk_project_directory_project_id_project_id_fk` FOREIGN KEY (`project_id`) REFERENCES `project`(`id`) ON DELETE CASCADE
);

File diff suppressed because it is too large Load Diff

View File

@ -26,5 +26,6 @@ export const migrations = (
import("./migration/20260601010001_normalize_storage_paths"),
import("./migration/20260601202201_amazing_prowler"),
import("./migration/20260602002951_lowly_union_jack"),
import("./migration/20260602182828_add_project_directories"),
])
).map((module) => module.default) satisfies DatabaseMigration.Migration[]

View File

@ -0,0 +1,20 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260602182828_add_project_directories",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`
CREATE TABLE \`project_directory\` (
\`project_id\` text NOT NULL,
\`directory\` text NOT NULL,
\`type\` text NOT NULL,
\`time_created\` integer NOT NULL,
CONSTRAINT \`project_directory_pk\` PRIMARY KEY(\`project_id\`, \`directory\`),
CONSTRAINT \`fk_project_directory_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
);
`)
})
},
} satisfies DatabaseMigration.Migration

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 { FSUtil } from "./fs-util"
@ -26,6 +26,13 @@ export interface Repo {
readonly store: AbsolutePath
}
export class WorktreeError extends Schema.TaggedErrorClass<WorktreeError>()("Git.WorktreeError", {
operation: Schema.Literals(["create", "remove", "list"]),
message: Schema.String,
directory: Schema.optional(AbsolutePath),
cause: Schema.optional(Schema.Defect),
}) {}
export interface Interface {
readonly find: (input: AbsolutePath) => Effect.Effect<Repo | undefined>
readonly remote: (repo: Repo, name?: string) => Effect.Effect<string | undefined>
@ -45,6 +52,9 @@ export interface Interface {
readonly fetchBranch: (directory: string, branch: string) => Effect.Effect<Result, AppProcess.AppProcessError>
readonly checkout: (directory: string, branch: string) => Effect.Effect<Result, AppProcess.AppProcessError>
readonly reset: (directory: string, target: string) => Effect.Effect<Result, AppProcess.AppProcessError>
readonly worktreeCreate: (input: { repo: Repo; directory: AbsolutePath }) => Effect.Effect<void, WorktreeError>
readonly worktreeRemove: (input: { repo: Repo; directory: AbsolutePath }) => Effect.Effect<void, WorktreeError>
readonly worktreeList: (repo: Repo) => Effect.Effect<AbsolutePath[], WorktreeError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/GitV2") {}
@ -149,6 +159,43 @@ export const layer = Layer.effect(
execute(directory, proc)(["reset", "--hard", target]),
)
const worktree = Effect.fnUntraced(function* (
operation: "create" | "remove" | "list",
repo: Repo,
args: string[],
worktreeDirectory?: AbsolutePath,
cwd = repo.directory,
) {
const result = yield* proc
.run(ChildProcess.make("git", args, { cwd, extendEnv: true, stdin: "ignore" }))
.pipe(
Effect.mapError(
(cause) => new WorktreeError({ operation, directory: worktreeDirectory, message: cause.message, cause }),
),
)
if (result.exitCode === 0) return result.stdout.toString("utf8")
return yield* new WorktreeError({
operation,
directory: worktreeDirectory,
message: result.stderr.toString("utf8").trim() || result.stdout.toString("utf8").trim() || "Git failed",
})
})
const worktreeCreate = Effect.fn("Git.worktreeCreate")(function* (input: { repo: Repo; directory: AbsolutePath }) {
yield* worktree("create", input.repo, ["worktree", "add", "--detach", input.directory, "HEAD"], input.directory)
})
const worktreeRemove = Effect.fn("Git.worktreeRemove")(function* (input: { repo: Repo; directory: AbsolutePath }) {
yield* worktree("remove", input.repo, ["worktree", "remove", "--force", input.directory], input.directory, input.repo.store)
})
const worktreeList = Effect.fn("Git.worktreeList")(function* (repo: Repo) {
return (yield* worktree("list", repo, ["worktree", "list", "--porcelain"]))
.split("\n")
.filter((line) => line.startsWith("worktree "))
.map((line) => AbsolutePath.make(resolvePath(repo.directory, line.slice("worktree ".length).trim())))
})
return Service.of({
find,
remote,
@ -163,6 +210,9 @@ export const layer = Layer.effect(
fetchBranch,
checkout,
reset,
worktreeCreate,
worktreeRemove,
worktreeList,
})
}),
)

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 { FSUtil } from "./fs-util"
import { Database } from "./database/database"
import { Git } from "./git"
import { Hash } from "./util/hash"
import { ProjectDirectoryTable } from "./project/sql"
export const ID = Schema.String.pipe(
Schema.brand("Project.ID"),
@ -28,7 +31,16 @@ export class Info extends Schema.Class<Info>("Project.Info")({
id: ID,
}) {}
export const DirectoriesInput = Schema.Struct({
projectID: ID,
}).annotate({ identifier: "Project.DirectoriesInput" })
export type DirectoriesInput = typeof DirectoriesInput.Type
export const Directories = Schema.Array(AbsolutePath).annotate({ identifier: "Project.Directories" })
export type Directories = typeof Directories.Type
export interface Interface {
readonly directories: (input: DirectoriesInput) => Effect.Effect<Directories>
readonly resolve: (input: AbsolutePath) => Effect.Effect<
{
previous?: ID
@ -55,9 +67,22 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Pr
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const db = (yield* Database.Service).db
const fs = yield* FSUtil.Service
const git = yield* Git.Service
const directories = Effect.fn("Project.directories")(function* (input: DirectoriesInput) {
const rows = yield* db
.select({ directory: ProjectDirectoryTable.directory })
.from(ProjectDirectoryTable)
.where(eq(ProjectDirectoryTable.project_id, input.projectID))
.all()
.pipe(Effect.orDie)
return rows
.toSorted((a, b) => a.directory.localeCompare(b.directory))
.map((row) => AbsolutePath.make(row.directory))
})
const cached = Effect.fnUntraced(function* (dir: string) {
return yield* fs.readFileString(path.join(dir, "opencode")).pipe(
Effect.map((value) => value.trim()),
@ -109,7 +134,6 @@ export const layer = Layer.effect(
const previous = yield* cached(repo.store)
const id = (yield* remote(repo)) ?? previous ?? (yield* root(repo))
return {
previous,
id: id ?? ID.global,
@ -122,8 +146,12 @@ export const layer = Layer.effect(
yield* fs.writeFileString(path.join(input.store, "opencode"), input.id).pipe(Effect.ignore)
})
return Service.of({ resolve, commit })
return Service.of({ directories, resolve, commit })
}),
)
export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(Git.defaultLayer))
export const defaultLayer = layer.pipe(
Layer.provide(Database.defaultLayer),
Layer.provide(FSUtil.defaultLayer),
Layer.provide(Git.defaultLayer),
)

View File

@ -0,0 +1,38 @@
import path from "path"
import { Effect } from "effect"
import { AbsolutePath } from "../schema"
import { FSUtil } from "../fs-util"
import { Git } from "../git"
import { DirectoryUnavailableError, type Copy, type Strategy, type StrategyID } from "./copy"
export function makeStrategies(input: {
git: Git.Interface
fs: FSUtil.Interface
canonical: (directory: AbsolutePath) => Effect.Effect<AbsolutePath, DirectoryUnavailableError>
}) {
const repo = (sourceDirectory: AbsolutePath) => ({ directory: sourceDirectory, store: sourceDirectory }) satisfies Git.Repo
const gitWorktree: Strategy = {
id: "git_worktree",
create: Effect.fn("ProjectCopy.GitWorktree.create")(function* (options) {
yield* input.git.worktreeCreate({ repo: repo(options.sourceDirectory), directory: options.directory })
return { directory: yield* input.canonical(options.directory) }
}),
remove: Effect.fn("ProjectCopy.GitWorktree.remove")(function* (directory) {
const found = yield* input.git.find(directory)
if (!found) return yield* new DirectoryUnavailableError({ directory })
yield* input.git.worktreeRemove({ repo: found, directory })
}),
list: Effect.fn("ProjectCopy.GitWorktree.list")(function* (directory) {
const entries = yield* input.git.worktreeList(repo(directory))
return yield* Effect.forEach(entries, (entry) =>
entry === directory ? Effect.succeed(undefined) : input.canonical(entry).pipe(Effect.map((directory) => ({ directory }))),
).pipe(Effect.map((items) => items.filter((item): item is Copy => item !== undefined)))
}),
detect: Effect.fn("ProjectCopy.GitWorktree.detect")(function* (inputDirectory) {
return yield* input.fs.isFile(path.join(inputDirectory, ".git"))
}),
}
return new Map<StrategyID, Strategy>([[gitWorktree.id, gitWorktree]])
}

View File

@ -0,0 +1,241 @@
export * as ProjectCopy from "./copy"
import { and, eq, inArray } from "drizzle-orm"
import { Context, Effect, Layer, Schema } from "effect"
import { AbsolutePath } from "../schema"
import { FSUtil } from "../fs-util"
import { Git } from "../git"
import { Database } from "../database/database"
import { EventV2 } from "../event"
import { Project } from "../project"
import { ProjectDirectoryTable } from "./sql"
import { makeStrategies } from "./copy-strategies"
export const StrategyID = Schema.Literal("git_worktree")
export type StrategyID = typeof StrategyID.Type
export const DetectInput = Schema.Struct({
directory: AbsolutePath,
}).annotate({ identifier: "ProjectCopy.DetectInput" })
export type DetectInput = typeof DetectInput.Type
export const CreateInput = Schema.Struct({
projectID: Project.ID,
strategy: StrategyID,
sourceDirectory: AbsolutePath,
directory: AbsolutePath,
}).annotate({ identifier: "ProjectCopy.CreateInput" })
export type CreateInput = typeof CreateInput.Type
export const RemoveInput = Schema.Struct({
projectID: Project.ID,
directory: AbsolutePath,
}).annotate({ identifier: "ProjectCopy.RemoveInput" })
export type RemoveInput = typeof RemoveInput.Type
export const RefreshInput = Schema.Struct({
projectID: Project.ID,
}).annotate({ identifier: "ProjectCopy.RefreshInput" })
export type RefreshInput = typeof RefreshInput.Type
export const Copy = Schema.Struct({
directory: AbsolutePath,
}).annotate({ identifier: "ProjectCopy.Copy" })
export type Copy = typeof Copy.Type
export type DirectoryType = "main" | "root" | StrategyID
export class SourceDirectoryNotFoundError extends Schema.TaggedErrorClass<SourceDirectoryNotFoundError>()(
"ProjectCopy.SourceDirectoryNotFoundError",
{ directory: AbsolutePath },
) {}
export class DestinationExistsError extends Schema.TaggedErrorClass<DestinationExistsError>()(
"ProjectCopy.DestinationExistsError",
{ directory: AbsolutePath },
) {}
export class DirectoryUnavailableError extends Schema.TaggedErrorClass<DirectoryUnavailableError>()(
"ProjectCopy.DirectoryUnavailableError",
{ directory: AbsolutePath },
) {}
export class StrategyNotFoundError extends Schema.TaggedErrorClass<StrategyNotFoundError>()(
"ProjectCopy.StrategyNotFoundError",
{ directory: AbsolutePath },
) {}
export type Error =
| SourceDirectoryNotFoundError
| DestinationExistsError
| DirectoryUnavailableError
| StrategyNotFoundError
| Git.WorktreeError
export interface Strategy {
readonly id: StrategyID
readonly create: (input: {
sourceDirectory: AbsolutePath
directory: AbsolutePath
}) => Effect.Effect<Copy, Git.WorktreeError | DirectoryUnavailableError>
readonly remove: (directory: AbsolutePath) => Effect.Effect<void, Git.WorktreeError | DirectoryUnavailableError>
readonly list: (directory: AbsolutePath) => Effect.Effect<Copy[], Git.WorktreeError | DirectoryUnavailableError>
readonly detect: (directory: AbsolutePath) => Effect.Effect<boolean>
}
export const Event = {
Updated: EventV2.define({
type: "project.directories.updated",
schema: { projectID: Project.ID },
}),
}
export interface Interface {
readonly detect: (input: DetectInput) => Effect.Effect<StrategyID | undefined>
readonly create: (input: CreateInput) => Effect.Effect<Copy, Error>
readonly remove: (input: RemoveInput) => Effect.Effect<void, Error>
readonly refresh: (input: RefreshInput) => Effect.Effect<void, Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ProjectCopy") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const git = yield* Git.Service
const events = yield* EventV2.Service
const db = (yield* Database.Service).db
const canonical = Effect.fnUntraced(function* (input: AbsolutePath) {
const resolved = AbsolutePath.make(FSUtil.resolve(input))
if (!(yield* fs.isDir(resolved))) return yield* new DirectoryUnavailableError({ directory: input })
return resolved
})
const registry = makeStrategies({ git, fs, canonical })
const source = Effect.fnUntraced(function* (input: AbsolutePath, projectID: Project.ID) {
const sourceDirectory = yield* canonical(input)
const row = yield* db
.select({ directory: ProjectDirectoryTable.directory })
.from(ProjectDirectoryTable)
.where(and(eq(ProjectDirectoryTable.project_id, projectID), eq(ProjectDirectoryTable.directory, sourceDirectory)))
.get()
.pipe(Effect.orDie)
if (!row) return yield* new SourceDirectoryNotFoundError({ directory: sourceDirectory })
return sourceDirectory
})
const insert = Effect.fnUntraced(function* (projectID: Project.ID, copyDirectory: AbsolutePath, type: StrategyID) {
return yield* db
.transaction(
(tx) =>
Effect.gen(function* () {
const row = yield* tx
.select({ directory: ProjectDirectoryTable.directory })
.from(ProjectDirectoryTable)
.where(and(eq(ProjectDirectoryTable.project_id, projectID), eq(ProjectDirectoryTable.directory, copyDirectory)))
.get()
if (row) return false
yield* tx.insert(ProjectDirectoryTable).values({ project_id: projectID, directory: copyDirectory, type }).run()
return true
}),
{ behavior: "immediate" },
)
.pipe(Effect.orDie)
})
const removeStored = Effect.fnUntraced(function* (projectID: Project.ID, copyDirectory: AbsolutePath) {
return (
(yield* db
.delete(ProjectDirectoryTable)
.where(and(eq(ProjectDirectoryTable.project_id, projectID), eq(ProjectDirectoryTable.directory, copyDirectory)))
.returning({ directory: ProjectDirectoryTable.directory })
.get()
.pipe(Effect.orDie)) !== undefined
)
})
const changed = Effect.fnUntraced(function* (projectID: Project.ID, update: boolean) {
if (update) yield* events.publish(Event.Updated, { projectID })
})
const strategy = (id: StrategyID) => registry.get(id) as Strategy
const detect = Effect.fn("ProjectCopy.detect")(function* (input: DetectInput) {
for (const strategy of registry.values()) {
if (yield* strategy.detect(input.directory)) return strategy.id
}
return undefined
})
const create = Effect.fn("ProjectCopy.create")(function* (input: CreateInput) {
if (yield* fs.existsSafe(input.directory)) return yield* new DestinationExistsError({ directory: input.directory })
const result = yield* strategy(input.strategy).create({
directory: input.directory,
sourceDirectory: yield* source(input.sourceDirectory, input.projectID),
})
yield* changed(input.projectID, yield* insert(input.projectID, result.directory, input.strategy))
return result
})
const remove = Effect.fn("ProjectCopy.remove")(function* (input: RemoveInput) {
const copyDirectory = yield* canonical(input.directory)
const id = yield* detect({ directory: copyDirectory })
if (!id) return yield* new StrategyNotFoundError({ directory: copyDirectory })
yield* strategy(id).remove(copyDirectory)
yield* changed(input.projectID, yield* removeStored(input.projectID, copyDirectory))
})
const refresh = Effect.fn("ProjectCopy.refresh")(function* (input: RefreshInput) {
const roots = yield* db
.select({ directory: ProjectDirectoryTable.directory })
.from(ProjectDirectoryTable)
.where(and(eq(ProjectDirectoryTable.project_id, input.projectID), inArray(ProjectDirectoryTable.type, ["main", "root"])))
.all()
.pipe(Effect.orDie)
const sourceDirectories = yield* Effect.forEach(roots, (item) => canonical(AbsolutePath.make(item.directory)), {
concurrency: "unbounded",
})
const discovered = yield* Effect.forEach(
sourceDirectories,
(sourceDirectory) =>
Effect.forEach(registry.values(), (strategy) =>
strategy
.list(sourceDirectory)
.pipe(Effect.map((items) => items.map((item) => ({ ...item, type: strategy.id })))),
),
{ concurrency: "unbounded" },
).pipe(Effect.map((sets) => new Map(sets.flat(2).map((item) => [item.directory, item] as const)).values().toArray()))
const stored = yield* db
.select({ directory: ProjectDirectoryTable.directory })
.from(ProjectDirectoryTable)
.where(eq(ProjectDirectoryTable.project_id, input.projectID))
.all()
.pipe(Effect.orDie)
const inserted = yield* Effect.forEach(discovered, (item) => insert(input.projectID, item.directory, item.type)).pipe(
Effect.map((items) => items.some(Boolean)),
)
const removed = yield* Effect.forEach(stored, (item) =>
fs
.isDir(item.directory)
.pipe(
Effect.flatMap((exists) =>
exists ? Effect.succeed(false) : removeStored(input.projectID, AbsolutePath.make(item.directory)),
),
),
).pipe(Effect.map((items) => items.some(Boolean)))
yield* changed(input.projectID, inserted || removed)
})
return Service.of({ detect, create, remove, refresh })
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(Database.defaultLayer),
Layer.provide(FSUtil.defaultLayer),
Layer.provide(Git.defaultLayer),
Layer.provide(EventV2.defaultLayer),
)

View File

@ -1,4 +1,4 @@
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"
import { sqliteTable, text, integer, primaryKey } from "drizzle-orm/sqlite-core"
import * as DatabasePath from "../database/path"
import { Timestamps } from "../database/schema.sql"
import { ProjectV2 } from "../project"
@ -16,3 +16,19 @@ export const ProjectTable = sqliteTable("project", {
sandboxes: DatabasePath.absoluteArrayColumn().notNull(),
commands: text({ mode: "json" }).$type<{ start?: string }>(),
})
export const ProjectDirectoryTable = sqliteTable(
"project_directory",
{
project_id: text()
.$type<ProjectV2.ID>()
.notNull()
.references(() => ProjectTable.id, { onDelete: "cascade" }),
directory: text().notNull(),
type: text().$type<"main" | "root" | "git_worktree">().notNull(),
time_created: integer()
.notNull()
.$default(() => Date.now()),
},
(table) => [primaryKey({ columns: [table.project_id, table.directory] })],
)

View File

@ -43,7 +43,7 @@ describe("DatabaseMigration", () => {
expect(yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session'`)).toEqual({
name: "session",
})
expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: 24 })
expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: 25 })
}),
)
})

View File

@ -1,8 +1,10 @@
import { describe, expect } from "bun:test"
import { $ } from "bun"
import fs from "fs/promises"
import path from "path"
import { Effect } from "effect"
import { Git } from "@opencode-ai/core/git"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { branch, commit, gitRemote } from "./fixture/git"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
@ -64,3 +66,41 @@ function withRemote<A, E, R>(body: (fixture: Awaited<ReturnType<typeof gitRemote
function read(file: string) {
return Effect.promise(() => fs.readFile(file, "utf8")).pipe(Effect.map((content) => content.replace(/\r\n/g, "\n")))
}
async function initRepo(directory: string) {
await $`git init`.cwd(directory).quiet()
await $`git config core.fsmonitor false`.cwd(directory).quiet()
await $`git config commit.gpgsign false`.cwd(directory).quiet()
await $`git config user.email test@opencode.test`.cwd(directory).quiet()
await $`git config user.name Test`.cwd(directory).quiet()
await $`git commit --allow-empty -m root`.cwd(directory).quiet()
}
describe("Git worktrees", () => {
it.live("creates, lists, and removes linked worktrees", () =>
Effect.gen(function* () {
const root = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
)
yield* Effect.promise(() => initRepo(root.path))
const directory = AbsolutePath.make(yield* Effect.promise(() => fs.realpath(root.path)))
const worktree = AbsolutePath.make(`${root.path}-git-worktree`)
yield* Effect.addFinalizer(() =>
Effect.promise(() => fs.rm(worktree, { recursive: true, force: true })).pipe(Effect.ignore),
)
const git = yield* Git.Service
const repo = { directory, store: AbsolutePath.make(path.join(directory, ".git")) }
yield* git.worktreeCreate({ repo, directory: worktree })
expect((yield* git.worktreeList(repo)).some((entry) => entry.endsWith("-git-worktree"))).toBe(true)
const linked = yield* git.find(worktree)
expect(linked?.directory).toBe(AbsolutePath.make(yield* Effect.promise(() => fs.realpath(worktree))))
expect(linked?.store).toBe(repo.store)
if (!linked) throw new Error("Linked worktree not found")
yield* git.worktreeRemove({ repo: linked, directory: worktree })
expect((yield* git.worktreeList(repo)).some((entry) => entry.endsWith("-git-worktree"))).toBe(false)
}),
)
})

View File

@ -9,6 +9,7 @@ const ref = { directory: AbsolutePath.make("/repo/packages/app"), workspaceID: "
const projectLayer = Layer.succeed(
Project.Service,
Project.Service.of({
directories: () => Effect.succeed([]),
resolve: () =>
Effect.succeed({
id: Project.ID.make("project"),

View File

@ -0,0 +1,191 @@
import { describe, expect } from "bun:test"
import { $ } from "bun"
import fs from "fs/promises"
import path from "path"
import { eq } from "drizzle-orm"
import { Effect, Fiber, Layer, Stream } from "effect"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Git } from "@opencode-ai/core/git"
import { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event"
import { Project } from "@opencode-ai/core/project"
import { ProjectDirectoryTable, ProjectTable } from "@opencode-ai/core/project/sql"
import { ProjectCopy } from "@opencode-ai/core/project/copy"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const databaseLayer = Database.layerFromPath(":memory:")
const eventLayer = EventV2.layer.pipe(Layer.provide(databaseLayer))
const copyLayer = ProjectCopy.layer.pipe(
Layer.provide(databaseLayer),
Layer.provide(eventLayer),
Layer.provide(FSUtil.defaultLayer),
Layer.provide(Git.defaultLayer),
)
const it = testEffect(Layer.mergeAll(copyLayer, databaseLayer, eventLayer))
function abs(input: string) {
return AbsolutePath.make(input)
}
async function initRepo(directory: string) {
await $`git init`.cwd(directory).quiet()
await $`git config core.fsmonitor false`.cwd(directory).quiet()
await $`git config commit.gpgsign false`.cwd(directory).quiet()
await $`git config user.email test@opencode.test`.cwd(directory).quiet()
await $`git config user.name Test`.cwd(directory).quiet()
await $`git commit --allow-empty -m root`.cwd(directory).quiet()
}
function setup() {
return Effect.gen(function* () {
const root = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
)
yield* Effect.promise(() => initRepo(root.path))
const sourceDirectory = abs(yield* Effect.promise(() => fs.realpath(root.path)))
const projectID = Project.ID.make("copy-project")
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: projectID, worktree: sourceDirectory, sandboxes: [], time_created: 1, time_updated: 1 })
.run()
.pipe(Effect.orDie)
yield* db
.insert(ProjectDirectoryTable)
.values({ project_id: projectID, directory: sourceDirectory, type: "main" })
.run()
.pipe(Effect.orDie)
return { root, sourceDirectory, projectID, db }
})
}
function stored(projectID: Project.ID) {
return Database.Service.use(({ db }) =>
db
.select({ directory: ProjectDirectoryTable.directory, type: ProjectDirectoryTable.type })
.from(ProjectDirectoryTable)
.where(eq(ProjectDirectoryTable.project_id, projectID))
.all()
.pipe(
Effect.orDie,
Effect.map((rows) => rows.toSorted((a, b) => a.directory.localeCompare(b.directory))),
),
)
}
describe("ProjectCopy", () => {
it.live("detects linked git worktrees but not root checkouts", () =>
Effect.gen(function* () {
const input = yield* setup()
const copy = yield* ProjectCopy.Service
const target = abs(`${input.root.path}-copy-detected`)
yield* Effect.addFinalizer(() =>
Effect.promise(() => fs.rm(target, { recursive: true, force: true })).pipe(Effect.ignore),
)
yield* Effect.promise(() => $`git worktree add --detach ${target} HEAD`.cwd(input.root.path).quiet())
expect(yield* copy.detect({ directory: input.sourceDirectory })).toBeUndefined()
expect(yield* copy.detect({ directory: target })).toBe("git_worktree")
}),
)
it.live("creates and removes a git worktree directory", () =>
Effect.gen(function* () {
const input = yield* setup()
const copy = yield* ProjectCopy.Service
const events = yield* EventV2.Service
const target = abs(`${input.root.path}-copy-created`)
yield* Effect.addFinalizer(() =>
Effect.promise(() => fs.rm(target, { recursive: true, force: true })).pipe(Effect.ignore),
)
const fiber = yield* events
.subscribe(ProjectCopy.Event.Updated)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
const created = yield* copy.create({
projectID: input.projectID,
strategy: "git_worktree",
sourceDirectory: input.sourceDirectory,
directory: target,
})
expect(yield* stored(input.projectID)).toEqual(
[
{ directory: input.sourceDirectory, type: "main" as const },
{ directory: created.directory, type: "git_worktree" as const },
].toSorted((a, b) => a.directory.localeCompare(b.directory)),
)
expect(Array.from(yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID })
yield* copy.remove({ projectID: input.projectID, directory: created.directory })
expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, type: "main" as const }])
expect(yield* Effect.promise(() => Bun.file(target).exists())).toBe(false)
}),
)
it.live("does not publish an event when refresh finds no directory changes", () =>
Effect.gen(function* () {
const input = yield* setup()
const copy = yield* ProjectCopy.Service
const events = yield* EventV2.Service
const event = yield* events.subscribe(ProjectCopy.Event.Updated).pipe(
Stream.take(1),
Stream.runCollect,
Effect.forkScoped,
Effect.flatMap((fiber) =>
Effect.gen(function* () {
yield* Effect.yieldNow
yield* copy.refresh({ projectID: input.projectID })
return yield* Fiber.join(fiber).pipe(Effect.timeoutOption("50 millis"))
}),
),
)
expect(event._tag).toBe("None")
}),
)
it.live("refresh discovers and prunes an externally managed git worktree", () =>
Effect.gen(function* () {
const input = yield* setup()
const copy = yield* ProjectCopy.Service
const events = yield* EventV2.Service
const target = abs(`${input.root.path}-copy-external`)
yield* Effect.addFinalizer(() =>
Effect.promise(() => fs.rm(target, { recursive: true, force: true })).pipe(Effect.ignore),
)
yield* Effect.promise(() => $`git worktree add --detach ${target} HEAD`.cwd(input.root.path).quiet())
const fiber = yield* events
.subscribe(ProjectCopy.Event.Updated)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
yield* copy.refresh({ projectID: input.projectID })
const discovered = abs(yield* Effect.promise(() => fs.realpath(target)))
expect(yield* stored(input.projectID)).toEqual(
[
{ directory: input.sourceDirectory, type: "main" as const },
{ directory: discovered, type: "git_worktree" as const },
].toSorted((a, b) => a.directory.localeCompare(b.directory)),
)
expect(Array.from(yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID })
yield* Effect.promise(() => $`git worktree remove --force ${target}`.cwd(input.root.path).quiet())
yield* copy.refresh({ projectID: input.projectID })
expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, type: "main" as const }])
}),
)
it.live("refresh with no roots is a no-op", () =>
Effect.gen(function* () {
const copy = yield* ProjectCopy.Service
yield* copy.refresh({ projectID: Project.ID.make("missing-project") })
}),
)
})

View File

@ -2,14 +2,28 @@ import { describe, expect } from "bun:test"
import { $ } from "bun"
import fs from "fs/promises"
import path from "path"
import { Effect } from "effect"
import { Effect, Layer, Schema } from "effect"
import { ProjectV2 } from "@opencode-ai/core/project"
import { ProjectDirectoryTable, ProjectTable } from "@opencode-ai/core/project/sql"
import { Database } from "@opencode-ai/core/database/database"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Git } from "@opencode-ai/core/git"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Hash } from "@opencode-ai/core/util/hash"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const it = testEffect(ProjectV2.defaultLayer)
const databaseLayer = Database.layerFromPath(":memory:")
const it = testEffect(
Layer.mergeAll(
ProjectV2.layer.pipe(
Layer.provide(databaseLayer),
Layer.provide(FSUtil.defaultLayer),
Layer.provide(Git.defaultLayer),
),
databaseLayer,
),
)
function remoteID(remote: string) {
return ProjectV2.ID.make(Hash.fast(`git-remote:${remote}`))
@ -37,6 +51,50 @@ async function rootCommit(dir: string) {
return (await $`git rev-list --max-parents=0 HEAD`.cwd(dir).text()).trim()
}
describe("Project directories schemas", () => {
it.effect("decodes project directory input and inline directory results", () =>
Effect.sync(() => {
expect(Schema.decodeUnknownSync(ProjectV2.DirectoriesInput)({ projectID: ProjectV2.ID.make("project") })).toEqual({
projectID: ProjectV2.ID.make("project"),
})
expect(Schema.decodeUnknownSync(ProjectV2.Directories)([AbsolutePath.make("/tmp/project")])).toEqual([
AbsolutePath.make("/tmp/project"),
])
}),
)
it.effect("lists stored project directories only for the requested project", () =>
Effect.gen(function* () {
const project = yield* ProjectV2.Service
const { db } = yield* Database.Service
const projectID = ProjectV2.ID.make("directories-project")
const otherID = ProjectV2.ID.make("directories-other")
yield* db
.insert(ProjectTable)
.values([
{ id: projectID, worktree: AbsolutePath.make("/repo"), sandboxes: [], time_created: 1, time_updated: 1 },
{ id: otherID, worktree: AbsolutePath.make("/other"), sandboxes: [], time_created: 1, time_updated: 1 },
])
.run()
.pipe(Effect.orDie)
yield* db
.insert(ProjectDirectoryTable)
.values([
{ project_id: projectID, directory: AbsolutePath.make("/repo/z"), type: "root" },
{ project_id: projectID, directory: AbsolutePath.make("/repo/a"), type: "main" },
{ project_id: otherID, directory: AbsolutePath.make("/other"), type: "main" },
])
.run()
.pipe(Effect.orDie)
expect(yield* project.directories({ projectID })).toEqual([
AbsolutePath.make("/repo/a"),
AbsolutePath.make("/repo/z"),
])
}),
)
})
describe("ProjectV2.resolve", () => {
it.live("returns global for non-git directory", () =>
Effect.gen(function* () {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View 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 },
)
})

View File

@ -34,6 +34,12 @@ import type {
ExperimentalConsoleListOrgsErrors,
ExperimentalConsoleListOrgsResponses,
ExperimentalConsoleSwitchOrgResponses,
ExperimentalProjectCopyCreateErrors,
ExperimentalProjectCopyCreateResponses,
ExperimentalProjectCopyRefreshErrors,
ExperimentalProjectCopyRefreshResponses,
ExperimentalProjectCopyRemoveErrors,
ExperimentalProjectCopyRemoveResponses,
ExperimentalResourceListErrors,
ExperimentalResourceListResponses,
ExperimentalSessionListErrors,
@ -120,6 +126,8 @@ import type {
PermissionV2Reply,
ProjectCurrentErrors,
ProjectCurrentResponses,
ProjectDirectoriesErrors,
ProjectDirectoriesResponses,
ProjectInitGitErrors,
ProjectInitGitResponses,
ProjectListErrors,
@ -937,6 +945,148 @@ export class Resource extends HeyApiClient {
}
}
export class ProjectCopy extends HeyApiClient {
/**
* Remove project copy
*
* Remove a local physical copy of a project using the selected strategy.
*/
public remove<ThrowOnError extends boolean = false>(
parameters: {
projectID: string
query_directory?: string
workspace?: string
body_directory?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "path", key: "projectID" },
{
in: "query",
key: "query_directory",
map: "directory",
},
{ in: "query", key: "workspace" },
{
in: "body",
key: "body_directory",
map: "directory",
},
],
},
],
)
return (options?.client ?? this.client).delete<
ExperimentalProjectCopyRemoveResponses,
ExperimentalProjectCopyRemoveErrors,
ThrowOnError
>({
url: "/experimental/project/{projectID}/copy",
...options,
...params,
headers: {
"Content-Type": "application/json",
...options?.headers,
...params.headers,
},
})
}
/**
* Create project copy
*
* Create a local physical copy of a project using the selected strategy.
*/
public create<ThrowOnError extends boolean = false>(
parameters: {
projectID: string
query_directory?: string
workspace?: string
strategy?: "git_worktree"
body_directory?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "path", key: "projectID" },
{
in: "query",
key: "query_directory",
map: "directory",
},
{ in: "query", key: "workspace" },
{ in: "body", key: "strategy" },
{
in: "body",
key: "body_directory",
map: "directory",
},
],
},
],
)
return (options?.client ?? this.client).post<
ExperimentalProjectCopyCreateResponses,
ExperimentalProjectCopyCreateErrors,
ThrowOnError
>({
url: "/experimental/project/{projectID}/copy",
...options,
...params,
headers: {
"Content-Type": "application/json",
...options?.headers,
...params.headers,
},
})
}
/**
* Refresh project copies
*
* Discover local project copies using one or all configured strategies.
*/
public refresh<ThrowOnError extends boolean = false>(
parameters: {
projectID: string
directory?: string
workspace?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "path", key: "projectID" },
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
],
},
],
)
return (options?.client ?? this.client).post<
ExperimentalProjectCopyRefreshResponses,
ExperimentalProjectCopyRefreshErrors,
ThrowOnError
>({
url: "/experimental/project/{projectID}/copy/refresh",
...options,
...params,
})
}
}
export class Adapter extends HeyApiClient {
/**
* List workspace adapters
@ -1226,6 +1376,11 @@ export class Experimental extends HeyApiClient {
return (this._resource ??= new Resource({ client: this.client }))
}
private _projectCopy?: ProjectCopy
get projectCopy(): ProjectCopy {
return (this._projectCopy ??= new ProjectCopy({ client: this.client }))
}
private _workspace?: Workspace
get workspace(): Workspace {
return (this._workspace ??= new Workspace({ client: this.client }))
@ -2388,6 +2543,38 @@ export class Project extends HeyApiClient {
},
})
}
/**
* List project directories
*
* List known local absolute directories for a project.
*/
public directories<ThrowOnError extends boolean = false>(
parameters: {
projectID: string
directory?: string
workspace?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "path", key: "projectID" },
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
],
},
],
)
return (options?.client ?? this.client).get<ProjectDirectoriesResponses, ProjectDirectoriesErrors, ThrowOnError>({
url: "/project/{projectID}/directories",
...options,
...params,
})
}
}
export class Pty extends HeyApiClient {

View File

@ -63,6 +63,7 @@ export type Event =
| EventMcpToolsChanged
| EventMcpBrowserOpenFailed
| EventCommandExecuted
| EventProjectDirectoriesUpdated
| EventProjectUpdated
| EventPtyCreated
| EventPtyUpdated
@ -1300,6 +1301,13 @@ export type GlobalEvent = {
messageID: string
}
}
| {
id: string
type: "project.directories.updated"
properties: {
projectID: string
}
}
| {
id: string
type: "project.updated"
@ -3366,6 +3374,12 @@ export type ConfigV2ExperimentalPolicy = {
resource: string
}
export type ProjectDirectories = Array<string>
export type ProjectCopyCopy = {
directory: string
}
export type LocationRef = {
directory: string
workspaceID?: string
@ -4406,6 +4420,14 @@ export type EventCommandExecuted = {
}
}
export type EventProjectDirectoriesUpdated = {
id: string
type: "project.directories.updated"
properties: {
projectID: string
}
}
export type EventProjectUpdated = {
id: string
type: "project.updated"
@ -6254,6 +6276,137 @@ export type ProjectUpdateResponses = {
export type ProjectUpdateResponse = ProjectUpdateResponses[keyof ProjectUpdateResponses]
export type ProjectDirectoriesData = {
body?: never
path: {
projectID: string
}
query?: {
directory?: string
workspace?: string
}
url: "/project/{projectID}/directories"
}
export type ProjectDirectoriesErrors = {
/**
* Bad request
*/
400: BadRequestError
}
export type ProjectDirectoriesError = ProjectDirectoriesErrors[keyof ProjectDirectoriesErrors]
export type ProjectDirectoriesResponses = {
/**
* Project directories
*/
200: ProjectDirectories
}
export type ProjectDirectoriesResponse = ProjectDirectoriesResponses[keyof ProjectDirectoriesResponses]
export type ExperimentalProjectCopyRemoveData = {
body?: {
directory: string
}
path: {
projectID: string
}
query?: {
directory?: string
workspace?: string
}
url: "/experimental/project/{projectID}/copy"
}
export type ExperimentalProjectCopyRemoveErrors = {
/**
* BadRequest | InvalidRequestError
*/
400: EffectHttpApiErrorBadRequest | InvalidRequestError
}
export type ExperimentalProjectCopyRemoveError =
ExperimentalProjectCopyRemoveErrors[keyof ExperimentalProjectCopyRemoveErrors]
export type ExperimentalProjectCopyRemoveResponses = {
/**
* Project copy removed
*/
204: void
}
export type ExperimentalProjectCopyRemoveResponse =
ExperimentalProjectCopyRemoveResponses[keyof ExperimentalProjectCopyRemoveResponses]
export type ExperimentalProjectCopyCreateData = {
body?: {
strategy: "git_worktree"
directory: string
}
path: {
projectID: string
}
query?: {
directory?: string
workspace?: string
}
url: "/experimental/project/{projectID}/copy"
}
export type ExperimentalProjectCopyCreateErrors = {
/**
* BadRequest | InvalidRequestError
*/
400: EffectHttpApiErrorBadRequest | InvalidRequestError
}
export type ExperimentalProjectCopyCreateError =
ExperimentalProjectCopyCreateErrors[keyof ExperimentalProjectCopyCreateErrors]
export type ExperimentalProjectCopyCreateResponses = {
/**
* Project copy created
*/
200: ProjectCopyCopy
}
export type ExperimentalProjectCopyCreateResponse =
ExperimentalProjectCopyCreateResponses[keyof ExperimentalProjectCopyCreateResponses]
export type ExperimentalProjectCopyRefreshData = {
body?: never
path: {
projectID: string
}
query?: {
directory?: string
workspace?: string
}
url: "/experimental/project/{projectID}/copy/refresh"
}
export type ExperimentalProjectCopyRefreshErrors = {
/**
* BadRequest | InvalidRequestError
*/
400: EffectHttpApiErrorBadRequest | InvalidRequestError
}
export type ExperimentalProjectCopyRefreshError =
ExperimentalProjectCopyRefreshErrors[keyof ExperimentalProjectCopyRefreshErrors]
export type ExperimentalProjectCopyRefreshResponses = {
/**
* Project copies refreshed
*/
204: void
}
export type ExperimentalProjectCopyRefreshResponse =
ExperimentalProjectCopyRefreshResponses[keyof ExperimentalProjectCopyRefreshResponses]
export type PtyShellsData = {
body?: never
path?: never

View File

@ -3699,6 +3699,295 @@
]
}
},
"/project/{projectID}/directories": {
"get": {
"tags": ["project"],
"operationId": "project.directories",
"parameters": [
{
"name": "projectID",
"in": "path",
"schema": {
"type": "string"
},
"required": true
},
{
"name": "directory",
"in": "query",
"schema": {
"type": "string"
},
"required": false
},
{
"name": "workspace",
"in": "query",
"schema": {
"type": "string"
},
"required": false
}
],
"responses": {
"200": {
"description": "Project directories",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProjectDirectories"
}
}
}
},
"400": {
"description": "Bad request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BadRequestError"
}
}
}
}
},
"description": "List known local absolute directories for a project.",
"summary": "List project directories",
"x-codeSamples": [
{
"lang": "js",
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.project.directories({\n ...\n})"
}
]
}
},
"/experimental/project/{projectID}/copy": {
"post": {
"tags": ["projectCopy"],
"operationId": "experimental.projectCopy.create",
"parameters": [
{
"name": "projectID",
"in": "path",
"schema": {
"type": "string"
},
"required": true
},
{
"name": "directory",
"in": "query",
"schema": {
"type": "string"
},
"required": false
},
{
"name": "workspace",
"in": "query",
"schema": {
"type": "string"
},
"required": false
}
],
"responses": {
"200": {
"description": "Project copy created",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProjectCopyCopy"
}
}
}
},
"400": {
"description": "BadRequest | InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/effect_HttpApiError_BadRequest"
},
{
"$ref": "#/components/schemas/InvalidRequestError"
}
]
}
}
}
}
},
"description": "Create a local physical copy of a project using the selected strategy.",
"summary": "Create project copy",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"strategy": {
"type": "string",
"enum": ["git_worktree"]
},
"directory": {
"type": "string"
}
},
"required": ["strategy", "directory"],
"additionalProperties": false
}
}
}
},
"x-codeSamples": [
{
"lang": "js",
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.projectCopy.create({\n ...\n})"
}
]
},
"delete": {
"tags": ["projectCopy"],
"operationId": "experimental.projectCopy.remove",
"parameters": [
{
"name": "projectID",
"in": "path",
"schema": {
"type": "string"
},
"required": true
},
{
"name": "directory",
"in": "query",
"schema": {
"type": "string"
},
"required": false
},
{
"name": "workspace",
"in": "query",
"schema": {
"type": "string"
},
"required": false
}
],
"responses": {
"204": {
"description": "Project copy removed"
},
"400": {
"description": "BadRequest | InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/effect_HttpApiError_BadRequest"
},
{
"$ref": "#/components/schemas/InvalidRequestError"
}
]
}
}
}
}
},
"description": "Remove a local physical copy of a project using the selected strategy.",
"summary": "Remove project copy",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"directory": {
"type": "string"
}
},
"required": ["directory"],
"additionalProperties": false
}
}
}
},
"x-codeSamples": [
{
"lang": "js",
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.projectCopy.remove({\n ...\n})"
}
]
}
},
"/experimental/project/{projectID}/copy/refresh": {
"post": {
"tags": ["projectCopy"],
"operationId": "experimental.projectCopy.refresh",
"parameters": [
{
"name": "projectID",
"in": "path",
"schema": {
"type": "string"
},
"required": true
},
{
"name": "directory",
"in": "query",
"schema": {
"type": "string"
},
"required": false
},
{
"name": "workspace",
"in": "query",
"schema": {
"type": "string"
},
"required": false
}
],
"responses": {
"204": {
"description": "Project copies refreshed"
},
"400": {
"description": "BadRequest | InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/effect_HttpApiError_BadRequest"
},
{
"$ref": "#/components/schemas/InvalidRequestError"
}
]
}
}
}
}
},
"description": "Discover local project copies using one or all configured strategies.",
"summary": "Refresh project copies",
"x-codeSamples": [
{
"lang": "js",
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.projectCopy.refresh({\n ...\n})"
}
]
}
},
"/pty/shells": {
"get": {
"tags": ["pty"],
@ -11173,6 +11462,9 @@
{
"$ref": "#/components/schemas/EventCommandExecuted"
},
{
"$ref": "#/components/schemas/EventProjectDirectoriesUpdated"
},
{
"$ref": "#/components/schemas/EventProjectUpdated"
},
@ -15039,6 +15331,30 @@
"required": ["id", "type", "properties"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string",
"enum": ["project.directories.updated"]
},
"properties": {
"type": "object",
"properties": {
"projectID": {
"type": "string"
}
},
"required": ["projectID"],
"additionalProperties": false
}
},
"required": ["id", "type", "properties"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
@ -21149,6 +21465,22 @@
"required": ["action", "effect", "resource"],
"additionalProperties": false
},
"ProjectDirectories": {
"type": "array",
"items": {
"type": "string"
}
},
"ProjectCopyCopy": {
"type": "object",
"properties": {
"directory": {
"type": "string"
}
},
"required": ["directory"],
"additionalProperties": false
},
"LocationRef": {
"type": "object",
"properties": {
@ -24299,6 +24631,30 @@
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"EventProjectDirectoriesUpdated": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string",
"enum": ["project.directories.updated"]
},
"properties": {
"type": "object",
"properties": {
"projectID": {
"type": "string"
}
},
"required": ["projectID"],
"additionalProperties": false
}
},
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"EventProjectUpdated": {
"type": "object",
"properties": {
@ -24947,6 +25303,10 @@
"name": "project",
"description": "Experimental HttpApi project routes."
},
{
"name": "projectCopy",
"description": "Project copy management routes."
},
{
"name": "pty",
"description": "Experimental HttpApi PTY routes."